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.
Files changed (66) hide show
  1. package/client/csrTypes.ts +7 -0
  2. package/client/frameConfig.ts +6 -1
  3. package/client/rscNavigation.ts +9 -0
  4. package/common/index.ts +5 -0
  5. package/common/websocketAuth.ts +24 -0
  6. package/constant/fieldInfo.ts +4 -3
  7. package/constant/index.ts +2 -0
  8. package/constant/textFieldPathSet.ts +8 -0
  9. package/constant/textFieldPaths.ts +59 -0
  10. package/constant/types.ts +0 -4
  11. package/constant/via.ts +13 -28
  12. package/dictionary/dictInfo.ts +4 -0
  13. package/document/documentQuery.ts +17 -1
  14. package/document/documentSchema.ts +1 -15
  15. package/document/filterMeta.ts +4 -2
  16. package/fetch/client/fetchClient.ts +1 -0
  17. package/fetch/client/wsClient.ts +28 -1
  18. package/index.ts +6 -0
  19. package/local/apps/serverLifecycle/serverLifecycle-local.db +0 -0
  20. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  21. package/local/apps/serverLifecycle/serverLifecycle-local.db-wal +0 -0
  22. package/package.json +1 -1
  23. package/server/akanServer.ts +5 -4
  24. package/server/devtools/types.ts +2 -2
  25. package/server/resolver/database.resolver.ts +5 -6
  26. package/server/resolver/signal.resolver.ts +23 -0
  27. package/server/routing/apiRouter.ts +11 -3
  28. package/server/routing/appWsData.ts +50 -0
  29. package/server/rscClient.tsx +9 -0
  30. package/service/predefinedAdaptor/database.adaptor.ts +212 -88
  31. package/service/predefinedAdaptor/index.ts +1 -0
  32. package/service/predefinedAdaptor/searchIndex.ts +517 -0
  33. package/service/predefinedAdaptor/sqlDescriptor.ts +25 -0
  34. package/signal/signalContext.ts +37 -12
  35. package/signal/types.ts +2 -1
  36. package/types/client/csrTypes.d.ts +7 -0
  37. package/types/client/rscNavigation.d.ts +8 -0
  38. package/types/common/index.d.ts +1 -0
  39. package/types/common/websocketAuth.d.ts +19 -0
  40. package/types/constant/fieldInfo.d.ts +4 -3
  41. package/types/constant/index.d.ts +2 -0
  42. package/types/constant/textFieldPathSet.d.ts +8 -0
  43. package/types/constant/textFieldPaths.d.ts +10 -0
  44. package/types/constant/types.d.ts +0 -3
  45. package/types/constant/via.d.ts +4 -24
  46. package/types/dictionary/base.dictionary.d.ts +1 -1
  47. package/types/dictionary/dictionary.d.ts +8 -8
  48. package/types/document/documentQuery.d.ts +13 -1
  49. package/types/document/documentSchema.d.ts +0 -3
  50. package/types/document/filterMeta.d.ts +2 -1
  51. package/types/fetch/client/wsClient.d.ts +6 -0
  52. package/types/index.d.ts +6 -0
  53. package/types/server/devtools/types.d.ts +2 -2
  54. package/types/server/resolver/signal.resolver.d.ts +6 -0
  55. package/types/server/routing/apiRouter.d.ts +3 -4
  56. package/types/server/routing/appWsData.d.ts +24 -0
  57. package/types/server/rscClient.d.ts +1 -0
  58. package/types/service/predefinedAdaptor/database.adaptor.d.ts +46 -13
  59. package/types/service/predefinedAdaptor/index.d.ts +1 -0
  60. package/types/service/predefinedAdaptor/searchIndex.d.ts +73 -0
  61. package/types/service/predefinedAdaptor/sqlDescriptor.d.ts +5 -0
  62. package/types/signal/signalContext.d.ts +7 -0
  63. package/types/signal/types.d.ts +5 -1
  64. package/types/ui/Constant/schemaDoc.d.ts +2 -2
  65. package/ui/Constant/schemaDoc.ts +8 -2
  66. 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 = Exclude<
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): { where: string; params: unknown[] } {
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 parts = query.queries.map((sub) => this.compileNode(sub)).filter((part) => part.sql);
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
- private compileField(path: string, value: unknown): { sql: string; params: unknown[] } {
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
- this.compiler = new QueryCompiler(fields, dialect);
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.safeQuery(query);
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.safeQuery(query);
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 order = options.sample ? "ORDER BY random()" : `ORDER BY ${this.compiler.orderBy(options.sort ?? undefined)}`;
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>(...params);
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 * FROM ${quoteIdent(this.table)} WHERE ${where} ${order}${limit}${offset}`,
1056
- ).all<SqliteDocumentRow>(...params);
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 order = options.sample ? "ORDER BY random()" : `ORDER BY ${this.compiler.orderBy(options.sort ?? undefined)}`;
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 order = options.sample ? "ORDER BY random()" : `ORDER BY ${this.compiler.orderBy(options.sort ?? undefined)}`;
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 * FROM ${quoteIdent(this.table)} WHERE ${where} ${order}${limit}${offset}`,
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
- } satisfies LibsqlDatabaseConfig;
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";