@omgbase/core 0.24.0 → 0.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) 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 +1 -1
  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 +9 -3
  34. package/dist/src/oqx-js/context.js +21 -10
  35. package/dist/src/oqx-js/names.js +1 -1
  36. package/dist/src/oqx-js/run.d.ts +8 -0
  37. package/dist/src/oqx-js/run.js +91 -4
  38. package/dist/src/oqx-js/sql/translate.js +21 -1
  39. package/dist/src/search/cel/parser.d.ts +3 -1
  40. package/dist/src/search/cel/parser.js +4 -1
  41. package/dist/src/spec-versions.d.ts +4 -4
  42. package/dist/src/spec-versions.js +4 -4
  43. package/package.json +1 -1
@@ -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;
@@ -5,8 +5,8 @@
5
5
  // store's raw column objects, tagged with their target; `get` routes field /
6
6
  // intrinsic / relation resolution per target, lazily querying the store for
7
7
  // relations. Domain functions (text/semantic/under_*/…) are row-scoped, so they
8
- // arrive via `callMethod` on a synthetic `$self` receiver (see run.ts's AST
9
- // rewrite) rather than as free functions (which see no row).
8
+ // arrive via `callMethod` on the row as receiver — run.ts's AST rewrite turns
9
+ // them into `$it.fn(…)` — rather than as free functions (which see no row).
10
10
  //
11
11
  // This is the correctness tier: N+1 queries, materialized collections — fine for
12
12
  // the corpus and the differential baseline. The tier-3 SQLite planner (planner.ts)
@@ -451,6 +451,18 @@ export function makeStoreContext(store, repoId, opts = {}) {
451
451
  return tag(load(), "blocks");
452
452
  return lazyOwner({ block_id: row.block_id, doc_id: row.doc_id, __path: row.__path }, "blocks", load);
453
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
+ };
454
466
  // ---- engine attributes -----------------------------------------------------
455
467
  // Bare, snake_case, winning on collision (see the name rule above; spec/surface
456
468
  // §1.2, 3.0). `path` (and the edge field `dst_path`) is the REFERENCE form
@@ -600,13 +612,10 @@ export function makeStoreContext(store, repoId, opts = {}) {
600
612
  return row[key];
601
613
  }
602
614
  const r = row;
603
- // `$self` = the current row: run.ts rewrites row-scoped domain functions
604
- // (text/semantic/under_*/…) to `$self.fn(…)` so they arrive via callMethod
605
- // with the row as receiver (a free function sees no row). Any other `$`
606
- // name is OQX's (resolved by the engine before the row) or unknown — never
607
- // an omgbase attribute.
608
- if (key === "$self")
609
- return r;
615
+ // A `$` name is OQX's (`$it`, `$key`, … — resolved by the engine before
616
+ // the row) or unknown — never an omgbase attribute. Row-scoped domain
617
+ // functions (text/semantic/under_*/…) never arrive here: run.ts rewrites
618
+ // them to `$it.fn(…)`, so they reach callMethod with the row as receiver.
610
619
  // engine attributes win on collision (the name rule above).
611
620
  if (ATTRIBUTES[t].has(key))
612
621
  return attribute(r, t, key);
@@ -628,6 +637,8 @@ export function makeStoreContext(store, repoId, opts = {}) {
628
637
  return owningBlock(r) ?? ABSENT_SINGLE;
629
638
  if (t === "links" && key === "dst")
630
639
  return destinationDoc(r) ?? ABSENT_SINGLE;
640
+ if (t === "links" && key === "block")
641
+ return linkBlock(r) ?? ABSENT_SINGLE;
631
642
  // relations.
632
643
  const rels = rel[t];
633
644
  if (rels[key])
@@ -656,7 +667,7 @@ export function makeStoreContext(store, repoId, opts = {}) {
656
667
  return jattr(r, key);
657
668
  return undefined;
658
669
  };
659
- // ---- domain functions (row-scoped, via callMethod on $self) ---------------
670
+ // ---- domain functions (row-scoped, via callMethod on `$it`) ---------------
660
671
  const method = (name, recv, args) => {
661
672
  const r = recv;
662
673
  const t = r?.[TARGET];
@@ -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. */
@@ -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,9 +14,11 @@ 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
- // row, so we rewrite them to `$self.fn(…)` (a method whose receiver is the row)
20
+ // row, so we rewrite them to `$it.fn(…)` (a method whose receiver is the row —
21
+ // `$it` is the language's own name for the scope's row, spec/oqx SEMANTICS §2)
20
22
  // — one `transform` over the AST (spec/oqx/AST.md §5), every block included.
21
23
  // `refs(x)` is NOT here: it reads no row (its argument is the value), so it stays
22
24
  // a free function served by the context's `callFunction`. The planner treats
@@ -117,7 +119,7 @@ export function foldDates(e) {
117
119
  return build.lit(dateMethod(e.name, e.recv.value, args).value);
118
120
  }
119
121
  function rewriteQuery(q) {
120
- return transform(q, (e) => (e.kind === "call" && e.recv === null && ROW_FNS.has(e.name) ? { ...e, recv: build.ident("$self") } : foldDates(rootPathLiterals(e))));
122
+ return transform(q, (e) => (e.kind === "call" && e.recv === null && ROW_FNS.has(e.name) ? { ...e, recv: build.ident("$it") } : foldDates(rootPathLiterals(e))));
121
123
  }
122
124
  // A top-level `limit`/`offset` on the collect path is applied by the runner (see
123
125
  // oqxRunInner), so it must be a plain number literal here — there is no row to
@@ -231,7 +233,7 @@ const VALUE_KEY = "__oqx_value";
231
233
  // Rows as values (spec/surface §1.4, 1.2). The engine hands back store rows
232
234
  // wherever the projection made a row a VALUE — a nested `collect { }` /
233
235
  // `first { }` / `single { }` with an empty projection ("the row itself",
234
- // spec/oqx §12), a `values` projection of `$it`/`$self`, a field bound to a
236
+ // spec/oqx §12), a `values` projection of `$it`, a field bound to a
235
237
  // row — and a store row is not a wire shape (column names, `attrs` as a JSON
236
238
  // string, the `__path` join column, Buffers). Walk the projected value and
237
239
  // render every tagged row as `{ id, path }` — the hit envelope's own keys
@@ -312,10 +314,95 @@ export function oqxRun(store, repoId, source, opts = {}) {
312
314
  catch (e) {
313
315
  // Normalize the library's errors to omgbase's contract (filter_invalid).
314
316
  if (e instanceof OqxError)
315
- throw new FilterInvalid(e.message, "OQX");
317
+ throw parseInvalid(source, e, opts.request);
316
318
  throw e;
317
319
  }
318
320
  }
321
+ // ---- the bracket-list fix (spec/surface §4 "Error data", 4.11) ----------------------
322
+ //
323
+ // OQX has no list literal; a tuple `("a", "b")` is the collection (`spec/oqx`
324
+ // SEMANTICS §3b), so `id in ["a", "b"]` — the SQL habit — is the parse error
325
+ // `unexpected '[' — expected a value (at offset N)`. When the source at N opens a
326
+ // bracketed comma list of LITERALS closed by `]` (two or more members, each a
327
+ // string, number, `true`, `false` or `null`; nothing nested), the message gains
328
+ // `; a list is written as a tuple: (…)` and, when the request is known, the
329
+ // error carries `fix`: the same request with that `[…]` rewritten to `(…)`.
330
+ // Anything else at N (an identifier member, a nested bracket, one member, an
331
+ // unclosed list) is the parser's error as it stands.
332
+ const BRACKET_ERROR = /^unexpected '\[' — expected a value \(at offset (\d+)\)$/;
333
+ export const TUPLE_HINT = "; a list is written as a tuple: (…)";
334
+ /** The source with the bracket list at the error's offset rewritten to a tuple, or null when the rule does not apply. */
335
+ export function bracketListAsTuple(source, message) {
336
+ const m = BRACKET_ERROR.exec(message);
337
+ if (!m)
338
+ return null;
339
+ const cps = Array.from(source);
340
+ const open = Number(m[1]);
341
+ if (cps[open] !== "[")
342
+ return null;
343
+ let i = open + 1;
344
+ let members = 0;
345
+ for (;;) {
346
+ i = skipSpaces(cps, i);
347
+ const end = literalEnd(cps, i);
348
+ if (end === null)
349
+ return null;
350
+ members++;
351
+ i = skipSpaces(cps, end);
352
+ if (cps[i] === ",") {
353
+ i++;
354
+ continue;
355
+ }
356
+ if (cps[i] === "]")
357
+ break;
358
+ return null;
359
+ }
360
+ if (members < 2)
361
+ return null;
362
+ cps[open] = "(";
363
+ cps[i] = ")";
364
+ return cps.join("");
365
+ }
366
+ function skipSpaces(cps, i) {
367
+ while (i < cps.length && /\s/.test(cps[i]))
368
+ i++;
369
+ return i;
370
+ }
371
+ /** 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. */
372
+ function literalEnd(cps, i) {
373
+ const c = cps[i];
374
+ if (c === undefined)
375
+ return null;
376
+ if (c === '"' || c === "'") {
377
+ let j = i + 1;
378
+ while (j < cps.length) {
379
+ if (cps[j] === "\\")
380
+ j += 2;
381
+ else if (cps[j] === c)
382
+ return j + 1;
383
+ else
384
+ j++;
385
+ }
386
+ return null;
387
+ }
388
+ if (/[0-9]/.test(c)) {
389
+ const m = /^[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?/.exec(cps.slice(i).join(""));
390
+ return m ? i + Array.from(m[0]).length : null;
391
+ }
392
+ for (const word of ["true", "false", "null"]) {
393
+ if (cps.slice(i, i + word.length).join("") === word && !/[A-Za-z0-9_$]/.test(cps[i + word.length] ?? ""))
394
+ return i + word.length;
395
+ }
396
+ return null;
397
+ }
398
+ /** The surface's `filter_invalid` for a parse error: the library's message, plus the tuple hint and `fix` when the bracket rule applies. */
399
+ function parseInvalid(source, e, request) {
400
+ const rewritten = bracketListAsTuple(source, e.message);
401
+ if (rewritten === null)
402
+ return new FilterInvalid(e.message, "OQX");
403
+ const fix = request !== undefined ? canonicalJson({ ...request, query: rewritten }) : undefined;
404
+ return new FilterInvalid(e.message + TUPLE_HINT, "OQX", fix);
405
+ }
319
406
  function oqxRunInner(store, repoId, source, opts) {
320
407
  const raw = parse(source);
321
408
  const reserved = reservedAlias(raw.select);
@@ -56,7 +56,7 @@ export const NON_PROPERTY_NAMES = {
56
56
  docs: new Set(["doc", "nodes", "blocks", "out_links", "in_links", "revisions", "data", "frontmatter", "inline"]),
57
57
  blocks: new Set(["block", "doc", "children", "nodes", "out_links", "section", "attrs"]),
58
58
  nodes: new Set(["section", "doc", "block", "blocks", "subsections", "children", "attrs"]),
59
- links: new Set(["doc", "dst"]),
59
+ links: new Set(["doc", "dst", "block"]),
60
60
  };
61
61
  // ---- engine attributes ------------------------------------------------------
62
62
  // The engine attributes (spec/surface §1.2, 3.0: bare, snake_case, winning on
@@ -365,6 +365,26 @@ export function translateOperand(e, ctx) {
365
365
  return text(`(SELECT bb.${k} FROM blocks bb WHERE bb.block_id = ${self}.block_id)`);
366
366
  return null;
367
367
  }
368
+ // block.<x> reach-through from a link (spec/surface §1.2, 4.11): the SOURCE
369
+ // block, keyed by the row's own `src_block` — `block.id` is that FK column
370
+ // (NULL for a frontmatter link, exactly where `block` is absent in memory);
371
+ // everything else is a primary-key probe of the block row, NULL for the
372
+ // same rows (`block.path` reads the scan's joined source document inside
373
+ // the probe, so it too is NULL when there is no block). `raw`, `hash`,
374
+ // `updated_at`, `attrs` and the relations stay residual.
375
+ if (head === "block" && target === "links" && rest.length === 1) {
376
+ const k = rest[0];
377
+ const probe = (col) => text(`(SELECT ${col} FROM blocks bb WHERE bb.block_id = ${self}.src_block)`);
378
+ if (k === "id")
379
+ return text(`${self}.src_block`);
380
+ if (k === "path")
381
+ return probe(`'/' || ${doc}.path`);
382
+ if (k === "doc_id")
383
+ return probe("bb.doc_id");
384
+ if (k === "type" || k === "text")
385
+ return probe(`bb.${k}`);
386
+ return null;
387
+ }
368
388
  return null;
369
389
  }
370
390
  case "call": {
@@ -2,6 +2,8 @@ import type { Node } from "./ast.js";
2
2
  export declare class FilterInvalid extends Error {
3
3
  reason: string;
4
4
  hint: string;
5
- constructor(reason: string, hint?: string);
5
+ /** (spec/surface 4.11) a literal corrected call, canonical JSON, when the surface can spell one (`data.fix`). */
6
+ fix: string | undefined;
7
+ constructor(reason: string, hint?: string, fix?: string);
6
8
  }
7
9
  export declare function parseFilter(src: string): Node;
@@ -4,10 +4,13 @@ import { lex, LexError } from "./lexer.js";
4
4
  export class FilterInvalid extends Error {
5
5
  reason;
6
6
  hint;
7
- constructor(reason, hint = "see query-language.md §3") {
7
+ /** (spec/surface 4.11) a literal corrected call, canonical JSON, when the surface can spell one (`data.fix`). */
8
+ fix;
9
+ constructor(reason, hint = "see query-language.md §3", fix) {
8
10
  super(reason);
9
11
  this.reason = reason;
10
12
  this.hint = hint;
13
+ this.fix = fix;
11
14
  }
12
15
  }
13
16
  const RELOPS = new Set(["==", "!=", "<", "<=", ">", ">="]);
@@ -2,13 +2,13 @@ export declare const SPEC_VERSIONS: {
2
2
  readonly oqx: "0.25";
3
3
  readonly format: "0.2";
4
4
  readonly reconcile: "2.3";
5
- readonly store: "15.0";
6
- readonly properties: "1.1";
5
+ readonly store: "16.0";
6
+ readonly properties: "1.2";
7
7
  readonly graph: "1.1";
8
8
  readonly search: "2.0";
9
- readonly mutate: "3.5";
9
+ readonly mutate: "3.6";
10
10
  readonly sync: "2.2";
11
- readonly surface: "4.10";
11
+ readonly surface: "4.11";
12
12
  readonly cli: "4.6";
13
13
  };
14
14
  export type SpecName = keyof typeof SPEC_VERSIONS;
@@ -13,13 +13,13 @@ export const SPEC_VERSIONS = {
13
13
  oqx: LANGUAGE_VERSION,
14
14
  format: "0.2",
15
15
  reconcile: "2.3",
16
- store: "15.0",
17
- properties: "1.1",
16
+ store: "16.0",
17
+ properties: "1.2",
18
18
  graph: "1.1",
19
19
  search: "2.0",
20
- mutate: "3.5",
20
+ mutate: "3.6",
21
21
  sync: "2.2",
22
- surface: "4.10",
22
+ surface: "4.11",
23
23
  cli: "4.6",
24
24
  };
25
25
  //# sourceMappingURL=spec-versions.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omgbase/core",
3
- "version": "0.24.0",
3
+ "version": "0.27.1",
4
4
  "description": "Open Markdown Graph Base — embedded engine",
5
5
  "license": "MIT",
6
6
  "repository": {