@vielzeug/codex 26.10.0 → 26.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/data/catalog.json +53 -59
- package/data/llms-full.txt +1322 -1069
- package/data/llms.txt +6 -6
- package/data/manifest.json +1 -1
- package/data/packages/arsenal.json +13 -13
- package/data/packages/assay.json +4 -4
- package/data/packages/clockwork.json +4 -4
- package/data/packages/codex.json +3 -3
- package/data/packages/coins.json +4 -4
- package/data/packages/conduit.json +4 -4
- package/data/packages/courier.json +4 -4
- package/data/packages/dnd.json +10 -10
- package/data/packages/familiar.json +4 -4
- package/data/packages/flux.json +4 -4
- package/data/packages/focus.json +4 -4
- package/data/packages/forge.json +4 -4
- package/data/packages/gesture.json +4 -4
- package/data/packages/herald.json +12 -12
- package/data/packages/illusionist.json +5 -5
- package/data/packages/keymap.json +9 -9
- package/data/packages/ledger.json +4 -4
- package/data/packages/lingua.json +4 -4
- package/data/packages/mesh.json +5 -5
- package/data/packages/necromancer.json +5 -5
- package/data/packages/orbit.json +7 -7
- package/data/packages/ore.json +5 -5
- package/data/packages/postmaster.json +4 -4
- package/data/packages/prism.json +50 -42
- package/data/packages/pulse.json +8 -8
- package/data/packages/refine.json +3 -3
- package/data/packages/ripple.json +4 -4
- package/data/packages/rune.json +9 -9
- package/data/packages/sandbox.json +4 -4
- package/data/packages/scout.json +7 -7
- package/data/packages/scroll.json +10 -10
- package/data/packages/sentinel.json +4 -4
- package/data/packages/sigil.json +6 -6
- package/data/packages/sourcerer.json +4 -4
- package/data/packages/spell.json +9 -9
- package/data/packages/tandem.json +5 -5
- package/data/packages/tavern.json +12 -12
- package/data/packages/tempo.json +4 -4
- package/data/packages/vault.json +13 -13
- package/data/packages/ward.json +9 -9
- package/data/packages/wayfinder.json +17 -17
- package/data/refine.json +259 -87
- package/data/search.json +246 -246
- package/dist/http.js +2 -2
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/refine.d.ts +1 -1
- package/dist/refine.js +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/dist/tools/refine.js +1 -1
- package/package.json +2 -2
- package/skills/vielzeug/SKILL.md +57 -57
package/data/packages/vault.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"apiSource": "export { VaultDisposedError, VaultError, VaultMigrationError, VaultQuotaError, VaultScopeError } from './errors';\nexport { ttl } from './ttl';\nexport type {\n AnySchema,\n CodecInput,\n DocumentVaultStore,\n DurableStoreOptions,\n KeyOf,\n KeyValueVaultStore,\n MemoryStoreOptions,\n Observer,\n RecordCodec,\n RecordOf,\n RecordParser,\n SchemaEntry,\n TableCodecs,\n TransactionContext,\n Unsubscribe,\n VaultConveniences,\n VaultKey,\n} from './types';\nexport { table, validatorCodec } from './types';\n",
|
|
3
3
|
"docs": {
|
|
4
|
-
"index": "---\ntitle: Vault
|
|
5
|
-
"api": "---\ntitle: Vault — API Reference\ndescription: Reference for Vault schemas, adapter entry points, storage capabilities, codecs, SQLite drivers, and errors.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createMemory()` | In-memory document store | Async API | Import from `/memory`; codecs optional |\n| `createLocalStorage()` / `createSessionStorage()` | Web Storage-backed key-value stores | Async API | Codecs required; available only where the corresponding Web API exists |\n| `createIndexedDB()` | Browser document store with transactions and cursor iteration | Async API | Import from `/indexeddb`; codecs required |\n| `createSQLite()` | Driver-neutral SQLite document store | Async API over a synchronous driver | Import from `/sqlite`; codecs required |\n| `table()` | Typed record schema | Sync | The key field must be a string or finite number |\n| `validatorCodec()` | Create an identity-encoding codec from a parser | Sync | Spell schemas can also be passed directly |\n| `ttl` | Valid expiration durations | Sync | Durations must be positive |\n\n## Package Entry Points\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/vault` | Adapter-free schemas, TTL, errors, codecs, derived helpers, and shared types |\n| `@vielzeug/vault/memory` | `createMemory` |\n| `@vielzeug/vault/local-storage` | `createLocalStorage` |\n| `@vielzeug/vault/session-storage` | `createSessionStorage` |\n| `@vielzeug/vault/indexeddb` | `createIndexedDB`, the `migrate` upgrade-hook types, and IndexedDB-only types |\n| `@vielzeug/vault/sqlite` | `createSQLite`, the SQLite driver protocol types, and `TransactionContext` |\n\n## Schemas, Codecs, and TTL\n\n### `table()`\n\n```ts\nfunction table<T extends object, Key extends keyof T & string = keyof T & string>(\n key: Key & (T[Key] extends VaultKey ? unknown : never),\n options?: { defaultTtl?: number; indexes?: readonly (keyof T & string)[] },\n): SchemaEntry<T, Key>;\n```\n\nDefines a typed table and its primary-key field.\n\n| Parameter | Description |\n| --- | --- |\n| `key` | A record field whose values are `string` or finite `number` keys |\n| `options.defaultTtl` | Per-table default TTL in milliseconds |\n| `options.indexes` | IndexedDB secondary index fields |\n\n**Returns:** A `SchemaEntry` describing the table.\n\n```ts\nimport { table, ttl } from '@vielzeug/vault';\n\nconst users = table<{ id: number; email: string }>('id', {\n indexes: ['email'],\n defaultTtl: ttl.days(7),\n});\n```\n\n---\n\n### `validatorCodec()`\n\n```ts\nfunction validatorCodec<T>(validator: { parse(value: unknown): T }): RecordCodec<T>;\n```\n\nWraps any object with a `parse(value): T` method as an identity-encoding `RecordCodec`. Parser schemas can also be passed directly in `codecs`; a Spell schema requires no adapter.\n\n**Returns:** A `RecordCodec<T>`.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst UserSchema = s.object({ id: s.number(), name: s.string() });\nconst store = createLocalStorage({\n name: 'app',\n schema: { users: table<{ id: number; name: string }>('id') },\n codecs: { users: UserSchema },\n});\n```\n\nCustom `RecordCodec` values validate writes by round-tripping `decode(encode(value))`. IndexedDB codecs must preserve declared indexed field names and values in their encoded object.\n\n---\n\n### `ttl`\n\n```ts\nconst ttl: {\n days(n: number): number;\n hours(n: number): number;\n minutes(n: number): number;\n ms(n: number): number;\n seconds(n: number): number;\n};\n```\n\nCreates a finite, positive duration in milliseconds for writes and table defaults.\n\n**Returns:** `number`.\n\n```ts\nimport { ttl } from '@vielzeug/vault';\n\nconst cacheLifetime = ttl.minutes(5);\n```\n\n## Factories\n\nDurable factories (LocalStorage, SessionStorage, IndexedDB, SQLite) require `codecs`. Memory may omit them.\n\n### `createMemory()`\n\n```ts\nfunction createMemory<S extends AnySchema>(options: MemoryStoreOptions<S>): DocumentVaultStore<S>;\n```\n\nCreates an in-memory document store with `batch()` and `iterate()`. `batch()` snapshots the declared tables and restores them when the callback throws. Codecs are optional because memory values never cross a trust boundary.\n\n| Parameter | Description |\n| --- | --- |\n| `schema` | Tables created by `table()` |\n| `codecs` | Optional per-table codecs |\n\n**Returns:** `DocumentVaultStore<S>`.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createMemory } from '@vielzeug/vault/memory';\n\nconst store = createMemory({ schema: { users: table<{ id: number; name: string }>('id') } });\n```\n\n---\n\n### `createLocalStorage()`\n\n```ts\nfunction createLocalStorage<S extends AnySchema>(options: DurableStoreOptions<S> & {\n name: string;\n}): KeyValueVaultStore<S>;\n```\n\nCreates a namespaced `localStorage` store. Codecs are required.\n\n| Parameter | Description |\n| --- | --- |\n| `schema` | Tables created by `table()` |\n| `codecs` | Required per-table codecs |\n| `name` | Required storage namespace |\n\nA write that exceeds the browser quota rejects with `VaultQuotaError`.\n\n**Returns:** `KeyValueVaultStore<S>`.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createLocalStorage } from '@vielzeug/vault/local-storage';\n\nconst store = createLocalStorage({\n name: 'app',\n schema: { settings: table<{ id: string }>('id') },\n codecs: { settings: SettingsSchema },\n});\n```\n\n---\n\n### `createSessionStorage()`\n\n```ts\nfunction createSessionStorage<S extends AnySchema>(options: DurableStoreOptions<S> & {\n name: string;\n}): KeyValueVaultStore<S>;\n```\n\nCreates a namespaced `sessionStorage` store. Its options and return type match `createLocalStorage()`.\n\n**Returns:** `KeyValueVaultStore<S>`.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createSessionStorage } from '@vielzeug/vault/session-storage';\n\nconst store = createSessionStorage({\n name: 'checkout',\n schema: { cart: table<{ id: string }>('id') },\n codecs: { cart: CartSchema },\n});\n```\n\n---\n\n### `createIndexedDB()`\n\n```ts\nfunction createIndexedDB<S extends AnySchema>(options: DurableStoreOptions<S> & {\n migrate?: MigrationFn;\n name: string;\n version?: number;\n}): IndexedDbVaultStore<S>;\n```\n\nCreates an IndexedDB document store with atomic batches, lazy cursor iteration, and optional schema migrations. Codecs are required.\n\n| Parameter | Description |\n| --- | --- |\n| `schema` | Tables and IndexedDB secondary indexes |\n| `codecs` | Required per-table codecs |\n| `name` | Required database name |\n| `version` | Positive schema version; defaults to `1` |\n| `migrate` | Synchronous `onupgradeneeded` callback. Object stores and schema-declared indexes are created automatically; use the hook only for deletes or record transforms |\n\n**Returns:** `IndexedDbVaultStore<S>`. Its `getAllByIndex(table, field, value)` method queries a declared IndexedDB index without scanning the full table.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createIndexedDB } from '@vielzeug/vault/indexeddb';\n\nconst store = createIndexedDB({\n name: 'app',\n schema: { users: table<{ id: number; role: string }>('id', { indexes: ['role'] }) },\n codecs: { users: UserSchema },\n});\n\nconst admins = await store.getAllByIndex('users', 'role', 'admin');\n```\n\n---\n\n### `createSQLite()`\n\n```ts\nfunction createSQLite<S extends AnySchema>(options: SQLiteVaultOptions<S>): DocumentVaultStore<S>;\n```\n\nCreates a namespaced SQLite document store with atomic batches and keyset-paginated iteration. Codecs are required. It accepts an application-provided positional-parameter driver and never opens or imports a runtime driver.\n\n| Parameter | Description |\n| --- | --- |\n| `schema` | Tables created by `table()` |\n| `codecs` | Required per-table codecs |\n| `database` | Caller-provided `SQLiteDatabase` connection |\n| `name` | Namespace within the connection |\n| `closeOnDispose` | Closes the connection during disposal; defaults to `false` |\n\n**Returns:** `DocumentVaultStore<S>`.\n\n```ts\nimport { DatabaseSync } from 'node:sqlite';\n\nimport { table } from '@vielzeug/vault';\nimport { createSQLite } from '@vielzeug/vault/sqlite';\n\nconst store = createSQLite({\n database: new DatabaseSync(':memory:'),\n name: 'tests',\n schema: { users: table<{ id: number; name: string }>('id') },\n codecs: { users: UserSchema },\n});\n```\n\nNode `DatabaseSync`, Bun `Database`, and Deno `jsr:@db/sqlite` `Database` satisfy the protocol. Codec output may be any JSON-compatible value. During a `batch()` callback, calls on every Vault store sharing that connection reject; use `tx.*` instead.\n\n## Store Capabilities\n\n### `KeyValueVaultStore`\n\n```ts\ninterface KeyValueVaultStore<S extends AnySchema> {\n clear(table): Promise<void>;\n count(table): Promise<number>;\n delete(table, key): Promise<boolean>;\n deleteMany(table, keys): Promise<number>;\n get(table, key): Promise<Record | undefined>;\n getAll(table): Promise<Record[]>;\n getMany(table, keys): Promise<(Record | undefined)[]>;\n has(table, key): Promise<boolean>;\n isEmpty(table): Promise<boolean>;\n keys(table, filter?): Promise<VaultKey[]>;\n update(table, key, changes, ttl?): Promise<Record | undefined>;\n upsert(table, key, update, ttl?): Promise<Record>;\n observe(table, listener, options?): Unsubscribe;\n pruneExpired(): Promise<Record<string, number>>;\n put<K extends keyof S & string>(table: K, value: RecordOf<S, K>, ttl?: number): Promise<RecordOf<S, K>>;\n putAll(table, values, ttl?): Promise<void>;\n readonly disposalSignal: AbortSignal;\n dispose(): Promise<void>;\n readonly disposed: boolean;\n [Symbol.asyncDispose](): Promise<void>;\n}\n```\n\nThe portable key-value store API is returned by Memory and Web Storage factories. `put()`, `update()`, and `upsert()` return the canonical value produced by codec validation. `observe()` emits the current table snapshot by default and then emits after mutations.\n\n---\n\n### `DocumentVaultStore`\n\n```ts\ninterface DocumentVaultStore<S extends AnySchema> extends KeyValueVaultStore<S> {\n batch<K extends keyof S & string, R>(\n tables: readonly K[],\n fn: (tx: TransactionContext<S, K>) => Promise<R>,\n ): Promise<R>;\n iterate<K extends keyof S & string>(table: K): AsyncIterable<RecordOf<S, K>>;\n}\n```\n\n`batch()` runs a scoped atomic callback. `iterate()` lazily yields table records — IndexedDB uses a cursor, SQLite uses keyset pagination. Both are provided by `createIndexedDB()` and `createSQLite()`.\n\n| Parameter | Description |\n| --- | --- |\n| `tables` | Tables the transaction may access |\n| `fn` | Async callback that uses only the supplied `tx` context |\n\n**Returns:** The callback result after commit.\n\n```ts\nawait store.batch(['users'], async (tx) => {\n await tx.put('users', { id: 1, name: 'Ada' });\n});\n\nfor await (const user of store.iterate('users')) console.log(user);\n```\n\n## Bound Helpers\n\nConvenience operations are bound to every store and transaction context. For filtering, ordering, and pagination, compose over `getAll()` — it returns a plain array — or use `iterate()` on document stores and `getAllByIndex()` on IndexedDB for declared equality indexes.\n\n| Helper | Signature | Description |\n| --- | --- | --- |\n| `has()` | `(table, key) => Promise<boolean>` | Check whether a record exists |\n| `count()` | `(table) => Promise<number>` | Count live records |\n| `isEmpty()` | `(table) => Promise<boolean>` | Check whether a table has no live records |\n| `getMany()` | `(table, keys) => Promise<(T \\| undefined)[]>` | Fetch multiple records by key |\n| `keys()` | `(table, filter?) => Promise<VaultKey[]>` | Fetch live primary keys, optionally filtered by record |\n| `deleteMany()` | `(table, keys) => Promise<number>` | Delete multiple records, return count |\n| `update()` | `(table, key, changes, ttl?) => Promise<T \\| undefined>` | Partially update an existing record |\n| `upsert()` | `(table, key, fn, ttl?) => Promise<T>` | Read-modify-write with callback |\n\n```ts\nconst exists = await store.has('users', 1);\nconst total = await store.count('users');\nconst [a, b] = await store.getMany('users', [1, 2]);\nconst updated = await store.update('users', 1, { name: 'Alice' });\nconst result = await store.upsert('users', 99, (existing) => ({ id: 99, name: existing?.name ?? 'Guest' }));\n\nconst all = await store.getAll('users');\nconst active = all.filter((user) => user.status === 'active').sort((a, b) => a.name.localeCompare(b.name)).slice(0, 20);\n```\n\nBound `update()` and `upsert()` are atomic on document stores. Their key-value-store forms are non-atomic convenience operations.\n\n## Types\n\n```ts\ntype VaultKey = number | string;\ntype Unsubscribe = () => void;\ntype Observer<T> = (records: T[]) => void;\ntype AnySchema = Record<string, {\n defaultTtl?: number;\n indexes?: readonly string[];\n key: string;\n}>;\ntype SchemaEntry<T extends object, Key extends keyof T & string = keyof T & string> = {\n defaultTtl?: number;\n indexes?: readonly (keyof T & string)[];\n key: Key;\n};\ntype RecordOf<S extends AnySchema, K extends keyof S> =\n S[K] extends SchemaEntry<infer R, infer _Key> ? R : never;\ntype KeyOf<S extends AnySchema, K extends keyof S> =\n Extract<S[K] extends SchemaEntry<infer R, infer Key> ? R[Key] : never, VaultKey>;\n```\n\n```ts\ntype RecordCodec<T> = {\n decode(value: unknown): T;\n encode(value: T): unknown;\n};\n\ntype RecordParser<T> = { parse(value: unknown): T };\ntype CodecInput<T> = RecordCodec<T> | RecordParser<T>;\n\ntype TableCodecs<S extends AnySchema> = {\n [K in keyof S]: CodecInput<RecordOf<S, K>>;\n};\n\ntype MemoryStoreOptions<S extends AnySchema> = {\n schema: S;\n codecs?: TableCodecs<S>;\n};\n\ntype DurableStoreOptions<S extends AnySchema> = {\n schema: S;\n codecs: TableCodecs<S>;\n};\n```\n\n```ts\ntype MigrationContext = {\n db: IDBDatabase;\n newVersion: number | null;\n oldVersion: number;\n tx: IDBTransaction;\n};\n\ntype MigrationFn = (ctx: MigrationContext) => void;\n```\n\nImport `MigrationContext` and `MigrationFn` from `@vielzeug/vault/indexeddb`.\n\n```ts\ntype SQLiteParameter = null | number | string;\n\ninterface SQLiteStatement {\n all(...parameters: SQLiteParameter[]): readonly Record<string, unknown>[];\n finalize?(): void;\n get(...parameters: SQLiteParameter[]): Record<string, unknown> | null | undefined;\n run(...parameters: SQLiteParameter[]): unknown;\n}\n\ninterface SQLiteDatabase {\n close?(): void;\n exec(sql: string): void;\n prepare(sql: string): SQLiteStatement;\n}\n\ntype SQLiteVaultOptions<S extends AnySchema> = DurableStoreOptions<S> & {\n closeOnDispose?: boolean;\n database: SQLiteDatabase;\n name: string;\n};\n```\n\n```ts\ninterface TransactionContext<S extends AnySchema, K extends keyof S & string = keyof S & string> {\n clear<T extends K>(table: T): Promise<void>;\n delete<T extends K>(table: T, key: KeyOf<S, T>): Promise<boolean>;\n get<T extends K>(table: T, key: KeyOf<S, T>): Promise<RecordOf<S, T> | undefined>;\n getAll<T extends K>(table: T): Promise<RecordOf<S, T>[]>;\n iterate<T extends K>(table: T): AsyncIterable<RecordOf<S, T>>;\n put<T extends K>(table: T, value: RecordOf<S, T>, ttl?: number): Promise<RecordOf<S, T>>;\n putAll<T extends K>(table: T, values: RecordOf<S, T>[], ttl?: number): Promise<void>;\n}\n```\n\n`TransactionContext` has core CRUD, `iterate()`, and TTL methods, narrowed to the tables declared in `batch()`. Import it from `@vielzeug/vault/indexeddb` or `@vielzeug/vault/sqlite`.\n\n## Errors\n\n| Error | Trigger |\n| --- | --- |\n| `VaultError` | Any Vault-originated validation, serialization, storage, or codec error |\n| `VaultDisposedError` | An operation after the store or observer hub is disposed |\n| `VaultScopeError` | A `batch()` callback accesses a table outside its declared scope |\n| `VaultQuotaError` | A LocalStorage or SessionStorage write exceeds the browser quota |\n| `VaultMigrationError` | An IndexedDB migration callback throws |\n\nEvery listed error extends `VaultError`.\n",
|
|
6
|
-
"usage": "---\ntitle: Vault — Usage Guide\ndescription: Persist typed browser or SQLite data, observe table snapshots, and use atomic transactions with required durable codecs.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCreate a key-value store with a schema and codecs, then write a typed row.\n\n```ts\nimport { s } from '@vielzeug/spell';\nimport { table } from '@vielzeug/vault';\nimport { createLocalStorage } from '@vielzeug/vault/local-storage';\n\ninterface Preference {\n id: string;\n theme: 'dark' | 'light';\n}\n\nconst PreferenceSchema = s.object({ id: s.string(), theme: s.union('dark', 'light') });\nconst store = createLocalStorage({\n name: 'app-v2',\n schema: { preferences: table<Preference>('id') },\n codecs: { preferences: PreferenceSchema },\n});\n\nawait store.put('preferences', { id: 'theme', theme: 'dark' });\nconsole.log(await store.get('preferences', 'theme'));\n```\n\n## Create a Key-Value Store\n\nLocalStorage and SessionStorage return `KeyValueVaultStore`; Memory returns a `DocumentVaultStore` (it adds `batch()` and `iterate()`). They share portable string/number keys, bound helpers, fluent queries, TTL, and `observe()`. Memory may omit codecs; Web Storage requires them. Malformed storage envelopes are evicted lazily, while codec validation failures are surfaced without deleting persisted data.\n\nThe root entry is adapter-free. Import `createMemory` from `@vielzeug/vault/memory`, `createLocalStorage` from `@vielzeug/vault/local-storage`, or `createSessionStorage` from `@vielzeug/vault/session-storage`. Import each adapter from its focused subpath so unused backends stay out of the bundle.\n\nUse a new storage name when upgrading from Vault 1. Old key and envelope formats are not read by Vault 2.\n\n```ts\nconst store = createLocalStorage({\n name: 'app-v2',\n schema: { preferences: table<Preference>('id') },\n codecs: { preferences: PreferenceSchema },\n});\n```\n\n## Read and Change Records\n\nUse bound `update()` and `upsert()` methods for read-modify-write operations; no helper imports are required.\n\n```ts\nconst updated = await store.update('preferences', 'theme', { theme: 'light' });\n\nawait store.upsert('preferences', 'locale', (current) => ({\n id: 'locale',\n theme: current?.theme ?? 'dark',\n}));\n\nconsole.log(updated);\n```\n\n`update()` returns `undefined` for a missing key. `upsert()` always writes the record returned by its callback. Document-store methods run atomically; key-value-store methods are non-atomic conveniences when multiple writers race.\n\n## Iterate and Filter Records\n\nFilter the plain array returned by `getAll()` for in-memory selection, sorting, and pagination. Use `iterate()` on a `DocumentVaultStore` for lazy cursor-based traversal, and `getAllByIndex()` on IndexedDB for declared equality indexes.\n\n```ts\n// In-memory selection over getAll()\nconst themed = (await store.getAll('preferences')).filter((preference) => preference.id.startsWith('theme'));\n\n// Document store: iterate lazily\nfor await (const pref of db.iterate('preferences')) {\n if (pref.id.startsWith('theme')) console.log(pref);\n}\n```\n\nFor large tables, prefer `iterate()` on IndexedDB or SQLite instead of materializing every record with `getAll()`.\n\n## Use TTL and Pruning\n\nUse `ttl.*` helpers for expiring rows. Call `pruneExpired()` to reclaim storage from stale rows that accumulate without reads; tables with removed rows publish fresh observer snapshots.\n\n```ts\nimport { ttl } from '@vielzeug/vault';\n\nawait store.put('preferences', { id: 'temporary', theme: 'dark' }, ttl.hours(1));\n\n// Reclaim expired rows on a schedule owned by the application.\nconst pruneInterval = setInterval(() => store.pruneExpired(), ttl.hours(6));\nstore.disposalSignal.addEventListener('abort', () => clearInterval(pruneInterval));\n```\n\n## Observe a Table\n\nUse `observe()` for current and future snapshots. Notifications are revision-ordered, and explicit TTL pruning publishes fresh snapshots for affected tables. Tie subscription lifetime to an `AbortSignal` when a component or request owns it.\n\n```ts\nconst controller = new AbortController();\n\nstore.observe('preferences', (preferences) => {\n console.log(preferences);\n}, { signal: controller.signal });\n\ncontroller.abort();\n```\n\n## Use IndexedDB for Browser Transactions\n\nChoose IndexedDB when browser storage needs multiple writes to commit together or cursor iteration.\n\n```ts\nimport { s } from '@vielzeug/spell';\nimport { table } from '@vielzeug/vault';\nimport { createIndexedDB } from '@vielzeug/vault/indexeddb';\n\nconst EventSchema = s.object({ id: s.number(), type: s.string() });\nconst db = createIndexedDB({\n name: 'app-v2',\n schema: { events: table<{ id: number; type: string }>('id') },\n codecs: { events: EventSchema },\n});\n\nawait db.batch(['events'], async (tx) => {\n await tx.put('events', { id: 1, type: 'opened' });\n await tx.put('events', { id: 2, type: 'saved' });\n});\n```\n\nTransaction contexts expose the same bound helpers; `tx.update()`, `tx.upsert()`, and `tx.deleteMany()` run inside the enclosing transaction. Only await `tx.*` operations inside a batch callback. Do not retain the transaction context after the callback; later use throws `VaultScopeError`. Do not await timers, fetches, or other external asynchronous work because IndexedDB can commit an inactive transaction.\n\n## Use SQLite Outside the Browser\n\nImport SQLite from the opt-in subpath so the browser root stays free of runtime drivers. Vault never opens a connection or configures its SQLite process behavior for you.\n\n```ts\nimport { DatabaseSync } from 'node:sqlite';\n\nimport { s } from '@vielzeug/spell';\nimport { table } from '@vielzeug/vault';\nimport { createSQLite } from '@vielzeug/vault/sqlite';\n\nconst EventSchema = s.object({ id: s.number(), type: s.string() });\nconst database = new DatabaseSync('app.db', { timeout: 5_000 });\nconst store = createSQLite({\n database,\n name: 'app-v2',\n schema: { events: table<{ id: number; type: string }>('id') },\n codecs: { events: EventSchema },\n});\n\nawait store.batch(['events'], async (tx) => {\n await tx.put('events', { id: 1, type: 'opened' });\n await tx.put('events', { id: 2, type: 'saved' });\n});\n```\n\nNode's `node:sqlite` API is experimental. Bun's `bun:sqlite` `Database` satisfies the same positional `exec()` and `prepare()` contract; configure WAL from your application when the deployment needs it. Deno does not include SQLite, but `jsr:@db/sqlite`'s `Database` satisfies the same contract when its FFI, filesystem, and environment permissions are granted.\n\nSQLite stores serialize all access through the injected connection. `batch()` starts `BEGIN IMMEDIATE` and rolls back callback failures. While its callback runs, calls on any store sharing that connection reject rather than waiting behind the transaction; use `tx.*` instead. The underlying drivers are synchronous, so move large scans and writes to a worker or isolate when event-loop latency matters.\n\n## Store SQLite Values and Observe Changes\n\nSQLite accepts any JSON-compatible codec output. Circular values, `bigint`, class instances, functions, and non-finite numbers must be transformed by the codec before writing. Number and string primary keys remain distinct.\n\n`observe()` sees mutations written through Vault stores sharing the same injected connection after a commit. It cannot detect direct SQL changes, writes from another process, or writes through another connection. The connection belongs to the caller by default; use `closeOnDispose: true` only when the store owns it.\n\n## Handle IndexedDB Schema Migrations\n\nDeclare IndexedDB indexes in the schema. Codecs must preserve each indexed field name and value in their encoded object. Use `migrate` only for IndexedDB version upgrades and mirror Vault's fixed `value.<field>` index path.\n\n```ts\nimport { s } from '@vielzeug/spell';\nimport { table } from '@vielzeug/vault';\nimport { createIndexedDB, type MigrationFn } from '@vielzeug/vault/indexeddb';\n\nconst UserSchema = s.object({ id: s.number(), name: s.string() });\nconst schema = { users: table<{ id: number; name: string }>('id', { indexes: ['name'] }) };\nconst migrate: MigrationFn = ({ db, oldVersion, tx }) => {\n if (oldVersion < 2 && db.objectStoreNames.contains('users')) {\n tx.objectStore('users').createIndex('name', 'value.name');\n }\n};\n\ncreateIndexedDB({\n name: 'app-v2',\n migrate,\n schema,\n version: 2,\n codecs: { users: UserSchema },\n});\n```\n\n## Framework Integration\n\n::: code-group\n\n```ts [React]\nimport { useEffect, useState } from 'react';\n\nimport type { AnySchema, KeyValueVaultStore, RecordOf } from '@vielzeug/vault';\n\nexport function useTable<S extends AnySchema, K extends keyof S & string>(store: KeyValueVaultStore<S>, table: K) {\n const [rows, setRows] = useState<RecordOf<S, K>[]>([]);\n\n useEffect(() => store.observe(table, setRows), [store, table]);\n return rows;\n}\n```\n\n```ts [Vue 3]\nimport { onUnmounted, shallowRef } from 'vue';\n\nimport type { AnySchema, KeyValueVaultStore, RecordOf } from '@vielzeug/vault';\n\nexport function useTable<S extends AnySchema, K extends keyof S & string>(store: KeyValueVaultStore<S>, table: K) {\n const rows = shallowRef<RecordOf<S, K>[]>([]);\n const stop = store.observe(table, (next) => (rows.value = next));\n\n onUnmounted(stop);\n return rows;\n}\n```\n\n```ts [Svelte]\nimport { readable } from 'svelte/store';\n\nimport type { AnySchema, KeyValueVaultStore, RecordOf } from '@vielzeug/vault';\n\nexport function tableStore<S extends AnySchema, K extends keyof S & string>(store: KeyValueVaultStore<S>, table: K) {\n return readable<RecordOf<S, K>[]>([], (set) => store.observe(table, set));\n}\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\nKeep Ripple signals as application state and persist selected changes through Vault writes.\n\n## Best Practices\n\n- Define one schema per storage namespace.\n- Use string or finite-number primary keys only.\n- Provide codecs for every durable adapter; pass Spell or another parser schema directly when identity encoding is sufficient.\n- Use `observe()` for table snapshots.\n- Use IndexedDB or SQLite for atomic work.\n- Keep external asynchronous work outside `batch()` callbacks.\n- Use `ttl.*` instead of raw durations.\n- Keep SQLite scans and writes off latency-sensitive event loops.\n- Dispose stores when their owner ends.\n",
|
|
7
|
-
"examples": "---\ntitle: Vault
|
|
4
|
+
"index": "---\ntitle: 'Vault: Typed storage'\ndescription: Typed browser storage and opt-in driver-neutral SQLite with portable keys, TTL, observation, and transactions.\npackage: vault\ncategory: Storage\nkeywords: [storage, indexeddb, localstorage, sessionstorage, sqlite, ttl, browser, node, deno]\nrelated: [courier, forge, ripple]\nexports: [table, ttl, validatorCodec, createMemory, createLocalStorage, createSessionStorage, createIndexedDB, createSQLite]\nenvironments: [browser, node, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"vault\" />\n\n## Why Vault?\n\nVault gives browser and SQLite persistence one typed schema while keeping backend guarantees explicit. Use `KeyValueVaultStore` for portable CRUD and observation; choose IndexedDB or the opt-in SQLite subpath when you need atomic transactions or lazy iteration. Durable adapters require codecs so invalid stored data never enters typed code.\n\n```ts\n// Before\nlocalStorage.setItem('theme', JSON.stringify({ value: 'dark' }));\nconst theme = JSON.parse(localStorage.getItem('theme') ?? '{}').value;\n\n// After\nawait store.put('preferences', { id: 'theme', value: 'dark' });\nconst theme = await store.get('preferences', 'theme');\n```\n\n| Feature | Vault | Raw Web Storage | Dexie |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"vault\" type=\"size\" /> | Browser built-in | Extra dependency |\n| Runtime dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Typed schema and keys | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Application-defined | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Portable key-value store API | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> | IndexedDB only |\n| Explicit atomic transactions | IndexedDB + SQLite capability | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Required durable codecs | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Driver-neutral SQLite | Opt-in subpath | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Vault when** you need typed browser persistence or application-owned SQLite with honest storage capabilities and required durable codecs.\n\n**Consider raw Web Storage when** you only persist one or two unstructured values. **Consider Dexie when** you need a broader IndexedDB ecosystem.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/vault\n```\n\n```sh [npm]\nnpm install @vielzeug/vault\n```\n\n```sh [yarn]\nyarn add @vielzeug/vault\n```\n\n:::\n\n## Quick Start\n\nDefine a schema, create a key-value store with codecs, and dispose it with its owner.\n\n```ts\nimport { s } from '@vielzeug/spell';\nimport { table } from '@vielzeug/vault';\nimport { createLocalStorage } from '@vielzeug/vault/local-storage';\n\nconst PreferenceSchema = s.object({ id: s.string(), theme: s.union('dark', 'light') });\nconst store = createLocalStorage({\n name: 'app-v2',\n schema: { preferences: table<{ id: string; theme: 'dark' | 'light' }>('id') },\n codecs: { preferences: PreferenceSchema },\n});\n\ntry {\n await store.put('preferences', { id: 'theme', theme: 'dark' });\n console.log(await store.get('preferences', 'theme'));\n} finally {\n await store.dispose();\n}\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `table()` defines typed records with portable string or number keys.\n- Spell and other parser schemas work directly as codecs; `validatorCodec()` creates an explicit identity-encoding codec.\n- `/memory` returns a portable `DocumentVaultStore` with `batch()` and `iterate()`: codecs optional (in-memory, no trust boundary).\n- `/local-storage` and `/session-storage` return `KeyValueVaultStore`: codecs required.\n- `observe()` emits current and changed table snapshots.\n- `ttl` creates validated expiration durations.\n- `/indexeddb` and `/sqlite` return `DocumentVaultStore` with `batch()` and `iterate()`: codecs required. `/memory` shares the same contract without codecs.\n- `/indexeddb` accepts a `migrate` hook for schema upgrades the table schema cannot express.\n- `pruneExpired()` removes stale TTL entries on demand.\n- Bound helpers (`has`, `count`, `update`, `upsert`, `getMany`, `keys`, `deleteMany`) are available on every store and transaction context.\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Forge](../forge/index.md) saves and restores form drafts through Vault stores.\n- [Ripple](../ripple/index.md) owns application state that can persist through Vault.\n- [Courier](../courier/index.md) can populate persistent cache data.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
|
+
"api": "---\ntitle: 'Vault: API Reference'\ndescription: Reference for Vault schemas, adapter entry points, storage capabilities, codecs, SQLite drivers, and errors.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createMemory()` | In-memory document store | Async API | Import from `/memory`; codecs optional |\n| `createLocalStorage()` / `createSessionStorage()` | Web Storage-backed key-value stores | Async API | Codecs required; available only where the corresponding Web API exists |\n| `createIndexedDB()` | Browser document store with transactions and cursor iteration | Async API | Import from `/indexeddb`; codecs required |\n| `createSQLite()` | Driver-neutral SQLite document store | Async API over a synchronous driver | Import from `/sqlite`; codecs required |\n| `table()` | Typed record schema | Sync | The key field must be a string or finite number |\n| `validatorCodec()` | Create an identity-encoding codec from a parser | Sync | Spell schemas can also be passed directly |\n| `ttl` | Valid expiration durations | Sync | Durations must be positive |\n\n## Package Entry Points\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/vault` | Adapter-free schemas, TTL, errors, codecs, derived helpers, and shared types |\n| `@vielzeug/vault/memory` | `createMemory` |\n| `@vielzeug/vault/local-storage` | `createLocalStorage` |\n| `@vielzeug/vault/session-storage` | `createSessionStorage` |\n| `@vielzeug/vault/indexeddb` | `createIndexedDB`, the `migrate` upgrade-hook types, and IndexedDB-only types |\n| `@vielzeug/vault/sqlite` | `createSQLite`, the SQLite driver protocol types, and `TransactionContext` |\n\n## Schemas, Codecs, and TTL\n\n### `table()`\n\n```ts\nfunction table<T extends object, Key extends keyof T & string = keyof T & string>(\n key: Key & (T[Key] extends VaultKey ? unknown : never),\n options?: { defaultTtl?: number; indexes?: readonly (keyof T & string)[] },\n): SchemaEntry<T, Key>;\n```\n\nDefines a typed table and its primary-key field.\n\n| Parameter | Description |\n| --- | --- |\n| `key` | A record field whose values are `string` or finite `number` keys |\n| `options.defaultTtl` | Per-table default TTL in milliseconds |\n| `options.indexes` | IndexedDB secondary index fields |\n\n**Returns:** A `SchemaEntry` describing the table.\n\n```ts\nimport { table, ttl } from '@vielzeug/vault';\n\nconst users = table<{ id: number; email: string }>('id', {\n indexes: ['email'],\n defaultTtl: ttl.days(7),\n});\n```\n\n---\n\n### `validatorCodec()`\n\n```ts\nfunction validatorCodec<T>(validator: { parse(value: unknown): T }): RecordCodec<T>;\n```\n\nWraps any object with a `parse(value): T` method as an identity-encoding `RecordCodec`. Parser schemas can also be passed directly in `codecs`; a Spell schema requires no adapter.\n\n**Returns:** A `RecordCodec<T>`.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst UserSchema = s.object({ id: s.number(), name: s.string() });\nconst store = createLocalStorage({\n name: 'app',\n schema: { users: table<{ id: number; name: string }>('id') },\n codecs: { users: UserSchema },\n});\n```\n\nCustom `RecordCodec` values validate writes by round-tripping `decode(encode(value))`. IndexedDB codecs must preserve declared indexed field names and values in their encoded object.\n\n---\n\n### `ttl`\n\n```ts\nconst ttl: {\n days(n: number): number;\n hours(n: number): number;\n minutes(n: number): number;\n ms(n: number): number;\n seconds(n: number): number;\n};\n```\n\nCreates a finite, positive duration in milliseconds for writes and table defaults.\n\n**Returns:** `number`.\n\n```ts\nimport { ttl } from '@vielzeug/vault';\n\nconst cacheLifetime = ttl.minutes(5);\n```\n\n## Factories\n\nDurable factories (LocalStorage, SessionStorage, IndexedDB, SQLite) require `codecs`. Memory may omit them.\n\n### `createMemory()`\n\n```ts\nfunction createMemory<S extends AnySchema>(options: MemoryStoreOptions<S>): DocumentVaultStore<S>;\n```\n\nCreates an in-memory document store with `batch()` and `iterate()`. `batch()` snapshots the declared tables and restores them when the callback throws. Codecs are optional because memory values never cross a trust boundary.\n\n| Parameter | Description |\n| --- | --- |\n| `schema` | Tables created by `table()` |\n| `codecs` | Optional per-table codecs |\n\n**Returns:** `DocumentVaultStore<S>`.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createMemory } from '@vielzeug/vault/memory';\n\nconst store = createMemory({ schema: { users: table<{ id: number; name: string }>('id') } });\n```\n\n---\n\n### `createLocalStorage()`\n\n```ts\nfunction createLocalStorage<S extends AnySchema>(options: DurableStoreOptions<S> & {\n name: string;\n}): KeyValueVaultStore<S>;\n```\n\nCreates a namespaced `localStorage` store. Codecs are required.\n\n| Parameter | Description |\n| --- | --- |\n| `schema` | Tables created by `table()` |\n| `codecs` | Required per-table codecs |\n| `name` | Required storage namespace |\n\nA write that exceeds the browser quota rejects with `VaultQuotaError`.\n\n**Returns:** `KeyValueVaultStore<S>`.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createLocalStorage } from '@vielzeug/vault/local-storage';\n\nconst store = createLocalStorage({\n name: 'app',\n schema: { settings: table<{ id: string }>('id') },\n codecs: { settings: SettingsSchema },\n});\n```\n\n---\n\n### `createSessionStorage()`\n\n```ts\nfunction createSessionStorage<S extends AnySchema>(options: DurableStoreOptions<S> & {\n name: string;\n}): KeyValueVaultStore<S>;\n```\n\nCreates a namespaced `sessionStorage` store. Its options and return type match `createLocalStorage()`.\n\n**Returns:** `KeyValueVaultStore<S>`.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createSessionStorage } from '@vielzeug/vault/session-storage';\n\nconst store = createSessionStorage({\n name: 'checkout',\n schema: { cart: table<{ id: string }>('id') },\n codecs: { cart: CartSchema },\n});\n```\n\n---\n\n### `createIndexedDB()`\n\n```ts\nfunction createIndexedDB<S extends AnySchema>(options: DurableStoreOptions<S> & {\n migrate?: MigrationFn;\n name: string;\n version?: number;\n}): IndexedDbVaultStore<S>;\n```\n\nCreates an IndexedDB document store with atomic batches, lazy cursor iteration, and optional schema migrations. Codecs are required.\n\n| Parameter | Description |\n| --- | --- |\n| `schema` | Tables and IndexedDB secondary indexes |\n| `codecs` | Required per-table codecs |\n| `name` | Required database name |\n| `version` | Positive schema version; defaults to `1` |\n| `migrate` | Synchronous `onupgradeneeded` callback. Object stores and schema-declared indexes are created automatically; use the hook only for deletes or record transforms |\n\n**Returns:** `IndexedDbVaultStore<S>`. Its `getAllByIndex(table, field, value)` method queries a declared IndexedDB index without scanning the full table.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createIndexedDB } from '@vielzeug/vault/indexeddb';\n\nconst store = createIndexedDB({\n name: 'app',\n schema: { users: table<{ id: number; role: string }>('id', { indexes: ['role'] }) },\n codecs: { users: UserSchema },\n});\n\nconst admins = await store.getAllByIndex('users', 'role', 'admin');\n```\n\n---\n\n### `createSQLite()`\n\n```ts\nfunction createSQLite<S extends AnySchema>(options: SQLiteVaultOptions<S>): DocumentVaultStore<S>;\n```\n\nCreates a namespaced SQLite document store with atomic batches and keyset-paginated iteration. Codecs are required. It accepts an application-provided positional-parameter driver and never opens or imports a runtime driver.\n\n| Parameter | Description |\n| --- | --- |\n| `schema` | Tables created by `table()` |\n| `codecs` | Required per-table codecs |\n| `database` | Caller-provided `SQLiteDatabase` connection |\n| `name` | Namespace within the connection |\n| `closeOnDispose` | Closes the connection during disposal; defaults to `false` |\n\n**Returns:** `DocumentVaultStore<S>`.\n\n```ts\nimport { DatabaseSync } from 'node:sqlite';\n\nimport { table } from '@vielzeug/vault';\nimport { createSQLite } from '@vielzeug/vault/sqlite';\n\nconst store = createSQLite({\n database: new DatabaseSync(':memory:'),\n name: 'tests',\n schema: { users: table<{ id: number; name: string }>('id') },\n codecs: { users: UserSchema },\n});\n```\n\nNode `DatabaseSync`, Bun `Database`, and Deno `jsr:@db/sqlite` `Database` satisfy the protocol. Codec output may be any JSON-compatible value. During a `batch()` callback, calls on every Vault store sharing that connection reject; use `tx.*` instead.\n\n## Store Capabilities\n\n### `KeyValueVaultStore`\n\n```ts\ninterface KeyValueVaultStore<S extends AnySchema> {\n clear(table): Promise<void>;\n count(table): Promise<number>;\n delete(table, key): Promise<boolean>;\n deleteMany(table, keys): Promise<number>;\n get(table, key): Promise<Record | undefined>;\n getAll(table): Promise<Record[]>;\n getMany(table, keys): Promise<(Record | undefined)[]>;\n has(table, key): Promise<boolean>;\n isEmpty(table): Promise<boolean>;\n keys(table, filter?): Promise<VaultKey[]>;\n update(table, key, changes, ttl?): Promise<Record | undefined>;\n upsert(table, key, update, ttl?): Promise<Record>;\n observe(table, listener, options?): Unsubscribe;\n pruneExpired(): Promise<Record<string, number>>;\n put<K extends keyof S & string>(table: K, value: RecordOf<S, K>, ttl?: number): Promise<RecordOf<S, K>>;\n putAll(table, values, ttl?): Promise<void>;\n readonly disposalSignal: AbortSignal;\n dispose(): Promise<void>;\n readonly disposed: boolean;\n [Symbol.asyncDispose](): Promise<void>;\n}\n```\n\nThe portable key-value store API is returned by Memory and Web Storage factories. `put()`, `update()`, and `upsert()` return the canonical value produced by codec validation. `observe()` emits the current table snapshot by default and then emits after mutations.\n\n---\n\n### `DocumentVaultStore`\n\n```ts\ninterface DocumentVaultStore<S extends AnySchema> extends KeyValueVaultStore<S> {\n batch<K extends keyof S & string, R>(\n tables: readonly K[],\n fn: (tx: TransactionContext<S, K>) => Promise<R>,\n ): Promise<R>;\n iterate<K extends keyof S & string>(table: K): AsyncIterable<RecordOf<S, K>>;\n}\n```\n\n`batch()` runs a scoped atomic callback. `iterate()` lazily yields table records: IndexedDB uses a cursor, SQLite uses keyset pagination. Both are provided by `createIndexedDB()` and `createSQLite()`.\n\n| Parameter | Description |\n| --- | --- |\n| `tables` | Tables the transaction may access |\n| `fn` | Async callback that uses only the supplied `tx` context |\n\n**Returns:** The callback result after commit.\n\n```ts\nawait store.batch(['users'], async (tx) => {\n await tx.put('users', { id: 1, name: 'Ada' });\n});\n\nfor await (const user of store.iterate('users')) console.log(user);\n```\n\n## Bound Helpers\n\nConvenience operations are bound to every store and transaction context. For filtering, ordering, and pagination, compose over `getAll()`: it returns a plain array, or use `iterate()` on document stores and `getAllByIndex()` on IndexedDB for declared equality indexes.\n\n| Helper | Signature | Description |\n| --- | --- | --- |\n| `has()` | `(table, key) => Promise<boolean>` | Check whether a record exists |\n| `count()` | `(table) => Promise<number>` | Count live records |\n| `isEmpty()` | `(table) => Promise<boolean>` | Check whether a table has no live records |\n| `getMany()` | `(table, keys) => Promise<(T \\| undefined)[]>` | Fetch multiple records by key |\n| `keys()` | `(table, filter?) => Promise<VaultKey[]>` | Fetch live primary keys, optionally filtered by record |\n| `deleteMany()` | `(table, keys) => Promise<number>` | Delete multiple records, return count |\n| `update()` | `(table, key, changes, ttl?) => Promise<T \\| undefined>` | Partially update an existing record |\n| `upsert()` | `(table, key, fn, ttl?) => Promise<T>` | Read-modify-write with callback |\n\n```ts\nconst exists = await store.has('users', 1);\nconst total = await store.count('users');\nconst [a, b] = await store.getMany('users', [1, 2]);\nconst updated = await store.update('users', 1, { name: 'Alice' });\nconst result = await store.upsert('users', 99, (existing) => ({ id: 99, name: existing?.name ?? 'Guest' }));\n\nconst all = await store.getAll('users');\nconst active = all.filter((user) => user.status === 'active').sort((a, b) => a.name.localeCompare(b.name)).slice(0, 20);\n```\n\nBound `update()` and `upsert()` are atomic on document stores. Their key-value-store forms are non-atomic convenience operations.\n\n## Types\n\n```ts\ntype VaultKey = number | string;\ntype Unsubscribe = () => void;\ntype Observer<T> = (records: T[]) => void;\ntype AnySchema = Record<string, {\n defaultTtl?: number;\n indexes?: readonly string[];\n key: string;\n}>;\ntype SchemaEntry<T extends object, Key extends keyof T & string = keyof T & string> = {\n defaultTtl?: number;\n indexes?: readonly (keyof T & string)[];\n key: Key;\n};\ntype RecordOf<S extends AnySchema, K extends keyof S> =\n K extends unknown\n ? S[K] extends SchemaEntry<infer R, infer _Key>\n ? R\n : never\n : never;\ntype KeyOf<S extends AnySchema, K extends keyof S> =\n K extends unknown\n ? Extract<S[K] extends SchemaEntry<infer R, infer Key> ? R[Key] : never, VaultKey>\n : never;\n```\n\n```ts\ntype RecordCodec<T> = {\n decode(value: unknown): T;\n encode(value: T): unknown;\n};\n\ntype RecordParser<T> = { parse(value: unknown): T };\ntype CodecInput<T> = RecordCodec<T> | RecordParser<T>;\n\ntype TableCodecs<S extends AnySchema> = {\n [K in keyof S]: CodecInput<RecordOf<S, K>>;\n};\n\ntype MemoryStoreOptions<S extends AnySchema> = {\n schema: S;\n codecs?: TableCodecs<S>;\n};\n\ntype DurableStoreOptions<S extends AnySchema> = {\n schema: S;\n codecs: TableCodecs<S>;\n};\n```\n\n```ts\ntype MigrationContext = {\n db: IDBDatabase;\n newVersion: number | null;\n oldVersion: number;\n tx: IDBTransaction;\n};\n\ntype MigrationFn = (ctx: MigrationContext) => void;\n```\n\nImport `MigrationContext` and `MigrationFn` from `@vielzeug/vault/indexeddb`.\n\n```ts\ntype SQLiteParameter = null | number | string;\n\ninterface SQLiteStatement {\n all(...parameters: SQLiteParameter[]): readonly Record<string, unknown>[];\n finalize?(): void;\n get(...parameters: SQLiteParameter[]): Record<string, unknown> | null | undefined;\n run(...parameters: SQLiteParameter[]): unknown;\n}\n\ninterface SQLiteDatabase {\n close?(): void;\n exec(sql: string): void;\n prepare(sql: string): SQLiteStatement;\n}\n\ntype SQLiteVaultOptions<S extends AnySchema> = DurableStoreOptions<S> & {\n closeOnDispose?: boolean;\n database: SQLiteDatabase;\n name: string;\n};\n```\n\n```ts\ninterface TransactionContext<S extends AnySchema, K extends keyof S & string = keyof S & string> {\n clear<T extends K>(table: T): Promise<void>;\n delete<T extends K>(table: T, key: KeyOf<S, T>): Promise<boolean>;\n get<T extends K>(table: T, key: KeyOf<S, T>): Promise<RecordOf<S, T> | undefined>;\n getAll<T extends K>(table: T): Promise<RecordOf<S, T>[]>;\n iterate<T extends K>(table: T): AsyncIterable<RecordOf<S, T>>;\n put<T extends K>(table: T, value: RecordOf<S, T>, ttl?: number): Promise<RecordOf<S, T>>;\n putAll<T extends K>(table: T, values: RecordOf<S, T>[], ttl?: number): Promise<void>;\n}\n```\n\n`TransactionContext` has core CRUD, `iterate()`, and TTL methods, narrowed to the tables declared in `batch()`. Import it from `@vielzeug/vault/indexeddb` or `@vielzeug/vault/sqlite`.\n\n## Errors\n\n| Error | Trigger |\n| --- | --- |\n| `VaultError` | Any Vault-originated validation, serialization, storage, or codec error |\n| `VaultDisposedError` | An operation after the store or observer hub is disposed |\n| `VaultScopeError` | A `batch()` callback accesses a table outside its declared scope |\n| `VaultQuotaError` | A LocalStorage or SessionStorage write exceeds the browser quota |\n| `VaultMigrationError` | An IndexedDB migration callback throws |\n\nEvery listed error extends `VaultError`.\n",
|
|
6
|
+
"usage": "---\ntitle: 'Vault: Usage Guide'\ndescription: Persist typed browser or SQLite data, observe table snapshots, and use atomic transactions with required durable codecs.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCreate a key-value store with a schema and codecs, then write a typed row.\n\n```ts\nimport { s } from '@vielzeug/spell';\nimport { table } from '@vielzeug/vault';\nimport { createLocalStorage } from '@vielzeug/vault/local-storage';\n\ninterface Preference {\n id: string;\n theme: 'dark' | 'light';\n}\n\nconst PreferenceSchema = s.object({ id: s.string(), theme: s.union('dark', 'light') });\nconst store = createLocalStorage({\n name: 'app-v2',\n schema: { preferences: table<Preference>('id') },\n codecs: { preferences: PreferenceSchema },\n});\n\nawait store.put('preferences', { id: 'theme', theme: 'dark' });\nconsole.log(await store.get('preferences', 'theme'));\n```\n\n## Create a Key-Value Store\n\nLocalStorage and SessionStorage return `KeyValueVaultStore`; Memory returns a `DocumentVaultStore` (it adds `batch()` and `iterate()`). They share portable string/number keys, bound helpers, fluent queries, TTL, and `observe()`. Memory may omit codecs; Web Storage requires them. Malformed storage envelopes are evicted lazily, while codec validation failures are surfaced without deleting persisted data.\n\nThe root entry is adapter-free. Import `createMemory` from `@vielzeug/vault/memory`, `createLocalStorage` from `@vielzeug/vault/local-storage`, or `createSessionStorage` from `@vielzeug/vault/session-storage`. Import each adapter from its focused subpath so unused backends stay out of the bundle.\n\nUse a new storage name when upgrading from Vault 1. Old key and envelope formats are not read by Vault 2.\n\n```ts\nconst store = createLocalStorage({\n name: 'app-v2',\n schema: { preferences: table<Preference>('id') },\n codecs: { preferences: PreferenceSchema },\n});\n```\n\n## Read and Change Records\n\nUse bound `update()` and `upsert()` methods for read-modify-write operations; no helper imports are required.\n\n```ts\nconst updated = await store.update('preferences', 'theme', { theme: 'light' });\n\nawait store.upsert('preferences', 'locale', (current) => ({\n id: 'locale',\n theme: current?.theme ?? 'dark',\n}));\n\nconsole.log(updated);\n```\n\n`update()` returns `undefined` for a missing key. `upsert()` always writes the record returned by its callback. Document-store methods run atomically; key-value-store methods are non-atomic conveniences when multiple writers race.\n\n## Iterate and Filter Records\n\nFilter the plain array returned by `getAll()` for in-memory selection, sorting, and pagination. Use `iterate()` on a `DocumentVaultStore` for lazy cursor-based traversal, and `getAllByIndex()` on IndexedDB for declared equality indexes.\n\n```ts\n// In-memory selection over getAll()\nconst themed = (await store.getAll('preferences')).filter((preference) => preference.id.startsWith('theme'));\n\n// Document store: iterate lazily\nfor await (const pref of db.iterate('preferences')) {\n if (pref.id.startsWith('theme')) console.log(pref);\n}\n```\n\nFor large tables, prefer `iterate()` on IndexedDB or SQLite instead of materializing every record with `getAll()`.\n\n## Use TTL and Pruning\n\nUse `ttl.*` helpers for expiring rows. Call `pruneExpired()` to reclaim storage from stale rows that accumulate without reads; tables with removed rows publish fresh observer snapshots.\n\n```ts\nimport { ttl } from '@vielzeug/vault';\n\nawait store.put('preferences', { id: 'temporary', theme: 'dark' }, ttl.hours(1));\n\n// Reclaim expired rows on a schedule owned by the application.\nconst pruneInterval = setInterval(() => store.pruneExpired(), ttl.hours(6));\nstore.disposalSignal.addEventListener('abort', () => clearInterval(pruneInterval));\n```\n\n## Observe a Table\n\nUse `observe()` for current and future snapshots. Notifications are revision-ordered, and explicit TTL pruning publishes fresh snapshots for affected tables. Tie subscription lifetime to an `AbortSignal` when a component or request owns it.\n\n```ts\nconst controller = new AbortController();\n\nstore.observe('preferences', (preferences) => {\n console.log(preferences);\n}, { signal: controller.signal });\n\ncontroller.abort();\n```\n\n## Use IndexedDB for Browser Transactions\n\nChoose IndexedDB when browser storage needs multiple writes to commit together or cursor iteration.\n\n```ts\nimport { s } from '@vielzeug/spell';\nimport { table } from '@vielzeug/vault';\nimport { createIndexedDB } from '@vielzeug/vault/indexeddb';\n\nconst EventSchema = s.object({ id: s.number(), type: s.string() });\nconst db = createIndexedDB({\n name: 'app-v2',\n schema: { events: table<{ id: number; type: string }>('id') },\n codecs: { events: EventSchema },\n});\n\nawait db.batch(['events'], async (tx) => {\n await tx.put('events', { id: 1, type: 'opened' });\n await tx.put('events', { id: 2, type: 'saved' });\n});\n```\n\nTransaction contexts expose the same bound helpers; `tx.update()`, `tx.upsert()`, and `tx.deleteMany()` run inside the enclosing transaction. Only await `tx.*` operations inside a batch callback. Do not retain the transaction context after the callback; later use throws `VaultScopeError`. Do not await timers, fetches, or other external asynchronous work because IndexedDB can commit an inactive transaction.\n\n## Use SQLite Outside the Browser\n\nImport SQLite from the opt-in subpath so the browser root stays free of runtime drivers. Vault never opens a connection or configures its SQLite process behavior for you.\n\n```ts\nimport { DatabaseSync } from 'node:sqlite';\n\nimport { s } from '@vielzeug/spell';\nimport { table } from '@vielzeug/vault';\nimport { createSQLite } from '@vielzeug/vault/sqlite';\n\nconst EventSchema = s.object({ id: s.number(), type: s.string() });\nconst database = new DatabaseSync('app.db', { timeout: 5_000 });\nconst store = createSQLite({\n database,\n name: 'app-v2',\n schema: { events: table<{ id: number; type: string }>('id') },\n codecs: { events: EventSchema },\n});\n\nawait store.batch(['events'], async (tx) => {\n await tx.put('events', { id: 1, type: 'opened' });\n await tx.put('events', { id: 2, type: 'saved' });\n});\n```\n\nNode's `node:sqlite` API is experimental. Bun's `bun:sqlite` `Database` satisfies the same positional `exec()` and `prepare()` contract; configure WAL from your application when the deployment needs it. Deno does not include SQLite, but `jsr:@db/sqlite`'s `Database` satisfies the same contract when its FFI, filesystem, and environment permissions are granted.\n\nSQLite stores serialize all access through the injected connection. `batch()` starts `BEGIN IMMEDIATE` and rolls back callback failures. While its callback runs, calls on any store sharing that connection reject rather than waiting behind the transaction; use `tx.*` instead. The underlying drivers are synchronous, so move large scans and writes to a worker or isolate when event-loop latency matters.\n\n## Store SQLite Values and Observe Changes\n\nSQLite accepts any JSON-compatible codec output. Circular values, `bigint`, class instances, functions, and non-finite numbers must be transformed by the codec before writing. Number and string primary keys remain distinct.\n\n`observe()` sees mutations written through Vault stores sharing the same injected connection after a commit. It cannot detect direct SQL changes, writes from another process, or writes through another connection. The connection belongs to the caller by default; use `closeOnDispose: true` only when the store owns it.\n\n## Handle IndexedDB Schema Migrations\n\nDeclare IndexedDB indexes in the schema. Codecs must preserve each indexed field name and value in their encoded object. Use `migrate` only for IndexedDB version upgrades and mirror Vault's fixed `value.<field>` index path.\n\n```ts\nimport { s } from '@vielzeug/spell';\nimport { table } from '@vielzeug/vault';\nimport { createIndexedDB, type MigrationFn } from '@vielzeug/vault/indexeddb';\n\nconst UserSchema = s.object({ id: s.number(), name: s.string() });\nconst schema = { users: table<{ id: number; name: string }>('id', { indexes: ['name'] }) };\nconst migrate: MigrationFn = ({ db, oldVersion, tx }) => {\n if (oldVersion < 2 && db.objectStoreNames.contains('users')) {\n tx.objectStore('users').createIndex('name', 'value.name');\n }\n};\n\ncreateIndexedDB({\n name: 'app-v2',\n migrate,\n schema,\n version: 2,\n codecs: { users: UserSchema },\n});\n```\n\n## Framework Integration\n\n::: code-group\n\n```ts [React]\nimport { useEffect, useState } from 'react';\n\nimport type { AnySchema, KeyValueVaultStore, RecordOf } from '@vielzeug/vault';\n\nexport function useTable<S extends AnySchema, K extends keyof S & string>(store: KeyValueVaultStore<S>, table: K) {\n const [rows, setRows] = useState<RecordOf<S, K>[]>([]);\n\n useEffect(() => store.observe(table, setRows), [store, table]);\n return rows;\n}\n```\n\n```ts [Vue 3]\nimport { onUnmounted, shallowRef } from 'vue';\n\nimport type { AnySchema, KeyValueVaultStore, RecordOf } from '@vielzeug/vault';\n\nexport function useTable<S extends AnySchema, K extends keyof S & string>(store: KeyValueVaultStore<S>, table: K) {\n const rows = shallowRef<RecordOf<S, K>[]>([]);\n const stop = store.observe(table, (next) => (rows.value = next));\n\n onUnmounted(stop);\n return rows;\n}\n```\n\n```ts [Svelte]\nimport { readable } from 'svelte/store';\n\nimport type { AnySchema, KeyValueVaultStore, RecordOf } from '@vielzeug/vault';\n\nexport function tableStore<S extends AnySchema, K extends keyof S & string>(store: KeyValueVaultStore<S>, table: K) {\n return readable<RecordOf<S, K>[]>([], (set) => store.observe(table, set));\n}\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\nKeep Ripple signals as application state and persist selected changes through Vault writes.\n\n## Best Practices\n\n- Define one schema per storage namespace.\n- Use string or finite-number primary keys only.\n- Provide codecs for every durable adapter; pass Spell or another parser schema directly when identity encoding is sufficient.\n- Use `observe()` for table snapshots.\n- Use IndexedDB or SQLite for atomic work.\n- Keep external asynchronous work outside `batch()` callbacks.\n- Use `ttl.*` instead of raw durations.\n- Keep SQLite scans and writes off latency-sensitive event loops.\n- Dispose stores when their owner ends.\n",
|
|
7
|
+
"examples": "---\ntitle: 'Vault: Examples'\ndescription: Practical Vault examples for browser storage, SQLite, TTL, transactions, observation, and framework integration.\n---\n\n## Browser Key-Value Store with TTL\n\n```ts\nimport { s } from '@vielzeug/spell';\nimport { table, ttl } from '@vielzeug/vault';\nimport { createLocalStorage } from '@vielzeug/vault/local-storage';\n\ninterface Session {\n id: string;\n token: string;\n}\n\nconst SessionSchema = s.object({ id: s.string(), token: s.string() });\nconst store = createLocalStorage({\n name: 'app-v2',\n schema: { sessions: table<Session>('id', { defaultTtl: ttl.hours(1) }) },\n codecs: { sessions: SessionSchema },\n});\n\nawait store.put('sessions', { id: 'current', token: 'FAKE-TOKEN-FOR-DOCS' });\nconsole.log(await store.get('sessions', 'current'));\n```\n\n## IndexedDB with Transactions and Iteration\n\n```ts\nimport { s } from '@vielzeug/spell';\nimport { table } from '@vielzeug/vault';\nimport { createIndexedDB } from '@vielzeug/vault/indexeddb';\n\ninterface Event {\n id: number;\n type: string;\n createdAt: number;\n}\n\nconst EventSchema = s.object({ createdAt: s.number(), id: s.number(), type: s.string() });\nconst db = createIndexedDB({\n name: 'events',\n schema: { events: table<Event>('id', { indexes: ['type'] }) },\n codecs: { events: EventSchema },\n});\n\nawait db.batch(['events'], async (tx) => {\n await tx.put('events', { id: 1, type: 'opened', createdAt: Date.now() });\n await tx.put('events', { id: 2, type: 'saved', createdAt: Date.now() });\n});\n\nfor await (const event of db.iterate('events')) {\n console.log(event.type);\n}\n```\n\n## SQLite Document Store\n\n```ts\nimport { DatabaseSync } from 'node:sqlite';\n\nimport { s } from '@vielzeug/spell';\nimport { table } from '@vielzeug/vault';\nimport { createSQLite } from '@vielzeug/vault/sqlite';\n\ninterface Job {\n id: string;\n status: 'queued' | 'running' | 'done';\n}\n\nconst JobSchema = s.object({ id: s.string(), status: s.union('queued', 'running', 'done') });\nconst database = new DatabaseSync(':memory:');\nconst store = createSQLite({\n database,\n name: 'app',\n schema: { jobs: table<Job>('id') },\n codecs: { jobs: JobSchema },\n});\n\nawait store.batch(['jobs'], async (tx) => {\n await tx.put('jobs', { id: 'job-1', status: 'queued' });\n await tx.put('jobs', { id: 'job-2', status: 'running' });\n});\n\nfor await (const job of store.iterate('jobs')) {\n console.log(job.id, job.status);\n}\n```\n\n## Observe Table Changes\n\n```ts\nconst stop = store.observe('sessions', (sessions) => {\n console.log(`Active sessions: ${sessions.length}`);\n});\n\nstore.disposalSignal.addEventListener('abort', stop);\n```\n\n## Bound Helpers\n\n```ts\nconsole.log(await store.has('sessions', 'current'));\nconsole.log(await store.count('sessions'));\nconsole.log(await store.isEmpty('sessions'));\n\nconst [a, b] = await store.getMany('sessions', ['current', 'old']);\nconsole.log(await store.keys('sessions'));\nconsole.log(await store.deleteMany('sessions', ['old', 'expired']));\n\nawait store.update('sessions', 'current', { token: 'FAKE-UPDATED-TOKEN' });\nawait store.upsert('sessions', 'temp', (existing) => ({\n id: 'temp',\n token: existing?.token ?? 'generated',\n}));\n\nconst current = (await store.getAll('sessions')).find((session) => session.id === 'current');\n(void a, b, current);\n```\n\n## React Hook\n\n```tsx\nimport { useEffect, useState } from 'react';\n\nimport type { AnySchema, KeyValueVaultStore, RecordOf } from '@vielzeug/vault';\n\nexport function useTable<S extends AnySchema, K extends keyof S & string>(\n store: KeyValueVaultStore<S>,\n table: K,\n): RecordOf<S, K>[] {\n const [rows, setRows] = useState<RecordOf<S, K>[]>([]);\n\n useEffect(() => store.observe(table, setRows), [store, table]);\n return rows;\n}\n```\n\n## Migration Example\n\nObject stores and schema-declared indexes are created automatically on upgrade. The `migrate` hook covers only what the schema cannot express, such as deleting a removed store or transforming old records.\n\n```ts\nimport { s } from '@vielzeug/spell';\nimport { table } from '@vielzeug/vault';\nimport { createIndexedDB } from '@vielzeug/vault/indexeddb';\n\nconst UserSchema = s.object({ email: s.string().email(), id: s.number() });\n\nconst db = createIndexedDB({\n name: 'app-v2',\n version: 2,\n migrate({ db, oldVersion, tx }) {\n // The 'users' store and its 'email' index already exist; drop a retired store.\n if (oldVersion < 2 && db.objectStoreNames.contains('legacy_sessions')) {\n tx.deleteObjectStore('legacy_sessions');\n }\n },\n schema: { users: table<{ id: number; email: string }>('id', { indexes: ['email'] }) },\n codecs: { users: UserSchema },\n});\n```\n\n## Recipe Examples\n\n- [CRUD Operations](./examples/crud.md)\n- [TTL and Pruning](./examples/ttl.md)\n- [IndexedDB Batch Transactions](./examples/batch.md)\n- [IndexedDB Lazy Iteration](./examples/iterate.md)\n- [SQLite Transactions and Iteration](./examples/sqlite.md)\n- [Reactive Tables with observe()](./examples/reactive.md)\n- [Validators and Error Handling](./examples/plugins.md)\n"
|
|
8
8
|
},
|
|
9
9
|
"examples": [
|
|
10
10
|
{
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"id": "bulk-operations",
|
|
17
|
-
"code": "import { s } from '@vielzeug/spell'\nimport { table } from '@vielzeug/vault'\nimport { createLocalStorage } from '@vielzeug/vault/local-storage'\n\nconst ItemSchema = s.object({ id: s.number(), value: s.number() })\nconst schema = {\n items: table('id'),\n}\n\nconst db = createLocalStorage({\n name: 'bulk-demo',\n schema,\n codecs: { items: ItemSchema },\n})\n\nconst items = Array.from({ length: 10 }, (_, index) => ({\n id: index + 1,\n value: +(Math.random() * 1000).toFixed(2),\n}))\n\nawait db.putAll('items', items)\nconsole.log('Inserted', items.length, 'items')\n\n// getMany
|
|
17
|
+
"code": "import { s } from '@vielzeug/spell'\nimport { table } from '@vielzeug/vault'\nimport { createLocalStorage } from '@vielzeug/vault/local-storage'\n\nconst ItemSchema = s.object({ id: s.number(), value: s.number() })\nconst schema = {\n items: table('id'),\n}\n\nconst db = createLocalStorage({\n name: 'bulk-demo',\n schema,\n codecs: { items: ItemSchema },\n})\n\nconst items = Array.from({ length: 10 }, (_, index) => ({\n id: index + 1,\n value: +(Math.random() * 1000).toFixed(2),\n}))\n\nawait db.putAll('items', items)\nconsole.log('Inserted', items.length, 'items')\n\n// getMany: fetch multiple by key in one call (missing keys return undefined)\nconst [first, missing, third] = await db.getMany('items', [1, 99, 3])\nconsole.log('getMany [1, 99, 3]:', first?.id, missing, third?.id)\n\n// deleteMany: remove multiple by key, returns count deleted\nconst deleted = await db.deleteMany('items', [1, 2, 3, 99])\nconsole.log('deleteMany [1,2,3,99] deleted:', deleted) // 3 (99 did not exist)\n\n// filter-driven removal: getAll + filter + deleteMany\nconst remaining = await db.getAll('items')\nconst toDelete = remaining.filter((item) => item.id <= 6).map((item) => item.id)\nconst queryDeleted = await db.deleteMany('items', toDelete)\nconsole.log('Deleted items with id ≤ 6:', queryDeleted)\n\nconsole.log('Remaining count:', await db.count('items'))\nconsole.log('First remaining item:', (await db.getAll('items')).sort((a, b) => a.id - b.id)[0])",
|
|
18
18
|
"name": "Bulk Operations"
|
|
19
19
|
},
|
|
20
20
|
{
|
|
@@ -29,27 +29,27 @@
|
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
"id": "filtering",
|
|
32
|
-
"code": "import { s } from '@vielzeug/spell'\nimport { table } from '@vielzeug/vault'\nimport { createLocalStorage } from '@vielzeug/vault/local-storage'\n\nconst ProductSchema = s.object({ category: s.string(), id: s.number(), inStock: s.boolean(), name: s.string(), price: s.number() })\nconst schema = {\n products: table('id'),\n}\n\nconst db = createLocalStorage({\n name: 'shop',\n schema,\n codecs: { products: ProductSchema },\n})\n\nawait db.putAll('products', [\n { id: 1, name: 'Laptop', price: 999, category: 'electronics', inStock: true },\n { id: 2, name: 'Mouse', price: 29, category: 'electronics', inStock: true },\n { id: 3, name: 'Desk', price: 299, category: 'furniture', inStock: false },\n { id: 4, name: 'Chair', price: 199, category: 'furniture', inStock: true },\n { id: 5, name: 'Monitor', price: 399, category: 'electronics', inStock: true },\n])\n\n// getAll() returns a plain array
|
|
33
|
-
"name": "Filtering
|
|
32
|
+
"code": "import { s } from '@vielzeug/spell'\nimport { table } from '@vielzeug/vault'\nimport { createLocalStorage } from '@vielzeug/vault/local-storage'\n\nconst ProductSchema = s.object({ category: s.string(), id: s.number(), inStock: s.boolean(), name: s.string(), price: s.number() })\nconst schema = {\n products: table('id'),\n}\n\nconst db = createLocalStorage({\n name: 'shop',\n schema,\n codecs: { products: ProductSchema },\n})\n\nawait db.putAll('products', [\n { id: 1, name: 'Laptop', price: 999, category: 'electronics', inStock: true },\n { id: 2, name: 'Mouse', price: 29, category: 'electronics', inStock: true },\n { id: 3, name: 'Desk', price: 299, category: 'furniture', inStock: false },\n { id: 4, name: 'Chair', price: 199, category: 'furniture', inStock: true },\n { id: 5, name: 'Monitor', price: 399, category: 'electronics', inStock: true },\n])\n\n// getAll() returns a plain array: compose with standard array operations\nconst all = await db.getAll('products')\nconst electronics = all\n .filter((product) => product.category === 'electronics' && product.inStock)\n .sort((a, b) => a.price - b.price)\n\nconst pageSize = 2\nconst pageIndex = 0\nconst page = electronics.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize)\n\nconsole.log('Page:', page.map((p) => p.name))\nconsole.log('Total matching:', electronics.length)\nconsole.log('Page 1 of', Math.ceil(electronics.length / pageSize))\n\n// prefix match\nconst mice = all.filter((product) => product.name.toLowerCase().startsWith('m'))\nconsole.log('Starts with m:', mice.map((p) => p.name))\n\n// deletion via deleteMany with the keys to remove\nconst outOfStock = all.filter((product) => !product.inStock).map((product) => product.id)\nconsole.log('Removed out-of-stock:', await db.deleteMany('products', outOfStock))\n\nconst cheapest = (await db.getAll('products')).sort((a, b) => a.price - b.price)[0]\nconsole.log('Cheapest:', cheapest?.name, cheapest?.price)\n\nconsole.log('Remaining count:', await db.count('products'))",
|
|
33
|
+
"name": "Filtering: getAll composition, pagination, and deletion"
|
|
34
34
|
},
|
|
35
35
|
{
|
|
36
36
|
"id": "indexed-db",
|
|
37
|
-
"code": "import { s } from '@vielzeug/spell'\nimport { table, ttl } from '@vielzeug/vault'\nimport { createIndexedDB } from '@vielzeug/vault/indexeddb'\n\nconst LogSchema = s.object({ id: s.number(), level: s.string(), message: s.string(), ts: s.number() })\nconst schema = {\n logs: table('id'),\n}\n\n// createIndexedDB returns DocumentVaultStore with transactions and cursor iteration\nconst db = createIndexedDB({\n name: 'app-logs',\n schema,\n codecs: { logs: LogSchema },\n version: 1,\n})\n\nawait db.putAll('logs', [\n { id: 1, level: 'info', message: 'App started', ts: Date.now() - 3000 },\n { id: 2, level: 'warn', message: 'Slow query detected', ts: Date.now() - 2000 },\n { id: 3, level: 'error', message: 'Request failed', ts: Date.now() - 1000 },\n { id: 4, level: 'info', message: 'Request succeeded', ts: Date.now() },\n], ttl.hours(1))\n\n// batch() is atomic on IndexedDB
|
|
38
|
-
"name": "IndexedDB
|
|
37
|
+
"code": "import { s } from '@vielzeug/spell'\nimport { table, ttl } from '@vielzeug/vault'\nimport { createIndexedDB } from '@vielzeug/vault/indexeddb'\n\nconst LogSchema = s.object({ id: s.number(), level: s.string(), message: s.string(), ts: s.number() })\nconst schema = {\n logs: table('id'),\n}\n\n// createIndexedDB returns DocumentVaultStore with transactions and cursor iteration\nconst db = createIndexedDB({\n name: 'app-logs',\n schema,\n codecs: { logs: LogSchema },\n version: 1,\n})\n\nawait db.putAll('logs', [\n { id: 1, level: 'info', message: 'App started', ts: Date.now() - 3000 },\n { id: 2, level: 'warn', message: 'Slow query detected', ts: Date.now() - 2000 },\n { id: 3, level: 'error', message: 'Request failed', ts: Date.now() - 1000 },\n { id: 4, level: 'info', message: 'Request succeeded', ts: Date.now() },\n], ttl.hours(1))\n\n// batch() is atomic on IndexedDB: all writes commit or none do\nawait db.batch(['logs'], async (tx) => {\n await tx.put('logs', { id: 5, level: 'info', message: 'Batch committed', ts: Date.now() })\n await tx.delete('logs', 1)\n await tx.delete('logs', 2)\n})\n\n// iterate(): cursor-based streaming, only on DocumentVaultStore\n// the full table is never loaded into memory at once\nconst messages = []\nfor await (const entry of db.iterate('logs')) {\n messages.push(entry.message)\n}\nconsole.log('Streamed via iterate():', messages)\n\nconst errors = (await db.getAll('logs')).filter((entry) => entry.level === 'error')\nconsole.log('Errors:', errors.map((e) => e.message))\nconsole.log('Total logs:', await db.count('logs'))\n\n// pruneExpired() reclaims storage from TTL-expired records that haven't been read\nconst pruned = await db.pruneExpired()\nconsole.log('Pruned:', pruned)\n\nawait db.dispose()",
|
|
38
|
+
"name": "IndexedDB: Atomic Batch & iterate()"
|
|
39
39
|
},
|
|
40
40
|
{
|
|
41
41
|
"id": "prune-schedule",
|
|
42
|
-
"code": "import { table, ttl } from '@vielzeug/vault'\nimport { createMemory } from '@vielzeug/vault/memory'\n\n// pruneExpired() sweeps all tables and removes expired records.\n// Schedule it with setInterval and cancel on disposalSignal.\n\nconst schema = { sessions: table('token') }\nconst db = createMemory({ schema })\n\nconst pruneInterval = setInterval(() => db.pruneExpired(), ttl.minutes(15))\ndb.disposalSignal.addEventListener('abort', () => clearInterval(pruneInterval))\n\n// Write a session that expires in 1 ms\nawait db.put('sessions', { token: 'FAKE-TOKEN-ONE', user: 1 }, ttl.ms(1))\nawait db.put('sessions', { token: 'FAKE-TOKEN-TWO', user: 2 }) // no TTL
|
|
43
|
-
"name": "TTL
|
|
42
|
+
"code": "import { table, ttl } from '@vielzeug/vault'\nimport { createMemory } from '@vielzeug/vault/memory'\n\n// pruneExpired() sweeps all tables and removes expired records.\n// Schedule it with setInterval and cancel on disposalSignal.\n\nconst schema = { sessions: table('token') }\nconst db = createMemory({ schema })\n\nconst pruneInterval = setInterval(() => db.pruneExpired(), ttl.minutes(15))\ndb.disposalSignal.addEventListener('abort', () => clearInterval(pruneInterval))\n\n// Write a session that expires in 1 ms\nawait db.put('sessions', { token: 'FAKE-TOKEN-ONE', user: 1 }, ttl.ms(1))\nawait db.put('sessions', { token: 'FAKE-TOKEN-TWO', user: 2 }) // no TTL: permanent\n\nconsole.log('before prune:', await db.count('sessions')) // 2 (lazy eviction: both exist physically)\n\n// Manual prune to demonstrate the API\nawait new Promise((resolve) => setTimeout(resolve, 5))\nconst pruned = await db.pruneExpired()\nconsole.log('pruned:', pruned.sessions) // 1 (the expired session)\nconsole.log('after prune:', await db.count('sessions')) // 1\n\nawait db.dispose()",
|
|
43
|
+
"name": "TTL: pruneExpired with disposalSignal"
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
46
|
"id": "reactive-observe",
|
|
47
47
|
"code": "import { table } from '@vielzeug/vault'\nimport { createMemory } from '@vielzeug/vault/memory'\n\nconst db = createMemory({ schema: { users: table('id') } })\nconst snapshots = []\nconst stop = db.observe('users', (users) => snapshots.push(users.map((user) => user.name)))\n\nawait Promise.resolve()\nawait db.put('users', { id: 1, name: 'Ada' })\nawait Promise.resolve()\n\nconsole.log(snapshots) // [[], ['Ada']]\nstop()\nawait db.dispose()",
|
|
48
|
-
"name": "Reactive
|
|
48
|
+
"name": "Reactive: observe()"
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"id": "ttl-expiration",
|
|
52
|
-
"code": "import { s } from '@vielzeug/spell'\nimport { table, ttl } from '@vielzeug/vault'\nimport { createLocalStorage } from '@vielzeug/vault/local-storage'\n\nconst CacheSchema = s.object({ data: s.string(), id: s.string() })\nconst schema = {\n cache: table('id'),\n}\n\nconst db = createLocalStorage({\n name: 'cache-demo',\n schema,\n codecs: { cache: CacheSchema },\n})\n\n// ttl helpers produce finite, positive millisecond durations\nawait db.put('cache', { id: 'short', data: 'Expires in 1 second' }, ttl.seconds(1))\nawait db.put('cache', { id: 'long', data: 'Expires in 5 minutes' }, ttl.minutes(5))\nconsole.log('Stored records with TTL')\nconsole.log('Immediate read:', await db.get('cache', 'short'))\n\nawait new Promise((resolve) => setTimeout(resolve, 1500))\nconsole.log('After 1.5s:', await db.get('cache', 'short')) // expired
|
|
52
|
+
"code": "import { s } from '@vielzeug/spell'\nimport { table, ttl } from '@vielzeug/vault'\nimport { createLocalStorage } from '@vielzeug/vault/local-storage'\n\nconst CacheSchema = s.object({ data: s.string(), id: s.string() })\nconst schema = {\n cache: table('id'),\n}\n\nconst db = createLocalStorage({\n name: 'cache-demo',\n schema,\n codecs: { cache: CacheSchema },\n})\n\n// ttl helpers produce finite, positive millisecond durations\nawait db.put('cache', { id: 'short', data: 'Expires in 1 second' }, ttl.seconds(1))\nawait db.put('cache', { id: 'long', data: 'Expires in 5 minutes' }, ttl.minutes(5))\nconsole.log('Stored records with TTL')\nconsole.log('Immediate read:', await db.get('cache', 'short'))\n\nawait new Promise((resolve) => setTimeout(resolve, 1500))\nconsole.log('After 1.5s:', await db.get('cache', 'short')) // expired: undefined\nconsole.log('Long-lived still here:', await db.get('cache', 'long'))\n\nconsole.log('ttl helpers:', {\n '100ms': ttl.ms(100),\n '5 minutes': ttl.minutes(5),\n '2 hours': ttl.hours(2),\n '7 days': ttl.days(7),\n})",
|
|
53
53
|
"name": "TTL & Expiration"
|
|
54
54
|
}
|
|
55
55
|
],
|
package/data/packages/ward.json
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"apiSource": "export { ANONYMOUS, WILDCARD } from './constants';\nexport { WardConditionError, WardConfigError, WardError } from './errors';\nexport { createWard } from './factory';\nexport { matchesPattern } from './resource';\nexport { allow, deny, predicate } from './rules';\nexport type {\n BoundWard,\n BoundWardDecisionInput,\n Principal,\n UserPrincipal,\n Ward,\n WardAttributes,\n WardAttributeValue,\n WardCondition,\n WardConditionInput,\n WardDecision,\n WardDecisionInput,\n WardEvent,\n WardPattern,\n WardRule,\n} from './types';\n",
|
|
3
3
|
"docs": {
|
|
4
|
-
"index": "---\ntitle: Ward
|
|
5
|
-
"api": "---\ntitle: Ward
|
|
6
|
-
"usage": "---\ntitle: Ward
|
|
7
|
-
"examples": "---\ntitle: Ward
|
|
4
|
+
"index": "---\ntitle: 'Ward: Ordered authorization rules'\ndescription: Zero-dependency authorization decisions with ordered rules, role helpers, ownership predicates, and default deny.\npackage: ward\ncategory: security\nkeywords: [authorization, rbac, permissions, policy, roles, predicates]\nexports: [createWard, allow, deny, predicate, ANONYMOUS, WILDCARD, matchesPattern]\nrelated: [herald, postmaster, refine]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"ward\" />\n\n## Why Ward?\n\nWard evaluates immutable ordered rules. The first matching rule wins; no match means deny. Typed action, resource, and attribute contracts keep role and ownership policies explicit.\n\n## Quick Start\n\n```ts\nimport { allow, createWard, deny, predicate, WILDCARD } from '@vielzeug/ward';\n\nconst ward = createWard([\n deny('blocked', WILDCARD, [WILDCARD]),\n allow('editor', 'posts', ['read', 'update'], { when: predicate.owns('authorId') }),\n allow('viewer', 'posts', ['read']),\n]);\n\nconst decision = ward.decide({\n action: 'update',\n attributes: { authorId: 'u1' },\n principal: { id: 'u1', roles: ['editor'] },\n resource: 'posts',\n});\n```\n\n| Feature | Ward | Inline conditionals |\n| --- | --- | --- |\n| Ordered first-match policy | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Manual |\n| Default deny | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Manual |\n| Typed actions | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Partial |\n| Ownership predicates | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Manual |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n\n## Features\n\n<div class=\"features-grid\">\n\n- Ordered `allow` and `deny` decisions\n- Declarative `roles` data on every rule\n- Concise role/action rule factories\n- `predicate.owns()`, `and()`, `or()`, and `not()`\n- Exact, namespace, and wildcard action/resource patterns\n- Immutable rules, principals, and JSON-compatible attributes\n- Typed batch, bound-principal, and allowed-action checks\n- `tap()` decision observability\n- Default deny when no rule matches\n\n</div>\n\n## Installation\n\n```sh\npnpm add @vielzeug/ward\n```\n\n## Documentation\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n## See Also\n\n- [Postmaster](/postmaster/): authorize durable job actions at application boundaries.\n- [Refine](/refine/): consume decisions in accessible UI components.\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
|
+
"api": "---\ntitle: 'Ward: API Reference'\ndescription: Ordered authorization rules, typed decisions, immutable policies, and observability.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createWard()` | Compile immutable ordered rules | Sync | First match wins |\n| `allow()` / `deny()` | Create role-conditioned rules | Sync | One rule is produced per action |\n| `predicate` | Compose typed synchronous conditions | Sync | Async results throw |\n| `matchesPattern()` | Match exact and wildcard values | Sync | `posts:*` does not match `posts` |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/ward` | All runtime APIs, errors, constants, and public types |\n\n## Core API\n\n### `createWard()`\n\n```ts\nfunction createWard<Action extends string, Resource extends string, Attributes extends WardAttributes>(\n rules?: readonly (WardRule<Action, Resource, Attributes> | readonly WardRule<Action, Resource, Attributes>[])[],\n): Ward<Action, Resource, Attributes>\n```\n\nReturns an immutable ordered policy. Nested rule arrays are flattened once. Rules, declarative attributes, and bound principals are snapshotted.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `rules` | `readonly (WardRule \\| readonly WardRule[])[]` | Rules in first-match order |\n\n**Returns:** `Ward<Action, Resource, Attributes>`.\n\n```ts\nimport { allow, createWard } from '@vielzeug/ward';\n\nconst ward = createWard<'read', 'posts'>([allow('viewer', 'posts', ['read'])]);\n```\n\n| Method | Returns | Behavior |\n| --- | --- | --- |\n| `decide(input)` | `WardDecision` | Evaluates and emits one decision event |\n| `checkAll(inputs)` | `WardDecision[]` | Evaluates each input in order |\n| `allowedActions(input)` | `Action[]` | Deduplicates and filters the derived known actions without emitting events |\n| `forPrincipal(principal?)` | `BoundWard` | Binds an immutable principal snapshot |\n| `tap(handler, options?)` | `() => void` | Observes decisions; supports `AbortSignal` |\n| `knownActions` | `readonly Action[]` | Exact allow-side actions derived from the compiled rules, in declaration order |\n| `rules` | `readonly WardRule[]` | Immutable compiled rules |\n\n---\n\n### `allow()` and `deny()`\n\n```ts\nallow<Action, Resource, Attributes>(role, resource, actions, options?): WardRule[]\ndeny<Action, Resource, Attributes>(role, resource, actions, options?): WardRule[]\n```\n\nReturn one role-conditioned rule per action. The role list is stored as declarative `roles` data on each rule, not a condition callback.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `role` | `string \\| readonly string[]` | Role, role alternatives, `ANONYMOUS`, or `WILDCARD` |\n| `resource` | `WardPattern<Resource>` | Exact or wildcard resource |\n| `actions` | `readonly WardPattern<Action>[]` | Exact or wildcard actions |\n| `options.when` | `WardCondition<Attributes>` | Additional synchronous condition |\n\n**Returns:** `WardRule<Action, Resource, Attributes>[]`.\n\n```ts\nimport { allow, deny, WILDCARD } from '@vielzeug/ward';\n\nconst rules = [deny('blocked', WILDCARD, [WILDCARD]), allow(['editor', 'admin'], 'posts', ['read'])];\n```\n\n---\n\n### `predicate`\n\n```ts\npredicate.owns<Attributes>(attribute)\npredicate.and<Attributes>(...conditions)\npredicate.or<Attributes>(...conditions)\npredicate.not<Attributes>(condition)\n```\n\nReturns typed `WardCondition` functions. `owns()` accepts only a string key from the selected attribute type. Role checks do not need a predicate: declare `roles` on the rule instead.\n\n---\n\n### Pattern helpers\n\n```ts\nmatchesPattern(pattern: string, value: string): boolean\n```\n\n`matchesPattern()` supports exact values, `*`, and namespace wildcards such as `posts:*`.\n\n## Types\n\n```ts\ntype WardAttributeValue =\n | boolean\n | number\n | string\n | null\n | readonly WardAttributeValue[]\n | { readonly [key: string]: WardAttributeValue };\n\ntype WardAttributes = Readonly<Record<string, WardAttributeValue>>;\ntype WardPattern<Value extends string> = Value | typeof WILDCARD | `${string}:*`;\n```\n\n```ts\ntype UserPrincipal = Readonly<{\n attributes?: WardAttributes;\n id: string;\n roles: readonly string[];\n}>;\n\ntype Principal = UserPrincipal | null;\n```\n\n```ts\ntype WardConditionInput<Attributes extends WardAttributes> = Readonly<{\n attributes?: Attributes;\n principal: Principal;\n}>;\n\ntype WardCondition<Attributes extends WardAttributes> = (\n input: WardConditionInput<Attributes>,\n) => boolean;\n```\n\n```ts\ntype WardRule<Action extends string, Resource extends string, Attributes extends WardAttributes> = Readonly<{\n action: WardPattern<Action>;\n attributes?: Attributes;\n condition?: WardCondition<Attributes>;\n effect: 'allow' | 'deny';\n resource: WardPattern<Resource>;\n roles?: readonly string[];\n}>;\n```\n\n```ts\ntype WardDecisionInput<Action extends string, Resource extends string, Attributes extends WardAttributes> = Readonly<{\n action: Action;\n attributes?: Attributes;\n principal?: Principal;\n resource: Resource;\n}>;\n```\n\n```ts\ntype WardDecision<Action extends string, Resource extends string, Attributes extends WardAttributes> =\n | { effect: 'allow' | 'deny'; matched: true; reason: string; rule: WardRule<Action, Resource, Attributes> }\n | { effect: 'deny'; matched: false; reason: string; rule?: never };\n```\n\n```ts\ntype WardEvent<Action extends string, Resource extends string, Attributes extends WardAttributes> = Readonly<{\n decision: WardDecision<Action, Resource, Attributes>;\n input: WardDecisionInput<Action, Resource, Attributes> & { principal: Principal };\n type: 'decision';\n}>;\n```\n\n`BoundWard` is `Pick<Ward, 'allowedActions' | 'checkAll' | 'decide'>` for the principal-bound form, and `BoundWardDecisionInput` is the principal-free decision input.\n\n## Errors\n\n| Error | Trigger | Notable properties |\n| --- | --- | --- |\n| `WardError` | Base for Ward-originated errors | Standard `cause` support |\n| `WardConfigError` | Invalid rules, principals, inputs, or attribute values | Configuration message with field path |\n| `WardConditionError` | Thrown, async, or non-boolean condition result | `ruleIndex`, `cause` |\n",
|
|
6
|
+
"usage": "---\ntitle: 'Ward: Usage Guide'\ndescription: Define ordered role, wildcard, attribute, and ownership authorization rules.\n---\n\n[[toc]]\n\n## Basic Usage\n\n```ts\nimport { allow, createWard, deny, WILDCARD } from '@vielzeug/ward';\n\nconst ward = createWard<'read' | 'update', 'posts'>([\n deny('blocked', WILDCARD, [WILDCARD]),\n allow('editor', 'posts', ['read', 'update']),\n allow('viewer', 'posts', ['read']),\n]);\n\nconst decision = ward.decide({\n action: 'update',\n principal: { id: 'u1', roles: ['editor'] },\n resource: 'posts',\n});\n```\n\nOrder is policy. Put exceptions and explicit denies before broad allows. No match returns a default-deny decision with `matched: false`.\n\n## Check ownership with typed attributes\n\n```ts\nimport { allow, createWard, predicate } from '@vielzeug/ward';\n\ntype Attributes = { authorId: string; status: 'draft' | 'published' };\n\nconst ward = createWard<'update', 'posts', Attributes>([\n allow<'update', 'posts', Attributes>('editor', 'posts', ['update'], {\n when: predicate.owns<Attributes>('authorId'),\n }),\n]);\n\nward.decide({\n action: 'update',\n attributes: { authorId: post.authorId, status: post.status },\n principal,\n resource: 'posts',\n});\n```\n\nAttribute values support finite numbers, strings, booleans, `null`, arrays, and plain objects. Ward rejects class instances, dates, maps, sets, functions, `undefined`, non-finite numbers, and circular values.\n\n## Combine conditions\n\n```ts\nconst canPublishOwnPost = predicate.and<Attributes>(\n predicate.owns<Attributes>('authorId'),\n ({ attributes }) => attributes?.status === 'draft',\n);\n```\n\nConditions are synchronous. A thrown error, Promise, or non-boolean result becomes a `WardConditionError` with the original value in `cause`. Role gating does not need a condition: declare `roles` on the rule.\n\n## Bind a principal\n\n```ts\nconst permissions = ward.forPrincipal(principal);\n\npermissions.decide({ action: 'update', attributes, resource: 'posts' });\npermissions.checkAll([\n { action: 'read', attributes, resource: 'posts' },\n { action: 'update', attributes, resource: 'posts' },\n]);\npermissions.allowedActions({ attributes, resource: 'posts' });\n```\n\n`forPrincipal()` snapshots the principal, roles, and principal attributes. Later caller mutations cannot alter bound decisions.\n\n`allowedActions()` draws from `ward.knownActions`: the exact allow-side actions derived from the compiled rules, and accepts an optional `filter` to narrow the candidates.\n\n## Observe decisions\n\n```ts\nconst controller = new AbortController();\n\nward.tap((event) => audit.write(event), { signal: controller.signal });\ncontroller.abort();\n```\n\n`tap()` observes `decide()` and `checkAll()`. It does not observe the hypothetical checks performed by `allowedActions()`. Handler failures are swallowed.\n\n## Anonymous and wildcard roles\n\n- `ANONYMOUS` matches `null` and an omitted principal.\n- `WILDCARD` as a role matches any authenticated principal.\n- `WILDCARD` as an action or resource matches every value.\n\n## Testing\n\nConstruct a fresh Ward for each policy test. Assert decisions and ordering through the public API; rules are immutable snapshots.\n\n## Framework Integration\n\nKeep one Ward instance near the application boundary. Bind the current principal when rendering several related controls, and repeat authorization at the mutation boundary.\n\n## Working with Other Vielzeug Libraries\n\nSend `WardEvent` values to a Rune logger or Herald bus when an application needs centralized diagnostics. Keep durable audit persistence in the application.\n\n## Best Practices\n\n- Put narrow exceptions before broad rules.\n- Keep default deny.\n- Prefer `allow()` and `deny()` for role policies.\n- Prefer declarative attributes before custom conditions.\n- Type action, resource, and attribute generics at shared policy boundaries.\n- Pass only authorization-relevant attributes.\n- Treat `reason` as diagnostics, not user-facing prose.\n",
|
|
7
|
+
"examples": "---\ntitle: 'Ward: Examples'\ndescription: Practical examples and recipes for ward.\n---\n\n## Examples\n\n- [Blog Roles](./examples/blog-roles.md)\n- [Multi-Role Rules](./examples/multi-role-rules.md)\n- [Wildcard Action](./examples/wildcard-action.md)\n- [Priority and Overrides](./examples/inheritance-and-overrides.md)\n- [Bound Guard in UI Layer](./examples/bound-guard-in-ui-layer.md)\n- [Rule Specificity](./examples/disabling-wildcard-fallback.md)\n- [Auditing Decisions](./examples/logger-for-auditing.md)\n- [Fresh Ward Per Test](./examples/snapshot-restore-for-test-isolation.md)\n- [Conflict Detection](./examples/conflict-detection.md)\n- [Trace a Decision](./examples/trace-decision.md)\n"
|
|
8
8
|
},
|
|
9
9
|
"examples": [
|
|
10
10
|
{
|
|
11
11
|
"id": "basic-rules",
|
|
12
|
-
"code": "import { WILDCARD, createWard } from '@vielzeug/ward'\n\n// Ordered first-match rules
|
|
12
|
+
"code": "import { WILDCARD, createWard } from '@vielzeug/ward'\n\n// Ordered first-match rules: the first matching rule wins; default deny if none match\nconst ward = createWard([\n { action: WILDCARD, resource: WILDCARD, effect: 'deny', roles: ['blocked'] },\n { action: 'read', resource: 'posts', effect: 'allow' },\n { action: 'update', resource: 'posts', effect: 'allow', roles: ['editor'] },\n])\n\nconst viewer = { id: 'u1', roles: ['viewer'] }\nconst editor = { id: 'u2', roles: ['editor'] }\nconst blocked = { id: 'u3', roles: ['blocked'] }\n\nconsole.log('viewer read: ', ward.decide({ action: 'read', principal: viewer, resource: 'posts' }).effect) // allow\nconsole.log('viewer update:', ward.decide({ action: 'update', principal: viewer, resource: 'posts' }).effect) // deny\nconsole.log('editor update:', ward.decide({ action: 'update', principal: editor, resource: 'posts' }).effect) // allow\nconsole.log('blocked read: ', ward.decide({ action: 'read', principal: blocked, resource: 'posts' }).effect) // deny",
|
|
13
13
|
"name": "Basic Rules"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"id": "basic-setup",
|
|
17
|
-
"code": "import { createWard } from '@vielzeug/ward'\n\n// Simple allow/deny rules evaluated in order
|
|
17
|
+
"code": "import { createWard } from '@vielzeug/ward'\n\n// Simple allow/deny rules evaluated in order: first match wins\nconst ward = createWard([\n { action: 'read', resource: 'posts', effect: 'allow' },\n { action: 'write', resource: 'posts', effect: 'allow', roles: ['writer'] },\n { action: 'delete', resource: 'posts', effect: 'deny' },\n])\n\nconst user = { id: 'u1', roles: ['writer'] }\n\nconsole.log('read: ', ward.decide({ action: 'read', principal: user, resource: 'posts' }).effect) // allow\nconsole.log('write: ', ward.decide({ action: 'write', principal: user, resource: 'posts' }).effect) // allow\nconsole.log('delete:', ward.decide({ action: 'delete', principal: user, resource: 'posts' }).effect) // deny",
|
|
18
18
|
"name": "Basic Setup"
|
|
19
19
|
},
|
|
20
20
|
{
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"id": "multi-role-rules",
|
|
27
|
-
"code": "import { createWard } from '@vielzeug/ward'\n\n// A rule's roles array matches with OR semantics
|
|
27
|
+
"code": "import { createWard } from '@vielzeug/ward'\n\n// A rule's roles array matches with OR semantics: any listed role matches\nconst ward = createWard([\n {\n action: 'read',\n resource: 'articles',\n effect: 'allow',\n roles: ['editor', 'reviewer', 'reader'],\n },\n { action: 'read', resource: 'articles', effect: 'deny' },\n])\n\nconst editor = { id: 'u1', roles: ['editor'] }\nconst reviewer = { id: 'u2', roles: ['reviewer'] }\nconst outsider = { id: 'u3', roles: ['guest'] }\n\nconsole.log('editor: ', ward.decide({ action: 'read', principal: editor, resource: 'articles' }).effect) // allow\nconsole.log('reviewer:', ward.decide({ action: 'read', principal: reviewer, resource: 'articles' }).effect) // allow\nconsole.log('outsider:', ward.decide({ action: 'read', principal: outsider, resource: 'articles' }).effect) // deny",
|
|
28
28
|
"name": "Multi-Role Rules"
|
|
29
29
|
},
|
|
30
30
|
{
|
|
@@ -34,12 +34,12 @@
|
|
|
34
34
|
},
|
|
35
35
|
{
|
|
36
36
|
"id": "permission-management",
|
|
37
|
-
"code": "import { createWard } from '@vielzeug/ward'\n\n// Rules are plain data
|
|
37
|
+
"code": "import { createWard } from '@vielzeug/ward'\n\n// Rules are plain data: build them dynamically from config or a database\nconst rules = [\n { action: 'read', resource: 'comments', effect: 'allow' },\n { action: 'write', resource: 'comments', effect: 'allow', roles: ['user'] },\n { action: 'delete', resource: 'comments', effect: 'allow', roles: ['moderator'] },\n]\n\nconst ward = createWard(rules)\n\nconst user = { id: 'u1', roles: ['user'] }\nconst mod = { id: 'u2', roles: ['moderator'] }\nconst guest = { id: 'u3', roles: [] }\n\nconsole.log('guest read: ', ward.decide({ action: 'read', principal: guest, resource: 'comments' }).effect) // allow\nconsole.log('user write: ', ward.decide({ action: 'write', principal: user, resource: 'comments' }).effect) // allow\nconsole.log('guest write: ', ward.decide({ action: 'write', principal: guest, resource: 'comments' }).effect) // deny\nconsole.log('mod delete:', ward.decide({ action: 'delete', principal: mod, resource: 'comments' }).effect) // allow",
|
|
38
38
|
"name": "Permission Management"
|
|
39
39
|
},
|
|
40
40
|
{
|
|
41
41
|
"id": "role-hierarchy",
|
|
42
|
-
"code": "import { createWard } from '@vielzeug/ward'\n\n// Roles are declarative data on each rule
|
|
42
|
+
"code": "import { createWard } from '@vielzeug/ward'\n\n// Roles are declarative data on each rule: no condition callbacks needed\nconst ward = createWard([\n { action: 'read', resource: 'posts', effect: 'allow' },\n { action: 'update', resource: 'posts', effect: 'allow', roles: ['editor'] },\n { action: 'delete', resource: 'posts', effect: 'allow', roles: ['admin'] },\n])\n\nconst editor = { id: 'u1', roles: ['editor'] }\nconst admin = { id: 'u2', roles: ['admin'] }\nconst viewer = { id: 'u3', roles: ['viewer'] }\n\nconsole.log('viewer read: ', ward.decide({ action: 'read', principal: viewer, resource: 'posts' }).effect) // allow\nconsole.log('editor update:', ward.decide({ action: 'update', principal: editor, resource: 'posts' }).effect) // allow\nconsole.log('admin delete:', ward.decide({ action: 'delete', principal: admin, resource: 'posts' }).effect) // allow\nconsole.log('viewer delete:', ward.decide({ action: 'delete', principal: viewer, resource: 'posts' }).effect) // deny",
|
|
43
43
|
"name": "Role Hierarchy"
|
|
44
44
|
},
|
|
45
45
|
{
|