From 88e7ab3f9cf4810163afe36dff6d7bbf6775a664 Mon Sep 17 00:00:00 2001 From: ditadi Date: Tue, 4 Aug 2026 23:58:57 +0100 Subject: [PATCH 1/3] feat(appkit): add hardened PostgreSQL runtime Repair schema invariants and add a bounded Drizzle execution boundary for future database APIs. Signed-off-by: ditadi --- .../src/database/contract/column-info.ts | 107 ---- .../appkit/src/database/contract/index.ts | 2 - .../appkit/src/database/contract/relation.ts | 24 - .../contract/tests/column-info.test.ts | 129 ----- .../database/contract/tests/registry.test.ts | 39 +- packages/appkit/src/database/contract/wire.ts | 7 +- .../appkit/src/database/runtime/data-path.ts | 126 ++++ .../runtime/engine/drizzle-data-path.ts | 270 +++++++++ .../src/database/runtime/engine/translate.ts | 290 ++++++++++ packages/appkit/src/database/runtime/index.ts | 15 + .../runtime/tests/data-path-contract.test.ts | 90 +++ .../runtime/tests/drizzle-data-path.test.ts | 489 ++++++++++++++++ .../database/runtime/tests/translate.test.ts | 273 +++++++++ .../src/database/schema-builder/columns.ts | 190 ++++-- .../database/schema-builder/define-schema.ts | 408 +++++++++---- .../schema-builder/engine/relations.ts | 30 +- .../database/schema-builder/engine/tables.ts | 193 ++++--- .../appkit/src/database/schema-builder/fk.ts | 119 +++- .../src/database/schema-builder/index.ts | 10 - .../src/database/schema-builder/private.ts | 27 - .../src/database/schema-builder/relations.ts | 92 +-- .../schema-builder/tests/columns.test.ts | 205 +++---- .../tests/define-schema.test.ts | 545 ++++++++++-------- .../tests/engine-relations.test.ts | 9 +- .../database/schema-builder/tests/fk.test.ts | 325 ++++++++++- .../schema-builder/tests/private.test.ts | 68 --- .../schema-builder/tests/relations.test.ts | 188 +++--- .../schema-builder/tests/validators.test.ts | 57 +- .../src/database/schema-builder/types.ts | 163 ++++-- .../src/database/schema-builder/validators.ts | 53 +- 30 files changed, 3230 insertions(+), 1313 deletions(-) delete mode 100644 packages/appkit/src/database/contract/column-info.ts delete mode 100644 packages/appkit/src/database/contract/relation.ts delete mode 100644 packages/appkit/src/database/contract/tests/column-info.test.ts create mode 100644 packages/appkit/src/database/runtime/data-path.ts create mode 100644 packages/appkit/src/database/runtime/engine/drizzle-data-path.ts create mode 100644 packages/appkit/src/database/runtime/engine/translate.ts create mode 100644 packages/appkit/src/database/runtime/index.ts create mode 100644 packages/appkit/src/database/runtime/tests/data-path-contract.test.ts create mode 100644 packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts create mode 100644 packages/appkit/src/database/runtime/tests/translate.test.ts delete mode 100644 packages/appkit/src/database/schema-builder/private.ts delete mode 100644 packages/appkit/src/database/schema-builder/tests/private.test.ts diff --git a/packages/appkit/src/database/contract/column-info.ts b/packages/appkit/src/database/contract/column-info.ts deleted file mode 100644 index 2def90fd4..000000000 --- a/packages/appkit/src/database/contract/column-info.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** Coarse classification of a Postgres column. */ -export type ColumnInfoKind = - | "string" - | "number" - | "bigint" - | "boolean" - | "date" - | "json" - | "uuid" - | "enum" - | "unknown"; - -export interface ColumnInfo { - /** Column name as stored in Postgres */ - name: string; - /** Canonical postgres data type */ - pgType: string; - /** Coarse classifier derived from {@link pgTypeToColumnInfoKind}. */ - kind: ColumnInfoKind; - /** Whether the column accepts NULL. */ - nullable: boolean; - /** Part of the primary key. */ - isPrimaryKey: boolean; - /** Value is produced by the database (serial / default), so it is omitted from inserts. */ - isServerGenerated: boolean; - /** Hidden from HTTP responses (`.private()`); reachable only by trusted server code. */ - isPrivate: boolean; - /** Enum members when {@link kind} is `"enum"`. */ - enumValues?: readonly string[]; -} - -const STRING_TYPES = new Set([ - "text", - "varchar", - "character varying", - "char", - "character", - "bpchar", - "name", - "citext", -]); - -const NUMBER_TYPES = new Set([ - "int2", - "smallint", - "int4", - "int", - "integer", - "serial", - "serial4", - "smallserial", - "real", - "float4", - "float8", - "double precision", - "numeric", - "decimal", - "money", -]); - -const BIGINT_TYPES = new Set(["int8", "bigint", "bigserial", "serial8"]); - -const BOOLEAN_TYPES = new Set(["bool", "boolean"]); - -const DATE_TYPES = new Set([ - "timestamp", - "timestamptz", - "timestamp with time zone", - "timestamp without time zone", - "date", - "time", - "timetz", - "time with time zone", - "time without time zone", -]); - -const JSON_TYPES = new Set(["json", "jsonb"]); - -/** - * Normalize a raw Postgres type token: lower-case, strip a length/precision - * specifier (`varchar(255)` → `varchar`) and a trailing array marker (`text[]`). - */ -export function normalizePgType(pgType: string): string { - return pgType - .trim() - .toLowerCase() - .replace(/\[\]$/, "") - .replace(/\(.*\)$/, "") - .trim(); -} - -/** - * Map a Postgres type to a coarse {@link ColumnInfoKind}. Enum columns are user - * (custom) types and are classified by the schema-builder/introspector directly, - * so an unrecognized type falls back to `"unknown"` here. - */ -export function pgTypeToColumnInfoKind(pgType: string): ColumnInfoKind { - const t = normalizePgType(pgType); - if (STRING_TYPES.has(t)) return "string"; - if (NUMBER_TYPES.has(t)) return "number"; - if (BIGINT_TYPES.has(t)) return "bigint"; - if (BOOLEAN_TYPES.has(t)) return "boolean"; - if (DATE_TYPES.has(t)) return "date"; - if (JSON_TYPES.has(t)) return "json"; - if (t === "uuid") return "uuid"; - return "unknown"; -} diff --git a/packages/appkit/src/database/contract/index.ts b/packages/appkit/src/database/contract/index.ts index b8eed0381..fa66cb2b1 100644 --- a/packages/appkit/src/database/contract/index.ts +++ b/packages/appkit/src/database/contract/index.ts @@ -1,4 +1,2 @@ -export * from "./column-info"; export * from "./registry"; -export * from "./relation"; export * from "./wire"; diff --git a/packages/appkit/src/database/contract/relation.ts b/packages/appkit/src/database/contract/relation.ts deleted file mode 100644 index d5f84ad0a..000000000 --- a/packages/appkit/src/database/contract/relation.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** Postgres referential actions for FK `ON DELETE` / `ON UPDATE`. */ -export type ReferentialAction = - | "cascade" - | "set null" - | "set default" - | "restrict" - | "no action"; - -/** - * A single foreign-key edge. The schema-builder produces these in both directions - * (`fk()` declares the relation once); the introspector reads them from the catalog. - */ -export interface RelationEdge { - /** Column on the owning table that holds the foreign key. */ - fromColumn: string; - /** Target table name (unqualified). */ - toTable: string; - /** Target column on the referenced table (usually its primary key). */ - toColumn: string; - /** Referential action for `ON DELETE`. */ - onDelete?: ReferentialAction; - /** Referential action for `ON UPDATE`. */ - onUpdate?: ReferentialAction; -} diff --git a/packages/appkit/src/database/contract/tests/column-info.test.ts b/packages/appkit/src/database/contract/tests/column-info.test.ts deleted file mode 100644 index 79abdd621..000000000 --- a/packages/appkit/src/database/contract/tests/column-info.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - type ColumnInfo, - type ColumnInfoKind, - normalizePgType, - pgTypeToColumnInfoKind, -} from "../index"; - -describe("normalizePgType", () => { - const cases: ReadonlyArray<[input: string, expected: string]> = [ - ["text", "text"], - ["TEXT", "text"], - [" Text ", "text"], - ["varchar(255)", "varchar"], - ["numeric(10,2)", "numeric"], - ["text[]", "text"], - ["varchar(255)[]", "varchar"], - ["timestamp with time zone", "timestamp with time zone"], - ["TIMESTAMPTZ", "timestamptz"], - ]; - - it.each(cases)("normalizes %j -> %j", (input, expected) => { - expect(normalizePgType(input)).toBe(expected); - }); -}); - -describe("pgTypeToColumnInfoKind", () => { - const cases: ReadonlyArray<[pgType: string, kind: ColumnInfoKind]> = [ - // string - ["text", "string"], - ["varchar", "string"], - ["varchar(255)", "string"], - ["character varying", "string"], - ["char", "string"], - ["character", "string"], - ["bpchar", "string"], - ["name", "string"], - ["citext", "string"], - // number - ["int2", "number"], - ["smallint", "number"], - ["int4", "number"], - ["int", "number"], - ["integer", "number"], - ["serial", "number"], - ["serial4", "number"], - ["smallserial", "number"], - ["real", "number"], - ["float4", "number"], - ["float8", "number"], - ["double precision", "number"], - ["numeric", "number"], - ["numeric(10,2)", "number"], - ["decimal", "number"], - ["money", "number"], - // bigint - ["int8", "bigint"], - ["bigint", "bigint"], - ["bigserial", "bigint"], - ["serial8", "bigint"], - // boolean - ["bool", "boolean"], - ["boolean", "boolean"], - // date - ["timestamp", "date"], - ["timestamptz", "date"], - ["timestamp with time zone", "date"], - ["timestamp without time zone", "date"], - ["date", "date"], - ["time", "date"], - ["timetz", "date"], - ["time with time zone", "date"], - ["time without time zone", "date"], - // json - ["json", "json"], - ["jsonb", "json"], - // uuid - ["uuid", "uuid"], - // unknown (enums are classified upstream, not here) - ["my_custom_enum", "unknown"], - ["bytea", "unknown"], - ["inet", "unknown"], - ["", "unknown"], - ]; - - it.each(cases)("classifies %j as %j", (pgType, kind) => { - expect(pgTypeToColumnInfoKind(pgType)).toBe(kind); - }); - - it("classifies case-insensitively and ignores parameters/array markers", () => { - expect(pgTypeToColumnInfoKind("VARCHAR(255)")).toBe("string"); - expect(pgTypeToColumnInfoKind("TEXT[]")).toBe("string"); - expect(pgTypeToColumnInfoKind(" TimestampTZ ")).toBe("date"); - }); - - it("never classifies a custom type as enum (enum is set upstream)", () => { - expect(pgTypeToColumnInfoKind("status_enum")).toBe("unknown"); - }); -}); - -describe("ColumnInfo", () => { - it("composes with a kind derived from the classifier", () => { - const column: ColumnInfo = { - name: "id", - pgType: normalizePgType("INT4"), - kind: pgTypeToColumnInfoKind("int4"), - nullable: false, - isPrimaryKey: true, - isServerGenerated: true, - isPrivate: false, - }; - expect(column.kind).toBe("number"); - expect(column.pgType).toBe("int4"); - }); - - it("carries enumValues only for enum columns", () => { - const column: ColumnInfo = { - name: "status", - pgType: "status_enum", - kind: "enum", - nullable: false, - isPrimaryKey: false, - isServerGenerated: false, - isPrivate: false, - enumValues: ["active", "archived"], - }; - expect(column.enumValues).toEqual(["active", "archived"]); - }); -}); diff --git a/packages/appkit/src/database/contract/tests/registry.test.ts b/packages/appkit/src/database/contract/tests/registry.test.ts index 30e0c4f6e..68b0e5904 100644 --- a/packages/appkit/src/database/contract/tests/registry.test.ts +++ b/packages/appkit/src/database/contract/tests/registry.test.ts @@ -1,10 +1,5 @@ import { describe, expectTypeOf, it } from "vitest"; -import type { - DatabaseRegistryEntry, - ReferentialAction, - RegisteredEntity, - RelationEdge, -} from "../index"; +import type { DatabaseRegistryEntry, RegisteredEntity } from "../index"; /** * Type-level tests for the contract. These are verified by `tsc` during @@ -57,35 +52,3 @@ describe("DatabaseRegistryEntry shape", () => { expectTypeOf().toHaveProperty("includes"); }); }); - -describe("RelationEdge shape", () => { - it("requires the from/to columns and allows optional referential actions", () => { - const edge: RelationEdge = { - fromColumn: "author_id", - toTable: "users", - toColumn: "id", - onDelete: "cascade", - onUpdate: "no action", - }; - expectTypeOf(edge.fromColumn).toEqualTypeOf(); - expectTypeOf(edge.toTable).toEqualTypeOf(); - expectTypeOf(edge.toColumn).toEqualTypeOf(); - expectTypeOf(edge.onDelete).toEqualTypeOf(); - expectTypeOf(edge.onUpdate).toEqualTypeOf(); - }); - - it("accepts a minimal edge without referential actions", () => { - const edge: RelationEdge = { - fromColumn: "author_id", - toTable: "users", - toColumn: "id", - }; - expectTypeOf(edge).toMatchTypeOf(); - }); - - it("pins the referential-action union", () => { - expectTypeOf().toEqualTypeOf< - "cascade" | "set null" | "set default" | "restrict" | "no action" - >(); - }); -}); diff --git a/packages/appkit/src/database/contract/wire.ts b/packages/appkit/src/database/contract/wire.ts index c0e17950f..5dd108049 100644 --- a/packages/appkit/src/database/contract/wire.ts +++ b/packages/appkit/src/database/contract/wire.ts @@ -1,14 +1,13 @@ /** Max number of values allowed in an `in.(…)` list. */ export const IN_CAP = 100; -/** Hard ceiling for `.limit()` clamp. */ +/** Hard ceiling for a runtime query limit. */ export const MAX_LIMIT = 500; /** Default page size when no `.limit()` is supplied. */ export const DEFAULT_LIMIT = 50; /** Max number of relations resolvable in a single `.include()`. */ export const MAX_INCLUDES = 10; - /** Filter operators usable in the runtime WHERE translator and the `where` spec type. */ -export const FILTER_OPERATORS = [ +export const FILTER_OPERATORS = Object.freeze([ "eq", "neq", "gt", @@ -19,7 +18,7 @@ export const FILTER_OPERATORS = [ "ilike", "in", "is", -] as const; +] as const); export type FilterOperator = (typeof FILTER_OPERATORS)[number]; diff --git a/packages/appkit/src/database/runtime/data-path.ts b/packages/appkit/src/database/runtime/data-path.ts new file mode 100644 index 000000000..2a486d627 --- /dev/null +++ b/packages/appkit/src/database/runtime/data-path.ts @@ -0,0 +1,126 @@ +import { DEFAULT_LIMIT, type FilterOperator, MAX_LIMIT } from "../contract"; +import type { AppKitTable, ColumnMeta } from "../schema-builder"; + +export type IdValue = string | number | bigint; +export type ScalarValue = string | number | bigint | boolean | null; +/** Operators for one column; array operands are reserved for `in`. */ +export type FilterOps = Partial< + Record +>; +export type WhereValue = ScalarValue | readonly ScalarValue[] | FilterOps; +/** Direct-column predicates with explicit `and` and `or` predicate groups. */ +export type WhereClause = Readonly< + Record +>; + +export type OrderDirection = "asc" | "desc"; +export type OrderSpec = Readonly>; + +export interface IncludeOptions { + readonly select?: readonly string[]; + readonly where?: WhereClause; + readonly order?: OrderSpec; + readonly limit?: number; +} + +/** Selection and bounds for one declared relation edge. */ +export type IncludeSpec = Readonly>; + +/** A bounded root read; adapters apply defaults and validate explicit bounds. */ +export interface QuerySpec { + readonly where?: WhereClause; + readonly order?: OrderSpec; + readonly select?: readonly string[]; + readonly include?: IncludeSpec; + readonly limit?: number; + readonly offset?: number; +} + +export type Row = Record; + +/** + * Backend-neutral operations; field names are schema keys that an adapter must + * resolve, never caller-provided SQL identifiers. + */ +export interface DataPath { + /** Read a bounded collection from one finalized table. */ + select(table: AppKitTable, spec: QuerySpec): Promise; + /** Read by the table's sole primary key with optional projection/include. */ + findOne( + table: AppKitTable, + id: IdValue, + spec?: Pick, + ): Promise; + count(table: AppKitTable, where?: WhereClause): Promise; + /** Return exactly one inserted row; zero or many is an invariant failure. */ + insert(table: AppKitTable, values: Row): Promise; + /** Return null for zero updated rows and reject more than one. */ + update(table: AppKitTable, id: IdValue, values: Row): Promise; + /** Return exactly one row for a validated primary-key or unique conflict. */ + upsert(table: AppKitTable, values: Row, onConflict: string): Promise; + /** Return false for zero deleted rows, true for one, and reject many. */ + delete(table: AppKitTable, id: IdValue): Promise; + /** Execute tagged SQL whose interpolations are parameter values, not SQL. */ + raw( + strings: TemplateStringsArray, + ...values: unknown[] + ): Promise; + /** Run the callback with one transaction-bound DataPath. */ + transaction(callback: (tx: DataPath) => Promise): Promise; +} + +/** Runtime failure that does not retain driver details. */ +export class DataPathError extends Error { + constructor(message: string) { + super(message); + this.name = "DataPathError"; + } +} + +/** Validate an explicit root or relation row limit. */ +export function validateLimit(limit: number): number { + if (!Number.isInteger(limit) || limit < 0 || limit > MAX_LIMIT) { + throw new DataPathError( + `limit must be an integer between 0 and ${MAX_LIMIT}`, + ); + } + return limit; +} + +/** Apply the conservative collection default when no limit is supplied. */ +export function limitOrDefault(limit?: number): number { + return limit === undefined ? DEFAULT_LIMIT : validateLimit(limit); +} + +/** Reject offsets that PostgreSQL cannot represent safely as JS integers. */ +export function validateOffset(offset: number): number { + if (!Number.isSafeInteger(offset) || offset < 0) { + throw new DataPathError("offset must be a non-negative safe integer"); + } + return offset; +} + +/** Resolve the sole primary key required by keyed operations. */ +export function primaryKeyMeta(table: AppKitTable): ColumnMeta { + const primaryKeys = Object.values(table.$columns).filter( + (column) => column.primaryKey, + ); + if (primaryKeys.length !== 1) { + throw new DataPathError(`Table "${table.$name}" has no primary key`); + } + return primaryKeys[0]; +} + +/** Resolve an upsert target that PostgreSQL can use for conflict detection. */ +export function conflictTargetMeta( + table: AppKitTable, + columnName: string, +): ColumnMeta { + const column = table.$columns[columnName]; + if (!column || (!column.primaryKey && !column.unique)) { + throw new DataPathError( + `Column "${table.$name}.${columnName}" is not a conflict target`, + ); + } + return column; +} diff --git a/packages/appkit/src/database/runtime/engine/drizzle-data-path.ts b/packages/appkit/src/database/runtime/engine/drizzle-data-path.ts new file mode 100644 index 000000000..01404be8a --- /dev/null +++ b/packages/appkit/src/database/runtime/engine/drizzle-data-path.ts @@ -0,0 +1,270 @@ +import { eq, isSQLWrapper, type SQL, sql } from "drizzle-orm"; +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import type { PgTable } from "drizzle-orm/pg-core"; +import type { Pool } from "pg"; +import type { AppKitTable, Schema } from "../../schema-builder"; +import { buildEngineRelations } from "../../schema-builder/engine/relations"; +import { + conflictTargetMeta, + type DataPath, + DataPathError, + limitOrDefault, + primaryKeyMeta, + type Row, + validateOffset, + type WhereClause, +} from "../data-path"; +import { + columnOf, + defaultColumns, + selectToColumns, + translateInclude, + translateOrder, + translateWhere, +} from "./translate"; + +/** Concrete Drizzle seam shared by the adapter and its focused tests. */ +export type DrizzleDb = NodePgDatabase>; + +/** Bind finalized AppKit metadata to a relational Drizzle database. */ +export function createDrizzleDb(pool: Pool, schema: Schema): DrizzleDb { + const completeSchema: Record = Object.assign( + Object.create(null), + schema.$engine, + buildEngineRelations(schema.$tables), + ); + return drizzle(pool, { schema: completeSchema }) as unknown as DrizzleDb; +} + +interface RelationalQueryBuilder { + findMany(config: Record): Promise; + findFirst(config: Record): Promise; +} + +/** Reject same-name or forged tables by requiring finalized object identity. */ +function assertRegisteredTable(schema: Schema, table: AppKitTable): void { + if (schema.$tables[table.$name] !== table) { + throw new DataPathError(`Table "${table.$name}" is not registered`); + } +} + +function relationalQueryBuilder( + db: DrizzleDb, + schema: Schema, + table: AppKitTable, +): RelationalQueryBuilder { + assertRegisteredTable(schema, table); + const query = (db.query as unknown as Record)[ + table.$name + ]; + if (!query) { + throw new DataPathError(`Table "${table.$name}" is not registered`); + } + return query; +} + +function selectedColumns( + table: AppKitTable, + select?: readonly string[], +): Record { + return select === undefined + ? defaultColumns(table) + : selectToColumns(table, select); +} + +/** Keep mutation identifiers schema-owned and every supplied value parameterized. */ +function mutationValues(table: AppKitTable, values: Row): Row { + if (values === null || typeof values !== "object" || Array.isArray(values)) { + throw new DataPathError("Database mutation values must be an object"); + } + for (const [key, value] of Object.entries(values)) { + if (!Object.hasOwn(table.$columns, key)) { + throw new DataPathError(`Unknown column "${table.$name}.${key}"`); + } + if (isSQLWrapper(value)) { + throw new DataPathError("Database mutation values cannot contain SQL"); + } + } + return values; +} + +async function runDatabaseOperation( + operation: () => Promise, +): Promise { + try { + return await operation(); + } catch (error) { + if (error instanceof DataPathError) throw error; + // Raw driver details stop at the adapter boundary. + throw new DataPathError("Database operation failed"); + } +} + +// Enforce the single-row DataPath contract before results reach callers. +function expectExactlyOne(rows: Row[]): Row { + if (rows.length !== 1) { + throw new DataPathError("Database mutation did not return exactly one row"); + } + return rows[0]; +} + +function expectZeroOrOne(rows: Row[]): Row | null { + if (rows.length > 1) { + throw new DataPathError("Database mutation returned more than one row"); + } + return rows[0] ?? null; +} + +/** Adapt a Drizzle database to the backend-neutral DataPath contract. */ +export function createDrizzleDataPath(db: DrizzleDb, schema: Schema): DataPath { + const pgTable = (table: AppKitTable): PgTable => { + assertRegisteredTable(schema, table); + return table.$engine as unknown as PgTable; + }; + const whereSql = ( + table: AppKitTable, + where?: WhereClause, + ): SQL | undefined => + where === undefined ? undefined : translateWhere(table, where); + + return { + async select(table, spec) { + return runDatabaseOperation(() => + relationalQueryBuilder(db, schema, table).findMany({ + where: whereSql(table, spec.where), + orderBy: + spec.order === undefined + ? undefined + : translateOrder(table, spec.order), + columns: selectedColumns(table, spec.select), + with: + spec.include === undefined + ? undefined + : translateInclude(table, schema, spec.include), + limit: limitOrDefault(spec.limit), + offset: + spec.offset === undefined ? undefined : validateOffset(spec.offset), + }), + ); + }, + + async findOne(table, id, spec) { + const primaryKey = primaryKeyMeta(table); + const row = await runDatabaseOperation(() => + relationalQueryBuilder(db, schema, table).findFirst({ + where: eq(columnOf(table, primaryKey.columnName), id), + columns: selectedColumns(table, spec?.select), + with: + spec?.include === undefined + ? undefined + : translateInclude(table, schema, spec.include), + }), + ); + return row ?? null; + }, + + async count(table, where) { + return runDatabaseOperation(() => + db.$count(pgTable(table), whereSql(table, where)), + ); + }, + + async insert(table, values) { + const engineTable = pgTable(table); + const parameters = mutationValues(table, values); + const rows = await runDatabaseOperation(() => + db.insert(engineTable).values(parameters).returning(), + ); + return expectExactlyOne(rows as Row[]); + }, + + async update(table, id, values) { + const engineTable = pgTable(table); + const parameters = mutationValues(table, values); + const primaryKey = primaryKeyMeta(table); + const rows = await runDatabaseOperation(() => + db + .update(engineTable) + .set(parameters) + .where(eq(columnOf(table, primaryKey.columnName), id)) + .returning(), + ); + return expectZeroOrOne(rows as Row[]); + }, + + async upsert(table, values, onConflict) { + const engineTable = pgTable(table); + const parameters = mutationValues(table, values); + const target = conflictTargetMeta(table, onConflict); + const rows = await runDatabaseOperation(() => + db + .insert(engineTable) + .values(parameters) + .onConflictDoUpdate({ + target: columnOf(table, target.columnName), + set: parameters, + }) + .returning(), + ); + return expectExactlyOne(rows as Row[]); + }, + + async delete(table, id) { + const primaryKey = primaryKeyMeta(table); + const rows = await runDatabaseOperation(() => + db + .delete(pgTable(table)) + .where(eq(columnOf(table, primaryKey.columnName), id)) + .returning({ id: columnOf(table, primaryKey.columnName) }), + ); + return expectZeroOrOne(rows as Row[]) !== null; + }, + + async raw( + strings: TemplateStringsArray, + ...values: unknown[] + ): Promise { + // SQL wrappers carry structure; tagged interpolations may carry values only. + if (values.some((value) => isSQLWrapper(value))) { + throw new DataPathError( + "Tagged SQL interpolations must be parameter values", + ); + } + const result = await runDatabaseOperation(() => + db.execute(sql(strings, ...values.map((value) => sql.param(value)))), + ); + return ((result as { rows?: unknown }).rows ?? result) as T[]; + }, + + async transaction(callback: (tx: DataPath) => Promise): Promise { + let callbackFailed = false; + let callbackError: unknown; + try { + return await db.transaction(async (transaction) => { + try { + return await callback( + createDrizzleDataPath( + transaction as unknown as DrizzleDb, + schema, + ), + ); + } catch (error) { + callbackFailed = true; + callbackError = error; + throw error; + } + }); + } catch (error) { + // Callback errors are application-owned; sanitize only tx lifecycle errors. + if ( + callbackFailed && + (error === callbackError || callbackError instanceof DataPathError) + ) { + throw callbackError; + } + if (error instanceof DataPathError) throw error; + throw new DataPathError("Database operation failed"); + } + }, + }; +} diff --git a/packages/appkit/src/database/runtime/engine/translate.ts b/packages/appkit/src/database/runtime/engine/translate.ts new file mode 100644 index 000000000..383693f7e --- /dev/null +++ b/packages/appkit/src/database/runtime/engine/translate.ts @@ -0,0 +1,290 @@ +import { + and, + asc, + desc, + eq, + gt, + gte, + ilike, + inArray, + isNull, + like, + lt, + lte, + ne, + or, + type SQL, + sql, +} from "drizzle-orm"; +import type { AnyPgColumn } from "drizzle-orm/pg-core"; +import { + DEFAULT_LIMIT, + type FilterOperator, + IN_CAP, + isFilterOperator, + MAX_INCLUDES, +} from "../../contract"; +import type { AppKitTable, ColumnMeta, Schema } from "../../schema-builder"; +import { filterOperatorsForKind } from "../../schema-builder/types"; +import { columnValueSchema } from "../../schema-builder/validators"; +import { + DataPathError, + type FilterOps, + type IncludeOptions, + type IncludeSpec, + type OrderSpec, + validateLimit, + type WhereClause, +} from "../data-path"; + +function columnMetaOf(table: AppKitTable, key: string): ColumnMeta { + const column = table.$columns[key]; + if (!column) { + throw new DataPathError(`Unknown column "${table.$name}.${key}"`); + } + return column; +} + +/** Resolve SQL identifiers only through columns finalized by the schema builder. */ +export function columnOf(table: AppKitTable, key: string): AnyPgColumn { + return columnMetaOf(table, key).engineColumn as unknown as AnyPgColumn; +} + +/** Default reads select all finalized columns except private application data. */ +export function defaultColumns(table: AppKitTable): Record { + const columns: Record = {}; + for (const column of Object.values(table.$columns)) { + if (!column.isPrivate) columns[column.columnName] = true; + } + return columns; +} + +function supportsOperator(meta: ColumnMeta, operator: FilterOperator): boolean { + if (operator === "is") { + return !meta.notNull && meta.kind !== "json" && meta.kind !== "unknown"; + } + return filterOperatorsForKind(meta.kind).includes(operator); +} + +function assertColumnValue( + table: AppKitTable, + meta: ColumnMeta, + operator: FilterOperator, + value: unknown, +): void { + if (!columnValueSchema(meta).safeParse(value).success) { + throw new DataPathError( + `Invalid ${operator} operand for "${table.$name}.${meta.columnName}"`, + ); + } +} + +function inList( + table: AppKitTable, + meta: ColumnMeta, + column: AnyPgColumn, + value: unknown, +): SQL { + if (!Array.isArray(value)) { + throw new DataPathError('The "in" operator requires an array'); + } + if (value.length > IN_CAP) { + throw new DataPathError(`in list exceeds the ${IN_CAP}-value limit`); + } + for (const item of value) { + if (item === null) { + throw new DataPathError('The "in" operator does not accept null'); + } + assertColumnValue(table, meta, "in", item); + } + return value.length === 0 ? sql.raw("false") : inArray(column, value); +} + +function translateOperator( + table: AppKitTable, + meta: ColumnMeta, + column: AnyPgColumn, + operator: FilterOperator, + value: unknown, +): SQL { + if (!supportsOperator(meta, operator)) { + throw new DataPathError( + `Operator "${operator}" is not supported for "${table.$name}.${meta.columnName}"`, + ); + } + if (operator === "is") { + if (value !== null) { + throw new DataPathError('The "is" operator accepts only null'); + } + return isNull(column); + } + if (operator === "in") return inList(table, meta, column, value); + + assertColumnValue(table, meta, operator, value); + switch (operator) { + case "eq": + return eq(column, value); + case "neq": + return ne(column, value); + case "gt": + return gt(column, value); + case "gte": + return gte(column, value); + case "lt": + return lt(column, value); + case "lte": + return lte(column, value); + case "like": + return like(column, value as string); + case "ilike": + return ilike(column, value as string); + default: + throw new DataPathError(`Unsupported filter operator "${operator}"`); + } +} + +/** Translate direct-column predicates. Relation predicates are not supported. */ +export function translateWhere( + table: AppKitTable, + clause: WhereClause, +): SQL | undefined { + if (clause === null || typeof clause !== "object" || Array.isArray(clause)) { + throw new DataPathError("where must be an object"); + } + const conditions: SQL[] = []; + for (const [key, value] of Object.entries(clause)) { + if (key === "and" || key === "or") { + if (!Array.isArray(value) || value.length === 0) { + throw new DataPathError(`${key} requires a non-empty predicate array`); + } + const groups = value.map((group) => { + const translated = translateWhere(table, group as WhereClause); + if (!translated) { + throw new DataPathError(`${key} predicates cannot be empty`); + } + return translated; + }); + const combined = key === "and" ? and(...groups) : or(...groups); + if (combined) { + conditions.push(combined); + } + continue; + } + + const meta = columnMetaOf(table, key); + const column = meta.engineColumn as unknown as AnyPgColumn; + if (Array.isArray(value)) { + conditions.push(translateOperator(table, meta, column, "in", value)); + } else if ( + value !== null && + typeof value === "object" && + !Array.isArray(value) + ) { + const operators = Object.entries(value as FilterOps); + if (operators.length === 0) { + throw new DataPathError( + `Filter for "${table.$name}.${key}" cannot be empty`, + ); + } + for (const [operator, operand] of operators) { + if (!isFilterOperator(operator)) { + throw new DataPathError(`Unknown filter operator "${operator}"`); + } + conditions.push( + translateOperator(table, meta, column, operator, operand), + ); + } + } else { + conditions.push(translateOperator(table, meta, column, "eq", value)); + } + } + return conditions.length > 0 ? and(...conditions) : undefined; +} + +export function translateOrder(table: AppKitTable, order: OrderSpec): SQL[] { + return Object.entries(order).map(([key, direction]) => { + if (direction !== "asc" && direction !== "desc") { + throw new DataPathError(`Unknown order direction "${direction}"`); + } + const column = columnOf(table, key); + return direction === "desc" ? desc(column) : asc(column); + }); +} + +export function selectToColumns( + table: AppKitTable, + select: readonly string[], +): Record { + const columns: Record = {}; + for (const key of select) { + columnOf(table, key); + columns[key] = true; + } + return columns; +} + +function tableByName(schema: Schema, name: string): AppKitTable { + const table = schema.$tables[name]; + if (!table) throw new DataPathError(`Unknown table "${name}"`); + return table; +} + +/** Translate one relation edge into Drizzle's relational `with` config. */ +export function translateInclude( + table: AppKitTable, + schema: Schema, + include: IncludeSpec, +): Record { + const entries = Object.entries(include); + if (entries.length > MAX_INCLUDES) { + throw new DataPathError( + `include exceeds the ${MAX_INCLUDES}-relation limit`, + ); + } + + const config: Record = {}; + for (const [relationName, rawOptions] of entries) { + const relation = table.$relations.find( + (candidate) => candidate.name === relationName, + ); + if (!relation) { + throw new DataPathError( + `Unknown relation "${table.$name}.${relationName}"`, + ); + } + if (rawOptions === false) continue; + + const target = tableByName(schema, relation.targetTable); + if (rawOptions === true) { + config[relationName] = { + columns: defaultColumns(target), + ...(relation.cardinality === "toMany" ? { limit: DEFAULT_LIMIT } : {}), + }; + continue; + } + + const options = rawOptions as IncludeOptions; + const relationConfig: Record = { + columns: + options.select === undefined + ? defaultColumns(target) + : selectToColumns(target, options.select), + }; + if (options.where !== undefined) { + relationConfig.where = translateWhere(target, options.where); + } + if (options.order !== undefined) { + relationConfig.orderBy = translateOrder(target, options.order); + } + if (options.limit !== undefined) { + if (relation.cardinality !== "toMany") { + throw new DataPathError("Only to-many relations accept a limit"); + } + relationConfig.limit = validateLimit(options.limit); + } else if (relation.cardinality === "toMany") { + relationConfig.limit = DEFAULT_LIMIT; + } + config[relationName] = relationConfig; + } + return config; +} diff --git a/packages/appkit/src/database/runtime/index.ts b/packages/appkit/src/database/runtime/index.ts new file mode 100644 index 000000000..057a4e29b --- /dev/null +++ b/packages/appkit/src/database/runtime/index.ts @@ -0,0 +1,15 @@ +export { + type DataPath, + DataPathError, + type FilterOps, + type IdValue, + type IncludeOptions, + type IncludeSpec, + type OrderDirection, + type OrderSpec, + type QuerySpec, + type Row, + type ScalarValue, + type WhereClause, + type WhereValue, +} from "./data-path"; diff --git a/packages/appkit/src/database/runtime/tests/data-path-contract.test.ts b/packages/appkit/src/database/runtime/tests/data-path-contract.test.ts new file mode 100644 index 000000000..18e39e09a --- /dev/null +++ b/packages/appkit/src/database/runtime/tests/data-path-contract.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import { DEFAULT_LIMIT, MAX_LIMIT } from "../../contract"; +import { defineSchema, id, text } from "../../schema-builder"; +import { + conflictTargetMeta, + limitOrDefault, + primaryKeyMeta, + validateLimit, + validateOffset, +} from "../data-path"; +import { + type DataPath, + DataPathError, + type IdValue, + type QuerySpec, + type Row, +} from "../index"; + +const schema = defineSchema((builder) => ({ + users: builder.table("users", { + id: id(), + email: text().unique(), + }), + events: builder.table("events", { body: text() }), +})); + +describe("DataPath contract", () => { + it("exposes the backend-neutral operations implemented in this phase", () => { + expectTypeOf().toHaveProperty("select"); + expectTypeOf().toHaveProperty("findOne"); + expectTypeOf().toHaveProperty("count"); + expectTypeOf().toHaveProperty("insert"); + expectTypeOf().toHaveProperty("update"); + expectTypeOf().toHaveProperty("upsert"); + expectTypeOf().toHaveProperty("delete"); + expectTypeOf().toHaveProperty("raw"); + expectTypeOf().toHaveProperty("transaction"); + }); + + it("keeps rows and identifiers backend-neutral", () => { + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().returns.resolves.toEqualTypeOf(); + expectTypeOf< + DataPath["findOne"] + >().returns.resolves.toEqualTypeOf(); + }); + + it("represents the query state translated by the Drizzle adapter", () => { + const spec = { + where: { email: { ilike: "%@example.com" } }, + order: { email: "asc" }, + select: ["id", "email"], + include: {}, + limit: 10, + offset: 5, + } satisfies QuerySpec; + expectTypeOf(spec).toMatchTypeOf(); + }); + + it("publishes only the Phase 1 runtime values", async () => { + expect(Object.keys(await import("../index"))).toEqual(["DataPathError"]); + }); +}); + +describe("runtime bounds and metadata", () => { + it("applies the default limit and validates explicit bounds", () => { + expect(limitOrDefault()).toBe(DEFAULT_LIMIT); + expect(validateLimit(0)).toBe(0); + expect(validateLimit(MAX_LIMIT)).toBe(MAX_LIMIT); + expect(() => validateLimit(-1)).toThrow(DataPathError); + expect(() => validateLimit(MAX_LIMIT + 1)).toThrow(DataPathError); + }); + + it("accepts only non-negative safe offsets", () => { + expect(validateOffset(0)).toBe(0); + expect(validateOffset(10)).toBe(10); + expect(() => validateOffset(-1)).toThrow(DataPathError); + expect(() => validateOffset(Number.MAX_VALUE)).toThrow(DataPathError); + }); + + it("resolves primary keys and explicit conflict targets from metadata", () => { + expect(primaryKeyMeta(schema.$tables.users).columnName).toBe("id"); + expect(conflictTargetMeta(schema.$tables.users, "email").unique).toBe(true); + expect(() => primaryKeyMeta(schema.$tables.events)).toThrow(DataPathError); + expect(() => conflictTargetMeta(schema.$tables.users, "body")).toThrow( + DataPathError, + ); + }); +}); diff --git a/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts b/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts new file mode 100644 index 000000000..4998a9eba --- /dev/null +++ b/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts @@ -0,0 +1,489 @@ +import { sql as drizzleSql, type SQL } from "drizzle-orm"; +import { PgDialect, type PgTable } from "drizzle-orm/pg-core"; +import { Pool } from "pg"; +import { afterAll, describe, expect, it } from "vitest"; +import { DEFAULT_LIMIT, MAX_LIMIT } from "../../contract"; +import { boolean, defineSchema, fk, id, text } from "../../schema-builder"; +import { DataPathError, type Row } from "../data-path"; +import { + createDrizzleDataPath, + createDrizzleDb, + type DrizzleDb, +} from "../engine/drizzle-data-path"; + +const schema = defineSchema((builder) => { + const users = builder.table("users", { + id: id(), + email: text().unique(), + name: text(), + active: boolean(), + secret: text().private(), + }); + const posts = builder.table("posts", { + id: id(), + authorId: fk(() => users.id), + title: text(), + }); + return { users, posts }; +}); + +const users = schema.$tables.users; +const dialect = new PgDialect(); + +function render(fragment: unknown): { sql: string; params: unknown[] } { + const query = dialect.sqlToQuery(fragment as SQL); + return { sql: query.sql, params: query.params as unknown[] }; +} + +interface FakeResults { + findMany?: Row[]; + findFirst?: Row; + count?: number; + insert?: Row[]; + update?: Row[]; + upsert?: Row[]; + delete?: Row[]; + execute?: unknown; + transactionFailure?: "begin" | "commit" | "rollback"; + transactionError?: unknown; +} + +interface FakeCalls { + findMany: { table: string; config: Record }[]; + findFirst: { table: string; config: Record }[]; + count: { table: unknown; filter: unknown }[]; + insert: { table: unknown; values: Row }[]; + update: { table: unknown; values: Row; where: unknown }[]; + upsert: { + table: unknown; + values: Row; + config: { target: unknown; set: Row }; + }[]; + delete: { table: unknown; where: unknown; returning: unknown }[]; + execute: unknown[]; + transactions: number; +} + +function makeFakeDb(results: FakeResults = {}): { + db: DrizzleDb; + calls: FakeCalls; +} { + const calls: FakeCalls = { + findMany: [], + findFirst: [], + count: [], + insert: [], + update: [], + upsert: [], + delete: [], + execute: [], + transactions: 0, + }; + const query = Object.fromEntries( + Object.keys(schema.$tables).map((table) => [ + table, + { + async findMany(config: Record) { + calls.findMany.push({ table, config }); + return results.findMany ?? []; + }, + async findFirst(config: Record) { + calls.findFirst.push({ table, config }); + return results.findFirst; + }, + }, + ]), + ); + + const db = { + query, + async $count(table: unknown, filter: unknown) { + calls.count.push({ table, filter }); + return results.count ?? 0; + }, + insert(table: unknown) { + return { + values(values: Row) { + return { + async returning() { + calls.insert.push({ table, values }); + return results.insert ?? []; + }, + onConflictDoUpdate(config: { target: unknown; set: Row }) { + return { + async returning() { + calls.upsert.push({ table, values, config }); + return results.upsert ?? []; + }, + }; + }, + }; + }, + }; + }, + update(table: unknown) { + return { + set(values: Row) { + return { + where(where: unknown) { + return { + async returning() { + calls.update.push({ table, values, where }); + return results.update ?? []; + }, + }; + }, + }; + }, + }; + }, + delete(table: unknown) { + return { + where(where: unknown) { + return { + async returning(returning: unknown) { + calls.delete.push({ table, where, returning }); + return results.delete ?? []; + }, + }; + }, + }; + }, + async execute(fragment: unknown) { + calls.execute.push(fragment); + return results.execute ?? { rows: [] }; + }, + async transaction(callback: (tx: unknown) => unknown) { + calls.transactions += 1; + if (results.transactionFailure === "begin") { + throw results.transactionError; + } + try { + const value = await callback(db); + if (results.transactionFailure === "commit") { + throw results.transactionError; + } + return value; + } catch (error) { + if (results.transactionFailure === "rollback") { + throw results.transactionError; + } + throw error; + } + }, + }; + return { db: db as unknown as DrizzleDb, calls }; +} + +describe("createDrizzleDataPath reads", () => { + it("translates bounded reads with a private-safe default projection", async () => { + const fake = makeFakeDb({ findMany: [{ id: 1, name: "Ada" }] }); + const dataPath = createDrizzleDataPath(fake.db, schema); + await expect( + dataPath.select(users, { + where: { active: true }, + order: { name: "asc" }, + include: { posts: true }, + offset: 2, + }), + ).resolves.toEqual([{ id: 1, name: "Ada" }]); + + const config = fake.calls.findMany[0].config; + expect(config.limit).toBe(DEFAULT_LIMIT); + expect(config.offset).toBe(2); + expect(config.columns).toEqual({ + id: true, + email: true, + name: true, + active: true, + }); + expect(config.with).toEqual({ + posts: { + columns: { id: true, authorId: true, title: true }, + limit: DEFAULT_LIMIT, + }, + }); + expect(render(config.where).params).toEqual([true]); + }); + + it("supports explicit selection and rejects excessive limits", async () => { + const fake = makeFakeDb(); + const dataPath = createDrizzleDataPath(fake.db, schema); + await dataPath.select(users, { + select: ["id", "secret"], + limit: MAX_LIMIT, + }); + expect(fake.calls.findMany[0].config.columns).toEqual({ + id: true, + secret: true, + }); + await expect( + dataPath.select(users, { limit: MAX_LIMIT + 1 }), + ).rejects.toBeInstanceOf(DataPathError); + }); + + it("finds by primary key and delegates count filters", async () => { + const fake = makeFakeDb({ findFirst: { id: 7, name: "Ada" }, count: 3 }); + const dataPath = createDrizzleDataPath(fake.db, schema); + await expect( + dataPath.findOne(users, 7, { select: ["id", "name"] }), + ).resolves.toEqual({ id: 7, name: "Ada" }); + expect(render(fake.calls.findFirst[0].config.where).params).toEqual([7]); + await expect(dataPath.count(users, { active: true })).resolves.toBe(3); + expect(render(fake.calls.count[0].filter).params).toEqual([true]); + }); + + it("rejects table handles from another schema", async () => { + const other = defineSchema((builder) => ({ + users: builder.table("users", { id: id() }), + })); + await expect( + createDrizzleDataPath(makeFakeDb().db, schema).select( + other.$tables.users, + {}, + ), + ).rejects.toBeInstanceOf(DataPathError); + }); +}); + +describe("Drizzle mutation cardinality", () => { + it("requires exactly one insert and upsert row", async () => { + const row = { id: 1, email: "a@example.com" }; + await expect( + createDrizzleDataPath(makeFakeDb({ insert: [row] }).db, schema).insert( + users, + { email: "a@example.com" }, + ), + ).resolves.toEqual(row); + await expect( + createDrizzleDataPath(makeFakeDb({ insert: [] }).db, schema).insert( + users, + {}, + ), + ).rejects.toBeInstanceOf(DataPathError); + await expect( + createDrizzleDataPath( + makeFakeDb({ insert: [row, row] }).db, + schema, + ).insert(users, {}), + ).rejects.toBeInstanceOf(DataPathError); + + const fake = makeFakeDb({ upsert: [row] }); + await expect( + createDrizzleDataPath(fake.db, schema).upsert( + users, + { email: "a@example.com" }, + "email", + ), + ).resolves.toEqual(row); + expect(fake.calls.upsert[0].config.target).toBe( + users.$columns.email.engineColumn, + ); + await expect( + createDrizzleDataPath(fake.db, schema).upsert(users, {}, "name"), + ).rejects.toBeInstanceOf(DataPathError); + await expect( + createDrizzleDataPath(makeFakeDb({ upsert: [] }).db, schema).upsert( + users, + { email: "a@example.com" }, + "email", + ), + ).rejects.toBeInstanceOf(DataPathError); + await expect( + createDrizzleDataPath( + makeFakeDb({ upsert: [row, row] }).db, + schema, + ).upsert(users, { email: "a@example.com" }, "email"), + ).rejects.toBeInstanceOf(DataPathError); + }); + + it("rejects unknown identifiers and structural Drizzle mutation values", async () => { + const fake = makeFakeDb({ insert: [{ id: 1 }] }); + const dataPath = createDrizzleDataPath(fake.db, schema); + + await expect( + dataPath.insert(users, { missing: "not a schema column" }), + ).rejects.toBeInstanceOf(DataPathError); + await expect( + dataPath.insert(users, { name: drizzleSql.raw("current_user") }), + ).rejects.toBeInstanceOf(DataPathError); + await expect( + dataPath.update(users, 1, { name: users.$columns.email.engineColumn }), + ).rejects.toBeInstanceOf(DataPathError); + await expect( + dataPath.upsert( + users, + { email: "a@example.com", name: drizzleSql.raw("current_user") }, + "email", + ), + ).rejects.toBeInstanceOf(DataPathError); + + expect(fake.calls.insert).toHaveLength(0); + expect(fake.calls.update).toHaveLength(0); + expect(fake.calls.upsert).toHaveLength(0); + }); + + it("accepts zero or one update row and rejects more", async () => { + const row = { id: 1, name: "Updated" }; + await expect( + createDrizzleDataPath(makeFakeDb({ update: [row] }).db, schema).update( + users, + 1, + { name: "Updated" }, + ), + ).resolves.toEqual(row); + await expect( + createDrizzleDataPath(makeFakeDb({ update: [] }).db, schema).update( + users, + 1, + {}, + ), + ).resolves.toBeNull(); + await expect( + createDrizzleDataPath( + makeFakeDb({ update: [row, row] }).db, + schema, + ).update(users, 1, {}), + ).rejects.toBeInstanceOf(DataPathError); + }); + + it("accepts zero or one delete row and rejects more", async () => { + await expect( + createDrizzleDataPath( + makeFakeDb({ delete: [{ id: 1 }] }).db, + schema, + ).delete(users, 1), + ).resolves.toBe(true); + await expect( + createDrizzleDataPath(makeFakeDb({ delete: [] }).db, schema).delete( + users, + 1, + ), + ).resolves.toBe(false); + await expect( + createDrizzleDataPath( + makeFakeDb({ delete: [{ id: 1 }, { id: 2 }] }).db, + schema, + ).delete(users, 1), + ).rejects.toBeInstanceOf(DataPathError); + }); +}); + +describe("tagged SQL and transactions", () => { + it("parameterizes tagged values and rejects structural interpolation", async () => { + const fake = makeFakeDb({ execute: { rows: [{ total: 1 }] } }); + const dataPath = createDrizzleDataPath(fake.db, schema); + const malicious = "1; drop table users"; + await expect( + dataPath.raw`select count(*) as total from users where id = ${malicious}`, + ).resolves.toEqual([{ total: 1 }]); + const query = render(fake.calls.execute[0]); + expect(query.sql).toContain("$1"); + expect(query.sql).not.toContain("drop table"); + expect(query.params).toEqual([malicious]); + + await expect( + dataPath.raw`select ${drizzleSql.raw("drop table users")}`, + ).rejects.toBeInstanceOf(DataPathError); + expect(fake.calls.execute).toHaveLength(1); + }); + + it("binds a DataPath to the Drizzle transaction and preserves callback errors", async () => { + const fake = makeFakeDb({ insert: [{ id: 1 }] }); + const dataPath = createDrizzleDataPath(fake.db, schema); + await expect( + dataPath.transaction((transaction) => transaction.insert(users, {})), + ).resolves.toEqual({ id: 1 }); + expect(fake.calls.transactions).toBe(1); + + const callbackError = new Error("application callback failed"); + await expect( + dataPath.transaction(async () => { + throw callbackError; + }), + ).rejects.toBe(callbackError); + + const classifiedError = new DataPathError("Already classified"); + await expect( + dataPath.transaction(async () => { + throw classifiedError; + }), + ).rejects.toBe(classifiedError); + }); + + it.each(["begin", "commit", "rollback"] as const)( + "sanitizes Drizzle %s failures", + async (transactionFailure) => { + const rawError = new Error(`${transactionFailure} leaked driver detail`); + const dataPath = createDrizzleDataPath( + makeFakeDb({ transactionFailure, transactionError: rawError }).db, + schema, + ); + + const error = await dataPath + .transaction(async () => { + if (transactionFailure === "rollback") { + throw new Error("application callback failed"); + } + return "done"; + }) + .catch((caught) => caught); + + expect(error).toBeInstanceOf(DataPathError); + expect(error.message).toBe("Database operation failed"); + expect(error.cause).toBeUndefined(); + }, + ); +}); + +describe("database failures", () => { + it("does not retain raw driver details", async () => { + const fake = makeFakeDb(); + const query = fake.db.query as unknown as Record< + string, + { findMany: () => Promise } + >; + query.users.findMany = async () => { + throw new Error("constraint users_email_key contains a secret"); + }; + + const error = await createDrizzleDataPath(fake.db, schema) + .select(users, {}) + .catch((caught) => caught); + expect(error).toBeInstanceOf(DataPathError); + expect(error.message).toBe("Database operation failed"); + expect(error.cause).toBeUndefined(); + }); +}); + +describe("createDrizzleDb", () => { + let pool: Pool | undefined; + afterAll(async () => { + await pool?.end(); + }); + + it("registers canonical tables and relations without connecting", () => { + pool = new Pool(); + const db = createDrizzleDb(pool, schema); + const query = db.query as unknown as Record< + string, + Record + >; + expect(typeof query.users.findMany).toBe("function"); + expect(typeof query.posts.findFirst).toBe("function"); + }); + + it("parameterizes ordinary mutation values", () => { + pool ??= new Pool(); + const db = createDrizzleDb(pool, schema); + const malicious = "x'); drop table users; --"; + const query = db + .insert(users.$engine as unknown as PgTable) + .values({ name: malicious }) + .toSQL(); + + expect(query.sql).toContain("$1"); + expect(query.sql).not.toContain("drop table"); + expect(query.params).toContain(malicious); + }); +}); diff --git a/packages/appkit/src/database/runtime/tests/translate.test.ts b/packages/appkit/src/database/runtime/tests/translate.test.ts new file mode 100644 index 000000000..775316be7 --- /dev/null +++ b/packages/appkit/src/database/runtime/tests/translate.test.ts @@ -0,0 +1,273 @@ +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; +import { IN_CAP, MAX_INCLUDES, MAX_LIMIT } from "../../contract"; +import { + bigint, + boolean, + defineSchema, + enumColumn, + fk, + id, + integer, + jsonb, + text, + timestamp, + uuid, +} from "../../schema-builder"; +import { filterOperatorsForKind } from "../../schema-builder/types"; +import { DataPathError } from "../data-path"; +import { + defaultColumns, + selectToColumns, + translateInclude, + translateOrder, + translateWhere, +} from "../engine/translate"; + +const schema = defineSchema((builder) => { + const users = builder.table("users", { + id: id(), + name: text(), + age: integer(), + active: boolean().notNull(), + large: bigint().notNull(), + createdAt: timestamp().notNull(), + externalId: uuid().notNull(), + status: enumColumn("user_status", ["active", "disabled"]).notNull(), + metadata: jsonb(), + secret: text().private(), + }); + const posts = builder.table("posts", { + id: id(), + authorId: fk(() => users.id), + title: text(), + secret: text().private(), + }); + return { users, posts }; +}); + +const users = schema.$tables.users; +const dialect = new PgDialect(); + +function render(fragment: SQL | undefined): { sql: string; params: unknown[] } { + if (!fragment) throw new Error("expected SQL fragment"); + const query = dialect.sqlToQuery(fragment); + return { sql: query.sql, params: query.params as unknown[] }; +} + +describe("translateWhere", () => { + it("uses one operator matrix for every column value kind", () => { + expect(filterOperatorsForKind("string")).toEqual([ + "eq", + "neq", + "in", + "like", + "ilike", + ]); + expect(filterOperatorsForKind("number")).toEqual([ + "eq", + "neq", + "in", + "gt", + "gte", + "lt", + "lte", + ]); + expect(filterOperatorsForKind("boolean")).toEqual(["eq", "neq", "in"]); + expect(filterOperatorsForKind("json")).toEqual([]); + }); + + it("resolves identifiers from metadata and parameterizes values", () => { + const injected = "x'; drop table users; --"; + const query = render(translateWhere(users, { name: injected })); + expect(query.sql).toBe(`"users"."name" = $1`); + expect(query.sql).not.toContain("drop table"); + expect(query.params).toEqual([injected]); + expect(() => translateWhere(users, { missing: injected })).toThrow( + DataPathError, + ); + }); + + it("translates the direct-column operator set", () => { + const checks = [ + [{ age: { eq: 1 } }, `"users"."age" = $1`], + [{ age: { neq: 1 } }, `"users"."age" <> $1`], + [{ age: { gt: 1 } }, `"users"."age" > $1`], + [{ age: { gte: 1 } }, `"users"."age" >= $1`], + [{ age: { lt: 1 } }, `"users"."age" < $1`], + [{ age: { lte: 1 } }, `"users"."age" <= $1`], + [{ name: { like: "a%" } }, `"users"."name" like $1`], + [{ name: { ilike: "a%" } }, `"users"."name" ilike $1`], + [{ name: { is: null } }, `"users"."name" is null`], + ] as const; + for (const [where, expected] of checks) { + expect(render(translateWhere(users, where)).sql).toBe(expected); + } + expect(() => + translateWhere(users, { age: { between: [1, 2] } as never }), + ).toThrow(DataPathError); + }); + + it("bounds in lists and gives an empty list deterministic semantics", () => { + const query = render(translateWhere(users, { id: { in: [1, 2, 3] } })); + expect(query.sql).toBe(`"users"."id" in ($1, $2, $3)`); + expect(query.params).toEqual([1, 2, 3]); + expect(render(translateWhere(users, { id: { in: [] } })).sql).toBe("false"); + expect(() => + translateWhere(users, { + id: { in: Array.from({ length: IN_CAP + 1 }, (_, index) => index) }, + }), + ).toThrow(DataPathError); + expect(() => + translateWhere(users, { name: { in: ["Ada", null] } }), + ).toThrow(DataPathError); + }); + + it("rejects operators and values that do not match column metadata", () => { + const validUuid = "123e4567-e89b-12d3-a456-426614174000"; + expect( + render(translateWhere(users, { externalId: validUuid })).params, + ).toEqual([validUuid]); + expect( + render( + translateWhere(users, { + createdAt: { gt: "2020-01-01T00:00:00Z" }, + }), + ).params, + ).toEqual(["2020-01-01T00:00:00Z"]); + expect(render(translateWhere(users, { large: { gt: 5n } })).params).toEqual( + [5n], + ); + + expect(() => + translateWhere(users, { age: { like: "1%" } as never }), + ).toThrow(DataPathError); + expect(() => + translateWhere(users, { active: { gt: true } as never }), + ).toThrow(DataPathError); + expect(() => + translateWhere(users, { metadata: { eq: { key: "value" } } as never }), + ).toThrow(DataPathError); + expect(() => translateWhere(users, { externalId: "not-a-uuid" })).toThrow( + DataPathError, + ); + expect(() => + translateWhere(users, { createdAt: { gt: "not-a-timestamp" } }), + ).toThrow(DataPathError); + expect(() => translateWhere(users, { status: "unknown" })).toThrow( + DataPathError, + ); + }); + + it("uses only is:null for nullable matching", () => { + expect(render(translateWhere(users, { name: { is: null } })).sql).toBe( + `"users"."name" is null`, + ); + expect(() => translateWhere(users, { name: null })).toThrow(DataPathError); + expect(() => translateWhere(users, { name: { eq: null } })).toThrow( + DataPathError, + ); + expect(() => translateWhere(users, { active: { is: null } })).toThrow( + DataPathError, + ); + }); + + it("rejects empty logical groups instead of widening a query", () => { + expect(() => translateWhere(users, { or: [] })).toThrow(DataPathError); + expect(() => translateWhere(users, { and: [{}] })).toThrow(DataPathError); + }); + + it("combines and/or groups without relation predicates", () => { + const query = render( + translateWhere(users, { + or: [ + { name: "Ada" }, + { and: [{ age: { gt: 18 } }, { age: { lt: 65 } }] }, + ], + }), + ); + expect(query.sql).toContain(" or "); + expect(query.sql).toContain(" and "); + }); +}); + +describe("ordering and selection", () => { + it("uses a private-safe default projection", () => { + expect(defaultColumns(users)).toEqual({ + id: true, + name: true, + age: true, + active: true, + large: true, + createdAt: true, + externalId: true, + status: true, + metadata: true, + }); + }); + + it("resolves only declared columns", () => { + const [age, name] = translateOrder(users, { age: "asc", name: "desc" }); + expect(render(age).sql).toBe(`"users"."age" asc`); + expect(render(name).sql).toBe(`"users"."name" desc`); + expect(selectToColumns(users, ["id", "secret"])).toEqual({ + id: true, + secret: true, + }); + expect(() => translateOrder(users, { missing: "asc" })).toThrow( + DataPathError, + ); + expect(() => selectToColumns(users, ["missing"])).toThrow(DataPathError); + expect(() => translateOrder(users, { age: "sideways" as "asc" })).toThrow( + DataPathError, + ); + }); +}); + +describe("translateInclude", () => { + it("uses private-safe defaults and bounds to-many reads", () => { + expect(translateInclude(users, schema, { posts: true })).toEqual({ + posts: { + columns: { id: true, authorId: true, title: true }, + limit: 50, + }, + }); + }); + + it("translates one-edge include options", () => { + const config = translateInclude(users, schema, { + posts: { + select: ["id", "secret"], + where: { title: { ilike: "a%" } }, + order: { id: "desc" }, + limit: 5, + }, + }) as { posts: Record }; + expect(config.posts.columns).toEqual({ id: true, secret: true }); + expect(config.posts.limit).toBe(5); + expect(render(config.posts.where as SQL).params).toEqual(["a%"]); + }); + + it("rejects unknown relations and invalid relation limits", () => { + expect(() => translateInclude(users, schema, { missing: true })).toThrow( + DataPathError, + ); + expect(() => + translateInclude(users, schema, { posts: { limit: MAX_LIMIT + 1 } }), + ).toThrow(DataPathError); + expect(() => + translateInclude(schema.$tables.posts, schema, { users: { limit: 1 } }), + ).toThrow(DataPathError); + + const tooMany = Object.fromEntries( + Array.from({ length: MAX_INCLUDES + 1 }, (_, index) => [ + `relation${index}`, + true, + ]), + ); + expect(() => translateInclude(users, schema, tooMany)).toThrow( + DataPathError, + ); + }); +}); diff --git a/packages/appkit/src/database/schema-builder/columns.ts b/packages/appkit/src/database/schema-builder/columns.ts index 8ab177d11..3c954805e 100644 --- a/packages/appkit/src/database/schema-builder/columns.ts +++ b/packages/appkit/src/database/schema-builder/columns.ts @@ -1,47 +1,133 @@ -import type { ColumnInfoKind, ReferentialAction } from "../contract"; import { type ColumnTypeSpec, + type ColumnValueKind, type MutableColumnMeta, + type ReferentialAction, SchemaBuildError, type StorageKind, } from "./types"; +import { columnValueSchema } from "./validators"; -const SERVER_GENERATED = new Set(["id", "bigid"]); +const REFERENTIAL_ACTIONS = new Set([ + "cascade", + "set null", + "set default", + "restrict", + "no action", +]); +const MAX_VARCHAR_LENGTH = 10_485_760; function specStorageKind(spec: ColumnTypeSpec): StorageKind { return spec.kind === "fk" ? "integer" : spec.kind; } -function stampDefaultExpr(value: string | number | boolean): string { - if (typeof value === "string") return `'${value.replace(/'/g, "''")}'`; - if (typeof value === "boolean") return value ? "true" : "false"; - return String(value); +function validateVarcharLength(length: number): void { + if (!Number.isInteger(length) || length < 1 || length > MAX_VARCHAR_LENGTH) { + throw new SchemaBuildError( + `varchar() length must be an integer between 1 and ${MAX_VARCHAR_LENGTH}`, + ); + } +} + +function validateEnum( + name: string, + values: readonly string[], +): readonly string[] { + if (!name) throw new SchemaBuildError("enum() requires a name"); + if (values.length === 0) { + throw new SchemaBuildError(`enum("${name}") requires at least one value`); + } + if (values.some((value) => typeof value !== "string" || value.length === 0)) { + throw new SchemaBuildError( + `enum("${name}") values must be non-empty strings`, + ); + } + if (new Set(values).size !== values.length) { + throw new SchemaBuildError(`enum("${name}") declares duplicate values`); + } + return Object.freeze([...values]); +} + +function validateReferentialAction(action: ReferentialAction): void { + if (!REFERENTIAL_ACTIONS.has(action)) { + throw new SchemaBuildError("Unsupported referential action"); + } +} + +interface DefaultValidationTable { + readonly name: string; + readonly metas: Readonly>; +} + +function isCompatibleLiteralDefault(meta: MutableColumnMeta): boolean { + switch (meta.storageKind) { + case "id": + case "bigid": + case "bigint": + case "jsonb": + return false; + default: + return columnValueSchema(meta).safeParse(meta.defaultValue).success; + } +} + +/** FK literals wait until finalization because fk() inherits target storage. */ +export function validateLiteralDefaults( + tables: Iterable, +): void { + for (const table of tables) { + for (const meta of Object.values(table.metas)) { + if ( + Object.hasOwn(meta, "defaultValue") && + !isCompatibleLiteralDefault(meta) + ) { + throw new SchemaBuildError( + `Default for column "${table.name}.${meta.columnName}" is not compatible with ${meta.storageKind} storage`, + ); + } + } + } } +/** Mutable DSL builder; table() clones its metadata before finalization. */ export class ColumnBuilder { - /** @internal */ readonly _spec: ColumnTypeSpec; /** @internal */ readonly _meta: MutableColumnMeta; + private readonly declarationKind: ColumnTypeSpec["kind"]; - constructor(spec: ColumnTypeSpec, pgType: string, kind: ColumnInfoKind) { - const serverGenerated = SERVER_GENERATED.has(spec.kind); - this._spec = spec; + constructor(spec: ColumnTypeSpec, kind: ColumnValueKind) { + this.declarationKind = spec.kind; + const enumValues = + spec.kind === "enum" + ? validateEnum(spec.enumName, spec.values) + : undefined; + + const serverGenerated = spec.kind === "id" || spec.kind === "bigid"; this._meta = { name: "", columnName: "", kind, - pgType, storageKind: specStorageKind(spec), - notNull: false, + notNull: serverGenerated, primaryKey: serverGenerated, unique: false, isPrivate: false, - isOwner: false, serverGenerated, hasDefault: serverGenerated, withTimezone: spec.kind === "timestamp" ? spec.withTimezone : undefined, varcharLength: spec.kind === "varchar" ? spec.length : undefined, enumName: spec.kind === "enum" ? spec.enumName : undefined, - enumValues: spec.kind === "enum" ? spec.values : undefined, + enumValues, + }; + } + + /** @internal Clone declaration state so builder reuse cannot mutate a table. */ + _cloneMeta(): MutableColumnMeta { + return { + ...this._meta, + enumValues: this._meta.enumValues + ? Object.freeze([...this._meta.enumValues]) + : undefined, + fk: this._meta.fk ? { ...this._meta.fk } : undefined, }; } @@ -52,6 +138,7 @@ export class ColumnBuilder { primaryKey(): this { this._meta.primaryKey = true; + this._meta.notNull = true; return this; } @@ -65,46 +152,59 @@ export class ColumnBuilder { return this; } - owner(): this { - this._meta.isOwner = true; - return this; - } - default(value: string | number | boolean): this { + this.requireNoDefault(); this._meta.hasDefault = true; - this._meta.defaultExpr = stampDefaultExpr(value); this._meta.defaultValue = value; return this; } defaultNow(): this { + this.requireNoDefault(); + if (this.declarationKind !== "timestamp") { + throw new SchemaBuildError( + ".defaultNow() is only valid on timestamp columns", + ); + } this._meta.hasDefault = true; - this._meta.defaultExpr = "now()"; this._meta.defaultNow = true; return this; } defaultRandom(): this { + this.requireNoDefault(); + if (this.declarationKind !== "uuid") { + throw new SchemaBuildError( + ".defaultRandom() is only valid on uuid columns", + ); + } this._meta.hasDefault = true; - this._meta.defaultExpr = "gen_random_uuid()"; this._meta.defaultRandom = true; return this; } onDelete(action: ReferentialAction): this { this.requireFk("onDelete"); + validateReferentialAction(action); this._meta.onDelete = action; return this; } onUpdate(action: ReferentialAction): this { this.requireFk("onUpdate"); + validateReferentialAction(action); this._meta.onUpdate = action; return this; } + private requireNoDefault(): void { + if (this._meta.hasDefault) { + throw new SchemaBuildError("A column may declare only one default mode"); + } + } + private requireFk(modifier: string): void { - if (this._spec.kind !== "fk") { + if (this.declarationKind !== "fk") { throw new SchemaBuildError( `.${modifier}() is only valid on fk() columns`, ); @@ -112,39 +212,23 @@ export class ColumnBuilder { } } -export const id = () => new ColumnBuilder({ kind: "id" }, "int4", "number"); -export const bigid = () => - new ColumnBuilder({ kind: "bigid" }, "int8", "bigint"); -export const text = () => new ColumnBuilder({ kind: "text" }, "text", "string"); -export const varchar = (length = 255) => - new ColumnBuilder({ kind: "varchar", length }, "varchar", "string"); -export const integer = () => - new ColumnBuilder({ kind: "integer" }, "int4", "number"); -export const bigint = () => - new ColumnBuilder({ kind: "bigint" }, "int8", "bigint"); -export const boolean = () => - new ColumnBuilder({ kind: "boolean" }, "bool", "boolean"); -export const uuid = () => new ColumnBuilder({ kind: "uuid" }, "uuid", "uuid"); +export const id = () => new ColumnBuilder({ kind: "id" }, "number"); +export const bigid = () => new ColumnBuilder({ kind: "bigid" }, "bigint"); +export const text = () => new ColumnBuilder({ kind: "text" }, "string"); +export const varchar = (length = 255) => { + validateVarcharLength(length); + return new ColumnBuilder({ kind: "varchar", length }, "string"); +}; +export const integer = () => new ColumnBuilder({ kind: "integer" }, "number"); +export const bigint = () => new ColumnBuilder({ kind: "bigint" }, "bigint"); +export const boolean = () => new ColumnBuilder({ kind: "boolean" }, "boolean"); +export const uuid = () => new ColumnBuilder({ kind: "uuid" }, "uuid"); export const timestamp = (opts?: { withTimezone?: boolean }) => { const withTimezone = opts?.withTimezone ?? false; - return new ColumnBuilder( - { kind: "timestamp", withTimezone }, - withTimezone ? "timestamptz" : "timestamp", - "date", - ); + return new ColumnBuilder({ kind: "timestamp", withTimezone }, "date"); }; -export const jsonb = () => - new ColumnBuilder({ kind: "jsonb" }, "jsonb", "json"); +export const jsonb = () => new ColumnBuilder({ kind: "jsonb" }, "json"); export function enumColumn(name: string, values: readonly string[]) { - if (!values || values.length === 0) { - throw new SchemaBuildError( - `enumColumn("${name}") requires at least one value`, - ); - } - return new ColumnBuilder( - { kind: "enum", enumName: name, values }, - name, - "enum", - ); + return new ColumnBuilder({ kind: "enum", enumName: name, values }, "enum"); } diff --git a/packages/appkit/src/database/schema-builder/define-schema.ts b/packages/appkit/src/database/schema-builder/define-schema.ts index e1fbd1c96..276405e1a 100644 --- a/packages/appkit/src/database/schema-builder/define-schema.ts +++ b/packages/appkit/src/database/schema-builder/define-schema.ts @@ -1,12 +1,14 @@ -import { ColumnBuilder } from "./columns"; +import { ColumnBuilder, enumColumn, validateLiteralDefaults } from "./columns"; import { buildEngineTables } from "./engine/tables"; -import { mirrorStorageKind, resolveFkRef } from "./fk"; +import { resolveForeignKeys } from "./fk"; import { buildRelations } from "./relations"; import { type AppKitTable, + type ColumnMeta, type ColumnRef, type DefineSchemaOptions, type MutableColumnMeta, + type ResolvedRelation, type Schema, SchemaBuildError, type TableHandle, @@ -14,9 +16,22 @@ import { import { deriveInsertSchema, deriveUpdateSchema } from "./validators"; interface RawTable { - name: string; - metas: Record; - handle: AppKitTable & Record; + readonly name: string; + readonly metas: Record; + readonly handle: Record; +} + +interface FinalizedTableCandidate { + readonly columns: Readonly>; + readonly engine: Schema["$engine"][string]; + readonly relations: readonly ResolvedRelation[]; + readonly insertSchema: unknown; + readonly updateSchema: unknown; +} + +interface DeclarationState { + readonly raw: Map; + readonly handleNames: Map; } export interface SchemaBuilderContext { @@ -27,133 +42,300 @@ export interface SchemaBuilderContext { enum(name: string, values: readonly string[]): ColumnBuilder; } -export function defineSchema( - builder: (ctx: SchemaBuilderContext) => Record, - options?: DefineSchemaOptions, -): Schema { - const schemaName = options?.schemaName ?? "public"; - const raw = new Map(); +const RESERVED_OBJECT_KEYS = new Set(["__proto__", "prototype", "constructor"]); +const TABLE_METADATA_KEYS = [ + "$name", + "$schemaName", + "$columns", + "$engine", + "$relations", + "$insertSchema", + "$updateSchema", +] as const; + +function nullRecord(): Record { + return Object.create(null) as Record; +} + +function assertRecord(value: unknown, label: string): asserts value is object { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new SchemaBuildError(`${label} must be an object`); + } +} + +function assertName(value: string, label: string): void { + if (!value || RESERVED_OBJECT_KEYS.has(value)) { + throw new SchemaBuildError(`${label} "${value}" is reserved`); + } +} + +function finalizeColumn(meta: MutableColumnMeta): ColumnMeta { + if (!meta.engineColumn) { + throw new SchemaBuildError( + `Engine column "${meta.columnName}" is missing during finalization`, + ); + } + const { fkRef: _fkRef, ...published } = meta; + return Object.freeze({ + ...published, + enumValues: meta.enumValues + ? Object.freeze([...meta.enumValues]) + : undefined, + fk: meta.fk ? Object.freeze({ ...meta.fk }) : undefined, + engineColumn: meta.engineColumn, + }) as ColumnMeta; +} + +function finalizeRelations( + relations: readonly ResolvedRelation[], +): readonly ResolvedRelation[] { + return Object.freeze( + relations.map((relation) => Object.freeze({ ...relation })), + ); +} + +/** Clone builders into per-table metadata and create stable column handles. */ +function declareTable>( + state: DeclarationState, + name: string, + columns: C, +): TableHandle { + assertName(name, "Table name"); + if (state.raw.has(name)) { + throw new SchemaBuildError(`Duplicate table "${name}"`); + } + assertRecord(columns, `Columns for table "${name}"`); + + const metas = nullRecord(); + const handle = Object.create(null) as Record; + for (const [key, column] of Object.entries(columns)) { + assertName(key, `Column name on table "${name}"`); + if (key.startsWith("$") || key === "and" || key === "or") { + throw new SchemaBuildError( + `Column "${name}.${key}" collides with AppKit runtime metadata`, + ); + } + if (!(column instanceof ColumnBuilder)) { + throw new SchemaBuildError( + `Column "${name}.${key}" was not created by an AppKit column builder`, + ); + } + + const meta = column._cloneMeta(); + meta.name = key; + meta.columnName = key; + metas[key] = meta; + + const reference: ColumnRef = Object.freeze({ + __isColumnRef: true, + tableName: name, + columnName: key, + }); + Object.defineProperty(handle, key, { + enumerable: true, + value: reference, + }); + } - const ctx: SchemaBuilderContext = { + state.raw.set(name, { name, metas, handle }); + state.handleNames.set(handle, name); + return handle as unknown as TableHandle; +} + +function createBuilderContext(state: DeclarationState): SchemaBuilderContext { + return { table(name, columns) { - if (raw.has(name)) - throw new SchemaBuildError(`Duplicate table "${name}"`); - const metas: Record = {}; - const handle = {} as AppKitTable & Record; - - for (const [key, col] of Object.entries(columns)) { - col._meta.name = key; - col._meta.columnName = key; - metas[key] = col._meta; - Object.defineProperty(handle, key, { - enumerable: true, - value: { - __isColumnRef: true, - tableName: name, - columnName: key, - } satisfies ColumnRef, - }); - } - - const ownerColumns = Object.values(metas).filter((meta) => meta.isOwner); - if (ownerColumns.length > 1) { - const ownerNames = ownerColumns - .map((meta) => meta.columnName) - .join(", "); - throw new SchemaBuildError( - `Table "${name}" declares multiple .owner() columns (${ownerNames}). Only one owner column is supported.`, - ); - } - - raw.set(name, { name, metas, handle }); - return handle as TableHandle; + return declareTable(state, name, columns); }, enum(name, values) { - if (values.length === 0) { - throw new SchemaBuildError( - `enum("${name}") requires at least one value`, - ); - } - - return new ColumnBuilder( - { kind: "enum", enumName: name, values }, - name, - "enum", - ); + return enumColumn(name, values); }, }; +} - const returned = builder(ctx); - - // resolve FKs - for (const { name, metas } of raw.values()) { - for (const meta of Object.values(metas)) { - if (!meta.fkRef) continue; - const ref = resolveFkRef(meta.fkRef); - const target = raw.get(ref.tableName); - - if (!target) - throw new SchemaBuildError( - `fk() on "${name}.${meta.columnName}" targets unknown table "${ref.tableName}"`, - ); - - const targetMeta = target.metas[ref.columnName]; - if (!targetMeta) - throw new SchemaBuildError( - `fk() on "${name}.${meta.columnName}" targets unknown column "${ref.tableName}.${ref.columnName}"`, - ); - - meta.storageKind = mirrorStorageKind(targetMeta.storageKind); - meta.pgType = - targetMeta.storageKind === "bigid" ? "int8" : targetMeta.pgType; - meta.kind = targetMeta.kind === "bigint" ? "bigint" : targetMeta.kind; - if (meta.storageKind === "integer") meta.pgType = "int4"; - if (meta.storageKind === "bigint") meta.pgType = "int8"; - - meta.fk = { - targetTable: ref.tableName, - targetColumn: ref.columnName, - onDelete: meta.onDelete, - onUpdate: meta.onUpdate, - }; +/** Require every exact table handle once under its declared identity. */ +function validateReturnedTables( + returned: unknown, + state: DeclarationState, +): void { + assertRecord(returned, "defineSchema() return value"); + const returnedHandles = new Set(); + for (const [key, value] of Object.entries(returned)) { + const name = + value !== null && typeof value === "object" + ? state.handleNames.get(value) + : undefined; + if (!name) { + throw new SchemaBuildError( + `defineSchema returned a value for "${key}" that was not produced by ctx.table()`, + ); } + if (key !== name) { + throw new SchemaBuildError( + `defineSchema returned table "${name}" under key "${key}"; aliases are not supported`, + ); + } + if (returnedHandles.has(value)) { + throw new SchemaBuildError(`Table "${name}" was returned more than once`); + } + returnedHandles.add(value); } - - // build engine tables - const built = buildEngineTables(raw.values(), schemaName); - const tables: Record = {}; - for (const { name, handle } of raw.values()) { - // Upgrade the handle in place so `defineSchema`'s return + column refs share it. - Object.assign(handle, built[name]); - tables[name] = handle; + if (returnedHandles.size !== state.raw.size) { + const omitted = [...state.raw.values()] + .filter((table) => !returnedHandles.has(table.handle)) + .map((table) => table.name); + throw new SchemaBuildError( + `defineSchema omitted declared table${omitted.length === 1 ? "" : "s"}: ${omitted.join(", ")}`, + ); } +} - buildRelations(tables); +/** Detect declaration-handle tampering before metadata publication. */ +function validateHandles(raw: ReadonlyMap): void { + for (const table of raw.values()) { + const ownKeys = Reflect.ownKeys(table.handle); + const hasOnlyDeclaredColumns = + ownKeys.length === Object.keys(table.metas).length && + ownKeys.every( + (key) => typeof key === "string" && Object.hasOwn(table.metas, key), + ); + const hasMetadataCollision = TABLE_METADATA_KEYS.some((key) => + Object.hasOwn(table.handle, key), + ); + if ( + Object.getPrototypeOf(table.handle) !== null || + !Object.isExtensible(table.handle) || + !hasOnlyDeclaredColumns || + hasMetadataCollision + ) { + throw new SchemaBuildError( + `Table handle "${table.name}" was modified during schema declaration`, + ); + } + } +} - // Map the returned keys back to the built handles (returned values ARE handles). - const byHandle = new Map(); - for (const [name, t] of Object.entries(tables)) { - byHandle.set(t, name); - t.$insertSchema = deriveInsertSchema(t); - t.$updateSchema = deriveUpdateSchema(t); +function validatePrimaryKeys(raw: ReadonlyMap): void { + for (const table of raw.values()) { + const primaryKeys = Object.values(table.metas).filter( + (meta) => meta.primaryKey, + ); + if (primaryKeys.length > 1) { + throw new SchemaBuildError( + `Table "${table.name}" declares multiple primary-key columns; composite primary keys are not supported`, + ); + } } - const result: Record = {}; - for (const [key, value] of Object.entries(returned)) { - const name = byHandle.get(value); - if (!name) +} + +function validateRelationKeys( + raw: ReadonlyMap, + relations: ReadonlyMap, +): void { + for (const [name, tableRelations] of relations) { + if (tableRelations.length > 0 && raw.has(`${name}Relations`)) { throw new SchemaBuildError( - `defineSchema returned a value for "${key}" that was not produced by ctx.table()`, + `Table "${name}Relations" collides with generated relation metadata for "${name}"`, ); + } + } +} - result[key] = value; +/** Prepare every engine object and validator without mutating handles. */ +function prepareTables( + raw: ReadonlyMap, + schemaName: string, + relations: ReadonlyMap, +): Map { + const built = buildEngineTables(raw.values(), schemaName); + const candidates = new Map(); + for (const table of raw.values()) { + const builtTable = built.get(table.name); + if (!builtTable) { + throw new SchemaBuildError( + `Engine table "${table.name}" was not constructed`, + ); + } + const columns = nullRecord(); + for (const [key, meta] of Object.entries(builtTable.columns)) { + columns[key] = finalizeColumn(meta); + } + Object.freeze(columns); + const tableRelations = finalizeRelations(relations.get(table.name) ?? []); + const validatorTable = { $columns: columns } as AppKitTable; + candidates.set(table.name, { + columns, + engine: builtTable.engine, + relations: tableRelations, + insertSchema: deriveInsertSchema(validatorTable), + updateSchema: deriveUpdateSchema(validatorTable), + }); } + return candidates; +} - const engineMap: Schema["$engine"] = {}; - for (const [key, t] of Object.entries(result)) engineMap[key] = t.$engine; +/** Atomically publish prepared metadata as the final schema transition. */ +function publishSchema( + raw: ReadonlyMap, + candidates: ReadonlyMap, + schemaName: string, +): Schema { + const publications = [...raw.values()].map((table) => { + const candidate = candidates.get(table.name); + if (!candidate) { + throw new SchemaBuildError( + `Table "${table.name}" was not prepared for finalization`, + ); + } + return { table, candidate }; + }); - return { + const tables = nullRecord(); + const engine = nullRecord(); + for (const { table, candidate } of publications) { + Object.defineProperties(table.handle, { + $name: { value: table.name }, + $schemaName: { value: schemaName }, + $columns: { value: candidate.columns }, + $engine: { value: candidate.engine }, + $relations: { value: candidate.relations }, + $insertSchema: { value: candidate.insertSchema }, + $updateSchema: { value: candidate.updateSchema }, + }); + const finalized = Object.freeze(table.handle) as unknown as AppKitTable; + tables[table.name] = finalized; + engine[table.name] = candidate.engine; + } + + return Object.freeze({ $schemaName: schemaName, - $tables: result, - $engine: engineMap, + $tables: Object.freeze(tables), + $engine: Object.freeze(engine), + }); +} + +export function defineSchema( + builder: (context: SchemaBuilderContext) => Record, + options?: DefineSchemaOptions, +): Schema { + const schemaName = options?.schemaName ?? "public"; + if (!schemaName) throw new SchemaBuildError("Schema name cannot be empty"); + + const state: DeclarationState = { + raw: new Map(), + handleNames: new Map(), }; + const returned = builder(createBuilderContext(state)); + + validateReturnedTables(returned, state); + validateHandles(state.raw); + validatePrimaryKeys(state.raw); + resolveForeignKeys(state.raw); + // FK literals are checked against the inherited target storage, not the placeholder. + validateLiteralDefaults(state.raw.values()); + + const relations = buildRelations(state.raw); + validateRelationKeys(state.raw, relations); + const candidates = prepareTables(state.raw, schemaName, relations); + return publishSchema(state.raw, candidates, schemaName); } diff --git a/packages/appkit/src/database/schema-builder/engine/relations.ts b/packages/appkit/src/database/schema-builder/engine/relations.ts index 7274b4950..725a708e7 100644 --- a/packages/appkit/src/database/schema-builder/engine/relations.ts +++ b/packages/appkit/src/database/schema-builder/engine/relations.ts @@ -1,37 +1,45 @@ import { type Relation, relations } from "drizzle-orm"; import type { AnyPgColumn, PgTable } from "drizzle-orm/pg-core"; -import type { AppKitTable } from "../types"; +import { type AppKitTable, SchemaBuildError } from "../types"; -function columnOf(table: PgTable, name: string): AnyPgColumn { - const col = (table as unknown as Record)[name]; - if (!col) - throw new Error(`engine relations: column "${name}" not found on table`); - return col as AnyPgColumn; +function columnOf(table: AppKitTable, name: string): AnyPgColumn { + const column = table.$columns[name]?.engineColumn; + if (!column) { + throw new SchemaBuildError( + `Engine relation column "${table.$name}.${name}" is not finalized`, + ); + } + return column as unknown as AnyPgColumn; } +/** Adapt finalized relation metadata to Drizzle's relation registration shape. */ export function buildEngineRelations( tables: Record, ): Record { const byName = new Map(); for (const table of Object.values(tables)) byName.set(table.$name, table); - const out: Record = {}; + const out: Record = Object.create(null); for (const table of Object.values(tables)) { if (table.$relations.length === 0) continue; const localEngine = table.$engine as unknown as PgTable; out[`${table.$name}Relations`] = relations(localEngine, ({ one, many }) => { - const config: Record = {}; + const config: Record = Object.create(null); for (const relation of table.$relations) { const target = byName.get(relation.targetTable); - if (!target) continue; + if (!target) { + throw new SchemaBuildError( + `Engine relation target "${relation.targetTable}" is not finalized`, + ); + } const targetEngine = target.$engine as unknown as PgTable; config[relation.name] = relation.cardinality === "toOne" ? one(targetEngine, { - fields: [columnOf(localEngine, relation.localColumn)], - references: [columnOf(targetEngine, relation.targetColumn)], + fields: [columnOf(table, relation.localColumn)], + references: [columnOf(target, relation.targetColumn)], }) : many(targetEngine); } diff --git a/packages/appkit/src/database/schema-builder/engine/tables.ts b/packages/appkit/src/database/schema-builder/engine/tables.ts index 7a500be8d..343bf8138 100644 --- a/packages/appkit/src/database/schema-builder/engine/tables.ts +++ b/packages/appkit/src/database/schema-builder/engine/tables.ts @@ -1,9 +1,7 @@ import { type AnyPgColumn, - bigserial, type PgColumnBuilderBase, type PgEnum, - type PgTable, bigint as pgBigint, boolean as pgBoolean, pgEnum, @@ -15,19 +13,14 @@ import { timestamp as pgTimestamp, uuid as pgUuid, varchar as pgVarchar, - serial, } from "drizzle-orm/pg-core"; -import type { ReferentialAction } from "../../contract"; -import { APPKIT_TABLE } from "../private"; import { - type AppKitTable, - type ColumnMeta, - type EngineColumn, + type EngineTable, type MutableColumnMeta, + type ReferentialAction, SchemaBuildError, } from "../types"; -/** Loosely-typed engine column builder seam */ type AnyColumnBuilder = PgColumnBuilderBase & { primaryKey(): AnyColumnBuilder; notNull(): AnyColumnBuilder; @@ -35,6 +28,7 @@ type AnyColumnBuilder = PgColumnBuilderBase & { default(value: unknown): AnyColumnBuilder; defaultNow(): AnyColumnBuilder; defaultRandom(): AnyColumnBuilder; + generatedByDefaultAsIdentity(): AnyColumnBuilder; references( ref: () => AnyPgColumn, actions?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction }, @@ -42,167 +36,194 @@ type AnyColumnBuilder = PgColumnBuilderBase & { }; type PgEnumValues = PgEnum<[string, ...string[]]>; +/** Reuse one Drizzle enum object for each enum name in this schema. */ type EnumRegistry = Map; +interface BuiltEngineTable { + readonly engine: EngineTable; + readonly columns: Record; +} + +function sameValues( + left: readonly string[], + right: readonly string[], +): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + function getEnum( registry: EnumRegistry, + schemaName: string, name: string, values: readonly string[], ): PgEnumValues { const existing = registry.get(name); - if (existing) return existing; + if (existing) { + if (!sameValues(existing.enumValues, values)) { + throw new SchemaBuildError( + `Enum "${name}" is declared with conflicting values`, + ); + } + return existing; + } - const created = pgEnum(name, values as [string, ...string[]]); - registry.set(name, created); - return created; + const tuple = values as [string, ...string[]]; + const created = + schemaName === "public" + ? pgEnum(name, tuple) + : pgSchema(schemaName).enum(name, tuple); + registry.set(name, created as PgEnumValues); + return created as PgEnumValues; } +/** Map resolved storage metadata to its Drizzle column builder. */ function baseColumn( meta: MutableColumnMeta, + schemaName: string, enums: EnumRegistry, ): AnyColumnBuilder { - const col = meta.columnName; + const name = meta.columnName; let builder: PgColumnBuilderBase; switch (meta.storageKind) { case "id": - builder = serial(col); + case "integer": + builder = pgInteger(name); break; case "bigid": - builder = bigserial(col, { mode: "bigint" }); + case "bigint": + builder = pgBigint(name, { mode: "bigint" }); break; case "text": - builder = pgText(col); + builder = pgText(name); break; case "varchar": - builder = pgVarchar(col, { length: meta.varcharLength ?? 255 }); - break; - case "integer": - builder = pgInteger(col); - break; - case "bigint": - builder = pgBigint(col, { mode: "bigint" }); + builder = pgVarchar(name, { length: meta.varcharLength ?? 255 }); break; case "boolean": - builder = pgBoolean(col); + builder = pgBoolean(name); break; case "uuid": - builder = pgUuid(col); + builder = pgUuid(name); break; case "timestamp": - builder = pgTimestamp(col, { withTimezone: meta.withTimezone ?? false }); + builder = pgTimestamp(name, { + mode: "string", + withTimezone: meta.withTimezone ?? false, + }); break; case "jsonb": - builder = pgJsonb(col); + builder = pgJsonb(name); break; - case "enum": - // biome-ignore lint/style/noNonNullAssertion: enum metas always carry an enumName. - builder = getEnum(enums, meta.enumName!, meta.enumValues ?? [])(col); + case "enum": { + if (!meta.enumName || !meta.enumValues?.length) { + throw new SchemaBuildError( + `Enum column "${meta.columnName}" has no enum definition`, + ); + } + builder = getEnum( + enums, + schemaName, + meta.enumName, + meta.enumValues, + )(name); break; + } } return builder as AnyColumnBuilder; } function buildColumn( meta: MutableColumnMeta, + schemaName: string, enums: EnumRegistry, resolveTarget: (table: string, column: string) => AnyPgColumn, ): PgColumnBuilderBase { - let c = baseColumn(meta, enums); - - if (meta.primaryKey && !meta.serverGenerated) c = c.primaryKey(); - if (meta.notNull && !meta.serverGenerated) c = c.notNull(); - if (meta.unique) c = c.unique(); + let column = baseColumn(meta, schemaName, enums); + if (meta.serverGenerated) column = column.generatedByDefaultAsIdentity(); + if (meta.primaryKey) column = column.primaryKey(); + else if (meta.notNull) column = column.notNull(); + if (meta.unique) column = column.unique(); if (!meta.serverGenerated) { - if (meta.defaultNow) c = c.defaultNow(); - else if (meta.defaultRandom) c = c.defaultRandom(); - else if (meta.defaultValue !== undefined) c = c.default(meta.defaultValue); + if (meta.defaultNow) column = column.defaultNow(); + else if (meta.defaultRandom) column = column.defaultRandom(); + else if (Object.hasOwn(meta, "defaultValue")) { + column = column.default(meta.defaultValue); + } } if (meta.fk) { const target = meta.fk; - c = c.references( + // Drizzle resolves this thunk after all referenced tables are registered. + column = column.references( () => resolveTarget(target.targetTable, target.targetColumn), { onDelete: target.onDelete, onUpdate: target.onUpdate }, ); } - - return c; + return column; } -/** - * Build one engine table from finalized column metas. `resolveTarget` reads the - * shared registry so FK `.references()` thunks resolve forward/self refs. - */ function buildTable( name: string, schemaName: string, metas: Record, enums: EnumRegistry, resolveTarget: (table: string, column: string) => AnyPgColumn, -): { engine: PgTable; columns: Record } { - const columnBuilders: Record = {}; +): BuiltEngineTable { + const columnBuilders: Record = + Object.create(null); for (const [key, meta] of Object.entries(metas)) { - columnBuilders[key] = buildColumn(meta, enums, resolveTarget); + columnBuilders[key] = buildColumn(meta, schemaName, enums, resolveTarget); } const engine = schemaName === "public" ? pgTable(name, columnBuilders) : pgSchema(schemaName).table(name, columnBuilders); - - const columns: Record = {}; for (const [key, meta] of Object.entries(metas)) { - // store the real engine column behind the opaque handle (quarantine file). - meta.engineColumn = (engine as unknown as Record)[ + const engineColumn = (engine as unknown as Record)[ key - ] as unknown as EngineColumn; - columns[key] = meta as ColumnMeta; + ]; + if (!engineColumn) { + throw new SchemaBuildError( + `Engine column "${name}.${key}" was not constructed`, + ); + } + meta.engineColumn = + engineColumn as unknown as MutableColumnMeta["engineColumn"]; } - - return { engine, columns }; + return { engine: engine as unknown as EngineTable, columns: metas }; } -function makeAppKitTable( - name: string, - schemaName: string, - built: { engine: PgTable; columns: Record }, -): AppKitTable { - return { - $name: name, - $schemaName: schemaName, - $columns: built.columns, - $engine: built.engine, - $relations: [], - [APPKIT_TABLE]: true, - } as unknown as AppKitTable; -} - -/** - * Build every engine table from finalized metas, resolving FK targets across the - * whole set via a shared registry so forward/self references wire correctly. - */ +/** Build all Drizzle tables only after declaration validation has succeeded. */ export function buildEngineTables( raw: Iterable<{ name: string; metas: Record }>, schemaName: string, -): Record { - const builtEngine: Record> = {}; +): Map { + const entries = [...raw]; + const builtEngine = new Map>(); const resolveTarget = (table: string, column: string): AnyPgColumn => { - const t = builtEngine[table]; - if (!t || !t[column]) + const target = builtEngine.get(table)?.[column]; + if (!target) { throw new SchemaBuildError( `Cannot resolve FK target "${table}.${column}"`, ); - - return t[column]; + } + return target; }; const enums: EnumRegistry = new Map(); - const tables: Record = {}; - for (const { name, metas } of raw) { + const tables = new Map(); + for (const { name, metas } of entries) { const built = buildTable(name, schemaName, metas, enums, resolveTarget); - builtEngine[name] = built.engine as unknown as Record; - tables[name] = makeAppKitTable(name, schemaName, built); + builtEngine.set( + name, + built.engine as unknown as Record, + ); + tables.set(name, built); } return tables; } diff --git a/packages/appkit/src/database/schema-builder/fk.ts b/packages/appkit/src/database/schema-builder/fk.ts index 60ce4b74d..a12d92577 100644 --- a/packages/appkit/src/database/schema-builder/fk.ts +++ b/packages/appkit/src/database/schema-builder/fk.ts @@ -1,15 +1,31 @@ import { ColumnBuilder } from "./columns"; -import type { ColumnRef, FkRef, StorageKind } from "./types"; +import { + type ColumnRef, + type FkRef, + type MutableColumnMeta, + SchemaBuildError, + type StorageKind, +} from "./types"; /** Declare foreign-key to another column. */ export function fk(ref: FkRef): ColumnBuilder { - const builder = new ColumnBuilder({ kind: "fk" }, "int4", "number"); + const builder = new ColumnBuilder({ kind: "fk" }, "number"); builder._meta.fkRef = ref; return builder; } export function resolveFkRef(ref: FkRef): ColumnRef { - return typeof ref === "function" ? ref() : ref; + const resolved = typeof ref === "function" ? ref() : ref; + if ( + !resolved || + typeof resolved !== "object" || + resolved.__isColumnRef !== true + ) { + throw new SchemaBuildError( + "fk() must reference a column created by table()", + ); + } + return resolved; } /** A serial PK target stores as its plain integer type on the FK side. */ @@ -18,3 +34,100 @@ export function mirrorStorageKind(targetStorage: StorageKind): StorageKind { if (targetStorage === "bigid") return "bigint"; return targetStorage; } + +interface ForeignKeyTable { + readonly name: string; + readonly metas: Readonly>; + readonly handle: Readonly>; +} + +/** Resolve FK identity, inherited storage, and action invariants in one pass. */ +export function resolveForeignKeys( + tables: ReadonlyMap, +): void { + const references = new Map< + ColumnRef, + { readonly table: ForeignKeyTable; readonly meta: MutableColumnMeta } + >(); + for (const table of tables.values()) { + for (const [columnName, reference] of Object.entries(table.handle)) { + const meta = table.metas[columnName]; + if (!meta) { + throw new SchemaBuildError( + `Column reference "${table.name}.${columnName}" has no metadata`, + ); + } + references.set(reference, { table, meta }); + } + } + + const resolving = new Set(); + const resolved = new Set(); + + const resolveForeignKey = ( + table: ForeignKeyTable, + meta: MutableColumnMeta, + ): void => { + if (!meta.fkRef || resolved.has(meta)) return; + if (resolving.has(meta)) { + throw new SchemaBuildError( + `Foreign key "${table.name}.${meta.columnName}" has a cyclic storage dependency`, + ); + } + + resolving.add(meta); + try { + const reference = resolveFkRef(meta.fkRef); + const targetIdentity = references.get(reference); + if (!targetIdentity) { + throw new SchemaBuildError( + `fk() on "${table.name}.${meta.columnName}" targets a column outside the returned schema`, + ); + } + + const { table: targetTable, meta: target } = targetIdentity; + resolveForeignKey(targetTable, target); + if (!target.primaryKey && !target.unique) { + throw new SchemaBuildError( + `fk() on "${table.name}.${meta.columnName}" must target a primary-key or unique column`, + ); + } + + meta.storageKind = mirrorStorageKind(target.storageKind); + meta.kind = target.kind; + meta.withTimezone = target.withTimezone; + meta.varcharLength = target.varcharLength; + meta.enumName = target.enumName; + meta.enumValues = target.enumValues + ? Object.freeze([...target.enumValues]) + : undefined; + meta.fk = { + targetTable: targetTable.name, + targetColumn: target.columnName, + onDelete: meta.onDelete, + onUpdate: meta.onUpdate, + }; + + const actions = [meta.onDelete, meta.onUpdate]; + if (actions.includes("set null") && meta.notNull) { + throw new SchemaBuildError( + `Foreign key "${table.name}.${meta.columnName}" uses SET NULL but is not-null`, + ); + } + if (actions.includes("set default") && !meta.hasDefault) { + throw new SchemaBuildError( + `Foreign key "${table.name}.${meta.columnName}" uses SET DEFAULT without a local default`, + ); + } + resolved.add(meta); + } finally { + resolving.delete(meta); + } + }; + + for (const table of tables.values()) { + for (const meta of Object.values(table.metas)) { + resolveForeignKey(table, meta); + } + } +} diff --git a/packages/appkit/src/database/schema-builder/index.ts b/packages/appkit/src/database/schema-builder/index.ts index 6dec6967f..bb68652e7 100644 --- a/packages/appkit/src/database/schema-builder/index.ts +++ b/packages/appkit/src/database/schema-builder/index.ts @@ -13,16 +13,7 @@ export { varchar, } from "./columns"; export { defineSchema, type SchemaBuilderContext } from "./define-schema"; -export { buildEngineRelations } from "./engine/relations"; export { fk } from "./fk"; -export { - APPKIT_TABLE, - isPrivateColumn, - nonPrivateColumnNames, - ownerColumnName, - privateColumnNames, -} from "./private"; -export { buildRelations } from "./relations"; export type { AppKitTable, ColumnMeta, @@ -34,4 +25,3 @@ export type { TableHandle, } from "./types"; export { SchemaBuildError } from "./types"; -export { deriveInsertSchema, deriveUpdateSchema } from "./validators"; diff --git a/packages/appkit/src/database/schema-builder/private.ts b/packages/appkit/src/database/schema-builder/private.ts deleted file mode 100644 index c06694eeb..000000000 --- a/packages/appkit/src/database/schema-builder/private.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { AppKitTable, ColumnMeta } from "./types"; - -/** Marker proving an object is an Appkit-built table */ -export const APPKIT_TABLE = Symbol.for("appkit.database.table"); - -export function isPrivateColumn(meta: ColumnMeta): boolean { - return meta.isPrivate; -} - -export function privateColumnNames(table: AppKitTable): string[] { - return Object.values(table.$columns) - .filter(isPrivateColumn) - .map((c) => c.columnName); -} - -export function nonPrivateColumnNames(table: AppKitTable): string[] { - return Object.values(table.$columns) - .filter((c) => !isPrivateColumn(c)) - .map((c) => c.columnName); -} - -/** - * The RLS owner column name (`.owner()`), if one is declared. - */ -export function ownerColumnName(table: AppKitTable): string | undefined { - return Object.values(table.$columns).find((c) => c.isOwner)?.columnName; -} diff --git a/packages/appkit/src/database/schema-builder/relations.ts b/packages/appkit/src/database/schema-builder/relations.ts index 814c897cc..87b6ddd71 100644 --- a/packages/appkit/src/database/schema-builder/relations.ts +++ b/packages/appkit/src/database/schema-builder/relations.ts @@ -1,63 +1,85 @@ -import { type AppKitTable, SchemaBuildError } from "./types"; +import type { MutableColumnMeta, ResolvedRelation } from "./types"; +import { SchemaBuildError } from "./types"; -export function buildRelations(tables: Record) { - for (const table of Object.values(tables)) { - const columnNames = new Set(Object.keys(table.$columns)); - const seenForward = new Set(); +interface RelationSourceTable { + readonly name: string; + readonly metas: Readonly>; +} - for (const meta of Object.values(table.$columns)) { - if (!meta.fk) continue; +/** @internal Build the canonical relation metadata before tables are published. */ +export function buildRelations( + tables: ReadonlyMap, +): Map { + const result = new Map(); + for (const name of tables.keys()) result.set(name, []); + + // Forward pass: each FK adds a to-one edge to its source table. + for (const table of tables.values()) { + const relations = result.get(table.name); + if (!relations) + throw new SchemaBuildError("Relation table is not registered"); + const columnNames = new Set(Object.keys(table.metas)); + const seenTargets = new Set(); - const name = meta.fk.targetTable; - if (columnNames.has(name)) + for (const meta of Object.values(table.metas)) { + if (!meta.fk) continue; + const relationName = meta.fk.targetTable; + if (columnNames.has(relationName)) { throw new SchemaBuildError( - `Forward relation "${table.$name}.${name}" collides with a column of the same name`, + `Forward relation "${table.name}.${relationName}" collides with a column of the same name`, ); - - if (seenForward.has(name)) + } + if (seenTargets.has(relationName)) { throw new SchemaBuildError( - `Ambiguous forward relation "${table.$name}.${name}": multiple foreign keys target "${name}". Rename one target or model the relation explicitly.`, + `Ambiguous forward relation "${table.name}.${relationName}": multiple foreign keys target "${relationName}"`, ); - seenForward.add(name); - table.$relations.push({ - name, + } + seenTargets.add(relationName); + relations.push({ + name: relationName, cardinality: "toOne", localColumn: meta.columnName, - targetTable: name, + targetTable: relationName, targetColumn: meta.fk.targetColumn, inferred: false, }); } } - for (const table of Object.values(tables)) { - for (const meta of Object.values(table.$columns)) { - if (!meta.fk) continue; - const targetTable = tables[meta.fk.targetTable]; - if (!targetTable) continue; - - // Skip self-referential relations. - if (targetTable === table) continue; - - const name = table.$name; - if (Object.keys(targetTable.$columns).includes(name)) + // Reverse pass: each non-self FK adds a to-many edge to its target table. + for (const table of tables.values()) { + for (const meta of Object.values(table.metas)) { + if (!meta.fk || meta.fk.targetTable === table.name) continue; + const target = tables.get(meta.fk.targetTable); + const targetRelations = result.get(meta.fk.targetTable); + if (!target || !targetRelations) { throw new SchemaBuildError( - `Reverse relation "${targetTable.$name}.${name}" collides with a column of the same name`, + `Relation target "${meta.fk.targetTable}" is not part of the schema`, ); + } - if (targetTable.$relations.some((r) => r.name === name)) + const relationName = table.name; + if (Object.hasOwn(target.metas, relationName)) { throw new SchemaBuildError( - `Reverse relation "${targetTable.$name}.${name}" is ambiguous (multiple foreign keys from "${table.$name}"). Disambiguate by renaming the source table.`, + `Reverse relation "${target.name}.${relationName}" collides with a column of the same name`, ); - - targetTable.$relations.push({ - name, + } + if (targetRelations.some((relation) => relation.name === relationName)) { + throw new SchemaBuildError( + `Reverse relation "${target.name}.${relationName}" is ambiguous`, + ); + } + // Reverse edges deliberately retain their stable to-many result shape. + targetRelations.push({ + name: relationName, cardinality: "toMany", localColumn: meta.fk.targetColumn, - targetTable: table.$name, + targetTable: table.name, targetColumn: meta.columnName, inferred: true, }); } } + + return result; } diff --git a/packages/appkit/src/database/schema-builder/tests/columns.test.ts b/packages/appkit/src/database/schema-builder/tests/columns.test.ts index 8e2e52c62..d5ce86132 100644 --- a/packages/appkit/src/database/schema-builder/tests/columns.test.ts +++ b/packages/appkit/src/database/schema-builder/tests/columns.test.ts @@ -1,10 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { bigid, bigint, boolean, ColumnBuilder, - type ColumnMeta, enumColumn, fk, id, @@ -16,154 +15,140 @@ import { uuid, varchar, } from "../index"; +import type { ColumnValueKind } from "../types"; + +describe("column constructors", () => { + it("uses value kinds independently of storage declarations", () => { + expectTypeOf().toEqualTypeOf< + | "string" + | "number" + | "bigint" + | "boolean" + | "date" + | "json" + | "uuid" + | "enum" + | "unknown" + >(); + }); -describe("column constructors — storage metadata", () => { it.each([ - ["id", id(), { storageKind: "id", pgType: "int4", kind: "number" }], - [ - "bigid", - bigid(), - { storageKind: "bigid", pgType: "int8", kind: "bigint" }, - ], - ["text", text(), { storageKind: "text", pgType: "text", kind: "string" }], - [ - "integer", - integer(), - { storageKind: "integer", pgType: "int4", kind: "number" }, - ], - [ - "bigint", - bigint(), - { storageKind: "bigint", pgType: "int8", kind: "bigint" }, - ], - [ - "boolean", - boolean(), - { storageKind: "boolean", pgType: "bool", kind: "boolean" }, - ], - ["uuid", uuid(), { storageKind: "uuid", pgType: "uuid", kind: "uuid" }], - ["jsonb", jsonb(), { storageKind: "jsonb", pgType: "jsonb", kind: "json" }], - ])("%s carries the expected meta", (_label, builder, expected) => { + ["id", id(), { storageKind: "id", kind: "number" }], + ["bigid", bigid(), { storageKind: "bigid", kind: "bigint" }], + ["text", text(), { storageKind: "text", kind: "string" }], + ["integer", integer(), { storageKind: "integer", kind: "number" }], + ["bigint", bigint(), { storageKind: "bigint", kind: "bigint" }], + ["boolean", boolean(), { storageKind: "boolean", kind: "boolean" }], + ["uuid", uuid(), { storageKind: "uuid", kind: "uuid" }], + ["jsonb", jsonb(), { storageKind: "jsonb", kind: "json" }], + ])("builds %s metadata", (_label, builder, expected) => { expect(builder).toBeInstanceOf(ColumnBuilder); expect(builder._meta).toMatchObject(expected); }); - it("varchar defaults to length 255 and a clean pgType", () => { - expect(varchar()._meta).toMatchObject({ - storageKind: "varchar", - pgType: "varchar", - varcharLength: 255, - }); + it("validates varchar lengths", () => { + expect(varchar()._meta.varcharLength).toBe(255); expect(varchar(64)._meta.varcharLength).toBe(64); + expect(() => varchar(0)).toThrow(SchemaBuildError); + expect(() => varchar(1.5)).toThrow(/length must be an integer/); }); - it("timestamp toggles withTimezone and pgType", () => { - expect(timestamp()._meta).toMatchObject({ - pgType: "timestamp", - withTimezone: false, - }); - expect(timestamp({ withTimezone: true })._meta).toMatchObject({ - pgType: "timestamptz", - withTimezone: true, - }); + it("records timestamp options", () => { + expect(timestamp()._meta.withTimezone).toBe(false); + expect(timestamp({ withTimezone: true })._meta.withTimezone).toBe(true); }); }); -describe("server-generated identity columns", () => { +describe("identity and modifiers", () => { it.each([id(), bigid()])( - "flags serial PKs as serverGenerated + primaryKey + hasDefault", + "makes generated identities real not-null PK metadata", (builder) => { - expect(builder._meta.serverGenerated).toBe(true); - expect(builder._meta.primaryKey).toBe(true); - expect(builder._meta.hasDefault).toBe(true); + expect(builder._meta).toMatchObject({ + serverGenerated: true, + primaryKey: true, + notNull: true, + hasDefault: true, + }); }, ); - it("non-identity columns are not server generated by default", () => { - expect(text()._meta.serverGenerated).toBe(false); - expect(text()._meta.primaryKey).toBe(false); - expect(text()._meta.hasDefault).toBe(false); - }); -}); - -describe("modifier chain", () => { - it("sets boolean flags and is chainable", () => { - const col = text().notNull().unique().primaryKey().private().owner(); - const meta: ColumnMeta = col._meta; - expect(meta).toMatchObject({ + it("keeps the supported modifier chain and removes owner", () => { + const builder = text().notNull().unique().primaryKey().private(); + expect(builder._meta).toMatchObject({ notNull: true, unique: true, primaryKey: true, isPrivate: true, - isOwner: true, }); + expect("owner" in builder).toBe(false); + // @ts-expect-error DatabasePlugin owner/RLS metadata is not supported. + expectTypeOf["owner"]>().toBeFunction(); }); }); -describe("default-expression stamping", () => { - it("quotes and escapes string literals", () => { - expect(text().default("active")._meta.defaultExpr).toBe("'active'"); - expect(text().default("O'Brien")._meta.defaultExpr).toBe("'O''Brien'"); - }); - - it("stamps numeric and boolean literals verbatim", () => { - expect(integer().default(0)._meta.defaultExpr).toBe("0"); - expect(boolean().default(true)._meta.defaultExpr).toBe("true"); - expect(boolean().default(false)._meta.defaultExpr).toBe("false"); +describe("default helpers", () => { + it("records literal defaults without synthesizing them", () => { + expect(text().default("O'Brien")._meta).toMatchObject({ + hasDefault: true, + defaultValue: "O'Brien", + }); + expect(integer().default(42)._meta.defaultValue).toBe(42); + expect(boolean().default(false)._meta.defaultValue).toBe(false); }); - it("stamps canonical now() / gen_random_uuid() expressions", () => { - const ts = timestamp().defaultNow()._meta; - expect(ts.defaultExpr).toBe("now()"); - expect(ts.defaultNow).toBe(true); - - const rand = uuid().defaultRandom()._meta; - expect(rand.defaultExpr).toBe("gen_random_uuid()"); - expect(rand.defaultRandom).toBe(true); + it("restricts helpers to timestamp and UUID columns", () => { + expect(timestamp().defaultNow()._meta.defaultNow).toBe(true); + expect(uuid().defaultRandom()._meta.defaultRandom).toBe(true); + expect(() => text().defaultNow()).toThrow(/timestamp/); + expect(() => text().defaultRandom()).toThrow(/uuid/); }); - it("records hasDefault and defaultValue for literals", () => { - const col = integer().default(42)._meta; - expect(col.hasDefault).toBe(true); - expect(col.defaultValue).toBe(42); + it("allows only one explicit default mode", () => { + expect(() => text().default("x").default("y")).toThrow(/only one default/); + expect(() => + timestamp().defaultNow().default("2020-01-01T00:00:00Z"), + ).toThrow(/only one default/); + expect(() => id().default(1)).toThrow(/only one default/); }); }); -describe("referential-action modifiers", () => { - it("are allowed on fk() columns", () => { - const col = fk(() => ({ - __isColumnRef: true, - tableName: "users", - columnName: "id", - })) - .onDelete("cascade") - .onUpdate("set null"); - expect(col._meta.onDelete).toBe("cascade"); - expect(col._meta.onUpdate).toBe("set null"); - }); +describe("foreign-key modifiers", () => { + const ref = { + __isColumnRef: true as const, + tableName: "users", + columnName: "id", + }; - it("throw on non-fk columns", () => { - expect(() => integer().onDelete("cascade")).toThrow(SchemaBuildError); - expect(() => text().onUpdate("cascade")).toThrow( - /only valid on fk\(\) columns/, + it("accepts referential actions only on fk columns", () => { + const builder = fk(ref).onDelete("cascade").onUpdate("set null"); + expect(builder._meta.onDelete).toBe("cascade"); + expect(builder._meta.onUpdate).toBe("set null"); + expect(() => integer().onDelete("cascade")).toThrow(/only valid on fk/); + expect(() => fk(ref).onDelete("truncate" as never)).toThrow( + /Unsupported referential action/, ); }); + + it("pins the supported referential-action type", () => { + expectTypeOf[0]>().toEqualTypeOf< + "cascade" | "set null" | "set default" | "restrict" | "no action" + >(); + }); }); describe("enumColumn", () => { - it("carries the enum name and values", () => { - const col = enumColumn("status", ["active", "archived"]); - expect(col._meta).toMatchObject({ - storageKind: "enum", - enumName: "status", - enumValues: ["active", "archived"], - kind: "enum", - }); + it("clones and validates enum declarations", () => { + const values = ["active", "archived"]; + const builder = enumColumn("status", values); + values.push("mutated"); + expect(builder._meta.enumValues).toEqual(["active", "archived"]); + expect(Object.isFrozen(builder._meta.enumValues)).toBe(true); }); - it("throws when no values are provided", () => { - expect(() => enumColumn("status", [])).toThrow( - /requires at least one value/, + it("rejects empty and duplicate declarations", () => { + expect(() => enumColumn("status", [])).toThrow(/at least one value/); + expect(() => enumColumn("status", ["active", "active"])).toThrow( + /duplicate values/, ); }); }); diff --git a/packages/appkit/src/database/schema-builder/tests/define-schema.test.ts b/packages/appkit/src/database/schema-builder/tests/define-schema.test.ts index c03b6903c..c4c4661ef 100644 --- a/packages/appkit/src/database/schema-builder/tests/define-schema.test.ts +++ b/packages/appkit/src/database/schema-builder/tests/define-schema.test.ts @@ -1,326 +1,377 @@ import { getTableConfig, type PgTable } from "drizzle-orm/pg-core"; -import { describe, expect, it } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { - APPKIT_TABLE, - type AppKitTable, bigid, + bigint, boolean, type ColumnBuilder, type DefineSchemaOptions, defineSchema, + enumColumn, fk, id, integer, - type ResolvedRelation, + jsonb, type Schema, type SchemaBuilderContext, type TableHandle, text, timestamp, + uuid, + varchar, } from "../index"; import type { EngineTable } from "../types"; -/** Cast the opaque engine handle back to a real PgTable (test-only). */ -const pgOf = (t: EngineTable): PgTable => t as unknown as PgTable; +const pgOf = (table: EngineTable): PgTable => table as unknown as PgTable; -describe("defineSchema — basic build", () => { - const schema: Schema = defineSchema((t) => ({ - users: t.table("users", { +describe("defineSchema finalization", () => { + const schema = defineSchema((builder) => ({ + users: builder.table("users", { id: id(), email: text().notNull().unique(), name: text(), }), })); - it("returns the table keyed by its return key", () => { - expect(Object.keys(schema.$tables)).toEqual(["users"]); + it("preserves literal table keys and canonical identity", () => { + expectTypeOf(schema.$tables).toHaveProperty("users"); expect(schema.$tables.users.$name).toBe("users"); + expect(Object.keys(schema.$tables)).toEqual(["users"]); + expect(Object.keys(schema.$tables.users)).toEqual(["id", "email", "name"]); + }); + + it("publishes complete immutable metadata", () => { + const users = schema.$tables.users; + expect(Object.isFrozen(schema)).toBe(true); + expect(Object.isFrozen(schema.$tables)).toBe(true); + expect(Object.isFrozen(schema.$engine)).toBe(true); + expect(Object.isFrozen(users)).toBe(true); + expect(Object.isFrozen(users.$columns)).toBe(true); + expect(Object.isFrozen(users.$columns.id)).toBe(true); + expect(Object.isFrozen(users.$relations)).toBe(true); + expect(users.$insertSchema).toBeDefined(); + expect(users.$updateSchema).toBeDefined(); + + expect(() => { + (users.$columns as Record).rogue = {}; + }).toThrow(TypeError); + expect(() => { + (schema.$tables as Record).rogue = users; + }).toThrow(TypeError); + }); + + it("uses collision-safe registries", () => { + expect(Object.getPrototypeOf(schema.$tables)).toBeNull(); + expect(Object.getPrototypeOf(schema.$engine)).toBeNull(); + expect(Object.getPrototypeOf(schema.$tables.users.$columns)).toBeNull(); }); - it("defaults schemaName to 'public'", () => { + it("defaults to public and supports a safe custom schema", () => { expect(schema.$schemaName).toBe("public"); - expect(schema.$tables.users.$schemaName).toBe("public"); + const options: DefineSchemaOptions = { schemaName: "application" }; + const custom = defineSchema( + (builder) => ({ widgets: builder.table("widgets", { id: id() }) }), + options, + ); + expect(custom.$schemaName).toBe("application"); + expect(custom.$tables.widgets.$schemaName).toBe("application"); }); - it("marks built tables with the APPKIT_TABLE symbol", () => { - expect( - (schema.$tables.users as unknown as Record)[ - APPKIT_TABLE - ], - ).toBe(true); + it("accepts the explicit builder context type", () => { + const build = (builder: SchemaBuilderContext) => { + const users: TableHandle<{ id: ColumnBuilder; email: ColumnBuilder }> = + builder.table("users", { id: id(), email: text() }); + return { users }; + }; + const typed: Schema = defineSchema(build); + expect(typed.$tables.users.$name).toBe("users"); }); - it("stamps column metadata", () => { - const cols = schema.$tables.users.$columns; - expect(cols.id.serverGenerated).toBe(true); - expect(cols.id.primaryKey).toBe(true); - expect(cols.id.hasDefault).toBe(true); - expect(cols.email.notNull).toBe(true); - expect(cols.email.unique).toBe(true); - expect(cols.name.notNull).toBe(false); + it("allows an explicitly empty schema", () => { + const empty = defineSchema(() => ({})); + expect(empty.$tables).toEqual({}); + expect(empty.$engine).toEqual({}); + expect(Object.isFrozen(empty)).toBe(true); }); - it("populates an engine table handle per column", () => { - expect(schema.$tables.users.$columns.id.engineColumn).toBeDefined(); + it("does not partially finalize handles when declaration validation fails", () => { + let firstHandle: TableHandle<{ id: ColumnBuilder }> | undefined; + expect(() => + defineSchema((builder) => { + const first = builder.table("first", { id: id() }); + const second = builder.table("second", { id: id() }); + firstHandle = first; + Object.preventExtensions(second); + return { first, second }; + }), + ).toThrow(/modified during schema declaration/); + if (!firstHandle) + throw new Error("fixture did not retain the first handle"); + expect("$name" in firstHandle).toBe(false); }); }); -describe("defineSchema — engine maps (no relations)", () => { - const schema = defineSchema((t) => ({ - users: t.table("users", { id: id() }), - tags: t.table("tags", { id: id(), label: text() }), - })); +describe("canonical table identity", () => { + it("requires every declared table exactly once", () => { + expect(() => + defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + builder.table("omitted", { id: id() }); + return { users }; + }), + ).toThrow(/omitted declared table: omitted/); + }); - it("$engine carries a handle per table", () => { - expect(Object.keys(schema.$engine).sort()).toEqual(["tags", "users"]); + it("rejects aliases and duplicate handles", () => { + expect(() => + defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + return { people: users }; + }), + ).toThrow(/aliases are not supported/); + + expect(() => + defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + return { users, duplicate: users }; + }), + ).toThrow(/aliases are not supported|returned more than once/); }); - it("leaves $relations empty on every table", () => { - for (const tbl of Object.values(schema.$tables)) { - const relations: ResolvedRelation[] = tbl.$relations; - expect(relations).toEqual([]); - } + it("rejects foreign values and duplicate table declarations", () => { + expect(() => + defineSchema((builder) => { + builder.table("users", { id: id() }); + return { rogue: {} as never }; + }), + ).toThrow(/was not produced by ctx\.table/); + expect(() => + defineSchema((builder) => { + const first = builder.table("users", { id: id() }); + builder.table("users", { id: id() }); + return { users: first }; + }), + ).toThrow(/Duplicate table/); }); -}); -describe("defineSchema — engine maps (with relations)", () => { - const schema = defineSchema((t) => ({ - users: t.table("users", { id: id() }), - posts: t.table("posts", { - id: id(), - authorId: fk(() => ({ - __isColumnRef: true, - tableName: "users", - columnName: "id", + it("allows ordinary quoted names while rejecting concrete runtime collisions", () => { + const quoted = defineSchema((builder) => ({ + toString: builder.table("toString", { displayName: text() }), + })); + expect(Object.values(quoted.$tables)[0].$name).toBe("toString"); + + expect(() => + defineSchema((builder) => ({ + users: builder.table("users", { and: text() }), })), - }), - })); + ).toThrow(/runtime metadata/); - it("$engine carries only the table handles", () => { - expect(Object.keys(schema.$engine).sort()).toEqual(["posts", "users"]); + const reservedColumns = Object.create(null) as Record< + string, + ColumnBuilder + >; + reservedColumns.__proto__ = text(); + expect(() => + defineSchema((builder) => ({ + users: builder.table("users", reservedColumns), + })), + ).toThrow(/reserved/); }); +}); - it("resolves the forward toOne on the FK owner", () => { - const relations: ResolvedRelation[] = schema.$tables.posts.$relations; - expect(relations).toEqual([ - { - name: "users", - cardinality: "toOne", - localColumn: "authorId", - targetTable: "users", - targetColumn: "id", - inferred: false, - }, - ]); +describe("primary-key invariants and Drizzle agreement", () => { + it.each([ + ["id", id()], + ["bigid", bigid()], + ])("emits %s as an actual identity primary key", (_name, key) => { + const schema = defineSchema((builder) => ({ + records: builder.table("records", { key }), + })); + const config = getTableConfig(pgOf(schema.$tables.records.$engine)); + const column = config.columns[0]; + expect(column.primary).toBe(true); + expect(column.notNull).toBe(true); + expect(column.generatedIdentity).toMatchObject({ type: "byDefault" }); + expect(schema.$tables.records.$columns.key.primaryKey).toBe(true); }); - it("infers the reverse toMany on the FK target", () => { - const relations: ResolvedRelation[] = schema.$tables.users.$relations; - expect(relations).toEqual([ - { - name: "posts", - cardinality: "toMany", - localColumn: "id", - targetTable: "posts", - targetColumn: "authorId", - inferred: true, - }, - ]); + it("supports keyless and one application-assigned primary key", () => { + const schema = defineSchema((builder) => ({ + events: builder.table("events", { body: text().notNull() }), + accounts: builder.table("accounts", { + slug: text().primaryKey(), + label: text(), + }), + })); + expect( + Object.values(schema.$tables.events.$columns).some( + (column) => column.primaryKey, + ), + ).toBe(false); + const config = getTableConfig(pgOf(schema.$tables.accounts.$engine)); + const slug = config.columns.find((column) => column.name === "slug"); + expect(slug?.primary).toBe(true); + expect(slug?.notNull).toBe(true); + expect(slug?.generatedIdentity).toBeUndefined(); }); -}); -describe("defineSchema — typed builder surface", () => { - it("accepts a typed SchemaBuilderContext callback", () => { - const build = (t: SchemaBuilderContext) => { - const users: TableHandle<{ id: ColumnBuilder; email: ColumnBuilder }> = - t.table("users", { id: id(), email: text() }); - return { users }; - }; - const schema = defineSchema(build); - expect(schema.$tables.users.$name).toBe("users"); + it("rejects multiple primary-key markers", () => { + expect(() => + defineSchema((builder) => ({ + invalid: builder.table("invalid", { + first: text().primaryKey(), + second: integer().primaryKey(), + }), + })), + ).toThrow(/multiple primary-key columns/); }); }); -describe("defineSchema — custom schemaName", () => { - it("threads schemaName onto the schema and tables", () => { - const options: DefineSchemaOptions = { schemaName: "app" }; - const schema = defineSchema( - (t) => ({ users: t.table("users", { id: id() }) }), - options, +describe("builder reuse and engine metadata", () => { + it("clones builder state across columns and schemas", () => { + const shared = text(); + const first = defineSchema((builder) => ({ + first: builder.table("first", { left: shared, right: shared }), + })); + shared.private().default("later"); + const second = defineSchema((builder) => ({ + second: builder.table("second", { value: shared }), + })); + + expect(first.$tables.first.$columns.left.isPrivate).toBe(false); + expect(first.$tables.first.$columns.right.hasDefault).toBe(false); + expect(second.$tables.second.$columns.value.isPrivate).toBe(true); + expect(second.$tables.second.$columns.value.defaultValue).toBe("later"); + expect(first.$tables.first.$columns.left).not.toBe( + first.$tables.first.$columns.right, + ); + expect(first.$tables.first.$columns.left.engineColumn).not.toBe( + second.$tables.second.$columns.value.engineColumn, ); - expect(schema.$schemaName).toBe("app"); - expect(schema.$tables.users.$schemaName).toBe("app"); }); -}); -describe("defineSchema — foreign keys", () => { - it("forward FK mirrors a serial PK to integer storage", () => { - const schema = defineSchema((t) => { - const users = t.table("users", { id: id(), email: text() }); - const posts = t.table("posts", { + it("materializes defaults in Drizzle configuration", () => { + const schema = defineSchema((builder) => ({ + records: builder.table("records", { id: id(), - authorId: fk(() => users.id).notNull(), - }); - return { users, posts }; - }); - const authorId = schema.$tables.posts.$columns.authorId; - expect(authorId.storageKind).toBe("integer"); - expect(authorId.pgType).toBe("int4"); - expect(authorId.notNull).toBe(true); - expect(authorId.fk).toEqual({ - targetTable: "users", - targetColumn: "id", - onDelete: undefined, - onUpdate: undefined, - }); + active: boolean().default(true), + count: integer().default(0), + createdAt: timestamp().defaultNow(), + }), + })); + const columns = Object.fromEntries( + getTableConfig(pgOf(schema.$tables.records.$engine)).columns.map( + (column) => [column.name, column], + ), + ); + expect(columns.active.default).toBe(true); + expect(columns.count.default).toBe(0); + expect(columns.createdAt.hasDefault).toBe(true); }); - it("forward FK to a bigid PK mirrors to bigint storage", () => { - const schema = defineSchema((t) => { - const orgs = t.table("orgs", { id: bigid() }); - const teams = t.table("teams", { id: id(), orgId: fk(() => orgs.id) }); - return { orgs, teams }; - }); - const orgId = schema.$tables.teams.$columns.orgId; - expect(orgId.storageKind).toBe("bigint"); - expect(orgId.pgType).toBe("int8"); - expect(orgId.kind).toBe("bigint"); + it("keeps timestamp values in the declared string runtime representation", () => { + const value = "2024-01-02T03:04:05Z"; + const schema = defineSchema((builder) => ({ + records: builder.table("records", { + occurredAt: timestamp().default(value), + }), + })); + const [column] = getTableConfig( + pgOf(schema.$tables.records.$engine), + ).columns; + expect(column.mapToDriverValue(value as never)).toBe(value); + expect(column.default).toBe(value); }); - it("supports self-referencing FKs", () => { - const schema = defineSchema((t) => { - const nodes = t.table("nodes", { - id: id(), - // self-ref via a direct ColumnRef thunk (avoids circular type inference). - parentId: fk(() => ({ - __isColumnRef: true, - tableName: "nodes", - columnName: "id", - })), - }); - return { nodes }; - }); - expect(schema.$tables.nodes.$columns.parentId.fk?.targetTable).toBe( - "nodes", - ); - }); + it("validates literal defaults against storage and enum values", () => { + const schema = defineSchema((builder) => ({ + records: builder.table("records", { + status: enumColumn("record_status", ["open", "closed"]).default("open"), + }), + })); + const [status] = getTableConfig( + pgOf(schema.$tables.records.$engine), + ).columns; + expect(status.default).toBe("open"); - it("carries onDelete/onUpdate referential actions onto the edge", () => { - const schema = defineSchema((t) => { - const users = t.table("users", { id: id() }); - const posts = t.table("posts", { - id: id(), - authorId: fk(() => users.id) - .onDelete("cascade") - .onUpdate("restrict"), - }); - return { users, posts }; - }); - expect(schema.$tables.posts.$columns.authorId.fk).toMatchObject({ - onDelete: "cascade", - onUpdate: "restrict", - }); + const incompatibleDefaults = [ + text().default(1), + integer().default("1"), + integer().default(2_147_483_648), + boolean().default("true"), + varchar(3).default("toolong"), + uuid().default("not-a-uuid"), + timestamp().default("not-a-timestamp"), + enumColumn("invalid_status", ["open", "closed"]).default("missing"), + bigint().default(1), + jsonb().default("{}"), + ]; + for (const value of incompatibleDefaults) { + expect(() => + defineSchema((builder) => ({ + records: builder.table("records", { value }), + })), + ).toThrow(/not compatible/); + } }); +}); - it("throws when fk() targets an unknown table", () => { +describe("enum identity", () => { + it("reuses equal declarations and rejects conflicting values", () => { expect(() => - defineSchema((t) => ({ - posts: t.table("posts", { - id: id(), - ghost: fk(() => ({ - __isColumnRef: true, - tableName: "missing", - columnName: "id", - })), + defineSchema((builder) => ({ + first: builder.table("first", { + status: enumColumn("status_kind", ["open", "closed"]), + }), + second: builder.table("second", { + status: builder.enum("status_kind", ["open", "closed"]), }), })), - ).toThrow(/unknown table "missing"/); - }); + ).not.toThrow(); - it("throws when fk() targets an unknown column", () => { expect(() => - defineSchema((t) => { - const users = t.table("users", { id: id() }); - const posts = t.table("posts", { - id: id(), - ghost: fk(() => ({ - __isColumnRef: true, - tableName: "users", - columnName: "nope", - })), - }); - return { users, posts }; - }), - ).toThrow(/unknown column "users\.nope"/); + defineSchema((builder) => ({ + first: builder.table("first", { + status: enumColumn("status_kind", ["open", "closed"]), + }), + second: builder.table("second", { + status: enumColumn("status_kind", ["open", "archived"]), + }), + })), + ).toThrow(/conflicting values/); }); -}); -describe("defineSchema — guard rails", () => { - it("throws on duplicate table names", () => { - expect(() => - defineSchema((t) => { - const a = t.table("users", { id: id() }); - const b = t.table("users", { id: id() }); - return { a, b }; + it("creates enums in the table's PostgreSQL schema", () => { + const schema = defineSchema( + (builder) => ({ + tickets: builder.table("tickets", { + status: builder.enum("ticket_status", ["open", "closed"]), + }), }), - ).toThrow(/Duplicate table "users"/); + { schemaName: "application" }, + ); + const [column] = getTableConfig( + pgOf(schema.$tables.tickets.$engine), + ).columns; + expect( + (column as unknown as { enum?: { schema?: string } }).enum?.schema, + ).toBe("application"); + expect(column.enumValues).toEqual(["open", "closed"]); }); +}); - it("throws when a returned value did not come from ctx.table()", () => { - const rogue = { - $name: "rogue", - $schemaName: "public", - $columns: {}, - $relations: [], - } as unknown as AppKitTable; +describe("generated relation-key collisions", () => { + it("rejects a table that would overwrite Drizzle relation metadata", () => { expect(() => - defineSchema((t) => { - t.table("users", { id: id() }); - return { rogue }; + defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + const posts = builder.table("posts", { + id: id(), + userId: fk(() => users.id), + }); + const usersRelations = builder.table("usersRelations", { id: id() }); + return { users, posts, usersRelations }; }), - ).toThrow(/was not produced by ctx\.table\(\)/); - }); -}); - -describe("defineSchema — real engine wiring (getTableConfig)", () => { - const schema = defineSchema((t) => { - const users = t.table("users", { - id: id(), - email: text().notNull(), - active: boolean().default(true), - createdAt: timestamp().defaultNow(), - }); - const posts = t.table("posts", { - id: id(), - authorId: fk(() => users.id) - .notNull() - .onDelete("cascade"), - views: integer().default(0), - }); - return { users, posts }; - }); - - it("emits a real FK with the correct local/target columns and action", () => { - const config = getTableConfig(pgOf(schema.$tables.posts.$engine)); - expect(config.foreignKeys).toHaveLength(1); - const fkConfig = config.foreignKeys[0]; - const ref = fkConfig.reference(); - expect(ref.columns.map((c) => c.name)).toEqual(["authorId"]); - expect(ref.foreignColumns.map((c) => c.name)).toEqual(["id"]); - expect(fkConfig.onDelete).toBe("cascade"); - }); - - it("wires column names and notNull onto the engine table", () => { - const config = getTableConfig(pgOf(schema.$tables.users.$engine)); - const byName = Object.fromEntries(config.columns.map((c) => [c.name, c])); - expect(Object.keys(byName).sort()).toEqual([ - "active", - "createdAt", - "email", - "id", - ]); - expect(byName.email.notNull).toBe(true); - // identity PK is tracked in our ColumnMeta, not pushed onto the serial builder. - expect(schema.$tables.users.$columns.id.primaryKey).toBe(true); + ).toThrow(/collides with generated relation metadata/); }); }); diff --git a/packages/appkit/src/database/schema-builder/tests/engine-relations.test.ts b/packages/appkit/src/database/schema-builder/tests/engine-relations.test.ts index 70a16e1fa..6a2ed0060 100644 --- a/packages/appkit/src/database/schema-builder/tests/engine-relations.test.ts +++ b/packages/appkit/src/database/schema-builder/tests/engine-relations.test.ts @@ -1,7 +1,8 @@ import { createTableRelationsHelpers, Many, One } from "drizzle-orm"; import type { PgTable } from "drizzle-orm/pg-core"; import { describe, expect, it } from "vitest"; -import { buildEngineRelations, defineSchema, fk, id, text } from "../index"; +import { buildEngineRelations } from "../engine/relations"; +import { defineSchema, fk, id, text } from "../index"; /** Structural view of a Drizzle `relations()` object (avoids leaning on internals' exact types). */ type RelationsLike = { @@ -63,14 +64,14 @@ describe("buildEngineRelations", () => { ).toBe("posts"); }); - it("resolves relation targets by table name even when return keys differ", () => { + it("resolves relation targets by canonical table identity", () => { const s = defineSchema((t) => { const cases = t.table("cases", { id: id() }); - const statusHistory = t.table("status_history", { + const status_history = t.table("status_history", { id: id(), caseId: fk(() => cases.id), }); - return { cases, statusHistory }; + return { cases, status_history }; }); const rels = buildEngineRelations(s.$tables); diff --git a/packages/appkit/src/database/schema-builder/tests/fk.test.ts b/packages/appkit/src/database/schema-builder/tests/fk.test.ts index ae7c1113a..795fbe11b 100644 --- a/packages/appkit/src/database/schema-builder/tests/fk.test.ts +++ b/packages/appkit/src/database/schema-builder/tests/fk.test.ts @@ -1,48 +1,317 @@ +import { getTableConfig, type PgTable } from "drizzle-orm/pg-core"; import { describe, expect, it } from "vitest"; import { mirrorStorageKind, resolveFkRef } from "../fk"; -import { type ColumnRef, fk, type StorageKind } from "../index"; +import { + bigint, + type ColumnBuilder, + type ColumnRef, + defineSchema, + enumColumn, + fk, + id, + type StorageKind, + type TableHandle, + text, + timestamp, + uuid, + varchar, +} from "../index"; +import type { EngineTable } from "../types"; -const ref: ColumnRef = { +const pgOf = (table: EngineTable): PgTable => table as unknown as PgTable; +const ref: ColumnRef = Object.freeze({ __isColumnRef: true, tableName: "users", columnName: "id", -}; +}); -describe("fk()", () => { - it("produces an fk column with a placeholder integer storage", () => { - const col = fk(ref); - expect(col._spec.kind).toBe("fk"); - expect(col._meta.storageKind).toBe("integer"); - expect(col._meta.fkRef).toBe(ref); +describe("fk references", () => { + it("stores direct and deferred references without resolving early", () => { + expect(fk(ref)._meta.fkRef).toBe(ref); + expect(typeof fk(() => ref)._meta.fkRef).toBe("function"); + expect(resolveFkRef(ref)).toBe(ref); + expect(resolveFkRef(() => ref)).toBe(ref); }); - it("accepts a thunk ref for forward/self references", () => { - const col = fk(() => ref); - expect(typeof col._meta.fkRef).toBe("function"); + it("rejects malformed references", () => { + expect(() => resolveFkRef({} as ColumnRef)).toThrow( + /must reference a column/, + ); }); }); -describe("resolveFkRef()", () => { - it("returns a direct ref unchanged", () => { - expect(resolveFkRef(ref)).toBe(ref); +describe("foreign-key storage and target invariants", () => { + it("mirrors generated identities to non-generated integer storage", () => { + expect(mirrorStorageKind("id")).toBe("integer"); + expect(mirrorStorageKind("bigid")).toBe("bigint"); + expect(mirrorStorageKind("uuid")).toBe("uuid"); }); - it("invokes a thunk ref", () => { - expect(resolveFkRef(() => ref)).toBe(ref); + it("inherits the complete target storage contract", () => { + const schema = defineSchema((builder) => { + const integer_targets = builder.table("integer_targets", { id: id() }); + const uuid_targets = builder.table("uuid_targets", { + value: uuid().unique(), + }); + const varchar_targets = builder.table("varchar_targets", { + value: varchar(32).unique(), + }); + const timestamp_targets = builder.table("timestamp_targets", { + value: timestamp({ withTimezone: true }).unique(), + }); + const bigint_targets = builder.table("bigint_targets", { + value: bigint().unique(), + }); + const enum_targets = builder.table("enum_targets", { + value: enumColumn("target_status", ["open", "closed"]).unique(), + }); + const references = builder.table("references", { + id: id(), + targetId: fk(() => integer_targets.id), + externalId: fk(() => uuid_targets.value), + code: fk(() => varchar_targets.value), + happenedAt: fk(() => timestamp_targets.value), + ordinal: fk(() => bigint_targets.value), + status: fk(() => enum_targets.value), + }); + return { + integer_targets, + uuid_targets, + varchar_targets, + timestamp_targets, + bigint_targets, + enum_targets, + references, + }; + }); + + const columns = schema.$tables.references.$columns; + expect(columns.targetId).toMatchObject({ + storageKind: "integer", + kind: "number", + }); + expect(columns.externalId).toMatchObject({ + storageKind: "uuid", + kind: "uuid", + }); + expect(columns.code).toMatchObject({ + storageKind: "varchar", + varcharLength: 32, + }); + expect(columns.happenedAt).toMatchObject({ + storageKind: "timestamp", + withTimezone: true, + kind: "date", + }); + expect(columns.ordinal).toMatchObject({ + storageKind: "bigint", + kind: "bigint", + }); + expect(columns.status).toMatchObject({ + storageKind: "enum", + enumName: "target_status", + enumValues: ["open", "closed"], + }); + }); + + it("resolves chained FK storage independently of declaration order", () => { + const schema = defineSchema((builder) => { + let middle: TableHandle<{ leafId: ColumnBuilder }>; + let leaf: TableHandle<{ id: ColumnBuilder }>; + const root = builder.table("root", { + middleId: fk(() => middle.leafId), + }); + middle = builder.table("middle", { + leafId: fk(() => leaf.id).unique(), + }); + leaf = builder.table("leaf", { id: uuid().primaryKey() }); + return { root, middle, leaf }; + }); + + expect(schema.$tables.middle.$columns.leafId.storageKind).toBe("uuid"); + expect(schema.$tables.root.$columns.middleId.storageKind).toBe("uuid"); + const [foreignKey] = getTableConfig( + pgOf(schema.$tables.root.$engine), + ).foreignKeys; + expect(foreignKey.reference().foreignColumns[0].name).toBe("leafId"); + }); + + it("rejects FK storage cycles that have no concrete target type", () => { + expect(() => + defineSchema((builder) => { + let left: TableHandle<{ rightId: ColumnBuilder }>; + const right = builder.table("right", { + leftId: fk(() => left.rightId).unique(), + }); + left = builder.table("left", { + rightId: fk(() => right.leftId).unique(), + }); + return { left, right }; + }), + ).toThrow(/cyclic storage dependency/); + }); + + it("requires a primary-key or unique target", () => { + expect(() => + defineSchema((builder) => { + const users = builder.table("users", { id: id(), email: text() }); + const notes = builder.table("notes", { + userEmail: fk(() => users.email), + }); + return { users, notes }; + }), + ).toThrow(/primary-key or unique/); + }); + + it("rejects unknown, cross-schema, and omitted targets", () => { + expect(() => + defineSchema((builder) => ({ + notes: builder.table("notes", { + userId: fk(() => ({ + __isColumnRef: true, + tableName: "missing", + columnName: "id", + })), + }), + })), + ).toThrow(/outside the returned schema/); + + let externalUsers!: TableHandle<{ id: ColumnBuilder }>; + defineSchema((builder) => { + externalUsers = builder.table("users", { id: id() }); + return { users: externalUsers }; + }); + expect(() => + defineSchema((builder) => ({ + notes: builder.table("notes", { + userId: fk(() => externalUsers.id), + }), + })), + ).toThrow(/outside the returned schema/); + + expect(() => + defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + const notes = builder.table("notes", { + userId: fk(() => externalUsers.id), + }); + return { users, notes }; + }), + ).toThrow(/outside the returned schema/); + + expect(() => + defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + const notes = builder.table("notes", { userId: fk(() => users.id) }); + return { notes }; + }), + ).toThrow(/omitted declared table: users/); }); }); -describe("mirrorStorageKind()", () => { - it("maps serial PK kinds to their plain integer storage", () => { - const fromId: StorageKind = mirrorStorageKind("id"); - expect(fromId).toBe("integer"); - expect(mirrorStorageKind("bigid")).toBe("bigint"); +describe("referential-action coherence", () => { + it("allows SET NULL only on nullable foreign keys", () => { + expect(() => + defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + const notes = builder.table("notes", { + userId: fk(() => users.id).onDelete("set null"), + }); + return { users, notes }; + }), + ).not.toThrow(); + + expect(() => + defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + const notes = builder.table("notes", { + userId: fk(() => users.id) + .notNull() + .onDelete("set null"), + }); + return { users, notes }; + }), + ).toThrow(/SET NULL but is not-null/); + }); + + it("allows SET DEFAULT only with a compatible local default", () => { + expect(() => + defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + const notes = builder.table("notes", { + userId: fk(() => users.id) + .default(0) + .onDelete("set default"), + }); + return { users, notes }; + }), + ).not.toThrow(); + + expect(() => + defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + const notes = builder.table("notes", { + userId: fk(() => users.id).onUpdate("set default"), + }); + return { users, notes }; + }), + ).toThrow(/SET DEFAULT without a local default/); }); - it.each(["text", "uuid", "integer", "bigint", "boolean"] as const)( - "passes %s through unchanged", - (kind) => { - expect(mirrorStorageKind(kind)).toBe(kind); - }, - ); + it("validates literal defaults after inheriting the target storage", () => { + expect(() => + defineSchema((builder) => { + const users = builder.table("users", { + externalId: uuid().unique(), + }); + const notes = builder.table("notes", { + userId: fk(() => users.externalId).default(1), + }); + return { users, notes }; + }), + ).toThrow(/not compatible with uuid storage/); + + expect(() => + defineSchema((builder) => { + const statuses = builder.table("statuses", { + value: enumColumn("status_kind", ["open", "closed"]).unique(), + }); + const records = builder.table("records", { + status: fk(() => statuses.value).default("missing"), + }); + return { statuses, records }; + }), + ).toThrow(/not compatible with enum storage/); + }); + + it("materializes validated foreign keys and actions in Drizzle", () => { + const schema = defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + const notes = builder.table("notes", { + id: id(), + userId: fk(() => users.id) + .notNull() + .onDelete("cascade") + .onUpdate("restrict"), + }); + return { users, notes }; + }); + const [foreignKey] = getTableConfig( + pgOf(schema.$tables.notes.$engine), + ).foreignKeys; + const reference = foreignKey.reference(); + expect(reference.columns.map((column) => column.name)).toEqual(["userId"]); + expect(reference.foreignColumns.map((column) => column.name)).toEqual([ + "id", + ]); + expect(foreignKey.onDelete).toBe("cascade"); + expect(foreignKey.onUpdate).toBe("restrict"); + }); +}); + +describe("StorageKind", () => { + it("keeps the supported inherited kinds", () => { + const kind: StorageKind = mirrorStorageKind("text"); + expect(kind).toBe("text"); + }); }); diff --git a/packages/appkit/src/database/schema-builder/tests/private.test.ts b/packages/appkit/src/database/schema-builder/tests/private.test.ts deleted file mode 100644 index 634d8e7ac..000000000 --- a/packages/appkit/src/database/schema-builder/tests/private.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - defineSchema, - fk, - id, - isPrivateColumn, - nonPrivateColumnNames, - ownerColumnName, - privateColumnNames, - text, -} from "../index"; - -const schema = defineSchema((t) => { - const users = t.table("users", { - id: id(), - email: text().notNull().owner(), - name: text(), - passwordHash: text().private(), - }); - const posts = t.table("posts", { - id: id(), - authorId: fk(() => users.id), - title: text(), - }); - return { users, posts }; -}); - -const users = schema.$tables.users; - -describe("isPrivateColumn", () => { - it("reflects the .private() modifier", () => { - expect(isPrivateColumn(users.$columns.passwordHash)).toBe(true); - expect(isPrivateColumn(users.$columns.email)).toBe(false); - }); -}); - -describe("privateColumnNames / nonPrivateColumnNames", () => { - it("partitions the columns by privacy", () => { - expect(privateColumnNames(users)).toEqual(["passwordHash"]); - expect(nonPrivateColumnNames(users)).toEqual(["id", "email", "name"]); - }); - - it("returns an empty private list when none are marked", () => { - expect(privateColumnNames(schema.$tables.posts)).toEqual([]); - }); -}); - -describe("ownerColumnName", () => { - it("returns the column flagged with .owner()", () => { - expect(ownerColumnName(users)).toBe("email"); - }); - - it("returns undefined when no owner column is declared", () => { - expect(ownerColumnName(schema.$tables.posts)).toBeUndefined(); - }); - - it("rejects tables with multiple owner columns", () => { - expect(() => - defineSchema((t) => ({ - users: t.table("users", { - id: id(), - email: text().owner(), - accountId: text().owner(), - }), - })), - ).toThrow(/multiple \.owner\(\) columns/); - }); -}); diff --git a/packages/appkit/src/database/schema-builder/tests/relations.test.ts b/packages/appkit/src/database/schema-builder/tests/relations.test.ts index 661032d42..6963dcade 100644 --- a/packages/appkit/src/database/schema-builder/tests/relations.test.ts +++ b/packages/appkit/src/database/schema-builder/tests/relations.test.ts @@ -1,25 +1,31 @@ import { describe, expect, it } from "vitest"; import { - type AppKitTable, - buildRelations, - type ColumnMeta, + type ColumnBuilder, defineSchema, fk, id, type ResolvedRelation, + type TableHandle, text, } from "../index"; -describe("buildRelations — forward toOne", () => { - const schema = defineSchema((t) => { - const cases = t.table("cases", { id: id() }); - const notes = t.table("notes", { id: id(), caseId: fk(() => cases.id) }); - return { cases, notes }; - }); +describe("deterministic relation metadata", () => { + it("uses target/source table identity for forward and reverse relations", () => { + const schema = defineSchema((builder) => { + const cases = builder.table("cases", { id: id() }); + const status_history = builder.table("status_history", { + id: id(), + caseId: fk(() => cases.id), + }); + return { cases, status_history }; + }); + + const forward: readonly ResolvedRelation[] = + schema.$tables.status_history.$relations; + const reverse: readonly ResolvedRelation[] = + schema.$tables.cases.$relations; - it("creates a forward toOne named after the target table on the FK owner", () => { - const relations: ResolvedRelation[] = schema.$tables.notes.$relations; - expect(relations).toEqual([ + expect(forward).toEqual([ { name: "cases", cardinality: "toOne", @@ -29,60 +35,40 @@ describe("buildRelations — forward toOne", () => { inferred: false, }, ]); - }); -}); - -describe("buildRelations — inferred reverse toMany", () => { - it("infers the reverse toMany using the SOURCE table name verbatim", () => { - const schema = defineSchema((t) => { - const cases = t.table("cases", { id: id() }); - const notes = t.table("notes", { id: id(), caseId: fk(() => cases.id) }); - return { cases, notes }; - }); - const reverse: ResolvedRelation[] = schema.$tables.cases.$relations; expect(reverse).toEqual([ { - // verbatim source name — NOT re-pluralized to "noteses" - name: "notes", + name: "status_history", cardinality: "toMany", localColumn: "id", - targetTable: "notes", + targetTable: "status_history", targetColumn: "caseId", inferred: true, }, ]); }); - it("leaves an already-plural source name unchanged on the reverse relation", () => { - const schema = defineSchema((t) => { - const cases = t.table("cases", { id: id() }); - const statusHistory = t.table("status_history", { + it("keeps reverse relations toMany even for a unique FK", () => { + const schema = defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + const profiles = builder.table("profiles", { id: id(), - caseId: fk(() => cases.id), + userId: fk(() => users.id).unique(), }); - return { cases, statusHistory }; + return { users, profiles }; }); - expect(schema.$tables.cases.$relations.map((r) => r.name)).toEqual([ - "status_history", - ]); + expect(schema.$tables.users.$relations[0].cardinality).toBe("toMany"); }); -}); -describe("buildRelations — self references", () => { - it("keeps only the forward toOne for a self-referential FK", () => { - const schema = defineSchema((t) => { - const nodes = t.table("nodes", { + it("exposes only the forward relation for a self reference", () => { + const schema = defineSchema((builder) => { + let nodes: TableHandle<{ id: ColumnBuilder; parentId: ColumnBuilder }>; + nodes = builder.table("nodes", { id: id(), - parentId: fk(() => ({ - __isColumnRef: true, - tableName: "nodes", - columnName: "id", - })), + parentId: fk(() => nodes.id), }); return { nodes }; }); - const relations: ResolvedRelation[] = schema.$tables.nodes.$relations; - expect(relations).toEqual([ + expect(schema.$tables.nodes.$relations).toEqual([ { name: "nodes", cardinality: "toOne", @@ -93,97 +79,63 @@ describe("buildRelations — self references", () => { }, ]); }); -}); -/** Minimal `AppKitTable` factory for exercising `buildRelations` directly. */ -function makeTable( - name: string, - columns: Record & { columnName: string }>, -): AppKitTable { - return { - $name: name, - $schemaName: "public", - $columns: columns as Record, - $engine: {} as AppKitTable["$engine"], - $relations: [], - }; -} - -describe("buildRelations — direct invocation", () => { - it("populates forward toOne and reverse toMany across the table map", () => { - const cases = makeTable("cases", { id: { columnName: "id" } }); - const notes = makeTable("notes", { - id: { columnName: "id" }, - caseId: { - columnName: "caseId", - fk: { targetTable: "cases", targetColumn: "id" }, - }, + it("freezes relation objects and arrays", () => { + const schema = defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + const posts = builder.table("posts", { + id: id(), + userId: fk(() => users.id), + }); + return { users, posts }; }); - - buildRelations({ cases, notes }); - - expect(notes.$relations).toEqual([ - { - name: "cases", - cardinality: "toOne", - localColumn: "caseId", - targetTable: "cases", - targetColumn: "id", - inferred: false, - }, - ]); - expect(cases.$relations).toEqual([ - { - name: "notes", - cardinality: "toMany", - localColumn: "id", - targetTable: "notes", - targetColumn: "caseId", - inferred: true, - }, - ]); + expect(Object.isFrozen(schema.$tables.users.$relations)).toBe(true); + expect(Object.isFrozen(schema.$tables.users.$relations[0])).toBe(true); + expect(() => { + (schema.$tables.users.$relations as unknown[]).push({}); + }).toThrow(TypeError); }); }); -describe("buildRelations — collision + ambiguity guards", () => { - it("throws when a forward relation name collides with a column", () => { +describe("relation ambiguity guards", () => { + it("rejects multiple FKs from one table to the same target", () => { expect(() => - defineSchema((t) => { - const tag = t.table("tag", { id: id() }); - const post = t.table("post", { + defineSchema((builder) => { + const users = builder.table("users", { id: id() }); + const messages = builder.table("messages", { id: id(), - tag: text(), - tagId: fk(() => tag.id), + senderId: fk(() => users.id), + recipientId: fk(() => users.id), }); - return { tag, post }; + return { users, messages }; }), - ).toThrow(/Forward relation "post\.tag" collides with a column/); + ).toThrow(/Ambiguous forward relation/); }); - it("throws when two FKs target the same table (ambiguous forward)", () => { + it("rejects forward relation/column collisions", () => { expect(() => - defineSchema((t) => { - const users = t.table("users", { id: id() }); - const messages = t.table("messages", { + defineSchema((builder) => { + const tags = builder.table("tags", { id: id() }); + const posts = builder.table("posts", { id: id(), - senderId: fk(() => users.id), - recipientId: fk(() => users.id), + tags: text(), + tagId: fk(() => tags.id), }); - return { users, messages }; + return { tags, posts }; }), - ).toThrow(/Ambiguous forward relation "messages\.users"/); + ).toThrow(/Forward relation .* collides with a column/); }); - it("throws when a reverse relation name collides with a column on the target", () => { + it("rejects reverse relation/column collisions", () => { expect(() => - defineSchema((t) => { - const notes = t.table("notes", { id: id(), posts: text() }); - const posts = t.table("posts", { + defineSchema((builder) => { + const users = builder.table("users", { id: id(), posts: text() }); + const posts = builder.table("posts", { id: id(), - noteId: fk(() => notes.id), + userId: fk(() => users.id), }); - return { notes, posts }; + return { users, posts }; }), - ).toThrow(/Reverse relation "notes\.posts" collides with a column/); + ).toThrow(/Reverse relation .* collides with a column/); }); }); diff --git a/packages/appkit/src/database/schema-builder/tests/validators.test.ts b/packages/appkit/src/database/schema-builder/tests/validators.test.ts index 6fbe56d44..d42474bef 100644 --- a/packages/appkit/src/database/schema-builder/tests/validators.test.ts +++ b/packages/appkit/src/database/schema-builder/tests/validators.test.ts @@ -5,8 +5,6 @@ import { bigint, boolean, defineSchema, - deriveInsertSchema, - deriveUpdateSchema, enumColumn, id, integer, @@ -14,7 +12,9 @@ import { text, timestamp, uuid, + varchar, } from "../index"; +import { deriveInsertSchema, deriveUpdateSchema } from "../validators"; /** Read the per-field shape off a derived Zod object schema (test-only seam). */ function shapeOf(schema: ZodType): Record { @@ -41,6 +41,7 @@ const schema = defineSchema((t) => ({ types: t.table("types", { id: id(), tags: text().notNull(), + short: varchar(3).notNull(), count: integer().notNull(), big: bigint().notNull(), flag: boolean().notNull(), @@ -54,6 +55,7 @@ const schema = defineSchema((t) => ({ const users = schema.$tables.users; const accounts = schema.$tables.accounts; const types = schema.$tables.types; +const validUserInsert = { email: "a@b.com", secret: "server-only" }; describe("defineSchema — validator wiring", () => { it("stamps $insertSchema and $updateSchema on every table", () => { @@ -67,19 +69,27 @@ describe("defineSchema — validator wiring", () => { describe("deriveInsertSchema", () => { const insert = deriveInsertSchema(users); - it("omits private and server-generated columns", () => { + it("includes private fields and omits only server-generated columns", () => { expect(Object.keys(shapeOf(insert)).sort()).toEqual([ "createdAt", "email", "loginCount", "name", "role", + "secret", ]); }); it("keeps a required (notNull, no default) column required", () => { expect(insert.safeParse({}).success).toBe(false); - expect(insert.safeParse({ email: "a@b.com" }).success).toBe(true); + expect(insert.safeParse({ email: "a@b.com" }).success).toBe(false); + expect(insert.safeParse(validUserInsert).success).toBe(true); + }); + + it("rejects unknown fields instead of stripping them", () => { + expect( + insert.safeParse({ ...validUserInsert, unexpected: true }).success, + ).toBe(false); }); it("makes defaulted columns optional even when notNull", () => { @@ -105,7 +115,7 @@ describe("deriveInsertSchema", () => { }); describe("deriveUpdateSchema", () => { - it("omits the primary key (and private + server-generated)", () => { + it("includes private fields and omits primary-key and generated columns", () => { expect(Object.keys(shapeOf(deriveUpdateSchema(accounts)))).toEqual([ "label", ]); @@ -115,6 +125,7 @@ describe("deriveUpdateSchema", () => { "loginCount", "name", "role", + "secret", ]); }); @@ -125,6 +136,12 @@ describe("deriveUpdateSchema", () => { // `email` is notNull/no-default (required on insert) but optional on update. expect(shapeOf(update).email.safeParse(undefined).success).toBe(true); }); + + it("rejects unknown fields instead of stripping them", () => { + expect( + deriveUpdateSchema(users).safeParse({ unexpected: true }).success, + ).toBe(false); + }); }); describe("zodForColumn — engine-neutral kind mapping", () => { @@ -133,17 +150,21 @@ describe("zodForColumn — engine-neutral kind mapping", () => { it("maps string columns to z.string()", () => { expect(shape.tags.safeParse("x").success).toBe(true); expect(shape.tags.safeParse(5).success).toBe(false); + expect(shape.short.safeParse("abc").success).toBe(true); + expect(shape.short.safeParse("toolong").success).toBe(false); }); - it("maps number columns to z.number()", () => { + it("accepts only PostgreSQL int4 values for number columns", () => { expect(shape.count.safeParse(5).success).toBe(true); expect(shape.count.safeParse("5").success).toBe(false); + expect(shape.count.safeParse(1.5).success).toBe(false); + expect(shape.count.safeParse(2_147_483_648).success).toBe(false); }); - it("maps bigint columns to a bigint | number | string union", () => { + it("uses bigint as the canonical bigint runtime value", () => { expect(shape.big.safeParse(5n).success).toBe(true); - expect(shape.big.safeParse(5).success).toBe(true); - expect(shape.big.safeParse("5").success).toBe(true); + expect(shape.big.safeParse(5).success).toBe(false); + expect(shape.big.safeParse("5").success).toBe(false); expect(shape.big.safeParse(true).success).toBe(false); }); @@ -152,18 +173,26 @@ describe("zodForColumn — engine-neutral kind mapping", () => { expect(shape.flag.safeParse("true").success).toBe(false); }); - it("maps date columns to a date | string union", () => { - expect(shape.when.safeParse(new Date()).success).toBe(true); + it("uses ISO 8601 strings as canonical timestamp values", () => { expect(shape.when.safeParse("2020-01-01T00:00:00Z").success).toBe(true); + expect(shape.when.safeParse("2020-01-01T00:00:00").success).toBe(true); + expect(shape.when.safeParse(new Date()).success).toBe(false); + expect(shape.when.safeParse("not-a-timestamp").success).toBe(false); expect(shape.when.safeParse(5).success).toBe(false); }); - it("maps json columns to z.unknown() (accepts arbitrary values)", () => { + it("accepts JSON values and rejects non-JSON runtime objects", () => { expect(shape.doc.safeParse({ nested: [1, 2] }).success).toBe(true); + expect(shape.doc.safeParse(new Date()).success).toBe(false); + expect(shape.doc.safeParse({ nested: undefined }).success).toBe(false); + expect(shape.doc.safeParse(1n).success).toBe(false); }); - it("maps uuid columns to z.string() (no format constraint)", () => { - expect(shape.ref.safeParse("not-a-real-uuid").success).toBe(true); + it("accepts canonical UUID strings", () => { + expect( + shape.ref.safeParse("123e4567-e89b-12d3-a456-426614174000").success, + ).toBe(true); + expect(shape.ref.safeParse("not-a-real-uuid").success).toBe(false); expect(shape.ref.safeParse(123).success).toBe(false); }); diff --git a/packages/appkit/src/database/schema-builder/types.ts b/packages/appkit/src/database/schema-builder/types.ts index 82de3516a..38b993623 100644 --- a/packages/appkit/src/database/schema-builder/types.ts +++ b/packages/appkit/src/database/schema-builder/types.ts @@ -1,29 +1,72 @@ -import type { ColumnInfoKind, ReferentialAction } from "../contract"; +import type { FilterOperator } from "../contract"; -/** - * Opaque handles to the internal query-engine objects. - */ declare const ENGINE_TABLE: unique symbol; declare const ENGINE_COLUMN: unique symbol; +/** Opaque handles keep Drizzle types behind the schema/runtime boundary. */ export type EngineTable = { readonly [ENGINE_TABLE]: true }; export type EngineColumn = { readonly [ENGINE_COLUMN]: true }; -/** Internal description of a column's storage type */ +/** JavaScript value category exposed by a column, independent of its storage. */ +export type ColumnValueKind = + | "string" + | "number" + | "bigint" + | "boolean" + | "date" + | "json" + | "uuid" + | "enum" + | "unknown"; + +/** The filter subset shared by runtime translation and later typed surfaces. */ +export function filterOperatorsForKind( + kind: ColumnValueKind, +): readonly FilterOperator[] { + switch (kind) { + case "string": + return ["eq", "neq", "in", "like", "ilike"]; + case "number": + case "bigint": + case "date": + return ["eq", "neq", "in", "gt", "gte", "lt", "lte"]; + case "enum": + case "boolean": + case "uuid": + return ["eq", "neq", "in"]; + case "json": + case "unknown": + return []; + } +} + +/** PostgreSQL action applied when a referenced row changes or is deleted. */ +export type ReferentialAction = + | "cascade" + | "set null" + | "set default" + | "restrict" + | "no action"; + +/** DSL declaration kind, including identity shorthand and unresolved FKs. */ export type ColumnTypeSpec = - | { kind: "id" } - | { kind: "bigid" } - | { kind: "text" } - | { kind: "varchar"; length: number } - | { kind: "integer" } - | { kind: "bigint" } - | { kind: "boolean" } - | { kind: "uuid" } - | { kind: "timestamp"; withTimezone: boolean } - | { kind: "jsonb" } - | { kind: "enum"; enumName: string; values: readonly string[] } - | { kind: "fk" }; - -/** Concrete storage kind after FK mirroring */ + | { readonly kind: "id" } + | { readonly kind: "bigid" } + | { readonly kind: "text" } + | { readonly kind: "varchar"; readonly length: number } + | { readonly kind: "integer" } + | { readonly kind: "bigint" } + | { readonly kind: "boolean" } + | { readonly kind: "uuid" } + | { readonly kind: "timestamp"; readonly withTimezone: boolean } + | { readonly kind: "jsonb" } + | { + readonly kind: "enum"; + readonly enumName: string; + readonly values: readonly string[]; + } + | { readonly kind: "fk" }; + +/** Resolved PostgreSQL storage used to construct the engine column. */ export type StorageKind = | "id" | "bigid" @@ -37,7 +80,6 @@ export type StorageKind = | "jsonb" | "enum"; -/** A deferred or direct reference to a target column */ export interface ColumnRef { readonly __isColumnRef: true; readonly tableName: string; @@ -46,22 +88,25 @@ export interface ColumnRef { export type FkRef = ColumnRef | (() => ColumnRef); -/** Mutable working metadata; frozen into {@link ColumnMeta} at the end of the build. */ +export interface ResolvedForeignKey { + readonly targetTable: string; + readonly targetColumn: string; + readonly onDelete?: ReferentialAction; + readonly onUpdate?: ReferentialAction; +} + +/** Mutable declaration state. It is cloned per table and never published. */ export interface MutableColumnMeta { name: string; columnName: string; - kind: ColumnInfoKind; - pgType: string; + kind: ColumnValueKind; storageKind: StorageKind; notNull: boolean; primaryKey: boolean; unique: boolean; isPrivate: boolean; - /** RLS owner column (`.owner()`) — its email value is compared to current_user_email() by the policy. */ - isOwner: boolean; serverGenerated: boolean; hasDefault: boolean; - defaultExpr?: string; defaultValue?: string | number | boolean; defaultNow?: boolean; defaultRandom?: boolean; @@ -72,61 +117,53 @@ export interface MutableColumnMeta { fkRef?: FkRef; onDelete?: ReferentialAction; onUpdate?: ReferentialAction; - fk?: { - targetTable: string; - targetColumn: string; - onDelete?: ReferentialAction; - onUpdate?: ReferentialAction; - }; - /** @internal opaque engine column handle */ + fk?: ResolvedForeignKey; engineColumn?: EngineColumn; } -/** Resolved, read-only column metadata exposed on a built table. */ -export type ColumnMeta = Readonly; +/** Immutable column metadata published by a finalized schema. */ +export type ColumnMeta = Readonly< + Omit & { + readonly enumValues?: readonly string[]; + readonly fk?: Readonly; + readonly engineColumn: EngineColumn; + } +>; -/** - * A named, directed relation resolved from FK edges. `toOne` is the forward - * many-to-one; `toMany` is the inferred reverse one-to-many. - */ export interface ResolvedRelation { - name: string; - cardinality: "toOne" | "toMany"; - localColumn: string; - targetTable: string; - targetColumn: string; - inferred: boolean; + readonly name: string; + readonly cardinality: "toOne" | "toMany"; + readonly localColumn: string; + readonly targetTable: string; + readonly targetColumn: string; + readonly inferred: boolean; } -/** A built table: the engine table handle plus AppKit metadata under `$`-keys. */ export interface AppKitTable { - $name: string; - $schemaName: string; - $columns: Record; - /** @internal opaque engine table handle */ - $engine: EngineTable; - $relations: ResolvedRelation[]; - /** @internal insert schema */ - $insertSchema?: unknown; - /** @internal update schema */ - $updateSchema?: unknown; + readonly $name: string; + readonly $schemaName: string; + readonly $columns: Readonly>; + readonly $engine: EngineTable; + readonly $relations: readonly ResolvedRelation[]; + /** @internal insert schema retained from the current-main foundation. */ + readonly $insertSchema: unknown; + /** @internal update schema retained from the current-main foundation. */ + readonly $updateSchema: unknown; } -/** The object returned by `ctx.table(...)`: column refs + (after build) the table metadata. */ +/** A declaration handle gains finalized table metadata only at publication. */ export type TableHandle> = AppKitTable & { readonly [K in keyof C]: ColumnRef; }; export interface DefineSchemaOptions { - /** Postgres schema name; canonical default is `"public"`. */ - schemaName?: string; + readonly schemaName?: string; } export interface Schema { - $schemaName: string; - $tables: Record; - /** @internal opaque engine table handles */ - $engine: Record; + readonly $schemaName: string; + readonly $tables: Readonly>; + readonly $engine: Readonly>; } export class SchemaBuildError extends Error { diff --git a/packages/appkit/src/database/schema-builder/validators.ts b/packages/appkit/src/database/schema-builder/validators.ts index 9efe9f206..f273b392f 100644 --- a/packages/appkit/src/database/schema-builder/validators.ts +++ b/packages/appkit/src/database/schema-builder/validators.ts @@ -2,55 +2,70 @@ import type { ZodType } from "zod"; import { z } from "zod"; import type { AppKitTable, ColumnMeta } from "./types"; -/** Map an engine-neutral ColumnMeta.kind to a Zod base type */ -function zodForColumn(meta: ColumnMeta): ZodType { +const PG_INTEGER_MIN = -2_147_483_648; +const PG_INTEGER_MAX = 2_147_483_647; +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Validate the canonical value shape accepted by the configured Drizzle column. */ +export function columnValueSchema( + meta: Pick< + ColumnMeta, + "kind" | "storageKind" | "varcharLength" | "enumValues" + >, +): ZodType { switch (meta.kind) { - case "string": - case "uuid": - return z.string(); + case "string": { + const value = z.string(); + return meta.storageKind === "varchar" + ? value.max(meta.varcharLength ?? 255) + : value; + } case "number": - return z.number(); + return z.number().int().min(PG_INTEGER_MIN).max(PG_INTEGER_MAX); case "bigint": - return z.union([z.bigint(), z.number(), z.string()]); + return z.bigint(); case "boolean": return z.boolean(); case "date": - return z.union([z.date(), z.string()]); + return z.iso.datetime({ local: true, offset: true }); case "json": - return z.unknown(); + return z.json(); + case "uuid": + return z.string().regex(UUID_RE); case "enum": return meta.enumValues && meta.enumValues.length > 0 ? z.enum([...meta.enumValues] as [string, ...string[]]) - : z.string(); + : z.never(); default: - return z.unknown(); + return z.never(); } } -/** Insert payload: omit private + server-generated; */ +/** Trusted insert payload: private fields are allowed; identities are not. */ export function deriveInsertSchema(tables: AppKitTable): ZodType { const shape: Record = {}; for (const meta of Object.values(tables.$columns)) { - if (meta.isPrivate || meta.serverGenerated) continue; - let field = zodForColumn(meta); + if (meta.serverGenerated) continue; + let field = columnValueSchema(meta); if (!meta.notNull) field = field.nullable(); if (!meta.notNull || meta.hasDefault) field = field.optional(); shape[meta.columnName] = field; } - return z.object(shape); + return z.strictObject(shape); } -/** Update payload: omit PK + private + server-generated; every field optional (partial). */ +/** Trusted update payload: private fields are allowed; keys and identities are not. */ export function deriveUpdateSchema(tables: AppKitTable): ZodType { const shape: Record = {}; for (const meta of Object.values(tables.$columns)) { - if (meta.isPrivate || meta.serverGenerated || meta.primaryKey) continue; - let field = zodForColumn(meta); + if (meta.serverGenerated || meta.primaryKey) continue; + let field = columnValueSchema(meta); if (!meta.notNull) field = field.nullable(); shape[meta.columnName] = field.optional(); } - return z.object(shape); + return z.strictObject(shape); } From 441393c2fa0942565a05973df2994b7f78daf5db Mon Sep 17 00:00:00 2001 From: ditadi Date: Thu, 6 Aug 2026 20:40:28 +0100 Subject: [PATCH 2/3] feat(appkit): add service-principal typed DatabasePlugin API Expose the hardened runtime as one service-principal plugin with typed entity clients, transactions, tagged SQL, and schema-derived declarations in the existing typegen flow. Driver, setup, and unclassified failures are logged with their original cause before the safe error replaces them, so operators can diagnose what the client never sees. Signed-off-by: ditadi --- docs/docs/api/appkit/Function.bigid.md | 9 + docs/docs/api/appkit/Function.bigint.md | 9 + docs/docs/api/appkit/Function.boolean.md | 9 + docs/docs/api/appkit/Function.database.md | 51 ++++ docs/docs/api/appkit/Function.defineSchema.md | 16 + docs/docs/api/appkit/Function.enumColumn.md | 16 + docs/docs/api/appkit/Function.fk.md | 17 ++ docs/docs/api/appkit/Function.id.md | 9 + docs/docs/api/appkit/Function.integer.md | 9 + docs/docs/api/appkit/Function.jsonb.md | 9 + docs/docs/api/appkit/Function.text.md | 9 + docs/docs/api/appkit/Function.timestamp.md | 18 ++ docs/docs/api/appkit/Function.uuid.md | 9 + docs/docs/api/appkit/Function.varchar.md | 15 + .../api/appkit/Interface.DatabaseRegistry.md | 4 + docs/docs/api/appkit/Interface.Schema.md | 25 ++ .../api/appkit/TypeAlias.DatabaseExports.md | 33 ++ .../api/appkit/TypeAlias.IDatabaseConfig.md | 23 ++ docs/docs/api/appkit/index.md | 18 ++ docs/docs/api/appkit/typedoc-sidebar.ts | 90 ++++++ packages/appkit/package.json | 1 + packages/appkit/src/beta.ts | 18 ++ .../appkit/src/database/contract/registry.ts | 8 +- .../database/contract/tests/registry.test.ts | 6 +- packages/appkit/src/database/contract/wire.ts | 6 + packages/appkit/src/database/errors.ts | 92 ++++++ .../appkit/src/database/runtime/data-path.ts | 45 +-- .../runtime/engine/drizzle-data-path.ts | 131 ++++++-- .../src/database/runtime/engine/translate.ts | 40 +-- packages/appkit/src/database/runtime/index.ts | 27 +- .../runtime/tests/data-path-contract.test.ts | 27 +- .../runtime/tests/drizzle-data-path.test.ts | 242 +++++++++++++-- .../database/runtime/tests/translate.test.ts | 54 ++-- .../database/schema-builder/define-schema.ts | 31 +- .../database/schema-builder/engine/tables.ts | 25 ++ .../tests/define-schema.test.ts | 55 ++++ packages/appkit/src/index.ts | 1 + .../src/plugins/beta-exports.generated.ts | 1 + .../appkit/src/plugins/database/database.ts | 95 ++++++ .../appkit/src/plugins/database/defaults.ts | 12 + .../src/plugins/database/entity-client.ts | 246 +++++++++++++++ .../src/plugins/database/entity-types.ts | 215 +++++++++++++ packages/appkit/src/plugins/database/index.ts | 3 + .../appkit/src/plugins/database/lifecycle.ts | 149 +++++++++ .../appkit/src/plugins/database/manifest.json | 83 +++++ .../database/tests/entity-client.test.ts | 253 +++++++++++++++ .../database/tests/entity-types.test.ts | 255 ++++++++++++++++ .../plugins/database/tests/lifecycle.test.ts | 252 +++++++++++++++ .../src/plugins/database/tests/plugin.test.ts | 158 ++++++++++ packages/appkit/src/plugins/database/types.ts | 6 + .../src/type-generator/database/generate.ts | 145 +++++++++ .../src/type-generator/database/index.ts | 5 + .../database/tests/generate.test.ts | 288 ++++++++++++++++++ .../type-generator/database/walk-schema.ts | 127 ++++++++ packages/appkit/src/type-generator/index.ts | 5 + .../type-generator/tests/vite-plugin.test.ts | 170 ++++++++++- .../appkit/src/type-generator/vite-plugin.ts | 90 +++++- packages/appkit/tsdown.config.ts | 3 +- .../src/cli/commands/generate-types.test.ts | 21 ++ .../shared/src/cli/commands/generate-types.ts | 28 +- .../src/cli/commands/type-generator.d.ts | 9 + pnpm-lock.yaml | 3 + template/appkit.plugins.json | 94 ++++++ 63 files changed, 3757 insertions(+), 166 deletions(-) create mode 100644 docs/docs/api/appkit/Function.bigid.md 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.database.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.jsonb.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.DatabaseRegistry.md create mode 100644 docs/docs/api/appkit/Interface.Schema.md create mode 100644 docs/docs/api/appkit/TypeAlias.DatabaseExports.md create mode 100644 docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md create mode 100644 packages/appkit/src/database/errors.ts 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/entity-client.ts create mode 100644 packages/appkit/src/plugins/database/entity-types.ts create mode 100644 packages/appkit/src/plugins/database/index.ts create mode 100644 packages/appkit/src/plugins/database/lifecycle.ts create mode 100644 packages/appkit/src/plugins/database/manifest.json create mode 100644 packages/appkit/src/plugins/database/tests/entity-client.test.ts create mode 100644 packages/appkit/src/plugins/database/tests/entity-types.test.ts create mode 100644 packages/appkit/src/plugins/database/tests/lifecycle.test.ts create mode 100644 packages/appkit/src/plugins/database/tests/plugin.test.ts create mode 100644 packages/appkit/src/plugins/database/types.ts create mode 100644 packages/appkit/src/type-generator/database/generate.ts create mode 100644 packages/appkit/src/type-generator/database/index.ts create mode 100644 packages/appkit/src/type-generator/database/tests/generate.test.ts create mode 100644 packages/appkit/src/type-generator/database/walk-schema.ts diff --git a/docs/docs/api/appkit/Function.bigid.md b/docs/docs/api/appkit/Function.bigid.md new file mode 100644 index 000000000..b92962844 --- /dev/null +++ b/docs/docs/api/appkit/Function.bigid.md @@ -0,0 +1,9 @@ +# Function: bigid() + +```ts +function bigid(): ColumnBuilder; +``` + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Function.bigint.md b/docs/docs/api/appkit/Function.bigint.md new file mode 100644 index 000000000..565798206 --- /dev/null +++ b/docs/docs/api/appkit/Function.bigint.md @@ -0,0 +1,9 @@ +# Function: bigint() + +```ts +function bigint(): ColumnBuilder; +``` + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Function.boolean.md b/docs/docs/api/appkit/Function.boolean.md new file mode 100644 index 000000000..88d85ac9a --- /dev/null +++ b/docs/docs/api/appkit/Function.boolean.md @@ -0,0 +1,9 @@ +# Function: boolean() + +```ts +function boolean(): ColumnBuilder; +``` + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Function.database.md b/docs/docs/api/appkit/Function.database.md new file mode 100644 index 000000000..8e0a3ace6 --- /dev/null +++ b/docs/docs/api/appkit/Function.database.md @@ -0,0 +1,51 @@ +# Function: database() + +```ts +function database(config: IDatabaseConfig): { + config: IDatabaseConfig; + name: "database"; + plugin: PluginConstructor>; +}; +``` + +Create a typed database plugin registration for a finalized schema. + +## Type Parameters + +| Type Parameter | +| ------ | +| `TSchema` *extends* [`Schema`](Interface.Schema.md) | + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `config` | [`IDatabaseConfig`](TypeAlias.IDatabaseConfig.md)\<`TSchema`\> | + +## Returns + +```ts +{ + config: IDatabaseConfig; + name: "database"; + plugin: PluginConstructor>; +} +``` + +### config + +```ts +config: IDatabaseConfig; +``` + +### name + +```ts +name: "database"; +``` + +### plugin + +```ts +plugin: PluginConstructor>; +``` diff --git a/docs/docs/api/appkit/Function.defineSchema.md b/docs/docs/api/appkit/Function.defineSchema.md new file mode 100644 index 000000000..05b828ad4 --- /dev/null +++ b/docs/docs/api/appkit/Function.defineSchema.md @@ -0,0 +1,16 @@ +# Function: defineSchema() + +```ts +function defineSchema(builder: (context: SchemaBuilderContext) => Record, options?: DefineSchemaOptions): Schema; +``` + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `builder` | (`context`: `SchemaBuilderContext`) => `Record`\<`string`, `AppKitTable`\> | +| `options?` | `DefineSchemaOptions` | + +## Returns + +[`Schema`](Interface.Schema.md) diff --git a/docs/docs/api/appkit/Function.enumColumn.md b/docs/docs/api/appkit/Function.enumColumn.md new file mode 100644 index 000000000..134b7025b --- /dev/null +++ b/docs/docs/api/appkit/Function.enumColumn.md @@ -0,0 +1,16 @@ +# Function: enumColumn() + +```ts +function enumColumn(name: string, values: readonly string[]): ColumnBuilder; +``` + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `name` | `string` | +| `values` | readonly `string`[] | + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Function.fk.md b/docs/docs/api/appkit/Function.fk.md new file mode 100644 index 000000000..0f2f28c50 --- /dev/null +++ b/docs/docs/api/appkit/Function.fk.md @@ -0,0 +1,17 @@ +# Function: fk() + +```ts +function fk(ref: FkRef): ColumnBuilder; +``` + +Declare foreign-key to another column. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `ref` | `FkRef` | + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Function.id.md b/docs/docs/api/appkit/Function.id.md new file mode 100644 index 000000000..90b5b5b73 --- /dev/null +++ b/docs/docs/api/appkit/Function.id.md @@ -0,0 +1,9 @@ +# Function: id() + +```ts +function id(): ColumnBuilder; +``` + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Function.integer.md b/docs/docs/api/appkit/Function.integer.md new file mode 100644 index 000000000..3c26a8e52 --- /dev/null +++ b/docs/docs/api/appkit/Function.integer.md @@ -0,0 +1,9 @@ +# Function: integer() + +```ts +function integer(): ColumnBuilder; +``` + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Function.jsonb.md b/docs/docs/api/appkit/Function.jsonb.md new file mode 100644 index 000000000..88ef41a5f --- /dev/null +++ b/docs/docs/api/appkit/Function.jsonb.md @@ -0,0 +1,9 @@ +# Function: jsonb() + +```ts +function jsonb(): ColumnBuilder; +``` + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Function.text.md b/docs/docs/api/appkit/Function.text.md new file mode 100644 index 000000000..f6db879b6 --- /dev/null +++ b/docs/docs/api/appkit/Function.text.md @@ -0,0 +1,9 @@ +# Function: text() + +```ts +function text(): ColumnBuilder; +``` + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Function.timestamp.md b/docs/docs/api/appkit/Function.timestamp.md new file mode 100644 index 000000000..d69cc1d8d --- /dev/null +++ b/docs/docs/api/appkit/Function.timestamp.md @@ -0,0 +1,18 @@ +# Function: timestamp() + +```ts +function timestamp(opts?: { + withTimezone?: boolean; +}): ColumnBuilder; +``` + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `opts?` | \{ `withTimezone?`: `boolean`; \} | +| `opts.withTimezone?` | `boolean` | + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Function.uuid.md b/docs/docs/api/appkit/Function.uuid.md new file mode 100644 index 000000000..d873581ba --- /dev/null +++ b/docs/docs/api/appkit/Function.uuid.md @@ -0,0 +1,9 @@ +# Function: uuid() + +```ts +function uuid(): ColumnBuilder; +``` + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Function.varchar.md b/docs/docs/api/appkit/Function.varchar.md new file mode 100644 index 000000000..56db81c58 --- /dev/null +++ b/docs/docs/api/appkit/Function.varchar.md @@ -0,0 +1,15 @@ +# Function: varchar() + +```ts +function varchar(length: number): ColumnBuilder; +``` + +## Parameters + +| Parameter | Type | Default value | +| ------ | ------ | ------ | +| `length` | `number` | `255` | + +## Returns + +`ColumnBuilder` diff --git a/docs/docs/api/appkit/Interface.DatabaseRegistry.md b/docs/docs/api/appkit/Interface.DatabaseRegistry.md new file mode 100644 index 000000000..27f8db88f --- /dev/null +++ b/docs/docs/api/appkit/Interface.DatabaseRegistry.md @@ -0,0 +1,4 @@ +# Interface: DatabaseRegistry + +CANONICAL augmentation target. Empty by default; the generated `database.d.ts` +augments it via `declare module "@databricks/appkit" { interface DatabaseRegistry { ... } }`. diff --git a/docs/docs/api/appkit/Interface.Schema.md b/docs/docs/api/appkit/Interface.Schema.md new file mode 100644 index 000000000..f94b39339 --- /dev/null +++ b/docs/docs/api/appkit/Interface.Schema.md @@ -0,0 +1,25 @@ +# Interface: Schema + +## Properties + +### $engine + +```ts +readonly $engine: Readonly>; +``` + +*** + +### $schemaName + +```ts +readonly $schemaName: string; +``` + +*** + +### $tables + +```ts +readonly $tables: Readonly>; +``` diff --git a/docs/docs/api/appkit/TypeAlias.DatabaseExports.md b/docs/docs/api/appkit/TypeAlias.DatabaseExports.md new file mode 100644 index 000000000..c076dab0a --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.DatabaseExports.md @@ -0,0 +1,33 @@ +# Type Alias: DatabaseExports + +```ts +type DatabaseExports = TransactionClient & { + transaction: Promise; +}; +``` + +Typed database API published by the plugin. + +## Type Declaration + +### transaction() + +```ts +transaction(callback: (tx: TransactionClient) => Promise): Promise; +``` + +#### Type Parameters + +| Type Parameter | +| ------ | +| `T` | + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `callback` | (`tx`: `TransactionClient`) => `Promise`\<`T`\> | + +#### Returns + +`Promise`\<`T`\> diff --git a/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md b/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md new file mode 100644 index 000000000..88bc807c5 --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md @@ -0,0 +1,23 @@ +# Type Alias: IDatabaseConfig\ + +```ts +type IDatabaseConfig = { + schema: TSchema; +}; +``` + +Configuration for one schema-bound DatabasePlugin instance. + +## Type Parameters + +| Type Parameter | +| ------ | +| `TSchema` *extends* [`Schema`](Interface.Schema.md) | + +## Properties + +### schema + +```ts +readonly schema: TSchema; +``` diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index f39a52db2..6269a2dbe 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -44,6 +44,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [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. | | [DatabaseCredential](Interface.DatabaseCredential.md) | Database credentials with OAuth token for Postgres connection | +| [DatabaseRegistry](Interface.DatabaseRegistry.md) | CANONICAL augmentation target. Empty by default; the generated `database.d.ts` augments it via `declare module "@databricks/appkit" { interface DatabaseRegistry { ... } }`. | | [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. | @@ -74,6 +75,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [ResourceRequirement](Interface.ResourceRequirement.md) | Declares a resource requirement for a plugin. Can be defined statically in a manifest or dynamically via getResourceRequirements(). | | [RunAgentInput](Interface.RunAgentInput.md) | - | | [RunAgentResult](Interface.RunAgentResult.md) | - | +| [Schema](Interface.Schema.md) | - | | [SearchRequest](Interface.SearchRequest.md) | - | | [SearchResponse](Interface.SearchResponse.md) | - | | [SearchResult](Interface.SearchResult.md) | - | @@ -106,11 +108,13 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [AgentToolsFn](TypeAlias.AgentToolsFn.md) | Function form of `AgentDefinition.tools`. Receives the typed [Plugins](TypeAlias.Plugins.md) map and returns a tool record. Invoked exactly once at setup (or once per `runAgent` call in standalone mode); the result is cached as the agent's resolved tool record. | | [BaseSystemPromptOption](TypeAlias.BaseSystemPromptOption.md) | - | | [ConfigSchema](TypeAlias.ConfigSchema.md) | Configuration schema definition for plugin config. Re-exported from the standard JSON Schema Draft 7 types. | +| [DatabaseExports](TypeAlias.DatabaseExports.md) | Typed database API published by the plugin. | | [ExecutionResult](TypeAlias.ExecutionResult.md) | Discriminated union for plugin execution results. | | [FileAction](TypeAlias.FileAction.md) | Every action the files plugin can perform. | | [FilePolicy](TypeAlias.FilePolicy.md) | A policy function that decides whether `user` may perform `action` on `resource`. Return `true` to allow, `false` to deny. | | [HostedTool](TypeAlias.HostedTool.md) | - | | [IAppRouter](TypeAlias.IAppRouter.md) | Express router type for plugin route registration | +| [IDatabaseConfig](TypeAlias.IDatabaseConfig.md) | Configuration for one schema-bound DatabasePlugin instance. | | [JobsExport](TypeAlias.JobsExport.md) | Public API shape of the jobs plugin. Callable to select a job by key. | | [PluginData](TypeAlias.PluginData.md) | Tuple of plugin class, config, and name. Created by `toPlugin()` and passed to `createApp()`. | | [Plugins](TypeAlias.Plugins.md) | Plugin map passed to the function form of [AgentDefinition.tools](Interface.AgentDefinition.md#tools). Each entry exposes a `.toolkit(opts?)` method that returns a record of [ToolkitEntry](Interface.ToolkitEntry.md) markers ready to be spread into a tool record. | @@ -142,15 +146,22 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [agentIdFromMarkdownPath](Function.agentIdFromMarkdownPath.md) | Derives the logical agent id from a markdown path. When the file is named `agent.md`, the id is the parent directory name (folder-based layout); otherwise the id is the file stem (e.g. legacy single-file paths). | | [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. | +| [bigid](Function.bigid.md) | - | +| [bigint](Function.bigint.md) | - | +| [boolean](Function.boolean.md) | - | | [createAgent](Function.createAgent.md) | Pure factory for agent definitions. Returns the passed-in definition after cycle-detecting the sub-agent graph. Accepts the full `AgentDefinition` shape and is safe to call at module top-level. | | [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. | | [createLakebasePoolManager](Function.createLakebasePoolManager.md) | Create a pool manager that maintains per-key Lakebase connection pools. | | [createWorkspaceClient](Function.createWorkspaceClient.md) | Construct an AppKit workspace client. | +| [database](Function.database.md) | Create a typed database plugin registration for a finalized schema. | +| [defineSchema](Function.defineSchema.md) | - | | [defineTool](Function.defineTool.md) | Defines a single tool entry for a plugin's internal registry. | +| [enumColumn](Function.enumColumn.md) | - | | [executeFromRegistry](Function.executeFromRegistry.md) | Validates tool-call arguments against the entry's schema and invokes its handler. On validation failure, returns an LLM-friendly error string (matching the behavior of `tool()`) rather than throwing, so the model can self-correct on its next turn. | | [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) | Declare foreign-key to another column. | | [fromSupervisorApi](Function.fromSupervisorApi.md) | Creates an [AgentAdapter](Interface.AgentAdapter.md) backed by the Databricks AI Gateway Responses API (`/ai-gateway/mlflow/v1/responses`). | | [functionToolToDefinition](Function.functionToolToDefinition.md) | - | | [generateDatabaseCredential](Function.generateDatabaseCredential.md) | Generate OAuth credentials for Postgres database connection using the proper Postgres API. | @@ -161,16 +172,23 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [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) | - | +| [integer](Function.integer.md) | - | | [isFunctionTool](Function.isFunctionTool.md) | - | | [isHostedTool](Function.isHostedTool.md) | - | | [isSQLTypeMarker](Function.isSQLTypeMarker.md) | Type guard to check if a value is a SQL type marker | | [isSupervisorTool](Function.isSupervisorTool.md) | Type guard for [HostedSupervisorTool](Interface.HostedSupervisorTool.md). Used by the agents plugin (`buildToolIndex`) and standalone `runAgent` (`classifyTool`) to route supervisor-hosted tools to the extensions payload rather than the adapter's `tools` array. | | [isToolkitEntry](Function.isToolkitEntry.md) | Type guard for `ToolkitEntry` — used by the agents plugin to differentiate toolkit references from inline tools in a mixed `tools` record. | +| [jsonb](Function.jsonb.md) | - | | [loadAgentFromFile](Function.loadAgentFromFile.md) | Loads a single markdown agent file and resolves its frontmatter against registered plugin toolkits + ambient tool library. | | [loadAgentsFromDir](Function.loadAgentsFromDir.md) | Scans a directory for one subdirectory per agent, each containing `agent.md` (frontmatter + body). Produces an `AgentDefinition` record keyed by agent id (folder name). Throws on frontmatter errors or unresolved references. Returns an empty map if the directory does not exist. | | [mcpServer](Function.mcpServer.md) | Factory for declaring a custom MCP server tool. | | [parseTextToolCalls](Function.parseTextToolCalls.md) | Parses text-based tool calls from model output. | | [resolveHostedTools](Function.resolveHostedTools.md) | - | | [runAgent](Function.runAgent.md) | Standalone agent execution without `createApp`. Resolves the adapter, binds inline tools, and drives the adapter's `run()` loop to completion. | +| [text](Function.text.md) | - | +| [timestamp](Function.timestamp.md) | - | | [tool](Function.tool.md) | Factory for defining function tools with Zod schemas. | | [toolsFromRegistry](Function.toolsFromRegistry.md) | Produces the `AgentToolDefinition[]` a ToolProvider exposes to the LLM, deriving `parameters` JSON Schema from each entry's Zod schema. | +| [uuid](Function.uuid.md) | - | +| [varchar](Function.varchar.md) | - | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index 18a5333b1..3eedd8ad8 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -152,6 +152,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.DatabaseCredential", label: "DatabaseCredential" }, + { + type: "doc", + id: "api/appkit/Interface.DatabaseRegistry", + label: "DatabaseRegistry" + }, { type: "doc", id: "api/appkit/Interface.EndpointConfig", @@ -302,6 +307,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.RunAgentResult", label: "RunAgentResult" }, + { + type: "doc", + id: "api/appkit/Interface.Schema", + label: "Schema" + }, { type: "doc", id: "api/appkit/Interface.SearchRequest", @@ -443,6 +453,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/TypeAlias.ConfigSchema", label: "ConfigSchema" }, + { + type: "doc", + id: "api/appkit/TypeAlias.DatabaseExports", + label: "DatabaseExports" + }, { type: "doc", id: "api/appkit/TypeAlias.ExecutionResult", @@ -468,6 +483,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/TypeAlias.IAppRouter", label: "IAppRouter" }, + { + type: "doc", + id: "api/appkit/TypeAlias.IDatabaseConfig", + label: "IDatabaseConfig" + }, { type: "doc", id: "api/appkit/TypeAlias.JobsExport", @@ -585,6 +605,21 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.appKitTypesPlugin", label: "appKitTypesPlugin" }, + { + type: "doc", + id: "api/appkit/Function.bigid", + label: "bigid" + }, + { + type: "doc", + id: "api/appkit/Function.bigint", + label: "bigint" + }, + { + type: "doc", + id: "api/appkit/Function.boolean", + label: "boolean" + }, { type: "doc", id: "api/appkit/Function.createAgent", @@ -610,11 +645,26 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.createWorkspaceClient", label: "createWorkspaceClient" }, + { + type: "doc", + id: "api/appkit/Function.database", + label: "database" + }, + { + type: "doc", + id: "api/appkit/Function.defineSchema", + label: "defineSchema" + }, { type: "doc", id: "api/appkit/Function.defineTool", label: "defineTool" }, + { + type: "doc", + id: "api/appkit/Function.enumColumn", + label: "enumColumn" + }, { type: "doc", id: "api/appkit/Function.executeFromRegistry", @@ -630,6 +680,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.fromSupervisorApi", @@ -680,6 +735,16 @@ 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.isFunctionTool", @@ -705,6 +770,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.isToolkitEntry", label: "isToolkitEntry" }, + { + type: "doc", + id: "api/appkit/Function.jsonb", + label: "jsonb" + }, { type: "doc", id: "api/appkit/Function.loadAgentFromFile", @@ -735,6 +805,16 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.runAgent", label: "runAgent" }, + { + type: "doc", + id: "api/appkit/Function.text", + label: "text" + }, + { + type: "doc", + id: "api/appkit/Function.timestamp", + label: "timestamp" + }, { type: "doc", id: "api/appkit/Function.tool", @@ -744,6 +824,16 @@ const typedocSidebar: SidebarsConfig = { type: "doc", id: "api/appkit/Function.toolsFromRegistry", label: "toolsFromRegistry" + }, + { + type: "doc", + id: "api/appkit/Function.uuid", + label: "uuid" + }, + { + type: "doc", + id: "api/appkit/Function.varchar", + label: "varchar" } ] } diff --git a/packages/appkit/package.json b/packages/appkit/package.json index ac841184c..dcf7b9614 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -83,6 +83,7 @@ "drizzle-orm": "0.45.1", "express": "4.22.2", "get-port": "7.2.0", + "jiti": "2.6.1", "js-yaml": "4.2.0", "magic-string": "0.30.21", "obug": "2.1.1", diff --git a/packages/appkit/src/beta.ts b/packages/appkit/src/beta.ts index d94b4e0d4..4de7ba79c 100644 --- a/packages/appkit/src/beta.ts +++ b/packages/appkit/src/beta.ts @@ -66,6 +66,23 @@ export { toolsFromRegistry, } from "./core/agent/tools"; +export type { Schema } from "./database/schema-builder"; +export { + bigid, + bigint, + boolean, + defineSchema, + enumColumn, + fk, + id, + integer, + jsonb, + text, + timestamp, + uuid, + varchar, +} from "./database/schema-builder"; + // Agent types export type { AgentDefinition, @@ -101,3 +118,4 @@ export type { SearchResult, } from "./plugins/ai-search/types"; export * from "./plugins/beta-exports.generated"; +export type { DatabaseExports, IDatabaseConfig } from "./plugins/database"; diff --git a/packages/appkit/src/database/contract/registry.ts b/packages/appkit/src/database/contract/registry.ts index de42f6697..fc2734551 100644 --- a/packages/appkit/src/database/contract/registry.ts +++ b/packages/appkit/src/database/contract/registry.ts @@ -2,14 +2,18 @@ export interface DatabaseRegistryEntry { /** Full server-side row (includes private columns). */ row: Record; - /** Accepted insert payload (private + server-generated columns omitted). */ + /** Default private-safe row returned by collection reads. */ + publicRow: Record; + /** Trusted insert payload (includes private fields; omits generated columns). */ insert: Record; - /** Accepted update payload (PK + private + server-generated omitted, all optional). */ + /** Trusted update payload (includes private fields; omits PK/generated columns). */ update: Record; /** Per-column filter operators usable in `where`. */ filters: Record; /** Relations that can be passed to `include`. */ includes: Record; + /** Literal capability used to omit keyed methods from keyless entities. */ + hasPrimaryKey: boolean; } /** diff --git a/packages/appkit/src/database/contract/tests/registry.test.ts b/packages/appkit/src/database/contract/tests/registry.test.ts index 68b0e5904..37daa1b90 100644 --- a/packages/appkit/src/database/contract/tests/registry.test.ts +++ b/packages/appkit/src/database/contract/tests/registry.test.ts @@ -44,11 +44,15 @@ describe("RegisteredEntity (declaration-merging behaviour)", () => { }); describe("DatabaseRegistryEntry shape", () => { - it("exposes the five generated facets as records", () => { + it("exposes all generated entity facets and key capability", () => { expectTypeOf().toHaveProperty("row"); + expectTypeOf().toHaveProperty("publicRow"); expectTypeOf().toHaveProperty("insert"); expectTypeOf().toHaveProperty("update"); expectTypeOf().toHaveProperty("filters"); expectTypeOf().toHaveProperty("includes"); + expectTypeOf< + DatabaseRegistryEntry["hasPrimaryKey"] + >().toEqualTypeOf(); }); }); diff --git a/packages/appkit/src/database/contract/wire.ts b/packages/appkit/src/database/contract/wire.ts index 5dd108049..0d8583023 100644 --- a/packages/appkit/src/database/contract/wire.ts +++ b/packages/appkit/src/database/contract/wire.ts @@ -6,6 +6,12 @@ export const MAX_LIMIT = 500; export const DEFAULT_LIMIT = 50; /** Max number of relations resolvable in a single `.include()`. */ export const MAX_INCLUDES = 10; + +/** Scalar values accepted by primary-key operations. */ +export type IdValue = string | number | bigint; +/** Ordering accepted by typed clients and the runtime adapter. */ +export type OrderDirection = "asc" | "desc"; + /** Filter operators usable in the runtime WHERE translator and the `where` spec type. */ export const FILTER_OPERATORS = Object.freeze([ "eq", diff --git a/packages/appkit/src/database/errors.ts b/packages/appkit/src/database/errors.ts new file mode 100644 index 000000000..10b162256 --- /dev/null +++ b/packages/appkit/src/database/errors.ts @@ -0,0 +1,92 @@ +import { AppKitError } from "../errors"; +import { createLogger } from "../logging/logger"; + +const logger = createLogger("database"); + +export type DatabaseErrorCategory = + | "INVALID_REQUEST" + | "CONFLICT" + | "FORBIDDEN" + | "INTERNAL" + | "SETUP_FAILED"; + +type DatabaseErrorPhase = + | "setup" + | "shutdown" + | "read" + | "write" + | "transaction" + | "runtime"; + +const definitions: Record< + DatabaseErrorCategory, + { readonly message: string; readonly statusCode: number } +> = { + INVALID_REQUEST: { message: "Invalid database request", statusCode: 400 }, + CONFLICT: { message: "Database conflict", statusCode: 409 }, + FORBIDDEN: { message: "Database operation forbidden", statusCode: 403 }, + INTERNAL: { message: "Database operation failed", statusCode: 500 }, + SETUP_FAILED: { message: "Database setup failed", statusCode: 500 }, +}; + +const categoryByStatus: Readonly> = { + 400: "INVALID_REQUEST", + 403: "FORBIDDEN", + 409: "CONFLICT", +}; + +/** AppKit-facing database failure with stable metadata and no driver details. */ +export class DatabasePluginError extends AppKitError { + readonly code = "DATABASE_PLUGIN_ERROR"; + readonly isRetryable = false; + readonly statusCode: number; + + constructor( + readonly category: DatabaseErrorCategory, + readonly phase: DatabaseErrorPhase, + runtimeMessage?: string, + ) { + const definition = definitions[category]; + // Plugin boundaries replace runtime diagnostics with the stable message. + super( + phase === "runtime" && runtimeMessage + ? runtimeMessage + : definition.message, + { + clientMessage: definition.message, + }, + ); + this.statusCode = definition.statusCode; + this.name = "DatabasePluginError"; + } +} + +/** Keep runtime diagnostics internal until a plugin boundary classifies them. */ +export function invalidDatabaseRequest( + runtimeMessage?: string, +): DatabasePluginError { + return new DatabasePluginError("INVALID_REQUEST", "runtime", runtimeMessage); +} + +/** Add operation context without retaining an unknown error's details. */ +export function classifyDatabaseError( + error: unknown, + phase: DatabaseErrorPhase, +): DatabasePluginError { + if (error instanceof DatabasePluginError) { + return error.phase === phase + ? error + : new DatabasePluginError(error.category, phase); + } + logger.error("Unclassified database error during %s: %O", phase, error); + return new DatabasePluginError("INTERNAL", phase); +} + +/** Restore the safe database category carried through `Plugin.execute()`. */ +export function databaseErrorFromStatus( + status: number, + phase: DatabaseErrorPhase, +): DatabasePluginError { + const category = categoryByStatus[status] ?? "INTERNAL"; + return new DatabasePluginError(category, phase); +} diff --git a/packages/appkit/src/database/runtime/data-path.ts b/packages/appkit/src/database/runtime/data-path.ts index 2a486d627..75df4a846 100644 --- a/packages/appkit/src/database/runtime/data-path.ts +++ b/packages/appkit/src/database/runtime/data-path.ts @@ -1,7 +1,14 @@ -import { DEFAULT_LIMIT, type FilterOperator, MAX_LIMIT } from "../contract"; +import { + DEFAULT_LIMIT, + type FilterOperator, + type IdValue, + MAX_LIMIT, + type OrderDirection, +} from "../contract"; +import { invalidDatabaseRequest } from "../errors"; import type { AppKitTable, ColumnMeta } from "../schema-builder"; -export type IdValue = string | number | bigint; +export type { IdValue, OrderDirection }; export type ScalarValue = string | number | bigint | boolean | null; /** Operators for one column; array operands are reserved for `in`. */ export type FilterOps = Partial< @@ -13,7 +20,6 @@ export type WhereClause = Readonly< Record >; -export type OrderDirection = "asc" | "desc"; export type OrderSpec = Readonly>; export interface IncludeOptions { @@ -38,18 +44,23 @@ export interface QuerySpec { export type Row = Record; -/** - * Backend-neutral operations; field names are schema keys that an adapter must - * resolve, never caller-provided SQL identifiers. - */ +/** Combine predicates without making callers understand the wire shape. */ +export function andWhere( + existing: WhereClause | undefined, + next: WhereClause, +): WhereClause { + return existing === undefined ? next : { and: [existing, next] }; +} + +/** Internal AppKit execution port; field names are schema-owned identifiers. */ export interface DataPath { /** Read a bounded collection from one finalized table. */ select(table: AppKitTable, spec: QuerySpec): Promise; - /** Read by the table's sole primary key with optional projection/include. */ + /** Read by the sole primary key while preserving supported query state. */ findOne( table: AppKitTable, id: IdValue, - spec?: Pick, + spec?: Pick, ): Promise; count(table: AppKitTable, where?: WhereClause): Promise; /** Return exactly one inserted row; zero or many is an invariant failure. */ @@ -69,18 +80,10 @@ export interface DataPath { transaction(callback: (tx: DataPath) => Promise): Promise; } -/** Runtime failure that does not retain driver details. */ -export class DataPathError extends Error { - constructor(message: string) { - super(message); - this.name = "DataPathError"; - } -} - /** Validate an explicit root or relation row limit. */ export function validateLimit(limit: number): number { if (!Number.isInteger(limit) || limit < 0 || limit > MAX_LIMIT) { - throw new DataPathError( + throw invalidDatabaseRequest( `limit must be an integer between 0 and ${MAX_LIMIT}`, ); } @@ -95,7 +98,7 @@ export function limitOrDefault(limit?: number): number { /** Reject offsets that PostgreSQL cannot represent safely as JS integers. */ export function validateOffset(offset: number): number { if (!Number.isSafeInteger(offset) || offset < 0) { - throw new DataPathError("offset must be a non-negative safe integer"); + throw invalidDatabaseRequest("offset must be a non-negative safe integer"); } return offset; } @@ -106,7 +109,7 @@ export function primaryKeyMeta(table: AppKitTable): ColumnMeta { (column) => column.primaryKey, ); if (primaryKeys.length !== 1) { - throw new DataPathError(`Table "${table.$name}" has no primary key`); + throw invalidDatabaseRequest(`Table "${table.$name}" has no primary key`); } return primaryKeys[0]; } @@ -118,7 +121,7 @@ export function conflictTargetMeta( ): ColumnMeta { const column = table.$columns[columnName]; if (!column || (!column.primaryKey && !column.unique)) { - throw new DataPathError( + throw invalidDatabaseRequest( `Column "${table.$name}.${columnName}" is not a conflict target`, ); } diff --git a/packages/appkit/src/database/runtime/engine/drizzle-data-path.ts b/packages/appkit/src/database/runtime/engine/drizzle-data-path.ts index 01404be8a..b79cb54c4 100644 --- a/packages/appkit/src/database/runtime/engine/drizzle-data-path.ts +++ b/packages/appkit/src/database/runtime/engine/drizzle-data-path.ts @@ -2,12 +2,20 @@ import { eq, isSQLWrapper, type SQL, sql } from "drizzle-orm"; import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; import type { PgTable } from "drizzle-orm/pg-core"; import type { Pool } from "pg"; -import type { AppKitTable, Schema } from "../../schema-builder"; +import { createLogger } from "../../../logging/logger"; +import { + type DatabaseErrorCategory, + DatabasePluginError, + invalidDatabaseRequest, +} from "../../errors"; +import type { AppKitTable, ColumnMeta, Schema } from "../../schema-builder"; import { buildEngineRelations } from "../../schema-builder/engine/relations"; +import { columnValueSchema } from "../../schema-builder/validators"; import { + andWhere, conflictTargetMeta, type DataPath, - DataPathError, + type IdValue, limitOrDefault, primaryKeyMeta, type Row, @@ -23,6 +31,8 @@ import { translateWhere, } from "./translate"; +const logger = createLogger("database"); + /** Concrete Drizzle seam shared by the adapter and its focused tests. */ export type DrizzleDb = NodePgDatabase>; @@ -44,7 +54,7 @@ interface RelationalQueryBuilder { /** Reject same-name or forged tables by requiring finalized object identity. */ function assertRegisteredTable(schema: Schema, table: AppKitTable): void { if (schema.$tables[table.$name] !== table) { - throw new DataPathError(`Table "${table.$name}" is not registered`); + throw invalidDatabaseRequest(`Table "${table.$name}" is not registered`); } } @@ -58,7 +68,7 @@ function relationalQueryBuilder( table.$name ]; if (!query) { - throw new DataPathError(`Table "${table.$name}" is not registered`); + throw invalidDatabaseRequest(`Table "${table.$name}" is not registered`); } return query; } @@ -75,47 +85,103 @@ function selectedColumns( /** Keep mutation identifiers schema-owned and every supplied value parameterized. */ function mutationValues(table: AppKitTable, values: Row): Row { if (values === null || typeof values !== "object" || Array.isArray(values)) { - throw new DataPathError("Database mutation values must be an object"); + throw invalidDatabaseRequest("Database mutation values must be an object"); } for (const [key, value] of Object.entries(values)) { if (!Object.hasOwn(table.$columns, key)) { - throw new DataPathError(`Unknown column "${table.$name}.${key}"`); + throw invalidDatabaseRequest(`Unknown column "${table.$name}.${key}"`); } if (isSQLWrapper(value)) { - throw new DataPathError("Database mutation values cannot contain SQL"); + throw invalidDatabaseRequest( + "Database mutation values cannot contain SQL", + ); } } return values; } +// Drizzle wraps driver failures in DrizzleQueryError, so the SQLSTATE sits on a +// nested `cause` rather than the thrown error. Walk a bounded chain to find it. +const MAX_CAUSE_DEPTH = 5; + +function sqlStateOf(error: unknown): string | undefined { + let current = error; + for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) { + if (!current || typeof current !== "object") return undefined; + try { + const candidate = Reflect.get(current, "code"); + // SQLSTATE is always a five-character alphanumeric class code. + if (typeof candidate === "string" && /^[0-9A-Z]{5}$/.test(candidate)) { + return candidate; + } + current = Reflect.get(current, "cause"); + } catch { + return undefined; + } + } + return undefined; +} + +/** Classify SQLSTATE without retaining the driver error or its properties. */ +function classifyDriverError(error: unknown): DatabasePluginError { + const code = sqlStateOf(error); + const category: DatabaseErrorCategory = + code === "42501" + ? "FORBIDDEN" + : code?.startsWith("23") + ? "CONFLICT" + : "INTERNAL"; + logger.error( + "Database driver error classified as %s (SQLSTATE %s): %O", + category, + code ?? "unknown", + error, + ); + return new DatabasePluginError(category, "runtime"); +} + async function runDatabaseOperation( operation: () => Promise, ): Promise { try { return await operation(); } catch (error) { - if (error instanceof DataPathError) throw error; - // Raw driver details stop at the adapter boundary. - throw new DataPathError("Database operation failed"); + if (error instanceof DatabasePluginError) throw error; + throw classifyDriverError(error); } } +/** Resolve and validate the sole key once before a keyed operation executes. */ +function validatedPrimaryKey( + table: AppKitTable, + id: unknown, +): { readonly meta: ColumnMeta; readonly value: IdValue } { + const meta = primaryKeyMeta(table); + const result = columnValueSchema(meta).safeParse(id); + if (!result.success) { + throw invalidDatabaseRequest( + `Invalid primary-key value for "${table.$name}.${meta.columnName}"`, + ); + } + return { meta, value: result.data as IdValue }; +} + // Enforce the single-row DataPath contract before results reach callers. function expectExactlyOne(rows: Row[]): Row { if (rows.length !== 1) { - throw new DataPathError("Database mutation did not return exactly one row"); + throw new DatabasePluginError("INTERNAL", "runtime"); } return rows[0]; } function expectZeroOrOne(rows: Row[]): Row | null { if (rows.length > 1) { - throw new DataPathError("Database mutation returned more than one row"); + throw new DatabasePluginError("INTERNAL", "runtime"); } return rows[0] ?? null; } -/** Adapt a Drizzle database to the backend-neutral DataPath contract. */ +/** Adapt a Drizzle database to AppKit's internal execution port. */ export function createDrizzleDataPath(db: DrizzleDb, schema: Schema): DataPath { const pgTable = (table: AppKitTable): PgTable => { assertRegisteredTable(schema, table); @@ -149,10 +215,22 @@ export function createDrizzleDataPath(db: DrizzleDb, schema: Schema): DataPath { }, async findOne(table, id, spec) { - const primaryKey = primaryKeyMeta(table); + const { meta: primaryKey, value: validatedId } = validatedPrimaryKey( + table, + id, + ); const row = await runDatabaseOperation(() => relationalQueryBuilder(db, schema, table).findFirst({ - where: eq(columnOf(table, primaryKey.columnName), id), + where: + spec?.where === undefined + ? eq(columnOf(table, primaryKey.columnName), validatedId) + : translateWhere( + table, + andWhere( + { [primaryKey.columnName]: { eq: validatedId } }, + spec.where, + ), + ), columns: selectedColumns(table, spec?.select), with: spec?.include === undefined @@ -181,12 +259,15 @@ export function createDrizzleDataPath(db: DrizzleDb, schema: Schema): DataPath { async update(table, id, values) { const engineTable = pgTable(table); const parameters = mutationValues(table, values); - const primaryKey = primaryKeyMeta(table); + const { meta: primaryKey, value: validatedId } = validatedPrimaryKey( + table, + id, + ); const rows = await runDatabaseOperation(() => db .update(engineTable) .set(parameters) - .where(eq(columnOf(table, primaryKey.columnName), id)) + .where(eq(columnOf(table, primaryKey.columnName), validatedId)) .returning(), ); return expectZeroOrOne(rows as Row[]); @@ -210,11 +291,14 @@ export function createDrizzleDataPath(db: DrizzleDb, schema: Schema): DataPath { }, async delete(table, id) { - const primaryKey = primaryKeyMeta(table); + const { meta: primaryKey, value: validatedId } = validatedPrimaryKey( + table, + id, + ); const rows = await runDatabaseOperation(() => db .delete(pgTable(table)) - .where(eq(columnOf(table, primaryKey.columnName), id)) + .where(eq(columnOf(table, primaryKey.columnName), validatedId)) .returning({ id: columnOf(table, primaryKey.columnName) }), ); return expectZeroOrOne(rows as Row[]) !== null; @@ -226,7 +310,7 @@ export function createDrizzleDataPath(db: DrizzleDb, schema: Schema): DataPath { ): Promise { // SQL wrappers carry structure; tagged interpolations may carry values only. if (values.some((value) => isSQLWrapper(value))) { - throw new DataPathError( + throw invalidDatabaseRequest( "Tagged SQL interpolations must be parameter values", ); } @@ -258,12 +342,13 @@ export function createDrizzleDataPath(db: DrizzleDb, schema: Schema): DataPath { // Callback errors are application-owned; sanitize only tx lifecycle errors. if ( callbackFailed && - (error === callbackError || callbackError instanceof DataPathError) + (error === callbackError || + callbackError instanceof DatabasePluginError) ) { throw callbackError; } - if (error instanceof DataPathError) throw error; - throw new DataPathError("Database operation failed"); + if (error instanceof DatabasePluginError) throw error; + throw classifyDriverError(error); } }, }; diff --git a/packages/appkit/src/database/runtime/engine/translate.ts b/packages/appkit/src/database/runtime/engine/translate.ts index 383693f7e..fe7a3bd77 100644 --- a/packages/appkit/src/database/runtime/engine/translate.ts +++ b/packages/appkit/src/database/runtime/engine/translate.ts @@ -24,11 +24,11 @@ import { isFilterOperator, MAX_INCLUDES, } from "../../contract"; +import { invalidDatabaseRequest } from "../../errors"; import type { AppKitTable, ColumnMeta, Schema } from "../../schema-builder"; import { filterOperatorsForKind } from "../../schema-builder/types"; import { columnValueSchema } from "../../schema-builder/validators"; import { - DataPathError, type FilterOps, type IncludeOptions, type IncludeSpec, @@ -40,7 +40,7 @@ import { function columnMetaOf(table: AppKitTable, key: string): ColumnMeta { const column = table.$columns[key]; if (!column) { - throw new DataPathError(`Unknown column "${table.$name}.${key}"`); + throw invalidDatabaseRequest(`Unknown column "${table.$name}.${key}"`); } return column; } @@ -73,7 +73,7 @@ function assertColumnValue( value: unknown, ): void { if (!columnValueSchema(meta).safeParse(value).success) { - throw new DataPathError( + throw invalidDatabaseRequest( `Invalid ${operator} operand for "${table.$name}.${meta.columnName}"`, ); } @@ -86,14 +86,14 @@ function inList( value: unknown, ): SQL { if (!Array.isArray(value)) { - throw new DataPathError('The "in" operator requires an array'); + throw invalidDatabaseRequest('The "in" operator requires an array'); } if (value.length > IN_CAP) { - throw new DataPathError(`in list exceeds the ${IN_CAP}-value limit`); + throw invalidDatabaseRequest(`in list exceeds the ${IN_CAP}-value limit`); } for (const item of value) { if (item === null) { - throw new DataPathError('The "in" operator does not accept null'); + throw invalidDatabaseRequest('The "in" operator does not accept null'); } assertColumnValue(table, meta, "in", item); } @@ -108,13 +108,13 @@ function translateOperator( value: unknown, ): SQL { if (!supportsOperator(meta, operator)) { - throw new DataPathError( + throw invalidDatabaseRequest( `Operator "${operator}" is not supported for "${table.$name}.${meta.columnName}"`, ); } if (operator === "is") { if (value !== null) { - throw new DataPathError('The "is" operator accepts only null'); + throw invalidDatabaseRequest('The "is" operator accepts only null'); } return isNull(column); } @@ -139,7 +139,7 @@ function translateOperator( case "ilike": return ilike(column, value as string); default: - throw new DataPathError(`Unsupported filter operator "${operator}"`); + throw invalidDatabaseRequest(`Unsupported filter operator "${operator}"`); } } @@ -149,18 +149,20 @@ export function translateWhere( clause: WhereClause, ): SQL | undefined { if (clause === null || typeof clause !== "object" || Array.isArray(clause)) { - throw new DataPathError("where must be an object"); + throw invalidDatabaseRequest("where must be an object"); } const conditions: SQL[] = []; for (const [key, value] of Object.entries(clause)) { if (key === "and" || key === "or") { if (!Array.isArray(value) || value.length === 0) { - throw new DataPathError(`${key} requires a non-empty predicate array`); + throw invalidDatabaseRequest( + `${key} requires a non-empty predicate array`, + ); } const groups = value.map((group) => { const translated = translateWhere(table, group as WhereClause); if (!translated) { - throw new DataPathError(`${key} predicates cannot be empty`); + throw invalidDatabaseRequest(`${key} predicates cannot be empty`); } return translated; }); @@ -182,13 +184,13 @@ export function translateWhere( ) { const operators = Object.entries(value as FilterOps); if (operators.length === 0) { - throw new DataPathError( + throw invalidDatabaseRequest( `Filter for "${table.$name}.${key}" cannot be empty`, ); } for (const [operator, operand] of operators) { if (!isFilterOperator(operator)) { - throw new DataPathError(`Unknown filter operator "${operator}"`); + throw invalidDatabaseRequest(`Unknown filter operator "${operator}"`); } conditions.push( translateOperator(table, meta, column, operator, operand), @@ -204,7 +206,7 @@ export function translateWhere( export function translateOrder(table: AppKitTable, order: OrderSpec): SQL[] { return Object.entries(order).map(([key, direction]) => { if (direction !== "asc" && direction !== "desc") { - throw new DataPathError(`Unknown order direction "${direction}"`); + throw invalidDatabaseRequest(`Unknown order direction "${direction}"`); } const column = columnOf(table, key); return direction === "desc" ? desc(column) : asc(column); @@ -225,7 +227,7 @@ export function selectToColumns( function tableByName(schema: Schema, name: string): AppKitTable { const table = schema.$tables[name]; - if (!table) throw new DataPathError(`Unknown table "${name}"`); + if (!table) throw invalidDatabaseRequest(`Unknown table "${name}"`); return table; } @@ -237,7 +239,7 @@ export function translateInclude( ): Record { const entries = Object.entries(include); if (entries.length > MAX_INCLUDES) { - throw new DataPathError( + throw invalidDatabaseRequest( `include exceeds the ${MAX_INCLUDES}-relation limit`, ); } @@ -248,7 +250,7 @@ export function translateInclude( (candidate) => candidate.name === relationName, ); if (!relation) { - throw new DataPathError( + throw invalidDatabaseRequest( `Unknown relation "${table.$name}.${relationName}"`, ); } @@ -278,7 +280,7 @@ export function translateInclude( } if (options.limit !== undefined) { if (relation.cardinality !== "toMany") { - throw new DataPathError("Only to-many relations accept a limit"); + throw invalidDatabaseRequest("Only to-many relations accept a limit"); } relationConfig.limit = validateLimit(options.limit); } else if (relation.cardinality === "toMany") { diff --git a/packages/appkit/src/database/runtime/index.ts b/packages/appkit/src/database/runtime/index.ts index 057a4e29b..987f4611c 100644 --- a/packages/appkit/src/database/runtime/index.ts +++ b/packages/appkit/src/database/runtime/index.ts @@ -1,15 +1,14 @@ -export { - type DataPath, - DataPathError, - type FilterOps, - type IdValue, - type IncludeOptions, - type IncludeSpec, - type OrderDirection, - type OrderSpec, - type QuerySpec, - type Row, - type ScalarValue, - type WhereClause, - type WhereValue, +export type { + DataPath, + FilterOps, + IdValue, + IncludeOptions, + IncludeSpec, + OrderDirection, + OrderSpec, + QuerySpec, + Row, + ScalarValue, + WhereClause, + WhereValue, } from "./data-path"; diff --git a/packages/appkit/src/database/runtime/tests/data-path-contract.test.ts b/packages/appkit/src/database/runtime/tests/data-path-contract.test.ts index 18e39e09a..f78b46208 100644 --- a/packages/appkit/src/database/runtime/tests/data-path-contract.test.ts +++ b/packages/appkit/src/database/runtime/tests/data-path-contract.test.ts @@ -1,5 +1,6 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import { DEFAULT_LIMIT, MAX_LIMIT } from "../../contract"; +import { DatabasePluginError } from "../../errors"; import { defineSchema, id, text } from "../../schema-builder"; import { conflictTargetMeta, @@ -8,13 +9,7 @@ import { validateLimit, validateOffset, } from "../data-path"; -import { - type DataPath, - DataPathError, - type IdValue, - type QuerySpec, - type Row, -} from "../index"; +import type { DataPath, IdValue, QuerySpec, Row } from "../index"; const schema = defineSchema((builder) => ({ users: builder.table("users", { @@ -58,8 +53,8 @@ describe("DataPath contract", () => { expectTypeOf(spec).toMatchTypeOf(); }); - it("publishes only the Phase 1 runtime values", async () => { - expect(Object.keys(await import("../index"))).toEqual(["DataPathError"]); + it("keeps the runtime barrel type-only", async () => { + expect(Object.keys(await import("../index"))).toEqual([]); }); }); @@ -68,23 +63,25 @@ describe("runtime bounds and metadata", () => { expect(limitOrDefault()).toBe(DEFAULT_LIMIT); expect(validateLimit(0)).toBe(0); expect(validateLimit(MAX_LIMIT)).toBe(MAX_LIMIT); - expect(() => validateLimit(-1)).toThrow(DataPathError); - expect(() => validateLimit(MAX_LIMIT + 1)).toThrow(DataPathError); + expect(() => validateLimit(-1)).toThrow(DatabasePluginError); + expect(() => validateLimit(MAX_LIMIT + 1)).toThrow(DatabasePluginError); }); it("accepts only non-negative safe offsets", () => { expect(validateOffset(0)).toBe(0); expect(validateOffset(10)).toBe(10); - expect(() => validateOffset(-1)).toThrow(DataPathError); - expect(() => validateOffset(Number.MAX_VALUE)).toThrow(DataPathError); + expect(() => validateOffset(-1)).toThrow(DatabasePluginError); + expect(() => validateOffset(Number.MAX_VALUE)).toThrow(DatabasePluginError); }); it("resolves primary keys and explicit conflict targets from metadata", () => { expect(primaryKeyMeta(schema.$tables.users).columnName).toBe("id"); expect(conflictTargetMeta(schema.$tables.users, "email").unique).toBe(true); - expect(() => primaryKeyMeta(schema.$tables.events)).toThrow(DataPathError); + expect(() => primaryKeyMeta(schema.$tables.events)).toThrow( + DatabasePluginError, + ); expect(() => conflictTargetMeta(schema.$tables.users, "body")).toThrow( - DataPathError, + DatabasePluginError, ); }); }); diff --git a/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts b/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts index 4998a9eba..2d8d0fde0 100644 --- a/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts +++ b/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts @@ -3,8 +3,17 @@ import { PgDialect, type PgTable } from "drizzle-orm/pg-core"; import { Pool } from "pg"; import { afterAll, describe, expect, it } from "vitest"; import { DEFAULT_LIMIT, MAX_LIMIT } from "../../contract"; -import { boolean, defineSchema, fk, id, text } from "../../schema-builder"; -import { DataPathError, type Row } from "../data-path"; +import { DatabasePluginError } from "../../errors"; +import { + bigid, + boolean, + defineSchema, + fk, + id, + text, + uuid, +} from "../../schema-builder"; +import type { Row } from "../data-path"; import { createDrizzleDataPath, createDrizzleDb, @@ -219,16 +228,22 @@ describe("createDrizzleDataPath reads", () => { }); await expect( dataPath.select(users, { limit: MAX_LIMIT + 1 }), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); }); it("finds by primary key and delegates count filters", async () => { const fake = makeFakeDb({ findFirst: { id: 7, name: "Ada" }, count: 3 }); const dataPath = createDrizzleDataPath(fake.db, schema); await expect( - dataPath.findOne(users, 7, { select: ["id", "name"] }), + dataPath.findOne(users, 7, { + where: { active: true }, + select: ["id", "name"], + }), ).resolves.toEqual({ id: 7, name: "Ada" }); - expect(render(fake.calls.findFirst[0].config.where).params).toEqual([7]); + expect(render(fake.calls.findFirst[0].config.where).params).toEqual([ + 7, + true, + ]); await expect(dataPath.count(users, { active: true })).resolves.toBe(3); expect(render(fake.calls.count[0].filter).params).toEqual([true]); }); @@ -242,8 +257,74 @@ describe("createDrizzleDataPath reads", () => { other.$tables.users, {}, ), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); }); + + it.each([ + [ + "number", + defineSchema(({ table }) => ({ users: table("users", { id: id() }) })), + 7, + "7", + ], + [ + "bigint", + defineSchema(({ table }) => ({ users: table("users", { id: bigid() }) })), + 7n, + 7, + ], + [ + "uuid", + defineSchema(({ table }) => ({ + users: table("users", { id: uuid().primaryKey() }), + })), + "123e4567-e89b-42d3-a456-426614174000", + "not-a-uuid", + ], + [ + "custom string", + defineSchema(({ table }) => ({ + users: table("users", { id: text().primaryKey() }), + })), + "user-key", + 7, + ], + ] as const)( + "validates %s primary-key IDs before builders", + async (_kind, idSchema, valid, invalid) => { + const fake = makeFakeDb({ + findFirst: { id: valid }, + update: [{ id: valid }], + delete: [{ id: valid }], + }); + const table = idSchema.$tables.users; + const dataPath = createDrizzleDataPath(fake.db, idSchema); + await expect(dataPath.findOne(table, valid, {})).resolves.toEqual({ + id: valid, + }); + await expect(dataPath.update(table, valid, {})).resolves.toEqual({ + id: valid, + }); + await expect(dataPath.delete(table, valid)).resolves.toBe(true); + const before = { + findFirst: fake.calls.findFirst.length, + update: fake.calls.update.length, + delete: fake.calls.delete.length, + }; + await expect( + dataPath.findOne(table, invalid as never, {}), + ).rejects.toMatchObject({ category: "INVALID_REQUEST" }); + await expect( + dataPath.update(table, invalid as never, {}), + ).rejects.toMatchObject({ category: "INVALID_REQUEST" }); + await expect( + dataPath.delete(table, invalid as never), + ).rejects.toMatchObject({ category: "INVALID_REQUEST" }); + expect(fake.calls.findFirst).toHaveLength(before.findFirst); + expect(fake.calls.update).toHaveLength(before.update); + expect(fake.calls.delete).toHaveLength(before.delete); + }, + ); }); describe("Drizzle mutation cardinality", () => { @@ -260,13 +341,13 @@ describe("Drizzle mutation cardinality", () => { users, {}, ), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); await expect( createDrizzleDataPath( makeFakeDb({ insert: [row, row] }).db, schema, ).insert(users, {}), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); const fake = makeFakeDb({ upsert: [row] }); await expect( @@ -281,20 +362,20 @@ describe("Drizzle mutation cardinality", () => { ); await expect( createDrizzleDataPath(fake.db, schema).upsert(users, {}, "name"), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); await expect( createDrizzleDataPath(makeFakeDb({ upsert: [] }).db, schema).upsert( users, { email: "a@example.com" }, "email", ), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); await expect( createDrizzleDataPath( makeFakeDb({ upsert: [row, row] }).db, schema, ).upsert(users, { email: "a@example.com" }, "email"), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); }); it("rejects unknown identifiers and structural Drizzle mutation values", async () => { @@ -303,20 +384,20 @@ describe("Drizzle mutation cardinality", () => { await expect( dataPath.insert(users, { missing: "not a schema column" }), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); await expect( dataPath.insert(users, { name: drizzleSql.raw("current_user") }), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); await expect( dataPath.update(users, 1, { name: users.$columns.email.engineColumn }), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); await expect( dataPath.upsert( users, { email: "a@example.com", name: drizzleSql.raw("current_user") }, "email", ), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); expect(fake.calls.insert).toHaveLength(0); expect(fake.calls.update).toHaveLength(0); @@ -344,7 +425,7 @@ describe("Drizzle mutation cardinality", () => { makeFakeDb({ update: [row, row] }).db, schema, ).update(users, 1, {}), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); }); it("accepts zero or one delete row and rejects more", async () => { @@ -365,7 +446,7 @@ describe("Drizzle mutation cardinality", () => { makeFakeDb({ delete: [{ id: 1 }, { id: 2 }] }).db, schema, ).delete(users, 1), - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); }); }); @@ -384,7 +465,7 @@ describe("tagged SQL and transactions", () => { await expect( dataPath.raw`select ${drizzleSql.raw("drop table users")}`, - ).rejects.toBeInstanceOf(DataPathError); + ).rejects.toBeInstanceOf(DatabasePluginError); expect(fake.calls.execute).toHaveLength(1); }); @@ -403,7 +484,10 @@ describe("tagged SQL and transactions", () => { }), ).rejects.toBe(callbackError); - const classifiedError = new DataPathError("Already classified"); + const classifiedError = new DatabasePluginError( + "INVALID_REQUEST", + "runtime", + ); await expect( dataPath.transaction(async () => { throw classifiedError; @@ -429,7 +513,7 @@ describe("tagged SQL and transactions", () => { }) .catch((caught) => caught); - expect(error).toBeInstanceOf(DataPathError); + expect(error).toBeInstanceOf(DatabasePluginError); expect(error.message).toBe("Database operation failed"); expect(error.cause).toBeUndefined(); }, @@ -450,10 +534,126 @@ describe("database failures", () => { const error = await createDrizzleDataPath(fake.db, schema) .select(users, {}) .catch((caught) => caught); - expect(error).toBeInstanceOf(DataPathError); + expect(error).toBeInstanceOf(DatabasePluginError); expect(error.message).toBe("Database operation failed"); expect(error.cause).toBeUndefined(); }); + + it.each([ + ["23505", "CONFLICT"], + ["23000", "CONFLICT"], + ["42501", "FORBIDDEN"], + ["XX000", "INTERNAL"], + [42, "INTERNAL"], + ] as const)( + "maps SQLSTATE %s without leaking driver fields", + async (code, category) => { + const fake = makeFakeDb(); + const query = fake.db.query as unknown as Record< + string, + { findMany: () => Promise } + >; + query.users.findMany = async () => { + throw { + code, + message: "password and SQL leaked", + constraint: "users_secret_key", + detail: "input secret", + }; + }; + const error = await createDrizzleDataPath(fake.db, schema) + .select(users, {}) + .catch((caught) => caught); + expect(error).toMatchObject({ + category, + }); + expect(error.cause).toBeUndefined(); + expect(JSON.stringify(error)).not.toContain("secret"); + }, + ); + + // Drizzle never rethrows the raw driver error; it wraps it in + // DrizzleQueryError and moves the SQLSTATE onto `cause`. + it.each([ + ["23503", "CONFLICT"], + ["42501", "FORBIDDEN"], + ["XX000", "INTERNAL"], + ] as const)( + "maps SQLSTATE %s carried on a wrapped driver cause", + async (code, category) => { + const fake = makeFakeDb(); + const query = fake.db.query as unknown as Record< + string, + { findMany: () => Promise } + >; + query.users.findMany = async () => { + const driver = Object.assign(new Error("secret constraint detail"), { + code, + constraint: "users_secret_key", + }); + throw Object.assign( + new Error("Failed query: select secret from users"), + { cause: driver }, + ); + }; + const error = await createDrizzleDataPath(fake.db, schema) + .select(users, {}) + .catch((caught) => caught); + expect(error).toMatchObject({ category }); + expect(error.cause).toBeUndefined(); + expect(JSON.stringify(error)).not.toContain("secret"); + }, + ); + + it("stops walking an error cause cycle", async () => { + const fake = makeFakeDb(); + const query = fake.db.query as unknown as Record< + string, + { findMany: () => Promise } + >; + query.users.findMany = async () => { + const cyclic: { cause?: unknown } = {}; + cyclic.cause = cyclic; + throw cyclic; + }; + const error = await createDrizzleDataPath(fake.db, schema) + .select(users, {}) + .catch((caught) => caught); + expect(error).toMatchObject({ category: "INTERNAL" }); + }); + + it.each([ + Object.defineProperty({}, "code", { + get: () => { + throw new Error("getter secret"); + }, + }), + new Proxy( + {}, + { + get: () => { + throw new Error("proxy secret"); + }, + }, + ), + ])("fails closed for hostile SQLSTATE access", async (hostile) => { + const fake = makeFakeDb(); + const query = fake.db.query as unknown as Record< + string, + { findMany: () => Promise } + >; + query.users.findMany = async () => { + throw hostile; + }; + const error = await createDrizzleDataPath(fake.db, schema) + .select(users, {}) + .catch((caught) => caught); + expect(error).toMatchObject({ + category: "INTERNAL", + message: "Database operation failed", + }); + expect(error.cause).toBeUndefined(); + }); }); describe("createDrizzleDb", () => { diff --git a/packages/appkit/src/database/runtime/tests/translate.test.ts b/packages/appkit/src/database/runtime/tests/translate.test.ts index 775316be7..b1b9e65b6 100644 --- a/packages/appkit/src/database/runtime/tests/translate.test.ts +++ b/packages/appkit/src/database/runtime/tests/translate.test.ts @@ -2,6 +2,7 @@ import type { SQL } from "drizzle-orm"; import { PgDialect } from "drizzle-orm/pg-core"; import { describe, expect, it } from "vitest"; import { IN_CAP, MAX_INCLUDES, MAX_LIMIT } from "../../contract"; +import { DatabasePluginError } from "../../errors"; import { bigint, boolean, @@ -16,7 +17,6 @@ import { uuid, } from "../../schema-builder"; import { filterOperatorsForKind } from "../../schema-builder/types"; -import { DataPathError } from "../data-path"; import { defaultColumns, selectToColumns, @@ -85,7 +85,7 @@ describe("translateWhere", () => { expect(query.sql).not.toContain("drop table"); expect(query.params).toEqual([injected]); expect(() => translateWhere(users, { missing: injected })).toThrow( - DataPathError, + DatabasePluginError, ); }); @@ -106,7 +106,7 @@ describe("translateWhere", () => { } expect(() => translateWhere(users, { age: { between: [1, 2] } as never }), - ).toThrow(DataPathError); + ).toThrow(DatabasePluginError); }); it("bounds in lists and gives an empty list deterministic semantics", () => { @@ -118,10 +118,10 @@ describe("translateWhere", () => { translateWhere(users, { id: { in: Array.from({ length: IN_CAP + 1 }, (_, index) => index) }, }), - ).toThrow(DataPathError); + ).toThrow(DatabasePluginError); expect(() => translateWhere(users, { name: { in: ["Ada", null] } }), - ).toThrow(DataPathError); + ).toThrow(DatabasePluginError); }); it("rejects operators and values that do not match column metadata", () => { @@ -142,21 +142,21 @@ describe("translateWhere", () => { expect(() => translateWhere(users, { age: { like: "1%" } as never }), - ).toThrow(DataPathError); + ).toThrow(DatabasePluginError); expect(() => translateWhere(users, { active: { gt: true } as never }), - ).toThrow(DataPathError); + ).toThrow(DatabasePluginError); expect(() => translateWhere(users, { metadata: { eq: { key: "value" } } as never }), - ).toThrow(DataPathError); + ).toThrow(DatabasePluginError); expect(() => translateWhere(users, { externalId: "not-a-uuid" })).toThrow( - DataPathError, + DatabasePluginError, ); expect(() => translateWhere(users, { createdAt: { gt: "not-a-timestamp" } }), - ).toThrow(DataPathError); + ).toThrow(DatabasePluginError); expect(() => translateWhere(users, { status: "unknown" })).toThrow( - DataPathError, + DatabasePluginError, ); }); @@ -164,18 +164,24 @@ describe("translateWhere", () => { expect(render(translateWhere(users, { name: { is: null } })).sql).toBe( `"users"."name" is null`, ); - expect(() => translateWhere(users, { name: null })).toThrow(DataPathError); + expect(() => translateWhere(users, { name: null })).toThrow( + DatabasePluginError, + ); expect(() => translateWhere(users, { name: { eq: null } })).toThrow( - DataPathError, + DatabasePluginError, ); expect(() => translateWhere(users, { active: { is: null } })).toThrow( - DataPathError, + DatabasePluginError, ); }); it("rejects empty logical groups instead of widening a query", () => { - expect(() => translateWhere(users, { or: [] })).toThrow(DataPathError); - expect(() => translateWhere(users, { and: [{}] })).toThrow(DataPathError); + expect(() => translateWhere(users, { or: [] })).toThrow( + DatabasePluginError, + ); + expect(() => translateWhere(users, { and: [{}] })).toThrow( + DatabasePluginError, + ); }); it("combines and/or groups without relation predicates", () => { @@ -216,11 +222,13 @@ describe("ordering and selection", () => { secret: true, }); expect(() => translateOrder(users, { missing: "asc" })).toThrow( - DataPathError, + DatabasePluginError, + ); + expect(() => selectToColumns(users, ["missing"])).toThrow( + DatabasePluginError, ); - expect(() => selectToColumns(users, ["missing"])).toThrow(DataPathError); expect(() => translateOrder(users, { age: "sideways" as "asc" })).toThrow( - DataPathError, + DatabasePluginError, ); }); }); @@ -251,14 +259,14 @@ describe("translateInclude", () => { it("rejects unknown relations and invalid relation limits", () => { expect(() => translateInclude(users, schema, { missing: true })).toThrow( - DataPathError, + DatabasePluginError, ); expect(() => translateInclude(users, schema, { posts: { limit: MAX_LIMIT + 1 } }), - ).toThrow(DataPathError); + ).toThrow(DatabasePluginError); expect(() => translateInclude(schema.$tables.posts, schema, { users: { limit: 1 } }), - ).toThrow(DataPathError); + ).toThrow(DatabasePluginError); const tooMany = Object.fromEntries( Array.from({ length: MAX_INCLUDES + 1 }, (_, index) => [ @@ -267,7 +275,7 @@ describe("translateInclude", () => { ]), ); expect(() => translateInclude(users, schema, tooMany)).toThrow( - DataPathError, + DatabasePluginError, ); }); }); diff --git a/packages/appkit/src/database/schema-builder/define-schema.ts b/packages/appkit/src/database/schema-builder/define-schema.ts index 276405e1a..be24ea6ff 100644 --- a/packages/appkit/src/database/schema-builder/define-schema.ts +++ b/packages/appkit/src/database/schema-builder/define-schema.ts @@ -43,6 +43,9 @@ export interface SchemaBuilderContext { } const RESERVED_OBJECT_KEYS = new Set(["__proto__", "prototype", "constructor"]); +const RESERVED_DATABASE_EXPORT_KEYS = new Set(["sql", "transaction"]); +// A global symbol lets typegen recognize schemas loaded through another AppKit instance. +const FINALIZED_SCHEMA = Symbol.for("@databricks/appkit.database.schema"); const TABLE_METADATA_KEYS = [ "$name", "$schemaName", @@ -69,6 +72,13 @@ function assertName(value: string, label: string): void { } } +function assertTableName(value: string): void { + assertName(value, "Table name"); + if (RESERVED_DATABASE_EXPORT_KEYS.has(value)) { + throw new SchemaBuildError(`Table name "${value}" is reserved`); + } +} + function finalizeColumn(meta: MutableColumnMeta): ColumnMeta { if (!meta.engineColumn) { throw new SchemaBuildError( @@ -100,7 +110,7 @@ function declareTable>( name: string, columns: C, ): TableHandle { - assertName(name, "Table name"); + assertTableName(name); if (state.raw.has(name)) { throw new SchemaBuildError(`Duplicate table "${name}"`); } @@ -215,6 +225,7 @@ function validateHandles(raw: ReadonlyMap): void { } } +/** Reject composite primary keys, which keyed operations cannot represent. */ function validatePrimaryKeys(raw: ReadonlyMap): void { for (const table of raw.values()) { const primaryKeys = Object.values(table.metas).filter( @@ -228,6 +239,7 @@ function validatePrimaryKeys(raw: ReadonlyMap): void { } } +/** Reject table names that collide with generated Drizzle relation keys. */ function validateRelationKeys( raw: ReadonlyMap, relations: ReadonlyMap, @@ -307,11 +319,24 @@ function publishSchema( engine[table.name] = candidate.engine; } - return Object.freeze({ + const schema = { $schemaName: schemaName, $tables: Object.freeze(tables), $engine: Object.freeze(engine), - }); + }; + Object.defineProperty(schema, FINALIZED_SCHEMA, { value: true }); + return Object.freeze(schema); +} + +/** @internal Reject values that were not finalized by `defineSchema()`. */ +export function assertFinalizedSchema(value: unknown): asserts value is Schema { + if ( + value === null || + typeof value !== "object" || + (value as Record)[FINALIZED_SCHEMA] !== true + ) { + throw new TypeError("Expected a finalized AppKit database schema"); + } } export function defineSchema( diff --git a/packages/appkit/src/database/schema-builder/engine/tables.ts b/packages/appkit/src/database/schema-builder/engine/tables.ts index 343bf8138..83fdf0733 100644 --- a/packages/appkit/src/database/schema-builder/engine/tables.ts +++ b/packages/appkit/src/database/schema-builder/engine/tables.ts @@ -166,6 +166,25 @@ function buildColumn( return column; } +// PostgreSQL renders a timestamp as `2026-06-29 19:05:19.051709+00` over the +// text protocol but as ISO-8601 inside the JSON aggregates Drizzle builds for +// relations, so one column reaches callers in two shapes depending on whether +// it was included. Both shapes are declared `string`, and only ISO-8601 parses +// per the ECMAScript grammar, so normalize to it. +const PG_TIMESTAMP = + /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2}(?:\.\d+)?)(?:([+-]\d{2})(?::?(\d{2}))?)?$/; + +function isoTimestamp(value: unknown): unknown { + if (typeof value !== "string") return value; + const parts = PG_TIMESTAMP.exec(value); + // Leave `infinity`, `-infinity`, and anything unrecognized untouched. + if (!parts) return value; + const [, date, time, offsetHours, offsetMinutes] = parts; + const offset = + offsetHours === undefined ? "" : `${offsetHours}:${offsetMinutes ?? "00"}`; + return `${date}T${time}${offset}`; +} + function buildTable( name: string, schemaName: string, @@ -192,6 +211,12 @@ function buildTable( `Engine column "${name}.${key}" was not constructed`, ); } + if (meta.storageKind === "timestamp") { + // Drizzle routes every read path through this decoder, so overriding it + // covers direct selects, relation includes, and mutation RETURNING alike. + (engineColumn as unknown as Record).mapFromDriverValue = + isoTimestamp; + } meta.engineColumn = engineColumn as unknown as MutableColumnMeta["engineColumn"]; } diff --git a/packages/appkit/src/database/schema-builder/tests/define-schema.test.ts b/packages/appkit/src/database/schema-builder/tests/define-schema.test.ts index c4c4661ef..08fa84d18 100644 --- a/packages/appkit/src/database/schema-builder/tests/define-schema.test.ts +++ b/packages/appkit/src/database/schema-builder/tests/define-schema.test.ts @@ -25,6 +25,18 @@ import type { EngineTable } from "../types"; const pgOf = (table: EngineTable): PgTable => table as unknown as PgTable; describe("defineSchema finalization", () => { + it.each(["sql", "transaction"])( + "rejects DatabaseExports root key %s", + (name) => { + expect(() => + defineSchema(({ table }) => { + const reserved = table(name, { value: text() }); + return { [name]: reserved }; + }), + ).toThrow(/reserved/); + }, + ); + const schema = defineSchema((builder) => ({ users: builder.table("users", { id: id(), @@ -283,6 +295,49 @@ describe("builder reuse and engine metadata", () => { expect(column.default).toBe(value); }); + it.each([ + // text protocol, as a direct select returns it + ["2026-06-29 19:05:19.051709+00", "2026-06-29T19:05:19.051709+00:00"], + ["2026-06-29 19:05:19+05:30", "2026-06-29T19:05:19+05:30"], + ["2026-06-29 19:05:19-03", "2026-06-29T19:05:19-03:00"], + // JSON aggregate, as a relation include returns it + ["2026-06-29T19:05:19.051709+00:00", "2026-06-29T19:05:19.051709+00:00"], + // no timezone declared + ["2026-06-29 19:05:19.051709", "2026-06-29T19:05:19.051709"], + // non-timestamp sentinels stay untouched + ["infinity", "infinity"], + ["-infinity", "-infinity"], + ])("decodes timestamp %s as ISO-8601", (driverValue, expected) => { + const schema = defineSchema((builder) => ({ + records: builder.table("records", { + occurredAt: timestamp({ withTimezone: true }), + }), + })); + const [column] = getTableConfig( + pgOf(schema.$tables.records.$engine), + ).columns; + expect(column.mapFromDriverValue(driverValue as never)).toBe(expected); + }); + + it("returns the same timestamp shape from a direct read and a relation", () => { + const schema = defineSchema((builder) => ({ + records: builder.table("records", { + occurredAt: timestamp({ withTimezone: true }), + }), + })); + const [column] = getTableConfig( + pgOf(schema.$tables.records.$engine), + ).columns; + const fromSelect = column.mapFromDriverValue( + "2026-06-29 19:05:19.051709+00" as never, + ); + const fromInclude = column.mapFromDriverValue( + "2026-06-29T19:05:19.051709+00:00" as never, + ); + expect(fromSelect).toBe(fromInclude); + expect(Number.isNaN(Date.parse(fromSelect as string))).toBe(false); + }); + it("validates literal defaults against storage and enum values", () => { const schema = defineSchema((builder) => ({ records: builder.table("records", { diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index eac0b27b9..548151ed5 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -38,6 +38,7 @@ export { } from "./connectors/lakebase"; export { getExecutionContext } from "./context"; export { createApp } from "./core"; +export type { DatabaseRegistry } from "./database/contract"; // Errors export { AppKitError, diff --git a/packages/appkit/src/plugins/beta-exports.generated.ts b/packages/appkit/src/plugins/beta-exports.generated.ts index 7e556ebd9..10982785b 100644 --- a/packages/appkit/src/plugins/beta-exports.generated.ts +++ b/packages/appkit/src/plugins/beta-exports.generated.ts @@ -7,3 +7,4 @@ export { agents } from "./agents"; export { aiSearch } from "./ai-search"; +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..f5109b7f2 --- /dev/null +++ b/packages/appkit/src/plugins/database/database.ts @@ -0,0 +1,95 @@ +import type { BasePluginConfig, PluginConstructor } from "shared"; +import { DatabasePluginError } from "../../database/errors"; +import type { Schema } from "../../database/schema-builder"; +import { Plugin } from "../../plugin"; +import type { PluginManifest } from "../../registry"; +import type { DatabaseExports } from "./entity-types"; +import { createDatabaseState, type DatabaseState } from "./lifecycle"; +import manifest from "./manifest.json"; +import type { IDatabaseConfig } from "./types"; + +/** Schema-driven database plugin */ +export class DatabasePlugin extends Plugin< + IDatabaseConfig +> { + /** Plugin metadata and required PostgreSQL resource. */ + static manifest = manifest as PluginManifest<"database">; + protected declare config: IDatabaseConfig; + private state: DatabaseState | null = null; + private setupPromise: Promise | null = null; + private draining = false; + private shutdownPromise: Promise | null = null; + + constructor(config: IDatabaseConfig) { + super({ schema: config.schema }); + this.config = { schema: config.schema }; + } + + /** Build and verify one candidate state before publishing its exports. */ + async setup(): Promise { + if (this.draining || this.state) + throw new DatabasePluginError("SETUP_FAILED", "setup"); + if (!this.setupPromise) { + const attempt = (async () => { + const candidate = await createDatabaseState( + this.config.schema, + (operation, options) => this.execute(operation, options), + ); + if (this.draining) { + // Setup may finish while shutdown is waiting; never publish that state. + candidate.deactivate(); + await candidate.pool.end().catch(() => undefined); + throw new DatabasePluginError("SETUP_FAILED", "setup"); + } + this.state = candidate; + })(); + this.setupPromise = attempt; + } + return this.setupPromise; + } + + /** Return the typed database API only while the plugin is active. */ + exports() { + if (!this.state || this.draining) + throw new DatabasePluginError("INTERNAL", "read"); + // AppKit binds exported functions onto this object on every access. + return Object.assign( + Object.create(null), + this.state.exports, + ) as DatabaseExports; + } + + /** Stop new work, wait for setup, and close the owned pool exactly once. */ + async shutdown(): Promise { + if (this.shutdownPromise) return this.shutdownPromise; + this.draining = true; + this.shutdownPromise = (async () => { + await this.setupPromise?.catch(() => undefined); + const state = this.state; + state?.deactivate(); + this.state = null; + if (state) { + try { + await state.pool.end(); + } catch { + throw new DatabasePluginError("INTERNAL", "shutdown"); + } + } + })(); + return this.shutdownPromise; + } +} + +/** Create a typed database plugin registration for a finalized schema. */ +export function database( + config: IDatabaseConfig, +) { + return { + plugin: DatabasePlugin as unknown as PluginConstructor< + BasePluginConfig, + DatabasePlugin + >, + config, + name: "database" as const, + }; +} diff --git a/packages/appkit/src/plugins/database/defaults.ts b/packages/appkit/src/plugins/database/defaults.ts new file mode 100644 index 000000000..68fdcef63 --- /dev/null +++ b/packages/appkit/src/plugins/database/defaults.ts @@ -0,0 +1,12 @@ +import type { PluginExecuteConfig } from "shared"; + +/** Default interceptor policy for bounded reads. */ +export const databaseReadDefaults: PluginExecuteConfig = { + retry: { enabled: false }, +}; + +/** Default interceptor policy for mutations. */ +export const databaseWriteDefaults: PluginExecuteConfig = { + cache: { enabled: false }, + retry: { enabled: false }, +}; diff --git a/packages/appkit/src/plugins/database/entity-client.ts b/packages/appkit/src/plugins/database/entity-client.ts new file mode 100644 index 000000000..4d0b3d48e --- /dev/null +++ b/packages/appkit/src/plugins/database/entity-client.ts @@ -0,0 +1,246 @@ +import type { PluginExecuteConfig } from "shared"; +import { + classifyDatabaseError, + DatabasePluginError, + databaseErrorFromStatus, +} from "../../database/errors"; +import type { + DataPath, + IdValue, + IncludeSpec, + OrderSpec, + QuerySpec, + Row, + WhereClause, +} from "../../database/runtime"; +import { + andWhere, + validateLimit, + validateOffset, +} from "../../database/runtime/data-path"; +import type { AppKitTable } from "../../database/schema-builder"; +import type { ExecutionResult } from "../../plugin/execution-result"; +import { databaseReadDefaults, databaseWriteDefaults } from "./defaults"; + +/** Apply AppKit execution policy to an entity operation. */ +export type EntityExecute = ( + fn: (signal?: AbortSignal) => Promise, + options: { default: PluginExecuteConfig; user?: PluginExecuteConfig }, +) => Promise>; + +/** Runtime dependencies shared by immutable clients for one table. */ +export interface EntityClientContext { + readonly table: AppKitTable; + readonly getDataPath: () => DataPath; + readonly execute: EntityExecute; + readonly assertActive: () => void; +} + +/** Clone plain acyclic input so caller mutation cannot change a built query. */ +function snapshotPlain(value: T, seen = new Set()): T { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" || + typeof value === "bigint" || + (typeof value === "number" && Number.isFinite(value)) + ) { + return value; + } + if (typeof value !== "object") { + throw new DatabasePluginError("INVALID_REQUEST", "read"); + } + if (seen.has(value)) throw new DatabasePluginError("INVALID_REQUEST", "read"); + if ( + !Array.isArray(value) && + Object.getPrototypeOf(value) !== Object.prototype + ) + throw new DatabasePluginError("INVALID_REQUEST", "read"); + seen.add(value); + const copy = ( + Array.isArray(value) + ? value.map((item) => snapshotPlain(item, seen)) + : Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + snapshotPlain(item, seen), + ]), + ) + ) as T; + seen.delete(value); + return copy; +} + +/** Report eager fluent-input failures through the public read phase. */ +function validateReadInput(validate: () => T): T { + try { + return validate(); + } catch (error) { + throw classifyDatabaseError(error, "read"); + } +} + +/** + * Plugin-facing client that composes immutable query state. The DataPath remains + * the schema-aware validation and execution boundary. + */ +export class EntityClient { + constructor( + private readonly ctx: EntityClientContext, + private readonly state: QuerySpec = {}, + ) {} + + private clone(state: QuerySpec): EntityClient { + return new EntityClient(this.ctx, state); + } + + /** Snapshot and compose predicates; the adapter validates columns/operators. */ + where(filter: WhereClause): EntityClient { + const snapshot = snapshotPlain(filter); + return this.clone({ + ...this.state, + where: andWhere(this.state.where, snapshot), + }); + } + + /** Merge ordering, replacing the direction for repeated columns. */ + order(order: OrderSpec): EntityClient { + const snapshot = snapshotPlain(order); + return this.clone({ + ...this.state, + order: { ...this.state.order, ...snapshot }, + }); + } + + select(columns: readonly string[]): EntityClient { + return this.clone({ ...this.state, select: snapshotPlain(columns) }); + } + + /** Merge relation includes, replacing repeated relation options. */ + include(include: IncludeSpec): EntityClient { + const snapshot = snapshotPlain(include); + return this.clone({ + ...this.state, + include: { ...this.state.include, ...snapshot }, + }); + } + + limit(limit: number): EntityClient { + return validateReadInput(() => + this.clone({ ...this.state, limit: validateLimit(limit) }), + ); + } + + offset(offset: number): EntityClient { + return validateReadInput(() => + this.clone({ ...this.state, offset: validateOffset(offset) }), + ); + } + + /** Execute a collection read; DataPath applies the default upper bound. */ + async toArray(): Promise { + return this.run("read", (dataPath) => + dataPath.select(this.ctx.table, this.state), + ); + } + + async first(): Promise { + return (await this.limit(1).toArray())[0] ?? null; + } + + find(id: IdValue): Promise { + return this.run("read", (dataPath) => + dataPath.findOne(this.ctx.table, id, { + where: this.state.where, + select: this.state.select, + include: this.state.include, + }), + ); + } + + count(): Promise { + return this.run("read", (dataPath) => + dataPath.count(this.ctx.table, this.state.where), + ); + } + + create(values: Row): Promise { + return this.write("create", values, (dataPath, payload) => + dataPath.insert(this.ctx.table, payload), + ); + } + + update(id: IdValue, values: Row): Promise { + return this.write("update", values, (dataPath, payload) => + dataPath.update(this.ctx.table, id, payload), + ); + } + + upsert(values: Row, options: { onConflict: string }): Promise { + const onConflict = options.onConflict; + return this.write("create", values, (dataPath, payload) => + dataPath.upsert(this.ctx.table, payload, onConflict), + ); + } + + delete(id: IdValue): Promise { + return this.run( + "write", + (dataPath) => dataPath.delete(this.ctx.table, id), + databaseWriteDefaults, + ); + } + + /** Validate caller values against the finalized trusted-write schema. */ + private async write( + kind: "create" | "update", + values: Row, + operation: (dataPath: DataPath, values: Row) => Promise, + ): Promise { + if (kind === "update" && Object.keys(values).length === 0) { + throw new DatabasePluginError("INVALID_REQUEST", "write"); + } + let parsed: Row; + try { + const validator = ( + kind === "create" + ? this.ctx.table.$insertSchema + : this.ctx.table.$updateSchema + ) as { parse(value: unknown): Row }; + parsed = validator.parse(values); + } catch { + throw new DatabasePluginError("INVALID_REQUEST", "write"); + } + return this.run( + "write", + (dataPath) => operation(dataPath, parsed), + databaseWriteDefaults, + ); + } + + /** Execute through AppKit interceptors and expose only safe failures. */ + private async run( + phase: "read" | "write", + operation: (dataPath: DataPath) => Promise, + defaults: PluginExecuteConfig = databaseReadDefaults, + ): Promise { + try { + this.ctx.assertActive(); + } catch (error) { + throw classifyDatabaseError(error, phase); + } + const result = await this.ctx.execute( + async () => { + try { + this.ctx.assertActive(); + return await operation(this.ctx.getDataPath()); + } catch (error) { + throw classifyDatabaseError(error, phase); + } + }, + { default: defaults }, + ); + if (result.ok) return result.data; + throw databaseErrorFromStatus(result.status, phase); + } +} diff --git a/packages/appkit/src/plugins/database/entity-types.ts b/packages/appkit/src/plugins/database/entity-types.ts new file mode 100644 index 000000000..927236870 --- /dev/null +++ b/packages/appkit/src/plugins/database/entity-types.ts @@ -0,0 +1,215 @@ +import type { + DatabaseRegistry, + DatabaseRegistryEntry, + IdValue, + OrderDirection, +} from "../../database/contract"; + +/** Registry shape accepted by reusable entity type helpers. */ +type RegistryShape = { + readonly [K in keyof R]: DatabaseRegistryEntry; +}; + +export type EntityNameFor> = Extract< + keyof R, + string +>; +type EntryFor< + R extends RegistryShape, + K extends EntityNameFor, +> = R[K] extends DatabaseRegistryEntry ? R[K] : never; +type RowOfFor< + R extends RegistryShape, + K extends EntityNameFor, +> = EntryFor["row"]; +type PublicRowOfFor< + R extends RegistryShape, + K extends EntityNameFor, +> = EntryFor["publicRow"]; +type InsertOfFor< + R extends RegistryShape, + K extends EntityNameFor, +> = EntryFor["insert"]; +type UpdateOfFor< + R extends RegistryShape, + K extends EntityNameFor, +> = EntryFor["update"]; +type FiltersOfFor< + R extends RegistryShape, + K extends EntityNameFor, +> = EntryFor["filters"]; +type IncludesOfFor< + R extends RegistryShape, + K extends EntityNameFor, +> = EntryFor["includes"]; + +export type EntityName = EntityNameFor; +type PublicRowOf = PublicRowOfFor; + +// Resolve generated relation metadata into include arguments and result types. +type RelationTargetFor< + Registry extends RegistryShape, + K extends EntityNameFor, + Relation extends keyof IncludesOfFor, +> = IncludesOfFor[Relation] extends { + to: infer Target extends EntityNameFor; +} + ? Target + : never; + +export type IncludeOptionsFor< + Registry extends RegistryShape, + K extends EntityNameFor, + Many extends boolean, +> = { + readonly select?: readonly (keyof RowOfFor & string)[]; + readonly where?: FiltersOfFor; + readonly order?: Partial< + Record & string, OrderDirection> + >; +} & (Many extends true + ? { readonly limit?: number } + : { readonly limit?: never }); + +export type IncludeArgFor< + Registry extends RegistryShape, + K extends EntityNameFor, +> = { + readonly [Relation in keyof IncludesOfFor]?: + | boolean + | IncludeOptionsFor< + Registry, + RelationTargetFor, + IncludesOfFor[Relation] extends { + many: infer Many extends boolean; + } + ? Many + : false + >; +}; + +// Explicit relation projections use trusted rows; implicit projections stay public. +type IncludedRowFor< + Registry extends RegistryShape, + K extends EntityNameFor, + Config, +> = Config extends { + readonly select: infer Columns extends readonly (keyof RowOfFor & + string)[]; +} + ? Pick, Columns[number]> + : PublicRowOfFor; + +type IncludedResultFor< + Registry extends RegistryShape, + K extends EntityNameFor, + I, +> = I extends Record + ? { + [Relation in keyof I & + keyof IncludesOfFor as I[Relation] extends false + ? never + : Relation]: IncludesOfFor[Relation] extends { + to: infer Target extends EntityNameFor; + many: infer Many; + } + ? Many extends true + ? IncludedRowFor[] + : IncludedRowFor | null + : never; + } + : Record; + +export type EntityResultFor< + Registry extends RegistryShape, + K extends EntityNameFor, + Selected, + I, +> = Selected & IncludedResultFor; + +/** Fluent entity API shared by keyed and keyless tables. */ +export interface CommonEntityClientFor< + Registry extends RegistryShape, + K extends EntityNameFor, + Selected = PublicRowOfFor, + I = Record, +> { + where( + filter: FiltersOfFor, + ): TypedEntityClientFor; + order( + order: Partial< + Record & string, OrderDirection> + >, + ): TypedEntityClientFor; + select & string)[]>( + columns: C, + ): TypedEntityClientFor< + Registry, + K, + Pick, C[number]>, + I + >; + include>( + include: Next, + ): TypedEntityClientFor & Next>; + limit(limit: number): TypedEntityClientFor; + offset(offset: number): TypedEntityClientFor; + toArray(): Promise>>; + first(): Promise | null>; + count(): Promise; + create(values: InsertOfFor): Promise>; + upsert( + values: InsertOfFor, + options: { onConflict: keyof RowOfFor & string }, + ): Promise>; +} + +/** Add keyed operations only when typegen records a primary key. */ +type KeyedEntityClientFor< + Registry extends RegistryShape, + K extends EntityNameFor, + Selected, + I, +> = EntryFor["hasPrimaryKey"] extends true + ? { + find( + id: IdValue, + ): Promise | null>; + update( + id: IdValue, + values: UpdateOfFor, + ): Promise | null>; + delete(id: IdValue): Promise; + } + : Record; + +export type TypedEntityClientFor< + Registry extends RegistryShape, + K extends EntityNameFor, + Selected = PublicRowOfFor, + I = Record, +> = CommonEntityClientFor & + KeyedEntityClientFor; + +export type TypedEntityClient< + K extends EntityName, + Selected = PublicRowOf, + I = Record, +> = TypedEntityClientFor; + +/** Parameterized SQL tag whose interpolations are always bound values. */ +export type SqlTag = >( + strings: TemplateStringsArray, + ...values: unknown[] +) => Promise; + +/** Entity and SQL capabilities bound to one transaction. */ +export type TransactionClient = { + readonly [K in EntityName]: TypedEntityClient; +} & { readonly sql: SqlTag }; + +/** Typed database API published by the plugin. */ +export type DatabaseExports = TransactionClient & { + transaction(callback: (tx: TransactionClient) => Promise): Promise; +}; diff --git a/packages/appkit/src/plugins/database/index.ts b/packages/appkit/src/plugins/database/index.ts new file mode 100644 index 000000000..48947ad32 --- /dev/null +++ b/packages/appkit/src/plugins/database/index.ts @@ -0,0 +1,3 @@ +export { database } from "./database"; +export type { DatabaseExports } from "./entity-types"; +export type { IDatabaseConfig } from "./types"; diff --git a/packages/appkit/src/plugins/database/lifecycle.ts b/packages/appkit/src/plugins/database/lifecycle.ts new file mode 100644 index 000000000..d4009ba24 --- /dev/null +++ b/packages/appkit/src/plugins/database/lifecycle.ts @@ -0,0 +1,149 @@ +import { createLakebasePool } from "../../connectors/lakebase"; +import { + classifyDatabaseError, + DatabasePluginError, +} from "../../database/errors"; +import type { DataPath } from "../../database/runtime"; +import { + createDrizzleDataPath, + createDrizzleDb, +} from "../../database/runtime/engine/drizzle-data-path"; +import type { Schema } from "../../database/schema-builder"; +import { assertFinalizedSchema } from "../../database/schema-builder/define-schema"; +import { createLogger } from "../../logging/logger"; +import { EntityClient, type EntityExecute } from "./entity-client"; +import type { + DatabaseExports, + SqlTag, + TransactionClient, +} from "./entity-types"; + +const logger = createLogger("database"); + +/** Resources owned by one successfully initialized plugin instance. */ +export interface DatabaseState { + readonly pool: ReturnType; + readonly exports: DatabaseExports; + readonly deactivate: () => void; +} + +interface ExportContext { + readonly schema: Schema; + readonly getDataPath: () => DataPath; + readonly execute: EntityExecute; + readonly assertActive: () => void; +} + +/** Execute value-only SQL on the bound DataPath without per-statement retries. */ +function buildSql(context: ExportContext): SqlTag { + return async >( + strings: TemplateStringsArray, + ...values: unknown[] + ) => { + try { + context.assertActive(); + // DataPath accepts value interpolation only and keeps Drizzle private. + return await context.getDataPath().raw(strings, ...values); + } catch (error) { + throw classifyDatabaseError(error, "read"); + } + }; +} + +/** Build entities and SQL bound to either the root pool or one transaction. */ +function buildTransactionClient(context: ExportContext): TransactionClient { + const result: Record = Object.create(null); + for (const [name, table] of Object.entries(context.schema.$tables)) { + result[name] = new EntityClient({ + table, + getDataPath: context.getDataPath, + execute: context.execute, + assertActive: context.assertActive, + }); + } + result.sql = buildSql(context); + return result as TransactionClient; +} + +/** Add the transaction entry point to the root database surface. */ +function buildDatabaseExports(context: ExportContext): DatabaseExports { + const result = buildTransactionClient(context) as DatabaseExports; + result.transaction = async ( + callback: (tx: TransactionClient) => Promise, + ) => { + try { + context.assertActive(); + return await context.getDataPath().transaction(async (txDataPath) => { + let active = true; + const assertTransactionActive = () => { + context.assertActive(); + if (!active) throw new DatabasePluginError("INTERNAL", "transaction"); + }; + // The outer transaction owns execution; inner clients use its connection. + const directExecute: EntityExecute = async (operation) => ({ + ok: true, + data: await operation(), + }); + const tx = buildTransactionClient({ + ...context, + getDataPath: () => txDataPath, + execute: directExecute, + assertActive: assertTransactionActive, + }); + try { + return await callback(tx); + } finally { + // Captured clients must not outlive the transaction callback. + active = false; + } + }); + } catch (error) { + throw classifyDatabaseError(error, "transaction"); + } + }; + return result; +} + +/** Validate the schema, create one pool-backed API, and verify connectivity. */ +export async function createDatabaseState( + schema: TSchema, + execute: EntityExecute, +): Promise { + try { + assertFinalizedSchema(schema); + } catch (error) { + logger.error("Database schema failed validation: %O", error); + throw new DatabasePluginError("SETUP_FAILED", "setup"); + } + + let active = true; + let pool: ReturnType | undefined; + const assertActive = () => { + if (!active) throw new DatabasePluginError("INTERNAL", "runtime"); + }; + try { + pool = createLakebasePool(); + const db = createDrizzleDb(pool, schema); + const dataPath = createDrizzleDataPath(db, schema); + const exports = buildDatabaseExports({ + schema, + getDataPath: () => dataPath, + execute, + assertActive, + }); + // Do not publish exports until an authenticated statement succeeds. + await dataPath.raw`select 1`; + return { + pool, + exports, + deactivate: () => { + active = false; + }, + }; + } catch (error) { + logger.error("Database setup failed: %O", error); + active = false; + await pool?.end().catch(() => undefined); + throw new DatabasePluginError("SETUP_FAILED", "setup"); + } +} diff --git a/packages/appkit/src/plugins/database/manifest.json b/packages/appkit/src/plugins/database/manifest.json new file mode 100644 index 000000000..517422b00 --- /dev/null +++ b/packages/appkit/src/plugins/database/manifest.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + "name": "database", + "displayName": "Database (Beta)", + "description": "Schema-driven typed access to Databricks Lakebase PostgreSQL", + "stability": "beta", + "hidden": false, + "resources": { + "required": [ + { + "type": "postgres", + "alias": "Postgres", + "resourceKey": "postgres", + "description": "Lakebase Postgres database for persistent storage", + "permission": "CAN_CONNECT_AND_CREATE", + "fields": { + "project": { + "description": "Lakebase project resource name", + "examples": ["projects/{project-id}"], + "discovery": { + "type": "kind", + "resourceKind": "postgres_project", + "select": "name" + } + }, + "branch": { + "description": "Lakebase branch resource name", + "examples": ["projects/{project-id}/branches/{branch-id}"], + "discovery": { + "type": "kind", + "resourceKind": "postgres_branch", + "select": "name", + "dependsOn": "project" + } + }, + "database": { + "description": "Lakebase database resource name", + "examples": [ + "projects/{project-id}/branches/{branch-id}/databases/{database-id}" + ], + "discovery": { + "type": "kind", + "resourceKind": "postgres_database", + "select": "name", + "dependsOn": "branch" + } + }, + "host": { + "env": "PGHOST", + "localOnly": true, + "resolve": "postgres:host", + "description": "Postgres host" + }, + "databaseName": { + "env": "PGDATABASE", + "localOnly": true, + "resolve": "postgres:databaseName", + "description": "Postgres database name" + }, + "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": [] + } +} diff --git a/packages/appkit/src/plugins/database/tests/entity-client.test.ts b/packages/appkit/src/plugins/database/tests/entity-client.test.ts new file mode 100644 index 000000000..78ad42950 --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/entity-client.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, test, vi } from "vitest"; +import { MAX_LIMIT } from "../../../database/contract"; +import type { + DataPath, + QuerySpec, + Row, + WhereClause, +} from "../../../database/runtime"; +import { defineSchema, id, text } from "../../../database/schema-builder"; +import { EntityClient, type EntityClientContext } from "../entity-client"; + +const schema = defineSchema(({ table }) => { + const notes = table("notes", { id: id(), body: text().notNull() }); + return { notes }; +}); + +function harness() { + const calls: Array<[string, unknown]> = []; + const dataPath: DataPath = { + select: async (_table, spec) => { + calls.push(["select", spec]); + return [{ id: 1, body: "a" }]; + }, + findOne: async (_table, _id, spec) => { + calls.push(["findOne", spec]); + return { id: 1, body: "a" }; + }, + count: async (_table, where) => { + calls.push(["count", where]); + return 1; + }, + insert: async (_table, values) => { + calls.push(["insert", values]); + return { id: 1, ...values }; + }, + update: async (_table, _id, values) => { + calls.push(["update", values]); + return { id: 1, ...values }; + }, + upsert: async (_table, values, target) => { + calls.push(["upsert", target]); + return { id: 1, ...values }; + }, + delete: async () => { + calls.push(["delete", undefined]); + return true; + }, + raw: async () => [], + transaction: async (callback) => callback(dataPath), + }; + const context: EntityClientContext = { + table: schema.$tables.notes, + getDataPath: () => dataPath, + assertActive: vi.fn(), + execute: async (operation) => ({ ok: true, data: await operation() }), + }; + return { client: new EntityClient(context), calls }; +} + +describe("EntityClient", () => { + test("retains accumulated predicates in find", async () => { + const { client, calls } = harness(); + const filter = { body: { eq: "a" } } as WhereClause; + await client.where(filter).find(1); + expect((calls[0][1] as Pick).where).toEqual(filter); + }); + + test("drives every fluent method and read terminal without mutating the root", async () => { + const { client, calls } = harness(); + const query = client + .where({ body: { eq: "a" } }) + .where({ id: { gt: 0 } }) + .order({ body: "asc" }) + .select(["id"]) + .include({ notes: true }) + .limit(2) + .offset(1); + await query.toArray(); + await query.first(); + await query.find(1); + await query.count(); + expect(calls.map(([name]) => name)).toEqual([ + "select", + "select", + "findOne", + "count", + ]); + expect(calls[0][1]).toMatchObject({ + where: { + and: [{ body: { eq: "a" } }, { id: { gt: 0 } }], + }, + order: { body: "asc" }, + select: ["id"], + include: { notes: true }, + limit: 2, + offset: 1, + }); + expect(calls[2][1]).toMatchObject({ + where: { + and: [{ body: { eq: "a" } }, { id: { gt: 0 } }], + }, + select: ["id"], + include: { notes: true }, + }); + }); + + test.each([ + ["limit", -1], + ["limit", 1.5], + ["limit", MAX_LIMIT + 1], + ["limit", Number.MAX_SAFE_INTEGER + 1], + ["offset", -1], + ["offset", 1.5], + ["offset", Number.NaN], + ["offset", Number.MAX_SAFE_INTEGER + 1], + ] as const)("maps invalid %s %s to a safe read error", (method, value) => { + const { client } = harness(); + expect(() => client[method](value)).toThrowError( + expect.objectContaining({ + category: "INVALID_REQUEST", + phase: "read", + statusCode: 400, + }), + ); + }); + + test("accepts exact limit and offset boundaries", async () => { + const { client, calls } = harness(); + await client.limit(0).offset(0).toArray(); + await client.limit(MAX_LIMIT).offset(Number.MAX_SAFE_INTEGER).toArray(); + expect(calls.map(([, value]) => value)).toEqual([ + expect.objectContaining({ limit: 0, offset: 0 }), + expect.objectContaining({ + limit: MAX_LIMIT, + offset: Number.MAX_SAFE_INTEGER, + }), + ]); + }); + + test.each([ + [400, "INVALID_REQUEST"], + [403, "FORBIDDEN"], + [409, "CONFLICT"], + [500, "INTERNAL"], + ] as const)("maps executor status %s", async (status, category) => { + const failing = new EntityClient({ + table: schema.$tables.notes, + getDataPath: () => { + throw new Error("must not run"); + }, + assertActive: vi.fn(), + execute: async () => ({ ok: false, status, message: "safe" }), + }); + await expect(failing.toArray()).rejects.toMatchObject({ + category, + phase: "read", + statusCode: status, + }); + }); + + test("rechecks activity inside delayed execution before DataPath access", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let active = true; + const getDataPath = vi.fn(() => { + throw new Error("must not access DataPath"); + }); + const client = new EntityClient({ + table: schema.$tables.notes, + getDataPath, + assertActive: () => { + if (!active) throw new Error("inactive raw detail"); + }, + execute: async (operation) => { + await gate; + return { ok: true, data: await operation() }; + }, + }); + const operation = client.toArray(); + active = false; + release(); + const error = await operation.catch((caught) => caught); + expect(error).toMatchObject({ category: "INTERNAL", phase: "read" }); + expect(error.message).not.toContain("inactive raw detail"); + expect(error.cause).toBeUndefined(); + expect(getDataPath).not.toHaveBeenCalled(); + }); + + test("snapshots fluent plain-data state", async () => { + const { client, calls } = harness(); + const filter = { body: { eq: "before" } }; + const order = { body: "asc" as const }; + const include = { notes: { where: { body: { eq: "child" } } } }; + const columns = ["body"]; + const query = client + .where(filter) + .order(order) + .include(include) + .select(columns); + filter.body.eq = "after"; + (order as { body: "asc" | "desc" }).body = "desc"; + include.notes.where.body.eq = "after"; + columns[0] = "id"; + + await query.toArray(); + expect(calls[0][1]).toMatchObject({ + where: { body: { eq: "before" } }, + order: { body: "asc" }, + include: { notes: { where: { body: { eq: "child" } } } }, + select: ["body"], + }); + }); + + test("fails safely for cyclic fluent state and empty updates", async () => { + const { client } = harness(); + const cyclic: Record = {}; + cyclic.self = cyclic; + expect(() => client.where(cyclic as WhereClause)).toThrowError(); + await expect(client.update(1, {})).rejects.toMatchObject({ + category: "INVALID_REQUEST", + }); + }); + + test("validates trusted writes and drives every mutation terminal", async () => { + const { client, calls } = harness(); + await client.create({ body: "a" }); + await client.update(1, { body: "b" }); + await client.upsert({ body: "c" }, { onConflict: "id" }); + await client.delete(1); + expect(calls.map(([name]) => name)).toEqual([ + "insert", + "update", + "upsert", + "delete", + ]); + await expect(client.create({ unknown: true } as Row)).rejects.toMatchObject( + { + category: "INVALID_REQUEST", + }, + ); + }); + + test("snapshots the upsert conflict target before deferred execution", async () => { + const { client, calls } = harness(); + const options = { onConflict: "id" }; + const operation = client.upsert({ body: "a" }, options); + options.onConflict = "body"; + await operation; + expect(calls).toContainEqual(["upsert", "id"]); + }); +}); diff --git a/packages/appkit/src/plugins/database/tests/entity-types.test.ts b/packages/appkit/src/plugins/database/tests/entity-types.test.ts new file mode 100644 index 000000000..6b90dff3d --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/entity-types.test.ts @@ -0,0 +1,255 @@ +import { describe, expectTypeOf, it } from "vitest"; +import type * as Beta from "../../../beta"; +import type { + DatabaseExports, + EntityResultFor, + IncludeArgFor, + TransactionClient, + TypedEntityClientFor, +} from "../entity-types"; + +// @ts-expect-error DataPath is an internal engine contract. +type InternalDataPath = Beta.DataPath; +// @ts-expect-error DatabasePluginError is internal. +type InternalDatabaseError = Beta.DatabasePluginError; +// @ts-expect-error Pool ownership is not part of the beta API. +type InternalPool = Beta.Pool; + +type TextFilter = + | string + | readonly string[] + | { + eq?: string; + neq?: string; + in?: readonly string[]; + like?: string; + ilike?: string; + is?: null; + }; +type NumberFilter = + | number + | readonly number[] + | { + eq?: number; + neq?: number; + in?: readonly number[]; + gt?: number; + gte?: number; + lt?: number; + lte?: number; + }; +type NoteFilters = { + body?: TextFilter; + rank?: NumberFilter; + and?: readonly NoteFilters[]; + or?: readonly NoteFilters[]; +}; + +interface TestRegistry { + notes: { + row: { id: string; body: string; secret: string | null; rank: number }; + publicRow: { id: string; body: string; rank: number }; + insert: { body: string; secret?: string | null; rank: number }; + update: { body?: string; secret?: string | null; rank?: number }; + filters: NoteFilters; + includes: { + author: { to: "users"; many: false }; + comments: { to: "comments"; many: true }; + }; + hasPrimaryKey: true; + }; + users: { + row: { id: string; name: string; token: string }; + publicRow: { id: string; name: string }; + insert: { name: string; token: string }; + update: { name?: string; token?: string }; + filters: { name?: TextFilter }; + includes: Record; + hasPrimaryKey: true; + }; + comments: { + row: { id: string; text: string; internal: string }; + publicRow: { id: string; text: string }; + insert: { text: string; internal: string }; + update: { text?: string; internal?: string }; + filters: { text?: TextFilter }; + includes: Record; + hasPrimaryKey: true; + }; + events: { + row: { message: string }; + publicRow: { message: string }; + insert: { message: string }; + update: { message?: string }; + filters: { message?: TextFilter }; + includes: Record; + hasPrimaryKey: false; + }; +} + +declare const notes: TypedEntityClientFor; +declare const events: TypedEntityClientFor; +declare const database: DatabaseExports; +declare const tx: TransactionClient; +const typecheckOnly = (): boolean => false; + +describe("typed database entity contract", () => { + it("keeps the intended beta types public and implementation types private", () => { + type PublicBetaTypes = [ + Beta.DatabaseExports, + Beta.IDatabaseConfig, + Beta.Schema, + ]; + expectTypeOf().not.toBeNever(); + expectTypeOf< + InternalDataPath | InternalDatabaseError | InternalPool + >().not.toBeNever(); + }); + + it("composes keyed and keyless entity capabilities", () => { + expectTypeOf>().toHaveProperty( + "find", + ); + expectTypeOf>().toHaveProperty( + "update", + ); + expectTypeOf>().toHaveProperty( + "delete", + ); + expectTypeOf().not.toEqualTypeOf<"find">(); + if (typecheckOnly()) { + events.where({ message: "created" }).order({ message: "asc" }); + events.select(["message"]).include({}).limit(10).offset(2); + void events.toArray(); + void events.first(); + void events.count(); + void events.create({ message: "created" }); + expectTypeOf(events).toHaveProperty("upsert"); + void events.upsert({ message: "created" }, { onConflict: "message" }); + } + }); + + it("defaults to public rows and narrows explicit root selections", () => { + expectTypeOf>>().toMatchTypeOf< + TestRegistry["notes"]["publicRow"] | null + >(); + if (typecheckOnly()) { + const selected = notes.select(["body", "secret"] as const); + expectTypeOf>>().toMatchTypeOf<{ + body: string; + secret: string | null; + } | null>(); + } + }); + + it("keeps implicit relation projections public and narrows explicit selects", () => { + type PublicAuthor = EntityResultFor< + TestRegistry, + "notes", + TestRegistry["notes"]["publicRow"], + { author: true } + >; + type FilteredAuthor = EntityResultFor< + TestRegistry, + "notes", + TestRegistry["notes"]["publicRow"], + { author: { where: { name: string }; order: { name: "asc" } } } + >; + type LimitedComments = EntityResultFor< + TestRegistry, + "notes", + TestRegistry["notes"]["publicRow"], + { comments: { limit: 2 } } + >; + type PrivateAuthor = EntityResultFor< + TestRegistry, + "notes", + TestRegistry["notes"]["publicRow"], + { author: { select: readonly ["token"] } } + >; + + expectTypeOf>().toEqualTypeOf< + TestRegistry["users"]["publicRow"] + >(); + expectTypeOf>().toEqualTypeOf< + TestRegistry["users"]["publicRow"] + >(); + expectTypeOf().toEqualTypeOf< + TestRegistry["comments"]["publicRow"][] + >(); + expectTypeOf>().toEqualTypeOf<{ + token: string; + }>(); + }); + + it("omits false relations and replaces successive include configurations", () => { + if (typecheckOnly()) { + const included = notes + .include({ author: { select: ["token"] } }) + .include({ comments: true }) + .include({ author: false }); + type Result = Awaited>; + expectTypeOf>().not.toHaveProperty("author"); + expectTypeOf>().toHaveProperty("comments"); + } + }); + + it("accepts the supported filter and include grammar", () => { + if (typecheckOnly()) { + notes.where({ body: "a", rank: [1, 2] }); + notes.where({ body: { ilike: "%a%", is: null }, rank: { gte: 1 } }); + notes.where({ + and: [{ body: { in: ["a"] } }], + or: [{ rank: { lt: 10 } }], + }); + notes.include({ + author: { where: { name: "Ada" }, order: { name: "asc" } }, + }); + notes.include({ comments: { limit: 5 } }); + + // @ts-expect-error direct null is not a filter shorthand + notes.where({ body: null }); + // @ts-expect-error text filters do not support range operators + notes.where({ body: { gt: "a" } }); + // @ts-expect-error number filters do not support pattern operators + notes.where({ rank: { like: "1" } }); + // @ts-expect-error JSON and unknown fields are not filterable + notes.where({ payload: { eq: {} } }); + // @ts-expect-error unknown relations fail closed + notes.include({ unknown: true }); + // @ts-expect-error unknown relation options fail closed + notes.include({ author: { offset: 1 } }); + // @ts-expect-error to-one includes cannot be limited + notes.include({ author: { limit: 1 } }); + // @ts-expect-error nested includes are not part of one-edge options + notes.include({ comments: { include: { author: true } } }); + } + }); + + it("restricts upsert targets to declared columns", () => { + if (typecheckOnly()) { + void notes.upsert({ body: "created", rank: 1 }, { onConflict: "id" }); + void notes.upsert({ body: "created", rank: 1 }, { onConflict: "body" }); + void notes.upsert( + { body: "created", rank: 1 }, + // @ts-expect-error unknown columns cannot be conflict targets + { onConflict: "missing" }, + ); + } + }); + + it("does not expose deferred or unsafe APIs", () => { + if (typecheckOnly()) { + // @ts-expect-error raw pool access is intentionally absent + database.getPool(); + // @ts-expect-error entity reads are always bounded + notes.unbounded(); + // @ts-expect-error transaction clients cannot open nested transactions + tx.transaction(async () => undefined); + } + }); + + it("keeps relationless include keys empty", () => { + expectTypeOf>().toBeNever(); + }); +}); diff --git a/packages/appkit/src/plugins/database/tests/lifecycle.test.ts b/packages/appkit/src/plugins/database/tests/lifecycle.test.ts new file mode 100644 index 000000000..27c577030 --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/lifecycle.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, test, vi } from "vitest"; +import { DatabasePluginError } from "../../../database/errors"; +import type { DataPath, Row } from "../../../database/runtime"; +import { defineSchema, id, text } from "../../../database/schema-builder"; + +const mocks = vi.hoisted(() => ({ + createLakebasePool: vi.fn(), + createDrizzleDb: vi.fn(), + createDrizzleDataPath: vi.fn(), +})); + +vi.mock("../../../connectors/lakebase", () => ({ + createLakebasePool: mocks.createLakebasePool, +})); +vi.mock("../../../database/runtime/engine/drizzle-data-path", () => ({ + createDrizzleDb: mocks.createDrizzleDb, + createDrizzleDataPath: mocks.createDrizzleDataPath, +})); + +import { createDatabaseState } from "../lifecycle"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const schema = defineSchema(({ table }) => { + const notes = table("notes", { id: id(), body: text().notNull() }); + const tags = table("tags", { id: id(), label: text() }); + return { notes, tags }; +}); + +function fakePath(overrides: Partial = {}): DataPath { + const path: DataPath = { + select: vi.fn(async () => []), + findOne: vi.fn(async () => null), + count: vi.fn(async () => 0), + insert: vi.fn(async (_table, values) => ({ id: 1, ...values })), + update: vi.fn(async (_table, id, values) => ({ id, ...values })), + upsert: vi.fn(async (_table, values) => ({ id: 1, ...values })), + delete: vi.fn(async () => true), + raw: vi.fn(async () => []), + transaction: vi.fn(async (callback) => callback(path)), + ...overrides, + }; + return path; +} + +type TestEntity = { + create(values: Row): Promise; + toArray(): Promise; +}; +type TestExports = { + notes: TestEntity; + sql: DataPath["raw"]; + transaction( + callback: (tx: { notes: TestEntity; sql: DataPath["raw"] }) => Promise, + ): Promise; +}; + +function arrange(path = fakePath()) { + const pool = { end: vi.fn(async () => undefined) }; + const db = { marker: Symbol("db") }; + mocks.createLakebasePool.mockReturnValue(pool); + mocks.createDrizzleDb.mockReturnValue(db); + mocks.createDrizzleDataPath.mockReturnValue(path); + const execute = vi.fn(async (operation) => ({ + ok: true as const, + data: await operation(), + })); + return { pool, db, path, execute }; +} + +describe("createDatabaseState", () => { + test("accepts authentic populated and empty schemas but rejects a forgery before allocation", async () => { + arrange(); + await expect( + createDatabaseState(schema, arrange().execute), + ).resolves.toBeDefined(); + await expect( + createDatabaseState( + defineSchema(() => ({})), + arrange().execute, + ), + ).resolves.toBeDefined(); + mocks.createLakebasePool.mockClear(); + await expect( + createDatabaseState( + { $tables: Object.create(null) } as typeof schema, + arrange().execute, + ), + ).rejects.toMatchObject({ category: "SETUP_FAILED", phase: "setup" }); + expect(mocks.createLakebasePool).not.toHaveBeenCalled(); + }); + + test("builds one default runtime, all entities, and waits for readiness", async () => { + const ready = deferred(); + const path = fakePath({ + raw: vi.fn(async () => ready.promise) as unknown as DataPath["raw"], + }); + const { pool, db, execute } = arrange(path); + const pending = createDatabaseState(schema, execute); + await vi.waitFor(() => expect(path.raw).toHaveBeenCalledTimes(1)); + let settled = false; + void pending.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + ready.resolve([]); + const state = await pending; + expect(mocks.createLakebasePool).toHaveBeenCalledTimes(1); + expect(mocks.createLakebasePool).toHaveBeenCalledWith(); + expect(mocks.createDrizzleDb).toHaveBeenCalledWith(pool, schema); + expect(mocks.createDrizzleDataPath).toHaveBeenCalledWith(db, schema); + expect(Object.keys(state.exports).sort()).toEqual([ + "notes", + "sql", + "tags", + "transaction", + ]); + expect(state.exports).not.toHaveProperty("getPool"); + expect((state.exports as unknown as TestExports).notes).not.toHaveProperty( + "unbounded", + ); + }); + + test.each(["drizzle", "dataPath", "readiness"] as const)( + "closes and sanitizes %s construction failures", + async (stage) => { + const { pool, execute } = arrange(); + const raw = new Error("secret constraint detail"); + if (stage === "drizzle") + mocks.createDrizzleDb.mockImplementationOnce(() => { + throw raw; + }); + if (stage === "dataPath") + mocks.createDrizzleDataPath.mockImplementationOnce(() => { + throw raw; + }); + if (stage === "readiness") + mocks.createDrizzleDataPath.mockReturnValueOnce( + fakePath({ + raw: vi.fn(async () => { + throw new DatabasePluginError("INTERNAL", "runtime", raw.message); + }), + }), + ); + const error = await createDatabaseState(schema, execute).catch( + (value) => value, + ); + expect(error).toMatchObject({ category: "SETUP_FAILED", phase: "setup" }); + expect(error.message).toBe("Database setup failed"); + expect(error.cause).toBeUndefined(); + expect(pool.end).toHaveBeenCalledTimes(1); + }, + ); + + test("sanitizes synchronous pool construction failures", async () => { + const { execute } = arrange(); + mocks.createLakebasePool.mockImplementationOnce(() => { + throw new Error("secret host and credential details"); + }); + + const error = await createDatabaseState(schema, execute).catch( + (caught) => caught, + ); + + expect(error).toMatchObject({ category: "SETUP_FAILED", phase: "setup" }); + expect(error.message).toBe("Database setup failed"); + expect(error.cause).toBeUndefined(); + }); + + test("runs root SQL directly, maps failures safely, and rejects after deactivation", async () => { + const path = fakePath(); + const { execute } = arrange(path); + const state = await createDatabaseState(schema, execute); + await state.exports.sql`select ${1}`; + expect(path.raw).toHaveBeenCalledTimes(2); + expect(execute).not.toHaveBeenCalled(); + for (const [category, expected] of [ + ["INVALID_REQUEST", "INVALID_REQUEST"], + ["CONFLICT", "CONFLICT"], + ["FORBIDDEN", "FORBIDDEN"], + ["INTERNAL", "INTERNAL"], + ] as const) { + vi.mocked(path.raw).mockRejectedValueOnce( + new DatabasePluginError(category, "runtime", "raw secret"), + ); + await expect(state.exports.sql`bad`).rejects.toMatchObject({ + category: expected, + }); + } + state.deactivate(); + await expect(state.exports.sql`select 1`).rejects.toMatchObject({ + category: "INTERNAL", + phase: "read", + }); + }); + + test("commits, rolls back, binds tx capabilities, and expires them", async () => { + const txPath = fakePath(); + const rootPath = fakePath({ + transaction: vi.fn(async (callback) => callback(txPath)), + }); + const { execute } = arrange(rootPath); + const state = await createDatabaseState(schema, execute); + const exports = state.exports as unknown as TestExports; + let captured!: Parameters[0]>[0]; + await expect( + exports.transaction(async (tx) => { + captured = tx; + await tx.notes.create({ body: "created" }); + await tx.sql`select ${1}`; + expect(tx).not.toHaveProperty("transaction"); + return "committed"; + }), + ).resolves.toBe("committed"); + expect(rootPath.transaction).toHaveBeenCalledTimes(1); + expect(txPath.insert).toHaveBeenCalledTimes(1); + expect(txPath.raw).toHaveBeenCalledTimes(1); + await expect(captured.notes.toArray()).rejects.toMatchObject({ + category: "INTERNAL", + }); + await expect(captured.sql`select 1`).rejects.toMatchObject({ + category: "INTERNAL", + }); + + await expect( + exports.transaction(async () => { + throw new Error("rollback"); + }), + ).rejects.toMatchObject({ category: "INTERNAL" }); + }); + + test("keeps independently created states isolated", async () => { + const first = arrange(); + const stateOne = await createDatabaseState(schema, first.execute); + const second = arrange(); + const stateTwo = await createDatabaseState(schema, second.execute); + expect(stateOne.pool).not.toBe(stateTwo.pool); + expect(stateOne.exports).not.toBe(stateTwo.exports); + stateOne.deactivate(); + await expect(stateOne.exports.sql`select 1`).rejects.toBeDefined(); + await expect(stateTwo.exports.sql`select 1`).resolves.toEqual([]); + }); +}); 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..1e0704d16 --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/plugin.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, expectTypeOf, test, vi } from "vitest"; +import { defineSchema } from "../../../database/schema-builder"; + +const mocks = vi.hoisted(() => ({ createDatabaseState: vi.fn() })); +vi.mock("../lifecycle", () => ({ + createDatabaseState: mocks.createDatabaseState, +})); + +import { DatabasePlugin, database } from "../database"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const schema = defineSchema(() => ({})); +function candidate(marker = "one") { + let active = true; + const end = vi.fn<() => Promise>(async () => undefined); + return { + pool: { end }, + exports: { + marker, + operation: () => { + if (!active) throw new Error("inactive"); + }, + }, + deactivate: vi.fn(() => { + active = false; + }), + }; +} + +describe("DatabasePlugin", () => { + beforeEach(() => mocks.createDatabaseState.mockReset()); + + test("retains schema and declares the fixed beta postgres manifest", () => { + const definition = database({ schema }); + expectTypeOf(definition.config.schema).toEqualTypeOf(); + const assertConfigTypes = () => { + // @ts-expect-error execution policy is internal and cannot be configured + database({ schema, retry: { enabled: true, attempts: 3 } }); + }; + void assertConfigTypes; + expect(definition).toMatchObject({ name: "database", config: { schema } }); + expect(DatabasePlugin.manifest).toMatchObject({ + name: "database", + stability: "beta", + }); + expect(DatabasePlugin.manifest.resources.required).toContainEqual( + expect.objectContaining({ resourceKey: "postgres", type: "postgres" }), + ); + const plugin = new DatabasePlugin({ + schema, + retry: { enabled: true, attempts: 3 }, + } as unknown as { schema: typeof schema }); + expect( + (plugin as unknown as { config: Record }).config, + ).toEqual({ schema }); + }); + + test("publishes only after readiness and setup is single-flight", async () => { + const construction = deferred>(); + mocks.createDatabaseState.mockReturnValue(construction.promise); + const plugin = new DatabasePlugin({ schema }); + const first = plugin.setup(); + const second = plugin.setup(); + expect(() => plugin.exports()).toThrow(); + expect(mocks.createDatabaseState).toHaveBeenCalledTimes(1); + const state = candidate(); + construction.resolve(state); + await Promise.all([first, second]); + expect(plugin.exports()).toEqual(state.exports); + }); + + test("hands out a fresh export surface per access", async () => { + const state = candidate(); + mocks.createDatabaseState.mockResolvedValue(state); + const plugin = new DatabasePlugin({ schema }); + await plugin.setup(); + expect(plugin.exports()).not.toBe(plugin.exports()); + expect(plugin.exports()).not.toBe(state.exports); + expect(plugin.exports()).toEqual(state.exports); + }); + + test("shutdown racing setup waits, prevents publication, deactivates, and closes", async () => { + const construction = deferred>(); + mocks.createDatabaseState.mockReturnValue(construction.promise); + const plugin = new DatabasePlugin({ schema }); + const setup = plugin.setup(); + const shutdown = plugin.shutdown(); + const state = candidate(); + construction.resolve(state); + await expect(setup).rejects.toMatchObject({ category: "SETUP_FAILED" }); + await shutdown; + expect(state.deactivate).toHaveBeenCalledTimes(1); + expect(state.pool.end).toHaveBeenCalledTimes(1); + expect(() => plugin.exports()).toThrow(); + }); + + test("deactivates and unpublishes before one shared close", async () => { + const close = deferred(); + const state = candidate(); + state.pool.end.mockReturnValue(close.promise); + mocks.createDatabaseState.mockResolvedValue(state); + const plugin = new DatabasePlugin({ schema }); + await plugin.setup(); + const first = plugin.shutdown(); + const second = plugin.shutdown(); + await vi.waitFor(() => expect(state.deactivate).toHaveBeenCalledTimes(1)); + expect(state.deactivate).toHaveBeenCalledTimes(1); + expect(() => plugin.exports()).toThrow(); + expect(state.pool.end).toHaveBeenCalledTimes(1); + close.resolve(); + await Promise.all([first, second]); + await plugin.shutdown(); + expect(state.pool.end).toHaveBeenCalledTimes(1); + }); + + test("sanitizes close failure and repeats the same safe rejection", async () => { + const state = candidate(); + state.pool.end.mockRejectedValue(new Error("socket password secret")); + mocks.createDatabaseState.mockResolvedValue(state); + const plugin = new DatabasePlugin({ schema }); + await plugin.setup(); + const first = plugin.shutdown(); + const error = await first.catch((caught) => caught); + expect(error).toMatchObject({ + category: "INTERNAL", + phase: "shutdown", + cause: undefined, + }); + expect(error.message).toBe("Database operation failed"); + await expect(plugin.shutdown()).rejects.toBe(error); + }); + + test("isolates plugin instances and drains their exports independently", async () => { + const one = candidate("one"); + const two = candidate("two"); + mocks.createDatabaseState + .mockResolvedValueOnce(one) + .mockResolvedValueOnce(two); + const first = new DatabasePlugin({ schema }); + const second = new DatabasePlugin({ schema }); + await Promise.all([first.setup(), second.setup()]); + expect(first.exports()).toEqual(one.exports); + expect(second.exports()).toEqual(two.exports); + await first.shutdown(); + expect(() => one.exports.operation()).toThrow("inactive"); + expect(() => first.exports()).toThrow(); + expect(second.exports()).toEqual(two.exports); + }); +}); diff --git a/packages/appkit/src/plugins/database/types.ts b/packages/appkit/src/plugins/database/types.ts new file mode 100644 index 000000000..1cffbbb1d --- /dev/null +++ b/packages/appkit/src/plugins/database/types.ts @@ -0,0 +1,6 @@ +import type { Schema } from "../../database/schema-builder"; + +/** Configuration for one schema-bound DatabasePlugin instance. */ +export type IDatabaseConfig = { + readonly schema: TSchema; +}; diff --git a/packages/appkit/src/type-generator/database/generate.ts b/packages/appkit/src/type-generator/database/generate.ts new file mode 100644 index 000000000..9ebb28dae --- /dev/null +++ b/packages/appkit/src/type-generator/database/generate.ts @@ -0,0 +1,145 @@ +import { randomUUID } from "node:crypto"; +import fsSync from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { Schema } from "../../database/schema-builder"; +import { assertFinalizedSchema } from "../../database/schema-builder/define-schema"; +import { walkSchema } from "./walk-schema"; + +/** Safe diagnostic raised when database declarations cannot be generated. */ +export class DatabaseTypegenError extends Error { + constructor(message = "Database schema generation failed") { + super(message); + this.name = "DatabaseTypegenError"; + } +} + +interface GenerateDatabaseTypesOptions { + readonly schemaFile: string; + readonly outFile: string; +} + +export const DATABASE_TYPES_FILE = "database.d.ts"; + +function schemaLabel(schemaFile: string): string { + return path.relative(process.cwd(), schemaFile) || schemaFile; +} + +function importFailureReason(error: unknown): string { + if (error instanceof SyntaxError) return "could not be parsed"; + let code: unknown; + try { + code = + error && typeof error === "object" + ? Reflect.get(error, "code") + : undefined; + } catch { + code = undefined; + } + if (code === "MODULE_NOT_FOUND" || code === "ERR_MODULE_NOT_FOUND") { + return "contains an unresolved import"; + } + return "threw while loading"; +} + +/** Empty augmentation used when no valid database schema is available. */ +export const NEUTRAL_DATABASE_TYPES = `// Auto-generated by AppKit - DO NOT EDIT +import "@databricks/appkit"; + +declare module "@databricks/appkit" { + interface DatabaseRegistry {} +} +`; + +/** Render one `DatabaseRegistry` augmentation from a finalized schema. */ +function render(schema: Schema): string { + const entries = walkSchema(schema) + .map( + (entry) => ` ${JSON.stringify(entry.name)}: { + row: ${entry.row}; + publicRow: ${entry.publicRow}; + insert: ${entry.insert}; + update: ${entry.update}; + filters: ${entry.filters}; + includes: ${entry.includes}; + hasPrimaryKey: ${entry.hasPrimaryKey}; + };`, + ) + .join("\n"); + return `// Auto-generated by AppKit - DO NOT EDIT +import "@databricks/appkit"; + +declare module "@databricks/appkit" { + type DatabaseLogicalFilter = T & { + and?: readonly DatabaseLogicalFilter[]; + or?: readonly DatabaseLogicalFilter[]; + }; + + interface DatabaseRegistry { +${entries} + } +} +`; +} + +/** Atomically replace generated output only when its content changes. */ +async function writeIfChanged(outFile: string, content: string): Promise { + if ( + fsSync.existsSync(outFile) && + (await fs.readFile(outFile, "utf8")) === content + ) + return; + await fs.mkdir(path.dirname(outFile), { recursive: true }); + const temporary = path.join( + path.dirname(outFile), + `.${path.basename(outFile)}.${randomUUID()}.tmp`, + ); + try { + await fs.writeFile(temporary, content, "utf8"); + await fs.rename(temporary, outFile); + } finally { + await fs.rm(temporary, { force: true }); + } +} + +/** Disable Jiti's module cache so watch-mode runs observe schema edits. */ +async function importSchema(schemaFile: string): Promise { + const { createJiti } = await import("jiti"); + return createJiti(import.meta.url, { moduleCache: false }).import(schemaFile); +} + +/** Generate current registry types or neutralize them when loading fails. */ +export async function generateDatabaseTypes( + options: GenerateDatabaseTypesOptions, +): Promise { + if (!fsSync.existsSync(options.schemaFile)) { + await writeIfChanged(options.outFile, NEUTRAL_DATABASE_TYPES); + return; + } + try { + const module = (await importSchema(options.schemaFile)) as { + schema?: Schema; + }; + if (!("schema" in module)) { + throw new DatabaseTypegenError( + `Database schema module "${schemaLabel(options.schemaFile)}" must export a named "schema"`, + ); + } + try { + assertFinalizedSchema(module.schema); + } catch { + throw new DatabaseTypegenError( + `Database schema "${schemaLabel(options.schemaFile)}" is not a finalized AppKit schema`, + ); + } + await writeIfChanged(options.outFile, render(module.schema)); + } catch (error) { + // A failed run must not leave stale entities visible to TypeScript. + await writeIfChanged(options.outFile, NEUTRAL_DATABASE_TYPES); + throw error instanceof DatabaseTypegenError + ? error + : new DatabaseTypegenError( + `Database schema "${schemaLabel(options.schemaFile)}" ${importFailureReason(error)}`, + ); + } +} diff --git a/packages/appkit/src/type-generator/database/index.ts b/packages/appkit/src/type-generator/database/index.ts new file mode 100644 index 000000000..0648bba2c --- /dev/null +++ b/packages/appkit/src/type-generator/database/index.ts @@ -0,0 +1,5 @@ +export { + DATABASE_TYPES_FILE, + DatabaseTypegenError, + generateDatabaseTypes, +} from "./generate"; diff --git a/packages/appkit/src/type-generator/database/tests/generate.test.ts b/packages/appkit/src/type-generator/database/tests/generate.test.ts new file mode 100644 index 000000000..0e306f41a --- /dev/null +++ b/packages/appkit/src/type-generator/database/tests/generate.test.ts @@ -0,0 +1,288 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, test } from "vitest"; +import { generateDatabaseTypes, NEUTRAL_DATABASE_TYPES } from "../generate"; + +const execFileAsync = promisify(execFile); +const roots: string[] = []; +const appkitRoot = path.resolve(import.meta.dirname, "../../../.."); +const sourceRoot = path.join(appkitRoot, "src"); +const builder = path.join(sourceRoot, "database/schema-builder/index.ts"); + +afterEach(async () => + Promise.all( + roots + .splice(0) + .map((root) => fs.rm(root, { recursive: true, force: true })), + ), +); + +async function files(source?: string) { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), "appkit-database-typegen-"), + ); + roots.push(root); + const schemaFile = path.join(root, "schema.ts"); + const outFile = path.join(root, "database.d.ts"); + if (source !== undefined) await fs.writeFile(schemaFile, source); + return { root, schemaFile, outFile }; +} + +const completeSchema = ` + import { + bigint, boolean, defineSchema, enumColumn, fk, id, integer, jsonb, + text, timestamp, uuid, varchar, + } from ${JSON.stringify(builder)}; + export const schema = defineSchema(({ table }) => { + const users = table("users", { + slug: text().primaryKey(), + name: varchar(80).notNull(), + secret: text().private().notNull(), + nickname: text().default("anonymous"), + created_at: timestamp().defaultNow().notNull(), + }); + const posts = table("posts", { + id: id(), + user_slug: fk(() => users.slug).notNull(), + title: text().notNull(), + score: integer(), + total: bigint().notNull(), + active: boolean().notNull(), + external_id: uuid().defaultRandom().notNull(), + happened_at: timestamp(), + payload: jsonb(), + status: enumColumn("post_status", ["draft", "live"]).notNull(), + }); + const events = table("events", { message: text().notNull(), payload: jsonb() }); + const blobs = table("blobs", { payload: jsonb() }); + return { users, posts, events, blobs }; + }); +`; + +describe("generateDatabaseTypes", () => { + test("renders every facet, scalar, enum, filter, relation, and key capability", async () => { + const options = await files(completeSchema); + await generateDatabaseTypes(options); + const output = await fs.readFile(options.outFile, "utf8"); + + expect(output).toContain('"slug": string;'); + expect(output).toContain('"score": number | null;'); + expect(output).toContain('"total": bigint;'); + expect(output).toContain('"active": boolean;'); + expect(output).toContain('"external_id": string;'); + expect(output).toContain('"happened_at": string | null;'); + expect(output).toContain('"payload": unknown | null;'); + expect(output).toContain('"status": "draft" | "live";'); + expect(output).toContain('readonly ("draft" | "live")[]'); + + const users = output.slice( + output.indexOf('"users": {'), + output.indexOf('\n "posts": {\n row:'), + ); + expect(users.match(/"secret"\??: string;/g)).toHaveLength(3); + expect(users).toContain("publicRow:"); + expect(users).toContain('insert: {\n "slug": string;'); + expect(users).toContain('"nickname"?: string | null;'); + expect(users).toContain('"created_at"?: string;'); + expect(users).not.toContain('update: {\n "slug"'); + + const posts = output.slice( + output.indexOf('\n "posts": {\n row:'), + output.indexOf('\n "events": {\n row:'), + ); + expect(posts).not.toContain('insert: {\n "id"'); + expect(posts).not.toContain('update: {\n "id"'); + expect(posts).toContain('"title"?: string;'); + expect(posts).toContain('"score"?: number | null;'); + expect(posts).toContain('"users": { to: "users"; many: false };'); + expect(users).toContain('"posts": { to: "posts"; many: true };'); + expect(output).toContain("hasPrimaryKey: true;"); + expect(output).toContain("hasPrimaryKey: false;"); + + expect(output).toContain( + '"title"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; };', + ); + expect(output).toContain( + "gt?: number; gte?: number; lt?: number; lte?: number;", + ); + expect(output).toContain("is?: null;"); + const postFilters = posts.slice( + posts.indexOf("filters:"), + posts.indexOf("includes:"), + ); + expect(postFilters).not.toContain('"payload"?:'); + expect(output).toContain("and?: readonly DatabaseLogicalFilter[];"); + expect(output).toContain("or?: readonly DatabaseLogicalFilter[];"); + expect(output).toContain("includes: {};"); + expect(output).toContain("filters: DatabaseLogicalFilter<{}>;"); + }); + + test("accepts named valid and explicitly empty schemas", async () => { + const valid = await files(completeSchema); + await generateDatabaseTypes(valid); + expect(await fs.readFile(valid.outFile, "utf8")).toContain('"users": {'); + + const empty = await files(` + import { defineSchema } from ${JSON.stringify(builder)}; + export const schema = defineSchema(() => ({})); + `); + await generateDatabaseTypes(empty); + expect(await fs.readFile(empty.outFile, "utf8")).toContain( + "interface DatabaseRegistry {\n\n }", + ); + }); + + test.each([ + ["default", "export default {};"], + ["alternate", "export const databaseSchema = {};"], + ["forged", "export const schema = { $tables: {} };"], + ])( + "rejects %s schema exports and neutralizes stale output", + async (_name, source) => { + const options = await files(source); + await fs.writeFile(options.outFile, "stale entity"); + await expect(generateDatabaseTypes(options)).rejects.toMatchObject({ + name: "DatabaseTypegenError", + }); + expect(await fs.readFile(options.outFile, "utf8")).toBe( + NEUTRAL_DATABASE_TYPES, + ); + }, + ); + + test("reports a bounded schema diagnostic while neutralizing output", async () => { + const options = await files('throw new Error("broken schema dependency");'); + await fs.writeFile(options.outFile, "stale entity"); + + const error = await generateDatabaseTypes(options).catch( + (caught) => caught, + ); + + expect(error).toMatchObject({ name: "DatabaseTypegenError" }); + expect(error.message).toContain("schema.ts"); + expect(error.message).toContain("threw while loading"); + expect(error.message).not.toContain("broken schema dependency"); + expect(await fs.readFile(options.outFile, "utf8")).toBe( + NEUTRAL_DATABASE_TYPES, + ); + }); + + test("writes a neutral contribution when the schema is absent", async () => { + const options = await files(); + await generateDatabaseTypes(options); + expect(await fs.readFile(options.outFile, "utf8")).toBe( + NEUTRAL_DATABASE_TYPES, + ); + }); + + test("reloads schema and imported dependency edits", async () => { + const options = await files(); + const dependency = path.join(options.root, "columns.ts"); + await fs.writeFile( + dependency, + `export const columnName = "first" as const;`, + ); + const source = ` + import { defineSchema, text } from ${JSON.stringify(builder)}; + import { columnName } from "./columns"; + export const schema = defineSchema(({ table }) => { + const records = table("records", { [columnName]: text() }); + return { records }; + }); + `; + await fs.writeFile(options.schemaFile, source); + await generateDatabaseTypes(options); + expect(await fs.readFile(options.outFile, "utf8")).toContain('"first"'); + + await fs.writeFile( + dependency, + `export const columnName = "second" as const;`, + ); + await generateDatabaseTypes(options); + expect(await fs.readFile(options.outFile, "utf8")).toContain('"second"'); + + await fs.writeFile( + options.schemaFile, + source + .replace('table("records"', 'table("updated"') + .replace("return { records };", "return { updated: records };"), + ); + await generateDatabaseTypes(options); + expect(await fs.readFile(options.outFile, "utf8")).toContain( + '"updated": {', + ); + }); + + test("does not rewrite an unchanged declaration", async () => { + const options = await files(completeSchema); + await generateDatabaseTypes(options); + const content = await fs.readFile(options.outFile, "utf8"); + const before = (await fs.stat(options.outFile)).mtimeMs; + await new Promise((resolve) => setTimeout(resolve, 20)); + await generateDatabaseTypes(options); + expect(await fs.readFile(options.outFile, "utf8")).toBe(content); + expect((await fs.stat(options.outFile)).mtimeMs).toBe(before); + }); + + test("compiles a semantic consumer through the beta subpath", async () => { + const options = await files(completeSchema); + await generateDatabaseTypes(options); + const consumer = path.join(options.root, "consumer.ts"); + const tsconfig = path.join(options.root, "tsconfig.json"); + await fs.writeFile( + consumer, + ` + import type { DatabaseExports } from "@databricks/appkit/beta"; + declare const db: DatabaseExports; + db.users.where({ name: { ilike: "%ada%" } }).include({ posts: { limit: 2 } }); + db.posts.where({ score: { gte: 1 }, and: [{ status: ["draft"] }] }); + db.events.create({ message: "created" }); + db.transaction(async (tx) => { await tx.posts.count(); await tx.sql\`select \${1}\`; }); + // @ts-expect-error keyless entities have no find + db.events.find("id"); + // @ts-expect-error private columns are absent from default rows + db.users.first().then((row) => row?.secret); + db.users.create({ slug: "ada", name: "Ada", secret: "token" }); + db.users.upsert( + { slug: "ada", name: "Ada", secret: "token" }, + { onConflict: "slug" }, + ); + // @ts-expect-error unknown columns are not conflict targets + db.users.upsert({ slug: "ada", name: "Ada", secret: "token" }, { onConflict: "missing" }); + // @ts-expect-error defaulted fields remain typed when explicitly supplied + db.users.create({ slug: "ada", name: "Ada", secret: "token", nickname: 1 }); + `, + ); + await fs.writeFile( + tsconfig, + JSON.stringify({ + compilerOptions: { + strict: true, + noEmit: true, + target: "ES2022", + module: "ESNext", + moduleResolution: "Bundler", + baseUrl: options.root, + paths: { + "@databricks/appkit": [ + path.join(sourceRoot, "database/contract/index.ts"), + ], + "@databricks/appkit/beta": [ + path.join(sourceRoot, "plugins/database/entity-types.ts"), + ], + }, + }, + files: [options.outFile, consumer], + }), + ); + + await expect( + execFileAsync("pnpm", ["exec", "tsc", "--noEmit", "-p", tsconfig], { + cwd: path.resolve(appkitRoot, "../.."), + }), + ).resolves.toMatchObject({ stderr: "" }); + }, 30_000); +}); diff --git a/packages/appkit/src/type-generator/database/walk-schema.ts b/packages/appkit/src/type-generator/database/walk-schema.ts new file mode 100644 index 000000000..1b6f49f21 --- /dev/null +++ b/packages/appkit/src/type-generator/database/walk-schema.ts @@ -0,0 +1,127 @@ +import type { + AppKitTable, + ColumnMeta, + Schema, +} from "../../database/schema-builder"; +import { filterOperatorsForKind } from "../../database/schema-builder/types"; + +/** Render-ready type facets for one database registry entry. */ +interface RegistryEntry { + readonly name: string; + readonly row: string; + readonly publicRow: string; + readonly insert: string; + readonly update: string; + readonly filters: string; + readonly includes: string; + readonly hasPrimaryKey: boolean; +} + +/** Keep generated scalars aligned with the schema's canonical runtime values. */ +function tsType(meta: ColumnMeta): string { + switch (meta.kind) { + case "string": + case "uuid": + case "date": + return "string"; + case "number": + return "number"; + case "bigint": + return "bigint"; + case "boolean": + return "boolean"; + case "json": + return "unknown"; + case "enum": + return ( + meta.enumValues?.map((value) => JSON.stringify(value)).join(" | ") || + "string" + ); + default: + return "unknown"; + } +} + +function objectFacet(lines: string[], empty = "{}"): string { + return lines.length ? `{\n${lines.join("\n")}\n }` : empty; +} + +function property(meta: ColumnMeta, optional = false): string { + const nullable = meta.notNull ? "" : " | null"; + return ` ${JSON.stringify(meta.columnName)}${optional ? "?" : ""}: ${tsType(meta)}${nullable};`; +} + +// Trusted facets retain private columns; only public rows project them out. +function rowType(table: AppKitTable, publicOnly: boolean): string { + return objectFacet( + Object.values(table.$columns) + .filter((c) => !publicOnly || !c.isPrivate) + .map((c) => property(c)), + "Record", + ); +} + +// Write facets mirror trusted validators; updates additionally omit primary keys. +function insertType(table: AppKitTable): string { + return objectFacet( + Object.values(table.$columns) + .filter((c) => !c.serverGenerated) + .map((c) => property(c, !c.notNull || c.hasDefault)), + "Record", + ); +} + +function updateType(table: AppKitTable): string { + return objectFacet( + Object.values(table.$columns) + .filter((c) => !c.serverGenerated && !c.primaryKey) + .map((c) => property(c, true)), + "Record", + ); +} + +/** Reuse the canonical operator matrix when rendering `where()` types. */ +function filtersType(table: AppKitTable): string { + const direct = objectFacet( + Object.values(table.$columns).flatMap((column) => { + const operators = filterOperatorsForKind(column.kind); + if (operators.length === 0) return []; + const value = tsType(column); + const fields = operators.map( + (operator) => + `${operator}?: ${operator === "in" ? `readonly (${value})[]` : value};`, + ); + if (!column.notNull) fields.push("is?: null;"); + return [ + ` ${JSON.stringify(column.columnName)}?: ${value} | readonly (${value})[] | { ${fields.join(" ")} };`, + ]; + }), + ); + return `DatabaseLogicalFilter<${direct}>`; +} + +/** Preserve finalized relation identity and cardinality in include types. */ +function includesType(table: AppKitTable): string { + return objectFacet( + table.$relations.map( + (relation) => + ` ${JSON.stringify(relation.name)}: { to: ${JSON.stringify(relation.targetTable)}; many: ${relation.cardinality === "toMany"} };`, + ), + ); +} + +/** Preserve schema table identity as the generated registry key. */ +export function walkSchema(schema: Schema): RegistryEntry[] { + return Object.entries(schema.$tables).map(([name, table]) => ({ + name, + row: rowType(table, false), + publicRow: rowType(table, true), + insert: insertType(table), + update: updateType(table), + filters: filtersType(table), + includes: includesType(table), + hasPrimaryKey: Object.values(table.$columns).some( + (column) => column.primaryKey, + ), + })); +} diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index 45cfce711..28dc526e3 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -905,3 +905,8 @@ export const TYPES_DIR = "appkit-types"; export const ANALYTICS_TYPES_FILE = "analytics.d.ts"; export const SERVING_TYPES_FILE = "serving.d.ts"; export const METRIC_TYPES_FILE = "metric-views.ts"; +export { + DATABASE_TYPES_FILE, + DatabaseTypegenError, + generateDatabaseTypes, +} from "./database"; diff --git a/packages/appkit/src/type-generator/tests/vite-plugin.test.ts b/packages/appkit/src/type-generator/tests/vite-plugin.test.ts index 52c25c9fc..f27b1e528 100644 --- a/packages/appkit/src/type-generator/tests/vite-plugin.test.ts +++ b/packages/appkit/src/type-generator/tests/vite-plugin.test.ts @@ -5,12 +5,35 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { WarehouseState } from "../warehouse-status"; const mocks = vi.hoisted(() => ({ + existsSync: vi.fn((_file: unknown) => true), + generateDatabaseTypes: vi.fn(), generateFromEntryPoint: vi.fn(), getWarehouseState: vi.fn(), startWarehouse: vi.fn(), waitUntilRunning: vi.fn(), + loggerError: vi.fn(), })); +vi.mock("node:fs", async (importOriginal) => ({ + ...(await importOriginal()), + existsSync: mocks.existsSync, +})); + +vi.mock("../../logging/logger", () => ({ + createLogger: () => ({ + debug: vi.fn(), + error: mocks.loggerError, + }), +})); + +vi.mock("../database", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + generateDatabaseTypes: mocks.generateDatabaseTypes, + }; +}); + // Mock the module vite-plugin.ts pulls generateFromEntryPoint from. The error // classes are imported for `instanceof` checks in the catch block, so they must // remain real constructors — only the warehouse-touching entry point is spied. @@ -45,6 +68,13 @@ const { appKitTypesPlugin } = await import("../vite-plugin"); // Real constant values: the "../index" mock spreads the actual module, so these // are the genuine defaults the plugin resolves outFile from. const { ANALYTICS_TYPES_FILE, TYPES_DIR } = await import("../index"); +const { DatabaseTypegenError } = await import("../database"); + +beforeEach(() => { + mocks.existsSync.mockReturnValue(true); + mocks.generateDatabaseTypes.mockReset(); + mocks.generateDatabaseTypes.mockResolvedValue(undefined); +}); // The plugin hooks are loosely typed on Vite's Plugin; cast to the shapes we // actually drive so we can call them directly without a Vite build. @@ -131,8 +161,7 @@ describe("appKitTypesPlugin — generation mode", () => { mocks.getWarehouseState.mockResolvedValue("DELETED" as WarehouseState); mocks.startWarehouse.mockResolvedValue(undefined); mocks.waitUntilRunning.mockResolvedValue("RUNNING" as WarehouseState); - // A non-empty warehouse ID is required or generate() short-circuits before - // ever calling generateFromEntryPoint. + // Default to the established warehouse-backed generation path. process.env.DATABRICKS_WAREHOUSE_ID = "wh-test"; }); @@ -170,12 +199,90 @@ describe("appKitTypesPlugin — generation mode", () => { ); }); - test("skips generation when warehouse ID is absent", async () => { + test("runs warehouse-independent generation when warehouse ID is absent", async () => { + delete process.env.DATABRICKS_WAREHOUSE_ID; + + await runPlugin(); + await flush(); + + expect(mocks.generateDatabaseTypes).toHaveBeenCalledWith({ + schemaFile: path.join(process.cwd(), "config", "database", "schema.ts"), + outFile: path.join( + process.cwd(), + "shared", + "appkit-types", + "database.d.ts", + ), + }); + expect(mocks.generateFromEntryPoint).not.toHaveBeenCalled(); + }); + + test("activates a database-only project without a warehouse", () => { delete process.env.DATABRICKS_WAREHOUSE_ID; + mocks.existsSync.mockImplementation((file) => + String(file).endsWith(path.join("config", "database", "schema.ts")), + ); + const apply = appKitTypesPlugin().apply; + expect(typeof apply).toBe("function"); + expect((apply as (config: unknown, env: unknown) => boolean)({}, {})).toBe( + true, + ); + }); + + test("does not run warehouse typegen for a database-only project", async () => { + mocks.existsSync.mockImplementation((file) => + String(file).endsWith(path.join("config", "database", "schema.ts")), + ); await runPlugin(); + await flush(); + expect(mocks.generateDatabaseTypes).toHaveBeenCalledTimes(1); expect(mocks.generateFromEntryPoint).not.toHaveBeenCalled(); + expect(mocks.getWarehouseState).not.toHaveBeenCalled(); + }); + + test("activates to neutralize an existing database declaration", () => { + delete process.env.DATABRICKS_WAREHOUSE_ID; + mocks.existsSync.mockImplementation((file) => + String(file).endsWith(path.join("appkit-types", "database.d.ts")), + ); + const apply = appKitTypesPlugin().apply; + expect(typeof apply).toBe("function"); + expect((apply as (config: unknown, env: unknown) => boolean)({}, {})).toBe( + true, + ); + }); + + test("logs DatabaseTypegenError message-only in development", async () => { + process.env.NODE_ENV = "development"; + mocks.generateDatabaseTypes.mockRejectedValueOnce( + new DatabaseTypegenError("Database schema generation failed"), + ); + + await runPlugin(); + await flush(); + + expect(mocks.loggerError).toHaveBeenCalledWith( + "%s", + "Database schema generation failed", + ); + expect(mocks.loggerError).not.toHaveBeenCalledWith( + expect.stringContaining("%O"), + expect.anything(), + ); + }); + + test("rejects DatabaseTypegenError message-only in production", async () => { + process.env.NODE_ENV = "production"; + const error = new DatabaseTypegenError("Database schema generation failed"); + mocks.generateDatabaseTypes.mockRejectedValueOnce(error); + + const plugin = makeConfiguredPlugin(); + await expect(getHook(plugin, "buildStart")()).rejects.toBe( + error, + ); + expect(error.stack).toBe(error.message); }); }); @@ -310,6 +417,63 @@ describe("appKitTypesPlugin — single-flight generate", () => { expect(mocks.generateFromEntryPoint).toHaveBeenCalledTimes(1); }); + test.each(["add", "change", "unlink"])( + "%s on the exact database schema regenerates without arming the warehouse", + async (event) => { + mocks.generateFromEntryPoint.mockResolvedValue(undefined); + const plugin = makeConfiguredPlugin(); + const { server, watcher } = makeFakeServer(); + getHook(plugin, "configureServer")(server); + + watcher.emit( + event, + path.join(process.cwd(), "config", "database", "schema.ts"), + ); + await flush(); + + // Database sources ride the same single-flight generate as `.sql` edits, + // but a schema edit tells us nothing about the warehouse, so the one-shot + // blocking re-describe is not armed for it. + expect(mocks.generateDatabaseTypes).toHaveBeenCalledTimes(1); + expect(mocks.getWarehouseState).not.toHaveBeenCalled(); + }, + ); + + test("regenerates when an imported database source changes", async () => { + mocks.generateFromEntryPoint.mockResolvedValue(undefined); + const plugin = makeConfiguredPlugin(); + const { server, watcher } = makeFakeServer(); + getHook(plugin, "configureServer")(server); + + watcher.emit( + "change", + path.join(process.cwd(), "config", "database", "tables", "notes.ts"), + ); + await flush(); + + expect(mocks.generateDatabaseTypes).toHaveBeenCalledTimes(1); + expect(mocks.getWarehouseState).not.toHaveBeenCalled(); + }); + + test("ignores database non-source files and prefix-collision siblings", async () => { + mocks.generateFromEntryPoint.mockResolvedValue(undefined); + const plugin = makeConfiguredPlugin(); + const { server, watcher } = makeFakeServer(); + getHook(plugin, "configureServer")(server); + + for (const file of [ + path.join(process.cwd(), "config", "database-copy", "schema.ts"), + path.join(process.cwd(), "config", "database", "README.md"), + path.join(process.cwd(), "other", "database", "schema.ts"), + ]) { + watcher.emit("change", file); + } + await flush(); + + expect(mocks.generateDatabaseTypes).not.toHaveBeenCalled(); + expect(mocks.generateFromEntryPoint).not.toHaveBeenCalled(); + }); + test("a definitions.json OUTSIDE the metric-views folder does NOT regenerate; one inside does (directory match, not bare basename)", async () => { mocks.generateFromEntryPoint.mockResolvedValue(undefined); diff --git a/packages/appkit/src/type-generator/vite-plugin.ts b/packages/appkit/src/type-generator/vite-plugin.ts index 3c79fc193..ed4099c5a 100644 --- a/packages/appkit/src/type-generator/vite-plugin.ts +++ b/packages/appkit/src/type-generator/vite-plugin.ts @@ -4,6 +4,11 @@ import type { Plugin } from "vite"; import { METRIC_CONFIG_FILE } from "../../../shared/src/schemas/metric-fqn"; import { createLogger } from "../logging/logger"; import { createWorkspaceClient } from "../workspace-client"; +import { + DATABASE_TYPES_FILE, + DatabaseTypegenError, + generateDatabaseTypes, +} from "./database"; import { ANALYTICS_TYPES_FILE, generateFromEntryPoint, @@ -45,6 +50,7 @@ interface AppKitTypesPluginOptions { * Folders to watch for changes. Defaults to `config/queries` and * `config/metric-views`. When overridden, include a `queries` folder and/or a * `metric-views` folder — they are resolved by their trailing path segment. + * Database schema sources are watched independently. */ watchFolders?: string[]; } @@ -64,6 +70,7 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { // `watchFolders` ordering (which used to assume queries was `watchFolders[0]`). let queryFolder: string | undefined; let metricViewsFolder: string | undefined; + let databaseFolder: string; // Single-flight state for runGenerate(). `inFlight` is the promise of the // currently-running drain (null when idle); `queued` records that a trigger @@ -103,17 +110,23 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { if (!warehouseId) { logger.debug("Warehouse ID not found. Skipping type generation."); - return; + } else if (hasAnalyticsSources()) { + await generateFromEntryPoint({ + outFile, + queryFolder, + metricViewsFolder, + warehouseId, + noCache: false, + mode, + mvOutFile, + }); } - await generateFromEntryPoint({ - outFile, - queryFolder, - metricViewsFolder, - warehouseId, - noCache: false, - mode, - mvOutFile, + // Database declarations need no warehouse. Generating them last keeps the + // query and metric-view outputs independent of a schema failure. + await generateDatabaseTypes({ + schemaFile: path.join(databaseFolder, "schema.ts"), + outFile: path.join(path.dirname(outFile), DATABASE_TYPES_FILE), }); } catch (error) { // TypegenSyntaxError / TypegenFatalError carry a complete, actionable @@ -122,7 +135,8 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { // message — both when failing the prod build and when logging in dev. const isTypegenError = error instanceof TypegenSyntaxError || - error instanceof TypegenFatalError; + error instanceof TypegenFatalError || + error instanceof DatabaseTypegenError; // throw in production to fail the build if (process.env.NODE_ENV === "production") { @@ -138,6 +152,18 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { } } + /** + * Whether this project has anything for the warehouse-backed passes to read. + * A database-only project activates the plugin without them, so the query and + * metric-view work — and the warehouse it would warm up — must stay dormant. + */ + function hasAnalyticsSources(): boolean { + return ( + (queryFolder !== undefined && existsSync(queryFolder)) || + (metricViewsFolder !== undefined && existsSync(metricViewsFolder)) + ); + } + /** * Single-flight wrapper around {@link generateOnce}. The initial build, the * .sql watcher, and the DEV warehouse watch all route through here so they can @@ -224,6 +250,7 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { */ function armWarehouseWatch(): void { if (process.env.NODE_ENV === "production") return; + if (!hasAnalyticsSources()) return; const warehouseId = process.env.DATABRICKS_WAREHOUSE_ID || ""; if (!warehouseId) return; @@ -297,8 +324,20 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { apply() { const warehouseId = process.env.DATABRICKS_WAREHOUSE_ID || ""; - - if (!warehouseId) { + const typesDir = path.dirname( + path.resolve( + process.cwd(), + options?.outFile ?? `shared/${TYPES_DIR}/${ANALYTICS_TYPES_FILE}`, + ), + ); + // A declared schema needs no warehouse, and an already-generated + // declaration must still be neutralized after its schema is deleted. + const hasDatabase = + existsSync( + path.join(process.cwd(), "config", "database", "schema.ts"), + ) || existsSync(path.join(typesDir, DATABASE_TYPES_FILE)); + + if (!warehouseId && !hasDatabase) { logger.debug("Warehouse ID not found. Skipping type generation."); return false; } @@ -313,7 +352,7 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { const hasMetricViews = existsSync( path.join(process.cwd(), "config", "metric-views"), ); - if (!hasQueries && !hasMetricViews) { + if (!hasQueries && !hasMetricViews && !hasDatabase) { return false; } @@ -354,6 +393,7 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { "config", "metric-views", ); + databaseFolder = path.join(process.cwd(), "config", "database"); watchFolders = options?.watchFolders ?? [ defaultQueryFolder, defaultMetricViewsFolder, @@ -392,8 +432,24 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { configureServer(server) { server.watcher.add(watchFolders); + server.watcher.add(databaseFolder); + + const isDatabaseSource = (changedFile: string): boolean => { + const normalizedFile = path.resolve(changedFile); + const relative = path.relative(databaseFolder, normalizedFile); + return ( + !relative.startsWith("..") && + !path.isAbsolute(relative) && + /\.(?:[cm]?ts|tsx)$/.test(normalizedFile) + ); + }; server.watcher.on("change", (changedFile) => { + if (isDatabaseSource(changedFile)) { + void runGenerate("non-blocking"); + return; + } + const isWatchedFile = watchFolders.some((folder) => changedFile.startsWith(folder), ); @@ -421,6 +477,14 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { armWarehouseWatch(); } }); + // Creation/deletion support is database-specific; query watchers retain + // their existing change-only behavior. + server.watcher.on("add", (file) => { + if (isDatabaseSource(file)) void runGenerate("non-blocking"); + }); + server.watcher.on("unlink", (file) => { + if (isDatabaseSource(file)) void runGenerate("non-blocking"); + }); // Tear down any pending warehouse watch when the dev server closes so a // long backoff can't keep the process alive after shutdown. diff --git a/packages/appkit/tsdown.config.ts b/packages/appkit/tsdown.config.ts index f5ae00475..b586905eb 100644 --- a/packages/appkit/tsdown.config.ts +++ b/packages/appkit/tsdown.config.ts @@ -6,10 +6,9 @@ export default defineConfig([ attw: { profile: "esm-only", level: "error", - excludeEntrypoints: ["./type-generator"], }, name: "@databricks/appkit", - entry: ["src/index.ts", "src/beta.ts"], + entry: ["src/index.ts", "src/beta.ts", "src/type-generator/index.ts"], outDir: "dist", hash: false, format: "esm", diff --git a/packages/shared/src/cli/commands/generate-types.test.ts b/packages/shared/src/cli/commands/generate-types.test.ts index 1c2f5c855..ff48bf29f 100644 --- a/packages/shared/src/cli/commands/generate-types.test.ts +++ b/packages/shared/src/cli/commands/generate-types.test.ts @@ -16,6 +16,7 @@ import { // created in a hoisted block too (plain top-level consts would be in the TDZ when // the hoisted factory runs). const { + generateDatabaseTypes, generateFromEntryPoint, generateServingTypes, unref, @@ -30,6 +31,7 @@ const { const lockPathOf = (root: string) => nodePath.join(root, "node_modules", ".databricks", "appkit", "worker.lock"); return { + generateDatabaseTypes: vi.fn(async () => {}), generateFromEntryPoint: vi.fn(async () => {}), generateServingTypes: vi.fn(async () => {}), unref, @@ -48,6 +50,8 @@ const { // command's `await import("@databricks/appkit/type-generator")` resolves to spies // and never touches a warehouse. vi.mock("@databricks/appkit/type-generator", () => ({ + DATABASE_TYPES_FILE: "database.d.ts", + generateDatabaseTypes, generateFromEntryPoint, generateServingTypes, })); @@ -143,6 +147,7 @@ describe("generate-types foreground spawn orchestration", () => { expect(generateFromEntryPoint).toHaveBeenCalledWith( expect.objectContaining({ mode: "non-blocking" }), ); + expect(generateDatabaseTypes).not.toHaveBeenCalled(); // Exactly one detached worker, re-invoking this CLI with --wait and the // worker lock, forwarding the same positional targets. @@ -170,6 +175,22 @@ describe("generate-types foreground spawn orchestration", () => { expect(unref).toHaveBeenCalledTimes(1); }); + test("generates database types without a warehouse", async () => { + delete process.env.DATABRICKS_WAREHOUSE_ID; + const schemaFile = path.join(tmpRoot, "config/database/schema.ts"); + const outFile = path.join(tmpRoot, "shared/appkit-types/analytics.d.ts"); + fs.mkdirSync(path.dirname(schemaFile), { recursive: true }); + fs.writeFileSync(schemaFile, "export const schema = {};", "utf8"); + + await runCli([tmpRoot, outFile]); + + expect(generateDatabaseTypes).toHaveBeenCalledWith({ + schemaFile, + outFile: path.join(path.dirname(outFile), "database.d.ts"), + }); + expect(generateFromEntryPoint).not.toHaveBeenCalled(); + }); + test("lock already held (fresh): does NOT spawn, foreground still resolves", async () => { acquireSpawnLock.mockReturnValue(false); diff --git a/packages/shared/src/cli/commands/generate-types.ts b/packages/shared/src/cli/commands/generate-types.ts index f38f323a2..eb07f70e7 100644 --- a/packages/shared/src/cli/commands/generate-types.ts +++ b/packages/shared/src/cli/commands/generate-types.ts @@ -56,16 +56,14 @@ async function runGenerateTypes( const mode = resolveTypegenMode(options); const typeGen = await import("@databricks/appkit/type-generator"); + const resolvedOutFile = + outFile || path.join(process.cwd(), "shared/appkit-types/analytics.d.ts"); // Generate analytics query types (requires warehouse ID) const resolvedWarehouseId = warehouseId || process.env.DATABRICKS_WAREHOUSE_ID; if (resolvedWarehouseId) { - const resolvedOutFile = - outFile || - path.join(process.cwd(), "shared/appkit-types/analytics.d.ts"); - const queryFolder = path.join(resolvedRootDir, "config/queries"); const metricViewsFolder = path.join( resolvedRootDir, @@ -115,6 +113,23 @@ async function runGenerateTypes( noCache, }); console.log(`Generated serving types: ${servingOutFile}`); + + // Generate database declarations. + const databaseSchemaFile = path.join( + resolvedRootDir, + "config/database/schema.ts", + ); + const databaseOutFile = path.join( + path.dirname(resolvedOutFile), + typeGen.DATABASE_TYPES_FILE, + ); + if (fs.existsSync(databaseSchemaFile) || fs.existsSync(databaseOutFile)) { + await typeGen.generateDatabaseTypes({ + schemaFile: databaseSchemaFile, + outFile: databaseOutFile, + }); + console.log(`Generated database types: ${databaseOutFile}`); + } } catch (error) { if ( error instanceof Error && @@ -133,7 +148,8 @@ async function runGenerateTypes( if ( error instanceof Error && (error.name === "TypegenSyntaxError" || - error.name === "TypegenFatalError") + error.name === "TypegenFatalError" || + error.name === "DatabaseTypegenError") ) { console.error(error.message); process.exit(1); @@ -267,7 +283,7 @@ async function generateTypesAction( } export const generateTypesCommand = new Command("generate-types") - .description("Generate TypeScript types from SQL queries") + .description("Generate TypeScript types from AppKit configuration") .argument("[rootDir]", "Root directory of the project", process.cwd()) .argument( "[outFile]", diff --git a/packages/shared/src/cli/commands/type-generator.d.ts b/packages/shared/src/cli/commands/type-generator.d.ts index 5e7e0a258..dfc9f3eb9 100644 --- a/packages/shared/src/cli/commands/type-generator.d.ts +++ b/packages/shared/src/cli/commands/type-generator.d.ts @@ -8,6 +8,13 @@ * `packages/appkit/src/type-generator/index.ts`. */ declare module "@databricks/appkit/type-generator" { + export const DATABASE_TYPES_FILE: "database.d.ts"; + + export function generateDatabaseTypes(options: { + schemaFile: string; + outFile: string; + }): Promise; + export function generateFromEntryPoint(options: { queryFolder?: string; metricViewsFolder?: string; @@ -26,6 +33,8 @@ declare module "@databricks/appkit/type-generator" { readonly queries: Array<{ name: string; message: string }>; } + export class DatabaseTypegenError extends Error {} + export function generateServingTypes(options: { outFile: string; noCache?: boolean; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e45b03b5..564fa78e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -320,6 +320,9 @@ importers: get-port: specifier: 7.2.0 version: 7.2.0 + jiti: + specifier: 2.6.1 + version: 2.6.1 js-yaml: specifier: 4.2.0 version: 4.2.0 diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index 078ab524a..d48af900d 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -102,6 +102,100 @@ } } }, + "database": { + "name": "database", + "displayName": "Database (Beta)", + "description": "Schema-driven typed access to Databricks Lakebase PostgreSQL", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "postgres", + "alias": "Postgres", + "resourceKey": "postgres", + "description": "Lakebase Postgres database for persistent storage", + "permission": "CAN_CONNECT_AND_CREATE", + "fields": { + "project": { + "description": "Lakebase project resource name", + "examples": [ + "projects/{project-id}" + ], + "discovery": { + "type": "kind", + "resourceKind": "postgres_project", + "select": "name" + }, + "origin": "user" + }, + "branch": { + "description": "Lakebase branch resource name", + "examples": [ + "projects/{project-id}/branches/{branch-id}" + ], + "discovery": { + "type": "kind", + "resourceKind": "postgres_branch", + "select": "name", + "dependsOn": "project" + }, + "origin": "user" + }, + "database": { + "description": "Lakebase database resource name", + "examples": [ + "projects/{project-id}/branches/{branch-id}/databases/{database-id}" + ], + "discovery": { + "type": "kind", + "resourceKind": "postgres_database", + "select": "name", + "dependsOn": "branch" + }, + "origin": "user" + }, + "host": { + "env": "PGHOST", + "description": "Postgres host", + "localOnly": true, + "resolve": "postgres:host", + "origin": "platform" + }, + "databaseName": { + "env": "PGDATABASE", + "description": "Postgres database name", + "localOnly": true, + "resolve": "postgres:databaseName", + "origin": "platform" + }, + "endpointPath": { + "env": "LAKEBASE_ENDPOINT", + "description": "Lakebase endpoint resource name", + "bundleIgnore": true, + "resolve": "postgres:endpointPath", + "origin": "cli" + }, + "port": { + "env": "PGPORT", + "description": "Postgres port", + "localOnly": true, + "value": "5432", + "origin": "platform" + }, + "sslmode": { + "env": "PGSSLMODE", + "description": "Postgres SSL mode", + "localOnly": true, + "value": "require", + "origin": "platform" + } + } + } + ], + "optional": [] + }, + "stability": "beta" + }, "files": { "name": "files", "displayName": "Files Plugin", From 977c936007f42f6cb046885744dec8f6e2716388 Mon Sep 17 00:00:00 2001 From: ditadi Date: Fri, 7 Aug 2026 15:59:45 +0100 Subject: [PATCH 3/3] feat(appkit): add opt-in generated database reads Project the typed entity API onto default-off list and detail routes that bound query grammar, include depth, and result cost before execution, and that shape rows through a private-safe projection and one synchronous serializer per table. Keep the declared table names in the schema type so exposure config cannot name a table the schema does not have. Signed-off-by: ditadi --- docs/docs/api/appkit/Function.database.md | 2 +- docs/docs/api/appkit/Function.defineSchema.md | 15 +- docs/docs/api/appkit/Interface.Schema.md | 14 +- .../api/appkit/TypeAlias.IDatabaseConfig.md | 18 + docs/docs/api/appkit/index.md | 4 +- .../src/database/contract/tests/wire.test.ts | 4 + packages/appkit/src/database/contract/wire.ts | 4 + packages/appkit/src/database/errors.ts | 29 ++ .../appkit/src/database/runtime/data-path.ts | 1 + .../src/database/runtime/engine/translate.ts | 35 +- .../database/runtime/tests/translate.test.ts | 17 + .../database/schema-builder/define-schema.ts | 10 +- .../src/database/schema-builder/types.ts | 9 +- .../src/plugins/database/crud/codecs.ts | 118 +++++ .../src/plugins/database/crud/contract.ts | 243 +++++++++ .../src/plugins/database/crud/exposure.ts | 49 ++ .../appkit/src/plugins/database/crud/query.ts | 472 ++++++++++++++++++ .../src/plugins/database/crud/routes.ts | 181 +++++++ .../database/crud/tests/codecs.test.ts | 107 ++++ .../database/crud/tests/contract.test.ts | 145 ++++++ .../plugins/database/crud/tests/query.test.ts | 310 ++++++++++++ .../database/crud/tests/routes.test.ts | 338 +++++++++++++ .../appkit/src/plugins/database/database.ts | 106 +++- .../appkit/src/plugins/database/defaults.ts | 27 + .../src/plugins/database/entity-types.ts | 23 +- .../plugins/database/tests/crud-span.test.ts | 175 +++++++ .../database/tests/entity-types.test.ts | 18 +- .../src/plugins/database/tests/plugin.test.ts | 132 ++++- packages/appkit/src/plugins/database/types.ts | 44 ++ 29 files changed, 2623 insertions(+), 27 deletions(-) create mode 100644 packages/appkit/src/plugins/database/crud/codecs.ts create mode 100644 packages/appkit/src/plugins/database/crud/contract.ts create mode 100644 packages/appkit/src/plugins/database/crud/exposure.ts create mode 100644 packages/appkit/src/plugins/database/crud/query.ts create mode 100644 packages/appkit/src/plugins/database/crud/routes.ts create mode 100644 packages/appkit/src/plugins/database/crud/tests/codecs.test.ts create mode 100644 packages/appkit/src/plugins/database/crud/tests/contract.test.ts create mode 100644 packages/appkit/src/plugins/database/crud/tests/query.test.ts create mode 100644 packages/appkit/src/plugins/database/crud/tests/routes.test.ts create mode 100644 packages/appkit/src/plugins/database/tests/crud-span.test.ts diff --git a/docs/docs/api/appkit/Function.database.md b/docs/docs/api/appkit/Function.database.md index 8e0a3ace6..9aa74dcf8 100644 --- a/docs/docs/api/appkit/Function.database.md +++ b/docs/docs/api/appkit/Function.database.md @@ -14,7 +14,7 @@ Create a typed database plugin registration for a finalized schema. | Type Parameter | | ------ | -| `TSchema` *extends* [`Schema`](Interface.Schema.md) | +| `TSchema` *extends* [`Schema`](Interface.Schema.md)\<`string`\> | ## Parameters diff --git a/docs/docs/api/appkit/Function.defineSchema.md b/docs/docs/api/appkit/Function.defineSchema.md index 05b828ad4..f69cb343f 100644 --- a/docs/docs/api/appkit/Function.defineSchema.md +++ b/docs/docs/api/appkit/Function.defineSchema.md @@ -1,16 +1,25 @@ # Function: defineSchema() ```ts -function defineSchema(builder: (context: SchemaBuilderContext) => Record, options?: DefineSchemaOptions): Schema; +function defineSchema(builder: (context: SchemaBuilderContext) => TTables, options?: DefineSchemaOptions): Schema>; ``` +Compile one declared schema. The returned type keeps the table names the +builder returned, so `crudRoutes` and `hooks` can name only real tables. + +## Type Parameters + +| Type Parameter | +| ------ | +| `TTables` *extends* `Record`\<`string`, `AppKitTable`\> | + ## Parameters | Parameter | Type | | ------ | ------ | -| `builder` | (`context`: `SchemaBuilderContext`) => `Record`\<`string`, `AppKitTable`\> | +| `builder` | (`context`: `SchemaBuilderContext`) => `TTables` | | `options?` | `DefineSchemaOptions` | ## Returns -[`Schema`](Interface.Schema.md) +[`Schema`](Interface.Schema.md)\<`Extract`\\> diff --git a/docs/docs/api/appkit/Interface.Schema.md b/docs/docs/api/appkit/Interface.Schema.md index f94b39339..377ffc07f 100644 --- a/docs/docs/api/appkit/Interface.Schema.md +++ b/docs/docs/api/appkit/Interface.Schema.md @@ -1,4 +1,14 @@ -# Interface: Schema +# Interface: Schema\ + +One finalized schema. `TTableName` keeps the declared names in the type, so +configuration that addresses a table by name is checked against the schema +it was written for. Code that accepts any schema uses the default. + +## Type Parameters + +| Type Parameter | Default type | +| ------ | ------ | +| `TTableName` *extends* `string` | `string` | ## Properties @@ -21,5 +31,5 @@ readonly $schemaName: string; ### $tables ```ts -readonly $tables: Readonly>; +readonly $tables: Readonly>; ``` diff --git a/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md b/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md index 88bc807c5..d95217a4e 100644 --- a/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md +++ b/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md @@ -2,6 +2,8 @@ ```ts type IDatabaseConfig = { + crudRoutes?: CrudRoutesConfig; + hooks?: { readonly [TTable in SchemaTableName]?: { serialize?: ReadSerializer } }; schema: TSchema; }; ``` @@ -16,6 +18,22 @@ Configuration for one schema-bound DatabasePlugin instance. ## Properties +### crudRoutes? + +```ts +readonly optional crudRoutes: CrudRoutesConfig; +``` + +*** + +### hooks? + +```ts +readonly optional hooks: { readonly [TTable in SchemaTableName]?: { serialize?: ReadSerializer } }; +``` + +*** + ### schema ```ts diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index 6269a2dbe..36d752e56 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -75,7 +75,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [ResourceRequirement](Interface.ResourceRequirement.md) | Declares a resource requirement for a plugin. Can be defined statically in a manifest or dynamically via getResourceRequirements(). | | [RunAgentInput](Interface.RunAgentInput.md) | - | | [RunAgentResult](Interface.RunAgentResult.md) | - | -| [Schema](Interface.Schema.md) | - | +| [Schema](Interface.Schema.md) | One finalized schema. `TTableName` keeps the declared names in the type, so configuration that addresses a table by name is checked against the schema it was written for. Code that accepts any schema uses the default. | | [SearchRequest](Interface.SearchRequest.md) | - | | [SearchResponse](Interface.SearchResponse.md) | - | | [SearchResult](Interface.SearchResult.md) | - | @@ -155,7 +155,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [createLakebasePoolManager](Function.createLakebasePoolManager.md) | Create a pool manager that maintains per-key Lakebase connection pools. | | [createWorkspaceClient](Function.createWorkspaceClient.md) | Construct an AppKit workspace client. | | [database](Function.database.md) | Create a typed database plugin registration for a finalized schema. | -| [defineSchema](Function.defineSchema.md) | - | +| [defineSchema](Function.defineSchema.md) | Compile one declared schema. The returned type keeps the table names the builder returned, so `crudRoutes` and `hooks` can name only real tables. | | [defineTool](Function.defineTool.md) | Defines a single tool entry for a plugin's internal registry. | | [enumColumn](Function.enumColumn.md) | - | | [executeFromRegistry](Function.executeFromRegistry.md) | Validates tool-call arguments against the entry's schema and invokes its handler. On validation failure, returns an LLM-friendly error string (matching the behavior of `tool()`) rather than throwing, so the model can self-correct on its next turn. | diff --git a/packages/appkit/src/database/contract/tests/wire.test.ts b/packages/appkit/src/database/contract/tests/wire.test.ts index 727f2cf25..8ce3d0f23 100644 --- a/packages/appkit/src/database/contract/tests/wire.test.ts +++ b/packages/appkit/src/database/contract/tests/wire.test.ts @@ -5,6 +5,8 @@ import { type FilterOperator, IN_CAP, isFilterOperator, + MAX_INCLUDE_DEPTH, + MAX_INCLUDE_NODES, MAX_INCLUDES, MAX_LIMIT, } from "../index"; @@ -15,6 +17,8 @@ describe("wire caps", () => { expect(MAX_LIMIT).toBe(500); expect(DEFAULT_LIMIT).toBe(50); expect(MAX_INCLUDES).toBe(10); + expect(MAX_INCLUDE_DEPTH).toBe(2); + expect(MAX_INCLUDE_NODES).toBe(25); }); it("keeps DEFAULT_LIMIT within MAX_LIMIT", () => { diff --git a/packages/appkit/src/database/contract/wire.ts b/packages/appkit/src/database/contract/wire.ts index 0d8583023..d70b651d1 100644 --- a/packages/appkit/src/database/contract/wire.ts +++ b/packages/appkit/src/database/contract/wire.ts @@ -6,6 +6,10 @@ export const MAX_LIMIT = 500; export const DEFAULT_LIMIT = 50; /** Max number of relations resolvable in a single `.include()`. */ export const MAX_INCLUDES = 10; +/** Max number of relation edges one include path may traverse. */ +export const MAX_INCLUDE_DEPTH = 2; +/** Max number of relation nodes across a complete include tree. */ +export const MAX_INCLUDE_NODES = 25; /** Scalar values accepted by primary-key operations. */ export type IdValue = string | number | bigint; diff --git a/packages/appkit/src/database/errors.ts b/packages/appkit/src/database/errors.ts index 10b162256..d4be7e6fa 100644 --- a/packages/appkit/src/database/errors.ts +++ b/packages/appkit/src/database/errors.ts @@ -5,11 +5,19 @@ const logger = createLogger("database"); export type DatabaseErrorCategory = | "INVALID_REQUEST" + | "NOT_FOUND" | "CONFLICT" | "FORBIDDEN" + | "PAYLOAD_TOO_LARGE" | "INTERNAL" | "SETUP_FAILED"; +/** Which request field a rejection concerns; it never carries caller values. */ +export interface DatabaseErrorDetail { + readonly path: readonly string[]; + readonly message: string; +} + type DatabaseErrorPhase = | "setup" | "shutdown" @@ -23,8 +31,13 @@ const definitions: Record< { readonly message: string; readonly statusCode: number } > = { INVALID_REQUEST: { message: "Invalid database request", statusCode: 400 }, + NOT_FOUND: { message: "Database record not found", statusCode: 404 }, CONFLICT: { message: "Database conflict", statusCode: 409 }, FORBIDDEN: { message: "Database operation forbidden", statusCode: 403 }, + PAYLOAD_TOO_LARGE: { + message: "Database response is too large", + statusCode: 413, + }, INTERNAL: { message: "Database operation failed", statusCode: 500 }, SETUP_FAILED: { message: "Database setup failed", statusCode: 500 }, }; @@ -45,6 +58,7 @@ export class DatabasePluginError extends AppKitError { readonly category: DatabaseErrorCategory, readonly phase: DatabaseErrorPhase, runtimeMessage?: string, + readonly details?: readonly DatabaseErrorDetail[], ) { const definition = definitions[category]; // Plugin boundaries replace runtime diagnostics with the stable message. @@ -68,6 +82,21 @@ export function invalidDatabaseRequest( return new DatabasePluginError("INVALID_REQUEST", "runtime", runtimeMessage); } +/** Refuse to publish a plugin whose configuration cannot be honored. */ +export function databaseSetupFailed(): DatabasePluginError { + return new DatabasePluginError("SETUP_FAILED", "setup"); +} + +/** Reject untrusted request input, naming the field but never its value. */ +export function invalidDatabaseInput( + path: readonly string[], + message: string, +): DatabasePluginError { + return new DatabasePluginError("INVALID_REQUEST", "read", undefined, [ + { path, message }, + ]); +} + /** Add operation context without retaining an unknown error's details. */ export function classifyDatabaseError( error: unknown, diff --git a/packages/appkit/src/database/runtime/data-path.ts b/packages/appkit/src/database/runtime/data-path.ts index 75df4a846..54434fabe 100644 --- a/packages/appkit/src/database/runtime/data-path.ts +++ b/packages/appkit/src/database/runtime/data-path.ts @@ -27,6 +27,7 @@ export interface IncludeOptions { readonly where?: WhereClause; readonly order?: OrderSpec; readonly limit?: number; + readonly include?: IncludeSpec; } /** Selection and bounds for one declared relation edge. */ diff --git a/packages/appkit/src/database/runtime/engine/translate.ts b/packages/appkit/src/database/runtime/engine/translate.ts index fe7a3bd77..cd1cb5c17 100644 --- a/packages/appkit/src/database/runtime/engine/translate.ts +++ b/packages/appkit/src/database/runtime/engine/translate.ts @@ -22,6 +22,8 @@ import { type FilterOperator, IN_CAP, isFilterOperator, + MAX_INCLUDE_DEPTH, + MAX_INCLUDE_NODES, MAX_INCLUDES, } from "../../contract"; import { invalidDatabaseRequest } from "../../errors"; @@ -231,12 +233,27 @@ function tableByName(schema: Schema, name: string): AppKitTable { return table; } -/** Translate one relation edge into Drizzle's relational `with` config. */ +/** Translate relation edges into Drizzle's relational `with` config. */ export function translateInclude( table: AppKitTable, schema: Schema, include: IncludeSpec, ): Record { + return translateIncludeTree(table, schema, include, 1, { nodes: 0 }); +} + +function translateIncludeTree( + table: AppKitTable, + schema: Schema, + include: IncludeSpec, + depth: number, + budget: { nodes: number }, +): Record { + if (depth > MAX_INCLUDE_DEPTH) { + throw invalidDatabaseRequest( + `include exceeds the ${MAX_INCLUDE_DEPTH}-edge depth limit`, + ); + } const entries = Object.entries(include); if (entries.length > MAX_INCLUDES) { throw invalidDatabaseRequest( @@ -256,6 +273,13 @@ export function translateInclude( } if (rawOptions === false) continue; + budget.nodes += 1; + if (budget.nodes > MAX_INCLUDE_NODES) { + throw invalidDatabaseRequest( + `include exceeds the ${MAX_INCLUDE_NODES}-node limit`, + ); + } + const target = tableByName(schema, relation.targetTable); if (rawOptions === true) { config[relationName] = { @@ -286,6 +310,15 @@ export function translateInclude( } else if (relation.cardinality === "toMany") { relationConfig.limit = DEFAULT_LIMIT; } + if (options.include !== undefined) { + relationConfig.with = translateIncludeTree( + target, + schema, + options.include, + depth + 1, + budget, + ); + } config[relationName] = relationConfig; } return config; diff --git a/packages/appkit/src/database/runtime/tests/translate.test.ts b/packages/appkit/src/database/runtime/tests/translate.test.ts index b1b9e65b6..4345cfb14 100644 --- a/packages/appkit/src/database/runtime/tests/translate.test.ts +++ b/packages/appkit/src/database/runtime/tests/translate.test.ts @@ -257,6 +257,23 @@ describe("translateInclude", () => { expect(render(config.posts.where as SQL).params).toEqual(["a%"]); }); + it("resolves a second relation edge with the target's own defaults", () => { + const config = translateInclude(users, schema, { + posts: { include: { users: true } }, + }) as { posts: { with: Record } }; + expect(config.posts.with).toEqual({ + users: { columns: defaultColumns(users) }, + }); + }); + + it("stops after the second relation edge", () => { + expect(() => + translateInclude(users, schema, { + posts: { include: { users: { include: { posts: true } } } }, + }), + ).toThrow(DatabasePluginError); + }); + it("rejects unknown relations and invalid relation limits", () => { expect(() => translateInclude(users, schema, { missing: true })).toThrow( DatabasePluginError, diff --git a/packages/appkit/src/database/schema-builder/define-schema.ts b/packages/appkit/src/database/schema-builder/define-schema.ts index be24ea6ff..bcf970baa 100644 --- a/packages/appkit/src/database/schema-builder/define-schema.ts +++ b/packages/appkit/src/database/schema-builder/define-schema.ts @@ -339,10 +339,14 @@ export function assertFinalizedSchema(value: unknown): asserts value is Schema { } } -export function defineSchema( - builder: (context: SchemaBuilderContext) => Record, +/** + * Compile one declared schema. The returned type keeps the table names the + * builder returned, so `crudRoutes` and `hooks` can name only real tables. + */ +export function defineSchema>( + builder: (context: SchemaBuilderContext) => TTables, options?: DefineSchemaOptions, -): Schema { +): Schema> { const schemaName = options?.schemaName ?? "public"; if (!schemaName) throw new SchemaBuildError("Schema name cannot be empty"); diff --git a/packages/appkit/src/database/schema-builder/types.ts b/packages/appkit/src/database/schema-builder/types.ts index 38b993623..d49a14b02 100644 --- a/packages/appkit/src/database/schema-builder/types.ts +++ b/packages/appkit/src/database/schema-builder/types.ts @@ -160,9 +160,14 @@ export interface DefineSchemaOptions { readonly schemaName?: string; } -export interface Schema { +/** + * One finalized schema. `TTableName` keeps the declared names in the type, so + * configuration that addresses a table by name is checked against the schema + * it was written for. Code that accepts any schema uses the default. + */ +export interface Schema { readonly $schemaName: string; - readonly $tables: Readonly>; + readonly $tables: Readonly>; readonly $engine: Readonly>; } diff --git a/packages/appkit/src/plugins/database/crud/codecs.ts b/packages/appkit/src/plugins/database/crud/codecs.ts new file mode 100644 index 000000000..a474859fc --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/codecs.ts @@ -0,0 +1,118 @@ +import { DatabasePluginError } from "../../../database/errors"; +import type { ScalarValue } from "../../../database/runtime"; +import type { ColumnMeta } from "../../../database/schema-builder"; +import { columnValueSchema } from "../../../database/schema-builder/validators"; + +/** JSON-representable value; every generated response is built from these. */ +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +/** Both wire directions for one column, compiled once per exposed table. */ +export interface CompiledColumn { + readonly meta: ColumnMeta; + /** Untrusted wire value to canonical runtime value; `undefined` when invalid. */ + decode(raw: unknown): ScalarValue | undefined; + /** Trusted runtime value to its deterministic JSON form. */ + encode(value: unknown): JsonValue; +} + +/** Decimal integer with no sign padding, leading zeros, or exponent. */ +const DECIMAL_INT = /^-?(0|[1-9]\d*)$/; + +/** + * Map a wire value onto the runtime representation the schema validators and + * Drizzle columns expect. Only path segments and JSON scalars reach this, so + * the accepted shapes stay exact rather than coercing whatever parses. + */ +function toRuntimeValue( + kind: ColumnMeta["kind"], + raw: unknown, +): ScalarValue | undefined { + switch (kind) { + case "number": + if (typeof raw === "number") return raw; + return typeof raw === "string" && DECIMAL_INT.test(raw) + ? Number(raw) + : undefined; + case "bigint": + if (typeof raw === "string" && DECIMAL_INT.test(raw)) return BigInt(raw); + return typeof raw === "number" && Number.isSafeInteger(raw) + ? BigInt(raw) + : undefined; + case "boolean": + return typeof raw === "boolean" ? raw : undefined; + case "string": + case "uuid": + case "enum": + case "date": + return typeof raw === "string" ? raw : undefined; + default: + return undefined; + } +} + +function encodeValue(meta: ColumnMeta, value: unknown): JsonValue { + if (value === null || value === undefined) return null; + switch (meta.kind) { + case "bigint": + // JSON cannot carry a bigint, and a large id must not lose precision. + if (typeof value === "bigint") return value.toString(); + if (typeof value === "number" && Number.isSafeInteger(value)) { + return value.toString(); + } + break; + case "number": + if (typeof value === "number" && Number.isFinite(value)) return value; + break; + case "boolean": + if (typeof value === "boolean") return value; + break; + case "string": + case "uuid": + case "enum": + case "date": + if (typeof value === "string") return value; + break; + case "json": + if (isJsonValue(value)) return value; + break; + case "unknown": + break; + } + // The driver produced a value this column contract cannot describe. + throw new DatabasePluginError("INTERNAL", "read"); +} + +function isJsonValue(value: unknown): value is JsonValue { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" + ) { + return true; + } + if (typeof value === "number") return Number.isFinite(value); + if (Array.isArray(value)) return true; + if (typeof value !== "object") return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** Compile the wire codecs for one column from its finalized metadata. */ +export function compileColumn(meta: ColumnMeta): CompiledColumn { + const schema = columnValueSchema(meta); + return { + meta, + decode: (raw) => { + const candidate = toRuntimeValue(meta.kind, raw); + if (candidate === undefined) return undefined; + return schema.safeParse(candidate).success ? candidate : undefined; + }, + encode: (value) => encodeValue(meta, value), + }; +} diff --git a/packages/appkit/src/plugins/database/crud/contract.ts b/packages/appkit/src/plugins/database/crud/contract.ts new file mode 100644 index 000000000..e0bf5a060 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/contract.ts @@ -0,0 +1,243 @@ +import { + DatabasePluginError, + invalidDatabaseInput, +} from "../../../database/errors"; +import type { IdValue, Row } from "../../../database/runtime"; +import type { AppKitTable } from "../../../database/schema-builder"; +import { filterOperatorsForKind } from "../../../database/schema-builder/types"; +import { MAX_SERIALIZED_DEPTH, MAX_SERIALIZED_NODES } from "../defaults"; +import { type CompiledColumn, compileColumn, type JsonValue } from "./codecs"; + +/** One relation edge wired to the contract of its target table. */ +export interface CrudRelation { + readonly cardinality: "toOne" | "toMany"; + readonly target: CrudTable; +} + +/** Private HTTP contract compiled once for one explicitly exposed table. */ +export interface CrudTable { + readonly name: string; + readonly primaryKey?: CompiledColumn; + readonly columns: ReadonlyMap; + /** Public columns a request may project. */ + readonly selectable: ReadonlySet; + /** Public columns a request may filter or order by. */ + readonly queryable: ReadonlySet; + readonly relations: ReadonlyMap; + decodeId(raw: string): IdValue; + projectPublicRow(row: Row): JsonValue; + sanitizeSerializedRow(row: unknown): JsonValue; +} + +type MutableCrudTable = Omit & { + readonly relations: Map; +}; + +interface SanitizeState { + nodes: number; + readonly ancestors: Set; +} + +/** A bare object literal; a `Date`, class instance, or `Map` is not JSON. */ +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** A serializer that breaks its contract is trusted code failing, not input. */ +function serializerFault(): never { + throw new DatabasePluginError("INTERNAL", "read"); +} + +/** Charge one value against the output budget before descending into it. */ +function countNode(depth: number, state: SanitizeState): void { + state.nodes += 1; + if (state.nodes > MAX_SERIALIZED_NODES || depth > MAX_SERIALIZED_DEPTH) { + serializerFault(); + } +} + +/** Walk a container while its ancestors are tracked, so a cycle cannot pass. */ +function enterObject( + value: object, + state: SanitizeState, + visit: () => T, +): T { + if (state.ancestors.has(value)) serializerFault(); + state.ancestors.add(value); + try { + return visit(); + } finally { + state.ancestors.delete(value); + } +} + +/** Accept a serializer's own added value only where it is already JSON. */ +function sanitizeJson( + value: unknown, + depth: number, + state: SanitizeState, +): JsonValue { + countNode(depth, state); + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" + ) { + return value; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) serializerFault(); + return value; + } + if (Array.isArray(value)) { + return enterObject(value, state, () => + value.map((item) => sanitizeJson(item, depth + 1, state)), + ); + } + if (!isPlainObject(value)) serializerFault(); + return enterObject(value, state, () => { + // A null prototype keeps `__proto__` an ordinary key instead of a setter. + const out: Record = Object.create(null); + for (const [key, child] of Object.entries(value)) { + if (child === undefined) continue; + out[key] = sanitizeJson(child, depth + 1, state); + } + return out; + }); +} + +/** Keep an included row under its own table's policy, one row or many. */ +function sanitizeRelation( + target: CrudTable, + value: unknown, + depth: number, + state: SanitizeState, +): JsonValue { + if (value === null) return null; + if (!Array.isArray(value)) return sanitizeRow(target, value, depth, state); + countNode(depth, state); + return enterObject(value, state, () => + value.map((row) => sanitizeRow(target, row, depth + 1, state)), + ); +} + +/** Re-apply the private-column policy wherever the output stays contracted. */ +function sanitizeRow( + table: CrudTable, + row: unknown, + depth: number, + state: SanitizeState, +): JsonValue { + countNode(depth, state); + if (!isPlainObject(row)) serializerFault(); + return enterObject(row, state, () => { + const out: Record = {}; + for (const [key, child] of Object.entries(row)) { + if (child === undefined) continue; + if (table.columns.get(key)?.meta.isPrivate) continue; + const relation = table.relations.get(key); + out[key] = relation + ? sanitizeRelation(relation.target, child, depth + 1, state) + : sanitizeJson(child, depth + 1, state); + } + return out; + }); +} + +/** Project an included row through its own table; absent to-one reads null. */ +function projectRelation(target: CrudTable, value: unknown): JsonValue { + if (value === null || value === undefined) return null; + return Array.isArray(value) + ? value.map((row) => target.projectPublicRow(row as Row)) + : target.projectPublicRow(value as Row); +} + +/** Build the public JSON for one driver row, dropping anything uncontracted. */ +function projectRow(table: CrudTable, row: Row): JsonValue { + const out: Record = {}; + for (const [key, value] of Object.entries(row)) { + const column = table.columns.get(key); + if (column) { + // Whatever the driver returned, only public contracted columns ship. + if (!column.meta.isPrivate) out[key] = column.encode(value); + continue; + } + const relation = table.relations.get(key); + if (relation) out[key] = projectRelation(relation.target, value); + } + return out; +} + +/** Compile one table's allowlists and codecs from its finalized metadata. */ +function compileTable(table: AppKitTable): MutableCrudTable { + const columns = new Map(); + const selectable = new Set(); + const queryable = new Set(); + let primaryKey: CompiledColumn | undefined; + + for (const meta of Object.values(table.$columns)) { + const column = compileColumn(meta); + columns.set(meta.columnName, column); + if (meta.primaryKey) primaryKey = column; + if (meta.isPrivate) continue; + selectable.add(meta.columnName); + if (filterOperatorsForKind(meta.kind).length > 0) { + queryable.add(meta.columnName); + } + } + + const compiled: MutableCrudTable = { + name: table.$name, + primaryKey, + columns, + selectable, + queryable, + relations: new Map(), + decodeId: (raw) => { + if (!primaryKey) throw new DatabasePluginError("INTERNAL", "read"); + const value = primaryKey.decode(raw); + if ( + typeof value !== "string" && + typeof value !== "number" && + typeof value !== "bigint" + ) { + throw invalidDatabaseInput(["id"], "Not a valid identifier"); + } + return value; + }, + projectPublicRow: (row) => projectRow(compiled, row), + sanitizeSerializedRow: (row) => + sanitizeRow(compiled, row, 0, { nodes: 0, ancestors: new Set() }), + }; + return compiled; +} + +/** + * Compile the HTTP contract for every exposed table and wire the relations + * they share. A relation whose target is not exposed stays unreachable, so + * enabling one table never widens another table's public surface. + */ +export function compileCrudTables( + tables: Record, +): Map { + const compiled = new Map(); + for (const table of Object.values(tables)) { + compiled.set(table.$name, compileTable(table)); + } + for (const table of Object.values(tables)) { + const entry = compiled.get(table.$name); + for (const relation of table.$relations) { + const target = compiled.get(relation.targetTable); + if (!entry || !target) continue; + entry.relations.set(relation.name, { + cardinality: relation.cardinality, + target, + }); + } + } + return compiled as Map; +} diff --git a/packages/appkit/src/plugins/database/crud/exposure.ts b/packages/appkit/src/plugins/database/crud/exposure.ts new file mode 100644 index 000000000..c77834a83 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/exposure.ts @@ -0,0 +1,49 @@ +import { databaseSetupFailed } from "../../../database/errors"; + +/** A table name also becomes a URL path segment, so keep it unambiguous. */ +const ROUTABLE_TABLE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; + +/** Refuse names that cannot address exactly one table over HTTP. */ +function assertRoutable(names: readonly string[]): void { + const lowercased = new Set(); + for (const name of names) { + // Express matches paths case-insensitively, so near-duplicates would alias. + if (!ROUTABLE_TABLE.test(name) || lowercased.has(name.toLowerCase())) { + throw databaseSetupFailed(); + } + lowercased.add(name.toLowerCase()); + } +} + +/** + * Resolve the tables whose generated reads are explicitly turned on. The + * exposure value arrives untyped because a finalized schema widens its table + * names to `string`, so every name is re-checked against the declared schema. + */ +export function resolveExposedTables( + exposure: unknown, + declared: readonly string[], +): string[] { + if (exposure === undefined || exposure === false) return []; + if (exposure === true) { + assertRoutable(declared); + return [...declared]; + } + if (typeof exposure !== "object" || exposure === null) { + throw databaseSetupFailed(); + } + const requested = (exposure as { tables?: unknown }).tables; + if (!Array.isArray(requested)) throw databaseSetupFailed(); + + const names: string[] = []; + for (const name of requested) { + // Unknown and duplicate names are configuration bugs, not empty routes. + if (typeof name !== "string" || !declared.includes(name)) { + throw databaseSetupFailed(); + } + if (names.includes(name)) throw databaseSetupFailed(); + names.push(name); + } + assertRoutable(names); + return names; +} diff --git a/packages/appkit/src/plugins/database/crud/query.ts b/packages/appkit/src/plugins/database/crud/query.ts new file mode 100644 index 000000000..80394d0ab --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/query.ts @@ -0,0 +1,472 @@ +import { + DEFAULT_LIMIT, + IN_CAP, + isFilterOperator, + MAX_INCLUDE_DEPTH, + MAX_INCLUDE_NODES, + MAX_INCLUDES, + MAX_LIMIT, +} from "../../../database/contract"; +import { invalidDatabaseInput } from "../../../database/errors"; +import type { + IncludeOptions, + IncludeSpec, + OrderDirection, + OrderSpec, + ScalarValue, + WhereClause, + WhereValue, +} from "../../../database/runtime"; +import { filterOperatorsForKind } from "../../../database/schema-builder/types"; +import { + MAX_GROUP_ITEMS, + MAX_MATERIALIZED_NODES, + MAX_OFFSET, + MAX_ORDER_FIELDS, + MAX_QUERY_BYTES, + MAX_WHERE_CONDITIONS, + MAX_WHERE_DEPTH, +} from "../defaults"; +import type { CompiledColumn } from "./codecs"; +import type { CrudTable } from "./contract"; + +/** One decoded, budget-checked page request. */ +interface DecodedListQuery { + readonly where?: WhereClause; + readonly order?: OrderSpec; + readonly select?: string[]; + readonly include?: IncludeSpec; + readonly limit: number; + readonly offset: number; +} + +/** One decoded, budget-checked single-row request. */ +interface DecodedDetailQuery { + readonly select?: string[]; + readonly include?: IncludeSpec; +} + +const LIST_PARAMS = new Set([ + "where", + "order", + "select", + "include", + "limit", + "offset", +]); +const DETAIL_PARAMS = new Set(["select", "include"]); +const CANONICAL_INT = /^-?(0|[1-9]\d*)$/; + +/** + * Reject one query parameter. `parameter` is always a fixed name and `reason` + * a fixed sentence, so a rejection can never echo caller-supplied text back. + */ +function reject(parameter: string, reason: string): never { + throw invalidDatabaseInput([parameter], reason); +} + +/** A decoded JSON object; arrays and null are separate wire shapes here. */ +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Read known, single-occurrence parameters from the raw query string. */ +function parseParams( + rawQuery: string, + allowed: ReadonlySet, +): Map { + if (Buffer.byteLength(rawQuery, "utf8") > MAX_QUERY_BYTES) { + reject("query", "Query string exceeds the maximum size"); + } + const params = new Map(); + for (const [key, value] of new URLSearchParams(rawQuery)) { + if (!allowed.has(key) || params.has(key)) { + reject("query", "Unknown or repeated query parameter"); + } + params.set(key, value); + } + return params; +} + +/** Parse one structured parameter without leaking the parser's diagnostics. */ +function parseJson(raw: string, parameter: string): unknown { + try { + return JSON.parse(raw); + } catch { + reject(parameter, "Expected JSON"); + } +} + +/** Accept one bounded integer, rejecting the forms `Number` would coerce. */ +function decodeIntParam( + raw: string | undefined, + parameter: string, + fallback: number, + max: number, +): number { + if (raw === undefined) return fallback; + if (!CANONICAL_INT.test(raw)) { + reject(parameter, "Expected a canonical decimal integer"); + } + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < 0 || value > max) { + reject(parameter, `Must be between 0 and ${max}`); + } + return value; +} + +/** Narrow a projection to the table's public columns. */ +function decodeSelect( + table: CrudTable, + value: unknown, + parameter: string, +): string[] { + if (!Array.isArray(value) || value.length === 0) { + reject(parameter, "Expected a non-empty array of public column names"); + } + for (const name of value) { + if (typeof name !== "string" || !table.selectable.has(name)) { + reject(parameter, "Names an unknown or private column"); + } + } + return value as string[]; +} + +/** Order only by columns the table can sort on, in a declared direction. */ +function decodeOrder( + table: CrudTable, + value: unknown, + parameter: string, +): OrderSpec { + if (!isPlainObject(value)) { + reject(parameter, "Expected an object of column directions"); + } + const entries = Object.entries(value); + if (entries.length === 0 || entries.length > MAX_ORDER_FIELDS) { + reject(parameter, `Expected 1 to ${MAX_ORDER_FIELDS} columns`); + } + const order: Record = {}; + for (const [column, direction] of entries) { + if (!table.queryable.has(column)) { + reject(parameter, "Names an unknown or unorderable column"); + } + if (direction !== "asc" && direction !== "desc") { + reject(parameter, "Expected a direction of asc or desc"); + } + order[column] = direction; + } + return order; +} + +/** Move one filter operand onto its column's canonical runtime value. */ +function decodeOperand( + column: CompiledColumn, + raw: unknown, + parameter: string, +): ScalarValue { + const value = column.decode(raw); + if (value === undefined) + reject(parameter, "Operand does not match its column type"); + return value; +} + +/** + * Decode the predicate on one column against the operator matrix its kind + * allows, counting conditions so a filter cannot grow without bound. + */ +function decodeCondition( + column: CompiledColumn, + value: unknown, + parameter: string, + state: { conditions: number }, +): WhereValue { + const countCondition = () => { + state.conditions += 1; + if (state.conditions > MAX_WHERE_CONDITIONS) { + reject(parameter, `Expected at most ${MAX_WHERE_CONDITIONS} conditions`); + } + }; + + if (value === null) { + // SQL three-valued matching stays explicit; a bare null is ambiguous. + reject(parameter, "Match a null column with is: null"); + } + if (!isPlainObject(value)) { + if (Array.isArray(value)) { + reject(parameter, "Expected a scalar or an operator object"); + } + countCondition(); + return decodeOperand(column, value, parameter); + } + + const nullable = !column.meta.notNull; + const supported = filterOperatorsForKind(column.meta.kind); + const operators: Record = {}; + const entries = Object.entries(value); + if (entries.length === 0) reject(parameter, "Filter cannot be empty"); + + for (const [operator, operand] of entries) { + countCondition(); + if (operator === "is") { + if (operand !== null || !nullable) { + reject( + parameter, + "The is operator accepts only null on a nullable column", + ); + } + operators.is = null; + continue; + } + if (!isFilterOperator(operator) || !supported.includes(operator)) { + reject(parameter, "Operator is not supported for this column"); + } + if (operator !== "in") { + operators[operator] = decodeOperand(column, operand, parameter); + continue; + } + if (!Array.isArray(operand) || operand.length > IN_CAP) { + reject(parameter, `Expected an array of at most ${IN_CAP} values`); + } + operators.in = operand.map((item) => { + if (item === null) + reject(parameter, "The in operator does not accept null"); + return decodeOperand(column, item, parameter); + }); + } + return operators as WhereValue; +} + +/** Decode one filter level, recursing through bounded `and`/`or` groups. */ +function decodeWhere( + table: CrudTable, + node: unknown, + depth: number, + parameter: string, + state: { conditions: number }, +): WhereClause { + if (depth > MAX_WHERE_DEPTH) { + reject(parameter, `Expected at most ${MAX_WHERE_DEPTH} nesting levels`); + } + if (!isPlainObject(node)) reject(parameter, "Expected an object"); + + const clause: Record = {}; + for (const [key, value] of Object.entries(node)) { + if (key === "and" || key === "or") { + if ( + !Array.isArray(value) || + value.length === 0 || + value.length > MAX_GROUP_ITEMS + ) { + reject(parameter, `Expected 1 to ${MAX_GROUP_ITEMS} group members`); + } + clause[key] = value.map((member) => + decodeWhere(table, member, depth + 1, parameter, state), + ); + continue; + } + // Relations are absent from `queryable`, so relation predicates fail here. + const column = table.queryable.has(key) + ? table.columns.get(key) + : undefined; + if (!column) reject(parameter, "Names an unknown or unfilterable column"); + clause[key] = decodeCondition(column, value, parameter, state); + } + return clause; +} + +/** + * Decode one relation's options against the target table, not the parent, and + * give every to-many edge a limit so an unqualified include stays bounded. + */ +function decodeIncludeOptions( + target: CrudTable, + value: unknown, + depth: number, + toMany: boolean, + parameter: string, +): boolean | IncludeOptions { + if (value === true) return toMany ? { limit: DEFAULT_LIMIT } : true; + if (!isPlainObject(value)) { + reject(parameter, "Expected true or an options object"); + } + + const options: { + select?: string[]; + where?: WhereClause; + order?: OrderSpec; + limit?: number; + include?: IncludeSpec; + } = {}; + for (const [key, inner] of Object.entries(value)) { + switch (key) { + case "select": + options.select = decodeSelect(target, inner, parameter); + break; + case "where": + options.where = decodeWhere(target, inner, 1, parameter, { + conditions: 0, + }); + break; + case "order": + options.order = decodeOrder(target, inner, parameter); + break; + case "limit": + if (!toMany) reject(parameter, "Only to-many relations accept a limit"); + if ( + typeof inner !== "number" || + !Number.isSafeInteger(inner) || + inner < 0 || + inner > MAX_LIMIT + ) { + reject(parameter, `Expected a limit between 0 and ${MAX_LIMIT}`); + } + options.limit = inner; + break; + case "include": + options.include = decodeInclude(target, inner, depth + 1, parameter); + break; + default: + reject(parameter, "Unsupported relation option"); + } + } + if (toMany && options.limit === undefined) options.limit = DEFAULT_LIMIT; + return options; +} + +/** Decode one include level; a relation to an unexposed table has no edge. */ +function decodeInclude( + table: CrudTable, + value: unknown, + depth: number, + parameter: string, +): IncludeSpec { + if (depth > MAX_INCLUDE_DEPTH) { + reject(parameter, `Expected at most ${MAX_INCLUDE_DEPTH} relation edges`); + } + if (!isPlainObject(value)) { + reject(parameter, "Expected an object of relation names"); + } + const entries = Object.entries(value); + if (entries.length > MAX_INCLUDES) { + reject(parameter, `Expected at most ${MAX_INCLUDES} relations per level`); + } + + const include: Record = {}; + for (const [name, options] of entries) { + const relation = table.relations.get(name); + if (!relation) reject(parameter, "Names an unknown or unexposed relation"); + include[name] = decodeIncludeOptions( + relation.target, + options, + depth, + relation.cardinality === "toMany", + parameter, + ); + } + return include; +} + +/** + * Rows one include tree materializes per parent row, counting relation nodes + * on the way. Every term is a non-negative integer bounded by `MAX_LIMIT` and + * both running totals are checked after each addition, so neither can overflow. + */ +function measureInclude( + table: CrudTable, + include: IncludeSpec | undefined, + budget: { nodes: number }, +): number { + let rows = 1; + if (!include) return rows; + for (const [name, value] of Object.entries(include)) { + const relation = table.relations.get(name); + if (!relation) continue; + budget.nodes += 1; + if (budget.nodes > MAX_INCLUDE_NODES) { + reject("include", `Expected at most ${MAX_INCLUDE_NODES} relations`); + } + const options = value === true ? undefined : (value as IncludeOptions); + const child = measureInclude(relation.target, options?.include, budget); + rows += + relation.cardinality === "toMany" + ? (options?.limit ?? DEFAULT_LIMIT) * child + : child; + if (rows > MAX_MATERIALIZED_NODES) { + reject("include", "Relation fan-out exceeds the read budget"); + } + } + return rows; +} + +/** Reject an include tree whose cost is only visible once it is assembled. */ +function assertReadBudget( + table: CrudTable, + include: IncludeSpec | undefined, + rootRows: number, +): void { + const rows = measureInclude(table, include, { nodes: 0 }); + if (rootRows * rows > MAX_MATERIALIZED_NODES) { + reject("include", "Relation fan-out exceeds the read budget"); + } +} + +/** Decode the projection parameters both reads share. */ +function decodeProjection( + table: CrudTable, + params: ReadonlyMap, +): DecodedDetailQuery { + const rawSelect = params.get("select"); + const rawInclude = params.get("include"); + return { + select: + rawSelect === undefined + ? undefined + : decodeSelect(table, parseJson(rawSelect, "select"), "select"), + include: + rawInclude === undefined + ? undefined + : decodeInclude(table, parseJson(rawInclude, "include"), 1, "include"), + }; +} + +/** Decode `GET /:table` and reject it before any database work is scheduled. */ +export function decodeListQuery( + table: CrudTable, + rawQuery: string, +): DecodedListQuery { + const params = parseParams(rawQuery, LIST_PARAMS); + const rawWhere = params.get("where"); + const rawOrder = params.get("order"); + + const where = + rawWhere === undefined + ? undefined + : decodeWhere(table, parseJson(rawWhere, "where"), 1, "where", { + conditions: 0, + }); + const order = + rawOrder === undefined + ? undefined + : decodeOrder(table, parseJson(rawOrder, "order"), "order"); + const { select, include } = decodeProjection(table, params); + const limit = decodeIntParam( + params.get("limit"), + "limit", + DEFAULT_LIMIT, + MAX_LIMIT, + ); + const offset = decodeIntParam(params.get("offset"), "offset", 0, MAX_OFFSET); + + assertReadBudget(table, include, limit); + return { where, order, select, include, limit, offset }; +} + +/** Decode `GET /:table/:id`, which supports projection and includes only. */ +export function decodeDetailQuery( + table: CrudTable, + rawQuery: string, +): DecodedDetailQuery { + const decoded = decodeProjection(table, parseParams(rawQuery, DETAIL_PARAMS)); + assertReadBudget(table, decoded.include, 1); + return decoded; +} diff --git a/packages/appkit/src/plugins/database/crud/routes.ts b/packages/appkit/src/plugins/database/crud/routes.ts new file mode 100644 index 000000000..41829a974 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/routes.ts @@ -0,0 +1,181 @@ +import type { Request, Response } from "express"; +import { + classifyDatabaseError, + type DatabaseErrorDetail, + DatabasePluginError, + invalidDatabaseInput, +} from "../../../database/errors"; +import type { + IdValue, + IncludeSpec, + OrderSpec, + Row, + WhereClause, +} from "../../../database/runtime"; +import { MAX_RESPONSE_BYTES } from "../defaults"; +import type { ReadSerializer } from "../types"; +import type { JsonValue } from "./codecs"; +import type { CrudTable } from "./contract"; +import { decodeDetailQuery, decodeListQuery } from "./query"; + +/** The `EntityClient` subset a generated read drives. */ +export interface CrudReadEntity { + where(where: WhereClause): CrudReadEntity; + order(order: OrderSpec): CrudReadEntity; + select(columns: string[]): CrudReadEntity; + include(include: IncludeSpec): CrudReadEntity; + limit(limit: number): CrudReadEntity; + offset(offset: number): CrudReadEntity; + toArray(): Promise; + find(id: IdValue): Promise; +} + +/** Everything one table's generated reads need from the plugin instance. */ +export interface ReadRouteDeps { + readonly table: CrudTable; + /** Resolved per request so a draining plugin cannot serve a stale client. */ + entity(): CrudReadEntity; + readonly serialize?: ReadSerializer; + runRouteSpan( + operation: "list" | "detail", + route: string, + run: () => Promise, + ): Promise; +} + +type ReadHandler = (req: Request, res: Response) => Promise; + +/** Low-cardinality span outcome for one failed generated read. */ +export function readRouteOutcome( + error: unknown, +): "not_found" | "rejected" | "failed" { + const { statusCode } = classifyDatabaseError(error, "read"); + if (statusCode === 404) return "not_found"; + return statusCode < 500 ? "rejected" : "failed"; +} + +/** Express normalizes `req.query`; the decoders need the untouched string. */ +function rawQuery(req: Request): string { + const url = req.originalUrl ?? req.url; + const start = url.indexOf("?"); + return start === -1 ? "" : url.slice(start + 1); +} + +/** + * Append the primary key so equal sort keys cannot reshuffle between pages. + * A keyless table has no unique tie-breaker to append, so it must order itself + * and its pages stay stable only while no concurrent write reorders them. + */ +function stableOrder( + primaryKey: string | undefined, + order: OrderSpec | undefined, +): OrderSpec { + if (primaryKey) + return { ...order, [primaryKey]: order?.[primaryKey] ?? "asc" }; + if (!order) { + throw invalidDatabaseInput( + ["order"], + "A table without a primary key requires an explicit order", + ); + } + return order; +} + +function serializeRow( + deps: ReadRouteDeps, + operation: "list" | "detail", + row: Row, +): JsonValue { + const projected = deps.table.projectPublicRow(row); + if (!deps.serialize) return projected; + const shaped = deps.serialize(projected as Record, { + entity: deps.table.name, + operation, + }); + return deps.table.sanitizeSerializedRow(shaped); +} + +/** + * Row data is never cacheable by a shared proxy or a browser: the same URL can + * answer differently once the underlying table or the caller's rights change. + */ +function writeJson(res: Response, status: number, payload: string): void { + res.status(status); + res.type("application/json"); + res.setHeader("Cache-Control", "no-store"); + res.send(payload); +} + +/** Measure the encoded body before sending so no partial response escapes. */ +function sendJson(res: Response, body: JsonValue): void { + const payload = JSON.stringify(body); + if (Buffer.byteLength(payload, "utf8") > MAX_RESPONSE_BYTES) { + throw new DatabasePluginError("PAYLOAD_TOO_LARGE", "read"); + } + writeJson(res, 200, payload); +} + +/** Answer with the failure's safe category and the field it concerns. */ +function writeError(res: Response, error: unknown): void { + if (res.headersSent) return; + const safe = classifyDatabaseError(error, "read"); + const body: { error: string; details?: readonly DatabaseErrorDetail[] } = { + error: safe.clientMessage, + }; + if (safe.details && safe.details.length > 0) body.details = safe.details; + writeJson(res, safe.statusCode, JSON.stringify(body)); +} + +/** `GET /:table` — one bounded page in the `{ items, limit, offset }` envelope. */ +export function createListHandler(deps: ReadRouteDeps): ReadHandler { + const primaryKey = deps.table.primaryKey?.meta.columnName; + const route = `/${deps.table.name}`; + + return async (req, res) => { + try { + await deps.runRouteSpan("list", route, async () => { + const decoded = decodeListQuery(deps.table, rawQuery(req)); + let query = deps + .entity() + .order(stableOrder(primaryKey, decoded.order)) + .limit(decoded.limit) + .offset(decoded.offset); + if (decoded.where) query = query.where(decoded.where); + if (decoded.select) query = query.select(decoded.select); + if (decoded.include) query = query.include(decoded.include); + + const rows = await query.toArray(); + sendJson(res, { + items: rows.map((row) => serializeRow(deps, "list", row)), + limit: decoded.limit, + offset: decoded.offset, + }); + }); + } catch (error) { + writeError(res, error); + } + }; +} + +/** `GET /:table/:id` — one public row, or 404 when nothing matches. */ +export function createDetailHandler(deps: ReadRouteDeps): ReadHandler { + const route = `/${deps.table.name}/:id`; + + return async (req, res) => { + try { + await deps.runRouteSpan("detail", route, async () => { + const id = deps.table.decodeId(req.params.id); + const decoded = decodeDetailQuery(deps.table, rawQuery(req)); + let query = deps.entity(); + if (decoded.select) query = query.select(decoded.select); + if (decoded.include) query = query.include(decoded.include); + + const row = await query.find(id); + if (row === null) throw new DatabasePluginError("NOT_FOUND", "read"); + sendJson(res, serializeRow(deps, "detail", row)); + }); + } catch (error) { + writeError(res, error); + } + }; +} diff --git a/packages/appkit/src/plugins/database/crud/tests/codecs.test.ts b/packages/appkit/src/plugins/database/crud/tests/codecs.test.ts new file mode 100644 index 000000000..09423c808 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/tests/codecs.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { DatabasePluginError } from "../../../../database/errors"; +import { + bigint, + boolean, + defineSchema, + enumColumn, + id, + integer, + jsonb, + text, + timestamp, + uuid, + varchar, +} from "../../../../database/schema-builder"; +import { compileColumn } from "../codecs"; + +const schema = defineSchema((builder) => { + const things = builder.table("things", { + id: id(), + name: text(), + label: varchar(4), + count: integer(), + total: bigint(), + active: boolean(), + external: uuid(), + status: enumColumn("codec_status", ["active", "disabled"]), + createdAt: timestamp(), + payload: jsonb(), + }); + return { things }; +}); + +const columns = schema.$tables.things.$columns; +const column = (name: keyof typeof columns) => compileColumn(columns[name]); +const UUID = "123e4567-e89b-12d3-a456-426614174000"; + +describe("compileColumn decode", () => { + it("accepts the canonical wire form of every supported kind", () => { + expect(column("count").decode(7)).toBe(7); + expect(column("count").decode("7")).toBe(7); + expect(column("total").decode("9007199254740993")).toBe(9007199254740993n); + expect(column("total").decode(12)).toBe(12n); + expect(column("active").decode(true)).toBe(true); + expect(column("name").decode("Ada")).toBe("Ada"); + expect(column("external").decode(UUID)).toBe(UUID); + expect(column("status").decode("active")).toBe("active"); + expect(column("createdAt").decode("2020-01-01T00:00:00Z")).toBe( + "2020-01-01T00:00:00Z", + ); + }); + + it("rejects coercible input instead of guessing the intended value", () => { + expect(column("active").decode("true")).toBeUndefined(); + expect(column("count").decode("7.0")).toBeUndefined(); + expect(column("count").decode("007")).toBeUndefined(); + expect(column("count").decode(7.5)).toBeUndefined(); + expect(column("count").decode(2_147_483_648)).toBeUndefined(); + expect(column("total").decode("1e3")).toBeUndefined(); + expect(column("total").decode(1.5)).toBeUndefined(); + expect(column("name").decode(7)).toBeUndefined(); + expect(column("label").decode("toolong")).toBeUndefined(); + expect(column("external").decode("not-a-uuid")).toBeUndefined(); + expect(column("status").decode("unknown")).toBeUndefined(); + expect(column("createdAt").decode("yesterday")).toBeUndefined(); + expect(column("createdAt").decode(0)).toBeUndefined(); + }); + + it("keeps unfilterable kinds undecodable", () => { + expect(column("payload").decode({ any: "value" })).toBeUndefined(); + expect(column("name").decode(null)).toBeUndefined(); + expect(column("name").decode(undefined)).toBeUndefined(); + }); +}); + +describe("compileColumn encode", () => { + it("emits one deterministic JSON form per kind", () => { + expect(column("total").encode(9007199254740993n)).toBe("9007199254740993"); + expect(column("total").encode(12)).toBe("12"); + expect(column("count").encode(7)).toBe(7); + expect(column("active").encode(false)).toBe(false); + expect(column("createdAt").encode("2020-01-01T00:00:00Z")).toBe( + "2020-01-01T00:00:00Z", + ); + expect(column("payload").encode({ nested: [1, "two"] })).toEqual({ + nested: [1, "two"], + }); + expect(column("name").encode(null)).toBeNull(); + expect(column("name").encode(undefined)).toBeNull(); + }); + + it("fails closed when a driver value contradicts its column", () => { + expect(() => column("name").encode(7)).toThrow(DatabasePluginError); + expect(() => column("createdAt").encode(new Date())).toThrow( + DatabasePluginError, + ); + expect(() => column("count").encode(Number.NaN)).toThrow( + DatabasePluginError, + ); + expect(() => column("payload").encode(new Map())).toThrow( + DatabasePluginError, + ); + expect(() => column("name").encode(7)).toThrow( + expect.objectContaining({ category: "INTERNAL" }), + ); + }); +}); diff --git a/packages/appkit/src/plugins/database/crud/tests/contract.test.ts b/packages/appkit/src/plugins/database/crud/tests/contract.test.ts new file mode 100644 index 000000000..ad362c80f --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/tests/contract.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { DatabasePluginError } from "../../../../database/errors"; +import { + bigid, + defineSchema, + fk, + id, + jsonb, + text, +} from "../../../../database/schema-builder"; +import { MAX_SERIALIZED_DEPTH } from "../../defaults"; +import { compileCrudTables } from "../contract"; + +const schema = defineSchema((builder) => { + const users = builder.table("users", { + id: id(), + name: text(), + token: text().private(), + profile: jsonb(), + }); + const notes = builder.table("notes", { + id: id(), + authorId: fk(() => users.id), + body: text(), + draft: text().private(), + }); + const ledger = builder.table("ledger", { id: bigid(), memo: text() }); + return { users, notes, ledger }; +}); + +const tables = compileCrudTables(schema.$tables); +const users = tables.get("users") as NonNullable>; +const notes = tables.get("notes") as NonNullable>; +const ledger = tables.get("ledger") as NonNullable< + ReturnType +>; + +describe("compileCrudTables", () => { + it("allowlists public columns and excludes unfilterable kinds", () => { + expect([...users.selectable]).toEqual(["id", "name", "profile"]); + expect([...users.queryable]).toEqual(["id", "name"]); + expect(users.columns.has("token")).toBe(true); + expect(users.primaryKey?.meta.columnName).toBe("id"); + }); + + it("wires relations only between exposed tables", () => { + expect(users.relations.get("notes")).toMatchObject({ + cardinality: "toMany", + target: notes, + }); + expect(notes.relations.get("users")).toMatchObject({ + cardinality: "toOne", + target: users, + }); + const isolated = compileCrudTables({ notes: schema.$tables.notes }); + expect(isolated.get("notes")?.relations.size).toBe(0); + }); + + it("decodes identifiers against the declared key type", () => { + expect(users.decodeId("42")).toBe(42); + expect(ledger.decodeId("9007199254740993")).toBe(9007199254740993n); + expect(() => users.decodeId("abc")).toThrow(DatabasePluginError); + expect(() => users.decodeId("abc")).toThrow( + expect.objectContaining({ + category: "INVALID_REQUEST", + details: [{ path: ["id"], message: expect.any(String) }], + }), + ); + }); +}); + +describe("projectPublicRow", () => { + it("drops private columns at every level and encodes relations", () => { + const projected = users.projectPublicRow({ + id: 1, + name: "Ada", + token: "secret", + profile: { theme: "dark" }, + notes: [{ id: 2, body: "hello", draft: "hidden" }], + unknown: "ignored", + }); + expect(projected).toEqual({ + id: 1, + name: "Ada", + profile: { theme: "dark" }, + notes: [{ id: 2, body: "hello" }], + }); + }); + + it("represents an absent to-one relation as null", () => { + expect(notes.projectPublicRow({ id: 1, users: null })).toEqual({ + id: 1, + users: null, + }); + }); +}); + +describe("sanitizeSerializedRow", () => { + it("re-applies the private-column policy to serializer output", () => { + const sanitized = users.sanitizeSerializedRow({ + id: 1, + token: "leaked", + computed: { label: "Ada", tags: ["a", "b"] }, + skipped: undefined, + notes: [{ id: 2, draft: "leaked", body: "hello" }], + }); + expect(sanitized).toEqual({ + id: 1, + computed: { label: "Ada", tags: ["a", "b"] }, + notes: [{ id: 2, body: "hello" }], + }); + }); + + it("treats a broken serializer as an internal fault", () => { + const cyclic: Record = { id: 1 }; + cyclic.self = cyclic; + let deep: Record = {}; + for (let level = 0; level <= MAX_SERIALIZED_DEPTH; level += 1) { + deep = { deep }; + } + for (const broken of [ + cyclic, + deep, + "not-an-object", + { id: Number.POSITIVE_INFINITY }, + { at: new Date() }, + ]) { + expect(() => users.sanitizeSerializedRow(broken)).toThrow( + expect.objectContaining({ category: "INTERNAL" }), + ); + } + }); + + it("carries a __proto__ key as data rather than dropping it", () => { + const sanitized = users.sanitizeSerializedRow({ + id: 1, + computed: JSON.parse('{"__proto__":{"owned":true},"keep":1}'), + }) as { computed: unknown }; + + expect(JSON.stringify(sanitized.computed)).toBe( + '{"__proto__":{"owned":true},"keep":1}', + ); + expect(({} as Record).owned).toBeUndefined(); + }); +}); diff --git a/packages/appkit/src/plugins/database/crud/tests/query.test.ts b/packages/appkit/src/plugins/database/crud/tests/query.test.ts new file mode 100644 index 000000000..f0ddad520 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/tests/query.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_LIMIT, + IN_CAP, + MAX_LIMIT, +} from "../../../../database/contract"; +import { DatabasePluginError } from "../../../../database/errors"; +import { + boolean, + type ColumnRef, + defineSchema, + fk, + id, + integer, + jsonb, + type SchemaBuilderContext, + text, + timestamp, +} from "../../../../database/schema-builder"; +import { MAX_OFFSET, MAX_QUERY_BYTES } from "../../defaults"; +import { type CrudTable, compileCrudTables } from "../contract"; +import { decodeDetailQuery, decodeListQuery } from "../query"; + +const schema = defineSchema((builder) => { + const users = builder.table("users", { + id: id(), + name: text(), + rank: integer(), + active: boolean().notNull(), + createdAt: timestamp(), + profile: jsonb(), + token: text().private(), + }); + const notes = builder.table("notes", { + id: id(), + authorId: fk(() => users.id), + body: text(), + }); + return { users, notes }; +}); + +const tables = compileCrudTables(schema.$tables); +const users = tables.get("users") as CrudTable; + +function query(params: Record): string { + const encoded = Object.entries(params).map( + ([key, value]): [string, string] => [ + key, + typeof value === "string" ? value : JSON.stringify(value), + ], + ); + return new URLSearchParams(encoded).toString(); +} + +function expectRejected(raw: string): void { + expect(() => decodeListQuery(users, raw)).toThrow(DatabasePluginError); + expect(() => decodeListQuery(users, raw)).toThrow( + expect.objectContaining({ category: "INVALID_REQUEST", statusCode: 400 }), + ); +} + +describe("query parameters", () => { + it("defaults pagination and accepts canonical integers", () => { + expect(decodeListQuery(users, "")).toEqual({ + where: undefined, + order: undefined, + select: undefined, + include: undefined, + limit: DEFAULT_LIMIT, + offset: 0, + }); + expect(decodeListQuery(users, "limit=10&offset=20")).toMatchObject({ + limit: 10, + offset: 20, + }); + }); + + it("rejects unknown, repeated, oversized, and non-canonical parameters", () => { + expectRejected("unknown=1"); + // A generated list is one page query, so there is no total to request. + expectRejected("includeTotal=true"); + expectRejected("limit=1&limit=2"); + expectRejected(`select=${"x".repeat(MAX_QUERY_BYTES)}`); + expectRejected("limit=01"); + expectRejected("limit=-1"); + expectRejected("limit=1.0"); + expectRejected(`limit=${MAX_LIMIT + 1}`); + expectRejected(`offset=${MAX_OFFSET + 1}`); + expectRejected("where=notjson"); + }); + + it("keeps detail requests to projection and includes", () => { + expect(decodeDetailQuery(users, query({ select: ["id"] }))).toEqual({ + select: ["id"], + include: undefined, + }); + expect(() => decodeDetailQuery(users, query({ where: { id: 1 } }))).toThrow( + DatabasePluginError, + ); + expect(() => decodeDetailQuery(users, "limit=1")).toThrow( + DatabasePluginError, + ); + }); +}); + +describe("select and order", () => { + it("accepts public columns only", () => { + expect( + decodeListQuery(users, query({ select: ["id", "name"] })).select, + ).toEqual(["id", "name"]); + expect( + decodeListQuery(users, query({ order: { name: "desc" } })).order, + ).toEqual({ name: "desc" }); + }); + + it("rejects private, unknown, empty, and unorderable selections", () => { + expectRejected(query({ select: ["token"] })); + expectRejected(query({ select: ["missing"] })); + expectRejected(query({ select: [] })); + expectRejected(query({ select: "id" })); + expectRejected(query({ order: { token: "asc" } })); + expectRejected(query({ order: { profile: "asc" } })); + expectRejected(query({ order: { name: "sideways" } })); + expectRejected(query({ order: {} })); + }); +}); + +describe("where", () => { + it("decodes operands through their column codec", () => { + expect( + decodeListQuery(users, query({ where: { rank: "3" } })).where, + ).toEqual({ rank: 3 }); + expect( + decodeListQuery(users, query({ where: { name: { ilike: "a%" } } })).where, + ).toEqual({ name: { ilike: "a%" } }); + expect( + decodeListQuery(users, query({ where: { name: { is: null } } })).where, + ).toEqual({ name: { is: null } }); + expect( + decodeListQuery( + users, + query({ + where: { or: [{ rank: { gte: 1 } }, { id: { in: [1, 2] } }] }, + }), + ).where, + ).toEqual({ or: [{ rank: { gte: 1 } }, { id: { in: [1, 2] } }] }); + }); + + it("applies one operator matrix per column kind", () => { + expectRejected(query({ where: { name: { gt: "a" } } })); + expectRejected(query({ where: { rank: { like: "1" } } })); + expectRejected(query({ where: { profile: { eq: {} } } })); + expectRejected(query({ where: { rank: "abc" } })); + expectRejected(query({ where: { createdAt: { gt: "yesterday" } } })); + }); + + it("keeps null matching explicit and bounds in lists", () => { + // An empty set is a legal filter that the engine renders as no match. + expect( + decodeListQuery(users, query({ where: { id: { in: [] } } })).where, + ).toEqual({ id: { in: [] } }); + expectRejected(query({ where: { name: null } })); + expectRejected(query({ where: { name: { eq: null } } })); + expectRejected(query({ where: { active: { is: null } } })); + expectRejected(query({ where: { name: { in: ["a", null] } } })); + expectRejected( + query({ + where: { id: { in: Array.from({ length: IN_CAP + 1 }, (_, i) => i) } }, + }), + ); + }); + + it("rejects unknown columns, relations, and empty filters", () => { + expectRejected(query({ where: { missing: 1 } })); + expectRejected(query({ where: { token: "secret" } })); + expectRejected(query({ where: { notes: { some: { body: "a" } } } })); + expectRejected(query({ where: { name: {} } })); + expectRejected(query({ where: { and: [] } })); + expectRejected(query({ where: [] })); + }); + + it("bounds nesting, group size, and total conditions", () => { + let deep: Record = { rank: 1 }; + for (let level = 0; level < 5; level += 1) deep = { and: [deep] }; + expectRejected(query({ where: deep })); + expectRejected( + query({ where: { or: Array.from({ length: 21 }, () => ({ rank: 1 })) } }), + ); + expectRejected( + query({ + where: { + and: Array.from({ length: 20 }, () => ({ + rank: { gt: 1, lt: 5, gte: 1 }, + })), + }, + }), + ); + }); +}); + +describe("include", () => { + it("bounds to-many relations and carries a second edge", () => { + expect( + decodeListQuery(users, query({ include: { notes: true } })).include, + ).toEqual({ notes: { limit: DEFAULT_LIMIT } }); + expect( + decodeListQuery( + users, + query({ + include: { + notes: { select: ["body"], limit: 5, include: { users: true } }, + }, + }), + ).include, + ).toEqual({ + notes: { select: ["body"], limit: 5, include: { users: true } }, + }); + }); + + it("rejects a third edge, unknown relations, and unsupported options", () => { + expectRejected( + query({ include: { notes: { include: { users: { include: {} } } } } }), + ); + expectRejected(query({ include: { missing: true } })); + expectRejected(query({ include: { notes: false } })); + expectRejected(query({ include: { notes: { offset: 1 } } })); + expectRejected(query({ include: { notes: { limit: MAX_LIMIT + 1 } } })); + expectRejected(query({ include: { notes: { select: ["missing"] } } })); + }); + + it("limits only to-many relations and scopes options to the target", () => { + const notes = tables.get("notes") as CrudTable; + expect(() => + decodeListQuery(notes, query({ include: { users: { limit: 1 } } })), + ).toThrow(DatabasePluginError); + expect( + decodeListQuery( + notes, + query({ include: { users: { where: { rank: 1 } } } }), + ).include, + ).toEqual({ users: { where: { rank: 1 } } }); + expect(() => + decodeListQuery( + notes, + query({ include: { users: { where: { body: "a" } } } }), + ), + ).toThrow(DatabasePluginError); + }); +}); + +describe("read budget", () => { + it("rejects fan-out before the entity terminal runs", () => { + expectRejected( + query({ limit: "500", include: { notes: { limit: MAX_LIMIT } } }), + ); + expectRejected( + query({ + limit: "50", + include: { notes: { limit: 100, include: { users: true } } }, + }), + ); + expect( + decodeListQuery( + users, + query({ + limit: "49", + include: { notes: { limit: 100, include: { users: true } } }, + }), + ).limit, + ).toBe(49); + }); + + it("bounds the total number of relation nodes in one tree", () => { + const wide = defineSchema((builder: SchemaBuilderContext) => { + const shared = builder.table("shared", { id: id() }); + const leaves: Record = {}; + for (let index = 0; index < 9; index += 1) { + leaves[`t${index}`] = builder.table(`t${index}`, { + id: id(), + sharedId: fk(() => shared.id), + }); + } + const hub = builder.table("hub", { + id: id(), + ...Object.fromEntries( + Object.entries(leaves).map(([name, leaf]) => [ + `${name}Id`, + fk(() => leaf.id), + ]), + ), + }); + return { shared, ...leaves, hub }; + }); + const hub = compileCrudTables(wide.$tables).get("hub") as CrudTable; + const branch = { include: { shared: true, hub: { limit: 0 } } }; + const tree = (count: number) => + query({ + limit: "1", + include: Object.fromEntries( + Array.from({ length: count }, (_, index) => [`t${index}`, branch]), + ), + }); + + // 8 branches materialize 24 relation nodes; a ninth crosses the 25 cap. + expect( + Object.keys(decodeListQuery(hub, tree(8)).include ?? {}), + ).toHaveLength(8); + expect(() => decodeListQuery(hub, tree(9))).toThrow(DatabasePluginError); + }); +}); diff --git a/packages/appkit/src/plugins/database/crud/tests/routes.test.ts b/packages/appkit/src/plugins/database/crud/tests/routes.test.ts new file mode 100644 index 000000000..5c3af5883 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/tests/routes.test.ts @@ -0,0 +1,338 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_LIMIT } from "../../../../database/contract"; +import { DatabasePluginError } from "../../../../database/errors"; +import type { Row } from "../../../../database/runtime"; +import { + defineSchema, + fk, + id, + text, +} from "../../../../database/schema-builder"; +import { MAX_RESPONSE_BYTES } from "../../defaults"; +import type { EntityClient } from "../../entity-client"; +import type { ReadSerializer } from "../../types"; +import { type CrudTable, compileCrudTables } from "../contract"; +import { + type CrudReadEntity, + createDetailHandler, + createListHandler, + type ReadRouteDeps, +} from "../routes"; + +const schema = defineSchema((builder) => { + const users = builder.table("users", { + id: id(), + name: text(), + token: text().private(), + }); + const notes = builder.table("notes", { + id: id(), + authorId: fk(() => users.id), + body: text(), + }); + const events = builder.table("events", { message: text() }); + return { users, notes, events }; +}); + +const tables = compileCrudTables(schema.$tables); + +// The routes reach their entity through an untyped export lookup, so the read +// surface they drive has to stay a subset of the real client. +const _entityClientSatisfiesReads: CrudReadEntity = {} as EntityClient; + +interface FakeEntity extends CrudReadEntity { + readonly calls: Record; +} + +function fakeEntity(rows: Row[], found: Row | null = null): FakeEntity { + const calls: Record = {}; + const record = (name: string, value: unknown) => { + calls[name] ??= []; + calls[name].push(value); + }; + const chain = (name: string) => (value: unknown) => { + record(name, value); + return entity; + }; + const entity: FakeEntity = { + calls, + where: chain("where"), + order: chain("order"), + select: chain("select"), + include: chain("include"), + limit: chain("limit"), + offset: chain("offset"), + toArray: async () => { + record("toArray", true); + return rows; + }, + find: async (value) => { + record("find", value); + return found; + }, + }; + return entity; +} + +function fakeResponse() { + const sent: { + status?: number; + body?: string; + type?: string; + headers: Record; + } = { headers: {} }; + const res = { + headersSent: false, + status: vi.fn((code: number) => { + sent.status = code; + return res; + }), + type: vi.fn((value: string) => { + sent.type = value; + return res; + }), + setHeader: vi.fn((name: string, value: string) => { + sent.headers[name] = value; + return res; + }), + send: vi.fn((body: string) => { + sent.body = body; + return res; + }), + }; + return { + res: res as unknown as Response, + sent, + json: () => JSON.parse(sent.body ?? "null"), + }; +} + +function request(url: string, params: Record = {}): Request { + return { originalUrl: url, url, params } as unknown as Request; +} + +function deps( + table: string, + entity: CrudReadEntity, + serialize?: ReadSerializer, +): ReadRouteDeps { + return { + table: tables.get(table) as CrudTable, + entity: () => entity, + serialize, + runRouteSpan: (_operation, _route, run) => run(), + }; +} + +let response = fakeResponse(); +beforeEach(() => { + response = fakeResponse(); +}); + +describe("list route", () => { + it("returns the bounded envelope from one query", async () => { + const entity = fakeEntity([ + { id: 1, name: "Ada", token: "secret" }, + { id: 2, name: "Grace", token: "secret" }, + ]); + await createListHandler(deps("users", entity))( + request("/users"), + response.res, + ); + + expect(response.sent.status).toBe(200); + expect(response.json()).toEqual({ + items: [ + { id: 1, name: "Ada" }, + { id: 2, name: "Grace" }, + ], + limit: DEFAULT_LIMIT, + offset: 0, + }); + expect(entity.calls.toArray).toHaveLength(1); + expect(entity.calls.where).toBeUndefined(); + expect(entity.calls.order).toEqual([{ id: "asc" }]); + }); + + it("keeps row data out of shared and browser caches", async () => { + await createListHandler(deps("users", fakeEntity([])))( + request("/users"), + response.res, + ); + expect(response.sent.headers["Cache-Control"]).toBe("no-store"); + + const rejected = fakeResponse(); + await createListHandler(deps("users", fakeEntity([])))( + request("/users?limit=abc"), + rejected.res, + ); + expect(rejected.sent.headers["Cache-Control"]).toBe("no-store"); + }); + + it("appends the primary key so equal sort keys stay stable", async () => { + const entity = fakeEntity([]); + await createListHandler(deps("users", entity))( + request(`/users?order=${encodeURIComponent('{"name":"desc"}')}`), + response.res, + ); + expect(entity.calls.order).toEqual([{ name: "desc", id: "asc" }]); + }); + + it("keeps a caller-supplied key direction", async () => { + const entity = fakeEntity([]); + await createListHandler(deps("users", entity))( + request(`/users?order=${encodeURIComponent('{"id":"desc"}')}`), + response.res, + ); + expect(entity.calls.order).toEqual([{ id: "desc" }]); + }); + + it("requires explicit ordering when a table has no key", async () => { + const entity = fakeEntity([]); + await createListHandler(deps("events", entity))( + request("/events"), + response.res, + ); + expect(response.sent.status).toBe(400); + expect(entity.calls.toArray).toBeUndefined(); + + const ordered = fakeEntity([]); + await createListHandler(deps("events", ordered))( + request(`/events?order=${encodeURIComponent('{"message":"asc"}')}`), + fakeResponse().res, + ); + expect(ordered.calls.order).toEqual([{ message: "asc" }]); + }); + + it("forwards only the parameters the caller supplied", async () => { + const entity = fakeEntity([]); + const url = `/notes?where=${encodeURIComponent('{"body":"a"}')}&select=${encodeURIComponent('["body"]')}&include=${encodeURIComponent('{"users":true}')}&limit=5&offset=3`; + await createListHandler(deps("notes", entity))(request(url), response.res); + expect(entity.calls).toMatchObject({ + where: [{ body: "a" }], + select: [["body"]], + include: [{ users: true }], + limit: [5], + offset: [3], + }); + }); + + it("rejects an invalid query before touching the entity", async () => { + const entity = fakeEntity([]); + await createListHandler(deps("users", entity))( + request("/users?limit=abc"), + response.res, + ); + expect(response.sent.status).toBe(400); + expect(response.json()).toEqual({ + error: "Invalid database request", + details: [{ path: ["limit"], message: expect.any(String) }], + }); + expect(entity.calls.toArray).toBeUndefined(); + }); +}); + +describe("detail route", () => { + it("returns one public row for a decoded key", async () => { + const entity = fakeEntity([], { id: 7, name: "Ada", token: "secret" }); + await createDetailHandler(deps("users", entity))( + request("/users/7", { id: "7" }), + response.res, + ); + expect(entity.calls.find).toEqual([7]); + expect(response.json()).toEqual({ id: 7, name: "Ada" }); + }); + + it("answers a missing row with 404 and no internal category", async () => { + const entity = fakeEntity([], null); + await createDetailHandler(deps("users", entity))( + request("/users/7", { id: "7" }), + response.res, + ); + expect(response.sent.status).toBe(404); + expect(response.json()).toEqual({ error: "Database record not found" }); + }); + + it("rejects an unrepresentable key without a lookup", async () => { + const entity = fakeEntity([], null); + await createDetailHandler(deps("users", entity))( + request("/users/abc", { id: "abc" }), + response.res, + ); + expect(response.sent.status).toBe(400); + expect(entity.calls.find).toBeUndefined(); + }); +}); + +describe("serialization and response limits", () => { + it("keeps the serializer contract synchronous", () => { + // @ts-expect-error a serializer may not defer work into the response path + const deferred: ReadSerializer = async (row) => row; + expect(deferred).toBeTypeOf("function"); + }); + + it("applies a synchronous serializer and re-checks private columns", async () => { + const serialize = vi.fn((row) => ({ + ...row, + label: `#${row.id}`, + token: "reintroduced", + })); + const entity = fakeEntity([{ id: 1, name: "Ada", token: "secret" }]); + await createListHandler(deps("users", entity, serialize))( + request("/users"), + response.res, + ); + expect(serialize).toHaveBeenCalledWith( + { id: 1, name: "Ada" }, + { entity: "users", operation: "list" }, + ); + expect(response.json().items).toEqual([ + { id: 1, name: "Ada", label: "#1" }, + ]); + }); + + it("maps a broken serializer to an opaque server error", async () => { + const entity = fakeEntity([{ id: 1, name: "Ada" }]); + const throwing = vi.fn(() => { + throw new Error("boom: select * from users"); + }); + await createListHandler(deps("users", entity, throwing))( + request("/users"), + response.res, + ); + expect(response.sent.status).toBe(500); + expect(response.json()).toEqual({ error: "Database operation failed" }); + }); + + it("rejects a response that exceeds the byte budget", async () => { + const wide = "x".repeat(1024 * 1024); + const entity = fakeEntity( + Array.from({ length: 6 }, (_, index) => ({ id: index, name: wide })), + ); + await createListHandler(deps("users", entity))( + request("/users"), + response.res, + ); + expect(response.sent.status).toBe(413); + expect(response.json()).toEqual({ + error: "Database response is too large", + }); + expect(MAX_RESPONSE_BYTES).toBeLessThan(6 * wide.length); + }); + + it("maps an entity failure to its safe category", async () => { + const entity = fakeEntity([]); + entity.toArray = async () => { + throw new DatabasePluginError("FORBIDDEN", "read"); + }; + await createListHandler(deps("users", entity))( + request("/users"), + response.res, + ); + expect(response.sent.status).toBe(403); + expect(response.json()).toEqual({ + error: "Database operation forbidden", + }); + }); +}); diff --git a/packages/appkit/src/plugins/database/database.ts b/packages/appkit/src/plugins/database/database.ts index f5109b7f2..53a9f929c 100644 --- a/packages/appkit/src/plugins/database/database.ts +++ b/packages/appkit/src/plugins/database/database.ts @@ -1,12 +1,25 @@ +import type express from "express"; import type { BasePluginConfig, PluginConstructor } from "shared"; -import { DatabasePluginError } from "../../database/errors"; +import { + DatabasePluginError, + databaseSetupFailed, +} from "../../database/errors"; import type { Schema } from "../../database/schema-builder"; import { Plugin } from "../../plugin"; import type { PluginManifest } from "../../registry"; +import { compileCrudTables } from "./crud/contract"; +import { resolveExposedTables } from "./crud/exposure"; +import { + type CrudReadEntity, + createDetailHandler, + createListHandler, + type ReadRouteDeps, + readRouteOutcome, +} from "./crud/routes"; import type { DatabaseExports } from "./entity-types"; import { createDatabaseState, type DatabaseState } from "./lifecycle"; import manifest from "./manifest.json"; -import type { IDatabaseConfig } from "./types"; +import type { IDatabaseConfig, ReadSerializer } from "./types"; /** Schema-driven database plugin */ export class DatabasePlugin extends Plugin< @@ -19,18 +32,26 @@ export class DatabasePlugin extends Plugin< private setupPromise: Promise | null = null; private draining = false; private shutdownPromise: Promise | null = null; + private exposedTables: string[] = []; constructor(config: IDatabaseConfig) { super({ schema: config.schema }); - this.config = { schema: config.schema }; + this.config = { + schema: config.schema, + crudRoutes: config.crudRoutes, + hooks: config.hooks, + }; } /** Build and verify one candidate state before publishing its exports. */ async setup(): Promise { - if (this.draining || this.state) - throw new DatabasePluginError("SETUP_FAILED", "setup"); + if (this.draining || this.state) throw databaseSetupFailed(); if (!this.setupPromise) { const attempt = (async () => { + this.exposedTables = resolveExposedTables( + this.config.crudRoutes, + Object.keys(this.config.schema.$tables), + ); const candidate = await createDatabaseState( this.config.schema, (operation, options) => this.execute(operation, options), @@ -39,7 +60,7 @@ export class DatabasePlugin extends Plugin< // Setup may finish while shutdown is waiting; never publish that state. candidate.deactivate(); await candidate.pool.end().catch(() => undefined); - throw new DatabasePluginError("SETUP_FAILED", "setup"); + throw databaseSetupFailed(); } this.state = candidate; })(); @@ -48,6 +69,49 @@ export class DatabasePlugin extends Plugin< return this.setupPromise; } + /** Register generated reads for explicitly exposed tables only. */ + injectRoutes(router: express.Router): void { + if (this.exposedTables.length === 0) return; + const tables = compileCrudTables( + Object.fromEntries( + this.exposedTables.map((name) => [ + name, + this.config.schema.$tables[name], + ]), + ), + ); + const serializers = this.config.hooks as + | Record + | undefined; + // Every exposed name is a declared table, so its export is an entity client. + const entities = () => + this.exports() as unknown as Record; + + for (const table of tables.values()) { + const deps: ReadRouteDeps = { + table, + entity: () => entities()[table.name], + serialize: serializers?.[table.name]?.serialize, + runRouteSpan: (operation, route, run) => + this.runReadSpan(table.name, operation, route, run), + }; + this.route(router, { + name: `${table.name}.list`, + method: "get", + path: `/${table.name}`, + handler: createListHandler(deps), + }); + if (table.primaryKey) { + this.route(router, { + name: `${table.name}.detail`, + method: "get", + path: `/${table.name}/:id`, + handler: createDetailHandler(deps), + }); + } + } + } + /** Return the typed database API only while the plugin is active. */ exports() { if (!this.state || this.draining) @@ -78,6 +142,36 @@ export class DatabasePlugin extends Plugin< })(); return this.shutdownPromise; } + + /** Trace one generated read with allowlisted, low-cardinality attributes. */ + private runReadSpan( + table: string, + operation: "list" | "detail", + route: string, + run: () => Promise, + ): Promise { + return this.telemetry.startActiveSpan( + "database.crud.route", + { + attributes: { + table_name: table, + operation, + "http.route": `/api/${this.name}${route}`, + }, + }, + async (span) => { + try { + await run(); + span.setAttribute("outcome", "success"); + } catch (error) { + span.setAttribute("outcome", readRouteOutcome(error)); + throw error; + } finally { + span.end(); + } + }, + ); + } } /** Create a typed database plugin registration for a finalized schema. */ diff --git a/packages/appkit/src/plugins/database/defaults.ts b/packages/appkit/src/plugins/database/defaults.ts index 68fdcef63..3dbf4806f 100644 --- a/packages/appkit/src/plugins/database/defaults.ts +++ b/packages/appkit/src/plugins/database/defaults.ts @@ -10,3 +10,30 @@ export const databaseWriteDefaults: PluginExecuteConfig = { cache: { enabled: false }, retry: { enabled: false }, }; + +// Generated-read limits. The wire caps a typed caller shares with HTTP live in +// `database/contract`; these bound only what an untrusted request may ask for. + +// Request decoding: rejected before any database work is scheduled. +/** Max encoded size of a generated read query string. */ +export const MAX_QUERY_BYTES = 8 * 1024; +/** Max nesting depth of `and`/`or` groups in one generated filter. */ +export const MAX_WHERE_DEPTH = 5; +/** Max column conditions across one generated filter. */ +export const MAX_WHERE_CONDITIONS = 50; +/** Max members in one `and`/`or` group. */ +export const MAX_GROUP_ITEMS = 20; +/** Max columns one generated `order` may name. */ +export const MAX_ORDER_FIELDS = 10; +/** Max accepted generated `offset`. */ +export const MAX_OFFSET = 10_000; +/** Max rows one generated read may materialize across its include tree. */ +export const MAX_MATERIALIZED_NODES = 10_000; + +// Response shaping: bounds trusted output on its way to the wire. +/** Max nesting depth of read-serializer output. */ +export const MAX_SERIALIZED_DEPTH = 32; +/** Max node count of read-serializer output. */ +export const MAX_SERIALIZED_NODES = 10_000; +/** Max UTF-8 byte length of one generated read response body. */ +export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024; diff --git a/packages/appkit/src/plugins/database/entity-types.ts b/packages/appkit/src/plugins/database/entity-types.ts index 927236870..6c8ad0642 100644 --- a/packages/appkit/src/plugins/database/entity-types.ts +++ b/packages/appkit/src/plugins/database/entity-types.ts @@ -57,10 +57,14 @@ type RelationTargetFor< ? Target : never; +/** How many further relation edges may nest inside one include level. */ +type RemainingEdges = 0 | 1; + export type IncludeOptionsFor< Registry extends RegistryShape, K extends EntityNameFor, Many extends boolean, + Remaining extends RemainingEdges = 0, > = { readonly select?: readonly (keyof RowOfFor & string)[]; readonly where?: FiltersOfFor; @@ -69,11 +73,15 @@ export type IncludeOptionsFor< >; } & (Many extends true ? { readonly limit?: number } - : { readonly limit?: never }); + : { readonly limit?: never }) & + (Remaining extends 1 + ? { readonly include?: IncludeArgFor } + : { readonly include?: never }); export type IncludeArgFor< Registry extends RegistryShape, K extends EntityNameFor, + Remaining extends RemainingEdges = 1, > = { readonly [Relation in keyof IncludesOfFor]?: | boolean @@ -84,12 +92,13 @@ export type IncludeArgFor< many: infer Many extends boolean; } ? Many - : false + : false, + Remaining >; }; // Explicit relation projections use trusted rows; implicit projections stay public. -type IncludedRowFor< +type SelectedRowFor< Registry extends RegistryShape, K extends EntityNameFor, Config, @@ -100,6 +109,14 @@ type IncludedRowFor< ? Pick, Columns[number]> : PublicRowOfFor; +type IncludedRowFor< + Registry extends RegistryShape, + K extends EntityNameFor, + Config, +> = Config extends { readonly include: infer Nested } + ? SelectedRowFor & IncludedResultFor + : SelectedRowFor; + type IncludedResultFor< Registry extends RegistryShape, K extends EntityNameFor, diff --git a/packages/appkit/src/plugins/database/tests/crud-span.test.ts b/packages/appkit/src/plugins/database/tests/crud-span.test.ts new file mode 100644 index 000000000..7536282b9 --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/crud-span.test.ts @@ -0,0 +1,175 @@ +import type { Span, SpanOptions } from "@opentelemetry/api"; +import type { Request, RequestHandler, Response } from "express"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { defineSchema, id, text } from "../../../database/schema-builder"; +import type { ITelemetry } from "../../../telemetry"; + +const mocks = vi.hoisted(() => ({ createDatabaseState: vi.fn() })); +vi.mock("../lifecycle", () => ({ + createDatabaseState: mocks.createDatabaseState, +})); + +import { DatabasePlugin } from "../database"; + +const schema = defineSchema((builder) => { + const users = builder.table("users", { + id: id(), + name: text(), + token: text().private(), + }); + return { users }; +}); + +interface RecordedSpan { + readonly name: string; + readonly attributes: Record; +} + +function fakeTelemetry(spans: RecordedSpan[]): ITelemetry { + return { + startActiveSpan: ( + name: string, + options: SpanOptions, + run: (span: Span) => Promise, + ) => { + const attributes: Record = { ...options.attributes }; + spans.push({ name, attributes }); + const span = { + setAttribute: (key: string, value: unknown) => { + attributes[key] = value; + }, + end: vi.fn(), + }; + return run(span as unknown as Span); + }, + } as unknown as ITelemetry; +} + +const rows = [{ id: 1, name: "Ada", token: "secret" }]; + +function entity(overrides: Record = {}) { + const chain: Record = { + where: () => chain, + order: () => chain, + select: () => chain, + include: () => chain, + limit: () => chain, + offset: () => chain, + toArray: async () => rows, + find: async () => rows[0], + ...overrides, + }; + return chain; +} + +async function mount(exports: Record) { + const spans: RecordedSpan[] = []; + mocks.createDatabaseState.mockResolvedValue({ + pool: { end: async () => undefined }, + exports, + deactivate: vi.fn(), + }); + const plugin = new DatabasePlugin({ schema, crudRoutes: true }); + (plugin as unknown as { telemetry: ITelemetry }).telemetry = + fakeTelemetry(spans); + await plugin.setup(); + + const handlers = new Map(); + plugin.injectRoutes({ + get: (path: string, handler: RequestHandler) => { + handlers.set(path, handler); + }, + } as unknown as Parameters[0]); + return { plugin, spans, handlers }; +} + +function fakeResponse(): Response { + const res = { + headersSent: false, + status: () => res, + type: () => res, + setHeader: () => res, + send: () => res, + }; + return res as unknown as Response; +} + +async function call( + handler: RequestHandler | undefined, + url: string, + params: Record = {}, +): Promise { + const request = { originalUrl: url, url, params } as unknown as Request; + await (handler as (req: Request, res: Response) => Promise)( + request, + fakeResponse(), + ); +} + +let mounted: Awaited>; +beforeEach(async () => { + mocks.createDatabaseState.mockReset(); + mounted = await mount({ users: entity() }); +}); + +describe("generated read spans", () => { + test("record only allowlisted, low-cardinality attributes", async () => { + await call( + mounted.handlers.get("/users"), + `/users?where=${encodeURIComponent('{"name":"Ada"}')}&limit=5`, + ); + await call(mounted.handlers.get("/users/:id"), "/users/1", { id: "1" }); + + expect(mounted.spans).toEqual([ + { + name: "database.crud.route", + attributes: { + table_name: "users", + operation: "list", + "http.route": "/api/database/users", + outcome: "success", + }, + }, + { + name: "database.crud.route", + attributes: { + table_name: "users", + operation: "detail", + "http.route": "/api/database/users/:id", + outcome: "success", + }, + }, + ]); + }); + + test("classify failures without carrying their cause", async () => { + const failing = await mount({ + users: entity({ + toArray: async () => { + throw new Error("select * from users where token = 'secret'"); + }, + find: async () => null, + }), + }); + await call(failing.handlers.get("/users"), "/users"); + await call(failing.handlers.get("/users"), "/users?limit=abc"); + await call(failing.handlers.get("/users/:id"), "/users/1", { id: "1" }); + + expect(failing.spans.map((span) => span.attributes.outcome)).toEqual([ + "failed", + "rejected", + "not_found", + ]); + const serialized = JSON.stringify(failing.spans); + expect(serialized).not.toContain("select"); + expect(serialized).not.toContain("secret"); + expect(serialized).not.toContain("INTERNAL"); + expect(serialized).not.toContain("Ada"); + }); + + test("stop serving rows once the plugin drains", async () => { + await mounted.plugin.shutdown(); + await call(mounted.handlers.get("/users"), "/users"); + expect(mounted.spans.at(-1)?.attributes.outcome).toBe("failed"); + }); +}); diff --git a/packages/appkit/src/plugins/database/tests/entity-types.test.ts b/packages/appkit/src/plugins/database/tests/entity-types.test.ts index 6b90dff3d..e341a6357 100644 --- a/packages/appkit/src/plugins/database/tests/entity-types.test.ts +++ b/packages/appkit/src/plugins/database/tests/entity-types.test.ts @@ -73,7 +73,7 @@ interface TestRegistry { insert: { text: string; internal: string }; update: { text?: string; internal?: string }; filters: { text?: TextFilter }; - includes: Record; + includes: { author: { to: "users"; many: false } }; hasPrimaryKey: true; }; events: { @@ -167,6 +167,12 @@ describe("typed database entity contract", () => { TestRegistry["notes"]["publicRow"], { author: { select: readonly ["token"] } } >; + type NestedAuthor = EntityResultFor< + TestRegistry, + "notes", + TestRegistry["notes"]["publicRow"], + { comments: { include: { author: true } } } + >; expectTypeOf>().toEqualTypeOf< TestRegistry["users"]["publicRow"] @@ -180,6 +186,9 @@ describe("typed database entity contract", () => { expectTypeOf>().toEqualTypeOf<{ token: string; }>(); + expectTypeOf< + NonNullable + >().toEqualTypeOf(); }); it("omits false relations and replaces successive include configurations", () => { @@ -206,6 +215,7 @@ describe("typed database entity contract", () => { author: { where: { name: "Ada" }, order: { name: "asc" } }, }); notes.include({ comments: { limit: 5 } }); + notes.include({ comments: { include: { author: true } } }); // @ts-expect-error direct null is not a filter shorthand notes.where({ body: null }); @@ -215,14 +225,16 @@ describe("typed database entity contract", () => { notes.where({ rank: { like: "1" } }); // @ts-expect-error JSON and unknown fields are not filterable notes.where({ payload: { eq: {} } }); + // @ts-expect-error a filter names columns on this entity, never relations + notes.where({ comments: { some: { text: "a" } } }); // @ts-expect-error unknown relations fail closed notes.include({ unknown: true }); // @ts-expect-error unknown relation options fail closed notes.include({ author: { offset: 1 } }); // @ts-expect-error to-one includes cannot be limited notes.include({ author: { limit: 1 } }); - // @ts-expect-error nested includes are not part of one-edge options - notes.include({ comments: { include: { author: true } } }); + // @ts-expect-error includes stop after the second relation edge + notes.include({ comments: { include: { author: { include: {} } } } }); } }); diff --git a/packages/appkit/src/plugins/database/tests/plugin.test.ts b/packages/appkit/src/plugins/database/tests/plugin.test.ts index 1e0704d16..53c3a10f8 100644 --- a/packages/appkit/src/plugins/database/tests/plugin.test.ts +++ b/packages/appkit/src/plugins/database/tests/plugin.test.ts @@ -1,5 +1,6 @@ +import type express from "express"; import { beforeEach, describe, expect, expectTypeOf, test, vi } from "vitest"; -import { defineSchema } from "../../../database/schema-builder"; +import { defineSchema, fk, id, text } from "../../../database/schema-builder"; const mocks = vi.hoisted(() => ({ createDatabaseState: vi.fn() })); vi.mock("../lifecycle", () => ({ @@ -19,6 +20,46 @@ function deferred() { } const schema = defineSchema(() => ({})); +const routedSchema = defineSchema((builder) => { + const users = builder.table("users", { id: id(), name: text() }); + const notes = builder.table("notes", { + id: id(), + authorId: fk(() => users.id), + }); + const events = builder.table("events", { message: text() }); + return { users, notes, events }; +}); + +function fakeRouter() { + const routes: string[] = []; + const record = + (method: string) => + (path: string): void => { + routes.push(`${method} ${path}`); + }; + return { + routes, + router: { + get: record("get"), + post: record("post"), + patch: record("patch"), + delete: record("delete"), + put: record("put"), + } as unknown as express.Router, + }; +} + +async function registerRoutes( + config: ConstructorParameters>[0], +) { + mocks.createDatabaseState.mockResolvedValue(candidate()); + const plugin = new DatabasePlugin(config); + await plugin.setup(); + const { router, routes } = fakeRouter(); + plugin.injectRoutes(router); + return { plugin, routes }; +} + function candidate(marker = "one") { let active = true; const end = vi.fn<() => Promise>(async () => undefined); @@ -139,6 +180,95 @@ describe("DatabasePlugin", () => { await expect(plugin.shutdown()).rejects.toBe(error); }); + test("registers no generated routes unless they are turned on", async () => { + for (const crudRoutes of [undefined, false] as const) { + const { routes } = await registerRoutes({ + schema: routedSchema, + crudRoutes, + }); + expect(routes).toEqual([]); + } + }); + + test("registers reads for every table or an explicit subset", async () => { + const assertNames = () => { + database({ schema: routedSchema, crudRoutes: { tables: ["notes"] } }); + database({ + schema: routedSchema, + hooks: { notes: { serialize: (row) => row } }, + }); + database({ + schema: routedSchema, + // @ts-expect-error only a declared table can be exposed + crudRoutes: { tables: ["missing"] }, + }); + database({ + schema: routedSchema, + // @ts-expect-error only a declared table can shape its own responses + hooks: { missing: { serialize: (row) => row } }, + }); + }; + void assertNames; + + const all = await registerRoutes({ + schema: routedSchema, + crudRoutes: true, + }); + expect(all.routes).toEqual([ + "get /users", + "get /users/:id", + "get /notes", + "get /notes/:id", + // A table without a primary key cannot address a single row. + "get /events", + ]); + expect(all.plugin.getEndpoints()).toMatchObject({ + "users.list": "/api/database/users", + "users.detail": "/api/database/users/:id", + }); + + const subset = await registerRoutes({ + schema: routedSchema, + crudRoutes: { tables: ["notes"] }, + }); + expect(subset.routes).toEqual(["get /notes", "get /notes/:id"]); + }); + + test("fails setup on an exposure list it cannot honor", async () => { + for (const tables of [["missing"], ["users", "users"], "users"]) { + mocks.createDatabaseState.mockResolvedValue(candidate()); + const plugin = new DatabasePlugin({ + schema: routedSchema, + crudRoutes: { tables } as unknown as { tables: ["users"] }, + }); + await expect(plugin.setup()).rejects.toMatchObject({ + category: "SETUP_FAILED", + }); + } + }); + + test("refuses to route names it cannot serve unambiguously", async () => { + const unsafe = [ + defineSchema((builder) => ({ + users: builder.table("users", { id: id() }), + Users: builder.table("Users", { id: id() }), + })), + defineSchema((builder) => ({ + _hidden: builder.table("_hidden", { id: id() }), + })), + ]; + for (const unsafeSchema of unsafe) { + mocks.createDatabaseState.mockResolvedValue(candidate()); + const plugin = new DatabasePlugin({ + schema: unsafeSchema, + crudRoutes: true, + }); + await expect(plugin.setup()).rejects.toMatchObject({ + category: "SETUP_FAILED", + }); + } + }); + test("isolates plugin instances and drains their exports independently", async () => { const one = candidate("one"); const two = candidate("two"); diff --git a/packages/appkit/src/plugins/database/types.ts b/packages/appkit/src/plugins/database/types.ts index 1cffbbb1d..a4d0a09bf 100644 --- a/packages/appkit/src/plugins/database/types.ts +++ b/packages/appkit/src/plugins/database/types.ts @@ -1,6 +1,50 @@ import type { Schema } from "../../database/schema-builder"; +/** Table names declared by one finalized schema. */ +export type SchemaTableName = Extract< + keyof TSchema["$tables"], + string +>; + +/** + * Generated read exposure: off by default, every table, or an explicit list. + * + * Reads run as the app's service principal and apply no per-user filter, so an + * enabled table is readable by anyone the app admits. Enabling a table also + * makes it includable from its neighbours; a relation whose target stays off + * cannot be included, which keeps one table's data behind one decision. + * + * Text filters accept caller-supplied `like`/`ilike` patterns, and this beta + * adds no statement cancellation below the connector, so an expensive pattern + * runs to completion while holding its pooled connection. + */ +export type CrudRoutesConfig = + | boolean + | { readonly tables: readonly SchemaTableName[] }; + +/** Which entity and generated operation produced the row being shaped. */ +export interface ReadSerializerContext { + readonly entity: string; + readonly operation: "list" | "detail"; +} + +/** + * Shape one already private-safe row before it reaches the wire. A `Promise` + * is not assignable to the return type, so an async callback fails to compile: + * serializers run inside the response path and must not add latency there. + */ +export type ReadSerializer = ( + row: Record, + context: ReadSerializerContext, +) => Record; + /** Configuration for one schema-bound DatabasePlugin instance. */ export type IDatabaseConfig = { readonly schema: TSchema; + readonly crudRoutes?: CrudRoutesConfig; + readonly hooks?: { + readonly [TTable in SchemaTableName]?: { + readonly serialize?: ReadSerializer; + }; + }; };