@omgbase/core 0.15.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/src/core/ingest.d.ts +2 -0
  2. package/dist/src/core/ingest.js +13 -0
  3. package/dist/src/core/read/document.d.ts +9 -8
  4. package/dist/src/core/read/document.js +0 -2
  5. package/dist/src/core/store/gc.d.ts +13 -1
  6. package/dist/src/core/store/gc.js +24 -5
  7. package/dist/src/core/store/size.d.ts +79 -0
  8. package/dist/src/core/store/size.js +232 -0
  9. package/dist/src/index.d.ts +4 -1
  10. package/dist/src/index.js +5 -1
  11. package/dist/src/mcp/arguments.d.ts +10 -2
  12. package/dist/src/mcp/arguments.js +37 -3
  13. package/dist/src/mcp/errors.d.ts +1 -1
  14. package/dist/src/mcp/reference.d.ts +1 -1
  15. package/dist/src/mcp/reference.js +63 -10
  16. package/dist/src/mcp/server.d.ts +34 -1
  17. package/dist/src/mcp/server.js +158 -46
  18. package/dist/src/mutate/canonical.d.ts +27 -0
  19. package/dist/src/mutate/canonical.js +138 -0
  20. package/dist/src/mutate/effects.d.ts +58 -8
  21. package/dist/src/mutate/effects.js +312 -43
  22. package/dist/src/mutate/known-ids.d.ts +19 -0
  23. package/dist/src/mutate/known-ids.js +47 -7
  24. package/dist/src/mutate/ops.d.ts +1 -0
  25. package/dist/src/mutate/ops.js +20 -1
  26. package/dist/src/mutate/plan.d.ts +26 -16
  27. package/dist/src/mutate/plan.js +152 -112
  28. package/dist/src/mutate/select.d.ts +78 -0
  29. package/dist/src/mutate/select.js +903 -0
  30. package/dist/src/mutate/tree.d.ts +2 -2
  31. package/dist/src/oqx-js/context.d.ts +9 -2
  32. package/dist/src/oqx-js/context.js +74 -28
  33. package/dist/src/oqx-js/names.d.ts +40 -0
  34. package/dist/src/oqx-js/names.js +347 -0
  35. package/dist/src/oqx-js/planner.d.ts +3 -3
  36. package/dist/src/oqx-js/planner.js +17 -9
  37. package/dist/src/oqx-js/root-row.d.ts +15 -4
  38. package/dist/src/oqx-js/root-row.js +18 -10
  39. package/dist/src/oqx-js/run.d.ts +45 -1
  40. package/dist/src/oqx-js/run.js +114 -32
  41. package/dist/src/oqx-js/sql/translate.js +1 -1
  42. package/dist/src/spec-versions.d.ts +5 -5
  43. package/dist/src/spec-versions.js +4 -4
  44. package/dist/src/sync/observe.js +9 -3
  45. package/dist/src/version.d.ts +7 -2
  46. package/dist/src/version.js +5 -2
  47. package/package.json +2 -2
@@ -1,4 +1,5 @@
1
1
  import type { Store } from "../core/store/store.js";
2
+ import type { TreeInputBlock } from "../core/store/writers.js";
2
3
  import type { IdResolver } from "../core/ingest.js";
3
4
  import type { MutDoc } from "./tree.js";
4
5
  /**
@@ -10,3 +11,21 @@ import type { MutDoc } from "./tree.js";
10
11
  export declare function makeKnownIdResolver(store: Store, repoId: string, doc: MutDoc, priorIds: Set<string>, opts?: {
11
12
  path?: string;
12
13
  }): IdResolver;
14
+ /** A carried block's live row, as spec/reconcile §2's carry classification reads it. */
15
+ interface PriorRow {
16
+ parent: string | null;
17
+ ordinal: number;
18
+ rawHashHex: string;
19
+ }
20
+ /**
21
+ * spec/reconcile §2 "Classifying a carry", applied to the api path's known ids:
22
+ * `moved` = the parent or the sibling index differs from the live row,
23
+ * `content_equal` = the raw bytes hash the same; `same` / `moved` / `edited` /
24
+ * `edited_moved` follow. An id with no prior row (new to this document — a
25
+ * mint or a cross-document arrival) is `inserted`. Pre-order over the tree.
26
+ */
27
+ export declare function classifyCarries(blocks: TreeInputBlock[], prior: Map<string, PriorRow>, priorIds: Set<string>): {
28
+ id: string;
29
+ kind: string;
30
+ }[];
31
+ export {};
@@ -1,4 +1,5 @@
1
1
  import { mintId } from "../core/ids.js";
2
+ import { sha256 } from "../core/hash.js";
2
3
  import { extractFromBlock, extractFromFrontmatter, resolveRelativePath } from "../graph/extract.js";
3
4
  import { resolveExternal, resolveDocPath } from "../core/store/edges.js";
4
5
  import { adapterForPath } from "../format/index.js";
@@ -79,12 +80,19 @@ export function makeKnownIdResolver(store, repoId, doc, priorIds, opts = {}) {
79
80
  const assigned = assignFromMut(rest, byKey);
80
81
  const nowIds = collectIds(assigned);
81
82
  const nowSet = new Set(nowIds);
82
- // Dispositions are INTENT, not inference: a carried id that existed before is
83
- // `same`/`edited` (confidence 1.0); an id new this commit is `inserted`; a
84
- // prior id no longer present is `deleted`.
85
- const dispositions = nowIds.map((id) => ({
86
- blockId: id,
87
- kind: priorIds.has(id) ? "edited" : "inserted",
83
+ // Dispositions are INTENT, not inference (confidence 1.0, reason `api`): an
84
+ // id new this commit is `inserted`; a prior id no longer present is
85
+ // `deleted` (pooled, no row); a carried id is classified exactly as
86
+ // spec/reconcile §2 classifies a carry — `same` when its raw bytes, parent
87
+ // and sibling index are those of its live row, else `edited` / `moved` /
88
+ // `edited_moved` — so a block the changeset did not touch is `same` and
89
+ // (spec/store §5.4 step 10, 14.2) never persisted: a one-paragraph edit
90
+ // moves that paragraph's `updated_at`, not its siblings'. Through store
91
+ // 14.1 every carried block was stamped `edited`.
92
+ const prior = docId === null ? new Map() : priorRows(db, docId);
93
+ const dispositions = classifyCarries(assigned, prior, priorIds).map((c) => ({
94
+ blockId: c.id,
95
+ kind: c.kind,
88
96
  confidence: 1,
89
97
  reason: "api",
90
98
  matcherV: null,
@@ -121,10 +129,42 @@ export function makeKnownIdResolver(store, repoId, doc, priorIds, opts = {}) {
121
129
  }
122
130
  return out;
123
131
  };
124
- void docId;
125
132
  return { assigned, dispositions, deleted, crossDocIds, extractEdges };
126
133
  };
127
134
  }
135
+ function priorRows(db, docId) {
136
+ const rows = db
137
+ .prepare("SELECT block_id, parent_block, ordinal, raw_hash FROM blocks WHERE doc_id = ? AND deleted_commit IS NULL")
138
+ .all(docId);
139
+ return new Map(rows.map((r) => [r.block_id, { parent: r.parent_block, ordinal: r.ordinal, rawHashHex: r.raw_hash.toString("hex") }]));
140
+ }
141
+ /**
142
+ * spec/reconcile §2 "Classifying a carry", applied to the api path's known ids:
143
+ * `moved` = the parent or the sibling index differs from the live row,
144
+ * `content_equal` = the raw bytes hash the same; `same` / `moved` / `edited` /
145
+ * `edited_moved` follow. An id with no prior row (new to this document — a
146
+ * mint or a cross-document arrival) is `inserted`. Pre-order over the tree.
147
+ */
148
+ export function classifyCarries(blocks, prior, priorIds) {
149
+ const out = [];
150
+ const walk = (list, parent) => {
151
+ list.forEach((b, index) => {
152
+ const row = priorIds.has(b.blockId) ? prior.get(b.blockId) : undefined;
153
+ if (!row) {
154
+ out.push({ id: b.blockId, kind: "inserted" });
155
+ }
156
+ else {
157
+ const moved = row.parent !== parent || row.ordinal !== index;
158
+ const contentEqual = row.rawHashHex === sha256(b.raw).toString("hex");
159
+ out.push({ id: b.blockId, kind: contentEqual ? (moved ? "moved" : "same") : moved ? "edited_moved" : "edited" });
160
+ }
161
+ if (b.children.length > 0)
162
+ walk(b.children, b.blockId);
163
+ });
164
+ };
165
+ walk(blocks, null);
166
+ return out;
167
+ }
128
168
  // Edge resolution — mirrors reconciling-ingest's resolvers (block-anchor targets
129
169
  // resolve to the document node in v1; the anchor is preserved on the edge).
130
170
  function resolveEdge(db, repoId, srcDoc, e, docDir) {
@@ -35,6 +35,7 @@ export declare function opRemove(doc: MutDoc, blockIds: string[], opIndex: numbe
35
35
  export declare function opSplit(doc: MutDoc, blockId: string, at: number[], opIndex: number, expect?: Expect): {
36
36
  ids: string[];
37
37
  };
38
+ export declare function trimCutEdges(pieces: readonly string[]): string[];
38
39
  export declare function opMerge(doc: MutDoc, blockIds: string[], opIndex: number, separator?: string, expectPer?: Record<string, Expect>): {
39
40
  ids: string[];
40
41
  mergedInto: string[];
@@ -505,7 +505,7 @@ export function opSplit(doc, blockId, at, opIndex, expect) {
505
505
  const pieces = [];
506
506
  for (let i = 1; i < cuts.length; i++)
507
507
  pieces.push(raw.slice(cuts[i - 1], cuts[i]));
508
- const nonEmpty = pieces.filter((p) => p.trim().length > 0);
508
+ const nonEmpty = trimCutEdges(pieces).filter((p) => p.trim().length > 0);
509
509
  if (nonEmpty.length < 2)
510
510
  throw new MutationError("type_mismatch", "split must yield ≥2 non-empty fragments");
511
511
  // First fragment carries the id (authored intent, 04 §1); rest minted.
@@ -533,6 +533,25 @@ export function opSplit(doc, blockId, at, opIndex, expect) {
533
533
  markContainerDirty(doc, found.siblings);
534
534
  return { ids: [blockId, ...newBlocks.map((b) => b.id)] };
535
535
  }
536
+ // spec/mutate §2.5 (3.5): the pieces of a split are trimmed at the CUT edges
537
+ // only — every piece but the last loses its trailing whitespace, every piece
538
+ // but the first its leading whitespace — so an offset on either side of a
539
+ // space (or inside the run, a `\n`, a hard break's ` \n`) yields the same
540
+ // pieces and no piece ends in a stray space. The raw's own start and end are
541
+ // not cut edges and stay as authored (a last piece keeps the raw's trailing
542
+ // spaces). The whitespace set is the format's trim set (`spec/format` §4.1
543
+ // step 4) — exactly what JavaScript's `trimStart`/`trimEnd` remove.
544
+ export function trimCutEdges(pieces) {
545
+ const last = pieces.length - 1;
546
+ return pieces.map((p, i) => {
547
+ let out = p;
548
+ if (i < last)
549
+ out = out.trimEnd();
550
+ if (i > 0)
551
+ out = out.trimStart();
552
+ return out;
553
+ });
554
+ }
536
555
  export function opMerge(doc, blockIds, opIndex, separator = " ", expectPer) {
537
556
  if (blockIds.length < 2)
538
557
  throw new MutationError("not_contiguous", "merge requires ≥2 blocks");
@@ -3,9 +3,10 @@ import { type ApplyRevision, type Op, type OpResult } from "./apply.js";
3
3
  import { type DocStore } from "./doc-store.js";
4
4
  import { type CreateEntry, type ResolvedEffect, type RetargetPreview } from "./effects.js";
5
5
  import type { IdentityPreview } from "./opset.js";
6
+ import { type Selection } from "./select.js";
6
7
  export declare const PLAN_VERSION: 1;
7
8
  /** The lowering + conflict rules version (§11.4; the `matcher_v` precedent). */
8
- export declare const PLANNER_V: "p2";
9
+ export declare const PLANNER_V: "p3";
9
10
  /** A kernel op as the plan spells it on the wire: the fixture spelling (`child_ids`). */
10
11
  export type WireOp = Exclude<Op, {
11
12
  op: "update";
@@ -77,6 +78,8 @@ export interface PlanPreview {
77
78
  identity?: Record<string, IdentityPreview>;
78
79
  /** (3.0) per `retarget` effect: its hits and pair counts; present when a plan has one */
79
80
  retarget?: RetargetPreview[];
81
+ /** (3.3, §11.9) one line in words per resolved effect, in order — the plan as a worked example; `[]` for a zero-hit selection */
82
+ summary: string[];
80
83
  }
81
84
  /** The executable core the digest covers. */
82
85
  export interface PlanCore {
@@ -89,10 +92,15 @@ export interface PlanCore {
89
92
  symbol: string;
90
93
  kind: "block" | "document";
91
94
  }[];
95
+ /** (3.1, §11.8) the selector, its templates and the hits it bound — present only for a selector plan */
96
+ selection?: Selection;
92
97
  }
93
98
  export interface Plan extends PlanCore {
94
99
  plan_id: string;
95
100
  diagnostics: string[];
101
+ /** (3.2, §11.8 "Sugar") a plain request's effects AS WRITTEN when one carried a `-> ` / `{ oqx }` expression;
102
+ * not digested (the core already holds the concrete effects), recorded in the intent record as `templates` */
103
+ templates?: unknown[];
96
104
  /** excluded from the digest; omittable on echo */
97
105
  preview?: PlanPreview;
98
106
  }
@@ -104,19 +112,11 @@ export interface Receipt {
104
112
  /** `$new.n` → the minted id */
105
113
  new_ids: Record<string, string>;
106
114
  committed: true;
115
+ /** (3.3, §11.9) the plan's `preview.summary` — what was committed, in words (`[]` when the plan was handed back without its preview) */
116
+ summary: string[];
107
117
  }
108
- /**
109
- * Canonical JSON (§11.5): no whitespace; object keys sorted by their UTF-8
110
- * bytes; members whose value is `undefined` omitted (an optional key absent is
111
- * absent, never `null`; a JSON `null` value stays `null`); strings escaped only
112
- * as JSON requires — `"`, `\`, and U+0000–U+001F (`\b \f \n \r \t`, else
113
- * `\u00xx` lower-case) — everything else literal UTF-8; a number that is
114
- * integral with |n| < 2^53 printed as a plain integer (`-0` as `0`), any other
115
- * finite number by ECMAScript `Number::toString` (shortest round-trip digits);
116
- * `true`/`false`/`null`.
117
- */
118
- export declare function canonicalJson(v: unknown): string;
119
- /** The executable core of a plan — the six digested keys, nothing else. */
118
+ export { canonicalJson } from "./canonical.js";
119
+ /** The executable core of a plan — the six digested keys (seven with `selection`), nothing else. */
120
120
  export declare function planCore(p: PlanCore): PlanCore;
121
121
  /** `plan_id = hex(sha256(canonical(core)))` (§11.5). */
122
122
  export declare function planDigest(core: PlanCore): string;
@@ -126,8 +126,12 @@ export interface PlanEffectsRequest {
126
126
  rootPath?: string;
127
127
  docStore?: DocStore;
128
128
  omgbaseDir?: string;
129
- /** The effects as given (validated here: a shape error is `filter_invalid`). */
129
+ /** The effects as given (validated here: a shape error is `filter_invalid`); with `select`, the templates (§11.8). */
130
130
  effects: unknown;
131
+ /** (3.1) an OQX selector: its hits × `effects` (templates) are the effects planned */
132
+ select?: unknown;
133
+ /** (3.1) `{ max }`: the bound on the selection (`selection_too_large` beyond it); the implicit ceiling without it */
134
+ expect?: unknown;
131
135
  }
132
136
  /** The kernel op as the plan carries it (`childIds` → `child_ids`). */
133
137
  export declare function wireOp(o: Op): WireOp;
@@ -165,10 +169,12 @@ export declare function admitPlan(input: unknown): Plan;
165
169
  export declare function executePlan(store: Store, req: ExecuteRequest & {
166
170
  plan: unknown;
167
171
  }): Receipt;
168
- /** `execute { plan_id, effects }` (§11.7): re-plan; the digest must equal `plan_id`, else `plan_mismatch`. */
172
+ /** `execute { plan_id, effects, select?, expect? }` (§11.7): re-plan (with the selector when one is given, §11.8); the digest must equal `plan_id`, else `plan_mismatch`. */
169
173
  export declare function executeByDigest(store: Store, req: ExecuteRequest & {
170
174
  planId: string;
171
175
  effects: unknown;
176
+ select?: unknown;
177
+ expect?: unknown;
172
178
  }): Receipt;
173
179
  /**
174
180
  * `apply { effects }` with both halves kept (the sugar's seam — `sugar.ts`,
@@ -179,14 +185,18 @@ export declare function executeByDigest(store: Store, req: ExecuteRequest & {
179
185
  */
180
186
  export declare function applyEffectsDetailed(store: Store, req: ExecuteRequest & {
181
187
  effects: unknown;
188
+ select?: unknown;
189
+ expect?: unknown;
182
190
  dryRun?: boolean;
183
191
  }): {
184
192
  plan: Plan;
185
193
  receipt?: Receipt;
186
194
  };
187
- /** `apply { effects }` (§11.7): plan + execute under one lock and one transaction; `dryRun` returns the Plan. */
195
+ /** `apply { effects, select?, expect? }` (§11.7): plan + execute under one lock and one transaction; `dryRun` returns the Plan. */
188
196
  export declare function applyEffects(store: Store, req: ExecuteRequest & {
189
197
  effects: unknown;
198
+ select?: unknown;
199
+ expect?: unknown;
190
200
  dryRun?: boolean;
191
201
  }): Plan | Receipt;
192
202
  /** Every string that is a key of `ids` replaced by its value, recursively (arrays and plain objects) — a plan's `$new.n` realized from a receipt's `new_ids`. */
@@ -7,112 +7,30 @@ import { ChangesetPlanner } from "./apply.js";
7
7
  import { commitChangeset, readDocBase } from "./commit.js";
8
8
  import { resolveDocStore } from "./doc-store.js";
9
9
  import { parseNewDoc } from "./docs.js";
10
- import { ConflictTracker, SymbolTable, isNewSymbol, lowerEffect, symbolicMinter, validateEffects, effectInvalid, } from "./effects.js";
10
+ import { ConflictTracker, SymbolTable, isNewSymbol, lowerEffect, symbolicMinter, effectInvalid, } from "./effects.js";
11
+ import { compilePlain, compileSelection, selectionInvalid } from "./select.js";
11
12
  import { MutationError } from "./tree.js";
13
+ import { canonicalJson, fixRequest, rootOfId, narrowedTo } from "./canonical.js";
12
14
  // The Plan (spec/mutate §11.4–§11.7, 2.3): the frozen, executable, digest-named
13
15
  // result of lowering an ordered effect list. `plan` writes nothing; `execute`
14
16
  // commits exactly a plan — its preconditions re-validated inside the
15
17
  // transaction by `commit.ts` — and `apply({ effects })` is plan + execute under
16
18
  // one writer lock and one transaction. New identities are symbolic (`$new.n`,
17
19
  // minted at execute, mapped in the receipt); a plan's identity is the sha256 of
18
- // the canonical JSON of its executable core.
20
+ // the canonical JSON of its executable core. Since 3.1 (§11.8) a request may
21
+ // carry an OQX `select` whose hits, bound to the `effects` as templates, ARE
22
+ // the effects (`select.ts`); the frozen `selection` is the core's seventh member.
19
23
  export const PLAN_VERSION = 1;
20
24
  /** The lowering + conflict rules version (§11.4; the `matcher_v` precedent). */
21
- export const PLANNER_V = "p2";
25
+ export const PLANNER_V = "p3";
22
26
  // ---- canonical JSON and the digest (§11.5) ----------------------------------------------
23
- function cmpUtf8(a, b) {
24
- return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
25
- }
26
- // A lone surrogate has no UTF-8 encoding: it cannot be digested (nor sent as JSON text).
27
- function isWellFormed(s) {
28
- for (let i = 0; i < s.length; i++) {
29
- const c = s.charCodeAt(i);
30
- if (c >= 0xd800 && c <= 0xdbff) {
31
- const n = s.charCodeAt(i + 1);
32
- if (!(n >= 0xdc00 && n <= 0xdfff))
33
- return false;
34
- i++;
35
- }
36
- else if (c >= 0xdc00 && c <= 0xdfff)
37
- return false;
38
- }
39
- return true;
40
- }
41
- function canonicalString(s) {
42
- if (!isWellFormed(s))
43
- throw effectInvalid("strings must be well-formed Unicode (a lone surrogate cannot be digested)");
44
- let out = '"';
45
- for (let i = 0; i < s.length; i++) {
46
- const c = s.charCodeAt(i);
47
- switch (c) {
48
- case 0x22:
49
- out += '\\"';
50
- break;
51
- case 0x5c:
52
- out += "\\\\";
53
- break;
54
- case 0x08:
55
- out += "\\b";
56
- break;
57
- case 0x0c:
58
- out += "\\f";
59
- break;
60
- case 0x0a:
61
- out += "\\n";
62
- break;
63
- case 0x0d:
64
- out += "\\r";
65
- break;
66
- case 0x09:
67
- out += "\\t";
68
- break;
69
- default:
70
- out += c < 0x20 ? `\\u${c.toString(16).padStart(4, "0")}` : s[i];
71
- }
72
- }
73
- return out + '"';
74
- }
75
- function canonicalNumber(n) {
76
- if (!Number.isFinite(n))
77
- throw effectInvalid("a number in a plan must be finite");
78
- if (Number.isInteger(n) && Math.abs(n) < 2 ** 53)
79
- return String(n === 0 ? 0 : n);
80
- return String(n);
81
- }
82
- /**
83
- * Canonical JSON (§11.5): no whitespace; object keys sorted by their UTF-8
84
- * bytes; members whose value is `undefined` omitted (an optional key absent is
85
- * absent, never `null`; a JSON `null` value stays `null`); strings escaped only
86
- * as JSON requires — `"`, `\`, and U+0000–U+001F (`\b \f \n \r \t`, else
87
- * `\u00xx` lower-case) — everything else literal UTF-8; a number that is
88
- * integral with |n| < 2^53 printed as a plain integer (`-0` as `0`), any other
89
- * finite number by ECMAScript `Number::toString` (shortest round-trip digits);
90
- * `true`/`false`/`null`.
91
- */
92
- export function canonicalJson(v) {
93
- if (v === null)
94
- return "null";
95
- switch (typeof v) {
96
- case "boolean":
97
- return v ? "true" : "false";
98
- case "number":
99
- return canonicalNumber(v);
100
- case "string":
101
- return canonicalString(v);
102
- case "object": {
103
- if (Array.isArray(v))
104
- return `[${v.map((x) => canonicalJson(x === undefined ? null : x)).join(",")}]`;
105
- const o = v;
106
- const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort(cmpUtf8);
107
- return `{${keys.map((k) => `${canonicalString(k)}:${canonicalJson(o[k])}`).join(",")}}`;
108
- }
109
- default:
110
- throw effectInvalid(`a ${typeof v} cannot be digested`);
111
- }
112
- }
113
- /** The executable core of a plan — the six digested keys, nothing else. */
27
+ export { canonicalJson } from "./canonical.js";
28
+ /** The executable core of a plan — the six digested keys (seven with `selection`), nothing else. */
114
29
  export function planCore(p) {
115
- return { version: p.version, planner_v: p.planner_v, effects: p.effects, opset: p.opset, preconditions: p.preconditions, new_ids: p.new_ids };
30
+ return {
31
+ version: p.version, planner_v: p.planner_v, effects: p.effects, opset: p.opset, preconditions: p.preconditions, new_ids: p.new_ids,
32
+ ...(p.selection !== undefined ? { selection: p.selection } : {}),
33
+ };
116
34
  }
117
35
  /** `plan_id = hex(sha256(canonical(core)))` (§11.5). */
118
36
  export function planDigest(core) {
@@ -141,7 +59,28 @@ export function kernelOp(o) {
141
59
  * freeze the preconditions, digest the core. Reads only; mints no real id.
142
60
  */
143
61
  export function planEffects(store, req) {
144
- const effects = validateEffects(req.effects);
62
+ // A selector request (§11.8): the templates compile into the concrete effects
63
+ // — hits × templates — and the frozen `selection` joins the core; an empty
64
+ // selection is an empty plan (the non-empty rule of §11.1 is the templates').
65
+ let effects;
66
+ let selection;
67
+ let templates;
68
+ let origins = [];
69
+ if (req.select !== undefined || req.expect !== undefined) {
70
+ if (req.select === undefined)
71
+ throw selectionInvalid("`expect` bounds a selection — pass `select` with it");
72
+ const compiled = compileSelection(store, req.repoId, req.select, req.expect, req.effects);
73
+ effects = compiled.effects;
74
+ selection = compiled.selection;
75
+ origins = compiled.origins;
76
+ }
77
+ else {
78
+ // (3.2) the `-> ` sugar and expressions over each effect's own target row; without any, the shape check alone.
79
+ const compiled = compilePlain(store, req.repoId, req.effects);
80
+ effects = compiled.effects;
81
+ templates = compiled.templates;
82
+ origins = compiled.origins;
83
+ }
145
84
  const docStore = resolveDocStore(req);
146
85
  const symbols = new SymbolTable();
147
86
  return withIdMinter(symbolicMinter(symbols), () => {
@@ -150,16 +89,27 @@ export function planEffects(store, req) {
150
89
  store, repoId: req.repoId, docStore, planner, symbols,
151
90
  blockSymbols: new Map(), creates: [], identity: new Map(), retargets: [], moves: [], tombstones: [], heads: new Map(), ops: [],
152
91
  pins: new Map(), parents: [], absentPaths: [], docBases: new Map(),
153
- tracker: new ConflictTracker(canonicalJson), canonical: canonicalJson, resolved: [],
92
+ tracker: new ConflictTracker(canonicalJson), canonical: canonicalJson, resolved: [], summary: [],
154
93
  };
155
94
  effects.forEach((e, i) => {
156
95
  try {
157
96
  lowerEffect(ctx, i, e);
158
97
  }
159
98
  catch (err) {
160
- // Every failure names the effect it arose from (the kernel's own data — `op_index`, `block` — stays).
161
- if (err instanceof MutationError && err.data.effect_index === undefined)
162
- err.data = { ...err.data, effect_index: i };
99
+ // Every failure names the effect it arose from (the kernel's own data — `op_index`, `block` — stays);
100
+ // an expanded effect's names its template and the hit it was bound to as well (§11.8).
101
+ if (err instanceof MutationError) {
102
+ if (err.data.effect_index === undefined)
103
+ err.data = { ...err.data, effect_index: i };
104
+ const origin = origins[i];
105
+ if (origin !== undefined && err.data.row === undefined) {
106
+ err.data = { ...err.data, ...(origin.template_index !== undefined ? { template_index: origin.template_index } : {}), row: origin.row };
107
+ }
108
+ // (3.3) the corrected example, built from the request as given (§8 "fix").
109
+ const fix = planningFix(err, i, req, origins, symbols);
110
+ if (fix !== undefined && err.data.fix === undefined)
111
+ err.data = { ...err.data, fix };
112
+ }
163
113
  throw err;
164
114
  }
165
115
  });
@@ -182,7 +132,7 @@ export function planEffects(store, req) {
182
132
  parents: ctx.parents,
183
133
  absent_paths: ctx.absentPaths,
184
134
  };
185
- const core = { version: PLAN_VERSION, planner_v: PLANNER_V, effects: ctx.resolved, opset, preconditions, new_ids: symbols.entries };
135
+ const core = { version: PLAN_VERSION, planner_v: PLANNER_V, effects: ctx.resolved, opset, preconditions, new_ids: symbols.entries, ...(selection !== undefined ? { selection } : {}) };
186
136
  const plan_id = planDigest(core);
187
137
  // The preview (not digested): the kernel results, every revision the commit
188
138
  // would record — a created document's under its symbol, from its adopted
@@ -205,14 +155,73 @@ export function planEffects(store, req) {
205
155
  if (moved)
206
156
  r.path = moved.to;
207
157
  }
208
- const preview = { results: changeset.results, revisions, diffs };
158
+ const preview = { results: changeset.results, revisions, diffs, summary: ctx.summary };
209
159
  if (ctx.identity.size > 0)
210
160
  preview.identity = Object.fromEntries(ctx.identity);
211
161
  if (ctx.retargets.length > 0)
212
162
  preview.retarget = ctx.retargets;
213
- return { ...core, plan_id, diagnostics: ctx.tracker.diagnostics, preview };
163
+ return { ...core, plan_id, diagnostics: ctx.tracker.diagnostics, ...(templates !== undefined ? { templates } : {}), preview };
214
164
  });
215
165
  }
166
+ // ---- the corrected examples of planning errors (§8 "fix", 3.3) ------------------------------
167
+ /** The request as the caller gave it, for a `fix`: `select`/`expect` only when present. */
168
+ function requestOf(req) {
169
+ return { effects: req.effects, ...(req.select !== undefined ? { select: req.select } : {}), ...(req.expect !== undefined ? { expect: req.expect } : {}) };
170
+ }
171
+ /**
172
+ * `conflicting_effects`: keep the later effect — the request minus the earlier
173
+ * one; under a selector, a cross-hit collision narrows the selector to the
174
+ * later hit (`from <root> where id == …`, `expect.max: 1`) and a same-hit
175
+ * collision drops the earlier template. `stale_plan` at plan time (an
176
+ * `expect.revision` that no longer holds): the same request with the current
177
+ * revision in that guard. `target_missing { symbol }` for a `$new.n` no earlier
178
+ * effect created: the next `create` of a plain request moved before the effect
179
+ * that uses it, the symbol renamed to the one that create will allocate.
180
+ */
181
+ function planningFix(err, i, req, origins, symbols) {
182
+ const effects = Array.isArray(req.effects) ? req.effects : null;
183
+ if (effects === null)
184
+ return undefined;
185
+ const selector = req.select !== undefined;
186
+ if (err.code === "conflicting_effects" && Array.isArray(err.data.effects)) {
187
+ const [a, b] = err.data.effects;
188
+ if (!selector)
189
+ return fixRequest({ effects: effects.filter((_, k) => k !== a) });
190
+ const oa = origins[a];
191
+ const ob = origins[b];
192
+ if (oa === undefined || ob === undefined || oa.template_index === undefined)
193
+ return undefined;
194
+ if (oa.row.id !== ob.row.id)
195
+ return fixRequest({ select: narrowedTo(rootOfId(ob.row.id), ob.row.id), effects, expect: { max: 1 } });
196
+ return fixRequest({ ...requestOf(req), effects: effects.filter((_, k) => k !== oa.template_index) });
197
+ }
198
+ if (err.code === "stale_plan" && isRecord(err.data.current) && typeof err.data.current.revision === "string") {
199
+ const t = selector ? origins[i]?.template_index : i;
200
+ if (t === undefined || !isRecord(effects[t]))
201
+ return undefined;
202
+ const e = effects[t];
203
+ const expect = { ...(isRecord(e.expect) ? e.expect : {}), revision: err.data.current.revision };
204
+ return fixRequest({ ...requestOf(req), effects: effects.map((x, k) => (k === t ? { ...e, expect } : x)) });
205
+ }
206
+ if (err.code === "target_missing" && typeof err.data.symbol === "string" && !selector && symbols.kindOf(err.data.symbol) === undefined) {
207
+ const c = effects.findIndex((e, k) => k > i && isRecord(e) && e.op === "create");
208
+ if (c < 0)
209
+ return undefined;
210
+ const symbol = `$new.${symbols.entries.length}`;
211
+ const reordered = effects.filter((_, k) => k !== c);
212
+ reordered.splice(i, 0, effects[c]);
213
+ return fixRequest({ effects: reordered.map((e, k) => (k === i + 1 ? substituteSymbols(e, { [err.data.symbol]: symbol }) : e)) });
214
+ }
215
+ return undefined;
216
+ }
217
+ /** The `fix` of a plan that cannot be executed as it stands (`stale_plan`, `plan_mismatch`): re-plan and commit in one call — `apply` over the plan's selector form when it has one, else over its concrete effects (none when the plan has no effects: `apply` takes at least one). */
218
+ function replanFix(plan) {
219
+ if (plan.selection !== undefined) {
220
+ const sel = plan.selection;
221
+ return fixRequest({ select: sel.query, effects: sel.templates, expect: sel.expect ?? { max: sel.cardinality } });
222
+ }
223
+ return Array.isArray(plan.effects) && plan.effects.length > 0 ? fixRequest({ effects: plan.effects }) : undefined;
224
+ }
216
225
  function isRecord(v) {
217
226
  return typeof v === "object" && v !== null && !Array.isArray(v);
218
227
  }
@@ -256,12 +265,24 @@ export function admitPlan(input) {
256
265
  }
257
266
  if (!Array.isArray(input.new_ids))
258
267
  throw effectInvalid("`plan.new_ids` must be an array");
268
+ if (input.selection !== undefined) {
269
+ // (3.1) the optional seventh member: its shape, so the digest below covers what it says.
270
+ const sel = input.selection;
271
+ if (!isRecord(sel) || typeof sel.query !== "string" || !Array.isArray(sel.templates) || typeof sel.cardinality !== "number" || !Array.isArray(sel.identities)) {
272
+ throw effectInvalid("`plan.selection` is { query, expect?, templates, cardinality, identities }");
273
+ }
274
+ if (sel.expect !== undefined && (!isRecord(sel.expect) || typeof sel.expect.max !== "number"))
275
+ throw effectInvalid("`plan.selection.expect` is { max }");
276
+ }
259
277
  if (typeof input.plan_id !== "string")
260
278
  throw effectInvalid("`plan.plan_id` must be a string");
279
+ // (3.2) the written effects ride outside the digest, like `diagnostics`; their shape is all that is checked.
280
+ if (input.templates !== undefined && !Array.isArray(input.templates))
281
+ throw effectInvalid("`plan.templates` must be an array");
261
282
  const plan = input;
262
283
  const actual = planDigest(plan);
263
284
  if (actual !== plan.plan_id) {
264
- throw new MutationError("plan_mismatch", "the plan's digest does not match its plan_id; it was edited or produced by a different planner — re-plan", { expected: plan.plan_id, actual });
285
+ throw new MutationError("plan_mismatch", "the plan's digest does not match its plan_id; it was edited or produced by a different planner — re-plan", { expected: plan.plan_id, actual, fix: replanFix(plan) });
265
286
  }
266
287
  return plan;
267
288
  }
@@ -271,13 +292,17 @@ export function executePlan(store, req) {
271
292
  const docStore = resolveDocStore(req);
272
293
  return underLock(req, () => executeAdmitted(store, req, plan, docStore));
273
294
  }
274
- /** `execute { plan_id, effects }` (§11.7): re-plan; the digest must equal `plan_id`, else `plan_mismatch`. */
295
+ /** The selector half of a request, threaded to the planner as given (absent keys stay absent). */
296
+ function selectorOf(req) {
297
+ return { ...(req.select !== undefined ? { select: req.select } : {}), ...(req.expect !== undefined ? { expect: req.expect } : {}) };
298
+ }
299
+ /** `execute { plan_id, effects, select?, expect? }` (§11.7): re-plan (with the selector when one is given, §11.8); the digest must equal `plan_id`, else `plan_mismatch`. */
275
300
  export function executeByDigest(store, req) {
276
301
  const docStore = resolveDocStore(req);
277
302
  return underLock(req, () => {
278
- const plan = planEffects(store, { repoId: req.repoId, docStore, ...(req.omgbaseDir !== undefined ? { omgbaseDir: req.omgbaseDir } : {}), effects: req.effects });
303
+ const plan = planEffects(store, { repoId: req.repoId, docStore, ...(req.omgbaseDir !== undefined ? { omgbaseDir: req.omgbaseDir } : {}), effects: req.effects, ...selectorOf(req) });
279
304
  if (plan.plan_id !== req.planId) {
280
- throw new MutationError("plan_mismatch", "the effects or the documents they target changed since the plan was made; re-plan", { expected: req.planId, actual: plan.plan_id });
305
+ throw new MutationError("plan_mismatch", "the effects or the documents they target changed since the plan was made; re-plan", { expected: req.planId, actual: plan.plan_id, fix: fixRequest(requestOf(req)) });
281
306
  }
282
307
  return executeAdmitted(store, req, plan, docStore);
283
308
  });
@@ -291,15 +316,23 @@ export function executeByDigest(store, req) {
291
316
  */
292
317
  export function applyEffectsDetailed(store, req) {
293
318
  const docStore = resolveDocStore(req);
294
- const planIt = () => planEffects(store, { repoId: req.repoId, docStore, ...(req.omgbaseDir !== undefined ? { omgbaseDir: req.omgbaseDir } : {}), effects: req.effects });
319
+ const planIt = () => planEffects(store, { repoId: req.repoId, docStore, ...(req.omgbaseDir !== undefined ? { omgbaseDir: req.omgbaseDir } : {}), effects: req.effects, ...selectorOf(req) });
295
320
  if (req.dryRun)
296
321
  return { plan: planIt() };
322
+ // One-shot `apply` with a selector must bound it (§11.8): the caller never saw
323
+ // the hits. The fix (3.3) is the same call bounded at the selection's actual
324
+ // size — the selector compiles (reads only) to count it; a selection the
325
+ // compile itself refuses reports its own error and fix.
326
+ if (req.select !== undefined && req.expect === undefined) {
327
+ const { selection } = compileSelection(store, req.repoId, req.select, undefined, req.effects);
328
+ throw selectionInvalid("`expect.max` is required on `apply` with `select` — bound the selection, or preview it with `plan`", { fix: fixRequest({ select: req.select, effects: req.effects, expect: { max: selection.cardinality } }) });
329
+ }
297
330
  return underLock(req, () => {
298
331
  const plan = planIt();
299
332
  return { plan, receipt: executeAdmitted(store, req, plan, docStore) };
300
333
  });
301
334
  }
302
- /** `apply { effects }` (§11.7): plan + execute under one lock and one transaction; `dryRun` returns the Plan. */
335
+ /** `apply { effects, select?, expect? }` (§11.7): plan + execute under one lock and one transaction; `dryRun` returns the Plan. */
303
336
  export function applyEffects(store, req) {
304
337
  const { plan, receipt } = applyEffectsDetailed(store, req);
305
338
  return receipt ?? plan;
@@ -347,7 +380,9 @@ function underLock(req, fn) {
347
380
  * `ingest_known { base: null }`; a moved document's revision lands at its new
348
381
  * path), tombstones — and `commitChangeset` re-validates every triple inside
349
382
  * `BEGIN IMMEDIATE`. The commit row's `ops` is the intent record
350
- * `canonical({ v: 1, plan_id, effects })`.
383
+ * `canonical({ v: 1, plan_id, effects, selection?, templates? })` (§11.8: the
384
+ * selector, its templates and the hits it bound ride along when the plan had
385
+ * one; a plain request's written effects when one carried an expression, 3.2).
351
386
  */
352
387
  function executeAdmitted(store, req, plan, docStore) {
353
388
  for (const d of plan.preconditions.docs) {
@@ -360,6 +395,7 @@ function executeAdmitted(store, req, plan, docStore) {
360
395
  expected_revision: d.base_revision,
361
396
  current: { revision: row.currentRev, hash: row.fileHash },
362
397
  retriable: true,
398
+ fix: replanFix(plan),
363
399
  });
364
400
  }
365
401
  }
@@ -375,7 +411,11 @@ function executeAdmitted(store, req, plan, docStore) {
375
411
  origin: "api",
376
412
  actor: req.origin.actor,
377
413
  reason: req.origin.reason ?? null,
378
- ops: canonicalJson({ v: 1, plan_id: plan.plan_id, effects: plan.effects }),
414
+ ops: canonicalJson({
415
+ v: 1, plan_id: plan.plan_id, effects: plan.effects,
416
+ ...(plan.selection !== undefined ? { selection: plan.selection } : {}),
417
+ ...(plan.templates !== undefined ? { templates: plan.templates } : {}),
418
+ }),
379
419
  };
380
420
  const stat = req.omgbaseDir !== undefined;
381
421
  const moved = new Map(plan.opset.moves.map((m) => [m.id, m]));
@@ -467,6 +507,6 @@ function executeAdmitted(store, req, plan, docStore) {
467
507
  if (id !== undefined)
468
508
  newIds[s.symbol] = id;
469
509
  }
470
- return { plan_id: plan.plan_id, commit: outcome.commitId, revisions, new_ids: newIds, committed: true };
510
+ return { plan_id: plan.plan_id, commit: outcome.commitId, revisions, new_ids: newIds, committed: true, summary: plan.preview?.summary ?? [] };
471
511
  }
472
512
  //# sourceMappingURL=plan.js.map