From 3bad942620d91c5fa1806c35c0d3060ebf717d2e Mon Sep 17 00:00:00 2001 From: ditadi Date: Fri, 23 Oct 2026 13:56:18 +0100 Subject: [PATCH] feat(appkit): define named database operations and policies Add defineOperation and definePolicy to @databricks/appkit/beta. A required, unique name identifies each operation, and the entity and method infer its default input and output. The declaration validator rejects malformed and duplicate operations and skips those without authorization. Nothing registers or serves an operation yet. Signed-off-by: ditadi --- .../api/appkit/Function.defineOperation.md | 76 ++ docs/docs/api/appkit/Function.definePolicy.md | 56 ++ docs/docs/api/appkit/Interface.Actor.md | 19 + .../Interface.DatabaseOperationContract.md | 28 + .../api/appkit/Interface.DatabaseRoutes.md | 31 + .../api/appkit/Interface.OperationContext.md | 70 ++ .../appkit/Interface.OperationDefinition.md | 80 ++ .../api/appkit/Interface.PolicyContext.md | 45 + .../docs/api/appkit/Interface.PolicyDenial.md | 19 + ...lias.DatabricksAppKitDatabaseOperations.md | 7 + .../api/appkit/TypeAlias.OperationMethod.md | 7 + .../appkit/TypeAlias.OperationWireInput.md | 13 + .../appkit/TypeAlias.OperationWireOutput.md | 13 + docs/docs/api/appkit/TypeAlias.Policy.md | 14 + .../api/appkit/TypeAlias.PolicyDecision.md | 7 + docs/docs/api/appkit/index.md | 15 + docs/docs/api/appkit/typedoc-sidebar.ts | 75 ++ packages/appkit/src/beta.ts | 19 + .../appkit/src/database/contract/registry.ts | 4 + .../database/contract/tests/registry.test.ts | 4 + packages/appkit/src/index.ts | 2 + .../plugins/database/operation-metadata.ts | 247 +++++ .../appkit/src/plugins/database/operations.ts | 414 ++++++++ .../database/tests/entity-types.test.ts | 28 + .../plugins/database/tests/operations.test.ts | 405 ++++++++ .../database/tests/operations.types.test.ts | 892 ++++++++++++++++++ packages/appkit/src/workspace-client/index.ts | 40 +- packages/shared/src/database/index.ts | 1 + packages/shared/src/database/operations.ts | 31 + 29 files changed, 2653 insertions(+), 9 deletions(-) create mode 100644 docs/docs/api/appkit/Function.defineOperation.md create mode 100644 docs/docs/api/appkit/Function.definePolicy.md create mode 100644 docs/docs/api/appkit/Interface.Actor.md create mode 100644 docs/docs/api/appkit/Interface.DatabaseOperationContract.md create mode 100644 docs/docs/api/appkit/Interface.DatabaseRoutes.md create mode 100644 docs/docs/api/appkit/Interface.OperationContext.md create mode 100644 docs/docs/api/appkit/Interface.OperationDefinition.md create mode 100644 docs/docs/api/appkit/Interface.PolicyContext.md create mode 100644 docs/docs/api/appkit/Interface.PolicyDenial.md create mode 100644 docs/docs/api/appkit/TypeAlias.DatabricksAppKitDatabaseOperations.md create mode 100644 docs/docs/api/appkit/TypeAlias.OperationMethod.md create mode 100644 docs/docs/api/appkit/TypeAlias.OperationWireInput.md create mode 100644 docs/docs/api/appkit/TypeAlias.OperationWireOutput.md create mode 100644 docs/docs/api/appkit/TypeAlias.Policy.md create mode 100644 docs/docs/api/appkit/TypeAlias.PolicyDecision.md create mode 100644 packages/appkit/src/plugins/database/operation-metadata.ts create mode 100644 packages/appkit/src/plugins/database/operations.ts create mode 100644 packages/appkit/src/plugins/database/tests/operations.test.ts create mode 100644 packages/appkit/src/plugins/database/tests/operations.types.test.ts create mode 100644 packages/shared/src/database/operations.ts diff --git a/docs/docs/api/appkit/Function.defineOperation.md b/docs/docs/api/appkit/Function.defineOperation.md new file mode 100644 index 000000000..911edd47f --- /dev/null +++ b/docs/docs/api/appkit/Function.defineOperation.md @@ -0,0 +1,76 @@ +# Function: defineOperation() + +## Call Signature + +```ts +function defineOperation(definition: { + entity: E; + method: M; + name: Name; + output?: O; +} & [Contract["input"]] extends [never] ? { + input: I & Schema; +} : { + input?: I; +} & { + authorize: ResourcePolicy, R>; + execute: (context: OperationContext, NonNullable>>) => Awaitable>; +}): OperationDefinition; +``` + +Declare one database operation. `name` is its public identifier. The entity +and method infer the default input and output; `input` and `output` Zod +schemas override them. Definition is pure: it freezes a descriptor and +calls nothing. + +### Type Parameters + +| Type Parameter | +| ------ | +| `Name` *extends* `string` | +| `E` *extends* `never` | +| `M` *extends* `DatabaseOperationMethod` | +| `I` *extends* `SchemaSlot` | +| `O` *extends* `SchemaSlot` | +| `R` *extends* \{ \} | + +### Parameters + +| Parameter | Type | +| ------ | ------ | +| `definition` | \{ `entity`: `E`; `method`: `M`; `name`: `Name`; `output?`: `O`; \} & \[`Contract`\<`E`, `M`\>\[`"input"`\]\] *extends* \[`never`\] ? \{ `input`: `I` & `Schema`; \} : \{ `input?`: `I`; \} & \{ `authorize`: `ResourcePolicy`\<`ParsedInput`\<`E`, `M`, `I`\>, `R`\>; `execute`: (`context`: [`OperationContext`](Interface.OperationContext.md)\<`ParsedInput`\<`E`, `M`, `I`\>, `NonNullable`\<`Awaited`\<`R`\>\>\>) => `Awaitable`\<`ExecuteResult`\<`E`, `M`, `O`\>\>; \} | + +### Returns + +[`OperationDefinition`](Interface.OperationDefinition.md)\<`E`, `M`, `I`, `O`, `R`, `Name`\> + +## Call Signature + +```ts +function defineOperation(definition: OperationOptions): OperationDefinition; +``` + +Declare one database operation. `name` is its public identifier. The entity +and method infer the default input and output; `input` and `output` Zod +schemas override them. Definition is pure: it freezes a descriptor and +calls nothing. + +### Type Parameters + +| Type Parameter | Default type | +| ------ | ------ | +| `Name` *extends* `string` | - | +| `E` *extends* `never` | - | +| `M` *extends* `DatabaseOperationMethod` | - | +| `I` *extends* `SchemaSlot` | `undefined` | +| `O` *extends* `SchemaSlot` | `undefined` | + +### Parameters + +| Parameter | Type | +| ------ | ------ | +| `definition` | `OperationOptions`\<`E`, `M`, `I`, `O`, `undefined`, `Name`\> | + +### Returns + +[`OperationDefinition`](Interface.OperationDefinition.md)\<`E`, `M`, `I`, `O`, `undefined`, `Name`\> diff --git a/docs/docs/api/appkit/Function.definePolicy.md b/docs/docs/api/appkit/Function.definePolicy.md new file mode 100644 index 000000000..1d95f1292 --- /dev/null +++ b/docs/docs/api/appkit/Function.definePolicy.md @@ -0,0 +1,56 @@ +# Function: definePolicy() + +## Call Signature + +```ts +function definePolicy(policy: AllowPolicy): AllowPolicy; +``` + +Declare a reusable authorization rule. Without `resource` it decides from +the actor and input. With `resource` it loads one record first; annotate +the lookup context to fix the input the policy needs. `allow` may return +`true`, `false`, or `{ deny }`. + +### Type Parameters + +| Type Parameter | Default type | +| ------ | ------ | +| `In` | `unknown` | + +### Parameters + +| Parameter | Type | +| ------ | ------ | +| `policy` | `AllowPolicy`\<`In`\> | + +### Returns + +`AllowPolicy`\<`In`\> + +## Call Signature + +```ts +function definePolicy(policy: ResourcePolicy): ResourcePolicy; +``` + +Declare a reusable authorization rule. Without `resource` it decides from +the actor and input. With `resource` it loads one record first; annotate +the lookup context to fix the input the policy needs. `allow` may return +`true`, `false`, or `{ deny }`. + +### Type Parameters + +| Type Parameter | +| ------ | +| `In` | +| `R` | + +### Parameters + +| Parameter | Type | +| ------ | ------ | +| `policy` | `ResourcePolicy`\<`In`, `R`\> | + +### Returns + +`ResourcePolicy`\<`In`, `R`\> diff --git a/docs/docs/api/appkit/Interface.Actor.md b/docs/docs/api/appkit/Interface.Actor.md new file mode 100644 index 000000000..e2bb61cd8 --- /dev/null +++ b/docs/docs/api/appkit/Interface.Actor.md @@ -0,0 +1,19 @@ +# Interface: Actor + +The authenticated caller. The HTTP boundary resolves it before any policy runs. + +## Properties + +### email? + +```ts +readonly optional email: string; +``` + +*** + +### id + +```ts +readonly id: string; +``` diff --git a/docs/docs/api/appkit/Interface.DatabaseOperationContract.md b/docs/docs/api/appkit/Interface.DatabaseOperationContract.md new file mode 100644 index 000000000..78050c8ee --- /dev/null +++ b/docs/docs/api/appkit/Interface.DatabaseOperationContract.md @@ -0,0 +1,28 @@ +# Interface: DatabaseOperationContract + +The browser-safe contract of one generated operation. `input` and `output` +are JSON types. A generated `database.d.ts` fills this interface by name. + +## Properties + +### input + +```ts +readonly input: unknown; +``` + +*** + +### method + +```ts +readonly method: DatabaseOperationMethod; +``` + +*** + +### output + +```ts +readonly output: unknown; +``` diff --git a/docs/docs/api/appkit/Interface.DatabaseRoutes.md b/docs/docs/api/appkit/Interface.DatabaseRoutes.md new file mode 100644 index 000000000..e6039988d --- /dev/null +++ b/docs/docs/api/appkit/Interface.DatabaseRoutes.md @@ -0,0 +1,31 @@ +# Interface: DatabaseRoutes + +The generated route entry. `schema` and each module stay lazy, so importing +the registry evaluates neither the schema nor an operation. + +## Properties + +### modules + +```ts +readonly modules: readonly { + file: string; + load: () => Promise>; +}[]; +``` + +*** + +### schema() + +```ts +readonly schema: () => Promise<{ + schema: Schema; +}>; +``` + +#### Returns + +`Promise`\<\{ + `schema`: [`Schema`](Interface.Schema.md); +\}\> diff --git a/docs/docs/api/appkit/Interface.OperationContext.md b/docs/docs/api/appkit/Interface.OperationContext.md new file mode 100644 index 000000000..17c6719ad --- /dev/null +++ b/docs/docs/api/appkit/Interface.OperationContext.md @@ -0,0 +1,70 @@ +# Interface: OperationContext\ + +What `allow` and `execute` receive: the policy context and the loaded resource. + +## Extends + +- [`PolicyContext`](Interface.PolicyContext.md)\<`In`\> + +## Type Parameters + +| Type Parameter | Default type | +| ------ | ------ | +| `In` | `unknown` | +| `R` | `undefined` | + +## Properties + +### actor + +```ts +readonly actor: Actor; +``` + +#### Inherited from + +[`PolicyContext`](Interface.PolicyContext.md).[`actor`](Interface.PolicyContext.md#actor) + +*** + +### appkit + +```ts +readonly appkit: OperationAppKit; +``` + +#### Inherited from + +[`PolicyContext`](Interface.PolicyContext.md).[`appkit`](Interface.PolicyContext.md#appkit) + +*** + +### db + +```ts +readonly db: DatabaseExports; +``` + +#### Inherited from + +[`PolicyContext`](Interface.PolicyContext.md).[`db`](Interface.PolicyContext.md#db) + +*** + +### input + +```ts +readonly input: In; +``` + +#### Inherited from + +[`PolicyContext`](Interface.PolicyContext.md).[`input`](Interface.PolicyContext.md#input) + +*** + +### resource + +```ts +readonly resource: R; +``` diff --git a/docs/docs/api/appkit/Interface.OperationDefinition.md b/docs/docs/api/appkit/Interface.OperationDefinition.md new file mode 100644 index 000000000..93f870703 --- /dev/null +++ b/docs/docs/api/appkit/Interface.OperationDefinition.md @@ -0,0 +1,80 @@ +# Interface: OperationDefinition\ + +One immutable operation descriptor. Nothing in it runs until a request does. + +## Type Parameters + +| Type Parameter | +| ------ | +| `E` *extends* `EntityName` | +| `M` *extends* [`OperationMethod`](TypeAlias.OperationMethod.md) | +| `I` *extends* `SchemaSlot` | +| `O` *extends* `SchemaSlot` | +| `R` | +| `Name` *extends* `string` | + +## Properties + +### authorize + +```ts +readonly authorize: Authorize, R>; +``` + +*** + +### entity + +```ts +readonly entity: E; +``` + +*** + +### execute() + +```ts +readonly execute: (context: OperationContext, [R] extends [undefined] ? undefined : NonNullable>>) => Awaitable>; +``` + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `context` | [`OperationContext`](Interface.OperationContext.md)\<`ParsedInput`\<`E`, `M`, `I`\>, \[`R`\] *extends* \[`undefined`\] ? `undefined` : `NonNullable`\<`Awaited`\<`R`\>\>\> | + +#### Returns + +`Awaitable`\<`ExecuteResult`\<`E`, `M`, `O`\>\> + +*** + +### input + +```ts +readonly input: I; +``` + +*** + +### method + +```ts +readonly method: M; +``` + +*** + +### name + +```ts +readonly name: Name; +``` + +*** + +### output + +```ts +readonly output: O; +``` diff --git a/docs/docs/api/appkit/Interface.PolicyContext.md b/docs/docs/api/appkit/Interface.PolicyContext.md new file mode 100644 index 000000000..6dd661f1a --- /dev/null +++ b/docs/docs/api/appkit/Interface.PolicyContext.md @@ -0,0 +1,45 @@ +# Interface: PolicyContext\ + +What every authorization callback receives. + +## Extended by + +- [`OperationContext`](Interface.OperationContext.md) + +## Type Parameters + +| Type Parameter | Default type | +| ------ | ------ | +| `In` | `unknown` | + +## Properties + +### actor + +```ts +readonly actor: Actor; +``` + +*** + +### appkit + +```ts +readonly appkit: OperationAppKit; +``` + +*** + +### db + +```ts +readonly db: DatabaseExports; +``` + +*** + +### input + +```ts +readonly input: In; +``` diff --git a/docs/docs/api/appkit/Interface.PolicyDenial.md b/docs/docs/api/appkit/Interface.PolicyDenial.md new file mode 100644 index 000000000..92f3d239d --- /dev/null +++ b/docs/docs/api/appkit/Interface.PolicyDenial.md @@ -0,0 +1,19 @@ +# Interface: PolicyDenial + +An explicit refusal. `reason` stays on the server; HTTP and logs do not receive it. + +## Properties + +### deny + +```ts +readonly deny: "forbidden" | "not-found"; +``` + +*** + +### reason? + +```ts +readonly optional reason: string; +``` diff --git a/docs/docs/api/appkit/TypeAlias.DatabricksAppKitDatabaseOperations.md b/docs/docs/api/appkit/TypeAlias.DatabricksAppKitDatabaseOperations.md new file mode 100644 index 000000000..c1c8cf0e5 --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.DatabricksAppKitDatabaseOperations.md @@ -0,0 +1,7 @@ +# Type Alias: DatabricksAppKitDatabaseOperations + +```ts +type DatabricksAppKitDatabaseOperations = globalThis.DatabricksAppKitDatabaseOperations; +``` + +The same global registry, available as a module type for package exports. diff --git a/docs/docs/api/appkit/TypeAlias.OperationMethod.md b/docs/docs/api/appkit/TypeAlias.OperationMethod.md new file mode 100644 index 000000000..8ca71d7fb --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.OperationMethod.md @@ -0,0 +1,7 @@ +# Type Alias: OperationMethod + +```ts +type OperationMethod = DatabaseOperationMethod; +``` + +HTTP methods an operation can answer; the browser client reads the same union. diff --git a/docs/docs/api/appkit/TypeAlias.OperationWireInput.md b/docs/docs/api/appkit/TypeAlias.OperationWireInput.md new file mode 100644 index 000000000..9861ad190 --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.OperationWireInput.md @@ -0,0 +1,13 @@ +# Type Alias: OperationWireInput\ + +```ts +type OperationWireInput = Operation extends OperationDefinition ? WireInputOf : never; +``` + +The JSON a caller sends to an operation; a bigint travels as a string or safe integer. + +## Type Parameters + +| Type Parameter | +| ------ | +| `Operation` | diff --git a/docs/docs/api/appkit/TypeAlias.OperationWireOutput.md b/docs/docs/api/appkit/TypeAlias.OperationWireOutput.md new file mode 100644 index 000000000..795176f8b --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.OperationWireOutput.md @@ -0,0 +1,13 @@ +# Type Alias: OperationWireOutput\ + +```ts +type OperationWireOutput = Operation extends OperationDefinition ? WireOutputOf : never; +``` + +The JSON a successful call answers with; a bigint travels as a decimal string. + +## Type Parameters + +| Type Parameter | +| ------ | +| `Operation` | diff --git a/docs/docs/api/appkit/TypeAlias.Policy.md b/docs/docs/api/appkit/TypeAlias.Policy.md new file mode 100644 index 000000000..ea56ef70b --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.Policy.md @@ -0,0 +1,14 @@ +# Type Alias: Policy\ + +```ts +type Policy = AllowPolicy | ResourcePolicy; +``` + +A reusable authorization rule, with or without a resource lookup. + +## Type Parameters + +| Type Parameter | Default type | +| ------ | ------ | +| `In` | `unknown` | +| `R` | `undefined` | diff --git a/docs/docs/api/appkit/TypeAlias.PolicyDecision.md b/docs/docs/api/appkit/TypeAlias.PolicyDecision.md new file mode 100644 index 000000000..50f688fca --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.PolicyDecision.md @@ -0,0 +1,7 @@ +# Type Alias: PolicyDecision + +```ts +type PolicyDecision = true | false | PolicyDenial; +``` + +Only literal `true` admits. False and an explicit denial refuse. diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index e29fd2dcd..4cbd14cfe 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -39,6 +39,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | Interface | Description | | ------ | ------ | +| [Actor](Interface.Actor.md) | The authenticated caller. The HTTP boundary resolves it before any policy runs. | | [AgentAdapter](Interface.AgentAdapter.md) | - | | [AgentDefinition](Interface.AgentDefinition.md) | - | | [AgentInput](Interface.AgentInput.md) | - | @@ -54,7 +55,9 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [CallerContext](Interface.CallerContext.md) | Caller identity and workspace for one immutable execution scope. | | [CustomJudgeSpec](Interface.CustomJudgeSpec.md) | A custom LLM-judge definition: a prompt template and choice→score mapping. | | [DatabaseCredential](Interface.DatabaseCredential.md) | Database credentials with OAuth token for Postgres connection | +| [DatabaseOperationContract](Interface.DatabaseOperationContract.md) | The browser-safe contract of one generated operation. `input` and `output` are JSON types. A generated `database.d.ts` fills this interface by name. | | [DatabaseRegistry](Interface.DatabaseRegistry.md) | CANONICAL augmentation target. Empty by default; the generated `database.d.ts` augments it via `declare module "@databricks/appkit" { interface DatabaseRegistry { ... } }`. | +| [DatabaseRoutes](Interface.DatabaseRoutes.md) | The generated route entry. `schema` and each module stay lazy, so importing the registry evaluates neither the schema nor an operation. | | [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. | @@ -92,8 +95,12 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [MatchResult](Interface.MatchResult.md) | Result of a deterministic matcher run against a value. | | [McpConnectAllResult](Interface.McpConnectAllResult.md) | Per-endpoint outcome of [AppKitMcpClient.connectAll](Class.AppKitMcpClient.md#connectall). Callers (the agents plugin in particular) use the split to warn at startup when some MCP servers are unreachable without aborting boot for the rest. | | [Message](Interface.Message.md) | - | +| [OperationContext](Interface.OperationContext.md) | What `allow` and `execute` receive: the policy context and the loaded resource. | +| [OperationDefinition](Interface.OperationDefinition.md) | One immutable operation descriptor. Nothing in it runs until a request does. | | [PluginManifest](Interface.PluginManifest.md) | Plugin manifest that declares metadata and resource requirements. Attached to plugin classes as a static property. Extends the shared PluginManifest with strict resource types. | | [PluginToolkitProvider](Interface.PluginToolkitProvider.md) | Minimum shape every entry in the [Plugins](TypeAlias.Plugins.md) map must expose. Core plugins (analytics, files, genie, lakebase) implement this directly via their `.toolkit()` method. The agents plugin and standalone `runAgent` synthesize this shape for any registered plugin that doesn't implement `.toolkit()` directly (falling back to `getAgentTools()` walking). | +| [PolicyContext](Interface.PolicyContext.md) | What every authorization callback receives. | +| [PolicyDenial](Interface.PolicyDenial.md) | An explicit refusal. `reason` stays on the server; HTTP and logs do not receive it. | | [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) | - | @@ -149,6 +156,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [DatabaseApiWriteOperation](TypeAlias.DatabaseApiWriteOperation.md) | Generated HTTP write operations. | | [DatabaseApiWritesConfig](TypeAlias.DatabaseApiWritesConfig.md) | All writes by default; false keeps reads only, and an object narrows writes. | | [DatabaseExports](TypeAlias.DatabaseExports.md) | Typed database API published by the plugin. | +| [DatabricksAppKitDatabaseOperations](TypeAlias.DatabricksAppKitDatabaseOperations.md) | The same global registry, available as a module type for package exports. | | [EvalProgress](TypeAlias.EvalProgress.md) | - | | [ExecutionContext](TypeAlias.ExecutionContext.md) | - | | [ExecutionResult](TypeAlias.ExecutionResult.md) | Discriminated union for plugin execution results. | @@ -161,8 +169,13 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [IDatabaseConfig](TypeAlias.IDatabaseConfig.md) | Configuration for one schema-bound DatabasePlugin instance. | | [JobsExport](TypeAlias.JobsExport.md) | Public API shape of the jobs plugin. Callable to select a job by key. | | [Matcher](TypeAlias.Matcher.md) | A deterministic matcher: inspects a string value and returns a result. | +| [OperationMethod](TypeAlias.OperationMethod.md) | HTTP methods an operation can answer; the browser client reads the same union. | +| [OperationWireInput](TypeAlias.OperationWireInput.md) | The JSON a caller sends to an operation; a bigint travels as a string or safe integer. | +| [OperationWireOutput](TypeAlias.OperationWireOutput.md) | The JSON a successful call answers with; a bigint travels as a decimal string. | | [PluginData](TypeAlias.PluginData.md) | Tuple of plugin class, config, and name. Created by `toPlugin()` and passed to `createApp()`. | | [Plugins](TypeAlias.Plugins.md) | Plugin map passed to the function form of [AgentDefinition.tools](Interface.AgentDefinition.md#tools). Each entry exposes a `.toolkit(opts?)` method that returns a record of [ToolkitEntry](Interface.ToolkitEntry.md) markers ready to be spread into a tool record. | +| [Policy](TypeAlias.Policy.md) | A reusable authorization rule, with or without a resource lookup. | +| [PolicyDecision](TypeAlias.PolicyDecision.md) | Only literal `true` admits. False and an explicit denial refuse. | | [ResolvedToolEntry](TypeAlias.ResolvedToolEntry.md) | Internal tool-index entry after a tool record has been resolved to a dispatchable form. | | [ResourceFieldEntry](TypeAlias.ResourceFieldEntry.md) | - | | [ResourcePermission](TypeAlias.ResourcePermission.md) | Union of all possible permission levels across all resource types. | @@ -212,6 +225,8 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [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). | +| [defineOperation](Function.defineOperation.md) | Declare one database operation. `name` is its public identifier. The entity and method infer the default input and output; `input` and `output` Zod schemas override them. Definition is pure: it freezes a descriptor and calls nothing. | +| [definePolicy](Function.definePolicy.md) | Declare a reusable authorization rule. Without `resource` it decides from the actor and input. With `resource` it loads one record first; annotate the lookup context to fix the input the policy needs. `allow` may return `true`, `false`, or `{ deny }`. | | [defineSchema](Function.defineSchema.md) | Compile one declared schema. The returned type keeps the table names the builder returned, so `api.tables` 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. | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index 6d389ed01..f824e1864 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -127,6 +127,11 @@ const typedocSidebar: SidebarsConfig = { type: "category", label: "Interfaces", items: [ + { + type: "doc", + id: "api/appkit/Interface.Actor", + label: "Actor" + }, { type: "doc", id: "api/appkit/Interface.AgentAdapter", @@ -202,11 +207,21 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.DatabaseCredential", label: "DatabaseCredential" }, + { + type: "doc", + id: "api/appkit/Interface.DatabaseOperationContract", + label: "DatabaseOperationContract" + }, { type: "doc", id: "api/appkit/Interface.DatabaseRegistry", label: "DatabaseRegistry" }, + { + type: "doc", + id: "api/appkit/Interface.DatabaseRoutes", + label: "DatabaseRoutes" + }, { type: "doc", id: "api/appkit/Interface.DatabaseValidationIssue", @@ -394,6 +409,16 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.Message", label: "Message" }, + { + type: "doc", + id: "api/appkit/Interface.OperationContext", + label: "OperationContext" + }, + { + type: "doc", + id: "api/appkit/Interface.OperationDefinition", + label: "OperationDefinition" + }, { type: "doc", id: "api/appkit/Interface.PluginManifest", @@ -404,6 +429,16 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.PluginToolkitProvider", label: "PluginToolkitProvider" }, + { + type: "doc", + id: "api/appkit/Interface.PolicyContext", + label: "PolicyContext" + }, + { + type: "doc", + id: "api/appkit/Interface.PolicyDenial", + label: "PolicyDenial" + }, { type: "doc", id: "api/appkit/Interface.PostResult", @@ -660,6 +695,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/TypeAlias.DatabaseExports", label: "DatabaseExports" }, + { + type: "doc", + id: "api/appkit/TypeAlias.DatabricksAppKitDatabaseOperations", + label: "DatabricksAppKitDatabaseOperations" + }, { type: "doc", id: "api/appkit/TypeAlias.EvalProgress", @@ -720,6 +760,21 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/TypeAlias.Matcher", label: "Matcher" }, + { + type: "doc", + id: "api/appkit/TypeAlias.OperationMethod", + label: "OperationMethod" + }, + { + type: "doc", + id: "api/appkit/TypeAlias.OperationWireInput", + label: "OperationWireInput" + }, + { + type: "doc", + id: "api/appkit/TypeAlias.OperationWireOutput", + label: "OperationWireOutput" + }, { type: "doc", id: "api/appkit/TypeAlias.PluginData", @@ -730,6 +785,16 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/TypeAlias.Plugins", label: "Plugins" }, + { + type: "doc", + id: "api/appkit/TypeAlias.Policy", + label: "Policy" + }, + { + type: "doc", + id: "api/appkit/TypeAlias.PolicyDecision", + label: "PolicyDecision" + }, { type: "doc", id: "api/appkit/TypeAlias.ResolvedToolEntry", @@ -938,6 +1003,16 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.defineManifest", label: "defineManifest" }, + { + type: "doc", + id: "api/appkit/Function.defineOperation", + label: "defineOperation" + }, + { + type: "doc", + id: "api/appkit/Function.definePolicy", + label: "definePolicy" + }, { type: "doc", id: "api/appkit/Function.defineSchema", diff --git a/packages/appkit/src/beta.ts b/packages/appkit/src/beta.ts index d1a939570..2486e9917 100644 --- a/packages/appkit/src/beta.ts +++ b/packages/appkit/src/beta.ts @@ -130,3 +130,22 @@ export type { IDatabaseConfig, TransactionClient, } from "./plugins/database"; +export type { + DatabaseOperationContract, + DatabricksAppKitDatabaseOperations, +} from "shared"; +export { + type Actor, + type DatabaseRoutes, + defineOperation, + definePolicy, + type OperationContext, + type OperationDefinition, + type OperationMethod, + type OperationWireInput, + type OperationWireOutput, + type Policy, + type PolicyContext, + type PolicyDecision, + type PolicyDenial, +} from "./plugins/database/operations"; diff --git a/packages/appkit/src/database/contract/registry.ts b/packages/appkit/src/database/contract/registry.ts index b4ade64e4..6635eff91 100644 --- a/packages/appkit/src/database/contract/registry.ts +++ b/packages/appkit/src/database/contract/registry.ts @@ -1,3 +1,5 @@ +import type { DatabaseApiEntry } from "shared"; + /** The shape of a single generated registry entry. */ export interface DatabaseRegistryEntry { /** Full server-side row (includes private columns). */ @@ -14,6 +16,8 @@ export interface DatabaseRegistryEntry { includes: Record; /** Literal capability used to omit keyed methods from keyless entities. */ hasPrimaryKey: boolean; + /** What the generated HTTP routes accept: public inputs, filters, order, and key. */ + api: DatabaseApiEntry["api"]; } /** diff --git a/packages/appkit/src/database/contract/tests/registry.test.ts b/packages/appkit/src/database/contract/tests/registry.test.ts index 74c5f0aa9..8dea6701f 100644 --- a/packages/appkit/src/database/contract/tests/registry.test.ts +++ b/packages/appkit/src/database/contract/tests/registry.test.ts @@ -1,3 +1,4 @@ +import type { DatabaseApiEntry } from "shared"; import { describe, expectTypeOf, it } from "vitest"; import type { DatabaseRegistryEntry, RegisteredEntity } from "../index"; @@ -55,5 +56,8 @@ describe("DatabaseRegistryEntry shape", () => { expectTypeOf< DatabaseRegistryEntry["hasPrimaryKey"] >().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + DatabaseApiEntry["api"] + >(); }); }); diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index fabe656b3..7aba62dc2 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -12,6 +12,8 @@ export type { UserScopedApp, BasePluginConfig, CacheConfig, + DatabaseOperationContract, + DatabricksAppKitDatabaseOperations, IAppRouter, PluginData, StreamExecutionSettings, diff --git a/packages/appkit/src/plugins/database/operation-metadata.ts b/packages/appkit/src/plugins/database/operation-metadata.ts new file mode 100644 index 000000000..337e7b5e4 --- /dev/null +++ b/packages/appkit/src/plugins/database/operation-metadata.ts @@ -0,0 +1,247 @@ +import { isOperationCandidate, type LocatedDeclaration } from "./operations"; + +/** HTTP methods a named operation can answer. */ +const METHODS = ["GET", "POST", "PATCH", "PUT", "DELETE"] as const; + +/** + * A public operation name is one URL segment. Dots separate its parts, and + * the character set matches the table names the database routes already accept. + */ +const OPERATION_NAME = + /^[A-Za-z][A-Za-z0-9_-]{0,63}(?:\.[A-Za-z][A-Za-z0-9_-]{0,63})*$/; + +/** Longest operation name, so a request path stays bounded. */ +const MAX_OPERATION_NAME_LENGTH = 255; + +/** Names a plain object cannot hold as its own key, as the boot payload does. */ +const RESERVED_NAMES: ReadonlySet = new Set([ + "constructor", + "prototype", +]); + +/** Metadata both the runtime and the generator can trust. Callbacks stay uncalled. */ +interface ValidatedOperation { + readonly file: string; + readonly exportName: string; + readonly name: string; + readonly entity: string; + readonly method: (typeof METHODS)[number]; + readonly input: object | undefined; + readonly output: object | undefined; + readonly authorize: + | ((...args: never[]) => unknown) + | { + readonly allow: (...args: never[]) => unknown; + readonly resource?: (...args: never[]) => unknown; + }; + readonly execute: (...args: never[]) => unknown; +} + +/** Why a declaration was not published, with the export it came from. */ +interface OperationMetadataIssue { + readonly file: string; + readonly exportName: string; + readonly code: + | "not-operation" + | "name" + | "entity" + | "method" + | "input" + | "output" + | "authorize" + | "execute" + | "duplicate"; + readonly message: string; +} + +/** + * `skipped` lists declarations whose only problem is missing authorization; + * the runtime warns and serves the rest. `errors` lists malformed or duplicate + * declarations; any error blocks the whole registry. + */ +interface OperationMetadataResult { + readonly operations: readonly ValidatedOperation[]; + readonly skipped: readonly OperationMetadataIssue[]; + readonly errors: readonly OperationMetadataIssue[]; +} + +function issue( + located: LocatedDeclaration, + code: OperationMetadataIssue["code"], + message: string, +): OperationMetadataIssue { + return { + file: located.file, + exportName: located.exportName, + code, + message, + }; +} + +function source(located: LocatedDeclaration): string { + return `${located.file}#${located.exportName}`; +} + +/** A Zod schema is an object with a callable safeParse. Nothing else is read. */ +function isSchema(value: unknown): value is object { + if (typeof value !== "object" || value === null) return false; + return typeof Reflect.get(value, "safeParse") === "function"; +} + +function readFields(value: object): Record { + return { + name: Reflect.get(value, "name"), + entity: Reflect.get(value, "entity"), + method: Reflect.get(value, "method"), + input: Reflect.get(value, "input"), + output: Reflect.get(value, "output"), + authorize: Reflect.get(value, "authorize"), + execute: Reflect.get(value, "execute"), + }; +} + +/** + * Check located exports once. A non-operation export is ignored. A descriptor + * whose only problem is missing authorization is skipped. Every malformed field + * is an error, and a valid name counts toward duplicates even when its + * declaration is skipped. The result keeps declaration order and calls no + * schema or callback. + */ +export function validateOperationMetadata( + located: readonly LocatedDeclaration[], + entities: ReadonlySet, +): OperationMetadataResult { + const operations: ValidatedOperation[] = []; + const skipped: OperationMetadataIssue[] = []; + const errors: OperationMetadataIssue[] = []; + const seen = new Map(); + + for (const entry of located) { + if (!isOperationCandidate(entry.value)) continue; + const fields = readFields(entry.value); + const problems: OperationMetadataIssue[] = []; + + const name = + typeof fields.name === "string" && + fields.name.length <= MAX_OPERATION_NAME_LENGTH && + OPERATION_NAME.test(fields.name) && + !RESERVED_NAMES.has(fields.name) + ? fields.name + : undefined; + if (name === undefined) { + problems.push( + issue( + entry, + "name", + typeof fields.name === "string" && RESERVED_NAMES.has(fields.name) + ? `${source(entry)} cannot use the reserved operation name ${JSON.stringify(fields.name)}.` + : `${source(entry)} must name the operation with letters, digits, "_", "-", and dots: at most 64 characters per part and ${MAX_OPERATION_NAME_LENGTH} in total.`, + ), + ); + } else { + const previous = seen.get(name); + if (previous) { + problems.push( + issue( + entry, + "duplicate", + `${source(entry)} repeats operation name ${JSON.stringify(name)}, already exported by ${source(previous)}.`, + ), + ); + } else { + seen.set(name, entry); + } + } + if (typeof fields.entity !== "string" || !entities.has(fields.entity)) { + problems.push( + issue( + entry, + "entity", + `${source(entry)} must name one entity in the database schema.`, + ), + ); + } + if ( + typeof fields.method !== "string" || + !METHODS.includes(fields.method as (typeof METHODS)[number]) + ) { + problems.push( + issue( + entry, + "method", + `${source(entry)} must use GET, POST, PATCH, PUT, or DELETE.`, + ), + ); + } + if (fields.input !== undefined && !isSchema(fields.input)) { + problems.push( + issue(entry, "input", `${source(entry)} input must be a Zod schema.`), + ); + } + if (fields.output !== undefined && !isSchema(fields.output)) { + problems.push( + issue(entry, "output", `${source(entry)} output must be a Zod schema.`), + ); + } + if (typeof fields.execute !== "function") { + problems.push( + issue( + entry, + "execute", + `${source(entry)} must provide an execute function.`, + ), + ); + } + + const unauthorized = fields.authorize === undefined; + if (unauthorized) { + skipped.push( + issue( + entry, + "authorize", + `${source(entry)} has no authorization and was skipped.`, + ), + ); + } else if (typeof fields.authorize !== "function") { + const policy = fields.authorize; + const allow = + typeof policy === "object" && policy !== null + ? Reflect.get(policy, "allow") + : undefined; + const resource = + typeof policy === "object" && policy !== null + ? Reflect.get(policy, "resource") + : undefined; + if ( + typeof allow !== "function" || + (resource !== undefined && typeof resource !== "function") + ) { + problems.push( + issue( + entry, + "authorize", + `${source(entry)} authorization must be a function or a policy with a callable allow rule.`, + ), + ); + } + } + + errors.push(...problems); + if (problems.length > 0 || unauthorized || name === undefined) continue; + + const authorize = fields.authorize as ValidatedOperation["authorize"]; + operations.push({ + file: entry.file, + exportName: entry.exportName, + name, + entity: fields.entity as string, + method: fields.method as (typeof METHODS)[number], + input: fields.input as object | undefined, + output: fields.output as object | undefined, + authorize, + execute: fields.execute as (...args: never[]) => unknown, + }); + } + + return { operations, skipped, errors }; +} diff --git a/packages/appkit/src/plugins/database/operations.ts b/packages/appkit/src/plugins/database/operations.ts new file mode 100644 index 000000000..1353c0cde --- /dev/null +++ b/packages/appkit/src/plugins/database/operations.ts @@ -0,0 +1,414 @@ +import type { + DatabaseOperationMethod, + PluginMap, + WireInput, + WireOutput, +} from "shared"; +import type { z } from "zod"; + +import type { + DatabaseRegistry, + DatabaseRegistryEntry, +} from "../../database/contract"; +import type { Schema as DatabaseSchema } from "../../database/schema-builder"; +import type * as betaPlugins from "../beta-exports.generated"; +import type * as gaPlugins from "../ga-exports.generated"; +import type { DatabaseExports, EntityName } from "./entity-types"; + +/** HTTP methods an operation can answer; the browser client reads the same union. */ +export type OperationMethod = DatabaseOperationMethod; + +type Awaitable = T | PromiseLike; +type Schema = z.ZodType; +type SchemaSlot = Schema | undefined; + +/** The authenticated caller. The HTTP boundary resolves it before any policy runs. */ +export interface Actor { + readonly id: string; + readonly email?: string; +} + +type SdkPluginFactory = + | (typeof gaPlugins)[keyof typeof gaPlugins] + | (typeof betaPlugins)[keyof typeof betaPlugins]; + +/** + * The service-principal plugin exports an operation can call. A route module + * cannot see which plugins the app registered, so each SDK plugin is optional + * and custom plugins are absent. `database` is always registered and is the + * same client as `db`. + */ +export type OperationAppKit = Readonly< + Partial[]>> +> & { + readonly database: DatabaseExports; +}; + +/** What every authorization callback receives. */ +export interface PolicyContext { + readonly actor: Actor; + readonly db: DatabaseExports; + readonly appkit: OperationAppKit; + readonly input: In; +} + +/** What `allow` and `execute` receive: the policy context and the loaded resource. */ +export interface OperationContext< + In = unknown, + R = undefined, +> extends PolicyContext { + readonly resource: R; +} + +/** An explicit refusal. `reason` stays on the server; HTTP and logs do not receive it. */ +export interface PolicyDenial { + readonly deny: "forbidden" | "not-found"; + readonly reason?: string; +} + +/** Only literal `true` admits. False and an explicit denial refuse. */ +export type PolicyDecision = true | false | PolicyDenial; + +/** Admits a request from its actor and input alone. */ +interface AllowPolicy { + readonly resource?: undefined; + readonly allow: ( + context: OperationContext, + ) => Awaitable; +} + +/** + * Loads one resource, then admits the request against it. A lookup that finds + * nothing ends the request before `allow` runs, so `allow` and `execute` see + * the lookup's non-null resource. + */ +interface ResourcePolicy { + readonly resource: ( + context: PolicyContext, + ) => Awaitable; + readonly allow: ( + context: OperationContext>>, + ) => Awaitable; +} + +/** A reusable authorization rule, with or without a resource lookup. */ +export type Policy = + | AllowPolicy + | ResourcePolicy; + +type Decision = (context: PolicyContext) => Awaitable; + +type Authorize = Decision | Policy; + +type Entry = + DatabaseRegistry[E] extends DatabaseRegistryEntry + ? DatabaseRegistry[E] + : never; +type PublicRow = Entry["publicRow"]; +type Http = Entry["api"]; +type Id = PublicRow[Http["key"] & + keyof PublicRow]; +/** A keyless entity has no id to default to, so its input must be explicit. */ +type Keyed = [Http["key"]] extends [never] + ? never + : T; + +/** The generated facet for a table with no writable public columns. */ +type EmptyObject = Record; +type HasUpdateField = T extends EmptyObject + ? false + : [keyof T] extends [never] + ? false + : true; + +/** + * The inferred contract of each method: the parsed `input`, what `execute` + * returns, and the successful response body before JSON encoding. Inputs come + * from the HTTP facets, never from the trusted insert or update shapes. A + * `null` result is a missing record; DELETE answers `false` for one. + * PATCH and PUT have no default when the public update facet is empty. + */ +interface DefaultContract { + GET: { + input: Keyed }>; + result: PublicRow | null; + output: PublicRow; + }; + POST: { + input: Http["insert"]; + result: PublicRow; + output: PublicRow; + }; + PATCH: { + input: HasUpdateField["update"]> extends true + ? Keyed; changes: Http["update"] }> + : never; + result: PublicRow | null; + output: PublicRow; + }; + PUT: { + input: HasUpdateField["update"]> extends true + ? Keyed< + E, + { + id: Id; + values: Required["update"], EmptyObject>>; + } + > + : never; + result: PublicRow | null; + output: PublicRow; + }; + DELETE: { + input: Keyed }>; + result: boolean; + output: void; + }; +} + +type Contract< + E extends EntityName, + M extends OperationMethod, +> = DefaultContract[M]; + +/** Methods whose `null` result means a missing record, whatever the output schema. */ +type NullableMethod = "GET" | "PATCH" | "PUT"; + +type ParsedInput< + E extends EntityName, + M extends OperationMethod, + I extends SchemaSlot, +> = [I] extends [Schema] ? z.output : Contract["input"]; + +type ExecuteResult< + E extends EntityName, + M extends OperationMethod, + O extends SchemaSlot, +> = [O] extends [Schema] + ? z.input | (M extends NullableMethod ? null : never) + : Contract["result"]; + +type WireInputOf< + E extends EntityName, + M extends OperationMethod, + I extends SchemaSlot, +> = [I] extends [Schema] ? z.input : WireInput["input"]>; + +/** + * A default body encodes bigint as a decimal string. An explicit output uses + * its parsed JSON type, and `null` is a successful body only when that type + * accepts it. DELETE has no body unless its output says otherwise. + */ +type WireOutputOf< + E extends EntityName, + M extends OperationMethod, + O extends SchemaSlot, +> = [O] extends [Schema] + ? null extends z.output + ? z.output + : M extends NullableMethod + ? Exclude, null> + : z.output + : WireOutput["output"]>; + +/** + * Everything `defineOperation` accepts besides its callbacks. Both overloads + * intersect it directly: inference cannot see `input` through an `Omit` of + * this conditional type, so an explicit input would silently become the default. + */ +type OperationFields< + E extends EntityName, + M extends OperationMethod, + I extends SchemaSlot, + O extends SchemaSlot, + Name extends string, +> = { + readonly name: Name & string; + readonly entity: E; + readonly method: M; + readonly output?: O; +} & ([Contract["input"]] extends [never] + ? { readonly input: I & Schema } + : { readonly input?: I }); + +/** What `defineOperation` accepts for one entity and method. */ +type OperationOptions< + E extends EntityName, + M extends OperationMethod, + I extends SchemaSlot, + O extends SchemaSlot, + R, + Name extends string, +> = OperationFields & { + readonly authorize: Authorize, R>; + readonly execute: ( + context: OperationContext< + ParsedInput, + [R] extends [undefined] ? undefined : NonNullable> + >, + ) => Awaitable>; +}; + +/** One immutable operation descriptor. Nothing in it runs until a request does. */ +export interface OperationDefinition< + E extends EntityName, + M extends OperationMethod, + I extends SchemaSlot, + O extends SchemaSlot, + R, + Name extends string, +> { + readonly name: Name; + readonly entity: E; + readonly method: M; + readonly input: I; + readonly output: O; + readonly authorize: Authorize, R>; + readonly execute: ( + context: OperationContext< + ParsedInput, + [R] extends [undefined] ? undefined : NonNullable> + >, + ) => Awaitable>; +} + +/** The JSON a caller sends to an operation; a bigint travels as a string or safe integer. */ +export type OperationWireInput = + Operation extends OperationDefinition< + infer E extends EntityName, + infer M extends OperationMethod, + infer I extends SchemaSlot, + infer _O extends SchemaSlot, + infer _R, + infer _Name extends string + > + ? WireInputOf + : never; + +/** The JSON a successful call answers with; a bigint travels as a decimal string. */ +export type OperationWireOutput = + Operation extends OperationDefinition< + infer E extends EntityName, + infer M extends OperationMethod, + infer _I extends SchemaSlot, + infer O extends SchemaSlot, + infer _R, + infer _Name extends string + > + ? WireOutputOf + : never; + +/** @internal What `defineOperation` produced; every field still needs validation. */ +interface OperationCandidate { + readonly name: unknown; + readonly entity: unknown; + readonly method: unknown; + readonly input: unknown; + readonly output: unknown; + readonly authorize: unknown; + readonly execute: unknown; +} + +type RawFields = { readonly [K in keyof T]?: unknown }; + +// A registered symbol, so a loader sees the tag across duplicated module copies. +const OPERATION = Symbol.for("@databricks/appkit/database/operation"); + +/** + * Declare one database operation. `name` is its public identifier. The entity + * and method infer the default input and output; `input` and `output` Zod + * schemas override them. Definition is pure: it freezes a descriptor and + * calls nothing. + */ +export function defineOperation< + const Name extends string, + E extends EntityName, + M extends OperationMethod, + I extends SchemaSlot, + O extends SchemaSlot, + R extends NonNullable, +>( + definition: OperationFields & { + readonly authorize: ResourcePolicy, R>; + readonly execute: ( + context: OperationContext, NonNullable>>, + ) => Awaitable>; + }, +): OperationDefinition; +export function defineOperation< + const Name extends string, + E extends EntityName, + M extends OperationMethod, + I extends SchemaSlot = undefined, + O extends SchemaSlot = undefined, +>( + definition: OperationOptions, +): OperationDefinition; +export function defineOperation( + definition: RawFields, +): OperationCandidate { + // An untyped file may omit authorize; the loader reports it, nothing here fills it. + const descriptor: OperationCandidate = { + name: definition.name, + entity: definition.entity, + method: definition.method, + input: definition.input, + output: definition.output, + authorize: definition.authorize, + execute: definition.execute, + }; + Object.defineProperty(descriptor, OPERATION, { value: true }); + return Object.freeze(descriptor); +} + +/** @internal Whether `value` came from `defineOperation`, not whether it is valid. */ +export function isOperationCandidate( + value: unknown, +): value is OperationCandidate { + return ( + typeof value === "object" && + value !== null && + Object.hasOwn(value, OPERATION) + ); +} + +/** + * Declare a reusable authorization rule. Without `resource` it decides from + * the actor and input. With `resource` it loads one record first; annotate + * the lookup context to fix the input the policy needs. `allow` may return + * `true`, `false`, or `{ deny }`. + */ +export function definePolicy( + policy: AllowPolicy, +): AllowPolicy; +export function definePolicy( + policy: ResourcePolicy, +): ResourcePolicy; +export function definePolicy( + policy: RawFields>, +): RawFields> { + return Object.freeze( + policy.resource === undefined + ? { allow: policy.allow } + : { resource: policy.resource, allow: policy.allow }, + ); +} + +/** One export a module loader found. `value` may be any export, not only an operation. */ +export interface LocatedDeclaration { + readonly file: string; + readonly exportName: string; + readonly value: unknown; +} + +/** + * The generated route entry. `schema` and each module stay lazy, so importing + * the registry evaluates neither the schema nor an operation. + */ +export interface DatabaseRoutes { + readonly schema: () => Promise<{ schema: DatabaseSchema }>; + readonly modules: readonly { + readonly file: string; + readonly load: () => Promise>; + }[]; +} diff --git a/packages/appkit/src/plugins/database/tests/entity-types.test.ts b/packages/appkit/src/plugins/database/tests/entity-types.test.ts index 035966156..b9fb6d494 100644 --- a/packages/appkit/src/plugins/database/tests/entity-types.test.ts +++ b/packages/appkit/src/plugins/database/tests/entity-types.test.ts @@ -58,6 +58,13 @@ interface TestRegistry { comments: { to: "comments"; many: true }; }; hasPrimaryKey: true; + api: { + insert: { body: string; rank: number }; + update: { body?: string; rank?: number }; + filters: { body?: string; rank?: number }; + orderable: "id" | "body" | "rank"; + key: "id"; + }; }; users: { row: { id: string; name: string; token: string }; @@ -67,6 +74,13 @@ interface TestRegistry { filters: { name?: TextFilter }; includes: Record; hasPrimaryKey: true; + api: { + insert: { name: string }; + update: { name?: string }; + filters: { name?: string }; + orderable: "id" | "name"; + key: "id"; + }; }; comments: { row: { id: string; text: string; internal: string }; @@ -76,6 +90,13 @@ interface TestRegistry { filters: { text?: TextFilter }; includes: { author: { to: "users"; many: false } }; hasPrimaryKey: true; + api: { + insert: { text: string }; + update: { text?: string }; + filters: { text?: string }; + orderable: "id" | "text"; + key: "id"; + }; }; events: { row: { message: string }; @@ -85,6 +106,13 @@ interface TestRegistry { filters: { message?: TextFilter }; includes: Record; hasPrimaryKey: false; + api: { + insert: { message: string }; + update: { message?: string }; + filters: { message?: string }; + orderable: "message"; + key: never; + }; }; } diff --git a/packages/appkit/src/plugins/database/tests/operations.test.ts b/packages/appkit/src/plugins/database/tests/operations.test.ts new file mode 100644 index 000000000..ea024f997 --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/operations.test.ts @@ -0,0 +1,405 @@ +import { describe, expect, expectTypeOf, test, vi } from "vitest"; +import { z } from "zod"; + +import type * as Beta from "../../../beta"; +import { validateOperationMetadata } from "../operation-metadata"; +import { + defineOperation, + definePolicy, + isOperationCandidate, + type OperationContext, + type PolicyContext, +} from "../operations"; + +// @ts-expect-error candidate detection is internal to the future loader. +type InternalCandidate = typeof Beta.isOperationCandidate; +// @ts-expect-error operations run only through the future HTTP runtime. +type InternalRunner = typeof Beta.runOperation; + +// The package has no generated registry, so these calls take the untyped JS path. +const defineUntyped = defineOperation as unknown as ( + definition: Record, +) => unknown; + +/** A schema that fails the test if definition reads anything from it. */ +function opaqueSchema(): z.ZodType { + return new Proxy(z.object({}), { + get(target, key) { + if (key === "then") return undefined; + throw new Error(`definition read schema.${String(key)}`); + }, + }); +} + +const context = { + actor: { id: "ada" }, + input: { id: 1 }, + resource: { id: 1, owner: "ada" }, +} as unknown as OperationContext<{ id: number }, { id: number; owner: string }>; + +describe("defineOperation", () => { + test("freezes a descriptor that keeps every callback and schema by identity", () => { + const input = z.object({ id: z.number() }); + const output = z.object({ id: z.number() }); + const authorize = vi.fn(() => true); + const execute = vi.fn(() => null); + + const operation = defineUntyped({ + name: "posts.get", + entity: "posts", + method: "GET", + input, + output, + authorize, + execute, + }); + + expect(Object.isFrozen(operation)).toBe(true); + expect(Object.keys(operation as object)).toEqual([ + "name", + "entity", + "method", + "input", + "output", + "authorize", + "execute", + ]); + expect(operation).toMatchObject({ + name: "posts.get", + entity: "posts", + method: "GET", + }); + expect( + Object.getOwnPropertyDescriptor( + operation, + Symbol.for("@databricks/appkit/database/operation"), + )?.enumerable, + ).toBe(false); + const fields = operation as Record; + expect(fields.input).toBe(input); + expect(fields.output).toBe(output); + expect(fields.authorize).toBe(authorize); + expect(fields.execute).toBe(execute); + expect("~types" in (operation as object)).toBe(false); + }); + + test("runs no callback and reads no schema at definition", () => { + const authorize = vi.fn(() => true); + const execute = vi.fn(() => null); + + defineUntyped({ + name: "posts.create", + entity: "posts", + method: "POST", + input: opaqueSchema(), + output: opaqueSchema(), + authorize, + execute, + }); + + expect(authorize).not.toHaveBeenCalled(); + expect(execute).not.toHaveBeenCalled(); + }); + + test("keeps an untyped definition without authorize as a raw candidate", () => { + const execute = () => null; + + const candidate = defineUntyped({ + name: "posts.get", + entity: "posts", + method: "GET", + execute, + }); + + expect(isOperationCandidate(candidate)).toBe(true); + expect(candidate).toMatchObject({ authorize: undefined, execute }); + expect(Object.isFrozen(candidate)).toBe(true); + if (isOperationCandidate(candidate)) { + expectTypeOf(candidate.authorize).toBeUnknown(); + expectTypeOf(candidate.execute).toBeUnknown(); + } + }); + + test("recognizes only descriptors it produced", () => { + const fields = { + name: "posts.get", + entity: "posts", + method: "GET", + authorize: () => true, + execute: () => null, + }; + + const operation = defineUntyped(fields); + + expect(isOperationCandidate(operation)).toBe(true); + expect(isOperationCandidate(fields)).toBe(false); + expect(isOperationCandidate({ ...(operation as object) })).toBe(false); + expect(isOperationCandidate(null)).toBe(false); + expect(isOperationCandidate(() => null)).toBe(false); + }); +}); + +describe("definePolicy", () => { + test("freezes an allow-only policy that stays directly callable", () => { + const allow = vi.fn(({ actor }: PolicyContext) => actor.id === "ada"); + + const policy = definePolicy({ allow }); + + expect(Object.isFrozen(policy)).toBe(true); + expect(Object.keys(policy)).toEqual(["allow"]); + expect(policy.allow).toBe(allow); + expect(allow).not.toHaveBeenCalled(); + expect(policy.allow({ ...context, resource: undefined })).toBe(true); + }); + + test("keeps a resource lookup and its allow rule without running either", async () => { + const resource = vi.fn( + async ({ input }: PolicyContext<{ id: number }>) => ({ + id: input.id, + owner: "ada", + }), + ); + const allow = vi.fn( + ({ + actor, + resource: post, + }: OperationContext<{ id: number }, { owner: string }>) => + post.owner === actor.id, + ); + + const policy = definePolicy({ resource, allow }); + + expect(Object.isFrozen(policy)).toBe(true); + expect(policy.resource).toBe(resource); + expect(policy.allow).toBe(allow); + expect(resource).not.toHaveBeenCalled(); + expect(allow).not.toHaveBeenCalled(); + await expect(policy.resource(context)).resolves.toEqual({ + id: 1, + owner: "ada", + }); + expect(policy.allow(context)).toBe(true); + }); + + test("returns an explicit denial unchanged and does not decide at definition", () => { + const allow = vi.fn(() => ({ + deny: "not-found" as const, + reason: "hidden", + })); + + const policy = definePolicy({ allow }); + + expect(allow).not.toHaveBeenCalled(); + expect(policy.allow({ ...context, resource: undefined })).toEqual({ + deny: "not-found", + reason: "hidden", + }); + }); +}); + +describe("validateOperationMetadata", () => { + const entities = new Set(["posts"]); + const located = (exportName: string, value: unknown) => ({ + file: "routes/posts.ts", + exportName, + value, + }); + + test("accepts a named operation and ignores an ordinary export", () => { + const authorize = () => true; + const execute = () => null; + const input = z.object({ id: z.number() }); + const operation = defineUntyped({ + name: "posts.get", + entity: "posts", + method: "GET", + input, + authorize, + execute, + }); + + const result = validateOperationMetadata( + [located("helper", { name: "posts.get" }), located("getPost", operation)], + entities, + ); + + expect(result.skipped).toEqual([]); + expect(result.errors).toEqual([]); + expect(result.operations).toEqual([ + { + file: "routes/posts.ts", + exportName: "getPost", + name: "posts.get", + entity: "posts", + method: "GET", + input, + output: undefined, + authorize, + execute, + }, + ]); + }); + + test("skips a declaration whose only problem is missing authorization", () => { + const unauthorized = defineUntyped({ + name: "posts.get", + entity: "posts", + method: "GET", + execute: () => null, + }); + + const result = validateOperationMetadata( + [located("unauthorized", unauthorized)], + entities, + ); + + expect(result.operations).toEqual([]); + expect(result.errors).toEqual([]); + expect(result.skipped).toMatchObject([ + { + file: "routes/posts.ts", + exportName: "unauthorized", + code: "authorize", + }, + ]); + expect(result.skipped[0]?.message).toContain("skipped"); + }); + + test("reports every malformed field, with or without authorization", () => { + const unauthorized = defineUntyped({ + name: "posts.get.", + entity: "missing", + method: "HEAD", + execute: () => null, + }); + const malformed = defineUntyped({ + // Four valid 64-character parts exceed the total length. + name: Array.from({ length: 4 }, () => "x".repeat(64)).join("."), + entity: "missing", + method: "HEAD", + input: {}, + output: "row", + authorize: { allow: true }, + execute: null, + }); + + const result = validateOperationMetadata( + [located("unauthorized", unauthorized), located("malformed", malformed)], + entities, + ); + + expect(result.operations).toEqual([]); + expect(result.skipped.map((entry) => entry.exportName)).toEqual([ + "unauthorized", + ]); + expect( + result.errors.map((entry) => `${entry.exportName} ${entry.code}`), + ).toEqual([ + "unauthorized name", + "unauthorized entity", + "unauthorized method", + "malformed name", + "malformed entity", + "malformed method", + "malformed input", + "malformed output", + "malformed execute", + "malformed authorize", + ]); + }); + + test("reserves names a plain object cannot hold as its own key", () => { + const reserved = ["constructor", "prototype"].map((name) => + located( + name, + defineUntyped({ + name, + entity: "posts", + method: "GET", + authorize: () => true, + execute: () => null, + }), + ), + ); + + const result = validateOperationMetadata(reserved, entities); + + expect(result.operations).toEqual([]); + expect(result.errors.map((entry) => entry.message)).toEqual([ + 'routes/posts.ts#constructor cannot use the reserved operation name "constructor".', + 'routes/posts.ts#prototype cannot use the reserved operation name "prototype".', + ]); + }); + + test("rejects a repeated operation name without dropping the first", () => { + const first = defineUntyped({ + name: "posts.get", + entity: "posts", + method: "GET", + authorize: () => true, + execute: () => null, + }); + const second = defineUntyped({ + name: "posts.get", + entity: "posts", + method: "GET", + authorize: () => true, + execute: () => null, + }); + + const result = validateOperationMetadata( + [located("first", first), located("second", second)], + entities, + ); + + expect(result.operations.map((entry) => entry.exportName)).toEqual([ + "first", + ]); + expect(result.errors).toMatchObject([ + { exportName: "second", code: "duplicate" }, + ]); + expect(result.errors[0]?.message).toContain("first"); + }); + + test("counts a skipped declaration's name toward duplicates", () => { + const unauthorized = defineUntyped({ + name: "posts.get", + entity: "posts", + method: "GET", + execute: () => null, + }); + const authorized = defineUntyped({ + name: "posts.get", + entity: "posts", + method: "GET", + authorize: () => true, + execute: () => null, + }); + + const result = validateOperationMetadata( + [ + located("unauthorized", unauthorized), + located("authorized", authorized), + ], + entities, + ); + + expect(result.operations).toEqual([]); + expect(result.skipped.map((entry) => entry.exportName)).toEqual([ + "unauthorized", + ]); + expect(result.errors).toMatchObject([ + { exportName: "authorized", code: "duplicate" }, + ]); + }); +}); + +test("publishes the factories on the beta entry without internal detection", async () => { + const beta = await import("../../../beta"); + + expect(beta.defineOperation).toBe(defineOperation); + expect(beta.definePolicy).toBe(definePolicy); + expect(beta).not.toHaveProperty("isOperationCandidate"); + expect(beta).not.toHaveProperty("runOperation"); + expectTypeOf().not.toBeNever(); +}, 30_000); diff --git a/packages/appkit/src/plugins/database/tests/operations.types.test.ts b/packages/appkit/src/plugins/database/tests/operations.types.test.ts new file mode 100644 index 000000000..9d8dc8a1c --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/operations.types.test.ts @@ -0,0 +1,892 @@ +import { existsSync } from "node:fs"; +import fs from "node:fs/promises"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; + +import ts from "typescript"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +import { generateDatabaseTypes } from "../../../type-generator/database/generate"; + +const appkitRoot = path.resolve(import.meta.dirname, "../../../.."); +const sourceRoot = path.join(appkitRoot, "src"); +const sharedRoot = path.resolve(appkitRoot, "../shared/src"); +const builder = path.join(sourceRoot, "database/schema-builder/index.ts"); +const zodRoot = path.dirname( + createRequire(import.meta.url).resolve("zod/package.json"), +); + +// String, number, bigint, and absent keys, a private column, defaults, and a bigint value. +const schemaSource = ` + import { bigid, bigint, defineSchema, fk, id, integer, text, timestamp, varchar } from ${JSON.stringify(builder)}; + export const schema = defineSchema(({ table }) => { + const users = table("users", { + slug: text().primaryKey(), + name: varchar(80).notNull(), + secret: text().private().notNull(), + nickname: text().default("anonymous"), + created_at: timestamp().defaultNow().notNull(), + }); + const posts = table("posts", { + id: id(), + user_slug: fk(() => users.slug).notNull(), + title: text().notNull(), + score: integer(), + total: bigint().notNull(), + }); + const ledger = table("ledger", { seq: bigid(), note: text().notNull() }); + const events = table("events", { message: text().notNull() }); + const stamps = table("stamps", { id: id(), at: timestamp().defaultNow().notNull() }); + return { users, posts, ledger, events, stamps }; + }); +`; + +const positive = ` +import type { DatabaseRegistry } from "@databricks/appkit"; +import { + type DatabaseExports, + defineOperation, + definePolicy, + type OperationContext, + type OperationWireInput, + type OperationWireOutput, + type PolicyContext, +} from "@databricks/appkit/beta"; +import { z } from "zod"; + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Expect = T; +type IsAny = 0 extends 1 & T ? true : false; +type Same = IsAny extends true + ? false + : [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +type UserRow = DatabaseRegistry["users"]["publicRow"]; +type PostRow = DatabaseRegistry["posts"]["publicRow"]; + +export type Registry = Expect< + Equal +>; + +export const getPost = defineOperation({ + name: "posts.get", + entity: "posts", + method: "GET", + authorize: ({ actor }) => actor.id.length > 0, + execute: ({ input, db, resource, actor, appkit }) => { + type _Input = Expect>; + type _Resource = Expect>; + type _Actor = Expect>; + type _Database = Expect>; + type _Plugins = Expect< + Equal< + Exclude, + "agents" | "aiSearch" | "analytics" | "files" | "genie" | "jobs" | "lakebase" | "server" | "serving" + > + >; + type _Optional = Expect>; + type _Typed = Expect["query"]>, false>>; + return db.posts.find(input.id); + }, +}); +export type GetPost = [ + Expect>, + Expect>, + Expect>, + Expect>, + Expect, { id: number }>>, + Expect< + Equal< + OperationWireOutput, + { id: number; user_slug: string; title: string; score: number | null; total: string } + > + >, +]; + +export const createUser = defineOperation({ + name: "users.create", + entity: "users", + method: "POST", + authorize: async ({ input, actor }) => input.slug === actor.id, + execute: async ({ input, db, actor }) => { + type _Input = Expect< + Equal + >; + return db.users.create({ ...input, secret: actor.id }); + }, +}); +export type CreateUser = [ + Expect< + Equal< + OperationWireInput, + { slug: string; name: string; nickname?: string | null; created_at?: string } + > + >, + Expect, UserRow>>, +]; + +export const logEvent = defineOperation({ + name: "events.log", + entity: "events", + method: "POST", + authorize: () => true, + execute: ({ input }) => input, +}); + +export const patchPost = defineOperation({ + name: "posts.patch", + entity: "posts", + method: "PATCH", + authorize: () => true, + execute: ({ input, db }) => { + type _Input = Expect< + Equal< + typeof input, + { id: number; changes: { user_slug?: string; title?: string; score?: number | null; total?: bigint } } + > + >; + return db.posts.update(input.id, input.changes); + }, +}); +export type PatchPost = Expect< + Equal< + OperationWireInput, + { + id: number; + changes: { user_slug?: string; title?: string; score?: number | null; total?: string | number }; + } + > +>; + +export const replaceUser = defineOperation({ + name: "users.replace", + entity: "users", + method: "PUT", + authorize: () => true, + execute: async ({ input, db }) => { + type _Input = Expect< + Equal + >; + return db.users.update(input.id, input.values); + }, +}); + +export const deletePost = defineOperation({ + name: "posts.delete", + entity: "posts", + method: "DELETE", + authorize: () => true, + execute: ({ input, db }) => db.posts.delete(input.id), +}); +export type DeletePost = [ + Expect, { id: number }>>, + Expect, void>>, +]; + +export const getEntry = defineOperation({ + name: "ledger.get", + entity: "ledger", + method: "GET", + authorize: () => true, + execute: ({ input, db }) => { + type _Input = Expect>; + return db.ledger.find(input.id); + }, +}); +export type GetEntry = [ + Expect, { id: string | number }>>, + Expect, { seq: string; note: string }>>, +]; + +export const latestEvent = defineOperation({ + name: "events.latest", + entity: "events", + method: "GET", + input: z.object({ message: z.string() }), + authorize: () => true, + execute: ({ input, db }) => db.events.where({ message: input.message }).first(), +}); +export type LatestEvent = [ + Expect, { message: string }>>, + Expect, { message: string }>>, +]; + +export const draftPost = defineOperation({ + name: "posts.draft", + entity: "posts", + method: "POST", + input: z.object({ title: z.string().trim(), pinned: z.boolean().default(false) }), + output: z.object({ id: z.number(), total: z.bigint().transform(String) }), + authorize: ({ input }) => input.title.length > 0, + execute: async ({ input, db, actor }) => { + type _Input = Expect>; + const post = await db.posts.create({ user_slug: actor.id, title: input.title, total: 0n }); + return { id: post.id, total: post.total }; + }, +}); +export type DraftPost = [ + Expect, { title: string; pinned?: boolean | undefined }>>, + Expect, { id: number; total: string }>>, +]; + +export const postSummary = defineOperation({ + name: "posts.summary", + entity: "posts", + method: "GET", + output: z.object({ id: z.number(), title: z.string() }), + authorize: () => true, + execute: async ({ input, db }) => { + const post = await db.posts.find(input.id); + return post && { id: post.id, title: post.title }; + }, +}); + +export const optionalPost = defineOperation({ + name: "posts.optional", + entity: "posts", + method: "GET", + output: z.object({ id: z.number() }).nullable(), + authorize: () => true, + execute: async ({ input, db }) => { + const post = await db.posts.find(input.id); + return post && { id: post.id }; + }, +}); +export type OptionalPost = Expect< + Equal, { id: number } | null> +>; + +export const hiddenPost = definePolicy({ + resource: ({ input, db }: PolicyContext<{ id: number }>) => db.posts.find(input.id), + allow: ({ resource }) => + resource.score === null ? { deny: "not-found", reason: "hidden" } : true, +}); + +export const readVisiblePost = defineOperation({ + name: "posts.visible", + entity: "posts", + method: "GET", + authorize: hiddenPost, + execute: ({ resource }) => { + type _Resource = Expect>; + type _Title = Expect>; + type _Total = Expect>; + return resource; + }, +}); + +export const stampInput = z.object({ id: z.number(), at: z.string() }); +export const recordStamp = defineOperation({ + name: "stamps.record", + entity: "stamps", + method: "PATCH", + input: stampInput, + authorize: ({ input }) => input.at.length > 0, + execute: () => null, +}); + +export const signedIn = definePolicy({ allow: ({ actor }) => actor.id !== "" }); + +export const authorOnly = definePolicy({ + resource: ({ input, db }: PolicyContext<{ id: number }>) => db.posts.find(input.id), + allow: ({ resource, actor }) => resource.user_slug === actor.id, +}); + +export const renamePost = defineOperation({ + name: "posts.rename", + entity: "posts", + method: "PATCH", + authorize: authorOnly, + execute: ({ input, db, resource }) => { + const _show: [typeof resource, PostRow] = null as never; + return db.posts.update(resource.id, input.changes); + }, +}); + +export const retitleOwnPost = defineOperation({ + name: "posts.retitleOwn", + entity: "posts", + method: "PATCH", + input: z.object({ id: z.number(), title: z.string().trim() }), + authorize: authorOnly, + execute: ({ input, db, resource }) => { + type _Input = Expect>; + return db.posts.update(resource.id, { title: input.title }); + }, +}); +export type RetitleOwnPost = Expect< + Equal, { id: number; title: string }> +>; + +export const readOwnPost = defineOperation({ + name: "posts.readOwn", + entity: "posts", + method: "GET", + authorize: authorOnly, + execute: ({ resource }) => resource, +}); + +export const removeOwnPost = defineOperation({ + name: "posts.removeOwn", + entity: "posts", + method: "DELETE", + authorize: authorOnly, + execute: ({ resource }) => resource.id > 0, +}); + +export const signedInCreate = defineOperation({ + name: "users.signedInCreate", + entity: "users", + method: "POST", + authorize: signedIn, + execute: ({ input, db, resource }) => { + type _Resource = Expect>; + return db.users.create({ ...input, secret: "s" }); + }, +}); + +export const signedInRead = defineOperation({ + name: "ledger.signedIn", + entity: "ledger", + method: "GET", + authorize: signedIn, + execute: ({ db, input }) => db.ledger.find(input.id), +}); + +export const inlineLookup = defineOperation({ + name: "posts.replaceScored", + entity: "posts", + method: "PUT", + authorize: definePolicy({ + resource: ({ input, db }: PolicyContext<{ id: number }>) => db.posts.find(input.id), + allow: ({ resource }) => resource.score !== null, + }), + execute: ({ db, resource, input }) => db.posts.update(resource.id, input.values), +}); + +declare const context: OperationContext<{ id: number }, PostRow>; +export const direct = authorOnly.allow(context); +export const directLookup = authorOnly.resource(context); +export const denied = authorOnly.allow({ + ...context, + actor: { ...context.actor, id: "other" }, +}); +type Decision = true | false | { deny: "forbidden" | "not-found"; reason?: string }; +export type Direct = [ + Expect extends Decision ? true : false, true>>, + Expect extends Decision ? true : false, true>>, + Expect, PostRow | null | undefined>>, +]; +`; + +// Each probe names the diagnostic its next line must raise once the directive is gone. +const negative = ` +import { + defineOperation, + definePolicy, + type OperationWireInput, + type OperationWireOutput, + type PolicyContext, +} from "@databricks/appkit/beta"; +import { z } from "zod"; +// @ts-expect-error TS2305 operations run only through the HTTP runtime +import { runOperation } from "@databricks/appkit/beta"; +// @ts-expect-error TS2305 candidate detection is internal +import { isOperationCandidate } from "@databricks/appkit/beta"; +// @ts-expect-error TS2305 operation definitions are beta +import { defineOperation as stable } from "@databricks/appkit"; +// @ts-expect-error TS2769 name is required +defineOperation({ + entity: "posts", + method: "GET", + authorize: () => true, + execute: () => null, +}); +// @ts-expect-error TS2769 authorize is required +defineOperation({ + name: "posts.missingAuthorize", + entity: "posts", + method: "GET", + execute: ({ input, db }) => db.posts.find(input.id), +}); +defineOperation({ + name: "missing.get", + // @ts-expect-error TS2769 entities are generated table names + entity: "missing", + method: "GET", + authorize: () => true, + execute: () => null, +}); +defineOperation({ + name: "posts.head", + entity: "posts", + // @ts-expect-error TS2769 methods are the five operation verbs + method: "HEAD", + authorize: () => true, + execute: () => null, +}); +defineOperation({ + // @ts-expect-error TS2769 an operation name is a string literal + name: 1, + entity: "posts", + method: "GET", + authorize: () => true, + execute: () => null, +}); +// @ts-expect-error TS2769 there is no separate action +defineOperation({ + name: "posts.get", + entity: "posts", + method: "GET", + authorize: () => true, + execute: () => null, + action: "read", +}); +defineOperation({ + name: "users.private", + entity: "users", + method: "POST", + authorize: () => true, + // @ts-expect-error TS2339 private columns are not part of the default input + execute: ({ input, db }) => db.users.create({ ...input, secret: input.secret }), +}); +defineOperation({ + name: "posts.partial", + entity: "posts", + method: "GET", + authorize: () => true, + // @ts-expect-error TS2769 a default read answers the full public row + execute: () => ({ id: 1 }), +}); +defineOperation({ + name: "posts.nullCreate", + entity: "posts", + method: "POST", + authorize: () => true, + // @ts-expect-error TS2769 a create answers a row, never a missing record + execute: () => null, +}); +defineOperation({ + name: "posts.badDelete", + entity: "posts", + method: "DELETE", + authorize: () => true, + // @ts-expect-error TS2769 a delete answers whether the record existed + execute: () => "deleted", +}); +defineOperation({ + name: "posts.badSummary", + entity: "posts", + method: "GET", + output: z.object({ id: z.number(), title: z.string() }), + authorize: () => true, + // @ts-expect-error TS2769 a custom output constrains the executor to its input type + execute: () => ({ id: "1", title: "x" }), +}); +defineOperation({ + name: "posts.truthyNumber", + entity: "posts", + method: "GET", + // @ts-expect-error TS2769 a decision is a boolean, not a truthy number + authorize: () => 1, + execute: () => null, +}); +defineOperation({ + name: "posts.truthyString", + entity: "posts", + method: "GET", + // @ts-expect-error TS2769 a decision is a boolean, not a truthy string + authorize: async ({ actor }) => actor.email, + execute: () => null, +}); +defineOperation({ + name: "posts.badDenial", + entity: "posts", + method: "GET", + // @ts-expect-error TS2769 a denial names forbidden or not-found + authorize: () => ({ deny: "hidden", reason: "no" }), + execute: () => null, +}); +definePolicy({ + resource: ({ input }: PolicyContext<{ id: number }>) => input.id, + // @ts-expect-error TS2769 a resource denial is the same decision + allow: () => ({ deny: "missing" }), +}); +defineOperation({ + name: "posts.email", + entity: "posts", + method: "GET", + // @ts-expect-error TS18048 the actor email is optional + authorize: ({ actor }) => actor.email.endsWith("@example.com"), + execute: () => null, +}); +defineOperation({ + name: "posts.noResource", + entity: "posts", + method: "GET", + authorize: () => true, + // @ts-expect-error TS18048 a decision loads no resource + execute: ({ resource }) => resource.title, +}); +definePolicy({ + // @ts-expect-error TS18048 an allow-only policy loads no resource + allow: ({ resource }) => resource.title.length > 0, +}); +definePolicy({ + resource: ({ input }: PolicyContext<{ id: number }>) => String(input.id), + // @ts-expect-error TS2769 allow receives the lookup result + allow: ({ resource }: { resource: number }) => resource > 0, +}); +definePolicy({ + resource: () => Math.random() > 0.5 ? "row" : null, + // @ts-expect-error TS2551 a nullable lookup is non-null after admission + allow: ({ resource }) => resource.length > 0 && resource.toFixed() === "1", +}); +const authorOnly = definePolicy({ + resource: ({ input, db }: PolicyContext<{ id: number }>) => db.posts.find(input.id), + allow: ({ resource, actor }) => resource.user_slug === actor.id, +}); +defineOperation({ + name: "posts.lookupCreate", + entity: "posts", + method: "POST", + // @ts-expect-error TS2769 a default create carries no id for the lookup + authorize: authorOnly, + execute: ({ input, db }) => db.posts.create(input), +}); +defineOperation({ + name: "users.wrongPolicy", + entity: "users", + method: "PATCH", + // @ts-expect-error TS2769 a users id is a string slug + authorize: authorOnly, + execute: () => null, +}); +defineOperation({ + name: "posts.secret", + entity: "posts", + method: "PATCH", + authorize: authorOnly, + // @ts-expect-error TS2339 the resource is the public row + execute: ({ resource }) => resource.secret, +}); +defineOperation({ + name: "events.get", + entity: "events", + method: "GET", + authorize: () => true, + execute: () => null, + // @ts-expect-error TS2769 a keyless entity has no default id input + input: undefined, +}); +// @ts-expect-error TS2769 an empty public update has no default input +defineOperation({ + name: "stamps.patch", + entity: "stamps", + method: "PATCH", + authorize: () => true, + execute: () => null, +}); +// @ts-expect-error TS2769 an empty public replacement has no default input +defineOperation({ + name: "stamps.put", + entity: "stamps", + method: "PUT", + authorize: () => true, + execute: () => null, +}); +defineOperation({ + name: "events.delete", + entity: "events", + method: "DELETE", + // @ts-expect-error TS2769 an explicit input must be a schema + input: undefined, + authorize: () => true, + execute: () => true, +}); +defineOperation({ + name: "posts.plugins", + entity: "posts", + method: "GET", + authorize: () => true, + execute: ({ appkit }) => { + // @ts-expect-error TS18048 SDK plugins may be absent from the app + appkit.analytics.query("q", {}); + // @ts-expect-error TS2339 custom plugins are not typed on the operation handle + appkit.custom; + return null; + }, +}); +const replaceUser = defineOperation({ + name: "users.replace", + entity: "users", + method: "PUT", + authorize: () => true, + execute: ({ input, db }) => db.users.update(input.id, input.values), +}); +// @ts-expect-error TS2741 a replacement carries every mutable public field +export const partial: OperationWireInput = { id: "ada", values: { name: "Ada" } }; +const getEntry = defineOperation({ + name: "ledger.get", + entity: "ledger", + method: "GET", + authorize: () => true, + execute: ({ input, db }) => db.ledger.find(input.id), +}); +// @ts-expect-error TS2322 JSON carries a bigint id as a string or safe integer +export const bigId: OperationWireInput = { id: 1n }; +export const safeBigId: OperationWireInput = { id: 1 }; +export const decimalBigId: OperationWireInput = { id: "1" }; +// @ts-expect-error TS2322 JSON answers a bigint as a decimal string +export const bigSeq: OperationWireOutput["seq"] = 1n; +const patchPost = defineOperation({ + name: "posts.patch", + entity: "posts", + method: "PATCH", + authorize: () => true, + execute: ({ input, db }) => db.posts.update(input.id, input.changes), +}); +// @ts-expect-error TS2322 a nested bigint change travels as a string or safe integer +export const bigChange: OperationWireInput = { id: 1, changes: { total: 1n } }; +export const decimalChange: OperationWireInput = { id: 1, changes: { total: "1" } }; +const getPost = defineOperation({ + name: "posts.get", + entity: "posts", + method: "GET", + authorize: () => true, + execute: ({ input, db }) => db.posts.find(input.id), +}); +// @ts-expect-error TS2322 a successful read answers a row, never null +export const missing: OperationWireOutput = null; +const nullableSummary = defineOperation({ + name: "posts.nullableSummary", + entity: "posts", + method: "GET", + output: z.object({ id: z.number(), title: z.string() }).nullable(), + authorize: () => true, + execute: () => null, +}); +export const absentSummary: OperationWireOutput = null; +const requiredSummary = defineOperation({ + name: "posts.requiredSummary", + entity: "posts", + method: "GET", + output: z.object({ id: z.number() }), + authorize: () => true, + execute: () => ({ id: 1 }), +}); +// @ts-expect-error TS2322 null is a successful body only when the output accepts it +export const rejectedSummary: OperationWireOutput = null; +const deletePost = defineOperation({ + name: "posts.delete", + entity: "posts", + method: "DELETE", + authorize: () => true, + execute: ({ input, db }) => db.posts.delete(input.id), +}); +// @ts-expect-error TS2322 a delete answers an empty body +export const deleted: OperationWireOutput = true; +const createUser = defineOperation({ + name: "users.create", + entity: "users", + method: "POST", + authorize: () => true, + execute: ({ input, db }) => db.users.create({ ...input, secret: "s" }), +}); +// @ts-expect-error TS2353 private columns are not wire inputs +export const leaked: OperationWireInput = { slug: "a", name: "A", secret: "s" }; +// @ts-expect-error TS2339 private columns are not wire outputs +export type Leaked = OperationWireOutput["secret"]; +// @ts-expect-error TS2540 descriptors are immutable +getPost.execute = () => null; +// @ts-expect-error TS2540 policies are immutable +authorOnly.allow = () => true;`; + +const DIRECTIVE = /\/\/ @ts-expect-error (TS\d+) /g; + +let root = ""; +let declaration = ""; +const parsed = new Map(); + +beforeAll(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "appkit-operations-")); + const schemaFile = path.join(root, "schema.ts"); + declaration = path.join(root, "database.d.ts"); + await fs.writeFile(schemaFile, schemaSource); + await generateDatabaseTypes({ schemaFile, outFile: declaration }); +}, 30_000); + +afterAll(() => fs.rm(root, { recursive: true, force: true })); + +/** Every diagnostic in the generated declaration and the consumers, as `file:line TScode`. */ +function typecheck(consumers: Record, built = false): string[] { + const entryRoot = built ? path.join(appkitRoot, "dist") : sourceRoot; + const extension = built ? ".d.ts" : ".ts"; + const nodeTypes = path.dirname( + path.dirname( + createRequire(import.meta.url).resolve("@types/node/package.json"), + ), + ); + const { options, errors } = ts.parseJsonConfigFileContent( + { + compilerOptions: { + strict: true, + noEmit: true, + target: "ES2022", + module: "ESNext", + moduleResolution: "Bundler", + esModuleInterop: true, + resolveJsonModule: true, + skipLibCheck: false, + types: ["node"], + typeRoots: [nodeTypes], + lib: ["ES2022"], + baseUrl: root, + paths: { + "@databricks/appkit": [path.join(entryRoot, `index${extension}`)], + "@databricks/appkit/beta": [path.join(entryRoot, `beta${extension}`)], + shared: [path.join(sharedRoot, "index.ts")], + "shared/*": [path.join(sharedRoot, "*")], + "@databricks/lakebase": [ + path.resolve(appkitRoot, "../lakebase/src/index.ts"), + ], + zod: [zodRoot], + }, + }, + }, + ts.sys, + root, + ); + expect(errors).toEqual([]); + const virtual = new Map( + Object.entries(consumers).map(([name, text]) => [ + path.join(root, name), + text, + ]), + ); + const host = ts.createCompilerHost(options); + const readFile = host.readFile.bind(host); + const getSourceFile = host.getSourceFile.bind(host); + host.readFile = (file) => virtual.get(file) ?? readFile(file); + host.getSourceFile = (file, language, ...rest) => { + const text = virtual.get(file); + if (text !== undefined) return ts.createSourceFile(file, text, language); + if (!parsed.has(file)) { + const source = getSourceFile(file, language, ...rest); + if (source) parsed.set(file, source); + } + return parsed.get(file); + }; + const files = [declaration, ...virtual.keys()]; + const program = ts.createProgram(files, options, host); + const diagnostics = built + ? ts.getPreEmitDiagnostics(program) + : [ + ...program.getOptionsDiagnostics(), + ...program.getGlobalDiagnostics(), + ...files.flatMap((file) => { + const source = program.getSourceFile(file); + if (!source) throw new Error(`Missing consumer source ${file}`); + return [ + ...program.getSyntacticDiagnostics(source), + ...program.getSemanticDiagnostics(source), + ]; + }), + ]; + return diagnostics.map((diagnostic) => { + const line = diagnostic.file + ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start ?? 0) + .line + 1 + : 0; + const file = diagnostic.file ? path.basename(diagnostic.file.fileName) : ""; + return `${file}:${line} TS${diagnostic.code} ${ts.flattenDiagnosticMessageText(diagnostic.messageText, " ")}`; + }); +} + +const byLine = (a: string, b: string) => + Number(a.split(/[: ]/)[1]) - Number(b.split(/[: ]/)[1]); + +/** The `file:line TScode` each directive promises for the line after it. */ +function expectedProbes(source: string): string[] { + return source.split("\n").flatMap((text, index) => { + const match = /\/\/ @ts-expect-error (TS\d+) /.exec(text); + return match ? [`negative.ts:${index + 2} ${match[1]}`] : []; + }); +} + +const globalContract = ` +import type { + DatabaseOperationContract, + DatabricksAppKitDatabaseOperations, +} from "@databricks/appkit/beta"; + +declare global { + interface DatabricksAppKitDatabaseOperations { + "posts.get": { + method: "GET"; + input: { id: number }; + output: { id: number; title: string; total: string }; + }; + } +} + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Expect = T; + +export type GeneratedOperation = [ + Expect>, + Expect>, + Expect< + Equal< + DatabricksAppKitDatabaseOperations["posts.get"]["output"], + { id: number; title: string; total: string } + > + >, + Expect< + Equal< + Pick, + { method: "GET"; input: { id: number }; output: { id: number; title: string; total: string } } + > + >, +]; +`; + +describe("operation definitions through the public entries", () => { + test("infer entities, inputs, resources, and wire bodies from the generated registry", () => { + expect( + typecheck({ "positive.ts": positive, "global.ts": globalContract }), + ).toEqual([]); + }, 60_000); + + // Unit tests run before the build in CI; the build job runs this file after it. + test.skipIf(!existsSync(path.join(appkitRoot, "dist/beta.d.ts")))( + "consume built public declarations without suppressing library diagnostics", + () => { + expect( + typecheck( + { "positive.ts": positive, "global.ts": globalContract }, + true, + ), + ).toEqual([]); + }, + 60_000, + ); + + test("reject every probe, each with its own diagnostic", () => { + expect( + typecheck({ "positive.ts": positive, "negative.ts": negative }), + ).toEqual([]); + + const raw = typecheck({ + "negative.ts": negative.replace(DIRECTIVE, "// probe $1 "), + }); + expect( + raw + .map((finding) => finding.split(" ").slice(0, 2).join(" ")) + .sort(byLine), + ).toEqual(expectedProbes(negative)); + }, 60_000); +}); diff --git a/packages/appkit/src/workspace-client/index.ts b/packages/appkit/src/workspace-client/index.ts index 40a9d7e1c..79e4f6a10 100644 --- a/packages/appkit/src/workspace-client/index.ts +++ b/packages/appkit/src/workspace-client/index.ts @@ -1,10 +1,32 @@ -/** - * AppKit workspace-client wrapper: the single entry point every AppKit module - * uses to reach a Databricks SDK client. The modular `@databricks/sdk-*` - * packages are isolated to `shared/workspace-client`. - */ - +/** Databricks SDK access stays behind the shared workspace-client wrapper. */ export { ApiError, createWorkspaceClient, isAuthConfigError } from "shared"; -// Forwards every wrapper type: the facade, its options, and the modular SDK -// client + model types. -export type * from "shared/workspace-client"; +export type { + BaseRun, + CancelRunRequest, + ClientOptions, + EndpointState, + ExecuteStatementRequest, + ExternalLink, + FilesDirectoryEntry, + FilesDownloadResponse, + GenieMessage, + GetJobRequest, + GetJobResponse, + GetRunOutputRequest, + GetRunOutputResponse, + GetRunRequest, + GetRunResponse, + ListRunsRequest, + QueryVectorIndexRequest, + QueryVectorIndexResponse, + ResultData, + RunNowRequest, + RunNowResponse, + StatementParameter, + StatementResponse, + StatementStatus, + SubmitRunRequest, + SubmitRunResponse, + WorkspaceClient, + WorkspaceClientOptions, +} from "shared/workspace-client"; diff --git a/packages/shared/src/database/index.ts b/packages/shared/src/database/index.ts index a14429b93..b163d1ea4 100644 --- a/packages/shared/src/database/index.ts +++ b/packages/shared/src/database/index.ts @@ -1,3 +1,4 @@ export * from "./api-types"; export * from "./errors"; +export * from "./operations"; export * from "./query-codec"; diff --git a/packages/shared/src/database/operations.ts b/packages/shared/src/database/operations.ts new file mode 100644 index 000000000..3e262cbbd --- /dev/null +++ b/packages/shared/src/database/operations.ts @@ -0,0 +1,31 @@ +/** HTTP methods a named database operation can answer. */ +export type DatabaseOperationMethod = + | "GET" + | "POST" + | "PATCH" + | "PUT" + | "DELETE"; + +/** + * The browser-safe contract of one generated operation. `input` and `output` + * are JSON types. A generated `database.d.ts` fills this interface by name. + */ +export interface DatabaseOperationContract { + readonly method: DatabaseOperationMethod; + readonly input: unknown; + readonly output: unknown; +} + +/** + * Names the UI resolves without importing a server module. Empty until + * generation adds one entry per operation name. The declaration is global so + * a generated `database.d.ts` can augment it without importing this module. + */ +declare global { + // oxlint-disable-next-line typescript/no-empty-object-type -- generated augmentation target. + interface DatabricksAppKitDatabaseOperations {} +} + +/** The same global registry, available as a module type for package exports. */ +type GlobalDatabaseOperations = globalThis.DatabricksAppKitDatabaseOperations; +export type { GlobalDatabaseOperations as DatabricksAppKitDatabaseOperations };