Compare commits

...

11 Commits

Author SHA1 Message Date
Morten Olsen
f6799586f4 fix: issue in document matching
Some checks failed
Build and release / Build (push) Failing after 1m11s
Build and release / update-release-draft (push) Has been skipped
Build and release / Release (push) Has been skipped
2025-12-11 08:47:58 +01:00
Morten Olsen
68abe3ce79 feat: improved zod handling
Some checks failed
Build and release / Build (push) Failing after 2m41s
Build and release / update-release-draft (push) Has been skipped
Build and release / Release (push) Has been skipped
2025-12-11 08:36:40 +01:00
Morten Olsen
3c475ab5d6 feat: support filtered subscriptions
Some checks failed
Build and release / Build (push) Failing after 1m13s
Build and release / update-release-draft (push) Has been skipped
Build and release / Release (push) Has been skipped
2025-12-11 00:00:24 +01:00
Morten Olsen
c7f9270ef2 feat: support filtering JS objects using QueryFilter
Some checks failed
Build and release / Build (push) Failing after 1m12s
Build and release / update-release-draft (push) Has been skipped
Build and release / Release (push) Has been skipped
2025-12-10 23:30:06 +01:00
Morten Olsen
904b0f783e feat: use postgres for change notification
Some checks failed
Build and release / Release (push) Has been skipped
Build and release / Build (push) Failing after 1m14s
Build and release / update-release-draft (push) Has been skipped
2025-12-10 23:21:25 +01:00
Morten Olsen
3641e86da5 feat: all fields optional
Some checks failed
Build and release / Build (push) Failing after 1m14s
Build and release / update-release-draft (push) Has been skipped
Build and release / Release (push) Has been skipped
2025-12-10 22:09:09 +01:00
Morten Olsen
1255639058 change document to text and binary content
Some checks failed
Build and release / Build (push) Failing after 1m12s
Build and release / update-release-draft (push) Has been skipped
Build and release / Release (push) Has been skipped
2025-12-10 22:06:15 +01:00
Morten Olsen
25f614a730 ci: add server build
Some checks failed
Build and release / Build (push) Failing after 2m28s
Build and release / update-release-draft (push) Has been skipped
Build and release / Release (push) Has been skipped
2025-12-10 21:52:20 +01:00
Morten Olsen
0646390d52 chore: improved schema 2025-12-10 21:12:17 +01:00
Morten Olsen
d02102977a chore: seperate into packages 2025-12-10 10:26:14 +01:00
Morten Olsen
f9494c88e2 update 2025-12-10 09:11:03 +01:00
93 changed files with 3828 additions and 1611 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
packages/*/dist/

48
.github/release-drafter-config.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name-template: "$RESOLVED_VERSION 🌈"
tag-template: "$RESOLVED_VERSION"
categories:
- title: "🚀 Features"
labels:
- "feature"
- "enhancement"
- title: "🐛 Bug Fixes"
labels:
- "fix"
- "bugfix"
- "bug"
- title: "🧰 Maintenance"
label: "chore"
change-template: "- $TITLE @$AUTHOR (#$NUMBER)"
change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks.
version-resolver:
major:
labels:
- "major"
minor:
labels:
- "minor"
patch:
labels:
- "patch"
default: patch
autolabeler:
- label: "chore"
files:
- "*.md"
branch:
- '/docs{0,1}\/.+/'
- label: "bug"
branch:
- '/fix\/.+/'
title:
- "/fix/i"
- label: "enhancement"
branch:
- '/feature\/.+/'
- '/feat\/.+/'
title:
- "/feat:.+/"
template: |
## Changes
$CHANGES

View File

@@ -0,0 +1,21 @@
name: Auto Labeler
on:
pull_request:
types: [opened, reopened, synchronize]
permissions:
contents: read
jobs:
auto-labeler:
permissions:
contents: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v6
with:
config-name: release-drafter-config.yml
disable-releaser: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

55
.github/workflows/job-build.yaml vendored Normal file
View File

@@ -0,0 +1,55 @@
name: Build
on:
workflow_call:
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "${{ env.NODE_VERSION }}"
registry-url: "${{ env.NODE_REGISTRY }}"
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Build
run: pnpm build
- name: Run tests
run: pnpm test
- uses: actions/upload-artifact@v4
with:
name: lib
retention-days: 5
path: |
packages/*/dist
extensions/*/dist
server/*/dist
package.json
README.md

View File

@@ -0,0 +1,18 @@
name: Draft release
on:
workflow_call:
jobs:
draft-release:
name: Update release drafter
permissions:
contents: write
pull-requests: write
environment: release
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v6
with:
config-name: release-drafter-config.yml
publish: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

115
.github/workflows/pipeline-default.yaml vendored Normal file
View File

@@ -0,0 +1,115 @@
name: Build and release
on:
push:
branches:
- main
pull_request:
types:
- opened
- synchronize
env:
environment: test
release_channel: latest
DO_NOT_TRACK: "1"
NODE_VERSION: "23.x"
NODE_REGISTRY: "https://registry.npmjs.org"
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
DOCKER_REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
PNPM_VERSION: 10.6.0
permissions:
contents: write
packages: read
pull-requests: write
id-token: write
actions: read
security-events: write
jobs:
build:
uses: ./.github/workflows/job-build.yaml
name: Build
update-release-draft:
needs: build
if: github.ref == 'refs/heads/main'
uses: ./.github/workflows/job-draft-release.yaml
release:
permissions:
contents: read
packages: write
attestations: write
id-token: write
pages: write
name: Release
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
needs: update-release-draft
environment: release
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# - uses: actions/setup-node@v4
# with:
# node-version: '${{ env.NODE_VERSION }}'
# registry-url: '${{ env.NODE_REGISTRY }}'
#
# - uses: pnpm/action-setup@v4
# name: Install pnpm
# with:
# version: ${{ env.PNPM_VERSION }}
# run_install: false
#
# - name: Install dependencies
# run: pnpm install
# env:
# NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
#
# - uses: actions/download-artifact@v4
# with:
# name: lib
# path: ./
#
# - name: Publish to npm
# run: |
# git config user.name "Github Actions Bot"
# git config user.email "<>"
# node ./scripts/set-version.mjs $(git describe --tag --abbrev=0)
# pnpm publish -r --no-git-checks --access public
# env:
# NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
id: push
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
file: ./packages/server/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# - name: Generate artifact attestation
# uses: actions/attest-build-provenance@v2
# with:
# subject-name: ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME}}
# subject-digest: ${{ steps.push.outputs.digest }}
# push-to-registry: true

5
.gitignore vendored
View File

@@ -1,4 +1,5 @@
/node_modules
/node_modules/
/packages/*/dist/
.turbo/
/.env
/coverage/
/coverage/

View File

@@ -36,6 +36,36 @@
"packageVersion": "1.0.0",
"packageName": "server"
}
},
{
"timestamp": "2025-12-10T07:50:20.652Z",
"template": "pkg",
"values": {
"monoRepo": true,
"packagePrefix": "@morten-olsen/stash-",
"packageVersion": "1.0.0",
"packageName": "query-dsl"
}
},
{
"timestamp": "2025-12-10T08:07:44.756Z",
"template": "pkg",
"values": {
"monoRepo": true,
"packagePrefix": "@morten-olsen/stash-",
"packageVersion": "1.0.0",
"packageName": "runtime"
}
},
{
"timestamp": "2025-12-10T09:46:52.130Z",
"template": "pkg",
"values": {
"monoRepo": true,
"packagePrefix": "@morten-olsen/stash-",
"packageVersion": "1.0.0",
"packageName": "client"
}
}
]
}

9
docker-compose.yaml Normal file
View File

@@ -0,0 +1,9 @@
name: stash
services:
app:
build:
context: .
dockerfile: ./packages/server/Dockerfile
read_only: true
ports:
- 3400:3400

View File

@@ -46,6 +46,6 @@ export default tseslint.config(
},
...compat.extends('plugin:prettier/recommended'),
{
ignores: ['**/node_modules/', '**/dist/', '**/.turbo/', '**/generated/'],
ignores: ['**/node_modules/', '**/dist/', '**/.turbo/', '**/__generated__/'],
},
);

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

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

View File

@@ -0,0 +1,31 @@
{
"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",
"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

@@ -10,11 +10,9 @@
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"noEmit": true,
"jsx": "react-jsx",
"isolatedModules": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"allowImportingTsExtensions": true
"erasableSyntaxOnly": true
}
}

4
packages/query-dsl/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,626 @@
<!-- This is a generated file -->
<!DOCTYPE html>
<meta charset="utf-8">
<style>
body {
background-color: hsl(30, 20%, 95%)
}
</style>
<link rel='stylesheet' href='https://unpkg.com/chevrotain@11.0.3/diagrams/diagrams.css'>
<script src='https://unpkg.com/chevrotain@11.0.3/diagrams/vendor/railroad-diagrams.js'></script>
<script src='https://unpkg.com/chevrotain@11.0.3/diagrams/src/diagrams_builder.js'></script>
<script src='https://unpkg.com/chevrotain@11.0.3/diagrams/src/diagrams_behavior.js'></script>
<script src='https://unpkg.com/chevrotain@11.0.3/diagrams/src/main.js'></script>
<div id="diagrams" align="center"></div>
<script>
window.serializedGrammar = [
{
"type": "Rule",
"name": "orExpression",
"orgText": "",
"definition": [
{
"type": "NonTerminal",
"name": "andExpression",
"idx": 0
},
{
"type": "Repetition",
"idx": 0,
"definition": [
{
"type": "Terminal",
"name": "Or",
"label": "Or",
"idx": 0,
"pattern": "OR"
},
{
"type": "NonTerminal",
"name": "andExpression",
"idx": 2
}
]
}
]
},
{
"type": "Rule",
"name": "andExpression",
"orgText": "",
"definition": [
{
"type": "NonTerminal",
"name": "primaryExpression",
"idx": 0
},
{
"type": "Repetition",
"idx": 0,
"definition": [
{
"type": "Terminal",
"name": "And",
"label": "And",
"idx": 0,
"pattern": "AND"
},
{
"type": "NonTerminal",
"name": "primaryExpression",
"idx": 2
}
]
}
]
},
{
"type": "Rule",
"name": "primaryExpression",
"orgText": "",
"definition": [
{
"type": "Alternation",
"idx": 0,
"definition": [
{
"type": "Alternative",
"definition": [
{
"type": "Terminal",
"name": "LParen",
"label": "LParen",
"idx": 0,
"pattern": "\\("
},
{
"type": "NonTerminal",
"name": "orExpression",
"idx": 0
},
{
"type": "Terminal",
"name": "RParen",
"label": "RParen",
"idx": 0,
"pattern": "\\)"
}
]
},
{
"type": "Alternative",
"definition": [
{
"type": "NonTerminal",
"name": "condition",
"idx": 0
}
]
}
]
}
]
},
{
"type": "Rule",
"name": "condition",
"orgText": "",
"definition": [
{
"type": "NonTerminal",
"name": "fieldReference",
"idx": 0
},
{
"type": "Alternation",
"idx": 0,
"definition": [
{
"type": "Alternative",
"definition": [
{
"type": "Terminal",
"name": "Is",
"label": "Is",
"idx": 0,
"pattern": "IS"
},
{
"type": "Option",
"idx": 0,
"definition": [
{
"type": "Terminal",
"name": "Not",
"label": "Not",
"idx": 0,
"pattern": "NOT"
}
]
},
{
"type": "Terminal",
"name": "Null",
"label": "Null",
"idx": 0,
"pattern": "NULL"
}
]
},
{
"type": "Alternative",
"definition": [
{
"type": "Terminal",
"name": "Not",
"label": "Not",
"idx": 2,
"pattern": "NOT"
},
{
"type": "Terminal",
"name": "In",
"label": "In",
"idx": 0,
"pattern": "IN"
},
{
"type": "NonTerminal",
"name": "stringInList",
"idx": 0
}
]
},
{
"type": "Alternative",
"definition": [
{
"type": "Terminal",
"name": "Not",
"label": "Not",
"idx": 3,
"pattern": "NOT"
},
{
"type": "Terminal",
"name": "In",
"label": "In",
"idx": 2,
"pattern": "IN"
},
{
"type": "NonTerminal",
"name": "numberInList",
"idx": 0
}
]
},
{
"type": "Alternative",
"definition": [
{
"type": "Terminal",
"name": "Not",
"label": "Not",
"idx": 4,
"pattern": "NOT"
},
{
"type": "Terminal",
"name": "Like",
"label": "Like",
"idx": 0,
"pattern": "LIKE"
},
{
"type": "Terminal",
"name": "StringLiteral",
"label": "StringLiteral",
"idx": 0,
"pattern": "'(?:''|[^'])*'"
}
]
},
{
"type": "Alternative",
"definition": [
{
"type": "Terminal",
"name": "In",
"label": "In",
"idx": 3,
"pattern": "IN"
},
{
"type": "NonTerminal",
"name": "stringInList",
"idx": 2
}
]
},
{
"type": "Alternative",
"definition": [
{
"type": "Terminal",
"name": "In",
"label": "In",
"idx": 4,
"pattern": "IN"
},
{
"type": "NonTerminal",
"name": "numberInList",
"idx": 2
}
]
},
{
"type": "Alternative",
"definition": [
{
"type": "Terminal",
"name": "Like",
"label": "Like",
"idx": 2,
"pattern": "LIKE"
},
{
"type": "Terminal",
"name": "StringLiteral",
"label": "StringLiteral",
"idx": 2,
"pattern": "'(?:''|[^'])*'"
}
]
},
{
"type": "Alternative",
"definition": [
{
"type": "Terminal",
"name": "Equals",
"label": "Equals",
"idx": 0,
"pattern": "="
},
{
"type": "Terminal",
"name": "StringLiteral",
"label": "StringLiteral",
"idx": 3,
"pattern": "'(?:''|[^'])*'"
}
]
},
{
"type": "Alternative",
"definition": [
{
"type": "Terminal",
"name": "Equals",
"label": "Equals",
"idx": 2,
"pattern": "="
},
{
"type": "Terminal",
"name": "NumberLiteral",
"label": "NumberLiteral",
"idx": 0,
"pattern": "-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?"
}
]
},
{
"type": "Alternative",
"definition": [
{
"type": "Terminal",
"name": "Equals",
"label": "Equals",
"idx": 3,
"pattern": "="
},
{
"type": "Terminal",
"name": "Null",
"label": "Null",
"idx": 2,
"pattern": "NULL"
}
]
},
{
"type": "Alternative",
"definition": [
{
"type": "Terminal",
"name": "NotEquals",
"label": "NotEquals",
"idx": 0,
"pattern": "!="
},
{
"type": "Terminal",
"name": "StringLiteral",
"label": "StringLiteral",
"idx": 4,
"pattern": "'(?:''|[^'])*'"
}
]
},
{
"type": "Alternative",
"definition": [
{
"type": "Terminal",
"name": "NotEquals",
"label": "NotEquals",
"idx": 2,
"pattern": "!="
},
{
"type": "Terminal",
"name": "NumberLiteral",
"label": "NumberLiteral",
"idx": 2,
"pattern": "-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?"
}
]
},
{
"type": "Alternative",
"definition": [
{
"type": "Terminal",
"name": "GreaterThan",
"label": "GreaterThan",
"idx": 0,
"pattern": ">"
},
{
"type": "Terminal",
"name": "NumberLiteral",
"label": "NumberLiteral",
"idx": 3,
"pattern": "-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?"
}
]
},
{
"type": "Alternative",
"definition": [
{
"type": "Terminal",
"name": "GreaterThanOrEqual",
"label": "GreaterThanOrEqual",
"idx": 0,
"pattern": ">="
},
{
"type": "Terminal",
"name": "NumberLiteral",
"label": "NumberLiteral",
"idx": 4,
"pattern": "-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?"
}
]
},
{
"type": "Alternative",
"definition": [
{
"type": "Terminal",
"name": "LessThan",
"label": "LessThan",
"idx": 0,
"pattern": "<"
},
{
"type": "Terminal",
"name": "NumberLiteral",
"label": "NumberLiteral",
"idx": 5,
"pattern": "-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?"
}
]
},
{
"type": "Alternative",
"definition": [
{
"type": "Terminal",
"name": "LessThanOrEqual",
"label": "LessThanOrEqual",
"idx": 0,
"pattern": "<="
},
{
"type": "Terminal",
"name": "NumberLiteral",
"label": "NumberLiteral",
"idx": 6,
"pattern": "-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?"
}
]
}
]
}
]
},
{
"type": "Rule",
"name": "fieldReference",
"orgText": "",
"definition": [
{
"type": "Terminal",
"name": "Identifier",
"label": "Identifier",
"idx": 0,
"pattern": "[a-zA-Z_][a-zA-Z0-9_]*"
},
{
"type": "Repetition",
"idx": 0,
"definition": [
{
"type": "Terminal",
"name": "Dot",
"label": "Dot",
"idx": 0,
"pattern": "\\."
},
{
"type": "Terminal",
"name": "Identifier",
"label": "Identifier",
"idx": 2,
"pattern": "[a-zA-Z_][a-zA-Z0-9_]*"
}
]
}
]
},
{
"type": "Rule",
"name": "stringInList",
"orgText": "",
"definition": [
{
"type": "Terminal",
"name": "LParen",
"label": "LParen",
"idx": 0,
"pattern": "\\("
},
{
"type": "Terminal",
"name": "StringLiteral",
"label": "StringLiteral",
"idx": 0,
"pattern": "'(?:''|[^'])*'"
},
{
"type": "Repetition",
"idx": 0,
"definition": [
{
"type": "Terminal",
"name": "Comma",
"label": "Comma",
"idx": 0,
"pattern": ","
},
{
"type": "Terminal",
"name": "StringLiteral",
"label": "StringLiteral",
"idx": 2,
"pattern": "'(?:''|[^'])*'"
}
]
},
{
"type": "Terminal",
"name": "RParen",
"label": "RParen",
"idx": 0,
"pattern": "\\)"
}
]
},
{
"type": "Rule",
"name": "numberInList",
"orgText": "",
"definition": [
{
"type": "Terminal",
"name": "LParen",
"label": "LParen",
"idx": 2,
"pattern": "\\("
},
{
"type": "Terminal",
"name": "NumberLiteral",
"label": "NumberLiteral",
"idx": 0,
"pattern": "-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?"
},
{
"type": "Repetition",
"idx": 0,
"definition": [
{
"type": "Terminal",
"name": "Comma",
"label": "Comma",
"idx": 2,
"pattern": ","
},
{
"type": "Terminal",
"name": "NumberLiteral",
"label": "NumberLiteral",
"idx": 2,
"pattern": "-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?"
}
]
},
{
"type": "Terminal",
"name": "RParen",
"label": "RParen",
"idx": 2,
"pattern": "\\)"
}
]
},
{
"type": "Rule",
"name": "query",
"orgText": "",
"definition": [
{
"type": "NonTerminal",
"name": "orExpression",
"idx": 0
}
]
}
];
</script>
<script>
var diagramsDiv = document.getElementById("diagrams");
main.drawDiagramsFromSerializedGrammar(serializedGrammar, diagramsDiv);
</script>

View File

@@ -0,0 +1,30 @@
{
"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"
],
"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",
"typescript": "5.9.3",
"vitest": "4.0.15"
},
"name": "@morten-olsen/stash-query-dsl",
"version": "1.0.0",
"dependencies": {
"chevrotain": "^11.0.3",
"zod": "4.1.13"
}
}

View File

@@ -0,0 +1,13 @@
import { createSyntaxDiagramsCode } from 'chevrotain';
import { QueryParser } from '../dist/exports.js';
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
const rootPath = resolve('./docs/diagram');
const parser = new QueryParser();
const diagram = createSyntaxDiagramsCode(parser.getSerializedGastProductions());
await mkdir(rootPath, { recursive: true });
await writeFile(resolve(rootPath, 'index.html'), diagram);

View File

@@ -0,0 +1,4 @@
export * from './query-parser.schemas.js';
export { QueryParser, queryParser } from './query-parser.js';
export * from './utils.filter.js';
export * from './query-parser.codec.js';

View File

@@ -0,0 +1,20 @@
import { z } from 'zod';
import { queryFilterSchema } from './query-parser.schemas.js';
import { queryParser } from './query-parser.js';
const queryStringSchema: typeof queryFilterSchema = z
.codec(z.string(), queryFilterSchema, {
encode: (filter) => {
return queryParser.stringify(filter);
},
decode: (input) => {
return queryParser.parse(input);
},
// eslint-disable-next-line
}).meta({ id: 'QueryString', examples: ["metadata.foo = 'bar'"] }) as any;
// eslint-disable-next-line
const querySchema: typeof queryFilterSchema = z.union([queryStringSchema, queryFilterSchema]) as any
export { querySchema };

View File

@@ -0,0 +1,460 @@
import { createToken, Lexer, EmbeddedActionsParser } from 'chevrotain';
import { type QueryFilter, type QueryCondition, queryFilterSchema } from './query-parser.schemas.js';
// ----------------- Lexer -----------------
// Whitespace (skipped)
const WhiteSpace = createToken({ name: 'WhiteSpace', pattern: /\s+/, group: Lexer.SKIPPED });
// Keywords (must be defined before Identifier to take precedence)
const And = createToken({ name: 'And', pattern: /AND/i, longer_alt: undefined });
const Or = createToken({ name: 'Or', pattern: /OR/i, longer_alt: undefined });
const Like = createToken({ name: 'Like', pattern: /LIKE/i, longer_alt: undefined });
const Not = createToken({ name: 'Not', pattern: /NOT/i, longer_alt: undefined });
const In = createToken({ name: 'In', pattern: /IN/i, longer_alt: undefined });
const Is = createToken({ name: 'Is', pattern: /IS/i, longer_alt: undefined });
const Null = createToken({ name: 'Null', pattern: /NULL/i, longer_alt: undefined });
// Identifier (must come after keywords)
const Identifier = createToken({ name: 'Identifier', pattern: /[a-zA-Z_][a-zA-Z0-9_]*/ });
// Set longer_alt for keywords to handle cases like "ANDROID" not matching "AND"
And.LONGER_ALT = Identifier;
Or.LONGER_ALT = Identifier;
Like.LONGER_ALT = Identifier;
Not.LONGER_ALT = Identifier;
In.LONGER_ALT = Identifier;
Is.LONGER_ALT = Identifier;
Null.LONGER_ALT = Identifier;
// Literals
const StringLiteral = createToken({
name: 'StringLiteral',
pattern: /'(?:''|[^'])*'/,
});
const NumberLiteral = createToken({
name: 'NumberLiteral',
pattern: /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/,
});
// Operators
const NotEquals = createToken({ name: 'NotEquals', pattern: /!=/ });
const GreaterThanOrEqual = createToken({ name: 'GreaterThanOrEqual', pattern: />=/ });
const LessThanOrEqual = createToken({ name: 'LessThanOrEqual', pattern: /<=/ });
const Equals = createToken({ name: 'Equals', pattern: /=/ });
const GreaterThan = createToken({ name: 'GreaterThan', pattern: />/ });
const LessThan = createToken({ name: 'LessThan', pattern: /</ });
// Punctuation
const LParen = createToken({ name: 'LParen', pattern: /\(/ });
const RParen = createToken({ name: 'RParen', pattern: /\)/ });
const Comma = createToken({ name: 'Comma', pattern: /,/ });
const Dot = createToken({ name: 'Dot', pattern: /\./ });
// Token order matters! More specific patterns first.
const allTokens = [
WhiteSpace,
// Multi-char operators first
NotEquals,
GreaterThanOrEqual,
LessThanOrEqual,
// Single-char operators
Equals,
GreaterThan,
LessThan,
// Punctuation
LParen,
RParen,
Comma,
Dot,
// Keywords (before Identifier)
And,
Or,
Like,
Not,
In,
Is,
Null,
// Literals
StringLiteral,
NumberLiteral,
// Identifier last
Identifier,
];
const QueryLexer = new Lexer(allTokens);
// ----------------- Parser -----------------
class QueryParserParser extends EmbeddedActionsParser {
constructor() {
super(allTokens);
this.performSelfAnalysis();
}
// OR has lowest precedence
#orExpression = this.RULE('orExpression', (): QueryFilter => {
let left = this.SUBRULE(this.#andExpression);
this.MANY(() => {
this.CONSUME(Or);
const right = this.SUBRULE2(this.#andExpression);
left = this.ACTION(() => this.#combineWithOperator(left, right, 'or'));
});
return left;
});
// AND has higher precedence than OR
#andExpression = this.RULE('andExpression', (): QueryFilter => {
let left = this.SUBRULE(this.#primaryExpression);
this.MANY(() => {
this.CONSUME(And);
const right = this.SUBRULE2(this.#primaryExpression);
left = this.ACTION(() => this.#combineWithOperator(left, right, 'and'));
});
return left;
});
// Primary: parenthesized expression or condition
#primaryExpression = this.RULE('primaryExpression', (): QueryFilter => {
return this.OR([
{
ALT: () => {
this.CONSUME(LParen);
const expr = this.SUBRULE(this.#orExpression);
this.CONSUME(RParen);
return expr;
},
},
{ ALT: () => this.SUBRULE(this.#condition) },
]);
});
// Condition: field followed by operator and value(s)
#condition = this.RULE('condition', (): QueryCondition => {
const field = this.SUBRULE(this.#fieldReference);
return this.OR([
// IS NULL / IS NOT NULL
{
ALT: () => {
this.CONSUME(Is);
const isNot = this.OPTION(() => this.CONSUME(Not)) !== undefined;
this.CONSUME(Null);
return this.ACTION(() => ({
type: 'text' as const,
field,
conditions: isNot ? { notEqual: undefined, equal: undefined } : { equal: null },
}));
},
},
// NOT IN (strings) - LA(1)=NOT, LA(2)=IN, LA(3)=(, LA(4)=value
{
GATE: () => this.LA(4).tokenType === StringLiteral,
ALT: () => {
this.CONSUME2(Not);
this.CONSUME(In);
const values = this.SUBRULE(this.#stringInList);
return this.ACTION(() => ({
type: 'text' as const,
field,
conditions: { notIn: values },
}));
},
},
// NOT IN (numbers)
{
GATE: () => this.LA(4).tokenType === NumberLiteral,
ALT: () => {
this.CONSUME3(Not);
this.CONSUME2(In);
const values = this.SUBRULE(this.#numberInList);
return this.ACTION(() => ({
type: 'number' as const,
field,
conditions: { notIn: values },
}));
},
},
// NOT LIKE
{
ALT: () => {
this.CONSUME4(Not);
this.CONSUME(Like);
const pattern = this.CONSUME(StringLiteral);
return this.ACTION(() => ({
type: 'text' as const,
field,
conditions: { notLike: this.#extractStringValue(pattern.image) },
}));
},
},
// IN (strings) - LA(1)=IN, LA(2)=(, LA(3)=value
{
GATE: () => this.LA(3).tokenType === StringLiteral,
ALT: () => {
this.CONSUME3(In);
const values = this.SUBRULE2(this.#stringInList);
return this.ACTION(() => ({
type: 'text' as const,
field,
conditions: { in: values },
}));
},
},
// IN (numbers)
{
GATE: () => this.LA(3).tokenType === NumberLiteral,
ALT: () => {
this.CONSUME4(In);
const values = this.SUBRULE2(this.#numberInList);
return this.ACTION(() => ({
type: 'number' as const,
field,
conditions: { in: values },
}));
},
},
// LIKE
{
ALT: () => {
this.CONSUME2(Like);
const pattern = this.CONSUME2(StringLiteral);
return this.ACTION(() => ({
type: 'text' as const,
field,
conditions: { like: this.#extractStringValue(pattern.image) },
}));
},
},
// = string
{
GATE: () => this.LA(2).tokenType === StringLiteral,
ALT: () => {
this.CONSUME(Equals);
const token = this.CONSUME3(StringLiteral);
return this.ACTION(() => ({
type: 'text' as const,
field,
conditions: { equal: this.#extractStringValue(token.image) },
}));
},
},
// = number
{
GATE: () => this.LA(2).tokenType === NumberLiteral,
ALT: () => {
this.CONSUME2(Equals);
const token = this.CONSUME(NumberLiteral);
return this.ACTION(() => ({
type: 'number' as const,
field,
conditions: { equals: parseFloat(token.image) },
}));
},
},
// = NULL
{
ALT: () => {
this.CONSUME3(Equals);
this.CONSUME2(Null);
return this.ACTION(() => ({
type: 'text' as const,
field,
conditions: { equal: null },
}));
},
},
// != string
{
GATE: () => this.LA(2).tokenType === StringLiteral,
ALT: () => {
this.CONSUME(NotEquals);
const token = this.CONSUME4(StringLiteral);
return this.ACTION(() => ({
type: 'text' as const,
field,
conditions: { notEqual: this.#extractStringValue(token.image) },
}));
},
},
// != number
{
ALT: () => {
this.CONSUME2(NotEquals);
const token = this.CONSUME2(NumberLiteral);
return this.ACTION(() => ({
type: 'number' as const,
field,
conditions: { notEquals: parseFloat(token.image) },
}));
},
},
// > number
{
ALT: () => {
this.CONSUME(GreaterThan);
const token = this.CONSUME3(NumberLiteral);
return this.ACTION(() => ({
type: 'number' as const,
field,
conditions: { greaterThan: parseFloat(token.image) },
}));
},
},
// >= number
{
ALT: () => {
this.CONSUME(GreaterThanOrEqual);
const token = this.CONSUME4(NumberLiteral);
return this.ACTION(() => ({
type: 'number' as const,
field,
conditions: { greaterThanOrEqual: parseFloat(token.image) },
}));
},
},
// < number
{
ALT: () => {
this.CONSUME(LessThan);
const token = this.CONSUME5(NumberLiteral);
return this.ACTION(() => ({
type: 'number' as const,
field,
conditions: { lessThan: parseFloat(token.image) },
}));
},
},
// <= number
{
ALT: () => {
this.CONSUME(LessThanOrEqual);
const token = this.CONSUME6(NumberLiteral);
return this.ACTION(() => ({
type: 'number' as const,
field,
conditions: { lessThanOrEqual: parseFloat(token.image) },
}));
},
},
]);
});
// Field reference: identifier.identifier.identifier...
#fieldReference = this.RULE('fieldReference', (): string[] => {
const parts: string[] = [];
const first = this.CONSUME(Identifier);
this.ACTION(() => parts.push(first.image));
this.MANY(() => {
this.CONSUME(Dot);
const next = this.CONSUME2(Identifier);
this.ACTION(() => parts.push(next.image));
});
return parts;
});
// String IN list: ('val1', 'val2', ...)
#stringInList = this.RULE('stringInList', (): string[] => {
const values: string[] = [];
this.CONSUME(LParen);
const first = this.CONSUME(StringLiteral);
this.ACTION(() => values.push(this.#extractStringValue(first.image)));
this.MANY(() => {
this.CONSUME(Comma);
const next = this.CONSUME2(StringLiteral);
this.ACTION(() => values.push(this.#extractStringValue(next.image)));
});
this.CONSUME(RParen);
return values;
});
// Number IN list: (1, 2, 3, ...)
#numberInList = this.RULE('numberInList', (): number[] => {
const values: number[] = [];
this.CONSUME2(LParen);
const first = this.CONSUME(NumberLiteral);
this.ACTION(() => values.push(parseFloat(first.image)));
this.MANY(() => {
this.CONSUME2(Comma);
const next = this.CONSUME2(NumberLiteral);
this.ACTION(() => values.push(parseFloat(next.image)));
});
this.CONSUME2(RParen);
return values;
});
// Extract string value from quoted literal, handling escaped quotes
#extractStringValue(image: string): string {
// Remove surrounding quotes and unescape doubled quotes
return image.slice(1, -1).replace(/''/g, "'");
}
// Combine two filters with an operator, flattening if possible
#combineWithOperator(left: QueryFilter, right: QueryFilter, operator: 'and' | 'or'): QueryFilter {
if (left.type === 'operator' && left.operator === operator) {
return {
type: 'operator',
operator,
conditions: [...left.conditions, right],
};
}
return {
type: 'operator',
operator,
conditions: [left, right],
};
}
// Entry point
#query = this.RULE('query', (): QueryFilter => {
return this.SUBRULE(this.#orExpression);
});
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) {
const error = lexResult.errors[0];
// Check if this looks like an unterminated string (starts with ' but lexer failed)
if (error.message.includes("'") || input.slice(error.offset).startsWith("'")) {
// Count unescaped single quotes
const unescapedQuotes = input.replace(/''/g, '').match(/'/g);
if (unescapedQuotes && unescapedQuotes.length % 2 !== 0) {
throw new Error(`Unterminated string starting at position ${error.offset}`);
}
}
throw new Error(`Lexer error at position ${error.offset}: ${error.message}`);
}
this.input = lexResult.tokens;
const result = this.#query();
if (this.errors.length > 0) {
const error = this.errors[0];
throw new Error(`Parse error: ${error.message}`);
}
return schema.parse(result);
};
}
export { QueryParserParser, QueryLexer };

View File

@@ -0,0 +1,65 @@
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(),
}),
})
.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(),
}),
})
.meta({ id: 'QueryConditionNumber' });
type QueryConditionNumber = z.infer<typeof queryConditionNumberSchema>;
const queryConditionSchema = z
.discriminatedUnion('type', [queryConditionTextSchema, queryConditionNumberSchema])
.meta({ id: 'QueryCondition' });
type QueryCondition = z.infer<typeof queryConditionSchema>;
const queryOperatorSchema = z
.object({
type: z.literal('operator'),
operator: z.enum(['and', 'or']),
get conditions(): ZodArray<typeof queryOperatorSchema | typeof queryConditionSchema> {
// eslint-disable-next-line
return z.array(queryFilterSchema) as any;
},
})
.meta({ id: 'QueryOperator' });
type QueryOperator = z.infer<typeof queryOperatorSchema>;
const queryFilterSchema = z.union([queryOperatorSchema, queryConditionSchema]).meta({ id: 'QueryFilter' });
type QueryFilter = z.infer<typeof queryFilterSchema>;
export type { QueryConditionText, QueryConditionNumber, QueryOperator, QueryCondition, QueryFilter };
export { queryConditionSchema, queryFilterSchema };

View File

@@ -4,7 +4,7 @@ import type {
QueryCondition,
QueryConditionText,
QueryConditionNumber,
} from '#root/utils/utils.query.ts';
} from './query-parser.schemas.js';
class Stringifier {
#stringifyFilter = (filter: QueryFilter, needsParens: boolean): string => {

View File

@@ -1,8 +1,7 @@
import { describe, it, expect } from 'vitest';
import { QueryParser } from './query-parser.ts';
import type { QueryConditionNumber, QueryConditionText, QueryFilter, QueryOperator } from '#root/utils/utils.query.ts';
import { QueryParser } from './query-parser.js';
import type { QueryConditionNumber, QueryConditionText, QueryFilter, QueryOperator } from './query-parser.schemas.js';
describe('QueryParser', () => {
const parser = new QueryParser();

View File

@@ -0,0 +1,24 @@
import { Stringifier } from './query-parser.stringifier.js';
import { QueryParserParser } from './query-parser.parser.js';
import type { QueryFilter } from './query-parser.schemas.js';
class QueryParser {
#stringifier = new Stringifier();
#parser = new QueryParserParser();
public getSerializedGastProductions() {
return this.#parser.getSerializedGastProductions();
}
public parse = (input: string): QueryFilter => {
return this.#parser.parse(input);
};
public stringify = (filter: QueryFilter): string => {
return this.#stringifier.stringify(filter);
};
}
const queryParser = new QueryParser();
export { QueryParser, queryParser };

View File

@@ -0,0 +1,171 @@
import type { QueryCondition, QueryConditionNumber, QueryConditionText, QueryFilter } from './query-parser.schemas.js';
const getFieldValue = <T extends Record<string, unknown>>(obj: T, field: string[]): unknown => {
let current: unknown = obj;
for (const key of field) {
if (current === null || current === undefined) {
return undefined;
}
if (typeof current !== 'object') {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return current;
};
/**
* Converts a SQL LIKE pattern to a RegExp.
* Handles % (any characters) and _ (single character) wildcards.
*/
const likeToRegex = (pattern: string): RegExp => {
const escaped = pattern
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape regex special chars
.replace(/%/g, '.*') // % matches any characters
.replace(/_/g, '.'); // _ matches single character
return new RegExp(`^${escaped}$`, 'i');
};
const applyQueryConditionText = <T extends Record<string, unknown>>(
obj: T,
{ field, conditions }: QueryConditionText,
): boolean => {
const value = getFieldValue(obj, field);
if (conditions.equal !== undefined) {
if (conditions.equal === null) {
if (value !== null && value !== undefined) return false;
} else {
if (value !== conditions.equal) return false;
}
}
if (conditions.notEqual !== undefined) {
if (conditions.notEqual === null) {
if (value === null || value === undefined) return false;
} else {
if (value === conditions.notEqual) return false;
}
}
if (conditions.like !== undefined) {
if (typeof value !== 'string') return false;
const regex = likeToRegex(conditions.like);
if (!regex.test(value)) return false;
}
if (conditions.notLike !== undefined) {
if (typeof value !== 'string') return false;
const regex = likeToRegex(conditions.notLike);
if (regex.test(value)) return false;
}
if (conditions.in !== undefined) {
if (!conditions.in.includes(value as string)) return false;
}
if (conditions.notIn !== undefined) {
if (conditions.notIn.includes(value as string)) return false;
}
return true;
};
const applyQueryConditionNumber = <T extends Record<string, unknown>>(
obj: T,
{ field, conditions }: QueryConditionNumber,
): boolean => {
const value = getFieldValue(obj, field);
if (conditions.equals !== undefined) {
if (conditions.equals === null) {
if (value !== null && value !== undefined) return false;
} else {
if (value !== conditions.equals) return false;
}
}
if (conditions.notEquals !== undefined) {
if (conditions.notEquals === null) {
if (value === null || value === undefined) return false;
} else {
if (value === conditions.notEquals) return false;
}
}
if (conditions.greaterThan !== undefined) {
if (typeof value !== 'number' || value <= conditions.greaterThan) return false;
}
if (conditions.greaterThanOrEqual !== undefined) {
if (typeof value !== 'number' || value < conditions.greaterThanOrEqual) return false;
}
if (conditions.lessThan !== undefined) {
if (typeof value !== 'number' || value >= conditions.lessThan) return false;
}
if (conditions.lessThanOrEqual !== undefined) {
if (typeof value !== 'number' || value > conditions.lessThanOrEqual) return false;
}
if (conditions.in !== undefined) {
if (!conditions.in.includes(value as number)) return false;
}
if (conditions.notIn !== undefined) {
if (conditions.notIn.includes(value as number)) return false;
}
return true;
};
const applyQueryCondition = <T extends Record<string, unknown>>(obj: T, options: QueryCondition): boolean => {
switch (options.type) {
case 'text': {
return applyQueryConditionText(obj, options);
}
case 'number': {
return applyQueryConditionNumber(obj, options);
}
default: {
throw new Error(`Unknown filter type`);
}
}
};
const applyQueryFilter = <T extends Record<string, unknown>>(obj: T, filter: QueryFilter): boolean => {
if (filter.type === 'operator') {
if (filter.conditions.length === 0) {
return true;
}
switch (filter.operator) {
case 'or': {
return filter.conditions.some((condition) => applyQueryFilter(obj, condition));
}
case 'and': {
return filter.conditions.every((condition) => applyQueryFilter(obj, condition));
}
}
} else {
return applyQueryCondition(obj, filter);
}
};
const createFilterFunction = <T extends Record<string, unknown>>(filter: QueryFilter): ((obj: T) => boolean) => {
return (obj: T) => applyQueryFilter(obj, filter);
};
const filterObjects = <T extends Record<string, unknown>>(objects: T[], filter: QueryFilter): T[] => {
return objects.filter(createFilterFunction(filter));
};
const isMatch = <T extends Record<string, unknown>>(input: T, filter: QueryFilter) => {
const fn = createFilterFunction(filter);
return fn(input);
};
export { createFilterFunction, filterObjects, isMatch };

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"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,
},
};
});

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

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

View File

@@ -0,0 +1,40 @@
{
"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"
],
"exports": {
".": "./dist/exports.js"
},
"devDependencies": {
"@morten-olsen/stash-configs": "workspace:*",
"@morten-olsen/stash-tests": "workspace:*",
"@types/deep-equal": "^1.0.4",
"@types/node": "24.10.2",
"@vitest/coverage-v8": "4.0.15",
"typescript": "5.9.3",
"vitest": "4.0.15"
},
"name": "@morten-olsen/stash-runtime",
"version": "1.0.0",
"dependencies": {
"@electric-sql/pglite": "^0.3.14",
"@huggingface/transformers": "^3.8.1",
"@langchain/textsplitters": "^1.0.1",
"@morten-olsen/stash-query-dsl": "workspace:*",
"better-sqlite3": "^12.5.0",
"deep-equal": "^2.2.3",
"knex": "^3.1.0",
"knex-pglite": "^0.13.0",
"pg": "^8.16.3",
"pgvector": "^0.2.1",
"zod": "4.1.13"
}
}

View File

@@ -0,0 +1,17 @@
import { env, pipeline } from '@huggingface/transformers';
import { EMBEDDING_MODEL } from './utils/utils.consts.js';
const setModelLocation = (location: string) => {
env.cacheDir = location;
};
const preloadModel = async () => {
await pipeline('feature-extraction', EMBEDDING_MODEL);
};
export { Services } from './utils/utils.services.js';
export { StashRuntime } from './runtime.js';
export * from './services/documents/documents.js';
export * from './services/document-chunks/document-chunks.js';
export { setModelLocation, preloadModel };

12
packages/runtime/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
import 'fastify';
import type { Services } from './utils/utils.services.ts';
// eslint-disable-next-line
declare type ExplicitAny = any;
declare module 'fastify' {
// eslint-disable-next-line
export interface FastifyInstance {
services: Services;
}
}

View File

@@ -0,0 +1,32 @@
import { DocumentChunksService } from './exports.js';
import { DatabaseService } from './services/database/database.js';
import { DocumentsService } from './services/documents/documents.js';
import { WarmupService } from './services/warmup/warmup.js';
import { Services } from './utils/utils.services.js';
class StashRuntime {
#services: Services;
constructor(services = new Services()) {
this.#services = services;
services.set(StashRuntime, this);
}
public get database() {
return this.#services.get(DatabaseService);
}
public get documents() {
return this.#services.get(DocumentsService);
}
public get documentChunks() {
return this.#services.get(DocumentChunksService);
}
public get warmup() {
return this.#services.get(WarmupService);
}
}
export { StashRuntime };

View File

@@ -0,0 +1,40 @@
import { z } from 'zod';
const insertedRecordEventSchema = z.object({
operation: z.literal('INSERT'),
table: z.string(),
schema: z.string(),
newRecord: z.record(z.string(), z.unknown()),
});
type InsertedRecordEvent = z.infer<typeof insertedRecordEventSchema>;
const deletedRecordEventSchema = z.object({
operation: z.literal('DELETE'),
table: z.string(),
schema: z.string(),
oldRecord: z.record(z.string(), z.unknown()),
});
type DeletedRecordEvent = z.infer<typeof deletedRecordEventSchema>;
const updatedRecordEventSchema = z.object({
operation: z.literal('UPDATE'),
table: z.string(),
schema: z.string(),
newRecord: z.record(z.string(), z.unknown()),
oldRecord: z.record(z.string(), z.unknown()),
});
type UpdatedRecordEvent = z.infer<typeof updatedRecordEventSchema>;
const changedRecordEventSchema = z.discriminatedUnion('operation', [
insertedRecordEventSchema,
deletedRecordEventSchema,
updatedRecordEventSchema,
]);
type ChangedRecordEvent = z.infer<typeof changedRecordEventSchema>;
export type { InsertedRecordEvent, DeletedRecordEvent, UpdatedRecordEvent, ChangedRecordEvent };
export { insertedRecordEventSchema, deletedRecordEventSchema, updatedRecordEventSchema, changedRecordEventSchema };

View File

@@ -0,0 +1,62 @@
import { destroy, type Services } from '../../utils/utils.services.js';
import { EventEmitter } from '../../utils/utils.event-emitter.js';
import { migrationSource } from './migrations/migrations.js';
import { type ChangedRecordEvent } from './database.schemas.js';
import type { GeneratorOutput } from './generators/generators.types.js';
import { pgLiteGenerator } from './generators/generators.pglite.js';
import { createEmitter } from './generators/generators.js';
type DatabaseServiceEvents = {
changedRecord: (event: ChangedRecordEvent) => void;
};
class DatabaseService extends EventEmitter<DatabaseServiceEvents> {
#services: Services;
#generated?: Promise<GeneratorOutput>;
constructor(services: Services) {
super();
this.#services = services;
}
#setup = async () => {
const emitter = createEmitter();
const output = await pgLiteGenerator({ emitter, config: {} });
const { instance } = output;
await instance.migrate.latest({
migrationSource: migrationSource({ services: this.#services }),
});
emitter.on('changed', this.emit.bind(this, 'changedRecord'));
return output;
};
#getGenerated = async () => {
if (!this.#generated) {
this.#generated = this.#setup();
}
return this.#generated;
};
public listen = async () => {
const { subscribe } = await this.#getGenerated();
await subscribe();
};
public getInstance = async () => {
const { instance } = await this.#getGenerated();
return instance;
};
[destroy] = async () => {
if (!this.#generated) {
return;
}
const { instance } = await this.#generated;
await instance.destroy();
};
}
export { type TableRows, tableNames } from './migrations/migrations.js';
export { DatabaseService };

View File

@@ -0,0 +1,38 @@
import { PGlite } from '@electric-sql/pglite';
import knex from 'knex';
import ClientPGLite from 'knex-pglite';
import { vector } from '@electric-sql/pglite/vector';
import { changedRecordEventSchema } from '../database.schemas.js';
import type { Generator } from './generators.types.js';
type PGLiteGeneratorOptions = {
dataLocation?: string;
};
const pgLiteGenerator: Generator<PGLiteGeneratorOptions> = async ({ emitter }) => {
const pglite = new PGlite({
extensions: { vector },
});
const instance = knex({
client: ClientPGLite,
dialect: 'postgres',
connection: () => ({ pglite }) as object,
});
await instance.raw(`CREATE EXTENSION IF NOT EXISTS vector`);
const subscribe = async () => {
pglite.onNotification((channel, data) => {
if (channel !== 'row_changed') {
return;
}
const payload = changedRecordEventSchema.parse(JSON.parse(data));
emitter.emit('changed', payload);
});
await instance.raw('LISTEN row_changed');
};
return { instance, subscribe };
};
export { pgLiteGenerator };

View File

@@ -0,0 +1,7 @@
import { EventEmitter } from '../../../utils/utils.event-emitter.js';
import type { GeneratorEvents } from './generators.types.js';
const createEmitter = () => new EventEmitter<GeneratorEvents>();
export { createEmitter };

View File

@@ -0,0 +1,21 @@
import type { Knex } from 'knex';
import type { ChangedRecordEvent } from '../database.schemas.js';
import type { EventEmitter } from '../../../utils/utils.event-emitter.js';
type GeneratorEvents = {
changed: (event: ChangedRecordEvent) => void;
};
type GeneratorOutput = {
instance: Knex;
subscribe: () => Promise<void>;
};
type GeneratorOptions<T> = {
config: T;
emitter: EventEmitter<GeneratorEvents>;
};
type Generator<T> = (options: GeneratorOptions<T>) => Promise<GeneratorOutput>;
export type { GeneratorEvents, GeneratorOutput, Generator };

View File

@@ -1,7 +1,7 @@
import type { Migration } from './migrations.types.ts';
import { EMBEDDING_MODEL } from '../../../utils/utils.consts.js';
import { EmbeddingsService } from '../../embeddings/embeddings.js';
import { EmbeddingsService } from '#root/services/embeddings/embeddings.ts';
import { EMBEDDING_MODEL } from '#root/utils/utils.consts.ts';
import type { Migration } from './migrations.types.js';
const tableNames = {
documents: 'documents',
@@ -15,6 +15,45 @@ const init: Migration = {
const embedding = services.get(EmbeddingsService);
const embeddingField = await embedding.getFieldType(EMBEDDING_MODEL);
await knex.raw(`
CREATE OR REPLACE FUNCTION notify_changes()
RETURNS trigger AS $$
DECLARE
payload TEXT;
BEGIN
-- Build the JSON payload based on the operation type
IF (TG_OP = 'DELETE') THEN
payload := json_build_object(
'operation', TG_OP,
'table', TG_TABLE_NAME,
'schema', TG_TABLE_SCHEMA,
'oldRecord', row_to_json(OLD)
)::text;
ELSIF (TG_OP = 'INSERT') THEN
payload := json_build_object(
'operation', TG_OP,
'table', TG_TABLE_NAME,
'schema', TG_TABLE_SCHEMA,
'newRecord', row_to_json(NEW)
)::text;
ELSIF (TG_OP = 'UPDATE') THEN
payload := json_build_object(
'operation', TG_OP,
'table', TG_TABLE_NAME,
'schema', TG_TABLE_SCHEMA,
'oldRecord', row_to_json(OLD),
'newRecord', row_to_json(NEW)
)::text;
END IF;
-- Send the notification to the channel
PERFORM pg_notify('row_changed', payload);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
await knex.schema.createTable(tableNames.documents, (table) => {
table.uuid('id').primary();
table.uuid('owner').nullable().references('id').inTable(tableNames.documents).onDelete('CASCADE');
@@ -22,18 +61,25 @@ const init: Migration = {
table.datetime('createdAt').notNullable();
table.datetime('deletedAt').nullable();
table.string('contentType').nullable();
table.text('content').nullable();
table.binary('content').nullable();
table.text('text').nullable();
table.string('source').nullable();
table.string('sourceId').nullable();
table.string('type').notNullable();
table.string('type').nullable();
table.integer('typeVersion').nullable();
table.text('searchText').nullable();
table.jsonb('metadata').nullable();
table.index(['source', 'sourceId']);
table.index(['owner']);
});
await knex.raw(`
CREATE TRIGGER document_changes_trigger
AFTER INSERT OR UPDATE OR DELETE ON documents
FOR EACH ROW
EXECUTE PROCEDURE notify_changes();
`);
await knex.schema.createTable(tableNames.documentChunks, (table) => {
table.uuid('id').primary();
table.uuid('owner').nullable().references('id').inTable(tableNames.documents).onDelete('CASCADE');
@@ -61,6 +107,8 @@ const init: Migration = {
});
},
down: async ({ knex }) => {
await knex.raw('DROP TRIGGER IF EXISTS document_changes_trigger ON documents;');
await knex.raw('DROP FUNCTION IF EXISTS notify_changes();');
await knex.schema.dropTableIfExists(tableNames.relations);
await knex.schema.dropTableIfExists(tableNames.documentChunks);
await knex.schema.dropTableIfExists(tableNames.documents);
@@ -74,13 +122,13 @@ type DocumentRow = {
createdAt: Date;
deletedAt: Date | null;
contentType: string | null;
content: string | null;
content: Buffer | null;
text: string | null;
source: string | null;
sourceId: string | null;
type: string;
type: string | null;
typeVersion: number | null;
searchText: string | null;
metadata: unknown;
metadata: unknown | null;
};
type DocumentChunkRow = {

View File

@@ -1,9 +1,9 @@
import type { Knex } from 'knex';
import type { Migration } from './migrations.types.ts';
import { init } from './migrations.001-init.ts';
import type { Services } from '../../../utils/utils.services.js';
import type { Services } from '#root/utils/utils.services.ts';
import type { Migration } from './migrations.types.js';
import { init } from './migrations.001-init.js';
const migrations = [init] satisfies Migration[];
@@ -21,5 +21,5 @@ const migrationSource = (options: MigrationSourceOptions): Knex.MigrationSource<
getMigrations: async () => migrations,
});
export { type TableRows, tableNames } from './migrations.001-init.ts';
export { type TableRows, tableNames } from './migrations.001-init.js';
export { migrationSource };

View File

@@ -1,6 +1,6 @@
import type { Knex } from 'knex';
import type { Services } from '#root/utils/utils.services.ts';
import type { Services } from '../../../utils/utils.services.js';
type MigrationOptions = {
knex: Knex;

View File

@@ -0,0 +1,11 @@
import type { TableRows } from '../database/database.js';
import { documentChunkWithDistanceSchema } from './document-chunks.schemas.js';
const mapFromDocumentChunkRow = (
row: TableRows['documentChunks'] & {
metadata?: unknown;
},
) => documentChunkWithDistanceSchema.parse(row);
export { mapFromDocumentChunkRow };

View File

@@ -0,0 +1,46 @@
import { z } from 'zod';
import { querySchema } from '@morten-olsen/stash-query-dsl';
import { createListResultSchema } from '../../utils/utils.schema.js';
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: querySchema.optional(),
})
.meta({ id: 'DocumentChunkFilter' });
type DocumentChunkFilter = z.infer<typeof documentChunkFilterSchema>;
const documentChunkWithDistanceSchema = documentChunkSchema
.extend({
distance: z.number().optional(),
})
.meta({ id: 'DocumentChunkWithDistance' });
const documentChunksFindResultSchema = createListResultSchema(documentChunkWithDistanceSchema).meta({
id: 'DocumentChunkFindResult',
});
type DocumentChunksFindResult = z.infer<typeof documentChunksFindResultSchema>;
export type { DocumentChunk, DocumentChunkFilter, DocumentChunksFindResult };
export {
documentChunkSchema,
documentChunkFilterSchema,
documentChunksFindResultSchema,
documentChunkWithDistanceSchema,
};

View File

@@ -1,14 +1,11 @@
import { DatabaseService, tableNames, type TableRows } from '../database/database.ts';
import { EmbeddingsService } from '../embeddings/embeddings.ts';
import { DatabaseService, tableNames, type TableRows } from '../database/database.js';
import { EmbeddingsService } from '../embeddings/embeddings.js';
import type { Services } from '../../utils/utils.services.js';
import { EMBEDDING_MODEL } from '../../utils/utils.consts.js';
import { applyQueryFilter } from '../../utils/utils.query.js';
import type { DocumentChunkFilter, DocumentChunksFindResult } from './document-chunks.schemas.ts';
import { mapFromDocumentChunkRow } from './document.mappings.ts';
import type { Services } from '#root/utils/utils.services.ts';
import { EMBEDDING_MODEL } from '#root/utils/utils.consts.ts';
import type { ExplicitAny } from '#root/global.js';
import { applyQueryFilter } from '#root/utils/utils.query.ts';
import { QueryParser } from '#root/query-parser/query-parser.ts';
import type { DocumentChunkFilter, DocumentChunksFindResult } from './document-chunks.schemas.js';
import { mapFromDocumentChunkRow } from './document-chunks.mappings.js';
const baseFields = [
`${tableNames.documentChunks}.*`,
@@ -44,11 +41,7 @@ class DocumentChunksService {
query = query.orderBy('createdAt', 'desc');
}
if (filter.conditions) {
const parser = this.#services.get(QueryParser);
query = applyQueryFilter(
query,
typeof filter.conditions === 'string' ? parser.parse(filter.conditions) : filter.conditions,
);
query = applyQueryFilter(query, filter.conditions);
}
query = query.limit(filter.limit).offset(filter.offset);
@@ -56,10 +49,10 @@ class DocumentChunksService {
const items = await query;
return {
items: items.map(mapFromDocumentChunkRow as ExplicitAny),
items: items.map(mapFromDocumentChunkRow),
};
};
}
export * from './document-chunks.schemas.ts';
export * from './document-chunks.schemas.js';
export { DocumentChunksService };

View File

@@ -0,0 +1,7 @@
import type { TableRows } from '../database/database.js';
import { documentSchema, type Document } from './documents.schemas.js';
const mapFromDocumentRow = (row: TableRows['documents']): Document => documentSchema.parse(row);
export { mapFromDocumentRow };

View File

@@ -1,42 +1,44 @@
import { z } from 'zod';
import { queryFilterSchema } from '@morten-olsen/stash-query-dsl';
import { createListResultSchema } from '#root/utils/utils.schema.ts';
import { queryFilterSchema } from '#root/utils/utils.query.ts';
import { createListResultSchema, queryDSLSchema } from '../../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.guid().nullable(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
deletedAt: z.coerce.date().nullable(),
contentType: z.string().nullable(),
text: z.string().nullable(),
source: z.string().nullable(),
sourceId: z.string().nullable(),
type: z.string().nullable(),
typeVersion: z.int().nullable(),
metadata: z.unknown().nullable(),
})
.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(),
text: z.string().nullish(),
source: z.string().nullish(),
sourceId: z.string().nullish(),
type: z.string().optional(),
type: z.string().nullish(),
typeVersion: z.int().nullish(),
searchText: z.string().nullish(),
metadata: z.unknown().nullish(),
})
.meta({
id: 'DocumentUpsert',
example: {
content: 'the cat is yellow',
text: 'the cat is yellow',
contentType: 'text/plain',
source: 'test',
sourceId: 'test',
@@ -61,7 +63,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>;
@@ -70,11 +72,26 @@ const documentFindResultSchema = createListResultSchema(documentSchema);
type DocumentFindResult = z.infer<typeof documentFindResultSchema>;
export type { Document, DocumentUpsert, DocumentUpsertResult, DocumentFilter, DocumentFindResult };
const documentFilterChangedEventSchema = z.object({
action: z.enum(['add', 'remove', 'update']),
document: documentSchema,
});
type DocumentFilterChangedEvent = z.infer<typeof documentFilterChangedEventSchema>;
export type {
Document,
DocumentUpsert,
DocumentUpsertResult,
DocumentFilter,
DocumentFindResult,
DocumentFilterChangedEvent,
};
export {
documentSchema,
documentUpsertSchema,
documentUpsertResultSchema,
documentFilterSchema,
documentFindResultSchema,
documentFilterChangedEventSchema,
};

View File

@@ -0,0 +1,308 @@
import { isMatch, QueryParser, type QueryFilter } from '@morten-olsen/stash-query-dsl';
import { DatabaseService, tableNames, type TableRows } from '../database/database.js';
import { SplittingService } from '../splitter/splitter.js';
import { EventEmitter } from '../../utils/utils.event-emitter.js';
import { destroy, type Services } from '../../utils/utils.services.js';
import { compareObjectKeys } from '../../utils/utils.compare.js';
import { applyQueryFilter } from '../../utils/utils.query.js';
import { base64ToMaybeBuffer } from '../../utils/utils.binary.js';
import { mapFromDocumentRow } from './documents.mapping.js';
import {
type Document,
type DocumentFilter,
type DocumentFilterChangedEvent,
type DocumentFindResult,
type DocumentUpsert,
type DocumentUpsertResult,
} from './documents.schemas.js';
type DocumentsServiceEvents = {
upserted: (document: Document) => void;
inserted: (document: Document) => void;
updated: (next: Document, prev: Document) => void;
deleted: (document: Document) => void;
};
type DocumentServiceFilterSubscriber = {
filter?: QueryFilter | string;
fn: (event: DocumentFilterChangedEvent) => void;
abortSignal?: AbortSignal;
};
class DocumentsService extends EventEmitter<DocumentsServiceEvents> {
#services: Services;
#subscribeListenAbortController: AbortController;
#databaseListenAbortController?: Promise<AbortController>;
constructor(services: Services) {
super();
this.#subscribeListenAbortController = new AbortController();
this.onSubscribe(
async () => {
await this.#listen();
},
{
abortSignal: this.#subscribeListenAbortController.signal,
},
);
this.#services = services;
}
#setupListen = async () => {
const abortController = new AbortController();
const databaseService = this.#services.get(DatabaseService);
await databaseService.listen();
databaseService.on(
'changedRecord',
(evt) => {
if (evt.table !== tableNames.documents) {
return;
}
if (evt.operation === 'INSERT') {
const newDocument = mapFromDocumentRow(evt.newRecord as TableRows['documents']);
this.emit('inserted', newDocument);
this.emit('upserted', newDocument);
}
if (evt.operation === 'UPDATE') {
const newDocument = mapFromDocumentRow(evt.newRecord as TableRows['documents']);
const oldDocument = mapFromDocumentRow(evt.oldRecord as TableRows['documents']);
this.emit('updated', newDocument, oldDocument);
this.emit('upserted', newDocument);
}
if (evt.operation === 'DELETE') {
const oldDocument = mapFromDocumentRow(evt.oldRecord as TableRows['documents']);
this.emit('deleted', oldDocument);
}
},
{ abortSignal: abortController.signal },
);
return abortController;
};
#listen = async () => {
if (!this.#databaseListenAbortController) {
this.#databaseListenAbortController = this.#setupListen();
}
return this.#databaseListenAbortController;
};
public subscribe = async (options: DocumentServiceFilterSubscriber) => {
const abortController = new AbortController();
const queryParser = this.#services.get(QueryParser);
const filter = typeof options.filter === 'string' ? queryParser.parse(options.filter) : options.filter;
this.on(
'inserted',
(next) => {
const nextIncluded = !filter || isMatch(next, filter);
if (!nextIncluded) {
return;
}
options.fn({
action: 'add',
document: next,
});
},
{ abortSignal: abortController.signal },
);
this.on(
'updated',
(next, prev) => {
const nextIncluded = !filter || isMatch(next, filter);
const prevIncluded = !filter || isMatch(prev, filter);
if (nextIncluded && prevIncluded) {
options.fn({
action: 'update',
document: next,
});
} else if (nextIncluded && !prevIncluded) {
options.fn({
action: 'add',
document: next,
});
} else if (!nextIncluded && prevIncluded) {
options.fn({
action: 'remove',
document: next,
});
}
},
{ abortSignal: abortController.signal },
);
this.on(
'deleted',
(prev) => {
const prevIncluded = !filter || isMatch(prev, filter);
if (!prevIncluded) {
return;
}
options.fn({
action: 'remove',
document: prev,
});
},
{ abortSignal: abortController.signal },
);
options.abortSignal?.addEventListener('abort', () => abortController.abort());
await this.#listen();
return () => abortController.abort();
};
public find = async (filter: DocumentFilter): Promise<DocumentFindResult> => {
const databaseService = this.#services.get(DatabaseService);
const db = await databaseService.getInstance();
let query = db<TableRows['documents']>(tableNames.documents);
if (filter) {
const parser = this.#services.get(QueryParser);
query = applyQueryFilter(
query,
typeof filter.condition === 'string' ? parser.parse(filter.condition) : filter.condition,
);
}
query = query.limit(filter.limit).offset(filter.offset);
const items = await query;
return {
items: items.map(mapFromDocumentRow),
};
};
public get = async (id: string): Promise<Document> => {
const databaseService = this.#services.get(DatabaseService);
const db = await databaseService.getInstance();
const [item] = await db<TableRows['documents']>(tableNames.documents).where('id', id).limit(1);
return mapFromDocumentRow(item);
};
public remove = async (id: string): Promise<void> => {
const databaseService = this.#services.get(DatabaseService);
const db = await databaseService.getInstance();
await db<TableRows['documents']>(tableNames.documents).where('id', id).delete();
};
public upsert = async (document: DocumentUpsert): Promise<DocumentUpsertResult> => {
const databaseService = this.#services.get(DatabaseService);
const db = await databaseService.getInstance();
const result = await db.transaction(async (trx) => {
let id = document.id || crypto.randomUUID();
if (!document.id && document.source && document.sourceId) {
const [currentSourceDocument] = await trx<TableRows['documents']>(tableNames.documents)
.where('source', document.source)
.andWhere('sourceId', document.sourceId)
.limit(1);
if (currentSourceDocument) {
id = currentSourceDocument.id;
}
}
const now = new Date();
const [current] = await trx<TableRows['documents']>(tableNames.documents).where('id', id).limit(1);
if (current) {
id = current.id;
document.id = id;
if (
compareObjectKeys(current, document, [
'sourceId',
'source',
'content',
'contentType',
'type',
'typeVersion',
'metadata',
'text',
])
) {
return {
id,
action: 'skipped',
document: mapFromDocumentRow(current),
} as const;
}
await trx<TableRows['documents']>(tableNames.documents)
.update({
...document,
content: base64ToMaybeBuffer(document.content),
id,
updatedAt: now,
})
.where('id', id);
const resultDocument: Document = mapFromDocumentRow({
...current,
...document,
content: base64ToMaybeBuffer(document.content ?? current.content) || null,
id,
});
return {
id,
action: 'updated',
document: resultDocument,
} as const;
} else {
await trx<TableRows['documents']>(tableNames.documents).insert({
...document,
content: base64ToMaybeBuffer(document.content),
id,
createdAt: now,
updatedAt: now,
});
const resultDocument: Document = mapFromDocumentRow({
type: null,
text: null,
owner: null,
contentType: null,
source: null,
sourceId: null,
typeVersion: null,
metadata: null,
...document,
content: base64ToMaybeBuffer(document.content) || null,
deletedAt: null,
id,
createdAt: now,
updatedAt: now,
});
return {
id,
action: 'inserted',
document: resultDocument,
} as const;
}
});
if (result.action !== 'skipped') {
await db.transaction(async (trx) => {
await trx<TableRows['documentChunks']>(tableNames.documentChunks).delete().where('owner', result.id);
const splittingService = this.#services.get(SplittingService);
const chunks = await splittingService.chunk(result.document);
if (chunks.length > 0) {
await trx<TableRows['documentChunks']>(tableNames.documentChunks).insert(
chunks.map((chunk) => ({
id: crypto.randomUUID(),
owner: result.id,
content: chunk.content,
embedding: chunk.vector.toSql(),
embeddingModel: chunk.model,
})),
);
}
});
}
return result;
};
[destroy] = async () => {
this.#subscribeListenAbortController.abort();
if (this.#databaseListenAbortController) {
(await this.#databaseListenAbortController).abort();
}
};
}
export * from './documents.schemas.js';
export { DocumentsService };

View File

@@ -1,6 +1,8 @@
import { pipeline, type FeatureExtractionPipeline } from '@huggingface/transformers';
import { Vector } from './embeddings.vector.ts';
import type { ExplicitAny } from '../../global.js';
import { Vector } from './embeddings.vector.js';
type ExtractOptions = {
input: string[];
@@ -57,4 +59,4 @@ class EmbeddingsService {
};
}
export { EmbeddingsService };
export { EmbeddingsService, Vector };

View File

@@ -1,11 +1,10 @@
import { EmbeddingsService } from '../embeddings/embeddings.ts';
import type { Document } from '../documents/documents.schemas.ts';
import { EmbeddingsService } from '../embeddings/embeddings.js';
import type { Document } from '../documents/documents.schemas.js';
import type { Services } from '../../utils/utils.services.js';
import { EMBEDDING_MODEL } from '../../utils/utils.consts.js';
import type { Chunk, Splitter } from './splitter.types.ts';
import { textSplitter } from './splitters/splitters.text.ts';
import type { Services } from '#root/utils/utils.services.ts';
import { EMBEDDING_MODEL } from '#root/utils/utils.consts.ts';
import type { Chunk, Splitter } from './splitter.types.js';
import { textSplitter } from './splitters/splitters.text.js';
class SplittingService {
#services: Services;
@@ -40,5 +39,5 @@ class SplittingService {
};
}
export * from './splitter.types.ts';
export * from './splitter.types.js';
export { SplittingService };

View File

@@ -1,5 +1,5 @@
import type { Document } from '../documents/documents.schemas.ts';
import type { Vector } from '../embeddings/embeddings.vector.ts';
import type { Document } from '../documents/documents.schemas.js';
import type { Vector } from '../embeddings/embeddings.vector.js';
type Chunk = {
content: string;

View File

@@ -1,15 +1,15 @@
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
import type { Splitter } from '../splitter.types.ts';
import type { Splitter } from '../splitter.types.js';
const textSplitter: Splitter = {
match: (document) => !!document.content,
match: (document) => !!document.text,
chunk: async (document) => {
if (!document.content) {
if (!document.text) {
return [];
}
const splitter = new RecursiveCharacterTextSplitter({ chunkSize: 100, chunkOverlap: 0 });
const texts = await splitter.splitText(document.content);
const texts = await splitter.splitText(document.text);
return texts;
},
};

View File

@@ -1,6 +1,5 @@
import { DatabaseService } from '../database/database.ts';
import { Services } from '#root/utils/utils.services.ts';
import { DatabaseService } from '../database/database.js';
import { Services } from '../../utils/utils.services.js';
class WarmupService {
#services: Services;

View File

@@ -0,0 +1,14 @@
const base64ToMaybeBuffer = (input?: string | null | Buffer) => {
if (input === null) {
return input;
}
if (!input) {
return undefined;
}
if (typeof input === 'object') {
return input;
}
return Buffer.from(input, 'base64');
};
export { base64ToMaybeBuffer };

View File

@@ -0,0 +1,21 @@
import deepEqual from 'deep-equal';
const compareObjectKeys = <A extends Record<string, unknown>, B extends Record<string, unknown>>(
a: A,
b: B,
keys: (keyof (A & B))[],
) => {
for (const key of keys) {
const avalue = a[key as keyof A];
const bvalue = b[key as keyof B];
if (bvalue === undefined) {
continue;
}
if (!deepEqual(avalue, bvalue)) {
return false;
}
}
return true;
};
export { compareObjectKeys };

View File

@@ -1,4 +1,7 @@
import type { ExplicitAny } from '../global.js';
type EventListener<T extends unknown[]> = (...args: T) => void | Promise<void>;
type SubscribeListener<T> = (type: T) => void | Promise<void>;
type OnOptions = {
abortSignal?: AbortSignal;
@@ -6,8 +9,25 @@ type OnOptions = {
class EventEmitter<T extends Record<string, (...args: ExplicitAny[]) => void | Promise<void>>> {
#listeners = new Map<keyof T, Set<EventListener<ExplicitAny>>>();
#subscribeListeners = new Set<SubscribeListener<keyof T>>();
onSubscribe = (callback: SubscribeListener<keyof T>, options: OnOptions = {}) => {
const { abortSignal } = options;
const callbackClone = (type: keyof T) => callback(type);
this.#subscribeListeners.add(callbackClone);
const abortController = new AbortController();
abortSignal?.addEventListener('abort', abortController.abort);
abortController.signal.addEventListener('abort', () => {
this.#subscribeListeners.difference(new Set([callbackClone]));
});
return abortController.abort;
};
on = <K extends keyof T>(event: K, callback: EventListener<Parameters<T[K]>>, options: OnOptions = {}) => {
for (const subscribeListener of this.#subscribeListeners) {
subscribeListener(event);
}
const { abortSignal } = options;
if (!this.#listeners.has(event)) {
this.#listeners.set(event, new Set());

View File

@@ -0,0 +1,161 @@
import type {
QueryCondition,
QueryConditionNumber,
QueryConditionText,
QueryFilter,
} from '@morten-olsen/stash-query-dsl';
import { type Knex } from 'knex';
/**
* Escapes a JSON key for use in PostgreSQL JSON operators.
* Escapes single quotes by doubling them, which is the PostgreSQL standard.
*/
const escapeJsonKey = (key: string): string => {
return key.replace(/'/g, "''");
};
const getFieldSelector = (query: Knex.QueryBuilder, field: string[], tableName?: string) => {
const baseColumn = field[0];
if (field.length === 1) {
return tableName ? `${tableName}.${baseColumn}` : baseColumn;
}
const baseFieldRef = tableName ? query.client.ref(baseColumn).withSchema(tableName) : query.client.ref(baseColumn);
const jsonPath = field.slice(1);
let sqlExpression = baseFieldRef.toString();
for (let i = 0; i < jsonPath.length - 1; i++) {
const escapedKey = escapeJsonKey(jsonPath[i]);
sqlExpression += ` -> '${escapedKey}'`;
}
const finalElement = jsonPath[jsonPath.length - 1];
const escapedFinalKey = escapeJsonKey(finalElement);
sqlExpression += ` ->> '${escapedFinalKey}'`;
return query.client.raw(sqlExpression);
};
const applyQueryConditionText = (query: Knex.QueryBuilder, { field, tableName, conditions }: QueryConditionText) => {
const selector = getFieldSelector(query, field, tableName);
if (conditions.equal) {
query = query.where(selector, '=', conditions.equal);
}
if (conditions.notEqual) {
query = query.where(selector, '<>', conditions.notEqual);
}
if (conditions.like) {
query = query.whereLike(selector, conditions.like);
}
if (conditions.notLike) {
query = query.not.whereLike(selector, conditions.notLike);
}
if (conditions.equal === null) {
query = query.whereNull(selector);
}
if (conditions.notEqual === null) {
query = query.whereNotNull(selector);
}
if (conditions.in) {
query = query.whereIn(selector, conditions.in);
}
if (conditions.notIn) {
query = query.whereNotIn(selector, conditions.notIn);
}
return query;
};
const applyQueryConditionNumber = (
query: Knex.QueryBuilder,
{ field, tableName, conditions }: QueryConditionNumber,
) => {
const selector = getFieldSelector(query, field, tableName);
if (conditions.equals !== undefined && conditions.equals !== null) {
query = query.where(selector, '=', conditions.equals);
}
if (conditions.notEquals !== undefined && conditions.notEquals !== null) {
query = query.where(selector, '<>', conditions.notEquals);
}
if (conditions.equals === null) {
query = query.whereNull(selector);
}
if (conditions.notEquals === null) {
query = query.whereNotNull(selector);
}
if (conditions.greaterThan) {
query = query.where(selector, '>', conditions.greaterThan);
}
if (conditions.greaterThanOrEqual) {
query = query.where(selector, '>=', conditions.greaterThanOrEqual);
}
if (conditions.lessThan) {
query = query.where(selector, '<', conditions.lessThan);
}
if (conditions.lessThanOrEqual) {
query = query.where(selector, '<=', conditions.lessThanOrEqual);
}
if (conditions.in) {
query = query.whereIn(selector, conditions.in);
}
if (conditions.notIn) {
query = query.whereNotIn(selector, conditions.notIn);
}
return query;
};
const applyQueryCondition = (query: Knex.QueryBuilder, options: QueryCondition) => {
switch (options.type) {
case 'text': {
return applyQueryConditionText(query, options);
}
case 'number': {
return applyQueryConditionNumber(query, options);
}
default: {
throw new Error(`Unknown filter type`);
}
}
};
const applyQueryFilter = (query: Knex.QueryBuilder, filter: QueryFilter) => {
if (filter.type === 'operator') {
if (filter.conditions.length === 0) {
return query;
}
switch (filter.operator) {
case 'or': {
return query.where((subquery) => {
let isFirst = true;
for (const condition of filter.conditions) {
if (isFirst) {
applyQueryFilter(subquery, condition);
isFirst = false;
} else {
subquery.orWhere((subSubquery) => {
applyQueryFilter(subSubquery, condition);
});
}
}
});
}
case 'and': {
return query.where((subquery) => {
let isFirst = true;
for (const condition of filter.conditions) {
if (isFirst) {
applyQueryFilter(subquery, condition);
isFirst = false;
} else {
subquery.andWhere((subSubquery) => {
applyQueryFilter(subSubquery, condition);
});
}
}
});
}
}
} else {
return applyQueryCondition(query, filter);
}
};
export { applyQueryCondition, applyQueryFilter };

View File

@@ -0,0 +1,26 @@
import { QueryParser } from '@morten-olsen/stash-query-dsl';
import { z, type ZodType } from 'zod';
const parser = new QueryParser();
const createListResultSchema = <T extends ZodType>(schema: T) =>
z.object({
items: z.array(schema),
});
const queryDSLSchema = z
.string()
.describe('Query DSL based filter')
.superRefine((value, context) => {
try {
parser.parse(value);
} catch (err) {
context.addIssue(String(err));
}
})
.meta({
id: 'QueryDQLString',
examples: ["metadata.foo = 'bar'"],
});
export { createListResultSchema, queryDSLSchema };

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"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

@@ -0,0 +1,32 @@
FROM node:23-slim AS base
ENV \
MODEL_DIR=/models
RUN corepack enable
WORKDIR /app
FROM base AS builder
RUN npm i -g turbo
COPY . .
RUN turbo prune @morten-olsen/stash-server --docker
FROM base AS installer
COPY --from=builder /app/out/json/ .
RUN pnpm install --frozen-lockfile
COPY --from=builder /app/out/full/ .
RUN \
pnpm build \
&& node /app/packages/server/dist/preload-data.js
FROM base AS runner
ENV \
SERVER_HOST=0.0.0.0
RUN \
addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nodejs \
&& mkdir /data \
&& chown nodejs:nodejs /data
COPY --from=installer /models /models
COPY --from=installer /app /app
USER nodejs
CMD ["node", "/app/packages/server/dist/start.js"]

View File

@@ -27,25 +27,15 @@
},
"name": "@morten-olsen/stash-server",
"version": "1.0.0",
"imports": {
"#root/*": "./src/*"
},
"dependencies": {
"@electric-sql/pglite": "^0.3.14",
"@fastify/cors": "11.1.0",
"@fastify/swagger": "9.6.1",
"@fastify/websocket": "11.2.0",
"@huggingface/transformers": "^3.8.1",
"@langchain/textsplitters": "^1.0.1",
"@morten-olsen/stash-query-dsl": "workspace:*",
"@morten-olsen/stash-runtime": "workspace:*",
"@scalar/fastify-api-reference": "1.40.2",
"better-sqlite3": "^12.5.0",
"deep-equal": "^2.2.3",
"fastify": "5.6.2",
"fastify-type-provider-zod": "6.1.0",
"knex": "^3.1.0",
"knex-pglite": "^0.13.0",
"pg": "^8.16.3",
"pgvector": "^0.2.1",
"zod": "4.1.13",
"zod-to-json-schema": "3.25.0"
}

View File

@@ -5,17 +5,18 @@ 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 { Services } from './utils/utils.services.ts';
import { systemEndpoints } from './endpoints/system/system.ts';
import { WarmupService } from './services/warmup/warmup.ts';
import { documentEndpoints } from './endpoints/documents/documents.ts';
import { documentFilterEndpoints } from './endpoints/document-filters/document-filters.ts';
import { documentChunkFilterEndpoints } from './endpoints/document-chunk-filters/document-chunk-filters.ts';
import { systemEndpoints } from './endpoints/system/system.js';
import { documentEndpoints } from './endpoints/documents/documents.js';
import { documentFilterEndpoints } from './endpoints/document-filters/document-filters.js';
import { documentChunkFilterEndpoints } from './endpoints/document-chunk-filters/document-chunk-filters.js';
class BaseError extends Error {
public statusCode: number;
@@ -26,12 +27,18 @@ class BaseError extends Error {
}
}
const createApi = async (services: Services = new Services()) => {
const createApi = async (runtime: StashRuntime = new StashRuntime()) => {
runtime.documents.subscribe({
filter: "metadata.foo = 'bar'",
fn: (document) => {
// console.log(document);
},
});
const app = fastify().withTypeProvider<ZodTypeProvider>();
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
app.decorate('services', services);
app.decorate('runtime', runtime);
app.register(fastifyCors);
app.register(fastifySwagger, {
@@ -42,10 +49,20 @@ const createApi = async (services: Services = new Services()) => {
},
},
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) => {
@@ -92,8 +109,7 @@ const createApi = async (services: Services = new Services()) => {
});
app.addHook('onReady', async () => {
const warmupService = app.services.get(WarmupService);
await warmupService.ensure();
app.runtime.warmup.ensure();
});
await app.register(systemEndpoints, { prefix: '/system' });

View File

@@ -1,37 +1,36 @@
import { StashRuntime, type DocumentUpsert } from '@morten-olsen/stash-runtime';
import { createApi } from './api.js';
import { DocumentsService, type DocumentUpsert } from './services/documents/documents.ts';
import { Services } from './utils/utils.services.ts';
const services = new Services();
const server = await createApi(services);
const runtime = new StashRuntime();
const server = await createApi(runtime);
const documentsService = services.get(DocumentsService);
const documents: DocumentUpsert[] = [
{
metadata: {
foo: 'bar',
},
type: 'demo',
content: 'the cat is yellow',
text: 'the cat is yellow',
},
{
metadata: {
foo: 'bar',
},
type: 'demo',
content: 'the dog is blue',
text: 'the dog is blue',
},
{
metadata: {
foo: 'baz',
},
source: 'test',
content: 'the pig says hi',
text: 'the pig says hi',
type: 'demo',
},
];
await Promise.all(documents.map((document) => documentsService.upsert(document)));
await Promise.all(documents.map((document) => runtime.documents.upsert(document)));
await server.listen({
port: 3400,

View File

@@ -1,11 +1,6 @@
import { documentChunkFilterSchema, documentChunksFindResultSchema } from '@morten-olsen/stash-runtime';
import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
import {
documentChunkFilterSchema,
documentChunksFindResultSchema,
DocumentChunksService,
} from '#root/services/document-chunks/document-chunks.ts';
const documentChunkFilterEndpoints: FastifyPluginAsyncZod = async (instance) => {
instance.route({
method: 'POST',
@@ -20,9 +15,8 @@ const documentChunkFilterEndpoints: FastifyPluginAsyncZod = async (instance) =>
},
},
handler: async (req, reply) => {
const { services } = instance;
const documentChunksService = services.get(DocumentChunksService);
const response = await documentChunksService.find(req.body);
const { runtime } = instance;
const response = await runtime.documentChunks.find(req.body);
await reply.send(response);
},
});

View File

@@ -1,11 +1,6 @@
import { documentFilterSchema, documentFindResultSchema } from '@morten-olsen/stash-runtime';
import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
import {
documentFilterSchema,
documentFindResultSchema,
DocumentsService,
} from '#root/services/documents/documents.ts';
const documentFilterEndpoints: FastifyPluginAsyncZod = async (instance) => {
instance.route({
method: 'POST',
@@ -20,9 +15,8 @@ const documentFilterEndpoints: FastifyPluginAsyncZod = async (instance) => {
},
},
handler: async (req, reply) => {
const { services } = instance;
const documentsService = services.get(DocumentsService);
const response = await documentsService.find(req.body);
const { runtime } = instance;
const response = await runtime.documents.find(req.body);
await reply.send(response);
},
});

View File

@@ -1,11 +1,6 @@
import { documentUpsertResultSchema, documentUpsertSchema } from '@morten-olsen/stash-runtime';
import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
import {
DocumentsService,
documentUpsertResultSchema,
documentUpsertSchema,
} from '#root/services/documents/documents.ts';
const documentEndpoints: FastifyPluginAsyncZod = async (instance) => {
instance.route({
method: 'POST',
@@ -20,9 +15,8 @@ const documentEndpoints: FastifyPluginAsyncZod = async (instance) => {
},
},
handler: async (req, reply) => {
const { services } = instance;
const documentsService = services.get(DocumentsService);
const response = await documentsService.upsert(req.body);
const { runtime } = instance;
const response = await runtime.documents.upsert(req.body);
await reply.send(response);
},
});

View File

@@ -1,8 +1,6 @@
import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
import { z } from 'zod';
import { DatabaseService } from '#root/services/database/database.ts';
const systemEndpoints: FastifyPluginAsyncZod = async (instance) => {
instance.route({
method: 'GET',
@@ -18,9 +16,8 @@ const systemEndpoints: FastifyPluginAsyncZod = async (instance) => {
},
},
handler: async (_, reply) => {
const { services } = instance;
const databaseService = services.get(DatabaseService);
const db = await databaseService.getInstance();
const { runtime } = instance;
const db = await runtime.database.getInstance();
await db.raw('SELECT 1=1');
await reply.send({
status: 'ok',

View File

@@ -1,5 +1,5 @@
import 'fastify';
import type { Services } from './utils/utils.services.ts';
import type { StashRuntime } from '@morten-olsen/stash-runtime';
// eslint-disable-next-line
declare type ExplicitAny = any;
@@ -7,6 +7,6 @@ declare type ExplicitAny = any;
declare module 'fastify' {
// eslint-disable-next-line
export interface FastifyInstance {
services: Services;
runtime: StashRuntime;
}
}

View File

@@ -0,0 +1,11 @@
import { mkdir } from 'fs/promises';
import { resolve } from 'path';
import { preloadModel, setModelLocation } from '@morten-olsen/stash-runtime';
if (process.env.MODEL_DIR) {
const modelDir = resolve(process.env.MODEL_DIR);
await mkdir(modelDir, { recursive: true });
setModelLocation(modelDir);
await preloadModel();
}

View File

@@ -1,202 +0,0 @@
import type { Token } from './query-parser.types.ts';
class Lexer {
#input: string;
#position = 0;
#tokens: Token[] = [];
constructor(input: string) {
this.#input = input;
}
#skipWhitespace = (): void => {
while (this.#position < this.#input.length && /\s/.test(this.#input[this.#position])) {
this.#position++;
}
};
#nextToken = (): Token | null => {
const char = this.#input[this.#position];
const startPosition = this.#position;
// Single character tokens
if (char === '(') {
this.#position++;
return { type: 'LPAREN', value: '(', position: startPosition };
}
if (char === ')') {
this.#position++;
return { type: 'RPAREN', value: ')', position: startPosition };
}
if (char === ',') {
this.#position++;
return { type: 'COMMA', value: ',', position: startPosition };
}
if (char === '.') {
this.#position++;
return { type: 'DOT', value: '.', position: startPosition };
}
// Two-character operators
if (char === '!' && this.#input[this.#position + 1] === '=') {
this.#position += 2;
return { type: 'NOT_EQUALS', value: '!=', position: startPosition };
}
if (char === '>' && this.#input[this.#position + 1] === '=') {
this.#position += 2;
return { type: 'GREATER_THAN_OR_EQUAL', value: '>=', position: startPosition };
}
if (char === '<' && this.#input[this.#position + 1] === '=') {
this.#position += 2;
return { type: 'LESS_THAN_OR_EQUAL', value: '<=', position: startPosition };
}
// Single character operators
if (char === '=') {
this.#position++;
return { type: 'EQUALS', value: '=', position: startPosition };
}
if (char === '>') {
this.#position++;
return { type: 'GREATER_THAN', value: '>', position: startPosition };
}
if (char === '<') {
this.#position++;
return { type: 'LESS_THAN', value: '<', position: startPosition };
}
// String literal
if (char === "'") {
return this.#readString();
}
// Number
if (/[0-9]/.test(char) || (char === '-' && /[0-9]/.test(this.#input[this.#position + 1]))) {
return this.#readNumber();
}
// Identifier or keyword
if (/[a-zA-Z_]/.test(char)) {
return this.#readIdentifierOrKeyword();
}
throw new Error(`Unexpected character '${char}' at position ${this.#position}`);
};
#readString = (): Token => {
const startPosition = this.#position;
this.#position++; // Skip opening quote
let value = '';
while (this.#position < this.#input.length) {
const char = this.#input[this.#position];
if (char === "'") {
// Check for escaped quote
if (this.#input[this.#position + 1] === "'") {
value += "'";
this.#position += 2;
} else {
this.#position++; // Skip closing quote
return { type: 'STRING', value, position: startPosition };
}
} else {
value += char;
this.#position++;
}
}
throw new Error(`Unterminated string starting at position ${startPosition}`);
};
#readNumber = (): Token => {
const startPosition = this.#position;
let value = '';
// Optional minus sign
if (this.#input[this.#position] === '-') {
value += '-';
this.#position++;
}
// Integer part
while (this.#position < this.#input.length && /[0-9]/.test(this.#input[this.#position])) {
value += this.#input[this.#position];
this.#position++;
}
// Decimal part
if (this.#input[this.#position] === '.' && /[0-9]/.test(this.#input[this.#position + 1])) {
value += '.';
this.#position++;
while (this.#position < this.#input.length && /[0-9]/.test(this.#input[this.#position])) {
value += this.#input[this.#position];
this.#position++;
}
}
// Scientific notation
if (this.#input[this.#position] === 'e' || this.#input[this.#position] === 'E') {
value += this.#input[this.#position];
this.#position++;
if (this.#input[this.#position] === '+' || this.#input[this.#position] === '-') {
value += this.#input[this.#position];
this.#position++;
}
while (this.#position < this.#input.length && /[0-9]/.test(this.#input[this.#position])) {
value += this.#input[this.#position];
this.#position++;
}
}
return { type: 'NUMBER', value, position: startPosition };
};
#readIdentifierOrKeyword = (): Token => {
const startPosition = this.#position;
let value = '';
while (this.#position < this.#input.length && /[a-zA-Z0-9_]/.test(this.#input[this.#position])) {
value += this.#input[this.#position];
this.#position++;
}
const upperValue = value.toUpperCase();
// Keywords
switch (upperValue) {
case 'AND':
return { type: 'AND', value, position: startPosition };
case 'OR':
return { type: 'OR', value, position: startPosition };
case 'LIKE':
return { type: 'LIKE', value, position: startPosition };
case 'NOT':
return { type: 'NOT', value, position: startPosition };
case 'IN':
return { type: 'IN', value, position: startPosition };
case 'IS':
return { type: 'IS', value, position: startPosition };
case 'NULL':
return { type: 'NULL', value, position: startPosition };
default:
return { type: 'IDENTIFIER', value, position: startPosition };
}
};
public tokenize = (): Token[] => {
while (this.#position < this.#input.length) {
this.#skipWhitespace();
if (this.#position >= this.#input.length) break;
const token = this.#nextToken();
if (token) {
this.#tokens.push(token);
}
}
this.#tokens.push({ type: 'EOF', value: '', position: this.#position });
return this.#tokens;
};
}
export { Lexer };

View File

@@ -1,317 +0,0 @@
import { Lexer } from './query-parser.lexer.ts';
import type { Token, TokenType } from './query-parser.types.ts';
import type { QueryConditionText, QueryConditionNumber, QueryFilter, QueryCondition } from '#root/utils/utils.query.ts';
class Parser {
#tokens: Token[] = [];
#position = 0;
#current = (): Token => {
return this.#tokens[this.#position];
};
#advance = (): Token => {
const token = this.#current();
this.#position++;
return token;
};
#expect = (type: TokenType): Token => {
const token = this.#current();
if (token.type !== type) {
throw new Error(`Expected ${type} but got ${token.type} at position ${token.position}`);
}
return this.#advance();
};
#parseExpression = (): QueryFilter => {
return this.#parseOr();
};
#parseOr = (): QueryFilter => {
let left = this.#parseAnd();
while (this.#current().type === 'OR') {
this.#advance();
const right = this.#parseAnd();
left = this.#combineWithOperator(left, right, 'or');
}
return left;
};
#parseAnd = (): QueryFilter => {
let left = this.#parsePrimary();
while (this.#current().type === 'AND') {
this.#advance();
const right = this.#parsePrimary();
left = this.#combineWithOperator(left, right, 'and');
}
return left;
};
#combineWithOperator = (left: QueryFilter, right: QueryFilter, operator: 'and' | 'or'): QueryFilter => {
// If left is already an operator of the same type, add to its conditions
if (left.type === 'operator' && left.operator === operator) {
return {
type: 'operator',
operator,
conditions: [...left.conditions, right],
};
}
return {
type: 'operator',
operator,
conditions: [left, right],
};
};
#parsePrimary = (): QueryFilter => {
// Handle parenthesized expressions
if (this.#current().type === 'LPAREN') {
this.#advance();
const expr = this.#parseExpression();
this.#expect('RPAREN');
return expr;
}
// Must be a condition
return this.#parseCondition();
};
#parseCondition = (): QueryCondition => {
const field = this.#parseField();
const token = this.#current();
// IS NULL / IS NOT NULL
if (token.type === 'IS') {
this.#advance();
const isNot = this.#current().type === 'NOT';
if (isNot) {
this.#advance();
}
this.#expect('NULL');
// IS NULL / IS NOT NULL could be either text or number - default to text
return {
type: 'text',
field,
conditions: isNot ? { notEqual: undefined, equal: undefined } : { equal: null },
} satisfies QueryConditionText;
}
// NOT IN / NOT LIKE
if (token.type === 'NOT') {
this.#advance();
const nextToken = this.#current();
if (nextToken.type === 'IN') {
this.#advance();
return this.#parseInCondition(field, true);
}
if (nextToken.type === 'LIKE') {
this.#advance();
const pattern = this.#expect('STRING').value;
return {
type: 'text',
field,
conditions: { notLike: pattern },
};
}
throw new Error(`Expected IN or LIKE after NOT at position ${nextToken.position}`);
}
// IN
if (token.type === 'IN') {
this.#advance();
return this.#parseInCondition(field, false);
}
// LIKE
if (token.type === 'LIKE') {
this.#advance();
const pattern = this.#expect('STRING').value;
return {
type: 'text',
field,
conditions: { like: pattern },
};
}
// Comparison operators
if (token.type === 'EQUALS') {
this.#advance();
return this.#parseValueCondition(field, 'equals');
}
if (token.type === 'NOT_EQUALS') {
this.#advance();
return this.#parseValueCondition(field, 'notEquals');
}
if (token.type === 'GREATER_THAN') {
this.#advance();
const value = this.#parseNumber();
return {
type: 'number',
field,
conditions: { greaterThan: value },
};
}
if (token.type === 'GREATER_THAN_OR_EQUAL') {
this.#advance();
const value = this.#parseNumber();
return {
type: 'number',
field,
conditions: { greaterThanOrEqual: value },
};
}
if (token.type === 'LESS_THAN') {
this.#advance();
const value = this.#parseNumber();
return {
type: 'number',
field,
conditions: { lessThan: value },
};
}
if (token.type === 'LESS_THAN_OR_EQUAL') {
this.#advance();
const value = this.#parseNumber();
return {
type: 'number',
field,
conditions: { lessThanOrEqual: value },
};
}
throw new Error(`Unexpected token '${token.value}' at position ${token.position}`);
};
#parseField = (): string[] => {
const parts: string[] = [];
parts.push(this.#expect('IDENTIFIER').value);
while (this.#current().type === 'DOT') {
this.#advance();
parts.push(this.#expect('IDENTIFIER').value);
}
return parts;
};
#parseValueCondition = (field: string[], operator: 'equals' | 'notEquals'): QueryCondition => {
const token = this.#current();
if (token.type === 'STRING') {
this.#advance();
const textCondition: QueryConditionText = {
type: 'text',
field,
conditions: operator === 'equals' ? { equal: token.value } : { notEqual: token.value },
};
return textCondition;
}
if (token.type === 'NUMBER') {
this.#advance();
const value = parseFloat(token.value);
const numCondition: QueryConditionNumber = {
type: 'number',
field,
conditions: operator === 'equals' ? { equals: value } : { notEquals: value },
};
return numCondition;
}
if (token.type === 'NULL') {
this.#advance();
// NULL equality - default to text type
return {
type: 'text',
field,
conditions: operator === 'equals' ? { equal: null } : {},
} as QueryConditionText;
}
throw new Error(`Expected value but got ${token.type} at position ${token.position}`);
};
#parseNumber = (): number => {
const token = this.#expect('NUMBER');
return parseFloat(token.value);
};
#parseInCondition = (field: string[], isNot: boolean): QueryCondition => {
this.#expect('LPAREN');
const firstToken = this.#current();
if (firstToken.type === 'STRING') {
// Text IN
const values: string[] = [];
values.push(this.#advance().value);
while (this.#current().type === 'COMMA') {
this.#advance();
values.push(this.#expect('STRING').value);
}
this.#expect('RPAREN');
return {
type: 'text',
field,
conditions: isNot ? { notIn: values } : { in: values },
};
}
if (firstToken.type === 'NUMBER') {
// Numeric IN
const values: number[] = [];
values.push(parseFloat(this.#advance().value));
while (this.#current().type === 'COMMA') {
this.#advance();
values.push(parseFloat(this.#expect('NUMBER').value));
}
this.#expect('RPAREN');
return {
type: 'number',
field,
conditions: isNot ? { notIn: values } : { in: values },
};
}
throw new Error(`Expected STRING or NUMBER in IN list at position ${firstToken.position}`);
};
public parse(input: string): QueryFilter {
const lexer = new Lexer(input);
this.#tokens = lexer.tokenize();
this.#position = 0;
const result = this.#parseExpression();
if (this.#current().type !== 'EOF') {
throw new Error(`Unexpected token '${this.#current().value}' at position ${this.#current().position}`);
}
return result;
}
}
export { Parser };

View File

@@ -1,19 +0,0 @@
import { Stringifier } from './query-parser.stringifier.ts';
import { Parser } from './query-parser.parser.ts';
import type { QueryFilter } from '#root/utils/utils.query.ts';
class QueryParser {
private parser = new Parser();
private stringifier = new Stringifier();
public parse = (input: string): QueryFilter => {
return this.parser.parse(input);
};
public stringify = (filter: QueryFilter): string => {
return this.stringifier.stringify(filter);
};
}
export { QueryParser };

View File

@@ -1,30 +0,0 @@
type TokenType =
| 'IDENTIFIER'
| 'STRING'
| 'NUMBER'
| 'AND'
| 'OR'
| 'LIKE'
| 'NOT'
| 'IN'
| 'IS'
| 'NULL'
| 'EQUALS'
| 'NOT_EQUALS'
| 'GREATER_THAN'
| 'GREATER_THAN_OR_EQUAL'
| 'LESS_THAN'
| 'LESS_THAN_OR_EQUAL'
| 'LPAREN'
| 'RPAREN'
| 'COMMA'
| 'DOT'
| 'EOF';
type Token = {
type: TokenType;
value: string;
position: number;
};
export type { TokenType, Token };

View File

@@ -1,54 +0,0 @@
import knex, { type Knex } from 'knex';
import ClientPgLite from 'knex-pglite';
import { PGlite } from '@electric-sql/pglite';
import { vector } from '@electric-sql/pglite/vector';
import { migrationSource } from './migrations/migrations.ts';
import { destroy, Services } from '#root/utils/utils.services.ts';
class DatabaseService {
#services: Services;
#instance?: Promise<Knex>;
constructor(services: Services) {
this.#services = services;
}
#setup = async () => {
const pglite = new PGlite({
extensions: { vector },
});
const instance = knex({
client: ClientPgLite,
dialect: 'postgres',
connection: () => ({ pglite }) as object,
});
await instance.raw(`CREATE EXTENSION IF NOT EXISTS vector`);
await instance.migrate.latest({
migrationSource: migrationSource({ services: this.#services }),
});
return instance;
};
public getInstance = () => {
if (!this.#instance) {
this.#instance = this.#setup();
}
return this.#instance;
};
[destroy] = async () => {
if (!this.#instance) {
return;
}
const instance = await this.#instance;
await instance.destroy();
};
}
export { type TableRows, tableNames } from './migrations/migrations.ts';
export { DatabaseService };

View File

@@ -1,33 +0,0 @@
import { z } from 'zod';
import { createListResultSchema } from '#root/utils/utils.schema.ts';
import { queryFilterSchema } from '#root/utils/utils.query.ts';
const documentChunkSchema = z.object({
id: z.string(),
owner: z.string(),
content: z.string(),
metadata: z.unknown(),
});
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(),
});
type DocumentChunkFilter = z.infer<typeof documentChunkFilterSchema>;
const documentChunksFindResultSchema = createListResultSchema(
documentChunkSchema.extend({
distance: z.number().optional(),
}),
);
type DocumentChunksFindResult = z.infer<typeof documentChunksFindResultSchema>;
export type { DocumentChunk, DocumentChunkFilter, DocumentChunksFindResult };
export { documentChunkSchema, documentChunkFilterSchema, documentChunksFindResultSchema };

View File

@@ -1,13 +0,0 @@
import type { TableRows } from '../database/database.ts';
import type { DocumentChunk } from './document-chunks.schemas.ts';
const mapFromDocumentChunkRow = (
row: TableRows['documentChunks'] & {
metadata: unknown;
},
): DocumentChunk => ({
...row,
});
export { mapFromDocumentChunkRow };

View File

@@ -1,12 +0,0 @@
import type { TableRows } from '../database/database.ts';
import type { Document } from './documents.schemas.ts';
const mapFromDocumentRow = (row: TableRows['documents']): Document => ({
...row,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() || null,
});
export { mapFromDocumentRow };

View File

@@ -1,178 +0,0 @@
import { DatabaseService, tableNames, type TableRows } from '../database/database.ts';
import { SplittingService } from '../splitter/splitter.ts';
import type {
Document,
DocumentFilter,
DocumentFindResult,
DocumentUpsert,
DocumentUpsertResult,
} from './documents.schemas.ts';
import { mapFromDocumentRow } from './documents.mapping.ts';
import { EventEmitter } from '#root/utils/utils.event-emitter.ts';
import type { Services } from '#root/utils/utils.services.ts';
import { compareObjectKeys } from '#root/utils/utils.compare.ts';
import { applyQueryFilter } from '#root/utils/utils.query.ts';
import { QueryParser } from '#root/query-parser/query-parser.ts';
type DocumentsServiceEvents = {
upserted: (document: Document) => void;
inserted: (document: Document) => void;
updated: (document: Document) => void;
};
class DocumentsService extends EventEmitter<DocumentsServiceEvents> {
#services: Services;
constructor(services: Services) {
super();
this.#services = services;
}
public find = async (filter: DocumentFilter): Promise<DocumentFindResult> => {
const databaseService = this.#services.get(DatabaseService);
const db = await databaseService.getInstance();
let query = db<TableRows['documents']>(tableNames.documents);
if (filter) {
const parser = this.#services.get(QueryParser);
query = applyQueryFilter(
query,
typeof filter.condition === 'string' ? parser.parse(filter.condition) : filter.condition,
);
}
query = query.limit(filter.limit).offset(filter.offset);
const items = await query;
return {
items: items.map(mapFromDocumentRow),
};
};
public get = async (id: string): Promise<Document> => {
const databaseService = this.#services.get(DatabaseService);
const db = await databaseService.getInstance();
const [item] = await db<TableRows['documents']>(tableNames.documents).where('id', id).limit(1);
return mapFromDocumentRow(item);
};
public remove = async (id: string): Promise<void> => {
const databaseService = this.#services.get(DatabaseService);
const db = await databaseService.getInstance();
await db<TableRows['documents']>(tableNames.documents).where('id', id).delete();
};
public upsert = async (document: DocumentUpsert): Promise<DocumentUpsertResult> => {
const databaseService = this.#services.get(DatabaseService);
const db = await databaseService.getInstance();
const result = await db.transaction(async (trx) => {
let id = document.id || crypto.randomUUID();
if (document.source && document.sourceId) {
const [currentSourceDocument] = await trx<TableRows['documents']>(tableNames.documents)
.where('source', document.source)
.andWhere('sourceId', document.sourceId)
.limit(1);
if (currentSourceDocument) {
id = currentSourceDocument.id;
}
}
const now = new Date();
const [current] = await trx<TableRows['documents']>(tableNames.documents).where('id', id).limit(1);
if (current) {
if (
compareObjectKeys(current, document, [
'sourceId',
'source',
'content',
'contentType',
'searchText',
'type',
'typeVersion',
'metadata',
])
) {
return {
id,
action: 'skipped',
document: mapFromDocumentRow(current),
} as const;
}
await trx<TableRows['documents']>(tableNames.documents)
.update({
...document,
id,
updatedAt: now,
})
.where('id', id);
const resultDocument: Document = mapFromDocumentRow({
...current,
...document,
id,
});
this.emit('updated', resultDocument);
this.emit('upserted', resultDocument);
return {
id,
action: 'updated',
document: resultDocument,
} as const;
} else {
await trx<TableRows['documents']>(tableNames.documents).insert({
metadata: {},
type: 'raw',
...document,
id,
createdAt: now,
updatedAt: now,
});
const resultDocument: Document = mapFromDocumentRow({
type: 'raw',
owner: null,
contentType: null,
content: null,
source: null,
sourceId: null,
typeVersion: null,
searchText: null,
metadata: {},
...document,
deletedAt: null,
id,
createdAt: now,
updatedAt: now,
});
this.emit('inserted', resultDocument);
this.emit('upserted', resultDocument);
return {
id,
action: 'inserted',
document: resultDocument,
} as const;
}
});
if (result.action !== 'skipped') {
await db.transaction(async (trx) => {
await trx<TableRows['documentChunks']>(tableNames.documentChunks).delete().where('owner', result.id);
const splittingService = this.#services.get(SplittingService);
const chunks = await splittingService.chunk(result.document);
if (chunks.length > 0) {
await trx<TableRows['documentChunks']>(tableNames.documentChunks).insert(
chunks.map((chunk) => ({
id: crypto.randomUUID(),
owner: result.id,
content: chunk.content,
embedding: chunk.vector.toSql(),
embeddingModel: chunk.model,
})),
);
}
});
}
return result;
};
}
export * from './documents.schemas.ts';
export { DocumentsService };

View File

@@ -1,7 +1,19 @@
import { resolve } from 'path';
import { mkdir } from 'fs/promises';
import { setModelLocation } from '@morten-olsen/stash-runtime';
import { createApi } from './api.js';
const server = await createApi();
if (process.env.MODEL_DIR) {
const modelDir = resolve(process.env.MODEL_DIR);
await mkdir(modelDir, { recursive: true });
setModelLocation(modelDir);
}
const server = await createApi();
await server.listen({
port: 3400,
host: process.env.SERVER_HOST,
});
console.log('Server started');

View File

@@ -1,14 +0,0 @@
import deepEqual from 'deep-equal';
const compareObjectKeys = <T extends Record<string, unknown>>(a: T, b: T, keys: (keyof T)[]) => {
for (const key of keys) {
const avalue = a[key];
const bvalue = b[key];
if (!deepEqual(avalue, bvalue)) {
return false;
}
}
return true;
};
export { compareObjectKeys };

View File

@@ -1,549 +0,0 @@
import { type Knex } from 'knex';
import { z } from 'zod';
/**
* Escapes a JSON key for use in PostgreSQL JSON operators.
* Escapes single quotes by doubling them, which is the PostgreSQL standard.
*/
const escapeJsonKey = (key: string): string => {
return key.replace(/'/g, "''");
};
const getFieldSelector = (query: Knex.QueryBuilder, field: string[], tableName?: string) => {
const baseColumn = field[0];
if (field.length === 1) {
return tableName ? `${tableName}.${baseColumn}` : baseColumn;
}
const baseFieldRef = tableName ? query.client.ref(baseColumn).withSchema(tableName) : query.client.ref(baseColumn);
const jsonPath = field.slice(1);
let sqlExpression = baseFieldRef.toString();
for (let i = 0; i < jsonPath.length - 1; i++) {
const escapedKey = escapeJsonKey(jsonPath[i]);
sqlExpression += ` -> '${escapedKey}'`;
}
const finalElement = jsonPath[jsonPath.length - 1];
const escapedFinalKey = escapeJsonKey(finalElement);
sqlExpression += ` ->> '${escapedFinalKey}'`;
return query.client.raw(sqlExpression);
};
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({
example: {
type: 'text',
field: ['metadata', 'foo'],
conditions: {
equal: 'bar',
},
},
examples: [
{
summary: 'Equal condition',
value: {
type: 'text',
field: ['metadata', 'foo'],
conditions: {
equal: 'bar',
},
},
},
{
summary: 'Like condition',
value: {
type: 'text',
field: ['content'],
conditions: {
like: '%cat%',
},
},
},
{
summary: 'In condition',
value: {
type: 'text',
field: ['type'],
conditions: {
in: ['demo', 'article', 'post'],
},
},
},
{
summary: 'Null check',
value: {
type: 'text',
field: ['source'],
conditions: {
equal: null,
},
},
},
],
});
type QueryConditionText = z.infer<typeof queryConditionTextSchema>;
const applyQueryConditionText = (query: Knex.QueryBuilder, { field, tableName, conditions }: QueryConditionText) => {
const selector = getFieldSelector(query, field, tableName);
if (conditions.equal) {
query = query.where(selector, '=', conditions.equal);
}
if (conditions.notEqual) {
query = query.where(selector, '<>', conditions.notEqual);
}
if (conditions.like) {
query = query.whereLike(selector, conditions.like);
}
if (conditions.notLike) {
query = query.not.whereLike(selector, conditions.notLike);
}
if (conditions.equal === null) {
query = query.whereNull(selector);
}
if (conditions.notEqual === null) {
query = query.whereNotNull(selector);
}
if (conditions.in) {
query = query.whereIn(selector, conditions.in);
}
if (conditions.notIn) {
query = query.whereNotIn(selector, conditions.notIn);
}
return query;
};
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({
example: {
type: 'number',
field: ['typeVersion'],
conditions: {
equals: 1,
},
},
examples: [
{
summary: 'Equals condition',
value: {
type: 'number',
field: ['typeVersion'],
conditions: {
equals: 1,
},
},
},
{
summary: 'Greater than condition',
value: {
type: 'number',
field: ['typeVersion'],
conditions: {
greaterThan: 0,
},
},
},
{
summary: 'Range condition',
value: {
type: 'number',
field: ['typeVersion'],
conditions: {
greaterThanOrEqual: 1,
lessThanOrEqual: 10,
},
},
},
{
summary: 'In condition',
value: {
type: 'number',
field: ['typeVersion'],
conditions: {
in: [1, 2, 3],
},
},
},
],
});
type QueryConditionNumber = z.infer<typeof queryConditionNumberSchema>;
const applyQueryConditionNumber = (
query: Knex.QueryBuilder,
{ field, tableName, conditions }: QueryConditionNumber,
) => {
const selector = getFieldSelector(query, field, tableName);
if (conditions.equals !== undefined && conditions.equals !== null) {
query = query.where(selector, '=', conditions.equals);
}
if (conditions.notEquals !== undefined && conditions.notEquals !== null) {
query = query.where(selector, '<>', conditions.notEquals);
}
if (conditions.equals === null) {
query = query.whereNull(selector);
}
if (conditions.notEquals === null) {
query = query.whereNotNull(selector);
}
if (conditions.greaterThan) {
query = query.where(selector, '>', conditions.greaterThan);
}
if (conditions.greaterThanOrEqual) {
query = query.where(selector, '>=', conditions.greaterThanOrEqual);
}
if (conditions.lessThan) {
query = query.where(selector, '<', conditions.lessThan);
}
if (conditions.lessThanOrEqual) {
query = query.where(selector, '<=', conditions.lessThanOrEqual);
}
if (conditions.in) {
query = query.whereIn(selector, conditions.in);
}
if (conditions.notIn) {
query = query.whereNotIn(selector, conditions.notIn);
}
return query;
};
const queryConditionSchema = z.discriminatedUnion('type', [queryConditionTextSchema, queryConditionNumberSchema]);
type QueryCondition = z.infer<typeof queryConditionSchema>;
const applyQueryCondition = (query: Knex.QueryBuilder, options: QueryCondition) => {
switch (options.type) {
case 'text': {
return applyQueryConditionText(query, options);
}
case 'number': {
return applyQueryConditionNumber(query, options);
}
default: {
throw new Error(`Unknown filter type`);
}
}
};
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({
type: z.literal('operator'),
operator: z.enum(['and', 'or']),
conditions: z.array(queryConditionSchema),
})
.meta({
example: {
type: 'operator',
operator: 'and',
conditions: [
{
type: 'text',
field: ['metadata', 'foo'],
conditions: {
equal: 'bar',
},
},
],
},
examples: [
{
summary: 'AND operator',
value: {
type: 'operator',
operator: 'and',
conditions: [
{
type: 'text',
field: ['metadata', 'foo'],
conditions: {
equal: 'bar',
},
},
{
type: 'text',
field: ['type'],
conditions: {
equal: 'demo',
},
},
],
},
},
{
summary: 'OR operator',
value: {
type: 'operator',
operator: 'or',
conditions: [
{
type: 'text',
field: ['metadata', 'foo'],
conditions: {
equal: 'bar',
},
},
{
type: 'text',
field: ['metadata', 'foo'],
conditions: {
equal: 'baz',
},
},
],
},
},
],
}),
]);
const queryFilterSchemaLevel1: z.ZodType<QueryFilter> = z.union([
queryConditionSchema,
z
.object({
type: z.literal('operator'),
operator: z.enum(['and', 'or']),
conditions: z.array(queryFilterSchemaLevel0),
})
.meta({
example: {
type: 'operator',
operator: 'or',
conditions: [
{
type: 'operator',
operator: 'and',
conditions: [
{
type: 'text',
field: ['metadata', 'foo'],
conditions: {
equal: 'bar',
},
},
],
},
{
type: 'text',
field: ['metadata', 'foo'],
conditions: {
equal: 'baz',
},
},
],
},
examples: [
{
summary: 'Nested AND within OR',
value: {
type: 'operator',
operator: 'or',
conditions: [
{
type: 'operator',
operator: 'and',
conditions: [
{
type: 'text',
field: ['metadata', 'foo'],
conditions: {
equal: 'bar',
},
},
{
type: 'text',
field: ['type'],
conditions: {
equal: 'demo',
},
},
],
},
{
type: 'text',
field: ['metadata', 'foo'],
conditions: {
equal: 'baz',
},
},
],
},
},
],
}),
]);
const queryFilterSchemaLevel2: z.ZodType<QueryFilter> = z.union([
queryConditionSchema,
z
.object({
type: z.literal('operator'),
operator: z.enum(['and', 'or']),
conditions: z.array(queryFilterSchemaLevel1),
})
.meta({
example: {
type: 'operator',
operator: 'and',
conditions: [
{
type: 'operator',
operator: 'or',
conditions: [
{
type: 'text',
field: ['metadata', 'foo'],
conditions: {
equal: 'bar',
},
},
{
type: 'text',
field: ['metadata', 'foo'],
conditions: {
equal: 'baz',
},
},
],
},
{
type: 'text',
field: ['type'],
conditions: {
equal: 'demo',
},
},
],
},
examples: [
{
summary: 'Complex nested query',
value: {
type: 'operator',
operator: 'and',
conditions: [
{
type: 'operator',
operator: 'or',
conditions: [
{
type: 'text',
field: ['metadata', 'foo'],
conditions: {
equal: 'bar',
},
},
{
type: 'text',
field: ['metadata', 'foo'],
conditions: {
equal: 'baz',
},
},
],
},
{
type: 'text',
field: ['type'],
conditions: {
equal: 'demo',
},
},
],
},
},
],
}),
]);
// Export the depth-limited schema (supports 3 levels of nesting)
// This works with OpenAPI schema generation
const queryFilterSchema = queryFilterSchemaLevel2;
const applyQueryFilter = (query: Knex.QueryBuilder, filter: QueryFilter) => {
if (filter.type === 'operator') {
if (filter.conditions.length === 0) {
return query;
}
switch (filter.operator) {
case 'or': {
return query.where((subquery) => {
let isFirst = true;
for (const condition of filter.conditions) {
if (isFirst) {
applyQueryFilter(subquery, condition);
isFirst = false;
} else {
subquery.orWhere((subSubquery) => {
applyQueryFilter(subSubquery, condition);
});
}
}
});
}
case 'and': {
return query.where((subquery) => {
let isFirst = true;
for (const condition of filter.conditions) {
if (isFirst) {
applyQueryFilter(subquery, condition);
isFirst = false;
} else {
subquery.andWhere((subSubquery) => {
applyQueryFilter(subSubquery, condition);
});
}
}
});
}
}
} else {
return applyQueryCondition(query, filter);
}
};
export type { QueryConditionText, QueryConditionNumber, QueryOperator, QueryCondition, QueryFilter };
export { applyQueryCondition, queryConditionSchema, queryFilterSchema, applyQueryFilter };

View File

@@ -1,8 +0,0 @@
import { z, type ZodType } from 'zod';
const createListResultSchema = <T extends ZodType>(schema: T) =>
z.object({
items: z.array(schema),
});
export { createListResultSchema };

View File

@@ -1,6 +1,7 @@
{
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"paths": {
"#root/*": [
"./src/*"

355
pnpm-lock.yaml generated
View File

@@ -48,43 +48,84 @@ importers:
specifier: 4.0.15
version: 4.0.15(@types/node@24.10.2)(tsx@4.21.0)(yaml@2.8.2)
packages/client:
dependencies:
openapi-fetch:
specifier: ^0.15.0
version: 0.15.0
devDependencies:
'@morten-olsen/stash-configs':
specifier: workspace:*
version: link:../configs
'@morten-olsen/stash-tests':
specifier: workspace:*
version: link:../tests
'@types/node':
specifier: 24.10.2
version: 24.10.2
'@vitest/coverage-v8':
specifier: 4.0.15
version: 4.0.15(vitest@4.0.15(@types/node@24.10.2)(tsx@4.21.0)(yaml@2.8.2))
openapi-typescript:
specifier: ^7.10.1
version: 7.10.1(typescript@5.9.3)
typescript:
specifier: 5.9.3
version: 5.9.3
vitest:
specifier: 4.0.15
version: 4.0.15(@types/node@24.10.2)(tsx@4.21.0)(yaml@2.8.2)
packages/configs: {}
packages/server:
packages/query-dsl:
dependencies:
chevrotain:
specifier: ^11.0.3
version: 11.0.3
zod:
specifier: 4.1.13
version: 4.1.13
devDependencies:
'@morten-olsen/stash-configs':
specifier: workspace:*
version: link:../configs
'@morten-olsen/stash-tests':
specifier: workspace:*
version: link:../tests
'@types/node':
specifier: 24.10.2
version: 24.10.2
'@vitest/coverage-v8':
specifier: 4.0.15
version: 4.0.15(vitest@4.0.15(@types/node@24.10.2)(tsx@4.21.0)(yaml@2.8.2))
typescript:
specifier: 5.9.3
version: 5.9.3
vitest:
specifier: 4.0.15
version: 4.0.15(@types/node@24.10.2)(tsx@4.21.0)(yaml@2.8.2)
packages/runtime:
dependencies:
'@electric-sql/pglite':
specifier: ^0.3.14
version: 0.3.14
'@fastify/cors':
specifier: 11.1.0
version: 11.1.0
'@fastify/swagger':
specifier: 9.6.1
version: 9.6.1
'@fastify/websocket':
specifier: 11.2.0
version: 11.2.0
'@huggingface/transformers':
specifier: ^3.8.1
version: 3.8.1
'@langchain/textsplitters':
specifier: ^1.0.1
version: 1.0.1(@langchain/core@1.1.4)
'@scalar/fastify-api-reference':
specifier: 1.40.2
version: 1.40.2
'@morten-olsen/stash-query-dsl':
specifier: workspace:*
version: link:../query-dsl
better-sqlite3:
specifier: ^12.5.0
version: 12.5.0
deep-equal:
specifier: ^2.2.3
version: 2.2.3
fastify:
specifier: 5.6.2
version: 5.6.2
fastify-type-provider-zod:
specifier: 6.1.0
version: 6.1.0(@fastify/swagger@9.6.1)(fastify@5.6.2)(openapi-types@12.1.3)(zod@4.1.13)
knex:
specifier: ^3.1.0
version: 3.1.0(better-sqlite3@12.5.0)(pg@8.16.3)
@@ -100,6 +141,58 @@ importers:
zod:
specifier: 4.1.13
version: 4.1.13
devDependencies:
'@morten-olsen/stash-configs':
specifier: workspace:*
version: link:../configs
'@morten-olsen/stash-tests':
specifier: workspace:*
version: link:../tests
'@types/deep-equal':
specifier: ^1.0.4
version: 1.0.4
'@types/node':
specifier: 24.10.2
version: 24.10.2
'@vitest/coverage-v8':
specifier: 4.0.15
version: 4.0.15(vitest@4.0.15(@types/node@24.10.2)(tsx@4.21.0)(yaml@2.8.2))
typescript:
specifier: 5.9.3
version: 5.9.3
vitest:
specifier: 4.0.15
version: 4.0.15(@types/node@24.10.2)(tsx@4.21.0)(yaml@2.8.2)
packages/server:
dependencies:
'@fastify/cors':
specifier: 11.1.0
version: 11.1.0
'@fastify/swagger':
specifier: 9.6.1
version: 9.6.1
'@fastify/websocket':
specifier: 11.2.0
version: 11.2.0
'@morten-olsen/stash-query-dsl':
specifier: workspace:*
version: link:../query-dsl
'@morten-olsen/stash-runtime':
specifier: workspace:*
version: link:../runtime
'@scalar/fastify-api-reference':
specifier: 1.40.2
version: 1.40.2
fastify:
specifier: 5.6.2
version: 5.6.2
fastify-type-provider-zod:
specifier: 6.1.0
version: 6.1.0(@fastify/swagger@9.6.1)(fastify@5.6.2)(openapi-types@12.1.3)(zod@4.1.13)
zod:
specifier: 4.1.13
version: 4.1.13
zod-to-json-schema:
specifier: 3.25.0
version: 3.25.0(zod@4.1.13)
@@ -181,6 +274,21 @@ packages:
'@cfworker/json-schema@4.1.1':
resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==}
'@chevrotain/cst-dts-gen@11.0.3':
resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==}
'@chevrotain/gast@11.0.3':
resolution: {integrity: sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==}
'@chevrotain/regexp-to-ast@11.0.3':
resolution: {integrity: sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==}
'@chevrotain/types@11.0.3':
resolution: {integrity: sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==}
'@chevrotain/utils@11.0.3':
resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==}
'@electric-sql/pglite@0.3.14':
resolution: {integrity: sha512-3DB258dhqdsArOI1fIt7cb9RpUOgcDg5hXWVgVHAeqVQ/qxtFy605QKs4gx6mFq3jWsSPqDN8TgSEsqC3OfV9Q==}
@@ -942,6 +1050,16 @@ packages:
'@protobufjs/utf8@1.1.0':
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
'@redocly/ajv@8.17.1':
resolution: {integrity: sha512-EDtsGZS964mf9zAUXAl9Ew16eYbeyAFWhsPr0fX6oaJxgd8rApYlPBf0joyhnUHz88WxrigyFtTaqqzXNzPgqw==}
'@redocly/config@0.22.2':
resolution: {integrity: sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==}
'@redocly/openapi-core@1.34.6':
resolution: {integrity: sha512-2+O+riuIUgVSuLl3Lyh5AplWZyVMNuG2F98/o6NrutKJfW4/GTZdPpZlIphS0HGgcOHgmWcCSHj+dWFlZaGSHw==}
engines: {node: '>=18.17.0', npm: '>=9.5.0'}
'@rollup/rollup-android-arm-eabi@4.53.3':
resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==}
cpu: [arm]
@@ -1232,6 +1350,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
ajv-draft-04@1.0.0:
resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==}
peerDependencies:
@@ -1257,6 +1379,10 @@ packages:
ansi-align@3.0.1:
resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==}
ansi-colors@4.1.3:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'}
ansi-diff@1.2.0:
resolution: {integrity: sha512-BIXwHKpjzghBjcwEV10Y4b17tjHfK4nhEqK3LqyQ3JgcMcjmi3DIevozNgrOpfvBMmrq9dfvrPJSu5/5vNUBQg==}
@@ -1418,10 +1544,16 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
change-case@5.4.4:
resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==}
char-regex@1.0.2:
resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
engines: {node: '>=10'}
chevrotain@11.0.3:
resolution: {integrity: sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==}
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
@@ -1448,6 +1580,9 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
colorette@1.4.0:
resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==}
colorette@2.0.19:
resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==}
@@ -1964,6 +2099,10 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
@@ -1987,6 +2126,10 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
index-to-position@1.2.0:
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
engines: {node: '>=18'}
individual@3.0.0:
resolution: {integrity: sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g==}
@@ -2161,6 +2304,10 @@ packages:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
js-levenshtein@1.1.6:
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
engines: {node: '>=0.10.0'}
js-tiktoken@1.0.21:
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
@@ -2288,6 +2435,9 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -2353,6 +2503,10 @@ packages:
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
minimatch@5.1.6:
resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==}
engines: {node: '>=10'}
minimatch@9.0.5:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -2472,9 +2626,21 @@ packages:
onnxruntime-web@1.22.0-dev.20250409-89f8206ba4:
resolution: {integrity: sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==}
openapi-fetch@0.15.0:
resolution: {integrity: sha512-OjQUdi61WO4HYhr9+byCPMj0+bgste/LtSBEcV6FzDdONTs7x0fWn8/ndoYwzqCsKWIxEZwo4FN/TG1c1rI8IQ==}
openapi-types@12.1.3:
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
openapi-typescript-helpers@0.0.15:
resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==}
openapi-typescript@7.10.1:
resolution: {integrity: sha512-rBcU8bjKGGZQT4K2ekSTY2Q5veOQbVG/lTKZ49DeCyT9z62hM2Vj/LLHjDHC9W7LJG8YMHcdXpRZDqC1ojB/lw==}
hasBin: true
peerDependencies:
typescript: ^5.x
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -2523,6 +2689,10 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
parse-json@8.3.0:
resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==}
engines: {node: '>=18'}
parse-ms@2.1.0:
resolution: {integrity: sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==}
engines: {node: '>=6'}
@@ -2617,6 +2787,10 @@ packages:
platform@1.3.6:
resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==}
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -2985,6 +3159,10 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -3119,6 +3297,10 @@ packages:
resolution: {integrity: sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==}
engines: {node: '>=8'}
type-fest@4.41.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
type-fest@5.0.0:
resolution: {integrity: sha512-GeJop7+u7BYlQ6yQCAY1nBQiRSHR+6OdCEtd8Bwp9a3NK3+fWAVjOaPKJDteB9f6cIJ0wt4IfnScjLG450EpXA==}
engines: {node: '>=20'}
@@ -3323,6 +3505,9 @@ packages:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
yaml-ast-parser@0.0.43:
resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==}
yaml@2.8.0:
resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==}
engines: {node: '>= 14.6'}
@@ -3333,6 +3518,10 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -3373,6 +3562,23 @@ snapshots:
'@cfworker/json-schema@4.1.1': {}
'@chevrotain/cst-dts-gen@11.0.3':
dependencies:
'@chevrotain/gast': 11.0.3
'@chevrotain/types': 11.0.3
lodash-es: 4.17.21
'@chevrotain/gast@11.0.3':
dependencies:
'@chevrotain/types': 11.0.3
lodash-es: 4.17.21
'@chevrotain/regexp-to-ast@11.0.3': {}
'@chevrotain/types@11.0.3': {}
'@chevrotain/utils@11.0.3': {}
'@electric-sql/pglite@0.3.14': {}
'@emnapi/runtime@1.7.1':
@@ -3546,7 +3752,7 @@ snapshots:
'@eslint/config-array@0.21.1':
dependencies:
'@eslint/object-schema': 2.1.7
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
minimatch: 3.1.2
transitivePeerDependencies:
- supports-color
@@ -3562,7 +3768,7 @@ snapshots:
'@eslint/eslintrc@3.3.3':
dependencies:
ajv: 6.12.6
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
@@ -4056,6 +4262,29 @@ snapshots:
'@protobufjs/utf8@1.1.0': {}
'@redocly/ajv@8.17.1':
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.0
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
'@redocly/config@0.22.2': {}
'@redocly/openapi-core@1.34.6(supports-color@10.2.2)':
dependencies:
'@redocly/ajv': 8.17.1
'@redocly/config': 0.22.2
colorette: 1.4.0
https-proxy-agent: 7.0.6(supports-color@10.2.2)
js-levenshtein: 1.1.6
js-yaml: 4.1.1
minimatch: 5.1.6
pluralize: 8.0.0
yaml-ast-parser: 0.0.43
transitivePeerDependencies:
- supports-color
'@rollup/rollup-android-arm-eabi@4.53.3':
optional: true
@@ -4218,7 +4447,7 @@ snapshots:
'@typescript-eslint/types': 8.49.0
'@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.49.0
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
eslint: 9.39.1
typescript: 5.9.3
transitivePeerDependencies:
@@ -4228,7 +4457,7 @@ snapshots:
dependencies:
'@typescript-eslint/tsconfig-utils': 8.49.0(typescript@5.9.3)
'@typescript-eslint/types': 8.49.0
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -4247,7 +4476,7 @@ snapshots:
'@typescript-eslint/types': 8.49.0
'@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.49.0(eslint@9.39.1)(typescript@5.9.3)
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
eslint: 9.39.1
ts-api-utils: 2.1.0(typescript@5.9.3)
typescript: 5.9.3
@@ -4262,7 +4491,7 @@ snapshots:
'@typescript-eslint/tsconfig-utils': 8.49.0(typescript@5.9.3)
'@typescript-eslint/types': 8.49.0
'@typescript-eslint/visitor-keys': 8.49.0
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
minimatch: 9.0.5
semver: 7.7.3
tinyglobby: 0.2.15
@@ -4355,6 +4584,8 @@ snapshots:
acorn@8.15.0: {}
agent-base@7.1.4: {}
ajv-draft-04@1.0.0(ajv@8.17.1):
optionalDependencies:
ajv: 8.17.1
@@ -4381,6 +4612,8 @@ snapshots:
dependencies:
string-width: 4.2.3
ansi-colors@4.1.3: {}
ansi-diff@1.2.0:
dependencies:
ansi-split: 1.0.1
@@ -4578,8 +4811,19 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
change-case@5.4.4: {}
char-regex@1.0.2: {}
chevrotain@11.0.3:
dependencies:
'@chevrotain/cst-dts-gen': 11.0.3
'@chevrotain/gast': 11.0.3
'@chevrotain/regexp-to-ast': 11.0.3
'@chevrotain/types': 11.0.3
'@chevrotain/utils': 11.0.3
lodash-es: 4.17.21
chownr@1.1.4: {}
chownr@3.0.0: {}
@@ -4599,6 +4843,8 @@ snapshots:
color-name@1.1.4: {}
colorette@1.4.0: {}
colorette@2.0.19: {}
commander@10.0.1: {}
@@ -4652,9 +4898,11 @@ snapshots:
dependencies:
ms: 2.1.2
debug@4.4.3:
debug@4.4.3(supports-color@10.2.2):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 10.2.2
decamelize@1.2.0: {}
@@ -4982,7 +5230,7 @@ snapshots:
ajv: 6.12.6
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@@ -5272,6 +5520,13 @@ snapshots:
html-escaper@2.0.2: {}
https-proxy-agent@7.0.6(supports-color@10.2.2):
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
human-signals@2.1.0: {}
ieee754@1.2.1: {}
@@ -5287,6 +5542,8 @@ snapshots:
imurmurhash@0.1.4: {}
index-to-position@1.2.0: {}
individual@3.0.0: {}
inherits@2.0.4: {}
@@ -5447,7 +5704,7 @@ snapshots:
istanbul-lib-source-maps@5.0.6:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
istanbul-lib-coverage: 3.2.2
transitivePeerDependencies:
- supports-color
@@ -5457,6 +5714,8 @@ snapshots:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
js-levenshtein@1.1.6: {}
js-tiktoken@1.0.21:
dependencies:
base64-js: 1.5.1
@@ -5479,7 +5738,7 @@ snapshots:
json-schema-resolver@3.0.0:
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
fast-uri: 3.1.0
rfdc: 1.4.1
transitivePeerDependencies:
@@ -5567,6 +5826,8 @@ snapshots:
dependencies:
p-locate: 5.0.0
lodash-es@4.17.21: {}
lodash.merge@4.6.2: {}
lodash@4.17.21: {}
@@ -5623,6 +5884,10 @@ snapshots:
dependencies:
brace-expansion: 1.1.12
minimatch@5.1.6:
dependencies:
brace-expansion: 2.0.2
minimatch@9.0.5:
dependencies:
brace-expansion: 2.0.2
@@ -5740,8 +6005,24 @@ snapshots:
platform: 1.3.6
protobufjs: 7.5.4
openapi-fetch@0.15.0:
dependencies:
openapi-typescript-helpers: 0.0.15
openapi-types@12.1.3: {}
openapi-typescript-helpers@0.0.15: {}
openapi-typescript@7.10.1(typescript@5.9.3):
dependencies:
'@redocly/openapi-core': 1.34.6(supports-color@10.2.2)
ansi-colors: 4.1.3
change-case: 5.4.4
parse-json: 8.3.0
supports-color: 10.2.2
typescript: 5.9.3
yargs-parser: 21.1.1
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -5795,6 +6076,12 @@ snapshots:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
parse-json@8.3.0:
dependencies:
'@babel/code-frame': 7.27.1
index-to-position: 1.2.0
type-fest: 4.41.0
parse-ms@2.1.0: {}
path-absolute@1.0.1: {}
@@ -5880,6 +6167,8 @@ snapshots:
platform@1.3.6: {}
pluralize@8.0.0: {}
possible-typed-array-names@1.1.0: {}
postcss@8.5.6:
@@ -6321,6 +6610,8 @@ snapshots:
strip-json-comments@3.1.1: {}
supports-color@10.2.2: {}
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -6446,6 +6737,8 @@ snapshots:
type-fest@0.6.0: {}
type-fest@4.41.0: {}
type-fest@5.0.0:
dependencies:
tagged-tag: 1.0.0
@@ -6656,10 +6949,14 @@ snapshots:
yallist@5.0.0: {}
yaml-ast-parser@0.0.43: {}
yaml@2.8.0: {}
yaml@2.8.2: {}
yargs-parser@21.1.1: {}
yocto-queue@0.1.0: {}
zod-to-json-schema@3.25.0(zod@4.1.13):

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"include": [],
"references": [
{
"path": "./packages/query-dsl/tsconfig.json"
},
{
"path": "./packages/runtime/tsconfig.json"
},
{
"path": "./packages/server/tsconfig.json"
},
{
"path": "./packages/client/tsconfig.json"
}
]
}

View File

@@ -1,8 +1,8 @@
import { defineConfig, type UserConfigExport } from 'vitest/config';
import { defineConfig, type ViteUserConfigExport } from 'vitest/config';
// eslint-disable-next-line import/no-default-export
export default defineConfig(async () => {
const config: UserConfigExport = {
const config: ViteUserConfigExport = {
test: {
coverage: {
provider: 'v8',