Files
buzz/desktop/src/features/channels/readState/readStateManager.ts
Wes 9203bf60ee perf(desktop): coalesce read state localStorage persistence (#5591)
Follow-on to #5453/#5454's localStorage work — found while investigating
app-slowness reports on a real profile.

## Problem

`ReadStateManager.persistLocalState()` serialized and rewrote **all
three** read-state localStorage blobs (`buzz.channel-read-state.v2`,
`.publishable.v1`, `.source-created-at.v1`) synchronously on every
context advance. On a real profile (1,643 contexts, ~450K chars across
the three blobs) this produced ~880KB of localStorage sqlite WAL growth
per 30 seconds at idle, with writes every ~5s — steady main-thread
serialization + sync IPC for no user-visible benefit. Observed WAL size
on the affected profile: 94–114MB.

## Fix

- Local persistence coalesced behind a **1s trailing-edge timer**: a
burst of N advances produces one `writeStoredReadState` (one write per
blob).
- Pending dirty state **flushes synchronously** on `pagehide`, hidden
`visibilitychange`, `destroy()`, and before each relay publish — disk is
current before any relay event goes out.
- Hydration still persists immediately. Publish debounce (5s), merge
logic, and blob formats unchanged (`DEBOUNCE_MS` renamed to
`PUBLISH_DEBOUNCE_MS` only).

## Accepted residual

A hard kill (SIGKILL/power loss — not webview teardown) inside the 1s
window loses ≤1s of local read-state advances; relay max-merge bounds
the effect to a message flickering back unread. On the record per
review.

## Validation

- `readStateManager.test.mjs`: fake-timer/mock-storage coverage —
exactly one 3-blob write per burst (zero before the timer fires),
hidden-flush cancels the timer and persists, hydrate persists
immediately, pre-publish flush. Suite 26/26.
- Push gate at the pushed commit: desktop check, typecheck, full desktop
unit suite 4,670/4,670.
- Independent adversarial FULL REVIEW: **APPROVE** at tree `371a02cf`
(commit metadata rewritten afterward for attribution; tree identical) —
all six `persistLocalState` call sites traced, lifecycle/leak checks
(StrictMode remount, pubkey change), no external readers of the blob
keys.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Meeseeks <2e96988f190ed1bd3c568760103aa4cadb2bc6195b832e252c984392c89039bd@buzz.block.builderlab.xyz>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-11 16:41:35 -07:00

999 lines
32 KiB
TypeScript

import { nip44EncryptToSelf, signRelayEvent } from "@/shared/api/tauri";
import type { RelayClient } from "@/shared/api/relayClientSession";
import type { RelayEvent } from "@/shared/api/types";
import { KIND_READ_STATE } from "@/shared/constants/kinds";
import {
READ_STATE_D_TAG_PREFIX,
READ_STATE_FETCH_LIMIT,
READ_STATE_HORIZON_SECONDS,
READ_STATE_MAX_PLAINTEXT_BYTES,
READ_STATE_MAX_SLOTS,
MSG_PREFIX,
THREAD_PREFIX,
localExtraSlotIdsKey,
type ReadStateBlob,
} from "@/features/channels/readState/readStateFormat";
import { parseReadStateEvent } from "@/features/channels/readState/readStateSnapshot";
import {
readStoredReadState,
writeStoredReadState,
} from "@/features/channels/readState/readStateStorage";
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
import { truncatePubkey } from "@/shared/lib/pubkey";
const CLIENT_ID_KEY_PREFIX = "buzz.nip-rs.client-id";
const SLOT_ID_KEY_PREFIX = "buzz.nip-rs.slot-id";
const PUBLISH_DEBOUNCE_MS = 5_000;
const LOCAL_PERSIST_MAX_WAIT_MS = 1_000;
function generateHex(bytes: number): string {
const arr = new Uint8Array(bytes);
crypto.getRandomValues(arr);
return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
}
function getOrCreatePersisted(key: string, generator: () => string): string {
let value = localStorage.getItem(key);
if (!value) {
value = generator();
setLocalStorageItemWithRecovery(key, value);
}
return value;
}
function clientIdKey(pubkey: string): string {
return `${CLIENT_ID_KEY_PREFIX}:${pubkey}`;
}
function slotIdKey(pubkey: string): string {
return `${SLOT_ID_KEY_PREFIX}:${pubkey}`;
}
function loadExtraSlotIds(pubkey: string): string[] {
try {
const raw = localStorage.getItem(localExtraSlotIdsKey(pubkey));
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(v): v is string => typeof v === "string" && v.length > 0,
);
} catch {
return [];
}
}
function saveExtraSlotIds(pubkey: string, ids: string[]): void {
setLocalStorageItemWithRecovery(
localExtraSlotIdsKey(pubkey),
JSON.stringify(ids),
);
}
export type ApplyRemoteContextResult = "unchanged" | "advanced";
export type ContextParentResolver = (contextId: string) => string | null;
/**
* NIP-RS Hierarchical Frontier Rule (NIP-RS.md:141-167):
* `effective(ctx) = max(merged[ctx], effective(parent(ctx)))`.
*
* The thread→channel relationship is NOT serialized into the blob
* (NIP-RS.md:136-139); it is derived from the event graph at evaluation time
* via `parentResolver`. When the resolver yields no parent (channels, or an
* unresolvable thread root), the frontier degrades to the context's own merged
* value alone (NIP-RS.md:165-167). Returns null when the context has never been
* read and no parent term covers it.
*/
export function resolveEffectiveTimestamp(args: {
effectiveState: Map<string, number>;
contextId: string;
parentResolver: ContextParentResolver | null;
}): number | null {
const { effectiveState, contextId, parentResolver } = args;
const own = effectiveState.get(contextId) ?? null;
const parentId = parentResolver?.(contextId) ?? null;
if (parentId === null) return own;
const parent = effectiveState.get(parentId) ?? null;
if (parent === null) return own;
if (own === null) return parent;
return Math.max(own, parent);
}
function resolveRemoteContextTimestamp(args: {
current: number;
timestamp: number;
}): { next: number; result: ApplyRemoteContextResult } {
const next = Math.max(args.current, args.timestamp);
return {
next,
result: next === args.current ? "unchanged" : "advanced",
};
}
export function applyRemoteContextTimestamp(args: {
effectiveState: Map<string, number>;
contextSourceCreatedAt: Map<string, number>;
contextId: string;
timestamp: number;
eventCreatedAt: number;
}): ApplyRemoteContextResult {
const {
effectiveState,
contextSourceCreatedAt,
contextId,
timestamp,
eventCreatedAt,
} = args;
const sourceCreatedAt = contextSourceCreatedAt.get(contextId) ?? 0;
const current = effectiveState.get(contextId) ?? 0;
const { next, result } = resolveRemoteContextTimestamp({
current,
timestamp,
});
if (result === "advanced") {
effectiveState.set(contextId, next);
}
if (eventCreatedAt > sourceCreatedAt) {
contextSourceCreatedAt.set(contextId, eventCreatedAt);
}
return result;
}
/**
* Result of a `splitContextsIntoBudgetedSlots` call.
*/
export interface SlotSplitResult {
/** Contexts record for each slot (primary slot first). */
slots: Array<Record<string, number>>;
/**
* Extra slot IDs allocated beyond the first. Length is `slots.length - 1`.
* The caller is responsible for persisting these.
*/
extraSlotIds: string[];
}
/**
* Partition `channelEntries` across slots so each slot's blob fits within
* `maxBytes`. Thread/msg entries are added to the primary slot (index 0) and
* trimmed to budget.
*
* `initialSlotCount` is the number of slots already available (≥ 1). If the
* initial distribution doesn't fit, new slot IDs are generated via
* `slotIdGenerator` until everything fits or `maxSlots` is reached.
*
* Returns `{ slots, extraSlotIds }` on success, or `null` when even `maxSlots`
* slots can't accommodate all channel keys.
*
* Exported for unit testing; callers should prefer `splitContextsIntoSlots()`.
*/
export function splitContextsIntoBudgetedSlots(args: {
channelEntries: [string, number][];
threadMsgEntries: [string, number][];
clientId: string;
initialSlotCount: number;
maxSlots: number;
maxBytes: number;
slotIdGenerator: () => string;
}): SlotSplitResult | null {
const {
channelEntries,
threadMsgEntries,
clientId,
initialSlotCount,
maxSlots,
maxBytes,
slotIdGenerator,
} = args;
const encoder = new TextEncoder();
const blobFor = (c: Record<string, number>) =>
JSON.stringify({ v: 1, client_id: clientId, contexts: c });
let slotCount = initialSlotCount;
const extraSlotIds: string[] = [];
// Distribute channel keys and check fit. Grow slot count until all fit.
const distribute = (count: number): Array<Record<string, number>> => {
const slotContexts: Array<Record<string, number>> = Array.from(
{ length: count },
() => ({}),
);
for (let i = 0; i < channelEntries.length; i++) {
const [key, ts] = channelEntries[i];
slotContexts[i % count][key] = ts;
}
return slotContexts;
};
let slotContexts = distribute(slotCount);
while (
slotContexts.some((c) => encoder.encode(blobFor(c)).length > maxBytes) &&
slotCount < maxSlots
) {
extraSlotIds.push(slotIdGenerator());
slotCount++;
slotContexts = distribute(slotCount);
}
if (slotContexts.some((c) => encoder.encode(blobFor(c)).length > maxBytes)) {
return null;
}
// Add thread/msg entries to the primary slot and trim to budget.
for (const [key, ts] of threadMsgEntries) {
slotContexts[0][key] = ts;
}
trimContextsToBudget(slotContexts[0], clientId, maxBytes);
return { slots: slotContexts, extraSlotIds };
}
/**
* Result of a `trimContextsToBudget` call.
*/
export interface TrimResult {
/** Number of entries removed from `contexts`. */
evicted: number;
/** True when the serialized blob fits within `maxBytes` after trimming. */
fitsAfterTrim: boolean;
}
/**
* Trim a contexts map to fit within `maxBytes` when serialized as the JSON
* blob `{v:1, client_id, contexts}`. Evicts oldest `msg:` entries first
* (lowest timestamp), then oldest `thread:` entries. Channel keys are never
* evicted. Mutates `contexts` in place.
*
* Returns `{ evicted, fitsAfterTrim }`. `fitsAfterTrim` is false when the
* remaining blob (channel keys only) still exceeds `maxBytes` — the caller
* must not publish in that case.
*
* Exported for unit testing; callers should prefer `currentContexts()`.
*/
export function trimContextsToBudget(
contexts: Record<string, number>,
clientId: string,
maxBytes: number,
): TrimResult {
const encoder = new TextEncoder();
const blobFor = (c: Record<string, number>) =>
JSON.stringify({ v: 1, client_id: clientId, contexts: c });
let currentBytes = encoder.encode(blobFor(contexts)).length;
if (currentBytes <= maxBytes) {
return { evicted: 0, fitsAfterTrim: true };
}
const msgEntries: [string, number][] = [];
const threadEntries: [string, number][] = [];
for (const [key, ts] of Object.entries(contexts)) {
if (key.startsWith(MSG_PREFIX)) {
msgEntries.push([key, ts]);
} else if (key.startsWith(THREAD_PREFIX)) {
threadEntries.push([key, ts]);
}
}
// Oldest-first within each tier.
msgEntries.sort((a, b) => a[1] - b[1]);
threadEntries.sort((a, b) => a[1] - b[1]);
// O(n) pass: subtract each entry's byte contribution from currentBytes and
// collect entries to evict. The per-entry estimate is `,"key":timestamp`
// (key.length + 3 bytes for `"`, `"`, `:` plus 1 comma) + timestamp digits.
// This is an approximation — the final encode below is the authoritative check.
const toEvict: string[] = [];
for (const [key, ts] of [...msgEntries, ...threadEntries]) {
if (currentBytes <= maxBytes) break;
// Contribution: `,"key":timestamp` — comma + quoted key + colon + value
currentBytes -= key.length + 3 + String(ts).length + 1;
toEvict.push(key);
}
for (const key of toEvict) {
delete contexts[key];
}
// Final authoritative check — handles JSON comma-accounting edge cases
// (e.g. last-entry comma disappears) that the per-entry estimate ignores.
const fitsAfterTrim = encoder.encode(blobFor(contexts)).length <= maxBytes;
return { evicted: toEvict.length, fitsAfterTrim };
}
export class ReadStateManager {
private pubkey: string;
private relayClient: RelayClient;
private clientId: string;
private slotId: string;
private extraSlotIds: string[];
private effectiveState = new Map<string, number>();
private publishableContextIds = new Set<string>();
private lastPublishedContexts: Record<string, number> = {};
private debounceTimer: number | null = null;
private localPersistTimer: number | null = null;
private listeners = new Set<() => void>();
private unsubscribeLive: (() => void) | null = null;
private initialized = false;
private maxFetchedCreatedAt = 0;
private contextSourceCreatedAt = new Map<string, number>();
private pendingSyncedAdvances = new Set<string>();
private destroyed = false;
private parentResolver: ContextParentResolver | null = null;
constructor(pubkey: string, relayClient: RelayClient) {
this.pubkey = pubkey;
this.relayClient = relayClient;
this.clientId = getOrCreatePersisted(clientIdKey(pubkey), () =>
crypto.randomUUID(),
);
this.slotId = getOrCreatePersisted(slotIdKey(pubkey), () =>
generateHex(16),
);
this.extraSlotIds = loadExtraSlotIds(pubkey);
window.addEventListener("pagehide", this.flushLocalState);
document.addEventListener("visibilitychange", this.handleVisibilityChange);
}
async initialize(): Promise<void> {
if (this.initialized || this.destroyed) return;
console.debug(
`[ReadStateManager] initialize pubkey=${truncatePubkey(this.pubkey)} clientId=${this.clientId.substring(0, 8)}… slotId=${this.slotId}`,
);
this.hydrateFromLocalStorage();
await this.fetchAndMerge();
if (this.destroyed) return;
await this.startLiveSubscription();
if (this.destroyed) return;
const initContexts = this.currentContexts();
if (initContexts === null) {
// Channel keys exceed single-slot budget — schedule a multi-slot publish.
this.schedulePublish();
} else if (!this.isIdenticalToLastPublished(initContexts)) {
this.schedulePublish();
}
this.initialized = true;
console.debug(
`[ReadStateManager] initialize complete maxFetchedCreatedAt=${this.maxFetchedCreatedAt} contexts=${this.effectiveState.size}`,
);
this.notifyListeners();
}
markContextRead(contextId: string, unixTimestamp: number): void {
this.advanceContext(contextId, unixTimestamp, { publishable: true });
this.contextSourceCreatedAt.set(
contextId,
Math.max(Math.floor(Date.now() / 1_000), this.maxFetchedCreatedAt + 1),
);
}
seedContextRead(contextId: string, unixTimestamp: number): void {
this.advanceContext(contextId, unixTimestamp, { publishable: false });
}
private advanceContext(
contextId: string,
unixTimestamp: number,
options: { publishable: boolean },
): void {
if (this.destroyed) return;
const current = this.effectiveState.get(contextId) ?? 0;
if (unixTimestamp <= current) {
if (!options.publishable || this.publishableContextIds.has(contextId)) {
return;
}
this.publishableContextIds.add(contextId);
this.persistLocalState();
this.schedulePublish();
return;
}
this.effectiveState.set(contextId, unixTimestamp);
if (options.publishable) {
this.publishableContextIds.add(contextId);
}
this.persistLocalState();
this.notifyListeners();
if (options.publishable) {
this.schedulePublish();
}
}
getEffectiveTimestamp(contextId: string): number | null {
return resolveEffectiveTimestamp({
effectiveState: this.effectiveState,
contextId,
parentResolver: this.parentResolver,
});
}
/**
* The context's OWN merged read marker, WITHOUT the hierarchical parent term.
* Callers that evaluate a `thread:<root>` context outside the active channel
* (e.g. the sidebar unread scan over background channels) must use this:
* getEffectiveTimestamp folds in parentResolver, which is installed by the
* active ChannelScreen and maps every thread to the *active* channel — using
* it for a background channel's thread would borrow the wrong channel marker.
*/
getOwnTimestamp(contextId: string): number | null {
return this.effectiveState.get(contextId) ?? null;
}
/**
* Inject the thread→channel parent resolver derived from the React event
* graph (NIP-RS.md:136-139). The hierarchical max in getEffectiveTimestamp
* is a no-op until this is set.
*/
setContextParentResolver(resolver: ContextParentResolver | null): void {
this.parentResolver = resolver;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
destroy(): void {
this.destroyed = true;
window.removeEventListener("pagehide", this.flushLocalState);
document.removeEventListener(
"visibilitychange",
this.handleVisibilityChange,
);
this.flushLocalState();
// Flush any pending relay publish immediately
if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
this.debounceTimer = null;
void this.publish();
}
if (this.unsubscribeLive) {
void this.unsubscribeLive();
this.unsubscribeLive = null;
}
this.listeners.clear();
}
private async fetchAndMerge(): Promise<void> {
let events: RelayEvent[];
try {
events = await this.relayClient.fetchEvents({
kinds: [KIND_READ_STATE],
authors: [this.pubkey],
"#t": ["read-state"],
since: Math.floor(Date.now() / 1_000) - READ_STATE_HORIZON_SECONDS,
limit: READ_STATE_FETCH_LIMIT,
});
} catch (error) {
console.debug("[ReadStateManager] fetchAndMerge failed:", error);
// If fetch fails, proceed with local state only
return;
}
await this.mergeEvents(events);
this.persistLocalState();
this.notifyListeners();
}
private async mergeEvents(events: RelayEvent[]): Promise<void> {
if (this.destroyed) return;
// Collect all own blobs (keyed by slot d-tag) to union them all.
// NIP-RS: multiple own-slot blobs must be max-merged, not winner-takes-all.
const ownBlobsBySlot = new Map<
string,
{ blob: ReadStateBlob; createdAt: number }
>();
for (const event of events) {
const parsed = await parseReadStateEvent(event, this.pubkey);
if (this.destroyed) return;
if (!parsed) continue;
this.maxFetchedCreatedAt = Math.max(
this.maxFetchedCreatedAt,
parsed.createdAt,
);
for (const [ctx, ts] of Object.entries(parsed.blob.contexts)) {
const result = applyRemoteContextTimestamp({
effectiveState: this.effectiveState,
contextSourceCreatedAt: this.contextSourceCreatedAt,
contextId: ctx,
timestamp: ts,
eventCreatedAt: parsed.createdAt,
});
if (result !== "unchanged") {
this.pendingSyncedAdvances.add(ctx);
this.publishableContextIds.add(ctx);
}
}
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,
});
}
}
}
// Conflict detection: check if another client_id is squatting on our
// d-tag coordinate. If so, rotate our slotId to avoid clobbering.
for (const event of events) {
const parsed = await parseReadStateEvent(event, this.pubkey);
if (this.destroyed) return;
if (!parsed || parsed.dTag !== `read-state:${this.slotId}`) continue;
if (parsed.blob.client_id !== this.clientId) {
this.slotId = generateHex(16);
setLocalStorageItemWithRecovery(slotIdKey(this.pubkey), this.slotId);
break;
}
}
// Union all own-slot blobs into lastPublishedContexts (max-merge).
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 existing = unionContexts[key];
if (existing === undefined || ts > existing) {
unionContexts[key] = ts;
}
}
for (const contextId of Object.keys(blob.contexts)) {
this.publishableContextIds.add(contextId);
}
}
this.lastPublishedContexts = unionContexts;
}
}
private async startLiveSubscription(): Promise<void> {
try {
const unsub = await this.relayClient.subscribeLive(
{
kinds: [KIND_READ_STATE],
authors: [this.pubkey],
"#t": ["read-state"],
limit: READ_STATE_FETCH_LIMIT,
},
(event: RelayEvent) => {
void this.handleIncomingEvent(event);
},
);
if (this.destroyed) {
unsub();
return;
}
this.unsubscribeLive = unsub;
console.debug("[ReadStateManager] live subscription established");
} catch (error) {
console.debug("[ReadStateManager] live subscription FAILED:", error);
// Non-fatal: we can still work with local state
}
}
private async handleIncomingEvent(event: RelayEvent): Promise<void> {
if (this.destroyed || event.pubkey !== this.pubkey) return;
console.debug(
`[ReadStateManager] incoming event=${event.id.substring(0, 8)}… created_at=${event.created_at}`,
);
const parsed = await parseReadStateEvent(event, this.pubkey);
if (!parsed || this.destroyed) return;
this.maxFetchedCreatedAt = Math.max(
this.maxFetchedCreatedAt,
parsed.createdAt,
);
const { blob } = parsed;
let anyAdvanced = false;
for (const [ctx, ts] of Object.entries(blob.contexts)) {
const result = applyRemoteContextTimestamp({
effectiveState: this.effectiveState,
contextSourceCreatedAt: this.contextSourceCreatedAt,
contextId: ctx,
timestamp: ts,
eventCreatedAt: parsed.createdAt,
});
if (result === "advanced") {
this.pendingSyncedAdvances.add(ctx);
anyAdvanced = true;
}
if (!this.publishableContextIds.has(ctx)) {
this.publishableContextIds.add(ctx);
anyAdvanced = true;
}
}
console.debug(
`[ReadStateManager] incoming result anyAdvanced=${anyAdvanced} clientId=${blob.client_id.substring(0, 8)}…`,
);
if (anyAdvanced) {
this.persistLocalState();
this.notifyListeners();
// If this was from another client instance, schedule a re-publish
// so our blob converges
if (blob.client_id !== this.clientId) {
this.schedulePublish();
}
}
}
private schedulePublish(): void {
if (this.destroyed) return;
if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
}
this.debounceTimer = window.setTimeout(() => {
this.debounceTimer = null;
void this.publish();
}, PUBLISH_DEBOUNCE_MS);
}
private async publish(): Promise<void> {
console.debug(`[ReadStateManager] publish starting slotId=${this.slotId}`);
await this.fetchOwnBlobBeforePublish();
if (this.destroyed) return;
this.flushLocalState();
// Build blob from contexts this client is allowed to publish.
const contexts = this.currentContexts();
if (contexts === null) {
// Channel keys alone exceed the single-slot budget — split across slots.
await this.publishSplitSlots();
return;
}
// Transitioning from split to single mode: delete stale extra-slot blobs
// from the relay so fetchOwnBlobBeforePublish stops re-inflating
// lastPublishedContexts from them. Reset lastPublishedContexts here (inside
// the guard) so stale keys from the previous split don't cause
// isIdenticalToLastPublished to return false forever. The reset must stay
// inside the guard — resetting unconditionally would clear the relay-fetched
// state on every debounce cycle and reintroduce the retry storm.
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 and
* maxFetchedCreatedAt on success.
*/
private async publishOneSlot(
slotId: string,
contexts: Record<string, number>,
): Promise<void> {
const blob: ReadStateBlob = {
v: 1,
client_id: this.clientId,
contexts,
};
try {
const plaintext = JSON.stringify(blob);
const ciphertext = await nip44EncryptToSelf(plaintext);
const dTagValue = `read-state:${slotId}`;
const tags: string[][] = [
["d", dTagValue],
["t", "read-state"],
];
const createdAt = Math.max(
Math.floor(Date.now() / 1_000),
this.maxFetchedCreatedAt + 1,
);
const event = await signRelayEvent({
kind: KIND_READ_STATE,
content: ciphertext,
createdAt,
tags,
});
await this.relayClient.publishEvent(
event,
"Timed out publishing read state.",
"Failed to publish read state.",
);
console.debug(
`[ReadStateManager] publish accepted slotId=${slotId} createdAt=${createdAt}`,
);
for (const key of Object.keys(contexts)) {
if (this.lastPublishedContexts[key] !== contexts[key]) {
this.contextSourceCreatedAt.set(key, createdAt);
}
}
// Merge this slot's contexts into lastPublishedContexts (union).
for (const [key, ts] of Object.entries(contexts)) {
this.lastPublishedContexts[key] = ts;
}
this.maxFetchedCreatedAt = Math.max(
this.maxFetchedCreatedAt,
event.created_at,
);
} catch (error) {
// Non-fatal: will retry on next debounce
console.warn("[ReadStateManager] publish failed:", error);
}
}
/**
* Multi-slot publish path. Invoked when channel keys alone exceed the
* single-slot byte budget. Partitions channel keys across slots and
* publishes each independently.
*/
private async publishSplitSlots(): Promise<void> {
const slots = this.splitContextsIntoSlots();
if (slots === null) return; // Truly degenerate — already logged.
// No-op suppression: compute the union of all slot contexts and skip if
// nothing changed since the last publish. Without this, every debounce
// cycle in split mode would re-publish all slots unconditionally.
const unionContexts: Record<string, number> = {};
for (const { contexts } of slots) {
for (const [key, ts] of Object.entries(contexts)) {
const existing = unionContexts[key];
if (existing === undefined || ts > existing) unionContexts[key] = ts;
}
}
if (this.isIdenticalToLastPublished(unionContexts)) return;
// Reset lastPublishedContexts before the multi-slot publish so we can
// rebuild it as the union of all slots.
this.lastPublishedContexts = {};
for (const { slotId, contexts } of slots) {
await this.publishOneSlot(slotId, contexts);
}
}
/**
* Publish NIP-09 kind:5 delete events for all extra slot blobs, then clear
* extraSlotIds. Called when transitioning from split mode back to single-slot
* mode to prevent stale extra-slot blobs from re-inflating lastPublishedContexts
* via fetchOwnBlobBeforePublish on every subsequent publish cycle.
*/
private async deleteExtraSlots(): Promise<void> {
for (const slotId of this.extraSlotIds) {
try {
const aTagValue = `${KIND_READ_STATE}:${this.pubkey}:${READ_STATE_D_TAG_PREFIX}${slotId}`;
const event = await signRelayEvent({
kind: 5,
content: "",
tags: [["a", aTagValue]],
});
await this.relayClient.publishEvent(
event,
"Timed out deleting extra read-state slot.",
"Failed to delete extra read-state slot.",
);
console.debug(`[ReadStateManager] deleted extra slot slotId=${slotId}`);
} catch (error) {
console.debug(
`[ReadStateManager] deleteExtraSlots failed for slotId=${slotId}:`,
error,
);
// Non-fatal: stale blob will expire from relay within the horizon window.
}
}
this.extraSlotIds = [];
saveExtraSlotIds(this.pubkey, []);
}
private async fetchOwnBlobBeforePublish(): Promise<void> {
// Fetch all own slots — primary + any extra slots allocated for splitting.
const allSlotIds = [this.slotId, ...this.extraSlotIds];
const dTags = allSlotIds.map((id) => `${READ_STATE_D_TAG_PREFIX}${id}`);
try {
const events = await this.relayClient.fetchEvents({
kinds: [KIND_READ_STATE],
authors: [this.pubkey],
"#d": dTags,
limit: READ_STATE_FETCH_LIMIT,
});
await this.mergeEvents(events);
this.persistLocalState();
} catch (error) {
console.debug(
"[ReadStateManager] fetchOwnBlobBeforePublish failed:",
error,
);
// Per NIP-RS, proceed with reachable data and merge on a later fetch.
}
}
private isIdenticalToLastPublished(
contexts: Record<string, number>,
): boolean {
const lastKeys = Object.keys(this.lastPublishedContexts);
const currentKeys = Object.keys(contexts);
if (lastKeys.length !== currentKeys.length) return false;
for (const key of currentKeys) {
if (this.lastPublishedContexts[key] !== contexts[key]) return false;
}
return true;
}
private currentContexts(): Record<string, number> | null {
const contexts: Record<string, number> = {};
for (const [ctx, ts] of this.effectiveState) {
if (!this.publishableContextIds.has(ctx)) {
continue;
}
contexts[ctx] = ts;
}
// Byte-budget trim (reactive backstop).
// Evict oldest msg: then thread: entries until the blob fits 32 KB.
// Channel keys are never evicted here.
const { evicted, fitsAfterTrim } = trimContextsToBudget(
contexts,
this.clientId,
READ_STATE_MAX_PLAINTEXT_BYTES,
);
if (evicted > 0) {
console.warn(
`[ReadStateManager] currentContexts trimmed ${evicted} entries to fit byte budget`,
);
}
if (!fitsAfterTrim) {
// Channel keys alone exceed budget — caller must use multi-slot split.
console.warn(
"[ReadStateManager] currentContexts: channel keys exceed byte budget — will split across slots",
);
return null;
}
return contexts;
}
/**
* Partition the full publishable contexts across multiple slots when channel
* keys alone exceed READ_STATE_MAX_PLAINTEXT_BYTES. Returns one contexts
* record per slot (primary slot first, extra slots following). Returns null
* when even READ_STATE_MAX_SLOTS slots can't accommodate all channel keys.
*
* Channel keys are distributed round-robin across all slots. Thread: and
* msg: entries are added to the primary slot and trimmed by the
* byte-budget guard there.
*/
private splitContextsIntoSlots(): Array<{
slotId: string;
contexts: Record<string, number>;
}> | null {
// Separate channel keys from thread/msg entries.
const channelEntries: [string, number][] = [];
const threadMsgEntries: [string, number][] = [];
for (const [ctx, ts] of this.effectiveState) {
if (!this.publishableContextIds.has(ctx)) continue;
if (ctx.startsWith(MSG_PREFIX) || ctx.startsWith(THREAD_PREFIX)) {
threadMsgEntries.push([ctx, ts]);
} else {
channelEntries.push([ctx, ts]);
}
}
const allSlotIds = [this.slotId, ...this.extraSlotIds];
const result = splitContextsIntoBudgetedSlots({
channelEntries,
threadMsgEntries,
clientId: this.clientId,
initialSlotCount: allSlotIds.length,
maxSlots: READ_STATE_MAX_SLOTS,
maxBytes: READ_STATE_MAX_PLAINTEXT_BYTES,
slotIdGenerator: () => generateHex(16),
});
if (result === null) {
console.error(
`[ReadStateManager] splitContextsIntoSlots: ${channelEntries.length} channel keys exceed ${READ_STATE_MAX_SLOTS}-slot budget — suppressing publish`,
);
return null;
}
// Persist any newly allocated extra slot IDs. Length comparison is
// sufficient: splitContextsIntoBudgetedSlots only appends new IDs (via
// slotIdGenerator) and never replaces existing ones — initialSlotCount
// ensures the existing slots are reused in place.
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,
contexts: result.slots[i],
}));
}
private hydrateFromLocalStorage(): void {
const stored = readStoredReadState(this.pubkey);
for (const [contextId, timestamp] of stored.contexts) {
this.effectiveState.set(contextId, timestamp);
}
for (const contextId of stored.publishableContextIds) {
this.publishableContextIds.add(contextId);
}
for (const [contextId, createdAt] of stored.contextSourceCreatedAt) {
this.contextSourceCreatedAt.set(contextId, createdAt);
}
this.writeLocalState();
}
private persistLocalState(): void {
if (this.destroyed || this.localPersistTimer !== null) return;
this.localPersistTimer = window.setTimeout(() => {
this.localPersistTimer = null;
this.writeLocalState();
}, LOCAL_PERSIST_MAX_WAIT_MS);
}
private readonly flushLocalState = (): void => {
if (this.localPersistTimer === null) return;
window.clearTimeout(this.localPersistTimer);
this.localPersistTimer = null;
this.writeLocalState();
};
private readonly handleVisibilityChange = (): void => {
if (document.visibilityState === "hidden") {
this.flushLocalState();
}
};
private writeLocalState(): void {
writeStoredReadState(
this.pubkey,
this.effectiveState,
this.publishableContextIds,
this.contextSourceCreatedAt,
);
}
drainSyncedAdvances(): ReadonlySet<string> {
const drained = this.pendingSyncedAdvances;
this.pendingSyncedAdvances = new Set<string>();
return drained;
}
private notifyListeners(): void {
for (const listener of this.listeners) {
try {
listener();
} catch (error) {
console.debug("[ReadStateManager] listener threw:", error);
// Don't let a broken listener break the manager
}
}
}
}