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 }, "