Files
Claude cb9ff514a0
CI / Node 22 (push) Successful in 13s
CI / Node 24 (push) Successful in 13s
CI / Node 26 (push) Successful in 14s
CI / Node 22 (pull_request) Successful in 12s
CI / Node 24 (pull_request) Successful in 13s
CI / Node 26 (pull_request) Successful in 14s
CI / Chromium (push) Successful in 46s
CI / Publish to staging registry (push) Successful in 9s
CI / Chromium (pull_request) Successful in 45s
CI / Publish to staging registry (pull_request) Skipped
Signed requests: X-Nonce header; composite: sync skips unconfirmed writes
- Requests carry a random X-Nonce, signed as ${timestamp}:${nonce}:${path}:${body}. The
  server rejects a nonce the same key already used within the timestamp window. Identical
  requests at the same moment (two tabs, parallel reads) are now distinct, replacing the
  per-process increasing-timestamp workaround.
- Composite: a sync no longer reverts keys with local writes the source hasn't confirmed
  yet (in flight or queued); they briefly disappeared when a sync's scan ran before the
  write reached the source. Pending keys are reloaded from a persistent queue on start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
2026-10-23 14:50:16 +00:00

295 lines
12 KiB
Markdown

# @harvmaster/storage
Layered TypeScript storage that works the same in the browser and on the server:
- **KV storage**: ordered keys and values, navigated as a tree (`db.child('users').get('1')`).
In memory, IndexedDB, or over HTTP, with live updates over Server-Sent Events.
- **Event storage**: append-only streams on top of any KV storage.
- **Document storage**: JSON documents with queries, updates (`$set`, `$inc`, …) and indexes, on
top of any KV storage, or event-sourced.
- **Sync**: local replicas that work offline and stay up to date with a server
(`KVStorageComposite`), end-to-end encryption (`KVStorageEncrypted`) and signed requests where
the user's public key is their id.
No runtime dependencies. Node 22+ and current browsers. Values are JSON plus `bigint` and
`Uint8Array`.
> **Status: experimental (0.x).** The API may change between minor versions. See
> [DESIGN.md](./DESIGN.md) for how everything works and its known limitations.
## Install
The package is published to a private registry. Add to your project's `.npmrc`:
```ini
@harvmaster:registry=https://verdaccio.harvmaster.com/
```
```sh
npm install @harvmaster/storage # latest release
npm install @harvmaster/storage@next # `next` branch
npm install @harvmaster/storage@dev-sync # a development branch (`dev-<branch>`)
```
## KV storage
Every store is a tree of nodes. `child(key)` returns a view rooted deeper (no I/O); methods take
an optional key to act on a child instead of the node itself.
```ts
import { KVStorageInMemory } from '@harvmaster/storage'
const db = new KVStorageInMemory()
const users = db.child('users')
await users.set('1', { name: 'Ada' })
await users.child('1').get() // { name: 'Ada' }
await users.get('1') // the same
await users.setMany([
['2', { name: 'Bob' }],
['3', { name: 'Cy' }],
])
await users.getMany(['1', 'missing']) // [{ name: 'Ada' }, undefined]
for await (const key of users.keys({ limit: 10 })) console.log(key) // '1', '2', '3'
for await (const [key, user] of users.entries({ reverse: true })) console.log(key, user)
await users.delete('3')
await users.deleteTree() // users and everything under it
```
A node's own value and its children are independent, so an object can also be stored field per key:
```ts
const settings = db.child('settings')
await settings.setFields({ theme: 'dark', fontSize: 14 })
await settings.get('theme') // 'dark'
await settings.getFields() // { fontSize: 14, theme: 'dark' }
```
Keys sort as strings (`'10'` before `'9'`), and any string or number is a valid key: special
characters are escaped. Every write emits a `change` event:
```ts
db.on('change', ({ changes, origin }) => console.log(origin, changes)) // 'local', [{ key: 'users/1', value: … }]
```
### Types
Describe the tree once to get typed keys and values:
```ts
import { KVStorageInMemory, type Many, type Fields } from '@harvmaster/storage'
type User = { name: string }
type Schema = { children: { users: Many<User>; settings: { children: Fields<{ theme: string; fontSize: number }> } } }
const typed = new KVStorageInMemory<Schema>()
await typed.child('users').set('1', { name: 'Ada' }) // checked
const theme = await typed.child('settings').get('theme') // string | undefined
```
### IndexedDB
```ts
import { KVStorageIndexedDB } from '@harvmaster/storage'
const local = new KVStorageIndexedDB({ name: 'my-app' })
```
## Event storage
Append-only streams. Every node is a stream (its own events) and a parent of other streams.
Appends never conflict: concurrent writers each add their own events, and every reader sees all of
them in the same (time) order.
```ts
import { EventStorageKV, KVStorageInMemory } from '@harvmaster/storage'
const events = new EventStorageKV(new KVStorageInMemory())
const orders = events.child('orders')
const [id] = await orders.append('o1', [{ type: 'created', total: 30 }])
await orders.child('o1').append([{ type: 'paid' }])
for await (const event of orders.read('o1')) console.log(event.id, event.at, event.data)
for await (const event of orders.read('o1', { after: id })) console.log(event.data) // { type: 'paid' }
for await (const key of orders.keys()) console.log(key) // 'o1'
```
Event ids are `<time>_<hash of the time and content>`, so writing the same event again (e.g. a
retried request) can't duplicate it.
## Document storage
```ts
import { DocumentStorageKV, KVStorageInMemory } from '@harvmaster/storage'
const docs = new DocumentStorageKV(new KVStorageInMemory())
const people = docs.child('people')
const id = await people.insert({ name: 'Ada', age: 36, tags: ['math'] })
await people.set('grace', { name: 'Grace', age: 45, tags: ['navy', 'cobol'] })
await people.update(id, { $inc: { age: 1 }, $push: { tags: 'engines' } })
for await (const { key, data } of people.find({ age: { $gte: 37 } }, { sort: { age: -1 }, limit: 10 })) console.log(key, data)
await people.findOne({ tags: 'cobol' }) // { key: 'grace', data: { … } }
await people.delete('grace') // true
```
Filters: `{ field: value }` (dot paths; arrays match any element), `$eq $gt $gte $lt $lte $in`.
Updates: `$set $unset $inc $push $pull $addToSet` (`$each` for push/addToSet).
- `DocumentStorageKV` stores each document as one KV value, so it works over any KV storage
(including a synced one, below). Queries scan the collection.
- `DocumentStorageInMemory({ indexes: { people: ['age'] } })` keeps equality indexes.
- `EventSourcedDocumentStorage(events, projections)` records every change as an event (with
`history(key)` and `getAt(key, eventId)`), and keeps the current state in any document store.
## Sync, offline and encryption
The pieces below combine into an offline-first app whose server only stores ciphertext.
### Server
`KVStorageHttpServer` serves any KV storage over HTTP. `handle(request)` takes a Fetch `Request`
and returns a `Response`, so it works with `Deno.serve`, `Bun.serve`, Cloudflare Workers, Hono,
and so on (in Node, through an adapter such as `@hono/node-server`).
```ts
import { Ed25519Verifier, KVStorageHttpServer, KVStorageInMemory } from '@harvmaster/storage'
const server = new KVStorageHttpServer(new KVStorageInMemory(), {
// Requests must be signed, and a user may only touch keys under their public key.
auth: { verifiers: [new Ed25519Verifier()] },
basePath: '/kv',
allowOrigins: ['https://app.example.com'], // CORS, if the app is on another origin
})
Deno.serve((request) => server.handle(request))
```
`auth: 'none'` turns authentication off (local development only). The server keeps its data in
whichever KV storage it wraps, and executes exactly the writes clients send (last write wins).
### Client
```ts
import { Ed25519Signer, KVStorageComposite, KVStorageEncrypted, KVStorageHttp, KVStorageInMemory, KVStorageIndexedDB } from '@harvmaster/storage'
// The user's identity: keep the private key (e.g. in IndexedDB or the OS keychain).
const signer = await Ed25519Signer.generate() // or Ed25519Signer.fromPrivateKey(saved)
const saved = await signer.exportPrivateKey()
// The encryption key: generate one and store it like the private key, or derive it from a password
// with a random salt created once and stored with the user's data (the salt isn't secret).
const salt = crypto.getRandomValues(new Uint8Array(16))
const key = await KVStorageEncrypted.deriveKey('correct horse battery staple', salt)
const indexedDb = new KVStorageIndexedDB({ name: 'my-app' })
const db = new KVStorageComposite({
source: new KVStorageEncrypted(new KVStorageHttp({ url: 'https://api.example.com/kv', signer }), { key }),
replicas: [new KVStorageInMemory(), indexedDb], // fastest first; the last one persists
queue: indexedDb.child('~sync'), // offline writes, kept across reloads (outside `prefix`)
prefix: signer.publicKey, // only this user's data is synced
})
const me = db.child(signer.publicKey)
await me.child('notes').set('1', { text: 'Works offline' }) // local immediately, then sent
await me.child('notes').get('1') // read locally
db.on('change', ({ changes, origin }) => {
if (origin === 'remote') render(changes) // made on another device
})
```
What the composite does:
- **Reads** come from the first replica once the first sync has completed; after a reload, straight
from IndexedDB, even offline.
- **Writes** go to every replica, then to the server. If the server is unreachable, they're queued
and replayed in order later; `db.queued` is the number waiting. A write the server refuses (e.g. 403) is undone locally and rejected.
- **Changes from other devices** arrive over SSE and are applied, and announced as `change`
events, only where they differ from local values.
- **`db.sync()`** replays queued writes and catches up with the server. It runs on start, whenever
the live connection is restored, and periodically while offline.
- `onError` receives errors with no caller to reject, e.g. a queued write the server later refused
(it's dropped, and the server's value restored).
- `db.close()` stops syncing and closes the live connection.
Documents and events work on top of the composite like any other KV storage:
```ts
type Todo = { title: string; done: boolean }
const todos = new DocumentStorageKV<{ children: { todos: Many<Todo> } }>(me).child('todos')
await todos.insert({ title: 'Ship it', done: false })
for await (const { data } of todos.find({ done: false })) console.log(data.title)
```
### Encryption
`KVStorageEncrypted` wraps any KV storage and AES-GCM encrypts each value with a fresh IV; the
key a value is stored under is authenticated too, so the server can't move values around.
- **Keys are not encrypted**: the server needs them for authorization and ordering. Don't put
secrets in keys. Value sizes and write times are visible as well.
- `generateKey()`, `exportKey(key)` / `importKey(raw)`, or `deriveKey(password, salt)` (PBKDF2;
store the random salt alongside the data, it isn't secret).
- Losing the key means losing the data. There is no recovery.
### Signed requests
Every request is signed with the user's key: `X-Public-Key`, `X-Timestamp`, `X-Nonce` (random
per request), `X-Signature` and `X-Signature-Algorithm` headers, over
`${timestamp}:${nonce}:${path}:${body}`. The server checks the signature, a 60 s timestamp
window and that the nonce wasn't used before (replay protection), then that every key is the
public key or under it. Ed25519 is built in (WebCrypto); other algorithms plug in by implementing
`RequestSigner` / `SignatureVerifier`.
### Live updates without the composite
```ts
const http = new KVStorageHttp({ url: 'https://api.example.com/kv', signer })
http.on('change', ({ changes }) => console.log(changes))
http.on('resync', () => reload()) // reconnected: changes may have been missed
await http.child(signer.publicKey).child('notes').subscribe()
```
Each `KVStorageHttp` instance keeps one SSE connection for all its subscriptions. Browsers allow
only 6 HTTP/1.1 connections per host, so share one client per server (or serve over HTTP/2).
## Writing your own storage
Implement the primitives of the base class for a layer; everything else is inherited. For KV:
```ts
import { BaseKVStorage, type KVChange, type ScanOptions } from '@harvmaster/storage'
class MyKVStorage extends BaseKVStorage {
async readMany(keys: string[]): Promise<unknown[]> {
/* values in order, undefined for missing */
}
async *scan(options?: ScanOptions): AsyncGenerator<[string, unknown]> {
/* key-ordered entries within { gte, lt }, honouring limit/reverse/values */
}
protected async write(changes: KVChange[]): Promise<void> {
/* set each key to its value, or delete it when value is undefined */
}
}
```
The repository's conformance suite (`test/kv-storage/kv-storage.conformance.ts`) checks the
behaviour every KV storage must have.
## Development
```sh
npm test # typecheck + Node tests
npm run test:browser # Chromium tests (real IndexedDB, WebCrypto, CORS, SSE)
npm run bench
npm run format
```
CI runs formatting, typechecking, Node 22/24/26, Chromium and the build on every push, and then
publishes: `main` → `latest`, `next` → `next`, other branches → `dev-<branch>`.