akanjs 2.4.1 → 2.4.2-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/csrTypes.ts +7 -0
- package/client/frameConfig.ts +6 -1
- package/client/rscNavigation.ts +9 -0
- package/common/index.ts +5 -0
- package/common/websocketAuth.ts +24 -0
- package/constant/fieldInfo.ts +4 -3
- package/constant/index.ts +2 -0
- package/constant/textFieldPathSet.ts +8 -0
- package/constant/textFieldPaths.ts +59 -0
- package/constant/types.ts +0 -4
- package/constant/via.ts +13 -28
- package/dictionary/dictInfo.ts +4 -0
- package/document/documentQuery.ts +17 -1
- package/document/documentSchema.ts +1 -15
- package/document/filterMeta.ts +4 -2
- package/fetch/client/fetchClient.ts +1 -0
- package/fetch/client/wsClient.ts +28 -1
- package/index.ts +6 -0
- package/local/apps/serverLifecycle/serverLifecycle-local.db +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local.db-wal +0 -0
- package/package.json +1 -1
- package/server/akanServer.ts +5 -4
- package/server/devtools/types.ts +2 -2
- package/server/resolver/database.resolver.ts +5 -6
- package/server/resolver/signal.resolver.ts +23 -0
- package/server/routing/apiRouter.ts +11 -3
- package/server/routing/appWsData.ts +50 -0
- package/server/rscClient.tsx +9 -0
- package/service/predefinedAdaptor/database.adaptor.ts +212 -88
- package/service/predefinedAdaptor/index.ts +1 -0
- package/service/predefinedAdaptor/searchIndex.ts +517 -0
- package/service/predefinedAdaptor/sqlDescriptor.ts +25 -0
- package/signal/signalContext.ts +37 -12
- package/signal/types.ts +2 -1
- package/types/client/csrTypes.d.ts +7 -0
- package/types/client/rscNavigation.d.ts +8 -0
- package/types/common/index.d.ts +1 -0
- package/types/common/websocketAuth.d.ts +19 -0
- package/types/constant/fieldInfo.d.ts +4 -3
- package/types/constant/index.d.ts +2 -0
- package/types/constant/textFieldPathSet.d.ts +8 -0
- package/types/constant/textFieldPaths.d.ts +10 -0
- package/types/constant/types.d.ts +0 -3
- package/types/constant/via.d.ts +4 -24
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/document/documentQuery.d.ts +13 -1
- package/types/document/documentSchema.d.ts +0 -3
- package/types/document/filterMeta.d.ts +2 -1
- package/types/fetch/client/wsClient.d.ts +6 -0
- package/types/index.d.ts +6 -0
- package/types/server/devtools/types.d.ts +2 -2
- package/types/server/resolver/signal.resolver.d.ts +6 -0
- package/types/server/routing/apiRouter.d.ts +3 -4
- package/types/server/routing/appWsData.d.ts +24 -0
- package/types/server/rscClient.d.ts +1 -0
- package/types/service/predefinedAdaptor/database.adaptor.d.ts +46 -13
- package/types/service/predefinedAdaptor/index.d.ts +1 -0
- package/types/service/predefinedAdaptor/searchIndex.d.ts +73 -0
- package/types/service/predefinedAdaptor/sqlDescriptor.d.ts +5 -0
- package/types/signal/signalContext.d.ts +7 -0
- package/types/signal/types.d.ts +5 -1
- package/types/ui/Constant/schemaDoc.d.ts +2 -2
- package/ui/Constant/schemaDoc.ts +8 -2
- package/ui/Model/EditModal.tsx +24 -6
|
@@ -22,9 +22,25 @@ import {
|
|
|
22
22
|
resolveDocumentUpdate,
|
|
23
23
|
type SchemaOf,
|
|
24
24
|
sanitizeJson,
|
|
25
|
+
searchColumns,
|
|
25
26
|
} from "akanjs/document";
|
|
26
27
|
import type { Sql } from "postgres";
|
|
27
28
|
import { adapt } from "../adapt";
|
|
29
|
+
import { ScheduleAdaptorRole } from "./role.adaptor";
|
|
30
|
+
import {
|
|
31
|
+
DEFAULT_SEARCH_WEIGHTS,
|
|
32
|
+
DEFAULT_TOKENIZER,
|
|
33
|
+
DOC_TABLE,
|
|
34
|
+
FTS_TABLE,
|
|
35
|
+
OPTIMIZE_CRON,
|
|
36
|
+
OPTIMIZE_CRON_KEY,
|
|
37
|
+
parseSearchEnabled,
|
|
38
|
+
RETRY_INTERVAL_KEY,
|
|
39
|
+
RETRY_INTERVAL_MS,
|
|
40
|
+
SearchIndex,
|
|
41
|
+
toMatchExpression,
|
|
42
|
+
} from "./searchIndex";
|
|
43
|
+
import { descriptorHash, jsonPath, quoteIdent, stableJson } from "./sqlDescriptor";
|
|
28
44
|
import { resolveDefaultSqliteFile } from "./sqlitePath";
|
|
29
45
|
|
|
30
46
|
export interface SqliteDatabaseConfig {
|
|
@@ -51,11 +67,17 @@ export interface PostgresDatabaseConfig {
|
|
|
51
67
|
password?: string;
|
|
52
68
|
}
|
|
53
69
|
|
|
70
|
+
export interface SearchConfig {
|
|
71
|
+
enabled?: boolean;
|
|
72
|
+
tokenizer?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
54
75
|
export interface DatabaseConfig {
|
|
55
76
|
driver?: "sqlite" | "libsql" | "postgres";
|
|
56
77
|
sqlite?: SqliteDatabaseConfig;
|
|
57
78
|
libsql?: LibsqlDatabaseConfig;
|
|
58
79
|
postgres?: PostgresDatabaseConfig;
|
|
80
|
+
search?: SearchConfig;
|
|
59
81
|
}
|
|
60
82
|
|
|
61
83
|
export interface DocumentStore {
|
|
@@ -117,6 +139,8 @@ export interface DatabaseAdaptor {
|
|
|
117
139
|
getConnection(): AkanSqlClient;
|
|
118
140
|
getStore(constant: ConstantModel, database: DatabaseModel, schema: DocumentSchema): DocumentStore;
|
|
119
141
|
transaction<T>(fn: () => PromiseOrObject<T>): Promise<T>;
|
|
142
|
+
|
|
143
|
+
getSearchIndex(): SearchIndex | null;
|
|
120
144
|
}
|
|
121
145
|
|
|
122
146
|
interface SqliteEnv extends BaseEnv {
|
|
@@ -129,7 +153,7 @@ interface TransactionContext {
|
|
|
129
153
|
}
|
|
130
154
|
|
|
131
155
|
const BASE_COLUMNS = new Set(["id", "createdAt", "updatedAt", "removedAt"]);
|
|
132
|
-
const RESERVED_RE = /^sqlite_|^_akan_meta$/i;
|
|
156
|
+
const RESERVED_RE = /^sqlite_|^_akan_meta$|^search_doc$|^search_fts$/i;
|
|
133
157
|
const REF_NAME_RE = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
134
158
|
const toSafeRefName = (value: string) => value.replace(/[^A-Za-z0-9_]+/g, "_").replace(/_+/g, "_");
|
|
135
159
|
type DocumentRecord = Record<string, unknown>;
|
|
@@ -146,10 +170,7 @@ type FindManyOptions = {
|
|
|
146
170
|
};
|
|
147
171
|
type FindOneOptions = { sort?: SortOption; skip?: number | null; sample?: boolean; select?: ProjectionOption };
|
|
148
172
|
type WriteHookOptions = { runSaveHooks?: boolean; crudType?: "update" | "remove" };
|
|
149
|
-
type QueryOperatorName =
|
|
150
|
-
DocumentQueryNode,
|
|
151
|
-
{ kind: "all" } | { kind: "any" } | { kind: "not" } | { kind: "raw" }
|
|
152
|
-
>["op"];
|
|
173
|
+
type QueryOperatorName = Extract<DocumentQueryNode, { kind: "op" }>["op"];
|
|
153
174
|
interface SqliteDocumentRow {
|
|
154
175
|
id: string;
|
|
155
176
|
createdAt: number | string;
|
|
@@ -161,6 +182,7 @@ type ProjectedSqliteDocumentRow = Omit<SqliteDocumentRow, "_doc"> & Record<strin
|
|
|
161
182
|
|
|
162
183
|
interface DocumentDatabaseOwner {
|
|
163
184
|
getConnection(): AkanSqlClient;
|
|
185
|
+
getSearchIndex(): SearchIndex | null;
|
|
164
186
|
getMeta(key: string): Promise<string | undefined> | string | undefined;
|
|
165
187
|
setMeta(key: string, value: string): Promise<void>;
|
|
166
188
|
afterCommit(fn: () => PromiseOrObject<void>): Promise<void>;
|
|
@@ -259,7 +281,6 @@ class PostgresAkanClient implements AkanSqlClient {
|
|
|
259
281
|
}
|
|
260
282
|
}
|
|
261
283
|
|
|
262
|
-
const quoteIdent = (identifier: string) => `"${identifier.replaceAll('"', '""')}"`;
|
|
263
284
|
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
264
285
|
!!value && typeof value === "object" && !Array.isArray(value) && !(value instanceof Buffer);
|
|
265
286
|
const toLibsqlValue = (value: unknown): InValue => {
|
|
@@ -300,11 +321,6 @@ const toPostgresSql = (sql: string, params: unknown[]) => {
|
|
|
300
321
|
params,
|
|
301
322
|
};
|
|
302
323
|
};
|
|
303
|
-
const jsonPath = (path: string) =>
|
|
304
|
-
`$.${path
|
|
305
|
-
.split(".")
|
|
306
|
-
.map((part) => part.replaceAll('"', '\\"'))
|
|
307
|
-
.join(".")}`;
|
|
308
324
|
const encodeSqlValue = (value: unknown) => encodeDocumentValue(value);
|
|
309
325
|
|
|
310
326
|
const decodeDateValue = (value: unknown) => {
|
|
@@ -330,28 +346,33 @@ const QUERY_OPERATOR_KEYS = new Set([
|
|
|
330
346
|
"contains",
|
|
331
347
|
]);
|
|
332
348
|
|
|
333
|
-
const stableJson = (value: unknown): string => {
|
|
334
|
-
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
335
|
-
if (value && typeof value === "object") {
|
|
336
|
-
return `{${Object.entries(value as Record<string, unknown>)
|
|
337
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
338
|
-
.map(([key, val]) => `${JSON.stringify(key)}:${stableJson(val)}`)
|
|
339
|
-
.join(",")}}`;
|
|
340
|
-
}
|
|
341
|
-
return JSON.stringify(value);
|
|
342
|
-
};
|
|
343
|
-
|
|
344
|
-
const descriptorHash = async (value: unknown) => {
|
|
345
|
-
const bytes = new TextEncoder().encode(stableJson(value));
|
|
346
|
-
const hash = await crypto.subtle.digest("SHA-256", bytes);
|
|
347
|
-
return [...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
348
|
-
};
|
|
349
|
-
|
|
350
349
|
interface SqlFrag {
|
|
351
350
|
sql: string;
|
|
352
351
|
params: unknown[];
|
|
353
352
|
}
|
|
354
353
|
|
|
354
|
+
interface SearchJoinProps {
|
|
355
|
+
alias: string;
|
|
356
|
+
ref: string;
|
|
357
|
+
match: string;
|
|
358
|
+
weights: number[];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
interface SearchJoin extends SqlFrag {
|
|
362
|
+
alias: string;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
interface CompiledQuery {
|
|
366
|
+
where: string;
|
|
367
|
+
params: unknown[];
|
|
368
|
+
joins: SearchJoin[];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
interface CompileContext {
|
|
372
|
+
joins: SearchJoin[];
|
|
373
|
+
conjunctive: boolean;
|
|
374
|
+
}
|
|
375
|
+
|
|
355
376
|
interface SqlDialect {
|
|
356
377
|
readonly name: "sqlite" | "postgres";
|
|
357
378
|
timestampType(): string;
|
|
@@ -370,6 +391,7 @@ interface SqlDialect {
|
|
|
370
391
|
empty(path: string): SqlFrag;
|
|
371
392
|
arrayHas(path: string, value: unknown): SqlFrag;
|
|
372
393
|
contains(path: string, value: unknown): SqlFrag;
|
|
394
|
+
searchJoin(props: SearchJoinProps): SqlFrag;
|
|
373
395
|
applyUpdate(acc: string, op: DocumentUpdateOperator, path: string, value: unknown): SqlFrag;
|
|
374
396
|
affectedRows(result: unknown): number;
|
|
375
397
|
}
|
|
@@ -447,6 +469,16 @@ export class SqliteDialect implements SqlDialect {
|
|
|
447
469
|
contains(path: string, value: unknown): SqlFrag {
|
|
448
470
|
return { sql: `${this.extract(path)} LIKE ?`, params: [`%${String(value)}%`] };
|
|
449
471
|
}
|
|
472
|
+
searchJoin({ alias, ref, match, weights }: SearchJoinProps): SqlFrag {
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
sql:
|
|
476
|
+
`JOIN (SELECT d."refId" AS rid, bm25(${FTS_TABLE}, ${weights.join(", ")}) AS score ` +
|
|
477
|
+
`FROM ${FTS_TABLE} JOIN ${DOC_TABLE} d ON d."fid" = ${FTS_TABLE}."rowid" ` +
|
|
478
|
+
`WHERE ${FTS_TABLE} MATCH ? AND d."ref" = ?) ${alias} ON ${alias}."rid" = ${quoteIdent(ref)}."id"`,
|
|
479
|
+
params: [match, ref],
|
|
480
|
+
};
|
|
481
|
+
}
|
|
450
482
|
applyUpdate(acc: string, op: DocumentUpdateOperator, path: string, value: unknown): SqlFrag {
|
|
451
483
|
const p = this.#path(path);
|
|
452
484
|
|
|
@@ -564,6 +596,12 @@ export class PostgresDialect implements SqlDialect {
|
|
|
564
596
|
contains(path: string, value: unknown): SqlFrag {
|
|
565
597
|
return { sql: `${this.#text(path)} LIKE ?`, params: [`%${String(value)}%`] };
|
|
566
598
|
}
|
|
599
|
+
searchJoin({ ref }: SearchJoinProps): SqlFrag {
|
|
600
|
+
|
|
601
|
+
throw new Error(
|
|
602
|
+
`Text search on "${ref}" requires the sqlite or libsql database; Postgres has no fts5 index to join.`,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
567
605
|
applyUpdate(acc: string, op: DocumentUpdateOperator, path: string, value: unknown): SqlFrag {
|
|
568
606
|
const p = this.#path(path);
|
|
569
607
|
|
|
@@ -676,18 +714,21 @@ class QueryCompiler {
|
|
|
676
714
|
constructor(
|
|
677
715
|
private readonly fields: FieldMap,
|
|
678
716
|
private readonly dialect: SqlDialect,
|
|
717
|
+
private readonly ref: string,
|
|
718
|
+
private readonly searchEnabled: () => boolean,
|
|
679
719
|
) {}
|
|
680
720
|
|
|
681
721
|
#leaf(path: string): QueryLeafOps {
|
|
682
722
|
return BASE_COLUMNS.has(path) ? BASE_COLUMN_LEAF : this.dialect;
|
|
683
723
|
}
|
|
684
724
|
|
|
685
|
-
compile(query?: DocumentQuery):
|
|
725
|
+
compile(query?: DocumentQuery): CompiledQuery {
|
|
726
|
+
const joins: SearchJoin[] = [];
|
|
686
727
|
if (!query || (typeof query === "object" && !Array.isArray(query) && Object.keys(query).length === 0)) {
|
|
687
|
-
return { where: "1 = 1", params: [] };
|
|
728
|
+
return { where: "1 = 1", params: [], joins };
|
|
688
729
|
}
|
|
689
|
-
const compiled = this.compileNode(query);
|
|
690
|
-
return { where: compiled.sql || "1 = 1", params: compiled.params };
|
|
730
|
+
const compiled = this.compileNode(query, { joins, conjunctive: true });
|
|
731
|
+
return { where: compiled.sql || "1 = 1", params: compiled.params, joins };
|
|
691
732
|
}
|
|
692
733
|
|
|
693
734
|
orderBy(sort: Record<string, 1 | -1> = { createdAt: -1 }) {
|
|
@@ -701,10 +742,20 @@ class QueryCompiler {
|
|
|
701
742
|
return BASE_COLUMNS.has(path) ? quoteIdent(path) : this.dialect.extract(path);
|
|
702
743
|
}
|
|
703
744
|
|
|
704
|
-
private compileNode(query: DocumentQuery): { sql: string; params: unknown[] } {
|
|
745
|
+
private compileNode(query: DocumentQuery, ctx: CompileContext): { sql: string; params: unknown[] } {
|
|
705
746
|
if (this.isQueryNode(query)) {
|
|
747
|
+
if (query.kind === "search") {
|
|
748
|
+
if (!ctx.conjunctive)
|
|
749
|
+
throw new Error(
|
|
750
|
+
`Text search on "${this.ref}" must sit at an AND position; q.search() cannot be nested under q.any() or q.not().`,
|
|
751
|
+
);
|
|
752
|
+
const join = this.#searchJoin(query, ctx.joins.length);
|
|
753
|
+
if (join) ctx.joins.push(join);
|
|
754
|
+
return { sql: join ? "" : "0 = 1", params: [] };
|
|
755
|
+
}
|
|
706
756
|
if (query.kind === "all" || query.kind === "any") {
|
|
707
|
-
const
|
|
757
|
+
const subCtx = query.kind === "all" ? ctx : { ...ctx, conjunctive: false };
|
|
758
|
+
const parts = query.queries.map((sub) => this.compileNode(sub, subCtx)).filter((part) => part.sql);
|
|
708
759
|
if (!parts.length) return { sql: "1 = 1", params: [] };
|
|
709
760
|
const joiner = query.kind === "all" ? " AND " : " OR ";
|
|
710
761
|
return {
|
|
@@ -713,7 +764,7 @@ class QueryCompiler {
|
|
|
713
764
|
};
|
|
714
765
|
}
|
|
715
766
|
if (query.kind === "not") {
|
|
716
|
-
const part = this.compileNode(query.query);
|
|
767
|
+
const part = this.compileNode(query.query, { ...ctx, conjunctive: false });
|
|
717
768
|
return { sql: `NOT (${part.sql})`, params: part.params };
|
|
718
769
|
}
|
|
719
770
|
if (query.kind === "raw") {
|
|
@@ -724,7 +775,7 @@ class QueryCompiler {
|
|
|
724
775
|
}
|
|
725
776
|
const parts = Object.entries(query).flatMap(([path, value]) => {
|
|
726
777
|
if (value === undefined) throw new Error(`Undefined query value is not allowed: ${path}`);
|
|
727
|
-
return [this.compileField(path, value)];
|
|
778
|
+
return [this.compileField(path, value, ctx)];
|
|
728
779
|
});
|
|
729
780
|
if (!parts.length) return { sql: "1 = 1", params: [] };
|
|
730
781
|
return {
|
|
@@ -733,12 +784,33 @@ class QueryCompiler {
|
|
|
733
784
|
};
|
|
734
785
|
}
|
|
735
786
|
|
|
736
|
-
|
|
787
|
+
#searchJoin(node: Extract<DocumentQueryNode, { kind: "search" }>, index: number): SearchJoin | null {
|
|
788
|
+
if (!this.searchEnabled())
|
|
789
|
+
throw new Error(
|
|
790
|
+
`Text search on "${this.ref}" is unavailable because the search index is switched off (AKAN_SEARCH_ENABLED).`,
|
|
791
|
+
);
|
|
792
|
+
const columns = node.columns?.filter((column) => searchColumns.includes(column));
|
|
793
|
+
if (node.columns?.length && !columns?.length)
|
|
794
|
+
throw new Error(`Unknown search column on "${this.ref}": ${node.columns.join(", ")}`);
|
|
795
|
+
const match = toMatchExpression(node.text, { prefix: node.prefix, columns });
|
|
796
|
+
|
|
797
|
+
if (!match) return null;
|
|
798
|
+
const weights = node.weights ?? DEFAULT_SEARCH_WEIGHTS;
|
|
799
|
+
|
|
800
|
+
if (weights.length !== searchColumns.length || weights.some((weight) => !Number.isFinite(weight)))
|
|
801
|
+
throw new Error(
|
|
802
|
+
`Search weights on "${this.ref}" must be ${searchColumns.length} finite numbers: ${JSON.stringify(node.weights)}`,
|
|
803
|
+
);
|
|
804
|
+
const alias = `__s${index}`;
|
|
805
|
+
return { alias, ...this.dialect.searchJoin({ alias, ref: this.ref, match, weights }) };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
private compileField(path: string, value: unknown, ctx: CompileContext): { sql: string; params: unknown[] } {
|
|
737
809
|
this.assertPath(path);
|
|
738
810
|
const field = this.fields[path]?.getProps?.() ?? this.fields[path];
|
|
739
811
|
const leaf = this.#leaf(path);
|
|
740
812
|
if (this.isQueryNode(value)) {
|
|
741
|
-
if (value.kind !== "op") return this.compileNode({ [path]: value } as DocumentQuery);
|
|
813
|
+
if (value.kind !== "op") return this.compileNode({ [path]: value } as DocumentQuery, ctx);
|
|
742
814
|
switch (value.op) {
|
|
743
815
|
case "eq":
|
|
744
816
|
return leaf.eq(path, value.value);
|
|
@@ -796,12 +868,12 @@ class QueryCompiler {
|
|
|
796
868
|
const parts = keys.flatMap((key) => {
|
|
797
869
|
if (!QUERY_OPERATOR_KEYS.has(key)) return [];
|
|
798
870
|
if (key === "exists")
|
|
799
|
-
return [this.compileField(path, { kind: "op", op: operators.exists ? "exists" : "missing" })];
|
|
871
|
+
return [this.compileField(path, { kind: "op", op: operators.exists ? "exists" : "missing" }, ctx)];
|
|
800
872
|
if (key === "missing")
|
|
801
|
-
return [this.compileField(path, { kind: "op", op: operators.missing ? "missing" : "exists" })];
|
|
873
|
+
return [this.compileField(path, { kind: "op", op: operators.missing ? "missing" : "exists" }, ctx)];
|
|
802
874
|
if (key === "empty")
|
|
803
|
-
return [this.compileField(path, { kind: "op", op: operators.empty ? "empty" : "exists" })];
|
|
804
|
-
return [this.compileField(path, { kind: "op", op: key as QueryOperatorName, value: operators[key] })];
|
|
875
|
+
return [this.compileField(path, { kind: "op", op: operators.empty ? "empty" : "exists" }, ctx)];
|
|
876
|
+
return [this.compileField(path, { kind: "op", op: key as QueryOperatorName, value: operators[key] }, ctx)];
|
|
805
877
|
});
|
|
806
878
|
return {
|
|
807
879
|
sql: `(${parts.map((part) => part.sql).join(" AND ")})`,
|
|
@@ -903,7 +975,8 @@ export class SqlDocumentStore {
|
|
|
903
975
|
this.schema = schema;
|
|
904
976
|
this.table = database.refName;
|
|
905
977
|
const fields = database.doc[FIELD_META] as unknown as FieldMap;
|
|
906
|
-
|
|
978
|
+
|
|
979
|
+
this.compiler = new QueryCompiler(fields, dialect, this.table, () => !!this.owner.getSearchIndex()?.enabled);
|
|
907
980
|
this.updateCompiler = new UpdateCompiler(fields, dialect);
|
|
908
981
|
}
|
|
909
982
|
|
|
@@ -938,6 +1011,7 @@ export class SqlDocumentStore {
|
|
|
938
1011
|
);
|
|
939
1012
|
await this.owner.setMeta(metaKey, hash);
|
|
940
1013
|
}
|
|
1014
|
+
await this.owner.getSearchIndex()?.ensureRef(this.constant, this.database);
|
|
941
1015
|
}
|
|
942
1016
|
|
|
943
1017
|
async create(data: DocumentRecord, { runSaveHooks = true }: WriteHookOptions = {}) {
|
|
@@ -977,7 +1051,7 @@ export class SqlDocumentStore {
|
|
|
977
1051
|
async updateOneByQuery(query: DocumentQuery, update: DocumentUpdateInput, options: DocumentUpdateOptions = {}) {
|
|
978
1052
|
const resolved = resolveDocumentUpdate(update);
|
|
979
1053
|
const { assignments, params } = this.compiledUpdate(resolved);
|
|
980
|
-
const { where, params: whereParams } = this.
|
|
1054
|
+
const { where, params: whereParams } = this.writeQuery(query, "updateOneByQuery");
|
|
981
1055
|
const subquery = `SELECT ${quoteIdent("id")} FROM ${quoteIdent(this.table)} WHERE ${where} ORDER BY ${this.compiler.orderBy()} LIMIT 1`;
|
|
982
1056
|
const sql = `UPDATE ${quoteIdent(this.table)} SET ${assignments.join(", ")} WHERE ${quoteIdent("id")} IN (${subquery})`;
|
|
983
1057
|
const changes = this.dialect.affectedRows(
|
|
@@ -996,7 +1070,7 @@ export class SqlDocumentStore {
|
|
|
996
1070
|
|
|
997
1071
|
async updateManyByQuery(query: DocumentQuery, update: DocumentUpdateInput) {
|
|
998
1072
|
const { assignments, params } = this.compiledUpdate(resolveDocumentUpdate(update));
|
|
999
|
-
const { where, params: whereParams } = this.
|
|
1073
|
+
const { where, params: whereParams } = this.writeQuery(query, "updateManyByQuery");
|
|
1000
1074
|
const sql = `UPDATE ${quoteIdent(this.table)} SET ${assignments.join(", ")} WHERE ${where}`;
|
|
1001
1075
|
const changes = this.dialect.affectedRows(
|
|
1002
1076
|
await this.owner
|
|
@@ -1038,22 +1112,26 @@ export class SqlDocumentStore {
|
|
|
1038
1112
|
}
|
|
1039
1113
|
|
|
1040
1114
|
async find(query?: DocumentQuery, options: FindManyOptions = {}) {
|
|
1041
|
-
const { where, params } = this.safeQuery(query);
|
|
1115
|
+
const { where, params, joins } = this.safeQuery(query);
|
|
1042
1116
|
const limitValue = Number(options.limit ?? 0);
|
|
1043
1117
|
const skipValue = Number(options.skip ?? 0);
|
|
1044
1118
|
const limit = limitValue ? ` LIMIT ${limitValue}` : "";
|
|
1045
1119
|
const offset = skipValue ? ` OFFSET ${skipValue}` : "";
|
|
1046
|
-
const
|
|
1120
|
+
const join = this.joinSql(joins);
|
|
1121
|
+
const order = options.sample ? "ORDER BY random()" : `ORDER BY ${this.orderBy(options.sort, joins)}`;
|
|
1122
|
+
const args = [...this.joinParams(joins), ...params];
|
|
1047
1123
|
const projection = this.resolveProjection(options.select);
|
|
1048
1124
|
if (projection) {
|
|
1049
1125
|
const rows = await this.prepareReadStmt(
|
|
1050
|
-
`SELECT ${this.projectionSql(projection)} FROM ${quoteIdent(this.table)} WHERE ${where} ${order}${limit}${offset}`,
|
|
1051
|
-
).all<ProjectedSqliteDocumentRow>(...
|
|
1126
|
+
`SELECT ${this.projectionSql(projection)} FROM ${quoteIdent(this.table)}${join} WHERE ${where} ${order}${limit}${offset}`,
|
|
1127
|
+
).all<ProjectedSqliteDocumentRow>(...args);
|
|
1052
1128
|
return rows.map((row) => this.hydrate(this.fromProjectedRow(row, projection)));
|
|
1053
1129
|
}
|
|
1130
|
+
|
|
1131
|
+
const star = joins.length ? `${quoteIdent(this.table)}.*` : "*";
|
|
1054
1132
|
const rows = await this.prepareReadStmt(
|
|
1055
|
-
`SELECT
|
|
1056
|
-
).all<SqliteDocumentRow>(...
|
|
1133
|
+
`SELECT ${star} FROM ${quoteIdent(this.table)}${join} WHERE ${where} ${order}${limit}${offset}`,
|
|
1134
|
+
).all<SqliteDocumentRow>(...args);
|
|
1057
1135
|
return rows.map((row) => this.hydrate(this.fromRow(row)));
|
|
1058
1136
|
}
|
|
1059
1137
|
|
|
@@ -1061,15 +1139,16 @@ export class SqlDocumentStore {
|
|
|
1061
1139
|
query?: DocumentQuery,
|
|
1062
1140
|
options: { sort?: SortOption; skip?: number | null; limit?: number | null; sample?: number } = {},
|
|
1063
1141
|
) {
|
|
1064
|
-
const { where, params } = this.safeQuery(query);
|
|
1142
|
+
const { where, params, joins } = this.safeQuery(query);
|
|
1065
1143
|
const limitValue = Number(options.limit ?? 0);
|
|
1066
1144
|
const skipValue = Number(options.skip ?? 0);
|
|
1067
1145
|
const limit = limitValue ? ` LIMIT ${limitValue}` : "";
|
|
1068
1146
|
const offset = skipValue ? ` OFFSET ${skipValue}` : "";
|
|
1069
|
-
const
|
|
1147
|
+
const join = this.joinSql(joins);
|
|
1148
|
+
const order = options.sample ? "ORDER BY random()" : `ORDER BY ${this.orderBy(options.sort, joins)}`;
|
|
1070
1149
|
const rows = await this.prepareReadStmt(
|
|
1071
|
-
`SELECT "id" FROM ${quoteIdent(this.table)} WHERE ${where} ${order}${limit}${offset}`,
|
|
1072
|
-
).all<{ id: string }>(...params);
|
|
1150
|
+
`SELECT ${quoteIdent(this.table)}."id" FROM ${quoteIdent(this.table)}${join} WHERE ${where} ${order}${limit}${offset}`,
|
|
1151
|
+
).all<{ id: string }>(...this.joinParams(joins), ...params);
|
|
1073
1152
|
return rows.map((row) => row.id);
|
|
1074
1153
|
}
|
|
1075
1154
|
|
|
@@ -1098,10 +1177,10 @@ export class SqlDocumentStore {
|
|
|
1098
1177
|
}
|
|
1099
1178
|
|
|
1100
1179
|
async count(query?: DocumentQuery) {
|
|
1101
|
-
const { where, params } = this.safeQuery(query);
|
|
1180
|
+
const { where, params, joins } = this.safeQuery(query);
|
|
1102
1181
|
const row = await this.prepareReadStmt(
|
|
1103
|
-
`SELECT count(*) as count FROM ${quoteIdent(this.table)} WHERE ${where}`,
|
|
1104
|
-
).get<{ count: number }>(...params);
|
|
1182
|
+
`SELECT count(*) as count FROM ${quoteIdent(this.table)}${this.joinSql(joins)} WHERE ${where}`,
|
|
1183
|
+
).get<{ count: number }>(...this.joinParams(joins), ...params);
|
|
1105
1184
|
return row?.count ?? 0;
|
|
1106
1185
|
}
|
|
1107
1186
|
|
|
@@ -1124,32 +1203,31 @@ export class SqlDocumentStore {
|
|
|
1124
1203
|
return result;
|
|
1125
1204
|
}
|
|
1126
1205
|
|
|
1127
|
-
async search(
|
|
1128
|
-
searchText: string | undefined | null,
|
|
1129
|
-
options: { skip?: number | null; limit?: number | null; sort?: SortOption } = {},
|
|
1130
|
-
) {
|
|
1131
|
-
const textFields = this.schema.indexes.flatMap((index) =>
|
|
1132
|
-
Object.entries(index.fields)
|
|
1133
|
-
.filter(([, mode]) => mode === "text")
|
|
1134
|
-
.map(([field]) => field),
|
|
1135
|
-
);
|
|
1136
|
-
const query =
|
|
1137
|
-
searchText && textFields.length
|
|
1138
|
-
? documentQueryHelper.any(
|
|
1139
|
-
...textFields.map((field) =>
|
|
1140
|
-
documentQueryHelper.raw(`${this.compiler.fieldExpr(field)} LIKE ?`, [`%${searchText}%`]),
|
|
1141
|
-
),
|
|
1142
|
-
)
|
|
1143
|
-
: {};
|
|
1144
|
-
const docs = await this.find(query, options);
|
|
1145
|
-
const count = await this.count(query);
|
|
1146
|
-
return { docs, count };
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
1206
|
private safeQuery(query?: DocumentQuery) {
|
|
1150
1207
|
return this.compiler.compile(documentQueryHelper.all(documentQueryHelper.empty("removedAt"), query ?? {}));
|
|
1151
1208
|
}
|
|
1152
1209
|
|
|
1210
|
+
private writeQuery(query: DocumentQuery | undefined, operation: string) {
|
|
1211
|
+
const compiled = this.safeQuery(query);
|
|
1212
|
+
if (compiled.joins.length)
|
|
1213
|
+
throw new Error(`q.search() cannot be used in ${operation} on "${this.table}"; query-level writes take no join.`);
|
|
1214
|
+
return compiled;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
private joinSql(joins: SearchJoin[]) {
|
|
1218
|
+
return joins.length ? ` ${joins.map((join) => join.sql).join(" ")}` : "";
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
private joinParams(joins: SearchJoin[]) {
|
|
1222
|
+
return joins.flatMap((join) => join.params);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
private orderBy(sort: SortOption, joins: SearchJoin[]) {
|
|
1226
|
+
const explicit = sort && Object.keys(sort).length ? sort : null;
|
|
1227
|
+
if (!explicit && joins.length) return `${joins[0].alias}."score", ${quoteIdent(this.table)}."id" DESC`;
|
|
1228
|
+
return this.compiler.orderBy(explicit ?? undefined);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1153
1231
|
private prepareDocument(data: DocumentRecord) {
|
|
1154
1232
|
const fields = this.database.doc[FIELD_META] as unknown as FieldMap;
|
|
1155
1233
|
const doc: MutableDocumentRecord = {};
|
|
@@ -1350,15 +1428,17 @@ export class SqlDocumentStore {
|
|
|
1350
1428
|
}
|
|
1351
1429
|
|
|
1352
1430
|
private async findForWrite(query?: DocumentQuery, options: FindManyOptions = {}) {
|
|
1353
|
-
const { where, params } = this.safeQuery(query);
|
|
1431
|
+
const { where, params, joins } = this.safeQuery(query);
|
|
1354
1432
|
const limitValue = Number(options.limit ?? 0);
|
|
1355
1433
|
const skipValue = Number(options.skip ?? 0);
|
|
1356
1434
|
const limit = limitValue ? ` LIMIT ${limitValue}` : "";
|
|
1357
1435
|
const offset = skipValue ? ` OFFSET ${skipValue}` : "";
|
|
1358
|
-
const
|
|
1436
|
+
const join = this.joinSql(joins);
|
|
1437
|
+
const order = options.sample ? "ORDER BY random()" : `ORDER BY ${this.orderBy(options.sort, joins)}`;
|
|
1438
|
+
const star = joins.length ? `${quoteIdent(this.table)}.*` : "*";
|
|
1359
1439
|
const rows = await this.prepareReadStmt(
|
|
1360
|
-
`SELECT
|
|
1361
|
-
).all<SqliteDocumentRow>(...params);
|
|
1440
|
+
`SELECT ${star} FROM ${quoteIdent(this.table)}${join} WHERE ${where} ${order}${limit}${offset}`,
|
|
1441
|
+
).all<SqliteDocumentRow>(...this.joinParams(joins), ...params);
|
|
1362
1442
|
return rows.map((row) => this.hydrate(this.fromRow(row)));
|
|
1363
1443
|
}
|
|
1364
1444
|
|
|
@@ -1622,7 +1702,8 @@ class PendingStoreEnsures {
|
|
|
1622
1702
|
}
|
|
1623
1703
|
|
|
1624
1704
|
export class SqliteDatabase
|
|
1625
|
-
extends adapt("sqliteDatabase", ({ env }) => ({
|
|
1705
|
+
extends adapt("sqliteDatabase", ({ env, plug }) => ({
|
|
1706
|
+
scheduler: plug(ScheduleAdaptorRole),
|
|
1626
1707
|
config: env((env: SqliteEnv) => {
|
|
1627
1708
|
const appName = env.appName ?? "akan";
|
|
1628
1709
|
const environment = env.environment ?? "local";
|
|
@@ -1640,10 +1721,14 @@ export class SqliteDatabase
|
|
|
1640
1721
|
foreignKeys: true,
|
|
1641
1722
|
...env.database?.sqlite,
|
|
1642
1723
|
filePath: env.database?.sqlite?.filePath ?? process.env.SQLITE_DATABASE_PATH ?? defaultFile,
|
|
1724
|
+
search: {
|
|
1725
|
+
enabled: env.database?.search?.enabled ?? parseSearchEnabled(process.env.AKAN_SEARCH_ENABLED),
|
|
1726
|
+
tokenizer: env.database?.search?.tokenizer ?? process.env.AKAN_SEARCH_TOKENIZER ?? DEFAULT_TOKENIZER,
|
|
1727
|
+
},
|
|
1643
1728
|
} satisfies Required<
|
|
1644
1729
|
Pick<SqliteDatabaseConfig, "filePath" | "journalMode" | "busyTimeoutMs" | "synchronous" | "foreignKeys">
|
|
1645
1730
|
> &
|
|
1646
|
-
SqliteDatabaseConfig;
|
|
1731
|
+
SqliteDatabaseConfig & { search: Required<SearchConfig> };
|
|
1647
1732
|
}),
|
|
1648
1733
|
}))
|
|
1649
1734
|
implements DatabaseAdaptor
|
|
@@ -1653,6 +1738,7 @@ export class SqliteDatabase
|
|
|
1653
1738
|
#stores = new Map<string, SqlDocumentStore>();
|
|
1654
1739
|
#transaction = new AsyncLocalStorage<TransactionContext>();
|
|
1655
1740
|
#ensures = new PendingStoreEnsures();
|
|
1741
|
+
#searchIndex!: SearchIndex;
|
|
1656
1742
|
|
|
1657
1743
|
override async onInit() {
|
|
1658
1744
|
await mkdir(path.dirname(this.config.filePath), { recursive: true });
|
|
@@ -1667,9 +1753,19 @@ export class SqliteDatabase
|
|
|
1667
1753
|
this.#db.run(
|
|
1668
1754
|
`CREATE TABLE IF NOT EXISTS "_akan_meta" ("key" TEXT PRIMARY KEY NOT NULL, "value" TEXT NOT NULL, "updatedAt" INTEGER NOT NULL)`,
|
|
1669
1755
|
);
|
|
1756
|
+
this.#searchIndex = new SearchIndex(this, this.config.search);
|
|
1757
|
+
await this.#searchIndex.ensureSchema();
|
|
1758
|
+
this.scheduler.registerCron(OPTIMIZE_CRON_KEY, OPTIMIZE_CRON, async () => {
|
|
1759
|
+
await this.#searchIndex.optimize();
|
|
1760
|
+
});
|
|
1761
|
+
this.scheduler.registerInterval(RETRY_INTERVAL_KEY, RETRY_INTERVAL_MS, async () => {
|
|
1762
|
+
await this.#searchIndex.retryPending();
|
|
1763
|
+
});
|
|
1670
1764
|
}
|
|
1671
1765
|
|
|
1672
1766
|
override async onDestroy() {
|
|
1767
|
+
this.scheduler.unregisterCron(OPTIMIZE_CRON_KEY);
|
|
1768
|
+
this.scheduler.unregisterInterval(RETRY_INTERVAL_KEY);
|
|
1673
1769
|
await this.#ensures.settle();
|
|
1674
1770
|
this.#db?.run("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
1675
1771
|
await this.#client?.close();
|
|
@@ -1679,6 +1775,10 @@ export class SqliteDatabase
|
|
|
1679
1775
|
return this.#client;
|
|
1680
1776
|
}
|
|
1681
1777
|
|
|
1778
|
+
getSearchIndex() {
|
|
1779
|
+
return this.#searchIndex;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1682
1782
|
getStore(constant: ConstantModel, database: DatabaseModel, schema: SchemaOf) {
|
|
1683
1783
|
const existing = this.#stores.get(database.refName);
|
|
1684
1784
|
if (existing) return existing;
|
|
@@ -1735,7 +1835,8 @@ export class SqliteDatabase
|
|
|
1735
1835
|
}
|
|
1736
1836
|
|
|
1737
1837
|
export class LibsqlDatabase
|
|
1738
|
-
extends adapt("libsqlDatabase", ({ env }) => ({
|
|
1838
|
+
extends adapt("libsqlDatabase", ({ env, plug }) => ({
|
|
1839
|
+
scheduler: plug(ScheduleAdaptorRole),
|
|
1739
1840
|
config: env((env: SqliteEnv) => {
|
|
1740
1841
|
const appName = env.appName ?? "akan";
|
|
1741
1842
|
const environment = env.environment ?? "local";
|
|
@@ -1753,7 +1854,11 @@ export class LibsqlDatabase
|
|
|
1753
1854
|
process.env.LIBSQL_URI ??
|
|
1754
1855
|
`file:${env.database?.sqlite?.filePath ?? process.env.SQLITE_DATABASE_PATH ?? defaultFile}`,
|
|
1755
1856
|
authToken: env.database?.libsql?.authToken ?? process.env.LIBSQL_AUTH_TOKEN,
|
|
1756
|
-
|
|
1857
|
+
search: {
|
|
1858
|
+
enabled: env.database?.search?.enabled ?? parseSearchEnabled(process.env.AKAN_SEARCH_ENABLED),
|
|
1859
|
+
tokenizer: env.database?.search?.tokenizer ?? process.env.AKAN_SEARCH_TOKENIZER ?? DEFAULT_TOKENIZER,
|
|
1860
|
+
},
|
|
1861
|
+
} satisfies LibsqlDatabaseConfig & { search: Required<SearchConfig> };
|
|
1757
1862
|
}),
|
|
1758
1863
|
}))
|
|
1759
1864
|
implements DatabaseAdaptor
|
|
@@ -1762,6 +1867,7 @@ export class LibsqlDatabase
|
|
|
1762
1867
|
#stores = new Map<string, SqlDocumentStore>();
|
|
1763
1868
|
#transaction = new AsyncLocalStorage<TransactionContext>();
|
|
1764
1869
|
#ensures = new PendingStoreEnsures();
|
|
1870
|
+
#searchIndex!: SearchIndex;
|
|
1765
1871
|
|
|
1766
1872
|
override async onInit() {
|
|
1767
1873
|
const url = this.config.url ?? "file:local.db";
|
|
@@ -1771,9 +1877,19 @@ export class LibsqlDatabase
|
|
|
1771
1877
|
await this.#client.execute(
|
|
1772
1878
|
`CREATE TABLE IF NOT EXISTS "_akan_meta" ("key" TEXT PRIMARY KEY NOT NULL, "value" TEXT NOT NULL, "updatedAt" INTEGER NOT NULL)`,
|
|
1773
1879
|
);
|
|
1880
|
+
this.#searchIndex = new SearchIndex(this, this.config.search);
|
|
1881
|
+
await this.#searchIndex.ensureSchema();
|
|
1882
|
+
this.scheduler.registerCron(OPTIMIZE_CRON_KEY, OPTIMIZE_CRON, async () => {
|
|
1883
|
+
await this.#searchIndex.optimize();
|
|
1884
|
+
});
|
|
1885
|
+
this.scheduler.registerInterval(RETRY_INTERVAL_KEY, RETRY_INTERVAL_MS, async () => {
|
|
1886
|
+
await this.#searchIndex.retryPending();
|
|
1887
|
+
});
|
|
1774
1888
|
}
|
|
1775
1889
|
|
|
1776
1890
|
override async onDestroy() {
|
|
1891
|
+
this.scheduler.unregisterCron(OPTIMIZE_CRON_KEY);
|
|
1892
|
+
this.scheduler.unregisterInterval(RETRY_INTERVAL_KEY);
|
|
1777
1893
|
await this.#ensures.settle();
|
|
1778
1894
|
await this.#client?.close();
|
|
1779
1895
|
}
|
|
@@ -1782,6 +1898,10 @@ export class LibsqlDatabase
|
|
|
1782
1898
|
return this.#client;
|
|
1783
1899
|
}
|
|
1784
1900
|
|
|
1901
|
+
getSearchIndex() {
|
|
1902
|
+
return this.#searchIndex;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1785
1905
|
getStore(constant: ConstantModel, database: DatabaseModel, schema: SchemaOf) {
|
|
1786
1906
|
const existing = this.#stores.get(database.refName);
|
|
1787
1907
|
if (existing) return existing;
|
|
@@ -1875,6 +1995,10 @@ export class PostgresDatabase
|
|
|
1875
1995
|
return this.#client;
|
|
1876
1996
|
}
|
|
1877
1997
|
|
|
1998
|
+
getSearchIndex() {
|
|
1999
|
+
return null;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
1878
2002
|
getStore(constant: ConstantModel, database: DatabaseModel, schema: SchemaOf) {
|
|
1879
2003
|
const existing = this.#stores.get(database.refName);
|
|
1880
2004
|
if (existing) return existing;
|
|
@@ -5,6 +5,7 @@ export * from "./logging.adaptor";
|
|
|
5
5
|
export * from "./queue.adaptor";
|
|
6
6
|
export * from "./role.adaptor";
|
|
7
7
|
export * from "./schedule.adaptor";
|
|
8
|
+
export * from "./searchIndex";
|
|
8
9
|
export * from "./solidCache.adaptor";
|
|
9
10
|
export * from "./solidPubsub.adaptor";
|
|
10
11
|
export * from "./solidQueue.adaptor";
|