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
|
@@ -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
|
+
};
|
package/signal/signalContext.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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;
|
package/types/common/index.d.ts
CHANGED
|
@@ -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";
|