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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
223 changes: 223 additions & 0 deletions packages/appkit/src/type-generator/database/generate-operations.ts
Original file line number Diff line number Diff line change
@@ -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<LocatedDeclaration[]> {
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<OperationContract[]> {
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<void> {
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."]);
}
}
54 changes: 36 additions & 18 deletions packages/appkit/src/type-generator/database/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -100,7 +101,10 @@ declare global {
}

/** Atomically replace generated output only when its content changes. */
async function writeIfChanged(outFile: string, content: string): Promise<void> {
export async function writeIfChanged(
outFile: string,
content: string,
): Promise<void> {
if (
fsSync.existsSync(outFile) &&
(await fs.readFile(outFile, "utf8")) === content
Expand All @@ -125,6 +129,32 @@ async function importSchema(schemaFile: string): Promise<unknown> {
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<Schema> {
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,
Expand All @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions packages/appkit/src/type-generator/database/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ export {
DatabaseTypegenError,
generateDatabaseTypes,
} from "./generate";
export {
DATABASE_OPERATIONS_TYPES_FILE,
generateDatabaseOperations,
} from "./generate-operations";
Loading
Loading