1toRecord() — the recipe is the value
A Dataset is a lazy chain, so the honest way to persist one is to persist the chain: where the
bytes came from, and which operations were applied. That is what toRecord() gives you
by default, and it is why storing a five-op derivation costs about as much as storing the original.
{ storeMaterialized: true } embeds the decoded grid as well — call
await ds.load() first, or it throws.
Dataset.fromRecord() rehydrates either form, replaying the op chain node by node. It
is required: a structured clone drops prototypes, so what comes back out of IndexedDB is
inert data until this rebuilds it.
const ds = await FimViz.parseFile(file); const derived = ds.clip(bbox).reclassify([{ min: 2, max: Infinity, value: 1 }]); const recipe = derived.toRecord(); // source + ops await derived.load(); const baked = derived.toRecord({ storeMaterialized: true }); // …plus the decoded grid const back = Dataset.fromRecord(recipe); // the chain rebuilt, still lazy await back.grid(); // re-runs it from the stored bytes // A callback-form reclassify does NOT survive: functions cannot be structured-cloned. // Use range rules for a chain you intend to persist.
Output
…
2Values are stored verbatim
Keys are out-of-line — the store never reaches into your value to find its key — and that is
precisely what lets the value be stored by structured clone rather than as JSON. An
ArrayBuffer, a TypedArray, a Blob, a Map, a
Set, a Date: all round-trip exactly.
The alternative silently destroys data, which is the whole argument:
JSON.stringify(arrayBuffer) is "{}". Not an error, not a warning — an
empty object where your raster used to be. The button below demonstrates it on real bytes.
const db = new Storage({ name: "fimviz-notebook", version: 1, tables: ["files", "notes"] }); await db.put("files", "depth.tif", arrayBuffer); // stored byte for byte (await db.get("files", "depth.tif")).byteLength; // unchanged JSON.stringify(arrayBuffer); // "{}" ← the thing this design exists to avoid
Output
…
3Discovery — ask before you open
IndexedDB cannot downgrade: opening at a version lower than the one on disk throws a hard
VersionError. And the on-disk version is not something a host can track for itself,
because createTable/dropTable bump it as a side effect — so the number
you passed to the constructor goes stale on its own. Those two facts are why discovery exists.
The safest open is to omit version entirely, which attaches to whatever is
already there. And Storage.databases() throws on engines without
indexedDB.databases() rather than reporting an empty list — "no databases" and
"cannot tell" must not look alike when the next step might be destroying data.
await Storage.databases(); // [{ name, version }, …] for this origin await Storage.exists("fimviz-notebook"); await db.version(); // the CURRENT on-disk version — never creates, never upgrades db.declaredVersion; // what THIS instance was constructed with new Storage({ name: "fimviz-notebook", tables: ["files"] }); // no version → no downgrade risk
Output
…
4Rows
db.table(name) binds the table so a call reads as a verb on the table itself. It is
pure sugar — every method delegates to the db-level call with table bound, so the two
forms are fully interchangeable and there is nothing to dispose.
Two distinctions the API keeps that a thinner wrapper usually loses. has()
distinguishes a stored undefined from a missing key. And a broken read rejects
rather than resolving undefined, so a failure cannot be mistaken for an absence. Every
write really commits before its promise resolves — it awaits the transaction, not just the request.
const files = db.table("files"); await files.put(key, value); // → the key await files.get(key); await files.has(key); // distinguishes a stored `undefined` from absent await files.delete(key); await files.list({ keys: true }); // cheap — no values deserialized (a filename picker) await files.list(); // [{ key, value }] in key order await files.clear(); // this table only
Output
…
5Two kinds of change, two different tools
Data migration needs no version bump at all: keys are out-of-line and values are verbatim,
so a record's shape was never part of the schema. map() walks a table in key
order inside one transaction; return a new value to update the row,
Storage.DELETE to remove it, or undefined to leave it alone. That last
default matters — a callback that forgets to return does nothing, rather than wiping the table.
Structural change — which stores exist — is the other tool, because IndexedDB only permits
it inside an upgrade transaction. createTable/dropTable bump the version
for you; versionMigrate is the hook for anything more (create a store, copy rows
between stores) and runs inside that transaction.
// DATA — no version bump. await db.table("notes").map((key, value) => { if (value.stale) return Storage.DELETE; // a Symbol — cannot collide with your data if (value.v === 1) return { ...value, v: 2 }; // rewrite it // return undefined → untouched }); // → how many rows were updated or deleted await db.clearAll(); // every row in every table, one transaction, schema kept // STRUCTURAL — bumps the version. await db.createTable("thumbnails"); // false if it already existed await db.dropTable("thumbnails"); new Storage({ name, version: 3, versionMigrate: ({ db, transaction, oldVersion }) => { … } });
Output
…
6The round trip that motivates all of it
Parse a file, derive from it, store the recipe, close the tab, come back, rehydrate, and force — the grid is rebuilt from bytes that never left the browser. This is the whole offline-dataset path, and the record it writes is small enough that a handful of them is unremarkable.
One thing to know when the chain includes a multi-dimensional format: rehydrating skips the parser,
so a selector-backed Dataset needs its decoders registered before fromRecord.
const derived = ds.clip(bbox).reclassify(rules); await db.table("files").put("scenario-a", derived.toRecord()); // … later, or after a reload … const record = await db.table("files").get("scenario-a"); const rebuilt = Dataset.fromRecord(record); await rebuilt.grid(); // same pixels, chain replayed await fim.addLayer(rebuilt); // or straight onto a map
Output
…
7Scope, and the end of the line
"Not a database library" is a decision, not a gap. There is no query language, no index management, and no transaction API beyond the calls above. If you need more, the intended answer is to build an adapter around a real database rather than to grow this class — the surface is kept small enough that such an adapter could slot in later.
The engine also names no database and no table, anywhere: a host owns the schema, which is
why fim.storage throws until you pass storage to mount(). And
the standing policy is that a schema change bumps the version and accepts a reset — this is storage
a host should treat as disposable.
await db.close(); // drop the connection; data persists, the next call reopens await db.destroy(); // delete the whole database // Shared across every map on an app, and only if the host asked for it: const fim = await mount("#map", { provider: "leaflet", storage: { name: "my-store", version: 1, tables: ["userFiles"] } }); fim.storage; // throws unless that option was given
Output
…