@ontrails/store
v0.2.3
Published
Schema-derived persistence for Trails.
Readme
@ontrails/store
Schema-derived persistence for Trails.
The root package owns the backend-agnostic store(...) declaration. External adapter packages such as @ontrails/drizzle bind that declaration to a concrete runtime, and first-party built-ins such as @ontrails/store/jsonfile live as opt-in subpaths on the same package.
The two layers
1. Declare the store contract
import { store } from '@ontrails/store';
export const db = store({
gists: {
schema: gistSchema,
identity: 'id',
generated: ['id', 'createdAt', 'updatedAt'],
indexed: ['owner', 'createdAt'],
versioned: true,
},
files: {
schema: fileSchema,
identity: 'id',
generated: ['id'],
references: { gistId: 'gists' },
},
});This declaration is pure metadata:
- full entity schema
- insert schema
- update schema
- fixture schema
- derived change-signal handles (
table.signals.created|updated|removed) - identity field
- generated-field metadata
- optional framework-managed version tracking
- indexed markers
- references
No database connection is opened here. The returned value is the durable authored source of truth.
2. Bind it to a concrete runtime
import { store } from '@ontrails/store';
import { connectDrizzle } from '@ontrails/drizzle';
const definition = store({
gists: {
schema: gistSchema,
identity: 'id',
generated: ['id', 'createdAt', 'updatedAt'],
},
});
export const db = connectDrizzle(definition, {
id: 'db.main',
url: ':memory:',
});The bound store is a resource. Use it directly in trails:
export const list = trail('gist.list', {
resources: [db],
intent: 'read',
implementation: async (_input, ctx) => {
const conn = db.from(ctx);
const gists = await conn.gists.list();
return Result.ok(gists);
},
});Built-in local backend
For a zero-extra-package local backend, use the first-party JSON file binding:
import { store } from '@ontrails/store';
import { jsonFile } from '@ontrails/store/jsonfile';
const definition = store({
gists: {
schema: gistSchema,
identity: 'id',
generated: ['id', 'createdAt', 'updatedAt'],
},
});
export const db = jsonFile(definition, {
dir: './data',
});Typed accessors
Every writable table on a bound connection exposes the backend-agnostic accessor contract:
const conn = db.from(ctx);
const created = await conn.gists.upsert({
ownerId: 'matt',
description: 'Hello, Trails',
});
const found = await conn.gists.get(created.id);
const page = await conn.gists.list({ ownerId: 'matt' }, { limit: 20, offset: 0 });
const updated = await conn.gists.upsert({
description: 'Updated description',
id: created.id,
ownerId: 'matt',
});
const removed = await conn.gists.remove(created.id);Types are derived from the Zod schema:
upsert()uses the fixture/entity shape with generated fields optionalget()returnsEntity | nulllist()accepts typed partial filters and pagination optionsversioned: trueadds a framework-managedversionfield to returned entities and letsupsert()accept an expectedversionfor optimistic concurrency
Each normalized table also derives typed change signals from the same schema:
const createdHandle = definition.tables.gists.signals.created;
const updatedHandle = definition.tables.gists.signals.updated;
const removedHandle = definition.tables.gists.signals.removed;These pre-bind handles preserve payload shape, but the canonical signal id materializes only when an adapter binds the store to a resource. The bound form is always resource:table.change:
const created = db.store.tables.gists.signals.created;
created.id;
// "db.main:gists.created"Adapter Support Subpath
Adapter authors who bind a store(...) definition to a concrete backend should import signal-binding helpers from @ontrails/store/adapter-support:
import { bindStoreDefinition } from '@ontrails/store/adapter-support';The subpath owns bindStoreDefinition, createStoreTableSignals, composeStoreSignalId, isValidResourceId, and StoreSignalChange. The root package stays focused on backend-agnostic store contracts.
Writable bindings fire those canonical scoped signals automatically when you access the resource through db.from(ctx) inside a trail context.
See Store Signal Identity Migration when updating existing on: clauses, surface-map fixtures, or custom resource wrappers from bare ids to scoped ids.
Tabular adapters such as @ontrails/drizzle also expose insert() and update() as convenience methods when the backend natively distinguishes create and patch operations.
Fixtures and mocks
Fixtures belong on the root definition:
export const db = store({
gists: {
schema: gistSchema,
identity: 'id',
generated: ['id', 'createdAt', 'updatedAt'],
fixtures: [
{ id: 'g_1', ownerId: 'matt', description: 'Seed gist' },
],
},
});When an adapter binds the store, those fixtures feed the resource mock automatically. Adapter options can also add or override seed data for tests.
That means testAll(app) can auto-resolve adapter-bound store resources without extra ceremony, as long as the resource is registered in the topo.
Read-only bindings
Use the Drizzle adapter's read-only binding when a trail should inspect persisted state without exposing writes:
import { connectReadOnlyDrizzle } from '@ontrails/drizzle';
const analytics = connectReadOnlyDrizzle(definition, {
id: 'analytics.db',
url: './data/analytics.sqlite',
});Read-only bindings expose get(), list(), and query(), but not upsert(), remove(), insert(), or update().
Accessor contract testing
Adapters can reuse the shared writable-accessor contract tests from @ontrails/store/testing:
import { createStoreAccessorContractCases } from '@ontrails/store/testing';That helper provides reusable cases for the baseline get(), list(), upsert(), and remove() behavior so adapter suites only need to wrap them with their normal test(...) calls and add backend-specific coverage on top.
Drizzle escape hatch
Complex queries use the adapter-native query builder through query():
const conn = db.from(ctx);
const rows = await conn.query(({ drizzle, tables }) =>
drizzle
.select()
.from(tables.gists)
);This keeps the default happy path derived and typed, while still giving you full access to the underlying adapter when the CRUD accessors are not enough.
Adapter binding
@ontrails/drizzle keeps the durable store(...) declaration in @ontrails/store and binds it to a concrete runtime:
import { connectDrizzle, connectReadOnlyDrizzle } from '@ontrails/drizzle';
import { store } from '@ontrails/store';
const definition = store({
gists: {
schema: gistSchema,
identity: 'id',
generated: ['id', 'createdAt', 'updatedAt'],
},
});
export const writable = connectDrizzle(definition, { url: ':memory:' });
export const readonly = connectReadOnlyDrizzle(definition, {
url: './data/gists.sqlite',
});The root package still owns the authored persistence model; adapter packages render that model into runnable resources.
Schema export for external tooling
If you need the raw derived Drizzle tables for tooling such as drizzle-kit, read them from the bound resource's tables field:
import { connectDrizzle } from '@ontrails/drizzle';
import { store } from '@ontrails/store';
const definition = store({
gists: {
schema: gistSchema,
identity: 'id',
generated: ['id', 'createdAt', 'updatedAt'],
},
});
const db = connectDrizzle(definition, { url: ':memory:' });
const schema = db.tables;Installation
These installation examples target Trails 0.2.1 on the normal npm release line.
bun add --exact @ontrails/[email protected] zodAdd Drizzle only when you want the external SQLite/ORM adapter:
bun add --exact @ontrails/[email protected]Migration
The Drizzle binding now lives in @ontrails/drizzle.
- Replace
import { ... } from '@ontrails/store/drizzle'withimport { ... } from '@ontrails/drizzle' - Keep backend-agnostic store declarations on
@ontrails/store
