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.
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.
- One app, one stack: the app embeds
adapter-localand 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 againstlocalhostor 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.
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.
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 |
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();An app has two layers, because the stack draws a line between them:
- A data layer that takes a
StackClient— the record APIStackandScopedStackboth 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()andgrantType()change the whole stack rather than one record, so they live onStackalone and are absent fromStackClient. Over the wire,POST /typesandPOST /records/:id/migrateare 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.
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
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).
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, permissioned0600, 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
CryptoKeyobject 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 theCryptoKeydirectly (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 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.
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' } }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.
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 andrestoreVersion()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.
The adapter interface is split into StackRecordAdapter (structured records) and StackBlobAdapter (binary files). Packages follow a naming convention that makes the type clear:
adapter-*— fullStackAdapter(convenience packages that cover both halves)record-adapter-*—StackRecordAdapteronlyblob-adapter-*—StackBlobAdapteronly
| 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.
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 typecheckSee 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.
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.
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.
haverstack/server— reference server implementation
CC0 1.0 Universal — public domain. No rights reserved.