From 441393c2fa0942565a05973df2994b7f78daf5db Mon Sep 17 00:00:00 2001 From: ditadi Date: Thu, 6 Aug 2026 20:40:28 +0100 Subject: [PATCH] feat(appkit): add service-principal typed DatabasePlugin API Expose the hardened runtime as one service-principal plugin with typed entity clients, transactions, tagged SQL, and schema-derived declarations in the existing typegen flow. Driver, setup, and unclassified failures are logged with their original cause before the safe error replaces them, so operators can diagnose what the client never sees. Signed-off-by: ditadi --- docs/docs/api/appkit/Function.bigid.md | 9 + docs/docs/api/appkit/Function.bigint.md | 9 + docs/docs/api/appkit/Function.boolean.md | 9 + docs/docs/api/appkit/Function.database.md | 51 ++++ docs/docs/api/appkit/Function.defineSchema.md | 16 + docs/docs/api/appkit/Function.enumColumn.md | 16 + docs/docs/api/appkit/Function.fk.md | 17 ++ docs/docs/api/appkit/Function.id.md | 9 + docs/docs/api/appkit/Function.integer.md | 9 + docs/docs/api/appkit/Function.jsonb.md | 9 + docs/docs/api/appkit/Function.text.md | 9 + docs/docs/api/appkit/Function.timestamp.md | 18 ++ docs/docs/api/appkit/Function.uuid.md | 9 + docs/docs/api/appkit/Function.varchar.md | 15 + .../api/appkit/Interface.DatabaseRegistry.md | 4 + docs/docs/api/appkit/Interface.Schema.md | 25 ++ .../api/appkit/TypeAlias.DatabaseExports.md | 33 ++ .../api/appkit/TypeAlias.IDatabaseConfig.md | 23 ++ docs/docs/api/appkit/index.md | 18 ++ docs/docs/api/appkit/typedoc-sidebar.ts | 90 ++++++ packages/appkit/package.json | 1 + packages/appkit/src/beta.ts | 18 ++ .../appkit/src/database/contract/registry.ts | 8 +- .../database/contract/tests/registry.test.ts | 6 +- packages/appkit/src/database/contract/wire.ts | 6 + packages/appkit/src/database/errors.ts | 92 ++++++ .../appkit/src/database/runtime/data-path.ts | 45 +-- .../runtime/engine/drizzle-data-path.ts | 131 ++++++-- .../src/database/runtime/engine/translate.ts | 40 +-- packages/appkit/src/database/runtime/index.ts | 27 +- .../runtime/tests/data-path-contract.test.ts | 27 +- .../runtime/tests/drizzle-data-path.test.ts | 242 +++++++++++++-- .../database/runtime/tests/translate.test.ts | 54 ++-- .../database/schema-builder/define-schema.ts | 31 +- .../database/schema-builder/engine/tables.ts | 25 ++ .../tests/define-schema.test.ts | 55 ++++ packages/appkit/src/index.ts | 1 + .../src/plugins/beta-exports.generated.ts | 1 + .../appkit/src/plugins/database/database.ts | 95 ++++++ .../appkit/src/plugins/database/defaults.ts | 12 + .../src/plugins/database/entity-client.ts | 246 +++++++++++++++ .../src/plugins/database/entity-types.ts | 215 +++++++++++++ packages/appkit/src/plugins/database/index.ts | 3 + .../appkit/src/plugins/database/lifecycle.ts | 149 +++++++++ .../appkit/src/plugins/database/manifest.json | 83 +++++ .../database/tests/entity-client.test.ts | 253 +++++++++++++++ .../database/tests/entity-types.test.ts | 255 ++++++++++++++++ .../plugins/database/tests/lifecycle.test.ts | 252 +++++++++++++++ .../src/plugins/database/tests/plugin.test.ts | 158 ++++++++++ packages/appkit/src/plugins/database/types.ts | 6 + .../src/type-generator/database/generate.ts | 145 +++++++++ .../src/type-generator/database/index.ts | 5 + .../database/tests/generate.test.ts | 288 ++++++++++++++++++ .../type-generator/database/walk-schema.ts | 127 ++++++++ packages/appkit/src/type-generator/index.ts | 5 + .../type-generator/tests/vite-plugin.test.ts | 170 ++++++++++- .../appkit/src/type-generator/vite-plugin.ts | 90 +++++- packages/appkit/tsdown.config.ts | 3 +- .../src/cli/commands/generate-types.test.ts | 21 ++ .../shared/src/cli/commands/generate-types.ts | 28 +- .../src/cli/commands/type-generator.d.ts | 9 + pnpm-lock.yaml | 3 + template/appkit.plugins.json | 94 ++++++ 63 files changed, 3757 insertions(+), 166 deletions(-) create mode 100644 docs/docs/api/appkit/Function.bigid.md create mode 100644 docs/docs/api/appkit/Function.bigint.md create mode 100644 docs/docs/api/appkit/Function.boolean.md create mode 100644 docs/docs/api/appkit/Function.database.md create mode 100644 docs/docs/api/appkit/Function.defineSchema.md create mode 100644 docs/docs/api/appkit/Function.enumColumn.md create mode 100644 docs/docs/api/appkit/Function.fk.md create mode 100644 docs/docs/api/appkit/Function.id.md create mode 100644 docs/docs/api/appkit/Function.integer.md create mode 100644 docs/docs/api/appkit/Function.jsonb.md create mode 100644 docs/docs/api/appkit/Function.text.md create mode 100644 docs/docs/api/appkit/Function.timestamp.md create mode 100644 docs/docs/api/appkit/Function.uuid.md create mode 100644 docs/docs/api/appkit/Function.varchar.md create mode 100644 docs/docs/api/appkit/Interface.DatabaseRegistry.md create mode 100644 docs/docs/api/appkit/Interface.Schema.md create mode 100644 docs/docs/api/appkit/TypeAlias.DatabaseExports.md create mode 100644 docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md create mode 100644 packages/appkit/src/database/errors.ts create mode 100644 packages/appkit/src/plugins/database/database.ts create mode 100644 packages/appkit/src/plugins/database/defaults.ts create mode 100644 packages/appkit/src/plugins/database/entity-client.ts create mode 100644 packages/appkit/src/plugins/database/entity-types.ts create mode 100644 packages/appkit/src/plugins/database/index.ts create mode 100644 packages/appkit/src/plugins/database/lifecycle.ts create mode 100644 packages/appkit/src/plugins/database/manifest.json create mode 100644 packages/appkit/src/plugins/database/tests/entity-client.test.ts create mode 100644 packages/appkit/src/plugins/database/tests/entity-types.test.ts create mode 100644 packages/appkit/src/plugins/database/tests/lifecycle.test.ts create mode 100644 packages/appkit/src/plugins/database/tests/plugin.test.ts create mode 100644 packages/appkit/src/plugins/database/types.ts create mode 100644 packages/appkit/src/type-generator/database/generate.ts create mode 100644 packages/appkit/src/type-generator/database/index.ts create mode 100644 packages/appkit/src/type-generator/database/tests/generate.test.ts create mode 100644 packages/appkit/src/type-generator/database/walk-schema.ts 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..8e0a3ace6 --- /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) | + +## 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..05b828ad4 --- /dev/null +++ b/docs/docs/api/appkit/Function.defineSchema.md @@ -0,0 +1,16 @@ +# Function: defineSchema() + +```ts +function defineSchema(builder: (context: SchemaBuilderContext) => Record, options?: DefineSchemaOptions): Schema; +``` + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `builder` | (`context`: `SchemaBuilderContext`) => `Record`\<`string`, `AppKitTable`\> | +| `options?` | `DefineSchemaOptions` | + +## Returns + +[`Schema`](Interface.Schema.md) 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..f94b39339 --- /dev/null +++ b/docs/docs/api/appkit/Interface.Schema.md @@ -0,0 +1,25 @@ +# Interface: Schema + +## 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..88bc807c5 --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md @@ -0,0 +1,23 @@ +# Type Alias: IDatabaseConfig\ + +```ts +type IDatabaseConfig = { + schema: TSchema; +}; +``` + +Configuration for one schema-bound DatabasePlugin instance. + +## Type Parameters + +| Type Parameter | +| ------ | +| `TSchema` *extends* [`Schema`](Interface.Schema.md) | + +## Properties + +### schema + +```ts +readonly schema: TSchema; +``` diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index f39a52db2..6269a2dbe 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) | - | | [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) | - | | [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/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/tests/registry.test.ts b/packages/appkit/src/database/contract/tests/registry.test.ts index 68b0e5904..37daa1b90 100644 --- a/packages/appkit/src/database/contract/tests/registry.test.ts +++ b/packages/appkit/src/database/contract/tests/registry.test.ts @@ -44,11 +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"); + expectTypeOf< + DatabaseRegistryEntry["hasPrimaryKey"] + >().toEqualTypeOf(); }); }); diff --git a/packages/appkit/src/database/contract/wire.ts b/packages/appkit/src/database/contract/wire.ts index 5dd108049..0d8583023 100644 --- a/packages/appkit/src/database/contract/wire.ts +++ b/packages/appkit/src/database/contract/wire.ts @@ -6,6 +6,12 @@ export const MAX_LIMIT = 500; export const DEFAULT_LIMIT = 50; /** Max number of relations resolvable in a single `.include()`. */ export const MAX_INCLUDES = 10; + +/** 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 = Object.freeze([ "eq", diff --git a/packages/appkit/src/database/errors.ts b/packages/appkit/src/database/errors.ts new file mode 100644 index 000000000..10b162256 --- /dev/null +++ b/packages/appkit/src/database/errors.ts @@ -0,0 +1,92 @@ +import { AppKitError } from "../errors"; +import { createLogger } from "../logging/logger"; + +const logger = createLogger("database"); + +export type DatabaseErrorCategory = + | "INVALID_REQUEST" + | "CONFLICT" + | "FORBIDDEN" + | "INTERNAL" + | "SETUP_FAILED"; + +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 }, + CONFLICT: { message: "Database conflict", statusCode: 409 }, + FORBIDDEN: { message: "Database operation forbidden", statusCode: 403 }, + 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, + ) { + 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); +} + +/** 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 index 2a486d627..75df4a846 100644 --- a/packages/appkit/src/database/runtime/data-path.ts +++ b/packages/appkit/src/database/runtime/data-path.ts @@ -1,7 +1,14 @@ -import { DEFAULT_LIMIT, type FilterOperator, MAX_LIMIT } from "../contract"; +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 = string | number | bigint; +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< @@ -13,7 +20,6 @@ export type WhereClause = Readonly< Record >; -export type OrderDirection = "asc" | "desc"; export type OrderSpec = Readonly>; export interface IncludeOptions { @@ -38,18 +44,23 @@ export interface QuerySpec { export type Row = Record; -/** - * Backend-neutral operations; field names are schema keys that an adapter must - * resolve, never caller-provided SQL identifiers. - */ +/** 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 table's sole primary key with optional projection/include. */ + /** Read by the sole primary key while preserving supported query state. */ findOne( table: AppKitTable, id: IdValue, - spec?: Pick, + spec?: Pick, ): Promise; count(table: AppKitTable, where?: WhereClause): Promise; /** Return exactly one inserted row; zero or many is an invariant failure. */ @@ -69,18 +80,10 @@ export interface DataPath { transaction(callback: (tx: DataPath) => Promise): Promise; } -/** Runtime failure that does not retain driver details. */ -export class DataPathError extends Error { - constructor(message: string) { - super(message); - this.name = "DataPathError"; - } -} - /** Validate an explicit root or relation row limit. */ export function validateLimit(limit: number): number { if (!Number.isInteger(limit) || limit < 0 || limit > MAX_LIMIT) { - throw new DataPathError( + throw invalidDatabaseRequest( `limit must be an integer between 0 and ${MAX_LIMIT}`, ); } @@ -95,7 +98,7 @@ export function limitOrDefault(limit?: number): number { /** Reject offsets that PostgreSQL cannot represent safely as JS integers. */ export function validateOffset(offset: number): number { if (!Number.isSafeInteger(offset) || offset < 0) { - throw new DataPathError("offset must be a non-negative safe integer"); + throw invalidDatabaseRequest("offset must be a non-negative safe integer"); } return offset; } @@ -106,7 +109,7 @@ export function primaryKeyMeta(table: AppKitTable): ColumnMeta { (column) => column.primaryKey, ); if (primaryKeys.length !== 1) { - throw new DataPathError(`Table "${table.$name}" has no primary key`); + throw invalidDatabaseRequest(`Table "${table.$name}" has no primary key`); } return primaryKeys[0]; } @@ -118,7 +121,7 @@ export function conflictTargetMeta( ): ColumnMeta { const column = table.$columns[columnName]; if (!column || (!column.primaryKey && !column.unique)) { - throw new DataPathError( + throw invalidDatabaseRequest( `Column "${table.$name}.${columnName}" is not a conflict target`, ); } diff --git a/packages/appkit/src/database/runtime/engine/drizzle-data-path.ts b/packages/appkit/src/database/runtime/engine/drizzle-data-path.ts index 01404be8a..b79cb54c4 100644 --- a/packages/appkit/src/database/runtime/engine/drizzle-data-path.ts +++ b/packages/appkit/src/database/runtime/engine/drizzle-data-path.ts @@ -2,12 +2,20 @@ 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 type { AppKitTable, Schema } from "../../schema-builder"; +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, - DataPathError, + type IdValue, limitOrDefault, primaryKeyMeta, type Row, @@ -23,6 +31,8 @@ import { translateWhere, } from "./translate"; +const logger = createLogger("database"); + /** Concrete Drizzle seam shared by the adapter and its focused tests. */ export type DrizzleDb = NodePgDatabase>; @@ -44,7 +54,7 @@ interface RelationalQueryBuilder { /** 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 new DataPathError(`Table "${table.$name}" is not registered`); + throw invalidDatabaseRequest(`Table "${table.$name}" is not registered`); } } @@ -58,7 +68,7 @@ function relationalQueryBuilder( table.$name ]; if (!query) { - throw new DataPathError(`Table "${table.$name}" is not registered`); + throw invalidDatabaseRequest(`Table "${table.$name}" is not registered`); } return query; } @@ -75,47 +85,103 @@ function selectedColumns( /** 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 new DataPathError("Database mutation values must be an object"); + throw invalidDatabaseRequest("Database mutation values must be an object"); } for (const [key, value] of Object.entries(values)) { if (!Object.hasOwn(table.$columns, key)) { - throw new DataPathError(`Unknown column "${table.$name}.${key}"`); + throw invalidDatabaseRequest(`Unknown column "${table.$name}.${key}"`); } if (isSQLWrapper(value)) { - throw new DataPathError("Database mutation values cannot contain SQL"); + 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 DataPathError) throw error; - // Raw driver details stop at the adapter boundary. - throw new DataPathError("Database operation failed"); + 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 DataPathError("Database mutation did not return exactly one row"); + throw new DatabasePluginError("INTERNAL", "runtime"); } return rows[0]; } function expectZeroOrOne(rows: Row[]): Row | null { if (rows.length > 1) { - throw new DataPathError("Database mutation returned more than one row"); + throw new DatabasePluginError("INTERNAL", "runtime"); } return rows[0] ?? null; } -/** Adapt a Drizzle database to the backend-neutral DataPath contract. */ +/** 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); @@ -149,10 +215,22 @@ export function createDrizzleDataPath(db: DrizzleDb, schema: Schema): DataPath { }, async findOne(table, id, spec) { - const primaryKey = primaryKeyMeta(table); + const { meta: primaryKey, value: validatedId } = validatedPrimaryKey( + table, + id, + ); const row = await runDatabaseOperation(() => relationalQueryBuilder(db, schema, table).findFirst({ - where: eq(columnOf(table, primaryKey.columnName), id), + 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 @@ -181,12 +259,15 @@ export function createDrizzleDataPath(db: DrizzleDb, schema: Schema): DataPath { async update(table, id, values) { const engineTable = pgTable(table); const parameters = mutationValues(table, values); - const primaryKey = primaryKeyMeta(table); + const { meta: primaryKey, value: validatedId } = validatedPrimaryKey( + table, + id, + ); const rows = await runDatabaseOperation(() => db .update(engineTable) .set(parameters) - .where(eq(columnOf(table, primaryKey.columnName), id)) + .where(eq(columnOf(table, primaryKey.columnName), validatedId)) .returning(), ); return expectZeroOrOne(rows as Row[]); @@ -210,11 +291,14 @@ export function createDrizzleDataPath(db: DrizzleDb, schema: Schema): DataPath { }, async delete(table, id) { - const primaryKey = primaryKeyMeta(table); + const { meta: primaryKey, value: validatedId } = validatedPrimaryKey( + table, + id, + ); const rows = await runDatabaseOperation(() => db .delete(pgTable(table)) - .where(eq(columnOf(table, primaryKey.columnName), id)) + .where(eq(columnOf(table, primaryKey.columnName), validatedId)) .returning({ id: columnOf(table, primaryKey.columnName) }), ); return expectZeroOrOne(rows as Row[]) !== null; @@ -226,7 +310,7 @@ export function createDrizzleDataPath(db: DrizzleDb, schema: Schema): DataPath { ): Promise { // SQL wrappers carry structure; tagged interpolations may carry values only. if (values.some((value) => isSQLWrapper(value))) { - throw new DataPathError( + throw invalidDatabaseRequest( "Tagged SQL interpolations must be parameter values", ); } @@ -258,12 +342,13 @@ export function createDrizzleDataPath(db: DrizzleDb, schema: Schema): DataPath { // Callback errors are application-owned; sanitize only tx lifecycle errors. if ( callbackFailed && - (error === callbackError || callbackError instanceof DataPathError) + (error === callbackError || + callbackError instanceof DatabasePluginError) ) { throw callbackError; } - if (error instanceof DataPathError) throw error; - throw new DataPathError("Database operation failed"); + 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 index 383693f7e..fe7a3bd77 100644 --- a/packages/appkit/src/database/runtime/engine/translate.ts +++ b/packages/appkit/src/database/runtime/engine/translate.ts @@ -24,11 +24,11 @@ import { isFilterOperator, 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 { - DataPathError, type FilterOps, type IncludeOptions, type IncludeSpec, @@ -40,7 +40,7 @@ import { function columnMetaOf(table: AppKitTable, key: string): ColumnMeta { const column = table.$columns[key]; if (!column) { - throw new DataPathError(`Unknown column "${table.$name}.${key}"`); + throw invalidDatabaseRequest(`Unknown column "${table.$name}.${key}"`); } return column; } @@ -73,7 +73,7 @@ function assertColumnValue( value: unknown, ): void { if (!columnValueSchema(meta).safeParse(value).success) { - throw new DataPathError( + throw invalidDatabaseRequest( `Invalid ${operator} operand for "${table.$name}.${meta.columnName}"`, ); } @@ -86,14 +86,14 @@ function inList( value: unknown, ): SQL { if (!Array.isArray(value)) { - throw new DataPathError('The "in" operator requires an array'); + throw invalidDatabaseRequest('The "in" operator requires an array'); } if (value.length > IN_CAP) { - throw new DataPathError(`in list exceeds the ${IN_CAP}-value limit`); + throw invalidDatabaseRequest(`in list exceeds the ${IN_CAP}-value limit`); } for (const item of value) { if (item === null) { - throw new DataPathError('The "in" operator does not accept null'); + throw invalidDatabaseRequest('The "in" operator does not accept null'); } assertColumnValue(table, meta, "in", item); } @@ -108,13 +108,13 @@ function translateOperator( value: unknown, ): SQL { if (!supportsOperator(meta, operator)) { - throw new DataPathError( + throw invalidDatabaseRequest( `Operator "${operator}" is not supported for "${table.$name}.${meta.columnName}"`, ); } if (operator === "is") { if (value !== null) { - throw new DataPathError('The "is" operator accepts only null'); + throw invalidDatabaseRequest('The "is" operator accepts only null'); } return isNull(column); } @@ -139,7 +139,7 @@ function translateOperator( case "ilike": return ilike(column, value as string); default: - throw new DataPathError(`Unsupported filter operator "${operator}"`); + throw invalidDatabaseRequest(`Unsupported filter operator "${operator}"`); } } @@ -149,18 +149,20 @@ export function translateWhere( clause: WhereClause, ): SQL | undefined { if (clause === null || typeof clause !== "object" || Array.isArray(clause)) { - throw new DataPathError("where must be an object"); + 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 new DataPathError(`${key} requires a non-empty predicate array`); + throw invalidDatabaseRequest( + `${key} requires a non-empty predicate array`, + ); } const groups = value.map((group) => { const translated = translateWhere(table, group as WhereClause); if (!translated) { - throw new DataPathError(`${key} predicates cannot be empty`); + throw invalidDatabaseRequest(`${key} predicates cannot be empty`); } return translated; }); @@ -182,13 +184,13 @@ export function translateWhere( ) { const operators = Object.entries(value as FilterOps); if (operators.length === 0) { - throw new DataPathError( + throw invalidDatabaseRequest( `Filter for "${table.$name}.${key}" cannot be empty`, ); } for (const [operator, operand] of operators) { if (!isFilterOperator(operator)) { - throw new DataPathError(`Unknown filter operator "${operator}"`); + throw invalidDatabaseRequest(`Unknown filter operator "${operator}"`); } conditions.push( translateOperator(table, meta, column, operator, operand), @@ -204,7 +206,7 @@ export function translateWhere( export function translateOrder(table: AppKitTable, order: OrderSpec): SQL[] { return Object.entries(order).map(([key, direction]) => { if (direction !== "asc" && direction !== "desc") { - throw new DataPathError(`Unknown order direction "${direction}"`); + throw invalidDatabaseRequest(`Unknown order direction "${direction}"`); } const column = columnOf(table, key); return direction === "desc" ? desc(column) : asc(column); @@ -225,7 +227,7 @@ export function selectToColumns( function tableByName(schema: Schema, name: string): AppKitTable { const table = schema.$tables[name]; - if (!table) throw new DataPathError(`Unknown table "${name}"`); + if (!table) throw invalidDatabaseRequest(`Unknown table "${name}"`); return table; } @@ -237,7 +239,7 @@ export function translateInclude( ): Record { const entries = Object.entries(include); if (entries.length > MAX_INCLUDES) { - throw new DataPathError( + throw invalidDatabaseRequest( `include exceeds the ${MAX_INCLUDES}-relation limit`, ); } @@ -248,7 +250,7 @@ export function translateInclude( (candidate) => candidate.name === relationName, ); if (!relation) { - throw new DataPathError( + throw invalidDatabaseRequest( `Unknown relation "${table.$name}.${relationName}"`, ); } @@ -278,7 +280,7 @@ export function translateInclude( } if (options.limit !== undefined) { if (relation.cardinality !== "toMany") { - throw new DataPathError("Only to-many relations accept a limit"); + throw invalidDatabaseRequest("Only to-many relations accept a limit"); } relationConfig.limit = validateLimit(options.limit); } else if (relation.cardinality === "toMany") { diff --git a/packages/appkit/src/database/runtime/index.ts b/packages/appkit/src/database/runtime/index.ts index 057a4e29b..987f4611c 100644 --- a/packages/appkit/src/database/runtime/index.ts +++ b/packages/appkit/src/database/runtime/index.ts @@ -1,15 +1,14 @@ -export { - type DataPath, - DataPathError, - type FilterOps, - type IdValue, - type IncludeOptions, - type IncludeSpec, - type OrderDirection, - type OrderSpec, - type QuerySpec, - type Row, - type ScalarValue, - type WhereClause, - type WhereValue, +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 index 18e39e09a..f78b46208 100644 --- a/packages/appkit/src/database/runtime/tests/data-path-contract.test.ts +++ b/packages/appkit/src/database/runtime/tests/data-path-contract.test.ts @@ -1,5 +1,6 @@ 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, @@ -8,13 +9,7 @@ import { validateLimit, validateOffset, } from "../data-path"; -import { - type DataPath, - DataPathError, - type IdValue, - type QuerySpec, - type Row, -} from "../index"; +import type { DataPath, IdValue, QuerySpec, Row } from "../index"; const schema = defineSchema((builder) => ({ users: builder.table("users", { @@ -58,8 +53,8 @@ describe("DataPath contract", () => { expectTypeOf(spec).toMatchTypeOf(); }); - it("publishes only the Phase 1 runtime values", async () => { - expect(Object.keys(await import("../index"))).toEqual(["DataPathError"]); + it("keeps the runtime barrel type-only", async () => { + expect(Object.keys(await import("../index"))).toEqual([]); }); }); @@ -68,23 +63,25 @@ describe("runtime bounds and metadata", () => { expect(limitOrDefault()).toBe(DEFAULT_LIMIT); expect(validateLimit(0)).toBe(0); expect(validateLimit(MAX_LIMIT)).toBe(MAX_LIMIT); - expect(() => validateLimit(-1)).toThrow(DataPathError); - expect(() => validateLimit(MAX_LIMIT + 1)).toThrow(DataPathError); + 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(DataPathError); - expect(() => validateOffset(Number.MAX_VALUE)).toThrow(DataPathError); + 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(DataPathError); + expect(() => primaryKeyMeta(schema.$tables.events)).toThrow( + DatabasePluginError, + ); expect(() => conflictTargetMeta(schema.$tables.users, "body")).toThrow( - DataPathError, + 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 index 4998a9eba..2d8d0fde0 100644 --- a/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts +++ b/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts @@ -3,8 +3,17 @@ 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 { boolean, defineSchema, fk, id, text } from "../../schema-builder"; -import { DataPathError, type Row } from "../data-path"; +import { DatabasePluginError } from "../../errors"; +import { + bigid, + boolean, + defineSchema, + fk, + id, + text, + uuid, +} from "../../schema-builder"; +import type { Row } from "../data-path"; import { createDrizzleDataPath, createDrizzleDb, @@ -219,16 +228,22 @@ describe("createDrizzleDataPath reads", () => { }); await expect( dataPath.select(users, { limit: MAX_LIMIT + 1 }), - ).rejects.toBeInstanceOf(DataPathError); + ).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, { select: ["id", "name"] }), + 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]); + 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]); }); @@ -242,8 +257,74 @@ describe("createDrizzleDataPath reads", () => { other.$tables.users, {}, ), - ).rejects.toBeInstanceOf(DataPathError); + ).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", () => { @@ -260,13 +341,13 @@ describe("Drizzle mutation cardinality", () => { users, {}, ), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); await expect( createDrizzleDataPath( makeFakeDb({ insert: [row, row] }).db, schema, ).insert(users, {}), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); const fake = makeFakeDb({ upsert: [row] }); await expect( @@ -281,20 +362,20 @@ describe("Drizzle mutation cardinality", () => { ); await expect( createDrizzleDataPath(fake.db, schema).upsert(users, {}, "name"), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); await expect( createDrizzleDataPath(makeFakeDb({ upsert: [] }).db, schema).upsert( users, { email: "a@example.com" }, "email", ), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); await expect( createDrizzleDataPath( makeFakeDb({ upsert: [row, row] }).db, schema, ).upsert(users, { email: "a@example.com" }, "email"), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); }); it("rejects unknown identifiers and structural Drizzle mutation values", async () => { @@ -303,20 +384,20 @@ describe("Drizzle mutation cardinality", () => { await expect( dataPath.insert(users, { missing: "not a schema column" }), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); await expect( dataPath.insert(users, { name: drizzleSql.raw("current_user") }), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); await expect( dataPath.update(users, 1, { name: users.$columns.email.engineColumn }), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); await expect( dataPath.upsert( users, { email: "a@example.com", name: drizzleSql.raw("current_user") }, "email", ), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); expect(fake.calls.insert).toHaveLength(0); expect(fake.calls.update).toHaveLength(0); @@ -344,7 +425,7 @@ describe("Drizzle mutation cardinality", () => { makeFakeDb({ update: [row, row] }).db, schema, ).update(users, 1, {}), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); }); it("accepts zero or one delete row and rejects more", async () => { @@ -365,7 +446,7 @@ describe("Drizzle mutation cardinality", () => { makeFakeDb({ delete: [{ id: 1 }, { id: 2 }] }).db, schema, ).delete(users, 1), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); }); }); @@ -384,7 +465,7 @@ describe("tagged SQL and transactions", () => { await expect( dataPath.raw`select ${drizzleSql.raw("drop table users")}`, - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); expect(fake.calls.execute).toHaveLength(1); }); @@ -403,7 +484,10 @@ describe("tagged SQL and transactions", () => { }), ).rejects.toBe(callbackError); - const classifiedError = new DataPathError("Already classified"); + const classifiedError = new DatabasePluginError( + "INVALID_REQUEST", + "runtime", + ); await expect( dataPath.transaction(async () => { throw classifiedError; @@ -429,7 +513,7 @@ describe("tagged SQL and transactions", () => { }) .catch((caught) => caught); - expect(error).toBeInstanceOf(DataPathError); + expect(error).toBeInstanceOf(DatabasePluginError); expect(error.message).toBe("Database operation failed"); expect(error.cause).toBeUndefined(); }, @@ -450,10 +534,126 @@ describe("database failures", () => { const error = await createDrizzleDataPath(fake.db, schema) .select(users, {}) .catch((caught) => caught); - expect(error).toBeInstanceOf(DataPathError); + 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", () => { diff --git a/packages/appkit/src/database/runtime/tests/translate.test.ts b/packages/appkit/src/database/runtime/tests/translate.test.ts index 775316be7..b1b9e65b6 100644 --- a/packages/appkit/src/database/runtime/tests/translate.test.ts +++ b/packages/appkit/src/database/runtime/tests/translate.test.ts @@ -2,6 +2,7 @@ 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, @@ -16,7 +17,6 @@ import { uuid, } from "../../schema-builder"; import { filterOperatorsForKind } from "../../schema-builder/types"; -import { DataPathError } from "../data-path"; import { defaultColumns, selectToColumns, @@ -85,7 +85,7 @@ describe("translateWhere", () => { expect(query.sql).not.toContain("drop table"); expect(query.params).toEqual([injected]); expect(() => translateWhere(users, { missing: injected })).toThrow( - DataPathError, + DatabasePluginError, ); }); @@ -106,7 +106,7 @@ describe("translateWhere", () => { } expect(() => translateWhere(users, { age: { between: [1, 2] } as never }), - ).toThrow(DataPathError); + ).toThrow(DatabasePluginError); }); it("bounds in lists and gives an empty list deterministic semantics", () => { @@ -118,10 +118,10 @@ describe("translateWhere", () => { translateWhere(users, { id: { in: Array.from({ length: IN_CAP + 1 }, (_, index) => index) }, }), - ).toThrow(DataPathError); + ).toThrow(DatabasePluginError); expect(() => translateWhere(users, { name: { in: ["Ada", null] } }), - ).toThrow(DataPathError); + ).toThrow(DatabasePluginError); }); it("rejects operators and values that do not match column metadata", () => { @@ -142,21 +142,21 @@ describe("translateWhere", () => { expect(() => translateWhere(users, { age: { like: "1%" } as never }), - ).toThrow(DataPathError); + ).toThrow(DatabasePluginError); expect(() => translateWhere(users, { active: { gt: true } as never }), - ).toThrow(DataPathError); + ).toThrow(DatabasePluginError); expect(() => translateWhere(users, { metadata: { eq: { key: "value" } } as never }), - ).toThrow(DataPathError); + ).toThrow(DatabasePluginError); expect(() => translateWhere(users, { externalId: "not-a-uuid" })).toThrow( - DataPathError, + DatabasePluginError, ); expect(() => translateWhere(users, { createdAt: { gt: "not-a-timestamp" } }), - ).toThrow(DataPathError); + ).toThrow(DatabasePluginError); expect(() => translateWhere(users, { status: "unknown" })).toThrow( - DataPathError, + DatabasePluginError, ); }); @@ -164,18 +164,24 @@ describe("translateWhere", () => { expect(render(translateWhere(users, { name: { is: null } })).sql).toBe( `"users"."name" is null`, ); - expect(() => translateWhere(users, { name: null })).toThrow(DataPathError); + expect(() => translateWhere(users, { name: null })).toThrow( + DatabasePluginError, + ); expect(() => translateWhere(users, { name: { eq: null } })).toThrow( - DataPathError, + DatabasePluginError, ); expect(() => translateWhere(users, { active: { is: null } })).toThrow( - DataPathError, + DatabasePluginError, ); }); it("rejects empty logical groups instead of widening a query", () => { - expect(() => translateWhere(users, { or: [] })).toThrow(DataPathError); - expect(() => translateWhere(users, { and: [{}] })).toThrow(DataPathError); + expect(() => translateWhere(users, { or: [] })).toThrow( + DatabasePluginError, + ); + expect(() => translateWhere(users, { and: [{}] })).toThrow( + DatabasePluginError, + ); }); it("combines and/or groups without relation predicates", () => { @@ -216,11 +222,13 @@ describe("ordering and selection", () => { secret: true, }); expect(() => translateOrder(users, { missing: "asc" })).toThrow( - DataPathError, + DatabasePluginError, + ); + expect(() => selectToColumns(users, ["missing"])).toThrow( + DatabasePluginError, ); - expect(() => selectToColumns(users, ["missing"])).toThrow(DataPathError); expect(() => translateOrder(users, { age: "sideways" as "asc" })).toThrow( - DataPathError, + DatabasePluginError, ); }); }); @@ -251,14 +259,14 @@ describe("translateInclude", () => { it("rejects unknown relations and invalid relation limits", () => { expect(() => translateInclude(users, schema, { missing: true })).toThrow( - DataPathError, + DatabasePluginError, ); expect(() => translateInclude(users, schema, { posts: { limit: MAX_LIMIT + 1 } }), - ).toThrow(DataPathError); + ).toThrow(DatabasePluginError); expect(() => translateInclude(schema.$tables.posts, schema, { users: { limit: 1 } }), - ).toThrow(DataPathError); + ).toThrow(DatabasePluginError); const tooMany = Object.fromEntries( Array.from({ length: MAX_INCLUDES + 1 }, (_, index) => [ @@ -267,7 +275,7 @@ describe("translateInclude", () => { ]), ); expect(() => translateInclude(users, schema, tooMany)).toThrow( - DataPathError, + DatabasePluginError, ); }); }); diff --git a/packages/appkit/src/database/schema-builder/define-schema.ts b/packages/appkit/src/database/schema-builder/define-schema.ts index 276405e1a..be24ea6ff 100644 --- a/packages/appkit/src/database/schema-builder/define-schema.ts +++ b/packages/appkit/src/database/schema-builder/define-schema.ts @@ -43,6 +43,9 @@ export interface SchemaBuilderContext { } 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", @@ -69,6 +72,13 @@ function assertName(value: string, label: string): void { } } +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( @@ -100,7 +110,7 @@ function declareTable>( name: string, columns: C, ): TableHandle { - assertName(name, "Table name"); + assertTableName(name); if (state.raw.has(name)) { throw new SchemaBuildError(`Duplicate table "${name}"`); } @@ -215,6 +225,7 @@ function validateHandles(raw: ReadonlyMap): void { } } +/** 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( @@ -228,6 +239,7 @@ function validatePrimaryKeys(raw: ReadonlyMap): void { } } +/** Reject table names that collide with generated Drizzle relation keys. */ function validateRelationKeys( raw: ReadonlyMap, relations: ReadonlyMap, @@ -307,11 +319,24 @@ function publishSchema( engine[table.name] = candidate.engine; } - return Object.freeze({ + const schema = { $schemaName: schemaName, $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"); + } } export function defineSchema( diff --git a/packages/appkit/src/database/schema-builder/engine/tables.ts b/packages/appkit/src/database/schema-builder/engine/tables.ts index 343bf8138..83fdf0733 100644 --- a/packages/appkit/src/database/schema-builder/engine/tables.ts +++ b/packages/appkit/src/database/schema-builder/engine/tables.ts @@ -166,6 +166,25 @@ function buildColumn( return column; } +// 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}`; +} + function buildTable( name: string, schemaName: string, @@ -192,6 +211,12 @@ function buildTable( `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"]; } 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 c4c4661ef..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 @@ -25,6 +25,18 @@ import type { EngineTable } from "../types"; const pgOf = (table: EngineTable): PgTable => table as unknown as PgTable; 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(), @@ -283,6 +295,49 @@ describe("builder reuse and engine metadata", () => { expect(column.default).toBe(value); }); + 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("validates literal defaults against storage and enum values", () => { const schema = defineSchema((builder) => ({ records: builder.table("records", { 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/database.ts b/packages/appkit/src/plugins/database/database.ts new file mode 100644 index 000000000..f5109b7f2 --- /dev/null +++ b/packages/appkit/src/plugins/database/database.ts @@ -0,0 +1,95 @@ +import type { BasePluginConfig, PluginConstructor } from "shared"; +import { DatabasePluginError } from "../../database/errors"; +import type { Schema } from "../../database/schema-builder"; +import { Plugin } from "../../plugin"; +import type { PluginManifest } from "../../registry"; +import type { DatabaseExports } from "./entity-types"; +import { createDatabaseState, type DatabaseState } from "./lifecycle"; +import manifest from "./manifest.json"; +import type { IDatabaseConfig } 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; + + constructor(config: IDatabaseConfig) { + super({ schema: config.schema }); + this.config = { schema: config.schema }; + } + + /** Build and verify one candidate state before publishing its exports. */ + async setup(): Promise { + if (this.draining || this.state) + throw new DatabasePluginError("SETUP_FAILED", "setup"); + if (!this.setupPromise) { + const attempt = (async () => { + 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 new DatabasePluginError("SETUP_FAILED", "setup"); + } + this.state = candidate; + })(); + this.setupPromise = attempt; + } + return this.setupPromise; + } + + /** 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; + } +} + +/** 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..68fdcef63 --- /dev/null +++ b/packages/appkit/src/plugins/database/defaults.ts @@ -0,0 +1,12 @@ +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 }, +}; 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..927236870 --- /dev/null +++ b/packages/appkit/src/plugins/database/entity-types.ts @@ -0,0 +1,215 @@ +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; + +export type IncludeOptionsFor< + Registry extends RegistryShape, + K extends EntityNameFor, + Many extends boolean, +> = { + readonly select?: readonly (keyof RowOfFor & string)[]; + readonly where?: FiltersOfFor; + readonly order?: Partial< + Record & string, OrderDirection> + >; +} & (Many extends true + ? { readonly limit?: number } + : { readonly limit?: never }); + +export type IncludeArgFor< + Registry extends RegistryShape, + K extends EntityNameFor, +> = { + readonly [Relation in keyof IncludesOfFor]?: + | boolean + | IncludeOptionsFor< + Registry, + RelationTargetFor, + IncludesOfFor[Relation] extends { + many: infer Many extends boolean; + } + ? Many + : false + >; +}; + +// Explicit relation projections use trusted rows; implicit projections stay public. +type IncludedRowFor< + Registry extends RegistryShape, + K extends EntityNameFor, + Config, +> = Config extends { + readonly select: infer Columns extends readonly (keyof RowOfFor & + string)[]; +} + ? Pick, Columns[number]> + : PublicRowOfFor; + +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/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..6b90dff3d --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/entity-types.test.ts @@ -0,0 +1,255 @@ +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: Record; + 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"] } } + >; + + expectTypeOf>().toEqualTypeOf< + TestRegistry["users"]["publicRow"] + >(); + expectTypeOf>().toEqualTypeOf< + TestRegistry["users"]["publicRow"] + >(); + expectTypeOf().toEqualTypeOf< + TestRegistry["comments"]["publicRow"][] + >(); + expectTypeOf>().toEqualTypeOf<{ + token: string; + }>(); + }); + + 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 } }); + + // @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 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 nested includes are not part of one-edge options + notes.include({ comments: { include: { author: true } } }); + } + }); + + 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..1e0704d16 --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/plugin.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, expectTypeOf, test, vi } from "vitest"; +import { defineSchema } 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(() => ({})); +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("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..1cffbbb1d --- /dev/null +++ b/packages/appkit/src/plugins/database/types.ts @@ -0,0 +1,6 @@ +import type { Schema } from "../../database/schema-builder"; + +/** Configuration for one schema-bound DatabasePlugin instance. */ +export type IDatabaseConfig = { + readonly schema: TSchema; +}; 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",