Address review on the persisted unread dots, plus a latent data-loss bug in the shared persistence helper that the restart e2e exposed. Review findings: - Session ids are caller-supplied and each profile backend is its own namespace, while the desktop's lists routinely mix profiles (cron and messaging slices are always cross-profile; recents are too in all-profiles mode). Both persisted records are now bucketed per profile - nested records keyed by the ROW's own profile (normalizeProfileKey, absent -> default), never the live gateway's, except the live busy->idle edge with no loaded row, which can only come from the active gateway. Same-id sessions in different profiles no longer share watermarks or markers. - Markers are now bounded (200 per profile, oldest evicted) and cleaned up when a session leaves the user's world: forgetSessionUnread() is wired into removeSession, archiveSession, and the settings permanent-delete path (which bypasses the other two). Cold-boot clobber (found by the restart e2e after the refactor): - persistentAtom wrote its value back to storage immediately at creation. On a cold boot the bundle can evaluate against a storage snapshot that has not caught up yet, so that echo overwrote real records with the fallback. Creation is now read-only; only actual changes persist. Regression-tested in persisted.test.ts. - The read side of the same race is handled in session-unread.ts: the first list arrival re-reads both records from storage (readable by then) and merges them under the in-memory state, so a boot that seeded empty atoms adopts the disk state instead of re-seeding every row and burying the unread gap. Unread listeners are also disabled in secondary windows - their partial list view must not write the primary's whole-record state (same isolation rule as session tiles). Tests: cross-profile same-id regression, live-edge profile fallback, forgetSessionUnread cleanup, marker cap, persistentAtom creation read-only; the restart e2e passes again end to end.
98 lines
3.3 KiB
TypeScript
98 lines
3.3 KiB
TypeScript
import { atom, type WritableAtom } from 'nanostores'
|
|
|
|
import { readKey, writeKey } from './storage'
|
|
|
|
// A nanostore that auto-persists. Reads its seed from localStorage through the
|
|
// storage choke point (so every read/write is observable in one place) and
|
|
// writes back on every change — no per-atom subscribe boilerplate.
|
|
//
|
|
// export const $foo = persistentAtom('hermes.desktop.foo', false, Codecs.bool)
|
|
|
|
// Maps a value to/from its stored string form. `decode` only ever sees a real
|
|
// stored string (absence falls back); `encode` returning null removes the key.
|
|
export interface Codec<T> {
|
|
decode(raw: string): T
|
|
encode(value: T): null | string
|
|
}
|
|
|
|
export const Codecs = {
|
|
bool: { decode: raw => raw === 'true', encode: (value: boolean) => String(value) } as Codec<boolean>,
|
|
nullableText: { decode: raw => raw, encode: value => value } as Codec<null | string>,
|
|
text: { decode: raw => raw, encode: (value: string) => value } as Codec<string>,
|
|
// Mirrors storedStringArray/persistStringArray: drops non-strings, empty → removed.
|
|
stringArray: {
|
|
decode: raw => {
|
|
const parsed = JSON.parse(raw) as unknown
|
|
|
|
return Array.isArray(parsed)
|
|
? parsed.filter((item): item is string => typeof item === 'string' && item.length > 0)
|
|
: []
|
|
},
|
|
encode: value => (value.length === 0 ? null : JSON.stringify(value))
|
|
} as Codec<string[]>,
|
|
// Mirrors storedStringRecord/persistStringRecord: keeps only string values.
|
|
stringRecord: {
|
|
decode: raw => {
|
|
const parsed = JSON.parse(raw) as unknown
|
|
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
return {}
|
|
}
|
|
|
|
return Object.fromEntries(
|
|
Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
|
|
)
|
|
},
|
|
encode: value => JSON.stringify(value)
|
|
} as Codec<Record<string, string>>,
|
|
/** JSON with an optional sanitizer for untrusted persisted shapes. */
|
|
json<T>(sanitize?: (value: unknown) => T): Codec<T> {
|
|
return {
|
|
decode: raw => {
|
|
const parsed = JSON.parse(raw) as unknown
|
|
|
|
return sanitize ? sanitize(parsed) : (parsed as T)
|
|
},
|
|
encode: value => JSON.stringify(value)
|
|
}
|
|
}
|
|
}
|
|
|
|
export function persistentAtom<T>(key: string, fallback: T, codec: Codec<T> = Codecs.json<T>()): WritableAtom<T> {
|
|
const raw = readKey(key)
|
|
let initial = fallback
|
|
|
|
if (raw !== null) {
|
|
try {
|
|
initial = codec.decode(raw)
|
|
} catch {
|
|
initial = fallback
|
|
}
|
|
}
|
|
|
|
const $value = atom<T>(initial)
|
|
|
|
// Persist CHANGES only — never the creation-time value. nanostores'
|
|
// subscribe fires immediately, and writing what was just read back is a
|
|
// no-op at best; at worst it is a data-loss clobber: on a cold boot the
|
|
// renderer bundle can run against a storage snapshot that has not caught
|
|
// up yet (an early hidden/boot load of the same bundle sees an empty
|
|
// area), and echoing the fallback back out overwrites the real record
|
|
// other loads are about to read. Observed with the unread-dot records:
|
|
// the early load wrote `{}` over a populated store between the disk read
|
|
// and the main window's module init.
|
|
let creationEmission = true
|
|
|
|
$value.subscribe(value => {
|
|
if (creationEmission) {
|
|
creationEmission = false
|
|
|
|
return
|
|
}
|
|
|
|
writeKey(key, codec.encode(value))
|
|
})
|
|
|
|
return $value
|
|
}
|