Store
@civility/store is the reactive, offline-first state layer every Civility app is built on. Sync and share build on it, but it’s useful alone:
- Schema + migrations, so local data survives your app’s structures changing.
- A change history, which is what makes offline edits mergeable across devices.
- Pluggable backends — LocalStorage, IndexedDB, in-memory, Deno FS.
Because the server is agnostic to your schema and your app, migrations run on the client rather than server-side. The store is what makes that work: it runs fully offline, with occasional schema updates and merges.
Data model and merge semantics: store spec. Full API:
jsr.io/@civility/store/doc.
1. Install #
deno add jsr:@civility/store
deno add jsr:@civility/store/idb
2. Entities #
Three storage entities:
| Entity | For | Example |
| ————————————————————— | ————————————— | —————— |
| Property | One primitive (a flag, a counter). | isFavorited |
| Document | One JSON object (settings, profile). | settings |
| Collection | Many keyed records that grow and merge. | items, recipes |
Each can be constructed on its own, but outside a Store you lose schema versioning, import/export, and the Civility services that build on them.
import { Collection, DocumentStore } from '@civility/store'
import { IDBStorage } from '@civility/store/idb'
const storage: DocumentStore<Todo> = new IDBStorage({ dbName: 'my-app' })
const todos = new Collection<Todo>(storage, { name: 'todos' })
await todos.set('todo-1', { title: 'Buy milk', done: false })
const todo = await todos.get('todo-1')
await todos.update('todo-1', { done: true })
await todos.delete('todo-1')
3. Create a store #
Store is the top-level container. It holds the three kinds of entity over one storage backend and manages their schema versions as JSON Schema. If using Zod, you can generate the schema via z.toJSONSchema()
import { DocumentStore, Store, StoreConfig } from '@civility/store'
import { IDBStorage } from '@civility/store/idb'
const APP_ID = 'me.example.myapp'
const documentStore: DocumentStore<unknown> = new IDBStorage({ dbName: APP_ID })
const config: StoreConfig = {
properties: [],
documents: ['settings'], // access methods are generated via name
collections: ['items'],
versions: [{
version: '0.0.1',
schema: {
type: 'object',
properties: { settings: { type: 'object' }, items: { type: 'object' } },
},
}],
}
const store = new Store(documentStore, config)
const settings = store.document<Settings>('settings', { colorScheme: 'auto' })
console.log((await settings.get()).colorScheme)
A Document<T> holds one object and get() returns the whole T — never
null, since the defaultValue stands in until something is written.
Store-level handles and lifecycle:
store.property<T>(name, defaultValue): Property<T> // T = string | number | boolean
store.document<T>(name, defaultValue): Document<T>
store.collection<T>(name): Collection<T>
store.ready(): Promise<void>
store.subscribe((entity, id, data, change?) => void): Subscription
store.batch(fn): Promise<void> // group writes into one change
store.export(options?): Promise<StoreExport> // whole-store JSON snapshot
store.import(data, options?): Promise<ImportResult>
store.deleteAll(): Promise<void> // Delete content and register everything as deleted (Use this if you need to sync deletes)
store.clearAll(): Promise<void> // Completely wipe all the data
store.dispose(): Promise<void>
4. Schema migrations #
The versions array allows apps to evolve their data without breaking existing users:
const store = new Store(documentStore, {
documents: ['settings'],
collections: ['recipes'],
versions: [
{ version: '1.0.0', schema: v1Schema },
{
version: '2.0.0',
schema: v2Schema,
from: '1.0.0',
rename: { 'recipes.title': 'recipes.name' }, // declarative field/entity rename
migrate: { recipes: (r) => ({ ...r, servings: r.servings ?? 1 }) }, // per-entity transform
},
],
})
Rules of thumb:
versionties a schema to a release. You can useglobalThis.__CIVILITY__?.version(injected by@civility/cli) to track your app’s current version automatically.fromlinks a version to its predecessor, forming a migration chain the store walks on upgrade.renamehandles field/entity renames declaratively (dottedentity.fieldpaths) so history and in-flight changes follow the rename.migrateis a per-entity transform ((data, ctx) => data, may be async) for anything a rename can’t express — new required fields, reshaping, backfills.ctx.storeis the owning store, so a migration that fans data out (say, extracting aningredientslist into its own collection) writes sibling entities directly instead of smuggling handles through module state.- Migrations are forward-only and additive in spirit. Don’t drop data that a still-syncing older client might send.