akanjs 2.4.2-rc.0 → 2.4.2-rc.2
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/common/index.ts +1 -1
- package/common/subRoute.ts +61 -0
- package/constant/cascadePaths.ts +32 -0
- package/constant/fieldInfo.ts +11 -3
- package/constant/index.ts +3 -0
- package/constant/textFieldPathSet.ts +8 -0
- package/constant/textFieldPaths.ts +59 -0
- package/constant/types.ts +0 -4
- package/constant/via.ts +19 -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/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/devtools/types.ts +2 -2
- package/server/di/diLifecycle.ts +7 -1
- package/server/proxy/hostBasePathWebProxy.ts +16 -5
- package/server/resolver/database.resolver.ts +5 -6
- package/server/resolver/service.resolver.ts +27 -5
- package/server/webRouter.ts +27 -11
- 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/types/common/index.d.ts +1 -1
- package/types/common/subRoute.d.ts +19 -0
- package/types/constant/cascadePaths.d.ts +11 -0
- package/types/constant/fieldInfo.d.ts +8 -3
- package/types/constant/index.d.ts +3 -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 +8 -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/server/devtools/types.d.ts +2 -2
- package/types/server/resolver/service.resolver.d.ts +3 -3
- 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/ui/Constant/schemaDoc.d.ts +2 -2
- package/ui/Constant/schemaDoc.ts +8 -2
|
@@ -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/types/common/index.d.ts
CHANGED
|
@@ -25,6 +25,6 @@ export { randomPicks } from "./randomPicks.d.ts";
|
|
|
25
25
|
export { assertUniqueRoutePatterns, compareRouteSpecificity, isRouteSourceFile, isSpecialRouteLeaf, matchRoutePattern, normalizeRoutePattern, type ParsedRouteModuleKey, parseRouteModuleKey, type RouteModuleKind, routeSegmentToPatternPart, routeSegmentToTreePath, tryParseRouteModuleKey, type ValidatePageSourceFileOptions, type ValidateSubRoutePageKeyOptions, validatePageSourceFile, validateSubRoutePageKey, } from "./routeConvention.d.ts";
|
|
26
26
|
export { sleep } from "./sleep.d.ts";
|
|
27
27
|
export { splitVersion } from "./splitVersion.d.ts";
|
|
28
|
-
export { getBasePathFromPathname, parseBasePaths } from "./subRoute.d.ts";
|
|
28
|
+
export { getBasePathFromPathname, parseBasePaths, parseSubRouteHosts, resolveSubRouteHosts } from "./subRoute.d.ts";
|
|
29
29
|
export type * from "./types.d.ts";
|
|
30
30
|
export { type WebsocketAuthAckData, type WebsocketAuthRequest, websocketAuthContract, } from "./websocketAuth.d.ts";
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import type { AkanI18nConfig } from "./localeConfig.d.ts";
|
|
2
2
|
export declare const parseBasePaths: (value: string | string[] | Set<string> | undefined | null) => string[];
|
|
3
|
+
/**
|
|
4
|
+
* `"soft=a.com,b.com;office=c.com"` -> `{ soft: ["a.com", "b.com"], office: ["c.com"] }`. A deployment platform
|
|
5
|
+
* renders this value, so a malformed entry is skipped rather than thrown: one bad character must not CrashLoop
|
|
6
|
+
* every pod that received it.
|
|
7
|
+
*/
|
|
8
|
+
export declare const parseSubRouteHosts: (value: string | undefined | null) => Record<string, string[]>;
|
|
9
|
+
/**
|
|
10
|
+
* Unions the env mapping onto the one baked into the build artifact, never replacing it — dropping the env is the
|
|
11
|
+
* rollback path. A basePath the build does not serve is reported back instead of honoured: the route tree is a
|
|
12
|
+
* build output, so accepting one would answer every request under it with a 404 and nothing to explain why.
|
|
13
|
+
*/
|
|
14
|
+
export declare const resolveSubRouteHosts: ({ subRoutes, basePaths, env, }: {
|
|
15
|
+
subRoutes: Record<string, string[]>;
|
|
16
|
+
basePaths: Iterable<string>;
|
|
17
|
+
env?: string | null;
|
|
18
|
+
}) => {
|
|
19
|
+
subRoutes: Record<string, string[]>;
|
|
20
|
+
ignoredBasePaths: string[];
|
|
21
|
+
};
|
|
3
22
|
export declare const getBasePathFromPathname: (pathname: string, { basePaths, i18n, headerBasePath, }: {
|
|
4
23
|
basePaths: Iterable<string>;
|
|
5
24
|
i18n?: Pick<AkanI18nConfig, "locales" | "defaultLocale">;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FieldObject } from "./fieldInfo.d.ts";
|
|
2
|
+
import type { ConstantModelRef } from "./via.d.ts";
|
|
3
|
+
/** What happens to the documents a relation field points at when the owner is removed. */
|
|
4
|
+
export declare const cascadeActions: readonly ["remove"];
|
|
5
|
+
export type CascadeAction = (typeof cascadeActions)[number];
|
|
6
|
+
export declare class CascadePaths {
|
|
7
|
+
#private;
|
|
8
|
+
/** Field key → the model its ids point at. Resolved to a refName later: the target may not be registered yet. */
|
|
9
|
+
readonly remove: Map<string, ConstantModelRef>;
|
|
10
|
+
collect(fieldMap: FieldObject): this;
|
|
11
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type Any, CLIENT_VALUE, type Cls, type Dayjs, type EnumInstance, type Float, Int, type PrimitiveScalar, SERVER_VALUE, type SingleValue, type UnCls } from "akanjs/base";
|
|
2
|
+
import type { CascadeAction } from "./cascadePaths.d.ts";
|
|
3
|
+
import type { TextFieldRole } from "./textFieldPaths.d.ts";
|
|
2
4
|
import type { BaseObject } from "./types.d.ts";
|
|
3
5
|
import type { ConstantModelRef } from "./via.d.ts";
|
|
4
6
|
export type ParamFieldType = (typeof PrimitiveScalar & {
|
|
@@ -43,7 +45,8 @@ export interface ConstantFieldProps<FieldType extends ConstantFieldKind = Consta
|
|
|
43
45
|
example?: FieldValue;
|
|
44
46
|
of?: MapValue;
|
|
45
47
|
validate?: (value: FieldValue, model: any) => boolean;
|
|
46
|
-
text?:
|
|
48
|
+
text?: TextFieldRole;
|
|
49
|
+
cascade?: CascadeAction;
|
|
47
50
|
meta?: Metadata;
|
|
48
51
|
}
|
|
49
52
|
export declare const fieldPresets: readonly ["email", "password", "url"];
|
|
@@ -89,7 +92,8 @@ interface ConstantFieldBuildProps<FieldType extends ConstantFieldKind = any, Fie
|
|
|
89
92
|
example?: FieldValue;
|
|
90
93
|
of?: MapValue;
|
|
91
94
|
validate?: (value: FieldValue, model: any) => boolean;
|
|
92
|
-
text?:
|
|
95
|
+
text?: TextFieldRole;
|
|
96
|
+
cascade?: CascadeAction;
|
|
93
97
|
modelRef: ConstantModelRef;
|
|
94
98
|
arrDepth: number;
|
|
95
99
|
optArrDepth: number;
|
|
@@ -132,7 +136,8 @@ export declare class ConstantField<FieldType extends ConstantFieldKind = Constan
|
|
|
132
136
|
readonly example?: FieldValue;
|
|
133
137
|
readonly of?: MapValue;
|
|
134
138
|
readonly validate?: (value: FieldValue, model: any) => boolean;
|
|
135
|
-
readonly text?:
|
|
139
|
+
readonly text?: TextFieldRole;
|
|
140
|
+
readonly cascade?: CascadeAction;
|
|
136
141
|
readonly modelRef: ConstantModelRef;
|
|
137
142
|
readonly arrDepth: number;
|
|
138
143
|
readonly optArrDepth: number;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export * from "./cascadePaths.d.ts";
|
|
1
2
|
export * from "./constantRegistry.d.ts";
|
|
2
3
|
export * from "./crystalize.d.ts";
|
|
3
4
|
export * from "./deserialize.d.ts";
|
|
@@ -6,5 +7,7 @@ export * from "./getDefault.d.ts";
|
|
|
6
7
|
export * from "./immerify.d.ts";
|
|
7
8
|
export * from "./purify.d.ts";
|
|
8
9
|
export * from "./serialize.d.ts";
|
|
10
|
+
export * from "./textFieldPathSet.d.ts";
|
|
11
|
+
export * from "./textFieldPaths.d.ts";
|
|
9
12
|
export * from "./types.d.ts";
|
|
10
13
|
export * from "./via.d.ts";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The `search_doc` columns a model feeds, each holding the document paths that write it. */
|
|
2
|
+
export declare class TextFieldPathSet {
|
|
3
|
+
readonly title: Set<string>;
|
|
4
|
+
readonly desc: Set<string>;
|
|
5
|
+
readonly tag: Set<string>;
|
|
6
|
+
readonly thumb: Set<string>;
|
|
7
|
+
readonly filter: Set<string>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FieldObject } from "./fieldInfo.d.ts";
|
|
2
|
+
import { TextFieldPathSet } from "./textFieldPathSet.d.ts";
|
|
3
|
+
/** Column a `text` field feeds in the `search_doc` mirror. */
|
|
4
|
+
export declare const textFieldRoles: readonly ["title", "desc", "tag", "thumb", "filter"];
|
|
5
|
+
export type TextFieldRole = (typeof textFieldRoles)[number];
|
|
6
|
+
export declare class TextFieldPaths extends TextFieldPathSet {
|
|
7
|
+
#private;
|
|
8
|
+
readonly children: TextFieldPathSet;
|
|
9
|
+
collect(fieldMap: FieldObject): this;
|
|
10
|
+
}
|
|
@@ -67,9 +67,6 @@ export interface ProtoPatch {
|
|
|
67
67
|
at: Dayjs;
|
|
68
68
|
}
|
|
69
69
|
export declare const DEFAULT_PAGE_SIZE = 20;
|
|
70
|
-
export interface TextDoc {
|
|
71
|
-
[key: string]: string | TextDoc;
|
|
72
|
-
}
|
|
73
70
|
export type NonFunctionalKeys<T> = {
|
|
74
71
|
[K in keyof T]: T[K] extends (...args: never[]) => unknown ? never : K;
|
|
75
72
|
}[keyof T];
|