@omgbase/core 0.23.0 → 0.27.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 (45) hide show
  1. package/dist/src/core/edit-script.d.ts +15 -0
  2. package/dist/src/core/edit-script.js +72 -0
  3. package/dist/src/core/ingest.js +42 -12
  4. package/dist/src/core/read/document.d.ts +7 -0
  5. package/dist/src/core/read/document.js +38 -17
  6. package/dist/src/core/store/archive.d.ts +28 -0
  7. package/dist/src/core/store/archive.js +106 -17
  8. package/dist/src/core/store/delta.d.ts +131 -0
  9. package/dist/src/core/store/delta.js +470 -0
  10. package/dist/src/core/store/gc.d.ts +10 -0
  11. package/dist/src/core/store/gc.js +23 -7
  12. package/dist/src/core/store/retention.d.ts +1 -1
  13. package/dist/src/core/store/retention.js +3 -17
  14. package/dist/src/core/store/schema.d.ts +1 -1
  15. package/dist/src/core/store/schema.js +2 -1
  16. package/dist/src/core/store/store.js +7 -0
  17. package/dist/src/core/store/writers.d.ts +42 -6
  18. package/dist/src/core/store/writers.js +67 -25
  19. package/dist/src/format/json.js +5 -0
  20. package/dist/src/format/yaml.js +15 -3
  21. package/dist/src/graph/history.d.ts +1 -15
  22. package/dist/src/graph/history.js +14 -74
  23. package/dist/src/mcp/arguments.d.ts +11 -0
  24. package/dist/src/mcp/arguments.js +47 -4
  25. package/dist/src/mcp/errors.js +1 -1
  26. package/dist/src/mcp/reference.d.ts +1 -1
  27. package/dist/src/mcp/reference.js +29 -9
  28. package/dist/src/mcp/server.js +4 -2
  29. package/dist/src/mutate/effects.js +87 -13
  30. package/dist/src/mutate/frontmatter-parts.d.ts +71 -0
  31. package/dist/src/mutate/frontmatter-parts.js +237 -0
  32. package/dist/src/mutate/load.js +9 -9
  33. package/dist/src/mutate/select.js +14 -4
  34. package/dist/src/oqx-js/context.js +32 -3
  35. package/dist/src/oqx-js/names.js +1 -1
  36. package/dist/src/oqx-js/planner.js +11 -7
  37. package/dist/src/oqx-js/run.d.ts +8 -0
  38. package/dist/src/oqx-js/run.js +103 -2
  39. package/dist/src/oqx-js/sql/translate.js +33 -6
  40. package/dist/src/oqx-js/store-index.js +1 -1
  41. package/dist/src/search/cel/parser.d.ts +3 -1
  42. package/dist/src/search/cel/parser.js +4 -1
  43. package/dist/src/spec-versions.d.ts +5 -5
  44. package/dist/src/spec-versions.js +4 -4
  45. package/package.json +2 -2
@@ -0,0 +1,237 @@
1
+ // Frontmatter entries (spec/mutate §11.3 "Frontmatter entries", 3.6). The
2
+ // effect planner's `set`/`unset frontmatter.<key>` edits ONE entry of a
3
+ // document's frontmatter and leaves every other byte alone. To do that the
4
+ // `frontmatter` block's raw (fences included) is cut into an ordered list of
5
+ // PARTS — the opening fence line, one part per top-level YAML entry (with the
6
+ // lines that continue it and the comment lines that lead it), the trailing
7
+ // comment/blank lines, the closing fence — by a LEXICAL, line-based rule, not
8
+ // a YAML parse: it is total (every frontmatter the store holds, valid YAML or
9
+ // not, splits), byte-exact (the parts concatenate back to the raw, always) and
10
+ // identical in both engines; what a line *means* is the properties layer's
11
+ // business. Where the rule and YAML disagree (a pathological document) the
12
+ // edit may touch a wider or narrower run of lines than a YAML parser would,
13
+ // but never loses a byte of the other entries.
14
+ //
15
+ // Storage is untouched: a revision's frontmatter stays one blob (spec/store
16
+ // 15.0). The parts are the planner's view of the bytes, computed from
17
+ // `frontmatter_raw` when a frontmatter key is set or unset.
18
+ /** Split text into lines, each keeping its own line ending (`\n` or `\r\n`); the last line has none. `""` → `[""]`. */
19
+ export function splitLines(text) {
20
+ const out = [];
21
+ let start = 0;
22
+ for (let i = 0; i < text.length; i++) {
23
+ if (text.charCodeAt(i) === 10) {
24
+ out.push(text.slice(start, i + 1));
25
+ start = i + 1;
26
+ }
27
+ }
28
+ out.push(text.slice(start));
29
+ return out;
30
+ }
31
+ /** A line without its line ending. */
32
+ function content(line) {
33
+ return line.endsWith("\r\n") ? line.slice(0, -2) : line.endsWith("\n") ? line.slice(0, -1) : line;
34
+ }
35
+ /** The line ending of `line` (`"\r\n"`, `"\n"`, or `""` when it has none). */
36
+ export function lineEndingOf(line) {
37
+ return line.endsWith("\r\n") ? "\r\n" : line.endsWith("\n") ? "\n" : "";
38
+ }
39
+ /** A blank line (empty, or spaces and tabs only). */
40
+ export function isBlankLine(line) {
41
+ return /^[ \t]*$/.test(content(line));
42
+ }
43
+ /** A comment line — `#` in column 0. (An indented `#` is content: a block scalar's line, for one.) */
44
+ export function isCommentLine(line) {
45
+ return content(line).startsWith("#");
46
+ }
47
+ /** A LEAD line: blank or comment — the lines that travel with the entry that follows them. */
48
+ function isLeadLine(line) {
49
+ return isBlankLine(line) || isCommentLine(line);
50
+ }
51
+ // Characters that cannot begin a plain YAML key (flow indicators, anchors,
52
+ // aliases, tags, block scalar indicators, directives, reserved).
53
+ const NOT_A_PLAIN_KEY_START = new Set(["[", "]", "{", "}", ",", "&", "*", "!", "|", ">", "%", "@", "`"]);
54
+ /**
55
+ * The entry boundary: the key of a column-0 line that opens a top-level
56
+ * mapping entry, or `null` when the line does not. A line opens an entry when
57
+ * it is not blank, not a comment, not a sequence item (`-` followed by a
58
+ * space, a tab or the end of the line), not an explicit key or value
59
+ * indicator (`?` / `:` the same way), not a document marker (`---` / `...`
60
+ * followed by whitespace or the end of the line), and reads as `<key>:`
61
+ * followed by a space, a tab or the end of the line — the key either
62
+ * - quoted: a `"…"` string (`\` escapes the next character) or a `'…'` string
63
+ * (`''` is a literal quote), closed on the same line, then optional spaces/
64
+ * tabs, then the colon; the key is the content with `\\` → `\`, `\"` → `"`
65
+ * (double) or `''` → `'` (single) undone and every other escape kept as
66
+ * written; or
67
+ * - plain: the text before the FIRST `:` that is followed by a space, a tab or
68
+ * the end of the line, trailing spaces/tabs trimmed — non-empty, not
69
+ * starting with a flow indicator, an anchor/alias/tag/block-scalar/
70
+ * directive/reserved character (`[ ] { } , & * ! | > % @ \``) and not
71
+ * containing ` #` or `\t#` (a comment would start there).
72
+ * Anything else — an indented line, a flow collection's continuation, a block
73
+ * scalar's content — is a continuation of the entry above it.
74
+ */
75
+ export function entryKeyOf(line) {
76
+ const c = content(line);
77
+ if (c.length === 0)
78
+ return null;
79
+ const c0 = c[0];
80
+ if (c0 === " " || c0 === "\t" || c0 === "#")
81
+ return null;
82
+ if (c0 === '"' || c0 === "'")
83
+ return quotedKey(c, c0);
84
+ const c1 = c.length > 1 ? c[1] : "";
85
+ const wsOrEnd = (ch) => ch === "" || ch === " " || ch === "\t";
86
+ if ((c0 === "-" || c0 === "?" || c0 === ":") && wsOrEnd(c1))
87
+ return null;
88
+ if ((c.startsWith("---") || c.startsWith("...")) && wsOrEnd(c.length > 3 ? c[3] : ""))
89
+ return null;
90
+ if (NOT_A_PLAIN_KEY_START.has(c0))
91
+ return null;
92
+ const m = /^(.*?):(?:[ \t]|$)/.exec(c);
93
+ if (!m)
94
+ return null;
95
+ const key = m[1].replace(/[ \t]+$/, "");
96
+ if (key.length === 0 || / #|\t#/.test(key))
97
+ return null;
98
+ return key;
99
+ }
100
+ function quotedKey(c, quote) {
101
+ let i = 1;
102
+ let close = -1;
103
+ if (quote === '"') {
104
+ while (i < c.length) {
105
+ const ch = c[i];
106
+ if (ch === "\\")
107
+ i += 2;
108
+ else if (ch === '"') {
109
+ close = i;
110
+ break;
111
+ }
112
+ else
113
+ i++;
114
+ }
115
+ }
116
+ else {
117
+ while (i < c.length) {
118
+ if (c[i] === "'") {
119
+ if (c[i + 1] === "'")
120
+ i += 2;
121
+ else {
122
+ close = i;
123
+ break;
124
+ }
125
+ }
126
+ else
127
+ i++;
128
+ }
129
+ }
130
+ if (close < 0)
131
+ return null;
132
+ if (!/^[ \t]*:(?:[ \t]|$)/.test(c.slice(close + 1)))
133
+ return null;
134
+ const inner = c.slice(1, close);
135
+ return quote === '"' ? inner.replace(/\\(["\\])/g, "$1") : inner.replace(/''/g, "'");
136
+ }
137
+ /**
138
+ * The frontmatter block's raw (fences included, no trailing line ending) → its
139
+ * parts, in order. The parts always concatenate back to `raw` byte for byte:
140
+ * every line of the raw lands in exactly one part.
141
+ *
142
+ * - part 0 is the opening fence line with its line ending, key `null`;
143
+ * - the last part is the closing fence line (no line ending), key `null`;
144
+ * - between them, the body lines: every line before the first entry line is
145
+ * one part with key `null` (comments, blank lines, or — when no line opens
146
+ * an entry: a bare scalar, a sequence — the whole body); then one part per
147
+ * entry, from the first of the lead lines (blank / column-0 comment)
148
+ * immediately preceding its key line (never reaching back past the
149
+ * previous entry's key line) through the line before the next entry's
150
+ * lead, the last entry ending before the trailing lead run; the trailing
151
+ * lead run (blank / comment lines after the last entry's content) is one
152
+ * part with key `null`.
153
+ *
154
+ * A raw with fewer than two lines (not a frontmatter block) is one part.
155
+ */
156
+ export function splitFrontmatterRaw(raw) {
157
+ const lines = splitLines(raw);
158
+ if (lines.length < 2)
159
+ return [{ key: null, bytes: raw }];
160
+ const open = lines[0];
161
+ const close = lines[lines.length - 1];
162
+ const body = lines.slice(1, -1);
163
+ const parts = [{ key: null, bytes: open }];
164
+ const entryAt = body.map(entryKeyOf);
165
+ const entryIdx = [];
166
+ entryAt.forEach((k, i) => {
167
+ if (k !== null)
168
+ entryIdx.push(i);
169
+ });
170
+ if (entryIdx.length === 0) {
171
+ if (body.length > 0)
172
+ parts.push({ key: null, bytes: body.join("") });
173
+ parts.push({ key: null, bytes: close });
174
+ return parts;
175
+ }
176
+ // Where each entry's part starts: its lead run, bounded by the previous
177
+ // entry's key line; the first entry starts at its key line (the lines
178
+ // before it are the leading null part).
179
+ const starts = entryIdx.map((i, n) => {
180
+ if (n === 0)
181
+ return i;
182
+ const floor = entryIdx[n - 1] + 1;
183
+ let j = i;
184
+ while (j - 1 >= floor && isLeadLine(body[j - 1]))
185
+ j--;
186
+ return j;
187
+ });
188
+ // The trailing null part: the lead run after the last entry's key line.
189
+ let tail = body.length;
190
+ const lastKey = entryIdx[entryIdx.length - 1];
191
+ while (tail - 1 > lastKey && isLeadLine(body[tail - 1]))
192
+ tail--;
193
+ if (starts[0] > 0)
194
+ parts.push({ key: null, bytes: body.slice(0, starts[0]).join("") });
195
+ entryIdx.forEach((i, n) => {
196
+ const end = n + 1 < starts.length ? starts[n + 1] : tail;
197
+ parts.push({ key: entryAt[i], bytes: body.slice(starts[n], end).join("") });
198
+ });
199
+ if (tail < body.length)
200
+ parts.push({ key: null, bytes: body.slice(tail).join("") });
201
+ parts.push({ key: null, bytes: close });
202
+ return parts;
203
+ }
204
+ /** The inverse of {@link splitFrontmatterRaw}: the parts' bytes concatenated. */
205
+ export function joinFrontmatterParts(parts) {
206
+ return parts.map((p) => p.bytes).join("");
207
+ }
208
+ /**
209
+ * The frontmatter block's raw and its trailing trivia, out of a document's
210
+ * `frontmatter_raw` (`raw + docs.frontmatter_trivia`, spec/mutate §2): the
211
+ * closing fence is the first line after the opening one that is `---`
212
+ * followed only by spaces/tabs (the format layer's rule); the raw ends with
213
+ * that line's content, the trivia is everything after. `null` when no closing
214
+ * fence is found (not a frontmatter the format layer would have produced).
215
+ */
216
+ export function splitFrontmatterTrivia(frontmatterRaw) {
217
+ const lines = splitLines(frontmatterRaw);
218
+ for (let i = 1; i < lines.length; i++) {
219
+ const line = lines[i];
220
+ if (/^---[ \t]*$/.test(content(line))) {
221
+ const upto = lines.slice(0, i).join("") + content(line);
222
+ return { raw: upto, trivia: frontmatterRaw.slice(upto.length) };
223
+ }
224
+ }
225
+ return null;
226
+ }
227
+ /** The lines of a part that LEAD its key line (blank / column-0 comment lines), as one string; `""` for a part without a key. */
228
+ export function leadOf(part) {
229
+ if (part.key === null)
230
+ return "";
231
+ const lines = splitLines(part.bytes);
232
+ let n = 0;
233
+ while (n < lines.length - 1 && isLeadLine(lines[n]))
234
+ n++;
235
+ return lines.slice(0, n).join("");
236
+ }
237
+ //# sourceMappingURL=frontmatter-parts.js.map
@@ -1,3 +1,4 @@
1
+ import { currentFrontmatterRaw } from "../core/read/document.js";
1
2
  export function loadMutDoc(db, docId) {
2
3
  const doc = db.prepare("SELECT doc_id, path, format, leading_trivia, frontmatter_trivia, current_rev FROM docs WHERE doc_id = ? AND deleted_commit IS NULL").get(docId);
3
4
  if (!doc)
@@ -44,15 +45,14 @@ export function loadMutDoc(db, docId) {
44
45
  }
45
46
  let frontmatterRaw = null;
46
47
  if (doc.current_rev) {
47
- const rev = db.prepare("SELECT frontmatter_blob FROM revisions WHERE rev_id = ?").get(doc.current_rev);
48
- if (rev?.frontmatter_blob) {
49
- const fm = blob.get(rev.frontmatter_blob);
50
- // Append the exact stored separator (docs.frontmatter_trivia) so the
51
- // write path round-trips byte-for-byte. Ingest always captures it when a
52
- // frontmatter block exists, so the ?? "" is just a type guard.
53
- if (fm)
54
- frontmatterRaw = fm.bytes.toString("utf8") + (doc.frontmatter_trivia ?? "");
55
- }
48
+ // The current revision's frontmatter, resolved when stored as a delta
49
+ // (spec/store §6.1, 16.0). Append the exact stored separator
50
+ // (docs.frontmatter_trivia) so the write path round-trips byte-for-byte.
51
+ // Ingest always captures it when a frontmatter block exists, so the ?? ""
52
+ // is just a type guard.
53
+ const fm = currentFrontmatterRaw(db, doc.current_rev);
54
+ if (fm !== null)
55
+ frontmatterRaw = fm + (doc.frontmatter_trivia ?? "");
56
56
  }
57
57
  return {
58
58
  docId: doc.doc_id,
@@ -1,5 +1,5 @@
1
1
  import { OqxError, parse } from "@omgbase/oqx";
2
- import { checkRootSpellings, oqxRunQuery, reservedAlias } from "../oqx-js/run.js";
2
+ import { TUPLE_HINT, bracketListAsTuple, checkRootSpellings, oqxRunQuery, reservedAlias } from "../oqx-js/run.js";
3
3
  import { rootTarget } from "../oqx-js/planner.js";
4
4
  import { FilterInvalid } from "../search/cel/parser.js";
5
5
  import { EFFECT_OPS, EXPRESSION_LEADER, bareTarget, effectInvalid, findDocumentTarget, isNewSymbol, isTargetId, looksLikePath, validateEffect, validateTarget, } from "./effects.js";
@@ -545,8 +545,14 @@ function admitSelector(select, request) {
545
545
  checkRootSpellings(raw);
546
546
  }
547
547
  catch (e) {
548
- if (e instanceof OqxError)
549
- throw selectionInvalid(`select: ${e.message}`);
548
+ if (e instanceof OqxError) {
549
+ // (spec/surface 4.11) `id in ["a", "b"]` — the bracket list rewritten to a
550
+ // tuple is the `fix` (the same request over the corrected selector).
551
+ const rewritten = bracketListAsTuple(select, e.message);
552
+ if (rewritten === null)
553
+ throw selectionInvalid(`select: ${e.message}`);
554
+ throw selectionInvalid(`select: ${e.message}${TUPLE_HINT}`, request === undefined ? {} : { fix: fixRequest({ ...request, select: rewritten }) });
555
+ }
550
556
  if (e instanceof FilterInvalid)
551
557
  throw selectionInvalid(`select: ${e.message}`);
552
558
  throw e;
@@ -616,7 +622,11 @@ export function compileSelection(store, repoId, select, expectRaw, templatesRaw)
616
622
  fix: fixRequest({ select, effects: templatesRaw, expect: { max: count } }),
617
623
  });
618
624
  }
619
- // Row → effects: hits in result order × templates in order (k = h·T + t).
625
+ // Row → effects: hits in result order × templates in order (k = h·T + t). A
626
+ // selector with `flatten` (oqx 0.25; §11.8) fans out per FLATTENED row: each
627
+ // hit is the parent row (its `id`/`path`), the slot items read the bound name
628
+ // (`-> tag`) in the flattened scope, and `expect.max` — the page bound above —
629
+ // counts the flattened hits.
620
630
  const effects = [];
621
631
  const origins = [];
622
632
  hits.forEach((hit) => {
@@ -45,7 +45,9 @@ function tagAll(rows, t) {
45
45
  // (`children`, `blocks`, `out_links`, `doc`, `frontmatter`, …) always followed:
46
46
  // a bare name that is an engine attribute reads the engine's value, never the
47
47
  // author's. The attributes: every row's `id`/`path`; docs' and blocks' `raw`/
48
- // `hash`/`updated_at`; docs' `title` (the computed title) and `tags` (authored
48
+ // `hash`/`updated_at`/`created_at` (4.10: the commit `ts` of the thing's first
49
+ // revision / earliest `block_changes` row — "first observed by omgbase", not a
50
+ // filesystem time); docs' `title` (the computed title) and `tags` (authored
49
51
  // tags ∪ body hashtags) and `format`; a foreign key named for what it is
50
52
  // (`doc_id`, `block_id`, `via_id`, `dst_id`, `dst_path`, `uri`), a column its
51
53
  // column (`type`, `text`, `depth`, `ordinal`, `kind`, `predicate`, `field`).
@@ -60,8 +62,8 @@ function tagAll(rows, t) {
60
62
  // target name — `docs`, `links`, … — is a loud error of its own (root-row.ts).
61
63
  // `revisions` on a document is a RELATION (its revision list, §1.2, 4.7) in
62
64
  // `rel.docs` below, and names.ts's `RELATIONS.docs` knows it as one.
63
- const DOC_ATTRIBUTES = new Set(["id", "path", "raw", "hash", "updated_at", "title", "tags", "format"]);
64
- const BLOCK_ATTRIBUTES = new Set(["id", "path", "raw", "hash", "updated_at", "doc_id", "type", "text", "attrs", "depth", "ordinal"]);
65
+ const DOC_ATTRIBUTES = new Set(["id", "path", "raw", "hash", "updated_at", "created_at", "title", "tags", "format"]);
66
+ const BLOCK_ATTRIBUTES = new Set(["id", "path", "raw", "hash", "updated_at", "created_at", "doc_id", "type", "text", "attrs", "depth", "ordinal"]);
65
67
  const NODE_ATTRIBUTES = new Set(["id", "path", "doc_id", "block_id", "kind", "name", "value", "attrs"]);
66
68
  const EDGE_ATTRIBUTES = new Set(["id", "path", "doc_id", "block_id", "via_id", "dst_id", "dst_path", "uri", "field", "dst_kind", "predicate", "provenance", "anchor"]);
67
69
  // The history target (spec/surface §1.2, 4.7): a revision row's own columns
@@ -449,6 +451,18 @@ export function makeStoreContext(store, repoId, opts = {}) {
449
451
  return tag(load(), "blocks");
450
452
  return lazyOwner({ block_id: row.block_id, doc_id: row.doc_id, __path: row.__path }, "blocks", load);
451
453
  };
454
+ // `block` on a link row (spec/surface §1.2, 4.11): the SOURCE block the link
455
+ // was written in — the blocks row `block_id` (the edge's `src_block`) names,
456
+ // in the source document (`__path` is already the block's document path) —
457
+ // mirroring `doc`; absent when the link has none (a frontmatter link).
458
+ const linkBlock = (row) => {
459
+ if (!row.src_block)
460
+ return undefined;
461
+ const load = () => one(`SELECT b.*, d.path AS __path FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ?`, row.src_block);
462
+ if (typeof row.__path !== "string")
463
+ return tag(load(), "blocks");
464
+ return lazyOwner({ block_id: row.src_block, doc_id: row.src_doc, __path: row.__path }, "blocks", load);
465
+ };
452
466
  // ---- engine attributes -----------------------------------------------------
453
467
  // Bare, snake_case, winning on collision (see the name rule above; spec/surface
454
468
  // §1.2, 3.0). `path` (and the edge field `dst_path`) is the REFERENCE form
@@ -464,6 +478,12 @@ export function makeStoreContext(store, repoId, opts = {}) {
464
478
  case "path": return referencePath(String(row.path));
465
479
  case "hash": return row.file_hash == null ? null : hex(row.file_hash);
466
480
  case "updated_at": return scalar(`SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = ?`, row.current_rev) ?? null;
481
+ // `created_at` (4.10): the commit `ts` of the document's first revision
482
+ // (`seq = 1`, the row `revisions first { ts values }` reads) — when
483
+ // omgbase first observed the document, not a filesystem time. The
484
+ // retention squash (spec/store §7.1, 15.0) keeps every revision and
485
+ // commit row, so the value survives archiving.
486
+ case "created_at": return scalar(`SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.doc_id = ? AND r.seq = 1`, row.doc_id) ?? null;
467
487
  // `raw`: the exact file bytes (spec/store §6.1, frontmatter included).
468
488
  case "raw": return docsRead(store, String(row.doc_id))?.raw ?? null;
469
489
  // `title`: the computed title (spec/properties §3.3 — the authored
@@ -487,6 +507,13 @@ export function makeStoreContext(store, repoId, opts = {}) {
487
507
  case "raw": return row.raw_hash == null ? null : scalar(`SELECT bytes FROM blobs WHERE hash = ?`, row.raw_hash)?.toString("utf8") ?? null;
488
508
  case "hash": return row.raw_hash == null ? null : hex(row.raw_hash);
489
509
  case "updated_at": return scalar(`SELECT MAX(c.ts) FROM block_changes bc JOIN commits c ON c.commit_id = bc.commit_id WHERE bc.block_id = ?`, row.block_id) ?? null;
510
+ // `created_at` (4.10): the EARLIEST commit `ts` among the block's
511
+ // `block_changes` — the same join as `updated_at` under MIN: the commit
512
+ // that first wrote the id (`inserted`, or the split/merge lineage row
513
+ // that minted it). Store 14.2 keeps that row and the 15.0 squash keeps
514
+ // each block's earliest row (spec/store §7.1), so it survives archiving;
515
+ // a resurrected block keeps its original creation.
516
+ case "created_at": return scalar(`SELECT MIN(c.ts) FROM block_changes bc JOIN commits c ON c.commit_id = bc.commit_id WHERE bc.block_id = ?`, row.block_id) ?? null;
490
517
  case "doc_id": return row.doc_id;
491
518
  case "type": return row.type;
492
519
  case "text": return row.text;
@@ -613,6 +640,8 @@ export function makeStoreContext(store, repoId, opts = {}) {
613
640
  return owningBlock(r) ?? ABSENT_SINGLE;
614
641
  if (t === "links" && key === "dst")
615
642
  return destinationDoc(r) ?? ABSENT_SINGLE;
643
+ if (t === "links" && key === "block")
644
+ return linkBlock(r) ?? ABSENT_SINGLE;
616
645
  // relations.
617
646
  const rels = rel[t];
618
647
  if (rels[key])
@@ -41,7 +41,7 @@ export const HANDLES = {
41
41
  docs: { doc: "docs" },
42
42
  blocks: { block: "blocks", doc: "docs" },
43
43
  nodes: { section: "nodes", doc: "docs", block: "blocks" },
44
- links: { doc: "docs", dst: "docs" },
44
+ links: { doc: "docs", dst: "docs", block: "blocks" },
45
45
  revisions: { doc: "docs" },
46
46
  };
47
47
  /** The kinds whose row shape is closed: no authored data reaches a row by bare name. */
@@ -97,6 +97,9 @@ function subMayRaise(s, target) {
97
97
  return true;
98
98
  if (s.having && whereMayRaise(s.having, target, false))
99
99
  return true;
100
+ // A flattened block (oqx 0.25) evaluates each `flatten` expression per row.
101
+ if (s.flatten.some((f) => exprMayRaise(f.expr, target, false)))
102
+ return true;
100
103
  if (s.limit && exprMayRaise(s.limit, target, false))
101
104
  return true;
102
105
  if (s.offset && exprMayRaise(s.offset, target, false))
@@ -158,13 +161,14 @@ export class SQLiteQueryPlanner {
158
161
  }
159
162
  plan(query, params) {
160
163
  // Only simple top-level scans: no follow, no `from E` re-projection, no
161
- // `group by` (oqx 0.24). A grouped query is declined whole: its `where`
162
- // conjuncts could be pushed (grouping happens after them, so the residual
163
- // would group the produced rows correctly), but a pushed conjunct that
164
- // empties the scan would hide an eval error an aggregate or `having` raises
165
- // in memory (`sum` over a string) — decline (c) extended to the post-group
166
- // clauses. SQL `GROUP BY` pushdown is a later round.
167
- if (query.follow || query.from.length > 0 || query.groupBy)
164
+ // `group by` (oqx 0.24), no `flatten` (oqx 0.25). A grouped or flattened
165
+ // query is declined whole: its `where` conjuncts could be pushed (the fan-out
166
+ // and the grouping happen after them, so the residual would flatten and
167
+ // group the produced rows correctly), but a pushed conjunct that empties the
168
+ // scan would hide an eval error a `flatten` expression, an aggregate or
169
+ // `having` raises in memory (`x!`, `sum` over a string) — decline (c)
170
+ // extended to the post-where clauses. SQL pushdown of either is a later round.
171
+ if (query.follow || query.from.length > 0 || query.groupBy || query.flatten.length > 0)
168
172
  return null;
169
173
  const target = rootTarget(query.source);
170
174
  if (!target)
@@ -69,6 +69,11 @@ export interface OqxOptions {
69
69
  * the default of the AST entry (`oqxRunQuery`), whose caller is the write
70
70
  * selector compiler, where a name no row carries would select every row. */
71
71
  unknownNames?: UnknownNames;
72
+ /** The `query` call as the caller received it (spec/surface 4.11): the
73
+ * arguments a corrected call is spelled from when a parse error can be fixed
74
+ * (`data.fix`, the bracket-list rule below). Absent, the message still gains
75
+ * its hint and no `fix` is composed. */
76
+ request?: Record<string, unknown>;
72
77
  }
73
78
  export declare function rootPathLiterals(e: Expr): Expr;
74
79
  export declare function checkRootSpellings(q: Query): void;
@@ -78,6 +83,9 @@ export declare function reservedAlias(items: readonly SelectItem[]): FilterInval
78
83
  export declare const BARE_NEEDS_SELECT_MESSAGE = "`bare` needs a `select` \u2014 without a projection a hit is only its envelope";
79
84
  export declare const BARE_WITH_VALUES_MESSAGE = "`bare` is redundant with `values` \u2014 `values` is already the bare-values form; drop one";
80
85
  export declare function oqxRun(store: Store, repoId: string, source: string, opts?: OqxOptions): OqxResult;
86
+ export declare const TUPLE_HINT = "; a list is written as a tuple: (\u2026)";
87
+ /** The source with the bracket list at the error's offset rewritten to a tuple, or null when the rule does not apply. */
88
+ export declare function bracketListAsTuple(source: string, message: string): string | null;
81
89
  /**
82
90
  * The AST entry (4.1): run an already parsed query — the source path above
83
91
  * after `parse`, and the selector compiler (`mutate/select.ts`), which appends
@@ -14,6 +14,7 @@ import { FilterInvalid } from "../search/cel/parser.js";
14
14
  import { float32ToBlob } from "../core/vec.js";
15
15
  import { encodeCursor as encodeKeyset, decodePathCursor } from "../core/cursor.js";
16
16
  import { referencePath } from "../core/paths.js";
17
+ import { canonicalJson } from "../mutate/canonical.js";
17
18
  // Row-scoped domain functions: authored as free calls (`text("x")`) that
18
19
  // implicitly reference the current row. `@omgbase/oqx` free functions receive no
19
20
  // row, so we rewrite them to `$self.fn(…)` (a method whose receiver is the row)
@@ -312,10 +313,95 @@ export function oqxRun(store, repoId, source, opts = {}) {
312
313
  catch (e) {
313
314
  // Normalize the library's errors to omgbase's contract (filter_invalid).
314
315
  if (e instanceof OqxError)
315
- throw new FilterInvalid(e.message, "OQX");
316
+ throw parseInvalid(source, e, opts.request);
316
317
  throw e;
317
318
  }
318
319
  }
320
+ // ---- the bracket-list fix (spec/surface §4 "Error data", 4.11) ----------------------
321
+ //
322
+ // OQX has no list literal; a tuple `("a", "b")` is the collection (`spec/oqx`
323
+ // SEMANTICS §3b), so `id in ["a", "b"]` — the SQL habit — is the parse error
324
+ // `unexpected '[' — expected a value (at offset N)`. When the source at N opens a
325
+ // bracketed comma list of LITERALS closed by `]` (two or more members, each a
326
+ // string, number, `true`, `false` or `null`; nothing nested), the message gains
327
+ // `; a list is written as a tuple: (…)` and, when the request is known, the
328
+ // error carries `fix`: the same request with that `[…]` rewritten to `(…)`.
329
+ // Anything else at N (an identifier member, a nested bracket, one member, an
330
+ // unclosed list) is the parser's error as it stands.
331
+ const BRACKET_ERROR = /^unexpected '\[' — expected a value \(at offset (\d+)\)$/;
332
+ export const TUPLE_HINT = "; a list is written as a tuple: (…)";
333
+ /** The source with the bracket list at the error's offset rewritten to a tuple, or null when the rule does not apply. */
334
+ export function bracketListAsTuple(source, message) {
335
+ const m = BRACKET_ERROR.exec(message);
336
+ if (!m)
337
+ return null;
338
+ const cps = Array.from(source);
339
+ const open = Number(m[1]);
340
+ if (cps[open] !== "[")
341
+ return null;
342
+ let i = open + 1;
343
+ let members = 0;
344
+ for (;;) {
345
+ i = skipSpaces(cps, i);
346
+ const end = literalEnd(cps, i);
347
+ if (end === null)
348
+ return null;
349
+ members++;
350
+ i = skipSpaces(cps, end);
351
+ if (cps[i] === ",") {
352
+ i++;
353
+ continue;
354
+ }
355
+ if (cps[i] === "]")
356
+ break;
357
+ return null;
358
+ }
359
+ if (members < 2)
360
+ return null;
361
+ cps[open] = "(";
362
+ cps[i] = ")";
363
+ return cps.join("");
364
+ }
365
+ function skipSpaces(cps, i) {
366
+ while (i < cps.length && /\s/.test(cps[i]))
367
+ i++;
368
+ return i;
369
+ }
370
+ /** The index just past one OQX literal starting at `i` (a string in either quote with `\<c>` escapes, an unsigned number, `true`/`false`/`null`), or null. */
371
+ function literalEnd(cps, i) {
372
+ const c = cps[i];
373
+ if (c === undefined)
374
+ return null;
375
+ if (c === '"' || c === "'") {
376
+ let j = i + 1;
377
+ while (j < cps.length) {
378
+ if (cps[j] === "\\")
379
+ j += 2;
380
+ else if (cps[j] === c)
381
+ return j + 1;
382
+ else
383
+ j++;
384
+ }
385
+ return null;
386
+ }
387
+ if (/[0-9]/.test(c)) {
388
+ const m = /^[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?/.exec(cps.slice(i).join(""));
389
+ return m ? i + Array.from(m[0]).length : null;
390
+ }
391
+ for (const word of ["true", "false", "null"]) {
392
+ if (cps.slice(i, i + word.length).join("") === word && !/[A-Za-z0-9_$]/.test(cps[i + word.length] ?? ""))
393
+ return i + word.length;
394
+ }
395
+ return null;
396
+ }
397
+ /** The surface's `filter_invalid` for a parse error: the library's message, plus the tuple hint and `fix` when the bracket rule applies. */
398
+ function parseInvalid(source, e, request) {
399
+ const rewritten = bracketListAsTuple(source, e.message);
400
+ if (rewritten === null)
401
+ return new FilterInvalid(e.message, "OQX");
402
+ const fix = request !== undefined ? canonicalJson({ ...request, query: rewritten }) : undefined;
403
+ return new FilterInvalid(e.message + TUPLE_HINT, "OQX", fix);
404
+ }
319
405
  function oqxRunInner(store, repoId, source, opts) {
320
406
  const raw = parse(source);
321
407
  const reserved = reservedAlias(raw.select);
@@ -384,6 +470,21 @@ function runResolved(store, repoId, raw, opts) {
384
470
  // under a custom `order by`). `distinct` cannot occur (a parse error).
385
471
  if (parsed.groupBy)
386
472
  return runGrouped(engine, parsed, consumer, opts);
473
+ // A flattened query (oqx 0.25, SEMANTICS §25) yields the parent rows, one per
474
+ // element: a hit is still a store row, so the envelope is injected as usual
475
+ // and repeats per element (Dataview's `TABLE … FLATTEN`). What changes is
476
+ // uniqueness — `(path, id)` no longer names one hit — so the keyset cursor
477
+ // cannot land inside a document's elements: a flattened query pages
478
+ // positionally with a null cursor, as a grouped query and a custom `order by`
479
+ // do (a keyset extended with the flatten ordinal is a later round). A bound
480
+ // name of `id` or `path` would shadow the envelope's own reads (the name
481
+ // shadows a same-named row property on the flattened row): refused, like a
482
+ // projection that collides with the envelope.
483
+ const flattened = parsed.flatten.length > 0;
484
+ for (const f of parsed.flatten) {
485
+ if (ENVELOPE_KEYS.has(f.name))
486
+ throw new FilterInvalid(`flatten \`as ${f.name}\` collides with the hit envelope — a flattened row is still the row, whose \`${f.name}\` the envelope reads; bind another name`, "OQX");
487
+ }
387
488
  // collect / first / single: inject id + path so every hit carries them. A
388
489
  // top-level `select distinct` is applied HERE, not in the engine: the injected
389
490
  // id/path are unique per row and would defeat the engine's projection dedup, so
@@ -437,7 +538,7 @@ function runResolved(store, repoId, raw, opts) {
437
538
  const limit = constBound(topLimit, "limit");
438
539
  if (offset || limit != null)
439
540
  rows = rows.slice(offset, limit == null ? undefined : offset + limit);
440
- const custom = !!parsed.orderBy && parsed.orderBy.length > 0;
541
+ const custom = (!!parsed.orderBy && parsed.orderBy.length > 0) || flattened;
441
542
  const cap = opts.limit ?? 50;
442
543
  let page = rows;
443
544
  if (!custom && opts.cursor) {