@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.
- package/dist/src/core/ids.d.ts +34 -3
- package/dist/src/core/ids.js +79 -6
- package/dist/src/core/ingest.js +15 -12
- package/dist/src/core/read/document.js +6 -1
- package/dist/src/core/read/nodes.js +5 -1
- package/dist/src/core/store/fts.d.ts +47 -7
- package/dist/src/core/store/fts.js +73 -9
- package/dist/src/core/store/rebuild.js +5 -4
- package/dist/src/core/store/store.d.ts +4 -0
- package/dist/src/core/store/store.js +40 -1
- package/dist/src/format/index.d.ts +12 -1
- package/dist/src/format/index.js +14 -1
- package/dist/src/format/markdown.d.ts +7 -0
- package/dist/src/format/markdown.js +7 -0
- package/dist/src/graph/history.d.ts +21 -0
- package/dist/src/graph/history.js +48 -6
- package/dist/src/graph/links.d.ts +2 -0
- package/dist/src/graph/links.js +22 -11
- package/dist/src/index.d.ts +8 -6
- package/dist/src/index.js +7 -5
- package/dist/src/mcp/errors.d.ts +8 -0
- package/dist/src/mcp/errors.js +17 -3
- package/dist/src/mcp/server.js +50 -57
- package/dist/src/mutate/apply.js +12 -4
- package/dist/src/mutate/doc-store.js +4 -1
- package/dist/src/mutate/docs.d.ts +19 -0
- package/dist/src/mutate/docs.js +64 -16
- package/dist/src/mutate/ops.js +25 -13
- package/dist/src/oqx-js/context.d.ts +11 -0
- package/dist/src/oqx-js/context.js +16 -0
- package/dist/src/oqx-js/planner.d.ts +20 -1
- package/dist/src/oqx-js/planner.js +95 -2
- package/dist/src/oqx-js/run.js +27 -2
- package/dist/src/oqx-js/sql/translate.d.ts +94 -1
- package/dist/src/oqx-js/sql/translate.js +263 -79
- package/dist/src/search/tasks.d.ts +15 -1
- package/dist/src/search/tasks.js +31 -3
- package/dist/src/sync/admin.d.ts +13 -0
- package/dist/src/sync/admin.js +11 -0
- package/dist/src/sync/external-source.js +20 -7
- package/dist/src/sync/plugin.d.ts +30 -5
- package/dist/src/sync/plugin.js +13 -1
- package/dist/src/sync/watcher.d.ts +2 -0
- package/dist/src/sync/watcher.js +10 -2
- package/package.json +1 -1
package/dist/src/core/ids.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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;
|
package/dist/src/core/ids.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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.
|
package/dist/src/core/ingest.js
CHANGED
|
@@ -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
|
|
292
|
-
// an FTS entry (external-content index: delete with the original text); a
|
|
293
|
-
// tombstoned
|
|
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
|
-
|
|
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
|
-
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
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
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
|
|
6
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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 {
|
|
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
|
-
//
|
|
18
|
-
|
|
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;
|
package/dist/src/format/index.js
CHANGED
|
@@ -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
|