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`);
}
}

4
packages/server/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/node_modules/
/dist/
/coverage/
/.env

View File

@@ -0,0 +1,39 @@
{
"type": "module",
"main": "dist/exports.js",
"scripts": {
"build": "tsc --build",
"test:unit": "vitest --run --passWithNoTests",
"test": "pnpm run \"/^test:/\""
},
"packageManager": "pnpm@10.6.0",
"files": [
"dist"
],
"imports": {
"#root/*": "./src/*"
},
"exports": {
".": "./dist/exports.js"
},
"devDependencies": {
"@morten-olsen/fluxcurrent-configs": "workspace:*",
"@morten-olsen/fluxcurrent-tests": "workspace:*",
"@types/node": "24.3.1",
"@vitest/coverage-v8": "3.2.4",
"typescript": "5.9.2",
"vitest": "3.2.4"
},
"dependencies": {
"@fastify/cors": "^11.1.0",
"@fastify/swagger": "^9.5.1",
"@morten-olsen/fluxcurrent-core": "workspace:*",
"@scalar/fastify-api-reference": "^1.35.1",
"fastify": "^5.6.0",
"fastify-sse-v2": "^4.2.1",
"fastify-type-provider-zod": "^6.0.0",
"zod": "^4.1.5"
},
"name": "@morten-olsen/fluxcurrent-server",
"version": "1.0.0"
}

View File

@@ -0,0 +1,45 @@
import fastify from 'fastify';
import fastifySwagger from '@fastify/swagger';
import fastifyApiReference from '@scalar/fastify-api-reference';
import { jsonSchemaTransform, serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
import type { Services } from '@morten-olsen/fluxcurrent-core/utils/services.ts';
import { FastifySSEPlugin } from 'fastify-sse-v2';
import { searchEndpoint } from './endpoints/endpoints.search.ts';
import { documentsEndpoint } from './endpoints/endpoints.documents.ts';
type CreateApiOptions = {
services: Services;
};
const createApi = async (options: CreateApiOptions) => {
const app = fastify();
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
app.register(fastifySwagger, {
openapi: {
info: {
title: 'SampleApi',
description: 'Sample backend service',
version: '1.0.0',
},
servers: [],
},
transform: jsonSchemaTransform,
});
await app.register(fastifyApiReference, {
routePrefix: '/docs',
});
await app.register(FastifySSEPlugin);
await app.register(searchEndpoint, { services: options.services, prefix: '/search' });
await app.register(documentsEndpoint, { services: options.services, prefix: '/documents' });
await app.ready();
return app;
};
export { createApi };

View File

@@ -0,0 +1,24 @@
import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
import { z } from 'zod/v4';
import type { Services } from '@morten-olsen/fluxcurrent-core/utils/services.ts';
import { DocumentsService } from '@morten-olsen/fluxcurrent-core/services/documents/documents.ts';
import { documentUpsertSchema } from '@morten-olsen/fluxcurrent-core/services/documents/documents.schemas.ts';
const documentsEndpoint: FastifyPluginAsyncZod<{ services: Services }> = async (fastify, { services }) => {
fastify.route({
method: 'POST',
url: '',
schema: {
body: z.object({
document: documentUpsertSchema,
}),
},
handler: async (req, res) => {
const documentsService = services.get(DocumentsService);
const documents = await documentsService.upsert(req.body.document);
res.send(documents);
},
});
};
export { documentsEndpoint };

View File

@@ -0,0 +1,62 @@
import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
import { z } from 'zod/v4';
import type { Services } from '@morten-olsen/fluxcurrent-core/utils/services.ts';
import { parseDSL } from '@morten-olsen/fluxcurrent-core/services/documents/documents.dsl.ts';
import { DocumentsService } from '@morten-olsen/fluxcurrent-core/services/documents/documents.ts';
import {
documentSchema,
type DocumentUpsertEvent,
} from '@morten-olsen/fluxcurrent-core/services/documents/documents.schemas.ts';
import { filterDocument } from '@morten-olsen/fluxcurrent-core/services/documents/documents.filter.ts';
const searchEndpoint: FastifyPluginAsyncZod<{ services: Services }> = async (fastify, { services }) => {
fastify.route({
method: 'POST',
url: '',
schema: {
body: z.object({
query: z.string().optional(),
}),
response: {
200: z.array(documentSchema),
},
},
handler: async (req, res) => {
const query = req.body.query ? parseDSL(req.body.query) : {};
const documentsService = services.get(DocumentsService);
const documents = await documentsService.search(query);
res.send(documents);
},
});
fastify.route({
method: 'GET',
url: '/stream',
schema: {
querystring: z.object({
query: z.string().optional(),
}),
},
handler: async (req, res) => {
const query = req.query.query ? parseDSL(req.query.query) : {};
const documentsService = services.get(DocumentsService);
res.sse({ event: 'init' });
const documents = await documentsService.search(query);
for (const document of documents) {
res.sse({ event: 'upsert', data: JSON.stringify(document) });
}
const listener = (event: DocumentUpsertEvent) => {
if (query.meta && !filterDocument(query.meta, event.document)) {
return;
}
res.sse({ event: 'upsert', data: JSON.stringify(event) });
};
documentsService.on('upsert', listener);
req.socket.on('close', () => {
documentsService.off('upsert', listener);
});
},
});
};
export { searchEndpoint };

View File

@@ -0,0 +1,664 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { webhookConfigSchema, mqttConfigSchema, configSchema } from './schemas.config.ts';
describe('Configuration Schemas', () => {
// Store original environment variables to restore after tests
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
originalEnv = { ...process.env };
});
afterEach(() => {
process.env = originalEnv;
});
describe('Environment Variable Substitution', () => {
it('should substitute environment variables in strings', () => {
process.env.TEST_URL = 'https://example.com';
process.env.TEST_SECRET = 'secret123';
const config = {
type: 'webhook' as const,
url: '${TEST_URL}/webhook',
secret: 'Bearer ${TEST_SECRET}',
};
const result = webhookConfigSchema.parse(config);
expect(result.url).toBe('https://example.com/webhook');
expect(result.secret).toBe('Bearer secret123');
});
it('should handle missing environment variables by substituting empty string', () => {
const config = {
type: 'webhook' as const,
url: '${MISSING_VAR}/webhook',
};
const result = webhookConfigSchema.parse(config);
expect(result.url).toBe('/webhook');
});
it('should handle multiple environment variables in a single string', () => {
process.env.HOST = 'localhost';
process.env.PORT = '3000';
process.env.PATH_PREFIX = '/api/v1';
const config = {
type: 'webhook' as const,
url: 'http://${HOST}:${PORT}${PATH_PREFIX}/webhook',
};
const result = webhookConfigSchema.parse(config);
expect(result.url).toBe('http://localhost:3000/api/v1/webhook');
});
it('should leave strings without substitution patterns unchanged', () => {
const config = {
type: 'webhook' as const,
url: 'https://static.example.com/webhook',
};
const result = webhookConfigSchema.parse(config);
expect(result.url).toBe('https://static.example.com/webhook');
});
it('should handle empty strings', () => {
const config = {
type: 'webhook' as const,
url: '',
};
const result = webhookConfigSchema.parse(config);
expect(result.url).toBe('');
});
});
describe('Webhook Configuration', () => {
it('should parse valid webhook configuration with all fields', () => {
const config = {
type: 'webhook' as const,
url: 'https://example.com/webhook',
secret: 'secret123',
};
const result = webhookConfigSchema.parse(config);
expect(result).toEqual(config);
});
it('should parse webhook configuration without optional secret', () => {
const config = {
type: 'webhook' as const,
url: 'https://example.com/webhook',
};
const result = webhookConfigSchema.parse(config);
expect(result).toEqual(config);
expect(result.secret).toBeUndefined();
});
it('should reject invalid webhook type', () => {
const config = {
type: 'invalid',
url: 'https://example.com/webhook',
};
expect(() => webhookConfigSchema.parse(config)).toThrow();
});
it('should reject webhook configuration without required url', () => {
const config = {
type: 'webhook' as const,
};
expect(() => webhookConfigSchema.parse(config)).toThrow();
});
});
describe('MQTT Configuration', () => {
it('should parse valid MQTT configuration with all fields', () => {
const config = {
type: 'mqtt' as const,
url: 'mqtt://broker.example.com:1883',
username: 'user123',
password: 'pass123',
baseTopic: 'notifications',
};
const result = mqttConfigSchema.parse(config);
expect(result).toEqual(config);
});
it('should parse MQTT configuration with only required fields', () => {
const config = {
type: 'mqtt' as const,
url: 'mqtt://broker.example.com:1883',
baseTopic: 'notifications',
};
const result = mqttConfigSchema.parse(config);
expect(result).toEqual(config);
expect(result.username).toBeUndefined();
expect(result.password).toBeUndefined();
});
it('should handle environment variable substitution in MQTT fields', () => {
process.env.MQTT_HOST = 'mqtt.example.com';
process.env.MQTT_USER = 'mqttuser';
process.env.MQTT_PASS = 'mqttpass';
process.env.MQTT_TOPIC = 'app/notifications';
const config = {
type: 'mqtt' as const,
url: 'mqtt://${MQTT_HOST}:1883',
username: '${MQTT_USER}',
password: '${MQTT_PASS}',
baseTopic: '${MQTT_TOPIC}',
};
const result = mqttConfigSchema.parse(config);
expect(result.url).toBe('mqtt://mqtt.example.com:1883');
expect(result.username).toBe('mqttuser');
expect(result.password).toBe('mqttpass');
expect(result.baseTopic).toBe('app/notifications');
});
it('should reject invalid MQTT type', () => {
const config = {
type: 'invalid',
url: 'mqtt://broker.example.com:1883',
baseTopic: 'notifications',
};
expect(() => mqttConfigSchema.parse(config)).toThrow();
});
it('should reject MQTT configuration without required fields', () => {
const config = {
type: 'mqtt' as const,
url: 'mqtt://broker.example.com:1883',
// missing baseTopic
};
expect(() => mqttConfigSchema.parse(config)).toThrow();
});
});
describe('Database Configuration', () => {
describe('PGLite Configuration', () => {
it('should parse valid PGLite configuration with path', () => {
const config = {
database: {
type: 'pg-lite' as const,
path: './data/db.sqlite',
},
};
const result = configSchema.parse(config);
expect(result.database).toEqual(config.database);
});
it('should parse PGLite configuration without optional path', () => {
const config = {
database: {
type: 'pg-lite' as const,
},
};
const result = configSchema.parse(config);
expect(result.database.type).toBe('pg-lite');
expect(result.database.path).toBeUndefined();
});
it('should handle environment variable substitution in PGLite path', () => {
process.env.DB_PATH = '/var/data/app.db';
const config = {
database: {
type: 'pg-lite' as const,
path: '${DB_PATH}',
},
};
const result = configSchema.parse(config);
expect(result.database.path).toBe('/var/data/app.db');
});
});
describe('PostgreSQL Configuration', () => {
it('should parse valid PostgreSQL configuration with all fields', () => {
const config = {
database: {
type: 'pg' as const,
host: 'localhost',
port: '5432',
user: 'postgres',
password: 'password123',
database: 'myapp',
},
};
const result = configSchema.parse(config);
expect(result.database).toEqual({
...config.database,
port: 5432, // should be transformed to number
});
});
it('should parse PostgreSQL configuration without optional port (defaults to 5432)', () => {
const config = {
database: {
type: 'pg' as const,
host: 'localhost',
user: 'postgres',
password: 'password123',
database: 'myapp',
},
};
const result = configSchema.parse(config);
expect(result.database.port).toBe(5432);
});
it('should transform port string to number', () => {
const config = {
database: {
type: 'pg' as const,
host: 'localhost',
port: '3306',
user: 'postgres',
password: 'password123',
database: 'myapp',
},
};
const result = configSchema.parse(config);
expect(result.database.port).toBe(3306);
expect(typeof result.database.port).toBe('number');
});
it('should handle environment variable substitution in PostgreSQL fields', () => {
process.env.DB_HOST = 'prod-db.example.com';
process.env.DB_USER = 'app_user';
process.env.DB_PASS = 'secure_password';
process.env.DB_NAME = 'production_db';
process.env.DB_PORT = '5433';
const config = {
database: {
type: 'pg' as const,
host: '${DB_HOST}',
port: '${DB_PORT}',
user: '${DB_USER}',
password: '${DB_PASS}',
database: '${DB_NAME}',
},
};
const result = configSchema.parse(config);
expect(result.database.host).toBe('prod-db.example.com');
expect(result.database.port).toBe(5433);
expect(result.database.user).toBe('app_user');
expect(result.database.password).toBe('secure_password');
expect(result.database.database).toBe('production_db');
});
it('should reject PostgreSQL configuration with missing required fields', () => {
const config = {
database: {
type: 'pg' as const,
host: 'localhost',
// missing user, password, database
},
};
expect(() => configSchema.parse(config)).toThrow();
});
});
it('should use PGLite as default when no database config is provided', () => {
const config = {};
const result = configSchema.parse(config);
expect(result.database).toEqual({ type: 'pg-lite' });
});
});
describe('OIDC Configuration', () => {
it('should parse valid OIDC configuration', () => {
const config = {
oidc: {
type: 'oidc' as const,
issuer: 'https://auth.example.com',
clientId: 'client123',
clientSecret: 'secret123',
},
};
const result = configSchema.parse(config);
expect(result.oidc).toEqual(config.oidc);
});
it('should handle environment variable substitution in OIDC fields', () => {
process.env.OIDC_ISSUER = 'https://login.provider.com';
process.env.OIDC_CLIENT_ID = 'my-app-client';
process.env.OIDC_CLIENT_SECRET = 'very-secret-key';
const config = {
oidc: {
type: 'oidc' as const,
issuer: '${OIDC_ISSUER}',
clientId: '${OIDC_CLIENT_ID}',
clientSecret: '${OIDC_CLIENT_SECRET}',
},
};
const result = configSchema.parse(config);
expect(result.oidc!.issuer).toBe('https://login.provider.com');
expect(result.oidc!.clientId).toBe('my-app-client');
expect(result.oidc!.clientSecret).toBe('very-secret-key');
});
it('should be optional and undefined when not provided', () => {
const config = {};
const result = configSchema.parse(config);
expect(result.oidc).toBeUndefined();
});
it('should reject OIDC configuration with missing required fields', () => {
const config = {
oidc: {
type: 'oidc' as const,
issuer: 'https://auth.example.com',
// missing clientId and clientSecret
},
};
expect(() => configSchema.parse(config)).toThrow();
});
});
describe('Notifications Configuration', () => {
it('should parse empty notifications array as default', () => {
const config = {};
const result = configSchema.parse(config);
expect(result.notifications).toEqual([]);
});
it('should parse array with webhook notifications', () => {
const config = {
notifications: [
{
type: 'webhook' as const,
url: 'https://hook1.example.com',
secret: 'secret1',
},
{
type: 'webhook' as const,
url: 'https://hook2.example.com',
},
],
};
const result = configSchema.parse(config);
expect(result.notifications).toEqual(config.notifications);
});
it('should parse array with MQTT notifications', () => {
const config = {
notifications: [
{
type: 'mqtt' as const,
url: 'mqtt://broker1.example.com',
baseTopic: 'app1/notifications',
username: 'user1',
password: 'pass1',
},
{
type: 'mqtt' as const,
url: 'mqtt://broker2.example.com',
baseTopic: 'app2/notifications',
},
],
};
const result = configSchema.parse(config);
expect(result.notifications).toEqual(config.notifications);
});
it('should parse mixed webhook and MQTT notifications', () => {
const config = {
notifications: [
{
type: 'webhook' as const,
url: 'https://webhook.example.com',
secret: 'webhook-secret',
},
{
type: 'mqtt' as const,
url: 'mqtt://mqtt.example.com',
baseTopic: 'notifications',
username: 'mqttuser',
},
],
};
const result = configSchema.parse(config);
expect(result.notifications).toEqual(config.notifications);
});
it('should reject invalid notification types', () => {
const config = {
notifications: [
{
type: 'invalid',
url: 'https://example.com',
},
],
};
expect(() => configSchema.parse(config)).toThrow();
});
});
describe('Complete Configuration', () => {
it('should parse complete configuration with all components', () => {
process.env.DB_HOST = 'db.example.com';
process.env.WEBHOOK_SECRET = 'webhook123';
process.env.OIDC_ISSUER = 'https://auth.example.com';
const config = {
database: {
type: 'pg' as const,
host: '${DB_HOST}',
port: '5432',
user: 'postgres',
password: 'password',
database: 'myapp',
},
oidc: {
type: 'oidc' as const,
issuer: '${OIDC_ISSUER}',
clientId: 'client123',
clientSecret: 'secret123',
},
notifications: [
{
type: 'webhook' as const,
url: 'https://webhook.example.com',
secret: '${WEBHOOK_SECRET}',
},
{
type: 'mqtt' as const,
url: 'mqtt://mqtt.example.com',
baseTopic: 'notifications',
},
],
};
const result = configSchema.parse(config);
expect(result.database.type).toBe('pg');
expect(result.database.host).toBe('db.example.com');
expect(result.database.port).toBe(5432);
expect(result.oidc?.issuer).toBe('https://auth.example.com');
expect(result.notifications).toHaveLength(2);
expect(result.notifications[0].type).toBe('webhook');
expect((result.notifications[0] as any).secret).toBe('webhook123');
expect(result.notifications[1].type).toBe('mqtt');
});
it('should parse minimal configuration with defaults', () => {
const config = {};
const result = configSchema.parse(config);
expect(result.database).toEqual({ type: 'pg-lite' });
expect(result.oidc).toBeUndefined();
expect(result.notifications).toEqual([]);
});
it('should override defaults when values are provided', () => {
const config = {
database: {
type: 'pg' as const,
host: 'localhost',
user: 'postgres',
password: 'password',
database: 'test',
},
notifications: [
{
type: 'webhook' as const,
url: 'https://test.com',
},
],
};
const result = configSchema.parse(config);
expect(result.database.type).toBe('pg');
expect(result.notifications).toHaveLength(1);
});
});
describe('Error Cases', () => {
it('should reject configuration with invalid database type', () => {
const config = {
database: {
type: 'invalid-db-type',
host: 'localhost',
},
};
expect(() => configSchema.parse(config)).toThrow();
});
it('should reject configuration with invalid notification structure', () => {
const config = {
notifications: [
{
type: 'webhook',
// missing required url
},
],
};
expect(() => configSchema.parse(config)).toThrow();
});
it('should reject non-array notifications', () => {
const config = {
notifications: {
type: 'webhook',
url: 'https://example.com',
},
};
expect(() => configSchema.parse(config)).toThrow();
});
it('should reject invalid OIDC type', () => {
const config = {
oidc: {
type: 'invalid-oidc',
issuer: 'https://example.com',
clientId: 'client',
clientSecret: 'secret',
},
};
expect(() => configSchema.parse(config)).toThrow();
});
});
describe('Edge Cases', () => {
it('should handle environment variables with special characters', () => {
process.env.SPECIAL_VAR = 'value-with-dashes_and_underscores.dots';
const config = {
notifications: [
{
type: 'webhook' as const,
url: 'https://example.com/${SPECIAL_VAR}',
},
],
};
const result = configSchema.parse(config);
expect((result.notifications[0] as any).url).toBe('https://example.com/value-with-dashes_and_underscores.dots');
});
it('should handle nested environment variable patterns', () => {
process.env.HOST = 'example.com';
const config = {
notifications: [
{
type: 'webhook' as const,
url: 'https://${HOST}/${HOST}/webhook',
},
],
};
const result = configSchema.parse(config);
expect((result.notifications[0] as any).url).toBe('https://example.com/example.com/webhook');
});
it('should handle port transformation with invalid number', () => {
const config = {
database: {
type: 'pg' as const,
host: 'localhost',
port: 'invalid-port',
user: 'postgres',
password: 'password',
database: 'test',
},
};
const result = configSchema.parse(config);
expect(result.database.port).toBeNaN();
});
it('should handle empty environment variable names in substitution', () => {
const config = {
notifications: [
{
type: 'webhook' as const,
url: 'https://example.com/${}',
},
],
};
const result = configSchema.parse(config);
// Empty braces ${} don't match the regex \w+ so they remain unchanged
expect((result.notifications[0] as any).url).toBe('https://example.com/${}');
});
});
});

View File

@@ -0,0 +1,57 @@
import { z } from 'zod';
const stringWithEnvSubstitutionSchema = z.string().transform((value) => {
if (!value) {
return value;
}
return value.replace(/\${(\w+)}/g, (_, p1) => {
return process.env[p1] || '';
});
});
const webhookConfigSchema = z.object({
type: z.literal('webhook'),
url: stringWithEnvSubstitutionSchema,
secret: stringWithEnvSubstitutionSchema.optional(),
});
const mqttConfigSchema = z.object({
type: z.literal('mqtt'),
url: stringWithEnvSubstitutionSchema,
username: stringWithEnvSubstitutionSchema.optional(),
password: stringWithEnvSubstitutionSchema.optional(),
baseTopic: stringWithEnvSubstitutionSchema,
});
const oidcConfigSchema = z.object({
type: z.literal('oidc'),
issuer: stringWithEnvSubstitutionSchema,
clientId: stringWithEnvSubstitutionSchema,
clientSecret: stringWithEnvSubstitutionSchema,
});
const notificationsConfigSchema = z.union([webhookConfigSchema, mqttConfigSchema]);
const pgLiteConfigSchema = z.object({
type: z.literal('pg-lite'),
path: stringWithEnvSubstitutionSchema.optional(),
});
const pgConfigSchema = z.object({
type: z.literal('pg'),
host: stringWithEnvSubstitutionSchema,
port: stringWithEnvSubstitutionSchema.optional().transform((value) => (value ? parseInt(value) : 5432)),
user: stringWithEnvSubstitutionSchema,
password: stringWithEnvSubstitutionSchema,
database: stringWithEnvSubstitutionSchema,
});
const databaseConfigSchema = z.union([pgLiteConfigSchema, pgConfigSchema]);
const configSchema = z.object({
database: databaseConfigSchema.default({ type: 'pg-lite' }),
oidc: oidcConfigSchema.optional(),
notifications: z.array(notificationsConfigSchema).default([]),
});
export { webhookConfigSchema, mqttConfigSchema, configSchema };

View File

@@ -0,0 +1,8 @@
import { Services } from '@morten-olsen/fluxcurrent-core/utils/services.ts';
import { createApi } from './api/api.ts';
const services = new Services();
const api = await createApi({ services });
await api.listen({ port: 3000 });

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"outDir": "./dist",
"paths": {
"#root/*": ["./src"]
}
},
"include": [
"src/**/*.ts"
],
"extends": "@morten-olsen/fluxcurrent-configs/tsconfig.json"
}

View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
import { getAliases } from '@morten-olsen/fluxcurrent-tests/vitest';
// eslint-disable-next-line import/no-default-export
export default defineConfig(async () => {
const aliases = await getAliases();
return {
resolve: {
alias: aliases,
},
};
});