persistence-save
v0.4.0
Published
Save-game persistence for Capacitor games: a SQLite save/load facade (jeep-sqlite/sql.js on the web, native SQLite with SQLCipher on device), chained snapshot migrations, corrupt-save isolation, an autosave scheduler and a Preferences key-value bridge.
Maintainers
Readme
persistence-save
Save-game persistence for Capacitor games. One API stores saves in SQLite on every platform: jeep-sqlite over sql.js and IndexedDB on the web, and native SQLite (optionally SQLCipher-encrypted) on iOS and Android.
- A save/load/list/delete facade over
@capacitor-community/sqlite, opened lazily and isolating corrupt rows instead of failing the whole list. - Chained snapshot migrations: pure
N → N+1steps that are validated, frozen and walked to the exact target version, so old saves load safely. - An autosave scheduler with debounce, a leading-edge throttle and re-entrant suppression, for games that save every tick.
- A namespaced key-value bridge over
@capacitor/preferencesfor small settings. - The matching sql.js WebAssembly binaries, shipped as package assets.
Documentation · API · Architecture · Changelog
Install
npm install persistence-save @capacitor/core @capacitor-community/sqlite @capacitor/preferencesThe three Capacitor packages are peer dependencies of the root entry. The 0.2
line is verified against @capacitor/core 8.5, @capacitor-community/sqlite
8.1 and @capacitor/preferences 8.0. Node.js 22, 24 and 26 are supported.
The autosave scheduler and the snapshot-migration walker have their own
entries, persistence-save/autosave and persistence-save/migrations, which
load no Capacitor code. The Capacitor peers are optional, so a game without
Capacitor installs persistence-save alone and imports those. A game on
another Capacitor major can import them too, but an optional peer is still
checked when it is present: the install reports the peers unmet (a warning by
default, an error under strict-peer-dependencies). The root entry needs the
8.x peers.
On the web, copy both WebAssembly assets into the directory you pass as
wasmAssetsPath (for example with your bundler's static-copy step):
node_modules/persistence-save/dist/assets/sql-wasm.wasm
node_modules/persistence-save/dist/assets/sql-wasm-browser.wasmThey are also exported as persistence-save/assets/sql-wasm.wasm and
persistence-save/assets/sql-wasm-browser.wasm.
Quick start
import { createPersistence } from 'persistence-save';
interface State {
name: string;
gold: number;
}
interface Snapshot extends State {
version: number;
}
const saves = createPersistence<State, Snapshot>({
dbName: 'com.example.game_v1',
snapshotVersion: 2,
serialize: (state) => ({ version: 2, ...state }),
deserialize: ({ name, gold }) => ({ name, gold }),
migrations: {
1: (old) => ({ ...old, version: 2, gold: old.gold ?? 0 }),
},
});
await saves.save('Slot 1', { name: 'Player One', gold: 7 });
const [latest] = await saves.list(); // newest first; corrupt rows are isolated, not thrown
const record = latest ? await saves.load(latest.id) : null;
console.log(record?.snapshot.gold); // 7Migrations
Every source version, target version and migration output is a safe integer.
Each migration receives a deeply frozen clone and is a pure N → N+1 step.
Mutation attempts, null or non-object output, skips, overshoots, fractions, and
a walk that does not end exactly at the target are rejected as typed version
errors. Reading an older save migrates it in memory without rewriting the stored
row; only an explicit save() writes the current format.
serialize() is validated before SQLite is opened: it must return a non-array
object whose version exactly equals snapshotVersion. Snapshot size caps are
measured in UTF-8 bytes on both read and write.
Sharing the connection
Games with their own SQLite tables can pass an idempotent
initializeSchema(connection) callback and use withConnection() and flush()
for their own statements. This keeps one connection manager and one web-store
flush path. encryptionKeyPreference keeps an existing native SQLCipher key
name when you adopt the package in a game that already encrypts its database.
Settings
Preferences store strings, so serialize structured values explicitly, and always use a stable reverse-domain namespace:
import { createPreferencesKv } from 'persistence-save';
const settings = createPreferencesKv('com.example.game');
await settings.set('audio.muted', 'false');Autosave
import { createAutoSaveScheduler } from 'persistence-save';
const autosave = createAutoSaveScheduler({
provider: () => (inMenu ? null : currentState()), // null skips the write
save: (state) => saves.save('AutoSave', state),
debounceMs: 400,
});
autosave.schedule(); // after a mutation; bursts coalesce into one write
autosave.scheduleThrottled('player.health', 2_000); // per-frame callers
addEventListener('pagehide', () => void autosave.flush()); // always saves
await autosave.flushPending(); // wait for a queued save; writes nothing if none is
autosave.pending; // true while a debounced or throttled save is queuedThe scheduler takes any save, so it works over your own storage too, from
the Capacitor-free entry:
import { createAutoSaveScheduler } from 'persistence-save/autosave';
import { migrateSnapshot } from 'persistence-save/migrations';See the API reference for every option.
Development
mise install # or: npm install --global corepack && corepack enable
pnpm install
pnpm verifypnpm verify runs Biome, markdownlint, strict TypeScript, the tests with
coverage, the ESM, CommonJS and type builds, publint, Are The Types Wrong, and
a packed-consumer proof. That proof packs the package, installs the tarball with
the exact Capacitor peers into a clean project with an empty home directory and
only the public registry, then runs the ESM and CommonJS entry points,
typechecks a separately written consumer, and compiles both WebAssembly assets.
pnpm test:web-cold-reload separately packs the built package, starts a clean
Vite consumer and uses a headed, muted Chromium session to prove that a flushed
save has IndexedDB bytes and reopens in a freshly navigated JavaScript realm.
Set PERSISTENCE_SAVE_CONSUMER_SOURCE=persistence-save@<version> to run the same
proof against a published version.
See CONTRIBUTING.md. Releases are automated with Release Please and published to npm with provenance.
License
MIT. The bundled sql.js WebAssembly binaries are MIT-licensed by the sql.js authors; see THIRD_PARTY_NOTICES.md.
