npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 optional
  • get() returns Entity | null
  • list() accepts typed partial filters and pagination options
  • versioned: true adds a framework-managed version field to returned entities and lets upsert() accept an expected version for 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] zod

Add 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' with import { ... } from '@ontrails/drizzle'
  • Keep backend-agnostic store declarations on @ontrails/store