From e27541c46a02b3a07b81ea66da5599eab420e88d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 07:29:38 +0000 Subject: [PATCH] Add Prettier formatting and Gitea Actions CI (format, typecheck, test, build) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE --- .gitea/workflows/ci.yml | 30 +++++ .prettierignore | 4 + .prettierrc.json | 6 + CLAUDE.md | 3 + DESIGN.md | 157 ++++++++++++++------------- package-lock.json | 25 +++++ package.json | 5 +- src/core/encoding.ts | 3 +- src/documents/event-sourced.ts | 26 ++++- src/documents/update.ts | 8 +- src/documents/values.ts | 3 +- src/errors.ts | 5 +- src/kv/node.ts | 1 - src/kv/sorted.ts | 1 - src/types/schema.ts | 5 +- test/core/encoding.test.ts | 3 +- test/core/path.test.ts | 11 +- test/documents/event-sourced.test.ts | 62 ++++++----- test/documents/units.test.ts | 8 +- test/helpers.ts | 3 +- test/kv/indexes.test.ts | 17 ++- test/kv/node.test.ts | 98 +++++++++-------- test/kv/sorted.test.ts | 43 ++++---- test/types.test-d.ts | 23 ++-- 24 files changed, 341 insertions(+), 209 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .prettierignore create mode 100644 .prettierrc.json diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..4789154 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + check: + name: Node ${{ matrix.node }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: [20, 22] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - name: Format + run: npm run format:check + - name: Typecheck + run: npm run typecheck + - name: Test + run: npm run test:only + - name: Build + run: npm run build diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..68f9def --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +node_modules +dist +coverage +package-lock.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..282f11e --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "printWidth": 160, + "trailingComma": "all" +} diff --git a/CLAUDE.md b/CLAUDE.md index 0a8cbe4..8553941 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,9 @@ Record new decisions in DESIGN.md section 12. - `npm run typecheck` — `tsc --noEmit` over src, test and bench - `npm run bench` — `vitest bench` - `npm run build` — emit `dist/` +- `npm run format` / `npm run format:check` — Prettier + +CI (`.gitea/workflows/ci.yml`, Gitea Actions) runs format check, typecheck, tests and build on Node 20 and 22. ## Rules diff --git a/DESIGN.md b/DESIGN.md index f4a3aa9..93b0e3a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -18,7 +18,7 @@ implementation decisions made where the brief was silent (section 12) and benchm ## 1. Principles 1. **Three layers**, each usable on its own: **KV** → **Event** (append-only, built on KV) → **Document** (Mongo-like, built on Events or native). -2. **Drivers vs stores.** Backends implement a tiny, flat, string-keyed *driver*. All path, child, index, escaping and typing logic lives once in the user-facing *node* classes, so every backend gets identical behaviour. +2. **Drivers vs stores.** Backends implement a tiny, flat, string-keyed _driver_. All path, child, index, escaping and typing logic lives once in the user-facing _node_ classes, so every backend gets identical behaviour. 3. **One navigation primitive: `child(key)`.** There are no collection/document classes. Every node is a store over its direct children, and `child(key)` re-roots to that key. Navigation is synchronous and does no I/O. 4. **No methods on data.** Reads return plain values. 5. **Performance is a requirement.** Batch operations are the primitive; scans are ordered and streamed; nothing replays or scans more than it must (section 8). @@ -59,24 +59,22 @@ export interface ScanOptions { lt?: string limit?: number reverse?: boolean - values?: boolean // when false/omitted, yield value as undefined + values?: boolean // when false/omitted, yield value as undefined } export interface KVDriver { - getMany(keys: string[]): Promise<(unknown | undefined)[]> // same order as keys + getMany(keys: string[]): Promise<(unknown | undefined)[]> // same order as keys setMany(entries: [key: string, value: unknown][]): Promise deleteMany(keys: string[]): Promise - scan(opts: ScanOptions): AsyncIterable<[key: string, value: unknown]> // MUST be key-ordered + scan(opts: ScanOptions): AsyncIterable<[key: string, value: unknown]> // MUST be key-ordered // Optional capabilities. Stores feature-detect them and fall back where possible. - setIfAbsent?(entries: [string, unknown][]): Promise // true = written - batch?(ops: BatchOp[]): Promise // atomic multi-op write + setIfAbsent?(entries: [string, unknown][]): Promise // true = written + batch?(ops: BatchOp[]): Promise // atomic multi-op write deleteRange?(gte: string, lt: string): Promise } -export type BatchOp = - | { type: 'set'; key: string; value: unknown } - | { type: 'delete'; key: string } +export type BatchOp = { type: 'set'; key: string; value: unknown } | { type: 'delete'; key: string } ``` - Values are passed through as JSON-serialisable values. Drivers that need bytes (HTTP, Redis, files) serialise internally via a pluggable `Codec` (default JSON). @@ -94,29 +92,31 @@ const user = users.child('123') API of every `KVNode` (types in section 7): -| Method | Meaning | -|---|---| -| `child(key)` | Sync. Returns a node at `path + key`. | -| `as()` | Type assertion for untyped use; returns the same node retyped. | -| `get()` / `get(key)` | Own value at `selfKey` / value at child key. | -| `getMany(keys)` | Batch child read, same order, `undefined` for missing. | -| `set(value)` / `set(key, value)` | Write own value / child value. | -| `setMany(entries)` | Batch child write. | -| `delete()` / `delete(key)` | Delete own value / child value only (not descendants). | -| `deleteMany(keys)` | Batch child delete. | -| `deleteTree()` / `deleteTree(key)` | Delete own value **and all descendants** (uses `deleteRange` if available). | -| `keys(opts?)` | `AsyncIterable` of **direct** child keys (unescaped). Options: `limit`, `after`, `reverse`. | -| `entries(opts?)` | `AsyncIterable<[key, value]>` of direct children that have an own value. | -| `tree({ depth })` | Assemble an object from descendant keys up to `depth` (for field-per-key style). Bounded and explicit; never the default. | -| `findBy(index, value \| { gte?, lt? }, opts?)` | Index lookup over direct children (section 3.3). | +| Method | Meaning | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `child(key)` | Sync. Returns a node at `path + key`. | +| `as()` | Type assertion for untyped use; returns the same node retyped. | +| `get()` / `get(key)` | Own value at `selfKey` / value at child key. | +| `getMany(keys)` | Batch child read, same order, `undefined` for missing. | +| `set(value)` / `set(key, value)` | Write own value / child value. | +| `setMany(entries)` | Batch child write. | +| `delete()` / `delete(key)` | Delete own value / child value only (not descendants). | +| `deleteMany(keys)` | Batch child delete. | +| `deleteTree()` / `deleteTree(key)` | Delete own value **and all descendants** (uses `deleteRange` if available). | +| `keys(opts?)` | `AsyncIterable` of **direct** child keys (unescaped). Options: `limit`, `after`, `reverse`. | +| `entries(opts?)` | `AsyncIterable<[key, value]>` of direct children that have an own value. | +| `tree({ depth })` | Assemble an object from descendant keys up to `depth` (for field-per-key style). Bounded and explicit; never the default. | +| `findBy(index, value \| { gte?, lt? }, opts?)` | Index lookup over direct children (section 3.3). | **Arity dispatch (important):** + - Self vs child is decided by `arguments.length`, never by checking for `undefined`. - If a key argument is passed but is `undefined` or `null`, throw a `TypeError` (this prevents `users.get(maybeId)` silently reading self). - The root node (`path = []`) has no own value: `get()`, `set(v)` and `delete()` on root throw. - Implement one private `#resolve(args)` that every method uses. **Direct-children listing (`keys`)** uses a skip-scan: + 1. Scan `gte: prefix`, `lt: prefix + succ(sep)`. 2. For each key, take the first segment after `prefix`. Skip reserved (`~…`) segments. Yield the segment (unescaped) if it differs from the previous one. 3. When the next key is a descendant of the segment just yielded, re-seek the driver to `gte: prefix + escSeg + succ(sep)` (where `succ(c)` is the next char code) instead of iterating through the subtree. Keep using the same iterator while keys are siblings. @@ -130,8 +130,8 @@ Indexes are declared on **path patterns** in the `KVStorage` constructor. A patt ```ts new KVStorage(driver, { indexes: { - 'users': { byEmail: (u: User) => u.email }, - 'users/*/posts': { byDate: (p: Post) => p.createdAt, byTag: (p: Post) => p.tags }, + users: { byEmail: (u: User) => u.email }, + 'users/*/posts': { byDate: (p: Post) => p.createdAt, byTag: (p: Post) => p.tags }, }, }) ``` @@ -155,14 +155,17 @@ new KVStorage(driver, { An append-only log per stream. A stream is identified by a node path. Same `child()` navigation as KV. ```ts -const events = new EventStorage(kv) // takes a KVStorage (or a KVNode as root) +const events = new EventStorage(kv) // takes a KVStorage (or a KVNode as root) const orders = events.child('orders') await orders.append('o1', [{ type: 'created', total: 40 }], { expectedVersion: 0 }) -await orders.child('o1').append([{ type: 'paid' }]) // self form -for await (const e of orders.read('o1', { from: 1 })) { /* { version, commit, at, data } */ } +await orders.child('o1').append([{ type: 'paid' }]) // self form +for await (const e of orders.read('o1', { from: 1 })) { + /* { version, commit, at, data } */ +} await orders.version('o1') -for await (const id of orders.keys()) { } // child streams +for await (const id of orders.keys()) { +} // child streams orders.child('o1').child('shipments') ``` @@ -186,23 +189,25 @@ orders.child('o1').child('shipments') ## 5. Layer 3: Documents (`src/documents/`) `DocumentStorage` is the interface. There are two implementations: + - `EventSourcedDocumentStorage(events, { projections?: KVNode })` is the v1 target. Projections default to the `~p` root on the same KV. - `MongoDocumentStorage(client)` is a later milestone (section 9). ```ts -const docs = new EventSourcedDocumentStorage(events, { indexes: { 'users': [{ fields: ['email'], unique: true }, { fields: ['age'] }] } }) +const docs = new EventSourcedDocumentStorage(events, { indexes: { users: [{ fields: ['email'], unique: true }, { fields: ['age'] }] } }) const users = docs.child('users') -const id = await users.insert({ name: 'Ada', age: 36 }) // generates a time-sortable id +const id = await users.insert({ name: 'Ada', age: 36 }) // generates a time-sortable id await users.set('123', { name: 'Ada', age: 36 }) await users.update('123', { $set: { name: 'Ada L.' }, $inc: { logins: 1 } }) -await users.child('123').update({ $unset: { nickname: true } }) // self form +await users.child('123').update({ $unset: { nickname: true } }) // self form await users.get('123') await users.delete('123') -for await (const { key, data } of users.find({ age: { $gte: 18 } }, { sort: { age: 1 }, limit: 20 })) { } +for await (const { key, data } of users.find({ age: { $gte: 18 } }, { sort: { age: 1 }, limit: 20 })) { +} await users.findOne({ email: 'ada@x.io' }) -await users.history('123') // event-sourced only -await users.getAt('123', 17) // state as of version 17 +await users.history('123') // event-sourced only +await users.getAt('123', 17) // state as of version 17 users.child('123').child('posts') // nested docs, same as KV ``` @@ -214,6 +219,7 @@ users.child('123').child('posts') // nested docs, same as KV - Supported update operators in v1: `$set`, `$unset`, `$inc`, `$push`, `$pull`, `$addToSet`, with dot-path support. **Write path:** + 1. Read the projection. 2. Compute the new state and patch. 3. `append` with `expectedVersion = projection.version`. @@ -223,6 +229,7 @@ users.child('123').child('posts') // nested docs, same as KV **Reads** are a single projection `get`. Never replay on read. **Crash between append and projection write:** + - The projection lags the stream. The next write's `expectedVersion` fails, which triggers catch-up, so the system self-heals. - Provide `get(key, { consistent: true })` that checks the stream head and catches up first. - Provide a `catchUp(node?)` maintenance method to repair a subtree. @@ -257,16 +264,15 @@ Each node is typed by a single **Spec** that describes its own value and its chi ```ts export type Spec = { value?: unknown; children?: Record } -export type AnySpec = { value: unknown; children: Record } // untyped default +export type AnySpec = { value: unknown; children: Record } // untyped default -export type ValueOf = S extends { value: infer V } ? V : never +export type ValueOf = S extends { value: infer V } ? V : never export type ChildrenOf = S extends { children: infer C } ? C : {} -export type KeyOf = Extract, string | number> +export type KeyOf = Extract, string | number> export type ChildOf> = ChildrenOf[K] // Helpers -export type Many = {}> = - { children: { [id: string]: { value: V; children: C } } } +export type Many = {}> = { children: { [id: string]: { value: V; children: C } } } export type Fields = { [K in keyof T & string]: { value: T[K] } } ``` @@ -286,21 +292,24 @@ Example to cover with `expectTypeOf` tests: ```ts type User = { username: string; age: number } type Post = { title: string; createdAt: string } -type Schema = { children: { - users: Many }> - profile: { children: { [id: string]: { children: Fields } } } // field-per-key style -} } +type Schema = { + children: { + users: Many }> + profile: { children: { [id: string]: { children: Fields } } } // field-per-key style + } +} const db = new KVStorage(driver) -db.child('users').get('123') // User | undefined -db.child('users').child('123').get() // User | undefined (same key) -db.child('users').child('123').child('posts') // KVNode> -db.child('users').child('123').child('postz') // ✗ type error -db.child('profile').child('9').get('age') // number | undefined -db.child('profile').child('9').get('agee') // ✗ type error +db.child('users').get('123') // User | undefined +db.child('users').child('123').get() // User | undefined (same key) +db.child('users').child('123').child('posts') // KVNode> +db.child('users').child('123').child('postz') // ✗ type error +db.child('profile').child('9').get('age') // number | undefined +db.child('profile').child('9').get('agee') // ✗ type error ``` Notes: + - **Named levels** check keys at compile time; **dynamic `[id: string]` levels** accept any string. - One node's children can't mix an index signature with named keys (a TS restriction), so schemas alternate between dynamic and named levels. That alternation exists only in types; the runtime API is just `child()`. - `EventStorage` and `DocumentStorage` nodes use the same Spec machinery (the value type is the event type or the document type respectively). @@ -383,7 +392,7 @@ Decisions taken where the brief was silent. Each is the simplest option consiste ### M2 — KV driver - **Conformance suite location**: `src/testing/kv-conformance.ts`, exported as `potential-storage/testing`. - It imports `vitest`, which is an *optional peer dependency*; the core entry point never imports it. + It imports `vitest`, which is an _optional peer dependency_; the core entry point never imports it. - **`InMemoryKVDriver` clones values** on write and read (`structuredClone`, skipped for primitives) so stored state can't be mutated through shared references, matching serialising drivers. Opt out with `{ clone: false }`. @@ -402,7 +411,7 @@ Decisions taken where the brief was silent. Each is the simplest option consiste - **Child order is "subtree order", not plain segment order.** The brief's skip-scan assumes a child's keys are contiguous, but they aren't when a sibling extends a segment with a character that sorts below the separator (for `/`: space and `!"#$%&'()*+,-.`). With children `a` and `a-1` - the key space is `p/a`, `p/a-1`, `p/a-1/x`, `p/a/x`, so `a`'s descendants sort *after* `a-1`, and + the key space is `p/a`, `p/a-1`, `p/a-1/x`, `p/a/x`, so `a`'s descendants sort _after_ `a-1`, and "yield if different from the previous segment" would emit `a` twice. `keys()`/`entries()` therefore order children by **escaped segment followed by the separator** (`a-1/` < `a/`), which is the order of their subtrees in key space. For keys without such characters this is plain lexicographic order. @@ -541,7 +550,7 @@ Decisions taken where the brief was silent. Each is the simplest option consiste field, both for index-ordered and in-memory sorts. - **In-memory sort** keeps a bounded top `skip + limit` buffer when a limit is given. Without a limit, it buffers all matches; this is the one place results are materialised. -- **Full-scan warning**: `console.warn` once per filter *shape* (values replaced by `?`) per storage +- **Full-scan warning**: `console.warn` once per filter _shape_ (values replaced by `?`) per storage instance, only for non-empty filters. On by default unless `NODE_ENV === 'production'`; set it with `warnOnFullScan`. - **Catch-up** re-asserts unique claims for the caught-up state with plain writes, since the log is @@ -609,37 +618,37 @@ container (shared CPU, so expect ±10–20% run-to-run). Mean time per operation **KV** (`bench/kv.bench.ts`): values are small objects. -| Operation | 10k keys | 100k keys | -|---|---|---| -| `get` | 1.2 µs | 1.6 µs | -| `set` (overwrite) | 1.7 µs | 2.0 µs | -| `getMany` × 100 keys | 21 µs | 22 µs | +| Operation | 10k keys | 100k keys | +| -------------------- | -------- | --------- | +| `get` | 1.2 µs | 1.6 µs | +| `set` (overwrite) | 1.7 µs | 2.0 µs | +| `getMany` × 100 keys | 21 µs | 22 µs | **`keys()`** (`bench/keys.bench.ts`): node with 1 000 children, each with 100 descendants (101k keys). -| Operation | Mean | -|---|---| +| Operation | Mean | +| -------------------------------- | ------ | | `keys()` skip-scan (1 000 seeks) | 2.9 ms | -| Reference: full subtree scan | 36 ms | +| Reference: full subtree scan | 36 ms | **Events** (`bench/events.bench.ts`): single-event commits. -| Operation | Mean | -|---|---| -| `append` 1 event (warm head cache) | 6.3 µs | +| Operation | Mean | +| ---------------------------------------------- | ------ | +| `append` 1 event (warm head cache) | 6.3 µs | | `read` 100 events from version 5 000 of 10 000 | 174 µs | -| `read` full 10 000-event stream | 21 ms | -| `version()` (reverse scan, `limit: 1`) | 3.9 µs | +| `read` full 10 000-event stream | 21 ms | +| `version()` (reverse scan, `limit: 1`) | 3.9 µs | **Documents** (`bench/documents.bench.ts`): 10 000 docs, indexes on `age` and unique `email`. -| Operation | Mean | -|---|---| -| `get` | 2.6 µs | -| `update` (`$inc`, indexed node: projection read + append + batch) | 22 µs | -| `findOne` by unique index (`email`) | 16 µs | -| `find` indexed (`age = k`, ~100 hits) | 0.57 ms | -| `find` full scan (`city = k`, ~200 hits of 10k) | 28 ms | +| Operation | Mean | +| ----------------------------------------------------------------- | ------- | +| `get` | 2.6 µs | +| `update` (`$inc`, indexed node: projection read + append + batch) | 22 µs | +| `findOne` by unique index (`email`) | 16 µs | +| `find` indexed (`age = k`, ~100 hits) | 0.57 ms | +| `find` full scan (`city = k`, ~200 hits of 10k) | 28 ms | For comparison, the first run with the sorted-array map and `structuredClone` measured: KV `get` 3.7 µs, `getMany`×100 186 µs, `append` 34 µs, document `update` 110 µs, full scan 57 ms, and diff --git a/package-lock.json b/package-lock.json index 889c03a..1a4a335 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,20 @@ "devDependencies": { "@types/node": "^22.20.5", "fast-check": "^4.10.2", + "prettier": "^3.9.10", "typescript": "^5.9.3", "vitest": "^3.2.7" }, "engines": { "node": ">=20" + }, + "peerDependencies": { + "vitest": ">=3" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } } }, "node_modules/@esbuild/aix-ppc64": { @@ -1299,6 +1308,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prettier": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pure-rand": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", diff --git a/package.json b/package.json index ce27cd5..5046331 100644 --- a/package.json +++ b/package.json @@ -43,11 +43,14 @@ "test": "tsc --noEmit && vitest run", "test:watch": "vitest", "bench": "vitest bench --run", - "test:only": "vitest run" + "test:only": "vitest run", + "format": "prettier --write .", + "format:check": "prettier --check ." }, "devDependencies": { "@types/node": "^22.20.5", "fast-check": "^4.10.2", + "prettier": "^3.9.10", "typescript": "^5.9.3", "vitest": "^3.2.7" }, diff --git a/src/core/encoding.ts b/src/core/encoding.ts index 8a35961..b7642f7 100644 --- a/src/core/encoding.ts +++ b/src/core/encoding.ts @@ -62,8 +62,7 @@ export function isIndexValue(v: unknown): v is IndexValue { function makeCodec(sep: string): IndexValueCodec { const sepCode = sep.charCodeAt(0) - const digits = - sepCode < 0x30 ? '0123456789abcdef' : Array.from({ length: 16 }, (_, i) => String.fromCharCode(sepCode + 1 + i)).join('') + const digits = sepCode < 0x30 ? '0123456789abcdef' : Array.from({ length: 16 }, (_, i) => String.fromCharCode(sepCode + 1 + i)).join('') const digitValue = new Map([...digits].map((d, i) => [d, i])) const TAG_NULL = digits[0]! const TAG_FALSE = digits[1]! diff --git a/src/documents/event-sourced.ts b/src/documents/event-sourced.ts index f9af064..a79f3a8 100644 --- a/src/documents/event-sourced.ts +++ b/src/documents/event-sourced.ts @@ -39,7 +39,8 @@ export interface EventSourcedDocumentStorageOptions { } /** Stored projection of one document. */ -export type Projection = { state: Doc; version: number; commit: number; deleted?: undefined } | { deleted: true; version: number; commit: number; state?: undefined } +export type Projection = + { state: Doc; version: number; commit: number; deleted?: undefined } | { deleted: true; version: number; commit: number; state?: undefined } interface DocContext { readonly driver: KVDriver @@ -120,7 +121,16 @@ export class EventSourcedDocumentNodeImpl implements E child>(key: K): EventSourcedDocumentNodeImpl> { const seg = toSegment(key) const esc = escapeSegment(seg, this.#ctx.sep) - return new EventSourcedDocumentNodeImpl(this.#ctx, this.#ev.child(seg), this.#ev, [...this.path, seg], this.#prefix + esc, esc, this.#ctx.registry.trie.step(this.#state, seg), this.#childIdx) + return new EventSourcedDocumentNodeImpl( + this.#ctx, + this.#ev.child(seg), + this.#ev, + [...this.path, seg], + this.#prefix + esc, + esc, + this.#ctx.registry.trie.step(this.#state, seg), + this.#childIdx, + ) } as(): EventSourcedDocumentNodeImpl { @@ -356,7 +366,9 @@ export class EventSourcedDocumentNodeImpl implements E throw err } const releases = oldClaims ? [...oldClaims.keys()].filter((k) => !newClaims!.has(k)) : [] - const projection: Projection = next ? { state: next, version: result.version, commit: result.commit } : { deleted: true, version: result.version, commit: result.commit } + const projection: Projection = next + ? { state: next, version: result.version, commit: result.commit } + : { deleted: true, version: result.version, commit: result.commit } await this.#writeProjection(t, cur, next, projection, releases) return next } @@ -515,7 +527,13 @@ export class EventSourcedDocumentNodeImpl implements E /** Filter with values replaced by `?`, for de-duplicating warnings. */ function filterShape(f: unknown): unknown { if (Array.isArray(f)) return f.map(filterShape) - if (isPlainObject(f)) return Object.fromEntries(Object.entries(f).map(([k, v]) => [k, k.startsWith('$') && (k === '$and' || k === '$or' || k === '$not') ? filterShape(v) : isPlainObject(v) ? filterShape(v) : '?'])) + if (isPlainObject(f)) + return Object.fromEntries( + Object.entries(f).map(([k, v]) => [ + k, + k.startsWith('$') && (k === '$and' || k === '$or' || k === '$not') ? filterShape(v) : isPlainObject(v) ? filterShape(v) : '?', + ]), + ) return '?' } diff --git a/src/documents/update.ts b/src/documents/update.ts index 6ce09b3..79ea9ab 100644 --- a/src/documents/update.ts +++ b/src/documents/update.ts @@ -12,10 +12,7 @@ export interface UpdateSpec { } /** Stored document events: resolved deltas, never operators, so replay is deterministic. */ -export type DocEvent = - | { op: 'set'; doc: Doc } - | { op: 'patch'; set?: Record; unset?: string[] } - | { op: 'delete' } +export type DocEvent = { op: 'set'; doc: Doc } | { op: 'patch'; set?: Record; unset?: string[] } | { op: 'delete' } export interface Patch { set: Record @@ -44,8 +41,7 @@ export function resolveUpdate(state: Doc, spec: UpdateSpec): Patch { if (!Array.isArray(cur)) throw new TypeError(`${op} on non-array field "${path}"`) return cur } - const eachOf = (v: unknown): unknown[] => - isPlainObject(v) && Object.keys(v).length === 1 && Array.isArray(v.$each) ? (v.$each as unknown[]) : [v] + const eachOf = (v: unknown): unknown[] => (isPlainObject(v) && Object.keys(v).length === 1 && Array.isArray(v.$each) ? (v.$each as unknown[]) : [v]) for (const [path, v] of Object.entries(spec.$set ?? {})) { touch(path) diff --git a/src/documents/values.ts b/src/documents/values.ts index 6f16f1c..4c416b3 100644 --- a/src/documents/values.ts +++ b/src/documents/values.ts @@ -81,7 +81,8 @@ export function unsetPath(doc: Doc, path: string): void { if (typeof v !== 'object' || v === null) return } const last = segs.at(-1)! - if (Array.isArray(v) && /^\d+$/.test(last)) v[Number(last)] = null // Mongo semantics: keep positions + if (Array.isArray(v) && /^\d+$/.test(last)) + v[Number(last)] = null // Mongo semantics: keep positions else if (isPlainObject(v)) delete v[last] } diff --git a/src/errors.ts b/src/errors.ts index 5a8993e..8b3528a 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -30,7 +30,10 @@ export class UniqueConstraintError extends PotentialStorageError { /** The driver lacks an optional capability required by the requested feature. */ export class CapabilityError extends PotentialStorageError { - constructor(readonly capability: string, detail?: string) { + constructor( + readonly capability: string, + detail?: string, + ) { super(`Driver lacks capability "${capability}"${detail ? `: ${detail}` : ''}`) } } diff --git a/src/kv/node.ts b/src/kv/node.ts index 0fd42ef..dea3b6d 100644 --- a/src/kv/node.ts +++ b/src/kv/node.ts @@ -400,4 +400,3 @@ export class KVStorage extends KVNode { super({ driver, sep, registry, readRepair: opts.readRepair ?? true }, [], '', '', registry.trie.rootState, undefined) } } - diff --git a/src/kv/sorted.ts b/src/kv/sorted.ts index 9adca73..53d72d7 100644 --- a/src/kv/sorted.ts +++ b/src/kv/sorted.ts @@ -17,7 +17,6 @@ export interface SortedMap { keys(range: { gte?: string; lt?: string; reverse?: boolean }): IterableIterator } - /** * Sorted chunks of at most `maxChunk` keys (split in half on overflow) plus a `Map` for values. * Inserts and deletes cost O(log n + maxChunk); iteration is O(1) per step while the map diff --git a/src/types/schema.ts b/src/types/schema.ts index 21082bb..80abbd2 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -22,9 +22,8 @@ export type KeyString = `${KeyOf}` export type EntryOf = { [K in KeyOf]: [K, ValueOf>] }[KeyOf] /** `[key, value]` as yielded at runtime (string keys). Dynamic levels collapse to `[string, V]`. */ -export type StringEntryOf = string extends KeyOf - ? [string, ValueOf & string>>] - : { [K in KeyOf]: [`${K}`, ValueOf>] }[KeyOf] +export type StringEntryOf = + string extends KeyOf ? [string, ValueOf & string>>] : { [K in KeyOf]: [`${K}`, ValueOf>] }[KeyOf] /** A dynamic level of children with values `V`, each with children `C`. */ export type Many = {}> = { children: { [id: string]: { value: V; children: C } } } diff --git a/test/core/encoding.test.ts b/test/core/encoding.test.ts index 35ff6d2..409f07f 100644 --- a/test/core/encoding.test.ts +++ b/test/core/encoding.test.ts @@ -32,7 +32,8 @@ describe('encodeIndexValue', () => { fc.assert( fc.property(indexValue, separators, (v, sep) => { const decoded = decodeIndexValue(encodeIndexValue(v, sep), sep) - if (typeof v === 'number' && v === 0) expect(decoded).toBe(0) // -0 normalised + if (typeof v === 'number' && v === 0) + expect(decoded).toBe(0) // -0 normalised else expect(decoded).toEqual(v) }), ) diff --git a/test/core/path.test.ts b/test/core/path.test.ts index f87a9fa..39279d8 100644 --- a/test/core/path.test.ts +++ b/test/core/path.test.ts @@ -1,15 +1,6 @@ import { describe, expect, it } from 'vitest' import fc from 'fast-check' -import { - escapeSegment, - joinPath, - pathKeys, - splitPath, - succ, - toSegment, - unescapeSegment, - validateSeparator, -} from '../../src/core/path.js' +import { escapeSegment, joinPath, pathKeys, splitPath, succ, toSegment, unescapeSegment, validateSeparator } from '../../src/core/path.js' import { InvalidKeyError } from '../../src/errors.js' const separators = fc.constantFrom('/', ':', '.', '|', '#', '\\', '!') diff --git a/test/documents/event-sourced.test.ts b/test/documents/event-sourced.test.ts index 5094d08..293bc1c 100644 --- a/test/documents/event-sourced.test.ts +++ b/test/documents/event-sourced.test.ts @@ -157,7 +157,7 @@ describe.each([ it('evaluates comparison, logical and element operators', async () => { const { users } = await seed() // Unsorted results come in index or key order depending on the plan; compare as sets. - const q = async (f: Filter) => (await keysOf(users.find(f))).sort() + const q = async (f: Filter) => (await keysOf(users.find(f))).sort() expect(await q({})).toEqual(['a', 'b', 'c', 'd', 'e']) expect(await q({ age: 20 })).toEqual(['b', 'd']) expect(await q({ age: { $gte: 20, $lt: 40 } })).toEqual(['a', 'b', 'd']) @@ -186,7 +186,9 @@ describe.each([ expect(await keysOf(users.find({}, { sort: { name: -1 }, skip: 1, limit: 2 }))).toEqual(['d', 'c']) expect(await keysOf(users.find({ age: { $gte: 20 } }, { sort: { age: -1 }, limit: 2 }))).toEqual(['c', 'a']) expect(await keysOf(users.find({ 'address.city': 'London' }, { sort: { age: 1 } }))).toEqual(['e', 'a', 'c']) - expect(await collect(users.find({ age: 36 }, { projection: { name: 1, 'address.city': 1 } }))).toEqual([{ key: 'a', data: { name: 'Ada', address: { city: 'London' } } }]) + expect(await collect(users.find({ age: 36 }, { projection: { name: 1, 'address.city': 1 } }))).toEqual([ + { key: 'a', data: { name: 'Ada', address: { city: 'London' } } }, + ]) expect(await collect(users.find({ age: 36 }, { projection: { tags: 0, address: 0, email: 0 } }))).toEqual([{ key: 'a', data: { name: 'Ada', age: 36 } }]) expect(await users.findOne({ email: 'bob@x' })).toEqual({ key: 'b', data: expect.objectContaining({ name: 'Bob' }) }) expect(await users.findOne({ email: 'nobody' })).toBeUndefined() @@ -278,7 +280,11 @@ describe.each([ it('catchUp() repairs a subtree', async () => { const { users, docs, events } = await lagged() - await events.child('users').child('9').child('posts').append('p', [{ op: 'set', doc: { createdAt: 'x' } }]) + await events + .child('users') + .child('9') + .child('posts') + .append('p', [{ op: 'set', doc: { createdAt: 'x' } }]) expect(await docs.catchUp()).toBe(2) expect(await users.get('1')).toEqual({ n: 2, email: 'b@x' }) expect(await users.child('9').child('posts').get('p')).toEqual({ createdAt: 'x' }) @@ -339,28 +345,34 @@ describe('property: indexed find equals unindexed find', () => { it('returns the same results in the same order', async () => { await fc.assert( - fc.asyncProperty(fc.array(fc.tuple(fc.constantFrom('1', '2', '3', '4', '5'), doc), { maxLength: 12 }), filter, sort, fc.option(fc.integer({ min: 1, max: 4 }), { nil: undefined }), async (writes, f, s, limit) => { - const plain = new EventSourcedDocumentStorage(new KVEventStorage(new KVStorage(new InMemoryKVDriver())), { warnOnFullScan: false }).child('users') - const indexed = new EventSourcedDocumentStorage(new KVEventStorage(new KVStorage(new InMemoryKVDriver())), { - warnOnFullScan: false, - indexes: { users: userIndexes.users!.map((i) => ({ ...i, unique: false })) }, - }).child('users') - for (const [k, v] of writes) { - await plain.set(k, v) - await indexed.set(k, v) - } - const opts = { ...(s ? { sort: s } : {}), ...(limit ? { limit } : {}) } - const a = await collect(plain.find(f, opts)) - const b = await collect(indexed.find(f, opts)) - if (s) { - expect(b).toEqual(a) - return - } - // Unsorted order depends on the plan: compare as sets (a limited result is any subset of that size). - const all = new Set((await collect(plain.find(f))).map((r) => r.key)) - expect(b).toHaveLength(a.length) - for (const r of b) expect(all.has(r.key)).toBe(true) - }), + fc.asyncProperty( + fc.array(fc.tuple(fc.constantFrom('1', '2', '3', '4', '5'), doc), { maxLength: 12 }), + filter, + sort, + fc.option(fc.integer({ min: 1, max: 4 }), { nil: undefined }), + async (writes, f, s, limit) => { + const plain = new EventSourcedDocumentStorage(new KVEventStorage(new KVStorage(new InMemoryKVDriver())), { warnOnFullScan: false }).child('users') + const indexed = new EventSourcedDocumentStorage(new KVEventStorage(new KVStorage(new InMemoryKVDriver())), { + warnOnFullScan: false, + indexes: { users: userIndexes.users!.map((i) => ({ ...i, unique: false })) }, + }).child('users') + for (const [k, v] of writes) { + await plain.set(k, v) + await indexed.set(k, v) + } + const opts = { ...(s ? { sort: s } : {}), ...(limit ? { limit } : {}) } + const a = await collect(plain.find(f, opts)) + const b = await collect(indexed.find(f, opts)) + if (s) { + expect(b).toEqual(a) + return + } + // Unsorted order depends on the plan: compare as sets (a limited result is any subset of that size). + const all = new Set((await collect(plain.find(f))).map((r) => r.key)) + expect(b).toHaveLength(a.length) + for (const r of b) expect(all.has(r.key)).toBe(true) + }, + ), { numRuns: 300 }, ) }) diff --git a/test/documents/units.test.ts b/test/documents/units.test.ts index e206270..16f5018 100644 --- a/test/documents/units.test.ts +++ b/test/documents/units.test.ts @@ -45,7 +45,13 @@ describe('filter semantics', () => { describe('update resolution', () => { it('$pull by sub-document filter and by condition', () => { - const state = { items: [{ k: 1, v: 'a' }, { k: 2, v: 'b' }], n: [1, 5, 9] } + const state = { + items: [ + { k: 1, v: 'a' }, + { k: 2, v: 'b' }, + ], + n: [1, 5, 9], + } const patch = resolveUpdate(state, { $pull: { items: { k: 2 }, n: { $gte: 5 } } }) expect(applyPatch(state, patch)).toEqual({ items: [{ k: 1, v: 'a' }], n: [1] }) }) diff --git a/test/helpers.ts b/test/helpers.ts index bc83631..fbb656f 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -40,8 +40,7 @@ export class CountingDriver implements KVDriver { readonly inner: KVDriver = new InMemoryKVDriver(), hide: Capability[] = [], ) { - if (inner.setIfAbsent && !hide.includes('setIfAbsent')) - this.setIfAbsent = (e: [string, unknown][]) => (this.#count('setIfAbsent'), inner.setIfAbsent!(e)) + if (inner.setIfAbsent && !hide.includes('setIfAbsent')) this.setIfAbsent = (e: [string, unknown][]) => (this.#count('setIfAbsent'), inner.setIfAbsent!(e)) if (inner.batch && !hide.includes('batch')) this.batch = (ops: BatchOp[]) => (this.#count('batch'), inner.batch!(ops)) if (inner.deleteRange && !hide.includes('deleteRange')) this.deleteRange = (gte: string, lt: string) => (this.#count('deleteRange'), inner.deleteRange!(gte, lt)) diff --git a/test/kv/indexes.test.ts b/test/kv/indexes.test.ts index acbc8dd..ee57e37 100644 --- a/test/kv/indexes.test.ts +++ b/test/kv/indexes.test.ts @@ -204,7 +204,10 @@ describe('index declarations', () => { }) describe('property: findBy equals brute-force filter', () => { - const val = fc.record({ age: fc.option(fc.integer({ min: -3, max: 3 }), { nil: undefined }), tags: fc.array(fc.constantFrom('a', 'b', 'c/d'), { maxLength: 3 }) }) + const val = fc.record({ + age: fc.option(fc.integer({ min: -3, max: 3 }), { nil: undefined }), + tags: fc.array(fc.constantFrom('a', 'b', 'c/d'), { maxLength: 3 }), + }) const op = fc.oneof( fc.record({ t: fc.constant('set' as const), k: fc.constantFrom('1', '2', '3', 'x/y'), v: val }), fc.record({ t: fc.constant('del' as const), k: fc.constantFrom('1', '2', '3', 'x/y') }), @@ -226,11 +229,19 @@ describe('property: findBy equals brute-force filter', () => { } } const got = (await collect(users.findBy('byAge', { gte: lo, lt: hi }))).map(([k]) => k).sort() - const exp = [...model].filter(([, v]) => v.age !== undefined && v.age >= lo && v.age < hi).map(([k]) => k).sort() + const exp = [...model] + .filter(([, v]) => v.age !== undefined && v.age >= lo && v.age < hi) + .map(([k]) => k) + .sort() expect(got).toEqual(exp) for (const tag of ['a', 'b', 'c/d']) { const g = (await collect(users.findBy('byTag', tag))).map(([k]) => k).sort() - expect(g).toEqual([...model].filter(([, v]) => v.tags?.includes(tag)).map(([k]) => k).sort()) + expect(g).toEqual( + [...model] + .filter(([, v]) => v.tags?.includes(tag)) + .map(([k]) => k) + .sort(), + ) } // No stale entries are ever left behind by clean writes. let entryCount = 0 diff --git a/test/kv/node.test.ts b/test/kv/node.test.ts index 36232f0..52af85f 100644 --- a/test/kv/node.test.ts +++ b/test/kv/node.test.ts @@ -293,56 +293,60 @@ describe('model-based (random ops vs reference Map)', () => { ] as const) { it(`matches the model (${name})`, async () => { await fc.assert( - fc.asyncProperty(fc.array(opArb, { maxLength: 30 }), fc.array(fc.array(segArb, { maxLength: 2 }), { minLength: 1, maxLength: 3 }), async (ops, probes) => { - const db = new KVStorage(make()) - const model = new Map() - for (const op of ops) { - switch (op.t) { - case 'set': { - if (op.selfForm) await nodeAt(db, op.path).set(op.value) - else await nodeAt(db, op.path.slice(0, -1)).set(op.path.at(-1)!, op.value) - model.set(enc(op.path), op.value) - break - } - case 'delete': { - if (op.selfForm) await nodeAt(db, op.path).delete() - else await nodeAt(db, op.path.slice(0, -1)).delete(op.path.at(-1)!) - model.delete(enc(op.path)) - break - } - case 'deleteTree': { - await nodeAt(db, op.path).deleteTree() - for (const k of [...model.keys()]) if (isPrefix(op.path, JSON.parse(k))) model.delete(k) - break - } - case 'setMany': { - await nodeAt(db, op.parent).setMany(op.entries) - for (const [k, v] of op.entries) model.set(enc([...op.parent, k]), v) - break + fc.asyncProperty( + fc.array(opArb, { maxLength: 30 }), + fc.array(fc.array(segArb, { maxLength: 2 }), { minLength: 1, maxLength: 3 }), + async (ops, probes) => { + const db = new KVStorage(make()) + const model = new Map() + for (const op of ops) { + switch (op.t) { + case 'set': { + if (op.selfForm) await nodeAt(db, op.path).set(op.value) + else await nodeAt(db, op.path.slice(0, -1)).set(op.path.at(-1)!, op.value) + model.set(enc(op.path), op.value) + break + } + case 'delete': { + if (op.selfForm) await nodeAt(db, op.path).delete() + else await nodeAt(db, op.path.slice(0, -1)).delete(op.path.at(-1)!) + model.delete(enc(op.path)) + break + } + case 'deleteTree': { + await nodeAt(db, op.path).deleteTree() + for (const k of [...model.keys()]) if (isPrefix(op.path, JSON.parse(k))) model.delete(k) + break + } + case 'setMany': { + await nodeAt(db, op.parent).setMany(op.entries) + for (const [k, v] of op.entries) model.set(enc([...op.parent, k]), v) + break + } } } - } - for (const [k, v] of model) expect(await nodeAt(db, JSON.parse(k)).get()).toEqual(v) - for (const parent of probes) { - const node = nodeAt(db, parent) - const exp = expectedChildren(model, parent) - const keys = exp.map((e) => e.key) - expect(await collect(node.keys())).toEqual(keys) - expect(await collect(node.keys({ reverse: true }))).toEqual([...keys].reverse()) - expect(await collect(node.entries())).toEqual(exp.filter((e) => e.hasOwn).map((e) => [e.key, e.own])) - expect(await collect(node.entries({ reverse: true }))).toEqual( - exp - .filter((e) => e.hasOwn) - .map((e) => [e.key, e.own]) - .reverse(), - ) - for (let i = 0; i < keys.length; i++) { - expect(await collect(node.keys({ after: keys[i]! }))).toEqual(keys.slice(i + 1)) - expect(await collect(node.keys({ after: keys[i]!, reverse: true }))).toEqual(keys.slice(0, i).reverse()) - expect(await collect(node.keys({ limit: i + 1 }))).toEqual(keys.slice(0, i + 1)) + for (const [k, v] of model) expect(await nodeAt(db, JSON.parse(k)).get()).toEqual(v) + for (const parent of probes) { + const node = nodeAt(db, parent) + const exp = expectedChildren(model, parent) + const keys = exp.map((e) => e.key) + expect(await collect(node.keys())).toEqual(keys) + expect(await collect(node.keys({ reverse: true }))).toEqual([...keys].reverse()) + expect(await collect(node.entries())).toEqual(exp.filter((e) => e.hasOwn).map((e) => [e.key, e.own])) + expect(await collect(node.entries({ reverse: true }))).toEqual( + exp + .filter((e) => e.hasOwn) + .map((e) => [e.key, e.own]) + .reverse(), + ) + for (let i = 0; i < keys.length; i++) { + expect(await collect(node.keys({ after: keys[i]! }))).toEqual(keys.slice(i + 1)) + expect(await collect(node.keys({ after: keys[i]!, reverse: true }))).toEqual(keys.slice(0, i).reverse()) + expect(await collect(node.keys({ limit: i + 1 }))).toEqual(keys.slice(0, i + 1)) + } } - } - }), + }, + ), { numRuns: 300 }, ) }, 60_000) diff --git a/test/kv/sorted.test.ts b/test/kv/sorted.test.ts index e7bb477..7ded610 100644 --- a/test/kv/sorted.test.ts +++ b/test/kv/sorted.test.ts @@ -36,26 +36,31 @@ describe('ChunkedSortedMap (model-based, tiny chunks)', () => { it('iterators resume correctly after mutations between steps', () => { fc.assert( - fc.property(fc.uniqueArray(key, { minLength: 1, maxLength: 60 }), fc.array(fc.tuple(fc.boolean(), key), { maxLength: 40 }), fc.boolean(), (initial, muts, reverse) => { - const m = new ChunkedSortedMap(3) - initial.forEach((k) => m.set(k, 0)) - const live = new Set(initial) - const seen: string[] = [] - let step = 0 - for (const k of m.keys({ reverse })) { - seen.push(k) - const mut = muts[step++] - if (mut) { - const [add, mk] = mut - if (add) (m.set(mk, 1), live.add(mk)) - else (m.delete(mk), live.delete(mk)) + fc.property( + fc.uniqueArray(key, { minLength: 1, maxLength: 60 }), + fc.array(fc.tuple(fc.boolean(), key), { maxLength: 40 }), + fc.boolean(), + (initial, muts, reverse) => { + const m = new ChunkedSortedMap(3) + initial.forEach((k) => m.set(k, 0)) + const live = new Set(initial) + const seen: string[] = [] + let step = 0 + for (const k of m.keys({ reverse })) { + seen.push(k) + const mut = muts[step++] + if (mut) { + const [add, mk] = mut + if (add) (m.set(mk, 1), live.add(mk)) + else (m.delete(mk), live.delete(mk)) + } } - } - // Strictly monotone, and every key present throughout the iteration was visited. - for (let i = 1; i < seen.length; i++) expect(reverse ? seen[i]! < seen[i - 1]! : seen[i]! > seen[i - 1]!).toBe(true) - const stable = initial.filter((k) => !muts.some(([, mk]) => mk === k)) - for (const k of stable) expect(seen).toContain(k) - }), + // Strictly monotone, and every key present throughout the iteration was visited. + for (let i = 1; i < seen.length; i++) expect(reverse ? seen[i]! < seen[i - 1]! : seen[i]! > seen[i - 1]!).toBe(true) + const stable = initial.filter((k) => !muts.some(([, mk]) => mk === k)) + for (const k of stable) expect(seen).toContain(k) + }, + ), { numRuns: 500 }, ) }) diff --git a/test/types.test-d.ts b/test/types.test-d.ts index a8b64e1..35fcfaa 100644 --- a/test/types.test-d.ts +++ b/test/types.test-d.ts @@ -47,12 +47,18 @@ describe('KV types (section 7)', () => { db.child('users').child('1').set({ username: 'a', age: 1 }) // @ts-expect-error wrong value type db.child('users').set('1', { username: 'a' }) - db.child('profile').child('9').setMany([ - ['age', 3], - ['username', 'x'], - ]) - // @ts-expect-error value doesn't match its key - db.child('profile').child('9').setMany([['age', 'x']]) + db.child('profile') + .child('9') + .setMany([ + ['age', 3], + ['username', 'x'], + ]) + db.child('profile') + .child('9') + .setMany([ + // @ts-expect-error value doesn't match its key + ['age', 'x'], + ]) }) it('types listings', () => { @@ -82,7 +88,10 @@ describe('event types', () => { orders.child('o1').append([{ type: 'paid' }]) // @ts-expect-error not an OrderEvent orders.append('o1', [{ type: 'refunded' }]) - orders.child('o1').child('shipments').append('s1', [{ carrier: 'x' }]) + orders + .child('o1') + .child('shipments') + .append('s1', [{ carrier: 'x' }]) }) it('types read results', () => {