@omgbase/core 0.11.0 → 0.15.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/README.md +1 -1
- package/dist/src/core/ids.d.ts +4 -0
- package/dist/src/core/ids.js +17 -0
- package/dist/src/core/ingest.d.ts +39 -3
- package/dist/src/core/ingest.js +233 -202
- package/dist/src/core/store/schema.d.ts +3 -2
- package/dist/src/core/store/schema.js +28 -3
- package/dist/src/core/store/store.d.ts +9 -0
- package/dist/src/core/store/store.js +11 -0
- package/dist/src/graph/history.js +15 -12
- package/dist/src/index.d.ts +9 -5
- package/dist/src/index.js +14 -7
- package/dist/src/mcp/arguments.d.ts +9 -2
- package/dist/src/mcp/arguments.js +15 -2
- package/dist/src/mcp/errors.d.ts +1 -1
- package/dist/src/mcp/errors.js +3 -0
- package/dist/src/mcp/reference.d.ts +1 -1
- package/dist/src/mcp/reference.js +9 -9
- package/dist/src/mcp/server.d.ts +22 -0
- package/dist/src/mcp/server.js +263 -186
- package/dist/src/mutate/apply.d.ts +93 -12
- package/dist/src/mutate/apply.js +203 -113
- package/dist/src/mutate/commit.d.ts +150 -0
- package/dist/src/mutate/commit.js +349 -0
- package/dist/src/mutate/doc-store.d.ts +3 -3
- package/dist/src/mutate/doc-store.js +21 -6
- package/dist/src/mutate/docs.d.ts +42 -87
- package/dist/src/mutate/docs.js +50 -204
- package/dist/src/mutate/effects.d.ts +331 -0
- package/dist/src/mutate/effects.js +981 -0
- package/dist/src/mutate/materialize.d.ts +49 -0
- package/dist/src/mutate/materialize.js +174 -0
- package/dist/src/mutate/opset.d.ts +27 -2
- package/dist/src/mutate/opset.js +12 -5
- package/dist/src/mutate/plan-update.d.ts +1 -46
- package/dist/src/mutate/plan-update.js +19 -67
- package/dist/src/mutate/plan.d.ts +200 -0
- package/dist/src/mutate/plan.js +472 -0
- package/dist/src/mutate/sugar.d.ts +135 -0
- package/dist/src/mutate/sugar.js +167 -0
- package/dist/src/mutate/tree.d.ts +4 -2
- package/dist/src/oqx-js/run.js +1 -1
- package/dist/src/spec-versions.d.ts +5 -5
- package/dist/src/spec-versions.js +5 -5
- package/dist/src/surface-paths.d.ts +17 -26
- package/dist/src/surface-paths.js +27 -22
- package/dist/src/sync/checkpoint.d.ts +5 -2
- package/dist/src/sync/checkpoint.js +10 -6
- package/dist/src/sync/freshness.d.ts +10 -7
- package/dist/src/sync/freshness.js +15 -17
- package/dist/src/sync/observe.d.ts +4 -1
- package/dist/src/sync/observe.js +56 -5
- package/dist/src/sync/recovery.d.ts +4 -0
- package/dist/src/sync/recovery.js +4 -1
- package/dist/src/sync/stat-cache.d.ts +7 -0
- package/dist/src/sync/stat-cache.js +22 -0
- package/dist/src/sync/tombstone.js +23 -19
- package/dist/src/sync/watcher.d.ts +2 -0
- package/dist/src/sync/watcher.js +4 -1
- package/dist/src/sync/writer-lock.d.ts +15 -0
- package/dist/src/sync/writer-lock.js +104 -17
- package/package.json +1 -1
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type { Store } from "../core/store/store.js";
|
|
2
|
+
import { type ApplyRevision, type Op, type OpResult } from "./apply.js";
|
|
3
|
+
import { type DocStore } from "./doc-store.js";
|
|
4
|
+
import { type CreateEntry, type ResolvedEffect, type RetargetPreview } from "./effects.js";
|
|
5
|
+
import type { IdentityPreview } from "./opset.js";
|
|
6
|
+
export declare const PLAN_VERSION: 1;
|
|
7
|
+
/** The lowering + conflict rules version (§11.4; the `matcher_v` precedent). */
|
|
8
|
+
export declare const PLANNER_V: "p2";
|
|
9
|
+
/** A kernel op as the plan spells it on the wire: the fixture spelling (`child_ids`). */
|
|
10
|
+
export type WireOp = Exclude<Op, {
|
|
11
|
+
op: "update";
|
|
12
|
+
}> | {
|
|
13
|
+
op: "update";
|
|
14
|
+
block: string;
|
|
15
|
+
markdown?: string;
|
|
16
|
+
attrs?: Record<string, unknown>;
|
|
17
|
+
expect?: {
|
|
18
|
+
content_hash?: string;
|
|
19
|
+
parent_children_hash?: string;
|
|
20
|
+
};
|
|
21
|
+
trivia?: string;
|
|
22
|
+
child_ids?: Record<string, string>;
|
|
23
|
+
};
|
|
24
|
+
/** The lowered form `execute` replays (§11.4): every key present, `[]` when empty. */
|
|
25
|
+
export interface PlanOpset {
|
|
26
|
+
/** `create` → the composed file, its document symbol and the index of the first kernel op planned after it (3.0; a later `set frontmatter.<k>` on the symbol is a head below) */
|
|
27
|
+
creates: CreateEntry[];
|
|
28
|
+
/** `move document` → the row/file rename (its retarget updates are in `ops`) */
|
|
29
|
+
moves: {
|
|
30
|
+
id: string;
|
|
31
|
+
from: string;
|
|
32
|
+
to: string;
|
|
33
|
+
}[];
|
|
34
|
+
/** `remove document` */
|
|
35
|
+
tombstones: {
|
|
36
|
+
id: string;
|
|
37
|
+
path: string;
|
|
38
|
+
}[];
|
|
39
|
+
/** per document, the file head — everything before the first block (frontmatter incl. fences and separator) */
|
|
40
|
+
set_frontmatter: {
|
|
41
|
+
doc: string;
|
|
42
|
+
raw: string;
|
|
43
|
+
}[];
|
|
44
|
+
/** the kernel ops, in order; new structure is referenced by kernel placeholders `$k.ids[j]` */
|
|
45
|
+
ops: WireOp[];
|
|
46
|
+
}
|
|
47
|
+
export interface PlanPreconditions {
|
|
48
|
+
/** the row triples (`spec/mutate` §4 step 5) of every document the plan touches, loaded ones first in load order */
|
|
49
|
+
docs: {
|
|
50
|
+
id: string;
|
|
51
|
+
path: string;
|
|
52
|
+
base_revision: string | null;
|
|
53
|
+
hash: string | null;
|
|
54
|
+
}[];
|
|
55
|
+
/** every block content hash the plan pins (caller-given or pinned from the live row), first-pin order */
|
|
56
|
+
blocks: {
|
|
57
|
+
id: string;
|
|
58
|
+
content_hash: string;
|
|
59
|
+
}[];
|
|
60
|
+
/** the caller's destination-parent CAS pins */
|
|
61
|
+
parents: {
|
|
62
|
+
doc: string;
|
|
63
|
+
parent: string | null;
|
|
64
|
+
parent_children_hash: string;
|
|
65
|
+
}[];
|
|
66
|
+
/** paths that must have no live document and no file: `create` paths and move destinations */
|
|
67
|
+
absent_paths: string[];
|
|
68
|
+
}
|
|
69
|
+
export interface PlanPreview {
|
|
70
|
+
results: OpResult[];
|
|
71
|
+
revisions: ApplyRevision[];
|
|
72
|
+
diffs: Record<string, {
|
|
73
|
+
before: string;
|
|
74
|
+
after: string;
|
|
75
|
+
}>;
|
|
76
|
+
/** (3.0) per `set content` document: the whole-document planner's identity consequences; present when a plan has one */
|
|
77
|
+
identity?: Record<string, IdentityPreview>;
|
|
78
|
+
/** (3.0) per `retarget` effect: its hits and pair counts; present when a plan has one */
|
|
79
|
+
retarget?: RetargetPreview[];
|
|
80
|
+
}
|
|
81
|
+
/** The executable core the digest covers. */
|
|
82
|
+
export interface PlanCore {
|
|
83
|
+
version: typeof PLAN_VERSION;
|
|
84
|
+
planner_v: string;
|
|
85
|
+
effects: ResolvedEffect[];
|
|
86
|
+
opset: PlanOpset;
|
|
87
|
+
preconditions: PlanPreconditions;
|
|
88
|
+
new_ids: {
|
|
89
|
+
symbol: string;
|
|
90
|
+
kind: "block" | "document";
|
|
91
|
+
}[];
|
|
92
|
+
}
|
|
93
|
+
export interface Plan extends PlanCore {
|
|
94
|
+
plan_id: string;
|
|
95
|
+
diagnostics: string[];
|
|
96
|
+
/** excluded from the digest; omittable on echo */
|
|
97
|
+
preview?: PlanPreview;
|
|
98
|
+
}
|
|
99
|
+
export interface Receipt {
|
|
100
|
+
plan_id: string;
|
|
101
|
+
/** the one `commits` row (`null` when the plan had nothing to commit) */
|
|
102
|
+
commit: string | null;
|
|
103
|
+
revisions: ApplyRevision[];
|
|
104
|
+
/** `$new.n` → the minted id */
|
|
105
|
+
new_ids: Record<string, string>;
|
|
106
|
+
committed: true;
|
|
107
|
+
}
|
|
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. */
|
|
120
|
+
export declare function planCore(p: PlanCore): PlanCore;
|
|
121
|
+
/** `plan_id = hex(sha256(canonical(core)))` (§11.5). */
|
|
122
|
+
export declare function planDigest(core: PlanCore): string;
|
|
123
|
+
export interface PlanEffectsRequest {
|
|
124
|
+
repoId: string;
|
|
125
|
+
/** Working-tree root for the default doc store (the `path_taken` disk check; the move/remove previews). */
|
|
126
|
+
rootPath?: string;
|
|
127
|
+
docStore?: DocStore;
|
|
128
|
+
omgbaseDir?: string;
|
|
129
|
+
/** The effects as given (validated here: a shape error is `filter_invalid`). */
|
|
130
|
+
effects: unknown;
|
|
131
|
+
}
|
|
132
|
+
/** The kernel op as the plan carries it (`childIds` → `child_ids`). */
|
|
133
|
+
export declare function wireOp(o: Op): WireOp;
|
|
134
|
+
/** A plan's op back to the kernel's spelling. */
|
|
135
|
+
export declare function kernelOp(o: WireOp): Op;
|
|
136
|
+
/**
|
|
137
|
+
* Lower an ordered effect list into a frozen Plan (§11.6): under the symbolic
|
|
138
|
+
* minter, resolve and lower each effect in order (conflicts by focus kind,
|
|
139
|
+
* identical duplicates dropped with a diagnostic), render the touched documents,
|
|
140
|
+
* freeze the preconditions, digest the core. Reads only; mints no real id.
|
|
141
|
+
*/
|
|
142
|
+
export declare function planEffects(store: Store, req: PlanEffectsRequest): Plan;
|
|
143
|
+
export interface ExecuteRequest {
|
|
144
|
+
repoId: string;
|
|
145
|
+
rootPath?: string;
|
|
146
|
+
docStore?: DocStore;
|
|
147
|
+
omgbaseDir?: string;
|
|
148
|
+
/** the commit's `actor` (null when the caller has none — a fixture step without one) and `reason` */
|
|
149
|
+
origin: {
|
|
150
|
+
actor: string | null;
|
|
151
|
+
reason?: string;
|
|
152
|
+
};
|
|
153
|
+
/** Commit timestamp (RFC 3339 UTC); defaults to now (see `ApplyRequest.ts`). */
|
|
154
|
+
ts?: string;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The plan form's admission (§11.7): `version` first — anything but 1 is
|
|
158
|
+
* `unsupported_plan_version { version, supported: [1] }` — then the core's keys
|
|
159
|
+
* must be present (`filter_invalid`), then the recomputed digest must equal the
|
|
160
|
+
* embedded `plan_id` (`plan_mismatch { expected, actual }`). `preview` and
|
|
161
|
+
* `diagnostics` are ignored.
|
|
162
|
+
*/
|
|
163
|
+
export declare function admitPlan(input: unknown): Plan;
|
|
164
|
+
/** `execute { plan }` (§11.7): admit, then commit exactly that plan. */
|
|
165
|
+
export declare function executePlan(store: Store, req: ExecuteRequest & {
|
|
166
|
+
plan: unknown;
|
|
167
|
+
}): Receipt;
|
|
168
|
+
/** `execute { plan_id, effects }` (§11.7): re-plan; the digest must equal `plan_id`, else `plan_mismatch`. */
|
|
169
|
+
export declare function executeByDigest(store: Store, req: ExecuteRequest & {
|
|
170
|
+
planId: string;
|
|
171
|
+
effects: unknown;
|
|
172
|
+
}): Receipt;
|
|
173
|
+
/**
|
|
174
|
+
* `apply { effects }` with both halves kept (the sugar's seam — `sugar.ts`,
|
|
175
|
+
* the `omg` verbs, the whole-document update): the Plan always, and the
|
|
176
|
+
* Receipt when it committed (`dryRun` plans only — the CLI's `--dry-run`,
|
|
177
|
+
* spec/cli §3.6). Plan + execute run under one lock and one transaction,
|
|
178
|
+
* exactly as `applyEffects`.
|
|
179
|
+
*/
|
|
180
|
+
export declare function applyEffectsDetailed(store: Store, req: ExecuteRequest & {
|
|
181
|
+
effects: unknown;
|
|
182
|
+
dryRun?: boolean;
|
|
183
|
+
}): {
|
|
184
|
+
plan: Plan;
|
|
185
|
+
receipt?: Receipt;
|
|
186
|
+
};
|
|
187
|
+
/** `apply { effects }` (§11.7): plan + execute under one lock and one transaction; `dryRun` returns the Plan. */
|
|
188
|
+
export declare function applyEffects(store: Store, req: ExecuteRequest & {
|
|
189
|
+
effects: unknown;
|
|
190
|
+
dryRun?: boolean;
|
|
191
|
+
}): Plan | Receipt;
|
|
192
|
+
/** 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`. */
|
|
193
|
+
export declare function substituteSymbols<T>(value: T, ids: Record<string, string>): T;
|
|
194
|
+
/**
|
|
195
|
+
* A planner error as the sugar reports it (`sugar.ts` `applySugar`): the
|
|
196
|
+
* `effect_index` the planner adds (§11.6) is dropped — the tool or verb built
|
|
197
|
+
* the effects itself, so the index names nothing the caller sent and the
|
|
198
|
+
* kernel's data (`op_index`, `block`, `current`, …) stays as pinned.
|
|
199
|
+
*/
|
|
200
|
+
export declare function stripEffectIndex(err: unknown): unknown;
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { sha256 } from "../core/hash.js";
|
|
2
|
+
import { observeMints, withIdMinter } from "../core/ids.js";
|
|
3
|
+
import { findDoc } from "../core/read/reader.js";
|
|
4
|
+
import { inboundLinksTo } from "../graph/inbound-links.js";
|
|
5
|
+
import { withWriterLock } from "../sync/writer-lock.js";
|
|
6
|
+
import { ChangesetPlanner } from "./apply.js";
|
|
7
|
+
import { commitChangeset, readDocBase } from "./commit.js";
|
|
8
|
+
import { resolveDocStore } from "./doc-store.js";
|
|
9
|
+
import { parseNewDoc } from "./docs.js";
|
|
10
|
+
import { ConflictTracker, SymbolTable, isNewSymbol, lowerEffect, symbolicMinter, validateEffects, effectInvalid, } from "./effects.js";
|
|
11
|
+
import { MutationError } from "./tree.js";
|
|
12
|
+
// The Plan (spec/mutate §11.4–§11.7, 2.3): the frozen, executable, digest-named
|
|
13
|
+
// result of lowering an ordered effect list. `plan` writes nothing; `execute`
|
|
14
|
+
// commits exactly a plan — its preconditions re-validated inside the
|
|
15
|
+
// transaction by `commit.ts` — and `apply({ effects })` is plan + execute under
|
|
16
|
+
// one writer lock and one transaction. New identities are symbolic (`$new.n`,
|
|
17
|
+
// minted at execute, mapped in the receipt); a plan's identity is the sha256 of
|
|
18
|
+
// the canonical JSON of its executable core.
|
|
19
|
+
export const PLAN_VERSION = 1;
|
|
20
|
+
/** The lowering + conflict rules version (§11.4; the `matcher_v` precedent). */
|
|
21
|
+
export const PLANNER_V = "p2";
|
|
22
|
+
// ---- 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. */
|
|
114
|
+
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 };
|
|
116
|
+
}
|
|
117
|
+
/** `plan_id = hex(sha256(canonical(core)))` (§11.5). */
|
|
118
|
+
export function planDigest(core) {
|
|
119
|
+
return sha256(canonicalJson(planCore(core))).toString("hex");
|
|
120
|
+
}
|
|
121
|
+
/** The kernel op as the plan carries it (`childIds` → `child_ids`). */
|
|
122
|
+
export function wireOp(o) {
|
|
123
|
+
if (o.op === "update") {
|
|
124
|
+
const { childIds, ...rest } = o;
|
|
125
|
+
return { ...rest, ...(childIds !== undefined ? { child_ids: childIds } : {}) };
|
|
126
|
+
}
|
|
127
|
+
return o;
|
|
128
|
+
}
|
|
129
|
+
/** A plan's op back to the kernel's spelling. */
|
|
130
|
+
export function kernelOp(o) {
|
|
131
|
+
if (o.op === "update") {
|
|
132
|
+
const { child_ids, ...rest } = o;
|
|
133
|
+
return { ...rest, ...(child_ids !== undefined ? { childIds: child_ids } : {}) };
|
|
134
|
+
}
|
|
135
|
+
return o;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Lower an ordered effect list into a frozen Plan (§11.6): under the symbolic
|
|
139
|
+
* minter, resolve and lower each effect in order (conflicts by focus kind,
|
|
140
|
+
* identical duplicates dropped with a diagnostic), render the touched documents,
|
|
141
|
+
* freeze the preconditions, digest the core. Reads only; mints no real id.
|
|
142
|
+
*/
|
|
143
|
+
export function planEffects(store, req) {
|
|
144
|
+
const effects = validateEffects(req.effects);
|
|
145
|
+
const docStore = resolveDocStore(req);
|
|
146
|
+
const symbols = new SymbolTable();
|
|
147
|
+
return withIdMinter(symbolicMinter(symbols), () => {
|
|
148
|
+
const planner = new ChangesetPlanner(store, req.repoId, req.omgbaseDir);
|
|
149
|
+
const ctx = {
|
|
150
|
+
store, repoId: req.repoId, docStore, planner, symbols,
|
|
151
|
+
blockSymbols: new Map(), creates: [], identity: new Map(), retargets: [], moves: [], tombstones: [], heads: new Map(), ops: [],
|
|
152
|
+
pins: new Map(), parents: [], absentPaths: [], docBases: new Map(),
|
|
153
|
+
tracker: new ConflictTracker(canonicalJson), canonical: canonicalJson, resolved: [],
|
|
154
|
+
};
|
|
155
|
+
effects.forEach((e, i) => {
|
|
156
|
+
try {
|
|
157
|
+
lowerEffect(ctx, i, e);
|
|
158
|
+
}
|
|
159
|
+
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 };
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
const changeset = planner.finish();
|
|
167
|
+
const opset = {
|
|
168
|
+
creates: ctx.creates,
|
|
169
|
+
moves: ctx.moves.map((m) => ({ id: m.id, from: m.from, to: m.to })),
|
|
170
|
+
tombstones: ctx.tombstones.map((t) => ({ id: t.id, path: t.path })),
|
|
171
|
+
set_frontmatter: [...ctx.heads].map(([doc, raw]) => ({ doc, raw })),
|
|
172
|
+
ops: ctx.ops.map(wireOp),
|
|
173
|
+
};
|
|
174
|
+
const docs = changeset.preconditions.map((p) => ({ id: p.id, path: p.path, base_revision: p.currentRev, hash: p.fileHash }));
|
|
175
|
+
for (const [id, base] of ctx.docBases) {
|
|
176
|
+
if (!docs.some((d) => d.id === id))
|
|
177
|
+
docs.push({ id, path: base.path, base_revision: base.currentRev, hash: base.fileHash });
|
|
178
|
+
}
|
|
179
|
+
const preconditions = {
|
|
180
|
+
docs,
|
|
181
|
+
blocks: [...ctx.pins].map(([id, content_hash]) => ({ id, content_hash })),
|
|
182
|
+
parents: ctx.parents,
|
|
183
|
+
absent_paths: ctx.absentPaths,
|
|
184
|
+
};
|
|
185
|
+
const core = { version: PLAN_VERSION, planner_v: PLANNER_V, effects: ctx.resolved, opset, preconditions, new_ids: symbols.entries };
|
|
186
|
+
const plan_id = planDigest(core);
|
|
187
|
+
// The preview (not digested): the kernel results, every revision the commit
|
|
188
|
+
// would record — a created document's under its symbol, from its adopted
|
|
189
|
+
// tree like any loaded document's — and the per-file diffs: a create
|
|
190
|
+
// `"" → bytes`, a move two entries (its retargeted bytes land at the new
|
|
191
|
+
// path), a removal `bytes → ""`.
|
|
192
|
+
const diffs = { ...changeset.diffs };
|
|
193
|
+
const revisions = [...changeset.revisions];
|
|
194
|
+
for (const m of ctx.moves) {
|
|
195
|
+
const bytes = docStore.read(m.from) ?? "";
|
|
196
|
+
const own = diffs[m.from];
|
|
197
|
+
delete diffs[m.from];
|
|
198
|
+
diffs[m.from] = { before: bytes, after: "" };
|
|
199
|
+
diffs[m.to] = { before: "", after: own?.after ?? bytes };
|
|
200
|
+
}
|
|
201
|
+
for (const t of ctx.tombstones)
|
|
202
|
+
diffs[t.path] = { before: docStore.read(t.path) ?? "", after: "" };
|
|
203
|
+
for (const r of revisions) {
|
|
204
|
+
const moved = ctx.moves.find((m) => m.id === r.id);
|
|
205
|
+
if (moved)
|
|
206
|
+
r.path = moved.to;
|
|
207
|
+
}
|
|
208
|
+
const preview = { results: changeset.results, revisions, diffs };
|
|
209
|
+
if (ctx.identity.size > 0)
|
|
210
|
+
preview.identity = Object.fromEntries(ctx.identity);
|
|
211
|
+
if (ctx.retargets.length > 0)
|
|
212
|
+
preview.retarget = ctx.retargets;
|
|
213
|
+
return { ...core, plan_id, diagnostics: ctx.tracker.diagnostics, preview };
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
function isRecord(v) {
|
|
217
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* The plan form's admission (§11.7): `version` first — anything but 1 is
|
|
221
|
+
* `unsupported_plan_version { version, supported: [1] }` — then the core's keys
|
|
222
|
+
* must be present (`filter_invalid`), then the recomputed digest must equal the
|
|
223
|
+
* embedded `plan_id` (`plan_mismatch { expected, actual }`). `preview` and
|
|
224
|
+
* `diagnostics` are ignored.
|
|
225
|
+
*/
|
|
226
|
+
export function admitPlan(input) {
|
|
227
|
+
if (!isRecord(input))
|
|
228
|
+
throw effectInvalid("`plan` must be an object");
|
|
229
|
+
if (input.version !== PLAN_VERSION) {
|
|
230
|
+
throw new MutationError("unsupported_plan_version", `plan version ${JSON.stringify(input.version)} is not supported (supported: ${PLAN_VERSION})`, { version: input.version ?? null, supported: [PLAN_VERSION] });
|
|
231
|
+
}
|
|
232
|
+
for (const k of ["planner_v", "effects", "opset", "preconditions", "new_ids", "plan_id"]) {
|
|
233
|
+
if (!(k in input))
|
|
234
|
+
throw effectInvalid(`\`plan.${k}\` is required`);
|
|
235
|
+
}
|
|
236
|
+
if (typeof input.planner_v !== "string")
|
|
237
|
+
throw effectInvalid("`plan.planner_v` must be a string");
|
|
238
|
+
if (!Array.isArray(input.effects))
|
|
239
|
+
throw effectInvalid("`plan.effects` must be an array");
|
|
240
|
+
if (!isRecord(input.opset))
|
|
241
|
+
throw effectInvalid("`plan.opset` must be an object");
|
|
242
|
+
for (const k of ["creates", "moves", "tombstones", "set_frontmatter", "ops"]) {
|
|
243
|
+
if (!Array.isArray(input.opset[k]))
|
|
244
|
+
throw effectInvalid(`\`plan.opset.${k}\` must be an array`);
|
|
245
|
+
}
|
|
246
|
+
for (const c of input.opset.creates) {
|
|
247
|
+
if (!isRecord(c) || typeof c.symbol !== "string" || typeof c.path !== "string" || typeof c.source !== "string" || typeof c.before_op !== "number") {
|
|
248
|
+
throw effectInvalid("`plan.opset.creates[]` entries are { symbol, path, source, before_op }");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (!isRecord(input.preconditions))
|
|
252
|
+
throw effectInvalid("`plan.preconditions` must be an object");
|
|
253
|
+
for (const k of ["docs", "blocks", "parents", "absent_paths"]) {
|
|
254
|
+
if (!Array.isArray(input.preconditions[k]))
|
|
255
|
+
throw effectInvalid(`\`plan.preconditions.${k}\` must be an array`);
|
|
256
|
+
}
|
|
257
|
+
if (!Array.isArray(input.new_ids))
|
|
258
|
+
throw effectInvalid("`plan.new_ids` must be an array");
|
|
259
|
+
if (typeof input.plan_id !== "string")
|
|
260
|
+
throw effectInvalid("`plan.plan_id` must be a string");
|
|
261
|
+
const plan = input;
|
|
262
|
+
const actual = planDigest(plan);
|
|
263
|
+
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 });
|
|
265
|
+
}
|
|
266
|
+
return plan;
|
|
267
|
+
}
|
|
268
|
+
/** `execute { plan }` (§11.7): admit, then commit exactly that plan. */
|
|
269
|
+
export function executePlan(store, req) {
|
|
270
|
+
const plan = admitPlan(req.plan);
|
|
271
|
+
const docStore = resolveDocStore(req);
|
|
272
|
+
return underLock(req, () => executeAdmitted(store, req, plan, docStore));
|
|
273
|
+
}
|
|
274
|
+
/** `execute { plan_id, effects }` (§11.7): re-plan; the digest must equal `plan_id`, else `plan_mismatch`. */
|
|
275
|
+
export function executeByDigest(store, req) {
|
|
276
|
+
const docStore = resolveDocStore(req);
|
|
277
|
+
return underLock(req, () => {
|
|
278
|
+
const plan = planEffects(store, { repoId: req.repoId, docStore, ...(req.omgbaseDir !== undefined ? { omgbaseDir: req.omgbaseDir } : {}), effects: req.effects });
|
|
279
|
+
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 });
|
|
281
|
+
}
|
|
282
|
+
return executeAdmitted(store, req, plan, docStore);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* `apply { effects }` with both halves kept (the sugar's seam — `sugar.ts`,
|
|
287
|
+
* the `omg` verbs, the whole-document update): the Plan always, and the
|
|
288
|
+
* Receipt when it committed (`dryRun` plans only — the CLI's `--dry-run`,
|
|
289
|
+
* spec/cli §3.6). Plan + execute run under one lock and one transaction,
|
|
290
|
+
* exactly as `applyEffects`.
|
|
291
|
+
*/
|
|
292
|
+
export function applyEffectsDetailed(store, req) {
|
|
293
|
+
const docStore = resolveDocStore(req);
|
|
294
|
+
const planIt = () => planEffects(store, { repoId: req.repoId, docStore, ...(req.omgbaseDir !== undefined ? { omgbaseDir: req.omgbaseDir } : {}), effects: req.effects });
|
|
295
|
+
if (req.dryRun)
|
|
296
|
+
return { plan: planIt() };
|
|
297
|
+
return underLock(req, () => {
|
|
298
|
+
const plan = planIt();
|
|
299
|
+
return { plan, receipt: executeAdmitted(store, req, plan, docStore) };
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
/** `apply { effects }` (§11.7): plan + execute under one lock and one transaction; `dryRun` returns the Plan. */
|
|
303
|
+
export function applyEffects(store, req) {
|
|
304
|
+
const { plan, receipt } = applyEffectsDetailed(store, req);
|
|
305
|
+
return receipt ?? plan;
|
|
306
|
+
}
|
|
307
|
+
// ---- symbols on the wire ----------------------------------------------------------------
|
|
308
|
+
/** 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`. */
|
|
309
|
+
export function substituteSymbols(value, ids) {
|
|
310
|
+
if (typeof value === "string")
|
|
311
|
+
return (Object.hasOwn(ids, value) ? ids[value] : value);
|
|
312
|
+
if (Array.isArray(value))
|
|
313
|
+
return value.map((x) => substituteSymbols(x, ids));
|
|
314
|
+
if (typeof value === "object" && value !== null) {
|
|
315
|
+
const out = {};
|
|
316
|
+
for (const [k, v] of Object.entries(value))
|
|
317
|
+
out[k] = substituteSymbols(v, ids);
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
return value;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* A planner error as the sugar reports it (`sugar.ts` `applySugar`): the
|
|
324
|
+
* `effect_index` the planner adds (§11.6) is dropped — the tool or verb built
|
|
325
|
+
* the effects itself, so the index names nothing the caller sent and the
|
|
326
|
+
* kernel's data (`op_index`, `block`, `current`, …) stays as pinned.
|
|
327
|
+
*/
|
|
328
|
+
export function stripEffectIndex(err) {
|
|
329
|
+
if (err instanceof MutationError && Object.hasOwn(err.data, "effect_index")) {
|
|
330
|
+
const { effect_index: _dropped, ...rest } = err.data;
|
|
331
|
+
err.data = rest;
|
|
332
|
+
}
|
|
333
|
+
return err;
|
|
334
|
+
}
|
|
335
|
+
function underLock(req, fn) {
|
|
336
|
+
return req.omgbaseDir ? withWriterLock(req.omgbaseDir, fn) : fn();
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Commit an admitted plan: the preconditions pre-checked (`stale_plan` /
|
|
340
|
+
* `doc_missing` / `path_taken`, outside the transaction, so a stale plan fails
|
|
341
|
+
* before any id is minted), the opset replayed under the production minter
|
|
342
|
+
* (`b` ids minted here, before the transaction — as every request's are; a
|
|
343
|
+
* created document is re-adopted from its bytes at its `before_op` position,
|
|
344
|
+
* so the replay mints in the plan's order and the i-th `b` IS the i-th block
|
|
345
|
+
* symbol — the alias the `$new.k` in the ops resolve through), the internal
|
|
346
|
+
* effects built in order — moves, the rendered documents (created ones as
|
|
347
|
+
* `ingest_known { base: null }`; a moved document's revision lands at its new
|
|
348
|
+
* path), tombstones — and `commitChangeset` re-validates every triple inside
|
|
349
|
+
* `BEGIN IMMEDIATE`. The commit row's `ops` is the intent record
|
|
350
|
+
* `canonical({ v: 1, plan_id, effects })`.
|
|
351
|
+
*/
|
|
352
|
+
function executeAdmitted(store, req, plan, docStore) {
|
|
353
|
+
for (const d of plan.preconditions.docs) {
|
|
354
|
+
const row = readDocBase(store.db, d.id);
|
|
355
|
+
if (!row)
|
|
356
|
+
throw new MutationError("doc_missing", `doc ${d.id} no longer exists`, { doc: d.id });
|
|
357
|
+
if (row.path !== d.path || row.currentRev !== d.base_revision || row.fileHash !== d.hash) {
|
|
358
|
+
throw new MutationError("stale_plan", `document ${d.id} changed since the plan was made; re-plan`, {
|
|
359
|
+
doc: d.id,
|
|
360
|
+
expected_revision: d.base_revision,
|
|
361
|
+
current: { revision: row.currentRev, hash: row.fileHash },
|
|
362
|
+
retriable: true,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
for (const path of plan.preconditions.absent_paths) {
|
|
367
|
+
if (findDoc(store, { repoId: req.repoId, path }))
|
|
368
|
+
throw new MutationError("path_taken", `document already exists at ${path}`, { path });
|
|
369
|
+
if (docStore.exists(path))
|
|
370
|
+
throw new MutationError("path_taken", `file already exists on disk at ${path}`, { path });
|
|
371
|
+
}
|
|
372
|
+
const minted = { b: [], d: [] };
|
|
373
|
+
const ts = req.ts ?? new Date().toISOString();
|
|
374
|
+
const head = {
|
|
375
|
+
origin: "api",
|
|
376
|
+
actor: req.origin.actor,
|
|
377
|
+
reason: req.origin.reason ?? null,
|
|
378
|
+
ops: canonicalJson({ v: 1, plan_id: plan.plan_id, effects: plan.effects }),
|
|
379
|
+
};
|
|
380
|
+
const stat = req.omgbaseDir !== undefined;
|
|
381
|
+
const moved = new Map(plan.opset.moves.map((m) => [m.id, m]));
|
|
382
|
+
const blockSymbols = plan.new_ids.filter((s) => s.kind === "block").map((s) => s.symbol);
|
|
383
|
+
const planner = new ChangesetPlanner(store, req.repoId, req.omgbaseDir);
|
|
384
|
+
const { effects, outcome } = observeMints((prefix, id) => {
|
|
385
|
+
if (prefix === "b") {
|
|
386
|
+
// The i-th `b` the replay mints is the i-th block symbol (§11.7): alias it
|
|
387
|
+
// so an op naming a created document's block by its symbol resolves.
|
|
388
|
+
const symbol = blockSymbols[minted.b.length];
|
|
389
|
+
minted.b.push(id);
|
|
390
|
+
if (symbol !== undefined)
|
|
391
|
+
planner.alias(symbol, id);
|
|
392
|
+
}
|
|
393
|
+
else if (prefix === "d")
|
|
394
|
+
minted.d.push(id);
|
|
395
|
+
}, () => {
|
|
396
|
+
const heads = plan.opset.set_frontmatter;
|
|
397
|
+
for (const h of heads)
|
|
398
|
+
if (!isNewSymbol(h.doc))
|
|
399
|
+
planner.setHead(h.doc, h.raw);
|
|
400
|
+
// Re-adopt each created document where the plan did (`before_op`), its
|
|
401
|
+
// head applied once its tree is loaded; the ops between in order.
|
|
402
|
+
const creates = plan.opset.creates;
|
|
403
|
+
let ci = 0;
|
|
404
|
+
const adoptUpTo = (opIndex) => {
|
|
405
|
+
while (ci < creates.length && creates[ci].before_op <= opIndex) {
|
|
406
|
+
const c = creates[ci++];
|
|
407
|
+
planner.adoptNew(c.symbol, parseNewDoc(c.symbol, c.path, c.source));
|
|
408
|
+
for (const h of heads)
|
|
409
|
+
if (h.doc === c.symbol)
|
|
410
|
+
planner.setHead(c.symbol, h.raw);
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
plan.opset.ops.forEach((op, i) => {
|
|
414
|
+
adoptUpTo(i);
|
|
415
|
+
planner.push(kernelOp(op));
|
|
416
|
+
});
|
|
417
|
+
adoptUpTo(plan.opset.ops.length);
|
|
418
|
+
const changeset = planner.finish();
|
|
419
|
+
const effects = [];
|
|
420
|
+
for (const m of plan.opset.moves) {
|
|
421
|
+
const bytes = docStore.read(m.from) ?? "";
|
|
422
|
+
const rev = readDocBase(store.db, m.id)?.currentRev ?? null;
|
|
423
|
+
effects.push({ kind: "move_path", docId: m.id, from: m.from, to: m.to, currentRev: rev, inbound: inboundLinksTo(store, req.repoId, m.id, m.from), bytes });
|
|
424
|
+
if (stat)
|
|
425
|
+
effects.push({ kind: "clear_stat", path: m.from }, { kind: "record_stat", path: m.to, bytes });
|
|
426
|
+
}
|
|
427
|
+
for (const e of changeset.effects) {
|
|
428
|
+
// The moved document is loaded under its OLD path (the row changes
|
|
429
|
+
// inside the transaction); its rewritten bytes belong at the new path.
|
|
430
|
+
if (e.kind === "ingest_known" && moved.has(e.docId))
|
|
431
|
+
effects.push({ ...e, path: moved.get(e.docId).to });
|
|
432
|
+
else if (e.kind === "record_stat") {
|
|
433
|
+
const m = plan.opset.moves.find((x) => x.from === e.path);
|
|
434
|
+
effects.push(m ? { ...e, path: m.to } : e);
|
|
435
|
+
}
|
|
436
|
+
else
|
|
437
|
+
effects.push(e);
|
|
438
|
+
}
|
|
439
|
+
for (const t of plan.opset.tombstones) {
|
|
440
|
+
const rev = readDocBase(store.db, t.id)?.currentRev ?? null;
|
|
441
|
+
effects.push({ kind: "tombstone", docId: t.id, path: t.path, currentRev: rev, pool: false });
|
|
442
|
+
if (stat)
|
|
443
|
+
effects.push({ kind: "clear_stat", path: t.path });
|
|
444
|
+
}
|
|
445
|
+
const outcome = commitChangeset(store, req.repoId, head, effects, ts, { docStore, ...(req.omgbaseDir !== undefined ? { omgbaseDir: req.omgbaseDir } : {}) });
|
|
446
|
+
return { effects, outcome };
|
|
447
|
+
});
|
|
448
|
+
// The receipt's revisions, in effect order; the ingest results pair with the
|
|
449
|
+
// ingest effects in order (a created document's id is the one its ingest minted).
|
|
450
|
+
const revisions = [];
|
|
451
|
+
let j = 0;
|
|
452
|
+
for (const e of effects) {
|
|
453
|
+
if (e.kind === "ingest_reconciled")
|
|
454
|
+
revisions.push({ id: outcome.ingests[j++].docId, path: e.path, hash: sha256(e.source).toString("hex") });
|
|
455
|
+
else if (e.kind === "ingest_known")
|
|
456
|
+
revisions.push({ id: outcome.ingests[j++].docId, path: e.path, hash: sha256(e.rendered).toString("hex") });
|
|
457
|
+
}
|
|
458
|
+
// `$new.n` → minted id: the i-th block symbol is the i-th `b` the replay
|
|
459
|
+
// minted (all before the transaction, in the plan's order — the aliases
|
|
460
|
+
// above), the i-th document symbol the i-th `d` the created documents'
|
|
461
|
+
// ingests minted inside it (load order = effect order).
|
|
462
|
+
const newIds = {};
|
|
463
|
+
let bi = 0;
|
|
464
|
+
let di = 0;
|
|
465
|
+
for (const s of plan.new_ids) {
|
|
466
|
+
const id = s.kind === "block" ? minted.b[bi++] : minted.d[di++];
|
|
467
|
+
if (id !== undefined)
|
|
468
|
+
newIds[s.symbol] = id;
|
|
469
|
+
}
|
|
470
|
+
return { plan_id: plan.plan_id, commit: outcome.commitId, revisions, new_ids: newIds, committed: true };
|
|
471
|
+
}
|
|
472
|
+
//# sourceMappingURL=plan.js.map
|