@omgbase/core 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/src/core/ids.d.ts +34 -3
  2. package/dist/src/core/ids.js +79 -6
  3. package/dist/src/core/ingest.js +15 -12
  4. package/dist/src/core/read/document.js +6 -1
  5. package/dist/src/core/read/nodes.js +5 -1
  6. package/dist/src/core/store/fts.d.ts +47 -7
  7. package/dist/src/core/store/fts.js +73 -9
  8. package/dist/src/core/store/rebuild.js +5 -4
  9. package/dist/src/core/store/store.d.ts +4 -0
  10. package/dist/src/core/store/store.js +40 -1
  11. package/dist/src/format/index.d.ts +12 -1
  12. package/dist/src/format/index.js +14 -1
  13. package/dist/src/format/markdown.d.ts +7 -0
  14. package/dist/src/format/markdown.js +7 -0
  15. package/dist/src/graph/history.d.ts +21 -0
  16. package/dist/src/graph/history.js +48 -6
  17. package/dist/src/graph/links.d.ts +2 -0
  18. package/dist/src/graph/links.js +22 -11
  19. package/dist/src/index.d.ts +8 -6
  20. package/dist/src/index.js +7 -5
  21. package/dist/src/mcp/errors.d.ts +8 -0
  22. package/dist/src/mcp/errors.js +17 -3
  23. package/dist/src/mcp/server.js +50 -57
  24. package/dist/src/mutate/apply.js +12 -4
  25. package/dist/src/mutate/doc-store.js +4 -1
  26. package/dist/src/mutate/docs.d.ts +19 -0
  27. package/dist/src/mutate/docs.js +64 -16
  28. package/dist/src/mutate/ops.js +25 -13
  29. package/dist/src/oqx-js/context.d.ts +11 -0
  30. package/dist/src/oqx-js/context.js +16 -0
  31. package/dist/src/oqx-js/planner.d.ts +20 -1
  32. package/dist/src/oqx-js/planner.js +95 -2
  33. package/dist/src/oqx-js/run.js +27 -2
  34. package/dist/src/oqx-js/sql/translate.d.ts +94 -1
  35. package/dist/src/oqx-js/sql/translate.js +263 -79
  36. package/dist/src/search/tasks.d.ts +15 -1
  37. package/dist/src/search/tasks.js +31 -3
  38. package/dist/src/sync/admin.d.ts +13 -0
  39. package/dist/src/sync/admin.js +11 -0
  40. package/dist/src/sync/external-source.js +20 -7
  41. package/dist/src/sync/plugin.d.ts +30 -5
  42. package/dist/src/sync/plugin.js +13 -1
  43. package/dist/src/sync/watcher.d.ts +2 -0
  44. package/dist/src/sync/watcher.js +10 -2
  45. package/package.json +1 -1
@@ -2,17 +2,48 @@ export type IdPrefix = "d" | "b" | "c" | "r" | "x" | "col" | "cp" | "e" | "rp" |
2
2
  export declare function randomSuffix(len?: number): string;
3
3
  /** A replacement id generator (spec/store §2.2): given a prefix, return the whole id. */
4
4
  export type IdMinter = (prefix: IdPrefix) => string;
5
- /** Install a replacement minter (null restores the CSPRNG default). */
5
+ /**
6
+ * An in-use oracle (spec/store §2.1): does `id` name a row of the prefix's
7
+ * table(s)? A `Store` registers one when it opens and unregisters it on close.
8
+ * `col`/`v` have no table; an oracle answers false for them.
9
+ */
10
+ export type IdOracle = (prefix: IdPrefix, id: string) => boolean;
11
+ /**
12
+ * Consecutive rejections after which a mint gives up. The CSPRNG cannot hit
13
+ * this (32⁷ candidates against at most millions in use); only a minter that
14
+ * can never produce a fresh id — a broken fixture minter — does, and it must
15
+ * fail loudly rather than spin forever.
16
+ */
17
+ export declare const MINT_GIVE_UP_AFTER = 1000;
18
+ /** Install a replacement minter (null restores the CSPRNG default). Starts a fresh issued set. */
6
19
  export declare function setIdMinter(minter: IdMinter | null): void;
7
- /** Run `body` with `minter` installed, restoring the previous minter afterwards (also on throw). */
20
+ /** Run `body` with `minter` installed, restoring the previous minter (and its issued set) afterwards (also on throw). */
8
21
  export declare function withIdMinter<T>(minter: IdMinter, body: () => T): T;
22
+ /**
23
+ * Register an in-use oracle (spec/store §2.1); returns the unregister handle.
24
+ * Every open `Store` holds one, so a mint is checked against every database
25
+ * this process has open — a false "in use" from another store costs one redraw.
26
+ */
27
+ export declare function registerIdOracle(oracle: IdOracle): () => void;
9
28
  /**
10
29
  * The fixture minter (spec/store §2.2): a sequential per-prefix counter —
11
30
  * `d_0, d_1, …`, `b_0, …`, each prefix counting from 0 independently. A fresh
12
31
  * instance per case resets every counter.
13
32
  */
14
33
  export declare function sequentialMinter(): IdMinter;
15
- /** Mint an ID with the given prefix. Collision checking is the store's job. */
34
+ /**
35
+ * The repeating fixture minter (spec/store §9.4, `"minter": "repeat"`): the
36
+ * sequential counter whose every id is offered twice — `rp_0, rp_0, b_0, b_0,
37
+ * b_1, b_1, …`. The store rejects the second offering as in use and asks
38
+ * again, so a case run under it projects exactly as under `sequentialMinter`.
39
+ */
40
+ export declare function repeatingMinter(): IdMinter;
41
+ /**
42
+ * Mint an id with the given prefix (spec/store §2.1 "Uniqueness at mint"):
43
+ * draw a candidate — from the installed minter, else the CSPRNG — and redraw
44
+ * while it is in use (issued in this process, or present in a registered
45
+ * store). Gives up after `MINT_GIVE_UP_AFTER` consecutive rejections.
46
+ */
16
47
  export declare function mintId(prefix: IdPrefix): string;
17
48
  export declare function isValidId(id: string, prefix?: IdPrefix): boolean;
18
49
  export declare function prefixOf(id: string): string | null;
@@ -8,6 +8,13 @@ export function randomSuffix(len = ID_LEN) {
8
8
  s += ALPHABET[randomInt(ALPHABET.length)];
9
9
  return s;
10
10
  }
11
+ /**
12
+ * Consecutive rejections after which a mint gives up. The CSPRNG cannot hit
13
+ * this (32⁷ candidates against at most millions in use); only a minter that
14
+ * can never produce a fresh id — a broken fixture minter — does, and it must
15
+ * fail loudly rather than spin forever.
16
+ */
17
+ export const MINT_GIVE_UP_AFTER = 1_000;
11
18
  // The installed minter, or null for production (CSPRNG). spec/store §2.2: the
12
19
  // store's tree hashes contain block ids, so a fixture runner installs a
13
20
  // deterministic per-prefix counter here to make ids comparable across
@@ -15,21 +22,47 @@ export function randomSuffix(len = ID_LEN) {
15
22
  // the reconcile module's phase 7 mints), commits, revisions, repos, sources —
16
23
  // goes through `mintId`, so one seam covers them all.
17
24
  let installed = null;
18
- /** Install a replacement minter (null restores the CSPRNG default). */
25
+ // Ids issued by the production minter in this process: a candidate already
26
+ // here is redrawn without asking any oracle. Bounded by the number of ids the
27
+ // process mints (a 360k-block ingest is a few tens of MB at most).
28
+ const productionIssued = new Set();
29
+ // Ids issued while a replacement minter is installed. A *fresh* set per
30
+ // install: a fixture case runs on a fresh database, so what the previous case
31
+ // minted must not reject this one's `d_0` — sequential ids stay byte-identical
32
+ // across cases. The repeating minter's second offering of an id is caught
33
+ // here (the row may not be written yet, so the oracle alone would miss it).
34
+ let installedIssued = new Set();
35
+ const oracles = new Set();
36
+ /** Install a replacement minter (null restores the CSPRNG default). Starts a fresh issued set. */
19
37
  export function setIdMinter(minter) {
20
38
  installed = minter;
39
+ installedIssued = new Set();
21
40
  }
22
- /** Run `body` with `minter` installed, restoring the previous minter afterwards (also on throw). */
41
+ /** Run `body` with `minter` installed, restoring the previous minter (and its issued set) afterwards (also on throw). */
23
42
  export function withIdMinter(minter, body) {
24
43
  const previous = installed;
44
+ const previousIssued = installedIssued;
25
45
  installed = minter;
46
+ installedIssued = new Set();
26
47
  try {
27
48
  return body();
28
49
  }
29
50
  finally {
30
51
  installed = previous;
52
+ installedIssued = previousIssued;
31
53
  }
32
54
  }
55
+ /**
56
+ * Register an in-use oracle (spec/store §2.1); returns the unregister handle.
57
+ * Every open `Store` holds one, so a mint is checked against every database
58
+ * this process has open — a false "in use" from another store costs one redraw.
59
+ */
60
+ export function registerIdOracle(oracle) {
61
+ oracles.add(oracle);
62
+ return () => {
63
+ oracles.delete(oracle);
64
+ };
65
+ }
33
66
  /**
34
67
  * The fixture minter (spec/store §2.2): a sequential per-prefix counter —
35
68
  * `d_0, d_1, …`, `b_0, …`, each prefix counting from 0 independently. A fresh
@@ -43,11 +76,51 @@ export function sequentialMinter() {
43
76
  return `${prefix}_${n}`;
44
77
  };
45
78
  }
46
- /** Mint an ID with the given prefix. Collision checking is the store's job. */
79
+ /**
80
+ * The repeating fixture minter (spec/store §9.4, `"minter": "repeat"`): the
81
+ * sequential counter whose every id is offered twice — `rp_0, rp_0, b_0, b_0,
82
+ * b_1, b_1, …`. The store rejects the second offering as in use and asks
83
+ * again, so a case run under it projects exactly as under `sequentialMinter`.
84
+ */
85
+ export function repeatingMinter() {
86
+ const next = sequentialMinter();
87
+ const pending = new Map();
88
+ return (prefix) => {
89
+ const again = pending.get(prefix);
90
+ if (again !== undefined) {
91
+ pending.delete(prefix);
92
+ return again;
93
+ }
94
+ const id = next(prefix);
95
+ pending.set(prefix, id);
96
+ return id;
97
+ };
98
+ }
99
+ function inUse(prefix, id, issued) {
100
+ if (issued.has(id))
101
+ return true;
102
+ for (const oracle of oracles)
103
+ if (oracle(prefix, id))
104
+ return true;
105
+ return false;
106
+ }
107
+ /**
108
+ * Mint an id with the given prefix (spec/store §2.1 "Uniqueness at mint"):
109
+ * draw a candidate — from the installed minter, else the CSPRNG — and redraw
110
+ * while it is in use (issued in this process, or present in a registered
111
+ * store). Gives up after `MINT_GIVE_UP_AFTER` consecutive rejections.
112
+ */
47
113
  export function mintId(prefix) {
48
- if (installed)
49
- return installed(prefix);
50
- return `${prefix}_${randomSuffix()}`;
114
+ const minter = installed;
115
+ const issued = minter ? installedIssued : productionIssued;
116
+ for (let rejected = 0; rejected < MINT_GIVE_UP_AFTER; rejected++) {
117
+ const candidate = minter ? minter(prefix) : `${prefix}_${randomSuffix()}`;
118
+ if (inUse(prefix, candidate, issued))
119
+ continue;
120
+ issued.add(candidate);
121
+ return candidate;
122
+ }
123
+ throw new Error(`mintId(${prefix}): ${MINT_GIVE_UP_AFTER} consecutive candidates were already in use — the ${minter ? "installed" : "production"} minter cannot produce a fresh id`);
51
124
  }
52
125
  // Production ids carry exactly ID_LEN suffix characters; the fixture minter
53
126
  // (spec/store §2.2) mints `d_0`, `b_12`, … — one to seven alphabet characters.
@@ -6,7 +6,7 @@ import { assignIds, writeBlockTree, putBlob, newCommit, writeRevision } from "./
6
6
  import { sha256, childQuoteDepth, visibleText } from "./hash.js";
7
7
  import { mintId } from "./ids.js";
8
8
  import { keyBetween } from "./order-key.js";
9
- import { ftsDeleteDoc, ftsIndexDoc } from "./store/fts.js";
9
+ import { ftsBeforeEvictRows, ftsDeleteDoc, ftsIndexDoc } from "./store/fts.js";
10
10
  import { rebuildSections } from "./store/sections.js";
11
11
  import { writeDocNodes, projectSectionNodes, toByteSpans } from "./store/nodes.js";
12
12
  import { flattenFrontmatter, flattenComputed, writeDocProperties } from "./store/properties.js";
@@ -288,25 +288,28 @@ export function ingestFile(store, repoId, path, content, opts = {}) {
288
288
  });
289
289
  }
290
290
  // Remove another document's `blocks` row for each id in `ids` (a cross-doc move
291
- // or a resurrection re-homes the id into `docId`). A live foreign row still has
292
- // an FTS entry (external-content index: delete with the original text); a
293
- // tombstoned one does not. The source document, if it commits later in the same
291
+ // or a resurrection re-homes the id into `docId`). A live foreign LEAF row still
292
+ // has an FTS entry (external-content index: delete with the original text); a
293
+ // tombstoned row or a container does not (spec/search §1.1) — ftsBeforeEvictRows
294
+ // decides leaf-ness over the table before any row goes and re-indexes a parent
295
+ // left childless. The source document, if it commits later in the same
294
296
  // checkpoint, rewrites its rows wholesale and never lists a carried id as
295
297
  // deleted, so nothing there depends on the evicted row.
296
298
  function evictForeignBlockRows(db, docId, ids) {
297
- const sel = db.prepare("SELECT rowid, text, deleted_commit FROM blocks WHERE block_id = ? AND doc_id != ?");
298
- const ftsDel = db.prepare("INSERT INTO blocks_fts(blocks_fts, rowid, text) VALUES('delete', ?, ?)");
299
+ const sel = db.prepare("SELECT rowid, block_id, doc_id, parent_block, text, deleted_commit FROM blocks WHERE block_id = ? AND doc_id != ?");
299
300
  const del = db.prepare("DELETE FROM blocks WHERE block_id = ? AND doc_id != ?");
300
301
  const unpool = db.prepare("DELETE FROM resurrection_pool WHERE block_id = ?");
302
+ const rows = [];
301
303
  for (const id of ids) {
302
304
  const row = sel.get(id, docId);
303
- if (row) {
304
- if (row.deleted_commit === null)
305
- ftsDel.run(row.rowid, row.text);
306
- del.run(id, docId);
307
- }
308
- unpool.run(id);
305
+ if (row)
306
+ rows.push(row);
309
307
  }
308
+ ftsBeforeEvictRows(db, rows);
309
+ for (const r of rows)
310
+ del.run(r.block_id, docId);
311
+ for (const id of ids)
312
+ unpool.run(id);
310
313
  }
311
314
  // Zip assigned block ids onto the parsed RawBlocks for node projection. The
312
315
  // parsed `rest` and `assigned` trees are the same blocks in the same order and
@@ -116,8 +116,13 @@ export function docsReadMany(store, repoId, refs, opts = {}) {
116
116
  errors.push({ ref, error: "doc_not_found" });
117
117
  continue;
118
118
  }
119
+ // The budget is checked before the SECOND item onward (spec/surface §2, 1.2):
120
+ // the first item is always emitted even when it alone exceeds the budget —
121
+ // the same "at least one row" contract docs_list/docs_tree keep, so a
122
+ // budgeted client always makes progress. `truncated` stays honest: it is
123
+ // set only when the budget stopped an item that would otherwise have shipped.
119
124
  const cost = Math.ceil(JSON.stringify(read).length / 4);
120
- if (tokens + cost > budget) {
125
+ if (items.length > 0 && tokens + cost > budget) {
121
126
  truncated = true;
122
127
  break;
123
128
  }
@@ -89,8 +89,12 @@ export function nodesGetMany(store, docId, blockIds, opts = {}) {
89
89
  continue;
90
90
  }
91
91
  const projected = project(store, node, resolution, false);
92
+ // At least one resolved node is always returned (spec/surface §2, 1.2): the
93
+ // budget is checked before the second node onward, as docsReadMany does, so
94
+ // a first node that alone exceeds it still ships and `truncated` only says
95
+ // that more remained.
92
96
  const cost = Math.ceil(JSON.stringify(projected).length / 4);
93
- if (tokens + cost > budget) {
97
+ if (nodes.length > 0 && tokens + cost > budget) {
94
98
  truncated = true;
95
99
  break;
96
100
  }
@@ -1,10 +1,50 @@
1
1
  import type { Database } from "better-sqlite3";
2
- /** Remove a document's live block rows from the FTS index (call before deleting
3
- * or tombstoning blocks). Only live blocks are indexed (see ftsIndexDoc), so the
4
- * delete must target the SAME set: an already-tombstoned block has no FTS row, and
5
- * re-issuing a 'delete' for it corrupts the external-content index. Keeping this
6
- * symmetric with ftsIndexDoc (deleted_commit IS NULL) makes a tombstone followed by
7
- * a later re-ingest of the same doc id (observed delete → recreate) idempotent. */
2
+ /**
3
+ * SQL predicate: the `blocks` row aliased `b` is a live leaf. `c.doc_id =
4
+ * b.doc_id` lets the NOT EXISTS use `idx_blocks_doc` (block ids are unique, so
5
+ * it never changes the answer).
6
+ */
7
+ export declare const LIVE_LEAF_SQL = "b.deleted_commit IS NULL AND NOT EXISTS (SELECT 1 FROM blocks c WHERE c.doc_id = b.doc_id AND c.parent_block = b.block_id AND c.deleted_commit IS NULL)";
8
+ /**
9
+ * How many rows the FTS index actually holds. `SELECT count(*) FROM blocks_fts`
10
+ * is answered from the external-content table (`blocks`, tombstones included),
11
+ * so the count comes from the index's own `_docsize` shadow table — one row per
12
+ * indexed row. `doctor` compares it to the live leaf count (spec/search §1.1).
13
+ */
14
+ export declare function ftsIndexedRowCount(db: Database): number;
15
+ /** The number of live leaf blocks — the rows the FTS index must hold (spec/search §1.1). */
16
+ export declare function liveLeafCount(db: Database): number;
17
+ /** Remove a document's live leaf rows from the FTS index (call before deleting
18
+ * or tombstoning blocks). Only live leaves are indexed (see ftsIndexDoc), so the
19
+ * delete must target the SAME set: an already-tombstoned block or a container has
20
+ * no FTS row, and re-issuing a 'delete' for it corrupts the external-content
21
+ * index's statistics. Keeping this symmetric with ftsIndexDoc makes a tombstone
22
+ * followed by a later re-ingest of the same doc id (observed delete → recreate)
23
+ * idempotent. */
8
24
  export declare function ftsDeleteDoc(db: Database, docId: string): void;
9
- /** Index a document's current block rows (call after inserting blocks). */
25
+ /** Index a document's current live leaf rows (call after inserting blocks). */
10
26
  export declare function ftsIndexDoc(db: Database, docId: string): void;
27
+ /**
28
+ * Rebuild the whole index: `'delete-all'`, then every live leaf of every live
29
+ * document. (FTS5's own `'rebuild'` command reads the content table wholesale,
30
+ * containers included, so it is not used.)
31
+ */
32
+ export declare function ftsRebuild(db: Database): void;
33
+ /**
34
+ * Remove single `blocks` rows (by id) that are about to be deleted out of
35
+ * another document's row set — the cross-document eviction of spec/store §5.4
36
+ * step 8 — keeping the index equal to the table's live leaves. Every row's
37
+ * leaf-ness is decided over the table BEFORE any of them goes (so a list moving
38
+ * with its items is order-independent: the items are leaves and lose their
39
+ * entries, the list never had one), and a live parent that keeps its row but
40
+ * loses its last live child becomes a leaf and gains an entry. The caller owns
41
+ * the DELETE of the rows, which must follow this call.
42
+ */
43
+ export declare function ftsBeforeEvictRows(db: Database, rows: {
44
+ rowid: number;
45
+ block_id: string;
46
+ doc_id: string;
47
+ parent_block: string | null;
48
+ text: string;
49
+ deleted_commit: string | null;
50
+ }[]): void;
@@ -1,22 +1,86 @@
1
- /** Remove a document's live block rows from the FTS index (call before deleting
2
- * or tombstoning blocks). Only live blocks are indexed (see ftsIndexDoc), so the
3
- * delete must target the SAME set: an already-tombstoned block has no FTS row, and
4
- * re-issuing a 'delete' for it corrupts the external-content index. Keeping this
5
- * symmetric with ftsIndexDoc (deleted_commit IS NULL) makes a tombstone followed by
6
- * a later re-ingest of the same doc id (observed delete → recreate) idempotent. */
1
+ /**
2
+ * SQL predicate: the `blocks` row aliased `b` is a live leaf. `c.doc_id =
3
+ * b.doc_id` lets the NOT EXISTS use `idx_blocks_doc` (block ids are unique, so
4
+ * it never changes the answer).
5
+ */
6
+ export const LIVE_LEAF_SQL = "b.deleted_commit IS NULL AND NOT EXISTS (SELECT 1 FROM blocks c WHERE c.doc_id = b.doc_id AND c.parent_block = b.block_id AND c.deleted_commit IS NULL)";
7
+ /**
8
+ * How many rows the FTS index actually holds. `SELECT count(*) FROM blocks_fts`
9
+ * is answered from the external-content table (`blocks`, tombstones included),
10
+ * so the count comes from the index's own `_docsize` shadow table — one row per
11
+ * indexed row. `doctor` compares it to the live leaf count (spec/search §1.1).
12
+ */
13
+ export function ftsIndexedRowCount(db) {
14
+ return db.prepare("SELECT count(*) c FROM blocks_fts_docsize").get().c;
15
+ }
16
+ /** The number of live leaf blocks — the rows the FTS index must hold (spec/search §1.1). */
17
+ export function liveLeafCount(db) {
18
+ return db.prepare(`SELECT count(*) c FROM blocks b WHERE ${LIVE_LEAF_SQL}`).get().c;
19
+ }
20
+ /** Remove a document's live leaf rows from the FTS index (call before deleting
21
+ * or tombstoning blocks). Only live leaves are indexed (see ftsIndexDoc), so the
22
+ * delete must target the SAME set: an already-tombstoned block or a container has
23
+ * no FTS row, and re-issuing a 'delete' for it corrupts the external-content
24
+ * index's statistics. Keeping this symmetric with ftsIndexDoc makes a tombstone
25
+ * followed by a later re-ingest of the same doc id (observed delete → recreate)
26
+ * idempotent. */
7
27
  export function ftsDeleteDoc(db, docId) {
8
- const rows = db.prepare("SELECT rowid, text FROM blocks WHERE doc_id = ? AND deleted_commit IS NULL").all(docId);
28
+ const rows = db.prepare(`SELECT b.rowid AS rowid, b.text AS text FROM blocks b WHERE b.doc_id = ? AND ${LIVE_LEAF_SQL}`).all(docId);
9
29
  const del = db.prepare("INSERT INTO blocks_fts(blocks_fts, rowid, text) VALUES('delete', ?, ?)");
10
30
  for (const r of rows)
11
31
  del.run(r.rowid, r.text);
12
32
  }
13
- /** Index a document's current block rows (call after inserting blocks). */
33
+ /** Index a document's current live leaf rows (call after inserting blocks). */
14
34
  export function ftsIndexDoc(db, docId) {
15
35
  const rows = db
16
- .prepare("SELECT rowid, text FROM blocks WHERE doc_id = ? AND deleted_commit IS NULL")
36
+ .prepare(`SELECT b.rowid AS rowid, b.text AS text FROM blocks b WHERE b.doc_id = ? AND ${LIVE_LEAF_SQL}`)
17
37
  .all(docId);
18
38
  const ins = db.prepare("INSERT INTO blocks_fts(rowid, text) VALUES(?, ?)");
19
39
  for (const r of rows)
20
40
  ins.run(r.rowid, r.text);
21
41
  }
42
+ /**
43
+ * Rebuild the whole index: `'delete-all'`, then every live leaf of every live
44
+ * document. (FTS5's own `'rebuild'` command reads the content table wholesale,
45
+ * containers included, so it is not used.)
46
+ */
47
+ export function ftsRebuild(db) {
48
+ db.exec("INSERT INTO blocks_fts(blocks_fts) VALUES('delete-all')");
49
+ const docIds = db.prepare("SELECT doc_id FROM docs WHERE deleted_commit IS NULL").all().map((r) => r.doc_id);
50
+ for (const docId of docIds)
51
+ ftsIndexDoc(db, docId);
52
+ }
53
+ /**
54
+ * Remove single `blocks` rows (by id) that are about to be deleted out of
55
+ * another document's row set — the cross-document eviction of spec/store §5.4
56
+ * step 8 — keeping the index equal to the table's live leaves. Every row's
57
+ * leaf-ness is decided over the table BEFORE any of them goes (so a list moving
58
+ * with its items is order-independent: the items are leaves and lose their
59
+ * entries, the list never had one), and a live parent that keeps its row but
60
+ * loses its last live child becomes a leaf and gains an entry. The caller owns
61
+ * the DELETE of the rows, which must follow this call.
62
+ */
63
+ export function ftsBeforeEvictRows(db, rows) {
64
+ if (rows.length === 0)
65
+ return;
66
+ const evicted = new Set(rows.map((r) => r.block_id));
67
+ const hasLiveChild = db.prepare("SELECT 1 FROM blocks c WHERE c.doc_id = ? AND c.parent_block = ? AND c.deleted_commit IS NULL LIMIT 1");
68
+ const del = db.prepare("INSERT INTO blocks_fts(blocks_fts, rowid, text) VALUES('delete', ?, ?)");
69
+ const leaves = rows.filter((r) => r.deleted_commit === null && !hasLiveChild.get(r.doc_id, r.block_id));
70
+ for (const r of leaves)
71
+ del.run(r.rowid, r.text);
72
+ // Parents that stay live but are left childless are leaves from now on.
73
+ const parentRow = db.prepare(`SELECT b.rowid AS rowid, b.text AS text FROM blocks b WHERE b.block_id = ? AND b.doc_id = ? AND b.deleted_commit IS NULL
74
+ AND NOT EXISTS (SELECT 1 FROM blocks c WHERE c.doc_id = b.doc_id AND c.parent_block = b.block_id AND c.deleted_commit IS NULL AND c.block_id NOT IN (${rows.map(() => "?").join(",")}))`);
75
+ const ins = db.prepare("INSERT INTO blocks_fts(rowid, text) VALUES(?, ?)");
76
+ const seen = new Set();
77
+ for (const r of rows) {
78
+ if (r.deleted_commit !== null || !r.parent_block || evicted.has(r.parent_block) || seen.has(r.parent_block))
79
+ continue;
80
+ seen.add(r.parent_block);
81
+ const p = parentRow.get(r.parent_block, r.doc_id, ...rows.map((x) => x.block_id));
82
+ if (p)
83
+ ins.run(p.rowid, p.text);
84
+ }
85
+ }
22
86
  //# sourceMappingURL=fts.js.map
@@ -1,6 +1,6 @@
1
1
  import { rebuildSections } from "./sections.js";
2
2
  import { rebuildDocEdges } from "./edges.js";
3
- import { ftsIndexDoc } from "./fts.js";
3
+ import { ftsRebuild } from "./fts.js";
4
4
  export function rebuildIndex(store, target = "all") {
5
5
  store.write((db) => {
6
6
  const docIds = db.prepare("SELECT doc_id FROM docs WHERE deleted_commit IS NULL").all().map((r) => r.doc_id);
@@ -14,8 +14,10 @@ export function rebuildIndex(store, target = "all") {
14
14
  rebuildDocEdges(db, docId);
15
15
  }
16
16
  if (target === "fts" || target === "all") {
17
- // Rebuild the external-content FTS index from current block text.
18
- db.exec("INSERT INTO blocks_fts(blocks_fts) VALUES('rebuild')");
17
+ // spec/search §1.1: 'delete-all', then every live leaf of every live doc
18
+ // (FTS5's 'rebuild' would index the content table wholesale, containers
19
+ // included).
20
+ ftsRebuild(db);
19
21
  }
20
22
  if (target === "block_changes" || target === "all") {
21
23
  // block_changes is a projection of dispositions across commits: rebuild
@@ -24,6 +26,5 @@ export function rebuildIndex(store, target = "all") {
24
26
  db.exec("INSERT OR IGNORE INTO block_changes (block_id, commit_id, kind) SELECT block_id, commit_id, kind FROM dispositions");
25
27
  }
26
28
  });
27
- void ftsIndexDoc; // fts rebuild uses the FTS5 'rebuild' command directly
28
29
  }
29
30
  //# sourceMappingURL=rebuild.js.map
@@ -5,7 +5,11 @@ export interface StoreOptions {
5
5
  }
6
6
  export declare class Store {
7
7
  readonly db: Database.Database;
8
+ private readonly unregisterOracle;
9
+ private readonly inUseStmts;
8
10
  constructor(opts: StoreOptions);
11
+ /** spec/store §2.1: does `id` name a row of the prefix's table(s)? */
12
+ private idInUse;
9
13
  private migrate;
10
14
  private migrateV3;
11
15
  private migrateV4;
@@ -3,9 +3,28 @@ import { mkdirSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { DDL, SCHEMA_VERSION, MIGRATIONS, SYNC_DDL } from "./schema.js";
5
5
  import { cosineBytes } from "../vec.js";
6
- import { mintId } from "../ids.js";
6
+ import { mintId, registerIdOracle } from "../ids.js";
7
+ // spec/store §2.1 "Uniqueness at mint": the table(s) and id column a minted
8
+ // id of each prefix must not already name a row of. A block id lives on in
9
+ // history after its `blocks` row is gone, hence `block_changes` and
10
+ // `resurrection_pool`. `col` and `v` have no table and no check.
11
+ const ID_TABLES = {
12
+ d: [["docs", "doc_id"]],
13
+ b: [["blocks", "block_id"], ["block_changes", "block_id"], ["resurrection_pool", "block_id"]],
14
+ c: [["commits", "commit_id"]],
15
+ r: [["revisions", "rev_id"]],
16
+ x: [["external_nodes", "node_id"]],
17
+ e: [["edges", "edge_id"]],
18
+ cp: [["checkpoints", "id"]],
19
+ rp: [["repos", "repo_id"]],
20
+ src: [["sources", "source_id"]],
21
+ };
7
22
  export class Store {
8
23
  db;
24
+ // The in-use oracle this store registered with `mintId` (spec/store §2.1);
25
+ // `close()` calls it to unregister. Prepared statements cached per prefix.
26
+ unregisterOracle;
27
+ inUseStmts = new Map();
9
28
  constructor(opts) {
10
29
  if (opts.path !== ":memory:")
11
30
  mkdirSync(dirname(opts.path), { recursive: true });
@@ -17,6 +36,24 @@ export class Store {
17
36
  // scalar. Deterministic (same inputs → same score) so SQLite may cache it.
18
37
  this.db.function("cosine", { deterministic: true }, (a, b) => cosineBytes(a, b));
19
38
  this.migrate();
39
+ // Registered after the migration so every table the oracle reads exists.
40
+ // (A migration's own mints — v13's `src_` per repo — are checked only
41
+ // against the process's issued set; they land in tables that were empty.)
42
+ this.unregisterOracle = registerIdOracle((prefix, id) => this.idInUse(prefix, id));
43
+ }
44
+ /** spec/store §2.1: does `id` name a row of the prefix's table(s)? */
45
+ idInUse(prefix, id) {
46
+ if (!this.db.open)
47
+ return false;
48
+ let stmts = this.inUseStmts.get(prefix);
49
+ if (!stmts) {
50
+ stmts = (ID_TABLES[prefix] ?? []).map(([table, column]) => this.db.prepare(`SELECT 1 FROM ${table} WHERE ${column} = ? LIMIT 1`));
51
+ this.inUseStmts.set(prefix, stmts);
52
+ }
53
+ for (const stmt of stmts)
54
+ if (stmt.get(id) !== undefined)
55
+ return true;
56
+ return false;
20
57
  }
21
58
  migrate() {
22
59
  const current = this.db.pragma("user_version", { simple: true });
@@ -158,6 +195,8 @@ export class Store {
158
195
  return this.db.pragma(name, { simple: true });
159
196
  }
160
197
  close() {
198
+ this.unregisterOracle();
199
+ this.inUseStmts.clear();
161
200
  this.db.close();
162
201
  }
163
202
  }
@@ -1,6 +1,17 @@
1
1
  export type { FormatAdapter, AdapterEdge, ProjectedNode, EmbeddingChunk, ReconcileHints, } from "./adapter.js";
2
2
  export { AdapterCapability } from "./adapter.js";
3
3
  export { registerAdapter, adapterForFormat, adapterForPath, registeredFormats, detectFormat, } from "./registry.js";
4
- export { markdownAdapter, MARKDOWN_FORMAT } from "./markdown.js";
4
+ export { markdownAdapter, MARKDOWN_FORMAT, MARKDOWN_CONTAINER_KINDS } from "./markdown.js";
5
5
  export { yamlAdapter, YAML_FORMAT } from "./yaml.js";
6
6
  export { jsonAdapter, JSON_FORMAT } from "./json.js";
7
+ /**
8
+ * May this block take children (spec/mutate §1.1)? Markdown decides by kind —
9
+ * the parser only ever nests under `list`, `list_item`, `task`, `blockquote`
10
+ * and `table`, so an empty `children` on a heading or paragraph is structural,
11
+ * not "an empty container". The other formats have no kind table for this yet:
12
+ * a block that has children is a container, a childless one a leaf.
13
+ */
14
+ export declare function isContainerBlock(format: string, block: {
15
+ type: string;
16
+ children: readonly unknown[];
17
+ }): boolean;
@@ -1,9 +1,22 @@
1
1
  // Format adapter system. Importing this module registers the built-in adapters.
2
2
  export { AdapterCapability } from "./adapter.js";
3
3
  export { registerAdapter, adapterForFormat, adapterForPath, registeredFormats, detectFormat, } from "./registry.js";
4
- export { markdownAdapter, MARKDOWN_FORMAT } from "./markdown.js";
4
+ export { markdownAdapter, MARKDOWN_FORMAT, MARKDOWN_CONTAINER_KINDS } from "./markdown.js";
5
5
  export { yamlAdapter, YAML_FORMAT } from "./yaml.js";
6
6
  export { jsonAdapter, JSON_FORMAT } from "./json.js";
7
+ import { MARKDOWN_FORMAT, MARKDOWN_CONTAINER_KINDS } from "./markdown.js";
8
+ /**
9
+ * May this block take children (spec/mutate §1.1)? Markdown decides by kind —
10
+ * the parser only ever nests under `list`, `list_item`, `task`, `blockquote`
11
+ * and `table`, so an empty `children` on a heading or paragraph is structural,
12
+ * not "an empty container". The other formats have no kind table for this yet:
13
+ * a block that has children is a container, a childless one a leaf.
14
+ */
15
+ export function isContainerBlock(format, block) {
16
+ if (format === MARKDOWN_FORMAT)
17
+ return MARKDOWN_CONTAINER_KINDS.has(block.type);
18
+ return block.children.length > 0;
19
+ }
7
20
  // Register built-in adapters.
8
21
  import { registerAdapter } from "./registry.js";
9
22
  import { markdownAdapter } from "./markdown.js";
@@ -1,5 +1,12 @@
1
1
  import { type FormatAdapter } from "./adapter.js";
2
2
  import { assertFullCoverage } from "../core/parse/tree.js";
3
3
  export declare const MARKDOWN_FORMAT = "markdown";
4
+ /**
5
+ * The Markdown block kinds whose `children` the parser produces (spec/format;
6
+ * `core/parse/parse.ts` CONTAINER_TYPES on the mdast side: list → list_item /
7
+ * task, blockquote, table → table_row). Every other kind is a leaf: its raw IS
8
+ * its whole content, so nothing may be placed inside it (spec/mutate §1.1).
9
+ */
10
+ export declare const MARKDOWN_CONTAINER_KINDS: ReadonlySet<string>;
4
11
  export declare const markdownAdapter: FormatAdapter;
5
12
  export { assertFullCoverage };
@@ -4,6 +4,13 @@ import { parseTree, assertFullCoverage } from "../core/parse/tree.js";
4
4
  import { render } from "../core/parse/render.js";
5
5
  import { extractFromBlock, extractFromFrontmatter, ownText, scanText, INLINE_FIELD_BRACKETED, INLINE_FIELD_LINE } from "../graph/extract.js";
6
6
  export const MARKDOWN_FORMAT = "markdown";
7
+ /**
8
+ * The Markdown block kinds whose `children` the parser produces (spec/format;
9
+ * `core/parse/parse.ts` CONTAINER_TYPES on the mdast side: list → list_item /
10
+ * task, blockquote, table → table_row). Every other kind is a leaf: its raw IS
11
+ * its whole content, so nothing may be placed inside it (spec/mutate §1.1).
12
+ */
13
+ export const MARKDOWN_CONTAINER_KINDS = new Set(["list", "list_item", "task", "blockquote", "table"]);
7
14
  function toAdapterEdge(e) {
8
15
  return {
9
16
  srcBlock: e.srcBlock,
@@ -28,6 +28,27 @@ export declare function diffBlocks(store: Store, docId: string, fromRev: string,
28
28
  /** `diff_unified` (spec/surface §3): a unified diff of two revisions' rendered
29
29
  * texts — each revision's live raws joined by `\n` — via {@link unifiedDiff}. */
30
30
  export declare function diffUnified(store: Store, docId: string, fromRev: string, toRev: string): string;
31
+ /** The `diff_unified` tool's result (spec/surface §3): the resolved revision pair and the diff. */
32
+ export interface DocDiffUnified {
33
+ doc: string;
34
+ path: string;
35
+ from: string;
36
+ to: string;
37
+ diff: string;
38
+ }
39
+ /**
40
+ * `diff_unified` over a resolved document: `to` defaults to the current revision,
41
+ * `from` to the one before it (or the current one again when there is only one:
42
+ * an empty diff). Shared by the MCP tool and `omg diff` so the shape cannot
43
+ * drift. `label` is how the caller named the document, for the error message.
44
+ * A document with no revision at all is `target_missing`; an unknown `rev` is
45
+ * `RevisionNotFound` (→ `target_missing` through `errorBody`).
46
+ */
47
+ export declare function docDiffUnified(store: Store, docId: string, opts?: {
48
+ fromRev?: string;
49
+ toRev?: string;
50
+ label?: string;
51
+ }): DocDiffUnified;
31
52
  /** How each text becomes lines: `split("\n")` exactly — no trimming, no
32
53
  * dropping of a trailing empty element (a raw ending in `\n` yields one) — with
33
54
  * a single special case: the empty text has *no* lines (an empty file is zero