From e92eee9b40065f8ac66f2e659d5d720613adc066 Mon Sep 17 00:00:00 2001 From: ditadi Date: Mon, 12 Oct 2026 13:33:40 +0100 Subject: [PATCH 1/4] feat(appkit): add appkit db migrate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `appkit db migrate` generates a migration from config/database/schema.ts with drizzle-kit, regenerates shared/appkit-types/database.d.ts, and applies pending migrations to the branch .env points at. It applies only to a branch created for this developer (dev-…) or this checkout (session-…); on any other branch, including the project default and any branch databricks.yml deploys to, it generates and says why nothing was applied. It connects only after the Lakebase API confirms that PGHOST is a host of LAKEBASE_ENDPOINT. The logic lives in a new @databricks/appkit/database-tooling entry point that the shared CLI loads lazily, so the runtime never imports drizzle-kit. drizzle-kit is an optional peer dependency: apps that generate migrations install it as a dev dependency, and the command says how when it is missing. `appkit db` stays hidden from --help until the database plugin is offered by `databricks apps init`. Signed-off-by: ditadi --- knip.json | 3 +- packages/appkit/package.json | 11 + .../src/connectors/lakebase/endpoint-host.ts | 2 +- .../appkit/src/database/tooling/context.ts | 122 ++++ .../src/database/tooling/drizzle-exports.ts | 24 + packages/appkit/src/database/tooling/guard.ts | 84 +++ .../appkit/src/database/tooling/identity.ts | 29 + packages/appkit/src/database/tooling/index.ts | 7 + .../src/database/tooling/lakebase-api.ts | 104 +++ .../appkit/src/database/tooling/migrate.ts | 127 ++++ .../appkit/src/database/tooling/migrations.ts | 262 ++++++++ .../appkit/src/database/tooling/naming.ts | 48 ++ .../appkit/src/database/tooling/targets.ts | 115 ++++ .../database/tooling/tests/context.test.ts | 140 +++++ .../tooling/tests/drizzle-exports.test.ts | 47 ++ .../tests/drizzle-kit-dependency.test.ts | 104 +++ .../src/database/tooling/tests/fakes.ts | 122 ++++ .../src/database/tooling/tests/guard.test.ts | 167 +++++ .../database/tooling/tests/identity.test.ts | 32 + .../tooling/tests/lakebase-api.test.ts | 87 +++ .../database/tooling/tests/migrate.test.ts | 253 ++++++++ .../database/tooling/tests/migrations.test.ts | 343 ++++++++++ .../src/database/tooling/tests/naming.test.ts | 39 ++ .../database/tooling/tests/targets.test.ts | 111 ++++ .../src/plugins/database/load-schema.ts | 14 +- .../src/plugins/database/schema-check.ts | 2 +- packages/appkit/tsdown.config.ts | 1 + .../src/cli/commands/db/database-tooling.d.ts | 39 ++ packages/shared/src/cli/commands/db/index.ts | 11 + .../src/cli/commands/db/migrate.test.ts | 152 +++++ .../shared/src/cli/commands/db/migrate.ts | 31 + packages/shared/src/cli/commands/db/output.ts | 68 ++ packages/shared/src/cli/index.ts | 3 + pnpm-lock.yaml | 590 +++++++++++++++++- 34 files changed, 3267 insertions(+), 27 deletions(-) create mode 100644 packages/appkit/src/database/tooling/context.ts create mode 100644 packages/appkit/src/database/tooling/drizzle-exports.ts create mode 100644 packages/appkit/src/database/tooling/guard.ts create mode 100644 packages/appkit/src/database/tooling/identity.ts create mode 100644 packages/appkit/src/database/tooling/index.ts create mode 100644 packages/appkit/src/database/tooling/lakebase-api.ts create mode 100644 packages/appkit/src/database/tooling/migrate.ts create mode 100644 packages/appkit/src/database/tooling/migrations.ts create mode 100644 packages/appkit/src/database/tooling/naming.ts create mode 100644 packages/appkit/src/database/tooling/targets.ts create mode 100644 packages/appkit/src/database/tooling/tests/context.test.ts create mode 100644 packages/appkit/src/database/tooling/tests/drizzle-exports.test.ts create mode 100644 packages/appkit/src/database/tooling/tests/drizzle-kit-dependency.test.ts create mode 100644 packages/appkit/src/database/tooling/tests/fakes.ts create mode 100644 packages/appkit/src/database/tooling/tests/guard.test.ts create mode 100644 packages/appkit/src/database/tooling/tests/identity.test.ts create mode 100644 packages/appkit/src/database/tooling/tests/lakebase-api.test.ts create mode 100644 packages/appkit/src/database/tooling/tests/migrate.test.ts create mode 100644 packages/appkit/src/database/tooling/tests/migrations.test.ts create mode 100644 packages/appkit/src/database/tooling/tests/naming.test.ts create mode 100644 packages/appkit/src/database/tooling/tests/targets.test.ts create mode 100644 packages/shared/src/cli/commands/db/database-tooling.d.ts create mode 100644 packages/shared/src/cli/commands/db/index.ts create mode 100644 packages/shared/src/cli/commands/db/migrate.test.ts create mode 100644 packages/shared/src/cli/commands/db/migrate.ts create mode 100644 packages/shared/src/cli/commands/db/output.ts diff --git a/knip.json b/knip.json index 02320c52b..722d1b550 100644 --- a/knip.json +++ b/knip.json @@ -12,7 +12,8 @@ "vitest", "@databricks/sdk-experimental", "@mlflow/core", - "autoevals" + "autoevals", + "drizzle-kit" ] }, "packages/appkit-ui": { diff --git a/packages/appkit/package.json b/packages/appkit/package.json index 0c81d8824..99d9abdba 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -42,6 +42,11 @@ "development": "./src/type-generator/index.ts", "default": "./dist/type-generator/index.js" }, + "./database-tooling": { + "types": "./dist/database/tooling/index.d.ts", + "development": "./src/database/tooling/index.ts", + "default": "./dist/database/tooling/index.js" + }, "./testing": { "types": "./dist/testing/index.d.ts", "development": "./src/testing/index.ts", @@ -108,6 +113,7 @@ "peerDependencies": { "@mlflow/core": "0.4.0", "autoevals": "0.3.0", + "drizzle-kit": "0.31.10", "vitest": ">=3" }, "peerDependenciesMeta": { @@ -117,6 +123,9 @@ "autoevals": { "optional": true }, + "drizzle-kit": { + "optional": true + }, "vitest": { "optional": true } @@ -131,6 +140,7 @@ "@types/ws": "8.18.1", "@vitejs/plugin-react": "5.1.1", "autoevals": "0.3.0", + "drizzle-kit": "0.31.10", "vitest": "3.2.4" }, "overrides": { @@ -143,6 +153,7 @@ "./beta": "./dist/beta.js", "./dist/shared/src/plugin": "./dist/shared/src/plugin.d.ts", "./type-generator": "./dist/type-generator/index.js", + "./database-tooling": "./dist/database/tooling/index.js", "./testing": { "types": "./dist/testing/index.d.ts", "default": "./dist/testing/index.js" diff --git a/packages/appkit/src/connectors/lakebase/endpoint-host.ts b/packages/appkit/src/connectors/lakebase/endpoint-host.ts index a3020901d..515d20c48 100644 --- a/packages/appkit/src/connectors/lakebase/endpoint-host.ts +++ b/packages/appkit/src/connectors/lakebase/endpoint-host.ts @@ -134,7 +134,7 @@ async function compareHosts( } /** Read every host the endpoint serves, read-write and read-only. */ -function endpointHosts(response: unknown): string[] { +export function endpointHosts(response: unknown): string[] { if (!response || typeof response !== "object") return []; const status = Reflect.get(response, "status"); if (!status || typeof status !== "object") return []; diff --git a/packages/appkit/src/database/tooling/context.ts b/packages/appkit/src/database/tooling/context.ts new file mode 100644 index 000000000..c8850dea0 --- /dev/null +++ b/packages/appkit/src/database/tooling/context.ts @@ -0,0 +1,122 @@ +import type { LakebasePoolConfig } from "@databricks/lakebase"; +import type { Pool } from "pg"; + +import { initializeLakebasePool } from "../../connectors/lakebase"; +import { endpointHosts } from "../../connectors/lakebase/endpoint-host"; +import { getClientOptions } from "../../context/client-options"; +import { createWorkspaceClient } from "../../workspace-client"; +import { LakebaseApi, type LakebaseEndpoint } from "./lakebase-api"; + +/** One REST call through the SDK's unified-auth client. */ +export interface ToolingRequest { + path: string; + method: "GET" | "POST" | "PATCH" | "DELETE"; + query?: Record; + headers: Headers; + raw: boolean; + payload?: unknown; +} + +/** + * The slice of the SDK client the tooling uses. It is also everything the + * Lakebase pool needs: token refresh calls `apiClient.request` and the user + * lookup calls `currentUser.me`. + */ +export interface ToolingWorkspaceClient { + readonly apiClient: { request(options: ToolingRequest): Promise }; + readonly currentUser: { me(): Promise<{ userName?: string }> }; +} + +/** Where a command runs: the app root, its environment and its Databricks identity. */ +export interface DbContext { + readonly root: string; + readonly env: NodeJS.ProcessEnv; + /** Lazy; the profile comes from DATABRICKS_CONFIG_PROFILE. */ + workspace(): ToolingWorkspaceClient; + /** Lazy Lakebase REST client over workspace(). */ + api(): LakebaseApi; +} + +export function createDbContext( + options: { root?: string; env?: NodeJS.ProcessEnv } = {}, +): DbContext { + const root = options.root ?? process.cwd(); + const env = options.env ?? process.env; + let client: ToolingWorkspaceClient | undefined; + let api: LakebaseApi | undefined; + const ctx: DbContext = { + root, + env, + workspace() { + if (!client) { + const profile = env.DATABRICKS_CONFIG_PROFILE; + client = createWorkspaceClient({ + ...(profile ? { profile } : {}), + clientOptions: getClientOptions(), + }).toLegacyWorkspaceClient(); + } + return client; + }, + api() { + api ??= new LakebaseApi(ctx.workspace()); + return api; + }, + }; + return ctx; +} + +/** + * The guard checks the branch named by LAKEBASE_ENDPOINT, but the pool connects + * to PGHOST. The runtime only warns when it cannot verify that PGHOST serves that + * endpoint; a command that writes refuses instead. + */ +async function assertHostServesEndpoint( + ctx: DbContext, + endpoint: string, + host: string, +): Promise { + let found: LakebaseEndpoint | null; + try { + found = await ctx.api().getEndpoint(endpoint); + } catch (error) { + throw new Error( + `Could not verify that PGHOST ${host} serves LAKEBASE_ENDPOINT ${endpoint}, so it did not connect.`, + { cause: error }, + ); + } + if (!found) { + throw new Error( + `LAKEBASE_ENDPOINT ${endpoint} does not exist, so it did not connect. Run \`appkit db init\` to point .env at your branch.`, + ); + } + const hosts = endpointHosts(found); + if (!hosts.includes(host.toLowerCase())) { + throw new Error( + `PGHOST ${host} is not a host of LAKEBASE_ENDPOINT ${endpoint} (${hosts.length > 0 ? `expected ${hosts.join(" or ")}` : "it has no hosts"}), so it did not connect. Run \`appkit db init\` to rewrite .env.`, + ); + } +} + +/** null when PGHOST / PGDATABASE / LAKEBASE_ENDPOINT are not configured. */ +export async function openBranchPool(ctx: DbContext): Promise { + const { PGHOST, PGDATABASE, LAKEBASE_ENDPOINT } = ctx.env; + if (!PGHOST || !PGDATABASE || !LAKEBASE_ENDPOINT) return null; + await assertHostServesEndpoint(ctx, LAKEBASE_ENDPOINT, PGHOST); + const port = ctx.env.PGPORT ? Number(ctx.env.PGPORT) : undefined; + const sslMode = ctx.env.PGSSLMODE as LakebasePoolConfig["sslMode"]; + return initializeLakebasePool({ + host: PGHOST, + database: PGDATABASE, + endpoint: LAKEBASE_ENDPOINT, + user: ctx.env.PGUSER || undefined, + ...(port ? { port } : {}), + ...(sslMode ? { sslMode } : {}), + // Only apiClient.request and currentUser.me are used (see ToolingWorkspaceClient). + workspaceClient: ctx.workspace() as unknown as NonNullable< + LakebasePoolConfig["workspaceClient"] + >, + // One-shot CLI pools: a small cap, and no lingering idle connections. + max: 2, + idleTimeoutMillis: 1_000, + }); +} diff --git a/packages/appkit/src/database/tooling/drizzle-exports.ts b/packages/appkit/src/database/tooling/drizzle-exports.ts new file mode 100644 index 000000000..038d0fa84 --- /dev/null +++ b/packages/appkit/src/database/tooling/drizzle-exports.ts @@ -0,0 +1,24 @@ +import { getTableColumns, is, type Table } from "drizzle-orm"; +import { PgEnumColumn } from "drizzle-orm/pg-core"; + +import type { Schema } from "../schema-builder"; + +/** + * The "module exports" drizzle-kit snapshots: every engine table plus every + * enum a column references. The pgSchema object is never included, so + * generated SQL contains no CREATE SCHEMA; the migrator creates the schema as + * the identity that applies the migrations, which then owns it. + */ +export function drizzleKitImports(schema: Schema): Record { + const imports: Record = Object.create(null); + for (const [name, table] of Object.entries(schema.$engine)) { + imports[`table:${name}`] = table; + const columns = getTableColumns(table as unknown as Table); + for (const column of Object.values(columns)) { + if (is(column, PgEnumColumn)) { + imports[`enum:${column.enum.enumName}`] = column.enum; + } + } + } + return imports; +} diff --git a/packages/appkit/src/database/tooling/guard.ts b/packages/appkit/src/database/tooling/guard.ts new file mode 100644 index 000000000..f135e8258 --- /dev/null +++ b/packages/appkit/src/database/tooling/guard.ts @@ -0,0 +1,84 @@ +import type { DbContext } from "./context"; +import { currentIdentity } from "./identity"; +import type { LakebaseBranch } from "./lakebase-api"; +import { devBranchId, sessionBranchId } from "./naming"; +import { + parseResourceName, + readDeployBranches, + readLocalTarget, + resourceId, +} from "./targets"; + +// Commands that write to a branch run only on branches `appkit db init` +// created for this developer (dev-…) or this checkout (session-…). + +interface BranchVerdict { + readonly appkitOwned: boolean; + /** Why the branch is not AppKit-owned. */ + readonly reason?: string; +} + +export function classifyBranch(input: { + branch: LakebaseBranch; + userName: string; + sessionId: string | null; + /** Every branch databricks.yml deploys to; the deployed app migrates those itself. */ + deployBranches: readonly string[]; +}): BranchVerdict { + const { branch, userName, sessionId, deployBranches } = input; + if (branch.status?.default) { + return { appkitOwned: false, reason: "it is the project's default branch" }; + } + if (branch.status?.is_protected) { + return { appkitOwned: false, reason: "it is a protected branch" }; + } + if (deployBranches.includes(branch.name)) { + return { + appkitOwned: false, + reason: "it is a branch databricks.yml deploys to", + }; + } + const id = resourceId(branch.name); + if (id === devBranchId(userName)) return { appkitOwned: true }; + if (sessionId && id === sessionBranchId(sessionId)) { + return { appkitOwned: true }; + } + return { + appkitOwned: false, + reason: `it is not your dev branch (${devBranchId(userName)})${ + sessionId + ? ` or this checkout's session branch (${sessionBranchId(sessionId)})` + : "" + }`, + }; +} + +interface BranchCheck { + readonly branch: LakebaseBranch; + readonly verdict: BranchVerdict; +} + +/** Classify the `.env` branch through the API (no database connection); null when none is configured. */ +export async function checkAppkitBranch( + ctx: DbContext, +): Promise { + const local = readLocalTarget(ctx.env); + const name = parseResourceName(local.endpoint ?? undefined)?.branch; + if (!name) return null; + const branch = await ctx.api().getBranch(name); + if (!branch) { + throw new Error( + `The .env branch ${name} does not exist (deleted or expired). Run \`appkit db init\` to create or pick one.`, + ); + } + const { userName } = await currentIdentity(ctx); + return { + branch, + verdict: classifyBranch({ + branch, + userName, + sessionId: local.session, + deployBranches: readDeployBranches(ctx.root), + }), + }; +} diff --git a/packages/appkit/src/database/tooling/identity.ts b/packages/appkit/src/database/tooling/identity.ts new file mode 100644 index 000000000..3b539abc6 --- /dev/null +++ b/packages/appkit/src/database/tooling/identity.ts @@ -0,0 +1,29 @@ +import type { DbContext } from "./context"; + +/** Who runs the command: the Postgres role name is the user name (email or SP client ID). */ +interface Identity { + readonly userName: string; +} + +const identities = new WeakMap>(); + +/** currentUser.me(), once per context. */ +export function currentIdentity(ctx: DbContext): Promise { + let identity = identities.get(ctx); + if (!identity) { + identity = ctx + .workspace() + .currentUser.me() + .then(({ userName }) => { + if (!userName) { + throw new Error( + "Could not determine the current Databricks user (currentUser.me returned no userName). Check DATABRICKS_CONFIG_PROFILE.", + ); + } + return { userName }; + }); + identity.catch(() => identities.delete(ctx)); + identities.set(ctx, identity); + } + return identity; +} diff --git a/packages/appkit/src/database/tooling/index.ts b/packages/appkit/src/database/tooling/index.ts new file mode 100644 index 000000000..7167ac662 --- /dev/null +++ b/packages/appkit/src/database/tooling/index.ts @@ -0,0 +1,7 @@ +/** + * `@databricks/appkit/database-tooling` — CLI-only logic behind `appkit db`. + * Loaded lazily by the shared CLI; it may import drizzle-kit, so the runtime + * plugin never imports it. + */ +export { createDbContext, type DbContext } from "./context"; +export { type MigrateOptions, type MigrateReport, runMigrate } from "./migrate"; diff --git a/packages/appkit/src/database/tooling/lakebase-api.ts b/packages/appkit/src/database/tooling/lakebase-api.ts new file mode 100644 index 000000000..1bde6df9d --- /dev/null +++ b/packages/appkit/src/database/tooling/lakebase-api.ts @@ -0,0 +1,104 @@ +import type { ToolingRequest, ToolingWorkspaceClient } from "./context"; + +// Lakebase Postgres REST API through the SDK's unified-auth client. Paths, +// query parameters and bodies mirror the Go SDK (service/postgres/impl.go). + +export interface LakebaseBranch { + name: string; + status?: { + default?: boolean; + is_protected?: boolean; + current_state?: string; + expire_time?: string; + source_branch?: string; + }; +} + +export interface LakebaseEndpoint { + name: string; + status?: { hosts?: { host?: string } }; +} + +export class LakebaseApiError extends Error { + /** HTTP status of the failing call. */ + readonly statusCode?: number; + /** What was being done, e.g. "create branch projects/p/branches/b". */ + readonly operation: string; + + constructor( + operation: string, + message: string, + options: { statusCode?: number; cause?: unknown } = {}, + ) { + super(`Lakebase: ${operation} failed: ${message}`, { + cause: options.cause, + }); + this.name = "LakebaseApiError"; + this.operation = operation; + this.statusCode = options.statusCode; + } +} + +const API = "/api/2.0/postgres"; + +function statusCodeOf(error: unknown): number | undefined { + const code = (error as { statusCode?: unknown } | undefined)?.statusCode; + return typeof code === "number" ? code : undefined; +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export class LakebaseApi { + constructor( + private readonly client: Pick, + ) {} + + getBranch(name: string): Promise { + return this.getOrNull(`get branch ${name}`, name); + } + + getEndpoint(name: string): Promise { + return this.getOrNull(`get endpoint ${name}`, name); + } + + private async call( + operation: string, + request: Omit, + ): Promise { + const headers = new Headers({ Accept: "application/json" }); + if (request.payload !== undefined) { + headers.set("Content-Type", "application/json"); + } + try { + return (await this.client.apiClient.request({ + ...request, + headers, + raw: false, + })) as T; + } catch (error) { + throw new LakebaseApiError(operation, messageOf(error), { + statusCode: statusCodeOf(error), + cause: error, + }); + } + } + + private async getOrNull( + operation: string, + name: string, + ): Promise { + try { + return await this.call(operation, { + path: `${API}/${name}`, + method: "GET", + }); + } catch (error) { + if (error instanceof LakebaseApiError && error.statusCode === 404) { + return null; + } + throw error; + } + } +} diff --git a/packages/appkit/src/database/tooling/migrate.ts b/packages/appkit/src/database/tooling/migrate.ts new file mode 100644 index 000000000..d0be9ec6d --- /dev/null +++ b/packages/appkit/src/database/tooling/migrate.ts @@ -0,0 +1,127 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { loadDefaultDatabaseSchema } from "../../plugins/database/load-schema"; +import { + DATABASE_TYPES_FILE, + generateDatabaseTypes, +} from "../../type-generator/database"; +import { applyPendingMigrations } from "../migrations/apply"; +import { migrationsFolder, readJournal } from "../migrations/paths"; +import { type DbContext, openBranchPool } from "./context"; +import { checkAppkitBranch } from "./guard"; +import { type GeneratedMigration, generateMigrationFiles } from "./migrations"; +import { resourceId } from "./targets"; + +/** Same application-relative conventions as the plugin and `appkit generate-types`. */ +const SCHEMA_FILE = "config/database/schema.ts"; + +/** Whether the checkout has a config/database/schema.ts yet. */ +function hasSchemaFile(root: string): boolean { + return fs.existsSync(path.join(root, SCHEMA_FILE)); +} + +/** Without schema.ts, point to introspect instead of a load error. */ +function requireSchemaFile(root: string): void { + if (hasSchemaFile(root)) return; + throw new Error( + `No ${SCHEMA_FILE} yet, so there is nothing to migrate. Run \`appkit db introspect\` to write it from the branch's tables, or create it.`, + ); +} +const DATABASE_TYPES_PATH = path.join( + "shared/appkit-types", + DATABASE_TYPES_FILE, +); + +export interface MigrateReport { + /** The migration written by this run; null when schema.ts matches the latest snapshot. */ + readonly generated: GeneratedMigration | null; + /** Tags applied to the `.env` branch by this run. */ + readonly applied: readonly string[]; + /** Tags recorded without running because their tables already existed (0000_baseline). */ + readonly adopted?: readonly string[]; + /** Why nothing was applied, when applying was skipped. */ + readonly notApplied?: string; + readonly typesFile: string; +} + +export interface MigrateOptions { + /** false: an ambiguous rename throws instead of prompting. */ + readonly interactive: boolean; +} + +type ApplyOutcome = Pick; + +const NO_BRANCH = + "no branch configured (PGHOST, PGDATABASE, LAKEBASE_ENDPOINT) — run appkit db init"; + +/** Why migrations must not be applied to the `.env` branch; undefined when they may. */ +async function applyBlocker(ctx: DbContext): Promise { + const check = await checkAppkitBranch(ctx); + if (!check) return NO_BRANCH; + if (check.verdict.appkitOwned) return undefined; + return `${resourceId(check.branch.name)} is not an AppKit dev or session branch (${check.verdict.reason}). Migrations are applied only to branches \`appkit db init\` created for you; the deployed app applies committed migrations to its own branch`; +} + +/** Apply pending migrations to the `.env` branch, or say why they were not. */ +async function applyToEnvBranch( + ctx: DbContext, + folder: string, + schemaName: string, +): Promise { + const blocker = readJournal(folder) + ? await applyBlocker(ctx) + : "no migrations to apply — schema.ts declares no tables yet"; + if (blocker) return { applied: [], notApplied: blocker }; + const pool = await openBranchPool(ctx); + if (!pool) return { applied: [], notApplied: NO_BRANCH }; + try { + const { applied, adopted } = await applyPendingMigrations(pool, { + migrationsFolder: folder, + schemaName, + }); + return { applied, ...(adopted.length > 0 ? { adopted } : {}) }; + } finally { + await pool.end().catch(() => undefined); + } +} + +/** + * `appkit db migrate`: generate a migration when schema.ts changed, regenerate + * database.d.ts, and apply pending migrations to the `.env` branch (only AppKit + * dev/session branches). + */ +export async function runMigrate( + ctx: DbContext, + options: MigrateOptions, +): Promise { + requireSchemaFile(ctx.root); + const schema = await loadDefaultDatabaseSchema(ctx.root, { + showImportError: true, + }); + const folder = migrationsFolder(ctx.root); + const generated = await generateMigrationFiles({ + folder, + schema, + interactive: options.interactive, + }); + + // The types follow schema.ts alone, so they are current even if applying fails. + const typesFile = path.join(ctx.root, DATABASE_TYPES_PATH); + await generateDatabaseTypes({ + schemaFile: path.join(ctx.root, SCHEMA_FILE), + outFile: typesFile, + }); + + let outcome: ApplyOutcome; + try { + outcome = await applyToEnvBranch(ctx, folder, schema.$schemaName); + } catch (error) { + if (!generated) throw error; + throw new Error( + `Wrote ${path.relative(ctx.root, generated.file)}, but did not apply it: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + return { generated, ...outcome, typesFile }; +} diff --git a/packages/appkit/src/database/tooling/migrations.ts b/packages/appkit/src/database/tooling/migrations.ts new file mode 100644 index 000000000..135d4401d --- /dev/null +++ b/packages/appkit/src/database/tooling/migrations.ts @@ -0,0 +1,262 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { + type Journal, + type JournalEntry, + journalFile, + readJournal, + readJsonFile, +} from "../migrations/paths"; +import type { Schema } from "../schema-builder"; +import { drizzleKitImports } from "./drizzle-exports"; + +/** + * The drizzle-kit release the snapshots and SQL are written for; the optional + * peer dependency in package.json pins the same version. + */ +export const DRIZZLE_KIT_VERSION = "0.31.10"; + +interface GenerateOptions { + /** Absolute migrations folder. */ + readonly folder: string; + readonly schema: Schema; + /** false: an ambiguous rename throws instead of prompting. */ + readonly interactive: boolean; +} + +export interface GeneratedMigration { + /** e.g. "0001_migration". */ + readonly tag: string; + /** Absolute path of the written .sql file. */ + readonly file: string; + readonly statements: readonly string[]; +} + +/** + * drizzle-kit can't tell a rename from a drop plus a create without asking. + * Raised in non-interactive runs instead of prompting. + */ +export class MigrationRenameAmbiguityError extends Error { + readonly kind: "table" | "column" | "enum"; + /** The table whose columns changed, or the schema for table and enum changes. */ + readonly table: string; + readonly created: readonly string[]; + readonly deleted: readonly string[]; + + constructor( + kind: MigrationRenameAmbiguityError["kind"], + table: string, + created: readonly string[], + deleted: readonly string[], + ) { + const scope = + kind === "column" ? `columns of table "${table}"` : `${kind}s`; + super( + `Cannot tell whether ${scope} were renamed: removed ${deleted.join(", ")}; added ${created.join(", ")}. ` + + "Run `appkit db migrate` in an interactive terminal to choose between a rename and a drop plus create.", + ); + this.name = "MigrationRenameAmbiguityError"; + this.kind = kind; + this.table = table; + this.created = Object.freeze([...created]); + this.deleted = Object.freeze([...deleted]); + } +} + +/** drizzle-kit's statement separator in v7 migration files. */ +const BREAKPOINT = "--> statement-breakpoint\n"; +const ORIGIN_ID = "00000000-0000-0000-0000-000000000000"; + +/** The parts of a drizzle-kit PostgreSQL snapshot this module reads. */ +interface Snapshot { + id: string; + tables: Record }>; + enums: Record; + [key: string]: unknown; +} + +/** drizzle-kit's empty starting snapshot (its `dryPg`). */ +function originSnapshot(): Snapshot { + return { + id: ORIGIN_ID, + prevId: "", + version: "7", + dialect: "postgresql", + tables: {}, + enums: {}, + schemas: {}, + sequences: {}, + roles: {}, + policies: {}, + views: {}, + _meta: { schemas: {}, tables: {}, columns: {} }, + }; +} + +function prefixOf(idx: number): string { + return String(idx).padStart(4, "0"); +} + +function snapshotFile(folder: string, idx: number): string { + return path.join(folder, "meta", `${prefixOf(idx)}_snapshot.json`); +} + +function latestSnapshot(folder: string, journal: Journal | null): Snapshot { + const last = journal?.entries.at(-1); + if (!last) return originSnapshot(); + const file = snapshotFile(folder, last.idx); + if (!fs.existsSync(file)) { + throw new Error( + `Missing ${file} for migration ${last.tag}. Restore it from version control; it is the baseline for the next migration.`, + ); + } + return readJsonFile(file); +} + +function keyDiff( + before: Record, + after: Record, +): { created: string[]; deleted: string[] } { + return { + created: Object.keys(after).filter((key) => !Object.hasOwn(before, key)), + deleted: Object.keys(before).filter((key) => !Object.hasOwn(after, key)), + }; +} + +/** The same "created and deleted together" conditions that make drizzle-kit prompt. */ +function findRenameAmbiguity( + prev: Snapshot, + cur: Snapshot, + schemaName: string, +): MigrationRenameAmbiguityError | null { + const tables = keyDiff(prev.tables, cur.tables); + if (tables.created.length > 0 && tables.deleted.length > 0) { + return new MigrationRenameAmbiguityError( + "table", + schemaName, + tables.created, + tables.deleted, + ); + } + const enums = keyDiff(prev.enums, cur.enums); + if (enums.created.length > 0 && enums.deleted.length > 0) { + return new MigrationRenameAmbiguityError( + "enum", + schemaName, + enums.created, + enums.deleted, + ); + } + for (const [key, table] of Object.entries(cur.tables)) { + const before = prev.tables[key]; + if (!before) continue; + const columns = keyDiff(before.columns, table.columns); + if (columns.created.length > 0 && columns.deleted.length > 0) { + return new MigrationRenameAmbiguityError( + "column", + key, + columns.created, + columns.deleted, + ); + } + } + return null; +} + +/** Whether drizzle-kit itself is not installed; module loaders may wrap that error. */ +function isDrizzleKitMissing(error: unknown): boolean { + let current = error; + for (let depth = 0; depth < 3 && current instanceof Error; depth++) { + if ( + (current as { code?: unknown }).code === "ERR_MODULE_NOT_FOUND" && + /['"]drizzle-kit['"]/.test(current.message) + ) { + return true; + } + current = current.cause; + } + return false; +} + +/** + * drizzle-kit is large, so apps install it as a dev dependency and it is loaded + * only when a migration is generated. + */ +async function loadDrizzleKit(): Promise { + try { + return await import("drizzle-kit/api"); + } catch (error) { + if (!isDrizzleKitMissing(error)) throw error; + throw new Error( + `Generating migrations needs drizzle-kit, which is not installed. Add it as a dev dependency: npm install --save-dev drizzle-kit@${DRIZZLE_KIT_VERSION}`, + { cause: error }, + ); + } +} + +/** + * Diff the schema against the latest committed snapshot with drizzle-kit and + * write the next v7 migration: `NNNN_migration.sql`, `meta/NNNN_snapshot.json`, + * and the appended `meta/_journal.json`. null when nothing changed. + */ +export async function generateMigrationFiles( + options: GenerateOptions, +): Promise { + const { generateDrizzleJson, generateMigration } = await loadDrizzleKit(); + + const journal = readJournal(options.folder); + const prev = latestSnapshot(options.folder, journal); + const cur = generateDrizzleJson(drizzleKitImports(options.schema), prev.id, [ + options.schema.$schemaName, + ]) as unknown as Snapshot; + + if (!options.interactive) { + const ambiguity = findRenameAmbiguity( + prev, + cur, + options.schema.$schemaName, + ); + if (ambiguity) throw ambiguity; + } + + type KitSnapshot = Parameters[0]; + const statements = await generateMigration( + prev as unknown as KitSnapshot, + cur as unknown as KitSnapshot, + ); + if (statements.length === 0) return null; + + const entries = journal?.entries ?? []; + const last = entries.at(-1); + const idx = last ? last.idx + 1 : 0; + const tag = `${prefixOf(idx)}_migration`; + const entry: JournalEntry = { + idx, + version: "7", + // The migrator applies entries newer than the last recorded one, so `when` must increase. + when: Math.max(Date.now(), (last?.when ?? 0) + 1), + tag, + breakpoints: true, + }; + const next: Journal = { + version: "7", + dialect: "postgresql", + entries: [...entries, entry], + }; + + const file = path.join(options.folder, `${tag}.sql`); + fs.mkdirSync(path.join(options.folder, "meta"), { recursive: true }); + fs.writeFileSync( + snapshotFile(options.folder, idx), + JSON.stringify( + { ...cur, _meta: { columns: {}, schemas: {}, tables: {} } }, + null, + 2, + ), + ); + fs.writeFileSync(file, statements.join(BREAKPOINT)); + // The journal is written last: it is what makes the migration visible. + fs.writeFileSync(journalFile(options.folder), JSON.stringify(next, null, 2)); + return { tag, file, statements }; +} diff --git a/packages/appkit/src/database/tooling/naming.ts b/packages/appkit/src/database/tooling/naming.ts new file mode 100644 index 000000000..aadaf374b --- /dev/null +++ b/packages/appkit/src/database/tooling/naming.ts @@ -0,0 +1,48 @@ +import { createHash } from "node:crypto"; + +// Branch names AppKit creates. Branch IDs: 1-63 chars, start with a +// lowercase letter, lowercase letters, digits and hyphens only. + +const MAX_BRANCH_ID = 63; +const DEV_PREFIX = "dev-"; +const SESSION_PREFIX = "session-"; +const HASH_LENGTH = 6; + +const SESSION_ID = /^[a-z0-9]{1,55}$/; + +function slug(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** + * dev--. The hash keeps users who share + * a local part apart; the name is the same on every machine. + */ +export function devBranchId(userName: string): string { + const normalized = userName.trim().toLowerCase(); + const hash = createHash("sha256") + .update(normalized) + .digest("hex") + .slice(0, HASH_LENGTH); + const room = MAX_BRANCH_ID - DEV_PREFIX.length - 1 - HASH_LENGTH; + const local = slug(normalized.split("@")[0] ?? "") + .slice(0, room) + .replace(/-+$/, ""); + return `${DEV_PREFIX}${local || "user"}-${hash}`; +} + +export function isValidSessionId(sessionId: string): boolean { + return SESSION_ID.test(sessionId); +} + +export function sessionBranchId(sessionId: string): string { + if (!isValidSessionId(sessionId)) { + throw new Error( + `Invalid APPKIT_DB_SESSION ${JSON.stringify(sessionId)}: use 1-55 lowercase letters or digits.`, + ); + } + return `${SESSION_PREFIX}${sessionId}`; +} diff --git a/packages/appkit/src/database/tooling/targets.ts b/packages/appkit/src/database/tooling/targets.ts new file mode 100644 index 000000000..ff1cef3e8 --- /dev/null +++ b/packages/appkit/src/database/tooling/targets.ts @@ -0,0 +1,115 @@ +import fs from "node:fs"; +import path from "node:path"; + +import yaml from "js-yaml"; + +import { isValidSessionId } from "./naming"; + +/** Full resource names, e.g. branch = "projects/p/branches/b". */ +interface ResourceName { + readonly project: string; + readonly branch?: string; + readonly endpoint?: string; + readonly database?: string; +} + +const RESOURCE = + /^(projects\/[^/\s]+)(?:\/branches\/([^/\s]+)(?:\/(endpoints|databases)\/([^/\s]+))?)?$/; + +/** null for anything that is not a Lakebase project/branch/endpoint/database name (e.g. a placeholder). */ +export function parseResourceName( + name: string | undefined, +): ResourceName | null { + const match = name ? RESOURCE.exec(name.trim()) : null; + if (!match) return null; + const [, project, branchId, kind, leafId] = match; + if (!project) return null; + const branch = branchId ? `${project}/branches/${branchId}` : undefined; + const leaf = branch && leafId ? `${branch}/${kind}/${leafId}` : undefined; + return { + project, + ...(branch ? { branch } : {}), + ...(kind === "endpoints" && leaf ? { endpoint: leaf } : {}), + ...(kind === "databases" && leaf ? { database: leaf } : {}), + }; +} + +/** The last path segment of a resource name. */ +export function resourceId(name: string): string { + return name.slice(name.lastIndexOf("/") + 1); +} + +/** What `.env` says about the local branch. Unparseable values (template placeholders) read as null. */ +interface LocalTarget { + readonly endpoint: string | null; + readonly session: string | null; +} + +export function readLocalTarget(env: NodeJS.ProcessEnv): LocalTarget { + const endpoint = parseResourceName(env.LAKEBASE_ENDPOINT)?.endpoint ?? null; + const session = + env.APPKIT_DB_SESSION && isValidSessionId(env.APPKIT_DB_SESSION) + ? env.APPKIT_DB_SESSION + : null; + return { endpoint, session }; +} + +type Yaml = Record | undefined; + +function record(value: unknown): Yaml { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +/** databricks.yml, parsed; null without one. Invalid YAML throws, so no check that reads it passes silently. */ +function readBundle(root: string): Record | null { + const file = ["databricks.yml", "databricks.yaml"] + .map((name) => path.join(root, name)) + .find((candidate) => fs.existsSync(candidate)); + if (!file) return null; + try { + return record(yaml.load(fs.readFileSync(file, "utf-8"))) ?? {}; + } catch (error) { + const reason = error instanceof Error ? error.message.split("\n")[0] : ""; + throw new Error( + `Cannot read ${path.basename(file)}, so the branches it deploys to are unknown: ${reason}`, + { cause: error }, + ); + } +} + +/** A target's value for a bundle variable, else the variable's default. */ +function variableOf( + bundle: Record, + target: Yaml, + name: string, +): string | null { + const value = + record(target?.variables)?.[name] ?? + record(record(bundle.variables)?.[name])?.default ?? + null; + return typeof value === "string" && value ? value : null; +} + +/** + * Every branch databricks.yml can deploy to: each target's postgres_branch and + * the variable's default. Empty without databricks.yml or a postgres binding. + */ +export function readDeployBranches(root: string): string[] { + const bundle = readBundle(root); + if (!bundle) return []; + const targets = [ + undefined, + ...Object.values(record(bundle.targets) ?? {}).map(record), + ]; + const branches = targets + .map( + (target) => + parseResourceName( + variableOf(bundle, target, "postgres_branch") ?? undefined, + )?.branch, + ) + .filter((branch): branch is string => !!branch); + return [...new Set(branches)]; +} diff --git a/packages/appkit/src/database/tooling/tests/context.test.ts b/packages/appkit/src/database/tooling/tests/context.test.ts new file mode 100644 index 000000000..e7ac24dd5 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/context.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const { createWorkspaceClient, initializeLakebasePool } = vi.hoisted(() => ({ + createWorkspaceClient: vi.fn(), + initializeLakebasePool: vi.fn(), +})); + +vi.mock("../../../connectors/lakebase", () => ({ initializeLakebasePool })); +vi.mock("../../../workspace-client", () => ({ createWorkspaceClient })); + +import { getClientOptions } from "../../../context/client-options"; +import { createDbContext, openBranchPool } from "../context"; +import type { LakebaseApi } from "../lakebase-api"; +import { cleanupTempApps, FakeLakebase, fakeContext, tempApp } from "./fakes"; + +const BRANCH = "projects/p/branches/dev-alice-3f9a2c"; +const ENDPOINT = `${BRANCH}/endpoints/primary`; +const HOST = "primary.dev-alice-3f9a2c.example.com"; +const pool = { end: vi.fn() }; + +beforeEach(() => { + initializeLakebasePool.mockReset().mockResolvedValue(pool); + createWorkspaceClient.mockReset(); +}); +afterEach(cleanupTempApps); + +function contextFor(env: NodeJS.ProcessEnv, api: LakebaseApi) { + return fakeContext({ root: tempApp(), env, api }); +} + +describe("openBranchPool", () => { + test("null without PGHOST, PGDATABASE and LAKEBASE_ENDPOINT, before any API call", async () => { + const lakebase = new FakeLakebase(); + + await expect( + openBranchPool( + contextFor({ PGHOST: HOST, PGDATABASE: "db" }, lakebase.api()), + ), + ).resolves.toBeNull(); + expect(lakebase.calls).toEqual([]); + expect(initializeLakebasePool).not.toHaveBeenCalled(); + }); + + test("connects to PGHOST once it is a host of LAKEBASE_ENDPOINT", async () => { + const lakebase = new FakeLakebase(); + lakebase.addBranch(BRANCH); + const ctx = contextFor( + { + PGHOST: HOST.toUpperCase(), + PGDATABASE: "databricks_postgres", + LAKEBASE_ENDPOINT: ENDPOINT, + PGUSER: "alice@example.com", + }, + lakebase.api(), + ); + + await expect(openBranchPool(ctx)).resolves.toBe(pool); + expect(lakebase.calls).toEqual([`getEndpoint ${ENDPOINT}`]); + expect(initializeLakebasePool).toHaveBeenCalledWith( + expect.objectContaining({ + host: HOST.toUpperCase(), + database: "databricks_postgres", + endpoint: ENDPOINT, + user: "alice@example.com", + max: 2, + }), + ); + }); + + test("refuses another branch's host, a missing endpoint, or a failed lookup, and never connects", async () => { + const lakebase = new FakeLakebase(); + lakebase.addBranch(BRANCH); + const env = { + PGDATABASE: "databricks_postgres", + LAKEBASE_ENDPOINT: ENDPOINT, + }; + + await expect( + openBranchPool( + contextFor( + { ...env, PGHOST: "primary.production.example.com" }, + lakebase.api(), + ), + ), + ).rejects.toThrow( + `PGHOST primary.production.example.com is not a host of LAKEBASE_ENDPOINT ${ENDPOINT} (expected ${HOST}), so it did not connect. Run \`appkit db init\` to rewrite .env.`, + ); + await expect( + openBranchPool( + contextFor( + { + ...env, + PGHOST: HOST, + LAKEBASE_ENDPOINT: `${BRANCH}/endpoints/gone`, + }, + lakebase.api(), + ), + ), + ).rejects.toThrow( + `LAKEBASE_ENDPOINT ${BRANCH}/endpoints/gone does not exist, so it did not connect.`, + ); + + const lookupFailure = new Error("Lakebase: get endpoint failed: timeout"); + const failing = { + getEndpoint: async () => { + throw lookupFailure; + }, + } as unknown as LakebaseApi; + const error = (await openBranchPool( + contextFor({ ...env, PGHOST: HOST }, failing), + ).catch((caught: unknown) => caught)) as Error; + expect(error.message).toBe( + `Could not verify that PGHOST ${HOST} serves LAKEBASE_ENDPOINT ${ENDPOINT}, so it did not connect.`, + ); + expect(error.cause).toBe(lookupFailure); + + expect(initializeLakebasePool).not.toHaveBeenCalled(); + }); +}); + +describe("createDbContext", () => { + test("the workspace client uses the .env profile and AppKit's client options, once", () => { + const legacy = { apiClient: {}, currentUser: {} }; + createWorkspaceClient.mockReturnValue({ + toLegacyWorkspaceClient: () => legacy, + }); + const ctx = createDbContext({ + root: tempApp(), + env: { DATABRICKS_CONFIG_PROFILE: "dev" }, + }); + + expect(ctx.workspace()).toBe(legacy); + expect(ctx.workspace()).toBe(legacy); + expect(createWorkspaceClient).toHaveBeenCalledOnce(); + expect(createWorkspaceClient).toHaveBeenCalledWith({ + profile: "dev", + clientOptions: getClientOptions(), + }); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/drizzle-exports.test.ts b/packages/appkit/src/database/tooling/tests/drizzle-exports.test.ts new file mode 100644 index 000000000..cda0e8a9a --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/drizzle-exports.test.ts @@ -0,0 +1,47 @@ +import { is } from "drizzle-orm"; +import { isPgEnum, PgSchema, PgTable } from "drizzle-orm/pg-core"; +import { describe, expect, test } from "vitest"; + +import { defineSchema, enumColumn, fk, id, text } from "../../schema-builder"; +import { drizzleKitImports } from "../drizzle-exports"; + +describe("drizzleKitImports", () => { + const schema = defineSchema(({ table }) => { + const users = table("users", { + id: id(), + role: enumColumn("user_role", ["admin", "member"]), + backup: enumColumn("user_role", ["admin", "member"]), + }); + const posts = table("posts", { + id: id(), + author: fk(() => users.id), + title: text(), + }); + return { users, posts }; + }); + + test("exports every engine table and each referenced enum once", () => { + const values = Object.values(drizzleKitImports(schema)); + expect(values.filter((value) => is(value, PgTable))).toEqual([ + schema.$engine.users, + schema.$engine.posts, + ]); + const enums = values.filter((value) => isPgEnum(value)); + expect(enums).toHaveLength(1); + const [userRole] = enums; + expect(userRole.enumName).toBe("user_role"); + expect(userRole.schema).toBe("app"); + expect(userRole.enumValues).toEqual(["admin", "member"]); + }); + + test("never exports the pgSchema object, so drizzle-kit emits no CREATE SCHEMA", () => { + const values = Object.values(drizzleKitImports(schema)); + expect(values.some((value) => is(value, PgSchema))).toBe(false); + }); + + test("an empty schema has nothing to export", () => { + expect(Object.keys(drizzleKitImports(defineSchema(() => ({}))))).toEqual( + [], + ); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/drizzle-kit-dependency.test.ts b/packages/appkit/src/database/tooling/tests/drizzle-kit-dependency.test.ts new file mode 100644 index 000000000..ec9bcb2d1 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/drizzle-kit-dependency.test.ts @@ -0,0 +1,104 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { defineSchema, id } from "../../schema-builder"; + +const SRC = path.resolve(import.meta.dirname, "../../.."); +const folders: string[] = []; + +afterEach(() => { + vi.doUnmock("drizzle-kit/api"); + vi.resetModules(); + for (const folder of folders.splice(0)) { + fs.rmSync(folder, { recursive: true, force: true }); + } +}); + +/** Load ../migrations with `import("drizzle-kit/api")` failing like Node's resolver does. */ +async function withoutPackage(name: string) { + vi.resetModules(); + vi.doMock("drizzle-kit/api", () => { + throw Object.assign( + new Error( + `Cannot find package '${name}' imported from /app/node_modules/@databricks/appkit/dist/database/tooling/migrations.js`, + ), + { code: "ERR_MODULE_NOT_FOUND" }, + ); + }); + return import("../migrations"); +} + +function generate(migrations: typeof import("../migrations")) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-drizzle-kit-")); + folders.push(root); + return migrations.generateMigrationFiles({ + folder: path.join(root, "migrations"), + schema: defineSchema(({ table }) => ({ + notes: table("notes", { id: id() }), + })), + interactive: false, + }); +} + +/** Source files outside tests, relative to src/. */ +function sourceFiles(): string[] { + return (fs.readdirSync(SRC, { recursive: true }) as string[]).filter( + (file) => + /\.tsx?$/.test(file) && + !/\.test\.tsx?$/.test(file) && + !file.split(path.sep).includes("tests"), + ); +} + +describe("drizzle-kit dependency", () => { + test("without drizzle-kit, generating says how to install it", async () => { + const migrations = await withoutPackage("drizzle-kit"); + + await expect(generate(migrations)).rejects.toThrow( + `Generating migrations needs drizzle-kit, which is not installed. Add it as a dev dependency: npm install --save-dev drizzle-kit@${migrations.DRIZZLE_KIT_VERSION}`, + ); + }); + + test("another missing package is not reported as a missing drizzle-kit", async () => { + const migrations = await withoutPackage("esbuild"); + + const error = (await generate(migrations).catch( + (caught: unknown) => caught, + )) as Error; + expect(error.message).not.toContain("npm install"); + }); + + test("it is an optional peer dependency pinned to DRIZZLE_KIT_VERSION, never a dependency", async () => { + const { DRIZZLE_KIT_VERSION } = await import("../migrations"); + const pkg = JSON.parse( + fs.readFileSync(path.resolve(SRC, "../package.json"), "utf-8"), + ); + + expect(pkg.dependencies["drizzle-kit"]).toBeUndefined(); + expect(pkg.peerDependencies["drizzle-kit"]).toBe(DRIZZLE_KIT_VERSION); + expect(pkg.peerDependenciesMeta["drizzle-kit"]).toEqual({ optional: true }); + expect(pkg.devDependencies["drizzle-kit"]).toBe(DRIZZLE_KIT_VERSION); + }); + + test("only the tooling imports drizzle-kit, and no runtime module imports the tooling", () => { + const imports = (file: string, pattern: RegExp) => + pattern.test(fs.readFileSync(path.join(SRC, file), "utf-8")); + const tooling = path.join("database", "tooling"); + + expect( + sourceFiles().filter((file) => + imports(file, /(?:from\s+|import\s*\(\s*)["']drizzle-kit/), + ), + ).toEqual([path.join(tooling, "migrations.ts")]); + expect( + sourceFiles().filter( + (file) => + !file.startsWith(`${tooling}${path.sep}`) && + imports(file, /from\s+["'][^"']*\/tooling(?:\/[^"']*)?["']/), + ), + ).toEqual([]); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/fakes.ts b/packages/appkit/src/database/tooling/tests/fakes.ts new file mode 100644 index 000000000..c10e139af --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/fakes.ts @@ -0,0 +1,122 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import type { DbContext } from "../context"; +import type { + LakebaseApi, + LakebaseBranch, + LakebaseEndpoint, +} from "../lakebase-api"; + +/** In-memory Lakebase: the branches the guard looks up, and their endpoints. */ +export class FakeLakebase { + readonly branches = new Map(); + readonly endpoints = new Map(); + readonly calls: string[] = []; + + addBranch( + name: string, + status: LakebaseBranch["status"] = {}, + ): LakebaseBranch { + const branch = { name, status: { current_state: "READY", ...status } }; + this.branches.set(name, branch); + this.endpoints.set(name, [this.endpoint(name, "primary")]); + return branch; + } + + endpoint(branch: string, id: string): LakebaseEndpoint { + return { + name: `${branch}/endpoints/${id}`, + status: { + hosts: { host: `${id}.${branch.split("/").pop()}.example.com` }, + }, + }; + } + + api(): LakebaseApi { + const fake = this; + const api = { + async getBranch(name: string) { + fake.calls.push(`getBranch ${name}`); + return fake.branches.get(name) ?? null; + }, + async getEndpoint(name: string) { + fake.calls.push(`getEndpoint ${name}`); + const branch = name.slice(0, name.indexOf("/endpoints/")); + return ( + fake.endpoints + .get(branch) + ?.find((endpoint) => endpoint.name === name) ?? null + ); + }, + }; + return api as unknown as LakebaseApi; + } +} + +const roots: string[] = []; + +/** A temp app root with optional files (relative path → contents). */ +export function tempApp(files: Record = {}): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-db-tooling-")); + roots.push(root); + for (const [file, contents] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), contents); + } + return root; +} + +export function cleanupTempApps(): void { + for (const root of roots.splice(0)) + fs.rmSync(root, { recursive: true, force: true }); +} + +export function fakeContext(options: { + root: string; + env?: NodeJS.ProcessEnv; + userName?: string; + api?: LakebaseApi; +}): DbContext { + const userName = options.userName ?? "alice@example.com"; + return { + root: options.root, + env: { ...options.env }, + workspace: () => ({ + apiClient: { + request: async () => { + throw new Error("no raw API calls expected"); + }, + }, + currentUser: { me: async () => ({ userName }) }, + }), + api: () => { + if (!options.api) throw new Error("no Lakebase API calls expected"); + return options.api; + }, + }; +} + +/** The bundle `databricks apps init` writes for a database app. */ +export function databricksYml(branch: string, database: string): string { + return `bundle: + name: my-app +variables: + postgres_branch: + description: Lakebase branch resource name + postgres_database: + description: Lakebase database resource name +resources: + apps: + app: + name: "my-app" + source_code_path: ./ +targets: + default: + default: true + variables: + postgres_branch: ${branch} + postgres_database: ${database} +`; +} diff --git a/packages/appkit/src/database/tooling/tests/guard.test.ts b/packages/appkit/src/database/tooling/tests/guard.test.ts new file mode 100644 index 000000000..175f9823c --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/guard.test.ts @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, test } from "vitest"; + +import { checkAppkitBranch, classifyBranch } from "../guard"; +import { devBranchId } from "../naming"; +import { + cleanupTempApps, + databricksYml, + FakeLakebase, + fakeContext, + tempApp, +} from "./fakes"; + +afterEach(cleanupTempApps); + +const USER = "alice@example.com"; +const P = "projects/p"; +const dev = `${P}/branches/${devBranchId(USER)}`; + +function verdict( + name: string, + status = {}, + sessionId: string | null = "k2x9q0ab", + deployBranches: string[] = [], +) { + return classifyBranch({ + branch: { name, status }, + userName: USER, + sessionId, + deployBranches, + }); +} + +describe("classifyBranch", () => { + test("only this developer's dev branch and this checkout's session branch are AppKit-owned", () => { + expect(verdict(dev)).toEqual({ appkitOwned: true }); + expect(verdict(`${P}/branches/session-k2x9q0ab`)).toEqual({ + appkitOwned: true, + }); + }); + + test("everything else is refused, with the reason", () => { + expect( + verdict(`${P}/branches/production`, { default: true }), + ).toMatchObject({ + appkitOwned: false, + reason: "it is the project's default branch", + }); + expect(verdict(dev, { is_protected: true })).toMatchObject({ + appkitOwned: false, + reason: "it is a protected branch", + }); + expect( + verdict(dev, {}, null, [`${P}/branches/staging`, dev]), + ).toMatchObject({ + appkitOwned: false, + reason: "it is a branch databricks.yml deploys to", + }); + expect( + verdict(`${P}/branches/${devBranchId("bob@example.com")}`).appkitOwned, + ).toBe(false); + expect(verdict(`${P}/branches/session-other`).appkitOwned).toBe(false); + expect( + verdict(`${P}/branches/session-k2x9q0ab`, {}, null).appkitOwned, + ).toBe(false); + expect(verdict(`${P}/branches/staging`).reason).toContain( + devBranchId(USER), + ); + }); +}); + +describe("checkAppkitBranch", () => { + const envFor = (branch: string) => ({ + LAKEBASE_ENDPOINT: `${branch}/endpoints/primary`, + }); + + test("classifies the .env branch through the API, without connecting", async () => { + const lakebase = new FakeLakebase(); + lakebase.addBranch(dev); + lakebase.addBranch(`${P}/branches/production`, { default: true }); + + const onDev = await checkAppkitBranch( + fakeContext({ root: tempApp(), env: envFor(dev), api: lakebase.api() }), + ); + expect(onDev).toMatchObject({ + branch: { name: dev }, + verdict: { appkitOwned: true }, + }); + + const onDefault = await checkAppkitBranch( + fakeContext({ + root: tempApp(), + env: envFor(`${P}/branches/production`), + api: lakebase.api(), + }), + ); + expect(onDefault?.verdict).toEqual({ + appkitOwned: false, + reason: "it is the project's default branch", + }); + expect(lakebase.calls.every((call) => call.startsWith("getBranch"))).toBe( + true, + ); + }); + + test("a branch databricks.yml deploys to is never AppKit-owned", async () => { + const lakebase = new FakeLakebase(); + lakebase.addBranch(dev); + const root = tempApp({ + "databricks.yml": databricksYml( + dev, + `${dev}/databases/databricks-postgres`, + ), + }); + + const check = await checkAppkitBranch( + fakeContext({ root, env: envFor(dev), api: lakebase.api() }), + ); + + expect(check?.verdict).toEqual({ + appkitOwned: false, + reason: "it is a branch databricks.yml deploys to", + }); + }); + + test("a non-default target counts too, and an unreadable databricks.yml refuses", async () => { + const lakebase = new FakeLakebase(); + lakebase.addBranch(dev); + const personalTarget = tempApp({ + "databricks.yml": `${databricksYml(`${P}/branches/production`, `${P}/branches/production/databases/db`)} personal: + variables: + postgres_branch: ${dev} +`, + }); + const broken = tempApp({ "databricks.yml": "targets:\n default: [\n" }); + + const check = await checkAppkitBranch( + fakeContext({ + root: personalTarget, + env: envFor(dev), + api: lakebase.api(), + }), + ); + expect(check?.verdict).toEqual({ + appkitOwned: false, + reason: "it is a branch databricks.yml deploys to", + }); + await expect( + checkAppkitBranch( + fakeContext({ root: broken, env: envFor(dev), api: lakebase.api() }), + ), + ).rejects.toThrow( + /^Cannot read databricks\.yml, so the branches it deploys to are unknown: /, + ); + }); + + test("null without a configured branch; a deleted branch points at init", async () => { + const lakebase = new FakeLakebase(); + await expect( + checkAppkitBranch(fakeContext({ root: tempApp(), api: lakebase.api() })), + ).resolves.toBeNull(); + await expect( + checkAppkitBranch( + fakeContext({ root: tempApp(), env: envFor(dev), api: lakebase.api() }), + ), + ).rejects.toThrow(/does not exist.*appkit db init/); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/identity.test.ts b/packages/appkit/src/database/tooling/tests/identity.test.ts new file mode 100644 index 000000000..d600a844b --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/identity.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { currentIdentity } from "../identity"; +import { cleanupTempApps, fakeContext, tempApp } from "./fakes"; + +afterEach(cleanupTempApps); + +describe("identity", () => { + test("currentUser.me() is called once per context", async () => { + const ctx = fakeContext({ root: tempApp() }); + const me = vi.fn(async () => ({ userName: "alice@example.com" })); + const client = { ...ctx.workspace(), currentUser: { me } }; + const counted = { ...ctx, workspace: () => client }; + + await expect(currentIdentity(counted)).resolves.toEqual({ + userName: "alice@example.com", + }); + await currentIdentity(counted); + expect(me).toHaveBeenCalledOnce(); + }); + + test("no userName is an error that points at the profile", async () => { + const ctx = fakeContext({ root: tempApp() }); + const client = { + ...ctx.workspace(), + currentUser: { me: async () => ({}) }, + }; + await expect( + currentIdentity({ ...ctx, workspace: () => client }), + ).rejects.toThrow(/DATABRICKS_CONFIG_PROFILE/); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/lakebase-api.test.ts b/packages/appkit/src/database/tooling/tests/lakebase-api.test.ts new file mode 100644 index 000000000..22adaa0f3 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/lakebase-api.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "vitest"; + +import type { ToolingRequest } from "../context"; +import { LakebaseApi, LakebaseApiError } from "../lakebase-api"; + +type Reply = unknown | ((request: ToolingRequest) => unknown); + +/** apiClient fake: replies in order, records each request. */ +function client(replies: Reply[]) { + const requests: ToolingRequest[] = []; + return { + requests, + apiClient: { + async request(request: ToolingRequest) { + requests.push(request); + if (replies.length === 0) + throw new Error( + `unexpected request ${request.method} ${request.path}`, + ); + const reply = replies.shift(); + const value = + typeof reply === "function" + ? (reply as (r: ToolingRequest) => unknown)(request) + : reply; + if (value instanceof Error) throw value; + return value; + }, + }, + }; +} + +const apiError = (statusCode: number, message: string) => + Object.assign(new Error(message), { statusCode, errorCode: "X" }); + +describe("LakebaseApi", () => { + test("getBranch: GET /api/2.0/postgres/{name}; 404 → null; other errors keep their status", async () => { + const fake = client([ + { name: "projects/p/branches/b" }, + apiError(404, "NOT_FOUND"), + apiError(403, "PERMISSION_DENIED"), + ]); + const api = new LakebaseApi(fake); + + await expect(api.getBranch("projects/p/branches/b")).resolves.toEqual({ + name: "projects/p/branches/b", + }); + await expect( + api.getBranch("projects/p/branches/missing"), + ).resolves.toBeNull(); + const error = await api + .getBranch("projects/p/branches/x") + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(LakebaseApiError); + expect(error).toMatchObject({ + statusCode: 403, + operation: "get branch projects/p/branches/x", + }); + + expect(fake.requests[0]).toMatchObject({ + path: "/api/2.0/postgres/projects/p/branches/b", + method: "GET", + raw: false, + }); + expect(fake.requests[0]?.headers.get("Accept")).toBe("application/json"); + expect(fake.requests[0]?.headers.get("Content-Type")).toBeNull(); + }); + + test("getEndpoint: GET /api/2.0/postgres/{name}; 404 → null", async () => { + const endpoint = { + name: "projects/p/branches/b/endpoints/primary", + status: { hosts: { host: "ep-1.example.com" } }, + }; + const fake = client([endpoint, apiError(404, "NOT_FOUND")]); + const api = new LakebaseApi(fake); + + await expect(api.getEndpoint(endpoint.name)).resolves.toEqual(endpoint); + await expect( + api.getEndpoint("projects/p/branches/b/endpoints/gone"), + ).resolves.toBeNull(); + expect( + fake.requests.map((request) => [request.method, request.path]), + ).toEqual([ + ["GET", "/api/2.0/postgres/projects/p/branches/b/endpoints/primary"], + ["GET", "/api/2.0/postgres/projects/p/branches/b/endpoints/gone"], + ]); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/migrate.test.ts b/packages/appkit/src/database/tooling/tests/migrate.test.ts new file mode 100644 index 000000000..93f0eb500 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/migrate.test.ts @@ -0,0 +1,253 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const { applyPendingMigrations, checkAppkitBranch, openBranchPool } = + vi.hoisted(() => ({ + applyPendingMigrations: vi.fn(), + checkAppkitBranch: vi.fn(), + openBranchPool: vi.fn(), + })); + +vi.mock("../../migrations/apply", () => ({ applyPendingMigrations })); +vi.mock("../guard", () => ({ checkAppkitBranch })); +vi.mock("../context", async (importOriginal) => ({ + ...(await importOriginal()), + openBranchPool, +})); + +import type { DbContext } from "../context"; +import { runMigrate } from "../migrate"; + +const builder = path.resolve( + import.meta.dirname, + "../../schema-builder/index.ts", +); +const roots: string[] = []; + +function app(tables: string): DbContext { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-db-migrate-")); + roots.push(root); + const file = path.join(root, "config/database/schema.ts"); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + `import { defineSchema, id, text } from ${JSON.stringify(builder)}; + export const schema = defineSchema(({ table }) => ({ ${tables} }));`, + ); + return { + root, + env: {}, + workspace: () => { + throw new Error("no workspace calls expected"); + }, + api: () => { + throw new Error("no Lakebase API calls expected"); + }, + }; +} + +const devBranch = { + branch: { name: "projects/p/branches/dev-alice-3f9a2c" }, + verdict: { appkitOwned: true }, +}; + +const pool = { end: vi.fn(async () => undefined) }; + +beforeEach(() => { + applyPendingMigrations.mockReset(); + openBranchPool.mockReset(); + checkAppkitBranch.mockReset(); + checkAppkitBranch.mockResolvedValue(devBranch); + pool.end.mockClear(); +}); + +afterEach(() => { + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("runMigrate", () => { + test("without schema.ts it points to introspect before touching anything", async () => { + const ctx = app(""); + fs.rmSync(path.join(ctx.root, "config/database/schema.ts")); + + await expect(runMigrate(ctx, { interactive: false })).rejects.toThrow( + "No config/database/schema.ts yet, so there is nothing to migrate. Run `appkit db introspect` to write it from the branch's tables, or create it.", + ); + expect(checkAppkitBranch).not.toHaveBeenCalled(); + expect( + fs.existsSync(path.join(ctx.root, "config/database/migrations")), + ).toBe(false); + }); + + test("an empty schema generates nothing, applies nothing, and still writes database.d.ts", async () => { + const ctx = app(""); + const report = await runMigrate(ctx, { interactive: false }); + + expect(report.generated).toBeNull(); + expect(report.applied).toEqual([]); + expect(report.notApplied).toMatch(/no migrations/); + expect(openBranchPool).not.toHaveBeenCalled(); + expect(checkAppkitBranch).not.toHaveBeenCalled(); + expect(report.typesFile).toBe( + path.join(ctx.root, "shared/appkit-types/database.d.ts"), + ); + expect(fs.existsSync(report.typesFile)).toBe(true); + }); + + test("generates, regenerates types, applies to the .env branch with the app schema, and ends the pool", async () => { + const ctx = app(`notes: table("notes", { id: id(), body: text() })`); + openBranchPool.mockResolvedValue(pool); + applyPendingMigrations.mockResolvedValue({ + applied: ["0000_migration"], + adopted: [], + }); + + const report = await runMigrate(ctx, { interactive: false }); + + expect(report.generated?.tag).toBe("0000_migration"); + expect(openBranchPool).toHaveBeenCalledWith(ctx); + expect(applyPendingMigrations).toHaveBeenCalledWith(pool, { + migrationsFolder: path.join(ctx.root, "config/database/migrations"), + schemaName: "app", + }); + expect(report.applied).toEqual(["0000_migration"]); + expect(report.notApplied).toBeUndefined(); + expect(pool.end).toHaveBeenCalledOnce(); + expect(fs.readFileSync(report.typesFile, "utf-8")).toContain("notes"); + }); + + test("applies committed migrations even when schema.ts did not change", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + openBranchPool.mockResolvedValue(null); + await runMigrate(ctx, { interactive: false }); + + openBranchPool.mockResolvedValue(pool); + applyPendingMigrations.mockResolvedValue({ + applied: ["0000_migration"], + adopted: [], + }); + const report = await runMigrate(ctx, { interactive: false }); + + expect(report.generated).toBeNull(); + expect(report.applied).toEqual(["0000_migration"]); + }); + + test("without a configured branch it still generates, and says why nothing was applied", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + checkAppkitBranch.mockResolvedValue(null); + + const report = await runMigrate(ctx, { interactive: false }); + + expect(report.generated?.tag).toBe("0000_migration"); + expect(applyPendingMigrations).not.toHaveBeenCalled(); + expect(report.notApplied).toMatch(/appkit db init/); + expect(fs.existsSync(report.typesFile)).toBe(true); + }); + + test("on a branch AppKit did not create for you it generates, never connects, and says why", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + checkAppkitBranch.mockResolvedValue({ + branch: { name: "projects/p/branches/production" }, + verdict: { + appkitOwned: false, + reason: "it is the project's default branch", + }, + }); + + const report = await runMigrate(ctx, { interactive: false }); + + expect(report.generated?.tag).toBe("0000_migration"); + expect(openBranchPool).not.toHaveBeenCalled(); + expect(applyPendingMigrations).not.toHaveBeenCalled(); + expect(report.notApplied).toBe( + "production is not an AppKit dev or session branch (it is the project's default branch). Migrations are applied only to branches `appkit db init` created for you; the deployed app applies committed migrations to its own branch", + ); + expect(fs.existsSync(report.typesFile)).toBe(true); + }); + + test("reports a baseline adopted without running it", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + checkAppkitBranch.mockResolvedValue(null); + await runMigrate(ctx, { interactive: false }); + + checkAppkitBranch.mockResolvedValue(devBranch); + openBranchPool.mockResolvedValue(pool); + applyPendingMigrations.mockResolvedValue({ + applied: [], + adopted: ["0000_baseline"], + }); + const report = await runMigrate(ctx, { interactive: false }); + + expect(report).toMatchObject({ applied: [], adopted: ["0000_baseline"] }); + }); + + test("an apply failure ends the pool and says the generated migration was written but not applied", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + openBranchPool.mockResolvedValue(pool); + const failure = new Error("apply failed"); + applyPendingMigrations.mockRejectedValue(failure); + + const error = (await runMigrate(ctx, { interactive: false }).catch( + (caught: unknown) => caught, + )) as Error; + + expect(error.message).toBe( + `Wrote ${path.join("config/database/migrations", "0000_migration.sql")}, but did not apply it: apply failed`, + ); + expect(error.cause).toBe(failure); + expect(pool.end).toHaveBeenCalledOnce(); + expect( + fs.existsSync( + path.join(ctx.root, "config/database/migrations/0000_migration.sql"), + ), + ).toBe(true); + expect( + fs.readFileSync( + path.join(ctx.root, "shared/appkit-types/database.d.ts"), + "utf-8", + ), + ).toContain("notes"); + }); + + test("a guard failure after generating says so too, and never connects", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + checkAppkitBranch.mockRejectedValue( + new Error("The .env branch projects/p/branches/dev-a does not exist"), + ); + + await expect(runMigrate(ctx, { interactive: false })).rejects.toThrow( + /^Wrote .*0000_migration\.sql, but did not apply it: The \.env branch .* does not exist$/, + ); + expect(openBranchPool).not.toHaveBeenCalled(); + }); + + test("a failure with nothing generated propagates unchanged", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + checkAppkitBranch.mockResolvedValue(null); + await runMigrate(ctx, { interactive: false }); + + checkAppkitBranch.mockResolvedValue(devBranch); + openBranchPool.mockResolvedValue(pool); + const failure = new Error("apply failed"); + applyPendingMigrations.mockRejectedValue(failure); + + await expect(runMigrate(ctx, { interactive: false })).rejects.toBe(failure); + }); + + test("a schema.ts that fails to import names the import error, not the runtime hint", async () => { + const ctx = app(`notes: table("notes", { id: id( })`); + + const error = (await runMigrate(ctx, { interactive: false }).catch( + (caught: unknown) => caught, + )) as Error; + + expect(error.message).toBe("Could not load config/database/schema.ts."); + expect((error.cause as Error).message).toMatch(/schema\.ts:\d+:\d+/); + expect(checkAppkitBranch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/migrations.test.ts b/packages/appkit/src/database/tooling/tests/migrations.test.ts new file mode 100644 index 000000000..39c8bf193 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/migrations.test.ts @@ -0,0 +1,343 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { readMigrationFiles } from "drizzle-orm/migrator"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { readJournal } from "../../migrations/paths"; +import { + bigid, + boolean, + defineSchema, + enumColumn, + fk, + id, + integer, + text, + timestamp, + varchar, +} from "../../schema-builder"; +import { + generateMigrationFiles, + MigrationRenameAmbiguityError, +} from "../migrations"; + +const folders: string[] = []; + +function tempFolder(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-migrations-")); + folders.push(root); + return path.join(root, "config/database/migrations"); +} + +/** Every file under a folder, relative, sorted — to prove nothing was written. */ +function listFiles(folder: string): string[] { + if (!fs.existsSync(folder)) return []; + return (fs.readdirSync(folder, { recursive: true }) as string[]) + .filter((entry) => fs.statSync(path.join(folder, entry)).isFile()) + .sort(); +} + +afterEach(() => { + vi.useRealTimers(); + for (const folder of folders.splice(0)) { + fs.rmSync(folder, { recursive: true, force: true }); + } +}); + +const blog = (extraPostColumns = {}) => + defineSchema(({ table }) => { + const authors = table("authors", { + id: id(), + email: text().notNull().unique(), + role: enumColumn("author_role", ["admin", "writer"]) + .notNull() + .default("writer"), + createdAt: timestamp({ withTimezone: true }).defaultNow(), + }); + const posts = table("posts", { + id: bigid(), + author: fk(() => authors.id).onDelete("cascade"), + title: varchar(120).notNull(), + published: boolean().default(false), + ...extraPostColumns, + }); + return { authors, posts }; + }); + +describe("generateMigrationFiles", () => { + test("writes a v7 migration from real drizzle-kit output for the builder's tables", async () => { + const folder = tempFolder(); + const generated = await generateMigrationFiles({ + folder, + schema: blog(), + interactive: false, + }); + + expect(generated?.tag).toBe("0000_migration"); + expect(generated?.file).toBe(path.join(folder, "0000_migration.sql")); + const sql = fs.readFileSync( + path.join(folder, "0000_migration.sql"), + "utf-8", + ); + + expect(sql).toContain( + `CREATE TYPE "app"."author_role" AS ENUM('admin', 'writer');`, + ); + expect(sql).toContain(`CREATE TABLE "app"."authors"`); + expect(sql).toContain(`CREATE TABLE "app"."posts"`); + expect(sql).toMatch( + /"id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY/, + ); + expect(sql).toMatch( + /"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY/, + ); + expect(sql).toContain( + `"role" "app"."author_role" DEFAULT 'writer' NOT NULL`, + ); + expect(sql).toMatch( + /FOREIGN KEY \("author"\) REFERENCES "app"\."authors"\("id"\) ON DELETE cascade/, + ); + // The migrator creates the schema as the identity that applies it; generated SQL never does. + expect(sql).not.toMatch(/CREATE SCHEMA/i); + expect(sql).toContain("--> statement-breakpoint\n"); + + expect(listFiles(folder)).toEqual([ + "0000_migration.sql", + path.join("meta", "0000_snapshot.json"), + path.join("meta", "_journal.json"), + ]); + const journal = readJournal(folder); + expect(journal).toMatchObject({ + version: "7", + dialect: "postgresql", + entries: [ + { idx: 0, version: "7", tag: "0000_migration", breakpoints: true }, + ], + }); + const snapshot = JSON.parse( + fs.readFileSync(path.join(folder, "meta/0000_snapshot.json"), "utf-8"), + ); + expect(Object.keys(snapshot.tables)).toEqual(["app.authors", "app.posts"]); + expect(snapshot.prevId).toBe("00000000-0000-0000-0000-000000000000"); + }); + + test("returns null and writes nothing when the schema matches the latest snapshot", async () => { + const folder = tempFolder(); + await generateMigrationFiles({ + folder, + schema: blog(), + interactive: false, + }); + const before = listFiles(folder).map((file) => [ + file, + fs.readFileSync(path.join(folder, file), "utf-8"), + ]); + + await expect( + generateMigrationFiles({ folder, schema: blog(), interactive: false }), + ).resolves.toBeNull(); + expect( + listFiles(folder).map((file) => [ + file, + fs.readFileSync(path.join(folder, file), "utf-8"), + ]), + ).toEqual(before); + }); + + test("an added column produces the next migration with an ALTER TABLE", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + const folder = tempFolder(); + await generateMigrationFiles({ + folder, + schema: blog(), + interactive: false, + }); + + // A clock that did not move must still yield a newer journal entry. + const next = await generateMigrationFiles({ + folder, + schema: blog({ views: integer().notNull().default(0) }), + interactive: false, + }); + + expect(next?.tag).toBe("0001_migration"); + expect(next?.statements).toEqual([ + `ALTER TABLE "app"."posts" ADD COLUMN "views" integer DEFAULT 0 NOT NULL;`, + ]); + const journal = readJournal(folder); + expect(journal?.entries.map((entry) => entry.tag)).toEqual([ + "0000_migration", + "0001_migration", + ]); + const [first, second] = journal?.entries ?? []; + expect(second.when).toBeGreaterThan(first.when); + const prev = JSON.parse( + fs.readFileSync(path.join(folder, "meta/0000_snapshot.json"), "utf-8"), + ); + const cur = JSON.parse( + fs.readFileSync(path.join(folder, "meta/0001_snapshot.json"), "utf-8"), + ); + expect(cur.prevId).toBe(prev.id); + }); + + test("a non-interactive column rename throws instead of prompting, and writes nothing", async () => { + const folder = tempFolder(); + await generateMigrationFiles({ + folder, + schema: blog({ summary: text() }), + interactive: false, + }); + const before = listFiles(folder); + + const error = await generateMigrationFiles({ + folder, + schema: blog({ abstract: text() }), + interactive: false, + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(MigrationRenameAmbiguityError); + expect(error).toMatchObject({ + kind: "column", + table: "app.posts", + created: ["abstract"], + deleted: ["summary"], + }); + expect((error as Error).message).toMatch(/interactive terminal/); + expect(listFiles(folder)).toEqual(before); + }); + + test("a non-interactive table rename throws too", async () => { + const folder = tempFolder(); + const named = (name: string) => + defineSchema(({ table }) => ({ [name]: table(name, { id: id() }) })); + await generateMigrationFiles({ + folder, + schema: named("notes"), + interactive: false, + }); + + await expect( + generateMigrationFiles({ + folder, + schema: named("memos"), + interactive: false, + }), + ).rejects.toMatchObject({ + name: "MigrationRenameAmbiguityError", + kind: "table", + created: ["app.memos"], + deleted: ["app.notes"], + }); + }); + + test("an empty schema with no history writes nothing", async () => { + const folder = tempFolder(); + await expect( + generateMigrationFiles({ + folder, + schema: defineSchema(() => ({})), + interactive: false, + }), + ).resolves.toBeNull(); + expect(listFiles(folder)).toEqual([]); + }); + + test("drizzle-orm's runtime migrator reads the written files", async () => { + const folder = tempFolder(); + await generateMigrationFiles({ + folder, + schema: blog(), + interactive: false, + }); + await generateMigrationFiles({ + folder, + schema: blog({ views: integer() }), + interactive: false, + }); + + const migrations = readMigrationFiles({ migrationsFolder: folder }); + const journal = readJournal(folder); + expect(migrations.map((migration) => migration.folderMillis)).toEqual( + journal?.entries.map((entry) => entry.when), + ); + // Four statements: the enum, two tables, the FK. + expect(migrations[0].sql).toHaveLength(4); + expect(migrations[1].sql).toEqual([ + `ALTER TABLE "app"."posts" ADD COLUMN "views" integer;`, + ]); + }); + + test("a non-interactive enum rename throws too, and writes nothing", async () => { + const folder = tempFolder(); + const roles = (enumName: string) => + defineSchema(({ table }) => ({ + users: table("users", { + id: id(), + role: enumColumn(enumName, ["admin", "member"]), + }), + })); + await generateMigrationFiles({ + folder, + schema: roles("user_role"), + interactive: false, + }); + const before = listFiles(folder); + + const error = await generateMigrationFiles({ + folder, + schema: roles("member_role"), + interactive: false, + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(MigrationRenameAmbiguityError); + expect(error).toMatchObject({ + kind: "enum", + created: ["app.member_role"], + deleted: ["app.user_role"], + }); + expect(listFiles(folder)).toEqual(before); + }); + + test("refuses to diff when the latest snapshot is missing", async () => { + const folder = tempFolder(); + await generateMigrationFiles({ + folder, + schema: blog(), + interactive: false, + }); + fs.rmSync(path.join(folder, "meta/0000_snapshot.json")); + await expect( + generateMigrationFiles({ + folder, + schema: blog({ views: integer() }), + interactive: false, + }), + ).rejects.toThrow(/Restore it from version control/); + }); + + test("a corrupt snapshot names the file and how to recover", async () => { + const folder = tempFolder(); + await generateMigrationFiles({ + folder, + schema: blog(), + interactive: false, + }); + const snapshot = path.join(folder, "meta/0000_snapshot.json"); + fs.writeFileSync(snapshot, "{ not json"); + + await expect( + generateMigrationFiles({ + folder, + schema: blog({ views: integer() }), + interactive: false, + }), + ).rejects.toThrow( + new RegExp( + `^Cannot read ${snapshot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}: .+\\. Restore it from version control\\.$`, + ), + ); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/naming.test.ts b/packages/appkit/src/database/tooling/tests/naming.test.ts new file mode 100644 index 000000000..423f33ad0 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/naming.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vitest"; + +import { devBranchId, isValidSessionId, sessionBranchId } from "../naming"; + +const BRANCH_ID = /^[a-z][a-z0-9-]{0,62}$/; + +describe("branch naming", () => { + test("dev branches are dev--<6 hex>, stable and case-insensitive", () => { + const id = devBranchId("Alice.Smith@example.com"); + expect(id).toMatch(/^dev-alice-smith-[0-9a-f]{6}$/); + expect(devBranchId("alice.smith@example.com")).toBe(id); + expect(id).toMatch(BRANCH_ID); + }); + + test("users sharing a local part get different branches", () => { + expect(devBranchId("alice@example.com")).not.toBe( + devBranchId("alice@example.org"), + ); + }); + + test("long or odd local parts stay within the 63-character limit", () => { + const id = devBranchId(`${"x".repeat(80)}@example.com`); + expect(id.length).toBeLessThanOrEqual(63); + expect(id).toMatch(BRANCH_ID); + expect(devBranchId("__@example.com")).toMatch(/^dev-user-[0-9a-f]{6}$/); + }); + + test("a service principal's client ID makes a valid branch ID too", () => { + expect(devBranchId("11111111-2222-3333-4444-555555555555")).toMatch( + BRANCH_ID, + ); + }); + + test("session branches are session-; ids are lowercase base36", () => { + expect(sessionBranchId("k2x9q0ab")).toBe("session-k2x9q0ab"); + expect(isValidSessionId("Bad-Id")).toBe(false); + expect(() => sessionBranchId("Bad-Id")).toThrow(/APPKIT_DB_SESSION/); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/targets.test.ts b/packages/appkit/src/database/tooling/tests/targets.test.ts new file mode 100644 index 000000000..ac159a7df --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/targets.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, test } from "vitest"; + +import { + parseResourceName, + readDeployBranches, + readLocalTarget, +} from "../targets"; +import { cleanupTempApps, databricksYml, tempApp } from "./fakes"; + +afterEach(cleanupTempApps); + +describe("parseResourceName", () => { + test("splits project, branch, endpoint and database names", () => { + expect( + parseResourceName("projects/p/branches/b/endpoints/primary"), + ).toEqual({ + project: "projects/p", + branch: "projects/p/branches/b", + endpoint: "projects/p/branches/b/endpoints/primary", + }); + expect( + parseResourceName("projects/p/branches/b/databases/databricks-postgres"), + ).toEqual({ + project: "projects/p", + branch: "projects/p/branches/b", + database: "projects/p/branches/b/databases/databricks-postgres", + }); + expect(parseResourceName("projects/p")).toEqual({ project: "projects/p" }); + }); + + test("template placeholders and junk are null", () => { + expect(parseResourceName("your_postgres_endpointPath")).toBeNull(); + expect(parseResourceName("")).toBeNull(); + expect(parseResourceName(undefined)).toBeNull(); + }); +}); + +describe("readLocalTarget", () => { + test("reads the .env endpoint and this checkout's session", () => { + expect( + readLocalTarget({ + LAKEBASE_ENDPOINT: "projects/p/branches/b/endpoints/primary", + PGHOST: "ep.example.com", + APPKIT_DB_SESSION: "k2x9q0ab", + }), + ).toEqual({ + endpoint: "projects/p/branches/b/endpoints/primary", + session: "k2x9q0ab", + }); + }); + + test("placeholders from .env.example read as not configured", () => { + expect( + readLocalTarget({ + LAKEBASE_ENDPOINT: "your_postgres_endpointPath", + APPKIT_DB_SESSION: "NOT VALID", + }), + ).toEqual({ endpoint: null, session: null }); + }); +}); + +describe("readDeployBranches", () => { + test("reads the default target's postgres_branch", () => { + const root = tempApp({ + "databricks.yml": databricksYml( + "projects/p/branches/production", + "projects/p/branches/production/databases/databricks-postgres", + ), + }); + expect(readDeployBranches(root)).toEqual([ + "projects/p/branches/production", + ]); + }); + + test("every target's branch counts, and the variable's default", () => { + const root = tempApp({ + "databricks.yml": `variables: + postgres_branch: + default: projects/p/branches/main +targets: + default: + default: true + personal: + variables: + postgres_branch: projects/p/branches/dev-alice-1a2b3c + staging: + variables: + postgres_branch: projects/p/branches/staging +`, + }); + expect(readDeployBranches(root)).toEqual([ + "projects/p/branches/main", + "projects/p/branches/dev-alice-1a2b3c", + "projects/p/branches/staging", + ]); + }); + + test("empty without databricks.yml or without a postgres binding", () => { + expect(readDeployBranches(tempApp())).toEqual([]); + expect( + readDeployBranches(tempApp({ "databricks.yml": "bundle:\n name: x\n" })), + ).toEqual([]); + }); + + test("an invalid databricks.yml throws instead of reading as no binding", () => { + const root = tempApp({ "databricks.yml": "targets:\n default: [\n" }); + expect(() => readDeployBranches(root)).toThrow( + /^Cannot read databricks\.yml, so the branches it deploys to are unknown: .+\(\d+:\d+\)$/, + ); + }); +}); diff --git a/packages/appkit/src/plugins/database/load-schema.ts b/packages/appkit/src/plugins/database/load-schema.ts index 9378dfd6f..fa9f3ef0f 100644 --- a/packages/appkit/src/plugins/database/load-schema.ts +++ b/packages/appkit/src/plugins/database/load-schema.ts @@ -8,9 +8,15 @@ import { assertFinalizedSchema } from "../../database/schema-builder/define-sche /** Same application-relative convention used by appkit generate-types. */ const DEFAULT_SCHEMA_FILE = "config/database/schema.ts"; +interface LoadSchemaOptions { + /** Report why the module failed to import. Only for local CLI runs: see the catch below. */ + readonly showImportError?: boolean; +} + /** Load application-owned declarations during setup, never during registration. */ export async function loadDefaultDatabaseSchema( root = process.cwd(), + options: LoadSchemaOptions = {}, ): Promise { const file = path.resolve(root, DEFAULT_SCHEMA_FILE); const label = JSON.stringify(file); @@ -34,8 +40,14 @@ export async function loadDefaultDatabaseSchema( module = await createJiti(import.meta.url, { moduleCache: false }).import( file, ); - } catch { + } catch (error) { // Import errors can contain credentials, row values, or application internals. + // A CLI run shows them to the developer who wrote the file; the runtime never does. + if (options.showImportError) { + throw new Error(`Could not load ${DEFAULT_SCHEMA_FILE}.`, { + cause: error, + }); + } throw databaseSetupFailed( `Could not load the default schema at ${label}. Check the module and its imports, or pass database({ schema }).`, ); diff --git a/packages/appkit/src/plugins/database/schema-check.ts b/packages/appkit/src/plugins/database/schema-check.ts index 69a6d575e..1a9067e78 100644 --- a/packages/appkit/src/plugins/database/schema-check.ts +++ b/packages/appkit/src/plugins/database/schema-check.ts @@ -78,7 +78,7 @@ export async function assertSchemaMatchesDatabase( const absent = tableNames.filter((name) => !found.has(name)); const advice = (await inPublicInstead(dataPath, schemaName, absent)) ?? - "Create them, or commit migrations under config/database/migrations and set APPKIT_DB_MIGRATE_ON_START=true in app.yaml so the deployed app applies them on start."; + "Run `npx appkit db migrate` locally, or commit migrations under config/database/migrations and set APPKIT_DB_MIGRATE_ON_START=true in app.yaml so the deployed app applies them on start."; throw databaseSetupFailed( `the declared schema does not match the database: ${problems.join("; ")}. ${advice}`, ); diff --git a/packages/appkit/tsdown.config.ts b/packages/appkit/tsdown.config.ts index bb17c29ba..d11a7b42d 100644 --- a/packages/appkit/tsdown.config.ts +++ b/packages/appkit/tsdown.config.ts @@ -14,6 +14,7 @@ export default defineConfig([ "src/testing/index.ts", "src/tsdown/index.ts", "src/type-generator/index.ts", + "src/database/tooling/index.ts", ], outDir: "dist", hash: false, diff --git a/packages/shared/src/cli/commands/db/database-tooling.d.ts b/packages/shared/src/cli/commands/db/database-tooling.d.ts new file mode 100644 index 000000000..b1c18beee --- /dev/null +++ b/packages/shared/src/cli/commands/db/database-tooling.d.ts @@ -0,0 +1,39 @@ +/** + * Intentionally narrowed mirror of `@databricks/appkit/database-tooling`. + * + * `shared` is a leaf package and must not statically depend on `appkit`, so the + * CLI loads the tooling via a dynamic `import(...)` and this declares its shape + * without a build-time dependency (same pattern as ../type-generator.d.ts). + * There is NO compile-time link to the real exports — if they change, re-sync + * this by hand against `packages/appkit/src/database/tooling/index.ts`. + */ +declare module "@databricks/appkit/database-tooling" { + export interface DbContext { + readonly root: string; + readonly env: NodeJS.ProcessEnv; + } + + export function createDbContext(options?: { + root?: string; + env?: NodeJS.ProcessEnv; + }): DbContext; + + export interface GeneratedMigration { + readonly tag: string; + readonly file: string; + readonly statements: readonly string[]; + } + + export interface MigrateReport { + readonly generated: GeneratedMigration | null; + readonly applied: readonly string[]; + readonly adopted?: readonly string[]; + readonly notApplied?: string; + readonly typesFile: string; + } + + export function runMigrate( + ctx: DbContext, + options: { interactive: boolean }, + ): Promise; +} diff --git a/packages/shared/src/cli/commands/db/index.ts b/packages/shared/src/cli/commands/db/index.ts new file mode 100644 index 000000000..aca41b888 --- /dev/null +++ b/packages/shared/src/cli/commands/db/index.ts @@ -0,0 +1,11 @@ +import { Command } from "commander"; + +import { dbMigrateCommand } from "./migrate"; + +/** + * Lakebase database lifecycle for the DatabasePlugin. The logic lives in + * `@databricks/appkit/database-tooling`; these commands only parse, prompt and print. + */ +export const dbCommand = new Command("db") + .description("Lakebase database lifecycle") + .addCommand(dbMigrateCommand); diff --git a/packages/shared/src/cli/commands/db/migrate.test.ts b/packages/shared/src/cli/commands/db/migrate.test.ts new file mode 100644 index 000000000..1640b5223 --- /dev/null +++ b/packages/shared/src/cli/commands/db/migrate.test.ts @@ -0,0 +1,152 @@ +import path from "node:path"; + +import { + afterEach, + beforeEach, + describe, + expect, + type MockInstance, + test, + vi, +} from "vitest"; + +const { createDbContext, runMigrate } = vi.hoisted(() => ({ + createDbContext: vi.fn(() => ({ root: "/app", env: {} })), + runMigrate: vi.fn(), +})); + +// The tooling is an ambient module loaded with a dynamic import; mock it so the +// command never touches drizzle-kit or a database. +vi.mock("@databricks/appkit/database-tooling", () => ({ + createDbContext, + runMigrate, +})); + +import { dbCommand } from "./index"; + +let log: MockInstance; +let error: MockInstance; + +function output(spy: MockInstance): string { + return spy.mock.calls.map((call) => call.join(" ")).join("\n"); +} + +beforeEach(() => { + runMigrate.mockReset(); + createDbContext.mockClear(); + log = vi.spyOn(console, "log").mockImplementation(() => undefined); + error = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; +}); + +const typesFile = path.join(process.cwd(), "shared/appkit-types/database.d.ts"); + +describe("appkit db migrate", () => { + test("is registered under the db group", () => { + expect(dbCommand.commands.map((command) => command.name())).toContain( + "migrate", + ); + }); + + test("prints the generated file, the applied tags and the types file", async () => { + runMigrate.mockResolvedValue({ + generated: { + tag: "0001_migration", + file: path.join( + process.cwd(), + "config/database/migrations/0001_migration.sql", + ), + statements: ["a", "b"], + }, + applied: ["0001_migration"], + typesFile, + }); + + await dbCommand.parseAsync(["migrate"], { from: "user" }); + + expect(createDbContext).toHaveBeenCalledWith(); + expect(runMigrate).toHaveBeenCalledWith( + { root: "/app", env: {} }, + // Vitest workers have no TTY, so the run is non-interactive. + { interactive: false }, + ); + const printed = output(log); + expect(printed).toContain( + `Generated ${path.join("config/database/migrations/0001_migration.sql")} (2 statements)`, + ); + expect(printed).toContain("Applied 0001_migration to the .env branch"); + expect(printed).toContain( + `Generated ${path.join("shared/appkit-types/database.d.ts")}`, + ); + expect(process.exitCode).toBeUndefined(); + }); + + test("reports an unchanged schema and an up-to-date branch", async () => { + runMigrate.mockResolvedValue({ generated: null, applied: [], typesFile }); + + await dbCommand.parseAsync(["migrate"], { from: "user" }); + + const printed = output(log); + expect(printed).toContain("No schema changes since the last migration"); + expect(printed).toContain("The .env branch is up to date"); + }); + + test("reports a baseline adopted without running it", async () => { + runMigrate.mockResolvedValue({ + generated: null, + applied: ["0001_migration"], + adopted: ["0000_baseline"], + typesFile, + }); + + await dbCommand.parseAsync(["migrate"], { from: "user" }); + + const printed = output(log); + expect(printed).toContain( + "Adopted 0000_baseline: its tables already existed, so it was recorded without running", + ); + expect(printed).toContain("Applied 0001_migration to the .env branch"); + }); + + test("says why nothing was applied", async () => { + runMigrate.mockResolvedValue({ + generated: null, + applied: [], + notApplied: "no branch configured — run appkit db init", + typesFile, + }); + + await dbCommand.parseAsync(["migrate"], { from: "user" }); + + expect(output(log)).toContain( + "Not applied: no branch configured — run appkit db init", + ); + expect(process.exitCode).toBeUndefined(); + }); + + test("prints the error and its root cause, and exits 1", async () => { + const driverError = new Error('relation "notes" already exists'); + const wrapped = new Error("Failed query: CREATE TABLE ...", { + cause: driverError, + }); + runMigrate.mockRejectedValue( + new Error( + "Applying migration 0000_migration failed (SQLSTATE 42P07); none were applied.", + { cause: wrapped }, + ), + ); + + await dbCommand.parseAsync(["migrate"], { from: "user" }); + + const printed = output(error); + expect(printed).toContain( + "Applying migration 0000_migration failed (SQLSTATE 42P07); none were applied.", + ); + expect(printed).toContain('relation "notes" already exists'); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/packages/shared/src/cli/commands/db/migrate.ts b/packages/shared/src/cli/commands/db/migrate.ts new file mode 100644 index 000000000..8d061f6b0 --- /dev/null +++ b/packages/shared/src/cli/commands/db/migrate.ts @@ -0,0 +1,31 @@ +import { Command } from "commander"; + +import { fail, isInteractive, printMigrateReport } from "./output"; + +async function runDbMigrate(): Promise { + try { + const tooling = await import("@databricks/appkit/database-tooling"); + const report = await tooling.runMigrate(tooling.createDbContext(), { + interactive: isInteractive(), + }); + printMigrateReport(report); + } catch (error) { + fail(error); + } +} + +export const dbMigrateCommand = new Command("migrate") + .description( + "Generate a migration from config/database/schema.ts, apply pending migrations to the .env branch, and regenerate database.d.ts", + ) + .addHelpText( + "after", + ` +Migrations are written to config/database/migrations/. Commit them with schema.ts; +the deployed app applies pending committed migrations when it starts. They are +applied locally only on your dev or session branch (see \`appkit db init\`). + +Examples: + $ appkit db migrate`, + ) + .action(runDbMigrate); diff --git a/packages/shared/src/cli/commands/db/output.ts b/packages/shared/src/cli/commands/db/output.ts new file mode 100644 index 000000000..2cfba1973 --- /dev/null +++ b/packages/shared/src/cli/commands/db/output.ts @@ -0,0 +1,68 @@ +import path from "node:path"; +import process from "node:process"; + +import type { MigrateReport } from "@databricks/appkit/database-tooling"; +import pc from "picocolors"; + +/** Prompts only when a human can answer them. */ +export function isInteractive(): boolean { + return Boolean(process.stdin.isTTY && process.stdout.isTTY); +} + +export function relative(file: string): string { + return path.relative(process.cwd(), file) || file; +} + +export const ok = (message: string) => + console.log(`${pc.green("✔")} ${message}`); +export const info = (message: string) => + console.log(`${pc.dim("•")} ${message}`); +export const warn = (message: string) => + console.log(`${pc.yellow("!")} ${message}`); + +/** Innermost message: drizzle wraps the driver error, whose text names the failing object. */ +function rootCause(error: unknown): string | undefined { + let current = (error as { cause?: unknown } | undefined)?.cause; + let message: string | undefined; + for (let depth = 0; depth < 5 && current instanceof Error; depth++) { + message = current.message; + current = (current as { cause?: unknown }).cause; + } + return message; +} + +/** Print the error and its root cause, and fail the process. */ +export function fail(error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + console.error(`${pc.red("✖")} ${message}`); + const cause = rootCause(error); + if (cause && cause !== message && !message.includes(cause)) + console.error(` ${cause}`); + process.exitCode = 1; +} + +export function printMigrateReport(report: MigrateReport): void { + if (report.generated) { + const count = report.generated.statements.length; + ok( + `Generated ${relative(report.generated.file)} (${count} statement${count === 1 ? "" : "s"})`, + ); + } else { + info("No schema changes since the last migration"); + } + + if (report.adopted?.length) { + ok( + `Adopted ${report.adopted.join(", ")}: its tables already existed, so it was recorded without running`, + ); + } + if (report.applied.length > 0) { + ok(`Applied ${report.applied.join(", ")} to the .env branch`); + } else if (report.notApplied) { + warn(`Not applied: ${report.notApplied}`); + } else if (!report.adopted?.length) { + info("The .env branch is up to date"); + } + + ok(`Generated ${relative(report.typesFile)}`); +} diff --git a/packages/shared/src/cli/index.ts b/packages/shared/src/cli/index.ts index af8c22e6e..2fce08005 100644 --- a/packages/shared/src/cli/index.ts +++ b/packages/shared/src/cli/index.ts @@ -8,6 +8,7 @@ import { Command } from "commander"; import { agentCommand } from "./commands/agent/index.js"; import { codemodCommand } from "./commands/codemod/index.js"; +import { dbCommand } from "./commands/db/index.js"; import { docsCommand } from "./commands/docs.js"; import { doctorCommand } from "./commands/doctor/index.js"; import { generateTypesCommand } from "./commands/generate-types.js"; @@ -35,6 +36,8 @@ cmd.addCommand(docsCommand); cmd.addCommand(pluginCommand); cmd.addCommand(codemodCommand); cmd.addCommand(doctorCommand); +// Hidden from --help until the database plugin is offered by `databricks apps init`. +cmd.addCommand(dbCommand, { hidden: true }); // Registry commands are executable but hidden from --help while the feature // is still in development (registry + add work end-to-end but aren't announced). cmd.addCommand(registryCommand, { hidden: true }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ced623e4..8f51d4855 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,7 +132,7 @@ importers: version: 19.2.3(@types/react@19.2.7) '@vitejs/plugin-react': specifier: 5.1.1 - version: 5.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2)) + version: 5.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2)) eslint: specifier: 9.39.1 version: 9.39.1(jiti@2.6.1) @@ -153,7 +153,7 @@ importers: version: 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) vite: specifier: 7.2.4 - version: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) + version: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2) apps/dev-playground: dependencies: @@ -368,7 +368,7 @@ importers: version: link:../shared vite: specifier: npm:rolldown-vite@7.1.14 - version: rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) + version: rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2) ws: specifier: 8.21.0 version: 8.21.0 @@ -399,13 +399,16 @@ importers: version: 8.18.1 '@vitejs/plugin-react': specifier: 5.1.1 - version: 5.1.1(rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2)) + version: 5.1.1(rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2)) autoevals: specifier: 0.3.0 version: 0.3.0(ws@8.21.0)(zod@4.3.6) + drizzle-kit: + specifier: 0.31.10 + version: 0.31.10 vitest: specifier: 3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2) packages/appkit-ui: dependencies: @@ -2189,6 +2192,9 @@ packages: resolution: {integrity: sha512-lBSBiRruFurFKXr5Hbsl2thmGweAPmddhF3jb99U4EMDA5L+e5Y1rAkOS07Nvrup7HUMBDrCV45meaxZnt28nQ==} engines: {node: '>=20.0'} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -2198,162 +2204,458 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + '@esbuild/aix-ppc64@0.25.10': resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.10': resolution: {integrity: sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.10': resolution: {integrity: sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.10': resolution: {integrity: sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.10': resolution: {integrity: sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.10': resolution: {integrity: sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.10': resolution: {integrity: sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.10': resolution: {integrity: sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.10': resolution: {integrity: sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.10': resolution: {integrity: sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.10': resolution: {integrity: sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.10': resolution: {integrity: sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.10': resolution: {integrity: sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.10': resolution: {integrity: sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.10': resolution: {integrity: sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.10': resolution: {integrity: sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.10': resolution: {integrity: sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.10': resolution: {integrity: sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.10': resolution: {integrity: sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.10': resolution: {integrity: sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.10': resolution: {integrity: sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.10': resolution: {integrity: sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.10': resolution: {integrity: sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.10': resolution: {integrity: sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.10': resolution: {integrity: sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.10': resolution: {integrity: sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.0': resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -7174,6 +7476,10 @@ packages: resolution: {integrity: sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + drizzle-orm@0.45.2: resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} peerDependencies: @@ -7415,11 +7721,21 @@ packages: esast-util-from-js@2.0.1: resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.10: resolution: {integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==} engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -11736,6 +12052,11 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -15057,6 +15378,8 @@ snapshots: - uglify-js - webpack-cli + '@drizzle-team/brocli@0.10.2': {} + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -15073,84 +15396,238 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.13.6 + '@esbuild/aix-ppc64@0.25.10': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + '@esbuild/android-arm64@0.25.10': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + '@esbuild/android-arm@0.25.10': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + '@esbuild/android-x64@0.25.10': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + '@esbuild/darwin-arm64@0.25.10': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + '@esbuild/darwin-x64@0.25.10': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + '@esbuild/freebsd-arm64@0.25.10': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + '@esbuild/freebsd-x64@0.25.10': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + '@esbuild/linux-arm64@0.25.10': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + '@esbuild/linux-arm@0.25.10': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + '@esbuild/linux-ia32@0.25.10': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + '@esbuild/linux-loong64@0.25.10': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + '@esbuild/linux-mips64el@0.25.10': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + '@esbuild/linux-ppc64@0.25.10': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + '@esbuild/linux-riscv64@0.25.10': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + '@esbuild/linux-s390x@0.25.10': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + '@esbuild/linux-x64@0.25.10': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + '@esbuild/netbsd-arm64@0.25.10': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + '@esbuild/netbsd-x64@0.25.10': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + '@esbuild/openbsd-arm64@0.25.10': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + '@esbuild/openbsd-x64@0.25.10': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + '@esbuild/openharmony-arm64@0.25.10': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + '@esbuild/sunos-x64@0.25.10': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + '@esbuild/win32-arm64@0.25.10': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + '@esbuild/win32-ia32@0.25.10': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + '@esbuild/win32-x64@0.25.10': optional: true + '@esbuild/win32-x64@0.28.2': + optional: true + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@2.6.1))': dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -18601,7 +19078,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@5.1.1(rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2))': + '@vitejs/plugin-react@5.1.1(rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -18609,11 +19086,11 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.47 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) + vite: rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@5.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2))': + '@vitejs/plugin-react@5.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -18621,7 +19098,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.47 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -18657,13 +19134,13 @@ snapshots: optionalDependencies: vite: 7.2.4(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) - '@vitest/mocker@3.2.4(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2) '@vitest/pretty-format@3.2.4': dependencies: @@ -20373,6 +20850,13 @@ snapshots: dottie@2.0.6: {} + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.10 + tsx: 4.23.15 + drizzle-orm@0.45.2(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(pg@8.18.0): optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -20510,6 +20994,31 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + esbuild@0.25.10: optionalDependencies: '@esbuild/aix-ppc64': 0.25.10 @@ -20539,6 +21048,35 @@ snapshots: '@esbuild/win32-ia32': 0.25.10 '@esbuild/win32-x64': 0.25.10 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} escape-goat@4.0.0: {} @@ -24809,7 +25347,7 @@ snapshots: tsx: 4.20.6 yaml: 2.8.2 - rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2): + rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2): dependencies: '@oxc-project/runtime': 0.92.0 fdir: 6.5.0(picomatch@4.0.3) @@ -24824,7 +25362,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 terser: 5.44.1 - tsx: 4.20.6 + tsx: 4.23.15 yaml: 2.8.2 rolldown@1.0.0-beta.41: @@ -25711,6 +26249,12 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsx@4.23.15: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -26121,13 +26665,13 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2): + vite-node@3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -26153,7 +26697,7 @@ snapshots: - supports-color - typescript - vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2): + vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2): dependencies: esbuild: 0.25.10 fdir: 6.5.0(picomatch@4.0.3) @@ -26167,7 +26711,7 @@ snapshots: jiti: 2.6.1 lightningcss: 1.30.2 terser: 5.44.1 - tsx: 4.20.6 + tsx: 4.23.15 yaml: 2.8.2 vite@7.2.4(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2): @@ -26187,7 +26731,7 @@ snapshots: tsx: 4.20.6 yaml: 2.8.2 - vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2): + vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2): dependencies: esbuild: 0.25.10 fdir: 6.5.0(picomatch@4.0.3) @@ -26201,7 +26745,7 @@ snapshots: jiti: 2.6.1 lightningcss: 1.30.2 terser: 5.44.1 - tsx: 4.20.6 + tsx: 4.23.15 yaml: 2.8.2 vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.2)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2): @@ -26247,11 +26791,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -26269,8 +26813,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.15)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 From 1827ebdf6b67b86cd6ba9daf98f92c08ad8a6a10 Mon Sep 17 00:00:00 2001 From: ditadi Date: Mon, 19 Oct 2026 14:44:21 +0200 Subject: [PATCH 2/4] fix(appkit): let appkit db migrate recover from a failed migration A generated migration that fails to apply is removed and the journal restored, so a fix in schema.ts replaces it instead of stacking behind it. Also: refuse a drizzle-kit other than the pinned one or one loading another drizzle-orm, parse the branch and endpoint responses the guard decides on, type the CLI against a contract appkit's typecheck enforces, and enforce the drizzle-kit and tooling import boundary with oxlint. Signed-off-by: ditadi --- .oxlintrc.json | 50 +++++++ .../appkit/src/database/tooling/context.ts | 18 ++- packages/appkit/src/database/tooling/guard.ts | 6 +- packages/appkit/src/database/tooling/index.ts | 3 + .../src/database/tooling/lakebase-api.ts | 43 +++++- .../appkit/src/database/tooling/migrate.ts | 59 ++++++--- .../appkit/src/database/tooling/migrations.ts | 108 +++++++++++++++- .../database/tooling/tests/context.test.ts | 36 +++++- .../database/tooling/tests/contract.test.ts | 20 +++ .../tests/drizzle-kit-dependency.test.ts | 122 ++++++++++++------ .../tooling/tests/lakebase-api.test.ts | 47 ++++++- .../database/tooling/tests/migrate.pg.test.ts | 115 +++++++++++++++++ .../database/tooling/tests/migrate.test.ts | 87 ++++++++++++- .../database/tooling/tests/migrations.test.ts | 40 ++++++ .../src/plugins/database/load-schema.ts | 2 +- .../src/type-generator/database/generate.ts | 8 ++ .../src/type-generator/database/index.ts | 1 + .../src/cli/commands/db/database-tooling.d.ts | 43 +----- .../src/cli/commands/db/load-tooling.ts | 6 + .../shared/src/cli/commands/db/migrate.ts | 3 +- packages/shared/src/cli/commands/db/output.ts | 3 +- .../src/cli/commands/db/tooling-contract.ts | 37 ++++++ 22 files changed, 733 insertions(+), 124 deletions(-) create mode 100644 packages/appkit/src/database/tooling/tests/contract.test.ts create mode 100644 packages/appkit/src/database/tooling/tests/migrate.pg.test.ts create mode 100644 packages/shared/src/cli/commands/db/load-tooling.ts create mode 100644 packages/shared/src/cli/commands/db/tooling-contract.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 96f5c64da..e469237e4 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -61,6 +61,56 @@ "rules": { "no-restricted-imports": "off" } + }, + { + "files": ["packages/appkit/src/**"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "@databricks/sdk-experimental", + "@databricks/sdk-experimental/**" + ], + "message": "Import the Databricks SDK only through the wrapper in packages/shared/src/workspace-client. Add a re-export there if you need a new symbol." + }, + { + "group": ["drizzle-kit", "drizzle-kit/**"], + "message": "drizzle-kit is an optional dev dependency that deployed apps do not install. Only packages/appkit/src/database/tooling may import it." + }, + { + "group": [ + "**/tooling", + "**/tooling/**", + "@databricks/appkit/database-tooling" + ], + "message": "The database tooling may import drizzle-kit, so only the CLI loads it. Runtime code must not import it." + } + ] + } + ] + } + }, + { + "files": ["packages/appkit/src/database/tooling/**"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "@databricks/sdk-experimental", + "@databricks/sdk-experimental/**" + ], + "message": "Import the Databricks SDK only through the wrapper in packages/shared/src/workspace-client. Add a re-export there if you need a new symbol." + } + ] + } + ] + } } ], "ignorePatterns": [ diff --git a/packages/appkit/src/database/tooling/context.ts b/packages/appkit/src/database/tooling/context.ts index c8850dea0..580ee303d 100644 --- a/packages/appkit/src/database/tooling/context.ts +++ b/packages/appkit/src/database/tooling/context.ts @@ -1,10 +1,11 @@ import type { LakebasePoolConfig } from "@databricks/lakebase"; import type { Pool } from "pg"; -import { initializeLakebasePool } from "../../connectors/lakebase"; +import { createLakebasePool } from "../../connectors/lakebase"; import { endpointHosts } from "../../connectors/lakebase/endpoint-host"; import { getClientOptions } from "../../context/client-options"; import { createWorkspaceClient } from "../../workspace-client"; +import { currentIdentity } from "./identity"; import { LakebaseApi, type LakebaseEndpoint } from "./lakebase-api"; /** One REST call through the SDK's unified-auth client. */ @@ -97,18 +98,25 @@ async function assertHostServesEndpoint( } } -/** null when PGHOST / PGDATABASE / LAKEBASE_ENDPOINT are not configured. */ +/** + * null when PGHOST / PGDATABASE / LAKEBASE_ENDPOINT are not configured. The + * pool is created directly: initializeLakebasePool would look the endpoint and + * the user up a second time. + */ export async function openBranchPool(ctx: DbContext): Promise { const { PGHOST, PGDATABASE, LAKEBASE_ENDPOINT } = ctx.env; if (!PGHOST || !PGDATABASE || !LAKEBASE_ENDPOINT) return null; - await assertHostServesEndpoint(ctx, LAKEBASE_ENDPOINT, PGHOST); + const [user] = await Promise.all([ + ctx.env.PGUSER || currentIdentity(ctx).then(({ userName }) => userName), + assertHostServesEndpoint(ctx, LAKEBASE_ENDPOINT, PGHOST), + ]); const port = ctx.env.PGPORT ? Number(ctx.env.PGPORT) : undefined; const sslMode = ctx.env.PGSSLMODE as LakebasePoolConfig["sslMode"]; - return initializeLakebasePool({ + return createLakebasePool({ host: PGHOST, database: PGDATABASE, endpoint: LAKEBASE_ENDPOINT, - user: ctx.env.PGUSER || undefined, + user, ...(port ? { port } : {}), ...(sslMode ? { sslMode } : {}), // Only apiClient.request and currentUser.me are used (see ToolingWorkspaceClient). diff --git a/packages/appkit/src/database/tooling/guard.ts b/packages/appkit/src/database/tooling/guard.ts index f135e8258..001afd60b 100644 --- a/packages/appkit/src/database/tooling/guard.ts +++ b/packages/appkit/src/database/tooling/guard.ts @@ -65,13 +65,15 @@ export async function checkAppkitBranch( const local = readLocalTarget(ctx.env); const name = parseResourceName(local.endpoint ?? undefined)?.branch; if (!name) return null; - const branch = await ctx.api().getBranch(name); + const [branch, { userName }] = await Promise.all([ + ctx.api().getBranch(name), + currentIdentity(ctx), + ]); if (!branch) { throw new Error( `The .env branch ${name} does not exist (deleted or expired). Run \`appkit db init\` to create or pick one.`, ); } - const { userName } = await currentIdentity(ctx); return { branch, verdict: classifyBranch({ diff --git a/packages/appkit/src/database/tooling/index.ts b/packages/appkit/src/database/tooling/index.ts index 7167ac662..bbec5337a 100644 --- a/packages/appkit/src/database/tooling/index.ts +++ b/packages/appkit/src/database/tooling/index.ts @@ -2,6 +2,9 @@ * `@databricks/appkit/database-tooling` — CLI-only logic behind `appkit db`. * Loaded lazily by the shared CLI; it may import drizzle-kit, so the runtime * plugin never imports it. + * + * Internal to the AppKit CLI: it has no semver guarantee, and only the CLI of + * the same AppKit release should import it. */ export { createDbContext, type DbContext } from "./context"; export { type MigrateOptions, type MigrateReport, runMigrate } from "./migrate"; diff --git a/packages/appkit/src/database/tooling/lakebase-api.ts b/packages/appkit/src/database/tooling/lakebase-api.ts index 1bde6df9d..a460846a1 100644 --- a/packages/appkit/src/database/tooling/lakebase-api.ts +++ b/packages/appkit/src/database/tooling/lakebase-api.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + import type { ToolingRequest, ToolingWorkspaceClient } from "./context"; // Lakebase Postgres REST API through the SDK's unified-auth client. Paths, @@ -41,6 +43,28 @@ export class LakebaseApiError extends Error { const API = "/api/2.0/postgres"; +// The guard decides from these fields whether a command may write to a branch. +// A response without them must fail, not read as "not default, not protected". +const branchResponse = z.looseObject({ + name: z.string(), + status: z.looseObject({ + default: z.boolean().optional(), + is_protected: z.boolean().optional(), + current_state: z.string().optional(), + expire_time: z.string().optional(), + source_branch: z.string().optional(), + }), +}); + +const endpointResponse = z.looseObject({ + name: z.string(), + status: z + .looseObject({ + hosts: z.looseObject({ host: z.string().optional() }).optional(), + }) + .optional(), +}); + function statusCodeOf(error: unknown): number | undefined { const code = (error as { statusCode?: unknown } | undefined)?.statusCode; return typeof code === "number" ? code : undefined; @@ -56,11 +80,26 @@ export class LakebaseApi { ) {} getBranch(name: string): Promise { - return this.getOrNull(`get branch ${name}`, name); + return this.getParsedOrNull(`get branch ${name}`, name, branchResponse); } getEndpoint(name: string): Promise { - return this.getOrNull(`get endpoint ${name}`, name); + return this.getParsedOrNull(`get endpoint ${name}`, name, endpointResponse); + } + + private async getParsedOrNull( + operation: string, + name: string, + schema: z.ZodType, + ): Promise { + const response = await this.getOrNull(operation, name); + if (response === null) return null; + const parsed = schema.safeParse(response); + if (parsed.success) return parsed.data; + const issues = parsed.error.issues + .map((issue) => `${issue.path.join(".") || "body"}: ${issue.message}`) + .join("; "); + throw new LakebaseApiError(operation, `unexpected response (${issues})`); } private async call( diff --git a/packages/appkit/src/database/tooling/migrate.ts b/packages/appkit/src/database/tooling/migrate.ts index d0be9ec6d..0182d49ce 100644 --- a/packages/appkit/src/database/tooling/migrate.ts +++ b/packages/appkit/src/database/tooling/migrate.ts @@ -1,33 +1,50 @@ import fs from "node:fs"; import path from "node:path"; -import { loadDefaultDatabaseSchema } from "../../plugins/database/load-schema"; +import { + DEFAULT_SCHEMA_FILE, + loadDefaultDatabaseSchema, +} from "../../plugins/database/load-schema"; import { DATABASE_TYPES_FILE, - generateDatabaseTypes, + writeDatabaseTypes, } from "../../type-generator/database"; -import { applyPendingMigrations } from "../migrations/apply"; -import { migrationsFolder, readJournal } from "../migrations/paths"; +import { + applyPendingMigrations, + MigrationApplyError, +} from "../migrations/apply"; +import { + journalFile, + migrationsFolder, + readJournal, +} from "../migrations/paths"; import { type DbContext, openBranchPool } from "./context"; import { checkAppkitBranch } from "./guard"; -import { type GeneratedMigration, generateMigrationFiles } from "./migrations"; +import { + discardMigrationFiles, + type GeneratedMigration, + generateMigrationFiles, +} from "./migrations"; import { resourceId } from "./targets"; -/** Same application-relative conventions as the plugin and `appkit generate-types`. */ -const SCHEMA_FILE = "config/database/schema.ts"; - /** Whether the checkout has a config/database/schema.ts yet. */ function hasSchemaFile(root: string): boolean { - return fs.existsSync(path.join(root, SCHEMA_FILE)); + return fs.existsSync(path.join(root, DEFAULT_SCHEMA_FILE)); } /** Without schema.ts, point to introspect instead of a load error. */ function requireSchemaFile(root: string): void { if (hasSchemaFile(root)) return; throw new Error( - `No ${SCHEMA_FILE} yet, so there is nothing to migrate. Run \`appkit db introspect\` to write it from the branch's tables, or create it.`, + `No ${DEFAULT_SCHEMA_FILE} yet, so there is nothing to migrate. Run \`appkit db introspect\` to write it from the branch's tables, or create it.`, ); } + +/** The journal's text before this run writes to it; null when there is none. */ +function readJournalText(folder: string): string | null { + const file = journalFile(folder); + return fs.existsSync(file) ? fs.readFileSync(file, "utf-8") : null; +} const DATABASE_TYPES_PATH = path.join( "shared/appkit-types", DATABASE_TYPES_FILE, @@ -100,6 +117,7 @@ export async function runMigrate( showImportError: true, }); const folder = migrationsFolder(ctx.root); + const journalBefore = readJournalText(folder); const generated = await generateMigrationFiles({ folder, schema, @@ -108,18 +126,29 @@ export async function runMigrate( // The types follow schema.ts alone, so they are current even if applying fails. const typesFile = path.join(ctx.root, DATABASE_TYPES_PATH); - await generateDatabaseTypes({ - schemaFile: path.join(ctx.root, SCHEMA_FILE), - outFile: typesFile, - }); + await writeDatabaseTypes(schema, typesFile); let outcome: ApplyOutcome; try { outcome = await applyToEnvBranch(ctx, folder, schema.$schemaName); } catch (error) { if (!generated) throw error; + const file = path.relative(ctx.root, generated.file); + // A failed migration left in the journal runs first on every later try, so a + // fix in schema.ts would stack behind it and never apply. + if ( + error instanceof MigrationApplyError && + error.failed === generated.tag && + error.code + ) { + discardMigrationFiles(folder, generated, journalBefore); + throw new Error( + `${error.message} Removed ${file}, so the next run writes it again from ${DEFAULT_SCHEMA_FILE}. Change ${DEFAULT_SCHEMA_FILE} or the branch's data, then rerun \`appkit db migrate\`.`, + { cause: error }, + ); + } throw new Error( - `Wrote ${path.relative(ctx.root, generated.file)}, but did not apply it: ${error instanceof Error ? error.message : String(error)}`, + `Wrote ${file}, but did not apply it: ${error instanceof Error ? error.message : String(error)}`, { cause: error }, ); } diff --git a/packages/appkit/src/database/tooling/migrations.ts b/packages/appkit/src/database/tooling/migrations.ts index 135d4401d..021ca2595 100644 --- a/packages/appkit/src/database/tooling/migrations.ts +++ b/packages/appkit/src/database/tooling/migrations.ts @@ -1,4 +1,6 @@ +import { randomUUID } from "node:crypto"; import fs from "node:fs"; +import { createRequire } from "node:module"; import path from "node:path"; import { @@ -164,13 +166,19 @@ function findRenameAmbiguity( return null; } -/** Whether drizzle-kit itself is not installed; module loaders may wrap that error. */ +/** + * Whether drizzle-kit itself is not installed, or is a release without the + * `./api` export; module loaders may wrap that error. + */ function isDrizzleKitMissing(error: unknown): boolean { let current = error; for (let depth = 0; depth < 3 && current instanceof Error; depth++) { + const code = (current as { code?: unknown }).code; if ( - (current as { code?: unknown }).code === "ERR_MODULE_NOT_FOUND" && - /['"]drizzle-kit['"]/.test(current.message) + (code === "ERR_MODULE_NOT_FOUND" && + /['"]drizzle-kit['"]/.test(current.message)) || + (code === "ERR_PACKAGE_PATH_NOT_EXPORTED" && + current.message.includes("drizzle-kit")) ) { return true; } @@ -179,11 +187,65 @@ function isDrizzleKitMissing(error: unknown): boolean { return false; } +interface InstalledPackage { + readonly version: string; + /** The resolved entry file, to resolve that package's own imports from. */ + readonly entry: string; +} + +/** The package `name` resolves to from `from`; null when it does not resolve. */ +function installedPackage(name: string, from: string): InstalledPackage | null { + let entry: string; + try { + entry = createRequire(from).resolve(name); + } catch { + return null; + } + // Neither drizzle-kit nor drizzle-orm exports ./package.json. + for ( + let dir = path.dirname(entry); + dir !== path.dirname(dir); + dir = path.dirname(dir) + ) { + const file = path.join(dir, "package.json"); + if (!fs.existsSync(file)) continue; + const pkg = readJsonFile<{ name?: unknown; version?: unknown }>(file); + if (pkg.name === name && typeof pkg.version === "string") { + return { version: pkg.version, entry }; + } + } + return null; +} + +/** + * Why the drizzle-kit that resolves from `from` must not write migrations; + * undefined when it may, or when it is not installed at all. drizzle-kit reads + * the schema's tables through whichever drizzle-orm it resolves, and declares + * no drizzle-orm dependency of its own. + */ +export function drizzleKitMismatch( + from: string = import.meta.url, +): string | undefined { + const kit = installedPackage("drizzle-kit", from); + if (!kit) return undefined; + if (kit.version !== DRIZZLE_KIT_VERSION) { + return `AppKit writes migrations with drizzle-kit ${DRIZZLE_KIT_VERSION}, but ${kit.version} is installed. Install that version: npm install --save-dev drizzle-kit@${DRIZZLE_KIT_VERSION}`; + } + const orm = installedPackage("drizzle-orm", import.meta.url); + const kitOrm = installedPackage("drizzle-orm", kit.entry); + if (orm && kitOrm && kitOrm.version !== orm.version) { + return `drizzle-kit loads drizzle-orm ${kitOrm.version}, but AppKit builds schemas with ${orm.version}. Use the same version: npm install drizzle-orm@${orm.version}`; + } + return undefined; +} + /** * drizzle-kit is large, so apps install it as a dev dependency and it is loaded * only when a migration is generated. */ async function loadDrizzleKit(): Promise { + const mismatch = drizzleKitMismatch(); + if (mismatch) throw new Error(mismatch); try { return await import("drizzle-kit/api"); } catch (error) { @@ -195,6 +257,20 @@ async function loadDrizzleKit(): Promise { } } +/** Replace a file through a rename, so a crash never leaves it half written. */ +function writeFileAtomic(file: string, content: string): void { + const temporary = path.join( + path.dirname(file), + `.${path.basename(file)}.${randomUUID()}.tmp`, + ); + try { + fs.writeFileSync(temporary, content); + fs.renameSync(temporary, file); + } finally { + fs.rmSync(temporary, { force: true }); + } +} + /** * Diff the schema against the latest committed snapshot with drizzle-kit and * write the next v7 migration: `NNNN_migration.sql`, `meta/NNNN_snapshot.json`, @@ -257,6 +333,30 @@ export async function generateMigrationFiles( ); fs.writeFileSync(file, statements.join(BREAKPOINT)); // The journal is written last: it is what makes the migration visible. - fs.writeFileSync(journalFile(options.folder), JSON.stringify(next, null, 2)); + writeFileAtomic(journalFile(options.folder), JSON.stringify(next, null, 2)); return { tag, file, statements }; } + +/** + * Remove a migration generateMigrationFiles wrote and put back the journal it + * replaced (null: there was none, so the folders it created go too). + */ +export function discardMigrationFiles( + folder: string, + migration: GeneratedMigration, + journalBefore: string | null, +): void { + fs.rmSync(migration.file, { force: true }); + fs.rmSync(snapshotFile(folder, Number.parseInt(migration.tag, 10)), { + force: true, + }); + if (journalBefore !== null) { + writeFileAtomic(journalFile(folder), journalBefore); + return; + } + fs.rmSync(journalFile(folder), { force: true }); + for (const dir of [path.join(folder, "meta"), folder]) { + if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) + fs.rmdirSync(dir); + } +} diff --git a/packages/appkit/src/database/tooling/tests/context.test.ts b/packages/appkit/src/database/tooling/tests/context.test.ts index e7ac24dd5..d787f442f 100644 --- a/packages/appkit/src/database/tooling/tests/context.test.ts +++ b/packages/appkit/src/database/tooling/tests/context.test.ts @@ -1,11 +1,11 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -const { createWorkspaceClient, initializeLakebasePool } = vi.hoisted(() => ({ +const { createWorkspaceClient, createLakebasePool } = vi.hoisted(() => ({ createWorkspaceClient: vi.fn(), - initializeLakebasePool: vi.fn(), + createLakebasePool: vi.fn(), })); -vi.mock("../../../connectors/lakebase", () => ({ initializeLakebasePool })); +vi.mock("../../../connectors/lakebase", () => ({ createLakebasePool })); vi.mock("../../../workspace-client", () => ({ createWorkspaceClient })); import { getClientOptions } from "../../../context/client-options"; @@ -19,7 +19,7 @@ const HOST = "primary.dev-alice-3f9a2c.example.com"; const pool = { end: vi.fn() }; beforeEach(() => { - initializeLakebasePool.mockReset().mockResolvedValue(pool); + createLakebasePool.mockReset().mockReturnValue(pool); createWorkspaceClient.mockReset(); }); afterEach(cleanupTempApps); @@ -38,7 +38,7 @@ describe("openBranchPool", () => { ), ).resolves.toBeNull(); expect(lakebase.calls).toEqual([]); - expect(initializeLakebasePool).not.toHaveBeenCalled(); + expect(createLakebasePool).not.toHaveBeenCalled(); }); test("connects to PGHOST once it is a host of LAKEBASE_ENDPOINT", async () => { @@ -56,7 +56,7 @@ describe("openBranchPool", () => { await expect(openBranchPool(ctx)).resolves.toBe(pool); expect(lakebase.calls).toEqual([`getEndpoint ${ENDPOINT}`]); - expect(initializeLakebasePool).toHaveBeenCalledWith( + expect(createLakebasePool).toHaveBeenCalledWith( expect.objectContaining({ host: HOST.toUpperCase(), database: "databricks_postgres", @@ -67,6 +67,28 @@ describe("openBranchPool", () => { ); }); + test("without PGUSER the pool connects as the Databricks identity, and the endpoint is looked up once", async () => { + const lakebase = new FakeLakebase(); + lakebase.addBranch(BRANCH); + const ctx = fakeContext({ + root: tempApp(), + env: { + PGHOST: HOST, + PGDATABASE: "databricks_postgres", + LAKEBASE_ENDPOINT: ENDPOINT, + }, + userName: "4b2c1e0a-9d8f-4e7a-b6c5-3d2e1f0a9b8c", + api: lakebase.api(), + }); + + await openBranchPool(ctx); + + expect(lakebase.calls).toEqual([`getEndpoint ${ENDPOINT}`]); + expect(createLakebasePool).toHaveBeenCalledWith( + expect.objectContaining({ user: "4b2c1e0a-9d8f-4e7a-b6c5-3d2e1f0a9b8c" }), + ); + }); + test("refuses another branch's host, a missing endpoint, or a failed lookup, and never connects", async () => { const lakebase = new FakeLakebase(); lakebase.addBranch(BRANCH); @@ -114,7 +136,7 @@ describe("openBranchPool", () => { ); expect(error.cause).toBe(lookupFailure); - expect(initializeLakebasePool).not.toHaveBeenCalled(); + expect(createLakebasePool).not.toHaveBeenCalled(); }); }); diff --git a/packages/appkit/src/database/tooling/tests/contract.test.ts b/packages/appkit/src/database/tooling/tests/contract.test.ts new file mode 100644 index 000000000..f4e3765d3 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/contract.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "vitest"; + +import type { DatabaseTooling } from "../../../../../shared/src/cli/commands/db/tooling-contract"; +import type { DbContext } from "../context"; +import * as tooling from "../index"; + +// The CLI in `shared` types this module with DatabaseTooling and cannot see +// these exports, so the assignment below is what keeps the two in step. +const exports: DatabaseTooling = tooling; + +const contractKeys = { + createDbContext: true, + runMigrate: true, +} satisfies Record; + +test("every function the CLI is typed with is exported", () => { + for (const key of Object.keys(contractKeys) as (keyof DatabaseTooling)[]) { + expect(typeof exports[key]).toBe("function"); + } +}); diff --git a/packages/appkit/src/database/tooling/tests/drizzle-kit-dependency.test.ts b/packages/appkit/src/database/tooling/tests/drizzle-kit-dependency.test.ts index ec9bcb2d1..badf5ff76 100644 --- a/packages/appkit/src/database/tooling/tests/drizzle-kit-dependency.test.ts +++ b/packages/appkit/src/database/tooling/tests/drizzle-kit-dependency.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, test, vi } from "vitest"; @@ -18,24 +19,30 @@ afterEach(() => { }); /** Load ../migrations with `import("drizzle-kit/api")` failing like Node's resolver does. */ -async function withoutPackage(name: string) { +async function failingImport(error: { message: string; code: string }) { vi.resetModules(); vi.doMock("drizzle-kit/api", () => { - throw Object.assign( - new Error( - `Cannot find package '${name}' imported from /app/node_modules/@databricks/appkit/dist/database/tooling/migrations.js`, - ), - { code: "ERR_MODULE_NOT_FOUND" }, - ); + throw Object.assign(new Error(error.message), { code: error.code }); }); return import("../migrations"); } -function generate(migrations: typeof import("../migrations")) { +function withoutPackage(name: string) { + return failingImport({ + message: `Cannot find package '${name}' imported from /app/node_modules/@databricks/appkit/dist/database/tooling/migrations.js`, + code: "ERR_MODULE_NOT_FOUND", + }); +} + +function tempRoot(): string { const root = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-drizzle-kit-")); folders.push(root); + return root; +} + +function generate(migrations: typeof import("../migrations")) { return migrations.generateMigrationFiles({ - folder: path.join(root, "migrations"), + folder: path.join(tempRoot(), "migrations"), schema: defineSchema(({ table }) => ({ notes: table("notes", { id: id() }), })), @@ -43,16 +50,30 @@ function generate(migrations: typeof import("../migrations")) { }); } -/** Source files outside tests, relative to src/. */ -function sourceFiles(): string[] { - return (fs.readdirSync(SRC, { recursive: true }) as string[]).filter( - (file) => - /\.tsx?$/.test(file) && - !/\.test\.tsx?$/.test(file) && - !file.split(path.sep).includes("tests"), - ); +/** A file URL in an app whose node_modules holds these drizzle-kit and drizzle-orm versions. */ +function appWith(versions: { kit?: string; orm?: string }): string { + const root = tempRoot(); + for (const [name, version] of [ + ["drizzle-kit", versions.kit], + ["drizzle-orm", versions.orm], + ] as const) { + if (!version) continue; + const dir = path.join(root, "node_modules", name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, "package.json"), + JSON.stringify({ name, version, main: "index.js" }), + ); + fs.writeFileSync(path.join(dir, "index.js"), ""); + } + return pathToFileURL(path.join(root, "app.js")).href; } +const pkg = JSON.parse( + fs.readFileSync(path.resolve(SRC, "../package.json"), "utf-8"), +); +const ORM_VERSION: string = pkg.dependencies["drizzle-orm"]; + describe("drizzle-kit dependency", () => { test("without drizzle-kit, generating says how to install it", async () => { const migrations = await withoutPackage("drizzle-kit"); @@ -62,6 +83,51 @@ describe("drizzle-kit dependency", () => { ); }); + test("a drizzle-kit without the ./api export gets the same install hint", async () => { + const migrations = await failingImport({ + message: `Package subpath './api' is not defined by "exports" in /app/node_modules/drizzle-kit/package.json`, + code: "ERR_PACKAGE_PATH_NOT_EXPORTED", + }); + + await expect(generate(migrations)).rejects.toThrow( + `npm install --save-dev drizzle-kit@${migrations.DRIZZLE_KIT_VERSION}`, + ); + }); + + test("the pinned drizzle-kit on AppKit's drizzle-orm may write migrations", async () => { + const { DRIZZLE_KIT_VERSION, drizzleKitMismatch } = + await import("../migrations"); + + expect( + drizzleKitMismatch( + appWith({ kit: DRIZZLE_KIT_VERSION, orm: ORM_VERSION }), + ), + ).toBeUndefined(); + expect(drizzleKitMismatch(appWith({}))).toBeUndefined(); + }); + + test("another drizzle-kit version is refused with the command that installs the pinned one", async () => { + const { DRIZZLE_KIT_VERSION, drizzleKitMismatch } = + await import("../migrations"); + + expect( + drizzleKitMismatch(appWith({ kit: "0.30.6", orm: ORM_VERSION })), + ).toBe( + `AppKit writes migrations with drizzle-kit ${DRIZZLE_KIT_VERSION}, but 0.30.6 is installed. Install that version: npm install --save-dev drizzle-kit@${DRIZZLE_KIT_VERSION}`, + ); + }); + + test("a drizzle-kit that loads another drizzle-orm is refused", async () => { + const { DRIZZLE_KIT_VERSION, drizzleKitMismatch } = + await import("../migrations"); + + expect( + drizzleKitMismatch(appWith({ kit: DRIZZLE_KIT_VERSION, orm: "0.44.0" })), + ).toBe( + `drizzle-kit loads drizzle-orm 0.44.0, but AppKit builds schemas with ${ORM_VERSION}. Use the same version: npm install drizzle-orm@${ORM_VERSION}`, + ); + }); + test("another missing package is not reported as a missing drizzle-kit", async () => { const migrations = await withoutPackage("esbuild"); @@ -73,32 +139,10 @@ describe("drizzle-kit dependency", () => { test("it is an optional peer dependency pinned to DRIZZLE_KIT_VERSION, never a dependency", async () => { const { DRIZZLE_KIT_VERSION } = await import("../migrations"); - const pkg = JSON.parse( - fs.readFileSync(path.resolve(SRC, "../package.json"), "utf-8"), - ); expect(pkg.dependencies["drizzle-kit"]).toBeUndefined(); expect(pkg.peerDependencies["drizzle-kit"]).toBe(DRIZZLE_KIT_VERSION); expect(pkg.peerDependenciesMeta["drizzle-kit"]).toEqual({ optional: true }); expect(pkg.devDependencies["drizzle-kit"]).toBe(DRIZZLE_KIT_VERSION); }); - - test("only the tooling imports drizzle-kit, and no runtime module imports the tooling", () => { - const imports = (file: string, pattern: RegExp) => - pattern.test(fs.readFileSync(path.join(SRC, file), "utf-8")); - const tooling = path.join("database", "tooling"); - - expect( - sourceFiles().filter((file) => - imports(file, /(?:from\s+|import\s*\(\s*)["']drizzle-kit/), - ), - ).toEqual([path.join(tooling, "migrations.ts")]); - expect( - sourceFiles().filter( - (file) => - !file.startsWith(`${tooling}${path.sep}`) && - imports(file, /from\s+["'][^"']*\/tooling(?:\/[^"']*)?["']/), - ), - ).toEqual([]); - }); }); diff --git a/packages/appkit/src/database/tooling/tests/lakebase-api.test.ts b/packages/appkit/src/database/tooling/tests/lakebase-api.test.ts index 22adaa0f3..75d8a85e2 100644 --- a/packages/appkit/src/database/tooling/tests/lakebase-api.test.ts +++ b/packages/appkit/src/database/tooling/tests/lakebase-api.test.ts @@ -34,16 +34,20 @@ const apiError = (statusCode: number, message: string) => describe("LakebaseApi", () => { test("getBranch: GET /api/2.0/postgres/{name}; 404 → null; other errors keep their status", async () => { + const branch = { + name: "projects/p/branches/b", + status: { current_state: "READY" }, + }; const fake = client([ - { name: "projects/p/branches/b" }, + branch, apiError(404, "NOT_FOUND"), apiError(403, "PERMISSION_DENIED"), ]); const api = new LakebaseApi(fake); - await expect(api.getBranch("projects/p/branches/b")).resolves.toEqual({ - name: "projects/p/branches/b", - }); + await expect(api.getBranch("projects/p/branches/b")).resolves.toEqual( + branch, + ); await expect( api.getBranch("projects/p/branches/missing"), ).resolves.toBeNull(); @@ -84,4 +88,39 @@ describe("LakebaseApi", () => { ["GET", "/api/2.0/postgres/projects/p/branches/b/endpoints/gone"], ]); }); + + test("getBranch refuses a branch the guard could not classify: no status, or a status of the wrong type", async () => { + const api = new LakebaseApi( + client([ + { name: "projects/p/branches/b" }, + { name: "projects/p/branches/b", status: { default: "true" } }, + ]), + ); + + await expect(api.getBranch("projects/p/branches/b")).rejects.toThrow( + "Lakebase: get branch projects/p/branches/b failed: unexpected response (status: ", + ); + await expect(api.getBranch("projects/p/branches/b")).rejects.toThrow( + /unexpected response \(status\.default: /, + ); + }); + + test("getBranch keeps the fields it does not check", async () => { + const branch = { + name: "projects/p/branches/b", + uid: "br-1", + status: { default: true, logical_size_bytes: 10 }, + }; + const api = new LakebaseApi(client([branch])); + + await expect(api.getBranch(branch.name)).resolves.toEqual(branch); + }); + + test("getEndpoint refuses a response without a name", async () => { + const api = new LakebaseApi(client([{ status: {} }])); + + await expect( + api.getEndpoint("projects/p/branches/b/endpoints/primary"), + ).rejects.toThrow(/unexpected response \(name: /); + }); }); diff --git a/packages/appkit/src/database/tooling/tests/migrate.pg.test.ts b/packages/appkit/src/database/tooling/tests/migrate.pg.test.ts new file mode 100644 index 000000000..34e6c99db --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/migrate.pg.test.ts @@ -0,0 +1,115 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { Pool } from "pg"; +import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; + +const { checkAppkitBranch, openBranchPool } = vi.hoisted(() => ({ + checkAppkitBranch: vi.fn(), + openBranchPool: vi.fn(), +})); + +vi.mock("../guard", () => ({ checkAppkitBranch })); +vi.mock("../context", async (importOriginal) => ({ + ...(await importOriginal()), + openBranchPool, +})); + +import type { DbContext } from "../context"; +import { runMigrate } from "../migrate"; + +// drizzle-kit and the migrator against a real server: the branch guard and the +// Lakebase pool are replaced, nothing else. APPKIT_TEST_PG_URL points at a +// scratch database, e.g. postgres://localhost:5432/postgres +const url = process.env.APPKIT_TEST_PG_URL; +const builder = path.resolve( + import.meta.dirname, + "../../schema-builder/index.ts", +); + +describe.skipIf(!url)("runMigrate against Postgres", () => { + const schema = `migrate_${randomUUID().slice(0, 8)}`; + let admin: Pool; + let ctx: DbContext; + + const writeSchema = (columns: string) => { + const file = path.join(ctx.root, "config/database/schema.ts"); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + `import { defineSchema, id, text } from ${JSON.stringify(builder)}; + export const schema = defineSchema( + ({ table }) => ({ notes: table("notes", { ${columns} }) }), + { schemaName: ${JSON.stringify(schema)} }, + );`, + ); + }; + const journalTags = () => + ( + JSON.parse( + fs.readFileSync( + path.join(ctx.root, "config/database/migrations/meta/_journal.json"), + "utf-8", + ), + ) as { entries: { tag: string }[] } + ).entries.map((entry) => entry.tag); + + beforeAll(() => { + admin = new Pool({ connectionString: url, max: 1 }); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-migrate-pg-")); + ctx = { + root, + env: {}, + workspace: () => { + throw new Error("no workspace calls expected"); + }, + api: () => { + throw new Error("no Lakebase API calls expected"); + }, + }; + checkAppkitBranch.mockResolvedValue({ + branch: { name: "projects/p/branches/dev-alice-3f9a2c" }, + verdict: { appkitOwned: true }, + }); + openBranchPool.mockImplementation( + async () => new Pool({ connectionString: url, max: 2 }), + ); + }); + + afterAll(async () => { + await admin.query(`drop schema if exists "${schema}" cascade`); + await admin.end(); + fs.rmSync(ctx.root, { recursive: true, force: true }); + }); + + test("a migration that fails on the branch's data is removed, and the fixed schema.ts applies", async () => { + writeSchema("id: id(), body: text()"); + await expect( + runMigrate(ctx, { interactive: false }), + ).resolves.toMatchObject({ applied: ["0000_migration"] }); + await admin.query(`insert into "${schema}".notes (body) values ('kept')`); + + writeSchema("id: id(), body: text(), title: text().notNull()"); + const error = (await runMigrate(ctx, { interactive: false }).catch( + (caught: unknown) => caught, + )) as Error; + expect(error.message).toMatch( + /^Migration 0001_migration failed \(SQLSTATE 23502\) and was rolled back\. .*Removed .*0001_migration\.sql, so the next run writes it again/, + ); + expect(journalTags()).toEqual(["0000_migration"]); + + writeSchema( + 'id: id(), body: text(), title: text().notNull().default("untitled")', + ); + await expect( + runMigrate(ctx, { interactive: false }), + ).resolves.toMatchObject({ applied: ["0001_migration"] }); + expect(journalTags()).toEqual(["0000_migration", "0001_migration"]); + const { rows } = await admin.query<{ body: string; title: string }>( + `select body, title from "${schema}".notes`, + ); + expect(rows).toEqual([{ body: "kept", title: "untitled" }]); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/migrate.test.ts b/packages/appkit/src/database/tooling/tests/migrate.test.ts index 93f0eb500..619fd7f08 100644 --- a/packages/appkit/src/database/tooling/tests/migrate.test.ts +++ b/packages/appkit/src/database/tooling/tests/migrate.test.ts @@ -11,13 +11,17 @@ const { applyPendingMigrations, checkAppkitBranch, openBranchPool } = openBranchPool: vi.fn(), })); -vi.mock("../../migrations/apply", () => ({ applyPendingMigrations })); +vi.mock("../../migrations/apply", async (importOriginal) => ({ + ...(await importOriginal()), + applyPendingMigrations, +})); vi.mock("../guard", () => ({ checkAppkitBranch })); vi.mock("../context", async (importOriginal) => ({ ...(await importOriginal()), openBranchPool, })); +import { MigrationApplyError } from "../../migrations/apply"; import type { DbContext } from "../context"; import { runMigrate } from "../migrate"; @@ -27,9 +31,7 @@ const builder = path.resolve( ); const roots: string[] = []; -function app(tables: string): DbContext { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-db-migrate-")); - roots.push(root); +function writeSchema(root: string, tables: string): void { const file = path.join(root, "config/database/schema.ts"); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync( @@ -37,6 +39,12 @@ function app(tables: string): DbContext { `import { defineSchema, id, text } from ${JSON.stringify(builder)}; export const schema = defineSchema(({ table }) => ({ ${tables} }));`, ); +} + +function app(tables: string): DbContext { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-db-migrate-")); + roots.push(root); + writeSchema(root, tables); return { root, env: {}, @@ -214,6 +222,77 @@ describe("runMigrate", () => { ).toContain("notes"); }); + test("when the migration this run wrote fails to apply, it is removed so a fix in schema.ts can replace it", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + checkAppkitBranch.mockResolvedValue(null); + await runMigrate(ctx, { interactive: false }); + const folder = path.join(ctx.root, "config/database/migrations"); + const journal = fs.readFileSync( + path.join(folder, "meta/_journal.json"), + "utf-8", + ); + + writeSchema( + ctx.root, + `notes: table("notes", { id: id(), title: text().notNull() })`, + ); + checkAppkitBranch.mockResolvedValue(devBranch); + openBranchPool.mockResolvedValue(pool); + const failure = new MigrationApplyError({ + step: "Applying migrations", + failed: "0001_migration", + pending: ["0001_migration"], + cause: Object.assign( + new Error('column "title" of relation "notes" contains null values'), + { code: "23502" }, + ), + }); + applyPendingMigrations.mockRejectedValue(failure); + + const error = (await runMigrate(ctx, { interactive: false }).catch( + (caught: unknown) => caught, + )) as Error; + + expect(error.message).toBe( + `${failure.message} Removed ${path.join("config/database/migrations", "0001_migration.sql")}, so the next run writes it again from config/database/schema.ts. Change config/database/schema.ts or the branch's data, then rerun \`appkit db migrate\`.`, + ); + expect(error.cause).toBe(failure); + expect(fs.existsSync(path.join(folder, "0001_migration.sql"))).toBe(false); + expect(fs.existsSync(path.join(folder, "meta/0001_snapshot.json"))).toBe( + false, + ); + expect( + fs.readFileSync(path.join(folder, "meta/_journal.json"), "utf-8"), + ).toBe(journal); + }); + + test("when an earlier migration fails, the one this run wrote stays for the next try", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + checkAppkitBranch.mockResolvedValue(null); + await runMigrate(ctx, { interactive: false }); + + writeSchema(ctx.root, `notes: table("notes", { id: id(), body: text() })`); + checkAppkitBranch.mockResolvedValue(devBranch); + openBranchPool.mockResolvedValue(pool); + applyPendingMigrations.mockRejectedValue( + new MigrationApplyError({ + step: "Applying migrations", + failed: "0000_migration", + pending: ["0000_migration", "0001_migration"], + cause: Object.assign(new Error("duplicate key"), { code: "23505" }), + }), + ); + + await expect(runMigrate(ctx, { interactive: false })).rejects.toThrow( + /^Wrote .*0001_migration\.sql, but did not apply it: Migration 0000_migration failed/, + ); + expect( + fs.existsSync( + path.join(ctx.root, "config/database/migrations/0001_migration.sql"), + ), + ).toBe(true); + }); + test("a guard failure after generating says so too, and never connects", async () => { const ctx = app(`notes: table("notes", { id: id() })`); checkAppkitBranch.mockRejectedValue( diff --git a/packages/appkit/src/database/tooling/tests/migrations.test.ts b/packages/appkit/src/database/tooling/tests/migrations.test.ts index 39c8bf193..79ed30f2e 100644 --- a/packages/appkit/src/database/tooling/tests/migrations.test.ts +++ b/packages/appkit/src/database/tooling/tests/migrations.test.ts @@ -19,6 +19,7 @@ import { varchar, } from "../../schema-builder"; import { + discardMigrationFiles, generateMigrationFiles, MigrationRenameAmbiguityError, } from "../migrations"; @@ -341,3 +342,42 @@ describe("generateMigrationFiles", () => { ); }); }); + +describe("discardMigrationFiles", () => { + test("a first migration goes with the folders it created", async () => { + const folder = tempFolder(); + const migration = await generateMigrationFiles({ + folder, + schema: blog(), + interactive: false, + }); + + discardMigrationFiles(folder, migration as never, null); + + expect(fs.existsSync(folder)).toBe(false); + }); + + test("a later migration goes, and the previous journal comes back byte for byte", async () => { + const folder = tempFolder(); + await generateMigrationFiles({ + folder, + schema: blog(), + interactive: false, + }); + const journalFile = path.join(folder, "meta/_journal.json"); + const journalBefore = `${fs.readFileSync(journalFile, "utf-8")}\n`; + fs.writeFileSync(journalFile, journalBefore); + const filesBefore = listFiles(folder); + const migration = await generateMigrationFiles({ + folder, + schema: blog({ summary: text() }), + interactive: false, + }); + expect(listFiles(folder)).not.toEqual(filesBefore); + + discardMigrationFiles(folder, migration as never, journalBefore); + + expect(listFiles(folder)).toEqual(filesBefore); + expect(fs.readFileSync(journalFile, "utf-8")).toBe(journalBefore); + }); +}); diff --git a/packages/appkit/src/plugins/database/load-schema.ts b/packages/appkit/src/plugins/database/load-schema.ts index fa9f3ef0f..a94438635 100644 --- a/packages/appkit/src/plugins/database/load-schema.ts +++ b/packages/appkit/src/plugins/database/load-schema.ts @@ -6,7 +6,7 @@ import type { Schema } from "../../database/schema-builder"; import { assertFinalizedSchema } from "../../database/schema-builder/define-schema"; /** Same application-relative convention used by appkit generate-types. */ -const DEFAULT_SCHEMA_FILE = "config/database/schema.ts"; +export const DEFAULT_SCHEMA_FILE = "config/database/schema.ts"; interface LoadSchemaOptions { /** Report why the module failed to import. Only for local CLI runs: see the catch below. */ diff --git a/packages/appkit/src/type-generator/database/generate.ts b/packages/appkit/src/type-generator/database/generate.ts index aea019c65..f4917d496 100644 --- a/packages/appkit/src/type-generator/database/generate.ts +++ b/packages/appkit/src/type-generator/database/generate.ts @@ -119,6 +119,14 @@ async function writeIfChanged(outFile: string, content: string): Promise { } } +/** Write the registry types for a schema the caller already loaded. */ +export function writeDatabaseTypes( + schema: Schema, + outFile: string, +): Promise { + return writeIfChanged(outFile, render(schema)); +} + /** Disable Jiti's module cache so watch-mode runs observe schema edits. */ async function importSchema(schemaFile: string): Promise { const { createJiti } = await import("jiti"); diff --git a/packages/appkit/src/type-generator/database/index.ts b/packages/appkit/src/type-generator/database/index.ts index 0648bba2c..e3581fb7f 100644 --- a/packages/appkit/src/type-generator/database/index.ts +++ b/packages/appkit/src/type-generator/database/index.ts @@ -2,4 +2,5 @@ export { DATABASE_TYPES_FILE, DatabaseTypegenError, generateDatabaseTypes, + writeDatabaseTypes, } from "./generate"; diff --git a/packages/shared/src/cli/commands/db/database-tooling.d.ts b/packages/shared/src/cli/commands/db/database-tooling.d.ts index b1c18beee..b133e7871 100644 --- a/packages/shared/src/cli/commands/db/database-tooling.d.ts +++ b/packages/shared/src/cli/commands/db/database-tooling.d.ts @@ -1,39 +1,4 @@ -/** - * Intentionally narrowed mirror of `@databricks/appkit/database-tooling`. - * - * `shared` is a leaf package and must not statically depend on `appkit`, so the - * CLI loads the tooling via a dynamic `import(...)` and this declares its shape - * without a build-time dependency (same pattern as ../type-generator.d.ts). - * There is NO compile-time link to the real exports — if they change, re-sync - * this by hand against `packages/appkit/src/database/tooling/index.ts`. - */ -declare module "@databricks/appkit/database-tooling" { - export interface DbContext { - readonly root: string; - readonly env: NodeJS.ProcessEnv; - } - - export function createDbContext(options?: { - root?: string; - env?: NodeJS.ProcessEnv; - }): DbContext; - - export interface GeneratedMigration { - readonly tag: string; - readonly file: string; - readonly statements: readonly string[]; - } - - export interface MigrateReport { - readonly generated: GeneratedMigration | null; - readonly applied: readonly string[]; - readonly adopted?: readonly string[]; - readonly notApplied?: string; - readonly typesFile: string; - } - - export function runMigrate( - ctx: DbContext, - options: { interactive: boolean }, - ): Promise; -} +// `shared` must not depend on `appkit`, so this only makes the dynamic import +// resolve. Its type is DatabaseTooling in ./tooling-contract.ts, which appkit's +// typecheck checks against the real exports. +declare module "@databricks/appkit/database-tooling"; diff --git a/packages/shared/src/cli/commands/db/load-tooling.ts b/packages/shared/src/cli/commands/db/load-tooling.ts new file mode 100644 index 000000000..870d1ad98 --- /dev/null +++ b/packages/shared/src/cli/commands/db/load-tooling.ts @@ -0,0 +1,6 @@ +import type { DatabaseTooling } from "./tooling-contract"; + +/** The appkit tooling, loaded on first use and typed by the checked contract. */ +export function loadDatabaseTooling(): Promise { + return import("@databricks/appkit/database-tooling"); +} diff --git a/packages/shared/src/cli/commands/db/migrate.ts b/packages/shared/src/cli/commands/db/migrate.ts index 8d061f6b0..7096022ba 100644 --- a/packages/shared/src/cli/commands/db/migrate.ts +++ b/packages/shared/src/cli/commands/db/migrate.ts @@ -1,10 +1,11 @@ import { Command } from "commander"; +import { loadDatabaseTooling } from "./load-tooling"; import { fail, isInteractive, printMigrateReport } from "./output"; async function runDbMigrate(): Promise { try { - const tooling = await import("@databricks/appkit/database-tooling"); + const tooling = await loadDatabaseTooling(); const report = await tooling.runMigrate(tooling.createDbContext(), { interactive: isInteractive(), }); diff --git a/packages/shared/src/cli/commands/db/output.ts b/packages/shared/src/cli/commands/db/output.ts index 2cfba1973..ec652b74a 100644 --- a/packages/shared/src/cli/commands/db/output.ts +++ b/packages/shared/src/cli/commands/db/output.ts @@ -1,9 +1,10 @@ import path from "node:path"; import process from "node:process"; -import type { MigrateReport } from "@databricks/appkit/database-tooling"; import pc from "picocolors"; +import type { MigrateReport } from "./tooling-contract"; + /** Prompts only when a human can answer them. */ export function isInteractive(): boolean { return Boolean(process.stdin.isTTY && process.stdout.isTTY); diff --git a/packages/shared/src/cli/commands/db/tooling-contract.ts b/packages/shared/src/cli/commands/db/tooling-contract.ts new file mode 100644 index 000000000..c8eea40b3 --- /dev/null +++ b/packages/shared/src/cli/commands/db/tooling-contract.ts @@ -0,0 +1,37 @@ +/** What the CLI reads from a tooling context; the rest belongs to the tooling. */ +export interface DbContext { + readonly root: string; + readonly env: NodeJS.ProcessEnv; +} + +export interface GeneratedMigration { + readonly tag: string; + readonly file: string; + readonly statements: readonly string[]; +} + +export interface MigrateReport { + readonly generated: GeneratedMigration | null; + readonly applied: readonly string[]; + readonly adopted?: readonly string[]; + readonly notApplied?: string; + readonly typesFile: string; +} + +/** + * The exports of `@databricks/appkit/database-tooling` the CLI calls. `shared` + * must not depend on appkit, so the CLI imports the tooling dynamically and + * types it with this interface, and appkit's typecheck checks its real exports + * against it (`database/tooling/tests/contract.test.ts`). Function properties, + * not methods, so a parameter narrowed in appkit fails that check. + */ +export interface DatabaseTooling { + readonly createDbContext: (options?: { + root?: string; + env?: NodeJS.ProcessEnv; + }) => Ctx; + readonly runMigrate: ( + ctx: Ctx, + options: { interactive: boolean }, + ) => Promise; +} From e87ab2149c433ed1d21bb55c65ceec4c6f96671c Mon Sep 17 00:00:00 2001 From: ditadi Date: Mon, 19 Oct 2026 15:10:08 +0200 Subject: [PATCH 3/4] refactor(appkit): resolve the .env branch kind once and share the branch pool resolveEnvBranch returns the branch kind (default, protected, deploy target, dev, session, other), and takesWrites is the one rule for which kinds take writes. It needs PGHOST, PGDATABASE and LAKEBASE_ENDPOINT, like the pool. withBranchPool opens and always closes the pool. The drizzle-kit v7 file names live only in migrations/paths.ts. Signed-off-by: ditadi --- .../appkit/src/database/migrations/apply.ts | 11 +-- .../appkit/src/database/migrations/paths.ts | 25 ++++- .../appkit/src/database/tooling/context.ts | 32 ++++++- packages/appkit/src/database/tooling/guard.ts | 62 ++++++++---- .../appkit/src/database/tooling/migrate.ts | 24 ++--- .../appkit/src/database/tooling/migrations.ts | 20 ++-- .../database/tooling/tests/context.test.ts | 54 ++++++++--- .../src/database/tooling/tests/guard.test.ts | 91 +++++++++--------- .../database/tooling/tests/migrate.pg.test.ts | 28 ++++-- .../database/tooling/tests/migrate.test.ts | 95 +++++++++---------- 10 files changed, 269 insertions(+), 173 deletions(-) diff --git a/packages/appkit/src/database/migrations/apply.ts b/packages/appkit/src/database/migrations/apply.ts index bc9a1f99d..c8e5f9c3f 100644 --- a/packages/appkit/src/database/migrations/apply.ts +++ b/packages/appkit/src/database/migrations/apply.ts @@ -1,6 +1,5 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; -import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { readMigrationFiles } from "drizzle-orm/migrator"; @@ -22,8 +21,10 @@ import { type Journal, MIGRATIONS_TABLE, MigrationFilesError, + migrationFile, readJournal, readJsonFile, + snapshotFile, } from "./paths"; const logger = createLogger("database"); @@ -228,7 +229,7 @@ type QueryClient = Pick; /** Every committed migration, in journal order. Fails on a missing file before connecting. */ function readMigrations(folder: string, journal: Journal): MigrationFile[] { for (const entry of journal.entries) { - const file = path.join(folder, `${entry.tag}.sql`); + const file = migrationFile(folder, entry.tag); if (!fs.existsSync(file)) { throw new MigrationFilesError( `Missing ${file}, listed in meta/_journal.json. Restore it from version control.`, @@ -454,11 +455,7 @@ function snapshotTables( idx: number, schemaName: string, ): Map { - const file = path.join( - folder, - "meta", - `${String(idx).padStart(4, "0")}_snapshot.json`, - ); + const file = snapshotFile(folder, idx); if (!fs.existsSync(file)) { throw new MigrationFilesError( `Missing ${file} for ${BASELINE_TAG}. Restore it from version control; it lists the tables the baseline adopts.`, diff --git a/packages/appkit/src/database/migrations/paths.ts b/packages/appkit/src/database/migrations/paths.ts index 8d5126076..63a8b7263 100644 --- a/packages/appkit/src/database/migrations/paths.ts +++ b/packages/appkit/src/database/migrations/paths.ts @@ -33,9 +33,32 @@ export function migrationsFolder(root: string): string { return path.resolve(root, MIGRATIONS_DIR); } +// drizzle-kit 0.31's v7 folder layout. drizzle-kit v1 replaces it with one +// folder per migration, so every path into the folder is built here. + +/** Folder of the journal and the snapshots. */ +export function metaFolder(folder: string): string { + return path.join(folder, "meta"); +} + /** Path of the journal inside a migrations folder. */ export function journalFile(folder: string): string { - return path.join(folder, "meta", "_journal.json"); + return path.join(metaFolder(folder), "_journal.json"); +} + +/** The `NNNN` that starts the tag of the migration at journal index idx. */ +export function migrationPrefix(idx: number): string { + return String(idx).padStart(4, "0"); +} + +/** Path of a migration's SQL. */ +export function migrationFile(folder: string, tag: string): string { + return path.join(folder, `${tag}.sql`); +} + +/** Path of the snapshot written with the migration at journal index idx. */ +export function snapshotFile(folder: string, idx: number): string { + return path.join(metaFolder(folder), `${migrationPrefix(idx)}_snapshot.json`); } /** A committed migration file is missing or unreadable; the message names the file. */ diff --git a/packages/appkit/src/database/tooling/context.ts b/packages/appkit/src/database/tooling/context.ts index 580ee303d..76d95f25e 100644 --- a/packages/appkit/src/database/tooling/context.ts +++ b/packages/appkit/src/database/tooling/context.ts @@ -98,14 +98,23 @@ async function assertHostServesEndpoint( } } +export const NO_ENV_BRANCH = + "no branch is configured in .env (PGHOST, PGDATABASE and LAKEBASE_ENDPOINT); run `appkit db init`"; + +/** Whether .env names a branch to connect to. */ +export function hasEnvBranch(env: NodeJS.ProcessEnv): boolean { + return Boolean(env.PGHOST && env.PGDATABASE && env.LAKEBASE_ENDPOINT); +} + /** - * null when PGHOST / PGDATABASE / LAKEBASE_ENDPOINT are not configured. The - * pool is created directly: initializeLakebasePool would look the endpoint and - * the user up a second time. + * The pool is created directly: initializeLakebasePool would look the endpoint + * and the user up a second time. */ -export async function openBranchPool(ctx: DbContext): Promise { +async function openBranchPool(ctx: DbContext): Promise { const { PGHOST, PGDATABASE, LAKEBASE_ENDPOINT } = ctx.env; - if (!PGHOST || !PGDATABASE || !LAKEBASE_ENDPOINT) return null; + if (!PGHOST || !PGDATABASE || !LAKEBASE_ENDPOINT) { + throw new Error(`Did not connect: ${NO_ENV_BRANCH}.`); + } const [user] = await Promise.all([ ctx.env.PGUSER || currentIdentity(ctx).then(({ userName }) => userName), assertHostServesEndpoint(ctx, LAKEBASE_ENDPOINT, PGHOST), @@ -128,3 +137,16 @@ export async function openBranchPool(ctx: DbContext): Promise { idleTimeoutMillis: 1_000, }); } + +/** Run fn with a pool on the .env branch, and close the pool after. */ +export async function withBranchPool( + ctx: DbContext, + fn: (pool: Pool) => Promise, +): Promise { + const pool = await openBranchPool(ctx); + try { + return await fn(pool); + } finally { + await pool.end().catch(() => undefined); + } +} diff --git a/packages/appkit/src/database/tooling/guard.ts b/packages/appkit/src/database/tooling/guard.ts index 001afd60b..994fe1fac 100644 --- a/packages/appkit/src/database/tooling/guard.ts +++ b/packages/appkit/src/database/tooling/guard.ts @@ -1,4 +1,4 @@ -import type { DbContext } from "./context"; +import { type DbContext, hasEnvBranch } from "./context"; import { currentIdentity } from "./identity"; import type { LakebaseBranch } from "./lakebase-api"; import { devBranchId, sessionBranchId } from "./naming"; @@ -9,12 +9,18 @@ import { resourceId, } from "./targets"; -// Commands that write to a branch run only on branches `appkit db init` -// created for this developer (dev-…) or this checkout (session-…). +/** What a branch is to this developer, checked in this order. */ +type BranchKind = + | "default" + | "protected" + | "deploy-target" + | "dev" + | "session" + | "other"; -interface BranchVerdict { - readonly appkitOwned: boolean; - /** Why the branch is not AppKit-owned. */ +interface Classification { + readonly kind: BranchKind; + /** Why the branch takes no writes; undefined for dev and session branches. */ readonly reason?: string; } @@ -24,27 +30,27 @@ export function classifyBranch(input: { sessionId: string | null; /** Every branch databricks.yml deploys to; the deployed app migrates those itself. */ deployBranches: readonly string[]; -}): BranchVerdict { +}): Classification { const { branch, userName, sessionId, deployBranches } = input; if (branch.status?.default) { - return { appkitOwned: false, reason: "it is the project's default branch" }; + return { kind: "default", reason: "it is the project's default branch" }; } if (branch.status?.is_protected) { - return { appkitOwned: false, reason: "it is a protected branch" }; + return { kind: "protected", reason: "it is a protected branch" }; } if (deployBranches.includes(branch.name)) { return { - appkitOwned: false, + kind: "deploy-target", reason: "it is a branch databricks.yml deploys to", }; } const id = resourceId(branch.name); - if (id === devBranchId(userName)) return { appkitOwned: true }; + if (id === devBranchId(userName)) return { kind: "dev" }; if (sessionId && id === sessionBranchId(sessionId)) { - return { appkitOwned: true }; + return { kind: "session" }; } return { - appkitOwned: false, + kind: "other", reason: `it is not your dev branch (${devBranchId(userName)})${ sessionId ? ` or this checkout's session branch (${sessionBranchId(sessionId)})` @@ -53,18 +59,34 @@ export function classifyBranch(input: { }; } -interface BranchCheck { +/** + * Commands write only to branches `appkit db init` created for this developer + * (dev-…) or this checkout (session-…). + */ +export function takesWrites(kind: BranchKind): boolean { + return kind === "dev" || kind === "session"; +} + +/** The .env branch and what it is to this developer. */ +interface EnvBranch extends Classification { readonly branch: LakebaseBranch; - readonly verdict: BranchVerdict; } -/** Classify the `.env` branch through the API (no database connection); null when none is configured. */ -export async function checkAppkitBranch( +/** Why a command does not write to the branch. */ +export function notYourBranch(env: EnvBranch): string { + return `${resourceId(env.branch.name)} is not your dev or session branch (${env.reason})`; +} + +/** + * Classify the .env branch through the API, without connecting. null when .env + * does not name a branch to connect to (see NO_ENV_BRANCH). + */ +export async function resolveEnvBranch( ctx: DbContext, -): Promise { +): Promise { const local = readLocalTarget(ctx.env); const name = parseResourceName(local.endpoint ?? undefined)?.branch; - if (!name) return null; + if (!name || !hasEnvBranch(ctx.env)) return null; const [branch, { userName }] = await Promise.all([ ctx.api().getBranch(name), currentIdentity(ctx), @@ -76,7 +98,7 @@ export async function checkAppkitBranch( } return { branch, - verdict: classifyBranch({ + ...classifyBranch({ branch, userName, sessionId: local.session, diff --git a/packages/appkit/src/database/tooling/migrate.ts b/packages/appkit/src/database/tooling/migrate.ts index 0182d49ce..dbcb41a7d 100644 --- a/packages/appkit/src/database/tooling/migrate.ts +++ b/packages/appkit/src/database/tooling/migrate.ts @@ -18,14 +18,13 @@ import { migrationsFolder, readJournal, } from "../migrations/paths"; -import { type DbContext, openBranchPool } from "./context"; -import { checkAppkitBranch } from "./guard"; +import { type DbContext, NO_ENV_BRANCH, withBranchPool } from "./context"; +import { notYourBranch, resolveEnvBranch, takesWrites } from "./guard"; import { discardMigrationFiles, type GeneratedMigration, generateMigrationFiles, } from "./migrations"; -import { resourceId } from "./targets"; /** Whether the checkout has a config/database/schema.ts yet. */ function hasSchemaFile(root: string): boolean { @@ -69,15 +68,12 @@ export interface MigrateOptions { type ApplyOutcome = Pick; -const NO_BRANCH = - "no branch configured (PGHOST, PGDATABASE, LAKEBASE_ENDPOINT) — run appkit db init"; - /** Why migrations must not be applied to the `.env` branch; undefined when they may. */ async function applyBlocker(ctx: DbContext): Promise { - const check = await checkAppkitBranch(ctx); - if (!check) return NO_BRANCH; - if (check.verdict.appkitOwned) return undefined; - return `${resourceId(check.branch.name)} is not an AppKit dev or session branch (${check.verdict.reason}). Migrations are applied only to branches \`appkit db init\` created for you; the deployed app applies committed migrations to its own branch`; + const env = await resolveEnvBranch(ctx); + if (!env) return NO_ENV_BRANCH; + if (takesWrites(env.kind)) return undefined; + return `${notYourBranch(env)}. Migrations are applied only to branches \`appkit db init\` created for you; the deployed app applies committed migrations to its own branch`; } /** Apply pending migrations to the `.env` branch, or say why they were not. */ @@ -90,17 +86,13 @@ async function applyToEnvBranch( ? await applyBlocker(ctx) : "no migrations to apply — schema.ts declares no tables yet"; if (blocker) return { applied: [], notApplied: blocker }; - const pool = await openBranchPool(ctx); - if (!pool) return { applied: [], notApplied: NO_BRANCH }; - try { + return withBranchPool(ctx, async (pool) => { const { applied, adopted } = await applyPendingMigrations(pool, { migrationsFolder: folder, schemaName, }); return { applied, ...(adopted.length > 0 ? { adopted } : {}) }; - } finally { - await pool.end().catch(() => undefined); - } + }); } /** diff --git a/packages/appkit/src/database/tooling/migrations.ts b/packages/appkit/src/database/tooling/migrations.ts index 021ca2595..21ada21bd 100644 --- a/packages/appkit/src/database/tooling/migrations.ts +++ b/packages/appkit/src/database/tooling/migrations.ts @@ -7,8 +7,12 @@ import { type Journal, type JournalEntry, journalFile, + metaFolder, + migrationFile, + migrationPrefix, readJournal, readJsonFile, + snapshotFile, } from "../migrations/paths"; import type { Schema } from "../schema-builder"; import { drizzleKitImports } from "./drizzle-exports"; @@ -96,14 +100,6 @@ function originSnapshot(): Snapshot { }; } -function prefixOf(idx: number): string { - return String(idx).padStart(4, "0"); -} - -function snapshotFile(folder: string, idx: number): string { - return path.join(folder, "meta", `${prefixOf(idx)}_snapshot.json`); -} - function latestSnapshot(folder: string, journal: Journal | null): Snapshot { const last = journal?.entries.at(-1); if (!last) return originSnapshot(); @@ -306,7 +302,7 @@ export async function generateMigrationFiles( const entries = journal?.entries ?? []; const last = entries.at(-1); const idx = last ? last.idx + 1 : 0; - const tag = `${prefixOf(idx)}_migration`; + const tag = `${migrationPrefix(idx)}_migration`; const entry: JournalEntry = { idx, version: "7", @@ -321,8 +317,8 @@ export async function generateMigrationFiles( entries: [...entries, entry], }; - const file = path.join(options.folder, `${tag}.sql`); - fs.mkdirSync(path.join(options.folder, "meta"), { recursive: true }); + const file = migrationFile(options.folder, tag); + fs.mkdirSync(metaFolder(options.folder), { recursive: true }); fs.writeFileSync( snapshotFile(options.folder, idx), JSON.stringify( @@ -355,7 +351,7 @@ export function discardMigrationFiles( return; } fs.rmSync(journalFile(folder), { force: true }); - for (const dir of [path.join(folder, "meta"), folder]) { + for (const dir of [metaFolder(folder), folder]) { if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) fs.rmdirSync(dir); } diff --git a/packages/appkit/src/database/tooling/tests/context.test.ts b/packages/appkit/src/database/tooling/tests/context.test.ts index d787f442f..df06855ea 100644 --- a/packages/appkit/src/database/tooling/tests/context.test.ts +++ b/packages/appkit/src/database/tooling/tests/context.test.ts @@ -9,17 +9,19 @@ vi.mock("../../../connectors/lakebase", () => ({ createLakebasePool })); vi.mock("../../../workspace-client", () => ({ createWorkspaceClient })); import { getClientOptions } from "../../../context/client-options"; -import { createDbContext, openBranchPool } from "../context"; +import { createDbContext, withBranchPool } from "../context"; import type { LakebaseApi } from "../lakebase-api"; import { cleanupTempApps, FakeLakebase, fakeContext, tempApp } from "./fakes"; const BRANCH = "projects/p/branches/dev-alice-3f9a2c"; const ENDPOINT = `${BRANCH}/endpoints/primary`; const HOST = "primary.dev-alice-3f9a2c.example.com"; -const pool = { end: vi.fn() }; +const pool = { end: vi.fn(async () => undefined) }; +const usePool = async (p: unknown) => p; beforeEach(() => { createLakebasePool.mockReset().mockReturnValue(pool); + pool.end.mockClear(); createWorkspaceClient.mockReset(); }); afterEach(cleanupTempApps); @@ -28,15 +30,18 @@ function contextFor(env: NodeJS.ProcessEnv, api: LakebaseApi) { return fakeContext({ root: tempApp(), env, api }); } -describe("openBranchPool", () => { - test("null without PGHOST, PGDATABASE and LAKEBASE_ENDPOINT, before any API call", async () => { +describe("withBranchPool", () => { + test("refuses without PGHOST, PGDATABASE and LAKEBASE_ENDPOINT, before any API call", async () => { const lakebase = new FakeLakebase(); await expect( - openBranchPool( + withBranchPool( contextFor({ PGHOST: HOST, PGDATABASE: "db" }, lakebase.api()), + usePool, ), - ).resolves.toBeNull(); + ).rejects.toThrow( + "Did not connect: no branch is configured in .env (PGHOST, PGDATABASE and LAKEBASE_ENDPOINT); run `appkit db init`.", + ); expect(lakebase.calls).toEqual([]); expect(createLakebasePool).not.toHaveBeenCalled(); }); @@ -54,7 +59,8 @@ describe("openBranchPool", () => { lakebase.api(), ); - await expect(openBranchPool(ctx)).resolves.toBe(pool); + await expect(withBranchPool(ctx, usePool)).resolves.toBe(pool); + expect(pool.end).toHaveBeenCalledOnce(); expect(lakebase.calls).toEqual([`getEndpoint ${ENDPOINT}`]); expect(createLakebasePool).toHaveBeenCalledWith( expect.objectContaining({ @@ -81,7 +87,7 @@ describe("openBranchPool", () => { api: lakebase.api(), }); - await openBranchPool(ctx); + await withBranchPool(ctx, usePool); expect(lakebase.calls).toEqual([`getEndpoint ${ENDPOINT}`]); expect(createLakebasePool).toHaveBeenCalledWith( @@ -98,17 +104,18 @@ describe("openBranchPool", () => { }; await expect( - openBranchPool( + withBranchPool( contextFor( { ...env, PGHOST: "primary.production.example.com" }, lakebase.api(), ), + usePool, ), ).rejects.toThrow( `PGHOST primary.production.example.com is not a host of LAKEBASE_ENDPOINT ${ENDPOINT} (expected ${HOST}), so it did not connect. Run \`appkit db init\` to rewrite .env.`, ); await expect( - openBranchPool( + withBranchPool( contextFor( { ...env, @@ -117,6 +124,7 @@ describe("openBranchPool", () => { }, lakebase.api(), ), + usePool, ), ).rejects.toThrow( `LAKEBASE_ENDPOINT ${BRANCH}/endpoints/gone does not exist, so it did not connect.`, @@ -128,8 +136,9 @@ describe("openBranchPool", () => { throw lookupFailure; }, } as unknown as LakebaseApi; - const error = (await openBranchPool( + const error = (await withBranchPool( contextFor({ ...env, PGHOST: HOST }, failing), + usePool, ).catch((caught: unknown) => caught)) as Error; expect(error.message).toBe( `Could not verify that PGHOST ${HOST} serves LAKEBASE_ENDPOINT ${ENDPOINT}, so it did not connect.`, @@ -138,6 +147,29 @@ describe("openBranchPool", () => { expect(createLakebasePool).not.toHaveBeenCalled(); }); + + test("closes the pool when fn fails, and rethrows fn's error", async () => { + const lakebase = new FakeLakebase(); + lakebase.addBranch(BRANCH); + const ctx = contextFor( + { + PGHOST: HOST, + PGDATABASE: "databricks_postgres", + LAKEBASE_ENDPOINT: ENDPOINT, + PGUSER: "alice@example.com", + }, + lakebase.api(), + ); + const failure = new Error("query failed"); + pool.end.mockRejectedValueOnce(new Error("end failed")); + + await expect( + withBranchPool(ctx, async () => { + throw failure; + }), + ).rejects.toBe(failure); + expect(pool.end).toHaveBeenCalledOnce(); + }); }); describe("createDbContext", () => { diff --git a/packages/appkit/src/database/tooling/tests/guard.test.ts b/packages/appkit/src/database/tooling/tests/guard.test.ts index 175f9823c..ee79cbf59 100644 --- a/packages/appkit/src/database/tooling/tests/guard.test.ts +++ b/packages/appkit/src/database/tooling/tests/guard.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "vitest"; -import { checkAppkitBranch, classifyBranch } from "../guard"; +import { classifyBranch, resolveEnvBranch, takesWrites } from "../guard"; import { devBranchId } from "../naming"; import { cleanupTempApps, @@ -31,45 +31,48 @@ function verdict( } describe("classifyBranch", () => { - test("only this developer's dev branch and this checkout's session branch are AppKit-owned", () => { - expect(verdict(dev)).toEqual({ appkitOwned: true }); + test("only this developer's dev branch and this checkout's session branch take writes", () => { + expect(verdict(dev)).toEqual({ kind: "dev" }); expect(verdict(`${P}/branches/session-k2x9q0ab`)).toEqual({ - appkitOwned: true, + kind: "session", }); + expect(takesWrites("dev")).toBe(true); + expect(takesWrites("session")).toBe(true); }); - test("everything else is refused, with the reason", () => { - expect( - verdict(`${P}/branches/production`, { default: true }), - ).toMatchObject({ - appkitOwned: false, + test("everything else is another kind, with the reason", () => { + expect(verdict(`${P}/branches/production`, { default: true })).toEqual({ + kind: "default", reason: "it is the project's default branch", }); - expect(verdict(dev, { is_protected: true })).toMatchObject({ - appkitOwned: false, + expect(verdict(dev, { is_protected: true })).toEqual({ + kind: "protected", reason: "it is a protected branch", }); - expect( - verdict(dev, {}, null, [`${P}/branches/staging`, dev]), - ).toMatchObject({ - appkitOwned: false, + expect(verdict(dev, {}, null, [`${P}/branches/staging`, dev])).toEqual({ + kind: "deploy-target", reason: "it is a branch databricks.yml deploys to", }); expect( - verdict(`${P}/branches/${devBranchId("bob@example.com")}`).appkitOwned, - ).toBe(false); - expect(verdict(`${P}/branches/session-other`).appkitOwned).toBe(false); - expect( - verdict(`${P}/branches/session-k2x9q0ab`, {}, null).appkitOwned, - ).toBe(false); + verdict(`${P}/branches/${devBranchId("bob@example.com")}`).kind, + ).toBe("other"); + expect(verdict(`${P}/branches/session-other`).kind).toBe("other"); + expect(verdict(`${P}/branches/session-k2x9q0ab`, {}, null).kind).toBe( + "other", + ); expect(verdict(`${P}/branches/staging`).reason).toContain( devBranchId(USER), ); + for (const kind of ["default", "protected", "deploy-target", "other"]) { + expect(takesWrites(kind as "other")).toBe(false); + } }); }); -describe("checkAppkitBranch", () => { +describe("resolveEnvBranch", () => { const envFor = (branch: string) => ({ + PGHOST: "primary.example.com", + PGDATABASE: "databricks_postgres", LAKEBASE_ENDPOINT: `${branch}/endpoints/primary`, }); @@ -78,23 +81,21 @@ describe("checkAppkitBranch", () => { lakebase.addBranch(dev); lakebase.addBranch(`${P}/branches/production`, { default: true }); - const onDev = await checkAppkitBranch( + const onDev = await resolveEnvBranch( fakeContext({ root: tempApp(), env: envFor(dev), api: lakebase.api() }), ); - expect(onDev).toMatchObject({ - branch: { name: dev }, - verdict: { appkitOwned: true }, - }); + expect(onDev).toMatchObject({ branch: { name: dev }, kind: "dev" }); + expect(onDev?.reason).toBeUndefined(); - const onDefault = await checkAppkitBranch( + const onDefault = await resolveEnvBranch( fakeContext({ root: tempApp(), env: envFor(`${P}/branches/production`), api: lakebase.api(), }), ); - expect(onDefault?.verdict).toEqual({ - appkitOwned: false, + expect(onDefault).toMatchObject({ + kind: "default", reason: "it is the project's default branch", }); expect(lakebase.calls.every((call) => call.startsWith("getBranch"))).toBe( @@ -102,7 +103,7 @@ describe("checkAppkitBranch", () => { ); }); - test("a branch databricks.yml deploys to is never AppKit-owned", async () => { + test("a branch databricks.yml deploys to never takes writes", async () => { const lakebase = new FakeLakebase(); lakebase.addBranch(dev); const root = tempApp({ @@ -112,12 +113,12 @@ describe("checkAppkitBranch", () => { ), }); - const check = await checkAppkitBranch( + const env = await resolveEnvBranch( fakeContext({ root, env: envFor(dev), api: lakebase.api() }), ); - expect(check?.verdict).toEqual({ - appkitOwned: false, + expect(env).toMatchObject({ + kind: "deploy-target", reason: "it is a branch databricks.yml deploys to", }); }); @@ -133,19 +134,16 @@ describe("checkAppkitBranch", () => { }); const broken = tempApp({ "databricks.yml": "targets:\n default: [\n" }); - const check = await checkAppkitBranch( + const env = await resolveEnvBranch( fakeContext({ root: personalTarget, env: envFor(dev), api: lakebase.api(), }), ); - expect(check?.verdict).toEqual({ - appkitOwned: false, - reason: "it is a branch databricks.yml deploys to", - }); + expect(env).toMatchObject({ kind: "deploy-target" }); await expect( - checkAppkitBranch( + resolveEnvBranch( fakeContext({ root: broken, env: envFor(dev), api: lakebase.api() }), ), ).rejects.toThrow( @@ -153,13 +151,20 @@ describe("checkAppkitBranch", () => { ); }); - test("null without a configured branch; a deleted branch points at init", async () => { + test("null unless .env names a branch to connect to; a deleted branch points at init", async () => { const lakebase = new FakeLakebase(); await expect( - checkAppkitBranch(fakeContext({ root: tempApp(), api: lakebase.api() })), + resolveEnvBranch(fakeContext({ root: tempApp(), api: lakebase.api() })), + ).resolves.toBeNull(); + const { PGHOST: _host, ...noHost } = envFor(dev); + await expect( + resolveEnvBranch( + fakeContext({ root: tempApp(), env: noHost, api: lakebase.api() }), + ), ).resolves.toBeNull(); + expect(lakebase.calls).toEqual([]); await expect( - checkAppkitBranch( + resolveEnvBranch( fakeContext({ root: tempApp(), env: envFor(dev), api: lakebase.api() }), ), ).rejects.toThrow(/does not exist.*appkit db init/); diff --git a/packages/appkit/src/database/tooling/tests/migrate.pg.test.ts b/packages/appkit/src/database/tooling/tests/migrate.pg.test.ts index 34e6c99db..b4748d573 100644 --- a/packages/appkit/src/database/tooling/tests/migrate.pg.test.ts +++ b/packages/appkit/src/database/tooling/tests/migrate.pg.test.ts @@ -6,15 +6,18 @@ import path from "node:path"; import { Pool } from "pg"; import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; -const { checkAppkitBranch, openBranchPool } = vi.hoisted(() => ({ - checkAppkitBranch: vi.fn(), - openBranchPool: vi.fn(), +const { resolveEnvBranch, withBranchPool } = vi.hoisted(() => ({ + resolveEnvBranch: vi.fn(), + withBranchPool: vi.fn(), })); -vi.mock("../guard", () => ({ checkAppkitBranch })); +vi.mock("../guard", async (importOriginal) => ({ + ...(await importOriginal()), + resolveEnvBranch, +})); vi.mock("../context", async (importOriginal) => ({ ...(await importOriginal()), - openBranchPool, + withBranchPool, })); import type { DbContext } from "../context"; @@ -69,12 +72,19 @@ describe.skipIf(!url)("runMigrate against Postgres", () => { throw new Error("no Lakebase API calls expected"); }, }; - checkAppkitBranch.mockResolvedValue({ + resolveEnvBranch.mockResolvedValue({ branch: { name: "projects/p/branches/dev-alice-3f9a2c" }, - verdict: { appkitOwned: true }, + kind: "dev", }); - openBranchPool.mockImplementation( - async () => new Pool({ connectionString: url, max: 2 }), + withBranchPool.mockImplementation( + async (_ctx, fn: (pool: Pool) => Promise) => { + const pool = new Pool({ connectionString: url, max: 2 }); + try { + return await fn(pool); + } finally { + await pool.end(); + } + }, ); }); diff --git a/packages/appkit/src/database/tooling/tests/migrate.test.ts b/packages/appkit/src/database/tooling/tests/migrate.test.ts index 619fd7f08..dd88e8e33 100644 --- a/packages/appkit/src/database/tooling/tests/migrate.test.ts +++ b/packages/appkit/src/database/tooling/tests/migrate.test.ts @@ -4,21 +4,25 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -const { applyPendingMigrations, checkAppkitBranch, openBranchPool } = - vi.hoisted(() => ({ +const { applyPendingMigrations, resolveEnvBranch, withBranchPool } = vi.hoisted( + () => ({ applyPendingMigrations: vi.fn(), - checkAppkitBranch: vi.fn(), - openBranchPool: vi.fn(), - })); + resolveEnvBranch: vi.fn(), + withBranchPool: vi.fn(), + }), +); vi.mock("../../migrations/apply", async (importOriginal) => ({ ...(await importOriginal()), applyPendingMigrations, })); -vi.mock("../guard", () => ({ checkAppkitBranch })); +vi.mock("../guard", async (importOriginal) => ({ + ...(await importOriginal()), + resolveEnvBranch, +})); vi.mock("../context", async (importOriginal) => ({ ...(await importOriginal()), - openBranchPool, + withBranchPool, })); import { MigrationApplyError } from "../../migrations/apply"; @@ -59,17 +63,18 @@ function app(tables: string): DbContext { const devBranch = { branch: { name: "projects/p/branches/dev-alice-3f9a2c" }, - verdict: { appkitOwned: true }, + kind: "dev", }; -const pool = { end: vi.fn(async () => undefined) }; +const pool = {}; beforeEach(() => { applyPendingMigrations.mockReset(); - openBranchPool.mockReset(); - checkAppkitBranch.mockReset(); - checkAppkitBranch.mockResolvedValue(devBranch); - pool.end.mockClear(); + withBranchPool + .mockReset() + .mockImplementation((_ctx, fn: (p: unknown) => unknown) => fn(pool)); + resolveEnvBranch.mockReset(); + resolveEnvBranch.mockResolvedValue(devBranch); }); afterEach(() => { @@ -86,7 +91,7 @@ describe("runMigrate", () => { await expect(runMigrate(ctx, { interactive: false })).rejects.toThrow( "No config/database/schema.ts yet, so there is nothing to migrate. Run `appkit db introspect` to write it from the branch's tables, or create it.", ); - expect(checkAppkitBranch).not.toHaveBeenCalled(); + expect(resolveEnvBranch).not.toHaveBeenCalled(); expect( fs.existsSync(path.join(ctx.root, "config/database/migrations")), ).toBe(false); @@ -99,17 +104,16 @@ describe("runMigrate", () => { expect(report.generated).toBeNull(); expect(report.applied).toEqual([]); expect(report.notApplied).toMatch(/no migrations/); - expect(openBranchPool).not.toHaveBeenCalled(); - expect(checkAppkitBranch).not.toHaveBeenCalled(); + expect(withBranchPool).not.toHaveBeenCalled(); + expect(resolveEnvBranch).not.toHaveBeenCalled(); expect(report.typesFile).toBe( path.join(ctx.root, "shared/appkit-types/database.d.ts"), ); expect(fs.existsSync(report.typesFile)).toBe(true); }); - test("generates, regenerates types, applies to the .env branch with the app schema, and ends the pool", async () => { + test("generates, regenerates types, and applies to the .env branch with the app schema", async () => { const ctx = app(`notes: table("notes", { id: id(), body: text() })`); - openBranchPool.mockResolvedValue(pool); applyPendingMigrations.mockResolvedValue({ applied: ["0000_migration"], adopted: [], @@ -118,23 +122,22 @@ describe("runMigrate", () => { const report = await runMigrate(ctx, { interactive: false }); expect(report.generated?.tag).toBe("0000_migration"); - expect(openBranchPool).toHaveBeenCalledWith(ctx); + expect(withBranchPool).toHaveBeenCalledWith(ctx, expect.any(Function)); expect(applyPendingMigrations).toHaveBeenCalledWith(pool, { migrationsFolder: path.join(ctx.root, "config/database/migrations"), schemaName: "app", }); expect(report.applied).toEqual(["0000_migration"]); expect(report.notApplied).toBeUndefined(); - expect(pool.end).toHaveBeenCalledOnce(); expect(fs.readFileSync(report.typesFile, "utf-8")).toContain("notes"); }); test("applies committed migrations even when schema.ts did not change", async () => { const ctx = app(`notes: table("notes", { id: id() })`); - openBranchPool.mockResolvedValue(null); + resolveEnvBranch.mockResolvedValue(null); await runMigrate(ctx, { interactive: false }); - openBranchPool.mockResolvedValue(pool); + resolveEnvBranch.mockResolvedValue(devBranch); applyPendingMigrations.mockResolvedValue({ applied: ["0000_migration"], adopted: [], @@ -147,44 +150,43 @@ describe("runMigrate", () => { test("without a configured branch it still generates, and says why nothing was applied", async () => { const ctx = app(`notes: table("notes", { id: id() })`); - checkAppkitBranch.mockResolvedValue(null); + resolveEnvBranch.mockResolvedValue(null); const report = await runMigrate(ctx, { interactive: false }); expect(report.generated?.tag).toBe("0000_migration"); expect(applyPendingMigrations).not.toHaveBeenCalled(); - expect(report.notApplied).toMatch(/appkit db init/); + expect(report.notApplied).toBe( + "no branch is configured in .env (PGHOST, PGDATABASE and LAKEBASE_ENDPOINT); run `appkit db init`", + ); expect(fs.existsSync(report.typesFile)).toBe(true); }); test("on a branch AppKit did not create for you it generates, never connects, and says why", async () => { const ctx = app(`notes: table("notes", { id: id() })`); - checkAppkitBranch.mockResolvedValue({ + resolveEnvBranch.mockResolvedValue({ branch: { name: "projects/p/branches/production" }, - verdict: { - appkitOwned: false, - reason: "it is the project's default branch", - }, + kind: "default", + reason: "it is the project's default branch", }); const report = await runMigrate(ctx, { interactive: false }); expect(report.generated?.tag).toBe("0000_migration"); - expect(openBranchPool).not.toHaveBeenCalled(); + expect(withBranchPool).not.toHaveBeenCalled(); expect(applyPendingMigrations).not.toHaveBeenCalled(); expect(report.notApplied).toBe( - "production is not an AppKit dev or session branch (it is the project's default branch). Migrations are applied only to branches `appkit db init` created for you; the deployed app applies committed migrations to its own branch", + "production is not your dev or session branch (it is the project's default branch). Migrations are applied only to branches `appkit db init` created for you; the deployed app applies committed migrations to its own branch", ); expect(fs.existsSync(report.typesFile)).toBe(true); }); test("reports a baseline adopted without running it", async () => { const ctx = app(`notes: table("notes", { id: id() })`); - checkAppkitBranch.mockResolvedValue(null); + resolveEnvBranch.mockResolvedValue(null); await runMigrate(ctx, { interactive: false }); - checkAppkitBranch.mockResolvedValue(devBranch); - openBranchPool.mockResolvedValue(pool); + resolveEnvBranch.mockResolvedValue(devBranch); applyPendingMigrations.mockResolvedValue({ applied: [], adopted: ["0000_baseline"], @@ -194,9 +196,8 @@ describe("runMigrate", () => { expect(report).toMatchObject({ applied: [], adopted: ["0000_baseline"] }); }); - test("an apply failure ends the pool and says the generated migration was written but not applied", async () => { + test("an apply failure says the generated migration was written but not applied", async () => { const ctx = app(`notes: table("notes", { id: id() })`); - openBranchPool.mockResolvedValue(pool); const failure = new Error("apply failed"); applyPendingMigrations.mockRejectedValue(failure); @@ -208,7 +209,6 @@ describe("runMigrate", () => { `Wrote ${path.join("config/database/migrations", "0000_migration.sql")}, but did not apply it: apply failed`, ); expect(error.cause).toBe(failure); - expect(pool.end).toHaveBeenCalledOnce(); expect( fs.existsSync( path.join(ctx.root, "config/database/migrations/0000_migration.sql"), @@ -224,7 +224,7 @@ describe("runMigrate", () => { test("when the migration this run wrote fails to apply, it is removed so a fix in schema.ts can replace it", async () => { const ctx = app(`notes: table("notes", { id: id() })`); - checkAppkitBranch.mockResolvedValue(null); + resolveEnvBranch.mockResolvedValue(null); await runMigrate(ctx, { interactive: false }); const folder = path.join(ctx.root, "config/database/migrations"); const journal = fs.readFileSync( @@ -236,8 +236,7 @@ describe("runMigrate", () => { ctx.root, `notes: table("notes", { id: id(), title: text().notNull() })`, ); - checkAppkitBranch.mockResolvedValue(devBranch); - openBranchPool.mockResolvedValue(pool); + resolveEnvBranch.mockResolvedValue(devBranch); const failure = new MigrationApplyError({ step: "Applying migrations", failed: "0001_migration", @@ -268,12 +267,11 @@ describe("runMigrate", () => { test("when an earlier migration fails, the one this run wrote stays for the next try", async () => { const ctx = app(`notes: table("notes", { id: id() })`); - checkAppkitBranch.mockResolvedValue(null); + resolveEnvBranch.mockResolvedValue(null); await runMigrate(ctx, { interactive: false }); writeSchema(ctx.root, `notes: table("notes", { id: id(), body: text() })`); - checkAppkitBranch.mockResolvedValue(devBranch); - openBranchPool.mockResolvedValue(pool); + resolveEnvBranch.mockResolvedValue(devBranch); applyPendingMigrations.mockRejectedValue( new MigrationApplyError({ step: "Applying migrations", @@ -295,23 +293,22 @@ describe("runMigrate", () => { test("a guard failure after generating says so too, and never connects", async () => { const ctx = app(`notes: table("notes", { id: id() })`); - checkAppkitBranch.mockRejectedValue( + resolveEnvBranch.mockRejectedValue( new Error("The .env branch projects/p/branches/dev-a does not exist"), ); await expect(runMigrate(ctx, { interactive: false })).rejects.toThrow( /^Wrote .*0000_migration\.sql, but did not apply it: The \.env branch .* does not exist$/, ); - expect(openBranchPool).not.toHaveBeenCalled(); + expect(withBranchPool).not.toHaveBeenCalled(); }); test("a failure with nothing generated propagates unchanged", async () => { const ctx = app(`notes: table("notes", { id: id() })`); - checkAppkitBranch.mockResolvedValue(null); + resolveEnvBranch.mockResolvedValue(null); await runMigrate(ctx, { interactive: false }); - checkAppkitBranch.mockResolvedValue(devBranch); - openBranchPool.mockResolvedValue(pool); + resolveEnvBranch.mockResolvedValue(devBranch); const failure = new Error("apply failed"); applyPendingMigrations.mockRejectedValue(failure); @@ -327,6 +324,6 @@ describe("runMigrate", () => { expect(error.message).toBe("Could not load config/database/schema.ts."); expect((error.cause as Error).message).toMatch(/schema\.ts:\d+:\d+/); - expect(checkAppkitBranch).not.toHaveBeenCalled(); + expect(resolveEnvBranch).not.toHaveBeenCalled(); }); }); From df1b1c921948d362f517e421b09eef532f0b59a1 Mon Sep 17 00:00:00 2001 From: ditadi Date: Mon, 19 Oct 2026 15:11:20 +0200 Subject: [PATCH 4/4] feat(appkit): warn when appkit db migrate writes a migration that breaks the previous release Apps keeps instances of the previous release serving while the new one migrates on start, and a code rollback cannot undo a migration. When the generated migration drops, renames, retypes or makes a column NOT NULL, the report lists those statements and the CLI says to ship code that works with both schemas first. Signed-off-by: ditadi --- .../appkit/src/database/tooling/migrate.ts | 11 +++++++- .../appkit/src/database/tooling/migrations.ts | 21 +++++++++++++++ .../database/tooling/tests/migrate.test.ts | 15 +++++++++++ .../database/tooling/tests/migrations.test.ts | 27 +++++++++++++++++++ .../src/cli/commands/db/migrate.test.ts | 23 ++++++++++++++++ packages/shared/src/cli/commands/db/output.ts | 6 +++++ .../src/cli/commands/db/tooling-contract.ts | 1 + 7 files changed, 103 insertions(+), 1 deletion(-) diff --git a/packages/appkit/src/database/tooling/migrate.ts b/packages/appkit/src/database/tooling/migrate.ts index dbcb41a7d..ec2343450 100644 --- a/packages/appkit/src/database/tooling/migrate.ts +++ b/packages/appkit/src/database/tooling/migrate.ts @@ -21,6 +21,7 @@ import { import { type DbContext, NO_ENV_BRANCH, withBranchPool } from "./context"; import { notYourBranch, resolveEnvBranch, takesWrites } from "./guard"; import { + breakingStatements, discardMigrationFiles, type GeneratedMigration, generateMigrationFiles, @@ -56,6 +57,8 @@ export interface MigrateReport { readonly applied: readonly string[]; /** Tags recorded without running because their tables already existed (0000_baseline). */ readonly adopted?: readonly string[]; + /** Statements of the generated migration that break instances still running the previous release. */ + readonly breaking?: readonly string[]; /** Why nothing was applied, when applying was skipped. */ readonly notApplied?: string; readonly typesFile: string; @@ -144,5 +147,11 @@ export async function runMigrate( { cause: error }, ); } - return { generated, ...outcome, typesFile }; + const breaking = breakingStatements(generated?.statements ?? []); + return { + generated, + ...(breaking.length > 0 ? { breaking } : {}), + ...outcome, + typesFile, + }; } diff --git a/packages/appkit/src/database/tooling/migrations.ts b/packages/appkit/src/database/tooling/migrations.ts index 21ada21bd..81786a253 100644 --- a/packages/appkit/src/database/tooling/migrations.ts +++ b/packages/appkit/src/database/tooling/migrations.ts @@ -253,6 +253,27 @@ async function loadDrizzleKit(): Promise { } } +const BREAKS_PREVIOUS_RELEASE = [ + /^DROP (TABLE|VIEW|MATERIALIZED VIEW|TYPE|SEQUENCE|SCHEMA)\b/i, + /\bDROP COLUMN\b/i, + /\bRENAME\b/i, + /\bSET DATA TYPE\b/i, + /\bSET NOT NULL\b/i, +]; + +/** + * Statements that break instances still running the previous release. Apps + * keeps them serving while the new instance migrates on start, and a code + * rollback cannot undo a migration. + */ +export function breakingStatements(statements: readonly string[]): string[] { + return statements.filter((statement) => + BREAKS_PREVIOUS_RELEASE.some((pattern) => + pattern.test(statement.trimStart()), + ), + ); +} + /** Replace a file through a rename, so a crash never leaves it half written. */ function writeFileAtomic(file: string, content: string): void { const temporary = path.join( diff --git a/packages/appkit/src/database/tooling/tests/migrate.test.ts b/packages/appkit/src/database/tooling/tests/migrate.test.ts index dd88e8e33..15056e199 100644 --- a/packages/appkit/src/database/tooling/tests/migrate.test.ts +++ b/packages/appkit/src/database/tooling/tests/migrate.test.ts @@ -129,6 +129,7 @@ describe("runMigrate", () => { }); expect(report.applied).toEqual(["0000_migration"]); expect(report.notApplied).toBeUndefined(); + expect(report.breaking).toBeUndefined(); expect(fs.readFileSync(report.typesFile, "utf-8")).toContain("notes"); }); @@ -326,4 +327,18 @@ describe("runMigrate", () => { expect((error.cause as Error).message).toMatch(/schema\.ts:\d+:\d+/); expect(resolveEnvBranch).not.toHaveBeenCalled(); }); + + test("reports the statements that break instances of the previous release", async () => { + const ctx = app(`notes: table("notes", { id: id(), body: text() })`); + resolveEnvBranch.mockResolvedValue(null); + await runMigrate(ctx, { interactive: false }); + + writeSchema(ctx.root, `notes: table("notes", { id: id() })`); + const report = await runMigrate(ctx, { interactive: false }); + + expect(report.generated?.statements).toEqual([ + 'ALTER TABLE "app"."notes" DROP COLUMN "body";', + ]); + expect(report.breaking).toEqual(report.generated?.statements); + }); }); diff --git a/packages/appkit/src/database/tooling/tests/migrations.test.ts b/packages/appkit/src/database/tooling/tests/migrations.test.ts index 79ed30f2e..edfdf0fb8 100644 --- a/packages/appkit/src/database/tooling/tests/migrations.test.ts +++ b/packages/appkit/src/database/tooling/tests/migrations.test.ts @@ -19,6 +19,7 @@ import { varchar, } from "../../schema-builder"; import { + breakingStatements, discardMigrationFiles, generateMigrationFiles, MigrationRenameAmbiguityError, @@ -343,6 +344,32 @@ describe("generateMigrationFiles", () => { }); }); +describe("breakingStatements", () => { + test("drops, renames, type changes and new NOT NULLs; additions and relaxations pass", () => { + const breaking = [ + 'DROP TABLE "app"."tags" CASCADE;', + 'ALTER TABLE "app"."notes" DROP COLUMN "body";', + 'ALTER TABLE "app"."notes" RENAME COLUMN "body" TO "text";', + 'ALTER TABLE "app"."notes" RENAME TO "posts";', + 'ALTER TABLE "app"."notes" ALTER COLUMN "n" SET DATA TYPE bigint;', + 'ALTER TABLE "app"."notes" ALTER COLUMN "title" SET NOT NULL;', + 'DROP TYPE "app"."mood";', + `ALTER TYPE "app"."mood" RENAME VALUE 'sad' TO 'blue';`, + ]; + const safe = [ + 'CREATE TABLE "app"."tags" ("id" serial PRIMARY KEY);', + 'ALTER TABLE "app"."notes" ADD COLUMN "title" text;', + 'ALTER TABLE "app"."notes" ALTER COLUMN "title" DROP NOT NULL;', + 'ALTER TABLE "app"."notes" ALTER COLUMN "title" DROP DEFAULT;', + 'ALTER TABLE "app"."notes" DROP CONSTRAINT "notes_tag_fk";', + 'DROP INDEX "app"."notes_title_idx";', + `ALTER TYPE "app"."mood" ADD VALUE 'calm';`, + ]; + + expect(breakingStatements([...safe, ...breaking])).toEqual(breaking); + }); +}); + describe("discardMigrationFiles", () => { test("a first migration goes with the folders it created", async () => { const folder = tempFolder(); diff --git a/packages/shared/src/cli/commands/db/migrate.test.ts b/packages/shared/src/cli/commands/db/migrate.test.ts index 1640b5223..d1fe7cd8c 100644 --- a/packages/shared/src/cli/commands/db/migrate.test.ts +++ b/packages/shared/src/cli/commands/db/migrate.test.ts @@ -112,6 +112,29 @@ describe("appkit db migrate", () => { expect(printed).toContain("Applied 0001_migration to the .env branch"); }); + test("warns when the migration breaks instances of the previous release", async () => { + const drop = 'ALTER TABLE "app"."notes" DROP COLUMN "body";'; + runMigrate.mockResolvedValue({ + generated: { + tag: "0002_migration", + file: path.join( + process.cwd(), + "config/database/migrations/0002_migration.sql", + ), + statements: [drop], + }, + breaking: [drop], + applied: ["0002_migration"], + typesFile, + }); + + await dbCommand.parseAsync(["migrate"], { from: "user" }); + + expect(output(log)).toContain( + `0002_migration breaks instances still running the previous release, which keep serving while the new one migrates on start: ${drop} Deploy code that works with both the old and the new schema first, then this change in a later deploy.`, + ); + }); + test("says why nothing was applied", async () => { runMigrate.mockResolvedValue({ generated: null, diff --git a/packages/shared/src/cli/commands/db/output.ts b/packages/shared/src/cli/commands/db/output.ts index ec652b74a..f3dc540a4 100644 --- a/packages/shared/src/cli/commands/db/output.ts +++ b/packages/shared/src/cli/commands/db/output.ts @@ -51,6 +51,12 @@ export function printMigrateReport(report: MigrateReport): void { } else { info("No schema changes since the last migration"); } + if (report.generated && report.breaking?.length) { + warn( + `${report.generated.tag} breaks instances still running the previous release, which keep serving while the new one migrates on start: ${report.breaking.join(" ")} ` + + "Deploy code that works with both the old and the new schema first, then this change in a later deploy.", + ); + } if (report.adopted?.length) { ok( diff --git a/packages/shared/src/cli/commands/db/tooling-contract.ts b/packages/shared/src/cli/commands/db/tooling-contract.ts index c8eea40b3..06e73e359 100644 --- a/packages/shared/src/cli/commands/db/tooling-contract.ts +++ b/packages/shared/src/cli/commands/db/tooling-contract.ts @@ -14,6 +14,7 @@ export interface MigrateReport { readonly generated: GeneratedMigration | null; readonly applied: readonly string[]; readonly adopted?: readonly string[]; + readonly breaking?: readonly string[]; readonly notApplied?: string; readonly typesFile: string; }