@cotal-ai/connector-core 0.18.0 → 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/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +12 -2
- package/dist/agent.js.map +1 -1
- package/dist/agui-holder.d.ts +89 -0
- package/dist/agui-holder.d.ts.map +1 -0
- package/dist/agui-holder.js +135 -0
- package/dist/agui-holder.js.map +1 -0
- package/dist/agui-render.d.ts +100 -0
- package/dist/agui-render.d.ts.map +1 -0
- package/dist/agui-render.js +283 -0
- package/dist/agui-render.js.map +1 -0
- package/dist/agui-wal-path.d.ts +103 -0
- package/dist/agui-wal-path.d.ts.map +1 -0
- package/dist/agui-wal-path.js +324 -0
- package/dist/agui-wal-path.js.map +1 -0
- package/dist/agui.d.ts +727 -0
- package/dist/agui.d.ts.map +1 -0
- package/dist/agui.js +1114 -0
- package/dist/agui.js.map +1 -0
- package/dist/docs-bundle.generated.d.ts.map +1 -1
- package/dist/docs-bundle.generated.js +16 -9
- package/dist/docs-bundle.generated.js.map +1 -1
- package/dist/durable-source.d.ts +127 -0
- package/dist/durable-source.d.ts.map +1 -0
- package/dist/durable-source.js +219 -0
- package/dist/durable-source.js.map +1 -0
- package/dist/event-wal.d.ts +265 -0
- package/dist/event-wal.d.ts.map +1 -0
- package/dist/event-wal.js +698 -0
- package/dist/event-wal.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/launch.d.ts +78 -2
- package/dist/launch.d.ts.map +1 -1
- package/dist/launch.js +120 -0
- package/dist/launch.js.map +1 -1
- package/dist/tool-specs.d.ts +40 -2
- package/dist/tool-specs.d.ts.map +1 -1
- package/dist/tool-specs.js +51 -1
- package/dist/tool-specs.js.map +1 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +5 -6
- package/dist/tools.js.map +1 -1
- package/package.json +8 -2
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The event emitter's write-ahead log: ONE file per thread, read once at boot.
|
|
3
|
+
*
|
|
4
|
+
* The emitter publishes to a per-principal, per-thread subject under an OPTIMISTIC-CONCURRENCY
|
|
5
|
+
* expectation, so it must know the tip it expects (`E`) and the dedup id it froze — and it must
|
|
6
|
+
* know them across a crash. That is all this file is: the durable half of the state machine in
|
|
7
|
+
* of the design, with the transitions kept honest by construction rather than by comment.
|
|
8
|
+
*
|
|
9
|
+
* THE ORDERING RULE THE WHOLE DESIGN RESTS ON. Transition 1 records `sent_unacked` and only THEN
|
|
10
|
+
* publishes; transition 2 records `acked` on a NON-duplicate ack, durably, BEFORE the frontier
|
|
11
|
+
* moves; transition 3 folds the frontier and clears pending. The `acked` state looks redundant
|
|
12
|
+
* until you remove it: an earlier draft committed the cursor and then unlinked pending, on the
|
|
13
|
+
* reasoning that a crash between them would "re-publish, and CAS or in-window dedupe absorbs it".
|
|
14
|
+
* That is false under these same rules. Pending still holds the PRE-STORE `E`, the tip has moved to
|
|
15
|
+
* `E+1`, so recovery takes a CAS loss — which is fail-loud — and the emitter wedges FOREVER on a
|
|
16
|
+
* frame that already landed. Making success durable before the frontier moves is what stops a
|
|
17
|
+
* completed store re-entering the fail-loud path.
|
|
18
|
+
*
|
|
19
|
+
* NOTHING HERE IS EVER REPAIRED. Every unreadable, mixed-vintage or impossible state fails loud.
|
|
20
|
+
* Discarding a WAL is indistinguishable from the case where the frame DID land, so a "recovery"
|
|
21
|
+
* heuristic here is a silent-loss path wearing a helpful face.
|
|
22
|
+
*/
|
|
23
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
24
|
+
import { constants } from "node:fs";
|
|
25
|
+
import { open, readFile, rename, unlink } from "node:fs/promises";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import { assertIdToken } from "@cotal-ai/core";
|
|
28
|
+
/**
|
|
29
|
+
* Bump ONLY with a migration. An OLDER document is migrated forward; a NEWER one fails loud.
|
|
30
|
+
*
|
|
31
|
+
* **v2 added `brackets`** — the AG-UI bracket machine's state, persisted so a mid-run restart can
|
|
32
|
+
* continue instead of refusing the first event it re-reads.
|
|
33
|
+
*
|
|
34
|
+
* **v3 added `gen`** — a counter this writer bumps on every durable replace, so a write can tell
|
|
35
|
+
* whether the document it is about to replace is the one it last read. See {@link EventWal.write}.
|
|
36
|
+
* A v2 document has never been written by a generation-aware writer, so it migrates forward at
|
|
37
|
+
* generation 0; from v3 on the field is REQUIRED, because "absent" and "zero" would otherwise be
|
|
38
|
+
* the same value and stripping the field would disable the guard silently.
|
|
39
|
+
*
|
|
40
|
+
* **THE MIGRATION IS FORWARD-ONLY, AND THAT MAKES THE STATE OUTLIVE A CODE ROLLBACK.** Once a
|
|
41
|
+
* process writes v2, reverting the code does NOT revert the state: the older build refuses the
|
|
42
|
+
* document it now finds. That is the right trade — fail-loud beats silently reading a schema you do
|
|
43
|
+
* not understand — but it converts "revert the commit" into "revert the commit and hand-migrate the
|
|
44
|
+
* state", and the person doing the reverting will otherwise discover that at the worst moment. So
|
|
45
|
+
* the refusal below distinguishes NEWER-than-this-code from unknown, and says which migration.
|
|
46
|
+
* There is deliberately no downgrade path: a lossy downgrade is worse than a halt.
|
|
47
|
+
*/
|
|
48
|
+
export const EVENT_WAL_VERSION = 3;
|
|
49
|
+
/** Every refusal in this file is one of these, so a caller can never mistake it for an I/O blip. */
|
|
50
|
+
export class WalCorruptError extends Error {
|
|
51
|
+
path;
|
|
52
|
+
invariant;
|
|
53
|
+
constructor(path, invariant, detail) {
|
|
54
|
+
super(`event WAL at ${path} is unusable (${invariant}): ${detail}`);
|
|
55
|
+
this.path = path;
|
|
56
|
+
this.invariant = invariant;
|
|
57
|
+
this.name = "WalCorruptError";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Thrown when this handle's document is not the one on disk any more.
|
|
62
|
+
*
|
|
63
|
+
* A DISTINCT type from {@link WalCorruptError} because the file is not corrupt: it is FINE, and it
|
|
64
|
+
* belongs to somebody else's newer view. An operator reading "corrupt" would go looking for a bad
|
|
65
|
+
* disk; the actual situation is two writers, and only one of them is current.
|
|
66
|
+
*/
|
|
67
|
+
export class WalStaleWriterError extends Error {
|
|
68
|
+
path;
|
|
69
|
+
expectedGen;
|
|
70
|
+
foundGen;
|
|
71
|
+
constructor(path, expectedGen, foundGen) {
|
|
72
|
+
super(`event WAL at ${path} was written by another handle since this one read it (this handle expects ` +
|
|
73
|
+
`generation ${expectedGen}, the file is at ${foundGen === undefined ? "no file at all" : `generation ${foundGen}`}) — ` +
|
|
74
|
+
`refusing to overwrite it. A second emitter or a stale handle is writing this principal's log.`);
|
|
75
|
+
this.path = path;
|
|
76
|
+
this.expectedGen = expectedGen;
|
|
77
|
+
this.foundGen = foundGen;
|
|
78
|
+
this.name = "WalStaleWriterError";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const isSafeNonNegInt = (n) => Number.isSafeInteger(n) && n >= 0;
|
|
82
|
+
/**
|
|
83
|
+
* The generation a raw document carries, stated in ONE place so the load path and the write path
|
|
84
|
+
* cannot drift apart about what a missing field means.
|
|
85
|
+
*
|
|
86
|
+
* Pre-v3 documents predate the field entirely, and 0 is that fact rather than a default: they have
|
|
87
|
+
* never been through a generation-aware write, so the first v3 write stamps 1 and every later
|
|
88
|
+
* comparison is exact. From v3 on the field is required — an absent one is refused, because a
|
|
89
|
+
* writer that treated it as 0 would silently accept a document with the guard stripped out.
|
|
90
|
+
*/
|
|
91
|
+
function docGeneration(path, doc) {
|
|
92
|
+
if (typeof doc.v === "number" && doc.v < 3)
|
|
93
|
+
return 0;
|
|
94
|
+
if (!isSafeNonNegInt(doc.gen))
|
|
95
|
+
throw new WalCorruptError(path, "gen is a safe non-negative integer", `found gen=${String(doc.gen)} on a v${String(doc.v)} document`);
|
|
96
|
+
return doc.gen;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Create a file EXCLUSIVELY and WITHOUT following a symlink, at mode 0600.
|
|
100
|
+
*
|
|
101
|
+
* **Exported so a test can drive the shipped flags rather than recompose them.** A cell that builds
|
|
102
|
+
* `O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW` itself is testing a COPY of this rule: it stays green
|
|
103
|
+
* while the production open drifts or loses a flag. That is not hypothetical here — the first
|
|
104
|
+
* version of those cells did exactly that, and a mutation reverting the real open to `"w"` left the
|
|
105
|
+
* suite fully green. Driving this function is what makes the mutation land.
|
|
106
|
+
*
|
|
107
|
+
* `O_EXCL` refuses a pre-existing file rather than adopting it — which matters because `"w"` does
|
|
108
|
+
* NOT re-chmod an existing inode, so an adopted 0644 temp would carry its mode onto the renamed WAL
|
|
109
|
+
* and expose `pending.id`. `O_NOFOLLOW` refuses a symlink rather than truncating its target.
|
|
110
|
+
*/
|
|
111
|
+
export async function openExclusiveNoFollow(path) {
|
|
112
|
+
return open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The boot invariant for an `acked` pending, checked in ONE place and stated positively.
|
|
116
|
+
*
|
|
117
|
+
* A WAL whose frontier is NEWER than its acked pending is a file of MIXED VINTAGE, and the reason
|
|
118
|
+
* that must fail loud rather than "already folded, drop it and continue" is not fussiness. If the
|
|
119
|
+
* frontier came from a later copy than the pending, its `sourceCursor` may ALREADY have advanced
|
|
120
|
+
* past events whose frames were never published — so resuming from it drops those events with no
|
|
121
|
+
* gap anywhere for a consumer to notice. Nothing inside the WAL distinguishes "genuinely folded
|
|
122
|
+
* already" from "this frontier is from a foreign vintage", which puts it in the same class as a
|
|
123
|
+
* corrupt or tuple-mismatched document.
|
|
124
|
+
*
|
|
125
|
+
* Named relations rather than one compound boolean: an operator who cannot see WHICH relation broke
|
|
126
|
+
* cannot act on it, and a single fused check gives a mutation only one cell to kill.
|
|
127
|
+
*
|
|
128
|
+
* **THERE IS DELIBERATELY NO `sourceCursor` RELATION HERE, AND THAT IS A CORRECTION.**
|
|
129
|
+
* An earlier version asserted `pending.sourceCursor >= frontier.sourceCursor` using string `<`.
|
|
130
|
+
* A cursor is **OPAQUE** — `DurableSource` defines it per source and states that a caller "never
|
|
131
|
+
* parses it". `JsonlFileSource` happens to emit `dev:ino:offset:seal`, and lexicographic order is
|
|
132
|
+
* NOT offset order once the digit width changes, so that check was wrong in BOTH directions:
|
|
133
|
+
*
|
|
134
|
+
* frontier offset 8, acked pending offset 45 → REFUSED a healthy WAL ("45" < "8")
|
|
135
|
+
* frontier offset 170, acked pending offset 98 → ACCEPTED a mixed-vintage one ("98" > "170")
|
|
136
|
+
*
|
|
137
|
+
* The first wedges recovery on ordinary source growth across any digit boundary — on the exact
|
|
138
|
+
* crash window the `acked` state exists to survive. The second silently admits the mixed vintage
|
|
139
|
+
* the relation was written to refuse. Found independently by two reviewers, then reproduced here.
|
|
140
|
+
*
|
|
141
|
+
* The root cause is not the operator; it is that **ordering an opaque value requires parsing it**,
|
|
142
|
+
* and parsing it would bind this WAL to one source's format — breaking the abstraction the moment
|
|
143
|
+
* a store-backed source uses something else (OpenCode's `part.id`, a rollout path, an API token).
|
|
144
|
+
* So the relation is not fixed, it is REMOVED: it cannot be stated correctly at this layer.
|
|
145
|
+
*
|
|
146
|
+
* What remains is sufficient and well-defined, because it uses the WAL's OWN monotonic counters
|
|
147
|
+
* rather than a foreign key: an acked frame must be exactly the frontier's successor, and its
|
|
148
|
+
* ackSeq must be ahead of the frontier's. A document whose halves come from different vintages
|
|
149
|
+
* disagrees on those too, since both sides are written by this file.
|
|
150
|
+
*/
|
|
151
|
+
function assertPendingVintage(path, p, f) {
|
|
152
|
+
// ── RELATIONS THAT HOLD FOR *EITHER* TAG ──
|
|
153
|
+
//
|
|
154
|
+
// These were previously checked ONLY for `acked`, which left `sent_unacked` — the crash window
|
|
155
|
+
// transition 1 exists to survive — almost unguarded. Documents with `E` disagreeing with the
|
|
156
|
+
// frontier's tip, or a `seq` that is not the frontier's successor, LOADED and would then retry a
|
|
157
|
+
// frozen expectation that cannot be the honest successor of this frontier: a permanent CAS halt,
|
|
158
|
+
// or a publish at the wrong stream position, with no loud corrupt at open.
|
|
159
|
+
//
|
|
160
|
+
// The asymmetry was mine: I wrote three named relations for the path that has already succeeded
|
|
161
|
+
// and almost none for the path that is still in flight. Found by fmae-rev-sec, reproduced by
|
|
162
|
+
// fmae-rev-eng and fmae-rev-wal, then here.
|
|
163
|
+
if (p.E !== f.lastSubjectSeq)
|
|
164
|
+
throw new WalCorruptError(path, "pending.E === frontier.lastSubjectSeq", `pending.E=${p.E} frontier.lastSubjectSeq=${f.lastSubjectSeq} — the frozen expectation is not ` +
|
|
165
|
+
`the tip this WAL believes, so retrying it can only CAS-halt or append at the wrong position`);
|
|
166
|
+
if (p.seq !== f.seq + 1)
|
|
167
|
+
throw new WalCorruptError(path, "pending.seq === frontier.seq + 1", `pending.seq=${p.seq} frontier.seq=${f.seq} — a pending frame must be exactly the frontier's successor`);
|
|
168
|
+
if (p.state === "sent_unacked") {
|
|
169
|
+
// The tag says "not yet acked". An ackSeq here means the document contradicts its own tag, and
|
|
170
|
+
// guessing which half is true is exactly the repair this file never does.
|
|
171
|
+
if (p.ackSeq !== undefined)
|
|
172
|
+
throw new WalCorruptError(path, "sent_unacked has no ackSeq", `state is "sent_unacked" but ackSeq=${String(p.ackSeq)} — the document contradicts its own tag`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (!isSafeNonNegInt(p.ackSeq))
|
|
176
|
+
throw new WalCorruptError(path, "acked.ackSeq present", `state is "acked" but ackSeq is ${String(p.ackSeq)}`);
|
|
177
|
+
if (!(p.ackSeq > f.lastSubjectSeq))
|
|
178
|
+
throw new WalCorruptError(path, "acked.ackSeq > frontier.lastSubjectSeq", `ackSeq=${p.ackSeq} frontier.lastSubjectSeq=${f.lastSubjectSeq} — the frontier is of a later ` +
|
|
179
|
+
`vintage than the acked frame, so its sourceCursor may already have passed events that were ` +
|
|
180
|
+
`never published; resuming would drop them with no gap for a consumer to see`);
|
|
181
|
+
// NO sourceCursor comparison — see the note above. The cursor is opaque; ordering it here is not
|
|
182
|
+
// a thing this layer can do correctly, and doing it with string `<` was wrong in both directions.
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Validate a persisted bracket state, or refuse it.
|
|
186
|
+
*
|
|
187
|
+
* `nullable` distinguishes the document-level field (which may legitimately be `null` for unknown)
|
|
188
|
+
* from a pending frame's (which never may — a frame that was decided has a state that was decided
|
|
189
|
+
* with it). Passing the same shape check over both is what keeps the two from drifting.
|
|
190
|
+
*
|
|
191
|
+
* The arrays are checked ELEMENT BY ELEMENT rather than by `Array.isArray` alone. A JSON array is a
|
|
192
|
+
* shape a foreign or hand-edited producer can satisfy while carrying numbers or objects, and this
|
|
193
|
+
* value is loaded straight into a `Set` that decides whether an event is refused.
|
|
194
|
+
*/
|
|
195
|
+
function parseBrackets(path, field, v, nullable) {
|
|
196
|
+
if (v === null && nullable)
|
|
197
|
+
return null;
|
|
198
|
+
if (typeof v !== "object" || v === null)
|
|
199
|
+
throw new WalCorruptError(path, `${field} is an object${nullable ? " or null" : ""}`, JSON.stringify(v));
|
|
200
|
+
const b = v;
|
|
201
|
+
if (b.run !== undefined && typeof b.run !== "string")
|
|
202
|
+
throw new WalCorruptError(path, `${field}.run is a string or absent`, JSON.stringify(b.run));
|
|
203
|
+
const lists = { text: b.text, reasoning: b.reasoning, tools: b.tools };
|
|
204
|
+
for (const [k, list] of Object.entries(lists)) {
|
|
205
|
+
if (!Array.isArray(list) || list.some((x) => typeof x !== "string"))
|
|
206
|
+
throw new WalCorruptError(path, `${field}.${k} is an array of strings`, JSON.stringify(list));
|
|
207
|
+
}
|
|
208
|
+
// A closed run with open messages is not a state any transition can produce: `RUN_FINISHED` is
|
|
209
|
+
// refused while anything it opened is still open. Refusing it here keeps a hand-edited document
|
|
210
|
+
// from restoring a machine into a state the machine itself would never enter.
|
|
211
|
+
if (b.run === undefined && b.text.concat(b.reasoning, b.tools).length > 0)
|
|
212
|
+
throw new WalCorruptError(path, `${field} has no open run while messages or tool calls are open`, JSON.stringify(b));
|
|
213
|
+
return { run: b.run, text: b.text, reasoning: b.reasoning, tools: b.tools };
|
|
214
|
+
}
|
|
215
|
+
function parseDoc(path, raw, space, threadId, principal) {
|
|
216
|
+
let d;
|
|
217
|
+
try {
|
|
218
|
+
d = JSON.parse(raw);
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
throw new WalCorruptError(path, "parseable JSON", e.message);
|
|
222
|
+
}
|
|
223
|
+
if (typeof d !== "object" || d === null)
|
|
224
|
+
throw new WalCorruptError(path, "document is an object", typeof d);
|
|
225
|
+
const doc = d;
|
|
226
|
+
// Version FIRST: on an unknown version every field below is a guess about a foreign schema.
|
|
227
|
+
//
|
|
228
|
+
// NEWER and OLDER are different situations and are answered differently. Older is MIGRATED below.
|
|
229
|
+
// Newer is refused with a message that says the STATE IS NEWER THAN THE CODE and names what
|
|
230
|
+
// introduced it — because "unknown version" sends an operator looking for corruption, and the
|
|
231
|
+
// actual situation is a rollback that left state behind. There is no downgrade: a lossy one would
|
|
232
|
+
// be worse than this halt.
|
|
233
|
+
if (typeof doc.v !== "number" || !Number.isSafeInteger(doc.v) || doc.v < 1)
|
|
234
|
+
throw new WalCorruptError(path, "v is a positive integer", `found v=${String(doc.v)}`);
|
|
235
|
+
if (doc.v > EVENT_WAL_VERSION)
|
|
236
|
+
throw new WalCorruptError(path, `v <= ${EVENT_WAL_VERSION}`, `this WAL is v${doc.v} and this build understands v${EVENT_WAL_VERSION} — THE STATE IS NEWER ` +
|
|
237
|
+
`THAN THE CODE, which is what a code rollback across the v2 migration (persisted bracket ` +
|
|
238
|
+
`state) or the v3 one (the write generation) leaves behind. The migration is forward-only ` +
|
|
239
|
+
`by design: there is no downgrade, ` +
|
|
240
|
+
`because a lossy one would silently discard state this file exists to preserve. Run the ` +
|
|
241
|
+
`newer build, or move this WAL aside and accept that the thread restarts from a new epoch.`);
|
|
242
|
+
// The tuple: a WAL that belongs to a different space, thread or principal is not ours to resume
|
|
243
|
+
// from. All THREE are checked, because all three are hashed path components and the design stores
|
|
244
|
+
// the unhashed tuple inside the file precisely so a collided or mis-resolved directory is a loud
|
|
245
|
+
// mismatch instead of silent cross-talk.
|
|
246
|
+
if (doc.space !== space)
|
|
247
|
+
throw new WalCorruptError(path, "space matches", `WAL space=${String(doc.space)} caller=${space}`);
|
|
248
|
+
if (doc.threadId !== threadId)
|
|
249
|
+
throw new WalCorruptError(path, "threadId matches", `WAL threadId=${String(doc.threadId)} caller=${threadId}`);
|
|
250
|
+
if (doc.principal !== principal)
|
|
251
|
+
throw new WalCorruptError(path, "principal matches", `WAL principal=${String(doc.principal)} caller=${principal}`);
|
|
252
|
+
if (typeof doc.epoch !== "string" || doc.epoch.length === 0)
|
|
253
|
+
throw new WalCorruptError(path, "epoch is a non-empty string", String(doc.epoch));
|
|
254
|
+
const f = doc.frontier;
|
|
255
|
+
if (!f || !isSafeNonNegInt(f.seq) || !isSafeNonNegInt(f.lastSubjectSeq))
|
|
256
|
+
throw new WalCorruptError(path, "frontier is well-formed", JSON.stringify(doc.frontier));
|
|
257
|
+
if (f.sourceCursor !== undefined && typeof f.sourceCursor !== "string")
|
|
258
|
+
throw new WalCorruptError(path, "frontier.sourceCursor is a string or absent", typeof f.sourceCursor);
|
|
259
|
+
// A NONZERO FRONTIER MUST CARRY THE POSITION IT WAS DERIVED FROM.
|
|
260
|
+
//
|
|
261
|
+
// This guard was omitted on the reasoning that no shipped transition can produce a nonzero
|
|
262
|
+
// frontier without a cursor — which is exactly backwards. A recovery component must refuse the
|
|
263
|
+
// states its own writer CANNOT produce, because those are precisely the states corruption
|
|
264
|
+
// produces. The happy path is not the input domain.
|
|
265
|
+
//
|
|
266
|
+
// The cost of accepting it is silent loss, and it is not theoretical: the emitter resumes by
|
|
267
|
+
// reading forward from this cursor, and `read(undefined)` does not resume — it ADOPTS AT THE
|
|
268
|
+
// CURRENT END (`durable-source.ts:155-156`). So an absent cursor makes every unread record
|
|
269
|
+
// vanish, transition 4 durably commits the new end, and nothing is left to notice it: no frame
|
|
270
|
+
// published, and therefore no gap in the consumer's `seq` either.
|
|
271
|
+
//
|
|
272
|
+
// A cursor at a ZERO frontier is legal and must stay so: a cursor-only advance over a
|
|
273
|
+
// source range that mapped to no events is exactly that state, and it is the ONLY asymmetry
|
|
274
|
+
// here. `seq` and `lastSubjectSeq` move together in transition 3 and reset together in
|
|
275
|
+
// abandonment, so a mixed pair is impossible by every shipped transition and refused for the
|
|
276
|
+
// same reason as the missing cursor.
|
|
277
|
+
if ((f.seq === 0) !== (f.lastSubjectSeq === 0))
|
|
278
|
+
throw new WalCorruptError(path, "frontier.seq and lastSubjectSeq are both zero or both nonzero", JSON.stringify(f));
|
|
279
|
+
if ((f.seq > 0 || f.lastSubjectSeq > 0) && typeof f.sourceCursor !== "string")
|
|
280
|
+
throw new WalCorruptError(path, "a nonzero frontier carries its sourceCursor", JSON.stringify(f));
|
|
281
|
+
// A MISSING `pending` KEY IS NOT AN EXPLICIT `null`, AND THE DIFFERENCE IS THE WHOLE POINT.
|
|
282
|
+
// `null` is this writer stating that nothing is outstanding. An ABSENT key is a document that
|
|
283
|
+
// never said — a truncated tail, a hand edit, a foreign producer — and accepting it silently
|
|
284
|
+
// reclassifies "we may have published and do not know" as "we have nothing in flight", which is
|
|
285
|
+
// the one downgrade a write-ahead log must never make on its own authority.
|
|
286
|
+
//
|
|
287
|
+
// ONE check, not two. The first version guarded the absent key with `hasOwnProperty` AND the
|
|
288
|
+
// shape with a `typeof`, and the mutation proof caught the redundancy: deleting the
|
|
289
|
+
// `hasOwnProperty` half killed nothing, because an absent key is `undefined` and the shape check
|
|
290
|
+
// already refuses it with the same invariant. Two mechanisms preventing one outcome means a cell
|
|
291
|
+
// asserting that outcome proves neither of them — so the belt came off and the cells now bite on
|
|
292
|
+
// the one guard that does the work.
|
|
293
|
+
if (doc.pending !== null && typeof doc.pending !== "object")
|
|
294
|
+
throw new WalCorruptError(path, "pending is present (null or an object)", doc.pending === undefined ? "the key is absent, which is not the same as null" : typeof doc.pending);
|
|
295
|
+
let pending = null;
|
|
296
|
+
if (doc.pending !== null) {
|
|
297
|
+
const p = doc.pending;
|
|
298
|
+
if (p.state !== "sent_unacked" && p.state !== "acked")
|
|
299
|
+
throw new WalCorruptError(path, "pending.state is a known tag", String(p.state));
|
|
300
|
+
if (typeof p.id !== "string" || p.id.length === 0)
|
|
301
|
+
throw new WalCorruptError(path, "pending.id is a non-empty string", String(p.id));
|
|
302
|
+
// ...and it must satisfy the WIRE grammar, not merely be a string. `beginSend` validates with
|
|
303
|
+
// `assertIdToken` on the way IN; open accepted anything non-empty on the way OUT — so a corrupted
|
|
304
|
+
// or hand-edited id (`"has.dots"`) was ADOPTED at recovery and then rejected by
|
|
305
|
+
// `multicastExpecting` on every republish attempt. That turns disk corruption into a permanent
|
|
306
|
+
// runtime wedge instead of a refusal at open, which inverts this file's posture. Validated with
|
|
307
|
+
// the SHIPPED grammar rather than a second copy that could drift from it.
|
|
308
|
+
try {
|
|
309
|
+
assertIdToken(p.id, "event WAL pending.id");
|
|
310
|
+
}
|
|
311
|
+
catch (e) {
|
|
312
|
+
throw new WalCorruptError(path, "pending.id satisfies the wire id grammar", e.message);
|
|
313
|
+
}
|
|
314
|
+
if (!isSafeNonNegInt(p.E) || !isSafeNonNegInt(p.seq))
|
|
315
|
+
throw new WalCorruptError(path, "pending E/seq are safe non-negative integers", JSON.stringify(p));
|
|
316
|
+
if (typeof p.sourceCursor !== "string")
|
|
317
|
+
throw new WalCorruptError(path, "pending.sourceCursor is a string", typeof p.sourceCursor);
|
|
318
|
+
// The frozen body must still be PUBLISHABLE, for the reason the id check above exists: a value
|
|
319
|
+
// that the wire rejects, adopted at recovery, turns disk corruption into a permanent wedge
|
|
320
|
+
// rather than a refusal at open.
|
|
321
|
+
//
|
|
322
|
+
// The bar is `multicastExpecting`'s OWN precondition — a non-empty array — and deliberately not
|
|
323
|
+
// core's `isMessagePart`. That predicate is core's INBOUND validator; the publish path does not
|
|
324
|
+
// apply it, so mirroring it here would refuse documents that would in fact publish, and would
|
|
325
|
+
// make this file a second, drifting source of truth about what a part is.
|
|
326
|
+
if (!Array.isArray(p.body) || p.body.length === 0)
|
|
327
|
+
throw new WalCorruptError(path, "pending.body is a non-empty array of parts", JSON.stringify(p.body));
|
|
328
|
+
// A v1 document with a frame IN FLIGHT cannot be migrated, and this is the one migration case
|
|
329
|
+
// that must refuse rather than default. The frame's bracket state is not recoverable from
|
|
330
|
+
// anything in the file, and inventing one would restore a machine into a state that never
|
|
331
|
+
// existed — on the exact path (`sent_unacked` recovery) where a wrong answer republishes.
|
|
332
|
+
// A v1 WAL at REST migrates cleanly; only one mid-flight does not.
|
|
333
|
+
if (doc.v === 1)
|
|
334
|
+
throw new WalCorruptError(path, "a v1 WAL has no frame in flight", `this v1 document holds a ${String(p.state)} frame, and v2 requires the ` +
|
|
335
|
+
`bracket state that belongs to it — which v1 never recorded and nothing here can reconstruct. ` +
|
|
336
|
+
`Let the older build settle this frame first, then start the newer one.`);
|
|
337
|
+
p.brackets = parseBrackets(path, "pending.brackets", p.brackets, false);
|
|
338
|
+
pending = p;
|
|
339
|
+
assertPendingVintage(path, pending, f);
|
|
340
|
+
}
|
|
341
|
+
// v1 KNEW NOTHING ABOUT BRACKETS, so migrating one forward yields `null` — unknown — and NOT an
|
|
342
|
+
// empty state. The document genuinely cannot say what was open when it was written, and saying
|
|
343
|
+
// "nothing was open" on its behalf is inventing an observation it never made.
|
|
344
|
+
const brackets = doc.v === 1 ? null : parseBrackets(path, "brackets", doc.brackets, true);
|
|
345
|
+
return {
|
|
346
|
+
v: EVENT_WAL_VERSION, // MIGRATED IN MEMORY; it reaches disk on the next durable write.
|
|
347
|
+
// The generation as the FILE states it, so the first write from this handle compares against
|
|
348
|
+
// what it actually read rather than against its own migrated shape.
|
|
349
|
+
gen: docGeneration(path, doc),
|
|
350
|
+
space,
|
|
351
|
+
epoch: doc.epoch,
|
|
352
|
+
threadId,
|
|
353
|
+
principal,
|
|
354
|
+
frontier: f,
|
|
355
|
+
pending,
|
|
356
|
+
brackets,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
export class EventWal {
|
|
360
|
+
path;
|
|
361
|
+
doc;
|
|
362
|
+
/**
|
|
363
|
+
* Every mutation runs one-at-a-time on this chain.
|
|
364
|
+
*
|
|
365
|
+
* Without it, two concurrent `beginSend` calls both read `this.doc.pending === null` before either
|
|
366
|
+
* durable replace finishes — the guard is an in-memory read that is NOT atomic with the write
|
|
367
|
+
* across its `await` points. Reviewers reproduced the split: one call fulfils, one rejects, and
|
|
368
|
+
* the process is left holding `pending.id === "A"` in memory while the disk says `"B"`. Recovery
|
|
369
|
+
* would then resume the frame on disk while the live emitter retries the other, which breaks the
|
|
370
|
+
* one thing this file exists to guarantee — that `id` and `E` are frozen and agreed.
|
|
371
|
+
*
|
|
372
|
+
* A per-instance chain is sufficient and honest about its scope, and the scope is narrower than it
|
|
373
|
+
* once claimed: it serializes THIS INSTANCE's callers. It does nothing about a SECOND `EventWal`
|
|
374
|
+
* on the same file, in this process or another — the chain is per object, so two objects are two
|
|
375
|
+
* chains and both of them "succeed". That gap was described here as "solved upstream by the
|
|
376
|
+
* principal-level lock" while no lock was ever acquired. Two things close it now, and neither is
|
|
377
|
+
* this chain: `acquirePrincipalLock` refuses a second emitter for the principal at start, and
|
|
378
|
+
* {@link EventWal.assertNotClobbering} refuses a stale handle's write even when it got past that.
|
|
379
|
+
*/
|
|
380
|
+
chain = Promise.resolve();
|
|
381
|
+
/** Run `op` after every previously-queued mutation, whether they resolved or threw. */
|
|
382
|
+
serialize(op) {
|
|
383
|
+
const next = this.chain.then(op, op);
|
|
384
|
+
// Keep the chain alive after a rejection so one failed mutation cannot wedge every later one.
|
|
385
|
+
this.chain = next.catch(() => undefined);
|
|
386
|
+
return next;
|
|
387
|
+
}
|
|
388
|
+
constructor(path, doc) {
|
|
389
|
+
this.path = path;
|
|
390
|
+
this.doc = doc;
|
|
391
|
+
}
|
|
392
|
+
get epoch() { return this.doc.epoch; }
|
|
393
|
+
/** The principal this WAL was loaded FOR — exposed so a consumer can prove it is holding its own.
|
|
394
|
+
* `open()` already refuses a document whose stored principal disagrees, but that check protects
|
|
395
|
+
* the FILE, not the caller: an emitter handed the wrong WAL object entirely would sail past it. */
|
|
396
|
+
get principal() { return this.doc.principal; }
|
|
397
|
+
get threadId() { return this.doc.threadId; }
|
|
398
|
+
/** The bracket machine at the folded position, or `null` when the document cannot say (migrated
|
|
399
|
+
* from v1). The two are different facts; see {@link WalDoc.brackets}. */
|
|
400
|
+
get brackets() { return this.doc.brackets; }
|
|
401
|
+
get frontier() { return { ...this.doc.frontier }; }
|
|
402
|
+
get pending() { return this.doc.pending ? { ...this.doc.pending } : null; }
|
|
403
|
+
/**
|
|
404
|
+
* Load an existing WAL, or start a virgin one.
|
|
405
|
+
*
|
|
406
|
+
* `subjectMayExist` is the caller's honest statement about whether this principal+thread could
|
|
407
|
+
* already have published. It is NOT a convenience flag: with it true, a missing or empty WAL is a
|
|
408
|
+
* refusal, because the tip cannot be inferred — agent creds hold no read shape over the subject,
|
|
409
|
+
* and guessing `E := 0` either CAS-halts forever or appends under a stale expectation. Recovery
|
|
410
|
+
* from that state is an explicit operator act, never a startup heuristic.
|
|
411
|
+
*/
|
|
412
|
+
static async open(path, opts) {
|
|
413
|
+
let raw;
|
|
414
|
+
let bytes;
|
|
415
|
+
try {
|
|
416
|
+
bytes = await readFile(path);
|
|
417
|
+
}
|
|
418
|
+
catch (e) {
|
|
419
|
+
if (e.code !== "ENOENT")
|
|
420
|
+
throw e;
|
|
421
|
+
}
|
|
422
|
+
if (bytes !== undefined) {
|
|
423
|
+
// FATAL UTF-8 — never `readFile(path, "utf8")`. Node's default decoder SUBSTITUTES U+FFFD for
|
|
424
|
+
// invalid bytes, so a corrupted file arrives as a changed-but-parseable document: a single raw
|
|
425
|
+
// 0xff inside the epoch string loaded cleanly with `epoch` silently rewritten. For a file whose
|
|
426
|
+
// whole posture is that every unreadable state fails loud, "quietly altered and accepted" is
|
|
427
|
+
// the one outcome it must not produce — the identity bytes recovery depends on would be the
|
|
428
|
+
// decoder's invention. `JsonlFileSource` in this same package already decodes fatally for
|
|
429
|
+
// exactly this class; this is that rule applied where it was missing, not a new one.
|
|
430
|
+
try {
|
|
431
|
+
raw = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
throw new WalCorruptError(path, "the file is valid UTF-8", "invalid UTF-8 bytes; refusing rather than substituting U+FFFD");
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (raw === undefined) {
|
|
438
|
+
if (opts.subjectMayExist)
|
|
439
|
+
throw new WalCorruptError(path, "WAL exists when the subject may", "no WAL file, but this thread may already have published");
|
|
440
|
+
return new EventWal(path, EventWal.virgin(opts.space, opts.threadId, opts.principal));
|
|
441
|
+
}
|
|
442
|
+
// A ZERO-BYTE file is its own case, and the trap this design is shaped to fall into: it reads
|
|
443
|
+
// as "no content, therefore fresh thread, therefore E := 0" — the exact guess the missing-WAL
|
|
444
|
+
// rule already forbids by another route. An atomic temp+rename never produces one; a filesystem
|
|
445
|
+
// that lost the tail does. It is NEVER virgin.
|
|
446
|
+
if (raw.length === 0)
|
|
447
|
+
throw new WalCorruptError(path, "WAL is non-empty", "the file is zero bytes — distinct from missing and never treated as a virgin thread");
|
|
448
|
+
return new EventWal(path, parseDoc(path, raw, opts.space, opts.threadId, opts.principal));
|
|
449
|
+
}
|
|
450
|
+
static virgin(space, threadId, principal) {
|
|
451
|
+
return {
|
|
452
|
+
v: EVENT_WAL_VERSION,
|
|
453
|
+
// Nothing has been written yet, so the first durable replace stamps generation 1 and must find
|
|
454
|
+
// NO FILE. A file present at generation 0 means somebody else created it while this handle
|
|
455
|
+
// believed the thread was virgin, and that is refused rather than overwritten.
|
|
456
|
+
gen: 0,
|
|
457
|
+
space,
|
|
458
|
+
epoch: randomUUID(),
|
|
459
|
+
threadId,
|
|
460
|
+
principal,
|
|
461
|
+
frontier: { seq: 0, lastSubjectSeq: 0, sourceCursor: undefined },
|
|
462
|
+
pending: null,
|
|
463
|
+
// KNOWN empty, not unknown: a virgin thread has published nothing, so "nothing is open" is an
|
|
464
|
+
// observation this writer can actually make.
|
|
465
|
+
brackets: { run: undefined, text: [], reasoning: [], tools: [] },
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
/** Transition 1 — record the frame, with `id` and `E` frozen, BEFORE any publish. */
|
|
469
|
+
async beginSend(frame) {
|
|
470
|
+
return this.serialize(async () => {
|
|
471
|
+
if (this.doc.pending)
|
|
472
|
+
throw new Error(`event WAL ${this.path}: a frame is already pending; one emit unit is one pending frame`);
|
|
473
|
+
// THE SAME GRAMMAR THE WIRE USES, not a restatement of it. `beginSend` previously accepted ids
|
|
474
|
+
// (" ", a newline, 65 chars, dots) that `multicastExpecting`'s `assertIdToken` rejects — so T1
|
|
475
|
+
// could freeze an id on disk that can NEVER publish, wedging the emitter until an abandon. The
|
|
476
|
+
// id is supposed to be one token on the wire and in the WAL; importing the check is what makes
|
|
477
|
+
// that true rather than asserted.
|
|
478
|
+
assertIdToken(frame.id, "event WAL pending id");
|
|
479
|
+
if (frame.E !== this.doc.frontier.lastSubjectSeq)
|
|
480
|
+
throw new Error(`event WAL ${this.path}: E=${frame.E} is not the frontier's tip ${this.doc.frontier.lastSubjectSeq}`);
|
|
481
|
+
if (frame.seq !== this.doc.frontier.seq + 1)
|
|
482
|
+
throw new Error(`event WAL ${this.path}: seq=${frame.seq} is not the frontier's successor ${this.doc.frontier.seq + 1}`);
|
|
483
|
+
// Refuse an unpublishable body HERE rather than freezing it and discovering it on the retry
|
|
484
|
+
// after a crash — the same reason the id is validated on the way in. Same bar as the load
|
|
485
|
+
// guard and as `multicastExpecting`: non-empty array.
|
|
486
|
+
if (!Array.isArray(frame.body) || frame.body.length === 0)
|
|
487
|
+
throw new Error(`event WAL ${this.path}: a frame body must be a non-empty array of parts`);
|
|
488
|
+
await this.write({ ...this.doc, pending: { state: "sent_unacked", ...frame } });
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Transition 2 — a NON-duplicate ack becomes durable before the frontier moves.
|
|
493
|
+
* A duplicate ack must never reach here; the caller fails loud on one.
|
|
494
|
+
*/
|
|
495
|
+
async recordAck(ackSeq) {
|
|
496
|
+
return this.serialize(async () => {
|
|
497
|
+
const p = this.doc.pending;
|
|
498
|
+
if (!p || p.state !== "sent_unacked")
|
|
499
|
+
throw new Error(`event WAL ${this.path}: no sent_unacked frame to ack`);
|
|
500
|
+
// VALIDATE BEFORE THE DURABLE WRITE. `recordAck(-1)` was accepted, `fold()` then persisted
|
|
501
|
+
// `frontier.lastSubjectSeq = -1`, and the NEXT open refused the file — a single bad ack durably
|
|
502
|
+
// bricked the WAL while every call reported success. Fail-closed has to happen before the write,
|
|
503
|
+
// not on the boot after it.
|
|
504
|
+
if (!isSafeNonNegInt(ackSeq))
|
|
505
|
+
throw new Error(`event WAL ${this.path}: ackSeq must be a safe non-negative integer, got ${String(ackSeq)}`);
|
|
506
|
+
if (ackSeq <= this.doc.frontier.lastSubjectSeq)
|
|
507
|
+
throw new Error(`event WAL ${this.path}: ackSeq=${ackSeq} is not ahead of the frontier's tip ${this.doc.frontier.lastSubjectSeq}`);
|
|
508
|
+
await this.write({ ...this.doc, pending: { ...p, state: "acked", ackSeq } });
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
/** Transition 3 — fold the acked frame into the frontier and clear pending. */
|
|
512
|
+
async fold() {
|
|
513
|
+
return this.serialize(async () => {
|
|
514
|
+
const p = this.doc.pending;
|
|
515
|
+
if (!p || p.state !== "acked" || p.ackSeq === undefined)
|
|
516
|
+
throw new Error(`event WAL ${this.path}: no acked frame to fold`);
|
|
517
|
+
await this.write({
|
|
518
|
+
...this.doc,
|
|
519
|
+
frontier: { seq: p.seq, lastSubjectSeq: p.ackSeq, sourceCursor: p.sourceCursor },
|
|
520
|
+
pending: null,
|
|
521
|
+
// The frame's frozen state becomes the document's, so `brackets` always describes exactly the
|
|
522
|
+
// events that are PUBLISHED AND FOLDED — never a batch that was validated and not yet sent.
|
|
523
|
+
brackets: p.brackets,
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Transition 4 — a bounded source range that mapped to NOTHING.
|
|
529
|
+
*
|
|
530
|
+
* A mapper SUCCESS returning zero events advances the cursor atomically and alone: no
|
|
531
|
+
* `seq` consumed, no pending written, no publish. A mapper ERROR never advances it. Empty and
|
|
532
|
+
* failed must not share a path: conflating them turns a parser bug into silently skipped history.
|
|
533
|
+
*/
|
|
534
|
+
async advanceCursorOnly(rangeEnd) {
|
|
535
|
+
return this.serialize(async () => {
|
|
536
|
+
if (this.doc.pending)
|
|
537
|
+
throw new Error(`event WAL ${this.path}: cannot advance the cursor while a frame is pending`);
|
|
538
|
+
await this.write({ ...this.doc, frontier: { ...this.doc.frontier, sourceCursor: rangeEnd } });
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Abandonment — explicit, destructive and TOTAL. Mints a new epoch AND resets `seq`,
|
|
543
|
+
* `lastSubjectSeq` and `sourceCursor` together, reusing the same subject; the new epoch is what
|
|
544
|
+
* tells a consumer the chain broke. Partial abandonment is not a state: either all four move or
|
|
545
|
+
* the emitter stays halted. Required after a filtered channel purge, which returns the subject
|
|
546
|
+
* tip to 0 while the WAL still holds a non-zero `E`, permanently CAS-failing every later publish.
|
|
547
|
+
*/
|
|
548
|
+
async abandon() {
|
|
549
|
+
return this.serialize(async () => {
|
|
550
|
+
await this.write({
|
|
551
|
+
...this.doc,
|
|
552
|
+
epoch: randomUUID(),
|
|
553
|
+
frontier: { seq: 0, lastSubjectSeq: 0, sourceCursor: undefined },
|
|
554
|
+
pending: null,
|
|
555
|
+
// Abandonment is TOTAL, and the bracket machine is part of the total. A new epoch tells a
|
|
556
|
+
// consumer the chain broke, so carrying the old chain's open runs into it would be a partial
|
|
557
|
+
// abandonment — and partial abandonment is not a state.
|
|
558
|
+
brackets: { run: undefined, text: [], reasoning: [], tools: [] },
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Durable replace: write a sibling temp file, fsync it, then rename over the target. The rename
|
|
564
|
+
* is what makes a reader see either the whole old document or the whole new one and never a torn
|
|
565
|
+
* prefix — which is precisely why a zero-byte WAL is treated as corruption rather than as virgin.
|
|
566
|
+
*
|
|
567
|
+
* **MODE 0600, AND THE REASON IS NOT TIDINESS: `pending.id` IS A PRE-PUBLICATION SECRET.**
|
|
568
|
+
* The dedup cache the frozen id is checked against is STREAM-WIDE, so anyone who learns an id
|
|
569
|
+
* BEFORE its frame is published can pre-seed it and make the real publish come back
|
|
570
|
+
* `duplicate: true`. The design attributes the safety of that entirely to `randomUUID()` entropy —
|
|
571
|
+
* which holds only while the id is unguessable AND unread. An id already on the wire is harmless
|
|
572
|
+
* (that message has landed); the only window where it is dangerous is exactly the window this
|
|
573
|
+
* file holds it in, between transition 1 and the ack.
|
|
574
|
+
*
|
|
575
|
+
* So the residual a reviewer raised as "gated on a local disk read rather than mesh access" is
|
|
576
|
+
* gated on a read OF THIS FILE. A world-readable WAL would convert a property the design credits
|
|
577
|
+
* to entropy into one credited to filesystem luck. Under our own rules the attack yields a LOUD
|
|
578
|
+
* halt rather than silent loss — a duplicate ack on a retry fails loud with the frontier and
|
|
579
|
+
* cursor unmoved — so this is denial of service, not corruption. Closing it by construction is
|
|
580
|
+
* cheap enough that naming it as an accepted residual would be the worse trade.
|
|
581
|
+
*/
|
|
582
|
+
async write(next) {
|
|
583
|
+
// NOBODY ELSE HAS WRITTEN THIS FILE SINCE THIS HANDLE READ IT. Checked FIRST, because every
|
|
584
|
+
// line below replaces the document wholesale.
|
|
585
|
+
await this.assertNotClobbering();
|
|
586
|
+
const stamped = { ...next, gen: this.doc.gen + 1 };
|
|
587
|
+
// The temp name is RANDOM per write, and the open is EXCLUSIVE and NON-FOLLOWING.
|
|
588
|
+
//
|
|
589
|
+
// The previous version derived the name from `path` + `pid` — predictable — and used
|
|
590
|
+
// `open(tmp, "w", 0o600)`. Both halves were exploitable and both were reproduced:
|
|
591
|
+
// - `open(…, "w")` does not re-chmod an EXISTING inode; the mode argument applies only on
|
|
592
|
+
// create. A planted 0644 temp therefore survived as the WAL's mode, and the file holding
|
|
593
|
+
// `pending.id` — a pre-publication secret by this class's own argument — ended up
|
|
594
|
+
// world-readable while a comment three lines up claimed 0600.
|
|
595
|
+
// - `"w"` follows symlinks. A symlink planted at the predicted name made the next transition
|
|
596
|
+
// truncate and overwrite an arbitrary file the process could open. One plant, one write.
|
|
597
|
+
// Found by fmae-rev-sec, reproduced independently by fmae-rev-eng and fmae-rev-wal, then here.
|
|
598
|
+
//
|
|
599
|
+
// **The symlink half is the one I have no excuse for: I added `O_NOFOLLOW` to `JsonlFileSource`
|
|
600
|
+
// in this same session, for this same class, and did not carry it to the file this module
|
|
601
|
+
// writes.** A fence built on the read path while the write path stayed open.
|
|
602
|
+
//
|
|
603
|
+
// O_EXCL makes a pre-existing temp a hard failure rather than something to adopt; O_NOFOLLOW
|
|
604
|
+
// refuses a symlink outright; the random suffix removes the predictability that made planting
|
|
605
|
+
// reliable; and 0600 comes from the CREATE flags, which is the only place it can come from —
|
|
606
|
+
// `rename` preserves the inode's mode, so there is no post-rename chmod here and this comment
|
|
607
|
+
// does not claim one. The suite asserts the mode on the surviving file, which is where the
|
|
608
|
+
// guarantee has to hold; the code's part is refusing to adopt an existing inode at all.
|
|
609
|
+
// (An earlier version of this sentence said the mode was "asserted on the surviving inode" as
|
|
610
|
+
// though the production path checked it. It does not — the SUITE does. Flagged independently by
|
|
611
|
+
// two reviewers: a comment describing a check that lives somewhere else is the same overclaim
|
|
612
|
+
// class this file's own header warns about.)
|
|
613
|
+
const tmp = join(dirname(this.path), `.${createHash("sha256").update(this.path).digest("hex").slice(0, 12)}.${process.pid}.${randomUUID().slice(0, 8)}.wal.tmp`);
|
|
614
|
+
const body = JSON.stringify(stamped);
|
|
615
|
+
const fh = await openExclusiveNoFollow(tmp);
|
|
616
|
+
try {
|
|
617
|
+
await fh.writeFile(body, "utf8");
|
|
618
|
+
await fh.sync();
|
|
619
|
+
}
|
|
620
|
+
finally {
|
|
621
|
+
await fh.close();
|
|
622
|
+
}
|
|
623
|
+
try {
|
|
624
|
+
await rename(tmp, this.path);
|
|
625
|
+
}
|
|
626
|
+
catch (e) {
|
|
627
|
+
await unlink(tmp).catch(() => { });
|
|
628
|
+
throw e;
|
|
629
|
+
}
|
|
630
|
+
this.doc = stamped;
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Refuse to replace a document this handle did not read.
|
|
634
|
+
*
|
|
635
|
+
* **THE FAILURE THIS EXISTS FOR WAS EXECUTED, NOT IMAGINED.** Two `EventWal` objects were opened
|
|
636
|
+
* on one file. A ran the full cycle and folded a frontier of `{seq:1, lastSubjectSeq:5}`. B, whose
|
|
637
|
+
* in-memory document was frozen back at the pending write, then called `recordAck(99)` and
|
|
638
|
+
* `fold()` — both SUCCEEDED, each replacing the whole file, and the WAL came back up claiming a
|
|
639
|
+
* durable tip of 99: a subject sequence the broker never assigned. The next publish freezes
|
|
640
|
+
* `E := 99` against a stream whose real tip is 5, so the emitter either CAS-halts forever or
|
|
641
|
+
* recovers a frontier that never existed. Nothing about that is loud; it reads as a healthy WAL.
|
|
642
|
+
*
|
|
643
|
+
* The per-instance `serialize` chain cannot see it (two instances, two chains) and neither can the
|
|
644
|
+
* principal lock (B's handle predates any lock B would take, and a lock is not held against a
|
|
645
|
+
* process's own second object). The guard has to be HERE, on the write, where the two views
|
|
646
|
+
* finally meet.
|
|
647
|
+
*
|
|
648
|
+
* **This is a check, not a transaction, and the difference is stated rather than glossed.** The
|
|
649
|
+
* read and the `rename` are separate syscalls, so a writer that lands in between is not caught by
|
|
650
|
+
* this; what is caught is every stale handle — the case that actually occurs, because a stale
|
|
651
|
+
* handle stays stale for as long as it exists rather than for a syscall's width. The lock is what
|
|
652
|
+
* keeps a second live writer from starting; this is what keeps one that already exists from
|
|
653
|
+
* winning.
|
|
654
|
+
*/
|
|
655
|
+
async assertNotClobbering() {
|
|
656
|
+
let bytes;
|
|
657
|
+
try {
|
|
658
|
+
bytes = await readFile(this.path);
|
|
659
|
+
}
|
|
660
|
+
catch (e) {
|
|
661
|
+
if (e.code !== "ENOENT")
|
|
662
|
+
throw e;
|
|
663
|
+
}
|
|
664
|
+
if (bytes === undefined) {
|
|
665
|
+
// Only a handle that has never written may create the file. A handle whose own document is on
|
|
666
|
+
// disk and now finds nothing has had it removed underneath it, and re-creating it would resume
|
|
667
|
+
// a thread from a state somebody deliberately took away.
|
|
668
|
+
if (this.doc.gen !== 0)
|
|
669
|
+
throw new WalStaleWriterError(this.path, this.doc.gen, undefined);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
// Same fatal decode as `open`: a document that is not valid UTF-8 is not one this writer wrote,
|
|
673
|
+
// and the substituting decoder would turn it into one that merely looks like it.
|
|
674
|
+
let raw;
|
|
675
|
+
try {
|
|
676
|
+
raw = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
throw new WalCorruptError(this.path, "the file is valid UTF-8", "invalid UTF-8 bytes on the file about to be replaced; refusing rather than substituting U+FFFD");
|
|
680
|
+
}
|
|
681
|
+
let d;
|
|
682
|
+
try {
|
|
683
|
+
d = JSON.parse(raw);
|
|
684
|
+
}
|
|
685
|
+
catch (e) {
|
|
686
|
+
throw new WalCorruptError(this.path, "parseable JSON", e.message);
|
|
687
|
+
}
|
|
688
|
+
if (typeof d !== "object" || d === null)
|
|
689
|
+
throw new WalCorruptError(this.path, "document is an object", typeof d);
|
|
690
|
+
// ONLY the generation is read here. Re-running the load guards would refuse this write for
|
|
691
|
+
// properties of a document that is on its way out, and would answer a question this function is
|
|
692
|
+
// not asking.
|
|
693
|
+
const onDisk = docGeneration(this.path, d);
|
|
694
|
+
if (onDisk !== this.doc.gen)
|
|
695
|
+
throw new WalStaleWriterError(this.path, this.doc.gen, onDisk);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
//# sourceMappingURL=event-wal.js.map
|