@wei840222/qmd 2026.8.23

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 (94) hide show
  1. package/CHANGELOG.md +1373 -0
  2. package/LICENSE +45 -0
  3. package/README.md +1439 -0
  4. package/THIRD_PARTY_NOTICES.md +31 -0
  5. package/bin/qmd +192 -0
  6. package/dist/ast.d.ts +65 -0
  7. package/dist/ast.js +334 -0
  8. package/dist/bench/bench.d.ts +35 -0
  9. package/dist/bench/bench.js +338 -0
  10. package/dist/bench/cjk-baseline.d.ts +36 -0
  11. package/dist/bench/cjk-baseline.js +111 -0
  12. package/dist/bench/fixture.d.ts +2 -0
  13. package/dist/bench/fixture.js +84 -0
  14. package/dist/bench/score.d.ts +38 -0
  15. package/dist/bench/score.js +107 -0
  16. package/dist/bench/types.d.ts +110 -0
  17. package/dist/bench/types.js +8 -0
  18. package/dist/cli/build-info.json +4 -0
  19. package/dist/cli/embed-lock.d.ts +24 -0
  20. package/dist/cli/embed-lock.js +94 -0
  21. package/dist/cli/embedding-owner.d.ts +10 -0
  22. package/dist/cli/embedding-owner.js +20 -0
  23. package/dist/cli/formatter.d.ts +120 -0
  24. package/dist/cli/formatter.js +355 -0
  25. package/dist/cli/mcp-pid.d.ts +25 -0
  26. package/dist/cli/mcp-pid.js +86 -0
  27. package/dist/cli/qmd.d.ts +72 -0
  28. package/dist/cli/qmd.js +4806 -0
  29. package/dist/cli/version.d.ts +42 -0
  30. package/dist/cli/version.js +80 -0
  31. package/dist/collections.d.ts +200 -0
  32. package/dist/collections.js +433 -0
  33. package/dist/db.d.ts +65 -0
  34. package/dist/db.js +143 -0
  35. package/dist/diagnostics.d.ts +62 -0
  36. package/dist/diagnostics.js +260 -0
  37. package/dist/embedding/config.d.ts +52 -0
  38. package/dist/embedding/config.js +229 -0
  39. package/dist/embedding/identity.d.ts +58 -0
  40. package/dist/embedding/identity.js +321 -0
  41. package/dist/embedding/local-identity.d.ts +1 -0
  42. package/dist/embedding/local-identity.js +15 -0
  43. package/dist/embedding/local.d.ts +34 -0
  44. package/dist/embedding/local.js +290 -0
  45. package/dist/embedding/openai.d.ts +79 -0
  46. package/dist/embedding/openai.js +477 -0
  47. package/dist/embedding/owner.d.ts +13 -0
  48. package/dist/embedding/owner.js +36 -0
  49. package/dist/embedding/provider.d.ts +68 -0
  50. package/dist/embedding/provider.js +16 -0
  51. package/dist/embedding/remote-chunking.d.ts +22 -0
  52. package/dist/embedding/remote-chunking.js +83 -0
  53. package/dist/embedding/remote-embedding.d.ts +15 -0
  54. package/dist/embedding/remote-embedding.js +77 -0
  55. package/dist/hybrid-llm.d.ts +18 -0
  56. package/dist/hybrid-llm.js +53 -0
  57. package/dist/index.d.ts +244 -0
  58. package/dist/index.js +418 -0
  59. package/dist/llm.d.ts +566 -0
  60. package/dist/llm.js +1847 -0
  61. package/dist/maintenance.d.ts +33 -0
  62. package/dist/maintenance.js +52 -0
  63. package/dist/mcp/origin-guard.d.ts +67 -0
  64. package/dist/mcp/origin-guard.js +137 -0
  65. package/dist/mcp/server.d.ts +116 -0
  66. package/dist/mcp/server.js +919 -0
  67. package/dist/paths.d.ts +1 -0
  68. package/dist/paths.js +4 -0
  69. package/dist/remote-llm.d.ts +52 -0
  70. package/dist/remote-llm.js +464 -0
  71. package/dist/search/cjk-analyzer.d.ts +33 -0
  72. package/dist/search/cjk-analyzer.js +158 -0
  73. package/dist/search/cjk-index.d.ts +104 -0
  74. package/dist/search/cjk-index.js +1031 -0
  75. package/dist/search/jieba-loader.d.ts +23 -0
  76. package/dist/search/jieba-loader.js +79 -0
  77. package/dist/search/query-expansion.d.ts +23 -0
  78. package/dist/search/query-expansion.js +43 -0
  79. package/dist/search/zh-dict.txt +624013 -0
  80. package/dist/store.d.ts +1218 -0
  81. package/dist/store.js +6076 -0
  82. package/dist/trust.d.ts +152 -0
  83. package/dist/trust.js +249 -0
  84. package/package.json +139 -0
  85. package/scripts/build.mjs +83 -0
  86. package/scripts/check-package-grammars.mjs +29 -0
  87. package/scripts/package-smoke.mjs +205 -0
  88. package/scripts/sync-zh-dict.mjs +187 -0
  89. package/scripts/test-all.mjs +45 -0
  90. package/skills/qmd/SKILL.md +324 -0
  91. package/skills/qmd/references/mcp-setup.md +119 -0
  92. package/skills/release/SKILL.md +141 -0
  93. package/skills/release/scripts/install-hooks.sh +38 -0
  94. package/skills/release/scripts/release-context.sh +129 -0
@@ -0,0 +1,1031 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { analyzeCjk, analyzeCjkWithCapability, CJK_ANALYZER_POLICY_VERSIONS, } from "./cjk-analyzer.js";
5
+ import { createJiebaUnavailableCapability, loadJiebaCapability, loadJiebaCapabilitySync, } from "./jieba-loader.js";
6
+ import { openDatabase } from "../db.js";
7
+ const CJK_INDEX_SCHEMA_VERSION = "3";
8
+ const DEFAULT_LEASE_MS = 60_000;
9
+ const BUILD_BATCH_SIZE = 200;
10
+ const BUILD_BATCH_BYTE_BUDGET = 8 * 1024 * 1024;
11
+ const IDENTIFIER_PATTERN = /^[a-z][a-z0-9_]*$/;
12
+ const ZH_DICTIONARY_VERSION = "zh-dict-v1";
13
+ const ZH_DICTIONARY_SHA256 = createHash("sha256")
14
+ .update(readFileSync(new URL("./zh-dict.txt", import.meta.url)))
15
+ .digest("hex");
16
+ function resolveJiebaVersion() {
17
+ try {
18
+ const require = createRequire(import.meta.url);
19
+ const manifest = require("@node-rs/jieba/package.json");
20
+ return typeof manifest.version === "string" ? manifest.version : "unknown";
21
+ }
22
+ catch {
23
+ return "unresolved";
24
+ }
25
+ }
26
+ export const CJK_ANALYZER_FINGERPRINT_SOURCE = Object.freeze({
27
+ analyzerVersion: CJK_ANALYZER_POLICY_VERSIONS.analyzer,
28
+ charPolicy: CJK_ANALYZER_POLICY_VERSIONS.char,
29
+ wordBoundaryPolicy: CJK_ANALYZER_POLICY_VERSIONS.wordBoundary,
30
+ wordEligibilityPolicy: CJK_ANALYZER_POLICY_VERSIONS.wordEligibility,
31
+ bigramPolicy: CJK_ANALYZER_POLICY_VERSIONS.bigram,
32
+ jiebaPackage: "@node-rs/jieba",
33
+ jiebaVersion: resolveJiebaVersion(),
34
+ jiebaHmm: "false",
35
+ dictionaryVersion: ZH_DICTIONARY_VERSION,
36
+ dictionarySha256: ZH_DICTIONARY_SHA256,
37
+ userDictionarySha256: "none",
38
+ });
39
+ export function computeCjkAnalyzerFingerprint(source) {
40
+ const canonical = Object.entries(source).sort(([left], [right]) => left.localeCompare(right));
41
+ return createHash("sha256").update(JSON.stringify(canonical), "utf8").digest("hex");
42
+ }
43
+ export function getCjkAnalyzerFingerprint(userDictContent) {
44
+ let userDictionarySha256 = "none";
45
+ if (userDictContent) {
46
+ userDictionarySha256 = createHash("sha256").update(userDictContent).digest("hex");
47
+ }
48
+ return computeCjkAnalyzerFingerprint({
49
+ ...CJK_ANALYZER_FINGERPRINT_SOURCE,
50
+ userDictionarySha256,
51
+ });
52
+ }
53
+ function quoteIdentifier(identifier) {
54
+ if (!IDENTIFIER_PATTERN.test(identifier)) {
55
+ throw new Error("Invalid CJK index table identifier");
56
+ }
57
+ return `"${identifier}"`;
58
+ }
59
+ function getMutationHead(db) {
60
+ const row = db.prepare(`SELECT COALESCE(MAX(seq), 0) AS seq FROM cjk_index_mutations`).get();
61
+ return Number(row.seq);
62
+ }
63
+ function processIsAlive(pid) {
64
+ if (!Number.isSafeInteger(pid) || pid <= 0)
65
+ return false;
66
+ try {
67
+ process.kill(pid, 0);
68
+ return true;
69
+ }
70
+ catch (error) {
71
+ return error.code === "EPERM";
72
+ }
73
+ }
74
+ /**
75
+ * Return the kernel process start-time token used to distinguish a live build
76
+ * owner from an unrelated process that later reused the same PID. Linux exposes
77
+ * this as field 22 in /proc/<pid>/stat. Other platforms conservatively return
78
+ * null, preserving legacy PID-only cleanup rather than risking a live owner.
79
+ */
80
+ export function getProcessStartToken(pid = process.pid) {
81
+ if (process.platform !== "linux" || !Number.isSafeInteger(pid) || pid <= 0)
82
+ return null;
83
+ try {
84
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
85
+ const commandEnd = stat.lastIndexOf(")");
86
+ if (commandEnd < 0)
87
+ return null;
88
+ const fieldsFromState = stat.slice(commandEnd + 1).trim().split(/\s+/u);
89
+ const startTime = fieldsFromState[19];
90
+ return startTime ? `linux:${startTime}` : null;
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ function registeredOwnerIsAlive(row) {
97
+ if (!processIsAlive(row.owner_pid))
98
+ return false;
99
+ if (row.owner_start_token === null)
100
+ return true;
101
+ const currentToken = getProcessStartToken(row.owner_pid);
102
+ return currentToken === null || currentToken === row.owner_start_token;
103
+ }
104
+ function dropRegisteredTable(db, name) {
105
+ if (!IDENTIFIER_PATTERN.test(name))
106
+ return;
107
+ db.exec(`DROP TABLE IF EXISTS ${quoteIdentifier(name)}`);
108
+ }
109
+ function validateBuildTableNames(db, row) {
110
+ if (!/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/u.test(row.build_id)) {
111
+ throw new Error("Invalid CJK build table identifier");
112
+ }
113
+ const suffix = row.build_id.replaceAll("-", "").slice(0, 20);
114
+ if (row.words_table !== `documents_fts_words_build_${suffix}`
115
+ || row.bigrams_table !== `documents_fts_bigrams_build_${suffix}`) {
116
+ throw new Error("Invalid CJK build table identifier");
117
+ }
118
+ const conflictingOwner = db.prepare(`
119
+ SELECT 1
120
+ FROM cjk_index_builds
121
+ WHERE build_id <> ?
122
+ AND (
123
+ words_table IN (?, ?) OR bigrams_table IN (?, ?)
124
+ OR retired_words_table IN (?, ?) OR retired_bigrams_table IN (?, ?)
125
+ )
126
+ LIMIT 1
127
+ `).get(row.build_id, row.words_table, row.bigrams_table, row.words_table, row.bigrams_table, row.words_table, row.bigrams_table, row.words_table, row.bigrams_table);
128
+ if (conflictingOwner != null) {
129
+ throw new Error("CJK build table is registered to another build");
130
+ }
131
+ }
132
+ function validateRetiredTableNames(db, row) {
133
+ if (!/^documents_fts_words_old_[a-f0-9]+$/u.test(row.retired_words_table)
134
+ || !/^documents_fts_bigrams_old_[a-f0-9]+$/u.test(row.retired_bigrams_table)) {
135
+ throw new Error("Invalid retired CJK index table identifier");
136
+ }
137
+ const conflictingOwner = db.prepare(`
138
+ SELECT 1
139
+ FROM cjk_index_builds
140
+ WHERE build_id <> ?
141
+ AND (
142
+ words_table IN (?, ?) OR bigrams_table IN (?, ?)
143
+ OR retired_words_table IN (?, ?) OR retired_bigrams_table IN (?, ?)
144
+ )
145
+ LIMIT 1
146
+ `).get(row.build_id, row.retired_words_table, row.retired_bigrams_table, row.retired_words_table, row.retired_bigrams_table, row.retired_words_table, row.retired_bigrams_table, row.retired_words_table, row.retired_bigrams_table);
147
+ if (conflictingOwner != null) {
148
+ throw new Error("Retired CJK index table is registered to another build");
149
+ }
150
+ }
151
+ /**
152
+ * Reclaim a bounded number of atomically registered retired FTS table pairs.
153
+ * Each pair is dropped and its tombstone cleared in one transaction, so a
154
+ * crash leaves either a fully discoverable pair or a fully reclaimed pair.
155
+ */
156
+ export function cleanupRetiredCjkIndexTables(db, options = {}) {
157
+ const limit = options.limit ?? 1;
158
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
159
+ throw new Error("CJK retired table cleanup limit must be an integer from 1 to 100");
160
+ }
161
+ const candidates = db.prepare(`
162
+ SELECT build_id
163
+ FROM cjk_index_builds
164
+ WHERE state = 'ready'
165
+ AND (retired_words_table IS NOT NULL OR retired_bigrams_table IS NOT NULL)
166
+ ORDER BY updated_at, build_id
167
+ LIMIT ?
168
+ `).all(limit);
169
+ const cleaned = [];
170
+ for (const candidate of candidates) {
171
+ db.exec("BEGIN IMMEDIATE");
172
+ try {
173
+ const row = db.prepare(`
174
+ SELECT build_id, retired_words_table, retired_bigrams_table
175
+ FROM cjk_index_builds
176
+ WHERE build_id = ? AND state = 'ready'
177
+ AND (retired_words_table IS NOT NULL OR retired_bigrams_table IS NOT NULL)
178
+ `).get(candidate.build_id);
179
+ if (!row) {
180
+ db.exec("COMMIT");
181
+ continue;
182
+ }
183
+ if (row.retired_words_table === null || row.retired_bigrams_table === null) {
184
+ throw new Error("Incomplete retired CJK index table tombstone");
185
+ }
186
+ const completeRow = {
187
+ build_id: row.build_id,
188
+ retired_words_table: row.retired_words_table,
189
+ retired_bigrams_table: row.retired_bigrams_table,
190
+ };
191
+ validateRetiredTableNames(db, completeRow);
192
+ db.exec(`
193
+ DROP TABLE IF EXISTS ${quoteIdentifier(completeRow.retired_words_table)};
194
+ DROP TABLE IF EXISTS ${quoteIdentifier(completeRow.retired_bigrams_table)};
195
+ `);
196
+ const cleared = db.prepare(`
197
+ UPDATE cjk_index_builds
198
+ SET retired_words_table = NULL, retired_bigrams_table = NULL,
199
+ updated_at = ?
200
+ WHERE build_id = ? AND state = 'ready'
201
+ AND retired_words_table = ? AND retired_bigrams_table = ?
202
+ `).run(Date.now(), completeRow.build_id, completeRow.retired_words_table, completeRow.retired_bigrams_table);
203
+ if (Number(cleared.changes) !== 1) {
204
+ throw new Error("Retired CJK index table ownership changed during cleanup");
205
+ }
206
+ db.exec("COMMIT");
207
+ cleaned.push(completeRow.build_id);
208
+ }
209
+ catch (error) {
210
+ try {
211
+ db.exec("ROLLBACK");
212
+ }
213
+ catch { }
214
+ throw error;
215
+ }
216
+ }
217
+ return cleaned;
218
+ }
219
+ function cleanupExpiredBuildsWithinTransaction(db, nowMs) {
220
+ const rows = db.prepare(`
221
+ SELECT build_id, state, owner_pid, owner_start_token, lease_expires_at,
222
+ words_table, bigrams_table
223
+ FROM cjk_index_builds
224
+ WHERE state = 'building' AND lease_expires_at < ?
225
+ ORDER BY started_at, build_id
226
+ `).all(nowMs);
227
+ const cleaned = [];
228
+ for (const row of rows) {
229
+ if (registeredOwnerIsAlive(row))
230
+ continue;
231
+ validateBuildTableNames(db, row);
232
+ dropRegisteredTable(db, row.words_table);
233
+ dropRegisteredTable(db, row.bigrams_table);
234
+ db.prepare(`
235
+ UPDATE cjk_index_builds
236
+ SET state = 'abandoned', updated_at = ?
237
+ WHERE build_id = ? AND state = 'building'
238
+ `).run(nowMs, row.build_id);
239
+ db.prepare(`
240
+ UPDATE cjk_index_state
241
+ SET active_build_id = NULL,
242
+ status = CASE
243
+ WHEN dirty_since_mutation_seq IS NOT NULL THEN 'dirty'
244
+ WHEN published_build_id IS NULL THEN 'empty'
245
+ ELSE 'ready'
246
+ END,
247
+ updated_at = ?
248
+ WHERE singleton = 1 AND active_build_id = ?
249
+ `).run(nowMs, row.build_id);
250
+ cleaned.push(row.build_id);
251
+ }
252
+ return cleaned;
253
+ }
254
+ export function cleanupExpiredCjkIndexBuilds(db, options = {}) {
255
+ const nowMs = options.nowMs ?? Date.now();
256
+ const candidate = db.prepare(`
257
+ SELECT 1 FROM cjk_index_builds
258
+ WHERE state = 'building' AND lease_expires_at < ?
259
+ LIMIT 1
260
+ `).get(nowMs);
261
+ if (candidate == null)
262
+ return [];
263
+ db.exec("BEGIN IMMEDIATE");
264
+ try {
265
+ const cleaned = cleanupExpiredBuildsWithinTransaction(db, nowMs);
266
+ db.exec("COMMIT");
267
+ return cleaned;
268
+ }
269
+ catch (error) {
270
+ db.exec("ROLLBACK");
271
+ throw error;
272
+ }
273
+ }
274
+ export function initializeCjkLexicalIndexSchema(db) {
275
+ const current = db.prepare(`SELECT value FROM store_config WHERE key = 'cjk_index_schema_version'`).get();
276
+ if (current?.value === CJK_INDEX_SCHEMA_VERSION)
277
+ return;
278
+ db.exec("BEGIN IMMEDIATE");
279
+ try {
280
+ const checked = db.prepare(`SELECT value FROM store_config WHERE key = 'cjk_index_schema_version'`).get();
281
+ if (checked?.value !== CJK_INDEX_SCHEMA_VERSION) {
282
+ db.exec(`
283
+ CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts_words USING fts5(
284
+ filepath, title, body,
285
+ tokenize='unicode61'
286
+ );
287
+ CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts_bigrams USING fts5(
288
+ filepath, title, body,
289
+ tokenize='unicode61'
290
+ );
291
+ CREATE TABLE IF NOT EXISTS cjk_index_state (
292
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
293
+ status TEXT NOT NULL CHECK (status IN ('empty', 'building', 'ready', 'unavailable', 'dirty')),
294
+ generation INTEGER NOT NULL DEFAULT 0,
295
+ analyzer_fingerprint TEXT,
296
+ word_capability TEXT NOT NULL DEFAULT 'unknown'
297
+ CHECK (word_capability IN ('unknown', 'available', 'unavailable')),
298
+ diagnostic_code TEXT,
299
+ active_build_id TEXT,
300
+ published_build_id TEXT,
301
+ dirty_since_mutation_seq INTEGER,
302
+ updated_at INTEGER NOT NULL DEFAULT 0
303
+ );
304
+ CREATE TABLE IF NOT EXISTS cjk_index_builds (
305
+ build_id TEXT PRIMARY KEY,
306
+ state TEXT NOT NULL CHECK (state IN ('building', 'ready', 'failed', 'abandoned')),
307
+ base_mutation_seq INTEGER NOT NULL,
308
+ applied_mutation_seq INTEGER NOT NULL,
309
+ analyzer_fingerprint TEXT NOT NULL,
310
+ owner_pid INTEGER NOT NULL,
311
+ owner_start_token TEXT,
312
+ lease_expires_at INTEGER NOT NULL,
313
+ words_table TEXT NOT NULL,
314
+ bigrams_table TEXT NOT NULL,
315
+ retired_words_table TEXT,
316
+ retired_bigrams_table TEXT,
317
+ started_at INTEGER NOT NULL,
318
+ updated_at INTEGER NOT NULL
319
+ );
320
+ CREATE TABLE IF NOT EXISTS cjk_index_mutations (
321
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
322
+ document_id INTEGER NOT NULL,
323
+ operation TEXT NOT NULL CHECK (operation IN ('upsert', 'delete'))
324
+ );
325
+ CREATE INDEX IF NOT EXISTS idx_cjk_index_mutations_document
326
+ ON cjk_index_mutations(document_id, seq);
327
+ INSERT OR IGNORE INTO cjk_index_state(singleton, status)
328
+ VALUES (1, 'empty');
329
+
330
+ `);
331
+ const stateColumns = db.prepare(`PRAGMA table_info(cjk_index_state)`).all();
332
+ if (!stateColumns.some(column => column.name === "dirty_since_mutation_seq")) {
333
+ db.exec(`ALTER TABLE cjk_index_state ADD COLUMN dirty_since_mutation_seq INTEGER`);
334
+ }
335
+ const buildColumns = db.prepare(`PRAGMA table_info(cjk_index_builds)`).all();
336
+ if (!buildColumns.some(column => column.name === "owner_start_token")) {
337
+ db.exec(`ALTER TABLE cjk_index_builds ADD COLUMN owner_start_token TEXT`);
338
+ }
339
+ if (!buildColumns.some(column => column.name === "retired_words_table")) {
340
+ db.exec(`ALTER TABLE cjk_index_builds ADD COLUMN retired_words_table TEXT`);
341
+ }
342
+ if (!buildColumns.some(column => column.name === "retired_bigrams_table")) {
343
+ db.exec(`ALTER TABLE cjk_index_builds ADD COLUMN retired_bigrams_table TEXT`);
344
+ }
345
+ db.exec(`
346
+ DROP TRIGGER IF EXISTS documents_cjk_journal_ai;
347
+ CREATE TRIGGER documents_cjk_journal_ai AFTER INSERT ON documents
348
+ BEGIN
349
+ INSERT INTO cjk_index_mutations(document_id, operation)
350
+ VALUES (new.id, CASE WHEN new.active = 1 THEN 'upsert' ELSE 'delete' END);
351
+ DELETE FROM documents_fts_words WHERE rowid = new.id;
352
+ DELETE FROM documents_fts_bigrams WHERE rowid = new.id;
353
+ UPDATE cjk_index_state
354
+ SET status = 'dirty', diagnostic_code = 'CJK_INDEX_RAW_WRITE',
355
+ dirty_since_mutation_seq = COALESCE(
356
+ dirty_since_mutation_seq,
357
+ (SELECT MAX(seq) FROM cjk_index_mutations)
358
+ ),
359
+ updated_at = unixepoch('subsec') * 1000
360
+ WHERE singleton = 1;
361
+ END;
362
+
363
+ DROP TRIGGER IF EXISTS documents_cjk_journal_au;
364
+ CREATE TRIGGER documents_cjk_journal_au
365
+ AFTER UPDATE OF collection, path, title, hash, active ON documents
366
+ WHEN old.collection IS NOT new.collection
367
+ OR old.path IS NOT new.path
368
+ OR old.title IS NOT new.title
369
+ OR old.hash IS NOT new.hash
370
+ OR old.active IS NOT new.active
371
+ BEGIN
372
+ INSERT INTO cjk_index_mutations(document_id, operation)
373
+ VALUES (new.id, CASE WHEN new.active = 1 THEN 'upsert' ELSE 'delete' END);
374
+ DELETE FROM documents_fts_words WHERE rowid = new.id;
375
+ DELETE FROM documents_fts_bigrams WHERE rowid = new.id;
376
+ UPDATE cjk_index_state
377
+ SET status = 'dirty', diagnostic_code = 'CJK_INDEX_RAW_WRITE',
378
+ dirty_since_mutation_seq = COALESCE(
379
+ dirty_since_mutation_seq,
380
+ (SELECT MAX(seq) FROM cjk_index_mutations)
381
+ ),
382
+ updated_at = unixepoch('subsec') * 1000
383
+ WHERE singleton = 1;
384
+ END;
385
+
386
+ DROP TRIGGER IF EXISTS documents_cjk_journal_ad;
387
+ CREATE TRIGGER documents_cjk_journal_ad AFTER DELETE ON documents
388
+ BEGIN
389
+ INSERT INTO cjk_index_mutations(document_id, operation)
390
+ VALUES (old.id, 'delete');
391
+ DELETE FROM documents_fts_words WHERE rowid = old.id;
392
+ DELETE FROM documents_fts_bigrams WHERE rowid = old.id;
393
+ UPDATE cjk_index_state
394
+ SET status = 'dirty', diagnostic_code = 'CJK_INDEX_RAW_WRITE',
395
+ dirty_since_mutation_seq = COALESCE(
396
+ dirty_since_mutation_seq,
397
+ (SELECT MAX(seq) FROM cjk_index_mutations)
398
+ ),
399
+ updated_at = unixepoch('subsec') * 1000
400
+ WHERE singleton = 1;
401
+ END;
402
+ `);
403
+ db.prepare(`
404
+ INSERT OR REPLACE INTO store_config(key, value)
405
+ VALUES ('cjk_index_schema_version', ?)
406
+ `).run(CJK_INDEX_SCHEMA_VERSION);
407
+ }
408
+ db.exec("COMMIT");
409
+ }
410
+ catch (error) {
411
+ db.exec("ROLLBACK");
412
+ throw error;
413
+ }
414
+ }
415
+ export function getCjkLexicalIndexState(db) {
416
+ const row = db.prepare(`
417
+ SELECT status, generation, analyzer_fingerprint, word_capability,
418
+ diagnostic_code, active_build_id, published_build_id,
419
+ dirty_since_mutation_seq, updated_at
420
+ FROM cjk_index_state WHERE singleton = 1
421
+ `).get();
422
+ if (!row)
423
+ throw new Error("CJK lexical index schema is not initialized");
424
+ return {
425
+ status: row.status,
426
+ generation: Number(row.generation),
427
+ analyzerFingerprint: row.analyzer_fingerprint,
428
+ wordCapability: row.word_capability,
429
+ diagnosticCode: row.diagnostic_code,
430
+ activeBuildId: row.active_build_id,
431
+ publishedBuildId: row.published_build_id,
432
+ dirtySinceMutationSeq: row.dirty_since_mutation_seq == null
433
+ ? null
434
+ : Number(row.dirty_since_mutation_seq),
435
+ updatedAt: Number(row.updated_at),
436
+ };
437
+ }
438
+ /**
439
+ * Repair the char-only fallback rows affected by raw document writes.
440
+ * Word/bigram rows and the dirty state intentionally remain gated until a
441
+ * complete analyzer rebuild publishes the journal head.
442
+ */
443
+ export function repairDirtyCjkCharFallback(db) {
444
+ const schema = db.prepare(`
445
+ SELECT COUNT(*) AS count
446
+ FROM sqlite_master
447
+ WHERE type = 'table' AND name IN ('cjk_index_state', 'cjk_index_mutations')
448
+ `).get();
449
+ if (Number(schema.count) !== 2)
450
+ return 0;
451
+ const repair = db.transaction(() => {
452
+ const state = getCjkLexicalIndexState(db);
453
+ if (state.status !== "dirty" || state.dirtySinceMutationSeq === null)
454
+ return 0;
455
+ const targetMutationSeq = getMutationHead(db);
456
+ const documentIds = db.prepare(`
457
+ SELECT DISTINCT document_id
458
+ FROM cjk_index_mutations
459
+ WHERE seq >= ? AND seq <= ?
460
+ ORDER BY document_id
461
+ `).all(state.dirtySinceMutationSeq, targetMutationSeq)
462
+ .map(row => Number(row.document_id));
463
+ const deleteChar = db.prepare(`DELETE FROM documents_fts WHERE rowid = ?`);
464
+ const insertChar = db.prepare(`
465
+ INSERT INTO documents_fts(rowid, filepath, title, body)
466
+ VALUES (?, ?, ?, ?)
467
+ `);
468
+ const fallbackCapability = createJiebaUnavailableCapability();
469
+ for (const documentId of documentIds) {
470
+ deleteChar.run(documentId);
471
+ const source = sourceRowForDocument(db, documentId);
472
+ if (!source)
473
+ continue;
474
+ const filepath = analyzeCjkWithCapability(`${source.collection}/${source.path}`, fallbackCapability);
475
+ const title = analyzeCjkWithCapability(source.title, fallbackCapability);
476
+ const body = analyzeCjkWithCapability(source.body, fallbackCapability);
477
+ insertChar.run(documentId, filepath.char, title.char, body.char);
478
+ }
479
+ return documentIds.length;
480
+ });
481
+ return Number(repair());
482
+ }
483
+ /**
484
+ * Commit a document/collection mutation and all published CJK lexical signals
485
+ * at one SQLite transaction boundary. If the published index is not safe to
486
+ * update (missing, rebuilding, stale fingerprint), raw triggers remove stale
487
+ * word/bigram rows and leave an explicit dirty marker for maintenance rebuild.
488
+ */
489
+ export function runCjkSynchronizedMutation(db, mutation) {
490
+ const synchronized = db.transaction(() => {
491
+ const before = getCjkLexicalIndexState(db);
492
+ const beforeMutationSeq = getMutationHead(db);
493
+ const result = mutation();
494
+ const targetMutationSeq = getMutationHead(db);
495
+ if (targetMutationSeq === beforeMutationSeq)
496
+ return result;
497
+ const canSynchronize = before.status === "ready"
498
+ && before.analyzerFingerprint === getCjkAnalyzerFingerprint()
499
+ && before.wordCapability === "available"
500
+ && before.activeBuildId === null
501
+ && before.publishedBuildId !== null;
502
+ if (!canSynchronize)
503
+ return result;
504
+ const capability = loadJiebaCapabilitySync();
505
+ if (!capability.available) {
506
+ throw new Error(`${capability.diagnostic.code}: ${capability.diagnostic.message} ${capability.diagnostic.remediation}`);
507
+ }
508
+ const mutationIds = db.prepare(`
509
+ SELECT DISTINCT document_id
510
+ FROM cjk_index_mutations
511
+ WHERE seq > ? AND seq <= ?
512
+ ORDER BY document_id
513
+ `).all(beforeMutationSeq, targetMutationSeq)
514
+ .map(row => Number(row.document_id));
515
+ const deleteChars = db.prepare(`DELETE FROM documents_fts WHERE rowid = ?`);
516
+ const deleteWords = db.prepare(`DELETE FROM documents_fts_words WHERE rowid = ?`);
517
+ const deleteBigrams = db.prepare(`DELETE FROM documents_fts_bigrams WHERE rowid = ?`);
518
+ const insertChars = db.prepare(`
519
+ INSERT INTO documents_fts(rowid, filepath, title, body)
520
+ VALUES (?, ?, ?, ?)
521
+ `);
522
+ const insertWords = db.prepare(`
523
+ INSERT INTO documents_fts_words(rowid, filepath, title, body)
524
+ VALUES (?, ?, ?, ?)
525
+ `);
526
+ const insertBigrams = db.prepare(`
527
+ INSERT INTO documents_fts_bigrams(rowid, filepath, title, body)
528
+ VALUES (?, ?, ?, ?)
529
+ `);
530
+ for (const documentId of mutationIds) {
531
+ deleteChars.run(documentId);
532
+ deleteWords.run(documentId);
533
+ deleteBigrams.run(documentId);
534
+ const source = sourceRowForDocument(db, documentId);
535
+ if (!source)
536
+ continue;
537
+ const filepath = analyzeCjkWithCapability(`${source.collection}/${source.path}`, capability);
538
+ const title = analyzeCjkWithCapability(source.title, capability);
539
+ const body = analyzeCjkWithCapability(source.body, capability);
540
+ if (!filepath.wordCapability.available || !title.wordCapability.available || !body.wordCapability.available) {
541
+ throw new Error("CJK_JIEBA_MUTATION_FAILED: Chinese word segmentation failed during mutation");
542
+ }
543
+ insertChars.run(documentId, filepath.char, title.char, body.char);
544
+ insertWords.run(documentId, filepath.word, title.word, body.word);
545
+ insertBigrams.run(documentId, filepath.bigram, title.bigram, body.bigram);
546
+ }
547
+ db.prepare(`
548
+ UPDATE cjk_index_state
549
+ SET status = 'ready', generation = ?, analyzer_fingerprint = ?,
550
+ word_capability = 'available', diagnostic_code = NULL,
551
+ dirty_since_mutation_seq = NULL, updated_at = ?
552
+ WHERE singleton = 1
553
+ `).run(targetMutationSeq, getCjkAnalyzerFingerprint(), Date.now());
554
+ return result;
555
+ });
556
+ return synchronized();
557
+ }
558
+ function createShadowTables(db, wordsTable, bigramsTable) {
559
+ db.exec(`
560
+ CREATE VIRTUAL TABLE ${quoteIdentifier(wordsTable)} USING fts5(
561
+ filepath, title, body,
562
+ tokenize='unicode61'
563
+ );
564
+ CREATE VIRTUAL TABLE ${quoteIdentifier(bigramsTable)} USING fts5(
565
+ filepath, title, body,
566
+ tokenize='unicode61'
567
+ )
568
+ `);
569
+ }
570
+ function renewLease(db, build) {
571
+ const nowMs = Date.now();
572
+ const result = db.prepare(`
573
+ UPDATE cjk_index_builds
574
+ SET lease_expires_at = ?, updated_at = ?
575
+ WHERE build_id = ? AND state = 'building'
576
+ AND owner_pid = ? AND owner_start_token IS ?
577
+ `).run(nowMs + build.leaseDurationMs, nowMs, build.buildId, process.pid, build.ownerStartToken);
578
+ if (Number(result.changes) !== 1)
579
+ throw new Error("CJK rebuild lease ownership was lost");
580
+ }
581
+ async function resolveCapability(loader) {
582
+ try {
583
+ return await loader();
584
+ }
585
+ catch {
586
+ return createJiebaUnavailableCapability();
587
+ }
588
+ }
589
+ function acquireBuild(db, capability, options) {
590
+ const nowMs = Date.now();
591
+ const fingerprint = getCjkAnalyzerFingerprint();
592
+ const leaseDurationMs = Math.max(1_000, options.leaseDurationMs ?? DEFAULT_LEASE_MS);
593
+ db.exec("BEGIN IMMEDIATE");
594
+ try {
595
+ cleanupExpiredBuildsWithinTransaction(db, nowMs);
596
+ const state = getCjkLexicalIndexState(db);
597
+ if (state.activeBuildId) {
598
+ const active = db.prepare(`
599
+ SELECT build_id FROM cjk_index_builds
600
+ WHERE build_id = ? AND state = 'building'
601
+ `).get(state.activeBuildId);
602
+ if (active) {
603
+ db.exec("COMMIT");
604
+ return { status: "busy", buildId: active.build_id };
605
+ }
606
+ db.prepare(`UPDATE cjk_index_state SET active_build_id = NULL WHERE singleton = 1`).run();
607
+ }
608
+ if (!capability.available) {
609
+ db.prepare(`
610
+ UPDATE cjk_index_state
611
+ SET status = 'unavailable', word_capability = 'unavailable',
612
+ diagnostic_code = ?, active_build_id = NULL, updated_at = ?
613
+ WHERE singleton = 1
614
+ `).run(capability.diagnostic.code, nowMs);
615
+ db.exec("COMMIT");
616
+ return {
617
+ status: "unavailable",
618
+ diagnostic: capability.diagnostic,
619
+ publishedBuildId: state.publishedBuildId,
620
+ };
621
+ }
622
+ if (!options.force && state.status === "ready" && state.analyzerFingerprint === fingerprint && state.publishedBuildId) {
623
+ db.exec("COMMIT");
624
+ return {
625
+ status: "ready",
626
+ buildId: state.publishedBuildId,
627
+ generation: state.generation,
628
+ fingerprint,
629
+ reused: true,
630
+ };
631
+ }
632
+ const buildId = randomUUID();
633
+ const suffix = buildId.replaceAll("-", "").slice(0, 20);
634
+ const wordsTable = `documents_fts_words_build_${suffix}`;
635
+ const bigramsTable = `documents_fts_bigrams_build_${suffix}`;
636
+ const ownerStartToken = getProcessStartToken();
637
+ createShadowTables(db, wordsTable, bigramsTable);
638
+ db.prepare(`
639
+ INSERT INTO cjk_index_builds(
640
+ build_id, state, base_mutation_seq, applied_mutation_seq,
641
+ analyzer_fingerprint, owner_pid, owner_start_token, lease_expires_at,
642
+ words_table, bigrams_table, started_at, updated_at
643
+ ) VALUES (?, 'building', 0, 0, ?, ?, ?, ?, ?, ?, ?, ?)
644
+ `).run(buildId, fingerprint, process.pid, ownerStartToken, nowMs + leaseDurationMs, wordsTable, bigramsTable, nowMs, nowMs);
645
+ db.prepare(`
646
+ UPDATE cjk_index_state
647
+ SET status = 'building', active_build_id = ?, word_capability = 'available',
648
+ diagnostic_code = NULL, updated_at = ?
649
+ WHERE singleton = 1
650
+ `).run(buildId, nowMs);
651
+ db.exec("COMMIT");
652
+ return { buildId, wordsTable, bigramsTable, leaseDurationMs, ownerStartToken };
653
+ }
654
+ catch (error) {
655
+ db.exec("ROLLBACK");
656
+ throw error;
657
+ }
658
+ }
659
+ class WordCapabilityLostError extends Error {
660
+ diagnostic;
661
+ constructor(diagnostic) {
662
+ super(diagnostic.code);
663
+ this.diagnostic = diagnostic;
664
+ }
665
+ }
666
+ async function analyzeSourceRow(row, loader) {
667
+ const filepath = await analyzeCjk(`${row.collection}/${row.path}`, loader);
668
+ const title = await analyzeCjk(row.title, loader);
669
+ const body = await analyzeCjk(row.body, loader);
670
+ if (!filepath.wordCapability.available)
671
+ throw new WordCapabilityLostError(filepath.wordCapability.diagnostic);
672
+ if (!title.wordCapability.available)
673
+ throw new WordCapabilityLostError(title.wordCapability.diagnostic);
674
+ if (!body.wordCapability.available)
675
+ throw new WordCapabilityLostError(body.wordCapability.diagnostic);
676
+ return {
677
+ id: row.id,
678
+ filepathWord: filepath.word,
679
+ titleWord: title.word,
680
+ bodyWord: body.word,
681
+ filepathBigram: filepath.bigram,
682
+ titleBigram: title.bigram,
683
+ bodyBigram: body.bigram,
684
+ };
685
+ }
686
+ function writeAnalyzedRows(db, build, rows, documentIds) {
687
+ const words = quoteIdentifier(build.wordsTable);
688
+ const bigrams = quoteIdentifier(build.bigramsTable);
689
+ const deleteWords = db.prepare(`DELETE FROM ${words} WHERE rowid = ?`);
690
+ const deleteBigrams = db.prepare(`DELETE FROM ${bigrams} WHERE rowid = ?`);
691
+ const insertWords = db.prepare(`INSERT INTO ${words}(rowid, filepath, title, body) VALUES (?, ?, ?, ?)`);
692
+ const insertBigrams = db.prepare(`INSERT INTO ${bigrams}(rowid, filepath, title, body) VALUES (?, ?, ?, ?)`);
693
+ const write = db.transaction(() => {
694
+ for (let index = 0; index < rows.length; index++) {
695
+ const row = rows[index];
696
+ const documentId = row?.id ?? documentIds?.[index];
697
+ if (documentId === undefined)
698
+ throw new Error("Missing CJK mutation document id");
699
+ deleteWords.run(documentId);
700
+ deleteBigrams.run(documentId);
701
+ if (!row)
702
+ continue;
703
+ insertWords.run(row.id, row.filepathWord, row.titleWord, row.bodyWord);
704
+ insertBigrams.run(row.id, row.filepathBigram, row.titleBigram, row.bodyBigram);
705
+ }
706
+ });
707
+ write();
708
+ }
709
+ function analyzedRowBytes(row) {
710
+ if (row === null)
711
+ return 0;
712
+ return Buffer.byteLength(row.filepathWord, "utf8")
713
+ + Buffer.byteLength(row.titleWord, "utf8")
714
+ + Buffer.byteLength(row.bodyWord, "utf8")
715
+ + Buffer.byteLength(row.filepathBigram, "utf8")
716
+ + Buffer.byteLength(row.titleBigram, "utf8")
717
+ + Buffer.byteLength(row.bodyBigram, "utf8");
718
+ }
719
+ async function analyzeAndWriteMutationPage(db, build, documentIds, loader) {
720
+ let rows = [];
721
+ let rowIds = [];
722
+ let bytes = 0;
723
+ const flush = () => {
724
+ if (rows.length === 0)
725
+ return;
726
+ writeAnalyzedRows(db, build, rows, rowIds);
727
+ rows = [];
728
+ rowIds = [];
729
+ bytes = 0;
730
+ renewLease(db, build);
731
+ };
732
+ for (const documentId of documentIds) {
733
+ const source = sourceRowForDocument(db, documentId);
734
+ const row = source ? await analyzeSourceRow(source, loader) : null;
735
+ const rowBytes = analyzedRowBytes(row);
736
+ if (rows.length > 0 && (rows.length >= BUILD_BATCH_SIZE || bytes + rowBytes > BUILD_BATCH_BYTE_BUDGET)) {
737
+ flush();
738
+ }
739
+ rows.push(row);
740
+ rowIds.push(documentId);
741
+ bytes += rowBytes;
742
+ }
743
+ flush();
744
+ }
745
+ function sourceRowForDocument(db, documentId) {
746
+ return db.prepare(`
747
+ SELECT d.id, d.collection, d.path, d.title, content.doc AS body
748
+ FROM documents d
749
+ JOIN content ON content.hash = d.hash
750
+ WHERE d.id = ? AND d.active = 1
751
+ `).get(documentId);
752
+ }
753
+ function validateShadowRowIds(db, build) {
754
+ const words = quoteIdentifier(build.wordsTable);
755
+ const bigrams = quoteIdentifier(build.bigramsTable);
756
+ const mismatch = db.prepare(`
757
+ SELECT id FROM (
758
+ SELECT id FROM documents WHERE active = 1
759
+ EXCEPT SELECT rowid AS id FROM ${words}
760
+ )
761
+ UNION ALL
762
+ SELECT id FROM (
763
+ SELECT rowid AS id FROM ${words}
764
+ EXCEPT SELECT id FROM documents WHERE active = 1
765
+ )
766
+ UNION ALL
767
+ SELECT id FROM (
768
+ SELECT id FROM documents WHERE active = 1
769
+ EXCEPT SELECT rowid AS id FROM ${bigrams}
770
+ )
771
+ UNION ALL
772
+ SELECT id FROM (
773
+ SELECT rowid AS id FROM ${bigrams}
774
+ EXCEPT SELECT id FROM documents WHERE active = 1
775
+ )
776
+ LIMIT 1
777
+ `).get();
778
+ if (mismatch != null)
779
+ throw new Error("CJK shadow index rowids do not match active documents");
780
+ }
781
+ function validateShadowAtMutationHead(db, build, appliedMutationSeq) {
782
+ db.exec("BEGIN");
783
+ try {
784
+ // Read the head first to establish the WAL snapshot used by the expensive
785
+ // bidirectional identity scan. A later mutation is caught again under the
786
+ // final writer lock before any live table name changes.
787
+ if (getMutationHead(db) !== appliedMutationSeq) {
788
+ db.exec("ROLLBACK");
789
+ return false;
790
+ }
791
+ validateShadowRowIds(db, build);
792
+ db.exec("COMMIT");
793
+ return true;
794
+ }
795
+ catch (error) {
796
+ try {
797
+ db.exec("ROLLBACK");
798
+ }
799
+ catch { }
800
+ throw error;
801
+ }
802
+ }
803
+ function publishShadowTables(db, build, generation) {
804
+ const suffix = build.buildId.replaceAll("-", "").slice(0, 20);
805
+ const oldWords = `documents_fts_words_old_${suffix}`;
806
+ const oldBigrams = `documents_fts_bigrams_old_${suffix}`;
807
+ const mutationTriggers = db.prepare(`
808
+ SELECT sql FROM sqlite_schema
809
+ WHERE type = 'trigger' AND name IN (
810
+ 'documents_cjk_journal_ai',
811
+ 'documents_cjk_journal_au',
812
+ 'documents_cjk_journal_ad'
813
+ )
814
+ ORDER BY name
815
+ `).all();
816
+ if (mutationTriggers.length !== 3 || mutationTriggers.some(trigger => !trigger.sql)) {
817
+ throw new Error("CJK mutation triggers are incomplete before publish");
818
+ }
819
+ db.exec(`
820
+ DROP TRIGGER documents_cjk_journal_ai;
821
+ DROP TRIGGER documents_cjk_journal_au;
822
+ DROP TRIGGER documents_cjk_journal_ad;
823
+ ALTER TABLE documents_fts_words RENAME TO ${quoteIdentifier(oldWords)};
824
+ ALTER TABLE ${quoteIdentifier(build.wordsTable)} RENAME TO documents_fts_words;
825
+ ALTER TABLE documents_fts_bigrams RENAME TO ${quoteIdentifier(oldBigrams)};
826
+ ALTER TABLE ${quoteIdentifier(build.bigramsTable)} RENAME TO documents_fts_bigrams;
827
+ `);
828
+ for (const trigger of mutationTriggers)
829
+ db.exec(trigger.sql);
830
+ const nowMs = Date.now();
831
+ const buildUpdate = db.prepare(`
832
+ UPDATE cjk_index_builds
833
+ SET state = 'ready', applied_mutation_seq = ?,
834
+ retired_words_table = ?, retired_bigrams_table = ?, updated_at = ?
835
+ WHERE build_id = ? AND state = 'building'
836
+ AND owner_pid = ? AND owner_start_token IS ?
837
+ `).run(generation, oldWords, oldBigrams, nowMs, build.buildId, process.pid, build.ownerStartToken);
838
+ if (Number(buildUpdate.changes) !== 1)
839
+ throw new Error("CJK rebuild lease ownership was lost");
840
+ const stateUpdate = db.prepare(`
841
+ UPDATE cjk_index_state
842
+ SET status = 'ready', generation = ?, analyzer_fingerprint = ?,
843
+ word_capability = 'available', diagnostic_code = NULL,
844
+ active_build_id = NULL, published_build_id = ?,
845
+ dirty_since_mutation_seq = NULL, updated_at = ?
846
+ WHERE singleton = 1 AND active_build_id = ?
847
+ `).run(generation, getCjkAnalyzerFingerprint(), build.buildId, nowMs, build.buildId);
848
+ if (Number(stateUpdate.changes) !== 1)
849
+ throw new Error("CJK rebuild state ownership was lost");
850
+ }
851
+ function failBuild(db, build, diagnostic) {
852
+ db.exec("BEGIN IMMEDIATE");
853
+ try {
854
+ const active = getCjkLexicalIndexState(db).activeBuildId === build.buildId;
855
+ if (active) {
856
+ dropRegisteredTable(db, build.wordsTable);
857
+ dropRegisteredTable(db, build.bigramsTable);
858
+ const nowMs = Date.now();
859
+ db.prepare(`
860
+ UPDATE cjk_index_builds SET state = 'failed', updated_at = ?
861
+ WHERE build_id = ? AND state = 'building'
862
+ `).run(nowMs, build.buildId);
863
+ db.prepare(`
864
+ UPDATE cjk_index_state
865
+ SET status = ?, active_build_id = NULL,
866
+ word_capability = ?, diagnostic_code = ?, updated_at = ?
867
+ WHERE singleton = 1 AND active_build_id = ?
868
+ `).run(diagnostic ? "unavailable" : "dirty", diagnostic ? "unavailable" : "unknown", diagnostic?.code ?? "CJK_REBUILD_FAILED", nowMs, build.buildId);
869
+ }
870
+ db.exec("COMMIT");
871
+ }
872
+ catch (error) {
873
+ db.exec("ROLLBACK");
874
+ throw error;
875
+ }
876
+ }
877
+ export async function rebuildCjkLexicalIndex(dbPath, options = {}) {
878
+ const requestedLoader = options.loadCapability ?? loadJiebaCapability;
879
+ const capability = await resolveCapability(requestedLoader);
880
+ const fixedLoader = async () => capability;
881
+ const coordinator = openDatabase(dbPath);
882
+ initializeCjkLexicalIndexSchema(coordinator);
883
+ cleanupRetiredCjkIndexTables(coordinator, { limit: 2 });
884
+ const acquired = acquireBuild(coordinator, capability, options);
885
+ if ("status" in acquired) {
886
+ coordinator.close();
887
+ return acquired;
888
+ }
889
+ const build = acquired;
890
+ const snapshot = openDatabase(dbPath);
891
+ let snapshotOpen = false;
892
+ try {
893
+ snapshot.exec("BEGIN");
894
+ snapshotOpen = true;
895
+ const baseMutationSeq = getMutationHead(snapshot);
896
+ coordinator.prepare(`
897
+ UPDATE cjk_index_builds
898
+ SET base_mutation_seq = ?, applied_mutation_seq = ?, updated_at = ?
899
+ WHERE build_id = ? AND state = 'building'
900
+ `).run(baseMutationSeq, baseMutationSeq, Date.now(), build.buildId);
901
+ const iterator = snapshot.prepare(`
902
+ SELECT d.id, d.collection, d.path, d.title, content.doc AS body
903
+ FROM documents d
904
+ JOIN content ON content.hash = d.hash
905
+ WHERE d.active = 1
906
+ ORDER BY d.id
907
+ `).iterate();
908
+ let batch = [];
909
+ for (const row of iterator) {
910
+ batch.push(await analyzeSourceRow(row, fixedLoader));
911
+ if (batch.length >= BUILD_BATCH_SIZE) {
912
+ writeAnalyzedRows(coordinator, build, batch);
913
+ batch = [];
914
+ renewLease(coordinator, build);
915
+ }
916
+ }
917
+ if (batch.length > 0)
918
+ writeAnalyzedRows(coordinator, build, batch);
919
+ snapshot.exec("COMMIT");
920
+ snapshotOpen = false;
921
+ renewLease(coordinator, build);
922
+ await options.onPhase?.({ phase: "snapshot-complete", buildId: build.buildId, baseMutationSeq });
923
+ let appliedMutationSeq = baseMutationSeq;
924
+ for (;;) {
925
+ const targetMutationSeq = getMutationHead(coordinator);
926
+ let processedDocumentCount = 0;
927
+ while (appliedMutationSeq < targetMutationSeq) {
928
+ const journalPage = coordinator.prepare(`
929
+ SELECT seq, document_id
930
+ FROM cjk_index_mutations
931
+ WHERE seq > ? AND seq <= ?
932
+ ORDER BY seq
933
+ LIMIT ?
934
+ `).all(appliedMutationSeq, targetMutationSeq, BUILD_BATCH_SIZE);
935
+ if (journalPage.length === 0) {
936
+ throw new Error("CJK mutation journal has a gap before the target head");
937
+ }
938
+ const mutationIds = [...new Set(journalPage.map((row) => Number(row.document_id)))];
939
+ await analyzeAndWriteMutationPage(coordinator, build, mutationIds, fixedLoader);
940
+ processedDocumentCount += mutationIds.length;
941
+ appliedMutationSeq = Number(journalPage[journalPage.length - 1].seq);
942
+ const updated = coordinator.prepare(`
943
+ UPDATE cjk_index_builds
944
+ SET applied_mutation_seq = ?, updated_at = ?
945
+ WHERE build_id = ? AND state = 'building'
946
+ AND owner_pid = ? AND owner_start_token IS ?
947
+ `).run(appliedMutationSeq, Date.now(), build.buildId, process.pid, build.ownerStartToken);
948
+ if (Number(updated.changes) !== 1)
949
+ throw new Error("CJK rebuild lease ownership was lost");
950
+ await options.onPhase?.({
951
+ phase: "catchup-page-complete",
952
+ buildId: build.buildId,
953
+ targetMutationSeq,
954
+ appliedMutationSeq,
955
+ documentCount: mutationIds.length,
956
+ });
957
+ }
958
+ await options.onPhase?.({
959
+ phase: "catchup-complete",
960
+ buildId: build.buildId,
961
+ targetMutationSeq,
962
+ documentCount: processedDocumentCount,
963
+ });
964
+ renewLease(coordinator, build);
965
+ if (!validateShadowAtMutationHead(coordinator, build, appliedMutationSeq)) {
966
+ continue;
967
+ }
968
+ renewLease(coordinator, build);
969
+ coordinator.exec("BEGIN IMMEDIATE");
970
+ try {
971
+ const state = getCjkLexicalIndexState(coordinator);
972
+ if (state.activeBuildId !== build.buildId) {
973
+ throw new Error("CJK rebuild lease ownership was lost");
974
+ }
975
+ const lease = coordinator.prepare(`
976
+ SELECT lease_expires_at
977
+ FROM cjk_index_builds
978
+ WHERE build_id = ? AND state = 'building'
979
+ AND owner_pid = ? AND owner_start_token IS ?
980
+ `).get(build.buildId, process.pid, build.ownerStartToken);
981
+ if (!lease || Number(lease.lease_expires_at) < Date.now()) {
982
+ throw new Error("CJK rebuild lease ownership was lost");
983
+ }
984
+ if (getMutationHead(coordinator) !== appliedMutationSeq) {
985
+ coordinator.exec("ROLLBACK");
986
+ renewLease(coordinator, build);
987
+ continue;
988
+ }
989
+ publishShadowTables(coordinator, build, appliedMutationSeq);
990
+ coordinator.exec("COMMIT");
991
+ }
992
+ catch (error) {
993
+ try {
994
+ coordinator.exec("ROLLBACK");
995
+ }
996
+ catch { }
997
+ throw error;
998
+ }
999
+ await options.onPhase?.({ phase: "published", buildId: build.buildId, generation: appliedMutationSeq });
1000
+ return {
1001
+ status: "ready",
1002
+ buildId: build.buildId,
1003
+ generation: appliedMutationSeq,
1004
+ fingerprint: getCjkAnalyzerFingerprint(),
1005
+ reused: false,
1006
+ };
1007
+ }
1008
+ }
1009
+ catch (error) {
1010
+ if (snapshotOpen) {
1011
+ try {
1012
+ snapshot.exec("ROLLBACK");
1013
+ }
1014
+ catch { }
1015
+ }
1016
+ const diagnostic = error instanceof WordCapabilityLostError ? error.diagnostic : null;
1017
+ failBuild(coordinator, build, diagnostic);
1018
+ if (diagnostic) {
1019
+ return {
1020
+ status: "unavailable",
1021
+ diagnostic,
1022
+ publishedBuildId: getCjkLexicalIndexState(coordinator).publishedBuildId,
1023
+ };
1024
+ }
1025
+ throw error;
1026
+ }
1027
+ finally {
1028
+ snapshot.close();
1029
+ coordinator.close();
1030
+ }
1031
+ }