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 };