diff --git a/docs/docs/api/appkit/Function.bigid.md b/docs/docs/api/appkit/Function.bigid.md new file mode 100644 index 000000000..b92962844 --- /dev/null +++ b/docs/docs/api/appkit/Function.bigid.md @@ -0,0 +1,9 @@ +# Function: bigid() + +```ts +function bigid(): ColumnBuilder; +``` + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Function.bigint.md b/docs/docs/api/appkit/Function.bigint.md new file mode 100644 index 000000000..565798206 --- /dev/null +++ b/docs/docs/api/appkit/Function.bigint.md @@ -0,0 +1,9 @@ +# Function: bigint() + +```ts +function bigint(): ColumnBuilder; +``` + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Function.boolean.md b/docs/docs/api/appkit/Function.boolean.md new file mode 100644 index 000000000..88d85ac9a --- /dev/null +++ b/docs/docs/api/appkit/Function.boolean.md @@ -0,0 +1,9 @@ +# Function: boolean() + +```ts +function boolean(): ColumnBuilder; +``` + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Function.database.md b/docs/docs/api/appkit/Function.database.md new file mode 100644 index 000000000..9aa74dcf8 --- /dev/null +++ b/docs/docs/api/appkit/Function.database.md @@ -0,0 +1,51 @@ +# Function: database() + +```ts +function database(config: IDatabaseConfig): { + config: IDatabaseConfig; + name: "database"; + plugin: PluginConstructor>; +}; +``` + +Create a typed database plugin registration for a finalized schema. + +## Type Parameters + +| Type Parameter | +| ------ | +| `TSchema` *extends* [`Schema`](Interface.Schema.md)\<`string`\> | + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `config` | [`IDatabaseConfig`](TypeAlias.IDatabaseConfig.md)\<`TSchema`\> | + +## Returns + +```ts +{ + config: IDatabaseConfig; + name: "database"; + plugin: PluginConstructor>; +} +``` + +### config + +```ts +config: IDatabaseConfig; +``` + +### name + +```ts +name: "database"; +``` + +### plugin + +```ts +plugin: PluginConstructor>; +``` diff --git a/docs/docs/api/appkit/Function.defineSchema.md b/docs/docs/api/appkit/Function.defineSchema.md new file mode 100644 index 000000000..f69cb343f --- /dev/null +++ b/docs/docs/api/appkit/Function.defineSchema.md @@ -0,0 +1,25 @@ +# Function: defineSchema() + +```ts +function defineSchema(builder: (context: SchemaBuilderContext) => TTables, options?: DefineSchemaOptions): Schema>; +``` + +Compile one declared schema. The returned type keeps the table names the +builder returned, so `crudRoutes` and `hooks` can name only real tables. + +## Type Parameters + +| Type Parameter | +| ------ | +| `TTables` *extends* `Record`\<`string`, `AppKitTable`\> | + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `builder` | (`context`: `SchemaBuilderContext`) => `TTables` | +| `options?` | `DefineSchemaOptions` | + +## Returns + +[`Schema`](Interface.Schema.md)\<`Extract`\\> diff --git a/docs/docs/api/appkit/Function.enumColumn.md b/docs/docs/api/appkit/Function.enumColumn.md new file mode 100644 index 000000000..134b7025b --- /dev/null +++ b/docs/docs/api/appkit/Function.enumColumn.md @@ -0,0 +1,16 @@ +# Function: enumColumn() + +```ts +function enumColumn(name: string, values: readonly string[]): ColumnBuilder; +``` + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `name` | `string` | +| `values` | readonly `string`[] | + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Function.fk.md b/docs/docs/api/appkit/Function.fk.md new file mode 100644 index 000000000..0f2f28c50 --- /dev/null +++ b/docs/docs/api/appkit/Function.fk.md @@ -0,0 +1,17 @@ +# Function: fk() + +```ts +function fk(ref: FkRef): ColumnBuilder; +``` + +Declare foreign-key to another column. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `ref` | `FkRef` | + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Function.id.md b/docs/docs/api/appkit/Function.id.md new file mode 100644 index 000000000..90b5b5b73 --- /dev/null +++ b/docs/docs/api/appkit/Function.id.md @@ -0,0 +1,9 @@ +# Function: id() + +```ts +function id(): ColumnBuilder; +``` + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Function.integer.md b/docs/docs/api/appkit/Function.integer.md new file mode 100644 index 000000000..3c26a8e52 --- /dev/null +++ b/docs/docs/api/appkit/Function.integer.md @@ -0,0 +1,9 @@ +# Function: integer() + +```ts +function integer(): ColumnBuilder; +``` + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Function.jsonb.md b/docs/docs/api/appkit/Function.jsonb.md new file mode 100644 index 000000000..88ef41a5f --- /dev/null +++ b/docs/docs/api/appkit/Function.jsonb.md @@ -0,0 +1,9 @@ +# Function: jsonb() + +```ts +function jsonb(): ColumnBuilder; +``` + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Function.text.md b/docs/docs/api/appkit/Function.text.md new file mode 100644 index 000000000..f6db879b6 --- /dev/null +++ b/docs/docs/api/appkit/Function.text.md @@ -0,0 +1,9 @@ +# Function: text() + +```ts +function text(): ColumnBuilder; +``` + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Function.timestamp.md b/docs/docs/api/appkit/Function.timestamp.md new file mode 100644 index 000000000..d69cc1d8d --- /dev/null +++ b/docs/docs/api/appkit/Function.timestamp.md @@ -0,0 +1,18 @@ +# Function: timestamp() + +```ts +function timestamp(opts?: { + withTimezone?: boolean; +}): ColumnBuilder; +``` + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `opts?` | \{ `withTimezone?`: `boolean`; \} | +| `opts.withTimezone?` | `boolean` | + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Function.uuid.md b/docs/docs/api/appkit/Function.uuid.md new file mode 100644 index 000000000..d873581ba --- /dev/null +++ b/docs/docs/api/appkit/Function.uuid.md @@ -0,0 +1,9 @@ +# Function: uuid() + +```ts +function uuid(): ColumnBuilder; +``` + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Function.varchar.md b/docs/docs/api/appkit/Function.varchar.md new file mode 100644 index 000000000..56db81c58 --- /dev/null +++ b/docs/docs/api/appkit/Function.varchar.md @@ -0,0 +1,15 @@ +# Function: varchar() + +```ts +function varchar(length: number): ColumnBuilder; +``` + +## Parameters + +| Parameter | Type | Default value | +| ------ | ------ | ------ | +| `length` | `number` | `255` | + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Interface.DatabaseRegistry.md b/docs/docs/api/appkit/Interface.DatabaseRegistry.md new file mode 100644 index 000000000..27f8db88f --- /dev/null +++ b/docs/docs/api/appkit/Interface.DatabaseRegistry.md @@ -0,0 +1,4 @@ +# Interface: DatabaseRegistry + +CANONICAL augmentation target. Empty by default; the generated `database.d.ts` +augments it via `declare module "@databricks/appkit" { interface DatabaseRegistry { ... } }`. diff --git a/docs/docs/api/appkit/Interface.Schema.md b/docs/docs/api/appkit/Interface.Schema.md new file mode 100644 index 000000000..377ffc07f --- /dev/null +++ b/docs/docs/api/appkit/Interface.Schema.md @@ -0,0 +1,35 @@ +# Interface: Schema\ + +One finalized schema. `TTableName` keeps the declared names in the type, so +configuration that addresses a table by name is checked against the schema +it was written for. Code that accepts any schema uses the default. + +## Type Parameters + +| Type Parameter | Default type | +| ------ | ------ | +| `TTableName` *extends* `string` | `string` | + +## Properties + +### $engine + +```ts +readonly $engine: Readonly>; +``` + +*** + +### $schemaName + +```ts +readonly $schemaName: string; +``` + +*** + +### $tables + +```ts +readonly $tables: Readonly>; +``` diff --git a/docs/docs/api/appkit/TypeAlias.DatabaseExports.md b/docs/docs/api/appkit/TypeAlias.DatabaseExports.md new file mode 100644 index 000000000..c076dab0a --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.DatabaseExports.md @@ -0,0 +1,33 @@ +# Type Alias: DatabaseExports + +```ts +type DatabaseExports = TransactionClient & { + transaction: Promise; +}; +``` + +Typed database API published by the plugin. + +## Type Declaration + +### transaction() + +```ts +transaction(callback: (tx: TransactionClient) => Promise): Promise; +``` + +#### Type Parameters + +| Type Parameter | +| ------ | +| `T` | + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `callback` | (`tx`: `TransactionClient`) => `Promise`\<`T`\> | + +#### Returns + +`Promise`\<`T`\> diff --git a/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md b/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md new file mode 100644 index 000000000..d95217a4e --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md @@ -0,0 +1,41 @@ +# Type Alias: IDatabaseConfig\ + +```ts +type IDatabaseConfig = { + crudRoutes?: CrudRoutesConfig; + hooks?: { readonly [TTable in SchemaTableName]?: { serialize?: ReadSerializer } }; + schema: TSchema; +}; +``` + +Configuration for one schema-bound DatabasePlugin instance. + +## Type Parameters + +| Type Parameter | +| ------ | +| `TSchema` *extends* [`Schema`](Interface.Schema.md) | + +## Properties + +### crudRoutes? + +```ts +readonly optional crudRoutes: CrudRoutesConfig; +``` + +*** + +### hooks? + +```ts +readonly optional hooks: { readonly [TTable in SchemaTableName]?: { serialize?: ReadSerializer } }; +``` + +*** + +### schema + +```ts +readonly schema: TSchema; +``` diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index f39a52db2..36d752e56 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -44,6 +44,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [BasePluginConfig](Interface.BasePluginConfig.md) | Base configuration interface for AppKit plugins | | [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 { ... } }`. | | [EndpointConfig](Interface.EndpointConfig.md) | - | | [FilePolicyUser](Interface.FilePolicyUser.md) | Minimal user identity passed to the policy function. | | [FileResource](Interface.FileResource.md) | Describes the file or directory being acted upon. | @@ -74,6 +75,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [ResourceRequirement](Interface.ResourceRequirement.md) | Declares a resource requirement for a plugin. Can be defined statically in a manifest or dynamically via getResourceRequirements(). | | [RunAgentInput](Interface.RunAgentInput.md) | - | | [RunAgentResult](Interface.RunAgentResult.md) | - | +| [Schema](Interface.Schema.md) | One finalized schema. `TTableName` keeps the declared names in the type, so configuration that addresses a table by name is checked against the schema it was written for. Code that accepts any schema uses the default. | | [SearchRequest](Interface.SearchRequest.md) | - | | [SearchResponse](Interface.SearchResponse.md) | - | | [SearchResult](Interface.SearchResult.md) | - | @@ -106,11 +108,13 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [AgentToolsFn](TypeAlias.AgentToolsFn.md) | Function form of `AgentDefinition.tools`. Receives the typed [Plugins](TypeAlias.Plugins.md) map and returns a tool record. Invoked exactly once at setup (or once per `runAgent` call in standalone mode); the result is cached as the agent's resolved tool record. | | [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. | | [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. | | [HostedTool](TypeAlias.HostedTool.md) | - | | [IAppRouter](TypeAlias.IAppRouter.md) | Express router type for plugin route registration | +| [IDatabaseConfig](TypeAlias.IDatabaseConfig.md) | Configuration for one schema-bound DatabasePlugin instance. | | [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. | @@ -142,15 +146,22 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [agentIdFromMarkdownPath](Function.agentIdFromMarkdownPath.md) | Derives the logical agent id from a markdown path. When the file is named `agent.md`, the id is the parent directory name (folder-based layout); otherwise the id is the file stem (e.g. legacy single-file paths). | | [appKitServingTypesPlugin](Function.appKitServingTypesPlugin.md) | Vite plugin to generate TypeScript types for AppKit serving endpoints. Fetches OpenAPI schemas from Databricks and generates a .d.ts with ServingEndpointRegistry module augmentation. | | [appKitTypesPlugin](Function.appKitTypesPlugin.md) | Vite plugin to generate types for AppKit queries. Calls generateFromEntryPoint under the hood. | +| [bigid](Function.bigid.md) | - | +| [bigint](Function.bigint.md) | - | +| [boolean](Function.boolean.md) | - | | [createAgent](Function.createAgent.md) | Pure factory for agent definitions. Returns the passed-in definition after cycle-detecting the sub-agent graph. Accepts the full `AgentDefinition` shape and is safe to call at module top-level. | | [createApp](Function.createApp.md) | Bootstraps AppKit with the provided configuration. | | [createLakebasePool](Function.createLakebasePool.md) | Create a Lakebase pool with appkit's logger integration. Telemetry automatically uses appkit's OpenTelemetry configuration via global registry. | | [createLakebasePoolManager](Function.createLakebasePoolManager.md) | Create a pool manager that maintains per-key Lakebase connection pools. | | [createWorkspaceClient](Function.createWorkspaceClient.md) | Construct an AppKit workspace client. | +| [database](Function.database.md) | Create a typed database plugin registration for a finalized schema. | +| [defineSchema](Function.defineSchema.md) | Compile one declared schema. The returned type keeps the table names the builder returned, so `crudRoutes` and `hooks` can name only real tables. | | [defineTool](Function.defineTool.md) | Defines a single tool entry for a plugin's internal registry. | +| [enumColumn](Function.enumColumn.md) | - | | [executeFromRegistry](Function.executeFromRegistry.md) | Validates tool-call arguments against the entry's schema and invokes its handler. On validation failure, returns an LLM-friendly error string (matching the behavior of `tool()`) rather than throwing, so the model can self-correct on its next turn. | | [extractServingEndpoints](Function.extractServingEndpoints.md) | Extract serving endpoint config from a server file by AST-parsing it. Looks for `serving({ endpoints: { alias: { env: "..." }, ... } })` calls and extracts the endpoint alias names and their environment variable mappings. | | [findServerFile](Function.findServerFile.md) | Find the server entry file by checking candidate paths in order. | +| [fk](Function.fk.md) | Declare foreign-key to another column. | | [fromSupervisorApi](Function.fromSupervisorApi.md) | Creates an [AgentAdapter](Interface.AgentAdapter.md) backed by the Databricks AI Gateway Responses API (`/ai-gateway/mlflow/v1/responses`). | | [functionToolToDefinition](Function.functionToolToDefinition.md) | - | | [generateDatabaseCredential](Function.generateDatabaseCredential.md) | Generate OAuth credentials for Postgres database connection using the proper Postgres API. | @@ -161,16 +172,23 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [getResourceRequirements](Function.getResourceRequirements.md) | Gets the resource requirements from a plugin's manifest. | | [getUsernameWithApiLookup](Function.getUsernameWithApiLookup.md) | Resolves the PostgreSQL username for a Lakebase connection. | | [getWorkspaceClient](Function.getWorkspaceClient.md) | Get workspace client from config or SDK default auth chain | +| [id](Function.id.md) | - | +| [integer](Function.integer.md) | - | | [isFunctionTool](Function.isFunctionTool.md) | - | | [isHostedTool](Function.isHostedTool.md) | - | | [isSQLTypeMarker](Function.isSQLTypeMarker.md) | Type guard to check if a value is a SQL type marker | | [isSupervisorTool](Function.isSupervisorTool.md) | Type guard for [HostedSupervisorTool](Interface.HostedSupervisorTool.md). Used by the agents plugin (`buildToolIndex`) and standalone `runAgent` (`classifyTool`) to route supervisor-hosted tools to the extensions payload rather than the adapter's `tools` array. | | [isToolkitEntry](Function.isToolkitEntry.md) | Type guard for `ToolkitEntry` — used by the agents plugin to differentiate toolkit references from inline tools in a mixed `tools` record. | +| [jsonb](Function.jsonb.md) | - | | [loadAgentFromFile](Function.loadAgentFromFile.md) | Loads a single markdown agent file and resolves its frontmatter against registered plugin toolkits + ambient tool library. | | [loadAgentsFromDir](Function.loadAgentsFromDir.md) | Scans a directory for one subdirectory per agent, each containing `agent.md` (frontmatter + body). Produces an `AgentDefinition` record keyed by agent id (folder name). Throws on frontmatter errors or unresolved references. Returns an empty map if the directory does not exist. | | [mcpServer](Function.mcpServer.md) | Factory for declaring a custom MCP server tool. | | [parseTextToolCalls](Function.parseTextToolCalls.md) | Parses text-based tool calls from model output. | | [resolveHostedTools](Function.resolveHostedTools.md) | - | | [runAgent](Function.runAgent.md) | Standalone agent execution without `createApp`. Resolves the adapter, binds inline tools, and drives the adapter's `run()` loop to completion. | +| [text](Function.text.md) | - | +| [timestamp](Function.timestamp.md) | - | | [tool](Function.tool.md) | Factory for defining function tools with Zod schemas. | | [toolsFromRegistry](Function.toolsFromRegistry.md) | Produces the `AgentToolDefinition[]` a ToolProvider exposes to the LLM, deriving `parameters` JSON Schema from each entry's Zod schema. | +| [uuid](Function.uuid.md) | - | +| [varchar](Function.varchar.md) | - | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index 18a5333b1..3eedd8ad8 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -152,6 +152,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.DatabaseCredential", label: "DatabaseCredential" }, + { + type: "doc", + id: "api/appkit/Interface.DatabaseRegistry", + label: "DatabaseRegistry" + }, { type: "doc", id: "api/appkit/Interface.EndpointConfig", @@ -302,6 +307,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.RunAgentResult", label: "RunAgentResult" }, + { + type: "doc", + id: "api/appkit/Interface.Schema", + label: "Schema" + }, { type: "doc", id: "api/appkit/Interface.SearchRequest", @@ -443,6 +453,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/TypeAlias.ConfigSchema", label: "ConfigSchema" }, + { + type: "doc", + id: "api/appkit/TypeAlias.DatabaseExports", + label: "DatabaseExports" + }, { type: "doc", id: "api/appkit/TypeAlias.ExecutionResult", @@ -468,6 +483,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/TypeAlias.IAppRouter", label: "IAppRouter" }, + { + type: "doc", + id: "api/appkit/TypeAlias.IDatabaseConfig", + label: "IDatabaseConfig" + }, { type: "doc", id: "api/appkit/TypeAlias.JobsExport", @@ -585,6 +605,21 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.appKitTypesPlugin", label: "appKitTypesPlugin" }, + { + type: "doc", + id: "api/appkit/Function.bigid", + label: "bigid" + }, + { + type: "doc", + id: "api/appkit/Function.bigint", + label: "bigint" + }, + { + type: "doc", + id: "api/appkit/Function.boolean", + label: "boolean" + }, { type: "doc", id: "api/appkit/Function.createAgent", @@ -610,11 +645,26 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.createWorkspaceClient", label: "createWorkspaceClient" }, + { + type: "doc", + id: "api/appkit/Function.database", + label: "database" + }, + { + type: "doc", + id: "api/appkit/Function.defineSchema", + label: "defineSchema" + }, { type: "doc", id: "api/appkit/Function.defineTool", label: "defineTool" }, + { + type: "doc", + id: "api/appkit/Function.enumColumn", + label: "enumColumn" + }, { type: "doc", id: "api/appkit/Function.executeFromRegistry", @@ -630,6 +680,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.findServerFile", label: "findServerFile" }, + { + type: "doc", + id: "api/appkit/Function.fk", + label: "fk" + }, { type: "doc", id: "api/appkit/Function.fromSupervisorApi", @@ -680,6 +735,16 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.getWorkspaceClient", label: "getWorkspaceClient" }, + { + type: "doc", + id: "api/appkit/Function.id", + label: "id" + }, + { + type: "doc", + id: "api/appkit/Function.integer", + label: "integer" + }, { type: "doc", id: "api/appkit/Function.isFunctionTool", @@ -705,6 +770,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.isToolkitEntry", label: "isToolkitEntry" }, + { + type: "doc", + id: "api/appkit/Function.jsonb", + label: "jsonb" + }, { type: "doc", id: "api/appkit/Function.loadAgentFromFile", @@ -735,6 +805,16 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.runAgent", label: "runAgent" }, + { + type: "doc", + id: "api/appkit/Function.text", + label: "text" + }, + { + type: "doc", + id: "api/appkit/Function.timestamp", + label: "timestamp" + }, { type: "doc", id: "api/appkit/Function.tool", @@ -744,6 +824,16 @@ const typedocSidebar: SidebarsConfig = { type: "doc", id: "api/appkit/Function.toolsFromRegistry", label: "toolsFromRegistry" + }, + { + type: "doc", + id: "api/appkit/Function.uuid", + label: "uuid" + }, + { + type: "doc", + id: "api/appkit/Function.varchar", + label: "varchar" } ] } diff --git a/packages/appkit/package.json b/packages/appkit/package.json index ac841184c..dcf7b9614 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -83,6 +83,7 @@ "drizzle-orm": "0.45.1", "express": "4.22.2", "get-port": "7.2.0", + "jiti": "2.6.1", "js-yaml": "4.2.0", "magic-string": "0.30.21", "obug": "2.1.1", diff --git a/packages/appkit/src/beta.ts b/packages/appkit/src/beta.ts index d94b4e0d4..4de7ba79c 100644 --- a/packages/appkit/src/beta.ts +++ b/packages/appkit/src/beta.ts @@ -66,6 +66,23 @@ export { toolsFromRegistry, } from "./core/agent/tools"; +export type { Schema } from "./database/schema-builder"; +export { + bigid, + bigint, + boolean, + defineSchema, + enumColumn, + fk, + id, + integer, + jsonb, + text, + timestamp, + uuid, + varchar, +} from "./database/schema-builder"; + // Agent types export type { AgentDefinition, @@ -101,3 +118,4 @@ export type { SearchResult, } from "./plugins/ai-search/types"; export * from "./plugins/beta-exports.generated"; +export type { DatabaseExports, IDatabaseConfig } from "./plugins/database"; diff --git a/packages/appkit/src/database/contract/column-info.ts b/packages/appkit/src/database/contract/column-info.ts deleted file mode 100644 index 2def90fd4..000000000 --- a/packages/appkit/src/database/contract/column-info.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** Coarse classification of a Postgres column. */ -export type ColumnInfoKind = - | "string" - | "number" - | "bigint" - | "boolean" - | "date" - | "json" - | "uuid" - | "enum" - | "unknown"; - -export interface ColumnInfo { - /** Column name as stored in Postgres */ - name: string; - /** Canonical postgres data type */ - pgType: string; - /** Coarse classifier derived from {@link pgTypeToColumnInfoKind}. */ - kind: ColumnInfoKind; - /** Whether the column accepts NULL. */ - nullable: boolean; - /** Part of the primary key. */ - isPrimaryKey: boolean; - /** Value is produced by the database (serial / default), so it is omitted from inserts. */ - isServerGenerated: boolean; - /** Hidden from HTTP responses (`.private()`); reachable only by trusted server code. */ - isPrivate: boolean; - /** Enum members when {@link kind} is `"enum"`. */ - enumValues?: readonly string[]; -} - -const STRING_TYPES = new Set([ - "text", - "varchar", - "character varying", - "char", - "character", - "bpchar", - "name", - "citext", -]); - -const NUMBER_TYPES = new Set([ - "int2", - "smallint", - "int4", - "int", - "integer", - "serial", - "serial4", - "smallserial", - "real", - "float4", - "float8", - "double precision", - "numeric", - "decimal", - "money", -]); - -const BIGINT_TYPES = new Set(["int8", "bigint", "bigserial", "serial8"]); - -const BOOLEAN_TYPES = new Set(["bool", "boolean"]); - -const DATE_TYPES = new Set([ - "timestamp", - "timestamptz", - "timestamp with time zone", - "timestamp without time zone", - "date", - "time", - "timetz", - "time with time zone", - "time without time zone", -]); - -const JSON_TYPES = new Set(["json", "jsonb"]); - -/** - * Normalize a raw Postgres type token: lower-case, strip a length/precision - * specifier (`varchar(255)` → `varchar`) and a trailing array marker (`text[]`). - */ -export function normalizePgType(pgType: string): string { - return pgType - .trim() - .toLowerCase() - .replace(/\[\]$/, "") - .replace(/\(.*\)$/, "") - .trim(); -} - -/** - * Map a Postgres type to a coarse {@link ColumnInfoKind}. Enum columns are user - * (custom) types and are classified by the schema-builder/introspector directly, - * so an unrecognized type falls back to `"unknown"` here. - */ -export function pgTypeToColumnInfoKind(pgType: string): ColumnInfoKind { - const t = normalizePgType(pgType); - if (STRING_TYPES.has(t)) return "string"; - if (NUMBER_TYPES.has(t)) return "number"; - if (BIGINT_TYPES.has(t)) return "bigint"; - if (BOOLEAN_TYPES.has(t)) return "boolean"; - if (DATE_TYPES.has(t)) return "date"; - if (JSON_TYPES.has(t)) return "json"; - if (t === "uuid") return "uuid"; - return "unknown"; -} diff --git a/packages/appkit/src/database/contract/index.ts b/packages/appkit/src/database/contract/index.ts index b8eed0381..fa66cb2b1 100644 --- a/packages/appkit/src/database/contract/index.ts +++ b/packages/appkit/src/database/contract/index.ts @@ -1,4 +1,2 @@ -export * from "./column-info"; export * from "./registry"; -export * from "./relation"; export * from "./wire"; diff --git a/packages/appkit/src/database/contract/registry.ts b/packages/appkit/src/database/contract/registry.ts index de42f6697..fc2734551 100644 --- a/packages/appkit/src/database/contract/registry.ts +++ b/packages/appkit/src/database/contract/registry.ts @@ -2,14 +2,18 @@ export interface DatabaseRegistryEntry { /** Full server-side row (includes private columns). */ row: Record; - /** Accepted insert payload (private + server-generated columns omitted). */ + /** Default private-safe row returned by collection reads. */ + publicRow: Record; + /** Trusted insert payload (includes private fields; omits generated columns). */ insert: Record; - /** Accepted update payload (PK + private + server-generated omitted, all optional). */ + /** Trusted update payload (includes private fields; omits PK/generated columns). */ update: Record; /** Per-column filter operators usable in `where`. */ filters: Record; /** Relations that can be passed to `include`. */ includes: Record; + /** Literal capability used to omit keyed methods from keyless entities. */ + hasPrimaryKey: boolean; } /** diff --git a/packages/appkit/src/database/contract/relation.ts b/packages/appkit/src/database/contract/relation.ts deleted file mode 100644 index d5f84ad0a..000000000 --- a/packages/appkit/src/database/contract/relation.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** Postgres referential actions for FK `ON DELETE` / `ON UPDATE`. */ -export type ReferentialAction = - | "cascade" - | "set null" - | "set default" - | "restrict" - | "no action"; - -/** - * A single foreign-key edge. The schema-builder produces these in both directions - * (`fk()` declares the relation once); the introspector reads them from the catalog. - */ -export interface RelationEdge { - /** Column on the owning table that holds the foreign key. */ - fromColumn: string; - /** Target table name (unqualified). */ - toTable: string; - /** Target column on the referenced table (usually its primary key). */ - toColumn: string; - /** Referential action for `ON DELETE`. */ - onDelete?: ReferentialAction; - /** Referential action for `ON UPDATE`. */ - onUpdate?: ReferentialAction; -} diff --git a/packages/appkit/src/database/contract/tests/column-info.test.ts b/packages/appkit/src/database/contract/tests/column-info.test.ts deleted file mode 100644 index 79abdd621..000000000 --- a/packages/appkit/src/database/contract/tests/column-info.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - type ColumnInfo, - type ColumnInfoKind, - normalizePgType, - pgTypeToColumnInfoKind, -} from "../index"; - -describe("normalizePgType", () => { - const cases: ReadonlyArray<[input: string, expected: string]> = [ - ["text", "text"], - ["TEXT", "text"], - [" Text ", "text"], - ["varchar(255)", "varchar"], - ["numeric(10,2)", "numeric"], - ["text[]", "text"], - ["varchar(255)[]", "varchar"], - ["timestamp with time zone", "timestamp with time zone"], - ["TIMESTAMPTZ", "timestamptz"], - ]; - - it.each(cases)("normalizes %j -> %j", (input, expected) => { - expect(normalizePgType(input)).toBe(expected); - }); -}); - -describe("pgTypeToColumnInfoKind", () => { - const cases: ReadonlyArray<[pgType: string, kind: ColumnInfoKind]> = [ - // string - ["text", "string"], - ["varchar", "string"], - ["varchar(255)", "string"], - ["character varying", "string"], - ["char", "string"], - ["character", "string"], - ["bpchar", "string"], - ["name", "string"], - ["citext", "string"], - // number - ["int2", "number"], - ["smallint", "number"], - ["int4", "number"], - ["int", "number"], - ["integer", "number"], - ["serial", "number"], - ["serial4", "number"], - ["smallserial", "number"], - ["real", "number"], - ["float4", "number"], - ["float8", "number"], - ["double precision", "number"], - ["numeric", "number"], - ["numeric(10,2)", "number"], - ["decimal", "number"], - ["money", "number"], - // bigint - ["int8", "bigint"], - ["bigint", "bigint"], - ["bigserial", "bigint"], - ["serial8", "bigint"], - // boolean - ["bool", "boolean"], - ["boolean", "boolean"], - // date - ["timestamp", "date"], - ["timestamptz", "date"], - ["timestamp with time zone", "date"], - ["timestamp without time zone", "date"], - ["date", "date"], - ["time", "date"], - ["timetz", "date"], - ["time with time zone", "date"], - ["time without time zone", "date"], - // json - ["json", "json"], - ["jsonb", "json"], - // uuid - ["uuid", "uuid"], - // unknown (enums are classified upstream, not here) - ["my_custom_enum", "unknown"], - ["bytea", "unknown"], - ["inet", "unknown"], - ["", "unknown"], - ]; - - it.each(cases)("classifies %j as %j", (pgType, kind) => { - expect(pgTypeToColumnInfoKind(pgType)).toBe(kind); - }); - - it("classifies case-insensitively and ignores parameters/array markers", () => { - expect(pgTypeToColumnInfoKind("VARCHAR(255)")).toBe("string"); - expect(pgTypeToColumnInfoKind("TEXT[]")).toBe("string"); - expect(pgTypeToColumnInfoKind(" TimestampTZ ")).toBe("date"); - }); - - it("never classifies a custom type as enum (enum is set upstream)", () => { - expect(pgTypeToColumnInfoKind("status_enum")).toBe("unknown"); - }); -}); - -describe("ColumnInfo", () => { - it("composes with a kind derived from the classifier", () => { - const column: ColumnInfo = { - name: "id", - pgType: normalizePgType("INT4"), - kind: pgTypeToColumnInfoKind("int4"), - nullable: false, - isPrimaryKey: true, - isServerGenerated: true, - isPrivate: false, - }; - expect(column.kind).toBe("number"); - expect(column.pgType).toBe("int4"); - }); - - it("carries enumValues only for enum columns", () => { - const column: ColumnInfo = { - name: "status", - pgType: "status_enum", - kind: "enum", - nullable: false, - isPrimaryKey: false, - isServerGenerated: false, - isPrivate: false, - enumValues: ["active", "archived"], - }; - expect(column.enumValues).toEqual(["active", "archived"]); - }); -}); diff --git a/packages/appkit/src/database/contract/tests/registry.test.ts b/packages/appkit/src/database/contract/tests/registry.test.ts index 30e0c4f6e..37daa1b90 100644 --- a/packages/appkit/src/database/contract/tests/registry.test.ts +++ b/packages/appkit/src/database/contract/tests/registry.test.ts @@ -1,10 +1,5 @@ import { describe, expectTypeOf, it } from "vitest"; -import type { - DatabaseRegistryEntry, - ReferentialAction, - RegisteredEntity, - RelationEdge, -} from "../index"; +import type { DatabaseRegistryEntry, RegisteredEntity } from "../index"; /** * Type-level tests for the contract. These are verified by `tsc` during @@ -49,43 +44,15 @@ describe("RegisteredEntity (declaration-merging behaviour)", () => { }); describe("DatabaseRegistryEntry shape", () => { - it("exposes the five generated facets as records", () => { + it("exposes all generated entity facets and key capability", () => { expectTypeOf().toHaveProperty("row"); + expectTypeOf().toHaveProperty("publicRow"); expectTypeOf().toHaveProperty("insert"); expectTypeOf().toHaveProperty("update"); expectTypeOf().toHaveProperty("filters"); expectTypeOf().toHaveProperty("includes"); - }); -}); - -describe("RelationEdge shape", () => { - it("requires the from/to columns and allows optional referential actions", () => { - const edge: RelationEdge = { - fromColumn: "author_id", - toTable: "users", - toColumn: "id", - onDelete: "cascade", - onUpdate: "no action", - }; - expectTypeOf(edge.fromColumn).toEqualTypeOf(); - expectTypeOf(edge.toTable).toEqualTypeOf(); - expectTypeOf(edge.toColumn).toEqualTypeOf(); - expectTypeOf(edge.onDelete).toEqualTypeOf(); - expectTypeOf(edge.onUpdate).toEqualTypeOf(); - }); - - it("accepts a minimal edge without referential actions", () => { - const edge: RelationEdge = { - fromColumn: "author_id", - toTable: "users", - toColumn: "id", - }; - expectTypeOf(edge).toMatchTypeOf(); - }); - - it("pins the referential-action union", () => { - expectTypeOf().toEqualTypeOf< - "cascade" | "set null" | "set default" | "restrict" | "no action" - >(); + expectTypeOf< + DatabaseRegistryEntry["hasPrimaryKey"] + >().toEqualTypeOf(); }); }); diff --git a/packages/appkit/src/database/contract/tests/wire.test.ts b/packages/appkit/src/database/contract/tests/wire.test.ts index 727f2cf25..8ce3d0f23 100644 --- a/packages/appkit/src/database/contract/tests/wire.test.ts +++ b/packages/appkit/src/database/contract/tests/wire.test.ts @@ -5,6 +5,8 @@ import { type FilterOperator, IN_CAP, isFilterOperator, + MAX_INCLUDE_DEPTH, + MAX_INCLUDE_NODES, MAX_INCLUDES, MAX_LIMIT, } from "../index"; @@ -15,6 +17,8 @@ describe("wire caps", () => { expect(MAX_LIMIT).toBe(500); expect(DEFAULT_LIMIT).toBe(50); expect(MAX_INCLUDES).toBe(10); + expect(MAX_INCLUDE_DEPTH).toBe(2); + expect(MAX_INCLUDE_NODES).toBe(25); }); it("keeps DEFAULT_LIMIT within MAX_LIMIT", () => { diff --git a/packages/appkit/src/database/contract/wire.ts b/packages/appkit/src/database/contract/wire.ts index c0e17950f..d70b651d1 100644 --- a/packages/appkit/src/database/contract/wire.ts +++ b/packages/appkit/src/database/contract/wire.ts @@ -1,14 +1,23 @@ /** Max number of values allowed in an `in.(…)` list. */ export const IN_CAP = 100; -/** Hard ceiling for `.limit()` clamp. */ +/** Hard ceiling for a runtime query limit. */ export const MAX_LIMIT = 500; /** Default page size when no `.limit()` is supplied. */ export const DEFAULT_LIMIT = 50; /** Max number of relations resolvable in a single `.include()`. */ export const MAX_INCLUDES = 10; +/** Max number of relation edges one include path may traverse. */ +export const MAX_INCLUDE_DEPTH = 2; +/** Max number of relation nodes across a complete include tree. */ +export const MAX_INCLUDE_NODES = 25; + +/** Scalar values accepted by primary-key operations. */ +export type IdValue = string | number | bigint; +/** Ordering accepted by typed clients and the runtime adapter. */ +export type OrderDirection = "asc" | "desc"; /** Filter operators usable in the runtime WHERE translator and the `where` spec type. */ -export const FILTER_OPERATORS = [ +export const FILTER_OPERATORS = Object.freeze([ "eq", "neq", "gt", @@ -19,7 +28,7 @@ export const FILTER_OPERATORS = [ "ilike", "in", "is", -] as const; +] as const); export type FilterOperator = (typeof FILTER_OPERATORS)[number]; diff --git a/packages/appkit/src/database/errors.ts b/packages/appkit/src/database/errors.ts new file mode 100644 index 000000000..d4be7e6fa --- /dev/null +++ b/packages/appkit/src/database/errors.ts @@ -0,0 +1,121 @@ +import { AppKitError } from "../errors"; +import { createLogger } from "../logging/logger"; + +const logger = createLogger("database"); + +export type DatabaseErrorCategory = + | "INVALID_REQUEST" + | "NOT_FOUND" + | "CONFLICT" + | "FORBIDDEN" + | "PAYLOAD_TOO_LARGE" + | "INTERNAL" + | "SETUP_FAILED"; + +/** Which request field a rejection concerns; it never carries caller values. */ +export interface DatabaseErrorDetail { + readonly path: readonly string[]; + readonly message: string; +} + +type DatabaseErrorPhase = + | "setup" + | "shutdown" + | "read" + | "write" + | "transaction" + | "runtime"; + +const definitions: Record< + DatabaseErrorCategory, + { readonly message: string; readonly statusCode: number } +> = { + INVALID_REQUEST: { message: "Invalid database request", statusCode: 400 }, + NOT_FOUND: { message: "Database record not found", statusCode: 404 }, + CONFLICT: { message: "Database conflict", statusCode: 409 }, + FORBIDDEN: { message: "Database operation forbidden", statusCode: 403 }, + PAYLOAD_TOO_LARGE: { + message: "Database response is too large", + statusCode: 413, + }, + INTERNAL: { message: "Database operation failed", statusCode: 500 }, + SETUP_FAILED: { message: "Database setup failed", statusCode: 500 }, +}; + +const categoryByStatus: Readonly> = { + 400: "INVALID_REQUEST", + 403: "FORBIDDEN", + 409: "CONFLICT", +}; + +/** AppKit-facing database failure with stable metadata and no driver details. */ +export class DatabasePluginError extends AppKitError { + readonly code = "DATABASE_PLUGIN_ERROR"; + readonly isRetryable = false; + readonly statusCode: number; + + constructor( + readonly category: DatabaseErrorCategory, + readonly phase: DatabaseErrorPhase, + runtimeMessage?: string, + readonly details?: readonly DatabaseErrorDetail[], + ) { + const definition = definitions[category]; + // Plugin boundaries replace runtime diagnostics with the stable message. + super( + phase === "runtime" && runtimeMessage + ? runtimeMessage + : definition.message, + { + clientMessage: definition.message, + }, + ); + this.statusCode = definition.statusCode; + this.name = "DatabasePluginError"; + } +} + +/** Keep runtime diagnostics internal until a plugin boundary classifies them. */ +export function invalidDatabaseRequest( + runtimeMessage?: string, +): DatabasePluginError { + return new DatabasePluginError("INVALID_REQUEST", "runtime", runtimeMessage); +} + +/** Refuse to publish a plugin whose configuration cannot be honored. */ +export function databaseSetupFailed(): DatabasePluginError { + return new DatabasePluginError("SETUP_FAILED", "setup"); +} + +/** Reject untrusted request input, naming the field but never its value. */ +export function invalidDatabaseInput( + path: readonly string[], + message: string, +): DatabasePluginError { + return new DatabasePluginError("INVALID_REQUEST", "read", undefined, [ + { path, message }, + ]); +} + +/** Add operation context without retaining an unknown error's details. */ +export function classifyDatabaseError( + error: unknown, + phase: DatabaseErrorPhase, +): DatabasePluginError { + if (error instanceof DatabasePluginError) { + return error.phase === phase + ? error + : new DatabasePluginError(error.category, phase); + } + logger.error("Unclassified database error during %s: %O", phase, error); + return new DatabasePluginError("INTERNAL", phase); +} + +/** Restore the safe database category carried through `Plugin.execute()`. */ +export function databaseErrorFromStatus( + status: number, + phase: DatabaseErrorPhase, +): DatabasePluginError { + const category = categoryByStatus[status] ?? "INTERNAL"; + return new DatabasePluginError(category, phase); +} diff --git a/packages/appkit/src/database/runtime/data-path.ts b/packages/appkit/src/database/runtime/data-path.ts new file mode 100644 index 000000000..54434fabe --- /dev/null +++ b/packages/appkit/src/database/runtime/data-path.ts @@ -0,0 +1,130 @@ +import { + DEFAULT_LIMIT, + type FilterOperator, + type IdValue, + MAX_LIMIT, + type OrderDirection, +} from "../contract"; +import { invalidDatabaseRequest } from "../errors"; +import type { AppKitTable, ColumnMeta } from "../schema-builder"; + +export type { IdValue, OrderDirection }; +export type ScalarValue = string | number | bigint | boolean | null; +/** Operators for one column; array operands are reserved for `in`. */ +export type FilterOps = Partial< + Record +>; +export type WhereValue = ScalarValue | readonly ScalarValue[] | FilterOps; +/** Direct-column predicates with explicit `and` and `or` predicate groups. */ +export type WhereClause = Readonly< + Record +>; + +export type OrderSpec = Readonly>; + +export interface IncludeOptions { + readonly select?: readonly string[]; + readonly where?: WhereClause; + readonly order?: OrderSpec; + readonly limit?: number; + readonly include?: IncludeSpec; +} + +/** Selection and bounds for one declared relation edge. */ +export type IncludeSpec = Readonly>; + +/** A bounded root read; adapters apply defaults and validate explicit bounds. */ +export interface QuerySpec { + readonly where?: WhereClause; + readonly order?: OrderSpec; + readonly select?: readonly string[]; + readonly include?: IncludeSpec; + readonly limit?: number; + readonly offset?: number; +} + +export type Row = Record; + +/** Combine predicates without making callers understand the wire shape. */ +export function andWhere( + existing: WhereClause | undefined, + next: WhereClause, +): WhereClause { + return existing === undefined ? next : { and: [existing, next] }; +} + +/** Internal AppKit execution port; field names are schema-owned identifiers. */ +export interface DataPath { + /** Read a bounded collection from one finalized table. */ + select(table: AppKitTable, spec: QuerySpec): Promise; + /** Read by the sole primary key while preserving supported query state. */ + findOne( + table: AppKitTable, + id: IdValue, + spec?: Pick, + ): Promise; + count(table: AppKitTable, where?: WhereClause): Promise; + /** 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; + /** 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; + /** Execute tagged SQL whose interpolations are parameter values, not SQL. */ + raw( + strings: TemplateStringsArray, + ...values: unknown[] + ): Promise; + /** Run the callback with one transaction-bound DataPath. */ + transaction(callback: (tx: DataPath) => Promise): Promise; +} + +/** Validate an explicit root or relation row limit. */ +export function validateLimit(limit: number): number { + if (!Number.isInteger(limit) || limit < 0 || limit > MAX_LIMIT) { + throw invalidDatabaseRequest( + `limit must be an integer between 0 and ${MAX_LIMIT}`, + ); + } + return limit; +} + +/** Apply the conservative collection default when no limit is supplied. */ +export function limitOrDefault(limit?: number): number { + return limit === undefined ? DEFAULT_LIMIT : validateLimit(limit); +} + +/** Reject offsets that PostgreSQL cannot represent safely as JS integers. */ +export function validateOffset(offset: number): number { + if (!Number.isSafeInteger(offset) || offset < 0) { + throw invalidDatabaseRequest("offset must be a non-negative safe integer"); + } + return offset; +} + +/** Resolve the sole primary key required by keyed operations. */ +export function primaryKeyMeta(table: AppKitTable): ColumnMeta { + const primaryKeys = Object.values(table.$columns).filter( + (column) => column.primaryKey, + ); + if (primaryKeys.length !== 1) { + throw invalidDatabaseRequest(`Table "${table.$name}" has no primary key`); + } + return primaryKeys[0]; +} + +/** Resolve an upsert target that PostgreSQL can use for conflict detection. */ +export function conflictTargetMeta( + table: AppKitTable, + columnName: string, +): ColumnMeta { + const column = table.$columns[columnName]; + if (!column || (!column.primaryKey && !column.unique)) { + throw invalidDatabaseRequest( + `Column "${table.$name}.${columnName}" is not a conflict target`, + ); + } + return column; +} diff --git a/packages/appkit/src/database/runtime/engine/drizzle-data-path.ts b/packages/appkit/src/database/runtime/engine/drizzle-data-path.ts new file mode 100644 index 000000000..b79cb54c4 --- /dev/null +++ b/packages/appkit/src/database/runtime/engine/drizzle-data-path.ts @@ -0,0 +1,355 @@ +import { eq, isSQLWrapper, type SQL, sql } from "drizzle-orm"; +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import type { PgTable } from "drizzle-orm/pg-core"; +import type { Pool } from "pg"; +import { createLogger } from "../../../logging/logger"; +import { + type DatabaseErrorCategory, + DatabasePluginError, + invalidDatabaseRequest, +} from "../../errors"; +import type { AppKitTable, ColumnMeta, Schema } from "../../schema-builder"; +import { buildEngineRelations } from "../../schema-builder/engine/relations"; +import { columnValueSchema } from "../../schema-builder/validators"; +import { + andWhere, + conflictTargetMeta, + type DataPath, + type IdValue, + limitOrDefault, + primaryKeyMeta, + type Row, + validateOffset, + type WhereClause, +} from "../data-path"; +import { + columnOf, + defaultColumns, + selectToColumns, + translateInclude, + translateOrder, + translateWhere, +} from "./translate"; + +const logger = createLogger("database"); + +/** Concrete Drizzle seam shared by the adapter and its focused tests. */ +export type DrizzleDb = NodePgDatabase>; + +/** Bind finalized AppKit metadata to a relational Drizzle database. */ +export function createDrizzleDb(pool: Pool, schema: Schema): DrizzleDb { + const completeSchema: Record = Object.assign( + Object.create(null), + schema.$engine, + buildEngineRelations(schema.$tables), + ); + return drizzle(pool, { schema: completeSchema }) as unknown as DrizzleDb; +} + +interface RelationalQueryBuilder { + findMany(config: Record): Promise; + findFirst(config: Record): Promise; +} + +/** Reject same-name or forged tables by requiring finalized object identity. */ +function assertRegisteredTable(schema: Schema, table: AppKitTable): void { + if (schema.$tables[table.$name] !== table) { + throw invalidDatabaseRequest(`Table "${table.$name}" is not registered`); + } +} + +function relationalQueryBuilder( + db: DrizzleDb, + schema: Schema, + table: AppKitTable, +): RelationalQueryBuilder { + assertRegisteredTable(schema, table); + const query = (db.query as unknown as Record)[ + table.$name + ]; + if (!query) { + throw invalidDatabaseRequest(`Table "${table.$name}" is not registered`); + } + return query; +} + +function selectedColumns( + table: AppKitTable, + select?: readonly string[], +): Record { + return select === undefined + ? defaultColumns(table) + : selectToColumns(table, select); +} + +/** Keep mutation identifiers schema-owned and every supplied value parameterized. */ +function mutationValues(table: AppKitTable, values: Row): Row { + if (values === null || typeof values !== "object" || Array.isArray(values)) { + throw invalidDatabaseRequest("Database mutation values must be an object"); + } + for (const [key, value] of Object.entries(values)) { + if (!Object.hasOwn(table.$columns, key)) { + throw invalidDatabaseRequest(`Unknown column "${table.$name}.${key}"`); + } + if (isSQLWrapper(value)) { + throw invalidDatabaseRequest( + "Database mutation values cannot contain SQL", + ); + } + } + return values; +} + +// Drizzle wraps driver failures in DrizzleQueryError, so the SQLSTATE sits on a +// nested `cause` rather than the thrown error. Walk a bounded chain to find it. +const MAX_CAUSE_DEPTH = 5; + +function sqlStateOf(error: unknown): string | undefined { + let current = error; + for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) { + if (!current || typeof current !== "object") return undefined; + try { + const candidate = Reflect.get(current, "code"); + // SQLSTATE is always a five-character alphanumeric class code. + if (typeof candidate === "string" && /^[0-9A-Z]{5}$/.test(candidate)) { + return candidate; + } + current = Reflect.get(current, "cause"); + } catch { + return undefined; + } + } + return undefined; +} + +/** Classify SQLSTATE without retaining the driver error or its properties. */ +function classifyDriverError(error: unknown): DatabasePluginError { + const code = sqlStateOf(error); + const category: DatabaseErrorCategory = + code === "42501" + ? "FORBIDDEN" + : code?.startsWith("23") + ? "CONFLICT" + : "INTERNAL"; + logger.error( + "Database driver error classified as %s (SQLSTATE %s): %O", + category, + code ?? "unknown", + error, + ); + return new DatabasePluginError(category, "runtime"); +} + +async function runDatabaseOperation( + operation: () => Promise, +): Promise { + try { + return await operation(); + } catch (error) { + if (error instanceof DatabasePluginError) throw error; + throw classifyDriverError(error); + } +} + +/** Resolve and validate the sole key once before a keyed operation executes. */ +function validatedPrimaryKey( + table: AppKitTable, + id: unknown, +): { readonly meta: ColumnMeta; readonly value: IdValue } { + const meta = primaryKeyMeta(table); + const result = columnValueSchema(meta).safeParse(id); + if (!result.success) { + throw invalidDatabaseRequest( + `Invalid primary-key value for "${table.$name}.${meta.columnName}"`, + ); + } + return { meta, value: result.data as IdValue }; +} + +// Enforce the single-row DataPath contract before results reach callers. +function expectExactlyOne(rows: Row[]): Row { + if (rows.length !== 1) { + throw new DatabasePluginError("INTERNAL", "runtime"); + } + return rows[0]; +} + +function expectZeroOrOne(rows: Row[]): Row | null { + if (rows.length > 1) { + throw new DatabasePluginError("INTERNAL", "runtime"); + } + return rows[0] ?? null; +} + +/** Adapt a Drizzle database to AppKit's internal execution port. */ +export function createDrizzleDataPath(db: DrizzleDb, schema: Schema): DataPath { + const pgTable = (table: AppKitTable): PgTable => { + assertRegisteredTable(schema, table); + return table.$engine as unknown as PgTable; + }; + const whereSql = ( + table: AppKitTable, + where?: WhereClause, + ): SQL | undefined => + where === undefined ? undefined : translateWhere(table, where); + + return { + async select(table, spec) { + return runDatabaseOperation(() => + relationalQueryBuilder(db, schema, table).findMany({ + where: whereSql(table, spec.where), + orderBy: + spec.order === undefined + ? undefined + : translateOrder(table, spec.order), + columns: selectedColumns(table, spec.select), + with: + spec.include === undefined + ? undefined + : translateInclude(table, schema, spec.include), + limit: limitOrDefault(spec.limit), + offset: + spec.offset === undefined ? undefined : validateOffset(spec.offset), + }), + ); + }, + + async findOne(table, id, spec) { + const { meta: primaryKey, value: validatedId } = validatedPrimaryKey( + table, + id, + ); + const row = await runDatabaseOperation(() => + relationalQueryBuilder(db, schema, table).findFirst({ + where: + spec?.where === undefined + ? eq(columnOf(table, primaryKey.columnName), validatedId) + : translateWhere( + table, + andWhere( + { [primaryKey.columnName]: { eq: validatedId } }, + spec.where, + ), + ), + columns: selectedColumns(table, spec?.select), + with: + spec?.include === undefined + ? undefined + : translateInclude(table, schema, spec.include), + }), + ); + return row ?? null; + }, + + async count(table, where) { + return runDatabaseOperation(() => + db.$count(pgTable(table), whereSql(table, where)), + ); + }, + + async insert(table, values) { + const engineTable = pgTable(table); + const parameters = mutationValues(table, values); + const rows = await runDatabaseOperation(() => + db.insert(engineTable).values(parameters).returning(), + ); + return expectExactlyOne(rows as Row[]); + }, + + async update(table, id, values) { + const engineTable = pgTable(table); + const parameters = mutationValues(table, values); + const { meta: primaryKey, value: validatedId } = validatedPrimaryKey( + table, + id, + ); + const rows = await runDatabaseOperation(() => + db + .update(engineTable) + .set(parameters) + .where(eq(columnOf(table, primaryKey.columnName), validatedId)) + .returning(), + ); + return expectZeroOrOne(rows as Row[]); + }, + + async upsert(table, values, onConflict) { + const engineTable = pgTable(table); + const parameters = mutationValues(table, values); + const target = conflictTargetMeta(table, onConflict); + const rows = await runDatabaseOperation(() => + db + .insert(engineTable) + .values(parameters) + .onConflictDoUpdate({ + target: columnOf(table, target.columnName), + set: parameters, + }) + .returning(), + ); + return expectExactlyOne(rows as Row[]); + }, + + async delete(table, id) { + const { meta: primaryKey, value: validatedId } = validatedPrimaryKey( + table, + id, + ); + const rows = await runDatabaseOperation(() => + db + .delete(pgTable(table)) + .where(eq(columnOf(table, primaryKey.columnName), validatedId)) + .returning({ id: columnOf(table, primaryKey.columnName) }), + ); + return expectZeroOrOne(rows as Row[]) !== null; + }, + + async raw( + strings: TemplateStringsArray, + ...values: unknown[] + ): Promise { + // SQL wrappers carry structure; tagged interpolations may carry values only. + if (values.some((value) => isSQLWrapper(value))) { + throw invalidDatabaseRequest( + "Tagged SQL interpolations must be parameter values", + ); + } + const result = await runDatabaseOperation(() => + db.execute(sql(strings, ...values.map((value) => sql.param(value)))), + ); + return ((result as { rows?: unknown }).rows ?? result) as T[]; + }, + + async transaction(callback: (tx: DataPath) => Promise): Promise { + let callbackFailed = false; + let callbackError: unknown; + try { + return await db.transaction(async (transaction) => { + try { + return await callback( + createDrizzleDataPath( + transaction as unknown as DrizzleDb, + schema, + ), + ); + } catch (error) { + callbackFailed = true; + callbackError = error; + throw error; + } + }); + } catch (error) { + // Callback errors are application-owned; sanitize only tx lifecycle errors. + if ( + callbackFailed && + (error === callbackError || + callbackError instanceof DatabasePluginError) + ) { + throw callbackError; + } + if (error instanceof DatabasePluginError) throw error; + throw classifyDriverError(error); + } + }, + }; +} diff --git a/packages/appkit/src/database/runtime/engine/translate.ts b/packages/appkit/src/database/runtime/engine/translate.ts new file mode 100644 index 000000000..cd1cb5c17 --- /dev/null +++ b/packages/appkit/src/database/runtime/engine/translate.ts @@ -0,0 +1,325 @@ +import { + and, + asc, + desc, + eq, + gt, + gte, + ilike, + inArray, + isNull, + like, + lt, + lte, + ne, + or, + type SQL, + sql, +} from "drizzle-orm"; +import type { AnyPgColumn } from "drizzle-orm/pg-core"; +import { + DEFAULT_LIMIT, + type FilterOperator, + IN_CAP, + isFilterOperator, + MAX_INCLUDE_DEPTH, + MAX_INCLUDE_NODES, + MAX_INCLUDES, +} from "../../contract"; +import { invalidDatabaseRequest } from "../../errors"; +import type { AppKitTable, ColumnMeta, Schema } from "../../schema-builder"; +import { filterOperatorsForKind } from "../../schema-builder/types"; +import { columnValueSchema } from "../../schema-builder/validators"; +import { + type FilterOps, + type IncludeOptions, + type IncludeSpec, + type OrderSpec, + validateLimit, + type WhereClause, +} from "../data-path"; + +function columnMetaOf(table: AppKitTable, key: string): ColumnMeta { + const column = table.$columns[key]; + if (!column) { + throw invalidDatabaseRequest(`Unknown column "${table.$name}.${key}"`); + } + return column; +} + +/** Resolve SQL identifiers only through columns finalized by the schema builder. */ +export function columnOf(table: AppKitTable, key: string): AnyPgColumn { + return columnMetaOf(table, key).engineColumn as unknown as AnyPgColumn; +} + +/** Default reads select all finalized columns except private application data. */ +export function defaultColumns(table: AppKitTable): Record { + const columns: Record = {}; + for (const column of Object.values(table.$columns)) { + if (!column.isPrivate) columns[column.columnName] = true; + } + return columns; +} + +function supportsOperator(meta: ColumnMeta, operator: FilterOperator): boolean { + if (operator === "is") { + return !meta.notNull && meta.kind !== "json" && meta.kind !== "unknown"; + } + return filterOperatorsForKind(meta.kind).includes(operator); +} + +function assertColumnValue( + table: AppKitTable, + meta: ColumnMeta, + operator: FilterOperator, + value: unknown, +): void { + if (!columnValueSchema(meta).safeParse(value).success) { + throw invalidDatabaseRequest( + `Invalid ${operator} operand for "${table.$name}.${meta.columnName}"`, + ); + } +} + +function inList( + table: AppKitTable, + meta: ColumnMeta, + column: AnyPgColumn, + value: unknown, +): SQL { + if (!Array.isArray(value)) { + throw invalidDatabaseRequest('The "in" operator requires an array'); + } + if (value.length > IN_CAP) { + throw invalidDatabaseRequest(`in list exceeds the ${IN_CAP}-value limit`); + } + for (const item of value) { + if (item === null) { + throw invalidDatabaseRequest('The "in" operator does not accept null'); + } + assertColumnValue(table, meta, "in", item); + } + return value.length === 0 ? sql.raw("false") : inArray(column, value); +} + +function translateOperator( + table: AppKitTable, + meta: ColumnMeta, + column: AnyPgColumn, + operator: FilterOperator, + value: unknown, +): SQL { + if (!supportsOperator(meta, operator)) { + throw invalidDatabaseRequest( + `Operator "${operator}" is not supported for "${table.$name}.${meta.columnName}"`, + ); + } + if (operator === "is") { + if (value !== null) { + throw invalidDatabaseRequest('The "is" operator accepts only null'); + } + return isNull(column); + } + if (operator === "in") return inList(table, meta, column, value); + + assertColumnValue(table, meta, operator, value); + switch (operator) { + case "eq": + return eq(column, value); + case "neq": + return ne(column, value); + case "gt": + return gt(column, value); + case "gte": + return gte(column, value); + case "lt": + return lt(column, value); + case "lte": + return lte(column, value); + case "like": + return like(column, value as string); + case "ilike": + return ilike(column, value as string); + default: + throw invalidDatabaseRequest(`Unsupported filter operator "${operator}"`); + } +} + +/** Translate direct-column predicates. Relation predicates are not supported. */ +export function translateWhere( + table: AppKitTable, + clause: WhereClause, +): SQL | undefined { + if (clause === null || typeof clause !== "object" || Array.isArray(clause)) { + throw invalidDatabaseRequest("where must be an object"); + } + const conditions: SQL[] = []; + for (const [key, value] of Object.entries(clause)) { + if (key === "and" || key === "or") { + if (!Array.isArray(value) || value.length === 0) { + throw invalidDatabaseRequest( + `${key} requires a non-empty predicate array`, + ); + } + const groups = value.map((group) => { + const translated = translateWhere(table, group as WhereClause); + if (!translated) { + throw invalidDatabaseRequest(`${key} predicates cannot be empty`); + } + return translated; + }); + const combined = key === "and" ? and(...groups) : or(...groups); + if (combined) { + conditions.push(combined); + } + continue; + } + + const meta = columnMetaOf(table, key); + const column = meta.engineColumn as unknown as AnyPgColumn; + if (Array.isArray(value)) { + conditions.push(translateOperator(table, meta, column, "in", value)); + } else if ( + value !== null && + typeof value === "object" && + !Array.isArray(value) + ) { + const operators = Object.entries(value as FilterOps); + if (operators.length === 0) { + throw invalidDatabaseRequest( + `Filter for "${table.$name}.${key}" cannot be empty`, + ); + } + for (const [operator, operand] of operators) { + if (!isFilterOperator(operator)) { + throw invalidDatabaseRequest(`Unknown filter operator "${operator}"`); + } + conditions.push( + translateOperator(table, meta, column, operator, operand), + ); + } + } else { + conditions.push(translateOperator(table, meta, column, "eq", value)); + } + } + return conditions.length > 0 ? and(...conditions) : undefined; +} + +export function translateOrder(table: AppKitTable, order: OrderSpec): SQL[] { + return Object.entries(order).map(([key, direction]) => { + if (direction !== "asc" && direction !== "desc") { + throw invalidDatabaseRequest(`Unknown order direction "${direction}"`); + } + const column = columnOf(table, key); + return direction === "desc" ? desc(column) : asc(column); + }); +} + +export function selectToColumns( + table: AppKitTable, + select: readonly string[], +): Record { + const columns: Record = {}; + for (const key of select) { + columnOf(table, key); + columns[key] = true; + } + return columns; +} + +function tableByName(schema: Schema, name: string): AppKitTable { + const table = schema.$tables[name]; + if (!table) throw invalidDatabaseRequest(`Unknown table "${name}"`); + return table; +} + +/** Translate relation edges into Drizzle's relational `with` config. */ +export function translateInclude( + table: AppKitTable, + schema: Schema, + include: IncludeSpec, +): Record { + return translateIncludeTree(table, schema, include, 1, { nodes: 0 }); +} + +function translateIncludeTree( + table: AppKitTable, + schema: Schema, + include: IncludeSpec, + depth: number, + budget: { nodes: number }, +): Record { + if (depth > MAX_INCLUDE_DEPTH) { + throw invalidDatabaseRequest( + `include exceeds the ${MAX_INCLUDE_DEPTH}-edge depth limit`, + ); + } + const entries = Object.entries(include); + if (entries.length > MAX_INCLUDES) { + throw invalidDatabaseRequest( + `include exceeds the ${MAX_INCLUDES}-relation limit`, + ); + } + + const config: Record = {}; + for (const [relationName, rawOptions] of entries) { + const relation = table.$relations.find( + (candidate) => candidate.name === relationName, + ); + if (!relation) { + throw invalidDatabaseRequest( + `Unknown relation "${table.$name}.${relationName}"`, + ); + } + if (rawOptions === false) continue; + + budget.nodes += 1; + if (budget.nodes > MAX_INCLUDE_NODES) { + throw invalidDatabaseRequest( + `include exceeds the ${MAX_INCLUDE_NODES}-node limit`, + ); + } + + const target = tableByName(schema, relation.targetTable); + if (rawOptions === true) { + config[relationName] = { + columns: defaultColumns(target), + ...(relation.cardinality === "toMany" ? { limit: DEFAULT_LIMIT } : {}), + }; + continue; + } + + const options = rawOptions as IncludeOptions; + const relationConfig: Record = { + columns: + options.select === undefined + ? defaultColumns(target) + : selectToColumns(target, options.select), + }; + if (options.where !== undefined) { + relationConfig.where = translateWhere(target, options.where); + } + if (options.order !== undefined) { + relationConfig.orderBy = translateOrder(target, options.order); + } + if (options.limit !== undefined) { + if (relation.cardinality !== "toMany") { + throw invalidDatabaseRequest("Only to-many relations accept a limit"); + } + relationConfig.limit = validateLimit(options.limit); + } else if (relation.cardinality === "toMany") { + relationConfig.limit = DEFAULT_LIMIT; + } + if (options.include !== undefined) { + relationConfig.with = translateIncludeTree( + target, + schema, + options.include, + depth + 1, + budget, + ); + } + config[relationName] = relationConfig; + } + return config; +} diff --git a/packages/appkit/src/database/runtime/index.ts b/packages/appkit/src/database/runtime/index.ts new file mode 100644 index 000000000..987f4611c --- /dev/null +++ b/packages/appkit/src/database/runtime/index.ts @@ -0,0 +1,14 @@ +export type { + DataPath, + FilterOps, + IdValue, + IncludeOptions, + IncludeSpec, + OrderDirection, + OrderSpec, + QuerySpec, + Row, + ScalarValue, + WhereClause, + WhereValue, +} from "./data-path"; diff --git a/packages/appkit/src/database/runtime/tests/data-path-contract.test.ts b/packages/appkit/src/database/runtime/tests/data-path-contract.test.ts new file mode 100644 index 000000000..f78b46208 --- /dev/null +++ b/packages/appkit/src/database/runtime/tests/data-path-contract.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import { DEFAULT_LIMIT, MAX_LIMIT } from "../../contract"; +import { DatabasePluginError } from "../../errors"; +import { defineSchema, id, text } from "../../schema-builder"; +import { + conflictTargetMeta, + limitOrDefault, + primaryKeyMeta, + validateLimit, + validateOffset, +} from "../data-path"; +import type { DataPath, IdValue, QuerySpec, Row } from "../index"; + +const schema = defineSchema((builder) => ({ + users: builder.table("users", { + id: id(), + email: text().unique(), + }), + events: builder.table("events", { body: text() }), +})); + +describe("DataPath contract", () => { + it("exposes the backend-neutral operations implemented in this phase", () => { + expectTypeOf().toHaveProperty("select"); + expectTypeOf().toHaveProperty("findOne"); + expectTypeOf().toHaveProperty("count"); + expectTypeOf().toHaveProperty("insert"); + expectTypeOf().toHaveProperty("update"); + expectTypeOf().toHaveProperty("upsert"); + expectTypeOf().toHaveProperty("delete"); + expectTypeOf().toHaveProperty("raw"); + expectTypeOf().toHaveProperty("transaction"); + }); + + it("keeps rows and identifiers backend-neutral", () => { + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().returns.resolves.toEqualTypeOf(); + expectTypeOf< + DataPath["findOne"] + >().returns.resolves.toEqualTypeOf(); + }); + + it("represents the query state translated by the Drizzle adapter", () => { + const spec = { + where: { email: { ilike: "%@example.com" } }, + order: { email: "asc" }, + select: ["id", "email"], + include: {}, + limit: 10, + offset: 5, + } satisfies QuerySpec; + expectTypeOf(spec).toMatchTypeOf(); + }); + + it("keeps the runtime barrel type-only", async () => { + expect(Object.keys(await import("../index"))).toEqual([]); + }); +}); + +describe("runtime bounds and metadata", () => { + it("applies the default limit and validates explicit bounds", () => { + expect(limitOrDefault()).toBe(DEFAULT_LIMIT); + expect(validateLimit(0)).toBe(0); + expect(validateLimit(MAX_LIMIT)).toBe(MAX_LIMIT); + expect(() => validateLimit(-1)).toThrow(DatabasePluginError); + expect(() => validateLimit(MAX_LIMIT + 1)).toThrow(DatabasePluginError); + }); + + it("accepts only non-negative safe offsets", () => { + expect(validateOffset(0)).toBe(0); + expect(validateOffset(10)).toBe(10); + expect(() => validateOffset(-1)).toThrow(DatabasePluginError); + expect(() => validateOffset(Number.MAX_VALUE)).toThrow(DatabasePluginError); + }); + + it("resolves primary keys and explicit conflict targets from metadata", () => { + expect(primaryKeyMeta(schema.$tables.users).columnName).toBe("id"); + expect(conflictTargetMeta(schema.$tables.users, "email").unique).toBe(true); + expect(() => primaryKeyMeta(schema.$tables.events)).toThrow( + DatabasePluginError, + ); + expect(() => conflictTargetMeta(schema.$tables.users, "body")).toThrow( + DatabasePluginError, + ); + }); +}); diff --git a/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts b/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts new file mode 100644 index 000000000..2d8d0fde0 --- /dev/null +++ b/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts @@ -0,0 +1,689 @@ +import { sql as drizzleSql, type SQL } from "drizzle-orm"; +import { PgDialect, type PgTable } from "drizzle-orm/pg-core"; +import { Pool } from "pg"; +import { afterAll, describe, expect, it } from "vitest"; +import { DEFAULT_LIMIT, MAX_LIMIT } from "../../contract"; +import { DatabasePluginError } from "../../errors"; +import { + bigid, + boolean, + defineSchema, + fk, + id, + text, + uuid, +} from "../../schema-builder"; +import type { Row } from "../data-path"; +import { + createDrizzleDataPath, + createDrizzleDb, + type DrizzleDb, +} from "../engine/drizzle-data-path"; + +const schema = defineSchema((builder) => { + const users = builder.table("users", { + id: id(), + email: text().unique(), + name: text(), + active: boolean(), + secret: text().private(), + }); + const posts = builder.table("posts", { + id: id(), + authorId: fk(() => users.id), + title: text(), + }); + return { users, posts }; +}); + +const users = schema.$tables.users; +const dialect = new PgDialect(); + +function render(fragment: unknown): { sql: string; params: unknown[] } { + const query = dialect.sqlToQuery(fragment as SQL); + return { sql: query.sql, params: query.params as unknown[] }; +} + +interface FakeResults { + findMany?: Row[]; + findFirst?: Row; + count?: number; + insert?: Row[]; + update?: Row[]; + upsert?: Row[]; + delete?: Row[]; + execute?: unknown; + transactionFailure?: "begin" | "commit" | "rollback"; + transactionError?: unknown; +} + +interface FakeCalls { + findMany: { table: string; config: Record }[]; + findFirst: { table: string; config: Record }[]; + count: { table: unknown; filter: unknown }[]; + insert: { table: unknown; values: Row }[]; + update: { table: unknown; values: Row; where: unknown }[]; + upsert: { + table: unknown; + values: Row; + config: { target: unknown; set: Row }; + }[]; + delete: { table: unknown; where: unknown; returning: unknown }[]; + execute: unknown[]; + transactions: number; +} + +function makeFakeDb(results: FakeResults = {}): { + db: DrizzleDb; + calls: FakeCalls; +} { + const calls: FakeCalls = { + findMany: [], + findFirst: [], + count: [], + insert: [], + update: [], + upsert: [], + delete: [], + execute: [], + transactions: 0, + }; + const query = Object.fromEntries( + Object.keys(schema.$tables).map((table) => [ + table, + { + async findMany(config: Record) { + calls.findMany.push({ table, config }); + return results.findMany ?? []; + }, + async findFirst(config: Record) { + calls.findFirst.push({ table, config }); + return results.findFirst; + }, + }, + ]), + ); + + const db = { + query, + async $count(table: unknown, filter: unknown) { + calls.count.push({ table, filter }); + return results.count ?? 0; + }, + insert(table: unknown) { + return { + values(values: Row) { + return { + async returning() { + calls.insert.push({ table, values }); + return results.insert ?? []; + }, + onConflictDoUpdate(config: { target: unknown; set: Row }) { + return { + async returning() { + calls.upsert.push({ table, values, config }); + return results.upsert ?? []; + }, + }; + }, + }; + }, + }; + }, + update(table: unknown) { + return { + set(values: Row) { + return { + where(where: unknown) { + return { + async returning() { + calls.update.push({ table, values, where }); + return results.update ?? []; + }, + }; + }, + }; + }, + }; + }, + delete(table: unknown) { + return { + where(where: unknown) { + return { + async returning(returning: unknown) { + calls.delete.push({ table, where, returning }); + return results.delete ?? []; + }, + }; + }, + }; + }, + async execute(fragment: unknown) { + calls.execute.push(fragment); + return results.execute ?? { rows: [] }; + }, + async transaction(callback: (tx: unknown) => unknown) { + calls.transactions += 1; + if (results.transactionFailure === "begin") { + throw results.transactionError; + } + try { + const value = await callback(db); + if (results.transactionFailure === "commit") { + throw results.transactionError; + } + return value; + } catch (error) { + if (results.transactionFailure === "rollback") { + throw results.transactionError; + } + throw error; + } + }, + }; + return { db: db as unknown as DrizzleDb, calls }; +} + +describe("createDrizzleDataPath reads", () => { + it("translates bounded reads with a private-safe default projection", async () => { + const fake = makeFakeDb({ findMany: [{ id: 1, name: "Ada" }] }); + const dataPath = createDrizzleDataPath(fake.db, schema); + await expect( + dataPath.select(users, { + where: { active: true }, + order: { name: "asc" }, + include: { posts: true }, + offset: 2, + }), + ).resolves.toEqual([{ id: 1, name: "Ada" }]); + + const config = fake.calls.findMany[0].config; + expect(config.limit).toBe(DEFAULT_LIMIT); + expect(config.offset).toBe(2); + expect(config.columns).toEqual({ + id: true, + email: true, + name: true, + active: true, + }); + expect(config.with).toEqual({ + posts: { + columns: { id: true, authorId: true, title: true }, + limit: DEFAULT_LIMIT, + }, + }); + expect(render(config.where).params).toEqual([true]); + }); + + it("supports explicit selection and rejects excessive limits", async () => { + const fake = makeFakeDb(); + const dataPath = createDrizzleDataPath(fake.db, schema); + await dataPath.select(users, { + select: ["id", "secret"], + limit: MAX_LIMIT, + }); + expect(fake.calls.findMany[0].config.columns).toEqual({ + id: true, + secret: true, + }); + await expect( + dataPath.select(users, { limit: MAX_LIMIT + 1 }), + ).rejects.toBeInstanceOf(DatabasePluginError); + }); + + it("finds by primary key and delegates count filters", async () => { + const fake = makeFakeDb({ findFirst: { id: 7, name: "Ada" }, count: 3 }); + const dataPath = createDrizzleDataPath(fake.db, schema); + await expect( + dataPath.findOne(users, 7, { + where: { active: true }, + select: ["id", "name"], + }), + ).resolves.toEqual({ id: 7, name: "Ada" }); + expect(render(fake.calls.findFirst[0].config.where).params).toEqual([ + 7, + true, + ]); + await expect(dataPath.count(users, { active: true })).resolves.toBe(3); + expect(render(fake.calls.count[0].filter).params).toEqual([true]); + }); + + it("rejects table handles from another schema", async () => { + const other = defineSchema((builder) => ({ + users: builder.table("users", { id: id() }), + })); + await expect( + createDrizzleDataPath(makeFakeDb().db, schema).select( + other.$tables.users, + {}, + ), + ).rejects.toBeInstanceOf(DatabasePluginError); + }); + + it.each([ + [ + "number", + defineSchema(({ table }) => ({ users: table("users", { id: id() }) })), + 7, + "7", + ], + [ + "bigint", + defineSchema(({ table }) => ({ users: table("users", { id: bigid() }) })), + 7n, + 7, + ], + [ + "uuid", + defineSchema(({ table }) => ({ + users: table("users", { id: uuid().primaryKey() }), + })), + "123e4567-e89b-42d3-a456-426614174000", + "not-a-uuid", + ], + [ + "custom string", + defineSchema(({ table }) => ({ + users: table("users", { id: text().primaryKey() }), + })), + "user-key", + 7, + ], + ] as const)( + "validates %s primary-key IDs before builders", + async (_kind, idSchema, valid, invalid) => { + const fake = makeFakeDb({ + findFirst: { id: valid }, + update: [{ id: valid }], + delete: [{ id: valid }], + }); + const table = idSchema.$tables.users; + const dataPath = createDrizzleDataPath(fake.db, idSchema); + await expect(dataPath.findOne(table, valid, {})).resolves.toEqual({ + id: valid, + }); + await expect(dataPath.update(table, valid, {})).resolves.toEqual({ + id: valid, + }); + await expect(dataPath.delete(table, valid)).resolves.toBe(true); + const before = { + findFirst: fake.calls.findFirst.length, + update: fake.calls.update.length, + delete: fake.calls.delete.length, + }; + await expect( + dataPath.findOne(table, invalid as never, {}), + ).rejects.toMatchObject({ category: "INVALID_REQUEST" }); + await expect( + dataPath.update(table, invalid as never, {}), + ).rejects.toMatchObject({ category: "INVALID_REQUEST" }); + await expect( + dataPath.delete(table, invalid as never), + ).rejects.toMatchObject({ category: "INVALID_REQUEST" }); + expect(fake.calls.findFirst).toHaveLength(before.findFirst); + expect(fake.calls.update).toHaveLength(before.update); + expect(fake.calls.delete).toHaveLength(before.delete); + }, + ); +}); + +describe("Drizzle mutation cardinality", () => { + it("requires exactly one insert and upsert row", async () => { + const row = { id: 1, email: "a@example.com" }; + await expect( + createDrizzleDataPath(makeFakeDb({ insert: [row] }).db, schema).insert( + users, + { email: "a@example.com" }, + ), + ).resolves.toEqual(row); + await expect( + createDrizzleDataPath(makeFakeDb({ insert: [] }).db, schema).insert( + users, + {}, + ), + ).rejects.toBeInstanceOf(DatabasePluginError); + await expect( + createDrizzleDataPath( + makeFakeDb({ insert: [row, row] }).db, + schema, + ).insert(users, {}), + ).rejects.toBeInstanceOf(DatabasePluginError); + + const fake = makeFakeDb({ upsert: [row] }); + await expect( + createDrizzleDataPath(fake.db, schema).upsert( + users, + { email: "a@example.com" }, + "email", + ), + ).resolves.toEqual(row); + expect(fake.calls.upsert[0].config.target).toBe( + users.$columns.email.engineColumn, + ); + await expect( + createDrizzleDataPath(fake.db, schema).upsert(users, {}, "name"), + ).rejects.toBeInstanceOf(DatabasePluginError); + await expect( + createDrizzleDataPath(makeFakeDb({ upsert: [] }).db, schema).upsert( + users, + { email: "a@example.com" }, + "email", + ), + ).rejects.toBeInstanceOf(DatabasePluginError); + await expect( + createDrizzleDataPath( + makeFakeDb({ upsert: [row, row] }).db, + schema, + ).upsert(users, { email: "a@example.com" }, "email"), + ).rejects.toBeInstanceOf(DatabasePluginError); + }); + + it("rejects unknown identifiers and structural Drizzle mutation values", async () => { + const fake = makeFakeDb({ insert: [{ id: 1 }] }); + const dataPath = createDrizzleDataPath(fake.db, schema); + + await expect( + dataPath.insert(users, { missing: "not a schema column" }), + ).rejects.toBeInstanceOf(DatabasePluginError); + await expect( + dataPath.insert(users, { name: drizzleSql.raw("current_user") }), + ).rejects.toBeInstanceOf(DatabasePluginError); + await expect( + dataPath.update(users, 1, { name: users.$columns.email.engineColumn }), + ).rejects.toBeInstanceOf(DatabasePluginError); + await expect( + dataPath.upsert( + users, + { email: "a@example.com", name: drizzleSql.raw("current_user") }, + "email", + ), + ).rejects.toBeInstanceOf(DatabasePluginError); + + expect(fake.calls.insert).toHaveLength(0); + expect(fake.calls.update).toHaveLength(0); + expect(fake.calls.upsert).toHaveLength(0); + }); + + it("accepts zero or one update row and rejects more", async () => { + const row = { id: 1, name: "Updated" }; + await expect( + createDrizzleDataPath(makeFakeDb({ update: [row] }).db, schema).update( + users, + 1, + { name: "Updated" }, + ), + ).resolves.toEqual(row); + await expect( + createDrizzleDataPath(makeFakeDb({ update: [] }).db, schema).update( + users, + 1, + {}, + ), + ).resolves.toBeNull(); + await expect( + createDrizzleDataPath( + makeFakeDb({ update: [row, row] }).db, + schema, + ).update(users, 1, {}), + ).rejects.toBeInstanceOf(DatabasePluginError); + }); + + it("accepts zero or one delete row and rejects more", async () => { + await expect( + createDrizzleDataPath( + makeFakeDb({ delete: [{ id: 1 }] }).db, + schema, + ).delete(users, 1), + ).resolves.toBe(true); + await expect( + createDrizzleDataPath(makeFakeDb({ delete: [] }).db, schema).delete( + users, + 1, + ), + ).resolves.toBe(false); + await expect( + createDrizzleDataPath( + makeFakeDb({ delete: [{ id: 1 }, { id: 2 }] }).db, + schema, + ).delete(users, 1), + ).rejects.toBeInstanceOf(DatabasePluginError); + }); +}); + +describe("tagged SQL and transactions", () => { + it("parameterizes tagged values and rejects structural interpolation", async () => { + const fake = makeFakeDb({ execute: { rows: [{ total: 1 }] } }); + const dataPath = createDrizzleDataPath(fake.db, schema); + const malicious = "1; drop table users"; + await expect( + dataPath.raw`select count(*) as total from users where id = ${malicious}`, + ).resolves.toEqual([{ total: 1 }]); + const query = render(fake.calls.execute[0]); + expect(query.sql).toContain("$1"); + expect(query.sql).not.toContain("drop table"); + expect(query.params).toEqual([malicious]); + + await expect( + dataPath.raw`select ${drizzleSql.raw("drop table users")}`, + ).rejects.toBeInstanceOf(DatabasePluginError); + expect(fake.calls.execute).toHaveLength(1); + }); + + it("binds a DataPath to the Drizzle transaction and preserves callback errors", async () => { + const fake = makeFakeDb({ insert: [{ id: 1 }] }); + const dataPath = createDrizzleDataPath(fake.db, schema); + await expect( + dataPath.transaction((transaction) => transaction.insert(users, {})), + ).resolves.toEqual({ id: 1 }); + expect(fake.calls.transactions).toBe(1); + + const callbackError = new Error("application callback failed"); + await expect( + dataPath.transaction(async () => { + throw callbackError; + }), + ).rejects.toBe(callbackError); + + const classifiedError = new DatabasePluginError( + "INVALID_REQUEST", + "runtime", + ); + await expect( + dataPath.transaction(async () => { + throw classifiedError; + }), + ).rejects.toBe(classifiedError); + }); + + it.each(["begin", "commit", "rollback"] as const)( + "sanitizes Drizzle %s failures", + async (transactionFailure) => { + const rawError = new Error(`${transactionFailure} leaked driver detail`); + const dataPath = createDrizzleDataPath( + makeFakeDb({ transactionFailure, transactionError: rawError }).db, + schema, + ); + + const error = await dataPath + .transaction(async () => { + if (transactionFailure === "rollback") { + throw new Error("application callback failed"); + } + return "done"; + }) + .catch((caught) => caught); + + expect(error).toBeInstanceOf(DatabasePluginError); + expect(error.message).toBe("Database operation failed"); + expect(error.cause).toBeUndefined(); + }, + ); +}); + +describe("database failures", () => { + it("does not retain raw driver details", async () => { + const fake = makeFakeDb(); + const query = fake.db.query as unknown as Record< + string, + { findMany: () => Promise } + >; + query.users.findMany = async () => { + throw new Error("constraint users_email_key contains a secret"); + }; + + const error = await createDrizzleDataPath(fake.db, schema) + .select(users, {}) + .catch((caught) => caught); + expect(error).toBeInstanceOf(DatabasePluginError); + expect(error.message).toBe("Database operation failed"); + expect(error.cause).toBeUndefined(); + }); + + it.each([ + ["23505", "CONFLICT"], + ["23000", "CONFLICT"], + ["42501", "FORBIDDEN"], + ["XX000", "INTERNAL"], + [42, "INTERNAL"], + ] as const)( + "maps SQLSTATE %s without leaking driver fields", + async (code, category) => { + const fake = makeFakeDb(); + const query = fake.db.query as unknown as Record< + string, + { findMany: () => Promise } + >; + query.users.findMany = async () => { + throw { + code, + message: "password and SQL leaked", + constraint: "users_secret_key", + detail: "input secret", + }; + }; + const error = await createDrizzleDataPath(fake.db, schema) + .select(users, {}) + .catch((caught) => caught); + expect(error).toMatchObject({ + category, + }); + expect(error.cause).toBeUndefined(); + expect(JSON.stringify(error)).not.toContain("secret"); + }, + ); + + // Drizzle never rethrows the raw driver error; it wraps it in + // DrizzleQueryError and moves the SQLSTATE onto `cause`. + it.each([ + ["23503", "CONFLICT"], + ["42501", "FORBIDDEN"], + ["XX000", "INTERNAL"], + ] as const)( + "maps SQLSTATE %s carried on a wrapped driver cause", + async (code, category) => { + const fake = makeFakeDb(); + const query = fake.db.query as unknown as Record< + string, + { findMany: () => Promise } + >; + query.users.findMany = async () => { + const driver = Object.assign(new Error("secret constraint detail"), { + code, + constraint: "users_secret_key", + }); + throw Object.assign( + new Error("Failed query: select secret from users"), + { cause: driver }, + ); + }; + const error = await createDrizzleDataPath(fake.db, schema) + .select(users, {}) + .catch((caught) => caught); + expect(error).toMatchObject({ category }); + expect(error.cause).toBeUndefined(); + expect(JSON.stringify(error)).not.toContain("secret"); + }, + ); + + it("stops walking an error cause cycle", async () => { + const fake = makeFakeDb(); + const query = fake.db.query as unknown as Record< + string, + { findMany: () => Promise } + >; + query.users.findMany = async () => { + const cyclic: { cause?: unknown } = {}; + cyclic.cause = cyclic; + throw cyclic; + }; + const error = await createDrizzleDataPath(fake.db, schema) + .select(users, {}) + .catch((caught) => caught); + expect(error).toMatchObject({ category: "INTERNAL" }); + }); + + it.each([ + Object.defineProperty({}, "code", { + get: () => { + throw new Error("getter secret"); + }, + }), + new Proxy( + {}, + { + get: () => { + throw new Error("proxy secret"); + }, + }, + ), + ])("fails closed for hostile SQLSTATE access", async (hostile) => { + const fake = makeFakeDb(); + const query = fake.db.query as unknown as Record< + string, + { findMany: () => Promise } + >; + query.users.findMany = async () => { + throw hostile; + }; + const error = await createDrizzleDataPath(fake.db, schema) + .select(users, {}) + .catch((caught) => caught); + expect(error).toMatchObject({ + category: "INTERNAL", + message: "Database operation failed", + }); + expect(error.cause).toBeUndefined(); + }); +}); + +describe("createDrizzleDb", () => { + let pool: Pool | undefined; + afterAll(async () => { + await pool?.end(); + }); + + it("registers canonical tables and relations without connecting", () => { + pool = new Pool(); + const db = createDrizzleDb(pool, schema); + const query = db.query as unknown as Record< + string, + Record + >; + expect(typeof query.users.findMany).toBe("function"); + expect(typeof query.posts.findFirst).toBe("function"); + }); + + it("parameterizes ordinary mutation values", () => { + pool ??= new Pool(); + const db = createDrizzleDb(pool, schema); + const malicious = "x'); drop table users; --"; + const query = db + .insert(users.$engine as unknown as PgTable) + .values({ name: malicious }) + .toSQL(); + + expect(query.sql).toContain("$1"); + expect(query.sql).not.toContain("drop table"); + expect(query.params).toContain(malicious); + }); +}); diff --git a/packages/appkit/src/database/runtime/tests/translate.test.ts b/packages/appkit/src/database/runtime/tests/translate.test.ts new file mode 100644 index 000000000..4345cfb14 --- /dev/null +++ b/packages/appkit/src/database/runtime/tests/translate.test.ts @@ -0,0 +1,298 @@ +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; +import { IN_CAP, MAX_INCLUDES, MAX_LIMIT } from "../../contract"; +import { DatabasePluginError } from "../../errors"; +import { + bigint, + boolean, + defineSchema, + enumColumn, + fk, + id, + integer, + jsonb, + text, + timestamp, + uuid, +} from "../../schema-builder"; +import { filterOperatorsForKind } from "../../schema-builder/types"; +import { + defaultColumns, + selectToColumns, + translateInclude, + translateOrder, + translateWhere, +} from "../engine/translate"; + +const schema = defineSchema((builder) => { + const users = builder.table("users", { + id: id(), + name: text(), + age: integer(), + active: boolean().notNull(), + large: bigint().notNull(), + createdAt: timestamp().notNull(), + externalId: uuid().notNull(), + status: enumColumn("user_status", ["active", "disabled"]).notNull(), + metadata: jsonb(), + secret: text().private(), + }); + const posts = builder.table("posts", { + id: id(), + authorId: fk(() => users.id), + title: text(), + secret: text().private(), + }); + return { users, posts }; +}); + +const users = schema.$tables.users; +const dialect = new PgDialect(); + +function render(fragment: SQL | undefined): { sql: string; params: unknown[] } { + if (!fragment) throw new Error("expected SQL fragment"); + const query = dialect.sqlToQuery(fragment); + return { sql: query.sql, params: query.params as unknown[] }; +} + +describe("translateWhere", () => { + it("uses one operator matrix for every column value kind", () => { + expect(filterOperatorsForKind("string")).toEqual([ + "eq", + "neq", + "in", + "like", + "ilike", + ]); + expect(filterOperatorsForKind("number")).toEqual([ + "eq", + "neq", + "in", + "gt", + "gte", + "lt", + "lte", + ]); + expect(filterOperatorsForKind("boolean")).toEqual(["eq", "neq", "in"]); + expect(filterOperatorsForKind("json")).toEqual([]); + }); + + it("resolves identifiers from metadata and parameterizes values", () => { + const injected = "x'; drop table users; --"; + const query = render(translateWhere(users, { name: injected })); + expect(query.sql).toBe(`"users"."name" = $1`); + expect(query.sql).not.toContain("drop table"); + expect(query.params).toEqual([injected]); + expect(() => translateWhere(users, { missing: injected })).toThrow( + DatabasePluginError, + ); + }); + + it("translates the direct-column operator set", () => { + const checks = [ + [{ age: { eq: 1 } }, `"users"."age" = $1`], + [{ age: { neq: 1 } }, `"users"."age" <> $1`], + [{ age: { gt: 1 } }, `"users"."age" > $1`], + [{ age: { gte: 1 } }, `"users"."age" >= $1`], + [{ age: { lt: 1 } }, `"users"."age" < $1`], + [{ age: { lte: 1 } }, `"users"."age" <= $1`], + [{ name: { like: "a%" } }, `"users"."name" like $1`], + [{ name: { ilike: "a%" } }, `"users"."name" ilike $1`], + [{ name: { is: null } }, `"users"."name" is null`], + ] as const; + for (const [where, expected] of checks) { + expect(render(translateWhere(users, where)).sql).toBe(expected); + } + expect(() => + translateWhere(users, { age: { between: [1, 2] } as never }), + ).toThrow(DatabasePluginError); + }); + + it("bounds in lists and gives an empty list deterministic semantics", () => { + const query = render(translateWhere(users, { id: { in: [1, 2, 3] } })); + expect(query.sql).toBe(`"users"."id" in ($1, $2, $3)`); + expect(query.params).toEqual([1, 2, 3]); + expect(render(translateWhere(users, { id: { in: [] } })).sql).toBe("false"); + expect(() => + translateWhere(users, { + id: { in: Array.from({ length: IN_CAP + 1 }, (_, index) => index) }, + }), + ).toThrow(DatabasePluginError); + expect(() => + translateWhere(users, { name: { in: ["Ada", null] } }), + ).toThrow(DatabasePluginError); + }); + + it("rejects operators and values that do not match column metadata", () => { + const validUuid = "123e4567-e89b-12d3-a456-426614174000"; + expect( + render(translateWhere(users, { externalId: validUuid })).params, + ).toEqual([validUuid]); + expect( + render( + translateWhere(users, { + createdAt: { gt: "2020-01-01T00:00:00Z" }, + }), + ).params, + ).toEqual(["2020-01-01T00:00:00Z"]); + expect(render(translateWhere(users, { large: { gt: 5n } })).params).toEqual( + [5n], + ); + + expect(() => + translateWhere(users, { age: { like: "1%" } as never }), + ).toThrow(DatabasePluginError); + expect(() => + translateWhere(users, { active: { gt: true } as never }), + ).toThrow(DatabasePluginError); + expect(() => + translateWhere(users, { metadata: { eq: { key: "value" } } as never }), + ).toThrow(DatabasePluginError); + expect(() => translateWhere(users, { externalId: "not-a-uuid" })).toThrow( + DatabasePluginError, + ); + expect(() => + translateWhere(users, { createdAt: { gt: "not-a-timestamp" } }), + ).toThrow(DatabasePluginError); + expect(() => translateWhere(users, { status: "unknown" })).toThrow( + DatabasePluginError, + ); + }); + + it("uses only is:null for nullable matching", () => { + expect(render(translateWhere(users, { name: { is: null } })).sql).toBe( + `"users"."name" is null`, + ); + expect(() => translateWhere(users, { name: null })).toThrow( + DatabasePluginError, + ); + expect(() => translateWhere(users, { name: { eq: null } })).toThrow( + DatabasePluginError, + ); + expect(() => translateWhere(users, { active: { is: null } })).toThrow( + DatabasePluginError, + ); + }); + + it("rejects empty logical groups instead of widening a query", () => { + expect(() => translateWhere(users, { or: [] })).toThrow( + DatabasePluginError, + ); + expect(() => translateWhere(users, { and: [{}] })).toThrow( + DatabasePluginError, + ); + }); + + it("combines and/or groups without relation predicates", () => { + const query = render( + translateWhere(users, { + or: [ + { name: "Ada" }, + { and: [{ age: { gt: 18 } }, { age: { lt: 65 } }] }, + ], + }), + ); + expect(query.sql).toContain(" or "); + expect(query.sql).toContain(" and "); + }); +}); + +describe("ordering and selection", () => { + it("uses a private-safe default projection", () => { + expect(defaultColumns(users)).toEqual({ + id: true, + name: true, + age: true, + active: true, + large: true, + createdAt: true, + externalId: true, + status: true, + metadata: true, + }); + }); + + it("resolves only declared columns", () => { + const [age, name] = translateOrder(users, { age: "asc", name: "desc" }); + expect(render(age).sql).toBe(`"users"."age" asc`); + expect(render(name).sql).toBe(`"users"."name" desc`); + expect(selectToColumns(users, ["id", "secret"])).toEqual({ + id: true, + secret: true, + }); + expect(() => translateOrder(users, { missing: "asc" })).toThrow( + DatabasePluginError, + ); + expect(() => selectToColumns(users, ["missing"])).toThrow( + DatabasePluginError, + ); + expect(() => translateOrder(users, { age: "sideways" as "asc" })).toThrow( + DatabasePluginError, + ); + }); +}); + +describe("translateInclude", () => { + it("uses private-safe defaults and bounds to-many reads", () => { + expect(translateInclude(users, schema, { posts: true })).toEqual({ + posts: { + columns: { id: true, authorId: true, title: true }, + limit: 50, + }, + }); + }); + + it("translates one-edge include options", () => { + const config = translateInclude(users, schema, { + posts: { + select: ["id", "secret"], + where: { title: { ilike: "a%" } }, + order: { id: "desc" }, + limit: 5, + }, + }) as { posts: Record }; + expect(config.posts.columns).toEqual({ id: true, secret: true }); + expect(config.posts.limit).toBe(5); + expect(render(config.posts.where as SQL).params).toEqual(["a%"]); + }); + + it("resolves a second relation edge with the target's own defaults", () => { + const config = translateInclude(users, schema, { + posts: { include: { users: true } }, + }) as { posts: { with: Record } }; + expect(config.posts.with).toEqual({ + users: { columns: defaultColumns(users) }, + }); + }); + + it("stops after the second relation edge", () => { + expect(() => + translateInclude(users, schema, { + posts: { include: { users: { include: { posts: true } } } }, + }), + ).toThrow(DatabasePluginError); + }); + + it("rejects unknown relations and invalid relation limits", () => { + expect(() => translateInclude(users, schema, { missing: true })).toThrow( + DatabasePluginError, + ); + expect(() => + translateInclude(users, schema, { posts: { limit: MAX_LIMIT + 1 } }), + ).toThrow(DatabasePluginError); + expect(() => + translateInclude(schema.$tables.posts, schema, { users: { limit: 1 } }), + ).toThrow(DatabasePluginError); + + const tooMany = Object.fromEntries( + Array.from({ length: MAX_INCLUDES + 1 }, (_, index) => [ + `relation${index}`, + true, + ]), + ); + expect(() => translateInclude(users, schema, tooMany)).toThrow( + DatabasePluginError, + ); + }); +}); diff --git a/packages/appkit/src/database/schema-builder/columns.ts b/packages/appkit/src/database/schema-builder/columns.ts index 8ab177d11..3c954805e 100644 --- a/packages/appkit/src/database/schema-builder/columns.ts +++ b/packages/appkit/src/database/schema-builder/columns.ts @@ -1,47 +1,133 @@ -import type { ColumnInfoKind, ReferentialAction } from "../contract"; import { type ColumnTypeSpec, + type ColumnValueKind, type MutableColumnMeta, + type ReferentialAction, SchemaBuildError, type StorageKind, } from "./types"; +import { columnValueSchema } from "./validators"; -const SERVER_GENERATED = new Set(["id", "bigid"]); +const REFERENTIAL_ACTIONS = new Set([ + "cascade", + "set null", + "set default", + "restrict", + "no action", +]); +const MAX_VARCHAR_LENGTH = 10_485_760; function specStorageKind(spec: ColumnTypeSpec): StorageKind { return spec.kind === "fk" ? "integer" : spec.kind; } -function stampDefaultExpr(value: string | number | boolean): string { - if (typeof value === "string") return `'${value.replace(/'/g, "''")}'`; - if (typeof value === "boolean") return value ? "true" : "false"; - return String(value); +function validateVarcharLength(length: number): void { + if (!Number.isInteger(length) || length < 1 || length > MAX_VARCHAR_LENGTH) { + throw new SchemaBuildError( + `varchar() length must be an integer between 1 and ${MAX_VARCHAR_LENGTH}`, + ); + } +} + +function validateEnum( + name: string, + values: readonly string[], +): readonly string[] { + if (!name) throw new SchemaBuildError("enum() requires a name"); + if (values.length === 0) { + throw new SchemaBuildError(`enum("${name}") requires at least one value`); + } + if (values.some((value) => typeof value !== "string" || value.length === 0)) { + throw new SchemaBuildError( + `enum("${name}") values must be non-empty strings`, + ); + } + if (new Set(values).size !== values.length) { + throw new SchemaBuildError(`enum("${name}") declares duplicate values`); + } + return Object.freeze([...values]); +} + +function validateReferentialAction(action: ReferentialAction): void { + if (!REFERENTIAL_ACTIONS.has(action)) { + throw new SchemaBuildError("Unsupported referential action"); + } +} + +interface DefaultValidationTable { + readonly name: string; + readonly metas: Readonly>; +} + +function isCompatibleLiteralDefault(meta: MutableColumnMeta): boolean { + switch (meta.storageKind) { + case "id": + case "bigid": + case "bigint": + case "jsonb": + return false; + default: + return columnValueSchema(meta).safeParse(meta.defaultValue).success; + } +} + +/** FK literals wait until finalization because fk() inherits target storage. */ +export function validateLiteralDefaults( + tables: Iterable, +): void { + for (const table of tables) { + for (const meta of Object.values(table.metas)) { + if ( + Object.hasOwn(meta, "defaultValue") && + !isCompatibleLiteralDefault(meta) + ) { + throw new SchemaBuildError( + `Default for column "${table.name}.${meta.columnName}" is not compatible with ${meta.storageKind} storage`, + ); + } + } + } } +/** Mutable DSL builder; table() clones its metadata before finalization. */ export class ColumnBuilder { - /** @internal */ readonly _spec: ColumnTypeSpec; /** @internal */ readonly _meta: MutableColumnMeta; + private readonly declarationKind: ColumnTypeSpec["kind"]; - constructor(spec: ColumnTypeSpec, pgType: string, kind: ColumnInfoKind) { - const serverGenerated = SERVER_GENERATED.has(spec.kind); - this._spec = spec; + constructor(spec: ColumnTypeSpec, kind: ColumnValueKind) { + this.declarationKind = spec.kind; + const enumValues = + spec.kind === "enum" + ? validateEnum(spec.enumName, spec.values) + : undefined; + + const serverGenerated = spec.kind === "id" || spec.kind === "bigid"; this._meta = { name: "", columnName: "", kind, - pgType, storageKind: specStorageKind(spec), - notNull: false, + notNull: serverGenerated, primaryKey: serverGenerated, unique: false, isPrivate: false, - isOwner: false, serverGenerated, hasDefault: serverGenerated, withTimezone: spec.kind === "timestamp" ? spec.withTimezone : undefined, varcharLength: spec.kind === "varchar" ? spec.length : undefined, enumName: spec.kind === "enum" ? spec.enumName : undefined, - enumValues: spec.kind === "enum" ? spec.values : undefined, + enumValues, + }; + } + + /** @internal Clone declaration state so builder reuse cannot mutate a table. */ + _cloneMeta(): MutableColumnMeta { + return { + ...this._meta, + enumValues: this._meta.enumValues + ? Object.freeze([...this._meta.enumValues]) + : undefined, + fk: this._meta.fk ? { ...this._meta.fk } : undefined, }; } @@ -52,6 +138,7 @@ export class ColumnBuilder { primaryKey(): this { this._meta.primaryKey = true; + this._meta.notNull = true; return this; } @@ -65,46 +152,59 @@ export class ColumnBuilder { return this; } - owner(): this { - this._meta.isOwner = true; - return this; - } - default(value: string | number | boolean): this { + this.requireNoDefault(); this._meta.hasDefault = true; - this._meta.defaultExpr = stampDefaultExpr(value); this._meta.defaultValue = value; return this; } defaultNow(): this { + this.requireNoDefault(); + if (this.declarationKind !== "timestamp") { + throw new SchemaBuildError( + ".defaultNow() is only valid on timestamp columns", + ); + } this._meta.hasDefault = true; - this._meta.defaultExpr = "now()"; this._meta.defaultNow = true; return this; } defaultRandom(): this { + this.requireNoDefault(); + if (this.declarationKind !== "uuid") { + throw new SchemaBuildError( + ".defaultRandom() is only valid on uuid columns", + ); + } this._meta.hasDefault = true; - this._meta.defaultExpr = "gen_random_uuid()"; this._meta.defaultRandom = true; return this; } onDelete(action: ReferentialAction): this { this.requireFk("onDelete"); + validateReferentialAction(action); this._meta.onDelete = action; return this; } onUpdate(action: ReferentialAction): this { this.requireFk("onUpdate"); + validateReferentialAction(action); this._meta.onUpdate = action; return this; } + private requireNoDefault(): void { + if (this._meta.hasDefault) { + throw new SchemaBuildError("A column may declare only one default mode"); + } + } + private requireFk(modifier: string): void { - if (this._spec.kind !== "fk") { + if (this.declarationKind !== "fk") { throw new SchemaBuildError( `.${modifier}() is only valid on fk() columns`, ); @@ -112,39 +212,23 @@ export class ColumnBuilder { } } -export const id = () => new ColumnBuilder({ kind: "id" }, "int4", "number"); -export const bigid = () => - new ColumnBuilder({ kind: "bigid" }, "int8", "bigint"); -export const text = () => new ColumnBuilder({ kind: "text" }, "text", "string"); -export const varchar = (length = 255) => - new ColumnBuilder({ kind: "varchar", length }, "varchar", "string"); -export const integer = () => - new ColumnBuilder({ kind: "integer" }, "int4", "number"); -export const bigint = () => - new ColumnBuilder({ kind: "bigint" }, "int8", "bigint"); -export const boolean = () => - new ColumnBuilder({ kind: "boolean" }, "bool", "boolean"); -export const uuid = () => new ColumnBuilder({ kind: "uuid" }, "uuid", "uuid"); +export const id = () => new ColumnBuilder({ kind: "id" }, "number"); +export const bigid = () => new ColumnBuilder({ kind: "bigid" }, "bigint"); +export const text = () => new ColumnBuilder({ kind: "text" }, "string"); +export const varchar = (length = 255) => { + validateVarcharLength(length); + return new ColumnBuilder({ kind: "varchar", length }, "string"); +}; +export const integer = () => new ColumnBuilder({ kind: "integer" }, "number"); +export const bigint = () => new ColumnBuilder({ kind: "bigint" }, "bigint"); +export const boolean = () => new ColumnBuilder({ kind: "boolean" }, "boolean"); +export const uuid = () => new ColumnBuilder({ kind: "uuid" }, "uuid"); export const timestamp = (opts?: { withTimezone?: boolean }) => { const withTimezone = opts?.withTimezone ?? false; - return new ColumnBuilder( - { kind: "timestamp", withTimezone }, - withTimezone ? "timestamptz" : "timestamp", - "date", - ); + return new ColumnBuilder({ kind: "timestamp", withTimezone }, "date"); }; -export const jsonb = () => - new ColumnBuilder({ kind: "jsonb" }, "jsonb", "json"); +export const jsonb = () => new ColumnBuilder({ kind: "jsonb" }, "json"); export function enumColumn(name: string, values: readonly string[]) { - if (!values || values.length === 0) { - throw new SchemaBuildError( - `enumColumn("${name}") requires at least one value`, - ); - } - return new ColumnBuilder( - { kind: "enum", enumName: name, values }, - name, - "enum", - ); + return new ColumnBuilder({ kind: "enum", enumName: name, values }, "enum"); } diff --git a/packages/appkit/src/database/schema-builder/define-schema.ts b/packages/appkit/src/database/schema-builder/define-schema.ts index e1fbd1c96..bcf970baa 100644 --- a/packages/appkit/src/database/schema-builder/define-schema.ts +++ b/packages/appkit/src/database/schema-builder/define-schema.ts @@ -1,12 +1,14 @@ -import { ColumnBuilder } from "./columns"; +import { ColumnBuilder, enumColumn, validateLiteralDefaults } from "./columns"; import { buildEngineTables } from "./engine/tables"; -import { mirrorStorageKind, resolveFkRef } from "./fk"; +import { resolveForeignKeys } from "./fk"; import { buildRelations } from "./relations"; import { type AppKitTable, + type ColumnMeta, type ColumnRef, type DefineSchemaOptions, type MutableColumnMeta, + type ResolvedRelation, type Schema, SchemaBuildError, type TableHandle, @@ -14,9 +16,22 @@ import { import { deriveInsertSchema, deriveUpdateSchema } from "./validators"; interface RawTable { - name: string; - metas: Record; - handle: AppKitTable & Record; + readonly name: string; + readonly metas: Record; + readonly handle: Record; +} + +interface FinalizedTableCandidate { + readonly columns: Readonly>; + readonly engine: Schema["$engine"][string]; + readonly relations: readonly ResolvedRelation[]; + readonly insertSchema: unknown; + readonly updateSchema: unknown; +} + +interface DeclarationState { + readonly raw: Map; + readonly handleNames: Map; } export interface SchemaBuilderContext { @@ -27,133 +42,329 @@ export interface SchemaBuilderContext { enum(name: string, values: readonly string[]): ColumnBuilder; } -export function defineSchema( - builder: (ctx: SchemaBuilderContext) => Record, - options?: DefineSchemaOptions, -): Schema { - const schemaName = options?.schemaName ?? "public"; - const raw = new Map(); +const RESERVED_OBJECT_KEYS = new Set(["__proto__", "prototype", "constructor"]); +const RESERVED_DATABASE_EXPORT_KEYS = new Set(["sql", "transaction"]); +// A global symbol lets typegen recognize schemas loaded through another AppKit instance. +const FINALIZED_SCHEMA = Symbol.for("@databricks/appkit.database.schema"); +const TABLE_METADATA_KEYS = [ + "$name", + "$schemaName", + "$columns", + "$engine", + "$relations", + "$insertSchema", + "$updateSchema", +] as const; + +function nullRecord(): Record { + return Object.create(null) as Record; +} + +function assertRecord(value: unknown, label: string): asserts value is object { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new SchemaBuildError(`${label} must be an object`); + } +} + +function assertName(value: string, label: string): void { + if (!value || RESERVED_OBJECT_KEYS.has(value)) { + throw new SchemaBuildError(`${label} "${value}" is reserved`); + } +} + +function assertTableName(value: string): void { + assertName(value, "Table name"); + if (RESERVED_DATABASE_EXPORT_KEYS.has(value)) { + throw new SchemaBuildError(`Table name "${value}" is reserved`); + } +} + +function finalizeColumn(meta: MutableColumnMeta): ColumnMeta { + if (!meta.engineColumn) { + throw new SchemaBuildError( + `Engine column "${meta.columnName}" is missing during finalization`, + ); + } + const { fkRef: _fkRef, ...published } = meta; + return Object.freeze({ + ...published, + enumValues: meta.enumValues + ? Object.freeze([...meta.enumValues]) + : undefined, + fk: meta.fk ? Object.freeze({ ...meta.fk }) : undefined, + engineColumn: meta.engineColumn, + }) as ColumnMeta; +} + +function finalizeRelations( + relations: readonly ResolvedRelation[], +): readonly ResolvedRelation[] { + return Object.freeze( + relations.map((relation) => Object.freeze({ ...relation })), + ); +} - const ctx: SchemaBuilderContext = { +/** Clone builders into per-table metadata and create stable column handles. */ +function declareTable>( + state: DeclarationState, + name: string, + columns: C, +): TableHandle { + assertTableName(name); + if (state.raw.has(name)) { + throw new SchemaBuildError(`Duplicate table "${name}"`); + } + assertRecord(columns, `Columns for table "${name}"`); + + const metas = nullRecord(); + const handle = Object.create(null) as Record; + for (const [key, column] of Object.entries(columns)) { + assertName(key, `Column name on table "${name}"`); + if (key.startsWith("$") || key === "and" || key === "or") { + throw new SchemaBuildError( + `Column "${name}.${key}" collides with AppKit runtime metadata`, + ); + } + if (!(column instanceof ColumnBuilder)) { + throw new SchemaBuildError( + `Column "${name}.${key}" was not created by an AppKit column builder`, + ); + } + + const meta = column._cloneMeta(); + meta.name = key; + meta.columnName = key; + metas[key] = meta; + + const reference: ColumnRef = Object.freeze({ + __isColumnRef: true, + tableName: name, + columnName: key, + }); + Object.defineProperty(handle, key, { + enumerable: true, + value: reference, + }); + } + + state.raw.set(name, { name, metas, handle }); + state.handleNames.set(handle, name); + return handle as unknown as TableHandle; +} + +function createBuilderContext(state: DeclarationState): SchemaBuilderContext { + return { table(name, columns) { - if (raw.has(name)) - throw new SchemaBuildError(`Duplicate table "${name}"`); - const metas: Record = {}; - const handle = {} as AppKitTable & Record; - - for (const [key, col] of Object.entries(columns)) { - col._meta.name = key; - col._meta.columnName = key; - metas[key] = col._meta; - Object.defineProperty(handle, key, { - enumerable: true, - value: { - __isColumnRef: true, - tableName: name, - columnName: key, - } satisfies ColumnRef, - }); - } - - const ownerColumns = Object.values(metas).filter((meta) => meta.isOwner); - if (ownerColumns.length > 1) { - const ownerNames = ownerColumns - .map((meta) => meta.columnName) - .join(", "); - throw new SchemaBuildError( - `Table "${name}" declares multiple .owner() columns (${ownerNames}). Only one owner column is supported.`, - ); - } - - raw.set(name, { name, metas, handle }); - return handle as TableHandle; + return declareTable(state, name, columns); }, enum(name, values) { - if (values.length === 0) { - throw new SchemaBuildError( - `enum("${name}") requires at least one value`, - ); - } - - return new ColumnBuilder( - { kind: "enum", enumName: name, values }, - name, - "enum", - ); + return enumColumn(name, values); }, }; +} - const returned = builder(ctx); - - // resolve FKs - for (const { name, metas } of raw.values()) { - for (const meta of Object.values(metas)) { - if (!meta.fkRef) continue; - const ref = resolveFkRef(meta.fkRef); - const target = raw.get(ref.tableName); - - if (!target) - throw new SchemaBuildError( - `fk() on "${name}.${meta.columnName}" targets unknown table "${ref.tableName}"`, - ); - - const targetMeta = target.metas[ref.columnName]; - if (!targetMeta) - throw new SchemaBuildError( - `fk() on "${name}.${meta.columnName}" targets unknown column "${ref.tableName}.${ref.columnName}"`, - ); - - meta.storageKind = mirrorStorageKind(targetMeta.storageKind); - meta.pgType = - targetMeta.storageKind === "bigid" ? "int8" : targetMeta.pgType; - meta.kind = targetMeta.kind === "bigint" ? "bigint" : targetMeta.kind; - if (meta.storageKind === "integer") meta.pgType = "int4"; - if (meta.storageKind === "bigint") meta.pgType = "int8"; - - meta.fk = { - targetTable: ref.tableName, - targetColumn: ref.columnName, - onDelete: meta.onDelete, - onUpdate: meta.onUpdate, - }; +/** Require every exact table handle once under its declared identity. */ +function validateReturnedTables( + returned: unknown, + state: DeclarationState, +): void { + assertRecord(returned, "defineSchema() return value"); + const returnedHandles = new Set(); + for (const [key, value] of Object.entries(returned)) { + const name = + value !== null && typeof value === "object" + ? state.handleNames.get(value) + : undefined; + if (!name) { + throw new SchemaBuildError( + `defineSchema returned a value for "${key}" that was not produced by ctx.table()`, + ); } + if (key !== name) { + throw new SchemaBuildError( + `defineSchema returned table "${name}" under key "${key}"; aliases are not supported`, + ); + } + if (returnedHandles.has(value)) { + throw new SchemaBuildError(`Table "${name}" was returned more than once`); + } + returnedHandles.add(value); } - - // build engine tables - const built = buildEngineTables(raw.values(), schemaName); - const tables: Record = {}; - for (const { name, handle } of raw.values()) { - // Upgrade the handle in place so `defineSchema`'s return + column refs share it. - Object.assign(handle, built[name]); - tables[name] = handle; + if (returnedHandles.size !== state.raw.size) { + const omitted = [...state.raw.values()] + .filter((table) => !returnedHandles.has(table.handle)) + .map((table) => table.name); + throw new SchemaBuildError( + `defineSchema omitted declared table${omitted.length === 1 ? "" : "s"}: ${omitted.join(", ")}`, + ); } +} - buildRelations(tables); +/** Detect declaration-handle tampering before metadata publication. */ +function validateHandles(raw: ReadonlyMap): void { + for (const table of raw.values()) { + const ownKeys = Reflect.ownKeys(table.handle); + const hasOnlyDeclaredColumns = + ownKeys.length === Object.keys(table.metas).length && + ownKeys.every( + (key) => typeof key === "string" && Object.hasOwn(table.metas, key), + ); + const hasMetadataCollision = TABLE_METADATA_KEYS.some((key) => + Object.hasOwn(table.handle, key), + ); + if ( + Object.getPrototypeOf(table.handle) !== null || + !Object.isExtensible(table.handle) || + !hasOnlyDeclaredColumns || + hasMetadataCollision + ) { + throw new SchemaBuildError( + `Table handle "${table.name}" was modified during schema declaration`, + ); + } + } +} - // Map the returned keys back to the built handles (returned values ARE handles). - const byHandle = new Map(); - for (const [name, t] of Object.entries(tables)) { - byHandle.set(t, name); - t.$insertSchema = deriveInsertSchema(t); - t.$updateSchema = deriveUpdateSchema(t); +/** Reject composite primary keys, which keyed operations cannot represent. */ +function validatePrimaryKeys(raw: ReadonlyMap): void { + for (const table of raw.values()) { + const primaryKeys = Object.values(table.metas).filter( + (meta) => meta.primaryKey, + ); + if (primaryKeys.length > 1) { + throw new SchemaBuildError( + `Table "${table.name}" declares multiple primary-key columns; composite primary keys are not supported`, + ); + } } - const result: Record = {}; - for (const [key, value] of Object.entries(returned)) { - const name = byHandle.get(value); - if (!name) +} + +/** Reject table names that collide with generated Drizzle relation keys. */ +function validateRelationKeys( + raw: ReadonlyMap, + relations: ReadonlyMap, +): void { + for (const [name, tableRelations] of relations) { + if (tableRelations.length > 0 && raw.has(`${name}Relations`)) { throw new SchemaBuildError( - `defineSchema returned a value for "${key}" that was not produced by ctx.table()`, + `Table "${name}Relations" collides with generated relation metadata for "${name}"`, ); + } + } +} - result[key] = value; +/** Prepare every engine object and validator without mutating handles. */ +function prepareTables( + raw: ReadonlyMap, + schemaName: string, + relations: ReadonlyMap, +): Map { + const built = buildEngineTables(raw.values(), schemaName); + const candidates = new Map(); + for (const table of raw.values()) { + const builtTable = built.get(table.name); + if (!builtTable) { + throw new SchemaBuildError( + `Engine table "${table.name}" was not constructed`, + ); + } + const columns = nullRecord(); + for (const [key, meta] of Object.entries(builtTable.columns)) { + columns[key] = finalizeColumn(meta); + } + Object.freeze(columns); + const tableRelations = finalizeRelations(relations.get(table.name) ?? []); + const validatorTable = { $columns: columns } as AppKitTable; + candidates.set(table.name, { + columns, + engine: builtTable.engine, + relations: tableRelations, + insertSchema: deriveInsertSchema(validatorTable), + updateSchema: deriveUpdateSchema(validatorTable), + }); } + return candidates; +} - const engineMap: Schema["$engine"] = {}; - for (const [key, t] of Object.entries(result)) engineMap[key] = t.$engine; +/** Atomically publish prepared metadata as the final schema transition. */ +function publishSchema( + raw: ReadonlyMap, + candidates: ReadonlyMap, + schemaName: string, +): Schema { + const publications = [...raw.values()].map((table) => { + const candidate = candidates.get(table.name); + if (!candidate) { + throw new SchemaBuildError( + `Table "${table.name}" was not prepared for finalization`, + ); + } + return { table, candidate }; + }); - return { + const tables = nullRecord(); + const engine = nullRecord(); + for (const { table, candidate } of publications) { + Object.defineProperties(table.handle, { + $name: { value: table.name }, + $schemaName: { value: schemaName }, + $columns: { value: candidate.columns }, + $engine: { value: candidate.engine }, + $relations: { value: candidate.relations }, + $insertSchema: { value: candidate.insertSchema }, + $updateSchema: { value: candidate.updateSchema }, + }); + const finalized = Object.freeze(table.handle) as unknown as AppKitTable; + tables[table.name] = finalized; + engine[table.name] = candidate.engine; + } + + const schema = { $schemaName: schemaName, - $tables: result, - $engine: engineMap, + $tables: Object.freeze(tables), + $engine: Object.freeze(engine), }; + Object.defineProperty(schema, FINALIZED_SCHEMA, { value: true }); + return Object.freeze(schema); +} + +/** @internal Reject values that were not finalized by `defineSchema()`. */ +export function assertFinalizedSchema(value: unknown): asserts value is Schema { + if ( + value === null || + typeof value !== "object" || + (value as Record)[FINALIZED_SCHEMA] !== true + ) { + throw new TypeError("Expected a finalized AppKit database schema"); + } +} + +/** + * Compile one declared schema. The returned type keeps the table names the + * builder returned, so `crudRoutes` and `hooks` can name only real tables. + */ +export function defineSchema>( + builder: (context: SchemaBuilderContext) => TTables, + options?: DefineSchemaOptions, +): Schema> { + const schemaName = options?.schemaName ?? "public"; + if (!schemaName) throw new SchemaBuildError("Schema name cannot be empty"); + + const state: DeclarationState = { + raw: new Map(), + handleNames: new Map(), + }; + const returned = builder(createBuilderContext(state)); + + validateReturnedTables(returned, state); + validateHandles(state.raw); + validatePrimaryKeys(state.raw); + resolveForeignKeys(state.raw); + // FK literals are checked against the inherited target storage, not the placeholder. + validateLiteralDefaults(state.raw.values()); + + const relations = buildRelations(state.raw); + validateRelationKeys(state.raw, relations); + const candidates = prepareTables(state.raw, schemaName, relations); + return publishSchema(state.raw, candidates, schemaName); } diff --git a/packages/appkit/src/database/schema-builder/engine/relations.ts b/packages/appkit/src/database/schema-builder/engine/relations.ts index 7274b4950..725a708e7 100644 --- a/packages/appkit/src/database/schema-builder/engine/relations.ts +++ b/packages/appkit/src/database/schema-builder/engine/relations.ts @@ -1,37 +1,45 @@ import { type Relation, relations } from "drizzle-orm"; import type { AnyPgColumn, PgTable } from "drizzle-orm/pg-core"; -import type { AppKitTable } from "../types"; +import { type AppKitTable, SchemaBuildError } from "../types"; -function columnOf(table: PgTable, name: string): AnyPgColumn { - const col = (table as unknown as Record)[name]; - if (!col) - throw new Error(`engine relations: column "${name}" not found on table`); - return col as AnyPgColumn; +function columnOf(table: AppKitTable, name: string): AnyPgColumn { + const column = table.$columns[name]?.engineColumn; + if (!column) { + throw new SchemaBuildError( + `Engine relation column "${table.$name}.${name}" is not finalized`, + ); + } + return column as unknown as AnyPgColumn; } +/** Adapt finalized relation metadata to Drizzle's relation registration shape. */ export function buildEngineRelations( tables: Record, ): Record { const byName = new Map(); for (const table of Object.values(tables)) byName.set(table.$name, table); - const out: Record = {}; + const out: Record = Object.create(null); for (const table of Object.values(tables)) { if (table.$relations.length === 0) continue; const localEngine = table.$engine as unknown as PgTable; out[`${table.$name}Relations`] = relations(localEngine, ({ one, many }) => { - const config: Record = {}; + const config: Record = Object.create(null); for (const relation of table.$relations) { const target = byName.get(relation.targetTable); - if (!target) continue; + if (!target) { + throw new SchemaBuildError( + `Engine relation target "${relation.targetTable}" is not finalized`, + ); + } const targetEngine = target.$engine as unknown as PgTable; config[relation.name] = relation.cardinality === "toOne" ? one(targetEngine, { - fields: [columnOf(localEngine, relation.localColumn)], - references: [columnOf(targetEngine, relation.targetColumn)], + fields: [columnOf(table, relation.localColumn)], + references: [columnOf(target, relation.targetColumn)], }) : many(targetEngine); } diff --git a/packages/appkit/src/database/schema-builder/engine/tables.ts b/packages/appkit/src/database/schema-builder/engine/tables.ts index 7a500be8d..83fdf0733 100644 --- a/packages/appkit/src/database/schema-builder/engine/tables.ts +++ b/packages/appkit/src/database/schema-builder/engine/tables.ts @@ -1,9 +1,7 @@ import { type AnyPgColumn, - bigserial, type PgColumnBuilderBase, type PgEnum, - type PgTable, bigint as pgBigint, boolean as pgBoolean, pgEnum, @@ -15,19 +13,14 @@ import { timestamp as pgTimestamp, uuid as pgUuid, varchar as pgVarchar, - serial, } from "drizzle-orm/pg-core"; -import type { ReferentialAction } from "../../contract"; -import { APPKIT_TABLE } from "../private"; import { - type AppKitTable, - type ColumnMeta, - type EngineColumn, + type EngineTable, type MutableColumnMeta, + type ReferentialAction, SchemaBuildError, } from "../types"; -/** Loosely-typed engine column builder seam */ type AnyColumnBuilder = PgColumnBuilderBase & { primaryKey(): AnyColumnBuilder; notNull(): AnyColumnBuilder; @@ -35,6 +28,7 @@ type AnyColumnBuilder = PgColumnBuilderBase & { default(value: unknown): AnyColumnBuilder; defaultNow(): AnyColumnBuilder; defaultRandom(): AnyColumnBuilder; + generatedByDefaultAsIdentity(): AnyColumnBuilder; references( ref: () => AnyPgColumn, actions?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction }, @@ -42,167 +36,219 @@ type AnyColumnBuilder = PgColumnBuilderBase & { }; type PgEnumValues = PgEnum<[string, ...string[]]>; +/** Reuse one Drizzle enum object for each enum name in this schema. */ type EnumRegistry = Map; +interface BuiltEngineTable { + readonly engine: EngineTable; + readonly columns: Record; +} + +function sameValues( + left: readonly string[], + right: readonly string[], +): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + function getEnum( registry: EnumRegistry, + schemaName: string, name: string, values: readonly string[], ): PgEnumValues { const existing = registry.get(name); - if (existing) return existing; + if (existing) { + if (!sameValues(existing.enumValues, values)) { + throw new SchemaBuildError( + `Enum "${name}" is declared with conflicting values`, + ); + } + return existing; + } - const created = pgEnum(name, values as [string, ...string[]]); - registry.set(name, created); - return created; + const tuple = values as [string, ...string[]]; + const created = + schemaName === "public" + ? pgEnum(name, tuple) + : pgSchema(schemaName).enum(name, tuple); + registry.set(name, created as PgEnumValues); + return created as PgEnumValues; } +/** Map resolved storage metadata to its Drizzle column builder. */ function baseColumn( meta: MutableColumnMeta, + schemaName: string, enums: EnumRegistry, ): AnyColumnBuilder { - const col = meta.columnName; + const name = meta.columnName; let builder: PgColumnBuilderBase; switch (meta.storageKind) { case "id": - builder = serial(col); + case "integer": + builder = pgInteger(name); break; case "bigid": - builder = bigserial(col, { mode: "bigint" }); + case "bigint": + builder = pgBigint(name, { mode: "bigint" }); break; case "text": - builder = pgText(col); + builder = pgText(name); break; case "varchar": - builder = pgVarchar(col, { length: meta.varcharLength ?? 255 }); - break; - case "integer": - builder = pgInteger(col); - break; - case "bigint": - builder = pgBigint(col, { mode: "bigint" }); + builder = pgVarchar(name, { length: meta.varcharLength ?? 255 }); break; case "boolean": - builder = pgBoolean(col); + builder = pgBoolean(name); break; case "uuid": - builder = pgUuid(col); + builder = pgUuid(name); break; case "timestamp": - builder = pgTimestamp(col, { withTimezone: meta.withTimezone ?? false }); + builder = pgTimestamp(name, { + mode: "string", + withTimezone: meta.withTimezone ?? false, + }); break; case "jsonb": - builder = pgJsonb(col); + builder = pgJsonb(name); break; - case "enum": - // biome-ignore lint/style/noNonNullAssertion: enum metas always carry an enumName. - builder = getEnum(enums, meta.enumName!, meta.enumValues ?? [])(col); + case "enum": { + if (!meta.enumName || !meta.enumValues?.length) { + throw new SchemaBuildError( + `Enum column "${meta.columnName}" has no enum definition`, + ); + } + builder = getEnum( + enums, + schemaName, + meta.enumName, + meta.enumValues, + )(name); break; + } } return builder as AnyColumnBuilder; } function buildColumn( meta: MutableColumnMeta, + schemaName: string, enums: EnumRegistry, resolveTarget: (table: string, column: string) => AnyPgColumn, ): PgColumnBuilderBase { - let c = baseColumn(meta, enums); - - if (meta.primaryKey && !meta.serverGenerated) c = c.primaryKey(); - if (meta.notNull && !meta.serverGenerated) c = c.notNull(); - if (meta.unique) c = c.unique(); + let column = baseColumn(meta, schemaName, enums); + if (meta.serverGenerated) column = column.generatedByDefaultAsIdentity(); + if (meta.primaryKey) column = column.primaryKey(); + else if (meta.notNull) column = column.notNull(); + if (meta.unique) column = column.unique(); if (!meta.serverGenerated) { - if (meta.defaultNow) c = c.defaultNow(); - else if (meta.defaultRandom) c = c.defaultRandom(); - else if (meta.defaultValue !== undefined) c = c.default(meta.defaultValue); + if (meta.defaultNow) column = column.defaultNow(); + else if (meta.defaultRandom) column = column.defaultRandom(); + else if (Object.hasOwn(meta, "defaultValue")) { + column = column.default(meta.defaultValue); + } } if (meta.fk) { const target = meta.fk; - c = c.references( + // Drizzle resolves this thunk after all referenced tables are registered. + column = column.references( () => resolveTarget(target.targetTable, target.targetColumn), { onDelete: target.onDelete, onUpdate: target.onUpdate }, ); } + return column; +} - return c; +// PostgreSQL renders a timestamp as `2026-06-29 19:05:19.051709+00` over the +// text protocol but as ISO-8601 inside the JSON aggregates Drizzle builds for +// relations, so one column reaches callers in two shapes depending on whether +// it was included. Both shapes are declared `string`, and only ISO-8601 parses +// per the ECMAScript grammar, so normalize to it. +const PG_TIMESTAMP = + /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2}(?:\.\d+)?)(?:([+-]\d{2})(?::?(\d{2}))?)?$/; + +function isoTimestamp(value: unknown): unknown { + if (typeof value !== "string") return value; + const parts = PG_TIMESTAMP.exec(value); + // Leave `infinity`, `-infinity`, and anything unrecognized untouched. + if (!parts) return value; + const [, date, time, offsetHours, offsetMinutes] = parts; + const offset = + offsetHours === undefined ? "" : `${offsetHours}:${offsetMinutes ?? "00"}`; + return `${date}T${time}${offset}`; } -/** - * Build one engine table from finalized column metas. `resolveTarget` reads the - * shared registry so FK `.references()` thunks resolve forward/self refs. - */ function buildTable( name: string, schemaName: string, metas: Record, enums: EnumRegistry, resolveTarget: (table: string, column: string) => AnyPgColumn, -): { engine: PgTable; columns: Record } { - const columnBuilders: Record = {}; +): BuiltEngineTable { + const columnBuilders: Record = + Object.create(null); for (const [key, meta] of Object.entries(metas)) { - columnBuilders[key] = buildColumn(meta, enums, resolveTarget); + columnBuilders[key] = buildColumn(meta, schemaName, enums, resolveTarget); } const engine = schemaName === "public" ? pgTable(name, columnBuilders) : pgSchema(schemaName).table(name, columnBuilders); - - const columns: Record = {}; for (const [key, meta] of Object.entries(metas)) { - // store the real engine column behind the opaque handle (quarantine file). - meta.engineColumn = (engine as unknown as Record)[ + const engineColumn = (engine as unknown as Record)[ key - ] as unknown as EngineColumn; - columns[key] = meta as ColumnMeta; + ]; + if (!engineColumn) { + throw new SchemaBuildError( + `Engine column "${name}.${key}" was not constructed`, + ); + } + if (meta.storageKind === "timestamp") { + // Drizzle routes every read path through this decoder, so overriding it + // covers direct selects, relation includes, and mutation RETURNING alike. + (engineColumn as unknown as Record).mapFromDriverValue = + isoTimestamp; + } + meta.engineColumn = + engineColumn as unknown as MutableColumnMeta["engineColumn"]; } - - return { engine, columns }; + return { engine: engine as unknown as EngineTable, columns: metas }; } -function makeAppKitTable( - name: string, - schemaName: string, - built: { engine: PgTable; columns: Record }, -): AppKitTable { - return { - $name: name, - $schemaName: schemaName, - $columns: built.columns, - $engine: built.engine, - $relations: [], - [APPKIT_TABLE]: true, - } as unknown as AppKitTable; -} - -/** - * Build every engine table from finalized metas, resolving FK targets across the - * whole set via a shared registry so forward/self references wire correctly. - */ +/** Build all Drizzle tables only after declaration validation has succeeded. */ export function buildEngineTables( raw: Iterable<{ name: string; metas: Record }>, schemaName: string, -): Record { - const builtEngine: Record> = {}; +): Map { + const entries = [...raw]; + const builtEngine = new Map>(); const resolveTarget = (table: string, column: string): AnyPgColumn => { - const t = builtEngine[table]; - if (!t || !t[column]) + const target = builtEngine.get(table)?.[column]; + if (!target) { throw new SchemaBuildError( `Cannot resolve FK target "${table}.${column}"`, ); - - return t[column]; + } + return target; }; const enums: EnumRegistry = new Map(); - const tables: Record = {}; - for (const { name, metas } of raw) { + const tables = new Map(); + for (const { name, metas } of entries) { const built = buildTable(name, schemaName, metas, enums, resolveTarget); - builtEngine[name] = built.engine as unknown as Record; - tables[name] = makeAppKitTable(name, schemaName, built); + builtEngine.set( + name, + built.engine as unknown as Record, + ); + tables.set(name, built); } return tables; } diff --git a/packages/appkit/src/database/schema-builder/fk.ts b/packages/appkit/src/database/schema-builder/fk.ts index 60ce4b74d..a12d92577 100644 --- a/packages/appkit/src/database/schema-builder/fk.ts +++ b/packages/appkit/src/database/schema-builder/fk.ts @@ -1,15 +1,31 @@ import { ColumnBuilder } from "./columns"; -import type { ColumnRef, FkRef, StorageKind } from "./types"; +import { + type ColumnRef, + type FkRef, + type MutableColumnMeta, + SchemaBuildError, + type StorageKind, +} from "./types"; /** Declare foreign-key to another column. */ export function fk(ref: FkRef): ColumnBuilder { - const builder = new ColumnBuilder({ kind: "fk" }, "int4", "number"); + const builder = new ColumnBuilder({ kind: "fk" }, "number"); builder._meta.fkRef = ref; return builder; } export function resolveFkRef(ref: FkRef): ColumnRef { - return typeof ref === "function" ? ref() : ref; + const resolved = typeof ref === "function" ? ref() : ref; + if ( + !resolved || + typeof resolved !== "object" || + resolved.__isColumnRef !== true + ) { + throw new SchemaBuildError( + "fk() must reference a column created by table()", + ); + } + return resolved; } /** A serial PK target stores as its plain integer type on the FK side. */ @@ -18,3 +34,100 @@ export function mirrorStorageKind(targetStorage: StorageKind): StorageKind { if (targetStorage === "bigid") return "bigint"; return targetStorage; } + +interface ForeignKeyTable { + readonly name: string; + readonly metas: Readonly>; + readonly handle: Readonly>; +} + +/** Resolve FK identity, inherited storage, and action invariants in one pass. */ +export function resolveForeignKeys( + tables: ReadonlyMap, +): void { + const references = new Map< + ColumnRef, + { readonly table: ForeignKeyTable; readonly meta: MutableColumnMeta } + >(); + for (const table of tables.values()) { + for (const [columnName, reference] of Object.entries(table.handle)) { + const meta = table.metas[columnName]; + if (!meta) { + throw new SchemaBuildError( + `Column reference "${table.name}.${columnName}" has no metadata`, + ); + } + references.set(reference, { table, meta }); + } + } + + const resolving = new Set(); + const resolved = new Set(); + + const resolveForeignKey = ( + table: ForeignKeyTable, + meta: MutableColumnMeta, + ): void => { + if (!meta.fkRef || resolved.has(meta)) return; + if (resolving.has(meta)) { + throw new SchemaBuildError( + `Foreign key "${table.name}.${meta.columnName}" has a cyclic storage dependency`, + ); + } + + resolving.add(meta); + try { + const reference = resolveFkRef(meta.fkRef); + const targetIdentity = references.get(reference); + if (!targetIdentity) { + throw new SchemaBuildError( + `fk() on "${table.name}.${meta.columnName}" targets a column outside the returned schema`, + ); + } + + const { table: targetTable, meta: target } = targetIdentity; + resolveForeignKey(targetTable, target); + if (!target.primaryKey && !target.unique) { + throw new SchemaBuildError( + `fk() on "${table.name}.${meta.columnName}" must target a primary-key or unique column`, + ); + } + + meta.storageKind = mirrorStorageKind(target.storageKind); + meta.kind = target.kind; + meta.withTimezone = target.withTimezone; + meta.varcharLength = target.varcharLength; + meta.enumName = target.enumName; + meta.enumValues = target.enumValues + ? Object.freeze([...target.enumValues]) + : undefined; + meta.fk = { + targetTable: targetTable.name, + targetColumn: target.columnName, + onDelete: meta.onDelete, + onUpdate: meta.onUpdate, + }; + + const actions = [meta.onDelete, meta.onUpdate]; + if (actions.includes("set null") && meta.notNull) { + throw new SchemaBuildError( + `Foreign key "${table.name}.${meta.columnName}" uses SET NULL but is not-null`, + ); + } + if (actions.includes("set default") && !meta.hasDefault) { + throw new SchemaBuildError( + `Foreign key "${table.name}.${meta.columnName}" uses SET DEFAULT without a local default`, + ); + } + resolved.add(meta); + } finally { + resolving.delete(meta); + } + }; + + for (const table of tables.values()) { + for (const meta of Object.values(table.metas)) { + resolveForeignKey(table, meta); + } + } +} diff --git a/packages/appkit/src/database/schema-builder/index.ts b/packages/appkit/src/database/schema-builder/index.ts index 6dec6967f..bb68652e7 100644 --- a/packages/appkit/src/database/schema-builder/index.ts +++ b/packages/appkit/src/database/schema-builder/index.ts @@ -13,16 +13,7 @@ export { varchar, } from "./columns"; export { defineSchema, type SchemaBuilderContext } from "./define-schema"; -export { buildEngineRelations } from "./engine/relations"; export { fk } from "./fk"; -export { - APPKIT_TABLE, - isPrivateColumn, - nonPrivateColumnNames, - ownerColumnName, - privateColumnNames, -} from "./private"; -export { buildRelations } from "./relations"; export type { AppKitTable, ColumnMeta, @@ -34,4 +25,3 @@ export type { TableHandle, } from "./types"; export { SchemaBuildError } from "./types"; -export { deriveInsertSchema, deriveUpdateSchema } from "./validators"; diff --git a/packages/appkit/src/database/schema-builder/private.ts b/packages/appkit/src/database/schema-builder/private.ts deleted file mode 100644 index c06694eeb..000000000 --- a/packages/appkit/src/database/schema-builder/private.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { AppKitTable, ColumnMeta } from "./types"; - -/** Marker proving an object is an Appkit-built table */ -export const APPKIT_TABLE = Symbol.for("appkit.database.table"); - -export function isPrivateColumn(meta: ColumnMeta): boolean { - return meta.isPrivate; -} - -export function privateColumnNames(table: AppKitTable): string[] { - return Object.values(table.$columns) - .filter(isPrivateColumn) - .map((c) => c.columnName); -} - -export function nonPrivateColumnNames(table: AppKitTable): string[] { - return Object.values(table.$columns) - .filter((c) => !isPrivateColumn(c)) - .map((c) => c.columnName); -} - -/** - * The RLS owner column name (`.owner()`), if one is declared. - */ -export function ownerColumnName(table: AppKitTable): string | undefined { - return Object.values(table.$columns).find((c) => c.isOwner)?.columnName; -} diff --git a/packages/appkit/src/database/schema-builder/relations.ts b/packages/appkit/src/database/schema-builder/relations.ts index 814c897cc..87b6ddd71 100644 --- a/packages/appkit/src/database/schema-builder/relations.ts +++ b/packages/appkit/src/database/schema-builder/relations.ts @@ -1,63 +1,85 @@ -import { type AppKitTable, SchemaBuildError } from "./types"; +import type { MutableColumnMeta, ResolvedRelation } from "./types"; +import { SchemaBuildError } from "./types"; -export function buildRelations(tables: Record) { - for (const table of Object.values(tables)) { - const columnNames = new Set(Object.keys(table.$columns)); - const seenForward = new Set(); +interface RelationSourceTable { + readonly name: string; + readonly metas: Readonly>; +} - for (const meta of Object.values(table.$columns)) { - if (!meta.fk) continue; +/** @internal Build the canonical relation metadata before tables are published. */ +export function buildRelations( + tables: ReadonlyMap, +): Map { + const result = new Map(); + for (const name of tables.keys()) result.set(name, []); + + // Forward pass: each FK adds a to-one edge to its source table. + for (const table of tables.values()) { + const relations = result.get(table.name); + if (!relations) + throw new SchemaBuildError("Relation table is not registered"); + const columnNames = new Set(Object.keys(table.metas)); + const seenTargets = new Set(); - const name = meta.fk.targetTable; - if (columnNames.has(name)) + for (const meta of Object.values(table.metas)) { + if (!meta.fk) continue; + const relationName = meta.fk.targetTable; + if (columnNames.has(relationName)) { throw new SchemaBuildError( - `Forward relation "${table.$name}.${name}" collides with a column of the same name`, + `Forward relation "${table.name}.${relationName}" collides with a column of the same name`, ); - - if (seenForward.has(name)) + } + if (seenTargets.has(relationName)) { throw new SchemaBuildError( - `Ambiguous forward relation "${table.$name}.${name}": multiple foreign keys target "${name}". Rename one target or model the relation explicitly.`, + `Ambiguous forward relation "${table.name}.${relationName}": multiple foreign keys target "${relationName}"`, ); - seenForward.add(name); - table.$relations.push({ - name, + } + seenTargets.add(relationName); + relations.push({ + name: relationName, cardinality: "toOne", localColumn: meta.columnName, - targetTable: name, + targetTable: relationName, targetColumn: meta.fk.targetColumn, inferred: false, }); } } - for (const table of Object.values(tables)) { - for (const meta of Object.values(table.$columns)) { - if (!meta.fk) continue; - const targetTable = tables[meta.fk.targetTable]; - if (!targetTable) continue; - - // Skip self-referential relations. - if (targetTable === table) continue; - - const name = table.$name; - if (Object.keys(targetTable.$columns).includes(name)) + // Reverse pass: each non-self FK adds a to-many edge to its target table. + for (const table of tables.values()) { + for (const meta of Object.values(table.metas)) { + if (!meta.fk || meta.fk.targetTable === table.name) continue; + const target = tables.get(meta.fk.targetTable); + const targetRelations = result.get(meta.fk.targetTable); + if (!target || !targetRelations) { throw new SchemaBuildError( - `Reverse relation "${targetTable.$name}.${name}" collides with a column of the same name`, + `Relation target "${meta.fk.targetTable}" is not part of the schema`, ); + } - if (targetTable.$relations.some((r) => r.name === name)) + const relationName = table.name; + if (Object.hasOwn(target.metas, relationName)) { throw new SchemaBuildError( - `Reverse relation "${targetTable.$name}.${name}" is ambiguous (multiple foreign keys from "${table.$name}"). Disambiguate by renaming the source table.`, + `Reverse relation "${target.name}.${relationName}" collides with a column of the same name`, ); - - targetTable.$relations.push({ - name, + } + if (targetRelations.some((relation) => relation.name === relationName)) { + throw new SchemaBuildError( + `Reverse relation "${target.name}.${relationName}" is ambiguous`, + ); + } + // Reverse edges deliberately retain their stable to-many result shape. + targetRelations.push({ + name: relationName, cardinality: "toMany", localColumn: meta.fk.targetColumn, - targetTable: table.$name, + targetTable: table.name, targetColumn: meta.columnName, inferred: true, }); } } + + return result; } diff --git a/packages/appkit/src/database/schema-builder/tests/columns.test.ts b/packages/appkit/src/database/schema-builder/tests/columns.test.ts index 8e2e52c62..d5ce86132 100644 --- a/packages/appkit/src/database/schema-builder/tests/columns.test.ts +++ b/packages/appkit/src/database/schema-builder/tests/columns.test.ts @@ -1,10 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { bigid, bigint, boolean, ColumnBuilder, - type ColumnMeta, enumColumn, fk, id, @@ -16,154 +15,140 @@ import { uuid, varchar, } from "../index"; +import type { ColumnValueKind } from "../types"; + +describe("column constructors", () => { + it("uses value kinds independently of storage declarations", () => { + expectTypeOf().toEqualTypeOf< + | "string" + | "number" + | "bigint" + | "boolean" + | "date" + | "json" + | "uuid" + | "enum" + | "unknown" + >(); + }); -describe("column constructors — storage metadata", () => { it.each([ - ["id", id(), { storageKind: "id", pgType: "int4", kind: "number" }], - [ - "bigid", - bigid(), - { storageKind: "bigid", pgType: "int8", kind: "bigint" }, - ], - ["text", text(), { storageKind: "text", pgType: "text", kind: "string" }], - [ - "integer", - integer(), - { storageKind: "integer", pgType: "int4", kind: "number" }, - ], - [ - "bigint", - bigint(), - { storageKind: "bigint", pgType: "int8", kind: "bigint" }, - ], - [ - "boolean", - boolean(), - { storageKind: "boolean", pgType: "bool", kind: "boolean" }, - ], - ["uuid", uuid(), { storageKind: "uuid", pgType: "uuid", kind: "uuid" }], - ["jsonb", jsonb(), { storageKind: "jsonb", pgType: "jsonb", kind: "json" }], - ])("%s carries the expected meta", (_label, builder, expected) => { + ["id", id(), { storageKind: "id", kind: "number" }], + ["bigid", bigid(), { storageKind: "bigid", kind: "bigint" }], + ["text", text(), { storageKind: "text", kind: "string" }], + ["integer", integer(), { storageKind: "integer", kind: "number" }], + ["bigint", bigint(), { storageKind: "bigint", kind: "bigint" }], + ["boolean", boolean(), { storageKind: "boolean", kind: "boolean" }], + ["uuid", uuid(), { storageKind: "uuid", kind: "uuid" }], + ["jsonb", jsonb(), { storageKind: "jsonb", kind: "json" }], + ])("builds %s metadata", (_label, builder, expected) => { expect(builder).toBeInstanceOf(ColumnBuilder); expect(builder._meta).toMatchObject(expected); }); - it("varchar defaults to length 255 and a clean pgType", () => { - expect(varchar()._meta).toMatchObject({ - storageKind: "varchar", - pgType: "varchar", - varcharLength: 255, - }); + it("validates varchar lengths", () => { + expect(varchar()._meta.varcharLength).toBe(255); expect(varchar(64)._meta.varcharLength).toBe(64); + expect(() => varchar(0)).toThrow(SchemaBuildError); + expect(() => varchar(1.5)).toThrow(/length must be an integer/); }); - it("timestamp toggles withTimezone and pgType", () => { - expect(timestamp()._meta).toMatchObject({ - pgType: "timestamp", - withTimezone: false, - }); - expect(timestamp({ withTimezone: true })._meta).toMatchObject({ - pgType: "timestamptz", - withTimezone: true, - }); + it("records timestamp options", () => { + expect(timestamp()._meta.withTimezone).toBe(false); + expect(timestamp({ withTimezone: true })._meta.withTimezone).toBe(true); }); }); -describe("server-generated identity columns", () => { +describe("identity and modifiers", () => { it.each([id(), bigid()])( - "flags serial PKs as serverGenerated + primaryKey + hasDefault", + "makes generated identities real not-null PK metadata", (builder) => { - expect(builder._meta.serverGenerated).toBe(true); - expect(builder._meta.primaryKey).toBe(true); - expect(builder._meta.hasDefault).toBe(true); + expect(builder._meta).toMatchObject({ + serverGenerated: true, + primaryKey: true, + notNull: true, + hasDefault: true, + }); }, ); - it("non-identity columns are not server generated by default", () => { - expect(text()._meta.serverGenerated).toBe(false); - expect(text()._meta.primaryKey).toBe(false); - expect(text()._meta.hasDefault).toBe(false); - }); -}); - -describe("modifier chain", () => { - it("sets boolean flags and is chainable", () => { - const col = text().notNull().unique().primaryKey().private().owner(); - const meta: ColumnMeta = col._meta; - expect(meta).toMatchObject({ + it("keeps the supported modifier chain and removes owner", () => { + const builder = text().notNull().unique().primaryKey().private(); + expect(builder._meta).toMatchObject({ notNull: true, unique: true, primaryKey: true, isPrivate: true, - isOwner: true, }); + expect("owner" in builder).toBe(false); + // @ts-expect-error DatabasePlugin owner/RLS metadata is not supported. + expectTypeOf["owner"]>().toBeFunction(); }); }); -describe("default-expression stamping", () => { - it("quotes and escapes string literals", () => { - expect(text().default("active")._meta.defaultExpr).toBe("'active'"); - expect(text().default("O'Brien")._meta.defaultExpr).toBe("'O''Brien'"); - }); - - it("stamps numeric and boolean literals verbatim", () => { - expect(integer().default(0)._meta.defaultExpr).toBe("0"); - expect(boolean().default(true)._meta.defaultExpr).toBe("true"); - expect(boolean().default(false)._meta.defaultExpr).toBe("false"); +describe("default helpers", () => { + it("records literal defaults without synthesizing them", () => { + expect(text().default("O'Brien")._meta).toMatchObject({ + hasDefault: true, + defaultValue: "O'Brien", + }); + expect(integer().default(42)._meta.defaultValue).toBe(42); + expect(boolean().default(false)._meta.defaultValue).toBe(false); }); - it("stamps canonical now() / gen_random_uuid() expressions", () => { - const ts = timestamp().defaultNow()._meta; - expect(ts.defaultExpr).toBe("now()"); - expect(ts.defaultNow).toBe(true); - - const rand = uuid().defaultRandom()._meta; - expect(rand.defaultExpr).toBe("gen_random_uuid()"); - expect(rand.defaultRandom).toBe(true); + it("restricts helpers to timestamp and UUID columns", () => { + expect(timestamp().defaultNow()._meta.defaultNow).toBe(true); + expect(uuid().defaultRandom()._meta.defaultRandom).toBe(true); + expect(() => text().defaultNow()).toThrow(/timestamp/); + expect(() => text().defaultRandom()).toThrow(/uuid/); }); - it("records hasDefault and defaultValue for literals", () => { - const col = integer().default(42)._meta; - expect(col.hasDefault).toBe(true); - expect(col.defaultValue).toBe(42); + it("allows only one explicit default mode", () => { + expect(() => text().default("x").default("y")).toThrow(/only one default/); + expect(() => + timestamp().defaultNow().default("2020-01-01T00:00:00Z"), + ).toThrow(/only one default/); + expect(() => id().default(1)).toThrow(/only one default/); }); }); -describe("referential-action modifiers", () => { - it("are allowed on fk() columns", () => { - const col = fk(() => ({ - __isColumnRef: true, - tableName: "users", - columnName: "id", - })) - .onDelete("cascade") - .onUpdate("set null"); - expect(col._meta.onDelete).toBe("cascade"); - expect(col._meta.onUpdate).toBe("set null"); - }); +describe("foreign-key modifiers", () => { + const ref = { + __isColumnRef: true as const, + tableName: "users", + columnName: "id", + }; - it("throw on non-fk columns", () => { - expect(() => integer().onDelete("cascade")).toThrow(SchemaBuildError); - expect(() => text().onUpdate("cascade")).toThrow( - /only valid on fk\(\) columns/, + it("accepts referential actions only on fk columns", () => { + const builder = fk(ref).onDelete("cascade").onUpdate("set null"); + expect(builder._meta.onDelete).toBe("cascade"); + expect(builder._meta.onUpdate).toBe("set null"); + expect(() => integer().onDelete("cascade")).toThrow(/only valid on fk/); + expect(() => fk(ref).onDelete("truncate" as never)).toThrow( + /Unsupported referential action/, ); }); + + it("pins the supported referential-action type", () => { + expectTypeOf[0]>().toEqualTypeOf< + "cascade" | "set null" | "set default" | "restrict" | "no action" + >(); + }); }); describe("enumColumn", () => { - it("carries the enum name and values", () => { - const col = enumColumn("status", ["active", "archived"]); - expect(col._meta).toMatchObject({ - storageKind: "enum", - enumName: "status", - enumValues: ["active", "archived"], - kind: "enum", - }); + it("clones and validates enum declarations", () => { + const values = ["active", "archived"]; + const builder = enumColumn("status", values); + values.push("mutated"); + expect(builder._meta.enumValues).toEqual(["active", "archived"]); + expect(Object.isFrozen(builder._meta.enumValues)).toBe(true); }); - it("throws when no values are provided", () => { - expect(() => enumColumn("status", [])).toThrow( - /requires at least one value/, + it("rejects empty and duplicate declarations", () => { + expect(() => enumColumn("status", [])).toThrow(/at least one value/); + expect(() => enumColumn("status", ["active", "active"])).toThrow( + /duplicate values/, ); }); }); diff --git a/packages/appkit/src/database/schema-builder/tests/define-schema.test.ts b/packages/appkit/src/database/schema-builder/tests/define-schema.test.ts index c03b6903c..08fa84d18 100644 --- a/packages/appkit/src/database/schema-builder/tests/define-schema.test.ts +++ b/packages/appkit/src/database/schema-builder/tests/define-schema.test.ts @@ -1,326 +1,432 @@ import { getTableConfig, type PgTable } from "drizzle-orm/pg-core"; -import { describe, expect, it } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { - APPKIT_TABLE, - type AppKitTable, bigid, + bigint, boolean, type ColumnBuilder, type DefineSchemaOptions, defineSchema, + enumColumn, fk, id, integer, - type ResolvedRelation, + jsonb, type Schema, type SchemaBuilderContext, type TableHandle, text, timestamp, + uuid, + varchar, } from "../index"; import type { EngineTable } from "../types"; -/** Cast the opaque engine handle back to a real PgTable (test-only). */ -const pgOf = (t: EngineTable): PgTable => t as unknown as PgTable; +const pgOf = (table: EngineTable): PgTable => table as unknown as PgTable; -describe("defineSchema — basic build", () => { - const schema: Schema = defineSchema((t) => ({ - users: t.table("users", { +describe("defineSchema finalization", () => { + it.each(["sql", "transaction"])( + "rejects DatabaseExports root key %s", + (name) => { + expect(() => + defineSchema(({ table }) => { + const reserved = table(name, { value: text() }); + return { [name]: reserved }; + }), + ).toThrow(/reserved/); + }, + ); + + const schema = defineSchema((builder) => ({ + users: builder.table("users", { id: id(), email: text().notNull().unique(), name: text(), }), })); - it("returns the table keyed by its return key", () => { - expect(Object.keys(schema.$tables)).toEqual(["users"]); + it("preserves literal table keys and canonical identity", () => { + expectTypeOf(schema.$tables).toHaveProperty("users"); expect(schema.$tables.users.$name).toBe("users"); + expect(Object.keys(schema.$tables)).toEqual(["users"]); + expect(Object.keys(schema.$tables.users)).toEqual(["id", "email", "name"]); + }); + + it("publishes complete immutable metadata", () => { + const users = schema.$tables.users; + expect(Object.isFrozen(schema)).toBe(true); + expect(Object.isFrozen(schema.$tables)).toBe(true); + expect(Object.isFrozen(schema.$engine)).toBe(true); + expect(Object.isFrozen(users)).toBe(true); + expect(Object.isFrozen(users.$columns)).toBe(true); + expect(Object.isFrozen(users.$columns.id)).toBe(true); + expect(Object.isFrozen(users.$relations)).toBe(true); + expect(users.$insertSchema).toBeDefined(); + expect(users.$updateSchema).toBeDefined(); + + expect(() => { + (users.$columns as Record).rogue = {}; + }).toThrow(TypeError); + expect(() => { + (schema.$tables as Record).rogue = users; + }).toThrow(TypeError); }); - it("defaults schemaName to 'public'", () => { + it("uses collision-safe registries", () => { + expect(Object.getPrototypeOf(schema.$tables)).toBeNull(); + expect(Object.getPrototypeOf(schema.$engine)).toBeNull(); + expect(Object.getPrototypeOf(schema.$tables.users.$columns)).toBeNull(); + }); + + it("defaults to public and supports a safe custom schema", () => { expect(schema.$schemaName).toBe("public"); - expect(schema.$tables.users.$schemaName).toBe("public"); + const options: DefineSchemaOptions = { schemaName: "application" }; + const custom = defineSchema( + (builder) => ({ widgets: builder.table("widgets", { id: id() }) }), + options, + ); + expect(custom.$schemaName).toBe("application"); + expect(custom.$tables.widgets.$schemaName).toBe("application"); }); - it("marks built tables with the APPKIT_TABLE symbol", () => { - expect( - (schema.$tables.users as unknown as Record)[ - APPKIT_TABLE - ], - ).toBe(true); + it("accepts the explicit builder context type", () => { + const build = (builder: SchemaBuilderContext) => { + const users: TableHandle<{ id: ColumnBuilder; email: ColumnBuilder }> = + builder.table("users", { id: id(), email: text() }); + return { users }; + }; + const typed: Schema = defineSchema(build); + expect(typed.$tables.users.$name).toBe("users"); }); - it("stamps column metadata", () => { - const cols = schema.$tables.users.$columns; - expect(cols.id.serverGenerated).toBe(true); - expect(cols.id.primaryKey).toBe(true); - expect(cols.id.hasDefault).toBe(true); - expect(cols.email.notNull).toBe(true); - expect(cols.email.unique).toBe(true); - expect(cols.name.notNull).toBe(false); + it("allows an explicitly empty schema", () => { + const empty = defineSchema(() => ({})); + expect(empty.$tables).toEqual({}); + expect(empty.$engine).toEqual({}); + expect(Object.isFrozen(empty)).toBe(true); }); - it("populates an engine table handle per column", () => { - expect(schema.$tables.users.$columns.id.engineColumn).toBeDefined(); + it("does not partially finalize handles when declaration validation fails", () => { + let firstHandle: TableHandle<{ id: ColumnBuilder }> | undefined; + expect(() => + defineSchema((builder) => { + const first = builder.table("first", { id: id() }); + const second = builder.table("second", { id: id() }); + firstHandle = first; + Object.preventExtensions(second); + return { first, second }; + }), + ).toThrow(/modified during schema declaration/); + if (!firstHandle) + throw new Error("fixture did not retain the first handle"); + expect("$name" in firstHandle).toBe(false); }); }); -describe("defineSchema — engine maps (no relations)", () => { - const schema = defineSchema((t) => ({ - users: t.table("users", { id: id() }), - tags: t.table("tags", { id: id(), label: text() }), - })); +describe("canonical table identity", () => { + it("requires every declared table exactly once", () => { + expect(() => + defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + builder.table("omitted", { id: id() }); + return { users }; + }), + ).toThrow(/omitted declared table: omitted/); + }); + + it("rejects aliases and duplicate handles", () => { + expect(() => + defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + return { people: users }; + }), + ).toThrow(/aliases are not supported/); - it("$engine carries a handle per table", () => { - expect(Object.keys(schema.$engine).sort()).toEqual(["tags", "users"]); + expect(() => + defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + return { users, duplicate: users }; + }), + ).toThrow(/aliases are not supported|returned more than once/); }); - it("leaves $relations empty on every table", () => { - for (const tbl of Object.values(schema.$tables)) { - const relations: ResolvedRelation[] = tbl.$relations; - expect(relations).toEqual([]); - } + it("rejects foreign values and duplicate table declarations", () => { + expect(() => + defineSchema((builder) => { + builder.table("users", { id: id() }); + return { rogue: {} as never }; + }), + ).toThrow(/was not produced by ctx\.table/); + expect(() => + defineSchema((builder) => { + const first = builder.table("users", { id: id() }); + builder.table("users", { id: id() }); + return { users: first }; + }), + ).toThrow(/Duplicate table/); }); -}); -describe("defineSchema — engine maps (with relations)", () => { - const schema = defineSchema((t) => ({ - users: t.table("users", { id: id() }), - posts: t.table("posts", { - id: id(), - authorId: fk(() => ({ - __isColumnRef: true, - tableName: "users", - columnName: "id", + it("allows ordinary quoted names while rejecting concrete runtime collisions", () => { + const quoted = defineSchema((builder) => ({ + toString: builder.table("toString", { displayName: text() }), + })); + expect(Object.values(quoted.$tables)[0].$name).toBe("toString"); + + expect(() => + defineSchema((builder) => ({ + users: builder.table("users", { and: text() }), })), - }), - })); + ).toThrow(/runtime metadata/); - it("$engine carries only the table handles", () => { - expect(Object.keys(schema.$engine).sort()).toEqual(["posts", "users"]); + const reservedColumns = Object.create(null) as Record< + string, + ColumnBuilder + >; + reservedColumns.__proto__ = text(); + expect(() => + defineSchema((builder) => ({ + users: builder.table("users", reservedColumns), + })), + ).toThrow(/reserved/); }); +}); - it("resolves the forward toOne on the FK owner", () => { - const relations: ResolvedRelation[] = schema.$tables.posts.$relations; - expect(relations).toEqual([ - { - name: "users", - cardinality: "toOne", - localColumn: "authorId", - targetTable: "users", - targetColumn: "id", - inferred: false, - }, - ]); +describe("primary-key invariants and Drizzle agreement", () => { + it.each([ + ["id", id()], + ["bigid", bigid()], + ])("emits %s as an actual identity primary key", (_name, key) => { + const schema = defineSchema((builder) => ({ + records: builder.table("records", { key }), + })); + const config = getTableConfig(pgOf(schema.$tables.records.$engine)); + const column = config.columns[0]; + expect(column.primary).toBe(true); + expect(column.notNull).toBe(true); + expect(column.generatedIdentity).toMatchObject({ type: "byDefault" }); + expect(schema.$tables.records.$columns.key.primaryKey).toBe(true); }); - it("infers the reverse toMany on the FK target", () => { - const relations: ResolvedRelation[] = schema.$tables.users.$relations; - expect(relations).toEqual([ - { - name: "posts", - cardinality: "toMany", - localColumn: "id", - targetTable: "posts", - targetColumn: "authorId", - inferred: true, - }, - ]); + it("supports keyless and one application-assigned primary key", () => { + const schema = defineSchema((builder) => ({ + events: builder.table("events", { body: text().notNull() }), + accounts: builder.table("accounts", { + slug: text().primaryKey(), + label: text(), + }), + })); + expect( + Object.values(schema.$tables.events.$columns).some( + (column) => column.primaryKey, + ), + ).toBe(false); + const config = getTableConfig(pgOf(schema.$tables.accounts.$engine)); + const slug = config.columns.find((column) => column.name === "slug"); + expect(slug?.primary).toBe(true); + expect(slug?.notNull).toBe(true); + expect(slug?.generatedIdentity).toBeUndefined(); }); -}); -describe("defineSchema — typed builder surface", () => { - it("accepts a typed SchemaBuilderContext callback", () => { - const build = (t: SchemaBuilderContext) => { - const users: TableHandle<{ id: ColumnBuilder; email: ColumnBuilder }> = - t.table("users", { id: id(), email: text() }); - return { users }; - }; - const schema = defineSchema(build); - expect(schema.$tables.users.$name).toBe("users"); + it("rejects multiple primary-key markers", () => { + expect(() => + defineSchema((builder) => ({ + invalid: builder.table("invalid", { + first: text().primaryKey(), + second: integer().primaryKey(), + }), + })), + ).toThrow(/multiple primary-key columns/); }); }); -describe("defineSchema — custom schemaName", () => { - it("threads schemaName onto the schema and tables", () => { - const options: DefineSchemaOptions = { schemaName: "app" }; - const schema = defineSchema( - (t) => ({ users: t.table("users", { id: id() }) }), - options, +describe("builder reuse and engine metadata", () => { + it("clones builder state across columns and schemas", () => { + const shared = text(); + const first = defineSchema((builder) => ({ + first: builder.table("first", { left: shared, right: shared }), + })); + shared.private().default("later"); + const second = defineSchema((builder) => ({ + second: builder.table("second", { value: shared }), + })); + + expect(first.$tables.first.$columns.left.isPrivate).toBe(false); + expect(first.$tables.first.$columns.right.hasDefault).toBe(false); + expect(second.$tables.second.$columns.value.isPrivate).toBe(true); + expect(second.$tables.second.$columns.value.defaultValue).toBe("later"); + expect(first.$tables.first.$columns.left).not.toBe( + first.$tables.first.$columns.right, + ); + expect(first.$tables.first.$columns.left.engineColumn).not.toBe( + second.$tables.second.$columns.value.engineColumn, ); - expect(schema.$schemaName).toBe("app"); - expect(schema.$tables.users.$schemaName).toBe("app"); }); -}); -describe("defineSchema — foreign keys", () => { - it("forward FK mirrors a serial PK to integer storage", () => { - const schema = defineSchema((t) => { - const users = t.table("users", { id: id(), email: text() }); - const posts = t.table("posts", { + it("materializes defaults in Drizzle configuration", () => { + const schema = defineSchema((builder) => ({ + records: builder.table("records", { id: id(), - authorId: fk(() => users.id).notNull(), - }); - return { users, posts }; - }); - const authorId = schema.$tables.posts.$columns.authorId; - expect(authorId.storageKind).toBe("integer"); - expect(authorId.pgType).toBe("int4"); - expect(authorId.notNull).toBe(true); - expect(authorId.fk).toEqual({ - targetTable: "users", - targetColumn: "id", - onDelete: undefined, - onUpdate: undefined, - }); + active: boolean().default(true), + count: integer().default(0), + createdAt: timestamp().defaultNow(), + }), + })); + const columns = Object.fromEntries( + getTableConfig(pgOf(schema.$tables.records.$engine)).columns.map( + (column) => [column.name, column], + ), + ); + expect(columns.active.default).toBe(true); + expect(columns.count.default).toBe(0); + expect(columns.createdAt.hasDefault).toBe(true); }); - it("forward FK to a bigid PK mirrors to bigint storage", () => { - const schema = defineSchema((t) => { - const orgs = t.table("orgs", { id: bigid() }); - const teams = t.table("teams", { id: id(), orgId: fk(() => orgs.id) }); - return { orgs, teams }; - }); - const orgId = schema.$tables.teams.$columns.orgId; - expect(orgId.storageKind).toBe("bigint"); - expect(orgId.pgType).toBe("int8"); - expect(orgId.kind).toBe("bigint"); + it("keeps timestamp values in the declared string runtime representation", () => { + const value = "2024-01-02T03:04:05Z"; + const schema = defineSchema((builder) => ({ + records: builder.table("records", { + occurredAt: timestamp().default(value), + }), + })); + const [column] = getTableConfig( + pgOf(schema.$tables.records.$engine), + ).columns; + expect(column.mapToDriverValue(value as never)).toBe(value); + expect(column.default).toBe(value); }); - it("supports self-referencing FKs", () => { - const schema = defineSchema((t) => { - const nodes = t.table("nodes", { - id: id(), - // self-ref via a direct ColumnRef thunk (avoids circular type inference). - parentId: fk(() => ({ - __isColumnRef: true, - tableName: "nodes", - columnName: "id", - })), - }); - return { nodes }; - }); - expect(schema.$tables.nodes.$columns.parentId.fk?.targetTable).toBe( - "nodes", + it.each([ + // text protocol, as a direct select returns it + ["2026-06-29 19:05:19.051709+00", "2026-06-29T19:05:19.051709+00:00"], + ["2026-06-29 19:05:19+05:30", "2026-06-29T19:05:19+05:30"], + ["2026-06-29 19:05:19-03", "2026-06-29T19:05:19-03:00"], + // JSON aggregate, as a relation include returns it + ["2026-06-29T19:05:19.051709+00:00", "2026-06-29T19:05:19.051709+00:00"], + // no timezone declared + ["2026-06-29 19:05:19.051709", "2026-06-29T19:05:19.051709"], + // non-timestamp sentinels stay untouched + ["infinity", "infinity"], + ["-infinity", "-infinity"], + ])("decodes timestamp %s as ISO-8601", (driverValue, expected) => { + const schema = defineSchema((builder) => ({ + records: builder.table("records", { + occurredAt: timestamp({ withTimezone: true }), + }), + })); + const [column] = getTableConfig( + pgOf(schema.$tables.records.$engine), + ).columns; + expect(column.mapFromDriverValue(driverValue as never)).toBe(expected); + }); + + it("returns the same timestamp shape from a direct read and a relation", () => { + const schema = defineSchema((builder) => ({ + records: builder.table("records", { + occurredAt: timestamp({ withTimezone: true }), + }), + })); + const [column] = getTableConfig( + pgOf(schema.$tables.records.$engine), + ).columns; + const fromSelect = column.mapFromDriverValue( + "2026-06-29 19:05:19.051709+00" as never, + ); + const fromInclude = column.mapFromDriverValue( + "2026-06-29T19:05:19.051709+00:00" as never, ); + expect(fromSelect).toBe(fromInclude); + expect(Number.isNaN(Date.parse(fromSelect as string))).toBe(false); }); - it("carries onDelete/onUpdate referential actions onto the edge", () => { - const schema = defineSchema((t) => { - const users = t.table("users", { id: id() }); - const posts = t.table("posts", { - id: id(), - authorId: fk(() => users.id) - .onDelete("cascade") - .onUpdate("restrict"), - }); - return { users, posts }; - }); - expect(schema.$tables.posts.$columns.authorId.fk).toMatchObject({ - onDelete: "cascade", - onUpdate: "restrict", - }); + it("validates literal defaults against storage and enum values", () => { + const schema = defineSchema((builder) => ({ + records: builder.table("records", { + status: enumColumn("record_status", ["open", "closed"]).default("open"), + }), + })); + const [status] = getTableConfig( + pgOf(schema.$tables.records.$engine), + ).columns; + expect(status.default).toBe("open"); + + const incompatibleDefaults = [ + text().default(1), + integer().default("1"), + integer().default(2_147_483_648), + boolean().default("true"), + varchar(3).default("toolong"), + uuid().default("not-a-uuid"), + timestamp().default("not-a-timestamp"), + enumColumn("invalid_status", ["open", "closed"]).default("missing"), + bigint().default(1), + jsonb().default("{}"), + ]; + for (const value of incompatibleDefaults) { + expect(() => + defineSchema((builder) => ({ + records: builder.table("records", { value }), + })), + ).toThrow(/not compatible/); + } }); +}); - it("throws when fk() targets an unknown table", () => { +describe("enum identity", () => { + it("reuses equal declarations and rejects conflicting values", () => { expect(() => - defineSchema((t) => ({ - posts: t.table("posts", { - id: id(), - ghost: fk(() => ({ - __isColumnRef: true, - tableName: "missing", - columnName: "id", - })), + defineSchema((builder) => ({ + first: builder.table("first", { + status: enumColumn("status_kind", ["open", "closed"]), + }), + second: builder.table("second", { + status: builder.enum("status_kind", ["open", "closed"]), }), })), - ).toThrow(/unknown table "missing"/); - }); + ).not.toThrow(); - it("throws when fk() targets an unknown column", () => { expect(() => - defineSchema((t) => { - const users = t.table("users", { id: id() }); - const posts = t.table("posts", { - id: id(), - ghost: fk(() => ({ - __isColumnRef: true, - tableName: "users", - columnName: "nope", - })), - }); - return { users, posts }; - }), - ).toThrow(/unknown column "users\.nope"/); + defineSchema((builder) => ({ + first: builder.table("first", { + status: enumColumn("status_kind", ["open", "closed"]), + }), + second: builder.table("second", { + status: enumColumn("status_kind", ["open", "archived"]), + }), + })), + ).toThrow(/conflicting values/); }); -}); -describe("defineSchema — guard rails", () => { - it("throws on duplicate table names", () => { - expect(() => - defineSchema((t) => { - const a = t.table("users", { id: id() }); - const b = t.table("users", { id: id() }); - return { a, b }; + it("creates enums in the table's PostgreSQL schema", () => { + const schema = defineSchema( + (builder) => ({ + tickets: builder.table("tickets", { + status: builder.enum("ticket_status", ["open", "closed"]), + }), }), - ).toThrow(/Duplicate table "users"/); + { schemaName: "application" }, + ); + const [column] = getTableConfig( + pgOf(schema.$tables.tickets.$engine), + ).columns; + expect( + (column as unknown as { enum?: { schema?: string } }).enum?.schema, + ).toBe("application"); + expect(column.enumValues).toEqual(["open", "closed"]); }); +}); - it("throws when a returned value did not come from ctx.table()", () => { - const rogue = { - $name: "rogue", - $schemaName: "public", - $columns: {}, - $relations: [], - } as unknown as AppKitTable; +describe("generated relation-key collisions", () => { + it("rejects a table that would overwrite Drizzle relation metadata", () => { expect(() => - defineSchema((t) => { - t.table("users", { id: id() }); - return { rogue }; + defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + const posts = builder.table("posts", { + id: id(), + userId: fk(() => users.id), + }); + const usersRelations = builder.table("usersRelations", { id: id() }); + return { users, posts, usersRelations }; }), - ).toThrow(/was not produced by ctx\.table\(\)/); - }); -}); - -describe("defineSchema — real engine wiring (getTableConfig)", () => { - const schema = defineSchema((t) => { - const users = t.table("users", { - id: id(), - email: text().notNull(), - active: boolean().default(true), - createdAt: timestamp().defaultNow(), - }); - const posts = t.table("posts", { - id: id(), - authorId: fk(() => users.id) - .notNull() - .onDelete("cascade"), - views: integer().default(0), - }); - return { users, posts }; - }); - - it("emits a real FK with the correct local/target columns and action", () => { - const config = getTableConfig(pgOf(schema.$tables.posts.$engine)); - expect(config.foreignKeys).toHaveLength(1); - const fkConfig = config.foreignKeys[0]; - const ref = fkConfig.reference(); - expect(ref.columns.map((c) => c.name)).toEqual(["authorId"]); - expect(ref.foreignColumns.map((c) => c.name)).toEqual(["id"]); - expect(fkConfig.onDelete).toBe("cascade"); - }); - - it("wires column names and notNull onto the engine table", () => { - const config = getTableConfig(pgOf(schema.$tables.users.$engine)); - const byName = Object.fromEntries(config.columns.map((c) => [c.name, c])); - expect(Object.keys(byName).sort()).toEqual([ - "active", - "createdAt", - "email", - "id", - ]); - expect(byName.email.notNull).toBe(true); - // identity PK is tracked in our ColumnMeta, not pushed onto the serial builder. - expect(schema.$tables.users.$columns.id.primaryKey).toBe(true); + ).toThrow(/collides with generated relation metadata/); }); }); diff --git a/packages/appkit/src/database/schema-builder/tests/engine-relations.test.ts b/packages/appkit/src/database/schema-builder/tests/engine-relations.test.ts index 70a16e1fa..6a2ed0060 100644 --- a/packages/appkit/src/database/schema-builder/tests/engine-relations.test.ts +++ b/packages/appkit/src/database/schema-builder/tests/engine-relations.test.ts @@ -1,7 +1,8 @@ import { createTableRelationsHelpers, Many, One } from "drizzle-orm"; import type { PgTable } from "drizzle-orm/pg-core"; import { describe, expect, it } from "vitest"; -import { buildEngineRelations, defineSchema, fk, id, text } from "../index"; +import { buildEngineRelations } from "../engine/relations"; +import { defineSchema, fk, id, text } from "../index"; /** Structural view of a Drizzle `relations()` object (avoids leaning on internals' exact types). */ type RelationsLike = { @@ -63,14 +64,14 @@ describe("buildEngineRelations", () => { ).toBe("posts"); }); - it("resolves relation targets by table name even when return keys differ", () => { + it("resolves relation targets by canonical table identity", () => { const s = defineSchema((t) => { const cases = t.table("cases", { id: id() }); - const statusHistory = t.table("status_history", { + const status_history = t.table("status_history", { id: id(), caseId: fk(() => cases.id), }); - return { cases, statusHistory }; + return { cases, status_history }; }); const rels = buildEngineRelations(s.$tables); diff --git a/packages/appkit/src/database/schema-builder/tests/fk.test.ts b/packages/appkit/src/database/schema-builder/tests/fk.test.ts index ae7c1113a..795fbe11b 100644 --- a/packages/appkit/src/database/schema-builder/tests/fk.test.ts +++ b/packages/appkit/src/database/schema-builder/tests/fk.test.ts @@ -1,48 +1,317 @@ +import { getTableConfig, type PgTable } from "drizzle-orm/pg-core"; import { describe, expect, it } from "vitest"; import { mirrorStorageKind, resolveFkRef } from "../fk"; -import { type ColumnRef, fk, type StorageKind } from "../index"; +import { + bigint, + type ColumnBuilder, + type ColumnRef, + defineSchema, + enumColumn, + fk, + id, + type StorageKind, + type TableHandle, + text, + timestamp, + uuid, + varchar, +} from "../index"; +import type { EngineTable } from "../types"; -const ref: ColumnRef = { +const pgOf = (table: EngineTable): PgTable => table as unknown as PgTable; +const ref: ColumnRef = Object.freeze({ __isColumnRef: true, tableName: "users", columnName: "id", -}; +}); -describe("fk()", () => { - it("produces an fk column with a placeholder integer storage", () => { - const col = fk(ref); - expect(col._spec.kind).toBe("fk"); - expect(col._meta.storageKind).toBe("integer"); - expect(col._meta.fkRef).toBe(ref); +describe("fk references", () => { + it("stores direct and deferred references without resolving early", () => { + expect(fk(ref)._meta.fkRef).toBe(ref); + expect(typeof fk(() => ref)._meta.fkRef).toBe("function"); + expect(resolveFkRef(ref)).toBe(ref); + expect(resolveFkRef(() => ref)).toBe(ref); }); - it("accepts a thunk ref for forward/self references", () => { - const col = fk(() => ref); - expect(typeof col._meta.fkRef).toBe("function"); + it("rejects malformed references", () => { + expect(() => resolveFkRef({} as ColumnRef)).toThrow( + /must reference a column/, + ); }); }); -describe("resolveFkRef()", () => { - it("returns a direct ref unchanged", () => { - expect(resolveFkRef(ref)).toBe(ref); +describe("foreign-key storage and target invariants", () => { + it("mirrors generated identities to non-generated integer storage", () => { + expect(mirrorStorageKind("id")).toBe("integer"); + expect(mirrorStorageKind("bigid")).toBe("bigint"); + expect(mirrorStorageKind("uuid")).toBe("uuid"); }); - it("invokes a thunk ref", () => { - expect(resolveFkRef(() => ref)).toBe(ref); + it("inherits the complete target storage contract", () => { + const schema = defineSchema((builder) => { + const integer_targets = builder.table("integer_targets", { id: id() }); + const uuid_targets = builder.table("uuid_targets", { + value: uuid().unique(), + }); + const varchar_targets = builder.table("varchar_targets", { + value: varchar(32).unique(), + }); + const timestamp_targets = builder.table("timestamp_targets", { + value: timestamp({ withTimezone: true }).unique(), + }); + const bigint_targets = builder.table("bigint_targets", { + value: bigint().unique(), + }); + const enum_targets = builder.table("enum_targets", { + value: enumColumn("target_status", ["open", "closed"]).unique(), + }); + const references = builder.table("references", { + id: id(), + targetId: fk(() => integer_targets.id), + externalId: fk(() => uuid_targets.value), + code: fk(() => varchar_targets.value), + happenedAt: fk(() => timestamp_targets.value), + ordinal: fk(() => bigint_targets.value), + status: fk(() => enum_targets.value), + }); + return { + integer_targets, + uuid_targets, + varchar_targets, + timestamp_targets, + bigint_targets, + enum_targets, + references, + }; + }); + + const columns = schema.$tables.references.$columns; + expect(columns.targetId).toMatchObject({ + storageKind: "integer", + kind: "number", + }); + expect(columns.externalId).toMatchObject({ + storageKind: "uuid", + kind: "uuid", + }); + expect(columns.code).toMatchObject({ + storageKind: "varchar", + varcharLength: 32, + }); + expect(columns.happenedAt).toMatchObject({ + storageKind: "timestamp", + withTimezone: true, + kind: "date", + }); + expect(columns.ordinal).toMatchObject({ + storageKind: "bigint", + kind: "bigint", + }); + expect(columns.status).toMatchObject({ + storageKind: "enum", + enumName: "target_status", + enumValues: ["open", "closed"], + }); + }); + + it("resolves chained FK storage independently of declaration order", () => { + const schema = defineSchema((builder) => { + let middle: TableHandle<{ leafId: ColumnBuilder }>; + let leaf: TableHandle<{ id: ColumnBuilder }>; + const root = builder.table("root", { + middleId: fk(() => middle.leafId), + }); + middle = builder.table("middle", { + leafId: fk(() => leaf.id).unique(), + }); + leaf = builder.table("leaf", { id: uuid().primaryKey() }); + return { root, middle, leaf }; + }); + + expect(schema.$tables.middle.$columns.leafId.storageKind).toBe("uuid"); + expect(schema.$tables.root.$columns.middleId.storageKind).toBe("uuid"); + const [foreignKey] = getTableConfig( + pgOf(schema.$tables.root.$engine), + ).foreignKeys; + expect(foreignKey.reference().foreignColumns[0].name).toBe("leafId"); + }); + + it("rejects FK storage cycles that have no concrete target type", () => { + expect(() => + defineSchema((builder) => { + let left: TableHandle<{ rightId: ColumnBuilder }>; + const right = builder.table("right", { + leftId: fk(() => left.rightId).unique(), + }); + left = builder.table("left", { + rightId: fk(() => right.leftId).unique(), + }); + return { left, right }; + }), + ).toThrow(/cyclic storage dependency/); + }); + + it("requires a primary-key or unique target", () => { + expect(() => + defineSchema((builder) => { + const users = builder.table("users", { id: id(), email: text() }); + const notes = builder.table("notes", { + userEmail: fk(() => users.email), + }); + return { users, notes }; + }), + ).toThrow(/primary-key or unique/); + }); + + it("rejects unknown, cross-schema, and omitted targets", () => { + expect(() => + defineSchema((builder) => ({ + notes: builder.table("notes", { + userId: fk(() => ({ + __isColumnRef: true, + tableName: "missing", + columnName: "id", + })), + }), + })), + ).toThrow(/outside the returned schema/); + + let externalUsers!: TableHandle<{ id: ColumnBuilder }>; + defineSchema((builder) => { + externalUsers = builder.table("users", { id: id() }); + return { users: externalUsers }; + }); + expect(() => + defineSchema((builder) => ({ + notes: builder.table("notes", { + userId: fk(() => externalUsers.id), + }), + })), + ).toThrow(/outside the returned schema/); + + expect(() => + defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + const notes = builder.table("notes", { + userId: fk(() => externalUsers.id), + }); + return { users, notes }; + }), + ).toThrow(/outside the returned schema/); + + expect(() => + defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + const notes = builder.table("notes", { userId: fk(() => users.id) }); + return { notes }; + }), + ).toThrow(/omitted declared table: users/); }); }); -describe("mirrorStorageKind()", () => { - it("maps serial PK kinds to their plain integer storage", () => { - const fromId: StorageKind = mirrorStorageKind("id"); - expect(fromId).toBe("integer"); - expect(mirrorStorageKind("bigid")).toBe("bigint"); +describe("referential-action coherence", () => { + it("allows SET NULL only on nullable foreign keys", () => { + expect(() => + defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + const notes = builder.table("notes", { + userId: fk(() => users.id).onDelete("set null"), + }); + return { users, notes }; + }), + ).not.toThrow(); + + expect(() => + defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + const notes = builder.table("notes", { + userId: fk(() => users.id) + .notNull() + .onDelete("set null"), + }); + return { users, notes }; + }), + ).toThrow(/SET NULL but is not-null/); + }); + + it("allows SET DEFAULT only with a compatible local default", () => { + expect(() => + defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + const notes = builder.table("notes", { + userId: fk(() => users.id) + .default(0) + .onDelete("set default"), + }); + return { users, notes }; + }), + ).not.toThrow(); + + expect(() => + defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + const notes = builder.table("notes", { + userId: fk(() => users.id).onUpdate("set default"), + }); + return { users, notes }; + }), + ).toThrow(/SET DEFAULT without a local default/); }); - it.each(["text", "uuid", "integer", "bigint", "boolean"] as const)( - "passes %s through unchanged", - (kind) => { - expect(mirrorStorageKind(kind)).toBe(kind); - }, - ); + it("validates literal defaults after inheriting the target storage", () => { + expect(() => + defineSchema((builder) => { + const users = builder.table("users", { + externalId: uuid().unique(), + }); + const notes = builder.table("notes", { + userId: fk(() => users.externalId).default(1), + }); + return { users, notes }; + }), + ).toThrow(/not compatible with uuid storage/); + + expect(() => + defineSchema((builder) => { + const statuses = builder.table("statuses", { + value: enumColumn("status_kind", ["open", "closed"]).unique(), + }); + const records = builder.table("records", { + status: fk(() => statuses.value).default("missing"), + }); + return { statuses, records }; + }), + ).toThrow(/not compatible with enum storage/); + }); + + it("materializes validated foreign keys and actions in Drizzle", () => { + const schema = defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + const notes = builder.table("notes", { + id: id(), + userId: fk(() => users.id) + .notNull() + .onDelete("cascade") + .onUpdate("restrict"), + }); + return { users, notes }; + }); + const [foreignKey] = getTableConfig( + pgOf(schema.$tables.notes.$engine), + ).foreignKeys; + const reference = foreignKey.reference(); + expect(reference.columns.map((column) => column.name)).toEqual(["userId"]); + expect(reference.foreignColumns.map((column) => column.name)).toEqual([ + "id", + ]); + expect(foreignKey.onDelete).toBe("cascade"); + expect(foreignKey.onUpdate).toBe("restrict"); + }); +}); + +describe("StorageKind", () => { + it("keeps the supported inherited kinds", () => { + const kind: StorageKind = mirrorStorageKind("text"); + expect(kind).toBe("text"); + }); }); diff --git a/packages/appkit/src/database/schema-builder/tests/private.test.ts b/packages/appkit/src/database/schema-builder/tests/private.test.ts deleted file mode 100644 index 634d8e7ac..000000000 --- a/packages/appkit/src/database/schema-builder/tests/private.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - defineSchema, - fk, - id, - isPrivateColumn, - nonPrivateColumnNames, - ownerColumnName, - privateColumnNames, - text, -} from "../index"; - -const schema = defineSchema((t) => { - const users = t.table("users", { - id: id(), - email: text().notNull().owner(), - name: text(), - passwordHash: text().private(), - }); - const posts = t.table("posts", { - id: id(), - authorId: fk(() => users.id), - title: text(), - }); - return { users, posts }; -}); - -const users = schema.$tables.users; - -describe("isPrivateColumn", () => { - it("reflects the .private() modifier", () => { - expect(isPrivateColumn(users.$columns.passwordHash)).toBe(true); - expect(isPrivateColumn(users.$columns.email)).toBe(false); - }); -}); - -describe("privateColumnNames / nonPrivateColumnNames", () => { - it("partitions the columns by privacy", () => { - expect(privateColumnNames(users)).toEqual(["passwordHash"]); - expect(nonPrivateColumnNames(users)).toEqual(["id", "email", "name"]); - }); - - it("returns an empty private list when none are marked", () => { - expect(privateColumnNames(schema.$tables.posts)).toEqual([]); - }); -}); - -describe("ownerColumnName", () => { - it("returns the column flagged with .owner()", () => { - expect(ownerColumnName(users)).toBe("email"); - }); - - it("returns undefined when no owner column is declared", () => { - expect(ownerColumnName(schema.$tables.posts)).toBeUndefined(); - }); - - it("rejects tables with multiple owner columns", () => { - expect(() => - defineSchema((t) => ({ - users: t.table("users", { - id: id(), - email: text().owner(), - accountId: text().owner(), - }), - })), - ).toThrow(/multiple \.owner\(\) columns/); - }); -}); diff --git a/packages/appkit/src/database/schema-builder/tests/relations.test.ts b/packages/appkit/src/database/schema-builder/tests/relations.test.ts index 661032d42..6963dcade 100644 --- a/packages/appkit/src/database/schema-builder/tests/relations.test.ts +++ b/packages/appkit/src/database/schema-builder/tests/relations.test.ts @@ -1,25 +1,31 @@ import { describe, expect, it } from "vitest"; import { - type AppKitTable, - buildRelations, - type ColumnMeta, + type ColumnBuilder, defineSchema, fk, id, type ResolvedRelation, + type TableHandle, text, } from "../index"; -describe("buildRelations — forward toOne", () => { - const schema = defineSchema((t) => { - const cases = t.table("cases", { id: id() }); - const notes = t.table("notes", { id: id(), caseId: fk(() => cases.id) }); - return { cases, notes }; - }); +describe("deterministic relation metadata", () => { + it("uses target/source table identity for forward and reverse relations", () => { + const schema = defineSchema((builder) => { + const cases = builder.table("cases", { id: id() }); + const status_history = builder.table("status_history", { + id: id(), + caseId: fk(() => cases.id), + }); + return { cases, status_history }; + }); + + const forward: readonly ResolvedRelation[] = + schema.$tables.status_history.$relations; + const reverse: readonly ResolvedRelation[] = + schema.$tables.cases.$relations; - it("creates a forward toOne named after the target table on the FK owner", () => { - const relations: ResolvedRelation[] = schema.$tables.notes.$relations; - expect(relations).toEqual([ + expect(forward).toEqual([ { name: "cases", cardinality: "toOne", @@ -29,60 +35,40 @@ describe("buildRelations — forward toOne", () => { inferred: false, }, ]); - }); -}); - -describe("buildRelations — inferred reverse toMany", () => { - it("infers the reverse toMany using the SOURCE table name verbatim", () => { - const schema = defineSchema((t) => { - const cases = t.table("cases", { id: id() }); - const notes = t.table("notes", { id: id(), caseId: fk(() => cases.id) }); - return { cases, notes }; - }); - const reverse: ResolvedRelation[] = schema.$tables.cases.$relations; expect(reverse).toEqual([ { - // verbatim source name — NOT re-pluralized to "noteses" - name: "notes", + name: "status_history", cardinality: "toMany", localColumn: "id", - targetTable: "notes", + targetTable: "status_history", targetColumn: "caseId", inferred: true, }, ]); }); - it("leaves an already-plural source name unchanged on the reverse relation", () => { - const schema = defineSchema((t) => { - const cases = t.table("cases", { id: id() }); - const statusHistory = t.table("status_history", { + it("keeps reverse relations toMany even for a unique FK", () => { + const schema = defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + const profiles = builder.table("profiles", { id: id(), - caseId: fk(() => cases.id), + userId: fk(() => users.id).unique(), }); - return { cases, statusHistory }; + return { users, profiles }; }); - expect(schema.$tables.cases.$relations.map((r) => r.name)).toEqual([ - "status_history", - ]); + expect(schema.$tables.users.$relations[0].cardinality).toBe("toMany"); }); -}); -describe("buildRelations — self references", () => { - it("keeps only the forward toOne for a self-referential FK", () => { - const schema = defineSchema((t) => { - const nodes = t.table("nodes", { + it("exposes only the forward relation for a self reference", () => { + const schema = defineSchema((builder) => { + let nodes: TableHandle<{ id: ColumnBuilder; parentId: ColumnBuilder }>; + nodes = builder.table("nodes", { id: id(), - parentId: fk(() => ({ - __isColumnRef: true, - tableName: "nodes", - columnName: "id", - })), + parentId: fk(() => nodes.id), }); return { nodes }; }); - const relations: ResolvedRelation[] = schema.$tables.nodes.$relations; - expect(relations).toEqual([ + expect(schema.$tables.nodes.$relations).toEqual([ { name: "nodes", cardinality: "toOne", @@ -93,97 +79,63 @@ describe("buildRelations — self references", () => { }, ]); }); -}); -/** Minimal `AppKitTable` factory for exercising `buildRelations` directly. */ -function makeTable( - name: string, - columns: Record & { columnName: string }>, -): AppKitTable { - return { - $name: name, - $schemaName: "public", - $columns: columns as Record, - $engine: {} as AppKitTable["$engine"], - $relations: [], - }; -} - -describe("buildRelations — direct invocation", () => { - it("populates forward toOne and reverse toMany across the table map", () => { - const cases = makeTable("cases", { id: { columnName: "id" } }); - const notes = makeTable("notes", { - id: { columnName: "id" }, - caseId: { - columnName: "caseId", - fk: { targetTable: "cases", targetColumn: "id" }, - }, + it("freezes relation objects and arrays", () => { + const schema = defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + const posts = builder.table("posts", { + id: id(), + userId: fk(() => users.id), + }); + return { users, posts }; }); - - buildRelations({ cases, notes }); - - expect(notes.$relations).toEqual([ - { - name: "cases", - cardinality: "toOne", - localColumn: "caseId", - targetTable: "cases", - targetColumn: "id", - inferred: false, - }, - ]); - expect(cases.$relations).toEqual([ - { - name: "notes", - cardinality: "toMany", - localColumn: "id", - targetTable: "notes", - targetColumn: "caseId", - inferred: true, - }, - ]); + expect(Object.isFrozen(schema.$tables.users.$relations)).toBe(true); + expect(Object.isFrozen(schema.$tables.users.$relations[0])).toBe(true); + expect(() => { + (schema.$tables.users.$relations as unknown[]).push({}); + }).toThrow(TypeError); }); }); -describe("buildRelations — collision + ambiguity guards", () => { - it("throws when a forward relation name collides with a column", () => { +describe("relation ambiguity guards", () => { + it("rejects multiple FKs from one table to the same target", () => { expect(() => - defineSchema((t) => { - const tag = t.table("tag", { id: id() }); - const post = t.table("post", { + defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + const messages = builder.table("messages", { id: id(), - tag: text(), - tagId: fk(() => tag.id), + senderId: fk(() => users.id), + recipientId: fk(() => users.id), }); - return { tag, post }; + return { users, messages }; }), - ).toThrow(/Forward relation "post\.tag" collides with a column/); + ).toThrow(/Ambiguous forward relation/); }); - it("throws when two FKs target the same table (ambiguous forward)", () => { + it("rejects forward relation/column collisions", () => { expect(() => - defineSchema((t) => { - const users = t.table("users", { id: id() }); - const messages = t.table("messages", { + defineSchema((builder) => { + const tags = builder.table("tags", { id: id() }); + const posts = builder.table("posts", { id: id(), - senderId: fk(() => users.id), - recipientId: fk(() => users.id), + tags: text(), + tagId: fk(() => tags.id), }); - return { users, messages }; + return { tags, posts }; }), - ).toThrow(/Ambiguous forward relation "messages\.users"/); + ).toThrow(/Forward relation .* collides with a column/); }); - it("throws when a reverse relation name collides with a column on the target", () => { + it("rejects reverse relation/column collisions", () => { expect(() => - defineSchema((t) => { - const notes = t.table("notes", { id: id(), posts: text() }); - const posts = t.table("posts", { + defineSchema((builder) => { + const users = builder.table("users", { id: id(), posts: text() }); + const posts = builder.table("posts", { id: id(), - noteId: fk(() => notes.id), + userId: fk(() => users.id), }); - return { notes, posts }; + return { users, posts }; }), - ).toThrow(/Reverse relation "notes\.posts" collides with a column/); + ).toThrow(/Reverse relation .* collides with a column/); }); }); diff --git a/packages/appkit/src/database/schema-builder/tests/validators.test.ts b/packages/appkit/src/database/schema-builder/tests/validators.test.ts index 6fbe56d44..d42474bef 100644 --- a/packages/appkit/src/database/schema-builder/tests/validators.test.ts +++ b/packages/appkit/src/database/schema-builder/tests/validators.test.ts @@ -5,8 +5,6 @@ import { bigint, boolean, defineSchema, - deriveInsertSchema, - deriveUpdateSchema, enumColumn, id, integer, @@ -14,7 +12,9 @@ import { text, timestamp, uuid, + varchar, } from "../index"; +import { deriveInsertSchema, deriveUpdateSchema } from "../validators"; /** Read the per-field shape off a derived Zod object schema (test-only seam). */ function shapeOf(schema: ZodType): Record { @@ -41,6 +41,7 @@ const schema = defineSchema((t) => ({ types: t.table("types", { id: id(), tags: text().notNull(), + short: varchar(3).notNull(), count: integer().notNull(), big: bigint().notNull(), flag: boolean().notNull(), @@ -54,6 +55,7 @@ const schema = defineSchema((t) => ({ const users = schema.$tables.users; const accounts = schema.$tables.accounts; const types = schema.$tables.types; +const validUserInsert = { email: "a@b.com", secret: "server-only" }; describe("defineSchema — validator wiring", () => { it("stamps $insertSchema and $updateSchema on every table", () => { @@ -67,19 +69,27 @@ describe("defineSchema — validator wiring", () => { describe("deriveInsertSchema", () => { const insert = deriveInsertSchema(users); - it("omits private and server-generated columns", () => { + it("includes private fields and omits only server-generated columns", () => { expect(Object.keys(shapeOf(insert)).sort()).toEqual([ "createdAt", "email", "loginCount", "name", "role", + "secret", ]); }); it("keeps a required (notNull, no default) column required", () => { expect(insert.safeParse({}).success).toBe(false); - expect(insert.safeParse({ email: "a@b.com" }).success).toBe(true); + expect(insert.safeParse({ email: "a@b.com" }).success).toBe(false); + expect(insert.safeParse(validUserInsert).success).toBe(true); + }); + + it("rejects unknown fields instead of stripping them", () => { + expect( + insert.safeParse({ ...validUserInsert, unexpected: true }).success, + ).toBe(false); }); it("makes defaulted columns optional even when notNull", () => { @@ -105,7 +115,7 @@ describe("deriveInsertSchema", () => { }); describe("deriveUpdateSchema", () => { - it("omits the primary key (and private + server-generated)", () => { + it("includes private fields and omits primary-key and generated columns", () => { expect(Object.keys(shapeOf(deriveUpdateSchema(accounts)))).toEqual([ "label", ]); @@ -115,6 +125,7 @@ describe("deriveUpdateSchema", () => { "loginCount", "name", "role", + "secret", ]); }); @@ -125,6 +136,12 @@ describe("deriveUpdateSchema", () => { // `email` is notNull/no-default (required on insert) but optional on update. expect(shapeOf(update).email.safeParse(undefined).success).toBe(true); }); + + it("rejects unknown fields instead of stripping them", () => { + expect( + deriveUpdateSchema(users).safeParse({ unexpected: true }).success, + ).toBe(false); + }); }); describe("zodForColumn — engine-neutral kind mapping", () => { @@ -133,17 +150,21 @@ describe("zodForColumn — engine-neutral kind mapping", () => { it("maps string columns to z.string()", () => { expect(shape.tags.safeParse("x").success).toBe(true); expect(shape.tags.safeParse(5).success).toBe(false); + expect(shape.short.safeParse("abc").success).toBe(true); + expect(shape.short.safeParse("toolong").success).toBe(false); }); - it("maps number columns to z.number()", () => { + it("accepts only PostgreSQL int4 values for number columns", () => { expect(shape.count.safeParse(5).success).toBe(true); expect(shape.count.safeParse("5").success).toBe(false); + expect(shape.count.safeParse(1.5).success).toBe(false); + expect(shape.count.safeParse(2_147_483_648).success).toBe(false); }); - it("maps bigint columns to a bigint | number | string union", () => { + it("uses bigint as the canonical bigint runtime value", () => { expect(shape.big.safeParse(5n).success).toBe(true); - expect(shape.big.safeParse(5).success).toBe(true); - expect(shape.big.safeParse("5").success).toBe(true); + expect(shape.big.safeParse(5).success).toBe(false); + expect(shape.big.safeParse("5").success).toBe(false); expect(shape.big.safeParse(true).success).toBe(false); }); @@ -152,18 +173,26 @@ describe("zodForColumn — engine-neutral kind mapping", () => { expect(shape.flag.safeParse("true").success).toBe(false); }); - it("maps date columns to a date | string union", () => { - expect(shape.when.safeParse(new Date()).success).toBe(true); + it("uses ISO 8601 strings as canonical timestamp values", () => { expect(shape.when.safeParse("2020-01-01T00:00:00Z").success).toBe(true); + expect(shape.when.safeParse("2020-01-01T00:00:00").success).toBe(true); + expect(shape.when.safeParse(new Date()).success).toBe(false); + expect(shape.when.safeParse("not-a-timestamp").success).toBe(false); expect(shape.when.safeParse(5).success).toBe(false); }); - it("maps json columns to z.unknown() (accepts arbitrary values)", () => { + it("accepts JSON values and rejects non-JSON runtime objects", () => { expect(shape.doc.safeParse({ nested: [1, 2] }).success).toBe(true); + expect(shape.doc.safeParse(new Date()).success).toBe(false); + expect(shape.doc.safeParse({ nested: undefined }).success).toBe(false); + expect(shape.doc.safeParse(1n).success).toBe(false); }); - it("maps uuid columns to z.string() (no format constraint)", () => { - expect(shape.ref.safeParse("not-a-real-uuid").success).toBe(true); + it("accepts canonical UUID strings", () => { + expect( + shape.ref.safeParse("123e4567-e89b-12d3-a456-426614174000").success, + ).toBe(true); + expect(shape.ref.safeParse("not-a-real-uuid").success).toBe(false); expect(shape.ref.safeParse(123).success).toBe(false); }); diff --git a/packages/appkit/src/database/schema-builder/types.ts b/packages/appkit/src/database/schema-builder/types.ts index 82de3516a..d49a14b02 100644 --- a/packages/appkit/src/database/schema-builder/types.ts +++ b/packages/appkit/src/database/schema-builder/types.ts @@ -1,29 +1,72 @@ -import type { ColumnInfoKind, ReferentialAction } from "../contract"; +import type { FilterOperator } from "../contract"; -/** - * Opaque handles to the internal query-engine objects. - */ declare const ENGINE_TABLE: unique symbol; declare const ENGINE_COLUMN: unique symbol; +/** Opaque handles keep Drizzle types behind the schema/runtime boundary. */ export type EngineTable = { readonly [ENGINE_TABLE]: true }; export type EngineColumn = { readonly [ENGINE_COLUMN]: true }; -/** Internal description of a column's storage type */ +/** JavaScript value category exposed by a column, independent of its storage. */ +export type ColumnValueKind = + | "string" + | "number" + | "bigint" + | "boolean" + | "date" + | "json" + | "uuid" + | "enum" + | "unknown"; + +/** The filter subset shared by runtime translation and later typed surfaces. */ +export function filterOperatorsForKind( + kind: ColumnValueKind, +): readonly FilterOperator[] { + switch (kind) { + case "string": + return ["eq", "neq", "in", "like", "ilike"]; + case "number": + case "bigint": + case "date": + return ["eq", "neq", "in", "gt", "gte", "lt", "lte"]; + case "enum": + case "boolean": + case "uuid": + return ["eq", "neq", "in"]; + case "json": + case "unknown": + return []; + } +} + +/** PostgreSQL action applied when a referenced row changes or is deleted. */ +export type ReferentialAction = + | "cascade" + | "set null" + | "set default" + | "restrict" + | "no action"; + +/** DSL declaration kind, including identity shorthand and unresolved FKs. */ export type ColumnTypeSpec = - | { kind: "id" } - | { kind: "bigid" } - | { kind: "text" } - | { kind: "varchar"; length: number } - | { kind: "integer" } - | { kind: "bigint" } - | { kind: "boolean" } - | { kind: "uuid" } - | { kind: "timestamp"; withTimezone: boolean } - | { kind: "jsonb" } - | { kind: "enum"; enumName: string; values: readonly string[] } - | { kind: "fk" }; - -/** Concrete storage kind after FK mirroring */ + | { readonly kind: "id" } + | { readonly kind: "bigid" } + | { readonly kind: "text" } + | { readonly kind: "varchar"; readonly length: number } + | { readonly kind: "integer" } + | { readonly kind: "bigint" } + | { readonly kind: "boolean" } + | { readonly kind: "uuid" } + | { readonly kind: "timestamp"; readonly withTimezone: boolean } + | { readonly kind: "jsonb" } + | { + readonly kind: "enum"; + readonly enumName: string; + readonly values: readonly string[]; + } + | { readonly kind: "fk" }; + +/** Resolved PostgreSQL storage used to construct the engine column. */ export type StorageKind = | "id" | "bigid" @@ -37,7 +80,6 @@ export type StorageKind = | "jsonb" | "enum"; -/** A deferred or direct reference to a target column */ export interface ColumnRef { readonly __isColumnRef: true; readonly tableName: string; @@ -46,22 +88,25 @@ export interface ColumnRef { export type FkRef = ColumnRef | (() => ColumnRef); -/** Mutable working metadata; frozen into {@link ColumnMeta} at the end of the build. */ +export interface ResolvedForeignKey { + readonly targetTable: string; + readonly targetColumn: string; + readonly onDelete?: ReferentialAction; + readonly onUpdate?: ReferentialAction; +} + +/** Mutable declaration state. It is cloned per table and never published. */ export interface MutableColumnMeta { name: string; columnName: string; - kind: ColumnInfoKind; - pgType: string; + kind: ColumnValueKind; storageKind: StorageKind; notNull: boolean; primaryKey: boolean; unique: boolean; isPrivate: boolean; - /** RLS owner column (`.owner()`) — its email value is compared to current_user_email() by the policy. */ - isOwner: boolean; serverGenerated: boolean; hasDefault: boolean; - defaultExpr?: string; defaultValue?: string | number | boolean; defaultNow?: boolean; defaultRandom?: boolean; @@ -72,61 +117,58 @@ export interface MutableColumnMeta { fkRef?: FkRef; onDelete?: ReferentialAction; onUpdate?: ReferentialAction; - fk?: { - targetTable: string; - targetColumn: string; - onDelete?: ReferentialAction; - onUpdate?: ReferentialAction; - }; - /** @internal opaque engine column handle */ + fk?: ResolvedForeignKey; engineColumn?: EngineColumn; } -/** Resolved, read-only column metadata exposed on a built table. */ -export type ColumnMeta = Readonly; +/** Immutable column metadata published by a finalized schema. */ +export type ColumnMeta = Readonly< + Omit & { + readonly enumValues?: readonly string[]; + readonly fk?: Readonly; + readonly engineColumn: EngineColumn; + } +>; -/** - * A named, directed relation resolved from FK edges. `toOne` is the forward - * many-to-one; `toMany` is the inferred reverse one-to-many. - */ export interface ResolvedRelation { - name: string; - cardinality: "toOne" | "toMany"; - localColumn: string; - targetTable: string; - targetColumn: string; - inferred: boolean; + readonly name: string; + readonly cardinality: "toOne" | "toMany"; + readonly localColumn: string; + readonly targetTable: string; + readonly targetColumn: string; + readonly inferred: boolean; } -/** A built table: the engine table handle plus AppKit metadata under `$`-keys. */ export interface AppKitTable { - $name: string; - $schemaName: string; - $columns: Record; - /** @internal opaque engine table handle */ - $engine: EngineTable; - $relations: ResolvedRelation[]; - /** @internal insert schema */ - $insertSchema?: unknown; - /** @internal update schema */ - $updateSchema?: unknown; + readonly $name: string; + readonly $schemaName: string; + readonly $columns: Readonly>; + readonly $engine: EngineTable; + readonly $relations: readonly ResolvedRelation[]; + /** @internal insert schema retained from the current-main foundation. */ + readonly $insertSchema: unknown; + /** @internal update schema retained from the current-main foundation. */ + readonly $updateSchema: unknown; } -/** The object returned by `ctx.table(...)`: column refs + (after build) the table metadata. */ +/** A declaration handle gains finalized table metadata only at publication. */ export type TableHandle> = AppKitTable & { readonly [K in keyof C]: ColumnRef; }; export interface DefineSchemaOptions { - /** Postgres schema name; canonical default is `"public"`. */ - schemaName?: string; + readonly schemaName?: string; } -export interface Schema { - $schemaName: string; - $tables: Record; - /** @internal opaque engine table handles */ - $engine: Record; +/** + * One finalized schema. `TTableName` keeps the declared names in the type, so + * configuration that addresses a table by name is checked against the schema + * it was written for. Code that accepts any schema uses the default. + */ +export interface Schema { + readonly $schemaName: string; + readonly $tables: Readonly>; + readonly $engine: Readonly>; } export class SchemaBuildError extends Error { diff --git a/packages/appkit/src/database/schema-builder/validators.ts b/packages/appkit/src/database/schema-builder/validators.ts index 9efe9f206..f273b392f 100644 --- a/packages/appkit/src/database/schema-builder/validators.ts +++ b/packages/appkit/src/database/schema-builder/validators.ts @@ -2,55 +2,70 @@ import type { ZodType } from "zod"; import { z } from "zod"; import type { AppKitTable, ColumnMeta } from "./types"; -/** Map an engine-neutral ColumnMeta.kind to a Zod base type */ -function zodForColumn(meta: ColumnMeta): ZodType { +const PG_INTEGER_MIN = -2_147_483_648; +const PG_INTEGER_MAX = 2_147_483_647; +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Validate the canonical value shape accepted by the configured Drizzle column. */ +export function columnValueSchema( + meta: Pick< + ColumnMeta, + "kind" | "storageKind" | "varcharLength" | "enumValues" + >, +): ZodType { switch (meta.kind) { - case "string": - case "uuid": - return z.string(); + case "string": { + const value = z.string(); + return meta.storageKind === "varchar" + ? value.max(meta.varcharLength ?? 255) + : value; + } case "number": - return z.number(); + return z.number().int().min(PG_INTEGER_MIN).max(PG_INTEGER_MAX); case "bigint": - return z.union([z.bigint(), z.number(), z.string()]); + return z.bigint(); case "boolean": return z.boolean(); case "date": - return z.union([z.date(), z.string()]); + return z.iso.datetime({ local: true, offset: true }); case "json": - return z.unknown(); + return z.json(); + case "uuid": + return z.string().regex(UUID_RE); case "enum": return meta.enumValues && meta.enumValues.length > 0 ? z.enum([...meta.enumValues] as [string, ...string[]]) - : z.string(); + : z.never(); default: - return z.unknown(); + return z.never(); } } -/** Insert payload: omit private + server-generated; */ +/** Trusted insert payload: private fields are allowed; identities are not. */ export function deriveInsertSchema(tables: AppKitTable): ZodType { const shape: Record = {}; for (const meta of Object.values(tables.$columns)) { - if (meta.isPrivate || meta.serverGenerated) continue; - let field = zodForColumn(meta); + if (meta.serverGenerated) continue; + let field = columnValueSchema(meta); if (!meta.notNull) field = field.nullable(); if (!meta.notNull || meta.hasDefault) field = field.optional(); shape[meta.columnName] = field; } - return z.object(shape); + return z.strictObject(shape); } -/** Update payload: omit PK + private + server-generated; every field optional (partial). */ +/** Trusted update payload: private fields are allowed; keys and identities are not. */ export function deriveUpdateSchema(tables: AppKitTable): ZodType { const shape: Record = {}; for (const meta of Object.values(tables.$columns)) { - if (meta.isPrivate || meta.serverGenerated || meta.primaryKey) continue; - let field = zodForColumn(meta); + if (meta.serverGenerated || meta.primaryKey) continue; + let field = columnValueSchema(meta); if (!meta.notNull) field = field.nullable(); shape[meta.columnName] = field.optional(); } - return z.object(shape); + return z.strictObject(shape); } diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index eac0b27b9..548151ed5 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -38,6 +38,7 @@ export { } from "./connectors/lakebase"; export { getExecutionContext } from "./context"; export { createApp } from "./core"; +export type { DatabaseRegistry } from "./database/contract"; // Errors export { AppKitError, diff --git a/packages/appkit/src/plugins/beta-exports.generated.ts b/packages/appkit/src/plugins/beta-exports.generated.ts index 7e556ebd9..10982785b 100644 --- a/packages/appkit/src/plugins/beta-exports.generated.ts +++ b/packages/appkit/src/plugins/beta-exports.generated.ts @@ -7,3 +7,4 @@ export { agents } from "./agents"; export { aiSearch } from "./ai-search"; +export { database } from "./database"; diff --git a/packages/appkit/src/plugins/database/crud/codecs.ts b/packages/appkit/src/plugins/database/crud/codecs.ts new file mode 100644 index 000000000..a474859fc --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/codecs.ts @@ -0,0 +1,118 @@ +import { DatabasePluginError } from "../../../database/errors"; +import type { ScalarValue } from "../../../database/runtime"; +import type { ColumnMeta } from "../../../database/schema-builder"; +import { columnValueSchema } from "../../../database/schema-builder/validators"; + +/** JSON-representable value; every generated response is built from these. */ +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +/** Both wire directions for one column, compiled once per exposed table. */ +export interface CompiledColumn { + readonly meta: ColumnMeta; + /** Untrusted wire value to canonical runtime value; `undefined` when invalid. */ + decode(raw: unknown): ScalarValue | undefined; + /** Trusted runtime value to its deterministic JSON form. */ + encode(value: unknown): JsonValue; +} + +/** Decimal integer with no sign padding, leading zeros, or exponent. */ +const DECIMAL_INT = /^-?(0|[1-9]\d*)$/; + +/** + * Map a wire value onto the runtime representation the schema validators and + * Drizzle columns expect. Only path segments and JSON scalars reach this, so + * the accepted shapes stay exact rather than coercing whatever parses. + */ +function toRuntimeValue( + kind: ColumnMeta["kind"], + raw: unknown, +): ScalarValue | undefined { + switch (kind) { + case "number": + if (typeof raw === "number") return raw; + return typeof raw === "string" && DECIMAL_INT.test(raw) + ? Number(raw) + : undefined; + case "bigint": + if (typeof raw === "string" && DECIMAL_INT.test(raw)) return BigInt(raw); + return typeof raw === "number" && Number.isSafeInteger(raw) + ? BigInt(raw) + : undefined; + case "boolean": + return typeof raw === "boolean" ? raw : undefined; + case "string": + case "uuid": + case "enum": + case "date": + return typeof raw === "string" ? raw : undefined; + default: + return undefined; + } +} + +function encodeValue(meta: ColumnMeta, value: unknown): JsonValue { + if (value === null || value === undefined) return null; + switch (meta.kind) { + case "bigint": + // JSON cannot carry a bigint, and a large id must not lose precision. + if (typeof value === "bigint") return value.toString(); + if (typeof value === "number" && Number.isSafeInteger(value)) { + return value.toString(); + } + break; + case "number": + if (typeof value === "number" && Number.isFinite(value)) return value; + break; + case "boolean": + if (typeof value === "boolean") return value; + break; + case "string": + case "uuid": + case "enum": + case "date": + if (typeof value === "string") return value; + break; + case "json": + if (isJsonValue(value)) return value; + break; + case "unknown": + break; + } + // The driver produced a value this column contract cannot describe. + throw new DatabasePluginError("INTERNAL", "read"); +} + +function isJsonValue(value: unknown): value is JsonValue { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" + ) { + return true; + } + if (typeof value === "number") return Number.isFinite(value); + if (Array.isArray(value)) return true; + if (typeof value !== "object") return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** Compile the wire codecs for one column from its finalized metadata. */ +export function compileColumn(meta: ColumnMeta): CompiledColumn { + const schema = columnValueSchema(meta); + return { + meta, + decode: (raw) => { + const candidate = toRuntimeValue(meta.kind, raw); + if (candidate === undefined) return undefined; + return schema.safeParse(candidate).success ? candidate : undefined; + }, + encode: (value) => encodeValue(meta, value), + }; +} diff --git a/packages/appkit/src/plugins/database/crud/contract.ts b/packages/appkit/src/plugins/database/crud/contract.ts new file mode 100644 index 000000000..e0bf5a060 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/contract.ts @@ -0,0 +1,243 @@ +import { + DatabasePluginError, + invalidDatabaseInput, +} from "../../../database/errors"; +import type { IdValue, 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"; +import { type CompiledColumn, compileColumn, type JsonValue } from "./codecs"; + +/** One relation edge wired to the contract of its target table. */ +export interface CrudRelation { + readonly cardinality: "toOne" | "toMany"; + readonly target: CrudTable; +} + +/** Private HTTP contract compiled once for one explicitly exposed table. */ +export interface CrudTable { + readonly name: string; + readonly primaryKey?: CompiledColumn; + readonly columns: ReadonlyMap; + /** Public columns a request may project. */ + readonly selectable: ReadonlySet; + /** Public columns a request may filter or order by. */ + readonly queryable: ReadonlySet; + readonly relations: ReadonlyMap; + decodeId(raw: string): IdValue; + projectPublicRow(row: Row): JsonValue; + sanitizeSerializedRow(row: unknown): JsonValue; +} + +type MutableCrudTable = Omit & { + readonly relations: Map; +}; + +interface SanitizeState { + nodes: number; + readonly ancestors: Set; +} + +/** A bare object literal; a `Date`, class instance, or `Map` is not JSON. */ +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** A serializer that breaks its contract is trusted code failing, not input. */ +function serializerFault(): never { + throw new DatabasePluginError("INTERNAL", "read"); +} + +/** Charge one value against the output budget before descending into it. */ +function countNode(depth: number, state: SanitizeState): void { + state.nodes += 1; + if (state.nodes > MAX_SERIALIZED_NODES || depth > MAX_SERIALIZED_DEPTH) { + serializerFault(); + } +} + +/** Walk a container while its ancestors are tracked, so a cycle cannot pass. */ +function enterObject( + value: object, + state: SanitizeState, + visit: () => T, +): T { + if (state.ancestors.has(value)) serializerFault(); + state.ancestors.add(value); + try { + return visit(); + } finally { + state.ancestors.delete(value); + } +} + +/** Accept a serializer's own added value only where it is already JSON. */ +function sanitizeJson( + value: unknown, + depth: number, + state: SanitizeState, +): JsonValue { + countNode(depth, state); + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" + ) { + return value; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) serializerFault(); + return value; + } + if (Array.isArray(value)) { + return enterObject(value, state, () => + value.map((item) => sanitizeJson(item, depth + 1, state)), + ); + } + if (!isPlainObject(value)) serializerFault(); + return enterObject(value, state, () => { + // A null prototype keeps `__proto__` an ordinary key instead of a setter. + const out: Record = Object.create(null); + for (const [key, child] of Object.entries(value)) { + if (child === undefined) continue; + out[key] = sanitizeJson(child, depth + 1, state); + } + return out; + }); +} + +/** Keep an included row under its own table's policy, one row or many. */ +function sanitizeRelation( + target: CrudTable, + value: unknown, + depth: number, + state: SanitizeState, +): JsonValue { + if (value === null) return null; + if (!Array.isArray(value)) return sanitizeRow(target, value, depth, state); + countNode(depth, state); + return enterObject(value, state, () => + value.map((row) => sanitizeRow(target, row, depth + 1, state)), + ); +} + +/** Re-apply the private-column policy wherever the output stays contracted. */ +function sanitizeRow( + table: CrudTable, + row: unknown, + depth: number, + state: SanitizeState, +): JsonValue { + countNode(depth, state); + if (!isPlainObject(row)) serializerFault(); + return enterObject(row, state, () => { + const out: Record = {}; + for (const [key, child] of Object.entries(row)) { + if (child === undefined) continue; + if (table.columns.get(key)?.meta.isPrivate) continue; + const relation = table.relations.get(key); + out[key] = relation + ? sanitizeRelation(relation.target, child, depth + 1, state) + : sanitizeJson(child, depth + 1, state); + } + return out; + }); +} + +/** 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; + return Array.isArray(value) + ? value.map((row) => target.projectPublicRow(row as Row)) + : target.projectPublicRow(value as Row); +} + +/** Build the public JSON for one driver row, dropping anything uncontracted. */ +function projectRow(table: CrudTable, row: Row): JsonValue { + const out: Record = {}; + for (const [key, value] of Object.entries(row)) { + const column = table.columns.get(key); + if (column) { + // Whatever the driver returned, only public contracted columns ship. + if (!column.meta.isPrivate) out[key] = column.encode(value); + continue; + } + const relation = table.relations.get(key); + if (relation) out[key] = projectRelation(relation.target, value); + } + return out; +} + +/** Compile one table's allowlists and codecs from its finalized metadata. */ +function compileTable(table: AppKitTable): MutableCrudTable { + const columns = new Map(); + const selectable = new Set(); + const queryable = new Set(); + let primaryKey: CompiledColumn | undefined; + + for (const meta of Object.values(table.$columns)) { + const column = compileColumn(meta); + columns.set(meta.columnName, column); + if (meta.primaryKey) primaryKey = column; + if (meta.isPrivate) continue; + selectable.add(meta.columnName); + if (filterOperatorsForKind(meta.kind).length > 0) { + queryable.add(meta.columnName); + } + } + + const compiled: MutableCrudTable = { + name: table.$name, + primaryKey, + columns, + selectable, + queryable, + 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() }), + }; + return compiled; +} + +/** + * Compile the HTTP contract for every exposed table and wire the relations + * they share. A relation whose target is not exposed stays unreachable, so + * enabling one table never widens another table's public surface. + */ +export function compileCrudTables( + tables: Record, +): Map { + const compiled = new Map(); + for (const table of Object.values(tables)) { + compiled.set(table.$name, compileTable(table)); + } + for (const table of Object.values(tables)) { + const entry = compiled.get(table.$name); + for (const relation of table.$relations) { + const target = compiled.get(relation.targetTable); + if (!entry || !target) continue; + entry.relations.set(relation.name, { + cardinality: relation.cardinality, + target, + }); + } + } + return compiled as Map; +} diff --git a/packages/appkit/src/plugins/database/crud/exposure.ts b/packages/appkit/src/plugins/database/crud/exposure.ts new file mode 100644 index 000000000..c77834a83 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/exposure.ts @@ -0,0 +1,49 @@ +import { databaseSetupFailed } from "../../../database/errors"; + +/** A table name also becomes a URL path segment, so keep it unambiguous. */ +const ROUTABLE_TABLE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; + +/** Refuse names that cannot address exactly one table over HTTP. */ +function assertRoutable(names: readonly string[]): void { + const lowercased = new Set(); + for (const name of names) { + // Express matches paths case-insensitively, so near-duplicates would alias. + if (!ROUTABLE_TABLE.test(name) || lowercased.has(name.toLowerCase())) { + throw databaseSetupFailed(); + } + lowercased.add(name.toLowerCase()); + } +} + +/** + * Resolve the tables whose generated reads are explicitly turned on. The + * exposure value arrives untyped because a finalized schema widens its table + * names to `string`, so every name is re-checked against the declared schema. + */ +export function resolveExposedTables( + exposure: unknown, + declared: readonly string[], +): string[] { + if (exposure === undefined || exposure === false) return []; + if (exposure === true) { + assertRoutable(declared); + return [...declared]; + } + if (typeof exposure !== "object" || exposure === null) { + throw databaseSetupFailed(); + } + const requested = (exposure as { tables?: unknown }).tables; + if (!Array.isArray(requested)) throw databaseSetupFailed(); + + const names: string[] = []; + for (const name of requested) { + // Unknown and duplicate names are configuration bugs, not empty routes. + if (typeof name !== "string" || !declared.includes(name)) { + throw databaseSetupFailed(); + } + if (names.includes(name)) throw databaseSetupFailed(); + names.push(name); + } + assertRoutable(names); + return names; +} diff --git a/packages/appkit/src/plugins/database/crud/query.ts b/packages/appkit/src/plugins/database/crud/query.ts new file mode 100644 index 000000000..80394d0ab --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/query.ts @@ -0,0 +1,472 @@ +import { + DEFAULT_LIMIT, + IN_CAP, + isFilterOperator, + MAX_INCLUDE_DEPTH, + MAX_INCLUDE_NODES, + MAX_INCLUDES, + MAX_LIMIT, +} from "../../../database/contract"; +import { invalidDatabaseInput } from "../../../database/errors"; +import type { + IncludeOptions, + IncludeSpec, + OrderDirection, + OrderSpec, + ScalarValue, + WhereClause, + WhereValue, +} from "../../../database/runtime"; +import { filterOperatorsForKind } from "../../../database/schema-builder/types"; +import { + MAX_GROUP_ITEMS, + MAX_MATERIALIZED_NODES, + MAX_OFFSET, + MAX_ORDER_FIELDS, + MAX_QUERY_BYTES, + MAX_WHERE_CONDITIONS, + MAX_WHERE_DEPTH, +} from "../defaults"; +import type { CompiledColumn } from "./codecs"; +import type { CrudTable } from "./contract"; + +/** One decoded, budget-checked page request. */ +interface DecodedListQuery { + readonly where?: WhereClause; + readonly order?: OrderSpec; + readonly select?: string[]; + readonly include?: IncludeSpec; + readonly limit: number; + readonly offset: number; +} + +/** One decoded, budget-checked single-row request. */ +interface DecodedDetailQuery { + readonly select?: string[]; + readonly include?: IncludeSpec; +} + +const LIST_PARAMS = new Set([ + "where", + "order", + "select", + "include", + "limit", + "offset", +]); +const DETAIL_PARAMS = new Set(["select", "include"]); +const CANONICAL_INT = /^-?(0|[1-9]\d*)$/; + +/** + * Reject one query parameter. `parameter` is always a fixed name and `reason` + * a fixed sentence, so a rejection can never echo caller-supplied text back. + */ +function reject(parameter: string, reason: string): never { + throw invalidDatabaseInput([parameter], reason); +} + +/** A decoded JSON object; arrays and null are separate wire shapes here. */ +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Read known, single-occurrence parameters from the raw query string. */ +function parseParams( + rawQuery: string, + allowed: ReadonlySet, +): Map { + if (Buffer.byteLength(rawQuery, "utf8") > MAX_QUERY_BYTES) { + reject("query", "Query string exceeds the maximum size"); + } + const params = new Map(); + for (const [key, value] of new URLSearchParams(rawQuery)) { + if (!allowed.has(key) || params.has(key)) { + reject("query", "Unknown or repeated query parameter"); + } + params.set(key, value); + } + return params; +} + +/** Parse one structured parameter without leaking the parser's diagnostics. */ +function parseJson(raw: string, parameter: string): unknown { + try { + return JSON.parse(raw); + } catch { + reject(parameter, "Expected JSON"); + } +} + +/** Accept one bounded integer, rejecting the forms `Number` would coerce. */ +function decodeIntParam( + raw: string | undefined, + parameter: string, + fallback: number, + max: number, +): number { + if (raw === undefined) return fallback; + if (!CANONICAL_INT.test(raw)) { + reject(parameter, "Expected a canonical decimal integer"); + } + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < 0 || value > max) { + reject(parameter, `Must be between 0 and ${max}`); + } + return value; +} + +/** Narrow a projection to the table's public columns. */ +function decodeSelect( + table: CrudTable, + value: unknown, + parameter: string, +): string[] { + if (!Array.isArray(value) || value.length === 0) { + reject(parameter, "Expected a non-empty array of public column names"); + } + for (const name of value) { + if (typeof name !== "string" || !table.selectable.has(name)) { + reject(parameter, "Names an unknown or private column"); + } + } + return value as string[]; +} + +/** Order only by columns the table can sort on, in a declared direction. */ +function decodeOrder( + table: CrudTable, + value: unknown, + parameter: string, +): OrderSpec { + if (!isPlainObject(value)) { + reject(parameter, "Expected an object of column directions"); + } + const entries = Object.entries(value); + if (entries.length === 0 || entries.length > MAX_ORDER_FIELDS) { + reject(parameter, `Expected 1 to ${MAX_ORDER_FIELDS} columns`); + } + const order: Record = {}; + for (const [column, direction] of entries) { + if (!table.queryable.has(column)) { + reject(parameter, "Names an unknown or unorderable column"); + } + if (direction !== "asc" && direction !== "desc") { + reject(parameter, "Expected a direction of asc or desc"); + } + order[column] = direction; + } + return order; +} + +/** Move one filter operand onto its column's canonical runtime value. */ +function decodeOperand( + column: CompiledColumn, + raw: unknown, + parameter: string, +): ScalarValue { + const value = column.decode(raw); + if (value === undefined) + reject(parameter, "Operand does not match its column type"); + return value; +} + +/** + * Decode the predicate on one column against the operator matrix its kind + * allows, counting conditions so a filter cannot grow without bound. + */ +function decodeCondition( + column: CompiledColumn, + value: unknown, + parameter: string, + state: { conditions: number }, +): WhereValue { + const countCondition = () => { + state.conditions += 1; + if (state.conditions > MAX_WHERE_CONDITIONS) { + reject(parameter, `Expected at most ${MAX_WHERE_CONDITIONS} conditions`); + } + }; + + if (value === null) { + // SQL three-valued matching stays explicit; a bare null is ambiguous. + reject(parameter, "Match a null column with is: null"); + } + if (!isPlainObject(value)) { + if (Array.isArray(value)) { + reject(parameter, "Expected a scalar or an operator object"); + } + countCondition(); + return decodeOperand(column, value, parameter); + } + + const nullable = !column.meta.notNull; + const supported = filterOperatorsForKind(column.meta.kind); + const operators: Record = {}; + const entries = Object.entries(value); + if (entries.length === 0) reject(parameter, "Filter cannot be empty"); + + for (const [operator, operand] of entries) { + countCondition(); + if (operator === "is") { + if (operand !== null || !nullable) { + reject( + parameter, + "The is operator accepts only null on a nullable column", + ); + } + operators.is = null; + continue; + } + if (!isFilterOperator(operator) || !supported.includes(operator)) { + reject(parameter, "Operator is not supported for this column"); + } + if (operator !== "in") { + operators[operator] = decodeOperand(column, operand, parameter); + continue; + } + if (!Array.isArray(operand) || operand.length > IN_CAP) { + reject(parameter, `Expected an array of at most ${IN_CAP} values`); + } + operators.in = operand.map((item) => { + if (item === null) + reject(parameter, "The in operator does not accept null"); + return decodeOperand(column, item, parameter); + }); + } + return operators as WhereValue; +} + +/** Decode one filter level, recursing through bounded `and`/`or` groups. */ +function decodeWhere( + table: CrudTable, + node: unknown, + depth: number, + parameter: string, + state: { conditions: number }, +): WhereClause { + if (depth > MAX_WHERE_DEPTH) { + reject(parameter, `Expected at most ${MAX_WHERE_DEPTH} nesting levels`); + } + if (!isPlainObject(node)) reject(parameter, "Expected an object"); + + const clause: Record = {}; + for (const [key, value] of Object.entries(node)) { + if (key === "and" || key === "or") { + if ( + !Array.isArray(value) || + value.length === 0 || + value.length > MAX_GROUP_ITEMS + ) { + reject(parameter, `Expected 1 to ${MAX_GROUP_ITEMS} group members`); + } + clause[key] = value.map((member) => + decodeWhere(table, member, depth + 1, parameter, state), + ); + continue; + } + // Relations are absent from `queryable`, so relation predicates fail here. + const column = table.queryable.has(key) + ? table.columns.get(key) + : undefined; + if (!column) reject(parameter, "Names an unknown or unfilterable column"); + clause[key] = decodeCondition(column, value, parameter, state); + } + return clause; +} + +/** + * Decode one relation's options against the target table, not the parent, and + * give every to-many edge a limit so an unqualified include stays bounded. + */ +function decodeIncludeOptions( + target: CrudTable, + value: unknown, + depth: number, + toMany: boolean, + parameter: string, +): boolean | IncludeOptions { + if (value === true) return toMany ? { limit: DEFAULT_LIMIT } : true; + if (!isPlainObject(value)) { + reject(parameter, "Expected true or an options object"); + } + + const options: { + select?: string[]; + where?: WhereClause; + order?: OrderSpec; + limit?: number; + include?: IncludeSpec; + } = {}; + for (const [key, inner] of Object.entries(value)) { + switch (key) { + case "select": + options.select = decodeSelect(target, inner, parameter); + break; + case "where": + options.where = decodeWhere(target, inner, 1, parameter, { + conditions: 0, + }); + break; + case "order": + options.order = decodeOrder(target, inner, parameter); + break; + case "limit": + if (!toMany) reject(parameter, "Only to-many relations accept a limit"); + if ( + typeof inner !== "number" || + !Number.isSafeInteger(inner) || + inner < 0 || + inner > MAX_LIMIT + ) { + reject(parameter, `Expected a limit between 0 and ${MAX_LIMIT}`); + } + options.limit = inner; + break; + case "include": + options.include = decodeInclude(target, inner, depth + 1, parameter); + break; + default: + reject(parameter, "Unsupported relation option"); + } + } + if (toMany && options.limit === undefined) options.limit = DEFAULT_LIMIT; + return options; +} + +/** Decode one include level; a relation to an unexposed table has no edge. */ +function decodeInclude( + table: CrudTable, + value: unknown, + depth: number, + parameter: string, +): IncludeSpec { + if (depth > MAX_INCLUDE_DEPTH) { + reject(parameter, `Expected at most ${MAX_INCLUDE_DEPTH} relation edges`); + } + if (!isPlainObject(value)) { + reject(parameter, "Expected an object of relation names"); + } + const entries = Object.entries(value); + if (entries.length > MAX_INCLUDES) { + reject(parameter, `Expected at most ${MAX_INCLUDES} relations per level`); + } + + const include: Record = {}; + for (const [name, options] of entries) { + const relation = table.relations.get(name); + if (!relation) reject(parameter, "Names an unknown or unexposed relation"); + include[name] = decodeIncludeOptions( + relation.target, + options, + depth, + relation.cardinality === "toMany", + parameter, + ); + } + return include; +} + +/** + * Rows one include tree materializes per parent row, counting relation nodes + * on the way. Every term is a non-negative integer bounded by `MAX_LIMIT` and + * both running totals are checked after each addition, so neither can overflow. + */ +function measureInclude( + table: CrudTable, + include: IncludeSpec | undefined, + budget: { nodes: number }, +): number { + let rows = 1; + if (!include) return rows; + for (const [name, value] of Object.entries(include)) { + const relation = table.relations.get(name); + if (!relation) continue; + budget.nodes += 1; + if (budget.nodes > MAX_INCLUDE_NODES) { + reject("include", `Expected at most ${MAX_INCLUDE_NODES} relations`); + } + const options = value === true ? undefined : (value as IncludeOptions); + const child = measureInclude(relation.target, options?.include, budget); + rows += + relation.cardinality === "toMany" + ? (options?.limit ?? DEFAULT_LIMIT) * child + : child; + if (rows > MAX_MATERIALIZED_NODES) { + reject("include", "Relation fan-out exceeds the read budget"); + } + } + return rows; +} + +/** Reject an include tree whose cost is only visible once it is assembled. */ +function assertReadBudget( + table: CrudTable, + include: IncludeSpec | undefined, + rootRows: number, +): void { + const rows = measureInclude(table, include, { nodes: 0 }); + if (rootRows * rows > MAX_MATERIALIZED_NODES) { + reject("include", "Relation fan-out exceeds the read budget"); + } +} + +/** Decode the projection parameters both reads share. */ +function decodeProjection( + table: CrudTable, + params: ReadonlyMap, +): DecodedDetailQuery { + const rawSelect = params.get("select"); + const rawInclude = params.get("include"); + return { + select: + rawSelect === undefined + ? undefined + : decodeSelect(table, parseJson(rawSelect, "select"), "select"), + include: + rawInclude === undefined + ? undefined + : decodeInclude(table, parseJson(rawInclude, "include"), 1, "include"), + }; +} + +/** Decode `GET /:table` and reject it before any database work is scheduled. */ +export function decodeListQuery( + table: CrudTable, + rawQuery: string, +): DecodedListQuery { + const params = parseParams(rawQuery, LIST_PARAMS); + const rawWhere = params.get("where"); + const rawOrder = params.get("order"); + + const where = + rawWhere === undefined + ? undefined + : decodeWhere(table, parseJson(rawWhere, "where"), 1, "where", { + conditions: 0, + }); + const order = + rawOrder === undefined + ? undefined + : decodeOrder(table, parseJson(rawOrder, "order"), "order"); + const { select, include } = decodeProjection(table, params); + const limit = decodeIntParam( + params.get("limit"), + "limit", + DEFAULT_LIMIT, + MAX_LIMIT, + ); + const offset = decodeIntParam(params.get("offset"), "offset", 0, MAX_OFFSET); + + assertReadBudget(table, include, limit); + return { where, order, select, include, limit, offset }; +} + +/** Decode `GET /:table/:id`, which supports projection and includes only. */ +export function decodeDetailQuery( + table: CrudTable, + rawQuery: string, +): DecodedDetailQuery { + const decoded = decodeProjection(table, parseParams(rawQuery, DETAIL_PARAMS)); + assertReadBudget(table, decoded.include, 1); + return decoded; +} diff --git a/packages/appkit/src/plugins/database/crud/routes.ts b/packages/appkit/src/plugins/database/crud/routes.ts new file mode 100644 index 000000000..41829a974 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/routes.ts @@ -0,0 +1,181 @@ +import type { Request, Response } from "express"; +import { + classifyDatabaseError, + type DatabaseErrorDetail, + DatabasePluginError, + invalidDatabaseInput, +} from "../../../database/errors"; +import type { + IdValue, + IncludeSpec, + OrderSpec, + 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; + toArray(): Promise; + find(id: IdValue): Promise; +} + +/** Everything one table's generated reads need from the plugin instance. */ +export interface ReadRouteDeps { + readonly table: CrudTable; + /** Resolved per request so a draining plugin cannot serve a stale client. */ + entity(): CrudReadEntity; + readonly serialize?: ReadSerializer; + runRouteSpan( + operation: "list" | "detail", + 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"; +} + +/** Express normalizes `req.query`; the decoders need the untouched string. */ +function rawQuery(req: Request): string { + const url = req.originalUrl ?? req.url; + const start = url.indexOf("?"); + return start === -1 ? "" : url.slice(start + 1); +} + +/** + * Append the primary key so equal sort keys cannot reshuffle between pages. + * A keyless table has no unique tie-breaker to append, so it must order itself + * and its pages stay stable only while no concurrent write reorders them. + */ +function stableOrder( + primaryKey: string | undefined, + order: OrderSpec | undefined, +): OrderSpec { + if (primaryKey) + return { ...order, [primaryKey]: order?.[primaryKey] ?? "asc" }; + if (!order) { + throw invalidDatabaseInput( + ["order"], + "A table without a primary key requires an explicit order", + ); + } + return order; +} + +function serializeRow( + deps: ReadRouteDeps, + operation: "list" | "detail", + row: Row, +): JsonValue { + const projected = deps.table.projectPublicRow(row); + if (!deps.serialize) return projected; + const shaped = deps.serialize(projected as Record, { + entity: deps.table.name, + operation, + }); + return deps.table.sanitizeSerializedRow(shaped); +} + +/** + * 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. */ +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"); + } + 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)); +} + +/** `GET /:table` — one bounded page in the `{ items, limit, offset }` envelope. */ +export function createListHandler(deps: ReadRouteDeps): ReadHandler { + const primaryKey = deps.table.primaryKey?.meta.columnName; + const route = `/${deps.table.name}`; + + return async (req, res) => { + try { + await deps.runRouteSpan("list", route, async () => { + const decoded = decodeListQuery(deps.table, rawQuery(req)); + let query = deps + .entity() + .order(stableOrder(primaryKey, decoded.order)) + .limit(decoded.limit) + .offset(decoded.offset); + if (decoded.where) query = query.where(decoded.where); + if (decoded.select) query = query.select(decoded.select); + if (decoded.include) query = query.include(decoded.include); + + const rows = await query.toArray(); + sendJson(res, { + items: rows.map((row) => serializeRow(deps, "list", row)), + limit: decoded.limit, + offset: decoded.offset, + }); + }); + } catch (error) { + writeError(res, error); + } + }; +} + +/** `GET /:table/:id` — one public row, or 404 when nothing matches. */ +export function createDetailHandler(deps: ReadRouteDeps): ReadHandler { + 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 decoded = decodeDetailQuery(deps.table, rawQuery(req)); + let query = deps.entity(); + if (decoded.select) query = query.select(decoded.select); + if (decoded.include) query = query.include(decoded.include); + + const row = await query.find(id); + if (row === null) throw new DatabasePluginError("NOT_FOUND", "read"); + sendJson(res, serializeRow(deps, "detail", row)); + }); + } catch (error) { + writeError(res, error); + } + }; +} diff --git a/packages/appkit/src/plugins/database/crud/tests/codecs.test.ts b/packages/appkit/src/plugins/database/crud/tests/codecs.test.ts new file mode 100644 index 000000000..09423c808 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/tests/codecs.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { DatabasePluginError } from "../../../../database/errors"; +import { + bigint, + boolean, + defineSchema, + enumColumn, + id, + integer, + jsonb, + text, + timestamp, + uuid, + varchar, +} from "../../../../database/schema-builder"; +import { compileColumn } from "../codecs"; + +const schema = defineSchema((builder) => { + const things = builder.table("things", { + id: id(), + name: text(), + label: varchar(4), + count: integer(), + total: bigint(), + active: boolean(), + external: uuid(), + status: enumColumn("codec_status", ["active", "disabled"]), + createdAt: timestamp(), + payload: jsonb(), + }); + return { things }; +}); + +const columns = schema.$tables.things.$columns; +const column = (name: keyof typeof columns) => compileColumn(columns[name]); +const UUID = "123e4567-e89b-12d3-a456-426614174000"; + +describe("compileColumn decode", () => { + it("accepts the canonical wire form of every supported kind", () => { + expect(column("count").decode(7)).toBe(7); + expect(column("count").decode("7")).toBe(7); + expect(column("total").decode("9007199254740993")).toBe(9007199254740993n); + expect(column("total").decode(12)).toBe(12n); + expect(column("active").decode(true)).toBe(true); + expect(column("name").decode("Ada")).toBe("Ada"); + expect(column("external").decode(UUID)).toBe(UUID); + expect(column("status").decode("active")).toBe("active"); + expect(column("createdAt").decode("2020-01-01T00:00:00Z")).toBe( + "2020-01-01T00:00:00Z", + ); + }); + + it("rejects coercible input instead of guessing the intended value", () => { + expect(column("active").decode("true")).toBeUndefined(); + expect(column("count").decode("7.0")).toBeUndefined(); + expect(column("count").decode("007")).toBeUndefined(); + expect(column("count").decode(7.5)).toBeUndefined(); + expect(column("count").decode(2_147_483_648)).toBeUndefined(); + expect(column("total").decode("1e3")).toBeUndefined(); + expect(column("total").decode(1.5)).toBeUndefined(); + expect(column("name").decode(7)).toBeUndefined(); + expect(column("label").decode("toolong")).toBeUndefined(); + expect(column("external").decode("not-a-uuid")).toBeUndefined(); + expect(column("status").decode("unknown")).toBeUndefined(); + expect(column("createdAt").decode("yesterday")).toBeUndefined(); + expect(column("createdAt").decode(0)).toBeUndefined(); + }); + + it("keeps unfilterable kinds undecodable", () => { + expect(column("payload").decode({ any: "value" })).toBeUndefined(); + expect(column("name").decode(null)).toBeUndefined(); + expect(column("name").decode(undefined)).toBeUndefined(); + }); +}); + +describe("compileColumn encode", () => { + it("emits one deterministic JSON form per kind", () => { + expect(column("total").encode(9007199254740993n)).toBe("9007199254740993"); + expect(column("total").encode(12)).toBe("12"); + expect(column("count").encode(7)).toBe(7); + expect(column("active").encode(false)).toBe(false); + expect(column("createdAt").encode("2020-01-01T00:00:00Z")).toBe( + "2020-01-01T00:00:00Z", + ); + expect(column("payload").encode({ nested: [1, "two"] })).toEqual({ + nested: [1, "two"], + }); + expect(column("name").encode(null)).toBeNull(); + expect(column("name").encode(undefined)).toBeNull(); + }); + + it("fails closed when a driver value contradicts its column", () => { + expect(() => column("name").encode(7)).toThrow(DatabasePluginError); + expect(() => column("createdAt").encode(new Date())).toThrow( + DatabasePluginError, + ); + expect(() => column("count").encode(Number.NaN)).toThrow( + DatabasePluginError, + ); + expect(() => column("payload").encode(new Map())).toThrow( + DatabasePluginError, + ); + expect(() => column("name").encode(7)).toThrow( + expect.objectContaining({ category: "INTERNAL" }), + ); + }); +}); diff --git a/packages/appkit/src/plugins/database/crud/tests/contract.test.ts b/packages/appkit/src/plugins/database/crud/tests/contract.test.ts new file mode 100644 index 000000000..ad362c80f --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/tests/contract.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { DatabasePluginError } from "../../../../database/errors"; +import { + bigid, + defineSchema, + fk, + id, + jsonb, + text, +} from "../../../../database/schema-builder"; +import { MAX_SERIALIZED_DEPTH } from "../../defaults"; +import { compileCrudTables } from "../contract"; + +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(), + draft: text().private(), + }); + const ledger = builder.table("ledger", { id: bigid(), memo: text() }); + return { users, notes, ledger }; +}); + +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 +>; + +describe("compileCrudTables", () => { + it("allowlists public columns and excludes unfilterable kinds", () => { + expect([...users.selectable]).toEqual(["id", "name", "profile"]); + expect([...users.queryable]).toEqual(["id", "name"]); + expect(users.columns.has("token")).toBe(true); + expect(users.primaryKey?.meta.columnName).toBe("id"); + }); + + it("wires relations only between exposed tables", () => { + expect(users.relations.get("notes")).toMatchObject({ + cardinality: "toMany", + target: notes, + }); + expect(notes.relations.get("users")).toMatchObject({ + cardinality: "toOne", + target: users, + }); + 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("projectPublicRow", () => { + it("drops private columns at every level and encodes relations", () => { + const projected = users.projectPublicRow({ + id: 1, + name: "Ada", + token: "secret", + profile: { theme: "dark" }, + notes: [{ id: 2, body: "hello", draft: "hidden" }], + unknown: "ignored", + }); + expect(projected).toEqual({ + id: 1, + name: "Ada", + profile: { theme: "dark" }, + notes: [{ id: 2, body: "hello" }], + }); + }); + + it("represents an absent to-one relation as null", () => { + expect(notes.projectPublicRow({ id: 1, users: null })).toEqual({ + id: 1, + users: null, + }); + }); +}); + +describe("sanitizeSerializedRow", () => { + it("re-applies the private-column policy to serializer output", () => { + const sanitized = users.sanitizeSerializedRow({ + id: 1, + token: "leaked", + computed: { label: "Ada", tags: ["a", "b"] }, + skipped: undefined, + notes: [{ id: 2, draft: "leaked", body: "hello" }], + }); + expect(sanitized).toEqual({ + id: 1, + computed: { label: "Ada", tags: ["a", "b"] }, + notes: [{ id: 2, body: "hello" }], + }); + }); + + it("treats a broken serializer as an internal fault", () => { + const cyclic: Record = { id: 1 }; + cyclic.self = cyclic; + let deep: Record = {}; + for (let level = 0; level <= MAX_SERIALIZED_DEPTH; level += 1) { + deep = { deep }; + } + for (const broken of [ + cyclic, + deep, + "not-an-object", + { id: Number.POSITIVE_INFINITY }, + { at: new Date() }, + ]) { + expect(() => users.sanitizeSerializedRow(broken)).toThrow( + expect.objectContaining({ category: "INTERNAL" }), + ); + } + }); + + it("carries a __proto__ key as data rather than dropping it", () => { + const sanitized = users.sanitizeSerializedRow({ + id: 1, + computed: JSON.parse('{"__proto__":{"owned":true},"keep":1}'), + }) as { computed: unknown }; + + expect(JSON.stringify(sanitized.computed)).toBe( + '{"__proto__":{"owned":true},"keep":1}', + ); + expect(({} as Record).owned).toBeUndefined(); + }); +}); diff --git a/packages/appkit/src/plugins/database/crud/tests/query.test.ts b/packages/appkit/src/plugins/database/crud/tests/query.test.ts new file mode 100644 index 000000000..f0ddad520 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/tests/query.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_LIMIT, + IN_CAP, + MAX_LIMIT, +} from "../../../../database/contract"; +import { DatabasePluginError } from "../../../../database/errors"; +import { + boolean, + type ColumnRef, + defineSchema, + fk, + id, + integer, + jsonb, + type SchemaBuilderContext, + text, + timestamp, +} from "../../../../database/schema-builder"; +import { MAX_OFFSET, MAX_QUERY_BYTES } from "../../defaults"; +import { type CrudTable, compileCrudTables } from "../contract"; +import { decodeDetailQuery, decodeListQuery } from "../query"; + +const schema = defineSchema((builder) => { + const users = builder.table("users", { + id: id(), + name: text(), + rank: integer(), + active: boolean().notNull(), + createdAt: timestamp(), + profile: jsonb(), + token: text().private(), + }); + const notes = builder.table("notes", { + id: id(), + authorId: fk(() => users.id), + body: text(), + }); + return { users, notes }; +}); + +const tables = compileCrudTables(schema.$tables); +const users = tables.get("users") as CrudTable; + +function query(params: Record): string { + const encoded = Object.entries(params).map( + ([key, value]): [string, string] => [ + key, + typeof value === "string" ? value : JSON.stringify(value), + ], + ); + return new URLSearchParams(encoded).toString(); +} + +function expectRejected(raw: string): void { + expect(() => decodeListQuery(users, raw)).toThrow(DatabasePluginError); + expect(() => decodeListQuery(users, raw)).toThrow( + expect.objectContaining({ category: "INVALID_REQUEST", statusCode: 400 }), + ); +} + +describe("query parameters", () => { + it("defaults pagination and accepts canonical integers", () => { + expect(decodeListQuery(users, "")).toEqual({ + where: undefined, + order: undefined, + select: undefined, + include: undefined, + limit: DEFAULT_LIMIT, + offset: 0, + }); + expect(decodeListQuery(users, "limit=10&offset=20")).toMatchObject({ + limit: 10, + offset: 20, + }); + }); + + it("rejects unknown, repeated, oversized, and non-canonical parameters", () => { + expectRejected("unknown=1"); + // A generated list is one page query, so there is no total to request. + expectRejected("includeTotal=true"); + expectRejected("limit=1&limit=2"); + expectRejected(`select=${"x".repeat(MAX_QUERY_BYTES)}`); + expectRejected("limit=01"); + expectRejected("limit=-1"); + expectRejected("limit=1.0"); + expectRejected(`limit=${MAX_LIMIT + 1}`); + expectRejected(`offset=${MAX_OFFSET + 1}`); + expectRejected("where=notjson"); + }); + + it("keeps detail requests to projection and includes", () => { + expect(decodeDetailQuery(users, query({ select: ["id"] }))).toEqual({ + select: ["id"], + include: undefined, + }); + expect(() => decodeDetailQuery(users, query({ where: { id: 1 } }))).toThrow( + DatabasePluginError, + ); + expect(() => decodeDetailQuery(users, "limit=1")).toThrow( + DatabasePluginError, + ); + }); +}); + +describe("select and order", () => { + it("accepts public columns only", () => { + expect( + decodeListQuery(users, query({ select: ["id", "name"] })).select, + ).toEqual(["id", "name"]); + expect( + decodeListQuery(users, query({ order: { name: "desc" } })).order, + ).toEqual({ name: "desc" }); + }); + + it("rejects private, unknown, empty, and unorderable selections", () => { + expectRejected(query({ select: ["token"] })); + expectRejected(query({ select: ["missing"] })); + expectRejected(query({ select: [] })); + expectRejected(query({ select: "id" })); + expectRejected(query({ order: { token: "asc" } })); + expectRejected(query({ order: { profile: "asc" } })); + expectRejected(query({ order: { name: "sideways" } })); + expectRejected(query({ order: {} })); + }); +}); + +describe("where", () => { + it("decodes operands through their column codec", () => { + expect( + decodeListQuery(users, query({ where: { rank: "3" } })).where, + ).toEqual({ rank: 3 }); + expect( + decodeListQuery(users, query({ where: { name: { ilike: "a%" } } })).where, + ).toEqual({ name: { ilike: "a%" } }); + expect( + decodeListQuery(users, query({ where: { name: { is: null } } })).where, + ).toEqual({ name: { is: null } }); + expect( + decodeListQuery( + users, + query({ + where: { or: [{ rank: { gte: 1 } }, { id: { in: [1, 2] } }] }, + }), + ).where, + ).toEqual({ or: [{ rank: { gte: 1 } }, { id: { in: [1, 2] } }] }); + }); + + it("applies one operator matrix per column kind", () => { + expectRejected(query({ where: { name: { gt: "a" } } })); + expectRejected(query({ where: { rank: { like: "1" } } })); + expectRejected(query({ where: { profile: { eq: {} } } })); + expectRejected(query({ where: { rank: "abc" } })); + expectRejected(query({ where: { createdAt: { gt: "yesterday" } } })); + }); + + it("keeps null matching explicit and bounds in lists", () => { + // An empty set is a legal filter that the engine renders as no match. + expect( + decodeListQuery(users, query({ where: { id: { in: [] } } })).where, + ).toEqual({ id: { in: [] } }); + expectRejected(query({ where: { name: null } })); + expectRejected(query({ where: { name: { eq: null } } })); + expectRejected(query({ where: { active: { is: null } } })); + expectRejected(query({ where: { name: { in: ["a", null] } } })); + expectRejected( + query({ + where: { id: { in: Array.from({ length: IN_CAP + 1 }, (_, i) => i) } }, + }), + ); + }); + + it("rejects unknown columns, relations, and empty filters", () => { + expectRejected(query({ where: { missing: 1 } })); + expectRejected(query({ where: { token: "secret" } })); + expectRejected(query({ where: { notes: { some: { body: "a" } } } })); + expectRejected(query({ where: { name: {} } })); + expectRejected(query({ where: { and: [] } })); + expectRejected(query({ where: [] })); + }); + + it("bounds nesting, group size, and total conditions", () => { + let deep: Record = { rank: 1 }; + for (let level = 0; level < 5; level += 1) deep = { and: [deep] }; + expectRejected(query({ where: deep })); + expectRejected( + query({ where: { or: Array.from({ length: 21 }, () => ({ rank: 1 })) } }), + ); + expectRejected( + query({ + where: { + and: Array.from({ length: 20 }, () => ({ + rank: { gt: 1, lt: 5, gte: 1 }, + })), + }, + }), + ); + }); +}); + +describe("include", () => { + it("bounds to-many relations and carries a second edge", () => { + expect( + decodeListQuery(users, query({ include: { notes: true } })).include, + ).toEqual({ notes: { limit: DEFAULT_LIMIT } }); + expect( + decodeListQuery( + users, + query({ + include: { + notes: { select: ["body"], limit: 5, include: { users: true } }, + }, + }), + ).include, + ).toEqual({ + notes: { select: ["body"], limit: 5, include: { users: true } }, + }); + }); + + it("rejects a third edge, unknown relations, and unsupported options", () => { + expectRejected( + query({ include: { notes: { include: { users: { include: {} } } } } }), + ); + expectRejected(query({ include: { missing: true } })); + expectRejected(query({ include: { notes: false } })); + expectRejected(query({ include: { notes: { offset: 1 } } })); + expectRejected(query({ include: { notes: { limit: MAX_LIMIT + 1 } } })); + expectRejected(query({ include: { notes: { select: ["missing"] } } })); + }); + + it("limits only to-many relations and scopes options to the target", () => { + const notes = tables.get("notes") as CrudTable; + expect(() => + decodeListQuery(notes, query({ include: { users: { limit: 1 } } })), + ).toThrow(DatabasePluginError); + expect( + decodeListQuery( + notes, + query({ include: { users: { where: { rank: 1 } } } }), + ).include, + ).toEqual({ users: { where: { rank: 1 } } }); + expect(() => + decodeListQuery( + notes, + query({ include: { users: { where: { body: "a" } } } }), + ), + ).toThrow(DatabasePluginError); + }); +}); + +describe("read budget", () => { + it("rejects fan-out before the entity terminal runs", () => { + expectRejected( + query({ limit: "500", include: { notes: { limit: MAX_LIMIT } } }), + ); + expectRejected( + query({ + limit: "50", + include: { notes: { limit: 100, include: { users: true } } }, + }), + ); + expect( + decodeListQuery( + users, + query({ + limit: "49", + include: { notes: { limit: 100, include: { users: true } } }, + }), + ).limit, + ).toBe(49); + }); + + it("bounds the total number of relation nodes in one tree", () => { + const wide = defineSchema((builder: SchemaBuilderContext) => { + const shared = builder.table("shared", { id: id() }); + const leaves: Record = {}; + for (let index = 0; index < 9; index += 1) { + leaves[`t${index}`] = builder.table(`t${index}`, { + id: id(), + sharedId: fk(() => shared.id), + }); + } + const hub = builder.table("hub", { + id: id(), + ...Object.fromEntries( + Object.entries(leaves).map(([name, leaf]) => [ + `${name}Id`, + fk(() => leaf.id), + ]), + ), + }); + return { shared, ...leaves, hub }; + }); + const hub = compileCrudTables(wide.$tables).get("hub") as CrudTable; + const branch = { include: { shared: true, hub: { limit: 0 } } }; + const tree = (count: number) => + query({ + limit: "1", + include: Object.fromEntries( + Array.from({ length: count }, (_, index) => [`t${index}`, branch]), + ), + }); + + // 8 branches materialize 24 relation nodes; a ninth crosses the 25 cap. + expect( + Object.keys(decodeListQuery(hub, tree(8)).include ?? {}), + ).toHaveLength(8); + expect(() => decodeListQuery(hub, tree(9))).toThrow(DatabasePluginError); + }); +}); diff --git a/packages/appkit/src/plugins/database/crud/tests/routes.test.ts b/packages/appkit/src/plugins/database/crud/tests/routes.test.ts new file mode 100644 index 000000000..5c3af5883 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/tests/routes.test.ts @@ -0,0 +1,338 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_LIMIT } from "../../../../database/contract"; +import { DatabasePluginError } from "../../../../database/errors"; +import type { Row } from "../../../../database/runtime"; +import { + defineSchema, + fk, + id, + text, +} from "../../../../database/schema-builder"; +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, + createDetailHandler, + createListHandler, + type ReadRouteDeps, +} from "../routes"; + +const schema = defineSchema((builder) => { + const users = builder.table("users", { + id: id(), + name: text(), + token: text().private(), + }); + const notes = builder.table("notes", { + id: id(), + authorId: fk(() => users.id), + body: text(), + }); + const events = builder.table("events", { message: text() }); + return { users, notes, events }; +}); + +const tables = compileCrudTables(schema.$tables); + +// The routes reach their entity through an untyped export lookup, so the read +// surface they drive has to stay a subset of the real client. +const _entityClientSatisfiesReads: CrudReadEntity = {} as EntityClient; + +interface FakeEntity extends CrudReadEntity { + readonly calls: Record; +} + +function fakeEntity(rows: Row[], found: Row | null = null): FakeEntity { + const calls: Record = {}; + const record = (name: string, value: unknown) => { + calls[name] ??= []; + calls[name].push(value); + }; + const chain = (name: string) => (value: unknown) => { + record(name, value); + return entity; + }; + const entity: FakeEntity = { + calls, + where: chain("where"), + order: chain("order"), + select: chain("select"), + include: chain("include"), + limit: chain("limit"), + offset: chain("offset"), + toArray: async () => { + record("toArray", true); + return rows; + }, + find: async (value) => { + record("find", value); + return found; + }, + }; + return entity; +} + +function fakeResponse() { + const sent: { + status?: number; + body?: string; + type?: string; + headers: Record; + } = { headers: {} }; + const res = { + headersSent: false, + status: vi.fn((code: number) => { + sent.status = code; + return res; + }), + type: vi.fn((value: string) => { + sent.type = value; + return res; + }), + setHeader: vi.fn((name: string, value: string) => { + sent.headers[name] = value; + return res; + }), + send: vi.fn((body: string) => { + sent.body = body; + return res; + }), + }; + return { + res: res as unknown as Response, + sent, + json: () => JSON.parse(sent.body ?? "null"), + }; +} + +function request(url: string, params: Record = {}): Request { + return { originalUrl: url, url, params } as unknown as Request; +} + +function deps( + table: string, + entity: CrudReadEntity, + serialize?: ReadSerializer, +): ReadRouteDeps { + return { + table: tables.get(table) as CrudTable, + entity: () => entity, + serialize, + runRouteSpan: (_operation, _route, run) => run(), + }; +} + +let response = fakeResponse(); +beforeEach(() => { + response = fakeResponse(); +}); + +describe("list route", () => { + it("returns the bounded envelope from one query", async () => { + const entity = fakeEntity([ + { id: 1, name: "Ada", token: "secret" }, + { id: 2, name: "Grace", token: "secret" }, + ]); + await createListHandler(deps("users", entity))( + request("/users"), + response.res, + ); + + expect(response.sent.status).toBe(200); + expect(response.json()).toEqual({ + items: [ + { id: 1, name: "Ada" }, + { id: 2, name: "Grace" }, + ], + limit: DEFAULT_LIMIT, + offset: 0, + }); + expect(entity.calls.toArray).toHaveLength(1); + expect(entity.calls.where).toBeUndefined(); + expect(entity.calls.order).toEqual([{ id: "asc" }]); + }); + + it("keeps row data out of shared and browser caches", async () => { + await createListHandler(deps("users", fakeEntity([])))( + request("/users"), + response.res, + ); + expect(response.sent.headers["Cache-Control"]).toBe("no-store"); + + const rejected = fakeResponse(); + await createListHandler(deps("users", fakeEntity([])))( + request("/users?limit=abc"), + rejected.res, + ); + expect(rejected.sent.headers["Cache-Control"]).toBe("no-store"); + }); + + it("appends the primary key so equal sort keys stay stable", async () => { + const entity = fakeEntity([]); + await createListHandler(deps("users", entity))( + request(`/users?order=${encodeURIComponent('{"name":"desc"}')}`), + response.res, + ); + expect(entity.calls.order).toEqual([{ name: "desc", id: "asc" }]); + }); + + it("keeps a caller-supplied key direction", async () => { + const entity = fakeEntity([]); + await createListHandler(deps("users", entity))( + request(`/users?order=${encodeURIComponent('{"id":"desc"}')}`), + response.res, + ); + expect(entity.calls.order).toEqual([{ id: "desc" }]); + }); + + it("requires explicit ordering when a table has no key", async () => { + const entity = fakeEntity([]); + await createListHandler(deps("events", entity))( + request("/events"), + response.res, + ); + expect(response.sent.status).toBe(400); + expect(entity.calls.toArray).toBeUndefined(); + + const ordered = fakeEntity([]); + await createListHandler(deps("events", ordered))( + request(`/events?order=${encodeURIComponent('{"message":"asc"}')}`), + fakeResponse().res, + ); + expect(ordered.calls.order).toEqual([{ message: "asc" }]); + }); + + it("forwards only the parameters the caller supplied", async () => { + const entity = fakeEntity([]); + const url = `/notes?where=${encodeURIComponent('{"body":"a"}')}&select=${encodeURIComponent('["body"]')}&include=${encodeURIComponent('{"users":true}')}&limit=5&offset=3`; + await createListHandler(deps("notes", entity))(request(url), response.res); + expect(entity.calls).toMatchObject({ + where: [{ body: "a" }], + select: [["body"]], + include: [{ users: true }], + limit: [5], + offset: [3], + }); + }); + + it("rejects an invalid query before touching the entity", async () => { + const entity = fakeEntity([]); + await createListHandler(deps("users", entity))( + request("/users?limit=abc"), + response.res, + ); + expect(response.sent.status).toBe(400); + expect(response.json()).toEqual({ + error: "Invalid database request", + details: [{ path: ["limit"], message: expect.any(String) }], + }); + expect(entity.calls.toArray).toBeUndefined(); + }); +}); + +describe("detail route", () => { + it("returns one public row for a decoded key", async () => { + const entity = fakeEntity([], { id: 7, name: "Ada", token: "secret" }); + await createDetailHandler(deps("users", entity))( + request("/users/7", { id: "7" }), + response.res, + ); + expect(entity.calls.find).toEqual([7]); + expect(response.json()).toEqual({ id: 7, name: "Ada" }); + }); + + it("answers a missing row with 404 and no internal category", async () => { + const entity = fakeEntity([], null); + await createDetailHandler(deps("users", entity))( + request("/users/7", { id: "7" }), + response.res, + ); + expect(response.sent.status).toBe(404); + expect(response.json()).toEqual({ error: "Database record not found" }); + }); + + it("rejects an unrepresentable key without a lookup", async () => { + const entity = fakeEntity([], null); + await createDetailHandler(deps("users", entity))( + request("/users/abc", { id: "abc" }), + response.res, + ); + expect(response.sent.status).toBe(400); + expect(entity.calls.find).toBeUndefined(); + }); +}); + +describe("serialization and response limits", () => { + it("keeps the serializer contract synchronous", () => { + // @ts-expect-error a serializer may not defer work into the response path + const deferred: ReadSerializer = async (row) => row; + expect(deferred).toBeTypeOf("function"); + }); + + it("applies a synchronous serializer and re-checks private columns", async () => { + const serialize = vi.fn((row) => ({ + ...row, + label: `#${row.id}`, + token: "reintroduced", + })); + const entity = fakeEntity([{ id: 1, name: "Ada", token: "secret" }]); + await createListHandler(deps("users", entity, serialize))( + request("/users"), + response.res, + ); + expect(serialize).toHaveBeenCalledWith( + { id: 1, name: "Ada" }, + { entity: "users", operation: "list" }, + ); + expect(response.json().items).toEqual([ + { id: 1, name: "Ada", label: "#1" }, + ]); + }); + + it("maps a broken serializer to an opaque server error", async () => { + const entity = fakeEntity([{ id: 1, name: "Ada" }]); + const throwing = vi.fn(() => { + throw new Error("boom: select * from users"); + }); + await createListHandler(deps("users", entity, throwing))( + request("/users"), + response.res, + ); + expect(response.sent.status).toBe(500); + expect(response.json()).toEqual({ error: "Database operation failed" }); + }); + + it("rejects a response that exceeds the byte budget", async () => { + const wide = "x".repeat(1024 * 1024); + const entity = fakeEntity( + Array.from({ length: 6 }, (_, index) => ({ id: index, name: wide })), + ); + await createListHandler(deps("users", entity))( + request("/users"), + response.res, + ); + expect(response.sent.status).toBe(413); + expect(response.json()).toEqual({ + error: "Database response is too large", + }); + expect(MAX_RESPONSE_BYTES).toBeLessThan(6 * wide.length); + }); + + it("maps an entity failure to its safe category", async () => { + const entity = fakeEntity([]); + entity.toArray = async () => { + throw new DatabasePluginError("FORBIDDEN", "read"); + }; + await createListHandler(deps("users", entity))( + request("/users"), + response.res, + ); + expect(response.sent.status).toBe(403); + expect(response.json()).toEqual({ + error: "Database operation forbidden", + }); + }); +}); diff --git a/packages/appkit/src/plugins/database/database.ts b/packages/appkit/src/plugins/database/database.ts new file mode 100644 index 000000000..53a9f929c --- /dev/null +++ b/packages/appkit/src/plugins/database/database.ts @@ -0,0 +1,189 @@ +import type express from "express"; +import type { BasePluginConfig, PluginConstructor } from "shared"; +import { + DatabasePluginError, + databaseSetupFailed, +} from "../../database/errors"; +import type { Schema } from "../../database/schema-builder"; +import { Plugin } from "../../plugin"; +import type { PluginManifest } from "../../registry"; +import { compileCrudTables } from "./crud/contract"; +import { resolveExposedTables } from "./crud/exposure"; +import { + type CrudReadEntity, + createDetailHandler, + createListHandler, + type ReadRouteDeps, + readRouteOutcome, +} from "./crud/routes"; +import type { DatabaseExports } from "./entity-types"; +import { createDatabaseState, type DatabaseState } from "./lifecycle"; +import manifest from "./manifest.json"; +import type { IDatabaseConfig, ReadSerializer } from "./types"; + +/** Schema-driven database plugin */ +export class DatabasePlugin extends Plugin< + IDatabaseConfig +> { + /** Plugin metadata and required PostgreSQL resource. */ + static manifest = manifest as PluginManifest<"database">; + protected declare config: IDatabaseConfig; + private state: DatabaseState | null = null; + private setupPromise: Promise | null = null; + private draining = false; + private shutdownPromise: Promise | null = null; + private exposedTables: string[] = []; + + constructor(config: IDatabaseConfig) { + super({ schema: config.schema }); + this.config = { + schema: config.schema, + crudRoutes: config.crudRoutes, + hooks: config.hooks, + }; + } + + /** Build and verify one candidate state before publishing its exports. */ + async setup(): Promise { + if (this.draining || this.state) throw databaseSetupFailed(); + if (!this.setupPromise) { + const attempt = (async () => { + this.exposedTables = resolveExposedTables( + this.config.crudRoutes, + Object.keys(this.config.schema.$tables), + ); + const candidate = await createDatabaseState( + this.config.schema, + (operation, options) => this.execute(operation, options), + ); + if (this.draining) { + // Setup may finish while shutdown is waiting; never publish that state. + candidate.deactivate(); + await candidate.pool.end().catch(() => undefined); + throw databaseSetupFailed(); + } + this.state = candidate; + })(); + this.setupPromise = attempt; + } + return this.setupPromise; + } + + /** Register generated reads for explicitly exposed tables only. */ + injectRoutes(router: express.Router): void { + if (this.exposedTables.length === 0) return; + const tables = compileCrudTables( + Object.fromEntries( + this.exposedTables.map((name) => [ + name, + this.config.schema.$tables[name], + ]), + ), + ); + const serializers = this.config.hooks as + | Record + | undefined; + // Every exposed name is a declared table, so its export is an entity client. + const entities = () => + this.exports() as unknown as Record; + + for (const table of tables.values()) { + const deps: ReadRouteDeps = { + table, + entity: () => entities()[table.name], + serialize: serializers?.[table.name]?.serialize, + runRouteSpan: (operation, route, run) => + this.runReadSpan(table.name, operation, route, run), + }; + this.route(router, { + name: `${table.name}.list`, + method: "get", + path: `/${table.name}`, + handler: createListHandler(deps), + }); + if (table.primaryKey) { + this.route(router, { + name: `${table.name}.detail`, + method: "get", + path: `/${table.name}/:id`, + handler: createDetailHandler(deps), + }); + } + } + } + + /** Return the typed database API only while the plugin is active. */ + exports() { + if (!this.state || this.draining) + throw new DatabasePluginError("INTERNAL", "read"); + // AppKit binds exported functions onto this object on every access. + return Object.assign( + Object.create(null), + this.state.exports, + ) as DatabaseExports; + } + + /** Stop new work, wait for setup, and close the owned pool exactly once. */ + async shutdown(): Promise { + if (this.shutdownPromise) return this.shutdownPromise; + this.draining = true; + this.shutdownPromise = (async () => { + await this.setupPromise?.catch(() => undefined); + const state = this.state; + state?.deactivate(); + this.state = null; + if (state) { + try { + await state.pool.end(); + } catch { + throw new DatabasePluginError("INTERNAL", "shutdown"); + } + } + })(); + return this.shutdownPromise; + } + + /** Trace one generated read with allowlisted, low-cardinality attributes. */ + private runReadSpan( + table: string, + operation: "list" | "detail", + route: string, + run: () => Promise, + ): Promise { + return this.telemetry.startActiveSpan( + "database.crud.route", + { + attributes: { + table_name: table, + operation, + "http.route": `/api/${this.name}${route}`, + }, + }, + async (span) => { + try { + await run(); + span.setAttribute("outcome", "success"); + } catch (error) { + span.setAttribute("outcome", readRouteOutcome(error)); + throw error; + } finally { + span.end(); + } + }, + ); + } +} + +/** Create a typed database plugin registration for a finalized schema. */ +export function database( + config: IDatabaseConfig, +) { + return { + plugin: DatabasePlugin as unknown as PluginConstructor< + BasePluginConfig, + DatabasePlugin + >, + config, + name: "database" as const, + }; +} diff --git a/packages/appkit/src/plugins/database/defaults.ts b/packages/appkit/src/plugins/database/defaults.ts new file mode 100644 index 000000000..3dbf4806f --- /dev/null +++ b/packages/appkit/src/plugins/database/defaults.ts @@ -0,0 +1,39 @@ +import type { PluginExecuteConfig } from "shared"; + +/** Default interceptor policy for bounded reads. */ +export const databaseReadDefaults: PluginExecuteConfig = { + retry: { enabled: false }, +}; + +/** Default interceptor policy for mutations. */ +export const databaseWriteDefaults: PluginExecuteConfig = { + cache: { enabled: false }, + retry: { enabled: false }, +}; + +// Generated-read limits. The wire caps a typed caller shares with HTTP live in +// `database/contract`; these bound only what an untrusted request may ask for. + +// Request decoding: rejected before any database work is scheduled. +/** Max encoded size of a generated read query string. */ +export const MAX_QUERY_BYTES = 8 * 1024; +/** Max nesting depth of `and`/`or` groups in one generated filter. */ +export const MAX_WHERE_DEPTH = 5; +/** Max column conditions across one generated filter. */ +export const MAX_WHERE_CONDITIONS = 50; +/** Max members in one `and`/`or` group. */ +export const MAX_GROUP_ITEMS = 20; +/** Max columns one generated `order` may name. */ +export const MAX_ORDER_FIELDS = 10; +/** Max accepted generated `offset`. */ +export const MAX_OFFSET = 10_000; +/** Max rows one generated read may materialize across its include tree. */ +export const MAX_MATERIALIZED_NODES = 10_000; + +// Response shaping: bounds trusted output on its way to the wire. +/** Max nesting depth of read-serializer output. */ +export const MAX_SERIALIZED_DEPTH = 32; +/** Max node count of read-serializer output. */ +export const MAX_SERIALIZED_NODES = 10_000; +/** Max UTF-8 byte length of one generated read response body. */ +export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024; diff --git a/packages/appkit/src/plugins/database/entity-client.ts b/packages/appkit/src/plugins/database/entity-client.ts new file mode 100644 index 000000000..4d0b3d48e --- /dev/null +++ b/packages/appkit/src/plugins/database/entity-client.ts @@ -0,0 +1,246 @@ +import type { PluginExecuteConfig } from "shared"; +import { + classifyDatabaseError, + DatabasePluginError, + databaseErrorFromStatus, +} from "../../database/errors"; +import type { + DataPath, + IdValue, + IncludeSpec, + OrderSpec, + QuerySpec, + Row, + WhereClause, +} from "../../database/runtime"; +import { + andWhere, + validateLimit, + validateOffset, +} from "../../database/runtime/data-path"; +import type { AppKitTable } from "../../database/schema-builder"; +import type { ExecutionResult } from "../../plugin/execution-result"; +import { databaseReadDefaults, databaseWriteDefaults } from "./defaults"; + +/** Apply AppKit execution policy to an entity operation. */ +export type EntityExecute = ( + fn: (signal?: AbortSignal) => Promise, + options: { default: PluginExecuteConfig; user?: PluginExecuteConfig }, +) => Promise>; + +/** Runtime dependencies shared by immutable clients for one table. */ +export interface EntityClientContext { + readonly table: AppKitTable; + readonly getDataPath: () => DataPath; + readonly execute: EntityExecute; + readonly assertActive: () => void; +} + +/** Clone plain acyclic input so caller mutation cannot change a built query. */ +function snapshotPlain(value: T, seen = new Set()): T { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" || + typeof value === "bigint" || + (typeof value === "number" && Number.isFinite(value)) + ) { + return value; + } + if (typeof value !== "object") { + throw new DatabasePluginError("INVALID_REQUEST", "read"); + } + if (seen.has(value)) throw new DatabasePluginError("INVALID_REQUEST", "read"); + if ( + !Array.isArray(value) && + Object.getPrototypeOf(value) !== Object.prototype + ) + throw new DatabasePluginError("INVALID_REQUEST", "read"); + seen.add(value); + const copy = ( + Array.isArray(value) + ? value.map((item) => snapshotPlain(item, seen)) + : Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + snapshotPlain(item, seen), + ]), + ) + ) as T; + seen.delete(value); + return copy; +} + +/** Report eager fluent-input failures through the public read phase. */ +function validateReadInput(validate: () => T): T { + try { + return validate(); + } catch (error) { + throw classifyDatabaseError(error, "read"); + } +} + +/** + * Plugin-facing client that composes immutable query state. The DataPath remains + * the schema-aware validation and execution boundary. + */ +export class EntityClient { + constructor( + private readonly ctx: EntityClientContext, + private readonly state: QuerySpec = {}, + ) {} + + private clone(state: QuerySpec): EntityClient { + return new EntityClient(this.ctx, state); + } + + /** Snapshot and compose predicates; the adapter validates columns/operators. */ + where(filter: WhereClause): EntityClient { + const snapshot = snapshotPlain(filter); + return this.clone({ + ...this.state, + where: andWhere(this.state.where, snapshot), + }); + } + + /** Merge ordering, replacing the direction for repeated columns. */ + order(order: OrderSpec): EntityClient { + const snapshot = snapshotPlain(order); + return this.clone({ + ...this.state, + order: { ...this.state.order, ...snapshot }, + }); + } + + select(columns: readonly string[]): EntityClient { + return this.clone({ ...this.state, select: snapshotPlain(columns) }); + } + + /** Merge relation includes, replacing repeated relation options. */ + include(include: IncludeSpec): EntityClient { + const snapshot = snapshotPlain(include); + return this.clone({ + ...this.state, + include: { ...this.state.include, ...snapshot }, + }); + } + + limit(limit: number): EntityClient { + return validateReadInput(() => + this.clone({ ...this.state, limit: validateLimit(limit) }), + ); + } + + offset(offset: number): EntityClient { + return validateReadInput(() => + this.clone({ ...this.state, offset: validateOffset(offset) }), + ); + } + + /** Execute a collection read; DataPath applies the default upper bound. */ + async toArray(): Promise { + return this.run("read", (dataPath) => + dataPath.select(this.ctx.table, this.state), + ); + } + + async first(): Promise { + return (await this.limit(1).toArray())[0] ?? null; + } + + find(id: IdValue): Promise { + return this.run("read", (dataPath) => + dataPath.findOne(this.ctx.table, id, { + where: this.state.where, + select: this.state.select, + include: this.state.include, + }), + ); + } + + count(): Promise { + return this.run("read", (dataPath) => + dataPath.count(this.ctx.table, this.state.where), + ); + } + + create(values: Row): Promise { + return this.write("create", values, (dataPath, payload) => + dataPath.insert(this.ctx.table, payload), + ); + } + + update(id: IdValue, values: Row): Promise { + return this.write("update", values, (dataPath, payload) => + dataPath.update(this.ctx.table, id, payload), + ); + } + + upsert(values: Row, options: { onConflict: string }): Promise { + const onConflict = options.onConflict; + return this.write("create", values, (dataPath, payload) => + dataPath.upsert(this.ctx.table, payload, onConflict), + ); + } + + delete(id: IdValue): Promise { + return this.run( + "write", + (dataPath) => dataPath.delete(this.ctx.table, id), + databaseWriteDefaults, + ); + } + + /** Validate caller values against the finalized trusted-write schema. */ + private async write( + kind: "create" | "update", + values: Row, + operation: (dataPath: DataPath, values: Row) => Promise, + ): Promise { + if (kind === "update" && Object.keys(values).length === 0) { + throw new DatabasePluginError("INVALID_REQUEST", "write"); + } + let parsed: Row; + try { + const validator = ( + kind === "create" + ? this.ctx.table.$insertSchema + : this.ctx.table.$updateSchema + ) as { parse(value: unknown): Row }; + parsed = validator.parse(values); + } catch { + throw new DatabasePluginError("INVALID_REQUEST", "write"); + } + return this.run( + "write", + (dataPath) => operation(dataPath, parsed), + databaseWriteDefaults, + ); + } + + /** Execute through AppKit interceptors and expose only safe failures. */ + private async run( + phase: "read" | "write", + operation: (dataPath: DataPath) => Promise, + defaults: PluginExecuteConfig = databaseReadDefaults, + ): Promise { + try { + this.ctx.assertActive(); + } catch (error) { + throw classifyDatabaseError(error, phase); + } + const result = await this.ctx.execute( + async () => { + try { + this.ctx.assertActive(); + return await operation(this.ctx.getDataPath()); + } catch (error) { + throw classifyDatabaseError(error, phase); + } + }, + { default: defaults }, + ); + if (result.ok) return result.data; + throw databaseErrorFromStatus(result.status, phase); + } +} diff --git a/packages/appkit/src/plugins/database/entity-types.ts b/packages/appkit/src/plugins/database/entity-types.ts new file mode 100644 index 000000000..6c8ad0642 --- /dev/null +++ b/packages/appkit/src/plugins/database/entity-types.ts @@ -0,0 +1,232 @@ +import type { + DatabaseRegistry, + DatabaseRegistryEntry, + IdValue, + OrderDirection, +} from "../../database/contract"; + +/** Registry shape accepted by reusable entity type helpers. */ +type RegistryShape = { + readonly [K in keyof R]: DatabaseRegistryEntry; +}; + +export type EntityNameFor> = Extract< + keyof R, + string +>; +type EntryFor< + R extends RegistryShape, + K extends EntityNameFor, +> = R[K] extends DatabaseRegistryEntry ? R[K] : never; +type RowOfFor< + R extends RegistryShape, + K extends EntityNameFor, +> = EntryFor["row"]; +type PublicRowOfFor< + R extends RegistryShape, + K extends EntityNameFor, +> = EntryFor["publicRow"]; +type InsertOfFor< + R extends RegistryShape, + K extends EntityNameFor, +> = EntryFor["insert"]; +type UpdateOfFor< + R extends RegistryShape, + K extends EntityNameFor, +> = EntryFor["update"]; +type FiltersOfFor< + R extends RegistryShape, + K extends EntityNameFor, +> = EntryFor["filters"]; +type IncludesOfFor< + R extends RegistryShape, + K extends EntityNameFor, +> = EntryFor["includes"]; + +export type EntityName = EntityNameFor; +type PublicRowOf = PublicRowOfFor; + +// Resolve generated relation metadata into include arguments and result types. +type RelationTargetFor< + Registry extends RegistryShape, + K extends EntityNameFor, + Relation extends keyof IncludesOfFor, +> = IncludesOfFor[Relation] extends { + to: infer Target extends EntityNameFor; +} + ? Target + : never; + +/** How many further relation edges may nest inside one include level. */ +type RemainingEdges = 0 | 1; + +export type IncludeOptionsFor< + Registry extends RegistryShape, + K extends EntityNameFor, + Many extends boolean, + Remaining extends RemainingEdges = 0, +> = { + readonly select?: readonly (keyof RowOfFor & string)[]; + readonly where?: FiltersOfFor; + readonly order?: Partial< + Record & string, OrderDirection> + >; +} & (Many extends true + ? { readonly limit?: number } + : { readonly limit?: never }) & + (Remaining extends 1 + ? { readonly include?: IncludeArgFor } + : { readonly include?: never }); + +export type IncludeArgFor< + Registry extends RegistryShape, + K extends EntityNameFor, + Remaining extends RemainingEdges = 1, +> = { + readonly [Relation in keyof IncludesOfFor]?: + | boolean + | IncludeOptionsFor< + Registry, + RelationTargetFor, + IncludesOfFor[Relation] extends { + many: infer Many extends boolean; + } + ? Many + : false, + Remaining + >; +}; + +// Explicit relation projections use trusted rows; implicit projections stay public. +type SelectedRowFor< + Registry extends RegistryShape, + K extends EntityNameFor, + Config, +> = Config extends { + readonly select: infer Columns extends readonly (keyof RowOfFor & + string)[]; +} + ? Pick, Columns[number]> + : PublicRowOfFor; + +type IncludedRowFor< + Registry extends RegistryShape, + K extends EntityNameFor, + Config, +> = Config extends { readonly include: infer Nested } + ? SelectedRowFor & IncludedResultFor + : SelectedRowFor; + +type IncludedResultFor< + Registry extends RegistryShape, + K extends EntityNameFor, + I, +> = I extends Record + ? { + [Relation in keyof I & + keyof IncludesOfFor as I[Relation] extends false + ? never + : Relation]: IncludesOfFor[Relation] extends { + to: infer Target extends EntityNameFor; + many: infer Many; + } + ? Many extends true + ? IncludedRowFor[] + : IncludedRowFor | null + : never; + } + : Record; + +export type EntityResultFor< + Registry extends RegistryShape, + K extends EntityNameFor, + Selected, + I, +> = Selected & IncludedResultFor; + +/** Fluent entity API shared by keyed and keyless tables. */ +export interface CommonEntityClientFor< + Registry extends RegistryShape, + K extends EntityNameFor, + Selected = PublicRowOfFor, + I = Record, +> { + where( + filter: FiltersOfFor, + ): TypedEntityClientFor; + order( + order: Partial< + Record & string, OrderDirection> + >, + ): TypedEntityClientFor; + select & string)[]>( + columns: C, + ): TypedEntityClientFor< + Registry, + K, + Pick, C[number]>, + I + >; + include>( + include: Next, + ): TypedEntityClientFor & Next>; + limit(limit: number): TypedEntityClientFor; + offset(offset: number): TypedEntityClientFor; + toArray(): Promise>>; + first(): Promise | null>; + count(): Promise; + create(values: InsertOfFor): Promise>; + upsert( + values: InsertOfFor, + options: { onConflict: keyof RowOfFor & string }, + ): Promise>; +} + +/** Add keyed operations only when typegen records a primary key. */ +type KeyedEntityClientFor< + Registry extends RegistryShape, + K extends EntityNameFor, + Selected, + I, +> = EntryFor["hasPrimaryKey"] extends true + ? { + find( + id: IdValue, + ): Promise | null>; + update( + id: IdValue, + values: UpdateOfFor, + ): Promise | null>; + delete(id: IdValue): Promise; + } + : Record; + +export type TypedEntityClientFor< + Registry extends RegistryShape, + K extends EntityNameFor, + Selected = PublicRowOfFor, + I = Record, +> = CommonEntityClientFor & + KeyedEntityClientFor; + +export type TypedEntityClient< + K extends EntityName, + Selected = PublicRowOf, + I = Record, +> = TypedEntityClientFor; + +/** Parameterized SQL tag whose interpolations are always bound values. */ +export type SqlTag = >( + strings: TemplateStringsArray, + ...values: unknown[] +) => Promise; + +/** Entity and SQL capabilities bound to one transaction. */ +export type TransactionClient = { + readonly [K in EntityName]: TypedEntityClient; +} & { readonly sql: SqlTag }; + +/** Typed database API published by the plugin. */ +export type DatabaseExports = TransactionClient & { + transaction(callback: (tx: TransactionClient) => Promise): Promise; +}; diff --git a/packages/appkit/src/plugins/database/index.ts b/packages/appkit/src/plugins/database/index.ts new file mode 100644 index 000000000..48947ad32 --- /dev/null +++ b/packages/appkit/src/plugins/database/index.ts @@ -0,0 +1,3 @@ +export { database } from "./database"; +export type { DatabaseExports } from "./entity-types"; +export type { IDatabaseConfig } from "./types"; diff --git a/packages/appkit/src/plugins/database/lifecycle.ts b/packages/appkit/src/plugins/database/lifecycle.ts new file mode 100644 index 000000000..d4009ba24 --- /dev/null +++ b/packages/appkit/src/plugins/database/lifecycle.ts @@ -0,0 +1,149 @@ +import { createLakebasePool } from "../../connectors/lakebase"; +import { + classifyDatabaseError, + DatabasePluginError, +} from "../../database/errors"; +import type { DataPath } from "../../database/runtime"; +import { + createDrizzleDataPath, + createDrizzleDb, +} from "../../database/runtime/engine/drizzle-data-path"; +import type { Schema } from "../../database/schema-builder"; +import { assertFinalizedSchema } from "../../database/schema-builder/define-schema"; +import { createLogger } from "../../logging/logger"; +import { EntityClient, type EntityExecute } from "./entity-client"; +import type { + DatabaseExports, + SqlTag, + TransactionClient, +} from "./entity-types"; + +const logger = createLogger("database"); + +/** Resources owned by one successfully initialized plugin instance. */ +export interface DatabaseState { + readonly pool: ReturnType; + readonly exports: DatabaseExports; + readonly deactivate: () => void; +} + +interface ExportContext { + readonly schema: Schema; + readonly getDataPath: () => DataPath; + readonly execute: EntityExecute; + readonly assertActive: () => void; +} + +/** Execute value-only SQL on the bound DataPath without per-statement retries. */ +function buildSql(context: ExportContext): SqlTag { + return async >( + strings: TemplateStringsArray, + ...values: unknown[] + ) => { + try { + context.assertActive(); + // DataPath accepts value interpolation only and keeps Drizzle private. + return await context.getDataPath().raw(strings, ...values); + } catch (error) { + throw classifyDatabaseError(error, "read"); + } + }; +} + +/** Build entities and SQL bound to either the root pool or one transaction. */ +function buildTransactionClient(context: ExportContext): TransactionClient { + const result: Record = Object.create(null); + for (const [name, table] of Object.entries(context.schema.$tables)) { + result[name] = new EntityClient({ + table, + getDataPath: context.getDataPath, + execute: context.execute, + assertActive: context.assertActive, + }); + } + result.sql = buildSql(context); + return result as TransactionClient; +} + +/** Add the transaction entry point to the root database surface. */ +function buildDatabaseExports(context: ExportContext): DatabaseExports { + const result = buildTransactionClient(context) as DatabaseExports; + result.transaction = async ( + callback: (tx: TransactionClient) => Promise, + ) => { + try { + context.assertActive(); + return await context.getDataPath().transaction(async (txDataPath) => { + let active = true; + const assertTransactionActive = () => { + context.assertActive(); + if (!active) throw new DatabasePluginError("INTERNAL", "transaction"); + }; + // The outer transaction owns execution; inner clients use its connection. + const directExecute: EntityExecute = async (operation) => ({ + ok: true, + data: await operation(), + }); + const tx = buildTransactionClient({ + ...context, + getDataPath: () => txDataPath, + execute: directExecute, + assertActive: assertTransactionActive, + }); + try { + return await callback(tx); + } finally { + // Captured clients must not outlive the transaction callback. + active = false; + } + }); + } catch (error) { + throw classifyDatabaseError(error, "transaction"); + } + }; + return result; +} + +/** Validate the schema, create one pool-backed API, and verify connectivity. */ +export async function createDatabaseState( + schema: TSchema, + execute: EntityExecute, +): Promise { + try { + assertFinalizedSchema(schema); + } catch (error) { + logger.error("Database schema failed validation: %O", error); + throw new DatabasePluginError("SETUP_FAILED", "setup"); + } + + let active = true; + let pool: ReturnType | undefined; + const assertActive = () => { + if (!active) throw new DatabasePluginError("INTERNAL", "runtime"); + }; + try { + pool = createLakebasePool(); + const db = createDrizzleDb(pool, schema); + const dataPath = createDrizzleDataPath(db, schema); + const exports = buildDatabaseExports({ + schema, + getDataPath: () => dataPath, + execute, + assertActive, + }); + // Do not publish exports until an authenticated statement succeeds. + await dataPath.raw`select 1`; + return { + pool, + exports, + deactivate: () => { + active = false; + }, + }; + } catch (error) { + logger.error("Database setup failed: %O", error); + active = false; + await pool?.end().catch(() => undefined); + throw new DatabasePluginError("SETUP_FAILED", "setup"); + } +} diff --git a/packages/appkit/src/plugins/database/manifest.json b/packages/appkit/src/plugins/database/manifest.json new file mode 100644 index 000000000..517422b00 --- /dev/null +++ b/packages/appkit/src/plugins/database/manifest.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + "name": "database", + "displayName": "Database (Beta)", + "description": "Schema-driven typed access to Databricks Lakebase PostgreSQL", + "stability": "beta", + "hidden": false, + "resources": { + "required": [ + { + "type": "postgres", + "alias": "Postgres", + "resourceKey": "postgres", + "description": "Lakebase Postgres database for persistent storage", + "permission": "CAN_CONNECT_AND_CREATE", + "fields": { + "project": { + "description": "Lakebase project resource name", + "examples": ["projects/{project-id}"], + "discovery": { + "type": "kind", + "resourceKind": "postgres_project", + "select": "name" + } + }, + "branch": { + "description": "Lakebase branch resource name", + "examples": ["projects/{project-id}/branches/{branch-id}"], + "discovery": { + "type": "kind", + "resourceKind": "postgres_branch", + "select": "name", + "dependsOn": "project" + } + }, + "database": { + "description": "Lakebase database resource name", + "examples": [ + "projects/{project-id}/branches/{branch-id}/databases/{database-id}" + ], + "discovery": { + "type": "kind", + "resourceKind": "postgres_database", + "select": "name", + "dependsOn": "branch" + } + }, + "host": { + "env": "PGHOST", + "localOnly": true, + "resolve": "postgres:host", + "description": "Postgres host" + }, + "databaseName": { + "env": "PGDATABASE", + "localOnly": true, + "resolve": "postgres:databaseName", + "description": "Postgres database name" + }, + "endpointPath": { + "env": "LAKEBASE_ENDPOINT", + "bundleIgnore": true, + "resolve": "postgres:endpointPath", + "description": "Lakebase endpoint resource name" + }, + "port": { + "env": "PGPORT", + "localOnly": true, + "value": "5432", + "description": "Postgres port" + }, + "sslmode": { + "env": "PGSSLMODE", + "localOnly": true, + "value": "require", + "description": "Postgres SSL mode" + } + } + } + ], + "optional": [] + } +} diff --git a/packages/appkit/src/plugins/database/tests/crud-span.test.ts b/packages/appkit/src/plugins/database/tests/crud-span.test.ts new file mode 100644 index 000000000..7536282b9 --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/crud-span.test.ts @@ -0,0 +1,175 @@ +import type { Span, SpanOptions } from "@opentelemetry/api"; +import type { Request, RequestHandler, Response } from "express"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { defineSchema, id, text } from "../../../database/schema-builder"; +import type { ITelemetry } from "../../../telemetry"; + +const mocks = vi.hoisted(() => ({ createDatabaseState: vi.fn() })); +vi.mock("../lifecycle", () => ({ + createDatabaseState: mocks.createDatabaseState, +})); + +import { DatabasePlugin } from "../database"; + +const schema = defineSchema((builder) => { + const users = builder.table("users", { + id: id(), + name: text(), + token: text().private(), + }); + return { users }; +}); + +interface RecordedSpan { + readonly name: string; + readonly attributes: Record; +} + +function fakeTelemetry(spans: RecordedSpan[]): ITelemetry { + return { + startActiveSpan: ( + name: string, + options: SpanOptions, + run: (span: Span) => Promise, + ) => { + const attributes: Record = { ...options.attributes }; + spans.push({ name, attributes }); + const span = { + setAttribute: (key: string, value: unknown) => { + attributes[key] = value; + }, + end: vi.fn(), + }; + return run(span as unknown as Span); + }, + } as unknown as ITelemetry; +} + +const rows = [{ id: 1, name: "Ada", token: "secret" }]; + +function entity(overrides: Record = {}) { + const chain: Record = { + where: () => chain, + order: () => chain, + select: () => chain, + include: () => chain, + limit: () => chain, + offset: () => chain, + toArray: async () => rows, + find: async () => rows[0], + ...overrides, + }; + return chain; +} + +async function mount(exports: Record) { + const spans: RecordedSpan[] = []; + mocks.createDatabaseState.mockResolvedValue({ + pool: { end: async () => undefined }, + exports, + deactivate: vi.fn(), + }); + const plugin = new DatabasePlugin({ schema, crudRoutes: true }); + (plugin as unknown as { telemetry: ITelemetry }).telemetry = + fakeTelemetry(spans); + await plugin.setup(); + + const handlers = new Map(); + plugin.injectRoutes({ + get: (path: string, handler: RequestHandler) => { + handlers.set(path, handler); + }, + } as unknown as Parameters[0]); + return { plugin, spans, handlers }; +} + +function fakeResponse(): Response { + const res = { + headersSent: false, + status: () => res, + type: () => res, + setHeader: () => res, + send: () => res, + }; + return res as unknown as Response; +} + +async function call( + handler: RequestHandler | undefined, + url: string, + params: Record = {}, +): Promise { + const request = { originalUrl: url, url, params } as unknown as Request; + await (handler as (req: Request, res: Response) => Promise)( + request, + fakeResponse(), + ); +} + +let mounted: Awaited>; +beforeEach(async () => { + mocks.createDatabaseState.mockReset(); + mounted = await mount({ users: entity() }); +}); + +describe("generated read spans", () => { + test("record only allowlisted, low-cardinality attributes", async () => { + await call( + mounted.handlers.get("/users"), + `/users?where=${encodeURIComponent('{"name":"Ada"}')}&limit=5`, + ); + await call(mounted.handlers.get("/users/:id"), "/users/1", { id: "1" }); + + expect(mounted.spans).toEqual([ + { + name: "database.crud.route", + attributes: { + table_name: "users", + operation: "list", + "http.route": "/api/database/users", + outcome: "success", + }, + }, + { + name: "database.crud.route", + attributes: { + table_name: "users", + operation: "detail", + "http.route": "/api/database/users/:id", + outcome: "success", + }, + }, + ]); + }); + + test("classify failures without carrying their cause", async () => { + const failing = await mount({ + users: entity({ + toArray: async () => { + throw new Error("select * from users where token = 'secret'"); + }, + find: async () => null, + }), + }); + await call(failing.handlers.get("/users"), "/users"); + await call(failing.handlers.get("/users"), "/users?limit=abc"); + await call(failing.handlers.get("/users/:id"), "/users/1", { id: "1" }); + + expect(failing.spans.map((span) => span.attributes.outcome)).toEqual([ + "failed", + "rejected", + "not_found", + ]); + const serialized = JSON.stringify(failing.spans); + expect(serialized).not.toContain("select"); + expect(serialized).not.toContain("secret"); + expect(serialized).not.toContain("INTERNAL"); + expect(serialized).not.toContain("Ada"); + }); + + test("stop serving rows once the plugin drains", async () => { + await mounted.plugin.shutdown(); + await call(mounted.handlers.get("/users"), "/users"); + expect(mounted.spans.at(-1)?.attributes.outcome).toBe("failed"); + }); +}); diff --git a/packages/appkit/src/plugins/database/tests/entity-client.test.ts b/packages/appkit/src/plugins/database/tests/entity-client.test.ts new file mode 100644 index 000000000..78ad42950 --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/entity-client.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, test, vi } from "vitest"; +import { MAX_LIMIT } from "../../../database/contract"; +import type { + DataPath, + QuerySpec, + Row, + WhereClause, +} from "../../../database/runtime"; +import { defineSchema, id, text } from "../../../database/schema-builder"; +import { EntityClient, type EntityClientContext } from "../entity-client"; + +const schema = defineSchema(({ table }) => { + const notes = table("notes", { id: id(), body: text().notNull() }); + return { notes }; +}); + +function harness() { + const calls: Array<[string, unknown]> = []; + const dataPath: DataPath = { + select: async (_table, spec) => { + calls.push(["select", spec]); + return [{ id: 1, body: "a" }]; + }, + findOne: async (_table, _id, spec) => { + calls.push(["findOne", spec]); + return { id: 1, body: "a" }; + }, + count: async (_table, where) => { + calls.push(["count", where]); + return 1; + }, + insert: async (_table, values) => { + calls.push(["insert", values]); + return { id: 1, ...values }; + }, + update: async (_table, _id, values) => { + calls.push(["update", values]); + return { id: 1, ...values }; + }, + upsert: async (_table, values, target) => { + calls.push(["upsert", target]); + return { id: 1, ...values }; + }, + delete: async () => { + calls.push(["delete", undefined]); + return true; + }, + raw: async () => [], + transaction: async (callback) => callback(dataPath), + }; + const context: EntityClientContext = { + table: schema.$tables.notes, + getDataPath: () => dataPath, + assertActive: vi.fn(), + execute: async (operation) => ({ ok: true, data: await operation() }), + }; + return { client: new EntityClient(context), calls }; +} + +describe("EntityClient", () => { + test("retains accumulated predicates in find", async () => { + const { client, calls } = harness(); + const filter = { body: { eq: "a" } } as WhereClause; + await client.where(filter).find(1); + expect((calls[0][1] as Pick).where).toEqual(filter); + }); + + test("drives every fluent method and read terminal without mutating the root", async () => { + const { client, calls } = harness(); + const query = client + .where({ body: { eq: "a" } }) + .where({ id: { gt: 0 } }) + .order({ body: "asc" }) + .select(["id"]) + .include({ notes: true }) + .limit(2) + .offset(1); + await query.toArray(); + await query.first(); + await query.find(1); + await query.count(); + expect(calls.map(([name]) => name)).toEqual([ + "select", + "select", + "findOne", + "count", + ]); + expect(calls[0][1]).toMatchObject({ + where: { + and: [{ body: { eq: "a" } }, { id: { gt: 0 } }], + }, + order: { body: "asc" }, + select: ["id"], + include: { notes: true }, + limit: 2, + offset: 1, + }); + expect(calls[2][1]).toMatchObject({ + where: { + and: [{ body: { eq: "a" } }, { id: { gt: 0 } }], + }, + select: ["id"], + include: { notes: true }, + }); + }); + + test.each([ + ["limit", -1], + ["limit", 1.5], + ["limit", MAX_LIMIT + 1], + ["limit", Number.MAX_SAFE_INTEGER + 1], + ["offset", -1], + ["offset", 1.5], + ["offset", Number.NaN], + ["offset", Number.MAX_SAFE_INTEGER + 1], + ] as const)("maps invalid %s %s to a safe read error", (method, value) => { + const { client } = harness(); + expect(() => client[method](value)).toThrowError( + expect.objectContaining({ + category: "INVALID_REQUEST", + phase: "read", + statusCode: 400, + }), + ); + }); + + test("accepts exact limit and offset boundaries", async () => { + const { client, calls } = harness(); + await client.limit(0).offset(0).toArray(); + await client.limit(MAX_LIMIT).offset(Number.MAX_SAFE_INTEGER).toArray(); + expect(calls.map(([, value]) => value)).toEqual([ + expect.objectContaining({ limit: 0, offset: 0 }), + expect.objectContaining({ + limit: MAX_LIMIT, + offset: Number.MAX_SAFE_INTEGER, + }), + ]); + }); + + test.each([ + [400, "INVALID_REQUEST"], + [403, "FORBIDDEN"], + [409, "CONFLICT"], + [500, "INTERNAL"], + ] as const)("maps executor status %s", async (status, category) => { + const failing = new EntityClient({ + table: schema.$tables.notes, + getDataPath: () => { + throw new Error("must not run"); + }, + assertActive: vi.fn(), + execute: async () => ({ ok: false, status, message: "safe" }), + }); + await expect(failing.toArray()).rejects.toMatchObject({ + category, + phase: "read", + statusCode: status, + }); + }); + + test("rechecks activity inside delayed execution before DataPath access", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let active = true; + const getDataPath = vi.fn(() => { + throw new Error("must not access DataPath"); + }); + const client = new EntityClient({ + table: schema.$tables.notes, + getDataPath, + assertActive: () => { + if (!active) throw new Error("inactive raw detail"); + }, + execute: async (operation) => { + await gate; + return { ok: true, data: await operation() }; + }, + }); + const operation = client.toArray(); + active = false; + release(); + const error = await operation.catch((caught) => caught); + expect(error).toMatchObject({ category: "INTERNAL", phase: "read" }); + expect(error.message).not.toContain("inactive raw detail"); + expect(error.cause).toBeUndefined(); + expect(getDataPath).not.toHaveBeenCalled(); + }); + + test("snapshots fluent plain-data state", async () => { + const { client, calls } = harness(); + const filter = { body: { eq: "before" } }; + const order = { body: "asc" as const }; + const include = { notes: { where: { body: { eq: "child" } } } }; + const columns = ["body"]; + const query = client + .where(filter) + .order(order) + .include(include) + .select(columns); + filter.body.eq = "after"; + (order as { body: "asc" | "desc" }).body = "desc"; + include.notes.where.body.eq = "after"; + columns[0] = "id"; + + await query.toArray(); + expect(calls[0][1]).toMatchObject({ + where: { body: { eq: "before" } }, + order: { body: "asc" }, + include: { notes: { where: { body: { eq: "child" } } } }, + select: ["body"], + }); + }); + + test("fails safely for cyclic fluent state and empty updates", async () => { + const { client } = harness(); + const cyclic: Record = {}; + cyclic.self = cyclic; + expect(() => client.where(cyclic as WhereClause)).toThrowError(); + await expect(client.update(1, {})).rejects.toMatchObject({ + category: "INVALID_REQUEST", + }); + }); + + test("validates trusted writes and drives every mutation terminal", async () => { + const { client, calls } = harness(); + await client.create({ body: "a" }); + await client.update(1, { body: "b" }); + await client.upsert({ body: "c" }, { onConflict: "id" }); + await client.delete(1); + expect(calls.map(([name]) => name)).toEqual([ + "insert", + "update", + "upsert", + "delete", + ]); + await expect(client.create({ unknown: true } as Row)).rejects.toMatchObject( + { + category: "INVALID_REQUEST", + }, + ); + }); + + test("snapshots the upsert conflict target before deferred execution", async () => { + const { client, calls } = harness(); + const options = { onConflict: "id" }; + const operation = client.upsert({ body: "a" }, options); + options.onConflict = "body"; + await operation; + expect(calls).toContainEqual(["upsert", "id"]); + }); +}); diff --git a/packages/appkit/src/plugins/database/tests/entity-types.test.ts b/packages/appkit/src/plugins/database/tests/entity-types.test.ts new file mode 100644 index 000000000..e341a6357 --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/entity-types.test.ts @@ -0,0 +1,267 @@ +import { describe, expectTypeOf, it } from "vitest"; +import type * as Beta from "../../../beta"; +import type { + DatabaseExports, + EntityResultFor, + IncludeArgFor, + TransactionClient, + TypedEntityClientFor, +} from "../entity-types"; + +// @ts-expect-error DataPath is an internal engine contract. +type InternalDataPath = Beta.DataPath; +// @ts-expect-error DatabasePluginError is internal. +type InternalDatabaseError = Beta.DatabasePluginError; +// @ts-expect-error Pool ownership is not part of the beta API. +type InternalPool = Beta.Pool; + +type TextFilter = + | string + | readonly string[] + | { + eq?: string; + neq?: string; + in?: readonly string[]; + like?: string; + ilike?: string; + is?: null; + }; +type NumberFilter = + | number + | readonly number[] + | { + eq?: number; + neq?: number; + in?: readonly number[]; + gt?: number; + gte?: number; + lt?: number; + lte?: number; + }; +type NoteFilters = { + body?: TextFilter; + rank?: NumberFilter; + and?: readonly NoteFilters[]; + or?: readonly NoteFilters[]; +}; + +interface TestRegistry { + notes: { + row: { id: string; body: string; secret: string | null; rank: number }; + publicRow: { id: string; body: string; rank: number }; + insert: { body: string; secret?: string | null; rank: number }; + update: { body?: string; secret?: string | null; rank?: number }; + filters: NoteFilters; + includes: { + author: { to: "users"; many: false }; + comments: { to: "comments"; many: true }; + }; + hasPrimaryKey: true; + }; + users: { + row: { id: string; name: string; token: string }; + publicRow: { id: string; name: string }; + insert: { name: string; token: string }; + update: { name?: string; token?: string }; + filters: { name?: TextFilter }; + includes: Record; + hasPrimaryKey: true; + }; + comments: { + row: { id: string; text: string; internal: string }; + publicRow: { id: string; text: string }; + insert: { text: string; internal: string }; + update: { text?: string; internal?: string }; + filters: { text?: TextFilter }; + includes: { author: { to: "users"; many: false } }; + hasPrimaryKey: true; + }; + events: { + row: { message: string }; + publicRow: { message: string }; + insert: { message: string }; + update: { message?: string }; + filters: { message?: TextFilter }; + includes: Record; + hasPrimaryKey: false; + }; +} + +declare const notes: TypedEntityClientFor; +declare const events: TypedEntityClientFor; +declare const database: DatabaseExports; +declare const tx: TransactionClient; +const typecheckOnly = (): boolean => false; + +describe("typed database entity contract", () => { + it("keeps the intended beta types public and implementation types private", () => { + type PublicBetaTypes = [ + Beta.DatabaseExports, + Beta.IDatabaseConfig, + Beta.Schema, + ]; + expectTypeOf().not.toBeNever(); + expectTypeOf< + InternalDataPath | InternalDatabaseError | InternalPool + >().not.toBeNever(); + }); + + it("composes keyed and keyless entity capabilities", () => { + expectTypeOf>().toHaveProperty( + "find", + ); + expectTypeOf>().toHaveProperty( + "update", + ); + expectTypeOf>().toHaveProperty( + "delete", + ); + expectTypeOf().not.toEqualTypeOf<"find">(); + if (typecheckOnly()) { + events.where({ message: "created" }).order({ message: "asc" }); + events.select(["message"]).include({}).limit(10).offset(2); + void events.toArray(); + void events.first(); + void events.count(); + void events.create({ message: "created" }); + expectTypeOf(events).toHaveProperty("upsert"); + void events.upsert({ message: "created" }, { onConflict: "message" }); + } + }); + + it("defaults to public rows and narrows explicit root selections", () => { + expectTypeOf>>().toMatchTypeOf< + TestRegistry["notes"]["publicRow"] | null + >(); + if (typecheckOnly()) { + const selected = notes.select(["body", "secret"] as const); + expectTypeOf>>().toMatchTypeOf<{ + body: string; + secret: string | null; + } | null>(); + } + }); + + it("keeps implicit relation projections public and narrows explicit selects", () => { + type PublicAuthor = EntityResultFor< + TestRegistry, + "notes", + TestRegistry["notes"]["publicRow"], + { author: true } + >; + type FilteredAuthor = EntityResultFor< + TestRegistry, + "notes", + TestRegistry["notes"]["publicRow"], + { author: { where: { name: string }; order: { name: "asc" } } } + >; + type LimitedComments = EntityResultFor< + TestRegistry, + "notes", + TestRegistry["notes"]["publicRow"], + { comments: { limit: 2 } } + >; + type PrivateAuthor = EntityResultFor< + TestRegistry, + "notes", + TestRegistry["notes"]["publicRow"], + { author: { select: readonly ["token"] } } + >; + type NestedAuthor = EntityResultFor< + TestRegistry, + "notes", + TestRegistry["notes"]["publicRow"], + { comments: { include: { author: true } } } + >; + + expectTypeOf>().toEqualTypeOf< + TestRegistry["users"]["publicRow"] + >(); + expectTypeOf>().toEqualTypeOf< + TestRegistry["users"]["publicRow"] + >(); + expectTypeOf().toEqualTypeOf< + TestRegistry["comments"]["publicRow"][] + >(); + expectTypeOf>().toEqualTypeOf<{ + token: string; + }>(); + expectTypeOf< + NonNullable + >().toEqualTypeOf(); + }); + + it("omits false relations and replaces successive include configurations", () => { + if (typecheckOnly()) { + const included = notes + .include({ author: { select: ["token"] } }) + .include({ comments: true }) + .include({ author: false }); + type Result = Awaited>; + expectTypeOf>().not.toHaveProperty("author"); + expectTypeOf>().toHaveProperty("comments"); + } + }); + + it("accepts the supported filter and include grammar", () => { + if (typecheckOnly()) { + notes.where({ body: "a", rank: [1, 2] }); + notes.where({ body: { ilike: "%a%", is: null }, rank: { gte: 1 } }); + notes.where({ + and: [{ body: { in: ["a"] } }], + or: [{ rank: { lt: 10 } }], + }); + notes.include({ + author: { where: { name: "Ada" }, order: { name: "asc" } }, + }); + notes.include({ comments: { limit: 5 } }); + notes.include({ comments: { include: { author: true } } }); + + // @ts-expect-error direct null is not a filter shorthand + notes.where({ body: null }); + // @ts-expect-error text filters do not support range operators + notes.where({ body: { gt: "a" } }); + // @ts-expect-error number filters do not support pattern operators + notes.where({ rank: { like: "1" } }); + // @ts-expect-error JSON and unknown fields are not filterable + notes.where({ payload: { eq: {} } }); + // @ts-expect-error a filter names columns on this entity, never relations + notes.where({ comments: { some: { text: "a" } } }); + // @ts-expect-error unknown relations fail closed + notes.include({ unknown: true }); + // @ts-expect-error unknown relation options fail closed + notes.include({ author: { offset: 1 } }); + // @ts-expect-error to-one includes cannot be limited + notes.include({ author: { limit: 1 } }); + // @ts-expect-error includes stop after the second relation edge + notes.include({ comments: { include: { author: { include: {} } } } }); + } + }); + + it("restricts upsert targets to declared columns", () => { + if (typecheckOnly()) { + void notes.upsert({ body: "created", rank: 1 }, { onConflict: "id" }); + void notes.upsert({ body: "created", rank: 1 }, { onConflict: "body" }); + void notes.upsert( + { body: "created", rank: 1 }, + // @ts-expect-error unknown columns cannot be conflict targets + { onConflict: "missing" }, + ); + } + }); + + it("does not expose deferred or unsafe APIs", () => { + if (typecheckOnly()) { + // @ts-expect-error raw pool access is intentionally absent + database.getPool(); + // @ts-expect-error entity reads are always bounded + notes.unbounded(); + // @ts-expect-error transaction clients cannot open nested transactions + tx.transaction(async () => undefined); + } + }); + + it("keeps relationless include keys empty", () => { + expectTypeOf>().toBeNever(); + }); +}); diff --git a/packages/appkit/src/plugins/database/tests/lifecycle.test.ts b/packages/appkit/src/plugins/database/tests/lifecycle.test.ts new file mode 100644 index 000000000..27c577030 --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/lifecycle.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, test, vi } from "vitest"; +import { DatabasePluginError } from "../../../database/errors"; +import type { DataPath, Row } from "../../../database/runtime"; +import { defineSchema, id, text } from "../../../database/schema-builder"; + +const mocks = vi.hoisted(() => ({ + createLakebasePool: vi.fn(), + createDrizzleDb: vi.fn(), + createDrizzleDataPath: vi.fn(), +})); + +vi.mock("../../../connectors/lakebase", () => ({ + createLakebasePool: mocks.createLakebasePool, +})); +vi.mock("../../../database/runtime/engine/drizzle-data-path", () => ({ + createDrizzleDb: mocks.createDrizzleDb, + createDrizzleDataPath: mocks.createDrizzleDataPath, +})); + +import { createDatabaseState } from "../lifecycle"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const schema = defineSchema(({ table }) => { + const notes = table("notes", { id: id(), body: text().notNull() }); + const tags = table("tags", { id: id(), label: text() }); + return { notes, tags }; +}); + +function fakePath(overrides: Partial = {}): DataPath { + const path: DataPath = { + select: vi.fn(async () => []), + findOne: vi.fn(async () => null), + count: vi.fn(async () => 0), + insert: vi.fn(async (_table, values) => ({ id: 1, ...values })), + update: vi.fn(async (_table, id, values) => ({ id, ...values })), + upsert: vi.fn(async (_table, values) => ({ id: 1, ...values })), + delete: vi.fn(async () => true), + raw: vi.fn(async () => []), + transaction: vi.fn(async (callback) => callback(path)), + ...overrides, + }; + return path; +} + +type TestEntity = { + create(values: Row): Promise; + toArray(): Promise; +}; +type TestExports = { + notes: TestEntity; + sql: DataPath["raw"]; + transaction( + callback: (tx: { notes: TestEntity; sql: DataPath["raw"] }) => Promise, + ): Promise; +}; + +function arrange(path = fakePath()) { + const pool = { end: vi.fn(async () => undefined) }; + const db = { marker: Symbol("db") }; + mocks.createLakebasePool.mockReturnValue(pool); + mocks.createDrizzleDb.mockReturnValue(db); + mocks.createDrizzleDataPath.mockReturnValue(path); + const execute = vi.fn(async (operation) => ({ + ok: true as const, + data: await operation(), + })); + return { pool, db, path, execute }; +} + +describe("createDatabaseState", () => { + test("accepts authentic populated and empty schemas but rejects a forgery before allocation", async () => { + arrange(); + await expect( + createDatabaseState(schema, arrange().execute), + ).resolves.toBeDefined(); + await expect( + createDatabaseState( + defineSchema(() => ({})), + arrange().execute, + ), + ).resolves.toBeDefined(); + mocks.createLakebasePool.mockClear(); + await expect( + createDatabaseState( + { $tables: Object.create(null) } as typeof schema, + arrange().execute, + ), + ).rejects.toMatchObject({ category: "SETUP_FAILED", phase: "setup" }); + expect(mocks.createLakebasePool).not.toHaveBeenCalled(); + }); + + test("builds one default runtime, all entities, and waits for readiness", async () => { + const ready = deferred(); + const path = fakePath({ + raw: vi.fn(async () => ready.promise) as unknown as DataPath["raw"], + }); + const { pool, db, execute } = arrange(path); + const pending = createDatabaseState(schema, execute); + await vi.waitFor(() => expect(path.raw).toHaveBeenCalledTimes(1)); + let settled = false; + void pending.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + ready.resolve([]); + const state = await pending; + expect(mocks.createLakebasePool).toHaveBeenCalledTimes(1); + expect(mocks.createLakebasePool).toHaveBeenCalledWith(); + expect(mocks.createDrizzleDb).toHaveBeenCalledWith(pool, schema); + expect(mocks.createDrizzleDataPath).toHaveBeenCalledWith(db, schema); + expect(Object.keys(state.exports).sort()).toEqual([ + "notes", + "sql", + "tags", + "transaction", + ]); + expect(state.exports).not.toHaveProperty("getPool"); + expect((state.exports as unknown as TestExports).notes).not.toHaveProperty( + "unbounded", + ); + }); + + test.each(["drizzle", "dataPath", "readiness"] as const)( + "closes and sanitizes %s construction failures", + async (stage) => { + const { pool, execute } = arrange(); + const raw = new Error("secret constraint detail"); + if (stage === "drizzle") + mocks.createDrizzleDb.mockImplementationOnce(() => { + throw raw; + }); + if (stage === "dataPath") + mocks.createDrizzleDataPath.mockImplementationOnce(() => { + throw raw; + }); + if (stage === "readiness") + mocks.createDrizzleDataPath.mockReturnValueOnce( + fakePath({ + raw: vi.fn(async () => { + throw new DatabasePluginError("INTERNAL", "runtime", raw.message); + }), + }), + ); + const error = await createDatabaseState(schema, execute).catch( + (value) => value, + ); + expect(error).toMatchObject({ category: "SETUP_FAILED", phase: "setup" }); + expect(error.message).toBe("Database setup failed"); + expect(error.cause).toBeUndefined(); + expect(pool.end).toHaveBeenCalledTimes(1); + }, + ); + + test("sanitizes synchronous pool construction failures", async () => { + const { execute } = arrange(); + mocks.createLakebasePool.mockImplementationOnce(() => { + throw new Error("secret host and credential details"); + }); + + const error = await createDatabaseState(schema, execute).catch( + (caught) => caught, + ); + + expect(error).toMatchObject({ category: "SETUP_FAILED", phase: "setup" }); + expect(error.message).toBe("Database setup failed"); + expect(error.cause).toBeUndefined(); + }); + + test("runs root SQL directly, maps failures safely, and rejects after deactivation", async () => { + const path = fakePath(); + const { execute } = arrange(path); + const state = await createDatabaseState(schema, execute); + await state.exports.sql`select ${1}`; + expect(path.raw).toHaveBeenCalledTimes(2); + expect(execute).not.toHaveBeenCalled(); + for (const [category, expected] of [ + ["INVALID_REQUEST", "INVALID_REQUEST"], + ["CONFLICT", "CONFLICT"], + ["FORBIDDEN", "FORBIDDEN"], + ["INTERNAL", "INTERNAL"], + ] as const) { + vi.mocked(path.raw).mockRejectedValueOnce( + new DatabasePluginError(category, "runtime", "raw secret"), + ); + await expect(state.exports.sql`bad`).rejects.toMatchObject({ + category: expected, + }); + } + state.deactivate(); + await expect(state.exports.sql`select 1`).rejects.toMatchObject({ + category: "INTERNAL", + phase: "read", + }); + }); + + test("commits, rolls back, binds tx capabilities, and expires them", async () => { + const txPath = fakePath(); + const rootPath = fakePath({ + transaction: vi.fn(async (callback) => callback(txPath)), + }); + const { execute } = arrange(rootPath); + const state = await createDatabaseState(schema, execute); + const exports = state.exports as unknown as TestExports; + let captured!: Parameters[0]>[0]; + await expect( + exports.transaction(async (tx) => { + captured = tx; + await tx.notes.create({ body: "created" }); + await tx.sql`select ${1}`; + expect(tx).not.toHaveProperty("transaction"); + return "committed"; + }), + ).resolves.toBe("committed"); + expect(rootPath.transaction).toHaveBeenCalledTimes(1); + expect(txPath.insert).toHaveBeenCalledTimes(1); + expect(txPath.raw).toHaveBeenCalledTimes(1); + await expect(captured.notes.toArray()).rejects.toMatchObject({ + category: "INTERNAL", + }); + await expect(captured.sql`select 1`).rejects.toMatchObject({ + category: "INTERNAL", + }); + + await expect( + exports.transaction(async () => { + throw new Error("rollback"); + }), + ).rejects.toMatchObject({ category: "INTERNAL" }); + }); + + test("keeps independently created states isolated", async () => { + const first = arrange(); + const stateOne = await createDatabaseState(schema, first.execute); + const second = arrange(); + const stateTwo = await createDatabaseState(schema, second.execute); + expect(stateOne.pool).not.toBe(stateTwo.pool); + expect(stateOne.exports).not.toBe(stateTwo.exports); + stateOne.deactivate(); + await expect(stateOne.exports.sql`select 1`).rejects.toBeDefined(); + await expect(stateTwo.exports.sql`select 1`).resolves.toEqual([]); + }); +}); diff --git a/packages/appkit/src/plugins/database/tests/plugin.test.ts b/packages/appkit/src/plugins/database/tests/plugin.test.ts new file mode 100644 index 000000000..53c3a10f8 --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/plugin.test.ts @@ -0,0 +1,288 @@ +import type express from "express"; +import { beforeEach, describe, expect, expectTypeOf, test, vi } from "vitest"; +import { defineSchema, fk, id, text } from "../../../database/schema-builder"; + +const mocks = vi.hoisted(() => ({ createDatabaseState: vi.fn() })); +vi.mock("../lifecycle", () => ({ + createDatabaseState: mocks.createDatabaseState, +})); + +import { DatabasePlugin, database } from "../database"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const schema = defineSchema(() => ({})); +const routedSchema = defineSchema((builder) => { + const users = builder.table("users", { id: id(), name: text() }); + const notes = builder.table("notes", { + id: id(), + authorId: fk(() => users.id), + }); + const events = builder.table("events", { message: text() }); + return { users, notes, events }; +}); + +function fakeRouter() { + const routes: string[] = []; + const record = + (method: string) => + (path: string): void => { + routes.push(`${method} ${path}`); + }; + return { + routes, + router: { + get: record("get"), + post: record("post"), + patch: record("patch"), + delete: record("delete"), + put: record("put"), + } as unknown as express.Router, + }; +} + +async function registerRoutes( + config: ConstructorParameters>[0], +) { + mocks.createDatabaseState.mockResolvedValue(candidate()); + const plugin = new DatabasePlugin(config); + await plugin.setup(); + const { router, routes } = fakeRouter(); + plugin.injectRoutes(router); + return { plugin, routes }; +} + +function candidate(marker = "one") { + let active = true; + const end = vi.fn<() => Promise>(async () => undefined); + return { + pool: { end }, + exports: { + marker, + operation: () => { + if (!active) throw new Error("inactive"); + }, + }, + deactivate: vi.fn(() => { + active = false; + }), + }; +} + +describe("DatabasePlugin", () => { + beforeEach(() => mocks.createDatabaseState.mockReset()); + + test("retains schema and declares the fixed beta postgres manifest", () => { + const definition = database({ schema }); + expectTypeOf(definition.config.schema).toEqualTypeOf(); + const assertConfigTypes = () => { + // @ts-expect-error execution policy is internal and cannot be configured + database({ schema, retry: { enabled: true, attempts: 3 } }); + }; + void assertConfigTypes; + expect(definition).toMatchObject({ name: "database", config: { schema } }); + expect(DatabasePlugin.manifest).toMatchObject({ + name: "database", + stability: "beta", + }); + expect(DatabasePlugin.manifest.resources.required).toContainEqual( + expect.objectContaining({ resourceKey: "postgres", type: "postgres" }), + ); + const plugin = new DatabasePlugin({ + schema, + retry: { enabled: true, attempts: 3 }, + } as unknown as { schema: typeof schema }); + expect( + (plugin as unknown as { config: Record }).config, + ).toEqual({ schema }); + }); + + test("publishes only after readiness and setup is single-flight", async () => { + const construction = deferred>(); + mocks.createDatabaseState.mockReturnValue(construction.promise); + const plugin = new DatabasePlugin({ schema }); + const first = plugin.setup(); + const second = plugin.setup(); + expect(() => plugin.exports()).toThrow(); + expect(mocks.createDatabaseState).toHaveBeenCalledTimes(1); + const state = candidate(); + construction.resolve(state); + await Promise.all([first, second]); + expect(plugin.exports()).toEqual(state.exports); + }); + + test("hands out a fresh export surface per access", async () => { + const state = candidate(); + mocks.createDatabaseState.mockResolvedValue(state); + const plugin = new DatabasePlugin({ schema }); + await plugin.setup(); + expect(plugin.exports()).not.toBe(plugin.exports()); + expect(plugin.exports()).not.toBe(state.exports); + expect(plugin.exports()).toEqual(state.exports); + }); + + test("shutdown racing setup waits, prevents publication, deactivates, and closes", async () => { + const construction = deferred>(); + mocks.createDatabaseState.mockReturnValue(construction.promise); + const plugin = new DatabasePlugin({ schema }); + const setup = plugin.setup(); + const shutdown = plugin.shutdown(); + const state = candidate(); + construction.resolve(state); + await expect(setup).rejects.toMatchObject({ category: "SETUP_FAILED" }); + await shutdown; + expect(state.deactivate).toHaveBeenCalledTimes(1); + expect(state.pool.end).toHaveBeenCalledTimes(1); + expect(() => plugin.exports()).toThrow(); + }); + + test("deactivates and unpublishes before one shared close", async () => { + const close = deferred(); + const state = candidate(); + state.pool.end.mockReturnValue(close.promise); + mocks.createDatabaseState.mockResolvedValue(state); + const plugin = new DatabasePlugin({ schema }); + await plugin.setup(); + const first = plugin.shutdown(); + const second = plugin.shutdown(); + await vi.waitFor(() => expect(state.deactivate).toHaveBeenCalledTimes(1)); + expect(state.deactivate).toHaveBeenCalledTimes(1); + expect(() => plugin.exports()).toThrow(); + expect(state.pool.end).toHaveBeenCalledTimes(1); + close.resolve(); + await Promise.all([first, second]); + await plugin.shutdown(); + expect(state.pool.end).toHaveBeenCalledTimes(1); + }); + + test("sanitizes close failure and repeats the same safe rejection", async () => { + const state = candidate(); + state.pool.end.mockRejectedValue(new Error("socket password secret")); + mocks.createDatabaseState.mockResolvedValue(state); + const plugin = new DatabasePlugin({ schema }); + await plugin.setup(); + const first = plugin.shutdown(); + const error = await first.catch((caught) => caught); + expect(error).toMatchObject({ + category: "INTERNAL", + phase: "shutdown", + cause: undefined, + }); + expect(error.message).toBe("Database operation failed"); + await expect(plugin.shutdown()).rejects.toBe(error); + }); + + test("registers no generated routes unless they are turned on", async () => { + for (const crudRoutes of [undefined, false] as const) { + const { routes } = await registerRoutes({ + schema: routedSchema, + crudRoutes, + }); + expect(routes).toEqual([]); + } + }); + + test("registers reads for every table or an explicit subset", async () => { + const assertNames = () => { + database({ schema: routedSchema, crudRoutes: { tables: ["notes"] } }); + database({ + schema: routedSchema, + hooks: { notes: { serialize: (row) => row } }, + }); + database({ + schema: routedSchema, + // @ts-expect-error only a declared table can be exposed + crudRoutes: { tables: ["missing"] }, + }); + database({ + schema: routedSchema, + // @ts-expect-error only a declared table can shape its own responses + hooks: { missing: { serialize: (row) => row } }, + }); + }; + void assertNames; + + const all = await registerRoutes({ + schema: routedSchema, + crudRoutes: true, + }); + expect(all.routes).toEqual([ + "get /users", + "get /users/:id", + "get /notes", + "get /notes/:id", + // A table without a primary key cannot address a single row. + "get /events", + ]); + expect(all.plugin.getEndpoints()).toMatchObject({ + "users.list": "/api/database/users", + "users.detail": "/api/database/users/:id", + }); + + const subset = await registerRoutes({ + schema: routedSchema, + crudRoutes: { tables: ["notes"] }, + }); + expect(subset.routes).toEqual(["get /notes", "get /notes/:id"]); + }); + + test("fails setup on an exposure list it cannot honor", async () => { + for (const tables of [["missing"], ["users", "users"], "users"]) { + mocks.createDatabaseState.mockResolvedValue(candidate()); + const plugin = new DatabasePlugin({ + schema: routedSchema, + crudRoutes: { tables } as unknown as { tables: ["users"] }, + }); + await expect(plugin.setup()).rejects.toMatchObject({ + category: "SETUP_FAILED", + }); + } + }); + + test("refuses to route names it cannot serve unambiguously", async () => { + const unsafe = [ + defineSchema((builder) => ({ + users: builder.table("users", { id: id() }), + Users: builder.table("Users", { id: id() }), + })), + defineSchema((builder) => ({ + _hidden: builder.table("_hidden", { id: id() }), + })), + ]; + for (const unsafeSchema of unsafe) { + mocks.createDatabaseState.mockResolvedValue(candidate()); + const plugin = new DatabasePlugin({ + schema: unsafeSchema, + crudRoutes: true, + }); + await expect(plugin.setup()).rejects.toMatchObject({ + category: "SETUP_FAILED", + }); + } + }); + + test("isolates plugin instances and drains their exports independently", async () => { + const one = candidate("one"); + const two = candidate("two"); + mocks.createDatabaseState + .mockResolvedValueOnce(one) + .mockResolvedValueOnce(two); + const first = new DatabasePlugin({ schema }); + const second = new DatabasePlugin({ schema }); + await Promise.all([first.setup(), second.setup()]); + expect(first.exports()).toEqual(one.exports); + expect(second.exports()).toEqual(two.exports); + await first.shutdown(); + expect(() => one.exports.operation()).toThrow("inactive"); + expect(() => first.exports()).toThrow(); + expect(second.exports()).toEqual(two.exports); + }); +}); diff --git a/packages/appkit/src/plugins/database/types.ts b/packages/appkit/src/plugins/database/types.ts new file mode 100644 index 000000000..a4d0a09bf --- /dev/null +++ b/packages/appkit/src/plugins/database/types.ts @@ -0,0 +1,50 @@ +import type { Schema } from "../../database/schema-builder"; + +/** Table names declared by one finalized schema. */ +export type SchemaTableName = Extract< + keyof TSchema["$tables"], + string +>; + +/** + * Generated read exposure: off by default, every table, or an explicit list. + * + * Reads run as the app's service principal and apply no per-user filter, so an + * enabled table is readable by anyone the app admits. Enabling a table also + * makes it includable from its neighbours; a relation whose target stays off + * cannot be included, which keeps one table's data behind one decision. + * + * Text filters accept caller-supplied `like`/`ilike` patterns, and this beta + * adds no statement cancellation below the connector, so an expensive pattern + * runs to completion while holding its pooled connection. + */ +export type CrudRoutesConfig = + | boolean + | { readonly tables: readonly SchemaTableName[] }; + +/** Which entity and generated operation produced the row being shaped. */ +export interface ReadSerializerContext { + readonly entity: string; + readonly operation: "list" | "detail"; +} + +/** + * 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. + */ +export type ReadSerializer = ( + row: Record, + context: ReadSerializerContext, +) => Record; + +/** Configuration for one schema-bound DatabasePlugin instance. */ +export type IDatabaseConfig = { + readonly schema: TSchema; + readonly crudRoutes?: CrudRoutesConfig; + readonly hooks?: { + readonly [TTable in SchemaTableName]?: { + readonly serialize?: ReadSerializer; + }; + }; +}; diff --git a/packages/appkit/src/type-generator/database/generate.ts b/packages/appkit/src/type-generator/database/generate.ts new file mode 100644 index 000000000..9ebb28dae --- /dev/null +++ b/packages/appkit/src/type-generator/database/generate.ts @@ -0,0 +1,145 @@ +import { randomUUID } from "node:crypto"; +import fsSync from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { Schema } from "../../database/schema-builder"; +import { assertFinalizedSchema } from "../../database/schema-builder/define-schema"; +import { walkSchema } from "./walk-schema"; + +/** Safe diagnostic raised when database declarations cannot be generated. */ +export class DatabaseTypegenError extends Error { + constructor(message = "Database schema generation failed") { + super(message); + this.name = "DatabaseTypegenError"; + } +} + +interface GenerateDatabaseTypesOptions { + readonly schemaFile: string; + readonly outFile: string; +} + +export const DATABASE_TYPES_FILE = "database.d.ts"; + +function schemaLabel(schemaFile: string): string { + return path.relative(process.cwd(), schemaFile) || schemaFile; +} + +function importFailureReason(error: unknown): string { + if (error instanceof SyntaxError) return "could not be parsed"; + let code: unknown; + try { + code = + error && typeof error === "object" + ? Reflect.get(error, "code") + : undefined; + } catch { + code = undefined; + } + if (code === "MODULE_NOT_FOUND" || code === "ERR_MODULE_NOT_FOUND") { + return "contains an unresolved import"; + } + return "threw while loading"; +} + +/** Empty augmentation used when no valid database schema is available. */ +export const NEUTRAL_DATABASE_TYPES = `// Auto-generated by AppKit - DO NOT EDIT +import "@databricks/appkit"; + +declare module "@databricks/appkit" { + interface DatabaseRegistry {} +} +`; + +/** Render one `DatabaseRegistry` augmentation from a finalized schema. */ +function render(schema: Schema): string { + const entries = walkSchema(schema) + .map( + (entry) => ` ${JSON.stringify(entry.name)}: { + row: ${entry.row}; + publicRow: ${entry.publicRow}; + insert: ${entry.insert}; + update: ${entry.update}; + filters: ${entry.filters}; + includes: ${entry.includes}; + hasPrimaryKey: ${entry.hasPrimaryKey}; + };`, + ) + .join("\n"); + return `// Auto-generated by AppKit - DO NOT EDIT +import "@databricks/appkit"; + +declare module "@databricks/appkit" { + type DatabaseLogicalFilter = T & { + and?: readonly DatabaseLogicalFilter[]; + or?: readonly DatabaseLogicalFilter[]; + }; + + interface DatabaseRegistry { +${entries} + } +} +`; +} + +/** Atomically replace generated output only when its content changes. */ +async function writeIfChanged(outFile: string, content: string): Promise { + if ( + fsSync.existsSync(outFile) && + (await fs.readFile(outFile, "utf8")) === content + ) + return; + await fs.mkdir(path.dirname(outFile), { recursive: true }); + const temporary = path.join( + path.dirname(outFile), + `.${path.basename(outFile)}.${randomUUID()}.tmp`, + ); + try { + await fs.writeFile(temporary, content, "utf8"); + await fs.rename(temporary, outFile); + } finally { + await fs.rm(temporary, { force: true }); + } +} + +/** Disable Jiti's module cache so watch-mode runs observe schema edits. */ +async function importSchema(schemaFile: string): Promise { + const { createJiti } = await import("jiti"); + return createJiti(import.meta.url, { moduleCache: false }).import(schemaFile); +} + +/** Generate current registry types or neutralize them when loading fails. */ +export async function generateDatabaseTypes( + options: GenerateDatabaseTypesOptions, +): Promise { + if (!fsSync.existsSync(options.schemaFile)) { + await writeIfChanged(options.outFile, NEUTRAL_DATABASE_TYPES); + return; + } + try { + const module = (await importSchema(options.schemaFile)) as { + schema?: Schema; + }; + if (!("schema" in module)) { + throw new DatabaseTypegenError( + `Database schema module "${schemaLabel(options.schemaFile)}" must export a named "schema"`, + ); + } + try { + assertFinalizedSchema(module.schema); + } catch { + throw new DatabaseTypegenError( + `Database schema "${schemaLabel(options.schemaFile)}" is not a finalized AppKit schema`, + ); + } + await writeIfChanged(options.outFile, render(module.schema)); + } catch (error) { + // A failed run must not leave stale entities visible to TypeScript. + await writeIfChanged(options.outFile, NEUTRAL_DATABASE_TYPES); + throw error instanceof DatabaseTypegenError + ? error + : new DatabaseTypegenError( + `Database schema "${schemaLabel(options.schemaFile)}" ${importFailureReason(error)}`, + ); + } +} diff --git a/packages/appkit/src/type-generator/database/index.ts b/packages/appkit/src/type-generator/database/index.ts new file mode 100644 index 000000000..0648bba2c --- /dev/null +++ b/packages/appkit/src/type-generator/database/index.ts @@ -0,0 +1,5 @@ +export { + DATABASE_TYPES_FILE, + DatabaseTypegenError, + generateDatabaseTypes, +} from "./generate"; diff --git a/packages/appkit/src/type-generator/database/tests/generate.test.ts b/packages/appkit/src/type-generator/database/tests/generate.test.ts new file mode 100644 index 000000000..0e306f41a --- /dev/null +++ b/packages/appkit/src/type-generator/database/tests/generate.test.ts @@ -0,0 +1,288 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, test } from "vitest"; +import { generateDatabaseTypes, NEUTRAL_DATABASE_TYPES } from "../generate"; + +const execFileAsync = promisify(execFile); +const roots: string[] = []; +const appkitRoot = path.resolve(import.meta.dirname, "../../../.."); +const sourceRoot = path.join(appkitRoot, "src"); +const builder = path.join(sourceRoot, "database/schema-builder/index.ts"); + +afterEach(async () => + Promise.all( + roots + .splice(0) + .map((root) => fs.rm(root, { recursive: true, force: true })), + ), +); + +async function files(source?: string) { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), "appkit-database-typegen-"), + ); + roots.push(root); + const schemaFile = path.join(root, "schema.ts"); + const outFile = path.join(root, "database.d.ts"); + if (source !== undefined) await fs.writeFile(schemaFile, source); + return { root, schemaFile, outFile }; +} + +const completeSchema = ` + import { + bigint, boolean, defineSchema, enumColumn, fk, id, integer, jsonb, + text, timestamp, uuid, varchar, + } from ${JSON.stringify(builder)}; + export const schema = defineSchema(({ table }) => { + const users = table("users", { + slug: text().primaryKey(), + name: varchar(80).notNull(), + secret: text().private().notNull(), + nickname: text().default("anonymous"), + created_at: timestamp().defaultNow().notNull(), + }); + const posts = table("posts", { + id: id(), + user_slug: fk(() => users.slug).notNull(), + title: text().notNull(), + score: integer(), + total: bigint().notNull(), + active: boolean().notNull(), + external_id: uuid().defaultRandom().notNull(), + happened_at: timestamp(), + payload: jsonb(), + status: enumColumn("post_status", ["draft", "live"]).notNull(), + }); + const events = table("events", { message: text().notNull(), payload: jsonb() }); + const blobs = table("blobs", { payload: jsonb() }); + return { users, posts, events, blobs }; + }); +`; + +describe("generateDatabaseTypes", () => { + test("renders every facet, scalar, enum, filter, relation, and key capability", async () => { + const options = await files(completeSchema); + await generateDatabaseTypes(options); + const output = await fs.readFile(options.outFile, "utf8"); + + expect(output).toContain('"slug": string;'); + expect(output).toContain('"score": number | null;'); + expect(output).toContain('"total": bigint;'); + expect(output).toContain('"active": boolean;'); + expect(output).toContain('"external_id": string;'); + expect(output).toContain('"happened_at": string | null;'); + expect(output).toContain('"payload": unknown | null;'); + expect(output).toContain('"status": "draft" | "live";'); + expect(output).toContain('readonly ("draft" | "live")[]'); + + const users = output.slice( + output.indexOf('"users": {'), + output.indexOf('\n "posts": {\n row:'), + ); + expect(users.match(/"secret"\??: string;/g)).toHaveLength(3); + expect(users).toContain("publicRow:"); + expect(users).toContain('insert: {\n "slug": string;'); + expect(users).toContain('"nickname"?: string | null;'); + expect(users).toContain('"created_at"?: string;'); + expect(users).not.toContain('update: {\n "slug"'); + + const posts = output.slice( + output.indexOf('\n "posts": {\n row:'), + output.indexOf('\n "events": {\n row:'), + ); + expect(posts).not.toContain('insert: {\n "id"'); + expect(posts).not.toContain('update: {\n "id"'); + expect(posts).toContain('"title"?: string;'); + expect(posts).toContain('"score"?: number | null;'); + expect(posts).toContain('"users": { to: "users"; many: false };'); + expect(users).toContain('"posts": { to: "posts"; many: true };'); + expect(output).toContain("hasPrimaryKey: true;"); + expect(output).toContain("hasPrimaryKey: false;"); + + expect(output).toContain( + '"title"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; };', + ); + expect(output).toContain( + "gt?: number; gte?: number; lt?: number; lte?: number;", + ); + expect(output).toContain("is?: null;"); + const postFilters = posts.slice( + posts.indexOf("filters:"), + posts.indexOf("includes:"), + ); + expect(postFilters).not.toContain('"payload"?:'); + expect(output).toContain("and?: readonly DatabaseLogicalFilter[];"); + expect(output).toContain("or?: readonly DatabaseLogicalFilter[];"); + expect(output).toContain("includes: {};"); + expect(output).toContain("filters: DatabaseLogicalFilter<{}>;"); + }); + + test("accepts named valid and explicitly empty schemas", async () => { + const valid = await files(completeSchema); + await generateDatabaseTypes(valid); + expect(await fs.readFile(valid.outFile, "utf8")).toContain('"users": {'); + + const empty = await files(` + import { defineSchema } from ${JSON.stringify(builder)}; + export const schema = defineSchema(() => ({})); + `); + await generateDatabaseTypes(empty); + expect(await fs.readFile(empty.outFile, "utf8")).toContain( + "interface DatabaseRegistry {\n\n }", + ); + }); + + test.each([ + ["default", "export default {};"], + ["alternate", "export const databaseSchema = {};"], + ["forged", "export const schema = { $tables: {} };"], + ])( + "rejects %s schema exports and neutralizes stale output", + async (_name, source) => { + const options = await files(source); + await fs.writeFile(options.outFile, "stale entity"); + await expect(generateDatabaseTypes(options)).rejects.toMatchObject({ + name: "DatabaseTypegenError", + }); + expect(await fs.readFile(options.outFile, "utf8")).toBe( + NEUTRAL_DATABASE_TYPES, + ); + }, + ); + + test("reports a bounded schema diagnostic while neutralizing output", async () => { + const options = await files('throw new Error("broken schema dependency");'); + await fs.writeFile(options.outFile, "stale entity"); + + const error = await generateDatabaseTypes(options).catch( + (caught) => caught, + ); + + expect(error).toMatchObject({ name: "DatabaseTypegenError" }); + expect(error.message).toContain("schema.ts"); + expect(error.message).toContain("threw while loading"); + expect(error.message).not.toContain("broken schema dependency"); + expect(await fs.readFile(options.outFile, "utf8")).toBe( + NEUTRAL_DATABASE_TYPES, + ); + }); + + test("writes a neutral contribution when the schema is absent", async () => { + const options = await files(); + await generateDatabaseTypes(options); + expect(await fs.readFile(options.outFile, "utf8")).toBe( + NEUTRAL_DATABASE_TYPES, + ); + }); + + test("reloads schema and imported dependency edits", async () => { + const options = await files(); + const dependency = path.join(options.root, "columns.ts"); + await fs.writeFile( + dependency, + `export const columnName = "first" as const;`, + ); + const source = ` + import { defineSchema, text } from ${JSON.stringify(builder)}; + import { columnName } from "./columns"; + export const schema = defineSchema(({ table }) => { + const records = table("records", { [columnName]: text() }); + return { records }; + }); + `; + await fs.writeFile(options.schemaFile, source); + await generateDatabaseTypes(options); + expect(await fs.readFile(options.outFile, "utf8")).toContain('"first"'); + + await fs.writeFile( + dependency, + `export const columnName = "second" as const;`, + ); + await generateDatabaseTypes(options); + expect(await fs.readFile(options.outFile, "utf8")).toContain('"second"'); + + await fs.writeFile( + options.schemaFile, + source + .replace('table("records"', 'table("updated"') + .replace("return { records };", "return { updated: records };"), + ); + await generateDatabaseTypes(options); + expect(await fs.readFile(options.outFile, "utf8")).toContain( + '"updated": {', + ); + }); + + test("does not rewrite an unchanged declaration", async () => { + const options = await files(completeSchema); + await generateDatabaseTypes(options); + const content = await fs.readFile(options.outFile, "utf8"); + const before = (await fs.stat(options.outFile)).mtimeMs; + await new Promise((resolve) => setTimeout(resolve, 20)); + await generateDatabaseTypes(options); + expect(await fs.readFile(options.outFile, "utf8")).toBe(content); + expect((await fs.stat(options.outFile)).mtimeMs).toBe(before); + }); + + test("compiles a semantic consumer through the beta subpath", async () => { + const options = await files(completeSchema); + await generateDatabaseTypes(options); + const consumer = path.join(options.root, "consumer.ts"); + const tsconfig = path.join(options.root, "tsconfig.json"); + await fs.writeFile( + consumer, + ` + import type { DatabaseExports } from "@databricks/appkit/beta"; + declare const db: DatabaseExports; + db.users.where({ name: { ilike: "%ada%" } }).include({ posts: { limit: 2 } }); + db.posts.where({ score: { gte: 1 }, and: [{ status: ["draft"] }] }); + db.events.create({ message: "created" }); + db.transaction(async (tx) => { await tx.posts.count(); await tx.sql\`select \${1}\`; }); + // @ts-expect-error keyless entities have no find + db.events.find("id"); + // @ts-expect-error private columns are absent from default rows + db.users.first().then((row) => row?.secret); + db.users.create({ slug: "ada", name: "Ada", secret: "token" }); + db.users.upsert( + { slug: "ada", name: "Ada", secret: "token" }, + { onConflict: "slug" }, + ); + // @ts-expect-error unknown columns are not conflict targets + db.users.upsert({ slug: "ada", name: "Ada", secret: "token" }, { onConflict: "missing" }); + // @ts-expect-error defaulted fields remain typed when explicitly supplied + db.users.create({ slug: "ada", name: "Ada", secret: "token", nickname: 1 }); + `, + ); + await fs.writeFile( + tsconfig, + JSON.stringify({ + compilerOptions: { + strict: true, + noEmit: true, + target: "ES2022", + module: "ESNext", + moduleResolution: "Bundler", + baseUrl: options.root, + paths: { + "@databricks/appkit": [ + path.join(sourceRoot, "database/contract/index.ts"), + ], + "@databricks/appkit/beta": [ + path.join(sourceRoot, "plugins/database/entity-types.ts"), + ], + }, + }, + files: [options.outFile, consumer], + }), + ); + + await expect( + execFileAsync("pnpm", ["exec", "tsc", "--noEmit", "-p", tsconfig], { + cwd: path.resolve(appkitRoot, "../.."), + }), + ).resolves.toMatchObject({ stderr: "" }); + }, 30_000); +}); diff --git a/packages/appkit/src/type-generator/database/walk-schema.ts b/packages/appkit/src/type-generator/database/walk-schema.ts new file mode 100644 index 000000000..1b6f49f21 --- /dev/null +++ b/packages/appkit/src/type-generator/database/walk-schema.ts @@ -0,0 +1,127 @@ +import type { + AppKitTable, + ColumnMeta, + Schema, +} from "../../database/schema-builder"; +import { filterOperatorsForKind } from "../../database/schema-builder/types"; + +/** Render-ready type facets for one database registry entry. */ +interface RegistryEntry { + readonly name: string; + readonly row: string; + readonly publicRow: string; + readonly insert: string; + readonly update: string; + readonly filters: string; + readonly includes: string; + readonly hasPrimaryKey: boolean; +} + +/** Keep generated scalars aligned with the schema's canonical runtime values. */ +function tsType(meta: ColumnMeta): string { + switch (meta.kind) { + case "string": + case "uuid": + case "date": + return "string"; + case "number": + return "number"; + case "bigint": + return "bigint"; + case "boolean": + return "boolean"; + case "json": + return "unknown"; + case "enum": + return ( + meta.enumValues?.map((value) => JSON.stringify(value)).join(" | ") || + "string" + ); + default: + return "unknown"; + } +} + +function objectFacet(lines: string[], empty = "{}"): string { + return lines.length ? `{\n${lines.join("\n")}\n }` : empty; +} + +function property(meta: ColumnMeta, optional = false): string { + const nullable = meta.notNull ? "" : " | null"; + return ` ${JSON.stringify(meta.columnName)}${optional ? "?" : ""}: ${tsType(meta)}${nullable};`; +} + +// Trusted facets retain private columns; only public rows project them out. +function rowType(table: AppKitTable, publicOnly: boolean): string { + return objectFacet( + Object.values(table.$columns) + .filter((c) => !publicOnly || !c.isPrivate) + .map((c) => property(c)), + "Record", + ); +} + +// Write facets mirror trusted validators; updates additionally omit primary keys. +function insertType(table: AppKitTable): string { + return objectFacet( + Object.values(table.$columns) + .filter((c) => !c.serverGenerated) + .map((c) => property(c, !c.notNull || c.hasDefault)), + "Record", + ); +} + +function updateType(table: AppKitTable): string { + return objectFacet( + Object.values(table.$columns) + .filter((c) => !c.serverGenerated && !c.primaryKey) + .map((c) => property(c, true)), + "Record", + ); +} + +/** Reuse the canonical operator matrix when rendering `where()` types. */ +function filtersType(table: AppKitTable): string { + const direct = objectFacet( + Object.values(table.$columns).flatMap((column) => { + const operators = filterOperatorsForKind(column.kind); + if (operators.length === 0) return []; + const value = tsType(column); + const fields = operators.map( + (operator) => + `${operator}?: ${operator === "in" ? `readonly (${value})[]` : value};`, + ); + if (!column.notNull) fields.push("is?: null;"); + return [ + ` ${JSON.stringify(column.columnName)}?: ${value} | readonly (${value})[] | { ${fields.join(" ")} };`, + ]; + }), + ); + return `DatabaseLogicalFilter<${direct}>`; +} + +/** Preserve finalized relation identity and cardinality in include types. */ +function includesType(table: AppKitTable): string { + return objectFacet( + table.$relations.map( + (relation) => + ` ${JSON.stringify(relation.name)}: { to: ${JSON.stringify(relation.targetTable)}; many: ${relation.cardinality === "toMany"} };`, + ), + ); +} + +/** Preserve schema table identity as the generated registry key. */ +export function walkSchema(schema: Schema): RegistryEntry[] { + return Object.entries(schema.$tables).map(([name, table]) => ({ + name, + row: rowType(table, false), + publicRow: rowType(table, true), + insert: insertType(table), + update: updateType(table), + filters: filtersType(table), + includes: includesType(table), + hasPrimaryKey: Object.values(table.$columns).some( + (column) => column.primaryKey, + ), + })); +} diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index 45cfce711..28dc526e3 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -905,3 +905,8 @@ export const TYPES_DIR = "appkit-types"; export const ANALYTICS_TYPES_FILE = "analytics.d.ts"; export const SERVING_TYPES_FILE = "serving.d.ts"; export const METRIC_TYPES_FILE = "metric-views.ts"; +export { + DATABASE_TYPES_FILE, + DatabaseTypegenError, + generateDatabaseTypes, +} from "./database"; diff --git a/packages/appkit/src/type-generator/tests/vite-plugin.test.ts b/packages/appkit/src/type-generator/tests/vite-plugin.test.ts index 52c25c9fc..f27b1e528 100644 --- a/packages/appkit/src/type-generator/tests/vite-plugin.test.ts +++ b/packages/appkit/src/type-generator/tests/vite-plugin.test.ts @@ -5,12 +5,35 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { WarehouseState } from "../warehouse-status"; const mocks = vi.hoisted(() => ({ + existsSync: vi.fn((_file: unknown) => true), + generateDatabaseTypes: vi.fn(), generateFromEntryPoint: vi.fn(), getWarehouseState: vi.fn(), startWarehouse: vi.fn(), waitUntilRunning: vi.fn(), + loggerError: vi.fn(), })); +vi.mock("node:fs", async (importOriginal) => ({ + ...(await importOriginal()), + existsSync: mocks.existsSync, +})); + +vi.mock("../../logging/logger", () => ({ + createLogger: () => ({ + debug: vi.fn(), + error: mocks.loggerError, + }), +})); + +vi.mock("../database", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + generateDatabaseTypes: mocks.generateDatabaseTypes, + }; +}); + // Mock the module vite-plugin.ts pulls generateFromEntryPoint from. The error // classes are imported for `instanceof` checks in the catch block, so they must // remain real constructors — only the warehouse-touching entry point is spied. @@ -45,6 +68,13 @@ const { appKitTypesPlugin } = await import("../vite-plugin"); // Real constant values: the "../index" mock spreads the actual module, so these // are the genuine defaults the plugin resolves outFile from. const { ANALYTICS_TYPES_FILE, TYPES_DIR } = await import("../index"); +const { DatabaseTypegenError } = await import("../database"); + +beforeEach(() => { + mocks.existsSync.mockReturnValue(true); + mocks.generateDatabaseTypes.mockReset(); + mocks.generateDatabaseTypes.mockResolvedValue(undefined); +}); // The plugin hooks are loosely typed on Vite's Plugin; cast to the shapes we // actually drive so we can call them directly without a Vite build. @@ -131,8 +161,7 @@ describe("appKitTypesPlugin — generation mode", () => { mocks.getWarehouseState.mockResolvedValue("DELETED" as WarehouseState); mocks.startWarehouse.mockResolvedValue(undefined); mocks.waitUntilRunning.mockResolvedValue("RUNNING" as WarehouseState); - // A non-empty warehouse ID is required or generate() short-circuits before - // ever calling generateFromEntryPoint. + // Default to the established warehouse-backed generation path. process.env.DATABRICKS_WAREHOUSE_ID = "wh-test"; }); @@ -170,12 +199,90 @@ describe("appKitTypesPlugin — generation mode", () => { ); }); - test("skips generation when warehouse ID is absent", async () => { + test("runs warehouse-independent generation when warehouse ID is absent", async () => { + delete process.env.DATABRICKS_WAREHOUSE_ID; + + await runPlugin(); + await flush(); + + expect(mocks.generateDatabaseTypes).toHaveBeenCalledWith({ + schemaFile: path.join(process.cwd(), "config", "database", "schema.ts"), + outFile: path.join( + process.cwd(), + "shared", + "appkit-types", + "database.d.ts", + ), + }); + expect(mocks.generateFromEntryPoint).not.toHaveBeenCalled(); + }); + + test("activates a database-only project without a warehouse", () => { delete process.env.DATABRICKS_WAREHOUSE_ID; + mocks.existsSync.mockImplementation((file) => + String(file).endsWith(path.join("config", "database", "schema.ts")), + ); + const apply = appKitTypesPlugin().apply; + expect(typeof apply).toBe("function"); + expect((apply as (config: unknown, env: unknown) => boolean)({}, {})).toBe( + true, + ); + }); + + test("does not run warehouse typegen for a database-only project", async () => { + mocks.existsSync.mockImplementation((file) => + String(file).endsWith(path.join("config", "database", "schema.ts")), + ); await runPlugin(); + await flush(); + expect(mocks.generateDatabaseTypes).toHaveBeenCalledTimes(1); expect(mocks.generateFromEntryPoint).not.toHaveBeenCalled(); + expect(mocks.getWarehouseState).not.toHaveBeenCalled(); + }); + + test("activates to neutralize an existing database declaration", () => { + delete process.env.DATABRICKS_WAREHOUSE_ID; + mocks.existsSync.mockImplementation((file) => + String(file).endsWith(path.join("appkit-types", "database.d.ts")), + ); + const apply = appKitTypesPlugin().apply; + expect(typeof apply).toBe("function"); + expect((apply as (config: unknown, env: unknown) => boolean)({}, {})).toBe( + true, + ); + }); + + test("logs DatabaseTypegenError message-only in development", async () => { + process.env.NODE_ENV = "development"; + mocks.generateDatabaseTypes.mockRejectedValueOnce( + new DatabaseTypegenError("Database schema generation failed"), + ); + + await runPlugin(); + await flush(); + + expect(mocks.loggerError).toHaveBeenCalledWith( + "%s", + "Database schema generation failed", + ); + expect(mocks.loggerError).not.toHaveBeenCalledWith( + expect.stringContaining("%O"), + expect.anything(), + ); + }); + + test("rejects DatabaseTypegenError message-only in production", async () => { + process.env.NODE_ENV = "production"; + const error = new DatabaseTypegenError("Database schema generation failed"); + mocks.generateDatabaseTypes.mockRejectedValueOnce(error); + + const plugin = makeConfiguredPlugin(); + await expect(getHook(plugin, "buildStart")()).rejects.toBe( + error, + ); + expect(error.stack).toBe(error.message); }); }); @@ -310,6 +417,63 @@ describe("appKitTypesPlugin — single-flight generate", () => { expect(mocks.generateFromEntryPoint).toHaveBeenCalledTimes(1); }); + test.each(["add", "change", "unlink"])( + "%s on the exact database schema regenerates without arming the warehouse", + async (event) => { + mocks.generateFromEntryPoint.mockResolvedValue(undefined); + const plugin = makeConfiguredPlugin(); + const { server, watcher } = makeFakeServer(); + getHook(plugin, "configureServer")(server); + + watcher.emit( + event, + path.join(process.cwd(), "config", "database", "schema.ts"), + ); + await flush(); + + // Database sources ride the same single-flight generate as `.sql` edits, + // but a schema edit tells us nothing about the warehouse, so the one-shot + // blocking re-describe is not armed for it. + expect(mocks.generateDatabaseTypes).toHaveBeenCalledTimes(1); + expect(mocks.getWarehouseState).not.toHaveBeenCalled(); + }, + ); + + test("regenerates when an imported database source changes", async () => { + mocks.generateFromEntryPoint.mockResolvedValue(undefined); + const plugin = makeConfiguredPlugin(); + const { server, watcher } = makeFakeServer(); + getHook(plugin, "configureServer")(server); + + watcher.emit( + "change", + path.join(process.cwd(), "config", "database", "tables", "notes.ts"), + ); + await flush(); + + expect(mocks.generateDatabaseTypes).toHaveBeenCalledTimes(1); + expect(mocks.getWarehouseState).not.toHaveBeenCalled(); + }); + + test("ignores database non-source files and prefix-collision siblings", async () => { + mocks.generateFromEntryPoint.mockResolvedValue(undefined); + const plugin = makeConfiguredPlugin(); + const { server, watcher } = makeFakeServer(); + getHook(plugin, "configureServer")(server); + + for (const file of [ + path.join(process.cwd(), "config", "database-copy", "schema.ts"), + path.join(process.cwd(), "config", "database", "README.md"), + path.join(process.cwd(), "other", "database", "schema.ts"), + ]) { + watcher.emit("change", file); + } + await flush(); + + expect(mocks.generateDatabaseTypes).not.toHaveBeenCalled(); + expect(mocks.generateFromEntryPoint).not.toHaveBeenCalled(); + }); + test("a definitions.json OUTSIDE the metric-views folder does NOT regenerate; one inside does (directory match, not bare basename)", async () => { mocks.generateFromEntryPoint.mockResolvedValue(undefined); diff --git a/packages/appkit/src/type-generator/vite-plugin.ts b/packages/appkit/src/type-generator/vite-plugin.ts index 3c79fc193..ed4099c5a 100644 --- a/packages/appkit/src/type-generator/vite-plugin.ts +++ b/packages/appkit/src/type-generator/vite-plugin.ts @@ -4,6 +4,11 @@ import type { Plugin } from "vite"; import { METRIC_CONFIG_FILE } from "../../../shared/src/schemas/metric-fqn"; import { createLogger } from "../logging/logger"; import { createWorkspaceClient } from "../workspace-client"; +import { + DATABASE_TYPES_FILE, + DatabaseTypegenError, + generateDatabaseTypes, +} from "./database"; import { ANALYTICS_TYPES_FILE, generateFromEntryPoint, @@ -45,6 +50,7 @@ interface AppKitTypesPluginOptions { * Folders to watch for changes. Defaults to `config/queries` and * `config/metric-views`. When overridden, include a `queries` folder and/or a * `metric-views` folder — they are resolved by their trailing path segment. + * Database schema sources are watched independently. */ watchFolders?: string[]; } @@ -64,6 +70,7 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { // `watchFolders` ordering (which used to assume queries was `watchFolders[0]`). let queryFolder: string | undefined; let metricViewsFolder: string | undefined; + let databaseFolder: string; // Single-flight state for runGenerate(). `inFlight` is the promise of the // currently-running drain (null when idle); `queued` records that a trigger @@ -103,17 +110,23 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { if (!warehouseId) { logger.debug("Warehouse ID not found. Skipping type generation."); - return; + } else if (hasAnalyticsSources()) { + await generateFromEntryPoint({ + outFile, + queryFolder, + metricViewsFolder, + warehouseId, + noCache: false, + mode, + mvOutFile, + }); } - await generateFromEntryPoint({ - outFile, - queryFolder, - metricViewsFolder, - warehouseId, - noCache: false, - mode, - mvOutFile, + // Database declarations need no warehouse. Generating them last keeps the + // query and metric-view outputs independent of a schema failure. + await generateDatabaseTypes({ + schemaFile: path.join(databaseFolder, "schema.ts"), + outFile: path.join(path.dirname(outFile), DATABASE_TYPES_FILE), }); } catch (error) { // TypegenSyntaxError / TypegenFatalError carry a complete, actionable @@ -122,7 +135,8 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { // message — both when failing the prod build and when logging in dev. const isTypegenError = error instanceof TypegenSyntaxError || - error instanceof TypegenFatalError; + error instanceof TypegenFatalError || + error instanceof DatabaseTypegenError; // throw in production to fail the build if (process.env.NODE_ENV === "production") { @@ -138,6 +152,18 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { } } + /** + * Whether this project has anything for the warehouse-backed passes to read. + * A database-only project activates the plugin without them, so the query and + * metric-view work — and the warehouse it would warm up — must stay dormant. + */ + function hasAnalyticsSources(): boolean { + return ( + (queryFolder !== undefined && existsSync(queryFolder)) || + (metricViewsFolder !== undefined && existsSync(metricViewsFolder)) + ); + } + /** * Single-flight wrapper around {@link generateOnce}. The initial build, the * .sql watcher, and the DEV warehouse watch all route through here so they can @@ -224,6 +250,7 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { */ function armWarehouseWatch(): void { if (process.env.NODE_ENV === "production") return; + if (!hasAnalyticsSources()) return; const warehouseId = process.env.DATABRICKS_WAREHOUSE_ID || ""; if (!warehouseId) return; @@ -297,8 +324,20 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { apply() { const warehouseId = process.env.DATABRICKS_WAREHOUSE_ID || ""; - - if (!warehouseId) { + const typesDir = path.dirname( + path.resolve( + process.cwd(), + options?.outFile ?? `shared/${TYPES_DIR}/${ANALYTICS_TYPES_FILE}`, + ), + ); + // A declared schema needs no warehouse, and an already-generated + // declaration must still be neutralized after its schema is deleted. + const hasDatabase = + existsSync( + path.join(process.cwd(), "config", "database", "schema.ts"), + ) || existsSync(path.join(typesDir, DATABASE_TYPES_FILE)); + + if (!warehouseId && !hasDatabase) { logger.debug("Warehouse ID not found. Skipping type generation."); return false; } @@ -313,7 +352,7 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { const hasMetricViews = existsSync( path.join(process.cwd(), "config", "metric-views"), ); - if (!hasQueries && !hasMetricViews) { + if (!hasQueries && !hasMetricViews && !hasDatabase) { return false; } @@ -354,6 +393,7 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { "config", "metric-views", ); + databaseFolder = path.join(process.cwd(), "config", "database"); watchFolders = options?.watchFolders ?? [ defaultQueryFolder, defaultMetricViewsFolder, @@ -392,8 +432,24 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { configureServer(server) { server.watcher.add(watchFolders); + server.watcher.add(databaseFolder); + + const isDatabaseSource = (changedFile: string): boolean => { + const normalizedFile = path.resolve(changedFile); + const relative = path.relative(databaseFolder, normalizedFile); + return ( + !relative.startsWith("..") && + !path.isAbsolute(relative) && + /\.(?:[cm]?ts|tsx)$/.test(normalizedFile) + ); + }; server.watcher.on("change", (changedFile) => { + if (isDatabaseSource(changedFile)) { + void runGenerate("non-blocking"); + return; + } + const isWatchedFile = watchFolders.some((folder) => changedFile.startsWith(folder), ); @@ -421,6 +477,14 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { armWarehouseWatch(); } }); + // Creation/deletion support is database-specific; query watchers retain + // their existing change-only behavior. + server.watcher.on("add", (file) => { + if (isDatabaseSource(file)) void runGenerate("non-blocking"); + }); + server.watcher.on("unlink", (file) => { + if (isDatabaseSource(file)) void runGenerate("non-blocking"); + }); // Tear down any pending warehouse watch when the dev server closes so a // long backoff can't keep the process alive after shutdown. diff --git a/packages/appkit/tsdown.config.ts b/packages/appkit/tsdown.config.ts index f5ae00475..b586905eb 100644 --- a/packages/appkit/tsdown.config.ts +++ b/packages/appkit/tsdown.config.ts @@ -6,10 +6,9 @@ export default defineConfig([ attw: { profile: "esm-only", level: "error", - excludeEntrypoints: ["./type-generator"], }, name: "@databricks/appkit", - entry: ["src/index.ts", "src/beta.ts"], + entry: ["src/index.ts", "src/beta.ts", "src/type-generator/index.ts"], outDir: "dist", hash: false, format: "esm", diff --git a/packages/shared/src/cli/commands/generate-types.test.ts b/packages/shared/src/cli/commands/generate-types.test.ts index 1c2f5c855..ff48bf29f 100644 --- a/packages/shared/src/cli/commands/generate-types.test.ts +++ b/packages/shared/src/cli/commands/generate-types.test.ts @@ -16,6 +16,7 @@ import { // created in a hoisted block too (plain top-level consts would be in the TDZ when // the hoisted factory runs). const { + generateDatabaseTypes, generateFromEntryPoint, generateServingTypes, unref, @@ -30,6 +31,7 @@ const { const lockPathOf = (root: string) => nodePath.join(root, "node_modules", ".databricks", "appkit", "worker.lock"); return { + generateDatabaseTypes: vi.fn(async () => {}), generateFromEntryPoint: vi.fn(async () => {}), generateServingTypes: vi.fn(async () => {}), unref, @@ -48,6 +50,8 @@ const { // command's `await import("@databricks/appkit/type-generator")` resolves to spies // and never touches a warehouse. vi.mock("@databricks/appkit/type-generator", () => ({ + DATABASE_TYPES_FILE: "database.d.ts", + generateDatabaseTypes, generateFromEntryPoint, generateServingTypes, })); @@ -143,6 +147,7 @@ describe("generate-types foreground spawn orchestration", () => { expect(generateFromEntryPoint).toHaveBeenCalledWith( expect.objectContaining({ mode: "non-blocking" }), ); + expect(generateDatabaseTypes).not.toHaveBeenCalled(); // Exactly one detached worker, re-invoking this CLI with --wait and the // worker lock, forwarding the same positional targets. @@ -170,6 +175,22 @@ describe("generate-types foreground spawn orchestration", () => { expect(unref).toHaveBeenCalledTimes(1); }); + test("generates database types without a warehouse", async () => { + delete process.env.DATABRICKS_WAREHOUSE_ID; + const schemaFile = path.join(tmpRoot, "config/database/schema.ts"); + const outFile = path.join(tmpRoot, "shared/appkit-types/analytics.d.ts"); + fs.mkdirSync(path.dirname(schemaFile), { recursive: true }); + fs.writeFileSync(schemaFile, "export const schema = {};", "utf8"); + + await runCli([tmpRoot, outFile]); + + expect(generateDatabaseTypes).toHaveBeenCalledWith({ + schemaFile, + outFile: path.join(path.dirname(outFile), "database.d.ts"), + }); + expect(generateFromEntryPoint).not.toHaveBeenCalled(); + }); + test("lock already held (fresh): does NOT spawn, foreground still resolves", async () => { acquireSpawnLock.mockReturnValue(false); diff --git a/packages/shared/src/cli/commands/generate-types.ts b/packages/shared/src/cli/commands/generate-types.ts index f38f323a2..eb07f70e7 100644 --- a/packages/shared/src/cli/commands/generate-types.ts +++ b/packages/shared/src/cli/commands/generate-types.ts @@ -56,16 +56,14 @@ async function runGenerateTypes( const mode = resolveTypegenMode(options); const typeGen = await import("@databricks/appkit/type-generator"); + const resolvedOutFile = + outFile || path.join(process.cwd(), "shared/appkit-types/analytics.d.ts"); // Generate analytics query types (requires warehouse ID) const resolvedWarehouseId = warehouseId || process.env.DATABRICKS_WAREHOUSE_ID; if (resolvedWarehouseId) { - const resolvedOutFile = - outFile || - path.join(process.cwd(), "shared/appkit-types/analytics.d.ts"); - const queryFolder = path.join(resolvedRootDir, "config/queries"); const metricViewsFolder = path.join( resolvedRootDir, @@ -115,6 +113,23 @@ async function runGenerateTypes( noCache, }); console.log(`Generated serving types: ${servingOutFile}`); + + // Generate database declarations. + const databaseSchemaFile = path.join( + resolvedRootDir, + "config/database/schema.ts", + ); + const databaseOutFile = path.join( + path.dirname(resolvedOutFile), + typeGen.DATABASE_TYPES_FILE, + ); + if (fs.existsSync(databaseSchemaFile) || fs.existsSync(databaseOutFile)) { + await typeGen.generateDatabaseTypes({ + schemaFile: databaseSchemaFile, + outFile: databaseOutFile, + }); + console.log(`Generated database types: ${databaseOutFile}`); + } } catch (error) { if ( error instanceof Error && @@ -133,7 +148,8 @@ async function runGenerateTypes( if ( error instanceof Error && (error.name === "TypegenSyntaxError" || - error.name === "TypegenFatalError") + error.name === "TypegenFatalError" || + error.name === "DatabaseTypegenError") ) { console.error(error.message); process.exit(1); @@ -267,7 +283,7 @@ async function generateTypesAction( } export const generateTypesCommand = new Command("generate-types") - .description("Generate TypeScript types from SQL queries") + .description("Generate TypeScript types from AppKit configuration") .argument("[rootDir]", "Root directory of the project", process.cwd()) .argument( "[outFile]", diff --git a/packages/shared/src/cli/commands/type-generator.d.ts b/packages/shared/src/cli/commands/type-generator.d.ts index 5e7e0a258..dfc9f3eb9 100644 --- a/packages/shared/src/cli/commands/type-generator.d.ts +++ b/packages/shared/src/cli/commands/type-generator.d.ts @@ -8,6 +8,13 @@ * `packages/appkit/src/type-generator/index.ts`. */ declare module "@databricks/appkit/type-generator" { + export const DATABASE_TYPES_FILE: "database.d.ts"; + + export function generateDatabaseTypes(options: { + schemaFile: string; + outFile: string; + }): Promise; + export function generateFromEntryPoint(options: { queryFolder?: string; metricViewsFolder?: string; @@ -26,6 +33,8 @@ declare module "@databricks/appkit/type-generator" { readonly queries: Array<{ name: string; message: string }>; } + export class DatabaseTypegenError extends Error {} + export function generateServingTypes(options: { outFile: string; noCache?: boolean; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e45b03b5..564fa78e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -320,6 +320,9 @@ importers: get-port: specifier: 7.2.0 version: 7.2.0 + jiti: + specifier: 2.6.1 + version: 2.6.1 js-yaml: specifier: 4.2.0 version: 4.2.0 diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index 078ab524a..d48af900d 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -102,6 +102,100 @@ } } }, + "database": { + "name": "database", + "displayName": "Database (Beta)", + "description": "Schema-driven typed access to Databricks Lakebase PostgreSQL", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "postgres", + "alias": "Postgres", + "resourceKey": "postgres", + "description": "Lakebase Postgres database for persistent storage", + "permission": "CAN_CONNECT_AND_CREATE", + "fields": { + "project": { + "description": "Lakebase project resource name", + "examples": [ + "projects/{project-id}" + ], + "discovery": { + "type": "kind", + "resourceKind": "postgres_project", + "select": "name" + }, + "origin": "user" + }, + "branch": { + "description": "Lakebase branch resource name", + "examples": [ + "projects/{project-id}/branches/{branch-id}" + ], + "discovery": { + "type": "kind", + "resourceKind": "postgres_branch", + "select": "name", + "dependsOn": "project" + }, + "origin": "user" + }, + "database": { + "description": "Lakebase database resource name", + "examples": [ + "projects/{project-id}/branches/{branch-id}/databases/{database-id}" + ], + "discovery": { + "type": "kind", + "resourceKind": "postgres_database", + "select": "name", + "dependsOn": "branch" + }, + "origin": "user" + }, + "host": { + "env": "PGHOST", + "description": "Postgres host", + "localOnly": true, + "resolve": "postgres:host", + "origin": "platform" + }, + "databaseName": { + "env": "PGDATABASE", + "description": "Postgres database name", + "localOnly": true, + "resolve": "postgres:databaseName", + "origin": "platform" + }, + "endpointPath": { + "env": "LAKEBASE_ENDPOINT", + "description": "Lakebase endpoint resource name", + "bundleIgnore": true, + "resolve": "postgres:endpointPath", + "origin": "cli" + }, + "port": { + "env": "PGPORT", + "description": "Postgres port", + "localOnly": true, + "value": "5432", + "origin": "platform" + }, + "sslmode": { + "env": "PGSSLMODE", + "description": "Postgres SSL mode", + "localOnly": true, + "value": "require", + "origin": "platform" + } + } + } + ], + "optional": [] + }, + "stability": "beta" + }, "files": { "name": "files", "displayName": "Files Plugin",