@statewalker/httpeers-hub 0.1.3

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,199 @@
1
+ /**
2
+ * The hub's durable state — members and spent invitation ids — over an
3
+ * INJECTED `SnapshotStore`, with no idea where that store puts anything.
4
+ *
5
+ * WHY THIS IS ITS OWN FILE, SEPARATE FROM `./persist.ts`. The
6
+ * `SnapshotStore` seam exists so the same hub logic can run in a browser
7
+ * page (`../pages/hub/`, Task 24) as well as in the Node process, and a
8
+ * seam is only as portable as the module that declares it: `persist.ts`
9
+ * imports `node:fs`/`node:path` at module scope for its own file store, so
10
+ * a browser bundle that reached `createPersistentHub` dragged both in with
11
+ * it. Vite does not fail that build — it substitutes a stub that throws on
12
+ * first property access — so the failure would have surfaced at runtime, in
13
+ * a tab, as an error naming `node:fs` and nothing about the hub. Everything
14
+ * in THIS file is storage-agnostic and free of `node:` imports; `persist.ts`
15
+ * keeps the Node file store and re-exports all of this unchanged, so no
16
+ * existing caller (or test) sees a different API.
17
+ *
18
+ * PRESENCE IS DELIBERATELY NOT HERE. It is TTL'd and regenerates within one
19
+ * heartbeat interval of a restart — persisting it would only let a stale
20
+ * "who was online" view survive past the moment it stopped being true.
21
+ *
22
+ * INVITATIONS THEMSELVES ARE ALSO NOT HERE — only which ids have already
23
+ * been *spent*. An unredeemed invitation forgotten across a restart is a
24
+ * minor inconvenience (issue a new one); a redeemed one becoming redeemable
25
+ * again is a membership bypass. Persisting exactly the set that guards
26
+ * against the second, and nothing else, is the smallest state that closes
27
+ * the hole. See `createInvitationStore` below: the spent-id check runs
28
+ * *before* the invitation is even looked up, so a code whose invitation
29
+ * record did not survive a restart still cannot be redeemed twice.
30
+ */
31
+
32
+ import type { RuleSet } from "@statewalker/httpeers-access";
33
+ import { assertValid, validateRoles } from "@statewalker/httpeers-access";
34
+ import type { MemberRecord, MemberStore } from "@statewalker/httpeers-core";
35
+
36
+ export interface HubSnapshot {
37
+ members: MemberRecord[];
38
+ spentInvitationIds: string[];
39
+ /**
40
+ * Invitations created and not yet redeemed.
41
+ *
42
+ * ABSENT FROM THE PROTOTYPE, and that was a defect rather than a decision:
43
+ * pending invitations lived in memory only, so a hub restart silently
44
+ * invalidated every invitation an operator had already handed out — and the
45
+ * person holding one got `not-found`, which reads like a typo rather than
46
+ * like "the hub restarted".
47
+ *
48
+ * Optional so a snapshot written by the older shape still loads: a hub
49
+ * upgrading in place finds `undefined` here and starts with no pending
50
+ * invitations, which is exactly the state it was already in.
51
+ */
52
+ pendingInvitations?: InvitationRecord[];
53
+ }
54
+
55
+ /** What a store with nothing in it yet must read as — see `SnapshotStore.read`. */
56
+ export const EMPTY_SNAPSHOT: HubSnapshot = {
57
+ members: [],
58
+ spentInvitationIds: [],
59
+ pendingInvitations: [],
60
+ };
61
+
62
+ /**
63
+ * Where a hub's snapshot lives. THE ONE SEAM BETWEEN THE HUB'S STATE AND
64
+ * ANY PARTICULAR STORAGE -- `./persist.ts`'s `createFileSnapshotStore` is
65
+ * the Node one (a JSON file), `../browser/snapshot-store.ts`'s is the
66
+ * browser one (IndexedDB); nothing else in this file knows either exists.
67
+ *
68
+ * `read` returns `EMPTY_SNAPSHOT`-shaped state on a first run rather than
69
+ * throwing: "nothing stored yet" is the normal first case, not a fault.
70
+ *
71
+ * `write` is synchronous on purpose: every mutation below calls it and then
72
+ * returns, so an async store would open a window where the caller has been
73
+ * told a member was added while the snapshot still says otherwise. A browser
74
+ * implementation that can only write asynchronously must therefore keep an
75
+ * in-memory copy authoritative and flush behind it, rather than making these
76
+ * methods async. See `../browser/snapshot-store.ts` for the one that does.
77
+ */
78
+ export interface SnapshotStore {
79
+ read(): HubSnapshot;
80
+ write(snapshot: HubSnapshot): void;
81
+ }
82
+
83
+ export interface PersistentHub {
84
+ memberStore: MemberStore;
85
+ invitations: InvitationStore;
86
+ }
87
+
88
+ export interface InvitationRecord {
89
+ id: string;
90
+ roles: string[];
91
+ expiresAt: number;
92
+ }
93
+
94
+ export type InvitationRedemption =
95
+ | { ok: true; roles: string[] }
96
+ | { ok: false; reason: "not-found" | "expired" | "already-redeemed" };
97
+
98
+ export interface InvitationStore {
99
+ /** Throws (via `assertValid`) if any role is one no rule of `rules` knows — caught where the invitation is made, not three hops later. */
100
+ create(id: string, roles: string[], ttlMs: number): InvitationRecord;
101
+ redeem(id: string): InvitationRedemption;
102
+ /** What became of this id. `unknown` means no invitation was ever created with it. */
103
+ status(id: string): "unspent" | "redeemed" | "expired" | "unknown";
104
+ /** Every invitation still awaiting redemption, for an operator's listing. */
105
+ pending(): InvitationRecord[];
106
+ }
107
+
108
+ export interface CreateHubStateInit {
109
+ /** Where the snapshot is loaded from at construction and written back to after every mutation. */
110
+ store: SnapshotStore;
111
+ /** This mesh's rules — the role registry `validateRoles` reads (ADR-0019). */
112
+ rules: RuleSet;
113
+ now?: () => number;
114
+ createMemberStore: (rules: RuleSet, clock?: () => number) => MemberStore;
115
+ }
116
+
117
+ /**
118
+ * Wrap a `MemberStore` so every mutation is followed by a synchronous
119
+ * snapshot write — members and spent invitation ids share ONE snapshot, so
120
+ * one write keeps both consistent with each other by construction (no
121
+ * window where one persisted and the other did not). Presence is
122
+ * intentionally not part of this — see the module comment.
123
+ */
124
+ export function createHubState(init: CreateHubStateInit): PersistentHub {
125
+ const now = init.now ?? Date.now;
126
+ const snapshot = init.store.read();
127
+
128
+ const innerMembers = init.createMemberStore(init.rules, now);
129
+ for (const m of snapshot.members) innerMembers.add(m.peerId, m.roles);
130
+ const spentIds = new Set(snapshot.spentInvitationIds);
131
+
132
+ const save = (): void => {
133
+ init.store.write({
134
+ members: innerMembers.list(),
135
+ spentInvitationIds: [...spentIds],
136
+ pendingInvitations: [...pending.values()],
137
+ });
138
+ };
139
+
140
+ const memberStore: MemberStore = {
141
+ add(peerId, roles) {
142
+ const record = innerMembers.add(peerId, roles);
143
+ save();
144
+ return record;
145
+ },
146
+ setRoles(peerId, roles) {
147
+ const record = innerMembers.setRoles(peerId, roles);
148
+ save();
149
+ return record;
150
+ },
151
+ remove(peerId) {
152
+ innerMembers.remove(peerId);
153
+ save();
154
+ },
155
+ get: innerMembers.get,
156
+ list: innerMembers.list,
157
+ };
158
+
159
+ // Restored from the snapshot, not merely held in memory — see
160
+ // `HubSnapshot.pendingInvitations` for what that fixes.
161
+ const pending = new Map<string, InvitationRecord>(
162
+ (snapshot.pendingInvitations ?? []).map((record) => [record.id, record]),
163
+ );
164
+
165
+ const invitations: InvitationStore = {
166
+ create(id, roles, ttlMs) {
167
+ assertValid(validateRoles(init.rules, roles, `invitation '${id}'`));
168
+ const record: InvitationRecord = { id, roles: [...roles], expiresAt: now() + ttlMs };
169
+ pending.set(id, record);
170
+ save();
171
+ return record;
172
+ },
173
+ redeem(id) {
174
+ // The spent check runs FIRST, and unconditionally — before the
175
+ // invitation record is even looked up. That is what makes double
176
+ // redemption impossible across a restart, and it stays first now that
177
+ // pending records persist too: the two facts are written together, but
178
+ // "already spent" must win over "here is the record" whatever order they
179
+ // were loaded in.
180
+ if (spentIds.has(id)) return { ok: false, reason: "already-redeemed" };
181
+ const record = pending.get(id);
182
+ if (record == null) return { ok: false, reason: "not-found" };
183
+ if (record.expiresAt <= now()) return { ok: false, reason: "expired" };
184
+ pending.delete(id);
185
+ spentIds.add(id);
186
+ save();
187
+ return { ok: true, roles: record.roles };
188
+ },
189
+ status(id) {
190
+ if (spentIds.has(id)) return "redeemed";
191
+ const record = pending.get(id);
192
+ if (record == null) return "unknown";
193
+ return record.expiresAt <= now() ? "expired" : "unspent";
194
+ },
195
+ pending: () => [...pending.values()],
196
+ };
197
+
198
+ return { memberStore, invitations };
199
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Minting membership, holding the registries, and saying who is in the mesh.
3
+ */
4
+
5
+ export { type CreateHubInit, createHub, type Hub } from "./create-hub.js";
6
+ export {
7
+ ADMIN_CAPABILITY,
8
+ createHubEndpoints,
9
+ DEFAULT_PRESENCE_TTL_MS,
10
+ type HubEndpoints,
11
+ type HubEndpointsInit,
12
+ usesTransportIdentity,
13
+ } from "./endpoints.js";
14
+ export {
15
+ type CreateHubStateInit,
16
+ createHubState,
17
+ EMPTY_SNAPSHOT,
18
+ type HubSnapshot,
19
+ type InvitationRecord,
20
+ type InvitationRedemption,
21
+ type InvitationStore,
22
+ type PersistentHub,
23
+ type SnapshotStore,
24
+ } from "./hub-state.js";
25
+ export { type AdvertisementPayload, buildMeshView } from "./mesh-view.js";
26
+ export { createAdvertisementStore, createMemberStore, createPresenceStore } from "./registries.js";
27
+ export {
28
+ type AsyncSnapshotStore,
29
+ asyncSnapshotStore,
30
+ filesStorage,
31
+ type KeyValueStorage,
32
+ memoryStorage,
33
+ } from "./storage.js";
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The mesh view: a PROJECTION over the three registries (members, presence,
3
+ * advertisements), not a fourth registry of its own. Nothing here is stored
4
+ * — it is recomputed from the hub's live state on every read, filtered by
5
+ * whoever is asking.
6
+ *
7
+ * THE HUB IS A BULLETIN BOARD, NEVER AN AUTHORITY. An advertisement says
8
+ * "worth asking"; the provider it names still decides for itself whether to
9
+ * answer. Nothing downstream may treat inclusion in this view as a grant —
10
+ * that is what the membership token and each provider's own policy
11
+ * are for.
12
+ *
13
+ * Two things vary by caller, both because different callers legitimately
14
+ * see different views:
15
+ * - a member with role `hidden` is omitted from the view of anyone who does
16
+ * not themselves hold `std:mesh.admin` — this is the one consumer of the
17
+ * `hidden` role `DEFAULT_RULES` already knows;
18
+ * - an advertisement is omitted unless the caller holds the capability that
19
+ * gates its `kind` (`advertisementAccess`, hub-owned config — analogous
20
+ * to an allow policy, but for the bulletin board rather than a path).
21
+ */
22
+ import type {
23
+ Advertisement,
24
+ MemberRecord,
25
+ MeshView,
26
+ MeshViewAdvertisement,
27
+ MeshViewMember,
28
+ PresenceRecord,
29
+ } from "@statewalker/httpeers-core";
30
+
31
+ // `MeshView` and its members moved to `httpeers-core`. The hub PRODUCES one
32
+ // and `httpeers-member` RETURNS one, and member must not depend on hub — so a
33
+ // type both consume belongs in the package both already import. Re-exported
34
+ // here so this file's own readers still find it where they expect.
35
+ export type { MeshView, MeshViewAdvertisement, MeshViewMember };
36
+
37
+ /** The payload shape this hub posts advertisements with — see `hub/endpoints.ts`'s presence handler. */
38
+ export interface AdvertisementPayload {
39
+ kind: string;
40
+ title: string;
41
+ }
42
+
43
+ export interface BuildMeshViewInit {
44
+ version: number;
45
+ self: string;
46
+ members: MemberRecord[];
47
+ presence: PresenceRecord[];
48
+ addrsByPeer: ReadonlyMap<string, string[]>;
49
+ advertisements: Advertisement[];
50
+ /** Capabilities the caller holds (already derived from their roles by the node's rules). */
51
+ callerCapabilities: ReadonlySet<string>;
52
+ /** `kind` -> capability required to see advertisements of that kind. A kind absent here is visible to any authenticated caller. */
53
+ advertisementAccess: Readonly<Record<string, string>>;
54
+ /** The capability that grants admin visibility (sees `hidden` members). */
55
+ adminCapability: string;
56
+ }
57
+
58
+ export function buildMeshView(init: BuildMeshViewInit): MeshView {
59
+ const isAdmin = init.callerCapabilities.has(init.adminCapability);
60
+ const present = new Set(init.presence.map((p) => p.peerId));
61
+
62
+ const members: MeshViewMember[] = init.members
63
+ .filter((m) => isAdmin || !m.roles.includes("hidden"))
64
+ .map((m) => ({
65
+ peerId: m.peerId,
66
+ roles: m.roles,
67
+ online: present.has(m.peerId),
68
+ addrs: init.addrsByPeer.get(m.peerId) ?? [],
69
+ }));
70
+
71
+ const advertisements: MeshViewAdvertisement[] = init.advertisements
72
+ .filter((ad) => {
73
+ const kind = (ad.payload as AdvertisementPayload).kind;
74
+ const required = init.advertisementAccess[kind];
75
+ return required == null || init.callerCapabilities.has(required);
76
+ })
77
+ .map((ad) => {
78
+ const payload = ad.payload as AdvertisementPayload;
79
+ return { peerId: ad.peerId, id: ad.key, kind: payload.kind, title: payload.title };
80
+ });
81
+
82
+ return { version: init.version, self: init.self, members, advertisements };
83
+ }
package/src/node.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The Node profile: hub state on disk.
3
+ *
4
+ * Write-then-rename, for the reason rung 04 measured: the prototype's file
5
+ * snapshot store wrote IN PLACE, so a crash mid-write left a truncated JSON
6
+ * document — and a hub that cannot parse its snapshot starts EMPTY. Every
7
+ * member forgotten, every spent invitation id forgotten and therefore
8
+ * replayable, and nothing reporting an error.
9
+ */
10
+
11
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
12
+ import { dirname } from "node:path";
13
+ import type { KeyValueStorage } from "./storage.js";
14
+
15
+ export function fileStorage(path: string): KeyValueStorage {
16
+ const pathFor = (key: string) => `${path}.${key.replace(/[^A-Za-z0-9_-]/g, "_")}`;
17
+ return {
18
+ async get(key) {
19
+ try {
20
+ return await readFile(pathFor(key), "utf8");
21
+ } catch (error) {
22
+ if ((error as { code?: string }).code === "ENOENT") return undefined;
23
+ throw error;
24
+ }
25
+ },
26
+ async set(key, value) {
27
+ const target = pathFor(key);
28
+ await mkdir(dirname(target), { recursive: true });
29
+ // Same directory, so the rename cannot cross a filesystem boundary —
30
+ // the one case where it would stop being atomic.
31
+ const staging = `${target}.${process.pid}.${Date.now()}.tmp`;
32
+ await writeFile(staging, value, { mode: 0o600 });
33
+ await rename(staging, target);
34
+ },
35
+ async delete(key) {
36
+ const { rm } = await import("node:fs/promises");
37
+ await rm(pathFor(key), { force: true });
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The hub's three registries: members, presence, advertisements.
3
+ *
4
+ * All three are plain in-memory data structures with an injected clock
5
+ * (`() => number`) so TTL and sequencing behaviour is deterministic under
6
+ * test, with no real timers involved. Persistence is a later task's job —
7
+ * nothing here touches the filesystem or a database.
8
+ */
9
+
10
+ import type { RuleSet } from "@statewalker/httpeers-access";
11
+ import { assertValid, validateRoles } from "@statewalker/httpeers-access";
12
+ import type {
13
+ Advertisement,
14
+ AdvertisementStore,
15
+ MemberRecord,
16
+ MemberStore,
17
+ PresenceRecord,
18
+ PresenceStore,
19
+ PresenceWriteResult,
20
+ } from "@statewalker/httpeers-core";
21
+
22
+ /**
23
+ * Durable membership: who belongs to the mesh, and with what roles.
24
+ *
25
+ * `setRoles` validates its `roles` against `rules` before writing — "catch a
26
+ * typo where it is made, not three hops later as a silent denial" (the v0.9.0
27
+ * delta's own rationale). Since ADR-0019 there is no separate role registry to
28
+ * validate against: a role exists for this node exactly when some rule fires
29
+ * on it, which is what `validateRoles` reads. This is deliberately at the STORE,
30
+ * not at whichever call site happens to reach `setRoles` today: a guard
31
+ * placed at a call site is only as durable as that call site, and a later
32
+ * one added without knowing the guard belongs here would ship an
33
+ * unvalidated role write. `add` is NOT validated here — every caller that
34
+ * feeds it caller-supplied roles (e.g. invitation redemption) validates
35
+ * them earlier, at the point the roles were themselves accepted
36
+ * (`InvitationStore.create` in `apps/httpeers-stack`), matching the
37
+ * archived delta's own choice to guard `createInvitation` and `setRoles`,
38
+ * never `addMember`.
39
+ */
40
+ export function createMemberStore(rules: RuleSet, clock: () => number = Date.now): MemberStore {
41
+ const members = new Map<string, MemberRecord>();
42
+
43
+ return {
44
+ add(peerId, roles) {
45
+ const record: MemberRecord = { peerId, roles: [...roles], updatedAt: clock() };
46
+ members.set(peerId, record);
47
+ return record;
48
+ },
49
+ setRoles(peerId, roles) {
50
+ assertValid(validateRoles(rules, roles, `setRoles(${peerId})`));
51
+ const record: MemberRecord = { peerId, roles: [...roles], updatedAt: clock() };
52
+ members.set(peerId, record);
53
+ return record;
54
+ },
55
+ remove(peerId) {
56
+ members.delete(peerId);
57
+ },
58
+ get(peerId) {
59
+ return members.get(peerId);
60
+ },
61
+ list() {
62
+ return [...members.values()];
63
+ },
64
+ };
65
+ }
66
+
67
+ /**
68
+ * TTL'd, swept presence: who is live right now.
69
+ *
70
+ * Writes are monotonic per peer — see `PresenceStore` in types.ts for why.
71
+ * The comparison is always `seq` against the peer's own last-accepted
72
+ * `seq`, never wall-clock time and never across different peers.
73
+ */
74
+ export function createPresenceStore(clock: () => number = Date.now): PresenceStore {
75
+ const presence = new Map<string, PresenceRecord>();
76
+
77
+ return {
78
+ heartbeat(peerId, seq, ttlMs): PresenceWriteResult {
79
+ const existing = presence.get(peerId);
80
+ if (existing !== undefined && seq <= existing.seq) {
81
+ return { accepted: false, reason: "stale-sequence" };
82
+ }
83
+ const record: PresenceRecord = { peerId, seq, expiresAt: clock() + ttlMs };
84
+ presence.set(peerId, record);
85
+ return { accepted: true, record };
86
+ },
87
+ isPresent(peerId) {
88
+ const record = presence.get(peerId);
89
+ return record !== undefined && record.expiresAt > clock();
90
+ },
91
+ get(peerId) {
92
+ return presence.get(peerId);
93
+ },
94
+ list() {
95
+ return [...presence.values()];
96
+ },
97
+ sweep() {
98
+ const now = clock();
99
+ const expired: string[] = [];
100
+ for (const record of presence.values()) {
101
+ if (record.expiresAt <= now) {
102
+ expired.push(record.peerId);
103
+ }
104
+ }
105
+ for (const peerId of expired) {
106
+ presence.delete(peerId);
107
+ }
108
+ return expired;
109
+ },
110
+ };
111
+ }
112
+
113
+ /**
114
+ * The bulletin board: what peers offer. Keyed by `(peerId, key)` via a
115
+ * two-level `Map` — not a joined string — so there is no delimiter to pick
116
+ * and therefore no way for a `peerId` or `key` value to collide with one.
117
+ * Entries persist until withdrawn or replaced, not on a timer.
118
+ */
119
+ export function createAdvertisementStore(clock: () => number = Date.now): AdvertisementStore {
120
+ const board = new Map<string, Map<string, Advertisement>>();
121
+
122
+ return {
123
+ post(peerId, key, payload) {
124
+ const ad: Advertisement = { peerId, key, payload, postedAt: clock() };
125
+ let byKey = board.get(peerId);
126
+ if (byKey === undefined) {
127
+ byKey = new Map();
128
+ board.set(peerId, byKey);
129
+ }
130
+ byKey.set(key, ad);
131
+ return ad;
132
+ },
133
+ withdraw(peerId, key) {
134
+ board.get(peerId)?.delete(key);
135
+ },
136
+ get(peerId, key) {
137
+ return board.get(peerId)?.get(key);
138
+ },
139
+ list(peerId) {
140
+ if (peerId !== undefined) {
141
+ return [...(board.get(peerId)?.values() ?? [])];
142
+ }
143
+ const all: Advertisement[] = [];
144
+ for (const byKey of board.values()) {
145
+ all.push(...byKey.values());
146
+ }
147
+ return all;
148
+ },
149
+ };
150
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The candidate `hub` package's storage seam, and two adapters for it.
3
+ *
4
+ * DERIVED, NOT DESIGNED. Everything the hub does to storage today is
5
+ * whole-value get / set / delete by key: `persist.ts` reads and writes one
6
+ * JSON file; `browser/snapshot-store.ts` reads and writes one IndexedDB
7
+ * value. Nothing lists, appends, or compare-and-sets. So the interface is
8
+ * those three methods and no more.
9
+ *
10
+ * `SnapshotStore` (`hub/hub-state.ts`) is deliberately SYNCHRONOUS, because
11
+ * every mutation writes and returns, and an async write would let a caller be
12
+ * told "member added" while the snapshot still said otherwise. Both async
13
+ * backends therefore need the same wrapper the browser already has: the
14
+ * in-memory copy is authoritative, and flushes are serialised behind it.
15
+ * `asyncSnapshotStore` below is that wrapper, generalised — it is
16
+ * `browser/snapshot-store.ts`'s logic over any `KeyValueStorage` rather than
17
+ * over idb-keyval specifically.
18
+ */
19
+
20
+ import type { FilesApi } from "@statewalker/webrun-files";
21
+ import { EMPTY_SNAPSHOT, type HubSnapshot, type SnapshotStore } from "./hub-state.js";
22
+
23
+ /** The whole persistence contract. Three methods, because three is what the hub uses. */
24
+ export interface KeyValueStorage {
25
+ get(key: string): Promise<string | undefined>;
26
+ set(key: string, value: string): Promise<void>;
27
+ delete(key: string): Promise<void>;
28
+ }
29
+
30
+ /** For tests and for a hub that deliberately keeps nothing. */
31
+ export function memoryStorage(): KeyValueStorage {
32
+ const map = new Map<string, string>();
33
+ return {
34
+ async get(key) {
35
+ return map.get(key);
36
+ },
37
+ async set(key, value) {
38
+ map.set(key, value);
39
+ },
40
+ async delete(key) {
41
+ map.delete(key);
42
+ },
43
+ };
44
+ }
45
+
46
+ /**
47
+ * A `FilesApi` as key/value storage — one file per key under `dir`.
48
+ *
49
+ * WRITE-THEN-MOVE, because a crash mid-write must not leave a half-written
50
+ * snapshot where the whole state lives. `persist.ts`'s Node implementation
51
+ * does NOT do this (it writes in place), which is a defect this adapter does
52
+ * not inherit.
53
+ *
54
+ * FilesApi HAS NO CONDITIONAL WRITE — no compare-and-set, no if-match. That
55
+ * is why the hub's single-writer precondition is a precondition rather than
56
+ * something the storage layer can enforce; rung 04's third claim measures
57
+ * exactly what that costs.
58
+ */
59
+ export function filesStorage(files: FilesApi, dir = "hub"): KeyValueStorage {
60
+ const path = (key: string): string => `${dir}/${encodeURIComponent(key)}.json`;
61
+
62
+ return {
63
+ async get(key) {
64
+ const file = path(key);
65
+ if (!(await files.exists(file))) return undefined;
66
+ const chunks: Uint8Array[] = [];
67
+ for await (const chunk of files.read(file)) chunks.push(chunk);
68
+ const total = chunks.reduce((n, c) => n + c.length, 0);
69
+ const bytes = new Uint8Array(total);
70
+ let at = 0;
71
+ for (const chunk of chunks) {
72
+ bytes.set(chunk, at);
73
+ at += chunk.length;
74
+ }
75
+ return new TextDecoder().decode(bytes);
76
+ },
77
+ async set(key, value) {
78
+ const file = path(key);
79
+ const temp = `${file}.tmp`;
80
+ const bytes = new TextEncoder().encode(value);
81
+ await files.write(
82
+ temp,
83
+ (async function* () {
84
+ yield bytes;
85
+ })(),
86
+ );
87
+ await files.move(temp, file);
88
+ },
89
+ async delete(key) {
90
+ await files.remove(path(key));
91
+ },
92
+ };
93
+ }
94
+
95
+ export interface AsyncSnapshotStore extends SnapshotStore {
96
+ /** Resolves when every write issued so far has reached the backend. */
97
+ flushed(): Promise<void>;
98
+ }
99
+
100
+ /**
101
+ * Load once, then serve reads from memory and flush writes behind them —
102
+ * the shape `SnapshotStore`'s synchronous contract forces on any async
103
+ * backend.
104
+ */
105
+ export async function asyncSnapshotStore(
106
+ storage: KeyValueStorage,
107
+ key = "snapshot",
108
+ ): Promise<AsyncSnapshotStore> {
109
+ const raw = await storage.get(key);
110
+ let current: HubSnapshot = EMPTY_SNAPSHOT;
111
+ if (raw != null) {
112
+ try {
113
+ current = JSON.parse(raw) as HubSnapshot;
114
+ } catch {
115
+ // A corrupt value reads as empty rather than throwing, matching
116
+ // `browser/snapshot-store.ts`. A hub that refuses to start because its
117
+ // state file was truncated is worse than one that starts empty and
118
+ // says so.
119
+ current = EMPTY_SNAPSHOT;
120
+ }
121
+ }
122
+
123
+ // Writes are chained, never concurrent: two overlapping flushes of the same
124
+ // key can land out of order, and the loser would be the newer state.
125
+ let chain: Promise<void> = Promise.resolve();
126
+
127
+ return {
128
+ read: () => current,
129
+ write(snapshot) {
130
+ current = snapshot;
131
+ const value = JSON.stringify(snapshot);
132
+ chain = chain.then(() => storage.set(key, value)).catch(() => {});
133
+ },
134
+ flushed: () => chain,
135
+ };
136
+ }