feat(storage): storage hub with named backend registry and data-form mounts
ctx.storage is a pure registration hub: multiple named backends stay mounted side by side, data forms (domain first) mount via the merge-extensible StorageForms map. src/backend.ts is the normative KV-facet contract; tests/contract.ts is the shared conformance suite every backend runs. Backends expose data-shape facets (kv now, an append-log facet reserved for the future session-backend migration).
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# @deepseek-ai/dsh-storage
|
||||
|
||||
Storage hub (`ctx.storage`) for non-session data: a named backend registry plus mounted data-form facilities. The hub performs no IO itself — backends own media, data forms own semantics. Design and trade-offs: [domain KV storage Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md).
|
||||
|
||||
## Shape
|
||||
|
||||
- `ctx.storage.backend` — name → backend table. Multiple backends stay mounted side by side (`json`, `sqlite`); which backend serves a consumer is that consumer's configuration (the domain layer's route table), never a hub-global choice. `register()` returns the disposer; duplicate names and unknown lookups fail loud.
|
||||
- `ctx.storage.mount(form, facility)` / `ctx.storage.form(form)` — data-form mounting. `StorageForms` is merge-extensible; the domain layer merges `domain` and is reached as `ctx.storage.domain`.
|
||||
- A backend owns one medium (file-tree root, database file) and exposes optional data-shape **facets** — `kv` today; an append-log facet is reserved for the future session-backend migration. `src/backend.ts` is the normative contract text; `tests/contract.ts` exports the shared conformance suite every backend runs.
|
||||
|
||||
## Packages in this group
|
||||
|
||||
| Package | Role |
|
||||
| --- | --- |
|
||||
| `dsh-storage` | The hub service + backend vocabulary + shared conformance suite |
|
||||
| `dsh-storage-json` | JSON backend: one unit per human-readable file, atomic whole-file rewrite |
|
||||
| `dsh-storage-sqlite` | SQLite backend: one database hosting all routed units, document-per-row |
|
||||
| `dsh-domain` | Domain data form (`ctx.storage.domain`): typed schemas, write chain, change events |
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-storage",
|
||||
"description": "Storage hub (ctx.storage): named backend registry plus mounted data-form facilities for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Backend-facing vocabulary of the storage hub: a backend owns one medium
|
||||
* (a file-tree root, a database file) and exposes data-shape facets over it.
|
||||
* This module is the normative contract text for backend implementers; the
|
||||
* shared conformance suite in `tests/contract.ts` asserts every clause.
|
||||
* @module @deepseek-ai/dsh-storage/src/backend
|
||||
*/
|
||||
|
||||
/** Allowed shape for unit and table names: safe as a file name and as a SQL identifier segment without escaping. */
|
||||
export const UNIT_NAME_RE = /^[a-z][a-z0-9_]*$/
|
||||
|
||||
/**
|
||||
* One registered backend. A backend owns exactly one medium and shares its
|
||||
* lifecycle across all facets; facets are optional members — a backend that
|
||||
* cannot serve a shape simply omits it, and resolution fails loud instead.
|
||||
*/
|
||||
export interface StorageBackend {
|
||||
/** Key-value data shape; absent when this backend cannot serve it. */
|
||||
readonly kv?: KvFacet
|
||||
|
||||
/**
|
||||
* Drain in-flight writes across all open units and release the medium.
|
||||
* Idempotent; concurrent and repeated calls resolve once teardown finishes.
|
||||
* @returns resolution after the medium is released.
|
||||
*/
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
/** The key-value data shape: whole-unit snapshots plus per-record durable writes. */
|
||||
export interface KvFacet {
|
||||
/**
|
||||
* Open one unit, creating it when the medium holds no trace of it yet
|
||||
* (materialization may defer to the first write, but {@link KvUnit.loadAll}
|
||||
* must immediately serve the empty shape). A version already stamped on the
|
||||
* medium that differs from `descriptor.version` rejects with
|
||||
* `version-mismatch`; a medium that cannot be parsed as this unit rejects
|
||||
* with `malformed-medium`. Opening the same unit name twice without closing
|
||||
* is a caller bug and rejects.
|
||||
* @param descriptor - Static identity and shape of the unit to open.
|
||||
* @returns the opened unit.
|
||||
*/
|
||||
open(descriptor: KvUnitDescriptor): Promise<KvUnit>
|
||||
}
|
||||
|
||||
/** Static identity and shape of one KV unit, projected from its owner's spec. */
|
||||
export interface KvUnitDescriptor {
|
||||
/** Unit name; must match {@link UNIT_NAME_RE}. Also the file-name / SQL-identifier segment. */
|
||||
readonly name: string
|
||||
/** Unit format version; a non-negative integer stamped on the medium at first materialization. */
|
||||
readonly version: number
|
||||
/** Table names; each must match {@link UNIT_NAME_RE}. */
|
||||
readonly tables: readonly string[]
|
||||
/** Whether this unit carries the global singleton slot. */
|
||||
readonly hasGlobal: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* One opened unit. Values are opaque JSON to this layer: no schema, no
|
||||
* events, no domain meaning. The unit does NOT serialize concurrent writes —
|
||||
* write ordering is the caller's responsibility (the domain layer runs one
|
||||
* write chain per unit); the unit only guarantees that each single call is
|
||||
* atomic on the medium and durable once resolved (a crash after resolution
|
||||
* followed by a re-open observes the write). Any call after {@link close}
|
||||
* rejects with `closed`.
|
||||
*/
|
||||
export interface KvUnit {
|
||||
/**
|
||||
* Read the full current snapshot.
|
||||
* @returns every table's records keyed by table name, plus the global
|
||||
* singleton (`null` when never written or not declared).
|
||||
*/
|
||||
loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown | null }>
|
||||
|
||||
/**
|
||||
* Upsert one record durably. Overwrite semantics: an existing key is replaced.
|
||||
* @param table - Declared table name.
|
||||
* @param key - Record key; any string is safe (keys never reach file paths).
|
||||
* @param value - Opaque JSON-serializable record.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
putRecord(table: string, key: string, value: unknown): Promise<void>
|
||||
|
||||
/**
|
||||
* Delete one record durably. Idempotent: a missing key is a no-op.
|
||||
* @param table - Declared table name.
|
||||
* @param key - Record key.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
deleteRecord(table: string, key: string): Promise<void>
|
||||
|
||||
/**
|
||||
* Write the global singleton durably. Only valid when the descriptor
|
||||
* declared `hasGlobal`.
|
||||
* @param value - Opaque JSON-serializable value.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
setGlobal(value: unknown): Promise<void>
|
||||
|
||||
/**
|
||||
* Drain this unit's in-flight writes and release it. Idempotent.
|
||||
* @returns resolution after the unit is released.
|
||||
*/
|
||||
close(): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Error vocabulary for the storage hub and its backends.
|
||||
* @module @deepseek-ai/dsh-storage/src/error
|
||||
*/
|
||||
|
||||
/** Discriminant codes carried by every {@link StorageError}. */
|
||||
export type StorageErrorCode =
|
||||
| 'backend-not-found'
|
||||
| 'form-not-mounted'
|
||||
| 'duplicate-backend'
|
||||
| 'duplicate-mount'
|
||||
| 'version-mismatch'
|
||||
| 'malformed-medium'
|
||||
| 'closed'
|
||||
|
||||
/**
|
||||
* Error thrown by the hub and by backend implementations. The `code` is the
|
||||
* stable contract consumers may switch on; `message` is diagnostic prose.
|
||||
*/
|
||||
export class StorageError extends Error {
|
||||
override readonly name = 'StorageError'
|
||||
|
||||
/**
|
||||
* @param code - Stable discriminant for the failure class.
|
||||
* @param message - Human-readable diagnostic detail.
|
||||
* @param options - Standard error options (`cause`).
|
||||
*/
|
||||
constructor(
|
||||
readonly code: StorageErrorCode,
|
||||
message: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Storage hub (`ctx.storage`): a named backend registry plus mounted
|
||||
* data-form facilities. The hub itself performs no IO — backends own media,
|
||||
* data forms (the domain layer first) own semantics.
|
||||
* @module @deepseek-ai/dsh-storage
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { StorageError } from './error.ts'
|
||||
import { BackendRegistry } from './registry.ts'
|
||||
|
||||
export { BackendRegistry } from './registry.ts'
|
||||
export { StorageError } from './error.ts'
|
||||
export type { StorageErrorCode } from './error.ts'
|
||||
export { UNIT_NAME_RE } from './backend.ts'
|
||||
export type { StorageBackend, KvFacet, KvUnit, KvUnitDescriptor } from './backend.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
storage: Storage
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data forms mountable on the hub, keyed by form name. Form owners extend
|
||||
* this map via declaration merging (the domain layer merges
|
||||
* `domain: DomainFacility`) and mount the facility in their `apply`.
|
||||
*/
|
||||
export interface StorageForms {}
|
||||
|
||||
/**
|
||||
* The storage hub service. Backends register under `backend`; data forms
|
||||
* mount under their `StorageForms` key and are reached as `ctx.storage.<form>`.
|
||||
*/
|
||||
export class Storage extends Service {
|
||||
/** Named backend table; multiple backends stay mounted side by side. */
|
||||
readonly backend = new BackendRegistry()
|
||||
|
||||
private readonly forms = new Map<keyof StorageForms, unknown>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'storage')
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a data-form facility on the hub. Mounting is an effect: the
|
||||
* returned disposer unmounts the form.
|
||||
* @param form - Form key declared in {@link StorageForms}.
|
||||
* @param facility - The facility instance to expose.
|
||||
* @returns the disposer that unmounts the form.
|
||||
*/
|
||||
mount<K extends keyof StorageForms>(form: K, facility: StorageForms[K]): () => void {
|
||||
if (this.forms.has(form)) {
|
||||
throw new StorageError('duplicate-mount', `storage form '${String(form)}' is already mounted`)
|
||||
}
|
||||
this.forms.set(form, facility)
|
||||
return () => {
|
||||
this.forms.delete(form)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a mounted data form.
|
||||
* @param form - Form key declared in {@link StorageForms}.
|
||||
* @returns the mounted facility.
|
||||
*/
|
||||
form<K extends keyof StorageForms>(form: K): StorageForms[K] {
|
||||
if (!this.forms.has(form)) {
|
||||
throw new StorageError('form-not-mounted', `storage form '${String(form)}' is not mounted`)
|
||||
}
|
||||
return this.forms.get(form) as StorageForms[K]
|
||||
}
|
||||
|
||||
/** Domain data form; present once the domain layer plugin is loaded. */
|
||||
get domain(): StorageForms extends { domain: infer D } ? D : never {
|
||||
return this.form('domain' as keyof StorageForms) as StorageForms extends { domain: infer D } ? D : never
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the storage hub service.
|
||||
* @param ctx - Plugin context.
|
||||
*/
|
||||
export function apply(ctx: Context) {
|
||||
ctx.plugin(Storage)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-storage`.
|
||||
* @module @deepseek-ai/dsh-storage/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-storage'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'storage-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the hub is a pure registration table (names →
|
||||
* backends, forms → facilities) whose consistency is fully enforced at the
|
||||
* call sites (duplicate/missing entries fail loud synchronously); it owns no
|
||||
* event stream or mutable medium to cross-check.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Named backend registry of the storage hub.
|
||||
* @module @deepseek-ai/dsh-storage/src/registry
|
||||
*/
|
||||
|
||||
import type { StorageBackend } from './backend.ts'
|
||||
import { StorageError } from './error.ts'
|
||||
|
||||
/**
|
||||
* Mutable name → backend table. Multiple backends stay mounted side by side;
|
||||
* which backend serves which consumer is the consumer's configuration
|
||||
* (e.g. the domain layer's route table), never a hub-global choice.
|
||||
*/
|
||||
export class BackendRegistry {
|
||||
private readonly backends = new Map<string, StorageBackend>()
|
||||
|
||||
/**
|
||||
* Register a named backend. Registration is an effect: the returned
|
||||
* disposer removes the name. Disposal does NOT close the backend — the
|
||||
* owning plugin closes it after unregistering.
|
||||
* @param name - Backend name, e.g. `json` or `sqlite`.
|
||||
* @param backend - The backend instance.
|
||||
* @returns the disposer that unregisters the name.
|
||||
*/
|
||||
register(name: string, backend: StorageBackend): () => void {
|
||||
if (this.backends.has(name)) {
|
||||
throw new StorageError('duplicate-backend', `storage backend '${name}' is already registered`)
|
||||
}
|
||||
this.backends.set(name, backend)
|
||||
return () => {
|
||||
this.backends.delete(name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a backend by name.
|
||||
* @param name - Registered backend name.
|
||||
* @returns the backend.
|
||||
*/
|
||||
get(name: string): StorageBackend {
|
||||
const backend = this.backends.get(name)
|
||||
if (!backend) {
|
||||
throw new StorageError(
|
||||
'backend-not-found',
|
||||
`storage backend '${name}' is not registered (registered: ${[...this.backends.keys()].join(', ') || 'none'})`,
|
||||
)
|
||||
}
|
||||
return backend
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered backend names, for diagnostics.
|
||||
* @returns a snapshot array of names.
|
||||
*/
|
||||
names(): string[] {
|
||||
return [...this.backends.keys()]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Shared KV-backend conformance suite. Each backend's spec file calls
|
||||
* {@link runKvBackendContract} with a factory bound to its own medium; the
|
||||
* suite asserts every clause of the `src/backend.ts` contract so both
|
||||
* backends are held to identical semantics.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { KvUnitDescriptor, StorageBackend } from '../src/backend.ts'
|
||||
|
||||
/** One conformance run: a fresh backend plus a way to reopen the same medium (crash simulation). */
|
||||
export interface KvBackendContractHarness {
|
||||
/** The backend under test, freshly created over an empty medium. */
|
||||
backend: StorageBackend
|
||||
/** Open a NEW backend instance over the SAME medium, as after a process restart. */
|
||||
reopen(): Promise<StorageBackend>
|
||||
}
|
||||
|
||||
const DESCRIPTOR: KvUnitDescriptor = {
|
||||
name: 'contract_unit',
|
||||
version: 3,
|
||||
tables: ['alpha', 'beta'],
|
||||
hasGlobal: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the shared conformance suite against one backend implementation.
|
||||
* @param label - Suite label, e.g. `json` / `sqlite`.
|
||||
* @param create - Factory producing a fresh harness per test.
|
||||
*/
|
||||
export function runKvBackendContract(label: string, create: () => Promise<KvBackendContractHarness>) {
|
||||
describe(`kv backend contract: ${label}`, () => {
|
||||
it('opens a missing unit as empty and serves loadAll immediately', async () => {
|
||||
const { backend } = await create()
|
||||
const unit = await backend.kv!.open(DESCRIPTOR)
|
||||
const snapshot = await unit.loadAll()
|
||||
expect(snapshot.tables).toEqual({ alpha: {}, beta: {} })
|
||||
expect(snapshot.global).toBeNull()
|
||||
await backend.close()
|
||||
})
|
||||
|
||||
it('round-trips records and global durably across reopen', async () => {
|
||||
const harness = await create()
|
||||
const unit = await harness.backend.kv!.open(DESCRIPTOR)
|
||||
await unit.putRecord('alpha', 'k1', { n: 1 })
|
||||
await unit.putRecord('alpha', 'k2', { n: 2 })
|
||||
await unit.putRecord('beta', 'weird key / with:stuff', { ok: true })
|
||||
await unit.setGlobal({ counter: 7 })
|
||||
await harness.backend.close()
|
||||
|
||||
const reopened = await harness.reopen()
|
||||
const unit2 = await reopened.kv!.open(DESCRIPTOR)
|
||||
const snapshot = await unit2.loadAll()
|
||||
expect(snapshot.tables['alpha']).toEqual({ k1: { n: 1 }, k2: { n: 2 } })
|
||||
expect(snapshot.tables['beta']).toEqual({ 'weird key / with:stuff': { ok: true } })
|
||||
expect(snapshot.global).toEqual({ counter: 7 })
|
||||
await reopened.close()
|
||||
})
|
||||
|
||||
it('putRecord overwrites and deleteRecord is idempotent', async () => {
|
||||
const { backend } = await create()
|
||||
const unit = await backend.kv!.open(DESCRIPTOR)
|
||||
await unit.putRecord('alpha', 'k', { v: 'old' })
|
||||
await unit.putRecord('alpha', 'k', { v: 'new' })
|
||||
await unit.deleteRecord('alpha', 'k')
|
||||
await unit.deleteRecord('alpha', 'k')
|
||||
await unit.deleteRecord('alpha', 'never-existed')
|
||||
const snapshot = await unit.loadAll()
|
||||
expect(snapshot.tables['alpha']).toEqual({})
|
||||
await backend.close()
|
||||
})
|
||||
|
||||
it('rejects a version mismatch on reopen without touching the data', async () => {
|
||||
const harness = await create()
|
||||
const unit = await harness.backend.kv!.open(DESCRIPTOR)
|
||||
await unit.putRecord('alpha', 'k', { v: 1 })
|
||||
await harness.backend.close()
|
||||
|
||||
const reopened = await harness.reopen()
|
||||
await expect(reopened.kv!.open({ ...DESCRIPTOR, version: 4 })).rejects.toMatchObject({
|
||||
name: 'StorageError',
|
||||
code: 'version-mismatch',
|
||||
})
|
||||
// Original version still opens and still holds the data.
|
||||
const unit2 = await reopened.kv!.open(DESCRIPTOR)
|
||||
expect((await unit2.loadAll()).tables['alpha']).toEqual({ k: { v: 1 } })
|
||||
await reopened.close()
|
||||
})
|
||||
|
||||
it('rejects operations after unit close, and close is idempotent', async () => {
|
||||
const { backend } = await create()
|
||||
const unit = await backend.kv!.open(DESCRIPTOR)
|
||||
await unit.close()
|
||||
await unit.close()
|
||||
await expect(unit.putRecord('alpha', 'k', {})).rejects.toMatchObject({ code: 'closed' })
|
||||
await expect(unit.loadAll()).rejects.toMatchObject({ code: 'closed' })
|
||||
await backend.close()
|
||||
await backend.close()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { BackendRegistry, Storage, apply } from '../src/index.ts'
|
||||
import type { StorageBackend } from '../src/index.ts'
|
||||
|
||||
const fakeBackend = (): StorageBackend => ({ close: async () => {} })
|
||||
|
||||
describe('BackendRegistry', () => {
|
||||
it('registers, resolves, and disposes names', () => {
|
||||
const registry = new BackendRegistry()
|
||||
const backend = fakeBackend()
|
||||
const dispose = registry.register('json', backend)
|
||||
expect(registry.get('json')).toBe(backend)
|
||||
expect(registry.names()).toEqual(['json'])
|
||||
dispose()
|
||||
expect(registry.names()).toEqual([])
|
||||
expect(() => registry.get('json')).toThrowMatchingObject({ code: 'backend-not-found' })
|
||||
})
|
||||
|
||||
it('rejects duplicate names', () => {
|
||||
const registry = new BackendRegistry()
|
||||
registry.register('json', fakeBackend())
|
||||
expect(() => registry.register('json', fakeBackend())).toThrowMatchingObject({ code: 'duplicate-backend' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Storage service', () => {
|
||||
it('mounts on the context and exposes registry plus form mounting', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin({ apply })
|
||||
expect(ctx.storage).toBeInstanceOf(Storage)
|
||||
|
||||
const facility = { marker: true }
|
||||
const dispose = ctx.storage.mount('domain' as never, facility as never)
|
||||
expect(ctx.storage.form('domain' as never)).toBe(facility)
|
||||
expect(() => ctx.storage.mount('domain' as never, facility as never)).toThrowMatchingObject({
|
||||
code: 'duplicate-mount',
|
||||
})
|
||||
dispose()
|
||||
expect(() => ctx.storage.form('domain' as never)).toThrowMatchingObject({ code: 'form-not-mounted' })
|
||||
})
|
||||
})
|
||||
|
||||
expect.extend({
|
||||
toThrowMatchingObject(received: () => unknown, expected: object) {
|
||||
try {
|
||||
received()
|
||||
} catch (error) {
|
||||
const pass = Object.entries(expected).every(
|
||||
(entry) => (error as Record<string, unknown>)[entry[0]] === entry[1],
|
||||
)
|
||||
return { pass, message: () => `expected thrown error to match ${JSON.stringify(expected)}, got ${String(error)}` }
|
||||
}
|
||||
return { pass: false, message: () => 'expected function to throw' }
|
||||
},
|
||||
})
|
||||
|
||||
declare module 'vitest' {
|
||||
interface Assertion<T> {
|
||||
toThrowMatchingObject(expected: object): T
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -74,6 +74,8 @@
|
||||
"./packages/hooks/*/src/invariant.ts",
|
||||
"./packages/session-persistence/*/src/invariant.ts",
|
||||
"./packages/session-query/*/src/invariant.ts",
|
||||
"./packages/storage/*/src/invariant.ts",
|
||||
"./packages/workspace/*/src/invariant.ts",
|
||||
"./packages/sdk/*/src/invariant.ts",
|
||||
"./packages/ui/*/src/invariant.ts",
|
||||
"./packages/examples/*/src/invariant.ts",
|
||||
@@ -142,6 +144,8 @@
|
||||
"./packages/session-persistence/*/src",
|
||||
"./packages/session-query/*/src",
|
||||
"./packages/session-title/*/src",
|
||||
"./packages/storage/*/src",
|
||||
"./packages/workspace/*/src",
|
||||
"./packages/sdk/*/src",
|
||||
"./packages/ui/*/src",
|
||||
"./packages/examples/*/src",
|
||||
|
||||
@@ -44,6 +44,11 @@
|
||||
{ "path": "./packages/session-persistence/session-persistence-sqlite" },
|
||||
{ "path": "./packages/session-query/session-query" },
|
||||
{ "path": "./packages/session-query/session-query-sqlite" },
|
||||
{ "path": "./packages/storage/storage" },
|
||||
{ "path": "./packages/storage/storage-json" },
|
||||
{ "path": "./packages/storage/storage-sqlite" },
|
||||
{ "path": "./packages/storage/domain" },
|
||||
{ "path": "./packages/workspace/workspace" },
|
||||
{ "path": "./packages/session-title/session-title" },
|
||||
{ "path": "./packages/session-title/session-title-llm" },
|
||||
{ "path": "./packages/session-title/session-title-first-message-llm" },
|
||||
|
||||
Reference in New Issue
Block a user