From a9e8ec67ba69e6da372c6910001c6c94e7c30ec1 Mon Sep 17 00:00:00 2001 From: ditadi Date: Tue, 23 Jun 2026 18:48:18 +0100 Subject: [PATCH 1/3] feat(database): define the database contract --- .../src/database/contract/column-info.ts | 107 +++++++++++++++ .../appkit/src/database/contract/index.ts | 4 + .../appkit/src/database/contract/registry.ts | 25 ++++ .../appkit/src/database/contract/relation.ts | 24 ++++ .../contract/tests/column-info.test.ts | 129 ++++++++++++++++++ .../database/contract/tests/registry.test.ts | 91 ++++++++++++ .../src/database/contract/tests/wire.test.ts | 61 +++++++++ packages/appkit/src/database/contract/wire.ts | 28 ++++ 8 files changed, 469 insertions(+) create mode 100644 packages/appkit/src/database/contract/column-info.ts create mode 100644 packages/appkit/src/database/contract/index.ts create mode 100644 packages/appkit/src/database/contract/registry.ts create mode 100644 packages/appkit/src/database/contract/relation.ts create mode 100644 packages/appkit/src/database/contract/tests/column-info.test.ts create mode 100644 packages/appkit/src/database/contract/tests/registry.test.ts create mode 100644 packages/appkit/src/database/contract/tests/wire.test.ts create mode 100644 packages/appkit/src/database/contract/wire.ts diff --git a/packages/appkit/src/database/contract/column-info.ts b/packages/appkit/src/database/contract/column-info.ts new file mode 100644 index 00000000..2def90fd --- /dev/null +++ b/packages/appkit/src/database/contract/column-info.ts @@ -0,0 +1,107 @@ +/** 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 new file mode 100644 index 00000000..b8eed038 --- /dev/null +++ b/packages/appkit/src/database/contract/index.ts @@ -0,0 +1,4 @@ +export * from "./column-info"; +export * from "./registry"; +export * from "./relation"; +export * from "./wire"; diff --git a/packages/appkit/src/database/contract/registry.ts b/packages/appkit/src/database/contract/registry.ts new file mode 100644 index 00000000..de42f669 --- /dev/null +++ b/packages/appkit/src/database/contract/registry.ts @@ -0,0 +1,25 @@ +/** The shape of a single generated registry entry. */ +export interface DatabaseRegistryEntry { + /** Full server-side row (includes private columns). */ + row: Record; + /** Accepted insert payload (private + server-generated columns omitted). */ + insert: Record; + /** Accepted update payload (PK + private + server-generated omitted, all optional). */ + update: Record; + /** Per-column filter operators usable in `where`. */ + filters: Record; + /** Relations that can be passed to `include`. */ + includes: Record; +} + +/** + * CANONICAL augmentation target. Empty by default; the generated `database.d.ts` + * augments it via `declare module "@databricks/appkit" { interface DatabaseRegistry { ... } }`. + */ +// biome-ignore lint/suspicious/noEmptyInterface: augmentation target, populated by typegen. +export interface DatabaseRegistry {} + +/** Literal entity keys present after typegen, or `never` before it has run. */ +export type RegisteredEntity = keyof { + [K in keyof DatabaseRegistry as string extends K ? never : K]: true; +}; diff --git a/packages/appkit/src/database/contract/relation.ts b/packages/appkit/src/database/contract/relation.ts new file mode 100644 index 00000000..d5f84ad0 --- /dev/null +++ b/packages/appkit/src/database/contract/relation.ts @@ -0,0 +1,24 @@ +/** 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 new file mode 100644 index 00000000..79abdd62 --- /dev/null +++ b/packages/appkit/src/database/contract/tests/column-info.test.ts @@ -0,0 +1,129 @@ +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 new file mode 100644 index 00000000..30e0c4f6 --- /dev/null +++ b/packages/appkit/src/database/contract/tests/registry.test.ts @@ -0,0 +1,91 @@ +import { describe, expectTypeOf, it } from "vitest"; +import type { + DatabaseRegistryEntry, + ReferentialAction, + RegisteredEntity, + RelationEdge, +} from "../index"; + +/** + * Type-level tests for the contract. These are verified by `tsc` during + * `pnpm typecheck` (vitest typecheck is not enabled in this repo); the runtime + * `it()` wrappers exist only so vitest registers the file as a suite. + * + * `DatabaseRegistry` is a global declaration-merging target, so we can't augment + * it per-test without polluting the whole project's typecheck. Instead we mirror + * the `RegisteredEntity` mapped-type logic over a local interface to prove the + * empty -> `never` and augmented -> literal-keys behaviour (same approach as + * plugins/serving/tests/types.test.ts). + */ + +// Mirror of RegisteredEntity from registry.ts, parameterised by an arbitrary +// registry interface so we can test both the empty and augmented states. +type RegisteredEntityOf = keyof { + [K in keyof R as string extends K ? never : K]: true; +}; + +describe("RegisteredEntity (declaration-merging behaviour)", () => { + it("resolves to never on the empty base registry", () => { + expectTypeOf>>().toBeNever(); + // The real, un-augmented contract type is also never until typegen runs. + expectTypeOf().toBeNever(); + }); + + it("resolves to the literal keys once the registry is augmented", () => { + interface AugmentedRegistry { + users: DatabaseRegistryEntry; + posts: DatabaseRegistryEntry; + } + expectTypeOf>().toEqualTypeOf< + "users" | "posts" + >(); + }); + + it("ignores a string index signature (stays never)", () => { + expectTypeOf< + RegisteredEntityOf> + >().toBeNever(); + }); +}); + +describe("DatabaseRegistryEntry shape", () => { + it("exposes the five generated facets as records", () => { + expectTypeOf().toHaveProperty("row"); + expectTypeOf().toHaveProperty("insert"); + expectTypeOf().toHaveProperty("update"); + expectTypeOf().toHaveProperty("filters"); + 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/tests/wire.test.ts b/packages/appkit/src/database/contract/tests/wire.test.ts new file mode 100644 index 00000000..727f2cf2 --- /dev/null +++ b/packages/appkit/src/database/contract/tests/wire.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_LIMIT, + FILTER_OPERATORS, + type FilterOperator, + IN_CAP, + isFilterOperator, + MAX_INCLUDES, + MAX_LIMIT, +} from "../index"; + +describe("wire caps", () => { + it("pins the literal cap values", () => { + expect(IN_CAP).toBe(100); + expect(MAX_LIMIT).toBe(500); + expect(DEFAULT_LIMIT).toBe(50); + expect(MAX_INCLUDES).toBe(10); + }); + + it("keeps DEFAULT_LIMIT within MAX_LIMIT", () => { + expect(DEFAULT_LIMIT).toBeLessThanOrEqual(MAX_LIMIT); + }); + + it("pins the filter-operator set (snapshot of the supported operators)", () => { + expect(FILTER_OPERATORS).toEqual([ + "eq", + "neq", + "gt", + "gte", + "lt", + "lte", + "like", + "ilike", + "in", + "is", + ]); + }); +}); + +describe("isFilterOperator", () => { + it.each([...FILTER_OPERATORS])("accepts %j", (op) => { + expect(isFilterOperator(op)).toBe(true); + }); + + it.each(["", "EQ", "equals", "between", "not", "or", "and", "=="])( + "rejects %j", + (token) => { + expect(isFilterOperator(token)).toBe(false); + }, + ); + + it("narrows an unknown string to FilterOperator", () => { + const token: string = "ilike"; + if (isFilterOperator(token)) { + const op: FilterOperator = token; + expect(FILTER_OPERATORS).toContain(op); + } else { + throw new Error("expected a valid operator"); + } + }); +}); diff --git a/packages/appkit/src/database/contract/wire.ts b/packages/appkit/src/database/contract/wire.ts new file mode 100644 index 00000000..c0e17950 --- /dev/null +++ b/packages/appkit/src/database/contract/wire.ts @@ -0,0 +1,28 @@ +/** Max number of values allowed in an `in.(…)` list. */ +export const IN_CAP = 100; +/** Hard ceiling for `.limit()` clamp. */ +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 = [ + "eq", + "neq", + "gt", + "gte", + "lt", + "lte", + "like", + "ilike", + "in", + "is", +] as const; + +export type FilterOperator = (typeof FILTER_OPERATORS)[number]; + +export function isFilterOperator(token: string): token is FilterOperator { + return (FILTER_OPERATORS as readonly string[]).includes(token); +} From bae2ec7a740a96f7089459ea6e1c45c76df4e2de Mon Sep 17 00:00:00 2001 From: ditadi Date: Wed, 24 Jun 2026 14:55:30 +0100 Subject: [PATCH 2/3] feat(database): schema DSL: columns, fk, table, single build pipeline --- packages/appkit/package.json | 1 + .../src/database/schema-builder/columns.ts | 150 +++++++++ .../database/schema-builder/define-schema.ts | 142 +++++++++ .../database/schema-builder/engine/tables.ts | 208 ++++++++++++ .../appkit/src/database/schema-builder/fk.ts | 20 ++ .../src/database/schema-builder/index.ts | 34 ++ .../src/database/schema-builder/private.ts | 27 ++ .../schema-builder/tests/columns.test.ts | 169 ++++++++++ .../tests/define-schema.test.ts | 295 ++++++++++++++++++ .../database/schema-builder/tests/fk.test.ts | 48 +++ .../schema-builder/tests/private.test.ts | 56 ++++ .../src/database/schema-builder/types.ts | 141 +++++++++ pnpm-lock.yaml | 8 + 13 files changed, 1299 insertions(+) create mode 100644 packages/appkit/src/database/schema-builder/columns.ts create mode 100644 packages/appkit/src/database/schema-builder/define-schema.ts create mode 100644 packages/appkit/src/database/schema-builder/engine/tables.ts create mode 100644 packages/appkit/src/database/schema-builder/fk.ts create mode 100644 packages/appkit/src/database/schema-builder/index.ts create mode 100644 packages/appkit/src/database/schema-builder/private.ts create mode 100644 packages/appkit/src/database/schema-builder/tests/columns.test.ts create mode 100644 packages/appkit/src/database/schema-builder/tests/define-schema.test.ts create mode 100644 packages/appkit/src/database/schema-builder/tests/fk.test.ts create mode 100644 packages/appkit/src/database/schema-builder/tests/private.test.ts create mode 100644 packages/appkit/src/database/schema-builder/types.ts diff --git a/packages/appkit/package.json b/packages/appkit/package.json index 2499075e..5fdb227c 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -79,6 +79,7 @@ "@types/semver": "7.7.1", "apache-arrow": "21.1.0", "dotenv": "16.6.1", + "drizzle-orm": "0.45.1", "express": "4.22.2", "get-port": "7.2.0", "js-yaml": "4.2.0", diff --git a/packages/appkit/src/database/schema-builder/columns.ts b/packages/appkit/src/database/schema-builder/columns.ts new file mode 100644 index 00000000..8ab177d1 --- /dev/null +++ b/packages/appkit/src/database/schema-builder/columns.ts @@ -0,0 +1,150 @@ +import type { ColumnInfoKind, ReferentialAction } from "../contract"; +import { + type ColumnTypeSpec, + type MutableColumnMeta, + SchemaBuildError, + type StorageKind, +} from "./types"; + +const SERVER_GENERATED = new Set(["id", "bigid"]); + +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); +} + +export class ColumnBuilder { + /** @internal */ readonly _spec: ColumnTypeSpec; + /** @internal */ readonly _meta: MutableColumnMeta; + + constructor(spec: ColumnTypeSpec, pgType: string, kind: ColumnInfoKind) { + const serverGenerated = SERVER_GENERATED.has(spec.kind); + this._spec = spec; + this._meta = { + name: "", + columnName: "", + kind, + pgType, + storageKind: specStorageKind(spec), + notNull: false, + 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, + }; + } + + notNull(): this { + this._meta.notNull = true; + return this; + } + + primaryKey(): this { + this._meta.primaryKey = true; + return this; + } + + unique(): this { + this._meta.unique = true; + return this; + } + + private(): this { + this._meta.isPrivate = true; + return this; + } + + owner(): this { + this._meta.isOwner = true; + return this; + } + + default(value: string | number | boolean): this { + this._meta.hasDefault = true; + this._meta.defaultExpr = stampDefaultExpr(value); + this._meta.defaultValue = value; + return this; + } + + defaultNow(): this { + this._meta.hasDefault = true; + this._meta.defaultExpr = "now()"; + this._meta.defaultNow = true; + return this; + } + + defaultRandom(): this { + this._meta.hasDefault = true; + this._meta.defaultExpr = "gen_random_uuid()"; + this._meta.defaultRandom = true; + return this; + } + + onDelete(action: ReferentialAction): this { + this.requireFk("onDelete"); + this._meta.onDelete = action; + return this; + } + + onUpdate(action: ReferentialAction): this { + this.requireFk("onUpdate"); + this._meta.onUpdate = action; + return this; + } + + private requireFk(modifier: string): void { + if (this._spec.kind !== "fk") { + throw new SchemaBuildError( + `.${modifier}() is only valid on fk() columns`, + ); + } + } +} + +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 timestamp = (opts?: { withTimezone?: boolean }) => { + const withTimezone = opts?.withTimezone ?? false; + return new ColumnBuilder( + { kind: "timestamp", withTimezone }, + withTimezone ? "timestamptz" : "timestamp", + "date", + ); +}; +export const jsonb = () => + new ColumnBuilder({ kind: "jsonb" }, "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", + ); +} diff --git a/packages/appkit/src/database/schema-builder/define-schema.ts b/packages/appkit/src/database/schema-builder/define-schema.ts new file mode 100644 index 00000000..cd572cd1 --- /dev/null +++ b/packages/appkit/src/database/schema-builder/define-schema.ts @@ -0,0 +1,142 @@ +import { ColumnBuilder } from "./columns"; +import { buildEngineTables } from "./engine/tables"; +import { mirrorStorageKind, resolveFkRef } from "./fk"; +import { + type AppKitTable, + type ColumnRef, + type DefineSchemaOptions, + type MutableColumnMeta, + type Schema, + SchemaBuildError, + type TableHandle, +} from "./types"; + +interface RawTable { + name: string; + metas: Record; + handle: AppKitTable & Record; +} + +export interface SchemaBuilderContext { + table>( + name: string, + columns: C, + ): TableHandle; + 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 ctx: SchemaBuilderContext = { + 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, + }); + } + raw.set(name, { name, metas, handle }); + return handle as TableHandle; + }, + 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", + ); + }, + }; + + 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, + }; + } + } + + // 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; + } + + // 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); + const result: Record = {}; + for (const [key, value] of Object.entries(returned)) { + const name = byHandle.get(value); + if (!name) + throw new SchemaBuildError( + `defineSchema returned a value for "${key}" that was not produced by ctx.table()`, + ); + + result[key] = value; + } + + const engineMap: Schema["$engine"] = {}; + for (const [key, t] of Object.entries(result)) engineMap[key] = t.$engine; + + return { + $schemaName: schemaName, + $tables: result, + $engine: engineMap, + $engineRelations: {}, + $engineSchema: { ...engineMap }, + }; +} diff --git a/packages/appkit/src/database/schema-builder/engine/tables.ts b/packages/appkit/src/database/schema-builder/engine/tables.ts new file mode 100644 index 00000000..7a500be8 --- /dev/null +++ b/packages/appkit/src/database/schema-builder/engine/tables.ts @@ -0,0 +1,208 @@ +import { + type AnyPgColumn, + bigserial, + type PgColumnBuilderBase, + type PgEnum, + type PgTable, + bigint as pgBigint, + boolean as pgBoolean, + pgEnum, + integer as pgInteger, + jsonb as pgJsonb, + pgSchema, + pgTable, + text as pgText, + 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 MutableColumnMeta, + SchemaBuildError, +} from "../types"; + +/** Loosely-typed engine column builder seam */ +type AnyColumnBuilder = PgColumnBuilderBase & { + primaryKey(): AnyColumnBuilder; + notNull(): AnyColumnBuilder; + unique(): AnyColumnBuilder; + default(value: unknown): AnyColumnBuilder; + defaultNow(): AnyColumnBuilder; + defaultRandom(): AnyColumnBuilder; + references( + ref: () => AnyPgColumn, + actions?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction }, + ): AnyColumnBuilder; +}; + +type PgEnumValues = PgEnum<[string, ...string[]]>; +type EnumRegistry = Map; + +function getEnum( + registry: EnumRegistry, + name: string, + values: readonly string[], +): PgEnumValues { + const existing = registry.get(name); + if (existing) return existing; + + const created = pgEnum(name, values as [string, ...string[]]); + registry.set(name, created); + return created; +} + +function baseColumn( + meta: MutableColumnMeta, + enums: EnumRegistry, +): AnyColumnBuilder { + const col = meta.columnName; + let builder: PgColumnBuilderBase; + switch (meta.storageKind) { + case "id": + builder = serial(col); + break; + case "bigid": + builder = bigserial(col, { mode: "bigint" }); + break; + case "text": + builder = pgText(col); + break; + case "varchar": + builder = pgVarchar(col, { length: meta.varcharLength ?? 255 }); + break; + case "integer": + builder = pgInteger(col); + break; + case "bigint": + builder = pgBigint(col, { mode: "bigint" }); + break; + case "boolean": + builder = pgBoolean(col); + break; + case "uuid": + builder = pgUuid(col); + break; + case "timestamp": + builder = pgTimestamp(col, { withTimezone: meta.withTimezone ?? false }); + break; + case "jsonb": + builder = pgJsonb(col); + break; + case "enum": + // biome-ignore lint/style/noNonNullAssertion: enum metas always carry an enumName. + builder = getEnum(enums, meta.enumName!, meta.enumValues ?? [])(col); + break; + } + return builder as AnyColumnBuilder; +} + +function buildColumn( + meta: MutableColumnMeta, + 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(); + + 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.fk) { + const target = meta.fk; + c = c.references( + () => resolveTarget(target.targetTable, target.targetColumn), + { onDelete: target.onDelete, onUpdate: target.onUpdate }, + ); + } + + return c; +} + +/** + * 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 = {}; + for (const [key, meta] of Object.entries(metas)) { + columnBuilders[key] = buildColumn(meta, 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)[ + key + ] as unknown as EngineColumn; + columns[key] = meta as ColumnMeta; + } + + return { engine, columns }; +} + +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. + */ +export function buildEngineTables( + raw: Iterable<{ name: string; metas: Record }>, + schemaName: string, +): Record { + const builtEngine: Record> = {}; + const resolveTarget = (table: string, column: string): AnyPgColumn => { + const t = builtEngine[table]; + if (!t || !t[column]) + throw new SchemaBuildError( + `Cannot resolve FK target "${table}.${column}"`, + ); + + return t[column]; + }; + + const enums: EnumRegistry = new Map(); + const tables: Record = {}; + for (const { name, metas } of raw) { + const built = buildTable(name, schemaName, metas, enums, resolveTarget); + builtEngine[name] = built.engine as unknown as Record; + tables[name] = makeAppKitTable(name, schemaName, built); + } + return tables; +} diff --git a/packages/appkit/src/database/schema-builder/fk.ts b/packages/appkit/src/database/schema-builder/fk.ts new file mode 100644 index 00000000..60ce4b74 --- /dev/null +++ b/packages/appkit/src/database/schema-builder/fk.ts @@ -0,0 +1,20 @@ +import { ColumnBuilder } from "./columns"; +import type { ColumnRef, FkRef, StorageKind } from "./types"; + +/** Declare foreign-key to another column. */ +export function fk(ref: FkRef): ColumnBuilder { + const builder = new ColumnBuilder({ kind: "fk" }, "int4", "number"); + builder._meta.fkRef = ref; + return builder; +} + +export function resolveFkRef(ref: FkRef): ColumnRef { + return typeof ref === "function" ? ref() : ref; +} + +/** A serial PK target stores as its plain integer type on the FK side. */ +export function mirrorStorageKind(targetStorage: StorageKind): StorageKind { + if (targetStorage === "id") return "integer"; + if (targetStorage === "bigid") return "bigint"; + return targetStorage; +} diff --git a/packages/appkit/src/database/schema-builder/index.ts b/packages/appkit/src/database/schema-builder/index.ts new file mode 100644 index 00000000..33f0ba46 --- /dev/null +++ b/packages/appkit/src/database/schema-builder/index.ts @@ -0,0 +1,34 @@ +export { + bigid, + bigint, + boolean, + ColumnBuilder, + enumColumn, + id, + integer, + jsonb, + text, + timestamp, + uuid, + varchar, +} from "./columns"; +export { defineSchema, type SchemaBuilderContext } from "./define-schema"; +export { fk } from "./fk"; +export { + APPKIT_TABLE, + isPrivateColumn, + nonPrivateColumnNames, + ownerColumnName, + privateColumnNames, +} from "./private"; +export type { + AppKitTable, + ColumnMeta, + ColumnRef, + DefineSchemaOptions, + ResolvedRelation, + Schema, + StorageKind, + TableHandle, +} from "./types"; +export { SchemaBuildError } from "./types"; diff --git a/packages/appkit/src/database/schema-builder/private.ts b/packages/appkit/src/database/schema-builder/private.ts new file mode 100644 index 00000000..c06694ee --- /dev/null +++ b/packages/appkit/src/database/schema-builder/private.ts @@ -0,0 +1,27 @@ +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/tests/columns.test.ts b/packages/appkit/src/database/schema-builder/tests/columns.test.ts new file mode 100644 index 00000000..8e2e52c6 --- /dev/null +++ b/packages/appkit/src/database/schema-builder/tests/columns.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; +import { + bigid, + bigint, + boolean, + ColumnBuilder, + type ColumnMeta, + enumColumn, + fk, + id, + integer, + jsonb, + SchemaBuildError, + text, + timestamp, + uuid, + varchar, +} from "../index"; + +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) => { + 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, + }); + expect(varchar(64)._meta.varcharLength).toBe(64); + }); + + it("timestamp toggles withTimezone and pgType", () => { + expect(timestamp()._meta).toMatchObject({ + pgType: "timestamp", + withTimezone: false, + }); + expect(timestamp({ withTimezone: true })._meta).toMatchObject({ + pgType: "timestamptz", + withTimezone: true, + }); + }); +}); + +describe("server-generated identity columns", () => { + it.each([id(), bigid()])( + "flags serial PKs as serverGenerated + primaryKey + hasDefault", + (builder) => { + expect(builder._meta.serverGenerated).toBe(true); + expect(builder._meta.primaryKey).toBe(true); + expect(builder._meta.hasDefault).toBe(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({ + notNull: true, + unique: true, + primaryKey: true, + isPrivate: true, + isOwner: true, + }); + }); +}); + +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"); + }); + + 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("records hasDefault and defaultValue for literals", () => { + const col = integer().default(42)._meta; + expect(col.hasDefault).toBe(true); + expect(col.defaultValue).toBe(42); + }); +}); + +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"); + }); + + it("throw on non-fk columns", () => { + expect(() => integer().onDelete("cascade")).toThrow(SchemaBuildError); + expect(() => text().onUpdate("cascade")).toThrow( + /only valid on fk\(\) columns/, + ); + }); +}); + +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("throws when no values are provided", () => { + expect(() => enumColumn("status", [])).toThrow( + /requires at least one value/, + ); + }); +}); 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 new file mode 100644 index 00000000..0924da89 --- /dev/null +++ b/packages/appkit/src/database/schema-builder/tests/define-schema.test.ts @@ -0,0 +1,295 @@ +import { getTableConfig, type PgTable } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; +import { + APPKIT_TABLE, + type AppKitTable, + bigid, + boolean, + type ColumnBuilder, + type DefineSchemaOptions, + defineSchema, + fk, + id, + integer, + type ResolvedRelation, + type Schema, + type SchemaBuilderContext, + type TableHandle, + text, + timestamp, +} 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; + +describe("defineSchema — basic build", () => { + const schema: Schema = defineSchema((t) => ({ + users: t.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"]); + expect(schema.$tables.users.$name).toBe("users"); + }); + + it("defaults schemaName to 'public'", () => { + expect(schema.$schemaName).toBe("public"); + expect(schema.$tables.users.$schemaName).toBe("public"); + }); + + it("marks built tables with the APPKIT_TABLE symbol", () => { + expect( + (schema.$tables.users as unknown as Record)[ + APPKIT_TABLE + ], + ).toBe(true); + }); + + 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("populates an engine table handle per column", () => { + expect(schema.$tables.users.$columns.id.engineColumn).toBeDefined(); + }); +}); + +describe("defineSchema — engine maps", () => { + 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("leaves $engineRelations empty", () => { + expect(schema.$engineRelations).toEqual({}); + }); + + it("$engineSchema is deep-equal to $engine when there are no relations", () => { + expect(schema.$engineSchema).toEqual(schema.$engine); + }); + + it("$engine carries a handle per table", () => { + expect(Object.keys(schema.$engine).sort()).toEqual(["posts", "users"]); + }); + + it("leaves $relations empty on every table", () => { + for (const tbl of Object.values(schema.$tables)) { + const relations: ResolvedRelation[] = tbl.$relations; + expect(relations).toEqual([]); + } + }); +}); + +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"); + }); +}); + +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, + ); + 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", { + 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, + }); + }); + + 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("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("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", + }); + }); + + it("throws when fk() targets an unknown table", () => { + expect(() => + defineSchema((t) => ({ + posts: t.table("posts", { + id: id(), + ghost: fk(() => ({ + __isColumnRef: true, + tableName: "missing", + columnName: "id", + })), + }), + })), + ).toThrow(/unknown table "missing"/); + }); + + 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"/); + }); +}); + +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 }; + }), + ).toThrow(/Duplicate table "users"/); + }); + + it("throws when a returned value did not come from ctx.table()", () => { + const rogue = { + $name: "rogue", + $schemaName: "public", + $columns: {}, + $relations: [], + } as unknown as AppKitTable; + expect(() => + defineSchema((t) => { + t.table("users", { id: id() }); + return { rogue }; + }), + ).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); + }); +}); diff --git a/packages/appkit/src/database/schema-builder/tests/fk.test.ts b/packages/appkit/src/database/schema-builder/tests/fk.test.ts new file mode 100644 index 00000000..ae7c1113 --- /dev/null +++ b/packages/appkit/src/database/schema-builder/tests/fk.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { mirrorStorageKind, resolveFkRef } from "../fk"; +import { type ColumnRef, fk, type StorageKind } from "../index"; + +const ref: ColumnRef = { + __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); + }); + + it("accepts a thunk ref for forward/self references", () => { + const col = fk(() => ref); + expect(typeof col._meta.fkRef).toBe("function"); + }); +}); + +describe("resolveFkRef()", () => { + it("returns a direct ref unchanged", () => { + expect(resolveFkRef(ref)).toBe(ref); + }); + + it("invokes a thunk ref", () => { + expect(resolveFkRef(() => ref)).toBe(ref); + }); +}); + +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"); + }); + + it.each(["text", "uuid", "integer", "bigint", "boolean"] as const)( + "passes %s through unchanged", + (kind) => { + expect(mirrorStorageKind(kind)).toBe(kind); + }, + ); +}); diff --git a/packages/appkit/src/database/schema-builder/tests/private.test.ts b/packages/appkit/src/database/schema-builder/tests/private.test.ts new file mode 100644 index 00000000..732b2994 --- /dev/null +++ b/packages/appkit/src/database/schema-builder/tests/private.test.ts @@ -0,0 +1,56 @@ +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(); + }); +}); diff --git a/packages/appkit/src/database/schema-builder/types.ts b/packages/appkit/src/database/schema-builder/types.ts new file mode 100644 index 00000000..231f87e7 --- /dev/null +++ b/packages/appkit/src/database/schema-builder/types.ts @@ -0,0 +1,141 @@ +import type { ColumnInfoKind, ReferentialAction } from "../contract"; + +/** + * Opaque handles to the internal query-engine objects. + */ +declare const ENGINE_TABLE: unique symbol; +declare const ENGINE_COLUMN: unique symbol; +export type EngineTable = { readonly [ENGINE_TABLE]: true }; +export type EngineColumn = { readonly [ENGINE_COLUMN]: true }; + +/** Internal description of a column's storage type */ +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 */ +export type StorageKind = + | "id" + | "bigid" + | "text" + | "varchar" + | "integer" + | "bigint" + | "boolean" + | "uuid" + | "timestamp" + | "jsonb" + | "enum"; + +/** A deferred or direct reference to a target column */ +export interface ColumnRef { + readonly __isColumnRef: true; + readonly tableName: string; + readonly columnName: string; +} + +export type FkRef = ColumnRef | (() => ColumnRef); + +/** Mutable working metadata; frozen into {@link ColumnMeta} at the end of the build. */ +export interface MutableColumnMeta { + name: string; + columnName: string; + kind: ColumnInfoKind; + pgType: string; + 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; + withTimezone?: boolean; + varcharLength?: number; + enumName?: string; + enumValues?: readonly string[]; + fkRef?: FkRef; + onDelete?: ReferentialAction; + onUpdate?: ReferentialAction; + fk?: { + targetTable: string; + targetColumn: string; + onDelete?: ReferentialAction; + onUpdate?: ReferentialAction; + }; + /** @internal opaque engine column handle */ + engineColumn?: EngineColumn; +} + +/** Resolved, read-only column metadata exposed on a built table. */ +export type ColumnMeta = Readonly; + +/** + * 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; +} + +/** 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; +} + +/** The object returned by `ctx.table(...)`: column refs + (after build) the table metadata. */ +export type TableHandle> = AppKitTable & { + readonly [K in keyof C]: ColumnRef; +}; + +export interface DefineSchemaOptions { + /** Postgres schema name; canonical default is `"public"`. */ + schemaName?: string; +} + +export interface Schema { + $schemaName: string; + $tables: Record; + /** @internal opaque engine table handles */ + $engine: Record; + /** @internal engine relations */ + $engineRelations: Record; + /** @internal engine schema */ + $engineSchema: Record; +} + +export class SchemaBuildError extends Error { + constructor(message: string) { + super(message); + this.name = "SchemaBuildError"; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20176851..a76e09e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -308,6 +308,9 @@ importers: dotenv: specifier: 16.6.1 version: 16.6.1 + drizzle-orm: + specifier: 0.45.1 + version: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(pg@8.18.0) express: specifier: 4.22.2 version: 4.22.2 @@ -11466,6 +11469,11 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} From 92c5dee6d4a805cc87bdd63b279f8c67d4c35b5c Mon Sep 17 00:00:00 2001 From: ditadi Date: Fri, 3 Jul 2026 13:06:05 +0100 Subject: [PATCH 3/3] feat(database): one-pass relations + zod validators + collision checks --- .gitignore | 1 + .../database/schema-builder/define-schema.ts | 23 ++- .../schema-builder/engine/relations.ts | 42 ++++ .../src/database/schema-builder/index.ts | 3 + .../src/database/schema-builder/relations.ts | 63 ++++++ .../tests/define-schema.test.ts | 59 ++++-- .../tests/engine-relations.test.ts | 95 +++++++++ .../schema-builder/tests/private.test.ts | 12 ++ .../schema-builder/tests/relations.test.ts | 189 ++++++++++++++++++ .../schema-builder/tests/validators.test.ts | 174 ++++++++++++++++ .../src/database/schema-builder/types.ts | 4 - .../src/database/schema-builder/validators.ts | 56 ++++++ 12 files changed, 700 insertions(+), 21 deletions(-) create mode 100644 packages/appkit/src/database/schema-builder/engine/relations.ts create mode 100644 packages/appkit/src/database/schema-builder/relations.ts create mode 100644 packages/appkit/src/database/schema-builder/tests/engine-relations.test.ts create mode 100644 packages/appkit/src/database/schema-builder/tests/relations.test.ts create mode 100644 packages/appkit/src/database/schema-builder/tests/validators.test.ts create mode 100644 packages/appkit/src/database/schema-builder/validators.ts diff --git a/.gitignore b/.gitignore index 645f5cf5..3c17548e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ coverage .databricks .claude/scheduled_tasks.lock +internal diff --git a/packages/appkit/src/database/schema-builder/define-schema.ts b/packages/appkit/src/database/schema-builder/define-schema.ts index cd572cd1..e1fbd1c9 100644 --- a/packages/appkit/src/database/schema-builder/define-schema.ts +++ b/packages/appkit/src/database/schema-builder/define-schema.ts @@ -1,6 +1,7 @@ import { ColumnBuilder } from "./columns"; import { buildEngineTables } from "./engine/tables"; import { mirrorStorageKind, resolveFkRef } from "./fk"; +import { buildRelations } from "./relations"; import { type AppKitTable, type ColumnRef, @@ -10,6 +11,7 @@ import { SchemaBuildError, type TableHandle, } from "./types"; +import { deriveInsertSchema, deriveUpdateSchema } from "./validators"; interface RawTable { name: string; @@ -52,6 +54,17 @@ export function defineSchema( } 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; }, @@ -115,9 +128,15 @@ export function defineSchema( tables[name] = handle; } + buildRelations(tables); + // 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); + for (const [name, t] of Object.entries(tables)) { + byHandle.set(t, name); + t.$insertSchema = deriveInsertSchema(t); + t.$updateSchema = deriveUpdateSchema(t); + } const result: Record = {}; for (const [key, value] of Object.entries(returned)) { const name = byHandle.get(value); @@ -136,7 +155,5 @@ export function defineSchema( $schemaName: schemaName, $tables: result, $engine: engineMap, - $engineRelations: {}, - $engineSchema: { ...engineMap }, }; } diff --git a/packages/appkit/src/database/schema-builder/engine/relations.ts b/packages/appkit/src/database/schema-builder/engine/relations.ts new file mode 100644 index 00000000..7274b495 --- /dev/null +++ b/packages/appkit/src/database/schema-builder/engine/relations.ts @@ -0,0 +1,42 @@ +import { type Relation, relations } from "drizzle-orm"; +import type { AnyPgColumn, PgTable } from "drizzle-orm/pg-core"; +import type { AppKitTable } 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; +} + +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 = {}; + + 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 = {}; + for (const relation of table.$relations) { + const target = byName.get(relation.targetTable); + if (!target) continue; + 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)], + }) + : many(targetEngine); + } + return config; + }); + } + return out; +} diff --git a/packages/appkit/src/database/schema-builder/index.ts b/packages/appkit/src/database/schema-builder/index.ts index 33f0ba46..6dec6967 100644 --- a/packages/appkit/src/database/schema-builder/index.ts +++ b/packages/appkit/src/database/schema-builder/index.ts @@ -13,6 +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, @@ -21,6 +22,7 @@ export { ownerColumnName, privateColumnNames, } from "./private"; +export { buildRelations } from "./relations"; export type { AppKitTable, ColumnMeta, @@ -32,3 +34,4 @@ export type { TableHandle, } from "./types"; export { SchemaBuildError } from "./types"; +export { deriveInsertSchema, deriveUpdateSchema } from "./validators"; diff --git a/packages/appkit/src/database/schema-builder/relations.ts b/packages/appkit/src/database/schema-builder/relations.ts new file mode 100644 index 00000000..814c897c --- /dev/null +++ b/packages/appkit/src/database/schema-builder/relations.ts @@ -0,0 +1,63 @@ +import { type AppKitTable, 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(); + + for (const meta of Object.values(table.$columns)) { + if (!meta.fk) continue; + + const name = meta.fk.targetTable; + if (columnNames.has(name)) + throw new SchemaBuildError( + `Forward relation "${table.$name}.${name}" collides with a column of the same name`, + ); + + if (seenForward.has(name)) + throw new SchemaBuildError( + `Ambiguous forward relation "${table.$name}.${name}": multiple foreign keys target "${name}". Rename one target or model the relation explicitly.`, + ); + seenForward.add(name); + table.$relations.push({ + name, + cardinality: "toOne", + localColumn: meta.columnName, + targetTable: name, + 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)) + throw new SchemaBuildError( + `Reverse relation "${targetTable.$name}.${name}" collides with a column of the same name`, + ); + + if (targetTable.$relations.some((r) => r.name === name)) + throw new SchemaBuildError( + `Reverse relation "${targetTable.$name}.${name}" is ambiguous (multiple foreign keys from "${table.$name}"). Disambiguate by renaming the source table.`, + ); + + targetTable.$relations.push({ + name, + cardinality: "toMany", + localColumn: meta.fk.targetColumn, + targetTable: table.$name, + targetColumn: meta.columnName, + inferred: true, + }); + } + } +} 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 0924da89..c03b6903 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 @@ -65,7 +65,25 @@ describe("defineSchema — basic build", () => { }); }); -describe("defineSchema — engine maps", () => { +describe("defineSchema — engine maps (no relations)", () => { + const schema = defineSchema((t) => ({ + users: t.table("users", { id: id() }), + tags: t.table("tags", { id: id(), label: text() }), + })); + + it("$engine carries a handle per table", () => { + expect(Object.keys(schema.$engine).sort()).toEqual(["tags", "users"]); + }); + + it("leaves $relations empty on every table", () => { + for (const tbl of Object.values(schema.$tables)) { + const relations: ResolvedRelation[] = tbl.$relations; + expect(relations).toEqual([]); + } + }); +}); + +describe("defineSchema — engine maps (with relations)", () => { const schema = defineSchema((t) => ({ users: t.table("users", { id: id() }), posts: t.table("posts", { @@ -78,23 +96,36 @@ describe("defineSchema — engine maps", () => { }), })); - it("leaves $engineRelations empty", () => { - expect(schema.$engineRelations).toEqual({}); - }); - - it("$engineSchema is deep-equal to $engine when there are no relations", () => { - expect(schema.$engineSchema).toEqual(schema.$engine); + it("$engine carries only the table handles", () => { + expect(Object.keys(schema.$engine).sort()).toEqual(["posts", "users"]); }); - it("$engine carries a handle per table", () => { - expect(Object.keys(schema.$engine).sort()).toEqual(["posts", "users"]); + 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, + }, + ]); }); - it("leaves $relations empty on every table", () => { - for (const tbl of Object.values(schema.$tables)) { - const relations: ResolvedRelation[] = tbl.$relations; - expect(relations).toEqual([]); - } + 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, + }, + ]); }); }); 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 new file mode 100644 index 00000000..70a16e1f --- /dev/null +++ b/packages/appkit/src/database/schema-builder/tests/engine-relations.test.ts @@ -0,0 +1,95 @@ +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"; + +/** Structural view of a Drizzle `relations()` object (avoids leaning on internals' exact types). */ +type RelationsLike = { + table: PgTable; + config: (helpers: unknown) => Record; +}; + +/** A built `one()` relation's resolved config. */ +type OneInternals = { + config?: { fields: { name: string }[]; references: { name: string }[] }; + referencedTableName: string; +}; + +/** Materialize the per-relation config from an emitted `relations()` object. */ +function configOf(rel: unknown): Record { + const r = rel as RelationsLike; + return r.config(createTableRelationsHelpers(r.table)); +} + +describe("buildEngineRelations", () => { + const schema = defineSchema((t) => { + const users = t.table("users", { id: id(), name: text() }); + const posts = t.table("posts", { + id: id(), + authorId: fk(() => users.id).notNull(), + }); + return { users, posts }; + }); + const engineRelations = buildEngineRelations(schema.$tables); + + it("emits a Relations entry per participating table", () => { + expect(Object.keys(engineRelations).sort()).toEqual([ + "postsRelations", + "usersRelations", + ]); + }); + + it("emits a one() with the correct fields/references on the FK owner", () => { + const cfg = configOf(engineRelations.postsRelations); + expect(Object.keys(cfg)).toEqual(["users"]); + + const rel = cfg.users; + expect(rel instanceof One).toBe(true); + + const one = rel as unknown as OneInternals; + expect(one.config?.fields.map((c) => c.name)).toEqual(["authorId"]); + expect(one.config?.references.map((c) => c.name)).toEqual(["id"]); + expect(one.referencedTableName).toBe("users"); + }); + + it("emits a many() on the FK target", () => { + const cfg = configOf(engineRelations.usersRelations); + expect(Object.keys(cfg)).toEqual(["posts"]); + + const rel = cfg.posts; + expect(rel instanceof Many).toBe(true); + expect( + (rel as unknown as { referencedTableName: string }).referencedTableName, + ).toBe("posts"); + }); + + it("resolves relation targets by table name even when return keys differ", () => { + const s = defineSchema((t) => { + const cases = t.table("cases", { id: id() }); + const statusHistory = t.table("status_history", { + id: id(), + caseId: fk(() => cases.id), + }); + return { cases, statusHistory }; + }); + const rels = buildEngineRelations(s.$tables); + + expect(Object.keys(rels).sort()).toEqual([ + "casesRelations", + "status_historyRelations", + ]); + expect(configOf(rels.casesRelations).status_history instanceof Many).toBe( + true, + ); + expect(configOf(rels.status_historyRelations).cases instanceof One).toBe( + true, + ); + }); + + it("returns an empty map when no table participates in a relation", () => { + const standalone = defineSchema((t) => ({ + widgets: t.table("widgets", { id: id(), label: text() }), + })); + expect(buildEngineRelations(standalone.$tables)).toEqual({}); + }); +}); diff --git a/packages/appkit/src/database/schema-builder/tests/private.test.ts b/packages/appkit/src/database/schema-builder/tests/private.test.ts index 732b2994..634d8e7a 100644 --- a/packages/appkit/src/database/schema-builder/tests/private.test.ts +++ b/packages/appkit/src/database/schema-builder/tests/private.test.ts @@ -53,4 +53,16 @@ describe("ownerColumnName", () => { 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 new file mode 100644 index 00000000..661032d4 --- /dev/null +++ b/packages/appkit/src/database/schema-builder/tests/relations.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; +import { + type AppKitTable, + buildRelations, + type ColumnMeta, + defineSchema, + fk, + id, + type ResolvedRelation, + 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 }; + }); + + it("creates a forward toOne named after the target table on the FK owner", () => { + const relations: ResolvedRelation[] = schema.$tables.notes.$relations; + expect(relations).toEqual([ + { + name: "cases", + cardinality: "toOne", + localColumn: "caseId", + targetTable: "cases", + targetColumn: "id", + 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", + cardinality: "toMany", + localColumn: "id", + targetTable: "notes", + 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", { + id: id(), + caseId: fk(() => cases.id), + }); + return { cases, statusHistory }; + }); + expect(schema.$tables.cases.$relations.map((r) => r.name)).toEqual([ + "status_history", + ]); + }); +}); + +describe("buildRelations — self references", () => { + it("keeps only the forward toOne for a self-referential FK", () => { + const schema = defineSchema((t) => { + const nodes = t.table("nodes", { + id: id(), + parentId: fk(() => ({ + __isColumnRef: true, + tableName: "nodes", + columnName: "id", + })), + }); + return { nodes }; + }); + const relations: ResolvedRelation[] = schema.$tables.nodes.$relations; + expect(relations).toEqual([ + { + name: "nodes", + cardinality: "toOne", + localColumn: "parentId", + targetTable: "nodes", + targetColumn: "id", + inferred: false, + }, + ]); + }); +}); + +/** 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" }, + }, + }); + + 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, + }, + ]); + }); +}); + +describe("buildRelations — collision + ambiguity guards", () => { + it("throws when a forward relation name collides with a column", () => { + expect(() => + defineSchema((t) => { + const tag = t.table("tag", { id: id() }); + const post = t.table("post", { + id: id(), + tag: text(), + tagId: fk(() => tag.id), + }); + return { tag, post }; + }), + ).toThrow(/Forward relation "post\.tag" collides with a column/); + }); + + it("throws when two FKs target the same table (ambiguous forward)", () => { + expect(() => + defineSchema((t) => { + const users = t.table("users", { id: id() }); + const messages = t.table("messages", { + id: id(), + senderId: fk(() => users.id), + recipientId: fk(() => users.id), + }); + return { users, messages }; + }), + ).toThrow(/Ambiguous forward relation "messages\.users"/); + }); + + it("throws when a reverse relation name collides with a column on the target", () => { + expect(() => + defineSchema((t) => { + const notes = t.table("notes", { id: id(), posts: text() }); + const posts = t.table("posts", { + id: id(), + noteId: fk(() => notes.id), + }); + return { notes, posts }; + }), + ).toThrow(/Reverse relation "notes\.posts" 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 new file mode 100644 index 00000000..6fbe56d4 --- /dev/null +++ b/packages/appkit/src/database/schema-builder/tests/validators.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import type { ZodType } from "zod"; +import { + type AppKitTable, + bigint, + boolean, + defineSchema, + deriveInsertSchema, + deriveUpdateSchema, + enumColumn, + id, + integer, + jsonb, + text, + timestamp, + uuid, +} from "../index"; + +/** Read the per-field shape off a derived Zod object schema (test-only seam). */ +function shapeOf(schema: ZodType): Record { + return (schema as unknown as { shape: Record }).shape; +} + +const schema = defineSchema((t) => ({ + users: t.table("users", { + id: id(), + email: text().notNull(), + name: text(), + role: enumColumn("user_role", ["admin", "member"]) + .notNull() + .default("member"), + loginCount: integer().notNull().default(0), + secret: text().notNull().private(), + createdAt: timestamp().notNull().defaultNow(), + }), + // Natural (non server-generated) PK: present in insert, omitted from update. + accounts: t.table("accounts", { + slug: text().primaryKey().notNull(), + label: text().notNull(), + }), + types: t.table("types", { + id: id(), + tags: text().notNull(), + count: integer().notNull(), + big: bigint().notNull(), + flag: boolean().notNull(), + when: timestamp().notNull(), + doc: jsonb().notNull(), + ref: uuid().notNull(), + status: enumColumn("status_kind", ["open", "closed"]).notNull(), + }), +})); + +const users = schema.$tables.users; +const accounts = schema.$tables.accounts; +const types = schema.$tables.types; + +describe("defineSchema — validator wiring", () => { + it("stamps $insertSchema and $updateSchema on every table", () => { + for (const table of Object.values(schema.$tables) as AppKitTable[]) { + expect(table.$insertSchema).toBeDefined(); + expect(table.$updateSchema).toBeDefined(); + } + }); +}); + +describe("deriveInsertSchema", () => { + const insert = deriveInsertSchema(users); + + it("omits private and server-generated columns", () => { + expect(Object.keys(shapeOf(insert)).sort()).toEqual([ + "createdAt", + "email", + "loginCount", + "name", + "role", + ]); + }); + + 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); + }); + + it("makes defaulted columns optional even when notNull", () => { + const shape = shapeOf(insert); + expect(shape.role.safeParse(undefined).success).toBe(true); + expect(shape.loginCount.safeParse(undefined).success).toBe(true); + expect(shape.createdAt.safeParse(undefined).success).toBe(true); + }); + + it("makes nullable columns both nullable and optional", () => { + const name = shapeOf(insert).name; + expect(name.safeParse(null).success).toBe(true); + expect(name.safeParse(undefined).success).toBe(true); + expect(name.safeParse("Ada").success).toBe(true); + }); + + it("keeps a non-server-generated PK in the insert payload", () => { + expect(Object.keys(shapeOf(deriveInsertSchema(accounts))).sort()).toEqual([ + "label", + "slug", + ]); + }); +}); + +describe("deriveUpdateSchema", () => { + it("omits the primary key (and private + server-generated)", () => { + expect(Object.keys(shapeOf(deriveUpdateSchema(accounts)))).toEqual([ + "label", + ]); + expect(Object.keys(shapeOf(deriveUpdateSchema(users))).sort()).toEqual([ + "createdAt", + "email", + "loginCount", + "name", + "role", + ]); + }); + + it("is fully partial — every field optional, including required ones", () => { + const update = deriveUpdateSchema(users); + expect(update.safeParse({}).success).toBe(true); + expect(update.safeParse({ email: "a@b.com" }).success).toBe(true); + // `email` is notNull/no-default (required on insert) but optional on update. + expect(shapeOf(update).email.safeParse(undefined).success).toBe(true); + }); +}); + +describe("zodForColumn — engine-neutral kind mapping", () => { + const shape = shapeOf(deriveInsertSchema(types)); + + it("maps string columns to z.string()", () => { + expect(shape.tags.safeParse("x").success).toBe(true); + expect(shape.tags.safeParse(5).success).toBe(false); + }); + + it("maps number columns to z.number()", () => { + expect(shape.count.safeParse(5).success).toBe(true); + expect(shape.count.safeParse("5").success).toBe(false); + }); + + it("maps bigint columns to a bigint | number | string union", () => { + 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(true).success).toBe(false); + }); + + it("maps boolean columns to z.boolean()", () => { + expect(shape.flag.safeParse(true).success).toBe(true); + 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); + expect(shape.when.safeParse("2020-01-01T00:00:00Z").success).toBe(true); + expect(shape.when.safeParse(5).success).toBe(false); + }); + + it("maps json columns to z.unknown() (accepts arbitrary values)", () => { + expect(shape.doc.safeParse({ nested: [1, 2] }).success).toBe(true); + }); + + it("maps uuid columns to z.string() (no format constraint)", () => { + expect(shape.ref.safeParse("not-a-real-uuid").success).toBe(true); + expect(shape.ref.safeParse(123).success).toBe(false); + }); + + it("maps enum columns to z.enum() over the declared values", () => { + expect(shape.status.safeParse("open").success).toBe(true); + expect(shape.status.safeParse("nope").success).toBe(false); + }); +}); diff --git a/packages/appkit/src/database/schema-builder/types.ts b/packages/appkit/src/database/schema-builder/types.ts index 231f87e7..82de3516 100644 --- a/packages/appkit/src/database/schema-builder/types.ts +++ b/packages/appkit/src/database/schema-builder/types.ts @@ -127,10 +127,6 @@ export interface Schema { $tables: Record; /** @internal opaque engine table handles */ $engine: Record; - /** @internal engine relations */ - $engineRelations: Record; - /** @internal engine schema */ - $engineSchema: Record; } 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 new file mode 100644 index 00000000..9efe9f20 --- /dev/null +++ b/packages/appkit/src/database/schema-builder/validators.ts @@ -0,0 +1,56 @@ +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 { + switch (meta.kind) { + case "string": + case "uuid": + return z.string(); + case "number": + return z.number(); + case "bigint": + return z.union([z.bigint(), z.number(), z.string()]); + case "boolean": + return z.boolean(); + case "date": + return z.union([z.date(), z.string()]); + case "json": + return z.unknown(); + case "enum": + return meta.enumValues && meta.enumValues.length > 0 + ? z.enum([...meta.enumValues] as [string, ...string[]]) + : z.string(); + default: + return z.unknown(); + } +} + +/** Insert payload: omit private + server-generated; */ +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.notNull) field = field.nullable(); + if (!meta.notNull || meta.hasDefault) field = field.optional(); + shape[meta.columnName] = field; + } + + return z.object(shape); +} + +/** Update payload: omit PK + private + server-generated; every field optional (partial). */ +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.notNull) field = field.nullable(); + shape[meta.columnName] = field.optional(); + } + return z.object(shape); +}