update
This commit is contained in:
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.turbo/
|
||||
/coverage/
|
||||
10
.u8.json
10
.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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
32
Dockerfile
Normal file
32
Dockerfile
Normal file
@@ -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"]
|
||||
9
docker-compose.yaml
Normal file
9
docker-compose.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
name: fluxcurrent
|
||||
|
||||
services:
|
||||
server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- 3000:3000
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
414
packages/core/src/services/documents/documents.dsl.test.ts
Normal file
414
packages/core/src/services/documents/documents.dsl.test.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { parseDSL } from './documents.dsl.ts';
|
||||
|
||||
describe('DSL Parser', () => {
|
||||
describe('Basic field filtering', () => {
|
||||
it('should parse simple URI equality', () => {
|
||||
const result = parseDSL('uri = "test-doc"');
|
||||
expect(result).toEqual({
|
||||
uris: ['test-doc'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse URI in array', () => {
|
||||
const result = parseDSL('uri in ["doc1", "doc2", "doc3"]');
|
||||
expect(result).toEqual({
|
||||
uris: ['doc1', 'doc2', 'doc3'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse simple type equality', () => {
|
||||
const result = parseDSL('type = "article"');
|
||||
expect(result).toEqual({
|
||||
types: ['article'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse type in array', () => {
|
||||
const result = parseDSL('type in ["article", "blog", "news"]');
|
||||
expect(result).toEqual({
|
||||
types: ['article', 'blog', 'news'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Meta field filtering', () => {
|
||||
it('should parse number equality', () => {
|
||||
const result = parseDSL('meta.priority = 5');
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { eq: 5 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse number comparisons', () => {
|
||||
const testCases = [
|
||||
{ query: 'meta.priority > 3', expected: { gt: 3 } },
|
||||
{ query: 'meta.priority >= 3', expected: { gte: 3 } },
|
||||
{ query: 'meta.priority < 10', expected: { lt: 10 } },
|
||||
{ query: 'meta.priority <= 10', expected: { lte: 10 } },
|
||||
{ query: 'meta.priority != 5', expected: { neq: 5 } },
|
||||
];
|
||||
|
||||
testCases.forEach(({ query, expected }) => {
|
||||
const result = parseDSL(query);
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: expected,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse text equality and inequality', () => {
|
||||
const result = parseDSL('meta.title = "Test Title"');
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'text',
|
||||
field: 'title',
|
||||
filter: { eq: 'Test Title' },
|
||||
},
|
||||
});
|
||||
|
||||
const result2 = parseDSL('meta.category != "archived"');
|
||||
expect(result2).toEqual({
|
||||
meta: {
|
||||
type: 'text',
|
||||
field: 'category',
|
||||
filter: { neq: 'archived' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse LIKE patterns', () => {
|
||||
const result = parseDSL('meta.title like "%Test%"');
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'text',
|
||||
field: 'title',
|
||||
filter: { like: '%Test%' },
|
||||
},
|
||||
});
|
||||
|
||||
const result2 = parseDSL('meta.title not like "%Draft%"');
|
||||
expect(result2).toEqual({
|
||||
meta: {
|
||||
type: 'text',
|
||||
field: 'title',
|
||||
filter: { nlike: '%Draft%' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse boolean values', () => {
|
||||
const result = parseDSL('meta.published = true');
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'bool',
|
||||
field: 'published',
|
||||
filter: { eq: true },
|
||||
},
|
||||
});
|
||||
|
||||
const result2 = parseDSL('meta.archived = false');
|
||||
expect(result2).toEqual({
|
||||
meta: {
|
||||
type: 'bool',
|
||||
field: 'archived',
|
||||
filter: { eq: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logical operations', () => {
|
||||
it('should parse AND conditions with "and" keyword', () => {
|
||||
const result = parseDSL('meta.priority = 5 and meta.published = true');
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { eq: 5 },
|
||||
},
|
||||
{
|
||||
type: 'bool',
|
||||
field: 'published',
|
||||
filter: { eq: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse AND conditions with "&" operator', () => {
|
||||
const result = parseDSL('meta.priority >= 5 & meta.priority < 10');
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { gte: 5 },
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { lt: 10 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse OR conditions with "or" keyword', () => {
|
||||
const result = parseDSL('type = "article" or type = "blog"');
|
||||
expect(result).toEqual({
|
||||
types: ['article', 'blog'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse OR conditions with "|" operator', () => {
|
||||
const result = parseDSL('meta.priority > 8 | meta.urgent = true');
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'or',
|
||||
conditions: [
|
||||
{
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { gt: 8 },
|
||||
},
|
||||
{
|
||||
type: 'bool',
|
||||
field: 'urgent',
|
||||
filter: { eq: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex expressions', () => {
|
||||
it('should parse mixed field types with logical operations', () => {
|
||||
const result = parseDSL('uri in ["doc1", "doc2"] and meta.priority >= 5');
|
||||
expect(result).toEqual({
|
||||
uris: ['doc1', 'doc2'],
|
||||
meta: {
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { gte: 5 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse parentheses grouping', () => {
|
||||
const result = parseDSL('(meta.priority > 5 or meta.urgent = true) and type = "article"');
|
||||
expect(result).toEqual({
|
||||
types: ['article'],
|
||||
meta: {
|
||||
type: 'or',
|
||||
conditions: [
|
||||
{
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { gt: 5 },
|
||||
},
|
||||
{
|
||||
type: 'bool',
|
||||
field: 'urgent',
|
||||
filter: { eq: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse nested parentheses', () => {
|
||||
const result = parseDSL('((meta.a = 1 and meta.b = 2) or meta.c = 3) and type = "test"');
|
||||
expect(result).toEqual({
|
||||
types: ['test'],
|
||||
meta: {
|
||||
type: 'or',
|
||||
conditions: [
|
||||
{
|
||||
type: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'number',
|
||||
field: 'a',
|
||||
filter: { eq: 1 },
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
field: 'b',
|
||||
filter: { eq: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
field: 'c',
|
||||
filter: { eq: 3 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle the example from the documentation', () => {
|
||||
const result = parseDSL(
|
||||
'uri in ["a", "b c", "d"] and (meta.created >= 12345 & meta.created < 23456 or (type = "foo" and meta.test != "stuff"))',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
uris: ['a', 'b c', 'd'],
|
||||
meta: {
|
||||
type: 'or',
|
||||
conditions: [
|
||||
{
|
||||
type: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'number',
|
||||
field: 'created',
|
||||
filter: { gte: 12345 },
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
field: 'created',
|
||||
filter: { lt: 23456 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
field: 'test',
|
||||
filter: { neq: 'stuff' },
|
||||
},
|
||||
],
|
||||
},
|
||||
types: ['foo'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('String handling', () => {
|
||||
it('should handle strings with spaces', () => {
|
||||
const result = parseDSL('meta.title = "This is a test with spaces"');
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'text',
|
||||
field: 'title',
|
||||
filter: { eq: 'This is a test with spaces' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle single quotes', () => {
|
||||
const result = parseDSL("meta.title = 'Single quoted string'");
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'text',
|
||||
field: 'title',
|
||||
filter: { eq: 'Single quoted string' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle escaped characters', () => {
|
||||
const result = parseDSL('meta.content = "String with \\"quotes\\" and \\n newlines"');
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'text',
|
||||
field: 'content',
|
||||
filter: { eq: 'String with "quotes" and \n newlines' },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Whitespace handling', () => {
|
||||
it('should ignore extra whitespace', () => {
|
||||
const result = parseDSL(' uri = "test" and meta.priority >= 5 ');
|
||||
expect(result).toEqual({
|
||||
uris: ['test'],
|
||||
meta: {
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { gte: 5 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should throw error for invalid syntax', () => {
|
||||
expect(() => parseDSL('uri =')).toThrow('DSL parsing error');
|
||||
expect(() => parseDSL('uri = "test')).toThrow('DSL parsing error');
|
||||
expect(() => parseDSL('(uri = "test"')).toThrow('DSL parsing error');
|
||||
});
|
||||
|
||||
it('should throw error for unknown fields', () => {
|
||||
expect(() => parseDSL('unknown = "test"')).toThrow('DSL parsing error');
|
||||
});
|
||||
|
||||
it('should throw error for unsupported operators', () => {
|
||||
expect(() => parseDSL('uri like "test"')).toThrow('DSL parsing error');
|
||||
expect(() => parseDSL('meta.test > "string"')).toThrow('DSL parsing error');
|
||||
});
|
||||
|
||||
it('should throw error for invalid tokens', () => {
|
||||
expect(() => parseDSL('uri = test@invalid')).toThrow('DSL parsing error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty arrays', () => {
|
||||
const result = parseDSL('uri in []');
|
||||
expect(result).toEqual({
|
||||
uris: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle decimal numbers', () => {
|
||||
const result = parseDSL('meta.score = 4.5');
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'number',
|
||||
field: 'score',
|
||||
filter: { eq: 4.5 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle negative numbers', () => {
|
||||
const result = parseDSL('meta.balance < -100');
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'number',
|
||||
field: 'balance',
|
||||
filter: { lt: -100 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should deduplicate URI and type arrays', () => {
|
||||
const result = parseDSL('uri = "test" or uri = "test" or type = "article" or type = "article"');
|
||||
expect(result).toEqual({
|
||||
uris: ['test'],
|
||||
types: ['article'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
616
packages/core/src/services/documents/documents.dsl.ts
Normal file
616
packages/core/src/services/documents/documents.dsl.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
import type { DocumentSearchOptions, MetaCondition, MetaFilter } from './documents.schemas.ts';
|
||||
|
||||
// Token types for the DSL
|
||||
type TokenType =
|
||||
| 'IDENTIFIER'
|
||||
| 'STRING'
|
||||
| 'NUMBER'
|
||||
| 'BOOLEAN'
|
||||
| 'OPERATOR'
|
||||
| 'KEYWORD'
|
||||
| 'LPAREN'
|
||||
| 'RPAREN'
|
||||
| 'LBRACKET'
|
||||
| 'RBRACKET'
|
||||
| 'COMMA'
|
||||
| 'DOT'
|
||||
| 'EOF';
|
||||
|
||||
type Token = {
|
||||
type: TokenType;
|
||||
value: string;
|
||||
position: number;
|
||||
};
|
||||
|
||||
class Tokenizer {
|
||||
private input: string;
|
||||
private position = 0;
|
||||
private currentChar: string | null;
|
||||
|
||||
constructor(input: string) {
|
||||
this.input = input.trim();
|
||||
this.currentChar = this.input.length > 0 ? this.input[0] : null;
|
||||
}
|
||||
|
||||
private advance(): void {
|
||||
this.position += 1;
|
||||
if (this.position >= this.input.length) {
|
||||
this.currentChar = null;
|
||||
} else {
|
||||
this.currentChar = this.input[this.position];
|
||||
}
|
||||
}
|
||||
|
||||
private skipWhitespace(): void {
|
||||
while (this.currentChar && /\s/.test(this.currentChar)) {
|
||||
this.advance();
|
||||
}
|
||||
}
|
||||
|
||||
private readString(): string {
|
||||
const quote = this.currentChar; // Either ' or "
|
||||
this.advance(); // Skip opening quote
|
||||
|
||||
let value = '';
|
||||
while (this.currentChar && this.currentChar !== quote) {
|
||||
if (this.currentChar === '\\') {
|
||||
this.advance(); // Skip escape character
|
||||
if (this.currentChar) {
|
||||
// Handle common escape sequences
|
||||
switch (this.currentChar as string) {
|
||||
case 'n':
|
||||
value += '\n';
|
||||
break;
|
||||
case 't':
|
||||
value += '\t';
|
||||
break;
|
||||
case 'r':
|
||||
value += '\r';
|
||||
break;
|
||||
case '\\':
|
||||
value += '\\';
|
||||
break;
|
||||
case '"':
|
||||
value += '"';
|
||||
break;
|
||||
case "'":
|
||||
value += "'";
|
||||
break;
|
||||
default:
|
||||
value += this.currentChar;
|
||||
break;
|
||||
}
|
||||
this.advance();
|
||||
}
|
||||
} else {
|
||||
value += this.currentChar;
|
||||
this.advance();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.currentChar === quote) {
|
||||
this.advance(); // Skip closing quote
|
||||
} else {
|
||||
throw new Error(`Unterminated string at position ${this.position}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private readNumber(): string {
|
||||
let value = '';
|
||||
|
||||
// Handle negative sign
|
||||
if (this.currentChar === '-') {
|
||||
value += this.currentChar;
|
||||
this.advance();
|
||||
}
|
||||
|
||||
while (this.currentChar && /[\d.]/.test(this.currentChar)) {
|
||||
value += this.currentChar;
|
||||
this.advance();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private readIdentifier(): string {
|
||||
let value = '';
|
||||
while (this.currentChar && /[a-zA-Z0-9_]/.test(this.currentChar)) {
|
||||
value += this.currentChar;
|
||||
this.advance();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private readOperator(): string {
|
||||
let value = '';
|
||||
|
||||
// Handle multi-character operators
|
||||
const remainingInput = this.input.slice(this.position);
|
||||
|
||||
if (remainingInput.startsWith('>=')) {
|
||||
this.advance();
|
||||
this.advance();
|
||||
return '>=';
|
||||
} else if (remainingInput.startsWith('<=')) {
|
||||
this.advance();
|
||||
this.advance();
|
||||
return '<=';
|
||||
} else if (remainingInput.startsWith('!=')) {
|
||||
this.advance();
|
||||
this.advance();
|
||||
return '!=';
|
||||
} else if (remainingInput.startsWith('not like')) {
|
||||
// Handle "not like" as a single operator
|
||||
this.position += 8;
|
||||
this.currentChar = this.position < this.input.length ? this.input[this.position] : null;
|
||||
return 'not like';
|
||||
} else {
|
||||
value = this.currentChar || '';
|
||||
this.advance();
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
tokenize(): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
|
||||
while (this.currentChar) {
|
||||
this.skipWhitespace();
|
||||
|
||||
if (!this.currentChar) break;
|
||||
|
||||
const startPosition = this.position;
|
||||
|
||||
if (this.currentChar === '"' || this.currentChar === "'") {
|
||||
const value = this.readString();
|
||||
tokens.push({ type: 'STRING', value, position: startPosition });
|
||||
} else if (
|
||||
/\d/.test(this.currentChar) ||
|
||||
(this.currentChar === '-' && /\d/.test(this.input[this.position + 1] || ''))
|
||||
) {
|
||||
const value = this.readNumber();
|
||||
tokens.push({ type: 'NUMBER', value, position: startPosition });
|
||||
} else if (/[a-zA-Z_]/.test(this.currentChar)) {
|
||||
const value = this.readIdentifier();
|
||||
|
||||
// Check for keywords and boolean values
|
||||
if (['and', 'or', 'in', 'like', 'not'].includes(value.toLowerCase())) {
|
||||
tokens.push({ type: 'KEYWORD', value: value.toLowerCase(), position: startPosition });
|
||||
} else if (['true', 'false'].includes(value.toLowerCase())) {
|
||||
tokens.push({ type: 'BOOLEAN', value: value.toLowerCase(), position: startPosition });
|
||||
} else {
|
||||
tokens.push({ type: 'IDENTIFIER', value, position: startPosition });
|
||||
}
|
||||
} else if (['=', '!', '>', '<', '&', '|'].includes(this.currentChar)) {
|
||||
const value = this.readOperator();
|
||||
// Handle & and | as logical operators
|
||||
if (value === '&') {
|
||||
tokens.push({ type: 'KEYWORD', value: 'and', position: startPosition });
|
||||
} else if (value === '|') {
|
||||
tokens.push({ type: 'KEYWORD', value: 'or', position: startPosition });
|
||||
} else {
|
||||
tokens.push({ type: 'OPERATOR', value, position: startPosition });
|
||||
}
|
||||
} else if (this.currentChar === '(') {
|
||||
tokens.push({ type: 'LPAREN', value: '(', position: startPosition });
|
||||
this.advance();
|
||||
} else if (this.currentChar === ')') {
|
||||
tokens.push({ type: 'RPAREN', value: ')', position: startPosition });
|
||||
this.advance();
|
||||
} else if (this.currentChar === '[') {
|
||||
tokens.push({ type: 'LBRACKET', value: '[', position: startPosition });
|
||||
this.advance();
|
||||
} else if (this.currentChar === ']') {
|
||||
tokens.push({ type: 'RBRACKET', value: ']', position: startPosition });
|
||||
this.advance();
|
||||
} else if (this.currentChar === ',') {
|
||||
tokens.push({ type: 'COMMA', value: ',', position: startPosition });
|
||||
this.advance();
|
||||
} else if (this.currentChar === '.') {
|
||||
tokens.push({ type: 'DOT', value: '.', position: startPosition });
|
||||
this.advance();
|
||||
} else {
|
||||
throw new Error(`Unexpected character '${this.currentChar}' at position ${this.position}`);
|
||||
}
|
||||
}
|
||||
|
||||
tokens.push({ type: 'EOF', value: '', position: this.position });
|
||||
return tokens;
|
||||
}
|
||||
}
|
||||
|
||||
// AST node types
|
||||
type ASTNode = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
type ComparisonNode = {
|
||||
type: 'comparison';
|
||||
field: string;
|
||||
operator: string;
|
||||
value: string | number | boolean | string[];
|
||||
} & ASTNode;
|
||||
|
||||
type LogicalNode = {
|
||||
type: 'logical';
|
||||
operator: 'and' | 'or';
|
||||
left: ASTNode;
|
||||
right: ASTNode;
|
||||
} & ASTNode;
|
||||
|
||||
class Parser {
|
||||
private tokens: Token[];
|
||||
private position = 0;
|
||||
private currentToken: Token;
|
||||
|
||||
constructor(tokens: Token[]) {
|
||||
this.tokens = tokens;
|
||||
this.currentToken = tokens[0];
|
||||
}
|
||||
|
||||
private advance() {
|
||||
this.position += 1;
|
||||
if (this.position < this.tokens.length) {
|
||||
this.currentToken = this.tokens[this.position];
|
||||
}
|
||||
return this.currentToken;
|
||||
}
|
||||
|
||||
private expect(tokenType: TokenType | TokenType[], value?: string): Token {
|
||||
const types = Array.isArray(tokenType) ? tokenType : [tokenType];
|
||||
|
||||
if (!types.includes(this.currentToken.type)) {
|
||||
throw new Error(
|
||||
`Expected ${types.join(' or ')}, got ${this.currentToken.type} at position ${this.currentToken.position}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (value && this.currentToken.value !== value) {
|
||||
throw new Error(
|
||||
`Expected '${value}', got '${this.currentToken.value}' at position ${this.currentToken.position}`,
|
||||
);
|
||||
}
|
||||
|
||||
const token = this.currentToken;
|
||||
this.advance();
|
||||
return token;
|
||||
}
|
||||
|
||||
private parseValue(): string | number | boolean | string[] {
|
||||
if (this.currentToken.type === 'STRING') {
|
||||
const value = this.currentToken.value;
|
||||
this.advance();
|
||||
return value;
|
||||
} else if (this.currentToken.type === 'NUMBER') {
|
||||
const value = parseFloat(this.currentToken.value);
|
||||
this.advance();
|
||||
return value;
|
||||
} else if (this.currentToken.type === 'BOOLEAN') {
|
||||
const value = this.currentToken.value === 'true';
|
||||
this.advance();
|
||||
return value;
|
||||
} else if (this.currentToken.type === 'LBRACKET') {
|
||||
this.advance(); // Skip [
|
||||
|
||||
const values: string[] = [];
|
||||
|
||||
if ((this.currentToken.type as TokenType) !== 'RBRACKET') {
|
||||
values.push(String(this.parseValue()));
|
||||
|
||||
while ((this.currentToken.type as TokenType) === 'COMMA') {
|
||||
this.advance(); // Skip comma
|
||||
values.push(String(this.parseValue()));
|
||||
}
|
||||
}
|
||||
|
||||
this.expect('RBRACKET');
|
||||
return values;
|
||||
} else {
|
||||
throw new Error(`Expected value, got ${this.currentToken.type} at position ${this.currentToken.position}`);
|
||||
}
|
||||
}
|
||||
|
||||
private parseComparison(): ComparisonNode {
|
||||
let field: string;
|
||||
|
||||
// Parse field (uri, type, or meta.fieldName)
|
||||
if (this.currentToken.type === 'IDENTIFIER') {
|
||||
const identifier = this.currentToken.value;
|
||||
this.advance();
|
||||
|
||||
if (identifier === 'meta' && (this.currentToken.type as TokenType) === 'DOT') {
|
||||
this.advance(); // Skip dot
|
||||
const fieldName = this.expect('IDENTIFIER').value;
|
||||
field = `meta.${fieldName}`;
|
||||
} else if (['uri', 'type'].includes(identifier)) {
|
||||
field = identifier;
|
||||
} else {
|
||||
throw new Error(`Unknown field '${identifier}' at position ${this.currentToken.position}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Expected field name, got ${this.currentToken.type} at position ${this.currentToken.position}`);
|
||||
}
|
||||
|
||||
// Parse operator
|
||||
let operator: string;
|
||||
if ((this.currentToken.type as TokenType) === 'OPERATOR') {
|
||||
operator = this.currentToken.value;
|
||||
this.advance();
|
||||
} else if ((this.currentToken.type as TokenType) === 'KEYWORD') {
|
||||
if (this.currentToken.value === 'in') {
|
||||
operator = 'in';
|
||||
this.advance();
|
||||
} else if (this.currentToken.value === 'like') {
|
||||
operator = 'like';
|
||||
this.advance();
|
||||
} else if (this.currentToken.value === 'not') {
|
||||
this.advance();
|
||||
this.expect('KEYWORD', 'like');
|
||||
operator = 'not like';
|
||||
} else {
|
||||
throw new Error(`Unexpected keyword '${this.currentToken.value}' at position ${this.currentToken.position}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Expected operator, got ${this.currentToken.type} at position ${this.currentToken.position}`);
|
||||
}
|
||||
|
||||
// Parse value
|
||||
if ((this.currentToken.type as TokenType) === 'EOF') {
|
||||
throw new Error(`Expected value after operator '${operator}' at position ${this.currentToken.position}`);
|
||||
}
|
||||
|
||||
const value = this.parseValue();
|
||||
|
||||
return {
|
||||
type: 'comparison',
|
||||
field,
|
||||
operator,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
private parseTerm(): ASTNode {
|
||||
if (this.currentToken.type === 'LPAREN') {
|
||||
this.advance(); // Skip (
|
||||
const node = this.parseExpression();
|
||||
this.expect('RPAREN');
|
||||
return node;
|
||||
} else {
|
||||
return this.parseComparison();
|
||||
}
|
||||
}
|
||||
|
||||
private parseExpression(): ASTNode {
|
||||
let node = this.parseTerm();
|
||||
|
||||
while (
|
||||
this.currentToken.type === 'KEYWORD' &&
|
||||
(this.currentToken.value === 'and' || this.currentToken.value === 'or')
|
||||
) {
|
||||
const operator = this.currentToken.value as 'and' | 'or';
|
||||
this.advance();
|
||||
const right = this.parseTerm();
|
||||
|
||||
node = {
|
||||
type: 'logical',
|
||||
operator,
|
||||
left: node,
|
||||
right,
|
||||
} as LogicalNode;
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
parse(): ASTNode {
|
||||
const result = this.parseExpression();
|
||||
this.expect('EOF');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert AST to DocumentSearchOptions
|
||||
class Converter {
|
||||
private uris: string[] = [];
|
||||
private types: string[] = [];
|
||||
private metaConditions: MetaCondition[] = [];
|
||||
private hasUriConditions = false;
|
||||
private hasTypeConditions = false;
|
||||
|
||||
private convertComparison(node: ComparisonNode): MetaCondition | null {
|
||||
if (node.field === 'uri') {
|
||||
this.hasUriConditions = true;
|
||||
if (node.operator === '=' && typeof node.value === 'string') {
|
||||
this.uris.push(node.value);
|
||||
} else if (node.operator === 'in' && Array.isArray(node.value)) {
|
||||
this.uris.push(...node.value);
|
||||
} else {
|
||||
throw new Error(`Unsupported operator '${node.operator}' for uri field`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.field === 'type') {
|
||||
this.hasTypeConditions = true;
|
||||
if (node.operator === '=' && typeof node.value === 'string') {
|
||||
this.types.push(node.value);
|
||||
} else if (node.operator === 'in' && Array.isArray(node.value)) {
|
||||
this.types.push(...node.value);
|
||||
} else {
|
||||
throw new Error(`Unsupported operator '${node.operator}' for type field`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.field.startsWith('meta.')) {
|
||||
const fieldName = node.field.slice(5); // Remove 'meta.' prefix
|
||||
return this.convertMetaFilter(fieldName, node.operator, node.value);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown field '${node.field}'`);
|
||||
}
|
||||
|
||||
private convertMetaFilter(field: string, operator: string, value: string | number | boolean | string[]): MetaFilter {
|
||||
if (typeof value === 'number') {
|
||||
// Number filter
|
||||
const filter: Record<string, number> = {};
|
||||
|
||||
switch (operator) {
|
||||
case '=':
|
||||
filter.eq = value;
|
||||
break;
|
||||
case '!=':
|
||||
filter.neq = value;
|
||||
break;
|
||||
case '>':
|
||||
filter.gt = value;
|
||||
break;
|
||||
case '>=':
|
||||
filter.gte = value;
|
||||
break;
|
||||
case '<':
|
||||
filter.lt = value;
|
||||
break;
|
||||
case '<=':
|
||||
filter.lte = value;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported operator '${operator}' for number field`);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'number',
|
||||
field,
|
||||
filter,
|
||||
};
|
||||
} else if (typeof value === 'boolean') {
|
||||
// Boolean filter
|
||||
if (operator !== '=') {
|
||||
throw new Error(`Unsupported operator '${operator}' for boolean field`);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'bool',
|
||||
field,
|
||||
filter: { eq: value },
|
||||
};
|
||||
} else if (typeof value === 'string') {
|
||||
// Text filter
|
||||
const filter: Record<string, string> = {};
|
||||
|
||||
switch (operator) {
|
||||
case '=':
|
||||
filter.eq = value;
|
||||
break;
|
||||
case '!=':
|
||||
filter.neq = value;
|
||||
break;
|
||||
case 'like':
|
||||
filter.like = value;
|
||||
break;
|
||||
case 'not like':
|
||||
filter.nlike = value;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported operator '${operator}' for text field`);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
field,
|
||||
filter,
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Unsupported value type for meta field`);
|
||||
}
|
||||
}
|
||||
|
||||
private convertLogical(node: LogicalNode): MetaCondition | null {
|
||||
const leftCondition = this.convertNode(node.left);
|
||||
const rightCondition = this.convertNode(node.right);
|
||||
|
||||
const conditions: MetaCondition[] = [];
|
||||
if (leftCondition) conditions.push(leftCondition);
|
||||
if (rightCondition) conditions.push(rightCondition);
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (conditions.length === 1) {
|
||||
return conditions[0];
|
||||
}
|
||||
|
||||
return {
|
||||
type: node.operator,
|
||||
conditions,
|
||||
};
|
||||
}
|
||||
|
||||
private convertNode(node: ASTNode): MetaCondition | null {
|
||||
if (node.type === 'comparison') {
|
||||
return this.convertComparison(node as ComparisonNode);
|
||||
} else if (node.type === 'logical') {
|
||||
return this.convertLogical(node as LogicalNode);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown node type '${node.type}'`);
|
||||
}
|
||||
|
||||
convert(ast: ASTNode): DocumentSearchOptions {
|
||||
this.uris = [];
|
||||
this.types = [];
|
||||
this.metaConditions = [];
|
||||
this.hasUriConditions = false;
|
||||
this.hasTypeConditions = false;
|
||||
|
||||
const metaCondition = this.convertNode(ast);
|
||||
|
||||
const options: DocumentSearchOptions = {};
|
||||
|
||||
if (this.hasUriConditions) {
|
||||
options.uris = [...new Set(this.uris)]; // Remove duplicates
|
||||
}
|
||||
|
||||
if (this.hasTypeConditions) {
|
||||
options.types = [...new Set(this.types)]; // Remove duplicates
|
||||
}
|
||||
|
||||
if (metaCondition) {
|
||||
options.meta = metaCondition;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a DSL query string into DocumentSearchOptions
|
||||
*
|
||||
* Supports the following syntax:
|
||||
* - URI filtering: `uri = "value"` or `uri in ["val1", "val2"]`
|
||||
* - Type filtering: `type = "value"` or `type in ["val1", "val2"]`
|
||||
* - Meta filtering: `meta.field = value`, `meta.field != value`, `meta.field > value`, etc.
|
||||
* - Boolean logic: `condition1 and condition2` (or `&`), `condition1 or condition2` (or `|`)
|
||||
* - Parentheses: `(condition1 or condition2) and condition3`
|
||||
*
|
||||
* Example: `uri in ["a", "b"] and (meta.priority >= 5 | type = "article")`
|
||||
*
|
||||
* @param query - The DSL query string to parse
|
||||
* @returns DocumentSearchOptions object
|
||||
*/
|
||||
export function parseDSL(query: string): DocumentSearchOptions {
|
||||
try {
|
||||
const tokenizer = new Tokenizer(query);
|
||||
const tokens = tokenizer.tokenize();
|
||||
|
||||
const parser = new Parser(tokens);
|
||||
const ast = parser.parse();
|
||||
|
||||
const converter = new Converter();
|
||||
return converter.convert(ast);
|
||||
} catch (error) {
|
||||
throw new Error(`DSL parsing error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
133
packages/core/src/services/documents/documents.filter.ts
Normal file
133
packages/core/src/services/documents/documents.filter.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { MetaCondition, MetaFilter, Document } from './documents.schemas.ts';
|
||||
|
||||
/**
|
||||
* Evaluates a meta filter against a document's metadata
|
||||
*/
|
||||
const evaluateMetaFilter = (filter: MetaFilter, document: Document): boolean => {
|
||||
const fieldValue = document.metadata[filter.field];
|
||||
const fieldExists = filter.field in document.metadata;
|
||||
|
||||
if (filter.type === 'number') {
|
||||
const { gt, gte, lt, lte, eq, neq, nill } = filter.filter;
|
||||
|
||||
// Handle null/undefined checks first
|
||||
if (nill !== undefined) {
|
||||
if (nill) {
|
||||
// Field doesn't exist or is null
|
||||
return !fieldExists || fieldValue === null || fieldValue === undefined;
|
||||
} else {
|
||||
// Field exists and is not null
|
||||
return fieldExists && fieldValue !== null && fieldValue !== undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// If field doesn't exist or is null for numeric operations, return false
|
||||
if (!fieldExists || fieldValue === null || fieldValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const numValue = typeof fieldValue === 'number' ? fieldValue : Number(fieldValue);
|
||||
|
||||
// If conversion to number fails, return false for numeric operations
|
||||
if (isNaN(numValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (eq !== undefined && numValue !== eq) return false;
|
||||
if (neq !== undefined && numValue === neq) return false;
|
||||
if (gt !== undefined && numValue <= gt) return false;
|
||||
if (gte !== undefined && numValue < gte) return false;
|
||||
if (lt !== undefined && numValue >= lt) return false;
|
||||
if (lte !== undefined && numValue > lte) return false;
|
||||
|
||||
return true;
|
||||
} else if (filter.type === 'text') {
|
||||
const { eq, neq, like, nlike, nill } = filter.filter;
|
||||
|
||||
// Handle null/undefined checks first
|
||||
if (nill !== undefined) {
|
||||
if (nill) {
|
||||
// Field doesn't exist or is null
|
||||
return !fieldExists || fieldValue === null || fieldValue === undefined;
|
||||
} else {
|
||||
// Field exists and is not null
|
||||
return fieldExists && fieldValue !== null && fieldValue !== undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// If field doesn't exist or is null for text operations, return false
|
||||
if (!fieldExists || fieldValue === null || fieldValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const strValue = String(fieldValue);
|
||||
|
||||
if (eq !== undefined && strValue !== eq) return false;
|
||||
if (neq !== undefined && strValue === neq) return false;
|
||||
if (like !== undefined) {
|
||||
// Convert SQL LIKE pattern to JavaScript regex
|
||||
const regexPattern = like.replace(/\\_/g, '_').replace(/%/g, '.*').replace(/\\\./g, '\\.');
|
||||
const regex = new RegExp(`^${regexPattern}$`, 'i'); // Case insensitive like SQL LIKE
|
||||
if (!regex.test(strValue)) return false;
|
||||
}
|
||||
if (nlike !== undefined) {
|
||||
// Convert SQL NOT LIKE pattern to JavaScript regex
|
||||
const regexPattern = nlike.replace(/\\_/g, '_').replace(/%/g, '.*').replace(/\\\./g, '\\.');
|
||||
const regex = new RegExp(`^${regexPattern}$`, 'i'); // Case insensitive like SQL LIKE
|
||||
if (regex.test(strValue)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} else if (filter.type === 'bool') {
|
||||
const { eq, nill } = filter.filter;
|
||||
|
||||
// Handle null/undefined checks first
|
||||
if (nill !== undefined) {
|
||||
if (nill) {
|
||||
// Field doesn't exist or is null
|
||||
return !fieldExists || fieldValue === null || fieldValue === undefined;
|
||||
} else {
|
||||
// Field exists and is not null
|
||||
return fieldExists && fieldValue !== null && fieldValue !== undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// If field doesn't exist or is null for boolean operations, return false
|
||||
if (!fieldExists || fieldValue === null || fieldValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const boolValue = typeof fieldValue === 'boolean' ? fieldValue : Boolean(fieldValue);
|
||||
return boolValue === eq;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates a meta condition (which can contain filters and nested AND/OR conditions) against a document
|
||||
*/
|
||||
const evaluateMetaCondition = (condition: MetaCondition, document: Document): boolean => {
|
||||
if (condition.type === 'and') {
|
||||
// ALL conditions must be true
|
||||
return condition.conditions.every((subCondition) => evaluateMetaCondition(subCondition, document));
|
||||
} else if (condition.type === 'or') {
|
||||
// AT LEAST ONE condition must be true
|
||||
return condition.conditions.some((subCondition) => evaluateMetaCondition(subCondition, document));
|
||||
} else {
|
||||
// It's a filter, not a condition
|
||||
return evaluateMetaFilter(condition, document);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters a document based on the provided filter/condition
|
||||
* Returns true if the document matches the filter, false otherwise
|
||||
*
|
||||
* @param filter - The filter or condition to apply
|
||||
* @param document - The document to test against the filter
|
||||
* @returns true if the document matches the filter, false otherwise
|
||||
*/
|
||||
export const filterDocument = (filter: MetaCondition | MetaFilter, document: Document): boolean => {
|
||||
return evaluateMetaCondition(filter, document);
|
||||
};
|
||||
@@ -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',
|
||||
125
packages/core/src/services/documents/documents.query.ts
Normal file
125
packages/core/src/services/documents/documents.query.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { Knex } from 'knex';
|
||||
|
||||
import type { MetaCondition, MetaFilter } from './documents.schemas.ts';
|
||||
|
||||
const buildMetaFilter = async (builder: Knex.QueryBuilder, filter: MetaFilter): Promise<void> => {
|
||||
const fieldPath = `metadata->'${filter.field}'`;
|
||||
|
||||
if (filter.type === 'number') {
|
||||
const { gt, gte, lt, lte, eq, neq, nill } = filter.filter;
|
||||
|
||||
if (nill !== undefined) {
|
||||
if (nill) {
|
||||
// Field doesn't exist or is null
|
||||
builder.where((subBuilder) => {
|
||||
subBuilder.whereRaw(`NOT (metadata ? '${filter.field}')`).orWhereRaw(`metadata->>'${filter.field}' IS NULL`);
|
||||
});
|
||||
} else {
|
||||
// Field exists and is not null
|
||||
builder.whereRaw(`metadata ? '${filter.field}' AND metadata->>'${filter.field}' IS NOT NULL`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (eq !== undefined) {
|
||||
builder.where(builder.client.raw(`(${fieldPath})::numeric`), eq);
|
||||
}
|
||||
if (neq !== undefined) {
|
||||
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '!=', neq);
|
||||
}
|
||||
if (gt !== undefined) {
|
||||
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '>', gt);
|
||||
}
|
||||
if (gte !== undefined) {
|
||||
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '>=', gte);
|
||||
}
|
||||
if (lt !== undefined) {
|
||||
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '<', lt);
|
||||
}
|
||||
if (lte !== undefined) {
|
||||
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '<=', lte);
|
||||
}
|
||||
} else if (filter.type === 'text') {
|
||||
const { eq, neq, like, nlike, nill } = filter.filter;
|
||||
|
||||
if (nill !== undefined) {
|
||||
if (nill) {
|
||||
// Field doesn't exist or is null
|
||||
builder.where((subBuilder) => {
|
||||
subBuilder.whereRaw(`NOT (metadata ? '${filter.field}')`).orWhereRaw(`metadata->>'${filter.field}' IS NULL`);
|
||||
});
|
||||
} else {
|
||||
// Field exists and is not null
|
||||
builder.whereRaw(`metadata ? '${filter.field}' AND metadata->>'${filter.field}' IS NOT NULL`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (eq !== undefined) {
|
||||
builder.where(builder.client.raw(`metadata->>'${filter.field}'`), eq);
|
||||
}
|
||||
if (neq !== undefined) {
|
||||
builder.where(builder.client.raw(`metadata->>'${filter.field}'`), '!=', neq);
|
||||
}
|
||||
if (like !== undefined) {
|
||||
builder.where(builder.client.raw(`metadata->>'${filter.field}'`), 'like', like);
|
||||
}
|
||||
if (nlike !== undefined) {
|
||||
builder.where(builder.client.raw(`metadata->>'${filter.field}'`), 'not like', nlike);
|
||||
}
|
||||
} else if (filter.type === 'bool') {
|
||||
const { eq, nill } = filter.filter;
|
||||
|
||||
if (nill !== undefined) {
|
||||
if (nill) {
|
||||
// Field doesn't exist or is null
|
||||
builder.where((subBuilder) => {
|
||||
subBuilder.whereRaw(`NOT (metadata ? '${filter.field}')`).orWhereRaw(`metadata->>'${filter.field}' IS NULL`);
|
||||
});
|
||||
} else {
|
||||
// Field exists and is not null
|
||||
builder.whereRaw(`metadata ? '${filter.field}' AND metadata->>'${filter.field}' IS NOT NULL`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
builder.where(builder.client.raw(`(${fieldPath})::boolean`), eq);
|
||||
}
|
||||
};
|
||||
|
||||
const buildMetaCondition = async (builder: Knex.QueryBuilder, condition: MetaCondition): Promise<void> => {
|
||||
if (condition.type === 'and') {
|
||||
// Handle AND conditions - all must be true
|
||||
for (const [index, subCondition] of condition.conditions.entries()) {
|
||||
if (index === 0) {
|
||||
// First condition doesn't need andWhere
|
||||
builder.where((subBuilder) => {
|
||||
buildMetaCondition(subBuilder, subCondition);
|
||||
});
|
||||
} else {
|
||||
builder.andWhere((subBuilder) => {
|
||||
buildMetaCondition(subBuilder, subCondition);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (condition.type === 'or') {
|
||||
// Handle OR conditions - at least one must be true
|
||||
for (const [index, subCondition] of condition.conditions.entries()) {
|
||||
if (index === 0) {
|
||||
// First condition doesn't need orWhere
|
||||
builder.where((subBuilder) => {
|
||||
buildMetaCondition(subBuilder, subCondition);
|
||||
});
|
||||
} else {
|
||||
builder.orWhere((subBuilder) => {
|
||||
buildMetaCondition(subBuilder, subCondition);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Handle individual filter conditions
|
||||
buildMetaFilter(builder, condition);
|
||||
}
|
||||
};
|
||||
|
||||
export { buildMetaFilter, buildMetaCondition };
|
||||
@@ -12,7 +12,7 @@ const documentSchema = z.object({
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
type Document = z.infer<typeof Document>;
|
||||
type Document = z.infer<typeof documentSchema>;
|
||||
|
||||
const documentUpsertSchema = documentSchema.omit({
|
||||
createdAt: true,
|
||||
@@ -89,5 +89,19 @@ const documentSearchOptionsSchema = z.object({
|
||||
|
||||
type DocumentSearchOptions = z.infer<typeof documentSearchOptionsSchema>;
|
||||
|
||||
export type { Document, DocumentUpsert, MetaFilter, MetaCondition, DocumentSearchOptions };
|
||||
export { documentSchema, documentUpsertSchema, metaFilterSchema, metaConditionSchema, documentSearchOptionsSchema };
|
||||
const documentUpsertEventSchema = z.object({
|
||||
action: z.union([z.literal('insert'), z.literal('update'), z.literal('delete')]),
|
||||
document: documentSchema,
|
||||
});
|
||||
|
||||
type DocumentUpsertEvent = z.infer<typeof documentUpsertEventSchema>;
|
||||
|
||||
export type { Document, DocumentUpsert, MetaFilter, MetaCondition, DocumentSearchOptions, DocumentUpsertEvent };
|
||||
export {
|
||||
documentSchema,
|
||||
documentUpsertSchema,
|
||||
metaFilterSchema,
|
||||
metaConditionSchema,
|
||||
documentSearchOptionsSchema,
|
||||
documentUpsertEventSchema,
|
||||
};
|
||||
|
||||
@@ -1,31 +1,53 @@
|
||||
import type { Knex } from 'knex';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
|
||||
import { DatabaseService, tableNames, type TableRow } from '../database/database.ts';
|
||||
|
||||
import type { DocumentSearchOptions, DocumentUpsert, MetaCondition, MetaFilter } from './documents.schemas.ts';
|
||||
import type { Document, DocumentSearchOptions, DocumentUpsert, DocumentUpsertEvent } from './documents.schemas.ts';
|
||||
import { buildMetaCondition } from './documents.query.ts';
|
||||
|
||||
import type { Services } from '#root/utils/services.ts';
|
||||
|
||||
class DocumentsService {
|
||||
type DocumentEvents = {
|
||||
upsert: (document: DocumentUpsertEvent) => void;
|
||||
};
|
||||
|
||||
class DocumentsService extends EventEmitter<DocumentEvents> {
|
||||
#services: Services;
|
||||
|
||||
constructor(services: Services) {
|
||||
super();
|
||||
this.#services = services;
|
||||
}
|
||||
|
||||
public upsert = async (document: DocumentUpsert) => {
|
||||
const db = await this.#services.get(DatabaseService).getDb();
|
||||
const baseItem = {
|
||||
const [current] = await db<TableRow['document']>(tableNames.documents)
|
||||
.where({
|
||||
uri: document.uri,
|
||||
type: document.type,
|
||||
})
|
||||
.limit(1);
|
||||
if (current) {
|
||||
const toInsert: Document = {
|
||||
...document,
|
||||
updatedAt: new Date(),
|
||||
createdAt: current.updatedAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
deletedAt: null,
|
||||
};
|
||||
await db(tableNames.documents)
|
||||
.insert({ ...baseItem, createdAt: new Date() })
|
||||
.onConflict(['uri', 'type'])
|
||||
.merge({
|
||||
...baseItem,
|
||||
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 };
|
||||
|
||||
50
packages/core/src/utils/tests.ts
Normal file
50
packages/core/src/utils/tests.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { it as vitestIt } from 'vitest';
|
||||
|
||||
type TimedTestOptions = {
|
||||
logTiming?: boolean;
|
||||
threshold?: number; // Log only if test takes longer than this (in ms)
|
||||
};
|
||||
|
||||
/**
|
||||
* A wrapper around vitest's `it` function that measures and logs the execution time
|
||||
* of just the test body (excluding beforeEach/afterEach hooks).
|
||||
*/
|
||||
export function itWithTiming(name: string, fn: () => void | Promise<void>, options: TimedTestOptions = {}) {
|
||||
const { logTiming = true, threshold = 0 } = options;
|
||||
|
||||
return vitestIt(name, async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
if (logTiming && duration >= threshold) {
|
||||
console.log(`⏱️ Test "${name}": ${duration.toFixed(2)}ms (body only)`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Like timedTest, but only logs timing for slow tests (configurable threshold)
|
||||
*/
|
||||
export function slowTest(name: string, fn: () => void | Promise<void>, thresholdMs = 100) {
|
||||
return itWithTiming(name, fn, { logTiming: true, threshold: thresholdMs });
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures execution time of a specific code block within a test
|
||||
*/
|
||||
export async function measureTime<T>(label: string, fn: () => T | Promise<T>): Promise<T> {
|
||||
const startTime = performance.now();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
console.log(`⏱️ ${label}: ${duration.toFixed(2)}ms`);
|
||||
}
|
||||
}
|
||||
4
packages/server/.gitignore
vendored
Normal file
4
packages/server/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/node_modules/
|
||||
/dist/
|
||||
/coverage/
|
||||
/.env
|
||||
39
packages/server/package.json
Normal file
39
packages/server/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"type": "module",
|
||||
"main": "dist/exports.js",
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"test:unit": "vitest --run --passWithNoTests",
|
||||
"test": "pnpm run \"/^test:/\""
|
||||
},
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"imports": {
|
||||
"#root/*": "./src/*"
|
||||
},
|
||||
"exports": {
|
||||
".": "./dist/exports.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@morten-olsen/fluxcurrent-configs": "workspace:*",
|
||||
"@morten-olsen/fluxcurrent-tests": "workspace:*",
|
||||
"@types/node": "24.3.1",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"typescript": "5.9.2",
|
||||
"vitest": "3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.1.0",
|
||||
"@fastify/swagger": "^9.5.1",
|
||||
"@morten-olsen/fluxcurrent-core": "workspace:*",
|
||||
"@scalar/fastify-api-reference": "^1.35.1",
|
||||
"fastify": "^5.6.0",
|
||||
"fastify-sse-v2": "^4.2.1",
|
||||
"fastify-type-provider-zod": "^6.0.0",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"name": "@morten-olsen/fluxcurrent-server",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
45
packages/server/src/api/api.ts
Normal file
45
packages/server/src/api/api.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import fastify from 'fastify';
|
||||
import fastifySwagger from '@fastify/swagger';
|
||||
import fastifyApiReference from '@scalar/fastify-api-reference';
|
||||
import { jsonSchemaTransform, serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
||||
import type { Services } from '@morten-olsen/fluxcurrent-core/utils/services.ts';
|
||||
import { FastifySSEPlugin } from 'fastify-sse-v2';
|
||||
|
||||
import { searchEndpoint } from './endpoints/endpoints.search.ts';
|
||||
import { documentsEndpoint } from './endpoints/endpoints.documents.ts';
|
||||
|
||||
type CreateApiOptions = {
|
||||
services: Services;
|
||||
};
|
||||
|
||||
const createApi = async (options: CreateApiOptions) => {
|
||||
const app = fastify();
|
||||
app.setValidatorCompiler(validatorCompiler);
|
||||
app.setSerializerCompiler(serializerCompiler);
|
||||
|
||||
app.register(fastifySwagger, {
|
||||
openapi: {
|
||||
info: {
|
||||
title: 'SampleApi',
|
||||
description: 'Sample backend service',
|
||||
version: '1.0.0',
|
||||
},
|
||||
servers: [],
|
||||
},
|
||||
transform: jsonSchemaTransform,
|
||||
});
|
||||
|
||||
await app.register(fastifyApiReference, {
|
||||
routePrefix: '/docs',
|
||||
});
|
||||
|
||||
await app.register(FastifySSEPlugin);
|
||||
|
||||
await app.register(searchEndpoint, { services: options.services, prefix: '/search' });
|
||||
await app.register(documentsEndpoint, { services: options.services, prefix: '/documents' });
|
||||
|
||||
await app.ready();
|
||||
return app;
|
||||
};
|
||||
|
||||
export { createApi };
|
||||
24
packages/server/src/api/endpoints/endpoints.documents.ts
Normal file
24
packages/server/src/api/endpoints/endpoints.documents.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
|
||||
import { z } from 'zod/v4';
|
||||
import type { Services } from '@morten-olsen/fluxcurrent-core/utils/services.ts';
|
||||
import { DocumentsService } from '@morten-olsen/fluxcurrent-core/services/documents/documents.ts';
|
||||
import { documentUpsertSchema } from '@morten-olsen/fluxcurrent-core/services/documents/documents.schemas.ts';
|
||||
|
||||
const documentsEndpoint: FastifyPluginAsyncZod<{ services: Services }> = async (fastify, { services }) => {
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '',
|
||||
schema: {
|
||||
body: z.object({
|
||||
document: documentUpsertSchema,
|
||||
}),
|
||||
},
|
||||
handler: async (req, res) => {
|
||||
const documentsService = services.get(DocumentsService);
|
||||
const documents = await documentsService.upsert(req.body.document);
|
||||
res.send(documents);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export { documentsEndpoint };
|
||||
62
packages/server/src/api/endpoints/endpoints.search.ts
Normal file
62
packages/server/src/api/endpoints/endpoints.search.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
|
||||
import { z } from 'zod/v4';
|
||||
import type { Services } from '@morten-olsen/fluxcurrent-core/utils/services.ts';
|
||||
import { parseDSL } from '@morten-olsen/fluxcurrent-core/services/documents/documents.dsl.ts';
|
||||
import { DocumentsService } from '@morten-olsen/fluxcurrent-core/services/documents/documents.ts';
|
||||
import {
|
||||
documentSchema,
|
||||
type DocumentUpsertEvent,
|
||||
} from '@morten-olsen/fluxcurrent-core/services/documents/documents.schemas.ts';
|
||||
import { filterDocument } from '@morten-olsen/fluxcurrent-core/services/documents/documents.filter.ts';
|
||||
|
||||
const searchEndpoint: FastifyPluginAsyncZod<{ services: Services }> = async (fastify, { services }) => {
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '',
|
||||
schema: {
|
||||
body: z.object({
|
||||
query: z.string().optional(),
|
||||
}),
|
||||
response: {
|
||||
200: z.array(documentSchema),
|
||||
},
|
||||
},
|
||||
handler: async (req, res) => {
|
||||
const query = req.body.query ? parseDSL(req.body.query) : {};
|
||||
const documentsService = services.get(DocumentsService);
|
||||
const documents = await documentsService.search(query);
|
||||
res.send(documents);
|
||||
},
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/stream',
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
query: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
handler: async (req, res) => {
|
||||
const query = req.query.query ? parseDSL(req.query.query) : {};
|
||||
const documentsService = services.get(DocumentsService);
|
||||
res.sse({ event: 'init' });
|
||||
const documents = await documentsService.search(query);
|
||||
for (const document of documents) {
|
||||
res.sse({ event: 'upsert', data: JSON.stringify(document) });
|
||||
}
|
||||
const listener = (event: DocumentUpsertEvent) => {
|
||||
if (query.meta && !filterDocument(query.meta, event.document)) {
|
||||
return;
|
||||
}
|
||||
res.sse({ event: 'upsert', data: JSON.stringify(event) });
|
||||
};
|
||||
documentsService.on('upsert', listener);
|
||||
req.socket.on('close', () => {
|
||||
documentsService.off('upsert', listener);
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export { searchEndpoint };
|
||||
664
packages/server/src/schemas/schemas.config.test.ts
Normal file
664
packages/server/src/schemas/schemas.config.test.ts
Normal file
@@ -0,0 +1,664 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { webhookConfigSchema, mqttConfigSchema, configSchema } from './schemas.config.ts';
|
||||
|
||||
describe('Configuration Schemas', () => {
|
||||
// Store original environment variables to restore after tests
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('Environment Variable Substitution', () => {
|
||||
it('should substitute environment variables in strings', () => {
|
||||
process.env.TEST_URL = 'https://example.com';
|
||||
process.env.TEST_SECRET = 'secret123';
|
||||
|
||||
const config = {
|
||||
type: 'webhook' as const,
|
||||
url: '${TEST_URL}/webhook',
|
||||
secret: 'Bearer ${TEST_SECRET}',
|
||||
};
|
||||
|
||||
const result = webhookConfigSchema.parse(config);
|
||||
expect(result.url).toBe('https://example.com/webhook');
|
||||
expect(result.secret).toBe('Bearer secret123');
|
||||
});
|
||||
|
||||
it('should handle missing environment variables by substituting empty string', () => {
|
||||
const config = {
|
||||
type: 'webhook' as const,
|
||||
url: '${MISSING_VAR}/webhook',
|
||||
};
|
||||
|
||||
const result = webhookConfigSchema.parse(config);
|
||||
expect(result.url).toBe('/webhook');
|
||||
});
|
||||
|
||||
it('should handle multiple environment variables in a single string', () => {
|
||||
process.env.HOST = 'localhost';
|
||||
process.env.PORT = '3000';
|
||||
process.env.PATH_PREFIX = '/api/v1';
|
||||
|
||||
const config = {
|
||||
type: 'webhook' as const,
|
||||
url: 'http://${HOST}:${PORT}${PATH_PREFIX}/webhook',
|
||||
};
|
||||
|
||||
const result = webhookConfigSchema.parse(config);
|
||||
expect(result.url).toBe('http://localhost:3000/api/v1/webhook');
|
||||
});
|
||||
|
||||
it('should leave strings without substitution patterns unchanged', () => {
|
||||
const config = {
|
||||
type: 'webhook' as const,
|
||||
url: 'https://static.example.com/webhook',
|
||||
};
|
||||
|
||||
const result = webhookConfigSchema.parse(config);
|
||||
expect(result.url).toBe('https://static.example.com/webhook');
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
const config = {
|
||||
type: 'webhook' as const,
|
||||
url: '',
|
||||
};
|
||||
|
||||
const result = webhookConfigSchema.parse(config);
|
||||
expect(result.url).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Webhook Configuration', () => {
|
||||
it('should parse valid webhook configuration with all fields', () => {
|
||||
const config = {
|
||||
type: 'webhook' as const,
|
||||
url: 'https://example.com/webhook',
|
||||
secret: 'secret123',
|
||||
};
|
||||
|
||||
const result = webhookConfigSchema.parse(config);
|
||||
expect(result).toEqual(config);
|
||||
});
|
||||
|
||||
it('should parse webhook configuration without optional secret', () => {
|
||||
const config = {
|
||||
type: 'webhook' as const,
|
||||
url: 'https://example.com/webhook',
|
||||
};
|
||||
|
||||
const result = webhookConfigSchema.parse(config);
|
||||
expect(result).toEqual(config);
|
||||
expect(result.secret).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject invalid webhook type', () => {
|
||||
const config = {
|
||||
type: 'invalid',
|
||||
url: 'https://example.com/webhook',
|
||||
};
|
||||
|
||||
expect(() => webhookConfigSchema.parse(config)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject webhook configuration without required url', () => {
|
||||
const config = {
|
||||
type: 'webhook' as const,
|
||||
};
|
||||
|
||||
expect(() => webhookConfigSchema.parse(config)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MQTT Configuration', () => {
|
||||
it('should parse valid MQTT configuration with all fields', () => {
|
||||
const config = {
|
||||
type: 'mqtt' as const,
|
||||
url: 'mqtt://broker.example.com:1883',
|
||||
username: 'user123',
|
||||
password: 'pass123',
|
||||
baseTopic: 'notifications',
|
||||
};
|
||||
|
||||
const result = mqttConfigSchema.parse(config);
|
||||
expect(result).toEqual(config);
|
||||
});
|
||||
|
||||
it('should parse MQTT configuration with only required fields', () => {
|
||||
const config = {
|
||||
type: 'mqtt' as const,
|
||||
url: 'mqtt://broker.example.com:1883',
|
||||
baseTopic: 'notifications',
|
||||
};
|
||||
|
||||
const result = mqttConfigSchema.parse(config);
|
||||
expect(result).toEqual(config);
|
||||
expect(result.username).toBeUndefined();
|
||||
expect(result.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle environment variable substitution in MQTT fields', () => {
|
||||
process.env.MQTT_HOST = 'mqtt.example.com';
|
||||
process.env.MQTT_USER = 'mqttuser';
|
||||
process.env.MQTT_PASS = 'mqttpass';
|
||||
process.env.MQTT_TOPIC = 'app/notifications';
|
||||
|
||||
const config = {
|
||||
type: 'mqtt' as const,
|
||||
url: 'mqtt://${MQTT_HOST}:1883',
|
||||
username: '${MQTT_USER}',
|
||||
password: '${MQTT_PASS}',
|
||||
baseTopic: '${MQTT_TOPIC}',
|
||||
};
|
||||
|
||||
const result = mqttConfigSchema.parse(config);
|
||||
expect(result.url).toBe('mqtt://mqtt.example.com:1883');
|
||||
expect(result.username).toBe('mqttuser');
|
||||
expect(result.password).toBe('mqttpass');
|
||||
expect(result.baseTopic).toBe('app/notifications');
|
||||
});
|
||||
|
||||
it('should reject invalid MQTT type', () => {
|
||||
const config = {
|
||||
type: 'invalid',
|
||||
url: 'mqtt://broker.example.com:1883',
|
||||
baseTopic: 'notifications',
|
||||
};
|
||||
|
||||
expect(() => mqttConfigSchema.parse(config)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject MQTT configuration without required fields', () => {
|
||||
const config = {
|
||||
type: 'mqtt' as const,
|
||||
url: 'mqtt://broker.example.com:1883',
|
||||
// missing baseTopic
|
||||
};
|
||||
|
||||
expect(() => mqttConfigSchema.parse(config)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Database Configuration', () => {
|
||||
describe('PGLite Configuration', () => {
|
||||
it('should parse valid PGLite configuration with path', () => {
|
||||
const config = {
|
||||
database: {
|
||||
type: 'pg-lite' as const,
|
||||
path: './data/db.sqlite',
|
||||
},
|
||||
};
|
||||
|
||||
const result = configSchema.parse(config);
|
||||
expect(result.database).toEqual(config.database);
|
||||
});
|
||||
|
||||
it('should parse PGLite configuration without optional path', () => {
|
||||
const config = {
|
||||
database: {
|
||||
type: 'pg-lite' as const,
|
||||
},
|
||||
};
|
||||
|
||||
const result = configSchema.parse(config);
|
||||
expect(result.database.type).toBe('pg-lite');
|
||||
expect(result.database.path).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle environment variable substitution in PGLite path', () => {
|
||||
process.env.DB_PATH = '/var/data/app.db';
|
||||
|
||||
const config = {
|
||||
database: {
|
||||
type: 'pg-lite' as const,
|
||||
path: '${DB_PATH}',
|
||||
},
|
||||
};
|
||||
|
||||
const result = configSchema.parse(config);
|
||||
expect(result.database.path).toBe('/var/data/app.db');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PostgreSQL Configuration', () => {
|
||||
it('should parse valid PostgreSQL configuration with all fields', () => {
|
||||
const config = {
|
||||
database: {
|
||||
type: 'pg' as const,
|
||||
host: 'localhost',
|
||||
port: '5432',
|
||||
user: 'postgres',
|
||||
password: 'password123',
|
||||
database: 'myapp',
|
||||
},
|
||||
};
|
||||
|
||||
const result = configSchema.parse(config);
|
||||
expect(result.database).toEqual({
|
||||
...config.database,
|
||||
port: 5432, // should be transformed to number
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse PostgreSQL configuration without optional port (defaults to 5432)', () => {
|
||||
const config = {
|
||||
database: {
|
||||
type: 'pg' as const,
|
||||
host: 'localhost',
|
||||
user: 'postgres',
|
||||
password: 'password123',
|
||||
database: 'myapp',
|
||||
},
|
||||
};
|
||||
|
||||
const result = configSchema.parse(config);
|
||||
expect(result.database.port).toBe(5432);
|
||||
});
|
||||
|
||||
it('should transform port string to number', () => {
|
||||
const config = {
|
||||
database: {
|
||||
type: 'pg' as const,
|
||||
host: 'localhost',
|
||||
port: '3306',
|
||||
user: 'postgres',
|
||||
password: 'password123',
|
||||
database: 'myapp',
|
||||
},
|
||||
};
|
||||
|
||||
const result = configSchema.parse(config);
|
||||
expect(result.database.port).toBe(3306);
|
||||
expect(typeof result.database.port).toBe('number');
|
||||
});
|
||||
|
||||
it('should handle environment variable substitution in PostgreSQL fields', () => {
|
||||
process.env.DB_HOST = 'prod-db.example.com';
|
||||
process.env.DB_USER = 'app_user';
|
||||
process.env.DB_PASS = 'secure_password';
|
||||
process.env.DB_NAME = 'production_db';
|
||||
process.env.DB_PORT = '5433';
|
||||
|
||||
const config = {
|
||||
database: {
|
||||
type: 'pg' as const,
|
||||
host: '${DB_HOST}',
|
||||
port: '${DB_PORT}',
|
||||
user: '${DB_USER}',
|
||||
password: '${DB_PASS}',
|
||||
database: '${DB_NAME}',
|
||||
},
|
||||
};
|
||||
|
||||
const result = configSchema.parse(config);
|
||||
expect(result.database.host).toBe('prod-db.example.com');
|
||||
expect(result.database.port).toBe(5433);
|
||||
expect(result.database.user).toBe('app_user');
|
||||
expect(result.database.password).toBe('secure_password');
|
||||
expect(result.database.database).toBe('production_db');
|
||||
});
|
||||
|
||||
it('should reject PostgreSQL configuration with missing required fields', () => {
|
||||
const config = {
|
||||
database: {
|
||||
type: 'pg' as const,
|
||||
host: 'localhost',
|
||||
// missing user, password, database
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => configSchema.parse(config)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('should use PGLite as default when no database config is provided', () => {
|
||||
const config = {};
|
||||
|
||||
const result = configSchema.parse(config);
|
||||
expect(result.database).toEqual({ type: 'pg-lite' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDC Configuration', () => {
|
||||
it('should parse valid OIDC configuration', () => {
|
||||
const config = {
|
||||
oidc: {
|
||||
type: 'oidc' as const,
|
||||
issuer: 'https://auth.example.com',
|
||||
clientId: 'client123',
|
||||
clientSecret: 'secret123',
|
||||
},
|
||||
};
|
||||
|
||||
const result = configSchema.parse(config);
|
||||
expect(result.oidc).toEqual(config.oidc);
|
||||
});
|
||||
|
||||
it('should handle environment variable substitution in OIDC fields', () => {
|
||||
process.env.OIDC_ISSUER = 'https://login.provider.com';
|
||||
process.env.OIDC_CLIENT_ID = 'my-app-client';
|
||||
process.env.OIDC_CLIENT_SECRET = 'very-secret-key';
|
||||
|
||||
const config = {
|
||||
oidc: {
|
||||
type: 'oidc' as const,
|
||||
issuer: '${OIDC_ISSUER}',
|
||||
clientId: '${OIDC_CLIENT_ID}',
|
||||
clientSecret: '${OIDC_CLIENT_SECRET}',
|
||||
},
|
||||
};
|
||||
|
||||
const result = configSchema.parse(config);
|
||||
expect(result.oidc!.issuer).toBe('https://login.provider.com');
|
||||
expect(result.oidc!.clientId).toBe('my-app-client');
|
||||
expect(result.oidc!.clientSecret).toBe('very-secret-key');
|
||||
});
|
||||
|
||||
it('should be optional and undefined when not provided', () => {
|
||||
const config = {};
|
||||
|
||||
const result = configSchema.parse(config);
|
||||
expect(result.oidc).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject OIDC configuration with missing required fields', () => {
|
||||
const config = {
|
||||
oidc: {
|
||||
type: 'oidc' as const,
|
||||
issuer: 'https://auth.example.com',
|
||||
// missing clientId and clientSecret
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => configSchema.parse(config)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Notifications Configuration', () => {
|
||||
it('should parse empty notifications array as default', () => {
|
||||
const config = {};
|
||||
|
||||
const result = configSchema.parse(config);
|
||||
expect(result.notifications).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse array with webhook notifications', () => {
|
||||
const config = {
|
||||
notifications: [
|
||||
{
|
||||
type: 'webhook' as const,
|
||||
url: 'https://hook1.example.com',
|
||||
secret: 'secret1',
|
||||
},
|
||||
{
|
||||
type: 'webhook' as const,
|
||||
url: 'https://hook2.example.com',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = configSchema.parse(config);
|
||||
expect(result.notifications).toEqual(config.notifications);
|
||||
});
|
||||
|
||||
it('should parse array with MQTT notifications', () => {
|
||||
const config = {
|
||||
notifications: [
|
||||
{
|
||||
type: 'mqtt' as const,
|
||||
url: 'mqtt://broker1.example.com',
|
||||
baseTopic: 'app1/notifications',
|
||||
username: 'user1',
|
||||
password: 'pass1',
|
||||
},
|
||||
{
|
||||
type: 'mqtt' as const,
|
||||
url: 'mqtt://broker2.example.com',
|
||||
baseTopic: 'app2/notifications',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = configSchema.parse(config);
|
||||
expect(result.notifications).toEqual(config.notifications);
|
||||
});
|
||||
|
||||
it('should parse mixed webhook and MQTT notifications', () => {
|
||||
const config = {
|
||||
notifications: [
|
||||
{
|
||||
type: 'webhook' as const,
|
||||
url: 'https://webhook.example.com',
|
||||
secret: 'webhook-secret',
|
||||
},
|
||||
{
|
||||
type: 'mqtt' as const,
|
||||
url: 'mqtt://mqtt.example.com',
|
||||
baseTopic: 'notifications',
|
||||
username: 'mqttuser',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = configSchema.parse(config);
|
||||
expect(result.notifications).toEqual(config.notifications);
|
||||
});
|
||||
|
||||
it('should reject invalid notification types', () => {
|
||||
const config = {
|
||||
notifications: [
|
||||
{
|
||||
type: 'invalid',
|
||||
url: 'https://example.com',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(() => configSchema.parse(config)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complete Configuration', () => {
|
||||
it('should parse complete configuration with all components', () => {
|
||||
process.env.DB_HOST = 'db.example.com';
|
||||
process.env.WEBHOOK_SECRET = 'webhook123';
|
||||
process.env.OIDC_ISSUER = 'https://auth.example.com';
|
||||
|
||||
const config = {
|
||||
database: {
|
||||
type: 'pg' as const,
|
||||
host: '${DB_HOST}',
|
||||
port: '5432',
|
||||
user: 'postgres',
|
||||
password: 'password',
|
||||
database: 'myapp',
|
||||
},
|
||||
oidc: {
|
||||
type: 'oidc' as const,
|
||||
issuer: '${OIDC_ISSUER}',
|
||||
clientId: 'client123',
|
||||
clientSecret: 'secret123',
|
||||
},
|
||||
notifications: [
|
||||
{
|
||||
type: 'webhook' as const,
|
||||
url: 'https://webhook.example.com',
|
||||
secret: '${WEBHOOK_SECRET}',
|
||||
},
|
||||
{
|
||||
type: 'mqtt' as const,
|
||||
url: 'mqtt://mqtt.example.com',
|
||||
baseTopic: 'notifications',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = configSchema.parse(config);
|
||||
|
||||
expect(result.database.type).toBe('pg');
|
||||
expect(result.database.host).toBe('db.example.com');
|
||||
expect(result.database.port).toBe(5432);
|
||||
|
||||
expect(result.oidc?.issuer).toBe('https://auth.example.com');
|
||||
|
||||
expect(result.notifications).toHaveLength(2);
|
||||
expect(result.notifications[0].type).toBe('webhook');
|
||||
expect((result.notifications[0] as any).secret).toBe('webhook123');
|
||||
expect(result.notifications[1].type).toBe('mqtt');
|
||||
});
|
||||
|
||||
it('should parse minimal configuration with defaults', () => {
|
||||
const config = {};
|
||||
|
||||
const result = configSchema.parse(config);
|
||||
|
||||
expect(result.database).toEqual({ type: 'pg-lite' });
|
||||
expect(result.oidc).toBeUndefined();
|
||||
expect(result.notifications).toEqual([]);
|
||||
});
|
||||
|
||||
it('should override defaults when values are provided', () => {
|
||||
const config = {
|
||||
database: {
|
||||
type: 'pg' as const,
|
||||
host: 'localhost',
|
||||
user: 'postgres',
|
||||
password: 'password',
|
||||
database: 'test',
|
||||
},
|
||||
notifications: [
|
||||
{
|
||||
type: 'webhook' as const,
|
||||
url: 'https://test.com',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = configSchema.parse(config);
|
||||
|
||||
expect(result.database.type).toBe('pg');
|
||||
expect(result.notifications).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Cases', () => {
|
||||
it('should reject configuration with invalid database type', () => {
|
||||
const config = {
|
||||
database: {
|
||||
type: 'invalid-db-type',
|
||||
host: 'localhost',
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => configSchema.parse(config)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject configuration with invalid notification structure', () => {
|
||||
const config = {
|
||||
notifications: [
|
||||
{
|
||||
type: 'webhook',
|
||||
// missing required url
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(() => configSchema.parse(config)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject non-array notifications', () => {
|
||||
const config = {
|
||||
notifications: {
|
||||
type: 'webhook',
|
||||
url: 'https://example.com',
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => configSchema.parse(config)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject invalid OIDC type', () => {
|
||||
const config = {
|
||||
oidc: {
|
||||
type: 'invalid-oidc',
|
||||
issuer: 'https://example.com',
|
||||
clientId: 'client',
|
||||
clientSecret: 'secret',
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => configSchema.parse(config)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle environment variables with special characters', () => {
|
||||
process.env.SPECIAL_VAR = 'value-with-dashes_and_underscores.dots';
|
||||
|
||||
const config = {
|
||||
notifications: [
|
||||
{
|
||||
type: 'webhook' as const,
|
||||
url: 'https://example.com/${SPECIAL_VAR}',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = configSchema.parse(config);
|
||||
expect((result.notifications[0] as any).url).toBe('https://example.com/value-with-dashes_and_underscores.dots');
|
||||
});
|
||||
|
||||
it('should handle nested environment variable patterns', () => {
|
||||
process.env.HOST = 'example.com';
|
||||
|
||||
const config = {
|
||||
notifications: [
|
||||
{
|
||||
type: 'webhook' as const,
|
||||
url: 'https://${HOST}/${HOST}/webhook',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = configSchema.parse(config);
|
||||
expect((result.notifications[0] as any).url).toBe('https://example.com/example.com/webhook');
|
||||
});
|
||||
|
||||
it('should handle port transformation with invalid number', () => {
|
||||
const config = {
|
||||
database: {
|
||||
type: 'pg' as const,
|
||||
host: 'localhost',
|
||||
port: 'invalid-port',
|
||||
user: 'postgres',
|
||||
password: 'password',
|
||||
database: 'test',
|
||||
},
|
||||
};
|
||||
|
||||
const result = configSchema.parse(config);
|
||||
expect(result.database.port).toBeNaN();
|
||||
});
|
||||
|
||||
it('should handle empty environment variable names in substitution', () => {
|
||||
const config = {
|
||||
notifications: [
|
||||
{
|
||||
type: 'webhook' as const,
|
||||
url: 'https://example.com/${}',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = configSchema.parse(config);
|
||||
// Empty braces ${} don't match the regex \w+ so they remain unchanged
|
||||
expect((result.notifications[0] as any).url).toBe('https://example.com/${}');
|
||||
});
|
||||
});
|
||||
});
|
||||
57
packages/server/src/schemas/schemas.config.ts
Normal file
57
packages/server/src/schemas/schemas.config.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const stringWithEnvSubstitutionSchema = z.string().transform((value) => {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
return value.replace(/\${(\w+)}/g, (_, p1) => {
|
||||
return process.env[p1] || '';
|
||||
});
|
||||
});
|
||||
|
||||
const webhookConfigSchema = z.object({
|
||||
type: z.literal('webhook'),
|
||||
url: stringWithEnvSubstitutionSchema,
|
||||
secret: stringWithEnvSubstitutionSchema.optional(),
|
||||
});
|
||||
|
||||
const mqttConfigSchema = z.object({
|
||||
type: z.literal('mqtt'),
|
||||
url: stringWithEnvSubstitutionSchema,
|
||||
username: stringWithEnvSubstitutionSchema.optional(),
|
||||
password: stringWithEnvSubstitutionSchema.optional(),
|
||||
baseTopic: stringWithEnvSubstitutionSchema,
|
||||
});
|
||||
|
||||
const oidcConfigSchema = z.object({
|
||||
type: z.literal('oidc'),
|
||||
issuer: stringWithEnvSubstitutionSchema,
|
||||
clientId: stringWithEnvSubstitutionSchema,
|
||||
clientSecret: stringWithEnvSubstitutionSchema,
|
||||
});
|
||||
|
||||
const notificationsConfigSchema = z.union([webhookConfigSchema, mqttConfigSchema]);
|
||||
|
||||
const pgLiteConfigSchema = z.object({
|
||||
type: z.literal('pg-lite'),
|
||||
path: stringWithEnvSubstitutionSchema.optional(),
|
||||
});
|
||||
|
||||
const pgConfigSchema = z.object({
|
||||
type: z.literal('pg'),
|
||||
host: stringWithEnvSubstitutionSchema,
|
||||
port: stringWithEnvSubstitutionSchema.optional().transform((value) => (value ? parseInt(value) : 5432)),
|
||||
user: stringWithEnvSubstitutionSchema,
|
||||
password: stringWithEnvSubstitutionSchema,
|
||||
database: stringWithEnvSubstitutionSchema,
|
||||
});
|
||||
|
||||
const databaseConfigSchema = z.union([pgLiteConfigSchema, pgConfigSchema]);
|
||||
|
||||
const configSchema = z.object({
|
||||
database: databaseConfigSchema.default({ type: 'pg-lite' }),
|
||||
oidc: oidcConfigSchema.optional(),
|
||||
notifications: z.array(notificationsConfigSchema).default([]),
|
||||
});
|
||||
|
||||
export { webhookConfigSchema, mqttConfigSchema, configSchema };
|
||||
8
packages/server/src/start.ts
Normal file
8
packages/server/src/start.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Services } from '@morten-olsen/fluxcurrent-core/utils/services.ts';
|
||||
|
||||
import { createApi } from './api/api.ts';
|
||||
|
||||
const services = new Services();
|
||||
const api = await createApi({ services });
|
||||
|
||||
await api.listen({ port: 3000 });
|
||||
12
packages/server/tsconfig.json
Normal file
12
packages/server/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"paths": {
|
||||
"#root/*": ["./src"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"extends": "@morten-olsen/fluxcurrent-configs/tsconfig.json"
|
||||
}
|
||||
12
packages/server/vitest.config.ts
Normal file
12
packages/server/vitest.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { getAliases } from '@morten-olsen/fluxcurrent-tests/vitest';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default defineConfig(async () => {
|
||||
const aliases = await getAliases();
|
||||
return {
|
||||
resolve: {
|
||||
alias: aliases,
|
||||
},
|
||||
};
|
||||
});
|
||||
777
pnpm-lock.yaml
generated
777
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user