diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..103174d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +node_modules/ +.turbo/ +/coverage/ \ No newline at end of file diff --git a/.u8.json b/.u8.json index 6182604..e7a1cf5 100644 --- a/.u8.json +++ b/.u8.json @@ -36,6 +36,16 @@ "packageVersion": "1.0.0", "packageName": "core" } + }, + { + "timestamp": "2025-09-09T11:45:37.369Z", + "template": "pkg", + "values": { + "monoRepo": true, + "packagePrefix": "@morten-olsen/fluxcurrent-", + "packageVersion": "1.0.0", + "packageName": "server" + } } ] } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a2aa189 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM node:23-alpine AS base +ENV NODE_ENV=production + +FROM base AS builder +RUN apk update +RUN apk add --no-cache libc6-compat +WORKDIR /app +RUN corepack enable +RUN npm install -g turbo +COPY . . +RUN turbo prune @morten-olsen/fluxcurrent-server --docker + +FROM base AS installer +RUN apk update +RUN apk add --no-cache libc6-compat +RUN corepack enable +WORKDIR /app + +COPY --from=builder /app/out/json/ . +RUN pnpm install --prod --frozen-lockfile + +COPY --from=builder /app/out/full/ . + +FROM base AS runner +WORKDIR /app + +# Don't run production as root +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 fluxcurrent +COPY --from=installer /app/ ./ +USER fluxcurrent +CMD ["node", "--no-warnings", "packages/server/src/start.ts"] \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..8f92f85 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,9 @@ +name: fluxcurrent + +services: + server: + build: + context: . + dockerfile: Dockerfile + ports: + - 3000:3000 diff --git a/packages/core/package.json b/packages/core/package.json index b8a8d8a..99a8d58 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/services/database/database.ts b/packages/core/src/services/database/database.ts index 7e64426..c0e361a 100644 --- a/packages/core/src/services/database/database.ts +++ b/packages/core/src/services/database/database.ts @@ -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'; diff --git a/packages/core/src/services/documents/documents.dsl.test.ts b/packages/core/src/services/documents/documents.dsl.test.ts new file mode 100644 index 0000000..0b9d040 --- /dev/null +++ b/packages/core/src/services/documents/documents.dsl.test.ts @@ -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'], + }); + }); + }); +}); diff --git a/packages/core/src/services/documents/documents.dsl.ts b/packages/core/src/services/documents/documents.dsl.ts new file mode 100644 index 0000000..7f499a7 --- /dev/null +++ b/packages/core/src/services/documents/documents.dsl.ts @@ -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 = {}; + + 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 = {}; + + 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)}`); + } +} diff --git a/packages/core/src/services/documents/documents.filter.ts b/packages/core/src/services/documents/documents.filter.ts new file mode 100644 index 0000000..cf1ef77 --- /dev/null +++ b/packages/core/src/services/documents/documents.filter.ts @@ -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); +}; diff --git a/packages/core/src/services/documents/documents.test.ts b/packages/core/src/services/documents/documents.query.test.ts similarity index 87% rename from packages/core/src/services/documents/documents.test.ts rename to packages/core/src/services/documents/documents.query.test.ts index 3ecd1ec..81efa46 100644 --- a/packages/core/src/services/documents/documents.test.ts +++ b/packages/core/src/services/documents/documents.query.test.ts @@ -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', diff --git a/packages/core/src/services/documents/documents.query.ts b/packages/core/src/services/documents/documents.query.ts new file mode 100644 index 0000000..5718758 --- /dev/null +++ b/packages/core/src/services/documents/documents.query.ts @@ -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 => { + 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 => { + 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 }; diff --git a/packages/core/src/services/documents/documents.schemas.ts b/packages/core/src/services/documents/documents.schemas.ts index d68408e..b4b7338 100644 --- a/packages/core/src/services/documents/documents.schemas.ts +++ b/packages/core/src/services/documents/documents.schemas.ts @@ -12,7 +12,7 @@ const documentSchema = z.object({ data: z.record(z.string(), z.unknown()), }); -type Document = z.infer; +type Document = z.infer; const documentUpsertSchema = documentSchema.omit({ createdAt: true, @@ -89,5 +89,19 @@ const documentSearchOptionsSchema = z.object({ type DocumentSearchOptions = z.infer; -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; + +export type { Document, DocumentUpsert, MetaFilter, MetaCondition, DocumentSearchOptions, DocumentUpsertEvent }; +export { + documentSchema, + documentUpsertSchema, + metaFilterSchema, + metaConditionSchema, + documentSearchOptionsSchema, + documentUpsertEventSchema, +}; diff --git a/packages/core/src/services/documents/documents.ts b/packages/core/src/services/documents/documents.ts index 9d1911c..f6e485f 100644 --- a/packages/core/src/services/documents/documents.ts +++ b/packages/core/src/services/documents/documents.ts @@ -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 { #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(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 }; diff --git a/packages/core/src/utils/tests.ts b/packages/core/src/utils/tests.ts new file mode 100644 index 0000000..63cebe7 --- /dev/null +++ b/packages/core/src/utils/tests.ts @@ -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, 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, 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(label: string, fn: () => T | Promise): Promise { + const startTime = performance.now(); + try { + return await fn(); + } finally { + const endTime = performance.now(); + const duration = endTime - startTime; + console.log(`⏱️ ${label}: ${duration.toFixed(2)}ms`); + } +} diff --git a/packages/server/.gitignore b/packages/server/.gitignore new file mode 100644 index 0000000..8511d52 --- /dev/null +++ b/packages/server/.gitignore @@ -0,0 +1,4 @@ +/node_modules/ +/dist/ +/coverage/ +/.env diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 0000000..c3caa8a --- /dev/null +++ b/packages/server/package.json @@ -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" +} \ No newline at end of file diff --git a/packages/server/src/api/api.ts b/packages/server/src/api/api.ts new file mode 100644 index 0000000..a4075c3 --- /dev/null +++ b/packages/server/src/api/api.ts @@ -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 }; diff --git a/packages/server/src/api/endpoints/endpoints.documents.ts b/packages/server/src/api/endpoints/endpoints.documents.ts new file mode 100644 index 0000000..f402471 --- /dev/null +++ b/packages/server/src/api/endpoints/endpoints.documents.ts @@ -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 }; diff --git a/packages/server/src/api/endpoints/endpoints.search.ts b/packages/server/src/api/endpoints/endpoints.search.ts new file mode 100644 index 0000000..1209a83 --- /dev/null +++ b/packages/server/src/api/endpoints/endpoints.search.ts @@ -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 }; diff --git a/packages/server/src/schemas/schemas.config.test.ts b/packages/server/src/schemas/schemas.config.test.ts new file mode 100644 index 0000000..e9a6e79 --- /dev/null +++ b/packages/server/src/schemas/schemas.config.test.ts @@ -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/${}'); + }); + }); +}); diff --git a/packages/server/src/schemas/schemas.config.ts b/packages/server/src/schemas/schemas.config.ts new file mode 100644 index 0000000..abea9d8 --- /dev/null +++ b/packages/server/src/schemas/schemas.config.ts @@ -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 }; diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts new file mode 100644 index 0000000..0481b6a --- /dev/null +++ b/packages/server/src/start.ts @@ -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 }); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 0000000..382e78a --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "paths": { + "#root/*": ["./src"] + } + }, + "include": [ + "src/**/*.ts" + ], + "extends": "@morten-olsen/fluxcurrent-configs/tsconfig.json" +} diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts new file mode 100644 index 0000000..1c32efe --- /dev/null +++ b/packages/server/vitest.config.ts @@ -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, + }, + }; +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04d162c..7086689 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 6.0.9(@pnpm/logger@5.2.0) '@vitest/coverage-v8': specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@24.3.1)) + version: 3.2.4(vitest@3.2.4(@types/node@24.3.1)(yaml@2.8.1)) eslint: specifier: 9.35.0 version: 9.35.0 @@ -46,12 +46,15 @@ importers: version: 8.43.0(eslint@9.35.0)(typescript@5.9.2) vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@24.3.1) + version: 3.2.4(@types/node@24.3.1)(yaml@2.8.1) packages/configs: {} packages/core: dependencies: + eventemitter3: + specifier: ^5.0.1 + version: 5.0.1 knex: specifier: ^3.1.0 version: 3.1.0(pg@8.16.3) @@ -76,13 +79,59 @@ importers: version: 24.3.1 '@vitest/coverage-v8': specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@24.3.1)) + version: 3.2.4(vitest@3.2.4(@types/node@24.3.1)(yaml@2.8.1)) typescript: specifier: 5.9.2 version: 5.9.2 vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@24.3.1) + version: 3.2.4(@types/node@24.3.1)(yaml@2.8.1) + + packages/server: + dependencies: + '@fastify/cors': + specifier: ^11.1.0 + version: 11.1.0 + '@fastify/swagger': + specifier: ^9.5.1 + version: 9.5.1 + '@morten-olsen/fluxcurrent-core': + specifier: workspace:* + version: link:../core + '@scalar/fastify-api-reference': + specifier: ^1.35.1 + version: 1.35.1(typescript@5.9.2) + fastify: + specifier: ^5.6.0 + version: 5.6.0 + fastify-sse-v2: + specifier: ^4.2.1 + version: 4.2.1(fastify@5.6.0) + fastify-type-provider-zod: + specifier: ^6.0.0 + version: 6.0.0(@fastify/swagger@9.5.1)(fastify@5.6.0)(openapi-types@12.1.3)(zod@4.1.5) + zod: + specifier: ^4.1.5 + version: 4.1.5 + devDependencies: + '@morten-olsen/fluxcurrent-configs': + specifier: workspace:* + version: link:../configs + '@morten-olsen/fluxcurrent-tests': + specifier: workspace:* + version: link:../tests + '@types/node': + specifier: 24.3.1 + version: 24.3.1 + '@vitest/coverage-v8': + specifier: 3.2.4 + version: 3.2.4(vitest@3.2.4(@types/node@24.3.1)(yaml@2.8.1)) + typescript: + specifier: 5.9.2 + version: 5.9.2 + vitest: + specifier: 3.2.4 + version: 3.2.4(@types/node@24.3.1)(yaml@2.8.1) packages/tests: dependencies: @@ -98,13 +147,13 @@ importers: version: 24.3.1 '@vitest/coverage-v8': specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@24.3.1)) + version: 3.2.4(vitest@3.2.4(@types/node@24.3.1)(yaml@2.8.1)) typescript: specifier: 5.9.2 version: 5.9.2 vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@24.3.1) + version: 3.2.4(@types/node@24.3.1)(yaml@2.8.1) packages: @@ -334,6 +383,30 @@ packages: resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fastify/ajv-compiler@4.0.2': + resolution: {integrity: sha512-Rkiu/8wIjpsf46Rr+Fitd3HRP+VsxUFDDeag0hs9L0ksfnwx2g7SPQQTFL0E8Qv+rfXzQOxBJnjUB9ITUDjfWQ==} + + '@fastify/cors@11.1.0': + resolution: {integrity: sha512-sUw8ed8wP2SouWZTIbA7V2OQtMNpLj2W6qJOYhNdcmINTu6gsxVYXjQiM9mdi8UUDlcoDDJ/W2syPo1WB2QjYA==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.0': + resolution: {integrity: sha512-kJExsp4JCms7ipzg7SJ3y8DwmePaELHxKYtg+tZow+k0znUTf3cb+npgyqm8+ATZOdmfgfydIebPDWM172wfyA==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.0.0': + resolution: {integrity: sha512-37qVVA1qZ5sgH7KpHkkC4z9SK6StIsIcOmpjvMPXNb3vx2GQxhZocogVYbr2PbbeLCQxYIPDok307xEvRZOzGA==} + + '@fastify/swagger@9.5.1': + resolution: {integrity: sha512-EGjYLA7vDmCPK7XViAYMF6y4+K3XUy5soVTVxsyXolNe/Svb4nFQxvtuQvvoQb2Gzc9pxiF3+ZQN/iZDHhKtTg==} + '@gwhitney/detect-indent@7.0.1': resolution: {integrity: sha512-7bQW+gkKa2kKZPeJf6+c6gFK9ARxQfn+FKy9ScTBppyKRWH2KzsmweXUoklqeEiHiNVWaeP5csIdsNq6w7QhzA==} engines: {node: '>=12.20'} @@ -644,6 +717,34 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@scalar/core@0.3.16': + resolution: {integrity: sha512-gZLKu6yzc9Md8X29OX7hN/Cgl95ReHK+t/HXbaT3O8hsbCc9JyhEZiKFpTfCH9vcgyYQ91fcq9w8tT4BVpFJUw==} + engines: {node: '>=20'} + + '@scalar/fastify-api-reference@1.35.1': + resolution: {integrity: sha512-JdMcI9w0/1y0jViL+0/EJ1H21WvI52xNrJn9xvPw4zHUrvjVhZs2p70vmXALUuo+WT7zOFDJGrGc/FHW4atjaQ==} + engines: {node: '>=20'} + + '@scalar/helpers@0.0.9': + resolution: {integrity: sha512-paQArJ1zVHkDpK+hXES0ZSg4Cj77zrQSQ3wOVI6xjQ8jTVSVVfbHwkxtM1Me4fbLY7EkkVj8tX21uAw2XzJ51w==} + engines: {node: '>=20'} + + '@scalar/json-magic@0.4.0': + resolution: {integrity: sha512-P4xfYSQB+rcJKDfxNC+T9cms4VNp9aMW0wrVcSKEUJLap/GDUFFUbMoVZMYLUcCehMta9KGM1SsN37f9FIBNow==} + engines: {node: '>=20'} + + '@scalar/openapi-parser@0.20.2': + resolution: {integrity: sha512-CxrEfvfGHHxyuOTdH+PLGPQAJWfLKU3NCNslPz9iHpppxVyK5S7MPB1pLZYIFH+pcohiPx8LmirUpspolKHBwQ==} + engines: {node: '>=20'} + + '@scalar/openapi-types@0.3.7': + resolution: {integrity: sha512-QHSvHBVDze3+dUwAhIGq6l1iOev4jdoqdBK7QpfeN1Q4h+6qpVEw3EEqBiH0AXUSh/iWwObBv4uMgfIx0aNZ5g==} + engines: {node: '>=20'} + + '@scalar/types@0.2.15': + resolution: {integrity: sha512-x2aCNmkDqr3VXUHjw7wPXK9KZwHbGGMs4NuxJIzy9MbAxUS9li8HXGG0K82Q5fDm47SAM+68z0/tnWkJpu+kzg==} + engines: {node: '>=20'} + '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} @@ -762,11 +863,43 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vue/compiler-core@3.5.21': + resolution: {integrity: sha512-8i+LZ0vf6ZgII5Z9XmUvrCyEzocvWT+TeR2VBUVlzIH6Tyv57E20mPZ1bCS+tbejgUgmjrEh7q/0F0bibskAmw==} + + '@vue/compiler-dom@3.5.21': + resolution: {integrity: sha512-jNtbu/u97wiyEBJlJ9kmdw7tAr5Vy0Aj5CgQmo+6pxWNQhXZDPsRr1UWPN4v3Zf82s2H3kF51IbzZ4jMWAgPlQ==} + + '@vue/compiler-sfc@3.5.21': + resolution: {integrity: sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ==} + + '@vue/compiler-ssr@3.5.21': + resolution: {integrity: sha512-vKQ5olH5edFZdf5ZrlEgSO1j1DMA4u23TVK5XR1uMhvwnYvVdDF0nHXJUblL/GvzlShQbjhZZ2uvYmDlAbgo9w==} + + '@vue/reactivity@3.5.21': + resolution: {integrity: sha512-3ah7sa+Cwr9iiYEERt9JfZKPw4A2UlbY8RbbnH2mGCE8NwHkhmlZt2VsH0oDA3P08X3jJd29ohBDtX+TbD9AsA==} + + '@vue/runtime-core@3.5.21': + resolution: {integrity: sha512-+DplQlRS4MXfIf9gfD1BOJpk5RSyGgGXD/R+cumhe8jdjUcq/qlxDawQlSI8hCKupBlvM+3eS1se5xW+SuNAwA==} + + '@vue/runtime-dom@3.5.21': + resolution: {integrity: sha512-3M2DZsOFwM5qI15wrMmNF5RJe1+ARijt2HM3TbzBbPSuBHOQpoidE+Pa+XEaVN+czbHf81ETRoG1ltztP2em8w==} + + '@vue/server-renderer@3.5.21': + resolution: {integrity: sha512-qr8AqgD3DJPJcGvLcJKQo2tAc8OnXRcfxhOJCPF+fcfn5bBGz7VCcO7t+qETOPxpWK1mgysXvVT/j+xWaHeMWA==} + peerDependencies: + vue: 3.5.21 + + '@vue/shared@3.5.21': + resolution: {integrity: sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==} + '@zkochan/which@2.0.3': resolution: {integrity: sha512-C1ReN7vt2/2O0fyTsx5xnbQuxBrmG5NMSbcIkPKCCfCTJgpZBsuRYzFXHj3nVq8vTfK7vxHUmzfCpSHgO7j4rg==} engines: {node: '>= 8'} hasBin: true + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -777,9 +910,28 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-align@3.0.1: resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} @@ -853,13 +1005,23 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + avvio@9.1.0: + resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -881,6 +1043,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -965,6 +1130,10 @@ packages: config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -973,6 +1142,9 @@ packages: resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} engines: {node: '>=8'} + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + data-uri-to-buffer@2.0.2: resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==} @@ -1032,6 +1204,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.0.4: resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} engines: {node: '>=8'} @@ -1053,6 +1229,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} @@ -1196,6 +1376,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -1203,6 +1386,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -1211,12 +1397,18 @@ packages: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -1224,12 +1416,47 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-json-stringify@6.0.1: + resolution: {integrity: sha512-s7SJE83QKBZwg54dIbD5rCtzOBVD43V1ReWXXYqBgwCwHLYAAT0RQc/FmrQglXqWPpz6omtryJQOau5jI4Nrvg==} + fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-redact@3.5.0: + resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} + engines: {node: '>=6'} + fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastify-plugin@4.5.1: + resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} + + fastify-plugin@5.0.1: + resolution: {integrity: sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==} + + fastify-sse-v2@4.2.1: + resolution: {integrity: sha512-VDUgAUpu+v+WsbAjcdiG7yB9zHhL64gHSN3mneiXB12nsC2QlCw+e0W97BfLApbSYcEgEz4J+1dqO2YmVqRbRw==} + peerDependencies: + fastify: '>=4' + + fastify-type-provider-zod@6.0.0: + resolution: {integrity: sha512-Bz+Qll2XuvvueHz0yhcr67V/43q1VecSyIqZm+P8OL8KZHznUXECZXkuwQePR5b6fWY/kzhhadmgNs9dB/Nifg==} + peerDependencies: + '@fastify/swagger': '>=9.5.1' + fastify: ^5.0.0 + openapi-types: ^12.1.3 + zod: '>=4.1.5' + + fastify@5.6.0: + resolution: {integrity: sha512-9j2r9TnwNsfGiCKGYT0Voqy244qwcoYM9qvNi/i+F8sNNWDnqUEVuGYNc9GyjldhXmMlJmVPS6gI1LdvjYGRJw==} + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -1250,6 +1477,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-my-way@9.3.0: + resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==} + engines: {node: '>=20'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1288,6 +1519,9 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-iterator@1.0.2: + resolution: {integrity: sha512-v+dm9bNVfOYsY1OrhaCrmyOcYoSeVvbt+hHZ0Au+T+p1y+0Uyj9aMaGIeUTT6xdpRbWzDeYKvfOslPhggQMcsg==} + get-package-type@0.1.0: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} @@ -1310,6 +1544,9 @@ packages: getopts@2.3.0: resolution: {integrity: sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==} + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1377,6 +1614,9 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1414,6 +1654,10 @@ packages: resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==} engines: {node: '>= 0.10'} + ipaddr.js@2.2.0: + resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==} + engines: {node: '>= 10'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -1559,6 +1803,12 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + it-pushable@1.4.2: + resolution: {integrity: sha512-vVPu0CGRsTI8eCfhMknA7KIBqqGFolbRx+1mbQ6XuZ7YCz995Qj7L4XUviwClFunisDq96FdxzF5FnAbw15afg==} + + it-to-stream@1.0.0: + resolution: {integrity: sha512-pLULMZMAB/+vbdvbZtebC0nWBTbG581lk6w8P7DfIIIKUfa8FbY7Oi0FxZcFPbxvISs7A9E+cMpLDBc1XhpAOA==} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -1578,9 +1828,19 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-ref-resolver@2.0.1: + resolution: {integrity: sha512-HG0SIB9X4J8bwbxCbnd5FfPEbcXAJYTi1pBJeP/QPON+w8ovSME8iRG+ElHNxZNX2Qh6eYn1GdzJFS4cDFfx0Q==} + + json-schema-resolver@3.0.0: + resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==} + engines: {node: '>=20'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -1596,6 +1856,10 @@ packages: engines: {node: '>=6'} hasBin: true + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1633,10 +1897,17 @@ packages: tedious: optional: true + leven@4.0.0: + resolution: {integrity: sha512-puehA3YKku3osqPlNuzGDUHq8WpwXupUg1V6NXdV38G+gr+gkBwFC8g1b/+YcIvp8gnqVIus+eJCH/eGsRmJNw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -1730,6 +2001,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.5: + resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==} + engines: {node: ^18 || >=20} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -1773,10 +2049,17 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1789,6 +2072,13 @@ packages: resolution: {integrity: sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==} engines: {node: '>=4'} + p-defer@3.0.0: + resolution: {integrity: sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw==} + engines: {node: '>=8'} + + p-fifo@1.0.0: + resolution: {integrity: sha512-IjoCxXW48tqdtDFz6fqo5q1UfFVjjVZe8TC1QRflvNUJtNfCUhxOUw6MOVZhDPjqhSzc26xKdugsO17gmzd5+A==} + p-filter@2.1.0: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} @@ -1901,6 +2191,16 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-std-serializers@7.0.0: + resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} + + pino@9.9.4: + resolution: {integrity: sha512-d1XorUQ7sSKqVcYdXuEYs2h1LKxejSorMEJ76XoZ0pPDf8VzJMe7GlPXpMBZeQ9gE4ZPIp5uGD+5Nw7scxiigg==} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -1949,6 +2249,12 @@ packages: printable-characters@1.0.42: resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==} + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} @@ -1959,6 +2265,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + quick-lru@4.0.1: resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==} engines: {node: '>=8'} @@ -1975,6 +2284,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + realpath-missing@1.1.0: resolution: {integrity: sha512-wnWtnywepjg/eHIgWR97R7UuM5i+qHLA195qdN9UPKvcMqfn60+67S8sPPW3vDlSEfYHoFkKU8IvpCNty3zQvQ==} engines: {node: '>=10'} @@ -1991,6 +2304,10 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2004,10 +2321,17 @@ packages: engines: {node: '>= 0.4'} hasBin: true + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + right-pad@1.1.1: resolution: {integrity: sha512-eHfYN/4Pds8z4/LnF1LtZSQvWcU9HHU2A7iYtARpFO2fQqt2TP1vHCRTdkO9si7Zg3glo2qh1vgAmyDBO5FGRQ==} engines: {node: '>= 0.10'} @@ -2043,6 +2367,16 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safe-regex2@5.0.0: + resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + secure-json-parse@4.0.0: + resolution: {integrity: sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2052,6 +2386,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-cookie-parser@2.7.1: + resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -2098,6 +2435,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sonic-boom@4.2.0: + resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + sort-keys@4.2.0: resolution: {integrity: sha512-aUYIEU/UviqPgc8mHR6IW1EGxkAXpeRETYcrzg8cLAvUPZcpAlleSXHV2mY7G12GphSH6Gzv+4MMVSSkbdteHg==} engines: {node: '>=8'} @@ -2207,6 +2547,9 @@ packages: resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} engines: {node: '>=18'} + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + through2@4.0.2: resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} @@ -2240,6 +2583,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -2416,6 +2763,14 @@ packages: jsdom: optional: true + vue@3.5.21: + resolution: {integrity: sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -2478,10 +2833,23 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + yaml@2.8.0: + resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==} + engines: {node: '>= 14.6'} + hasBin: true + + yaml@2.8.1: + resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} + engines: {node: '>= 14.6'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@3.24.1: + resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} + zod@4.1.5: resolution: {integrity: sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==} @@ -2637,6 +3005,44 @@ snapshots: '@eslint/core': 0.15.2 levn: 0.4.1 + '@fastify/ajv-compiler@4.0.2': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.1.0 + + '@fastify/cors@11.1.0': + dependencies: + fastify-plugin: 5.0.1 + toad-cache: 3.7.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.0.1 + + '@fastify/forwarded@3.0.0': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.0.0': + dependencies: + '@fastify/forwarded': 3.0.0 + ipaddr.js: 2.2.0 + + '@fastify/swagger@9.5.1': + dependencies: + fastify-plugin: 5.0.1 + json-schema-resolver: 3.0.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.8.1 + transitivePeerDependencies: + - supports-color + '@gwhitney/detect-indent@7.0.1': {} '@humanfs/core@0.19.1': {} @@ -2991,6 +3397,53 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@scalar/core@0.3.16': + dependencies: + '@scalar/types': 0.2.15 + + '@scalar/fastify-api-reference@1.35.1(typescript@5.9.2)': + dependencies: + '@scalar/core': 0.3.16 + '@scalar/openapi-parser': 0.20.2(typescript@5.9.2) + '@scalar/openapi-types': 0.3.7 + fastify-plugin: 4.5.1 + github-slugger: 2.0.0 + transitivePeerDependencies: + - typescript + + '@scalar/helpers@0.0.9': {} + + '@scalar/json-magic@0.4.0(typescript@5.9.2)': + dependencies: + '@scalar/helpers': 0.0.9 + vue: 3.5.21(typescript@5.9.2) + yaml: 2.8.0 + transitivePeerDependencies: + - typescript + + '@scalar/openapi-parser@0.20.2(typescript@5.9.2)': + dependencies: + '@scalar/json-magic': 0.4.0(typescript@5.9.2) + '@scalar/openapi-types': 0.3.7 + ajv: 8.17.1 + ajv-draft-04: 1.0.0(ajv@8.17.1) + ajv-formats: 3.0.1(ajv@8.17.1) + jsonpointer: 5.0.1 + leven: 4.0.0 + yaml: 2.8.0 + transitivePeerDependencies: + - typescript + + '@scalar/openapi-types@0.3.7': + dependencies: + zod: 3.24.1 + + '@scalar/types@0.2.15': + dependencies: + '@scalar/openapi-types': 0.3.7 + nanoid: 5.1.5 + zod: 3.24.1 + '@types/chai@5.2.2': dependencies: '@types/deep-eql': 4.0.2 @@ -3104,7 +3557,7 @@ snapshots: '@typescript-eslint/types': 8.43.0 eslint-visitor-keys: 4.2.1 - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.3.1))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.3.1)(yaml@2.8.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -3119,7 +3572,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.3.1) + vitest: 3.2.4(@types/node@24.3.1)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -3131,13 +3584,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.1.5(@types/node@24.3.1))': + '@vitest/mocker@3.2.4(vite@7.1.5(@types/node@24.3.1)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.19 optionalDependencies: - vite: 7.1.5(@types/node@24.3.1) + vite: 7.1.5(@types/node@24.3.1)(yaml@2.8.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -3165,16 +3618,80 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + '@vue/compiler-core@3.5.21': + dependencies: + '@babel/parser': 7.28.4 + '@vue/shared': 3.5.21 + entities: 4.5.0 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.21': + dependencies: + '@vue/compiler-core': 3.5.21 + '@vue/shared': 3.5.21 + + '@vue/compiler-sfc@3.5.21': + dependencies: + '@babel/parser': 7.28.4 + '@vue/compiler-core': 3.5.21 + '@vue/compiler-dom': 3.5.21 + '@vue/compiler-ssr': 3.5.21 + '@vue/shared': 3.5.21 + estree-walker: 2.0.2 + magic-string: 0.30.19 + postcss: 8.5.6 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.21': + dependencies: + '@vue/compiler-dom': 3.5.21 + '@vue/shared': 3.5.21 + + '@vue/reactivity@3.5.21': + dependencies: + '@vue/shared': 3.5.21 + + '@vue/runtime-core@3.5.21': + dependencies: + '@vue/reactivity': 3.5.21 + '@vue/shared': 3.5.21 + + '@vue/runtime-dom@3.5.21': + dependencies: + '@vue/reactivity': 3.5.21 + '@vue/runtime-core': 3.5.21 + '@vue/shared': 3.5.21 + csstype: 3.1.3 + + '@vue/server-renderer@3.5.21(vue@3.5.21(typescript@5.9.2))': + dependencies: + '@vue/compiler-ssr': 3.5.21 + '@vue/shared': 3.5.21 + vue: 3.5.21(typescript@5.9.2) + + '@vue/shared@3.5.21': {} + '@zkochan/which@2.0.3': dependencies: isexe: 2.0.0 + abstract-logging@2.0.1: {} + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 acorn@8.15.0: {} + ajv-draft-04@1.0.0(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -3182,6 +3699,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-align@3.0.1: dependencies: string-width: 4.2.3 @@ -3275,12 +3799,21 @@ snapshots: async-function@1.0.0: {} + atomic-sleep@1.0.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 + avvio@9.1.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.19.1 + balanced-match@1.0.2: {} + base64-js@1.5.1: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 @@ -3314,6 +3847,11 @@ snapshots: dependencies: fill-range: 7.1.1 + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + cac@6.7.14: {} call-bind-apply-helpers@1.0.2: @@ -3392,6 +3930,8 @@ snapshots: ini: 1.3.8 proto-list: 1.2.4 + cookie@1.0.2: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3400,6 +3940,8 @@ snapshots: crypto-random-string@2.0.0: {} + csstype@3.1.3: {} + data-uri-to-buffer@2.0.2: {} data-view-buffer@1.0.2: @@ -3452,6 +3994,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + dequal@2.0.3: {} + detect-libc@2.0.4: {} doctrine@2.1.0: @@ -3470,6 +4014,8 @@ snapshots: emoji-regex@9.2.2: {} + entities@4.5.0: {} + error-ex@1.3.2: dependencies: is-arrayish: 0.2.1 @@ -3718,12 +4264,16 @@ snapshots: estraverse@5.3.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 esutils@2.0.3: {} + eventemitter3@5.0.1: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -3738,10 +4288,14 @@ snapshots: expect-type@1.2.2: {} + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3752,10 +4306,64 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fast-json-stringify@6.0.1: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.1.0 + json-schema-ref-resolver: 2.0.1 + rfdc: 1.4.1 + fast-levenshtein@2.0.6: {} + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-redact@3.5.0: {} + fast-safe-stringify@2.1.1: {} + fast-uri@3.1.0: {} + + fastify-plugin@4.5.1: {} + + fastify-plugin@5.0.1: {} + + fastify-sse-v2@4.2.1(fastify@5.6.0): + dependencies: + fastify: 5.6.0 + fastify-plugin: 4.5.1 + it-pushable: 1.4.2 + it-to-stream: 1.0.0 + + fastify-type-provider-zod@6.0.0(@fastify/swagger@9.5.1)(fastify@5.6.0)(openapi-types@12.1.3)(zod@4.1.5): + dependencies: + '@fastify/error': 4.2.0 + '@fastify/swagger': 9.5.1 + fastify: 5.6.0 + openapi-types: 12.1.3 + zod: 4.1.5 + + fastify@5.6.0: + dependencies: + '@fastify/ajv-compiler': 4.0.2 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.0.0 + abstract-logging: 2.0.1 + avvio: 9.1.0 + fast-json-stringify: 6.0.1 + find-my-way: 9.3.0 + light-my-request: 6.6.0 + pino: 9.9.4 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.0.0 + semver: 7.7.2 + toad-cache: 3.7.0 + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -3772,6 +4380,12 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-my-way@9.3.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -3822,6 +4436,8 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 + get-iterator@1.0.2: {} + get-package-type@0.1.0: {} get-proto@1.0.1: @@ -3844,6 +4460,8 @@ snapshots: getopts@2.3.0: {} + github-slugger@2.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -3902,6 +4520,8 @@ snapshots: human-signals@2.1.0: {} + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -3929,6 +4549,8 @@ snapshots: interpret@2.2.0: {} + ipaddr.js@2.2.0: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -4075,6 +4697,19 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + it-pushable@1.4.2: + dependencies: + fast-fifo: 1.3.2 + + it-to-stream@1.0.0: + dependencies: + buffer: 6.0.3 + fast-fifo: 1.3.2 + get-iterator: 1.0.2 + p-defer: 3.0.0 + p-fifo: 1.0.0 + readable-stream: 3.6.2 + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -4093,8 +4728,22 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-ref-resolver@2.0.1: + dependencies: + dequal: 2.0.3 + + json-schema-resolver@3.0.0: + dependencies: + debug: 4.4.1 + fast-uri: 3.1.0 + rfdc: 1.4.1 + transitivePeerDependencies: + - supports-color + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-safe@5.0.1: {} @@ -4105,6 +4754,8 @@ snapshots: json5@2.2.3: {} + jsonpointer@5.0.1: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -4135,11 +4786,19 @@ snapshots: transitivePeerDependencies: - supports-color + leven@4.0.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 type-check: 0.4.0 + light-my-request@6.6.0: + dependencies: + cookie: 1.0.2 + process-warning: 4.0.1 + set-cookie-parser: 2.7.1 + lines-and-columns@1.2.4: {} load-json-file@6.2.0: @@ -4219,6 +4878,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.5: {} + natural-compare@1.4.0: {} ndjson@2.0.0: @@ -4270,10 +4931,14 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + on-exit-leak-free@2.1.2: {} + onetime@5.1.2: dependencies: mimic-fn: 2.1.0 + openapi-types@12.1.3: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -4291,6 +4956,13 @@ snapshots: p-defer@1.0.0: {} + p-defer@3.0.0: {} + + p-fifo@1.0.0: + dependencies: + fast-fifo: 1.3.2 + p-defer: 3.0.0 + p-filter@2.1.0: dependencies: p-map: 2.1.0 @@ -4386,6 +5058,26 @@ snapshots: picomatch@4.0.3: {} + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.0.0: {} + + pino@9.9.4: + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.5.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.0.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.0 + thread-stream: 3.1.0 + possible-typed-array-names@1.1.0: {} postcss@8.5.6: @@ -4420,12 +5112,18 @@ snapshots: printable-characters@1.0.42: {} + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + proto-list@1.2.4: {} punycode@2.3.1: {} queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + quick-lru@4.0.1: {} read-ini-file@4.0.0: @@ -4444,6 +5142,8 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + real-require@0.2.0: {} + realpath-missing@1.1.0: {} rechoir@0.8.0: @@ -4470,6 +5170,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -4480,8 +5182,12 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + ret@0.5.0: {} + reusify@1.1.0: {} + rfdc@1.4.1: {} + right-pad@1.1.1: {} rollup@4.50.1: @@ -4546,10 +5252,20 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-regex2@5.0.0: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + secure-json-parse@4.0.0: {} + semver@6.3.1: {} semver@7.7.2: {} + set-cookie-parser@2.7.1: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -4612,6 +5328,10 @@ snapshots: signal-exit@4.1.0: {} + sonic-boom@4.2.0: + dependencies: + atomic-sleep: 1.0.0 + sort-keys@4.2.0: dependencies: is-plain-obj: 2.1.0 @@ -4724,6 +5444,10 @@ snapshots: glob: 10.4.5 minimatch: 9.0.5 + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + through2@4.0.2: dependencies: readable-stream: 3.6.2 @@ -4749,6 +5473,8 @@ snapshots: dependencies: is-number: 7.0.0 + toad-cache@3.7.0: {} + ts-api-utils@2.1.0(typescript@5.9.2): dependencies: typescript: 5.9.2 @@ -4862,13 +5588,13 @@ snapshots: util-deprecate@1.0.2: {} - vite-node@3.2.4(@types/node@24.3.1): + vite-node@3.2.4(@types/node@24.3.1)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.1.5(@types/node@24.3.1) + vite: 7.1.5(@types/node@24.3.1)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -4883,7 +5609,7 @@ snapshots: - tsx - yaml - vite@7.1.5(@types/node@24.3.1): + vite@7.1.5(@types/node@24.3.1)(yaml@2.8.1): dependencies: esbuild: 0.25.9 fdir: 6.5.0(picomatch@4.0.3) @@ -4894,12 +5620,13 @@ snapshots: optionalDependencies: '@types/node': 24.3.1 fsevents: 2.3.3 + yaml: 2.8.1 - vitest@3.2.4(@types/node@24.3.1): + vitest@3.2.4(@types/node@24.3.1)(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.5(@types/node@24.3.1)) + '@vitest/mocker': 3.2.4(vite@7.1.5(@types/node@24.3.1)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -4917,8 +5644,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.5(@types/node@24.3.1) - vite-node: 3.2.4(@types/node@24.3.1) + vite: 7.1.5(@types/node@24.3.1)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@24.3.1)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.3.1 @@ -4936,6 +5663,16 @@ snapshots: - tsx - yaml + vue@3.5.21(typescript@5.9.2): + dependencies: + '@vue/compiler-dom': 3.5.21 + '@vue/compiler-sfc': 3.5.21 + '@vue/runtime-dom': 3.5.21 + '@vue/server-renderer': 3.5.21(vue@3.5.21(typescript@5.9.2)) + '@vue/shared': 3.5.21 + optionalDependencies: + typescript: 5.9.2 + wcwidth@1.0.1: dependencies: defaults: 1.0.4 @@ -5024,6 +5761,12 @@ snapshots: xtend@4.0.2: {} + yaml@2.8.0: {} + + yaml@2.8.1: {} + yocto-queue@0.1.0: {} + zod@3.24.1: {} + zod@4.1.5: {}