@omgbase/core 0.15.0 → 0.19.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.
@@ -0,0 +1,879 @@
1
+ import { OqxError, parse } from "@omgbase/oqx";
2
+ import { checkRootSpellings, oqxRunQuery, reservedAlias } from "../oqx-js/run.js";
3
+ import { rootTarget } from "../oqx-js/planner.js";
4
+ import { FilterInvalid } from "../search/cel/parser.js";
5
+ import { EFFECT_OPS, EXPRESSION_LEADER, bareTarget, effectInvalid, findDocumentTarget, isNewSymbol, isTargetId, looksLikePath, validateEffect, validateTarget, } from "./effects.js";
6
+ import { MutationError } from "./tree.js";
7
+ import { fixRequest, narrowedTo, withMember } from "./canonical.js";
8
+ // Query-directed planning (spec/mutate §11.8, 3.1): an OQX **selector** plus
9
+ // effect **templates** compile into the concrete effects the planner lowers.
10
+ // The selector is a producer of effects, never a second write engine — the
11
+ // plan that comes out has today's shape plus a frozen `selection` block.
12
+ //
13
+ // Pipeline: `parse(select)` → `checkRootSpellings` → the static admission pass
14
+ // (§5 of the design: consumer, root, no follow / body from / offset / distinct /
15
+ // values; templates row-shaped, `{ oqx }` only in the slots below) → the AST
16
+ // rewrite (one `field` item per `{ oqx }` slot appended AFTER the user's items
17
+ // on the RAW tree, so `resolveAliases` in the runner inlines a user alias a
18
+ // template reads) → `oqxRunQuery` with `limit = bound + 1` → hits → the row
19
+ // binding (hits in result order × templates in order, `k = h·T + t`). The
20
+ // expanded list feeds the existing lowering loop unchanged, so the conflict
21
+ // tracker gives cross-hit semantics for free. Nothing is spliced as text.
22
+ //
23
+ // Since 3.2 (§11.8 "Sugar") the `-> expr` leader is the written form of a
24
+ // `{ oqx }` directive (`desugarEffect` runs before admission, for templates
25
+ // and plain effects alike), a bare string is a target, and a plain request —
26
+ // no selector — may carry expressions too: each evaluates in its own primary
27
+ // target's row through one synthetic query per target (`compilePlain`).
28
+ /** The implicit bound on a selection without `expect.max` (hits beyond it are `selection_too_large`). */
29
+ export const SELECTION_CEILING = 1000;
30
+ // ---- errors -------------------------------------------------------------------------
31
+ /** The admission pass's refusal: `filter_invalid { reason, hint: "selection" }`. */
32
+ export function selectionInvalid(message, data = {}) {
33
+ return new MutationError("filter_invalid", message, { reason: message, hint: "selection", ...data });
34
+ }
35
+ function needsSelector(where) {
36
+ return selectionInvalid(`${where}: an expression target needs a selector — without \`select\` there is no row to read it from`);
37
+ }
38
+ function isRecord(v) {
39
+ return typeof v === "object" && v !== null && !Array.isArray(v);
40
+ }
41
+ /** `{ oqx: "<expression>" }` — exactly that one key. */
42
+ function isOqx(v) {
43
+ return isRecord(v) && Object.keys(v).length === 1 && typeof v.oqx === "string";
44
+ }
45
+ /** A row rendered as a value by the runner (`{ id, path }` and nothing else, spec/surface §1.4). */
46
+ function isRowRef(v) {
47
+ return isRecord(v) && Object.keys(v).length === 2 && typeof v.id === "string" && typeof v.path === "string";
48
+ }
49
+ function describeValue(v) {
50
+ if (v === null || v === undefined)
51
+ return "absent";
52
+ if (typeof v === "string")
53
+ return `a string (${JSON.stringify(v)})`;
54
+ if (typeof v === "number")
55
+ return `a number (${String(v)})`;
56
+ if (typeof v === "boolean")
57
+ return `a boolean (${String(v)})`;
58
+ if (Array.isArray(v))
59
+ return `a list of ${v.length}`;
60
+ return "an object";
61
+ }
62
+ // ---- the expression wrapper -----------------------------------------------------------
63
+ // OQX has no expression entry point: each `{ oqx: e }` is parsed through this
64
+ // wrapper and the one select item taken. A top-level item is scope depth 1 —
65
+ // exactly the hit's scope — so `^` and `N^` validate as they will evaluate.
66
+ const WRAPPER_PREFIX = "select __omg_x: ";
67
+ const WRAPPER_SUFFIX = " from docs";
68
+ const OFFSET_RE = /\(at offset (\d+)\)/;
69
+ /** Parse one expression; a parse error's offset is shifted back by the wrapper and forward by `shift` (the `-> ` leader's 3). */
70
+ function parseExpression(src, here, shift = 0) {
71
+ let q;
72
+ try {
73
+ q = parse(WRAPPER_PREFIX + src + WRAPPER_SUFFIX);
74
+ }
75
+ catch (e) {
76
+ if (!(e instanceof OqxError))
77
+ throw e;
78
+ // Parse-error offsets are shifted by the wrapper prefix (ASCII, so code points = chars).
79
+ const message = e.message.replace(OFFSET_RE, (_m, n) => `(at offset ${Math.max(0, Number(n) - WRAPPER_PREFIX.length) + shift})`);
80
+ throw selectionInvalid(`${here}: ${message}`);
81
+ }
82
+ // The whole source must have been one expression: a query smuggled into the
83
+ // slot (`x from blocks where …`) parses, but not as one item over `docs`.
84
+ const one = q.select.length === 1 && q.source.kind === "ident" && q.source.name === "docs" && q.from.length === 0 && q.where === null
85
+ && q.orderBy === null && q.follow === null && q.frontier === null && q.limit === null && q.offset === null && !q.distinct && !q.values && q.consumer === "collect";
86
+ if (!one)
87
+ throw selectionInvalid(`${here}: \`oqx\` must be one expression over the hit, not a query`);
88
+ return q.select[0];
89
+ }
90
+ // ---- the `-> ` sugar (3.2, §11.8 "Sugar") -----------------------------------------------
91
+ /** Exactly `-> ` (dash, greater-than, space) at the start: `->x`, ` -> x` and `-->` are literal. */
92
+ function isLeader(v) {
93
+ return typeof v === "string" && v.startsWith(EXPRESSION_LEADER);
94
+ }
95
+ /**
96
+ * `"-> <expr>"` → `{ oqx: "<expr>" }` — or, when the expression is a bare
97
+ * string literal or a template literal without interpolation, the literal
98
+ * itself (constant folding: `-> "x"` and `"x"` plan identically, and
99
+ * `-> "-> note"` is how a literal that begins with the leader is spelled).
100
+ * A parse error is `filter_invalid { hint: "selection" }` with its offset
101
+ * counted from the start of the written string (past the leader).
102
+ */
103
+ export function desugarLeader(v, here) {
104
+ const src = v.slice(EXPRESSION_LEADER.length);
105
+ const item = parseExpression(src, here, EXPRESSION_LEADER.length);
106
+ if (item.kind === "field") {
107
+ const x = item.expr;
108
+ if (x.kind === "lit" && typeof x.value === "string")
109
+ return x.value;
110
+ if (x.kind === "template" && x.parts.length === 1)
111
+ return x.parts[0];
112
+ }
113
+ return { oqx: src };
114
+ }
115
+ /** The text a stray leader meant literally: a quoted string literal or an interpolation-free template literal folds to its text (as constant folding does), anything else is the rest of the string verbatim. */
116
+ function literalOf(v) {
117
+ const src = v.slice(EXPRESSION_LEADER.length);
118
+ try {
119
+ const item = parseExpression(src, "");
120
+ if (item.kind === "field") {
121
+ if (item.expr.kind === "lit" && typeof item.expr.value === "string")
122
+ return item.expr.value;
123
+ if (item.expr.kind === "template" && item.expr.parts.length === 1 && typeof item.expr.parts[0] === "string")
124
+ return item.expr.parts[0];
125
+ }
126
+ }
127
+ catch {
128
+ // not an expression at all: the rest of the string is the literal
129
+ }
130
+ return src;
131
+ }
132
+ /** A `-> ` string where no expression is admitted — anywhere under `v`. The fix (3.3): the literal form, the same request with that member spelled literally (`literalOf`). */
133
+ function refuseLeader(v, here, path, fix) {
134
+ if (isLeader(v)) {
135
+ throw selectionInvalid(`${here}: \`-> \` is not allowed here — this member takes no expression`, fix !== undefined ? { fix: fix(path, literalOf(v)) } : {});
136
+ }
137
+ if (Array.isArray(v))
138
+ v.forEach((x, k) => refuseLeader(x, `${here}[${k}]`, [...path, String(k)], fix));
139
+ else if (isRecord(v))
140
+ for (const [k, x] of Object.entries(v))
141
+ refuseLeader(x, `${here}.${k}`, [...path, k], fix);
142
+ }
143
+ /**
144
+ * Desugar one effect or template as written (§11.8 "Sugar"), before the shape
145
+ * check and the admission: in every slot that admits an expression — the
146
+ * strings `markdown`, `to`, `path`, `separator`, `at.before` / `at.after`,
147
+ * the JSON `value`, each top-level `create.frontmatter` value, a target's `id`
148
+ * / `path`, and a whole target (`into`, `target`, `targets[k]`) — a string
149
+ * beginning `-> ` becomes `{ oqx }` (or folds to its literal); a `-> ` string
150
+ * anywhere else (`expect`, `field`, `op`, a section's `heading_id`, …) is
151
+ * refused. Every other string is literal data, with no escaping and no `${`
152
+ * scanning; a string nested inside a `value` or a frontmatter value is data
153
+ * (the slot is the top-level value, as for `{ oqx }`). Shape problems are the
154
+ * shape check's.
155
+ */
156
+ export function desugarEffect(raw, index, request) {
157
+ if (!isRecord(raw) || typeof raw.op !== "string")
158
+ return raw;
159
+ const here = `effects[${index}]`;
160
+ const out = clone(raw);
161
+ const slotKeys = new Set(["op"]);
162
+ // The corrected example of a leader in a literal slot: the request as given with that member spelled literally.
163
+ const literalFix = request === undefined ? undefined : (path, literal) => fixRequest({ ...request, effects: withMember(request.effects, [String(index), ...path], literal) });
164
+ const refuse = (v, at, path) => refuseLeader(v, at, path, literalFix);
165
+ const leaderAt = (key) => {
166
+ slotKeys.add(key);
167
+ if (isLeader(out[key]))
168
+ out[key] = desugarLeader(out[key], `${here}.${key}`);
169
+ };
170
+ const target = (key) => {
171
+ slotKeys.add(key);
172
+ const t = out[key];
173
+ // A bare `-> expr` target: the whole target is the directive (a folded literal stays a bare string for the shape check).
174
+ if (isLeader(t))
175
+ out[key] = desugarLeader(t, `${here}.${key}`);
176
+ else if (isRecord(t)) {
177
+ for (const member of ["id", "path"])
178
+ if (isLeader(t[member]))
179
+ t[member] = desugarLeader(t[member], `${here}.${key}.${member}`);
180
+ for (const k of Object.keys(t))
181
+ if (k !== "id" && k !== "path")
182
+ refuse(t[k], `${here}.${key}.${k}`, [key, k]);
183
+ }
184
+ };
185
+ const targets = () => {
186
+ slotKeys.add("targets");
187
+ const list = out.targets;
188
+ if (!Array.isArray(list))
189
+ return;
190
+ list.forEach((t, k) => {
191
+ if (isLeader(t))
192
+ list[k] = desugarLeader(t, `${here}.targets[${k}]`);
193
+ else if (isRecord(t)) {
194
+ if (isLeader(t.id))
195
+ t.id = desugarLeader(t.id, `${here}.targets[${k}].id`);
196
+ for (const m of Object.keys(t))
197
+ if (m !== "id")
198
+ refuse(t[m], `${here}.targets[${k}].${m}`, ["targets", String(k), m]);
199
+ }
200
+ });
201
+ };
202
+ const at = () => {
203
+ slotKeys.add("at");
204
+ const a = out.at;
205
+ if (!isRecord(a)) {
206
+ refuse(a, `${here}.at`, ["at"]);
207
+ return;
208
+ }
209
+ for (const member of ["before", "after"])
210
+ if (isLeader(a[member]))
211
+ a[member] = desugarLeader(a[member], `${here}.at.${member}`);
212
+ for (const k of Object.keys(a))
213
+ if (k !== "before" && k !== "after")
214
+ refuse(a[k], `${here}.at.${k}`, ["at", k]);
215
+ };
216
+ switch (out.op) {
217
+ case "create": {
218
+ leaderAt("path");
219
+ leaderAt("markdown");
220
+ slotKeys.add("frontmatter");
221
+ const fm = out.frontmatter;
222
+ if (isRecord(fm))
223
+ for (const k of Object.keys(fm))
224
+ if (isLeader(fm[k]))
225
+ fm[k] = desugarLeader(fm[k], `${here}.frontmatter.${k}`);
226
+ break;
227
+ }
228
+ case "insert":
229
+ target("into");
230
+ at();
231
+ leaderAt("markdown");
232
+ break;
233
+ case "set":
234
+ target("target");
235
+ leaderAt("value");
236
+ break;
237
+ case "unset":
238
+ target("target");
239
+ break;
240
+ case "move":
241
+ if ("targets" in out || "into" in out) {
242
+ targets();
243
+ target("into");
244
+ at();
245
+ }
246
+ else {
247
+ target("target");
248
+ leaderAt("to");
249
+ }
250
+ break;
251
+ case "remove":
252
+ if ("targets" in out)
253
+ targets();
254
+ else
255
+ target("target");
256
+ break;
257
+ case "split":
258
+ target("target"); // `at` is byte offsets: no expression
259
+ break;
260
+ case "merge":
261
+ targets();
262
+ leaderAt("separator");
263
+ break;
264
+ case "retarget":
265
+ break; // not row-shaped: no slot
266
+ default:
267
+ return out; // an unknown op: the shape check names it
268
+ }
269
+ for (const key of Object.keys(out))
270
+ if (!slotKeys.has(key))
271
+ refuse(out[key], `${here}.${key}`, [key]);
272
+ return out;
273
+ }
274
+ const HIT_KIND = { docs: "document", blocks: "block", nodes: "node" };
275
+ /** Deep-copy JSON (templates are plain data). */
276
+ function clone(v) {
277
+ return v === undefined ? v : JSON.parse(JSON.stringify(v));
278
+ }
279
+ function containsOqx(v) {
280
+ if (isOqx(v))
281
+ return true;
282
+ if (Array.isArray(v))
283
+ return v.some(containsOqx);
284
+ if (isRecord(v))
285
+ return Object.values(v).some(containsOqx);
286
+ return false;
287
+ }
288
+ /**
289
+ * Admit one template statically (before the query runs): a row-shaped op, the
290
+ * `{ oqx }` directives only in the slots the design names, every expression
291
+ * parseable, and the template's SHAPE valid with a probe value in each slot
292
+ * (`validateEffect`, the `effects[i]` messages of §11.1, `hint: "arguments"`).
293
+ * `plain` (3.2): a plain effect — no selector — keeps its own target: nothing
294
+ * defaults to a hit, and its primary target may not be an expression (the
295
+ * primary target IS the row the expressions read).
296
+ */
297
+ function admitTemplate(raw, index, root, plain) {
298
+ const here = `effects[${index}]`;
299
+ if (!isRecord(raw) || typeof raw.op !== "string")
300
+ throw selectionInvalid(`${here}: a template is an effect object whose \`op\` is one of ${EFFECT_OPS.filter((o) => o !== "retarget").join("/")}`);
301
+ if (raw.op === "retarget")
302
+ throw selectionInvalid(`${here}: \`retarget\` is not row-shaped — it cannot be a selector template`);
303
+ if (!EFFECT_OPS.includes(raw.op))
304
+ throw selectionInvalid(`${here}: \`op\` must be one of ${EFFECT_OPS.filter((o) => o !== "retarget").join("/")} (got "${raw.op}")`);
305
+ const op = raw.op;
306
+ const base = clone(raw);
307
+ const slots = [];
308
+ const probe = clone(raw);
309
+ const slotKeys = new Set();
310
+ const expr = (src, at) => parseExpression(src, `${here}.${at}`);
311
+ // A string-valued member that may be an expression (`markdown`, `to`, `path`, `separator`).
312
+ const stringSlot = (key) => {
313
+ slotKeys.add(key);
314
+ if (!isOqx(raw[key]))
315
+ return;
316
+ const src = raw[key].oqx;
317
+ delete base[key];
318
+ probe[key] = "$sel";
319
+ slots.push({ name: key, kind: "string", here: `${here}.${key}`, path: [key], expression: src, item: expr(src, key), put: (e, v) => { e[key] = v; } });
320
+ };
321
+ // A JSON-valued member (`value`): the top-level `{ oqx }` is the expression; nested ones are data.
322
+ const jsonSlot = (key) => {
323
+ slotKeys.add(key);
324
+ if (!isOqx(raw[key]))
325
+ return;
326
+ const src = raw[key].oqx;
327
+ delete base[key];
328
+ probe[key] = null;
329
+ slots.push({ name: key, kind: "json", here: `${here}.${key}`, path: [key], expression: src, item: expr(src, key), put: (e, v) => { e[key] = v; } });
330
+ };
331
+ // A target member: `{ kind, id: { oqx } }` or `{ kind: "document", path: { oqx } }`, or (3.2) the whole target
332
+ // `{ oqx }` — the identity the value names; absent → the hit when `defaultable` (never on a plain effect).
333
+ // `probeKind` is a kind the slot admits, standing in for the whole-target expression's unknown kind.
334
+ const targetSlot = (key, defaultable, probeKind, primary) => {
335
+ slotKeys.add(key);
336
+ const t = raw[key];
337
+ if (t === undefined) {
338
+ if (!defaultable || plain)
339
+ return null;
340
+ probe[key] = { kind: HIT_KIND[root], id: "$sel" };
341
+ return { key, list: false };
342
+ }
343
+ if (isOqx(t)) {
344
+ if (plain && primary)
345
+ throw needsSelector(`${here}.${key}`);
346
+ const src = t.oqx;
347
+ delete base[key];
348
+ probe[key] = { kind: probeKind, id: "$sel" };
349
+ slots.push({ name: key, kind: "target", here: `${here}.${key}`, path: [key], expression: src, item: expr(src, key), put: (e, v) => { e[key] = v; } });
350
+ return null;
351
+ }
352
+ if (isRecord(t)) {
353
+ const idX = isOqx(t.id);
354
+ const pathX = isOqx(t.path);
355
+ if (idX && pathX)
356
+ throw selectionInvalid(`${here}.${key}: give \`id\` or \`path\` as an expression, not both`);
357
+ if (idX || pathX) {
358
+ if (plain && primary)
359
+ throw needsSelector(`${here}.${key}`);
360
+ const member = idX ? "id" : "path";
361
+ const src = t[member].oqx;
362
+ const literal = { ...t };
363
+ delete literal[member];
364
+ base[key] = literal;
365
+ probe[key][member] = "$sel";
366
+ slots.push({
367
+ name: key, kind: "identity", here: `${here}.${key}.${member}`, path: [key, member], expression: src, item: expr(src, `${key}.${member}`),
368
+ put: (e, v) => { e[key] = { ...e[key], ...identityMember(member, v) }; },
369
+ });
370
+ }
371
+ for (const k of Object.keys(t))
372
+ if (k !== "id" && k !== "path" && containsOqx(t[k]))
373
+ throw selectionInvalid(`${here}.${key}.${k}: \`{ oqx }\` is not allowed here`);
374
+ }
375
+ return null;
376
+ };
377
+ // A `targets` list: each element `{ kind: "block", id: { oqx } }` (or, 3.2, a whole `{ oqx }`) yields one block;
378
+ // absent → `[the hit]` when `defaultable`. The list is always the primary target of its op.
379
+ const targetsSlot = (defaultable) => {
380
+ slotKeys.add("targets");
381
+ const list = raw.targets;
382
+ if (list === undefined) {
383
+ if (!defaultable || plain)
384
+ return null;
385
+ probe.targets = [{ kind: HIT_KIND[root], id: "$sel" }];
386
+ return { key: "targets", list: true };
387
+ }
388
+ if (Array.isArray(list)) {
389
+ list.forEach((t, k) => {
390
+ if (isOqx(t)) {
391
+ if (plain)
392
+ throw needsSelector(`${here}.targets[${k}]`);
393
+ const src = t.oqx;
394
+ base.targets[k] = {};
395
+ probe.targets[k] = { kind: "block", id: "$sel" };
396
+ slots.push({
397
+ name: `targets_${k}`, kind: "target", here: `${here}.targets[${k}]`, path: ["targets", String(k)], expression: src, item: expr(src, `targets[${k}]`),
398
+ put: (e, v) => { e.targets[k] = v; },
399
+ });
400
+ return;
401
+ }
402
+ if (!isRecord(t))
403
+ return;
404
+ if (isOqx(t.path))
405
+ throw selectionInvalid(`${here}.targets[${k}]: a block target has \`id\`, not \`path\``);
406
+ if (isOqx(t.id)) {
407
+ if (plain)
408
+ throw needsSelector(`${here}.targets[${k}].id`);
409
+ const src = t.id.oqx;
410
+ const literal = { ...t };
411
+ delete literal.id;
412
+ base.targets[k] = literal;
413
+ probe.targets[k].id = "$sel";
414
+ slots.push({
415
+ name: `targets_${k}`, kind: "identity", here: `${here}.targets[${k}].id`, path: ["targets", String(k), "id"], expression: src, item: expr(src, `targets[${k}].id`),
416
+ put: (e, v) => { const arr = e.targets; arr[k] = { ...arr[k], ...identityMember("id", v) }; },
417
+ });
418
+ }
419
+ for (const key of Object.keys(t))
420
+ if (key !== "id" && containsOqx(t[key]))
421
+ throw selectionInvalid(`${here}.targets[${k}].${key}: \`{ oqx }\` is not allowed here`);
422
+ });
423
+ }
424
+ return null;
425
+ };
426
+ // `at.before` / `at.after` may be an expression yielding a block id.
427
+ const atSlot = () => {
428
+ slotKeys.add("at");
429
+ const at = raw.at;
430
+ if (!isRecord(at))
431
+ return;
432
+ for (const member of ["before", "after"]) {
433
+ if (!isOqx(at[member]))
434
+ continue;
435
+ const src = at[member].oqx;
436
+ base.at = {};
437
+ probe.at = { [member]: "$sel" };
438
+ slots.push({ name: "at", kind: "string", here: `${here}.at.${member}`, path: ["at", member], expression: src, item: expr(src, `at.${member}`), put: (e, v) => { e.at = { [member]: v }; } });
439
+ }
440
+ };
441
+ let defaultTarget = null;
442
+ switch (op) {
443
+ case "create": {
444
+ stringSlot("path");
445
+ stringSlot("markdown");
446
+ slotKeys.add("frontmatter");
447
+ if (isRecord(raw.frontmatter)) {
448
+ Object.entries(raw.frontmatter).forEach(([k, v], j) => {
449
+ if (!isOqx(v))
450
+ return;
451
+ delete base.frontmatter[k];
452
+ probe.frontmatter[k] = null;
453
+ slots.push({ name: `fm_${j}`, kind: "json", here: `${here}.frontmatter.${k}`, path: ["frontmatter", k], expression: v.oqx, item: expr(v.oqx, `frontmatter.${k}`), put: (e, x) => { e.frontmatter[k] = x; } });
454
+ });
455
+ }
456
+ break;
457
+ }
458
+ case "insert":
459
+ defaultTarget = targetSlot("into", true, "document", true);
460
+ atSlot();
461
+ stringSlot("markdown");
462
+ break;
463
+ case "set":
464
+ defaultTarget = targetSlot("target", true, "document", true);
465
+ jsonSlot("value");
466
+ break;
467
+ case "unset":
468
+ defaultTarget = targetSlot("target", true, "document", true);
469
+ break;
470
+ case "move":
471
+ if ("into" in raw) {
472
+ // the block form: `targets` default to the hit, `into` is the destination
473
+ defaultTarget = targetsSlot(true);
474
+ targetSlot("into", false, "document", false);
475
+ atSlot();
476
+ }
477
+ else {
478
+ defaultTarget = targetSlot("target", true, "document", true);
479
+ stringSlot("to");
480
+ }
481
+ break;
482
+ case "remove": {
483
+ const docForm = "target" in raw || (!("targets" in raw) && root === "docs");
484
+ defaultTarget = docForm ? targetSlot("target", true, "document", true) : targetsSlot(true);
485
+ break;
486
+ }
487
+ case "split":
488
+ defaultTarget = targetSlot("target", true, "block", true);
489
+ break;
490
+ case "merge":
491
+ if (raw.targets === undefined)
492
+ throw selectionInvalid(`${here}: \`merge\` needs \`targets\` — one hit cannot be merged with itself`);
493
+ targetsSlot(false);
494
+ stringSlot("separator");
495
+ break;
496
+ }
497
+ // Everything else is literal: an expression anywhere else is refused.
498
+ for (const key of Object.keys(raw)) {
499
+ if (slotKeys.has(key) || key === "op")
500
+ continue;
501
+ if (containsOqx(raw[key]))
502
+ throw selectionInvalid(`${here}.${key}: \`{ oqx }\` is not allowed here`);
503
+ }
504
+ // The shape, with a probe in every slot (the §11.1 messages, naming `effects[i]`).
505
+ validateEffect(probe, index);
506
+ return { index, base, slots, defaultTarget };
507
+ }
508
+ /** The `id`/`path` member a bound identity value supplies: a string as given, a row ref by its id. */
509
+ function identityMember(member, v) {
510
+ if (isRowRef(v))
511
+ return { id: v.id };
512
+ return { [member]: v };
513
+ }
514
+ // ---- the request ---------------------------------------------------------------------
515
+ function validateExpect(v) {
516
+ if (v === undefined)
517
+ return undefined;
518
+ if (!isRecord(v))
519
+ throw selectionInvalid("`expect` must be an object { max }");
520
+ const extra = Object.keys(v).filter((k) => k !== "max");
521
+ if (extra.length > 0)
522
+ throw selectionInvalid(`\`expect\`: unknown key${extra.length > 1 ? "s" : ""} ${extra.map((k) => `\`${k}\``).join(", ")}`);
523
+ if (typeof v.max !== "number" || !Number.isInteger(v.max) || v.max < 0)
524
+ throw selectionInvalid("`expect.max` must be a non-negative integer");
525
+ return { max: v.max };
526
+ }
527
+ /**
528
+ * The fix of a refused selector source (3.3): the same request over an
529
+ * admitted root — `links` becomes `blocks` (a link lives in a block), any other
530
+ * source `docs` — spliced over the source's span of the written query.
531
+ */
532
+ function rootFix(select, source, request) {
533
+ if (request === undefined)
534
+ return {};
535
+ const [start, end] = source.span;
536
+ const cps = Array.from(select);
537
+ const root = source.kind === "ident" && source.name === "links" ? "blocks" : "docs";
538
+ return { fix: fixRequest({ ...request, select: cps.slice(0, start).join("") + root + cps.slice(end).join("") }) };
539
+ }
540
+ /** The static admission of the selector itself (the design's writability pass, first release). */
541
+ function admitSelector(select, request) {
542
+ let raw;
543
+ try {
544
+ raw = parse(select);
545
+ checkRootSpellings(raw);
546
+ }
547
+ catch (e) {
548
+ if (e instanceof OqxError)
549
+ throw selectionInvalid(`select: ${e.message}`);
550
+ if (e instanceof FilterInvalid)
551
+ throw selectionInvalid(`select: ${e.message}`);
552
+ throw e;
553
+ }
554
+ const reserved = reservedAlias(raw.select);
555
+ if (reserved)
556
+ throw selectionInvalid(`select: ${reserved.message}`);
557
+ if (raw.consumer !== "collect" && raw.consumer !== "first" && raw.consumer !== "single") {
558
+ throw selectionInvalid(`select: \`${raw.consumer}\` yields no rows — a selector's consumer is collect, first or single`);
559
+ }
560
+ if (raw.values)
561
+ throw selectionInvalid("select: a selector yields rows, not `values`");
562
+ const root = rootTarget(raw.source);
563
+ if (root === null)
564
+ throw selectionInvalid("select: a selector scans a root collection — `from docs`, `from blocks` or `from nodes`", rootFix(select, raw.source, request));
565
+ if (root === "links")
566
+ throw selectionInvalid("select: `links` rows have no effect target — select docs, blocks or nodes", rootFix(select, raw.source, request));
567
+ if (raw.follow !== null)
568
+ throw selectionInvalid("select: `follow` is not allowed in a selector — a hit must be a row of the root");
569
+ if (raw.from.length > 0)
570
+ throw selectionInvalid("select: a body `from` re-projects the rows — a hit must be a row of the root");
571
+ if (raw.offset !== null)
572
+ throw selectionInvalid("select: `offset` is not allowed — a selection is a set, not a page");
573
+ if (raw.distinct)
574
+ throw selectionInvalid("select: `distinct` is not allowed in a selector");
575
+ return { raw, root };
576
+ }
577
+ /** Run a compiled query over the store; an OQX error is the runner's `filter_invalid`. */
578
+ function runQuery(store, repoId, q, limit) {
579
+ try {
580
+ const res = oqxRunQuery(store, repoId, q, { limit });
581
+ return { hits: res.hits, truncated: res.truncated };
582
+ }
583
+ catch (e) {
584
+ if (e instanceof OqxError)
585
+ throw new FilterInvalid(e.message, "OQX");
586
+ throw e;
587
+ }
588
+ }
589
+ // ---- compile ---------------------------------------------------------------------------
590
+ /**
591
+ * Compile `{ select, expect?, effects: templates }` into the concrete effects
592
+ * (§11.8): admit statically, rewrite, run once over one snapshot with
593
+ * `limit = bound + 1`, refuse a selection beyond the bound, bind every hit to
594
+ * every template. Reads only.
595
+ */
596
+ export function compileSelection(store, repoId, select, expectRaw, templatesRaw) {
597
+ const expect = validateExpect(expectRaw);
598
+ const request = { select, effects: templatesRaw, ...(expectRaw !== undefined ? { expect: expectRaw } : {}) };
599
+ const { rewritten, root, templates } = admitRequest(select, templatesRaw, request);
600
+ const bound = expect?.max ?? SELECTION_CEILING;
601
+ const { hits, truncated } = runQuery(store, repoId, rewritten, bound + 1);
602
+ if (truncated || hits.length > bound) {
603
+ // The fix (3.3): the same request bounded at the selection's actual size — counted
604
+ // by a second, unbounded run when the first page was cut (the engine never plans
605
+ // a partial set, so the page bound is what it is; the count is for the example).
606
+ const count = truncated ? runQuery(store, repoId, rewritten, Number.MAX_SAFE_INTEGER).hits.length : hits.length;
607
+ throw new MutationError("selection_too_large", `the selection has ${truncated ? `more than ${bound}` : String(hits.length)} hits, over the bound of ${bound}${expect === undefined ? " (the implicit ceiling; pass expect.max to raise it)" : ""} — narrow the selector or raise \`expect.max\``, {
608
+ cardinality: truncated ? `>${bound}` : hits.length, max: bound, ...(expect === undefined ? { implicit: true } : {}),
609
+ fix: fixRequest({ select, effects: templatesRaw, expect: { max: count } }),
610
+ });
611
+ }
612
+ // Row → effects: hits in result order × templates in order (k = h·T + t).
613
+ const effects = [];
614
+ const origins = [];
615
+ hits.forEach((hit) => {
616
+ for (const t of templates) {
617
+ const k = effects.length;
618
+ const row = { id: hit.id, path: hit.path };
619
+ const origin = { template_index: t.index, row };
620
+ effects.push(bind(hit, t, k, origin, root, request));
621
+ origins.push(origin);
622
+ }
623
+ });
624
+ const selection = {
625
+ query: select,
626
+ ...(expect !== undefined ? { expect } : {}),
627
+ templates: templatesRaw,
628
+ cardinality: hits.length,
629
+ identities: hits.map((h) => h.id),
630
+ };
631
+ return { effects, selection, origins };
632
+ }
633
+ /** The static half (admission + rewrite; no store): the rewritten query, its root and the admitted templates. */
634
+ function admitRequest(select, templatesRaw, request) {
635
+ if (typeof select !== "string" || select.trim() === "")
636
+ throw selectionInvalid("`select` must be an OQX query (a non-empty string)");
637
+ if (!Array.isArray(templatesRaw))
638
+ throw selectionInvalid("`effects` must be an array of effect templates");
639
+ if (templatesRaw.length === 0)
640
+ throw selectionInvalid("`effects` must name at least one template");
641
+ const { raw, root } = admitSelector(select, request);
642
+ const templates = templatesRaw.map((t, i) => admitTemplate(desugarEffect(t, i, request), i, root, false));
643
+ // The rewrite: the slot items, named `__omg_sel_<t>_<slot>`, appended after
644
+ // the user's items on the raw tree (so a user alias resolves into them).
645
+ const items = [];
646
+ for (const t of templates)
647
+ for (const s of t.slots)
648
+ items.push({ ...s.item, name: slotName(t.index, s.name) });
649
+ return { rewritten: { ...raw, select: [...raw.select, ...items] }, root, templates };
650
+ }
651
+ /** The query a selector request compiles to (admission + rewrite, before any row is read) — `print` it to see what runs. */
652
+ export function selectionQuery(select, templates) {
653
+ return admitRequest(select, templates, { select, effects: templates }).rewritten;
654
+ }
655
+ function slotName(t, slot) {
656
+ return `__omg_sel_${t}_${slot}`;
657
+ }
658
+ /**
659
+ * The fix of an ambiguous target (3.3): the slot spelled as the FIRST identity the
660
+ * expression yielded (a row by its id), and under a selector the selector narrowed
661
+ * to the row the expression was read from (`expect.max: 1`); a plain request keeps
662
+ * its effects with the slot rewritten.
663
+ */
664
+ function ambiguousFix(request, t, s, hit, root, list) {
665
+ const head = list[0];
666
+ const first = isRowRef(head) ? head.id : head;
667
+ if (typeof first !== "string")
668
+ return {};
669
+ const effects = withMember(request.effects, [String(t.index), ...s.path], first);
670
+ if (request.select === undefined)
671
+ return { fix: fixRequest({ effects }) };
672
+ return { fix: fixRequest({ select: narrowedTo(root, hit.id), effects, expect: { max: 1 } }) };
673
+ }
674
+ /** One hit × one template → the concrete effect (the binding table of §11.8). */
675
+ function bind(hit, t, k, origin, root, request) {
676
+ const e = clone(t.base);
677
+ const data = { effect_index: k, ...(origin.template_index !== undefined ? { template_index: origin.template_index } : {}), row: origin.row };
678
+ if (t.defaultTarget !== null) {
679
+ const target = { kind: HIT_KIND[root], id: hit.id };
680
+ e[t.defaultTarget.key] = t.defaultTarget.list ? [target] : target;
681
+ }
682
+ for (const s of t.slots) {
683
+ const v = hit[slotName(t.index, s.name)];
684
+ const absent = v === undefined || v === null;
685
+ switch (s.kind) {
686
+ case "identity":
687
+ case "target": {
688
+ if (absent || (Array.isArray(v) && v.length === 0)) {
689
+ throw new MutationError("target_missing", `${s.here}: the expression yielded no identity for row ${hit.id}`, { ...data, expression: s.expression });
690
+ }
691
+ if (Array.isArray(v)) {
692
+ throw new MutationError("ambiguous_target", `${s.here}: the expression yielded ${v.length} identities for row ${hit.id}; a target resolves to exactly one`, { ...data, expression: s.expression, count: v.length, ...ambiguousFix(request, t, s, hit, root, v) });
693
+ }
694
+ if (typeof v !== "string" && !isRowRef(v))
695
+ throw selectionInvalid(`${s.here}: the expression yielded ${describeValue(v)}, not an identity (a string or a row)`, { ...data, expression: s.expression });
696
+ if (s.kind === "identity") {
697
+ s.put(e, v);
698
+ break;
699
+ }
700
+ // (3.2) a whole target: a row by its id's prefix, a string as a bare target (a path → document, an id → its kind).
701
+ const ref = isRowRef(v) ? v.id : v;
702
+ if (!isRowRef(v) && !looksLikePath(ref) && !isTargetId(ref) && !isNewSymbol(ref)) {
703
+ throw selectionInvalid(`${s.here}: the expression yielded ${describeValue(v)}, not an identity (a document path, an id or a row)`, { ...data, expression: s.expression });
704
+ }
705
+ s.put(e, bareTarget(ref, s.here));
706
+ break;
707
+ }
708
+ case "string":
709
+ if (absent)
710
+ throw selectionInvalid(`${s.here}: the expression yielded absent for row ${hit.id}`, { ...data, expression: s.expression });
711
+ if (typeof v !== "string")
712
+ throw selectionInvalid(`${s.here}: the expression yielded ${describeValue(v)}, expected a string`, { ...data, expression: s.expression });
713
+ s.put(e, v);
714
+ break;
715
+ case "json":
716
+ if (absent)
717
+ throw selectionInvalid(`${s.here}: the expression yielded absent for row ${hit.id}${s.name === "value" ? " — use `unset` to remove a key" : ""}`, { ...data, expression: s.expression });
718
+ s.put(e, v);
719
+ break;
720
+ }
721
+ }
722
+ return validateEffect(e, k);
723
+ }
724
+ // ---- plain requests with expressions (3.2, §11.8 "Sugar") ---------------------------------
725
+ const PLACEMENT_KINDS = ["document", "section", "block"];
726
+ const DOCUMENT_KIND = ["document"];
727
+ const BLOCK_KIND = ["block"];
728
+ const SET_KINDS = ["document", "block", "node"];
729
+ /**
730
+ * The primary target of a plain effect (the §11.8 table's: `target` of
731
+ * set/unset/split and of a document move/remove, `into` of insert, the first
732
+ * of `targets` for the block forms) as a row: a `create` has none; an
733
+ * expression there needs a selector; a `$new.n` has no row yet.
734
+ */
735
+ function scopeOf(store, repoId, e, i) {
736
+ const here = `effects[${i}]`;
737
+ let key;
738
+ let v;
739
+ let kinds;
740
+ let placement = false;
741
+ const first = () => {
742
+ if (!Array.isArray(e.targets) || e.targets.length === 0)
743
+ validateEffect(e, i); // the shape check's `targets` message
744
+ key = "targets[0]";
745
+ v = e.targets[0];
746
+ kinds = BLOCK_KIND;
747
+ };
748
+ switch (e.op) {
749
+ case "create":
750
+ throw selectionInvalid(`${here}: a \`create\` has no row — an expression on a create needs a selector (\`select\`)`);
751
+ case "insert":
752
+ key = "into";
753
+ v = e.into;
754
+ kinds = PLACEMENT_KINDS;
755
+ placement = true;
756
+ break;
757
+ case "set":
758
+ key = "target";
759
+ v = e.target;
760
+ kinds = SET_KINDS;
761
+ break;
762
+ case "unset":
763
+ key = "target";
764
+ v = e.target;
765
+ kinds = DOCUMENT_KIND;
766
+ break;
767
+ case "move":
768
+ if ("targets" in e || "into" in e)
769
+ first();
770
+ else {
771
+ key = "target";
772
+ v = e.target;
773
+ kinds = DOCUMENT_KIND;
774
+ }
775
+ break;
776
+ case "remove":
777
+ if ("targets" in e)
778
+ first();
779
+ else {
780
+ key = "target";
781
+ v = e.target;
782
+ kinds = DOCUMENT_KIND;
783
+ }
784
+ break;
785
+ case "split":
786
+ key = "target";
787
+ v = e.target;
788
+ kinds = BLOCK_KIND;
789
+ break;
790
+ case "merge":
791
+ first();
792
+ break;
793
+ default:
794
+ validateEffect(e, i); // an unknown op (or `retarget`): the shape check refuses it
795
+ throw effectInvalid(`${here}: \`op\` ${JSON.stringify(e.op)} is not row-shaped`);
796
+ }
797
+ if (isOqx(v) || (isRecord(v) && (isOqx(v.id) || isOqx(v.path))))
798
+ throw needsSelector(`${here}.${key}`);
799
+ const t = validateTarget(v, `${here}.${key}`, kinds);
800
+ switch (t.kind) {
801
+ case "symbol":
802
+ throw selectionInvalid(`${here}.${key}: ${t.id} has no row yet — it is created by this plan; use a literal target or a selector`);
803
+ case "document": {
804
+ const d = findDocumentTarget(store, repoId, t);
805
+ return { root: "docs", id: d.id, missing: () => new MutationError("doc_missing", `doc ${d.id} not found`, { doc: d.id }) };
806
+ }
807
+ case "block":
808
+ return {
809
+ root: "blocks", id: t.id,
810
+ missing: () => (placement ? new MutationError("parent_missing", `parent ${t.id} not found`) : new MutationError("block_missing", `block ${t.id} not found`, { block: t.id })),
811
+ };
812
+ case "section":
813
+ return { root: "blocks", id: t.heading_id, missing: () => new MutationError("parent_missing", `parent ${t.heading_id} not found`) };
814
+ case "node":
815
+ return { root: "nodes", id: t.id, missing: () => new MutationError("block_missing", `node ${t.id} not found`, {}) };
816
+ }
817
+ }
818
+ /**
819
+ * Compile a plain `effects` list (no selector): desugar the `-> ` leaders,
820
+ * then — when nothing is an expression — the shape check as before (3.0
821
+ * byte-identical). Otherwise each effect with expressions is admitted as a
822
+ * template over its own primary target's row: the effects are grouped by that
823
+ * row's identity, one synthetic query per identity — `from <root> where id ==
824
+ * "<id>"` with the slot items appended, named `__omg_sel_<effectIndex>_<slot>`
825
+ * — runs, and the row binds the slots exactly as a selector's hit would. The
826
+ * concrete effects feed the lowering unchanged; `templates` is the list as
827
+ * written, for the intent record.
828
+ */
829
+ export function compilePlain(store, repoId, raw) {
830
+ if (!Array.isArray(raw))
831
+ throw effectInvalid("`effects` must be an array of effects");
832
+ if (raw.length === 0)
833
+ throw effectInvalid("`effects` must name at least one effect");
834
+ const request = { effects: raw };
835
+ const desugared = raw.map((e, i) => desugarEffect(e, i, request));
836
+ if (!desugared.some(containsOqx))
837
+ return { effects: desugared.map((e, i) => validateEffect(e, i)), origins: [] };
838
+ const effects = [];
839
+ const origins = [];
840
+ const scopes = new Map();
841
+ desugared.forEach((e, i) => {
842
+ if (!containsOqx(e) || !isRecord(e)) {
843
+ effects[i] = validateEffect(e, i);
844
+ return;
845
+ }
846
+ const scope = scopeOf(store, repoId, e, i);
847
+ const t = admitTemplate(e, i, scope.root, true);
848
+ if (t.slots.length === 0) {
849
+ // `{ oqx }` only inside data (a nested `value`): literal, as for a template.
850
+ effects[i] = validateEffect(e, i);
851
+ return;
852
+ }
853
+ const key = `${scope.root}:${scope.id}`;
854
+ let g = scopes.get(key);
855
+ if (g === undefined) {
856
+ g = { ...scope, members: [] };
857
+ scopes.set(key, g);
858
+ }
859
+ g.members.push(t);
860
+ });
861
+ for (const g of scopes.values()) {
862
+ const q = parse(`from ${g.root} where id == ${JSON.stringify(g.id)}`);
863
+ const items = [];
864
+ for (const t of g.members)
865
+ for (const s of t.slots)
866
+ items.push({ ...s.item, name: slotName(t.index, s.name) });
867
+ const { hits } = runQuery(store, repoId, { ...q, select: items }, 2);
868
+ const hit = hits[0];
869
+ if (hit === undefined)
870
+ throw g.missing();
871
+ for (const t of g.members) {
872
+ const origin = { row: { id: hit.id, path: hit.path } };
873
+ effects[t.index] = bind(hit, t, t.index, origin, g.root, request);
874
+ origins[t.index] = origin;
875
+ }
876
+ }
877
+ return { effects, templates: raw, origins };
878
+ }
879
+ //# sourceMappingURL=select.js.map