@omgbase/core 0.11.1 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/size.d.ts +76 -0
- package/dist/src/core/store/size.js +223 -0
- 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 +12 -5
- package/dist/src/index.js +18 -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 +27 -6
- package/dist/src/mcp/server.d.ts +56 -1
- package/dist/src/mcp/server.js +365 -203
- package/dist/src/mutate/apply.d.ts +93 -12
- package/dist/src/mutate/apply.js +203 -113
- package/dist/src/mutate/canonical.d.ts +27 -0
- package/dist/src/mutate/canonical.js +138 -0
- 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 +363 -0
- package/dist/src/mutate/effects.js +1161 -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 +210 -0
- package/dist/src/mutate/plan.js +512 -0
- package/dist/src/mutate/select.d.ts +89 -0
- package/dist/src/mutate/select.js +879 -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/planner.js +4 -0
- package/dist/src/oqx-js/run.d.ts +28 -1
- package/dist/src/oqx-js/run.js +85 -22
- package/dist/src/spec-versions.d.ts +6 -6
- 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/dist/src/version.d.ts +7 -2
- package/dist/src/version.js +5 -2
- package/package.json +2 -2
|
@@ -0,0 +1,512 @@
|
|
|
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, effectInvalid, } from "./effects.js";
|
|
11
|
+
import { compilePlain, compileSelection, selectionInvalid } from "./select.js";
|
|
12
|
+
import { MutationError } from "./tree.js";
|
|
13
|
+
import { canonicalJson, fixRequest, rootOfId, narrowedTo } from "./canonical.js";
|
|
14
|
+
// The Plan (spec/mutate §11.4–§11.7, 2.3): the frozen, executable, digest-named
|
|
15
|
+
// result of lowering an ordered effect list. `plan` writes nothing; `execute`
|
|
16
|
+
// commits exactly a plan — its preconditions re-validated inside the
|
|
17
|
+
// transaction by `commit.ts` — and `apply({ effects })` is plan + execute under
|
|
18
|
+
// one writer lock and one transaction. New identities are symbolic (`$new.n`,
|
|
19
|
+
// minted at execute, mapped in the receipt); a plan's identity is the sha256 of
|
|
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.
|
|
23
|
+
export const PLAN_VERSION = 1;
|
|
24
|
+
/** The lowering + conflict rules version (§11.4; the `matcher_v` precedent). */
|
|
25
|
+
export const PLANNER_V = "p3";
|
|
26
|
+
// ---- canonical JSON and the digest (§11.5) ----------------------------------------------
|
|
27
|
+
export { canonicalJson } from "./canonical.js";
|
|
28
|
+
/** The executable core of a plan — the six digested keys (seven with `selection`), nothing else. */
|
|
29
|
+
export function planCore(p) {
|
|
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
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** `plan_id = hex(sha256(canonical(core)))` (§11.5). */
|
|
36
|
+
export function planDigest(core) {
|
|
37
|
+
return sha256(canonicalJson(planCore(core))).toString("hex");
|
|
38
|
+
}
|
|
39
|
+
/** The kernel op as the plan carries it (`childIds` → `child_ids`). */
|
|
40
|
+
export function wireOp(o) {
|
|
41
|
+
if (o.op === "update") {
|
|
42
|
+
const { childIds, ...rest } = o;
|
|
43
|
+
return { ...rest, ...(childIds !== undefined ? { child_ids: childIds } : {}) };
|
|
44
|
+
}
|
|
45
|
+
return o;
|
|
46
|
+
}
|
|
47
|
+
/** A plan's op back to the kernel's spelling. */
|
|
48
|
+
export function kernelOp(o) {
|
|
49
|
+
if (o.op === "update") {
|
|
50
|
+
const { child_ids, ...rest } = o;
|
|
51
|
+
return { ...rest, ...(child_ids !== undefined ? { childIds: child_ids } : {}) };
|
|
52
|
+
}
|
|
53
|
+
return o;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Lower an ordered effect list into a frozen Plan (§11.6): under the symbolic
|
|
57
|
+
* minter, resolve and lower each effect in order (conflicts by focus kind,
|
|
58
|
+
* identical duplicates dropped with a diagnostic), render the touched documents,
|
|
59
|
+
* freeze the preconditions, digest the core. Reads only; mints no real id.
|
|
60
|
+
*/
|
|
61
|
+
export function planEffects(store, req) {
|
|
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
|
+
}
|
|
84
|
+
const docStore = resolveDocStore(req);
|
|
85
|
+
const symbols = new SymbolTable();
|
|
86
|
+
return withIdMinter(symbolicMinter(symbols), () => {
|
|
87
|
+
const planner = new ChangesetPlanner(store, req.repoId, req.omgbaseDir);
|
|
88
|
+
const ctx = {
|
|
89
|
+
store, repoId: req.repoId, docStore, planner, symbols,
|
|
90
|
+
blockSymbols: new Map(), creates: [], identity: new Map(), retargets: [], moves: [], tombstones: [], heads: new Map(), ops: [],
|
|
91
|
+
pins: new Map(), parents: [], absentPaths: [], docBases: new Map(),
|
|
92
|
+
tracker: new ConflictTracker(canonicalJson), canonical: canonicalJson, resolved: [], summary: [],
|
|
93
|
+
};
|
|
94
|
+
effects.forEach((e, i) => {
|
|
95
|
+
try {
|
|
96
|
+
lowerEffect(ctx, i, e);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
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
|
+
}
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
const changeset = planner.finish();
|
|
117
|
+
const opset = {
|
|
118
|
+
creates: ctx.creates,
|
|
119
|
+
moves: ctx.moves.map((m) => ({ id: m.id, from: m.from, to: m.to })),
|
|
120
|
+
tombstones: ctx.tombstones.map((t) => ({ id: t.id, path: t.path })),
|
|
121
|
+
set_frontmatter: [...ctx.heads].map(([doc, raw]) => ({ doc, raw })),
|
|
122
|
+
ops: ctx.ops.map(wireOp),
|
|
123
|
+
};
|
|
124
|
+
const docs = changeset.preconditions.map((p) => ({ id: p.id, path: p.path, base_revision: p.currentRev, hash: p.fileHash }));
|
|
125
|
+
for (const [id, base] of ctx.docBases) {
|
|
126
|
+
if (!docs.some((d) => d.id === id))
|
|
127
|
+
docs.push({ id, path: base.path, base_revision: base.currentRev, hash: base.fileHash });
|
|
128
|
+
}
|
|
129
|
+
const preconditions = {
|
|
130
|
+
docs,
|
|
131
|
+
blocks: [...ctx.pins].map(([id, content_hash]) => ({ id, content_hash })),
|
|
132
|
+
parents: ctx.parents,
|
|
133
|
+
absent_paths: ctx.absentPaths,
|
|
134
|
+
};
|
|
135
|
+
const core = { version: PLAN_VERSION, planner_v: PLANNER_V, effects: ctx.resolved, opset, preconditions, new_ids: symbols.entries, ...(selection !== undefined ? { selection } : {}) };
|
|
136
|
+
const plan_id = planDigest(core);
|
|
137
|
+
// The preview (not digested): the kernel results, every revision the commit
|
|
138
|
+
// would record — a created document's under its symbol, from its adopted
|
|
139
|
+
// tree like any loaded document's — and the per-file diffs: a create
|
|
140
|
+
// `"" → bytes`, a move two entries (its retargeted bytes land at the new
|
|
141
|
+
// path), a removal `bytes → ""`.
|
|
142
|
+
const diffs = { ...changeset.diffs };
|
|
143
|
+
const revisions = [...changeset.revisions];
|
|
144
|
+
for (const m of ctx.moves) {
|
|
145
|
+
const bytes = docStore.read(m.from) ?? "";
|
|
146
|
+
const own = diffs[m.from];
|
|
147
|
+
delete diffs[m.from];
|
|
148
|
+
diffs[m.from] = { before: bytes, after: "" };
|
|
149
|
+
diffs[m.to] = { before: "", after: own?.after ?? bytes };
|
|
150
|
+
}
|
|
151
|
+
for (const t of ctx.tombstones)
|
|
152
|
+
diffs[t.path] = { before: docStore.read(t.path) ?? "", after: "" };
|
|
153
|
+
for (const r of revisions) {
|
|
154
|
+
const moved = ctx.moves.find((m) => m.id === r.id);
|
|
155
|
+
if (moved)
|
|
156
|
+
r.path = moved.to;
|
|
157
|
+
}
|
|
158
|
+
const preview = { results: changeset.results, revisions, diffs, summary: ctx.summary };
|
|
159
|
+
if (ctx.identity.size > 0)
|
|
160
|
+
preview.identity = Object.fromEntries(ctx.identity);
|
|
161
|
+
if (ctx.retargets.length > 0)
|
|
162
|
+
preview.retarget = ctx.retargets;
|
|
163
|
+
return { ...core, plan_id, diagnostics: ctx.tracker.diagnostics, ...(templates !== undefined ? { templates } : {}), preview };
|
|
164
|
+
});
|
|
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
|
+
}
|
|
225
|
+
function isRecord(v) {
|
|
226
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The plan form's admission (§11.7): `version` first — anything but 1 is
|
|
230
|
+
* `unsupported_plan_version { version, supported: [1] }` — then the core's keys
|
|
231
|
+
* must be present (`filter_invalid`), then the recomputed digest must equal the
|
|
232
|
+
* embedded `plan_id` (`plan_mismatch { expected, actual }`). `preview` and
|
|
233
|
+
* `diagnostics` are ignored.
|
|
234
|
+
*/
|
|
235
|
+
export function admitPlan(input) {
|
|
236
|
+
if (!isRecord(input))
|
|
237
|
+
throw effectInvalid("`plan` must be an object");
|
|
238
|
+
if (input.version !== PLAN_VERSION) {
|
|
239
|
+
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] });
|
|
240
|
+
}
|
|
241
|
+
for (const k of ["planner_v", "effects", "opset", "preconditions", "new_ids", "plan_id"]) {
|
|
242
|
+
if (!(k in input))
|
|
243
|
+
throw effectInvalid(`\`plan.${k}\` is required`);
|
|
244
|
+
}
|
|
245
|
+
if (typeof input.planner_v !== "string")
|
|
246
|
+
throw effectInvalid("`plan.planner_v` must be a string");
|
|
247
|
+
if (!Array.isArray(input.effects))
|
|
248
|
+
throw effectInvalid("`plan.effects` must be an array");
|
|
249
|
+
if (!isRecord(input.opset))
|
|
250
|
+
throw effectInvalid("`plan.opset` must be an object");
|
|
251
|
+
for (const k of ["creates", "moves", "tombstones", "set_frontmatter", "ops"]) {
|
|
252
|
+
if (!Array.isArray(input.opset[k]))
|
|
253
|
+
throw effectInvalid(`\`plan.opset.${k}\` must be an array`);
|
|
254
|
+
}
|
|
255
|
+
for (const c of input.opset.creates) {
|
|
256
|
+
if (!isRecord(c) || typeof c.symbol !== "string" || typeof c.path !== "string" || typeof c.source !== "string" || typeof c.before_op !== "number") {
|
|
257
|
+
throw effectInvalid("`plan.opset.creates[]` entries are { symbol, path, source, before_op }");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (!isRecord(input.preconditions))
|
|
261
|
+
throw effectInvalid("`plan.preconditions` must be an object");
|
|
262
|
+
for (const k of ["docs", "blocks", "parents", "absent_paths"]) {
|
|
263
|
+
if (!Array.isArray(input.preconditions[k]))
|
|
264
|
+
throw effectInvalid(`\`plan.preconditions.${k}\` must be an array`);
|
|
265
|
+
}
|
|
266
|
+
if (!Array.isArray(input.new_ids))
|
|
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
|
+
}
|
|
277
|
+
if (typeof input.plan_id !== "string")
|
|
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");
|
|
282
|
+
const plan = input;
|
|
283
|
+
const actual = planDigest(plan);
|
|
284
|
+
if (actual !== plan.plan_id) {
|
|
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) });
|
|
286
|
+
}
|
|
287
|
+
return plan;
|
|
288
|
+
}
|
|
289
|
+
/** `execute { plan }` (§11.7): admit, then commit exactly that plan. */
|
|
290
|
+
export function executePlan(store, req) {
|
|
291
|
+
const plan = admitPlan(req.plan);
|
|
292
|
+
const docStore = resolveDocStore(req);
|
|
293
|
+
return underLock(req, () => executeAdmitted(store, req, plan, docStore));
|
|
294
|
+
}
|
|
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`. */
|
|
300
|
+
export function executeByDigest(store, req) {
|
|
301
|
+
const docStore = resolveDocStore(req);
|
|
302
|
+
return underLock(req, () => {
|
|
303
|
+
const plan = planEffects(store, { repoId: req.repoId, docStore, ...(req.omgbaseDir !== undefined ? { omgbaseDir: req.omgbaseDir } : {}), effects: req.effects, ...selectorOf(req) });
|
|
304
|
+
if (plan.plan_id !== req.planId) {
|
|
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)) });
|
|
306
|
+
}
|
|
307
|
+
return executeAdmitted(store, req, plan, docStore);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* `apply { effects }` with both halves kept (the sugar's seam — `sugar.ts`,
|
|
312
|
+
* the `omg` verbs, the whole-document update): the Plan always, and the
|
|
313
|
+
* Receipt when it committed (`dryRun` plans only — the CLI's `--dry-run`,
|
|
314
|
+
* spec/cli §3.6). Plan + execute run under one lock and one transaction,
|
|
315
|
+
* exactly as `applyEffects`.
|
|
316
|
+
*/
|
|
317
|
+
export function applyEffectsDetailed(store, req) {
|
|
318
|
+
const docStore = resolveDocStore(req);
|
|
319
|
+
const planIt = () => planEffects(store, { repoId: req.repoId, docStore, ...(req.omgbaseDir !== undefined ? { omgbaseDir: req.omgbaseDir } : {}), effects: req.effects, ...selectorOf(req) });
|
|
320
|
+
if (req.dryRun)
|
|
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
|
+
}
|
|
330
|
+
return underLock(req, () => {
|
|
331
|
+
const plan = planIt();
|
|
332
|
+
return { plan, receipt: executeAdmitted(store, req, plan, docStore) };
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
/** `apply { effects, select?, expect? }` (§11.7): plan + execute under one lock and one transaction; `dryRun` returns the Plan. */
|
|
336
|
+
export function applyEffects(store, req) {
|
|
337
|
+
const { plan, receipt } = applyEffectsDetailed(store, req);
|
|
338
|
+
return receipt ?? plan;
|
|
339
|
+
}
|
|
340
|
+
// ---- symbols on the wire ----------------------------------------------------------------
|
|
341
|
+
/** 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`. */
|
|
342
|
+
export function substituteSymbols(value, ids) {
|
|
343
|
+
if (typeof value === "string")
|
|
344
|
+
return (Object.hasOwn(ids, value) ? ids[value] : value);
|
|
345
|
+
if (Array.isArray(value))
|
|
346
|
+
return value.map((x) => substituteSymbols(x, ids));
|
|
347
|
+
if (typeof value === "object" && value !== null) {
|
|
348
|
+
const out = {};
|
|
349
|
+
for (const [k, v] of Object.entries(value))
|
|
350
|
+
out[k] = substituteSymbols(v, ids);
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
return value;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* A planner error as the sugar reports it (`sugar.ts` `applySugar`): the
|
|
357
|
+
* `effect_index` the planner adds (§11.6) is dropped — the tool or verb built
|
|
358
|
+
* the effects itself, so the index names nothing the caller sent and the
|
|
359
|
+
* kernel's data (`op_index`, `block`, `current`, …) stays as pinned.
|
|
360
|
+
*/
|
|
361
|
+
export function stripEffectIndex(err) {
|
|
362
|
+
if (err instanceof MutationError && Object.hasOwn(err.data, "effect_index")) {
|
|
363
|
+
const { effect_index: _dropped, ...rest } = err.data;
|
|
364
|
+
err.data = rest;
|
|
365
|
+
}
|
|
366
|
+
return err;
|
|
367
|
+
}
|
|
368
|
+
function underLock(req, fn) {
|
|
369
|
+
return req.omgbaseDir ? withWriterLock(req.omgbaseDir, fn) : fn();
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Commit an admitted plan: the preconditions pre-checked (`stale_plan` /
|
|
373
|
+
* `doc_missing` / `path_taken`, outside the transaction, so a stale plan fails
|
|
374
|
+
* before any id is minted), the opset replayed under the production minter
|
|
375
|
+
* (`b` ids minted here, before the transaction — as every request's are; a
|
|
376
|
+
* created document is re-adopted from its bytes at its `before_op` position,
|
|
377
|
+
* so the replay mints in the plan's order and the i-th `b` IS the i-th block
|
|
378
|
+
* symbol — the alias the `$new.k` in the ops resolve through), the internal
|
|
379
|
+
* effects built in order — moves, the rendered documents (created ones as
|
|
380
|
+
* `ingest_known { base: null }`; a moved document's revision lands at its new
|
|
381
|
+
* path), tombstones — and `commitChangeset` re-validates every triple inside
|
|
382
|
+
* `BEGIN IMMEDIATE`. The commit row's `ops` is the intent record
|
|
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).
|
|
386
|
+
*/
|
|
387
|
+
function executeAdmitted(store, req, plan, docStore) {
|
|
388
|
+
for (const d of plan.preconditions.docs) {
|
|
389
|
+
const row = readDocBase(store.db, d.id);
|
|
390
|
+
if (!row)
|
|
391
|
+
throw new MutationError("doc_missing", `doc ${d.id} no longer exists`, { doc: d.id });
|
|
392
|
+
if (row.path !== d.path || row.currentRev !== d.base_revision || row.fileHash !== d.hash) {
|
|
393
|
+
throw new MutationError("stale_plan", `document ${d.id} changed since the plan was made; re-plan`, {
|
|
394
|
+
doc: d.id,
|
|
395
|
+
expected_revision: d.base_revision,
|
|
396
|
+
current: { revision: row.currentRev, hash: row.fileHash },
|
|
397
|
+
retriable: true,
|
|
398
|
+
fix: replanFix(plan),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
for (const path of plan.preconditions.absent_paths) {
|
|
403
|
+
if (findDoc(store, { repoId: req.repoId, path }))
|
|
404
|
+
throw new MutationError("path_taken", `document already exists at ${path}`, { path });
|
|
405
|
+
if (docStore.exists(path))
|
|
406
|
+
throw new MutationError("path_taken", `file already exists on disk at ${path}`, { path });
|
|
407
|
+
}
|
|
408
|
+
const minted = { b: [], d: [] };
|
|
409
|
+
const ts = req.ts ?? new Date().toISOString();
|
|
410
|
+
const head = {
|
|
411
|
+
origin: "api",
|
|
412
|
+
actor: req.origin.actor,
|
|
413
|
+
reason: req.origin.reason ?? null,
|
|
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
|
+
}),
|
|
419
|
+
};
|
|
420
|
+
const stat = req.omgbaseDir !== undefined;
|
|
421
|
+
const moved = new Map(plan.opset.moves.map((m) => [m.id, m]));
|
|
422
|
+
const blockSymbols = plan.new_ids.filter((s) => s.kind === "block").map((s) => s.symbol);
|
|
423
|
+
const planner = new ChangesetPlanner(store, req.repoId, req.omgbaseDir);
|
|
424
|
+
const { effects, outcome } = observeMints((prefix, id) => {
|
|
425
|
+
if (prefix === "b") {
|
|
426
|
+
// The i-th `b` the replay mints is the i-th block symbol (§11.7): alias it
|
|
427
|
+
// so an op naming a created document's block by its symbol resolves.
|
|
428
|
+
const symbol = blockSymbols[minted.b.length];
|
|
429
|
+
minted.b.push(id);
|
|
430
|
+
if (symbol !== undefined)
|
|
431
|
+
planner.alias(symbol, id);
|
|
432
|
+
}
|
|
433
|
+
else if (prefix === "d")
|
|
434
|
+
minted.d.push(id);
|
|
435
|
+
}, () => {
|
|
436
|
+
const heads = plan.opset.set_frontmatter;
|
|
437
|
+
for (const h of heads)
|
|
438
|
+
if (!isNewSymbol(h.doc))
|
|
439
|
+
planner.setHead(h.doc, h.raw);
|
|
440
|
+
// Re-adopt each created document where the plan did (`before_op`), its
|
|
441
|
+
// head applied once its tree is loaded; the ops between in order.
|
|
442
|
+
const creates = plan.opset.creates;
|
|
443
|
+
let ci = 0;
|
|
444
|
+
const adoptUpTo = (opIndex) => {
|
|
445
|
+
while (ci < creates.length && creates[ci].before_op <= opIndex) {
|
|
446
|
+
const c = creates[ci++];
|
|
447
|
+
planner.adoptNew(c.symbol, parseNewDoc(c.symbol, c.path, c.source));
|
|
448
|
+
for (const h of heads)
|
|
449
|
+
if (h.doc === c.symbol)
|
|
450
|
+
planner.setHead(c.symbol, h.raw);
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
plan.opset.ops.forEach((op, i) => {
|
|
454
|
+
adoptUpTo(i);
|
|
455
|
+
planner.push(kernelOp(op));
|
|
456
|
+
});
|
|
457
|
+
adoptUpTo(plan.opset.ops.length);
|
|
458
|
+
const changeset = planner.finish();
|
|
459
|
+
const effects = [];
|
|
460
|
+
for (const m of plan.opset.moves) {
|
|
461
|
+
const bytes = docStore.read(m.from) ?? "";
|
|
462
|
+
const rev = readDocBase(store.db, m.id)?.currentRev ?? null;
|
|
463
|
+
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 });
|
|
464
|
+
if (stat)
|
|
465
|
+
effects.push({ kind: "clear_stat", path: m.from }, { kind: "record_stat", path: m.to, bytes });
|
|
466
|
+
}
|
|
467
|
+
for (const e of changeset.effects) {
|
|
468
|
+
// The moved document is loaded under its OLD path (the row changes
|
|
469
|
+
// inside the transaction); its rewritten bytes belong at the new path.
|
|
470
|
+
if (e.kind === "ingest_known" && moved.has(e.docId))
|
|
471
|
+
effects.push({ ...e, path: moved.get(e.docId).to });
|
|
472
|
+
else if (e.kind === "record_stat") {
|
|
473
|
+
const m = plan.opset.moves.find((x) => x.from === e.path);
|
|
474
|
+
effects.push(m ? { ...e, path: m.to } : e);
|
|
475
|
+
}
|
|
476
|
+
else
|
|
477
|
+
effects.push(e);
|
|
478
|
+
}
|
|
479
|
+
for (const t of plan.opset.tombstones) {
|
|
480
|
+
const rev = readDocBase(store.db, t.id)?.currentRev ?? null;
|
|
481
|
+
effects.push({ kind: "tombstone", docId: t.id, path: t.path, currentRev: rev, pool: false });
|
|
482
|
+
if (stat)
|
|
483
|
+
effects.push({ kind: "clear_stat", path: t.path });
|
|
484
|
+
}
|
|
485
|
+
const outcome = commitChangeset(store, req.repoId, head, effects, ts, { docStore, ...(req.omgbaseDir !== undefined ? { omgbaseDir: req.omgbaseDir } : {}) });
|
|
486
|
+
return { effects, outcome };
|
|
487
|
+
});
|
|
488
|
+
// The receipt's revisions, in effect order; the ingest results pair with the
|
|
489
|
+
// ingest effects in order (a created document's id is the one its ingest minted).
|
|
490
|
+
const revisions = [];
|
|
491
|
+
let j = 0;
|
|
492
|
+
for (const e of effects) {
|
|
493
|
+
if (e.kind === "ingest_reconciled")
|
|
494
|
+
revisions.push({ id: outcome.ingests[j++].docId, path: e.path, hash: sha256(e.source).toString("hex") });
|
|
495
|
+
else if (e.kind === "ingest_known")
|
|
496
|
+
revisions.push({ id: outcome.ingests[j++].docId, path: e.path, hash: sha256(e.rendered).toString("hex") });
|
|
497
|
+
}
|
|
498
|
+
// `$new.n` → minted id: the i-th block symbol is the i-th `b` the replay
|
|
499
|
+
// minted (all before the transaction, in the plan's order — the aliases
|
|
500
|
+
// above), the i-th document symbol the i-th `d` the created documents'
|
|
501
|
+
// ingests minted inside it (load order = effect order).
|
|
502
|
+
const newIds = {};
|
|
503
|
+
let bi = 0;
|
|
504
|
+
let di = 0;
|
|
505
|
+
for (const s of plan.new_ids) {
|
|
506
|
+
const id = s.kind === "block" ? minted.b[bi++] : minted.d[di++];
|
|
507
|
+
if (id !== undefined)
|
|
508
|
+
newIds[s.symbol] = id;
|
|
509
|
+
}
|
|
510
|
+
return { plan_id: plan.plan_id, commit: outcome.commitId, revisions, new_ids: newIds, committed: true, summary: plan.preview?.summary ?? [] };
|
|
511
|
+
}
|
|
512
|
+
//# sourceMappingURL=plan.js.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { type Query } from "@omgbase/oqx";
|
|
2
|
+
import type { Store } from "../core/store/store.js";
|
|
3
|
+
import { type Effect } from "./effects.js";
|
|
4
|
+
import { MutationError } from "./tree.js";
|
|
5
|
+
import { type FixRequest } from "./canonical.js";
|
|
6
|
+
/** The implicit bound on a selection without `expect.max` (hits beyond it are `selection_too_large`). */
|
|
7
|
+
export declare const SELECTION_CEILING = 1000;
|
|
8
|
+
export interface SelectionExpect {
|
|
9
|
+
max: number;
|
|
10
|
+
}
|
|
11
|
+
/** The seventh digested member of a plan's core (§11.4): the selector and its templates AS GIVEN, the hits it bound. */
|
|
12
|
+
export interface Selection {
|
|
13
|
+
query: string;
|
|
14
|
+
expect?: SelectionExpect;
|
|
15
|
+
templates: unknown[];
|
|
16
|
+
cardinality: number;
|
|
17
|
+
/** the hit ids, result order */
|
|
18
|
+
identities: string[];
|
|
19
|
+
}
|
|
20
|
+
/** Where an expanded effect came from (every error from one carries both, §11.8); a plain effect's
|
|
21
|
+
* expressions (3.2) have a row but no template. */
|
|
22
|
+
export interface EffectOrigin {
|
|
23
|
+
template_index?: number;
|
|
24
|
+
row: {
|
|
25
|
+
id: string;
|
|
26
|
+
path: string;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export interface CompiledSelection {
|
|
30
|
+
/** the concrete, validated effects: hits × templates */
|
|
31
|
+
effects: Effect[];
|
|
32
|
+
selection: Selection;
|
|
33
|
+
/** expanded index → its origin */
|
|
34
|
+
origins: EffectOrigin[];
|
|
35
|
+
}
|
|
36
|
+
/** (3.2) A plain request compiled: the concrete effects and, when any effect carried an expression, the list as written. */
|
|
37
|
+
export interface CompiledPlain {
|
|
38
|
+
effects: Effect[];
|
|
39
|
+
/** the effects AS WRITTEN — present only when at least one slot was an expression (the intent record's `templates`) */
|
|
40
|
+
templates?: unknown[];
|
|
41
|
+
/** effect index → the row its expressions were read from (sparse; literal effects have none) */
|
|
42
|
+
origins: (EffectOrigin | undefined)[];
|
|
43
|
+
}
|
|
44
|
+
/** The admission pass's refusal: `filter_invalid { reason, hint: "selection" }`. */
|
|
45
|
+
export declare function selectionInvalid(message: string, data?: Record<string, unknown>): MutationError;
|
|
46
|
+
/**
|
|
47
|
+
* `"-> <expr>"` → `{ oqx: "<expr>" }` — or, when the expression is a bare
|
|
48
|
+
* string literal or a template literal without interpolation, the literal
|
|
49
|
+
* itself (constant folding: `-> "x"` and `"x"` plan identically, and
|
|
50
|
+
* `-> "-> note"` is how a literal that begins with the leader is spelled).
|
|
51
|
+
* A parse error is `filter_invalid { hint: "selection" }` with its offset
|
|
52
|
+
* counted from the start of the written string (past the leader).
|
|
53
|
+
*/
|
|
54
|
+
export declare function desugarLeader(v: string, here: string): unknown;
|
|
55
|
+
/**
|
|
56
|
+
* Desugar one effect or template as written (§11.8 "Sugar"), before the shape
|
|
57
|
+
* check and the admission: in every slot that admits an expression — the
|
|
58
|
+
* strings `markdown`, `to`, `path`, `separator`, `at.before` / `at.after`,
|
|
59
|
+
* the JSON `value`, each top-level `create.frontmatter` value, a target's `id`
|
|
60
|
+
* / `path`, and a whole target (`into`, `target`, `targets[k]`) — a string
|
|
61
|
+
* beginning `-> ` becomes `{ oqx }` (or folds to its literal); a `-> ` string
|
|
62
|
+
* anywhere else (`expect`, `field`, `op`, a section's `heading_id`, …) is
|
|
63
|
+
* refused. Every other string is literal data, with no escaping and no `${`
|
|
64
|
+
* scanning; a string nested inside a `value` or a frontmatter value is data
|
|
65
|
+
* (the slot is the top-level value, as for `{ oqx }`). Shape problems are the
|
|
66
|
+
* shape check's.
|
|
67
|
+
*/
|
|
68
|
+
export declare function desugarEffect(raw: unknown, index: number, request?: FixRequest): unknown;
|
|
69
|
+
/**
|
|
70
|
+
* Compile `{ select, expect?, effects: templates }` into the concrete effects
|
|
71
|
+
* (§11.8): admit statically, rewrite, run once over one snapshot with
|
|
72
|
+
* `limit = bound + 1`, refuse a selection beyond the bound, bind every hit to
|
|
73
|
+
* every template. Reads only.
|
|
74
|
+
*/
|
|
75
|
+
export declare function compileSelection(store: Store, repoId: string, select: unknown, expectRaw: unknown, templatesRaw: unknown): CompiledSelection;
|
|
76
|
+
/** The query a selector request compiles to (admission + rewrite, before any row is read) — `print` it to see what runs. */
|
|
77
|
+
export declare function selectionQuery(select: unknown, templates: unknown): Query;
|
|
78
|
+
/**
|
|
79
|
+
* Compile a plain `effects` list (no selector): desugar the `-> ` leaders,
|
|
80
|
+
* then — when nothing is an expression — the shape check as before (3.0
|
|
81
|
+
* byte-identical). Otherwise each effect with expressions is admitted as a
|
|
82
|
+
* template over its own primary target's row: the effects are grouped by that
|
|
83
|
+
* row's identity, one synthetic query per identity — `from <root> where id ==
|
|
84
|
+
* "<id>"` with the slot items appended, named `__omg_sel_<effectIndex>_<slot>`
|
|
85
|
+
* — runs, and the row binds the slots exactly as a selector's hit would. The
|
|
86
|
+
* concrete effects feed the lowering unchanged; `templates` is the list as
|
|
87
|
+
* written, for the intent record.
|
|
88
|
+
*/
|
|
89
|
+
export declare function compilePlain(store: Store, repoId: string, raw: unknown): CompiledPlain;
|