diff --git a/.gitignore b/.gitignore index 5d417368..66e336f9 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 34034537..b76a58f8 100644 --- a/docs/docs/api/appkit/Class.Plugin.md +++ b/docs/docs/api/appkit/Class.Plugin.md @@ -208,12 +208,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 00000000..2384908f --- /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 00000000..54e940bc --- /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 00000000..db43a216 --- /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 00000000..4aef57b9 --- /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 00000000..5a7f7fbe --- /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 00000000..ac0f85c5 --- /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 00000000..8a8f838a --- /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 00000000..5182fa88 --- /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 00000000..be89ad9e --- /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 00000000..b69d038a --- /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 00000000..962d70e6 --- /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 00000000..d9b6f12b --- /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 00000000..af3e26db --- /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 00000000..9bc47e4a --- /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 00000000..0667bde4 --- /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 00000000..8da0ec4b --- /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 00000000..d6053ae1 --- /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 00000000..dd622a5e --- /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 00000000..a2f618da --- /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 00000000..5b89f196 --- /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 00000000..df26cdec --- /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 00000000..9c0ef60e --- /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 00000000..47f3b8be --- /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 00000000..1f937e23 --- /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 00000000..6103dd4a --- /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/packages/appkit/package.json b/packages/appkit/package.json index a5cb234d..c4189dc5 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", "js-yaml": "4.1.1", diff --git a/packages/appkit/src/beta.ts b/packages/appkit/src/beta.ts index 3f5bba80..2cc7fabe 100644 --- a/packages/appkit/src/beta.ts +++ b/packages/appkit/src/beta.ts @@ -73,3 +73,11 @@ export { } from "./plugins/agents"; 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 00000000..55d54b94 --- /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 00000000..3ba3e017 --- /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, + ColumnKind, + 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, + pgKind: "serial", + }); +} + +/** + * Create a text column. + * @returns The wrapped column chain. + */ +export function text(): AppKitColumnChain { + return wrap(pgText(), { pgKind: "text" }); +} + +/** + * Create an integer column. + * @returns The wrapped column chain. + */ +export function integer(): AppKitColumnChain { + return wrap(pgInteger(), { pgKind: "integer" }); +} + +/** + * Create a bigint column. + * @returns The wrapped column chain. + */ +export function bigint(): AppKitColumnChain { + return wrap(pgBigint({ mode: "number" }), { pgKind: "bigint" }); +} + +/** + * Create a boolean column. + * @returns The wrapped column chain. + */ +export function boolean(): AppKitColumnChain { + return wrap(pgBoolean(), { pgKind: "boolean" }); +} + +/** + * Create a timestamp column. + * @returns The wrapped column chain. + */ +export function timestamp(): AppKitColumnChain { + return wrap(pgTimestamp({ mode: "date" }), { pgKind: "timestamp" }); +} + +/** + * Create a jsonb column. + * @returns The wrapped column chain. + */ +export function jsonb(): AppKitColumnChain { + return wrap(pgJsonb(), { pgKind: "jsonb" }); +} + +/** + * Create a uuid column. + * @returns The wrapped column chain. + */ +export function uuid(): AppKitColumnChain { + return wrap(pgUuid(), { pgKind: "uuid" }); +} + +/** + * 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 }), { pgKind: "varchar" }); +} + +/** + * 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(), { pgKind: "enum" }); +} + +/** Drizzle column constructor matching a `ColumnKind`, used by `fk()`. */ +function buildFkColumn(kind: ColumnKind): unknown { + switch (kind) { + case "text": + return pgText(); + case "varchar": + return pgVarchar({ length: 255 }); + case "uuid": + return pgUuid(); + case "bigint": + case "bigserial": + return pgBigint({ mode: "number" }); + case "boolean": + return pgBoolean(); + case "jsonb": + return pgJsonb(); + case "timestamp": + return pgTimestamp({ mode: "date" }); + case "enum": + // Enums always live in the target table; FK column reuses text storage. + return pgText(); + default: + return pgInteger(); + } +} + +/** + * 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 mirrors the target's `pgKind` (e.g. `text`, `uuid`, + * `bigint`), falling back to `integer` if the target is unstamped. + * + * @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 kind = target.$meta.pgKind ?? "integer"; + const baseChain = wrap(buildFkColumn(kind), { + pgKind: kind, + // Live target reference; buildTable() resolves to toTable/toColumn after + // all tables have been built and column names stamped. + references: { target }, + }); + + const fkChain = baseChain as FkColumnChain; + Object.assign(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; + }, + }); + + 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 00000000..89ed323b --- /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 00000000..03791760 --- /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 00000000..72f9bc8c --- /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 00000000..99f37d36 --- /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 00000000..0d27dcdd --- /dev/null +++ b/packages/appkit/src/database/schema-builder/types.ts @@ -0,0 +1,137 @@ +import type { z } from "zod"; + +/** + * Symbol for identifying AppKit table metadata. + */ +export const APPKIT_TABLE = Symbol.for("appkit.database.table"); + +/** + * Source pg type tag stamped by each column helper. `fk()` reads it from the + * target column to mirror the type instead of hardcoding `integer`. + */ +export type ColumnKind = + | "text" + | "varchar" + | "uuid" + | "integer" + | "bigint" + | "serial" + | "bigserial" + | "boolean" + | "jsonb" + | "timestamp" + | "enum"; + +/** + * 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 Source pg type tag — used by `fk()` to mirror target type. */ + pgKind?: ColumnKind; + /** @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 00000000..62b777a5 --- /dev/null +++ b/packages/appkit/src/database/tests/define-schema.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, test } from "vitest"; +import { + APPKIT_TABLE, + boolean, + defineSchema, + enumeration, + fk, + id, + integer, + jsonb, + text, + timestamp, + uuid, +} 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("fk() inherits target pgType for text primary keys", () => { + const schema = defineSchema(({ table }) => { + const caseCols = { + case_id: text().primaryKey(), + status: text().notNull(), + }; + const cases = table("cases", caseCols); + const activity_log = table("activity_log", { + log_id: text().primaryKey(), + case_id: fk(caseCols.case_id).notNull(), + action: text().notNull(), + }); + + return { cases, activity_log }; + }); + + expect(schema.activity_log.$columns.case_id.pgKind).toBe("text"); + expect(schema.activity_log.$relations).toEqual([ + { fromColumn: "case_id", toTable: "cases", toColumn: "case_id" }, + ]); + }); + + test("fk() inherits target pgType for uuid primary keys", () => { + const schema = defineSchema(({ table }) => { + const orgCols = { id: uuid().primaryKey() }; + const orgs = table("orgs", orgCols); + const members = table("members", { + id: uuid().primaryKey(), + org_id: fk(orgCols.id).notNull(), + }); + + return { orgs, members }; + }); + + expect(schema.members.$columns.org_id.pgKind).toBe("uuid"); + expect(schema.members.$relations).toEqual([ + { fromColumn: "org_id", toTable: "orgs", toColumn: "id" }, + ]); + }); + + 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 b2538073..e9587313 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -25,7 +25,7 @@ export type { RequestedClaims, RequestedResource, } from "./connectors/lakebase"; -// Lakebase Autoscaling connector + export { createLakebasePool, createLakebasePoolManager, @@ -38,6 +38,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 4c00c41e..380e74ed 100644 --- a/packages/appkit/src/plugin/plugin.ts +++ b/packages/appkit/src/plugin/plugin.ts @@ -287,7 +287,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 82f6c4a7..f4c77235 100644 --- a/packages/appkit/src/plugins/beta-exports.generated.ts +++ b/packages/appkit/src/plugins/beta-exports.generated.ts @@ -6,3 +6,4 @@ // manifests and the synced appkit.plugins.json. export { agents } from "./agents"; +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 00000000..46d79f22 --- /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 00000000..1cc527d5 --- /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 00000000..ab45c90c --- /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 00000000..bbbfc8f4 --- /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 00000000..fb8c9170 --- /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 00000000..28891894 --- /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 00000000..6f67359f --- /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 00000000..a9838a00 --- /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 e66abf5a..1c9e755e 100644 --- a/packages/appkit/src/plugins/server/index.ts +++ b/packages/appkit/src/plugins/server/index.ts @@ -401,6 +401,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(); } @@ -411,21 +418,49 @@ export class ServerPlugin extends Plugin { TelemetryReporter.getInstance()?.stop(); - // 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. const shutdownPlugins = this.context?.getPlugins(); if (shutdownPlugins) { - for (const plugin of shutdownPlugins.values()) { - if (plugin.abortActiveOperations) { + const DRAIN_TIMEOUT_MS = 13_000; + const drains = Array.from(shutdownPlugins.values()) + .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); } } @@ -435,12 +470,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 651840c7..4ed540f8 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 c576bd74..ed841213 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 @@ -6770,6 +6776,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'} @@ -19287,6 +19299,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 131bf409..53e4d598 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -51,6 +51,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",