Skip to content

Repository files navigation

Haverstack

A portable personal data stack. Take your data with you.

Haverstack is a structured data store for individuals and small organizations. Apps write Records into your stack — and the stack handles storage, querying, versioning, and permissions, regardless of where data actually lives.

Status: Early development. APIs are unstable.


What is a stack?

A stack is a personal or organizational data store. It belongs to one Entity (a person or org) and holds Records — structured data objects that apps create and read.

The key idea: apps talk to the Haverstack library, not to a storage format directly — switch backends without changing your app's data-access code. That said, storage ownership is exclusive: a stack file is owned by exactly one process at a time.


How apps share a stack

  • One app, one stack: the app embeds adapter-local and owns the file directly. This is the simple, common case.
  • Multiple apps, one stack: run a server (local or hosted) that owns the file, and have each app connect through adapter-api — the same client works against localhost or a remote provider.

Don't point more than one app at the same stack file with adapter-local. Nothing enforces permissions at that layer — appId is self-reported and grants aren't checked — so direct file access is a full-trust, single-owner arrangement, not a way to share data between apps. See Concurrency & storage ownership in the spec for the full rationale.

Containing an app you don't fully trust

Through a server, an app you install can be given its own identity instead of running as you. It mints a did:key keypair, authenticates with it, and reaches only the types you grant it:

// One-time, from the owner's side: name the app, then grant it types.
await stack.create('_app@1', {
  appId: 'com.example.notesapp', // the software this card is about
  name: 'My Notes App',
  did: notesAppDid, // the keypair the app generated at install
});

await stack.grantType('com.example.myapp/note', {
  actions: ['create', 'read-own', 'update-own', 'delete-own'],
  grantee: { kind: 'entity', entityId: notesAppDid },
});

An app can instead ship those steps as a manifest — its types and the grants it asks for — which the owner reviews and applies in one call. The stack keeps the approval as an _install record, so the grants it made can be listed, upgraded and withdrawn together, and the app can migrate its own types without the owner running its code. See App installs.

const plan = await stack.planInstall(manifest, { did: notesAppDid }); // show this to the owner
await stack.installApp(plan);

The containment is the type list, not the -own suffix. When a delegated app acts for someone, -own is read as the bare verb and the subject decides which records are in reach — so in a personal stack, where nearly everything is owner-authored, read-own is close to read-any. Grant an app the types it needs and no more.

On the app's side, connecting is the keypair plus a URL. APIAdapter performs the challenge–response handshake on open and re-runs it whenever the token expires, so there is no token to obtain, store, or refresh by hand:

import { APIAdapter } from '@haverstack/adapter-api';
import { didCredentialFromKeypair } from '@haverstack/core/wire';

const adapter = await APIAdapter.open({
  url: 'https://stack.example.com',
  credential: didCredentialFromKeypair(appKeypair), // or your own { did, sign }
  ownerEntityId: ownerDid, // refuse a server claiming to be someone else's stack
});

A plaintext http:// URL to anything but loopback is refused: the token, the handshake signature and every record would travel in the clear. localhost and 127.0.0.1 need no flag, so a local server is unaffected; pass allowInsecure: true if the transport is already private (a tunnel, a private network you control).

credential is a signing callback, not a private key — key custody stays with the app, so it can be backed by a hardware key or a keychain prompt just as easily as by a keypair in memory.

Server-side, a token resolves to two identities, and when an app acts for a person rather than for itself the server names them both — authority becomes the intersection of what the app may do and what that person may do, while authorship stays with the person:

const session = await tokens.lookupToken(bearer); // { subjectId, principalId }
const scoped = stack.asActor(session);

The delegation itself — "this app acts for Bob" — is asserted by you when the token is issued, not by the app: proving key possession proves who the app is and nothing about whom it may speak for. An app that could name its own subject would be choosing its own authority.

You don't do this for software you didn't choose. An app someone else uses to reach your stack — a visitor's own client posting a comment — authenticates as them, and is bounded by what you granted people, typically a default grant. You grant types to people, never to every client they might be running. See Identity § App for both postures and Access control § Delegation for what each identity governs; the handshake itself is Wire format § Authentication.


Packages

This is a monorepo. Packages are published to npm under the @haverstack scope.

Package Description
@haverstack/core Stack class, types, schema, validation, ID generation
@haverstack/adapter-local Local adapter (native SQLite + disk) — single-app/embedded or server use
@haverstack/record-adapter-sqlite Node native SQLite (node:sqlite) StackRecordAdapter — used by adapter-local
@haverstack/record-adapter-do-sqlite Cloudflare Durable Objects (SQLite storage) StackRecordAdapter — Workers
@haverstack/blob-adapter-disk Disk filesystem StackBlobAdapter
@haverstack/blob-adapter-s3 S3 (and S3-compatible, e.g. Cloudflare R2) StackBlobAdapter
@haverstack/adapter-api HTTP adapter for remote stack servers
@haverstack/commons Canonical Schema Commons type definitions (note, task, contact, ...)
@haverstack/wire-types HTTP wire types, error mapping and serialization — for server implementers
@haverstack/conformance-fixtures Request/response fixtures a server can test its wire implementation against
@haverstack/adapter-conformance Runnable vitest suite an adapter implementation can test itself against

Planned:

Package Description
@haverstack/adapter-json JSON file storage adapter

Quick start

import { Stack, typeHandle } from '@haverstack/core';
import { generateDidKeypair, exportDidPrivateKeyJwk } from '@haverstack/core/did';
import { LocalAdapter } from '@haverstack/adapter-local';
import { writeFile } from 'node:fs/promises';

const dbPath = './my-stack.db';
const keyPath = './my-stack.key.json'; // see "Key custody" below for where this really belongs

// First run: neither file exists yet, so this generates an identity
// keypair and persists the private key before creating the store. Every run
// after that: the db exists, so this just opens it — the ownerEntityId
// function below is never called, so no throwaway keypair is minted.
const adapter = await LocalAdapter.open({
  path: dbPath,
  create: 'ifMissing',
  timezone: 'America/New_York',
  ownerEntityId: async () => {
    const { did, privateKey } = await generateDidKeypair();
    await writeFile(keyPath, JSON.stringify(await exportDidPrivateKeyJwk(privateKey)));
    return did;
  },
});

// ownerProfile creates your own _entity profile record on first run —
// safe to keep passing on every open, it's a no-op once the record exists.
const stack = await Stack.open(adapter, { ownerProfile: { name: 'Jane Smith' } });

// Define a type. The handle carries the id and schema, and the compiler
// derives the content type from it — no separate interface to keep in step.
const Note = typeHandle('com.example.myapp/note@1', {
  text: { kind: 'text', required: true },
  title: { kind: 'string' },
});
await stack.defineType({ ...Note, name: 'Note' });

// Create a record
const note = await stack.create(Note, {
  text: 'Hello, Haverstack!',
  title: 'My first note',
});

// Update its content (partial merge — only changed fields needed)
await stack.patchContent(Note, note.id, { title: 'Updated title' });

// Read it back, typed: `content.text` is a string
const same = await stack.get(Note, note.id);

// Tag it
await stack.associate(note.id, [{ kind: 'tag', label: 'favourite' }]);

// Or change several things at once — one version, one atomic write
await stack.mutate(note.id, {
  contentPatch: { title: 'Final title' },
  permissions: [{ kind: 'anyone', label: 'read' }],
  unlisted: false,
});

// Query. With no filter, query() returns every record you can read, from every
// app and system types included, so filter by typeId, baseId or appId to get your own.
const notes = await stack.query({
  filter: { typeId: 'com.example.myapp/note@1', tags: ['favourite'] },
  sort: { field: 'createdAt', direction: 'desc' },
});

// Tear down when done (flushes pending writes and releases resources)
await stack.close();

Writing an app

An app has two layers, because the stack draws a line between them:

  • A data layer that takes a StackClient — the record API Stack and ScopedStack both implement. The same code then runs embedded as the owner, or behind a server as a requester who reaches only what they were granted.
  • An install function that takes a Stack, run by the owner. Defining types, migrateAll() and grantType() change the whole stack rather than one record, so they live on Stack alone and are absent from StackClient. Over the wire, POST /types and POST /records/:id/migrate are served to the owner acting alone.

registerMigration() belongs to neither. Its registry lives in memory on each Stack instance, so it runs at every startup, right after Stack.open() — an install function that registers migrations and runs once leaves every later start without them.

import { Stack, typeHandle, type StackClient } from '@haverstack/core';

const NoteV1 = typeHandle('com.example.myapp/note@1', {
  text: { kind: 'text', required: true },
});
export const Note = typeHandle('com.example.myapp/note@2', {
  text: { kind: 'text', required: true },
  pinned: { kind: 'boolean', required: true },
});

// Data layer: whoever the stack lets in.
export class Notes {
  constructor(private readonly client: StackClient) {}
  add(text: string) {
    return this.client.create(Note, { text, pinned: false });
  }
  pin(id: string) {
    return this.client.patchContent(Note, id, { pinned: true });
  }
  list() {
    return this.client.query(Note);
  }
}

// Startup: every open, every Stack instance.
export function registerNoteMigrations(stack: Stack) {
  stack.registerMigration({
    from: NoteV1.id,
    to: Note.id,
    migrate: (content) => ({ ...content, pinned: false }),
  });
}

// Install: the owner, once per stack and again after a schema change.
// defineType() is a no-op for a schema already stored, so re-running is safe.
export async function installNotes(stack: Stack, appDid?: string) {
  await stack.defineType({ ...NoteV1, name: 'Note' });
  await stack.defineType({ ...Note, name: 'Note', migratesFrom: NoteV1.id });
  await stack.migrateAll(Note.baseId);
  if (appDid) {
    await stack.grantType(Note.baseId, {
      actions: ['create', 'read-own', 'update-own', 'delete-own'],
      grantee: { kind: 'entity', entityId: appDid },
    });
  }
}

The owner's own app calls registerNoteMigrations(stack) and installNotes(stack), then new Notes(stack). A server hands each requester new Notes(stack.asActor(session)). An app that isn't the owner has no way to install its own types yet; the owner runs its install function for it.


Core concepts

Records

The fundamental unit of data. Every record has:

  • A Crockford base-32 ID — time-sortable, human-readable, URL-safe
  • A type — defined by the app that created it
  • Content — a JSON object validated against the type's schema
  • Optional: parentId, createdBy, appId, permissions, associations

Identity

Every identity — a record's author, permissions, grants, group membership, the stack owner — is a DID string, e.g. did:key:z6Mk.... An identity is a keypair; there's no provider, directory, or domain to trust. did:key (a public key, encoded — nothing else) is the mandatory floor; generateDidKeypair() mints one. Other DID methods (did:web, did:plc, ...) are valid entityId values too.

The _entity record type is a local profile about a DID, not the identity itself — a petname card ({ did, name, handle? }) with a display name you chose for that DID. Two stacks can hold different _entity cards with different names for the same DID; that's correct, it's each owner's own contact card. Stack.open(adapter, { ownerProfile }) creates the owner's own card on first run.

See Identity in the spec for the full model, including authentication (challenge–response, not a shared secret) and what's deliberately deferred (key rotation).

Key custody

generateDidKeypair() returns a privateKey; nothing in @haverstack/core or any adapter stores it — only the public did travels with stack data. Where the key lives, and how it survives a reinstall, is entirely on you. Some starting points:

  • Node / server — write the JWK (exportDidPrivateKeyJwk()) to a file outside version control, ideally encrypted at rest (e.g. via your OS keychain, or a secrets manager if the process runs on infrastructure you don't hold in your hands). A bare unencrypted file on disk, permissioned 0600, is the honest floor for local dev.
  • Desktop (Electron, Tauri, ...) — use the platform keychain binding your framework exposes (e.g. Electron's safeStorage, or the OS keychain directly) rather than a plain file; these run in a context with real users and real disks that get imaged and backed up by other software.
  • Browser — store the CryptoKey object itself in IndexedDB instead of exporting to JWK — generateDidKeypair() returns an extractable key, but a browser app never has to extract it. Structured-clone support means IndexedDB can hold the CryptoKey directly (idb.put('keys', privateKey, 'owner')), so the raw key material never touches JS-readable memory as a string.

On every path, reconstruct the key with importDidPrivateKeyJwk() (or read the CryptoKey straight back out of IndexedDB) and hand it to signWithDid() / buildAuthChallengePayload() when authenticating to a server — see Identity § Authentication in the spec.

The asymmetry that makes this matter: losing the key doesn't break anything local — nothing in the stack ever asks for it again, open() only needs the did. But you can never again authenticate as that identity to any server, because there's no recovery path — did:key identity is the key (see Deferred: key rotation). An early "didn't bother persisting it" decision is invisible until the day you want to serve or share the stack, and by then it's permanent. Persist it from the first run, even if you don't yet know why you'd need it.

Types

Types define the schema for a record's content. They are identified by a namespaced, versioned string:

com.example.myapp/note@1

The app author controls the namespace. Two stacks running the same app have the same type IDs and can interop.

Associations

Tags, attachments, and relationships are unified under a single model:

{ kind: 'tag',          label: 'favourite' }
{ kind: 'attachment',   label: 'avatar',   fileId: '...' }
{ kind: 'relationship', label: 'reply-to', target: { kind: 'record', recordId: '...' } }

A relationship's target says which identifier space its value lives in — a Record here or in another stack ({ kind: 'record', recordId, stackUrl? }), a "who" as a DID ({ kind: 'entity', entityId }), or something outside the stack entirely ({ kind: 'external', ns, id }). That last one is how a record points at an ATProto post, an ActivityPub actor, an email address or a plain URL: Haverstack expresses the reference and never dereferences it, so no protocol is privileged.

{ kind: 'relationship', label: 'syndicated-to',
  target: { kind: 'external', ns: 'atproto', id: 'at://did:plc:abc/app.bsky.feed.post/3k4' } }

Migrations

Types can evolve over time. Register migration functions between adjacent versions — the library composes them into chains automatically:

await stack.defineType({
  id: 'com.example.myapp/note@2',
  name: 'Note',
  schema: {
    text: { kind: 'text', required: true },
    title: { kind: 'string', required: false },
  },
  migratesFrom: 'com.example.myapp/note@1',
});

stack.registerMigration({
  from: 'com.example.myapp/note@1',
  to: 'com.example.myapp/note@2',
  migrate: (content) => ({ ...content, title: '' }),
});

Records stay at the version they were written at: get() and query() return them as stored, presentAt: 'latest' migrates them in memory for one read, and stack.migrateAll() commits a family to disk. Register migrations at every startup — see Writing an app.

History, and watching for changes

Every write a record undergoes is recorded twice, in two durable tiers the library keeps for you:

  • Version history — a full snapshot of the record's prior state, taken on every mutation that bumps version. getVersions() reads it and restoreVersion() puts one back. It answers what could be put back.
  • The change journal — one entry per change, naming which aspects moved, who moved them, and the association deltas nothing else retains. getJournal() reads it. It answers what happened.

Both are on the mutate surface, not the read surface: a plain reader of a record is not handed its past. A purge destroys both, which is what makes it the erasure primitive.

const unsubscribe = await stack.subscribe((change) => {
  console.log(change.kind, change.ops, change.recordId);
});

subscribe() reports that something changed; query() and get() report what it now is. Content is the one aspect that bumps version: associations, permissions, containment and listing state are all invertible, so none of them needs a snapshot and each is recovered from the journal instead. Their deltas ride the change event too, save the permission half — a subscriber is told the ACL moved, and reads what it moved to off the record if it may.

Adapters

The adapter interface is split into StackRecordAdapter (structured records) and StackBlobAdapter (binary files). Packages follow a naming convention that makes the type clear:

  • adapter-* — full StackAdapter (convenience packages that cover both halves)
  • record-adapter-* — StackRecordAdapter only
  • blob-adapter-* — StackBlobAdapter only
Package Type Use case
adapter-local full Single-app/embedded or server use — native SQLite records + disk blobs
record-adapter-sqlite record Node native SQLite (node:sqlite) records, FTS5, WAL — used by adapter-local
record-adapter-do-sqlite record Cloudflare Durable Objects (SQLite storage) records, FTS5
blob-adapter-disk blob Content-addressed blobs on the local filesystem
blob-adapter-s3 blob Content-addressed blobs on S3 or an S3-compatible store (e.g. Cloudflare R2)
adapter-api full Hosted/shared stacks via HTTP
adapter-json full Portable JSON files (planned)

Use combineAdapters({ record, blob }) from @haverstack/core/adapter to compose a record adapter with a different blob backend — for example, NativeSQLiteRecordAdapter with S3BlobAdapter. adapter-local wraps this pattern for the common case.


Development

This repo uses pnpm workspaces.

# Install dependencies
pnpm install

# Run all tests
pnpm test

# Build all packages
pnpm build

# Typecheck all packages (requires a build first — packages resolve
# each other through dist/*.d.ts)
pnpm typecheck

See CONTRIBUTING.md for the full pre-push checklist, comment and commit conventions, the architecture conventions this codebase follows, and where things live.

Versions and npm publishes are automated with Changesets: a change that ships to npm carries a pnpm changeset file, and CI turns pending changesets into a release PR whose merge publishes. See CONTRIBUTING.md § Releasing.


Spec

The design spec lives in docs/spec.md, which indexes focused sub-documents under docs/spec/. Together they cover the full data model, adapter contract, wire format, and open questions. If you're building an adapter or a server implementation, start there.

Shared, app-neutral record types (note, task, contact, article, page, …) live in the Schema Commons — start there if you want your app's data to interoperate with other Haverstack apps.


Security

Found a weakness? Report it privately through GitHub Security Advisories. SECURITY.md says what's in scope, and which properties are documented decisions rather than bugs.


Related


License

CC0 1.0 Universal — public domain. No rights reserved.

About

No description, website, or topics provided.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages