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

@a16n/pact-client

v1.2.0

Published

Local-first document store with optional sync, realtime, blobs, migrations, and end-to-end encryption — the client half of Pact.

Downloads

268

Readme

Pact

A general purpose client library for document-based data storage. Local-only by default, with sync and realtime capabilities built in.

npm install @a16n/pact-client

Pact deliberately trades generality for simplicity:

  • A small, high-trust group. One server per app group (e.g. a household). Auth is one shared app password traded for per-client tokens; there's no per-document access control.
  • Last-write-wins is good enough. Conflicts resolve by updatedAt. No CRDTs, no merge UIs.
  • Schemas are required, and owned by you. The collection definitions you hand the store are the set of collections that exist — writes validate against them, and undefined collections are rejected.

What you definitely need to know

Creating a store

Pact uses a store which you can interact with. Create a store like so:

import { Store } from '@a16n/pact-client';

const databaseAdapter = new InMemoryAdapter();

const store = await Store.create({
  adapter: databaseAdapter,
  collections: [todos],
});

The adapter lets pact use the storage backend of your choice. Recipes are provided here that you can copy+paste for common storage backends (localStorage, indexedDb, sqlite, file system, ...).

Alternatively implement your own: see DatabaseAdapter for a full reference. Everything else a store can take (blobs, encryption, hooks) is on StoreOptions.

Creating collections

Now you'll need some collections, to continue our earlier example:

const todos = defineCollection({
  name: 'todos',
  idPrefix: 'td',
  schema: (base) =>
    base.extend({
      title: z.string().min(1),
      done: z.boolean().default(false),
    }),
});

The schema can be any zod schema extending the base schema. Every document id carries the collection's prefix (td-Ab3xY9kQz2) — enforced at runtime and in the type system, so passing a todo id where a recipe id belongs is a compile error. See CollectionConfig for the full set of options.

All the CRUD you'd expect

Documents are read and written through a collection handle, fully typed from the schema:

const todosCollection = store.collection('todos');

// Create one — the id is generated for you (pass `id` to choose your own)
const todo = await todosCollection.create({ title: 'Do laundry' });

// Find one
todosCollection.get('td-123'); // returns null if not found

// List all
todosCollection.list();

// Update one — a partial merge: fields you omit are left alone
todosCollection.update('td-123', { title: 'Do laundry' });

// Create-or-update by id
todosCollection.upsert({ id: 'td-123', title: 'Do laundry' });

// Delete one (soft: a tombstone remains, so the delete syncs)
todosCollection.delete('td-123');

Each of create/update/delete has a ...Many batch form, get/list accept { includeDeleted: true } to see tombstones, and pull/pullAll fetch fresh from a sync server. See Collection for the full reference.

Reacting to changes

Every mutation — local writes, pulled changes from other devices, blob activity — emits a change event with the collection that changed. Hang your UI invalidation off it:

store.on('change', (collection) => {
  // re-read whatever your UI shows from that collection
});

That's all you strictly need to know. Read on for more features that you'll probably need

What you might need to know

Migrations

Sooner or later you'll want to change a schema. Pact supports migrations, which do exactly this.

We actually wanted done to be called completed, so we can add a migration to rename the field:

const todos = defineCollection({
  name: 'todos',
  idPrefix: 'td',
  schema: (base) =>
    base.extend({
      title: z.string().min(1),
      completed: z.boolean().default(false),
    }),
  migrations: {
    current: 2,
    migrations: [
      {
        from: 1,
        to: 2,
        up: (doc) => {
          doc.completed = doc.done;
          return doc;
        },
      },
    ],
  },
});

Old documents upgrade lazily as they're read. Note that you don't always need a migration. If you add a new field with a default value, they will still parse correctly

Syncing

Point the store at a pact server and the same CRUD code syncs, with offline writes queued durably:

await store.sync.register(url, appPassword, 'myapp', "Alice's laptop"); // once per install
await store.author.set('us-alice'); // claim who this device writes as
await store.author.reassignLocal('us-alice'); // adopt any pre-identity writes

await store.sync.push(); // drain queued writes, push everything
await todosCollection.pullAll(); // pull everyone else's changes

Registration persists, so future launches reconnect automatically — and when the server advertises realtime, changes from other devices arrive over a WebSocket with no extra code. Everything server-related lives under store.sync; identity under store.author.

Binary blobs

If your app is like most apps, it probably needs to store binary data - images, video, pdfs etc. Blobs are content-addressed: the key is the SHA-256 of the bytes, so writes are idempotent and dedupe is automatic. Pass a BlobAdapter as blobs when creating the store, and declare a blobHashes extractor so pact knows which blobs your documents reference:

const store = await Store.create({
  adapter: databaseAdapter,
  blobs: myBlobAdapter, // a BlobAdapter — recipes provided, or implement the 7-method interface
  collections: [photos],
  blobHashes: blobFields({ photos: ['imageHash'] }), // which fields hold blob references
});

// Write bytes, reference them from a document by hash
const hash = await store.blobs.write(jpegBytes, 'image/jpeg');
await store.collection('photos').create({ caption: 'Sunset', imageHash: hash });

// Render it
const uri = store.blobs.uri(hash); // e.g. file://… — null if not local yet

// Syncing (with a registered server)
await store.blobs.push(); // upload blobs the server doesn't have
await store.blobs.pullReferenced(); // download blobs your docs reference but you don't hold
await store.blobs.prune(); // locally delete blobs no live doc references

The blobHashes extractor is what makes pullReferenced and prune possible — without it pact can't tell a referenced blob from an orphan. blobFields covers flat fields; write the function by hand for nested references.

What you probably don't need to know

Backups

Pact can pack every document (and optionally blobs) into a single portable archive, independent of any server:

const bytes = await store.backup.create(); // persist however you like
await store.backup.restore(bytes); // merge (last-write-wins)
await store.backup.restore(bytes, { mode: 'replace' });

End-to-end encryption

Pass encryption: { cipher } when creating the store and domain fields are sealed into ciphertext — at rest locally and on the sync wire; the server only ever sees base sync fields (ids, timestamps, authors) plus the envelope:

const key = await deriveEncryptionKey(passphrase, 'myapp');
const store = await Store.create({
  adapter: databaseAdapter,
  collections: [todos],
  encryption: { cipher: createWebCryptoCipher(key) },
});

Wrong keys fail fast at startup. createWebCryptoCipher covers Node/web/Workers; React Native apps inject their own two-method DocCipher. All clients of the app must hold the same key — losing it loses the server-side data. Key management lives under store.encryption.

Seeds

store.seed() loads versioned reference data identically on every client without syncing it — system-authored docs that user edits always win over.

Indexes

Most of the time, calling collection.list() and filtering after the fact is good enough. If you really need to, you can declare indexes on a collection with .withIndexes() and then query by index value:

const todos = defineCollection({
  name: 'todos',
  idPrefix: 'td',
  schema: (base) =>
    base.extend({
      title: z.string().min(1),
      done: z.boolean().default(false),
      tags: z.array(z.string()).default([]),
    }),
}).withIndexes({
  done: (doc) => doc.done, // doc is fully typed here
  tags: (doc) => doc.tags, // multi-valued: indexed under each tag
});

// Then query by index — index name and value are both checked at compile time
const doneTodos = await store.collection('todos').listByIndex('done', true);
const urgentTodos = await store.collection('todos').listByIndex('tags', 'urgent');

An extractor returns the key(s) a doc is found under — one value, an array (each indexed separately), or [] to exclude it. Indexes are local, in-memory derived state: computed from the decrypted, migrated docs, rebuilt on every Store.create, and never persisted or synced. Equality/membership only — no ranges or sorting. listByIndex resolves matches through getMany, so results are always migrated and tombstone-free even if the index lags.

Again, you probably don't need this.

License

UNLICENSED — published for the author's own projects; no rights granted for other use.