Add Prettier formatting and Gitea Actions CI (format, typecheck, test, build)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
package-lock.json
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 160,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<void>
|
||||
deleteMany(keys: string[]): Promise<void>
|
||||
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<boolean[]> // true = written
|
||||
batch?(ops: BatchOp[]): Promise<void> // atomic multi-op write
|
||||
setIfAbsent?(entries: [string, unknown][]): Promise<boolean[]> // true = written
|
||||
batch?(ops: BatchOp[]): Promise<void> // atomic multi-op write
|
||||
deleteRange?(gte: string, lt: string): Promise<void>
|
||||
}
|
||||
|
||||
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<S>()` | 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<string>` 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<S>()` | 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<string>` 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<string, Spec> }
|
||||
export type AnySpec = { value: unknown; children: Record<string, AnySpec> } // untyped default
|
||||
export type AnySpec = { value: unknown; children: Record<string, AnySpec> } // untyped default
|
||||
|
||||
export type ValueOf<S> = S extends { value: infer V } ? V : never
|
||||
export type ValueOf<S> = S extends { value: infer V } ? V : never
|
||||
export type ChildrenOf<S> = S extends { children: infer C } ? C : {}
|
||||
export type KeyOf<S> = Extract<keyof ChildrenOf<S>, string | number>
|
||||
export type KeyOf<S> = Extract<keyof ChildrenOf<S>, string | number>
|
||||
export type ChildOf<S, K extends KeyOf<S>> = ChildrenOf<S>[K]
|
||||
|
||||
// Helpers
|
||||
export type Many<V, C extends Record<string, Spec> = {}> =
|
||||
{ children: { [id: string]: { value: V; children: C } } }
|
||||
export type Many<V, C extends Record<string, Spec> = {}> = { children: { [id: string]: { value: V; children: C } } }
|
||||
export type Fields<T> = { [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<User, { posts: Many<Post> }>
|
||||
profile: { children: { [id: string]: { children: Fields<User> } } } // field-per-key style
|
||||
} }
|
||||
type Schema = {
|
||||
children: {
|
||||
users: Many<User, { posts: Many<Post> }>
|
||||
profile: { children: { [id: string]: { children: Fields<User> } } } // field-per-key style
|
||||
}
|
||||
}
|
||||
|
||||
const db = new KVStorage<Schema>(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<Many<Post>>
|
||||
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<Many<Post>>
|
||||
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
|
||||
|
||||
Generated
+25
@@ -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",
|
||||
|
||||
+4
-1
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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<string, number>([...digits].map((d, i) => [d, i]))
|
||||
const TAG_NULL = digits[0]!
|
||||
const TAG_FALSE = digits[1]!
|
||||
|
||||
@@ -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<S extends Spec = AnySpec> implements E
|
||||
child<K extends KeyOf<S>>(key: K): EventSourcedDocumentNodeImpl<ChildOf<S, K>> {
|
||||
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<S2 extends Spec>(): EventSourcedDocumentNodeImpl<S2> {
|
||||
@@ -356,7 +366,9 @@ export class EventSourcedDocumentNodeImpl<S extends Spec = AnySpec> 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<S extends Spec = AnySpec> 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 '?'
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>; unset?: string[] }
|
||||
| { op: 'delete' }
|
||||
export type DocEvent = { op: 'set'; doc: Doc } | { op: 'patch'; set?: Record<string, unknown>; unset?: string[] } | { op: 'delete' }
|
||||
|
||||
export interface Patch {
|
||||
set: Record<string, unknown>
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -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}` : ''}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,4 +400,3 @@ export class KVStorage<S extends Spec = AnySpec> extends KVNode<S> {
|
||||
super({ driver, sep, registry, readRepair: opts.readRepair ?? true }, [], '', '', registry.trie.rootState, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ export interface SortedMap<V> {
|
||||
keys(range: { gte?: string; lt?: string; reverse?: boolean }): IterableIterator<string>
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
+2
-3
@@ -22,9 +22,8 @@ export type KeyString<S> = `${KeyOf<S>}`
|
||||
export type EntryOf<S> = { [K in KeyOf<S>]: [K, ValueOf<ChildOf<S, K>>] }[KeyOf<S>]
|
||||
|
||||
/** `[key, value]` as yielded at runtime (string keys). Dynamic levels collapse to `[string, V]`. */
|
||||
export type StringEntryOf<S> = string extends KeyOf<S>
|
||||
? [string, ValueOf<ChildOf<S, KeyOf<S> & string>>]
|
||||
: { [K in KeyOf<S>]: [`${K}`, ValueOf<ChildOf<S, K>>] }[KeyOf<S>]
|
||||
export type StringEntryOf<S> =
|
||||
string extends KeyOf<S> ? [string, ValueOf<ChildOf<S, KeyOf<S> & string>>] : { [K in KeyOf<S>]: [`${K}`, ValueOf<ChildOf<S, K>>] }[KeyOf<S>]
|
||||
|
||||
/** A dynamic level of children with values `V`, each with children `C`. */
|
||||
export type Many<V, C extends Record<string, Spec> = {}> = { children: { [id: string]: { value: V; children: C } } }
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
+1
-10
@@ -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('/', ':', '.', '|', '#', '\\', '!')
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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] })
|
||||
})
|
||||
|
||||
+1
-2
@@ -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))
|
||||
|
||||
+14
-3
@@ -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
|
||||
|
||||
+51
-47
@@ -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<string, unknown>()
|
||||
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<string, unknown>()
|
||||
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)
|
||||
|
||||
+24
-19
@@ -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<number>(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<number>(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 },
|
||||
)
|
||||
})
|
||||
|
||||
+16
-7
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user