From 88e7ab3f9cf4810163afe36dff6d7bbf6775a664 Mon Sep 17 00:00:00 2001 From: ditadi Date: Tue, 4 Aug 2026 23:58:57 +0100 Subject: [PATCH] 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); }