Add encryption, Chromium tests and a README
CI / Node 24 (push) Successful in 15s
CI / Node 26 (push) Successful in 16s
CI / Node 22 (pull_request) Successful in 13s
CI / Node 24 (pull_request) Failing after 3s
CI / Node 26 (pull_request) Failing after 4s
CI / Chromium (push) Failing after 1m8s
CI / Publish to staging registry (push) Skipped
CI / Chromium (pull_request) Failing after 1m3s
CI / Publish to staging registry (pull_request) Skipped
CI / Node 22 (push) Failing after 4s

- KVStorageEncrypted: AES-GCM per value (fresh IV, storage key as AAD), keys stay
  readable; decrypts and re-emits pushed changes and passes subscriptions through, so it
  can be a composite source. generateKey / exportKey / importKey / deriveKey (PBKDF2).
- KVStorageHttpServer: allowOrigins (CORS), including cache-control for SSE preflights.
- Chromium tests (Vitest browser mode + Playwright) against a real cross-origin server:
  conformance for IndexedDB, encrypted, HTTP and composite stores; signed requests and
  SSE; two devices syncing, offline writes surviving a reload. New CI job.
- Composite fixes found by those tests: sync() during a running sync schedules one more
  (changes between the first scan and the stream connecting were missed); a queued write
  the source refuses is dropped, restored and reported (onError) instead of blocking the
  queue forever.
- README with examples, mirrored in test/readme.test.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
This commit is contained in:
Claude
2026-10-23 14:25:22 +00:00
parent 3525dbc622
commit 68d1a1c8e9
21 changed files with 1387 additions and 24 deletions
+16 -1
View File
@@ -32,11 +32,26 @@ jobs:
- name: Build - name: Build
run: npm run build run: npm run build
browser:
name: Chromium
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Install Chromium
run: npx playwright install --with-deps chromium
- name: Browser tests
run: npm run test:browser
# Pushes only (not pull requests): main → `latest`, next → `next`, other branches → `dev-<branch>`. # Pushes only (not pull requests): main → `latest`, next → `next`, other branches → `dev-<branch>`.
# See scripts/publish.mjs. Needs the NPM_REGISTRY_TOKEN secret. # See scripts/publish.mjs. Needs the NPM_REGISTRY_TOKEN secret.
staging: staging:
name: Publish to staging registry name: Publish to staging registry
needs: check needs: [check, browser]
if: github.event_name == 'push' if: github.event_name == 'push'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+6 -1
View File
@@ -7,10 +7,14 @@ Layered TypeScript storage library: KV → Events → Documents. The design is i
- `npm test`: `tsc --noEmit`, then all Vitest tests (including `test/types.test-d.ts`) - `npm test`: `tsc --noEmit`, then all Vitest tests (including `test/types.test-d.ts`)
- `npm run test:only -- <file>`: Vitest only - `npm run test:only -- <file>`: Vitest only
- `npm run test:browser`: `test/browser/` in headless Chromium (Vitest browser mode + Playwright)
against a real server on another origin (`test/browser/global-setup.ts`)
- `npm run typecheck`, `npm run build`, `npm run bench` - `npm run typecheck`, `npm run build`, `npm run bench`
- `npm run format` / `npm run format:check`: Prettier - `npm run format` / `npm run format:check`: Prettier
CI (`.gitea/workflows/ci.yml`, Gitea Actions) runs the format check, typecheck, tests and build on Node 22, 24 and 26. CI (`.gitea/workflows/ci.yml`, Gitea Actions) runs the format check, typecheck, tests and build on
Node 22, 24 and 26, and the Chromium tests. On pushes it then publishes to the staging registry
(`scripts/publish.mjs`: `main` → `latest`, `next` → `next`, others → `dev-<branch>`).
## Conventions ## Conventions
@@ -23,6 +27,7 @@ CI (`.gitea/workflows/ci.yml`, Gitea Actions) runs the format check, typecheck,
object or in Maps (see `KVStorageComposite`, `KVStorageHttp`). object or in Maps (see `KVStorageComposite`, `KVStorageHttp`).
- No runtime dependencies; third-party code is vendored (`src/utils/sse-session/`, `event-emitter.ts`, - No runtime dependencies; third-party code is vendored (`src/utils/sse-session/`, `event-emitter.ts`,
`extended-json.ts`, with a header naming the source). Tests live only in `test/`, mirroring `src/`. `extended-json.ts`, with a header naming the source). Tests live only in `test/`, mirroring `src/`.
- `README.md` examples are mirrored in `test/readme.test.ts`; change both together.
- New KV/document implementations must pass `test/kv-storage/kv-storage.conformance.ts` / - New KV/document implementations must pass `test/kv-storage/kv-storage.conformance.ts` /
`test/document-storage/document-storage.suite.ts`. `test/document-storage/document-storage.suite.ts`.
- Ask the owner before pushing. - Ask the owner before pushing.
+36 -5
View File
@@ -33,13 +33,16 @@ section 7 lists what changed and why.
``` ```
src/ src/
kv-storage/ base-kv-storage.ts, kv-storage-in-memory.ts, kv-storage-indexeddb.ts, kv-storage/ base-kv-storage.ts, kv-storage-in-memory.ts, kv-storage-indexeddb.ts,
kv-storage-http.ts, kv-storage-http-server.ts, kv-storage-composite.ts kv-storage-http.ts, kv-storage-http-server.ts, kv-storage-composite.ts,
kv-storage-encrypted.ts
event-storage/ base-event-storage.ts, event-storage-kv.ts event-storage/ base-event-storage.ts, event-storage-kv.ts
document-storage/ base-document-storage.ts, document-storage-in-memory.ts, document-storage-kv.ts, document-storage/ base-document-storage.ts, document-storage-in-memory.ts, document-storage-kv.ts,
event-sourced-document-storage.ts event-sourced-document-storage.ts
utils/ keys, sorted-map, json, extended-json, event-emitter, ulid, errors, schema (types), utils/ keys, sorted-map, json, extended-json, event-emitter, ulid, errors, schema (types),
document-path/query/update, sse-session/ (vendored) document-path/query/update, sse-session/ (vendored)
test/ mirrors src/; shared suites: kv-storage.conformance.ts, document-storage.suite.ts test/ mirrors src/; shared suites: kv-storage.conformance.ts, document-storage.suite.ts;
browser/ runs in headless Chromium against a real cross-origin server;
readme.test.ts runs the README's examples
bench/ bench/
``` ```
@@ -99,7 +102,10 @@ no own value. A node's own value and its children are independent keys.
`scan` streams NDJSON. Unreachable server (fetch failure, 502/503/504) → `scan` streams NDJSON. Unreachable server (fetch failure, 502/503/504) →
`StorageUnavailableError`. The server executes exactly what clients send and never reads values `StorageUnavailableError`. The server executes exactly what clients send and never reads values
for decisions, so values can be encrypted end to end. for decisions, so values can be encrypted end to end.
- `KVStorageComposite`: see 4.2. - `KVStorageComposite`: see 4.2. `KVStorageEncrypted`: see 4.3.
- **CORS.** `allowOrigins` (`['https://app…']` or `'*'`; default none) answers preflights and adds
`Access-Control-Allow-Origin` for allowed origins. Allowed request headers: `content-type`,
`cache-control` (sent by `SSESession`), `last-event-id` and the auth headers.
- **Change stream (SSE).** `POST /events` with `{ paths }` streams every change to those keys and - **Change stream (SSE).** `POST /events` with `{ paths }` streams every change to those keys and
their subtrees as `event: change` (data: the changes), including the subscriber's own writes. their subtrees as `event: change` (data: the changes), including the subscriber's own writes.
`event: ready` follows each connection; keep-alive comments are sent every 15 s. The server keeps `event: ready` follows each connection; keep-alive comments are sent every 15 s. The server keeps
@@ -109,6 +115,8 @@ no own value. A node's own value and its children are independent keys.
new list; `subscribe` resolves once connected. A 401/403 rejects instead of retrying. new list; `subscribe` resolves once connected. A 401/403 rejects instead of retrying.
- The client emits each pushed batch as `change` (`origin: 'remote'`), and `resync` whenever the - The client emits each pushed batch as `change` (`origin: 'remote'`), and `resync` whenever the
stream reconnects after a drop, since changes may have been missed meanwhile. stream reconnects after a drop, since changes may have been missed meanwhile.
- One SSE connection per `KVStorageHttp` instance. Browsers allow 6 HTTP/1.1 connections per
host, so apps should share one client per server (or use HTTP/2); `close()` releases it.
- Limitation: a server only sees writes made through its own storage instance, so several server - Limitation: a server only sees writes made through its own storage instance, so several server
processes over one database need a shared pub/sub (not built). processes over one database need a shared pub/sub (not built).
- **Wrappers** (e.g. a future encryption layer) extend `BaseKVStorage` and delegate the primitives. - **Wrappers** (e.g. a future encryption layer) extend `BaseKVStorage` and delegate the primitives.
@@ -159,20 +167,41 @@ is only appropriate when the client may read all of it.
- A write the source refuses (e.g. 403) is undone locally and rethrown. - A write the source refuses (e.g. 403) is undone locally and rethrown.
- On `StorageUnavailableError`, or while a sync runs or writes are queued (to keep order), the - On `StorageUnavailableError`, or while a sync runs or writes are queued (to keep order), the
write goes to the queue and is sent later. The last write to reach the source wins. write goes to the queue and is sent later. The last write to reach the source wins.
- A queued write the source refuses on replay is dropped (retrying would block every later
write), the source's values for its keys are restored locally, and it is reported to `onError`.
- **Remote changes** pushed by the source are applied and announced (`change`, `origin: 'remote'`) - **Remote changes** pushed by the source are applied and announced (`change`, `origin: 'remote'`)
only where they differ from what the first replica holds. No write tagging or echo tracking is only where they differ from what the first replica holds. No write tagging or echo tracking is
needed: echoes of this client's writes change nothing. needed: echoes of this client's writes change nothing.
- **`sync()`**: replay the queue in order (send, then re-apply locally) → merge-scan the source - **`sync()`**: replay the queue in order (send, then re-apply locally) → merge-scan the source
against the last replica (both key-ordered), applying the difference in batches of 500 → against the last replica (both key-ordered), applying the difference in batches of 500 →
replay again (writes made meanwhile) → mark `synced`. It runs on start, on `resync` (the stream replay again (writes made meanwhile) → mark `synced`. It runs on start, on `resync` (the stream
reconnected), and `retryMs` after a failure while offline. reconnected), and `retryMs` after a failure while offline. Called while a sync is running, it
schedules one more after it (shared by all callers meanwhile), so changes after the running
sync's scan aren't missed; e.g. the sync after subscribing, which closes the gap between the
first scan and the stream connecting.
- **Reads** wait for the first sync. Until one has completed, they go to the source. With a - **Reads** wait for the first sync. Until one has completed, they go to the source. With a
persistent queue, a restart reads locally straight away (offline too). persistent queue, a restart reads locally straight away (offline too).
- `start()` (also lazy on first use), `sync()`, `queued`, `close()`. - `start()` (also lazy on first use), `sync()`, `queued`, `close()`; `onError` receives errors no
caller can be rejected with (refused queued writes, failed syncs). Default `console.error`.
- Limitations: catching up re-scans the whole range (no server history); a remote change can - Limitations: catching up re-scans the whole range (no server history); a remote change can
overwrite a value whose write is still queued until that write is replayed (last write wins); overwrite a value whose write is still queued until that write is replayed (last write wins);
replicas, source and queue must be root stores (the primitives use absolute keys). replicas, source and queue must be root stores (the primitives use absolute keys).
### 4.3 `KVStorageEncrypted(inner, { key })`
Encrypts values before they reach another KV storage (usually `KVStorageHttp`), so the server only
stores ciphertext.
- AES-GCM (WebCrypto) with a fresh 96-bit random IV per write. Stored value: `Uint8Array`
`[1 (format), iv (12), ciphertext + tag]`. The storage key is the additional authenticated data,
so a value moved to another key, or altered, fails to decrypt (`Error`, with the cause).
- Keys stay plaintext (the server needs them for authorization, ordering and prefix scans); value
sizes and timing are visible.
- Remote `change` events from the inner storage are decrypted (in order) and re-emitted; `resync`
and `subscribe` / `unsubscribe` pass through, so it works as a composite `source`.
- Keys: `generateKey()` (extractable), `exportKey` / `importKey` (raw), `deriveKey(password, salt,
iterations = 600 000)` (PBKDF2-SHA-256, not extractable). No key rotation yet.
## 5. Event storage ## 5. Event storage
`BaseEventStorage` primitives over stream paths: `appendTo(stream, events)` → ids, `BaseEventStorage` primitives over stream paths: `appendTo(stream, events)` → ids,
@@ -258,6 +287,8 @@ Node API: `get(key?)`, `getMany`, `set(doc)` / `set(key, doc)`, `insert(doc)`
re-scan (would need server-side history or a change log in event storage). re-scan (would need server-side history or a change log in event storage).
- Event-sourced documents: subscribing to event streams to run `catchUp` automatically. - Event-sourced documents: subscribing to event streams to run `catchUp` automatically.
- Authorization beyond "public key = user id" (sharing a subtree with other keys). - Authorization beyond "public key = user id" (sharing a subtree with other keys).
- Encryption: key rotation (re-encrypt, or a key id in the value format); sharing encrypted data
between users.
- Multi-process servers need a shared change feed (pub/sub) for SSE. - Multi-process servers need a shared change feed (pub/sub) for SSE.
- Hard deletes from immutable event logs (privacy requests): compaction vs crypto-shredding. The - Hard deletes from immutable event logs (privacy requests): compaction vs crypto-shredding. The
+293
View File
@@ -0,0 +1,293 @@
# @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-Signature` and
`X-Signature-Algorithm` headers, over `${timestamp}:${path}:${body}`. The server checks the
signature and a 60 s timestamp window (with 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>`.
+314
View File
@@ -9,8 +9,10 @@
"version": "0.1.0", "version": "0.1.0",
"devDependencies": { "devDependencies": {
"@types/node": "^22.20.5", "@types/node": "^22.20.5",
"@vitest/browser": "^3.2.7",
"fake-indexeddb": "^6.2.6", "fake-indexeddb": "^6.2.6",
"fast-check": "^4.10.2", "fast-check": "^4.10.2",
"playwright": "^1.56.1",
"prettier": "^3.9.10", "prettier": "^3.9.10",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^3.2.7" "vitest": "^3.2.7"
@@ -19,6 +21,48 @@
"node": ">=22" "node": ">=22"
} }
}, },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/code-frame/node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -485,6 +529,13 @@
"node": "^22.20 || ^24.12 || >=25" "node": "^22.20 || ^24.12 || >=25"
} }
}, },
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.65.0", "version": "4.65.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.65.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.65.0.tgz",
@@ -835,6 +886,47 @@
"win32" "win32"
] ]
}, },
"node_modules/@testing-library/dom": {
"version": "10.4.2",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz",
"integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/user-event": {
"version": "14.6.8",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.8.tgz",
"integrity": "sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12",
"npm": ">=6"
},
"peerDependencies": {
"@testing-library/dom": ">=7.21.4"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/chai": { "node_modules/@types/chai": {
"version": "5.2.3", "version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -870,6 +962,42 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@vitest/browser": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.7.tgz",
"integrity": "sha512-gIzazUkbQfv6T1rJHOLhMMKQnplKAvvQ7QNGaFwI6oCsp4z2aSDZCojGpX3QX3+MYsvJdyy/8BRIYVEbAkMkEA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/user-event": "^14.6.1",
"@vitest/mocker": "3.2.7",
"@vitest/utils": "3.2.7",
"magic-string": "^0.30.17",
"sirv": "^3.0.1",
"tinyrainbow": "^2.0.0",
"ws": "^8.18.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"playwright": "*",
"vitest": "3.2.7",
"webdriverio": "^7.0.0 || ^8.0.0 || ^9.0.0"
},
"peerDependenciesMeta": {
"playwright": {
"optional": true
},
"safaridriver": {
"optional": true
},
"webdriverio": {
"optional": true
}
}
},
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "3.2.7", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
@@ -985,6 +1113,39 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/assertion-error": { "node_modules/assertion-error": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1060,6 +1221,23 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
},
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@@ -1209,6 +1387,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1219,6 +1407,16 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1282,6 +1480,53 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.56.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.31", "version": "8.5.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
@@ -1327,6 +1572,21 @@
"url": "https://github.com/prettier/prettier?sponsor=1" "url": "https://github.com/prettier/prettier?sponsor=1"
} }
}, },
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/pure-rand": { "node_modules/pure-rand": {
"version": "8.4.2", "version": "8.4.2",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
@@ -1344,6 +1604,13 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.65.0", "version": "4.65.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.65.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.65.0.tgz",
@@ -1397,6 +1664,21 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
@@ -1495,6 +1777,16 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1703,6 +1995,28 @@
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
},
"node_modules/ws": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
} }
} }
} }
+4 -1
View File
@@ -29,12 +29,15 @@
"bench": "vitest bench --run", "bench": "vitest bench --run",
"test:only": "vitest run", "test:only": "vitest run",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check ." "format:check": "prettier --check .",
"test:browser": "vitest run --config vitest.browser.config.ts"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.20.5", "@types/node": "^22.20.5",
"@vitest/browser": "^3.2.7",
"fake-indexeddb": "^6.2.6", "fake-indexeddb": "^6.2.6",
"fast-check": "^4.10.2", "fast-check": "^4.10.2",
"playwright": "^1.56.1",
"prettier": "^3.9.10", "prettier": "^3.9.10",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^3.2.7" "vitest": "^3.2.7"
+1
View File
@@ -4,6 +4,7 @@ export * from './kv-storage/kv-storage-http.js'
export * from './kv-storage/kv-storage-http-server.js' export * from './kv-storage/kv-storage-http-server.js'
export * from './kv-storage/kv-storage-indexeddb.js' export * from './kv-storage/kv-storage-indexeddb.js'
export * from './kv-storage/kv-storage-composite.js' export * from './kv-storage/kv-storage-composite.js'
export * from './kv-storage/kv-storage-encrypted.js'
export * from './event-storage/base-event-storage.js' export * from './event-storage/base-event-storage.js'
export * from './event-storage/event-storage-kv.js' export * from './event-storage/event-storage-kv.js'
export * from './document-storage/base-document-storage.js' export * from './document-storage/base-document-storage.js'
+32 -7
View File
@@ -22,6 +22,11 @@ export interface KVStorageCompositeOptions {
prefix?: string prefix?: string
/** Delay before retrying when the source is unreachable. Default 5 000 ms. */ /** Delay before retrying when the source is unreachable. Default 5 000 ms. */
retryMs?: number retryMs?: number
/**
* Called with errors that have no caller to reject: a queued write the source refused (it is
* dropped, and the source's values restored locally), or a failed sync. Default: `console.error`.
*/
onError?: (error: Error) => void
} }
/** Sources that push remote changes (e.g. `KVStorageHttp`). */ /** Sources that push remote changes (e.g. `KVStorageHttp`). */
@@ -58,11 +63,14 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
private readonly writes: BaseKVStorage private readonly writes: BaseKVStorage
private readonly range: KeyRange private readonly range: KeyRange
private readonly retryMs: number private readonly retryMs: number
private readonly onError: (error: Error) => void
private readonly ids = new Ulid() private readonly ids = new Ulid()
/** Mutable state lives here: child nodes share this object (see `child()`). */ /** Mutable state lives here: child nodes share this object (see `child()`). */
private readonly state: { private readonly state: {
started?: Promise<void> started?: Promise<void>
syncing?: Promise<void> syncing?: Promise<void>
/** A sync requested while one was running. */
again?: Promise<void>
/** A sync has completed at some point, so the replicas hold a full copy. */ /** A sync has completed at some point, so the replicas hold a full copy. */
synced: boolean synced: boolean
/** Writes waiting in the queue. */ /** Writes waiting in the queue. */
@@ -82,6 +90,7 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
this.writes = this.queue.child('writes') this.writes = this.queue.child('writes')
this.range = new KeyRange(options.prefix ?? '') this.range = new KeyRange(options.prefix ?? '')
this.retryMs = options.retryMs ?? 5000 this.retryMs = options.retryMs ?? 5000
this.onError = options.onError ?? ((error) => console.error(error))
// A full sync makes the replicas match the source within the range, which would delete queued // A full sync makes the replicas match the source within the range, which would delete queued
// writes stored inside that range. // writes stored inside that range.
const queueRoot = KVStorageComposite.rootOf(this.queue) const queueRoot = KVStorageComposite.rootOf(this.queue)
@@ -95,9 +104,15 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
return (this.state.started ??= this.open()) return (this.state.started ??= this.open())
} }
/** Replays queued writes, then brings the replicas up to date with the source (starts first if needed). */ /**
* Replays queued writes, then brings the replicas up to date with the source (starts first if
* needed). Called while a sync is running, it resolves after one more sync that starts after it.
*/
sync(): Promise<void> { sync(): Promise<void> {
return (this.state.syncing ??= this.runSync().finally(() => (this.state.syncing = undefined))) // During a sync, schedule one more after it (shared by every caller meanwhile): what changed
// since the running one scanned that part of the source must be picked up too.
if (this.state.syncing) return (this.state.again ??= this.state.syncing.then(() => ((this.state.again = undefined), this.sync())))
return (this.state.syncing = this.runSync().finally(() => (this.state.syncing = undefined)))
} }
/** Number of writes waiting for the source. */ /** Number of writes waiting for the source. */
@@ -158,7 +173,7 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
this.source.on('change', (event) => { this.source.on('change', (event) => {
if (event.origin !== 'remote') return if (event.origin !== 'remote') return
const changes = event.changes.filter(({ key }) => this.range.contains(key)) const changes = event.changes.filter(({ key }) => this.range.contains(key))
this.state.applying = this.state.applying.then(() => this.applyRemote(changes)).catch((error) => console.error(error)) this.state.applying = this.state.applying.then(() => this.applyRemote(changes)).catch((error: Error) => this.onError(error))
}), }),
this.source.on('resync', () => void this.sync()), this.source.on('resync', () => void this.sync()),
) )
@@ -166,7 +181,7 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
void this.source void this.source
.subscribe(this.range.prefix) .subscribe(this.range.prefix)
.then(() => this.sync()) .then(() => this.sync())
.catch((error) => console.error(error)) .catch((error: Error) => this.onError(error))
} else { } else {
void this.sync() void this.sync()
} }
@@ -183,7 +198,7 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
await this.queue.set('synced', true) await this.queue.set('synced', true)
this.state.synced = true this.state.synced = true
} catch (error) { } catch (error) {
if (!(error instanceof StorageUnavailableError)) console.error(error) if (!(error instanceof StorageUnavailableError)) this.onError(error as Error)
this.retryLater() this.retryLater()
} }
} }
@@ -195,8 +210,18 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
for await (const entry of this.writes.entries({ limit: 1 })) next = entry for await (const entry of this.writes.entries({ limit: 1 })) next = entry
if (!next) return if (!next) return
const changes = next[1] as KVChange[] const changes = next[1] as KVChange[]
await this.source.writeMany(changes) try {
await this.apply(changes) await this.source.writeMany(changes)
await this.apply(changes)
} catch (error) {
if (error instanceof StorageUnavailableError) throw error
// Refused (e.g. 403): retrying won't help, and would block every later write. Drop it and
// restore what the source holds.
this.onError(new Error('A queued write was refused by the source and dropped', { cause: error }))
const keys = changes.map(({ key }) => key)
const current = await this.source.readMany(keys).catch(() => undefined)
if (current) await this.applyRemote(keys.map((key, i) => ({ key, value: current[i] })))
}
await this.writes.delete(next[0]) await this.writes.delete(next[0])
this.state.queued-- this.state.queued--
} }
+149
View File
@@ -0,0 +1,149 @@
import { ExtendedJson } from '../utils/extended-json.js'
import type { AnySpec, ChildOf, KeyOf, Spec } from '../utils/schema.js'
import { BaseKVStorage, type KVChange, type ScanOptions } from './base-kv-storage.js'
export interface KVStorageEncryptedOptions {
/** An AES-GCM key, e.g. from `KVStorageEncrypted.generateKey()` or `deriveKey(password, salt)`. */
key: CryptoKey
/** Called when a pushed change can't be decrypted (it is dropped). Default: `console.error`. */
onError?: (error: Error) => void
}
/** Sources that push remote changes (e.g. `KVStorageHttp`). */
interface Subscribable {
subscribe(path: string): Promise<void>
unsubscribe(path: string): Promise<void>
}
// Types only: children of a KVStorageEncrypted are KVStorageEncrypted nodes too (child() creates
// them with Object.create, so they share this class's prototype).
export interface KVStorageEncrypted<S extends Spec = AnySpec> {
child<K extends KeyOf<S>>(key: K): KVStorageEncrypted<ChildOf<S, K>>
}
/**
* Encrypts values (not keys) before they reach another KV storage, e.g. `KVStorageHttp`, so the
* server only ever stores ciphertext.
*
* - Each value is AES-GCM encrypted with a fresh random IV and stored as a `Uint8Array`:
* `[1 (format), iv (12 bytes), ciphertext]`. The key it is stored under is authenticated too, so
* a value moved to another key fails to decrypt.
* - Keys stay readable: the server needs them for authorization, ordering and prefix scans. Don't
* put secrets in keys. Value sizes and write times are visible as well.
* - Changes the inner storage pushes (`origin: 'remote'`) are decrypted and re-emitted, and
* `subscribe` / `unsubscribe` are passed through, so it can be a `KVStorageComposite` source.
*/
export class KVStorageEncrypted<S extends Spec = AnySpec> extends BaseKVStorage<S> {
private readonly cryptoKey: CryptoKey
private readonly onError: (error: Error) => void
/** Mutable state lives here: child nodes share this object (see `child()`). */
private readonly state = { relaying: Promise.resolve() }
constructor(
private readonly inner: BaseKVStorage,
options: KVStorageEncryptedOptions,
) {
super()
this.cryptoKey = options.key
this.onError = options.onError ?? ((error) => console.error(error))
inner.on('change', ({ changes, origin }) => {
if (origin !== 'remote') return
// Decrypted one batch at a time, so changes are re-emitted in the order they arrived.
this.state.relaying = this.state.relaying
.then(async () => {
this.emit('change', { changes: await Promise.all(changes.map(({ key, value }) => this.decryptChange(key, value))), origin: 'remote' })
})
.catch((error: Error) => this.onError(error))
})
inner.on('resync', () => this.emit('resync', undefined))
}
async readMany(keys: string[]): Promise<unknown[]> {
const values = await this.inner.readMany(keys)
return Promise.all(values.map((value, i) => (value === undefined ? undefined : this.decrypt(keys[i]!, value))))
}
async *scan(options: ScanOptions = {}): AsyncGenerator<[string, unknown]> {
for await (const [key, value] of this.inner.scan(options)) yield [key, options.values ? await this.decrypt(key, value) : undefined]
}
/** Passes through to the inner storage, when it supports subscriptions. */
async subscribe(path: string = this.key): Promise<void> {
await (this.inner as Partial<Subscribable>).subscribe?.(path)
}
async unsubscribe(path: string = this.key): Promise<void> {
await (this.inner as Partial<Subscribable>).unsubscribe?.(path)
}
protected async write(changes: KVChange[]): Promise<void> {
await this.inner.writeMany(
await Promise.all(changes.map(async ({ key, value }) => (value === undefined ? { key } : { key, value: await this.encrypt(key, value) }))),
)
}
private async encrypt(key: string, value: unknown): Promise<Uint8Array> {
const iv = crypto.getRandomValues(new Uint8Array(12))
const plaintext = new TextEncoder().encode(ExtendedJson.stringify(value))
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: KVStorageEncrypted.encodeKey(key) }, this.cryptoKey, plaintext),
)
const out = new Uint8Array(1 + iv.length + ciphertext.length)
out[0] = 1
out.set(iv, 1)
out.set(ciphertext, 1 + iv.length)
return out
}
private async decrypt(key: string, value: unknown): Promise<unknown> {
if (!(value instanceof Uint8Array) || value[0] !== 1 || value.length < 13) throw new TypeError(`The value at "${key}" is not encrypted`)
const iv = value.subarray(1, 13)
try {
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv as BufferSource, additionalData: KVStorageEncrypted.encodeKey(key) },
this.cryptoKey,
value.subarray(13) as BufferSource,
)
return ExtendedJson.parse(new TextDecoder().decode(plaintext))
} catch (error) {
throw new Error(`Cannot decrypt the value at "${key}" (wrong key, or the data was changed)`, { cause: error })
}
}
private async decryptChange(key: string, value: unknown): Promise<KVChange> {
return value === undefined ? { key } : { key, value: await this.decrypt(key, value) }
}
/** A new random AES-GCM 256 key (extractable, so it can be saved with `exportKey`). */
static generateKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'])
}
/** The raw bytes of a key, to store it (e.g. in a password manager or the OS keychain). */
static async exportKey(key: CryptoKey): Promise<Uint8Array> {
return new Uint8Array(await crypto.subtle.exportKey('raw', key))
}
static importKey(raw: Uint8Array): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', raw as BufferSource, 'AES-GCM', true, ['encrypt', 'decrypt'])
}
/**
* A key derived from a password with PBKDF2-SHA-256. Use the same `salt` (random, stored with the
* data, not secret) on every device. Not extractable.
*/
static async deriveKey(password: string, salt: Uint8Array, iterations = 600_000): Promise<CryptoKey> {
const material = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey'])
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: 'SHA-256', salt: salt as BufferSource, iterations },
material,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
)
}
private static encodeKey(key: string): Uint8Array<ArrayBuffer> {
return new TextEncoder().encode(key)
}
}
+25 -1
View File
@@ -1,7 +1,7 @@
import { AuthError } from '../utils/errors.js' import { AuthError } from '../utils/errors.js'
import { ExtendedJson } from '../utils/extended-json.js' import { ExtendedJson } from '../utils/extended-json.js'
import { KeyRange } from '../utils/key-range.js' import { KeyRange } from '../utils/key-range.js'
import { RequestAuth, type RequestAuthOptions, type SignatureVerifier } from '../utils/request-auth.js' import { AUTH_HEADERS, RequestAuth, type RequestAuthOptions, type SignatureVerifier } from '../utils/request-auth.js'
import type { BaseKVStorage, KVChange, ScanOptions } from './base-kv-storage.js' import type { BaseKVStorage, KVChange, ScanOptions } from './base-kv-storage.js'
export interface KVStorageHttpServerOptions { export interface KVStorageHttpServerOptions {
@@ -15,6 +15,11 @@ export interface KVStorageHttpServerOptions {
basePath?: string basePath?: string
/** Interval of SSE keep-alive comments. Default 15 000 ms. */ /** Interval of SSE keep-alive comments. Default 15 000 ms. */
keepAliveMs?: number keepAliveMs?: number
/**
* Origins allowed to call the server from a browser on another origin (CORS), e.g.
* `['https://app.example.com']`, or `'*'` for any. Default: none (same origin only).
*/
allowOrigins?: string[] | '*'
} }
type RequestBody = { keys: string[]; changes: KVChange[]; paths: string[] } & ScanOptions type RequestBody = { keys: string[]; changes: KVChange[]; paths: string[] } & ScanOptions
@@ -39,6 +44,7 @@ export class KVStorageHttpServer {
private readonly auth: RequestAuth | undefined private readonly auth: RequestAuth | undefined
private readonly basePath: string private readonly basePath: string
private readonly keepAliveMs: number private readonly keepAliveMs: number
private readonly allowOrigins: string[] | '*'
private readonly subscribers = new Set<{ ranges: KeyRange[]; send: (frame: string) => void }>() private readonly subscribers = new Set<{ ranges: KeyRange[]; send: (frame: string) => void }>()
constructor( constructor(
@@ -48,6 +54,7 @@ export class KVStorageHttpServer {
this.auth = options.auth === 'none' ? undefined : new RequestAuth(options.auth.verifiers, options.auth) this.auth = options.auth === 'none' ? undefined : new RequestAuth(options.auth.verifiers, options.auth)
this.basePath = options.basePath ?? '' this.basePath = options.basePath ?? ''
this.keepAliveMs = options.keepAliveMs ?? 15_000 this.keepAliveMs = options.keepAliveMs ?? 15_000
this.allowOrigins = options.allowOrigins ?? []
storage.on('change', ({ changes }) => { storage.on('change', ({ changes }) => {
for (const subscriber of this.subscribers) { for (const subscriber of this.subscribers) {
const matching = changes.filter(({ key }) => subscriber.ranges.some((range) => range.contains(key))) const matching = changes.filter(({ key }) => subscriber.ranges.some((range) => range.contains(key)))
@@ -57,7 +64,24 @@ export class KVStorageHttpServer {
} }
async handle(request: Request): Promise<Response> { async handle(request: Request): Promise<Response> {
const response = await this.route(request)
const origin = request.headers.get('origin')
if (!origin || !(this.allowOrigins === '*' || this.allowOrigins.includes(origin))) return response
response.headers.set('access-control-allow-origin', origin)
response.headers.append('vary', 'origin')
return response
}
private async route(request: Request): Promise<Response> {
const path = new URL(request.url).pathname const path = new URL(request.url).pathname
if (request.method === 'OPTIONS') {
// CORS preflight (signed requests carry custom headers). `handle` adds the allowed origin.
const headers = ['content-type', 'cache-control', 'last-event-id', ...Object.values(AUTH_HEADERS)].join(', ')
return new Response(null, {
status: 204,
headers: { 'access-control-allow-methods': 'POST', 'access-control-allow-headers': headers, 'access-control-max-age': '600' },
})
}
if (request.method !== 'POST' || !path.startsWith(this.basePath)) return KVStorageHttpServer.error(404, 'Not found') if (request.method !== 'POST' || !path.startsWith(this.basePath)) return KVStorageHttpServer.error(404, 'Not found')
try { try {
const text = await request.text() const text = await request.text()
+33
View File
@@ -0,0 +1,33 @@
import type { TestProject } from 'vitest/node'
import { KVStorageHttpServer } from '../../src/kv-storage/kv-storage-http-server.js'
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
import { Ed25519Verifier } from '../../src/utils/request-auth.js'
import { serve } from '../kv-storage/node-server.js'
declare module 'vitest' {
interface ProvidedContext {
/** Base URL of the test server. `<url>/open/<space>` and `<url>/signed/<space>` are fresh, isolated stores. */
serverUrl: string
}
}
/** A real HTTP server on another origin than the test page, with one store per `/<auth>/<space>`. */
export default async function setup(project: TestProject): Promise<() => Promise<void>> {
const spaces = new Map<string, KVStorageHttpServer>()
const server = await serve(async (request) => {
const [, auth, space] = new URL(request.url).pathname.split('/')
const basePath = `/${auth}/${space}`
let handler = spaces.get(basePath)
if (!handler) {
handler = new KVStorageHttpServer(new KVStorageInMemory(), {
auth: auth === 'signed' ? { verifiers: [new Ed25519Verifier()] } : 'none',
basePath,
allowOrigins: '*',
})
spaces.set(basePath, handler)
}
return handler.handle(request)
})
project.provide('serverUrl', server.url)
return server.close
}
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, inject, it, vi } from 'vitest'
import { DocumentStorageKV } from '../../src/document-storage/document-storage-kv.js'
import type { BaseKVStorage } from '../../src/kv-storage/base-kv-storage.js'
import { KVStorageComposite } from '../../src/kv-storage/kv-storage-composite.js'
import { KVStorageEncrypted } from '../../src/kv-storage/kv-storage-encrypted.js'
import { KVStorageHttp } from '../../src/kv-storage/kv-storage-http.js'
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
import { KVStorageIndexedDB } from '../../src/kv-storage/kv-storage-indexeddb.js'
import { Ed25519Signer, type RequestSigner } from '../../src/utils/request-auth.js'
import { collect } from '../helpers.js'
import { runKVStorageConformance } from '../kv-storage/kv-storage.conformance.js'
const serverUrl = inject('serverUrl')
const key = await KVStorageEncrypted.generateKey()
let n = 0
const unique = (name: string) => `${name}-${Date.now()}-${n++}`
/** A fresh store on the test server: `open/<id>` (no auth) or `signed/<id>` (Ed25519). */
const space = (auth: 'open' | 'signed') => `${auth}/${unique('space')}`
/** A client for a store on the test server (another origin, so CORS applies). */
function http(path = space('open'), options: { signer?: RequestSigner; network?: { online: boolean } } = {}) {
return new KVStorageHttp({
url: `${serverUrl}/${path}`,
signer: options.signer,
fetch: (url, init) => (options.network?.online === false ? Promise.reject(new TypeError('Failed to fetch')) : fetch(url, init)),
})
}
runKVStorageConformance('KVStorageIndexedDB (Chromium)', () => new KVStorageIndexedDB({ name: unique('idb') }))
runKVStorageConformance('KVStorageEncrypted over IndexedDB (Chromium)', () => new KVStorageEncrypted(new KVStorageIndexedDB({ name: unique('idb') }), { key }))
runKVStorageConformance('KVStorageHttp (Chromium, cross-origin)', () => http())
runKVStorageConformance(
'KVStorageComposite: encrypted HTTP + memory + IndexedDB (Chromium)',
() =>
new KVStorageComposite({
source: new KVStorageEncrypted(http(), { key }),
replicas: [new KVStorageInMemory(), new KVStorageIndexedDB({ name: unique('idb') })],
}),
)
describe('signed requests (Chromium)', () => {
it('signs with Ed25519 and only allows the signer’s own keys', async () => {
const path = space('signed')
const alice = await Ed25519Signer.generate()
const bob = await Ed25519Signer.generate()
const client = http(path, { signer: alice })
await client.child(alice.publicKey).set('profile', { name: 'Alice' })
expect(await client.child(alice.publicKey).get('profile')).toEqual({ name: 'Alice' })
await expect(client.child(bob.publicKey).set('profile', {})).rejects.toMatchObject({ status: 403 })
await expect(http(path).child(alice.publicKey).get('profile')).rejects.toMatchObject({ status: 401 })
})
it('streams changes over SSE across origins', async () => {
const path = space('signed')
const alice = await Ed25519Signer.generate()
const watcher = http(path, { signer: alice })
await watcher.child(alice.publicKey).subscribe()
const pushed = watcher.waitFor('change', (e) => e.origin === 'remote')
await http(path, { signer: alice }).child(alice.publicKey).set('x', 1n)
expect((await pushed).changes).toEqual([{ key: `${alice.publicKey}/x`, value: 1n }])
await watcher.close()
})
})
describe('two devices syncing through the server (Chromium)', () => {
/** One device: an encrypted, signed source and a persistent IndexedDB replica and queue. */
function device(path: string, signer: Ed25519Signer, dbName: string, network = { online: true }) {
const indexedDb = new KVStorageIndexedDB({ name: dbName })
const db = new KVStorageComposite({
source: new KVStorageEncrypted(http(path, { signer, network }), { key }),
replicas: [new KVStorageInMemory(), indexedDb],
queue: indexedDb.child('~sync'),
prefix: signer.publicKey,
retryMs: 50,
})
return { db, user: db.child(signer.publicKey), network }
}
it('pushes writes from one device to the other', async () => {
const path = space('signed')
const signer = await Ed25519Signer.generate()
const a = device(path, signer, unique('a'))
const b = device(path, signer, unique('b'))
await Promise.all([a.db.sync(), b.db.sync()])
const seen = b.db.waitFor('change', (e) => e.origin === 'remote')
await a.user.child('notes').set('1', { text: 'from A' })
expect((await seen).changes).toEqual([{ key: `${signer.publicKey}/notes/1`, value: { text: 'from A' } }])
expect(await b.user.child('notes').get('1')).toEqual({ text: 'from A' })
await Promise.all([a.db.close(), b.db.close()])
})
it('works offline, survives a reload with queued writes, and syncs when back online', async () => {
const path = space('signed')
const signer = await Ed25519Signer.generate()
const dbName = unique('a')
const first = device(path, signer, dbName)
const docs = new DocumentStorageKV(first.user).child('todos')
await docs.set('1', { title: 'Synced', done: false })
await first.db.sync()
first.network.online = false
await docs.update('1', { $set: { done: true } })
await docs.set('2', { title: 'Written offline', done: false })
expect(first.db.queued).toBe(2)
await first.db.close()
// "Reload": new instances over the same IndexedDB database, still offline.
const network = { online: false }
const reloaded = device(path, signer, dbName, network)
const reloadedDocs = new DocumentStorageKV(reloaded.user).child('todos')
expect(await collect(reloadedDocs.find({ done: false }))).toEqual([{ key: '2', data: { title: 'Written offline', done: false } }])
network.online = true
await reloaded.db.sync()
expect(reloaded.db.queued).toBe(0)
const other = device(path, signer, unique('b'))
await other.db.sync()
expect(await collect(new DocumentStorageKV(other.user).child('todos').find())).toEqual([
{ key: '1', data: { title: 'Synced', done: true } },
{ key: '2', data: { title: 'Written offline', done: false } },
])
await Promise.all([reloaded.db.close(), other.db.close()])
})
it('stores only ciphertext on the server', async () => {
const path = space('signed')
const signer = await Ed25519Signer.generate()
const a = device(path, signer, unique('a'))
await a.user.set('secret', 'the plans')
const raw: BaseKVStorage = http(path, { signer })
const [stored] = await raw.readMany([`${signer.publicKey}/secret`])
expect(stored).toBeInstanceOf(Uint8Array)
await vi.waitFor(async () => expect(await a.user.get('secret')).toBe('the plans'))
await a.db.close()
})
})
+32 -1
View File
@@ -41,7 +41,7 @@ describe('KVStorageComposite sync', () => {
network.store.reset() network.store.reset()
expect(await db.child('users').get('1')).toEqual({ name: 'Ada' }) expect(await db.child('users').get('1')).toEqual({ name: 'Ada' })
expect(await collect(db.child('users').keys())).toEqual(['1', '2']) expect(await collect(db.child('users').keys())).toEqual(['1', '2'])
expect(network.store.calls).toEqual({}) expect(network.store.calls.readMany).toBeUndefined() // (a follow-up sync started on subscribe may still scan)
}) })
it('applies changes pushed from other clients to every replica', async () => { it('applies changes pushed from other clients to every replica', async () => {
@@ -226,4 +226,35 @@ describe('KVStorageComposite sync', () => {
['c', 3], ['c', 3],
]) ])
}) })
it('sync() during a running sync runs one more, so changes after its scan are picked up', async () => {
let release!: () => void
let reached!: () => void
const gate = new Promise<void>((r) => (release = r))
const paused = new Promise<void>((r) => (reached = r))
const inner = new KVStorageInMemory()
await inner.writeMany([{ key: 'a', value: 1 }])
const source = new KVStorageCounting(inner)
const scan = source.scan.bind(source)
let scans = 0
source.scan = async function* (options) {
const entries = await collect(scan(options)) // a snapshot, like a read-ahead stream
if (options?.values && scans++ === 0) (reached(), await gate)
yield* entries
}
const db = new KVStorageComposite({ source, replicas: [new KVStorageInMemory()] })
composites.push(db)
const first = db.sync()
await paused
await inner.writeMany([{ key: 'b', value: 2 }]) // after the running sync's snapshot
const second = db.sync()
expect(db.sync()).toBe(second) // later callers share the pending one
release()
await first
await second
expect(await collect(db.scan({ values: true }))).toEqual([
['a', 1],
['b', 2],
])
})
}) })
@@ -0,0 +1,92 @@
import { afterEach, describe, expect, it } from 'vitest'
import { KVStorageComposite } from '../../src/kv-storage/kv-storage-composite.js'
import { KVStorageEncrypted } from '../../src/kv-storage/kv-storage-encrypted.js'
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
import { collect } from '../helpers.js'
import { runKVStorageConformance } from './kv-storage.conformance.js'
import { TestNetwork } from './sync-helpers.js'
const key = await KVStorageEncrypted.generateKey()
runKVStorageConformance('KVStorageEncrypted (in memory)', () => new KVStorageEncrypted(new KVStorageInMemory(), { key }))
let network: TestNetwork | undefined
afterEach(async () => {
await network?.close()
network = undefined
})
describe('KVStorageEncrypted', () => {
it('stores only ciphertext, with readable keys', async () => {
const inner = new KVStorageInMemory()
await new KVStorageEncrypted(inner, { key }).child('users').set('1', { name: 'Ada', n: 1n })
const [[storedKey, stored]] = (await collect(inner.scan({ values: true }))) as [[string, Uint8Array]]
expect(storedKey).toBe('users/1')
expect(stored).toBeInstanceOf(Uint8Array)
expect(new TextDecoder().decode(stored)).not.toContain('Ada')
})
it('uses a fresh IV for every write', async () => {
const inner = new KVStorageInMemory()
const db = new KVStorageEncrypted(inner, { key })
await db.set('a', 'same')
await db.set('b', 'same')
const [a, b] = (await inner.readMany(['a', 'b'])) as Uint8Array[]
expect(a).not.toEqual(b)
})
it('rejects a wrong key, a value moved to another key, tampering and plaintext', async () => {
const inner = new KVStorageInMemory()
await new KVStorageEncrypted(inner, { key }).set('a', 'secret')
const other = new KVStorageEncrypted(inner, { key: await KVStorageEncrypted.generateKey() })
await expect(other.get('a')).rejects.toThrow(/Cannot decrypt/)
const db = new KVStorageEncrypted(inner, { key })
const [stored] = (await inner.readMany(['a'])) as [Uint8Array]
await inner.writeMany([{ key: 'b', value: stored }])
await expect(db.get('b')).rejects.toThrow(/Cannot decrypt/)
const tampered = stored.slice()
tampered[tampered.length - 1]! ^= 1
await inner.writeMany([{ key: 'c', value: tampered }])
await expect(db.get('c')).rejects.toThrow(/Cannot decrypt/)
await inner.writeMany([{ key: 'd', value: 'plain' }])
await expect(db.get('d')).rejects.toThrow(/not encrypted/)
})
it('round-trips keys through export/import, and derives the same key from the same password and salt', async () => {
const inner = new KVStorageInMemory()
await new KVStorageEncrypted(inner, { key }).set('a', 1)
const imported = await KVStorageEncrypted.importKey(await KVStorageEncrypted.exportKey(key))
expect(await new KVStorageEncrypted(inner, { key: imported }).get('a')).toBe(1)
const salt = crypto.getRandomValues(new Uint8Array(16))
await new KVStorageEncrypted(inner, { key: await KVStorageEncrypted.deriveKey('hunter2', salt, 1000) }).set('p', 'x')
expect(await new KVStorageEncrypted(inner, { key: await KVStorageEncrypted.deriveKey('hunter2', salt, 1000) }).get('p')).toBe('x')
await expect(new KVStorageEncrypted(inner, { key: await KVStorageEncrypted.deriveKey('wrong', salt, 1000) }).get('p')).rejects.toThrow(/Cannot decrypt/)
})
it('decrypts pushed changes and passes subscriptions through', async () => {
network = new TestNetwork()
const watcher = new KVStorageEncrypted(network.client(), { key })
await watcher.child('notes').subscribe()
const pushed = watcher.waitFor('change', (e) => e.origin === 'remote')
await new KVStorageEncrypted(network.client(), { key }).child('notes').set('1', { text: 'hi' })
expect((await pushed).changes).toEqual([{ key: 'notes/1', value: { text: 'hi' } }])
})
it('works as a composite source: the server stores ciphertext, replicas hold plaintext', async () => {
network = new TestNetwork()
const replica = new KVStorageInMemory()
const a = new KVStorageComposite({ source: new KVStorageEncrypted(network.client(), { key }), replicas: [replica] })
const b = new KVStorageComposite({ source: new KVStorageEncrypted(network.client(), { key }), replicas: [new KVStorageInMemory()] })
await Promise.all([a.sync(), b.sync()])
const seen = b.waitFor('change', (e) => e.origin === 'remote')
await a.child('notes').set('1', { text: 'hi' })
expect((await seen).changes).toEqual([{ key: 'notes/1', value: { text: 'hi' } }])
expect(await replica.readMany(['notes/1'])).toEqual([{ text: 'hi' }])
expect((await network.store.readMany(['notes/1']))[0]).toBeInstanceOf(Uint8Array)
await Promise.all([a.close(), b.close()])
})
})
+37 -5
View File
@@ -5,7 +5,7 @@ import { KVStorageHttpServer } from '../../src/kv-storage/kv-storage-http-server
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js' import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
import { AuthError } from '../../src/utils/errors.js' import { AuthError } from '../../src/utils/errors.js'
import { Ed25519Signer, Ed25519Verifier } from '../../src/utils/request-auth.js' import { Ed25519Signer, Ed25519Verifier } from '../../src/utils/request-auth.js'
import { collect } from '../helpers.js' import { collect, KVStorageCounting } from '../helpers.js'
import { TestNetwork } from './sync-helpers.js' import { TestNetwork } from './sync-helpers.js'
let network: TestNetwork let network: TestNetwork
@@ -24,6 +24,11 @@ beforeEach(async () => {
}) })
afterEach(() => network.close()) afterEach(() => network.close())
/** Swaps in a server that rejects every signature (as if the user's access was revoked). */
function refuseEverySignature() {
network.server = new KVStorageHttpServer(network.store, { auth: { verifiers: [{ algorithm: 'ed25519', verify: async () => false }] } })
}
describe('authenticated KVStorageHttpServer', () => { describe('authenticated KVStorageHttpServer', () => {
it('lets a user read, write and list their own keys', async () => { it('lets a user read, write and list their own keys', async () => {
const mine = network.client(alice).child(alice.publicKey) const mine = network.client(alice).child(alice.publicKey)
@@ -82,13 +87,40 @@ describe('authenticated KVStorageHttpServer', () => {
}) })
it('a composite undoes a local write the server refuses', async () => { it('a composite undoes a local write the server refuses', async () => {
const replica = new KVStorageInMemory() // A source without subscriptions, so no follow-up sync is running when the write is made.
const db = new KVStorageComposite({ source: network.client(alice), prefix: alice.publicKey, replicas: [replica] }) const db = new KVStorageComposite({ source: new KVStorageCounting(network.client(alice)), prefix: alice.publicKey, replicas: [new KVStorageInMemory()] })
await db.sync()
await db.child(alice.publicKey).set('x', 'before') await db.child(alice.publicKey).set('x', 'before')
network.server = new KVStorageHttpServer(network.store, { auth: { verifiers: [{ algorithm: 'ed25519', verify: async () => false }] } }) // now rejects every signature await db.sync()
refuseEverySignature()
await expect(db.child(alice.publicKey).set('x', 'after')).rejects.toMatchObject({ status: 401 }) await expect(db.child(alice.publicKey).set('x', 'after')).rejects.toMatchObject({ status: 401 })
expect(await db.child(alice.publicKey).get('x')).toBe('before') expect(await db.child(alice.publicKey).get('x')).toBe('before')
await db.close() await db.close()
}) })
it('a composite drops a queued write the server refuses, restores the server value, and reports it', async () => {
const errors: Error[] = []
const db = new KVStorageComposite({
source: network.client(alice),
prefix: alice.publicKey,
replicas: [new KVStorageInMemory()],
onError: (e) => errors.push(e),
})
const mine = db.child(alice.publicKey)
await mine.set('x', 'before')
await db.sync()
network.online = false
await mine.set('x', 'queued')
await mine.set('y', 'queued after')
expect(db.queued).toBe(2)
const server = network.server
refuseEverySignature()
network.online = true
await db.sync()
network.server = server
expect(db.queued).toBe(0) // dropped, not retried forever
expect(errors.filter((e) => e.message === 'A queued write was refused by the source and dropped')).toHaveLength(2) // the sync's own scan is reported too
await db.sync()
expect(await mine.getMany(['x', 'y'])).toEqual(['before', undefined])
await db.close()
})
}) })
+13
View File
@@ -51,4 +51,17 @@ describe('server', () => {
expect((await server.handle(new Request('http://x/nope', { method: 'POST', body: '{}' }))).status).toBe(404) expect((await server.handle(new Request('http://x/nope', { method: 'POST', body: '{}' }))).status).toBe(404)
expect((await server.handle(new Request('http://x/readMany', { method: 'POST', body: '{"keys": 5}' }))).status).toBe(500) expect((await server.handle(new Request('http://x/readMany', { method: 'POST', body: '{"keys": 5}' }))).status).toBe(500)
}) })
it('answers CORS preflights and allows only listed origins', async () => {
const server = new KVStorageHttpServer(new KVStorageInMemory(), { auth: 'none', allowOrigins: ['https://app.example'] })
const preflight = await server.handle(new Request('http://x/write', { method: 'OPTIONS', headers: { origin: 'https://app.example' } }))
expect(preflight.status).toBe(204)
expect(preflight.headers.get('access-control-allow-origin')).toBe('https://app.example')
expect(preflight.headers.get('access-control-allow-headers')).toContain('X-Signature')
expect(preflight.headers.get('access-control-allow-headers')).toContain('cache-control') // sent by SSESession
const allowed = await server.handle(new Request('http://x/readMany', { method: 'POST', body: '{"keys":[]}', headers: { origin: 'https://app.example' } }))
expect(allowed.headers.get('access-control-allow-origin')).toBe('https://app.example')
const other = await server.handle(new Request('http://x/readMany', { method: 'POST', body: '{"keys":[]}', headers: { origin: 'https://evil.example' } }))
expect(other.headers.get('access-control-allow-origin')).toBeNull()
})
}) })
+6 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { BaseKVStorage, ScanOptions } from '../../src/kv-storage/base-kv-storage.js' import type { BaseKVStorage, ScanOptions } from '../../src/kv-storage/base-kv-storage.js'
import { InvalidKeyError } from '../../src/utils/errors.js' import { InvalidKeyError } from '../../src/utils/errors.js'
import { collect } from '../helpers.js' import { collect } from '../helpers.js'
@@ -10,6 +10,11 @@ export function runKVStorageConformance(name: string, make: () => BaseKVStorage
beforeEach(async () => { beforeEach(async () => {
db = await make() db = await make()
}) })
// Stores with open connections (e.g. a subscribed composite) release them; browsers allow
// only 6 HTTP/1.1 connections per host.
afterEach(async () => {
await (db as Partial<{ close(): Promise<void> }>).close?.()
})
const scanKeys = async (options: ScanOptions) => (await collect(db.scan(options))).map(([k]) => k) const scanKeys = async (options: ScanOptions) => (await collect(db.scan(options))).map(([k]) => k)
describe('primitives', () => { describe('primitives', () => {
+5 -1
View File
@@ -7,7 +7,11 @@ export async function serve(handle: (request: Request) => Promise<Response>): Pr
const chunks: Buffer[] = [] const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(chunk as Buffer) for await (const chunk of req) chunks.push(chunk as Buffer)
const response = await handle( const response = await handle(
new Request(`http://localhost${req.url}`, { method: req.method, body: req.method === 'POST' ? Buffer.concat(chunks) : undefined }), new Request(`http://localhost${req.url}`, {
method: req.method,
headers: Object.entries(req.headers).flatMap(([name, value]) => (typeof value === 'string' ? [[name, value] as [string, string]] : [])),
body: req.method === 'POST' ? Buffer.concat(chunks) : undefined,
}),
) )
res.writeHead(response.status, Object.fromEntries(response.headers)) res.writeHead(response.status, Object.fromEntries(response.headers))
if (!response.body) return void res.end() if (!response.body) return void res.end()
+137
View File
@@ -0,0 +1,137 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
DocumentStorageKV,
Ed25519Signer,
Ed25519Verifier,
EventStorageKV,
KVStorageComposite,
KVStorageEncrypted,
KVStorageHttp,
KVStorageHttpServer,
KVStorageInMemory,
type Fields,
type Many,
} from '../src/index.js'
import { collect } from './helpers.js'
// The README's examples, run as written (imports from the package entry point), so they stay correct.
const closers: (() => Promise<void>)[] = []
afterEach(async () => {
for (const close of closers.splice(0)) await close()
})
describe('README', () => {
it('KV storage', async () => {
const db = new KVStorageInMemory()
const users = db.child('users')
await users.set('1', { name: 'Ada' })
expect(await users.child('1').get()).toEqual({ name: 'Ada' })
expect(await users.get('1')).toEqual({ name: 'Ada' })
await users.setMany([
['2', { name: 'Bob' }],
['3', { name: 'Cy' }],
])
expect(await users.getMany(['1', 'missing'])).toEqual([{ name: 'Ada' }, undefined])
expect(await collect(users.keys({ limit: 10 }))).toEqual(['1', '2', '3'])
expect((await collect(users.entries({ reverse: true }))).map(([key]) => key)).toEqual(['3', '2', '1'])
await users.delete('3')
await users.deleteTree()
expect(await collect(db.scan())).toEqual([])
const settings = db.child('settings')
await settings.setFields({ theme: 'dark', fontSize: 14 })
expect(await settings.get('theme')).toBe('dark')
expect(await settings.getFields()).toEqual({ fontSize: 14, theme: 'dark' })
const changes: unknown[] = []
db.on('change', ({ changes: c, origin }) => changes.push([origin, c]))
await users.set('1', { name: 'Ada' })
expect(changes).toEqual([['local', [{ key: 'users/1', value: { name: 'Ada' } }]]])
})
it('types', async () => {
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' })
// @ts-expect-error not a User
await typed.child('users').set('2', { nom: 'x' })
await typed.child('settings').set('theme', 'dark')
const theme: string | undefined = await typed.child('settings').get('theme')
expect(theme).toBe('dark')
})
it('event storage', async () => {
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' }])
expect((await collect(orders.read('o1'))).map((e) => e.data)).toEqual([{ type: 'created', total: 30 }, { type: 'paid' }])
expect((await collect(orders.read('o1', { after: id }))).map((e) => e.data)).toEqual([{ type: 'paid' }])
expect(await collect(orders.keys())).toEqual(['o1'])
})
it('document storage', async () => {
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' } })
expect((await collect(people.find({ age: { $gte: 37 } }, { sort: { age: -1 }, limit: 10 }))).map((r) => r.key)).toEqual(['grace', id])
expect((await people.findOne({ tags: 'cobol' }))?.key).toBe('grace')
expect(await people.get(id)).toEqual({ name: 'Ada', age: 37, tags: ['math', 'engines'] })
expect(await people.delete('grace')).toBe(true)
})
it('sync, offline and encryption (server and client)', async () => {
const server = new KVStorageHttpServer(new KVStorageInMemory(), {
auth: { verifiers: [new Ed25519Verifier()] },
basePath: '/kv',
allowOrigins: ['https://app.example.com'],
})
const fetch = (url: string, init: RequestInit) => server.handle(new Request(url, init))
const signer = await Ed25519Signer.generate()
const saved = await signer.exportPrivateKey()
expect((await Ed25519Signer.fromPrivateKey(saved)).publicKey).toBe(signer.publicKey)
const salt = crypto.getRandomValues(new Uint8Array(16))
const key = await KVStorageEncrypted.deriveKey('correct horse battery staple', salt, 1000)
const local = new KVStorageInMemory() // stands in for KVStorageIndexedDB
const db = new KVStorageComposite({
source: new KVStorageEncrypted(new KVStorageHttp({ url: 'https://api.example.com/kv', signer, fetch }), { key }),
replicas: [new KVStorageInMemory(), local],
queue: local.child('~sync'),
prefix: signer.publicKey,
})
closers.push(() => db.close())
const me = db.child(signer.publicKey)
await me.child('notes').set('1', { text: 'Works offline' })
expect(await me.child('notes').get('1')).toEqual({ text: 'Works offline' })
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 })
expect((await collect(todos.find({ done: false }))).map((r) => r.data.title)).toEqual(['Ship it'])
// Another device with the same keys sees the data; the server only has ciphertext.
const other = new KVStorageComposite({
source: new KVStorageEncrypted(new KVStorageHttp({ url: 'https://api.example.com/kv', signer, fetch }), { key }),
replicas: [new KVStorageInMemory()],
prefix: signer.publicKey,
})
closers.push(() => other.close())
expect(await other.child(signer.publicKey).child('notes').get('1')).toEqual({ text: 'Works offline' })
const http = new KVStorageHttp({ url: 'https://api.example.com/kv', signer, fetch })
closers.push(() => http.close())
const [stored] = await http.readMany([`${signer.publicKey}/notes/1`])
expect(stored).toBeInstanceOf(Uint8Array)
const pushed = http.waitFor('change', () => true)
await http.child(signer.publicKey).child('notes').subscribe()
await me.child('notes').set('2', { text: 'live' })
expect((await pushed).changes[0]!.key).toBe(`${signer.publicKey}/notes/2`)
})
})
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config'
// Runs test/browser in headless Chromium (Playwright) against a real HTTP server on another
// origin, so real IndexedDB, WebCrypto, fetch streaming and CORS are exercised.
export default defineConfig({
test: {
include: ['test/browser/**/*.test.ts'],
globalSetup: ['test/browser/global-setup.ts'],
browser: {
enabled: true,
provider: 'playwright',
headless: true,
screenshotFailures: false,
instances: [{ browser: 'chromium' }],
},
},
})
+1
View File
@@ -3,6 +3,7 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({ export default defineConfig({
test: { test: {
include: ['test/**/*.test.ts'], include: ['test/**/*.test.ts'],
exclude: ['test/browser/**', 'node_modules/**'],
typecheck: { enabled: true, include: ['test/**/*.test-d.ts'] }, typecheck: { enabled: true, include: ['test/**/*.test-d.ts'] },
}, },
bench: { include: ['bench/**/*.bench.ts'] }, bench: { include: ['bench/**/*.bench.ts'] },