@omgbase/core 0.1.0 → 0.3.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.
Files changed (84) hide show
  1. package/README.md +172 -0
  2. package/dist/src/cli/eval-matcher.d.ts +6 -1
  3. package/dist/src/cli/eval-matcher.js +14 -2
  4. package/dist/src/core/clock.d.ts +9 -0
  5. package/dist/src/core/clock.js +46 -0
  6. package/dist/src/core/hash.d.ts +25 -1
  7. package/dist/src/core/hash.js +110 -10
  8. package/dist/src/core/ids.d.ts +12 -0
  9. package/dist/src/core/ids.js +43 -1
  10. package/dist/src/core/ingest.d.ts +22 -2
  11. package/dist/src/core/ingest.js +92 -18
  12. package/dist/src/core/parse/parse.d.ts +1 -1
  13. package/dist/src/core/parse/parse.js +31 -9
  14. package/dist/src/core/store/nodes.d.ts +13 -0
  15. package/dist/src/core/store/nodes.js +24 -0
  16. package/dist/src/core/store/writers.d.ts +6 -0
  17. package/dist/src/core/store/writers.js +1 -0
  18. package/dist/src/core/utf8.d.ts +29 -0
  19. package/dist/src/core/utf8.js +96 -0
  20. package/dist/src/format/adapter.d.ts +7 -1
  21. package/dist/src/format/markdown.js +36 -41
  22. package/dist/src/graph/extract.d.ts +41 -1
  23. package/dist/src/graph/extract.js +59 -1
  24. package/dist/src/graph/history.d.ts +39 -1
  25. package/dist/src/graph/history.js +154 -20
  26. package/dist/src/index.d.ts +10 -6
  27. package/dist/src/index.js +11 -6
  28. package/dist/src/mcp/graph.js +8 -4
  29. package/dist/src/mcp/reference.d.ts +1 -1
  30. package/dist/src/mcp/reference.js +17 -6
  31. package/dist/src/mcp/server.js +15 -10
  32. package/dist/src/mutate/apply.d.ts +9 -0
  33. package/dist/src/mutate/apply.js +24 -11
  34. package/dist/src/mutate/docs.d.ts +2 -0
  35. package/dist/src/mutate/docs.js +10 -6
  36. package/dist/src/mutate/known-ids.js +11 -3
  37. package/dist/src/mutate/macros.js +9 -2
  38. package/dist/src/mutate/ops.d.ts +5 -4
  39. package/dist/src/mutate/ops.js +62 -15
  40. package/dist/src/mutate/plan-update.d.ts +7 -1
  41. package/dist/src/mutate/plan-update.js +3 -1
  42. package/dist/src/oqx-js/context.js +57 -7
  43. package/dist/src/reconcile/eval/generator.d.ts +24 -1
  44. package/dist/src/reconcile/eval/generator.js +248 -0
  45. package/dist/src/reconcile/eval/metrics.d.ts +33 -9
  46. package/dist/src/reconcile/eval/metrics.js +55 -32
  47. package/dist/src/reconcile/flatten.js +7 -5
  48. package/dist/src/reconcile/phase5.d.ts +2 -1
  49. package/dist/src/reconcile/phase5.js +27 -10
  50. package/dist/src/reconcile/phase6a.js +17 -5
  51. package/dist/src/reconcile/phases.d.ts +44 -6
  52. package/dist/src/reconcile/phases.js +0 -0
  53. package/dist/src/reconcile/reconcile.js +16 -9
  54. package/dist/src/reconcile/types.d.ts +4 -1
  55. package/dist/src/reconcile/types.js +11 -2
  56. package/dist/src/search/external.d.ts +10 -0
  57. package/dist/src/search/external.js +18 -1
  58. package/dist/src/search/provider.d.ts +7 -3
  59. package/dist/src/search/provider.js +8 -7
  60. package/dist/src/search/rrf.js +4 -0
  61. package/dist/src/search/tasks.d.ts +28 -0
  62. package/dist/src/search/tasks.js +36 -23
  63. package/dist/src/search/vector.js +30 -14
  64. package/dist/src/sync/attach.d.ts +7 -2
  65. package/dist/src/sync/attach.js +10 -8
  66. package/dist/src/sync/checkpoint.d.ts +18 -1
  67. package/dist/src/sync/checkpoint.js +31 -29
  68. package/dist/src/sync/driver.d.ts +15 -4
  69. package/dist/src/sync/driver.js +18 -43
  70. package/dist/src/sync/external-source.js +7 -1
  71. package/dist/src/sync/freshness.d.ts +73 -15
  72. package/dist/src/sync/freshness.js +128 -97
  73. package/dist/src/sync/fs-util.d.ts +19 -0
  74. package/dist/src/sync/fs-util.js +24 -1
  75. package/dist/src/sync/observe.d.ts +53 -5
  76. package/dist/src/sync/observe.js +162 -21
  77. package/dist/src/sync/reconciling-ingest.d.ts +42 -2
  78. package/dist/src/sync/reconciling-ingest.js +87 -13
  79. package/dist/src/sync/recovery.d.ts +5 -1
  80. package/dist/src/sync/recovery.js +8 -8
  81. package/dist/src/sync/workspace.d.ts +11 -3
  82. package/dist/src/sync/workspace.js +39 -30
  83. package/dist/src/sync/writer-lock.js +16 -26
  84. package/package.json +3 -3
@@ -3,24 +3,36 @@ import { parseTree } from "./parse/tree.js";
3
3
  import { render } from "./parse/render.js";
4
4
  import { reconstructContent } from "./read/document.js";
5
5
  import { assignIds, writeBlockTree, putBlob, newCommit, writeRevision } from "./store/writers.js";
6
- import { sha256, normalizeVisibleText } from "./hash.js";
6
+ import { sha256, childQuoteDepth, visibleText } from "./hash.js";
7
7
  import { mintId } from "./ids.js";
8
8
  import { keyBetween } from "./order-key.js";
9
9
  import { ftsDeleteDoc, ftsIndexDoc } from "./store/fts.js";
10
10
  import { rebuildSections } from "./store/sections.js";
11
- import { writeDocNodes, projectSectionNodes } from "./store/nodes.js";
11
+ import { writeDocNodes, projectSectionNodes, toByteSpans } from "./store/nodes.js";
12
12
  import { flattenFrontmatter, flattenComputed, writeDocProperties } from "./store/properties.js";
13
13
  import { maintainEdges, adoptPhantoms } from "./store/edges.js";
14
14
  import { parse as parseYaml } from "yaml";
15
15
  import { adapterForPath } from "../format/index.js";
16
+ /** The parse step of ingest, exported so a batch caller can parse + reconcile
17
+ * every document of a checkpoint BEFORE committing any (the cross-document
18
+ * phase needs all per-doc results first) and hand the tree back via
19
+ * `opts.parsed`. Frontmatter is split off: `rest` is what the IdResolver sees. */
20
+ export function parseForIngest(path, content) {
21
+ const adapter = adapterForPath(path);
22
+ const tree = adapter ? adapter.parse(content) : parseTree(content);
23
+ return { tree, ...extractFrontmatter(tree.children) };
24
+ }
16
25
  function extractFrontmatter(blocks) {
17
26
  if (blocks.length > 0 && blocks[0].type === "frontmatter") {
18
27
  return { fmBlock: blocks[0], rest: blocks.slice(1) };
19
28
  }
20
29
  return { fmBlock: null, rest: blocks };
21
30
  }
22
- // Parse a frontmatter block's raw (incl. --- fences) into a JSON-safe object.
23
- // Malformed YAML yields {} — frontmatter is queryable metadata, not load-bearing.
31
+ // Parse a frontmatter block's raw (incl. --- fences) into a JSON-safe object:
32
+ // the YAML text is the raw minus its first line (the opening `---`) and its last
33
+ // line (the closing `---`, with any trailing whitespace); `\n` or `\r\n` endings
34
+ // (spec/properties §3.1). Malformed YAML, or a document that is not a mapping,
35
+ // yields {} — frontmatter is queryable metadata, not load-bearing.
24
36
  function parseFrontmatter(fmBlock) {
25
37
  if (!fmBlock)
26
38
  return {};
@@ -35,12 +47,14 @@ function parseFrontmatter(fmBlock) {
35
47
  return {};
36
48
  }
37
49
  }
38
- function flatten(blocks, parent, depth, ancestorPath, out) {
50
+ // `quoteDepth` is the number of blockquote ancestors — the §4.1 text rule needs
51
+ // it (spec/format), and containers derive text from their children.
52
+ function flatten(blocks, parent, depth, ancestorPath, out, quoteDepth = 0) {
39
53
  let prevKey = null;
40
54
  blocks.forEach((b, ordinal) => {
41
55
  const orderKey = keyBetween(prevKey, null);
42
56
  prevKey = orderKey;
43
- const visible = normalizeVisibleText(b.raw, b.type);
57
+ const visible = visibleText(b, quoteDepth);
44
58
  out.push({
45
59
  blockId: b.blockId,
46
60
  parentBlock: parent,
@@ -56,7 +70,7 @@ function flatten(blocks, parent, depth, ancestorPath, out) {
56
70
  triviaHash: b.trivia.length > 0 ? sha256(b.trivia) : null,
57
71
  });
58
72
  if (b.children.length > 0) {
59
- flatten(b.children, b.blockId, depth + 1, `${ancestorPath}${b.blockId}/`, out);
73
+ flatten(b.children, b.blockId, depth + 1, `${ancestorPath}${b.blockId}/`, out, childQuoteDepth(b.type, quoteDepth));
60
74
  }
61
75
  });
62
76
  }
@@ -67,7 +81,7 @@ export function ingestFile(store, repoId, path, content, opts = {}) {
67
81
  const adapter = adapterForPath(path);
68
82
  const format = opts.format ?? adapter?.format ?? "markdown";
69
83
  return store.write((db) => {
70
- const tree = adapter ? adapter.parse(content) : parseTree(content);
84
+ const tree = opts.parsed ?? (adapter ? adapter.parse(content) : parseTree(content));
71
85
  const { fmBlock, rest } = extractFrontmatter(tree.children);
72
86
  // Frontmatter blob (markdown-specific) preserved for revision history.
73
87
  const fmBlobHex = fmBlock ? putBlob(db, fmBlock.raw) : null;
@@ -76,24 +90,38 @@ export function ingestFile(store, repoId, path, content, opts = {}) {
76
90
  // reconstructs byte-for-byte instead of assuming a canonical blank line.
77
91
  // NULL when there is no frontmatter.
78
92
  const fmTrivia = fmBlock ? fmBlock.trivia : null;
79
- // Document property bag: adapter-provided for non-markdown formats,
80
- // frontmatter-parsed for markdown. Flattened into the properties table
81
- // below (source=frontmatter); also drives frontmatter edge extraction.
82
- const metadata = (adapter?.extractMetadata)
83
- ? (adapter.extractMetadata(content) ?? parseFrontmatter(fmBlock))
93
+ // Document property bag: whole-document metadata for the formats whose
94
+ // adapter extracts one (YAML, JSON); for Markdown — whose adapter has no
95
+ // `extractMetadata` — the YAML of the `frontmatter` block and nothing else
96
+ // (spec/properties §3.1: a document whose first block is not `frontmatter`
97
+ // has no frontmatter rows, whatever else its bytes resemble). Flattened into
98
+ // the properties table below (source=frontmatter); also drives frontmatter
99
+ // edge extraction.
100
+ const metadata = adapter?.extractMetadata
101
+ ? (adapter.extractMetadata(content) ?? {})
84
102
  : parseFrontmatter(fmBlock);
85
103
  // Upsert the document row.
86
- let doc = db.prepare("SELECT doc_id FROM docs WHERE repo_id = ? AND path = ?").get(repoId, path);
104
+ let doc = db.prepare("SELECT doc_id, deleted_commit FROM docs WHERE repo_id = ? AND path = ?").get(repoId, path);
87
105
  const docId = doc?.doc_id ?? mintId("d");
88
106
  const isNew = !doc;
89
107
  if (!doc) {
90
108
  db.prepare("INSERT INTO docs (doc_id, repo_id, path, format, leading_trivia, frontmatter_trivia) VALUES (?, ?, ?, ?, ?, ?)").run(docId, repoId, path, format, tree.leadingTrivia, fmTrivia);
91
- doc = { doc_id: docId };
109
+ doc = { doc_id: docId, deleted_commit: null };
92
110
  // Adopt phantom edges that pointed at this path so backlinks re-point.
93
111
  adoptPhantoms(db, path, docId);
94
112
  }
95
113
  else {
96
- db.prepare("UPDATE docs SET format = ?, leading_trivia = ?, frontmatter_trivia = ? WHERE doc_id = ?").run(format, tree.leadingTrivia, fmTrivia, docId);
114
+ // A tombstoned row still owns the path's identity (spec/store §5.6
115
+ // "Re-creation"): bytes observed again at the path revive it — the row is
116
+ // live again, so reads that filter `deleted_commit IS NULL` see it and the
117
+ // next identical observation echo-gates instead of re-ingesting.
118
+ const revived = doc.deleted_commit !== null;
119
+ db.prepare("UPDATE docs SET format = ?, leading_trivia = ?, frontmatter_trivia = ?, deleted_commit = NULL WHERE doc_id = ?").run(format, tree.leadingTrivia, fmTrivia, docId);
120
+ // spec/graph §3.5: adoption happens whenever a row BECOMES live, so a
121
+ // revived tombstone also collects the phantom edges minted at its path
122
+ // while it was gone (§8 "Fixed — a revived document adopted no phantoms").
123
+ if (revived)
124
+ adoptPhantoms(db, path, docId);
97
125
  }
98
126
  // Assign block ids via the resolver when supplied (it reconciles against
99
127
  // the prior revision — for a new doc that's an empty old tree, so all blocks
@@ -103,7 +131,7 @@ export function ingestFile(store, repoId, path, content, opts = {}) {
103
131
  : { assigned: assignIds(rest), dispositions: [], deleted: [] };
104
132
  const assigned = resolved.assigned;
105
133
  const rootTreeHex = writeBlockTree(db, assigned);
106
- const commit = newCommit(db, { repoId, ts, origin });
134
+ const commit = newCommit(db, { repoId, ts, origin, actor: opts.actor ?? null, reason: opts.reason ?? null });
107
135
  const renderedHash = sha256(content);
108
136
  const rev = writeRevision(db, {
109
137
  docId,
@@ -122,6 +150,14 @@ export function ingestFile(store, repoId, path, content, opts = {}) {
122
150
  for (const id of resolved.deleted)
123
151
  pool.run(commit.commitId, expires, id, docId);
124
152
  }
153
+ // Ids arriving from ANOTHER document — a cross-document move whose source
154
+ // commits later in this checkpoint (or was tombstoned in it), or a
155
+ // resurrection out of a tombstoned doc whose rows still sit in `blocks` —
156
+ // must not collide on the block_id primary key. Evict the foreign row (and
157
+ // its FTS entry when live) and drop any pool row: the id is live here now.
158
+ const incoming = [...(resolved.crossDocIds ?? []), ...(resolved.consumedPool ?? [])];
159
+ if (incoming.length > 0)
160
+ evictForeignBlockRows(db, docId, incoming);
125
161
  // Refresh current-state blocks (clear + repopulate with carried/minted ids).
126
162
  // FTS is external-content: delete old index rows before dropping blocks.
127
163
  ftsDeleteDoc(db, docId);
@@ -155,7 +191,12 @@ export function ingestFile(store, repoId, path, content, opts = {}) {
155
191
  ...n,
156
192
  blockId: n.blockId && idSet.has(n.blockId) ? n.blockId : "",
157
193
  }));
158
- writeDocNodes(db, repoId, docId, [...withIds, ...sectionNodes]);
194
+ // Adapters record spans as string indices; the store keeps byte offsets
195
+ // into the block's UTF-8 raw (spec/graph §2.3), so convert here — the one
196
+ // place that has both the nodes and the raws they index.
197
+ const rawById = collectRaws(assigned);
198
+ const byteSpanned = toByteSpans(withIds, (id) => rawById.get(id));
199
+ writeDocNodes(db, repoId, docId, [...byteSpanned, ...sectionNodes]);
159
200
  // Inline properties (key:: value) → property rows (source=inline). The
160
201
  // authored shape within the inline source drives `card`: a key that occurs
161
202
  // exactly once in the document is `scalar` (so `element == "fire"` can
@@ -246,6 +287,27 @@ export function ingestFile(store, repoId, path, content, opts = {}) {
246
287
  };
247
288
  });
248
289
  }
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
294
+ // checkpoint, rewrites its rows wholesale and never lists a carried id as
295
+ // deleted, so nothing there depends on the evicted row.
296
+ 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 del = db.prepare("DELETE FROM blocks WHERE block_id = ? AND doc_id != ?");
300
+ const unpool = db.prepare("DELETE FROM resurrection_pool WHERE block_id = ?");
301
+ for (const id of ids) {
302
+ 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);
309
+ }
310
+ }
249
311
  // Zip assigned block ids onto the parsed RawBlocks for node projection. The
250
312
  // parsed `rest` and `assigned` trees are the same blocks in the same order and
251
313
  // nesting (assigned is a 1:1 id-carrying projection of rest), so we walk them in
@@ -272,6 +334,18 @@ function typedInlineValue(raw) {
272
334
  return { valText: null, valNum: Number(v), valBool: null, valJson: null, type: "number" };
273
335
  return { valText: v, valNum: null, valBool: null, valJson: null, type: "string" };
274
336
  }
337
+ function collectRaws(blocks) {
338
+ const raws = new Map();
339
+ const walk = (list) => {
340
+ for (const b of list) {
341
+ raws.set(b.blockId, b.raw);
342
+ if (b.children.length > 0)
343
+ walk(b.children);
344
+ }
345
+ };
346
+ walk(blocks);
347
+ return raws;
348
+ }
275
349
  function collectBlockIds(blocks) {
276
350
  const ids = new Set();
277
351
  const walk = (list) => {
@@ -1,3 +1,3 @@
1
1
  import type { RawBlock } from "./types.js";
2
- /** Parse Markdown source into a flat list of top-level blocks with byte spans. */
2
+ /** Parse Markdown source into a flat list of top-level blocks with spans into `source`. */
3
3
  export declare function parseBlocks(source: string): RawBlock[];
@@ -3,7 +3,7 @@ import { gfm } from "micromark-extension-gfm";
3
3
  import { gfmFromMarkdown } from "mdast-util-gfm";
4
4
  import { frontmatter } from "micromark-extension-frontmatter";
5
5
  import { frontmatterFromMarkdown } from "mdast-util-frontmatter";
6
- import { normalizeVisibleText } from "../hash.js";
6
+ import { childQuoteDepth, visibleText } from "../hash.js";
7
7
  // Nodes whose children nest as blocks (03 §1: parser nesting only).
8
8
  // Everything else at block level maps to a leaf block; unknown → opaque.
9
9
  function mapType(node) {
@@ -66,16 +66,30 @@ function childrenOf(node) {
66
66
  const kids = node.children;
67
67
  return Array.isArray(kids) ? kids : [];
68
68
  }
69
- function buildBlock(node, source) {
70
- const start = node.position?.start.offset;
71
- const end = node.position?.end.offset;
72
- if (start === undefined || end === undefined) {
69
+ function isLineEnding(code) {
70
+ return code === 0x0a /* \n */ || code === 0x0d /* \r */;
71
+ }
72
+ // `quoteDepth` = number of blockquote ancestors, which the §4.1 text rule needs
73
+ // because a nested block's raw keeps the `> ` prefixes of its continuation lines.
74
+ function buildBlock(node, source, shift, quoteDepth) {
75
+ const rawStart = node.position?.start.offset;
76
+ const rawEnd = node.position?.end.offset;
77
+ if (rawStart === undefined || rawEnd === undefined) {
73
78
  throw new Error(`node ${node.type} missing position offsets`);
74
79
  }
80
+ const start = rawStart + shift;
81
+ let end = rawEnd + shift;
82
+ // Spans exclude the terminating line ending (spec/format §1 inv. 4). micromark
83
+ // already ends most blocks before it, but a construct that runs to EOF without
84
+ // a closer (an unclosed fence, an unclosed HTML comment, and the list items
85
+ // holding one) is handed every trailing line ending, blank lines included.
86
+ // Those bytes belong to the trivia that follows the block.
87
+ while (end > start && isLineEnding(source.charCodeAt(end - 1)))
88
+ end--;
75
89
  const raw = source.slice(start, end);
76
90
  const mapped = mapType(node);
77
91
  const type = mapped ?? "opaque";
78
- let children = mapped === null ? [] : childrenOf(node).map((c) => buildBlock(c, source));
92
+ let children = mapped === null ? [] : childrenOf(node).map((c) => buildBlock(c, source, shift, childQuoteDepth(type, quoteDepth)));
79
93
  // Fold a tight list item's lone paragraph: the item block carries the text
80
94
  // directly (frozen outline format, 06 §6). Loose/multi-block items keep them.
81
95
  if ((type === "list_item" || type === "task") && children.length === 1 && children[0].type === "paragraph") {
@@ -85,7 +99,7 @@ function buildBlock(node, source) {
85
99
  type,
86
100
  span: { start, end },
87
101
  raw,
88
- text: normalizeVisibleText(raw, type),
102
+ text: visibleText({ type, raw, children }, quoteDepth),
89
103
  attrs: mapped === null ? {} : attrsFor(node),
90
104
  children,
91
105
  trivia: "",
@@ -93,12 +107,20 @@ function buildBlock(node, source) {
93
107
  outLinks: [],
94
108
  };
95
109
  }
96
- /** Parse Markdown source into a flat list of top-level blocks with byte spans. */
110
+ // micromark's preprocessor (micromark/lib/preprocess.js) drops exactly one
111
+ // leading U+FEFF before tokenizing and never emits it as a chunk, so every
112
+ // position it reports is an index into the stripped string. Spans must index
113
+ // the true source (spec/format §1 inv. 6: the BOM is leading trivia), so all
114
+ // offsets move by one code unit when — and only when — the source starts with
115
+ // a BOM. A second BOM, or one anywhere else, is ordinary content and unshifted.
116
+ const BOM = 0xfeff;
117
+ /** Parse Markdown source into a flat list of top-level blocks with spans into `source`. */
97
118
  export function parseBlocks(source) {
98
119
  const tree = fromMarkdown(source, {
99
120
  extensions: [gfm(), frontmatter(["yaml"])],
100
121
  mdastExtensions: [gfmFromMarkdown(), frontmatterFromMarkdown(["yaml"])],
101
122
  });
102
- return tree.children.map((c) => buildBlock(c, source));
123
+ const shift = source.charCodeAt(0) === BOM ? 1 : 0;
124
+ return tree.children.map((c) => buildBlock(c, source, shift, 0));
103
125
  }
104
126
  //# sourceMappingURL=parse.js.map
@@ -1,5 +1,18 @@
1
1
  import type { Database } from "better-sqlite3";
2
2
  import type { ProjectedNode } from "../../format/adapter.js";
3
3
  export declare function projectSectionNodes(db: Database, docId: string): ProjectedNode[];
4
+ /**
5
+ * Convert adapter-projected spans (JavaScript string indices — UTF-16 code
6
+ * units, spec/graph §2.3) to byte offsets into the block's UTF-8 `raw`, which is
7
+ * what the `nodes` table stores (spec/graph §8 "Fixed"). `rawOf` returns the raw
8
+ * of a block by id; a node whose block raw is unknown keeps no span rather than a
9
+ * wrong one. Nodes without a span (sections) pass through untouched.
10
+ */
11
+ export declare function toByteSpans(projected: ProjectedNode[], rawOf: (blockId: string) => string | undefined): ProjectedNode[];
4
12
  export declare function deleteDocNodes(db: Database, docId: string): void;
13
+ /**
14
+ * Replace a document's `nodes` rows (and their FTS rows) with `projected`, in
15
+ * order; `node_id` is the spec/graph §2.3 hash over (doc, block, kind, ordinal).
16
+ * Spans are stored as given — callers convert to bytes first (`toByteSpans`).
17
+ */
5
18
  export declare function writeDocNodes(db: Database, repoId: string, docId: string, projected: ProjectedNode[]): number;
@@ -1,4 +1,5 @@
1
1
  import { hashHex } from "../hash.js";
2
+ import { codeUnitSpanToBytes } from "../utf8.js";
2
3
  function mintNodeId(docId, blockId, kind, ordinal) {
3
4
  return "n_" + hashHex(`${docId}|${blockId}|${kind}|${ordinal}`).slice(0, 12);
4
5
  }
@@ -27,6 +28,24 @@ export function projectSectionNodes(db, docId) {
27
28
  attrs: { level: r.level, first_ordinal: r.first_ordinal, last_ordinal: r.last_ordinal },
28
29
  }));
29
30
  }
31
+ /**
32
+ * Convert adapter-projected spans (JavaScript string indices — UTF-16 code
33
+ * units, spec/graph §2.3) to byte offsets into the block's UTF-8 `raw`, which is
34
+ * what the `nodes` table stores (spec/graph §8 "Fixed"). `rawOf` returns the raw
35
+ * of a block by id; a node whose block raw is unknown keeps no span rather than a
36
+ * wrong one. Nodes without a span (sections) pass through untouched.
37
+ */
38
+ export function toByteSpans(projected, rawOf) {
39
+ return projected.map((p) => {
40
+ if (p.spanStart === undefined || p.spanEnd === undefined)
41
+ return p;
42
+ const raw = rawOf(p.blockId);
43
+ if (raw === undefined)
44
+ return { ...p, spanStart: undefined, spanEnd: undefined };
45
+ const { start, end } = codeUnitSpanToBytes(raw, p.spanStart, p.spanEnd);
46
+ return { ...p, spanStart: start, spanEnd: end };
47
+ });
48
+ }
30
49
  export function deleteDocNodes(db, docId) {
31
50
  const rows = db.prepare("SELECT rowid, name, value FROM nodes WHERE doc_id = ?").all(docId);
32
51
  for (const r of rows) {
@@ -34,6 +53,11 @@ export function deleteDocNodes(db, docId) {
34
53
  }
35
54
  db.prepare("DELETE FROM nodes WHERE doc_id = ?").run(docId);
36
55
  }
56
+ /**
57
+ * Replace a document's `nodes` rows (and their FTS rows) with `projected`, in
58
+ * order; `node_id` is the spec/graph §2.3 hash over (doc, block, kind, ordinal).
59
+ * Spans are stored as given — callers convert to bytes first (`toByteSpans`).
60
+ */
37
61
  export function writeDocNodes(db, repoId, docId, projected) {
38
62
  deleteDocNodes(db, docId);
39
63
  const insert = db.prepare(`INSERT INTO nodes (node_id, repo_id, doc_id, block_id, kind, name, value, span_start, span_end, attrs)
@@ -9,6 +9,12 @@ export interface TreeInputBlock {
9
9
  trivia: string;
10
10
  attrs: Record<string, unknown>;
11
11
  children: TreeInputBlock[];
12
+ /** The parsed block's source span, when the tree came from a parse — what
13
+ * `ownText` (spec/properties §3.2) uses to blank a container's children. */
14
+ span?: {
15
+ start: number;
16
+ end: number;
17
+ };
12
18
  }
13
19
  /** Assign fresh minted ids to a parsed block tree (Stage-1 re-mint path). */
14
20
  export declare function assignIds(blocks: RawBlock[]): TreeInputBlock[];
@@ -9,6 +9,7 @@ export function assignIds(blocks) {
9
9
  trivia: b.trivia,
10
10
  attrs: b.attrs,
11
11
  children: assignIds(b.children),
12
+ span: b.span,
12
13
  }));
13
14
  }
14
15
  /** Content-address raw text into blobs; returns hex hash. Dedups. */
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Byte offset of every code-unit index of `s` (length + 1 entries, so the
3
+ * end-of-string index resolves too). One pass over the string; a surrogate
4
+ * pair contributes 4 bytes at the index after the pair, and the index between
5
+ * its two halves maps to the pair's start (no span ever lands there).
6
+ */
7
+ export declare function byteOffsetTable(s: string): Uint32Array;
8
+ /** The UTF-8 byte offset of code-unit index `index` of `s` (clamped to the string). */
9
+ export declare function byteOffsetOf(s: string, index: number): number;
10
+ /**
11
+ * Convert a `[start, end)` code-unit span of `s` to a byte span. Both ends are
12
+ * clamped to the string; `start <= end` is preserved.
13
+ */
14
+ export declare function codeUnitSpanToBytes(s: string, start: number, end: number): {
15
+ start: number;
16
+ end: number;
17
+ };
18
+ /**
19
+ * The code-unit index whose byte offset is `byte` in `s`. A byte offset that
20
+ * falls inside a multi-byte sequence (never produced by this module, but a
21
+ * foreign writer could) rounds down to the start of that character; an offset
22
+ * past the end clamps to `s.length`.
23
+ */
24
+ export declare function codeUnitIndexOf(s: string, byte: number): number;
25
+ /** Convert a `[start, end)` byte span of `s` back to a code-unit span (inverse of `codeUnitSpanToBytes`). */
26
+ export declare function byteSpanToCodeUnits(s: string, start: number, end: number): {
27
+ start: number;
28
+ end: number;
29
+ };
@@ -0,0 +1,96 @@
1
+ // UTF-16 code-unit index ⇄ UTF-8 byte offset (spec/format §1 inv. 5, spec/graph
2
+ // §2.3). JavaScript strings index code units; every language-neutral offset the
3
+ // store persists is a byte offset into the UTF-8 encoding of the same text, so
4
+ // the reference converts at the store boundary: node spans are converted to
5
+ // bytes before `nodes` rows are written, and back to code units before an
6
+ // editor slices a block's raw. Both directions are exact for well-formed text;
7
+ // a lone surrogate counts as the 3 bytes `Buffer.from(s, "utf8")` emits for
8
+ // U+FFFD, so the table always agrees with the bytes actually stored.
9
+ /**
10
+ * Byte offset of every code-unit index of `s` (length + 1 entries, so the
11
+ * end-of-string index resolves too). One pass over the string; a surrogate
12
+ * pair contributes 4 bytes at the index after the pair, and the index between
13
+ * its two halves maps to the pair's start (no span ever lands there).
14
+ */
15
+ export function byteOffsetTable(s) {
16
+ const table = new Uint32Array(s.length + 1);
17
+ let bytes = 0;
18
+ let i = 0;
19
+ while (i < s.length) {
20
+ table[i] = bytes;
21
+ const c = s.charCodeAt(i);
22
+ if (c < 0x80) {
23
+ bytes += 1;
24
+ i += 1;
25
+ }
26
+ else if (c < 0x800) {
27
+ bytes += 2;
28
+ i += 1;
29
+ }
30
+ else if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) {
31
+ const d = s.charCodeAt(i + 1);
32
+ if (d >= 0xdc00 && d <= 0xdfff) {
33
+ table[i + 1] = bytes;
34
+ bytes += 4;
35
+ i += 2;
36
+ }
37
+ else {
38
+ bytes += 3; // lone high surrogate → U+FFFD
39
+ i += 1;
40
+ }
41
+ }
42
+ else {
43
+ bytes += 3; // BMP ≥ U+0800, or a lone surrogate → U+FFFD
44
+ i += 1;
45
+ }
46
+ }
47
+ table[s.length] = bytes;
48
+ return table;
49
+ }
50
+ /** The UTF-8 byte offset of code-unit index `index` of `s` (clamped to the string). */
51
+ export function byteOffsetOf(s, index) {
52
+ return byteOffsetTable(s)[Math.max(0, Math.min(index, s.length))];
53
+ }
54
+ /**
55
+ * Convert a `[start, end)` code-unit span of `s` to a byte span. Both ends are
56
+ * clamped to the string; `start <= end` is preserved.
57
+ */
58
+ export function codeUnitSpanToBytes(s, start, end) {
59
+ const table = byteOffsetTable(s);
60
+ const clamp = (i) => Math.max(0, Math.min(i, s.length));
61
+ return { start: table[clamp(start)], end: table[clamp(Math.max(start, end))] };
62
+ }
63
+ /**
64
+ * The code-unit index whose byte offset is `byte` in `s`. A byte offset that
65
+ * falls inside a multi-byte sequence (never produced by this module, but a
66
+ * foreign writer could) rounds down to the start of that character; an offset
67
+ * past the end clamps to `s.length`.
68
+ */
69
+ export function codeUnitIndexOf(s, byte) {
70
+ const table = byteOffsetTable(s);
71
+ // `table` is non-decreasing; find the greatest index whose offset ≤ byte.
72
+ let lo = 0;
73
+ let hi = s.length;
74
+ while (lo < hi) {
75
+ const mid = (lo + hi + 1) >>> 1;
76
+ if (table[mid] <= byte)
77
+ lo = mid;
78
+ else
79
+ hi = mid - 1;
80
+ }
81
+ // The index between the halves of a surrogate pair shares the pair's start
82
+ // offset; step back to the high surrogate so a slice never splits the pair.
83
+ if (lo > 0 && lo < s.length) {
84
+ const c = s.charCodeAt(lo);
85
+ if (c >= 0xdc00 && c <= 0xdfff && table[lo] === table[lo - 1])
86
+ lo -= 1;
87
+ }
88
+ return lo;
89
+ }
90
+ /** Convert a `[start, end)` byte span of `s` back to a code-unit span (inverse of `codeUnitSpanToBytes`). */
91
+ export function byteSpanToCodeUnits(s, start, end) {
92
+ const a = codeUnitIndexOf(s, start);
93
+ const b = codeUnitIndexOf(s, Math.max(start, end));
94
+ return { start: a, end: Math.max(a, b) };
95
+ }
96
+ //# sourceMappingURL=utf8.js.map
@@ -14,6 +14,12 @@ export interface ProjectedNode {
14
14
  name?: string | undefined;
15
15
  value?: string | undefined;
16
16
  blockId: string;
17
+ /**
18
+ * `[spanStart, spanEnd)` of the feature within the block's raw, as JavaScript
19
+ * string indices (UTF-16 code units). Ingest converts to UTF-8 byte offsets
20
+ * before the `nodes` row is written (spec/graph §2.3), and `node_set`
21
+ * converts back before an editor slices the raw.
22
+ */
17
23
  spanStart?: number | undefined;
18
24
  spanEnd?: number | undefined;
19
25
  attrs?: Record<string, unknown> | undefined;
@@ -27,7 +33,7 @@ export interface EmbeddingChunk {
27
33
  export interface NodeEditContext {
28
34
  /** The block's current raw bytes (what the edit rewrites). */
29
35
  blockRaw: string;
30
- /** The node's byte span within blockRaw (null when unrecorded — see spans). */
36
+ /** The node's span within blockRaw as string indices — the stored byte span converted back (null when unrecorded). */
31
37
  span: {
32
38
  start: number;
33
39
  end: number;