@rindle/room 0.5.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/build.sh +53 -0
- package/dist/authority.d.ts +57 -0
- package/dist/authority.d.ts.map +1 -0
- package/dist/authority.js +73 -0
- package/dist/authority.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/journal.d.ts +64 -0
- package/dist/journal.d.ts.map +1 -0
- package/dist/journal.js +53 -0
- package/dist/journal.js.map +1 -0
- package/dist/mutation-tx.d.ts +79 -0
- package/dist/mutation-tx.d.ts.map +1 -0
- package/dist/mutation-tx.js +207 -0
- package/dist/mutation-tx.js.map +1 -0
- package/dist/shell.d.ts +134 -0
- package/dist/shell.d.ts.map +1 -0
- package/dist/shell.js +1589 -0
- package/dist/shell.js.map +1 -0
- package/dist/token.d.ts +89 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +158 -0
- package/dist/token.js.map +1 -0
- package/dist/wasm.d.ts +7 -0
- package/dist/wasm.d.ts.map +1 -0
- package/dist/wasm.js +29 -0
- package/dist/wasm.js.map +1 -0
- package/package.json +71 -0
- package/pkg/rindle_room.d.ts +345 -0
- package/pkg/rindle_room.js +924 -0
- package/pkg/rindle_room_bg.wasm +0 -0
- package/pkg/rindle_room_bg.wasm.d.ts +45 -0
- package/src/authority.ts +127 -0
- package/src/index.ts +49 -0
- package/src/journal.ts +110 -0
- package/src/mutation-tx.ts +275 -0
- package/src/shell.ts +1859 -0
- package/src/token.ts +219 -0
- package/src/wasm.ts +32 -0
|
Binary file
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/* tslint:disable */
|
|
2
|
+
/* eslint-disable */
|
|
3
|
+
export const memory: WebAssembly.Memory;
|
|
4
|
+
export const __rindle_room_wasm_start: () => void;
|
|
5
|
+
export const __wbg_wasmroom_free: (a: number, b: number) => void;
|
|
6
|
+
export const wasmroom_ack: (a: number, b: number, c: number) => [number, number, number, number];
|
|
7
|
+
export const wasmroom_ackedLmid: (a: number, b: number, c: number) => number;
|
|
8
|
+
export const wasmroom_appliedMid: (a: number, b: number, c: number) => number;
|
|
9
|
+
export const wasmroom_apply: (a: number, b: number, c: number) => [number, number, number, number];
|
|
10
|
+
export const wasmroom_beginFlush: (a: number) => [number, number, number, number];
|
|
11
|
+
export const wasmroom_beginMutation: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => [number, number, number, number];
|
|
12
|
+
export const wasmroom_commitAll: (a: number) => [number, number];
|
|
13
|
+
export const wasmroom_commitMutation: (a: number) => [number, number, number, number];
|
|
14
|
+
export const wasmroom_cv: (a: number) => [number, number];
|
|
15
|
+
export const wasmroom_dirtyLen: (a: number) => number;
|
|
16
|
+
export const wasmroom_durableLmid: (a: number, b: number, c: number) => number;
|
|
17
|
+
export const wasmroom_enableWrites: (a: number, b: number, c: number) => [number, number];
|
|
18
|
+
export const wasmroom_enableWritesV2: (a: number, b: number, c: number) => [number, number];
|
|
19
|
+
export const wasmroom_epoch: (a: number) => number;
|
|
20
|
+
export const wasmroom_flushConflict: (a: number, b: number, c: number) => [number, number, number, number];
|
|
21
|
+
export const wasmroom_flushInFlight: (a: number) => number;
|
|
22
|
+
export const wasmroom_flushOk: (a: number) => [number, number, number, number];
|
|
23
|
+
export const wasmroom_headCv: (a: number) => number;
|
|
24
|
+
export const wasmroom_isLive: (a: number) => number;
|
|
25
|
+
export const wasmroom_isPoisoned: (a: number) => number;
|
|
26
|
+
export const wasmroom_lmidSubscribe: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number, number, number];
|
|
27
|
+
export const wasmroom_materializationCount: (a: number) => number;
|
|
28
|
+
export const wasmroom_open: (a: number, b: number, c: number) => [number, number, number];
|
|
29
|
+
export const wasmroom_pendingLen: (a: number) => number;
|
|
30
|
+
export const wasmroom_rejectMutation: (a: number, b: number, c: number, d: number, e: number) => [number, number];
|
|
31
|
+
export const wasmroom_seedDurable: (a: number, b: number, c: number) => [number, number, number, number];
|
|
32
|
+
export const wasmroom_subscribe: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number, number, number];
|
|
33
|
+
export const wasmroom_subscriberCount: (a: number) => number;
|
|
34
|
+
export const wasmroom_sweep: (a: number, b: number) => [number, number, number];
|
|
35
|
+
export const wasmroom_txAdd: (a: number, b: number, c: number, d: number, e: number) => [number, number];
|
|
36
|
+
export const wasmroom_txEdit: (a: number, b: number, c: number, d: number, e: number) => [number, number];
|
|
37
|
+
export const wasmroom_txGet: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
|
|
38
|
+
export const wasmroom_txRemove: (a: number, b: number, c: number, d: number, e: number) => [number, number];
|
|
39
|
+
export const wasmroom_unsubscribe: (a: number, b: number, c: number, d: number) => [number, number, number];
|
|
40
|
+
export const __wbindgen_free: (a: number, b: number, c: number) => void;
|
|
41
|
+
export const __wbindgen_malloc: (a: number, b: number) => number;
|
|
42
|
+
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
|
43
|
+
export const __wbindgen_externrefs: WebAssembly.Table;
|
|
44
|
+
export const __externref_table_dealloc: (a: number) => void;
|
|
45
|
+
export const __wbindgen_start: () => void;
|
package/src/authority.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// The room's **write-authority seam** (RINDLE-REALTIME-DESIGN.md §5.3.1): the three
|
|
2
|
+
// calls a flushing room makes against the app's authority — claim a placement epoch at
|
|
3
|
+
// boot (§2.5), probe the durable lmids before journal replay (§3.3, T2's
|
|
4
|
+
// committed-before-crash row), and apply one journaled batch. In every deployment shape
|
|
5
|
+
// the counterpart is the API server's `/apply-row-change-txn` host ([`httpAuthority`]);
|
|
6
|
+
// the P3 gate drives the same interface against an in-process mock, which is what keeps
|
|
7
|
+
// the contract host-independent.
|
|
8
|
+
//
|
|
9
|
+
// The apply takes the **exact composed body string** the shell journaled — never a
|
|
10
|
+
// re-serialized object. One flush id names one immutable byte body forever (§5.3
|
|
11
|
+
// step 4): a retry, a crash-replay resubmission, and the first send all put identical
|
|
12
|
+
// bytes on the wire, which is what makes the authority's `batch_hash` identity check
|
|
13
|
+
// (§8.3, T6) meaningful.
|
|
14
|
+
|
|
15
|
+
/** One CAS rejection in the authority's `409 conflict` body: the row's authoritative
|
|
16
|
+
* current image (`null` = absent), keyed by table + pk cells. */
|
|
17
|
+
export interface AuthorityConflict {
|
|
18
|
+
table: string;
|
|
19
|
+
pk: unknown[];
|
|
20
|
+
current: unknown[] | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The apply outcome. Anything else — network failure, a 5xx — is a **throw**: plain
|
|
24
|
+
* errors retry (same body, same id; dedup absorbs a landed retry), errors marked
|
|
25
|
+
* `fatal: true` (an identity mismatch — same id, different body — or a malformed
|
|
26
|
+
* refusal) kill the incarnation loudly instead. */
|
|
27
|
+
export type AuthorityApplyResult =
|
|
28
|
+
| { kind: "ok"; applied: boolean; cv?: number }
|
|
29
|
+
| { kind: "conflict"; conflicts: AuthorityConflict[] }
|
|
30
|
+
| { kind: "fenced"; currentEpoch?: number };
|
|
31
|
+
|
|
32
|
+
export interface RoomAuthority {
|
|
33
|
+
/** Claim the placement epoch for `doc` (§2.5) — once per shell process, at boot,
|
|
34
|
+
* AFTER resubmitting the previous incarnation's unconfirmed batches (their recorded
|
|
35
|
+
* epochs must still be current to land). */
|
|
36
|
+
claimEpoch(doc: string): Promise<number>;
|
|
37
|
+
/** The authority's ledger lmid per client (0 = none) — the boot probe that lets
|
|
38
|
+
* journal replay absorb already-durable mutations as dedup. */
|
|
39
|
+
lmids(doc: string, clients: string[]): Promise<Record<string, number>>;
|
|
40
|
+
/** Apply one batch: `body` is the exact journaled `/apply-row-change-txn` body
|
|
41
|
+
* string, sent verbatim. */
|
|
42
|
+
applyRowChangeTxn(body: string): Promise<AuthorityApplyResult>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** An apply error the shell must NOT retry (retrying cannot help; something is wrong
|
|
46
|
+
* with the batch or the room, not the network). */
|
|
47
|
+
export interface FatalAuthorityError extends Error {
|
|
48
|
+
fatal: true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function fatalAuthorityError(message: string): FatalAuthorityError {
|
|
52
|
+
const e = new Error(message) as FatalAuthorityError;
|
|
53
|
+
e.fatal = true;
|
|
54
|
+
return e;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface HttpAuthorityOptions {
|
|
58
|
+
/** The API server's apply endpoint (e.g. `http://…/api/rindle/apply-row-change-txn`). */
|
|
59
|
+
applyUrl: string;
|
|
60
|
+
/** The epoch-claim endpoint (e.g. `http://…/api/rindle/claim-room-epoch`). */
|
|
61
|
+
claimUrl: string;
|
|
62
|
+
/** The lmid-probe endpoint (e.g. `http://…/api/rindle/room-lmids`). */
|
|
63
|
+
lmidsUrl: string;
|
|
64
|
+
/** Extra headers on every call — the epoch-bound flush credential rides here. */
|
|
65
|
+
headers?: Record<string, string>;
|
|
66
|
+
fetch?: typeof fetch;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The HTTP [`RoomAuthority`]: the API-server host as the room's sole counterpart
|
|
70
|
+
* (§5.3.1). Maps `409 {error:"fenced"}` / `409 {error:"conflict"}` to their results,
|
|
71
|
+
* a `500` mentioning a batch-identity mismatch to a fatal error, and anything else
|
|
72
|
+
* non-OK to a retryable throw. */
|
|
73
|
+
export function httpAuthority(opts: HttpAuthorityOptions): RoomAuthority {
|
|
74
|
+
const doFetch = opts.fetch ?? fetch;
|
|
75
|
+
const headers = { "content-type": "application/json", ...(opts.headers ?? {}) };
|
|
76
|
+
const post = async (url: string, body: string): Promise<Response> =>
|
|
77
|
+
doFetch(url, { method: "POST", headers, body });
|
|
78
|
+
return {
|
|
79
|
+
async claimEpoch(doc) {
|
|
80
|
+
const res = await post(opts.claimUrl, JSON.stringify({ doc }));
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
throw new Error(`claim-room-epoch failed: ${res.status} ${await res.text()}`);
|
|
83
|
+
}
|
|
84
|
+
const out = (await res.json()) as { epoch?: number };
|
|
85
|
+
if (typeof out.epoch !== "number") {
|
|
86
|
+
throw fatalAuthorityError("claim-room-epoch returned no epoch");
|
|
87
|
+
}
|
|
88
|
+
return out.epoch;
|
|
89
|
+
},
|
|
90
|
+
async lmids(doc, clients) {
|
|
91
|
+
const res = await post(opts.lmidsUrl, JSON.stringify({ doc, clients }));
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
throw new Error(`room-lmids failed: ${res.status} ${await res.text()}`);
|
|
94
|
+
}
|
|
95
|
+
const out = (await res.json()) as { lmids?: Record<string, number> };
|
|
96
|
+
return out.lmids ?? {};
|
|
97
|
+
},
|
|
98
|
+
async applyRowChangeTxn(body) {
|
|
99
|
+
const res = await post(opts.applyUrl, body);
|
|
100
|
+
if (res.status === 409) {
|
|
101
|
+
const out = (await res.json()) as {
|
|
102
|
+
error?: string;
|
|
103
|
+
currentEpoch?: number;
|
|
104
|
+
conflicts?: AuthorityConflict[];
|
|
105
|
+
};
|
|
106
|
+
if (out.error === "fenced") {
|
|
107
|
+
return { kind: "fenced", currentEpoch: out.currentEpoch };
|
|
108
|
+
}
|
|
109
|
+
if (out.error === "conflict") {
|
|
110
|
+
return { kind: "conflict", conflicts: out.conflicts ?? [] };
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`apply-row-change-txn 409: ${JSON.stringify(out)}`);
|
|
113
|
+
}
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
const text = await res.text();
|
|
116
|
+
// The §8.3 identity check: same flush id, different body — OUR bug, loud,
|
|
117
|
+
// never retried (a retry re-sends the same mismatched bytes forever).
|
|
118
|
+
if (res.status === 500 && text.includes("batch identity")) {
|
|
119
|
+
throw fatalAuthorityError(`apply-row-change-txn: ${text}`);
|
|
120
|
+
}
|
|
121
|
+
throw new Error(`apply-row-change-txn failed: ${res.status} ${text}`);
|
|
122
|
+
}
|
|
123
|
+
const out = (await res.json()) as { applied?: boolean; cv?: number };
|
|
124
|
+
return { kind: "ok", applied: out.applied === true, cv: out.cv };
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @rindle/room — the Rindle Realtime room (P0 read-only follower + P1 lease-gated
|
|
2
|
+
// downstream + P2 shared optimistic writes).
|
|
3
|
+
//
|
|
4
|
+
// Four layers, deliberately separable:
|
|
5
|
+
// - `initRoomWasm` / `WasmRoom` (src/wasm.ts): the wasm room core — rindle-room-wasm
|
|
6
|
+
// compiled to `pkg/`, host-neutral (the Durable Object shell runs the SAME artifact).
|
|
7
|
+
// - `mintRoomToken` / `verifyRoomToken` (src/token.ts): the §10.1 self-authorizing
|
|
8
|
+
// signed lease token — minted by the API server (the authority), verified by any
|
|
9
|
+
// shell via WebCrypto (Node and Workers alike).
|
|
10
|
+
// - `MutationTx` / `RoomJournal` (src/mutation-tx.ts, src/journal.ts): the §5.1 write
|
|
11
|
+
// plane's two seams — the §4.2 mutator interface (a shared app registry drops in
|
|
12
|
+
// verbatim) and the pluggable durable sidecar an ack means (§8.1).
|
|
13
|
+
// - `createRoomShell` (src/shell.ts): the Node test shell — upstream ws subscriber of a
|
|
14
|
+
// live rindled, downstream ws server speaking the `@rindle/remote` protocol verbatim,
|
|
15
|
+
// gated by the tokens, with lease-TTL enforcement, the `/revoke` control plane, and
|
|
16
|
+
// the journaled write path.
|
|
17
|
+
|
|
18
|
+
export { initRoomWasm, WasmRoom } from "./wasm.ts";
|
|
19
|
+
export { createRoomShell } from "./shell.ts";
|
|
20
|
+
export type {
|
|
21
|
+
DownstreamOptions,
|
|
22
|
+
RoomScopeSpec,
|
|
23
|
+
RoomShell,
|
|
24
|
+
RoomShellOptions,
|
|
25
|
+
UpstreamOptions,
|
|
26
|
+
WritesOptions,
|
|
27
|
+
} from "./shell.ts";
|
|
28
|
+
export { journalEntryOutcome, memoryJournal } from "./journal.ts";
|
|
29
|
+
export type { RoomFlushRecord, RoomJournal, RoomJournalEntry } from "./journal.ts";
|
|
30
|
+
export { fatalAuthorityError, httpAuthority } from "./authority.ts";
|
|
31
|
+
export type {
|
|
32
|
+
AuthorityApplyResult,
|
|
33
|
+
AuthorityConflict,
|
|
34
|
+
HttpAuthorityOptions,
|
|
35
|
+
RoomAuthority,
|
|
36
|
+
} from "./authority.ts";
|
|
37
|
+
export type {
|
|
38
|
+
KeyedRow,
|
|
39
|
+
MutationTx,
|
|
40
|
+
RoomMutator,
|
|
41
|
+
RoomMutatorCtx,
|
|
42
|
+
WireValue,
|
|
43
|
+
} from "./mutation-tx.ts";
|
|
44
|
+
export { mintRoomToken, verifyRoomToken, RoomTokenError, scopeSpecsHash } from "./token.ts";
|
|
45
|
+
export type {
|
|
46
|
+
MintRoomTokenOptions,
|
|
47
|
+
RoomTokenPayload,
|
|
48
|
+
VerifyRoomTokenOptions,
|
|
49
|
+
} from "./token.ts";
|
package/src/journal.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// The room's pluggable journal (RINDLE-REALTIME-DESIGN.md §2.2, §5.1, §8.1): the
|
|
2
|
+
// durable sidecar an **ack** means. The shell appends mutation ENVELOPES under a group
|
|
3
|
+
// commit and advances the author's lmid row only after `append` resolves — so `acked`
|
|
4
|
+
// survives a room-process crash by construction, replayed by re-invoking the mutators
|
|
5
|
+
// against the freshly re-subscribed base (§3.3; recovery is re-invocation, never effect
|
|
6
|
+
// replay). Each host brings its own durability: the Durable Object shell backs this
|
|
7
|
+
// with per-object transactional storage (P4); the Node test shell defaults to
|
|
8
|
+
// `memoryJournal`, whose durability class is exactly "the shell process" — which is
|
|
9
|
+
// what makes T2's crash matrix a plain test: hand ONE journal to a second shell and
|
|
10
|
+
// the restart-with-journal failure class runs in-process.
|
|
11
|
+
//
|
|
12
|
+
// With P3's write-behind the journal ALSO holds the built flush batches (§5.3 step 4):
|
|
13
|
+
// the exact body bytes are appended BEFORE the first send and dropped once the
|
|
14
|
+
// authority confirms, so a crash-replay resubmits the same immutable body — one flush
|
|
15
|
+
// id names one byte body forever (the §8.3/T6 identity property). Mutation entries
|
|
16
|
+
// still grow without bound here; bounded retention (truncate ≤ the durable watermark)
|
|
17
|
+
// is a host-journal concern (P4's storage journal).
|
|
18
|
+
|
|
19
|
+
/** One journaled mutation: the wire envelope plus its recorded outcome. Replay applies
|
|
20
|
+
* the OUTCOME — a non-applied (`rejected`/`deopt`) entry consumes its mid without
|
|
21
|
+
* running the mutator. */
|
|
22
|
+
export interface RoomJournalEntry {
|
|
23
|
+
clientID: string;
|
|
24
|
+
mid: number;
|
|
25
|
+
name: string;
|
|
26
|
+
args: unknown;
|
|
27
|
+
/** The connection's authenticated subject at push time (the lease token's `sub`,
|
|
28
|
+
* shell-stamped — managed-writes §3.3): recovery is re-invocation, so identity is
|
|
29
|
+
* an input that must survive the crash. `""` = an entry journaled before the
|
|
30
|
+
* identity plane existed (mutators see it as unauthenticated). */
|
|
31
|
+
sub: string;
|
|
32
|
+
/** The recorded verdict (H-iv-b): `"applied"` replays by RE-INVOKING the mutator
|
|
33
|
+
* (§3.3 — and against a moved base the re-invocation may legitimately reject or
|
|
34
|
+
* DEOPT; the journal record is never rewritten, the shell's recorded-outcome map
|
|
35
|
+
* reflects what the replaying incarnation produced); `"rejected"` (final — authz /
|
|
36
|
+
* validation / unknown mutator) and `"deopt"` (the §3.3 commit gate refused; the
|
|
37
|
+
* client was told to re-route the mutation) both replay as a consumed-mid-no-effect
|
|
38
|
+
* WITHOUT running anything — re-judging a deopt could invent effects the client
|
|
39
|
+
* already re-routed elsewhere. Absent = a legacy pre-H-iv-b entry: read through
|
|
40
|
+
* {@link journalEntryOutcome}. */
|
|
41
|
+
outcome?: "applied" | "rejected" | "deopt";
|
|
42
|
+
/** Legacy pre-H-iv-b flag, superseded by {@link outcome} but still WRITTEN (`true`)
|
|
43
|
+
* alongside BOTH non-applied outcomes: a legacy reader replays either kind as a
|
|
44
|
+
* consumed-mid-no-effect, which is exactly right. */
|
|
45
|
+
rejected?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The ONE reading rule for an entry's verdict across journal generations: `outcome`
|
|
49
|
+
* when present, else the legacy `rejected` flag, else applied. */
|
|
50
|
+
export function journalEntryOutcome(entry: RoomJournalEntry): "applied" | "rejected" | "deopt" {
|
|
51
|
+
return entry.outcome ?? (entry.rejected === true ? "rejected" : "applied");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One journaled flush batch: the room's flush-stream position, the placement epoch it
|
|
55
|
+
* was built under, and the EXACT `/apply-row-change-txn` body string — resubmitted
|
|
56
|
+
* verbatim, never rebuilt (§5.3 step 4). */
|
|
57
|
+
export interface RoomFlushRecord {
|
|
58
|
+
seq: number;
|
|
59
|
+
epoch: number;
|
|
60
|
+
body: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RoomJournal {
|
|
64
|
+
/** Append `entries` durably, in order. Resolving is the ack gate (§8.1): the shell
|
|
65
|
+
* advances lmid rows only after this resolves. A rejection is fatal to the
|
|
66
|
+
* incarnation — an ack that might not survive must never be sent. */
|
|
67
|
+
append(entries: RoomJournalEntry[]): Promise<void>;
|
|
68
|
+
/** Every entry ever appended, in append order — the boot-time replay source. */
|
|
69
|
+
replay(): Promise<RoomJournalEntry[]>;
|
|
70
|
+
/** Journal one built flush batch, BEFORE its first send. Same durability contract
|
|
71
|
+
* as `append`: a rejection is fatal (an unjournaled batch must never reach the
|
|
72
|
+
* wire — a retry could otherwise rebuild different bytes under the same id). */
|
|
73
|
+
appendFlush(record: RoomFlushRecord): Promise<void>;
|
|
74
|
+
/** The authority settled flush `seq` (committed, deduped, or dead) — drop it. */
|
|
75
|
+
confirmFlush(seq: number): Promise<void>;
|
|
76
|
+
/** Unconfirmed flush records in seq order, plus the highest seq ever appended
|
|
77
|
+
* (0 = none) — the boot-time resubmission source and the seq seed. */
|
|
78
|
+
replayFlushes(): Promise<{ records: RoomFlushRecord[]; maxSeq: number }>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** An in-process journal: survives incarnations within one shell process (and, handed
|
|
82
|
+
* to a second shell, a simulated process crash — the T2 harness). Not durable beyond
|
|
83
|
+
* the process, by definition. */
|
|
84
|
+
export function memoryJournal(): RoomJournal {
|
|
85
|
+
const log: RoomJournalEntry[] = [];
|
|
86
|
+
const flushes = new Map<number, RoomFlushRecord>();
|
|
87
|
+
let maxSeq = 0;
|
|
88
|
+
return {
|
|
89
|
+
append(entries) {
|
|
90
|
+
log.push(...entries);
|
|
91
|
+
return Promise.resolve();
|
|
92
|
+
},
|
|
93
|
+
replay() {
|
|
94
|
+
return Promise.resolve([...log]);
|
|
95
|
+
},
|
|
96
|
+
appendFlush(record) {
|
|
97
|
+
flushes.set(record.seq, record);
|
|
98
|
+
maxSeq = Math.max(maxSeq, record.seq);
|
|
99
|
+
return Promise.resolve();
|
|
100
|
+
},
|
|
101
|
+
confirmFlush(seq) {
|
|
102
|
+
flushes.delete(seq);
|
|
103
|
+
return Promise.resolve();
|
|
104
|
+
},
|
|
105
|
+
replayFlushes() {
|
|
106
|
+
const records = [...flushes.values()].sort((a, b) => a.seq - b.seq);
|
|
107
|
+
return Promise.resolve({ records, maxSeq });
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// The room-side MutationTx (RINDLE-REALTIME-DESIGN.md §5.1; OPTIMISTIC-WRITES-DESIGN.md
|
|
2
|
+
// §4.2): the write handle a room mutator runs against, structurally identical to
|
|
3
|
+
// `@rindle/optimistic`'s client MutationTx — the whole point of §4.2's "two registries,
|
|
4
|
+
// one interface" is that an app can register its client mutators in the room VERBATIM
|
|
5
|
+
// (`mutators` from a shared app-def typechecks against `RoomMutator` as-is). Declared
|
|
6
|
+
// locally rather than imported so @rindle/room does not depend on the browser client
|
|
7
|
+
// stack; TS structural typing keeps the two in lockstep at the app's call site.
|
|
8
|
+
//
|
|
9
|
+
// Backing: the wasm room's staged transaction (txGet/txAdd/txEdit/txRemove). Only
|
|
10
|
+
// concrete cells cross the JSON boundary — `undefined` ("leave unchanged") in the
|
|
11
|
+
// positional `edit` and the partial keyed `update` is resolved HERE against the
|
|
12
|
+
// effective row (`txGet`: live head under this tx's own staged writes), the same merge
|
|
13
|
+
// the browser WriteTxn does at its staging boundary. Reads are read-your-writes by
|
|
14
|
+
// construction. Every refusal below (unknown column, width, presence) throws — the
|
|
15
|
+
// shell catches a mutator throw and turns the whole mutation into a reject.
|
|
16
|
+
|
|
17
|
+
import type { WasmRoom } from "./wasm.ts";
|
|
18
|
+
|
|
19
|
+
/** A bare wire cell (the client stack's `WireValue`). */
|
|
20
|
+
export type WireValue = number | string | boolean | null;
|
|
21
|
+
/** A row keyed by column name. */
|
|
22
|
+
export type KeyedRow = Record<string, WireValue>;
|
|
23
|
+
|
|
24
|
+
/** The §4.2 write handle. Keyed methods are schema-checked; positional methods are the
|
|
25
|
+
* raw wire shape (cells in schema column order, pk cells in `primaryKey` order).
|
|
26
|
+
* `query` is not available in room mutators yet (it throws) — typed so a client
|
|
27
|
+
* registry that uses it still registers, and fails loudly at run time. */
|
|
28
|
+
export interface MutationTx {
|
|
29
|
+
/** Read one row by primary key (e.g. `tx.row("issue", { id: 1 })`). */
|
|
30
|
+
row(table: string, pk: KeyedRow): KeyedRow | undefined;
|
|
31
|
+
/** Insert a FULL row (every column named; missing or unknown columns throw). */
|
|
32
|
+
insert(table: string, row: KeyedRow): void;
|
|
33
|
+
/** Update the row identified by the pk columns; only the named non-pk columns
|
|
34
|
+
* change. A missing row is a NO-OP (rebase-friendly). */
|
|
35
|
+
update(table: string, row: KeyedRow): void;
|
|
36
|
+
/** Insert, or fully replace when the pk already exists (a FULL row, like insert). */
|
|
37
|
+
upsert(table: string, row: KeyedRow): void;
|
|
38
|
+
/** Delete the row identified by the pk columns. A missing row is a NO-OP. */
|
|
39
|
+
delete(table: string, pk: KeyedRow): void;
|
|
40
|
+
/** NOT SUPPORTED in room mutators yet — throws. (Typed to accept the client
|
|
41
|
+
* builder's queries so shared registries typecheck.) */
|
|
42
|
+
query(query: { ast(): unknown }): never;
|
|
43
|
+
// --- positional (the wire shape) ---
|
|
44
|
+
get(table: string, pk: WireValue[]): WireValue[] | undefined;
|
|
45
|
+
add(table: string, row: WireValue[]): void;
|
|
46
|
+
remove(table: string, row: WireValue[]): void;
|
|
47
|
+
edit(table: string, oldRow: WireValue[], newRow: (WireValue | undefined)[]): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The ambient authorization context a room mutator runs under (managed-writes design
|
|
51
|
+
* §3.2). Shell-stamped from the connection's DO-verified lease token — NEVER
|
|
52
|
+
* client-supplied — so per-row/per-field rules checked against it are trustworthy.
|
|
53
|
+
* The shape deliberately matches the shared-generator drivers' `ctx.user`, so one
|
|
54
|
+
* registry body runs identically in all three homes (client / api-server / room). */
|
|
55
|
+
export interface RoomMutatorCtx {
|
|
56
|
+
/** The authenticated subject (the lease token's `sub`). `""` on replay of an entry
|
|
57
|
+
* journaled before the identity plane — treat as unauthenticated. */
|
|
58
|
+
user: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A room mutator: deterministic, replayable — re-invoked on journal replay against
|
|
62
|
+
* the freshly re-subscribed base (§3.3), so the same purity rules as a client
|
|
63
|
+
* mutator apply (no clock, no randomness, a pure function of `(base, args, ctx)`).
|
|
64
|
+
* An auth check against `ctx` re-runs on replay against the freshly rebuilt base —
|
|
65
|
+
* a mutation that passed before a crash can replay as rejected if the permission
|
|
66
|
+
* row changed in between; that is §3.3's intended rebase behavior. */
|
|
67
|
+
export type RoomMutator = (tx: MutationTx, args: never, ctx: RoomMutatorCtx) => void;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Tag an error as an ENVIRONMENT shortfall (H-iv-b): the room lacks a capability the
|
|
71
|
+
* mutation needs (today: `tx.query`), which is a verdict about the ROOM, not the
|
|
72
|
+
* mutation — the shell classifies it as a DEOPT (the client re-routes the mutation to
|
|
73
|
+
* the daemon stream, where the capability exists) instead of a FINAL rejection (which
|
|
74
|
+
* would drop the mutation). Contrast a validation/authz throw: re-routing can't help
|
|
75
|
+
* those, so they stay `rejected`.
|
|
76
|
+
*/
|
|
77
|
+
export function environmentShortfall(message: string): Error {
|
|
78
|
+
const e = new Error(message);
|
|
79
|
+
(e as { roomEnvironmentShortfall?: boolean }).roomEnvironmentShortfall = true;
|
|
80
|
+
return e;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Whether `e` was tagged by {@link environmentShortfall}. */
|
|
84
|
+
export function isEnvironmentShortfall(e: unknown): boolean {
|
|
85
|
+
return (
|
|
86
|
+
(e as { roomEnvironmentShortfall?: boolean } | null)?.roomEnvironmentShortfall === true
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Guard against the two mutator shapes that would corrupt silently instead of failing loudly.
|
|
92
|
+
* A `shared(...)` GENERATOR registered verbatim returns an un-iterated generator — zero writes,
|
|
93
|
+
* acked as applied (data loss); an ASYNC mutator runs synchronously only to its first `await`,
|
|
94
|
+
* so later writes land OUTSIDE the committed transaction. Both shells call this on the
|
|
95
|
+
* mutator's return value inside their try/reject path, so either shape becomes an explicit
|
|
96
|
+
* rejection with a pointed message. (An adapter that DRIVES a shared generator registry against
|
|
97
|
+
* the room tx is future work — managed-writes design §8.)
|
|
98
|
+
*/
|
|
99
|
+
export function assertSyncMutatorReturn(returned: unknown, name: string): void {
|
|
100
|
+
if (returned === undefined || returned === null) return;
|
|
101
|
+
const r = returned as { next?: unknown; then?: unknown };
|
|
102
|
+
if (typeof r.then === "function") {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`mutator \`${name}\` returned a promise — room mutators must be synchronous ` +
|
|
105
|
+
`(writes after an \`await\` would land outside the transaction)`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (typeof r.next === "function") {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`mutator \`${name}\` returned a generator — a shared(...) registry cannot register ` +
|
|
111
|
+
`verbatim as room mutators (nothing would drive it; zero writes would be acked). ` +
|
|
112
|
+
`Write plain synchronous (tx, args, ctx) mutators for the room bundle.`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** One table's positional shape, parsed from the upstream hello. */
|
|
118
|
+
export interface TableShape {
|
|
119
|
+
columns: string[];
|
|
120
|
+
/** Indices into `columns`. */
|
|
121
|
+
primaryKey: number[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function shapeOf(shapes: Map<string, TableShape>, table: string): TableShape {
|
|
125
|
+
const s = shapes.get(table);
|
|
126
|
+
if (!s) throw new Error(`unknown table \`${table}\``);
|
|
127
|
+
return s;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** pk cells (primaryKey order) from a keyed probe — every pk column must be named. */
|
|
131
|
+
function keyedPk(shape: TableShape, table: string, pk: KeyedRow): WireValue[] {
|
|
132
|
+
return shape.primaryKey.map((c) => {
|
|
133
|
+
const name = shape.columns[c];
|
|
134
|
+
const v = pk[name];
|
|
135
|
+
if (v === undefined) {
|
|
136
|
+
throw new Error(`missing primary-key column \`${name}\` for \`${table}\``);
|
|
137
|
+
}
|
|
138
|
+
return v;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function assertKnownColumns(shape: TableShape, table: string, row: KeyedRow): void {
|
|
143
|
+
for (const name of Object.keys(row)) {
|
|
144
|
+
if (!shape.columns.includes(name)) {
|
|
145
|
+
throw new Error(`unknown column \`${name}\` for \`${table}\``);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Build the MutationTx for one open wasm transaction. Valid only while that
|
|
151
|
+
* transaction is open — the shell creates one per mutation and never retains it. */
|
|
152
|
+
export function mutationTx(room: WasmRoom, shapes: Map<string, TableShape>): MutationTx {
|
|
153
|
+
const getRow = (table: string, pkCells: WireValue[]): WireValue[] | undefined => {
|
|
154
|
+
const text = room.txGet(table, JSON.stringify(pkCells));
|
|
155
|
+
return text === undefined ? undefined : (JSON.parse(text) as WireValue[]);
|
|
156
|
+
};
|
|
157
|
+
/** Resolve `undefined` cells ("leave unchanged") against the effective row; the
|
|
158
|
+
* row must exist. Only concrete cells may cross the JSON boundary — a stringified
|
|
159
|
+
* `undefined` would silently become `null`. */
|
|
160
|
+
const resolveCells = (
|
|
161
|
+
table: string,
|
|
162
|
+
shape: TableShape,
|
|
163
|
+
cells: (WireValue | undefined)[],
|
|
164
|
+
): WireValue[] | undefined => {
|
|
165
|
+
if (cells.length !== shape.columns.length) {
|
|
166
|
+
throw new Error(`row width does not match the schema of \`${table}\``);
|
|
167
|
+
}
|
|
168
|
+
const pkCells = shape.primaryKey.map((c) => {
|
|
169
|
+
const v = cells[c];
|
|
170
|
+
if (v === undefined) {
|
|
171
|
+
throw new Error(`primary-key cells must be concrete (\`${table}\`)`);
|
|
172
|
+
}
|
|
173
|
+
return v;
|
|
174
|
+
});
|
|
175
|
+
const current = getRow(table, pkCells);
|
|
176
|
+
if (cells.every((v) => v !== undefined)) return cells as WireValue[];
|
|
177
|
+
if (current === undefined) return undefined;
|
|
178
|
+
return cells.map((v, i) => (v === undefined ? current[i] : v)) as WireValue[];
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
row(table, pk) {
|
|
183
|
+
const shape = shapeOf(shapes, table);
|
|
184
|
+
const cells = getRow(table, keyedPk(shape, table, pk));
|
|
185
|
+
if (cells === undefined) return undefined;
|
|
186
|
+
const out: KeyedRow = {};
|
|
187
|
+
shape.columns.forEach((name, i) => (out[name] = cells[i]));
|
|
188
|
+
return out;
|
|
189
|
+
},
|
|
190
|
+
insert(table, row) {
|
|
191
|
+
const shape = shapeOf(shapes, table);
|
|
192
|
+
assertKnownColumns(shape, table, row);
|
|
193
|
+
const cells = shape.columns.map((name) => {
|
|
194
|
+
const v = row[name];
|
|
195
|
+
if (v === undefined) {
|
|
196
|
+
throw new Error(`insert of \`${table}\` is missing column \`${name}\``);
|
|
197
|
+
}
|
|
198
|
+
return v;
|
|
199
|
+
});
|
|
200
|
+
room.txAdd(table, JSON.stringify(cells));
|
|
201
|
+
},
|
|
202
|
+
update(table, row) {
|
|
203
|
+
const shape = shapeOf(shapes, table);
|
|
204
|
+
assertKnownColumns(shape, table, row);
|
|
205
|
+
const pkCells = keyedPk(shape, table, row);
|
|
206
|
+
const current = getRow(table, pkCells);
|
|
207
|
+
if (current === undefined) return; // rebase-friendly no-op
|
|
208
|
+
const cells = shape.columns.map((name, i) => {
|
|
209
|
+
const v = row[name];
|
|
210
|
+
return v === undefined ? current[i] : v;
|
|
211
|
+
});
|
|
212
|
+
room.txEdit(table, JSON.stringify(cells));
|
|
213
|
+
},
|
|
214
|
+
upsert(table, row) {
|
|
215
|
+
const shape = shapeOf(shapes, table);
|
|
216
|
+
assertKnownColumns(shape, table, row);
|
|
217
|
+
const cells = shape.columns.map((name) => {
|
|
218
|
+
const v = row[name];
|
|
219
|
+
if (v === undefined) {
|
|
220
|
+
throw new Error(`upsert of \`${table}\` is missing column \`${name}\``);
|
|
221
|
+
}
|
|
222
|
+
return v;
|
|
223
|
+
});
|
|
224
|
+
const pkCells = keyedPk(shape, table, row);
|
|
225
|
+
if (getRow(table, pkCells) === undefined) {
|
|
226
|
+
room.txAdd(table, JSON.stringify(cells));
|
|
227
|
+
} else {
|
|
228
|
+
room.txEdit(table, JSON.stringify(cells));
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
delete(table, pk) {
|
|
232
|
+
const shape = shapeOf(shapes, table);
|
|
233
|
+
const pkCells = keyedPk(shape, table, pk);
|
|
234
|
+
if (getRow(table, pkCells) === undefined) return; // rebase-friendly no-op
|
|
235
|
+
room.txRemove(table, JSON.stringify(pkCells));
|
|
236
|
+
},
|
|
237
|
+
query() {
|
|
238
|
+
// An environment shortfall, not a mutation verdict: the shell classifies this
|
|
239
|
+
// throw as a DEOPT so the client re-routes to the daemon stream (H-iv-b).
|
|
240
|
+
throw environmentShortfall("tx.query is not supported in room mutators yet");
|
|
241
|
+
},
|
|
242
|
+
get(table, pk) {
|
|
243
|
+
shapeOf(shapes, table);
|
|
244
|
+
return getRow(table, pk);
|
|
245
|
+
},
|
|
246
|
+
add(table, row) {
|
|
247
|
+
const shape = shapeOf(shapes, table);
|
|
248
|
+
if (row.some((v) => v === undefined)) {
|
|
249
|
+
throw new Error(`add of \`${table}\`: cells must be concrete`);
|
|
250
|
+
}
|
|
251
|
+
if (row.length !== shape.columns.length) {
|
|
252
|
+
throw new Error(`row width does not match the schema of \`${table}\``);
|
|
253
|
+
}
|
|
254
|
+
room.txAdd(table, JSON.stringify(row));
|
|
255
|
+
},
|
|
256
|
+
remove(table, row) {
|
|
257
|
+
const shape = shapeOf(shapes, table);
|
|
258
|
+
if (row.length !== shape.columns.length) {
|
|
259
|
+
throw new Error(`row width does not match the schema of \`${table}\``);
|
|
260
|
+
}
|
|
261
|
+
room.txRemove(table, JSON.stringify(shape.primaryKey.map((c) => row[c])));
|
|
262
|
+
},
|
|
263
|
+
edit(table, _oldRow, newRow) {
|
|
264
|
+
// The authority composes `old` from its own effective read (the wasm side);
|
|
265
|
+
// the caller's oldRow is its *prediction* of old — unused here, kept in the
|
|
266
|
+
// signature for client-registry compatibility.
|
|
267
|
+
const shape = shapeOf(shapes, table);
|
|
268
|
+
const resolved = resolveCells(table, shape, newRow);
|
|
269
|
+
if (resolved === undefined) {
|
|
270
|
+
throw new Error(`no row with that primary key in \`${table}\``);
|
|
271
|
+
}
|
|
272
|
+
room.txEdit(table, JSON.stringify(resolved));
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|