@omgbase/core 0.19.0 → 0.21.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 (36) hide show
  1. package/dist/src/core/ingest.d.ts +2 -0
  2. package/dist/src/core/ingest.js +13 -0
  3. package/dist/src/core/read/document.d.ts +9 -8
  4. package/dist/src/core/read/document.js +0 -2
  5. package/dist/src/core/store/gc.d.ts +13 -1
  6. package/dist/src/core/store/gc.js +24 -5
  7. package/dist/src/core/store/size.d.ts +4 -1
  8. package/dist/src/core/store/size.js +11 -2
  9. package/dist/src/mcp/arguments.d.ts +10 -2
  10. package/dist/src/mcp/arguments.js +37 -3
  11. package/dist/src/mcp/reference.d.ts +1 -1
  12. package/dist/src/mcp/reference.js +36 -4
  13. package/dist/src/mcp/server.js +50 -23
  14. package/dist/src/mutate/effects.d.ts +23 -5
  15. package/dist/src/mutate/effects.js +111 -22
  16. package/dist/src/mutate/known-ids.d.ts +19 -0
  17. package/dist/src/mutate/known-ids.js +47 -7
  18. package/dist/src/mutate/ops.d.ts +1 -0
  19. package/dist/src/mutate/ops.js +20 -1
  20. package/dist/src/mutate/select.d.ts +0 -11
  21. package/dist/src/mutate/select.js +33 -9
  22. package/dist/src/oqx-js/context.d.ts +9 -2
  23. package/dist/src/oqx-js/context.js +74 -28
  24. package/dist/src/oqx-js/names.d.ts +40 -0
  25. package/dist/src/oqx-js/names.js +347 -0
  26. package/dist/src/oqx-js/planner.d.ts +3 -3
  27. package/dist/src/oqx-js/planner.js +13 -9
  28. package/dist/src/oqx-js/root-row.d.ts +15 -4
  29. package/dist/src/oqx-js/root-row.js +18 -10
  30. package/dist/src/oqx-js/run.d.ts +18 -1
  31. package/dist/src/oqx-js/run.js +31 -12
  32. package/dist/src/oqx-js/sql/translate.js +1 -1
  33. package/dist/src/spec-versions.d.ts +5 -5
  34. package/dist/src/spec-versions.js +4 -4
  35. package/dist/src/sync/observe.js +9 -3
  36. package/package.json +2 -2
@@ -1,4 +1,5 @@
1
1
  import { mintId } from "../core/ids.js";
2
+ import { sha256 } from "../core/hash.js";
2
3
  import { extractFromBlock, extractFromFrontmatter, resolveRelativePath } from "../graph/extract.js";
3
4
  import { resolveExternal, resolveDocPath } from "../core/store/edges.js";
4
5
  import { adapterForPath } from "../format/index.js";
@@ -79,12 +80,19 @@ export function makeKnownIdResolver(store, repoId, doc, priorIds, opts = {}) {
79
80
  const assigned = assignFromMut(rest, byKey);
80
81
  const nowIds = collectIds(assigned);
81
82
  const nowSet = new Set(nowIds);
82
- // Dispositions are INTENT, not inference: a carried id that existed before is
83
- // `same`/`edited` (confidence 1.0); an id new this commit is `inserted`; a
84
- // prior id no longer present is `deleted`.
85
- const dispositions = nowIds.map((id) => ({
86
- blockId: id,
87
- kind: priorIds.has(id) ? "edited" : "inserted",
83
+ // Dispositions are INTENT, not inference (confidence 1.0, reason `api`): an
84
+ // id new this commit is `inserted`; a prior id no longer present is
85
+ // `deleted` (pooled, no row); a carried id is classified exactly as
86
+ // spec/reconcile §2 classifies a carry — `same` when its raw bytes, parent
87
+ // and sibling index are those of its live row, else `edited` / `moved` /
88
+ // `edited_moved` — so a block the changeset did not touch is `same` and
89
+ // (spec/store §5.4 step 10, 14.2) never persisted: a one-paragraph edit
90
+ // moves that paragraph's `updated_at`, not its siblings'. Through store
91
+ // 14.1 every carried block was stamped `edited`.
92
+ const prior = docId === null ? new Map() : priorRows(db, docId);
93
+ const dispositions = classifyCarries(assigned, prior, priorIds).map((c) => ({
94
+ blockId: c.id,
95
+ kind: c.kind,
88
96
  confidence: 1,
89
97
  reason: "api",
90
98
  matcherV: null,
@@ -121,10 +129,42 @@ export function makeKnownIdResolver(store, repoId, doc, priorIds, opts = {}) {
121
129
  }
122
130
  return out;
123
131
  };
124
- void docId;
125
132
  return { assigned, dispositions, deleted, crossDocIds, extractEdges };
126
133
  };
127
134
  }
135
+ function priorRows(db, docId) {
136
+ const rows = db
137
+ .prepare("SELECT block_id, parent_block, ordinal, raw_hash FROM blocks WHERE doc_id = ? AND deleted_commit IS NULL")
138
+ .all(docId);
139
+ return new Map(rows.map((r) => [r.block_id, { parent: r.parent_block, ordinal: r.ordinal, rawHashHex: r.raw_hash.toString("hex") }]));
140
+ }
141
+ /**
142
+ * spec/reconcile §2 "Classifying a carry", applied to the api path's known ids:
143
+ * `moved` = the parent or the sibling index differs from the live row,
144
+ * `content_equal` = the raw bytes hash the same; `same` / `moved` / `edited` /
145
+ * `edited_moved` follow. An id with no prior row (new to this document — a
146
+ * mint or a cross-document arrival) is `inserted`. Pre-order over the tree.
147
+ */
148
+ export function classifyCarries(blocks, prior, priorIds) {
149
+ const out = [];
150
+ const walk = (list, parent) => {
151
+ list.forEach((b, index) => {
152
+ const row = priorIds.has(b.blockId) ? prior.get(b.blockId) : undefined;
153
+ if (!row) {
154
+ out.push({ id: b.blockId, kind: "inserted" });
155
+ }
156
+ else {
157
+ const moved = row.parent !== parent || row.ordinal !== index;
158
+ const contentEqual = row.rawHashHex === sha256(b.raw).toString("hex");
159
+ out.push({ id: b.blockId, kind: contentEqual ? (moved ? "moved" : "same") : moved ? "edited_moved" : "edited" });
160
+ }
161
+ if (b.children.length > 0)
162
+ walk(b.children, b.blockId);
163
+ });
164
+ };
165
+ walk(blocks, null);
166
+ return out;
167
+ }
128
168
  // Edge resolution — mirrors reconciling-ingest's resolvers (block-anchor targets
129
169
  // resolve to the document node in v1; the anchor is preserved on the edge).
130
170
  function resolveEdge(db, repoId, srcDoc, e, docDir) {
@@ -35,6 +35,7 @@ export declare function opRemove(doc: MutDoc, blockIds: string[], opIndex: numbe
35
35
  export declare function opSplit(doc: MutDoc, blockId: string, at: number[], opIndex: number, expect?: Expect): {
36
36
  ids: string[];
37
37
  };
38
+ export declare function trimCutEdges(pieces: readonly string[]): string[];
38
39
  export declare function opMerge(doc: MutDoc, blockIds: string[], opIndex: number, separator?: string, expectPer?: Record<string, Expect>): {
39
40
  ids: string[];
40
41
  mergedInto: string[];
@@ -505,7 +505,7 @@ export function opSplit(doc, blockId, at, opIndex, expect) {
505
505
  const pieces = [];
506
506
  for (let i = 1; i < cuts.length; i++)
507
507
  pieces.push(raw.slice(cuts[i - 1], cuts[i]));
508
- const nonEmpty = pieces.filter((p) => p.trim().length > 0);
508
+ const nonEmpty = trimCutEdges(pieces).filter((p) => p.trim().length > 0);
509
509
  if (nonEmpty.length < 2)
510
510
  throw new MutationError("type_mismatch", "split must yield ≥2 non-empty fragments");
511
511
  // First fragment carries the id (authored intent, 04 §1); rest minted.
@@ -533,6 +533,25 @@ export function opSplit(doc, blockId, at, opIndex, expect) {
533
533
  markContainerDirty(doc, found.siblings);
534
534
  return { ids: [blockId, ...newBlocks.map((b) => b.id)] };
535
535
  }
536
+ // spec/mutate §2.5 (3.5): the pieces of a split are trimmed at the CUT edges
537
+ // only — every piece but the last loses its trailing whitespace, every piece
538
+ // but the first its leading whitespace — so an offset on either side of a
539
+ // space (or inside the run, a `\n`, a hard break's ` \n`) yields the same
540
+ // pieces and no piece ends in a stray space. The raw's own start and end are
541
+ // not cut edges and stay as authored (a last piece keeps the raw's trailing
542
+ // spaces). The whitespace set is the format's trim set (`spec/format` §4.1
543
+ // step 4) — exactly what JavaScript's `trimStart`/`trimEnd` remove.
544
+ export function trimCutEdges(pieces) {
545
+ const last = pieces.length - 1;
546
+ return pieces.map((p, i) => {
547
+ let out = p;
548
+ if (i < last)
549
+ out = out.trimEnd();
550
+ if (i > 0)
551
+ out = out.trimStart();
552
+ return out;
553
+ });
554
+ }
536
555
  export function opMerge(doc, blockIds, opIndex, separator = " ", expectPer) {
537
556
  if (blockIds.length < 2)
538
557
  throw new MutationError("not_contiguous", "merge requires ≥2 blocks");
@@ -75,15 +75,4 @@ export declare function desugarEffect(raw: unknown, index: number, request?: Fix
75
75
  export declare function compileSelection(store: Store, repoId: string, select: unknown, expectRaw: unknown, templatesRaw: unknown): CompiledSelection;
76
76
  /** The query a selector request compiles to (admission + rewrite, before any row is read) — `print` it to see what runs. */
77
77
  export declare function selectionQuery(select: unknown, templates: unknown): Query;
78
- /**
79
- * Compile a plain `effects` list (no selector): desugar the `-> ` leaders,
80
- * then — when nothing is an expression — the shape check as before (3.0
81
- * byte-identical). Otherwise each effect with expressions is admitted as a
82
- * template over its own primary target's row: the effects are grouped by that
83
- * row's identity, one synthetic query per identity — `from <root> where id ==
84
- * "<id>"` with the slot items appended, named `__omg_sel_<effectIndex>_<slot>`
85
- * — runs, and the row binds the slots exactly as a selector's hit would. The
86
- * concrete effects feed the lowering unchanged; `templates` is the list as
87
- * written, for the intent record.
88
- */
89
78
  export declare function compilePlain(store: Store, repoId: string, raw: unknown): CompiledPlain;
@@ -294,7 +294,7 @@ function containsOqx(v) {
294
294
  * defaults to a hit, and its primary target may not be an expression (the
295
295
  * primary target IS the row the expressions read).
296
296
  */
297
- function admitTemplate(raw, index, root, plain) {
297
+ function admitTemplate(raw, index, root, plain, fix) {
298
298
  const here = `effects[${index}]`;
299
299
  if (!isRecord(raw) || typeof raw.op !== "string")
300
300
  throw selectionInvalid(`${here}: a template is an effect object whose \`op\` is one of ${EFFECT_OPS.filter((o) => o !== "retarget").join("/")}`);
@@ -501,8 +501,8 @@ function admitTemplate(raw, index, root, plain) {
501
501
  if (containsOqx(raw[key]))
502
502
  throw selectionInvalid(`${here}.${key}: \`{ oqx }\` is not allowed here`);
503
503
  }
504
- // The shape, with a probe in every slot (the §11.1 messages, naming `effects[i]`).
505
- validateEffect(probe, index);
504
+ // The shape, with a probe in every slot (the §11.1 messages, naming `effects[i]`; the §8 shape fixes rewrite the template as written).
505
+ validateEffect(probe, index, fix);
506
506
  return { index, base, slots, defaultTarget };
507
507
  }
508
508
  /** The `id`/`path` member a bound identity value supplies: a string as given, a row ref by its id. */
@@ -527,7 +527,7 @@ function validateExpect(v) {
527
527
  /**
528
528
  * The fix of a refused selector source (3.3): the same request over an
529
529
  * admitted root — `links` becomes `blocks` (a link lives in a block), any other
530
- * source `docs` — spliced over the source's span of the written query.
530
+ * source (`revisions` included) `docs` — spliced over the source's span of the written query.
531
531
  */
532
532
  function rootFix(select, source, request) {
533
533
  if (request === undefined)
@@ -560,6 +560,9 @@ function admitSelector(select, request) {
560
560
  if (raw.values)
561
561
  throw selectionInvalid("select: a selector yields rows, not `values`");
562
562
  const root = rootTarget(raw.source);
563
+ if (root === null && raw.source.kind === "ident" && raw.source.name === "revisions") {
564
+ throw selectionInvalid("select: `revisions` rows have no effect target — select docs, blocks or nodes", rootFix(select, raw.source, request));
565
+ }
563
566
  if (root === null)
564
567
  throw selectionInvalid("select: a selector scans a root collection — `from docs`, `from blocks` or `from nodes`", rootFix(select, raw.source, request));
565
568
  if (root === "links")
@@ -583,6 +586,10 @@ function runQuery(store, repoId, q, limit) {
583
586
  catch (e) {
584
587
  if (e instanceof OqxError)
585
588
  throw new FilterInvalid(e.message, "OQX");
589
+ // An unknown name (spec/surface §1.5) is refused before any row is read; a
590
+ // selector's refusal wears the admission prefix and hint like every other.
591
+ if (e instanceof FilterInvalid)
592
+ throw selectionInvalid(`select: ${e.message}`);
586
593
  throw e;
587
594
  }
588
595
  }
@@ -639,7 +646,9 @@ function admitRequest(select, templatesRaw, request) {
639
646
  if (templatesRaw.length === 0)
640
647
  throw selectionInvalid("`effects` must name at least one template");
641
648
  const { raw, root } = admitSelector(select, request);
642
- const templates = templatesRaw.map((t, i) => admitTemplate(desugarEffect(t, i, request), i, root, false));
649
+ // Under a selector a template's `into` is bound per hit, so the anchor fix (a parent lookup) does not apply: `parentOf` finds nothing.
650
+ const fix = request === undefined ? undefined : { request, parentOf: () => null };
651
+ const templates = templatesRaw.map((t, i) => admitTemplate(desugarEffect(t, i, request), i, root, false, fix));
643
652
  // The rewrite: the slot items, named `__omg_sel_<t>_<slot>`, appended after
644
653
  // the user's items on the raw tree (so a user alias resolves into them).
645
654
  const items = [];
@@ -826,28 +835,43 @@ function scopeOf(store, repoId, e, i) {
826
835
  * concrete effects feed the lowering unchanged; `templates` is the list as
827
836
  * written, for the intent record.
828
837
  */
838
+ /**
839
+ * The live-row lookup behind the anchor fix (§8, 3.4): a block's parent id
840
+ * (`null` at the top level) and its document's path, from the `blocks` row
841
+ * that is not deleted; `null` for an id the store does not know (a `$new.n`,
842
+ * a typo) — then there is no fix.
843
+ */
844
+ function parentLookup(store) {
845
+ return (id) => {
846
+ const row = store.db
847
+ .prepare("SELECT b.parent_block AS parent, d.path AS path FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ? AND b.deleted_commit IS NULL")
848
+ .get(id);
849
+ return row === undefined ? null : { parent: row.parent, path: row.path };
850
+ };
851
+ }
829
852
  export function compilePlain(store, repoId, raw) {
830
853
  if (!Array.isArray(raw))
831
854
  throw effectInvalid("`effects` must be an array of effects");
832
855
  if (raw.length === 0)
833
856
  throw effectInvalid("`effects` must name at least one effect");
834
857
  const request = { effects: raw };
858
+ const fix = { request, parentOf: parentLookup(store) };
835
859
  const desugared = raw.map((e, i) => desugarEffect(e, i, request));
836
860
  if (!desugared.some(containsOqx))
837
- return { effects: desugared.map((e, i) => validateEffect(e, i)), origins: [] };
861
+ return { effects: desugared.map((e, i) => validateEffect(e, i, fix)), origins: [] };
838
862
  const effects = [];
839
863
  const origins = [];
840
864
  const scopes = new Map();
841
865
  desugared.forEach((e, i) => {
842
866
  if (!containsOqx(e) || !isRecord(e)) {
843
- effects[i] = validateEffect(e, i);
867
+ effects[i] = validateEffect(e, i, fix);
844
868
  return;
845
869
  }
846
870
  const scope = scopeOf(store, repoId, e, i);
847
- const t = admitTemplate(e, i, scope.root, true);
871
+ const t = admitTemplate(e, i, scope.root, true, fix);
848
872
  if (t.slots.length === 0) {
849
873
  // `{ oqx }` only inside data (a nested `value`): literal, as for a template.
850
- effects[i] = validateEffect(e, i);
874
+ effects[i] = validateEffect(e, i, fix);
851
875
  return;
852
876
  }
853
877
  const key = `${scope.root}:${scope.id}`;
@@ -1,11 +1,18 @@
1
1
  import type { DataContext } from "@omgbase/oqx";
2
+ import { type Root } from "./root-row.js";
2
3
  import type { Store } from "../core/store/store.js";
3
4
  import type { SemanticVec } from "../search/cel/compile.js";
4
5
  export type Target = "docs" | "blocks" | "nodes" | "links";
6
+ /** Every row kind the context tags (spec/surface §1.1): the four targets and
7
+ * `revisions` (4.7) — the history target, whose rows are the `revisions` ×
8
+ * `commits` join. `Target` stays the four kinds the unknown-names pass judges
9
+ * (names.ts keys its tables by it); everything that tags, scans or renders a
10
+ * row is keyed by `Kind`. */
11
+ export type Kind = Root;
5
12
  declare const TARGET: unique symbol;
6
13
  /** The engine attributes per kind (spec/surface §1.2 table). Exported so the
7
14
  * translator's tests pin the two against each other. */
8
- export declare const ATTRIBUTES: Readonly<Record<Target, ReadonlySet<string>>>;
15
+ export declare const ATTRIBUTES: Readonly<Record<Kind, ReadonlySet<string>>>;
9
16
  /** The source handles of a document (§1.2): `data` is the merged authored view
10
17
  * (frontmatter + inline), the other two one source each. */
11
18
  export declare const SOURCE_HANDLES: ReadonlySet<string>;
@@ -31,7 +38,7 @@ export interface StoreContextOptions {
31
38
  export declare function makeStoreContext(store: Store, repoId: string, opts?: StoreContextOptions): DataContext;
32
39
  /** Tag SQL rows with their target so the store context resolves them (used by the
33
40
  * tier-3 planner to hand produced rows back for residual evaluation). */
34
- export declare function tagRows(rows: Record<string, unknown>[], target: Target): unknown[];
41
+ export declare function tagRows(rows: Record<string, unknown>[], target: Kind): unknown[];
35
42
  /**
36
43
  * A store row surfacing as a VALUE in a result (spec/surface §1.4, rows as
37
44
  * values, 1.2) is rendered as `{ id, path }` (3.0) — its id column and its
@@ -13,7 +13,7 @@
13
13
  // pushes the hot predicates down; both must agree (conformance suite).
14
14
  import { semantics } from "@omgbase/oqx";
15
15
  import { storeIndexFor } from "./store-index.js";
16
- import { TARGETS, bareTargetMessage } from "./root-row.js";
16
+ import { ROOTS, ROOT_NAMES, bareTargetMessage } from "./root-row.js";
17
17
  import { COLS, FROM, ORDER, guards } from "./sql/scan.js";
18
18
  import { detectRange } from "../core/store/properties.js";
19
19
  import { docsRead } from "../core/read/document.js";
@@ -57,15 +57,29 @@ function tagAll(rows, t) {
57
57
  // `hash`/`raw` is simply the engine's attribute (before this batch of 3.0 the
58
58
  // attributes were `$`-prefixed and the bare spelling was an error, §9). A bare
59
59
  // target name — `docs`, `links`, … — is a loud error of its own (root-row.ts).
60
+ // `revisions` on a document is a RELATION (its revision list, §1.2, 4.7) in
61
+ // `rel.docs` below, and names.ts's `RELATIONS.docs` knows it as one.
60
62
  const DOC_ATTRIBUTES = new Set(["id", "path", "raw", "hash", "updated_at", "title", "tags", "format"]);
61
63
  const BLOCK_ATTRIBUTES = new Set(["id", "path", "raw", "hash", "updated_at", "doc_id", "type", "text", "attrs", "depth", "ordinal"]);
62
64
  const NODE_ATTRIBUTES = new Set(["id", "path", "doc_id", "block_id", "kind", "name", "value", "attrs"]);
63
65
  const EDGE_ATTRIBUTES = new Set(["id", "path", "doc_id", "block_id", "via_id", "dst_id", "dst_path", "uri", "field", "dst_kind", "predicate", "provenance", "anchor"]);
66
+ // The history target (spec/surface §1.2, 4.7): a revision row's own columns
67
+ // under the names `docs_history` already uses (`rev`, `seq`, `commit`, `origin`,
68
+ // `actor`, `hash`), the commit's `ts` and `reason`, and `is_current`.
69
+ const REVISION_ATTRIBUTES = new Set(["id", "path", "rev", "seq", "doc_id", "commit", "ts", "origin", "actor", "reason", "hash", "is_current"]);
64
70
  /** The engine attributes per kind (spec/surface §1.2 table). Exported so the
65
71
  * translator's tests pin the two against each other. */
66
72
  export const ATTRIBUTES = {
67
- docs: DOC_ATTRIBUTES, blocks: BLOCK_ATTRIBUTES, nodes: NODE_ATTRIBUTES, links: EDGE_ATTRIBUTES,
73
+ docs: DOC_ATTRIBUTES, blocks: BLOCK_ATTRIBUTES, nodes: NODE_ATTRIBUTES, links: EDGE_ATTRIBUTES, revisions: REVISION_ATTRIBUTES,
68
74
  };
75
+ // The revisions scan (spec/surface §1.1, 4.7): every revision of every document
76
+ // of the repository — tombstoned documents included, history is history — with
77
+ // its commit, in the keyset order `(path as of the revision, rev_id)`. The
78
+ // `revisions` table carries no `repo_id`, so the repository guard joins `docs`.
79
+ // `is_current` is `docs_history`'s `isCurrent`: the revision is the document's
80
+ // `current_rev`.
81
+ const REVISION_COLS = "r.rev_id, r.doc_id, r.seq, r.rendered_hash, r.path, r.commit_id, c.ts, c.origin, c.actor, c.reason, (d.current_rev = r.rev_id) AS is_current";
82
+ const REVISION_FROM = "revisions r JOIN docs d ON d.doc_id = r.doc_id JOIN commits c ON c.commit_id = r.commit_id";
69
83
  /** The source handles of a document (§1.2): `data` is the merged authored view
70
84
  * (frontmatter + inline), the other two one source each. */
71
85
  export const SOURCE_HANDLES = new Set(["data", "frontmatter", "inline"]);
@@ -87,11 +101,17 @@ export function makeStoreContext(store, repoId, opts = {}) {
87
101
  return Object.values(r)[0];
88
102
  };
89
103
  // ---- root collections (the shared scan shape of sql/scan.ts: live rows of
90
- // this repo, ordered for a stable (path, id) default) -----------------------
104
+ // this repo, ordered for a stable (path, id) default; the revisions scan is
105
+ // its own statement above, in the same (path, id) keyset order) -------------
91
106
  const scan = (t) => {
107
+ if (t === "revisions")
108
+ return tagAll(all(`SELECT ${REVISION_COLS} FROM ${REVISION_FROM} WHERE d.repo_id = ? ORDER BY r.path, r.rev_id`, repoId), t);
92
109
  const g = guards(t, repoId);
93
110
  return tagAll(all(`SELECT ${COLS[t]} FROM ${FROM[t]} WHERE ${g.sql} ORDER BY ${ORDER[t]}`, ...g.params), t);
94
111
  };
112
+ // A document's revisions (the `revisions` relation of a docs row, §1.2, 4.7):
113
+ // oldest first, as `docs_history` lists them.
114
+ const docRevisions = (docId) => tagAll(all(`SELECT ${REVISION_COLS} FROM ${REVISION_FROM} WHERE r.doc_id = ? ORDER BY r.seq`, docId), "revisions");
95
115
  // A root scan is handed out LAZILY: an array (a `Proxy` over one — `Array.isArray`,
96
116
  // iteration, indexing, `length`, rendering all see a plain array of tagged
97
117
  // rows) that runs its SELECT on first use. The engine's optimizer asks
@@ -138,21 +158,14 @@ export function makeStoreContext(store, repoId, opts = {}) {
138
158
  rootScans.set(proxy, { target: t, rows: fill });
139
159
  return proxy;
140
160
  };
141
- const rootFns = {
142
- docs: () => lazyRoot("docs"), blocks: () => lazyRoot("blocks"), nodes: () => lazyRoot("nodes"), links: () => lazyRoot("links"),
143
- };
161
+ const rootFns = Object.fromEntries(ROOT_NAMES.map((t) => [t, () => lazyRoot(t)]));
144
162
  // The root row (spec/surface §1.1, 2.0; spec/oqx 0.18 `rootObject`): the
145
163
  // repository, reached as `^$it` from a top-level row (`0^$it` from any depth).
146
- // Its own enumerable keys are the four collections — lazy getters, so
147
- // `entries(^$it)` names them without running a scan and `^$it.docs` is the
148
- // same handle as `^docs` — and `get` answers `id` with the repository id (an
149
- // attribute, not an entry). Tagged so `get` recognizes it.
150
- const rootRow = Object.defineProperties({ [ROOT_ROW]: true }, {
151
- docs: { enumerable: true, get: () => lazyRoot("docs") },
152
- blocks: { enumerable: true, get: () => lazyRoot("blocks") },
153
- nodes: { enumerable: true, get: () => lazyRoot("nodes") },
154
- links: { enumerable: true, get: () => lazyRoot("links") },
155
- });
164
+ // Its own enumerable keys are the five collections in `ROOT_NAMES` order —
165
+ // lazy getters, so `entries(^$it)` names them without running a scan and
166
+ // `^$it.docs` is the same handle as `^docs` — and `get` answers `id` with the
167
+ // repository id (an attribute, not an entry). Tagged so `get` recognizes it.
168
+ const rootRow = Object.defineProperties({ [ROOT_ROW]: true }, Object.fromEntries(ROOT_NAMES.map((t) => [t, { enumerable: true, get: () => lazyRoot(t) }])));
156
169
  // ---- attrs / property decoding --------------------------------------------
157
170
  const parseJson = (v) => {
158
171
  if (typeof v !== "string")
@@ -298,6 +311,8 @@ export function makeStoreContext(store, repoId, opts = {}) {
298
311
  docs: {
299
312
  nodes: (r) => docNodesInOrder(r.doc_id, r.path),
300
313
  blocks: (r) => tagAll(docBlocksPreorder(r.doc_id, r.path).map((x) => x.row), "blocks"),
314
+ // A document's revision list (spec/surface §1.2, 4.7), oldest first.
315
+ revisions: (r) => docRevisions(r.doc_id),
301
316
  // The linked / linking DOCUMENTS are reached through the link rows (3.0
302
317
  // follow-up, §9: the `out` / `in` relations are gone): `out_links collect
303
318
  // distinct { from dst }` and `in_links collect distinct { from doc }`.
@@ -351,6 +366,7 @@ export function makeStoreContext(store, repoId, opts = {}) {
351
366
  },
352
367
  },
353
368
  links: {},
369
+ revisions: {},
354
370
  };
355
371
  const jattr = (row, k) => {
356
372
  const a = row.attrs;
@@ -417,6 +433,11 @@ export function makeStoreContext(store, repoId, opts = {}) {
417
433
  return undefined;
418
434
  return tag(one(`SELECT * FROM docs WHERE doc_id = ?`, row.dst_node), "docs");
419
435
  };
436
+ // `doc` on a revision row (spec/surface §1.2, 4.7): the document's CURRENT
437
+ // live row — the revision's `path` may be an earlier one — and absent (the
438
+ // single-with-no-row marker, as `dst` on a dangling link) for a tombstoned
439
+ // document: its revisions are rows, `doc == null` tells them apart.
440
+ const revisionDoc = (row) => tag(one(`SELECT * FROM docs WHERE doc_id = ? AND deleted_commit IS NULL`, row.doc_id), "docs");
420
441
  const owningBlock = (row) => {
421
442
  if (!row.block_id)
422
443
  return undefined;
@@ -501,6 +522,22 @@ export function makeStoreContext(store, repoId, opts = {}) {
501
522
  case "dst_kind":
502
523
  case "anchor": return row[name];
503
524
  }
525
+ if (t === "revisions")
526
+ switch (name) {
527
+ // the `revisions` × `commits` columns under docs_history's names (spec/surface §1.2, 4.7).
528
+ case "id":
529
+ case "rev": return row.rev_id;
530
+ case "path": return referencePath(String(row.path));
531
+ case "seq": return row.seq;
532
+ case "doc_id": return row.doc_id;
533
+ case "commit": return row.commit_id;
534
+ case "ts": return row.ts;
535
+ case "origin": return row.origin;
536
+ case "actor": return row.actor ?? null;
537
+ case "reason": return row.reason ?? null;
538
+ case "hash": return hex(row.rendered_hash);
539
+ case "is_current": return row.is_current === 1 || row.is_current === true;
540
+ }
504
541
  return undefined;
505
542
  };
506
543
  // `tags` on a document: authored ∪ hashtags, each list as the property rule
@@ -561,8 +598,11 @@ export function makeStoreContext(store, repoId, opts = {}) {
561
598
  return r;
562
599
  if (t === "nodes" && key === "section")
563
600
  return r;
564
- // reach-through to owning entities, and from a link row to its destination —
565
- // statically single relations: absent is the single-with-no-row marker.
601
+ // reach-through to owning entities, from a link row to its destination and
602
+ // from a revision to its document's current row — statically single
603
+ // relations: absent is the single-with-no-row marker.
604
+ if (t === "revisions" && key === "doc")
605
+ return revisionDoc(r) ?? ABSENT_SINGLE;
566
606
  if (t !== "docs" && key === "doc")
567
607
  return owningDoc(r) ?? ABSENT_SINGLE;
568
608
  if (t === "nodes" && key === "block")
@@ -576,9 +616,9 @@ export function makeStoreContext(store, repoId, opts = {}) {
576
616
  // A bare target name that is not one of this row's relations reads a
577
617
  // property the row has none of (spec/surface §1.1, 2.0): loud, naming the
578
618
  // root-row spelling. `docs`/`links` never get here (run.ts refuses them
579
- // statically); `blocks` on a block or edge row and `nodes` on a node or
580
- // edge row do.
581
- if (TARGETS.has(key))
619
+ // statically); `blocks` on a block or edge row, `nodes` on a node or edge
620
+ // row and `revisions` on anything but a document do.
621
+ if (ROOTS.has(key))
582
622
  throw new FilterInvalid(bareTargetMessage(key, null), "OQX");
583
623
  // the author's data.
584
624
  if (t === "docs") {
@@ -652,8 +692,8 @@ export function makeStoreContext(store, repoId, opts = {}) {
652
692
  }
653
693
  };
654
694
  const textMatch = (t, r, terms) => {
655
- if (t === "links")
656
- throw new FilterInvalid("text(...) is not available on the links target", "10 §5");
695
+ if (t === "links" || t === "revisions")
696
+ throw new FilterInvalid(`text(...) is not available on the ${t} target`, "10 §5");
657
697
  const match = sanitizeFtsQuery(terms);
658
698
  if (match === "")
659
699
  return false;
@@ -676,7 +716,7 @@ export function makeStoreContext(store, repoId, opts = {}) {
676
716
  return one(`SELECT vec FROM embeddings WHERE content_hash = ? AND ctx_hash = ? AND model = ?`, r.raw_hash, Buffer.from(ctxHashHex(ctx), "hex"), model);
677
717
  };
678
718
  const semanticScore = (t, r, phrase) => {
679
- if (t === "nodes" || t === "links")
719
+ if (t === "nodes" || t === "links" || t === "revisions")
680
720
  throw new FilterInvalid("semantic(...) is available on the docs and blocks targets", "OQX semantic");
681
721
  const resolved = sv?.get(phrase);
682
722
  if (!resolved)
@@ -771,10 +811,12 @@ export function makeStoreContext(store, repoId, opts = {}) {
771
811
  return row.node_id;
772
812
  if (t === "links")
773
813
  return row.edge_id;
814
+ if (t === "revisions")
815
+ return row.rev_id;
774
816
  return row;
775
817
  };
776
818
  return {
777
- // The root scope's names (spec/oqx §2 step 5 at the root): the four lazy
819
+ // The root scope's names (spec/oqx §2 step 5 at the root): the five lazy
778
820
  // scans and `id`, the repository id — so `^docs` / `0^docs` from any row is
779
821
  // the scan and `^id` / `0^id` the id.
780
822
  root(name) {
@@ -817,7 +859,9 @@ export function makeStoreContext(store, repoId, opts = {}) {
817
859
  if (collection === null || typeof collection !== "object")
818
860
  return undefined;
819
861
  const root = rootScans.get(collection);
820
- if (!root)
862
+ // The revisions scan has no store index (4.7): the engine builds its own
863
+ // over the materialized rows (one SELECT per context).
864
+ if (!root || root.target === "revisions")
821
865
  return undefined;
822
866
  const key = `${root.target}\u0000${JSON.stringify(path)}`;
823
867
  let index = indexes.get(key);
@@ -891,8 +935,10 @@ export function rowRef(value) {
891
935
  if (!t)
892
936
  return undefined;
893
937
  const r = value;
894
- const id = t === "docs" ? r.doc_id : t === "blocks" ? r.block_id : t === "nodes" ? r.node_id : r.edge_id;
895
- const path = t === "docs" ? r.path : r.__path;
938
+ const id = t === "docs" ? r.doc_id : t === "blocks" ? r.block_id : t === "nodes" ? r.node_id : t === "revisions" ? r.rev_id : r.edge_id;
939
+ // a revision's path is `revisions.path` — the path it was committed under
940
+ // (a move re-paths the current revision; §1.2, 4.7).
941
+ const path = t === "docs" || t === "revisions" ? r.path : r.__path;
896
942
  return { id: String(id), path: path == null ? "" : referencePath(String(path)) };
897
943
  }
898
944
  export { TARGET };
@@ -0,0 +1,40 @@
1
+ import type { Query, ReferenceStep } from "@omgbase/oqx";
2
+ import { type Kind } from "./context.js";
3
+ import type { Store } from "../core/store/store.js";
4
+ /** What to do with a tier-3 name on an open kind: record it in `hints` (every read) or refuse the query (a write selector). */
5
+ export type UnknownNames = "hint" | "refuse";
6
+ /** The relations of each kind and the kind of the rows they yield (spec/surface §1.2). */
7
+ export declare const RELATIONS: Readonly<Record<Kind, Readonly<Record<string, Kind>>>>;
8
+ /** The self and reach-through handles of each kind and the kind of the row they reach (§1.2). */
9
+ export declare const HANDLES: Readonly<Record<Kind, Readonly<Record<string, Kind>>>>;
10
+ /** The vocabulary of a kind in a repository (cached on the head commit; the SQL runs only when a name is not intrinsic). */
11
+ export declare function vocabulary(store: Store, repoId: string, target: Kind): {
12
+ present: boolean;
13
+ keys: readonly string[];
14
+ };
15
+ /** The kind of the rows a scope holds, from its row chain (AST.md §5b); null when the chain reaches a value no kind describes. */
16
+ export declare function kindOfScope(steps: readonly ReferenceStep[]): Kind | null;
17
+ /** Optimal string alignment distance (Damerau–Levenshtein with adjacent transpositions, each edit 1). */
18
+ export declare function editDistance(a: string, b: string): number;
19
+ /**
20
+ * The nearest match of `name` among `candidates` (§1.5): case-insensitive; a
21
+ * candidate qualifies when its distance is ≤ 2, or when one of the two is a
22
+ * substring of the other and the shorter has at least three characters; the
23
+ * lowest distance wins, ties broken by the shorter candidate, then
24
+ * alphabetically. Null when nothing qualifies.
25
+ */
26
+ export declare function nearest(name: string, candidates: readonly string[]): string | null;
27
+ /** The error of a closed kind: `` `target` is not a field of links (fields: id, path, …); did you mean `…`? ``. */
28
+ export declare function fieldErrorMessage(store: Store, repoId: string, target: Kind, name: string): string;
29
+ /** The hint of an open kind: `` `preview` is not a field of blocks and no block in this repository carries a property by that name; did you mean `…`? ``.
30
+ * Under a source handle the name is an authored lookup by construction, so the
31
+ * hint says only that: `` no document in this repository carries a property `path` (`data.path`); did you mean `…`? ``,
32
+ * the nearest match drawn from the authored keys alone. */
33
+ export declare function propertyHintMessage(store: Store, repoId: string, target: Kind, name: string, handle?: string): string;
34
+ /**
35
+ * Judge every name the query reads. Returns the hints (one per offending name
36
+ * of an open kind, first-reference order, deduplicated) under `"hint"`; throws
37
+ * `FilterInvalid` for a tier-3 name on a closed kind in either mode, and for
38
+ * one on an open kind under `"refuse"`.
39
+ */
40
+ export declare function checkNames(store: Store, repoId: string, raw: Query, mode: UnknownNames): string[];