@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.
- package/dist/src/core/ingest.d.ts +2 -0
- package/dist/src/core/ingest.js +13 -0
- package/dist/src/core/read/document.d.ts +9 -8
- package/dist/src/core/read/document.js +0 -2
- package/dist/src/core/store/gc.d.ts +13 -1
- package/dist/src/core/store/gc.js +24 -5
- package/dist/src/core/store/size.d.ts +79 -0
- package/dist/src/core/store/size.js +232 -0
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.js +5 -1
- package/dist/src/mcp/arguments.d.ts +10 -2
- package/dist/src/mcp/arguments.js +37 -3
- package/dist/src/mcp/errors.d.ts +1 -1
- package/dist/src/mcp/reference.d.ts +1 -1
- package/dist/src/mcp/reference.js +63 -10
- package/dist/src/mcp/server.d.ts +34 -1
- package/dist/src/mcp/server.js +158 -46
- package/dist/src/mutate/canonical.d.ts +27 -0
- package/dist/src/mutate/canonical.js +138 -0
- package/dist/src/mutate/effects.d.ts +58 -8
- package/dist/src/mutate/effects.js +312 -43
- package/dist/src/mutate/known-ids.d.ts +19 -0
- package/dist/src/mutate/known-ids.js +47 -7
- package/dist/src/mutate/ops.d.ts +1 -0
- package/dist/src/mutate/ops.js +20 -1
- package/dist/src/mutate/plan.d.ts +26 -16
- package/dist/src/mutate/plan.js +152 -112
- package/dist/src/mutate/select.d.ts +78 -0
- package/dist/src/mutate/select.js +903 -0
- package/dist/src/mutate/tree.d.ts +2 -2
- package/dist/src/oqx-js/context.d.ts +9 -2
- package/dist/src/oqx-js/context.js +74 -28
- package/dist/src/oqx-js/names.d.ts +40 -0
- package/dist/src/oqx-js/names.js +347 -0
- package/dist/src/oqx-js/planner.d.ts +3 -3
- package/dist/src/oqx-js/planner.js +17 -9
- package/dist/src/oqx-js/root-row.d.ts +15 -4
- package/dist/src/oqx-js/root-row.js +18 -10
- package/dist/src/oqx-js/run.d.ts +45 -1
- package/dist/src/oqx-js/run.js +114 -32
- package/dist/src/oqx-js/sql/translate.js +1 -1
- package/dist/src/spec-versions.d.ts +5 -5
- package/dist/src/spec-versions.js +4 -4
- package/dist/src/sync/observe.js +9 -3
- package/dist/src/version.d.ts +7 -2
- package/dist/src/version.js +5 -2
- 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
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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) {
|
package/dist/src/mutate/ops.d.ts
CHANGED
|
@@ -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[];
|
package/dist/src/mutate/ops.js
CHANGED
|
@@ -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: "
|
|
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
|
-
|
|
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`. */
|
package/dist/src/mutate/plan.js
CHANGED
|
@@ -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,
|
|
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 = "
|
|
25
|
+
export const PLANNER_V = "p3";
|
|
22
26
|
// ---- canonical JSON and the digest (§11.5) ----------------------------------------------
|
|
23
|
-
|
|
24
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
/**
|
|
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({
|
|
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
|