@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,138 @@
|
|
|
1
|
+
import { MutationError } from "./tree.js";
|
|
2
|
+
/** The argument-shape error of the surface (`effects.ts` `effectInvalid`), spelled here to avoid the module cycle. */
|
|
3
|
+
function effectInvalid(message) {
|
|
4
|
+
return new MutationError("filter_invalid", message, { reason: message, hint: "arguments" });
|
|
5
|
+
}
|
|
6
|
+
// Canonical JSON (spec/mutate §11.5): the text a plan's digest is taken over,
|
|
7
|
+
// and since 3.3 the text every error `fix` and every `summary` value is spelled
|
|
8
|
+
// in — one serialization both engines produce byte for byte. Kept apart from
|
|
9
|
+
// `plan.ts` so the compile (`select.ts`) and the lowering (`effects.ts`) can use
|
|
10
|
+
// it without a module cycle.
|
|
11
|
+
function cmpUtf8(a, b) {
|
|
12
|
+
return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
|
|
13
|
+
}
|
|
14
|
+
// A lone surrogate has no UTF-8 encoding: it cannot be digested (nor sent as JSON text).
|
|
15
|
+
function isWellFormed(s) {
|
|
16
|
+
for (let i = 0; i < s.length; i++) {
|
|
17
|
+
const c = s.charCodeAt(i);
|
|
18
|
+
if (c >= 0xd800 && c <= 0xdbff) {
|
|
19
|
+
const n = s.charCodeAt(i + 1);
|
|
20
|
+
if (!(n >= 0xdc00 && n <= 0xdfff))
|
|
21
|
+
return false;
|
|
22
|
+
i++;
|
|
23
|
+
}
|
|
24
|
+
else if (c >= 0xdc00 && c <= 0xdfff)
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
function canonicalString(s) {
|
|
30
|
+
if (!isWellFormed(s))
|
|
31
|
+
throw effectInvalid("strings must be well-formed Unicode (a lone surrogate cannot be digested)");
|
|
32
|
+
let out = '"';
|
|
33
|
+
for (let i = 0; i < s.length; i++) {
|
|
34
|
+
const c = s.charCodeAt(i);
|
|
35
|
+
switch (c) {
|
|
36
|
+
case 0x22:
|
|
37
|
+
out += '\\"';
|
|
38
|
+
break;
|
|
39
|
+
case 0x5c:
|
|
40
|
+
out += "\\\\";
|
|
41
|
+
break;
|
|
42
|
+
case 0x08:
|
|
43
|
+
out += "\\b";
|
|
44
|
+
break;
|
|
45
|
+
case 0x0c:
|
|
46
|
+
out += "\\f";
|
|
47
|
+
break;
|
|
48
|
+
case 0x0a:
|
|
49
|
+
out += "\\n";
|
|
50
|
+
break;
|
|
51
|
+
case 0x0d:
|
|
52
|
+
out += "\\r";
|
|
53
|
+
break;
|
|
54
|
+
case 0x09:
|
|
55
|
+
out += "\\t";
|
|
56
|
+
break;
|
|
57
|
+
default:
|
|
58
|
+
out += c < 0x20 ? `\\u${c.toString(16).padStart(4, "0")}` : s[i];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out + '"';
|
|
62
|
+
}
|
|
63
|
+
function canonicalNumber(n) {
|
|
64
|
+
if (!Number.isFinite(n))
|
|
65
|
+
throw effectInvalid("a number in a plan must be finite");
|
|
66
|
+
if (Number.isInteger(n) && Math.abs(n) < 2 ** 53)
|
|
67
|
+
return String(n === 0 ? 0 : n);
|
|
68
|
+
return String(n);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Canonical JSON (§11.5): no whitespace; object keys sorted by their UTF-8
|
|
72
|
+
* bytes; members whose value is `undefined` omitted (an optional key absent is
|
|
73
|
+
* absent, never `null`; a JSON `null` value stays `null`); strings escaped only
|
|
74
|
+
* as JSON requires — `"`, `\`, and U+0000–U+001F (`\b \f \n \r \t`, else
|
|
75
|
+
* `\u00xx` lower-case) — everything else literal UTF-8; a number that is
|
|
76
|
+
* integral with |n| < 2^53 printed as a plain integer (`-0` as `0`), any other
|
|
77
|
+
* finite number by ECMAScript `Number::toString` (shortest round-trip digits);
|
|
78
|
+
* `true`/`false`/`null`.
|
|
79
|
+
*/
|
|
80
|
+
export function canonicalJson(v) {
|
|
81
|
+
if (v === null)
|
|
82
|
+
return "null";
|
|
83
|
+
switch (typeof v) {
|
|
84
|
+
case "boolean":
|
|
85
|
+
return v ? "true" : "false";
|
|
86
|
+
case "number":
|
|
87
|
+
return canonicalNumber(v);
|
|
88
|
+
case "string":
|
|
89
|
+
return canonicalString(v);
|
|
90
|
+
case "object": {
|
|
91
|
+
if (Array.isArray(v))
|
|
92
|
+
return `[${v.map((x) => canonicalJson(x === undefined ? null : x)).join(",")}]`;
|
|
93
|
+
const o = v;
|
|
94
|
+
const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort(cmpUtf8);
|
|
95
|
+
return `{${keys.map((k) => `${canonicalString(k)}:${canonicalJson(o[k])}`).join(",")}}`;
|
|
96
|
+
}
|
|
97
|
+
default:
|
|
98
|
+
throw effectInvalid(`a ${typeof v} cannot be digested`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** The `fix` for a request to the SAME tool: the corrected arguments, canonical JSON. */
|
|
102
|
+
export function fixRequest(req) {
|
|
103
|
+
return canonicalJson({
|
|
104
|
+
...(req.select !== undefined ? { select: req.select } : {}),
|
|
105
|
+
effects: req.effects,
|
|
106
|
+
...(req.expect !== undefined ? { expect: req.expect } : {}),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/** The `fix` naming another tool: `<tool> <canonical args>`. */
|
|
110
|
+
export function fixTool(tool, args) {
|
|
111
|
+
return `${tool} ${canonicalJson(args)}`;
|
|
112
|
+
}
|
|
113
|
+
/** The selector that names exactly one row: the request narrowed to `from <root> where id == "<id>"` with `expect.max: 1` (the fix of a cross-hit collision or an ambiguous target under a selector). */
|
|
114
|
+
export function narrowedTo(root, id) {
|
|
115
|
+
return `from ${root} where id == ${JSON.stringify(id)}`;
|
|
116
|
+
}
|
|
117
|
+
/** The root a row id belongs to, by its prefix (`d_` / `b_` / `n_`). */
|
|
118
|
+
export function rootOfId(id) {
|
|
119
|
+
if (id.startsWith("d_"))
|
|
120
|
+
return "docs";
|
|
121
|
+
if (id.startsWith("n_"))
|
|
122
|
+
return "nodes";
|
|
123
|
+
return "blocks";
|
|
124
|
+
}
|
|
125
|
+
/** `value` with the member at `path` (keys and array indices) replaced; a copy, the input untouched. */
|
|
126
|
+
export function withMember(value, path, replacement) {
|
|
127
|
+
if (path.length === 0)
|
|
128
|
+
return replacement;
|
|
129
|
+
const [head, ...rest] = path;
|
|
130
|
+
if (Array.isArray(value)) {
|
|
131
|
+
const copy = [...value];
|
|
132
|
+
copy[Number(head)] = withMember(copy[Number(head)], rest, replacement);
|
|
133
|
+
return copy;
|
|
134
|
+
}
|
|
135
|
+
const o = (typeof value === "object" && value !== null ? value : {});
|
|
136
|
+
return { ...o, [head]: withMember(o[head], rest, replacement) };
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=canonical.js.map
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { Database } from "better-sqlite3";
|
|
2
|
+
import type { Store } from "../core/store/store.js";
|
|
3
|
+
import { type IngestResult } from "../core/ingest.js";
|
|
4
|
+
import type { InboundLink } from "../graph/inbound-links.js";
|
|
5
|
+
import type { DocStore } from "./doc-store.js";
|
|
6
|
+
import { type MutDoc } from "./tree.js";
|
|
7
|
+
/** The row triple a document was planned against (spec/mutate §4 step 5, 2.2):
|
|
8
|
+
* `docs.path`, `docs.current_rev` and `docs.file_hash` (lower-case hex, `null`
|
|
9
|
+
* when the row has none) as one `SELECT` read them at plan time. Invariant I3
|
|
10
|
+
* (spec/store §8) makes `current_rev` the version vector of every live block,
|
|
11
|
+
* so the commit need not re-read blocks: a block cannot change without a new
|
|
12
|
+
* revision. The same triple the opset precondition `{ id, path, base_revision,
|
|
13
|
+
* hash }` freezes. */
|
|
14
|
+
export interface DocBase {
|
|
15
|
+
path: string;
|
|
16
|
+
currentRev: string | null;
|
|
17
|
+
fileHash: string | null;
|
|
18
|
+
}
|
|
19
|
+
/** One `SELECT path, current_rev, file_hash` of a live document: the triple
|
|
20
|
+
* the plan captures and the commit re-validates. `null` when no live row. */
|
|
21
|
+
export declare function readDocBase(db: Database, docId: string): DocBase | null;
|
|
22
|
+
export type Effect =
|
|
23
|
+
/** The api ingest of a mutated working tree: `rendered` is `renderDoc(mutDoc)`,
|
|
24
|
+
* the known-id resolver threads `mutDoc`'s ids onto the re-parse and labels
|
|
25
|
+
* dispositions against `priorIds`. `path` is where the bytes are written and
|
|
26
|
+
* the path the revision carries — the moved document of a `docs_move` is
|
|
27
|
+
* loaded under its OLD path and overridden to the new one; `base.path` is
|
|
28
|
+
* the path it was loaded under (where its bytes are on disk at phase A).
|
|
29
|
+
* `base` is the triple the document was planned against — or `null` for a
|
|
30
|
+
* document the request CREATES (spec/mutate §11.3, 3.0: the planner adopted
|
|
31
|
+
* its parsed tree, so its blocks commit with known ids and `inserted / api`
|
|
32
|
+
* dispositions); the guard is then `path_taken` — no live document at `path`
|
|
33
|
+
* when the transaction validates — and `docId` is the plan's symbol, the
|
|
34
|
+
* ingest minting the real `d`. */
|
|
35
|
+
{
|
|
36
|
+
kind: "ingest_known";
|
|
37
|
+
docId: string;
|
|
38
|
+
path: string;
|
|
39
|
+
rendered: string;
|
|
40
|
+
mutDoc: MutDoc;
|
|
41
|
+
priorIds: Set<string>;
|
|
42
|
+
base: DocBase | null;
|
|
43
|
+
}
|
|
44
|
+
/** A whole-file ingest through the probabilistic reconciler: identity is
|
|
45
|
+
* re-derived from the bytes. `precondition` is `null` for `docs_create` (no
|
|
46
|
+
* live document may exist at `path`, else `path_taken`) and, for
|
|
47
|
+
* `docs_set_meta`, the patched document's triple plus `original` — the bytes
|
|
48
|
+
* the patch was computed from, which must still be what the row's
|
|
49
|
+
* `file_hash` names (2.2 closes the unchecked gap: a human edit under a
|
|
50
|
+
* `set_meta` is a `sync_conflict`, not an api commit of their bytes);
|
|
51
|
+
* `original: null` when the doc store had no bytes (headless: no disk check). */
|
|
52
|
+
| {
|
|
53
|
+
kind: "ingest_reconciled";
|
|
54
|
+
path: string;
|
|
55
|
+
source: string;
|
|
56
|
+
precondition: {
|
|
57
|
+
docId: string;
|
|
58
|
+
base: DocBase;
|
|
59
|
+
original: string | null;
|
|
60
|
+
} | null;
|
|
61
|
+
}
|
|
62
|
+
/** `docs_move`'s row half: `docs.path` and the current revision's `path`,
|
|
63
|
+
* the open inbound edges re-pointed at `phantom:<from>`, phantoms at `to`
|
|
64
|
+
* adopted. The file half (a `rename` row, spec/store §5.7) renames `from` →
|
|
65
|
+
* `to`, or writes the revision's bytes at `to` when the source file is
|
|
66
|
+
* absent; `bytes` is the source file as read at plan time (the hash the row
|
|
67
|
+
* falls back to for a document with no revision). Precondition: the document is live at
|
|
68
|
+
* `from` with `currentRev` (else `stale_plan`; gone → `doc_missing`) and no
|
|
69
|
+
* live document is at `to` (else `path_taken`). */
|
|
70
|
+
| {
|
|
71
|
+
kind: "move_path";
|
|
72
|
+
docId: string;
|
|
73
|
+
from: string;
|
|
74
|
+
to: string;
|
|
75
|
+
currentRev: string | null;
|
|
76
|
+
inbound: InboundLink[];
|
|
77
|
+
bytes: string;
|
|
78
|
+
}
|
|
79
|
+
/** Tombstone the document and its live blocks under the commit. `pool: false`
|
|
80
|
+
* is `docs_delete` (an intentional removal pools nothing, spec/mutate §10);
|
|
81
|
+
* `pool: true` is the observed deletion's snapshot (spec/store §5.6 — the
|
|
82
|
+
* observe path still commits through `sync/tombstone.ts`; the flag is here
|
|
83
|
+
* for the day `observe_many` runs through this executor). The file at
|
|
84
|
+
* `path` is unlinked by B' iff it still holds the deleted revision's bytes
|
|
85
|
+
* (a `remove` row, spec/store §5.7). Precondition: the document is
|
|
86
|
+
* live at `path` with `currentRev` (`doc_missing` / `stale_plan`). */
|
|
87
|
+
| {
|
|
88
|
+
kind: "tombstone";
|
|
89
|
+
docId: string;
|
|
90
|
+
path: string;
|
|
91
|
+
currentRev: string | null;
|
|
92
|
+
pool: boolean;
|
|
93
|
+
}
|
|
94
|
+
/** Freshness stat cache (spec/sync §4.3): refresh the row for `path` after
|
|
95
|
+
* `bytes` were written there / drop it. Added only when the request named a
|
|
96
|
+
* workspace (`omgbaseDir`), as before. */
|
|
97
|
+
| {
|
|
98
|
+
kind: "record_stat";
|
|
99
|
+
path: string;
|
|
100
|
+
bytes: string;
|
|
101
|
+
} | {
|
|
102
|
+
kind: "clear_stat";
|
|
103
|
+
path: string;
|
|
104
|
+
};
|
|
105
|
+
/** The one `commits` row a request records. `ops` is threaded, never populated
|
|
106
|
+
* yet (ADR-003's intent record waits for the planner step). */
|
|
107
|
+
export interface CommitHead {
|
|
108
|
+
origin: "api";
|
|
109
|
+
actor: string | null;
|
|
110
|
+
reason: string | null;
|
|
111
|
+
checkpointId?: string | null;
|
|
112
|
+
ops?: string | null;
|
|
113
|
+
}
|
|
114
|
+
/** What a committed request returns: the shared commit id (`null` when the
|
|
115
|
+
* request had no effect at all — a changeset that touched no document records
|
|
116
|
+
* nothing, as before) and, in effect order, one `IngestResult` per
|
|
117
|
+
* `ingest_known`/`ingest_reconciled` effect. */
|
|
118
|
+
export interface CommitOutcome {
|
|
119
|
+
commitId: string | null;
|
|
120
|
+
ingests: IngestResult[];
|
|
121
|
+
}
|
|
122
|
+
/** Validation's finding: the document whose on-disk bytes moved from under the request. */
|
|
123
|
+
export interface FileConflict {
|
|
124
|
+
docId: string;
|
|
125
|
+
path: string;
|
|
126
|
+
bytes: string;
|
|
127
|
+
}
|
|
128
|
+
export type CommitPhasesResult = {
|
|
129
|
+
committed: CommitOutcome;
|
|
130
|
+
} | {
|
|
131
|
+
conflict: FileConflict;
|
|
132
|
+
};
|
|
133
|
+
export interface CommitTargets {
|
|
134
|
+
docStore: DocStore;
|
|
135
|
+
/** Set when the request runs in a workspace: the conflict path refreshes the
|
|
136
|
+
* stat cache for the re-ingested file (the effects carry their own). */
|
|
137
|
+
omgbaseDir?: string;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Commit `effects` as one request under `head` at `ts`. Throws `sync_conflict`
|
|
141
|
+
* (retriable) when a document changed on disk — after ingesting those bytes as
|
|
142
|
+
* an `observed` commit so a retry sees them — `stale_plan` (retriable, `{ doc,
|
|
143
|
+
* expected_revision, current: { revision, hash } }`) when a document's row
|
|
144
|
+
* moved since the plan, `doc_missing` / `path_taken` per effect, and otherwise
|
|
145
|
+
* returns the shared commit id with the ingest results. The caller holds the
|
|
146
|
+
* writer lock when it has a workspace.
|
|
147
|
+
*/
|
|
148
|
+
export declare function commitChangeset(store: Store, repoId: string, head: CommitHead, effects: Effect[], ts: string, targets: CommitTargets): CommitOutcome;
|
|
149
|
+
/** Phases A → C → B' over the effects; a disk conflict is returned, not thrown. */
|
|
150
|
+
export declare function runPhases(store: Store, repoId: string, head: CommitHead, effects: Effect[], ts: string, docStore: DocStore): CommitPhasesResult;
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { sha256 } from "../core/hash.js";
|
|
2
|
+
import { ingestFile, ingestInto } from "../core/ingest.js";
|
|
3
|
+
import { deletePending, insertPending, materializeRow, readPending } from "./materialize.js";
|
|
4
|
+
import { newCommit } from "../core/store/writers.js";
|
|
5
|
+
import { ftsDeleteDoc } from "../core/store/fts.js";
|
|
6
|
+
import { adoptPhantoms, rebuildDocEdges } from "../core/store/edges.js";
|
|
7
|
+
import { makeReconcilingResolver } from "../sync/reconciling-ingest.js";
|
|
8
|
+
import { makeKnownIdResolver } from "./known-ids.js";
|
|
9
|
+
import { MutationError } from "./tree.js";
|
|
10
|
+
/** One `SELECT path, current_rev, file_hash` of a live document: the triple
|
|
11
|
+
* the plan captures and the commit re-validates. `null` when no live row. */
|
|
12
|
+
export function readDocBase(db, docId) {
|
|
13
|
+
const row = db.prepare("SELECT path, current_rev, lower(hex(file_hash)) hash FROM docs WHERE doc_id = ? AND deleted_commit IS NULL").get(docId);
|
|
14
|
+
return row ? { path: row.path, currentRev: row.current_rev, fileHash: row.hash } : null;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Commit `effects` as one request under `head` at `ts`. Throws `sync_conflict`
|
|
18
|
+
* (retriable) when a document changed on disk — after ingesting those bytes as
|
|
19
|
+
* an `observed` commit so a retry sees them — `stale_plan` (retriable, `{ doc,
|
|
20
|
+
* expected_revision, current: { revision, hash } }`) when a document's row
|
|
21
|
+
* moved since the plan, `doc_missing` / `path_taken` per effect, and otherwise
|
|
22
|
+
* returns the shared commit id with the ingest results. The caller holds the
|
|
23
|
+
* writer lock when it has a workspace.
|
|
24
|
+
*/
|
|
25
|
+
export function commitChangeset(store, repoId, head, effects, ts, targets) {
|
|
26
|
+
const result = runPhases(store, repoId, head, effects, ts, targets.docStore);
|
|
27
|
+
if ("conflict" in result) {
|
|
28
|
+
const { path, bytes } = result.conflict;
|
|
29
|
+
// A human edit landed first: ingest it (its own observed commit, after the
|
|
30
|
+
// rollback), then the caller must retry against the new revision. The file
|
|
31
|
+
// won, so an intent still pending at the path is superseded (spec/store
|
|
32
|
+
// §5.7) — dropped in the same transaction as the observed commit.
|
|
33
|
+
store.write((db) => {
|
|
34
|
+
ingestFile(store, repoId, path, bytes, { ts, resolveIds: makeReconcilingResolver(store, repoId, { ts, path }) });
|
|
35
|
+
deletePending(db, repoId, path);
|
|
36
|
+
});
|
|
37
|
+
if (targets.omgbaseDir)
|
|
38
|
+
targets.docStore.recordStat(store, repoId, path, bytes);
|
|
39
|
+
throw new MutationError("sync_conflict", `file ${path} changed on disk; re-ingested — retry`, { retriable: true });
|
|
40
|
+
}
|
|
41
|
+
return result.committed;
|
|
42
|
+
}
|
|
43
|
+
// The disk half of a failed validation travels as this private signal — thrown
|
|
44
|
+
// inside the transaction it rolls the transaction back (nothing minted), and
|
|
45
|
+
// `runPhases` turns it into the `{ conflict }` return so the observed ingest
|
|
46
|
+
// can run in a transaction of its own. The row-side findings (`stale_plan`,
|
|
47
|
+
// `doc_missing`, `path_taken`) are thrown as `MutationError`s directly: they
|
|
48
|
+
// need no ingest, and a throw out of the transaction is its rollback.
|
|
49
|
+
class ConflictSignal {
|
|
50
|
+
conflict;
|
|
51
|
+
constructor(conflict) {
|
|
52
|
+
this.conflict = conflict;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Phases A → C → B' over the effects; a disk conflict is returned, not thrown. */
|
|
56
|
+
export function runPhases(store, repoId, head, effects, ts, docStore) {
|
|
57
|
+
// A request with nothing to do (a macro whose plan found no block, a
|
|
58
|
+
// `links_repair` with no hit) writes no file and mints no commit row — an
|
|
59
|
+
// `api` commit with no revision would read as a move or a deletion in the
|
|
60
|
+
// change feed. `macros::links-repair-path-glob-no-hits` pins it.
|
|
61
|
+
if (effects.length === 0)
|
|
62
|
+
return { committed: { commitId: null, ingests: [] } };
|
|
63
|
+
// Phase A — reads only. First the intent an earlier request may have left
|
|
64
|
+
// at a path this one touches (its file is behind the database until some
|
|
65
|
+
// materializer runs; this is one): replayed through the DocStore so the
|
|
66
|
+
// bytes read next are the engine's, not a stale file mistaken for a human
|
|
67
|
+
// edit (a foreign file keeps its row and is read as it is). Then the bytes
|
|
68
|
+
// on disk of every `ingest_known` document (at the path it was loaded under
|
|
69
|
+
// — the moved document of a `docs_move` is still at `from` here); a
|
|
70
|
+
// `docs_set_meta` carries the bytes it patched. A path with no file (a
|
|
71
|
+
// headless NullDocStore, a deleted file) has no disk check. The I/O stays
|
|
72
|
+
// outside the transaction; phase C compares these hashes.
|
|
73
|
+
for (const path of touchedPaths(effects)) {
|
|
74
|
+
const row = readPending(store.db, repoId, path);
|
|
75
|
+
if (row)
|
|
76
|
+
materializeRow(store, repoId, row, docStore);
|
|
77
|
+
}
|
|
78
|
+
const onDisk = new Map();
|
|
79
|
+
for (const e of effects) {
|
|
80
|
+
if (e.kind === "ingest_known" && e.base) {
|
|
81
|
+
const bytes = docStore.read(e.base.path);
|
|
82
|
+
if (bytes !== null)
|
|
83
|
+
onDisk.set(e.docId, bytes);
|
|
84
|
+
}
|
|
85
|
+
else if (e.kind === "ingest_reconciled" && e.precondition?.original !== null && e.precondition?.original !== undefined) {
|
|
86
|
+
onDisk.set(e.precondition.docId, e.precondition.original);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Phase C — one IMMEDIATE transaction: validate under the write lock, THEN
|
|
90
|
+
// one commit row, then the database half of every effect in order, then the
|
|
91
|
+
// file intent. Mint order: one `c`, then per document `r`, `x`, `e` (and `d`
|
|
92
|
+
// for a created document) — nothing before validation passes.
|
|
93
|
+
let committed;
|
|
94
|
+
try {
|
|
95
|
+
committed = store.writeImmediate((db) => {
|
|
96
|
+
validate(db, repoId, effects, onDisk);
|
|
97
|
+
const commit = newCommit(db, {
|
|
98
|
+
repoId,
|
|
99
|
+
ts,
|
|
100
|
+
origin: head.origin,
|
|
101
|
+
actor: head.actor,
|
|
102
|
+
reason: head.reason,
|
|
103
|
+
checkpointId: head.checkpointId ?? null,
|
|
104
|
+
ops: head.ops ?? null,
|
|
105
|
+
});
|
|
106
|
+
const commitId = commit.commitId;
|
|
107
|
+
const ingests = [];
|
|
108
|
+
// The rows this request will materialize, keyed by the path the file
|
|
109
|
+
// must end up at (spec/store §5.7): one per path, in effect order.
|
|
110
|
+
const pending = new Map();
|
|
111
|
+
for (const e of effects) {
|
|
112
|
+
switch (e.kind) {
|
|
113
|
+
case "ingest_known": {
|
|
114
|
+
// INTENT path: persist the ops' tree with its KNOWN block ids — never
|
|
115
|
+
// re-derive identity from the rendered bytes (node-editability).
|
|
116
|
+
const res = ingestInto(db, repoId, e.path, e.rendered, {
|
|
117
|
+
ts,
|
|
118
|
+
commitId,
|
|
119
|
+
resolveIds: makeKnownIdResolver(store, repoId, e.mutDoc, e.priorIds, { path: e.path }),
|
|
120
|
+
});
|
|
121
|
+
ingests.push(res);
|
|
122
|
+
recordWrite(pending, e.path, res, sha256(e.rendered), commitId);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case "ingest_reconciled": {
|
|
126
|
+
const res = ingestInto(db, repoId, e.path, e.source, {
|
|
127
|
+
ts,
|
|
128
|
+
commitId,
|
|
129
|
+
resolveIds: makeReconcilingResolver(store, repoId, { ts, path: e.path }),
|
|
130
|
+
});
|
|
131
|
+
ingests.push(res);
|
|
132
|
+
recordWrite(pending, e.path, res, sha256(e.source), commitId);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case "move_path": {
|
|
136
|
+
movePath(db, e);
|
|
137
|
+
// The bytes expected at `to`: the moved document's current revision
|
|
138
|
+
// (a self-linking source's retarget below replaces them with its own).
|
|
139
|
+
const rev = e.currentRev === null ? undefined : db.prepare("SELECT rendered_hash FROM revisions WHERE rev_id = ?").get(e.currentRev);
|
|
140
|
+
pending.set(e.to, { path: e.to, kind: "rename", fromPath: e.from, docId: e.docId, revId: e.currentRev, renderedHash: rev?.rendered_hash ?? sha256(e.bytes), commitId });
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case "tombstone": {
|
|
144
|
+
// The bytes it is safe to unlink: what the document's last revision
|
|
145
|
+
// rendered (or the last observed bytes when it has no revision).
|
|
146
|
+
const row = db
|
|
147
|
+
.prepare("SELECT r.rendered_hash, d.file_hash FROM docs d LEFT JOIN revisions r ON r.rev_id = d.current_rev WHERE d.doc_id = ?")
|
|
148
|
+
.get(e.docId);
|
|
149
|
+
tombstone(db, commitId, ts, e);
|
|
150
|
+
pending.set(e.path, { path: e.path, kind: "remove", fromPath: null, docId: e.docId, revId: null, renderedHash: row?.rendered_hash ?? row?.file_hash ?? sha256(""), commitId });
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
default:
|
|
154
|
+
// record_stat / clear_stat: the stat cache reads the real file,
|
|
155
|
+
// which does not exist in its new form yet — phase B'.
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const row of pending.values())
|
|
160
|
+
insertPending(db, repoId, row);
|
|
161
|
+
return { commitId, ingests };
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
if (err instanceof ConflictSignal)
|
|
166
|
+
return { conflict: err.conflict };
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
// Phase B' — the file half, in effect order, each row deleted as its file
|
|
170
|
+
// lands. The stat cache follows only a file that landed: a path whose row was
|
|
171
|
+
// kept (foreign bytes) must stay a candidate for the next sweep, which is
|
|
172
|
+
// what lets the human's edit win.
|
|
173
|
+
const landed = new Set();
|
|
174
|
+
const land = (path, priorHash) => {
|
|
175
|
+
const row = readPending(store.db, repoId, path);
|
|
176
|
+
if (!row)
|
|
177
|
+
return;
|
|
178
|
+
if (materializeRow(store, repoId, row, docStore, priorHash === undefined ? {} : { priorHash }))
|
|
179
|
+
landed.add(path);
|
|
180
|
+
};
|
|
181
|
+
for (const e of effects) {
|
|
182
|
+
switch (e.kind) {
|
|
183
|
+
case "ingest_known":
|
|
184
|
+
land(e.path, e.base ? fromHex(e.base.fileHash) : null);
|
|
185
|
+
break;
|
|
186
|
+
case "ingest_reconciled":
|
|
187
|
+
land(e.path, e.precondition ? fromHex(e.precondition.base.fileHash) : null);
|
|
188
|
+
break;
|
|
189
|
+
case "move_path":
|
|
190
|
+
land(e.to, undefined);
|
|
191
|
+
break;
|
|
192
|
+
case "tombstone":
|
|
193
|
+
land(e.path, undefined);
|
|
194
|
+
break;
|
|
195
|
+
case "record_stat":
|
|
196
|
+
if (landed.has(e.path))
|
|
197
|
+
docStore.recordStat(store, repoId, e.path, e.bytes);
|
|
198
|
+
break;
|
|
199
|
+
case "clear_stat":
|
|
200
|
+
docStore.clearStat(store, repoId, e.path);
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return { committed };
|
|
205
|
+
}
|
|
206
|
+
// The row the file at `path` must become after an ingest: a `write` of the
|
|
207
|
+
// revision's bytes — unless a `rename` already lands the moved document there
|
|
208
|
+
// (the self-linking source of a `docs_move`), which then renders THESE bytes.
|
|
209
|
+
function recordWrite(pending, path, res, renderedHash, commitId) {
|
|
210
|
+
const existing = pending.get(path);
|
|
211
|
+
if (existing && existing.kind === "rename") {
|
|
212
|
+
existing.revId = res.revId;
|
|
213
|
+
existing.renderedHash = renderedHash;
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
pending.set(path, { path, kind: "write", fromPath: null, docId: res.docId, revId: res.revId, renderedHash, commitId });
|
|
217
|
+
}
|
|
218
|
+
// Every path whose file an effect reads or leaves: where a pending row of an
|
|
219
|
+
// earlier request would stand between the request and the disk.
|
|
220
|
+
function touchedPaths(effects) {
|
|
221
|
+
const out = new Set();
|
|
222
|
+
for (const e of effects) {
|
|
223
|
+
switch (e.kind) {
|
|
224
|
+
case "ingest_known":
|
|
225
|
+
if (e.base)
|
|
226
|
+
out.add(e.base.path);
|
|
227
|
+
out.add(e.path);
|
|
228
|
+
break;
|
|
229
|
+
case "ingest_reconciled":
|
|
230
|
+
out.add(e.path);
|
|
231
|
+
break;
|
|
232
|
+
case "move_path":
|
|
233
|
+
out.add(e.from);
|
|
234
|
+
out.add(e.to);
|
|
235
|
+
break;
|
|
236
|
+
case "tombstone":
|
|
237
|
+
out.add(e.path);
|
|
238
|
+
break;
|
|
239
|
+
default:
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return [...out];
|
|
244
|
+
}
|
|
245
|
+
function fromHex(hex) {
|
|
246
|
+
return hex === null ? null : Buffer.from(hex, "hex");
|
|
247
|
+
}
|
|
248
|
+
// Every effect's precondition against the rows `db` sees now (spec/mutate §4
|
|
249
|
+
// step 5, 2.2), inside the transaction. Row first — a document whose row moved
|
|
250
|
+
// is `stale_plan` whatever its bytes — then the disk: `file_hash` (when the
|
|
251
|
+
// row has one) must name the bytes phase A read.
|
|
252
|
+
function validate(db, repoId, effects, onDisk) {
|
|
253
|
+
const liveAt = db.prepare("SELECT doc_id FROM docs WHERE repo_id = ? AND path = ? AND deleted_commit IS NULL");
|
|
254
|
+
const pathTaken = (path) => liveAt.get(repoId, path) !== undefined;
|
|
255
|
+
const checkRow = (docId, expected) => {
|
|
256
|
+
const row = readDocBase(db, docId);
|
|
257
|
+
if (!row)
|
|
258
|
+
throw new MutationError("doc_missing", `doc ${docId} no longer exists`, { doc: docId });
|
|
259
|
+
const moved = row.path !== expected.path || row.currentRev !== expected.currentRev || (expected.fileHash !== undefined && row.fileHash !== expected.fileHash);
|
|
260
|
+
if (moved) {
|
|
261
|
+
throw new MutationError("stale_plan", `document ${docId} changed since the request was planned; re-plan`, {
|
|
262
|
+
doc: docId,
|
|
263
|
+
expected_revision: expected.currentRev,
|
|
264
|
+
current: { revision: row.currentRev, hash: row.fileHash },
|
|
265
|
+
retriable: true,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
return row;
|
|
269
|
+
};
|
|
270
|
+
const checkDisk = (docId, row) => {
|
|
271
|
+
const bytes = onDisk.get(docId);
|
|
272
|
+
if (bytes === undefined || row.fileHash === null)
|
|
273
|
+
return;
|
|
274
|
+
if (sha256(bytes).toString("hex") !== row.fileHash)
|
|
275
|
+
throw new ConflictSignal({ docId, path: row.path, bytes });
|
|
276
|
+
};
|
|
277
|
+
for (const e of effects) {
|
|
278
|
+
switch (e.kind) {
|
|
279
|
+
case "ingest_known":
|
|
280
|
+
// A created document (`base: null`, the `Absent` guard): no live document
|
|
281
|
+
// may be at the path — the planner's check re-run through the transaction.
|
|
282
|
+
if (e.base)
|
|
283
|
+
checkDisk(e.docId, checkRow(e.docId, e.base));
|
|
284
|
+
else if (pathTaken(e.path))
|
|
285
|
+
throw new MutationError("path_taken", `document already exists at ${e.path}`, { path: e.path });
|
|
286
|
+
break;
|
|
287
|
+
case "ingest_reconciled":
|
|
288
|
+
if (e.precondition)
|
|
289
|
+
checkDisk(e.precondition.docId, checkRow(e.precondition.docId, e.precondition.base));
|
|
290
|
+
else if (pathTaken(e.path))
|
|
291
|
+
throw new MutationError("path_taken", `document already exists at ${e.path}`, { path: e.path });
|
|
292
|
+
break;
|
|
293
|
+
case "move_path":
|
|
294
|
+
checkRow(e.docId, { path: e.from, currentRev: e.currentRev });
|
|
295
|
+
if (pathTaken(e.to))
|
|
296
|
+
throw new MutationError("path_taken", `a document already exists at ${e.to}`, { path: e.to });
|
|
297
|
+
break;
|
|
298
|
+
case "tombstone":
|
|
299
|
+
checkRow(e.docId, { path: e.path, currentRev: e.currentRev });
|
|
300
|
+
break;
|
|
301
|
+
default:
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// `docs_move`'s rows (spec/mutate §6): the document row's path + its current
|
|
307
|
+
// revision's path pointer; then the edge index. Every open edge from ANOTHER
|
|
308
|
+
// doc into this one resolved via the old path, so it dangles now → re-point it
|
|
309
|
+
// at the old path's phantom (in place, mirroring adoptPhantoms). A self-doc
|
|
310
|
+
// edge dangles only when the link names the path (`[x](/old.md#H)`); a pure
|
|
311
|
+
// fragment (`#H`) resolves to the doc regardless of path and is left alone —
|
|
312
|
+
// the inbound scan distinguishes them from the raw. Runs BEFORE the retarget's
|
|
313
|
+
// ingests so each rewritten source's `maintainEdges` resolves `[x](new)` to the
|
|
314
|
+
// document (adopted here), not to a phantom.
|
|
315
|
+
function movePath(db, e) {
|
|
316
|
+
db.prepare("UPDATE docs SET path = ? WHERE doc_id = ?").run(e.to, e.docId);
|
|
317
|
+
db.prepare("UPDATE revisions SET path = ? WHERE doc_id = ? AND rev_id = ?").run(e.to, e.docId, e.currentRev);
|
|
318
|
+
const phantom = `phantom:${e.from}`;
|
|
319
|
+
const affected = new Set();
|
|
320
|
+
const others = db.prepare("SELECT DISTINCT src_doc FROM edges WHERE dst_node = ? AND to_commit IS NULL AND src_doc != ?").all(e.docId, e.docId);
|
|
321
|
+
for (const r of others)
|
|
322
|
+
affected.add(r.src_doc);
|
|
323
|
+
db.prepare("UPDATE edges SET dst_node = ? WHERE dst_node = ? AND to_commit IS NULL AND src_doc != ?").run(phantom, e.docId, e.docId);
|
|
324
|
+
const selfEdge = db.prepare("UPDATE edges SET dst_node = ? WHERE dst_node = ? AND to_commit IS NULL AND src_doc = ? AND src_block = ? AND anchor IS ?");
|
|
325
|
+
for (const l of e.inbound) {
|
|
326
|
+
if (l.doc_id !== e.docId || l.block_id === null)
|
|
327
|
+
continue;
|
|
328
|
+
selfEdge.run(phantom, e.docId, e.docId, l.block_id, l.anchor);
|
|
329
|
+
affected.add(e.docId);
|
|
330
|
+
}
|
|
331
|
+
for (const d of affected)
|
|
332
|
+
rebuildDocEdges(db, d);
|
|
333
|
+
// Links already written against the NEW path resolve to this doc now.
|
|
334
|
+
adoptPhantoms(db, e.to, e.docId);
|
|
335
|
+
}
|
|
336
|
+
// Tombstone the document and its live blocks under the commit; FTS rows drop
|
|
337
|
+
// with the blocks. `pool` snapshots the live blocks first (spec/store §5.6).
|
|
338
|
+
function tombstone(db, commitId, ts, e) {
|
|
339
|
+
if (e.pool) {
|
|
340
|
+
const expires = new Date(Date.parse(ts) + 30 * 24 * 3600 * 1000).toISOString();
|
|
341
|
+
db.prepare(`INSERT OR REPLACE INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
|
|
342
|
+
SELECT block_id, repo_id, doc_id, raw_hash, norm_hash, type, ?, ?
|
|
343
|
+
FROM blocks WHERE doc_id = ? AND deleted_commit IS NULL`).run(commitId, expires, e.docId);
|
|
344
|
+
}
|
|
345
|
+
ftsDeleteDoc(db, e.docId);
|
|
346
|
+
db.prepare("UPDATE blocks SET deleted_commit = ? WHERE doc_id = ? AND deleted_commit IS NULL").run(commitId, e.docId);
|
|
347
|
+
db.prepare("UPDATE docs SET deleted_commit = ? WHERE doc_id = ?").run(commitId, e.docId);
|
|
348
|
+
}
|
|
349
|
+
//# sourceMappingURL=commit.js.map
|
|
@@ -33,9 +33,9 @@ export declare class FsDocStore implements DocStore {
|
|
|
33
33
|
export declare class NullDocStore implements DocStore {
|
|
34
34
|
exists(): boolean;
|
|
35
35
|
read(): string | null;
|
|
36
|
-
write(): void;
|
|
37
|
-
rename(): void;
|
|
38
|
-
remove(): void;
|
|
36
|
+
write(_path: string, _bytes: string): void;
|
|
37
|
+
rename(_from: string, _to: string): void;
|
|
38
|
+
remove(_path: string): void;
|
|
39
39
|
recordStat(): void;
|
|
40
40
|
clearStat(): void;
|
|
41
41
|
}
|