Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb9ff514a0 | ||
|
|
7d755cecae | ||
|
|
68d1a1c8e9 | ||
|
|
3525dbc622 | ||
|
|
ad517965ee | ||
|
|
631c75428b | ||
|
|
1f82ec9110 | ||
|
|
a8c9539ad9 | ||
|
|
df7de27e20 | ||
|
|
4ffbc7e78d |
+43
-2
@@ -2,9 +2,12 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: ['**'] # not tags
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
NPM_REGISTRY: https://verdaccio.harvmaster.com/
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Node ${{ matrix.node }}
|
||||
@@ -12,7 +15,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: [20, 22]
|
||||
node: [22, 24, 26]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -28,3 +31,41 @@ jobs:
|
||||
run: npm run test:only
|
||||
- name: 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 }}
|
||||
|
||||
@@ -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 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 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
|
||||
|
||||
@@ -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.
|
||||
- Classes over loose functions; TS `private`, not `#`; no overloads (use `param?` or tuple-union
|
||||
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` /
|
||||
`test/document-storage/document-storage.suite.ts`.
|
||||
- Ask the owner before pushing.
|
||||
|
||||
@@ -14,6 +14,9 @@ section 7 lists what changed and why.
|
||||
that delegates the primitives.
|
||||
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.
|
||||
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
|
||||
but `undefined`/`null` throws (`InvalidKeyError`) instead of silently meaning "self". Methods use
|
||||
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).
|
||||
5. **Simple over clever.** Code style: classes, TS `private` (not `#`), no method overrides in
|
||||
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
|
||||
|
||||
```
|
||||
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
|
||||
document-storage/ base-document-storage.ts, document-storage-in-memory.ts, document-storage-kv.ts,
|
||||
event-sourced-document-storage.ts
|
||||
utils/ keys, sorted-map, json, ulid, errors, schema (types), document-path/query/update
|
||||
test/ mirrors src/; shared suites: kv-storage.conformance.ts, document-storage.suite.ts
|
||||
utils/ keys, sorted-map, json, extended-json, event-emitter, ulid, errors, schema (types),
|
||||
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/
|
||||
```
|
||||
|
||||
@@ -49,51 +60,175 @@ bench/
|
||||
|
||||
## 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 | |
|
||||
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `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) |
|
||||
| `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)`,
|
||||
`setMany`, `delete(key?)`, `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.
|
||||
`setMany`, `setFields({ a, b })` / `getFields()` (direct children as an object), `delete(key?)`,
|
||||
`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
|
||||
descendants share one iterator. It lists children that only have descendants, and skips
|
||||
reserved segments.
|
||||
- **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.
|
||||
- **Implementations:** `KVStorageInMemory` (`SortedMap`: sorted chunks of ≤1024 keys; values deep-copied);
|
||||
`KVStorageHttp` (client) + `KVStorageHttpServer` (wraps any KV storage; `handle(Request) → Response`,
|
||||
`POST /<primitive>` with JSON bodies, `scan` streamed as NDJSON). The server exposes the whole
|
||||
underlying store (primitives use absolute keys).
|
||||
- **Implementations:**
|
||||
- `KVStorageInMemory`: `SortedMap` (sorted chunks of ≤1024 keys); values deep-copied.
|
||||
- `KVStorageIndexedDB`: one object store with string keys. Scans read in batches, each in its own
|
||||
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.
|
||||
Keys must stay plaintext (or be deterministically encrypted) for prefix scans to work; values can be
|
||||
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
|
||||
|
||||
`BaseEventStorage` primitives over stream paths: `appendTo`, `readFrom`, `versionOf`, `streamsOf`.
|
||||
Node API: `append(events, opts?)` / `append(key, events, opts?)`, `read(opts?)` / `read(key, opts?)`,
|
||||
`version(key?)`, `keys()`. The root is not a stream. An empty stream is version 0; the first event is 1.
|
||||
`expectedVersion: number | 'any'` (default `'any'`).
|
||||
`BaseEventStorage` primitives over stream paths: `appendTo(stream, events)` → ids,
|
||||
`readFrom(stream, { after, limit, reverse })`, `streamsOf`. Node API: `append(events)` /
|
||||
`append(key, events)` → ids, `read(opts?)` / `read(key, opts?)` → `{ id, at, data }`, `keys()`.
|
||||
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 }`.
|
||||
Keying by commit number (not version) means any writer with a stale view collides with the
|
||||
existing key.
|
||||
- Append: head from a bounded cache (else one reverse scan, `limit: 1`) → check `expectedVersion`
|
||||
(re-reading the head before rejecting, in case the cache is stale) → `writeIfAbsent`. On a lost race:
|
||||
`ConcurrencyError` if a version was expected, otherwise retry (default 5).
|
||||
- `read({ from })`: commit `n` starts at a version ≥ n, so try commit `from` first (exact for
|
||||
single-event commits), else binary-search; then forward-scan.
|
||||
- Multi-writer safe on any store, because `writeIfAbsent` is a required primitive.
|
||||
**`EventStorageKV`** (on any KV storage): each event is one plain KV write,
|
||||
`<stream>/~e/<time:15>_<sha256(time + canonical Extended JSON of the event)>` → the event.
|
||||
|
||||
- Keys sort by time, so a stream reads in time order with one scan; `after` is an exclusive id.
|
||||
- Events from different writers in the same millisecond order by hash: arbitrary, but the same for
|
||||
every reader. One instance never reuses a time, so its own appends keep their order.
|
||||
- The id can be recomputed from the content, and re-sending an event (e.g. a retried request)
|
||||
writes the same key, so it can't be duplicated.
|
||||
- An append is one `writeMany`, so it works through `KVStorageComposite` offline and syncs like any
|
||||
other data.
|
||||
|
||||
## 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
|
||||
its own stream. Current state lives in **any** `BaseDocumentStorage` used as the projection store,
|
||||
which serves all reads, queries and indexes.
|
||||
- Projections carry `_version` (reserved; stripped from results), used as `expectedVersion` on the
|
||||
next append.
|
||||
- If a write crashes between append and projection update, the next write conflicts, rebuilds the
|
||||
projection from the stream, and retries. `catchUp(key?)` rebuilds on demand.
|
||||
- Missing projections with stream history (deleted documents, crashed first writes) are rebuilt
|
||||
before writing.
|
||||
- Writes to one document are serialised in-process, so retries only happen across processes.
|
||||
- Extras: `history(key?)`, `getAt(version)` / `getAt(key, version)`. Children keep these methods
|
||||
- A write reads the projection, resolves the change (`$inc` → `set: { n: 6 }`), appends it, and
|
||||
applies it to the projection.
|
||||
- Concurrent edits to different fields both survive; the same field ends with the later event's
|
||||
value. (Increments from two devices don't add up: each resolves to a final value.)
|
||||
- Events written elsewhere (another device, arriving through sync) are folded in by
|
||||
`catchUp(key?)`, which rebuilds the projection from the whole stream. It also repairs a
|
||||
projection left behind by a crash between append and projection write.
|
||||
- Extras: `history(key?)`, `getAt(eventId)` / `getAt(key, eventId)`. Children keep these methods
|
||||
in their types through interface declaration merging (types only).
|
||||
|
||||
## 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
|
||||
removed the subtree-order workaround in child listing.
|
||||
- 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` /
|
||||
`deleteRange`).
|
||||
- No conditional writes and no versions: KV stores execute exactly what they're given (so a server
|
||||
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).
|
||||
|
||||
## 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
|
||||
encryption wrapper could support per-subject keys.
|
||||
- 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 `getMany` × 100 | 25–35 µs |
|
||||
| `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 `read` 100 from version 5 000 of 10k | 131 µs |
|
||||
| Event `read` full 10k stream | 18 ms |
|
||||
| Event `append` (one SHA-256 via WebCrypto + one write) | 72 µs |
|
||||
| Event `read` 100 from the middle of 10k | 119 µs |
|
||||
| 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: `find` indexed (~100 hits) / scan (~200 of 10k) | 0.20 / 11 ms |
|
||||
| Event-sourced @ 10k: `get` / `update` | 1.6 / 17 µs |
|
||||
|
||||
@@ -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>`.
|
||||
@@ -4,15 +4,19 @@ import { KVStorageInMemory } from '../src/kv-storage/kv-storage-in-memory.js'
|
||||
|
||||
const events = new EventStorageKV(new KVStorageInMemory())
|
||||
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
|
||||
|
||||
describe('events', () => {
|
||||
bench('append 1 event (warm cache)', async () => {
|
||||
bench('append 1 event', async () => {
|
||||
await streams.append(`s${s++ % 1000}`, [{ type: 'e' }])
|
||||
})
|
||||
bench('read 100 events from version 5000 of 10k', async () => {
|
||||
for await (const _ of streams.read('long', { from: 5000, limit: 100 })) {
|
||||
bench('read 100 events from the middle of 10k', async () => {
|
||||
for await (const _ of streams.read('long', { after: middle, limit: 100 })) {
|
||||
}
|
||||
})
|
||||
bench('read full 10k-event stream', async () => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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'
|
||||
|
||||
for (const n of [10_000, 100_000]) {
|
||||
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 keys = Array.from({ length: 100 }, (_, i) => `u${(i * 7919) % n}`)
|
||||
let i = 0
|
||||
@@ -21,10 +22,10 @@ for (const n of [10_000, 100_000]) {
|
||||
}
|
||||
|
||||
const tree = new KVStorageInMemory()
|
||||
const writes: [string, number][] = []
|
||||
const writes: KVChange[] = []
|
||||
for (let c = 0; c < 1000; c++) {
|
||||
writes.push([`n/c${c}`, c])
|
||||
for (let d = 0; d < 100; d++) writes.push([`n/c${c}/d${d}`, d])
|
||||
writes.push({ key: `n/c${c}`, value: c })
|
||||
for (let d = 0; d < 100; d++) writes.push({ key: `n/c${c}/d${d}`, value: d })
|
||||
}
|
||||
await tree.writeMany(writes)
|
||||
|
||||
|
||||
Generated
+327
-10
@@ -1,29 +1,66 @@
|
||||
{
|
||||
"name": "potential-storage",
|
||||
"name": "@harvmaster/storage",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "potential-storage",
|
||||
"name": "@harvmaster/storage",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.5",
|
||||
"@vitest/browser": "^3.2.7",
|
||||
"fake-indexeddb": "^6.2.6",
|
||||
"fast-check": "^4.10.2",
|
||||
"playwright": "^1.56.1",
|
||||
"prettier": "^3.9.10",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vitest": ">=3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"vitest": {
|
||||
"optional": true
|
||||
}
|
||||
"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": {
|
||||
@@ -492,6 +529,13 @@
|
||||
"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": {
|
||||
"version": "4.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.65.0.tgz",
|
||||
@@ -842,6 +886,47 @@
|
||||
"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": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
@@ -877,6 +962,42 @@
|
||||
"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": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
|
||||
@@ -992,6 +1113,39 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -1067,6 +1221,23 @@
|
||||
"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": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
@@ -1136,6 +1307,16 @@
|
||||
"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": {
|
||||
"version": "4.10.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz",
|
||||
@@ -1206,6 +1387,16 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -1216,6 +1407,16 @@
|
||||
"@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": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -1279,6 +1480,53 @@
|
||||
"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": {
|
||||
"version": "8.5.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
|
||||
@@ -1324,6 +1572,21 @@
|
||||
"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": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
|
||||
@@ -1341,6 +1604,13 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "4.65.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.65.0.tgz",
|
||||
@@ -1394,6 +1664,21 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
@@ -1492,6 +1777,16 @@
|
||||
"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": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -1700,6 +1995,28 @@
|
||||
"engines": {
|
||||
"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
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "potential-storage",
|
||||
"name": "@harvmaster/storage",
|
||||
"version": "0.1.0",
|
||||
"description": "Layered TypeScript storage: KV -> Events -> Documents",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -14,22 +14,30 @@
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://verdaccio.harvmaster.com/"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"prepack": "npm run build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsc --noEmit && vitest run",
|
||||
"test:watch": "vitest",
|
||||
"bench": "vitest bench --run",
|
||||
"test:only": "vitest run",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
"format:check": "prettier --check .",
|
||||
"test:browser": "vitest run --config vitest.browser.config.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.5",
|
||||
"@vitest/browser": "^3.2.7",
|
||||
"fake-indexeddb": "^6.2.6",
|
||||
"fast-check": "^4.10.2",
|
||||
"playwright": "^1.56.1",
|
||||
"prettier": "^3.9.10",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.7"
|
||||
|
||||
@@ -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> {
|
||||
/** 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()
|
||||
|
||||
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>
|
||||
|
||||
child<K extends KeyOf<S>>(key: K): BaseDocumentStorage<ChildOf<S, K>> {
|
||||
const node = Object.create(this) as BaseDocumentStorage<ChildOf<S, K>>
|
||||
node.path = [...this.path, Keys.segment(key)]
|
||||
const node = Object.create(this, { path: { value: [...this.path, Keys.segment(key)], enumerable: true } }) as BaseDocumentStorage<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
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export class DocumentStorageKV<S extends Spec = AnySpec> extends BaseDocumentSto
|
||||
const resolved = change(current)
|
||||
if (!resolved) return current
|
||||
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])
|
||||
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 { DocumentQuery, FindResult } from '../utils/document-query.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 { 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
|
||||
// them with Object.create, so they share this class's prototype).
|
||||
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
|
||||
* current state is kept in a projection store (any `BaseDocumentStorage`), which serves all
|
||||
* reads and queries.
|
||||
* 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 reads
|
||||
* and queries.
|
||||
*
|
||||
* Projections carry a `_version` field (stripped from results) used as `expectedVersion` on the
|
||||
* next append. If a write crashes between appending and updating the projection, the next
|
||||
* write's append conflicts, the projection is rebuilt from the stream, and the write retries.
|
||||
* `_version` is therefore reserved in documents.
|
||||
* A document's state is its events folded in order. Changes are stored as resolved values
|
||||
* (`$inc` becomes `set: { n: 6 }`), so two devices editing the same field concurrently end with
|
||||
* the later event's value; edits to different fields both survive.
|
||||
*
|
||||
* 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> {
|
||||
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(
|
||||
private readonly events: BaseEventStorage,
|
||||
private readonly projections: BaseDocumentStorage,
|
||||
options: EventSourcedDocumentStorageOptions = {},
|
||||
) {
|
||||
super()
|
||||
this.maxRetries = options.maxRetries ?? 5
|
||||
}
|
||||
|
||||
async readMany(paths: (readonly string[])[]): Promise<(Doc | undefined)[]> {
|
||||
return (await this.projections.readMany(paths)).map((doc) => EventSourcedDocumentStorage.strip(doc))
|
||||
readMany(paths: (readonly string[])[]): Promise<(Doc | undefined)[]> {
|
||||
return this.projections.readMany(paths)
|
||||
}
|
||||
|
||||
async *query(parent: readonly string[], query: DocumentQuery): AsyncGenerator<FindResult> {
|
||||
for await (const { key, data } of this.projections.query(parent, query)) yield { key, data: EventSourcedDocumentStorage.strip(data)! }
|
||||
query(parent: readonly string[], query: DocumentQuery): AsyncIterable<FindResult> {
|
||||
return this.projections.query(parent, query)
|
||||
}
|
||||
|
||||
mutate(path: readonly string[], change: (current: Doc | undefined) => DocChange | null): Promise<Doc | undefined> {
|
||||
const id = JSON.stringify(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
|
||||
async mutate(path: readonly string[], change: (current: Doc | undefined) => DocChange | null): Promise<Doc | undefined> {
|
||||
const [current] = await this.projections.readMany([path])
|
||||
const resolved = change(current)
|
||||
if (!resolved) return current
|
||||
await this.events.appendTo(path, [resolved])
|
||||
// Apply the change itself (not the state computed above), so concurrent writes in this
|
||||
// process each land on the latest projection.
|
||||
return this.projections.mutate(path, () => resolved)
|
||||
}
|
||||
|
||||
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)
|
||||
if (!resolved) return current
|
||||
const next = DocumentUpdate.apply(current, resolved)
|
||||
try {
|
||||
await this.events.appendTo(path, [resolved], version)
|
||||
} catch (error) {
|
||||
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>> {
|
||||
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. */
|
||||
async getAt<K extends KeyOf<S>>(...args: [version: number] | [key: K, version: number]): Promise<ValueAt<S, K> | undefined> {
|
||||
const path = this.pathOf(args.length > 1, args[0])
|
||||
/** State right after event `eventId`: `getAt(eventId)` for this document, `getAt(key, eventId)` for a child. */
|
||||
async getAt<K extends KeyOf<S>>(...args: [eventId: string] | [key: K, eventId: string]): Promise<ValueAt<S, K> | undefined> {
|
||||
const until = args[args.length - 1] as string
|
||||
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)
|
||||
}
|
||||
return state as ValueAt<S, K> | undefined
|
||||
}
|
||||
|
||||
/** Rebuilds the projection of this document (or a child) from its stream. Returns the state. */
|
||||
catchUp(key?: KeyOf<S>): Promise<Doc | undefined> {
|
||||
return this.rebuild(this.pathOf(arguments.length > 0, key))
|
||||
}
|
||||
|
||||
private async rebuild(path: readonly string[]): Promise<Doc | undefined> {
|
||||
/** Rebuilds the projection of this document (or a child) from all its events. Returns the state. */
|
||||
async catchUp(key?: KeyOf<S>): Promise<Doc | undefined> {
|
||||
const path = this.pathOf(arguments.length > 0, key)
|
||||
let state: Doc | undefined
|
||||
let version = 0
|
||||
for await (const event of this.events.readFrom(path, {})) {
|
||||
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
|
||||
for await (const event of this.events.readFrom(path, {})) state = DocumentUpdate.apply(state, event.data as DocChange)
|
||||
await this.projections.mutate(path, () => (state ? { op: 'set', doc: state } : { op: 'delete' }))
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
import { Keys } from '../utils/keys.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 {
|
||||
/** First version to read (inclusive). Default 1. */
|
||||
from?: number
|
||||
/** Last version to read (inclusive). Default: the head. */
|
||||
to?: number
|
||||
/** Start after this event id (exclusive), in the direction of travel. */
|
||||
after?: string
|
||||
limit?: number
|
||||
reverse?: boolean
|
||||
}
|
||||
|
||||
export interface StoredEvent<E> {
|
||||
version: number
|
||||
/** Append timestamp (ms since epoch). */
|
||||
/** Unique, time-ordered id (see the implementation for its format). */
|
||||
id: string
|
||||
/** Append time (ms since epoch). */
|
||||
at: number
|
||||
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
|
||||
* 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.
|
||||
*
|
||||
* 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> {
|
||||
/** 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 versionOf(stream: readonly string[]): Promise<number>
|
||||
abstract streamsOf(parent: readonly string[], options: StreamKeysOptions): AsyncIterable<string>
|
||||
|
||||
child<K extends KeyOf<S>>(key: K): BaseEventStorage<ChildOf<S, K>> {
|
||||
const node = Object.create(this) as BaseEventStorage<ChildOf<S, K>>
|
||||
node.path = [...this.path, Keys.segment(key)]
|
||||
const node = Object.create(this, { path: { value: [...this.path, Keys.segment(key)], enumerable: true } }) as BaseEventStorage<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
|
||||
}
|
||||
|
||||
@@ -56,15 +53,12 @@ export abstract class BaseEventStorage<S extends Spec = AnySpec> {
|
||||
return this as unknown as BaseEventStorage<S2>
|
||||
}
|
||||
|
||||
/** `append(events, options?)` on this stream; `append(key, events, options?)` on a child stream. */
|
||||
append<K extends KeyOf<S>>(
|
||||
...args: [events: ValueOf<S>[], options?: AppendOptions] | [key: K, events: ValueOf<ChildOf<S, K>>[], options?: AppendOptions]
|
||||
): Promise<AppendResult> {
|
||||
/** `append(events)` to this stream; `append(key, events)` to a child stream. Returns the event ids. */
|
||||
append<K extends KeyOf<S>>(...args: [events: ValueOf<S>[]] | [key: K, events: ValueOf<ChildOf<S, K>>[]]): Promise<string[]> {
|
||||
const hasKey = !Array.isArray(args[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'))
|
||||
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. */
|
||||
@@ -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>>>
|
||||
}
|
||||
|
||||
/** 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(options: StreamKeysOptions = {}): AsyncIterable<KeyString<S>> {
|
||||
return this.streamsOf(this.path, options) as AsyncIterable<KeyString<S>>
|
||||
|
||||
@@ -1,151 +1,86 @@
|
||||
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 type { AnySpec, Spec } from '../utils/schema.js'
|
||||
import { BaseEventStorage, type AppendResult, 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
|
||||
}
|
||||
import type { AnySpec, ChildOf, KeyOf, Spec } from '../utils/schema.js'
|
||||
import { BaseEventStorage, type ReadOptions, type StoredEvent, type StreamKeysOptions } from './base-event-storage.js'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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:
|
||||
* `<stream>/~e/<commit:12>` → `{ firstVersion, at, events }`. Commits are claimed with
|
||||
* `writeIfAbsent`, so a writer with a stale view always collides with the existing key.
|
||||
* Event storage on any KV storage. Each event is one plain write:
|
||||
* `<stream>/~e/<time:15>_<hash>` → the event, where `hash` is the SHA-256 (hex) of the time and
|
||||
* 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> {
|
||||
private readonly heads = new Map<string, Head>()
|
||||
private readonly maxRetries: number
|
||||
private readonly cacheSize: number
|
||||
private readonly now: () => number
|
||||
/** Mutable state lives here: child nodes share this object (see `child()`). */
|
||||
private readonly state = { lastTime: 0 }
|
||||
|
||||
constructor(
|
||||
private readonly kv: BaseKVStorage,
|
||||
options: EventStorageKVOptions = {},
|
||||
) {
|
||||
super()
|
||||
this.maxRetries = options.maxRetries ?? 5
|
||||
this.cacheSize = options.cacheSize ?? 10_000
|
||||
this.now = options.now ?? Date.now
|
||||
}
|
||||
|
||||
async appendTo(stream: readonly string[], events: unknown[], expectedVersion: number | 'any'): Promise<AppendResult> {
|
||||
const base = this.commitPrefix(stream)
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
let head = this.heads.get(base) ?? (await this.loadHead(base))
|
||||
// The cache may be stale; only trust a mismatch after re-reading.
|
||||
if (expectedVersion !== 'any' && head.version !== expectedVersion) head = await this.loadHead(base)
|
||||
if (expectedVersion !== 'any' && head.version !== expectedVersion) throw new ConcurrencyError(base, expectedVersion, head.version)
|
||||
if (events.length === 0) return { version: head.version }
|
||||
|
||||
const commit: Commit = { firstVersion: head.version + 1, at: this.now(), events }
|
||||
const [written] = await this.kv.writeIfAbsent([[base + EventStorageKV.pad(head.commit + 1), commit]])
|
||||
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 appendTo(stream: readonly string[], events: unknown[]): Promise<string[]> {
|
||||
const base = this.eventsPrefix(stream)
|
||||
const entries = await Promise.all(
|
||||
events.map(async (data) => {
|
||||
this.state.lastTime = Math.max(this.now(), this.state.lastTime + 1)
|
||||
const id = await EventStorageKV.id(this.state.lastTime, data)
|
||||
return { key: base + id, value: data }
|
||||
}),
|
||||
)
|
||||
await this.kv.writeMany(entries)
|
||||
return entries.map(({ key }) => key.slice(base.length))
|
||||
}
|
||||
|
||||
async *readFrom(stream: readonly string[], options: ReadOptions): AsyncGenerator<StoredEvent<unknown>> {
|
||||
const from = Math.max(1, options.from ?? 1)
|
||||
const to = options.to ?? Infinity
|
||||
let remaining = options.limit ?? Infinity
|
||||
if (to < from || remaining <= 0) return
|
||||
const base = this.commitPrefix(stream)
|
||||
const start = from === 1 ? 1 : await this.findCommit(base, from)
|
||||
if (start === 0) return
|
||||
for await (const [, value] of this.kv.scan({ gte: base + EventStorageKV.pad(start), lt: this.commitEnd(base), values: true })) {
|
||||
const commit = value as Commit
|
||||
for (let i = 0; i < commit.events.length; i++) {
|
||||
const version = commit.firstVersion + i
|
||||
if (version < from) continue
|
||||
if (version > to || remaining-- <= 0) return
|
||||
yield { version, at: commit.at, data: commit.events[i] }
|
||||
}
|
||||
const base = this.eventsPrefix(stream)
|
||||
const { after, reverse = false, limit } = options
|
||||
// `after` is exclusive: start at it and skip it.
|
||||
const bounds = reverse
|
||||
? { gte: base, lt: after === undefined ? base.slice(0, -1) + Keys.afterSeparator : base + after }
|
||||
: { gte: base + (after ?? ''), lt: base.slice(0, -1) + Keys.afterSeparator }
|
||||
let remaining = limit ?? Infinity
|
||||
for await (const [key, data] of this.kv.scan({ ...bounds, reverse, values: true })) {
|
||||
if (remaining <= 0) return
|
||||
const id = key.slice(base.length)
|
||||
if (id === after) continue
|
||||
remaining--
|
||||
yield { id, at: Number(id.slice(0, 15)), data }
|
||||
}
|
||||
}
|
||||
|
||||
async versionOf(stream: readonly string[]): Promise<number> {
|
||||
return (await this.loadHead(this.commitPrefix(stream))).version
|
||||
}
|
||||
|
||||
async *streamsOf(parent: readonly string[], options: StreamKeysOptions): AsyncGenerator<string> {
|
||||
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. */
|
||||
private async loadHead(base: string): Promise<Head> {
|
||||
for await (const [key, value] of this.kv.scan({ gte: base, lt: this.commitEnd(base), reverse: true, limit: 1, values: true })) {
|
||||
const commit = value as Commit
|
||||
const head = { commit: Number(key.slice(base.length)), version: commit.firstVersion + commit.events.length - 1 }
|
||||
this.cacheHead(base, head)
|
||||
return head
|
||||
}
|
||||
return { commit: 0, version: 0 }
|
||||
/** `<time:15>_<sha256(time + canonical data)>`. */
|
||||
static async id(time: number, data: unknown): Promise<string> {
|
||||
const stamp = String(time).padStart(15, '0')
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(stamp + ExtendedJson.canonical(data)))
|
||||
return `${stamp}_${Hex.encode(new Uint8Array(digest))}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit containing `version`, or 0 if the stream is shorter. Commit `n` starts at a version
|
||||
* >= 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')
|
||||
private eventsPrefix(stream: readonly string[]): string {
|
||||
return `${this.kv.prefix}${Keys.join(stream)}/~e/`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-http.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/event-storage-kv.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-update.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/keys.js'
|
||||
export * from './utils/schema.js'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EventEmitter } from '../utils/event-emitter.js'
|
||||
import { Keys } from '../utils/keys.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
|
||||
}
|
||||
|
||||
/** 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;
|
||||
* everything else (navigation, self/child reads and writes, child listing) is defined here once.
|
||||
* Ordered key-value storage. Implementations provide three primitives over absolute keys:
|
||||
* `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.
|
||||
* 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".
|
||||
*/
|
||||
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). */
|
||||
path: readonly string[] = []
|
||||
readonly path: readonly string[] = []
|
||||
/** This node's own key (`''` for the root). */
|
||||
key = ''
|
||||
readonly key: string = ''
|
||||
/** Prefix of every descendant key (`''` for the root). */
|
||||
prefix = ''
|
||||
readonly prefix: string = ''
|
||||
|
||||
/** Values for absolute keys, in order; `undefined` for missing. */
|
||||
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`). */
|
||||
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
|
||||
|
||||
/** A view of this storage rooted at `key`. Synchronous; no I/O. */
|
||||
child<K extends KeyOf<S>>(key: K): BaseKVStorage<ChildOf<S, K>> {
|
||||
const node = Object.create(this) as BaseKVStorage<ChildOf<S, K>>
|
||||
node.path = [...this.path, Keys.segment(key)]
|
||||
node.key = this.prefix + Keys.escape(Keys.segment(key))
|
||||
node.prefix = node.key + Keys.separator
|
||||
const own = this.prefix + Keys.escape(Keys.segment(key))
|
||||
const node = Object.create(this, {
|
||||
path: { value: [...this.path, Keys.segment(key)], enumerable: true },
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
const value = args[args.length - 1]
|
||||
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> {
|
||||
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. */
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)`
|
||||
* returns a `Response`. All routes are `POST /<primitive>` with a JSON body; `scan` streams NDJSON.
|
||||
* The raw primitives use absolute keys, so this exposes the whole store behind `storage`.
|
||||
* returns a `Response`. It executes exactly the operations clients send; it doesn't know about
|
||||
* 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 {
|
||||
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(
|
||||
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> {
|
||||
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
|
||||
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')
|
||||
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)) {
|
||||
case '/readMany': {
|
||||
this.authorize(owner, body.keys)
|
||||
const values = await this.storage.readMany(body.keys)
|
||||
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':
|
||||
await this.storage.writeMany(body.entries)
|
||||
case '/write':
|
||||
this.authorize(
|
||||
owner,
|
||||
body.changes.map(({ key }) => key),
|
||||
)
|
||||
await this.storage.writeMany(body.changes)
|
||||
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':
|
||||
if (owner !== undefined && !new KeyRange(owner).covers(body.gte, body.lt)) throw new AuthError(403, 'Scan outside your keys')
|
||||
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:
|
||||
return KVStorageHttpServer.error(404, 'Not found')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) return KVStorageHttpServer.error(error.status, error.message)
|
||||
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 {
|
||||
const iterator = this.storage.scan(options)[Symbol.asyncIterator]()
|
||||
const encoder = new TextEncoder()
|
||||
@@ -53,7 +159,7 @@ export class KVStorageHttpServer {
|
||||
if (chunk) controller.enqueue(encoder.encode(chunk))
|
||||
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))
|
||||
},
|
||||
|
||||
@@ -1,45 +1,61 @@
|
||||
import type { AnySpec, Spec } from '../utils/schema.js'
|
||||
import { BaseKVStorage, type ScanOptions } from './base-kv-storage.js'
|
||||
import { AuthError, StorageUnavailableError } from '../utils/errors.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 {
|
||||
/** Base URL of a `KVStorageHttpServer`, e.g. `https://host/kv`. */
|
||||
url: string
|
||||
/** Signs every request (required by servers with authentication). */
|
||||
signer?: RequestSigner
|
||||
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> {
|
||||
private readonly url: string
|
||||
private readonly signer: RequestSigner | undefined
|
||||
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) {
|
||||
super()
|
||||
this.url = options.url.replace(/\/$/, '')
|
||||
this.signer = options.signer
|
||||
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[]> {
|
||||
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
|
||||
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]> {
|
||||
if (options.limit !== undefined && options.limit <= 0) return
|
||||
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')
|
||||
buffer = lines.pop()!
|
||||
for (const line of lines) {
|
||||
const [key, value] = JSON.parse(line) as [string, unknown?]
|
||||
yield [key, value]
|
||||
const [key, entryValue] = ExtendedJson.parse(line) as [string, unknown?]
|
||||
yield [key, entryValue]
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
return response.status === 204 ? undefined : response.json()
|
||||
/**
|
||||
* Relays changes to `path` (default: this node's key) and everything under it. Resolves once
|
||||
* 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> {
|
||||
const response = await this.fetcher(`${this.url}/${path}`, {
|
||||
/** Stops relaying changes to `path` (default: this node's key). */
|
||||
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',
|
||||
body,
|
||||
fetch: this.fetcher,
|
||||
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()}`)
|
||||
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))
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Json } from '../utils/json.js'
|
||||
import { SortedMap } from '../utils/sorted-map.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. */
|
||||
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)))
|
||||
}
|
||||
|
||||
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]> {
|
||||
let remaining = options.limit ?? Infinity
|
||||
if (remaining <= 0) return
|
||||
@@ -31,4 +19,11 @@ export class KVStorageInMemory<S extends Spec = AnySpec> extends BaseKVStorage<S
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
export class InvalidKeyError extends TypeError {
|
||||
constructor(message: string) {
|
||||
@@ -17,3 +5,22 @@ export class InvalidKeyError extends TypeError {
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
/** Deep copy; much cheaper than `structuredClone` for plain JSON. */
|
||||
/** Deep copy; much cheaper than `structuredClone` for plain data. */
|
||||
static clone<T>(value: T): T {
|
||||
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
|
||||
const out: Record<string, unknown> = {}
|
||||
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 {
|
||||
if (a === b) return true
|
||||
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
|
||||
const ka = Object.keys(a)
|
||||
const kb = Object.keys(b)
|
||||
@@ -19,6 +25,6 @@ export class Json {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}`))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -42,43 +42,48 @@ describe('EventSourcedDocumentStorage', () => {
|
||||
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')
|
||||
await users.set('1', { n: 5 })
|
||||
await users.update('1', { $inc: { n: 1 } })
|
||||
await users.delete('1')
|
||||
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: 'patch', set: { n: 6 }, unset: [] },
|
||||
{ op: 'delete' },
|
||||
{ op: 'set', doc: { n: 10 } },
|
||||
])
|
||||
expect(await users.getAt('1', 2)).toEqual({ n: 6 })
|
||||
expect(await users.child('1').getAt(3)).toBeUndefined()
|
||||
expect(await users.getAt('1', history[1]!.id)).toEqual({ n: 6 })
|
||||
expect(await users.child('1').getAt(history[2]!.id)).toBeUndefined()
|
||||
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 users = docs.child('users')
|
||||
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.update('1', { $inc: { n: 1 } })).toEqual({ n: 3 })
|
||||
await events.child('users').append('2', [{ op: 'set', doc: { n: 7 } }]) // first write crashed
|
||||
expect(await users.catchUp('2')).toEqual({ n: 7 })
|
||||
expect(await users.get('2')).toEqual({ n: 7 })
|
||||
expect(await users.catchUp('1')).toEqual({ n: 1, m: 2 })
|
||||
expect(await users.get('1')).toEqual({ n: 1, m: 2 })
|
||||
await events.child('users').append('2', [{ op: 'set', doc: { n: 7 } }, { op: 'delete' }])
|
||||
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 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 b = make()
|
||||
await a.set('1', { n: 0 })
|
||||
await Promise.all([...Array(5)].flatMap(() => [a.update('1', { $inc: { n: 1 } }), b.update('1', { $inc: { n: 1 } })]))
|
||||
expect(await a.get('1')).toEqual({ n: 10 })
|
||||
await a.set('1', { name: 'x', age: 1 })
|
||||
await Promise.all([a.update('1', { $set: { name: 'Ada' } }), b.update('1', { $set: { age: 2 } })])
|
||||
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 () => {
|
||||
@@ -86,6 +91,6 @@ describe('EventSourcedDocumentStorage', () => {
|
||||
await docs.child('users').set('1', { n: 1 })
|
||||
kv.reset()
|
||||
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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,45 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fc from 'fast-check'
|
||||
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 { ConcurrencyError } from '../../src/utils/errors.js'
|
||||
import { collect, KVStorageCounting } from '../helpers.js'
|
||||
|
||||
function setup(kv = new KVStorageCounting()) {
|
||||
let t = 1000
|
||||
function setup(kv: BaseKVStorage = new KVStorageCounting(), start = 1000) {
|
||||
let t = start
|
||||
return { kv, events: new EventStorageKV(kv, { now: () => t++ }) }
|
||||
}
|
||||
|
||||
describe('EventStorageKV', () => {
|
||||
it('appends and reads in self and child forms', async () => {
|
||||
const orders = setup().events.child('orders')
|
||||
expect(await orders.append('o1', [{ type: 'created' }], { expectedVersion: 0 })).toEqual({ version: 1 })
|
||||
expect(await orders.child('o1').append([{ type: 'paid' }, { type: 'shipped' }])).toEqual({ version: 3 })
|
||||
const [created] = await orders.append('o1', [{ type: 'created' }])
|
||||
const ids = await orders.child('o1').append([{ type: 'paid' }, { type: 'shipped' }])
|
||||
expect(await collect(orders.read('o1'))).toEqual([
|
||||
{ version: 1, at: 1000, data: { type: 'created' } },
|
||||
{ version: 2, at: 1001, data: { type: 'paid' } },
|
||||
{ version: 3, at: 1001, data: { type: 'shipped' } },
|
||||
{ id: created, at: 1000, data: { type: 'created' } },
|
||||
{ id: ids[0], at: 1001, data: { type: 'paid' } },
|
||||
{ id: ids[1], at: 1002, data: { type: 'shipped' } },
|
||||
])
|
||||
expect(await collect(orders.child('o1').read({ from: 3 }))).toHaveLength(1)
|
||||
expect(await orders.version('o1')).toBe(3)
|
||||
expect(await orders.version('none')).toBe(0)
|
||||
expect((await collect(orders.child('o1').read({ after: ids[0] }))).map((e) => e.data)).toEqual([{ type: 'shipped' }])
|
||||
expect((await collect(orders.read('o1', { reverse: true, limit: 2 }))).map((e) => e.id)).toEqual([ids[1], ids[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()
|
||||
await events.child('s').append(['a'])
|
||||
await events.child('s').append(['b', 'c'])
|
||||
expect((await collect(kv.scan())).map(([k]) => k)).toEqual(['s/~e/000000000001', 's/~e/000000000002'])
|
||||
const [id] = await events.child('s').append([{ b: 1, a: 2 }])
|
||||
expect(id).toMatch(/^000000000001000_[0-9a-f]{64}$/)
|
||||
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 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.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 () => {
|
||||
@@ -51,46 +76,26 @@ describe('EventStorageKV', () => {
|
||||
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 () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.array(fc.integer({ min: 1, max: 4 }), { maxLength: 20 }),
|
||||
fc.record({
|
||||
from: fc.option(fc.integer({ min: 0, max: 60 }), { nil: undefined }),
|
||||
to: fc.option(fc.integer({ min: 0, max: 60 }), { nil: undefined }),
|
||||
after: fc.option(fc.nat(60), { 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')
|
||||
let n = 0
|
||||
for (const size of sizes) await s.append(Array.from({ length: size }, () => ++n))
|
||||
let expected = Array.from({ length: n }, (_, i) => i + 1).filter((v) => v >= (options.from ?? 1) && v <= (options.to ?? Infinity))
|
||||
if (options.limit !== undefined) expected = expected.slice(0, options.limit)
|
||||
expect((await collect(s.read(options))).map((e) => e.data)).toEqual(expected)
|
||||
const ids: string[] = []
|
||||
for (const size of sizes) ids.push(...(await s.append(Array.from({ length: size }, () => ids.length))))
|
||||
const all = await collect(s.read())
|
||||
expect(all.map((e) => e.id)).toEqual(ids)
|
||||
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 },
|
||||
|
||||
+5
-10
@@ -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'
|
||||
|
||||
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
|
||||
* (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 {
|
||||
calls: Record<string, number> = {}
|
||||
@@ -25,14 +26,8 @@ export class KVStorageCounting extends BaseKVStorage {
|
||||
readMany(keys: string[]) {
|
||||
return this.count('readMany', () => this.inner.readMany(keys))
|
||||
}
|
||||
writeMany(entries: [string, unknown][]) {
|
||||
return this.count('writeMany', () => this.inner.writeMany(entries))
|
||||
}
|
||||
removeMany(keys: string[]) {
|
||||
return this.count('removeMany', () => this.inner.removeMany(keys))
|
||||
}
|
||||
writeIfAbsent(entries: [string, unknown][]) {
|
||||
return this.count('writeIfAbsent', () => this.inner.writeIfAbsent(entries))
|
||||
protected write(changes: KVChange[]) {
|
||||
return this.count('writeMany', () => this.inner.writeMany(changes))
|
||||
}
|
||||
scan(options?: ScanOptions) {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,7 @@ import { runKVStorageConformance } from './kv-storage.conformance.js'
|
||||
import { serve } from './node-server.js'
|
||||
|
||||
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)) })
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('over a real socket', () => {
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
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}` })
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('over a real socket', () => {
|
||||
|
||||
it('stops a long scan early', async () => {
|
||||
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[] = []
|
||||
for await (const [key] of db.scan()) if (seen.push(key) === 3) break
|
||||
expect(seen).toEqual(['k00000', 'k00001', 'k00002'])
|
||||
@@ -47,8 +47,21 @@ describe('over a real socket', () => {
|
||||
|
||||
describe('server', () => {
|
||||
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/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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fc from 'fast-check'
|
||||
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 { runKVStorageConformance } from './kv-storage.conformance.js'
|
||||
|
||||
@@ -16,14 +16,14 @@ describe('costs', () => {
|
||||
await users.get('1')
|
||||
await users.child('1').get()
|
||||
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 () => {
|
||||
const db = new KVStorageCounting()
|
||||
const writes: [string, number][] = []
|
||||
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++) writes.push([`n/leaf${c}`, c])
|
||||
const writes: KVChange[] = []
|
||||
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({ key: `n/leaf${c}`, value: c })
|
||||
await db.writeMany(writes)
|
||||
db.reset()
|
||||
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 }))
|
||||
@@ -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 { InvalidKeyError } from '../../src/utils/errors.js'
|
||||
import { collect } from '../helpers.js'
|
||||
@@ -10,19 +10,24 @@ export function runKVStorageConformance(name: string, make: () => BaseKVStorage
|
||||
beforeEach(async () => {
|
||||
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)
|
||||
|
||||
describe('primitives', () => {
|
||||
beforeEach(async () => {
|
||||
await db.writeMany([
|
||||
['b', 2],
|
||||
['a/2', { n: 'a2' }],
|
||||
['c', [3]],
|
||||
['a', 1],
|
||||
['a/1', 'a1'],
|
||||
['a0', null],
|
||||
['10', 10],
|
||||
['9', 9],
|
||||
{ key: 'b', value: 2 },
|
||||
{ key: 'a/2', value: { n: 'a2' } },
|
||||
{ key: 'c', value: [3] },
|
||||
{ key: 'a', value: 1 },
|
||||
{ key: 'a/1', value: 'a1' },
|
||||
{ key: 'a0', value: null },
|
||||
{ key: '10', value: 10 },
|
||||
{ key: '9', value: 9 },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -48,16 +53,9 @@ export function runKVStorageConformance(name: string, make: () => BaseKVStorage
|
||||
expect(await db.readMany(['b', 'c'])).toEqual([undefined, [3]])
|
||||
})
|
||||
|
||||
it('writeIfAbsent writes only absent keys, and only one concurrent claim wins', async () => {
|
||||
expect(
|
||||
await db.writeIfAbsent([
|
||||
['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('writes and removes in one batch', async () => {
|
||||
await db.writeMany([{ key: 'b' }, { key: 'new', value: 'y' }, { key: 'a', value: 'x' }])
|
||||
expect(await db.readMany(['a', 'b', 'new'])).toEqual(['x', undefined, 'y'])
|
||||
})
|
||||
|
||||
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'])
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const [value] = (await db.readMany(['a/2'])) as [{ n: string }]
|
||||
value.n = 'changed'
|
||||
@@ -101,6 +117,14 @@ export function runKVStorageConformance(name: string, make: () => BaseKVStorage
|
||||
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 () => {
|
||||
const user = db.child('users').child('1')
|
||||
await user.set('own')
|
||||
@@ -135,7 +159,7 @@ export function runKVStorageConformance(name: string, make: () => BaseKVStorage
|
||||
await n.set('c', 3)
|
||||
await n.child('c').child('x').set('y', 1)
|
||||
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({ reverse: true }))).toEqual(['c', 'b', 'a-1', 'a'])
|
||||
expect(await collect(n.keys({ after: 'a', limit: 2 }))).toEqual(['a-1', 'b'])
|
||||
|
||||
@@ -7,7 +7,11 @@ export async function serve(handle: (request: Request) => Promise<Response>): Pr
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of req) chunks.push(chunk as Buffer)
|
||||
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))
|
||||
if (!response.body) return void res.end()
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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`)
|
||||
})
|
||||
})
|
||||
@@ -84,6 +84,6 @@ describe('event and document types', () => {
|
||||
new DocumentStorageInMemory(),
|
||||
)
|
||||
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>>()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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"/)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -3,7 +3,7 @@
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
@@ -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' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { defineConfig } from 'vitest/config'
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
exclude: ['test/browser/**', 'node_modules/**'],
|
||||
typecheck: { enabled: true, include: ['test/**/*.test-d.ts'] },
|
||||
},
|
||||
bench: { include: ['bench/**/*.bench.ts'] },
|
||||
|
||||
Reference in New Issue
Block a user