From f6541507acf8cb389b9e9f02c401baca653f8dcc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 07:19:11 +0000 Subject: [PATCH] M7: Spec typing across KV, events and documents with expectTypeOf tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE --- DESIGN.md | 15 ++++++ src/types/schema.ts | 6 ++- test/types.test-d.ts | 114 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 test/types.test-d.ts diff --git a/DESIGN.md b/DESIGN.md index 49f8083..65087af 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -551,6 +551,21 @@ Decisions taken where the brief was silent. Each is the simplest option consiste - **Projections for non-KV event stores** must be passed explicitly (`projections: KVNode`); the projection key of a document is then `projections.prefix + docKey`. +### M7 — Types + +- **`AnySpec` uses an index signature** (`{ [key: string]: AnySpec }`), not `Record`, so + `KeyOf` is `string | number` and number keys type-check on untyped stores. +- **`ChildOf` guards with `infer C extends Spec`** so that `KVNode>` satisfies the `Spec` + constraint generically. +- **`setMany` entries are a union of precise `[key, value]` pairs** (`EntryOf`), so on named levels a + value must match its own key (`['age', 'x']` is rejected). +- **Listings**: `keys()` yields `` `${KeyOf}` `` (string literals on named levels, `string` on dynamic + ones). `entries()` and `findBy()` yield `[string, V]` on dynamic levels. +- **"Accept any node" parameters** are typed `KVNode` / `EventNode`. A typed node is + assignable to these, but not to `KVNode`, because `KeyOf` collapses to `never`. +- **Type tests** live in `test/types.test-d.ts` and are enforced by `tsc --noEmit` (part of `npm test`), + including the `@ts-expect-error` negative cases from section 7. + ## 13. Benchmark baselines diff --git a/src/types/schema.ts b/src/types/schema.ts index 6089198..21082bb 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -21,8 +21,10 @@ export type KeyString = `${KeyOf}` /** `[key, value]` for each child key, as a union of precise pairs. */ export type EntryOf = { [K in KeyOf]: [K, ValueOf>] }[KeyOf] -/** `[key, value]` as yielded at runtime (string keys). */ -export type StringEntryOf = { [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] /** 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/types.test-d.ts b/test/types.test-d.ts new file mode 100644 index 0000000..a8b64e1 --- /dev/null +++ b/test/types.test-d.ts @@ -0,0 +1,114 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { KVStorage, type KVNode } from '../src/kv/node.js' +import { InMemoryKVDriver } from '../src/kv/memory.js' +import { KVEventStorage } from '../src/events/kv.js' +import type { StoredEvent } from '../src/events/types.js' +import { EventSourcedDocumentStorage } from '../src/documents/event-sourced.js' +import type { FindResult } from '../src/documents/types.js' +import type { AnySpec, Fields, Many } from '../src/types/schema.js' + +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 + } +} + +const driver = new InMemoryKVDriver() + +describe('KV types (section 7)', () => { + const db = new KVStorage(driver) + + it('reads child values at dynamic levels', () => { + expectTypeOf(db.child('users').get('123')).toEqualTypeOf>() + expectTypeOf(db.child('users').child('123').get()).toEqualTypeOf>() + expectTypeOf(db.child('users').getMany(['1', '2'])).toEqualTypeOf>() + }) + + it('navigates named levels', () => { + expectTypeOf(db.child('users').child('123').child('posts')).toEqualTypeOf>>() + // @ts-expect-error unknown named child + db.child('users').child('123').child('postz') + // @ts-expect-error unknown top-level child + db.child('userz') + }) + + it('reads field-per-key values', () => { + expectTypeOf(db.child('profile').child('9').get('age')).toEqualTypeOf>() + expectTypeOf(db.child('profile').child('9').get('username')).toEqualTypeOf>() + // @ts-expect-error unknown field + db.child('profile').child('9').get('agee') + }) + + it('checks written values', () => { + db.child('users').set('1', { username: 'a', age: 1 }) + 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']]) + }) + + it('types listings', () => { + expectTypeOf(db.child('users').keys()).toEqualTypeOf>() + expectTypeOf(db.child('profile').child('9').keys()).toEqualTypeOf>() + expectTypeOf(db.child('users').entries()).toEqualTypeOf>() + }) + + it('defaults to untyped and supports as()', () => { + const raw = new KVStorage(driver) + expectTypeOf(raw.child('anything').child(5).get('x')).toEqualTypeOf>() + const users = raw.child('users').as>() + expectTypeOf(users.get('1')).toEqualTypeOf>() + expectTypeOf(raw).toMatchTypeOf>() + }) +}) + +type OrderEvent = { type: 'created'; total: number } | { type: 'paid' } +type EventSchema = { children: { orders: Many }> } } + +describe('event types', () => { + const events = new KVEventStorage(new KVStorage(driver)) + const orders = events.child('orders') + + it('checks appended events', () => { + orders.append('o1', [{ type: 'created', total: 40 }]) + 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' }]) + }) + + it('types read results', () => { + expectTypeOf(orders.read('o1')).toEqualTypeOf>>() + expectTypeOf(orders.version('o1')).toEqualTypeOf>() + }) +}) + +type DocSchema = { children: { users: Many }> } } + +describe('document types', () => { + const docs = new EventSourcedDocumentStorage(new KVEventStorage(new KVStorage(driver))) + const users = docs.child('users') + + it('types reads and writes', () => { + expectTypeOf(users.get('1')).toEqualTypeOf>() + expectTypeOf(users.child('1').get()).toEqualTypeOf>() + expectTypeOf(users.insert({ username: 'a', age: 1 })).toEqualTypeOf>() + // @ts-expect-error missing field + users.insert({ username: 'a' }) + expectTypeOf(users.update('1', { $inc: { age: 1 } })).toEqualTypeOf>() + expectTypeOf(users.child('1').child('posts').get('p')).toEqualTypeOf>() + }) + + it('types queries', () => { + expectTypeOf(users.find({ age: { $gte: 18 } })).toEqualTypeOf>>() + expectTypeOf(users.findOne()).toEqualTypeOf | undefined>>() + }) +})