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