@omgbase/core 0.11.1 → 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/server.d.ts +22 -0
- package/dist/src/mcp/server.js +262 -185
- 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/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,49 @@
|
|
|
1
|
+
import type { Database } from "better-sqlite3";
|
|
2
|
+
import type { Store } from "../core/store/store.js";
|
|
3
|
+
import type { DocStore } from "./doc-store.js";
|
|
4
|
+
export type PendingKind = "write" | "rename" | "remove";
|
|
5
|
+
/** One `pending_materializations` row (spec/store §3.1), hash as the 32 raw bytes. */
|
|
6
|
+
export interface PendingRow {
|
|
7
|
+
path: string;
|
|
8
|
+
kind: PendingKind;
|
|
9
|
+
fromPath: string | null;
|
|
10
|
+
docId: string;
|
|
11
|
+
revId: string | null;
|
|
12
|
+
renderedHash: Buffer;
|
|
13
|
+
commitId: string;
|
|
14
|
+
}
|
|
15
|
+
/** The pending row at `(repo, path)`, or null. */
|
|
16
|
+
export declare function readPending(db: Database, repoId: string, path: string): PendingRow | null;
|
|
17
|
+
/** Every pending row of a repo, in insertion order (a replaced row moves to the end). */
|
|
18
|
+
export declare function listPending(db: Database, repoId: string): PendingRow[];
|
|
19
|
+
/** Record an intent (`INSERT OR REPLACE`: one row per path — a later request for the same path supersedes the earlier). */
|
|
20
|
+
export declare function insertPending(db: Database, repoId: string, row: PendingRow): void;
|
|
21
|
+
/** Drop the intent at `(repo, path)`: the file is as recorded, or a later observation superseded it. */
|
|
22
|
+
export declare function deletePending(db: Database, repoId: string, path: string): void;
|
|
23
|
+
/** The rendered hash of the revision BEFORE `revId` of the same document (what
|
|
24
|
+
* the file held before the commit that recorded the row), or null for a first
|
|
25
|
+
* revision / an unknown one. */
|
|
26
|
+
export declare function priorRenderedHash(db: Database, revId: string | null): Buffer | null;
|
|
27
|
+
export interface MaterializeOptions {
|
|
28
|
+
/** The hash of the bytes the materializer may overwrite at the row's path (the
|
|
29
|
+
* engine's own previous bytes). Defaults to the rendered hash of the revision
|
|
30
|
+
* before the row's `rev_id`. `null` = only an absent or already-equal file is
|
|
31
|
+
* acceptable (a created document has no previous bytes). */
|
|
32
|
+
priorHash?: Buffer | null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Materialize one row through `docStore` and, when the file is as recorded,
|
|
36
|
+
* delete the row. Returns true when the row was cleared, false when it was kept
|
|
37
|
+
* (the path holds foreign bytes — the next observation decides — or the bytes
|
|
38
|
+
* could not be rendered). Never throws for a foreign file; a doc store write
|
|
39
|
+
* failure propagates with the row intact.
|
|
40
|
+
*/
|
|
41
|
+
export declare function materializeRow(store: Store, repoId: string, row: PendingRow, docStore: DocStore, opts?: MaterializeOptions): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Replay every pending row of a repo through `docStore`, in insertion order.
|
|
44
|
+
* Returns the paths whose rows were cleared (materialized or confirmed); rows
|
|
45
|
+
* kept for a foreign file are not listed. The recovery point of the freshness
|
|
46
|
+
* sweep (step 0) and of `recoverRepo`; the commit phase materializes its own
|
|
47
|
+
* rows per effect instead, interleaved with the stat-cache refreshes.
|
|
48
|
+
*/
|
|
49
|
+
export declare function materializePending(store: Store, repoId: string, docStore: DocStore): string[];
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { sha256 } from "../core/hash.js";
|
|
2
|
+
import { readDocumentAtRevision } from "../core/read/document.js";
|
|
3
|
+
const COLUMNS = "path, kind, from_path, doc_id, rev_id, rendered_hash, commit_id";
|
|
4
|
+
function fromRaw(r) {
|
|
5
|
+
return { path: r.path, kind: r.kind, fromPath: r.from_path, docId: r.doc_id, revId: r.rev_id, renderedHash: r.rendered_hash, commitId: r.commit_id };
|
|
6
|
+
}
|
|
7
|
+
/** The pending row at `(repo, path)`, or null. */
|
|
8
|
+
export function readPending(db, repoId, path) {
|
|
9
|
+
const r = db.prepare(`SELECT ${COLUMNS} FROM pending_materializations WHERE repo_id = ? AND path = ?`).get(repoId, path);
|
|
10
|
+
return r ? fromRaw(r) : null;
|
|
11
|
+
}
|
|
12
|
+
/** Every pending row of a repo, in insertion order (a replaced row moves to the end). */
|
|
13
|
+
export function listPending(db, repoId) {
|
|
14
|
+
return db.prepare(`SELECT ${COLUMNS} FROM pending_materializations WHERE repo_id = ? ORDER BY rowid`).all(repoId).map(fromRaw);
|
|
15
|
+
}
|
|
16
|
+
/** Record an intent (`INSERT OR REPLACE`: one row per path — a later request for the same path supersedes the earlier). */
|
|
17
|
+
export function insertPending(db, repoId, row) {
|
|
18
|
+
db.prepare(`INSERT OR REPLACE INTO pending_materializations (repo_id, ${COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(repoId, row.path, row.kind, row.fromPath, row.docId, row.revId, row.renderedHash, row.commitId);
|
|
19
|
+
}
|
|
20
|
+
/** Drop the intent at `(repo, path)`: the file is as recorded, or a later observation superseded it. */
|
|
21
|
+
export function deletePending(db, repoId, path) {
|
|
22
|
+
db.prepare("DELETE FROM pending_materializations WHERE repo_id = ? AND path = ?").run(repoId, path);
|
|
23
|
+
}
|
|
24
|
+
/** The rendered hash of the revision BEFORE `revId` of the same document (what
|
|
25
|
+
* the file held before the commit that recorded the row), or null for a first
|
|
26
|
+
* revision / an unknown one. */
|
|
27
|
+
export function priorRenderedHash(db, revId) {
|
|
28
|
+
if (revId === null)
|
|
29
|
+
return null;
|
|
30
|
+
const r = db
|
|
31
|
+
.prepare("SELECT p.rendered_hash FROM revisions r JOIN revisions p ON p.doc_id = r.doc_id AND p.seq = r.seq - 1 WHERE r.rev_id = ?")
|
|
32
|
+
.get(revId);
|
|
33
|
+
return r?.rendered_hash ?? null;
|
|
34
|
+
}
|
|
35
|
+
// The bytes a `write`/`rename` row materializes: spec/store §6.2 reconstruct at
|
|
36
|
+
// the row's revision, which must hash to `rendered_hash` (it does for the
|
|
37
|
+
// current revision; a row always names the document's current revision because
|
|
38
|
+
// a later commit to the same path replaces the row). Null when nothing can be
|
|
39
|
+
// rendered — the row is then left for a later materializer rather than guessed.
|
|
40
|
+
function renderedBytes(store, row) {
|
|
41
|
+
if (row.revId === null)
|
|
42
|
+
return null;
|
|
43
|
+
const at = readDocumentAtRevision(store, row.docId, row.revId);
|
|
44
|
+
if (!at || !sha256(at.raw).equals(row.renderedHash))
|
|
45
|
+
return null;
|
|
46
|
+
return at.raw;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Materialize one row through `docStore` and, when the file is as recorded,
|
|
50
|
+
* delete the row. Returns true when the row was cleared, false when it was kept
|
|
51
|
+
* (the path holds foreign bytes — the next observation decides — or the bytes
|
|
52
|
+
* could not be rendered). Never throws for a foreign file; a doc store write
|
|
53
|
+
* failure propagates with the row intact.
|
|
54
|
+
*/
|
|
55
|
+
export function materializeRow(store, repoId, row, docStore, opts = {}) {
|
|
56
|
+
const db = store.db;
|
|
57
|
+
const prior = opts.priorHash !== undefined ? opts.priorHash : priorRenderedHash(db, row.revId);
|
|
58
|
+
const own = (bytes) => {
|
|
59
|
+
const h = sha256(bytes);
|
|
60
|
+
return h.equals(row.renderedHash) || (prior !== null && h.equals(prior));
|
|
61
|
+
};
|
|
62
|
+
let done;
|
|
63
|
+
switch (row.kind) {
|
|
64
|
+
case "write": {
|
|
65
|
+
const current = docStore.read(row.path);
|
|
66
|
+
if (current !== null && sha256(current).equals(row.renderedHash))
|
|
67
|
+
done = true;
|
|
68
|
+
else if (current !== null && !own(current))
|
|
69
|
+
done = false;
|
|
70
|
+
else {
|
|
71
|
+
const bytes = renderedBytes(store, row);
|
|
72
|
+
if (bytes === null)
|
|
73
|
+
done = false;
|
|
74
|
+
else {
|
|
75
|
+
docStore.write(row.path, bytes);
|
|
76
|
+
done = true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
case "rename": {
|
|
82
|
+
const at = docStore.read(row.path);
|
|
83
|
+
if (at !== null) {
|
|
84
|
+
// The destination is occupied: by the moved bytes (done — a stale copy of
|
|
85
|
+
// the engine's own bytes left at `from` goes) or by a foreign file (the
|
|
86
|
+
// row stays; the sweep sees `to` win and `from` become untracked).
|
|
87
|
+
if (!sha256(at).equals(row.renderedHash))
|
|
88
|
+
done = false;
|
|
89
|
+
else {
|
|
90
|
+
const from = row.fromPath === null ? null : docStore.read(row.fromPath);
|
|
91
|
+
if (from !== null && own(from))
|
|
92
|
+
docStore.remove(row.fromPath);
|
|
93
|
+
done = true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else if (row.fromPath !== null && docStore.exists(row.fromPath)) {
|
|
97
|
+
let renamed = true;
|
|
98
|
+
try {
|
|
99
|
+
docStore.rename(row.fromPath, row.path);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// EXDEV, a destination that appeared, a vanished source: fall back
|
|
103
|
+
// to writing the revision's bytes at `to` (below) when `to` is still
|
|
104
|
+
// free; a foreign file that appeared keeps the row.
|
|
105
|
+
renamed = false;
|
|
106
|
+
}
|
|
107
|
+
if (renamed) {
|
|
108
|
+
// A self-linking moved document renders new bytes under its new path:
|
|
109
|
+
// the renamed file holds the previous revision, so it is rewritten.
|
|
110
|
+
const moved = docStore.read(row.path);
|
|
111
|
+
if (moved !== null && !sha256(moved).equals(row.renderedHash) && own(moved)) {
|
|
112
|
+
const bytes = renderedBytes(store, row);
|
|
113
|
+
if (bytes !== null)
|
|
114
|
+
docStore.write(row.path, bytes);
|
|
115
|
+
}
|
|
116
|
+
done = true;
|
|
117
|
+
}
|
|
118
|
+
else if (docStore.read(row.path) !== null) {
|
|
119
|
+
done = false;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
const bytes = renderedBytes(store, row);
|
|
123
|
+
if (bytes === null)
|
|
124
|
+
done = false;
|
|
125
|
+
else {
|
|
126
|
+
docStore.write(row.path, bytes);
|
|
127
|
+
const from = docStore.read(row.fromPath);
|
|
128
|
+
if (from !== null && own(from))
|
|
129
|
+
docStore.remove(row.fromPath);
|
|
130
|
+
done = true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
const bytes = renderedBytes(store, row);
|
|
136
|
+
if (bytes === null)
|
|
137
|
+
done = false;
|
|
138
|
+
else {
|
|
139
|
+
docStore.write(row.path, bytes);
|
|
140
|
+
done = true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case "remove": {
|
|
146
|
+
// Unlink only the bytes the deleted revision rendered; a file a human
|
|
147
|
+
// rewrote meanwhile stays (untracked now — the next sweep re-creates it).
|
|
148
|
+
const at = docStore.read(row.path);
|
|
149
|
+
if (at !== null && sha256(at).equals(row.renderedHash))
|
|
150
|
+
docStore.remove(row.path);
|
|
151
|
+
done = true;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (done)
|
|
156
|
+
deletePending(db, repoId, row.path);
|
|
157
|
+
return done;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Replay every pending row of a repo through `docStore`, in insertion order.
|
|
161
|
+
* Returns the paths whose rows were cleared (materialized or confirmed); rows
|
|
162
|
+
* kept for a foreign file are not listed. The recovery point of the freshness
|
|
163
|
+
* sweep (step 0) and of `recoverRepo`; the commit phase materializes its own
|
|
164
|
+
* rows per effect instead, interleaved with the stat-cache refreshes.
|
|
165
|
+
*/
|
|
166
|
+
export function materializePending(store, repoId, docStore) {
|
|
167
|
+
const out = [];
|
|
168
|
+
for (const row of listPending(store.db, repoId)) {
|
|
169
|
+
if (materializeRow(store, repoId, row, docStore))
|
|
170
|
+
out.push(row.path);
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=materialize.js.map
|
|
@@ -65,8 +65,9 @@ export interface Opset {
|
|
|
65
65
|
/**
|
|
66
66
|
* Document-level frontmatter change, when the proposed content changes it.
|
|
67
67
|
* `raw` is the new frontmatter incl. fences + trailing separator (null drops
|
|
68
|
-
* it). Frontmatter is a materialization unit, not a block op;
|
|
69
|
-
*
|
|
68
|
+
* it). Frontmatter is a materialization unit, not a block op; the `set
|
|
69
|
+
* content` lowering sets it as the document's file head (`setHead`,
|
|
70
|
+
* spec/mutate §11.3 — the kernel request's `set_frontmatter` through 2.x).
|
|
70
71
|
*/
|
|
71
72
|
frontmatter?: {
|
|
72
73
|
raw: string | null;
|
|
@@ -81,6 +82,28 @@ export interface Opset {
|
|
|
81
82
|
converges: boolean;
|
|
82
83
|
diagnostics: string[];
|
|
83
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* The identity consequences of a `set content` effect as a Plan surfaces them
|
|
87
|
+
* (spec/mutate §7/§11.4, 3.0: `preview.identity[<doc id>]`): the Opset's
|
|
88
|
+
* `matcher_v`, `summary`, `diagnostics`, and per planned op its position in
|
|
89
|
+
* the plan's `opset.ops` with the `PlanOp` annotation — what `docs_update`'s
|
|
90
|
+
* `{ opset, plan }` result carried through 3.x, minus the kernel ops (they are
|
|
91
|
+
* the plan's) and `converges` (a returned plan converges; the failure is
|
|
92
|
+
* `plan_not_convergent`).
|
|
93
|
+
*/
|
|
94
|
+
export interface IdentityPreview {
|
|
95
|
+
matcher_v: string;
|
|
96
|
+
summary: OpsetSummary;
|
|
97
|
+
ops: {
|
|
98
|
+
op_index: number;
|
|
99
|
+
disposition: PlanDisposition;
|
|
100
|
+
blocks: string[];
|
|
101
|
+
confidence: number | null;
|
|
102
|
+
reason: string | null;
|
|
103
|
+
detail?: Record<string, unknown>;
|
|
104
|
+
}[];
|
|
105
|
+
diagnostics: string[];
|
|
106
|
+
}
|
|
84
107
|
/** The bare kernel ops, in order — what `apply` actually replays. */
|
|
85
108
|
export declare function opsetKernelOps(opset: Opset): Op[];
|
|
86
109
|
/** Compute the summary from the planned ops + carried/ambiguous counts. */
|
|
@@ -93,3 +116,5 @@ export declare function serializeOpset(opset: Opset): string;
|
|
|
93
116
|
/** Parse a serialized opset, validating the version tag. */
|
|
94
117
|
export declare function parseOpset(text: string): Opset;
|
|
95
118
|
export declare function renderOpsetPlan(opset: Opset): string;
|
|
119
|
+
/** The same text over a Plan's `preview.identity[<doc>]` (3.0; `omg update --dry-run`): one line per op, an empty line, the summary. */
|
|
120
|
+
export declare function renderIdentityPlan(identity: IdentityPreview): string;
|
package/dist/src/mutate/opset.js
CHANGED
|
@@ -60,20 +60,27 @@ const VERB = {
|
|
|
60
60
|
copied_from: "COPY ", resurrected: "RESURR", bulk_rewrite: "REWRITE", retiled: "RETILE",
|
|
61
61
|
};
|
|
62
62
|
export function renderOpsetPlan(opset) {
|
|
63
|
+
const lines = planLines(opset.ops, opset.summary);
|
|
64
|
+
if (!opset.converges)
|
|
65
|
+
lines.push(`WARNING: plan does not reproduce the proposed content exactly — will not apply.`);
|
|
66
|
+
return lines.join("\n");
|
|
67
|
+
}
|
|
68
|
+
/** The same text over a Plan's `preview.identity[<doc>]` (3.0; `omg update --dry-run`): one line per op, an empty line, the summary. */
|
|
69
|
+
export function renderIdentityPlan(identity) {
|
|
70
|
+
return planLines(identity.ops, identity.summary).join("\n");
|
|
71
|
+
}
|
|
72
|
+
function planLines(ops, s) {
|
|
63
73
|
const lines = [];
|
|
64
|
-
for (const p of
|
|
74
|
+
for (const p of ops) {
|
|
65
75
|
const subject = p.blocks[0] ?? "(new)";
|
|
66
76
|
const conf = p.confidence !== null ? ` ~${p.confidence.toFixed(2)}` : "";
|
|
67
77
|
const why = p.reason ? ` [${p.reason}]` : "";
|
|
68
78
|
lines.push(`${VERB[p.disposition]} ${subject.padEnd(9)} ${p.disposition}${conf}${why}`);
|
|
69
79
|
}
|
|
70
|
-
const s = opset.summary;
|
|
71
80
|
lines.push("");
|
|
72
81
|
lines.push(`preserved: ${s.preserved} updated: ${s.updated} moved: ${s.moved} ` +
|
|
73
82
|
`created: ${s.created} removed: ${s.removed} split: ${s.split} ` +
|
|
74
83
|
`merged: ${s.merged} ambiguous: ${s.ambiguous}`);
|
|
75
|
-
|
|
76
|
-
lines.push(`WARNING: plan does not reproduce the proposed content exactly — will not apply.`);
|
|
77
|
-
return lines.join("\n");
|
|
84
|
+
return lines;
|
|
78
85
|
}
|
|
79
86
|
//# sourceMappingURL=opset.js.map
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import type { Store } from "../core/store/store.js";
|
|
2
2
|
import { type ReconcileConfig } from "../reconcile/types.js";
|
|
3
|
-
import { type ApplyResult } from "./apply.js";
|
|
4
|
-
import type { DocStore } from "./doc-store.js";
|
|
5
3
|
import { type Opset } from "./opset.js";
|
|
6
4
|
export interface PlanUpdateOptions {
|
|
7
5
|
config?: ReconcileConfig;
|
|
@@ -9,49 +7,6 @@ export interface PlanUpdateOptions {
|
|
|
9
7
|
/**
|
|
10
8
|
* Plan a whole-document update. `docRef` is a doc id or repo-relative path;
|
|
11
9
|
* `content` is the proposed complete document. Throws doc_missing if the
|
|
12
|
-
* document does not exist (a create is
|
|
10
|
+
* document does not exist (a create is the `create` effect's job). Never writes.
|
|
13
11
|
*/
|
|
14
12
|
export declare function planUpdate(store: Store, repoId: string, rootPath: string, docRef: string, content: string, opts?: PlanUpdateOptions): Opset;
|
|
15
|
-
export interface ApplyOpsetRequest {
|
|
16
|
-
repoId: string;
|
|
17
|
-
/** Working-tree root for the default filesystem write target; omit only with an explicit `docStore`. */
|
|
18
|
-
rootPath?: string;
|
|
19
|
-
/** Write target (ADR-014 §5); defaults to a filesystem store at `rootPath`. */
|
|
20
|
-
docStore?: DocStore;
|
|
21
|
-
opset: Opset;
|
|
22
|
-
origin: {
|
|
23
|
-
actor: string;
|
|
24
|
-
reason?: string;
|
|
25
|
-
};
|
|
26
|
-
dryRun?: boolean;
|
|
27
|
-
omgbaseDir?: string;
|
|
28
|
-
/** Commit timestamp (RFC 3339 UTC); defaults to now (see `ApplyRequest.ts`). */
|
|
29
|
-
ts?: string;
|
|
30
|
-
}
|
|
31
|
-
/**
|
|
32
|
-
* Apply an opset through the kernel write path. Validates the plan's
|
|
33
|
-
* preconditions against current state first (a stale plan fails, carrying
|
|
34
|
-
* current truth, rather than silently reinterpreting) and refuses a plan that
|
|
35
|
-
* did not verify byte-convergent.
|
|
36
|
-
*/
|
|
37
|
-
export declare function applyOpset(store: Store, req: ApplyOpsetRequest): ApplyResult;
|
|
38
|
-
export interface DocsUpdateContext {
|
|
39
|
-
repoId: string;
|
|
40
|
-
rootPath: string;
|
|
41
|
-
omgbaseDir?: string;
|
|
42
|
-
actor?: string;
|
|
43
|
-
}
|
|
44
|
-
export interface DocsUpdateResult {
|
|
45
|
-
opset: Opset;
|
|
46
|
-
/** null on dry_run (plan only) */
|
|
47
|
-
result: ApplyResult | null;
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Whole-document update convenience: plan then apply. `dryRun` returns the
|
|
51
|
-
* opset without executing (the note's docs.update(..., dry_run=true)).
|
|
52
|
-
*/
|
|
53
|
-
export declare function docsUpdate(store: Store, ctx: DocsUpdateContext, docRef: string, content: string, opts?: {
|
|
54
|
-
dryRun?: boolean;
|
|
55
|
-
config?: ReconcileConfig;
|
|
56
|
-
reason?: string;
|
|
57
|
-
}): DocsUpdateResult;
|
|
@@ -8,9 +8,9 @@ import { DEFAULT_CONFIG } from "../reconcile/types.js";
|
|
|
8
8
|
import { loadOldMatchBlocks } from "../sync/reconciling-ingest.js";
|
|
9
9
|
import { loadMutDoc } from "./load.js";
|
|
10
10
|
import { renderDoc, MutationError } from "./tree.js";
|
|
11
|
-
import {
|
|
11
|
+
import { ChangesetPlanner } from "./apply.js";
|
|
12
12
|
import { lowerTopLevel, lowerReplace } from "./lower.js";
|
|
13
|
-
import { summarizeOps
|
|
13
|
+
import { summarizeOps } from "./opset.js";
|
|
14
14
|
function toFlatSource(b) {
|
|
15
15
|
return { type: b.type, raw: b.raw, children: b.children.map(toFlatSource) };
|
|
16
16
|
}
|
|
@@ -22,9 +22,10 @@ function extractFrontmatter(blocks) {
|
|
|
22
22
|
/**
|
|
23
23
|
* Plan a whole-document update. `docRef` is a doc id or repo-relative path;
|
|
24
24
|
* `content` is the proposed complete document. Throws doc_missing if the
|
|
25
|
-
* document does not exist (a create is
|
|
25
|
+
* document does not exist (a create is the `create` effect's job). Never writes.
|
|
26
26
|
*/
|
|
27
27
|
export function planUpdate(store, repoId, rootPath, docRef, content, opts = {}) {
|
|
28
|
+
void rootPath; // kept for the call shape; the planner never writes (3.0: the verify runs the in-memory planner, no doc store)
|
|
28
29
|
const config = opts.config ?? DEFAULT_CONFIG;
|
|
29
30
|
const info = findDocByRef(store, repoId, docRef);
|
|
30
31
|
if (!info)
|
|
@@ -51,14 +52,15 @@ export function planUpdate(store, repoId, rootPath, docRef, content, opts = {})
|
|
|
51
52
|
// builds frontmatterRaw as `bytes + separator`; mirror that for the target.
|
|
52
53
|
const targetFm = fmBlock ? fmBlock.raw + fmBlock.trivia : null;
|
|
53
54
|
const fmChanged = targetFm !== oldDoc.frontmatterRaw;
|
|
54
|
-
|
|
55
|
+
// The verify's file head (spec/mutate §11.3: `leading_trivia + raw`), when the frontmatter changes.
|
|
56
|
+
const head = fmChanged ? oldDoc.leadingTrivia + (targetFm ?? "") : undefined;
|
|
55
57
|
const diagnostics = [];
|
|
56
58
|
// Try lowerings in order of decreasing identity preservation; take the first
|
|
57
59
|
// that reproduces the proposed content exactly.
|
|
58
60
|
let chosen = null;
|
|
59
61
|
let converges = false;
|
|
60
62
|
const t2 = lowerTopLevel(oldDoc, rest, assignmentOf(result), dispositionsOf(result));
|
|
61
|
-
const v2 = verify(store, repoId,
|
|
63
|
+
const v2 = verify(store, repoId, docId, path, opsOf(t2), head, content, currentContent);
|
|
62
64
|
if (v2.ok) {
|
|
63
65
|
chosen = t2;
|
|
64
66
|
converges = true;
|
|
@@ -66,7 +68,7 @@ export function planUpdate(store, repoId, rootPath, docRef, content, opts = {})
|
|
|
66
68
|
else {
|
|
67
69
|
diagnostics.push(`top-level lowering did not reproduce the proposed content byte-for-byte (${describeDivergence(v2, tree.children, content)}); falling back to full replace`);
|
|
68
70
|
const t3 = lowerReplace(oldDoc, content);
|
|
69
|
-
const v3 = verify(store, repoId,
|
|
71
|
+
const v3 = verify(store, repoId, docId, path, opsOf(t3), head, content, currentContent);
|
|
70
72
|
if (v3.ok) {
|
|
71
73
|
chosen = t3;
|
|
72
74
|
converges = true;
|
|
@@ -99,14 +101,20 @@ function dispositionsOf(r) {
|
|
|
99
101
|
return r.dispositions;
|
|
100
102
|
}
|
|
101
103
|
function opsOf(l) { return l.ops.map((p) => p.op); }
|
|
102
|
-
// Simulate a candidate op list
|
|
103
|
-
//
|
|
104
|
-
|
|
104
|
+
// Simulate a candidate op list (§4 steps 1–4 through the incremental planner,
|
|
105
|
+
// the file head set first) and check the rendered result equals the exact
|
|
106
|
+
// proposed content. An invalid script throws → treated as non-convergent.
|
|
107
|
+
function verify(store, repoId, docId, path, ops, head, expected, currentContent) {
|
|
105
108
|
try {
|
|
106
|
-
const
|
|
109
|
+
const planner = new ChangesetPlanner(store, repoId);
|
|
110
|
+
if (head !== undefined)
|
|
111
|
+
planner.setHead(docId, head);
|
|
112
|
+
for (const op of ops)
|
|
113
|
+
planner.push(op);
|
|
114
|
+
const res = planner.finish();
|
|
107
115
|
// A doc untouched by any op (e.g. an empty plan) is absent from diffs; its
|
|
108
116
|
// effective result is the unchanged current content.
|
|
109
|
-
const after = res.diffs
|
|
117
|
+
const after = res.diffs[path]?.after ?? currentContent;
|
|
110
118
|
return after === expected ? { ok: true } : { ok: false, actual: after, error: null };
|
|
111
119
|
}
|
|
112
120
|
catch (e) {
|
|
@@ -131,60 +139,4 @@ function describeDivergence(v, proposed, expected) {
|
|
|
131
139
|
const snippet = (s) => JSON.stringify(s.slice(off, off + 24));
|
|
132
140
|
return `first divergence at byte ${off} in ${where}: expected ${snippet(expected)}, rendered ${snippet(actual)}`;
|
|
133
141
|
}
|
|
134
|
-
/**
|
|
135
|
-
* Apply an opset through the kernel write path. Validates the plan's
|
|
136
|
-
* preconditions against current state first (a stale plan fails, carrying
|
|
137
|
-
* current truth, rather than silently reinterpreting) and refuses a plan that
|
|
138
|
-
* did not verify byte-convergent.
|
|
139
|
-
*/
|
|
140
|
-
export function applyOpset(store, req) {
|
|
141
|
-
const { opset } = req;
|
|
142
|
-
if (!opset.converges) {
|
|
143
|
-
const why = opset.diagnostics.length > 0 ? ` — ${opset.diagnostics[opset.diagnostics.length - 1]}` : "";
|
|
144
|
-
throw new MutationError("plan_not_convergent", `opset does not reproduce the proposed content; re-plan${why}`, { diagnostics: opset.diagnostics });
|
|
145
|
-
}
|
|
146
|
-
// Precondition: the document must still be at the revision/content the plan
|
|
147
|
-
// was computed against.
|
|
148
|
-
const docId = opset.precondition.id;
|
|
149
|
-
const row = store.db.prepare("SELECT current_rev, lower(hex(file_hash)) fh FROM docs WHERE doc_id = ? AND deleted_commit IS NULL").get(docId);
|
|
150
|
-
if (!row)
|
|
151
|
-
throw new MutationError("doc_missing", `doc ${docId} not found`, { doc: docId });
|
|
152
|
-
const stale = (opset.precondition.baseRevision !== null && row.current_rev !== opset.precondition.baseRevision) || (row.fh !== null && row.fh !== opset.precondition.hash);
|
|
153
|
-
if (stale) {
|
|
154
|
-
throw new MutationError("stale_plan", "document changed since the plan was computed; re-plan", {
|
|
155
|
-
doc: docId,
|
|
156
|
-
expected_revision: opset.precondition.baseRevision,
|
|
157
|
-
current: { revision: row.current_rev, hash: row.fh },
|
|
158
|
-
retriable: true,
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
return apply(store, {
|
|
162
|
-
repoId: req.repoId,
|
|
163
|
-
...(req.rootPath !== undefined ? { rootPath: req.rootPath } : {}),
|
|
164
|
-
...(req.docStore !== undefined ? { docStore: req.docStore } : {}),
|
|
165
|
-
ops: opsetKernelOps(opset),
|
|
166
|
-
origin: req.origin,
|
|
167
|
-
...(req.dryRun !== undefined ? { dryRun: req.dryRun } : {}),
|
|
168
|
-
...(req.omgbaseDir !== undefined ? { omgbaseDir: req.omgbaseDir } : {}),
|
|
169
|
-
...(req.ts !== undefined ? { ts: req.ts } : {}),
|
|
170
|
-
...(opset.frontmatter ? { setFrontmatter: [{ doc: docId, raw: opset.frontmatter.raw }] } : {}),
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
/**
|
|
174
|
-
* Whole-document update convenience: plan then apply. `dryRun` returns the
|
|
175
|
-
* opset without executing (the note's docs.update(..., dry_run=true)).
|
|
176
|
-
*/
|
|
177
|
-
export function docsUpdate(store, ctx, docRef, content, opts = {}) {
|
|
178
|
-
const opset = planUpdate(store, ctx.repoId, ctx.rootPath, docRef, content, opts.config ? { config: opts.config } : {});
|
|
179
|
-
if (opts.dryRun)
|
|
180
|
-
return { opset, result: null };
|
|
181
|
-
const result = applyOpset(store, {
|
|
182
|
-
repoId: ctx.repoId,
|
|
183
|
-
rootPath: ctx.rootPath,
|
|
184
|
-
opset,
|
|
185
|
-
origin: { actor: ctx.actor ?? "agent:update", ...(opts.reason !== undefined ? { reason: opts.reason } : {}) },
|
|
186
|
-
...(ctx.omgbaseDir !== undefined ? { omgbaseDir: ctx.omgbaseDir } : {}),
|
|
187
|
-
});
|
|
188
|
-
return { opset, result };
|
|
189
|
-
}
|
|
190
142
|
//# sourceMappingURL=plan-update.js.map
|