@openlfcp/client 0.1.0-rc.1

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.
@@ -0,0 +1,31 @@
1
+ import type { LfcpStorage, ProfileCheckpoint, StorageWrite } from "@openlfcp/storage";
2
+ /**
3
+ * Persisting a Data Profile's local state (LFCP-036): an explicit,
4
+ * caller-driven debounce. The sync engine calls noteChange() after merges
5
+ * and local writes, and maybeFlush(now) on its own schedule; nothing here
6
+ * keeps timers. A local write can carry the checkpoint in its own atomic
7
+ * batch instead (write(), e.g. as createQueuedDataUnit's `also`).
8
+ */
9
+ /** Anything that can describe its persistent state (e.g. SharedObjectsDataProfile). */
10
+ export interface CheckpointSource {
11
+ checkpoint(): ProfileCheckpoint;
12
+ }
13
+ export declare class ProfileCheckpointer {
14
+ #private;
15
+ constructor(storage: Pick<LfcpStorage, "commit">, source: CheckpointSource, options: {
16
+ readonly minIntervalMs: number;
17
+ });
18
+ /** The profile state changed since the last checkpoint. */
19
+ noteChange(): void;
20
+ get dirty(): boolean;
21
+ /** Writes a checkpoint if something changed and `minIntervalMs` passed since the last one. */
22
+ maybeFlush(nowMs: number): Promise<boolean>;
23
+ /** Writes a checkpoint now (e.g. before closing). */
24
+ flush(nowMs: number): Promise<void>;
25
+ /**
26
+ * The checkpoint as a write for another atomic batch. The state counts as
27
+ * persisted once that batch commits; call it as the batch is built.
28
+ */
29
+ write(): StorageWrite;
30
+ }
31
+ //# sourceMappingURL=checkpoint.d.ts.map
@@ -0,0 +1,50 @@
1
+ export class ProfileCheckpointer {
2
+ #storage;
3
+ #source;
4
+ #minIntervalMs;
5
+ #dirty = false;
6
+ #lastFlushMs = null;
7
+ constructor(storage, source, options) {
8
+ this.#storage = storage;
9
+ this.#source = source;
10
+ this.#minIntervalMs = options.minIntervalMs;
11
+ }
12
+ /** The profile state changed since the last checkpoint. */
13
+ noteChange() {
14
+ this.#dirty = true;
15
+ }
16
+ get dirty() {
17
+ return this.#dirty;
18
+ }
19
+ /** Writes a checkpoint if something changed and `minIntervalMs` passed since the last one. */
20
+ async maybeFlush(nowMs) {
21
+ if (!this.#dirty)
22
+ return false;
23
+ if (this.#lastFlushMs !== null && nowMs - this.#lastFlushMs < this.#minIntervalMs)
24
+ return false;
25
+ await this.flush(nowMs);
26
+ return true;
27
+ }
28
+ /** Writes a checkpoint now (e.g. before closing). */
29
+ async flush(nowMs) {
30
+ try {
31
+ const r = await this.#storage.commit([this.write()]);
32
+ if (!r.ok)
33
+ throw new Error(`the checkpoint was not stored: ${r.reason}`);
34
+ }
35
+ catch (e) {
36
+ this.#dirty = true;
37
+ throw e;
38
+ }
39
+ this.#lastFlushMs = nowMs;
40
+ }
41
+ /**
42
+ * The checkpoint as a write for another atomic batch. The state counts as
43
+ * persisted once that batch commits; call it as the batch is built.
44
+ */
45
+ write() {
46
+ this.#dirty = false;
47
+ return { op: "put-profile-checkpoint", checkpoint: this.#source.checkpoint() };
48
+ }
49
+ }
50
+ //# sourceMappingURL=checkpoint.js.map
@@ -0,0 +1,87 @@
1
+ import { type AnyMessage, type ClientConnectionState, type ClientHandshakeConfig, type ReadySession } from "@openlfcp/wire";
2
+ /**
3
+ * One LFCP connection over a WebSocket (LFCP-WIRE-01 §30, §31, §34-§38,
4
+ * §63), for the client sync session (LFCP-039a). Portable: it uses the
5
+ * platform WebSocket (browsers, Electron/Obsidian and Node 24 all have
6
+ * `globalThis.WebSocket`), injectable for tests; no node:* imports.
7
+ *
8
+ * - Requests the `lfcp-1` subprotocol and drops a connection that did not
9
+ * get it (§30, G-SM3).
10
+ * - Binary frames only, one LFCP message per WebSocket message; a text
11
+ * frame or a message above the size limit in force is answered with
12
+ * ERROR and closes the connection (§31).
13
+ * - The handshake is the pure one of LFCP-027 (startClientHandshake,
14
+ * clientReceive); the §63 state follows clientConnectionTransition.
15
+ * - Heartbeat (§38): tick(now) sends PING when nothing was sent for the
16
+ * READY interval and treats 3 intervals without any LFCP message as a
17
+ * dead connection. Time comes from the caller; nothing here sets timers.
18
+ */
19
+ /** The parts of the WHATWG WebSocket this client uses. */
20
+ export interface WebSocketLike {
21
+ binaryType: string;
22
+ readonly protocol: string;
23
+ send(data: Uint8Array): void;
24
+ close(code?: number, reason?: string): void;
25
+ onopen: ((ev: unknown) => void) | null;
26
+ onmessage: ((ev: {
27
+ readonly data: unknown;
28
+ }) => void) | null;
29
+ onclose: ((ev: {
30
+ readonly code: number;
31
+ readonly reason: string;
32
+ }) => void) | null;
33
+ onerror: ((ev: unknown) => void) | null;
34
+ }
35
+ /** Opens a WebSocket: `new WebSocket(url, protocols)` in production. */
36
+ export type WebSocketFactory = (url: string, protocols: readonly string[]) => WebSocketLike;
37
+ /** The platform WebSocket (globalThis.WebSocket); throws if the runtime has none. */
38
+ export declare function platformWebSocket(): WebSocketFactory;
39
+ /** §30: the only LFCP subprotocol. */
40
+ export declare const LFCP_SUBPROTOCOL = "lfcp-1";
41
+ export interface ConnectionEvents {
42
+ /** The §63 state changed. */
43
+ state(state: ClientConnectionState): void;
44
+ /** READY arrived: the session's parameters (§37). */
45
+ ready(ready: ReadySession): void;
46
+ /** A message after READY that the handshake does not consume. */
47
+ message(message: AnyMessage): void;
48
+ /** The connection is gone (DISCONNECTED); `reason` is for humans and never carries payloads. */
49
+ closed(reason: string): void;
50
+ }
51
+ export interface ConnectionOptions extends ClientHandshakeConfig {
52
+ readonly url: string;
53
+ readonly webSocket?: WebSocketFactory;
54
+ /** The current time in milliseconds (the caller's clock). */
55
+ readonly now: () => number;
56
+ /**
57
+ * LFCP-WIRE-01 §31: this client's own maximum message size (at least the
58
+ * 8 MiB default, which is also the default here). A larger value in
59
+ * READY never raises the receive limit above it.
60
+ */
61
+ readonly maxMessageBytes?: number;
62
+ }
63
+ export declare class LfcpConnection {
64
+ #private;
65
+ constructor(options: ConnectionOptions, events: ConnectionEvents);
66
+ get state(): ClientConnectionState;
67
+ /** The READY parameters while the session is READY. */
68
+ get ready(): ReadySession | null;
69
+ /** Opens the WebSocket (DISCONNECTED → CONNECTING). */
70
+ connect(): void;
71
+ /**
72
+ * Sends one message on a READY session. A message above READY's size
73
+ * limit is refused (MESSAGE_TOO_LARGE) and never sent.
74
+ */
75
+ send(message: AnyMessage): void;
76
+ /** Sends already encoded message bytes (e.g. an OutboundMessage) on a READY session. */
77
+ sendEncoded(bytes: Uint8Array): void;
78
+ /**
79
+ * Heartbeat (§38), driven by the caller's clock: a PING when nothing was
80
+ * sent for READY's interval, and a close when nothing arrived for three.
81
+ * Returns false when the connection was declared dead.
82
+ */
83
+ tick(now: number): boolean;
84
+ /** Closes the connection (normal closure). */
85
+ close(reason?: string): void;
86
+ }
87
+ //# sourceMappingURL=connection.d.ts.map
@@ -0,0 +1,210 @@
1
+ import { LfcpError, secureRandom } from "@openlfcp/core";
2
+ import { clientConnectionTransition, clientReceive, createMessage, DEFAULT_MAX_MESSAGE_BYTES, decodeFrame, ERROR_CODE, encodeMessage, startClientHandshake, } from "@openlfcp/wire";
3
+ /** The platform WebSocket (globalThis.WebSocket); throws if the runtime has none. */
4
+ export function platformWebSocket() {
5
+ const ctor = globalThis.WebSocket;
6
+ if (ctor === undefined)
7
+ throw new LfcpError("UNSUPPORTED_VALUE", "this runtime has no global WebSocket; inject a factory");
8
+ return (url, protocols) => new ctor(url, [...protocols]);
9
+ }
10
+ /** §30: the only LFCP subprotocol. */
11
+ export const LFCP_SUBPROTOCOL = "lfcp-1";
12
+ const BYTES = (data) => {
13
+ if (typeof data === "string")
14
+ return data;
15
+ if (data instanceof ArrayBuffer)
16
+ return new Uint8Array(data);
17
+ if (ArrayBuffer.isView(data))
18
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
19
+ return undefined;
20
+ };
21
+ export class LfcpConnection {
22
+ #options;
23
+ #events;
24
+ #socket = null;
25
+ #state = "DISCONNECTED";
26
+ #session = null;
27
+ #ready = null;
28
+ #lastSent = 0;
29
+ #lastReceived = 0;
30
+ #closedReported = true;
31
+ constructor(options, events) {
32
+ this.#options = options;
33
+ this.#events = events;
34
+ }
35
+ get state() {
36
+ return this.#state;
37
+ }
38
+ /** The READY parameters while the session is READY. */
39
+ get ready() {
40
+ return this.#ready;
41
+ }
42
+ #move(event) {
43
+ const next = clientConnectionTransition(this.#state, event);
44
+ if (next === undefined)
45
+ return;
46
+ if (next !== this.#state) {
47
+ this.#state = next;
48
+ this.#events.state(next);
49
+ }
50
+ }
51
+ /** Opens the WebSocket (DISCONNECTED → CONNECTING). */
52
+ connect() {
53
+ if (this.#state !== "DISCONNECTED")
54
+ return;
55
+ this.#move("OPEN");
56
+ this.#closedReported = false;
57
+ this.#ready = null;
58
+ const socket = (this.#options.webSocket ?? platformWebSocket())(this.#options.url, [
59
+ LFCP_SUBPROTOCOL,
60
+ ]);
61
+ this.#socket = socket;
62
+ socket.binaryType = "arraybuffer";
63
+ socket.onopen = () => {
64
+ if (socket !== this.#socket)
65
+ return;
66
+ if (socket.protocol !== LFCP_SUBPROTOCOL) {
67
+ this.#drop("CONNECT_FAILED", `the server did not accept ${LFCP_SUBPROTOCOL}`);
68
+ return;
69
+ }
70
+ this.#move("CONNECTED");
71
+ const now = this.#options.now();
72
+ this.#lastReceived = now;
73
+ this.#apply(startClientHandshake(this.#options));
74
+ };
75
+ socket.onmessage = (ev) => {
76
+ if (socket === this.#socket)
77
+ this.#receive(ev.data);
78
+ };
79
+ socket.onclose = (ev) => {
80
+ if (socket === this.#socket)
81
+ this.#drop("CONNECTION_LOST", `the WebSocket closed (${ev.code})`, false);
82
+ };
83
+ socket.onerror = () => {
84
+ if (socket === this.#socket)
85
+ this.#drop("CONNECTION_LOST", "the WebSocket failed");
86
+ };
87
+ }
88
+ #apply(step) {
89
+ const before = this.#session?.phase;
90
+ this.#session = step.session;
91
+ for (const m of step.send)
92
+ this.#write(m);
93
+ if (step.close) {
94
+ this.#drop(before === "AUTHENTICATING" ? "AUTH_FAILURE" : "FATAL_ERROR", step.session.phase === "DISCONNECTED" ? step.session.reason : "handshake failure");
95
+ return;
96
+ }
97
+ if (step.session.phase === "AUTHENTICATING" && before === "NEGOTIATING")
98
+ this.#move("CHALLENGED");
99
+ if (step.session.phase === "READY" && before !== "READY") {
100
+ this.#ready = step.session.ready;
101
+ this.#move("READY_RECEIVED");
102
+ this.#events.ready(step.session.ready);
103
+ }
104
+ if (step.deliver !== undefined)
105
+ this.#events.message(step.deliver);
106
+ }
107
+ /** §31: the receive limit is the server's advertised maximum, never above this client's own. */
108
+ #limit() {
109
+ const local = Math.max(this.#options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES, DEFAULT_MAX_MESSAGE_BYTES);
110
+ return this.#ready === null
111
+ ? DEFAULT_MAX_MESSAGE_BYTES
112
+ : Math.min(Number(this.#ready.maxMessageBytes), local);
113
+ }
114
+ #receive(data) {
115
+ const frame = BYTES(data);
116
+ if (frame === undefined)
117
+ return;
118
+ const result = decodeFrame(typeof frame === "string" ? { kind: "text", data: frame } : { kind: "binary", data: frame }, { maxMessageBytes: this.#limit() });
119
+ this.#lastReceived = this.#options.now();
120
+ if (result.kind === "error") {
121
+ this.#error(result.wireCode, result.reason);
122
+ if (result.closesConnection)
123
+ this.#drop("CONNECTION_LOST", `${result.wireCode}: ${result.reason}`);
124
+ return;
125
+ }
126
+ if (this.#session === null)
127
+ return;
128
+ this.#apply(clientReceive(this.#session, result.message, this.#options));
129
+ }
130
+ #error(code, diagnostic) {
131
+ this.#write(createMessage("ERROR", { code: ERROR_CODE[code], diagnostic }));
132
+ }
133
+ #write(message) {
134
+ const socket = this.#socket;
135
+ if (socket === null)
136
+ return;
137
+ socket.send(encodeMessage(message));
138
+ this.#lastSent = this.#options.now();
139
+ }
140
+ /**
141
+ * Sends one message on a READY session. A message above READY's size
142
+ * limit is refused (MESSAGE_TOO_LARGE) and never sent.
143
+ */
144
+ send(message) {
145
+ this.sendEncoded(encodeMessage(message));
146
+ }
147
+ /** Sends already encoded message bytes (e.g. an OutboundMessage) on a READY session. */
148
+ sendEncoded(bytes) {
149
+ if (this.#state !== "READY" || this.#socket === null)
150
+ throw new LfcpError("UNSUPPORTED_VALUE", "the connection is not READY");
151
+ if (bytes.length > this.#limit())
152
+ throw new LfcpError("MESSAGE_TOO_LARGE", `${bytes.length} bytes exceed the session limit ${this.#limit()}`);
153
+ this.#socket.send(bytes);
154
+ this.#lastSent = this.#options.now();
155
+ }
156
+ /**
157
+ * Heartbeat (§38), driven by the caller's clock: a PING when nothing was
158
+ * sent for READY's interval, and a close when nothing arrived for three.
159
+ * Returns false when the connection was declared dead.
160
+ */
161
+ tick(now) {
162
+ if (this.#state !== "READY" || this.#ready === null) {
163
+ // A handshake that stalls is as dead as an idle session.
164
+ if ((this.#state === "NEGOTIATING" || this.#state === "AUTHENTICATING") &&
165
+ now - this.#lastReceived > 30_000) {
166
+ this.#drop("CONNECTION_LOST", "the handshake timed out");
167
+ return false;
168
+ }
169
+ return true;
170
+ }
171
+ const h = Number(this.#ready.heartbeatMs);
172
+ if (h <= 0)
173
+ return true;
174
+ if (now - this.#lastReceived > 3 * h) {
175
+ this.#drop("CONNECTION_LOST", "no LFCP message for three heartbeat intervals (§38)");
176
+ return false;
177
+ }
178
+ if (now - this.#lastSent >= h)
179
+ this.#write(createMessage("PING", { payload: secureRandom(8) }));
180
+ return true;
181
+ }
182
+ /** Closes the connection (normal closure). */
183
+ close(reason = "closed by the client") {
184
+ this.#drop("CONNECTION_LOST", reason);
185
+ }
186
+ #drop(event, reason, closeSocket = true) {
187
+ const socket = this.#socket;
188
+ this.#socket = null;
189
+ this.#session = null;
190
+ this.#ready = null;
191
+ if (socket !== null) {
192
+ socket.onopen = socket.onmessage = socket.onclose = socket.onerror = null;
193
+ if (closeSocket)
194
+ try {
195
+ socket.close(1000, reason.slice(0, 120));
196
+ }
197
+ catch {
198
+ // already closing
199
+ }
200
+ }
201
+ this.#move(event);
202
+ if (this.#state !== "DISCONNECTED")
203
+ this.#move("CONNECTION_LOST");
204
+ if (!this.#closedReported) {
205
+ this.#closedReported = true;
206
+ this.#events.closed(reason);
207
+ }
208
+ }
209
+ }
210
+ //# sourceMappingURL=connection.js.map
@@ -0,0 +1,50 @@
1
+ import { type ActorSequence, type DataEpoch, type DataUnitId } from "@openlfcp/core";
2
+ import { type ResourceDEK } from "@openlfcp/crypto";
3
+ import type { ActorSequenceReservation, SequenceReuseGuard } from "@openlfcp/storage";
4
+ import { type ControlView, type DataProfileCodec, type Signer } from "@openlfcp/wire";
5
+ /**
6
+ * Creating a Data Unit (LFCP-WIRE-01 §8, §12, §26). The application value
7
+ * becomes plaintext through its Data Profile codec, then LFCP encrypts and
8
+ * signs it; only the resulting opaque bytes are ever sent.
9
+ */
10
+ export interface CreateDataUnitOptions<T> {
11
+ /** The writer's validated view of the Resource's Control Chain. */
12
+ readonly view: ControlView;
13
+ /** The Control Head the unit is authorized at (usually the latest one). */
14
+ readonly controlHead: Uint8Array;
15
+ /** The actor: the writer's signing key and descriptor. */
16
+ readonly actor: Signer;
17
+ /** The DEK of the current Data Epoch at `controlHead`. */
18
+ readonly dek: ResourceDEK;
19
+ /**
20
+ * The only source of actor sequences (§8): durable in production
21
+ * (LFCP-034 to LFCP-036). A sequence is never passed in directly.
22
+ */
23
+ readonly sequences: ActorSequenceReservation;
24
+ /** Optional second line of defence against a sequence used twice in this process. */
25
+ readonly guard?: SequenceReuseGuard;
26
+ /**
27
+ * The actor's unit at the previous sequence (§26.2), or null for its
28
+ * first unit. A non-null value with sequence 1, or null after 1, is
29
+ * refused (the reserved sequence is then abandoned, never reused).
30
+ */
31
+ readonly previousUnitId: DataUnitId | null;
32
+ readonly profile: DataProfileCodec<T>;
33
+ readonly value: T;
34
+ }
35
+ export interface CreatedDataUnit {
36
+ /** The exact signed bytes: opaque, the only form sent to servers and peers. */
37
+ readonly bytes: Uint8Array;
38
+ readonly unitId: DataUnitId;
39
+ readonly seq: ActorSequence;
40
+ readonly epoch: DataEpoch;
41
+ }
42
+ /**
43
+ * Creates an encrypted, signed Data Unit. Before a sequence is reserved,
44
+ * it checks that the head is on the chain, that the profile is the
45
+ * Resource's, that the actor holds data/write at the head and that the DEK
46
+ * is the one committed for the head's current epoch; then it reserves the
47
+ * next sequence and seals the unit (sealDataUnit).
48
+ */
49
+ export declare function createDataUnit<T>(options: CreateDataUnitOptions<T>): Promise<CreatedDataUnit>;
50
+ //# sourceMappingURL=data-unit.d.ts.map
@@ -0,0 +1,39 @@
1
+ import { bytesEqual, LfcpError, } from "@openlfcp/core";
2
+ import { dekCommitment } from "@openlfcp/crypto";
3
+ import { ABILITY, hasAbility, sealDataUnit, } from "@openlfcp/wire";
4
+ /**
5
+ * Creates an encrypted, signed Data Unit. Before a sequence is reserved,
6
+ * it checks that the head is on the chain, that the profile is the
7
+ * Resource's, that the actor holds data/write at the head and that the DEK
8
+ * is the one committed for the head's current epoch; then it reserves the
9
+ * next sequence and seals the unit (sealDataUnit).
10
+ */
11
+ export async function createDataUnit(options) {
12
+ const atHead = options.view.stateAt(options.controlHead);
13
+ if (atHead === undefined)
14
+ throw new LfcpError("MISSING_DEPENDENCY", "the Control Head is not on the validated chain");
15
+ if (options.profile.dataProfile !== atHead.dataProfile)
16
+ throw new LfcpError("DATA_PROFILE_MISMATCH", `the profile codec is for ${options.profile.dataProfile}, the Resource uses ${atHead.dataProfile}`);
17
+ const actor = options.actor.descriptor.principalId;
18
+ if (!hasAbility(atHead, actor, ABILITY.DATA_WRITE))
19
+ throw new LfcpError("AUTHORIZATION_FAILED", "the actor does not hold data/write at the Control Head (§26.3)");
20
+ const epoch = atHead.epoch.epoch;
21
+ if (!bytesEqual(dekCommitment(atHead.resourceId, epoch, options.dek), atHead.epoch.dekCommitment))
22
+ throw new LfcpError("DEK_COMMITMENT_MISMATCH", `the DEK is not the one committed for epoch ${epoch} at the Control Head`);
23
+ const plaintext = options.profile.encode(options.value);
24
+ const seq = await options.sequences.reserveNext(atHead.resourceId, actor);
25
+ options.guard?.claim(atHead.resourceId, actor, seq);
26
+ if ((seq === 1n) !== (options.previousUnitId === null))
27
+ throw new LfcpError("INVALID_STRUCTURE", seq === 1n
28
+ ? "the actor's first unit (sequence 1) must have a null previous unit (§26.2)"
29
+ : `sequence ${seq} needs the actor's unit at sequence ${seq - 1n} as its previous unit (§26.2)`);
30
+ const sealed = sealDataUnit({
31
+ resourceId: atHead.resourceId,
32
+ dataEpoch: epoch,
33
+ actorSeq: seq,
34
+ prevDataUnitId: options.previousUnitId,
35
+ controlHead: atHead.head,
36
+ }, plaintext, options.dek, options.actor);
37
+ return Object.freeze({ bytes: sealed.bytes, unitId: sealed.unitId, seq, epoch });
38
+ }
39
+ //# sourceMappingURL=data-unit.js.map
@@ -0,0 +1,58 @@
1
+ import { type ResourceId } from "@openlfcp/core";
2
+ import type { LfcpStorage } from "@openlfcp/storage";
3
+ /**
4
+ * The crash-loop breaker: received content that traps the profile engine
5
+ * (e.g. Automerge's wasm module, which then stays terminated for the whole
6
+ * process) must not crash every restart again.
7
+ *
8
+ * Before an engine call on received content (applying Data Units, loading
9
+ * a Snapshot), the items it covers are recorded durably
10
+ * (`applying:<scope>:<resource>`, one record per scope: "units" for the
11
+ * applier, "snapshots" for Snapshot loads); the record is removed when the call returns or
12
+ * throws an ordinary error. A record found later means the process died
13
+ * inside the call: a trap, or anything else (killed, power loss, a bug in
14
+ * our own code).
15
+ *
16
+ * Blame needs two such crashes on the item alone:
17
+ * - after a crash, every item of the leftover record becomes a suspect
18
+ * (`suspect:<resource>:<item>` = 1) and is applied alone from then on,
19
+ * never in a batch, so an innocent batch member is cleared by its own
20
+ * successful apply;
21
+ * - a suspect that crashes again alone (count 2) is quarantined locally:
22
+ * never given to the engine again, here or on later delivery, and
23
+ * surfaced. The mark stays, so a re-delivered copy is refused too.
24
+ *
25
+ * So a single crash mid-apply (the normal crash-restart path) only costs a
26
+ * retry; a unit is blamed only when it crashed the engine twice by itself.
27
+ * A poison unit costs at most three process restarts (batch, alone, then
28
+ * quarantined at the next start).
29
+ */
30
+ /** A thrown value that looks like an engine trap (WebAssembly RuntimeError, a terminated module). */
31
+ export declare function isEngineTrap(e: unknown): boolean;
32
+ /** An item the engine processes: a Data Unit or a Snapshot, by ID. */
33
+ export type EngineItem = `unit:${string}` | `snapshot:${string}`;
34
+ export declare const unitItem: (id: Uint8Array) => EngineItem;
35
+ export declare const snapshotItem: (id: Uint8Array) => EngineItem;
36
+ /** How often an item crashed the engine: 1 suspect (applied alone), 2+ quarantined. */
37
+ export type Suspicion = 0 | 1 | 2;
38
+ export declare class EngineGuard {
39
+ #private;
40
+ constructor(storage: Pick<LfcpStorage, "localMarks" | "commit">, scope: "units" | "snapshots");
41
+ /**
42
+ * Once per Resource and guard: turns a leftover `applying` record into
43
+ * suspects (counting one more crash for each), and loads the suspects.
44
+ * Returns the items quarantined by this crash (count reached 2).
45
+ */
46
+ recover(resource: ResourceId): Promise<EngineItem[]>;
47
+ /** How often `item` crashed the engine (after recover()). */
48
+ suspicion(resource: ResourceId, item: EngineItem): Suspicion;
49
+ /**
50
+ * Runs `call` (the engine work on `items`) with the durable record in
51
+ * place. Returns normally or with an ordinary error: the record is
52
+ * removed, and suspects of count 1 that went through are cleared. On a
53
+ * trap the record stays (the module is dead; the process must restart)
54
+ * and the trap is rethrown.
55
+ */
56
+ run<T>(resource: ResourceId, items: readonly EngineItem[], call: () => T | Promise<T>): Promise<T>;
57
+ }
58
+ //# sourceMappingURL=engine-guard.d.ts.map
@@ -0,0 +1,148 @@
1
+ import { toHex } from "@openlfcp/core";
2
+ /**
3
+ * The crash-loop breaker: received content that traps the profile engine
4
+ * (e.g. Automerge's wasm module, which then stays terminated for the whole
5
+ * process) must not crash every restart again.
6
+ *
7
+ * Before an engine call on received content (applying Data Units, loading
8
+ * a Snapshot), the items it covers are recorded durably
9
+ * (`applying:<scope>:<resource>`, one record per scope: "units" for the
10
+ * applier, "snapshots" for Snapshot loads); the record is removed when the call returns or
11
+ * throws an ordinary error. A record found later means the process died
12
+ * inside the call: a trap, or anything else (killed, power loss, a bug in
13
+ * our own code).
14
+ *
15
+ * Blame needs two such crashes on the item alone:
16
+ * - after a crash, every item of the leftover record becomes a suspect
17
+ * (`suspect:<resource>:<item>` = 1) and is applied alone from then on,
18
+ * never in a batch, so an innocent batch member is cleared by its own
19
+ * successful apply;
20
+ * - a suspect that crashes again alone (count 2) is quarantined locally:
21
+ * never given to the engine again, here or on later delivery, and
22
+ * surfaced. The mark stays, so a re-delivered copy is refused too.
23
+ *
24
+ * So a single crash mid-apply (the normal crash-restart path) only costs a
25
+ * retry; a unit is blamed only when it crashed the engine twice by itself.
26
+ * A poison unit costs at most three process restarts (batch, alone, then
27
+ * quarantined at the next start).
28
+ */
29
+ /** A thrown value that looks like an engine trap (WebAssembly RuntimeError, a terminated module). */
30
+ export function isEngineTrap(e) {
31
+ const wasm = globalThis
32
+ .WebAssembly?.RuntimeError;
33
+ if (wasm !== undefined && e instanceof wasm)
34
+ return true;
35
+ if (!(e instanceof Error))
36
+ return false;
37
+ return (e.name === "RuntimeError" ||
38
+ /\b(module|instance)\b.*\bterminated\b|\bunreachable\b executed|\bwasm trap\b/i.test(e.message));
39
+ }
40
+ export const unitItem = (id) => `unit:${toHex(id)}`;
41
+ export const snapshotItem = (id) => `snapshot:${toHex(id)}`;
42
+ export class EngineGuard {
43
+ #storage;
44
+ #scope;
45
+ #suspects = new Map();
46
+ #recovering = new Map();
47
+ constructor(storage, scope) {
48
+ this.#storage = storage;
49
+ this.#scope = scope;
50
+ }
51
+ #applying(R) {
52
+ return `applying:${this.#scope}:${R}`;
53
+ }
54
+ /**
55
+ * Once per Resource and guard: turns a leftover `applying` record into
56
+ * suspects (counting one more crash for each), and loads the suspects.
57
+ * Returns the items quarantined by this crash (count reached 2).
58
+ */
59
+ recover(resource) {
60
+ const key = toHex(resource);
61
+ let running = this.#recovering.get(key);
62
+ if (running === undefined) {
63
+ running = this.#recover(key);
64
+ this.#recovering.set(key, running);
65
+ }
66
+ return running;
67
+ }
68
+ async #recover(R) {
69
+ const counts = new Map();
70
+ for (const m of await this.#storage.localMarks.list(`suspect:${R}:`))
71
+ counts.set(m.key.slice(`suspect:${R}:`.length), Number(m.value));
72
+ const leftover = await this.#storage.localMarks.get(this.#applying(R));
73
+ const quarantined = [];
74
+ if (leftover !== undefined) {
75
+ const items = JSON.parse(leftover);
76
+ for (const item of items) {
77
+ const n = (counts.get(item) ?? 0) + 1;
78
+ counts.set(item, n);
79
+ if (n === 2)
80
+ quarantined.push(item);
81
+ }
82
+ const r = await this.#storage.commit([
83
+ ...items.map((item) => ({
84
+ op: "put-local-mark",
85
+ key: `suspect:${R}:${item}`,
86
+ value: String(counts.get(item)),
87
+ })),
88
+ { op: "put-local-mark", key: this.#applying(R), value: null },
89
+ ]);
90
+ if (!r.ok)
91
+ throw new Error(`the crash record was not stored: ${r.reason}`);
92
+ }
93
+ this.#suspects.set(R, counts);
94
+ return quarantined;
95
+ }
96
+ /** How often `item` crashed the engine (after recover()). */
97
+ suspicion(resource, item) {
98
+ const n = this.#suspects.get(toHex(resource))?.get(item) ?? 0;
99
+ return n >= 2 ? 2 : n === 1 ? 1 : 0;
100
+ }
101
+ /**
102
+ * Runs `call` (the engine work on `items`) with the durable record in
103
+ * place. Returns normally or with an ordinary error: the record is
104
+ * removed, and suspects of count 1 that went through are cleared. On a
105
+ * trap the record stays (the module is dead; the process must restart)
106
+ * and the trap is rethrown.
107
+ */
108
+ async run(resource, items, call) {
109
+ const R = toHex(resource);
110
+ if (items.length === 0)
111
+ return call();
112
+ await this.#mark([
113
+ { op: "put-local-mark", key: this.#applying(R), value: JSON.stringify(items) },
114
+ ]);
115
+ let result;
116
+ try {
117
+ result = await call();
118
+ }
119
+ catch (e) {
120
+ if (isEngineTrap(e))
121
+ throw e;
122
+ await this.#settled(R, items, false);
123
+ throw e;
124
+ }
125
+ await this.#settled(R, items, true);
126
+ return result;
127
+ }
128
+ async #settled(R, items, passed) {
129
+ const counts = this.#suspects.get(R);
130
+ const cleared = passed ? items.filter((item) => counts?.get(item) === 1) : [];
131
+ for (const item of cleared)
132
+ counts?.delete(item);
133
+ await this.#mark([
134
+ { op: "put-local-mark", key: this.#applying(R), value: null },
135
+ ...cleared.map((item) => ({
136
+ op: "put-local-mark",
137
+ key: `suspect:${R}:${item}`,
138
+ value: null,
139
+ })),
140
+ ]);
141
+ }
142
+ async #mark(writes) {
143
+ const r = await this.#storage.commit(writes);
144
+ if (!r.ok)
145
+ throw new Error(`the apply record was not stored: ${r.reason}`);
146
+ }
147
+ }
148
+ //# sourceMappingURL=engine-guard.js.map