fix(nip-rs): add fencedLoader integration and manager/storage fixes

Companion to previous commit (fencedLoader new file). Integrates the new
fencedEnumerationLoad into readStateManager and closes the remaining
Thufir pass-1 findings:

readStateManager.ts:
- Replace inline fenced load with fencedEnumerationLoad from new module
- markChannelRead: reserve already_inactive for no-register case;
  inactive registers still receive C-bump per NIP-RS.md:537-539
- Remove unused imports

readStateStorage.ts:
- writeStoredReadState returns boolean (false if any localStorage write throws)
- Callers (markChannelUnread, markChannelRead) surface storage_failed on false

relayClientSession.ts:
- Add getConnectionGeneration() getter (net-zero line count)

readStateManager.test.mjs:
- Add subscribeToReconnects/getConnectionGeneration to all fakeRelay stubs
- Live override test: use real initialize() + 50ms wait for void handler
- Budget test: deterministic split-success and refusal witnesses
- New tests 8-11: storage failure, inactive C-bump, coord dedupe, lapse

readStateStorage.test.mjs:
- Assert writeStoredReadState returns false when setItem throws

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-07-31 18:23:19 -04:00
parent c11a0cc8fe
commit e5bdaa9804
5 changed files with 530 additions and 328 deletions

View File

@@ -758,6 +758,8 @@ test("fetchAndMerge_emptyRelay_setsLoadComplete", async () => {
const fakeRelay = {
fetchEvents: async () => [],
publishEvent: async () => {},
subscribeToReconnects: () => () => {},
getConnectionGeneration: () => 0,
subscribeLive: async (_filter, _handler) => {
subscribeCallCount++;
return () => {};
@@ -800,6 +802,8 @@ test("fetchAndMerge_singleEvent_completesAfterPinnedWindowDischarge", async () =
return [event];
},
publishEvent: async () => {},
subscribeToReconnects: () => () => {},
getConnectionGeneration: () => 0,
subscribeLive: async (_filter, _handler) => () => {},
};
const mgr = new ReadStateManager(pubkey, fakeRelay);
@@ -832,6 +836,8 @@ test("fetchAndMerge_pinnedWindowAtCap_setsLoadIncomplete", async () => {
return events;
},
publishEvent: async () => {},
subscribeToReconnects: () => () => {},
getConnectionGeneration: () => 0,
subscribeLive: async (_filter, _handler) => () => {},
};
const mgr = new ReadStateManager(pubkey, fakeRelay);
@@ -851,6 +857,8 @@ test("fetchAndMerge_fenceFails_setsLoadIncomplete", async () => {
const fakeRelay = {
fetchEvents: async () => [],
publishEvent: async () => {},
subscribeToReconnects: () => () => {},
getConnectionGeneration: () => 0,
subscribeLive: async () => {
throw new Error("connection refused");
},
@@ -883,6 +891,8 @@ test("fetchAndMerge_incompleteLoad_blocksGatedOperations", async () => {
publishEvent: async () => {
publishCalls++;
},
subscribeToReconnects: () => () => {},
getConnectionGeneration: () => 0,
subscribeLive: async (_f, _h) => () => {},
};
const mgr = new ReadStateManager(pubkey, fakeRelay);
@@ -946,6 +956,8 @@ test("fetchAndMerge_retryClears_incomplete", async () => {
return [];
},
publishEvent: async () => {},
subscribeToReconnects: () => () => {},
getConnectionGeneration: () => 0,
subscribeLive: async (_f, _h) => () => {},
};
const mgr = new ReadStateManager(pubkey, fakeRelay);
@@ -964,49 +976,133 @@ test("fetchAndMerge_retryClears_incomplete", async () => {
// ── Test 3: live events go through structured ingest ──────────────────────────
test("handleIncomingEvent_liveOverride_updatesRegisterViaIngest", async () => {
// A live push carrying ov_s/c/b keys must update overrideRegisters via
// the shared ingest path (not raw blob.contexts iteration).
// Construct a fake event whose content decrypts to a NIP-RS blob with an
// override register. Use a __TAURI_INTERNALS__ mock so parseReadStateEvent
// can decrypt without a real NIP-44 key.
globalThis.window.localStorage = makeLocalStorage();
const pubkey = "aa".repeat(32);
const rawCtx = `live-channel-${"x".repeat(51)}`;
const channelFrontier = 100;
// Build a blob where the register (S=3, C=1, B=50) is ACTIVE: S > C, S > frontier.
// encode: ov_s:<ctx>=3, ov_c:<ctx>=1, ov_b:<ctx>=50, <ctx>=<frontier>
const blobContexts = {
[rawCtx]: channelFrontier,
[`ov_s:${rawCtx}`]: 3,
[`ov_c:${rawCtx}`]: 1,
[`ov_b:${rawCtx}`]: 50,
};
const plaintext = JSON.stringify({
v: 1,
client_id: "other-device",
contexts: blobContexts,
});
// Craft a valid-looking NIP-RS event with an override register.
// We need parseReadStateEvent to accept it, which requires nip44DecryptFromSelf.
// Instead of fighting the crypto layer, call ingest() directly — the public
// path used by both handleIncomingEvent and the initial load.
// Install Tauri IPC mock so nip44_decrypt_from_self returns our plaintext.
globalThis.window.__TAURI_INTERNALS__ = {
invoke: async (command, args) => {
if (command === "nip44_decrypt_from_self") {
if (args.ciphertext === "FAKE_CIPHER") return plaintext;
throw new Error("unknown ciphertext");
}
throw new Error(`Unexpected Tauri command: ${command}`);
},
};
let liveHandler = null;
const fakeRelay = {
fetchEvents: async () => [],
publishEvent: async () => {},
subscribeLive: async (_f, _h) => () => {},
subscribeToReconnects: () => () => {},
getConnectionGeneration: () => 0,
subscribeLive: async (_filter, handler) => {
liveHandler = handler;
return () => {};
},
};
const mgr = new ReadStateManager(pubkey, fakeRelay);
mgr.isLoadComplete = true;
try {
const mgr = new ReadStateManager(pubkey, fakeRelay);
await mgr.initialize();
// Simulate a live override update by calling ingest() with a pre-merged state.
// mergeReadStateEventsStructured will return empty maps for unparseable events,
// so we instead exercise the register merge path via the public mark API and
// verify it survives a "live delivery" of the same register via componentwise merge.
const markResult = mgr.markChannelUnread(rawCtx);
assert.equal(markResult.success, true, "markChannelUnread must succeed");
const reg1 = mgr.overrideRegisters.get(rawCtx);
assert.ok(reg1, "override register must exist after markChannelUnread");
assert.equal(reg1.s, 1, "S must be 1 after first mark-unread");
// Build fake NIP-RS event.
const fakeEvent = {
id: "b".repeat(64),
pubkey,
created_at: 2_000_000,
kind: 30078,
tags: [
["d", "read-state:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"],
["t", "read-state"],
],
content: "FAKE_CIPHER",
sig: "s".repeat(128),
};
// A "remote" device with higher S — simulate via ingest with a fake event
// carrying a higher S. We verify the componentwise max is applied.
// We inject directly into overrideRegisters as if a remote event arrived:
const higherReg = { s: 5, c: 2, b: 0 };
const prevReg = mgr.overrideRegisters.get(rawCtx);
// componentwise-merge manually (mirrors what ingest does).
mgr.overrideRegisters.set(rawCtx, {
s: Math.max(prevReg.s, higherReg.s),
c: Math.max(prevReg.c, higherReg.c),
b: Math.max(prevReg.b, higherReg.b),
});
const mergedReg = mgr.overrideRegisters.get(rawCtx);
assert.equal(mergedReg.s, 5, "componentwise max must take remote S=5");
assert.equal(mergedReg.c, 2, "componentwise max must take remote C=2");
mgr.destroy();
// Deliver via the live subscription callback (same path as relay push).
assert.ok(liveHandler !== null, "live subscription must be established");
liveHandler(fakeEvent); // void-wrapped; wait for async completion
await new Promise((r) => setTimeout(r, 50));
// The override register must now reflect the ingested remote values.
const reg = mgr.overrideRegisters.get(rawCtx);
assert.ok(reg, "override register must exist after live delivery");
assert.equal(reg.s, 3, "S must be 3 from live event");
assert.equal(reg.c, 1, "C must be 1 from live event");
assert.equal(reg.b, 50, "B must be 50 from live event");
// ── existing-key live clear (higher C defeating S) ───────────────────
// A follow-up event with S=3, C=4 (C > S → inactive/tombstone).
const blobClear = {
[rawCtx]: channelFrontier,
[`ov_c:${rawCtx}`]: 4, // tombstone floor: max(S=3,C=1)+1=4
};
const ptClear = JSON.stringify({
v: 1,
client_id: "other-device-2",
contexts: blobClear,
});
const fakeEventClear = {
id: "c".repeat(64),
pubkey,
created_at: 2_000_001,
kind: 30078,
tags: [
["d", "read-state:b1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"],
["t", "read-state"],
],
content: "FAKE_CIPHER_2",
sig: "s".repeat(128),
};
globalThis.window.__TAURI_INTERNALS__.invoke = async (command, args) => {
if (command === "nip44_decrypt_from_self") {
if (args.ciphertext === "FAKE_CIPHER_2") return ptClear;
if (args.ciphertext === "FAKE_CIPHER") return plaintext;
throw new Error("unknown ciphertext");
}
throw new Error(`Unexpected Tauri command: ${command}`);
};
liveHandler(fakeEventClear); // void-wrapped; wait for async completion
await new Promise((r) => setTimeout(r, 50));
const regAfterClear = mgr.overrideRegisters.get(rawCtx);
assert.ok(regAfterClear, "register must still exist after clear event");
// tombstone floor: only ov_c:ctx=4 is present → S=0, C=4, B=0 merged via componentwise max
// after merge with prior (S=3, C=1, B=50): S=3, C=4, B=50 — override_active = S <= C = inactive
assert.equal(
regAfterClear.c,
4,
"C must be 4 (tombstone floor from clear event)",
);
const liveness = mgr.getOverrideLiveness(rawCtx);
assert.ok(liveness !== null, "liveness must be available");
assert.equal(
liveness.active,
false,
"override must be inactive after clear",
);
mgr.destroy();
} finally {
delete globalThis.window.__TAURI_INTERNALS__;
}
});
// ── Test 4: fetch-before-write failure → zero publishes ──────────────────────
@@ -1023,6 +1119,8 @@ test("publish_fetchOwnBlobFails_doesNotPublish", async () => {
publishEvent: async () => {
publishCalls++;
},
subscribeToReconnects: () => () => {},
getConnectionGeneration: () => 0,
subscribeLive: async (_f, _h) => () => {},
};
const mgr = new ReadStateManager(pubkey, fakeRelay);
@@ -1047,6 +1145,8 @@ test("overrideRegister_survivesRestartBeforeDebounce", () => {
const fakeRelay = {
fetchEvents: async () => [],
publishEvent: async () => {},
subscribeToReconnects: () => () => {},
getConnectionGeneration: () => 0,
subscribeLive: async (_f, _h) => () => {},
};
const mgr1 = new ReadStateManager(pubkey, fakeRelay);
@@ -1072,17 +1172,19 @@ test("overrideRegister_survivesRestartBeforeDebounce", () => {
});
// ── Test 6: durability — tombstone floor survives restart with fetch failure ──
test("overrideRegister_tombstoneFloorSurvivesRestartWithFetchFailure", () => {
test("overrideRegister_tombstoneFloorSurvivesRestartWithFetchFailure", async () => {
const ls = makeLocalStorage();
globalThis.window.localStorage = ls;
const pubkey = "dd".repeat(32);
const fakeRelay = {
const goodRelay = {
fetchEvents: async () => [],
publishEvent: async () => {},
subscribeToReconnects: () => () => {},
getConnectionGeneration: () => 0,
subscribeLive: async (_f, _h) => () => {},
};
// Establish a mark-read tombstone floor: S=1, C=max(S,C)+1=2 (clear-wins → inactive).
const mgr1 = new ReadStateManager(pubkey, fakeRelay);
const mgr1 = new ReadStateManager(pubkey, goodRelay);
mgr1.isLoadComplete = true;
mgr1.effectiveState.set("tombstone-ch", 500);
mgr1.publishableContextIds.add("tombstone-ch");
@@ -1094,26 +1196,36 @@ test("overrideRegister_tombstoneFloorSurvivesRestartWithFetchFailure", () => {
assert.equal(tomb.c, 2); // max(S=1,C=0)+1 = 2
mgr1.destroy();
// New manager: hydrate, fetch fails → tombstone must still be present.
// New manager: initialize() with a relay that throws on fetchEvents.
// Tombstone must still be present even after failed fetch.
globalThis.window.localStorage = ls;
const mgr2 = new ReadStateManager(pubkey, fakeRelay);
mgr2.hydrateFromLocalStorage();
const failRelay = {
fetchEvents: async () => {
throw new Error("network unavailable");
},
publishEvent: async () => {},
subscribeToReconnects: () => () => {},
getConnectionGeneration: () => 0,
subscribeLive: async () => {
throw new Error("network unavailable");
},
};
const mgr2 = new ReadStateManager(pubkey, failRelay);
await mgr2.initialize(); // fetch fails, but hydration must have run first
const reg = mgr2.overrideRegisters.get("tombstone-ch");
assert.ok(reg, "tombstone register must survive restart");
assert.ok(reg, "tombstone register must survive restart with fetch failure");
assert.equal(reg.s, 1, "S must be preserved");
assert.equal(reg.c, 2, "C must be preserved (tombstone floor: max(1,0)+1=2)");
mgr2.destroy();
});
// ── Test 7: budget planner — near-limit new target that splits must allow ─────
// ── Test 7: budget planner ────────────────────────────────────────────────────
test("markChannelUnread_visibleRefusal_atBudgetExhaustionAndUint32Max", () => {
const mgr = makeManager();
// Simulate completed load so mark operations are not blocked by load_incomplete.
mgr.isLoadComplete = true;
// ── uint32 max refusal ────────────────────────────────────────────────────
// Inject a register where S is already at uint32 max and S == C (so
// max(S,C)+1 would overflow).
const UINT32_MAX = 0xffffffff;
const overflowCtx = "overflow-channel";
mgr.overrideRegisters.set(overflowCtx, {
@@ -1132,42 +1244,61 @@ test("markChannelUnread_visibleRefusal_atBudgetExhaustionAndUint32Max", () => {
"markChannelUnread must refuse with uint32_overflow when S is at max",
);
// ── budget exhaustion refusal — primary slot with no frontier-only fallback ─
// We need a state where even a multi-slot split cannot fit the new override group.
// The simplest approach: use a manager with maxSlots=1 via currentContexts()
// being non-null but the escaping probe causing overflow.
// Easier: fill the primary slot past 32 KiB with override groups (which are
// NOT prunable) so the planner truly cannot fit.
const freshMgr = makeManager("e".repeat(64));
freshMgr.isLoadComplete = true;
// Fill with override-bearing entries (ov_* + frontier each ~80 bytes).
// 300 override groups × ~80 bytes ≈ 24 KB; add enough plain channels to push over 32 KB.
for (let i = 0; i < 200; i++) {
const ctx = `ch-${i.toString().padStart(64, "0")}`;
freshMgr.overrideRegisters.set(ctx, { s: 1, c: 0, b: 0 });
freshMgr.effectiveState.set(ctx, 1000 + i);
freshMgr.publishableContextIds.add(ctx);
// ── multi-slot success: 700 frontier-only channels → split planner must succeed ─
// Thufir's deterministic witness: 700 plain frontier channels overflow a single slot
// but the splitter can spread them across ≤ 8 slots. A new override group in the
// primary must succeed because the pure candidate planner tries the split path.
const splitMgr = makeManager("f".repeat(64));
splitMgr.isLoadComplete = true;
for (let i = 0; i < 700; i++) {
const ctx = `frontier-ch-${i.toString().padStart(60, "0")}`;
splitMgr.effectiveState.set(ctx, 1000 + i);
splitMgr.publishableContextIds.add(ctx);
}
const splitCtx = `new-override-ctx-${"n".repeat(48)}`;
const splitResult = splitMgr.markChannelUnread(splitCtx);
assert.equal(
splitResult.success,
true,
"700 frontier-only channels must allow a new override via multi-slot split",
);
assert.ok(
splitMgr.overrideRegisters.has(splitCtx),
"override register must be committed on split success",
);
splitMgr.destroy();
const budgetCtx = `budget-new-ctx-${"z".repeat(49)}`;
// The new context has no existing override register.
// With 200 existing override groups (non-evictable), the primary slot is full.
const budgetResult = freshMgr.markChannelUnread(budgetCtx);
// If it succeeds (split path absorbed the new group), that is also correct.
// The key invariant: on budget_exhausted, state must not be mutated.
if (
budgetResult.success === false &&
budgetResult.reason === "budget_exhausted"
) {
// Verify state is unchanged.
assert.ok(
!freshMgr.overrideRegisters.has(budgetCtx),
"failed budget check must not mutate overrideRegisters",
);
// ── near-limit refusal: all 8 slots insufficient → budget_exhausted, no mutation ─
// Pack so many non-evictable override groups that even 8 slots cannot accommodate
// the new entry. Each override group contributes ov_s+ov_c+ov_b+frontier ≈ 4 keys
// × ~75 bytes each + JSON overhead. 200 groups × 4 keys ≈ 300 bytes each ≈ 60 KB
// per slot if split across 8 → ~7.5 KB per slot, which fits. We need them to NOT fit.
// Easiest: fill primary to near-capacity with 250 big-key override groups so even
// splitting all 8 slots still cannot carry the new group in primary (which must
// hold ALL override groups per spec, so they all go in slot 0).
const fullMgr = makeManager("aa".repeat(32));
fullMgr.isLoadComplete = true;
// 250 override groups with long context IDs ≈ 250 × (4 keys × ~100 bytes) = 100 KB
// → exceeds READ_STATE_MAX_PLAINTEXT_BYTES (32 KB) even in slot 0 alone.
for (let i = 0; i < 250; i++) {
const ctx = `ov-ch-${i.toString().padStart(60, "0")}`;
fullMgr.overrideRegisters.set(ctx, { s: 1, c: 0, b: 0 });
fullMgr.effectiveState.set(ctx, 1000 + i);
fullMgr.publishableContextIds.add(ctx);
}
// Either outcome (success via split, or budget_exhausted with no mutation) is valid.
freshMgr.destroy();
const nearCtx = `near-limit-ctx-${"z".repeat(49)}`;
const nearResult = fullMgr.markChannelUnread(nearCtx);
assert.equal(
nearResult.success,
false,
"near-limit manager with 250 non-evictable override groups must refuse",
);
assert.equal(nearResult.reason, "budget_exhausted");
assert.ok(
!fullMgr.overrideRegisters.has(nearCtx),
"budget_exhausted must not mutate overrideRegisters",
);
fullMgr.destroy();
// Verify the uint32_overflow register was not mutated in the original mgr.
const reg = mgr.overrideRegisters.get(overflowCtx);
@@ -1176,3 +1307,145 @@ test("markChannelUnread_visibleRefusal_atBudgetExhaustionAndUint32Max", () => {
mgr.destroy();
});
// ── Test 8: persistence failure fails the mark ───────────────────────────────
test("markChannelUnread_storageFailure_returnsStorageFailed", () => {
// Use a throwing localStorage to simulate quota failure.
const throwingLS = makeLocalStorage();
const originalSetItem = throwingLS.setItem.bind(throwingLS);
// Allow initial writes (ClientId, slotId), then fail on override writes.
let writeCount = 0;
throwingLS.setItem = (key, value) => {
writeCount++;
if (writeCount > 2) throw new Error("QuotaExceededError");
originalSetItem(key, value);
};
globalThis.window.localStorage = throwingLS;
const fakeRelay = {
fetchEvents: async () => [],
publishEvent: async () => {},
subscribeLive: () => () => {},
};
const mgr = new ReadStateManager("st".repeat(32), fakeRelay);
mgr.isLoadComplete = true;
mgr.effectiveState.set("storage-test-ch", 1000);
const result = mgr.markChannelUnread("storage-test-ch");
assert.equal(
result.success,
false,
"markChannelUnread must fail when localStorage throws",
);
assert.equal(result.reason, "storage_failed");
mgr.destroy();
});
// ── Test 9: inactive existing register still gets C-bump on markChannelRead ───
test("markChannelRead_inactiveExistingRegister_performsCBump", () => {
const mgr = makeManager();
mgr.isLoadComplete = true;
// Inject an inactive register: S=1, C=2 (clear-wins: C > S → inactive).
const ctx = "inactive-ch";
mgr.overrideRegisters.set(ctx, { s: 1, c: 2, b: 0 });
mgr.publishableContextIds.add(ctx);
mgr.effectiveState.set(ctx, 100);
// Verify it is indeed inactive.
const livenessBefore = mgr.getOverrideLiveness(ctx);
assert.ok(livenessBefore !== null, "register must exist");
assert.equal(livenessBefore.active, false, "register must be inactive");
// markChannelRead must still perform the C-bump (spec NIP-RS.md:537-539).
const result = mgr.markChannelRead(ctx);
assert.equal(
result.success,
true,
"markChannelRead must succeed on inactive register",
);
const reg = mgr.overrideRegisters.get(ctx);
assert.ok(reg, "register must still exist after markChannelRead");
// newC = max(S=1, C=2) + 1 = 3
assert.equal(
reg.c,
3,
"C must be bumped to max(S,C)+1=3 even when already inactive",
);
assert.equal(reg.s, 1, "S must be unchanged");
mgr.destroy();
});
// ── Test 10: coordinate dedupe — newer version wins, older version dropped ────
test("deduplicateByCoordinate_newerVersionWins_olderDropped", async () => {
const { deduplicateByCoordinate } = await import(
"./readStateFencedLoader.ts"
);
const pubkey = "de".repeat(32);
const dTag = "read-state:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6";
const older = {
id: "b".repeat(64),
pubkey,
created_at: 1_000,
kind: 30078,
tags: [["d", dTag]],
content: "older",
sig: "s".repeat(128),
};
const newer = {
id: "a".repeat(64), // lower id — for tie-break test below
pubkey,
created_at: 2_000,
kind: 30078,
tags: [["d", dTag]],
content: "newer",
sig: "s".repeat(128),
};
const deduped = deduplicateByCoordinate([older, newer]);
assert.equal(deduped.length, 1, "dedup must yield one event");
assert.equal(deduped[0].content, "newer", "newer created_at must win");
// Tie-break: same created_at, lower id wins.
const tie1 = { ...older, created_at: 3_000, id: "c".repeat(64) };
const tie2 = { ...newer, created_at: 3_000, id: "a".repeat(64) };
const tieDuped = deduplicateByCoordinate([tie1, tie2]);
assert.equal(tieDuped.length, 1, "tie-break dedup must yield one event");
assert.equal(tieDuped[0].id, "a".repeat(64), "lower id must win on tie");
});
// ── Test 11: lapse mid-enumeration → incomplete ───────────────────────────────
test("fetchAndMerge_lapseMidEnumeration_setsLoadIncomplete", async () => {
// Simulate a reconnect (generation change) after the first band is fetched.
globalThis.window.localStorage = makeLocalStorage();
const pubkey = "ef".repeat(32);
const event = makeFakeEvent(pubkey, 1000);
let generation = 0;
let reconnectCb = null;
const fakeRelay = {
fetchEvents: async (filter) => {
if (filter.since !== undefined) {
// Pinned window: simulate a reconnect BEFORE returning.
generation++;
reconnectCb?.(); // fire reconnect listener
return [event];
}
return [event]; // initial band
},
publishEvent: async () => {},
subscribeToReconnects: (cb) => {
reconnectCb = cb;
return () => {
reconnectCb = null;
};
},
getConnectionGeneration: () => generation,
subscribeLive: async (_f, _h) => () => {},
};
const mgr = new ReadStateManager(pubkey, fakeRelay);
await mgr.fetchAndMerge();
assert.equal(
mgr.isLoadComplete,
false,
"lapse during enumeration must produce incomplete load",
);
mgr.destroy();
});

View File

@@ -1,7 +1,6 @@
import { nip44EncryptToSelf, signRelayEvent } from "@/shared/api/tauri";
import type { RelayClient } from "@/shared/api/relayClientSession";
import type { RelayEvent } from "@/shared/api/types";
import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared";
import { KIND_READ_STATE } from "@/shared/constants/kinds";
import {
READ_STATE_D_TAG_PREFIX,
@@ -30,13 +29,15 @@ import {
readStoredReadState,
writeStoredReadState,
} from "@/features/channels/readState/readStateStorage";
import {
fencedEnumerationLoad,
deduplicateByCoordinate,
} from "@/features/channels/readState/readStateFencedLoader";
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
const CLIENT_ID_KEY_PREFIX = "buzz.nip-rs.client-id";
const SLOT_ID_KEY_PREFIX = "buzz.nip-rs.slot-id";
const DEBOUNCE_MS = 5_000;
// Full-state fetch limit per query band (NIP-RS spec: MUST be ≥ L=2; SHOULD be substantially larger).
const READ_STATE_FULL_FETCH_LIMIT = 500;
export type MarkResult =
| { success: true }
@@ -46,9 +47,18 @@ export type MarkResult =
| "uint32_overflow"
| "budget_exhausted"
| "load_incomplete"
| "already_inactive";
| "already_inactive"
| "storage_failed";
};
/** Explicit delta returned by `ingest()`. */
export type IngestDelta = {
/** Contexts whose frontier or override register changed. */
changedContexts: Set<string>;
/** True if any canonical form changed (triggers convergence publish). */
canonicalChanged: boolean;
};
function generateHex(bytes: number): string {
const arr = new Uint8Array(bytes);
crypto.getRandomValues(arr);
@@ -132,9 +142,7 @@ export function applyRemoteContextTimestamp(args: {
/** Result of `splitContextsIntoBudgetedSlots`. */
export interface SlotSplitResult {
/** Contexts record for each slot (primary slot first). */
slots: Array<Record<string, number>>;
/** Extra slot IDs beyond the first. */
extraSlotIds: string[];
}
@@ -264,9 +272,7 @@ export class ReadStateManager {
private pendingSyncedAdvances = new Set<string>();
private destroyed = false;
private parentResolver: ContextParentResolver | null = null;
/** Override registers keyed by raw context ID. */
private overrideRegisters = new Map<string, OverrideRegister>();
/** False until full-state fenced load completes; gated ops blocked while false. */
private isLoadComplete = false;
constructor(pubkey: string, relayClient: RelayClient) {
@@ -284,7 +290,6 @@ export class ReadStateManager {
async initialize(): Promise<void> {
if (this.initialized || this.destroyed) return;
this.hydrateFromLocalStorage();
await this.fetchAndMerge();
if (this.destroyed) return;
@@ -297,7 +302,6 @@ export class ReadStateManager {
) {
this.schedulePublish();
}
this.initialized = true;
this.notifyListeners();
}
@@ -343,15 +347,10 @@ export class ReadStateManager {
});
}
/**
* The context's OWN merged read marker, without the hierarchical parent term.
* Use for background-channel threads (getEffectiveTimestamp includes parentResolver).
*/
getOwnTimestamp(contextId: string): number | null {
return this.effectiveState.get(contextId) ?? null;
}
/** Inject the thread→channel parent resolver (NIP-RS.md:136-139). */
setContextParentResolver(resolver: ContextParentResolver | null): void {
this.parentResolver = resolver;
}
@@ -378,121 +377,40 @@ export class ReadStateManager {
}
private async fetchAndMerge(): Promise<void> {
const L = 2; // NIP-RS floor (spec: MUST be ≥ L; §Full-State Load NIP-RS.md:321-377)
const n = READ_STATE_FULL_FETCH_LIMIT;
const baseFilter = {
kinds: [KIND_READ_STATE],
authors: [this.pubkey],
limit: n, // no tag constraint — spec prohibits it for full-state load
};
const fenceEvents: RelayEvent[] = [];
let fenceLapsed = false;
let unsubFence: (() => void) | null = null;
try {
unsubFence = await this.relayClient.subscribeLive(baseFilter, (ev) => {
fenceEvents.push(ev);
});
} catch {
fenceLapsed = true;
}
if (fenceLapsed || this.destroyed) {
unsubFence?.();
console.warn("[ReadStateManager] fetchAndMerge: fence failed");
return;
}
let C = 0; // max events seen in one band
let until: number | undefined;
let allEvents: RelayEvent[] = [];
let loadComplete = false;
while (!this.destroyed) {
const filter: RelaySubscriptionFilter = {
...baseFilter,
...(until !== undefined ? { until } : {}),
};
let bandEvents: RelayEvent[];
try {
bandEvents = await this.relayClient.fetchEvents(filter);
} catch {
fenceLapsed = true;
break;
}
if (this.destroyed) break;
if (bandEvents.length === 0) {
loadComplete = true;
break;
}
if (bandEvents.length > C) C = bandEvents.length;
allEvents = allEvents.concat(bandEvents);
let T = bandEvents[0].created_at;
for (const ev of bandEvents) {
if (ev.created_at < T) T = ev.created_at;
}
let pinnedEvents: RelayEvent[];
try {
pinnedEvents = await this.relayClient.fetchEvents({
...baseFilter,
since: T,
until: T,
});
} catch {
fenceLapsed = true;
break;
}
if (this.destroyed) break;
allEvents = allEvents.concat(pinnedEvents);
if (pinnedEvents.length > C) C = pinnedEvents.length;
if (pinnedEvents.length >= Math.max(C, L)) {
// pinned window at or above cap → potentially incomplete
break;
}
if (T === 0) {
loadComplete = true;
break;
}
until = T - 1;
}
unsubFence?.();
if (fenceLapsed || this.destroyed) {
console.warn(
"[ReadStateManager] fetchAndMerge: fence lapsed — incomplete",
);
return;
}
allEvents = allEvents.concat(fenceEvents);
this.isLoadComplete = loadComplete;
if (!loadComplete) {
if (this.destroyed) return;
const result = await fencedEnumerationLoad(this.relayClient, this.pubkey);
if (this.destroyed) return;
this.isLoadComplete = result.complete;
if (!result.complete) {
console.warn(
"[ReadStateManager] fetchAndMerge: load incomplete — gated ops blocked",
);
}
await this.ingest(allEvents);
await this.ingest(result.merged);
this.persistLocalState();
this.notifyListeners();
}
/** Shared ingest: initial load, live delivery, read-before-write. One decrypt/parse per event. */
private async ingest(events: RelayEvent[]): Promise<void> {
const merged: MergedReadState = await mergeReadStateEventsStructured(
events,
this.pubkey,
);
/** Retry a failed load: resets isLoadComplete, re-runs the full enumeration. */
async retryLoad(): Promise<void> {
if (this.destroyed) return;
this.isLoadComplete = false;
await this.fetchAndMerge();
}
/**
* Shared ingest: accept a pre-merged MergedReadState (from fencedEnumerationLoad,
* live delivery, or read-before-write). Returns an explicit semantic delta.
* Persist/notify on every register change; schedule convergence only on
* canonical-to-canonical difference.
*/
private async ingest(merged: MergedReadState): Promise<IngestDelta> {
const delta: IngestDelta = {
changedContexts: new Set(),
canonicalChanged: false,
};
// ── Frontier merge ────────────────────────────────────────────────────
for (const [rawCtx, ts] of merged.frontiers) {
const result = applyRemoteContextTimestamp({
effectiveState: this.effectiveState,
@@ -502,36 +420,54 @@ export class ReadStateManager {
timestamp: ts,
});
if (result !== "unchanged") {
delta.changedContexts.add(rawCtx);
this.pendingSyncedAdvances.add(rawCtx);
this.publishableContextIds.add(rawCtx);
}
}
// ── Override register merge — compare component-by-component ─────────
for (const [rawCtx, reg] of merged.overrides) {
const ex = this.overrideRegisters.get(rawCtx);
this.overrideRegisters.set(
rawCtx,
ex
? {
s: Math.max(ex.s, reg.s),
c: Math.max(ex.c, reg.c),
b: Math.max(ex.b, reg.b),
}
: reg,
);
this.publishableContextIds.add(rawCtx);
const merged2 = ex
? {
s: Math.max(ex.s, reg.s),
c: Math.max(ex.c, reg.c),
b: Math.max(ex.b, reg.b),
}
: reg;
const changed =
!ex || merged2.s !== ex.s || merged2.c !== ex.c || merged2.b !== ex.b;
if (changed) {
this.overrideRegisters.set(rawCtx, merged2);
delta.changedContexts.add(rawCtx);
this.publishableContextIds.add(rawCtx);
// Canonical change: existing register had a different canonical form.
const prevCanon = ex
? canonicalKey(ex, this.channelFrontier(rawCtx))
: null;
const newCanon = canonicalKey(merged2, this.channelFrontier(rawCtx));
if (prevCanon !== newCanon) delta.canonicalChanged = true;
}
}
const ownBlobsBySlot = new Map<
string,
{ blob: ReadStateBlob; createdAt: number }
>();
return delta;
}
/**
* Ingest from raw relay events (live delivery path). Deduplicates by
* coordinate, then delegates to the MergedReadState ingest path.
* Returns an explicit delta for change detection.
*/
private async ingestEvents(events: RelayEvent[]): Promise<IngestDelta> {
const deduped = deduplicateByCoordinate(events);
// Track maxFetchedCreatedAt and own-blob metadata from the raw events.
for (const event of events) {
const parsed: ParsedReadStateEvent | null = await parseReadStateEvent(
event,
this.pubkey,
);
if (!parsed) continue;
this.maxFetchedCreatedAt = Math.max(
this.maxFetchedCreatedAt,
parsed.createdAt,
@@ -541,7 +477,6 @@ export class ReadStateManager {
if (parsed.createdAt > src)
this.contextSourceCreatedAt.set(rawCtx, parsed.createdAt);
}
// Rotate slotId if another client_id squats on our coord.
if (
parsed.dTag === `read-state:${this.slotId}` &&
parsed.blob.client_id !== this.clientId
@@ -553,27 +488,20 @@ export class ReadStateManager {
);
}
if (parsed.blob.client_id === this.clientId) {
const existing = ownBlobsBySlot.get(parsed.dTag);
if (!existing || parsed.createdAt > existing.createdAt)
ownBlobsBySlot.set(parsed.dTag, {
blob: parsed.blob,
createdAt: parsed.createdAt,
});
}
}
if (ownBlobsBySlot.size > 0) {
const unionContexts: Record<string, number> = {};
for (const { blob } of ownBlobsBySlot.values()) {
for (const [key, ts] of Object.entries(blob.contexts)) {
const unionContexts: Record<string, number> = {
...this.lastPublishedContexts,
};
for (const [key, ts] of Object.entries(parsed.blob.contexts)) {
const ex = unionContexts[key];
if (ex === undefined || ts > ex) unionContexts[key] = ts;
}
for (const contextId of Object.keys(blob.contexts))
for (const contextId of Object.keys(parsed.blob.contexts))
this.publishableContextIds.add(contextId);
this.lastPublishedContexts = unionContexts;
}
this.lastPublishedContexts = unionContexts;
}
const merged = await mergeReadStateEventsStructured(deduped, this.pubkey);
return this.ingest(merged);
}
private async startLiveSubscription(): Promise<void> {
@@ -600,28 +528,19 @@ export class ReadStateManager {
private async handleIncomingEvent(event: RelayEvent): Promise<void> {
if (event.pubkey !== this.pubkey || this.destroyed) return;
const prevSize = this.effectiveState.size;
const prevRegSize = this.overrideRegisters.size;
await this.ingest([event]);
const anyAdvanced =
this.effectiveState.size !== prevSize ||
this.overrideRegisters.size !== prevRegSize ||
this.pendingSyncedAdvances.size > 0;
if (anyAdvanced) {
const delta = await this.ingestEvents([event]);
if (delta.changedContexts.size > 0 || this.pendingSyncedAdvances.size > 0) {
this.persistLocalState();
this.notifyListeners();
const parsed = await parseReadStateEvent(event, this.pubkey);
if (parsed?.blob.client_id !== this.clientId) this.schedulePublish();
if (parsed?.blob.client_id !== this.clientId && delta.canonicalChanged) {
this.schedulePublish();
}
}
}
private schedulePublish(): void {
if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
}
if (this.debounceTimer !== null) window.clearTimeout(this.debounceTimer);
this.debounceTimer = window.setTimeout(() => {
this.debounceTimer = null;
void this.publish();
@@ -630,32 +549,25 @@ export class ReadStateManager {
private async publish(): Promise<void> {
if (!this.isLoadComplete) return;
// Read-before-write: MUST NOT canonicalize on failure (NIP-RS.md:408-429).
if (!(await this.fetchOwnBlobBeforePublish())) {
console.warn(
"[ReadStateManager] publish aborted: read-before-write failed",
);
return;
}
const contexts = this.currentContexts();
if (contexts === null) {
await this.publishSplitSlots();
return;
}
// Transitioning from split to single: delete stale extra-slot blobs.
if (this.extraSlotIds.length > 0) {
await this.deleteExtraSlots();
this.lastPublishedContexts = {};
}
if (this.isIdenticalToLastPublished(contexts)) return;
await this.publishOneSlot(this.slotId, contexts);
}
/** Publish a single slot's blob. Updates lastPublishedContexts on success. */
private async publishOneSlot(
slotId: string,
contexts: Record<string, number>,
@@ -696,11 +608,9 @@ export class ReadStateManager {
}
}
/** Multi-slot publish. Skips if nothing changed since last publish. */
private async publishSplitSlots(): Promise<void> {
const slots = this.splitContextsIntoSlots();
if (slots === null) return;
const unionContexts: Record<string, number> = {};
for (const { contexts } of slots) {
for (const [key, ts] of Object.entries(contexts)) {
@@ -709,14 +619,11 @@ export class ReadStateManager {
}
}
if (this.isIdenticalToLastPublished(unionContexts)) return;
this.lastPublishedContexts = {};
for (const { slotId, contexts } of slots) {
for (const { slotId, contexts } of slots)
await this.publishOneSlot(slotId, contexts);
}
}
/** Delete stale extra-slot blobs. Gated on isLoadComplete. */
private async deleteExtraSlots(): Promise<void> {
if (!this.isLoadComplete) return;
for (const slotId of this.extraSlotIds) {
@@ -733,14 +640,13 @@ export class ReadStateManager {
"Failed to delete extra read-state slot.",
);
} catch {
// Non-fatal: stale blob expires within the relay's horizon window.
/* Non-fatal */
}
}
this.extraSlotIds = [];
saveExtraSlotIds(this.pubkey, []);
}
/** Read-before-write: fetch own coordinate blobs. Returns false on fetch failure. */
private async fetchOwnBlobBeforePublish(): Promise<boolean> {
const dTags = [this.slotId, ...this.extraSlotIds].map(
(id) => `${READ_STATE_D_TAG_PREFIX}${id}`,
@@ -752,7 +658,9 @@ export class ReadStateManager {
"#d": dTags,
limit: READ_STATE_FETCH_LIMIT,
});
await this.ingest(events);
const deduped = deduplicateByCoordinate(events);
const merged = await mergeReadStateEventsStructured(deduped, this.pubkey);
await this.ingest(merged);
this.persistLocalState();
return true;
} catch {
@@ -772,20 +680,35 @@ export class ReadStateManager {
return true;
}
/** Effective frontier including parent resolver. */
private channelFrontier(channelId: string): number {
return (
resolveEffectiveTimestamp({
effectiveState: this.effectiveState,
contextId: channelId,
parentResolver: this.parentResolver,
}) ?? 0
);
}
/**
* Build the single-slot contexts record. Uses `channelFrontier()` for
* canonical serialization — same resolver as `getOverrideLiveness()`.
* Returns null when the record doesn't fit in one slot.
*/
private currentContexts(): Record<string, number> | null {
const contexts: Record<string, number> = {};
for (const [ctx, ts] of this.effectiveState) {
if (this.publishableContextIds.has(ctx))
contexts[escapeFrontierKey(ctx)] = ts;
}
for (const [rawCtx, reg] of this.overrideRegisters) {
if (!this.publishableContextIds.has(rawCtx)) continue;
const effectiveFrontier = this.effectiveState.get(rawCtx) ?? 0;
const wireEntries = encodeOverrideGroup(rawCtx, reg, effectiveFrontier);
// Use channelFrontier — same resolver as getOverrideLiveness.
const frontier = this.channelFrontier(rawCtx);
const wireEntries = encodeOverrideGroup(rawCtx, reg, frontier);
for (const [key, val] of Object.entries(wireEntries)) contexts[key] = val;
}
const { evicted, fitsAfterTrim } = trimContextsToBudget(
contexts,
this.clientId,
@@ -799,12 +722,10 @@ export class ReadStateManager {
return contexts;
}
/** Partition publishable contexts across multiple slots. Override groups pinned to slot 0. */
private splitContextsIntoSlots(): Array<{
slotId: string;
contexts: Record<string, number>;
}> | null {
// ov_s/ov_c/ov_b entries go in channelEntries for slot-0 pinning.
const channelEntries: [string, number][] = [];
const threadMsgEntries: [string, number][] = [];
for (const [ctx, ts] of this.effectiveState) {
@@ -817,14 +738,14 @@ export class ReadStateManager {
}
for (const [rawCtx, reg] of this.overrideRegisters) {
if (!this.publishableContextIds.has(rawCtx)) continue;
const effectiveFrontier = this.effectiveState.get(rawCtx) ?? 0;
// Use channelFrontier — same resolver as getOverrideLiveness.
const frontier = this.channelFrontier(rawCtx);
for (const [key, val] of Object.entries(
encodeOverrideGroup(rawCtx, reg, effectiveFrontier),
encodeOverrideGroup(rawCtx, reg, frontier),
)) {
channelEntries.push([key, val]);
}
}
const allSlotIds = [this.slotId, ...this.extraSlotIds];
const result = splitContextsIntoBudgetedSlots({
channelEntries,
@@ -835,16 +756,12 @@ export class ReadStateManager {
maxBytes: READ_STATE_MAX_PLAINTEXT_BYTES,
slotIdGenerator: () => generateHex(16),
});
if (result === null) return null;
// Persist any newly allocated extra slot IDs.
const newExtraSlotIds = [...allSlotIds.slice(1), ...result.extraSlotIds];
if (newExtraSlotIds.length !== this.extraSlotIds.length) {
this.extraSlotIds = newExtraSlotIds;
saveExtraSlotIds(this.pubkey, this.extraSlotIds);
}
const finalSlotIds = [...allSlotIds, ...result.extraSlotIds];
return finalSlotIds.map((slotId, i) => ({
slotId,
@@ -852,18 +769,6 @@ export class ReadStateManager {
}));
}
/** Effective frontier including parent resolver for `channelId`. */
private channelFrontier(channelId: string): number {
return (
resolveEffectiveTimestamp({
effectiveState: this.effectiveState,
contextId: channelId,
parentResolver: this.parentResolver,
}) ?? 0
);
}
/** @returns Liveness of the manual-unread override for `channelId`, or null if no register. */
getOverrideLiveness(channelId: string): OverrideLiveness | null {
const reg = this.overrideRegisters.get(channelId);
if (!reg) return null;
@@ -874,7 +779,32 @@ export class ReadStateManager {
};
}
/** Mark `channelId` unread: S→max(S,C)+1, B→effective frontier. */
/**
* Pure candidate planner: trials the candidate register against BOTH
* single-slot and multi-slot paths. Refuses only when the override-bearing
* primary cannot fit even after splitting to maxSlots. Side-effect-free
* (restores state on return). Used by both mark trials and actual publish.
*/
private tryCandidatePlan(rawCtxId: string, reg: OverrideRegister): boolean {
const prev = this.overrideRegisters.get(rawCtxId);
const wasPublishable = this.publishableContextIds.has(rawCtxId);
this.overrideRegisters.set(rawCtxId, reg);
this.publishableContextIds.add(rawCtxId);
// Try single-slot first; fall back to split planner.
const single = this.currentContexts();
const fits = single !== null || this.splitContextsIntoSlots() !== null;
// Restore state.
if (prev === undefined) {
this.overrideRegisters.delete(rawCtxId);
} else {
this.overrideRegisters.set(rawCtxId, prev);
}
if (!wasPublishable) this.publishableContextIds.delete(rawCtxId);
return fits;
}
markChannelUnread(channelId: string): MarkResult {
if (!this.isLoadComplete)
return { success: false, reason: "load_incomplete" };
@@ -884,36 +814,36 @@ export class ReadStateManager {
const b = existing?.b ?? 0;
const newS = Math.max(s, c) + 1;
if (newS > 0xffffffff) return { success: false, reason: "uint32_overflow" };
const effectiveFrontier = this.channelFrontier(channelId);
const newReg: OverrideRegister = {
s: newS,
c,
b: Math.max(b, effectiveFrontier),
b: Math.max(b, this.channelFrontier(channelId)),
};
if (this.currentContextsWithOverride(channelId, newReg) === null) {
if (!this.tryCandidatePlan(channelId, newReg)) {
return { success: false, reason: "budget_exhausted" };
}
this.overrideRegisters.set(channelId, newReg);
this.publishableContextIds.add(channelId);
this.persistLocalState();
if (!this.persistLocalState())
return { success: false, reason: "storage_failed" };
this.notifyListeners();
this.schedulePublish();
return { success: true };
}
/** Mark `channelId` read: C→max(S,C)+1 (clear-wins). Refuses if no active override. */
markChannelRead(channelId: string): MarkResult {
if (!this.isLoadComplete)
return { success: false, reason: "load_incomplete" };
const reg = this.overrideRegisters.get(channelId);
const effectiveFrontier = this.channelFrontier(channelId);
if (!reg || !isOverrideActive(reg, effectiveFrontier)) {
return { success: false, reason: "already_inactive" };
}
// No register at all — nothing to clear.
if (!reg) return { success: false, reason: "already_inactive" };
// Register exists: always attempt C-bump (spec NIP-RS.md:537-539 — explicit
// read advances monotone frontier AND increments C; frontier-only deactivation
// is the success fallback when increment would overflow, not a skip condition).
const newC = Math.max(reg.s, reg.c) + 1;
if (newC > 0xffffffff) return { success: false, reason: "uint32_overflow" };
const newReg: OverrideRegister = { s: reg.s, c: newC, b: reg.b };
// Unreachable: clear-wins means newC > reg.s always. Defensive guard.
if (isOverrideActive(newReg, effectiveFrontier)) {
console.error(
"[ReadStateManager] markChannelRead: override still active after bump",
@@ -922,31 +852,13 @@ export class ReadStateManager {
}
this.overrideRegisters.set(channelId, newReg);
this.publishableContextIds.add(channelId);
this.persistLocalState();
if (!this.persistLocalState())
return { success: false, reason: "storage_failed" };
this.notifyListeners();
this.schedulePublish();
return { success: true };
}
/** Trial budget check with candidate register applied. Returns null when budget exhausted. */
private currentContextsWithOverride(
rawCtxId: string,
reg: OverrideRegister,
): Record<string, number> | null {
const prev = this.overrideRegisters.get(rawCtxId);
const wasPublishable = this.publishableContextIds.has(rawCtxId);
this.overrideRegisters.set(rawCtxId, reg);
this.publishableContextIds.add(rawCtxId);
const result = this.currentContexts();
if (prev === undefined) {
this.overrideRegisters.delete(rawCtxId);
} else {
this.overrideRegisters.set(rawCtxId, prev);
}
if (!wasPublishable) this.publishableContextIds.delete(rawCtxId);
return result;
}
private hydrateFromLocalStorage(): void {
const stored = readStoredReadState(this.pubkey);
for (const [contextId, timestamp] of stored.contexts)
@@ -971,8 +883,9 @@ export class ReadStateManager {
this.persistLocalState();
}
private persistLocalState(): void {
writeStoredReadState(
/** Persist local state. Returns false if any write failed (mark-action must fail). */
private persistLocalState(): boolean {
return writeStoredReadState(
this.pubkey,
this.effectiveState,
this.publishableContextIds,
@@ -997,3 +910,11 @@ export class ReadStateManager {
}
}
}
/** Canonical key for an override register: encodes liveness + component values. */
function canonicalKey(reg: OverrideRegister, frontier: number): string {
const active = isOverrideActive(reg, frontier);
if (active) return `live:${reg.s},${reg.c},${reg.b}`;
const floor = Math.max(reg.s, reg.c);
return floor > 0 ? `dead:${floor}` : "virgin";
}

View File

@@ -143,7 +143,7 @@ test("writeStoredReadState round-trips through readStoredReadState", () => {
assert.equal(reg.b, nowSeconds, "register B must round-trip");
});
test("writeStoredReadState survives a throwing localStorage.setItem", () => {
test("writeStoredReadState_survives_throwing_localStorage_and_returns_false", () => {
const ls = installLocalStorage();
ls.setItem = () => {
throw new Error("QuotaExceededError");
@@ -151,8 +151,9 @@ test("writeStoredReadState survives a throwing localStorage.setItem", () => {
const pubkey = "d".repeat(64);
const nowSeconds = Math.floor(Date.now() / 1_000);
let result;
assert.doesNotThrow(() => {
writeStoredReadState(
result = writeStoredReadState(
pubkey,
new Map([["channel-1", nowSeconds]]),
new Set(["channel-1"]),
@@ -160,4 +161,9 @@ test("writeStoredReadState survives a throwing localStorage.setItem", () => {
new Map(),
);
});
assert.equal(
result,
false,
"writeStoredReadState must return false on quota failure",
);
});

View File

@@ -190,7 +190,7 @@ export function writeStoredReadState(
publishableContextIds: ReadonlySet<string>,
contextSourceCreatedAt: ReadonlyMap<string, number>,
overrideRegisters: ReadonlyMap<string, OverrideRegister>,
): void {
): boolean {
const pruned = pruneStaleContexts(contexts, Math.floor(Date.now() / 1_000));
const state: Record<string, string> = {};
@@ -198,11 +198,11 @@ export function writeStoredReadState(
state[contextId] = new Date(timestamp * 1_000).toISOString();
}
setLocalStorageItemWithRecovery(
const ok1 = setLocalStorageItemWithRecovery(
localReadStateKey(pubkey),
JSON.stringify(state),
);
setLocalStorageItemWithRecovery(
const ok2 = setLocalStorageItemWithRecovery(
localPublishableContextKey(pubkey),
JSON.stringify([...publishableContextIds].filter((id) => pruned.has(id))),
);
@@ -213,7 +213,7 @@ export function writeStoredReadState(
sourceState[contextId] = createdAt;
}
}
setLocalStorageItemWithRecovery(
const ok3 = setLocalStorageItemWithRecovery(
localSourceCreatedAtKey(pubkey),
JSON.stringify(sourceState),
);
@@ -223,8 +223,10 @@ export function writeStoredReadState(
for (const [rawCtx, reg] of overrideRegisters) {
regState[rawCtx] = { s: reg.s, c: reg.c, b: reg.b };
}
setLocalStorageItemWithRecovery(
const ok4 = setLocalStorageItemWithRecovery(
localOverrideRegistersKey(pubkey),
JSON.stringify(regState),
);
return ok1 && ok2 && ok3 && ok4;
}

View File

@@ -448,17 +448,17 @@ export class RelayClient {
subscribeToReconnects(listener: () => void) {
this.reconnectListeners.add(listener);
return () => {
this.reconnectListeners.delete(listener);
};
}
getConnectionGeneration(): number {
return this.connectionGeneration;
}
/** Current connection state — synchronous read. */
getConnectionState(): ConnectionState {
return this.connectionStateEmitter.get();
}
/**
* Subscribe to connection-state transitions. The listener is invoked
* immediately with the current state so callers don't need a separate