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
@@ -0,0 +1,517 @@
1
+ import { FIELD_META } from "akanjs/base";
2
+ import { Logger } from "akanjs/common";
3
+ import { type ConstantField, type ConstantModel, type FieldObject, textFieldRoles } from "akanjs/constant";
4
+ import { type DatabaseModel, type SearchColumn, searchColumns } from "akanjs/document";
5
+ import type { AkanSqlClient } from "./database.adaptor";
6
+ import { descriptorHash, jsonPath, quoteIdent } from "./sqlDescriptor";
7
+
8
+ export const DOC_TABLE = "search_doc";
9
+ export const FTS_TABLE = "search_fts";
10
+ export const DEFAULT_TOKENIZER = "unicode61 remove_diacritics 2";
11
+
12
+ const SCHEMA_META_KEY = "search:schema";
13
+ const MIRROR_META_KEY = "search:mirror";
14
+ const DISABLED_META_KEY = "search:disabled";
15
+ const REF_META_PREFIX = "search:ref:";
16
+ const LOCK_META_PREFIX = "search:lock:";
17
+
18
+ const LOCK_TTL_MS = 10 * 60 * 1000;
19
+ const BACKFILL_CHUNK = 5000;
20
+
21
+ const MERGE_PAGES = 64;
22
+ const OPTIMIZE_LOCK_REF = "__optimize";
23
+ export const OPTIMIZE_CRON_KEY = "searchIndexOptimize";
24
+
25
+ export const OPTIMIZE_CRON = "17 4 * * *";
26
+ export const RETRY_INTERVAL_KEY = "searchIndexRetry";
27
+
28
+ export const RETRY_INTERVAL_MS = 60_000;
29
+
30
+ export const DEFAULT_SEARCH_WEIGHTS = [10, 1, 3, 0];
31
+
32
+ export interface SearchIndexOwner {
33
+ getConnection(): AkanSqlClient;
34
+ getMeta(key: string): Promise<string | undefined> | string | undefined;
35
+ setMeta(key: string, value: string): Promise<void>;
36
+ }
37
+
38
+ export interface SearchIndexOptions {
39
+ enabled: boolean;
40
+ tokenizer: string;
41
+ }
42
+
43
+ type SearchColumns = Record<(typeof textFieldRoles)[number], string>;
44
+
45
+ /**
46
+ * Reads `AKAN_SEARCH_ENABLED`. Unset means enabled; an unrecognised value fails the boot rather than
47
+ * silently falling back, because a typo like `ture` would otherwise look identical to the default.
48
+ */
49
+ export const parseSearchEnabled = (value: string | undefined) => {
50
+ if (value === undefined || value.trim() === "") return true;
51
+ const normalized = value.trim().toLowerCase();
52
+ if (normalized === "1" || normalized === "true") return true;
53
+ if (normalized === "0" || normalized === "false") return false;
54
+ throw new Error(`Invalid AKAN_SEARCH_ENABLED value: "${value}". Use 1/true or 0/false.`);
55
+ };
56
+
57
+ /**
58
+ * Turns raw user input into an fts5 MATCH expression.
59
+ *
60
+ * Raw text cannot be passed through: `-`, `:`, `*`, `"` and a trailing `AND` are all fts5 syntax and each
61
+ * raises `SQLiteError` instead of returning no rows. Quoting every term makes the whole input literal.
62
+ */
63
+ export const toMatchExpression = (
64
+ text: string,
65
+ { prefix = false, columns }: { prefix?: boolean; columns?: readonly SearchColumn[] } = {},
66
+ ) => {
67
+ const terms = text
68
+ .split(/\s+/)
69
+ .filter(Boolean)
70
+ .map((term) => `"${term.replaceAll('"', '""')}"`);
71
+ if (!terms.length) return null;
72
+ if (prefix) terms[terms.length - 1] = `${terms[terms.length - 1]}*`;
73
+ const expression = terms.join(" ");
74
+
75
+ return columns?.length ? `{${columns.join(" ")}} : (${expression})` : expression;
76
+ };
77
+
78
+ /**
79
+ * Owns the `search_doc` mirror and its fts5 index.
80
+ *
81
+ * The mirror is maintained by SQL triggers rather than document hooks because `updateOneByQuery` and friends
82
+ * deliberately fire no hooks — most searchable-field mutations go through exactly that path, so an app-level
83
+ * hook would miss them silently.
84
+ */
85
+ export class SearchIndex {
86
+ static readonly #docColumns = textFieldRoles.map(quoteIdent).join(", ");
87
+ static readonly #upsertTail = `ON CONFLICT("ref", "refId") DO UPDATE SET ${textFieldRoles
88
+ .map((role) => `${quoteIdent(role)} = excluded.${quoteIdent(role)}`)
89
+ .join(", ")}`;
90
+
91
+ static readonly #slugSeparators = [" ", "-", ".", "/", ":", "@", ",", "+", "#", "(", ")", "'"];
92
+
93
+ static #sqlString(value: string) {
94
+ return value.replaceAll("'", "''");
95
+ }
96
+
97
+ static #slug(value: string) {
98
+ return SearchIndex.#slugSeparators.reduce(
99
+ (expression, separator) => `replace(${expression}, '${SearchIndex.#sqlString(separator)}', '_')`,
100
+ `lower(COALESCE(${value}, ''))`,
101
+ );
102
+ }
103
+
104
+ static #filterToken(path: string, value: string) {
105
+ const key = SearchIndex.#sqlString(path.split(".").join("_"));
106
+ return `NULLIF('${key}_' || ${SearchIndex.#slug(value)}, '${key}_')`;
107
+ }
108
+
109
+ readonly #owner: SearchIndexOwner;
110
+ readonly #enabled: boolean;
111
+ readonly #tokenizer: string;
112
+ readonly #logger = new Logger("SearchIndex");
113
+
114
+ readonly #claims = new Map<string, string>();
115
+
116
+ readonly #pending = new Map<string, [ConstantModel, DatabaseModel]>();
117
+
118
+ constructor(owner: SearchIndexOwner, { enabled, tokenizer }: SearchIndexOptions) {
119
+ this.#owner = owner;
120
+ this.#enabled = enabled;
121
+ this.#tokenizer = tokenizer;
122
+ }
123
+
124
+ get enabled() {
125
+ return this.#enabled;
126
+ }
127
+
128
+ async ensureSchema() {
129
+ if (!this.#enabled) {
130
+
131
+ await this.#owner.setMeta(DISABLED_META_KEY, "1");
132
+ this.#logger.info("Search index disabled by AKAN_SEARCH_ENABLED; model triggers will be dropped");
133
+ return;
134
+ }
135
+ const conn = this.#owner.getConnection();
136
+ if (await this.#owner.getMeta(DISABLED_META_KEY)) {
137
+ await this.#clearRefHashes();
138
+ await conn.execute(`DELETE FROM "_akan_meta" WHERE "key" = ?`, [DISABLED_META_KEY]);
139
+ this.#logger.info("Search index re-enabled; every ref will be reconciled");
140
+ }
141
+ await conn.execute(
142
+ `CREATE TABLE IF NOT EXISTS ${quoteIdent(DOC_TABLE)} (
143
+ "fid" INTEGER PRIMARY KEY AUTOINCREMENT,
144
+ "ref" TEXT NOT NULL,
145
+ "refId" TEXT NOT NULL,
146
+ ${textFieldRoles.map((role) => `${quoteIdent(role)} TEXT NOT NULL DEFAULT ''`).join(",\n ")},
147
+ UNIQUE("ref", "refId")
148
+ )`,
149
+ );
150
+ const hash = await descriptorHash({ doc: textFieldRoles, columns: searchColumns, tokenizer: this.#tokenizer });
151
+
152
+ if ((await this.#ftsExists()) && (await this.#owner.getMeta(SCHEMA_META_KEY)) === hash) {
153
+ await this.#ensureMirrorTriggers();
154
+ return;
155
+ }
156
+ const added = await this.#addMissingDocColumns();
157
+
158
+ await this.#dropMirrorTriggers();
159
+ await conn.execute(`DROP TABLE IF EXISTS ${quoteIdent(FTS_TABLE)}`);
160
+ try {
161
+ await conn.execute(
162
+ `CREATE VIRTUAL TABLE ${quoteIdent(FTS_TABLE)} USING fts5(
163
+ ${searchColumns.map(quoteIdent).join(", ")},
164
+ content='${DOC_TABLE}', content_rowid='fid', tokenize='${SearchIndex.#sqlString(this.#tokenizer)}')`,
165
+ );
166
+ } catch (error) {
167
+
168
+ const message = error instanceof Error ? error.message : String(error);
169
+ throw new Error(
170
+ `Failed to create the search index with tokenizer "${this.#tokenizer}": ${message}. Fix AKAN_SEARCH_TOKENIZER, or set AKAN_SEARCH_ENABLED=0 to run without text search.`,
171
+ );
172
+ }
173
+ await this.#ensureMirrorTriggers({ resync: false });
174
+ if (added.length) {
175
+ await this.#clearRefHashes();
176
+ this.#logger.info(`Search mirror gained ${added.join(", ")}; every ref will be reconciled`);
177
+ }
178
+ await conn.execute(`INSERT INTO ${quoteIdent(FTS_TABLE)}(${quoteIdent(FTS_TABLE)}) VALUES('rebuild')`);
179
+ await this.#owner.setMeta(SCHEMA_META_KEY, hash);
180
+ }
181
+
182
+ /** Returns whether this ref's mirror is now current. `false` means another process is rebuilding it. */
183
+ async ensureRef(constant: ConstantModel, database: DatabaseModel) {
184
+ const ref = database.refName;
185
+ const columns = this.#enabled ? this.#buildColumns(constant, database, "NEW") : null;
186
+ if (!columns) {
187
+ await this.#dropModelTriggers(ref);
188
+ return true;
189
+ }
190
+ const triggers = this.#modelTriggers(ref, columns, this.#buildColumns(constant, database, "OLD"));
191
+
192
+ const hash = await descriptorHash(triggers);
193
+ if ((await this.#owner.getMeta(`${REF_META_PREFIX}${ref}`)) === hash) {
194
+
195
+ await this.#createTriggers(triggers);
196
+ this.#pending.delete(ref);
197
+ return true;
198
+ }
199
+ if (!(await this.#claimLock(ref))) {
200
+
201
+ this.#pending.set(ref, [constant, database]);
202
+ this.#logger.warn(`Search index for ${ref} is held by another process; will retry`);
203
+ return false;
204
+ }
205
+ try {
206
+
207
+ await this.#dropModelTriggers(ref);
208
+ await this.#createTriggers(triggers);
209
+ const reconciled = await this.reconcileRef(ref, columns, () => this.#renewLock(ref));
210
+
211
+ if (reconciled) await this.#owner.setMeta(`${REF_META_PREFIX}${ref}`, hash);
212
+ if (reconciled) this.#pending.delete(ref);
213
+ else this.#pending.set(ref, [constant, database]);
214
+ return reconciled;
215
+ } finally {
216
+ await this.#releaseLock(ref);
217
+ }
218
+ }
219
+
220
+ /** Re-runs the refs another process was holding. Returns how many are still outstanding. */
221
+ async retryPending() {
222
+ for (const [ref, [constant, database]] of [...this.#pending]) {
223
+ if (await this.ensureRef(constant, database)) this.#logger.info(`Search index for ${ref} is current again`);
224
+ }
225
+ return this.#pending.size;
226
+ }
227
+
228
+ /**
229
+ * Rebuilds one ref's mirror rows in id-ordered chunks so a large table does not block the boot. `onChunk` runs
230
+ * between chunks and reports whether this process still holds the claim: a backfill that outlives the lock TTL
231
+ * would otherwise keep writing rows underneath the `DELETE` of the process that took over. Returns whether the
232
+ * whole table was covered.
233
+ */
234
+ async reconcileRef(ref: string, columns: SearchColumns, onChunk?: () => Promise<boolean>) {
235
+ const conn = this.#owner.getConnection();
236
+ await conn.execute(`DELETE FROM ${quoteIdent(DOC_TABLE)} WHERE "ref" = ?`, [ref]);
237
+ let cursor = "";
238
+ for (;;) {
239
+ const rows = await conn
240
+ .prepare(
241
+ `SELECT "id" FROM ${quoteIdent(ref)} WHERE "removedAt" IS NULL AND "id" > ? ORDER BY "id" LIMIT ${BACKFILL_CHUNK}`,
242
+ )
243
+ .all<{ id: string }>(cursor);
244
+ const last = rows.at(-1)?.id;
245
+ if (!last) return true;
246
+ await conn.execute(
247
+ `INSERT INTO ${quoteIdent(DOC_TABLE)}("ref", "refId", ${SearchIndex.#docColumns})
248
+ SELECT '${ref}', NEW."id", ${this.#columnList(columns)}
249
+ FROM ${quoteIdent(ref)} AS NEW
250
+ WHERE NEW."removedAt" IS NULL AND NEW."id" > ? AND NEW."id" <= ?
251
+ ${SearchIndex.#upsertTail}`,
252
+ [cursor, last],
253
+ );
254
+ if (rows.length < BACKFILL_CHUNK) return true;
255
+ cursor = last;
256
+ if (onChunk && !(await onChunk())) {
257
+ this.#logger.warn(`Search backfill for ${ref} stopped: another process took the claim over`);
258
+ return false;
259
+ }
260
+ }
261
+ }
262
+
263
+ /** Merges accumulated fts5 segments. Returns whether this process was the one that did the work. */
264
+ async optimize() {
265
+ if (!this.#enabled) return false;
266
+
267
+ if (!(await this.#claimLock(OPTIMIZE_LOCK_REF))) return false;
268
+ try {
269
+ await this.#owner
270
+ .getConnection()
271
+ .execute(`INSERT INTO ${quoteIdent(FTS_TABLE)}(${quoteIdent(FTS_TABLE)}, rank) VALUES('merge', ?)`, [
272
+ MERGE_PAGES,
273
+ ]);
274
+ return true;
275
+ } catch (error) {
276
+
277
+ this.#logger.warn(`Search index merge failed: ${error instanceof Error ? error.message : String(error)}`);
278
+ return false;
279
+ } finally {
280
+ await this.#releaseLock(OPTIMIZE_LOCK_REF);
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Drops a ref's triggers so a bulk import skips the per-row mirror write. Pair with `resume`.
286
+ *
287
+ * The hash is cleared here rather than in `resume` alone: a process that dies mid-import never reaches `resume`,
288
+ * and a boot that finds the hash intact recreates the triggers and skips the backfill, leaving everything
289
+ * written in between missing from the mirror for good.
290
+ */
291
+ async suspend(database: DatabaseModel) {
292
+ await this.#owner.setMeta(`${REF_META_PREFIX}${database.refName}`, "");
293
+ await this.#dropModelTriggers(database.refName);
294
+ }
295
+
296
+ /** Returns whether the mirror is current again; `false` means another process holds the rebuild. */
297
+ async resume(constant: ConstantModel, database: DatabaseModel) {
298
+ await this.#owner.setMeta(`${REF_META_PREFIX}${database.refName}`, "");
299
+ return await this.ensureRef(constant, database);
300
+ }
301
+
302
+ #columnList(columns: SearchColumns) {
303
+ return textFieldRoles.map((role) => columns[role]).join(", ");
304
+ }
305
+
306
+ async #clearRefHashes() {
307
+ await this.#owner.getConnection().execute(`DELETE FROM "_akan_meta" WHERE "key" LIKE '${REF_META_PREFIX}%'`);
308
+ }
309
+
310
+ /**
311
+ * Widens an existing mirror to a column this build knows about but the database predates. Without it a release
312
+ * that adds a role fails every boot on `rebuild`, which is the migration step the descriptor hash exists to
313
+ * avoid. A column dropped from a later build is left in place; it defaults to empty and costs nothing.
314
+ */
315
+ async #addMissingDocColumns() {
316
+ const conn = this.#owner.getConnection();
317
+ const existing = new Set(
318
+ (await conn.prepare(`PRAGMA table_info(${quoteIdent(DOC_TABLE)})`).all<{ name: string }>()).map(
319
+ (column) => column.name,
320
+ ),
321
+ );
322
+ const missing = textFieldRoles.filter((role) => !existing.has(role));
323
+ for (const role of missing) {
324
+ await conn.execute(
325
+ `ALTER TABLE ${quoteIdent(DOC_TABLE)} ADD COLUMN ${quoteIdent(role)} TEXT NOT NULL DEFAULT ''`,
326
+ );
327
+ }
328
+ return missing;
329
+ }
330
+
331
+ #mirrorTriggers(): [string, string][] {
332
+ const cols = searchColumns.map(quoteIdent).join(", ");
333
+ const fts = quoteIdent(FTS_TABLE);
334
+ const doc = quoteIdent(DOC_TABLE);
335
+ const values = (alias: "new" | "old") => searchColumns.map((col) => `${alias}.${quoteIdent(col)}`).join(", ");
336
+ const remove = `INSERT INTO ${fts}(${fts}, rowid, ${cols}) VALUES('delete', old."fid", ${values("old")})`;
337
+ const insert = `INSERT INTO ${fts}(rowid, ${cols}) VALUES(new."fid", ${values("new")})`;
338
+ return [
339
+ [`${DOC_TABLE}_ai`, `AFTER INSERT ON ${doc} BEGIN ${insert}; END`],
340
+ [`${DOC_TABLE}_ad`, `AFTER DELETE ON ${doc} BEGIN ${remove}; END`],
341
+ [`${DOC_TABLE}_au`, `AFTER UPDATE ON ${doc} BEGIN ${remove}; ${insert}; END`],
342
+ ];
343
+ }
344
+
345
+ /**
346
+ * Only replaces these when their definition actually changed. A mirror row written while `search_doc_au` is
347
+ * missing leaves fts5 holding the previous text: the current value stops matching, the old one returns a ghost
348
+ * hit, and `integrity-check` still passes — so the damage is both silent and invisible to the usual check.
349
+ * The replacing path resyncs the index afterwards, unless the caller is about to rebuild it anyway.
350
+ */
351
+ async #ensureMirrorTriggers({ resync = true } = {}) {
352
+ const conn = this.#owner.getConnection();
353
+ const triggers = this.#mirrorTriggers();
354
+ const hash = await descriptorHash(triggers);
355
+ if ((await this.#owner.getMeta(MIRROR_META_KEY)) === hash) {
356
+ await this.#createTriggers(triggers);
357
+ return;
358
+ }
359
+ await this.#dropMirrorTriggers();
360
+ await this.#createTriggers(triggers);
361
+ if (resync) await conn.execute(`INSERT INTO ${quoteIdent(FTS_TABLE)}(${quoteIdent(FTS_TABLE)}) VALUES('rebuild')`);
362
+ await this.#owner.setMeta(MIRROR_META_KEY, hash);
363
+ }
364
+
365
+ async #dropMirrorTriggers() {
366
+ const conn = this.#owner.getConnection();
367
+ for (const [name] of this.#mirrorTriggers()) await conn.execute(`DROP TRIGGER IF EXISTS ${quoteIdent(name)}`);
368
+ }
369
+
370
+ async #ftsExists() {
371
+ const table = await this.#owner
372
+ .getConnection()
373
+ .prepare(`SELECT "name" FROM "sqlite_master" WHERE "type" = 'table' AND "name" = ?`)
374
+ .get<{ name: string }>(FTS_TABLE);
375
+ return !!table;
376
+ }
377
+
378
+ async #createTriggers(triggers: [string, string][]) {
379
+ const conn = this.#owner.getConnection();
380
+ for (const [name, sql] of triggers) {
381
+
382
+ await conn.execute(`CREATE TRIGGER IF NOT EXISTS ${quoteIdent(name)} ${sql}`);
383
+ }
384
+ }
385
+
386
+ async #dropModelTriggers(ref: string) {
387
+ const conn = this.#owner.getConnection();
388
+ for (const suffix of ["ai", "au", "soft", "ad"]) {
389
+ await conn.execute(`DROP TRIGGER IF EXISTS ${quoteIdent(`${ref}_search_${suffix}`)}`);
390
+ }
391
+ }
392
+
393
+ #modelTriggers(ref: string, next: SearchColumns, prev: SearchColumns | null): [string, string][] {
394
+ const table = quoteIdent(ref);
395
+ const doc = quoteIdent(DOC_TABLE);
396
+ const upsert = `INSERT INTO ${doc}("ref", "refId", ${SearchIndex.#docColumns})
397
+ VALUES ('${ref}', NEW."id", ${this.#columnList(next)}) ${SearchIndex.#upsertTail}`;
398
+ const purge = `DELETE FROM ${doc} WHERE "ref" = '${ref}' AND "refId" = %ID%`;
399
+
400
+ const changed = prev ? textFieldRoles.map((role) => `${next[role]} IS NOT ${prev[role]}`).join(" OR ") : "1 = 1";
401
+ const triggers: [string, string][] = [
402
+ [`${ref}_search_ai`, `AFTER INSERT ON ${table} WHEN NEW."removedAt" IS NULL BEGIN ${upsert}; END`],
403
+ [
404
+ `${ref}_search_au`,
405
+ `AFTER UPDATE ON ${table} WHEN NEW."removedAt" IS NULL AND (OLD."removedAt" IS NOT NULL OR (${changed}))
406
+ BEGIN ${upsert}; END`,
407
+ ],
408
+ [
409
+ `${ref}_search_soft`,
410
+ `AFTER UPDATE ON ${table} WHEN NEW."removedAt" IS NOT NULL AND OLD."removedAt" IS NULL
411
+ BEGIN ${purge.replace("%ID%", 'NEW."id"')}; END`,
412
+ ],
413
+ [`${ref}_search_ad`, `AFTER DELETE ON ${table} BEGIN ${purge.replace("%ID%", 'OLD."id"')}; END`],
414
+ ];
415
+ return triggers;
416
+ }
417
+
418
+ /**
419
+ * One conditional upsert rather than read-then-write inside `transaction()`: that helper detects nesting through
420
+ * AsyncLocalStorage, so a claim raised from an unrelated async context opens a second `BEGIN IMMEDIATE` on the
421
+ * same connection and one of the two dies. A single statement is atomic in SQLite, which is all a claim needs.
422
+ */
423
+ async #claimLock(ref: string) {
424
+ const now = Date.now();
425
+ const token = String(now);
426
+ const claimed = await this.#owner
427
+ .getConnection()
428
+ .prepare(
429
+ `INSERT INTO "_akan_meta" ("key", "value", "updatedAt") VALUES (?, ?, ?)
430
+ ON CONFLICT("key") DO UPDATE SET "value" = ?, "updatedAt" = ?
431
+ WHERE CAST("_akan_meta"."value" AS INTEGER) < ?
432
+ RETURNING "value"`,
433
+ )
434
+ .get<{ value: string }>(`${LOCK_META_PREFIX}${ref}`, token, now, token, now, now - LOCK_TTL_MS);
435
+ if (!claimed) return false;
436
+ this.#claims.set(ref, token);
437
+ return true;
438
+ }
439
+
440
+ /**
441
+ * Extends this process's claim, and reports `false` once someone else holds it. Renew and release both match on
442
+ * the stored token: an unconditional write would let a process that stalled past the TTL take the claim back
443
+ * from whoever legitimately replaced it, and then both would reconcile the same ref over each other.
444
+ */
445
+ async #renewLock(ref: string) {
446
+ const held = this.#claims.get(ref);
447
+ if (!held) return false;
448
+ const now = Date.now();
449
+ const token = String(now);
450
+ const renewed = await this.#owner
451
+ .getConnection()
452
+ .prepare(`UPDATE "_akan_meta" SET "value" = ?, "updatedAt" = ? WHERE "key" = ? AND "value" = ? RETURNING "value"`)
453
+ .get<{ value: string }>(token, now, `${LOCK_META_PREFIX}${ref}`, held);
454
+ if (!renewed) {
455
+ this.#claims.delete(ref);
456
+ return false;
457
+ }
458
+ this.#claims.set(ref, token);
459
+ return true;
460
+ }
461
+
462
+ async #releaseLock(ref: string) {
463
+ const held = this.#claims.get(ref);
464
+ if (!held) return;
465
+ this.#claims.delete(ref);
466
+ await this.#owner
467
+ .getConnection()
468
+ .execute(`DELETE FROM "_akan_meta" WHERE "key" = ? AND "value" = ?`, [`${LOCK_META_PREFIX}${ref}`, held]);
469
+ }
470
+
471
+ #buildColumns(constant: ConstantModel, database: DatabaseModel, alias: "NEW" | "OLD"): SearchColumns | null {
472
+ const paths = constant.full.text;
473
+ const fields = database.doc[FIELD_META] as unknown as FieldObject;
474
+ if (!paths || !fields) return null;
475
+ const columns = {} as SearchColumns;
476
+ let declared = false;
477
+ for (const role of textFieldRoles) {
478
+ const rolePaths = [...paths[role], ...paths.children[role]];
479
+ const parts = rolePaths
480
+ .map((path) => this.#roleExpression(fields, path, role, alias))
481
+ .filter((part): part is string => !!part);
482
+ if (parts.length) declared = true;
483
+ columns[role] = parts.length ? `TRIM(${parts.join(` || ' ' || `)})` : `''`;
484
+ }
485
+ return declared ? columns : null;
486
+ }
487
+
488
+ #roleExpression(fields: FieldObject, path: string, role: string, alias: "NEW" | "OLD") {
489
+ const doc = `${alias}.${quoteIdent("_doc")}`;
490
+ const segments = path.split(".");
491
+ let current: FieldObject | undefined = fields;
492
+ let arrayAt = -1;
493
+ for (const [idx, segment] of segments.entries()) {
494
+ const field: ConstantField | undefined = current?.[segment];
495
+ if (!field) return null;
496
+ if (field.arrDepth > 0 && arrayAt < 0) arrayAt = idx;
497
+ current = field.modelRef[FIELD_META] as unknown as FieldObject | undefined;
498
+ }
499
+
500
+ const wrap = (value: string) => (role === "filter" ? SearchIndex.#filterToken(path, value) : value);
501
+ const rows = (column: string, from: string, where = "") =>
502
+ `COALESCE((SELECT group_concat(${wrap(column)}, ' ') FROM ${from}${where}), '')`;
503
+ if (arrayAt < 0)
504
+ return `COALESCE(${wrap(`json_extract(${doc}, '${SearchIndex.#sqlString(jsonPath(path))}')`)}, '')`;
505
+ const array = `'${SearchIndex.#sqlString(jsonPath(segments.slice(0, arrayAt + 1).join(".")))}'`;
506
+ if (arrayAt === segments.length - 1) return rows("value", `json_each(${doc}, ${array})`);
507
+ const leaf = SearchIndex.#sqlString(segments[segments.length - 1]);
508
+ const tree = `json_tree(${doc}, ${array})`;
509
+
510
+ return rows(
511
+ `t."atom"`,
512
+ `${tree} AS t`,
513
+ ` WHERE t."atom" IS NOT NULL AND (t."key" = '${leaf}'
514
+ OR t."parent" IN (SELECT p."id" FROM ${tree} AS p WHERE p."key" = '${leaf}'))`,
515
+ );
516
+ }
517
+ }
@@ -0,0 +1,25 @@
1
+ export const quoteIdent = (identifier: string) => `"${identifier.replaceAll('"', '""')}"`;
2
+
3
+ export const jsonPath = (path: string) =>
4
+ `$.${path
5
+ .split(".")
6
+ .map((part) => part.replaceAll('"', '\\"'))
7
+ .join(".")}`;
8
+
9
+ export const stableJson = (value: unknown): string => {
10
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
11
+ if (value && typeof value === "object") {
12
+ return `{${Object.entries(value as Record<string, unknown>)
13
+ .sort(([a], [b]) => a.localeCompare(b))
14
+ .map(([key, val]) => `${JSON.stringify(key)}:${stableJson(val)}`)
15
+ .join(",")}}`;
16
+ }
17
+ return JSON.stringify(value);
18
+ };
19
+
20
+ /** Stable hash of a schema descriptor, stored in `_akan_meta` so a changed definition is detected without migrations. */
21
+ export const descriptorHash = async (value: unknown) => {
22
+ const bytes = new TextEncoder().encode(stableJson(value));
23
+ const hash = await crypto.subtle.digest("SHA-256", bytes);
24
+ return [...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
25
+ };
@@ -125,6 +125,38 @@ export class SignalContext<
125
125
  }),
126
126
  );
127
127
  }
128
+ /**
129
+ * Re-checks this context's guards outside of a request, for a websocket room that is already
130
+ * subscribed. Only global middlewares run: they carry the account resolution this depends on,
131
+ * while endpoint middlewares (cache/timeout/retry) would observe a call that never executes.
132
+ */
133
+ async authorize(): Promise<boolean> {
134
+ try {
135
+ await this.#withMiddleware(async () => await this.#checkGuards(), { endpointMiddlewares: false })();
136
+ return true;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+ #withMiddleware(
142
+ coreExec: () => Promise<unknown>,
143
+ { endpointMiddlewares = true }: { endpointMiddlewares?: boolean } = {},
144
+ ): () => Promise<unknown> {
145
+ const middlewares = [
146
+ ...this.#middleware.values(),
147
+ ...(endpointMiddlewares ? (this.endpointInfo.signalOption.middlewares ?? []) : []),
148
+ ];
149
+ if (middlewares.length === 0) return coreExec;
150
+ let next = coreExec;
151
+ for (let i = middlewares.length - 1; i >= 0; i--) {
152
+ const MiddlewareCls = middlewares[i];
153
+ if (!MiddlewareCls) continue;
154
+ const middleware = new MiddlewareCls();
155
+ const currentNext = next;
156
+ next = async () => await (await middleware.use(this.getEnv()))(this, currentNext);
157
+ }
158
+ return next;
159
+ }
128
160
  async exec() {
129
161
  if (!this.trace) return await this.#exec();
130
162
  return await runWithTrace(this.trace, async () => {
@@ -137,7 +169,6 @@ export class SignalContext<
137
169
  }
138
170
  async #exec() {
139
171
  if (!this.endpointInfo.execFn) throw new Exception.Error("Exec function is not set");
140
- const endpointMiddlewares = this.endpointInfo.signalOption.middlewares ?? [];
141
172
  const coreExec = async () => {
142
173
  if (!this.endpointInfo.execFn) throw new Exception.Error("Exec function is not set");
143
174
  if (this.trace) await traceSpan("guards", () => this.#checkGuards());
@@ -158,17 +189,7 @@ export class SignalContext<
158
189
  async () => await this.endpointInfo.execFn?.call(this.adaptor, ...this.args, ...this.internalArgs),
159
190
  );
160
191
  };
161
- let next = coreExec;
162
- if (this.#middleware.size > 0 || endpointMiddlewares.length > 0) {
163
- const middlewares = [...this.#middleware.values(), ...endpointMiddlewares];
164
- for (let i = middlewares.length - 1; i >= 0; i--) {
165
- const MiddlewareCls = middlewares[i];
166
- if (!MiddlewareCls) continue;
167
- const middleware = new MiddlewareCls();
168
- const currentNext = next;
169
- next = async () => await (await middleware.use(this.getEnv()))(this, currentNext);
170
- }
171
- }
192
+ const next = this.#withMiddleware(coreExec);
172
193
  const result = this.trace ? await traceSpan("execChain", () => next()) : await next();
173
194
  if (this.endpointInfo.type === "pubsub") return;
174
195
  if (result instanceof Response) return result;
@@ -340,6 +361,10 @@ export class SignalContext<
340
361
  if (this.transport !== "websocket") throw new Error("Transport is not websocket");
341
362
  return this.ctx as WebSocketExecutionContext<Appended>;
342
363
  }
364
+ get<T = unknown>(key: string): T | null {
365
+ if (this.transport === "http") return this.getHttpContext<{ [key: string]: T }>().req[key] ?? null;
366
+ return this.getWebSocketContext<{ [key: string]: T }>().ws.data[key] ?? null;
367
+ }
343
368
  getRoomId(key: string) {
344
369
  if (this.transport !== "websocket") throw new Error("Transport is not websocket");
345
370
  else if (this.endpointInfo.type !== "pubsub") throw new Error("Endpoint is not pubsub");
package/signal/types.ts CHANGED
@@ -140,4 +140,5 @@ export type WebsocketReqData = { key: string; data: unknown[]; subscribe?: boole
140
140
  export type WebsocketMessageData = { type: "msg"; key: string; data: object | object[] };
141
141
  export type WebsocketSubscribeAck = { type: "sub"; roomId: string; subscribe: boolean };
142
142
  export type WebsocketPublishData = { type: "pub"; roomId: string; data: object | object[] };
143
- export type WebsocketResData = WebsocketMessageData | WebsocketSubscribeAck | WebsocketPublishData;
143
+ export type WebsocketAuthAck = { type: "auth"; revokedRooms: string[] };
144
+ export type WebsocketResData = WebsocketMessageData | WebsocketSubscribeAck | WebsocketPublishData | WebsocketAuthAck;
@@ -43,6 +43,13 @@ export interface PageConfig {
43
43
  rscPatchHeadSafe?: boolean;
44
44
  topSafeAreaColor?: string;
45
45
  bottomSafeAreaColor?: string;
46
+ /**
47
+ * Keeps the route out of `akan build`. The route still serves under `akan start`, but nothing about it
48
+ * reaches production: no bundle, no manifest entry, no URL. On a `_layout`, every route under that
49
+ * directory is excluded with it. Must be written as a literal `true`/`false` — the build reads it from
50
+ * the source without evaluating the module.
51
+ */
52
+ devOnly?: boolean;
46
53
  }
47
54
  export interface CsrState {
48
55
  transition: TransitionType;
@@ -1,17 +1,25 @@
1
1
  declare global {
2
2
  var __AKAN_RSC_CLEAR_CACHE__: (() => void) | undefined;
3
+ var __AKAN_RSC_IS_FROM_CACHE__: (() => boolean) | undefined;
3
4
  var __AKAN_RSC_NAVIGATE__: ((href: string, options?: {
4
5
  replace?: boolean;
5
6
  scrollToTop?: boolean;
6
7
  }) => Promise<void>) | undefined;
7
8
  }
8
9
  export declare const clearRscNavigationCache: () => void;
10
+ /**
11
+ * True when the page tree currently on screen was replayed from the RSC navigation cache instead of
12
+ * fetched from the server. Data hydrated out of such a payload can be arbitrarily old, so anything
13
+ * that must show current values should refetch.
14
+ */
15
+ export declare const isRscNavigationFromCache: () => boolean;
9
16
  export declare const navigateRsc: (href: string, options?: {
10
17
  replace?: boolean;
11
18
  scrollToTop?: boolean;
12
19
  }) => Promise<void> | undefined;
13
20
  export declare const useRscNavigation: () => {
14
21
  clearCache: () => void;
22
+ isFromCache: () => boolean;
15
23
  navigate: (href: string, options?: {
16
24
  replace?: boolean;
17
25
  scrollToTop?: boolean;
@@ -27,3 +27,4 @@ export { sleep } from "./sleep.d.ts";
27
27
  export { splitVersion } from "./splitVersion.d.ts";
28
28
  export { getBasePathFromPathname, parseBasePaths } from "./subRoute.d.ts";
29
29
  export type * from "./types.d.ts";
30
+ export { type WebsocketAuthAckData, type WebsocketAuthRequest, websocketAuthContract, } from "./websocketAuth.d.ts";