Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ coverage
.databricks

.claude/scheduled_tasks.lock
internal
1 change: 1 addition & 0 deletions packages/appkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
107 changes: 107 additions & 0 deletions packages/appkit/src/database/contract/column-info.ts
Original file line number Diff line number Diff line change
@@ -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";
}
4 changes: 4 additions & 0 deletions packages/appkit/src/database/contract/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./column-info";
export * from "./registry";
export * from "./relation";
export * from "./wire";
25 changes: 25 additions & 0 deletions packages/appkit/src/database/contract/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/** The shape of a single generated registry entry. */
export interface DatabaseRegistryEntry {
/** Full server-side row (includes private columns). */
row: Record<string, unknown>;
/** Accepted insert payload (private + server-generated columns omitted). */
insert: Record<string, unknown>;
/** Accepted update payload (PK + private + server-generated omitted, all optional). */
update: Record<string, unknown>;
/** Per-column filter operators usable in `where`. */
filters: Record<string, unknown>;
/** Relations that can be passed to `include`. */
includes: Record<string, unknown>;
}

/**
* 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;
};
24 changes: 24 additions & 0 deletions packages/appkit/src/database/contract/relation.ts
Original file line number Diff line number Diff line change
@@ -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;
}
129 changes: 129 additions & 0 deletions packages/appkit/src/database/contract/tests/column-info.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
91 changes: 91 additions & 0 deletions packages/appkit/src/database/contract/tests/registry.test.ts
Original file line number Diff line number Diff line change
@@ -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<R> = 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<RegisteredEntityOf<Record<never, never>>>().toBeNever();
// The real, un-augmented contract type is also never until typegen runs.
expectTypeOf<RegisteredEntity>().toBeNever();
});

it("resolves to the literal keys once the registry is augmented", () => {
interface AugmentedRegistry {
users: DatabaseRegistryEntry;
posts: DatabaseRegistryEntry;
}
expectTypeOf<RegisteredEntityOf<AugmentedRegistry>>().toEqualTypeOf<
"users" | "posts"
>();
});

it("ignores a string index signature (stays never)", () => {
expectTypeOf<
RegisteredEntityOf<Record<string, DatabaseRegistryEntry>>
>().toBeNever();
});
});

describe("DatabaseRegistryEntry shape", () => {
it("exposes the five generated facets as records", () => {
expectTypeOf<DatabaseRegistryEntry>().toHaveProperty("row");
expectTypeOf<DatabaseRegistryEntry>().toHaveProperty("insert");
expectTypeOf<DatabaseRegistryEntry>().toHaveProperty("update");
expectTypeOf<DatabaseRegistryEntry>().toHaveProperty("filters");
expectTypeOf<DatabaseRegistryEntry>().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<string>();
expectTypeOf(edge.toTable).toEqualTypeOf<string>();
expectTypeOf(edge.toColumn).toEqualTypeOf<string>();
expectTypeOf(edge.onDelete).toEqualTypeOf<ReferentialAction | undefined>();
expectTypeOf(edge.onUpdate).toEqualTypeOf<ReferentialAction | undefined>();
});

it("accepts a minimal edge without referential actions", () => {
const edge: RelationEdge = {
fromColumn: "author_id",
toTable: "users",
toColumn: "id",
};
expectTypeOf(edge).toMatchTypeOf<RelationEdge>();
});

it("pins the referential-action union", () => {
expectTypeOf<ReferentialAction>().toEqualTypeOf<
"cascade" | "set null" | "set default" | "restrict" | "no action"
>();
});
});
Loading
Loading