@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.
- package/dist/src/core/edit-script.d.ts +15 -0
- package/dist/src/core/edit-script.js +72 -0
- package/dist/src/core/ingest.js +42 -12
- package/dist/src/core/read/document.d.ts +7 -0
- package/dist/src/core/read/document.js +38 -17
- package/dist/src/core/store/archive.d.ts +28 -0
- package/dist/src/core/store/archive.js +106 -17
- package/dist/src/core/store/delta.d.ts +131 -0
- package/dist/src/core/store/delta.js +470 -0
- package/dist/src/core/store/gc.d.ts +10 -0
- package/dist/src/core/store/gc.js +23 -7
- package/dist/src/core/store/retention.d.ts +1 -1
- package/dist/src/core/store/retention.js +3 -17
- package/dist/src/core/store/schema.d.ts +1 -1
- package/dist/src/core/store/schema.js +2 -1
- package/dist/src/core/store/store.js +7 -0
- package/dist/src/core/store/writers.d.ts +42 -6
- package/dist/src/core/store/writers.js +67 -25
- package/dist/src/format/json.js +5 -0
- package/dist/src/format/yaml.js +15 -3
- package/dist/src/graph/history.d.ts +1 -15
- package/dist/src/graph/history.js +14 -74
- package/dist/src/mcp/arguments.d.ts +11 -0
- package/dist/src/mcp/arguments.js +47 -4
- package/dist/src/mcp/errors.js +1 -1
- package/dist/src/mcp/reference.d.ts +1 -1
- package/dist/src/mcp/reference.js +29 -9
- package/dist/src/mcp/server.js +4 -2
- package/dist/src/mutate/effects.js +87 -13
- package/dist/src/mutate/frontmatter-parts.d.ts +71 -0
- package/dist/src/mutate/frontmatter-parts.js +237 -0
- package/dist/src/mutate/load.js +9 -9
- package/dist/src/mutate/select.js +14 -4
- package/dist/src/oqx-js/context.js +32 -3
- package/dist/src/oqx-js/names.js +1 -1
- package/dist/src/oqx-js/planner.js +11 -7
- package/dist/src/oqx-js/run.d.ts +8 -0
- package/dist/src/oqx-js/run.js +103 -2
- package/dist/src/oqx-js/sql/translate.js +33 -6
- package/dist/src/oqx-js/store-index.js +1 -1
- package/dist/src/search/cel/parser.d.ts +3 -1
- package/dist/src/search/cel/parser.js +4 -1
- package/dist/src/spec-versions.d.ts +5 -5
- package/dist/src/spec-versions.js +4 -4
- 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
|
package/dist/src/mutate/load.js
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
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])
|
package/dist/src/oqx-js/names.js
CHANGED
|
@@ -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)
|
|
162
|
-
// conjuncts could be pushed (
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
|
|
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)
|
package/dist/src/oqx-js/run.d.ts
CHANGED
|
@@ -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
|
package/dist/src/oqx-js/run.js
CHANGED
|
@@ -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
|
|
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) {
|