diff --git a/.gitignore b/.gitignore index 5d417368a..66e336f9d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ coverage .turbo .databricks +internal diff --git a/docs/docs/api/appkit/Class.Plugin.md b/docs/docs/api/appkit/Class.Plugin.md index 06e558dcf..c5ecc1407 100644 --- a/docs/docs/api/appkit/Class.Plugin.md +++ b/docs/docs/api/appkit/Class.Plugin.md @@ -200,12 +200,12 @@ Plugin initialization phase. ### abortActiveOperations() ```ts -abortActiveOperations(): void; +abortActiveOperations(): void | Promise; ``` #### Returns -`void` +`void` \| `Promise`\<`void`\> #### Implementation of diff --git a/docs/docs/api/appkit/Function.bigint.md b/docs/docs/api/appkit/Function.bigint.md new file mode 100644 index 000000000..2384908fc --- /dev/null +++ b/docs/docs/api/appkit/Function.bigint.md @@ -0,0 +1,13 @@ +# Function: bigint() + +```ts +function bigint(): AppKitColumnChain; +``` + +Create a bigint column. + +## Returns + +[`AppKitColumnChain`](Interface.AppKitColumnChain.md) + +The wrapped column chain. diff --git a/docs/docs/api/appkit/Function.boolean.md b/docs/docs/api/appkit/Function.boolean.md new file mode 100644 index 000000000..54e940bc8 --- /dev/null +++ b/docs/docs/api/appkit/Function.boolean.md @@ -0,0 +1,13 @@ +# Function: boolean() + +```ts +function boolean(): AppKitColumnChain; +``` + +Create a boolean column. + +## Returns + +[`AppKitColumnChain`](Interface.AppKitColumnChain.md) + +The wrapped column chain. diff --git a/docs/docs/api/appkit/Function.defineSchema.md b/docs/docs/api/appkit/Function.defineSchema.md new file mode 100644 index 000000000..db43a216f --- /dev/null +++ b/docs/docs/api/appkit/Function.defineSchema.md @@ -0,0 +1,26 @@ +# Function: defineSchema() + +```ts +function defineSchema(build: (ctx: SchemaBuilderContext) => T, options: DefineSchemaOptions): Schema; +``` + +Define a schema. This is used to build the schema for the database. + +## Type Parameters + +| Type Parameter | +| ------ | +| `T` *extends* `Record`\<`string`, [`AppKitTable`](Interface.AppKitTable.md)\<`string`\>\> | + +## Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `build` | (`ctx`: [`SchemaBuilderContext`](Interface.SchemaBuilderContext.md)) => `T` | A function that builds the schema. | +| `options` | [`DefineSchemaOptions`](Interface.DefineSchemaOptions.md) | Options for defining the schema. | + +## Returns + +[`Schema`](TypeAlias.Schema.md)\<`T`\> + +The defined schema. diff --git a/docs/docs/api/appkit/Function.enumColumn.md b/docs/docs/api/appkit/Function.enumColumn.md new file mode 100644 index 000000000..4aef57b91 --- /dev/null +++ b/docs/docs/api/appkit/Function.enumColumn.md @@ -0,0 +1,20 @@ +# Function: enumColumn() + +```ts +function enumColumn(name: string, values: readonly string[]): AppKitColumnChain; +``` + +Create an enum column. + +## Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `name` | `string` | The name of the enum. | +| `values` | readonly `string`[] | The values of the enum. | + +## Returns + +[`AppKitColumnChain`](Interface.AppKitColumnChain.md) + +The wrapped column chain. diff --git a/docs/docs/api/appkit/Function.fk.md b/docs/docs/api/appkit/Function.fk.md new file mode 100644 index 000000000..5a7f7fbe0 --- /dev/null +++ b/docs/docs/api/appkit/Function.fk.md @@ -0,0 +1,27 @@ +# Function: fk() + +```ts +function fk(target: AppKitColumn): FkColumnChain; +``` + +Create a foreign key column. The reference target is captured live and +resolved at `buildTable()` time, so forward references (e.g. `fk(other.id)` +declared before `table("other", ...)`) work. + +The FK column type is currently fixed to `integer`. If the target is a +`bigid()` (`bigserial`) or `uuid()` PK, declare the FK column with the +matching type explicitly until per-target type inference is added. + +## Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `target` | [`AppKitColumn`](Interface.AppKitColumn.md) | The target column to reference. | + +## Returns + +[`FkColumnChain`](Interface.FkColumnChain.md) + +A FK column chain. `onDelete`/`onUpdate` return the FK chain so +order does not matter; chain methods (`.notNull()`, `.unique()`, etc.) also +return the FK chain so `.notNull().onDelete("cascade")` typechecks. diff --git a/docs/docs/api/appkit/Function.id.md b/docs/docs/api/appkit/Function.id.md new file mode 100644 index 000000000..ac0f85c51 --- /dev/null +++ b/docs/docs/api/appkit/Function.id.md @@ -0,0 +1,13 @@ +# Function: id() + +```ts +function id(): AppKitColumnChain; +``` + +Create a primary key column with a serial type. + +## Returns + +[`AppKitColumnChain`](Interface.AppKitColumnChain.md) + +The wrapped column chain. diff --git a/docs/docs/api/appkit/Function.integer.md b/docs/docs/api/appkit/Function.integer.md new file mode 100644 index 000000000..8a8f838a2 --- /dev/null +++ b/docs/docs/api/appkit/Function.integer.md @@ -0,0 +1,13 @@ +# Function: integer() + +```ts +function integer(): AppKitColumnChain; +``` + +Create an integer column. + +## Returns + +[`AppKitColumnChain`](Interface.AppKitColumnChain.md) + +The wrapped column chain. diff --git a/docs/docs/api/appkit/Function.isPrivateColumn.md b/docs/docs/api/appkit/Function.isPrivateColumn.md new file mode 100644 index 000000000..5182fa88e --- /dev/null +++ b/docs/docs/api/appkit/Function.isPrivateColumn.md @@ -0,0 +1,18 @@ +# Function: isPrivateColumn() + +```ts +function isPrivateColumn(table: AppKitTable, columnName: string): boolean; +``` + +Returns true if `columnName` is marked `.private()` on `table`. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `table` | [`AppKitTable`](Interface.AppKitTable.md) | +| `columnName` | `string` | + +## Returns + +`boolean` diff --git a/docs/docs/api/appkit/Function.jsonb.md b/docs/docs/api/appkit/Function.jsonb.md new file mode 100644 index 000000000..be89ad9ed --- /dev/null +++ b/docs/docs/api/appkit/Function.jsonb.md @@ -0,0 +1,13 @@ +# Function: jsonb() + +```ts +function jsonb(): AppKitColumnChain; +``` + +Create a jsonb column. + +## Returns + +[`AppKitColumnChain`](Interface.AppKitColumnChain.md) + +The wrapped column chain. diff --git a/docs/docs/api/appkit/Function.nonPrivateColumnNames.md b/docs/docs/api/appkit/Function.nonPrivateColumnNames.md new file mode 100644 index 000000000..b69d038ab --- /dev/null +++ b/docs/docs/api/appkit/Function.nonPrivateColumnNames.md @@ -0,0 +1,17 @@ +# Function: nonPrivateColumnNames() + +```ts +function nonPrivateColumnNames(table: AppKitTable): string[]; +``` + +Returns the column names of `table` that are NOT marked `.private()`. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `table` | [`AppKitTable`](Interface.AppKitTable.md) | + +## Returns + +`string`[] diff --git a/docs/docs/api/appkit/Function.privateColumnNames.md b/docs/docs/api/appkit/Function.privateColumnNames.md new file mode 100644 index 000000000..962d70e64 --- /dev/null +++ b/docs/docs/api/appkit/Function.privateColumnNames.md @@ -0,0 +1,17 @@ +# Function: privateColumnNames() + +```ts +function privateColumnNames(table: AppKitTable): string[]; +``` + +Returns the column names of `table` that ARE marked `.private()`. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `table` | [`AppKitTable`](Interface.AppKitTable.md) | + +## Returns + +`string`[] diff --git a/docs/docs/api/appkit/Function.text.md b/docs/docs/api/appkit/Function.text.md new file mode 100644 index 000000000..d9b6f12b1 --- /dev/null +++ b/docs/docs/api/appkit/Function.text.md @@ -0,0 +1,13 @@ +# Function: text() + +```ts +function text(): AppKitColumnChain; +``` + +Create a text column. + +## Returns + +[`AppKitColumnChain`](Interface.AppKitColumnChain.md) + +The wrapped column chain. diff --git a/docs/docs/api/appkit/Function.timestamp.md b/docs/docs/api/appkit/Function.timestamp.md new file mode 100644 index 000000000..af3e26dbd --- /dev/null +++ b/docs/docs/api/appkit/Function.timestamp.md @@ -0,0 +1,13 @@ +# Function: timestamp() + +```ts +function timestamp(): AppKitColumnChain; +``` + +Create a timestamp column. + +## Returns + +[`AppKitColumnChain`](Interface.AppKitColumnChain.md) + +The wrapped column chain. diff --git a/docs/docs/api/appkit/Function.uuid.md b/docs/docs/api/appkit/Function.uuid.md new file mode 100644 index 000000000..9bc47e4a6 --- /dev/null +++ b/docs/docs/api/appkit/Function.uuid.md @@ -0,0 +1,13 @@ +# Function: uuid() + +```ts +function uuid(): AppKitColumnChain; +``` + +Create a uuid column. + +## Returns + +[`AppKitColumnChain`](Interface.AppKitColumnChain.md) + +The wrapped column chain. diff --git a/docs/docs/api/appkit/Function.varchar.md b/docs/docs/api/appkit/Function.varchar.md new file mode 100644 index 000000000..0667bde40 --- /dev/null +++ b/docs/docs/api/appkit/Function.varchar.md @@ -0,0 +1,19 @@ +# Function: varchar() + +```ts +function varchar(length: number): AppKitColumnChain; +``` + +Create a varchar column. + +## Parameters + +| Parameter | Type | Default value | Description | +| ------ | ------ | ------ | ------ | +| `length` | `number` | `255` | The length of the column. | + +## Returns + +[`AppKitColumnChain`](Interface.AppKitColumnChain.md) + +The wrapped column chain. diff --git a/docs/docs/api/appkit/Interface.AppKitColumn.md b/docs/docs/api/appkit/Interface.AppKitColumn.md new file mode 100644 index 000000000..8da0ec4b5 --- /dev/null +++ b/docs/docs/api/appkit/Interface.AppKitColumn.md @@ -0,0 +1,23 @@ +# Interface: AppKitColumn + +An AppKit column. This is returned by the column builder methods. + +## Extended by + +- [`AppKitColumnChain`](Interface.AppKitColumnChain.md) + +## Properties + +### $builder + +```ts +$builder: unknown; +``` + +*** + +### $meta + +```ts +$meta: ColumnMeta; +``` diff --git a/docs/docs/api/appkit/Interface.AppKitColumnChain.md b/docs/docs/api/appkit/Interface.AppKitColumnChain.md new file mode 100644 index 000000000..d6053ae1d --- /dev/null +++ b/docs/docs/api/appkit/Interface.AppKitColumnChain.md @@ -0,0 +1,131 @@ +# Interface: AppKitColumnChain + +A chain of AppKit column methods. This is returned by the column builder methods. + +## Extends + +- [`AppKitColumn`](Interface.AppKitColumn.md) + +## Extended by + +- [`FkColumnChain`](Interface.FkColumnChain.md) + +## Properties + +### $builder + +```ts +$builder: unknown; +``` + +#### Inherited from + +[`AppKitColumn`](Interface.AppKitColumn.md).[`$builder`](Interface.AppKitColumn.md#builder) + +*** + +### $meta + +```ts +$meta: ColumnMeta; +``` + +#### Inherited from + +[`AppKitColumn`](Interface.AppKitColumn.md).[`$meta`](Interface.AppKitColumn.md#meta) + +## Methods + +### default() + +```ts +default(value: T): AppKitColumnChain; +``` + +#### Type Parameters + +| Type Parameter | +| ------ | +| `T` | + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `value` | `T` | + +#### Returns + +`AppKitColumnChain` + +*** + +### defaultNow() + +```ts +defaultNow(): AppKitColumnChain; +``` + +#### Returns + +`AppKitColumnChain` + +*** + +### defaultRandom() + +```ts +defaultRandom(): AppKitColumnChain; +``` + +#### Returns + +`AppKitColumnChain` + +*** + +### notNull() + +```ts +notNull(): AppKitColumnChain; +``` + +#### Returns + +`AppKitColumnChain` + +*** + +### primaryKey() + +```ts +primaryKey(): AppKitColumnChain; +``` + +#### Returns + +`AppKitColumnChain` + +*** + +### private() + +```ts +private(): AppKitColumnChain; +``` + +#### Returns + +`AppKitColumnChain` + +*** + +### unique() + +```ts +unique(): AppKitColumnChain; +``` + +#### Returns + +`AppKitColumnChain` diff --git a/docs/docs/api/appkit/Interface.AppKitTable.md b/docs/docs/api/appkit/Interface.AppKitTable.md new file mode 100644 index 000000000..dd622a5e5 --- /dev/null +++ b/docs/docs/api/appkit/Interface.AppKitTable.md @@ -0,0 +1,66 @@ +# Interface: AppKitTable\ + +An AppKit table. This is returned by the table builder methods. +This is used to define the table schema and relationships. + +## Type Parameters + +| Type Parameter | Default type | +| ------ | ------ | +| `TName` *extends* `string` | `string` | + +## Properties + +### \[APPKIT\_TABLE\] + +```ts +readonly [APPKIT_TABLE]: true; +``` + +*** + +### $columns + +```ts +readonly $columns: Record; +``` + +*** + +### $drizzle + +```ts +readonly $drizzle: unknown; +``` + +*** + +### $insertSchema + +```ts +readonly $insertSchema: ZodType; +``` + +*** + +### $relations + +```ts +readonly $relations: Relation[]; +``` + +*** + +### $updateSchema + +```ts +readonly $updateSchema: ZodType; +``` + +*** + +### name + +```ts +readonly name: TName; +``` diff --git a/docs/docs/api/appkit/Interface.ColumnMeta.md b/docs/docs/api/appkit/Interface.ColumnMeta.md new file mode 100644 index 000000000..a2f618daa --- /dev/null +++ b/docs/docs/api/appkit/Interface.ColumnMeta.md @@ -0,0 +1,30 @@ +# Interface: ColumnMeta + +Metadata for an AppKit column. This is used to store the column metadata in the schema. + +## Properties + +### primaryKey? + +```ts +optional primaryKey: boolean; +``` + +*** + +### private? + +```ts +optional private: boolean; +``` + +Marks this column as private. +Excludes the column from the generated `$insertSchema` and `$updateSchema` (i.e. blocks writes through the validators). + +*** + +### serverGenerated? + +```ts +optional serverGenerated: boolean; +``` diff --git a/docs/docs/api/appkit/Interface.DefineSchemaOptions.md b/docs/docs/api/appkit/Interface.DefineSchemaOptions.md new file mode 100644 index 000000000..5b89f196f --- /dev/null +++ b/docs/docs/api/appkit/Interface.DefineSchemaOptions.md @@ -0,0 +1,11 @@ +# Interface: DefineSchemaOptions + +Options for defining a schema. + +## Properties + +### schemaName? + +```ts +optional schemaName: string; +``` diff --git a/docs/docs/api/appkit/Interface.FkColumnChain.md b/docs/docs/api/appkit/Interface.FkColumnChain.md new file mode 100644 index 000000000..df26cdec3 --- /dev/null +++ b/docs/docs/api/appkit/Interface.FkColumnChain.md @@ -0,0 +1,191 @@ +# Interface: FkColumnChain + +A foreign-key column chain. Returned by `fk(target)`. + +## Extends + +- [`AppKitColumnChain`](Interface.AppKitColumnChain.md) + +## Properties + +### $builder + +```ts +$builder: unknown; +``` + +#### Inherited from + +[`AppKitColumnChain`](Interface.AppKitColumnChain.md).[`$builder`](Interface.AppKitColumnChain.md#builder) + +*** + +### $meta + +```ts +$meta: ColumnMeta; +``` + +#### Inherited from + +[`AppKitColumnChain`](Interface.AppKitColumnChain.md).[`$meta`](Interface.AppKitColumnChain.md#meta) + +## Methods + +### default() + +```ts +default(value: T): FkColumnChain; +``` + +#### Type Parameters + +| Type Parameter | +| ------ | +| `T` | + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `value` | `T` | + +#### Returns + +`FkColumnChain` + +#### Overrides + +[`AppKitColumnChain`](Interface.AppKitColumnChain.md).[`default`](Interface.AppKitColumnChain.md#default) + +*** + +### defaultNow() + +```ts +defaultNow(): FkColumnChain; +``` + +#### Returns + +`FkColumnChain` + +#### Overrides + +[`AppKitColumnChain`](Interface.AppKitColumnChain.md).[`defaultNow`](Interface.AppKitColumnChain.md#defaultnow) + +*** + +### defaultRandom() + +```ts +defaultRandom(): FkColumnChain; +``` + +#### Returns + +`FkColumnChain` + +#### Overrides + +[`AppKitColumnChain`](Interface.AppKitColumnChain.md).[`defaultRandom`](Interface.AppKitColumnChain.md#defaultrandom) + +*** + +### notNull() + +```ts +notNull(): FkColumnChain; +``` + +#### Returns + +`FkColumnChain` + +#### Overrides + +[`AppKitColumnChain`](Interface.AppKitColumnChain.md).[`notNull`](Interface.AppKitColumnChain.md#notnull) + +*** + +### onDelete() + +```ts +onDelete(value: NonNullable<"cascade" | "set null" | "restrict" | "no action" | undefined>): FkColumnChain; +``` + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `value` | `NonNullable`\<`"cascade"` \| `"set null"` \| `"restrict"` \| `"no action"` \| `undefined`\> | + +#### Returns + +`FkColumnChain` + +*** + +### onUpdate() + +```ts +onUpdate(value: NonNullable<"cascade" | "set null" | "restrict" | "no action" | undefined>): FkColumnChain; +``` + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `value` | `NonNullable`\<`"cascade"` \| `"set null"` \| `"restrict"` \| `"no action"` \| `undefined`\> | + +#### Returns + +`FkColumnChain` + +*** + +### primaryKey() + +```ts +primaryKey(): FkColumnChain; +``` + +#### Returns + +`FkColumnChain` + +#### Overrides + +[`AppKitColumnChain`](Interface.AppKitColumnChain.md).[`primaryKey`](Interface.AppKitColumnChain.md#primarykey) + +*** + +### private() + +```ts +private(): FkColumnChain; +``` + +#### Returns + +`FkColumnChain` + +#### Overrides + +[`AppKitColumnChain`](Interface.AppKitColumnChain.md).[`private`](Interface.AppKitColumnChain.md#private) + +*** + +### unique() + +```ts +unique(): FkColumnChain; +``` + +#### Returns + +`FkColumnChain` + +#### Overrides + +[`AppKitColumnChain`](Interface.AppKitColumnChain.md).[`unique`](Interface.AppKitColumnChain.md#unique) diff --git a/docs/docs/api/appkit/Interface.Relation.md b/docs/docs/api/appkit/Interface.Relation.md new file mode 100644 index 000000000..9c0ef60ed --- /dev/null +++ b/docs/docs/api/appkit/Interface.Relation.md @@ -0,0 +1,43 @@ +# Interface: Relation + +A relation between two tables. This is used to define the foreign key relationships between tables. + +## Properties + +### fromColumn + +```ts +fromColumn: string; +``` + +*** + +### onDelete? + +```ts +optional onDelete: "cascade" | "set null" | "restrict" | "no action"; +``` + +*** + +### onUpdate? + +```ts +optional onUpdate: "cascade" | "set null" | "restrict" | "no action"; +``` + +*** + +### toColumn + +```ts +toColumn: string; +``` + +*** + +### toTable + +```ts +toTable: string; +``` diff --git a/docs/docs/api/appkit/Interface.SchemaBuilderContext.md b/docs/docs/api/appkit/Interface.SchemaBuilderContext.md new file mode 100644 index 000000000..47f3b8be0 --- /dev/null +++ b/docs/docs/api/appkit/Interface.SchemaBuilderContext.md @@ -0,0 +1,48 @@ +# Interface: SchemaBuilderContext + +A context for the schema builder. This is used to build the schema. + +## Properties + +### enum() + +```ts +enum: (name: string, values: readonly string[]) => AppKitColumnChain; +``` + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `name` | `string` | +| `values` | readonly `string`[] | + +#### Returns + +[`AppKitColumnChain`](Interface.AppKitColumnChain.md) + +*** + +### table() + +```ts +table: (name: TName, columns: TCols) => AppKitTable; +``` + +#### Type Parameters + +| Type Parameter | +| ------ | +| `TName` *extends* `string` | +| `TCols` *extends* `Record`\<`string`, [`AppKitColumn`](Interface.AppKitColumn.md)\> | + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `name` | `TName` | +| `columns` | `TCols` | + +#### Returns + +[`AppKitTable`](Interface.AppKitTable.md)\<`TName`\> diff --git a/docs/docs/api/appkit/TypeAlias.Schema.md b/docs/docs/api/appkit/TypeAlias.Schema.md new file mode 100644 index 000000000..1f937e235 --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.Schema.md @@ -0,0 +1,47 @@ +# Type Alias: Schema\ + +```ts +type Schema = T & { + $drizzle: unknown; + $migrations: { + snapshotHints: unknown; + }; + $tables: Record; +}; +``` + +A schema. This is used to define the schema for the database. + +## Type Declaration + +### $drizzle + +```ts +readonly $drizzle: unknown; +``` + +### $migrations + +```ts +readonly $migrations: { + snapshotHints: unknown; +}; +``` + +#### $migrations.snapshotHints + +```ts +snapshotHints: unknown; +``` + +### $tables + +```ts +readonly $tables: Record; +``` + +## Type Parameters + +| Type Parameter | Default type | +| ------ | ------ | +| `T` *extends* `Record`\<`string`, `unknown`\> | `Record`\<`string`, `unknown`\> | diff --git a/docs/docs/api/appkit/Variable.APPKIT_TABLE.md b/docs/docs/api/appkit/Variable.APPKIT_TABLE.md new file mode 100644 index 000000000..6103dd4a2 --- /dev/null +++ b/docs/docs/api/appkit/Variable.APPKIT_TABLE.md @@ -0,0 +1,7 @@ +# Variable: APPKIT\_TABLE + +```ts +const APPKIT_TABLE: typeof APPKIT_TABLE; +``` + +Symbol for identifying AppKit table metadata. diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index 5a21e935f..1a14efb18 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -31,12 +31,18 @@ plugin architecture, and React integration. | Interface | Description | | ------ | ------ | +| [AppKitColumn](Interface.AppKitColumn.md) | An AppKit column. This is returned by the column builder methods. | +| [AppKitColumnChain](Interface.AppKitColumnChain.md) | A chain of AppKit column methods. This is returned by the column builder methods. | +| [AppKitTable](Interface.AppKitTable.md) | An AppKit table. This is returned by the table builder methods. This is used to define the table schema and relationships. | | [BasePluginConfig](Interface.BasePluginConfig.md) | Base configuration interface for AppKit plugins | | [CacheConfig](Interface.CacheConfig.md) | Configuration for the CacheInterceptor. Controls TTL, size limits, storage backend, and probabilistic cleanup. | +| [ColumnMeta](Interface.ColumnMeta.md) | Metadata for an AppKit column. This is used to store the column metadata in the schema. | | [DatabaseCredential](Interface.DatabaseCredential.md) | Database credentials with OAuth token for Postgres connection | +| [DefineSchemaOptions](Interface.DefineSchemaOptions.md) | Options for defining a schema. | | [EndpointConfig](Interface.EndpointConfig.md) | - | | [FilePolicyUser](Interface.FilePolicyUser.md) | Minimal user identity passed to the policy function. | | [FileResource](Interface.FileResource.md) | Describes the file or directory being acted upon. | +| [FkColumnChain](Interface.FkColumnChain.md) | A foreign-key column chain. Returned by `fk(target)`. | | [GenerateDatabaseCredentialRequest](Interface.GenerateDatabaseCredentialRequest.md) | Request parameters for generating database OAuth credentials | | [IJobsConfig](Interface.IJobsConfig.md) | Configuration for the Jobs plugin. | | [ITelemetry](Interface.ITelemetry.md) | Plugin-facing interface for OpenTelemetry instrumentation. Provides a thin abstraction over OpenTelemetry APIs for plugins. | @@ -45,11 +51,13 @@ plugin architecture, and React integration. | [JobsConnectorConfig](Interface.JobsConnectorConfig.md) | - | | [LakebasePoolConfig](Interface.LakebasePoolConfig.md) | Configuration for creating a Lakebase connection pool | | [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. | +| [Relation](Interface.Relation.md) | A relation between two tables. This is used to define the foreign key relationships between tables. | | [RequestedClaims](Interface.RequestedClaims.md) | Optional claims for fine-grained Unity Catalog table permissions When specified, the returned token will be scoped to only the requested tables | | [RequestedResource](Interface.RequestedResource.md) | Resource to request permissions for in Unity Catalog | | [ResourceEntry](Interface.ResourceEntry.md) | Internal representation of a resource in the registry. Extends ResourceRequirement with resolution state and plugin ownership. | | [ResourceFieldEntry](Interface.ResourceFieldEntry.md) | Defines a single field for a resource. Each field has its own environment variable and optional description. Single-value types use one key (e.g. id); multi-value types (database, secret) use multiple (e.g. instance_name, database_name or scope, key). | | [ResourceRequirement](Interface.ResourceRequirement.md) | Declares a resource requirement for a plugin. Can be defined statically in a manifest or dynamically via getResourceRequirements(). Narrows the generated base: type → ResourceType enum, permission → ResourcePermission union. | +| [SchemaBuilderContext](Interface.SchemaBuilderContext.md) | A context for the schema builder. This is used to build the schema. | | [ServingEndpointEntry](Interface.ServingEndpointEntry.md) | Shape of a single registry entry. | | [ServingEndpointRegistry](Interface.ServingEndpointRegistry.md) | Registry interface for serving endpoint type generation. Empty by default — augmented by the Vite type generator's `.d.ts` output via module augmentation. When populated, provides autocomplete for alias names and typed request/response/chunk per endpoint. | | [StreamExecutionSettings](Interface.StreamExecutionSettings.md) | Execution settings for streaming endpoints. Extends PluginExecutionSettings with SSE stream configuration. | @@ -69,6 +77,7 @@ plugin architecture, and React integration. | [JobsExport](TypeAlias.JobsExport.md) | Public API shape of the jobs plugin. Callable to select a job by key. | | [PluginData](TypeAlias.PluginData.md) | Tuple of plugin class, config, and name. Created by `toPlugin()` and passed to `createApp()`. | | [ResourcePermission](TypeAlias.ResourcePermission.md) | Union of all possible permission levels across all resource types. | +| [Schema](TypeAlias.Schema.md) | A schema. This is used to define the schema for the database. | | [ServingFactory](TypeAlias.ServingFactory.md) | Factory function returned by `AppKit.serving`. | | [ToPlugin](TypeAlias.ToPlugin.md) | Factory function type returned by `toPlugin()`. Accepts optional config and returns a PluginData tuple. | @@ -76,6 +85,7 @@ plugin architecture, and React integration. | Variable | Description | | ------ | ------ | +| [APPKIT\_TABLE](Variable.APPKIT_TABLE.md) | Symbol for identifying AppKit table metadata. | | [READ\_ACTIONS](Variable.READ_ACTIONS.md) | Actions that only read data. | | [sql](Variable.sql.md) | SQL helper namespace | | [WRITE\_ACTIONS](Variable.WRITE_ACTIONS.md) | Actions that mutate data. | @@ -86,10 +96,15 @@ plugin architecture, and React integration. | ------ | ------ | | [appKitServingTypesPlugin](Function.appKitServingTypesPlugin.md) | Vite plugin to generate TypeScript types for AppKit serving endpoints. Fetches OpenAPI schemas from Databricks and generates a .d.ts with ServingEndpointRegistry module augmentation. | | [appKitTypesPlugin](Function.appKitTypesPlugin.md) | Vite plugin to generate types for AppKit queries. Calls generateFromEntryPoint under the hood. | +| [bigint](Function.bigint.md) | Create a bigint column. | +| [boolean](Function.boolean.md) | Create a boolean column. | | [createApp](Function.createApp.md) | Bootstraps AppKit with the provided configuration. | | [createLakebasePool](Function.createLakebasePool.md) | Create a Lakebase pool with appkit's logger integration. Telemetry automatically uses appkit's OpenTelemetry configuration via global registry. | +| [defineSchema](Function.defineSchema.md) | Define a schema. This is used to build the schema for the database. | +| [enumColumn](Function.enumColumn.md) | Create an enum column. | | [extractServingEndpoints](Function.extractServingEndpoints.md) | Extract serving endpoint config from a server file by AST-parsing it. Looks for `serving({ endpoints: { alias: { env: "..." }, ... } })` calls and extracts the endpoint alias names and their environment variable mappings. | | [findServerFile](Function.findServerFile.md) | Find the server entry file by checking candidate paths in order. | +| [fk](Function.fk.md) | Create a foreign key column. The reference target is captured live and resolved at `buildTable()` time, so forward references (e.g. `fk(other.id)` declared before `table("other", ...)`) work. | | [generateDatabaseCredential](Function.generateDatabaseCredential.md) | Generate OAuth credentials for Postgres database connection using the proper Postgres API. | | [getExecutionContext](Function.getExecutionContext.md) | Get the current execution context. | | [getLakebaseOrmConfig](Function.getLakebaseOrmConfig.md) | Get Lakebase connection configuration for ORMs that don't accept pg.Pool directly. | @@ -98,4 +113,20 @@ plugin architecture, and React integration. | [getResourceRequirements](Function.getResourceRequirements.md) | Gets the resource requirements from a plugin's manifest. | | [getUsernameWithApiLookup](Function.getUsernameWithApiLookup.md) | Resolves the PostgreSQL username for a Lakebase connection. | | [getWorkspaceClient](Function.getWorkspaceClient.md) | Get workspace client from config or SDK default auth chain | +| [id](Function.id.md) | Create a primary key column with a serial type. | +| [integer](Function.integer.md) | Create an integer column. | +| [isPrivateColumn](Function.isPrivateColumn.md) | Returns true if `columnName` is marked `.private()` on `table`. | | [isSQLTypeMarker](Function.isSQLTypeMarker.md) | Type guard to check if a value is a SQL type marker | +| [jsonb](Function.jsonb.md) | Create a jsonb column. | +| [nonPrivateColumnNames](Function.nonPrivateColumnNames.md) | Returns the column names of `table` that are NOT marked `.private()`. | +| [privateColumnNames](Function.privateColumnNames.md) | Returns the column names of `table` that ARE marked `.private()`. | +| [text](Function.text.md) | Create a text column. | +| [timestamp](Function.timestamp.md) | Create a timestamp column. | +| [uuid](Function.uuid.md) | Create a uuid column. | +| [varchar](Function.varchar.md) | Create a varchar column. | + +## References + +### enumeration + +Renames and re-exports [enumColumn](Function.enumColumn.md) diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index 162c3e68b..f93461640 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -87,6 +87,21 @@ const typedocSidebar: SidebarsConfig = { type: "category", label: "Interfaces", items: [ + { + type: "doc", + id: "api/appkit/Interface.AppKitColumn", + label: "AppKitColumn" + }, + { + type: "doc", + id: "api/appkit/Interface.AppKitColumnChain", + label: "AppKitColumnChain" + }, + { + type: "doc", + id: "api/appkit/Interface.AppKitTable", + label: "AppKitTable" + }, { type: "doc", id: "api/appkit/Interface.BasePluginConfig", @@ -97,11 +112,21 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.CacheConfig", label: "CacheConfig" }, + { + type: "doc", + id: "api/appkit/Interface.ColumnMeta", + label: "ColumnMeta" + }, { type: "doc", id: "api/appkit/Interface.DatabaseCredential", label: "DatabaseCredential" }, + { + type: "doc", + id: "api/appkit/Interface.DefineSchemaOptions", + label: "DefineSchemaOptions" + }, { type: "doc", id: "api/appkit/Interface.EndpointConfig", @@ -117,6 +142,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.FileResource", label: "FileResource" }, + { + type: "doc", + id: "api/appkit/Interface.FkColumnChain", + label: "FkColumnChain" + }, { type: "doc", id: "api/appkit/Interface.GenerateDatabaseCredentialRequest", @@ -157,6 +187,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.PluginManifest", label: "PluginManifest" }, + { + type: "doc", + id: "api/appkit/Interface.Relation", + label: "Relation" + }, { type: "doc", id: "api/appkit/Interface.RequestedClaims", @@ -182,6 +217,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.ResourceRequirement", label: "ResourceRequirement" }, + { + type: "doc", + id: "api/appkit/Interface.SchemaBuilderContext", + label: "SchemaBuilderContext" + }, { type: "doc", id: "api/appkit/Interface.ServingEndpointEntry", @@ -258,6 +298,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/TypeAlias.ResourcePermission", label: "ResourcePermission" }, + { + type: "doc", + id: "api/appkit/TypeAlias.Schema", + label: "Schema" + }, { type: "doc", id: "api/appkit/TypeAlias.ServingFactory", @@ -274,6 +319,11 @@ const typedocSidebar: SidebarsConfig = { type: "category", label: "Variables", items: [ + { + type: "doc", + id: "api/appkit/Variable.APPKIT_TABLE", + label: "APPKIT_TABLE" + }, { type: "doc", id: "api/appkit/Variable.READ_ACTIONS", @@ -305,6 +355,16 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.appKitTypesPlugin", label: "appKitTypesPlugin" }, + { + type: "doc", + id: "api/appkit/Function.bigint", + label: "bigint" + }, + { + type: "doc", + id: "api/appkit/Function.boolean", + label: "boolean" + }, { type: "doc", id: "api/appkit/Function.createApp", @@ -315,6 +375,16 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.createLakebasePool", label: "createLakebasePool" }, + { + type: "doc", + id: "api/appkit/Function.defineSchema", + label: "defineSchema" + }, + { + type: "doc", + id: "api/appkit/Function.enumColumn", + label: "enumColumn" + }, { type: "doc", id: "api/appkit/Function.extractServingEndpoints", @@ -325,6 +395,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.findServerFile", label: "findServerFile" }, + { + type: "doc", + id: "api/appkit/Function.fk", + label: "fk" + }, { type: "doc", id: "api/appkit/Function.generateDatabaseCredential", @@ -365,10 +440,60 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.getWorkspaceClient", label: "getWorkspaceClient" }, + { + type: "doc", + id: "api/appkit/Function.id", + label: "id" + }, + { + type: "doc", + id: "api/appkit/Function.integer", + label: "integer" + }, + { + type: "doc", + id: "api/appkit/Function.isPrivateColumn", + label: "isPrivateColumn" + }, { type: "doc", id: "api/appkit/Function.isSQLTypeMarker", label: "isSQLTypeMarker" + }, + { + type: "doc", + id: "api/appkit/Function.jsonb", + label: "jsonb" + }, + { + type: "doc", + id: "api/appkit/Function.nonPrivateColumnNames", + label: "nonPrivateColumnNames" + }, + { + type: "doc", + id: "api/appkit/Function.privateColumnNames", + label: "privateColumnNames" + }, + { + type: "doc", + id: "api/appkit/Function.text", + label: "text" + }, + { + type: "doc", + id: "api/appkit/Function.timestamp", + label: "timestamp" + }, + { + type: "doc", + id: "api/appkit/Function.uuid", + label: "uuid" + }, + { + type: "doc", + id: "api/appkit/Function.varchar", + label: "varchar" } ] } diff --git a/packages/appkit/package.json b/packages/appkit/package.json index 147e7eaf9..8ac2a4584 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -74,6 +74,8 @@ "@opentelemetry/semantic-conventions": "1.38.0", "@types/semver": "7.7.1", "dotenv": "16.6.1", + "drizzle-orm": "0.45.1", + "drizzle-zod": "^0.8.3", "express": "4.22.0", "get-port": "7.2.0", "obug": "2.1.1", diff --git a/packages/appkit/src/beta.ts b/packages/appkit/src/beta.ts index 04e893bf3..31cc16be2 100644 --- a/packages/appkit/src/beta.ts +++ b/packages/appkit/src/beta.ts @@ -6,3 +6,11 @@ // "stability" field. See tools/generate-plugin-entries.ts. export { DatabricksAdapter, parseTextToolCalls } from "./agents/databricks"; export * from "./plugins/beta-exports.generated"; +export type { + DatabasePoolTuning, + EntityHooks, + HookContext, + HttpAccess, + HttpEntityOverride, + IDatabaseConfig, +} from "./plugins/database"; diff --git a/packages/appkit/src/database/index.ts b/packages/appkit/src/database/index.ts new file mode 100644 index 000000000..55d54b94e --- /dev/null +++ b/packages/appkit/src/database/index.ts @@ -0,0 +1 @@ +export * from "./schema-builder"; diff --git a/packages/appkit/src/database/schema-builder/columns.ts b/packages/appkit/src/database/schema-builder/columns.ts new file mode 100644 index 000000000..0f5f7022d --- /dev/null +++ b/packages/appkit/src/database/schema-builder/columns.ts @@ -0,0 +1,247 @@ +import { + bigint as pgBigint, + boolean as pgBoolean, + pgEnum, + integer as pgInteger, + jsonb as pgJsonb, + text as pgText, + timestamp as pgTimestamp, + uuid as pgUuid, + varchar as pgVarchar, + serial, +} from "drizzle-orm/pg-core"; +import { ValidationError } from "../../errors"; +import type { + AppKitColumn, + AppKitColumnChain, + ColumnMeta, + FkColumnChain, + Relation, +} from "./types"; + +/** + * Wrap a column builder with a chain of methods. + * This is used to build the column schema. + * @param builder - The column builder to wrap. + * @param meta - The metadata for the column. + * @returns The wrapped column chain. + */ +function wrap(builder: unknown, meta: ColumnMeta = {}): AppKitColumnChain { + const column: AppKitColumn = { $builder: builder, $meta: meta }; + + const chain: AppKitColumnChain = Object.assign(column, { + notNull() { + column.$builder = ( + column.$builder as { notNull: () => unknown } + ).notNull(); + return chain; + }, + unique() { + column.$builder = (column.$builder as { unique: () => unknown }).unique(); + return chain; + }, + primaryKey() { + column.$builder = ( + column.$builder as { primaryKey: () => unknown } + ).primaryKey(); + // Stamp meta so derivePkColumn / $updateSchema PK omit don't have to + // round-trip through the Drizzle table to discover this is a PK. + column.$meta.primaryKey = true; + return chain; + }, + default(value: T) { + column.$builder = ( + column.$builder as { default: (value: T) => unknown } + ).default(value); + return chain; + }, + defaultNow() { + column.$builder = ( + column.$builder as { defaultNow: () => unknown } + ).defaultNow(); + column.$meta.serverGenerated = true; + return chain; + }, + defaultRandom() { + column.$builder = ( + column.$builder as { defaultRandom: () => unknown } + ).defaultRandom(); + column.$meta.serverGenerated = true; + return chain; + }, + private() { + column.$meta.private = true; + return chain; + }, + }); + + return chain; +} + +/** + * Create a primary key column with a serial type. + * @returns The wrapped column chain. + */ +export function id(): AppKitColumnChain { + return wrap(serial().primaryKey(), { + serverGenerated: true, + primaryKey: true, + }); +} + +/** + * Create a text column. + * @returns The wrapped column chain. + */ +export function text(): AppKitColumnChain { + return wrap(pgText()); +} + +/** + * Create an integer column. + * @returns The wrapped column chain. + */ +export function integer(): AppKitColumnChain { + return wrap(pgInteger()); +} + +/** + * Create a bigint column. + * @returns The wrapped column chain. + */ +export function bigint(): AppKitColumnChain { + return wrap(pgBigint({ mode: "number" })); +} + +/** + * Create a boolean column. + * @returns The wrapped column chain. + */ +export function boolean(): AppKitColumnChain { + return wrap(pgBoolean()); +} + +/** + * Create a timestamp column. + * @returns The wrapped column chain. + */ +export function timestamp(): AppKitColumnChain { + return wrap(pgTimestamp({ mode: "date" })); +} + +/** + * Create a jsonb column. + * @returns The wrapped column chain. + */ +export function jsonb(): AppKitColumnChain { + return wrap(pgJsonb()); +} + +/** + * Create a uuid column. + * @returns The wrapped column chain. + */ +export function uuid(): AppKitColumnChain { + return wrap(pgUuid()); +} + +/** + * Create a varchar column. + * @param length - The length of the column. + * @returns The wrapped column chain. + */ +export function varchar(length = 255): AppKitColumnChain { + return wrap(pgVarchar({ length })); +} + +/** + * Create an enum column. + * @param name - The name of the enum. + * @param values - The values of the enum. + * @returns The wrapped column chain. + */ +export function enumColumn( + name: string, + values: readonly string[], +): AppKitColumnChain { + if (values.length === 0) { + throw new ValidationError( + `enum "${name}" must declare at least one value`, + { context: { enumName: name } }, + ); + } + + const enumType = pgEnum(name, values as [string, ...string[]]); + return wrap(enumType()); +} + +/** + * Create a foreign key column. The reference target is captured live and + * resolved at `buildTable()` time, so forward references (e.g. `fk(other.id)` + * declared before `table("other", ...)`) work. + * + * The FK column type is currently fixed to `integer`. If the target is a + * `bigid()` (`bigserial`) or `uuid()` PK, declare the FK column with the + * matching type explicitly until per-target type inference is added. + * + * @param target - The target column to reference. + * @returns A FK column chain. `onDelete`/`onUpdate` return the FK chain so + * order does not matter; chain methods (`.notNull()`, `.unique()`, etc.) also + * return the FK chain so `.notNull().onDelete("cascade")` typechecks. + */ +export function fk(target: AppKitColumn): FkColumnChain { + const baseChain = wrap(pgInteger(), { + // Live target reference; buildTable() resolves to toTable/toColumn after + // all tables have been built and column names stamped. + references: { target }, + }); + + // Override chain methods to return FkColumnChain at the type level. Runtime + // returns the same chain object so the cast is safe. + const fkChain: FkColumnChain = Object.assign(baseChain, { + notNull: () => { + baseChain.notNull(); + return fkChain; + }, + unique: () => { + baseChain.unique(); + return fkChain; + }, + primaryKey: () => { + baseChain.primaryKey(); + return fkChain; + }, + default(value: T) { + baseChain.default(value); + return fkChain; + }, + defaultNow: () => { + baseChain.defaultNow(); + return fkChain; + }, + defaultRandom: () => { + baseChain.defaultRandom(); + return fkChain; + }, + private: () => { + baseChain.private(); + return fkChain; + }, + onDelete: (value: NonNullable) => { + fkChain.$meta.references = { + ...(fkChain.$meta.references ?? {}), + onDelete: value, + }; + return fkChain; + }, + onUpdate: (value: NonNullable) => { + fkChain.$meta.references = { + ...(fkChain.$meta.references ?? {}), + onUpdate: value, + }; + return fkChain; + }, + }) as FkColumnChain; + + return fkChain; +} diff --git a/packages/appkit/src/database/schema-builder/define-schema.ts b/packages/appkit/src/database/schema-builder/define-schema.ts new file mode 100644 index 000000000..89ed323b0 --- /dev/null +++ b/packages/appkit/src/database/schema-builder/define-schema.ts @@ -0,0 +1,78 @@ +import { pgSchema } from "drizzle-orm/pg-core"; +import { ValidationError } from "../../errors"; +import { enumColumn } from "./columns"; +import { buildTable, rebuildRelationsFromColumns } from "./table"; +import { + APPKIT_TABLE, + type AppKitTable, + type Relation, + type Schema, + type SchemaBuilderContext, +} from "./types"; + +/** + * Options for defining a schema. + */ +export interface DefineSchemaOptions { + schemaName?: string; +} + +/** + * Define a schema. This is used to build the schema for the database. + * @param build - A function that builds the schema. + * @param options - Options for defining the schema. + * @returns The defined schema. + */ +export function defineSchema>( + build: (ctx: SchemaBuilderContext) => T, + options: DefineSchemaOptions = {}, +): Schema { + const schemaInstance = pgSchema(options.schemaName ?? "app"); + + const context: SchemaBuilderContext = { + table: (name, columns) => buildTable(schemaInstance, name, columns), + enum: (name, values) => enumColumn(name, values), + }; + + const tables = build(context); + const tableMap: Record = {}; + for (const [key, value] of Object.entries(tables)) { + if ((value as AppKitTable)[APPKIT_TABLE]) { + tableMap[key] = value as AppKitTable; + } + } + + // Resolve any deferred FK targets now that all tables have been built and column names stamped. + for (const table of Object.values(tableMap)) { + let touched = false; + for (const [columnName, columnMeta] of Object.entries(table.$columns)) { + const reference = columnMeta.references; + if (!reference?.target) continue; + if (reference.toTable && reference.toColumn) continue; + const targetTable = reference.target.$meta.tableName; + const targetColumn = reference.target.$meta.columnName; + if (!targetTable || !targetColumn) { + throw new ValidationError( + `fk() target on ${table.name}.${columnName} was not declared via table(...). ` + + `Pass the target column to table() before referencing it from fk().`, + { context: { table: table.name, column: columnName } }, + ); + } + reference.toTable = targetTable; + reference.toColumn = targetColumn; + touched = true; + } + if (touched) { + const rebuilt: Relation[] = rebuildRelationsFromColumns(table.$columns); + // $relations is readonly in the public type but the runtime object is mutable. + (table as { $relations: Relation[] }).$relations = rebuilt; + } + } + + return { + ...tables, + $drizzle: schemaInstance, + $tables: tableMap, + $migrations: { snapshotHints: undefined }, + } as Schema; +} diff --git a/packages/appkit/src/database/schema-builder/index.ts b/packages/appkit/src/database/schema-builder/index.ts new file mode 100644 index 000000000..03791760d --- /dev/null +++ b/packages/appkit/src/database/schema-builder/index.ts @@ -0,0 +1,31 @@ +export { + bigint, + boolean, + enumColumn, + enumColumn as enumeration, + fk, + id, + integer, + jsonb, + text, + timestamp, + uuid, + varchar, +} from "./columns"; +export { type DefineSchemaOptions, defineSchema } from "./define-schema"; +export { + isPrivateColumn, + nonPrivateColumnNames, + privateColumnNames, +} from "./private"; +export type { + AppKitColumn, + AppKitColumnChain, + AppKitTable, + ColumnMeta, + FkColumnChain, + Relation, + Schema, + SchemaBuilderContext, +} from "./types"; +export { APPKIT_TABLE } from "./types"; diff --git a/packages/appkit/src/database/schema-builder/private.ts b/packages/appkit/src/database/schema-builder/private.ts new file mode 100644 index 000000000..72f9bc8ce --- /dev/null +++ b/packages/appkit/src/database/schema-builder/private.ts @@ -0,0 +1,33 @@ +import type { AppKitTable } from "./types"; + +/** + * Returns the column names of `table` that are NOT marked `.private()`. + */ +export function nonPrivateColumnNames(table: AppKitTable): string[] { + const out: string[] = []; + for (const [name, meta] of Object.entries(table.$columns)) { + if (meta.private !== true) out.push(name); + } + return out; +} + +/** + * Returns the column names of `table` that ARE marked `.private()`. + */ +export function privateColumnNames(table: AppKitTable): string[] { + const out: string[] = []; + for (const [name, meta] of Object.entries(table.$columns)) { + if (meta.private === true) out.push(name); + } + return out; +} + +/** + * Returns true if `columnName` is marked `.private()` on `table`. + */ +export function isPrivateColumn( + table: AppKitTable, + columnName: string, +): boolean { + return table.$columns[columnName]?.private === true; +} diff --git a/packages/appkit/src/database/schema-builder/table.ts b/packages/appkit/src/database/schema-builder/table.ts new file mode 100644 index 000000000..99f37d366 --- /dev/null +++ b/packages/appkit/src/database/schema-builder/table.ts @@ -0,0 +1,134 @@ +import type { pgSchema } from "drizzle-orm/pg-core"; +import { createInsertSchema, createUpdateSchema } from "drizzle-zod"; +import type { z } from "zod"; +import { + APPKIT_TABLE, + type AppKitColumn, + type AppKitTable, + type ColumnMeta, + type Relation, +} from "./types"; + +/** + * Build the resolved `$relations` list for a table from its column metadata. + */ +function buildRelations(columns: Record): Relation[] { + const relations: Relation[] = []; + for (const [columnName, column] of Object.entries(columns)) { + const reference = column.$meta.references; + if (!reference?.toTable || !reference?.toColumn) continue; + const relation: Relation = { + fromColumn: columnName, + toTable: reference.toTable, + toColumn: reference.toColumn, + }; + if (reference.onDelete) relation.onDelete = reference.onDelete; + if (reference.onUpdate) relation.onUpdate = reference.onUpdate; + relations.push(relation); + } + return relations; +} + +/** + * Rebuild `$relations` from the column-meta map. + * Used by `defineSchema` after resolving cross-table deferred references. + */ +export function rebuildRelationsFromColumns( + columnMetas: Record, +): Relation[] { + const relations: Relation[] = []; + for (const [columnName, meta] of Object.entries(columnMetas)) { + const reference = meta.references; + if (!reference?.toTable || !reference?.toColumn) continue; + const relation: Relation = { + fromColumn: columnName, + toTable: reference.toTable, + toColumn: reference.toColumn, + }; + if (reference.onDelete) relation.onDelete = reference.onDelete; + if (reference.onUpdate) relation.onUpdate = reference.onUpdate; + relations.push(relation); + } + return relations; +} + +/** + * Build a table. Returns an AppKit table object that can be used to define the table schema and relationships. + * @param schemaInstance - The schema instance. + * @param name - The name of the table. + * @param columns - The columns of the table. + * @returns The built table. + */ +export function buildTable< + TName extends string, + TCols extends Record, +>( + schemaInstance: ReturnType, + name: TName, + columns: TCols, +): AppKitTable { + for (const [columnName, column] of Object.entries(columns)) { + column.$meta.tableName = name; + column.$meta.columnName = columnName; + } + + // Resolve any self-FK targets now that names on this table are stamped. + for (const column of Object.values(columns)) { + const reference = column.$meta.references; + if (!reference?.target) continue; + if (reference.toTable && reference.toColumn) continue; + const targetTable = reference.target.$meta.tableName; + const targetColumn = reference.target.$meta.columnName; + if (targetTable === name && targetColumn) { + reference.toTable = targetTable; + reference.toColumn = targetColumn; + } + } + + const drizzleColumns = Object.fromEntries( + Object.entries(columns).map(([columnName, definition]) => [ + columnName, + definition.$builder, + ]), + ); + + const drizzleTable = schemaInstance.table(name, drizzleColumns as never); + + const $columns = Object.fromEntries( + Object.entries(columns).map(([columnName, definition]) => [ + columnName, + definition.$meta, + ]), + ); + + const $relations: Relation[] = buildRelations(columns); + + const privateMask = Object.fromEntries( + Object.entries(columns) + .filter(([, definition]) => definition.$meta.private === true) + .map(([columnName]) => [columnName, true as const]), + ); + + const insertSchema = createInsertSchema(drizzleTable as never); + const updateSchema = createUpdateSchema(drizzleTable as never); + + return { + [APPKIT_TABLE]: true, + name, + $drizzle: drizzleTable, + $columns, + $relations, + $insertSchema: + Object.keys(privateMask).length > 0 + ? (insertSchema as unknown as z.ZodObject).omit( + privateMask as never, + ) + : insertSchema, + $updateSchema: + Object.keys(privateMask).length > 0 + ? (updateSchema as unknown as z.ZodObject).omit( + privateMask as never, + ) + : updateSchema, + }; +} diff --git a/packages/appkit/src/database/schema-builder/types.ts b/packages/appkit/src/database/schema-builder/types.ts new file mode 100644 index 000000000..f7cfba78e --- /dev/null +++ b/packages/appkit/src/database/schema-builder/types.ts @@ -0,0 +1,118 @@ +import type { z } from "zod"; + +/** + * Symbol for identifying AppKit table metadata. + */ +export const APPKIT_TABLE = Symbol.for("appkit.database.table"); + +/** + * Metadata for an AppKit column. This is used to store the column metadata in the schema. + */ +export interface ColumnMeta { + serverGenerated?: boolean; + primaryKey?: boolean; + /** + * Marks this column as private. + * Excludes the column from the generated `$insertSchema` and `$updateSchema` (i.e. blocks writes through the validators). + */ + private?: boolean; + /** @internal */ + tableName?: string; + /** @internal */ + columnName?: string; + /** + * @internal + * Foreign-key reference in one of two states: **deferred** (`target` set) + * or **resolved** (`toTable`/`toColumn` populated). + */ + references?: { + target?: AppKitColumn; + toTable?: string; + toColumn?: string; + onDelete?: Relation["onDelete"]; + onUpdate?: Relation["onUpdate"]; + }; +} + +/** + * An AppKit column. This is returned by the column builder methods. + */ +export interface AppKitColumn { + $builder: unknown; + $meta: ColumnMeta; +} + +/** + * A chain of AppKit column methods. This is returned by the column builder methods. + */ +export interface AppKitColumnChain extends AppKitColumn { + notNull(): AppKitColumnChain; + unique(): AppKitColumnChain; + primaryKey(): AppKitColumnChain; + default(value: T): AppKitColumnChain; + defaultNow(): AppKitColumnChain; + defaultRandom(): AppKitColumnChain; + private(): AppKitColumnChain; +} + +/** + * A foreign-key column chain. Returned by `fk(target)`. + */ +export interface FkColumnChain extends AppKitColumnChain { + notNull(): FkColumnChain; + unique(): FkColumnChain; + primaryKey(): FkColumnChain; + default(value: T): FkColumnChain; + defaultNow(): FkColumnChain; + defaultRandom(): FkColumnChain; + private(): FkColumnChain; + onDelete(value: NonNullable): FkColumnChain; + onUpdate(value: NonNullable): FkColumnChain; +} + +/** + * A relation between two tables. This is used to define the foreign key relationships between tables. + */ +export interface Relation { + fromColumn: string; + toTable: string; + toColumn: string; + onDelete?: "cascade" | "set null" | "restrict" | "no action"; + onUpdate?: "cascade" | "set null" | "restrict" | "no action"; +} + +/** + * An AppKit table. This is returned by the table builder methods. + * This is used to define the table schema and relationships. + */ +export interface AppKitTable { + readonly [APPKIT_TABLE]: true; + readonly name: TName; + readonly $drizzle: unknown; + readonly $columns: Record; + readonly $insertSchema: z.ZodTypeAny; + readonly $updateSchema: z.ZodTypeAny; + readonly $relations: Relation[]; +} + +/** + * A schema. This is used to define the schema for the database. + */ +export type Schema< + T extends Record = Record, +> = T & { + readonly $drizzle: unknown; + readonly $tables: Record; + readonly $migrations: { snapshotHints: unknown }; +}; + +/** + * A context for the schema builder. This is used to build the schema. + */ +export interface SchemaBuilderContext { + table: >( + name: TName, + columns: TCols, + ) => AppKitTable; + enum: (name: string, values: readonly string[]) => AppKitColumnChain; +} diff --git a/packages/appkit/src/database/tests/define-schema.test.ts b/packages/appkit/src/database/tests/define-schema.test.ts new file mode 100644 index 000000000..7f3b2ebe7 --- /dev/null +++ b/packages/appkit/src/database/tests/define-schema.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test } from "vitest"; +import { + APPKIT_TABLE, + boolean, + defineSchema, + enumeration, + fk, + id, + integer, + jsonb, + text, + timestamp, +} from "../schema-builder"; + +describe("defineSchema", () => { + test("collects tables and relations", () => { + const schema = defineSchema(({ table }) => { + const userCols = { + id: id(), + email: text().notNull().unique(), + }; + const user = table("user", userCols); + const post = table("post", { + id: id(), + authorId: fk(userCols.id).onDelete("cascade"), + title: text().notNull(), + }); + + return { user, post }; + }); + + expect(schema.user[APPKIT_TABLE]).toBe(true); + expect(Object.keys(schema.$tables)).toEqual(["user", "post"]); + expect(schema.post.$relations).toEqual([ + { + fromColumn: "authorId", + toTable: "user", + toColumn: "id", + onDelete: "cascade", + }, + ]); + }); + + test("derives insert and update validators", () => { + const schema = defineSchema(({ table }) => ({ + user: table("user", { + id: id(), + email: text().notNull(), + }), + })); + + expect( + schema.user.$insertSchema.safeParse({ email: "a@example.com" }).success, + ).toBe(true); + expect(schema.user.$insertSchema.safeParse({}).success).toBe(false); + expect( + schema.user.$updateSchema.safeParse({ email: "b@example.com" }).success, + ).toBe(true); + }); + + describe("drizzle-zod regression coverage", () => { + test("integer columns reject non-numbers and accept whole numbers", () => { + const schema = defineSchema(({ table }) => ({ + product: table("product", { id: id(), price: integer().notNull() }), + })); + + expect( + schema.product.$insertSchema.safeParse({ price: 100 }).success, + ).toBe(true); + expect( + schema.product.$insertSchema.safeParse({ price: "100" }).success, + ).toBe(false); + expect( + schema.product.$insertSchema.safeParse({ price: 1.5 }).success, + ).toBe(false); + }); + + test("boolean columns reject coerced strings", () => { + const schema = defineSchema(({ table }) => ({ + flag: table("flag", { id: id(), on: boolean().notNull() }), + })); + + expect(schema.flag.$insertSchema.safeParse({ on: true }).success).toBe( + true, + ); + expect(schema.flag.$insertSchema.safeParse({ on: "true" }).success).toBe( + false, + ); + }); + + test("jsonb accepts arbitrary JSON shapes", () => { + const schema = defineSchema(({ table }) => ({ + event: table("event", { id: id(), payload: jsonb().notNull() }), + })); + + expect( + schema.event.$insertSchema.safeParse({ payload: { a: 1 } }).success, + ).toBe(true); + expect( + schema.event.$insertSchema.safeParse({ payload: [1, 2, 3] }).success, + ).toBe(true); + expect( + schema.event.$insertSchema.safeParse({ payload: "hello" }).success, + ).toBe(true); + }); + + test("nullable column accepts null; required column does not", () => { + const schema = defineSchema(({ table }) => ({ + user: table("user", { + id: id(), + email: text().notNull(), + nickname: text(), + }), + })); + + expect( + schema.user.$insertSchema.safeParse({ + email: "a@x", + nickname: null, + }).success, + ).toBe(true); + expect( + schema.user.$insertSchema.safeParse({ email: null, nickname: "Al" }) + .success, + ).toBe(false); + }); + + test("update schema treats every field as optional, including required ones", () => { + const schema = defineSchema(({ table }) => ({ + user: table("user", { + id: id(), + email: text().notNull(), + nickname: text(), + }), + })); + + // Insert: email is required. + expect(schema.user.$insertSchema.safeParse({}).success).toBe(false); + // Update: empty patch is allowed. + expect(schema.user.$updateSchema.safeParse({}).success).toBe(true); + // Update: partial patch with only nickname is allowed. + expect( + schema.user.$updateSchema.safeParse({ nickname: "Al" }).success, + ).toBe(true); + }); + + test("enum columns accept declared values and reject anything else", () => { + const schema = defineSchema(({ table }) => ({ + case: table("case", { + id: id(), + status: enumeration("case_status", [ + "new", + "open", + "closed", + ]).notNull(), + }), + })); + + expect( + schema.case.$insertSchema.safeParse({ status: "new" }).success, + ).toBe(true); + expect( + schema.case.$insertSchema.safeParse({ status: "archived" }).success, + ).toBe(false); + }); + + test("timestamp accepts Date instances", () => { + const schema = defineSchema(({ table }) => ({ + case: table("case", { + id: id(), + createdAt: timestamp().notNull(), + }), + })); + + expect( + schema.case.$insertSchema.safeParse({ createdAt: new Date() }).success, + ).toBe(true); + }); + }); + + test("private columns are omitted from insert and update schemas", () => { + const schema = defineSchema(({ table }) => ({ + user: table("user", { + id: id(), + email: text().notNull(), + passwordHash: text().notNull().private(), + }), + })); + + expect(schema.user.$columns.passwordHash.private).toBe(true); + + const inserted = schema.user.$insertSchema.safeParse({ + email: "a@example.com", + passwordHash: "ignored", + }); + expect(inserted.success).toBe(true); + if (inserted.success) { + expect("passwordHash" in (inserted.data as Record)).toBe( + false, + ); + } + + const updated = schema.user.$updateSchema.safeParse({ + passwordHash: "ignored", + }); + if (updated.success) { + expect("passwordHash" in (updated.data as Record)).toBe( + false, + ); + } + }); +}); diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index 00fd6ff86..607336ea5 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -23,7 +23,7 @@ export type { RequestedClaims, RequestedResource, } from "./connectors/lakebase"; -// Lakebase Autoscaling connector + export { createLakebasePool, generateDatabaseCredential, @@ -35,6 +35,8 @@ export { } from "./connectors/lakebase"; export { getExecutionContext } from "./context"; export { createApp } from "./core"; +// Database +export * from "./database"; // Errors export { AppKitError, diff --git a/packages/appkit/src/plugin/plugin.ts b/packages/appkit/src/plugin/plugin.ts index 9a992f2c4..7b046d1f7 100644 --- a/packages/appkit/src/plugin/plugin.ts +++ b/packages/appkit/src/plugin/plugin.ts @@ -231,7 +231,7 @@ export abstract class Plugin< return this.skipBodyParsingPaths; } - abortActiveOperations(): void { + abortActiveOperations(): Promise | void { this.streamManager.abortAll(); } diff --git a/packages/appkit/src/plugins/beta-exports.generated.ts b/packages/appkit/src/plugins/beta-exports.generated.ts index 7fff0af71..175181869 100644 --- a/packages/appkit/src/plugins/beta-exports.generated.ts +++ b/packages/appkit/src/plugins/beta-exports.generated.ts @@ -5,4 +5,4 @@ // subpath ships each plugin. Editing this file by hand will drift it from the // manifests and the synced appkit.plugins.json. -export {}; +export { database } from "./database"; diff --git a/packages/appkit/src/plugins/database/convention.ts b/packages/appkit/src/plugins/database/convention.ts new file mode 100644 index 000000000..46d79f228 --- /dev/null +++ b/packages/appkit/src/plugins/database/convention.ts @@ -0,0 +1,99 @@ +import { access } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import type { Schema } from "../../database"; +import { ConfigurationError } from "../../errors"; +import { createLogger } from "../../logging/logger"; + +const logger = createLogger("database:convention"); + +/** + * Convention paths for loading the database schema. + */ +const CONVENTION_PATHS = [ + "config/database/schema.ts", + "config/database/schema/index.ts", + "dist/config/database/schema.js", + "dist/config/database/schema/index.js", +] as const; + +/** + * Result of loading the database schema by convention. + */ +interface LoadSchemaResult { + schema: Schema; + schemaPath: string; +} + +/** + * Options for loading the database schema by convention. + */ +interface LoadSchemaByConventionOptions { + /** The current working directory. */ + cwd?: string; + /** A function to import the schema module. */ + importer?: (absolutePath: string) => Promise; +} + +export async function pathExists(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +export function isSchema(value: unknown): value is Schema { + return ( + typeof value === "object" && + value !== null && + "$drizzle" in value && + "$tables" in value && + typeof (value as { $tables?: unknown }).$tables === "object" + ); +} + +export async function loadSchemaByConvention( + options: LoadSchemaByConventionOptions = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const importer = options.importer ?? defaultImporter; + + const probed: string[] = []; + for (const candidate of CONVENTION_PATHS) { + const absolutePath = path.resolve(cwd, candidate); + probed.push(absolutePath); + if (!(await pathExists(absolutePath))) continue; + + const mod = await importer(absolutePath); + const schema = extractSchema(mod); + + if (!isSchema(schema)) { + throw new ConfigurationError( + `Database schema at ${absolutePath} is not a valid AppKit schema. Export the result of defineSchema(...) as the default export.`, + { context: { schemaPath: absolutePath } }, + ); + } + + return { schema, schemaPath: absolutePath }; + } + + logger.info( + "No database schema found. Probed paths:\n - %s", + probed.join("\n - "), + ); + return null; +} + +async function defaultImporter(absolutePath: string): Promise { + return import(pathToFileURL(absolutePath).href); +} + +function extractSchema(mod: unknown): unknown { + if (isSchema(mod)) return mod; + if (typeof mod !== "object" || mod === null) return undefined; + + const exports = mod as { default?: unknown; schema?: unknown }; + return exports.default ?? exports.schema; +} diff --git a/packages/appkit/src/plugins/database/database.ts b/packages/appkit/src/plugins/database/database.ts new file mode 100644 index 000000000..1cc527d56 --- /dev/null +++ b/packages/appkit/src/plugins/database/database.ts @@ -0,0 +1,180 @@ +import type { Pool } from "pg"; +import { Plugin, toPlugin } from "@/plugin"; +import { createLakebasePool } from "../../connectors/lakebase"; +import type { Schema } from "../../database"; +import { ConfigurationError } from "../../errors"; +import { createLogger } from "../../logging/logger"; +import type { PluginManifest } from "../../registry"; +import { loadSchemaByConvention } from "./convention"; +import { + APPLICATION_NAME, + POOL_DEFAULTS, + STATEMENT_TIMEOUT_DEFAULT_MS, +} from "./defaults"; +import manifest from "./manifest.json"; +import type { IDatabaseConfig } from "./types"; + +const logger = createLogger("database"); + +class DatabasePlugin extends Plugin { + static manifest = manifest as PluginManifest<"database">; + + protected declare config: IDatabaseConfig; + protected pool: Pool | null = null; + protected schema: Schema | null = null; + protected schemaPath: string | null = null; + + constructor(config: IDatabaseConfig = {}) { + super(config); + this.config = config; + } + + async setup() { + this.pool = createLakebasePool({ + ...POOL_DEFAULTS, + ...this.config.connection, + }); + attachSessionDefaults(this.pool, this.config.statementTimeoutMs); + if (process.env.APPKIT_DEBUG_POOL || process.env.DEBUG_POOL) { + startPoolStatsLog(this.pool, "service-principal"); + } + logger.info("Database plugin pool initialized"); + + try { + const loaded = await loadSchemaByConvention(); + if (!loaded) { + logger.warn( + "Database plugin did not find config/database/schema.ts, using empty schema", + ); + return; + } + + this.schema = loaded.schema; + this.schemaPath = loaded.schemaPath; + logger.info( + "Database schema loaded from %s with %d entries", + loaded.schemaPath, + Object.keys(loaded.schema.$tables).length, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error( + "Database schema load failed (config/database/schema.ts): %s", + message, + ); + if (!this.config.tolerateSetupFailure) { + const stalePool = this.pool; + this.pool = null; + if (stalePool) { + await stalePool.end().catch((endErr) => { + logger.error( + "Error draining stale pool after schema-load failure: %O", + endErr, + ); + }); + } + throw err; + } + } + } + + async abortActiveOperations(): Promise { + super.abortActiveOperations(); + if (!this.pool) return; + + logger.info("Closing database pool"); + const draining = this.pool.end(); + this.pool = null; + try { + await draining; + } catch (err) { + logger.error("Error closing database pool: %O", err); + } + } + + exports() { + return { + getPool: () => this.requirePool(), + }; + } + + protected requirePool(): Pool { + if (!this.pool) { + throw ConfigurationError.resourceNotFound( + "Database", + "Database pool not initialized", + ); + } + return this.pool; + } +} + +export const database = toPlugin(DatabasePlugin); + +/** + * Attach a `connect` listener that sets per-session defaults on + * every new Postgres session checked out of the pool + * @param pool + * @param override + */ +function attachSessionDefaults(pool: Pool, override?: number): void { + const ms = override ?? STATEMENT_TIMEOUT_DEFAULT_MS; + const applicationName = applicationNameForSession(); + pool.on("connect", (client) => { + let destroyed = false; + const destroy = (label: string, err: unknown) => { + if (destroyed) return; + destroyed = true; + logger.error( + "Failed to set %s on pool connection; destroying client to prevent unguarded use: %O", + label, + err, + ); + // `release(true)` removes the client from the pool entirely. pg will + // build a fresh connection on next acquire and re-fire `connect`. + const maybeRelease = ( + client as unknown as { release?: (destroy?: boolean) => void } + ).release; + try { + maybeRelease?.call(client, true); + } catch (releaseErr) { + logger.error("Failed to destroy pool client: %O", releaseErr); + } + }; + client + .query(`SET application_name = '${applicationName}'`) + .catch((err) => destroy("application_name", err)); + if (Number.isFinite(ms) && ms > 0) { + client + .query(`SET statement_timeout = ${Math.floor(ms)}`) + .catch((err) => destroy("statement_timeout", err)); + } + }); +} + +/** + * Build a per-session `application_name` string. + */ +function applicationNameForSession(): string { + const appName = process.env.DATABRICKS_APP_NAME; + // Sanitize: only allow common identifier characters in the discriminator. + const safeAppName = appName?.replace(/[^A-Za-z0-9._-]/g, "_") ?? ""; + const composed = safeAppName + ? `${APPLICATION_NAME}:${safeAppName}` + : APPLICATION_NAME; + return composed.slice(0, 60); +} + +function startPoolStatsLog(pool: Pool, label: string): void { + const intervalMs = 30_000; + const handle = setInterval(() => { + logger.info( + "Pool stats [%s] total=%d idle=%d waiting=%d", + label, + pool.totalCount, + pool.idleCount, + pool.waitingCount, + ); + }, intervalMs); + if (typeof handle.unref === "function") handle.unref(); +} diff --git a/packages/appkit/src/plugins/database/defaults.ts b/packages/appkit/src/plugins/database/defaults.ts new file mode 100644 index 000000000..ab45c90cb --- /dev/null +++ b/packages/appkit/src/plugins/database/defaults.ts @@ -0,0 +1,26 @@ +/** + * Connection pool defaults for the service-principal pool. + * 10 connections in the pool at maximum + * 30 seconds to keep the connection alive + * 3 seconds to acquire a connection + * 1000 uses to recycle the connection + */ +export const POOL_DEFAULTS = { + max: 10, + idleTimeoutMillis: 30_000, + connectionTimeoutMillis: 3_000, + maxUses: 1000, +}; + +/** + * Default Postgres `statement_timeout` set on every pooled connection. + * Caps runaway queries server-side; pairs with the AppKit timeout interceptor. + */ +export const STATEMENT_TIMEOUT_DEFAULT_MS = 15_000; + +/** + * Postgres `application_name` advertised on every connection. Surfaces in + * `pg_stat_activity` and Lakebase audit so an operator can attribute + * connections back to AppKit. + */ +export const APPLICATION_NAME = "appkit:database"; diff --git a/packages/appkit/src/plugins/database/index.ts b/packages/appkit/src/plugins/database/index.ts new file mode 100644 index 000000000..bbbfc8f4d --- /dev/null +++ b/packages/appkit/src/plugins/database/index.ts @@ -0,0 +1,9 @@ +export * from "./database"; +export type { + DatabasePoolTuning, + EntityHooks, + HookContext, + HttpAccess, + HttpEntityOverride, + IDatabaseConfig, +} from "./types"; diff --git a/packages/appkit/src/plugins/database/manifest.json b/packages/appkit/src/plugins/database/manifest.json new file mode 100644 index 000000000..fb8c91700 --- /dev/null +++ b/packages/appkit/src/plugins/database/manifest.json @@ -0,0 +1,104 @@ +{ + "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + "name": "database", + "displayName": "Database", + "description": "Lakebase Postgres pool + schema declaration via defineSchema. CRUD/OBO/RLS surface ships incrementally in subsequent stack layers; this layer provides the pool, schema convention loader, and column metadata.", + "hidden": false, + "stability": "beta", + "resources": { + "required": [ + { + "type": "postgres", + "alias": "Application Database", + "resourceKey": "database", + "description": "Lakebase Postgres instance for application data. Schema lives at config/database/schema.ts.", + "permission": "CAN_CONNECT_AND_CREATE", + "fields": { + "branch": { + "description": "Full Lakebase Postgres branch resource name.", + "examples": ["projects/{project-id}/branches/{branch-id}"] + }, + "database": { + "description": "Full Lakebase Postgres database resource name." + }, + "host": { + "env": "PGHOST", + "localOnly": true, + "resolve": "postgres:host", + "description": "Postgres host for local development." + }, + "databaseName": { + "env": "PGDATABASE", + "localOnly": true, + "resolve": "postgres:databaseName", + "description": "Postgres database name for local development." + }, + "endpointPath": { + "env": "LAKEBASE_ENDPOINT", + "bundleIgnore": true, + "resolve": "postgres:endpointPath", + "description": "Lakebase endpoint resource name." + }, + "port": { + "env": "PGPORT", + "localOnly": true, + "value": "5432", + "description": "Postgres port." + }, + "sslmode": { + "env": "PGSSLMODE", + "localOnly": true, + "value": "require", + "description": "Postgres SSL mode." + } + } + } + ], + "optional": [] + }, + "config": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "connection": { + "type": "object", + "additionalProperties": true, + "description": "Optional pg.Pool overrides forwarded to createLakebasePool. Avoid setting `password`/`user` here — Lakebase uses OAuth." + }, + "statementTimeoutMs": { + "type": "number", + "description": "Server-side `statement_timeout` (ms) applied per pool connection. Defaults to 15_000." + }, + "tolerateSetupFailure": { + "type": "boolean", + "description": "If true, plugin boot continues with an empty schema when config/database/schema.ts fails to load. Off by default." + }, + "oboPoolMax": { + "type": "number", + "description": "Max number of distinct OBO pools held in the LRU. Worst-case fan-out is oboPoolMax × OBO_POOL_DEFAULTS.max + POOL_DEFAULTS.max connections per app instance." + }, + "http": { "type": "object", "additionalProperties": true }, + "hooks": { "type": "object", "additionalProperties": true }, + "cache": { + "type": "object", + "properties": { + "list": { + "type": "object", + "properties": { "ttl": { "type": "number" } } + }, + "find": { + "type": "object", + "properties": { "ttl": { "type": "number" } } + }, + "count": { + "type": "object", + "properties": { "ttl": { "type": "number" } } + } + } + } + } + } + }, + "onSetupMessage": "Database plugin installed. Configure your schema in config/database/schema.ts via defineSchema(). The plugin currently exposes pool access (appkit.database.getPool()); CRUD, OBO, and RLS surfaces ship in subsequent stack layers." +} diff --git a/packages/appkit/src/plugins/database/tests/convention.test.ts b/packages/appkit/src/plugins/database/tests/convention.test.ts new file mode 100644 index 000000000..288918948 --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/convention.test.ts @@ -0,0 +1,87 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { defineSchema, id } from "../../../database"; +import { ConfigurationError } from "../../../errors"; +import { isSchema, loadSchemaByConvention, pathExists } from "../convention"; + +describe("database schema convention loader", () => { + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(path.join(tmpdir(), "appkit-db-schema-")); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + async function touch(relativePath: string): Promise { + const absolutePath = path.join(cwd, relativePath); + await mkdir(path.dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, "export default schema;\n"); + return absolutePath; + } + + test("returns null when no schema file exists", async () => { + await expect(loadSchemaByConvention({ cwd })).resolves.toBeNull(); + }); + + test("loads schema.ts before schema/index.ts", async () => { + const defaultPath = await touch("config/database/schema.ts"); + await touch("config/database/schema/index.ts"); + + const schema = defineSchema(({ table }) => ({ + user: table("user", { id: id() }), + })); + const importer = vi.fn(async () => ({ default: schema })); + + const result = await loadSchemaByConvention({ cwd, importer }); + + expect(result).toEqual({ schema, schemaPath: defaultPath }); + expect(importer).toHaveBeenCalledWith(defaultPath); + }); + + test("loads production dist schema path", async () => { + const distPath = await touch("dist/config/database/schema.js"); + const schema = defineSchema(({ table }) => ({ + user: table("user", { id: id() }), + })); + + const result = await loadSchemaByConvention({ + cwd, + importer: vi.fn(async () => ({ default: schema })), + }); + + expect(result?.schemaPath).toBe(distPath); + expect(result?.schema).toBe(schema); + }); + + test("throws a configuration error for invalid schema modules", async () => { + await touch("config/database/schema.ts"); + + await expect( + loadSchemaByConvention({ + cwd, + importer: vi.fn(async () => ({ default: { nope: true } })), + }), + ).rejects.toThrow(ConfigurationError); + await expect( + loadSchemaByConvention({ + cwd, + importer: vi.fn(async () => ({ default: { nope: true } })), + }), + ).rejects.toThrow(/defineSchema/); + }); + + test("recognizes AppKit schema objects", async () => { + const schema = defineSchema(({ table }) => ({ + user: table("user", { id: id() }), + })); + + expect(isSchema(schema)).toBe(true); + expect(isSchema({ $tables: {} })).toBe(false); + expect(await pathExists(path.join(cwd, "missing.ts"))).toBe(false); + }); +}); diff --git a/packages/appkit/src/plugins/database/tests/plugin.test.ts b/packages/appkit/src/plugins/database/tests/plugin.test.ts new file mode 100644 index 000000000..6f67359f3 --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/plugin.test.ts @@ -0,0 +1,169 @@ +import type { Pool } from "pg"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { createLakebasePool } from "../../../connectors/lakebase"; +import { defineSchema, id } from "../../../database"; +import { loadSchemaByConvention } from "../convention"; +import { database } from "../database"; + +vi.mock("../../../connectors/lakebase", () => ({ + createLakebasePool: vi.fn(), +})); + +vi.mock("../../../cache", () => ({ + CacheManager: { + getInstanceSync: vi.fn(() => ({ + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + getOrExecute: vi.fn(async (_key: unknown[], fn: () => Promise) => + fn(), + ), + generateKey: vi.fn(), + })), + }, +})); + +vi.mock("../convention", () => ({ + loadSchemaByConvention: vi.fn(), +})); + +const pool = { + end: vi.fn(async () => undefined), + on: vi.fn(), +} as unknown as Pool; + +type DatabasePluginInstance = InstanceType< + ReturnType["plugin"] +>; + +function createPlugin(config: Parameters[0] = {}) { + const pluginData = database(config); + return new pluginData.plugin(pluginData.config) as DatabasePluginInstance; +} + +describe("DatabasePlugin", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createLakebasePool).mockReturnValue(pool); + vi.mocked(loadSchemaByConvention).mockResolvedValue(null); + }); + + test("plugin factory exposes the database plugin name", () => { + expect(database().name).toBe("database"); + }); + + test("initializes the pool with defaults and config overrides", async () => { + const plugin = createPlugin({ + connection: { max: 3 }, + }); + + await plugin.setup(); + + expect(createLakebasePool).toHaveBeenCalledWith({ + max: 3, + idleTimeoutMillis: 30_000, + // POOL_DEFAULTS.connectionTimeoutMillis was lowered to fail-fast on + // pool acquire so the timeout interceptor + retry can re-route under + // saturation (was 10_000). + connectionTimeoutMillis: 3_000, + maxUses: 1000, + }); + expect(plugin.exports()).toEqual({ getPool: expect.any(Function) }); + expect((plugin.exports() as { getPool: () => Pool }).getPool()).toBe(pool); + }); + + test("stores convention-loaded schemas when present", async () => { + const schema = defineSchema(({ table }) => ({ + user: table("user", { id: id() }), + })); + vi.mocked(loadSchemaByConvention).mockResolvedValue({ + schema, + schemaPath: "/app/config/database/schema.ts", + }); + + const plugin = createPlugin(); + await plugin.setup(); + + expect( + (plugin as unknown as { schema: typeof schema; schemaPath: string }) + .schema, + ).toBe(schema); + expect( + (plugin as unknown as { schema: typeof schema; schemaPath: string }) + .schemaPath, + ).toBe("/app/config/database/schema.ts"); + }); + + test("closes the pool during shutdown", async () => { + const plugin = createPlugin(); + await plugin.setup(); + + await plugin.abortActiveOperations(); + + expect(pool.end).toHaveBeenCalled(); + }); + + test("abortActiveOperations awaits pool.end so SIGTERM doesn't cut drain", async () => { + let drainResolve: (() => void) | undefined; + const drainGate = new Promise((resolve) => { + drainResolve = resolve; + }); + const slowPool = { + end: vi.fn(() => drainGate), + on: vi.fn(), + } as unknown as Pool; + vi.mocked(createLakebasePool).mockReturnValueOnce(slowPool); + + const plugin = createPlugin(); + await plugin.setup(); + + const promise = plugin.abortActiveOperations(); + let settled = false; + promise?.then(() => { + settled = true; + }); + await new Promise((r) => setTimeout(r, 10)); + expect(settled).toBe(false); + drainResolve?.(); + await promise; + expect(settled).toBe(true); + }); + + test("setup applies session defaults (application_name + statement_timeout) on every new connection", async () => { + const plugin = createPlugin({ statementTimeoutMs: 7_000 }); + await plugin.setup(); + + expect(pool.on).toHaveBeenCalledWith("connect", expect.any(Function)); + const handler = vi + .mocked(pool.on) + .mock.calls.find( + ([event]) => event === "connect", + )?.[1] as unknown as (client: { + query: ReturnType; + }) => void; + const client = { query: vi.fn(async () => ({})) }; + handler(client); + expect(client.query).toHaveBeenCalledWith( + "SET application_name = 'appkit:database'", + ); + expect(client.query).toHaveBeenCalledWith("SET statement_timeout = 7000"); + }); + + test("schema-load failure is decorated and re-raised by default", async () => { + vi.mocked(loadSchemaByConvention).mockRejectedValue( + new Error("syntax error in schema.ts"), + ); + + const plugin = createPlugin(); + await expect(plugin.setup()).rejects.toThrow("syntax error in schema.ts"); + }); + + test("schema-load failure is swallowed when tolerateSetupFailure is set", async () => { + vi.mocked(loadSchemaByConvention).mockRejectedValue( + new Error("syntax error in schema.ts"), + ); + + const plugin = createPlugin({ tolerateSetupFailure: true }); + await expect(plugin.setup()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/appkit/src/plugins/database/types.ts b/packages/appkit/src/plugins/database/types.ts new file mode 100644 index 000000000..a9838a008 --- /dev/null +++ b/packages/appkit/src/plugins/database/types.ts @@ -0,0 +1,148 @@ +import type { BasePluginConfig } from "shared"; + +/** + * Pool tuning exposed via `IDatabaseConfig.connection`. + * Intentionally excludes auth fields; Lakebase resolves credentials via OAuth + env. + */ +export interface DatabasePoolTuning { + /** Maximum number of clients in the pool. */ + max?: number; + /** Idle timeout (ms) before closing an idle client. */ + idleTimeoutMillis?: number; + /** Connection acquire timeout (ms). */ + connectionTimeoutMillis?: number; + /** + * Recycle a client after N uses to reduce stale-token issues. + */ + maxUses?: number; + /** + * Statement timeout (ms) set per new connection; top-level setting wins. + */ + statement_timeout?: number; + /** Random jitter (ms) added to statement timeout when supported. */ + statement_timeout_jitter_ms?: number; +} + +/** + * HTTP access control for entity operations. + * @public + */ +export type HttpAccess = "public" | "obo" | "service" | false; + +/** + * HTTP access control overrides for entity operations. + * @public + */ +export interface HttpEntityOverride { + /** Access mode for list. */ + list?: HttpAccess; + /** Access mode for find. */ + find?: HttpAccess; + /** Access mode for count. */ + count?: HttpAccess; + /** Access mode for create. */ + create?: HttpAccess; + /** Access mode for update. */ + update?: HttpAccess; + /** Access mode for delete. */ + delete?: HttpAccess; +} + +/** + * Context for entity hooks. + * @public + */ +export interface HookContext { + /** Request object. */ + req?: import("express").Request; + /** Entity name. */ + entity?: string; + /** User ID. */ + userId?: string; +} + +/** + * Entity hooks. + * @public + */ +export interface EntityHooks { + /** Runs before create. */ + beforeCreate?: ( + data: Record, + ctx: HookContext, + ) => Promise | void>; + /** Runs after create. */ + afterCreate?: ( + row: Record, + ctx: HookContext, + ) => Promise; + /** Runs before update. */ + beforeUpdate?: ( + id: unknown, + patch: Record, + ctx: HookContext, + ) => Promise | void>; + /** Runs after update. */ + afterUpdate?: ( + row: Record, + ctx: HookContext, + ) => Promise; + /** Runs before delete. */ + beforeDelete?: (id: unknown, ctx: HookContext) => Promise; + /** Runs after delete. */ + afterDelete?: (id: unknown, ctx: HookContext) => Promise; +} + +/** + * Cache action settings. + * @public + */ +export interface CacheActionSettings { + /** Cache TTL in seconds. */ + ttl?: number; +} + +/** + * Cache settings. + * @public + */ +export interface CacheSettings { + /** Cache settings for list. */ + list?: CacheActionSettings; + /** Cache settings for find. */ + find?: CacheActionSettings; + /** Cache settings for count. */ + count?: CacheActionSettings; +} + +/** + * Database configuration. + * @public + */ +export interface IDatabaseConfig extends BasePluginConfig { + /** + * Pool tuning forwarded to `createLakebasePool` (no auth fields). + */ + connection?: DatabasePoolTuning; + /** Per-entity HTTP access overrides. */ + http?: Record; + /** Per-entity lifecycle hooks. */ + hooks?: Record; + /** Per-operation cache settings. */ + cache?: CacheSettings; + /** + * Max distinct OBO pools kept alive. Defaults to 25. + * Worst-case fan-out is `(1 + oboPoolMax) × poolMax`. + */ + oboPoolMax?: number; + /** + * Postgres `statement_timeout` (ms) for pooled connections. Defaults to 15s. + * Set `0` to disable server-side timeout (client timeout still applies). + */ + statementTimeoutMs?: number; + /** + * If true, `setup()` schema/drift failures are logged and ignored. + * Defaults to false (fail closed). + */ + tolerateSetupFailure?: boolean; +} diff --git a/packages/appkit/src/plugins/server/index.ts b/packages/appkit/src/plugins/server/index.ts index 50e9d991f..4206f1d1e 100644 --- a/packages/appkit/src/plugins/server/index.ts +++ b/packages/appkit/src/plugins/server/index.ts @@ -374,6 +374,13 @@ export class ServerPlugin extends Plugin { private async _gracefulShutdown() { logger.info("Starting graceful shutdown..."); + // 15 seconds to force the process to exit + const forceExit = setTimeout(() => { + logger.debug("Force shutdown after timeout"); + process.exit(1); + }, 15000); + forceExit.unref(); + if (this.viteDevServer) { await this.viteDevServer.close(); } @@ -382,20 +389,48 @@ export class ServerPlugin extends Plugin { this.remoteTunnelController.cleanup(); } - // 1. abort active operations from plugins + // 1. abort active operations from plugins; await any returned promises so + // pool drains finish before we trigger process.exit on shutdown timeout. + // Each drain is capped at DRAIN_TIMEOUT_MS so a single hung pool can't + // starve the rest. Total wall time is bounded by the force-exit timer + // above. if (this.config.plugins) { - for (const plugin of Object.values(this.config.plugins)) { - if (plugin.abortActiveOperations) { + const DRAIN_TIMEOUT_MS = 13_000; + const drains = Object.values(this.config.plugins) + .map((plugin) => { + if (!plugin.abortActiveOperations) return null; try { - plugin.abortActiveOperations(); + const drain = Promise.resolve(plugin.abortActiveOperations()); + const timeout = new Promise((resolve) => { + const handle = setTimeout(() => { + logger.warn( + "Drain timed out for plugin %s after %d ms", + plugin.name, + DRAIN_TIMEOUT_MS, + ); + resolve(); + }, DRAIN_TIMEOUT_MS); + handle.unref(); + }); + return Promise.race([drain, timeout]).catch((err) => { + logger.error( + "Error aborting operations for plugin %s: %O", + plugin.name, + err, + ); + }); } catch (err) { logger.error( "Error aborting operations for plugin %s: %O", plugin.name, err, ); + return null; } - } + }) + .filter((p): p is Promise => p !== null); + if (drains.length > 0) { + await Promise.all(drains); } } @@ -405,12 +440,6 @@ export class ServerPlugin extends Plugin { logger.debug("Server closed gracefully"); process.exit(0); }); - - // 3. timeout to force shutdown after 15 seconds - setTimeout(() => { - logger.debug("Force shutdown after timeout"); - process.exit(1); - }, 15000); } else { process.exit(0); } diff --git a/packages/shared/src/plugin.ts b/packages/shared/src/plugin.ts index 9fa8066c0..078f5e57f 100644 --- a/packages/shared/src/plugin.ts +++ b/packages/shared/src/plugin.ts @@ -13,7 +13,7 @@ export type { ResourceFieldEntry }; export interface BasePlugin { name: string; - abortActiveOperations?(): void; + abortActiveOperations?(): void | Promise; setup(): Promise; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f5d14bf8..386451460 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -302,6 +302,12 @@ importers: dotenv: specifier: 16.6.1 version: 16.6.1 + drizzle-orm: + specifier: 0.45.1 + version: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(pg@8.18.0) + drizzle-zod: + specifier: ^0.8.3 + version: 0.8.3(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(pg@8.18.0))(zod@4.3.6) express: specifier: 4.22.0 version: 4.22.0 @@ -5554,7 +5560,7 @@ packages: basic-ftp@5.0.5: resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} engines: {node: '>=10.0.0'} - deprecated: Security vulnerability fixed in 5.2.0, please upgrade + deprecated: Security vulnerability fixed in 5.2.1, please upgrade batch@0.6.1: resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==} @@ -6668,6 +6674,7 @@ packages: dottie@2.0.6: resolution: {integrity: sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. drizzle-orm@0.45.1: resolution: {integrity: sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==} @@ -6761,6 +6768,12 @@ packages: sqlite3: optional: true + drizzle-zod@0.8.3: + resolution: {integrity: sha512-66yVOuvGhKJnTdiqj1/Xaaz9/qzOdRJADpDa68enqS6g3t0kpNkwNYjUuaeXgZfO/UWuIM9HIhSlJ6C5ZraMww==} + peerDependencies: + drizzle-orm: '>=0.36.0' + zod: ^3.25.0 || ^4.0.0 + dts-resolver@2.1.3: resolution: {integrity: sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==} engines: {node: '>=20.19.0'} @@ -19280,6 +19293,11 @@ snapshots: '@types/pg': 8.16.0 pg: 8.18.0 + drizzle-zod@0.8.3(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(pg@8.18.0))(zod@4.3.6): + dependencies: + drizzle-orm: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(pg@8.18.0) + zod: 4.3.6 + dts-resolver@2.1.3(oxc-resolver@11.19.1): optionalDependencies: oxc-resolver: 11.19.1 diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index d3c8702f9..be0b61f8b 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -26,6 +26,67 @@ "optional": [] } }, + "database": { + "name": "database", + "displayName": "Database", + "description": "Lakebase Postgres pool + schema declaration via defineSchema. CRUD/OBO/RLS surface ships incrementally in subsequent stack layers; this layer provides the pool, schema convention loader, and column metadata.", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "postgres", + "alias": "Application Database", + "resourceKey": "database", + "description": "Lakebase Postgres instance for application data. Schema lives at config/database/schema.ts.", + "permission": "CAN_CONNECT_AND_CREATE", + "fields": { + "branch": { + "description": "Full Lakebase Postgres branch resource name.", + "examples": [ + "projects/{project-id}/branches/{branch-id}" + ] + }, + "database": { + "description": "Full Lakebase Postgres database resource name." + }, + "host": { + "env": "PGHOST", + "localOnly": true, + "resolve": "postgres:host", + "description": "Postgres host for local development." + }, + "databaseName": { + "env": "PGDATABASE", + "localOnly": true, + "resolve": "postgres:databaseName", + "description": "Postgres database name for local development." + }, + "endpointPath": { + "env": "LAKEBASE_ENDPOINT", + "bundleIgnore": true, + "resolve": "postgres:endpointPath", + "description": "Lakebase endpoint resource name." + }, + "port": { + "env": "PGPORT", + "localOnly": true, + "value": "5432", + "description": "Postgres port." + }, + "sslmode": { + "env": "PGSSLMODE", + "localOnly": true, + "value": "require", + "description": "Postgres SSL mode." + } + } + } + ], + "optional": [] + }, + "onSetupMessage": "Database plugin installed. Configure your schema in config/database/schema.ts via defineSchema(). The plugin currently exposes pool access (appkit.database.getPool()); CRUD, OBO, and RLS surfaces ship in subsequent stack layers.", + "stability": "beta" + }, "files": { "name": "files", "displayName": "Files Plugin",