M7: Spec typing across KV, events and documents with expectTypeOf tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
This commit is contained in:
@@ -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<string, …>`, so
|
||||
`KeyOf<AnySpec>` is `string | number` and number keys type-check on untyped stores.
|
||||
- **`ChildOf` guards with `infer C extends Spec`** so that `KVNode<ChildOf<S, K>>` satisfies the `Spec`
|
||||
constraint generically.
|
||||
- **`setMany` entries are a union of precise `[key, value]` pairs** (`EntryOf<S>`), so on named levels a
|
||||
value must match its own key (`['age', 'x']` is rejected).
|
||||
- **Listings**: `keys()` yields `` `${KeyOf<S>}` `` (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<AnySpec>` / `EventNode<AnySpec>`. A typed node is
|
||||
assignable to these, but not to `KVNode<any>`, because `KeyOf<any>` 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.
|
||||
|
||||
<!-- decisions appended per milestone -->
|
||||
|
||||
## 13. Benchmark baselines
|
||||
|
||||
+4
-2
@@ -21,8 +21,10 @@ export type KeyString<S> = `${KeyOf<S>}`
|
||||
/** `[key, value]` for each child key, as a union of precise pairs. */
|
||||
export type EntryOf<S> = { [K in KeyOf<S>]: [K, ValueOf<ChildOf<S, K>>] }[KeyOf<S>]
|
||||
|
||||
/** `[key, value]` as yielded at runtime (string keys). */
|
||||
export type StringEntryOf<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>]
|
||||
|
||||
/** 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 } } }
|
||||
|
||||
@@ -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<User, { posts: Many<Post> }>
|
||||
profile: { children: { [id: string]: { children: Fields<User> } } } // field-per-key style
|
||||
}
|
||||
}
|
||||
|
||||
const driver = new InMemoryKVDriver()
|
||||
|
||||
describe('KV types (section 7)', () => {
|
||||
const db = new KVStorage<Schema>(driver)
|
||||
|
||||
it('reads child values at dynamic levels', () => {
|
||||
expectTypeOf(db.child('users').get('123')).toEqualTypeOf<Promise<User | undefined>>()
|
||||
expectTypeOf(db.child('users').child('123').get()).toEqualTypeOf<Promise<User | undefined>>()
|
||||
expectTypeOf(db.child('users').getMany(['1', '2'])).toEqualTypeOf<Promise<(User | undefined)[]>>()
|
||||
})
|
||||
|
||||
it('navigates named levels', () => {
|
||||
expectTypeOf(db.child('users').child('123').child('posts')).toEqualTypeOf<KVNode<Many<Post>>>()
|
||||
// @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<Promise<number | undefined>>()
|
||||
expectTypeOf(db.child('profile').child('9').get('username')).toEqualTypeOf<Promise<string | undefined>>()
|
||||
// @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<AsyncGenerator<string>>()
|
||||
expectTypeOf(db.child('profile').child('9').keys()).toEqualTypeOf<AsyncGenerator<'username' | 'age'>>()
|
||||
expectTypeOf(db.child('users').entries()).toEqualTypeOf<AsyncGenerator<[string, User]>>()
|
||||
})
|
||||
|
||||
it('defaults to untyped and supports as<S>()', () => {
|
||||
const raw = new KVStorage(driver)
|
||||
expectTypeOf(raw.child('anything').child(5).get('x')).toEqualTypeOf<Promise<unknown>>()
|
||||
const users = raw.child('users').as<Many<User>>()
|
||||
expectTypeOf(users.get('1')).toEqualTypeOf<Promise<User | undefined>>()
|
||||
expectTypeOf(raw).toMatchTypeOf<KVNode<AnySpec>>()
|
||||
})
|
||||
})
|
||||
|
||||
type OrderEvent = { type: 'created'; total: number } | { type: 'paid' }
|
||||
type EventSchema = { children: { orders: Many<OrderEvent, { shipments: Many<{ carrier: string }> }> } }
|
||||
|
||||
describe('event types', () => {
|
||||
const events = new KVEventStorage<EventSchema>(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<AsyncIterable<StoredEvent<OrderEvent>>>()
|
||||
expectTypeOf(orders.version('o1')).toEqualTypeOf<Promise<number>>()
|
||||
})
|
||||
})
|
||||
|
||||
type DocSchema = { children: { users: Many<User, { posts: Many<Post> }> } }
|
||||
|
||||
describe('document types', () => {
|
||||
const docs = new EventSourcedDocumentStorage<DocSchema>(new KVEventStorage(new KVStorage(driver)))
|
||||
const users = docs.child('users')
|
||||
|
||||
it('types reads and writes', () => {
|
||||
expectTypeOf(users.get('1')).toEqualTypeOf<Promise<User | undefined>>()
|
||||
expectTypeOf(users.child('1').get()).toEqualTypeOf<Promise<User | undefined>>()
|
||||
expectTypeOf(users.insert({ username: 'a', age: 1 })).toEqualTypeOf<Promise<string>>()
|
||||
// @ts-expect-error missing field
|
||||
users.insert({ username: 'a' })
|
||||
expectTypeOf(users.update('1', { $inc: { age: 1 } })).toEqualTypeOf<Promise<User | undefined>>()
|
||||
expectTypeOf(users.child('1').child('posts').get('p')).toEqualTypeOf<Promise<Post | undefined>>()
|
||||
})
|
||||
|
||||
it('types queries', () => {
|
||||
expectTypeOf(users.find({ age: { $gte: 18 } })).toEqualTypeOf<AsyncGenerator<FindResult<User>>>()
|
||||
expectTypeOf(users.findOne()).toEqualTypeOf<Promise<FindResult<User> | undefined>>()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user