From b7602da21fb34010fcba40f5b852b382a0a84abd Mon Sep 17 00:00:00 2001 From: ditadi Date: Sat, 2 May 2026 16:54:40 +0100 Subject: [PATCH 01/19] feat(connectors/lakebase): add createLakebasePostgrestClient Signed-off-by: ditadi --- packages/appkit/package.json | 1 + .../appkit/src/connectors/lakebase/index.ts | 8 +++ .../src/connectors/lakebase/postgrest.ts | 72 +++++++++++++++++++ .../lakebase/tests/postgrest.test.ts | 65 +++++++++++++++++ packages/appkit/src/index.ts | 4 ++ pnpm-lock.yaml | 18 +++++ 6 files changed, 168 insertions(+) create mode 100644 packages/appkit/src/connectors/lakebase/postgrest.ts create mode 100644 packages/appkit/src/connectors/lakebase/tests/postgrest.test.ts diff --git a/packages/appkit/package.json b/packages/appkit/package.json index c144b1118..2be940bb4 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -57,6 +57,7 @@ "@ast-grep/napi": "0.37.0", "@databricks/lakebase": "workspace:*", "@databricks/sdk-experimental": "0.16.0", + "@neondatabase/postgrest-js": "0.1.0-alpha.2", "@opentelemetry/api": "1.9.0", "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/auto-instrumentations-node": "0.67.2", diff --git a/packages/appkit/src/connectors/lakebase/index.ts b/packages/appkit/src/connectors/lakebase/index.ts index c58b7a8cb..9cef6b7dd 100644 --- a/packages/appkit/src/connectors/lakebase/index.ts +++ b/packages/appkit/src/connectors/lakebase/index.ts @@ -35,3 +35,11 @@ export { RequestedClaimsPermissionSet, type RequestedResource, } from "@databricks/lakebase"; + +// Export Lakebase PostgREST client related types and functions. +export { + createLakebasePostgrestClient, + type LakebasePostgrestClient, + type LakebasePostgrestClientConfig, + type LakebaseTokenResolver, +} from "./postgrest"; diff --git a/packages/appkit/src/connectors/lakebase/postgrest.ts b/packages/appkit/src/connectors/lakebase/postgrest.ts new file mode 100644 index 000000000..13473a30d --- /dev/null +++ b/packages/appkit/src/connectors/lakebase/postgrest.ts @@ -0,0 +1,72 @@ +import { + fetchWithToken, + NeonPostgrestClient, +} from "@neondatabase/postgrest-js"; +import { ConfigurationError } from "@/errors"; +import { createLogger } from "@/logging/logger"; + +const logger = createLogger("connectors:lakebase:postgrest"); + +/** + * A function that resolves a Lakebase token. + * @example + * ```ts + * const resolveToken = async () => { + * const token = await getLakebaseServicePrincipalToken(); + * return token; + * }; + * ``` + */ +export type LakebaseTokenResolver = () => Promise; + +/** + * Configuration for creating a Lakebase PostgREST client. + * @example + * ```ts + * const config: LakebasePostgrestClientConfig = { + * dataApiUrl: "https://data-api.lakebase.databricks.com", + * schema: "app", + * resolveToken: async () => { + * const token = await getLakebaseServicePrincipalToken(); + * return token; + * }, + * }; + * ``` + */ +export interface LakebasePostgrestClientConfig { + dataApiUrl?: string; + schema?: string; + resolveToken: LakebaseTokenResolver; + fetch?: typeof fetch; +} + +// Add unknown type to avoid importing NeonPostgrestClient type. +export type LakebasePostgrestClient = unknown; + +/** + * Create a Lakebase PostgREST client. + * + * @param config - Configuration for creating a Lakebase PostgREST client. + * @returns A Lakebase PostgREST client. + */ +export function createLakebasePostgrestClient( + config: LakebasePostgrestClientConfig, +): LakebasePostgrestClient { + const dataApiUrl = config.dataApiUrl ?? process.env.LAKEBASE_DATA_API_URL; + + if (!dataApiUrl) { + throw ConfigurationError.missingEnvVar("LAKEBASE_DATA_API_URL"); + } + + logger.debug("createLakebasePostgrestClient: dataApiUrl", dataApiUrl); + + return new NeonPostgrestClient({ + dataApiUrl, + options: { + db: { schema: config.schema ?? "app" }, + global: { + fetch: fetchWithToken(config.resolveToken, config.fetch), + }, + }, + }); +} diff --git a/packages/appkit/src/connectors/lakebase/tests/postgrest.test.ts b/packages/appkit/src/connectors/lakebase/tests/postgrest.test.ts new file mode 100644 index 000000000..16e7c4c14 --- /dev/null +++ b/packages/appkit/src/connectors/lakebase/tests/postgrest.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { ConfigurationError } from "../../../errors"; +import { createLakebasePostgrestClient } from "../postgrest"; + +const mocks = vi.hoisted(() => ({ + fetchWithToken: vi.fn((_resolveToken: unknown, _fetch?: unknown) => ({ + wrappedFetch: true, + })), + NeonPostgrestClient: vi.fn(function MockNeonPostgrestClient( + this: { config?: unknown }, + config: unknown, + ) { + this.config = config; + }), +})); + +vi.mock("@neondatabase/postgrest-js", () => mocks); + +describe("createLakebasePostgrestClient", () => { + afterEach(() => { + vi.clearAllMocks(); + delete process.env.LAKEBASE_DATA_API_URL; + }); + + test("throws an AppKit configuration error when no Data API URL is configured", () => { + expect(() => + createLakebasePostgrestClient({ resolveToken: async () => "tok" }), + ).toThrow(ConfigurationError); + }); + + test("creates a PostgREST client with token-aware fetch", () => { + const resolveToken = vi.fn(async () => "tok"); + const fetchSpy = vi.fn(); + + const client = createLakebasePostgrestClient({ + dataApiUrl: "https://example.test/rest/v1", + schema: "custom", + resolveToken, + fetch: fetchSpy as unknown as typeof fetch, + }) as { config: Record }; + + expect(mocks.fetchWithToken).toHaveBeenCalledWith(resolveToken, fetchSpy); + expect(mocks.NeonPostgrestClient).toHaveBeenCalledTimes(1); + expect(client.config).toEqual({ + dataApiUrl: "https://example.test/rest/v1", + options: { + db: { schema: "custom" }, + global: { fetch: { wrappedFetch: true } }, + }, + }); + }); + + test("falls back to LAKEBASE_DATA_API_URL and app schema", () => { + process.env.LAKEBASE_DATA_API_URL = "https://env.example/rest/v1"; + + const client = createLakebasePostgrestClient({ + resolveToken: async () => "tok", + }) as { config: Record }; + + expect(client.config).toMatchObject({ + dataApiUrl: "https://env.example/rest/v1", + options: { db: { schema: "app" } }, + }); + }); +}); diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index 00fd6ff86..24277ef4c 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -20,12 +20,16 @@ export type { DatabaseCredential, GenerateDatabaseCredentialRequest, LakebasePoolConfig, + LakebasePostgrestClient, + LakebasePostgrestClientConfig, + LakebaseTokenResolver, RequestedClaims, RequestedResource, } from "./connectors/lakebase"; // Lakebase Autoscaling connector export { createLakebasePool, + createLakebasePostgrestClient, generateDatabaseCredential, getLakebaseOrmConfig, getLakebasePgConfig, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1d8f247e..72bf809a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,6 +251,9 @@ importers: '@databricks/sdk-experimental': specifier: 0.16.0 version: 0.16.0 + '@neondatabase/postgrest-js': + specifier: 0.1.0-alpha.2 + version: 0.1.0-alpha.2 '@opentelemetry/api': specifier: 1.9.0 version: 1.9.0 @@ -2673,6 +2676,9 @@ packages: '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@neondatabase/postgrest-js@0.1.0-alpha.2': + resolution: {integrity: sha512-fE3Kd6Tj3uUVx6jvDUA3USW4gZaSAL0NoGePFg31xDfBbmS2EBpdboKUnac8rJVajUiCDQPj6/j4G1eXOleYsg==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -4542,6 +4548,10 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@supabase/postgrest-js@2.79.0': + resolution: {integrity: sha512-2i8EFm3/49ecjt6dk/TGVROBbtOmhryiC4NL3u0FBIrm2hqj+FvbELv1jjM6r+a6abnh+uzIV/bFsWHAa/k3/w==} + engines: {node: '>=20.0.0'} + '@svgr/babel-plugin-add-jsx-attribute@8.0.0': resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} engines: {node: '>=14'} @@ -14929,6 +14939,10 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@neondatabase/postgrest-js@0.1.0-alpha.2': + dependencies: + '@supabase/postgrest-js': 2.79.0 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -16847,6 +16861,10 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@supabase/postgrest-js@2.79.0': + dependencies: + tslib: 2.8.1 + '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 From 218e37f4010e8ab85278575e475358a35343201f Mon Sep 17 00:00:00 2001 From: ditadi Date: Sat, 2 May 2026 16:55:56 +0100 Subject: [PATCH 02/19] feat(appkit): add defineSchema with column helpers Signed-off-by: ditadi --- packages/appkit/package.json | 2 + packages/appkit/src/database/index.ts | 1 + .../src/database/schema-builder/columns.ts | 204 ++++++++++++++++++ .../database/schema-builder/define-schema.ts | 49 +++++ .../src/database/schema-builder/index.ts | 24 +++ .../src/database/schema-builder/table.ts | 70 ++++++ .../src/database/schema-builder/types.ts | 138 ++++++++++++ .../src/database/tests/define-schema.test.ts | 49 +++++ packages/appkit/src/index.ts | 2 + pnpm-lock.yaml | 17 ++ 10 files changed, 556 insertions(+) create mode 100644 packages/appkit/src/database/index.ts create mode 100644 packages/appkit/src/database/schema-builder/columns.ts create mode 100644 packages/appkit/src/database/schema-builder/define-schema.ts create mode 100644 packages/appkit/src/database/schema-builder/index.ts create mode 100644 packages/appkit/src/database/schema-builder/table.ts create mode 100644 packages/appkit/src/database/schema-builder/types.ts create mode 100644 packages/appkit/src/database/tests/define-schema.test.ts diff --git a/packages/appkit/package.json b/packages/appkit/package.json index 2be940bb4..a773c3414 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -75,6 +75,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", "obug": "2.1.1", "pg": "8.18.0", 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..433de192a --- /dev/null +++ b/packages/appkit/src/database/schema-builder/columns.ts @@ -0,0 +1,204 @@ +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, + 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(); + 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; + }, + }); + + 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, + }); +} + +/** + * 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(`enumColumn ${name} values must not be empty`, { + context: { enumName: name }, + }); + } + + const enumType = pgEnum(name, values as [string, ...string[]]); + return wrap(enumType()); +} + +/** + * Create a foreign key column. + * @param target - The target column to reference. + * @returns The wrapped column chain. + */ +export function fk(target: AppKitColumn): AppKitColumnChain & { + onDelete(value: NonNullable): AppKitColumnChain; + onUpdate(value: NonNullable): AppKitColumnChain; +} { + const chain = wrap(pgInteger(), { + references: + target.$meta.tableName && target.$meta.columnName + ? { + toTable: target.$meta.tableName, + toColumn: target.$meta.columnName, + } + : undefined, + }) as AppKitColumnChain & { + onDelete(value: NonNullable): AppKitColumnChain; + onUpdate(value: NonNullable): AppKitColumnChain; + }; + chain.onDelete = (value) => { + chain.$meta.references = { + ...(chain.$meta.references ?? { toTable: "", toColumn: "" }), + onDelete: value, + }; + return chain; + }; + chain.onUpdate = (value) => { + chain.$meta.references = { + ...(chain.$meta.references ?? { toTable: "", toColumn: "" }), + onUpdate: value, + }; + return chain; + }; + return chain; +} 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..d498e25a8 --- /dev/null +++ b/packages/appkit/src/database/schema-builder/define-schema.ts @@ -0,0 +1,49 @@ +import { pgSchema } from "drizzle-orm/pg-core"; +import { enumColumn } from "./columns"; +import { buildTable } from "./table"; +import { + APPKIT_TABLE, + type AppKitTable, + 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; + } + } + + 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..9d41e53b7 --- /dev/null +++ b/packages/appkit/src/database/schema-builder/index.ts @@ -0,0 +1,24 @@ +export { + bigint, + boolean, + enumColumn as enumeration, + fk, + id, + integer, + jsonb, + text, + timestamp, + uuid, + varchar, +} from "./columns"; +export { type DefineSchemaOptions, defineSchema } from "./define-schema"; +export type { + AppKitColumn, + AppKitColumnChain, + AppKitTable, + ColumnMeta, + Relation, + Schema, + SchemaBuilderContext, +} from "./types"; +export { APPKIT_TABLE } from "./types"; 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..454972ec8 --- /dev/null +++ b/packages/appkit/src/database/schema-builder/table.ts @@ -0,0 +1,70 @@ +import type { pgSchema } from "drizzle-orm/pg-core"; +import { createInsertSchema, createUpdateSchema } from "drizzle-zod"; +import { + APPKIT_TABLE, + type AppKitColumn, + type AppKitTable, + type Relation, +} from "./types"; + +/** + * 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; + } + + 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[] = Object.entries(columns) + .map(([columnName, definition]): Relation | null => { + const reference = definition.$meta.references; + if (!reference?.toTable || !reference?.toColumn) return null; + 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; + return relation; + }) + .filter((relation): relation is Relation => relation !== null); + + return { + [APPKIT_TABLE]: true, + name, + $drizzle: drizzleTable, + $columns, + $relations, + $insertSchema: createInsertSchema(drizzleTable as never), + $updateSchema: createUpdateSchema(drizzleTable as never), + }; +} 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..4962feac1 --- /dev/null +++ b/packages/appkit/src/database/schema-builder/types.ts @@ -0,0 +1,138 @@ +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. + * @example + * ```ts + * const columnMeta: ColumnMeta = { + * serverGenerated: true, + * }; + * ``` + */ +export interface ColumnMeta { + serverGenerated?: boolean; + /** @internal */ + tableName?: string; + /** @internal */ + columnName?: string; + /** @internal */ + references?: Pick; +} + +/** + * An AppKit column. This is returned by the column builder methods. + * @example + * ```ts + * const column: AppKitColumn = { + * $builder: unknown, + * $meta: columnMeta, + * }; + * ``` + */ +export interface AppKitColumn { + $builder: unknown; + $meta: ColumnMeta; +} + +/** + * A chain of AppKit column methods. This is returned by the column builder methods. + * @example + * ```ts + * const column: AppKitColumnChain = { + * $builder: unknown, + * $meta: columnMeta, + * }; + * ``` + */ +export interface AppKitColumnChain extends AppKitColumn { + notNull(): AppKitColumnChain; + unique(): AppKitColumnChain; + primaryKey(): AppKitColumnChain; + default(value: T): AppKitColumnChain; + defaultNow(): AppKitColumnChain; + defaultRandom(): AppKitColumnChain; +} + +/** + * A relation between two tables. This is used to define the foreign key relationships between tables. + * @example + * ```ts + * const relation: Relation = { + * fromColumn: "userId", + * toTable: "users", + * toColumn: "id", + * onDelete: "cascade", + * onUpdate: "cascade", + * }; + * ``` + */ +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. + * @example + * ```ts + * const table: AppKitTable = { + * $builder: unknown, + * $meta: tableMeta, + * }; + * ``` + */ +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. + * @example + * ```ts + * const schema: Schema = { + * $drizzle: unknown, + * $tables: { tableName: AppKitTable }, + * $migrations: { snapshotHints: unknown }, + * }; + * ``` + */ +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. + * @example + * ```ts + * const context: SchemaBuilderContext = { + * table: (name, columns) => table(name, columns), + * enum: (name, values) => enum(name, values), + * }; + * ``` + */ +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..f48c5d7a9 --- /dev/null +++ b/packages/appkit/src/database/tests/define-schema.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "vitest"; +import { APPKIT_TABLE, defineSchema, fk, id, text } 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); + }); +}); diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index 24277ef4c..8fd04efba 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -39,6 +39,8 @@ export { } from "./connectors/lakebase"; export { getExecutionContext } from "./context"; export { createApp } from "./core"; +// Database +export * from "./database"; // Errors export { AppKitError, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72bf809a4..4c12189be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -305,6 +305,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 @@ -6768,6 +6774,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'} @@ -19291,6 +19303,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 From b745ca5e1f669113ae04178efe3b25dd995da18a Mon Sep 17 00:00:00 2001 From: ditadi Date: Sat, 2 May 2026 16:58:04 +0100 Subject: [PATCH 03/19] feat(database): plugin skeleton with manifest Signed-off-by: ditadi --- packages/appkit/src/beta.ts | 8 ++ .../src/plugins/beta-exports.generated.ts | 2 +- .../appkit/src/plugins/database/database.ts | 60 ++++++++++ .../appkit/src/plugins/database/defaults.ts | 37 ++++++ packages/appkit/src/plugins/database/index.ts | 8 ++ .../appkit/src/plugins/database/manifest.json | 87 ++++++++++++++ packages/appkit/src/plugins/database/types.ts | 109 ++++++++++++++++++ 7 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 packages/appkit/src/plugins/database/database.ts create mode 100644 packages/appkit/src/plugins/database/defaults.ts create mode 100644 packages/appkit/src/plugins/database/index.ts create mode 100644 packages/appkit/src/plugins/database/manifest.json create mode 100644 packages/appkit/src/plugins/database/types.ts diff --git a/packages/appkit/src/beta.ts b/packages/appkit/src/beta.ts index 04e893bf3..66cd8ee65 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 { + EntityHooks, + HookContext, + HttpAccess, + HttpEntityOverride, + IDatabaseConfig, +} from "./plugins/database"; +export { readDefaults, writeDefaults } from "./plugins/database/defaults"; 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/database.ts b/packages/appkit/src/plugins/database/database.ts new file mode 100644 index 000000000..93c5cfedd --- /dev/null +++ b/packages/appkit/src/plugins/database/database.ts @@ -0,0 +1,60 @@ +import type { Pool } from "pg"; +import { Plugin, toPlugin } from "@/plugin"; +import { createLakebasePool } from "../../connectors/lakebase"; +import { ConfigurationError } from "../../errors"; +import { createLogger } from "../../logging/logger"; +import type { PluginManifest } from "../../registry"; +import { POOL_DEFAULTS } 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; + + constructor(config: IDatabaseConfig = {}) { + super(config); + this.config = config; + } + + async setup() { + this.pool = createLakebasePool({ + ...POOL_DEFAULTS, + ...this.config.connection, + }); + logger.info("Database plugin pool initialized"); + } + + abortActiveOperations(): void { + super.abortActiveOperations(); + if (!this.pool) return; + + logger.info("Closing database pool"); + this.pool.end().catch((err) => { + logger.error("Error closing database pool: %O", err); + }); + this.pool = null; + } + + 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); diff --git a/packages/appkit/src/plugins/database/defaults.ts b/packages/appkit/src/plugins/database/defaults.ts new file mode 100644 index 000000000..29dec8d86 --- /dev/null +++ b/packages/appkit/src/plugins/database/defaults.ts @@ -0,0 +1,37 @@ +import type { PluginExecuteConfig } from "shared"; + +/** + * Execution defaults for read-tier operations (list, find, count). + * Cache 0s (ttl in seconds) + * Retry 3x with 200ms backoff + * Timeout 15s + */ +export const readDefaults: PluginExecuteConfig = { + cache: { enabled: false, ttl: 0 }, + retry: { enabled: true, initialDelay: 200, attempts: 3 }, + timeout: 15_000, +}; + +/** + * Execution defaults for write-tier operations (create, update, delete). + * No cache + * No retry + * Timeout 15s + */ +export const writeDefaults: PluginExecuteConfig = { + cache: { enabled: false, ttl: 0 }, + retry: { enabled: false, initialDelay: 0, attempts: 1 }, + timeout: 15_000, +}; + +/** + * Connection pool defaults for the database. + * Max 10 connections + * Idle timeout 30s + * Connection timeout 10s + */ +export const POOL_DEFAULTS = { + max: 10, + idleTimeoutMillis: 30_000, + connectionTimeoutMillis: 10_000, +}; diff --git a/packages/appkit/src/plugins/database/index.ts b/packages/appkit/src/plugins/database/index.ts new file mode 100644 index 000000000..17d527a99 --- /dev/null +++ b/packages/appkit/src/plugins/database/index.ts @@ -0,0 +1,8 @@ +export * from "./database"; +export type { + 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..e66c755d0 --- /dev/null +++ b/packages/appkit/src/plugins/database/manifest.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + "name": "database", + "displayName": "Database", + "description": "Application database with schema-driven CRUD, type generation, OBO, RLS, and LLM tools", + "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": { + "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. Next steps:\n Greenfield: npx appkit db generate user name:string email:string\n Brownfield: npx appkit db introspect\n Then: npx appkit db migrate up && pnpm dev" +} diff --git a/packages/appkit/src/plugins/database/types.ts b/packages/appkit/src/plugins/database/types.ts new file mode 100644 index 000000000..ebbe94331 --- /dev/null +++ b/packages/appkit/src/plugins/database/types.ts @@ -0,0 +1,109 @@ +import type { BasePluginConfig } from "shared"; +import type { LakebasePoolConfig } from "@/connectors"; + +/** + * 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 { + /** The HTTP access control for the list operation. */ + list?: HttpAccess; + /** The HTTP access control for the find operation. */ + find?: HttpAccess; + /** The HTTP access control for the count operation. */ + count?: HttpAccess; + /** The HTTP access control for the create operation. */ + create?: HttpAccess; + /** The HTTP access control for the update operation. */ + update?: HttpAccess; + /** The HTTP access control for the delete operation. */ + delete?: HttpAccess; +} + +/** + * Context for entity hooks. + * @public + */ +export interface HookContext { + /** The request object. */ + req?: import("express").Request; + /** The entity name. */ + entity?: string; + /** The user ID. */ + userId?: string; +} + +/** + * Entity hooks. + * @public + */ +export interface EntityHooks { + /** A hook to run before a create operation. */ + beforeCreate?: ( + data: Record, + ctx: HookContext, + ) => Promise | void>; + /** A hook to run after a create operation. */ + afterCreate?: ( + row: Record, + ctx: HookContext, + ) => Promise; + /** A hook to run before an update operation. */ + beforeUpdate?: ( + id: unknown, + patch: Record, + ctx: HookContext, + ) => Promise | void>; + /** A hook to run after an update operation. */ + afterUpdate?: ( + row: Record, + ctx: HookContext, + ) => Promise; + /** A hook to run before a delete operation. */ + beforeDelete?: (id: unknown, ctx: HookContext) => Promise; + /** A hook to run after a delete operation. */ + afterDelete?: (id: unknown, ctx: HookContext) => Promise; +} + +/** + * Cache action settings. + * @public + */ +export interface CacheActionSettings { + /** The time to live for the cache in seconds. */ + ttl?: number; +} + +/** + * Cache settings. + * @public + */ +export interface CacheSettings { + /** The cache settings for the list operation. */ + list?: CacheActionSettings; + /** The cache settings for the find operation. */ + find?: CacheActionSettings; + /** The cache settings for the count operation. */ + count?: CacheActionSettings; +} + +/** + * Database configuration. + * @public + */ +export interface IDatabaseConfig extends BasePluginConfig { + /** The connection settings for the database. */ + connection?: Partial; + /** The HTTP entity overrides for the database. */ + http?: Record; + /** The entity hooks for the database. */ + hooks?: Record; + /** The cache settings for the database. */ + cache?: CacheSettings; +} From aa9c7203ddf36858c5094d7007fc13822c0cc2d8 Mon Sep 17 00:00:00 2001 From: ditadi Date: Mon, 4 May 2026 00:42:19 +0100 Subject: [PATCH 04/19] feat(database): convention loader with prod paths Signed-off-by: ditadi --- .../src/database/schema-builder/types.ts | 1 + .../appkit/src/plugins/database/convention.ts | 90 ++++++++++++++++ .../appkit/src/plugins/database/database.ts | 20 ++++ .../plugins/database/tests/convention.test.ts | 87 +++++++++++++++ .../src/plugins/database/tests/plugin.test.ts | 100 ++++++++++++++++++ 5 files changed, 298 insertions(+) create mode 100644 packages/appkit/src/plugins/database/convention.ts create mode 100644 packages/appkit/src/plugins/database/tests/convention.test.ts create mode 100644 packages/appkit/src/plugins/database/tests/plugin.test.ts diff --git a/packages/appkit/src/database/schema-builder/types.ts b/packages/appkit/src/database/schema-builder/types.ts index 4962feac1..45daeeb1c 100644 --- a/packages/appkit/src/database/schema-builder/types.ts +++ b/packages/appkit/src/database/schema-builder/types.ts @@ -16,6 +16,7 @@ export const APPKIT_TABLE = Symbol.for("appkit.database.table"); */ export interface ColumnMeta { serverGenerated?: boolean; + primaryKey?: boolean; /** @internal */ tableName?: string; /** @internal */ diff --git a/packages/appkit/src/plugins/database/convention.ts b/packages/appkit/src/plugins/database/convention.ts new file mode 100644 index 000000000..93235c7b3 --- /dev/null +++ b/packages/appkit/src/plugins/database/convention.ts @@ -0,0 +1,90 @@ +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"; + +/** + * 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; + + for (const candidate of CONVENTION_PATHS) { + const absolutePath = path.resolve(cwd, candidate); + 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 }; + } + + 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 index 93c5cfedd..5a17b134e 100644 --- a/packages/appkit/src/plugins/database/database.ts +++ b/packages/appkit/src/plugins/database/database.ts @@ -1,9 +1,11 @@ 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 { POOL_DEFAULTS } from "./defaults"; import manifest from "./manifest.json"; import type { IDatabaseConfig } from "./types"; @@ -15,6 +17,8 @@ class DatabasePlugin extends Plugin { protected declare config: IDatabaseConfig; protected pool: Pool | null = null; + protected schema: Schema | null = null; + protected schemaPath: string | null = null; constructor(config: IDatabaseConfig = {}) { super(config); @@ -27,6 +31,22 @@ class DatabasePlugin extends Plugin { ...this.config.connection, }); logger.info("Database plugin pool initialized"); + + 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, + ); } abortActiveOperations(): void { 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..23b2bbed3 --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/plugin.test.ts @@ -0,0 +1,100 @@ +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), +} 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, + connectionTimeoutMillis: 10_000, + }); + 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(); + + plugin.abortActiveOperations(); + + expect(pool.end).toHaveBeenCalled(); + }); +}); From 1822b4adfb0b907257a2be95c39cde326dfa5d4f Mon Sep 17 00:00:00 2001 From: ditadi Date: Mon, 4 May 2026 16:34:54 +0100 Subject: [PATCH 05/19] feat(database): add column.private() to hide secrets from default I/O --- .../src/database/schema-builder/columns.ts | 4 +++ .../src/database/schema-builder/table.ts | 24 ++++++++++++-- .../src/database/schema-builder/types.ts | 7 ++++ .../src/database/tests/define-schema.test.ts | 32 +++++++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/packages/appkit/src/database/schema-builder/columns.ts b/packages/appkit/src/database/schema-builder/columns.ts index 433de192a..85d009bff 100644 --- a/packages/appkit/src/database/schema-builder/columns.ts +++ b/packages/appkit/src/database/schema-builder/columns.ts @@ -65,6 +65,10 @@ function wrap(builder: unknown, meta: ColumnMeta = {}): AppKitColumnChain { column.$meta.serverGenerated = true; return chain; }, + private() { + column.$meta.private = true; + return chain; + }, }); return chain; diff --git a/packages/appkit/src/database/schema-builder/table.ts b/packages/appkit/src/database/schema-builder/table.ts index 454972ec8..9a8524c0d 100644 --- a/packages/appkit/src/database/schema-builder/table.ts +++ b/packages/appkit/src/database/schema-builder/table.ts @@ -1,5 +1,6 @@ import type { pgSchema } from "drizzle-orm/pg-core"; import { createInsertSchema, createUpdateSchema } from "drizzle-zod"; +import type { z } from "zod"; import { APPKIT_TABLE, type AppKitColumn, @@ -58,13 +59,32 @@ export function buildTable< }) .filter((relation): relation is Relation => relation !== null); + 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: createInsertSchema(drizzleTable as never), - $updateSchema: createUpdateSchema(drizzleTable as never), + $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 index 45daeeb1c..5c6d20006 100644 --- a/packages/appkit/src/database/schema-builder/types.ts +++ b/packages/appkit/src/database/schema-builder/types.ts @@ -17,6 +17,12 @@ export const APPKIT_TABLE = Symbol.for("appkit.database.table"); export interface ColumnMeta { serverGenerated?: boolean; primaryKey?: boolean; + /** + * Hides the column from default reads, writes, and column metadata. Set via + * `.private()` on the column chain. Used to keep secrets like password hashes + * out of the public surface without forking the schema. + */ + private?: boolean; /** @internal */ tableName?: string; /** @internal */ @@ -57,6 +63,7 @@ export interface AppKitColumnChain extends AppKitColumn { default(value: T): AppKitColumnChain; defaultNow(): AppKitColumnChain; defaultRandom(): AppKitColumnChain; + private(): AppKitColumnChain; } /** diff --git a/packages/appkit/src/database/tests/define-schema.test.ts b/packages/appkit/src/database/tests/define-schema.test.ts index f48c5d7a9..baf92721b 100644 --- a/packages/appkit/src/database/tests/define-schema.test.ts +++ b/packages/appkit/src/database/tests/define-schema.test.ts @@ -46,4 +46,36 @@ describe("defineSchema", () => { schema.user.$updateSchema.safeParse({ email: "b@example.com" }).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, + ); + } + }); }); From e5432d7b7343ebdac05679118f90e37ffba09ab0 Mon Sep 17 00:00:00 2001 From: ditadi Date: Mon, 4 May 2026 17:10:53 +0100 Subject: [PATCH 06/19] feat(plugin): allow abortActiveOperations to return a promise; await drains on shutdown --- packages/appkit/src/plugin/plugin.ts | 2 +- packages/appkit/src/plugins/server/index.ts | 24 ++++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/appkit/src/plugin/plugin.ts b/packages/appkit/src/plugin/plugin.ts index 75d994d88..ed8be8c6f 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/server/index.ts b/packages/appkit/src/plugins/server/index.ts index 8ed13cea0..c3021e48b 100644 --- a/packages/appkit/src/plugins/server/index.ts +++ b/packages/appkit/src/plugins/server/index.ts @@ -345,20 +345,34 @@ 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. if (this.config.plugins) { - for (const plugin of Object.values(this.config.plugins)) { - if (plugin.abortActiveOperations) { + const drains = Object.values(this.config.plugins) + .map((plugin) => { + if (!plugin.abortActiveOperations) return null; try { - plugin.abortActiveOperations(); + return Promise.resolve(plugin.abortActiveOperations()).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); } } From 4d18358fd100b753c4ef365b70b7147bdc4a694a Mon Sep 17 00:00:00 2001 From: ditadi Date: Mon, 4 May 2026 17:11:06 +0100 Subject: [PATCH 07/19] feat(database): async pool drain, statement_timeout, schema-load isolation --- .../appkit/src/plugins/database/database.ts | 71 ++++++++++++++----- .../appkit/src/plugins/database/defaults.ts | 8 ++- .../src/plugins/database/tests/plugin.test.ts | 64 ++++++++++++++++- packages/appkit/src/plugins/database/types.ts | 20 ++++++ 4 files changed, 143 insertions(+), 20 deletions(-) diff --git a/packages/appkit/src/plugins/database/database.ts b/packages/appkit/src/plugins/database/database.ts index 5a17b134e..2773a3afd 100644 --- a/packages/appkit/src/plugins/database/database.ts +++ b/packages/appkit/src/plugins/database/database.ts @@ -6,7 +6,7 @@ import { ConfigurationError } from "../../errors"; import { createLogger } from "../../logging/logger"; import type { PluginManifest } from "../../registry"; import { loadSchemaByConvention } from "./convention"; -import { POOL_DEFAULTS } from "./defaults"; +import { POOL_DEFAULTS, STATEMENT_TIMEOUT_DEFAULT_MS } from "./defaults"; import manifest from "./manifest.json"; import type { IDatabaseConfig } from "./types"; @@ -30,34 +30,51 @@ class DatabasePlugin extends Plugin { ...POOL_DEFAULTS, ...this.config.connection, }); + attachStatementTimeout(this.pool, this.config.statementTimeoutMs); logger.info("Database plugin pool initialized"); - const loaded = await loadSchemaByConvention(); - if (!loaded) { - logger.warn( - "Database plugin did not find config/database/schema.ts, using empty schema", + 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) { + // A throwing schema-load otherwise cascades through Promise.all in core + // and crashes every plugin's boot. Decorate the error with the + // convention path so the operator can find it, then re-raise unless the + // caller opted into tolerant boot. + const message = err instanceof Error ? err.message : String(err); + logger.error( + "Database schema load failed (config/database/schema.ts): %s", + message, ); - return; + if (!this.config.tolerateSetupFailure) throw err; } - - 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, - ); } - abortActiveOperations(): void { + async abortActiveOperations(): Promise { super.abortActiveOperations(); if (!this.pool) return; logger.info("Closing database pool"); - this.pool.end().catch((err) => { - logger.error("Error closing database pool: %O", err); - }); + const draining = this.pool.end(); this.pool = null; + try { + await draining; + } catch (err) { + logger.error("Error closing database pool: %O", err); + } } exports() { @@ -78,3 +95,21 @@ class DatabasePlugin extends Plugin { } export const database = toPlugin(DatabasePlugin); + +/** + * Attach a `connect` listener that sets `statement_timeout` on every new + * Postgres session checked out of the pool. Caps runaway queries server-side + * even when the client signal is dropped. + */ +function attachStatementTimeout(pool: Pool, override?: number): void { + const ms = override ?? STATEMENT_TIMEOUT_DEFAULT_MS; + if (!Number.isFinite(ms) || ms <= 0) return; + pool.on("connect", (client) => { + client.query(`SET statement_timeout = ${Math.floor(ms)}`).catch((err) => { + logger.error( + "Failed to set statement_timeout on pool connection: %O", + err, + ); + }); + }); +} diff --git a/packages/appkit/src/plugins/database/defaults.ts b/packages/appkit/src/plugins/database/defaults.ts index 29dec8d86..9134ed2eb 100644 --- a/packages/appkit/src/plugins/database/defaults.ts +++ b/packages/appkit/src/plugins/database/defaults.ts @@ -25,7 +25,7 @@ export const writeDefaults: PluginExecuteConfig = { }; /** - * Connection pool defaults for the database. + * Connection pool defaults for the service-principal pool. * Max 10 connections * Idle timeout 30s * Connection timeout 10s @@ -35,3 +35,9 @@ export const POOL_DEFAULTS = { idleTimeoutMillis: 30_000, connectionTimeoutMillis: 10_000, }; + +/** + * 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; diff --git a/packages/appkit/src/plugins/database/tests/plugin.test.ts b/packages/appkit/src/plugins/database/tests/plugin.test.ts index 23b2bbed3..b3cf2c9a9 100644 --- a/packages/appkit/src/plugins/database/tests/plugin.test.ts +++ b/packages/appkit/src/plugins/database/tests/plugin.test.ts @@ -29,6 +29,7 @@ vi.mock("../convention", () => ({ const pool = { end: vi.fn(async () => undefined), + on: vi.fn(), } as unknown as Pool; type DatabasePluginInstance = InstanceType< @@ -93,8 +94,69 @@ describe("DatabasePlugin", () => { const plugin = createPlugin(); await plugin.setup(); - plugin.abortActiveOperations(); + 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 statement_timeout to every new pool 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 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 index ebbe94331..c88caa07e 100644 --- a/packages/appkit/src/plugins/database/types.ts +++ b/packages/appkit/src/plugins/database/types.ts @@ -106,4 +106,24 @@ export interface IDatabaseConfig extends BasePluginConfig { hooks?: Record; /** The cache settings for the database. */ cache?: CacheSettings; + /** + * Maximum number of distinct per-user (OBO) pools the registry keeps alive + * at once. Each pool defaults to `OBO_POOL_DEFAULTS.max = 4` connections, so + * the worst-case fan-out is `(1 + oboPoolMax) × poolMax`. Defaults to 25 — + * tune up for hot OBO traffic, down for low-tier Lakebase plans. + */ + oboPoolMax?: number; + /** + * Postgres `statement_timeout` applied to every pooled connection (ms). + * Defaults to 15s. Set to `0` to disable the server-side cap; the AppKit + * timeout interceptor still applies on the client side. + */ + statementTimeoutMs?: number; + /** + * When true, schema-load and drift-check failures during `setup()` are + * logged but do not throw. Defaults to false (fail closed). Useful in + * environments where the database is provisioned out of band and the boot + * shouldn't crash before the schema is reachable. + */ + tolerateSetupFailure?: boolean; } From e5e92cc5725b78ffa00f0019a18184a60128312c Mon Sep 17 00:00:00 2001 From: ditadi Date: Mon, 4 May 2026 17:44:04 +0100 Subject: [PATCH 08/19] feat(database): set application_name + pool stats; tighten foundation polish --- .../lakebase/tests/postgrest.test.ts | 113 ++++++++++----- .../src/database/tests/define-schema.test.ts | 133 +++++++++++++++++- .../appkit/src/plugins/database/convention.ts | 9 ++ .../appkit/src/plugins/database/database.ts | 61 ++++++-- .../appkit/src/plugins/database/defaults.ts | 10 ++ .../appkit/src/plugins/database/manifest.json | 2 +- .../src/plugins/database/tests/plugin.test.ts | 6 +- 7 files changed, 282 insertions(+), 52 deletions(-) diff --git a/packages/appkit/src/connectors/lakebase/tests/postgrest.test.ts b/packages/appkit/src/connectors/lakebase/tests/postgrest.test.ts index 16e7c4c14..0d0fd48ee 100644 --- a/packages/appkit/src/connectors/lakebase/tests/postgrest.test.ts +++ b/packages/appkit/src/connectors/lakebase/tests/postgrest.test.ts @@ -1,65 +1,106 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; +import { afterEach, describe, expect, test } from "vitest"; import { ConfigurationError } from "../../../errors"; import { createLakebasePostgrestClient } from "../postgrest"; -const mocks = vi.hoisted(() => ({ - fetchWithToken: vi.fn((_resolveToken: unknown, _fetch?: unknown) => ({ - wrappedFetch: true, - })), - NeonPostgrestClient: vi.fn(function MockNeonPostgrestClient( - this: { config?: unknown }, - config: unknown, - ) { - this.config = config; - }), -})); +interface RecordedCall { + url: string; + headers: Record; +} -vi.mock("@neondatabase/postgrest-js", () => mocks); +function recordingFetch(): { + fetch: typeof fetch; + calls: RecordedCall[]; +} { + const calls: RecordedCall[] = []; + const fetchImpl = (async ( + input: Parameters[0], + init?: RequestInit, + ) => { + const headers: Record = {}; + if (init?.headers) { + const h = + init.headers instanceof Headers + ? init.headers + : new Headers(init.headers); + h.forEach((value, key) => { + headers[key.toLowerCase()] = value; + }); + } + calls.push({ url: String(input), headers }); + return new Response("[]", { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + return { fetch: fetchImpl, calls }; +} + +interface PostgrestClient { + from(table: string): { + select(): Promise; + }; +} describe("createLakebasePostgrestClient", () => { afterEach(() => { - vi.clearAllMocks(); delete process.env.LAKEBASE_DATA_API_URL; }); - test("throws an AppKit configuration error when no Data API URL is configured", () => { + test("throws when no Data API URL is configured", () => { expect(() => createLakebasePostgrestClient({ resolveToken: async () => "tok" }), ).toThrow(ConfigurationError); }); - test("creates a PostgREST client with token-aware fetch", () => { - const resolveToken = vi.fn(async () => "tok"); - const fetchSpy = vi.fn(); + test("issues PostgREST requests against the configured dataApiUrl with token + schema headers", async () => { + const { fetch: recording, calls } = recordingFetch(); const client = createLakebasePostgrestClient({ dataApiUrl: "https://example.test/rest/v1", schema: "custom", - resolveToken, - fetch: fetchSpy as unknown as typeof fetch, - }) as { config: Record }; + resolveToken: async () => "tok-abc", + fetch: recording, + }) as PostgrestClient; - expect(mocks.fetchWithToken).toHaveBeenCalledWith(resolveToken, fetchSpy); - expect(mocks.NeonPostgrestClient).toHaveBeenCalledTimes(1); - expect(client.config).toEqual({ - dataApiUrl: "https://example.test/rest/v1", - options: { - db: { schema: "custom" }, - global: { fetch: { wrappedFetch: true } }, - }, - }); + await client.from("widgets").select(); + + expect(calls).toHaveLength(1); + const [call] = calls; + expect(call.url).toContain("https://example.test/rest/v1/widgets"); + expect(call.headers.authorization).toBe("Bearer tok-abc"); + expect(call.headers["accept-profile"]).toBe("custom"); }); - test("falls back to LAKEBASE_DATA_API_URL and app schema", () => { + test("falls back to LAKEBASE_DATA_API_URL and the app schema", async () => { process.env.LAKEBASE_DATA_API_URL = "https://env.example/rest/v1"; + const { fetch: recording, calls } = recordingFetch(); const client = createLakebasePostgrestClient({ resolveToken: async () => "tok", - }) as { config: Record }; + fetch: recording, + }) as PostgrestClient; - expect(client.config).toMatchObject({ - dataApiUrl: "https://env.example/rest/v1", - options: { db: { schema: "app" } }, - }); + await client.from("widgets").select(); + + expect(calls).toHaveLength(1); + expect(calls[0].url).toContain("https://env.example/rest/v1/widgets"); + expect(calls[0].headers["accept-profile"]).toBe("app"); + }); + + test("a null token surfaces as AuthRequiredError without firing fetch", async () => { + const { fetch: recording, calls } = recordingFetch(); + + const client = createLakebasePostgrestClient({ + dataApiUrl: "https://example.test/rest/v1", + resolveToken: async () => null, + fetch: recording, + }) as PostgrestClient; + + const result = (await client.from("widgets").select()) as { + error: { message: string } | null; + }; + + expect(calls).toHaveLength(0); + expect(result.error?.message).toMatch(/AuthRequiredError/); }); }); diff --git a/packages/appkit/src/database/tests/define-schema.test.ts b/packages/appkit/src/database/tests/define-schema.test.ts index baf92721b..7f3b2ebe7 100644 --- a/packages/appkit/src/database/tests/define-schema.test.ts +++ b/packages/appkit/src/database/tests/define-schema.test.ts @@ -1,5 +1,16 @@ import { describe, expect, test } from "vitest"; -import { APPKIT_TABLE, defineSchema, fk, id, text } from "../schema-builder"; +import { + APPKIT_TABLE, + boolean, + defineSchema, + enumeration, + fk, + id, + integer, + jsonb, + text, + timestamp, +} from "../schema-builder"; describe("defineSchema", () => { test("collects tables and relations", () => { @@ -47,6 +58,126 @@ describe("defineSchema", () => { ).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", { diff --git a/packages/appkit/src/plugins/database/convention.ts b/packages/appkit/src/plugins/database/convention.ts index 93235c7b3..46d79f228 100644 --- a/packages/appkit/src/plugins/database/convention.ts +++ b/packages/appkit/src/plugins/database/convention.ts @@ -3,6 +3,9 @@ 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. @@ -57,8 +60,10 @@ export async function loadSchemaByConvention( 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); @@ -74,6 +79,10 @@ export async function loadSchemaByConvention( return { schema, schemaPath: absolutePath }; } + logger.info( + "No database schema found. Probed paths:\n - %s", + probed.join("\n - "), + ); return null; } diff --git a/packages/appkit/src/plugins/database/database.ts b/packages/appkit/src/plugins/database/database.ts index 2773a3afd..e9e2f25f3 100644 --- a/packages/appkit/src/plugins/database/database.ts +++ b/packages/appkit/src/plugins/database/database.ts @@ -6,7 +6,11 @@ import { ConfigurationError } from "../../errors"; import { createLogger } from "../../logging/logger"; import type { PluginManifest } from "../../registry"; import { loadSchemaByConvention } from "./convention"; -import { POOL_DEFAULTS, STATEMENT_TIMEOUT_DEFAULT_MS } from "./defaults"; +import { + APPLICATION_NAME, + POOL_DEFAULTS, + STATEMENT_TIMEOUT_DEFAULT_MS, +} from "./defaults"; import manifest from "./manifest.json"; import type { IDatabaseConfig } from "./types"; @@ -30,7 +34,9 @@ class DatabasePlugin extends Plugin { ...POOL_DEFAULTS, ...this.config.connection, }); - attachStatementTimeout(this.pool, this.config.statementTimeoutMs); + attachSessionDefaults(this.pool, this.config.statementTimeoutMs); + if (process.env.DEBUG_POOL) + startPoolStatsLog(this.pool, "service-principal"); logger.info("Database plugin pool initialized"); try { @@ -97,19 +103,48 @@ class DatabasePlugin extends Plugin { export const database = toPlugin(DatabasePlugin); /** - * Attach a `connect` listener that sets `statement_timeout` on every new - * Postgres session checked out of the pool. Caps runaway queries server-side - * even when the client signal is dropped. + * Attach a `connect` listener that sets per-session defaults on every new + * Postgres session checked out of the pool: `statement_timeout` (caps runaway + * queries even when the client signal is dropped) and `application_name` (so + * the connection is attributable in `pg_stat_activity`). */ -function attachStatementTimeout(pool: Pool, override?: number): void { +function attachSessionDefaults(pool: Pool, override?: number): void { const ms = override ?? STATEMENT_TIMEOUT_DEFAULT_MS; - if (!Number.isFinite(ms) || ms <= 0) return; pool.on("connect", (client) => { - client.query(`SET statement_timeout = ${Math.floor(ms)}`).catch((err) => { - logger.error( - "Failed to set statement_timeout on pool connection: %O", - err, - ); - }); + client + .query(`SET application_name = '${APPLICATION_NAME}'`) + .catch((err) => { + logger.error( + "Failed to set application_name on pool connection: %O", + err, + ); + }); + if (Number.isFinite(ms) && ms > 0) { + client.query(`SET statement_timeout = ${Math.floor(ms)}`).catch((err) => { + logger.error( + "Failed to set statement_timeout on pool connection: %O", + err, + ); + }); + } }); } + +/** + * When `DEBUG_POOL=1` is set, periodically log the pool's + * total/idle/waiting connection counts so operators can observe saturation. + * The interval is unrefed so it never blocks shutdown. + */ +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 index 9134ed2eb..02b7a4249 100644 --- a/packages/appkit/src/plugins/database/defaults.ts +++ b/packages/appkit/src/plugins/database/defaults.ts @@ -29,11 +29,14 @@ export const writeDefaults: PluginExecuteConfig = { * Max 10 connections * Idle timeout 30s * Connection timeout 10s + * MaxUses 1000 — recycle long-lived connections (mitigates Lakebase OAuth + * token expiry on idle keep-alives). */ export const POOL_DEFAULTS = { max: 10, idleTimeoutMillis: 30_000, connectionTimeoutMillis: 10_000, + maxUses: 1000, }; /** @@ -41,3 +44,10 @@ export const POOL_DEFAULTS = { * 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/manifest.json b/packages/appkit/src/plugins/database/manifest.json index e66c755d0..162b0b969 100644 --- a/packages/appkit/src/plugins/database/manifest.json +++ b/packages/appkit/src/plugins/database/manifest.json @@ -2,7 +2,7 @@ "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", "name": "database", "displayName": "Database", - "description": "Application database with schema-driven CRUD, type generation, OBO, RLS, and LLM tools", + "description": "Application database with schema-driven CRUD, type generation, OBO, and RLS", "hidden": false, "stability": "beta", "resources": { diff --git a/packages/appkit/src/plugins/database/tests/plugin.test.ts b/packages/appkit/src/plugins/database/tests/plugin.test.ts index b3cf2c9a9..6ef5071d6 100644 --- a/packages/appkit/src/plugins/database/tests/plugin.test.ts +++ b/packages/appkit/src/plugins/database/tests/plugin.test.ts @@ -63,6 +63,7 @@ describe("DatabasePlugin", () => { max: 3, idleTimeoutMillis: 30_000, connectionTimeoutMillis: 10_000, + maxUses: 1000, }); expect(plugin.exports()).toEqual({ getPool: expect.any(Function) }); expect((plugin.exports() as { getPool: () => Pool }).getPool()).toBe(pool); @@ -125,7 +126,7 @@ describe("DatabasePlugin", () => { expect(settled).toBe(true); }); - test("setup applies statement_timeout to every new pool connection", async () => { + test("setup applies session defaults (application_name + statement_timeout) on every new connection", async () => { const plugin = createPlugin({ statementTimeoutMs: 7_000 }); await plugin.setup(); @@ -139,6 +140,9 @@ describe("DatabasePlugin", () => { }) => 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"); }); From e4ed45e1a8ddd8ac45d18e164b7d220b42a2d5f0 Mon Sep 17 00:00:00 2001 From: ditadi Date: Tue, 5 May 2026 14:04:59 +0100 Subject: [PATCH 09/19] fix(server): cap pool drain wait so SIGTERM force-quit always fires Signed-off-by: ditadi --- packages/appkit/src/plugins/server/index.ts | 41 ++++++++++++++------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/packages/appkit/src/plugins/server/index.ts b/packages/appkit/src/plugins/server/index.ts index c3021e48b..7496cbd10 100644 --- a/packages/appkit/src/plugins/server/index.ts +++ b/packages/appkit/src/plugins/server/index.ts @@ -337,6 +337,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(); } @@ -347,20 +354,34 @@ export class ServerPlugin extends Plugin { // 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) { + const DRAIN_TIMEOUT_MS = 13_000; const drains = Object.values(this.config.plugins) .map((plugin) => { if (!plugin.abortActiveOperations) return null; try { - return Promise.resolve(plugin.abortActiveOperations()).catch( - (err) => { - logger.error( - "Error aborting operations for plugin %s: %O", + 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, - err, + 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", @@ -382,12 +403,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); } From 53f29e079a6305f67753df707c7b505c524e474e Mon Sep 17 00:00:00 2001 From: ditadi Date: Tue, 5 May 2026 14:05:12 +0100 Subject: [PATCH 10/19] feat(plugin): allow BasePlugin.abortActiveOperations to return a promise Signed-off-by: ditadi --- packages/shared/src/plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; From 6b9239e71c37869ae166bb3ec40c495f61968227 Mon Sep 17 00:00:00 2001 From: ditadi Date: Tue, 5 May 2026 14:05:25 +0100 Subject: [PATCH 11/19] feat(database): defer fk() refs; FkColumnChain preserves chain typing; add private-column helpers Signed-off-by: ditadi --- .../src/database/schema-builder/columns.ts | 100 ++++++++++++------ .../database/schema-builder/define-schema.ts | 31 +++++- .../src/database/schema-builder/index.ts | 7 ++ .../src/database/schema-builder/private.ts | 33 ++++++ .../src/database/schema-builder/table.ts | 72 ++++++++++--- .../src/database/schema-builder/types.ts | 86 +++++---------- 6 files changed, 224 insertions(+), 105 deletions(-) create mode 100644 packages/appkit/src/database/schema-builder/private.ts diff --git a/packages/appkit/src/database/schema-builder/columns.ts b/packages/appkit/src/database/schema-builder/columns.ts index 85d009bff..563c5dfda 100644 --- a/packages/appkit/src/database/schema-builder/columns.ts +++ b/packages/appkit/src/database/schema-builder/columns.ts @@ -15,6 +15,7 @@ import type { AppKitColumn, AppKitColumnChain, ColumnMeta, + FkColumnChain, Relation, } from "./types"; @@ -170,39 +171,72 @@ export function enumColumn( } /** - * Create a foreign key column. + * 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 The wrapped column chain. + * @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): AppKitColumnChain & { - onDelete(value: NonNullable): AppKitColumnChain; - onUpdate(value: NonNullable): AppKitColumnChain; -} { - const chain = wrap(pgInteger(), { - references: - target.$meta.tableName && target.$meta.columnName - ? { - toTable: target.$meta.tableName, - toColumn: target.$meta.columnName, - } - : undefined, - }) as AppKitColumnChain & { - onDelete(value: NonNullable): AppKitColumnChain; - onUpdate(value: NonNullable): AppKitColumnChain; - }; - chain.onDelete = (value) => { - chain.$meta.references = { - ...(chain.$meta.references ?? { toTable: "", toColumn: "" }), - onDelete: value, - }; - return chain; - }; - chain.onUpdate = (value) => { - chain.$meta.references = { - ...(chain.$meta.references ?? { toTable: "", toColumn: "" }), - onUpdate: value, - }; - return chain; - }; - return chain; +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 index d498e25a8..89ed323b0 100644 --- a/packages/appkit/src/database/schema-builder/define-schema.ts +++ b/packages/appkit/src/database/schema-builder/define-schema.ts @@ -1,9 +1,11 @@ import { pgSchema } from "drizzle-orm/pg-core"; +import { ValidationError } from "../../errors"; import { enumColumn } from "./columns"; -import { buildTable } from "./table"; +import { buildTable, rebuildRelationsFromColumns } from "./table"; import { APPKIT_TABLE, type AppKitTable, + type Relation, type Schema, type SchemaBuilderContext, } from "./types"; @@ -40,6 +42,33 @@ export function defineSchema>( } } + // 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, diff --git a/packages/appkit/src/database/schema-builder/index.ts b/packages/appkit/src/database/schema-builder/index.ts index 9d41e53b7..03791760d 100644 --- a/packages/appkit/src/database/schema-builder/index.ts +++ b/packages/appkit/src/database/schema-builder/index.ts @@ -1,6 +1,7 @@ export { bigint, boolean, + enumColumn, enumColumn as enumeration, fk, id, @@ -12,11 +13,17 @@ export { 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, 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 index 9a8524c0d..99f37d366 100644 --- a/packages/appkit/src/database/schema-builder/table.ts +++ b/packages/appkit/src/database/schema-builder/table.ts @@ -5,9 +5,53 @@ 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. @@ -28,6 +72,19 @@ export function buildTable< 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, @@ -44,20 +101,7 @@ export function buildTable< ]), ); - const $relations: Relation[] = Object.entries(columns) - .map(([columnName, definition]): Relation | null => { - const reference = definition.$meta.references; - if (!reference?.toTable || !reference?.toColumn) return null; - 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; - return relation; - }) - .filter((relation): relation is Relation => relation !== null); + const $relations: Relation[] = buildRelations(columns); const privateMask = Object.fromEntries( Object.entries(columns) diff --git a/packages/appkit/src/database/schema-builder/types.ts b/packages/appkit/src/database/schema-builder/types.ts index 5c6d20006..f7cfba78e 100644 --- a/packages/appkit/src/database/schema-builder/types.ts +++ b/packages/appkit/src/database/schema-builder/types.ts @@ -7,39 +7,35 @@ 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. - * @example - * ```ts - * const columnMeta: ColumnMeta = { - * serverGenerated: true, - * }; - * ``` */ export interface ColumnMeta { serverGenerated?: boolean; primaryKey?: boolean; /** - * Hides the column from default reads, writes, and column metadata. Set via - * `.private()` on the column chain. Used to keep secrets like password hashes - * out of the public surface without forking the schema. + * 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 */ - references?: Pick; + /** + * @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. - * @example - * ```ts - * const column: AppKitColumn = { - * $builder: unknown, - * $meta: columnMeta, - * }; - * ``` */ export interface AppKitColumn { $builder: unknown; @@ -48,13 +44,6 @@ export interface AppKitColumn { /** * A chain of AppKit column methods. This is returned by the column builder methods. - * @example - * ```ts - * const column: AppKitColumnChain = { - * $builder: unknown, - * $meta: columnMeta, - * }; - * ``` */ export interface AppKitColumnChain extends AppKitColumn { notNull(): AppKitColumnChain; @@ -66,18 +55,23 @@ export interface AppKitColumnChain extends AppKitColumn { 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. - * @example - * ```ts - * const relation: Relation = { - * fromColumn: "userId", - * toTable: "users", - * toColumn: "id", - * onDelete: "cascade", - * onUpdate: "cascade", - * }; - * ``` */ export interface Relation { fromColumn: string; @@ -90,13 +84,6 @@ export interface Relation { /** * An AppKit table. This is returned by the table builder methods. * This is used to define the table schema and relationships. - * @example - * ```ts - * const table: AppKitTable = { - * $builder: unknown, - * $meta: tableMeta, - * }; - * ``` */ export interface AppKitTable { readonly [APPKIT_TABLE]: true; @@ -110,14 +97,6 @@ export interface AppKitTable { /** * A schema. This is used to define the schema for the database. - * @example - * ```ts - * const schema: Schema = { - * $drizzle: unknown, - * $tables: { tableName: AppKitTable }, - * $migrations: { snapshotHints: unknown }, - * }; - * ``` */ export type Schema< T extends Record = Record, @@ -129,13 +108,6 @@ export type Schema< /** * A context for the schema builder. This is used to build the schema. - * @example - * ```ts - * const context: SchemaBuilderContext = { - * table: (name, columns) => table(name, columns), - * enum: (name, values) => enum(name, values), - * }; - * ``` */ export interface SchemaBuilderContext { table: >( From 6ba8a3de51a6fcae48124fc7fab4e1e2b017d20e Mon Sep 17 00:00:00 2001 From: ditadi Date: Tue, 5 May 2026 14:05:37 +0100 Subject: [PATCH 12/19] chore(connectors/lakebase): drop unused PostgREST factory and @neondatabase/postgrest-js dep Signed-off-by: ditadi --- packages/appkit/package.json | 1 - .../appkit/src/connectors/lakebase/index.ts | 8 -- .../src/connectors/lakebase/postgrest.ts | 72 ------------ .../lakebase/tests/postgrest.test.ts | 106 ------------------ packages/appkit/src/index.ts | 6 +- 5 files changed, 1 insertion(+), 192 deletions(-) delete mode 100644 packages/appkit/src/connectors/lakebase/postgrest.ts delete mode 100644 packages/appkit/src/connectors/lakebase/tests/postgrest.test.ts diff --git a/packages/appkit/package.json b/packages/appkit/package.json index a773c3414..9d7994684 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -57,7 +57,6 @@ "@ast-grep/napi": "0.37.0", "@databricks/lakebase": "workspace:*", "@databricks/sdk-experimental": "0.16.0", - "@neondatabase/postgrest-js": "0.1.0-alpha.2", "@opentelemetry/api": "1.9.0", "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/auto-instrumentations-node": "0.67.2", diff --git a/packages/appkit/src/connectors/lakebase/index.ts b/packages/appkit/src/connectors/lakebase/index.ts index 9cef6b7dd..c58b7a8cb 100644 --- a/packages/appkit/src/connectors/lakebase/index.ts +++ b/packages/appkit/src/connectors/lakebase/index.ts @@ -35,11 +35,3 @@ export { RequestedClaimsPermissionSet, type RequestedResource, } from "@databricks/lakebase"; - -// Export Lakebase PostgREST client related types and functions. -export { - createLakebasePostgrestClient, - type LakebasePostgrestClient, - type LakebasePostgrestClientConfig, - type LakebaseTokenResolver, -} from "./postgrest"; diff --git a/packages/appkit/src/connectors/lakebase/postgrest.ts b/packages/appkit/src/connectors/lakebase/postgrest.ts deleted file mode 100644 index 13473a30d..000000000 --- a/packages/appkit/src/connectors/lakebase/postgrest.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - fetchWithToken, - NeonPostgrestClient, -} from "@neondatabase/postgrest-js"; -import { ConfigurationError } from "@/errors"; -import { createLogger } from "@/logging/logger"; - -const logger = createLogger("connectors:lakebase:postgrest"); - -/** - * A function that resolves a Lakebase token. - * @example - * ```ts - * const resolveToken = async () => { - * const token = await getLakebaseServicePrincipalToken(); - * return token; - * }; - * ``` - */ -export type LakebaseTokenResolver = () => Promise; - -/** - * Configuration for creating a Lakebase PostgREST client. - * @example - * ```ts - * const config: LakebasePostgrestClientConfig = { - * dataApiUrl: "https://data-api.lakebase.databricks.com", - * schema: "app", - * resolveToken: async () => { - * const token = await getLakebaseServicePrincipalToken(); - * return token; - * }, - * }; - * ``` - */ -export interface LakebasePostgrestClientConfig { - dataApiUrl?: string; - schema?: string; - resolveToken: LakebaseTokenResolver; - fetch?: typeof fetch; -} - -// Add unknown type to avoid importing NeonPostgrestClient type. -export type LakebasePostgrestClient = unknown; - -/** - * Create a Lakebase PostgREST client. - * - * @param config - Configuration for creating a Lakebase PostgREST client. - * @returns A Lakebase PostgREST client. - */ -export function createLakebasePostgrestClient( - config: LakebasePostgrestClientConfig, -): LakebasePostgrestClient { - const dataApiUrl = config.dataApiUrl ?? process.env.LAKEBASE_DATA_API_URL; - - if (!dataApiUrl) { - throw ConfigurationError.missingEnvVar("LAKEBASE_DATA_API_URL"); - } - - logger.debug("createLakebasePostgrestClient: dataApiUrl", dataApiUrl); - - return new NeonPostgrestClient({ - dataApiUrl, - options: { - db: { schema: config.schema ?? "app" }, - global: { - fetch: fetchWithToken(config.resolveToken, config.fetch), - }, - }, - }); -} diff --git a/packages/appkit/src/connectors/lakebase/tests/postgrest.test.ts b/packages/appkit/src/connectors/lakebase/tests/postgrest.test.ts deleted file mode 100644 index 0d0fd48ee..000000000 --- a/packages/appkit/src/connectors/lakebase/tests/postgrest.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { afterEach, describe, expect, test } from "vitest"; -import { ConfigurationError } from "../../../errors"; -import { createLakebasePostgrestClient } from "../postgrest"; - -interface RecordedCall { - url: string; - headers: Record; -} - -function recordingFetch(): { - fetch: typeof fetch; - calls: RecordedCall[]; -} { - const calls: RecordedCall[] = []; - const fetchImpl = (async ( - input: Parameters[0], - init?: RequestInit, - ) => { - const headers: Record = {}; - if (init?.headers) { - const h = - init.headers instanceof Headers - ? init.headers - : new Headers(init.headers); - h.forEach((value, key) => { - headers[key.toLowerCase()] = value; - }); - } - calls.push({ url: String(input), headers }); - return new Response("[]", { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }) as typeof fetch; - return { fetch: fetchImpl, calls }; -} - -interface PostgrestClient { - from(table: string): { - select(): Promise; - }; -} - -describe("createLakebasePostgrestClient", () => { - afterEach(() => { - delete process.env.LAKEBASE_DATA_API_URL; - }); - - test("throws when no Data API URL is configured", () => { - expect(() => - createLakebasePostgrestClient({ resolveToken: async () => "tok" }), - ).toThrow(ConfigurationError); - }); - - test("issues PostgREST requests against the configured dataApiUrl with token + schema headers", async () => { - const { fetch: recording, calls } = recordingFetch(); - - const client = createLakebasePostgrestClient({ - dataApiUrl: "https://example.test/rest/v1", - schema: "custom", - resolveToken: async () => "tok-abc", - fetch: recording, - }) as PostgrestClient; - - await client.from("widgets").select(); - - expect(calls).toHaveLength(1); - const [call] = calls; - expect(call.url).toContain("https://example.test/rest/v1/widgets"); - expect(call.headers.authorization).toBe("Bearer tok-abc"); - expect(call.headers["accept-profile"]).toBe("custom"); - }); - - test("falls back to LAKEBASE_DATA_API_URL and the app schema", async () => { - process.env.LAKEBASE_DATA_API_URL = "https://env.example/rest/v1"; - const { fetch: recording, calls } = recordingFetch(); - - const client = createLakebasePostgrestClient({ - resolveToken: async () => "tok", - fetch: recording, - }) as PostgrestClient; - - await client.from("widgets").select(); - - expect(calls).toHaveLength(1); - expect(calls[0].url).toContain("https://env.example/rest/v1/widgets"); - expect(calls[0].headers["accept-profile"]).toBe("app"); - }); - - test("a null token surfaces as AuthRequiredError without firing fetch", async () => { - const { fetch: recording, calls } = recordingFetch(); - - const client = createLakebasePostgrestClient({ - dataApiUrl: "https://example.test/rest/v1", - resolveToken: async () => null, - fetch: recording, - }) as PostgrestClient; - - const result = (await client.from("widgets").select()) as { - error: { message: string } | null; - }; - - expect(calls).toHaveLength(0); - expect(result.error?.message).toMatch(/AuthRequiredError/); - }); -}); diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index 8fd04efba..607336ea5 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -20,16 +20,12 @@ export type { DatabaseCredential, GenerateDatabaseCredentialRequest, LakebasePoolConfig, - LakebasePostgrestClient, - LakebasePostgrestClientConfig, - LakebaseTokenResolver, RequestedClaims, RequestedResource, } from "./connectors/lakebase"; -// Lakebase Autoscaling connector + export { createLakebasePool, - createLakebasePostgrestClient, generateDatabaseCredential, getLakebaseOrmConfig, getLakebasePgConfig, From e648b0735175c836d30e9adb93d9cb6c72b6267c Mon Sep 17 00:00:00 2001 From: ditadi Date: Tue, 5 May 2026 14:06:42 +0100 Subject: [PATCH 13/19] chore(database): align manifest config; tune defaults; drop forward-leaked exports Signed-off-by: ditadi --- packages/appkit/src/beta.ts | 2 +- .../appkit/src/plugins/database/defaults.ts | 41 ++------- packages/appkit/src/plugins/database/index.ts | 1 + .../appkit/src/plugins/database/manifest.json | 21 ++++- packages/appkit/src/plugins/database/types.ts | 91 +++++++++++-------- 5 files changed, 83 insertions(+), 73 deletions(-) diff --git a/packages/appkit/src/beta.ts b/packages/appkit/src/beta.ts index 66cd8ee65..31cc16be2 100644 --- a/packages/appkit/src/beta.ts +++ b/packages/appkit/src/beta.ts @@ -7,10 +7,10 @@ export { DatabricksAdapter, parseTextToolCalls } from "./agents/databricks"; export * from "./plugins/beta-exports.generated"; export type { + DatabasePoolTuning, EntityHooks, HookContext, HttpAccess, HttpEntityOverride, IDatabaseConfig, } from "./plugins/database"; -export { readDefaults, writeDefaults } from "./plugins/database/defaults"; diff --git a/packages/appkit/src/plugins/database/defaults.ts b/packages/appkit/src/plugins/database/defaults.ts index 02b7a4249..ab45c90cb 100644 --- a/packages/appkit/src/plugins/database/defaults.ts +++ b/packages/appkit/src/plugins/database/defaults.ts @@ -1,47 +1,20 @@ -import type { PluginExecuteConfig } from "shared"; - -/** - * Execution defaults for read-tier operations (list, find, count). - * Cache 0s (ttl in seconds) - * Retry 3x with 200ms backoff - * Timeout 15s - */ -export const readDefaults: PluginExecuteConfig = { - cache: { enabled: false, ttl: 0 }, - retry: { enabled: true, initialDelay: 200, attempts: 3 }, - timeout: 15_000, -}; - -/** - * Execution defaults for write-tier operations (create, update, delete). - * No cache - * No retry - * Timeout 15s - */ -export const writeDefaults: PluginExecuteConfig = { - cache: { enabled: false, ttl: 0 }, - retry: { enabled: false, initialDelay: 0, attempts: 1 }, - timeout: 15_000, -}; - /** * Connection pool defaults for the service-principal pool. - * Max 10 connections - * Idle timeout 30s - * Connection timeout 10s - * MaxUses 1000 — recycle long-lived connections (mitigates Lakebase OAuth - * token expiry on idle keep-alives). + * 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: 10_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. + * 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; diff --git a/packages/appkit/src/plugins/database/index.ts b/packages/appkit/src/plugins/database/index.ts index 17d527a99..bbbfc8f4d 100644 --- a/packages/appkit/src/plugins/database/index.ts +++ b/packages/appkit/src/plugins/database/index.ts @@ -1,5 +1,6 @@ export * from "./database"; export type { + DatabasePoolTuning, EntityHooks, HookContext, HttpAccess, diff --git a/packages/appkit/src/plugins/database/manifest.json b/packages/appkit/src/plugins/database/manifest.json index 162b0b969..fb8c91700 100644 --- a/packages/appkit/src/plugins/database/manifest.json +++ b/packages/appkit/src/plugins/database/manifest.json @@ -2,7 +2,7 @@ "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", "name": "database", "displayName": "Database", - "description": "Application database with schema-driven CRUD, type generation, OBO, and RLS", + "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": { @@ -61,6 +61,23 @@ "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": { @@ -83,5 +100,5 @@ } } }, - "onSetupMessage": "Database plugin installed. Next steps:\n Greenfield: npx appkit db generate user name:string email:string\n Brownfield: npx appkit db introspect\n Then: npx appkit db migrate up && pnpm dev" + "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/types.ts b/packages/appkit/src/plugins/database/types.ts index c88caa07e..a9838a008 100644 --- a/packages/appkit/src/plugins/database/types.ts +++ b/packages/appkit/src/plugins/database/types.ts @@ -1,5 +1,27 @@ import type { BasePluginConfig } from "shared"; -import type { LakebasePoolConfig } from "@/connectors"; + +/** + * 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. @@ -12,17 +34,17 @@ export type HttpAccess = "public" | "obo" | "service" | false; * @public */ export interface HttpEntityOverride { - /** The HTTP access control for the list operation. */ + /** Access mode for list. */ list?: HttpAccess; - /** The HTTP access control for the find operation. */ + /** Access mode for find. */ find?: HttpAccess; - /** The HTTP access control for the count operation. */ + /** Access mode for count. */ count?: HttpAccess; - /** The HTTP access control for the create operation. */ + /** Access mode for create. */ create?: HttpAccess; - /** The HTTP access control for the update operation. */ + /** Access mode for update. */ update?: HttpAccess; - /** The HTTP access control for the delete operation. */ + /** Access mode for delete. */ delete?: HttpAccess; } @@ -31,11 +53,11 @@ export interface HttpEntityOverride { * @public */ export interface HookContext { - /** The request object. */ + /** Request object. */ req?: import("express").Request; - /** The entity name. */ + /** Entity name. */ entity?: string; - /** The user ID. */ + /** User ID. */ userId?: string; } @@ -44,30 +66,30 @@ export interface HookContext { * @public */ export interface EntityHooks { - /** A hook to run before a create operation. */ + /** Runs before create. */ beforeCreate?: ( data: Record, ctx: HookContext, ) => Promise | void>; - /** A hook to run after a create operation. */ + /** Runs after create. */ afterCreate?: ( row: Record, ctx: HookContext, ) => Promise; - /** A hook to run before an update operation. */ + /** Runs before update. */ beforeUpdate?: ( id: unknown, patch: Record, ctx: HookContext, ) => Promise | void>; - /** A hook to run after an update operation. */ + /** Runs after update. */ afterUpdate?: ( row: Record, ctx: HookContext, ) => Promise; - /** A hook to run before a delete operation. */ + /** Runs before delete. */ beforeDelete?: (id: unknown, ctx: HookContext) => Promise; - /** A hook to run after a delete operation. */ + /** Runs after delete. */ afterDelete?: (id: unknown, ctx: HookContext) => Promise; } @@ -76,7 +98,7 @@ export interface EntityHooks { * @public */ export interface CacheActionSettings { - /** The time to live for the cache in seconds. */ + /** Cache TTL in seconds. */ ttl?: number; } @@ -85,11 +107,11 @@ export interface CacheActionSettings { * @public */ export interface CacheSettings { - /** The cache settings for the list operation. */ + /** Cache settings for list. */ list?: CacheActionSettings; - /** The cache settings for the find operation. */ + /** Cache settings for find. */ find?: CacheActionSettings; - /** The cache settings for the count operation. */ + /** Cache settings for count. */ count?: CacheActionSettings; } @@ -98,32 +120,29 @@ export interface CacheSettings { * @public */ export interface IDatabaseConfig extends BasePluginConfig { - /** The connection settings for the database. */ - connection?: Partial; - /** The HTTP entity overrides for the database. */ + /** + * Pool tuning forwarded to `createLakebasePool` (no auth fields). + */ + connection?: DatabasePoolTuning; + /** Per-entity HTTP access overrides. */ http?: Record; - /** The entity hooks for the database. */ + /** Per-entity lifecycle hooks. */ hooks?: Record; - /** The cache settings for the database. */ + /** Per-operation cache settings. */ cache?: CacheSettings; /** - * Maximum number of distinct per-user (OBO) pools the registry keeps alive - * at once. Each pool defaults to `OBO_POOL_DEFAULTS.max = 4` connections, so - * the worst-case fan-out is `(1 + oboPoolMax) × poolMax`. Defaults to 25 — - * tune up for hot OBO traffic, down for low-tier Lakebase plans. + * Max distinct OBO pools kept alive. Defaults to 25. + * Worst-case fan-out is `(1 + oboPoolMax) × poolMax`. */ oboPoolMax?: number; /** - * Postgres `statement_timeout` applied to every pooled connection (ms). - * Defaults to 15s. Set to `0` to disable the server-side cap; the AppKit - * timeout interceptor still applies on the client side. + * Postgres `statement_timeout` (ms) for pooled connections. Defaults to 15s. + * Set `0` to disable server-side timeout (client timeout still applies). */ statementTimeoutMs?: number; /** - * When true, schema-load and drift-check failures during `setup()` are - * logged but do not throw. Defaults to false (fail closed). Useful in - * environments where the database is provisioned out of band and the boot - * shouldn't crash before the schema is reachable. + * If true, `setup()` schema/drift failures are logged and ignored. + * Defaults to false (fail closed). */ tolerateSetupFailure?: boolean; } From 0f3e93be56de425e1d85b617303dc95a6d4d59e6 Mon Sep 17 00:00:00 2001 From: ditadi Date: Tue, 5 May 2026 14:06:52 +0100 Subject: [PATCH 14/19] fix(database): destroy pool client on SET failure; drain pool on schema-load failure Signed-off-by: ditadi --- .../appkit/src/plugins/database/database.ts | 82 +++++++++++++------ .../src/plugins/database/tests/plugin.test.ts | 5 +- 2 files changed, 60 insertions(+), 27 deletions(-) diff --git a/packages/appkit/src/plugins/database/database.ts b/packages/appkit/src/plugins/database/database.ts index e9e2f25f3..1cc527d56 100644 --- a/packages/appkit/src/plugins/database/database.ts +++ b/packages/appkit/src/plugins/database/database.ts @@ -35,8 +35,9 @@ class DatabasePlugin extends Plugin { ...this.config.connection, }); attachSessionDefaults(this.pool, this.config.statementTimeoutMs); - if (process.env.DEBUG_POOL) + if (process.env.APPKIT_DEBUG_POOL || process.env.DEBUG_POOL) { startPoolStatsLog(this.pool, "service-principal"); + } logger.info("Database plugin pool initialized"); try { @@ -56,16 +57,24 @@ class DatabasePlugin extends Plugin { Object.keys(loaded.schema.$tables).length, ); } catch (err) { - // A throwing schema-load otherwise cascades through Promise.all in core - // and crashes every plugin's boot. Decorate the error with the - // convention path so the operator can find it, then re-raise unless the - // caller opted into tolerant boot. 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) throw err; + 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; + } } } @@ -103,38 +112,59 @@ class DatabasePlugin extends Plugin { export const database = toPlugin(DatabasePlugin); /** - * Attach a `connect` listener that sets per-session defaults on every new - * Postgres session checked out of the pool: `statement_timeout` (caps runaway - * queries even when the client signal is dropped) and `application_name` (so - * the connection is attributable in `pg_stat_activity`). + * 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 = '${APPLICATION_NAME}'`) - .catch((err) => { - logger.error( - "Failed to set application_name on pool connection: %O", - err, - ); - }); + .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) => { - logger.error( - "Failed to set statement_timeout on pool connection: %O", - err, - ); - }); + client + .query(`SET statement_timeout = ${Math.floor(ms)}`) + .catch((err) => destroy("statement_timeout", err)); } }); } /** - * When `DEBUG_POOL=1` is set, periodically log the pool's - * total/idle/waiting connection counts so operators can observe saturation. - * The interval is unrefed so it never blocks shutdown. + * 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(() => { diff --git a/packages/appkit/src/plugins/database/tests/plugin.test.ts b/packages/appkit/src/plugins/database/tests/plugin.test.ts index 6ef5071d6..6f67359f3 100644 --- a/packages/appkit/src/plugins/database/tests/plugin.test.ts +++ b/packages/appkit/src/plugins/database/tests/plugin.test.ts @@ -62,7 +62,10 @@ describe("DatabasePlugin", () => { expect(createLakebasePool).toHaveBeenCalledWith({ max: 3, idleTimeoutMillis: 30_000, - connectionTimeoutMillis: 10_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) }); From 08c385aba919c60d106fe6578e3b757ad62d5e8b Mon Sep 17 00:00:00 2001 From: ditadi Date: Wed, 6 May 2026 13:36:21 +0100 Subject: [PATCH 15/19] fix(database): id() stamps primaryKey meta; primaryKey() chain stamps too; trim enum error --- .../appkit/src/database/schema-builder/columns.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/appkit/src/database/schema-builder/columns.ts b/packages/appkit/src/database/schema-builder/columns.ts index 563c5dfda..0f5f7022d 100644 --- a/packages/appkit/src/database/schema-builder/columns.ts +++ b/packages/appkit/src/database/schema-builder/columns.ts @@ -44,6 +44,9 @@ function wrap(builder: unknown, meta: ColumnMeta = {}): AppKitColumnChain { 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) { @@ -82,6 +85,7 @@ function wrap(builder: unknown, meta: ColumnMeta = {}): AppKitColumnChain { export function id(): AppKitColumnChain { return wrap(serial().primaryKey(), { serverGenerated: true, + primaryKey: true, }); } @@ -161,9 +165,10 @@ export function enumColumn( values: readonly string[], ): AppKitColumnChain { if (values.length === 0) { - throw new ValidationError(`enumColumn ${name} values must not be empty`, { - context: { enumName: name }, - }); + throw new ValidationError( + `enum "${name}" must declare at least one value`, + { context: { enumName: name } }, + ); } const enumType = pgEnum(name, values as [string, ...string[]]); From 0f444171c43201bcb8174deae46f69b54c5f265c Mon Sep 17 00:00:00 2001 From: ditadi Date: Thu, 7 May 2026 00:53:59 +0100 Subject: [PATCH 16/19] fix: update pnpm lockfile From f0ab3cd8a251976072243669b4881de2ff04e43a Mon Sep 17 00:00:00 2001 From: ditadi Date: Thu, 7 May 2026 00:54:18 +0100 Subject: [PATCH 17/19] fix: update pnpm lockfile --- .gitignore | 1 + pnpm-lock.yaml | 21 ++------------------- 2 files changed, 3 insertions(+), 19 deletions(-) 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/pnpm-lock.yaml b/pnpm-lock.yaml index 823a75249..386451460 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,9 +251,6 @@ importers: '@databricks/sdk-experimental': specifier: 0.16.0 version: 0.16.0 - '@neondatabase/postgrest-js': - specifier: 0.1.0-alpha.2 - version: 0.1.0-alpha.2 '@opentelemetry/api': specifier: 1.9.0 version: 1.9.0 @@ -2685,9 +2682,6 @@ packages: '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} - '@neondatabase/postgrest-js@0.1.0-alpha.2': - resolution: {integrity: sha512-fE3Kd6Tj3uUVx6jvDUA3USW4gZaSAL0NoGePFg31xDfBbmS2EBpdboKUnac8rJVajUiCDQPj6/j4G1eXOleYsg==} - '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -4557,10 +4551,6 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@supabase/postgrest-js@2.79.0': - resolution: {integrity: sha512-2i8EFm3/49ecjt6dk/TGVROBbtOmhryiC4NL3u0FBIrm2hqj+FvbELv1jjM6r+a6abnh+uzIV/bFsWHAa/k3/w==} - engines: {node: '>=20.0.0'} - '@svgr/babel-plugin-add-jsx-attribute@8.0.0': resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} engines: {node: '>=14'} @@ -5570,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==} @@ -6684,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==} @@ -14958,10 +14949,6 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@neondatabase/postgrest-js@0.1.0-alpha.2': - dependencies: - '@supabase/postgrest-js': 2.79.0 - '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -16880,10 +16867,6 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@supabase/postgrest-js@2.79.0': - dependencies: - tslib: 2.8.1 - '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 From 979f2a90b8fd9fcf4353eaf19996a29c3523969a Mon Sep 17 00:00:00 2001 From: ditadi Date: Thu, 7 May 2026 01:05:59 +0100 Subject: [PATCH 18/19] fix: sync template plugin manifest --- template/appkit.plugins.json | 61 ++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) 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", From 04798130912455ebc60b0a65b47fb1b9050a4468 Mon Sep 17 00:00:00 2001 From: ditadi Date: Thu, 7 May 2026 02:22:13 +0100 Subject: [PATCH 19/19] fix: build docs --- docs/docs/api/appkit/Class.Plugin.md | 4 +- docs/docs/api/appkit/Function.bigint.md | 13 ++ docs/docs/api/appkit/Function.boolean.md | 13 ++ docs/docs/api/appkit/Function.defineSchema.md | 26 +++ docs/docs/api/appkit/Function.enumColumn.md | 20 ++ docs/docs/api/appkit/Function.fk.md | 27 +++ docs/docs/api/appkit/Function.id.md | 13 ++ docs/docs/api/appkit/Function.integer.md | 13 ++ .../api/appkit/Function.isPrivateColumn.md | 18 ++ docs/docs/api/appkit/Function.jsonb.md | 13 ++ .../appkit/Function.nonPrivateColumnNames.md | 17 ++ .../api/appkit/Function.privateColumnNames.md | 17 ++ docs/docs/api/appkit/Function.text.md | 13 ++ docs/docs/api/appkit/Function.timestamp.md | 13 ++ docs/docs/api/appkit/Function.uuid.md | 13 ++ docs/docs/api/appkit/Function.varchar.md | 19 ++ .../docs/api/appkit/Interface.AppKitColumn.md | 23 +++ .../api/appkit/Interface.AppKitColumnChain.md | 131 ++++++++++++ docs/docs/api/appkit/Interface.AppKitTable.md | 66 ++++++ docs/docs/api/appkit/Interface.ColumnMeta.md | 30 +++ .../appkit/Interface.DefineSchemaOptions.md | 11 + .../api/appkit/Interface.FkColumnChain.md | 191 ++++++++++++++++++ docs/docs/api/appkit/Interface.Relation.md | 43 ++++ .../appkit/Interface.SchemaBuilderContext.md | 48 +++++ docs/docs/api/appkit/TypeAlias.Schema.md | 47 +++++ docs/docs/api/appkit/Variable.APPKIT_TABLE.md | 7 + docs/docs/api/appkit/index.md | 31 +++ docs/docs/api/appkit/typedoc-sidebar.ts | 125 ++++++++++++ 28 files changed, 1003 insertions(+), 2 deletions(-) create mode 100644 docs/docs/api/appkit/Function.bigint.md create mode 100644 docs/docs/api/appkit/Function.boolean.md create mode 100644 docs/docs/api/appkit/Function.defineSchema.md create mode 100644 docs/docs/api/appkit/Function.enumColumn.md create mode 100644 docs/docs/api/appkit/Function.fk.md create mode 100644 docs/docs/api/appkit/Function.id.md create mode 100644 docs/docs/api/appkit/Function.integer.md create mode 100644 docs/docs/api/appkit/Function.isPrivateColumn.md create mode 100644 docs/docs/api/appkit/Function.jsonb.md create mode 100644 docs/docs/api/appkit/Function.nonPrivateColumnNames.md create mode 100644 docs/docs/api/appkit/Function.privateColumnNames.md create mode 100644 docs/docs/api/appkit/Function.text.md create mode 100644 docs/docs/api/appkit/Function.timestamp.md create mode 100644 docs/docs/api/appkit/Function.uuid.md create mode 100644 docs/docs/api/appkit/Function.varchar.md create mode 100644 docs/docs/api/appkit/Interface.AppKitColumn.md create mode 100644 docs/docs/api/appkit/Interface.AppKitColumnChain.md create mode 100644 docs/docs/api/appkit/Interface.AppKitTable.md create mode 100644 docs/docs/api/appkit/Interface.ColumnMeta.md create mode 100644 docs/docs/api/appkit/Interface.DefineSchemaOptions.md create mode 100644 docs/docs/api/appkit/Interface.FkColumnChain.md create mode 100644 docs/docs/api/appkit/Interface.Relation.md create mode 100644 docs/docs/api/appkit/Interface.SchemaBuilderContext.md create mode 100644 docs/docs/api/appkit/TypeAlias.Schema.md create mode 100644 docs/docs/api/appkit/Variable.APPKIT_TABLE.md 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" } ] }