@equationalapplications/core-llm-wiki 7.12.0 → 7.13.0

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/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { BaseRepository, WikiGraphNodeOwnershipConflict, parseJsonObject, parseJsonArray, liveAtSql, __privateAdd, MetadataRepository, EmbeddingService, SearchService, TemporalService, JobManager, PromptService, resolveGrounding, IngestionService, MaintenanceService, ImportExportService, RetrievalService, WriteService, __privateGet, __privateSet, createLegacySourceTypeIndex, writeNormalizeStamp, CURRENT_SCHEMA_VERSION, detectSchemaVersion, MIGRATIONS, runSourceRefNormalize, normalizeSourceRef, normalizeSourceHash, entitySummaryMetaKey, WikiSourceRefHashCollision, DiagnosticBuffer, validateManifest, WikiDraftNotFound, generateId, emptyManifest, resolveNodeType, WikiStrictOntologyViolation, validateInlineEdges, resolveEdgeDefinitions, typeSatisfies, normalizeTitleKey, edgeTripleAllowed, MAX_EMBED_ATTEMPTS, UsageMeter, isWikiSearchUnavailableError, WikiBusyError, WikiInvalidReadOptions, isLiveAt, packFactsByBudget, factTokenCost, probePendingWork, WikiMigrationRequiredError, setupDatabase, UnavailableIndexStrategy, WikiTransactionError, extractSqliteCode } from './chunk-KN4XSLLB.mjs';
2
- export { DEFAULT_CHUNK_OVERLAP, DEFAULT_MAX_CHUNK_LENGTH, DEFAULT_MAX_EMBED_CHARS, EMBED_CHARS_CEILING, HEAL_ANCHORS_PER_CANDIDATE, HEAL_BATCH_SIZE, HEAL_MAX_FACT_BODY_CHARS_L3, HEAL_MAX_TASKS, HEAL_RECHECK_MS, HOOK_TIMEOUT_MARKER, ONTOLOGY_BACKFILL_BATCH_SIZE, ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS, ONTOLOGY_BACKFILL_RECHECK_MS, ONTOLOGY_BACKFILL_SYSTEM_PROMPT, PromptService, PrunePartialFailureError, WikiBusyError, WikiDraftNotFound, WikiDuplicateHashError, WikiGraphNodeOwnershipConflict, WikiIngestEmptyError, WikiInvalidReadOptions, WikiMigrationRequiredError, WikiParseError, WikiSearchUnavailableError, WikiSourceRefHashCollision, WikiStrictOntologyViolation, WikiSupersedeError, WikiTransactionError, chunkText, configureRandomSource, isWikiSearchUnavailableError, parseEmbedding, safeSlice, validateManifest } from './chunk-KN4XSLLB.mjs';
1
+ import { BaseRepository, WikiGraphNodeOwnershipConflict, parseJsonObject, parseJsonArray, liveAtSql, reanchorRebuildFingerprint, __privateAdd, MetadataRepository, EmbeddingService, SearchService, TemporalService, JobManager, PromptService, resolveGrounding, IngestionService, MaintenanceService, ImportExportService, RetrievalService, WriteService, __privateGet, __privateSet, createLegacySourceTypeIndex, writeNormalizeStamp, CURRENT_SCHEMA_VERSION, detectSchemaVersion, MIGRATIONS, runSourceRefNormalize, normalizeSourceRef, normalizeSourceHash, entitySummaryMetaKey, WikiSourceRefHashCollision, DiagnosticBuffer, validateManifest, WikiDraftNotFound, generateId, emptyManifest, resolveNodeType, WikiStrictOntologyViolation, validateInlineEdges, resolveEdgeDefinitions, typeSatisfies, normalizeTitleKey, edgeTripleAllowed, MAX_EMBED_ATTEMPTS, UsageMeter, isWikiSearchUnavailableError, WikiBusyError, WikiInvalidReadOptions, isLiveAt, packFactsByBudget, factTokenCost, probePendingWork, WikiMigrationRequiredError, setupDatabase, UnavailableIndexStrategy, FTS5_STATE_KEY, fts5LedgerDropSql, fts5TablesDdl, fts5PendingIndexesDdl, fts5TriggersDdl, writeRebuildStart, FTS5_RETIRED_COUNTER_KEY, retiredTableName, FTS5_RETIRED_TABLES_KEY, isRetiredTableName, idReindexChunkSql, readRebuildProgress, FTS5_DRAIN_CHUNK, FTS5_RECLAIM_MERGE_PAGES, windowHiSql, windowChunkSql, FTS5_REBUILD_WM_KEY, sweepHiSql, sweepChunkSql, FTS5_SWEEP_CURSOR_KEY, drainHiSql, drainChunkSql, FTS5_REBUILD_SNAPSHOT_KEY, idDrainChunkSql, entityDrainHiSql, entityDrainChunkSql, fts5TriggerNames, extractSqliteCode, WikiTransactionError } from './chunk-PCRYIUV5.mjs';
2
+ export { DEFAULT_CHUNK_OVERLAP, DEFAULT_MAX_CHUNK_LENGTH, DEFAULT_MAX_EMBED_CHARS, EMBED_CHARS_CEILING, HEAL_ANCHORS_PER_CANDIDATE, HEAL_BATCH_SIZE, HEAL_MAX_FACT_BODY_CHARS_L3, HEAL_MAX_TASKS, HEAL_RECHECK_MS, HOOK_TIMEOUT_MARKER, ONTOLOGY_BACKFILL_BATCH_SIZE, ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS, ONTOLOGY_BACKFILL_RECHECK_MS, ONTOLOGY_BACKFILL_SYSTEM_PROMPT, PromptService, PrunePartialFailureError, WikiBusyError, WikiDraftNotFound, WikiDuplicateHashError, WikiGraphNodeOwnershipConflict, WikiIngestEmptyError, WikiInvalidReadOptions, WikiMigrationRequiredError, WikiParseError, WikiSearchUnavailableError, WikiSourceRefHashCollision, WikiStrictOntologyViolation, WikiSupersedeError, WikiTransactionError, chunkText, configureRandomSource, isWikiSearchUnavailableError, parseEmbedding, safeSlice, validateManifest } from './chunk-PCRYIUV5.mjs';
3
3
  import { deriveTrustTier, isStaleAfter, appendRelatedSection, buildConceptDocument, buildLogMd, buildEntityIndexMd, buildRootIndexMd, isAllowedOkfPath, parseRootIndexMd, parseEntityIndexMd, parseConcept, splitRelatedSection, extractMarkdownLinks, parseLogMd, parseEventIdComment, latestVerified, formatSourcesJson, appendEventIdComment, parseVerifiedFlexible, parseCitationsList } from '@equationalapplications/core-okf';
4
4
  import MiniSearch from 'minisearch';
5
5
 
@@ -695,6 +695,7 @@ var _EntryRepository = class _EntryRepository extends BaseRepository {
695
695
  }
696
696
  }
697
697
  }
698
+ if (totalChanges > 0) await reanchorRebuildFingerprint(executor, this.prefix, ids);
698
699
  return totalChanges;
699
700
  }
700
701
  /**
@@ -2512,87 +2513,11 @@ function assertOwnedBy(entityId, documents) {
2512
2513
  }
2513
2514
  }
2514
2515
 
2515
- // src/services/search/fts5Sql.ts
2516
- var FTS5_STATE_KEY = "fts5_index_state";
2517
- var FTS5_DRAIN_CHUNK = 500;
2518
- function fts5TriggerNames(prefix) {
2519
- return [`${prefix}entries_fts_ai`, `${prefix}entries_fts_au`, `${prefix}entries_fts_ad`];
2520
- }
2521
- function tagsExpr(col) {
2522
- return `CASE WHEN json_valid(${col}) THEN
2523
- CASE WHEN json_type(${col}) = 'array' THEN
2524
- (SELECT group_concat(CASE type WHEN 'true' THEN 'true' WHEN 'false' THEN 'false'
2525
- WHEN 'null' THEN '' ELSE value END, ' ')
2526
- FROM json_each(${col}))
2527
- ELSE ${col} END
2528
- ELSE ${col} END`;
2529
- }
2530
- function fts5TablesDdl(p) {
2531
- return `
2532
- CREATE VIRTUAL TABLE IF NOT EXISTS ${p}entries_fts USING fts5(
2533
- id UNINDEXED, entity_id UNINDEXED, title, body, tags,
2534
- tokenize = 'porter unicode61'
2535
- );
2536
- CREATE TABLE IF NOT EXISTS ${p}fts_map (
2537
- fts_rowid INTEGER PRIMARY KEY,
2538
- id TEXT NOT NULL UNIQUE,
2539
- entity_id TEXT NOT NULL
2540
- );
2541
- CREATE INDEX IF NOT EXISTS ${p}fts_map_entity ON ${p}fts_map(entity_id);
2542
- CREATE TABLE IF NOT EXISTS ${p}fts_pending (
2543
- seq INTEGER PRIMARY KEY,
2544
- entity_id TEXT NOT NULL,
2545
- id TEXT NOT NULL
2546
- );`;
2547
- }
2548
- function fts5TriggersDdl(p) {
2549
- const [ai, au, ad] = fts5TriggerNames(p);
2550
- return `
2551
- CREATE TRIGGER IF NOT EXISTS ${ai} AFTER INSERT ON ${p}entries BEGIN
2552
- INSERT INTO ${p}fts_pending (entity_id, id) VALUES (NEW.entity_id, NEW.id);
2553
- END;
2554
- CREATE TRIGGER IF NOT EXISTS ${au}
2555
- AFTER UPDATE OF id, entity_id, title, body, tags, deleted_at ON ${p}entries BEGIN
2556
- INSERT INTO ${p}fts_pending (entity_id, id) VALUES (OLD.entity_id, OLD.id);
2557
- INSERT INTO ${p}fts_pending (entity_id, id)
2558
- SELECT NEW.entity_id, NEW.id WHERE NEW.id <> OLD.id OR NEW.entity_id <> OLD.entity_id;
2559
- END;
2560
- CREATE TRIGGER IF NOT EXISTS ${ad} AFTER DELETE ON ${p}entries BEGIN
2561
- INSERT INTO ${p}fts_pending (entity_id, id) VALUES (OLD.entity_id, OLD.id);
2562
- END;`;
2563
- }
2564
- function drainHiSql(p) {
2565
- return `SELECT max(seq) AS hi FROM (SELECT seq FROM ${p}fts_pending ORDER BY seq LIMIT ${FTS5_DRAIN_CHUNK})`;
2566
- }
2567
- function drainChunkSql(p) {
2568
- const chunkIds = `SELECT id FROM ${p}fts_pending WHERE seq <= ?`;
2569
- return [
2570
- `DELETE FROM ${p}entries_fts WHERE rowid IN (SELECT m.fts_rowid FROM ${p}fts_map m WHERE m.id IN (${chunkIds}))`,
2571
- `DELETE FROM ${p}fts_map WHERE id IN (${chunkIds})`,
2572
- `INSERT INTO ${p}fts_map (id, entity_id)
2573
- SELECT e.id, e.entity_id FROM ${p}entries e WHERE e.id IN (${chunkIds}) AND e.deleted_at IS NULL`,
2574
- `INSERT INTO ${p}entries_fts (rowid, id, entity_id, title, body, tags)
2575
- SELECT m.fts_rowid, e.id, e.entity_id, e.title, e.body, ${tagsExpr("e.tags")}
2576
- FROM ${p}fts_map m JOIN ${p}entries e ON e.id = m.id
2577
- WHERE m.id IN (${chunkIds})`,
2578
- `DELETE FROM ${p}fts_pending WHERE seq <= ?`
2579
- ];
2580
- }
2581
- function rebuildSql(p) {
2582
- return [
2583
- `INSERT INTO ${p}fts_map (id, entity_id) SELECT id, entity_id FROM ${p}entries WHERE deleted_at IS NULL`,
2584
- `INSERT INTO ${p}entries_fts (rowid, id, entity_id, title, body, tags)
2585
- SELECT m.fts_rowid, e.id, e.entity_id, e.title, e.body, ${tagsExpr("e.tags")}
2586
- FROM ${p}fts_map m JOIN ${p}entries e ON e.id = m.id`
2587
- ];
2588
- }
2589
- function fts5LedgerDropSql(p) {
2590
- return [
2591
- ...fts5TriggerNames(p).map((n) => `DROP TRIGGER IF EXISTS ${n};`),
2592
- `DROP TABLE IF EXISTS ${p}fts_pending;`,
2593
- `DROP TABLE IF EXISTS ${p}fts_map;`
2594
- ].join("\n");
2595
- }
2516
+ // src/utils/monotonicNow.ts
2517
+ var monotonicNow = () => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
2518
+
2519
+ // src/utils/setupDefaults.ts
2520
+ var DEFAULT_SETUP_BUDGET_MS = 1500;
2596
2521
 
2597
2522
  // src/services/search/ftsQuery.ts
2598
2523
  var TOKEN = /[\p{L}\p{N}]+/gu;
@@ -2605,41 +2530,363 @@ function buildFtsMatchQuery(query) {
2605
2530
 
2606
2531
  // src/services/search/Fts5IndexStrategy.ts
2607
2532
  var Fts5IndexStrategy = class {
2608
- constructor(db, prefix, metadataRepo) {
2533
+ constructor(db, prefix, metadataRepo, budgetMs = DEFAULT_SETUP_BUDGET_MS) {
2609
2534
  this.db = db;
2610
2535
  this.prefix = prefix;
2611
2536
  this.metadataRepo = metadataRepo;
2537
+ this.budgetMs = budgetMs;
2538
+ }
2539
+ /**
2540
+ * Resolves the index state (spec #280 Change 3 transition table). Every
2541
+ * read-only probe runs before the one transition transaction, so a probe
2542
+ * failure leaves zero mutation (Change 4 ordering).
2543
+ */
2544
+ async init(opts = {}) {
2545
+ try {
2546
+ await this.initOnce(opts);
2547
+ } catch (err) {
2548
+ const wrapped = unwrapFtsOnly(err);
2549
+ const cls = ftsCorruptionClass(wrapped.cause);
2550
+ if (cls === "vtab" || cls === "corrupt" && wrapped.ftsOnly) {
2551
+ await this.resetAndRearm();
2552
+ return;
2553
+ }
2554
+ throw wrapped.cause;
2555
+ }
2556
+ }
2557
+ /** Marks errors from statements that touch only FTS tables (entries_fts + shadows). */
2558
+ async ftsOnly(fn) {
2559
+ try {
2560
+ return await fn();
2561
+ } catch (err) {
2562
+ throw new FtsOnlyStatementError(err);
2563
+ }
2612
2564
  }
2613
- async init() {
2565
+ async initOnce(opts) {
2566
+ const p = this.prefix;
2614
2567
  const state = await this.metadataRepo.getMeta(FTS5_STATE_KEY);
2615
- if (state === "live" && await this.triggersPresent()) {
2616
- await this.db.execAsync(fts5TablesDdl(this.prefix));
2568
+ const schema = await this.readSchema();
2569
+ const ftsExists = schema.tables.has(`${p}entries_fts`);
2570
+ const mapExists = schema.tables.has(`${p}fts_map`);
2571
+ const pendingExists = schema.tables.has(`${p}fts_pending`);
2572
+ const trig = schema.triggersPresent;
2573
+ const ftsDdl = schema.tables.get(`${p}entries_fts`) ?? null;
2574
+ const tokenizerOk = ftsDdl === null || /tokenize\s*=\s*['"]porter\s+unicode61['"]/i.test(ftsDdl);
2575
+ if (state === null && opts.entriesExisted === false) {
2576
+ await this.db.withTransactionAsync(async (tx) => {
2577
+ await tx.execAsync(fts5TablesDdl(p) + fts5PendingIndexesDdl(p) + fts5TriggersDdl(p));
2578
+ await this.metadataRepo.setMeta(FTS5_STATE_KEY, "live", tx);
2579
+ });
2617
2580
  return;
2618
2581
  }
2582
+ if (state === "detached") return this.resetAndRearm();
2583
+ if (!tokenizerOk) return this.resetAndRearm();
2584
+ const intact = trig && ftsExists && mapExists && pendingExists;
2585
+ if (intact && state === "rebuilding") return;
2586
+ if (intact && state === "live") {
2587
+ await this.ftsOnly(() => this.db.getFirstAsync(`SELECT rowid FROM ${p}entries_fts ORDER BY rowid DESC LIMIT 1`));
2588
+ if (await this.liveRowUnindexed()) return this.resetAndRearm();
2589
+ return;
2590
+ }
2591
+ if (ftsExists && mapExists && await this.keepMapConsistent()) {
2592
+ await this.db.withTransactionAsync((tx) => this.rearmInTx(tx));
2593
+ return;
2594
+ }
2595
+ return this.resetAndRearm();
2596
+ }
2597
+ /**
2598
+ * The index was lost (rev 8 m3 / rev 9 m4): the oldest or the newest live entry is neither mapped nor
2599
+ * ledgered, which `live` never allows. The oldest one keeps detection armed after post-loss writes
2600
+ * repopulate the map and the ledger (#284 review). Every-open path: each side reads at most
2601
+ * FTS5_DRAIN_CHUNK entries rows (final review M1), so an all-soft-deleted store is never fully scanned;
2602
+ * a lost index whose oldest and newest rows are all deleted goes undetected.
2603
+ */
2604
+ async liveRowUnindexed() {
2605
+ const p = this.prefix;
2606
+ const edge = (dir) => `SELECT id FROM (SELECT id FROM (SELECT id, deleted_at FROM ${p}entries ORDER BY rowid ${dir} LIMIT ${FTS5_DRAIN_CHUNK}) WHERE deleted_at IS NULL LIMIT 1)`;
2607
+ return await this.db.getFirstAsync(
2608
+ `SELECT 1 AS x FROM (${edge("ASC")} UNION ALL ${edge("DESC")}) e
2609
+ WHERE NOT EXISTS (SELECT 1 FROM ${p}fts_map m WHERE m.id = e.id)
2610
+ AND NOT EXISTS (SELECT 1 FROM ${p}fts_pending q WHERE q.id = e.id)
2611
+ LIMIT 1`
2612
+ ) !== null;
2613
+ }
2614
+ /**
2615
+ * Read-only keep-map check (spec DDL note): every surviving entries_fts
2616
+ * rowid must have a map row. Max-rowid seek, then docsize vs map counts.
2617
+ */
2618
+ async keepMapConsistent() {
2619
+ const p = this.prefix;
2620
+ const fts = await this.ftsOnly(() => this.db.getFirstAsync(`SELECT rowid FROM ${p}entries_fts ORDER BY rowid DESC LIMIT 1`));
2621
+ const map = await this.db.getFirstAsync(
2622
+ `SELECT coalesce(max(fts_rowid), 0) AS hi, count(*) AS n FROM ${p}fts_map`
2623
+ );
2624
+ if ((fts?.rowid ?? 0) > (map?.hi ?? 0)) return false;
2625
+ const doc = await this.ftsOnly(() => this.db.getFirstAsync(`SELECT count(*) AS n FROM ${p}entries_fts_docsize`));
2626
+ return (doc?.n ?? 0) === (map?.n ?? 0);
2627
+ }
2628
+ /** Keep-map re-arm: triggers, empty ledger, rebuild from 0, state rebuilding. One transaction. */
2629
+ async rearmInTx(tx) {
2630
+ const p = this.prefix;
2631
+ await tx.execAsync(fts5TablesDdl(p));
2632
+ await tx.execAsync(`DELETE FROM ${p}fts_pending;`);
2633
+ await tx.execAsync(fts5PendingIndexesDdl(p) + fts5TriggersDdl(p));
2634
+ await writeRebuildStart(tx, p, this.metadataRepo);
2635
+ await this.metadataRepo.setMeta(FTS5_STATE_KEY, "rebuilding", tx);
2636
+ }
2637
+ /** Reset path: retire entries_fts, recreate it empty, clear map + ledger, then the re-arm. One transaction. */
2638
+ async resetAndRearm() {
2639
+ const p = this.prefix;
2619
2640
  await this.db.withTransactionAsync(async (tx) => {
2620
- await tx.execAsync(`DROP TABLE IF EXISTS ${this.prefix}entries_fts;`);
2621
- await tx.execAsync(fts5TablesDdl(this.prefix));
2622
- await this.rebuildInTx(tx);
2623
- await tx.execAsync(fts5TriggersDdl(this.prefix));
2641
+ await this.retireFtsTable(tx);
2642
+ await tx.execAsync(fts5TablesDdl(p));
2643
+ await tx.execAsync(`DELETE FROM ${p}fts_map; DELETE FROM ${p}fts_pending;`);
2644
+ await this.rearmInTx(tx);
2645
+ });
2646
+ }
2647
+ /** Rename-retire entries_fts (spec #280 rev 11); DROP only when the RENAME throws. */
2648
+ async retireFtsTable(tx) {
2649
+ const p = this.prefix;
2650
+ if (!await this.metadataRepo.tableExists(`${p}entries_fts`, tx)) return;
2651
+ const n = Number(await this.metadataRepo.getMeta(FTS5_RETIRED_COUNTER_KEY, tx) ?? "0") + 1;
2652
+ const name = retiredTableName(p, n);
2653
+ try {
2654
+ await tx.execAsync(`ALTER TABLE ${p}entries_fts RENAME TO ${name}`);
2655
+ } catch (err) {
2656
+ if (isTransientOrTxFatal(err)) throw err;
2657
+ await tx.execAsync(`DROP TABLE ${p}entries_fts`);
2658
+ return;
2659
+ }
2660
+ await this.metadataRepo.setMeta(FTS5_RETIRED_TABLES_KEY, JSON.stringify([...await this.retiredList(tx), name]), tx);
2661
+ await this.metadataRepo.setMeta(FTS5_RETIRED_COUNTER_KEY, String(n), tx);
2662
+ }
2663
+ async retiredList(tx = this.db) {
2664
+ const raw = parseJsonArray(await this.metadataRepo.getMeta(FTS5_RETIRED_TABLES_KEY, tx), []);
2665
+ return raw.filter((n) => isRetiredTableName(this.prefix, n));
2666
+ }
2667
+ async drain(opts = {}) {
2668
+ try {
2669
+ await this.drainOnce(opts);
2670
+ } catch (err) {
2671
+ if (ftsCorruptionClass(err) !== "vtab") throw err;
2672
+ await this.resetAndRearm();
2673
+ if (opts.ids && opts.ids.length > 0) await this.drainIds(opts.ids, idReindexChunkSql(this.prefix));
2674
+ }
2675
+ }
2676
+ async drainOnce(opts) {
2677
+ const ids = opts.ids ?? [];
2678
+ if (ids.length > 0) await this.drainIds(ids);
2679
+ if (opts.mode === "ledger") {
2680
+ if (ids.length === 0 && opts.entityId !== void 0) await this.drainEntityChunk(opts.entityId);
2681
+ else await this.drainLedgerChunk();
2682
+ return;
2683
+ }
2684
+ await this.fullQuantum(opts.deadline ?? monotonicNow() + this.budgetMs);
2685
+ }
2686
+ /**
2687
+ * One full quantum (spec #280 Change 3): ledger first (at most half the
2688
+ * budget while `rebuilding`); then, while `rebuilding`, ≥1 window unless
2689
+ * the ledger drain overran the deadline, one sweep tx, and the flip check;
2690
+ * then reclamation. A `live` store reads only the state.
2691
+ */
2692
+ async fullQuantum(deadline) {
2693
+ const rebuilding = await this.metadataRepo.getMeta(FTS5_STATE_KEY) === "rebuilding";
2694
+ const start = monotonicNow();
2695
+ const ledgerRan = await this.drainLedgerUntil(rebuilding ? start + (deadline - start) / 2 : deadline);
2696
+ const ledgerOverran = ledgerRan && monotonicNow() >= deadline;
2697
+ if (rebuilding) {
2698
+ const progress = await readRebuildProgress(this.metadataRepo);
2699
+ if (!progress || !await this.fingerprintValid(progress.wm)) {
2700
+ await this.restartRebuild();
2701
+ } else {
2702
+ if (!ledgerOverran) {
2703
+ let step;
2704
+ do {
2705
+ step = await this.runWindow(progress);
2706
+ } while (step === "more" && monotonicNow() < deadline);
2707
+ if (step !== "stale") await this.runSweepTx(progress);
2708
+ }
2709
+ await this.tryFlip();
2710
+ }
2711
+ }
2712
+ try {
2713
+ await this.reclaimRetired(deadline);
2714
+ } catch (err) {
2715
+ if (!isSqliteError(err)) throw err;
2716
+ }
2717
+ }
2718
+ /** Bounded reclamation steps, each its own tx, until the deadline or nothing is retired. */
2719
+ async reclaimRetired(deadline) {
2720
+ while (monotonicNow() < deadline) {
2721
+ const [name] = await this.retiredList();
2722
+ if (name === void 0) return;
2723
+ try {
2724
+ if (!await this.metadataRepo.tableExists(name)) {
2725
+ await this.delistRetired(name, false);
2726
+ continue;
2727
+ }
2728
+ while (!await this.reclaimStep(name) && monotonicNow() < deadline) ;
2729
+ } catch (err) {
2730
+ if (ftsCorruptionClass(err) === null) throw err;
2731
+ try {
2732
+ await this.delistRetired(name, true);
2733
+ } catch (dropErr) {
2734
+ if (!isSqliteError(dropErr)) throw dropErr;
2735
+ await this.delistRetired(name, false);
2736
+ }
2737
+ }
2738
+ }
2739
+ }
2740
+ /** (1) delete <=500 rows, else (2) one bounded merge, else (3) DROP + delist. True once delisted. */
2741
+ async reclaimStep(name) {
2742
+ if (await this.db.getFirstAsync(`SELECT rowid FROM ${name} LIMIT 1`)) {
2743
+ await this.db.withTransactionAsync(async (tx) => {
2744
+ await tx.runAsync(`DELETE FROM ${name} WHERE rowid IN (SELECT rowid FROM ${name} ORDER BY rowid LIMIT ${FTS5_DRAIN_CHUNK})`);
2745
+ });
2746
+ return false;
2747
+ }
2748
+ let merged = false;
2749
+ await this.db.withTransactionAsync(async (tx) => {
2750
+ const before = await tx.getFirstAsync(`SELECT total_changes() AS n`);
2751
+ await tx.runAsync(`INSERT INTO ${name}(${name}, rank) VALUES('merge', ${FTS5_RECLAIM_MERGE_PAGES})`);
2752
+ const after = await tx.getFirstAsync(`SELECT total_changes() AS n`);
2753
+ merged = (after?.n ?? 0) - (before?.n ?? 0) >= 2;
2754
+ });
2755
+ if (!merged) await this.delistRetired(name, true);
2756
+ return !merged;
2757
+ }
2758
+ async delistRetired(name, drop) {
2759
+ await this.db.withTransactionAsync(async (tx) => {
2760
+ if (drop) await tx.execAsync(`DROP TABLE IF EXISTS ${name}`);
2761
+ const rest = (await this.retiredList(tx)).filter((n) => n !== name);
2762
+ await this.metadataRepo.setMeta(FTS5_RETIRED_TABLES_KEY, JSON.stringify(rest), tx);
2763
+ });
2764
+ }
2765
+ /** The id at the watermark's rowid still matches. A null id (start, or windows complete) has nothing to check. */
2766
+ async fingerprintValid(wm, tx = this.db) {
2767
+ if (wm.id === null) return true;
2768
+ const row = await tx.getFirstAsync(`SELECT id FROM ${this.prefix}entries WHERE rowid = ?`, [wm.rowid]);
2769
+ return row?.id === wm.id;
2770
+ }
2771
+ /** External change under the watermark: start over (same re-arm transaction as a trigger-loss restart). */
2772
+ async restartRebuild() {
2773
+ await this.db.withTransactionAsync((tx) => this.rearmInTx(tx));
2774
+ }
2775
+ /**
2776
+ * Progress as stored, re-read inside a window/sweep tx: false when it no
2777
+ * longer matches what this quantum assumed (final review I1). Writing a
2778
+ * watermark or cursor computed from a stale `pr` would overwrite a
2779
+ * concurrent restart (runPrune VACUUM is not on the sync chain).
2780
+ */
2781
+ async progressUnchanged(pr, tx) {
2782
+ if (await this.metadataRepo.getMeta(FTS5_STATE_KEY, tx) !== "rebuilding") return false;
2783
+ const cur = await readRebuildProgress(this.metadataRepo, tx);
2784
+ return cur !== null && cur.wm.rowid === pr.wm.rowid && cur.wm.id === pr.wm.id && cur.cursor === pr.cursor && cur.snapshot.entries === pr.snapshot.entries && cur.snapshot.map === pr.snapshot.map;
2785
+ }
2786
+ /** One window tx. 'done' once no rows remain in (wm, snapshot] (the watermark is then pinned at the snapshot, F9); 'stale' when progress moved under the quantum. */
2787
+ async runWindow(pr) {
2788
+ if (pr.wm.rowid >= pr.snapshot.entries) return "done";
2789
+ const p = this.prefix;
2790
+ let next = pr.wm;
2791
+ let more = false;
2792
+ let stale = false;
2793
+ await this.db.withTransactionAsync(async (tx) => {
2794
+ if (!await this.progressUnchanged(pr, tx)) {
2795
+ stale = true;
2796
+ return;
2797
+ }
2798
+ const row = await tx.getFirstAsync(windowHiSql(p), [pr.wm.rowid, pr.snapshot.entries]);
2799
+ if (row?.hi == null) {
2800
+ next = { rowid: pr.snapshot.entries, id: null };
2801
+ } else {
2802
+ for (const sql of windowChunkSql(p)) await tx.runAsync(sql, [pr.wm.rowid, row.hi]);
2803
+ const fp = await tx.getFirstAsync(`SELECT id FROM ${p}entries WHERE rowid = ?`, [row.hi]);
2804
+ next = { rowid: row.hi, id: fp.id };
2805
+ more = true;
2806
+ }
2807
+ await this.metadataRepo.setMeta(FTS5_REBUILD_WM_KEY, JSON.stringify(next), tx);
2808
+ });
2809
+ if (stale) return "stale";
2810
+ pr.wm = next;
2811
+ return more ? "more" : "done";
2812
+ }
2813
+ /** At most one bounded sweep tx; the cursor advances in the same tx. */
2814
+ async runSweepTx(pr) {
2815
+ if (pr.cursor >= pr.snapshot.map) return;
2816
+ const p = this.prefix;
2817
+ let next = pr.cursor;
2818
+ await this.db.withTransactionAsync(async (tx) => {
2819
+ if (!await this.progressUnchanged(pr, tx)) return;
2820
+ const row = await tx.getFirstAsync(sweepHiSql(p), [pr.cursor, pr.snapshot.map]);
2821
+ const hi = row?.hi ?? pr.snapshot.map;
2822
+ for (const sql of sweepChunkSql(p)) await tx.runAsync(sql, [pr.cursor, hi]);
2823
+ await this.metadataRepo.setMeta(FTS5_SWEEP_CURSOR_KEY, String(hi), tx);
2824
+ next = hi;
2825
+ });
2826
+ pr.cursor = next;
2827
+ }
2828
+ /** Completion, re-checked inside the flipping tx; the residual ledger (≤ one chunk) drains in it. */
2829
+ async tryFlip() {
2830
+ const p = this.prefix;
2831
+ await this.db.withTransactionAsync(async (tx) => {
2832
+ if (await this.metadataRepo.getMeta(FTS5_STATE_KEY, tx) !== "rebuilding") return;
2833
+ const pr = await readRebuildProgress(this.metadataRepo, tx);
2834
+ if (!pr || pr.wm.rowid < pr.snapshot.entries || pr.cursor < pr.snapshot.map) return;
2835
+ if (!await this.fingerprintValid(pr.wm, tx)) return;
2836
+ const residual = await tx.getFirstAsync(
2837
+ `SELECT count(*) AS n FROM (SELECT 1 FROM ${p}fts_pending LIMIT ${FTS5_DRAIN_CHUNK + 1})`
2838
+ );
2839
+ if ((residual?.n ?? 0) > FTS5_DRAIN_CHUNK) return;
2840
+ const hi = await tx.getFirstAsync(drainHiSql(p));
2841
+ if (hi?.hi != null) for (const sql of drainChunkSql(p)) await tx.runAsync(sql, [hi.hi]);
2842
+ await this.metadataRepo.deleteMeta(FTS5_REBUILD_WM_KEY, tx);
2843
+ await this.metadataRepo.deleteMeta(FTS5_REBUILD_SNAPSHOT_KEY, tx);
2844
+ await this.metadataRepo.deleteMeta(FTS5_SWEEP_CURSOR_KEY, tx);
2624
2845
  await this.metadataRepo.setMeta(FTS5_STATE_KEY, "live", tx);
2625
2846
  });
2626
2847
  }
2627
- async rebuildFromSource() {
2628
- await this.db.withTransactionAsync((tx) => this.rebuildInTx(tx));
2848
+ /** Seq-ordered ledger chunks until empty or `stopAt`. True when a chunk ran. */
2849
+ async drainLedgerUntil(stopAt) {
2850
+ let ran = false;
2851
+ while (await this.drainLedgerChunk()) {
2852
+ ran = true;
2853
+ if (monotonicNow() >= stopAt) break;
2854
+ }
2855
+ return ran;
2629
2856
  }
2630
- async drain() {
2631
- const hiSql = drainHiSql(this.prefix);
2632
- const chunk = drainChunkSql(this.prefix);
2633
- for (; ; ) {
2634
- const done = await this.db.withTransactionAsync(async (tx) => {
2635
- const row = await tx.getFirstAsync(hiSql);
2636
- if (row?.hi == null) return true;
2637
- for (const sql of chunk) await tx.runAsync(sql, [row.hi]);
2638
- return false;
2857
+ /** The caller's own ids, to completion, ≤ FTS5_DRAIN_CHUNK per transaction. */
2858
+ async drainIds(ids, chunk = idDrainChunkSql(this.prefix)) {
2859
+ const unique = [...new Set(ids)];
2860
+ for (let i = 0; i < unique.length; i += FTS5_DRAIN_CHUNK) {
2861
+ const json = JSON.stringify(unique.slice(i, i + FTS5_DRAIN_CHUNK));
2862
+ await this.db.withTransactionAsync(async (tx) => {
2863
+ for (const sql of chunk) await tx.runAsync(sql, [json]);
2639
2864
  });
2640
- if (done) return;
2641
2865
  }
2642
2866
  }
2867
+ /** One seq-ordered chunk. False when the ledger was empty. */
2868
+ async drainLedgerChunk() {
2869
+ const hiSql = drainHiSql(this.prefix);
2870
+ const chunk = drainChunkSql(this.prefix);
2871
+ let ran = false;
2872
+ await this.db.withTransactionAsync(async (tx) => {
2873
+ const row = await tx.getFirstAsync(hiSql);
2874
+ if (row?.hi == null) return;
2875
+ for (const sql of chunk) await tx.runAsync(sql, [row.hi]);
2876
+ ran = true;
2877
+ });
2878
+ return ran;
2879
+ }
2880
+ /** One entity-targeted chunk (ORDER BY seq, ≤ FTS5_DRAIN_CHUNK). */
2881
+ async drainEntityChunk(entityId) {
2882
+ const hiSql = entityDrainHiSql(this.prefix);
2883
+ const chunk = entityDrainChunkSql(this.prefix);
2884
+ await this.db.withTransactionAsync(async (tx) => {
2885
+ const row = await tx.getFirstAsync(hiSql, [entityId]);
2886
+ if (row?.hi == null) return;
2887
+ for (const sql of chunk) await tx.runAsync(sql, [entityId, row.hi]);
2888
+ });
2889
+ }
2643
2890
  async replace(entityId, ids, documents) {
2644
2891
  assertEntity(entityId, documents);
2645
2892
  const docIds = documents.map((d) => d.id);
@@ -2717,20 +2964,57 @@ var Fts5IndexStrategy = class {
2717
2964
  );
2718
2965
  }
2719
2966
  }
2720
- async rebuildInTx(tx) {
2967
+ /** The init probes' schema facts in one sqlite_master read (#284 review): FTS tables with their DDL, and the triggers. */
2968
+ async readSchema() {
2721
2969
  const p = this.prefix;
2722
- await tx.execAsync(`DELETE FROM ${p}entries_fts; DELETE FROM ${p}fts_map; DELETE FROM ${p}fts_pending;`);
2723
- for (const sql of rebuildSql(p)) await tx.runAsync(sql);
2724
- }
2725
- async triggersPresent() {
2726
- const names = fts5TriggerNames(this.prefix);
2970
+ const triggers = fts5TriggerNames(p);
2971
+ const names = [`${p}entries_fts`, `${p}fts_map`, `${p}fts_pending`, ...triggers];
2727
2972
  const rows = await this.db.getAllAsync(
2728
- `SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?, ?)`,
2729
- [...names]
2973
+ `SELECT type, name, sql FROM sqlite_master WHERE name IN (${names.map(() => "?").join(", ")})`,
2974
+ names
2730
2975
  );
2731
- return rows.length === names.length;
2976
+ const tables = new Map(rows.filter((r) => r.type === "table").map((r) => [r.name, r.sql]));
2977
+ const present = new Set(rows.filter((r) => r.type === "trigger").map((r) => r.name));
2978
+ return { tables, triggersPresent: triggers.every((n) => present.has(n)) };
2979
+ }
2980
+ };
2981
+ var FtsOnlyStatementError = class extends Error {
2982
+ constructor(cause) {
2983
+ super("FTS-only statement failed");
2984
+ this.ftsOnlyCause = cause;
2732
2985
  }
2733
2986
  };
2987
+ function unwrapFtsOnly(err) {
2988
+ try {
2989
+ if (err instanceof FtsOnlyStatementError) return { cause: err.ftsOnlyCause, ftsOnly: true };
2990
+ } catch {
2991
+ }
2992
+ return { cause: err, ftsOnly: false };
2993
+ }
2994
+ function safeSqliteCode(err) {
2995
+ try {
2996
+ const code = extractSqliteCode(err);
2997
+ if (code !== void 0) return code;
2998
+ const lifted = err?.sqliteErrorCode;
2999
+ return typeof lifted === "string" ? lifted : void 0;
3000
+ } catch {
3001
+ return void 0;
3002
+ }
3003
+ }
3004
+ var TRANSIENT_OR_TX_FATAL = ["SQLITE_BUSY", "SQLITE_LOCKED", "SQLITE_FULL", "SQLITE_IOERR", "SQLITE_NOMEM", "SQLITE_INTERRUPT", "SQLITE_ABORT", "SQLITE_READONLY"];
3005
+ function isTransientOrTxFatal(err) {
3006
+ const code = safeSqliteCode(err);
3007
+ return code !== void 0 && TRANSIENT_OR_TX_FATAL.some((c) => code === c || code.startsWith(`${c}_`));
3008
+ }
3009
+ function ftsCorruptionClass(err) {
3010
+ const code = safeSqliteCode(err);
3011
+ if (code === "SQLITE_CORRUPT_VTAB") return "vtab";
3012
+ if (code?.startsWith("SQLITE_CORRUPT")) return "corrupt";
3013
+ return null;
3014
+ }
3015
+ function isSqliteError(err) {
3016
+ return safeSqliteCode(err) !== void 0;
3017
+ }
2734
3018
  function assertEntity(entityId, documents) {
2735
3019
  for (const d of documents) {
2736
3020
  if (d.entity_id !== entityId) {
@@ -2788,15 +3072,15 @@ async function detachFts5(db, prefix, metadataRepo) {
2788
3072
  } catch {
2789
3073
  }
2790
3074
  }
2791
- async function createIndexStrategy(db, prefix, metadataRepo, preferred) {
3075
+ async function createIndexStrategy(db, prefix, metadataRepo, preferred, opts = {}) {
2792
3076
  if (preferred === "minisearch") {
2793
3077
  await detachFts5(db, prefix, metadataRepo);
2794
3078
  return new MiniSearchIndexStrategy();
2795
3079
  }
2796
3080
  const attempt = async () => {
2797
3081
  if (!await probeFts5(db, prefix)) return "absent";
2798
- const strategy = new Fts5IndexStrategy(db, prefix, metadataRepo);
2799
- await strategy.init();
3082
+ const strategy = new Fts5IndexStrategy(db, prefix, metadataRepo, opts.setupBudgetMs);
3083
+ await strategy.init({ entriesExisted: opts.entriesExisted });
2800
3084
  return strategy;
2801
3085
  };
2802
3086
  const absent = async () => {
@@ -3508,7 +3792,6 @@ var TABLE_PREFIX_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,30}_$/;
3508
3792
  var SEARCH_REINIT_COOLDOWN_MS = 5e3;
3509
3793
  var SEARCH_REINIT_COOLDOWN_MAX_MS = 5 * 6e4;
3510
3794
  var SEARCH_REINIT_COST_FACTOR = 10;
3511
- var monotonicNow = () => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
3512
3795
  function decodeDraftCursor(cursor) {
3513
3796
  const sep = typeof cursor === "string" ? cursor.indexOf(":") : -1;
3514
3797
  const createdAt = sep > 0 ? Number(cursor.slice(0, sep)) : Number.NaN;
@@ -3523,6 +3806,16 @@ var WikiMemory = class {
3523
3806
  constructor(db, options) {
3524
3807
  /** Emits `__testAccess` console warning at most once per instance when NODE_ENV ≠ "test". */
3525
3808
  __privateAdd(this, _testAccessNonTestEnvWarned, false);
3809
+ /**
3810
+ * upsertGraph node and superseded-fact ids per entity, awaiting syncSearchIndex(), each with the
3811
+ * generation of its latest stash. Ids leave the stash only once a drain
3812
+ * succeeded, so a failed drain retries them next call.
3813
+ */
3814
+ this.upsertIdStash = /* @__PURE__ */ new Map();
3815
+ /** Total ids across upsertIdStash, kept in step so the cap check is O(1). */
3816
+ this.upsertIdStashSize = 0;
3817
+ /** Bumped per stashed id; a re-stash during an in-flight drain outlives that drain's drop. */
3818
+ this.upsertIdStashGen = 0;
3526
3819
  this.reinitSearch = null;
3527
3820
  /** Monotonic time before which a no-arg syncSearchIndex() skips the re-init (0 = no cooldown). */
3528
3821
  this.reinitRetryAt = 0;
@@ -3659,6 +3952,49 @@ var WikiMemory = class {
3659
3952
  jobManager: this.jobManager
3660
3953
  };
3661
3954
  }
3955
+ stashUpsertIds(entityId, ids) {
3956
+ if (!this.searchService.hasLedger()) return;
3957
+ const cap = this.constructor.UPSERT_ID_STASH_CAP;
3958
+ const stash = this.upsertIdStash.get(entityId) ?? /* @__PURE__ */ new Map();
3959
+ for (const id of ids) {
3960
+ if (!stash.has(id)) {
3961
+ if (this.upsertIdStashSize >= cap) continue;
3962
+ this.upsertIdStashSize++;
3963
+ }
3964
+ stash.set(id, ++this.upsertIdStashGen);
3965
+ }
3966
+ if (stash.size > 0) this.upsertIdStash.set(entityId, stash);
3967
+ }
3968
+ /** Snapshot of the stash (one entity, or all) with generations, without removing anything. */
3969
+ peekStashedIds(entityId) {
3970
+ const snapshot = /* @__PURE__ */ new Map();
3971
+ for (const [id, stash] of this.upsertIdStash) {
3972
+ if (entityId === void 0 || id === entityId) snapshot.set(id, new Map(stash));
3973
+ }
3974
+ return snapshot;
3975
+ }
3976
+ /**
3977
+ * Removes a drained snapshot. An id re-stashed after the peek has a newer
3978
+ * generation and stays: the drain may have run before that write committed.
3979
+ */
3980
+ dropStashedIds(snapshot) {
3981
+ for (const [entityId, drained] of snapshot) {
3982
+ const stash = this.upsertIdStash.get(entityId);
3983
+ if (!stash) continue;
3984
+ for (const [id, gen] of drained) {
3985
+ if (stash.get(id) === gen && stash.delete(id)) this.upsertIdStashSize--;
3986
+ }
3987
+ if (stash.size === 0) this.upsertIdStash.delete(entityId);
3988
+ }
3989
+ }
3990
+ /** `config.setupBudgetMs` or the default, validated: a positive finite number. */
3991
+ setupBudgetMs() {
3992
+ const budget = this.options.config?.setupBudgetMs ?? DEFAULT_SETUP_BUDGET_MS;
3993
+ if (typeof budget !== "number" || !Number.isFinite(budget) || budget <= 0) {
3994
+ throw new TypeError(`setupBudgetMs must be a positive finite number; got ${String(budget)}`);
3995
+ }
3996
+ return budget;
3997
+ }
3662
3998
  /**
3663
3999
  * Opens the store: migration gate, DDL, schema migrations, then the stamped
3664
4000
  * source_ref normalize. Await it before any other call on this instance — the
@@ -3669,8 +4005,15 @@ var WikiMemory = class {
3669
4005
  * Pass `{ migrations: 'refuse' }` from inside a writer lease: pending one-time
3670
4006
  * work then throws WikiMigrationRequiredError before any mutation, and the
3671
4007
  * host runs a plain `setup()` outside the lease.
4008
+ *
4009
+ * Keyword-index catch-up work is bounded by `config.setupBudgetMs`; anything
4010
+ * left converges on later opens and no-arg `syncSearchIndex()` calls. Not
4011
+ * bounded: an index reset (tokenizer change, re-attach after detach, a lost
4012
+ * or inconsistent index), which runs as one unchunked transaction.
3672
4013
  */
3673
4014
  async setup(opts) {
4015
+ const setupBudgetMs = this.setupBudgetMs();
4016
+ const openDeadline = monotonicNow() + setupBudgetMs;
3674
4017
  const entriesExistedBeforeSetup = await this.metadataRepo.tableExists(`${this.prefix}entries`);
3675
4018
  const migrationPolicy = opts?.migrations ?? this.options.config?.migrations ?? "allow";
3676
4019
  await setupDatabaseWithGate(this.db, this.prefix, this.metadataRepo, migrationPolicy);
@@ -3705,10 +4048,11 @@ var WikiMemory = class {
3705
4048
  this.db,
3706
4049
  this.prefix,
3707
4050
  this.metadataRepo,
3708
- this.options.config?.indexStrategy ?? "auto"
4051
+ this.options.config?.indexStrategy ?? "auto",
4052
+ { setupBudgetMs, entriesExisted: entriesExistedBeforeSetup }
3709
4053
  )
3710
4054
  );
3711
- await this.searchService.sync();
4055
+ await this.searchService.sync(void 0, { deadline: openDeadline });
3712
4056
  }
3713
4057
  async hasChanged(entityId, sourceRefOrEntries, sourceHashArg) {
3714
4058
  if (typeof sourceRefOrEntries === "string") {
@@ -3959,14 +4303,18 @@ var WikiMemory = class {
3959
4303
  * commit, so without this the nodes stay out of keyword, hybrid and
3960
4304
  * fallback retrieval until the entity's next core write.
3961
4305
  *
3962
- * With `entityId`, rebuilds that entity if it is stale or has never been
3963
- * indexed; otherwise it is a no-op that still waits for rebuilds already
3964
- * queued. Without an argument, rebuilds every entity currently marked
3965
- * stale; when none is, it resolves immediately and does not wait for rebuilds
3966
- * already queued. While keyword search is unavailable it first retries the
3967
- * index init; after a failed attempt it waits at least 5 s, doubling per
3968
- * consecutive failure up to 5 min (longer when an attempt is itself slow),
3969
- * before retrying again.
4306
+ * With `entityId`: drains that entity's pending `upsertGraph` ids (its new
4307
+ * nodes and the facts they superseded) first, to completion, then at most
4308
+ * one more ledger chunk — so its new nodes are searchable, and the
4309
+ * superseded facts gone, when the call resolves, under any backlog. Without an
4310
+ * argument: drains every stashed `upsertGraph` id across entities first,
4311
+ * then runs one keyword-index quantum bounded by `config.setupBudgetMs`
4312
+ * (backlog drain, then rebuild work while the index is rebuilding). Hosts
4313
+ * with a long-lived process should call the no-arg form on their own
4314
+ * schedule: that is what converges a rebuilding index. While keyword search
4315
+ * is unavailable it first retries the index init; after a failed attempt it
4316
+ * waits at least 5 s, doubling per consecutive failure up to 5 min (longer
4317
+ * when an attempt is itself slow), before retrying again.
3970
4318
  *
3971
4319
  * Call only after the host's transaction commits: called inside it, the
3972
4320
  * rebuild may index rows that later roll back, or miss them and clear the
@@ -3992,10 +4340,11 @@ var WikiMemory = class {
3992
4340
  this.db,
3993
4341
  this.prefix,
3994
4342
  this.metadataRepo,
3995
- this.options.config?.indexStrategy ?? "auto"
4343
+ this.options.config?.indexStrategy ?? "auto",
4344
+ { setupBudgetMs: this.setupBudgetMs() }
3996
4345
  );
3997
4346
  this.searchService.setIndexStrategy(strategy);
3998
- if (this.searchService.isKeywordAvailable()) await this.searchService.sync();
4347
+ if (this.searchService.isKeywordAvailable() && !this.searchService.hasLedger()) await this.searchService.sync();
3999
4348
  } finally {
4000
4349
  if (this.searchService.isKeywordAvailable()) {
4001
4350
  this.reinitFailures = 0;
@@ -4011,10 +4360,24 @@ var WikiMemory = class {
4011
4360
  })());
4012
4361
  await this.reinitSearch;
4013
4362
  }
4014
- return this.searchService.syncStale();
4363
+ const stashed2 = this.peekStashedIds();
4364
+ const ids = [...stashed2.values()].flatMap((m) => [...m.keys()]);
4365
+ if (await this.syncDrained(this.searchService.syncStale({ ids }))) this.dropStashedIds(stashed2);
4366
+ return;
4015
4367
  }
4016
4368
  assertEntityId(entityId);
4017
- return this.searchService.syncEntries(entityId, []);
4369
+ const stashed = this.peekStashedIds(entityId);
4370
+ if (await this.syncDrained(this.searchService.syncEntries(entityId, stashed.get(entityId)?.keys() ?? []))) {
4371
+ this.dropStashedIds(stashed);
4372
+ }
4373
+ }
4374
+ /**
4375
+ * True when a sync applied the stashed ids. While keyword search is
4376
+ * unavailable the stand-in strategy's drain is a no-op that reports success,
4377
+ * so the ids stay stashed for the first sync after a re-init (#284 review).
4378
+ */
4379
+ async syncDrained(sync) {
4380
+ return await sync && this.searchService.isKeywordAvailable();
4018
4381
  }
4019
4382
  async exportDump(entityIds) {
4020
4383
  return this.importExportService.exportDump(entityIds);
@@ -4065,7 +4428,10 @@ var WikiMemory = class {
4065
4428
  * post-commit work (search sync, embedding, cache eviction). After the
4066
4429
  * transaction commits, call `syncSearchIndex(entityId)` — or
4067
4430
  * `syncSearchIndex()` after writing several entities in one transaction —
4068
- * to make the new nodes keyword-searchable. Embeddings still come from the maintenance
4431
+ * to make the new nodes keyword-searchable: both forms drain this call's node ids
4432
+ * first, to completion, so the nodes are searchable under any backlog (up to
4433
+ * `WikiMemory.UPSERT_ID_STASH_CAP` pending ids; beyond that they converge with
4434
+ * the backlog). Embeddings still come from the maintenance
4069
4435
  * sweep (`runLibrarian` / `runHeal` / `runOntologyBackfill` / `runPrune`,
4070
4436
  * scoped via `listEntityIds`).
4071
4437
  *
@@ -4115,12 +4481,13 @@ var WikiMemory = class {
4115
4481
  });
4116
4482
  }
4117
4483
  const diagBuffer = new DiagnosticBuffer();
4118
- const result = await this.ingestionService.upsertGraphCore(
4484
+ const { supersededFactIds, ...result } = await this.ingestionService.upsertGraphCore(
4119
4485
  entityId,
4120
4486
  { sourceRef, sourceHash, nodes: params.nodes, edges: params.edges },
4121
4487
  adapter,
4122
4488
  { diag: { buffer: diagBuffer, operation: "upsertGraph" } }
4123
4489
  );
4490
+ this.stashUpsertIds(entityId, [...supersededFactIds, ...params.nodes.map((n) => n.id)]);
4124
4491
  this.searchService.markStale(entityId);
4125
4492
  diagBuffer.flush(this.options);
4126
4493
  return result;
@@ -4303,6 +4670,8 @@ var WikiMemory = class {
4303
4670
  }
4304
4671
  };
4305
4672
  _testAccessNonTestEnvWarned = new WeakMap();
4673
+ /** Max upsertGraph node ids held for their post-commit sync (spec #280 id threading). */
4674
+ WikiMemory.UPSERT_ID_STASH_CAP = 1e4;
4306
4675
  function assertEntityId(entityId) {
4307
4676
  if (typeof entityId !== "string" || entityId.length === 0) {
4308
4677
  throw new TypeError("Invalid entityId: must be a non-empty string.");
@@ -5228,6 +5597,6 @@ function createWiki(db, options) {
5228
5597
  return new WikiMemory(db, options);
5229
5598
  }
5230
5599
 
5231
- export { DEFAULT_LIBRARIAN_SYNTHESIS_PROMPT, WikiMemory, createWiki, formatContext, formatGraphContext, formatMemoryDump, formatOkfBundle, hydrateLibrarianPrompt, mapLibrarianOptionsToReadOptions, parseOkfBundle, validateLibrarianPromptTemplate };
5600
+ export { DEFAULT_LIBRARIAN_SYNTHESIS_PROMPT, DEFAULT_SETUP_BUDGET_MS, WikiMemory, createWiki, formatContext, formatGraphContext, formatMemoryDump, formatOkfBundle, hydrateLibrarianPrompt, mapLibrarianOptionsToReadOptions, parseOkfBundle, validateLibrarianPromptTemplate };
5232
5601
  //# sourceMappingURL=index.mjs.map
5233
5602
  //# sourceMappingURL=index.mjs.map