From fe60ed658837dc5f82f3bf4d15d4a62de266bf33 Mon Sep 17 00:00:00 2001 From: ditadi Date: Fri, 7 Aug 2026 19:56:15 +0100 Subject: [PATCH] feat(appkit): add database mutations and transactional hooks Extend the typed entity API and the generated routes with create, update, upsert, and delete, and let an entity declare before/after hooks that run inside the mutation's own transaction, so writes a hook issues commit or roll back with it. Keep the HTTP write allowlist narrower than trusted code's: a key, a generated identity, and a materialized stamp stay server-owned. Answer a hook's DatabaseValidationError with 422 carrying only the issues that name a public column, and leave every other hook failure opaque. Keyed mutations narrow by the accumulated predicate as find(id) already does, and an insert that would silently drop one is rejected, so no terminal operation ignores fluent state. Signed-off-by: ditadi --- docs/docs/api/appkit/Class.AppKitError.md | 1 + .../appkit/Class.DatabaseValidationError.md | 190 ++++++++++++ .../Interface.DatabaseValidationIssue.md | 19 ++ .../appkit/Interface.EntityMutationHooks.md | 170 ++++++++++ docs/docs/api/appkit/Interface.HookApp.md | 11 + docs/docs/api/appkit/Interface.HookContext.md | 19 ++ .../appkit/Interface.ReadSerializerContext.md | 19 ++ .../api/appkit/TypeAlias.DatabaseExports.md | 2 +- docs/docs/api/appkit/TypeAlias.EntityHooks.md | 23 ++ .../api/appkit/TypeAlias.IDatabaseConfig.md | 4 +- .../api/appkit/TypeAlias.ReadSerializer.md | 20 ++ .../api/appkit/TypeAlias.TransactionClient.md | 17 + docs/docs/api/appkit/index.md | 9 + docs/docs/api/appkit/typedoc-sidebar.ts | 45 +++ packages/appkit/src/beta.ts | 12 +- packages/appkit/src/database/errors.ts | 18 +- .../appkit/src/database/runtime/data-path.ts | 13 +- .../runtime/engine/drizzle-data-path.ts | 21 +- .../appkit/src/errors/database-validation.ts | 30 ++ packages/appkit/src/errors/index.ts | 4 + .../appkit/src/errors/tests/errors.test.ts | 37 +++ packages/appkit/src/index.ts | 2 + .../src/plugins/database/crud/contract.ts | 44 +-- .../src/plugins/database/crud/request.ts | 79 +++++ .../src/plugins/database/crud/response.ts | 99 ++++++ .../src/plugins/database/crud/routes.ts | 160 ++++++---- .../database/crud/tests/contract.test.ts | 38 ++- .../database/crud/tests/request.test.ts | 141 +++++++++ .../database/crud/tests/routes.test.ts | 233 +++++++++++++- .../appkit/src/plugins/database/database.ts | 80 +++-- .../src/plugins/database/entity-client.ts | 213 ++++++++++++- packages/appkit/src/plugins/database/hooks.ts | 73 +++++ packages/appkit/src/plugins/database/index.ts | 10 +- .../appkit/src/plugins/database/lifecycle.ts | 87 ++++-- packages/appkit/src/plugins/database/scope.ts | 68 ++++ .../plugins/database/tests/crud-span.test.ts | 96 ++++-- .../database/tests/crud.integration.test.ts | 293 ++++++++++++++++++ .../database/tests/entity-client.test.ts | 273 ++++++++++++++-- .../plugins/database/tests/lifecycle.test.ts | 134 +++++++- .../src/plugins/database/tests/plugin.test.ts | 31 +- .../src/plugins/database/tests/scope.test.ts | 63 ++++ packages/appkit/src/plugins/database/types.ts | 25 +- 42 files changed, 2684 insertions(+), 242 deletions(-) create mode 100644 docs/docs/api/appkit/Class.DatabaseValidationError.md create mode 100644 docs/docs/api/appkit/Interface.DatabaseValidationIssue.md create mode 100644 docs/docs/api/appkit/Interface.EntityMutationHooks.md create mode 100644 docs/docs/api/appkit/Interface.HookApp.md create mode 100644 docs/docs/api/appkit/Interface.HookContext.md create mode 100644 docs/docs/api/appkit/Interface.ReadSerializerContext.md create mode 100644 docs/docs/api/appkit/TypeAlias.EntityHooks.md create mode 100644 docs/docs/api/appkit/TypeAlias.ReadSerializer.md create mode 100644 docs/docs/api/appkit/TypeAlias.TransactionClient.md create mode 100644 packages/appkit/src/errors/database-validation.ts create mode 100644 packages/appkit/src/plugins/database/crud/request.ts create mode 100644 packages/appkit/src/plugins/database/crud/response.ts create mode 100644 packages/appkit/src/plugins/database/crud/tests/request.test.ts create mode 100644 packages/appkit/src/plugins/database/hooks.ts create mode 100644 packages/appkit/src/plugins/database/scope.ts create mode 100644 packages/appkit/src/plugins/database/tests/crud.integration.test.ts create mode 100644 packages/appkit/src/plugins/database/tests/scope.test.ts diff --git a/docs/docs/api/appkit/Class.AppKitError.md b/docs/docs/api/appkit/Class.AppKitError.md index 18b852d26..1311a903e 100644 --- a/docs/docs/api/appkit/Class.AppKitError.md +++ b/docs/docs/api/appkit/Class.AppKitError.md @@ -30,6 +30,7 @@ console.error(error.toJSON()); // Safe for logging, sensitive values redacted - [`AuthenticationError`](Class.AuthenticationError.md) - [`ConfigurationError`](Class.ConfigurationError.md) - [`ConnectionError`](Class.ConnectionError.md) +- [`DatabaseValidationError`](Class.DatabaseValidationError.md) - [`ExecutionError`](Class.ExecutionError.md) - [`InitializationError`](Class.InitializationError.md) - [`ServerError`](Class.ServerError.md) diff --git a/docs/docs/api/appkit/Class.DatabaseValidationError.md b/docs/docs/api/appkit/Class.DatabaseValidationError.md new file mode 100644 index 000000000..d9315dbf6 --- /dev/null +++ b/docs/docs/api/appkit/Class.DatabaseValidationError.md @@ -0,0 +1,190 @@ +# Class: DatabaseValidationError + +Deliberate validation failure raised by a database mutation hook. Generated +routes answer `422` and echo only the issues naming a public column; every +other failure raised inside a hook stays an opaque server error. + +## Extends + +- [`AppKitError`](Class.AppKitError.md) + +## Constructors + +### Constructor + +```ts +new DatabaseValidationError(message: string, issues: readonly DatabaseValidationIssue[]): DatabaseValidationError; +``` + +#### Parameters + +| Parameter | Type | Default value | +| ------ | ------ | ------ | +| `message` | `string` | `undefined` | +| `issues` | readonly [`DatabaseValidationIssue`](Interface.DatabaseValidationIssue.md)[] | `[]` | + +#### Returns + +`DatabaseValidationError` + +#### Overrides + +[`AppKitError`](Class.AppKitError.md).[`constructor`](Class.AppKitError.md#constructor) + +## Properties + +### \_clientMessage? + +```ts +protected readonly optional _clientMessage: string; +``` + +Client-safe error message. When set, callers serializing the error to +a client (SSE, HTTP body) MUST prefer `clientMessage` over `message` +— `message` may contain raw upstream / SDK text including statement +fragments, internal object names, and correlation IDs. + +Subclasses can set this in their constructor for a fixed sanitized +string. When unset, `clientMessage` defaults to a generic per-code +string (see the getter), and the raw `message` is kept server-side +only. + +#### Inherited from + +[`AppKitError`](Class.AppKitError.md).[`_clientMessage`](Class.AppKitError.md#_clientmessage) + +*** + +### cause? + +```ts +readonly optional cause: Error; +``` + +Optional cause of the error + +#### Inherited from + +[`AppKitError`](Class.AppKitError.md).[`cause`](Class.AppKitError.md#cause) + +*** + +### code + +```ts +readonly code: "DATABASE_VALIDATION_ERROR" = "DATABASE_VALIDATION_ERROR"; +``` + +Error code for programmatic error handling + +#### Overrides + +[`AppKitError`](Class.AppKitError.md).[`code`](Class.AppKitError.md#code) + +*** + +### context? + +```ts +readonly optional context: Record; +``` + +Additional context for the error + +#### Inherited from + +[`AppKitError`](Class.AppKitError.md).[`context`](Class.AppKitError.md#context) + +*** + +### isRetryable + +```ts +readonly isRetryable: false = false; +``` + +Whether this error type is generally safe to retry + +#### Overrides + +[`AppKitError`](Class.AppKitError.md).[`isRetryable`](Class.AppKitError.md#isretryable) + +*** + +### issues + +```ts +readonly issues: readonly DatabaseValidationIssue[]; +``` + +*** + +### statusCode + +```ts +readonly statusCode: 422 = 422; +``` + +HTTP status code suggestion (can be overridden) + +#### Overrides + +[`AppKitError`](Class.AppKitError.md).[`statusCode`](Class.AppKitError.md#statuscode) + +## Accessors + +### clientMessage + +#### Get Signature + +```ts +get clientMessage(): string; +``` + +Sanitized message safe to forward to clients. Override in subclasses +if a more specific default is appropriate. + +##### Returns + +`string` + +#### Inherited from + +[`AppKitError`](Class.AppKitError.md).[`clientMessage`](Class.AppKitError.md#clientmessage) + +## Methods + +### toJSON() + +```ts +toJSON(): Record; +``` + +Convert error to JSON for logging/serialization. +Sensitive values in context are automatically redacted. + +#### Returns + +`Record`\<`string`, `unknown`\> + +#### Inherited from + +[`AppKitError`](Class.AppKitError.md).[`toJSON`](Class.AppKitError.md#tojson) + +*** + +### toString() + +```ts +toString(): string; +``` + +Create a human-readable string representation + +#### Returns + +`string` + +#### Inherited from + +[`AppKitError`](Class.AppKitError.md).[`toString`](Class.AppKitError.md#tostring) diff --git a/docs/docs/api/appkit/Interface.DatabaseValidationIssue.md b/docs/docs/api/appkit/Interface.DatabaseValidationIssue.md new file mode 100644 index 000000000..41584850d --- /dev/null +++ b/docs/docs/api/appkit/Interface.DatabaseValidationIssue.md @@ -0,0 +1,19 @@ +# Interface: DatabaseValidationIssue + +One rejected field; `path` names public columns, never their values. + +## Properties + +### message + +```ts +readonly message: string; +``` + +*** + +### path + +```ts +readonly path: readonly string[]; +``` diff --git a/docs/docs/api/appkit/Interface.EntityMutationHooks.md b/docs/docs/api/appkit/Interface.EntityMutationHooks.md new file mode 100644 index 000000000..1cc9a8da2 --- /dev/null +++ b/docs/docs/api/appkit/Interface.EntityMutationHooks.md @@ -0,0 +1,170 @@ +# Interface: EntityMutationHooks\ + +Mutation lifecycle for one entity. A before hook may return a replacement +payload, which is revalidated against the trusted schema before it is +persisted. Every hook, the mutation, and any write a hook issues through +`ctx.app.database` share one transaction, so a rejection anywhere rolls all +of them back. Throw `DatabaseValidationError` to answer a generated route +with `422`; any other failure stays an opaque server error. + +## Type Parameters + +| Type Parameter | Default type | +| ------ | ------ | +| `TTable` *extends* `string` | `string` | + +## Methods + +### afterCreate()? + +```ts +optional afterCreate(row: FacetOf, context: HookContext): MaybePromise; +``` + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `row` | `FacetOf`\<`TTable`, `"row"`\> | +| `context` | [`HookContext`](Interface.HookContext.md) | + +#### Returns + +`MaybePromise`\<`void`\> + +*** + +### afterDelete()? + +```ts +optional afterDelete(id: IdValue, context: HookContext): MaybePromise; +``` + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `id` | `IdValue` | +| `context` | [`HookContext`](Interface.HookContext.md) | + +#### Returns + +`MaybePromise`\<`void`\> + +*** + +### afterUpdate()? + +```ts +optional afterUpdate(row: FacetOf, context: HookContext): MaybePromise; +``` + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `row` | `FacetOf`\<`TTable`, `"row"`\> | +| `context` | [`HookContext`](Interface.HookContext.md) | + +#### Returns + +`MaybePromise`\<`void`\> + +*** + +### afterUpsert()? + +```ts +optional afterUpsert(row: FacetOf, context: HookContext): MaybePromise; +``` + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `row` | `FacetOf`\<`TTable`, `"row"`\> | +| `context` | [`HookContext`](Interface.HookContext.md) | + +#### Returns + +`MaybePromise`\<`void`\> + +*** + +### beforeCreate()? + +```ts +optional beforeCreate(values: FacetOf, context: HookContext): MaybePromise>; +``` + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `values` | `FacetOf`\<`TTable`, `"insert"`\> | +| `context` | [`HookContext`](Interface.HookContext.md) | + +#### Returns + +`MaybePromise`\<`void` \| `FacetOf`\<`TTable`, `"insert"`\>\> + +*** + +### beforeDelete()? + +```ts +optional beforeDelete(id: IdValue, context: HookContext): MaybePromise; +``` + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `id` | `IdValue` | +| `context` | [`HookContext`](Interface.HookContext.md) | + +#### Returns + +`MaybePromise`\<`void`\> + +*** + +### beforeUpdate()? + +```ts +optional beforeUpdate( + id: IdValue, + values: FacetOf, +context: HookContext): MaybePromise>; +``` + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `id` | `IdValue` | +| `values` | `FacetOf`\<`TTable`, `"update"`\> | +| `context` | [`HookContext`](Interface.HookContext.md) | + +#### Returns + +`MaybePromise`\<`void` \| `FacetOf`\<`TTable`, `"update"`\>\> + +*** + +### beforeUpsert()? + +```ts +optional beforeUpsert(values: FacetOf, context: HookContext): MaybePromise>; +``` + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `values` | `FacetOf`\<`TTable`, `"insert"`\> | +| `context` | [`HookContext`](Interface.HookContext.md) | + +#### Returns + +`MaybePromise`\<`void` \| `FacetOf`\<`TTable`, `"insert"`\>\> diff --git a/docs/docs/api/appkit/Interface.HookApp.md b/docs/docs/api/appkit/Interface.HookApp.md new file mode 100644 index 000000000..0f9189fac --- /dev/null +++ b/docs/docs/api/appkit/Interface.HookApp.md @@ -0,0 +1,11 @@ +# Interface: HookApp + +The only capability a hook receives: entities bound to its transaction. + +## Properties + +### database + +```ts +readonly database: TransactionClient; +``` diff --git a/docs/docs/api/appkit/Interface.HookContext.md b/docs/docs/api/appkit/Interface.HookContext.md new file mode 100644 index 000000000..62a956c75 --- /dev/null +++ b/docs/docs/api/appkit/Interface.HookContext.md @@ -0,0 +1,19 @@ +# Interface: HookContext + +Which entity is being mutated, and the surface a hook may write through. + +## Properties + +### app + +```ts +readonly app: HookApp; +``` + +*** + +### entity + +```ts +readonly entity: string; +``` diff --git a/docs/docs/api/appkit/Interface.ReadSerializerContext.md b/docs/docs/api/appkit/Interface.ReadSerializerContext.md new file mode 100644 index 000000000..d8dc3b4ad --- /dev/null +++ b/docs/docs/api/appkit/Interface.ReadSerializerContext.md @@ -0,0 +1,19 @@ +# Interface: ReadSerializerContext + +Which entity and generated operation produced the row being shaped. + +## Properties + +### entity + +```ts +readonly entity: string; +``` + +*** + +### operation + +```ts +readonly operation: "detail" | "list"; +``` diff --git a/docs/docs/api/appkit/TypeAlias.DatabaseExports.md b/docs/docs/api/appkit/TypeAlias.DatabaseExports.md index c076dab0a..1d9381668 100644 --- a/docs/docs/api/appkit/TypeAlias.DatabaseExports.md +++ b/docs/docs/api/appkit/TypeAlias.DatabaseExports.md @@ -26,7 +26,7 @@ transaction(callback: (tx: TransactionClient) => Promise): Promise; | Parameter | Type | | ------ | ------ | -| `callback` | (`tx`: `TransactionClient`) => `Promise`\<`T`\> | +| `callback` | (`tx`: [`TransactionClient`](TypeAlias.TransactionClient.md)) => `Promise`\<`T`\> | #### Returns diff --git a/docs/docs/api/appkit/TypeAlias.EntityHooks.md b/docs/docs/api/appkit/TypeAlias.EntityHooks.md new file mode 100644 index 000000000..19ef8afa9 --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.EntityHooks.md @@ -0,0 +1,23 @@ +# Type Alias: EntityHooks\ + +```ts +type EntityHooks = EntityMutationHooks & { + serialize?: ReadSerializer; +}; +``` + +Response shaping and mutation lifecycle declared for one table. + +## Type Declaration + +### serialize? + +```ts +readonly optional serialize: ReadSerializer; +``` + +## Type Parameters + +| Type Parameter | Default type | +| ------ | ------ | +| `TTable` *extends* `string` | `string` | diff --git a/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md b/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md index d95217a4e..b238e8bc1 100644 --- a/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md +++ b/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md @@ -3,7 +3,7 @@ ```ts type IDatabaseConfig = { crudRoutes?: CrudRoutesConfig; - hooks?: { readonly [TTable in SchemaTableName]?: { serialize?: ReadSerializer } }; + hooks?: { readonly [TTable in SchemaTableName]?: EntityHooks }; schema: TSchema; }; ``` @@ -29,7 +29,7 @@ readonly optional crudRoutes: CrudRoutesConfig; ### hooks? ```ts -readonly optional hooks: { readonly [TTable in SchemaTableName]?: { serialize?: ReadSerializer } }; +readonly optional hooks: { readonly [TTable in SchemaTableName]?: EntityHooks }; ``` *** diff --git a/docs/docs/api/appkit/TypeAlias.ReadSerializer.md b/docs/docs/api/appkit/TypeAlias.ReadSerializer.md new file mode 100644 index 000000000..b036d9ad7 --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.ReadSerializer.md @@ -0,0 +1,20 @@ +# Type Alias: ReadSerializer() + +```ts +type ReadSerializer = (row: Record, context: ReadSerializerContext) => Record; +``` + +Shape one already private-safe row before it reaches the wire. A `Promise` +is not assignable to the return type, so an async callback fails to compile: +serializers run inside the response path and must not add latency there. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `row` | `Record`\<`string`, `unknown`\> | +| `context` | [`ReadSerializerContext`](Interface.ReadSerializerContext.md) | + +## Returns + +`Record`\<`string`, `unknown`\> diff --git a/docs/docs/api/appkit/TypeAlias.TransactionClient.md b/docs/docs/api/appkit/TypeAlias.TransactionClient.md new file mode 100644 index 000000000..747cb2ef2 --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.TransactionClient.md @@ -0,0 +1,17 @@ +# Type Alias: TransactionClient + +```ts +type TransactionClient = { readonly [K in EntityName]: TypedEntityClient } & { + sql: SqlTag; +}; +``` + +Entity and SQL capabilities bound to one transaction. + +## Type Declaration + +### sql + +```ts +readonly sql: SqlTag; +``` diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index 36d752e56..fae8ca2eb 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -19,6 +19,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [AuthenticationError](Class.AuthenticationError.md) | Error thrown when authentication fails. Use for missing tokens, invalid credentials, or authorization failures. | | [ConfigurationError](Class.ConfigurationError.md) | Error thrown when configuration is missing or invalid. Use for missing environment variables, invalid settings, or setup issues. | | [ConnectionError](Class.ConnectionError.md) | Error thrown when a connection or network operation fails. Use for database pool errors, API failures, timeouts, etc. | +| [DatabaseValidationError](Class.DatabaseValidationError.md) | Deliberate validation failure raised by a database mutation hook. Generated routes answer `422` and echo only the issues naming a public column; every other failure raised inside a hook stays an opaque server error. | | [DatabricksAdapter](Class.DatabricksAdapter.md) | Adapter that talks directly to Databricks Model Serving `/invocations` endpoint. | | [ExecutionError](Class.ExecutionError.md) | Error thrown when an operation execution fails. Use for statement failures, canceled operations, or unexpected states. | | [InitializationError](Class.InitializationError.md) | Error thrown when a service or component is not properly initialized. Use when accessing services before they are ready. | @@ -45,12 +46,16 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [CacheConfig](Interface.CacheConfig.md) | Configuration for the CacheInterceptor. Controls TTL, size limits, storage backend, and probabilistic cleanup. | | [DatabaseCredential](Interface.DatabaseCredential.md) | Database credentials with OAuth token for Postgres connection | | [DatabaseRegistry](Interface.DatabaseRegistry.md) | CANONICAL augmentation target. Empty by default; the generated `database.d.ts` augments it via `declare module "@databricks/appkit" { interface DatabaseRegistry { ... } }`. | +| [DatabaseValidationIssue](Interface.DatabaseValidationIssue.md) | One rejected field; `path` names public columns, never their values. | | [EndpointConfig](Interface.EndpointConfig.md) | - | +| [EntityMutationHooks](Interface.EntityMutationHooks.md) | Mutation lifecycle for one entity. A before hook may return a replacement payload, which is revalidated against the trusted schema before it is persisted. Every hook, the mutation, and any write a hook issues through `ctx.app.database` share one transaction, so a rejection anywhere rolls all of them back. Throw `DatabaseValidationError` to answer a generated route with `422`; any other failure stays an opaque server error. | | [FilePolicyUser](Interface.FilePolicyUser.md) | Minimal user identity passed to the policy function. | | [FileResource](Interface.FileResource.md) | Describes the file or directory being acted upon. | | [FunctionTool](Interface.FunctionTool.md) | - | | [GenerateDatabaseCredentialRequest](Interface.GenerateDatabaseCredentialRequest.md) | Request parameters for generating database OAuth credentials | | [GenerationParams](Interface.GenerationParams.md) | Optional generation parameters forwarded to the OpenAI-compatible serving request body. Names match the serving API wire keys. Only keys that are set are sent — undefined values are omitted so the endpoint applies its own defaults. Ranges are not validated here; the serving endpoint validates. | +| [HookApp](Interface.HookApp.md) | The only capability a hook receives: entities bound to its transaction. | +| [HookContext](Interface.HookContext.md) | Which entity is being mutated, and the surface a hook may write through. | | [HostedSupervisorTool](Interface.HostedSupervisorTool.md) | Tagged record returned by every [supervisorTools](Variable.supervisorTools.md) factory. The `__kind` discriminator lets the agents plugin (and standalone `runAgent`) classify these tools without a structural match against the wire format — keeps the SA wire shape free to evolve and avoids namespace collisions with MCP hosted tools (which use `type: "genie-space"` hyphenated, vs SA's `type: "genie_space"` underscored). | | [IAiSearchConfig](Interface.IAiSearchConfig.md) | Base configuration interface for AppKit plugins | | [IJobsConfig](Interface.IJobsConfig.md) | Configuration for the Jobs plugin. | @@ -67,6 +72,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [PluginManifest](Interface.PluginManifest.md) | Plugin manifest that declares metadata and resource requirements. Attached to plugin classes as a static property. Extends the shared PluginManifest with strict resource types. | | [PluginToolkitProvider](Interface.PluginToolkitProvider.md) | Minimum shape every entry in the [Plugins](TypeAlias.Plugins.md) map must expose. Core plugins (analytics, files, genie, lakebase) implement this directly via their `.toolkit()` method. The agents plugin and standalone `runAgent` synthesize this shape for any registered plugin that doesn't implement `.toolkit()` directly (falling back to `getAgentTools()` walking). | | [PromptContext](Interface.PromptContext.md) | Context passed to `baseSystemPrompt` callbacks. | +| [ReadSerializerContext](Interface.ReadSerializerContext.md) | Which entity and generated operation produced the row being shaped. | | [RegisteredAgent](Interface.RegisteredAgent.md) | - | | [RequestedClaims](Interface.RequestedClaims.md) | Optional claims for fine-grained Unity Catalog table permissions When specified, the returned token will be scoped to only the requested tables | | [RequestedResource](Interface.RequestedResource.md) | Resource to request permissions for in Unity Catalog | @@ -109,6 +115,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [BaseSystemPromptOption](TypeAlias.BaseSystemPromptOption.md) | - | | [ConfigSchema](TypeAlias.ConfigSchema.md) | Configuration schema definition for plugin config. Re-exported from the standard JSON Schema Draft 7 types. | | [DatabaseExports](TypeAlias.DatabaseExports.md) | Typed database API published by the plugin. | +| [EntityHooks](TypeAlias.EntityHooks.md) | Response shaping and mutation lifecycle declared for one table. | | [ExecutionResult](TypeAlias.ExecutionResult.md) | Discriminated union for plugin execution results. | | [FileAction](TypeAlias.FileAction.md) | Every action the files plugin can perform. | | [FilePolicy](TypeAlias.FilePolicy.md) | A policy function that decides whether `user` may perform `action` on `resource`. Return `true` to allow, `false` to deny. | @@ -118,6 +125,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [JobsExport](TypeAlias.JobsExport.md) | Public API shape of the jobs plugin. Callable to select a job by key. | | [PluginData](TypeAlias.PluginData.md) | Tuple of plugin class, config, and name. Created by `toPlugin()` and passed to `createApp()`. | | [Plugins](TypeAlias.Plugins.md) | Plugin map passed to the function form of [AgentDefinition.tools](Interface.AgentDefinition.md#tools). Each entry exposes a `.toolkit(opts?)` method that returns a record of [ToolkitEntry](Interface.ToolkitEntry.md) markers ready to be spread into a tool record. | +| [ReadSerializer](TypeAlias.ReadSerializer.md) | Shape one already private-safe row before it reaches the wire. A `Promise` is not assignable to the return type, so an async callback fails to compile: serializers run inside the response path and must not add latency there. | | [ResolvedToolEntry](TypeAlias.ResolvedToolEntry.md) | Internal tool-index entry after a tool record has been resolved to a dispatchable form. | | [ResourceFieldEntry](TypeAlias.ResourceFieldEntry.md) | - | | [ResourcePermission](TypeAlias.ResourcePermission.md) | Union of all possible permission levels across all resource types. | @@ -126,6 +134,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [SupervisorTool](TypeAlias.SupervisorTool.md) | Tools supported by the Databricks AI Gateway Responses API. The shapes match the wire format the endpoint expects, so the adapter passes the array straight into the request body. | | [ToolRegistry](TypeAlias.ToolRegistry.md) | - | | [ToPlugin](TypeAlias.ToPlugin.md) | Factory function type returned by `toPlugin()`. Accepts optional config and returns a PluginData tuple. | +| [TransactionClient](TypeAlias.TransactionClient.md) | Entity and SQL capabilities bound to one transaction. | ## Variables diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index 3eedd8ad8..cf354b2a9 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -46,6 +46,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Class.ConnectionError", label: "ConnectionError" }, + { + type: "doc", + id: "api/appkit/Class.DatabaseValidationError", + label: "DatabaseValidationError" + }, { type: "doc", id: "api/appkit/Class.DatabricksAdapter", @@ -157,11 +162,21 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.DatabaseRegistry", label: "DatabaseRegistry" }, + { + type: "doc", + id: "api/appkit/Interface.DatabaseValidationIssue", + label: "DatabaseValidationIssue" + }, { type: "doc", id: "api/appkit/Interface.EndpointConfig", label: "EndpointConfig" }, + { + type: "doc", + id: "api/appkit/Interface.EntityMutationHooks", + label: "EntityMutationHooks" + }, { type: "doc", id: "api/appkit/Interface.FilePolicyUser", @@ -187,6 +202,16 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.GenerationParams", label: "GenerationParams" }, + { + type: "doc", + id: "api/appkit/Interface.HookApp", + label: "HookApp" + }, + { + type: "doc", + id: "api/appkit/Interface.HookContext", + label: "HookContext" + }, { type: "doc", id: "api/appkit/Interface.HostedSupervisorTool", @@ -267,6 +292,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.PromptContext", label: "PromptContext" }, + { + type: "doc", + id: "api/appkit/Interface.ReadSerializerContext", + label: "ReadSerializerContext" + }, { type: "doc", id: "api/appkit/Interface.RegisteredAgent", @@ -458,6 +488,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/TypeAlias.DatabaseExports", label: "DatabaseExports" }, + { + type: "doc", + id: "api/appkit/TypeAlias.EntityHooks", + label: "EntityHooks" + }, { type: "doc", id: "api/appkit/TypeAlias.ExecutionResult", @@ -503,6 +538,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/TypeAlias.Plugins", label: "Plugins" }, + { + type: "doc", + id: "api/appkit/TypeAlias.ReadSerializer", + label: "ReadSerializer" + }, { type: "doc", id: "api/appkit/TypeAlias.ResolvedToolEntry", @@ -542,6 +582,11 @@ const typedocSidebar: SidebarsConfig = { type: "doc", id: "api/appkit/TypeAlias.ToPlugin", label: "ToPlugin" + }, + { + type: "doc", + id: "api/appkit/TypeAlias.TransactionClient", + label: "TransactionClient" } ] }, diff --git a/packages/appkit/src/beta.ts b/packages/appkit/src/beta.ts index 4de7ba79c..cb972e05e 100644 --- a/packages/appkit/src/beta.ts +++ b/packages/appkit/src/beta.ts @@ -118,4 +118,14 @@ export type { SearchResult, } from "./plugins/ai-search/types"; export * from "./plugins/beta-exports.generated"; -export type { DatabaseExports, IDatabaseConfig } from "./plugins/database"; +export type { + DatabaseExports, + EntityHooks, + EntityMutationHooks, + HookApp, + HookContext, + IDatabaseConfig, + ReadSerializer, + ReadSerializerContext, + TransactionClient, +} from "./plugins/database"; diff --git a/packages/appkit/src/database/errors.ts b/packages/appkit/src/database/errors.ts index d4be7e6fa..dff51ca9d 100644 --- a/packages/appkit/src/database/errors.ts +++ b/packages/appkit/src/database/errors.ts @@ -5,9 +5,11 @@ const logger = createLogger("database"); export type DatabaseErrorCategory = | "INVALID_REQUEST" + | "VALIDATION_FAILED" | "NOT_FOUND" | "CONFLICT" | "FORBIDDEN" + | "UNSUPPORTED_MEDIA_TYPE" | "PAYLOAD_TOO_LARGE" | "INTERNAL" | "SETUP_FAILED"; @@ -31,9 +33,17 @@ const definitions: Record< { readonly message: string; readonly statusCode: number } > = { INVALID_REQUEST: { message: "Invalid database request", statusCode: 400 }, + VALIDATION_FAILED: { + message: "Database request failed validation", + statusCode: 422, + }, NOT_FOUND: { message: "Database record not found", statusCode: 404 }, CONFLICT: { message: "Database conflict", statusCode: 409 }, FORBIDDEN: { message: "Database operation forbidden", statusCode: 403 }, + UNSUPPORTED_MEDIA_TYPE: { + message: "Database request body must be JSON", + statusCode: 415, + }, PAYLOAD_TOO_LARGE: { message: "Database response is too large", statusCode: 413, @@ -103,9 +113,15 @@ export function classifyDatabaseError( phase: DatabaseErrorPhase, ): DatabasePluginError { if (error instanceof DatabasePluginError) { + // Details name request fields only, so they survive a change of phase. return error.phase === phase ? error - : new DatabasePluginError(error.category, phase); + : new DatabasePluginError( + error.category, + phase, + undefined, + error.details, + ); } logger.error("Unclassified database error during %s: %O", phase, error); return new DatabasePluginError("INTERNAL", phase); diff --git a/packages/appkit/src/database/runtime/data-path.ts b/packages/appkit/src/database/runtime/data-path.ts index 54434fabe..c8d83a64b 100644 --- a/packages/appkit/src/database/runtime/data-path.ts +++ b/packages/appkit/src/database/runtime/data-path.ts @@ -67,11 +67,20 @@ export interface DataPath { /** Return exactly one inserted row; zero or many is an invariant failure. */ insert(table: AppKitTable, values: Row): Promise; /** Return null for zero updated rows and reject more than one. */ - update(table: AppKitTable, id: IdValue, values: Row): Promise; + update( + table: AppKitTable, + id: IdValue, + values: Row, + where?: WhereClause, + ): Promise; /** Return exactly one row for a validated primary-key or unique conflict. */ upsert(table: AppKitTable, values: Row, onConflict: string): Promise; /** Return false for zero deleted rows, true for one, and reject many. */ - delete(table: AppKitTable, id: IdValue): Promise; + delete( + table: AppKitTable, + id: IdValue, + where?: WhereClause, + ): Promise; /** Execute tagged SQL whose interpolations are parameter values, not SQL. */ raw( strings: TemplateStringsArray, diff --git a/packages/appkit/src/database/runtime/engine/drizzle-data-path.ts b/packages/appkit/src/database/runtime/engine/drizzle-data-path.ts index b79cb54c4..b27d4e476 100644 --- a/packages/appkit/src/database/runtime/engine/drizzle-data-path.ts +++ b/packages/appkit/src/database/runtime/engine/drizzle-data-path.ts @@ -192,6 +192,19 @@ export function createDrizzleDataPath(db: DrizzleDb, schema: Schema): DataPath { where?: WhereClause, ): SQL | undefined => where === undefined ? undefined : translateWhere(table, where); + /** Narrow by the primary key while preserving an accumulated predicate. */ + const keyedWhere = ( + table: AppKitTable, + primaryKey: ColumnMeta, + id: IdValue, + where?: WhereClause, + ): SQL | undefined => + where === undefined + ? eq(columnOf(table, primaryKey.columnName), id) + : translateWhere( + table, + andWhere({ [primaryKey.columnName]: { eq: id } }, where), + ); return { async select(table, spec) { @@ -256,7 +269,7 @@ export function createDrizzleDataPath(db: DrizzleDb, schema: Schema): DataPath { return expectExactlyOne(rows as Row[]); }, - async update(table, id, values) { + async update(table, id, values, where) { const engineTable = pgTable(table); const parameters = mutationValues(table, values); const { meta: primaryKey, value: validatedId } = validatedPrimaryKey( @@ -267,7 +280,7 @@ export function createDrizzleDataPath(db: DrizzleDb, schema: Schema): DataPath { db .update(engineTable) .set(parameters) - .where(eq(columnOf(table, primaryKey.columnName), validatedId)) + .where(keyedWhere(table, primaryKey, validatedId, where)) .returning(), ); return expectZeroOrOne(rows as Row[]); @@ -290,7 +303,7 @@ export function createDrizzleDataPath(db: DrizzleDb, schema: Schema): DataPath { return expectExactlyOne(rows as Row[]); }, - async delete(table, id) { + async delete(table, id, where) { const { meta: primaryKey, value: validatedId } = validatedPrimaryKey( table, id, @@ -298,7 +311,7 @@ export function createDrizzleDataPath(db: DrizzleDb, schema: Schema): DataPath { const rows = await runDatabaseOperation(() => db .delete(pgTable(table)) - .where(eq(columnOf(table, primaryKey.columnName), validatedId)) + .where(keyedWhere(table, primaryKey, validatedId, where)) .returning({ id: columnOf(table, primaryKey.columnName) }), ); return expectZeroOrOne(rows as Row[]) !== null; diff --git a/packages/appkit/src/errors/database-validation.ts b/packages/appkit/src/errors/database-validation.ts new file mode 100644 index 000000000..49a61a9cd --- /dev/null +++ b/packages/appkit/src/errors/database-validation.ts @@ -0,0 +1,30 @@ +import { AppKitError } from "./base"; + +/** Cap how many issues one rejection can put on the wire. */ +const MAX_ISSUES = 50; + +/** One rejected field; `path` names public columns, never their values. */ +export interface DatabaseValidationIssue { + readonly path: readonly string[]; + readonly message: string; +} + +/** + * Deliberate validation failure raised by a database mutation hook. Generated + * routes answer `422` and echo only the issues naming a public column; every + * other failure raised inside a hook stays an opaque server error. + */ +export class DatabaseValidationError extends AppKitError { + readonly code = "DATABASE_VALIDATION_ERROR"; + readonly statusCode = 422; + readonly isRetryable = false; + readonly issues: readonly DatabaseValidationIssue[]; + + constructor( + message: string, + issues: readonly DatabaseValidationIssue[] = [], + ) { + super(message, { clientMessage: "Database request failed validation" }); + this.issues = issues.slice(0, MAX_ISSUES); + } +} diff --git a/packages/appkit/src/errors/index.ts b/packages/appkit/src/errors/index.ts index a367b843c..22ab10f0c 100644 --- a/packages/appkit/src/errors/index.ts +++ b/packages/appkit/src/errors/index.ts @@ -23,6 +23,10 @@ export { AuthenticationError } from "./authentication"; export { AppKitError } from "./base"; export { ConfigurationError } from "./configuration"; export { ConnectionError } from "./connection"; +export { + DatabaseValidationError, + type DatabaseValidationIssue, +} from "./database-validation"; export { ExecutionError } from "./execution"; export { InitializationError } from "./initialization"; export { ServerError } from "./server"; diff --git a/packages/appkit/src/errors/tests/errors.test.ts b/packages/appkit/src/errors/tests/errors.test.ts index 347ce1c0d..6a588bb98 100644 --- a/packages/appkit/src/errors/tests/errors.test.ts +++ b/packages/appkit/src/errors/tests/errors.test.ts @@ -4,6 +4,7 @@ import { AuthenticationError, ConfigurationError, ConnectionError, + DatabaseValidationError, ExecutionError, InitializationError, ServerError, @@ -402,6 +403,41 @@ describe("TunnelError", () => { }); }); +describe("DatabaseValidationError", () => { + test("carries a fixed safe message and a 422", () => { + const error = new DatabaseValidationError( + "note body 'top secret' violates rule #4", + [{ path: ["body"], message: "must not be empty" }], + ); + expect(error.code).toBe("DATABASE_VALIDATION_ERROR"); + expect(error.statusCode).toBe(422); + expect(error.isRetryable).toBe(false); + expect(error.clientMessage).toBe("Database request failed validation"); + expect(error.clientMessage).not.toContain("top secret"); + expect(error.issues).toEqual([ + { path: ["body"], message: "must not be empty" }, + ]); + }); + + test("defaults to no issues and bounds how many it keeps", () => { + expect(new DatabaseValidationError("invalid").issues).toEqual([]); + const many = Array.from({ length: 200 }, (_, index) => ({ + path: [`field${index}`], + message: "invalid", + })); + expect(new DatabaseValidationError("invalid", many).issues).toHaveLength( + 50, + ); + }); + + test("keeps its issue list detached from the caller's array", () => { + const issues = [{ path: ["body"], message: "must not be empty" }]; + const error = new DatabaseValidationError("invalid", issues); + issues.push({ path: ["token"], message: "leaked" }); + expect(error.issues).toHaveLength(1); + }); +}); + describe("Error hierarchy", () => { test("all errors should extend AppKitError", () => { expect(new ValidationError("test")).toBeInstanceOf(AppKitError); @@ -412,6 +448,7 @@ describe("Error hierarchy", () => { expect(new InitializationError("test")).toBeInstanceOf(AppKitError); expect(new ServerError("test")).toBeInstanceOf(AppKitError); expect(new TunnelError("test")).toBeInstanceOf(AppKitError); + expect(new DatabaseValidationError("test")).toBeInstanceOf(AppKitError); }); test("errors can be caught by base class", () => { diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index 548151ed5..eff19541b 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -45,6 +45,8 @@ export { AuthenticationError, ConfigurationError, ConnectionError, + DatabaseValidationError, + type DatabaseValidationIssue, ExecutionError, InitializationError, ServerError, diff --git a/packages/appkit/src/plugins/database/crud/contract.ts b/packages/appkit/src/plugins/database/crud/contract.ts index e0bf5a060..7664c1a9f 100644 --- a/packages/appkit/src/plugins/database/crud/contract.ts +++ b/packages/appkit/src/plugins/database/crud/contract.ts @@ -1,8 +1,5 @@ -import { - DatabasePluginError, - invalidDatabaseInput, -} from "../../../database/errors"; -import type { IdValue, Row } from "../../../database/runtime"; +import { DatabasePluginError } from "../../../database/errors"; +import type { Row } from "../../../database/runtime"; import type { AppKitTable } from "../../../database/schema-builder"; import { filterOperatorsForKind } from "../../../database/schema-builder/types"; import { MAX_SERIALIZED_DEPTH, MAX_SERIALIZED_NODES } from "../defaults"; @@ -23,8 +20,11 @@ export interface CrudTable { readonly selectable: ReadonlySet; /** Public columns a request may filter or order by. */ readonly queryable: ReadonlySet; + /** Public columns a create body may set, including a caller-chosen key. */ + readonly creatable: ReadonlySet; + /** Public columns an update body may set; a key or a stamp is never one. */ + readonly updatable: ReadonlySet; readonly relations: ReadonlyMap; - decodeId(raw: string): IdValue; projectPublicRow(row: Row): JsonValue; sanitizeSerializedRow(row: unknown): JsonValue; } @@ -39,7 +39,9 @@ interface SanitizeState { } /** A bare object literal; a `Date`, class instance, or `Map` is not JSON. */ -function isPlainObject(value: unknown): value is Record { +export function isPlainObject( + value: unknown, +): value is Record { if (value === null || typeof value !== "object" || Array.isArray(value)) { return false; } @@ -148,6 +150,11 @@ function sanitizeRow( }); } +/** The budget a caller's own JSON has to fit, same as the one going back out. */ +export function boundedJson(value: unknown): JsonValue { + return sanitizeJson(value, 0, { nodes: 0, ancestors: new Set() }); +} + /** Project an included row through its own table; absent to-one reads null. */ function projectRelation(target: CrudTable, value: unknown): JsonValue { if (value === null || value === undefined) return null; @@ -177,6 +184,8 @@ function compileTable(table: AppKitTable): MutableCrudTable { const columns = new Map(); const selectable = new Set(); const queryable = new Set(); + const creatable = new Set(); + const updatable = new Set(); let primaryKey: CompiledColumn | undefined; for (const meta of Object.values(table.$columns)) { @@ -188,6 +197,13 @@ function compileTable(table: AppKitTable): MutableCrudTable { if (filterOperatorsForKind(meta.kind).length > 0) { queryable.add(meta.columnName); } + // A generated identity belongs to the server, never the caller. + if (meta.serverGenerated) continue; + creatable.add(meta.columnName); + // Rewriting a key would move a row out from under every existing reference, + // and rewriting a database-materialized stamp would rewrite history. + if (meta.primaryKey || meta.defaultNow || meta.defaultRandom) continue; + updatable.add(meta.columnName); } const compiled: MutableCrudTable = { @@ -196,19 +212,9 @@ function compileTable(table: AppKitTable): MutableCrudTable { columns, selectable, queryable, + creatable, + updatable, relations: new Map(), - decodeId: (raw) => { - if (!primaryKey) throw new DatabasePluginError("INTERNAL", "read"); - const value = primaryKey.decode(raw); - if ( - typeof value !== "string" && - typeof value !== "number" && - typeof value !== "bigint" - ) { - throw invalidDatabaseInput(["id"], "Not a valid identifier"); - } - return value; - }, projectPublicRow: (row) => projectRow(compiled, row), sanitizeSerializedRow: (row) => sanitizeRow(compiled, row, 0, { nodes: 0, ancestors: new Set() }), diff --git a/packages/appkit/src/plugins/database/crud/request.ts b/packages/appkit/src/plugins/database/crud/request.ts new file mode 100644 index 000000000..2a59cd213 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/request.ts @@ -0,0 +1,79 @@ +import { + DatabasePluginError, + invalidDatabaseInput, +} from "../../../database/errors"; +import type { IdValue, Row, ScalarValue } from "../../../database/runtime"; +import type { CompiledColumn, JsonValue } from "./codecs"; +import { boundedJson, type CrudTable, isPlainObject } from "./contract"; + +/** + * Decode a path identifier against the declared key type. A keyless table gets + * no `/:id` route, so arriving here without a key is a wiring fault, not input. + */ +export function decodeId(table: CrudTable, raw: string): IdValue { + const { primaryKey } = table; + if (!primaryKey) throw new DatabasePluginError("INTERNAL", "read"); + const value = primaryKey.decode(raw); + if ( + typeof value !== "string" && + typeof value !== "number" && + typeof value !== "bigint" + ) { + throw invalidDatabaseInput(["id"], "Not a valid identifier"); + } + return value; +} + +/** Map one body value onto its column; `undefined` when it does not fit. */ +function decodeWriteValue( + column: CompiledColumn, + raw: unknown, +): ScalarValue | JsonValue | undefined { + if (raw === null) return column.meta.notNull ? undefined : null; + if (column.meta.kind !== "json") return column.decode(raw); + try { + // JSON columns accept any JSON the response budget can carry back. + return boundedJson(raw); + } catch { + return undefined; + } +} + +/** Decode one untrusted body against the columns this operation may set. */ +function decodeBody( + table: CrudTable, + writable: ReadonlySet, + raw: unknown, +): Row { + if (!isPlainObject(raw)) { + throw invalidDatabaseInput(["body"], "Expected a JSON object"); + } + const values: Row = {}; + for (const [key, value] of Object.entries(raw)) { + // Private, server-generated, and unknown fields are refused, not dropped. + const column = writable.has(key) ? table.columns.get(key) : undefined; + if (!column) { + // Naming the field echoes caller input, so only a public name is named. + throw invalidDatabaseInput( + table.selectable.has(key) ? [key] : ["body"], + "Unknown or read-only field", + ); + } + const decoded = decodeWriteValue(column, value); + if (decoded === undefined) { + throw invalidDatabaseInput([key], "Does not match the column type"); + } + values[key] = decoded; + } + return values; +} + +/** Decode the body of `POST /:table`, which may carry a caller-chosen key. */ +export function decodeCreateBody(table: CrudTable, raw: unknown): Row { + return decodeBody(table, table.creatable, raw); +} + +/** Decode the body of `PATCH /:table/:id`, which may not carry a key or stamp. */ +export function decodeUpdateBody(table: CrudTable, raw: unknown): Row { + return decodeBody(table, table.updatable, raw); +} diff --git a/packages/appkit/src/plugins/database/crud/response.ts b/packages/appkit/src/plugins/database/crud/response.ts new file mode 100644 index 000000000..12dd927e3 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/response.ts @@ -0,0 +1,99 @@ +import type { Response } from "express"; +import { + classifyDatabaseError, + type DatabaseErrorDetail, + DatabasePluginError, +} from "../../../database/errors"; +import { DatabaseValidationError } from "../../../errors"; +import { MAX_RESPONSE_BYTES } from "../defaults"; +import type { JsonValue } from "./codecs"; +import type { CrudTable } from "./contract"; + +/** Low-cardinality span outcome for one failed generated route. */ +export function routeOutcome( + error: unknown, +): "not_found" | "rejected" | "failed" { + const statusCode = + error instanceof DatabaseValidationError + ? error.statusCode + : classifyDatabaseError(error, "read").statusCode; + if (statusCode === 404) return "not_found"; + return statusCode < 500 ? "rejected" : "failed"; +} + +/** + * Row data is never cacheable by a shared proxy or a browser: the same URL can + * answer differently once the underlying table or the caller's rights change. + */ +function writeJson(res: Response, status: number, payload: string): void { + res.status(status); + res.type("application/json"); + res.setHeader("Cache-Control", "no-store"); + res.send(payload); +} + +/** Measure the encoded body before sending so no partial response escapes. */ +export function sendJson(res: Response, status: number, body: JsonValue): void { + const payload = JSON.stringify(body); + if (Buffer.byteLength(payload, "utf8") > MAX_RESPONSE_BYTES) { + throw new DatabasePluginError("PAYLOAD_TOO_LARGE", "read"); + } + writeJson(res, status, payload); +} + +/** A `204` carries no body but owes the same cache promise as one that does. */ +export function sendEmpty(res: Response, status: number): void { + res.status(status); + res.setHeader("Cache-Control", "no-store"); + res.send(); +} + +/** + * Convert a failure into its safe category. A hook's deliberate validation + * error is the one signal that reaches the caller, and only through the issues + * naming a public column of this table. + */ +function safeError( + table: CrudTable, + phase: "read" | "write", + error: unknown, +): DatabasePluginError { + if (!(error instanceof DatabaseValidationError)) { + return classifyDatabaseError(error, phase); + } + const details = error.issues + .filter( + (issue) => issue.path.length > 0 && table.selectable.has(issue.path[0]), + ) + .map((issue) => ({ path: [...issue.path], message: issue.message })); + return new DatabasePluginError( + "VALIDATION_FAILED", + phase, + undefined, + details, + ); +} + +/** Answer with the failure's safe category and the field it concerns. */ +export function sendError( + res: Response, + table: CrudTable, + phase: "read" | "write", + error: unknown, +): void { + if (res.headersSent) return; + const safe = safeError(table, phase, error); + const body: { error: string; details?: readonly DatabaseErrorDetail[] } = { + error: safe.clientMessage, + }; + if (safe.details && safe.details.length > 0) body.details = safe.details; + const payload = JSON.stringify(body); + // A failure owes the same byte budget, and its details are what can grow. + writeJson( + res, + safe.statusCode, + Buffer.byteLength(payload, "utf8") > MAX_RESPONSE_BYTES + ? JSON.stringify({ error: safe.clientMessage }) + : payload, + ); +} diff --git a/packages/appkit/src/plugins/database/crud/routes.ts b/packages/appkit/src/plugins/database/crud/routes.ts index 41829a974..5fb60f5b5 100644 --- a/packages/appkit/src/plugins/database/crud/routes.ts +++ b/packages/appkit/src/plugins/database/crud/routes.ts @@ -1,7 +1,5 @@ import type { Request, Response } from "express"; import { - classifyDatabaseError, - type DatabaseErrorDetail, DatabasePluginError, invalidDatabaseInput, } from "../../../database/errors"; @@ -12,47 +10,45 @@ import type { Row, WhereClause, } from "../../../database/runtime"; -import { MAX_RESPONSE_BYTES } from "../defaults"; import type { ReadSerializer } from "../types"; import type { JsonValue } from "./codecs"; import type { CrudTable } from "./contract"; import { decodeDetailQuery, decodeListQuery } from "./query"; - -/** The `EntityClient` subset a generated read drives. */ -export interface CrudReadEntity { - where(where: WhereClause): CrudReadEntity; - order(order: OrderSpec): CrudReadEntity; - select(columns: string[]): CrudReadEntity; - include(include: IncludeSpec): CrudReadEntity; - limit(limit: number): CrudReadEntity; - offset(offset: number): CrudReadEntity; +import { decodeCreateBody, decodeId, decodeUpdateBody } from "./request"; +import { sendEmpty, sendError, sendJson } from "./response"; + +/** The `EntityClient` subset the generated routes drive. */ +export interface CrudEntity { + where(where: WhereClause): CrudEntity; + order(order: OrderSpec): CrudEntity; + select(columns: string[]): CrudEntity; + include(include: IncludeSpec): CrudEntity; + limit(limit: number): CrudEntity; + offset(offset: number): CrudEntity; toArray(): Promise; find(id: IdValue): Promise; + create(values: Row): Promise; + update(id: IdValue, values: Row): Promise; + delete(id: IdValue): Promise; } -/** Everything one table's generated reads need from the plugin instance. */ -export interface ReadRouteDeps { +/** Which generated operation a span and its route belong to. */ +export type CrudOperation = "list" | "detail" | "create" | "update" | "delete"; + +/** Everything one table's generated routes need from the plugin instance. */ +export interface CrudRouteDeps { readonly table: CrudTable; /** Resolved per request so a draining plugin cannot serve a stale client. */ - entity(): CrudReadEntity; + entity(): CrudEntity; readonly serialize?: ReadSerializer; runRouteSpan( - operation: "list" | "detail", + operation: CrudOperation, route: string, run: () => Promise, ): Promise; } -type ReadHandler = (req: Request, res: Response) => Promise; - -/** Low-cardinality span outcome for one failed generated read. */ -export function readRouteOutcome( - error: unknown, -): "not_found" | "rejected" | "failed" { - const { statusCode } = classifyDatabaseError(error, "read"); - if (statusCode === 404) return "not_found"; - return statusCode < 500 ? "rejected" : "failed"; -} +type RouteHandler = (req: Request, res: Response) => Promise; /** Express normalizes `req.query`; the decoders need the untouched string. */ function rawQuery(req: Request): string { @@ -82,7 +78,7 @@ function stableOrder( } function serializeRow( - deps: ReadRouteDeps, + deps: CrudRouteDeps, operation: "list" | "detail", row: Row, ): JsonValue { @@ -96,38 +92,24 @@ function serializeRow( } /** - * Row data is never cacheable by a shared proxy or a browser: the same URL can - * answer differently once the underlying table or the caller's rights change. + * A write takes its whole input from the path and the body. A query string + * would silently look like a filter, so it is refused rather than ignored. */ -function writeJson(res: Response, status: number, payload: string): void { - res.status(status); - res.type("application/json"); - res.setHeader("Cache-Control", "no-store"); - res.send(payload); -} - -/** Measure the encoded body before sending so no partial response escapes. */ -function sendJson(res: Response, body: JsonValue): void { - const payload = JSON.stringify(body); - if (Buffer.byteLength(payload, "utf8") > MAX_RESPONSE_BYTES) { - throw new DatabasePluginError("PAYLOAD_TOO_LARGE", "read"); +function assertNoQuery(req: Request): void { + if (rawQuery(req) !== "") { + throw invalidDatabaseInput(["query"], "Writes accept no query parameters"); } - writeJson(res, 200, payload); } -/** Answer with the failure's safe category and the field it concerns. */ -function writeError(res: Response, error: unknown): void { - if (res.headersSent) return; - const safe = classifyDatabaseError(error, "read"); - const body: { error: string; details?: readonly DatabaseErrorDetail[] } = { - error: safe.clientMessage, - }; - if (safe.details && safe.details.length > 0) body.details = safe.details; - writeJson(res, safe.statusCode, JSON.stringify(body)); +/** The body parser only ran if the caller declared JSON. */ +function assertJsonBody(req: Request): void { + if (!req.is("application/json")) { + throw new DatabasePluginError("UNSUPPORTED_MEDIA_TYPE", "write"); + } } /** `GET /:table` — one bounded page in the `{ items, limit, offset }` envelope. */ -export function createListHandler(deps: ReadRouteDeps): ReadHandler { +export function createListHandler(deps: CrudRouteDeps): RouteHandler { const primaryKey = deps.table.primaryKey?.meta.columnName; const route = `/${deps.table.name}`; @@ -145,26 +127,26 @@ export function createListHandler(deps: ReadRouteDeps): ReadHandler { if (decoded.include) query = query.include(decoded.include); const rows = await query.toArray(); - sendJson(res, { + sendJson(res, 200, { items: rows.map((row) => serializeRow(deps, "list", row)), limit: decoded.limit, offset: decoded.offset, }); }); } catch (error) { - writeError(res, error); + sendError(res, deps.table, "read", error); } }; } /** `GET /:table/:id` — one public row, or 404 when nothing matches. */ -export function createDetailHandler(deps: ReadRouteDeps): ReadHandler { +export function createDetailHandler(deps: CrudRouteDeps): RouteHandler { const route = `/${deps.table.name}/:id`; return async (req, res) => { try { await deps.runRouteSpan("detail", route, async () => { - const id = deps.table.decodeId(req.params.id); + const id = decodeId(deps.table, req.params.id); const decoded = decodeDetailQuery(deps.table, rawQuery(req)); let query = deps.entity(); if (decoded.select) query = query.select(decoded.select); @@ -172,10 +154,72 @@ export function createDetailHandler(deps: ReadRouteDeps): ReadHandler { const row = await query.find(id); if (row === null) throw new DatabasePluginError("NOT_FOUND", "read"); - sendJson(res, serializeRow(deps, "detail", row)); + sendJson(res, 200, serializeRow(deps, "detail", row)); + }); + } catch (error) { + sendError(res, deps.table, "read", error); + } + }; +} + +/** + * `POST /:table` — the created row at `201`. A mutation answers the row the + * database actually holds, so a read serializer never reshapes it. + */ +export function createCreateHandler(deps: CrudRouteDeps): RouteHandler { + const route = `/${deps.table.name}`; + + return async (req, res) => { + try { + await deps.runRouteSpan("create", route, async () => { + assertNoQuery(req); + assertJsonBody(req); + const values = decodeCreateBody(deps.table, req.body); + const row = await deps.entity().create(values); + sendJson(res, 201, deps.table.projectPublicRow(row)); + }); + } catch (error) { + sendError(res, deps.table, "write", error); + } + }; +} + +/** `PATCH /:table/:id` — the updated row at `200`, or 404 when it is gone. */ +export function createUpdateHandler(deps: CrudRouteDeps): RouteHandler { + const route = `/${deps.table.name}/:id`; + + return async (req, res) => { + try { + await deps.runRouteSpan("update", route, async () => { + assertNoQuery(req); + assertJsonBody(req); + const id = decodeId(deps.table, req.params.id); + const values = decodeUpdateBody(deps.table, req.body); + const row = await deps.entity().update(id, values); + if (row === null) throw new DatabasePluginError("NOT_FOUND", "write"); + sendJson(res, 200, deps.table.projectPublicRow(row)); + }); + } catch (error) { + sendError(res, deps.table, "write", error); + } + }; +} + +/** `DELETE /:table/:id` — `204` with no body, or 404 when nothing matched. */ +export function createDeleteHandler(deps: CrudRouteDeps): RouteHandler { + const route = `/${deps.table.name}/:id`; + + return async (req, res) => { + try { + await deps.runRouteSpan("delete", route, async () => { + assertNoQuery(req); + const id = decodeId(deps.table, req.params.id); + const deleted = await deps.entity().delete(id); + if (!deleted) throw new DatabasePluginError("NOT_FOUND", "write"); + sendEmpty(res, 204); }); } catch (error) { - writeError(res, error); + sendError(res, deps.table, "write", error); } }; } diff --git a/packages/appkit/src/plugins/database/crud/tests/contract.test.ts b/packages/appkit/src/plugins/database/crud/tests/contract.test.ts index ad362c80f..ac628dce2 100644 --- a/packages/appkit/src/plugins/database/crud/tests/contract.test.ts +++ b/packages/appkit/src/plugins/database/crud/tests/contract.test.ts @@ -1,12 +1,11 @@ import { describe, expect, it } from "vitest"; -import { DatabasePluginError } from "../../../../database/errors"; import { - bigid, defineSchema, fk, id, jsonb, text, + timestamp, } from "../../../../database/schema-builder"; import { MAX_SERIALIZED_DEPTH } from "../../defaults"; import { compileCrudTables } from "../contract"; @@ -24,14 +23,19 @@ const schema = defineSchema((builder) => { body: text(), draft: text().private(), }); - const ledger = builder.table("ledger", { id: bigid(), memo: text() }); - return { users, notes, ledger }; + const invites = builder.table("invites", { + code: text().primaryKey(), + email: text().notNull(), + label: text().default("guest"), + createdAt: timestamp().defaultNow(), + }); + return { users, notes, invites }; }); const tables = compileCrudTables(schema.$tables); const users = tables.get("users") as NonNullable>; const notes = tables.get("notes") as NonNullable>; -const ledger = tables.get("ledger") as NonNullable< +const invites = tables.get("invites") as NonNullable< ReturnType >; @@ -55,17 +59,21 @@ describe("compileCrudTables", () => { const isolated = compileCrudTables({ notes: schema.$tables.notes }); expect(isolated.get("notes")?.relations.size).toBe(0); }); +}); - it("decodes identifiers against the declared key type", () => { - expect(users.decodeId("42")).toBe(42); - expect(ledger.decodeId("9007199254740993")).toBe(9007199254740993n); - expect(() => users.decodeId("abc")).toThrow(DatabasePluginError); - expect(() => users.decodeId("abc")).toThrow( - expect.objectContaining({ - category: "INVALID_REQUEST", - details: [{ path: ["id"], message: expect.any(String) }], - }), - ); +describe("write allowlists", () => { + it("allows a caller-chosen key on create but never a generated one", () => { + expect([...users.creatable]).toEqual(["name", "profile"]); + expect([...users.updatable]).toEqual(["name", "profile"]); + expect([...invites.creatable]).toEqual([ + "code", + "email", + "label", + "createdAt", + ]); + // A key rewrite would move the row out from under every reference to it, + // and a caller who could rewrite `createdAt` could rewrite history. + expect([...invites.updatable]).toEqual(["email", "label"]); }); }); diff --git a/packages/appkit/src/plugins/database/crud/tests/request.test.ts b/packages/appkit/src/plugins/database/crud/tests/request.test.ts new file mode 100644 index 000000000..f80222e7b --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/tests/request.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { DatabasePluginError } from "../../../../database/errors"; +import { + bigid, + defineSchema, + fk, + id, + jsonb, + text, + timestamp, +} from "../../../../database/schema-builder"; +import { MAX_SERIALIZED_DEPTH } from "../../defaults"; +import { compileCrudTables } from "../contract"; +import { decodeCreateBody, decodeId, decodeUpdateBody } from "../request"; + +const schema = defineSchema((builder) => { + const users = builder.table("users", { + id: id(), + name: text(), + token: text().private(), + profile: jsonb(), + }); + const notes = builder.table("notes", { + id: id(), + authorId: fk(() => users.id), + body: text(), + }); + const ledger = builder.table("ledger", { id: bigid(), memo: text() }); + const invites = builder.table("invites", { + code: text().primaryKey(), + email: text().notNull(), + label: text().default("guest"), + createdAt: timestamp().defaultNow(), + }); + return { users, notes, ledger, invites }; +}); + +const tables = compileCrudTables(schema.$tables); +const users = tables.get("users") as NonNullable>; +const notes = tables.get("notes") as NonNullable>; +const ledger = tables.get("ledger") as NonNullable< + ReturnType +>; +const invites = tables.get("invites") as NonNullable< + ReturnType +>; + +describe("decodeId", () => { + it("decodes identifiers against the declared key type", () => { + expect(decodeId(users, "42")).toBe(42); + expect(decodeId(ledger, "9007199254740993")).toBe(9007199254740993n); + expect(() => decodeId(users, "abc")).toThrow(DatabasePluginError); + expect(() => decodeId(users, "abc")).toThrow( + expect.objectContaining({ + category: "INVALID_REQUEST", + details: [{ path: ["id"], message: expect.any(String) }], + }), + ); + }); +}); + +describe("decodeCreateBody / decodeUpdateBody", () => { + it("accepts the public columns each operation may set", () => { + expect( + decodeCreateBody(users, { name: "Ada", profile: { theme: "dark" } }), + ).toEqual({ name: "Ada", profile: { theme: "dark" } }); + expect(decodeCreateBody(invites, { code: "abc", email: "a@b.c" })).toEqual({ + code: "abc", + email: "a@b.c", + }); + // Columns with defaults stay optional rather than becoming required. + expect(decodeUpdateBody(invites, { label: "member" })).toEqual({ + label: "member", + }); + expect(decodeUpdateBody(users, {})).toEqual({}); + }); + + it.each<[string, () => void, string[]]>([ + ["a generated identity", () => decodeCreateBody(users, { id: 1 }), ["id"]], + [ + "a private column", + () => decodeCreateBody(users, { token: "t" }), + ["body"], + ], + ["an unknown field", () => decodeCreateBody(users, { nope: 1 }), ["body"]], + ["a relation name", () => decodeCreateBody(notes, { users: {} }), ["body"]], + [ + "a key that is markup", + () => decodeCreateBody(users, { "": 1 }), + ["body"], + ], + [ + "a primary key update", + () => decodeUpdateBody(invites, { code: "b" }), + ["code"], + ], + [ + "a materialized stamp update", + () => decodeUpdateBody(invites, { createdAt: "2099-01-01T00:00:00Z" }), + ["createdAt"], + ], + ["a mistyped value", () => decodeCreateBody(users, { name: 7 }), ["name"]], + [ + "a null in a NOT NULL column", + () => decodeCreateBody(invites, { email: null }), + ["email"], + ], + [ + "a non-object body", + () => decodeCreateBody(users, [{ name: "Ada" }]), + ["body"], + ], + ])("rejects %s, naming only a public column", (_case, decode, path) => { + const error = (() => { + try { + decode(); + } catch (caught) { + return caught as DatabasePluginError; + } + throw new Error("expected a rejection"); + })(); + expect(error).toMatchObject({ + category: "INVALID_REQUEST", + details: [{ path, message: expect.any(String) }], + }); + expect(JSON.stringify(error.details)).not.toContain("Ada"); + }); + + it("keeps a nullable column nullable and bounds JSON input", () => { + expect(decodeCreateBody(users, { profile: null })).toEqual({ + profile: null, + }); + let deep: Record = {}; + for (let level = 0; level <= MAX_SERIALIZED_DEPTH; level += 1) { + deep = { deep }; + } + expect(() => decodeCreateBody(users, { profile: deep })).toThrow( + expect.objectContaining({ category: "INVALID_REQUEST" }), + ); + }); +}); diff --git a/packages/appkit/src/plugins/database/crud/tests/routes.test.ts b/packages/appkit/src/plugins/database/crud/tests/routes.test.ts index 5c3af5883..9f66fb55b 100644 --- a/packages/appkit/src/plugins/database/crud/tests/routes.test.ts +++ b/packages/appkit/src/plugins/database/crud/tests/routes.test.ts @@ -9,15 +9,19 @@ import { id, text, } from "../../../../database/schema-builder"; +import { DatabaseValidationError } from "../../../../errors"; import { MAX_RESPONSE_BYTES } from "../../defaults"; import type { EntityClient } from "../../entity-client"; import type { ReadSerializer } from "../../types"; import { type CrudTable, compileCrudTables } from "../contract"; import { - type CrudReadEntity, + type CrudEntity, + type CrudRouteDeps, + createCreateHandler, + createDeleteHandler, createDetailHandler, createListHandler, - type ReadRouteDeps, + createUpdateHandler, } from "../routes"; const schema = defineSchema((builder) => { @@ -37,11 +41,11 @@ const schema = defineSchema((builder) => { const tables = compileCrudTables(schema.$tables); -// The routes reach their entity through an untyped export lookup, so the read +// The routes reach their entity through an untyped export lookup, so the // surface they drive has to stay a subset of the real client. -const _entityClientSatisfiesReads: CrudReadEntity = {} as EntityClient; +const _entityClientSatisfiesRoutes: CrudEntity = {} as EntityClient; -interface FakeEntity extends CrudReadEntity { +interface FakeEntity extends CrudEntity { readonly calls: Record; } @@ -71,6 +75,18 @@ function fakeEntity(rows: Row[], found: Row | null = null): FakeEntity { record("find", value); return found; }, + create: async (values) => { + record("create", values); + return { id: 1, ...values, token: "secret" }; + }, + update: async (value, values) => { + record("update", [value, values]); + return found && { ...found, ...values }; + }, + delete: async (value) => { + record("delete", value); + return found !== null; + }, }; return entity; } @@ -112,11 +128,26 @@ function request(url: string, params: Record = {}): Request { return { originalUrl: url, url, params } as unknown as Request; } +function writeRequest( + url: string, + body: unknown, + params: Record = {}, + contentType: string | null = "application/json", +): Request { + return { + originalUrl: url, + url, + params, + body, + is: (type: string) => contentType?.includes(type.split("/")[1]) ?? false, + } as unknown as Request; +} + function deps( table: string, - entity: CrudReadEntity, + entity: CrudEntity, serialize?: ReadSerializer, -): ReadRouteDeps { +): CrudRouteDeps { return { table: tables.get(table) as CrudTable, entity: () => entity, @@ -336,3 +367,191 @@ describe("serialization and response limits", () => { }); }); }); + +describe("write routes", () => { + it("creates a row at 201 and returns only its public columns", async () => { + const entity = fakeEntity([]); + await createCreateHandler(deps("users", entity))( + writeRequest("/users", { name: "Ada" }), + response.res, + ); + expect(entity.calls.create).toEqual([{ name: "Ada" }]); + expect(response.sent.status).toBe(201); + expect(response.json()).toEqual({ id: 1, name: "Ada" }); + expect(response.sent.headers["Cache-Control"]).toBe("no-store"); + }); + + it("never reshapes a mutation response with a read serializer", async () => { + const serialize = vi.fn((row) => ({ ...row, extra: true })); + const entity = fakeEntity([]); + await createCreateHandler(deps("users", entity, serialize))( + writeRequest("/users", { name: "Ada" }), + response.res, + ); + expect(serialize).not.toHaveBeenCalled(); + expect(response.json()).toEqual({ id: 1, name: "Ada" }); + }); + + it("updates a row at 200 and deletes one at 204", async () => { + const entity = fakeEntity([], { id: 7, name: "Ada", token: "secret" }); + await createUpdateHandler(deps("users", entity))( + writeRequest("/users/7", { name: "Grace" }, { id: "7" }), + response.res, + ); + expect(entity.calls.update).toEqual([[7, { name: "Grace" }]]); + expect(response.sent.status).toBe(200); + expect(response.json()).toEqual({ id: 7, name: "Grace" }); + + const removed = fakeResponse(); + await createDeleteHandler(deps("users", entity))( + request("/users/7", { id: "7" }), + removed.res, + ); + expect(entity.calls.delete).toEqual([7]); + expect(removed.sent.status).toBe(204); + expect(removed.sent.body).toBeUndefined(); + }); + + it("answers 404 when an update or delete matches nothing", async () => { + const entity = fakeEntity([], null); + await createUpdateHandler(deps("users", entity))( + writeRequest("/users/7", { name: "Grace" }, { id: "7" }), + response.res, + ); + expect(response.sent.status).toBe(404); + expect(response.json()).toEqual({ error: "Database record not found" }); + + const removed = fakeResponse(); + await createDeleteHandler(deps("users", entity))( + request("/users/7", { id: "7" }), + removed.res, + ); + expect(removed.sent.status).toBe(404); + }); + + it("requires a JSON body and refuses query parameters", async () => { + const entity = fakeEntity([]); + await createCreateHandler(deps("users", entity))( + writeRequest("/users", "name=Ada", {}, "text/plain"), + response.res, + ); + expect(response.sent.status).toBe(415); + expect(response.json()).toEqual({ + error: "Database request body must be JSON", + }); + + const filtered = fakeResponse(); + await createCreateHandler(deps("users", entity))( + writeRequest("/users?where=x", { name: "Ada" }), + filtered.res, + ); + expect(filtered.sent.status).toBe(400); + expect(filtered.json().details).toEqual([ + { path: ["query"], message: expect.any(String) }, + ]); + + const patched = fakeResponse(); + await createUpdateHandler(deps("users", entity))( + writeRequest("/users/7?where=x", { name: "Ada" }, { id: "7" }), + patched.res, + ); + expect(patched.sent.status).toBe(400); + + const onDelete = fakeResponse(); + await createDeleteHandler(deps("users", entity))( + request("/users/7?cascade=true", { id: "7" }), + onDelete.res, + ); + expect(onDelete.sent.status).toBe(400); + expect(entity.calls.create).toBeUndefined(); + expect(entity.calls.update).toBeUndefined(); + expect(entity.calls.delete).toBeUndefined(); + }); + + it("names a public column the caller may not write", async () => { + const entity = fakeEntity([]); + await createCreateHandler(deps("users", entity))( + writeRequest("/users", { name: "Ada", id: 7 }), + response.res, + ); + expect(response.sent.status).toBe(400); + expect(response.json()).toEqual({ + error: "Invalid database request", + details: [{ path: ["id"], message: expect.any(String) }], + }); + expect(entity.calls.create).toBeUndefined(); + }); + + it.each([ + ["a private column", { token: "stolen" }, "stolen"], + ["caller markup", { "": 1 }, "