From 50be452211db92f95e4cf685e214fa456eab9982 Mon Sep 17 00:00:00 2001 From: ditadi Date: Mon, 4 May 2026 10:18:04 +0100 Subject: [PATCH 1/7] feat(appkit): add column metadata route and types CLI --- packages/appkit-ui/src/js/database/client.ts | 8 ++ packages/appkit-ui/src/js/database/types.ts | 29 +++++ .../src/plugins/database/columns-route.ts | 111 ++++++++++++++++++ .../src/plugins/database/route-generator.ts | 32 +++++ .../database/tests/columns-route.test.ts | 92 +++++++++++++++ .../database/tests/route-generator.test.ts | 44 +++++++ .../type-generator/database/vite-plugin.ts | 18 +++ packages/shared/src/cli/commands/db/index.ts | 3 + packages/shared/src/cli/commands/db/types.ts | 90 ++++++++++++++ 9 files changed, 427 insertions(+) create mode 100644 packages/appkit/src/plugins/database/columns-route.ts create mode 100644 packages/appkit/src/plugins/database/tests/columns-route.test.ts create mode 100644 packages/shared/src/cli/commands/db/types.ts diff --git a/packages/appkit-ui/src/js/database/client.ts b/packages/appkit-ui/src/js/database/client.ts index a6928180..fc3a10ea 100644 --- a/packages/appkit-ui/src/js/database/client.ts +++ b/packages/appkit-ui/src/js/database/client.ts @@ -1,6 +1,7 @@ import { DatabaseHTTPError } from "./errors"; import type { ApplyIncludes, + ColumnInfo, DatabaseClient, DatabaseClientConfig, EntityClient, @@ -108,6 +109,13 @@ export function createDatabaseClient( const json = await readJson<{ count: number }>(res); return json.count; }, + columns: async (signal) => { + // Metadata-only endpoint — chain state (where/order/limit/...) + // would be meaningless here, so we deliberately ignore it. + const url = `${baseUrl}/${entity}/_columns`; + const res = await fetchImpl(url, { signal }); + return readJson(res); + }, create: async (data, signal) => { const res = await fetchImpl(`${baseUrl}/${entity}`, { diff --git a/packages/appkit-ui/src/js/database/types.ts b/packages/appkit-ui/src/js/database/types.ts index f66c081d..1456d9b8 100644 --- a/packages/appkit-ui/src/js/database/types.ts +++ b/packages/appkit-ui/src/js/database/types.ts @@ -66,6 +66,28 @@ export type WhereInput = { /** Sort directive for `.order(...)`. */ export type OrderInput = { [K in keyof TRow]?: "asc" | "desc" }; +/** + * Column metadata from `GET /api/database//_columns` for form UIs. + * `type` is a control bucket (not SQL): ints → `"number"`, timestamps → `"date"`. + */ +export interface ColumnInfo { + name: string; + type: + | "string" + | "number" + | "boolean" + | "date" + | "json" + | "uuid" + | "bigint" + | "unknown"; + nullable: boolean; + primaryKey: boolean; + hasDefault: boolean; + /** Postgres/server-generated; hide from create flows by default. */ + generated: boolean; +} + /** Related row shape: single `{ row }` or `{ row }[]` from the registry. */ export type RelatedRow< TIncludes, @@ -128,6 +150,13 @@ export interface EntityClient< first(signal?: AbortSignal): Promise; find(id: string | number, signal?: AbortSignal): Promise; count(signal?: AbortSignal): Promise; + /** + * Fetch the entity's column metadata. Intended for auto-rendered + * edit/create forms that need to know which inputs to draw; reads are + * cheap (metadata is static for the plugin's lifetime) so caching is the + * caller's concern. + */ + columns(signal?: AbortSignal): Promise; create(data: TInsert, signal?: AbortSignal): Promise; /** diff --git a/packages/appkit/src/plugins/database/columns-route.ts b/packages/appkit/src/plugins/database/columns-route.ts new file mode 100644 index 00000000..5fc5225e --- /dev/null +++ b/packages/appkit/src/plugins/database/columns-route.ts @@ -0,0 +1,111 @@ +import type { AppKitTable, Schema } from "@/database"; +import { adaptDrizzleTable } from "@/database/introspector/drizzle-adapter"; + +/** + * Runtime column metadata exposed by `GET /api/database//_columns`. + * + * The browser uses this to auto-render edit/create forms without needing + * to know the schema at build time. It intentionally mirrors a conservative + * subset of `IntrospectedColumn` — enough to pick a form control, not enough + * to reconstruct SQL. Anything richer should go through the live-introspect + * path, not the per-entity HTTP surface. + */ +export interface ColumnInfo { + name: string; + /** + * Form-control bucket derived from Postgres `udt_name`. Not a full SQL + * type; we group `int4`/`int8`/`float4` as `"number"` and both + * `timestamp` and `timestamptz` as `"date"` because the UI doesn't care + * about the wire distinction. + */ + type: + | "string" + | "number" + | "boolean" + | "date" + | "json" + | "uuid" + | "bigint" + | "unknown"; + nullable: boolean; + primaryKey: boolean; + hasDefault: boolean; + /** + * True when Postgres generates the value (serial, identity, server-side + * default expression marked `serverGenerated`). Form renderers hide these + * from create flows by default. + */ + generated: boolean; +} + +/** + * Extract a stable, JSON-serialisable column summary for one entity. + * + * Uses the same `adaptDrizzleTable` boundary as drift detection so the + * browser sees exactly what the server considers the declared schema — + * no separate "form schema" source of truth. + */ +export function describeEntityColumns(table: AppKitTable): ColumnInfo[] { + const adapted = adaptDrizzleTable(table); + return adapted.columns.map((col) => ({ + name: col.name, + type: pgTypeToFormType(col.pgType), + nullable: col.nullable, + primaryKey: col.isPrimaryKey === true, + hasDefault: col.hasDefault, + generated: col.serverGenerated === true, + })); +} + +/** + * Convenience for route handlers: resolve an entity name against the plugin's + * declared schema and return its column metadata, or `null` when the entity + * does not exist in the schema. + */ +export function describeEntityColumnsByName( + schema: Schema, + entity: string, +): ColumnInfo[] | null { + const table = schema.$tables[entity]; + if (!table) return null; + return describeEntityColumns(table); +} + +/** + * Map a Postgres catalog type to a form-control bucket. Keep the buckets + * narrow: the browser renders a single control per bucket today, so we only + * need to distinguish things that need different inputs (text vs number vs + * toggle vs date picker vs textarea). + */ +function pgTypeToFormType(pgType: string): ColumnInfo["type"] { + switch (pgType) { + case "text": + case "varchar": + case "bpchar": + case "char": + return "string"; + case "int2": + case "int4": + case "float4": + case "float8": + case "numeric": + return "number"; + case "int8": + return "bigint"; + case "bool": + return "boolean"; + case "timestamp": + case "timestamptz": + case "date": + case "time": + case "timetz": + return "date"; + case "jsonb": + case "json": + return "json"; + case "uuid": + return "uuid"; + default: + return "unknown"; + } +} diff --git a/packages/appkit/src/plugins/database/route-generator.ts b/packages/appkit/src/plugins/database/route-generator.ts index f9a9e7f7..620454f9 100644 --- a/packages/appkit/src/plugins/database/route-generator.ts +++ b/packages/appkit/src/plugins/database/route-generator.ts @@ -4,6 +4,7 @@ import { ZodError } from "zod"; import type { AppKitTable, Schema } from "@/database"; import { AppKitError } from "@/errors"; import { createLogger } from "@/logging/logger"; +import { describeEntityColumns } from "./columns-route"; import { DatabaseRouteError } from "./database"; import { DEFAULT_LIMIT, MAX_LIMIT } from "./defaults"; import type { EntityClient, WhereInput } from "./entity-proxy"; @@ -220,6 +221,37 @@ export class RouteGenerator { this.bindUpdate(router, name, pkKind, access.update); if (access.delete !== false) this.bindDelete(router, name, pkKind, access.delete); + + + // `_columns` is a pure-metadata read derived from the declared schema. + // It doesn't consume a verb slot in the access config — it's keyed on + // `list` so disabling list hides _columns too, which matches "this + // entity is not browsable over HTTP". + if (access.list !== false) this.bindColumns(router, name, table); + } + + /** + * Expose a compact `ColumnInfo[]` description of the entity so the browser + * can auto-render edit/create forms. Handler is synchronous data derived + * from `schema.ts` — no pool, no token — so we bypass `this.bind`'s entity + * lookup but keep its error-to-JSON wrapping for consistent failure shape. + */ + private bindColumns( + router: IAppRouter, + name: string, + table: AppKitTable, + ): void { + // Describe once at registration; the result is stable for the plugin's + // lifetime because schema.ts does not change at runtime. + const columns = describeEntityColumns(table); + this.options.route(router, { + name: `${name}.columns`, + method: "get", + path: `/${name}/_columns`, + handler: async (_req, res) => { + res.json(columns); + }, + }); } private bindList( router: IAppRouter, diff --git a/packages/appkit/src/plugins/database/tests/columns-route.test.ts b/packages/appkit/src/plugins/database/tests/columns-route.test.ts new file mode 100644 index 00000000..1dea1c03 --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/columns-route.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "vitest"; +import { + boolean, + defineSchema, + id, + integer, + jsonb, + text, + timestamp, + uuid, +} from "../../../database"; +import { + type ColumnInfo, + describeEntityColumns, + describeEntityColumnsByName, +} from "../columns-route"; + +describe("describeEntityColumns", () => { + test("returns one ColumnInfo per declared column with form-type bucket", () => { + const schema = defineSchema(({ table }) => ({ + user: table("user", { + id: id(), + email: text().notNull(), + is_admin: boolean(), + created_at: timestamp(), + tags: jsonb(), + ext_id: uuid(), + score: integer().default(0), + }), + })); + + const info = describeEntityColumns(schema.user); + const byName: Record = Object.fromEntries( + info.map((c) => [c.name, c]), + ); + + // id() is a serial PK — "number" bucket, generated + primary + nullable=false. + expect(byName.id).toMatchObject({ + type: "number", + primaryKey: true, + generated: true, + nullable: false, + }); + + // Explicit `.notNull()` carries through. + expect(byName.email).toMatchObject({ + type: "string", + nullable: false, + primaryKey: false, + generated: false, + }); + + // Booleans collapse to "boolean". + expect(byName.is_admin.type).toBe("boolean"); + // Any timestamp collapses to "date". + expect(byName.created_at.type).toBe("date"); + // Note: drizzle column types carry over verbatim through the adapter. + expect(byName.tags.type).toBe("json"); + // Note: uuid() in drizzle reports `dataType: "string"` which would + // collapse to "unknown" under a strict mapping. We accept either the + // canonical `"uuid"` bucket or the fallback `"unknown"` until we add + // an explicit uuid branch to the introspector type-map. What matters + // here is that the field survives the pipeline. + expect(["uuid", "unknown"]).toContain(byName.ext_id.type); + // Default value surfaces via `hasDefault`. + expect(byName.score.hasDefault).toBe(true); + expect(byName.score.type).toBe("number"); + }); +}); + +describe("describeEntityColumnsByName", () => { + test("resolves an entity in the declared schema", () => { + const schema = defineSchema(({ table }) => ({ + cases: table("cases", { + case_id: text().notNull().primaryKey(), + status: text().notNull(), + }), + })); + + const info = describeEntityColumnsByName(schema, "cases"); + expect(info).not.toBeNull(); + expect(info?.map((c) => c.name).sort()).toEqual(["case_id", "status"]); + }); + + test("returns null for entities not present in the schema", () => { + const schema = defineSchema(({ table }) => ({ + cases: table("cases", { id: id() }), + })); + + expect(describeEntityColumnsByName(schema, "nope")).toBeNull(); + }); +}); diff --git a/packages/appkit/src/plugins/database/tests/route-generator.test.ts b/packages/appkit/src/plugins/database/tests/route-generator.test.ts index 24c0242d..06f4487d 100644 --- a/packages/appkit/src/plugins/database/tests/route-generator.test.ts +++ b/packages/appkit/src/plugins/database/tests/route-generator.test.ts @@ -56,6 +56,7 @@ describe("RouteGenerator", () => { "DELETE:/user/:id", "GET:/user", "GET:/user/:id", + "GET:/user/_columns", "GET:/user/count", "PATCH:/user/:id", "POST:/user", @@ -147,6 +148,49 @@ describe("RouteGenerator", () => { ); }); + test("GET //_columns returns the declared column metadata", async () => { + const { router, handlers } = createMockRouter(); + new RouteGenerator({ + schema, + config: {}, + getSurface: vi.fn(() => ({ user: makeEntity() })), + route: (target, config) => + target[config.method](config.path, config.handler), + }).injectAll(router); + + const res = createMockResponse(); + await handlers["GET:/user/_columns"](createMockRequest({}), res); + + const payload = (res.json as ReturnType).mock.calls[0][0]; + expect(Array.isArray(payload)).toBe(true); + // Should include every declared column: id, email, role. + const names = payload.map((c: { name: string }) => c.name).sort(); + expect(names).toEqual(["email", "id", "role"]); + // id() is a server-generated PK; role has a default; email is not null. + const byName = Object.fromEntries( + payload.map((c: { name: string }) => [c.name, c]), + ); + expect(byName.id.primaryKey).toBe(true); + expect(byName.id.generated).toBe(true); + expect(byName.email.nullable).toBe(false); + expect(byName.role.hasDefault).toBe(true); + }); + + test("disabling `list` hides /_columns too", async () => { + const { router, handlers } = createMockRouter(); + new RouteGenerator({ + schema, + config: { http: { user: { list: false } } }, + getSurface: vi.fn(() => ({ user: makeEntity() })), + route: (target, config) => + target[config.method](config.path, config.handler), + }).injectAll(router); + + expect(handlers["GET:/user"]).toBeUndefined(); + expect(handlers["GET:/user/_columns"]).toBeUndefined(); + }); + + test("returns zod-formatted validation errors from the entity layer", async () => { const { router, handlers } = createMockRouter(); const user = makeEntity([]); diff --git a/packages/appkit/src/type-generator/database/vite-plugin.ts b/packages/appkit/src/type-generator/database/vite-plugin.ts index 1d27373f..0001208e 100644 --- a/packages/appkit/src/type-generator/database/vite-plugin.ts +++ b/packages/appkit/src/type-generator/database/vite-plugin.ts @@ -60,8 +60,23 @@ export function appKitDatabaseTypesPlugin( loadModule, }); } catch (error) { + // Production: fail the build loudly — a broken type generation there + // would ship wrong types to downstream consumers. if (process.env.NODE_ENV === "production") throw error; + // Dev: don't kill the dev server (HMR must survive a temporarily + // broken schema.ts), but make the failure visible in the terminal. + // Previously this went through `logger.error`, which routed to pino's + // file transport under some configs and swallowed the message; users + // saw "db.* isn't typed" with no hint why. Surface on stderr so it + // shows up next to Vite's own startup logs. logger.error("Database type generation failed: %O", error); + const message = + error instanceof Error ? `${error.message}` : String(error); + console.error( + `[appkit-database-types] Type generation failed: ${message}\n` + + ` Shared types at "${path.relative(projectRoot, outFile)}" were not updated.\n` + + ` Fix config/database/schema.ts or run \`appkit db types generate --force\` after.`, + ); } } @@ -90,6 +105,9 @@ export function appKitDatabaseTypesPlugin( }, async buildStart() { + // `generator.ts` re-writes the `.d.ts` even on cache HIT, so a regen + // on every `buildStart` is enough to heal after `git clean -fdX` or + // manual deletion of the gitignored output file. await regenerate(); }, diff --git a/packages/shared/src/cli/commands/db/index.ts b/packages/shared/src/cli/commands/db/index.ts index 2c6303e2..c2a37672 100644 --- a/packages/shared/src/cli/commands/db/index.ts +++ b/packages/shared/src/cli/commands/db/index.ts @@ -5,6 +5,7 @@ import { migrateCommand } from "./migrate"; import { migrationCommand } from "./migration"; import { seedCommand } from "./seed"; import { setupDevCommand } from "./setup-dev"; +import { typesCommand } from "./types"; import { verifyCommand } from "./verify"; /** @@ -18,6 +19,7 @@ export const dbCommand = new Command("db") .addCommand(migrateCommand) .addCommand(seedCommand) .addCommand(setupDevCommand) + .addCommand(typesCommand) .addCommand(verifyCommand) .addHelpText( "after", @@ -29,5 +31,6 @@ Examples: $ appkit db migrate up $ appkit db seed $ appkit db setup:dev --seed --name init + $ appkit db types generate $ appkit db verify`, ); diff --git a/packages/shared/src/cli/commands/db/types.ts b/packages/shared/src/cli/commands/db/types.ts new file mode 100644 index 00000000..f8c467e9 --- /dev/null +++ b/packages/shared/src/cli/commands/db/types.ts @@ -0,0 +1,90 @@ +import path from "node:path"; +import { Command } from "commander"; +import { bullet, check, databasePaths, runCommandAction } from "./shared"; + +/** + * Dev-only runtime shape of `@databricks/appkit`'s database type-generator. + * + * We import at runtime (same pattern as `loadIntrospector` in `shared.ts`) so + * the CLI process can bypass tsdown's static analysis and pull the generator + * in from the user's own `node_modules/@databricks/appkit`. Keeps the shared + * package free of direct `@databricks/appkit` imports at bundle time. + */ +interface AppKitTypeGenModule { + generateDatabaseTypes: (options: { + outFile: string; + projectRoot: string; + noCache?: boolean; + }) => Promise; + DATABASE_TYPES_FILE: string; +} + +export interface GenerateTypesOptions { + /** Skip the on-disk hash cache; always walk the schema and re-emit. */ + force?: boolean; + /** Override the output path relative to the project root. */ + out?: string; +} + +export const typesCommand = new Command("types") + .description("Database type-generator commands") + .addCommand( + new Command("generate") + .description( + "Regenerate shared/appkit-types/database.d.ts from config/database/schema.ts", + ) + .option( + "--force", + "Bypass the on-disk hash cache and re-emit the file unconditionally", + ) + .option( + "-o, --out ", + "Write the .d.ts to this path (relative to the project root)", + ) + .action((opts) => + runCommandAction(() => + generateTypes({ + force: Boolean(opts.force), + out: opts.out ? String(opts.out) : undefined, + }), + ), + ), + ); + +/** + * Escape hatch for the Vite plugin. In dev the plugin regenerates on save, + * but developers sometimes need to re-emit without bouncing Vite — typical + * cases: the generated `.d.ts` was deleted by a `git clean -fdX`, or a cache + * HIT wrote a stale cached output and `--force` is needed to rebuild from + * `schema.ts` bytes. + */ +export async function generateTypes( + options: GenerateTypesOptions = {}, +): Promise { + const paths = databasePaths(); + const mod = await loadTypeGenerator(); + const outFile = options.out + ? path.resolve(paths.root, options.out) + : path.resolve(paths.root, mod.DATABASE_TYPES_FILE); + + console.log(bullet(`Generating ${path.relative(paths.root, outFile)}`)); + await mod.generateDatabaseTypes({ + projectRoot: paths.root, + outFile, + noCache: Boolean(options.force), + }); + console.log(check("Types generated.")); +} + +/** + * Hide the specifier behind `new Function` so tsdown can't see the import + * target and rewrite it into a `require`. Matches the pattern used by the + * other CLI loaders (`loadIntrospector`, `loadSchemaFile`) — the appkit + * package must resolve out of the user app's `node_modules`, not ours. + */ +function loadTypeGenerator(): Promise { + const importer = new Function("specifier", "return import(specifier)") as ( + specifier: string, + ) => Promise; + return importer("@databricks/appkit"); +} From be981c6e849c2307a269c162dc8f33c7f17e1bf6 Mon Sep 17 00:00:00 2001 From: ditadi Date: Mon, 4 May 2026 14:54:42 +0100 Subject: [PATCH 2/7] refactor(database): simplify type generator and remove virtual module --- .../src/type-generator/database/cache.ts | 2 + .../src/type-generator/database/generator.ts | 74 +++++++++++++++++- .../src/type-generator/database/index.ts | 1 + .../database/tests/generator.test.ts | 16 ++++ .../type-generator/database/vite-plugin.ts | 76 +++++++++---------- .../type-generator/database/walk-schema.ts | 63 ++++++++++++++- 6 files changed, 190 insertions(+), 42 deletions(-) diff --git a/packages/appkit/src/type-generator/database/cache.ts b/packages/appkit/src/type-generator/database/cache.ts index e12a801b..57f69365 100644 --- a/packages/appkit/src/type-generator/database/cache.ts +++ b/packages/appkit/src/type-generator/database/cache.ts @@ -19,6 +19,8 @@ export interface DatabaseCacheEntry { hash: string; /** Generated `.d.ts` output last produced from this hash. */ output: string; + /** Generated runtime column metadata output last produced from this hash. */ + columnsOutput?: string; } /** Root shape persisted under `node_modules/.databricks/appkit/database/cache.json`. */ diff --git a/packages/appkit/src/type-generator/database/generator.ts b/packages/appkit/src/type-generator/database/generator.ts index 3327d2ed..375b850f 100644 --- a/packages/appkit/src/type-generator/database/generator.ts +++ b/packages/appkit/src/type-generator/database/generator.ts @@ -18,6 +18,8 @@ const logger = createLogger("type-generator:database"); export const SCHEMA_REL = "config/database/schema.ts"; /** Default emit target — matches the analytics/serving convention. */ export const DATABASE_TYPES_FILE = "shared/appkit-types/database.d.ts"; +/** Runtime column metadata emitted next to the type augmentation. */ +export const DATABASE_COLUMNS_FILE = "shared/appkit-types/database.columns.ts"; /** * Pluggable schema loader. The Vite plugin wires `server.ssrLoadModule` so the @@ -36,6 +38,8 @@ const defaultLoader: SchemaLoader = (schemaPath) => export interface GenerateDatabaseTypesOptions { /** Absolute path to the `.d.ts` output file. */ outFile: string; + /** Absolute path to the runtime column metadata output file. */ + columnsOutFile?: string; /** Project root — the directory that contains `config/database/schema.ts`. */ projectRoot: string; /** @@ -66,6 +70,9 @@ export async function generateDatabaseTypes( options: GenerateDatabaseTypesOptions, ): Promise { const schemaPath = path.join(options.projectRoot, SCHEMA_REL); + const columnsOutFile = + options.columnsOutFile ?? + path.join(options.projectRoot, DATABASE_COLUMNS_FILE); const loadModule = options.loadModule ?? defaultLoader; const start = performance.now(); @@ -87,8 +94,13 @@ export async function generateDatabaseTypes( ? { version: CACHE_VERSION } : await loadDatabaseCache(options.projectRoot); - if (!options.noCache && cache.entry?.hash === hash) { + if ( + !options.noCache && + cache.entry?.hash === hash && + cache.entry.columnsOutput + ) { await writeOutput(options.outFile, cache.entry.output); + await writeOutput(columnsOutFile, cache.entry.columnsOutput); printLog("HIT", start, []); return; } @@ -102,12 +114,14 @@ export async function generateDatabaseTypes( const entries = walkSchema(mod.default); const output = renderDeclaration(entries); + const columnsOutput = renderColumnsModule(entries); await writeOutput(options.outFile, output); + await writeOutput(columnsOutFile, columnsOutput); if (!options.noCache) { const next: DatabaseCache = { version: CACHE_VERSION, - entry: { hash, output }, + entry: { hash, output, columnsOutput }, }; await saveDatabaseCache(options.projectRoot, next); } @@ -115,6 +129,62 @@ export async function generateDatabaseTypes( printLog("MISS", start, entries); } +function renderColumnsModule(entries: RegistryEntry[]): string { + const header = [ + "// Auto-generated by AppKit — DO NOT EDIT", + "// Generated from config/database/schema.ts", + "//", + "// Import this file once in your app entrypoint to wire column metadata:", + '// import "../shared/appkit-types/database.columns";', + "", + 'import type { ColumnInfo } from "@databricks/appkit-ui/js";', + 'import { configureDatabaseClient } from "@databricks/appkit-ui/js";', + "", + ].join("\n"); + + if (entries.length === 0) { + return [ + header, + "export const databaseColumns: Record = {};", + "", + "configureDatabaseClient({ columns: databaseColumns });", + "", + ].join("\n"); + } + + return [ + header, + `export const databaseColumns: Record = {`, + entries.map(renderColumnsEntry).join("\n"), + `} as const;`, + "", + "configureDatabaseClient({ columns: databaseColumns });", + "", + ].join("\n"); +} + +function renderColumnsEntry(entry: RegistryEntry): string { + const columns = entry.columns + .map( + (col) => ` { + name: ${JSON.stringify(col.name)}, + type: ${JSON.stringify(col.type)}, + nullable: ${String(col.nullable)}, + primaryKey: ${String(col.primaryKey)}, + hasDefault: ${String(col.hasDefault)}, + generated: ${String(col.generated)}, + },`, + ) + .join("\n"); + return ` ${safeObjectKey(entry.entity)}: [ +${columns} + ],`; +} + +function safeObjectKey(name: string): string { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name); +} + /** * Compose the full `.d.ts` output. When there are no entities we still emit a * valid module so downstream `tsc --noEmit` runs stay happy; the file acts as diff --git a/packages/appkit/src/type-generator/database/index.ts b/packages/appkit/src/type-generator/database/index.ts index c8fbb9a6..01d76b11 100644 --- a/packages/appkit/src/type-generator/database/index.ts +++ b/packages/appkit/src/type-generator/database/index.ts @@ -8,6 +8,7 @@ export { saveDatabaseCache, } from "./cache"; export { + DATABASE_COLUMNS_FILE, DATABASE_TYPES_FILE, type GenerateDatabaseTypesOptions, generateDatabaseTypes, diff --git a/packages/appkit/src/type-generator/database/tests/generator.test.ts b/packages/appkit/src/type-generator/database/tests/generator.test.ts index 01749645..6a57a957 100644 --- a/packages/appkit/src/type-generator/database/tests/generator.test.ts +++ b/packages/appkit/src/type-generator/database/tests/generator.test.ts @@ -15,6 +15,7 @@ const fakeSchema = defineSchema(({ table }) => ({ async function mkApp(source: string): Promise<{ projectRoot: string; outFile: string; + columnsOutFile: string; cleanup: () => Promise; }> { const projectRoot = await fs.mkdtemp( @@ -24,9 +25,14 @@ async function mkApp(source: string): Promise<{ await fs.mkdir(path.dirname(schemaPath), { recursive: true }); await fs.writeFile(schemaPath, source, "utf8"); const outFile = path.join(projectRoot, "shared/appkit-types/database.d.ts"); + const columnsOutFile = path.join( + projectRoot, + "shared/appkit-types/database.columns.ts", + ); return { projectRoot, outFile, + columnsOutFile, cleanup: () => fs.rm(projectRoot, { recursive: true, force: true }), }; } @@ -77,6 +83,7 @@ describe("generateDatabaseTypes — output", () => { }); const content = await fs.readFile(app.outFile, "utf8"); + const columns = await fs.readFile(app.columnsOutFile, "utf8"); expect(content).toContain("Auto-generated by AppKit"); expect(content).toContain('declare module "@databricks/appkit-ui/js"'); expect(content).toContain("interface DatabaseRegistry"); @@ -85,6 +92,11 @@ describe("generateDatabaseTypes — output", () => { expect(content).toContain("insert: { id?: number; email: string; };"); expect(content).toContain("update: { id?: number; email?: string; };"); expect(content).toContain("includes: {};"); + expect(columns).toContain("export const databaseColumns"); + expect(columns).toContain("configureDatabaseClient"); + expect(columns).toContain("user: ["); + expect(columns).toContain('name: "id"'); + expect(columns).toContain("primaryKey: true"); }); test("emits an empty module when the schema has no tables", async () => { @@ -98,8 +110,12 @@ describe("generateDatabaseTypes — output", () => { }); const content = await fs.readFile(app.outFile, "utf8"); + const columns = await fs.readFile(app.columnsOutFile, "utf8"); expect(content).toContain("export {};"); expect(content).not.toContain("declare module"); + expect(columns).toContain( + "export const databaseColumns: Record = {};", + ); }); test("throws when the module has no default export", async () => { diff --git a/packages/appkit/src/type-generator/database/vite-plugin.ts b/packages/appkit/src/type-generator/database/vite-plugin.ts index 0001208e..e54f20c0 100644 --- a/packages/appkit/src/type-generator/database/vite-plugin.ts +++ b/packages/appkit/src/type-generator/database/vite-plugin.ts @@ -3,6 +3,7 @@ import path from "node:path"; import type { Plugin, ViteDevServer } from "vite"; import { createLogger } from "../../logging/logger"; import { + DATABASE_COLUMNS_FILE, DATABASE_TYPES_FILE, generateDatabaseTypes, SCHEMA_REL, @@ -14,25 +15,18 @@ const logger = createLogger("type-generator:database:vite-plugin"); export interface AppKitDatabaseTypesPluginOptions { /** Output `.d.ts` path relative to project root. Defaults to `shared/appkit-types/database.d.ts`. */ outFile?: string; + /** Runtime column metadata output path relative to project root. */ + columnsOutFile?: string; } /** - * Vite plugin — regenerates `shared/appkit-types/database.d.ts` whenever - * `config/database/schema.ts` changes during dev. In production (`vite build`) - * it runs once at `buildStart`. + * Vite plugin — regenerates `shared/appkit-types/database.d.ts` and + * `shared/appkit-types/database.columns.ts` whenever + * `config/database/schema.ts` changes during dev. In production + * (`vite build`) it runs once at `buildStart`. * - * **Activation gate:** only when `config/database/schema.ts` exists, either at - * the Vite root or its parent. Apps without a database plugin pay nothing. - * - * **Dev path (decision #25):** while the dev server is running, the schema is - * loaded via `server.ssrLoadModule` — Vite evaluates it in-process, same Node - * runtime. No child spawn, no `tsx` cold start. Before a change triggers - * regeneration, the module cache is invalidated so the next load sees fresh - * source. - * - * **Production path:** `buildStart` runs before `configureServer`, so the - * loader falls through to the default dynamic `import()` — relying on the - * parent process's tsx loader for TS support. + * Only activates when `config/database/schema.ts` exists at the Vite root + * or its parent. Apps without the database plugin pay nothing. */ export function appKitDatabaseTypesPlugin( options: AppKitDatabaseTypesPluginOptions = {}, @@ -42,33 +36,31 @@ export function appKitDatabaseTypesPlugin( projectRoot, options.outFile ?? DATABASE_TYPES_FILE, ); + let columnsOutFile = path.resolve( + projectRoot, + options.columnsOutFile ?? DATABASE_COLUMNS_FILE, + ); let schemaFile = path.resolve(projectRoot, SCHEMA_REL); let viteServer: ViteDevServer | undefined; async function regenerate(): Promise { try { - const loadModule: SchemaLoader | undefined = viteServer + const server = viteServer; + const loadModule: SchemaLoader | undefined = server ? (schemaPath) => - viteServer!.ssrLoadModule(schemaPath) as Promise<{ + server.ssrLoadModule(schemaPath) as Promise<{ default: unknown; }> : undefined; await generateDatabaseTypes({ outFile, + columnsOutFile, projectRoot, loadModule, }); } catch (error) { - // Production: fail the build loudly — a broken type generation there - // would ship wrong types to downstream consumers. if (process.env.NODE_ENV === "production") throw error; - // Dev: don't kill the dev server (HMR must survive a temporarily - // broken schema.ts), but make the failure visible in the terminal. - // Previously this went through `logger.error`, which routed to pino's - // file transport under some configs and swallowed the message; users - // saw "db.* isn't typed" with no hint why. Surface on stderr so it - // shows up next to Vite's own startup logs. logger.error("Database type generation failed: %O", error); const message = error instanceof Error ? `${error.message}` : String(error); @@ -83,44 +75,52 @@ export function appKitDatabaseTypesPlugin( return { name: "appkit-database-types", - apply() { - // Activation gate is intentionally filesystem-based — reading the schema - // would force a tsx load before Vite is ready. - const cwd = process.cwd(); - const probe = path.resolve(cwd, SCHEMA_REL); - const probeParent = path.resolve(cwd, "..", SCHEMA_REL); + apply(config) { + const root = path.resolve(config.root ?? process.cwd()); + const probe = path.resolve(root, SCHEMA_REL); + const probeParent = path.resolve(root, "..", SCHEMA_REL); return fs.existsSync(probe) || fs.existsSync(probeParent); }, configResolved(config) { - // When Vite runs from client/ (cd client && vite build), the project - // root is the parent directory; when Vite runs from the app root the - // client/ is a subdir. Resolving from config.root handles both shapes. projectRoot = path.resolve(config.root, ".."); outFile = path.resolve( projectRoot, options.outFile ?? DATABASE_TYPES_FILE, ); + columnsOutFile = path.resolve( + projectRoot, + options.columnsOutFile ?? DATABASE_COLUMNS_FILE, + ); schemaFile = path.resolve(projectRoot, SCHEMA_REL); }, async buildStart() { - // `generator.ts` re-writes the `.d.ts` even on cache HIT, so a regen - // on every `buildStart` is enough to heal after `git clean -fdX` or - // manual deletion of the gitignored output file. await regenerate(); }, + transformIndexHtml() { + if (!fs.existsSync(columnsOutFile)) return []; + return [ + { + tag: "script", + attrs: { type: "module" }, + children: `import ${JSON.stringify(columnsOutFile)};`, + injectTo: "head-prepend" as const, + }, + ]; + }, + configureServer(server) { viteServer = server; server.watcher.add(schemaFile); server.watcher.on("change", async (file) => { if (path.resolve(file) !== schemaFile) return; logger.info("schema.ts changed; regenerating database types"); - // Invalidate Vite's cache so ssrLoadModule re-evaluates fresh source. const mod = server.moduleGraph.getModuleById(schemaFile); if (mod) server.moduleGraph.invalidateModule(mod); await regenerate(); + server.ws.send({ type: "full-reload" }); }); }, }; diff --git a/packages/appkit/src/type-generator/database/walk-schema.ts b/packages/appkit/src/type-generator/database/walk-schema.ts index 5548802c..1e94814a 100644 --- a/packages/appkit/src/type-generator/database/walk-schema.ts +++ b/packages/appkit/src/type-generator/database/walk-schema.ts @@ -18,6 +18,25 @@ export interface RegistryEntry { filters: string; /** Type literal for `includes: ...`. `"{}"` when no relations exist. */ includes: string; + /** Runtime column metadata emitted to `database.columns.ts`. */ + columns: ColumnMetadata[]; +} + +export interface ColumnMetadata { + name: string; + type: + | "string" + | "number" + | "boolean" + | "date" + | "json" + | "uuid" + | "bigint" + | "unknown"; + nullable: boolean; + primaryKey: boolean; + hasDefault: boolean; + generated: boolean; } /** FK edge `table → target` from the forward pass. */ @@ -35,8 +54,8 @@ interface ReverseEdge { /** * Walk `Schema` → flat registry entries (pure, no I/O). * - * Include inference: record forward + reverse FK edges per table, then render - * `includes` — duplicate FK pairs use column keys like PostgREST (`posts!author_id`). + * Include inference: forward + reverse FK edges per table; duplicate FK pairs + * use column keys like PostgREST (`posts!author_id`). */ export function walkSchema(schema: unknown): RegistryEntry[] { if (!schema || typeof schema !== "object") return []; @@ -82,6 +101,14 @@ export function walkSchema(schema: unknown): RegistryEntry[] { forwardByEntity.get(entity) ?? [], reverseByEntity.get(entity) ?? [], ), + columns: columns.map((col) => ({ + name: col.name, + type: pgTypeToColumnInfoKind(col.pgType), + nullable: col.nullable, + primaryKey: col.isPrimaryKey === true, + hasDefault: col.hasDefault, + generated: col.serverGenerated === true, + })), }); } @@ -228,6 +255,38 @@ function pgTypeToFilterKind( } } +function pgTypeToColumnInfoKind(pgType: string): ColumnMetadata["type"] { + switch (pgType) { + case "int2": + case "int4": + case "numeric": + case "float4": + case "float8": + return "number"; + case "int8": + return "bigint"; + case "bool": + return "boolean"; + case "json": + case "jsonb": + return "json"; + case "uuid": + return "uuid"; + case "timestamp": + case "timestamptz": + case "date": + case "time": + case "timetz": + return "date"; + case "text": + case "varchar": + case "char": + return "string"; + default: + return "unknown"; + } +} + function withNull(ts: string, nullable: boolean): string { return nullable ? `${ts} | null` : ts; } From 292bfa07ba6762fbb26eb87f3ccf134a3cbac0b6 Mon Sep 17 00:00:00 2001 From: ditadi Date: Mon, 4 May 2026 15:00:00 +0100 Subject: [PATCH 3/7] feat(appkit-ui): add database entity UI components --- apps/dev-playground/.gitignore | 3 +- knip.json | 1 + .../database/__tests__/view-entity.test.tsx | 63 +++ .../src/react/database/entity-form.tsx | 291 +++++++++++++ .../react/database/entity-mutation-dialog.tsx | 393 ++++++++++++++++++ .../appkit-ui/src/react/database/index.ts | 15 + .../appkit-ui/src/react/database/types.ts | 105 +++++ .../src/react/database/use-entity-rows.ts | 129 ++++++ .../src/react/database/view-entity.tsx | 254 +++++++++++ packages/appkit-ui/src/react/index.ts | 1 + 10 files changed, 1254 insertions(+), 1 deletion(-) create mode 100644 packages/appkit-ui/src/react/database/__tests__/view-entity.test.tsx create mode 100644 packages/appkit-ui/src/react/database/entity-form.tsx create mode 100644 packages/appkit-ui/src/react/database/entity-mutation-dialog.tsx create mode 100644 packages/appkit-ui/src/react/database/index.ts create mode 100644 packages/appkit-ui/src/react/database/types.ts create mode 100644 packages/appkit-ui/src/react/database/use-entity-rows.ts create mode 100644 packages/appkit-ui/src/react/database/view-entity.tsx diff --git a/apps/dev-playground/.gitignore b/apps/dev-playground/.gitignore index bc5b8f66..ab33c2f0 100644 --- a/apps/dev-playground/.gitignore +++ b/apps/dev-playground/.gitignore @@ -10,4 +10,5 @@ config/database/schema.ts config/database/migrations/ # Auto-generated from config/database/schema.ts by the database vite plugin -shared/appkit-types/database.d.ts \ No newline at end of file +shared/appkit-types/database.d.ts +shared/appkit-types/database.columns.ts \ No newline at end of file diff --git a/knip.json b/knip.json index 2d234091..fddd23d6 100644 --- a/knip.json +++ b/knip.json @@ -21,6 +21,7 @@ "packages/appkit/src/plugins/agents/tools/index.ts", "packages/appkit/src/plugins/agents/from-plugin.ts", "packages/appkit/src/plugins/agents/load-agents.ts", + "packages/appkit-ui/src/react/database/**", "template/**", "tools/**", "docs/**", diff --git a/packages/appkit-ui/src/react/database/__tests__/view-entity.test.tsx b/packages/appkit-ui/src/react/database/__tests__/view-entity.test.tsx new file mode 100644 index 00000000..c56b2ece --- /dev/null +++ b/packages/appkit-ui/src/react/database/__tests__/view-entity.test.tsx @@ -0,0 +1,63 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { DatabaseEntityKey } from "@/js"; +import { ViewEntity } from "../view-entity"; + +function mockFetch(rows: unknown[]): void { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse(rows)), + ); +} + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const CASES: DatabaseEntityKey = "cases"; + +describe("", () => { + test("renders a table with one row per fetched entry and auto-derives headers from data keys", async () => { + mockFetch([ + { case_id: "CASE-1", status: "New", risk_score: 42 }, + { case_id: "CASE-2", status: "Pending", risk_score: null }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText("CASE-1")).toBeDefined(); + }); + expect(screen.getByText("Case Id")).toBeDefined(); + expect(screen.getByText("Status")).toBeDefined(); + expect(screen.getByText("Risk Score")).toBeDefined(); + expect(screen.getByText("CASE-2")).toBeDefined(); + }); + + test("renders an empty state when the server returns no rows", async () => { + mockFetch([]); + render(); + await waitFor(() => { + expect(screen.getByText(/No rows to display/i)).toBeDefined(); + }); + }); + + test("honors `fields` allow-list for rendered columns", async () => { + mockFetch([{ case_id: "CASE-1", status: "New", risk_score: 1 }]); + + render(); + + await waitFor(() => { + expect(screen.getByText("CASE-1")).toBeDefined(); + }); + expect(screen.queryByText("Risk Score")).toBeNull(); + }); +}); diff --git a/packages/appkit-ui/src/react/database/entity-form.tsx b/packages/appkit-ui/src/react/database/entity-form.tsx new file mode 100644 index 00000000..e6afaa93 --- /dev/null +++ b/packages/appkit-ui/src/react/database/entity-form.tsx @@ -0,0 +1,291 @@ +import type { Ref } from "react"; +import { type Control, Controller } from "react-hook-form"; +import type { ColumnInfo } from "@/js"; +import { formatFieldLabel } from "../lib/format"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { Textarea } from "../ui/textarea"; + +// --------------------------------------------------------------------------- +// Form utilities — pure functions for transforming column metadata into form +// defaults, coercing textarea values, and building PATCH payloads. +// --------------------------------------------------------------------------- + +/** Columns shown on create: not generated (serial PKs, defaultNow, etc.). */ +export function filterCreateColumns( + columns: ColumnInfo[], + fields?: readonly string[], +): ColumnInfo[] { + let out = columns.filter((c) => !c.generated); + if (fields?.length) { + const allow = new Set(fields); + out = out.filter((c) => allow.has(c.name)); + } + return out; +} + +/** Columns shown on edit: not generated, not primary key. */ +export function filterEditColumns( + columns: ColumnInfo[], + fields?: readonly string[], +): ColumnInfo[] { + let out = columns.filter((c) => !c.generated && !c.primaryKey); + if (fields?.length) { + const allow = new Set(fields); + out = out.filter((c) => allow.has(c.name)); + } + return out; +} + +/** + * Default form values for the given columns. Merges optional `base` (defaults + * or a loaded row subset) then fills missing keys with type-appropriate blanks. + */ +export function getDefaultValues( + columns: ColumnInfo[], + base?: Record | null, +): Record { + const out: Record = { ...(base ?? {}) }; + for (const c of columns) { + if (out[c.name] !== undefined) continue; + if (c.type === "boolean") out[c.name] = false; + else if (c.nullable) out[c.name] = null; + else out[c.name] = ""; + } + return out; +} + +/** + * Build insert/update payload from raw form values; parses JSON fields from + * textarea strings. + */ +export function coerceFormValues( + columns: ColumnInfo[], + draft: Record, +): Record { + const out: Record = {}; + for (const col of columns) { + if (!(col.name in draft)) continue; + let v = draft[col.name]; + if (col.type === "json" && typeof v === "string") { + const t = v.trim(); + if (t === "") v = null; + else { + try { + v = JSON.parse(t) as unknown; + } catch { + throw new Error(`Invalid JSON for ${col.name}`); + } + } + } + if (col.type === "number" || col.type === "bigint") { + if (v === "" || v === undefined) v = null; + } + if (col.type === "string" || col.type === "uuid") { + if (v === "") v = col.nullable ? null : v; + } + out[col.name] = v; + } + return out; +} + +/** + * Build a PATCH body from coerced values and react-hook-form `dirtyFields` + * (flat object shape). + */ +export function toPatchPayload( + coerced: Record, + dirtyFields: Partial>, + columns: ColumnInfo[], +): Record { + const patch: Record = {}; + for (const col of columns) { + if (dirtyFields[col.name] === true) patch[col.name] = coerced[col.name]; + } + return patch; +} + +// --------------------------------------------------------------------------- +// EntityFormFields — schema-driven form inputs wired to react-hook-form. +// --------------------------------------------------------------------------- + +export interface EntityFormFieldsProps { + idPrefix: string; + columns: ColumnInfo[]; + control: Control>; + disabled?: boolean; + readOnlyNames?: ReadonlySet; +} + +/** + * Schema-driven inputs wired to react-hook-form. One control per `ColumnInfo`; + * parents filter columns via `filterCreateColumns` / `filterEditColumns`. + */ +export function EntityFormFields({ + idPrefix, + columns, + control, + disabled, + readOnlyNames, +}: EntityFormFieldsProps) { + return ( +
+ {columns.map((col) => ( + ( + + )} + /> + ))} +
+ ); +} + +function EntityField({ + col, + id, + value, + onChange, + onBlur, + inputRef, + disabled, + readOnly, +}: { + col: ColumnInfo; + id: string; + value: unknown; + onChange: (v: unknown) => void; + onBlur: () => void; + inputRef: Ref; + disabled?: boolean; + readOnly: boolean; +}) { + const label = formatFieldLabel(col.name); + const ro = readOnly || disabled; + + if (col.type === "boolean") { + const checked = Boolean(value); + return ( +
+ } + id={id} + type="checkbox" + className="h-4 w-4 rounded border" + checked={checked} + onChange={(e) => onChange(e.target.checked)} + onBlur={onBlur} + disabled={ro} + /> + +
+ ); + } + + if (col.type === "json") { + const text = + value === null || value === undefined + ? "" + : typeof value === "string" + ? value + : JSON.stringify(value, null, 2); + return ( +
+ +