diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc0fb6272..1a60176c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -213,6 +213,9 @@ jobs: working-directory: pr-template run: npm install + - name: Validate database template startup declarations + run: pnpm exec tsx tools/check-database-template.ts pr-template + # npm install above runs under the JFrog .npmrc (setup-jfrog-npm), which # bakes internal registry URLs into the regenerated lock. Rewrite them back # to public npm and fail-closed if any non-public registry remains, so the diff --git a/docs/docs/api/appkit/Function.database.md b/docs/docs/api/appkit/Function.database.md index 9aa74dcf8..ec21cfd6c 100644 --- a/docs/docs/api/appkit/Function.database.md +++ b/docs/docs/api/appkit/Function.database.md @@ -1,51 +1,62 @@ # Function: database() +## Call Signature + ```ts -function database(config: IDatabaseConfig): { - config: IDatabaseConfig; - name: "database"; - plugin: PluginConstructor>; +function database(config: IDatabaseConfig & { + schema: TSchema; +}): DatabaseRegistration & { + config: IDatabaseConfig & { + schema: TSchema; + }; }; ``` -Create a typed database plugin registration for a finalized schema. +Create the database plugin. Omit configuration to load +`config/database/schema.ts`, or supply a typed schema override. -## Type Parameters +### Type Parameters | Type Parameter | | ------ | | `TSchema` *extends* [`Schema`](Interface.Schema.md)\<`string`\> | -## Parameters +### Parameters | Parameter | Type | | ------ | ------ | -| `config` | [`IDatabaseConfig`](TypeAlias.IDatabaseConfig.md)\<`TSchema`\> | +| `config` | [`IDatabaseConfig`](TypeAlias.IDatabaseConfig.md)\<`TSchema`\> & \{ `schema`: `TSchema`; \} | -## Returns +### Returns -```ts -{ - config: IDatabaseConfig; - name: "database"; - plugin: PluginConstructor>; -} -``` +`DatabaseRegistration`\<`TSchema`\> & \{ + `config`: [`IDatabaseConfig`](TypeAlias.IDatabaseConfig.md)\<`TSchema`\> & \{ + `schema`: `TSchema`; + \}; +\} -### config +## Call Signature ```ts -config: IDatabaseConfig; +function database(config?: IDatabaseConfig): DatabaseRegistration; ``` -### name +Register the database plugin with opinionated defaults and full HTTP CRUD. +By default, setup loads the named `schema` export from the application's +`config/database/schema.ts`. Registration itself performs no file or database I/O. -```ts -name: "database"; -``` +### Type Parameters -### plugin +| Type Parameter | Default type | +| ------ | ------ | +| `TSchema` *extends* [`Schema`](Interface.Schema.md)\<`string`\> | `DefaultDatabaseSchema` | -```ts -plugin: PluginConstructor>; -``` +### Parameters + +| Parameter | Type | +| ------ | ------ | +| `config?` | [`IDatabaseConfig`](TypeAlias.IDatabaseConfig.md)\<`TSchema`\> | + +### Returns + +`DatabaseRegistration`\<`TSchema`\> diff --git a/docs/docs/api/appkit/Function.defineEvalConfig.md b/docs/docs/api/appkit/Function.defineEvalConfig.md new file mode 100644 index 000000000..72de6c6e0 --- /dev/null +++ b/docs/docs/api/appkit/Function.defineEvalConfig.md @@ -0,0 +1,17 @@ +# Function: defineEvalConfig() + +```ts +function defineEvalConfig(config: EvalConfig): EvalConfig; +``` + +Define per-directory eval config. Default-export from `evals.config.ts`. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `config` | `EvalConfig` | + +## Returns + +`EvalConfig` diff --git a/docs/docs/api/appkit/Function.discoverEvalConfigs.md b/docs/docs/api/appkit/Function.discoverEvalConfigs.md new file mode 100644 index 000000000..14fc5500a --- /dev/null +++ b/docs/docs/api/appkit/Function.discoverEvalConfigs.md @@ -0,0 +1,20 @@ +# Function: discoverEvalConfigs() + +```ts +function discoverEvalConfigs(rootDir: string): DiscoveredEvalConfig[]; +``` + +Discover the per-agent `evals.config.ts` (from [defineEvalConfig](Function.defineEvalConfig.md)) at +`/server/agents//evals/evals.config.ts`. Config is per-agent: +each agent's config applies only to that agent's evals. Agents without a +config file are omitted. Returns a stable, sorted list. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `rootDir` | `string` | + +## Returns + +[`DiscoveredEvalConfig`](Interface.DiscoveredEvalConfig.md)[] diff --git a/docs/docs/api/appkit/Function.formatResultsJUnit.md b/docs/docs/api/appkit/Function.formatResultsJUnit.md new file mode 100644 index 000000000..de0e3df36 --- /dev/null +++ b/docs/docs/api/appkit/Function.formatResultsJUnit.md @@ -0,0 +1,20 @@ +# Function: formatResultsJUnit() + +```ts +function formatResultsJUnit(results: EvalResult[]): string; +``` + +Render results as JUnit XML for standard CI test reporters: a single +`` with one `` per result. +Failures carry a `` (error or failing-gate summary); skips a +``. All attribute/text values are XML-escaped. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `results` | [`EvalResult`](Interface.EvalResult.md)[] | + +## Returns + +`string` diff --git a/docs/docs/api/appkit/Function.formatResultsJson.md b/docs/docs/api/appkit/Function.formatResultsJson.md new file mode 100644 index 000000000..7cf11944a --- /dev/null +++ b/docs/docs/api/appkit/Function.formatResultsJson.md @@ -0,0 +1,19 @@ +# Function: formatResultsJson() + +```ts +function formatResultsJson(results: EvalResult[]): string; +``` + +Render results as a machine-readable JSON report (2-space indented): +`{ summary: EvalSummary, results: EvalResult[] }`. Faithful to the types — +every field present on a result round-trips. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `results` | [`EvalResult`](Interface.EvalResult.md)[] | + +## Returns + +`string` diff --git a/docs/docs/api/appkit/Function.readEvalDataset.md b/docs/docs/api/appkit/Function.readEvalDataset.md new file mode 100644 index 000000000..faf01d49f --- /dev/null +++ b/docs/docs/api/appkit/Function.readEvalDataset.md @@ -0,0 +1,26 @@ +# Function: readEvalDataset() + +```ts +function readEvalDataset(client: WorkspaceClient, options: ReadEvalDatasetOptions): Promise; +``` + +Read a Databricks managed evaluation dataset (a Unity Catalog table with +`inputs`/`expectations` columns) into rows, over the public SQL Statement +Execution API. Reuses SQLWarehouseConnector for submit/poll/transform +— its result transform already JSON-parses string columns into objects, so +`inputs`/`expectations` come back as records whether the table stores them as +JSON strings or structs. + +The Python `mlflow.genai.datasets` API needs a Spark session (no TS +equivalent), so we read the backing table directly. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `client` | [`WorkspaceClient`](Interface.WorkspaceClient.md) | +| `options` | [`ReadEvalDatasetOptions`](Interface.ReadEvalDatasetOptions.md) | + +## Returns + +`Promise`\<[`DatasetRow`](Interface.DatasetRow.md)[]\> diff --git a/docs/docs/api/appkit/Function.resolveWorkspaceClient.md b/docs/docs/api/appkit/Function.resolveWorkspaceClient.md new file mode 100644 index 000000000..49d9fe00b --- /dev/null +++ b/docs/docs/api/appkit/Function.resolveWorkspaceClient.md @@ -0,0 +1,21 @@ +# Function: resolveWorkspaceClient() + +```ts +function resolveWorkspaceClient(options: ResolveDatabricksAuthOptions): WorkspaceClient | undefined; +``` + +Construct a Databricks `WorkspaceClient` for the eval runner — the object the +SDK-backed connectors (e.g. `SQLWarehouseConnector`) take. An explicit +host+token builds a PAT client; otherwise the profile (or ambient config) is +used and the SDK resolves credentials, minting OAuth as needed. Returns +`undefined` if construction throws (missing/invalid config). + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `options` | [`ResolveDatabricksAuthOptions`](Interface.ResolveDatabricksAuthOptions.md) | + +## Returns + +[`WorkspaceClient`](Interface.WorkspaceClient.md) \| `undefined` diff --git a/docs/docs/api/appkit/Function.runWithRetries.md b/docs/docs/api/appkit/Function.runWithRetries.md new file mode 100644 index 000000000..81a35419e --- /dev/null +++ b/docs/docs/api/appkit/Function.runWithRetries.md @@ -0,0 +1,33 @@ +# Function: runWithRetries() + +```ts +function runWithRetries( + retries: number, + attempt: (attemptNumber: number) => Promise, + options: { + baseDelayMs?: number; +}): Promise; +``` + +Run `attempt` up to `1 + retries` times, stopping as soon as it returns a +result that is neither a thrown error / per-eval timeout (`error`) nor a +transport/agent turn failure (`infraFailure`). Assertion failures set +neither, so a failed-but-completed eval is returned on the first try and +never retried. Returns the last result when every attempt failed on infra. + +Between attempts it waits a full-jittered exponential backoff (infra flakes +are overload-correlated). `retries` is coerced to a finite non-negative +integer; `baseDelayMs: 0` disables the wait (tests). + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `retries` | `number` | +| `attempt` | (`attemptNumber`: `number`) => `Promise`\<[`EvalResult`](Interface.EvalResult.md)\> | +| `options` | \{ `baseDelayMs?`: `number`; \} | +| `options.baseDelayMs?` | `number` | + +## Returns + +`Promise`\<[`EvalResult`](Interface.EvalResult.md)\> diff --git a/docs/docs/api/appkit/Function.userTurns.md b/docs/docs/api/appkit/Function.userTurns.md new file mode 100644 index 000000000..1ad9c8706 --- /dev/null +++ b/docs/docs/api/appkit/Function.userTurns.md @@ -0,0 +1,26 @@ +# Function: userTurns() + +```ts +function userTurns(input: Record): string[]; +``` + +Extract every user-message content, in order, from an MLflow +`{"messages":[{"role":"user","content":"..."}]}` input. A dataset row can +carry a full multi-turn conversation; replaying these against one thread (one +`t.send` per returned string) lets the agent see the accumulating history. + +Only `role === "user"` turns are returned — any interleaved `assistant`/ +`system` messages in the row are ignored, since the agent generates its own +responses; you never inject the dataset's assistant turns. A single-user-turn +row yields a one-element array (backward compatible); a row with no `messages` +yields `[]`. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `input` | `Record`\<`string`, `unknown`\> | + +## Returns + +`string`[] diff --git a/docs/docs/api/appkit/Interface.AgentsPluginConfig.md b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md index a2f2c963f..2f888b9d0 100644 --- a/docs/docs/api/appkit/Interface.AgentsPluginConfig.md +++ b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md @@ -260,6 +260,23 @@ are discovered at boot and on `reload()` and read as the service principal. *** +### streamConfig? + +```ts +optional streamConfig: StreamConfig; +``` + +SSE stream configuration for this plugin's `executeStream()` calls (buffer +sizes, `maxEventSize`, TTL, heartbeat). Sets the plugin's StreamManager +defaults; a per-call `stream` config still overrides these. Use it to raise +`maxEventSize` above the 5 MiB default when a stream emits larger events. + +#### Inherited from + +[`BasePluginConfig`](Interface.BasePluginConfig.md).[`streamConfig`](Interface.BasePluginConfig.md#streamconfig) + +*** + ### telemetry? ```ts diff --git a/docs/docs/api/appkit/Interface.AssertionHandle.md b/docs/docs/api/appkit/Interface.AssertionHandle.md index 02e3c746b..0e640960b 100644 --- a/docs/docs/api/appkit/Interface.AssertionHandle.md +++ b/docs/docs/api/appkit/Interface.AssertionHandle.md @@ -12,7 +12,9 @@ metric; `.atLeast(n)` is a soft, score-thresholded assertion. atLeast(threshold: number): AssertionHandle; ``` -Soft assertion that passes only when the score is at least `threshold`. +Set the pass threshold for a scored assertion: it passes only when the +score is at least `threshold`. Keeps the current severity (gate unless also +chained with `.soft()`). #### Parameters diff --git a/docs/docs/api/appkit/Interface.BasePluginConfig.md b/docs/docs/api/appkit/Interface.BasePluginConfig.md index a109fd560..9ee98474b 100644 --- a/docs/docs/api/appkit/Interface.BasePluginConfig.md +++ b/docs/docs/api/appkit/Interface.BasePluginConfig.md @@ -32,6 +32,19 @@ optional name: string; *** +### streamConfig? + +```ts +optional streamConfig: StreamConfig; +``` + +SSE stream configuration for this plugin's `executeStream()` calls (buffer +sizes, `maxEventSize`, TTL, heartbeat). Sets the plugin's StreamManager +defaults; a per-call `stream` config still overrides these. Use it to raise +`maxEventSize` above the 5 MiB default when a stream emits larger events. + +*** + ### telemetry? ```ts diff --git a/docs/docs/api/appkit/Interface.DatasetRow.md b/docs/docs/api/appkit/Interface.DatasetRow.md new file mode 100644 index 000000000..32644c2dc --- /dev/null +++ b/docs/docs/api/appkit/Interface.DatasetRow.md @@ -0,0 +1,22 @@ +# Interface: DatasetRow + +One row of a managed evaluation dataset. `inputs` are the kwargs passed to the +agent for the turn; `expectations` (when present) is the row's ground truth / +guidelines. Mirrors the `{inputs, expectations}` shape of `mlflow.genai` +datasets and of the Unity Catalog table backing a managed eval dataset. + +## Properties + +### expectations? + +```ts +optional expectations: Record; +``` + +*** + +### inputs + +```ts +inputs: Record; +``` diff --git a/docs/docs/api/appkit/Interface.DiscoveredEvalConfig.md b/docs/docs/api/appkit/Interface.DiscoveredEvalConfig.md new file mode 100644 index 000000000..0f75d6015 --- /dev/null +++ b/docs/docs/api/appkit/Interface.DiscoveredEvalConfig.md @@ -0,0 +1,23 @@ +# Interface: DiscoveredEvalConfig + +A per-agent `evals.config.ts` found under `server/agents//evals/`. + +## Properties + +### agent + +```ts +agent: string; +``` + +The agent id whose evals this config applies to. + +*** + +### file + +```ts +file: string; +``` + +Absolute path to the `evals.config.ts` file. diff --git a/docs/docs/api/appkit/Interface.DriveResult.md b/docs/docs/api/appkit/Interface.DriveResult.md index 15625c1ac..f168e875a 100644 --- a/docs/docs/api/appkit/Interface.DriveResult.md +++ b/docs/docs/api/appkit/Interface.DriveResult.md @@ -34,6 +34,31 @@ Whether the turn completed without an agent/stream error. *** +### toolCallDetails + +```ts +toolCallDetails: { + args: Record; + name: string; +}[]; +``` + +Tool calls with their parsed arguments, in call order. + +#### args + +```ts +args: Record; +``` + +#### name + +```ts +name: string; +``` + +*** + ### toolCalls ```ts diff --git a/docs/docs/api/appkit/Interface.EvalDefinition.md b/docs/docs/api/appkit/Interface.EvalDefinition.md index 3bf035507..e13628e26 100644 --- a/docs/docs/api/appkit/Interface.EvalDefinition.md +++ b/docs/docs/api/appkit/Interface.EvalDefinition.md @@ -14,6 +14,34 @@ Target agent id. Defaults to the eval's parent `server/agents/` dir. *** +### dataset? + +```ts +optional dataset: { + limit?: number; + table: string; +}; +``` + +Run this eval once per row of a Databricks managed evaluation dataset (a +Unity Catalog `catalog.schema.table` with `inputs`/`expectations` columns). +Each row is bound to `t.input`/`t.expected`. Requires the runner to have a +workspace client + warehouse (`--warehouse-id`). Omit for a single-run eval. + +#### limit? + +```ts +optional limit: number; +``` + +#### table + +```ts +table: string; +``` + +*** + ### description? ```ts @@ -22,6 +50,27 @@ optional description: string; Short human description, shown in reports. +*** + +### tags? + +```ts +optional tags: string[]; +``` + +Free-form tags for filtering (see the runner's `tags` / `--tag` option). + +*** + +### timeoutMs? + +```ts +optional timeoutMs: number; +``` + +Per-eval timeout (ms): `runEval` races the test against it and records a +non-passing result instead of hanging. Overrides the runner/CLI default. + ## Methods ### test() diff --git a/docs/docs/api/appkit/Interface.EvalDriver.md b/docs/docs/api/appkit/Interface.EvalDriver.md index 69d81a05a..57be788d5 100644 --- a/docs/docs/api/appkit/Interface.EvalDriver.md +++ b/docs/docs/api/appkit/Interface.EvalDriver.md @@ -5,17 +5,40 @@ app's agents endpoint; future drivers (in-process) implement the same shape. ## Methods +### reset()? + +```ts +optional reset(): void; +``` + +Drop the current conversation so the next `send` starts a fresh thread. +Optional: drivers without a session concept omit it. + +#### Returns + +`void` + +*** + ### send() ```ts -send(message: string): Promise; +send(message: string, options?: { + signal?: AbortSignal; +}): Promise; ``` +Drive one turn. `options.signal`, when provided, aborts the in-flight turn: +the runner passes its per-eval timeout signal so a timed-out eval cancels +the request instead of leaking a live stream. + #### Parameters | Parameter | Type | | ------ | ------ | | `message` | `string` | +| `options?` | \{ `signal?`: `AbortSignal`; \} | +| `options.signal?` | `AbortSignal` | #### Returns diff --git a/docs/docs/api/appkit/Interface.EvalResult.md b/docs/docs/api/appkit/Interface.EvalResult.md index 94a6c9aff..45aae670b 100644 --- a/docs/docs/api/appkit/Interface.EvalResult.md +++ b/docs/docs/api/appkit/Interface.EvalResult.md @@ -38,6 +38,17 @@ id: string; *** +### infraFailure? + +```ts +optional infraFailure: boolean; +``` + +A turn failed at the transport/agent level (`succeeded: false`), not on an +assertion — a retryable infra flake, distinct from `error`. + +*** + ### passed ```ts diff --git a/docs/docs/api/appkit/Interface.EvalSummary.md b/docs/docs/api/appkit/Interface.EvalSummary.md index 3c8fc1e6e..11a682148 100644 --- a/docs/docs/api/appkit/Interface.EvalSummary.md +++ b/docs/docs/api/appkit/Interface.EvalSummary.md @@ -28,6 +28,16 @@ passed: number; *** +### passRate + +```ts +passRate: number; +``` + +Fraction of scored (non-skipped) evals that passed, 0..1 (1 when none scored). + +*** + ### skipped ```ts diff --git a/docs/docs/api/appkit/Interface.IAiSearchConfig.md b/docs/docs/api/appkit/Interface.IAiSearchConfig.md index 316b4aac3..679d8b18f 100644 --- a/docs/docs/api/appkit/Interface.IAiSearchConfig.md +++ b/docs/docs/api/appkit/Interface.IAiSearchConfig.md @@ -46,6 +46,23 @@ optional name: string; *** +### streamConfig? + +```ts +optional streamConfig: StreamConfig; +``` + +SSE stream configuration for this plugin's `executeStream()` calls (buffer +sizes, `maxEventSize`, TTL, heartbeat). Sets the plugin's StreamManager +defaults; a per-call `stream` config still overrides these. Use it to raise +`maxEventSize` above the 5 MiB default when a stream emits larger events. + +#### Inherited from + +[`BasePluginConfig`](Interface.BasePluginConfig.md).[`streamConfig`](Interface.BasePluginConfig.md#streamconfig) + +*** + ### telemetry? ```ts diff --git a/docs/docs/api/appkit/Interface.IJobsConfig.md b/docs/docs/api/appkit/Interface.IJobsConfig.md index aeff8fa92..d86e7280c 100644 --- a/docs/docs/api/appkit/Interface.IJobsConfig.md +++ b/docs/docs/api/appkit/Interface.IJobsConfig.md @@ -58,6 +58,23 @@ Poll interval for waitForRun in milliseconds. Defaults to 5000. *** +### streamConfig? + +```ts +optional streamConfig: StreamConfig; +``` + +SSE stream configuration for this plugin's `executeStream()` calls (buffer +sizes, `maxEventSize`, TTL, heartbeat). Sets the plugin's StreamManager +defaults; a per-call `stream` config still overrides these. Use it to raise +`maxEventSize` above the 5 MiB default when a stream emits larger events. + +#### Inherited from + +[`BasePluginConfig`](Interface.BasePluginConfig.md).[`streamConfig`](Interface.BasePluginConfig.md#streamconfig) + +*** + ### telemetry? ```ts diff --git a/docs/docs/api/appkit/Interface.ReadEvalDatasetOptions.md b/docs/docs/api/appkit/Interface.ReadEvalDatasetOptions.md new file mode 100644 index 000000000..f6411df51 --- /dev/null +++ b/docs/docs/api/appkit/Interface.ReadEvalDatasetOptions.md @@ -0,0 +1,31 @@ +# Interface: ReadEvalDatasetOptions + +## Properties + +### limit? + +```ts +optional limit: number; +``` + +Optional row cap. + +*** + +### table + +```ts +table: string; +``` + +Fully-qualified UC table: `catalog.schema.table`. + +*** + +### warehouseId + +```ts +warehouseId: string; +``` + +SQL warehouse id to run the read against. diff --git a/docs/docs/api/appkit/Interface.RunEvalOptions.md b/docs/docs/api/appkit/Interface.RunEvalOptions.md index 16a6b8190..f89837405 100644 --- a/docs/docs/api/appkit/Interface.RunEvalOptions.md +++ b/docs/docs/api/appkit/Interface.RunEvalOptions.md @@ -22,6 +22,16 @@ Stable id for the eval (e.g. its file path relative to the evals dir). *** +### row? + +```ts +optional row: DatasetRow; +``` + +Dataset row bound to `t.input`/`t.expected` for dataset-driven evals. + +*** + ### strict? ```ts @@ -29,3 +39,14 @@ optional strict: boolean; ``` When true, soft assertion failures also fail the eval. + +*** + +### timeoutMs? + +```ts +optional timeoutMs: number; +``` + +Runner-level default per-eval timeout (ms). `def.timeoutMs` wins over this; +when both are unset the eval runs unbounded (current behavior). diff --git a/docs/docs/api/appkit/Interface.RunEvalsOptions.md b/docs/docs/api/appkit/Interface.RunEvalsOptions.md index 48df08c07..a005d350b 100644 --- a/docs/docs/api/appkit/Interface.RunEvalsOptions.md +++ b/docs/docs/api/appkit/Interface.RunEvalsOptions.md @@ -151,6 +151,18 @@ Progress callback, invoked as evals are discovered, started, and finished. *** +### retries? + +```ts +optional retries: number; +``` + +Re-run an eval up to this many extra times when it fails on infrastructure — +a thrown error/timeout (`result.error`) or a transport/agent turn failure +(`result.infraFailure`). Assertion failures are never retried. Defaults to `0`. + +*** + ### rootDir? ```ts @@ -171,10 +183,44 @@ Soft assertion failures also fail the eval. *** +### tags? + +```ts +optional tags: string[]; +``` + +Only run evals whose `tags` intersect this list. Empty/undefined runs all. +Tags live on the eval def, so filtering happens after each file is loaded. + +*** + ### timeoutMs? ```ts optional timeoutMs: number; ``` -Per-turn wall-clock timeout (ms) before a turn is failed. Defaults to 120s. +Default per-eval timeout (ms): `runEval` races the whole test against it and +it also caps each driver turn. A per-eval `def.timeoutMs` overrides it, and +it wins over an agent's `evals.config.ts` `timeoutMs`. Unbounded when unset. + +*** + +### warehouseId? + +```ts +optional warehouseId: string; +``` + +SQL warehouse id used to read managed evaluation datasets. + +*** + +### workspaceClient? + +```ts +optional workspaceClient: WorkspaceClient; +``` + +Workspace client used to read managed evaluation datasets (for evals that +declare `dataset`). Required alongside [warehouseId](#warehouseid) for those evals. diff --git a/docs/docs/api/appkit/Interface.TestContext.md b/docs/docs/api/appkit/Interface.TestContext.md index e21156235..f1b53925c 100644 --- a/docs/docs/api/appkit/Interface.TestContext.md +++ b/docs/docs/api/appkit/Interface.TestContext.md @@ -4,6 +4,28 @@ The `t` context passed to an eval's `test` function. ## Properties +### expected + +```ts +readonly expected: Record | undefined; +``` + +The current dataset row's `expectations` (ground truth / guidelines), or +`undefined` when the row has none or the eval isn't dataset-driven. + +*** + +### input + +```ts +readonly input: Record; +``` + +The current dataset row's `inputs` when the eval is dataset-driven (see +[EvalDefinition.dataset](Interface.EvalDefinition.md#dataset)); `{}` for a plain single-run eval. + +*** + ### judge ```ts @@ -15,9 +37,10 @@ judge: { ``` LLM-as-judge scoring of the last reply (via autoevals → a Databricks judge -model). Each returns a scored, soft-by-default assertion; chain `.atLeast(n)` -to set the pass threshold or `.gate()` to make it a hard gate. Requires the -judge to be configured (`--judge-model`). +model). Each returns a scored assertion that gates by default (a miss fails +the eval); chain `.atLeast(n)` to change the pass threshold or `.soft()` to +demote to a tracked-only metric. Requires the judge to be configured +(`--judge-model`). #### closedQA() @@ -125,6 +148,30 @@ Assert a tool was called during the run (gate by default). *** +### calledToolWith() + +```ts +calledToolWith(name: string, expected: Record): AssertionHandle; +``` + +Assert a tool was called with arguments that deep-contain `expected`: every +key in `expected` must equal the actual argument (recursively for nested +objects; arrays match element-for-element), so extra arguments are ignored. +Gate by default. + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `name` | `string` | +| `expected` | `Record`\<`string`, `unknown`\> | + +#### Returns + +[`AssertionHandle`](Interface.AssertionHandle.md) + +*** + ### check() ```ts @@ -146,6 +193,22 @@ Assert a value against a matcher, e.g. `t.check(t.reply, includes("Sunny"))`. *** +### reset() + +```ts +reset(): void; +``` + +Start a fresh conversation: the next `send` opens a new thread with no +history. Use to run several independent one-shot checks in one test. +Consecutive `send`s (without a `reset`) stay in one multi-turn conversation. + +#### Returns + +`void` + +*** + ### send() ```ts diff --git a/docs/docs/api/appkit/TypeAlias.DatabaseApiConfig.md b/docs/docs/api/appkit/TypeAlias.DatabaseApiConfig.md index 4e17f285a..8ec0c7be7 100644 --- a/docs/docs/api/appkit/TypeAlias.DatabaseApiConfig.md +++ b/docs/docs/api/appkit/TypeAlias.DatabaseApiConfig.md @@ -1,7 +1,7 @@ # Type Alias: DatabaseApiConfig\ ```ts -type DatabaseApiConfig = +type DatabaseApiConfig = | boolean | { tables?: readonly SchemaTableName[]; diff --git a/docs/docs/api/appkit/TypeAlias.DatabaseApiWritesConfig.md b/docs/docs/api/appkit/TypeAlias.DatabaseApiWritesConfig.md index 33051931a..1af91e127 100644 --- a/docs/docs/api/appkit/TypeAlias.DatabaseApiWritesConfig.md +++ b/docs/docs/api/appkit/TypeAlias.DatabaseApiWritesConfig.md @@ -1,7 +1,7 @@ # Type Alias: DatabaseApiWritesConfig\ ```ts -type DatabaseApiWritesConfig = +type DatabaseApiWritesConfig = | boolean | { operations?: readonly DatabaseApiWriteOperation[]; diff --git a/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md b/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md index d8d13adb1..2461127b6 100644 --- a/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md +++ b/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md @@ -4,7 +4,7 @@ type IDatabaseConfig = { api?: DatabaseApiConfig; hooks?: { readonly [TTable in SchemaTableName]?: EntityHooks }; - schema: TSchema; + schema?: TSchema; }; ``` @@ -12,9 +12,9 @@ Configuration for one schema-bound DatabasePlugin instance. ## Type Parameters -| Type Parameter | -| ------ | -| `TSchema` *extends* [`Schema`](Interface.Schema.md) | +| Type Parameter | Default type | +| ------ | ------ | +| `TSchema` *extends* [`Schema`](Interface.Schema.md) | `DefaultDatabaseSchema` | ## Properties @@ -49,8 +49,12 @@ readonly optional hooks: { readonly [TTable in SchemaTableName]?: Entit *** -### schema +### schema? ```ts -readonly schema: TSchema; +readonly optional schema: TSchema; ``` + +Explicit schema override. When omitted, setup loads the named `schema` +export from `config/database/schema.ts` under the application's working +directory. Missing or invalid declarations fail setup before pool creation. diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index d278350bf..2ac555bac 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -53,7 +53,9 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [DatabaseRegistry](Interface.DatabaseRegistry.md) | CANONICAL augmentation target. Empty by default; the generated `database.d.ts` augments it via `declare module "@databricks/appkit" { interface DatabaseRegistry { ... } }`. | | [DatabaseValidationIssue](Interface.DatabaseValidationIssue.md) | One rejected field; `path` names public columns, never their values. | | [DatabricksAuth](Interface.DatabricksAuth.md) | Resolved Databricks host + bearer token for the eval runner's REST calls. | +| [DatasetRow](Interface.DatasetRow.md) | One row of a managed evaluation dataset. `inputs` are the kwargs passed to the agent for the turn; `expectations` (when present) is the row's ground truth / guidelines. Mirrors the `{inputs, expectations}` shape of `mlflow.genai` datasets and of the Unity Catalog table backing a managed eval dataset. | | [DiscoveredEval](Interface.DiscoveredEval.md) | An eval file found under `server/agents//evals/`. | +| [DiscoveredEvalConfig](Interface.DiscoveredEvalConfig.md) | A per-agent `evals.config.ts` found under `server/agents//evals/`. | | [DriveResult](Interface.DriveResult.md) | What a driver returns for a single `t.send`. | | [EndpointConfig](Interface.EndpointConfig.md) | - | | [EntityMutationHooks](Interface.EntityMutationHooks.md) | Mutation lifecycle for one entity. A before hook may return a replacement payload, which is revalidated against the trusted schema before it is persisted. Every hook, the mutation, and any write a hook issues through `ctx.app.database` share one transaction, so a rejection anywhere rolls all of them back. Throw `DatabaseValidationError` to answer a generated route with `422`; any other failure stays an opaque server error. | @@ -90,6 +92,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [PluginToolkitProvider](Interface.PluginToolkitProvider.md) | Minimum shape every entry in the [Plugins](TypeAlias.Plugins.md) map must expose. Core plugins (analytics, files, genie, lakebase) implement this directly via their `.toolkit()` method. The agents plugin and standalone `runAgent` synthesize this shape for any registered plugin that doesn't implement `.toolkit()` directly (falling back to `getAgentTools()` walking). | | [PostResult](Interface.PostResult.md) | Structured result for a best-effort POST that must not throw. | | [PromptContext](Interface.PromptContext.md) | Context passed to `baseSystemPrompt` callbacks. | +| [ReadEvalDatasetOptions](Interface.ReadEvalDatasetOptions.md) | - | | [ReadSerializerContext](Interface.ReadSerializerContext.md) | Which entity and generated operation produced the row being shaped. | | [RegisteredAgent](Interface.RegisteredAgent.md) | - | | [ReportOutcome](Interface.ReportOutcome.md) | - | @@ -195,11 +198,13 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [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. | +| [database](Function.database.md) | Create the database plugin. Omit configuration to load `config/database/schema.ts`, or supply a typed schema override. | | [defineEval](Function.defineEval.md) | Define an agent eval. Default-export the result from a `server/agents//evals/*.eval.ts` file. | +| [defineEvalConfig](Function.defineEvalConfig.md) | Define per-directory eval config. Default-export from `evals.config.ts`. | | [defineManifest](Function.defineManifest.md) | Validates a raw manifest (typically a `manifest.json` import) against the canonical Zod schema and returns it as a strict [PluginManifest](Interface.PluginManifest.md). | | [defineSchema](Function.defineSchema.md) | Compile one declared schema. The returned type keeps the table names the builder returned, so `api.tables` and `hooks` can name only real tables. | | [defineTool](Function.defineTool.md) | Defines a single tool entry for a plugin's internal registry. | +| [discoverEvalConfigs](Function.discoverEvalConfigs.md) | Discover the per-agent `evals.config.ts` (from [defineEvalConfig](Function.defineEvalConfig.md)) at `/server/agents//evals/evals.config.ts`. Config is per-agent: each agent's config applies only to that agent's evals. Agents without a config file are omitted. Returns a stable, sorted list. | | [discoverEvalFiles](Function.discoverEvalFiles.md) | Discover evals under `/server/agents//evals/` — co-located with each agent's `agent.{md,ts}` (same folder-per-agent layout the agents plugin discovers). The agent id is the folder name; the eval id is the file path relative to that evals dir with `.eval.ts` stripped. Sorted + stable. | | [enumColumn](Function.enumColumn.md) | - | | [equals](Function.equals.md) | Passes when the value equals `expected` exactly. | @@ -211,6 +216,8 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [formatEvalDetail](Function.formatEvalDetail.md) | Indented detail lines for a failing eval (error + failing assertions). | | [formatEvalHeadline](Function.formatEvalHeadline.md) | The one-line header for a single eval result (no failure detail). | | [formatEvalResults](Function.formatEvalResults.md) | Render all results as a human-readable console report (non-streaming). | +| [formatResultsJson](Function.formatResultsJson.md) | Render results as a machine-readable JSON report (2-space indented): `{ summary: EvalSummary, results: EvalResult[] }`. Faithful to the types — every field present on a result round-trips. | +| [formatResultsJUnit](Function.formatResultsJUnit.md) | Render results as JUnit XML for standard CI test reporters: a single `` with one `` per result. Failures carry a `` (error or failing-gate summary); skips a ``. All attribute/text values are XML-escaped. | | [formatSummaryLine](Function.formatSummaryLine.md) | The final PASS/FAIL summary line. | | [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) | - | @@ -238,16 +245,20 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [mcpServer](Function.mcpServer.md) | Factory for declaring a custom MCP server tool. | | [normalizeHost](Function.normalizeHost.md) | Ensure the host has a scheme (Databricks env often lacks `https://`). | | [parseTextToolCalls](Function.parseTextToolCalls.md) | Parses text-based tool calls from model output. | +| [readEvalDataset](Function.readEvalDataset.md) | Read a Databricks managed evaluation dataset (a Unity Catalog table with `inputs`/`expectations` columns) into rows, over the public SQL Statement Execution API. Reuses SQLWarehouseConnector for submit/poll/transform — its result transform already JSON-parses string columns into objects, so `inputs`/`expectations` come back as records whether the table stores them as JSON strings or structs. | | [reportToMlflow](Function.reportToMlflow.md) | Write one pass/fail assessment per eval result to the Databricks MLflow REST API. Never throws — failures are collected so the run still reports. | | [resolveDatabricksAuth](Function.resolveDatabricksAuth.md) | - | | [resolveHostedTools](Function.resolveHostedTools.md) | - | +| [resolveWorkspaceClient](Function.resolveWorkspaceClient.md) | Construct a Databricks `WorkspaceClient` for the eval runner — the object the SDK-backed connectors (e.g. `SQLWarehouseConnector`) take. An explicit host+token builds a PAT client; otherwise the profile (or ambient config) is used and the SDK resolves credentials, minting OAuth as needed. Returns `undefined` if construction throws (missing/invalid config). | | [runAgent](Function.runAgent.md) | Standalone agent execution without `createApp`. Resolves the adapter, binds inline tools, and drives the adapter's `run()` loop to completion. | | [runEval](Function.runEval.md) | Run a single eval against a driver. Never throws for assertion or agent failures — those become a non-passing [EvalResult](Interface.EvalResult.md). Only a malformed eval definition surfaces as `result.error`. | | [runEvalsInDir](Function.runEvalsInDir.md) | Discover, load, and run every eval under each agent's `evals/` dir, driving the agents on a running app. Never throws for an individual eval — load/run failures become non-passing [EvalResult](Interface.EvalResult.md)s. | +| [runWithRetries](Function.runWithRetries.md) | Run `attempt` up to `1 + retries` times, stopping as soon as it returns a result that is neither a thrown error / per-eval timeout (`error`) nor a transport/agent turn failure (`infraFailure`). Assertion failures set neither, so a failed-but-completed eval is returned on the first try and never retried. Returns the last result when every attempt failed on infra. | | [summarize](Function.summarize.md) | - | | [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. | +| [userTurns](Function.userTurns.md) | Extract every user-message content, in order, from an MLflow `{"messages":[{"role":"user","content":"..."}]}` input. A dataset row can carry a full multi-turn conversation; replaying these against one thread (one `t.send` per returned string) lets the agent see the accumulating history. | | [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 9db57d21d..236715a69 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -197,11 +197,21 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.DatabricksAuth", label: "DatabricksAuth" }, + { + type: "doc", + id: "api/appkit/Interface.DatasetRow", + label: "DatasetRow" + }, { type: "doc", id: "api/appkit/Interface.DiscoveredEval", label: "DiscoveredEval" }, + { + type: "doc", + id: "api/appkit/Interface.DiscoveredEvalConfig", + label: "DiscoveredEvalConfig" + }, { type: "doc", id: "api/appkit/Interface.DriveResult", @@ -382,6 +392,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.PromptContext", label: "PromptContext" }, + { + type: "doc", + id: "api/appkit/Interface.ReadEvalDatasetOptions", + label: "ReadEvalDatasetOptions" + }, { type: "doc", id: "api/appkit/Interface.ReadSerializerContext", @@ -860,6 +875,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.defineEval", label: "defineEval" }, + { + type: "doc", + id: "api/appkit/Function.defineEvalConfig", + label: "defineEvalConfig" + }, { type: "doc", id: "api/appkit/Function.defineManifest", @@ -875,6 +895,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.defineTool", label: "defineTool" }, + { + type: "doc", + id: "api/appkit/Function.discoverEvalConfigs", + label: "discoverEvalConfigs" + }, { type: "doc", id: "api/appkit/Function.discoverEvalFiles", @@ -930,6 +955,16 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.formatEvalResults", label: "formatEvalResults" }, + { + type: "doc", + id: "api/appkit/Function.formatResultsJson", + label: "formatResultsJson" + }, + { + type: "doc", + id: "api/appkit/Function.formatResultsJUnit", + label: "formatResultsJUnit" + }, { type: "doc", id: "api/appkit/Function.formatSummaryLine", @@ -1065,6 +1100,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.parseTextToolCalls", label: "parseTextToolCalls" }, + { + type: "doc", + id: "api/appkit/Function.readEvalDataset", + label: "readEvalDataset" + }, { type: "doc", id: "api/appkit/Function.reportToMlflow", @@ -1080,6 +1120,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.resolveHostedTools", label: "resolveHostedTools" }, + { + type: "doc", + id: "api/appkit/Function.resolveWorkspaceClient", + label: "resolveWorkspaceClient" + }, { type: "doc", id: "api/appkit/Function.runAgent", @@ -1095,6 +1140,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.runEvalsInDir", label: "runEvalsInDir" }, + { + type: "doc", + id: "api/appkit/Function.runWithRetries", + label: "runWithRetries" + }, { type: "doc", id: "api/appkit/Function.summarize", @@ -1120,6 +1170,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.toolsFromRegistry", label: "toolsFromRegistry" }, + { + type: "doc", + id: "api/appkit/Function.userTurns", + label: "userTurns" + }, { type: "doc", id: "api/appkit/Function.uuid", diff --git a/docs/docs/plugins/database.md b/docs/docs/plugins/database.md index 0e71a7e37..754c2f1b9 100644 --- a/docs/docs/plugins/database.md +++ b/docs/docs/plugins/database.md @@ -10,9 +10,10 @@ This plugin is currently **beta**. APIs may change between minor releases. Impor ::: -Declare a schema and use `database({ schema })` to get generated HTTP CRUD and a -server-side database client. CRUD is enabled for every declared table by default. -Use `api` to restrict the generated routes without disabling server-side access. +Declare your tables in `config/database/schema.ts` and register `database()` to +get generated HTTP CRUD and a server-side database client. CRUD is enabled for +every declared table by default. Use `api` to restrict the generated routes +without disabling server-side access. :::caution[Shared application access] This plugin uses the app's service principal in deployed Databricks Apps. It does @@ -33,19 +34,37 @@ as described in [Lakebase configuration](./lakebase.md#environment-variables). The database tables must already exist and match the declared schema. This plugin checks connectivity during setup; it does not create or migrate tables. +Apps scaffolded with the Database plugin selected include an empty +`config/database/schema.ts`, so `database()` can start without requiring sample +tables. Replace the empty declaration with your models when their PostgreSQL +tables are ready. + +For local development, AppKit's Lakebase connector resolves the PostgreSQL +username from the application's Databricks credentials when `PGUSER` and +`DATABRICKS_CLIENT_ID` are absent. The Database plugin delegates connection +initialization to this connector, which uses `@databricks/lakebase` underneath. +An explicitly configured username still takes precedence; the Database plugin +does not introduce a request-level OBO connection. + ```ts -import { createApp, server } from "@databricks/appkit"; -import { database, defineSchema, id, text } from "@databricks/appkit/beta"; +// config/database/schema.ts +import { defineSchema, id, text } from "@databricks/appkit/beta"; -const schema = defineSchema((builder) => ({ +export const schema = defineSchema((builder) => ({ notes: builder.table("notes", { id: id(), body: text().notNull(), }), })); +``` + +```ts +// server/index.ts +import { createApp, server } from "@databricks/appkit"; +import { database } from "@databricks/appkit/beta"; const AppKit = await createApp({ - plugins: [server(), database({ schema })], + plugins: [server(), database()], }); ``` @@ -62,6 +81,36 @@ With the server plugin enabled, this registers: A table without a public primary key supports list and create only. `upsert` is available to server code but has no generated HTTP route. +## Schema discovery and overrides + +`database()` and `database({})` use the same defaults. During setup, the plugin +loads the named `schema` export from `config/database/schema.ts`, relative to the +application's working directory. The file must export a finalized `defineSchema()` +result. Missing files, import failures, and invalid exports fail setup before the +plugin creates a connection pool; they do not silently create an empty schema. + +Keep `config/database/schema.ts` and its local imports in your deployment. The +plugin loads TypeScript through Jiti, so a plain Node production process does not +need a separate TypeScript loader. The schema module should only declare tables, +not connect to the database or start the app. + +For a different layout or a deployment that contains only a server bundle, import +the schema explicitly and pass it to the plugin: + +```ts +import { schema } from "../config/database/schema"; + +database({ schema }); +``` + +An explicit schema always takes precedence and skips file discovery. An invalid +explicit schema fails setup instead of falling back to another file. + +Run `appkit generate-types` to generate the database registry. With that registry, +configuration without an explicit schema still infers table names and hook payloads. +An explicitly supplied schema also checks configuration keys against its own table +names. + ## Restrict the generated API Omitting `api`, or setting it to `true` or `{}`, enables full CRUD. Restrictions @@ -69,29 +118,28 @@ are optional. There is no separate write opt-in. ```ts // No generated HTTP routes. The server-side client still works. -database({ schema, api: false }); +database({ api: false }); // Read-only routes for every table. -database({ schema, api: { writes: false } }); +database({ api: { writes: false } }); // Full CRUD for selected tables only. -database({ schema, api: { tables: ["notes"] } }); +database({ api: { tables: ["notes"] } }); // Allow reads, create, and update, but not delete. database({ - schema, api: { writes: { operations: ["create", "update"] } }, }); // Read every table, but allow writes only to notes. database({ - schema, api: { writes: { tables: ["notes"] } }, }); ``` | Option | Default | Effect | | --- | --- | --- | +| `schema` | Named export in `config/database/schema.ts` | Overrides automatic schema loading | | `api` | `true` | `false` disables all generated routes | | `api.tables` | All declared tables | Limits which tables have routes | | `api.writes` | `true` | `false` keeps only read routes | @@ -153,7 +201,6 @@ other plugins or external services transactional. import { DatabaseValidationError } from "@databricks/appkit"; database({ - schema, hooks: { notes: { beforeCreate(values) { diff --git a/packages/appkit/src/beta.ts b/packages/appkit/src/beta.ts index 4dc04bcfe..70aa90780 100644 --- a/packages/appkit/src/beta.ts +++ b/packages/appkit/src/beta.ts @@ -120,6 +120,8 @@ export type { SearchResult, } from "./plugins/ai-search/types"; export * from "./plugins/beta-exports.generated"; +// Hidden from CLI scaffolding; still available for explicit SDK configuration. +export { database } from "./plugins/database"; export type { DatabaseApiConfig, DatabaseApiWriteOperation, diff --git a/packages/appkit/src/connectors/lakebase/index.ts b/packages/appkit/src/connectors/lakebase/index.ts index a4b6762ec..b7bf2e1d4 100644 --- a/packages/appkit/src/connectors/lakebase/index.ts +++ b/packages/appkit/src/connectors/lakebase/index.ts @@ -1,10 +1,15 @@ import { createLakebasePool as createLakebasePoolBase, + getUsernameWithApiLookup, type LakebasePoolConfig, } from "@databricks/lakebase"; import type { Pool } from "pg"; +import { getClientOptions } from "../../context/client-options"; +import { ServiceContext } from "../../context/service-context"; +import { ConfigurationError } from "../../errors"; import { createLogger } from "../../logging/logger"; +import { createWorkspaceClient } from "../../workspace-client"; /** * Create a Lakebase pool with appkit's logger integration. @@ -20,6 +25,38 @@ export function createLakebasePool(config?: Partial): Pool { }); } +/** + * Resolve the startup identity and create a pool through the existing Lakebase + * connector. Explicit clients win over the app's service client; request/OBO + * context is never selected implicitly. The pool connects lazily on first use. + * + * Keep createLakebasePool synchronous for existing callers and OBO pool caches. + */ +export async function initializeLakebasePool( + config: Partial = {}, +): Promise { + const resolved = { ...config }; + // Native password auth with an explicit/environment user needs no Databricks + // client. Otherwise use one client for both identity lookup and token refresh. + const needsClient = + config.password === undefined || + !(config.user || process.env.PGUSER || process.env.DATABRICKS_CLIENT_ID); + if (!resolved.workspaceClient && needsClient) { + const client = ServiceContext.isInitialized() + ? ServiceContext.get().client + : createWorkspaceClient({ clientOptions: getClientOptions() }); + resolved.workspaceClient = client.toLegacyWorkspaceClient(); + } + const user = await getUsernameWithApiLookup(resolved); + if (!user) { + throw ConfigurationError.invalidConnection( + "Lakebase", + "Could not determine the PostgreSQL user from the current Databricks credentials. Check your authentication or set PGUSER explicitly.", + ); + } + return createLakebasePool({ ...resolved, user }); +} + // Re-export everything else from lakebase export { type DatabaseCredential, diff --git a/packages/appkit/src/connectors/lakebase/tests/initialize-pool.test.ts b/packages/appkit/src/connectors/lakebase/tests/initialize-pool.test.ts new file mode 100644 index 000000000..11c10028b --- /dev/null +++ b/packages/appkit/src/connectors/lakebase/tests/initialize-pool.test.ts @@ -0,0 +1,222 @@ +import type { LakebasePoolConfig } from "@databricks/lakebase"; +import type { Pool } from "pg"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { runInUserContext } from "../../../context/execution-context"; +import { ServiceContext } from "../../../context/service-context"; +import type { UserContext } from "../../../context/user-context"; + +const mocks = vi.hoisted(() => { + const me = vi.fn(); + const client = { currentUser: { me } }; + return { + me, + client, + createPool: vi.fn(), + createWorkspaceClient: vi.fn(() => ({ + toLegacyWorkspaceClient: () => client, + })), + }; +}); + +// Keep identity resolution real; replace only the low-level pool allocation. +vi.mock("@databricks/lakebase", async (importOriginal) => ({ + ...(await importOriginal()), + createLakebasePool: mocks.createPool, +})); +vi.mock("../../../workspace-client", async (importOriginal) => ({ + ...(await importOriginal()), + createWorkspaceClient: mocks.createWorkspaceClient, +})); + +import { createLakebasePool, initializeLakebasePool } from "../index"; + +const pool = { query: vi.fn(), end: vi.fn() } as unknown as Pool; +type Client = NonNullable; +function poolConfig(): Partial { + return mocks.createPool.mock.lastCall?.[0]; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("PGUSER", ""); + vi.stubEnv("DATABRICKS_CLIENT_ID", ""); + vi.spyOn(ServiceContext, "isInitialized").mockReturnValue(false); + mocks.me.mockResolvedValue({ userName: "local-user@example.test" }); + mocks.createPool.mockReturnValue(pool); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +describe("AppKit Lakebase connector initialization", () => { + test("resolves a local username and returns the standalone connector's pool", async () => { + expect(await initializeLakebasePool()).toBe(pool); + expect(mocks.me).toHaveBeenCalledOnce(); + expect(mocks.createWorkspaceClient).toHaveBeenCalledWith({ + clientOptions: expect.objectContaining({ product: "@databricks/appkit" }), + }); + expect(mocks.createPool).toHaveBeenCalledWith({ + user: "local-user@example.test", + workspaceClient: mocks.client, + logger: expect.any(Object), + }); + }); + + test.each([ + ["PGUSER", "postgres-role"], + ["DATABRICKS_CLIENT_ID", "service-principal"], + ])("uses %s without an identity API call", async (key, user) => { + vi.stubEnv(key, user); + await initializeLakebasePool(); + expect(poolConfig().user).toBe(user); + expect(mocks.me).not.toHaveBeenCalled(); + }); + + test("an explicit user wins over environment values", async () => { + vi.stubEnv("PGUSER", "environment-user"); + vi.stubEnv("DATABRICKS_CLIENT_ID", "environment-client"); + await initializeLakebasePool({ user: "explicit-user" }); + expect(poolConfig().user).toBe("explicit-user"); + expect(mocks.me).not.toHaveBeenCalled(); + }); + + test("uses the app service client for both lookup and pool authentication", async () => { + const legacy = { + currentUser: { me: vi.fn(async () => ({ userName: "app-user" })) }, + }; + vi.mocked(ServiceContext.isInitialized).mockReturnValue(true); + vi.spyOn(ServiceContext, "get").mockReturnValue({ + client: { toLegacyWorkspaceClient: () => legacy }, + } as unknown as ReturnType); + await initializeLakebasePool(); + expect(poolConfig()).toMatchObject({ + user: "app-user", + workspaceClient: legacy, + }); + expect(mocks.createWorkspaceClient).not.toHaveBeenCalled(); + expect(legacy.currentUser.me).toHaveBeenCalledOnce(); + }); + + test("an explicit workspace client wins over the app service client", async () => { + const explicit = { + currentUser: { + me: vi.fn(async () => ({ userName: "explicit-client-user" })), + }, + }; + vi.mocked(ServiceContext.isInitialized).mockReturnValue(true); + const getContext = vi.spyOn(ServiceContext, "get"); + await initializeLakebasePool({ + workspaceClient: explicit as unknown as Client, + }); + expect(poolConfig()).toMatchObject({ + user: "explicit-client-user", + workspaceClient: explicit, + }); + expect(getContext).not.toHaveBeenCalled(); + expect(mocks.createWorkspaceClient).not.toHaveBeenCalled(); + }); + + test("PGUSER wins over the service principal environment value", async () => { + vi.stubEnv("PGUSER", "postgres-role"); + vi.stubEnv("DATABRICKS_CLIENT_ID", "service-principal"); + await initializeLakebasePool(); + expect(poolConfig().user).toBe("postgres-role"); + expect(mocks.me).not.toHaveBeenCalled(); + }); + + test("never selects an active request identity implicitly", async () => { + const requestLookup = vi.fn(async () => ({ userName: "request-user" })); + const requestClient = { currentUser: { me: requestLookup } }; + await runInUserContext( + { + client: { toLegacyWorkspaceClient: () => requestClient }, + userId: "request-user-id", + userEmail: "request-user@example.test", + workspaceId: Promise.resolve("workspace"), + isUserContext: true, + } as unknown as UserContext, + () => initializeLakebasePool(), + ); + expect(poolConfig().user).toBe("local-user@example.test"); + expect(poolConfig().workspaceClient).toBe(mocks.client); + expect(requestLookup).not.toHaveBeenCalled(); + }); + + test("does not substitute another identity when an explicit client fails", async () => { + const explicit = { + currentUser: { + me: vi.fn(async () => { + throw new Error("Lookup unavailable"); + }), + }, + }; + await expect( + initializeLakebasePool({ + workspaceClient: explicit as unknown as Client, + }), + ).rejects.toThrow("Could not determine the PostgreSQL user"); + expect(mocks.createWorkspaceClient).not.toHaveBeenCalled(); + expect(mocks.me).not.toHaveBeenCalled(); + expect(mocks.createPool).not.toHaveBeenCalled(); + }); + + test("rejects an identity response without a username before allocation", async () => { + mocks.me.mockResolvedValueOnce({ id: "identity-without-username" }); + await expect(initializeLakebasePool()).rejects.toThrow( + "Could not determine the PostgreSQL user", + ); + expect(mocks.createPool).not.toHaveBeenCalled(); + }); + + test("passes pool options through without mutating caller configuration", async () => { + const config = Object.freeze({ + user: "user", + statement_timeout: 30_000, + idle_in_transaction_session_timeout: 15_000, + max: 4, + }); + await initializeLakebasePool(config); + expect(poolConfig()).toMatchObject(config); + expect(poolConfig()).not.toBe(config); + expect(config).not.toHaveProperty("workspaceClient"); + }); + + test("preserves password authentication without requiring Databricks credentials", async () => { + await initializeLakebasePool({ + user: "native-user", + password: "test-only-password", + host: "localhost", + database: "test", + sslMode: "disable", + }); + expect(poolConfig().user).toBe("native-user"); + expect(poolConfig().password).toBe("test-only-password"); + expect(poolConfig().workspaceClient).toBeUndefined(); + expect(mocks.createWorkspaceClient).not.toHaveBeenCalled(); + expect(mocks.me).not.toHaveBeenCalled(); + }); + + test("does not allocate a pool when identity cannot be resolved", async () => { + mocks.me.mockRejectedValueOnce(new Error("Private auth detail")); + await expect(initializeLakebasePool()).rejects.toMatchObject({ + code: "CONFIGURATION_ERROR", + message: expect.stringContaining( + "Could not determine the PostgreSQL user", + ), + }); + expect(mocks.createPool).not.toHaveBeenCalled(); + }); + + test("keeps the existing pool factory synchronous", () => { + const result = createLakebasePool({ + user: "explicit-user", + password: "test-only", + }); + expect(result).toBe(pool); + expect(result).not.toBeInstanceOf(Promise); + expect(mocks.me).not.toHaveBeenCalled(); + expect(mocks.createWorkspaceClient).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/appkit/src/plugins/beta-exports.generated.ts b/packages/appkit/src/plugins/beta-exports.generated.ts index 10982785b..7e556ebd9 100644 --- a/packages/appkit/src/plugins/beta-exports.generated.ts +++ b/packages/appkit/src/plugins/beta-exports.generated.ts @@ -7,4 +7,3 @@ export { agents } from "./agents"; export { aiSearch } from "./ai-search"; -export { database } from "./database"; diff --git a/packages/appkit/src/plugins/database/config.ts b/packages/appkit/src/plugins/database/config.ts new file mode 100644 index 000000000..40e2a51e2 --- /dev/null +++ b/packages/appkit/src/plugins/database/config.ts @@ -0,0 +1,22 @@ +import { databaseSetupFailed } from "../../database/errors"; + +/** Reject invalid arguments instead of turning them into the default API. */ +export function assertDatabaseConfig(config: unknown): void { + if (typeof config !== "object" || config === null || Array.isArray(config)) { + throw databaseSetupFailed( + "Expected a database configuration object or no argument.", + ); + } + const prototype = Object.getPrototypeOf(config); + if (prototype !== Object.prototype && prototype !== null) { + throw databaseSetupFailed( + "Expected a plain database configuration object.", + ); + } + // Do not silently turn a previous opt-out into the default full API. + if ("crudRoutes" in config) { + throw databaseSetupFailed( + '"crudRoutes" was renamed to "api". Use api: false to disable generated routes or api: { writes: false } for reads only.', + ); + } +} diff --git a/packages/appkit/src/plugins/database/database.ts b/packages/appkit/src/plugins/database/database.ts index 7b100bec7..b18c72c3e 100644 --- a/packages/appkit/src/plugins/database/database.ts +++ b/packages/appkit/src/plugins/database/database.ts @@ -6,8 +6,10 @@ import { databaseSetupFailed, } from "../../database/errors"; import type { Schema } from "../../database/schema-builder"; +import { assertFinalizedSchema } from "../../database/schema-builder/define-schema"; import { Plugin } from "../../plugin"; import type { PluginManifest } from "../../registry"; +import { assertDatabaseConfig } from "./config"; import { compileCrudTables } from "./crud/contract"; import { type CrudExposure, resolveCrudExposure } from "./crud/exposure"; import { routeOutcome } from "./crud/response"; @@ -23,13 +25,18 @@ import { } from "./crud/routes"; import type { DatabaseExports } from "./entity-types"; import { createDatabaseState, type DatabaseState } from "./lifecycle"; +import { loadDefaultDatabaseSchema } from "./load-schema"; import manifest from "./manifest.json"; -import type { DatabaseHooks, IDatabaseConfig } from "./types"; +import type { + DatabaseHooks, + DefaultDatabaseSchema, + IDatabaseConfig, +} from "./types"; /** Schema-driven database plugin */ -export class DatabasePlugin extends Plugin< - IDatabaseConfig -> { +export class DatabasePlugin< + TSchema extends Schema = DefaultDatabaseSchema, +> extends Plugin> { /** Plugin metadata and required PostgreSQL resource. */ static manifest = manifest as PluginManifest<"database">; declare protected config: IDatabaseConfig; @@ -38,15 +45,11 @@ export class DatabasePlugin extends Plugin< private draining = false; private shutdownPromise: Promise | null = null; private exposure: CrudExposure = { tables: [], writes: new Map() }; + private resolvedSchema: Schema | null = null; - constructor(config: IDatabaseConfig) { + constructor(config: IDatabaseConfig = {}) { + assertDatabaseConfig(config); super({ schema: config.schema }); - // Do not silently turn a previous opt-out into the default full API. - if ("crudRoutes" in config) { - throw databaseSetupFailed( - '"crudRoutes" was renamed to "api". Use api: false to disable generated routes or api: { writes: false } for reads only.', - ); - } this.config = { schema: config.schema, api: config.api, @@ -59,20 +62,32 @@ export class DatabasePlugin extends Plugin< if (this.draining || this.state) throw databaseSetupFailed(); if (!this.setupPromise) { const attempt = (async () => { - this.exposure = resolveCrudExposure( + const schema = + this.config.schema === undefined + ? await loadDefaultDatabaseSchema() + : this.config.schema; + try { + assertFinalizedSchema(schema); + } catch { + throw databaseSetupFailed( + "schema must be a finalized AppKit schema created with defineSchema().", + ); + } + if (this.draining) throw databaseSetupFailed(); + const exposure = resolveCrudExposure( this.config.api, - Object.keys(this.config.schema.$tables), + Object.keys(schema.$tables), ); // A hook key naming no declared table would silently never run. for (const name of Object.keys(this.hooks() ?? {})) { - if (!Object.hasOwn(this.config.schema.$tables, name)) { + if (!Object.hasOwn(schema.$tables, name)) { throw databaseSetupFailed( `hooks names undeclared table ${JSON.stringify(name)}. Use a table declared in schema.`, ); } } const candidate = await createDatabaseState( - this.config.schema, + schema, (operation, options) => this.execute(operation, options), this.hooks(), ); @@ -82,6 +97,8 @@ export class DatabasePlugin extends Plugin< await candidate.pool.end().catch(() => undefined); throw databaseSetupFailed(); } + this.resolvedSchema = schema; + this.exposure = exposure; this.state = candidate; })(); this.setupPromise = attempt; @@ -92,12 +109,11 @@ export class DatabasePlugin extends Plugin< /** Register generated CRUD, subject to the configured table and write restrictions. */ injectRoutes(router: express.Router): void { if (this.exposure.tables.length === 0) return; + const schema = this.resolvedSchema; + if (!schema) throw databaseSetupFailed(); const tables = compileCrudTables( Object.fromEntries( - this.exposure.tables.map((name) => [ - name, - this.config.schema.$tables[name], - ]), + this.exposure.tables.map((name) => [name, schema.$tables[name]]), ), ); const hooks = this.hooks(); @@ -222,10 +238,33 @@ export class DatabasePlugin extends Plugin< } } -/** Create a typed database plugin registration for a finalized schema. */ +type DatabaseRegistration = { + plugin: PluginConstructor>; + config: IDatabaseConfig; + name: "database"; +}; + +/** + * Create the database plugin. Omit configuration to load + * `config/database/schema.ts`, or supply a typed schema override. + */ export function database( - config: IDatabaseConfig, -) { + config: IDatabaseConfig & { readonly schema: TSchema }, +): DatabaseRegistration & { + config: IDatabaseConfig & { readonly schema: TSchema }; +}; +/** + * Register the database plugin with opinionated defaults and full HTTP CRUD. + * By default, setup loads the named `schema` export from the application's + * `config/database/schema.ts`. Registration itself performs no file or database I/O. + */ +export function database( + config?: IDatabaseConfig, +): DatabaseRegistration; +export function database( + config: IDatabaseConfig = {}, +): DatabaseRegistration { + assertDatabaseConfig(config); return { plugin: DatabasePlugin as unknown as PluginConstructor< BasePluginConfig, diff --git a/packages/appkit/src/plugins/database/lifecycle.ts b/packages/appkit/src/plugins/database/lifecycle.ts index f38023c14..60fa80066 100644 --- a/packages/appkit/src/plugins/database/lifecycle.ts +++ b/packages/appkit/src/plugins/database/lifecycle.ts @@ -1,4 +1,4 @@ -import { createLakebasePool } from "../../connectors/lakebase"; +import { initializeLakebasePool } from "../../connectors/lakebase"; import { classifyDatabaseError, DatabasePluginError, @@ -30,7 +30,7 @@ const logger = createLogger("database"); /** Resources owned by one successfully initialized plugin instance. */ export interface DatabaseState { - readonly pool: ReturnType; + readonly pool: Awaited>; readonly exports: DatabaseExports; readonly deactivate: () => void; } @@ -175,12 +175,12 @@ export async function createDatabaseState( } let active = true; - let pool: ReturnType | undefined; + let pool: DatabaseState["pool"] | undefined; const assertActive = () => { if (!active) throw new DatabasePluginError("INTERNAL", "runtime"); }; try { - pool = createLakebasePool({ + pool = await initializeLakebasePool({ statement_timeout: STATEMENT_TIMEOUT_MS, idle_in_transaction_session_timeout: IDLE_IN_TRANSACTION_TIMEOUT_MS, }); diff --git a/packages/appkit/src/plugins/database/load-schema.ts b/packages/appkit/src/plugins/database/load-schema.ts new file mode 100644 index 000000000..9378dfd6f --- /dev/null +++ b/packages/appkit/src/plugins/database/load-schema.ts @@ -0,0 +1,62 @@ +import { stat } from "node:fs/promises"; +import path from "node:path"; + +import { databaseSetupFailed } from "../../database/errors"; +import type { Schema } from "../../database/schema-builder"; +import { assertFinalizedSchema } from "../../database/schema-builder/define-schema"; + +/** Same application-relative convention used by appkit generate-types. */ +const DEFAULT_SCHEMA_FILE = "config/database/schema.ts"; + +/** Load application-owned declarations during setup, never during registration. */ +export async function loadDefaultDatabaseSchema( + root = process.cwd(), +): Promise { + const file = path.resolve(root, DEFAULT_SCHEMA_FILE); + const label = JSON.stringify(file); + let isFile: boolean; + try { + isFile = (await stat(file)).isFile(); + } catch { + throw databaseSetupFailed( + `Cannot read the default schema at ${label}. Create ${DEFAULT_SCHEMA_FILE} with a named "schema" export, or pass database({ schema }). The path is relative to the application's working directory.`, + ); + } + if (!isFile) { + throw databaseSetupFailed(`The default schema at ${label} must be a file.`); + } + + let module: unknown; + try { + // Jiti also loads TypeScript declarations in a plain Node production run. + // No module cache: a fresh plugin instance must not inherit a stale schema. + const { createJiti } = await import("jiti"); + module = await createJiti(import.meta.url, { moduleCache: false }).import( + file, + ); + } catch { + // Import errors can contain credentials, row values, or application internals. + throw databaseSetupFailed( + `Could not load the default schema at ${label}. Check the module and its imports, or pass database({ schema }).`, + ); + } + if ( + typeof module !== "object" || + module === null || + !Object.hasOwn(module, "schema") + ) { + throw databaseSetupFailed( + `The default schema module at ${label} must export a named "schema" created with defineSchema().`, + ); + } + let schema: unknown; + try { + schema = (module as { schema: unknown }).schema; + assertFinalizedSchema(schema); + } catch { + throw databaseSetupFailed( + `The "schema" export from ${label} must be a finalized AppKit schema created with defineSchema().`, + ); + } + return schema; +} diff --git a/packages/appkit/src/plugins/database/manifest.json b/packages/appkit/src/plugins/database/manifest.json index 517422b00..111a7b6be 100644 --- a/packages/appkit/src/plugins/database/manifest.json +++ b/packages/appkit/src/plugins/database/manifest.json @@ -4,7 +4,7 @@ "displayName": "Database (Beta)", "description": "Schema-driven typed access to Databricks Lakebase PostgreSQL", "stability": "beta", - "hidden": false, + "hidden": true, "resources": { "required": [ { diff --git a/packages/appkit/src/plugins/database/tests/crud.integration.test.ts b/packages/appkit/src/plugins/database/tests/crud.integration.test.ts index 9f7a2e730..7a2b6fe6f 100644 --- a/packages/appkit/src/plugins/database/tests/crud.integration.test.ts +++ b/packages/appkit/src/plugins/database/tests/crud.integration.test.ts @@ -8,13 +8,13 @@ import type { ITelemetry } from "../../../telemetry"; import type { EntityHooks } from "../types"; const mocks = vi.hoisted(() => ({ - createLakebasePool: vi.fn(), + initializeLakebasePool: vi.fn(), createDrizzleDb: vi.fn(), createDrizzleDataPath: vi.fn(), })); vi.mock("../../../connectors/lakebase", () => ({ - createLakebasePool: mocks.createLakebasePool, + initializeLakebasePool: mocks.initializeLakebasePool, })); vi.mock("../../../database/runtime/engine/drizzle-data-path", () => ({ createDrizzleDb: mocks.createDrizzleDb, @@ -125,7 +125,7 @@ function fakeResponse() { async function mount(hooks: Record) { const database = memoryDataPath(); - mocks.createLakebasePool.mockReturnValue({ + mocks.initializeLakebasePool.mockResolvedValue({ end: vi.fn(async () => undefined), }); mocks.createDrizzleDb.mockReturnValue({}); @@ -195,7 +195,7 @@ const auditingHooks: Record = { }; beforeEach(() => { - mocks.createLakebasePool.mockReset(); + mocks.initializeLakebasePool.mockReset(); mocks.createDrizzleDb.mockReset(); mocks.createDrizzleDataPath.mockReset(); }); diff --git a/packages/appkit/src/plugins/database/tests/lifecycle.test.ts b/packages/appkit/src/plugins/database/tests/lifecycle.test.ts index 9a7d79cf3..20bbb04de 100644 --- a/packages/appkit/src/plugins/database/tests/lifecycle.test.ts +++ b/packages/appkit/src/plugins/database/tests/lifecycle.test.ts @@ -1,17 +1,16 @@ -import { describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, 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(), + initializeLakebasePool: vi.fn(), createDrizzleDb: vi.fn(), createDrizzleDataPath: vi.fn(), })); - vi.mock("../../../connectors/lakebase", () => ({ - createLakebasePool: mocks.createLakebasePool, + initializeLakebasePool: mocks.initializeLakebasePool, })); vi.mock("../../../database/runtime/engine/drizzle-data-path", () => ({ createDrizzleDb: mocks.createDrizzleDb, @@ -80,7 +79,7 @@ const txSurface = (tx: unknown) => tx as TestTransaction; function arrange(path = fakePath()) { const pool = { end: vi.fn(async () => undefined) }; const db = { marker: Symbol("db") }; - mocks.createLakebasePool.mockReturnValue(pool); + mocks.initializeLakebasePool.mockResolvedValue(pool); mocks.createDrizzleDb.mockReturnValue(db); mocks.createDrizzleDataPath.mockReturnValue(path); const execute = vi.fn(async (operation) => ({ @@ -90,6 +89,9 @@ function arrange(path = fakePath()) { return { pool, db, path, execute }; } +beforeEach(() => vi.clearAllMocks()); +afterEach(() => vi.restoreAllMocks()); + describe("createDatabaseState", () => { test("accepts authentic populated and empty schemas but rejects a forgery before allocation", async () => { arrange(); @@ -102,14 +104,14 @@ describe("createDatabaseState", () => { arrange().execute, ), ).resolves.toBeDefined(); - mocks.createLakebasePool.mockClear(); + mocks.initializeLakebasePool.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(); + expect(mocks.initializeLakebasePool).not.toHaveBeenCalled(); }); test("builds one default runtime, all entities, and waits for readiness", async () => { @@ -128,8 +130,8 @@ describe("createDatabaseState", () => { expect(settled).toBe(false); ready.resolve([]); const state = await pending; - expect(mocks.createLakebasePool).toHaveBeenCalledTimes(1); - expect(mocks.createLakebasePool).toHaveBeenCalledWith({ + expect(mocks.initializeLakebasePool).toHaveBeenCalledTimes(1); + expect(mocks.initializeLakebasePool).toHaveBeenCalledWith({ statement_timeout: STATEMENT_TIMEOUT_MS, idle_in_transaction_session_timeout: IDLE_IN_TRANSACTION_TIMEOUT_MS, }); @@ -180,11 +182,11 @@ describe("createDatabaseState", () => { }, ); - test("sanitizes synchronous pool construction failures", async () => { + test("sanitizes connector initialization failures", async () => { const { execute } = arrange(); - mocks.createLakebasePool.mockImplementationOnce(() => { - throw new Error("secret host and credential details"); - }); + mocks.initializeLakebasePool.mockRejectedValueOnce( + new Error("secret host and credential details"), + ); const error = await createDatabaseState(schema, execute).catch( (caught) => caught, diff --git a/packages/appkit/src/plugins/database/tests/load-schema.test.ts b/packages/appkit/src/plugins/database/tests/load-schema.test.ts new file mode 100644 index 000000000..2e3f5dc0b --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/load-schema.test.ts @@ -0,0 +1,164 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { loadDefaultDatabaseSchema } from "../load-schema"; + +const roots: string[] = []; +const builder = path.resolve( + import.meta.dirname, + "../../../database/schema-builder/index.ts", +); +const source = ` + import { defineSchema, id, text } from ${JSON.stringify(builder)}; + const tableName: string = "notes"; + export const schema = defineSchema(({ table }) => ({ + notes: table(tableName, { id: id(), body: text().notNull(), secret: text().private() }), + })); +`; + +async function fixture(contents?: string) { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), "appkit-default-schema-"), + ); + roots.push(root); + const file = path.join(root, "config/database/schema.ts"); + await fs.mkdir(path.dirname(file), { recursive: true }); + if (contents !== undefined) await fs.writeFile(file, contents); + return { root, file }; +} + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all( + roots + .splice(0) + .map((root) => fs.rm(root, { recursive: true, force: true })), + ); +}); + +describe("default database schema", () => { + test("loads the named TypeScript schema from the application convention", async () => { + const { root } = await fixture(source); + const schema = await loadDefaultDatabaseSchema(root); + expect(Object.keys(schema.$tables)).toEqual(["notes"]); + expect(schema.$tables.notes.$columns.secret.isPrivate).toBe(true); + expect(schema.$tables.notes.$columns.body.notNull).toBe(true); + expect(Object.isFrozen(schema)).toBe(true); + }); + + test("uses the application's working directory when no root is passed", async () => { + const { root } = await fixture(source); + vi.spyOn(process, "cwd").mockReturnValue(root); + expect(Object.keys((await loadDefaultDatabaseSchema()).$tables)).toEqual([ + "notes", + ]); + }); + + test("accepts an intentionally empty declaration without inventing tables", async () => { + const { root } = await fixture(` + import { defineSchema } from ${JSON.stringify(builder)}; + export const schema = defineSchema(() => ({})); + `); + expect((await loadDefaultDatabaseSchema(root)).$tables).toEqual({}); + }); + + test("explains the convention and explicit override when the file is missing", async () => { + const { root } = await fixture(); + await expect(loadDefaultDatabaseSchema(root)).rejects.toMatchObject({ + category: "SETUP_FAILED", + phase: "setup", + message: expect.stringContaining("config/database/schema.ts"), + clientMessage: "Database setup failed", + cause: undefined, + }); + await expect(loadDefaultDatabaseSchema(root)).rejects.toThrow( + "database({ schema })", + ); + }); + + test("rejects a directory instead of searching it for another module", async () => { + const { root, file } = await fixture(); + await fs.mkdir(file); + await expect(loadDefaultDatabaseSchema(root)).rejects.toThrow( + "must be a file", + ); + }); + + test.each([ + [ + "default export", + source.replace("export const schema =", "export default"), + ], + [ + "alternate export", + source.replace("export const schema =", "export const otherSchema ="), + ], + ])( + "requires the named schema rather than accepting a %s", + async (_name, contents) => { + const { root } = await fixture(contents); + await expect(loadDefaultDatabaseSchema(root)).rejects.toMatchObject({ + category: "SETUP_FAILED", + message: expect.stringContaining('named "schema"'), + clientMessage: "Database setup failed", + }); + }, + ); + + test.each(["null", "undefined", "{}", "{ $tables: {} }", "42"])( + "rejects an unfinalized schema export: %s", + async (value) => { + const { root } = await fixture(`export const schema = ${value};`); + await expect(loadDefaultDatabaseSchema(root)).rejects.toMatchObject({ + category: "SETUP_FAILED", + message: expect.stringContaining("defineSchema()"), + clientMessage: "Database setup failed", + }); + }, + ); + + test.each([ + 'throw new Error("credential secret");', + "export const schema = ;", + 'import "./missing-dependency"; export const schema = {};', + ])( + "keeps schema import failures actionable without leaking their details", + async (contents) => { + const { root } = await fixture(contents); + const error = await loadDefaultDatabaseSchema(root).catch( + (caught) => caught, + ); + expect(error).toMatchObject({ + category: "SETUP_FAILED", + message: expect.stringContaining("Could not load the default schema"), + clientMessage: "Database setup failed", + cause: undefined, + details: undefined, + }); + expect(error.message).not.toContain("credential secret"); + expect(error.message).not.toContain("missing-dependency"); + }, + ); + + test("fresh loads observe edits in the schema's imported declarations", async () => { + const { root, file } = await fixture(` + import { defineSchema, text } from ${JSON.stringify(builder)}; + import { field } from "./columns"; + export const schema = defineSchema(({ table }) => ({ + notes: table("notes", { [field]: text() }), + })); + `); + const dependency = path.join(path.dirname(file), "columns.ts"); + await fs.writeFile(dependency, 'export const field = "before";'); + expect( + (await loadDefaultDatabaseSchema(root)).$tables.notes.$columns, + ).toHaveProperty("before"); + await fs.writeFile(dependency, 'export const field = "after";'); + expect( + (await loadDefaultDatabaseSchema(root)).$tables.notes.$columns, + ).toHaveProperty("after"); + }); +}); diff --git a/packages/appkit/src/plugins/database/tests/plugin.test.ts b/packages/appkit/src/plugins/database/tests/plugin.test.ts index 8d25f0f4c..eaf127ae1 100644 --- a/packages/appkit/src/plugins/database/tests/plugin.test.ts +++ b/packages/appkit/src/plugins/database/tests/plugin.test.ts @@ -3,10 +3,16 @@ import { beforeEach, describe, expect, expectTypeOf, test, vi } from "vitest"; import { defineSchema, fk, id, text } from "../../../database/schema-builder"; -const mocks = vi.hoisted(() => ({ createDatabaseState: vi.fn() })); +const mocks = vi.hoisted(() => ({ + createDatabaseState: vi.fn(), + loadDefaultDatabaseSchema: vi.fn(), +})); vi.mock("../lifecycle", () => ({ createDatabaseState: mocks.createDatabaseState, })); +vi.mock("../load-schema", () => ({ + loadDefaultDatabaseSchema: mocks.loadDefaultDatabaseSchema, +})); import { DatabasePlugin, database } from "../database"; @@ -79,7 +85,10 @@ function candidate(marker = "one") { } describe("DatabasePlugin", () => { - beforeEach(() => mocks.createDatabaseState.mockReset()); + beforeEach(() => { + mocks.createDatabaseState.mockReset(); + mocks.loadDefaultDatabaseSchema.mockReset(); + }); test("retains schema and declares the fixed beta postgres manifest", () => { const definition = database({ schema }); @@ -106,6 +115,123 @@ describe("DatabasePlugin", () => { ).toEqual({ schema }); }); + test("accepts omitted and empty configuration without doing I/O during registration", () => { + for (const definition of [database(), database({}), database(undefined)]) { + expect(definition).toMatchObject({ name: "database", config: {} }); + } + new DatabasePlugin(); + expect(mocks.loadDefaultDatabaseSchema).not.toHaveBeenCalled(); + expect(mocks.createDatabaseState).not.toHaveBeenCalled(); + }); + + test.each([undefined, {}, { schema: undefined }])( + "loads the conventional schema and enables full CRUD with config=%j", + async (config) => { + mocks.loadDefaultDatabaseSchema.mockResolvedValue(routedSchema); + const automatic = await registerRoutes(config); + const explicit = await registerRoutes({ schema: routedSchema }); + expect(automatic.routes).toEqual(explicit.routes); + expect(mocks.loadDefaultDatabaseSchema).toHaveBeenCalledOnce(); + expect(mocks.createDatabaseState).toHaveBeenNthCalledWith( + 1, + routedSchema, + expect.any(Function), + undefined, + ); + }, + ); + + test("applies API restrictions and hooks to the discovered schema", async () => { + mocks.loadDefaultDatabaseSchema.mockResolvedValue(routedSchema); + const hooks = { notes: { beforeCreate: vi.fn() } }; + const { routes } = await registerRoutes({ + api: { tables: ["notes"], writes: false }, + hooks, + }); + expect(routes).toEqual(["get /notes", "get /notes/:id"]); + expect(mocks.createDatabaseState).toHaveBeenCalledWith( + routedSchema, + expect.any(Function), + hooks, + ); + }); + + test("keeps the default schema's typed client available when HTTP is disabled", async () => { + mocks.loadDefaultDatabaseSchema.mockResolvedValue(routedSchema); + const { routes, plugin } = await registerRoutes({ api: false }); + expect(routes).toEqual([]); + expect(plugin.exports()).toHaveProperty("operation"); + }); + + test("never loads the convention when an explicit schema is supplied", async () => { + await registerRoutes({ schema: routedSchema }); + expect(mocks.loadDefaultDatabaseSchema).not.toHaveBeenCalled(); + }); + + test.each([null, false, 0, "", [], new Date()])( + "rejects invalid config=%j rather than enabling defaults", + (config) => { + expect(() => database(config as never)).toThrow("configuration object"); + expect(() => new DatabasePlugin(config as never)).toThrow( + "configuration object", + ); + expect(mocks.loadDefaultDatabaseSchema).not.toHaveBeenCalled(); + expect(mocks.createDatabaseState).not.toHaveBeenCalled(); + }, + ); + + test.each([null, {}, { $tables: {} }])( + "rejects an invalid explicit schema instead of falling back: %j", + async (invalid) => { + const plugin = new DatabasePlugin({ schema: invalid as never }); + await expect(plugin.setup()).rejects.toMatchObject({ + category: "SETUP_FAILED", + message: expect.stringContaining("defineSchema()"), + }); + expect(mocks.loadDefaultDatabaseSchema).not.toHaveBeenCalled(); + expect(mocks.createDatabaseState).not.toHaveBeenCalled(); + }, + ); + + test("does not allocate or publish when default schema loading fails", async () => { + const error = new Error("Default schema unavailable"); + mocks.loadDefaultDatabaseSchema.mockRejectedValue(error); + const plugin = new DatabasePlugin(); + await expect(plugin.setup()).rejects.toBe(error); + expect(mocks.createDatabaseState).not.toHaveBeenCalled(); + expect(() => plugin.exports()).toThrow(); + const { router, routes } = fakeRouter(); + plugin.injectRoutes(router); + expect(routes).toEqual([]); + }); + + test("shares one schema load across concurrent setup calls", async () => { + const loaded = deferred(); + mocks.loadDefaultDatabaseSchema.mockReturnValue(loaded.promise); + mocks.createDatabaseState.mockResolvedValue(candidate()); + const plugin = new DatabasePlugin(); + const first = plugin.setup(); + const second = plugin.setup(); + expect(mocks.loadDefaultDatabaseSchema).toHaveBeenCalledOnce(); + expect(mocks.createDatabaseState).not.toHaveBeenCalled(); + loaded.resolve(routedSchema); + await Promise.all([first, second]); + expect(mocks.createDatabaseState).toHaveBeenCalledOnce(); + }); + + test("does not create a pool when shutdown happens during schema loading", async () => { + const loaded = deferred(); + mocks.loadDefaultDatabaseSchema.mockReturnValue(loaded.promise); + const plugin = new DatabasePlugin(); + const setup = plugin.setup(); + const shutdown = plugin.shutdown(); + loaded.resolve(routedSchema); + await expect(setup).rejects.toMatchObject({ category: "SETUP_FAILED" }); + await shutdown; + expect(mocks.createDatabaseState).not.toHaveBeenCalled(); + expect(() => plugin.exports()).toThrow(); + }); + test("publishes only after readiness and setup is single-flight", async () => { const construction = deferred>(); mocks.createDatabaseState.mockReturnValue(construction.promise); diff --git a/packages/appkit/src/plugins/database/tests/template.test.ts b/packages/appkit/src/plugins/database/tests/template.test.ts new file mode 100644 index 000000000..b4c92c5bf --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/template.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { createJiti } from "jiti"; +import { describe, expect, test } from "vitest"; + +import { assertFinalizedSchema } from "../../../database/schema-builder/define-schema"; + +describe("database template starter", () => { + test("ships a finalized empty schema, not an implicit runtime fallback", async () => { + // The entire starter must be conditional, with no content outside the + // database block. Validate the selected branch using the SDK source so + // this check also runs in CI before the packages have been built. + const template = await readFile( + path.resolve( + import.meta.dirname, + "../../../../../../template/config/database/schema.ts", + ), + "utf8", + ); + const block = template.match( + /^\{\{if \.plugins\.database -\}\}\s*([\s\S]*?)\s*\{\{- end\}\}\s*$/, + ); + assert(block, "schema.ts must be entirely guarded by .plugins.database"); + const root = await mkdtemp(path.join(tmpdir(), "appkit-template-schema-")); + const jiti = createJiti(import.meta.url, { + moduleCache: false, + alias: { + "@databricks/appkit/beta": path.resolve( + import.meta.dirname, + "../../../database/schema-builder/index.ts", + ), + }, + }); + try { + const file = path.join(root, "schema.ts"); + await writeFile(file, block[1] + "\n"); + const { schema } = await jiti.import<{ schema: unknown }>(file); + expect(() => assertFinalizedSchema(schema)).not.toThrow(); + assertFinalizedSchema(schema); + expect(Object.keys(schema.$tables)).toEqual([]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/appkit/src/plugins/database/types.ts b/packages/appkit/src/plugins/database/types.ts index 21c2fdab7..80824ffe6 100644 --- a/packages/appkit/src/plugins/database/types.ts +++ b/packages/appkit/src/plugins/database/types.ts @@ -1,6 +1,14 @@ +import type { DatabaseRegistry } from "../../database/contract"; import type { Schema } from "../../database/schema-builder"; import type { EntityMutationHooks } from "./hooks"; +type RegistryTableName = Extract; + +/** @internal Infer conventional schema names from typegen, when available. */ +export type DefaultDatabaseSchema = Schema< + [RegistryTableName] extends [never] ? string : RegistryTableName +>; + /** Table names declared by one finalized schema. */ export type SchemaTableName = Extract< keyof TSchema["$tables"], @@ -72,8 +80,13 @@ export type EntityHooks = export type DatabaseHooks = Readonly>; /** Configuration for one schema-bound DatabasePlugin instance. */ -export type IDatabaseConfig = { - readonly schema: TSchema; +export type IDatabaseConfig = { + /** + * Explicit schema override. When omitted, setup loads the named `schema` + * export from `config/database/schema.ts` under the application's working + * directory. Missing or invalid declarations fail setup before pool creation. + */ + readonly schema?: TSchema; /** * Generated HTTP CRUD is enabled for all tables by default, using the app's * service principal. Every admitted caller receives the enabled operations; diff --git a/packages/appkit/src/type-generator/database/tests/generate.test.ts b/packages/appkit/src/type-generator/database/tests/generate.test.ts index ac883bf51..b8a29271a 100644 --- a/packages/appkit/src/type-generator/database/tests/generate.test.ts +++ b/packages/appkit/src/type-generator/database/tests/generate.test.ts @@ -237,7 +237,28 @@ describe("generateDatabaseTypes", () => { await fs.writeFile( consumer, ` - import type { DatabaseExports } from "@databricks/appkit/beta"; + import { database, type DatabaseExports, type IDatabaseConfig } from "@databricks/appkit/beta"; + database(); + database({}); + const defaults: IDatabaseConfig = { api: { writes: false } }; + database(defaults); + database({ + api: { tables: ["posts"], writes: { operations: ["create"] } }, + hooks: { + posts: { + beforeCreate(values) { + const title: string = values.title; + // @ts-expect-error inferred hook fields are not any + const invalid: number = values.title; + return { ...values, title }; + }, + }, + }, + }); + // @ts-expect-error default API restrictions use generated table names + database({ api: { tables: ["missing"] } }); + // @ts-expect-error default hooks use generated table names + database({ hooks: { missing: { beforeCreate() {} } } }); declare const db: DatabaseExports; db.users.where({ name: { ilike: "%ada%" } }).include({ posts: { limit: 2 } }); db.posts.where({ score: { gte: 1 }, and: [{ status: ["draft"] }] }); @@ -267,13 +288,22 @@ describe("generateDatabaseTypes", () => { target: "ES2022", module: "ESNext", moduleResolution: "Bundler", + esModuleInterop: true, + resolveJsonModule: true, + skipLibCheck: true, baseUrl: options.root, paths: { "@databricks/appkit": [ path.join(sourceRoot, "database/contract/index.ts"), ], "@databricks/appkit/beta": [ - path.join(sourceRoot, "plugins/database/entity-types.ts"), + path.join(sourceRoot, "plugins/database/index.ts"), + ], + shared: [path.resolve(appkitRoot, "../shared/src/index.ts")], + // CI runs unit tests before build, including imports of shared subpaths. + "shared/*": [path.resolve(appkitRoot, "../shared/src/*")], + "@databricks/lakebase": [ + path.resolve(appkitRoot, "../lakebase/src/index.ts"), ], }, }, diff --git a/template/README.md b/template/README.md index 621631c13..da037ec7d 100644 --- a/template/README.md +++ b/template/README.md @@ -9,6 +9,9 @@ A Databricks App powered by [AppKit](https://developers.databricks.com/docs/appk {{- if .plugins.lakebase}} - **Lakebase** -- Fully managed Postgres database for transactional (OLTP) workloads on Databricks {{- end}} +{{- if .plugins.database}} +- **Database** -- Schema-driven PostgreSQL access with generated CRUD routes +{{- end}} {{- if .plugins.genie}} - **Genie** -- AI/BI Genie conversational interface for natural language data queries {{- end}} @@ -44,6 +47,20 @@ DATABRICKS_APP_PORT=8000 The Lakebase plugin requires additional environment variables for PostgreSQL connectivity. To learn how to configure the Lakebase plugin, see the [Lakebase plugin documentation](https://developers.databricks.com/docs/appkit/v0/plugins/lakebase). {{- end}} +{{- if .plugins.database}} + +#### Database schema + +`database()` loads `config/database/schema.ts`. The generated schema starts empty, +so initialization does not require sample tables or expose existing data. +Add your table declarations after creating the corresponding PostgreSQL tables; +AppKit does not create or migrate them. CRUD routes are enabled for declared tables +by default, and can be restricted with `api` in the plugin configuration. + +During local development, the PostgreSQL username is resolved from your Databricks +credentials when `PGUSER` and `DATABRICKS_CLIENT_ID` are not set. +{{- end}} + ### CLI Authentication The Databricks CLI requires authentication to deploy and manage apps. Configure authentication using one of these methods: diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index 998dc8b06..59bb3e265 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -135,100 +135,6 @@ } } }, - "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", diff --git a/template/config/database/schema.ts b/template/config/database/schema.ts new file mode 100644 index 000000000..c0f6dc1e3 --- /dev/null +++ b/template/config/database/schema.ts @@ -0,0 +1,7 @@ +{{if .plugins.database -}} +import { defineSchema } from '@databricks/appkit/beta'; + +// database() discovers this file automatically. Start with no exposed tables. +// Add declarations here after creating the corresponding PostgreSQL tables. +export const schema = defineSchema(() => ({})); +{{- end}} diff --git a/tools/check-database-template.ts b/tools/check-database-template.ts new file mode 100644 index 000000000..d132b7d74 --- /dev/null +++ b/tools/check-database-template.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env tsx +/** Validate the database starter using the SDK installed in the template artifact. */ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const root = path.resolve(process.argv[2] ?? "pr-template"); +const require = createRequire(path.join(root, "package.json")); +const sdkRoot = path.dirname( + require.resolve("@databricks/appkit/package.json"), +); +// This is a package integration check, so use the artifact's actual runtime loader. +const { loadDefaultDatabaseSchema } = await import( + pathToFileURL(path.join(sdkRoot, "dist/plugins/database/load-schema.js")).href +); +const template = await readFile( + path.join(root, "config/database/schema.ts"), + "utf8", +); +const block = template.match( + /^\{\{if \.plugins\.database -\}\}\s*([\s\S]*?)\s*\{\{- end\}\}\s*$/, +); +assert( + block, + "The database starter must be entirely guarded by .plugins.database", +); +// Validate the selected branch of this single-block template. Keep the fixture +// inside the artifact so its imports resolve to the installed SDK, not the repo. +const fixture = await mkdtemp(path.join(root, ".database-template-check-")); +try { + await mkdir(path.join(fixture, "config/database"), { recursive: true }); + await writeFile( + path.join(fixture, "config/database/schema.ts"), + block[1] + "\n", + ); + const schema = await loadDefaultDatabaseSchema(fixture); + assert.deepEqual( + Object.keys(schema.$tables), + [], + "The starter must not expose or require sample tables", + ); + console.log( + "Conditional database starter loads successfully with the packaged SDK", + ); +} finally { + await rm(fixture, { recursive: true, force: true }); +}