10 Commits
Author SHA1 Message Date
Claude cb9ff514a0 Signed requests: X-Nonce header; composite: sync skips unconfirmed writes
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
- 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
Claude 7d755cecae Signed requests: strictly increasing timestamps per key
CI / Node 22 (push) Successful in 13s
CI / Node 24 (push) Successful in 13s
CI / Node 26 (push) Successful in 14s
CI / Node 24 (pull_request) Successful in 12s
CI / Chromium (pull_request) Successful in 48s
CI / Publish to staging registry (pull_request) Skipped
CI / Node 22 (pull_request) Successful in 13s
CI / Node 26 (pull_request) Successful in 12s
CI / Chromium (push) Successful in 47s
CI / Publish to staging registry (push) Successful in 10s
Ed25519 signatures are deterministic, so two identical requests from one key in the same
millisecond (e.g. two devices subscribing to the same path, or parallel identical reads)
had the same signature, and the server rejected the second as a replay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
2026-10-23 14:36:25 +00:00
Claude 68d1a1c8e9 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
2026-10-23 14:25:22 +00:00
Claude 3525dbc622 Name the package @harvmaster/storage
CI / Node 24 (pull_request) Successful in 12s
CI / Node 26 (pull_request) Successful in 10s
CI / Publish to staging registry (pull_request) Skipped
CI / Node 22 (push) Successful in 11s
CI / Publish to staging registry (push) Successful in 7s
CI / Node 22 (pull_request) Successful in 16s
CI / Node 24 (push) Successful in 13s
CI / Node 26 (push) Successful in 15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
2026-10-23 13:49:42 +00:00
Claude ad517965ee CI: publish every push to the staging registry
main → latest (the package.json version, skipped if already published), next → next,
other branches → dev-<branch> as <version>-<tag>.<timestamp> prereleases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
2026-10-23 13:43:51 +00:00
Claude 631c75428b Plain operations: last write wins, hash-keyed events, simpler composite
CI / Node 22 (pull_request) Successful in 12s
CI / Node 26 (pull_request) Successful in 13s
CI / Node 24 (pull_request) Successful in 14s
- KV: one write primitive, write(changes) with { key, value? }; writeIfAbsent removed.
  The server executes exactly what clients send and never reads values, so they can be
  encrypted end to end. Adds setFields/getFields on the base class.
- HTTP: no server history or event ids; SSE relays every change (own writes included) and
  the client emits resync when a dropped stream reconnects.
- Composite: writes apply locally, then go to the source (undone locally if refused);
  pushed changes are applied and announced only where they differ from local values;
  offline writes go to a queue store (any KV storage); sync() = replay, diff, replay.
  Removes KVReplicaSet, KVOutbox, KeyCounter, SerialQueue and RetryTimer.
- Events: id = <time>_<sha256(time + canonical data)>, so concurrent appends never
  collide and retries can't duplicate; no versions or ConcurrencyError.
- Event-sourced documents: read, resolve, append, apply; catchUp folds in foreign events;
  getAt takes an event id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
2026-10-23 13:33:07 +00:00
Claude 1f82ec9110 Break KVStorageComposite into focused classes
CI / Node 22 (pull_request) Successful in 11s
CI / Node 24 (pull_request) Successful in 12s
CI / Node 26 (pull_request) Successful in 12s
- KVReplicaSet: seed, apply, local scan, diff against the source, sync meta
- KVOutbox: persisted offline queue with in-order replay (load() resumes it)
- utils: KeyRange (also used for server authorization), KeyCounter,
  SerialQueue (also used by KVStorageHttp and the event-sourced store),
  RetryTimer (separate timers for sync and replay; previously one shared
  timer could cancel the other's retry)

The composite now only orchestrates. Behaviour unchanged; new unit tests for
the building blocks and the outbox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
2026-10-23 12:43:49 +00:00
Claude a8c9539ad9 Authenticated, scoped sync: signed requests and per-path subscriptions
CI / Node 24 (pull_request) Successful in 12s
CI / Node 22 (pull_request) Successful in 13s
CI / Node 26 (pull_request) Successful in 13s
- RequestAuth: clients sign `${timestamp}:${path}:${body}` (as in
  sync-server-example); headers carry public key, timestamp, signature and
  algorithm. Pluggable signers/verifiers; Ed25519 via WebCrypto built in.
  Timestamp window and replay protection.
- KVStorageHttpServer requires auth ('none' must be explicit). With auth, every
  key, scan range and subscription path must be under the caller's public key.
- POST /events with { paths }: subscribers only receive (and on resume only
  replay) changes under their authorized paths.
- KVStorageHttp: signer option; subscribe(path)/unsubscribe(path)/close()
  replace watch(); 401/403 reject instead of retrying.
- KVStorageComposite: prefix option scopes sync, cache and subscription.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
2026-10-23 10:01:06 +00:00
Claude df7de27e20 Composite: don't undo writes made while a full sync is scanning
CI / Node 24 (pull_request) Successful in 11s
CI / Node 26 (pull_request) Successful in 12s
CI / Node 22 (pull_request) Successful in 13s
A write made during a full sync could be scanned in the replica but not in
the (read-ahead) source scan, so the diff removed it, and echo tracking then
consumed the server's echo. Keys written during a sync are now left out of
the diff. Deterministic regression test with a read-ahead source.

Also exclude -0 from the Extended JSON property test (JSON has no -0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
2026-10-23 08:47:18 +00:00
Claude 4ffbc7e78d Add KVStorageComposite, IndexedDB storage and SSE change streams
CI / Node 24 (pull_request) Failing after 12s
CI / Node 22 (pull_request) Successful in 14s
CI / Node 26 (pull_request) Failing after 21s
- Every KV storage emits change events (base class owns writeMany/removeMany/
  writeIfAbsent; implementations provide protected apply* methods)
- KVStorageHttpServer streams changes over SSE (GET /events) with a resumable
  history; KVStorageHttp.watch() subscribes and resumes via Last-Event-ID
- KVStorageIndexedDB for local persistence
- KVStorageComposite keeps replicas of a source in sync: local reads, resume
  instead of full sync, offline outbox replayed in order, echo tracking
- Values may contain bigint/Uint8Array (Extended JSON over the network)
- Vendor sse-session, event-emitter and extended-json (no runtime deps)
- Freeze child nodes so shadowed state on children fails loudly
- CI on Node 22, 24 and 26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
2026-10-23 08:41:42 +00:00
60 changed files with 4934 additions and 496 deletions
+43 -2
View File
@@ -2,9 +2,12 @@ name: CI
on: on:
push: push:
branches: [main] branches: ['**'] # not tags
pull_request: pull_request:
env:
NPM_REGISTRY: https://verdaccio.harvmaster.com/
jobs: jobs:
check: check:
name: Node ${{ matrix.node }} name: Node ${{ matrix.node }}
@@ -12,7 +15,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
node: [20, 22] node: [22, 24, 26]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@@ -28,3 +31,41 @@ jobs:
run: npm run test:only run: npm run test:only
- 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>`.
# See scripts/publish.mjs. Needs the NPM_REGISTRY_TOKEN secret.
staging:
name: Publish to staging registry
needs: [check, browser]
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Configure registry
run: |
host="${NPM_REGISTRY#https://}"
printf 'registry=%s\n//%s:_authToken=${NPM_REGISTRY_TOKEN}\n' "$NPM_REGISTRY" "$host" > .npmrc
- name: Publish
run: node scripts/publish.mjs "${{ github.ref_name }}"
env:
NPM_REGISTRY_TOKEN: ${{ secrets.NPM_REGISTRY_TOKEN }}
+11 -2
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 20 and 22. 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
@@ -18,7 +22,12 @@ CI (`.gitea/workflows/ci.yml`, Gitea Actions) runs the format check, typecheck,
primitives, and the user-facing API lives in the base class. primitives, and the user-facing API lives in the base class.
- Classes over loose functions; TS `private`, not `#`; no overloads (use `param?` or tuple-union - Classes over loose functions; TS `private`, not `#`; no overloads (use `param?` or tuple-union
rest params); don't override base methods in subclasses. rest params); don't override base methods in subclasses.
- No runtime dependencies. Tests live only in `test/`, mirroring `src/`. - **Never reassign a field after construction.** `child()` nodes are frozen `Object.create(root)`
views, so `this.x = ...` on a child throws. Keep mutable state in a `private readonly state`
object or in Maps (see `KVStorageComposite`, `KVStorageHttp`).
- 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/`.
- `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.
+185 -40
View File
@@ -14,6 +14,9 @@ section 7 lists what changed and why.
that delegates the primitives. that delegates the primitives.
2. **One navigation primitive: `child(key)`.** Every instance is a node; `child` returns a view of 2. **One navigation primitive: `child(key)`.** Every instance is a node; `child` returns a view of
the same storage rooted deeper (via `Object.create`, so it shares the instance's state). Sync, no I/O. the same storage rooted deeper (via `Object.create`, so it shares the instance's state). Sync, no I/O.
Child nodes are **frozen**: an implementation that reassigns a field while running on a child
would silently shadow the root's value, so that throws instead. Implementations keep mutable
state in a `readonly state` object or in Maps.
3. **Self or child by argument.** `get()` reads this node, `get(key)` a child. A key that is passed 3. **Self or child by argument.** `get()` reads this node, `get(key)` a child. A key that is passed
but `undefined`/`null` throws (`InvalidKeyError`) instead of silently meaning "self". Methods use but `undefined`/`null` throws (`InvalidKeyError`) instead of silently meaning "self". Methods use
optional parameters or tuple-union rest parameters, not overloads. optional parameters or tuple-union rest parameters, not overloads.
@@ -21,17 +24,25 @@ section 7 lists what changed and why.
suits the backend (in-memory equality maps, native Mongo/SQLite/IndexedDB indexes, or nothing). suits the backend (in-memory equality maps, native Mongo/SQLite/IndexedDB indexes, or nothing).
5. **Simple over clever.** Code style: classes, TS `private` (not `#`), no method overrides in 5. **Simple over clever.** Code style: classes, TS `private` (not `#`), no method overrides in
subclasses, tests only under `test/`. subclasses, tests only under `test/`.
6. **No runtime dependencies.** Third-party code is vendored into `src/utils/` with a header naming
its source: `sse-session/` (Harvmaster/sse-session), `event-emitter.ts` and `extended-json.ts`
(xo-cash-utils, branch installable-sse). Node ≥ 22; CI runs Node 22, 24 and 26.
## 2. Layout ## 2. Layout
``` ```
src/ src/
kv-storage/ base-kv-storage.ts, kv-storage-in-memory.ts, kv-storage-http.ts, kv-storage-http-server.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-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, ulid, errors, schema (types), document-path/query/update utils/ keys, sorted-map, json, extended-json, event-emitter, ulid, errors, schema (types),
test/ mirrors src/; shared suites: kv-storage.conformance.ts, document-storage.suite.ts document-path/query/update, sse-session/ (vendored)
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/
``` ```
@@ -49,51 +60,175 @@ bench/
## 4. KV storage ## 4. KV storage
`BaseKVStorage` primitives (absolute keys): `BaseKVStorage` primitives (absolute keys). Implementations provide two reads and one protected
write; the public `writeMany(changes)` / `removeMany(keys)` live in the base class and emit a
`change` event after each write, so every store emits change events.
| Primitive | | | Primitive | |
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | ------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `readMany(keys)` | values in order, `undefined` for missing | | `readMany(keys)` | values in order, `undefined` for missing |
| `writeMany(entries)` | |
| `removeMany(keys)` | |
| `writeIfAbsent(entries)` | `true` = written; atomic per key (needed by events) |
| `scan({ gte, lt, limit, reverse, values })` | key-ordered; must keep working if keys change mid-scan (keys present throughout appear exactly once) | | `scan({ gte, lt, limit, reverse, values })` | key-ordered; must keep working if keys change mid-scan (keys present throughout appear exactly once) |
| `write(changes)` → public `writeMany` | `{ key, value }` sets, `{ key }` deletes, in one batch |
There is no conditional write. Stores execute exactly the operations they are given, and the last
write wins. Data that can be written concurrently belongs in event storage (section 5), where
concurrent appends never touch the same key. Data whose key is the hash of its content is
immutable, so concurrent writes of it are identical.
**Values** are JSON plus `bigint` and `Uint8Array` (as in JSON, `-0` becomes `0` and `undefined` properties are dropped). Anything crossing the network is encoded as
Extended JSON (`<bigint: 123n>`, `<uint8array: abcd>`); IndexedDB stores them natively.
**Events** (`EventEmitter`, shared by all nodes of a storage): `change` →
`{ changes: { key, value? }[], origin: 'local' | 'remote' }` (no `value` = removed), and
`resync` → changes may have been missed, so re-read.
Node API (defined once): `child`, `as`, `get(key?)`, `getMany`, `set(value)` / `set(key, value)`, Node API (defined once): `child`, `as`, `get(key?)`, `getMany`, `set(value)` / `set(key, value)`,
`setMany`, `delete(key?)`, `deleteMany`, `deleteTree(key?)`, `keys({ limit, after, reverse })`, `setMany`, `setFields({ a, b })` / `getFields()` (direct children as an object), `delete(key?)`,
`entries(...)`. The root has no own value. A node's own value and its children are independent keys. `deleteMany`, `deleteTree(key?)`, `keys({ limit, after, reverse })`, `entries(...)`. The root has
no own value. A node's own value and its children are independent keys.
- **`keys()`** is a skip-scan: one seek per child that has descendants; siblings without - **`keys()`** is a skip-scan: one seek per child that has descendants; siblings without
descendants share one iterator. It lists children that only have descendants, and skips descendants share one iterator. It lists children that only have descendants, and skips
reserved segments. reserved segments.
- **The only KV "index" is the ordered key space.** There is no `find`: with KV you know where to look, - **The only KV "index" is the ordered key space.** There is no `find`: with KV you know where to look,
or you list a node's children. or you list a node's children.
- **Implementations:** `KVStorageInMemory` (`SortedMap`: sorted chunks of ≤1024 keys; values deep-copied); - **Implementations:**
`KVStorageHttp` (client) + `KVStorageHttpServer` (wraps any KV storage; `handle(Request) → Response`, - `KVStorageInMemory`: `SortedMap` (sorted chunks of ≤1024 keys); values deep-copied.
`POST /<primitive>` with JSON bodies, `scan` streamed as NDJSON). The server exposes the whole - `KVStorageIndexedDB`: one object store with string keys. Scans read in batches, each in its own
underlying store (primitives use absolute keys). transaction, resuming after the last key (IndexedDB transactions close when the code awaits
anything else). A write is one readwrite transaction.
- `KVStorageHttp` (client) and `KVStorageHttpServer` (wraps any KV storage;
`handle(Request) → Response`): `POST /readMany`, `/write`, `/scan` with Extended JSON bodies;
`scan` streams NDJSON. Unreachable server (fetch failure, 502/503/504) →
`StorageUnavailableError`. The server executes exactly what clients send and never reads values
for decisions, so values can be encrypted end to end.
- `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
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
no history: nothing is replayed after a reconnect.
- `KVStorageHttp.subscribe(path = this.key)` / `unsubscribe(path)` / `close()` manage the
subscribed paths. Each change reconnects the stream (vendored `SSESession`, backoff) with the
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
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
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.
Keys must stay plaintext (or be deterministically encrypted) for prefix scans to work; values can be Keys must stay plaintext (or be deterministically encrypted) for prefix scans to work; values can be
encrypted freely. encrypted freely.
### 4.1 Authentication (`utils/request-auth.ts`)
The server requires `auth`: either `{ verifiers, maxSkewMs?, now? }`, or `'none'` (open, for local
development and tests). It can't be left open by omission.
- **Signed requests.** The client's `RequestSigner` signs `${timestamp}:${nonce}:${path}:${body}`
(nonce = 16 random bytes as hex, fresh per request; path = URL pathname; body = the exact request
body). Based on Harvmaster/sync-server-example, plus the nonce. Headers: `X-Public-Key`
(lowercase hex), `X-Timestamp` (ms), `X-Nonce`, `X-Signature` (hex) and `X-Signature-Algorithm`.
Every request is signed, and so is each SSE (re)connect, with a fresh timestamp and nonce.
- **Verification (401).** The server picks the `SignatureVerifier` registered for the algorithm
(unknown → 401), checks the nonce format (16–128 characters of `[0-9A-Za-z_-]`) and that the
timestamp is within `maxSkewMs` (default 60 s). It then rejects a nonce the same public key
already used within that window (replay protection), and verifies the signature over the
received timestamp, nonce, path and raw body. The body is parsed only afterwards.
- **Why a nonce.** Ed25519 signatures are deterministic, so without it two identical requests
from one key at the same millisecond (two tabs or devices subscribing to the same path, parallel
identical reads) would be indistinguishable, and the second rejected as a replay.
- **Algorithms are pluggable.** Built in: `Ed25519Signer` / `Ed25519Verifier` (WebCrypto, so no
dependencies; Node 22+ and current browsers; keys persist as PKCS #8). Others, e.g. secp256k1 via
libauth, implement `RequestSigner` (`algorithm`, `publicKey`, `sign`) and `SignatureVerifier`
(`algorithm`, `verify`). The server can accept several algorithms at once.
- **Authorization (403): the public key is the user id.** Every key in `readMany` and `write`, and
every subscription path, must be `<publicKey>` or under
`<publicKey>/`. A scan must stay within `[<publicKey>, <publicKey>0)`, i.e. the key and its
subtree (escaping guarantees no other key falls in that range, including lookalikes such as
`<publicKey>x`).
- **Scoped events.** Each subscriber only receives changes under its own (authorized) paths.
- Proxies must preserve the URL path, because the signature covers it.
### 4.2 `KVStorageComposite({ source, replicas, queue?, prefix?, retryMs? })`
Keeps local copies of a source (e.g. `KVStorageHttp`) so reads are local and the app works
offline. With `prefix` (e.g. the user's public key) only that key and its subtree are synced, cached
and subscribed to; keys outside it throw `RangeError`. Without it, the whole source is copied, which
is only appropriate when the client may read all of it.
- **Replicas** are listed fastest first (e.g. `[inMemory, indexedDb]`); the last one seeds the
others on start. Reads come from the first.
- **The queue** is any KV store (default: in memory). It holds writes the source couldn't take
(`writes/<ulid>`) and whether a sync has completed (`synced`). Use a persistent node outside the
synced range, e.g. `indexedDb.child('~sync')` with `prefix: userKey`; a queue inside the range is
refused, since a sync would delete it.
- **Writes** are applied to every replica, then sent to the source, exactly as given.
- Applying locally first means the source relaying the write back finds nothing new.
- 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
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'`)
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.
- **`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, except
for keys with local writes the source hasn't confirmed yet (in flight or queued; counted per
key, and reloaded from a persistent queue on start), which would otherwise briefly revert →
replay again (writes made meanwhile) → mark `synced`. It runs on start, on `resync` (the stream
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
persistent queue, a restart reads locally straight away (offline too).
- `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
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).
### 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`, `readFrom`, `versionOf`, `streamsOf`. `BaseEventStorage` primitives over stream paths: `appendTo(stream, events)` → ids,
Node API: `append(events, opts?)` / `append(key, events, opts?)`, `read(opts?)` / `read(key, opts?)`, `readFrom(stream, { after, limit, reverse })`, `streamsOf`. Node API: `append(events)` /
`version(key?)`, `keys()`. The root is not a stream. An empty stream is version 0; the first event is 1. `append(key, events)` → ids, `read(opts?)` / `read(key, opts?)` → `{ id, at, data }`, `keys()`.
`expectedVersion: number | 'any'` (default `'any'`). The root is not a stream.
**`EventStorageKV`** (on any KV storage): **Appends never conflict.** Concurrent writers each add their own events, and every reader sees all
of them in the same order. There are no versions and no `expectedVersion`; folding events into
state is where concurrent changes meet (see event-sourced documents).
- One key per append, densely numbered: `<stream>/~e/<commit:12>` → `{ firstVersion, at, events }`. **`EventStorageKV`** (on any KV storage): each event is one plain KV write,
Keying by commit number (not version) means any writer with a stale view collides with the `<stream>/~e/<time:15>_<sha256(time + canonical Extended JSON of the event)>` → the event.
existing key.
- Append: head from a bounded cache (else one reverse scan, `limit: 1`) → check `expectedVersion` - Keys sort by time, so a stream reads in time order with one scan; `after` is an exclusive id.
(re-reading the head before rejecting, in case the cache is stale) → `writeIfAbsent`. On a lost race: - Events from different writers in the same millisecond order by hash: arbitrary, but the same for
`ConcurrencyError` if a version was expected, otherwise retry (default 5). every reader. One instance never reuses a time, so its own appends keep their order.
- `read({ from })`: commit `n` starts at a version ≥ n, so try commit `from` first (exact for - The id can be recomputed from the content, and re-sending an event (e.g. a retried request)
single-event commits), else binary-search; then forward-scan. writes the same key, so it can't be duplicated.
- Multi-writer safe on any store, because `writeIfAbsent` is a required primitive. - An append is one `writeMany`, so it works through `KVStorageComposite` offline and syncs like any
other data.
## 6. Document storage ## 6. Document storage
@@ -127,14 +262,14 @@ Node API: `get(key?)`, `getMany`, `set(doc)` / `set(key, doc)`, `insert(doc)`
- `EventSourcedDocumentStorage(events, projections)`: each document's `DocChange`s are appended to - `EventSourcedDocumentStorage(events, projections)`: each document's `DocChange`s are appended to
its own stream. Current state lives in **any** `BaseDocumentStorage` used as the projection store, its own stream. Current state lives in **any** `BaseDocumentStorage` used as the projection store,
which serves all reads, queries and indexes. which serves all reads, queries and indexes.
- Projections carry `_version` (reserved; stripped from results), used as `expectedVersion` on the - A write reads the projection, resolves the change (`$inc` → `set: { n: 6 }`), appends it, and
next append. applies it to the projection.
- If a write crashes between append and projection update, the next write conflicts, rebuilds the - Concurrent edits to different fields both survive; the same field ends with the later event's
projection from the stream, and retries. `catchUp(key?)` rebuilds on demand. value. (Increments from two devices don't add up: each resolves to a final value.)
- Missing projections with stream history (deleted documents, crashed first writes) are rebuilt - Events written elsewhere (another device, arriving through sync) are folded in by
before writing. `catchUp(key?)`, which rebuilds the projection from the whole stream. It also repairs a
- Writes to one document are serialised in-process, so retries only happen across processes. projection left behind by a crash between append and projection write.
- Extras: `history(key?)`, `getAt(version)` / `getAt(key, version)`. Children keep these methods - Extras: `history(key?)`, `getAt(eventId)` / `getAt(key, eventId)`. Children keep these methods
in their types through interface declaration merging (types only). in their types through interface declaration merging (types only).
## 7. Changes from the original brief ## 7. Changes from the original brief
@@ -147,12 +282,21 @@ Node API: `get(key?)`, `getMany`, `set(doc)` / `set(key, doc)`, `insert(doc)`
- Separator fixed to `/`, and every character ≤ `/` is escaped so child keys stay contiguous. This - Separator fixed to `/`, and every character ≤ `/` is escaped so child keys stay contiguous. This
removed the subtree-order workaround in child listing. removed the subtree-order workaround in child listing.
- Event-sourced documents are events + a projection store instead of a bespoke projection format. - Event-sourced documents are events + a projection store instead of a bespoke projection format.
- `writeIfAbsent` is a required primitive (no single-writer mutex fallback; no optional `batch` / - No conditional writes and no versions: KV stores execute exactly what they're given (so a server
`deleteRange`). never needs to read values, which may be encrypted), and events get time + content-hash ids so
concurrent appends never collide. Optimistic concurrency (`expectedVersion`) was dropped.
- `tree()` removed (use `entries()` for field-per-key reads). - `tree()` removed (use `entries()` for field-per-key reads).
## 8. Open questions ## 8. Open questions
- Composite: lazily syncing parts of a large subtree on first read; catching up without a full
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.
- 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.
- 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
encryption wrapper could support per-subject keys. encryption wrapper could support per-subject keys.
- Order-preserving numeric keys (`9` before `10`) as an opt-in key encoding. - Order-preserving numeric keys (`9` before `10`) as an opt-in key encoding.
@@ -167,9 +311,10 @@ Node API: `get(key?)`, `getMany`, `set(doc)` / `set(key, doc)`, `insert(doc)`
| KV `get` / `set` @ 100k keys | 2.0 / 2.3 µs | | KV `get` / `set` @ 100k keys | 2.0 / 2.3 µs |
| KV `getMany` × 100 | 25–35 µs | | KV `getMany` × 100 | 25–35 µs |
| `keys()` on 1k children × 100 descendants (skip-scan vs full scan) | 2.8 ms vs 34 ms | | `keys()` on 1k children × 100 descendants (skip-scan vs full scan) | 2.8 ms vs 34 ms |
| Event `append` (warm cache) | 7.8 µs | | Event `append` (one SHA-256 via WebCrypto + one write) | 72 µs |
| Event `read` 100 from version 5 000 of 10k | 131 µs | | Event `read` 100 from the middle of 10k | 119 µs |
| Event `read` full 10k stream | 18 ms | | Event `read` full 10k stream | 11 ms |
| Event-sourced document `update ($inc)` | 86 µs |
| Documents in-memory @ 10k: `get` / `update` | 1.8 / 6.6 µs | | Documents in-memory @ 10k: `get` / `update` | 1.8 / 6.6 µs |
| Documents in-memory: `find` indexed (~100 hits) / scan (~200 of 10k) | 0.20 / 11 ms | | Documents in-memory: `find` indexed (~100 hits) / scan (~200 of 10k) | 0.20 / 11 ms |
| Event-sourced @ 10k: `get` / `update` | 1.6 / 17 µs | | Event-sourced @ 10k: `get` / `update` | 1.6 / 17 µs |
+294
View File
@@ -0,0 +1,294 @@
# @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>`.
+8 -4
View File
@@ -4,15 +4,19 @@ import { KVStorageInMemory } from '../src/kv-storage/kv-storage-in-memory.js'
const events = new EventStorageKV(new KVStorageInMemory()) const events = new EventStorageKV(new KVStorageInMemory())
const streams = events.child('streams') const streams = events.child('streams')
for (let i = 0; i < 10_000; i++) await streams.append('long', [{ i }]) const ids = await streams.append(
'long',
Array.from({ length: 10_000 }, (_, i) => ({ i })),
)
const middle = ids[5000]
let s = 0 let s = 0
describe('events', () => { describe('events', () => {
bench('append 1 event (warm cache)', async () => { bench('append 1 event', async () => {
await streams.append(`s${s++ % 1000}`, [{ type: 'e' }]) await streams.append(`s${s++ % 1000}`, [{ type: 'e' }])
}) })
bench('read 100 events from version 5000 of 10k', async () => { bench('read 100 events from the middle of 10k', async () => {
for await (const _ of streams.read('long', { from: 5000, limit: 100 })) { for await (const _ of streams.read('long', { after: middle, limit: 100 })) {
} }
}) })
bench('read full 10k-event stream', async () => { bench('read full 10k-event stream', async () => {
+5 -4
View File
@@ -1,9 +1,10 @@
import { bench, describe } from 'vitest' import { bench, describe } from 'vitest'
import type { KVChange } from '../src/kv-storage/base-kv-storage.js'
import { KVStorageInMemory } from '../src/kv-storage/kv-storage-in-memory.js' import { KVStorageInMemory } from '../src/kv-storage/kv-storage-in-memory.js'
for (const n of [10_000, 100_000]) { for (const n of [10_000, 100_000]) {
const db = new KVStorageInMemory() const db = new KVStorageInMemory()
await db.writeMany(Array.from({ length: n }, (_, i) => [`users/u${i}`, { name: `user ${i}`, n: i }])) await db.writeMany(Array.from({ length: n }, (_, i) => ({ key: `users/u${i}`, value: { name: `user ${i}`, n: i } })))
const users = db.child('users') const users = db.child('users')
const keys = Array.from({ length: 100 }, (_, i) => `u${(i * 7919) % n}`) const keys = Array.from({ length: 100 }, (_, i) => `u${(i * 7919) % n}`)
let i = 0 let i = 0
@@ -21,10 +22,10 @@ for (const n of [10_000, 100_000]) {
} }
const tree = new KVStorageInMemory() const tree = new KVStorageInMemory()
const writes: [string, number][] = [] const writes: KVChange[] = []
for (let c = 0; c < 1000; c++) { for (let c = 0; c < 1000; c++) {
writes.push([`n/c${c}`, c]) writes.push({ key: `n/c${c}`, value: c })
for (let d = 0; d < 100; d++) writes.push([`n/c${c}/d${d}`, d]) for (let d = 0; d < 100; d++) writes.push({ key: `n/c${c}/d${d}`, value: d })
} }
await tree.writeMany(writes) await tree.writeMany(writes)
+327 -10
View File
@@ -1,29 +1,66 @@
{ {
"name": "potential-storage", "name": "@harvmaster/storage",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "potential-storage", "name": "@harvmaster/storage",
"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",
"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"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=22"
},
"peerDependencies": {
"vitest": ">=3"
},
"peerDependenciesMeta": {
"vitest": {
"optional": true
} }
},
"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": {
@@ -492,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",
@@ -842,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",
@@ -877,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",
@@ -992,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",
@@ -1067,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",
@@ -1136,6 +1307,16 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/fake-indexeddb": {
"version": "6.2.6",
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.6.tgz",
"integrity": "sha512-ihhmr0KoET51jT8R4YhbUYuLLvr1qqO6INISygtN16i8loR8kAxtAUr4+EKHTSMjL+lggaAn4OC7DRqzrNgnuQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/fast-check": { "node_modules/fast-check": {
"version": "4.10.2", "version": "4.10.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz",
@@ -1206,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",
@@ -1216,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",
@@ -1279,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",
@@ -1324,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",
@@ -1341,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",
@@ -1394,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",
@@ -1492,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",
@@ -1700,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
}
}
} }
} }
} }
+11 -3
View File
@@ -1,10 +1,10 @@
{ {
"name": "potential-storage", "name": "@harvmaster/storage",
"version": "0.1.0", "version": "0.1.0",
"description": "Layered TypeScript storage: KV -> Events -> Documents", "description": "Layered TypeScript storage: KV -> Events -> Documents",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=20" "node": ">=22"
}, },
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -14,22 +14,30 @@
"import": "./dist/index.js" "import": "./dist/index.js"
} }
}, },
"publishConfig": {
"registry": "https://verdaccio.harvmaster.com/"
},
"files": [ "files": [
"dist" "dist"
], ],
"scripts": { "scripts": {
"build": "tsc -p tsconfig.build.json", "build": "tsc -p tsconfig.build.json",
"prepack": "npm run build",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "tsc --noEmit && vitest run", "test": "tsc --noEmit && vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"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",
"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"
+38
View File
@@ -0,0 +1,38 @@
// Publishes the package for a branch: `main` → `latest`, `next` → `next`, any other branch →
// `dev-<branch>`. Usage: node scripts/publish.mjs <branch> [--dry-run]
//
// `main` publishes the version in package.json as is, and skips if it is already published (bump
// the version to release). Other branches publish a prerelease, `<version>-<tag>.<timestamp>`,
// so every push gets its own version. The registry and its token come from `.npmrc` / npm config.
import { execFileSync } from 'node:child_process'
import { readFileSync, writeFileSync } from 'node:fs'
const [branch, ...flags] = process.argv.slice(2)
if (!branch) throw new Error('Usage: node scripts/publish.mjs <branch> [--dry-run]')
const dryRun = flags.includes('--dry-run')
const npm = (...args) => execFileSync('npm', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }).trim()
const pkg = JSON.parse(readFileSync('package.json', 'utf8'))
const base = pkg.version.replace(/\+.*/, '')
const tag = branch === 'main' ? 'latest' : branch === 'next' ? 'next' : `dev-${branch.replace(/[^0-9A-Za-z-]/g, '-')}`
if (tag === 'latest') {
let published = ''
try {
published = npm('view', `${pkg.name}@${base}`, 'version')
} catch {
// Not found (or the package doesn't exist yet): publish it.
}
if (published) {
console.log(`${pkg.name}@${base} is already published; bump the version in package.json to release.`)
process.exit(0)
}
} else {
const timestamp = new Date().toISOString().replace(/\D/g, '')
pkg.version = `${base}${base.includes('-') ? '.' : '-'}${tag}.${timestamp}`
writeFileSync('package.json', `${JSON.stringify(pkg, null, 2)}\n`)
}
console.log(`Publishing ${pkg.name}@${pkg.version} with tag "${tag}"`)
execFileSync('npm', ['publish', '--tag', tag, ...(dryRun ? ['--dry-run'] : [])], { stdio: 'inherit' })
@@ -27,7 +27,7 @@ export type ChildDoc<S> = ValueOf<ChildOf<S, KeyOf<S>>>
*/ */
export abstract class BaseDocumentStorage<S extends Spec = AnySpec> { export abstract class BaseDocumentStorage<S extends Spec = AnySpec> {
/** Unescaped path of this node (`[]` for the root, which has no own document). */ /** Unescaped path of this node (`[]` for the root, which has no own document). */
path: readonly string[] = [] readonly path: readonly string[] = []
protected readonly ids = new Ulid() protected readonly ids = new Ulid()
abstract readMany(paths: (readonly string[])[]): Promise<(Doc | undefined)[]> abstract readMany(paths: (readonly string[])[]): Promise<(Doc | undefined)[]>
@@ -40,8 +40,11 @@ export abstract class BaseDocumentStorage<S extends Spec = AnySpec> {
abstract query(parent: readonly string[], query: DocumentQuery): AsyncIterable<FindResult> abstract query(parent: readonly string[], query: DocumentQuery): AsyncIterable<FindResult>
child<K extends KeyOf<S>>(key: K): BaseDocumentStorage<ChildOf<S, K>> { child<K extends KeyOf<S>>(key: K): BaseDocumentStorage<ChildOf<S, K>> {
const node = Object.create(this) as BaseDocumentStorage<ChildOf<S, K>> const node = Object.create(this, { path: { value: [...this.path, Keys.segment(key)], enumerable: true } }) as BaseDocumentStorage<ChildOf<S, K>>
node.path = [...this.path, Keys.segment(key)] // Frozen: child nodes share the root's state through the prototype, so an implementation that
// reassigns a field (`this.x = ...`) while running on a child would silently shadow it.
// Keep mutable state in objects (Maps, a `state` object) instead; this makes mistakes throw.
Object.freeze(node)
return node return node
} }
+1 -1
View File
@@ -26,7 +26,7 @@ export class DocumentStorageKV<S extends Spec = AnySpec> extends BaseDocumentSto
const resolved = change(current) const resolved = change(current)
if (!resolved) return current if (!resolved) return current
const next = DocumentUpdate.apply(current, resolved) const next = DocumentUpdate.apply(current, resolved)
if (next) await this.kv.writeMany([[key, next]]) if (next) await this.kv.writeMany([{ key, value: next }])
else await this.kv.removeMany([key]) else await this.kv.removeMany([key])
return next return next
} }
@@ -2,15 +2,9 @@ import type { BaseEventStorage, StoredEvent } from '../event-storage/base-event-
import type { Doc } from '../utils/document-path.js' import type { Doc } from '../utils/document-path.js'
import type { DocumentQuery, FindResult } from '../utils/document-query.js' import type { DocumentQuery, FindResult } from '../utils/document-query.js'
import { DocumentUpdate, type DocChange } from '../utils/document-update.js' import { DocumentUpdate, type DocChange } from '../utils/document-update.js'
import { ConcurrencyError } from '../utils/errors.js'
import type { AnySpec, ChildOf, KeyOf, Spec, ValueAt } from '../utils/schema.js' import type { AnySpec, ChildOf, KeyOf, Spec, ValueAt } from '../utils/schema.js'
import { BaseDocumentStorage } from './base-document-storage.js' import { BaseDocumentStorage } from './base-document-storage.js'
export interface EventSourcedDocumentStorageOptions {
/** Retries after a write conflict, each preceded by a catch-up. Default 5. */
maxRetries?: number
}
// Types only: children of an event-sourced store are event-sourced stores too (child() creates // Types only: children of an event-sourced store are event-sourced stores too (child() creates
// them with Object.create, so they share this class's prototype). // them with Object.create, so they share this class's prototype).
export interface EventSourcedDocumentStorage<S extends Spec = AnySpec> { export interface EventSourcedDocumentStorage<S extends Spec = AnySpec> {
@@ -18,108 +12,66 @@ export interface EventSourcedDocumentStorage<S extends Spec = AnySpec> {
} }
/** /**
* Documents as event streams: each document's changes are appended to its own stream, and the * Documents as event streams: each change to a document is appended to its own stream, and the
* current state is kept in a projection store (any `BaseDocumentStorage`), which serves all * current state is kept in a projection store (any `BaseDocumentStorage`), which serves all reads
* reads and queries. * and queries.
* *
* Projections carry a `_version` field (stripped from results) used as `expectedVersion` on the * A document's state is its events folded in order. Changes are stored as resolved values
* next append. If a write crashes between appending and updating the projection, the next * (`$inc` becomes `set: { n: 6 }`), so two devices editing the same field concurrently end with
* write's append conflicts, the projection is rebuilt from the stream, and the write retries. * the later event's value; edits to different fields both survive.
* `_version` is therefore reserved in documents. *
* The projection is updated by this store's own writes. Events written elsewhere (another device,
* arriving through sync) are folded in by `catchUp(key)`, which rebuilds the document from its
* whole stream; call it when such events arrive.
*/ */
export class EventSourcedDocumentStorage<S extends Spec = AnySpec> extends BaseDocumentStorage<S> { export class EventSourcedDocumentStorage<S extends Spec = AnySpec> extends BaseDocumentStorage<S> {
private readonly maxRetries: number
/** Tail of the pending writes per document, so writes in one process don't conflict with each other. */
private readonly locks = new Map<string, Promise<unknown>>()
constructor( constructor(
private readonly events: BaseEventStorage, private readonly events: BaseEventStorage,
private readonly projections: BaseDocumentStorage, private readonly projections: BaseDocumentStorage,
options: EventSourcedDocumentStorageOptions = {},
) { ) {
super() super()
this.maxRetries = options.maxRetries ?? 5
} }
async readMany(paths: (readonly string[])[]): Promise<(Doc | undefined)[]> { readMany(paths: (readonly string[])[]): Promise<(Doc | undefined)[]> {
return (await this.projections.readMany(paths)).map((doc) => EventSourcedDocumentStorage.strip(doc)) return this.projections.readMany(paths)
} }
async *query(parent: readonly string[], query: DocumentQuery): AsyncGenerator<FindResult> { query(parent: readonly string[], query: DocumentQuery): AsyncIterable<FindResult> {
for await (const { key, data } of this.projections.query(parent, query)) yield { key, data: EventSourcedDocumentStorage.strip(data)! } return this.projections.query(parent, query)
} }
mutate(path: readonly string[], change: (current: Doc | undefined) => DocChange | null): Promise<Doc | undefined> { async mutate(path: readonly string[], change: (current: Doc | undefined) => DocChange | null): Promise<Doc | undefined> {
const id = JSON.stringify(path) const [current] = await this.projections.readMany([path])
const result = (this.locks.get(id) ?? Promise.resolve()).then(() => this.write(path, change))
const tail = result.catch(() => {})
this.locks.set(id, tail)
void tail.then(() => this.locks.get(id) === tail && this.locks.delete(id))
return result
}
private async write(path: readonly string[], change: (current: Doc | undefined) => DocChange | null): Promise<Doc | undefined> {
for (let attempt = 0; ; attempt++) {
const [stored] = await this.projections.readMany([path])
const version = (stored?._version as number | undefined) ?? (await this.events.versionOf(path))
if (!stored && version > 0) {
// No projection but the stream has history (deleted, or a crashed first write): rebuild.
if ((await this.rebuild(path)) !== undefined) continue
}
const current = EventSourcedDocumentStorage.strip(stored)
const resolved = change(current) const resolved = change(current)
if (!resolved) return current if (!resolved) return current
const next = DocumentUpdate.apply(current, resolved) await this.events.appendTo(path, [resolved])
try { // Apply the change itself (not the state computed above), so concurrent writes in this
await this.events.appendTo(path, [resolved], version) // process each land on the latest projection.
} catch (error) { return this.projections.mutate(path, () => resolved)
if (!(error instanceof ConcurrencyError) || attempt >= this.maxRetries) throw error
await this.rebuild(path)
continue
}
await this.project(path, next, version + 1)
return next
}
} }
/** Events of this document (or a child). */ /** Events of this document (or a child), oldest first. */
history(key?: KeyOf<S>): AsyncIterable<StoredEvent<DocChange>> { history(key?: KeyOf<S>): AsyncIterable<StoredEvent<DocChange>> {
return this.events.readFrom(this.pathOf(arguments.length > 0, key), {}) as AsyncIterable<StoredEvent<DocChange>> return this.events.readFrom(this.pathOf(arguments.length > 0, key), {}) as AsyncIterable<StoredEvent<DocChange>>
} }
/** State as of `version`: `getAt(version)` for this document, `getAt(key, version)` for a child. */ /** State right after event `eventId`: `getAt(eventId)` for this document, `getAt(key, eventId)` for a child. */
async getAt<K extends KeyOf<S>>(...args: [version: number] | [key: K, version: number]): Promise<ValueAt<S, K> | undefined> { async getAt<K extends KeyOf<S>>(...args: [eventId: string] | [key: K, eventId: string]): Promise<ValueAt<S, K> | undefined> {
const path = this.pathOf(args.length > 1, args[0]) const until = args[args.length - 1] as string
let state: Doc | undefined let state: Doc | undefined
for await (const event of this.events.readFrom(path, { to: args[args.length - 1] as number })) { for await (const event of this.events.readFrom(this.pathOf(args.length > 1, args[0]), {})) {
if (event.id > until) break
state = DocumentUpdate.apply(state, event.data as DocChange) state = DocumentUpdate.apply(state, event.data as DocChange)
} }
return state as ValueAt<S, K> | undefined return state as ValueAt<S, K> | undefined
} }
/** Rebuilds the projection of this document (or a child) from its stream. Returns the state. */ /** Rebuilds the projection of this document (or a child) from all its events. Returns the state. */
catchUp(key?: KeyOf<S>): Promise<Doc | undefined> { async catchUp(key?: KeyOf<S>): Promise<Doc | undefined> {
return this.rebuild(this.pathOf(arguments.length > 0, key)) const path = this.pathOf(arguments.length > 0, key)
}
private async rebuild(path: readonly string[]): Promise<Doc | undefined> {
let state: Doc | undefined let state: Doc | undefined
let version = 0 for await (const event of this.events.readFrom(path, {})) state = DocumentUpdate.apply(state, event.data as DocChange)
for await (const event of this.events.readFrom(path, {})) { await this.projections.mutate(path, () => (state ? { op: 'set', doc: state } : { op: 'delete' }))
state = DocumentUpdate.apply(state, event.data as DocChange)
version = event.version
}
await this.project(path, state, version)
return state
}
private async project(path: readonly string[], state: Doc | undefined, version: number): Promise<void> {
await this.projections.mutate(path, () => (state ? { op: 'set', doc: { ...state, _version: version } } : { op: 'delete' }))
}
private static strip(doc: Doc | undefined): Doc | undefined {
if (!doc) return undefined
const { _version, ...state } = doc
return state return state
} }
} }
+22 -33
View File
@@ -1,27 +1,17 @@
import { Keys } from '../utils/keys.js' import { Keys } from '../utils/keys.js'
import type { AnySpec, ChildOf, KeyOf, KeyString, Spec, ValueAt, ValueOf } from '../utils/schema.js' import type { AnySpec, ChildOf, KeyOf, KeyString, Spec, ValueAt, ValueOf } from '../utils/schema.js'
export interface AppendOptions {
/** Version the stream must be at (0 = empty). Default `'any'`. */
expectedVersion?: number | 'any'
}
export interface AppendResult {
/** Stream version after the append. */
version: number
}
export interface ReadOptions { export interface ReadOptions {
/** First version to read (inclusive). Default 1. */ /** Start after this event id (exclusive), in the direction of travel. */
from?: number after?: string
/** Last version to read (inclusive). Default: the head. */
to?: number
limit?: number limit?: number
reverse?: boolean
} }
export interface StoredEvent<E> { export interface StoredEvent<E> {
version: number /** Unique, time-ordered id (see the implementation for its format). */
/** Append timestamp (ms since epoch). */ id: string
/** Append time (ms since epoch). */
at: number at: number
data: E data: E
} }
@@ -34,21 +24,28 @@ export interface StreamKeysOptions {
/** /**
* Append-only event streams. Every node is a stream (its own events) and a parent of child * Append-only event streams. Every node is a stream (its own events) and a parent of child
* streams. Implementations provide four primitives over stream paths; the user-facing, * streams. Implementations provide three primitives over stream paths; the user-facing,
* self-or-child API is defined here once. * self-or-child API is defined here once.
*
* Appends never conflict: concurrent writers each add their own events, and every reader sees all
* of them in the same (time) order. Folding events into state is where concurrent changes meet.
*/ */
export abstract class BaseEventStorage<S extends Spec = AnySpec> { export abstract class BaseEventStorage<S extends Spec = AnySpec> {
/** Unescaped path of this node (`[]` for the root, which is not a stream). */ /** Unescaped path of this node (`[]` for the root, which is not a stream). */
path: readonly string[] = [] readonly path: readonly string[] = []
abstract appendTo(stream: readonly string[], events: unknown[], expectedVersion: number | 'any'): Promise<AppendResult> /** Appends events in order; returns their ids. */
abstract appendTo(stream: readonly string[], events: unknown[]): Promise<string[]>
/** Events in id (time) order. */
abstract readFrom(stream: readonly string[], options: ReadOptions): AsyncIterable<StoredEvent<unknown>> abstract readFrom(stream: readonly string[], options: ReadOptions): AsyncIterable<StoredEvent<unknown>>
abstract versionOf(stream: readonly string[]): Promise<number>
abstract streamsOf(parent: readonly string[], options: StreamKeysOptions): AsyncIterable<string> abstract streamsOf(parent: readonly string[], options: StreamKeysOptions): AsyncIterable<string>
child<K extends KeyOf<S>>(key: K): BaseEventStorage<ChildOf<S, K>> { child<K extends KeyOf<S>>(key: K): BaseEventStorage<ChildOf<S, K>> {
const node = Object.create(this) as BaseEventStorage<ChildOf<S, K>> const node = Object.create(this, { path: { value: [...this.path, Keys.segment(key)], enumerable: true } }) as BaseEventStorage<ChildOf<S, K>>
node.path = [...this.path, Keys.segment(key)] // Frozen: child nodes share the root's state through the prototype, so an implementation that
// reassigns a field (`this.x = ...`) while running on a child would silently shadow it.
// Keep mutable state in objects (Maps, a `state` object) instead; this makes mistakes throw.
Object.freeze(node)
return node return node
} }
@@ -56,15 +53,12 @@ export abstract class BaseEventStorage<S extends Spec = AnySpec> {
return this as unknown as BaseEventStorage<S2> return this as unknown as BaseEventStorage<S2>
} }
/** `append(events, options?)` on this stream; `append(key, events, options?)` on a child stream. */ /** `append(events)` to this stream; `append(key, events)` to a child stream. Returns the event ids. */
append<K extends KeyOf<S>>( append<K extends KeyOf<S>>(...args: [events: ValueOf<S>[]] | [key: K, events: ValueOf<ChildOf<S, K>>[]]): Promise<string[]> {
...args: [events: ValueOf<S>[], options?: AppendOptions] | [key: K, events: ValueOf<ChildOf<S, K>>[], options?: AppendOptions]
): Promise<AppendResult> {
const hasKey = !Array.isArray(args[0]) const hasKey = !Array.isArray(args[0])
const events = args[hasKey ? 1 : 0] const events = args[hasKey ? 1 : 0]
const options = (args[hasKey ? 2 : 1] ?? {}) as AppendOptions
if (!Array.isArray(events)) return Promise.reject(new TypeError('append() expects an array of events')) if (!Array.isArray(events)) return Promise.reject(new TypeError('append() expects an array of events'))
return this.withStream(hasKey, args[0], (stream) => this.appendTo(stream, events, options.expectedVersion ?? 'any')) return this.withStream(hasKey, args[0], (stream) => this.appendTo(stream, events))
} }
/** `read(options?)` this stream; `read(key, options?)` a child stream. */ /** `read(options?)` this stream; `read(key, options?)` a child stream. */
@@ -74,11 +68,6 @@ export abstract class BaseEventStorage<S extends Spec = AnySpec> {
yield* this.readFrom(stream, ((hasKey ? args[1] : args[0]) ?? {}) as ReadOptions) as AsyncIterable<StoredEvent<ValueAt<S, K>>> yield* this.readFrom(stream, ((hasKey ? args[1] : args[0]) ?? {}) as ReadOptions) as AsyncIterable<StoredEvent<ValueAt<S, K>>>
} }
/** Current version of this stream (or a child's); 0 when empty. */
version(key?: KeyOf<S>): Promise<number> {
return this.withStream(arguments.length > 0, key, (stream) => this.versionOf(stream))
}
/** Keys of child streams. */ /** Keys of child streams. */
keys(options: StreamKeysOptions = {}): AsyncIterable<KeyString<S>> { keys(options: StreamKeysOptions = {}): AsyncIterable<KeyString<S>> {
return this.streamsOf(this.path, options) as AsyncIterable<KeyString<S>> return this.streamsOf(this.path, options) as AsyncIterable<KeyString<S>>
+52 -117
View File
@@ -1,151 +1,86 @@
import type { BaseKVStorage } from '../kv-storage/base-kv-storage.js' import type { BaseKVStorage } from '../kv-storage/base-kv-storage.js'
import { ConcurrencyError } from '../utils/errors.js' import { ExtendedJson } from '../utils/extended-json.js'
import { Hex } from '../utils/hex.js'
import { Keys } from '../utils/keys.js' import { Keys } from '../utils/keys.js'
import type { AnySpec, Spec } from '../utils/schema.js' import type { AnySpec, ChildOf, KeyOf, Spec } from '../utils/schema.js'
import { BaseEventStorage, type AppendResult, type ReadOptions, type StoredEvent, type StreamKeysOptions } from './base-event-storage.js' import { BaseEventStorage, type ReadOptions, type StoredEvent, type StreamKeysOptions } from './base-event-storage.js'
/** Stored value of one commit key. */
interface Commit {
firstVersion: number
at: number
events: unknown[]
}
interface Head {
commit: number
version: number
}
export interface EventStorageKVOptions { export interface EventStorageKVOptions {
/** Retries after losing a commit race when `expectedVersion` is `'any'`. Default 5. */
maxRetries?: number
/** Max streams whose head is cached. Default 10 000. */
cacheSize?: number
now?: () => number now?: () => number
} }
// Types only: children of an EventStorageKV are EventStorageKV nodes too (child() creates them with
// Object.create, so they share this class's prototype).
export interface EventStorageKV<S extends Spec = AnySpec> {
child<K extends KeyOf<S>>(key: K): EventStorageKV<ChildOf<S, K>>
}
/** /**
* Event storage on any KV storage. Each append is one commit key, densely numbered: * Event storage on any KV storage. Each event is one plain write:
* `<stream>/~e/<commit:12>` → `{ firstVersion, at, events }`. Commits are claimed with * `<stream>/~e/<time:15>_<hash>` → the event, where `hash` is the SHA-256 (hex) of the time and
* `writeIfAbsent`, so a writer with a stale view always collides with the existing key. * the event's canonical Extended JSON.
*
* - Concurrent appends from different writers get different keys, so all are kept.
* - Keys sort by time, so a stream reads in time order; equal times order by hash, the same way
* for every reader. One instance never reuses a time, so its own appends keep their order.
* - The key can be recomputed from the content, and writing the same event again writes the same
* key (e.g. a retried request), so it can't be duplicated.
*/ */
export class EventStorageKV<S extends Spec = AnySpec> extends BaseEventStorage<S> { export class EventStorageKV<S extends Spec = AnySpec> extends BaseEventStorage<S> {
private readonly heads = new Map<string, Head>()
private readonly maxRetries: number
private readonly cacheSize: number
private readonly now: () => number private readonly now: () => number
/** Mutable state lives here: child nodes share this object (see `child()`). */
private readonly state = { lastTime: 0 }
constructor( constructor(
private readonly kv: BaseKVStorage, private readonly kv: BaseKVStorage,
options: EventStorageKVOptions = {}, options: EventStorageKVOptions = {},
) { ) {
super() super()
this.maxRetries = options.maxRetries ?? 5
this.cacheSize = options.cacheSize ?? 10_000
this.now = options.now ?? Date.now this.now = options.now ?? Date.now
} }
async appendTo(stream: readonly string[], events: unknown[], expectedVersion: number | 'any'): Promise<AppendResult> { async appendTo(stream: readonly string[], events: unknown[]): Promise<string[]> {
const base = this.commitPrefix(stream) const base = this.eventsPrefix(stream)
for (let attempt = 0; ; attempt++) { const entries = await Promise.all(
let head = this.heads.get(base) ?? (await this.loadHead(base)) events.map(async (data) => {
// The cache may be stale; only trust a mismatch after re-reading. this.state.lastTime = Math.max(this.now(), this.state.lastTime + 1)
if (expectedVersion !== 'any' && head.version !== expectedVersion) head = await this.loadHead(base) const id = await EventStorageKV.id(this.state.lastTime, data)
if (expectedVersion !== 'any' && head.version !== expectedVersion) throw new ConcurrencyError(base, expectedVersion, head.version) return { key: base + id, value: data }
if (events.length === 0) return { version: head.version } }),
)
const commit: Commit = { firstVersion: head.version + 1, at: this.now(), events } await this.kv.writeMany(entries)
const [written] = await this.kv.writeIfAbsent([[base + EventStorageKV.pad(head.commit + 1), commit]]) return entries.map(({ key }) => key.slice(base.length))
if (written) {
const next = { commit: head.commit + 1, version: head.version + events.length }
this.cacheHead(base, next)
return { version: next.version }
}
this.heads.delete(base)
if (expectedVersion !== 'any' || attempt >= this.maxRetries) {
throw new ConcurrencyError(base, expectedVersion, (await this.loadHead(base)).version)
}
}
} }
async *readFrom(stream: readonly string[], options: ReadOptions): AsyncGenerator<StoredEvent<unknown>> { async *readFrom(stream: readonly string[], options: ReadOptions): AsyncGenerator<StoredEvent<unknown>> {
const from = Math.max(1, options.from ?? 1) const base = this.eventsPrefix(stream)
const to = options.to ?? Infinity const { after, reverse = false, limit } = options
let remaining = options.limit ?? Infinity // `after` is exclusive: start at it and skip it.
if (to < from || remaining <= 0) return const bounds = reverse
const base = this.commitPrefix(stream) ? { gte: base, lt: after === undefined ? base.slice(0, -1) + Keys.afterSeparator : base + after }
const start = from === 1 ? 1 : await this.findCommit(base, from) : { gte: base + (after ?? ''), lt: base.slice(0, -1) + Keys.afterSeparator }
if (start === 0) return let remaining = limit ?? Infinity
for await (const [, value] of this.kv.scan({ gte: base + EventStorageKV.pad(start), lt: this.commitEnd(base), values: true })) { for await (const [key, data] of this.kv.scan({ ...bounds, reverse, values: true })) {
const commit = value as Commit if (remaining <= 0) return
for (let i = 0; i < commit.events.length; i++) { const id = key.slice(base.length)
const version = commit.firstVersion + i if (id === after) continue
if (version < from) continue remaining--
if (version > to || remaining-- <= 0) return yield { id, at: Number(id.slice(0, 15)), data }
yield { version, at: commit.at, data: commit.events[i] }
} }
} }
}
async versionOf(stream: readonly string[]): Promise<number> {
return (await this.loadHead(this.commitPrefix(stream))).version
}
async *streamsOf(parent: readonly string[], options: StreamKeysOptions): AsyncGenerator<string> { async *streamsOf(parent: readonly string[], options: StreamKeysOptions): AsyncGenerator<string> {
yield* parent.reduce<BaseKVStorage>((node, segment) => node.child(segment), this.kv).keys(options) yield* parent.reduce<BaseKVStorage>((node, segment) => node.child(segment), this.kv).keys(options)
} }
/** Last commit of a stream: one reverse scan with limit 1. Refreshes the cache. */ /** `<time:15>_<sha256(time + canonical data)>`. */
private async loadHead(base: string): Promise<Head> { static async id(time: number, data: unknown): Promise<string> {
for await (const [key, value] of this.kv.scan({ gte: base, lt: this.commitEnd(base), reverse: true, limit: 1, values: true })) { const stamp = String(time).padStart(15, '0')
const commit = value as Commit const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(stamp + ExtendedJson.canonical(data)))
const head = { commit: Number(key.slice(base.length)), version: commit.firstVersion + commit.events.length - 1 } return `${stamp}_${Hex.encode(new Uint8Array(digest))}`
this.cacheHead(base, head)
return head
}
return { commit: 0, version: 0 }
} }
/** private eventsPrefix(stream: readonly string[]): string {
* Commit containing `version`, or 0 if the stream is shorter. Commit `n` starts at a version return `${this.kv.prefix}${Keys.join(stream)}/~e/`
* >= n, so it's at most `version`: try that first (exact when commits hold one event each),
* then binary-search the commits below it.
*/
private async findCommit(base: string, version: number): Promise<number> {
const firstVersion = async (commit: number) => ((await this.kv.readMany([base + EventStorageKV.pad(commit)]))[0] as Commit | undefined)?.firstVersion
const guess = await firstVersion(version)
if (guess !== undefined && guess <= version) return version
let hi = version - 1
if (guess === undefined) {
const head = await this.loadHead(base)
if (head.version < version) return 0
hi = head.commit
}
let lo = 1
while (lo < hi) {
const mid = (lo + hi + 1) >>> 1
if ((await firstVersion(mid))! <= version) lo = mid
else hi = mid - 1
}
return lo
}
private cacheHead(base: string, head: Head): void {
this.heads.delete(base)
this.heads.set(base, head)
if (this.heads.size > this.cacheSize) this.heads.delete(this.heads.keys().next().value!)
}
private commitPrefix(stream: readonly string[]): string {
const prefix = this.kv.prefix + Keys.join(stream)
return `${prefix}/~e/`
}
private commitEnd(base: string): string {
return base.slice(0, -1) + Keys.afterSeparator
}
private static pad(commit: number): string {
return String(commit).padStart(12, '0')
} }
} }
+8
View File
@@ -2,6 +2,9 @@ export * from './kv-storage/base-kv-storage.js'
export * from './kv-storage/kv-storage-in-memory.js' export * from './kv-storage/kv-storage-in-memory.js'
export * from './kv-storage/kv-storage-http.js' 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-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'
@@ -12,6 +15,11 @@ export * from './utils/document-path.js'
export * from './utils/document-query.js' export * from './utils/document-query.js'
export * from './utils/document-update.js' export * from './utils/document-update.js'
export * from './utils/errors.js' export * from './utils/errors.js'
export * from './utils/event-emitter.js'
export * from './utils/extended-json.js'
export * from './utils/hex.js'
export * from './utils/key-range.js'
export * from './utils/request-auth.js'
export * from './utils/json.js' export * from './utils/json.js'
export * from './utils/keys.js' export * from './utils/keys.js'
export * from './utils/schema.js' export * from './utils/schema.js'
+71 -16
View File
@@ -1,3 +1,4 @@
import { EventEmitter } from '../utils/event-emitter.js'
import { Keys } from '../utils/keys.js' import { Keys } from '../utils/keys.js'
import type { AnySpec, ChildOf, EntryOf, KeyOf, KeyString, Spec, StringEntryOf, ValueAt, ValueOf } from '../utils/schema.js' import type { AnySpec, ChildOf, EntryOf, KeyOf, KeyString, Spec, StringEntryOf, ValueAt, ValueOf } from '../utils/schema.js'
@@ -19,39 +20,81 @@ export interface KeysOptions {
reverse?: boolean reverse?: boolean
} }
/** One key's new value; `value: undefined` means it was removed. */
export interface KVChange {
key: string
value?: unknown
}
export interface KVChangeEvent {
changes: KVChange[]
/** `local`: made through this instance. `remote`: observed from elsewhere (e.g. pushed by a server). */
origin: 'local' | 'remote'
}
export type KVStorageEvents = {
change: KVChangeEvent
/** Changes may have been missed (e.g. a dropped connection was restored): consumers should re-read. */
resync: void
}
/** /**
* Ordered key-value storage. Implementations provide five raw primitives over absolute keys; * Ordered key-value storage. Implementations provide three primitives over absolute keys:
* everything else (navigation, self/child reads and writes, child listing) is defined here once. * `readMany`, `scan` and the protected `write`. Everything else (navigation, self/child reads and
* writes, child listing, change events) is defined here once.
*
* Writes are plain operations: set a key, or delete it. There are no versions or conditions;
* concurrent writers to one key end with whichever write arrived last. Use event storage when
* concurrent writes must all be kept.
*
* Every write emits a `change` event from the storage (shared by all its child nodes).
* *
* Every instance is a node: `child(key)` returns a view of the same storage rooted at that key. * Every instance is a node: `child(key)` returns a view of the same storage rooted at that key.
* Methods taking an optional `key` act on that child, or on this node's own value when omitted. * Methods taking an optional `key` act on that child, or on this node's own value when omitted.
* A key that is passed but `undefined`/`null` throws rather than silently meaning "self". * A key that is passed but `undefined`/`null` throws rather than silently meaning "self".
*/ */
export abstract class BaseKVStorage<S extends Spec = AnySpec> { export abstract class BaseKVStorage<S extends Spec = AnySpec> extends EventEmitter<KVStorageEvents> {
/** Unescaped path of this node (`[]` for the root). */ /** Unescaped path of this node (`[]` for the root). */
path: readonly string[] = [] readonly path: readonly string[] = []
/** This node's own key (`''` for the root). */ /** This node's own key (`''` for the root). */
key = '' readonly key: string = ''
/** Prefix of every descendant key (`''` for the root). */ /** Prefix of every descendant key (`''` for the root). */
prefix = '' readonly prefix: string = ''
/** Values for absolute keys, in order; `undefined` for missing. */ /** Values for absolute keys, in order; `undefined` for missing. */
abstract readMany(keys: string[]): Promise<unknown[]> abstract readMany(keys: string[]): Promise<unknown[]>
abstract writeMany(entries: [key: string, value: unknown][]): Promise<void>
abstract removeMany(keys: string[]): Promise<void>
/** Writes each entry only if its key is absent; `true` = written. Must be atomic per key. */
abstract writeIfAbsent(entries: [key: string, value: unknown][]): Promise<boolean[]>
/** Entries in key order (descending when `reverse`). */ /** Entries in key order (descending when `reverse`). */
abstract scan(options?: ScanOptions): AsyncIterable<[key: string, value: unknown]> abstract scan(options?: ScanOptions): AsyncIterable<[key: string, value: unknown]>
/** Applies changes in order: sets each key to its value, or deletes it when the value is `undefined`. */
protected abstract write(changes: KVChange[]): Promise<void>
// ------------------------------------------------------------------ raw writes (absolute keys)
/** Applies changes (`value: undefined` deletes) and emits them as a `change` event. */
async writeMany(changes: KVChange[]): Promise<void> {
if (changes.length === 0) return
await this.write(changes)
this.emit('change', { changes, origin: 'local' })
}
async removeMany(keys: string[]): Promise<void> {
await this.writeMany(keys.map((key) => ({ key })))
}
// ------------------------------------------------------------------ navigation // ------------------------------------------------------------------ navigation
/** A view of this storage rooted at `key`. Synchronous; no I/O. */ /** A view of this storage rooted at `key`. Synchronous; no I/O. */
child<K extends KeyOf<S>>(key: K): BaseKVStorage<ChildOf<S, K>> { child<K extends KeyOf<S>>(key: K): BaseKVStorage<ChildOf<S, K>> {
const node = Object.create(this) as BaseKVStorage<ChildOf<S, K>> const own = this.prefix + Keys.escape(Keys.segment(key))
node.path = [...this.path, Keys.segment(key)] const node = Object.create(this, {
node.key = this.prefix + Keys.escape(Keys.segment(key)) path: { value: [...this.path, Keys.segment(key)], enumerable: true },
node.prefix = node.key + Keys.separator key: { value: own, enumerable: true },
prefix: { value: own + Keys.separator, enumerable: true },
}) as BaseKVStorage<ChildOf<S, K>>
// Frozen: child nodes share the root's state through the prototype, so an implementation that
// reassigns a field (`this.x = ...`) while running on a child would silently shadow it.
// Keep mutable state in objects (Maps, a `state` object) instead; this makes mistakes throw.
Object.freeze(node)
return node return node
} }
@@ -75,12 +118,24 @@ export abstract class BaseKVStorage<S extends Spec = AnySpec> {
async set<K extends KeyOf<S>>(...args: [value: ValueOf<S>] | [key: K, value: ValueOf<ChildOf<S, K>>]): Promise<void> { async set<K extends KeyOf<S>>(...args: [value: ValueOf<S>] | [key: K, value: ValueOf<ChildOf<S, K>>]): Promise<void> {
const value = args[args.length - 1] const value = args[args.length - 1]
if (value === undefined) throw new TypeError('Cannot set undefined; use delete()') if (value === undefined) throw new TypeError('Cannot set undefined; use delete()')
await this.writeMany([[this.keyOf(args.length > 1, args[0]), value]]) await this.writeMany([{ key: this.keyOf(args.length > 1, args[0]), value }])
} }
async setMany(entries: readonly EntryOf<S>[]): Promise<void> { async setMany(entries: readonly EntryOf<S>[]): Promise<void> {
if (entries.some(([, v]) => v === undefined)) throw new TypeError('Cannot set undefined; use delete()') if (entries.some(([, v]) => v === undefined)) throw new TypeError('Cannot set undefined; use delete()')
await this.writeMany(entries.map(([k, v]) => [this.keyOf(true, k), v])) await this.writeMany(entries.map(([k, value]) => ({ key: this.keyOf(true, k), value })))
}
/** Writes each property of `fields` as a child key (one write). Nested objects stay single values. */
async setFields(fields: { [K in KeyString<S>]?: ValueOf<ChildOf<S, K & KeyOf<S>>> }): Promise<void> {
await this.setMany(Object.entries(fields).filter(([, v]) => v !== undefined) as unknown as EntryOf<S>[])
}
/** Reads the direct children that have a value into an object. */
async getFields(): Promise<{ [K in KeyString<S>]?: ValueOf<ChildOf<S, K & KeyOf<S>>> }> {
const fields: Record<string, unknown> = {}
for await (const [key, value] of this.entries()) fields[key] = value
return fields as { [K in KeyString<S>]?: ValueOf<ChildOf<S, K & KeyOf<S>>> }
} }
/** Deletes this node's own value (or a child's), not descendants. */ /** Deletes this node's own value (or a child's), not descendants. */
+326
View File
@@ -0,0 +1,326 @@
import { StorageUnavailableError } from '../utils/errors.js'
import type { OffCallback } from '../utils/event-emitter.js'
import { Json } from '../utils/json.js'
import { KeyRange } from '../utils/key-range.js'
import type { AnySpec, ChildOf, KeyOf, Spec } from '../utils/schema.js'
import { Ulid } from '../utils/ulid.js'
import { BaseKVStorage, type KVChange, type ScanOptions } from './base-kv-storage.js'
import { KVStorageInMemory } from './kv-storage-in-memory.js'
export interface KVStorageCompositeOptions {
/** The source of truth, e.g. `KVStorageHttp`. */
source: BaseKVStorage
/** Local copies, fastest first, e.g. `[inMemory, indexedDb]`. The last one seeds the others on start. */
replicas: BaseKVStorage[]
/**
* Holds writes made while the source is unreachable (under its `writes` child) and whether a
* sync has completed (`synced`). Use a persistent node outside `prefix`, e.g.
* `indexedDb.child('sync')`, so queued writes survive a reload. Default: in memory.
*/
queue?: BaseKVStorage
/** Only this key and its subtree are synced (e.g. the user's public key). Default: everything. */
prefix?: string
/** Delay before retrying when the source is unreachable. Default 5 000 ms. */
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`). */
interface Subscribable {
subscribe(path: string): Promise<void>
unsubscribe(path: string): Promise<void>
}
// Types only: children of a KVStorageComposite are KVStorageComposite nodes too (child() creates
// them with Object.create, so they share this class's prototype).
export interface KVStorageComposite<S extends Spec = AnySpec> {
child<K extends KeyOf<S>>(key: K): KVStorageComposite<ChildOf<S, K>>
}
/**
* Combines a source with local replicas: reads come from the first replica, writes go to every
* replica and then the source, exactly as given (no merging: the last write wins). A write the
* source refuses (e.g. 403) is undone locally and rethrown.
*
* - Changes pushed by the source (`subscribe`) are applied to the replicas, and announced as
* `change` events, only where they differ from what the replicas hold. The source relaying this
* client's own writes therefore changes nothing.
* - A write the source can't take (`StorageUnavailableError`) is applied locally and stored in
* the `queue` store; later writes queue behind it, so order is kept.
* - `sync()` replays the queue, then copies what changed on the source into the replicas, except
* keys with local writes the source hasn't confirmed yet (in flight or queued). It runs
* on start, when the source reconnects (`resync`), and after a retry delay while offline.
* - Reads wait for the first sync (until one succeeds, they go to the source). Whether a sync has
* completed is kept in the `queue` store, so with a persistent one a restart reads offline.
*/
export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<S> {
private readonly source: BaseKVStorage
private readonly replicas: BaseKVStorage[]
private readonly queue: BaseKVStorage
private readonly writes: BaseKVStorage
private readonly range: KeyRange
private readonly retryMs: number
private readonly onError: (error: Error) => void
private readonly ids = new Ulid()
/** Mutable state lives here: child nodes share this object (see `child()`). */
private readonly state: {
started?: 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. */
synced: boolean
/** Writes waiting in the queue. */
queued: number
/** Remote changes are applied one after another. */
applying: Promise<unknown>
/** Keys with local writes the source hasn't confirmed yet (in flight or queued) → count. */
pending: Map<string, number>
retry?: ReturnType<typeof setTimeout>
offs: OffCallback[]
} = { synced: false, queued: 0, applying: Promise.resolve(), pending: new Map(), offs: [] }
constructor(options: KVStorageCompositeOptions) {
super()
if (options.replicas.length === 0) throw new TypeError('KVStorageComposite needs at least one replica')
this.source = options.source
this.replicas = options.replicas
this.queue = options.queue ?? new KVStorageInMemory()
this.writes = this.queue.child('writes')
this.range = new KeyRange(options.prefix ?? '')
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
// writes stored inside that range.
const queueRoot = KVStorageComposite.rootOf(this.queue)
if (this.replicas.some((replica) => KVStorageComposite.rootOf(replica) === queueRoot) && this.range.contains(this.queue.key)) {
throw new TypeError('The queue must be outside the synced range (e.g. a separate store, or a node outside `prefix`)')
}
}
/** Loads local data and starts syncing (also happens on first use). */
start(): Promise<void> {
return (this.state.started ??= this.open())
}
/**
* 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> {
// 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. */
get queued(): number {
return this.state.queued
}
async close(): Promise<void> {
clearTimeout(this.state.retry)
for (const off of this.state.offs.splice(0)) off()
if (KVStorageComposite.isSubscribable(this.source)) await this.source.unsubscribe(this.range.prefix)
}
async readMany(keys: string[]): Promise<unknown[]> {
this.checkRange(keys)
return (await this.reader()).readMany(keys)
}
async *scan(options: ScanOptions = {}): AsyncGenerator<[string, unknown]> {
const scoped = this.range.clamp(options)
if (scoped) yield* (await this.reader()).scan(scoped)
}
protected async write(changes: KVChange[]): Promise<void> {
const keys = changes.map(({ key }) => key)
this.checkRange(keys)
await this.start()
// Applied locally first, so the source relaying this write back finds nothing new. Until the
// source confirms it, a sync must not replace it with the source's (older) value.
const previous = await this.replicas[0]!.readMany(keys)
this.markPending(keys, 1)
await this.apply(changes)
// While syncing or with writes queued, queue this one too so the source sees writes in order.
if (this.state.queued > 0 || this.state.syncing) return this.enqueue(changes)
try {
await this.source.writeMany(changes)
} catch (error) {
if (error instanceof StorageUnavailableError) return this.enqueue(changes)
this.markPending(keys, -1)
await this.apply(keys.map((key, i) => ({ key, value: previous[i] })))
throw error
}
this.markPending(keys, -1)
}
private async enqueue(changes: KVChange[]): Promise<void> {
await this.writes.set(this.ids.generate(), changes)
this.state.queued++
this.retryLater()
}
/** The first replica once it holds a full copy; until then, a sync is attempted first and the source is the fallback. */
private async reader(): Promise<BaseKVStorage> {
if (!this.state.synced) await this.sync()
return this.state.synced ? this.replicas[0]! : this.source
}
private async open(): Promise<void> {
this.state.synced = (await this.queue.get('synced')) === true
for await (const [, changes] of this.writes.entries()) {
this.state.queued++
this.markPending(
(changes as KVChange[]).map(({ key }) => key),
1,
)
}
await this.seed()
this.state.offs.push(
this.source.on('change', (event) => {
if (event.origin !== 'remote') return
const changes = event.changes.filter(({ key }) => this.range.contains(key))
this.state.applying = this.state.applying.then(() => this.applyRemote(changes)).catch((error: Error) => this.onError(error))
}),
this.source.on('resync', () => void this.sync()),
)
if (KVStorageComposite.isSubscribable(this.source)) {
void this.source
.subscribe(this.range.prefix)
.then(() => this.sync())
.catch((error: Error) => this.onError(error))
} else {
void this.sync()
}
}
private async runSync(): Promise<void> {
await this.start()
try {
await this.replay()
let batch: KVChange[] = []
for await (const change of this.diff()) {
if (this.state.pending.has(change.key)) continue // a newer local write, not yet sent
if (batch.push(change) >= 500) await this.apply(batch.splice(0), true)
}
await this.apply(batch, true)
await this.replay() // writes made during the diff: send them, and re-apply them over it
await this.queue.set('synced', true)
this.state.synced = true
} catch (error) {
if (!(error instanceof StorageUnavailableError)) this.onError(error as Error)
this.retryLater()
}
}
/** Sends queued writes to the source in order, and re-applies each locally (a remote change may have overwritten it meanwhile). */
private async replay(): Promise<void> {
for (;;) {
let next: [string, unknown] | undefined
for await (const entry of this.writes.entries({ limit: 1 })) next = entry
if (!next) return
const changes = next[1] as KVChange[]
try {
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])
this.state.queued--
this.markPending(
changes.map(({ key }) => key),
-1,
)
}
}
/** What the source has that differs from the last replica (both scanned in key order). */
private async *diff(): AsyncGenerator<KVChange> {
const options = { gte: this.range.gte, lt: this.range.lt, values: true }
const remote = this.source.scan(options)[Symbol.asyncIterator]()
const local = this.replicas.at(-1)!.scan(options)[Symbol.asyncIterator]()
let r = await remote.next()
let l = await local.next()
while (!r.done || !l.done) {
if (l.done || (!r.done && r.value[0] < l.value[0])) {
yield { key: r.value![0], value: r.value![1] }
r = await remote.next()
} else if (r.done || l.value[0] < r.value[0]) {
yield { key: l.value[0] }
l = await local.next()
} else {
if (!Json.equal(r.value[1], l.value[1])) yield { key: r.value[0], value: r.value[1] }
;[r, l] = await Promise.all([remote.next(), local.next()])
}
}
}
/** Applies pushed changes that differ from what the replicas hold. */
private async applyRemote(changes: KVChange[]): Promise<void> {
const current = await this.replicas[0]!.readMany(changes.map(({ key }) => key))
await this.apply(
changes.filter(({ value }, i) => !Json.equal(value, current[i])),
true,
)
}
/** Writes changes to every replica; `remote` ones are also announced as `change` events. */
private async apply(changes: KVChange[], remote = false): Promise<void> {
if (changes.length === 0) return
await Promise.all(this.replicas.map((replica) => replica.writeMany(changes)))
if (remote) this.emit('change', { changes, origin: 'remote' })
}
/** Copies the last (persistent) replica into the faster ones. */
private async seed(): Promise<void> {
if (this.replicas.length < 2) return
let batch: KVChange[] = []
const flush = () => Promise.all(this.replicas.slice(0, -1).map((replica) => replica.writeMany(batch.splice(0))))
for await (const [key, value] of this.replicas.at(-1)!.scan({ gte: this.range.gte, lt: this.range.lt, values: true })) {
if (batch.push({ key, value }) >= 500) await flush()
}
await flush()
}
private markPending(keys: string[], delta: 1 | -1): void {
for (const key of keys) {
const count = (this.state.pending.get(key) ?? 0) + delta
if (count > 0) this.state.pending.set(key, count)
else this.state.pending.delete(key)
}
}
private retryLater(): void {
clearTimeout(this.state.retry)
this.state.retry = setTimeout(() => void this.sync(), this.retryMs)
;(this.state.retry as { unref?: () => void }).unref?.()
}
private checkRange(keys: string[]): void {
for (const key of keys) if (!this.range.contains(key)) throw new RangeError(`"${key}" is outside this composite's prefix "${this.range.prefix}"`)
}
/** The storage instance a node belongs to (child nodes are frozen views over it). */
private static rootOf(node: BaseKVStorage): BaseKVStorage {
while (Object.isFrozen(node)) node = Object.getPrototypeOf(node) as BaseKVStorage
return node
}
private static isSubscribable(source: BaseKVStorage): source is BaseKVStorage & Subscribable {
return typeof (source as Partial<Subscribable>).subscribe === 'function'
}
}
+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)
}
}
+121 -15
View File
@@ -1,45 +1,151 @@
import type { BaseKVStorage, ScanOptions } from './base-kv-storage.js' import { AuthError } from '../utils/errors.js'
import { ExtendedJson } from '../utils/extended-json.js'
import { KeyRange } from '../utils/key-range.js'
import { AUTH_HEADERS, RequestAuth, type RequestAuthOptions, type SignatureVerifier } from '../utils/request-auth.js'
import type { BaseKVStorage, KVChange, ScanOptions } from './base-kv-storage.js'
export interface KVStorageHttpServerOptions {
/**
* `{ verifiers }`: requests must be signed (see `RequestAuth`), and a caller may only touch keys
* under its own public key (`<publicKey>` and `<publicKey>/...`).
* `'none'`: no authentication, everything is open; for local development and tests only.
*/
auth: 'none' | ({ verifiers: SignatureVerifier[] } & RequestAuthOptions)
/** Path prefix to strip, e.g. `/kv`. Default `''`. */
basePath?: string
/** Interval of SSE keep-alive comments. Default 15 000 ms. */
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
/** /**
* Serves any KV storage over HTTP for `KVStorageHttp`. Framework-agnostic: `handle(Request)` * Serves any KV storage over HTTP for `KVStorageHttp`. Framework-agnostic: `handle(Request)`
* returns a `Response`. All routes are `POST /<primitive>` with a JSON body; `scan` streams NDJSON. * returns a `Response`. It executes exactly the operations clients send; it doesn't know about
* The raw primitives use absolute keys, so this exposes the whole store behind `storage`. * versions or conflicts. Every route is `POST` with an Extended JSON body:
*
* - `/readMany { keys }`, `/write { changes }`, `/scan { gte, lt, limit, reverse, values }` (NDJSON).
* - `/events { paths }`: Server-Sent Events relaying each change to those keys (and their
* subtrees) as it happens, including the subscriber's own writes. A `ready` event follows the
* connection. Nothing is replayed after a reconnect; clients catch up by reading.
*
* With authentication, each request is checked first (401), then every key, scan range and
* subscription path must be the caller's public key or under it (403).
*
* Only changes made through the wrapped storage instance are relayed; several server processes
* over one database each relay only their own writes.
*/ */
export class KVStorageHttpServer { export class KVStorageHttpServer {
private readonly auth: RequestAuth | undefined
private readonly basePath: string
private readonly keepAliveMs: number
private readonly allowOrigins: string[] | '*'
private readonly subscribers = new Set<{ ranges: KeyRange[]; send: (frame: string) => void }>()
constructor( constructor(
private readonly storage: BaseKVStorage, private readonly storage: BaseKVStorage,
private readonly basePath = '', options: KVStorageHttpServerOptions,
) {} ) {
this.auth = options.auth === 'none' ? undefined : new RequestAuth(options.auth.verifiers, options.auth)
this.basePath = options.basePath ?? ''
this.keepAliveMs = options.keepAliveMs ?? 15_000
this.allowOrigins = options.allowOrigins ?? []
storage.on('change', ({ changes }) => {
for (const subscriber of this.subscribers) {
const matching = changes.filter(({ key }) => subscriber.ranges.some((range) => range.contains(key)))
if (matching.length) subscriber.send(`event: change\ndata: ${ExtendedJson.stringify(matching)}\n\n`)
}
})
}
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 body = (await request.json()) as { keys: string[]; entries: [string, unknown][] } & ScanOptions const text = await request.text()
const owner = this.auth ? await this.auth.verify(request.headers, path, text) : undefined
const body = ExtendedJson.parse(text) as RequestBody
switch (path.slice(this.basePath.length)) { switch (path.slice(this.basePath.length)) {
case '/readMany': { case '/readMany': {
this.authorize(owner, body.keys)
const values = await this.storage.readMany(body.keys) const values = await this.storage.readMany(body.keys)
const missing = values.flatMap((v, i) => (v === undefined ? [i] : [])) const missing = values.flatMap((v, i) => (v === undefined ? [i] : []))
return Response.json({ values: values.map((v) => v ?? null), missing }) return new Response(ExtendedJson.stringify({ values: values.map((v) => v ?? null), missing }), { headers: { 'content-type': 'application/json' } })
} }
case '/writeMany': case '/write':
await this.storage.writeMany(body.entries) this.authorize(
owner,
body.changes.map(({ key }) => key),
)
await this.storage.writeMany(body.changes)
return new Response(null, { status: 204 }) return new Response(null, { status: 204 })
case '/removeMany':
await this.storage.removeMany(body.keys)
return new Response(null, { status: 204 })
case '/writeIfAbsent':
return Response.json(await this.storage.writeIfAbsent(body.entries))
case '/scan': case '/scan':
if (owner !== undefined && !new KeyRange(owner).covers(body.gte, body.lt)) throw new AuthError(403, 'Scan outside your keys')
return this.scan(body) return this.scan(body)
case '/events':
if (!Array.isArray(body.paths) || body.paths.length === 0) return KVStorageHttpServer.error(400, 'Subscribe to at least one path')
this.authorize(owner, body.paths)
return this.events(body.paths)
default: default:
return KVStorageHttpServer.error(404, 'Not found') return KVStorageHttpServer.error(404, 'Not found')
} }
} catch (error) { } catch (error) {
if (error instanceof AuthError) return KVStorageHttpServer.error(error.status, error.message)
return KVStorageHttpServer.error(500, error instanceof Error ? error.message : String(error)) return KVStorageHttpServer.error(500, error instanceof Error ? error.message : String(error))
} }
} }
/** Every key must be the owner's key or under it (no-op without authentication). */
private authorize(owner: string | undefined, keys: string[]): void {
if (owner === undefined) return
const allowed = new KeyRange(owner)
for (const key of keys) {
if (typeof key !== 'string' || !allowed.contains(key)) throw new AuthError(403, `No access to "${key}"`)
}
}
private events(paths: string[]): Response {
const encoder = new TextEncoder()
let subscriber: { ranges: KeyRange[]; send: (frame: string) => void } | undefined
let keepAlive: ReturnType<typeof setInterval> | undefined
const stream = new ReadableStream<Uint8Array>({
start: (controller) => {
subscriber = { ranges: paths.map((path) => new KeyRange(path)), send: (frame) => controller.enqueue(encoder.encode(frame)) }
this.subscribers.add(subscriber)
subscriber.send('event: ready\ndata: null\n\n')
keepAlive = setInterval(() => subscriber!.send(': keep-alive\n\n'), this.keepAliveMs)
;(keepAlive as { unref?: () => void }).unref?.()
},
cancel: () => {
if (subscriber) this.subscribers.delete(subscriber)
clearInterval(keepAlive)
},
})
return new Response(stream, { headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' } })
}
private scan(options: ScanOptions): Response { private scan(options: ScanOptions): Response {
const iterator = this.storage.scan(options)[Symbol.asyncIterator]() const iterator = this.storage.scan(options)[Symbol.asyncIterator]()
const encoder = new TextEncoder() const encoder = new TextEncoder()
@@ -53,7 +159,7 @@ export class KVStorageHttpServer {
if (chunk) controller.enqueue(encoder.encode(chunk)) if (chunk) controller.enqueue(encoder.encode(chunk))
return controller.close() return controller.close()
} }
chunk += JSON.stringify(options.values ? next.value : [next.value[0]]) + '\n' chunk += ExtendedJson.stringify(options.values ? next.value : [next.value[0]]) + '\n'
} }
controller.enqueue(encoder.encode(chunk)) controller.enqueue(encoder.encode(chunk))
}, },
+132 -27
View File
@@ -1,45 +1,61 @@
import type { AnySpec, Spec } from '../utils/schema.js' import { AuthError, StorageUnavailableError } from '../utils/errors.js'
import { BaseKVStorage, type ScanOptions } from './base-kv-storage.js' import { ExtendedJson } from '../utils/extended-json.js'
import { RequestAuth, type RequestSigner } from '../utils/request-auth.js'
import type { AnySpec, ChildOf, KeyOf, Spec } from '../utils/schema.js'
import { ExponentialBackoff } from '../utils/sse-session/exponential-backoff.js'
import { SSESession } from '../utils/sse-session/sse-session.js'
import { BaseKVStorage, type KVChange, type ScanOptions } from './base-kv-storage.js'
export interface KVStorageHttpOptions { export interface KVStorageHttpOptions {
/** Base URL of a `KVStorageHttpServer`, e.g. `https://host/kv`. */ /** Base URL of a `KVStorageHttpServer`, e.g. `https://host/kv`. */
url: string url: string
/** Signs every request (required by servers with authentication). */
signer?: RequestSigner
headers?: Record<string, string> headers?: Record<string, string>
fetch?: typeof fetch fetch?: (url: string, init: RequestInit) => Promise<Response>
/** Called when the change stream fails (it keeps reconnecting). Default: ignore. */
onSubscriptionError?: (error: Error) => void
} }
/** KV storage client for a `KVStorageHttpServer`. Same API as any other KV storage. */ // Types only: children of a KVStorageHttp are KVStorageHttp nodes too (child() creates them with
// Object.create, so they share this class's prototype).
export interface KVStorageHttp<S extends Spec = AnySpec> {
child<K extends KeyOf<S>>(key: K): KVStorageHttp<ChildOf<S, K>>
}
/**
* KV storage client for a `KVStorageHttpServer`. Same API as any other KV storage.
*
* `subscribe(path)` relays every change the server pushes for a key and its subtree (including
* this client's own writes) as `change` events with `origin: 'remote'`. When a dropped stream reconnects, `resync` is emitted: changes may have been
* missed, so re-read what you need. With a `signer`, every request is signed. Values travel as
* Extended JSON (`bigint` and `Uint8Array` survive).
*/
export class KVStorageHttp<S extends Spec = AnySpec> extends BaseKVStorage<S> { export class KVStorageHttp<S extends Spec = AnySpec> extends BaseKVStorage<S> {
private readonly url: string private readonly url: string
private readonly signer: RequestSigner | undefined
private readonly headers: Record<string, string> private readonly headers: Record<string, string>
private readonly fetcher: typeof fetch private readonly fetcher: (url: string, init: RequestInit) => Promise<Response>
private readonly onSubscriptionError: (error: Error) => void
/** Mutable state lives here: child nodes share this object (see `child()`). */
private readonly state: { paths: Set<string>; session?: SSESession; updating: Promise<unknown> } = { paths: new Set(), updating: Promise.resolve() }
constructor(options: KVStorageHttpOptions) { constructor(options: KVStorageHttpOptions) {
super() super()
this.url = options.url.replace(/\/$/, '') this.url = options.url.replace(/\/$/, '')
this.signer = options.signer
this.headers = options.headers ?? {} this.headers = options.headers ?? {}
this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis) this.fetcher = options.fetch ?? ((url, init) => globalThis.fetch(url, init))
this.onSubscriptionError = options.onSubscriptionError ?? (() => {})
} }
async readMany(keys: string[]): Promise<unknown[]> { async readMany(keys: string[]): Promise<unknown[]> {
if (keys.length === 0) return [] if (keys.length === 0) return []
const { values, missing } = (await this.post('readMany', { keys })) as { values: unknown[]; missing: number[] } const { values, missing } = ExtendedJson.parse(await (await this.request('readMany', { keys })).text()) as { values: unknown[]; missing: number[] }
for (const i of missing) values[i] = undefined for (const i of missing) values[i] = undefined
return values return values
} }
async writeMany(entries: [string, unknown][]): Promise<void> {
if (entries.length) await this.post('writeMany', { entries })
}
async removeMany(keys: string[]): Promise<void> {
if (keys.length) await this.post('removeMany', { keys })
}
async writeIfAbsent(entries: [string, unknown][]): Promise<boolean[]> {
return entries.length ? ((await this.post('writeIfAbsent', { entries })) as boolean[]) : []
}
async *scan(options: ScanOptions = {}): AsyncGenerator<[string, unknown]> { async *scan(options: ScanOptions = {}): AsyncGenerator<[string, unknown]> {
if (options.limit !== undefined && options.limit <= 0) return if (options.limit !== undefined && options.limit <= 0) return
const response = await this.request('scan', options) const response = await this.request('scan', options)
@@ -53,8 +69,8 @@ export class KVStorageHttp<S extends Spec = AnySpec> extends BaseKVStorage<S> {
const lines = buffer.split('\n') const lines = buffer.split('\n')
buffer = lines.pop()! buffer = lines.pop()!
for (const line of lines) { for (const line of lines) {
const [key, value] = JSON.parse(line) as [string, unknown?] const [key, entryValue] = ExtendedJson.parse(line) as [string, unknown?]
yield [key, value] yield [key, entryValue]
} }
} }
} finally { } finally {
@@ -62,18 +78,107 @@ export class KVStorageHttp<S extends Spec = AnySpec> extends BaseKVStorage<S> {
} }
} }
private async post(path: string, body: unknown): Promise<unknown> { /**
const response = await this.request(path, body) * Relays changes to `path` (default: this node's key) and everything under it. Resolves once
return response.status === 204 ? undefined : response.json() * the stream is connected. While the server is unreachable this stays pending, so don't await it
* on a path that must work offline. A 401/403 rejects instead of retrying.
*/
subscribe(path: string = this.key): Promise<void> {
return this.updateSubscriptions(() => this.state.paths.add(path))
} }
private async request(path: string, body: unknown): Promise<Response> { /** Stops relaying changes to `path` (default: this node's key). */
const response = await this.fetcher(`${this.url}/${path}`, { unsubscribe(path: string = this.key): Promise<void> {
return this.updateSubscriptions(() => this.state.paths.delete(path))
}
/** Drops every subscription and closes the stream. */
close(): Promise<void> {
return this.updateSubscriptions(() => this.state.paths.clear())
}
protected async write(changes: KVChange[]): Promise<void> {
await this.request('write', { changes })
}
/** Applies a subscription change (one at a time), then reconnects with the new path list. */
private updateSubscriptions(change: () => unknown): Promise<void> {
const run = async () => {
const before = JSON.stringify([...this.state.paths])
change()
if (JSON.stringify([...this.state.paths]) === before && (this.state.session || this.state.paths.size === 0)) return
await this.state.session?.disconnect()
this.state.session = undefined
if (this.state.paths.size > 0) await this.connect([...this.state.paths])
}
const result = this.state.updating.then(run)
this.state.updating = result.catch(() => {})
return result
}
private async connect(paths: string[]): Promise<void> {
const url = `${this.url}/events`
const body = ExtendedJson.stringify({ paths })
const session = new SSESession(url, {
method: 'POST', method: 'POST',
body,
fetch: this.fetcher,
headers: { ...this.headers, 'content-type': 'application/json' }, headers: { ...this.headers, 'content-type': 'application/json' },
body: JSON.stringify(body), persistent: true,
onError: this.onSubscriptionError,
retry: KVStorageHttp.retryUnlessDenied(),
// Signed on every (re)connect, so the timestamp is always fresh.
onRequest: async (request) => ({ ...request, headers: { ...(request.headers as Record<string, string>), ...(await this.authHeaders(url, body)) } }),
}) })
let readies = 0
session.on('message', (message) => {
if (message.event === 'change') this.emit('change', { changes: ExtendedJson.parse(message.data) as KVChange[], origin: 'remote' })
// Every `ready` after the first is a reconnect: changes may have been missed meanwhile.
if (message.event === 'ready' && readies++ > 0) this.emit('resync', undefined)
})
this.state.session = session
const ready = session.waitFor('message', (message) => message.event === 'ready')
try {
await session.connect()
} catch (error) {
this.state.session = undefined
throw (error as Error).cause instanceof AuthError ? (error as Error).cause : error
}
await ready
}
private async request(path: string, payload: unknown): Promise<Response> {
const url = `${this.url}/${path}`
const body = ExtendedJson.stringify(payload)
let response: Response
try {
response = await this.fetcher(url, {
method: 'POST',
headers: { ...this.headers, 'content-type': 'application/json', ...(await this.authHeaders(url, body)) },
body,
})
} catch (error) {
throw new StorageUnavailableError(`Cannot reach ${this.url}`, { cause: error })
}
if ([502, 503, 504].includes(response.status)) throw new StorageUnavailableError(`HTTP ${response.status} from ${this.url}`)
if (response.status === 401 || response.status === 403) throw new AuthError(response.status, await response.text())
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`) if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`)
return response return response
} }
private async authHeaders(url: string, body: string): Promise<Record<string, string>> {
return this.signer ? RequestAuth.headers(this.signer, new URL(url).pathname, body) : {}
}
/** Retries connecting with backoff, except when the server denies access (401/403): that won't fix itself. */
private static retryUnlessDenied(): { run<T>(fn: () => Promise<T>): Promise<T> } {
const backoff = new ExponentialBackoff({ baseDelay: 1000, maxDelay: 10_000, maxAttempts: 0, growthRate: 1.3, jitter: 0.3 })
return {
run: (fn) =>
backoff.run(fn, (error, context) => {
const status = /^HTTP error: (40[13])/.exec(error.message)?.[1]
if (status) context.abort(new AuthError(Number(status) as 401 | 403, error.message))
}),
}
}
} }
+8 -13
View File
@@ -1,7 +1,7 @@
import { Json } from '../utils/json.js' import { Json } from '../utils/json.js'
import { SortedMap } from '../utils/sorted-map.js' import { SortedMap } from '../utils/sorted-map.js'
import type { AnySpec, Spec } from '../utils/schema.js' import type { AnySpec, Spec } from '../utils/schema.js'
import { BaseKVStorage, type ScanOptions } from './base-kv-storage.js' import { BaseKVStorage, type KVChange, type ScanOptions } from './base-kv-storage.js'
/** In-memory KV storage. Values are deep-copied on write and read. */ /** In-memory KV storage. Values are deep-copied on write and read. */
export class KVStorageInMemory<S extends Spec = AnySpec> extends BaseKVStorage<S> { export class KVStorageInMemory<S extends Spec = AnySpec> extends BaseKVStorage<S> {
@@ -11,18 +11,6 @@ export class KVStorageInMemory<S extends Spec = AnySpec> extends BaseKVStorage<S
return keys.map((k) => Json.clone(this.data.get(k))) return keys.map((k) => Json.clone(this.data.get(k)))
} }
async writeMany(entries: [string, unknown][]): Promise<void> {
for (const [k, v] of entries) this.data.set(k, Json.clone(v))
}
async removeMany(keys: string[]): Promise<void> {
for (const k of keys) this.data.delete(k)
}
async writeIfAbsent(entries: [string, unknown][]): Promise<boolean[]> {
return entries.map(([k, v]) => !this.data.has(k) && (this.data.set(k, Json.clone(v)), true))
}
async *scan(options: ScanOptions = {}): AsyncGenerator<[string, unknown]> { async *scan(options: ScanOptions = {}): AsyncGenerator<[string, unknown]> {
let remaining = options.limit ?? Infinity let remaining = options.limit ?? Infinity
if (remaining <= 0) return if (remaining <= 0) return
@@ -31,4 +19,11 @@ export class KVStorageInMemory<S extends Spec = AnySpec> extends BaseKVStorage<S
if (--remaining <= 0) return if (--remaining <= 0) return
} }
} }
protected async write(changes: KVChange[]): Promise<void> {
for (const { key, value } of changes) {
if (value === undefined) this.data.delete(key)
else this.data.set(key, Json.clone(value))
}
}
} }
+112
View File
@@ -0,0 +1,112 @@
import type { AnySpec, Spec } from '../utils/schema.js'
import { BaseKVStorage, type KVChange, type ScanOptions } from './base-kv-storage.js'
export interface KVStorageIndexedDBOptions {
/** Database name. */
name: string
/** Object store name. Default `kv`. */
storeName?: string
/** IndexedDB factory. Default `globalThis.indexedDB`. */
indexedDB?: IDBFactory
/** Entries read per transaction while scanning. Default 256. */
scanBatchSize?: number
}
/**
* KV storage in the browser's IndexedDB (one object store, string keys). Values are stored with the
* structured clone algorithm, so `bigint` and `Uint8Array` survive. IndexedDB orders string keys by
* UTF-16 code unit, like JavaScript string comparison.
*/
export class KVStorageIndexedDB<S extends Spec = AnySpec> extends BaseKVStorage<S> {
private readonly storeName: string
private readonly scanBatchSize: number
private readonly db: Promise<IDBDatabase>
constructor(options: KVStorageIndexedDBOptions) {
super()
this.storeName = options.storeName ?? 'kv'
this.scanBatchSize = options.scanBatchSize ?? 256
const factory = options.indexedDB ?? globalThis.indexedDB
const open = factory.open(options.name, 1)
open.onupgradeneeded = () => open.result.createObjectStore(this.storeName)
this.db = KVStorageIndexedDB.done(open)
}
async readMany(keys: string[]): Promise<unknown[]> {
const store = await this.store('readonly')
return Promise.all(keys.map((key) => KVStorageIndexedDB.done(store.get(key))))
}
async *scan(options: ScanOptions = {}): AsyncGenerator<[string, unknown]> {
let remaining = options.limit ?? Infinity
let last: string | undefined
while (remaining > 0) {
const batch = await this.readBatch(options, last, Math.min(remaining, this.scanBatchSize))
for (const entry of batch) {
yield entry
remaining--
}
if (batch.length < this.scanBatchSize) return
last = batch.at(-1)![0]
}
}
/** Closes the database connection. */
async close(): Promise<void> {
;(await this.db).close()
}
protected async write(changes: KVChange[]): Promise<void> {
const store = await this.store('readwrite')
for (const { key, value } of changes) {
if (value === undefined) store.delete(key)
else store.put(value, key)
}
await KVStorageIndexedDB.committed(store.transaction)
}
/** Up to `count` entries after `last` (exclusive), in scan order, within one transaction. */
private async readBatch(options: ScanOptions, last: string | undefined, count: number): Promise<[string, unknown][]> {
const reverse = options.reverse ?? false
// The upper bound is always exclusive (`lt`, or the last key seen in reverse); the lower bound
// is exclusive only when resuming forwards after `last`.
const lower = reverse ? options.gte : (last ?? options.gte)
const upper = reverse ? (last ?? options.lt) : options.lt
const lowerOpen = !reverse && last !== undefined
if (lower !== undefined && upper !== undefined && lower >= upper) return []
let range: IDBKeyRange | undefined
if (lower !== undefined && upper !== undefined) range = IDBKeyRange.bound(lower, upper, lowerOpen, true)
else if (lower !== undefined) range = IDBKeyRange.lowerBound(lower, lowerOpen)
else if (upper !== undefined) range = IDBKeyRange.upperBound(upper, true)
const store = await this.store('readonly')
const out: [string, unknown][] = []
const request = options.values ? store.openCursor(range, reverse ? 'prev' : 'next') : store.openKeyCursor(range, reverse ? 'prev' : 'next')
return new Promise((resolve, reject) => {
request.onerror = () => reject(request.error)
request.onsuccess = () => {
const cursor = request.result
if (!cursor || out.length >= count) return resolve(out)
out.push([cursor.key as string, options.values ? (cursor as IDBCursorWithValue).value : undefined])
cursor.continue()
}
})
}
private async store(mode: IDBTransactionMode): Promise<IDBObjectStore> {
return (await this.db).transaction(this.storeName, mode).objectStore(this.storeName)
}
private static done<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
private static committed(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve()
transaction.onerror = transaction.onabort = () => reject(transaction.error)
})
}
}
+19 -12
View File
@@ -1,15 +1,3 @@
/** An optimistic-concurrency check failed (stale `expectedVersion` or a lost commit race). */
export class ConcurrencyError extends Error {
constructor(
readonly stream: string,
readonly expectedVersion: number | 'any',
readonly actualVersion: number,
) {
super(`Concurrency conflict on "${stream}": expected version ${expectedVersion}, actual ${actualVersion}`)
this.name = 'ConcurrencyError'
}
}
/** A key argument was passed but is `undefined`/`null`, empty, or not a string/number. */ /** A key argument was passed but is `undefined`/`null`, empty, or not a string/number. */
export class InvalidKeyError extends TypeError { export class InvalidKeyError extends TypeError {
constructor(message: string) { constructor(message: string) {
@@ -17,3 +5,22 @@ export class InvalidKeyError extends TypeError {
this.name = 'InvalidKeyError' this.name = 'InvalidKeyError'
} }
} }
/** A remote storage could not be reached (network failure or 502/503/504). Writes may be retried later. */
export class StorageUnavailableError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message, options)
this.name = 'StorageUnavailableError'
}
}
/** A request was not authenticated (401) or not allowed for this caller (403). */
export class AuthError extends Error {
constructor(
readonly status: 401 | 403,
message: string,
) {
super(message)
this.name = 'AuthError'
}
}
+134
View File
@@ -0,0 +1,134 @@
// Adapted from xo-cash-utils `source/event-emitter.ts` (branch installable-sse): payloads are passed
// through as-is (no cloning, freezing or DeeplyReadonly), and timeouts reject with a plain Error.
export type EventMap = Record<string, unknown>
type Listener<T> = (detail: T) => void
/** Lets listeners for individual payloads live in one collection typed with the union of payloads. */
type StoredListener<T> = {
bivarianceHack(detail: T): void
}['bivarianceHack']
interface ListenerEntry<T> {
listener: StoredListener<T>
wrappedListener: StoredListener<T>
cancel: () => void
}
/** Returned by {@link EventEmitter.on} and {@link EventEmitter.once} to remove the listener. */
export type OffCallback = () => void
/** A small typed event emitter. */
export class EventEmitter<T extends EventMap> {
private readonly listeners: Map<keyof T, Set<ListenerEntry<T[keyof T]>>> = new Map()
/**
* Adds a listener.
* @param debounceMilliseconds - When > 0, only the last event within this window is delivered.
*/
on<K extends keyof T>(type: K, listener: Listener<T[K]>, debounceMilliseconds: number = 0): OffCallback {
const { cancel, listener: cancellableListener } = this.cancellable(listener)
const wrappedListener = debounceMilliseconds > 0 ? this.debounce(cancellableListener, debounceMilliseconds) : cancellableListener
this.entriesFor(type).add({ listener, wrappedListener, cancel } as ListenerEntry<T[keyof T]>)
return () => this.off(type, listener)
}
/** Adds a listener that is removed after its first call. */
once<K extends keyof T>(type: K, listener: Listener<T[K]>, debounceMilliseconds: number = 0): OffCallback {
const wrappedListener: Listener<T[K]> = (detail) => {
this.off(type, listener)
listener(detail)
}
const { cancel, listener: cancellableListener } = this.cancellable(wrappedListener)
const debouncedListener = debounceMilliseconds > 0 ? this.debounce(cancellableListener, debounceMilliseconds) : cancellableListener
this.entriesFor(type).add({ listener, wrappedListener: debouncedListener, cancel } as ListenerEntry<T[keyof T]>)
return () => this.off(type, listener)
}
/** Removes one listener, or all listeners for `type` when none is given. */
off<K extends keyof T>(type: K, listener?: Listener<T[K]>): void {
const listeners = this.listeners.get(type)
if (!listeners) return
for (const entry of Array.from(listeners)) {
if (listener && entry.listener !== listener && entry.wrappedListener !== listener) continue
// Cancel so a pending debounced call doesn't fire after removal.
entry.cancel()
listeners.delete(entry)
}
if (!listener || listeners.size === 0) this.listeners.delete(type)
}
/**
* Calls every listener for `type` with `payload`. Listener errors are logged, not thrown.
* @returns Whether any listener was registered.
*/
emit<K extends keyof T>(type: K, payload: T[K]): boolean {
const listeners = this.listeners.get(type)
if (!listeners) return false
for (const entry of Array.from(listeners)) {
try {
entry.wrappedListener(payload)
} catch (error) {
console.error(error)
}
}
return listeners.size > 0
}
removeAllListeners(): void {
for (const type of Array.from(this.listeners.keys())) this.off(type)
}
/** Resolves with the first payload of `type` matching `predicate`; rejects after `timeoutMs` if given. */
waitFor<K extends keyof T>(type: K, predicate: (payload: T[K]) => boolean, timeoutMs?: number): Promise<T[K]> {
return new Promise((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined
const cleanup = (): void => {
this.off(type, listener)
if (timeoutId !== undefined) clearTimeout(timeoutId)
}
const listener = (payload: T[K]): void => {
try {
if (!predicate(payload)) return
cleanup()
resolve(payload)
} catch (error) {
cleanup()
reject(error)
}
}
if (timeoutMs !== undefined) {
timeoutId = setTimeout(() => {
this.off(type, listener)
reject(new Error(`Timed out waiting for "${String(type)}"`))
}, timeoutMs)
}
this.on(type, listener)
})
}
private entriesFor<K extends keyof T>(type: K): Set<ListenerEntry<T[keyof T]>> {
let entries = this.listeners.get(type)
if (!entries) this.listeners.set(type, (entries = new Set()))
return entries
}
private debounce<K extends keyof T>(func: Listener<T[K]>, wait: number): Listener<T[K]> {
let timeout: ReturnType<typeof setTimeout> | undefined
return (detail) => {
if (timeout !== undefined) clearTimeout(timeout)
timeout = setTimeout(() => func(detail), wait)
}
}
private cancellable<K extends keyof T>(func: Listener<T[K]>): { cancel: () => void; listener: Listener<T[K]> } {
let cancelled = false
return {
cancel: () => void (cancelled = true),
listener: (detail) => {
if (!cancelled) func(detail)
},
}
}
}
+54
View File
@@ -0,0 +1,54 @@
// Adapted from xo-cash-utils `source/extended-json.ts` (branch installable-sse), with local hex
// encoding instead of @bitauth/libauth.
import { Hex } from './hex.js'
/** Matches a bigint encoded as `<bigint: 123n>`. */
const BIGINT_PATTERN = /^<bigint: (?<bigint>[+-]?[0-9]+)n>$/u
/** Matches a Uint8Array encoded as `<uint8array: abcd>`. */
const UINT8ARRAY_PATTERN = /^<uint8array: (?<hex>[0-9a-f]*)>$/u
/**
* JSON that also round-trips `bigint` (`<bigint: 123n>`) and `Uint8Array` (`<uint8array: abcd>`).
* Used for every value that crosses the network.
*/
export class ExtendedJson {
static stringify(value: unknown): string {
return JSON.stringify(value, ExtendedJson.replacer)
}
/** Like `stringify`, with object keys sorted, so equal values always give the same text (for hashing). */
static canonical(value: unknown): string {
return JSON.stringify(value, (key, v) => {
const replaced = ExtendedJson.replacer(key, v)
if (typeof replaced !== 'object' || replaced === null || Array.isArray(replaced)) return replaced
return Object.fromEntries(
Object.keys(replaced)
.sort()
.map((k) => [k, (replaced as Record<string, unknown>)[k]]),
)
})
}
static parse(text: string): unknown {
return JSON.parse(text, ExtendedJson.reviver)
}
/** `JSON.stringify` replacer. */
static replacer(this: void, _key: string, value: unknown): unknown {
if (value instanceof Uint8Array) return `<uint8array: ${Hex.encode(value)}>`
if (typeof value === 'bigint') return `<bigint: ${value.toString()}n>`
return value
}
/** `JSON.parse` reviver. */
static reviver(this: void, _key: string, value: unknown): unknown {
if (typeof value !== 'string') return value
const bigint = value.match(BIGINT_PATTERN)
if (bigint) return BigInt(bigint.groups!.bigint!)
const bytes = value.match(UINT8ARRAY_PATTERN)
if (bytes) return Hex.decode(bytes.groups!.hex!)
return value
}
}
+16
View File
@@ -0,0 +1,16 @@
/** Lowercase hex encoding of bytes. */
export class Hex {
static encode(bytes: Uint8Array): string {
let hex = ''
for (const byte of bytes) hex += byte.toString(16).padStart(2, '0')
return hex
}
/** Throws on odd length or non-hex characters. */
static decode(hex: string): Uint8Array {
if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) throw new TypeError('Invalid hex string')
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
return bytes
}
}
+9 -3
View File
@@ -1,8 +1,11 @@
/** Helpers for JSON-compatible values (all stored values are JSON by contract). */ /**
* Helpers for stored values: JSON plus `bigint` and `Uint8Array` (see `ExtendedJson`).
*/
export class Json { export class Json {
/** Deep copy; much cheaper than `structuredClone` for plain JSON. */ /** Deep copy; much cheaper than `structuredClone` for plain data. */
static clone<T>(value: T): T { static clone<T>(value: T): T {
if (value === null || typeof value !== 'object') return value if (value === null || typeof value !== 'object') return value
if (value instanceof Uint8Array) return value.slice() as T
if (Array.isArray(value)) return value.map((v) => Json.clone(v)) as T if (Array.isArray(value)) return value.map((v) => Json.clone(v)) as T
const out: Record<string, unknown> = {} const out: Record<string, unknown> = {}
for (const k in value) out[k] = Json.clone((value as Record<string, unknown>)[k]) for (const k in value) out[k] = Json.clone((value as Record<string, unknown>)[k])
@@ -12,6 +15,9 @@ export class Json {
static equal(a: unknown, b: unknown): boolean { static equal(a: unknown, b: unknown): boolean {
if (a === b) return true if (a === b) return true
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
if (a instanceof Uint8Array || b instanceof Uint8Array) {
return a instanceof Uint8Array && b instanceof Uint8Array && a.length === b.length && a.every((byte, i) => byte === b[i])
}
if (Array.isArray(a) !== Array.isArray(b)) return false if (Array.isArray(a) !== Array.isArray(b)) return false
const ka = Object.keys(a) const ka = Object.keys(a)
const kb = Object.keys(b) const kb = Object.keys(b)
@@ -19,6 +25,6 @@ export class Json {
} }
static isObject(value: unknown): value is Record<string, unknown> { static isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value) return typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Uint8Array)
} }
} }
+36
View File
@@ -0,0 +1,36 @@
import type { ScanOptions } from '../kv-storage/base-kv-storage.js'
import { Keys } from './keys.js'
/**
* A key and its whole subtree: `[prefix, prefix + '0')`. Escaping guarantees nothing else sorts
* in that range (not even lookalikes such as `prefix-x`). The empty prefix is every key.
*/
export class KeyRange {
/** Inclusive lower bound (`undefined` = unbounded). */
readonly gte: string | undefined
/** Exclusive upper bound (`undefined` = unbounded). */
readonly lt: string | undefined
constructor(readonly prefix: string) {
this.gte = prefix === '' ? undefined : prefix
this.lt = prefix === '' ? undefined : prefix + Keys.afterSeparator
}
contains(key: string): boolean {
return this.prefix === '' || key === this.prefix || key.startsWith(this.prefix + Keys.separator)
}
/** Whether a scan over `[gte, lt)` stays inside this range. */
covers(gte: string | undefined, lt: string | undefined): boolean {
if (this.prefix === '') return true
return gte !== undefined && lt !== undefined && gte >= this.gte! && lt <= this.lt!
}
/** Scan options narrowed to this range, or `undefined` if nothing is left. */
clamp(options: ScanOptions): ScanOptions | undefined {
const gte = this.gte !== undefined && (options.gte === undefined || options.gte < this.gte) ? this.gte : options.gte
const lt = this.lt !== undefined && (options.lt === undefined || options.lt > this.lt) ? this.lt : options.lt
if (gte !== undefined && lt !== undefined && gte >= lt) return undefined
return { ...options, gte, lt }
}
}
+157
View File
@@ -0,0 +1,157 @@
import { AuthError } from './errors.js'
import { Hex } from './hex.js'
/** Signs requests. Implement this to use another algorithm (e.g. secp256k1 via libauth). */
export interface RequestSigner {
/** Algorithm name sent in `X-Signature-Algorithm`; the server must have a matching verifier. */
readonly algorithm: string
/** Public key as lowercase hex. Also the owner segment of the caller's keys (`/<publicKey>/...`). */
readonly publicKey: string
sign(payload: Uint8Array): Promise<Uint8Array>
}
/** Verifies signatures for one algorithm. */
export interface SignatureVerifier {
readonly algorithm: string
verify(publicKey: string, payload: Uint8Array, signature: Uint8Array): Promise<boolean>
}
export const AUTH_HEADERS = {
publicKey: 'X-Public-Key',
timestamp: 'X-Timestamp',
nonce: 'X-Nonce',
signature: 'X-Signature',
algorithm: 'X-Signature-Algorithm',
} as const
export interface RequestAuthOptions {
/** Accepted difference between the request timestamp and the server clock. Default 60 000 ms. */
maxSkewMs?: number
now?: () => number
}
/**
* Signed-request authentication. The client signs `${timestamp}:${nonce}:${path}:${body}` (nonce =
* random per request, path = URL pathname, body = the exact request body) and sends the public
* key, timestamp, nonce, signature (hex) and algorithm as headers. The server checks the
* timestamp is recent, verifies the signature with the verifier for that algorithm, and rejects a
* nonce the same key already used within the timestamp window (replay protection). The nonce also
* keeps identical requests made at the same moment distinct (Ed25519 signatures are deterministic).
*/
export class RequestAuth {
private readonly verifiers: Map<string, SignatureVerifier>
private readonly maxSkewMs: number
private readonly now: () => number
/** `<publicKey>:<nonce>` accepted within the window → when it expires; insertion order ≈ expiry order. */
private readonly seen = new Map<string, number>()
constructor(verifiers: SignatureVerifier[], options: RequestAuthOptions = {}) {
if (verifiers.length === 0) throw new TypeError('RequestAuth needs at least one verifier')
this.verifiers = new Map(verifiers.map((v) => [v.algorithm, v]))
this.maxSkewMs = options.maxSkewMs ?? 60_000
this.now = options.now ?? Date.now
}
static payload(timestamp: string, nonce: string, path: string, body: string): Uint8Array {
return new TextEncoder().encode(`${timestamp}:${nonce}:${path}:${body}`)
}
/** Auth headers for a request to `path` with `body`. */
static async headers(signer: RequestSigner, path: string, body: string, now: number = Date.now()): Promise<Record<string, string>> {
const timestamp = String(now)
const nonce = Hex.encode(crypto.getRandomValues(new Uint8Array(16)))
const signature = await signer.sign(RequestAuth.payload(timestamp, nonce, path, body))
return {
[AUTH_HEADERS.publicKey]: signer.publicKey,
[AUTH_HEADERS.timestamp]: timestamp,
[AUTH_HEADERS.nonce]: nonce,
[AUTH_HEADERS.signature]: Hex.encode(signature),
[AUTH_HEADERS.algorithm]: signer.algorithm,
}
}
/** Returns the caller's public key, or throws `AuthError` (401). */
async verify(headers: Headers, path: string, body: string): Promise<string> {
const publicKey = headers.get(AUTH_HEADERS.publicKey)
const timestamp = headers.get(AUTH_HEADERS.timestamp)
const nonce = headers.get(AUTH_HEADERS.nonce)
const signature = headers.get(AUTH_HEADERS.signature)
const algorithm = headers.get(AUTH_HEADERS.algorithm)
if (!publicKey || !timestamp || !nonce || !signature || !algorithm) throw new AuthError(401, 'Missing authentication headers')
const verifier = this.verifiers.get(algorithm)
if (!verifier) throw new AuthError(401, `Unsupported signature algorithm "${algorithm}"`)
if (!/^[0-9a-f]+$/.test(publicKey)) throw new AuthError(401, 'Public key must be lowercase hex')
if (!/^[0-9A-Za-z_-]{16,128}$/.test(nonce)) throw new AuthError(401, 'Nonce must be 16-128 characters of [0-9A-Za-z_-]')
const now = this.now()
const time = Number(timestamp)
if (!Number.isInteger(time) || Math.abs(now - time) > this.maxSkewMs) throw new AuthError(401, 'Timestamp outside the accepted window')
this.forgetExpired(now)
const used = `${publicKey}:${nonce}`
if (this.seen.has(used)) throw new AuthError(401, 'Replayed request')
let valid = false
try {
valid = await verifier.verify(publicKey, RequestAuth.payload(timestamp, nonce, path, body), Hex.decode(signature))
} catch {
valid = false
}
if (!valid) throw new AuthError(401, 'Invalid signature')
this.seen.set(used, time + this.maxSkewMs)
return publicKey
}
private forgetExpired(now: number): void {
for (const [used, expires] of this.seen) {
if (expires >= now) break
this.seen.delete(used)
}
}
}
/** Ed25519 signer using WebCrypto (Node 22+ and current browsers; no dependencies). */
export class Ed25519Signer implements RequestSigner {
readonly algorithm = 'ed25519'
private constructor(
private readonly privateKey: CryptoKey,
readonly publicKey: string,
) {}
/** A new random key pair. */
static async generate(): Promise<Ed25519Signer> {
const pair = (await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as CryptoKeyPair
return new Ed25519Signer(pair.privateKey, Hex.encode(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))))
}
/** Restores a key exported with `exportPrivateKey()` (PKCS #8). */
static async fromPrivateKey(pkcs8: Uint8Array): Promise<Ed25519Signer> {
const privateKey = await crypto.subtle.importKey('pkcs8', pkcs8 as BufferSource, { name: 'Ed25519' }, true, ['sign'])
// WebCrypto can't derive the public key directly; the JWK export of a private key includes it (`x`).
const jwk = await crypto.subtle.exportKey('jwk', privateKey)
const publicKey = Uint8Array.from(atob(jwk.x!.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0))
return new Ed25519Signer(privateKey, Hex.encode(publicKey))
}
async exportPrivateKey(): Promise<Uint8Array> {
return new Uint8Array(await crypto.subtle.exportKey('pkcs8', this.privateKey))
}
async sign(payload: Uint8Array): Promise<Uint8Array> {
return new Uint8Array(await crypto.subtle.sign({ name: 'Ed25519' }, this.privateKey, payload as BufferSource))
}
}
/** Ed25519 verifier using WebCrypto. */
export class Ed25519Verifier implements SignatureVerifier {
readonly algorithm = 'ed25519'
private readonly keys = new Map<string, Promise<CryptoKey>>()
async verify(publicKey: string, payload: Uint8Array, signature: Uint8Array): Promise<boolean> {
let key = this.keys.get(publicKey)
if (!key) {
key = crypto.subtle.importKey('raw', Hex.decode(publicKey) as BufferSource, { name: 'Ed25519' }, false, ['verify'])
this.keys.set(publicKey, key)
if (this.keys.size > 10_000) this.keys.delete(this.keys.keys().next().value!)
}
return crypto.subtle.verify({ name: 'Ed25519' }, await key, signature as BufferSource, payload as BufferSource)
}
}
@@ -0,0 +1,85 @@
// Vendored from https://git.harvmaster.com/Harvmaster/sse-session (commit c08267e85c).
// Local changes: import paths, uses ../event-emitter.ts, tryAsync folded into SSESession.
/**
* An async iterable queue that bridges push-based producers and pull-based consumers.
*
* Composes an internal {@link ReadableStream} instead of extending it, so producers
* call {@link push} while consumers use standard async iteration (`for await...of`).
*
* ```ts
* const messages = new AsyncPushIterator<SSEvent>();
*
* // Producer (elsewhere)
* messages.push(event);
*
* // Consumer
* for await (const event of messages) {
* handle(event);
* }
* ```
*
* {@link Symbol.asyncIterator} returns `stream.values({ preventCancel: true })` so
* breaking out of `for await...of` does not cancel the underlying stream. That
* matters for long-lived sessions where the producer keeps pushing after a consumer
* stops reading early (for example, test helpers that only collect a fixed count).
*/
export class AsyncPushIterator<T> {
/** ReadableStream backing the async iterator returned from {@link Symbol.asyncIterator}. */
private readonly stream: ReadableStream<T>
/** Controller used to enqueue values and close the stream from {@link push} and {@link close}. */
private controller: ReadableStreamDefaultController<T> | undefined
/** When true, no more values are accepted and iteration eventually completes. */
public closed = false
public constructor() {
// `start`'s `this` is the underlying source object when using a plain method.
// An arrow function captures the class instance so the controller is stored here.
this.stream = new ReadableStream({
start: (controller: ReadableStreamDefaultController<T>): void => {
this.controller = controller
},
})
}
/**
* Enqueues a value for the consumer.
*
* After {@link close}, pushes are silently dropped.
*
* @param value - The next value to yield from the iterator.
*/
push(value: T): void {
if (this.closed) return
this.controller?.enqueue(value)
}
/**
* Ends the stream.
*
* Marks the iterator closed so future {@link push} calls are ignored.
* Buffered values are still yielded before iteration completes.
*/
close(): void {
this.closed = true
try {
this.controller?.close()
} catch {
// The reader may already have released or cancelled the stream.
}
}
/**
* Returns an async iterator over the composed stream.
*
* Uses `preventCancel: true` so early `break` from `for await...of` does not
* close the stream and block later pushes.
*/
[Symbol.asyncIterator](): AsyncIterableIterator<T> {
return this.stream.values({ preventCancel: true })
}
}
@@ -0,0 +1,251 @@
// Vendored from https://git.harvmaster.com/Harvmaster/sse-session (commit c08267e85c).
// Local changes: import paths, uses ../event-emitter.ts, tryAsync folded into SSESession.
/**
* Exponential backoff is a technique used to retry a function after a delay.
*
* The delay increases exponentially with each attempt, up to a maximum delay.
*
* The jitter is a random amount of time added to the delay to prevent thundering herd problems.
*
* The growth rate is the factor by which the delay increases with each attempt.
*/
export class ExponentialBackoff {
/**
* Create a new ExponentialBackoff instance
*
* @param config - The configuration for the exponential backoff
* @returns The ExponentialBackoff instance
*/
static from(config?: Partial<ExponentialBackoffOptions>): ExponentialBackoff {
const backoff = new ExponentialBackoff(config)
return backoff
}
/**
* Run the function with exponential backoff
*
* @param fn - The function to run
* @param onError - The callback to call when an error occurs
* @param options - The configuration for the exponential backoff
*
* @throws An AggregateError with all the errors that were thrown by the task function
*
* @returns The result of the function
*/
static run<T>(
fn: (context: ExpontialBackoffCallbackContext) => Promise<T>,
onError = (_error: Error, _context: ExpontialBackoffCallbackContext): void => {},
options?: Partial<ExponentialBackoffOptions>,
): Promise<T> {
const backoff = ExponentialBackoff.from(options)
return backoff.run(fn, onError)
}
private readonly options: ExponentialBackoffOptions
/**
* Creates a new exponential-backoff instance.
*
* Unspecified options use the defaults listed below.
*
* @param options - Exponential-backoff configuration overrides.
* @param options.maxDelay - Maximum delay between retries. Default: `10_000` ms.
* @param options.maxAttempts - Maximum number of attempts; `0` retries indefinitely. Default: `10`.
* @param options.baseDelay - Delay used as the basis for the first retry. Default: `1_000` ms.
* @param options.growthRate - Multiplier applied to the delay after each attempt. Default: `2`.
* @param options.jitter - Random proportional variation applied to each delay. Default: `0.1`.
*/
constructor(options: Partial<ExponentialBackoffOptions> = {}) {
this.options = {
maxDelay: 10_000,
maxAttempts: 10,
baseDelay: 1_000,
growthRate: 2,
jitter: 0.1,
...options,
}
ExponentialBackoff.validateOptions(this.options)
}
/**
* Run the function with exponential backoff
*
* If the function fails but we have not hit the max attempts, the error will be passed to the onError callback
* and the function will be retried with an exponential delay
*
* If the function fails and we have hit the max attempts, an AggregateError will be thrown with all the errors that were thrown by the task function
*
* @param fn - The function to run
* @param onError - The callback to call when an error occurs
*
* @throws An AggregateError with all the errors that were thrown by the task function
*
* @returns The result of the function
*/
async run<T>(
fn: (context: ExpontialBackoffCallbackContext) => Promise<T>,
onError = (_error: Error, _context: ExpontialBackoffCallbackContext): void => {},
): Promise<T> {
const abortController = new AbortController()
const context: ExpontialBackoffCallbackContext = {
errors: [],
attempt: 1,
options: { ...this.options },
nextDelay: 0,
signal: abortController.signal,
abort: (reason?: unknown) => abortController.abort(reason),
}
while (context.options.maxAttempts === 0 || context.attempt <= context.options.maxAttempts) {
context.nextDelay = ExponentialBackoff.calculateDelay(context.options, context.attempt - 1)
try {
// Await the promise before returning so its execution context remains in the try-catch
// If we didn't await, this `run` function would successfully return and any errors would not be caught here.
return await fn(context)
} catch (error) {
// Store the error in case we fail every attempt
const errorInstance = error instanceof Error ? error : new Error(`${error}`)
context.errors.push(errorInstance)
await onError(errorInstance, context)
}
// If the abort controller is aborted, throw an error
if (abortController.signal.aborted) {
const reasonError = abortController.signal.reason instanceof Error ? abortController.signal.reason : new Error(`${abortController.signal.reason}`)
throw new Error('Exponential backoff aborted', { cause: reasonError })
}
// If we have hit the max attempts, break out of the loop
const unlimitedAttempts = context.options.maxAttempts === 0
if (!unlimitedAttempts && context.attempt >= context.options.maxAttempts) {
break
}
// Wait before going to the next attempt
await this.wait(context.nextDelay, abortController.signal)
context.attempt++
}
// We completed the loop without ever succeeding. Throw an ExponentialBackoffMaxRetriesHitError with all the errors we got
throw new AggregateError(context.errors, 'Exponential backoff max retries hit')
}
private wait(delay: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
// Set a timeout to resolve the promise
const timeout = setTimeout(() => {
resolve()
}, delay)
// If the signal is aborted, reject the promise
signal.addEventListener('abort', () => {
clearTimeout(timeout)
reject(new Error('Exponential backoff aborted'))
})
})
}
/**
* Calculate the delay before we should attempt to retry
*
* NOTE: The maximum delay is (maxDelay * (1 + jitter))
*
* @param attempt
* @returns The time in milliseconds before another attempt should be made
*/
public static calculateDelay(options: ExponentialBackoffOptions, attempt: number): number {
// Get the power of the growth rate
const power = options.growthRate ** attempt
// Get the delay before jitter or limit
const rawDelay = options.baseDelay * power
// Cap the delay to the maximum. Do this before the jitter so jitter does not become larger than delay
const cappedDelay = Math.min(rawDelay, options.maxDelay)
// Get a random number for the amount to "jitter" the delay by
const jitterAmount = Math.random()
// Calculate the jitter
const jitter = jitterAmount * options.jitter * cappedDelay
// Subtract the jitter from the delay
return cappedDelay - jitter
}
/**
* Validate the options, ensure that no values are NaN
* @param options
*/
public static validateOptions(options: ExponentialBackoffOptions): void {
const keys = Object.keys(options) as (keyof ExponentialBackoffOptions)[]
for (const key of keys) {
if (!isFinite(options[key])) {
throw new Error(`Invalid option: ${key} is not finite`)
}
if (key === 'jitter' && (options[key] < 0 || options[key] > 1)) {
throw new Error(`Invalid option: jitter is not between 0 and 1`)
}
if (options[key] < 0) {
throw new Error(`Invalid option: ${key} is less than 0`)
}
}
}
}
export type ExponentialBackoffOptions = {
/**
* The maximum delay between attempts in milliseconds
*/
maxDelay: number
/**
* The maximum number of attempts. Passing 0 will result in infinite attempts.
*/
maxAttempts: number
/**
* The base delay between attempts in milliseconds
*/
baseDelay: number
/**
* The growth rate of the delay
*/
growthRate: number
/**
* The jitter of the delay as a percentage of growthRate
*/
jitter: number
}
export type ExpontialBackoffCallbackContext = {
/** The errors that have occurred */
errors: Error[]
/** The current attempt */
attempt: number
/** The options for the exponential backoff */
options: ExponentialBackoffOptions
/** The next delay before the next attempt */
nextDelay: number
/** The abort signal */
signal: AbortSignal
/** The abort function */
abort: (reason?: unknown) => void
}
+5
View File
@@ -0,0 +1,5 @@
export * from './types.js'
export * from './sse-session.js'
export * from './sse-event-parser.js'
export * from './async-push-iterator.js'
export * from './exponential-backoff.js'
+214
View File
@@ -0,0 +1,214 @@
// Vendored from https://git.harvmaster.com/Harvmaster/sse-session (commit c08267e85c).
// Local changes: import paths, uses ../event-emitter.ts, tryAsync folded into SSESession.
import type { SSEvent } from './types.js'
/**
* Optional encoders used when decoding incoming SSE bytes and re-encoding
* any buffered remainder between chunks.
*/
export interface SSEEventParserOptions {
/** Decodes raw stream bytes into text. Defaults to a new `TextDecoder`. */
textDecoder: TextDecoder
}
/**
* Regex that splits decoded SSE text into lines.
*
* The SSE wire format is line-oriented (`field: value` per line). Servers may
* send `\r\n` (HTTP default), `\n` (Unix), or `\r` (legacy Mac). Matching all
* three keeps parsing correct regardless of platform or server implementation.
*/
export const SSE_LINE_ENDINGS = /\r\n|\r|\n/
/**
* Regex that matches the single optional leading space in an SSE field value.
*
* Per the SSE spec, `field: value` may include one space immediately after the
* colon; that space is not part of the value. Used with `.replace()` to strip
* it when parsing lines such as `data: hello` → `hello`.
*/
export const SSE_FIELD_VALUE_REGEX = /^ /
/**
* The newline character used when normalizing SSE text internally.
*
* Used to join consecutive `data:` lines into one payload and to reassemble
* buffered partial lines between streamed chunks before the next parse call.
*/
export const NEW_LINE = '\n'
/**
* Incrementally parses Server-Sent Events (SSE) from streamed byte chunks.
*
* SSE payloads are line-oriented: each event is a sequence of `field: value`
* lines terminated by a blank line. This parser accepts arbitrary chunk
* boundaries from a live HTTP response body and emits only complete events.
*
* Typical usage is one parser instance per connection, calling {@link parseEvents}
* for each chunk received from the stream:
*
* ```ts
* const parser = new SSEEventParser();
*
* for await (const chunk of response.body) {
* for (const event of parser.parseEvents(chunk)) {
* // handle event.data, event.event, event.id, event.retry
* }
* }
* ```
*
* Supported fields follow the SSE spec: `data`, `event`, `id`, and `retry`.
* Multiple `data:` lines in one event are joined with `\n`. An event is only
* emitted once a blank line is seen and at least one `data` field was collected.
*/
export class SSEEventParser {
private readonly textDecoder: TextDecoder
/** Bytes from a partial line or incomplete event, carried over to the next chunk. */
private messageBuffer: string = ''
/**
* Creates a parser for one SSE stream.
*
* Inject custom encoders in tests or when a non-default character encoding
* is required; production callers can rely on the defaults.
*
* @param options - Optional text encoders for decode/encode of stream bytes.
*/
constructor(options: Partial<SSEEventParserOptions> = {}) {
this.textDecoder = options.textDecoder ?? new TextDecoder()
}
/**
* Clears any buffered bytes from a partial line or incomplete event.
*
* Call when abandoning a transport so the next connection does not prepend
* stale bytes to incoming chunks.
*/
public reset(): void {
// Clear the message buffer
this.messageBuffer = ''
// Reset the decoder to clear any buffered bytes
this.textDecoder.decode()
}
/**
* Parses all complete SSE events contained in a newly received chunk.
*
* The chunk is appended to any bytes buffered from earlier calls. Complete
* events (blank-line delimited blocks with at least one `data` field) are
* returned immediately; any trailing partial line or in-progress event stays
* in the internal buffer until a later chunk completes it.
*
* @param chunk - Newly received SSE stream bytes.
* @returns Zero or more complete parsed SSE events from this chunk.
*/
public parseEvents(chunk: Uint8Array): SSEvent[] {
const lines = this.getBufferedLines(chunk)
// The final split item is the incomplete remainder after the final line
// ending. It must not be processed as a complete SSE line.
const completeLines = lines.slice(0, -1)
const events: SSEvent[] = []
let event: Partial<SSEvent> = {}
let processedLineCount = 0
for (const [index, line] of completeLines.entries()) {
// A blank line indicates the end of an event. If we have received data, we can complete the event
if (line === '') {
if (event.data !== undefined) {
// Cast event when pushing to an SSE Event so it changes from Partial<SSEvent> to SSEvent
// The "Partial" can safely be removed as we have guaranteed that `data` is defined
events.push(event as SSEvent)
}
event = {}
processedLineCount = index + 1
continue
}
this.parseLine(line, event)
}
this.storeRemainingLines(lines, processedLineCount)
return events
}
/**
* Appends a new chunk to the buffered bytes and splits the combined payload
* into lines.
*
* Accepts `\r\n`, `\r`, and `\n` line endings so events parse correctly
* regardless of server or platform conventions.
*/
private getBufferedLines(chunk: Uint8Array): string[] {
this.messageBuffer += this.textDecoder.decode(chunk, { stream: true })
return this.messageBuffer.split(SSE_LINE_ENDINGS)
}
/**
* Parses one SSE field line into an in-progress event.
*
* Lines starting with a colon are ignored as comments. A single optional space after the colon
* is stripped from the field value, per the SSE spec.
*/
private parseLine(line: string, event: Partial<SSEvent>): void {
// Split the line at the first colon. Before the first colon is the field, after the first colon is the value.
const [field, ...valueArray] = line.split(':')
// Join the rest of the array back together to get the value as it was before the split.
// If the value has a space at the start, remove it (according to the SSE spec)
const value = valueArray.join(':').replace(SSE_FIELD_VALUE_REGEX, '')
switch (field) {
case 'data':
event.data = event.data !== undefined ? `${event.data}${NEW_LINE}${value}` : value
return
case 'event':
event.event = value
return
case 'id':
event.id = value
return
case 'retry':
this.parseRetry(value, event)
return
}
}
/**
* Applies a numeric `retry:` field to an in-progress event.
*
* Non-numeric values are ignored rather than failing the parse.
*/
private parseRetry(value: string, event: Partial<SSEvent>): void {
const retry = parseInt(value, 10)
if (!isNaN(retry)) {
event.retry = retry
}
}
/**
* Preserves incomplete trailing lines for the next received chunk.
*
* Only lines that were fully processed (through a completed event boundary)
* are discarded; the remainder is re-encoded into {@link messageBuffer}.
*/
private storeRemainingLines(lines: string[], processedLineCount: number): void {
this.messageBuffer = lines.slice(processedLineCount).join(NEW_LINE)
}
}
+471
View File
@@ -0,0 +1,471 @@
// Vendored from https://git.harvmaster.com/Harvmaster/sse-session (commit c08267e85c).
// Local changes: import paths, uses ../event-emitter.ts, tryAsync folded into SSESession.
import type { SSESessionOptions, SSESessionEventMap, SSEvent } from './types.js'
import { EventEmitter } from '../event-emitter.js'
import { AsyncPushIterator } from './async-push-iterator.js'
import { ExponentialBackoff } from './exponential-backoff.js'
import { SSEEventParser } from './sse-event-parser.js'
/**
* A fetch-based Server-Sent Events (SSE) client with reconnect and optional
* browser tab visibility handling.
*
* Each session maintains one HTTP streaming connection at a time. Incoming
* bytes are parsed into {@link SSEvent} objects and delivered through two
* surfaces:
*
* - **Events** — `"connected"`, `"message"`, `"disconnected"`, `"error"`,
* and `"closed"` on the session itself (extends {@link EventEmitter}).
* - **Messages** — {@link messages}, an async iterable for `for await...of`
* consumers.
*
* Typical usage:
*
* ```ts
* const session = await SSESession.create("/events");
*
* session.on("message", (event) => console.log(event.data));
*
* for await (const event of session.messages) {
* handle(event);
* }
* ```
*
* ## Lifecycle
*
* - {@link connect} opens (or reopens) the transport. It resolves once the
* HTTP stream is established; reading continues in the background.
* - {@link abort} stops the in-flight fetch without ending the session.
* Used internally for tab visibility. The {@link messages} iterator stays
* open so an existing consumer resumes when the tab becomes visible again.
* - {@link disconnect} aborts the transport, closes {@link messages}, emits
* `"closed"`, and disables attached visibility handlers until the next
* manual {@link connect}.
*
* Automatic reconnect is controlled by {@link SSESessionOptions.persistent}
* (server closed the stream) and
* {@link SSESessionOptions.attemptReconnect} (transport error).
*
* ## Connection supersession
*
* Each {@link connect} or {@link abort} bumps an internal `connectionId`.
* Background read loops capture their id at start and exit quietly when a
* newer connection supersedes them, avoiding duplicate events or errors from
* stale transports.
*/
export class SSESession extends EventEmitter<SSESessionEventMap> {
/**
* Creates a session and waits until the first connection is established.
*
* @param url - The SSE endpoint URL.
* @param options - Configuration merged with instance defaults.
* @returns A connected session.
* @throws When the initial connection cannot be established.
*/
static async create(url: string, options: Partial<SSESessionOptions> = {}): Promise<SSESession> {
const client = new SSESession(url, options)
await client.connect()
return client
}
/**
* Enables SSE resume semantics by sending `Last-Event-ID` on reconnect.
*
* Listens for incoming `"message"` events and remembers the most recent
* {@link SSEvent.id}. On every subsequent connect or reconnect, the session's
* {@link onRequest} hook is wrapped so that header is attached when an id is
* known, allowing the server to replay only events the client has not yet
* received.
*
* The existing {@link onRequest} callback is preserved and runs after the
* header is applied, so auth or other header mutations continue to work.
*
* Attach as early in the session lifetime as possible. When added after
* {@link create}, the initial connection omits the header (no id yet);
* all later reconnects include it. To instrument before the first connect,
* call this on the session returned from {@link withBrowserVisibility}
* before awaiting a separate {@link connect} when the tab starts hidden.
*
* ```ts
* const session = await SSESession.create(url);
* await SSESession.addLastEventIdReconnect(session);
* // Reconnects send Last-Event-ID once an event with an id is received.
* ```
*
* @param client - The session to instrument.
* @returns The same session, for chaining.
*/
static async addLastEventIdReconnect(client: SSESession): Promise<SSESession> {
let lastEventId: string | undefined
client.on('message', (event) => {
// If the event has no id, we should not update the lastEventId and instead keep the old one.
lastEventId = event.id ?? lastEventId
})
const originalOnRequest = client.options.onRequest
client.options.onRequest = async (request: RequestInit): Promise<RequestInit> => {
if (lastEventId) {
request.headers = { ...request.headers, 'Last-Event-ID': lastEventId }
}
return originalOnRequest(request)
}
return client
}
/**
* Pauses and resumes a session based on browser tab visibility.
*
* Uses the Page Visibility API (`document.visibilitychange`):
*
* - **hidden** — {@link abort} stops the active fetch. {@link messages}
* stays open; `"disconnected"` fires but `"closed"` does not.
* - **visible** — {@link connect} re-establishes the stream if needed.
*
* The listener is removed when {@link disconnect} emits `"closed"`, and
* re-attached automatically on the next `"connected"` event.
*
* No-op in non-browser environments where `document` is undefined.
*
* @param client - The session to manage.
*/
static addBrowserVisibilityHandler(client: SSESession): SSESession {
if (typeof document === 'undefined') return client
const handleVisibilityChange = (): void => {
if (document.visibilityState === 'hidden') {
void client.abort()
return
}
client.connect().catch(() => {
// connect() reports failures via onError and the "error" event.
})
}
document.addEventListener('visibilitychange', handleVisibilityChange)
// Stop managing visibility after an explicit disconnect; re-register
// when the same instance is manually connected again.
client.once('closed', () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
client.once('connected', () => {
SSESession.addBrowserVisibilityHandler(client)
})
})
return client
}
/** SSE endpoint URL for this session. */
private readonly url: string
/**
* Per-instance configuration.
*
* Defaults live on the instance field (not a shared static) so each session
* gets its own {@link SSEEventParser} and {@link ExponentialBackoff}.
*/
public options: SSESessionOptions = {
fetch: (...args) => fetch(...args),
method: 'GET',
headers: {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
},
body: new FormData(),
onRequest: (request) => Promise.resolve(request),
onConnected: () => {},
onDisconnected: () => {},
onError: (error) => console.error('SSEClient error:', error),
// Retry the initial fetch until it succeeds (maxAttempts: 0 = unlimited).
retry: new ExponentialBackoff({
baseDelay: 1000,
maxDelay: 10000,
maxAttempts: 0,
growthRate: 1.3,
jitter: 0.3,
}),
attemptReconnect: true,
persistent: false,
eventParser: new SSEEventParser(),
}
/** AbortController for the currently active fetch, if any. */
private controller: AbortController | null = null
/**
* Asynchronous stream of parsed SSE events for the active connection.
*
* Stays open across {@link abort} and automatic reconnects so an existing
* `for await` consumer keeps receiving events after visibility resumes.
*
* Closes when:
* - the server ends the stream and {@link SSESessionOptions.persistent}
* is false,
* - {@link disconnect} is called, or
* - a transport error occurs with
* {@link SSESessionOptions.attemptReconnect} disabled.
*
* A later {@link connect} replaces this with a new iterator when the
* previous one was closed. Consumers should read from `session.messages`
* rather than caching a reference across terminal disconnects.
*/
public messages: AsyncPushIterator<SSEvent> = new AsyncPushIterator<SSEvent>()
public constructor(url: string, options: Partial<SSESessionOptions> = {}) {
super()
this.url = url
this.options = {
...this.options,
...options,
// Shallow merge would drop default headers when options.headers is set.
headers: { ...this.options.headers, ...options.headers },
}
}
/**
* Connects or reconnects to the SSE endpoint.
*
* Resolves once the HTTP stream is established and `"connected"` has been
* emitted. Body reading continues asynchronously in the background via
* {@link readStream}.
*
* @throws When the fetch retry policy exhausts attempts or the connection
* is superseded before the reader is handed off (in the latter case the
* promise resolves without throwing).
*/
public async connect(): Promise<void> {
// If there is already a controller present, we are already connected.
if (this.controller) return
// Prepare for a fresh transport. Parser state from an abandoned connection
// must not bleed into the next one; reopen messages if a prior terminal
// close ended the consumer's iteration loop.
// NOTE: The resetEventParser may be out of spec and should not be done here: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
this.options.eventParser.reset()
// If the last messages iterator wasnt closed, we keep it around as this is likely a reconnect.
if (this.messages.closed) {
this.messages = new AsyncPushIterator<SSEvent>()
}
const controller = new AbortController()
this.controller = controller
const { method, headers, body } = this.options
const fetchBody = method === 'POST' ? body : null
const fetchOptions: RequestInit = {
method,
headers: headers || {},
body: fetchBody ?? null,
signal: controller.signal,
cache: 'no-store',
}
let reader: ReadableStreamDefaultReader<Uint8Array>
try {
reader = await this.options.retry.run(() => this.createReader(fetchOptions))
} catch (error) {
// A newer abort/connect superseded this attempt — leave state to the winner.
if (this.controller !== controller) return
this.controller = null
await this.notifyDisconnected()
await this.notifyError(error)
this.messages.close()
throw error
}
// Connection succeeded but was already replaced (for example abort during fetch).
if (this.controller !== controller) {
await reader.cancel()
return
}
await SSESession.tryAsync(
() => this.options.onConnected(),
(error) => this.notifyError(error),
)
this.emit('connected', undefined)
// Fire-and-forget: connect() resolves while the stream is consumed.
this.readStream(reader, controller).catch((error) => {
this.options.onError(error)
})
}
/**
* Aborts only the currently active transport.
*
* The session remains reusable: {@link messages} stays open, visibility
* handling stays attached, and {@link connect} can reopen the stream.
* Partial parser state from the abandoned transport is discarded.
*
* Emits `"disconnected"` but not `"closed"`.
*/
public async abort(): Promise<void> {
if (!this.controller) return
// Grab the current controller to ensure we are aborting the correct one.
const controller = this.controller
this.controller = null
// Invalidate any in-flight read loop and fetch for this transport.
controller.abort()
this.options.eventParser.reset()
await this.notifyDisconnected()
}
/**
* Terminates the session and disables attached visibility handling until
* the same instance is manually {@link connect connected} again.
*
* Closes {@link messages} and emits `"closed"`.
*/
public async disconnect(): Promise<void> {
this.messages.close()
this.emit('closed', undefined)
if (this.controller) {
await this.abort()
} else {
this.options.eventParser.reset()
}
}
/**
* Performs the HTTP request and returns a reader for the response body.
*
* {@link SSESessionOptions.onRequest} may mutate headers (for example auth
* tokens or `Last-Event-ID`) before the fetch runs.
*/
private async createReader(fetchOptions: RequestInit): Promise<ReadableStreamDefaultReader<Uint8Array>> {
const requestOptions = await this.options.onRequest(fetchOptions)
const response = await this.options.fetch(this.url, requestOptions)
if (!response.ok) {
const responseCode = response.status
const responseText = await response.text()
const error = new Error(`HTTP error: ${responseCode} ${responseText}`)
void this.notifyError(error)
throw error
}
if (!response.body) {
const error = new Error('HTTP error: Response body is null')
void this.notifyError(error)
throw error
}
return response.body.getReader()
}
/**
* Reads bytes from an established stream until it ends, errors, or is
* superseded by a newer connection.
*/
private async readStream(reader: ReadableStreamDefaultReader<Uint8Array>, controller: AbortController): Promise<void> {
try {
while (this.controller === controller) {
const { done, value } = await reader.read()
// abort() or a newer connect() may have landed while we were awaiting.
if (this.controller !== controller) return
if (done) {
this.controller = null
await this.notifyDisconnected()
if (this.options.persistent) {
// Server closed gracefully — reopen unless the consumer opted out.
await this.connect()
} else {
this.messages.close()
}
return
}
// Some environments yield `{ done: false, value: undefined }`.
if (!value) continue
for (const event of this.options.eventParser.parseEvents(value)) {
this.emit('message', event)
this.messages.push(event)
}
}
} catch (error) {
// If the controller is different, we already started a new connection and it would be confusing to handle this error.
if (controller !== this.controller) return
// Invalidate the current controller to allow for reconnection if needed
this.controller = null
await this.notifyDisconnected()
// Expected path for abort() — do not treat as an error or reconnect.
if (controller.signal.aborted) return
await this.notifyError(error)
if (this.options.attemptReconnect) {
await this.connect()
} else {
this.messages.close()
}
}
}
/** Invokes {@link SSESessionOptions.onDisconnected} and emits `"disconnected"`. */
private async notifyDisconnected(): Promise<void> {
await SSESession.tryAsync(
() => this.options.onDisconnected(),
(error) => this.options.onError(error),
)
this.emit('disconnected', undefined)
}
/** Invokes {@link SSESessionOptions.onError} and emits `"error"`. */
private async notifyError(error: unknown): Promise<void> {
const errorInstance = error instanceof Error ? error : new Error(String(error))
await SSESession.tryAsync(
() => this.options.onError(errorInstance),
(callbackError) => console.error('SSESession error:', callbackError),
)
this.emit('error', errorInstance)
}
/** Runs `fn`, passing any error to `onError` instead of throwing. */
private static async tryAsync(fn: () => unknown, onError?: (error: Error) => void): Promise<void> {
try {
await fn()
} catch (error) {
onError?.(error instanceof Error ? error : new Error(`${error}`))
}
}
}
+145
View File
@@ -0,0 +1,145 @@
// Vendored from https://git.harvmaster.com/Harvmaster/sse-session (commit c08267e85c).
// Local changes: import paths, uses ../event-emitter.ts, tryAsync folded into SSESession.
export type SSERequestInit = {
/**
* The HTTP method to use.
*/
method: 'GET' | 'POST'
/**
* Request headers sent on every connect and reconnect.
*/
headers?: Record<string, string>
/**
* Request body for POST-based SSE endpoints.
*/
body?: string | FormData
}
/**
* The fetch function to use.
*
* NOTE: This is compatible with Browser/Node's native "fetch" function.
* We use this in place of "typeof fetch" so that we can accept non-standard URLs ("url" is a "string" here).
* For example, a LibP2P adapter might not use a standardized URL format (and might only include "path").
* This would cause a type error as native fetch expects type "URL".
*/
export type SSERequestFunction = {
fetch: (url: string, options: RequestInit) => Promise<Response>
}
/**
* Lifecycle hooks invoked by {@link SSESession} during connect, read, and teardown.
*/
export type SSESessionCallbacks = {
/**
* Called before each fetch so callers can attach auth headers, cookies, or
* a `Last-Event-ID` for resume semantics.
*/
onRequest: (request: RequestInit) => Promise<RequestInit>
/**
* Called after the HTTP stream is established and before body reading begins.
*/
onConnected: () => void
/**
* Called when the active transport ends — including {@link SSESession.abort},
* server stream completion, and errors. Not paired with {@link SSESessionCallbacks.onConnected}
* when the initial connect never succeeds.
*/
onDisconnected: () => void
/**
* Called on fetch or read failures. Not invoked for intentional
* {@link SSESession.abort} aborts.
*/
onError: (error: Error) => void
}
export type SSESessionRetryInterface = {
/**
* Retry policy used while establishing the HTTP connection in
* {@link SSESession.connect}. Defaults to {@link ExponentialBackoff} with
* unlimited attempts.
*/
retry: {
run<T>(fn: () => Promise<T>, onError?: (error: Error) => void): Promise<T>
}
}
export interface SSEParser {
/**
* Incremental SSE frame parser for the response body.
*
* {@link SSEEventParser.reset} is called by the session when abandoning a
* transport so partial frames do not carry over to the next connection.
*/
eventParser: {
parseEvents(buffer: Uint8Array): SSEvent[]
reset(): void
}
}
export type SSELifecycleOptions = {
/**
* When true, {@link SSESession} calls {@link SSESession.connect} again after
* a transport **error** (not an intentional abort).
*/
attemptReconnect: boolean
/**
* When true, {@link SSESession} calls {@link SSESession.connect} again after
* the **server** closes the stream normally (`done`).
*/
persistent: boolean
}
/**
* Events emitted by {@link SSESession}.
*
* - `"connected"` — HTTP stream established.
* - `"message"` — A complete SSE event was parsed.
* - `"disconnected"` — The active transport ended (including {@link SSESession.abort}).
* - `"error"` — An unexpected fetch or read failure.
* - `"closed"` — {@link SSESession.disconnect} was called; visibility handling is detached.
*/
export type SSESessionEventMap = {
connected: void
disconnected: void
error: Error
message: SSEvent
closed: void
}
/**
* Configuration for {@link SSESession}.
*/
export type SSESessionOptions = SSESessionCallbacks & SSERequestInit & SSERequestFunction & SSESessionRetryInterface & SSELifecycleOptions & SSEParser
/**
* Represents a Server-Sent Event.
*/
export interface SSEvent {
/**
* Event data.
*/
data: string
/**
* Event type.
*/
event?: string
/**
* Event ID.
*/
id?: string
/**
* Reconnection time in milliseconds.
*/
retry?: number
}
+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()
})
})
+21 -16
View File
@@ -42,43 +42,48 @@ describe('EventSourcedDocumentStorage', () => {
return { kv, events, projections, docs: new EventSourcedDocumentStorage(events, projections) } return { kv, events, projections, docs: new EventSourcedDocumentStorage(events, projections) }
} }
it('records resolved changes as history and replays any version', async () => { it('records resolved changes as history and replays any event', async () => {
const users = setup().docs.child('users') const users = setup().docs.child('users')
await users.set('1', { n: 5 }) await users.set('1', { n: 5 })
await users.update('1', { $inc: { n: 1 } }) await users.update('1', { $inc: { n: 1 } })
await users.delete('1') await users.delete('1')
await users.set('1', { n: 10 }) await users.set('1', { n: 10 })
expect((await collect(users.history('1'))).map((e) => e.data)).toEqual([ const history = await collect(users.history('1'))
expect(history.map((e) => e.data)).toEqual([
{ op: 'set', doc: { n: 5 } }, { op: 'set', doc: { n: 5 } },
{ op: 'patch', set: { n: 6 }, unset: [] }, { op: 'patch', set: { n: 6 }, unset: [] },
{ op: 'delete' }, { op: 'delete' },
{ op: 'set', doc: { n: 10 } }, { op: 'set', doc: { n: 10 } },
]) ])
expect(await users.getAt('1', 2)).toEqual({ n: 6 }) expect(await users.getAt('1', history[1]!.id)).toEqual({ n: 6 })
expect(await users.child('1').getAt(3)).toBeUndefined() expect(await users.child('1').getAt(history[2]!.id)).toBeUndefined()
expect(await users.get('1')).toEqual({ n: 10 }) expect(await users.get('1')).toEqual({ n: 10 })
}) })
it('heals a projection left behind by a crash between append and projection write', async () => { it('catchUp folds in events written elsewhere', async () => {
const { docs, events } = setup() const { docs, events } = setup()
const users = docs.child('users') const users = docs.child('users')
await users.set('1', { n: 1 }) await users.set('1', { n: 1 })
await events.child('users').append('1', [{ op: 'patch', set: { n: 2 }, unset: [] }]) // projection never written await events.child('users').append('1', [{ op: 'patch', set: { m: 2 }, unset: [] }]) // e.g. synced from another device
expect(await users.get('1')).toEqual({ n: 1 }) expect(await users.get('1')).toEqual({ n: 1 })
expect(await users.update('1', { $inc: { n: 1 } })).toEqual({ n: 3 }) expect(await users.catchUp('1')).toEqual({ n: 1, m: 2 })
await events.child('users').append('2', [{ op: 'set', doc: { n: 7 } }]) // first write crashed expect(await users.get('1')).toEqual({ n: 1, m: 2 })
expect(await users.catchUp('2')).toEqual({ n: 7 }) await events.child('users').append('2', [{ op: 'set', doc: { n: 7 } }, { op: 'delete' }])
expect(await users.get('2')).toEqual({ n: 7 }) expect(await users.catchUp('2')).toBeUndefined()
expect(await users.get('2')).toBeUndefined()
}) })
it('two instances incrementing the same document converge', async () => { it('two instances editing different fields both keep their edits; the same field ends with the later value', async () => {
const kv = new KVStorageInMemory() const kv = new KVStorageInMemory()
const make = () => new EventSourcedDocumentStorage(new EventStorageKV(kv), new DocumentStorageKV(kv.child('projections'))).child('users') let t = 1000
const make = () =>
new EventSourcedDocumentStorage(new EventStorageKV(kv, { now: () => t++ }), new DocumentStorageKV(kv.child('projections'))).child('users')
const a = make() const a = make()
const b = make() const b = make()
await a.set('1', { n: 0 }) await a.set('1', { name: 'x', age: 1 })
await Promise.all([...Array(5)].flatMap(() => [a.update('1', { $inc: { n: 1 } }), b.update('1', { $inc: { n: 1 } })])) await Promise.all([a.update('1', { $set: { name: 'Ada' } }), b.update('1', { $set: { age: 2 } })])
expect(await a.get('1')).toEqual({ n: 10 }) await b.update('1', { $set: { name: 'Bob' } })
expect(await a.catchUp('1')).toEqual({ name: 'Bob', age: 2 })
}) })
it('a write is a projection read, an append and a projection write', async () => { it('a write is a projection read, an append and a projection write', async () => {
@@ -86,6 +91,6 @@ describe('EventSourcedDocumentStorage', () => {
await docs.child('users').set('1', { n: 1 }) await docs.child('users').set('1', { n: 1 })
kv.reset() kv.reset()
await docs.child('users').update('1', { $inc: { n: 1 } }) await docs.child('users').update('1', { $inc: { n: 1 } })
expect(kv.calls).toEqual({ readMany: 2, writeIfAbsent: 1, writeMany: 1 }) expect(kv.calls).toEqual({ readMany: 2, writeMany: 2 })
}) })
}) })
+57 -52
View File
@@ -1,45 +1,70 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import fc from 'fast-check' import fc from 'fast-check'
import { EventStorageKV } from '../../src/event-storage/event-storage-kv.js' import { EventStorageKV } from '../../src/event-storage/event-storage-kv.js'
import type { BaseKVStorage } from '../../src/kv-storage/base-kv-storage.js'
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js' import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
import { ConcurrencyError } from '../../src/utils/errors.js'
import { collect, KVStorageCounting } from '../helpers.js' import { collect, KVStorageCounting } from '../helpers.js'
function setup(kv = new KVStorageCounting()) { function setup(kv: BaseKVStorage = new KVStorageCounting(), start = 1000) {
let t = 1000 let t = start
return { kv, events: new EventStorageKV(kv, { now: () => t++ }) } return { kv, events: new EventStorageKV(kv, { now: () => t++ }) }
} }
describe('EventStorageKV', () => { describe('EventStorageKV', () => {
it('appends and reads in self and child forms', async () => { it('appends and reads in self and child forms', async () => {
const orders = setup().events.child('orders') const orders = setup().events.child('orders')
expect(await orders.append('o1', [{ type: 'created' }], { expectedVersion: 0 })).toEqual({ version: 1 }) const [created] = await orders.append('o1', [{ type: 'created' }])
expect(await orders.child('o1').append([{ type: 'paid' }, { type: 'shipped' }])).toEqual({ version: 3 }) const ids = await orders.child('o1').append([{ type: 'paid' }, { type: 'shipped' }])
expect(await collect(orders.read('o1'))).toEqual([ expect(await collect(orders.read('o1'))).toEqual([
{ version: 1, at: 1000, data: { type: 'created' } }, { id: created, at: 1000, data: { type: 'created' } },
{ version: 2, at: 1001, data: { type: 'paid' } }, { id: ids[0], at: 1001, data: { type: 'paid' } },
{ version: 3, at: 1001, data: { type: 'shipped' } }, { id: ids[1], at: 1002, data: { type: 'shipped' } },
]) ])
expect(await collect(orders.child('o1').read({ from: 3 }))).toHaveLength(1) expect((await collect(orders.child('o1').read({ after: ids[0] }))).map((e) => e.data)).toEqual([{ type: 'shipped' }])
expect(await orders.version('o1')).toBe(3) expect((await collect(orders.read('o1', { reverse: true, limit: 2 }))).map((e) => e.id)).toEqual([ids[1], ids[0]])
expect(await orders.version('none')).toBe(0) expect((await collect(orders.read('o1', { reverse: true, after: ids[0] }))).map((e) => e.id)).toEqual([created])
}) })
it('stores one key per commit', async () => { it('ids are the time and a hash of the time and content', async () => {
const { kv, events } = setup() const { kv, events } = setup()
await events.child('s').append(['a']) const [id] = await events.child('s').append([{ b: 1, a: 2 }])
await events.child('s').append(['b', 'c']) expect(id).toMatch(/^000000000001000_[0-9a-f]{64}$/)
expect((await collect(kv.scan())).map(([k]) => k)).toEqual(['s/~e/000000000001', 's/~e/000000000002']) expect(id).toBe(await EventStorageKV.id(1000, { a: 2, b: 1 }))
expect((await collect(kv.scan())).map(([k]) => k)).toEqual([`s/~e/${id}`])
}) })
it('enforces expectedVersion and the arity rules', async () => { it('one append is one write; one instance never reuses a time', async () => {
const kv = new KVStorageCounting()
const events = new EventStorageKV(kv, { now: () => 5 })
const ids = await events.child('s').append(['a', 'a'])
expect(kv.calls).toEqual({ writeMany: 1 })
expect(ids.map((id) => id.slice(0, 15))).toEqual(['000000000000005', '000000000000006'])
expect((await collect(events.child('s').read())).map((e) => e.data)).toEqual(['a', 'a'])
})
it('keeps concurrent appends from different writers, ordered by time', async () => {
const kv = new KVStorageInMemory()
const a = setup(kv, 1000).events.child('s')
const b = setup(kv, 1000).events.child('s')
await Promise.all([a.append(['a1', 'a2']), b.append(['b1', 'b2'])])
const read = await collect(a.read())
expect(read).toHaveLength(4)
expect(read.map((e) => e.at)).toEqual([1000, 1000, 1001, 1001])
expect(read.map((e) => e.id)).toEqual([...read.map((e) => e.id)].sort())
expect(await collect(b.read())).toEqual(read)
})
it('writing the same event again does not duplicate it', async () => {
const kv = new KVStorageInMemory()
await setup(kv).events.child('s').append(['x'])
await setup(kv).events.child('s').append(['x'])
expect(await collect(new EventStorageKV(kv).child('s').read())).toHaveLength(1)
})
it('checks the arity rules', async () => {
const { events } = setup() const { events } = setup()
const s = events.child('s')
await s.append(['a'])
await expect(s.append(['b'], { expectedVersion: 0 })).rejects.toBeInstanceOf(ConcurrencyError)
await expect(events.append(['x'])).rejects.toThrow(/root/) await expect(events.append(['x'])).rejects.toThrow(/root/)
await expect(events.child('o').append(undefined as never, ['x'] as never)).rejects.toThrow(TypeError) await expect(events.child('o').append(undefined as never, ['x'] as never)).rejects.toThrow(TypeError)
await expect(events.child('o').version(undefined as never)).rejects.toThrow(TypeError)
}) })
it('lists child streams', async () => { it('lists child streams', async () => {
@@ -51,46 +76,26 @@ describe('EventStorageKV', () => {
expect(await collect(orders.child('o1').keys())).toEqual(['notes']) expect(await collect(orders.child('o1').keys())).toEqual(['notes'])
}) })
it('a warm append is one writeIfAbsent; a cold one adds one reverse scan', async () => {
const { kv, events } = setup()
await events.child('s').append(['a'])
kv.reset()
await events.child('s').append(['b'])
expect(kv.calls).toEqual({ writeIfAbsent: 1 })
const cold = setup(kv).events
kv.reset()
await cold.child('s').append(['c'])
expect(kv.calls).toEqual({ scan: 1, writeIfAbsent: 1 })
})
it('a writer with a stale cache collides; with "any" it retries', async () => {
const kv = new KVStorageInMemory()
const a = setup(new KVStorageCounting(kv)).events.child('s')
const b = setup(new KVStorageCounting(kv)).events.child('s')
await a.append(['a1'])
await b.append(['b1'])
await expect(a.append(['a2'], { expectedVersion: 1 })).rejects.toMatchObject({ actualVersion: 2 })
expect(await a.append(['a3'])).toEqual({ version: 3 })
const results = await Promise.allSettled([a.append(['x'], { expectedVersion: 3 }), b.append(['y'], { expectedVersion: 3 })])
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1)
})
it('reads any window correctly (property)', async () => { it('reads any window correctly (property)', async () => {
await fc.assert( await fc.assert(
fc.asyncProperty( fc.asyncProperty(
fc.array(fc.integer({ min: 1, max: 4 }), { maxLength: 20 }), fc.array(fc.integer({ min: 1, max: 4 }), { maxLength: 20 }),
fc.record({ fc.record({
from: fc.option(fc.integer({ min: 0, max: 60 }), { nil: undefined }), after: fc.option(fc.nat(60), { nil: undefined }),
to: fc.option(fc.integer({ min: 0, max: 60 }), { nil: undefined }),
limit: fc.option(fc.nat(10), { nil: undefined }), limit: fc.option(fc.nat(10), { nil: undefined }),
reverse: fc.boolean(),
}), }),
async (sizes, options) => { async (sizes, { after, limit, reverse }) => {
const s = setup().events.child('s') const s = setup().events.child('s')
let n = 0 const ids: string[] = []
for (const size of sizes) await s.append(Array.from({ length: size }, () => ++n)) for (const size of sizes) ids.push(...(await s.append(Array.from({ length: size }, () => ids.length))))
let expected = Array.from({ length: n }, (_, i) => i + 1).filter((v) => v >= (options.from ?? 1) && v <= (options.to ?? Infinity)) const all = await collect(s.read())
if (options.limit !== undefined) expected = expected.slice(0, options.limit) expect(all.map((e) => e.id)).toEqual(ids)
expect((await collect(s.read(options))).map((e) => e.data)).toEqual(expected) const ordered = reverse ? [...ids].reverse() : ids
const afterId = after === undefined ? undefined : ids[after]
let expected = afterId === undefined ? ordered : ordered.slice(ordered.indexOf(afterId) + 1)
if (limit !== undefined) expected = expected.slice(0, limit)
expect((await collect(s.read({ after: afterId, limit, reverse }))).map((e) => e.id)).toEqual(expected)
}, },
), ),
{ numRuns: 200 }, { numRuns: 200 },
+5 -10
View File
@@ -1,4 +1,4 @@
import { BaseKVStorage, type ScanOptions } from '../src/kv-storage/base-kv-storage.js' import { BaseKVStorage, type KVChange, type ScanOptions } from '../src/kv-storage/base-kv-storage.js'
import { KVStorageInMemory } from '../src/kv-storage/kv-storage-in-memory.js' import { KVStorageInMemory } from '../src/kv-storage/kv-storage-in-memory.js'
export async function collect<T>(iterable: AsyncIterable<T>): Promise<T[]> { export async function collect<T>(iterable: AsyncIterable<T>): Promise<T[]> {
@@ -9,7 +9,8 @@ export async function collect<T>(iterable: AsyncIterable<T>): Promise<T[]> {
/** /**
* Wraps another KV storage and counts primitive calls. Also shows the wrapper pattern * Wraps another KV storage and counts primitive calls. Also shows the wrapper pattern
* (e.g. an encryption layer): extend BaseKVStorage and delegate the five primitives. * (e.g. an encryption layer): extend BaseKVStorage and delegate the three primitives.
* Writes are counted as `writeMany`.
*/ */
export class KVStorageCounting extends BaseKVStorage { export class KVStorageCounting extends BaseKVStorage {
calls: Record<string, number> = {} calls: Record<string, number> = {}
@@ -25,14 +26,8 @@ export class KVStorageCounting extends BaseKVStorage {
readMany(keys: string[]) { readMany(keys: string[]) {
return this.count('readMany', () => this.inner.readMany(keys)) return this.count('readMany', () => this.inner.readMany(keys))
} }
writeMany(entries: [string, unknown][]) { protected write(changes: KVChange[]) {
return this.count('writeMany', () => this.inner.writeMany(entries)) return this.count('writeMany', () => this.inner.writeMany(changes))
}
removeMany(keys: string[]) {
return this.count('removeMany', () => this.inner.removeMany(keys))
}
writeIfAbsent(entries: [string, unknown][]) {
return this.count('writeIfAbsent', () => this.inner.writeIfAbsent(entries))
} }
scan(options?: ScanOptions) { scan(options?: ScanOptions) {
return this.count('scan', () => this.inner.scan(options)) return this.count('scan', () => this.inner.scan(options))
@@ -0,0 +1,285 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { KVStorageComposite } from '../../src/kv-storage/kv-storage-composite.js'
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
import type { BaseKVStorage, KVChangeEvent } from '../../src/kv-storage/base-kv-storage.js'
import { DocumentStorageKV } from '../../src/document-storage/document-storage-kv.js'
import { collect, KVStorageCounting } from '../helpers.js'
import { runKVStorageConformance } from './kv-storage.conformance.js'
import { TestNetwork } from './sync-helpers.js'
runKVStorageConformance(
'KVStorageComposite (in-memory source, 1 replica)',
() => new KVStorageComposite({ source: new KVStorageInMemory(), replicas: [new KVStorageInMemory()] }),
)
runKVStorageConformance('KVStorageComposite (http source, 2 replicas)', () => {
network = new TestNetwork()
return new KVStorageComposite({ source: network.client(), replicas: [new KVStorageInMemory(), new KVStorageInMemory()] })
})
let network: TestNetwork
const composites: KVStorageComposite[] = []
afterEach(async () => {
await Promise.all(composites.splice(0).map((c) => c.close()))
await network?.close()
})
function composite(persistent: BaseKVStorage = new KVStorageInMemory(), queue?: BaseKVStorage) {
const c = new KVStorageComposite({ source: network.client(), replicas: [new KVStorageInMemory(), persistent], queue, retryMs: 10 })
composites.push(c)
return c
}
describe('KVStorageComposite sync', () => {
it('loads existing data with one sync, then serves reads locally', async () => {
network = new TestNetwork()
await network.store.writeMany([
{ key: 'users/1', value: { name: 'Ada' } },
{ key: 'users/2', value: { name: 'Bob' } },
])
const db = composite()
await db.sync()
network.store.reset()
expect(await db.child('users').get('1')).toEqual({ name: 'Ada' })
expect(await collect(db.child('users').keys())).toEqual(['1', '2'])
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 () => {
network = new TestNetwork()
const memory = new KVStorageInMemory()
const db = composite(memory)
await db.sync()
const seen = db.waitFor('change', (e) => e.origin === 'remote')
await network.client().child('users').set('9', { name: 'Remote' })
expect((await seen).changes).toEqual([{ key: 'users/9', value: { name: 'Remote' } }])
expect(await db.child('users').get('9')).toEqual({ name: 'Remote' })
expect(await memory.readMany(['users/9'])).toEqual([{ name: 'Remote' }])
})
it('announces only remote changes that differ from local values', async () => {
network = new TestNetwork()
const db = composite()
await db.sync()
const events: KVChangeEvent[] = []
db.on('change', (e) => events.push(e))
await db.set('mine', 1) // relayed back by the server: same value, so no remote event
const other = db.waitFor('change', (e) => e.origin === 'remote')
await network.client().writeMany([
{ key: 'mine', value: 1 },
{ key: 'theirs', value: 2 },
])
await other
expect(events).toEqual([
{ changes: [{ key: 'mine', value: 1 }], origin: 'local' },
{ changes: [{ key: 'theirs', value: 2 }], origin: 'remote' },
])
})
it('queues writes while offline and replays them in order when back online', async () => {
network = new TestNetwork()
const db = composite()
await db.sync()
network.online = false
await db.child('users').set('1', { v: 1 })
await db.child('users').set('1', { v: 2 })
await db.child('users').delete('1')
await db.child('users').set('2', { v: 3 })
expect(db.queued).toBe(4)
expect(await db.child('users').get('2')).toEqual({ v: 3 }) // served locally
network.online = true
await vi.waitFor(() => expect(db.queued).toBe(0))
expect(await network.store.readMany(['users/1', 'users/2'])).toEqual([undefined, { v: 3 }])
})
it('the last write wins when the server pushes a change while a local write is queued', async () => {
network = new TestNetwork()
const db = composite()
await db.sync()
network.online = false
await db.set('k', { from: 'local' })
await network.store.writeMany([{ key: 'k', value: { from: 'remote' } }]) // pushed over the still-open stream
await vi.waitFor(async () => expect(await db.get('k')).toEqual({ from: 'remote' }))
network.online = true
await vi.waitFor(() => expect(db.queued).toBe(0)) // the queued write is sent last, so it wins
expect(await network.store.readMany(['k'])).toEqual([{ from: 'local' }])
expect(await db.get('k')).toEqual({ from: 'local' })
})
it('restarts from the persistent replica, then catches up', async () => {
network = new TestNetwork()
const persistent = new KVStorageInMemory()
const queue = new KVStorageInMemory()
const first = composite(persistent, queue)
await first.set('a', 1)
await first.set('gone', 1)
await first.sync()
await first.close()
await network.client().writeMany([{ key: 'b', value: 2 }, { key: 'gone' }]) // missed while closed
network.online = false
const second = composite(persistent, queue)
expect(await second.get('a')).toBe(1) // available without the server
network.online = true
await second.sync()
expect(await collect(second.scan({ values: true }))).toEqual([
['a', 1],
['b', 2],
])
})
it('replays writes queued by a previous session from a persistent queue', async () => {
network = new TestNetwork()
const persistent = new KVStorageInMemory()
const queue = persistent.child('~sync')
const first = new KVStorageComposite({ source: network.client(), replicas: [persistent], queue, prefix: 'data' })
composites.push(first)
await first.sync()
network.online = false
await first.child('data').set('offline', 'queued')
await first.close()
network.online = true
const second = new KVStorageComposite({ source: network.client(), replicas: [persistent], queue, prefix: 'data' })
composites.push(second)
expect(second.queued).toBe(0)
await second.sync()
expect(second.queued).toBe(0)
expect(await network.store.readMany(['data/offline'])).toEqual(['queued'])
})
it('catches up on changes missed while the stream was down', async () => {
network = new TestNetwork()
const db = composite()
await db.sync()
const resynced = vi.fn()
db.on('change', resynced)
network.online = false
network.dropStreams()
await network.store.writeMany([{ key: 'missed', value: 1 }])
network.online = true
await vi.waitFor(async () => expect(await db.get('missed')).toBe(1), { timeout: 5000 })
expect(resynced).toHaveBeenCalledWith({ changes: [{ key: 'missed', value: 1 }], origin: 'remote' })
})
it('syncs only its prefix and rejects keys outside it', async () => {
network = new TestNetwork()
await network.store.writeMany([
{ key: 'alice/a', value: 1 },
{ key: 'bob/b', value: 2 },
])
const db = new KVStorageComposite({ source: network.client(), replicas: [new KVStorageInMemory()], prefix: 'alice' })
composites.push(db)
await db.sync()
expect(await collect(db.scan({ values: true }))).toEqual([['alice/a', 1]])
await expect(db.child('bob').get('b')).rejects.toBeInstanceOf(RangeError)
})
it('refuses a queue inside the synced range of a replica', () => {
const replica = new KVStorageInMemory()
expect(() => new KVStorageComposite({ source: new KVStorageInMemory(), replicas: [replica], queue: replica.child('q') })).toThrow(/outside/)
expect(() => new KVStorageComposite({ source: new KVStorageInMemory(), replicas: [replica], queue: replica.child('q'), prefix: 'data' })).not.toThrow()
})
it('serves document queries from the local replica, offline too', async () => {
network = new TestNetwork()
const db = composite()
const users = new DocumentStorageKV(db).child('users')
await users.set('1', { age: 30 })
await users.set('2', { age: 40 })
await db.sync()
network.online = false
expect((await collect(users.find({ age: { $gt: 35 } }))).map((r) => r.key)).toEqual(['2'])
})
it('does not undo writes made while a sync is scanning', async () => {
// A source whose scan reads ahead (like the HTTP stream) and pauses after its first entry.
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 },
{ key: 'c', value: 3 },
])
const source = new KVStorageCounting(inner)
const scan = source.scan.bind(source)
source.scan = async function* (options) {
let first = true
for (const entry of await collect(scan(options))) {
yield entry
if (first && options?.values) ((first = false), reached(), await gate)
}
}
const persistent = new KVStorageInMemory()
await persistent.writeMany([{ key: 'a0', value: 'stale' }])
const db = new KVStorageComposite({ source, replicas: [persistent] })
composites.push(db)
void db.start()
await paused // the diff has passed 'b' in the source but not in the replica
await db.set('b', 2)
release()
await vi.waitFor(() => expect(db.queued).toBe(0))
await db.sync()
expect(await collect(db.scan({ values: true }))).toEqual([
['a', 1],
['b', 2],
['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],
])
})
it('a sync started while a write is on its way to the source does not undo it', async () => {
let release!: () => void
const gate = new Promise<void>((r) => (release = r))
const inner = new KVStorageInMemory()
const source = new KVStorageCounting(inner)
const writeMany = source.writeMany.bind(source)
source.writeMany = async (changes) => {
await gate // a slow request
return writeMany(changes)
}
const replica = new KVStorageInMemory()
const db = new KVStorageComposite({ source, replicas: [replica] })
composites.push(db)
await db.sync()
const writing = db.set('a', 1)
await vi.waitFor(async () => expect(await replica.readMany(['a'])).toEqual([1])) // applied locally, not yet on the source
const syncing = db.sync()
await new Promise((r) => setTimeout(r, 10))
expect(await replica.readMany(['a'])).toEqual([1]) // the sync skips the unconfirmed key
release()
await Promise.all([writing, syncing])
expect(await replica.readMany(['a'])).toEqual([1])
expect(await inner.readMany(['a'])).toEqual([1])
})
})
@@ -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()])
})
})
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { KVChangeEvent } from '../../src/kv-storage/base-kv-storage.js'
import { KVStorageComposite } from '../../src/kv-storage/kv-storage-composite.js'
import { KVStorageHttpServer } from '../../src/kv-storage/kv-storage-http-server.js'
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
import { AuthError } from '../../src/utils/errors.js'
import { Ed25519Signer, Ed25519Verifier } from '../../src/utils/request-auth.js'
import { collect, KVStorageCounting } from '../helpers.js'
import { TestNetwork } from './sync-helpers.js'
let network: TestNetwork
let alice: Ed25519Signer
let bob: Ed25519Signer
beforeEach(async () => {
network = new TestNetwork({ auth: { verifiers: [new Ed25519Verifier()] } })
alice = await Ed25519Signer.generate()
bob = await Ed25519Signer.generate()
await network.store.writeMany([
{ key: `${alice.publicKey}/profile`, value: { name: 'Alice' } },
{ key: `${bob.publicKey}/profile`, value: { name: 'Bob' } },
{ key: `${bob.publicKey}/posts/1`, value: { title: 'secret' } },
])
})
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', () => {
it('lets a user read, write and list their own keys', async () => {
const mine = network.client(alice).child(alice.publicKey)
expect(await mine.get('profile')).toEqual({ name: 'Alice' })
await mine.child('posts').set('1', { title: 'hello' })
expect(await collect(mine.keys())).toEqual(['posts', 'profile'])
expect(await collect(mine.child('posts').entries())).toEqual([['1', { title: 'hello' }]])
})
it("forbids reading, writing or scanning another user's keys", async () => {
const client = network.client(alice)
const theirs = client.child(bob.publicKey)
await expect(theirs.get('profile')).rejects.toMatchObject({ status: 403 })
await expect(theirs.set('profile', { name: 'hacked' })).rejects.toBeInstanceOf(AuthError)
await expect(theirs.delete('profile')).rejects.toMatchObject({ status: 403 })
await expect(collect(theirs.keys())).rejects.toMatchObject({ status: 403 })
await expect(collect(client.keys())).rejects.toMatchObject({ status: 403 }) // root listing
await expect(client.readMany([alice.publicKey + 'x/a'])).rejects.toMatchObject({ status: 403 }) // lookalike prefix
expect(await network.store.readMany([`${bob.publicKey}/profile`])).toEqual([{ name: 'Bob' }])
})
it('rejects unsigned requests', async () => {
await expect(network.client().child(alice.publicKey).get('profile')).rejects.toMatchObject({ status: 401 })
})
it('delivers only subscribed, authorized changes', async () => {
const watcher = network.client(alice)
const received: KVChangeEvent[] = []
watcher.on('change', (e) => e.origin === 'remote' && received.push(e))
await watcher.child(alice.publicKey).child('posts').subscribe()
const writer = network.client(alice).child(alice.publicKey)
await network.client(bob).child(bob.publicKey).child('posts').set('2', { title: 'bob only' })
await writer.set('profile', { name: 'Alice 2' }) // not subscribed
const arrived = watcher.waitFor('change', () => true)
await writer.child('posts').set('1', { title: 'mine' })
await arrived
expect(received.flatMap((e) => e.changes)).toEqual([{ key: `${alice.publicKey}/posts/1`, value: { title: 'mine' } }])
})
it("refuses subscriptions to another user's keys without retrying forever", async () => {
await expect(network.client(alice).child(bob.publicKey).subscribe()).rejects.toMatchObject({ status: 403 })
await expect(network.client(alice).subscribe('')).rejects.toMatchObject({ status: 403 })
})
it('a composite scoped to the user syncs only their data', async () => {
const db = new KVStorageComposite({ source: network.client(alice), prefix: alice.publicKey, replicas: [new KVStorageInMemory()] })
await db.sync()
expect(await collect(db.scan({ values: true }))).toEqual([[`${alice.publicKey}/profile`, { name: 'Alice' }]])
expect(await collect(db.keys())).toEqual([alice.publicKey])
await expect(db.child(bob.publicKey).get('profile')).rejects.toBeInstanceOf(RangeError)
const pushed = db.waitFor('change', (e) => e.origin === 'remote')
await network.client(bob).child(bob.publicKey).set('ignored', 1)
await network.client(alice).child(alice.publicKey).set('fromElsewhere', 1)
expect((await pushed).changes).toEqual([{ key: `${alice.publicKey}/fromElsewhere`, value: 1 }])
await db.close()
})
it('a composite undoes a local write the server refuses', async () => {
// A source without subscriptions, so no follow-up sync is running when the write is made.
const db = new KVStorageComposite({ source: new KVStorageCounting(network.client(alice)), prefix: alice.publicKey, replicas: [new KVStorageInMemory()] })
await db.child(alice.publicKey).set('x', 'before')
await db.sync()
refuseEverySignature()
await expect(db.child(alice.publicKey).set('x', 'after')).rejects.toMatchObject({ status: 401 })
expect(await db.child(alice.publicKey).get('x')).toBe('before')
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()
})
})
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { KVChangeEvent } from '../../src/kv-storage/base-kv-storage.js'
import { StorageUnavailableError } from '../../src/utils/errors.js'
import { TestNetwork } from './sync-helpers.js'
let network: TestNetwork
afterEach(() => network?.close())
describe('KVStorageHttp.subscribe (SSE)', () => {
it('relays every change under the path as remote change events, its own writes included', async () => {
network = new TestNetwork()
const watcher = network.client()
await watcher.child('users').subscribe()
const received: KVChangeEvent[] = []
watcher.on('change', (e) => e.origin === 'remote' && received.push(e))
const other = watcher.waitFor('change', (e) => e.origin === 'remote')
await network.client().child('users').set('1', { name: 'Ada', n: 1n })
await other
const own = watcher.waitFor('change', (e) => e.origin === 'remote')
await network.client().set('elsewhere', 1) // not subscribed
await watcher.child('users').delete('1')
await own
expect(received.map((e) => e.changes)).toEqual([[{ key: 'users/1', value: { name: 'Ada', n: 1n } }], [{ key: 'users/1' }]])
})
it('emits resync when a dropped stream reconnects', async () => {
network = new TestNetwork()
const watcher = network.client()
await watcher.subscribe('')
const resync = watcher.waitFor('resync', () => true)
network.dropStreams()
await resync
const after = watcher.waitFor('change', () => true)
await network.client().set('a', 1)
expect((await after).changes).toEqual([{ key: 'a', value: 1 }])
})
it('stops relaying after unsubscribe', async () => {
network = new TestNetwork()
const watcher = network.client()
await watcher.subscribe('a')
await watcher.subscribe('b')
await watcher.unsubscribe('a')
const received: KVChangeEvent[] = []
watcher.on('change', (e) => received.push(e))
const arrived = watcher.waitFor('change', () => true)
await network.client().set('a', 1)
await network.client().set('b', 2)
await arrived
expect(received.flatMap((e) => e.changes)).toEqual([{ key: 'b', value: 2 }])
})
it('reports an unreachable server as StorageUnavailableError', async () => {
network = new TestNetwork()
const client = network.client()
network.online = false
await expect(client.readMany(['x'])).rejects.toBeInstanceOf(StorageUnavailableError)
})
})
+17 -4
View File
@@ -9,7 +9,7 @@ import { runKVStorageConformance } from './kv-storage.conformance.js'
import { serve } from './node-server.js' import { serve } from './node-server.js'
runKVStorageConformance('KVStorageHttp (in-process)', () => { runKVStorageConformance('KVStorageHttp (in-process)', () => {
const server = new KVStorageHttpServer(new KVStorageInMemory(), '/kv') const server = new KVStorageHttpServer(new KVStorageInMemory(), { auth: 'none', basePath: '/kv' })
return new KVStorageHttp({ url: 'http://test/kv', fetch: async (input, init) => server.handle(new Request(input, init)) }) return new KVStorageHttp({ url: 'http://test/kv', fetch: async (input, init) => server.handle(new Request(input, init)) })
}) })
@@ -22,7 +22,7 @@ describe('over a real socket', () => {
}) })
afterAll(() => server.close()) afterAll(() => server.close())
const fresh = () => { const fresh = () => {
stores.push(new KVStorageHttpServer(new KVStorageInMemory(), `/${stores.length}`)) stores.push(new KVStorageHttpServer(new KVStorageInMemory(), { auth: 'none', basePath: `/${stores.length}` }))
return new KVStorageHttp({ url: `${server.url}/${stores.length - 1}` }) return new KVStorageHttp({ url: `${server.url}/${stores.length - 1}` })
} }
@@ -30,7 +30,7 @@ describe('over a real socket', () => {
it('stops a long scan early', async () => { it('stops a long scan early', async () => {
const db = fresh() const db = fresh()
await db.writeMany(Array.from({ length: 5000 }, (_, i) => [`k${String(i).padStart(5, '0')}`, i] as [string, number])) await db.writeMany(Array.from({ length: 5000 }, (_, i) => ({ key: `k${String(i).padStart(5, '0')}`, value: i })))
const seen: string[] = [] const seen: string[] = []
for await (const [key] of db.scan()) if (seen.push(key) === 3) break for await (const [key] of db.scan()) if (seen.push(key) === 3) break
expect(seen).toEqual(['k00000', 'k00001', 'k00002']) expect(seen).toEqual(['k00000', 'k00001', 'k00002'])
@@ -47,8 +47,21 @@ describe('over a real socket', () => {
describe('server', () => { describe('server', () => {
it('maps errors to 404 / 500', async () => { it('maps errors to 404 / 500', async () => {
const server = new KVStorageHttpServer(new KVStorageInMemory()) const server = new KVStorageHttpServer(new KVStorageInMemory(), { auth: 'none' })
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()
})
}) })
+14 -5
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import fc from 'fast-check' import fc from 'fast-check'
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js' import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
import type { BaseKVStorage } from '../../src/kv-storage/base-kv-storage.js' import type { BaseKVStorage, KVChange } from '../../src/kv-storage/base-kv-storage.js'
import { collect, KVStorageCounting } from '../helpers.js' import { collect, KVStorageCounting } from '../helpers.js'
import { runKVStorageConformance } from './kv-storage.conformance.js' import { runKVStorageConformance } from './kv-storage.conformance.js'
@@ -16,14 +16,14 @@ describe('costs', () => {
await users.get('1') await users.get('1')
await users.child('1').get() await users.child('1').get()
await users.delete('1') await users.delete('1')
expect(db.calls).toEqual({ writeMany: 1, readMany: 2, removeMany: 1 }) expect(db.calls).toEqual({ writeMany: 2, readMany: 2 })
}) })
it('keys() seeks once per child with descendants, not once per key', async () => { it('keys() seeks once per child with descendants, not once per key', async () => {
const db = new KVStorageCounting() const db = new KVStorageCounting()
const writes: [string, number][] = [] const writes: KVChange[] = []
for (let c = 0; c < 50; c++) for (let d = 0; d < 20; d++) writes.push([`n/c${c}/d${d}`, d]) for (let c = 0; c < 50; c++) for (let d = 0; d < 20; d++) writes.push({ key: `n/c${c}/d${d}`, value: d })
for (let c = 0; c < 50; c++) writes.push([`n/leaf${c}`, c]) for (let c = 0; c < 50; c++) writes.push({ key: `n/leaf${c}`, value: c })
await db.writeMany(writes) await db.writeMany(writes)
db.reset() db.reset()
expect(await collect(db.child('n').keys())).toHaveLength(100) expect(await collect(db.child('n').keys())).toHaveLength(100)
@@ -76,3 +76,12 @@ describe('model-based: random writes vs a reference map', () => {
) )
}) })
}) })
describe('child nodes', () => {
it('are frozen, so implementations cannot accidentally shadow shared state', () => {
const child = new KVStorageInMemory().child('a').child('b')
expect(Object.isFrozen(child)).toBe(true)
expect(() => ((child as unknown as { state: number }).state = 1)).toThrow(TypeError)
expect(child.key).toBe('a/b')
})
})
@@ -0,0 +1,8 @@
import 'fake-indexeddb/auto'
import { KVStorageIndexedDB } from '../../src/kv-storage/kv-storage-indexeddb.js'
import { runKVStorageConformance } from './kv-storage.conformance.js'
let n = 0
runKVStorageConformance('KVStorageIndexedDB', () => new KVStorageIndexedDB({ name: `db-${n++}` }))
// Tiny batches exercise resuming scans across transactions.
runKVStorageConformance('KVStorageIndexedDB (scan batches of 2)', () => new KVStorageIndexedDB({ name: `db-${n++}`, scanBatchSize: 2 }))
+44 -20
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,19 +10,24 @@ 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', () => {
beforeEach(async () => { beforeEach(async () => {
await db.writeMany([ await db.writeMany([
['b', 2], { key: 'b', value: 2 },
['a/2', { n: 'a2' }], { key: 'a/2', value: { n: 'a2' } },
['c', [3]], { key: 'c', value: [3] },
['a', 1], { key: 'a', value: 1 },
['a/1', 'a1'], { key: 'a/1', value: 'a1' },
['a0', null], { key: 'a0', value: null },
['10', 10], { key: '10', value: 10 },
['9', 9], { key: '9', value: 9 },
]) ])
}) })
@@ -48,16 +53,9 @@ export function runKVStorageConformance(name: string, make: () => BaseKVStorage
expect(await db.readMany(['b', 'c'])).toEqual([undefined, [3]]) expect(await db.readMany(['b', 'c'])).toEqual([undefined, [3]])
}) })
it('writeIfAbsent writes only absent keys, and only one concurrent claim wins', async () => { it('writes and removes in one batch', async () => {
expect( await db.writeMany([{ key: 'b' }, { key: 'new', value: 'y' }, { key: 'a', value: 'x' }])
await db.writeIfAbsent([ expect(await db.readMany(['a', 'b', 'new'])).toEqual(['x', undefined, 'y'])
['a', 'x'],
['new', 'y'],
]),
).toEqual([false, true])
expect(await db.readMany(['a', 'new'])).toEqual([1, 'y'])
const results = await Promise.all([1, 2, 3].map((n) => db.writeIfAbsent([['claim', n]])))
expect(results.flat().filter(Boolean)).toHaveLength(1)
}) })
it('keeps scanning correctly when keys change mid-scan', async () => { it('keeps scanning correctly when keys change mid-scan', async () => {
@@ -71,6 +69,24 @@ export function runKVStorageConformance(name: string, make: () => BaseKVStorage
expect(seen.filter((k) => k !== 'a/1')).toEqual(['10', '9', 'a', 'a/2', 'a0', 'b', 'c']) expect(seen.filter((k) => k !== 'a/1')).toEqual(['10', '9', 'a', 'a/2', 'a0', 'b', 'c'])
}) })
it('emits change events for writes and removals', async () => {
const events: unknown[] = []
const off = db.on('change', (event) => events.push(event))
await db.writeMany([{ key: 'x', value: 1 }])
await db.removeMany(['x'])
await db.writeMany([])
off()
expect(events).toEqual([
{ changes: [{ key: 'x', value: 1 }], origin: 'local' },
{ changes: [{ key: 'x' }], origin: 'local' },
])
})
it('stores bigint and Uint8Array values', async () => {
await db.writeMany([{ key: 'bin', value: { n: 10n ** 20n, bytes: new Uint8Array([0, 255]) } }])
expect(await db.readMany(['bin'])).toEqual([{ n: 10n ** 20n, bytes: new Uint8Array([0, 255]) }])
})
it('does not share references with callers', async () => { it('does not share references with callers', async () => {
const [value] = (await db.readMany(['a/2'])) as [{ n: string }] const [value] = (await db.readMany(['a/2'])) as [{ n: string }]
value.n = 'changed' value.n = 'changed'
@@ -101,6 +117,14 @@ export function runKVStorageConformance(name: string, make: () => BaseKVStorage
expect(await n.getMany(['a', 'b'])).toEqual([undefined, 2]) expect(await n.getMany(['a', 'b'])).toEqual([undefined, 2])
}) })
it('sets and gets direct children as fields', async () => {
const user = db.child('users').child('1')
await user.setFields({ name: 'Ada', hobby: 'chess', skipped: undefined })
await user.child('address').set('city', 'Paris')
expect(await user.get('hobby')).toBe('chess')
expect(await user.getFields()).toEqual({ hobby: 'chess', name: 'Ada' })
})
it('keeps own values and children independent', async () => { it('keeps own values and children independent', async () => {
const user = db.child('users').child('1') const user = db.child('users').child('1')
await user.set('own') await user.set('own')
@@ -135,7 +159,7 @@ export function runKVStorageConformance(name: string, make: () => BaseKVStorage
await n.set('c', 3) await n.set('c', 3)
await n.child('c').child('x').set('y', 1) await n.child('c').child('x').set('y', 1)
await n.set('a-1', 4) await n.set('a-1', 4)
await db.writeMany([['n/~e/000000000001', {}]]) await db.writeMany([{ key: 'n/~e/000000000001', value: {} }])
expect(await collect(n.keys())).toEqual(['a', 'a-1', 'b', 'c']) expect(await collect(n.keys())).toEqual(['a', 'a-1', 'b', 'c'])
expect(await collect(n.keys({ reverse: true }))).toEqual(['c', 'b', 'a-1', 'a']) expect(await collect(n.keys({ reverse: true }))).toEqual(['c', 'b', 'a-1', 'a'])
expect(await collect(n.keys({ after: 'a', limit: 2 }))).toEqual(['a-1', 'b']) expect(await collect(n.keys({ after: 'a', limit: 2 }))).toEqual(['a-1', 'b'])
+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()
+69
View File
@@ -0,0 +1,69 @@
import { KVStorageHttp } from '../../src/kv-storage/kv-storage-http.js'
import { KVStorageHttpServer, type KVStorageHttpServerOptions } from '../../src/kv-storage/kv-storage-http-server.js'
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
import type { RequestSigner } from '../../src/utils/request-auth.js'
import { KVStorageCounting } from '../helpers.js'
/**
* An in-process server with a network switch. While `online` is false, new requests fail like a
* dropped connection (open SSE streams stay up, so pushes can still be simulated).
* `dropStreams()` ends every open SSE stream, as a proxy or server restart would.
*/
export class TestNetwork {
online = true
readonly store = new KVStorageCounting(new KVStorageInMemory())
server: KVStorageHttpServer
private readonly clients: KVStorageHttp[] = []
private readonly streams = new Set<() => void>()
constructor(options: Partial<KVStorageHttpServerOptions> = {}) {
this.server = new KVStorageHttpServer(this.store, { auth: 'none', ...options })
}
client(signer?: RequestSigner): KVStorageHttp {
const client = new KVStorageHttp({
url: 'http://server',
signer,
fetch: async (url, init) => {
if (!this.online) throw new TypeError('fetch failed')
const response = await this.server.handle(new Request(url, init))
return url.endsWith('/events') && response.body ? this.droppable(response) : response
},
})
this.clients.push(client)
return client
}
dropStreams(): void {
for (const drop of this.streams) drop()
this.streams.clear()
}
private droppable(response: Response): Response {
const reader = response.body!.getReader()
let drop!: () => void
const body = new ReadableStream<Uint8Array>({
start: (controller) => {
drop = () => {
controller.close()
void reader.cancel()
}
this.streams.add(drop)
},
pull: async (controller) => {
const { done, value } = await reader.read()
if (done) controller.close()
else controller.enqueue(value)
},
cancel: async () => {
this.streams.delete(drop)
await reader.cancel()
},
})
return new Response(body, { status: response.status, headers: response.headers })
}
async close(): Promise<void> {
await Promise.all(this.clients.map((c) => c.close()))
}
}
+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`)
})
})
+1 -1
View File
@@ -84,6 +84,6 @@ describe('event and document types', () => {
new DocumentStorageInMemory(), new DocumentStorageInMemory(),
) )
expectTypeOf(docs.child('users').history('1')).toEqualTypeOf<AsyncIterable<StoredEvent<DocChange>>>() expectTypeOf(docs.child('users').history('1')).toEqualTypeOf<AsyncIterable<StoredEvent<DocChange>>>()
expectTypeOf(docs.child('users').getAt('1', 3)).toEqualTypeOf<Promise<User | undefined>>() expectTypeOf(docs.child('users').getAt('1', 'id')).toEqualTypeOf<Promise<User | undefined>>()
}) })
}) })
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import fc from 'fast-check'
import { KeyRange } from '../../src/utils/key-range.js'
import { Keys } from '../../src/utils/keys.js'
describe('KeyRange', () => {
it('contains a key and its subtree, not lookalikes', () => {
const range = new KeyRange('ab')
expect(['ab', 'ab/c', 'ab/c/d'].every((k) => range.contains(k))).toBe(true)
expect(['a', 'abc', 'ab0', 'ab~2D1', 'b'].some((k) => range.contains(k))).toBe(false)
expect(new KeyRange('').contains('anything')).toBe(true)
})
it('agrees with its scan bounds for escaped keys (property)', () => {
const path = fc.array(fc.string({ unit: fc.constantFrom('a', 'b', '-', '/', '~', '0', '.'), minLength: 1, maxLength: 3 }), { minLength: 1, maxLength: 3 })
fc.assert(
fc.property(path, path, (p, q) => {
const range = new KeyRange(Keys.join(p))
const key = Keys.join(q)
expect(range.contains(key)).toBe(key >= range.gte! && key < range.lt!)
}),
)
})
it('clamps and checks scan ranges', () => {
const range = new KeyRange('ab')
expect(range.clamp({ limit: 5 })).toEqual({ limit: 5, gte: 'ab', lt: 'ab0' })
expect(range.clamp({ gte: 'ab/x', lt: 'zz' })).toEqual({ gte: 'ab/x', lt: 'ab0' })
expect(range.clamp({ gte: 'b' })).toBeUndefined()
expect(range.covers('ab/', 'ab0')).toBe(true)
expect(range.covers('a', 'ab0')).toBe(false)
expect(range.covers(undefined, 'ab0')).toBe(false)
expect(new KeyRange('').covers(undefined, undefined)).toBe(true)
})
})
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest'
import { EventEmitter } from '../../src/utils/event-emitter.js'
type Events = { tick: number; done: void }
describe('EventEmitter', () => {
it('delivers payloads as-is (no cloning or freezing) and supports off', () => {
const emitter = new EventEmitter<Events & { obj: { n: number } }>()
const seen: unknown[] = []
const payload = { n: 1 }
const off = emitter.on('obj', (p) => seen.push(p))
expect(emitter.emit('obj', payload)).toBe(true)
off()
expect(emitter.emit('obj', payload)).toBe(false)
expect(seen[0]).toBe(payload)
expect(Object.isFrozen(payload)).toBe(false)
})
it('once fires a single time; listener errors are logged, not thrown', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const emitter = new EventEmitter<Events>()
const once = vi.fn()
emitter.once('tick', once)
emitter.on('tick', () => {
throw new Error('boom')
})
emitter.emit('tick', 1)
emitter.emit('tick', 2)
expect(once).toHaveBeenCalledTimes(1)
expect(error).toHaveBeenCalledTimes(2)
error.mockRestore()
})
it('debounces and cancels pending debounced calls on off', async () => {
vi.useFakeTimers()
const emitter = new EventEmitter<Events>()
const listener = vi.fn()
const off = emitter.on('tick', listener, 50)
emitter.emit('tick', 1)
emitter.emit('tick', 2)
vi.advanceTimersByTime(60)
expect(listener).toHaveBeenCalledExactlyOnceWith(2)
emitter.emit('tick', 3)
off()
vi.advanceTimersByTime(60)
expect(listener).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
it('waitFor resolves on a matching payload and rejects on timeout', async () => {
const emitter = new EventEmitter<Events>()
const waiting = emitter.waitFor('tick', (n) => n > 1)
emitter.emit('tick', 1)
emitter.emit('tick', 2)
expect(await waiting).toBe(2)
await expect(emitter.waitFor('done', () => true, 5)).rejects.toThrow(/Timed out waiting for "done"/)
})
})
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import fc from 'fast-check'
import { ExtendedJson } from '../../src/utils/extended-json.js'
describe('ExtendedJson', () => {
it('round-trips bigint and Uint8Array, nested anywhere', () => {
const value = { a: 1n, b: [new Uint8Array([0, 1, 255]), -(2n ** 70n)], c: { d: new Uint8Array(0), e: 'plain', f: null } }
expect(ExtendedJson.stringify({ n: 5n, bytes: new Uint8Array([171, 205]) })).toBe('{"n":"<bigint: 5n>","bytes":"<uint8array: abcd>"}')
expect(ExtendedJson.parse(ExtendedJson.stringify(value))).toEqual(value)
})
it('round-trips arbitrary data (property)', () => {
const data = fc.letrec((tie) => ({
value: fc.oneof(
{ depthSize: 'small' },
fc.bigInt(),
fc.uint8Array(),
fc.string(),
fc.double({ noNaN: true, noDefaultInfinity: true }).map((x) => (x === 0 ? 0 : x)) /* JSON has no -0 */,
fc.boolean(),
fc.constant(null),
fc.array(tie('value')),
fc.dictionary(fc.string(), tie('value')),
),
})).value
fc.assert(
fc.property(data, (v) => {
expect(ExtendedJson.parse(ExtendedJson.stringify(v))).toEqual(v)
}),
)
})
})
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import { AUTH_HEADERS, Ed25519Signer, Ed25519Verifier, RequestAuth, type RequestSigner, type SignatureVerifier } from '../../src/utils/request-auth.js'
import { AuthError } from '../../src/utils/errors.js'
const NOW = 1_700_000_000_000
async function signed(signer: RequestSigner, path = '/kv/readMany', body = '{"keys":["a"]}', at = NOW) {
return new Headers(await RequestAuth.headers(signer, path, body, at))
}
describe('RequestAuth', () => {
it('accepts a correctly signed request and returns the public key', async () => {
const signer = await Ed25519Signer.generate()
const auth = new RequestAuth([new Ed25519Verifier()], { now: () => NOW })
const headers = await signed(signer)
expect(headers.get(AUTH_HEADERS.algorithm)).toBe('ed25519')
expect(signer.publicKey).toMatch(/^[0-9a-f]{64}$/)
expect(await auth.verify(headers, '/kv/readMany', '{"keys":["a"]}')).toBe(signer.publicKey)
})
it('rejects tampered bodies, paths, timestamps and nonces', async () => {
const signer = await Ed25519Signer.generate()
const auth = new RequestAuth([new Ed25519Verifier()], { now: () => NOW })
await expect(auth.verify(await signed(signer), '/kv/readMany', '{"keys":["b"]}')).rejects.toThrow(/Invalid signature/)
await expect(auth.verify(await signed(signer), '/kv/writeMany', '{"keys":["a"]}')).rejects.toThrow(/Invalid signature/)
const headers = await signed(signer)
headers.set(AUTH_HEADERS.timestamp, String(NOW + 1))
await expect(auth.verify(headers, '/kv/readMany', '{"keys":["a"]}')).rejects.toThrow(/Invalid signature/)
const renonced = await signed(signer)
renonced.set(AUTH_HEADERS.nonce, 'f'.repeat(32))
await expect(auth.verify(renonced, '/kv/readMany', '{"keys":["a"]}')).rejects.toThrow(/Invalid signature/)
const other = await Ed25519Signer.generate()
const swapped = await signed(signer)
swapped.set(AUTH_HEADERS.publicKey, other.publicKey)
await expect(auth.verify(swapped, '/kv/readMany', '{"keys":["a"]}')).rejects.toThrow(/Invalid signature/)
})
it('accepts identical requests made at the same moment (each has its own nonce)', async () => {
const signer = await Ed25519Signer.generate()
const sameKey = await Ed25519Signer.fromPrivateKey(await signer.exportPrivateKey())
const auth = new RequestAuth([new Ed25519Verifier()], { now: () => NOW })
const requests = await Promise.all([signed(signer), signed(signer), signed(sameKey)])
expect(new Set(requests.map((h) => h.get(AUTH_HEADERS.timestamp)))).toEqual(new Set([String(NOW)]))
expect(new Set(requests.map((h) => h.get(AUTH_HEADERS.nonce))).size).toBe(3)
for (const headers of requests) expect(await auth.verify(headers, '/kv/readMany', '{"keys":["a"]}')).toBe(signer.publicKey)
})
it('rejects old or future timestamps, replays, missing headers and unknown algorithms', async () => {
const signer = await Ed25519Signer.generate()
const auth = new RequestAuth([new Ed25519Verifier()], { now: () => NOW, maxSkewMs: 1000 })
await expect(auth.verify(await signed(signer, '/p', 'b', NOW - 5000), '/p', 'b')).rejects.toThrow(/window/)
await expect(auth.verify(await signed(signer, '/p', 'b', NOW + 5000), '/p', 'b')).rejects.toThrow(/window/)
const once = await signed(signer, '/p', 'b')
await auth.verify(once, '/p', 'b')
await expect(auth.verify(once, '/p', 'b')).rejects.toThrow(/Replayed/)
await expect(auth.verify(new Headers(), '/p', 'b')).rejects.toBeInstanceOf(AuthError)
const noNonce = await signed(signer, '/p', 'd')
noNonce.delete(AUTH_HEADERS.nonce)
await expect(auth.verify(noNonce, '/p', 'd')).rejects.toThrow(/Missing/)
const shortNonce = await signed(signer, '/p', 'e')
shortNonce.set(AUTH_HEADERS.nonce, 'abc')
await expect(auth.verify(shortNonce, '/p', 'e')).rejects.toThrow(/Nonce/)
const unknown = await signed(signer, '/p', 'c')
unknown.set(AUTH_HEADERS.algorithm, 'rot13')
await expect(auth.verify(unknown, '/p', 'c')).rejects.toThrow(/Unsupported signature algorithm "rot13"/)
})
it('restores an Ed25519 key from its exported private key', async () => {
const signer = await Ed25519Signer.generate()
const restored = await Ed25519Signer.fromPrivateKey(await signer.exportPrivateKey())
expect(restored.publicKey).toBe(signer.publicKey)
const auth = new RequestAuth([new Ed25519Verifier()], { now: () => NOW })
expect(await auth.verify(await signed(restored), '/kv/readMany', '{"keys":["a"]}')).toBe(signer.publicKey)
})
it('supports other algorithms through the verifier registry', async () => {
// A toy algorithm (signature = sha256(publicKey + payload)) standing in for e.g. secp256k1 via libauth.
const digest = async (publicKey: string, payload: Uint8Array) =>
new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array([...new TextEncoder().encode(publicKey), ...payload])))
const toySigner: RequestSigner = { algorithm: 'toy', publicKey: 'abcd', sign: (payload) => digest('abcd', payload) }
const toyVerifier: SignatureVerifier = {
algorithm: 'toy',
verify: async (publicKey, payload, signature) => (await digest(publicKey, payload)).every((byte, i) => byte === signature[i]),
}
const auth = new RequestAuth([new Ed25519Verifier(), toyVerifier], { now: () => NOW })
expect(await auth.verify(await signed(toySigner), '/kv/readMany', '{"keys":["a"]}')).toBe('abcd')
})
})
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { SSEEventParser } from '../../src/utils/sse-session/sse-event-parser.js'
import { SSESession } from '../../src/utils/sse-session/sse-session.js'
const encode = (text: string) => new TextEncoder().encode(text)
describe('vendored sse-session', () => {
it('parses events across arbitrary chunk boundaries', () => {
const parser = new SSEEventParser()
const text = 'id: 1\nevent: change\ndata: {"a":1}\n\n: comment\ndata: line1\ndata: line2\n\n'
const events = []
for (let i = 0; i < text.length; i += 3) events.push(...parser.parseEvents(encode(text.slice(i, i + 3))))
expect(events).toEqual([{ id: '1', event: 'change', data: '{"a":1}' }, { data: 'line1\nline2' }])
})
it('connects, delivers messages and reconnects when persistent', async () => {
let connections = 0
const session = new SSESession('http://test/events', {
persistent: true,
fetch: async () => {
connections++
return new Response(encode(`data: hello ${connections}\n\n`))
},
})
const received: string[] = []
session.on('message', (m) => received.push(m.data))
await session.connect()
await session.waitFor('message', (m) => m.data === 'hello 2')
await session.disconnect()
expect(received.slice(0, 2)).toEqual(['hello 1', 'hello 2'])
})
})
+1 -1
View File
@@ -3,7 +3,7 @@
"target": "ES2022", "target": "ES2022",
"module": "NodeNext", "module": "NodeNext",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"lib": ["ES2022"], "lib": ["ES2022", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
"types": ["node"], "types": ["node"],
"strict": true, "strict": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
+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'] },