Simplified layers, authenticated sync, composite storage and hash-keyed events #2
+16
-1
@@ -32,11 +32,26 @@ jobs:
|
||||
- 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
|
||||
needs: [check, browser]
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -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 22, 24 and 26.
|
||||
CI (`.gitea/workflows/ci.yml`, Gitea Actions) runs the format check, typecheck, tests and build on
|
||||
Node 22, 24 and 26, and the Chromium tests. On pushes it then publishes to the staging registry
|
||||
(`scripts/publish.mjs`: `main` → `latest`, `next` → `next`, others → `dev-<branch>`).
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -23,6 +27,7 @@ CI (`.gitea/workflows/ci.yml`, Gitea Actions) runs the format check, typecheck,
|
||||
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.
|
||||
|
||||
@@ -33,13 +33,16 @@ section 7 lists what changed and why.
|
||||
```
|
||||
src/
|
||||
kv-storage/ base-kv-storage.ts, kv-storage-in-memory.ts, kv-storage-indexeddb.ts,
|
||||
kv-storage-http.ts, kv-storage-http-server.ts, kv-storage-composite.ts
|
||||
kv-storage-http.ts, kv-storage-http-server.ts, kv-storage-composite.ts,
|
||||
kv-storage-encrypted.ts
|
||||
event-storage/ base-event-storage.ts, event-storage-kv.ts
|
||||
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, 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
|
||||
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/
|
||||
```
|
||||
|
||||
@@ -99,7 +102,10 @@ no own value. A node's own value and its children are independent keys.
|
||||
`scan` streams NDJSON. Unreachable server (fetch failure, 502/503/504) →
|
||||
`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.
|
||||
- `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
|
||||
@@ -109,6 +115,8 @@ no own value. A node's own value and its children are independent keys.
|
||||
new list; `subscribe` resolves once connected. A 401/403 rejects instead of retrying.
|
||||
- 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.
|
||||
@@ -159,20 +167,41 @@ is only appropriate when the client may read all of it.
|
||||
- A write the source refuses (e.g. 403) is undone locally and rethrown.
|
||||
- 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 →
|
||||
replay again (writes made meanwhile) → mark `synced`. It runs on start, on `resync` (the stream
|
||||
reconnected), and `retryMs` after a failure while offline.
|
||||
reconnected), and `retryMs` after a failure while offline. Called while a sync is running, it
|
||||
schedules one more after it (shared by all callers meanwhile), so changes after the running
|
||||
sync's scan aren't missed; e.g. the sync after subscribing, which closes the gap between the
|
||||
first scan and the stream connecting.
|
||||
- **Reads** wait for the first sync. Until one has completed, they go to the source. With a
|
||||
persistent queue, a restart reads locally straight away (offline too).
|
||||
- `start()` (also lazy on first use), `sync()`, `queued`, `close()`.
|
||||
- `start()` (also lazy on first use), `sync()`, `queued`, `close()`; `onError` receives errors no
|
||||
caller can be rejected with (refused queued writes, failed syncs). Default `console.error`.
|
||||
- Limitations: catching up re-scans the whole range (no server history); a remote change can
|
||||
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(stream, events)` → ids,
|
||||
@@ -258,6 +287,8 @@ Node API: `get(key?)`, `getMany`, `set(doc)` / `set(key, doc)`, `insert(doc)`
|
||||
re-scan (would need server-side history or a change log in event storage).
|
||||
- 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
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
# @harvmaster/storage
|
||||
|
||||
Layered TypeScript storage that works the same in the browser and on the server:
|
||||
|
||||
- **KV storage**: ordered keys and values, navigated as a tree (`db.child('users').get('1')`).
|
||||
In memory, IndexedDB, or over HTTP, with live updates over Server-Sent Events.
|
||||
- **Event storage**: append-only streams on top of any KV storage.
|
||||
- **Document storage**: JSON documents with queries, updates (`$set`, `$inc`, …) and indexes, on
|
||||
top of any KV storage, or event-sourced.
|
||||
- **Sync**: local replicas that work offline and stay up to date with a server
|
||||
(`KVStorageComposite`), end-to-end encryption (`KVStorageEncrypted`) and signed requests where
|
||||
the user's public key is their id.
|
||||
|
||||
No runtime dependencies. Node 22+ and current browsers. Values are JSON plus `bigint` and
|
||||
`Uint8Array`.
|
||||
|
||||
> **Status: experimental (0.x).** The API may change between minor versions. See
|
||||
> [DESIGN.md](./DESIGN.md) for how everything works and its known limitations.
|
||||
|
||||
## Install
|
||||
|
||||
The package is published to a private registry. Add to your project's `.npmrc`:
|
||||
|
||||
```ini
|
||||
@harvmaster:registry=https://verdaccio.harvmaster.com/
|
||||
```
|
||||
|
||||
```sh
|
||||
npm install @harvmaster/storage # latest release
|
||||
npm install @harvmaster/storage@next # `next` branch
|
||||
npm install @harvmaster/storage@dev-sync # a development branch (`dev-<branch>`)
|
||||
```
|
||||
|
||||
## KV storage
|
||||
|
||||
Every store is a tree of nodes. `child(key)` returns a view rooted deeper (no I/O); methods take
|
||||
an optional key to act on a child instead of the node itself.
|
||||
|
||||
```ts
|
||||
import { KVStorageInMemory } from '@harvmaster/storage'
|
||||
|
||||
const db = new KVStorageInMemory()
|
||||
const users = db.child('users')
|
||||
|
||||
await users.set('1', { name: 'Ada' })
|
||||
await users.child('1').get() // { name: 'Ada' }
|
||||
await users.get('1') // the same
|
||||
await users.setMany([
|
||||
['2', { name: 'Bob' }],
|
||||
['3', { name: 'Cy' }],
|
||||
])
|
||||
await users.getMany(['1', 'missing']) // [{ name: 'Ada' }, undefined]
|
||||
|
||||
for await (const key of users.keys({ limit: 10 })) console.log(key) // '1', '2', '3'
|
||||
for await (const [key, user] of users.entries({ reverse: true })) console.log(key, user)
|
||||
|
||||
await users.delete('3')
|
||||
await users.deleteTree() // users and everything under it
|
||||
```
|
||||
|
||||
A node's own value and its children are independent, so an object can also be stored field per key:
|
||||
|
||||
```ts
|
||||
const settings = db.child('settings')
|
||||
await settings.setFields({ theme: 'dark', fontSize: 14 })
|
||||
await settings.get('theme') // 'dark'
|
||||
await settings.getFields() // { fontSize: 14, theme: 'dark' }
|
||||
```
|
||||
|
||||
Keys sort as strings (`'10'` before `'9'`), and any string or number is a valid key: special
|
||||
characters are escaped. Every write emits a `change` event:
|
||||
|
||||
```ts
|
||||
db.on('change', ({ changes, origin }) => console.log(origin, changes)) // 'local', [{ key: 'users/1', value: … }]
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
Describe the tree once to get typed keys and values:
|
||||
|
||||
```ts
|
||||
import { KVStorageInMemory, type Many, type Fields } from '@harvmaster/storage'
|
||||
|
||||
type User = { name: string }
|
||||
type Schema = { children: { users: Many<User>; settings: { children: Fields<{ theme: string; fontSize: number }> } } }
|
||||
|
||||
const typed = new KVStorageInMemory<Schema>()
|
||||
await typed.child('users').set('1', { name: 'Ada' }) // checked
|
||||
const theme = await typed.child('settings').get('theme') // string | undefined
|
||||
```
|
||||
|
||||
### IndexedDB
|
||||
|
||||
```ts
|
||||
import { KVStorageIndexedDB } from '@harvmaster/storage'
|
||||
|
||||
const local = new KVStorageIndexedDB({ name: 'my-app' })
|
||||
```
|
||||
|
||||
## Event storage
|
||||
|
||||
Append-only streams. Every node is a stream (its own events) and a parent of other streams.
|
||||
Appends never conflict: concurrent writers each add their own events, and every reader sees all of
|
||||
them in the same (time) order.
|
||||
|
||||
```ts
|
||||
import { EventStorageKV, KVStorageInMemory } from '@harvmaster/storage'
|
||||
|
||||
const events = new EventStorageKV(new KVStorageInMemory())
|
||||
const orders = events.child('orders')
|
||||
|
||||
const [id] = await orders.append('o1', [{ type: 'created', total: 30 }])
|
||||
await orders.child('o1').append([{ type: 'paid' }])
|
||||
|
||||
for await (const event of orders.read('o1')) console.log(event.id, event.at, event.data)
|
||||
for await (const event of orders.read('o1', { after: id })) console.log(event.data) // { type: 'paid' }
|
||||
for await (const key of orders.keys()) console.log(key) // 'o1'
|
||||
```
|
||||
|
||||
Event ids are `<time>_<hash of the time and content>`, so writing the same event again (e.g. a
|
||||
retried request) can't duplicate it.
|
||||
|
||||
## Document storage
|
||||
|
||||
```ts
|
||||
import { DocumentStorageKV, KVStorageInMemory } from '@harvmaster/storage'
|
||||
|
||||
const docs = new DocumentStorageKV(new KVStorageInMemory())
|
||||
const people = docs.child('people')
|
||||
|
||||
const id = await people.insert({ name: 'Ada', age: 36, tags: ['math'] })
|
||||
await people.set('grace', { name: 'Grace', age: 45, tags: ['navy', 'cobol'] })
|
||||
await people.update(id, { $inc: { age: 1 }, $push: { tags: 'engines' } })
|
||||
|
||||
for await (const { key, data } of people.find({ age: { $gte: 37 } }, { sort: { age: -1 }, limit: 10 })) console.log(key, data)
|
||||
await people.findOne({ tags: 'cobol' }) // { key: 'grace', data: { … } }
|
||||
await people.delete('grace') // true
|
||||
```
|
||||
|
||||
Filters: `{ field: value }` (dot paths; arrays match any element), `$eq $gt $gte $lt $lte $in`.
|
||||
Updates: `$set $unset $inc $push $pull $addToSet` (`$each` for push/addToSet).
|
||||
|
||||
- `DocumentStorageKV` stores each document as one KV value, so it works over any KV storage
|
||||
(including a synced one, below). Queries scan the collection.
|
||||
- `DocumentStorageInMemory({ indexes: { people: ['age'] } })` keeps equality indexes.
|
||||
- `EventSourcedDocumentStorage(events, projections)` records every change as an event (with
|
||||
`history(key)` and `getAt(key, eventId)`), and keeps the current state in any document store.
|
||||
|
||||
## Sync, offline and encryption
|
||||
|
||||
The pieces below combine into an offline-first app whose server only stores ciphertext.
|
||||
|
||||
### Server
|
||||
|
||||
`KVStorageHttpServer` serves any KV storage over HTTP. `handle(request)` takes a Fetch `Request`
|
||||
and returns a `Response`, so it works with `Deno.serve`, `Bun.serve`, Cloudflare Workers, Hono,
|
||||
and so on (in Node, through an adapter such as `@hono/node-server`).
|
||||
|
||||
```ts
|
||||
import { Ed25519Verifier, KVStorageHttpServer, KVStorageInMemory } from '@harvmaster/storage'
|
||||
|
||||
const server = new KVStorageHttpServer(new KVStorageInMemory(), {
|
||||
// Requests must be signed, and a user may only touch keys under their public key.
|
||||
auth: { verifiers: [new Ed25519Verifier()] },
|
||||
basePath: '/kv',
|
||||
allowOrigins: ['https://app.example.com'], // CORS, if the app is on another origin
|
||||
})
|
||||
|
||||
Deno.serve((request) => server.handle(request))
|
||||
```
|
||||
|
||||
`auth: 'none'` turns authentication off (local development only). The server keeps its data in
|
||||
whichever KV storage it wraps, and executes exactly the writes clients send (last write wins).
|
||||
|
||||
### Client
|
||||
|
||||
```ts
|
||||
import { Ed25519Signer, KVStorageComposite, KVStorageEncrypted, KVStorageHttp, KVStorageInMemory, KVStorageIndexedDB } from '@harvmaster/storage'
|
||||
|
||||
// The user's identity: keep the private key (e.g. in IndexedDB or the OS keychain).
|
||||
const signer = await Ed25519Signer.generate() // or Ed25519Signer.fromPrivateKey(saved)
|
||||
const saved = await signer.exportPrivateKey()
|
||||
|
||||
// The encryption key: generate one and store it like the private key, or derive it from a password
|
||||
// with a random salt created once and stored with the user's data (the salt isn't secret).
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16))
|
||||
const key = await KVStorageEncrypted.deriveKey('correct horse battery staple', salt)
|
||||
|
||||
const indexedDb = new KVStorageIndexedDB({ name: 'my-app' })
|
||||
const db = new KVStorageComposite({
|
||||
source: new KVStorageEncrypted(new KVStorageHttp({ url: 'https://api.example.com/kv', signer }), { key }),
|
||||
replicas: [new KVStorageInMemory(), indexedDb], // fastest first; the last one persists
|
||||
queue: indexedDb.child('~sync'), // offline writes, kept across reloads (outside `prefix`)
|
||||
prefix: signer.publicKey, // only this user's data is synced
|
||||
})
|
||||
|
||||
const me = db.child(signer.publicKey)
|
||||
await me.child('notes').set('1', { text: 'Works offline' }) // local immediately, then sent
|
||||
await me.child('notes').get('1') // read locally
|
||||
|
||||
db.on('change', ({ changes, origin }) => {
|
||||
if (origin === 'remote') render(changes) // made on another device
|
||||
})
|
||||
```
|
||||
|
||||
What the composite does:
|
||||
|
||||
- **Reads** come from the first replica once the first sync has completed; after a reload, straight
|
||||
from IndexedDB, even offline.
|
||||
- **Writes** go to every replica, then to the server. If the server is unreachable, they're queued
|
||||
and replayed in order later; `db.queued` is the number waiting. A write the server refuses (e.g. 403) is undone locally and rejected.
|
||||
- **Changes from other devices** arrive over SSE and are applied, and announced as `change`
|
||||
events, only where they differ from local values.
|
||||
- **`db.sync()`** replays queued writes and catches up with the server. It runs on start, whenever
|
||||
the live connection is restored, and periodically while offline.
|
||||
- `onError` receives errors with no caller to reject, e.g. a queued write the server later refused
|
||||
(it's dropped, and the server's value restored).
|
||||
- `db.close()` stops syncing and closes the live connection.
|
||||
|
||||
Documents and events work on top of the composite like any other KV storage:
|
||||
|
||||
```ts
|
||||
type Todo = { title: string; done: boolean }
|
||||
const todos = new DocumentStorageKV<{ children: { todos: Many<Todo> } }>(me).child('todos')
|
||||
await todos.insert({ title: 'Ship it', done: false })
|
||||
for await (const { data } of todos.find({ done: false })) console.log(data.title)
|
||||
```
|
||||
|
||||
### Encryption
|
||||
|
||||
`KVStorageEncrypted` wraps any KV storage and AES-GCM encrypts each value with a fresh IV; the
|
||||
key a value is stored under is authenticated too, so the server can't move values around.
|
||||
|
||||
- **Keys are not encrypted**: the server needs them for authorization and ordering. Don't put
|
||||
secrets in keys. Value sizes and write times are visible as well.
|
||||
- `generateKey()`, `exportKey(key)` / `importKey(raw)`, or `deriveKey(password, salt)` (PBKDF2;
|
||||
store the random salt alongside the data, it isn't secret).
|
||||
- Losing the key means losing the data. There is no recovery.
|
||||
|
||||
### Signed requests
|
||||
|
||||
Every request is signed with the user's key: `X-Public-Key`, `X-Timestamp`, `X-Signature` and
|
||||
`X-Signature-Algorithm` headers, over `${timestamp}:${path}:${body}`. The server checks the
|
||||
signature and a 60 s timestamp window (with replay protection), then that every key is the public
|
||||
key or under it. Ed25519 is built in (WebCrypto); other algorithms plug in by implementing
|
||||
`RequestSigner` / `SignatureVerifier`.
|
||||
|
||||
### Live updates without the composite
|
||||
|
||||
```ts
|
||||
const http = new KVStorageHttp({ url: 'https://api.example.com/kv', signer })
|
||||
http.on('change', ({ changes }) => console.log(changes))
|
||||
http.on('resync', () => reload()) // reconnected: changes may have been missed
|
||||
await http.child(signer.publicKey).child('notes').subscribe()
|
||||
```
|
||||
|
||||
Each `KVStorageHttp` instance keeps one SSE connection for all its subscriptions. Browsers allow
|
||||
only 6 HTTP/1.1 connections per host, so share one client per server (or serve over HTTP/2).
|
||||
|
||||
## Writing your own storage
|
||||
|
||||
Implement the primitives of the base class for a layer; everything else is inherited. For KV:
|
||||
|
||||
```ts
|
||||
import { BaseKVStorage, type KVChange, type ScanOptions } from '@harvmaster/storage'
|
||||
|
||||
class MyKVStorage extends BaseKVStorage {
|
||||
async readMany(keys: string[]): Promise<unknown[]> {
|
||||
/* values in order, undefined for missing */
|
||||
}
|
||||
async *scan(options?: ScanOptions): AsyncGenerator<[string, unknown]> {
|
||||
/* key-ordered entries within { gte, lt }, honouring limit/reverse/values */
|
||||
}
|
||||
protected async write(changes: KVChange[]): Promise<void> {
|
||||
/* set each key to its value, or delete it when value is undefined */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The repository's conformance suite (`test/kv-storage/kv-storage.conformance.ts`) checks the
|
||||
behaviour every KV storage must have.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
npm test # typecheck + Node tests
|
||||
npm run test:browser # Chromium tests (real IndexedDB, WebCrypto, CORS, SSE)
|
||||
npm run bench
|
||||
npm run format
|
||||
```
|
||||
|
||||
CI runs formatting, typechecking, Node 22/24/26, Chromium and the build on every push, and then
|
||||
publishes: `main` → `latest`, `next` → `next`, other branches → `dev-<branch>`.
|
||||
Generated
+314
@@ -9,8 +9,10 @@
|
||||
"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"
|
||||
@@ -19,6 +21,48 @@
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame/node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
|
||||
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
@@ -485,6 +529,13 @@
|
||||
"node": "^22.20 || ^24.12 || >=25"
|
||||
}
|
||||
},
|
||||
"node_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",
|
||||
@@ -835,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",
|
||||
@@ -870,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",
|
||||
@@ -985,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",
|
||||
@@ -1060,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",
|
||||
@@ -1209,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",
|
||||
@@ -1219,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",
|
||||
@@ -1282,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",
|
||||
@@ -1327,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",
|
||||
@@ -1344,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",
|
||||
@@ -1397,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",
|
||||
@@ -1495,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",
|
||||
@@ -1703,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -29,12 +29,15 @@
|
||||
"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"
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './kv-storage/kv-storage-http.js'
|
||||
export * from './kv-storage/kv-storage-http-server.js'
|
||||
export * from './kv-storage/kv-storage-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'
|
||||
|
||||
@@ -22,6 +22,11 @@ export interface KVStorageCompositeOptions {
|
||||
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`). */
|
||||
@@ -58,11 +63,14 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends 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. */
|
||||
@@ -82,6 +90,7 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
|
||||
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)
|
||||
@@ -95,9 +104,15 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
|
||||
return (this.state.started ??= this.open())
|
||||
}
|
||||
|
||||
/** Replays queued writes, then brings the replicas up to date with the source (starts first if needed). */
|
||||
/**
|
||||
* Replays queued writes, then brings the replicas up to date with the source (starts first if
|
||||
* needed). Called while a sync is running, it resolves after one more sync that starts after it.
|
||||
*/
|
||||
sync(): Promise<void> {
|
||||
return (this.state.syncing ??= this.runSync().finally(() => (this.state.syncing = undefined)))
|
||||
// During a sync, schedule one more after it (shared by every caller meanwhile): what changed
|
||||
// since the running one scanned that part of the source must be picked up too.
|
||||
if (this.state.syncing) return (this.state.again ??= this.state.syncing.then(() => ((this.state.again = undefined), this.sync())))
|
||||
return (this.state.syncing = this.runSync().finally(() => (this.state.syncing = undefined)))
|
||||
}
|
||||
|
||||
/** Number of writes waiting for the source. */
|
||||
@@ -158,7 +173,7 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
|
||||
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) => console.error(error))
|
||||
this.state.applying = this.state.applying.then(() => this.applyRemote(changes)).catch((error: Error) => this.onError(error))
|
||||
}),
|
||||
this.source.on('resync', () => void this.sync()),
|
||||
)
|
||||
@@ -166,7 +181,7 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
|
||||
void this.source
|
||||
.subscribe(this.range.prefix)
|
||||
.then(() => this.sync())
|
||||
.catch((error) => console.error(error))
|
||||
.catch((error: Error) => this.onError(error))
|
||||
} else {
|
||||
void this.sync()
|
||||
}
|
||||
@@ -183,7 +198,7 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
|
||||
await this.queue.set('synced', true)
|
||||
this.state.synced = true
|
||||
} catch (error) {
|
||||
if (!(error instanceof StorageUnavailableError)) console.error(error)
|
||||
if (!(error instanceof StorageUnavailableError)) this.onError(error as Error)
|
||||
this.retryLater()
|
||||
}
|
||||
}
|
||||
@@ -195,8 +210,18 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
|
||||
for await (const entry of this.writes.entries({ limit: 1 })) next = entry
|
||||
if (!next) return
|
||||
const changes = next[1] as KVChange[]
|
||||
await this.source.writeMany(changes)
|
||||
await this.apply(changes)
|
||||
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--
|
||||
}
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
import { AuthError } from '../utils/errors.js'
|
||||
import { ExtendedJson } from '../utils/extended-json.js'
|
||||
import { KeyRange } from '../utils/key-range.js'
|
||||
import { RequestAuth, type RequestAuthOptions, type SignatureVerifier } from '../utils/request-auth.js'
|
||||
import { AUTH_HEADERS, RequestAuth, type RequestAuthOptions, type SignatureVerifier } from '../utils/request-auth.js'
|
||||
import type { BaseKVStorage, KVChange, ScanOptions } from './base-kv-storage.js'
|
||||
|
||||
export interface KVStorageHttpServerOptions {
|
||||
@@ -15,6 +15,11 @@ export interface KVStorageHttpServerOptions {
|
||||
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
|
||||
@@ -39,6 +44,7 @@ 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(
|
||||
@@ -48,6 +54,7 @@ export class KVStorageHttpServer {
|
||||
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)))
|
||||
@@ -57,7 +64,24 @@ export class KVStorageHttpServer {
|
||||
}
|
||||
|
||||
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 text = await request.text()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -41,7 +41,7 @@ describe('KVStorageComposite 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).toEqual({})
|
||||
expect(network.store.calls.readMany).toBeUndefined() // (a follow-up sync started on subscribe may still scan)
|
||||
})
|
||||
|
||||
it('applies changes pushed from other clients to every replica', async () => {
|
||||
@@ -226,4 +226,35 @@ describe('KVStorageComposite sync', () => {
|
||||
['c', 3],
|
||||
])
|
||||
})
|
||||
|
||||
it('sync() during a running sync runs one more, so changes after its scan are picked up', async () => {
|
||||
let release!: () => void
|
||||
let reached!: () => void
|
||||
const gate = new Promise<void>((r) => (release = r))
|
||||
const paused = new Promise<void>((r) => (reached = r))
|
||||
const inner = new KVStorageInMemory()
|
||||
await inner.writeMany([{ key: 'a', value: 1 }])
|
||||
const source = new KVStorageCounting(inner)
|
||||
const scan = source.scan.bind(source)
|
||||
let scans = 0
|
||||
source.scan = async function* (options) {
|
||||
const entries = await collect(scan(options)) // a snapshot, like a read-ahead stream
|
||||
if (options?.values && scans++ === 0) (reached(), await gate)
|
||||
yield* entries
|
||||
}
|
||||
const db = new KVStorageComposite({ source, replicas: [new KVStorageInMemory()] })
|
||||
composites.push(db)
|
||||
const first = db.sync()
|
||||
await paused
|
||||
await inner.writeMany([{ key: 'b', value: 2 }]) // after the running sync's snapshot
|
||||
const second = db.sync()
|
||||
expect(db.sync()).toBe(second) // later callers share the pending one
|
||||
release()
|
||||
await first
|
||||
await second
|
||||
expect(await collect(db.scan({ values: true }))).toEqual([
|
||||
['a', 1],
|
||||
['b', 2],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { KVStorageComposite } from '../../src/kv-storage/kv-storage-composite.js'
|
||||
import { KVStorageEncrypted } from '../../src/kv-storage/kv-storage-encrypted.js'
|
||||
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
|
||||
import { collect } from '../helpers.js'
|
||||
import { runKVStorageConformance } from './kv-storage.conformance.js'
|
||||
import { TestNetwork } from './sync-helpers.js'
|
||||
|
||||
const key = await KVStorageEncrypted.generateKey()
|
||||
|
||||
runKVStorageConformance('KVStorageEncrypted (in memory)', () => new KVStorageEncrypted(new KVStorageInMemory(), { key }))
|
||||
|
||||
let network: TestNetwork | undefined
|
||||
afterEach(async () => {
|
||||
await network?.close()
|
||||
network = undefined
|
||||
})
|
||||
|
||||
describe('KVStorageEncrypted', () => {
|
||||
it('stores only ciphertext, with readable keys', async () => {
|
||||
const inner = new KVStorageInMemory()
|
||||
await new KVStorageEncrypted(inner, { key }).child('users').set('1', { name: 'Ada', n: 1n })
|
||||
const [[storedKey, stored]] = (await collect(inner.scan({ values: true }))) as [[string, Uint8Array]]
|
||||
expect(storedKey).toBe('users/1')
|
||||
expect(stored).toBeInstanceOf(Uint8Array)
|
||||
expect(new TextDecoder().decode(stored)).not.toContain('Ada')
|
||||
})
|
||||
|
||||
it('uses a fresh IV for every write', async () => {
|
||||
const inner = new KVStorageInMemory()
|
||||
const db = new KVStorageEncrypted(inner, { key })
|
||||
await db.set('a', 'same')
|
||||
await db.set('b', 'same')
|
||||
const [a, b] = (await inner.readMany(['a', 'b'])) as Uint8Array[]
|
||||
expect(a).not.toEqual(b)
|
||||
})
|
||||
|
||||
it('rejects a wrong key, a value moved to another key, tampering and plaintext', async () => {
|
||||
const inner = new KVStorageInMemory()
|
||||
await new KVStorageEncrypted(inner, { key }).set('a', 'secret')
|
||||
const other = new KVStorageEncrypted(inner, { key: await KVStorageEncrypted.generateKey() })
|
||||
await expect(other.get('a')).rejects.toThrow(/Cannot decrypt/)
|
||||
|
||||
const db = new KVStorageEncrypted(inner, { key })
|
||||
const [stored] = (await inner.readMany(['a'])) as [Uint8Array]
|
||||
await inner.writeMany([{ key: 'b', value: stored }])
|
||||
await expect(db.get('b')).rejects.toThrow(/Cannot decrypt/)
|
||||
|
||||
const tampered = stored.slice()
|
||||
tampered[tampered.length - 1]! ^= 1
|
||||
await inner.writeMany([{ key: 'c', value: tampered }])
|
||||
await expect(db.get('c')).rejects.toThrow(/Cannot decrypt/)
|
||||
|
||||
await inner.writeMany([{ key: 'd', value: 'plain' }])
|
||||
await expect(db.get('d')).rejects.toThrow(/not encrypted/)
|
||||
})
|
||||
|
||||
it('round-trips keys through export/import, and derives the same key from the same password and salt', async () => {
|
||||
const inner = new KVStorageInMemory()
|
||||
await new KVStorageEncrypted(inner, { key }).set('a', 1)
|
||||
const imported = await KVStorageEncrypted.importKey(await KVStorageEncrypted.exportKey(key))
|
||||
expect(await new KVStorageEncrypted(inner, { key: imported }).get('a')).toBe(1)
|
||||
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16))
|
||||
await new KVStorageEncrypted(inner, { key: await KVStorageEncrypted.deriveKey('hunter2', salt, 1000) }).set('p', 'x')
|
||||
expect(await new KVStorageEncrypted(inner, { key: await KVStorageEncrypted.deriveKey('hunter2', salt, 1000) }).get('p')).toBe('x')
|
||||
await expect(new KVStorageEncrypted(inner, { key: await KVStorageEncrypted.deriveKey('wrong', salt, 1000) }).get('p')).rejects.toThrow(/Cannot decrypt/)
|
||||
})
|
||||
|
||||
it('decrypts pushed changes and passes subscriptions through', async () => {
|
||||
network = new TestNetwork()
|
||||
const watcher = new KVStorageEncrypted(network.client(), { key })
|
||||
await watcher.child('notes').subscribe()
|
||||
const pushed = watcher.waitFor('change', (e) => e.origin === 'remote')
|
||||
await new KVStorageEncrypted(network.client(), { key }).child('notes').set('1', { text: 'hi' })
|
||||
expect((await pushed).changes).toEqual([{ key: 'notes/1', value: { text: 'hi' } }])
|
||||
})
|
||||
|
||||
it('works as a composite source: the server stores ciphertext, replicas hold plaintext', async () => {
|
||||
network = new TestNetwork()
|
||||
const replica = new KVStorageInMemory()
|
||||
const a = new KVStorageComposite({ source: new KVStorageEncrypted(network.client(), { key }), replicas: [replica] })
|
||||
const b = new KVStorageComposite({ source: new KVStorageEncrypted(network.client(), { key }), replicas: [new KVStorageInMemory()] })
|
||||
await Promise.all([a.sync(), b.sync()])
|
||||
const seen = b.waitFor('change', (e) => e.origin === 'remote')
|
||||
await a.child('notes').set('1', { text: 'hi' })
|
||||
expect((await seen).changes).toEqual([{ key: 'notes/1', value: { text: 'hi' } }])
|
||||
expect(await replica.readMany(['notes/1'])).toEqual([{ text: 'hi' }])
|
||||
expect((await network.store.readMany(['notes/1']))[0]).toBeInstanceOf(Uint8Array)
|
||||
await Promise.all([a.close(), b.close()])
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@ import { KVStorageHttpServer } from '../../src/kv-storage/kv-storage-http-server
|
||||
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
|
||||
import { AuthError } from '../../src/utils/errors.js'
|
||||
import { Ed25519Signer, Ed25519Verifier } from '../../src/utils/request-auth.js'
|
||||
import { collect } from '../helpers.js'
|
||||
import { collect, KVStorageCounting } from '../helpers.js'
|
||||
import { TestNetwork } from './sync-helpers.js'
|
||||
|
||||
let network: TestNetwork
|
||||
@@ -24,6 +24,11 @@ beforeEach(async () => {
|
||||
})
|
||||
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)
|
||||
@@ -82,13 +87,40 @@ describe('authenticated KVStorageHttpServer', () => {
|
||||
})
|
||||
|
||||
it('a composite undoes a local write the server refuses', async () => {
|
||||
const replica = new KVStorageInMemory()
|
||||
const db = new KVStorageComposite({ source: network.client(alice), prefix: alice.publicKey, replicas: [replica] })
|
||||
await db.sync()
|
||||
// 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')
|
||||
network.server = new KVStorageHttpServer(network.store, { auth: { verifiers: [{ algorithm: 'ed25519', verify: async () => false }] } }) // now rejects every signature
|
||||
await db.sync()
|
||||
refuseEverySignature()
|
||||
await expect(db.child(alice.publicKey).set('x', 'after')).rejects.toMatchObject({ status: 401 })
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,4 +51,17 @@ describe('server', () => {
|
||||
expect((await server.handle(new Request('http://x/nope', { method: 'POST', body: '{}' }))).status).toBe(404)
|
||||
expect((await server.handle(new Request('http://x/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,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,6 +10,11 @@ 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', () => {
|
||||
|
||||
@@ -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,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`)
|
||||
})
|
||||
})
|
||||
@@ -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