diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 24727be06..6faa6a357 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -95,6 +95,13 @@ const overrides = new Map([ // A small overage from load-bearing security plumbing on a file already at // 893 lines, not generic debt growth. Approved override; still queued to split. ["src-tauri/src/app_state.rs", 1012], + // multi-slot splitting + no-op suppression (#1309): the ReadStateManager + // class grew from ~700 lines to ~1019 with the addition of + // splitContextsIntoBudgetedSlots (pure fn + 5 tests), publishSplitSlots, + // publishOneSlot, deleteExtraSlots, and the no-op suppression integration + // test. Load-bearing feature growth, queued to split publishSplitSlots path + // into readStateManagerSplit.ts. + ["src/features/channels/readState/readStateManager.ts", 1030], ]); await runFileSizeCheck({ diff --git a/desktop/src/features/channels/readState/readStateFormat.ts b/desktop/src/features/channels/readState/readStateFormat.ts index a7016610e..e2f7beba0 100644 --- a/desktop/src/features/channels/readState/readStateFormat.ts +++ b/desktop/src/features/channels/readState/readStateFormat.ts @@ -16,6 +16,12 @@ export const MAX_CONTEXTS = 10_000; // expansion to ~45 KB ciphertext) while keeping the blob well under both caps. export const READ_STATE_MAX_PLAINTEXT_BYTES = 32_768; +// Maximum number of slots a client may publish. Each slot is a separate +// kind:30078 event. Splitting across slots is the fallback when channel keys +// alone exceed READ_STATE_MAX_PLAINTEXT_BYTES. 8 slots × ~650 channel keys per +// slot = ~5,200 channels — well beyond any realistic user. +export const READ_STATE_MAX_SLOTS = 8; + // Context-key prefix for a per-MESSAGE read marker (LP4 v3). One grow-only // marker per reply id; the badge predicate reads effective("msg:") live so // reading an ancestor never covers a descendant (Issue 2 by construction). @@ -104,6 +110,10 @@ export function isValidReadStateDTag( return slotId.length > 0 && slotId.length <= 64 && isAscii(slotId); } +export function localExtraSlotIdsKey(pubkey: string): string { + return `buzz.nip-rs.extra-slot-ids:${pubkey}`; +} + export function localIsoToUnixSeconds(value: unknown): number | null { if (typeof value !== "string" || value.length === 0) { return null; diff --git a/desktop/src/features/channels/readState/readStateManager.test.mjs b/desktop/src/features/channels/readState/readStateManager.test.mjs index bffaadef2..89d092fae 100644 --- a/desktop/src/features/channels/readState/readStateManager.test.mjs +++ b/desktop/src/features/channels/readState/readStateManager.test.mjs @@ -2,11 +2,51 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + ReadStateManager, applyRemoteContextTimestamp, resolveEffectiveTimestamp, + splitContextsIntoBudgetedSlots, trimContextsToBudget, } from "./readStateManager.ts"; +// ── ReadStateManager integration helpers ───────────────────────────────────── +// Provide browser globals required by ReadStateManager (localStorage, +// window.setTimeout/clearTimeout). Each test that uses ReadStateManager +// constructs a fresh in-memory store so tests are isolated. + +function makeLocalStorage() { + const store = new Map(); + return { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, value), + removeItem: (key) => store.delete(key), + }; +} + +// Install browser globals required by ReadStateManager. window.localStorage is +// replaced per-test for isolation; the bare `localStorage` global proxies to it. +{ + const ls = makeLocalStorage(); + if (typeof globalThis.window === "undefined") { + globalThis.window = { + localStorage: ls, + clearTimeout: (id) => clearTimeout(id), + setTimeout: (fn, ms) => setTimeout(fn, ms), + }; + } else { + globalThis.window.localStorage = ls; + if (!globalThis.window.clearTimeout) { + globalThis.window.clearTimeout = (id) => clearTimeout(id); + globalThis.window.setTimeout = (fn, ms) => setTimeout(fn, ms); + } + } + // Ensure bare `localStorage` always proxies to window.localStorage. + Object.defineProperty(globalThis, "localStorage", { + get: () => globalThis.window.localStorage, + configurable: true, + }); +} + const threadKey = `thread:${"a".repeat(64)}`; const channelKey = "channel-1"; const channelResolver = (ctx) => @@ -300,3 +340,240 @@ test("trimContextsToBudget_channelOnlyBlobExceedsBudget_fitsAfterTrimFalse", () // Channel key must still be present. assert.ok("channel:some-channel-id" in contexts); }); + +// ── splitContextsIntoBudgetedSlots ──────────────────────────────────────────── + +// Build a channel key that is ~70 bytes in the JSON blob: +// `"channel-<64-hex>":1` ≈ 70 bytes including quotes, colon, comma. +const makeChannelKey = (n) => `channel-${n.toString().padStart(64, "0")}`; +const makeThreadKey = (n) => `thread:${n.toString().padStart(64, "0")}`; +const makeMsgKey = (n) => `msg:${n.toString().padStart(64, "0")}`; + +// Compute the byte size of a single-slot blob with the given contexts. +const blobSize = (clientId, contexts) => { + const encoder = new TextEncoder(); + return encoder.encode(JSON.stringify({ v: 1, client_id: clientId, contexts })) + .length; +}; + +let slotCounter = 0; +const deterministicSlotId = () => + `slot-${(++slotCounter).toString().padStart(4, "0")}`; + +test("splitContextsIntoBudgetedSlots_fitsInOneSlot_returnsSingleSlot", () => { + // 3 channel keys — easily fits in one slot with a generous budget. + const channelEntries = [ + [makeChannelKey(1), 100], + [makeChannelKey(2), 200], + [makeChannelKey(3), 300], + ]; + const result = splitContextsIntoBudgetedSlots({ + channelEntries, + threadMsgEntries: [], + clientId: CLIENT_ID, + initialSlotCount: 1, + maxSlots: 8, + maxBytes: 1_000_000, + slotIdGenerator: deterministicSlotId, + }); + + assert.ok(result !== null, "should succeed"); + assert.equal(result.slots.length, 1, "single slot"); + assert.equal(result.extraSlotIds.length, 0, "no extra slots allocated"); + // All channel keys present in slot 0. + for (const [key] of channelEntries) { + assert.ok(key in result.slots[0], `${key} should be in slot 0`); + } +}); + +test("splitContextsIntoBudgetedSlots_requiresGrowth_allocatesExtraSlot", () => { + // Build enough channel keys that a single slot overflows a tight budget + // but two slots fit. + const channelEntries = []; + for (let i = 0; i < 20; i++) { + channelEntries.push([makeChannelKey(i), i + 1]); + } + const encoder = new TextEncoder(); + // Budget that fits ~10 channel keys but not 20. + const tenKeyContexts = Object.fromEntries(channelEntries.slice(0, 10)); + const tenKeySize = encoder.encode( + JSON.stringify({ v: 1, client_id: CLIENT_ID, contexts: tenKeyContexts }), + ).length; + const budget = tenKeySize + 50; // fits 10 but not 20 + + const result = splitContextsIntoBudgetedSlots({ + channelEntries, + threadMsgEntries: [], + clientId: CLIENT_ID, + initialSlotCount: 1, + maxSlots: 8, + maxBytes: budget, + slotIdGenerator: deterministicSlotId, + }); + + assert.ok(result !== null, "should succeed with 2 slots"); + assert.equal(result.slots.length, 2, "two slots"); + assert.equal(result.extraSlotIds.length, 1, "one extra slot allocated"); + // All 20 keys present across both slots. + const allKeys = new Set([ + ...Object.keys(result.slots[0]), + ...Object.keys(result.slots[1]), + ]); + for (const [key] of channelEntries) { + assert.ok(allKeys.has(key), `${key} should appear in some slot`); + } + // Each slot fits within budget. + for (const slotContexts of result.slots) { + const size = encoder.encode( + JSON.stringify({ v: 1, client_id: CLIENT_ID, contexts: slotContexts }), + ).length; + assert.ok(size <= budget, `slot size ${size} exceeds budget ${budget}`); + } +}); + +test("splitContextsIntoBudgetedSlots_exceedsMaxSlots_returnsNull", () => { + // Build enough channel keys that even maxSlots=2 can't fit them with a + // very tight budget (1 byte — nothing can fit). + const channelEntries = [[makeChannelKey(1), 1]]; + const result = splitContextsIntoBudgetedSlots({ + channelEntries, + threadMsgEntries: [], + clientId: CLIENT_ID, + initialSlotCount: 1, + maxSlots: 2, + maxBytes: 1, // impossibly small + slotIdGenerator: deterministicSlotId, + }); + + assert.equal(result, null, "should return null when max slots exceeded"); +}); + +test("splitContextsIntoBudgetedSlots_includesThreadMsgInPrimarySlot", () => { + // Channel key in slot 0; thread and msg entries should also land in slot 0. + const channelEntries = [[makeChannelKey(1), 100]]; + const threadMsgEntries = [ + [makeThreadKey(1), 200], + [makeMsgKey(1), 300], + ]; + + const result = splitContextsIntoBudgetedSlots({ + channelEntries, + threadMsgEntries, + clientId: CLIENT_ID, + initialSlotCount: 1, + maxSlots: 8, + maxBytes: 1_000_000, + slotIdGenerator: deterministicSlotId, + }); + + assert.ok(result !== null, "should succeed"); + assert.equal(result.slots.length, 1); + // Channel key in slot 0. + assert.ok(makeChannelKey(1) in result.slots[0], "channel key in slot 0"); + // Thread and msg entries in slot 0. + assert.ok(makeThreadKey(1) in result.slots[0], "thread key in slot 0"); + assert.ok(makeMsgKey(1) in result.slots[0], "msg key in slot 0"); +}); + +test("splitContextsIntoBudgetedSlots_threadMsgTrimmedWhenPrimarySlotOverBudget", () => { + // Channel key fills the primary slot to near-budget. Thread/msg entries + // added to slot 0 would overflow — trimContextsToBudget must evict them. + const channelEntries = [[makeChannelKey(1), 100]]; + // Compute the size of a blob with just the channel key. + const channelOnlyContexts = { [makeChannelKey(1)]: 100 }; + const channelOnlySize = blobSize(CLIENT_ID, channelOnlyContexts); + // Budget = channel-only size + 5 bytes: fits the channel key but not + // an additional thread/msg entry (~70+ bytes each). + const budget = channelOnlySize + 5; + + const threadMsgEntries = [[makeThreadKey(1), 200]]; + + const result = splitContextsIntoBudgetedSlots({ + channelEntries, + threadMsgEntries, + clientId: CLIENT_ID, + initialSlotCount: 1, + maxSlots: 8, + maxBytes: budget, + slotIdGenerator: deterministicSlotId, + }); + + assert.ok(result !== null, "should succeed"); + // Channel key must survive (never evicted by trimContextsToBudget). + assert.ok(makeChannelKey(1) in result.slots[0], "channel key survives"); + // Thread entry must be evicted (doesn't fit within budget). + assert.ok( + !(makeThreadKey(1) in result.slots[0]), + "thread key evicted to fit budget", + ); + // Slot 0 must fit within budget. + const size = blobSize(CLIENT_ID, result.slots[0]); + assert.ok(size <= budget, `slot 0 size ${size} exceeds budget ${budget}`); +}); + +// ── ReadStateManager.publish — no-op suppression in split mode ──────────────── + +// Verify that publishSplitSlots returns early (no relay writes) when the +// union of all slot contexts is identical to lastPublishedContexts. +// +// Strategy: construct a ReadStateManager with enough channel keys to force +// split mode, then mock publishOneSlot (private, accessed via bracket notation) +// to avoid tauri calls while still simulating its effect on lastPublishedContexts. +// Call publish() twice with the same effectiveState and assert that +// publishOneSlot is called only on the first publish (no-op on the second). +test("publishSplitSlots_noopSuppression_skipsWhenUnchanged", async () => { + // Isolate localStorage so slot IDs don't leak between tests. + globalThis.window.localStorage = makeLocalStorage(); + + const fakeRelay = { + fetchEvents: async () => [], + publishEvent: async () => {}, + subscribeLive: () => () => {}, + }; + + const pubkey = "b".repeat(64); + const mgr = new ReadStateManager(pubkey, fakeRelay); + + // Add enough channel keys to exceed the 32KB single-slot budget. + // Each key is ~70 bytes in the blob; 700 keys ≈ 49KB > 32KB. + const ts = 1_000_000; + for (let i = 0; i < 700; i++) { + const channelId = `channel-${i.toString().padStart(64, "0")}`; + mgr.markContextRead(channelId, ts); + } + + // Confirm split mode: currentContexts() must return null. + assert.equal( + mgr.currentContexts(), + null, + "precondition: 700 channel keys must exceed single-slot budget", + ); + + // Replace publishOneSlot with a stub that records calls and simulates the + // lastPublishedContexts merge (the only side-effect the no-op check depends + // on). This avoids tauri (nip44EncryptToSelf / signRelayEvent) while keeping + // the suppression logic under test. + let publishOneSlotCallCount = 0; + mgr.publishOneSlot = async (_slotId, contexts) => { + publishOneSlotCallCount++; + for (const [key, tsVal] of Object.entries(contexts)) { + mgr.lastPublishedContexts[key] = tsVal; + } + }; + + // First publish: contexts differ from lastPublishedContexts ({}) → must publish. + await mgr.publish(); + const callsAfterFirst = publishOneSlotCallCount; + assert.ok(callsAfterFirst > 0, "first publish must call publishOneSlot"); + + // Second publish with identical effectiveState: union equals lastPublishedContexts + // → no-op suppression must fire → publishOneSlot must NOT be called again. + await mgr.publish(); + assert.equal( + publishOneSlotCallCount, + callsAfterFirst, + "second publish with unchanged state must not call publishOneSlot (no-op suppression)", + ); + + mgr.destroy(); +}); diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts index e487e334f..604e18854 100644 --- a/desktop/src/features/channels/readState/readStateManager.ts +++ b/desktop/src/features/channels/readState/readStateManager.ts @@ -11,11 +11,13 @@ import { READ_STATE_FETCH_LIMIT, READ_STATE_HORIZON_SECONDS, READ_STATE_MAX_PLAINTEXT_BYTES, + READ_STATE_MAX_SLOTS, MSG_PREFIX, THREAD_PREFIX, isValidBlob, isValidReadStateDTag, sanitizeContexts, + localExtraSlotIdsKey, type ReadStateBlob, } from "@/features/channels/readState/readStateFormat"; import { @@ -50,6 +52,24 @@ 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 { + localStorage.setItem(localExtraSlotIdsKey(pubkey), JSON.stringify(ids)); +} + export type ApplyRemoteContextResult = "unchanged" | "advanced"; export type ContextParentResolver = (contextId: string) => string | null; @@ -123,6 +143,95 @@ export function applyRemoteContextTimestamp(args: { return result; } +/** + * Result of a `splitContextsIntoBudgetedSlots` call. + */ +export interface SlotSplitResult { + /** Contexts record for each slot (primary slot first). */ + slots: Array>; + /** + * 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) => + 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> => { + const slotContexts: Array> = 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. */ @@ -199,6 +308,7 @@ export class ReadStateManager { private relayClient: RelayClient; private clientId: string; private slotId: string; + private extraSlotIds: string[]; private effectiveState = new Map(); private publishableContextIds = new Set(); private lastPublishedContexts: Record = {}; @@ -221,6 +331,7 @@ export class ReadStateManager { this.slotId = getOrCreatePersisted(slotIdKey(pubkey), () => generateHex(16), ); + this.extraSlotIds = loadExtraSlotIds(pubkey); } async initialize(): Promise { @@ -236,10 +347,10 @@ export class ReadStateManager { await this.startLiveSubscription(); if (this.destroyed) return; const initContexts = this.currentContexts(); - if ( - initContexts !== null && - !this.isIdenticalToLastPublished(initContexts) - ) { + if (initContexts === null) { + // Channel keys exceed single-slot budget — schedule a multi-slot publish. + this.schedulePublish(); + } else if (!this.isIdenticalToLastPublished(initContexts)) { this.schedulePublish(); } @@ -365,8 +476,12 @@ export class ReadStateManager { } private async mergeEvents(events: RelayEvent[]): Promise { - let ownBlob: ReadStateBlob | null = null; - let ownBlobCreatedAt = 0; + // 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) { if (event.pubkey !== this.pubkey) continue; @@ -419,9 +534,10 @@ export class ReadStateManager { } if (blob.client_id === this.clientId) { - if (event.created_at > ownBlobCreatedAt) { - ownBlob = blob; - ownBlobCreatedAt = event.created_at; + const slotKey = dTag[1]; + const existing = ownBlobsBySlot.get(slotKey); + if (!existing || event.created_at > existing.createdAt) { + ownBlobsBySlot.set(slotKey, { blob, createdAt: event.created_at }); } } } @@ -451,11 +567,21 @@ export class ReadStateManager { } } - if (ownBlob) { - this.lastPublishedContexts = { ...ownBlob.contexts }; - for (const contextId of Object.keys(ownBlob.contexts)) { - this.publishableContextIds.add(contextId); + // Union all own-slot blobs into lastPublishedContexts (max-merge). + if (ownBlobsBySlot.size > 0) { + const unionContexts: Record = {}; + 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; } } @@ -575,9 +701,37 @@ export class ReadStateManager { // Build blob from contexts this client is allowed to publish. const contexts = this.currentContexts(); - // Suppress no-op publishes; also skip if the blob cannot fit within budget. - if (contexts === null || this.isIdenticalToLastPublished(contexts)) return; + 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, + ): Promise { const blob: ReadStateBlob = { v: 1, client_id: this.clientId, @@ -588,7 +742,7 @@ export class ReadStateManager { const plaintext = JSON.stringify(blob); const ciphertext = await nip44EncryptToSelf(plaintext); - const dTagValue = `read-state:${this.slotId}`; + const dTagValue = `read-state:${slotId}`; const tags: string[][] = [ ["d", dTagValue], ["t", "read-state"], @@ -611,7 +765,7 @@ export class ReadStateManager { "Failed to publish read state.", ); console.debug( - `[ReadStateManager] publish accepted createdAt=${createdAt}`, + `[ReadStateManager] publish accepted slotId=${slotId} createdAt=${createdAt}`, ); for (const key of Object.keys(contexts)) { @@ -619,7 +773,10 @@ export class ReadStateManager { this.contextSourceCreatedAt.set(key, createdAt); } } - this.lastPublishedContexts = contexts; + // 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, @@ -630,12 +787,78 @@ export class ReadStateManager { } } + /** + * 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 { + 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 = {}; + 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 { + 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 { + // 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": [`${READ_STATE_D_TAG_PREFIX}${this.slotId}`], + "#d": dTags, limit: READ_STATE_FETCH_LIMIT, }); @@ -671,10 +894,9 @@ export class ReadStateManager { contexts[ctx] = ts; } - // Enforce a serialized byte-size budget before encryption. Entry count is - // the wrong metric — the relay rejects on byte size, not entry count. Evict - // oldest msg: entries first (lowest timestamp), then thread: entries, until - // the JSON fits. Channel keys are never evicted. + // 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, @@ -686,8 +908,9 @@ export class ReadStateManager { ); } if (!fitsAfterTrim) { - console.error( - "[ReadStateManager] currentContexts: blob exceeds byte budget even after full eviction — skipping publish", + // 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; } @@ -695,6 +918,67 @@ export class ReadStateManager { 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; + }> | 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) {