From faf172879f82376dd0f5b52625e89ceb1f6992ac Mon Sep 17 00:00:00 2001 From: ditadi Date: Fri, 23 Oct 2026 14:03:07 +0100 Subject: [PATCH] feat(appkit): generate the database operation registry and browser contracts Scan server/database/routes/*.ts and write a lazy routes.gen.ts registry and the exact JSON contract of each operation. The Vite plugin and appkit generate-types run it, strictly in production. An app without that directory generates nothing new. CI checks the built declarations and a server bundle that loads its operations without sources. Signed-off-by: ditadi --- .github/workflows/ci.yml | 4 + .../database/generate-operations.ts | 223 +++++++ .../src/type-generator/database/generate.ts | 54 +- .../src/type-generator/database/index.ts | 4 + .../database/operation-contracts.ts | 570 ++++++++++++++++++ .../tests/generate-operations.test.ts | 429 +++++++++++++ .../tests/operation-contracts.types.test.ts | 356 +++++++++++ packages/appkit/src/type-generator/index.ts | 2 + .../appkit/src/type-generator/vite-plugin.ts | 74 ++- .../src/cli/commands/generate-types.test.ts | 23 + .../shared/src/cli/commands/generate-types.ts | 15 + .../src/cli/commands/type-generator.d.ts | 7 + tools/verify-database-bundle.ts | 149 +++++ 13 files changed, 1875 insertions(+), 35 deletions(-) create mode 100644 packages/appkit/src/type-generator/database/generate-operations.ts create mode 100644 packages/appkit/src/type-generator/database/operation-contracts.ts create mode 100644 packages/appkit/src/type-generator/database/tests/generate-operations.test.ts create mode 100644 packages/appkit/src/type-generator/database/tests/operation-contracts.types.test.ts create mode 100644 tools/verify-database-bundle.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4370677d..bb13fa943 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,6 +158,10 @@ jobs: # The generated database registry must reach appkit-ui's built declarations. - name: Typecheck database components run: pnpm --filter=dev-playground typecheck:database + - name: Typecheck built database operation declarations + run: pnpm exec vitest run --project appkit packages/appkit/src/plugins/database/tests/operations.types.test.ts + - name: Run a built database server without sources + run: pnpm exec tsx tools/verify-database-bundle.ts - name: Run Integration Tests run: pnpm --filter=dev-playground test:integration env: diff --git a/packages/appkit/src/type-generator/database/generate-operations.ts b/packages/appkit/src/type-generator/database/generate-operations.ts new file mode 100644 index 000000000..9e065b3db --- /dev/null +++ b/packages/appkit/src/type-generator/database/generate-operations.ts @@ -0,0 +1,223 @@ +import fsSync from "node:fs"; +import path from "node:path"; + +import { createLogger } from "../../logging/logger"; +import { validateOperationMetadata } from "../../plugins/database/operation-metadata"; +import type { LocatedDeclaration } from "../../plugins/database/operations"; +import { + DatabaseTypegenError, + importFailureReason, + loadDatabaseSchema, + writeIfChanged, +} from "./generate"; +import { + type OperationContract, + operationContract, + renderOperationTypes, +} from "./operation-contracts"; + +const logger = createLogger("type-generator:database"); + +/** Browser-safe operation contracts, next to `database.d.ts`. */ +export const DATABASE_OPERATIONS_TYPES_FILE = "database-operations.d.ts"; + +/** Application-relative POSIX paths of the operation conventions. */ +const ROUTES_DIR = "server/database/routes"; +const ROUTES_FILE = "server/database/routes.gen.ts"; +const SCHEMA_FILE = "config/database/schema.ts"; + +/** Fixed header shared by both generated files. */ +const HEADER = "// Auto-generated by AppKit - DO NOT EDIT"; + +/** The operation contracts when nothing can be published. */ +export const NEUTRAL_DATABASE_OPERATION_TYPES = renderOperationTypes([]); + +interface GenerateDatabaseOperationsOptions { + /** `config/database/schema.ts` and `server/database/routes/` resolve from it. */ + readonly appRoot: string; + /** Where the browser-safe operation contracts are written. */ + readonly outFile: string; + /** + * Fail on a declaration without authorization instead of skipping it. + * Production builds and CI verification pass `true`. + */ + readonly strict?: boolean; +} + +function fromRoot(appRoot: string, posixPath: string): string { + return path.join(appRoot, ...posixPath.split("/")); +} + +function compare(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +function isRouteModule(name: string): boolean { + return name.endsWith(".ts") && !/\.(?:test|d|gen)\.ts$/.test(name); +} + +/** Direct `*.ts` children of the routes directory, as sorted POSIX paths. */ +function discoverRouteModules(appRoot: string): readonly string[] { + const directory = fromRoot(appRoot, ROUTES_DIR); + return fsSync + .readdirSync(directory, { withFileTypes: true }) + .filter( + (entry) => + isRouteModule(entry.name) && + (entry.isFile() || + (entry.isSymbolicLink() && + fsSync.statSync(path.join(directory, entry.name)).isFile())), + ) + .map((entry) => `${ROUTES_DIR}/${entry.name}`) + .sort(compare); +} + +/** A literal specifier from `routes.gen.ts`, extensionless for tsx and tsdown alike. */ +function specifier(file: string): string { + const relative = path.posix + .relative(path.posix.dirname(ROUTES_FILE), file) + .replace(/\.ts$/, ""); + return relative.startsWith(".") ? relative : `./${relative}`; +} + +/** + * The committed route entry: one type import and lazy literal imports, so a + * bundler includes every module and importing the entry evaluates none. + */ +function renderDatabaseRoutes(files: readonly string[]): string { + const modules = files.map( + (file) => ` { + file: ${JSON.stringify(file)}, + load: () => import(${JSON.stringify(specifier(file))}), + },`, + ); + return `${HEADER} +import type { DatabaseRoutes } from "@databricks/appkit/beta"; + +export const databaseRoutes = { + schema: () => import(${JSON.stringify(specifier(SCHEMA_FILE))}), + modules: [${modules.length > 0 ? `\n${modules.join("\n")}\n ` : ""}], +} satisfies DatabaseRoutes; +`; +} + +/** Every export of every module. A module that cannot load is named, never quoted. */ +async function loadDeclarations( + appRoot: string, + files: readonly string[], +): Promise { + const { createJiti } = await import("jiti"); + // No module cache, so a rerun observes edits; resolve the app's own dependencies. + const jiti = createJiti(path.join(appRoot, "package.json"), { + moduleCache: false, + interopDefault: false, + }); + const located: LocatedDeclaration[] = []; + const failures: string[] = []; + for (const file of files) { + try { + const namespace: unknown = await jiti.import(fromRoot(appRoot, file)); + if (typeof namespace !== "object" || namespace === null) { + throw new TypeError("not a module namespace"); + } + for (const exportName of Object.keys(namespace).sort(compare)) { + located.push({ + file, + exportName, + value: Reflect.get(namespace, exportName), + }); + } + } catch (error) { + failures.push(`${file} ${importFailureReason(error)}.`); + } + } + if (failures.length > 0) throw generationFailed(failures); + return located; +} + +function generationFailed(problems: readonly string[]): DatabaseTypegenError { + return new DatabaseTypegenError( + `Database operations could not be generated:\n${problems.map((problem) => ` - ${problem}`).join("\n")}`, + ); +} + +/** Load, validate, and type every discovered operation, or throw every problem. */ +async function contractsOf( + appRoot: string, + files: readonly string[], + strict: boolean, +): Promise { + const schemaFile = fromRoot(appRoot, SCHEMA_FILE); + if (!fsSync.existsSync(schemaFile)) { + throw generationFailed([`${ROUTES_DIR} needs a schema at ${SCHEMA_FILE}.`]); + } + if (files.length === 0) return []; + const schema = await loadDatabaseSchema(schemaFile); + const located = await loadDeclarations(appRoot, files); + const { operations, skipped, errors } = validateOperationMetadata( + located, + new Set(Object.keys(schema.$tables)), + ); + const problems = errors.map((issue) => issue.message); + for (const issue of skipped) { + if (strict) { + problems.push( + `${issue.file}#${issue.exportName} has no authorization; strict generation requires one.`, + ); + } else { + logger.warn("%s", issue.message); + } + } + const contracts: OperationContract[] = []; + for (const operation of operations) { + const result = operationContract(schema, operation); + if (result.ok) contracts.push(result.contract); + else problems.push(...result.problems); + } + if (problems.length > 0) throw generationFailed(problems); + return contracts; +} + +/** + * Discover `server/database/routes/*.ts`, write the lazy `routes.gen.ts` entry + * that lists every module, and write the browser contract of each published + * operation. Any malformed or duplicate declaration fails generation and + * neutralizes the contracts; a declaration without authorization is skipped + * with a warning, or fails in strict mode. Both files are created only while + * the routes directory exists, and emptied once it is gone. + */ +export async function generateDatabaseOperations( + options: GenerateDatabaseOperationsOptions, +): Promise { + const routesFile = fromRoot(options.appRoot, ROUTES_FILE); + const routesPresent = + fsSync + .statSync(fromRoot(options.appRoot, ROUTES_DIR), { + throwIfNoEntry: false, + }) + ?.isDirectory() ?? false; + const files = routesPresent ? discoverRouteModules(options.appRoot) : []; + if (routesPresent || fsSync.existsSync(routesFile)) { + await writeIfChanged(routesFile, renderDatabaseRoutes(files)); + } + if (!routesPresent) { + if (fsSync.existsSync(options.outFile)) { + await writeIfChanged(options.outFile, NEUTRAL_DATABASE_OPERATION_TYPES); + } + return; + } + try { + const contracts = await contractsOf( + options.appRoot, + files, + options.strict ?? false, + ); + await writeIfChanged(options.outFile, renderOperationTypes(contracts)); + } catch (error) { + // A failed run must not leave a previous operation visible to the client. + await writeIfChanged(options.outFile, NEUTRAL_DATABASE_OPERATION_TYPES); + throw error instanceof DatabaseTypegenError + ? error + : generationFailed(["an unexpected error interrupted generation."]); + } +} diff --git a/packages/appkit/src/type-generator/database/generate.ts b/packages/appkit/src/type-generator/database/generate.ts index aea019c65..75b3f999b 100644 --- a/packages/appkit/src/type-generator/database/generate.ts +++ b/packages/appkit/src/type-generator/database/generate.ts @@ -26,7 +26,8 @@ function schemaLabel(schemaFile: string): string { return path.relative(process.cwd(), schemaFile) || schemaFile; } -function importFailureReason(error: unknown): string { +/** A bounded reason for a failed import; the raw error text never leaves. */ +export function importFailureReason(error: unknown): string { if (error instanceof SyntaxError) return "could not be parsed"; let code: unknown; try { @@ -100,7 +101,10 @@ declare global { } /** Atomically replace generated output only when its content changes. */ -async function writeIfChanged(outFile: string, content: string): Promise { +export async function writeIfChanged( + outFile: string, + content: string, +): Promise { if ( fsSync.existsSync(outFile) && (await fs.readFile(outFile, "utf8")) === content @@ -125,6 +129,32 @@ async function importSchema(schemaFile: string): Promise { return createJiti(import.meta.url, { moduleCache: false }).import(schemaFile); } +/** Load the named, finalized `schema` export; every failure is a safe diagnostic. */ +export async function loadDatabaseSchema(schemaFile: string): Promise { + let module: unknown; + try { + module = await importSchema(schemaFile); + } catch (error) { + throw new DatabaseTypegenError( + `Database schema "${schemaLabel(schemaFile)}" ${importFailureReason(error)}`, + ); + } + if (typeof module !== "object" || module === null || !("schema" in module)) { + throw new DatabaseTypegenError( + `Database schema module "${schemaLabel(schemaFile)}" must export a named "schema"`, + ); + } + const { schema } = module; + try { + assertFinalizedSchema(schema); + } catch { + throw new DatabaseTypegenError( + `Database schema "${schemaLabel(schemaFile)}" is not a finalized AppKit schema`, + ); + } + return schema; +} + /** Generate current registry types or neutralize them when loading fails. */ export async function generateDatabaseTypes( options: GenerateDatabaseTypesOptions, @@ -134,22 +164,10 @@ export async function generateDatabaseTypes( return; } try { - const module = (await importSchema(options.schemaFile)) as { - schema?: Schema; - }; - if (!("schema" in module)) { - throw new DatabaseTypegenError( - `Database schema module "${schemaLabel(options.schemaFile)}" must export a named "schema"`, - ); - } - try { - assertFinalizedSchema(module.schema); - } catch { - throw new DatabaseTypegenError( - `Database schema "${schemaLabel(options.schemaFile)}" is not a finalized AppKit schema`, - ); - } - await writeIfChanged(options.outFile, render(module.schema)); + await writeIfChanged( + options.outFile, + render(await loadDatabaseSchema(options.schemaFile)), + ); } catch (error) { // A failed run must not leave stale entities visible to TypeScript. await writeIfChanged(options.outFile, NEUTRAL_DATABASE_TYPES); diff --git a/packages/appkit/src/type-generator/database/index.ts b/packages/appkit/src/type-generator/database/index.ts index 0648bba2c..0874f6e92 100644 --- a/packages/appkit/src/type-generator/database/index.ts +++ b/packages/appkit/src/type-generator/database/index.ts @@ -3,3 +3,7 @@ export { DatabaseTypegenError, generateDatabaseTypes, } from "./generate"; +export { + DATABASE_OPERATIONS_TYPES_FILE, + generateDatabaseOperations, +} from "./generate-operations"; diff --git a/packages/appkit/src/type-generator/database/operation-contracts.ts b/packages/appkit/src/type-generator/database/operation-contracts.ts new file mode 100644 index 000000000..fad16b663 --- /dev/null +++ b/packages/appkit/src/type-generator/database/operation-contracts.ts @@ -0,0 +1,570 @@ +import { z } from "zod"; + +import type { + AppKitTable, + ColumnMeta, + Schema, +} from "../../database/schema-builder"; +import { columnHttpCapabilities } from "../../plugins/database/crud/capabilities"; +import type { validateOperationMetadata } from "../../plugins/database/operation-metadata"; +import type { OperationMethod } from "../../plugins/database/operations"; + +type ValidatedOperation = ReturnType< + typeof validateOperationMetadata +>["operations"][number]; + +/** A printable TypeScript type. Generated contracts never contain `any`. */ +type TsType = + | { + readonly kind: "keyword"; + readonly name: "string" | "number" | "boolean" | "null" | "void"; + } + /** A JSON column or `z.unknown()`: `unknown` on both sides of the wire. */ + | { readonly kind: "json" } + /** `z.record` with plain string keys. */ + | { + readonly kind: "record"; + readonly value: TsType; + readonly readonly: boolean; + } + | { readonly kind: "literal"; readonly value: string | number | boolean } + | { + readonly kind: "array"; + readonly element: TsType; + readonly readonly: boolean; + } + | { + readonly kind: "object"; + readonly properties: readonly TsProperty[]; + readonly readonly: boolean; + } + | { readonly kind: "union"; readonly members: readonly TsType[] }; + +interface TsProperty { + readonly name: string; + readonly optional: boolean; + readonly type: TsType; +} + +/** The browser contract of one published operation, ready to print. */ +export interface OperationContract { + readonly name: string; + readonly method: OperationMethod; + readonly input: TsType; + readonly output: TsType; +} + +type ContractResult = + | { readonly ok: true; readonly contract: OperationContract } + | { readonly ok: false; readonly problems: readonly string[] }; + +/** Why one slot has no exact JSON type. The message names no user data. */ +class ContractProblem extends Error {} + +const keyword = ( + name: Extract["name"], +): TsType => ({ kind: "keyword", name }); +const STRING = keyword("string"); +const NUMBER = keyword("number"); +const BOOLEAN = keyword("boolean"); +const NULL = keyword("null"); + +function union(members: readonly TsType[]): TsType { + const flat = members.flatMap((member) => + member.kind === "union" ? member.members : [member], + ); + if (flat.length === 0) throw new ContractProblem("is an empty union"); + // `unknown` absorbs every other member, exactly as TypeScript reduces it. + const json = flat.find((member) => member.kind === "json"); + if (json) return json; + return flat.length === 1 ? flat[0] : { kind: "union", members: flat }; +} + +function object(properties: readonly TsProperty[], readonly = false): TsType { + return { kind: "object", properties, readonly }; +} + +// --------------------------------------------------------------------------- +// Default contracts, from the same capability predicates the runtime compiles. +// --------------------------------------------------------------------------- + +/** JSON carries a bigint as a decimal string out, and a string or safe integer in. */ +function columnType(meta: ColumnMeta, direction: "input" | "output"): TsType { + const scalar = ((): TsType => { + switch (meta.kind) { + case "string": + case "uuid": + case "date": + return STRING; + case "number": + return NUMBER; + case "bigint": + return direction === "input" ? union([STRING, NUMBER]) : STRING; + case "boolean": + return BOOLEAN; + case "enum": + return meta.enumValues?.length + ? union(meta.enumValues.map((value) => ({ kind: "literal", value }))) + : STRING; + case "json": + case "unknown": + return { kind: "json" }; + } + })(); + return meta.notNull ? scalar : union([scalar, NULL]); +} + +function column( + meta: ColumnMeta, + direction: "input" | "output", + optional: boolean, +): TsProperty { + return { + name: meta.columnName, + optional, + type: columnType(meta, direction), + }; +} + +function noDefault(table: AppKitTable, method: OperationMethod): never { + throw new ContractProblem( + `needs an explicit schema: ${JSON.stringify(table.$name)} has no default ${method} input`, + ); +} + +/** `{ id }`, `{ id, changes }`, `{ id, values }`, or the public create body. */ +function defaultInput(table: AppKitTable, method: OperationMethod): TsType { + const columns = Object.values(table.$columns).map((meta) => ({ + meta, + can: columnHttpCapabilities(meta), + })); + const key = columns.find(({ can }) => can.publicKey)?.meta; + const updatable = columns.filter(({ can }) => can.updatable); + if (method === "POST") { + return object( + columns + .filter(({ can }) => can.creatable) + .map(({ meta }) => + column(meta, "input", !meta.notNull || meta.hasDefault), + ), + ); + } + if (!key) return noDefault(table, method); + const id: TsProperty = { ...column(key, "input", false), name: "id" }; + if (method === "GET" || method === "DELETE") return object([id]); + if (updatable.length === 0) return noDefault(table, method); + // PATCH changes any subset; PUT replaces every mutable public column. + const patch = method === "PATCH"; + return object([ + id, + { + name: patch ? "changes" : "values", + optional: false, + type: object(updatable.map(({ meta }) => column(meta, "input", patch))), + }, + ]); +} + +/** Every public scalar column; a delete answers without a body. */ +function defaultOutput(table: AppKitTable, method: OperationMethod): TsType { + if (method === "DELETE") return keyword("void"); + return object( + Object.values(table.$columns) + .filter((meta) => columnHttpCapabilities(meta).selectable) + .map((meta) => column(meta, "output", false)), + ); +} + +// --------------------------------------------------------------------------- +// Explicit Zod contracts, through Zod's own JSON Schema conversion. +// --------------------------------------------------------------------------- + +/** + * Zod schema kinds whose JSON Schema states their exact `z.input` and + * `z.output`. Anything else (tuples, intersections, catch, any, JSON values) + * fails generation instead of printing a looser type. + */ +const RENDERABLE: ReadonlySet = new Set([ + "array", + "boolean", + "default", + "enum", + "lazy", + "literal", + "nonoptional", + "null", + "nullable", + "number", + "object", + "optional", + "pipe", + "prefault", + "readonly", + "record", + "string", + "union", + "unknown", +]); + +/** Marks a JSON node whose Zod value may be `undefined`; JSON Schema cannot say so. */ +const MAY_BE_UNDEFINED = "x-appkit-undefined"; + +/** Marks the node of a `z.unknown()`, whose JSON Schema is the empty schema. */ +const UNKNOWN = "x-appkit-unknown"; + +/** Marks the node of a `z.record` keyed by plain strings. */ +const RECORD = "x-appkit-record"; + +/** Structure this renderer does not translate. */ +const UNSUPPORTED_KEYWORDS = [ + "allOf", + "not", + "prefixItems", + "propertyNames", + "patternProperties", + "if", + "then", + "else", + "dependentSchemas", + "unevaluatedProperties", + "unevaluatedItems", + "contains", + "additionalItems", + "$dynamicRef", +]; + +type JsonNode = Readonly>; + +function isJsonNode(value: unknown): value is JsonNode { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** A Zod 4 schema carries its definition under `_zod.def`. */ +function isZodSchema(value: object): value is z.core.$ZodType { + const internals: unknown = Reflect.get(value, "_zod"); + if (typeof internals !== "object" || internals === null) return false; + const def: unknown = Reflect.get(internals, "def"); + return ( + typeof def === "object" && + def !== null && + typeof Reflect.get(def, "type") === "string" + ); +} + +/** Zod's own reasons are safe to repeat; anything else may carry user text. */ +function conversionFailure(error: unknown): string { + const message = error instanceof Error ? error.message : ""; + return /^[\w ]+ cannot be represented in JSON Schema$/.test(message) + ? `has no JSON type (${message})` + : "could not be converted to JSON Schema"; +} + +function literal(value: unknown): TsType { + if (value === null) return NULL; + if ( + typeof value === "string" || + typeof value === "boolean" || + (typeof value === "number" && Number.isFinite(value)) + ) { + return { kind: "literal", value }; + } + throw new ContractProblem("has a literal that is not a JSON scalar"); +} + +interface RenderScope { + readonly root: JsonNode; + /** References being rendered; meeting one again means recursion. */ + readonly refs: readonly string[]; +} + +interface Position { + /** Only an object property can be absent, so only it may be undefined. */ + readonly undefinedAllowed: boolean; + /** `.readonly()` applies to this node's own object or array. */ + readonly readonly: boolean; +} + +const VALUE: Position = { undefinedAllowed: false, readonly: false }; + +function resolveRef(node: JsonNode, scope: RenderScope, at: Position): TsType { + const ref = node.$ref; + if (typeof ref !== "string") { + throw new ContractProblem("has a malformed reference"); + } + for (const sibling of ["type", "properties", "items", "anyOf", "oneOf"]) { + if (sibling in node) { + throw new ContractProblem("combines a reference with other structure"); + } + } + if (ref === "#" || scope.refs.includes(ref)) { + throw new ContractProblem("is recursive"); + } + const prefix = "#/$defs/"; + const name = ref.startsWith(prefix) + ? ref.slice(prefix.length).replaceAll("~1", "/").replaceAll("~0", "~") + : undefined; + const defs = scope.root.$defs; + if (name === undefined || !isJsonNode(defs) || !Object.hasOwn(defs, name)) { + throw new ContractProblem("has a reference that cannot be resolved"); + } + return render(defs[name], { ...scope, refs: [...scope.refs, ref] }, at); +} + +function renderObject(node: JsonNode, scope: RenderScope, readonly: boolean) { + const extra = node.additionalProperties; + if (extra !== undefined && extra !== false) { + throw new ContractProblem( + "allows undeclared keys, which have no exact type", + ); + } + const properties = node.properties ?? {}; + const required = node.required ?? []; + if (!isJsonNode(properties) || !Array.isArray(required)) { + throw new ContractProblem("has a malformed object schema"); + } + for (const name of required) { + if (typeof name !== "string" || !Object.hasOwn(properties, name)) { + throw new ContractProblem("requires a property it does not declare"); + } + } + return object( + Object.entries(properties).map(([name, child]) => { + const optional = !required.includes(name); + return { + name, + optional, + type: render(child, scope, { + undefinedAllowed: optional, + readonly: false, + }), + }; + }), + readonly, + ); +} + +/** Translate one JSON Schema node into the exact type Zod infers for it. */ +function render(node: unknown, scope: RenderScope, at: Position): TsType { + if (!isJsonNode(node)) { + throw new ContractProblem("has a schema that is not a JSON object"); + } + // `unknown` already includes `undefined`, wherever it sits. + if (node[UNKNOWN] === true) return { kind: "json" }; + if (node[MAY_BE_UNDEFINED] === true && !at.undefinedAllowed) { + throw new ContractProblem("may be undefined outside an object property"); + } + if (node[RECORD] === true) { + return { + kind: "record", + value: render(node.additionalProperties, scope, VALUE), + readonly: at.readonly || node.readOnly === true, + }; + } + for (const keyword of UNSUPPORTED_KEYWORDS) { + if (keyword in node) { + throw new ContractProblem(`uses ${keyword}, which has no exact type`); + } + } + const readonly = at.readonly || node.readOnly === true; + if ("$ref" in node) return resolveRef(node, scope, { ...at, readonly }); + const members = node.anyOf ?? node.oneOf; + if (members !== undefined) { + if (!Array.isArray(members)) { + throw new ContractProblem("has a malformed union"); + } + // Readonly distributes over a union, and so does an absent property. + return union( + members.map((member) => render(member, scope, { ...at, readonly })), + ); + } + if ("const" in node) return literal(node.const); + if (node.enum !== undefined) { + if (!Array.isArray(node.enum)) { + throw new ContractProblem("has a malformed enum"); + } + return union(node.enum.map(literal)); + } + switch (node.type) { + case "string": + return STRING; + case "number": + case "integer": + return NUMBER; + case "boolean": + return BOOLEAN; + case "null": + return NULL; + case "array": + return { + kind: "array", + element: render(node.items, scope, VALUE), + readonly, + }; + case "object": + return renderObject(node, scope, readonly); + default: + throw new ContractProblem("accepts any value"); + } +} + +/** `z.input` or `z.output` of `schema`, or a {@link ContractProblem}. */ +function zodType(schema: object, io: "input" | "output"): TsType { + if (!isZodSchema(schema)) throw new ContractProblem("is not a Zod 4 schema"); + let json: unknown; + try { + json = z.toJSONSchema(schema, { + io, + // `.meta()` must not restate a type: only the definitions decide it. + metadata: z.registry(), + override: ({ zodSchema, jsonSchema }) => { + const internals = zodSchema._zod; + const { def } = internals; + if (!RENDERABLE.has(def.type)) { + throw new ContractProblem( + `uses ${def.type}, which has no exact JSON type`, + ); + } + if (def.type === "unknown") jsonSchema[UNKNOWN] = true; + if (def.type === "record") { + const key = def.keyType._zod.def; + if (key.type !== "string" || (key.checks?.length ?? 0) > 0) { + throw new ContractProblem( + "uses a record whose keys are not plain strings", + ); + } + jsonSchema[RECORD] = true; + } + if (io === "input" && "coerce" in def && def.coerce) { + throw new ContractProblem("coerces its input from any value"); + } + if ( + io === "input" && + def.type === "pipe" && + def.in._zod.def.type === "transform" + ) { + throw new ContractProblem("preprocesses its input from any value"); + } + // A wrapper inherits its inner node's keys, so `.nonoptional()` must + // also clear the mark its optional inner schema left. + if ( + (io === "input" ? internals.optin : internals.optout) === "optional" + ) { + jsonSchema[MAY_BE_UNDEFINED] = true; + } else { + delete jsonSchema[MAY_BE_UNDEFINED]; + } + }, + }); + } catch (error) { + if (error instanceof ContractProblem) throw error; + throw new ContractProblem(conversionFailure(error)); + } + if (!isJsonNode(json)) throw new ContractProblem("has no JSON Schema"); + return render(json, { root: json, refs: [] }, VALUE); +} + +/** + * Derive the wire contract of one validated operation: an explicit Zod slot + * from its JSON Schema, a default slot from the entity's HTTP capabilities. + * Every slot that has no exact type is reported with its source and slot. + */ +export function operationContract( + schema: Schema, + operation: ValidatedOperation, +): ContractResult { + const source = `${operation.file}#${operation.exportName}`; + if (!Object.hasOwn(schema.$tables, operation.entity)) { + return { + ok: false, + problems: [`${source} must name one entity in the database schema.`], + }; + } + const table = schema.$tables[operation.entity]; + const problems: string[] = []; + const slot = ( + name: "input" | "output", + explicit: object | undefined, + fallback: (table: AppKitTable, method: OperationMethod) => TsType, + ): TsType | undefined => { + try { + return explicit + ? zodType(explicit, name) + : fallback(table, operation.method); + } catch (error) { + if (!(error instanceof ContractProblem)) throw error; + problems.push(`${source} ${name} ${error.message}.`); + return undefined; + } + }; + const input = slot("input", operation.input, defaultInput); + const output = slot("output", operation.output, defaultOutput); + if (!input || !output) return { ok: false, problems }; + return { + ok: true, + contract: { + name: operation.name, + method: operation.method, + input, + output, + }, + }; +} + +function printType(type: TsType, indent: string): string { + switch (type.kind) { + case "keyword": + return type.name; + case "json": + return "unknown"; + case "record": + return `{ ${type.readonly ? "readonly " : ""}[key: string]: ${printType(type.value, indent)} }`; + case "literal": + return JSON.stringify(type.value); + case "union": + return type.members + .map((member) => printType(member, indent)) + .join(" | "); + case "array": { + const element = printType(type.element, indent); + return `${type.readonly ? "readonly " : ""}${type.element.kind === "union" ? `(${element})` : element}[]`; + } + case "object": { + if (type.properties.length === 0) { + return type.readonly + ? "Readonly>" + : "Record"; + } + const inner = `${indent} `; + const modifier = type.readonly ? "readonly " : ""; + const lines = type.properties.map( + (property) => + `${inner}${modifier}${JSON.stringify(property.name)}${property.optional ? "?" : ""}: ${printType(property.type, inner)};`, + ); + return `{\n${lines.join("\n")}\n${indent}}`; + } + } +} + +/** The global interface the UI reads. Empty when no operation is published. */ +export function renderOperationTypes( + contracts: readonly OperationContract[], +): string { + const entries = [...contracts] + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) + .map( + (contract) => ` ${JSON.stringify(contract.name)}: { + method: ${JSON.stringify(contract.method)}; + input: ${printType(contract.input, " ")}; + output: ${printType(contract.output, " ")}; + };`, + ); + const body = entries.length > 0 ? `\n${entries.join("\n")}\n ` : ""; + return `// Auto-generated by AppKit - DO NOT EDIT + +declare global { + interface DatabricksAppKitDatabaseOperations {${body}} +} + +export {}; +`; +} diff --git a/packages/appkit/src/type-generator/database/tests/generate-operations.test.ts b/packages/appkit/src/type-generator/database/tests/generate-operations.test.ts new file mode 100644 index 000000000..1513aa4a2 --- /dev/null +++ b/packages/appkit/src/type-generator/database/tests/generate-operations.test.ts @@ -0,0 +1,429 @@ +import fs from "node:fs/promises"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { validateOperationMetadata } from "../../../plugins/database/operation-metadata"; +import type { + DatabaseRoutes, + LocatedDeclaration, +} from "../../../plugins/database/operations"; +import { + generateDatabaseOperations, + NEUTRAL_DATABASE_OPERATION_TYPES, +} from "../generate-operations"; + +const appkitRoot = path.resolve(import.meta.dirname, "../../../.."); +const sourceRoot = path.join(appkitRoot, "src"); +const builder = path.join(sourceRoot, "database/schema-builder/index"); +const operationsModule = path.join(sourceRoot, "plugins/database/operations"); +const zodPackage = path.dirname( + createRequire(import.meta.url).resolve("zod/package.json"), +); + +const ROUTES = "server/database/routes"; +const ROUTES_FILE = "server/database/routes.gen.ts"; +const TYPES_FILE = "shared/appkit-types/database-operations.d.ts"; + +const schemaSource = ` + import { bigint, defineSchema, id, integer, text, timestamp } from ${JSON.stringify(builder)}; + export const schema = defineSchema(({ table }) => ({ + notes: table("notes", { id: id(), body: text().notNull(), rank: integer() }), + boards: table("boards", { slug: text().primaryKey(), title: text().notNull(), total: bigint() }), + events: table("events", { message: text().notNull() }), + stamps: table("stamps", { id: id(), at: timestamp().defaultNow().notNull() }), + })); +`; + +/** A route module importing the real operation API, as an app's would. */ +const route = (body: string) => ` +import { z } from "zod"; +import { defineOperation } from ${JSON.stringify(operationsModule)}; +void z; +${body} +`; + +const getNote = `export const getNote = defineOperation({ + name: "notes.get", + entity: "notes", + method: "GET", + authorize: () => true, + execute: ({ input, db }) => db.notes.find(input.id), +});`; + +const getBoard = ( + exportName = "getBoard", +) => `export const ${exportName} = defineOperation({ + name: "boards.get", + entity: "boards", + method: "GET", + authorize: () => true, + execute: ({ input, db }) => db.boards.find(input.id), +});`; + +const roots: string[] = []; + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all( + roots + .splice(0) + .map((root) => fs.rm(root, { recursive: true, force: true })), + ); +}); + +async function write(root: string, file: string, content: string) { + const target = path.join(root, file); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, content); +} + +/** A temporary app with a schema, `zod` installed, and the given files. */ +async function app(files: Record = {}): Promise { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), "appkit-database-operations-"), + ); + roots.push(root); + await fs.mkdir(path.join(root, "node_modules")); + await fs.symlink(zodPackage, path.join(root, "node_modules", "zod"), "dir"); + await write(root, "config/database/schema.ts", schemaSource); + for (const [file, content] of Object.entries(files)) { + await write(root, file, content); + } + return root; +} + +function generate(root: string, strict = false) { + return generateDatabaseOperations({ + appRoot: root, + outFile: path.join(root, TYPES_FILE), + strict, + }); +} + +const read = (root: string, file: string) => + fs.readFile(path.join(root, file), "utf8"); + +const exists = (root: string, file: string) => + fs.access(path.join(root, file)).then( + () => true, + () => false, + ); + +async function failure(promise: Promise): Promise { + const error: unknown = await promise.then( + () => undefined, + (caught: unknown) => caught, + ); + if (!(error instanceof Error)) throw new Error("generation did not fail"); + expect(error.name).toBe("DatabaseTypegenError"); + return error; +} + +/** Every export of every listed module, loaded through the generated entry. */ +async function loadGenerated(root: string) { + const { databaseRoutes }: { databaseRoutes: DatabaseRoutes } = await import( + path.join(root, ROUTES_FILE) + ); + const { schema } = await databaseRoutes.schema(); + const located: LocatedDeclaration[] = []; + for (const module of databaseRoutes.modules) { + const namespace = await module.load(); + for (const [exportName, value] of Object.entries(namespace)) { + located.push({ file: module.file, exportName, value }); + } + } + return validateOperationMetadata( + located, + new Set(Object.keys(schema.$tables)), + ); +} + +describe("generateDatabaseOperations", () => { + test("lists direct route modules in path order, lazily, behind one type import", async () => { + const root = await app({ + [`${ROUTES}/notes.ts`]: route(getNote), + [`${ROUTES}/boards.ts`]: route(getBoard()), + [`${ROUTES}/boards.test.ts`]: "throw new Error('a test is not a route');", + [`${ROUTES}/shapes.d.ts`]: "export {};", + [`${ROUTES}/table.gen.ts`]: "throw new Error('generated');", + [`${ROUTES}/nested/deep.ts`]: "throw new Error('not recursive');", + [`${ROUTES}/README.md`]: "notes", + }); + + await generate(root); + + expect(await read(root, ROUTES_FILE)) + .toBe(`// Auto-generated by AppKit - DO NOT EDIT +import type { DatabaseRoutes } from "@databricks/appkit/beta"; + +export const databaseRoutes = { + schema: () => import("../../config/database/schema"), + modules: [ + { + file: "server/database/routes/boards.ts", + load: () => import("./routes/boards"), + }, + { + file: "server/database/routes/notes.ts", + load: () => import("./routes/notes"), + }, + ], +} satisfies DatabaseRoutes; +`); + const types = await read(root, TYPES_FILE); + expect(types).toContain('"boards.get": {'); + expect(types).toContain('"notes.get": {'); + expect(types).not.toMatch(/^\s*import\b/m); + }); + + test("creates nothing for an app without operation routes", async () => { + const root = await app(); + await generate(root); + expect(await exists(root, ROUTES_FILE)).toBe(false); + expect(await exists(root, TYPES_FILE)).toBe(false); + }); + + test("regenerates both files deterministically as modules are added, renamed, and removed", async () => { + const root = await app({ [`${ROUTES}/notes.ts`]: route(getNote) }); + await generate(root); + const notesOnly = { + routes: await read(root, ROUTES_FILE), + types: await read(root, TYPES_FILE), + }; + expect(notesOnly.types).toContain('"notes.get": {'); + + await write(root, `${ROUTES}/boards.ts`, route(getBoard())); + await generate(root); + const withBoards = { + routes: await read(root, ROUTES_FILE), + types: await read(root, TYPES_FILE), + }; + expect(withBoards.routes).toContain('import("./routes/boards")'); + expect(withBoards.types).toContain('"boards.get": {'); + + // A name, not a path or export identifier, keys the browser contract. + await fs.rename( + path.join(root, ROUTES, "boards.ts"), + path.join(root, ROUTES, "panels.ts"), + ); + await generate(root); + expect(await read(root, ROUTES_FILE)).toContain( + 'load: () => import("./routes/panels")', + ); + expect(await read(root, ROUTES_FILE)).not.toContain("boards"); + expect(await read(root, TYPES_FILE)).toBe(withBoards.types); + + await write(root, `${ROUTES}/panels.ts`, route(getBoard("readPanel"))); + await generate(root); + expect(await read(root, TYPES_FILE)).toBe(withBoards.types); + + await fs.rm(path.join(root, ROUTES, "panels.ts")); + await generate(root); + expect(await read(root, ROUTES_FILE)).toBe(notesOnly.routes); + expect(await read(root, TYPES_FILE)).toBe(notesOnly.types); + + const before = (await fs.stat(path.join(root, ROUTES_FILE))).mtimeMs; + await new Promise((resolve) => setTimeout(resolve, 20)); + await generate(root); + expect((await fs.stat(path.join(root, ROUTES_FILE))).mtimeMs).toBe(before); + + await fs.rm(path.join(root, ROUTES), { recursive: true }); + await generate(root); + expect(await read(root, ROUTES_FILE)).toContain(" modules: [],\n"); + expect(await read(root, TYPES_FILE)).toBe(NEUTRAL_DATABASE_OPERATION_TYPES); + }); + + test("deleting authorize drops the operation and the runtime validator skips it", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const root = await app({ [`${ROUTES}/notes.ts`]: route(getNote) }); + await generate(root); + expect(await read(root, TYPES_FILE)).toContain('"notes.get": {'); + + await write( + root, + `${ROUTES}/notes.ts`, + route(getNote.replace(" authorize: () => true,\n", "")), + ); + await generate(root); + + expect(await read(root, TYPES_FILE)).toBe(NEUTRAL_DATABASE_OPERATION_TYPES); + expect(await read(root, ROUTES_FILE)).toContain('import("./routes/notes")'); + expect(warn).toHaveBeenCalledWith( + expect.any(String), + "server/database/routes/notes.ts#getNote has no authorization and was skipped.", + ); + const runtime = await loadGenerated(root); + expect(runtime.operations).toEqual([]); + expect(runtime.errors).toEqual([]); + expect(runtime.skipped).toMatchObject([ + { + file: "server/database/routes/notes.ts", + exportName: "getNote", + code: "authorize", + }, + ]); + }); + + test("skips an unauthorized declaration in development and fails it in strict mode", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const root = await app({ + [`${ROUTES}/notes.ts`]: route( + `${getNote}\nexport const open = defineOperation({ name: "notes.open", entity: "notes", method: "GET", execute: () => null });`, + ), + }); + + await generate(root); + const development = await read(root, TYPES_FILE); + expect(development).toContain('"notes.get": {'); + expect(development).not.toContain("notes.open"); + + const error = await failure(generate(root, true)); + expect(error.message).toContain( + "server/database/routes/notes.ts#open has no authorization; strict generation requires one.", + ); + expect(await read(root, TYPES_FILE)).toBe(NEUTRAL_DATABASE_OPERATION_TYPES); + }); + + test("names a module that cannot load without its error text and neutralizes the contracts", async () => { + const root = await app({ [`${ROUTES}/notes.ts`]: route(getNote) }); + await generate(root); + expect(await read(root, TYPES_FILE)).toContain('"notes.get": {'); + + await write( + root, + `${ROUTES}/broken.ts`, + 'throw new Error("SECRET-TOKEN in a row value");', + ); + await write( + root, + `${ROUTES}/orphan.ts`, + 'import { missing } from "./not-there";\nexport const value = missing;', + ); + const error = await failure(generate(root)); + + expect(error.message).toContain( + "server/database/routes/broken.ts threw while loading.", + ); + expect(error.message).toContain( + "server/database/routes/orphan.ts contains an unresolved import.", + ); + expect(error.message).not.toContain("SECRET-TOKEN"); + expect(error.message).not.toContain("not-there"); + expect(await read(root, TYPES_FILE)).toBe(NEUTRAL_DATABASE_OPERATION_TYPES); + // The route entry still lists every module; the runtime revalidates them. + const routes = await read(root, ROUTES_FILE); + expect(routes).toContain('import("./routes/broken")'); + expect(routes).toContain('import("./routes/orphan")'); + }); + + test("fails on duplicate names and malformed declarations", async () => { + const root = await app({ + [`${ROUTES}/notes.ts`]: route(getNote), + [`${ROUTES}/copy.ts`]: route(getNote.replace("getNote", "copiedNote")), + [`${ROUTES}/invalid.ts`]: route(` +export const ghost = defineOperation({ name: "ghosts.get", entity: "ghosts", method: "GET", authorize: () => true, execute: () => null }); +export const keyless = defineOperation({ name: "events.get", entity: "events", method: "GET", authorize: () => true, execute: () => null }); +export const frozen = defineOperation({ name: "stamps.patch", entity: "stamps", method: "PATCH", authorize: () => true, execute: () => null }); +export const headless = defineOperation({ name: "notes.head", entity: "notes", method: "HEAD", authorize: () => true, execute: () => null }); +`), + }); + const error = await failure(generate(root)); + + expect(error.message).toContain( + 'server/database/routes/notes.ts#getNote repeats operation name "notes.get", already exported by server/database/routes/copy.ts#copiedNote.', + ); + expect(error.message).toContain( + "server/database/routes/invalid.ts#ghost must name one entity in the database schema.", + ); + expect(error.message).toContain( + "server/database/routes/invalid.ts#headless must use GET, POST, PATCH, PUT, or DELETE.", + ); + expect(error.message).toContain( + 'server/database/routes/invalid.ts#keyless input needs an explicit schema: "events" has no default GET input.', + ); + expect(error.message).toContain( + 'server/database/routes/invalid.ts#frozen input needs an explicit schema: "stamps" has no default PATCH input.', + ); + expect(await read(root, TYPES_FILE)).toBe(NEUTRAL_DATABASE_OPERATION_TYPES); + }); + + test("requires the schema the routes are declared against", async () => { + const root = await app({ [`${ROUTES}/notes.ts`]: route(getNote) }); + await fs.rm(path.join(root, "config"), { recursive: true }); + const error = await failure(generate(root)); + expect(error.message).toContain( + "server/database/routes needs a schema at config/database/schema.ts.", + ); + expect(await read(root, TYPES_FILE)).toBe(NEUTRAL_DATABASE_OPERATION_TYPES); + }); + + test("names the source and slot of every Zod schema without an exact JSON type", async () => { + const operation = (name: string, input: string, output = "undefined") => + `export const ${name} = defineOperation({ name: "notes.${name}", entity: "notes", method: "POST", input: ${input}, output: ${output}, authorize: () => true, execute: () => null } as never);`; + const unsupported: Record = { + big: ["input", "z.object({ n: z.bigint() })"], + date: ["output", "z.object({})", "z.object({ at: z.date() })"], + transformed: ["output", "z.object({})", "z.string().transform(Number)"], + recursive: [ + "input", + "(() => { const tree = z.object({ name: z.string(), get children() { return z.array(tree); } }); return tree; })()", + ], + enumKeys: ["input", 'z.record(z.enum(["a", "b"]), z.number())'], + checkedKeys: ["input", "z.record(z.string().min(2), z.number())"], + json: ["input", "z.object({ value: z.json() })"], + any: ["input", "z.object({ value: z.any() })"], + coerced: ["input", "z.object({ value: z.coerce.number() })"], + preprocessed: [ + "input", + "z.object({ value: z.preprocess((v) => v, z.string()) })", + ], + optionalRoot: ["input", "z.object({ a: z.string() }).optional()"], + optionalItem: ["input", "z.array(z.string().optional())"], + tuple: ["input", "z.tuple([z.string(), z.number()])"], + intersection: [ + "input", + "z.intersection(z.object({ a: z.string() }), z.object({ b: z.string() }))", + ], + catchall: ["input", "z.object({ a: z.string() }).catchall(z.number())"], + caught: ["input", 'z.object({ a: z.string().catch("x") })'], + template: [ + "input", + 'z.object({ a: z.templateLiteral(["n", z.number()]) })', + ], + leaky: ["input", 'z.lazy(() => { throw new Error("SECRET-TOKEN"); })'], + }; + const root = await app({ + [`${ROUTES}/zod.ts`]: route( + Object.entries(unsupported) + .map(([name, [, input, output]]) => operation(name, input, output)) + .join("\n"), + ), + }); + + const error = await failure(generate(root)); + + for (const [name, [slot]] of Object.entries(unsupported)) { + expect(error.message).toContain( + `server/database/routes/zod.ts#${name} ${slot} `, + ); + } + expect(error.message).toContain( + "#big input has no JSON type (BigInt cannot be represented in JSON Schema).", + ); + expect(error.message).toContain("#recursive input is recursive."); + expect(error.message).toContain( + "#enumKeys input uses a record whose keys are not plain strings.", + ); + expect(error.message).toContain( + "#optionalRoot input may be undefined outside an object property.", + ); + expect(error.message).toContain( + "#leaky input could not be converted to JSON Schema.", + ); + expect(error.message).not.toContain("SECRET-TOKEN"); + expect(await read(root, TYPES_FILE)).toBe(NEUTRAL_DATABASE_OPERATION_TYPES); + }); +}); diff --git a/packages/appkit/src/type-generator/database/tests/operation-contracts.types.test.ts b/packages/appkit/src/type-generator/database/tests/operation-contracts.types.test.ts new file mode 100644 index 000000000..3798ba35d --- /dev/null +++ b/packages/appkit/src/type-generator/database/tests/operation-contracts.types.test.ts @@ -0,0 +1,356 @@ +import fs from "node:fs/promises"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; + +import ts from "typescript"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +import { generateDatabaseTypes } from "../generate"; +import { generateDatabaseOperations } from "../generate-operations"; + +const appkitRoot = path.resolve(import.meta.dirname, "../../../.."); +const sourceRoot = path.join(appkitRoot, "src"); +const sharedRoot = path.resolve(appkitRoot, "../shared/src"); +const builder = path.join(sourceRoot, "database/schema-builder/index"); +const operationsModule = path.join(sourceRoot, "plugins/database/operations"); +const require = createRequire(import.meta.url); +const zodPackage = path.dirname(require.resolve("zod/package.json")); +const nodeTypes = path.dirname( + path.dirname(require.resolve("@types/node/package.json")), +); + +// String, number, and bigint keys; private, nullable, defaulted, and stamped +// columns; a keyless table; and a table whose public update facet is empty. +const schemaSource = ` +import { bigid, bigint, defineSchema, enumColumn, fk, id, integer, jsonb, text, timestamp, varchar } from ${JSON.stringify(builder)}; +export const schema = defineSchema(({ table }) => { + const users = table("users", { + slug: text().primaryKey(), + name: varchar(80).notNull(), + secret: text().private().notNull(), + nickname: text().default("anonymous"), + created_at: timestamp().defaultNow().notNull(), + }); + const posts = table("posts", { + id: id(), + user_slug: fk(() => users.slug).notNull(), + title: text().notNull(), + score: integer(), + total: bigint().notNull(), + status: enumColumn("post_status", ["draft", "live"]).notNull(), + payload: jsonb(), + }); + const ledger = table("ledger", { seq: bigid(), note: text().notNull(), amount: bigint() }); + const events = table("events", { message: text().notNull() }); + const stamps = table("stamps", { id: id(), at: timestamp().defaultNow().notNull() }); + return { users, posts, ledger, events, stamps }; +}); +`; + +const header = ` +import { z } from "zod"; +import { defineOperation, definePolicy, type PolicyContext } from ${JSON.stringify(operationsModule)}; +`; + +/** Every method's default contract, inferred from the entity alone. */ +const defaults = `${header} +export const getPost = defineOperation({ name: "posts.get", entity: "posts", method: "GET", authorize: () => true, execute: ({ input, db }) => db.posts.find(input.id) }); +export const getUser = defineOperation({ name: "users.get", entity: "users", method: "GET", authorize: () => true, execute: ({ input, db }) => db.users.find(input.id) }); +export const getEntry = defineOperation({ name: "ledger.get", entity: "ledger", method: "GET", authorize: () => true, execute: ({ input, db }) => db.ledger.find(input.id) }); +export const createUser = defineOperation({ name: "users.create", entity: "users", method: "POST", authorize: () => true, execute: ({ input, db, actor }) => db.users.create({ ...input, secret: actor.id }) }); +export const createPost = defineOperation({ name: "posts.create", entity: "posts", method: "POST", authorize: () => true, execute: ({ input, db }) => db.posts.create(input) }); +export const logEvent = defineOperation({ name: "events.log", entity: "events", method: "POST", authorize: () => true, execute: ({ input, db }) => db.events.create(input) }); +export const stamp = defineOperation({ name: "stamps.create", entity: "stamps", method: "POST", authorize: () => true, execute: ({ input, db }) => db.stamps.create(input) }); +export const patchPost = defineOperation({ name: "posts.patch", entity: "posts", method: "PATCH", authorize: () => true, execute: ({ input, db }) => db.posts.update(input.id, input.changes) }); +export const patchEntry = defineOperation({ name: "ledger.patch", entity: "ledger", method: "PATCH", authorize: () => true, execute: ({ input, db }) => db.ledger.update(input.id, input.changes) }); +export const replaceUser = defineOperation({ name: "users.replace", entity: "users", method: "PUT", authorize: () => true, execute: ({ input, db }) => db.users.update(input.id, input.values) }); +export const replacePost = defineOperation({ name: "posts.replace", entity: "posts", method: "PUT", authorize: () => true, execute: ({ input, db }) => db.posts.update(input.id, input.values) }); +export const deletePost = defineOperation({ name: "posts.delete", entity: "posts", method: "DELETE", authorize: () => true, execute: ({ input, db }) => db.posts.delete(input.id) }); +export const deleteUser = defineOperation({ name: "users.delete", entity: "users", method: "DELETE", authorize: () => true, execute: ({ input, db }) => db.users.delete(input.id) }); +const ownPost = definePolicy({ + resource: ({ input, db }: PolicyContext<{ id: number }>) => db.posts.find(input.id), + allow: ({ resource, actor }) => resource.user_slug === actor.id, +}); +export const readOwnPost = defineOperation({ name: "posts.readOwn", entity: "posts", method: "GET", authorize: ownPost, execute: ({ resource }) => resource }); +`; + +/** Every construct the Zod renderer supports, on both sides of the wire. */ +const custom = `${header} +const Slug = z.string().min(1).meta({ id: "AppKitOperationFixtureSlug" }); +export const search = defineOperation({ + name: "posts.search", + entity: "posts", + method: "GET", + input: z.object({ + q: z.string().min(1), + tags: z.array(z.enum(["a", "b"])).optional(), + limit: z.int().default(10), + page: z.number().prefault(1), + mode: z.union([z.literal("fast"), z.literal(2), z.literal(true), z.null()]), + cursor: z.string().nullish(), + later: z.string().optional().nullable(), + flags: z.array(z.union([z.string(), z.null()])), + owner: Slug, + reviewer: Slug.optional(), + strict: z.strictObject({ exact: z.boolean() }), + lazy: z.lazy(() => z.object({ leaf: z.string() })), + required: z.string().optional().nonoptional(), + trimmed: z.string().transform((value) => value.trim()), + choice: z.enum({ Low: "low", High: "high" }), + level: z.enum({ One: 1, Two: 2 }), + exact: z.literal(["x", 0, false]), + empty: z.object({}), + email: z.email(), + }), + output: z.object({ + items: z.array(z.object({ id: z.number(), title: z.string().nullable() })).readonly(), + next: z.string().nullable(), + total: z.number().default(0), + filters: z.object({ a: z.string() }).readonly(), + switch: z.stringbool(), + piped: z.string().pipe(z.string().min(1)), + }), + authorize: () => true, + execute: () => ({ items: [], next: null, filters: { a: "a" }, switch: "yes", piped: "p" }), +}); +export const publish = defineOperation({ + name: "posts.publish", + entity: "posts", + method: "POST", + input: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("now") }), + z.object({ kind: z.literal("later"), at: z.iso.datetime() }), + ]), + output: z.xor([z.object({ id: z.number() }), z.object({ queued: z.literal(true) })]), + authorize: () => true, + // Zod output types give TypeScript no literal context, so a literal needs as const. + execute: () => ({ queued: true as const }), +}); +export const inspect = defineOperation({ + name: "posts.inspect", + entity: "posts", + method: "PATCH", + input: z.object({ + id: z.number(), + payload: z.unknown(), + tags: z.record(z.string(), z.array(z.string())), + // Metadata cannot restate a type; the definition decides it. + label: z.string().meta({ type: "number", readOnly: true }), + }), + output: z.object({ + payloads: z.array(z.unknown()), + counts: z.record(z.string(), z.number()).readonly(), + nested: z.record(z.string(), z.object({ at: z.string() })), + }), + authorize: () => true, + execute: () => ({ payloads: [], counts: {}, nested: {} }), +}); +export const maybePost = defineOperation({ + name: "posts.maybe", + entity: "posts", + method: "GET", + output: z.object({ id: z.number() }).nullable(), + authorize: () => true, + execute: () => null, +}); +export const archive = defineOperation({ + name: "posts.archive", + entity: "posts", + method: "DELETE", + output: z.object({ archived: z.boolean() }), + authorize: () => true, + execute: () => ({ archived: true }), +}); +export const resetStamp = defineOperation({ + name: "stamps.reset", + entity: "stamps", + method: "PUT", + input: z.object({ id: z.number(), at: z.iso.datetime() }), + authorize: () => true, + execute: () => null, +}); +export const touch = defineOperation({ + name: "events.touch", + entity: "events", + method: "PATCH", + input: z.object({}), + output: z.object({ touched: z.number() }).readonly(), + authorize: () => true, + execute: () => ({ touched: 1 }), +}); +`; + +const operations: Record = { + "posts.get": "getPost", + "users.get": "getUser", + "ledger.get": "getEntry", + "users.create": "createUser", + "posts.create": "createPost", + "events.log": "logEvent", + "stamps.create": "stamp", + "posts.patch": "patchPost", + "ledger.patch": "patchEntry", + "users.replace": "replaceUser", + "posts.replace": "replacePost", + "posts.delete": "deletePost", + "users.delete": "deleteUser", + "posts.readOwn": "readOwnPost", + "posts.search": "search", + "posts.publish": "publish", + "posts.inspect": "inspect", + "posts.maybe": "maybePost", + "posts.archive": "archive", + "stamps.reset": "resetStamp", + "events.touch": "touch", +}; + +/** The generated entry of every operation equals the server's own wire types. */ +const consumer = ` +import type { OperationWireInput, OperationWireOutput } from "@databricks/appkit/beta"; +import * as defaults from "./server/database/routes/defaults"; +import * as custom from "./server/database/routes/custom"; + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Expect = T; +type Operations = DatabricksAppKitDatabaseOperations; +const server = { ...defaults, ...custom }; + +export type Names = Expect JSON.stringify(name)) + .join(" | ")}>>; +// One line per operation and facet, so a mismatch names its operation. +${Object.entries(operations) + .flatMap(([name, exportName]) => [ + `export type Method_${exportName} = Expect>;`, + `export type Input_${exportName} = Expect>>;`, + `export type Output_${exportName} = Expect>>;`, + ]) + .join("\n")} + +// The harness distinguishes near misses: a bigint key, a nullable column, and a body. +// @ts-expect-error JSON carries a bigint key as a string or safe integer +export type BigKey = Expect>; +// @ts-expect-error a nullable column keeps null +export type Nullable = Expect>; +// @ts-expect-error a delete answers without a body +export type Body = Expect>; +`; + +let root = ""; + +beforeAll(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "appkit-operation-types-")); + const files: Record = { + "config/database/schema.ts": schemaSource, + "server/database/routes/defaults.ts": defaults, + "server/database/routes/custom.ts": custom, + "consumer.ts": consumer, + }; + await fs.mkdir(path.join(root, "node_modules")); + await fs.symlink(zodPackage, path.join(root, "node_modules", "zod"), "dir"); + for (const [file, content] of Object.entries(files)) { + await fs.mkdir(path.dirname(path.join(root, file)), { recursive: true }); + await fs.writeFile(path.join(root, file), content); + } + await generateDatabaseTypes({ + schemaFile: path.join(root, "config/database/schema.ts"), + outFile: path.join(root, "shared/appkit-types/database.d.ts"), + }); + await generateDatabaseOperations({ + appRoot: root, + outFile: path.join(root, "shared/appkit-types/database-operations.d.ts"), + strict: true, + }); +}, 30_000); + +afterAll(() => fs.rm(root, { recursive: true, force: true })); + +/** Every diagnostic of a program over `files`, as `file:line TScode message`. */ +function diagnose( + files: readonly string[], + compilerOptions: Record, +): string[] { + const { options, errors } = ts.parseJsonConfigFileContent( + { compilerOptions }, + ts.sys, + root, + ); + expect(errors).toEqual([]); + const program = ts.createProgram( + files.map((file) => path.join(root, file)), + options, + ); + return ts.getPreEmitDiagnostics(program).map((diagnostic) => { + const file = diagnostic.file + ? path.relative(root, diagnostic.file.fileName) + : ""; + const line = diagnostic.file + ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start ?? 0) + .line + 1 + : 0; + return `${file}:${line} TS${diagnostic.code} ${ts.flattenDiagnosticMessageText(diagnostic.messageText, " ")}`; + }); +} + +const strict = { + strict: true, + noEmit: true, + target: "ES2022", + module: "ESNext", + moduleResolution: "Bundler", + lib: ["ES2022"], +}; + +describe("generated operation contracts", () => { + test("equal the server's OperationWireInput and OperationWireOutput for every operation", () => { + expect( + diagnose( + [ + "shared/appkit-types/database.d.ts", + "shared/appkit-types/database-operations.d.ts", + "server/database/routes.gen.ts", + "consumer.ts", + ], + { + ...strict, + esModuleInterop: true, + resolveJsonModule: true, + skipLibCheck: true, + types: ["node"], + typeRoots: [nodeTypes], + baseUrl: root, + paths: { + "@databricks/appkit": [path.join(sourceRoot, "index.ts")], + "@databricks/appkit/beta": [path.join(sourceRoot, "beta.ts")], + shared: [path.join(sharedRoot, "index.ts")], + "shared/*": [path.join(sharedRoot, "*")], + "@databricks/lakebase": [ + path.resolve(appkitRoot, "../lakebase/src/index.ts"), + ], + zod: [zodPackage], + }, + }, + ), + ).toEqual([]); + }, 120_000); + + test("compile alone in a browser program, without imports or Node types", async () => { + const declaration = await fs.readFile( + path.join(root, "shared/appkit-types/database-operations.d.ts"), + "utf8", + ); + expect(declaration).not.toMatch(/^\s*(?:import|export\s+\*|\/\/\/)/m); + expect(declaration).not.toMatch(/\bany\b/); + expect( + diagnose(["shared/appkit-types/database-operations.d.ts"], { + ...strict, + lib: ["ES2022", "DOM"], + types: [], + skipLibCheck: false, + noResolve: true, + }), + ).toEqual([]); + }, 30_000); +}); diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index e4ea97fd3..3cee48da9 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -914,7 +914,9 @@ export const ANALYTICS_TYPES_FILE = "analytics.d.ts"; export const SERVING_TYPES_FILE = "serving.d.ts"; export const METRIC_TYPES_FILE = "metric-views.d.ts"; export { + DATABASE_OPERATIONS_TYPES_FILE, DATABASE_TYPES_FILE, DatabaseTypegenError, + generateDatabaseOperations, generateDatabaseTypes, } from "./database"; diff --git a/packages/appkit/src/type-generator/vite-plugin.ts b/packages/appkit/src/type-generator/vite-plugin.ts index 5f1a28286..2c9e7ebe8 100644 --- a/packages/appkit/src/type-generator/vite-plugin.ts +++ b/packages/appkit/src/type-generator/vite-plugin.ts @@ -6,8 +6,10 @@ import type { Plugin } from "vite"; import { METRIC_CONFIG_FILE } from "../../../shared/src/schemas/metric-fqn"; import { createLogger } from "../logging/logger"; import { + DATABASE_OPERATIONS_TYPES_FILE, DATABASE_TYPES_FILE, DatabaseTypegenError, + generateDatabaseOperations, generateDatabaseTypes, } from "./database"; import { @@ -52,7 +54,7 @@ interface AppKitTypesPluginOptions { * Folders to watch for changes. Defaults to `config/queries` and * `config/metric-views`. When overridden, include a `queries` folder and/or a * `metric-views` folder — they are resolved by their trailing path segment. - * Database schema sources are watched independently. + * Database schema and operation sources are watched independently. */ watchFolders?: string[]; } @@ -72,7 +74,9 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { // `watchFolders` ordering (which used to assume queries was `watchFolders[0]`). let queryFolder: string | undefined; let metricViewsFolder: string | undefined; + let appRoot: string; let databaseFolder: string; + let routesFolder: string; // Single-flight state for runGenerate(). `inFlight` is the promise of the // currently-running drain (null when idle); `queued` records that a trigger @@ -126,10 +130,7 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { // Database declarations need no warehouse. Generating them last keeps the // query and metric-view outputs independent of a schema failure. - await generateDatabaseTypes({ - schemaFile: path.join(databaseFolder, "schema.ts"), - outFile: path.join(path.dirname(outFile), DATABASE_TYPES_FILE), - }); + await generateDatabase(); } catch (error) { // TypegenSyntaxError / TypegenFatalError carry a complete, actionable // report in their message. Their stack frames and attached query arrays @@ -154,6 +155,36 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { } } + /** + * Schema declarations, then operation contracts. Both always run, so a + * schema failure also neutralizes the operations; the first failure wins. + * A production build is strict: an operation without authorization fails it. + */ + async function generateDatabase(): Promise { + const typesDir = path.dirname(outFile); + const failures: unknown[] = []; + for (const step of [ + () => + generateDatabaseTypes({ + schemaFile: path.join(databaseFolder, "schema.ts"), + outFile: path.join(typesDir, DATABASE_TYPES_FILE), + }), + () => + generateDatabaseOperations({ + appRoot, + outFile: path.join(typesDir, DATABASE_OPERATIONS_TYPES_FILE), + strict: process.env.NODE_ENV === "production", + }), + ]) { + try { + await step(); + } catch (error) { + failures.push(error); + } + } + if (failures.length > 0) throw failures[0]; + } + /** * Whether this project has anything for the warehouse-backed passes to read. * A database-only project activates the plugin without them, so the query and @@ -332,12 +363,16 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { options?.outFile ?? `shared/${TYPES_DIR}/${ANALYTICS_TYPES_FILE}`, ), ); - // A declared schema needs no warehouse, and an already-generated - // declaration must still be neutralized after its schema is deleted. - const hasDatabase = - existsSync( - path.join(process.cwd(), "config", "database", "schema.ts"), - ) || existsSync(path.join(typesDir, DATABASE_TYPES_FILE)); + // A declared schema or operation needs no warehouse, and an + // already-generated output must still be neutralized after its source + // is deleted. + const hasDatabase = [ + path.join(process.cwd(), "config", "database", "schema.ts"), + path.join(process.cwd(), "server", "database", "routes"), + path.join(process.cwd(), "server", "database", "routes.gen.ts"), + path.join(typesDir, DATABASE_TYPES_FILE), + path.join(typesDir, DATABASE_OPERATIONS_TYPES_FILE), + ].some((file) => existsSync(file)); if (!warehouseId && !hasDatabase) { logger.debug("Warehouse ID not found. Skipping type generation."); @@ -384,7 +419,9 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { "config", "metric-views", ); - databaseFolder = path.join(process.cwd(), "config", "database"); + appRoot = process.cwd(); + databaseFolder = path.join(appRoot, "config", "database"); + routesFolder = path.join(appRoot, "server", "database", "routes"); watchFolders = options?.watchFolders ?? [ defaultQueryFolder, defaultMetricViewsFolder, @@ -423,15 +460,18 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { configureServer(server) { server.watcher.add(watchFolders); - server.watcher.add(databaseFolder); + server.watcher.add([databaseFolder, routesFolder]); + // A route module may import helpers beside it, so any source under + // either folder regenerates; `routes.gen.ts` itself sits outside both. const isDatabaseSource = (changedFile: string): boolean => { const normalizedFile = path.resolve(changedFile); - const relative = path.relative(databaseFolder, normalizedFile); return ( - !relative.startsWith("..") && - !path.isAbsolute(relative) && - /\.(?:[cm]?ts|tsx)$/.test(normalizedFile) + /\.(?:[cm]?ts|tsx)$/.test(normalizedFile) && + [databaseFolder, routesFolder].some((folder) => { + const relative = path.relative(folder, normalizedFile); + return !relative.startsWith("..") && !path.isAbsolute(relative); + }) ); }; diff --git a/packages/shared/src/cli/commands/generate-types.test.ts b/packages/shared/src/cli/commands/generate-types.test.ts index 9223d960c..dc7a6bebd 100644 --- a/packages/shared/src/cli/commands/generate-types.test.ts +++ b/packages/shared/src/cli/commands/generate-types.test.ts @@ -17,6 +17,7 @@ import { // created in a hoisted block too (plain top-level consts would be in the TDZ when // the hoisted factory runs). const { + generateDatabaseOperations, generateDatabaseTypes, generateFromEntryPoint, generateServingTypes, @@ -32,6 +33,7 @@ const { const lockPathOf = (root: string) => nodePath.join(root, "node_modules", ".databricks", "appkit", "worker.lock"); return { + generateDatabaseOperations: vi.fn(async () => {}), generateDatabaseTypes: vi.fn(async () => {}), generateFromEntryPoint: vi.fn(async () => {}), generateServingTypes: vi.fn(async () => {}), @@ -51,7 +53,9 @@ const { // command's `await import("@databricks/appkit/type-generator")` resolves to spies // and never touches a warehouse. vi.mock("@databricks/appkit/type-generator", () => ({ + DATABASE_OPERATIONS_TYPES_FILE: "database-operations.d.ts", DATABASE_TYPES_FILE: "database.d.ts", + generateDatabaseOperations, generateDatabaseTypes, generateFromEntryPoint, generateServingTypes, @@ -192,6 +196,25 @@ describe("generate-types foreground spawn orchestration", () => { expect(generateFromEntryPoint).not.toHaveBeenCalled(); }); + test("generates the database operations registry and contracts, strictly in production", async () => { + delete process.env.DATABRICKS_WAREHOUSE_ID; + const outFile = path.join(tmpRoot, "shared/appkit-types/analytics.d.ts"); + const operations = { + appRoot: tmpRoot, + outFile: path.join(path.dirname(outFile), "database-operations.d.ts"), + }; + vi.stubEnv("NODE_ENV", "development"); + await runCli([tmpRoot, outFile]); + vi.stubEnv("NODE_ENV", "production"); + await runCli([tmpRoot, outFile]); + vi.unstubAllEnvs(); + + expect(generateDatabaseOperations.mock.calls).toEqual([ + [{ ...operations, strict: false }], + [{ ...operations, strict: true }], + ]); + }); + test("lock already held (fresh): does NOT spawn, foreground still resolves", async () => { acquireSpawnLock.mockReturnValue(false); diff --git a/packages/shared/src/cli/commands/generate-types.ts b/packages/shared/src/cli/commands/generate-types.ts index cbabbd097..594c27337 100644 --- a/packages/shared/src/cli/commands/generate-types.ts +++ b/packages/shared/src/cli/commands/generate-types.ts @@ -132,6 +132,21 @@ async function runGenerateTypes( }); console.log(`Generated database types: ${databaseOutFile}`); } + + // Generate the routes registry the server loads and the operation + // contracts the UI reads. A production run is strict, as in the Vite plugin. + const operationsOutFile = path.join( + path.dirname(resolvedOutFile), + typeGen.DATABASE_OPERATIONS_TYPES_FILE, + ); + await typeGen.generateDatabaseOperations({ + appRoot: resolvedRootDir, + outFile: operationsOutFile, + strict: process.env.NODE_ENV === "production", + }); + if (fs.existsSync(path.join(resolvedRootDir, "server/database/routes"))) { + console.log(`Generated database operations: ${operationsOutFile}`); + } } catch (error) { if ( error instanceof Error && diff --git a/packages/shared/src/cli/commands/type-generator.d.ts b/packages/shared/src/cli/commands/type-generator.d.ts index dfc9f3eb9..27d664617 100644 --- a/packages/shared/src/cli/commands/type-generator.d.ts +++ b/packages/shared/src/cli/commands/type-generator.d.ts @@ -9,12 +9,19 @@ */ declare module "@databricks/appkit/type-generator" { export const DATABASE_TYPES_FILE: "database.d.ts"; + export const DATABASE_OPERATIONS_TYPES_FILE: "database-operations.d.ts"; export function generateDatabaseTypes(options: { schemaFile: string; outFile: string; }): Promise; + export function generateDatabaseOperations(options: { + appRoot: string; + outFile: string; + strict?: boolean; + }): Promise; + export function generateFromEntryPoint(options: { queryFolder?: string; metricViewsFolder?: string; diff --git a/tools/verify-database-bundle.ts b/tools/verify-database-bundle.ts new file mode 100644 index 000000000..31dfaed50 --- /dev/null +++ b/tools/verify-database-bundle.ts @@ -0,0 +1,149 @@ +/** + * Prove a built AppKit server loads its database operations without source. + * + * Builds a fixture app with the template's `appkitServerConfig()` preset, + * moves every `.ts` source away, and runs the output under plain Node. The + * fixture's entry loads the generated `databaseRoutes` registry, its schema, + * and each operation module. No database or workspace is contacted. + * + * Run after `pnpm build`: `pnpm exec tsx tools/verify-database-bundle.ts` + */ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +import { generateDatabaseOperations } from "../packages/appkit/src/type-generator/database/generate-operations"; + +const run = promisify(execFile); +const repo = path.resolve(import.meta.dirname, ".."); +const appkit = path.join(repo, "packages/appkit"); +const zod = path.dirname( + createRequire(path.join(appkit, "package.json")).resolve("zod/package.json"), +); +const tsdown = path.join(repo, "node_modules/.bin/tsdown"); + +const fixture: Record = { + "package.json": JSON.stringify({ name: "bundle-fixture", type: "module" }), + "tsdown.server.config.ts": `import { appkitServerConfig } from "@databricks/appkit/tsdown"; +export default appkitServerConfig(); +`, + "config/database/schema.ts": `import { defineSchema, id, text } from "@databricks/appkit/beta"; +export const schema = defineSchema(({ table }) => ({ + notes: table("notes", { id: id(), body: text().notNull() }), +})); +`, + "server/database/routes/notes.ts": `import { defineOperation } from "@databricks/appkit/beta"; +import { z } from "zod"; +export const getNote = defineOperation({ + name: "notes.get", + entity: "notes", + method: "GET", + authorize: ({ actor }) => actor.id !== "", + execute: ({ input, db }) => db.notes.find(input.id), +}); +export const searchNotes = defineOperation({ + name: "notes.search", + entity: "notes", + method: "GET", + input: z.object({ q: z.string() }), + output: z.array(z.object({ id: z.number(), body: z.string() })), + authorize: () => true, + execute: ({ input, db }) => db.notes.where({ body: { ilike: input.q } }).all(), +}); +`, + "server/database/routes/stats.ts": `import { defineOperation } from "@databricks/appkit/beta"; +import { z } from "zod"; +export const countNotes = defineOperation({ + name: "notes.count", + entity: "notes", + method: "GET", + input: z.object({}), + output: z.number(), + authorize: () => true, + execute: ({ db }) => db.notes.count(), +}); +`, + "server/server.ts": `import { databaseRoutes } from "./database/routes.gen"; +const { schema } = await databaseRoutes.schema(); +const operations: string[] = []; +for (const module of databaseRoutes.modules) { + for (const value of Object.values(await module.load())) { + operations.push(\`\${String(Reflect.get(Object(value), "name"))} \${String(Reflect.get(Object(value), "method"))}\`); + } +} +console.log(JSON.stringify({ tables: Object.keys(schema.$tables), operations })); +`, +}; + +async function main(): Promise { + await fs.access(path.join(appkit, "dist/beta.js")).catch(() => { + throw new Error("Build first: packages/appkit/dist/beta.js is missing."); + }); + const root = await fs.mkdtemp( + path.join(os.tmpdir(), "appkit-database-bundle-"), + ); + try { + for (const [file, content] of Object.entries(fixture)) { + await fs.mkdir(path.dirname(path.join(root, file)), { recursive: true }); + await fs.writeFile(path.join(root, file), content); + } + await fs.mkdir(path.join(root, "node_modules/@databricks"), { + recursive: true, + }); + await fs.symlink( + appkit, + path.join(root, "node_modules/@databricks/appkit"), + ); + await fs.symlink(zod, path.join(root, "node_modules/zod")); + + await generateDatabaseOperations({ + appRoot: root, + outFile: path.join(root, "shared/appkit-types/database-operations.d.ts"), + strict: true, + }); + await run(tsdown, ["-c", "tsdown.server.config.ts"], { cwd: root }); + + // Production must not need any TypeScript source. + const removed = path.join(root, ".removed-sources"); + await fs.mkdir(removed); + for (const directory of ["server", "config", "shared"]) { + await fs.rename( + path.join(root, directory), + path.join(removed, directory), + ); + } + const built = await fs.readdir(path.join(root, "dist"), { + recursive: true, + }); + const sources = built.filter((file) => /\.(?:c|m)?ts$/.test(file)); + if (sources.length > 0) { + throw new Error( + `The build emitted TypeScript sources: ${sources.join(", ")}`, + ); + } + + const { stdout } = await run(process.execPath, ["dist/server.js"], { + cwd: root, + }); + const result: unknown = JSON.parse(stdout.trim()); + const expected = { + tables: ["notes"], + operations: ["notes.get GET", "notes.search GET", "notes.count GET"], + }; + if (JSON.stringify(result) !== JSON.stringify(expected)) { + throw new Error( + `Unexpected output ${JSON.stringify(result)}; expected ${JSON.stringify(expected)}`, + ); + } + console.log( + `Source-less bundle loaded ${expected.operations.length} operations from ${built.length} built files.`, + ); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +await main();