@openmaic/storage
v0.2.2
Published
The MAIC pluggable persistence layer: document / runtime / KV / asset primitives with browser and HTTP backends, depending only on @openmaic/dsl.
Maintainers
Readme
@openmaic/storage
The MAIC pluggable persistence layer: small, swappable-backend primitives for
persisting app state, depending only on @openmaic/dsl.
The DSL owns what persists (document / runtime shape + validation + migration +
the asset StorageProvider interface). This package owns where / how it
persists — the primitives and their backends. The pluggable seam is the
backend, not the database driver: browser backends (the zero-server
clone-and-run default), HTTP clients plus a reference server, and PostgreSQL
server backends.
Dependency arrow (acyclic)
@openmaic/storage -> @openmaic/dslNo dependency on React, zustand, or any host app. Backends take their Storage
/ IDBFactory by injection, so the package is app-agnostic and testable without
a browser.
What's in here
| Export | Role | Browser backend |
| --- | --- | --- |
| KVStore | small device / account-scoped values not owned by the DSL | BrowserKVStore over localStorage |
| StorageProvider (from @openmaic/dsl) | the asset seam: put(blob) → ref, resolve(ref) → url, remove(ref) | BrowserAssetStore over IndexedDB (assets registry + blobs) + object URLs |
| kvPersistStorage | adapt a KVStore into a zustand persist storage | — |
| DocumentStore | persist the DSL document aggregate (stage + scenes + embedded agents / quiz / actions + an outline snapshot) | BrowserDocumentStore over IndexedDB (normalized stages / scenes / outlines) |
| RuntimeStore | persist what a learner produces while taking a course — sessions + append-only records (chat, quiz attempts, playback facts) | BrowserRuntimeStore over IndexedDB (sessions / records) |
- Scopes.
accountvalues are user data a server-backed deployment syncs across devices;devicevalues (theme, locale, layout) never leave the device — every backend honours that, so the scope is part of the primitive, not the backend choice. The KV HTTP contract isaccount-only and carries no scope on the wire at all, soHttpKVStoreroutesdeviceto aLocalKVStoreit requires at construction — a branded local backend, because a networked store satisfies plainKVStorestructurally and would otherwise be accepted as the place device values live. - The asset pool.
BrowserAssetStoreis a global asset pool (#1007): an allocatedAssetId(ast_+ 128 random bits) names a registry entry (contentHash,mime,meta), and the registry names content-addressed bytes. A document embeds only the id and the store resolves it to a URL at render time (a raw URL would bake in a provider + expiry and break portability). Two levels of indirection buy three things at once: an id survives the bytes behind it being regenerated throughBrowserAssetStore.replace; identical bytes are stored once however many ids name them; and the content hash never leaves the package, so the "whoever knows the hash can reach the bytes" threat that pure content-addressing must defend against does not arise. Images, audio and video share one id space — the medium is amimecolumn, not a partition.putalways allocates a new id, so its successful return values and branches do not reveal whether the bytes were already present. The browser registry embeds itsblobstable in the same database because reference counting, byte writes, and reclamation must share one transaction. This is not a replaceable browser-side blob backend; a replaceable blob interface is a server-backend concern (delivery plan part 4), where consistency is enforced server-side. Resource-accounting channels remain: quota errors, storage estimates, and server billing or metering can disclose existence, so server deployments must budget them per principal. Object URLs are minted per id, not shared percontentHash: sharing would let a holder of two ids learn that their bytes match by comparing URL strings. Eachreplace(id, ...)followed byresolve(id)adds one retired snapshot that onlyrelease(id)orclose()reclaims; each ref retains at most one current snapshot plus that retired history. A returned URL is an immutable snapshot: mutations affect future resolutions but never revoke a URL already issued by this or another store instance. Application code that constructs a concreteBrowserAssetStoreowns that lifecycle (the narrower DSLStorageProviderseam exposes neither method), and media-heavy applications should reclaim snapshots explicitly.releaseis an owner-level escape hatch for a caller that owns every use of every URL returned for that id in the instance;closereclaims the whole instance. Cross-instance correctness comes from comparing the registry identity on every resolve, so a remove yieldsnulland a replacement yields a fresh URL on the next call without reclaiming older snapshots. The id domain is opaque and unvalidated (the KV key-domain lesson, applied forward): an unrecognized id is a miss, never an error. The server backend is still to come. - Document normalization. The DSL
documentis a portable embedded aggregate;DocumentStorenormalizes it into per-entity rows so scene-level writes (putScene) stay cheap, and reassembles it on read. Each document is stamped with adslVersion; reads run the DSL migration ladder forward, and writes are validated against the DSL gate (validateStage/validateScene) so schema drift fails loud. The outline is an opaque, app-owned snapshot carried alongside — persisted verbatim, neither validated nor migrated. - Generic over scene type.
DocumentStore<TScene>defaults to the DSLScene(universalslide/quiz). An app that widensScenewith its own kinds (interactive/pbl, content the DSL does not own) parameterizes the store over its scene union and injects a matchingvalidateScene, so those scenes persist and the gate stays fail-loud for the app's shapes. - Runtime layer.
RuntimeStoreis partitioned by(stageId, learnerKey): a stage has many sessions — one or more per learner — so every listing is partition-scoped (there is deliberately no global listing; single-session operations are id-keyed, andmergeLearneris the one deliberate cross-stage sweep). Sessions are born stamped: the store writesruntimeDslVersionitself atcreateSession, and the runtime line has no unversioned epoch, so an unstamped row fails loud instead of being lifted like a legacy document. Records are append-only ordered facts under an active session; the store assigns the per-session monotonicseqon append — the sole replay ordering key, never timestamps. Record payloads are gated per kind by injectable validators, defaulting to the DSL skeleton guards forchat/quizAttempt(playbackand app-defined kinds carry app-owned payloads).mergeLearnerre-keys an anonymous learner's sessions to a signed-in key across all stages;deleteLearnerRuntimecascades one learner's sessions + records on one stage, anddeleteStageRuntimeclears a whole stage — the hook a document deletion cascades through. deleteAllRuntimeclears every runtime session and record for explicit whole-cache reset flows.
Upgrading from 0.1.x
Version 0.2.0 removes BrowserAssetProvider outright; it no longer ships. The
asset API is now BrowserAssetStore, whose refs are allocated ids and whose
data lives in the new maic-asset-pool database.
Reusing a custom dbName created by a 0.1.x provider raises an explicit
legacy-schema error rather than corrupting data or operating only partially.
BrowserAssetStoredeliberately does not read data written by the 0.1.x provider inmaic-assets. Its content-addressedsha256-refs are no longer outward references, and the contract suite pins sha256-shaped refs as misses; a silent read-through would restore the reference model this release removes.- If persisted 0.1.x data must be carried forward, open an issue. The supported shape is an explicit one-time import helper that enumerates old rows, allocates an id per blob, and returns an old-ref-to-new-id mapping for the caller to apply to its documents.
Backend equivalence
Each primitive has one implementation-agnostic contract suite
(test/kv-contract.ts, test/asset-contract.ts, test/document-contract.ts,
test/runtime-contract.ts).
Every backend is proven by running the same suite against it, so browser, HTTP,
and PostgreSQL implementations cannot silently diverge from a primitive's
semantics. Assets use the single test/asset-contract.ts suite for the
allocated-id store: identical bytes never share a caller-visible id. Asset
backends must let the suite temporarily instrument the production allocation
source while constructing the store through the same factory used by every
other contract test. This proves that every successful put consumes exactly
one allocator output, independent of whether the bytes already existed, without
adding a caller-configurable allocation path.
Roadmap
- [x]
KVStore+ browser backend; zustandpersistadapter - [x]
StorageProvider(in@openmaic/dsl) + browser asset registry: allocatedAssetIdover an embedded byte table inBrowserAssetStore(#1007) - [x] implementation-agnostic contract suites
- [x]
DocumentStore(aggregate ↔ normalized adapter, migrate-on-read via the DSL migration registry, validation gate) + browser backend - [x]
RuntimeStore(sessions + append-only records, runtime version line, per-kind payload gate) + browser backend - [x] wire the app's settings + user-profile
persiststores throughKVStore(bothaccountscope). No automatic migration of pre-cutover data: new data persists throughKVStore, legacylocalStoragekeys are ignored (not migrated) and best-effort purged, and a user reconfigures once on upgrade - [ ] wire the app's third
persiststore (agent-registry-storage), still on zustand's defaultlocalStorage - [ ] wire the app's remaining ad-hoc
localStoragekeys throughKVStore - [ ] a hydration gate the app actually consumes — required before an
accountscope can be served remotely. With the browser backend, hydration resolves within microtasks of module evaluation and nothing observes it; a network round trip makes the gap visible, and the one-shot decisions taken against a not-yet-hydrated store (classroom agent-selection restore, media orchestration, scene-generator retry, server-provider reconcile) decide wrongly and then have their corrective writes refused - [x] RuntimeStore HTTP backend + reference server + HTTP contract
- [x] RuntimeStore PostgreSQL backend
- [x] DocumentStore HTTP backend + reference-server routes + HTTP contract
- [x] DocumentStore PostgreSQL backend
- [x]
KVStore(account) HTTP backend + HTTP contract - [ ]
KVStoreserver-side reference backend and reference-server route - [ ] asset server backend — registry (principal column, server-derived) + replaceable blob storage, over the global resource pool model (#1007), with consistency enforced server-side. It must validate or allowlist content types before serving bytes rather than reflecting cross-principal metadata into response content types
- [ ] asset manifest: the one enumeration of "which
AssetIds does this course reference?" the export paths converge on (#1007)
License
MIT
