This commit is contained in:
Morten Olsen
2025-09-09 18:06:45 +02:00
parent ba7aa90434
commit 0ff0b0992b
25 changed files with 3177 additions and 198 deletions

View File

@@ -28,6 +28,7 @@
"name": "@morten-olsen/fluxcurrent-core",
"version": "1.0.0",
"dependencies": {
"eventemitter3": "^5.0.1",
"knex": "^3.1.0",
"knex-pglite": "^0.12.0",
"pg": "^8.16.3",

View File

@@ -25,6 +25,17 @@ class DatabaseService {
}
return this.#dbPromise;
};
public init = async () => {
await this.getDb();
};
public close = async () => {
if (this.#dbPromise) {
await this.#dbPromise;
this.#dbPromise = undefined;
}
};
}
export { tableNames, type TableRow } from './migrations/migrations.ts';

View File

@@ -0,0 +1,414 @@
import { describe, it, expect } from 'vitest';
import { parseDSL } from './documents.dsl.ts';
describe('DSL Parser', () => {
describe('Basic field filtering', () => {
it('should parse simple URI equality', () => {
const result = parseDSL('uri = "test-doc"');
expect(result).toEqual({
uris: ['test-doc'],
});
});
it('should parse URI in array', () => {
const result = parseDSL('uri in ["doc1", "doc2", "doc3"]');
expect(result).toEqual({
uris: ['doc1', 'doc2', 'doc3'],
});
});
it('should parse simple type equality', () => {
const result = parseDSL('type = "article"');
expect(result).toEqual({
types: ['article'],
});
});
it('should parse type in array', () => {
const result = parseDSL('type in ["article", "blog", "news"]');
expect(result).toEqual({
types: ['article', 'blog', 'news'],
});
});
});
describe('Meta field filtering', () => {
it('should parse number equality', () => {
const result = parseDSL('meta.priority = 5');
expect(result).toEqual({
meta: {
type: 'number',
field: 'priority',
filter: { eq: 5 },
},
});
});
it('should parse number comparisons', () => {
const testCases = [
{ query: 'meta.priority > 3', expected: { gt: 3 } },
{ query: 'meta.priority >= 3', expected: { gte: 3 } },
{ query: 'meta.priority < 10', expected: { lt: 10 } },
{ query: 'meta.priority <= 10', expected: { lte: 10 } },
{ query: 'meta.priority != 5', expected: { neq: 5 } },
];
testCases.forEach(({ query, expected }) => {
const result = parseDSL(query);
expect(result).toEqual({
meta: {
type: 'number',
field: 'priority',
filter: expected,
},
});
});
});
it('should parse text equality and inequality', () => {
const result = parseDSL('meta.title = "Test Title"');
expect(result).toEqual({
meta: {
type: 'text',
field: 'title',
filter: { eq: 'Test Title' },
},
});
const result2 = parseDSL('meta.category != "archived"');
expect(result2).toEqual({
meta: {
type: 'text',
field: 'category',
filter: { neq: 'archived' },
},
});
});
it('should parse LIKE patterns', () => {
const result = parseDSL('meta.title like "%Test%"');
expect(result).toEqual({
meta: {
type: 'text',
field: 'title',
filter: { like: '%Test%' },
},
});
const result2 = parseDSL('meta.title not like "%Draft%"');
expect(result2).toEqual({
meta: {
type: 'text',
field: 'title',
filter: { nlike: '%Draft%' },
},
});
});
it('should parse boolean values', () => {
const result = parseDSL('meta.published = true');
expect(result).toEqual({
meta: {
type: 'bool',
field: 'published',
filter: { eq: true },
},
});
const result2 = parseDSL('meta.archived = false');
expect(result2).toEqual({
meta: {
type: 'bool',
field: 'archived',
filter: { eq: false },
},
});
});
});
describe('Logical operations', () => {
it('should parse AND conditions with "and" keyword', () => {
const result = parseDSL('meta.priority = 5 and meta.published = true');
expect(result).toEqual({
meta: {
type: 'and',
conditions: [
{
type: 'number',
field: 'priority',
filter: { eq: 5 },
},
{
type: 'bool',
field: 'published',
filter: { eq: true },
},
],
},
});
});
it('should parse AND conditions with "&" operator', () => {
const result = parseDSL('meta.priority >= 5 & meta.priority < 10');
expect(result).toEqual({
meta: {
type: 'and',
conditions: [
{
type: 'number',
field: 'priority',
filter: { gte: 5 },
},
{
type: 'number',
field: 'priority',
filter: { lt: 10 },
},
],
},
});
});
it('should parse OR conditions with "or" keyword', () => {
const result = parseDSL('type = "article" or type = "blog"');
expect(result).toEqual({
types: ['article', 'blog'],
});
});
it('should parse OR conditions with "|" operator', () => {
const result = parseDSL('meta.priority > 8 | meta.urgent = true');
expect(result).toEqual({
meta: {
type: 'or',
conditions: [
{
type: 'number',
field: 'priority',
filter: { gt: 8 },
},
{
type: 'bool',
field: 'urgent',
filter: { eq: true },
},
],
},
});
});
});
describe('Complex expressions', () => {
it('should parse mixed field types with logical operations', () => {
const result = parseDSL('uri in ["doc1", "doc2"] and meta.priority >= 5');
expect(result).toEqual({
uris: ['doc1', 'doc2'],
meta: {
type: 'number',
field: 'priority',
filter: { gte: 5 },
},
});
});
it('should parse parentheses grouping', () => {
const result = parseDSL('(meta.priority > 5 or meta.urgent = true) and type = "article"');
expect(result).toEqual({
types: ['article'],
meta: {
type: 'or',
conditions: [
{
type: 'number',
field: 'priority',
filter: { gt: 5 },
},
{
type: 'bool',
field: 'urgent',
filter: { eq: true },
},
],
},
});
});
it('should parse nested parentheses', () => {
const result = parseDSL('((meta.a = 1 and meta.b = 2) or meta.c = 3) and type = "test"');
expect(result).toEqual({
types: ['test'],
meta: {
type: 'or',
conditions: [
{
type: 'and',
conditions: [
{
type: 'number',
field: 'a',
filter: { eq: 1 },
},
{
type: 'number',
field: 'b',
filter: { eq: 2 },
},
],
},
{
type: 'number',
field: 'c',
filter: { eq: 3 },
},
],
},
});
});
it('should handle the example from the documentation', () => {
const result = parseDSL(
'uri in ["a", "b c", "d"] and (meta.created >= 12345 & meta.created < 23456 or (type = "foo" and meta.test != "stuff"))',
);
expect(result).toEqual({
uris: ['a', 'b c', 'd'],
meta: {
type: 'or',
conditions: [
{
type: 'and',
conditions: [
{
type: 'number',
field: 'created',
filter: { gte: 12345 },
},
{
type: 'number',
field: 'created',
filter: { lt: 23456 },
},
],
},
{
type: 'text',
field: 'test',
filter: { neq: 'stuff' },
},
],
},
types: ['foo'],
});
});
});
describe('String handling', () => {
it('should handle strings with spaces', () => {
const result = parseDSL('meta.title = "This is a test with spaces"');
expect(result).toEqual({
meta: {
type: 'text',
field: 'title',
filter: { eq: 'This is a test with spaces' },
},
});
});
it('should handle single quotes', () => {
const result = parseDSL("meta.title = 'Single quoted string'");
expect(result).toEqual({
meta: {
type: 'text',
field: 'title',
filter: { eq: 'Single quoted string' },
},
});
});
it('should handle escaped characters', () => {
const result = parseDSL('meta.content = "String with \\"quotes\\" and \\n newlines"');
expect(result).toEqual({
meta: {
type: 'text',
field: 'content',
filter: { eq: 'String with "quotes" and \n newlines' },
},
});
});
});
describe('Whitespace handling', () => {
it('should ignore extra whitespace', () => {
const result = parseDSL(' uri = "test" and meta.priority >= 5 ');
expect(result).toEqual({
uris: ['test'],
meta: {
type: 'number',
field: 'priority',
filter: { gte: 5 },
},
});
});
});
describe('Error handling', () => {
it('should throw error for invalid syntax', () => {
expect(() => parseDSL('uri =')).toThrow('DSL parsing error');
expect(() => parseDSL('uri = "test')).toThrow('DSL parsing error');
expect(() => parseDSL('(uri = "test"')).toThrow('DSL parsing error');
});
it('should throw error for unknown fields', () => {
expect(() => parseDSL('unknown = "test"')).toThrow('DSL parsing error');
});
it('should throw error for unsupported operators', () => {
expect(() => parseDSL('uri like "test"')).toThrow('DSL parsing error');
expect(() => parseDSL('meta.test > "string"')).toThrow('DSL parsing error');
});
it('should throw error for invalid tokens', () => {
expect(() => parseDSL('uri = test@invalid')).toThrow('DSL parsing error');
});
});
describe('Edge cases', () => {
it('should handle empty arrays', () => {
const result = parseDSL('uri in []');
expect(result).toEqual({
uris: [],
});
});
it('should handle decimal numbers', () => {
const result = parseDSL('meta.score = 4.5');
expect(result).toEqual({
meta: {
type: 'number',
field: 'score',
filter: { eq: 4.5 },
},
});
});
it('should handle negative numbers', () => {
const result = parseDSL('meta.balance < -100');
expect(result).toEqual({
meta: {
type: 'number',
field: 'balance',
filter: { lt: -100 },
},
});
});
it('should deduplicate URI and type arrays', () => {
const result = parseDSL('uri = "test" or uri = "test" or type = "article" or type = "article"');
expect(result).toEqual({
uris: ['test'],
types: ['article'],
});
});
});
});

View File

@@ -0,0 +1,616 @@
import type { DocumentSearchOptions, MetaCondition, MetaFilter } from './documents.schemas.ts';
// Token types for the DSL
type TokenType =
| 'IDENTIFIER'
| 'STRING'
| 'NUMBER'
| 'BOOLEAN'
| 'OPERATOR'
| 'KEYWORD'
| 'LPAREN'
| 'RPAREN'
| 'LBRACKET'
| 'RBRACKET'
| 'COMMA'
| 'DOT'
| 'EOF';
type Token = {
type: TokenType;
value: string;
position: number;
};
class Tokenizer {
private input: string;
private position = 0;
private currentChar: string | null;
constructor(input: string) {
this.input = input.trim();
this.currentChar = this.input.length > 0 ? this.input[0] : null;
}
private advance(): void {
this.position += 1;
if (this.position >= this.input.length) {
this.currentChar = null;
} else {
this.currentChar = this.input[this.position];
}
}
private skipWhitespace(): void {
while (this.currentChar && /\s/.test(this.currentChar)) {
this.advance();
}
}
private readString(): string {
const quote = this.currentChar; // Either ' or "
this.advance(); // Skip opening quote
let value = '';
while (this.currentChar && this.currentChar !== quote) {
if (this.currentChar === '\\') {
this.advance(); // Skip escape character
if (this.currentChar) {
// Handle common escape sequences
switch (this.currentChar as string) {
case 'n':
value += '\n';
break;
case 't':
value += '\t';
break;
case 'r':
value += '\r';
break;
case '\\':
value += '\\';
break;
case '"':
value += '"';
break;
case "'":
value += "'";
break;
default:
value += this.currentChar;
break;
}
this.advance();
}
} else {
value += this.currentChar;
this.advance();
}
}
if (this.currentChar === quote) {
this.advance(); // Skip closing quote
} else {
throw new Error(`Unterminated string at position ${this.position}`);
}
return value;
}
private readNumber(): string {
let value = '';
// Handle negative sign
if (this.currentChar === '-') {
value += this.currentChar;
this.advance();
}
while (this.currentChar && /[\d.]/.test(this.currentChar)) {
value += this.currentChar;
this.advance();
}
return value;
}
private readIdentifier(): string {
let value = '';
while (this.currentChar && /[a-zA-Z0-9_]/.test(this.currentChar)) {
value += this.currentChar;
this.advance();
}
return value;
}
private readOperator(): string {
let value = '';
// Handle multi-character operators
const remainingInput = this.input.slice(this.position);
if (remainingInput.startsWith('>=')) {
this.advance();
this.advance();
return '>=';
} else if (remainingInput.startsWith('<=')) {
this.advance();
this.advance();
return '<=';
} else if (remainingInput.startsWith('!=')) {
this.advance();
this.advance();
return '!=';
} else if (remainingInput.startsWith('not like')) {
// Handle "not like" as a single operator
this.position += 8;
this.currentChar = this.position < this.input.length ? this.input[this.position] : null;
return 'not like';
} else {
value = this.currentChar || '';
this.advance();
return value;
}
}
tokenize(): Token[] {
const tokens: Token[] = [];
while (this.currentChar) {
this.skipWhitespace();
if (!this.currentChar) break;
const startPosition = this.position;
if (this.currentChar === '"' || this.currentChar === "'") {
const value = this.readString();
tokens.push({ type: 'STRING', value, position: startPosition });
} else if (
/\d/.test(this.currentChar) ||
(this.currentChar === '-' && /\d/.test(this.input[this.position + 1] || ''))
) {
const value = this.readNumber();
tokens.push({ type: 'NUMBER', value, position: startPosition });
} else if (/[a-zA-Z_]/.test(this.currentChar)) {
const value = this.readIdentifier();
// Check for keywords and boolean values
if (['and', 'or', 'in', 'like', 'not'].includes(value.toLowerCase())) {
tokens.push({ type: 'KEYWORD', value: value.toLowerCase(), position: startPosition });
} else if (['true', 'false'].includes(value.toLowerCase())) {
tokens.push({ type: 'BOOLEAN', value: value.toLowerCase(), position: startPosition });
} else {
tokens.push({ type: 'IDENTIFIER', value, position: startPosition });
}
} else if (['=', '!', '>', '<', '&', '|'].includes(this.currentChar)) {
const value = this.readOperator();
// Handle & and | as logical operators
if (value === '&') {
tokens.push({ type: 'KEYWORD', value: 'and', position: startPosition });
} else if (value === '|') {
tokens.push({ type: 'KEYWORD', value: 'or', position: startPosition });
} else {
tokens.push({ type: 'OPERATOR', value, position: startPosition });
}
} else if (this.currentChar === '(') {
tokens.push({ type: 'LPAREN', value: '(', position: startPosition });
this.advance();
} else if (this.currentChar === ')') {
tokens.push({ type: 'RPAREN', value: ')', position: startPosition });
this.advance();
} else if (this.currentChar === '[') {
tokens.push({ type: 'LBRACKET', value: '[', position: startPosition });
this.advance();
} else if (this.currentChar === ']') {
tokens.push({ type: 'RBRACKET', value: ']', position: startPosition });
this.advance();
} else if (this.currentChar === ',') {
tokens.push({ type: 'COMMA', value: ',', position: startPosition });
this.advance();
} else if (this.currentChar === '.') {
tokens.push({ type: 'DOT', value: '.', position: startPosition });
this.advance();
} else {
throw new Error(`Unexpected character '${this.currentChar}' at position ${this.position}`);
}
}
tokens.push({ type: 'EOF', value: '', position: this.position });
return tokens;
}
}
// AST node types
type ASTNode = {
type: string;
};
type ComparisonNode = {
type: 'comparison';
field: string;
operator: string;
value: string | number | boolean | string[];
} & ASTNode;
type LogicalNode = {
type: 'logical';
operator: 'and' | 'or';
left: ASTNode;
right: ASTNode;
} & ASTNode;
class Parser {
private tokens: Token[];
private position = 0;
private currentToken: Token;
constructor(tokens: Token[]) {
this.tokens = tokens;
this.currentToken = tokens[0];
}
private advance() {
this.position += 1;
if (this.position < this.tokens.length) {
this.currentToken = this.tokens[this.position];
}
return this.currentToken;
}
private expect(tokenType: TokenType | TokenType[], value?: string): Token {
const types = Array.isArray(tokenType) ? tokenType : [tokenType];
if (!types.includes(this.currentToken.type)) {
throw new Error(
`Expected ${types.join(' or ')}, got ${this.currentToken.type} at position ${this.currentToken.position}`,
);
}
if (value && this.currentToken.value !== value) {
throw new Error(
`Expected '${value}', got '${this.currentToken.value}' at position ${this.currentToken.position}`,
);
}
const token = this.currentToken;
this.advance();
return token;
}
private parseValue(): string | number | boolean | string[] {
if (this.currentToken.type === 'STRING') {
const value = this.currentToken.value;
this.advance();
return value;
} else if (this.currentToken.type === 'NUMBER') {
const value = parseFloat(this.currentToken.value);
this.advance();
return value;
} else if (this.currentToken.type === 'BOOLEAN') {
const value = this.currentToken.value === 'true';
this.advance();
return value;
} else if (this.currentToken.type === 'LBRACKET') {
this.advance(); // Skip [
const values: string[] = [];
if ((this.currentToken.type as TokenType) !== 'RBRACKET') {
values.push(String(this.parseValue()));
while ((this.currentToken.type as TokenType) === 'COMMA') {
this.advance(); // Skip comma
values.push(String(this.parseValue()));
}
}
this.expect('RBRACKET');
return values;
} else {
throw new Error(`Expected value, got ${this.currentToken.type} at position ${this.currentToken.position}`);
}
}
private parseComparison(): ComparisonNode {
let field: string;
// Parse field (uri, type, or meta.fieldName)
if (this.currentToken.type === 'IDENTIFIER') {
const identifier = this.currentToken.value;
this.advance();
if (identifier === 'meta' && (this.currentToken.type as TokenType) === 'DOT') {
this.advance(); // Skip dot
const fieldName = this.expect('IDENTIFIER').value;
field = `meta.${fieldName}`;
} else if (['uri', 'type'].includes(identifier)) {
field = identifier;
} else {
throw new Error(`Unknown field '${identifier}' at position ${this.currentToken.position}`);
}
} else {
throw new Error(`Expected field name, got ${this.currentToken.type} at position ${this.currentToken.position}`);
}
// Parse operator
let operator: string;
if ((this.currentToken.type as TokenType) === 'OPERATOR') {
operator = this.currentToken.value;
this.advance();
} else if ((this.currentToken.type as TokenType) === 'KEYWORD') {
if (this.currentToken.value === 'in') {
operator = 'in';
this.advance();
} else if (this.currentToken.value === 'like') {
operator = 'like';
this.advance();
} else if (this.currentToken.value === 'not') {
this.advance();
this.expect('KEYWORD', 'like');
operator = 'not like';
} else {
throw new Error(`Unexpected keyword '${this.currentToken.value}' at position ${this.currentToken.position}`);
}
} else {
throw new Error(`Expected operator, got ${this.currentToken.type} at position ${this.currentToken.position}`);
}
// Parse value
if ((this.currentToken.type as TokenType) === 'EOF') {
throw new Error(`Expected value after operator '${operator}' at position ${this.currentToken.position}`);
}
const value = this.parseValue();
return {
type: 'comparison',
field,
operator,
value,
};
}
private parseTerm(): ASTNode {
if (this.currentToken.type === 'LPAREN') {
this.advance(); // Skip (
const node = this.parseExpression();
this.expect('RPAREN');
return node;
} else {
return this.parseComparison();
}
}
private parseExpression(): ASTNode {
let node = this.parseTerm();
while (
this.currentToken.type === 'KEYWORD' &&
(this.currentToken.value === 'and' || this.currentToken.value === 'or')
) {
const operator = this.currentToken.value as 'and' | 'or';
this.advance();
const right = this.parseTerm();
node = {
type: 'logical',
operator,
left: node,
right,
} as LogicalNode;
}
return node;
}
parse(): ASTNode {
const result = this.parseExpression();
this.expect('EOF');
return result;
}
}
// Convert AST to DocumentSearchOptions
class Converter {
private uris: string[] = [];
private types: string[] = [];
private metaConditions: MetaCondition[] = [];
private hasUriConditions = false;
private hasTypeConditions = false;
private convertComparison(node: ComparisonNode): MetaCondition | null {
if (node.field === 'uri') {
this.hasUriConditions = true;
if (node.operator === '=' && typeof node.value === 'string') {
this.uris.push(node.value);
} else if (node.operator === 'in' && Array.isArray(node.value)) {
this.uris.push(...node.value);
} else {
throw new Error(`Unsupported operator '${node.operator}' for uri field`);
}
return null;
}
if (node.field === 'type') {
this.hasTypeConditions = true;
if (node.operator === '=' && typeof node.value === 'string') {
this.types.push(node.value);
} else if (node.operator === 'in' && Array.isArray(node.value)) {
this.types.push(...node.value);
} else {
throw new Error(`Unsupported operator '${node.operator}' for type field`);
}
return null;
}
if (node.field.startsWith('meta.')) {
const fieldName = node.field.slice(5); // Remove 'meta.' prefix
return this.convertMetaFilter(fieldName, node.operator, node.value);
}
throw new Error(`Unknown field '${node.field}'`);
}
private convertMetaFilter(field: string, operator: string, value: string | number | boolean | string[]): MetaFilter {
if (typeof value === 'number') {
// Number filter
const filter: Record<string, number> = {};
switch (operator) {
case '=':
filter.eq = value;
break;
case '!=':
filter.neq = value;
break;
case '>':
filter.gt = value;
break;
case '>=':
filter.gte = value;
break;
case '<':
filter.lt = value;
break;
case '<=':
filter.lte = value;
break;
default:
throw new Error(`Unsupported operator '${operator}' for number field`);
}
return {
type: 'number',
field,
filter,
};
} else if (typeof value === 'boolean') {
// Boolean filter
if (operator !== '=') {
throw new Error(`Unsupported operator '${operator}' for boolean field`);
}
return {
type: 'bool',
field,
filter: { eq: value },
};
} else if (typeof value === 'string') {
// Text filter
const filter: Record<string, string> = {};
switch (operator) {
case '=':
filter.eq = value;
break;
case '!=':
filter.neq = value;
break;
case 'like':
filter.like = value;
break;
case 'not like':
filter.nlike = value;
break;
default:
throw new Error(`Unsupported operator '${operator}' for text field`);
}
return {
type: 'text',
field,
filter,
};
} else {
throw new Error(`Unsupported value type for meta field`);
}
}
private convertLogical(node: LogicalNode): MetaCondition | null {
const leftCondition = this.convertNode(node.left);
const rightCondition = this.convertNode(node.right);
const conditions: MetaCondition[] = [];
if (leftCondition) conditions.push(leftCondition);
if (rightCondition) conditions.push(rightCondition);
if (conditions.length === 0) {
return null;
}
if (conditions.length === 1) {
return conditions[0];
}
return {
type: node.operator,
conditions,
};
}
private convertNode(node: ASTNode): MetaCondition | null {
if (node.type === 'comparison') {
return this.convertComparison(node as ComparisonNode);
} else if (node.type === 'logical') {
return this.convertLogical(node as LogicalNode);
}
throw new Error(`Unknown node type '${node.type}'`);
}
convert(ast: ASTNode): DocumentSearchOptions {
this.uris = [];
this.types = [];
this.metaConditions = [];
this.hasUriConditions = false;
this.hasTypeConditions = false;
const metaCondition = this.convertNode(ast);
const options: DocumentSearchOptions = {};
if (this.hasUriConditions) {
options.uris = [...new Set(this.uris)]; // Remove duplicates
}
if (this.hasTypeConditions) {
options.types = [...new Set(this.types)]; // Remove duplicates
}
if (metaCondition) {
options.meta = metaCondition;
}
return options;
}
}
/**
* Parse a DSL query string into DocumentSearchOptions
*
* Supports the following syntax:
* - URI filtering: `uri = "value"` or `uri in ["val1", "val2"]`
* - Type filtering: `type = "value"` or `type in ["val1", "val2"]`
* - Meta filtering: `meta.field = value`, `meta.field != value`, `meta.field > value`, etc.
* - Boolean logic: `condition1 and condition2` (or `&`), `condition1 or condition2` (or `|`)
* - Parentheses: `(condition1 or condition2) and condition3`
*
* Example: `uri in ["a", "b"] and (meta.priority >= 5 | type = "article")`
*
* @param query - The DSL query string to parse
* @returns DocumentSearchOptions object
*/
export function parseDSL(query: string): DocumentSearchOptions {
try {
const tokenizer = new Tokenizer(query);
const tokens = tokenizer.tokenize();
const parser = new Parser(tokens);
const ast = parser.parse();
const converter = new Converter();
return converter.convert(ast);
} catch (error) {
throw new Error(`DSL parsing error: ${error instanceof Error ? error.message : String(error)}`);
}
}

View File

@@ -0,0 +1,133 @@
import type { MetaCondition, MetaFilter, Document } from './documents.schemas.ts';
/**
* Evaluates a meta filter against a document's metadata
*/
const evaluateMetaFilter = (filter: MetaFilter, document: Document): boolean => {
const fieldValue = document.metadata[filter.field];
const fieldExists = filter.field in document.metadata;
if (filter.type === 'number') {
const { gt, gte, lt, lte, eq, neq, nill } = filter.filter;
// Handle null/undefined checks first
if (nill !== undefined) {
if (nill) {
// Field doesn't exist or is null
return !fieldExists || fieldValue === null || fieldValue === undefined;
} else {
// Field exists and is not null
return fieldExists && fieldValue !== null && fieldValue !== undefined;
}
}
// If field doesn't exist or is null for numeric operations, return false
if (!fieldExists || fieldValue === null || fieldValue === undefined) {
return false;
}
const numValue = typeof fieldValue === 'number' ? fieldValue : Number(fieldValue);
// If conversion to number fails, return false for numeric operations
if (isNaN(numValue)) {
return false;
}
if (eq !== undefined && numValue !== eq) return false;
if (neq !== undefined && numValue === neq) return false;
if (gt !== undefined && numValue <= gt) return false;
if (gte !== undefined && numValue < gte) return false;
if (lt !== undefined && numValue >= lt) return false;
if (lte !== undefined && numValue > lte) return false;
return true;
} else if (filter.type === 'text') {
const { eq, neq, like, nlike, nill } = filter.filter;
// Handle null/undefined checks first
if (nill !== undefined) {
if (nill) {
// Field doesn't exist or is null
return !fieldExists || fieldValue === null || fieldValue === undefined;
} else {
// Field exists and is not null
return fieldExists && fieldValue !== null && fieldValue !== undefined;
}
}
// If field doesn't exist or is null for text operations, return false
if (!fieldExists || fieldValue === null || fieldValue === undefined) {
return false;
}
const strValue = String(fieldValue);
if (eq !== undefined && strValue !== eq) return false;
if (neq !== undefined && strValue === neq) return false;
if (like !== undefined) {
// Convert SQL LIKE pattern to JavaScript regex
const regexPattern = like.replace(/\\_/g, '_').replace(/%/g, '.*').replace(/\\\./g, '\\.');
const regex = new RegExp(`^${regexPattern}$`, 'i'); // Case insensitive like SQL LIKE
if (!regex.test(strValue)) return false;
}
if (nlike !== undefined) {
// Convert SQL NOT LIKE pattern to JavaScript regex
const regexPattern = nlike.replace(/\\_/g, '_').replace(/%/g, '.*').replace(/\\\./g, '\\.');
const regex = new RegExp(`^${regexPattern}$`, 'i'); // Case insensitive like SQL LIKE
if (regex.test(strValue)) return false;
}
return true;
} else if (filter.type === 'bool') {
const { eq, nill } = filter.filter;
// Handle null/undefined checks first
if (nill !== undefined) {
if (nill) {
// Field doesn't exist or is null
return !fieldExists || fieldValue === null || fieldValue === undefined;
} else {
// Field exists and is not null
return fieldExists && fieldValue !== null && fieldValue !== undefined;
}
}
// If field doesn't exist or is null for boolean operations, return false
if (!fieldExists || fieldValue === null || fieldValue === undefined) {
return false;
}
const boolValue = typeof fieldValue === 'boolean' ? fieldValue : Boolean(fieldValue);
return boolValue === eq;
}
return false;
};
/**
* Evaluates a meta condition (which can contain filters and nested AND/OR conditions) against a document
*/
const evaluateMetaCondition = (condition: MetaCondition, document: Document): boolean => {
if (condition.type === 'and') {
// ALL conditions must be true
return condition.conditions.every((subCondition) => evaluateMetaCondition(subCondition, document));
} else if (condition.type === 'or') {
// AT LEAST ONE condition must be true
return condition.conditions.some((subCondition) => evaluateMetaCondition(subCondition, document));
} else {
// It's a filter, not a condition
return evaluateMetaFilter(condition, document);
}
};
/**
* Filters a document based on the provided filter/condition
* Returns true if the document matches the filter, false otherwise
*
* @param filter - The filter or condition to apply
* @param document - The document to test against the filter
* @returns true if the document matches the filter, false otherwise
*/
export const filterDocument = (filter: MetaCondition | MetaFilter, document: Document): boolean => {
return evaluateMetaCondition(filter, document);
};

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, expect, beforeEach, afterEach } from 'vitest';
import { DatabaseService, tableNames } from '../database/database.ts';
@@ -6,25 +6,26 @@ import { DocumentsService } from './documents.ts';
import type { DocumentUpsert, DocumentSearchOptions, MetaCondition } from './documents.schemas.ts';
import { Services } from '#root/utils/services.ts';
import { itWithTiming } from '#root/utils/tests.ts';
describe('DocumentsService', () => {
let services: Services;
let documentsService: DocumentsService;
let databaseService: DatabaseService;
beforeEach(() => {
beforeEach(async () => {
services = new Services();
documentsService = services.get(DocumentsService);
databaseService = services.get(DatabaseService);
await databaseService.init();
});
afterEach(async () => {
const db = await databaseService.getDb();
await db(tableNames.documents).del();
await databaseService.close();
});
describe('upsert', () => {
it('should insert a new document', async () => {
itWithTiming('should insert a new document', async () => {
const document: DocumentUpsert = {
uri: 'test-doc-1',
type: 'article',
@@ -56,7 +57,7 @@ describe('DocumentsService', () => {
expect(actualResult?.deletedAt).toBeNull();
});
it('should update an existing document', async () => {
itWithTiming('should update an existing document', async () => {
const document: DocumentUpsert = {
uri: 'test-doc-1',
type: 'article',
@@ -96,7 +97,7 @@ describe('DocumentsService', () => {
expect(actualResult?.updatedAt).toBeDefined();
});
it('should allow same URI with different types', async () => {
itWithTiming('should allow same URI with different types', async () => {
const doc1: DocumentUpsert = {
uri: 'shared-uri',
type: 'article',
@@ -178,14 +179,14 @@ describe('DocumentsService', () => {
}
});
it('should return all documents when no filters applied', async () => {
itWithTiming('should return all documents when no filters applied', async () => {
const options: DocumentSearchOptions = {};
const results = await documentsService.search(options);
expect(results).toHaveLength(4);
});
it('should filter by uris', async () => {
itWithTiming('should filter by uris', async () => {
const options: DocumentSearchOptions = {
uris: ['doc-1', 'doc-3'],
};
@@ -195,7 +196,7 @@ describe('DocumentsService', () => {
expect(results.map((r) => r.uri).sort()).toEqual(['doc-1', 'doc-3']);
});
it('should filter by types', async () => {
itWithTiming('should filter by types', async () => {
const options: DocumentSearchOptions = {
types: ['article'],
};
@@ -204,7 +205,7 @@ describe('DocumentsService', () => {
expect(results.every((r) => r.type === 'article')).toBe(true);
});
it('should apply limit', async () => {
itWithTiming('should apply limit', async () => {
const options: DocumentSearchOptions = {
limit: 2,
};
@@ -212,7 +213,7 @@ describe('DocumentsService', () => {
expect(results).toHaveLength(2);
});
it('should apply offset', async () => {
itWithTiming('should apply offset', async () => {
const options: DocumentSearchOptions = {
limit: 2,
offset: 1,
@@ -221,7 +222,7 @@ describe('DocumentsService', () => {
expect(results).toHaveLength(2);
});
it('should combine multiple filters', async () => {
itWithTiming('should combine multiple filters', async () => {
const options: DocumentSearchOptions = {
types: ['article'],
uris: ['doc-1', 'doc-2', 'doc-4'],
@@ -293,7 +294,7 @@ describe('DocumentsService', () => {
});
describe('number filters', () => {
it('should filter by exact number equality', async () => {
itWithTiming('should filter by exact number equality', async () => {
const meta: MetaCondition = {
type: 'number',
field: 'priority',
@@ -305,7 +306,7 @@ describe('DocumentsService', () => {
expect(results[0].uri).toBe('meta-2');
});
it('should filter by number range', async () => {
itWithTiming('should filter by number range', async () => {
const meta: MetaCondition = {
type: 'number',
field: 'priority',
@@ -317,7 +318,7 @@ describe('DocumentsService', () => {
expect(results[0].uri).toBe('meta-2');
});
it('should filter by greater than', async () => {
itWithTiming('should filter by greater than', async () => {
const meta: MetaCondition = {
type: 'number',
field: 'priority',
@@ -329,7 +330,7 @@ describe('DocumentsService', () => {
expect(results[0].uri).toBe('meta-1');
});
it('should filter by not equal', async () => {
itWithTiming('should filter by not equal', async () => {
const meta: MetaCondition = {
type: 'number',
field: 'priority',
@@ -343,7 +344,7 @@ describe('DocumentsService', () => {
});
describe('text filters', () => {
it('should filter by exact text equality', async () => {
itWithTiming('should filter by exact text equality', async () => {
const meta: MetaCondition = {
type: 'text',
field: 'category',
@@ -355,7 +356,7 @@ describe('DocumentsService', () => {
expect(results.map((r) => r.uri).sort()).toEqual(['meta-1', 'meta-4']);
});
it('should filter by text like pattern', async () => {
itWithTiming('should filter by text like pattern', async () => {
const meta: MetaCondition = {
type: 'text',
field: 'title',
@@ -367,7 +368,7 @@ describe('DocumentsService', () => {
expect(results.map((r) => r.uri).sort()).toEqual(['meta-1', 'meta-2']);
});
it('should filter by text not like pattern', async () => {
itWithTiming('should filter by text not like pattern', async () => {
const meta: MetaCondition = {
type: 'text',
field: 'title',
@@ -379,7 +380,7 @@ describe('DocumentsService', () => {
expect(results.map((r) => r.uri).sort()).toEqual(['meta-3', 'meta-4']);
});
it('should filter by text not equal', async () => {
itWithTiming('should filter by text not equal', async () => {
const meta: MetaCondition = {
type: 'text',
field: 'category',
@@ -393,7 +394,7 @@ describe('DocumentsService', () => {
});
describe('boolean filters', () => {
it('should filter by boolean true', async () => {
itWithTiming('should filter by boolean true', async () => {
const meta: MetaCondition = {
type: 'bool',
field: 'published',
@@ -405,7 +406,7 @@ describe('DocumentsService', () => {
expect(results.map((r) => r.uri).sort()).toEqual(['meta-1', 'meta-3']);
});
it('should filter by boolean false', async () => {
itWithTiming('should filter by boolean false', async () => {
const meta: MetaCondition = {
type: 'bool',
field: 'published',
@@ -419,7 +420,7 @@ describe('DocumentsService', () => {
});
describe.skip('null filters', () => {
it('should filter by null values for numbers', async () => {
itWithTiming('should filter by null values for numbers', async () => {
const meta: MetaCondition = {
type: 'number',
field: 'nonexistent',
@@ -430,7 +431,7 @@ describe('DocumentsService', () => {
expect(results).toHaveLength(4); // All documents should match as none have this field
});
it('should filter by non-null values', async () => {
itWithTiming('should filter by non-null values', async () => {
const meta: MetaCondition = {
type: 'number',
field: 'priority',
@@ -443,7 +444,7 @@ describe('DocumentsService', () => {
});
describe('complex conditions', () => {
it('should handle AND conditions', async () => {
itWithTiming('should handle AND conditions', async () => {
const meta: MetaCondition = {
type: 'and',
conditions: [
@@ -465,7 +466,7 @@ describe('DocumentsService', () => {
expect(results[0].uri).toBe('meta-1');
});
it('should handle OR conditions', async () => {
itWithTiming('should handle OR conditions', async () => {
const meta: MetaCondition = {
type: 'or',
conditions: [
@@ -487,7 +488,7 @@ describe('DocumentsService', () => {
expect(results.map((r) => r.uri).sort()).toEqual(['meta-1', 'meta-3']);
});
it('should handle nested AND/OR conditions', async () => {
itWithTiming('should handle nested AND/OR conditions', async () => {
const meta: MetaCondition = {
type: 'and',
conditions: [
@@ -519,7 +520,7 @@ describe('DocumentsService', () => {
expect(results.map((r) => r.uri).sort()).toEqual(['meta-2', 'meta-4']);
});
it('should handle deeply nested conditions', async () => {
itWithTiming('should handle deeply nested conditions', async () => {
const meta: MetaCondition = {
type: 'or',
conditions: [
@@ -564,13 +565,13 @@ describe('DocumentsService', () => {
});
describe('edge cases', () => {
it('should handle empty search options', async () => {
itWithTiming('should handle empty search options', async () => {
const results = await documentsService.search({});
expect(results).toBeDefined();
expect(Array.isArray(results)).toBe(true);
});
it('should handle search with no results', async () => {
itWithTiming('should handle search with no results', async () => {
const options: DocumentSearchOptions = {
uris: ['non-existent-uri'],
};
@@ -578,7 +579,7 @@ describe('DocumentsService', () => {
expect(results).toHaveLength(0);
});
it('should handle meta search with no matching conditions', async () => {
itWithTiming('should handle meta search with no matching conditions', async () => {
await documentsService.upsert({
uri: 'test-doc',
type: 'article',

View File

@@ -0,0 +1,125 @@
import type { Knex } from 'knex';
import type { MetaCondition, MetaFilter } from './documents.schemas.ts';
const buildMetaFilter = async (builder: Knex.QueryBuilder, filter: MetaFilter): Promise<void> => {
const fieldPath = `metadata->'${filter.field}'`;
if (filter.type === 'number') {
const { gt, gte, lt, lte, eq, neq, nill } = filter.filter;
if (nill !== undefined) {
if (nill) {
// Field doesn't exist or is null
builder.where((subBuilder) => {
subBuilder.whereRaw(`NOT (metadata ? '${filter.field}')`).orWhereRaw(`metadata->>'${filter.field}' IS NULL`);
});
} else {
// Field exists and is not null
builder.whereRaw(`metadata ? '${filter.field}' AND metadata->>'${filter.field}' IS NOT NULL`);
}
return;
}
if (eq !== undefined) {
builder.where(builder.client.raw(`(${fieldPath})::numeric`), eq);
}
if (neq !== undefined) {
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '!=', neq);
}
if (gt !== undefined) {
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '>', gt);
}
if (gte !== undefined) {
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '>=', gte);
}
if (lt !== undefined) {
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '<', lt);
}
if (lte !== undefined) {
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '<=', lte);
}
} else if (filter.type === 'text') {
const { eq, neq, like, nlike, nill } = filter.filter;
if (nill !== undefined) {
if (nill) {
// Field doesn't exist or is null
builder.where((subBuilder) => {
subBuilder.whereRaw(`NOT (metadata ? '${filter.field}')`).orWhereRaw(`metadata->>'${filter.field}' IS NULL`);
});
} else {
// Field exists and is not null
builder.whereRaw(`metadata ? '${filter.field}' AND metadata->>'${filter.field}' IS NOT NULL`);
}
return;
}
if (eq !== undefined) {
builder.where(builder.client.raw(`metadata->>'${filter.field}'`), eq);
}
if (neq !== undefined) {
builder.where(builder.client.raw(`metadata->>'${filter.field}'`), '!=', neq);
}
if (like !== undefined) {
builder.where(builder.client.raw(`metadata->>'${filter.field}'`), 'like', like);
}
if (nlike !== undefined) {
builder.where(builder.client.raw(`metadata->>'${filter.field}'`), 'not like', nlike);
}
} else if (filter.type === 'bool') {
const { eq, nill } = filter.filter;
if (nill !== undefined) {
if (nill) {
// Field doesn't exist or is null
builder.where((subBuilder) => {
subBuilder.whereRaw(`NOT (metadata ? '${filter.field}')`).orWhereRaw(`metadata->>'${filter.field}' IS NULL`);
});
} else {
// Field exists and is not null
builder.whereRaw(`metadata ? '${filter.field}' AND metadata->>'${filter.field}' IS NOT NULL`);
}
return;
}
builder.where(builder.client.raw(`(${fieldPath})::boolean`), eq);
}
};
const buildMetaCondition = async (builder: Knex.QueryBuilder, condition: MetaCondition): Promise<void> => {
if (condition.type === 'and') {
// Handle AND conditions - all must be true
for (const [index, subCondition] of condition.conditions.entries()) {
if (index === 0) {
// First condition doesn't need andWhere
builder.where((subBuilder) => {
buildMetaCondition(subBuilder, subCondition);
});
} else {
builder.andWhere((subBuilder) => {
buildMetaCondition(subBuilder, subCondition);
});
}
}
} else if (condition.type === 'or') {
// Handle OR conditions - at least one must be true
for (const [index, subCondition] of condition.conditions.entries()) {
if (index === 0) {
// First condition doesn't need orWhere
builder.where((subBuilder) => {
buildMetaCondition(subBuilder, subCondition);
});
} else {
builder.orWhere((subBuilder) => {
buildMetaCondition(subBuilder, subCondition);
});
}
}
} else {
// Handle individual filter conditions
buildMetaFilter(builder, condition);
}
};
export { buildMetaFilter, buildMetaCondition };

View File

@@ -12,7 +12,7 @@ const documentSchema = z.object({
data: z.record(z.string(), z.unknown()),
});
type Document = z.infer<typeof Document>;
type Document = z.infer<typeof documentSchema>;
const documentUpsertSchema = documentSchema.omit({
createdAt: true,
@@ -89,5 +89,19 @@ const documentSearchOptionsSchema = z.object({
type DocumentSearchOptions = z.infer<typeof documentSearchOptionsSchema>;
export type { Document, DocumentUpsert, MetaFilter, MetaCondition, DocumentSearchOptions };
export { documentSchema, documentUpsertSchema, metaFilterSchema, metaConditionSchema, documentSearchOptionsSchema };
const documentUpsertEventSchema = z.object({
action: z.union([z.literal('insert'), z.literal('update'), z.literal('delete')]),
document: documentSchema,
});
type DocumentUpsertEvent = z.infer<typeof documentUpsertEventSchema>;
export type { Document, DocumentUpsert, MetaFilter, MetaCondition, DocumentSearchOptions, DocumentUpsertEvent };
export {
documentSchema,
documentUpsertSchema,
metaFilterSchema,
metaConditionSchema,
documentSearchOptionsSchema,
documentUpsertEventSchema,
};

View File

@@ -1,31 +1,53 @@
import type { Knex } from 'knex';
import { EventEmitter } from 'eventemitter3';
import { DatabaseService, tableNames, type TableRow } from '../database/database.ts';
import type { DocumentSearchOptions, DocumentUpsert, MetaCondition, MetaFilter } from './documents.schemas.ts';
import type { Document, DocumentSearchOptions, DocumentUpsert, DocumentUpsertEvent } from './documents.schemas.ts';
import { buildMetaCondition } from './documents.query.ts';
import type { Services } from '#root/utils/services.ts';
class DocumentsService {
type DocumentEvents = {
upsert: (document: DocumentUpsertEvent) => void;
};
class DocumentsService extends EventEmitter<DocumentEvents> {
#services: Services;
constructor(services: Services) {
super();
this.#services = services;
}
public upsert = async (document: DocumentUpsert) => {
const db = await this.#services.get(DatabaseService).getDb();
const baseItem = {
...document,
updatedAt: new Date(),
deletedAt: null,
};
await db(tableNames.documents)
.insert({ ...baseItem, createdAt: new Date() })
.onConflict(['uri', 'type'])
.merge({
...baseItem,
const [current] = await db<TableRow['document']>(tableNames.documents)
.where({
uri: document.uri,
type: document.type,
})
.limit(1);
if (current) {
const toInsert: Document = {
...document,
createdAt: current.updatedAt,
updatedAt: new Date().toISOString(),
deletedAt: null,
};
await db(tableNames.documents).update(toInsert).where({
uri: document.uri,
type: document.type,
});
this.emit('upsert', { action: 'update', document: toInsert });
} else {
const toInsert: Document = {
...document,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await db(tableNames.documents).insert(toInsert);
this.emit('upsert', { action: 'insert', document: toInsert });
}
};
public search = async (options: DocumentSearchOptions) => {
@@ -43,7 +65,7 @@ class DocumentsService {
}
if (meta) {
query = query.where((builder) => {
this.buildMetaCondition(builder, meta);
buildMetaCondition(builder, meta);
});
}
if (offset) {
@@ -51,138 +73,6 @@ class DocumentsService {
}
return query;
};
/**
* Recursively builds meta search conditions with proper scoping
*/
private buildMetaCondition(builder: Knex.QueryBuilder, condition: MetaCondition): void {
if (condition.type === 'and') {
// Handle AND conditions - all must be true
for (const [index, subCondition] of condition.conditions.entries()) {
if (index === 0) {
// First condition doesn't need andWhere
builder.where((subBuilder) => {
this.buildMetaCondition(subBuilder, subCondition);
});
} else {
builder.andWhere((subBuilder) => {
this.buildMetaCondition(subBuilder, subCondition);
});
}
}
} else if (condition.type === 'or') {
// Handle OR conditions - at least one must be true
for (const [index, subCondition] of condition.conditions.entries()) {
if (index === 0) {
// First condition doesn't need orWhere
builder.where((subBuilder) => {
this.buildMetaCondition(subBuilder, subCondition);
});
} else {
builder.orWhere((subBuilder) => {
this.buildMetaCondition(subBuilder, subCondition);
});
}
}
} else {
// Handle individual filter conditions
this.buildMetaFilter(builder, condition);
}
}
/**
* Builds individual meta filter conditions using JSONB operators
*/
private buildMetaFilter(builder: Knex.QueryBuilder, filter: MetaFilter): void {
const fieldPath = `metadata->'${filter.field}'`;
if (filter.type === 'number') {
const { gt, gte, lt, lte, eq, neq, nill } = filter.filter;
if (nill !== undefined) {
if (nill) {
// Field doesn't exist or is null
builder.where((subBuilder) => {
subBuilder
.whereRaw(`NOT (metadata ? '${filter.field}')`)
.orWhereRaw(`metadata->>'${filter.field}' IS NULL`);
});
} else {
// Field exists and is not null
builder.whereRaw(`metadata ? '${filter.field}' AND metadata->>'${filter.field}' IS NOT NULL`);
}
return;
}
if (eq !== undefined) {
builder.where(builder.client.raw(`(${fieldPath})::numeric`), eq);
}
if (neq !== undefined) {
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '!=', neq);
}
if (gt !== undefined) {
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '>', gt);
}
if (gte !== undefined) {
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '>=', gte);
}
if (lt !== undefined) {
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '<', lt);
}
if (lte !== undefined) {
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '<=', lte);
}
} else if (filter.type === 'text') {
const { eq, neq, like, nlike, nill } = filter.filter;
if (nill !== undefined) {
if (nill) {
// Field doesn't exist or is null
builder.where((subBuilder) => {
subBuilder
.whereRaw(`NOT (metadata ? '${filter.field}')`)
.orWhereRaw(`metadata->>'${filter.field}' IS NULL`);
});
} else {
// Field exists and is not null
builder.whereRaw(`metadata ? '${filter.field}' AND metadata->>'${filter.field}' IS NOT NULL`);
}
return;
}
if (eq !== undefined) {
builder.where(builder.client.raw(`metadata->>'${filter.field}'`), eq);
}
if (neq !== undefined) {
builder.where(builder.client.raw(`metadata->>'${filter.field}'`), '!=', neq);
}
if (like !== undefined) {
builder.where(builder.client.raw(`metadata->>'${filter.field}'`), 'like', like);
}
if (nlike !== undefined) {
builder.where(builder.client.raw(`metadata->>'${filter.field}'`), 'not like', nlike);
}
} else if (filter.type === 'bool') {
const { eq, nill } = filter.filter;
if (nill !== undefined) {
if (nill) {
// Field doesn't exist or is null
builder.where((subBuilder) => {
subBuilder
.whereRaw(`NOT (metadata ? '${filter.field}')`)
.orWhereRaw(`metadata->>'${filter.field}' IS NULL`);
});
} else {
// Field exists and is not null
builder.whereRaw(`metadata ? '${filter.field}' AND metadata->>'${filter.field}' IS NOT NULL`);
}
return;
}
builder.where(builder.client.raw(`(${fieldPath})::boolean`), eq);
}
}
}
export { DocumentsService };

View File

@@ -0,0 +1,50 @@
import { it as vitestIt } from 'vitest';
type TimedTestOptions = {
logTiming?: boolean;
threshold?: number; // Log only if test takes longer than this (in ms)
};
/**
* A wrapper around vitest's `it` function that measures and logs the execution time
* of just the test body (excluding beforeEach/afterEach hooks).
*/
export function itWithTiming(name: string, fn: () => void | Promise<void>, options: TimedTestOptions = {}) {
const { logTiming = true, threshold = 0 } = options;
return vitestIt(name, async () => {
const startTime = performance.now();
try {
await fn();
} finally {
const endTime = performance.now();
const duration = endTime - startTime;
if (logTiming && duration >= threshold) {
console.log(`⏱️ Test "${name}": ${duration.toFixed(2)}ms (body only)`);
}
}
});
}
/**
* Like timedTest, but only logs timing for slow tests (configurable threshold)
*/
export function slowTest(name: string, fn: () => void | Promise<void>, thresholdMs = 100) {
return itWithTiming(name, fn, { logTiming: true, threshold: thresholdMs });
}
/**
* Measures execution time of a specific code block within a test
*/
export async function measureTime<T>(label: string, fn: () => T | Promise<T>): Promise<T> {
const startTime = performance.now();
try {
return await fn();
} finally {
const endTime = performance.now();
const duration = endTime - startTime;
console.log(`⏱️ ${label}: ${duration.toFixed(2)}ms`);
}
}