API reference · layer 1

@latticekit/persist

Saves that survive: versioned state, an explicit migration chain, pluggable storage, debounced writes and integrity checks.

exports49 symbols in 7 modules — start with createStore, migrations, createRecorder
depends on@latticekit/core
environmentisomorphic (storage adapter is injected)
gzipped5.79 kB against a 12 kB budget
sourcepackages/persist · README · index.d.ts

@latticekit/persist — saves that survive a version bump, a crashed tab and a browser that lies about its storage.

It does that by making the save an explicitly versioned envelope, the upgrade an explicit chain of one-step migrations, and every failure a reported value instead of a thrown exception on boot.

Two consequences shape the whole surface:

  • The storage adapter is injected. localStorage is named in exactly one function in this package (browserStorage, in the one module marked @browser-only), and everything else — chain, envelope, checksum, coalescing, quarantine, replay — runs and tests in Node with no shims.
  • The read path never throws. Boot is the one moment a game cannot recover from an exception, because there is no UI yet to show it in. All seven ways a save can be unusable are fields on a returned object.
const chain = migrations(1, isV1)
  .step(2, 'one coin counter became a wallet of currencies', v1 => ({ version: 2, wallet: { coin: v1.coins } }), isV2)
  .seal();
const store = createStore({ key: 'campus', chain, adapter: browserStorage(), fresh: newGame, now });
const opened = store.open();                       // never throws. `opened.failure` says why if it degraded
const auto = store.autosave(() => game.state, { schedule: scheduleFrom(loop.real) });
installFlushTriggers(auto, { visibility: document, page: window });

scheduleFrom and not loop.real.after: loop schedules in seconds and this package in milliseconds, so passing the method directly is a compile error — and, cast away, an autosave every 67 minutes that nothing reports. The conversion lives in one place.

There is no version option, because the chain is the version; no validate option, because validation is per-version inside the chain; no timer, because schedule is injected; and no clock, because reading one would break non-negotiable #1 and defaulting one would silently zero every offline gap.

A note on the examples in this package

Anything in a doc comment that looks like a call is reachable from a test, and that is a rule rather than an aspiration. Two examples in this package were once wrong — one wired loop.real.after straight into schedule, which is a compile error on the return type and a 67-minute autosave interval once someone casts it away; the other composed a core guard that cannot accept an unknown. Both survived a review, a full suite and 100% coverage, because prose is not compiled and nothing was checking it.

The lesson is narrower and more useful than "keep docs current": a run-tested example and a hand-written one are indistinguishable to a reader, and are read with equal trust. A reader copies the snippet that is nearest to the symbol they are looking at, not the one that happens to be under test. So an example either compiles and runs somewhere, or it is marked as a sketch — and the cheapest way to keep that honest is to paste the doc's example into the test file verbatim and let tsc and vitest own it from then on.

What it promises

  • The chain IS the version. createStore reads the head off the migration chain, so declaring version 7 and shipping a chain that ends at 6 is inexpressible.
  • Every migration steps exactly one rung, and every rung carries a recognizer. A save can never be orphaned.
  • Writes flush on visibilitychange, not beforeunload — mobile Safari does not reliably deliver the latter. reset() closes handles BEFORE removing the key, or the autosave writes the live state back over the clear.
  • A corrupt save degrades to a fresh one with one of seven closed reasons, returned as a value. Never a thrown exception on boot.
  • A save from the future makes the store read-only. A stale deploy must not eat a good save.
  • A replay log is evidence, not progress: it is never migrated. A version, stepMs or profile mismatch is refused by name, because a migrated recording would produce a confident wrong answer.

Verbatim from .lattice/kit.json, which npm run lint keeps in step with the code.

integrity2 symbols

Integrity: one 32-bit digest, taken over the exact bytes that were written.

This module is four lines of code and a page of prose, and the ratio is correct. The code is core's hashString rendered as hex; the prose is the two things a reader has to know before they touch it — what the digest is for (damage, not adversaries) and why it must never normalize its input (because the bytes are the subject, not the text).

Checksum type ↳ src/integrity.ts:34

type Checksum = (text: string) => string

A checksum over the exact payload text.

A 32-bit digest detects corruption. It does not authenticate, and pretending otherwise is worse than having none at all. It catches a truncated write, a string clipped by a quota limit, a sync extension that half-wrote the key, and a payload hand-edited into invalid state — the class of damage that otherwise loads as a subtly wrong world three sessions later. It cannot stop a determined player: the algorithm is in the bundle they downloaded, there is no key, and recomputing it in a devtools console takes under a minute. If your game's economy needs a save the player cannot edit, your game needs a server, and this kit deliberately does not have one.

Collision maths, stated so nobody has to guess: 32 bits is a birthday collision at roughly 77,000 distinct inputs, and one specific damaged payload passes with probability 2^-32. For "did these bytes survive the round trip" that is ample; for anything adversarial it is meaningless, because an adversary does not need a collision, they need a calculator.

Substituting your own is supported and is the reason this is a type: pass checksum: text => sha256Hex(text) and the store uses it for both writing and reading. It must be a pure function of the string, or every save written by one build fails to verify under the next.

defaultChecksum const ↳ src/integrity.ts:76

const defaultChecksum: Checksum

hashString from @latticekit/core, rendered as eight lowercase hex digits.

Deliberately not a bespoke CRC or FNV implementation: core split hash into its own module precisely so persist, draw and iso would not each grow a private 32-bit hash. One implementation, one set of tests, one portability seam.

The payload is checksummed as read, unnormalised — and that is deliberate

hashString walks UTF-16 code units, so 'café' spelled NFC (café) and NFD (café) hash differently. That is correct here: they are different bytes, and the checksum's entire job is to notice that the bytes changed. Do not "fix" it by normalising before hashing — the digest would then cover a string that was never written, and a save genuinely truncated mid-combining-sequence would pass.

Where the same fact is a bug instead, and it is not in this file

The moment a player-authored string reaches a hash whose output is used as an identity — a save-file key, a slot id, a seed derived from a typed name — UTF-16 code units become a portability defect rather than a feature. macOS hands you NFD from the filesystem and from some IME paths; Windows and most browsers hand you NFC. The same visible name typed on two machines then hashes to two different numbers, so the player gets two different save keys, two different worlds, and a bug that reproduces on nobody's machine.

The rule, and it belongs at every such call site rather than in here:

// A key derived from something a player typed. Normalize first, always.
const key = `campus:save:${hashString(playerName.normalize('NFC')).toString(16)}`;
// A checksum over a payload. Never normalize — the bytes are the subject.
const c = defaultChecksum(payloadText);

The two rules look contradictory and are not: one hashes text a human means, the other hashes bytes a machine wrote. Ask which of the two you have before you reach for normalize.

adapters4 symbols

Where a save goes — the seam that makes this package testable in Node.

Nothing here knows what a browser is. webStorage wraps anything with the three methods localStorage has; memoryStorage is a Map. The one function that reaches for a real host lives in browser.ts and says so in its header.

StorageLike interface ↳ src/adapters.ts:17

interface StorageLike {

The shape of localStorage and sessionStorage, structurally, so neither is imported.

Three methods and no length, no key(i), no clear(). The omissions are the design: clear() is the API shape of the reset trap (a game that clears the origin wipes the player's volume with their campus), and a store that could enumerate keys would be a store that could read another store's key. Neither is reachable from here.

3 members
getItem(key: string): string | null
setItem(key: string, value: string): void
removeItem(key: string): void

StorageAdapter interface ↳ src/adapters.ts:37

interface StorageAdapter {

Where a save goes. Synchronous on purpose.

The write that matters happens as the page is being discarded, and a discarded page runs your synchronous work and drops the rest. An async adapter would admit IndexedDB and a server and would make the last four seconds of every session a coin flip.

An adapter may throw exactly where the platform throwsset on a full quota, get on storage that was revoked mid-session — and the store catches all of it. Nothing an adapter does escapes as an exception: a failed read becomes failure.reason: 'unreadable' on the open result and a failed write becomes WriteResult.error, because the alternative is an exception thrown inside a pagehide handler where there is nothing left to do about it. If you write your own adapter, throwing is safe; returning a lie is not.

4 members
readonly durable: boolean

Whether writes are expected to outlive the tab. false for the memory fallback.

Surfaced on OpenResult and as status: 'not-persistent' so a game can tell a private-mode player once, at the start, that progress will not be kept — which is useful — rather than saying nothing and letting them discover it after two hours, which is what silence buys you.

get(key: string): string | null
set(key: string, value: string): void
remove(key: string): void

webStorage function ↳ src/adapters.ts:65

function webStorage(storage: StorageLike): StorageAdapter

Wraps any StorageLike. This is the seam: pass localStorage, sessionStorage, a same-origin iframe's storage, or a fake with three methods in a test.

durable: true, because every real StorageLike outlives the tab and a caller who wraps something that does not has told the store a falsehood it cannot check. Wrap a session-scoped or in-memory backing store with memoryStorage instead, or the player never sees the "this will not be saved" notice they are owed.

Errors from the underlying object are passed through, deliberately. That is what lets the store tell a quota failure from a success and a revoked storage from an empty one; a wrapper that swallowed them would report every failed write as a write.

memoryStorage function ↳ src/adapters.ts:89

function memoryStorage(seed?: Readonly<Record<string, string>>): StorageAdapter

An in-process map. durable: false, and that is the important field.

The default adapter in every test in this kit, and the fallback browserStorage() returns when the platform has no usable storage. A store on one of these reports status: 'not-persistent' from the moment it is constructed, so a private-mode player can be told at the door rather than after two hours of play.

Parameters
seed

initial contents, copied. The map is private afterwards, so a test can hand in a fixture and then mutate its own object without reaching into the adapter.

migrate6 symbols

The migration chain: the only version number in the system.

A save format's version is not a constant a build declares and a chain then tries to keep up with — it is the head of the chain. createStore reads it off chain.head, so "declaring 7 while shipping a chain that ends at 6" is not a bug you can write down.

The chain is proven to have no holes three times over: at compile time, because step's to is typed Increment<Head>; at construction, because seal() re-walks the rungs for callers who arrived from JavaScript; and in a test, because only a test can catch a rung that exists and is wrong.

The trap this replaces, from the game this kit was extracted from: parsed.version === SAVE_VERSION, with a fallback to createGame() for everything else. That is not a migration policy. It is a delete, and its own source said so — *"a bump is not a migration — it is a deletion of every player's campus."*

Increment type ↳ src/migrate.ts:26

type Increment<N extends number, Counter extends readonly unknown[] = []> = Counter['length'] extends N ? [...Counter, unknown]['length'] & number : Increment<N, [...Counter, unknown]>

N + 1, at the type level, so a chain that skips a version does not compile.

Counting with a tuple caps out around 999, which is roughly 990 more save formats than any game has ever shipped. Past that the compiler reports an excessively deep instantiation and you have a different problem.

Recognize type ↳ src/migrate.ts:72

type Recognize<T> = (value: unknown) => T

How a version recognizes itself: returns the value typed, or throws.

Not (value: unknown) => value is T. A boolean predicate has already discarded the thing that was wrong by the time it returns, so it cannot produce the message house rule 9 demands — it can only ever say "no". This is the same shape @latticekit/core's guard module took for the same reason, and it composes directly with it:

import { expectObject, expectRecordOfFinite } from '@latticekit/core';

const isV2: Recognize<V2> = value => {
  const o = expectObject(value, 'save.v2');
  return { version: 2, wallet: expectRecordOfFinite(o['wallet'], 'save.v2.wallet') };
};

That example compiles, and test/migrate.test.ts runs it verbatim — see the note on examples in index.ts about why that matters.

Note the bracket access. expectObject returns Record<string, unknown>, and the repo builds with noPropertyAccessFromIndexSignature, so o.wallet does not compile and o['wallet'] does. The guards that take a number (expectFinite, expectSerializable) cannot be handed an unknown straight from JSON.parse without a cast; reach for the unknown-accepting ones (expectObject, expectRecordOfFinite) on the save path, and check a lone scalar with a typeof of your own until core widens the others.

Two things fall out of returning rather than asserting. The thrown message travels into ReadFailure.message, so a rejected save says which field was wrong instead of "the guard said no" — the difference between a fixable bug report and a shrug. And a recognizer may normalize as it validates, returning a repaired value, which is the cheapest possible migration for a field that only ever needed a default.

There is no optional variant, no default v => v as T, and no "skip validation in production" flag. It is also not an assertion function: build tools strip those, which would leave the check running only where it is least needed.

Make it as loose as you can defend. Checking the two or three fields your migration actually reads beats a field-by-field validator nobody maintains — but do check that your currencies are finite (expectSerializable), because Infinity serializes to null and comes back as NaN with a perfectly valid checksum.

MigrationStep interface ↳ src/migrate.ts:75

interface MigrationStep {

One rung, for reporting and for tests. why is prose a reviewer reads, not a label.

3 members
readonly from: number
readonly to: number
readonly why: string

MigrationChain interface ↳ src/migrate.ts:87

interface MigrationChain<Head extends number, T> {

A sealed chain from floor to head with no gaps.

head is carried in the type, which is how createStore knows the current version without being told it twice.

5 members
readonly floor: number

The oldest version still readable. A save older than this reads as orphaned — deliberate, announced data loss, which is why the floor is an argument and never inferred. Raising it should be a commit of its own with the number in the message.

readonly head: Head
readonly steps: readonly MigrationStep[]
recognize(value: unknown): T

The head recognizer. store.decode runs it last; a throw becomes invalid, carrying the thrown message.

run(value: unknown, from: number, onEnter?: (version: number) => void): T

Run value from version from up to head, one rung at a time, recognising at every version on the way.

Throws — the only throwing function in the package — because a migration is game code and can do anything. store.decode is the caller that wraps it and turns the throw into a migration-failed failure naming the rung. Exported so a test can drive a fixture through the chain without a store.

ChainBuilder interface ↳ src/migrate.ts:125

interface ChainBuilder<Head extends number, Current> {

The chain under construction. migrations() starts one; seal() ends it.

2 members
step<Next extends Increment<Head>, Migrated>(to: Next, why: string, migrate: (prior: Current) => Migrated, recognize: Recognize<Migrated>): ChainBuilder<Next, Migrated>

Add the rung Head → Head + 1. to is typed Increment<Head>, so migrations(1, isV1).step(3, …) fails to compile with Argument of type '3' is not assignable to parameter of type '2'.

migrate receives the previous version typed, because the previous version was recognized by its own recognizer before it was handed over. That is the whole reason a recognizer is mandatory rather than optional: without it a migration reads unknown and every line in it is a cast.

There is no 3 → 7 shortcut and there will not be one. A shortcut means two paths from 3 to 7 and only one of them is ever exercised; the untested one is the path a player's four-year-old save takes.

Throws

TypeError if migrate or recognize is not a function, or why is empty. A developer error at construction, which is a different moment from a player's save at boot and is allowed to be loud.

seal(): MigrationChain<Head, Current>

Freeze. Re-checks the chain at runtime for callers who arrived from JavaScript or through an any, and throws RangeError naming the missing version.

A hole caught here is a developer error at construction. A hole caught at decode time would present as a player losing a save, which is why this exists at all.

Throws

RangeError if the rungs do not form floor → head in steps of exactly one.

migrations functionstart here ↳ src/migrate.ts:194

function migrations<Floor extends number, T>(floor: Floor, recognize: Recognize<T>): ChainBuilder<Floor, T>

Start a chain at the oldest version you still support, with the recognizer for that version.

Every version has a recognizer, mandatory, including the floor. That is how migrate receives a typed argument instead of unknown: a chain of migrations that each begin with a cast is not a chain, it is a stack of hopes.

Parameters
floor

the oldest readable version. Raising it is a decision to abandon every save below it; make it in a commit of its own with the number in the message.

Throws

RangeError if floor is not an integer, TypeError if recognize is not a function.

store20 symbols

The envelope, the read pipeline, and the store.

Two rules govern everything in this file and they are worth stating before the code:

  1. The read path never throws. Boot is the one moment a game cannot recover from an exception, because there is no UI yet to show it in. Every read outcome — including all seven ways a save can be unusable — is a field on a returned object.
  2. The write path never throws either, for a different reason: the write that matters happens inside a pagehide handler on a page that is being discarded, where an exception is both unhandleable and invisible. A failed write is a WriteResult.

Neither rule extends to construction. createStore and seal() throw loudly at nonsense, because a developer error at startup is a different moment from a player's save at boot and should be as loud as possible.

Envelope interface ↳ src/store.ts:54

interface Envelope {

What is actually on disk. Five short keys, because this string is re-encoded every four seconds for the life of a session.

d is the payload as a JSON string, not a nested object, for two reasons that are worth the double encoding:

  1. the checksum then covers the exact bytes read, not a re-serialization of a parse. A checksum computed over JSON.stringify(JSON.parse(text)) is a checksum of your serializer's key ordering, and it will pass over damage and fail over nothing;
  2. v stays readable when d is garbage. Detecting a save from the future must not require parsing a payload written by a build that no longer exists.

Use inspect() rather than eyeballing it in devtools.

5 members
readonly v: number

Save format version — the head of the chain that wrote it.

readonly t: EpochMillis

When it was written, in epoch ms, read from the store's injected now — this package has no clock of its own. The only timestamp in the format.

readonly n: number

Write sequence, monotonic per key. Only used for cross-tab conflict detection.

readonly c: string

checksum(d).

readonly d: string

The game's state, JSON-encoded.

FailureReason type ↳ src/store.ts:74

type FailureReason = 
/** Storage itself refused to be read. Private mode, a disabled setting, an I/O error. */
'unreadable'
/** Not JSON, or JSON that is not an envelope. Something else wrote to this key. */
 | 'malformed'
/** Checksum mismatch, or the payload did not parse though the envelope did. Damaged bytes. */
 | 'corrupt'
/**
 * `v` is above the chain head: the player has opened an older deploy. This one is not the
 * player's fault and must not cost them their save — it is the only reason that also sets
 * `writable: false`.
 */
 | 'future'
/** `v` is below the chain floor: a save from before the versions this build still carries. */
 | 'orphaned'
/**
 * A migration threw, or a step's recognizer rejected its own output. `atVersion` names the
 * rung and `message` carries what the recognizer said was wrong.
 */
 | 'migration-failed'
/**
 * Migrated to the head and the head recognizer still threw. The chain has a bug, or
 * something else has been writing this key.
 */
 | 'invalid'

Every way a save can fail to become a state. Closed union; a reviewer can count the branches, and every one of them degrades to a fresh state rather than throwing.

ReadFailure interface ↳ src/store.ts:108

interface ReadFailure {

The report. This is what "reported" means: a value, not a log line and not a thrown error.

The package never renders text at a player, never calls console, and never phones home. It hands the game a record the game can log, count, put behind a debug panel, or show as "we could not read your save" in its own voice — and that a test can assert on exactly.

7 members
readonly reason: FailureReason
readonly message: string

Names the caller's mistake in prose, with the key and the versions in it — e.g. persist: save "campus" is version 9 but this build reads up to 7 — the player has an older deploy. Storage was left untouched and this store will not write.

For a game's own logs and bug reports. Do not show it to a player: it is written in one voice and one language, and a game that puts it on screen has a sentence in its UI it cannot change without patching a dependency. Switch on reason and say it yourself.

readonly savedVersion: number | null

The version on disk, or null when the envelope was not readable at all.

readonly atVersion: number | null

For migration-failed, the rung that threw. null otherwise.

readonly savedAt: number | null

The instant on disk, when the envelope carried a readable one.

readonly quarantined: boolean

Whether the offending text was kept under ${key}:rejected. false if quarantine is off, if the quarantine write itself failed, if the text was never read, or for future — which is never quarantined because nothing is being destroyed.

readonly cause: unknown

Whatever was thrown, unchanged. unknown because a migration can throw a string.

OpenResult interface ↳ src/store.ts:137

interface OpenResult<T> {

What open() and decode() produce. Always playable, whatever happened.

8 members
readonly state: T

Always a playable state. fresh() was called if the save did not survive.

readonly source: 'save' | 'fresh'
readonly firstRun: boolean

True only when storage held nothing. source: 'fresh' with firstRun: false is a save that was lost, and a game that treats the two the same will report a healthy funnel while quietly losing people.

readonly migratedFrom: number | null

The version read from disk, when it was below the head and migrated up.

readonly savedAt: number | null

When the loaded save was written — non-null exactly when source === 'save'. What offline accrual is measured from, and it can be in the future if the device clock moved. Reported faithfully and never clamped; elapsedSince does the clamping.

readonly writable: boolean

False when this store refuses to write over what it found. Today that is future only.

readonly durable: boolean

From the adapter. False means this session will not be there tomorrow.

readonly failure: ReadFailure | null

Non-null exactly when source === 'fresh' && !firstRun.

WriteSkip type ↳ src/store.ts:167

type WriteSkip = 
/** The store was never opened, or has been closed or reset. */
'closed'
/** A save from the future is on disk and this build must not overwrite it. */
 | 'not-writable'
/** The coalescing interval has not elapsed. The overwhelmingly common skip. */
 | 'too-soon'
/** Another tab has written since we last did, and `conflict: 'refuse'` is set. */
 | 'conflict'
/** The envelope exceeds `maxBytes`, so the quota was not discovered by throwing. */
 | 'too-large'

Why a write did not happen. Not errors — every one of these is the store working correctly, which is why they are a separate field from error.

WriteFailure interface ↳ src/store.ts:180

interface WriteFailure {

A write that was attempted and refused by the platform.

3 members
readonly reason: 'quota' | 'unavailable'
readonly message: string
readonly cause: unknown

WriteResult interface ↳ src/store.ts:187

interface WriteResult {

The outcome of one write attempt. written, skipped and error are mutually exclusive.

4 members
readonly written: boolean
readonly bytes: number

The envelope's length in UTF-16 code units — what a browser storage quota actually counts, and what maxBytes is compared against. 0 when nothing was serialized.

readonly skipped: WriteSkip | null
readonly error: WriteFailure | null

Rejected interface ↳ src/store.ts:204

interface Rejected {

A save that could not be read, kept so a bug report can carry it.

Degrading to fresh without keeping the bytes destroys the only copy of the bug that just ate a player's campus, and the support conversation is then two people guessing.

3 members
readonly failure: ReadFailure
readonly text: string

The offending text, up to the quarantine cap.

readonly truncated: boolean

True when text is a prefix rather than the whole of what was on disk.

Schedule type ↳ src/store.ts:234

type Schedule = (afterMs: number, fn: () => void) => Cancel

Run fn after afterMs have passed, and hand back a way to cancel it.

Injected, never created. persist may not import @latticekit/loop — they are siblings on layer 1 and the DAG forbids the edge — and it may not reach for a timer of its own, because a package that creates a timer is a package that owns a leak. A browser game wraps loop.real with scheduleFrom; a Node test passes a function that records its callbacks and runs them by hand, and every coalescing test then finishes in microseconds with no fake timers.

The unit is milliseconds, as the parameter name says and as minWriteIntervalMs, EpochMillis and elapsedSince all say. @latticekit/loop schedules in seconds, so loop.real.after is not a Schedule and must never be passed as one — use scheduleFrom.

Whatever you pass must keep firing in a hidden tab. requestAnimationFrame is 0 Hz when the tab is backgrounded, so an rAF-backed scheduler stops saving at precisely the moment a player is most likely to close the tab.

SecondsTimeline interface ↳ src/store.ts:246

interface SecondsTimeline {

A seconds-based timeline, structurally — loop.real and loop.sim both satisfy it.

Declared rather than imported, exactly as StorageLike declares localStorage and ListenerTarget declares document: @latticekit/loop is a sibling on layer 1 and the DAG forbids the edge, so the coupling is two method signatures and nothing else.

delay is in seconds and the returned handle is an opaque id, which is the whole reason scheduleFrom exists.

2 members
after(delay: number, fn: () => void): number
cancel(id: number): boolean

scheduleFrom function ↳ src/store.ts:277

function scheduleFrom(timeline: SecondsTimeline): Schedule

Turn a seconds-based timeline into the millisecond Schedule this package takes.

const auto = store.autosave(() => game.state, { schedule: scheduleFrom(loop.real) });

This function exists because the alternative is a silent hour-long outage. loop counts in seconds throughout and this package counts in milliseconds throughout, and neither is wrong: a save file's calendar is EpochMillis, and a frame budget is naturally sub-second. But the two meet at exactly one call site, and a reader who writes { schedule: loop.real.after } and silences the return-type error with a cast has just asked for a write every 4,000 seconds — 67 minutes — instead of every four. Nothing looks broken. The game plays, the store reports ok, and the player loses an hour of progress the first time the tab is closed at the wrong moment.

So the conversion lives here, in one audited place, rather than in the same three-line shim in every game. It is also the only / 1000 in the package, and it should stay that way.

The returned Cancel is idempotent: it forgets its id after the first call, so a stale disposer cannot cancel a timer that some later after was given the same id for. loop never reuses ids, but this takes any conforming timeline and the guard costs one boolean.

Throws

TypeError if timeline does not have after and cancel — a developer error at wiring time, which is a much better moment than the first missed autosave.

StoreStatus type ↳ src/store.ts:314

type StoreStatus = 
/** Reading and writing normally. The overwhelmingly common value. */
'ok'
/**
 * A save exists that this build cannot read and must not overwrite. Nothing is being
 * written and nothing this session will survive.
 *
 * Set by `open()`, cleared only by `reset()` or by an `open()` that no longer finds a newer
 * save. **What the player loses by missing it: the entire session, silently.**
 */
 | 'refusing-newer'
/**
 * Writes are being attempted and rejected — quota, or storage revoked mid-session. Set on a
 * failed write, cleared by the next successful one, so it is stable exactly as long as the
 * condition is.
 *
 * **What the player loses by missing it: everything since the last successful write**,
 * which grows for as long as they keep playing.
 */
 | 'write-failing'
/**
 * The adapter is not durable — private mode, or storage refused at boot. The session plays
 * and will not be there tomorrow.
 *
 * **What the player loses by missing it: this session, once they close the tab.** Known at
 * construction, and it never changes for the life of the store.
 */
 | 'not-persistent'

What is wrong with this store right now, as a condition rather than a message.

@latticekit/ui latches player-facing notices on this value, so the contract is narrow:

  • It is stable while the condition is. A bare member of this union, always. It never carries a timestamp, an attempt count, a byte size or a version number. Interpolating a detail would defeat the latch in exactly the case the latch exists for — the autosave rediscovers full storage every four seconds, and a status that differs each time is shown each time. The details live on Autosave.lastWrite, store.rejected() and OpenResult.failure; the status is the key, not the payload.
  • It is readable the moment open() returns, before a single tick. The newer-save case has to reach a player whose session has not started.
  • One value, most severe first. refusing-newer masks write-failing masks not-persistent, and that is correct rather than a compromise: a store that is refusing to write has nothing to say about whether its writes would have survived.

StoreOptions interface ↳ src/store.ts:344

interface StoreOptions<Head extends number, T> {

Everything createStore needs. Four of these are required and none of them has a default.

12 members
readonly key: string

The storage key, and with it the lifetime. One store, one key, and no store ever reads or writes another store's key — which is what makes progress, settings and replays genuinely independent rather than independent by convention.

The convention this kit recommends: campus:save, campus:settings, campus:replay:<id>, plus campus:save:rejected, which the store manages itself. Save slots are separate stores on separate keys; there is no slot concept and there does not need to be one.

If any part of the key is derived from something a player typed, hash it through hashString(name.normalize('NFC')) — see defaultChecksum's note. Without the normalization the same name typed on macOS and on Windows produces two different keys and two different worlds.

readonly chain: MigrationChain<Head, T>

The chain. Its head is the store's version; there is no other version number.

readonly adapter: StorageAdapter
readonly fresh: () => T

A brand-new game. Called on first run and on every degraded read. Must not throw — if this throws, boot is over and there is nothing left to degrade to.

readonly now: Now

The game's calendar. Required, with no default, deliberately.

persist stamps savedAt and cannot read a clock of its own: Date.now is banned inside every package's src/ and the linter enforces it. Defaulting this to () => 0 would be the worst bug this package could ship, because every save would load with an elapsed time of zero, offline progress would silently pay out nothing, and *nothing would look broken*. A missing argument is a compile error; a zeroed timestamp is a support ticket in eight months.

readonly minWriteIntervalMs?: number

Floor on the interval between coalesced writes. Default 4000.

readonly conflict?: 'last-write-wins' | 'refuse'

What to do when another tab has written since we last did. Default 'last-write-wins', which is free; 'refuse' costs one extra adapter read per write and reports WriteSkip: 'conflict' so the game can say "this game is open in another tab".

This is detection, not a lock, and it is deliberately not one. Web Locks is not available everywhere this kit runs and BroadcastChannel cannot tell you the holder was killed rather than closed, so a half-lock leaks on a crashed tab and locks a player out of their own game permanently. With no lock the loser merely overwrites; with a half-lock the winner cannot play at all.

readonly checksum?: Checksum

Default defaultChecksum, which is core's hashString as eight hex digits.

readonly quarantine?: false | { readonly maxBytes?: number; }

Keep unreadable saves under ${key}:rejected. Default on, capped at 64 kB.

readonly maxBytes?: number

Refuse to write an envelope larger than this rather than discover the quota by throwing. Default 1,000,000.

readonly onFailure?: (failure: ReadFailure) => void

Called once, during open() or decode(), with the same record the result carries. For a counter or a breadcrumb — the result is the source of truth, and a game that ignores this loses nothing.

readonly onWriteError?: (failure: WriteFailure) => void

Called on a failed write. Expect it more than once: a full quota does not heal.

AutosaveOptions interface ↳ src/store.ts:414

interface AutosaveOptions {

How an Autosave decides when to write.

1 member
readonly schedule?: Schedule

The timer, injected. When supplied, the handle re-arms itself after every write and you never call tick.

Prefer this to polling: a tick driven by the simulation stops when the simulation stops, and a paused or backgrounded game still owes the player the last four seconds of progress.

Autosave interface ↳ src/store.ts:429

interface Autosave {

A coalescing write handle bound to one getter. store.autosave makes it; store.reset and store.close kill it.

4 members
tick(): boolean

The polling form, for a game with no scheduler. Writes iff minWriteIntervalMs has passed since the last attempt, reading the instant from the store's injected now.

Returns a boolean, not a result object: this is called for the life of the session and an object per call is a garbage-collector pause with a pleasant signature. The detail of the last write that actually happened is on lastWrite.

Do not drive it from requestAnimationFrame: rAF is 0 Hz in a hidden tab, and a save that stops when the tab is backgrounded is a save that never survives the tab being closed. A no-op returning false when schedule was supplied, so wiring both is harmless rather than a double write.

flush(): WriteResult

Write now if anything is owed, ignoring the interval. What the visibility handler calls.

readonly lastWrite: WriteResult | null

The last write this handle attempted, or null. One object per real attempt, not per tick.

stop(): void

Detach and cancel any scheduled write. Idempotent. A stopped handle's tick and flush are no-ops reporting 'closed' — which is half of why reset() actually resets.

Store interface ↳ src/store.ts:456

interface Store<T> {

One key's worth of saved state, versioned by its chain.

14 members
readonly key: string
readonly version: number

The chain head. There is no other version number in the system.

readonly phase: 'new' | 'open' | 'closed'
readonly writable: boolean

False when a save from the future is on disk. Every write then skips 'not-writable'.

readonly status: StoreStatus

The current condition, safe to read on every frame and every update.

A plain property returning a string literal: no allocation, no event subscription, no disposer to leak. ui polls it, latches on it, and decides for itself what a given condition is worth interrupting a player for — which is the correct division, because this package cannot know what the player is in the middle of.

open(): OpenResult<T>

Read storage and produce a state. Never throws, for any content whatsoever. Calling it twice re-reads; calling it after reset() or close() reopens the store.

decode(text: string): OpenResult<T>

open() minus the adapter: the entire read pipeline as a function of a string.

This is the testing seam — a fixture file per historical version, run through decode, is the regression test that the chain still reaches the head. It touches no storage, so it quarantines nothing (failure.quarantined is always false here) and changes no store state: the writable on its result is what open() would have set.

encode(state: T): string

The envelope text for state, exactly as save would write it. A backup or share-code button is this plus the game's own encoding of choice.

Throws

TypeError if the state cannot be JSON-encoded — a BigInt, a cycle, or a bare undefined. This is the one write-path function that throws, because it is a developer tool rather than something a pagehide handler calls; save() catches the same failure and reports it as a WriteResult.

save(state: T): WriteResult

Write now, unconditionally, subject only to writable, phase, maxBytes and conflict.

autosave(get: () => T, options?: AutosaveOptions): Autosave
reset(): T

A real reset. In order: close the store to writes, stop every autosave handle it has created, remove the key and the quarantine key, return a fresh state.

The ordering is the whole point. localStorage.clear() followed by a reload does not reset a game — the live autosave flushes on pagehide and writes the state back over the clear. The game this kit came from lost real time to exactly that, and its fix was a hand-rolled window.game.reset(). Here it is the API: after reset() returns, no code path in this package writes to the adapter until open() is called again.

Scoped to one store. A game's START OVER calls it on the save store only; resetting the settings store means "back to factory volume", which is a different button most games do not have. There is no resetEverything() and the absence is deliberate.

close(options?: { readonly flush?: false; } | { readonly flush: true; readonly get: () => T; }): void

Tear down. Pass a getter to flush on the way out; pass nothing to close silently — which is what a "delete my save" button wants and what reset does internally. Idempotent.

rejected(): Rejected | null

The last save this store could not read, if quarantine kept it. Survives a reload.

clearRejected(): void

inspect function ↳ src/store.ts:572

function inspect(text: string): Envelope | null

The envelope only, payload untouched, or null if this is not one.

For tools, debug panels, and the future check — which must work without parsing a payload written by a build that no longer exists. That is the whole reason d is a string.

Never throws: null covers "not JSON", "JSON of another shape", and "an envelope whose numbers are NaN". The numeric fields are checked with core's isSerializable rather than expectSerializable precisely because this runs on the boot path.

elapsedSince function ↳ src/store.ts:607

function elapsedSince(opened: OpenResult<unknown>, now: EpochMillis): number

Milliseconds between the loaded save and now — the offline gap, and the one derived quantity this package will compute for you.

Returns 0 when there is nothing to measure (a first run, or a degraded read), which is the correct elapsed time for a game that has just begun. Clamped at zero from below, because a player who changes their device date produces a savedAt in the future and a negative elapsed is not a thing a simulation should have to defend against.

Not clamped from above. An offline cap is a balance decision — how much of eight hours away a game chooses to pay out — and it belongs to @latticekit/sim, not here. This function reports the gap; sim decides what it is worth. Read OpenResult.savedAt directly if you want to detect the backwards clock and say something about it.

createStore functionstart here ↳ src/store.ts:644

function createStore<Head extends number, T>(options: StoreOptions<Head, T>): Store<T>

Build a store for one key.

There is no version option. The chain is the version: createStore reads chain.head, so declaring 7 while shipping a chain that ends at 6 is not expressible. There is no validate option either — validation is per-version, inside the chain, so there is one concept rather than two.

Throws

TypeError if key is not a non-empty string, or fresh/now is not a function.

Throws

RangeError if minWriteIntervalMs is negative or non-finite, or maxBytes is not a positive finite number. All of these are developer errors at construction, which is a different moment from a player's save at boot and is allowed to be loud.

replay12 symbols

Replay: the kit's headline claim, made falsifiable.

AGENTS.md #1 promises that a session replays from a seed and an input log and lands on the same pixel. Nothing owned that, which made the claim unfalsifiable — the worst state for a kit selling determinism, because it is either the best feature or a lie and nothing decides which. A replay is a save with a different payload: it needs a version, integrity, and an envelope, and all three already live here.

A replay is evidence, and evidence is never migrated

Everything in migrate.ts argues that a save must survive at almost any cost. A replay takes the opposite policy, and a reader arriving from there will assume otherwise, so it is stated here as a contrast rather than left to be inferred.

A save is a player's progress, and progress that cannot be read is a loss the player feels. A replay is evidence, and evidence that has been migrated is no longer evidence. A session recorded at a 16.667 ms step and replayed at 20 ms will not land on the same pixel; a session recorded under a tap threshold of 8 px and replayed at 12 px turns one pointer stream into a different sequence of actions. "Migrating" either would produce a confident wrong answer, and a divergence report that cannot be trusted puts the determinism claim back where it started while looking like it has been tested.

savereplay
old formatmigrated, rung by rungrefusedorphaned
mechanisma chain with rungs from floor to heada chain with no rungs: migrations(N, isLog).seal(), floor === head
near-misstolerated; a recognizer may normalize as it validatesrefused, exactly: version, stepMs and profile are compared for equality and the differing field is named
failure costsa player's campusa test result nobody should have trusted

The mechanism is worth noticing: "never migrate" is expressible in the machinery already here, as a chain with zero rungs. A replay store is an ordinary createStore whose chain has floor equal to head, so a replay in an older format reads as orphaned — an existing failure reason, already meaning "older than anything this build will read", already degrading without a throw. No second code path, and no exception to the read pipeline.

The compatibility triple is checked in a second, separate place — createVerifier, before the first tick — because the two refusals answer different questions. orphaned means this build cannot read the file. A Refusal means *the file is readable and was recorded under conditions this build does not reproduce*. Collapsing them would lose the distinction that tells you whether to go and find an older build or go and fix the step.

What is not here, and where it went

The cursor that plays a log back belongs to @latticekit/input: this package stores the log verbatim, which necessarily means opaquely, and a package that cannot see inside a structure cannot iterate it. The driver — constructing a game, restoring the rng snapshot, turning the fixed-step crank — belongs to @latticekit/loop, which this package may not import. persist hands over a log and a verifier; loop turns the crank.

ReplayCompat interface ↳ src/replay.ts:65

interface ReplayCompat {

The only three fields this package reads out of a recorded input log.

@latticekit/input owns the log's shape and persist may not import it — input is layer 2 and this is layer 1, so the edge does not exist. This structural constraint is therefore the entire coupling between them: three fields, compared for exact equality, never interpreted. Everything else about a log is opaque here and is stored verbatim.

3 members
readonly version: number

The input log's own format version.

readonly stepMs: number

The fixed step the session was recorded at. A replay driven at a different step is a different simulation, however similar it looks — which is why this is compared for equality and never coerced.

readonly profile: string

The gesture/binding profile in force. A tap threshold that moved turns one recorded pointer stream into a different sequence of actions.

Digest type ↳ src/replay.ts:91

type Digest<T> = (state: T) => number

"The same pixel", reduced to a uint32.

The game supplies it because only the game knows what is canonical: the wallet and the building list, probably; a camera position and a tween phase, definitely not, or every replay diverges the first time somebody scrolls. Build it from core's hashParts, and keep it Tier A — a digest that reaches Math.pow may disagree in the last bit between two conforming engines, and a determinism check that fails on a different browser is worse than none.

Checkpoint interface ↳ src/replay.ts:97

interface Checkpoint {

Eight bytes. The interval between them trades log size against how tightly a divergence can be bracketed — a checkpoint every ten seconds means "somewhere in these 600 ticks".

2 members
readonly tick: number
readonly digest: number

ReplayLog interface ↳ src/replay.ts:109

interface ReplayLog<L extends ReplayCompat> {

A recorded session: a starting stream, an input log, and the digests that make the claim checkable.

Store it in an ordinary createStore whose chain has no rungs, so an old one reads as orphaned rather than being migrated into a confident wrong answer.

7 members
readonly kit: string

The kit build this was recorded under. A divergence against an unknown build is unattributable, and an unattributable divergence report is theatre.

readonly game: string

The game's own build identity, however the game versions itself.

readonly rng: RngSnapshot

The stream the session started from, cursor included — not just the seed. A log that restores a seed but not the cursor re-rolls every draw the session had already spent, and it looks correct for the first few draws, which is what makes it expensive.

readonly startTick: number
readonly endTick: number
readonly inputs: L

The input log, verbatim. Never rewritten, never normalized, never migrated.

stepMs and profile live in here rather than being copied up to this level, deliberately: a duplicated field is a field that can disagree with itself, and the copy that disagrees is always the one the check reads.

readonly checkpoints: readonly Checkpoint[]

Ascending by tick.

RecorderOptions interface ↳ src/replay.ts:138

interface RecorderOptions<T> {

What a recorder needs to know before the first tick.

6 members
readonly kit: string
readonly game: string
readonly rng: RngSnapshot

The stream's full state at startTick, cursor included.

readonly startTick: number
readonly digest: Digest<T>
readonly checkpointEvery?: number

Ticks between checkpoints. Default 600 — ten seconds at 60 Hz.

Recorder interface ↳ src/replay.ts:160

interface Recorder<T> {

Records checkpoints, and nothing else.

It does not record inputs: @latticekit/input already keeps a per-tick bucketed log keyed by an integer tick index, and a second recorder here would be a second copy of the same data with its own ordering bugs. The game hands that log over once, at stop.

Checkpoints are digests, not states. Storing states would make a replay a save-scumming format and a hundred times larger, and would answer a question nothing asked ("what did it look like") in place of the one that matters ("did it diverge").

3 members
mark(tick: number, state: T): boolean

Advance to tick, taking a checkpoint if one is due. Returns whether it took one.

A boolean, not a result object: this is called every tick for the whole session, and digest runs only on the ticks that actually checkpoint.

A no-op returning false after stop(). Ticks that arrive out of order or behind the next due point are ignored rather than rejected, because a driver that skipped is a driver problem and losing the recording is not the proportionate answer.

readonly checkpointCount: number
stop<L extends ReplayCompat>(tick: number, state: T, inputs: L): ReplayLog<L>

Take a final checkpoint and seal the log around the input log you pass in. Idempotent: the second call returns the first call's log and ignores its arguments, so a driver that stops in both a normal path and a teardown path does not record two different endings.

Refusal type ↳ src/replay.ts:185

type Refusal = {
    readonly kind: 'mismatch';
    readonly field: 'kit' | 'game' | 'log-version' | 'stepMs' | 'profile';
    readonly recorded: string | number;
    readonly current: string | number;
} | {
    readonly kind: 'no-checkpoints';
}

Why a replay was not run. A refusal is never a pass, and the field that differed is named because "incompatible" sends someone reading five things to find out which one.

Divergence interface ↳ src/replay.ts:195

interface Divergence {

Where two runs first disagreed, and the bracket the bug is inside.

5 members
readonly tick: number

The checkpoint tick where the digests first disagreed.

readonly lastAgreedTick: number

The last tick known to agree. The bug is between these two numbers — which is the entire value of a checkpoint interval, and why the report leads with the bracket.

readonly expected: number
readonly actual: number
readonly checkpointIndex: number

ReplayVerdict interface ↳ src/replay.ts:209

interface ReplayVerdict {

The answer. matched is true only if every recorded checkpoint was checked and agreed.

4 members
readonly matched: boolean

True only when every checkpoint in the log was reached and agreed. A driver that stopped early reports false with no divergence, because a verifier that reported green over checkpoints it never visited is the same failure as one that reported green because it refused to check.

readonly checkpointsChecked: number
readonly divergence: Divergence | null

The first divergence only. Every later one is a consequence of this one, and reporting them is noise that buries the line that matters.

readonly refused: Refusal | null

Non-null means the replay was declined before it started. matched is then false, never true — a verifier that reported green because it had refused to check is exactly how a determinism claim rots into a slogan.

ReplayVerifier interface ↳ src/replay.ts:232

interface ReplayVerifier<T> {

Drives digest comparisons tick by tick. One per replay attempt; not reusable.

2 members
mark(tick: number, state: T): boolean

Compare at tick if a checkpoint is due there. Returns false once it has diverged or refused, so a driver can stop immediately rather than run an hour of ticks past the answer.

finish(): ReplayVerdict

createRecorder functionstart here ↳ src/replay.ts:250

function createRecorder<T>(options: RecorderOptions<T>): Recorder<T>

Start recording checkpoints for a session.

Throws

TypeError if digest is not a function — without one there is nothing to compare and the recording would be a log that always matches.

Throws

RangeError if checkpointEvery is not a positive integer. Zero would checkpoint every tick and make the log the size of the session.

createVerifier function ↳ src/replay.ts:320

function createVerifier<T, L extends ReplayCompat>(log: ReplayLog<L>, current: {
    readonly kit: string;
    readonly game: string;
    readonly inputs: ReplayCompat;
    readonly digest: Digest<T>;
}): ReplayVerifier<T>

Build the verifier for a log, against this build's identity and input configuration.

The compatibility check is exact equality on five values and runs before the first tick. It is not a migration and there is no coercion: a near-miss is refused by name, because the alternative is a divergence report nobody should trust.

Pass current.inputs read off a freshly created input log rather than typed out at the call site, so the recorded and current triples cannot drift apart in a refactor.

browser4 symbols

@browser-only — the one module in this package that knows a browser exists.

It knows through parameters. Everything here compiles without the DOM lib, tests in Node against plain objects, and is the single grep-able exception to the rule that @latticekit/persist runs unchanged under node. If a second module in this package ever needs this header, the package has stopped being isomorphic and the change should be argued rather than merged.

Two traps from the game this kit was extracted from are answered here, and both of them cost real time:

  1. beforeunload does not fire reliably on mobile Safari. A save that only runs on unload loses the session for the players whose sessions end by the phone going into a pocket. Bind visibilitychange (guarded on visibilityState === 'hidden') and pagehide instead — visibilitychange fires when the app is backgrounded, which is the moment that actually corresponds to "the player has stopped playing".
  2. Private-mode Safari throws on the property access, not merely on the write. The guard has to wrap the read of globalThis.localStorage itself; a try/catch around setItem alone still takes the page down at module scope.

ListenerTarget interface ↳ src/browser.ts:30

interface ListenerTarget {

addEventListener/removeEventListener, structurally, so no DOM type is imported.

2 members
addEventListener(type: string, listener: () => void): void
removeEventListener(type: string, listener: () => void): void

FlushTargets interface ↳ src/browser.ts:36

interface FlushTargets {

The two hosts a flush has to be wired to, and nothing else about them.

2 members
readonly visibility: ListenerTarget & { readonly visibilityState: string; }

document, structurally.

readonly page: ListenerTarget

window, structurally.

installFlushTriggers function ↳ src/browser.ts:59

function installFlushTriggers(autosave: Autosave, targets: FlushTargets): () => void

Flush on the events that actually fire, and return a disposer.

Binds visibilitychange (flushing only when visibilityState === 'hidden') and pagehide. Not beforeunload — see this module's header.

The returned disposer removes both listeners. It does not flush, and that is load-bearing rather than an omission: a disposer that writes is exactly the mechanism that makes a reset fail. The game this kit came from had one, and localStorage.clear() plus a reload therefore did not reset the game — the flush on the way out wrote the live state back over the clear. store.reset() stops the handle before it removes the key, and this disposer stays silent, so both halves of that trap are closed.

It is safe to call the disposer more than once: the second removeEventListener for a listener that is no longer bound does nothing, per the DOM spec.

browserStorage function ↳ src/browser.ts:111

function browserStorage(scope?: {
    readonly localStorage?: StorageLike;
}): StorageAdapter

localStorage if the platform will give it up, memory if it will not. The only mention of localStorage in this package.

A player whose browser refuses storage still gets to play. They just do not get to come back to it, and durable: false says so — which surfaces as store.status === 'not-persistent' from the moment the store is constructed, so a game can tell them once, at the start, rather than letting them find out after two hours.

Parameters
scope

where to look. Defaults to globalThis. Pass { localStorage: sessionStorage } for a session-scoped store, or a fake in a test — the parameter exists so this function is testable in Node, which is the only place this package's suite runs.

index1 symbol