chore: improved schema

This commit is contained in:
Morten Olsen
2025-12-10 20:45:02 +01:00
parent d02102977a
commit 0646390d52
16 changed files with 920 additions and 118 deletions

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

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

View File

@@ -0,0 +1,34 @@
{
"type": "module",
"main": "dist/exports.js",
"scripts": {
"build": "tsc --build",
"test:unit": "vitest --run --passWithNoTests",
"test": "pnpm run \"/^test:/\"",
"generate:client": "openapi-typescript http://localhost:3400/docs/openapi.json -o src/__generated__/schema.ts"
},
"packageManager": "pnpm@10.6.0",
"files": [
"dist"
],
"exports": {
".": "./dist/exports.js"
},
"devDependencies": {
"@morten-olsen/stash-configs": "workspace:*",
"@morten-olsen/stash-tests": "workspace:*",
"@types/node": "24.10.2",
"@vitest/coverage-v8": "4.0.15",
"openapi-typescript": "^7.10.1",
"typescript": "5.9.3",
"vitest": "4.0.15"
},
"name": "@morten-olsen/stash-client",
"version": "1.0.0",
"imports": {
"#root/*": "./src/*"
},
"dependencies": {
"openapi-fetch": "^0.15.0"
}
}

View File

@@ -0,0 +1,515 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/system/ready": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get system ready state */
get: operations["GET/system/ready"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/documents": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Upsert document */
post: operations["POST/documents"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/document-filters": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Find documents */
post: operations["POST/documents-filters"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/document-chunk-filters": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Find document chunks */
post: operations["POST/documents-chunk-filters"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: never;
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
"GET/system/ready": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Default Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @enum {string} */
status: "ok";
};
};
};
};
};
"POST/documents": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: {
content: {
"application/json": {
id?: string | null;
owner?: string | null;
contentType?: string | null;
content?: string | null;
source?: string | null;
sourceId?: string | null;
type?: string;
typeVersion?: number | null;
searchText?: string | null;
metadata?: unknown;
};
};
};
responses: {
/** @description Default Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @enum {string} */
action: "inserted" | "updated" | "skipped";
id: string;
document: {
id: string;
owner: string | null;
/** Format: date-time */
createdAt: string;
/** Format: date-time */
updatedAt: string;
/** Format: date-time */
deletedAt: string | null;
contentType: string | null;
content: string | null;
source: string | null;
sourceId: string | null;
type: string;
typeVersion: number | null;
searchText: string | null;
metadata: unknown;
};
};
};
};
};
};
"POST/documents-filters": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @default 0 */
offset?: number;
/** @default 20 */
limit?: number;
condition: (({
/** @enum {string} */
type: "text";
tableName?: string;
field: string[];
conditions: {
equal?: string | null;
notEqual?: string;
like?: string;
notLike?: string;
in?: string[];
notIn?: string[];
};
} | {
/** @enum {string} */
type: "number";
tableName?: string;
field: string[];
conditions: {
equals?: number | null;
notEquals?: number | null;
greaterThan?: number;
greaterThanOrEqual?: number;
lessThan?: number;
lessThanOrEqual?: number;
in?: number[];
notIn?: number[];
};
}) | {
/** @enum {string} */
type: "operator";
/** @enum {string} */
operator: "and" | "or";
conditions: (({
/** @enum {string} */
type: "text";
tableName?: string;
field: string[];
conditions: {
equal?: string | null;
notEqual?: string;
like?: string;
notLike?: string;
in?: string[];
notIn?: string[];
};
} | {
/** @enum {string} */
type: "number";
tableName?: string;
field: string[];
conditions: {
equals?: number | null;
notEquals?: number | null;
greaterThan?: number;
greaterThanOrEqual?: number;
lessThan?: number;
lessThanOrEqual?: number;
in?: number[];
notIn?: number[];
};
}) | {
/** @enum {string} */
type: "operator";
/** @enum {string} */
operator: "and" | "or";
conditions: (({
/** @enum {string} */
type: "text";
tableName?: string;
field: string[];
conditions: {
equal?: string | null;
notEqual?: string;
like?: string;
notLike?: string;
in?: string[];
notIn?: string[];
};
} | {
/** @enum {string} */
type: "number";
tableName?: string;
field: string[];
conditions: {
equals?: number | null;
notEquals?: number | null;
greaterThan?: number;
greaterThanOrEqual?: number;
lessThan?: number;
lessThanOrEqual?: number;
in?: number[];
notIn?: number[];
};
}) | {
/** @enum {string} */
type: "operator";
/** @enum {string} */
operator: "and" | "or";
conditions: ({
/** @enum {string} */
type: "text";
tableName?: string;
field: string[];
conditions: {
equal?: string | null;
notEqual?: string;
like?: string;
notLike?: string;
in?: string[];
notIn?: string[];
};
} | {
/** @enum {string} */
type: "number";
tableName?: string;
field: string[];
conditions: {
equals?: number | null;
notEquals?: number | null;
greaterThan?: number;
greaterThanOrEqual?: number;
lessThan?: number;
lessThanOrEqual?: number;
in?: number[];
notIn?: number[];
};
})[];
})[];
})[];
}) | string;
};
};
};
responses: {
/** @description Default Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
items: {
id: string;
owner: string | null;
/** Format: date-time */
createdAt: string;
/** Format: date-time */
updatedAt: string;
/** Format: date-time */
deletedAt: string | null;
contentType: string | null;
content: string | null;
source: string | null;
sourceId: string | null;
type: string;
typeVersion: number | null;
searchText: string | null;
metadata: unknown;
}[];
};
};
};
};
};
"POST/documents-chunk-filters": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: {
content: {
"application/json": {
/** @default 20 */
limit?: number;
/** @default 0 */
offset?: number;
semanticText?: string;
conditions?: (({
/** @enum {string} */
type: "text";
tableName?: string;
field: string[];
conditions: {
equal?: string | null;
notEqual?: string;
like?: string;
notLike?: string;
in?: string[];
notIn?: string[];
};
} | {
/** @enum {string} */
type: "number";
tableName?: string;
field: string[];
conditions: {
equals?: number | null;
notEquals?: number | null;
greaterThan?: number;
greaterThanOrEqual?: number;
lessThan?: number;
lessThanOrEqual?: number;
in?: number[];
notIn?: number[];
};
}) | {
/** @enum {string} */
type: "operator";
/** @enum {string} */
operator: "and" | "or";
conditions: (({
/** @enum {string} */
type: "text";
tableName?: string;
field: string[];
conditions: {
equal?: string | null;
notEqual?: string;
like?: string;
notLike?: string;
in?: string[];
notIn?: string[];
};
} | {
/** @enum {string} */
type: "number";
tableName?: string;
field: string[];
conditions: {
equals?: number | null;
notEquals?: number | null;
greaterThan?: number;
greaterThanOrEqual?: number;
lessThan?: number;
lessThanOrEqual?: number;
in?: number[];
notIn?: number[];
};
}) | {
/** @enum {string} */
type: "operator";
/** @enum {string} */
operator: "and" | "or";
conditions: (({
/** @enum {string} */
type: "text";
tableName?: string;
field: string[];
conditions: {
equal?: string | null;
notEqual?: string;
like?: string;
notLike?: string;
in?: string[];
notIn?: string[];
};
} | {
/** @enum {string} */
type: "number";
tableName?: string;
field: string[];
conditions: {
equals?: number | null;
notEquals?: number | null;
greaterThan?: number;
greaterThanOrEqual?: number;
lessThan?: number;
lessThanOrEqual?: number;
in?: number[];
notIn?: number[];
};
}) | {
/** @enum {string} */
type: "operator";
/** @enum {string} */
operator: "and" | "or";
conditions: ({
/** @enum {string} */
type: "text";
tableName?: string;
field: string[];
conditions: {
equal?: string | null;
notEqual?: string;
like?: string;
notLike?: string;
in?: string[];
notIn?: string[];
};
} | {
/** @enum {string} */
type: "number";
tableName?: string;
field: string[];
conditions: {
equals?: number | null;
notEquals?: number | null;
greaterThan?: number;
greaterThanOrEqual?: number;
lessThan?: number;
lessThanOrEqual?: number;
in?: number[];
notIn?: number[];
};
})[];
})[];
})[];
}) | string;
};
};
};
responses: {
/** @description Default Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
items: {
id: string;
owner: string;
content: string;
metadata: unknown;
distance?: number;
}[];
};
};
};
};
};
}

View File

@@ -0,0 +1,19 @@
import createApiClient from 'openapi-fetch';
import type { paths } from './__generated__/schema.js';
type CreateStashClientOptions = {
baseUrl: string;
};
type StashClient = ReturnType<typeof createApiClient<paths>>;
const createStashClient = (options: CreateStashClientOptions): StashClient => {
const client = createApiClient<paths>({
baseUrl: options.baseUrl,
});
return client;
};
export type { StashClient };
export { createStashClient };

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"outDir": "./dist"
},
"include": [
"src/**/*.ts"
],
"extends": "@morten-olsen/stash-configs/tsconfig.json"
}

View File

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

View File

@@ -1,6 +1,7 @@
import { createToken, Lexer, EmbeddedActionsParser } from 'chevrotain';
import type { ZodType } from 'zod';
import type { QueryFilter, QueryCondition } from './query-parser.schemas.js';
import { type QueryFilter, type QueryCondition, queryFilterSchema } from './query-parser.schemas.js';
// ----------------- Lexer -----------------
@@ -426,7 +427,10 @@ class QueryParserParser extends EmbeddedActionsParser {
return this.SUBRULE(this.#orExpression);
});
public parse = (input: string): QueryFilter => {
public parse = <T extends typeof queryFilterSchema>(
input: string,
schema: T = queryFilterSchema as unknown as T,
): QueryFilter => {
const lexResult = QueryLexer.tokenize(input);
if (lexResult.errors.length > 0) {
@@ -450,7 +454,7 @@ class QueryParserParser extends EmbeddedActionsParser {
throw new Error(`Parse error: ${error.message}`);
}
return result;
return schema.parse(result);
};
}

View File

@@ -1,85 +1,65 @@
import { z } from 'zod';
import { z, ZodArray } from 'zod';
const queryConditionTextSchema = z.object({
type: z.literal('text'),
tableName: z.string().optional(),
field: z.array(z.string()),
conditions: z.object({
equal: z.string().nullish(),
notEqual: z.string().optional(),
like: z.string().optional(),
notLike: z.string().optional(),
in: z.array(z.string()).optional(),
notIn: z.array(z.string()).optional(),
}),
});
const queryConditionTextSchema = z
.object({
type: z.literal('text'),
tableName: z.string().optional(),
field: z.array(z.string()),
conditions: z.object({
equal: z.string().nullish(),
notEqual: z.string().optional(),
like: z.string().optional(),
notLike: z.string().optional(),
in: z.array(z.string()).optional(),
notIn: z.array(z.string()).optional(),
}),
})
.meta({ id: 'QueryConditionText' });
type QueryConditionText = z.infer<typeof queryConditionTextSchema>;
const queryConditionNumberSchema = z.object({
type: z.literal('number'),
tableName: z.string().optional(),
field: z.array(z.string()),
conditions: z.object({
equals: z.number().nullish(),
notEquals: z.number().nullish(),
greaterThan: z.number().optional(),
greaterThanOrEqual: z.number().optional(),
lessThan: z.number().optional(),
lessThanOrEqual: z.number().optional(),
in: z.array(z.number()).optional(),
notIn: z.array(z.number()).optional(),
}),
});
const queryConditionNumberSchema = z
.object({
type: z.literal('number'),
tableName: z.string().optional(),
field: z.array(z.string()),
conditions: z.object({
equals: z.number().nullish(),
notEquals: z.number().nullish(),
greaterThan: z.number().optional(),
greaterThanOrEqual: z.number().optional(),
lessThan: z.number().optional(),
lessThanOrEqual: z.number().optional(),
in: z.array(z.number()).optional(),
notIn: z.array(z.number()).optional(),
}),
})
.meta({ id: 'QueryConditionNumber' });
type QueryConditionNumber = z.infer<typeof queryConditionNumberSchema>;
const queryConditionSchema = z.discriminatedUnion('type', [queryConditionTextSchema, queryConditionNumberSchema]);
const queryConditionSchema = z
.discriminatedUnion('type', [queryConditionTextSchema, queryConditionNumberSchema])
.meta({ id: 'QueryCondition' });
type QueryCondition = z.infer<typeof queryConditionSchema>;
type QueryFilter = QueryCondition | QueryOperator;
type QueryOperator = {
type: 'operator';
operator: 'and' | 'or';
conditions: QueryFilter[];
};
// Create a depth-limited recursive schema for OpenAPI compatibility
// This supports up to 3 levels of nesting, which should be sufficient for most use cases
// OpenAPI cannot handle z.lazy(), so we manually define the nesting
// If you need deeper nesting, you can add more levels (Level3, Level4, etc.)
const queryFilterSchemaLevel0: z.ZodType<QueryFilter> = z.union([
queryConditionSchema,
z.object({
const queryOperatorSchema = z
.object({
type: z.literal('operator'),
operator: z.enum(['and', 'or']),
conditions: z.array(queryConditionSchema),
}),
]);
get conditions(): ZodArray<typeof queryOperatorSchema | typeof queryConditionSchema> {
// eslint-disable-next-line
return z.array(queryFilterSchema) as any;
},
})
.meta({ id: 'QueryOperator' });
const queryFilterSchemaLevel1: z.ZodType<QueryFilter> = z.union([
queryConditionSchema,
z.object({
type: z.literal('operator'),
operator: z.enum(['and', 'or']),
conditions: z.array(queryFilterSchemaLevel0),
}),
]);
type QueryOperator = z.infer<typeof queryOperatorSchema>;
const queryFilterSchemaLevel2: z.ZodType<QueryFilter> = z.union([
queryConditionSchema,
z.object({
type: z.literal('operator'),
operator: z.enum(['and', 'or']),
conditions: z.array(queryFilterSchemaLevel1),
}),
]);
const queryFilterSchema = z.union([queryOperatorSchema, queryConditionSchema]).meta({ id: 'QueryFilter' });
// Export the depth-limited schema (supports 3 levels of nesting)
// This works with OpenAPI schema generation
const queryFilterSchema = queryFilterSchemaLevel2;
type QueryFilter = z.infer<typeof queryFilterSchema>;
export type { QueryConditionText, QueryConditionNumber, QueryOperator, QueryCondition, QueryFilter };
export { queryConditionSchema, queryFilterSchema };

View File

@@ -1,31 +1,37 @@
import { z } from 'zod';
import { queryFilterSchema } from '@morten-olsen/stash-query-dsl';
import { createListResultSchema } from '#root/utils/utils.schema.js';
import { createListResultSchema, queryDSLSchema } from '#root/utils/utils.schema.js';
const documentChunkSchema = z.object({
id: z.string(),
owner: z.string(),
content: z.string(),
metadata: z.unknown(),
});
const documentChunkSchema = z
.object({
id: z.guid(),
owner: z.string(),
content: z.string(),
metadata: z.unknown(),
})
.meta({ id: 'DocumentChunk' });
type DocumentChunk = z.infer<typeof documentChunkSchema>;
const documentChunkFilterSchema = z.object({
limit: z.number().default(20),
offset: z.number().default(0),
semanticText: z.string().optional(),
conditions: z.union([queryFilterSchema, z.string()]).optional(),
});
const documentChunkFilterSchema = z
.object({
limit: z.number().default(20),
offset: z.number().default(0),
semanticText: z.string().optional(),
conditions: z.union([queryDSLSchema, queryFilterSchema]).optional(),
})
.meta({ id: 'DocumentChunkFilter' });
type DocumentChunkFilter = z.infer<typeof documentChunkFilterSchema>;
const documentChunksFindResultSchema = createListResultSchema(
documentChunkSchema.extend({
distance: z.number().optional(),
}),
);
documentChunkSchema
.extend({
distance: z.number().optional(),
})
.meta({ id: 'DocumentChunkWithDistance' }),
).meta({ id: 'DocumentChunkFindResult' });
type DocumentChunksFindResult = z.infer<typeof documentChunksFindResultSchema>;

View File

@@ -1,29 +1,31 @@
import { z } from 'zod';
import { queryFilterSchema } from '@morten-olsen/stash-query-dsl';
import { createListResultSchema } from '#root/utils/utils.schema.js';
import { createListResultSchema, queryDSLSchema } from '#root/utils/utils.schema.js';
const documentSchema = z.object({
id: z.string(),
owner: z.string().nullable(),
createdAt: z.iso.datetime(),
updatedAt: z.iso.datetime(),
deletedAt: z.iso.datetime().nullable(),
contentType: z.string().nullable(),
content: z.string().nullable(),
source: z.string().nullable(),
sourceId: z.string().nullable(),
type: z.string(),
typeVersion: z.int().nullable(),
searchText: z.string().nullable(),
metadata: z.unknown(),
});
const documentSchema = z
.object({
id: z.guid(),
owner: z.string().nullable(),
createdAt: z.iso.datetime(),
updatedAt: z.iso.datetime(),
deletedAt: z.iso.datetime().nullable(),
contentType: z.string().nullable(),
content: z.string().nullable(),
source: z.string().nullable(),
sourceId: z.string().nullable(),
type: z.string(),
typeVersion: z.int().nullable(),
searchText: z.string().nullable(),
metadata: z.unknown(),
})
.meta({ id: 'Document' });
type Document = z.infer<typeof documentSchema>;
const documentUpsertSchema = z
.object({
id: z.string().nullish(),
id: z.guid().optional(),
owner: z.string().nullish(),
contentType: z.string().nullish(),
content: z.string().nullish(),
@@ -35,6 +37,7 @@ const documentUpsertSchema = z
metadata: z.unknown().nullish(),
})
.meta({
id: 'DocumentUpsert',
example: {
content: 'the cat is yellow',
contentType: 'text/plain',
@@ -61,7 +64,7 @@ type DocumentUpsertResult = z.infer<typeof documentUpsertResultSchema>;
const documentFilterSchema = z.object({
offset: z.number().default(0),
limit: z.number().default(20),
condition: z.union([queryFilterSchema, z.string()]),
condition: z.union([queryDSLSchema, queryFilterSchema]),
});
type DocumentFilter = z.infer<typeof documentFilterSchema>;

View File

@@ -5,4 +5,12 @@ const createListResultSchema = <T extends ZodType>(schema: T) =>
items: z.array(schema),
});
export { createListResultSchema };
const queryDSLSchema = z
.string()
.describe('Query DSL based filter')
.meta({
id: 'QueryDSLString',
examples: ["metadata.foo = 'bar'"],
});
export { createListResultSchema, queryDSLSchema };

View File

@@ -5,10 +5,12 @@ import {
hasZodFastifySchemaValidationErrors,
isResponseSerializationError,
jsonSchemaTransform,
jsonSchemaTransformObject,
serializerCompiler,
validatorCompiler,
type ZodTypeProvider,
} from 'fastify-type-provider-zod';
import scalar from '@scalar/fastify-api-reference';
import { StashRuntime } from '@morten-olsen/stash-runtime';
import { systemEndpoints } from './endpoints/system/system.js';
@@ -41,10 +43,20 @@ const createApi = async (runtime: StashRuntime = new StashRuntime()) => {
},
},
transform: jsonSchemaTransform,
transformObject: jsonSchemaTransformObject,
});
await app.register(import('@scalar/fastify-api-reference'), {
await app.register(scalar, {
routePrefix: '/docs',
configuration: {
pageTitle: 'Foo',
title: 'Hello World!',
telemetry: false,
hideClientButton: true,
theme: 'laserwave',
persistAuth: true,
orderRequiredPropertiesFirst: false,
},
});
app.setErrorHandler((err, req, reply) => {