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/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/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 new file mode 100644 index 000000000..76d95f25e --- /dev/null +++ b/packages/appkit/src/database/tooling/context.ts @@ -0,0 +1,152 @@ +import type { LakebasePoolConfig } from "@databricks/lakebase"; +import type { Pool } from "pg"; + +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. */ +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.`, + ); + } +} + +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); +} + +/** + * The pool is created directly: initializeLakebasePool would look the endpoint + * and the user up a second time. + */ +async function openBranchPool(ctx: DbContext): Promise { + const { PGHOST, PGDATABASE, LAKEBASE_ENDPOINT } = ctx.env; + 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), + ]); + const port = ctx.env.PGPORT ? Number(ctx.env.PGPORT) : undefined; + const sslMode = ctx.env.PGSSLMODE as LakebasePoolConfig["sslMode"]; + return createLakebasePool({ + host: PGHOST, + database: PGDATABASE, + endpoint: LAKEBASE_ENDPOINT, + user, + ...(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, + }); +} + +/** 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/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..994fe1fac --- /dev/null +++ b/packages/appkit/src/database/tooling/guard.ts @@ -0,0 +1,108 @@ +import { type DbContext, hasEnvBranch } from "./context"; +import { currentIdentity } from "./identity"; +import type { LakebaseBranch } from "./lakebase-api"; +import { devBranchId, sessionBranchId } from "./naming"; +import { + parseResourceName, + readDeployBranches, + readLocalTarget, + resourceId, +} from "./targets"; + +/** What a branch is to this developer, checked in this order. */ +type BranchKind = + | "default" + | "protected" + | "deploy-target" + | "dev" + | "session" + | "other"; + +interface Classification { + readonly kind: BranchKind; + /** Why the branch takes no writes; undefined for dev and session branches. */ + 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[]; +}): Classification { + const { branch, userName, sessionId, deployBranches } = input; + if (branch.status?.default) { + return { kind: "default", reason: "it is the project's default branch" }; + } + if (branch.status?.is_protected) { + return { kind: "protected", reason: "it is a protected branch" }; + } + if (deployBranches.includes(branch.name)) { + return { + kind: "deploy-target", + reason: "it is a branch databricks.yml deploys to", + }; + } + const id = resourceId(branch.name); + if (id === devBranchId(userName)) return { kind: "dev" }; + if (sessionId && id === sessionBranchId(sessionId)) { + return { kind: "session" }; + } + return { + kind: "other", + reason: `it is not your dev branch (${devBranchId(userName)})${ + sessionId + ? ` or this checkout's session branch (${sessionBranchId(sessionId)})` + : "" + }`, + }; +} + +/** + * 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; +} + +/** 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 { + const local = readLocalTarget(ctx.env); + const name = parseResourceName(local.endpoint ?? undefined)?.branch; + if (!name || !hasEnvBranch(ctx.env)) return null; + 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.`, + ); + } + return { + branch, + ...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..bbec5337a --- /dev/null +++ b/packages/appkit/src/database/tooling/index.ts @@ -0,0 +1,10 @@ +/** + * `@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 new file mode 100644 index 000000000..a460846a1 --- /dev/null +++ b/packages/appkit/src/database/tooling/lakebase-api.ts @@ -0,0 +1,143 @@ +import { z } from "zod"; + +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"; + +// 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; +} + +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.getParsedOrNull(`get branch ${name}`, name, branchResponse); + } + + getEndpoint(name: string): Promise { + 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( + 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..ec2343450 --- /dev/null +++ b/packages/appkit/src/database/tooling/migrate.ts @@ -0,0 +1,157 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { + DEFAULT_SCHEMA_FILE, + loadDefaultDatabaseSchema, +} from "../../plugins/database/load-schema"; +import { + DATABASE_TYPES_FILE, + writeDatabaseTypes, +} from "../../type-generator/database"; +import { + applyPendingMigrations, + MigrationApplyError, +} from "../migrations/apply"; +import { + journalFile, + migrationsFolder, + readJournal, +} from "../migrations/paths"; +import { type DbContext, NO_ENV_BRANCH, withBranchPool } from "./context"; +import { notYourBranch, resolveEnvBranch, takesWrites } from "./guard"; +import { + breakingStatements, + discardMigrationFiles, + type GeneratedMigration, + generateMigrationFiles, +} from "./migrations"; + +/** Whether the checkout has a config/database/schema.ts yet. */ +function hasSchemaFile(root: string): boolean { + 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 ${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, +); + +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[]; + /** 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; +} + +export interface MigrateOptions { + /** false: an ambiguous rename throws instead of prompting. */ + readonly interactive: boolean; +} + +type ApplyOutcome = Pick; + +/** Why migrations must not be applied to the `.env` branch; undefined when they may. */ +async function applyBlocker(ctx: DbContext): Promise { + 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. */ +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 }; + return withBranchPool(ctx, async (pool) => { + const { applied, adopted } = await applyPendingMigrations(pool, { + migrationsFolder: folder, + schemaName, + }); + return { applied, ...(adopted.length > 0 ? { adopted } : {}) }; + }); +} + +/** + * `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 journalBefore = readJournalText(folder); + 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 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 ${file}, but did not apply it: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + 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 new file mode 100644 index 000000000..81786a253 --- /dev/null +++ b/packages/appkit/src/database/tooling/migrations.ts @@ -0,0 +1,379 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; + +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"; + +/** + * 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 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, 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 ( + (code === "ERR_MODULE_NOT_FOUND" && + /['"]drizzle-kit['"]/.test(current.message)) || + (code === "ERR_PACKAGE_PATH_NOT_EXPORTED" && + current.message.includes("drizzle-kit")) + ) { + return true; + } + current = current.cause; + } + 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) { + 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 }, + ); + } +} + +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( + 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`, + * 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 = `${migrationPrefix(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 = migrationFile(options.folder, tag); + fs.mkdirSync(metaFolder(options.folder), { 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. + 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 [metaFolder(folder), folder]) { + if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) + fs.rmdirSync(dir); + } +} 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..df06855ea --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/context.test.ts @@ -0,0 +1,194 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const { createWorkspaceClient, createLakebasePool } = vi.hoisted(() => ({ + createWorkspaceClient: vi.fn(), + createLakebasePool: vi.fn(), +})); + +vi.mock("../../../connectors/lakebase", () => ({ createLakebasePool })); +vi.mock("../../../workspace-client", () => ({ createWorkspaceClient })); + +import { getClientOptions } from "../../../context/client-options"; +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(async () => undefined) }; +const usePool = async (p: unknown) => p; + +beforeEach(() => { + createLakebasePool.mockReset().mockReturnValue(pool); + pool.end.mockClear(); + createWorkspaceClient.mockReset(); +}); +afterEach(cleanupTempApps); + +function contextFor(env: NodeJS.ProcessEnv, api: LakebaseApi) { + return fakeContext({ root: tempApp(), env, api }); +} + +describe("withBranchPool", () => { + test("refuses without PGHOST, PGDATABASE and LAKEBASE_ENDPOINT, before any API call", async () => { + const lakebase = new FakeLakebase(); + + await expect( + withBranchPool( + contextFor({ PGHOST: HOST, PGDATABASE: "db" }, lakebase.api()), + usePool, + ), + ).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(); + }); + + 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(withBranchPool(ctx, usePool)).resolves.toBe(pool); + expect(pool.end).toHaveBeenCalledOnce(); + expect(lakebase.calls).toEqual([`getEndpoint ${ENDPOINT}`]); + expect(createLakebasePool).toHaveBeenCalledWith( + expect.objectContaining({ + host: HOST.toUpperCase(), + database: "databricks_postgres", + endpoint: ENDPOINT, + user: "alice@example.com", + max: 2, + }), + ); + }); + + 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 withBranchPool(ctx, usePool); + + 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); + const env = { + PGDATABASE: "databricks_postgres", + LAKEBASE_ENDPOINT: ENDPOINT, + }; + + await expect( + 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( + withBranchPool( + contextFor( + { + ...env, + PGHOST: HOST, + LAKEBASE_ENDPOINT: `${BRANCH}/endpoints/gone`, + }, + lakebase.api(), + ), + usePool, + ), + ).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 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.`, + ); + expect(error.cause).toBe(lookupFailure); + + 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", () => { + 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/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-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..badf5ff76 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/drizzle-kit-dependency.test.ts @@ -0,0 +1,148 @@ +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"; + +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 failingImport(error: { message: string; code: string }) { + vi.resetModules(); + vi.doMock("drizzle-kit/api", () => { + throw Object.assign(new Error(error.message), { code: error.code }); + }); + return 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(tempRoot(), "migrations"), + schema: defineSchema(({ table }) => ({ + notes: table("notes", { id: id() }), + })), + interactive: false, + }); +} + +/** 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"); + + 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("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"); + + 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"); + + 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); + }); +}); 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..ee79cbf59 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/guard.test.ts @@ -0,0 +1,172 @@ +import { afterEach, describe, expect, test } from "vitest"; + +import { classifyBranch, resolveEnvBranch, takesWrites } 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 take writes", () => { + expect(verdict(dev)).toEqual({ kind: "dev" }); + expect(verdict(`${P}/branches/session-k2x9q0ab`)).toEqual({ + kind: "session", + }); + expect(takesWrites("dev")).toBe(true); + expect(takesWrites("session")).toBe(true); + }); + + 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 })).toEqual({ + kind: "protected", + reason: "it is a protected branch", + }); + 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")}`).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("resolveEnvBranch", () => { + const envFor = (branch: string) => ({ + PGHOST: "primary.example.com", + PGDATABASE: "databricks_postgres", + 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 resolveEnvBranch( + fakeContext({ root: tempApp(), env: envFor(dev), api: lakebase.api() }), + ); + expect(onDev).toMatchObject({ branch: { name: dev }, kind: "dev" }); + expect(onDev?.reason).toBeUndefined(); + + const onDefault = await resolveEnvBranch( + fakeContext({ + root: tempApp(), + env: envFor(`${P}/branches/production`), + api: lakebase.api(), + }), + ); + expect(onDefault).toMatchObject({ + kind: "default", + 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 never takes writes", async () => { + const lakebase = new FakeLakebase(); + lakebase.addBranch(dev); + const root = tempApp({ + "databricks.yml": databricksYml( + dev, + `${dev}/databases/databricks-postgres`, + ), + }); + + const env = await resolveEnvBranch( + fakeContext({ root, env: envFor(dev), api: lakebase.api() }), + ); + + expect(env).toMatchObject({ + kind: "deploy-target", + 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 env = await resolveEnvBranch( + fakeContext({ + root: personalTarget, + env: envFor(dev), + api: lakebase.api(), + }), + ); + expect(env).toMatchObject({ kind: "deploy-target" }); + await expect( + resolveEnvBranch( + 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 unless .env names a branch to connect to; a deleted branch points at init", async () => { + const lakebase = new FakeLakebase(); + await expect( + 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( + 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/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..75d8a85e2 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/lakebase-api.test.ts @@ -0,0 +1,126 @@ +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 branch = { + name: "projects/p/branches/b", + status: { current_state: "READY" }, + }; + const fake = client([ + branch, + apiError(404, "NOT_FOUND"), + apiError(403, "PERMISSION_DENIED"), + ]); + const api = new LakebaseApi(fake); + + await expect(api.getBranch("projects/p/branches/b")).resolves.toEqual( + branch, + ); + 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"], + ]); + }); + + 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..b4748d573 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/migrate.pg.test.ts @@ -0,0 +1,125 @@ +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 { resolveEnvBranch, withBranchPool } = vi.hoisted(() => ({ + resolveEnvBranch: vi.fn(), + withBranchPool: vi.fn(), +})); + +vi.mock("../guard", async (importOriginal) => ({ + ...(await importOriginal()), + resolveEnvBranch, +})); +vi.mock("../context", async (importOriginal) => ({ + ...(await importOriginal()), + withBranchPool, +})); + +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"); + }, + }; + resolveEnvBranch.mockResolvedValue({ + branch: { name: "projects/p/branches/dev-alice-3f9a2c" }, + kind: "dev", + }); + 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(); + } + }, + ); + }); + + 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 new file mode 100644 index 000000000..15056e199 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/migrate.test.ts @@ -0,0 +1,344 @@ +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, resolveEnvBranch, withBranchPool } = vi.hoisted( + () => ({ + applyPendingMigrations: vi.fn(), + resolveEnvBranch: vi.fn(), + withBranchPool: vi.fn(), + }), +); + +vi.mock("../../migrations/apply", async (importOriginal) => ({ + ...(await importOriginal()), + applyPendingMigrations, +})); +vi.mock("../guard", async (importOriginal) => ({ + ...(await importOriginal()), + resolveEnvBranch, +})); +vi.mock("../context", async (importOriginal) => ({ + ...(await importOriginal()), + withBranchPool, +})); + +import { MigrationApplyError } from "../../migrations/apply"; +import type { DbContext } from "../context"; +import { runMigrate } from "../migrate"; + +const builder = path.resolve( + import.meta.dirname, + "../../schema-builder/index.ts", +); +const roots: string[] = []; + +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( + file, + `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: {}, + 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" }, + kind: "dev", +}; + +const pool = {}; + +beforeEach(() => { + applyPendingMigrations.mockReset(); + withBranchPool + .mockReset() + .mockImplementation((_ctx, fn: (p: unknown) => unknown) => fn(pool)); + resolveEnvBranch.mockReset(); + resolveEnvBranch.mockResolvedValue(devBranch); +}); + +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(resolveEnvBranch).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(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, and applies to the .env branch with the app schema", async () => { + const ctx = app(`notes: table("notes", { id: id(), body: text() })`); + applyPendingMigrations.mockResolvedValue({ + applied: ["0000_migration"], + adopted: [], + }); + + const report = await runMigrate(ctx, { interactive: false }); + + expect(report.generated?.tag).toBe("0000_migration"); + 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(report.breaking).toBeUndefined(); + 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() })`); + resolveEnvBranch.mockResolvedValue(null); + await runMigrate(ctx, { interactive: false }); + + resolveEnvBranch.mockResolvedValue(devBranch); + 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() })`); + resolveEnvBranch.mockResolvedValue(null); + + const report = await runMigrate(ctx, { interactive: false }); + + expect(report.generated?.tag).toBe("0000_migration"); + expect(applyPendingMigrations).not.toHaveBeenCalled(); + 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() })`); + resolveEnvBranch.mockResolvedValue({ + branch: { name: "projects/p/branches/production" }, + 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(withBranchPool).not.toHaveBeenCalled(); + expect(applyPendingMigrations).not.toHaveBeenCalled(); + expect(report.notApplied).toBe( + "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() })`); + resolveEnvBranch.mockResolvedValue(null); + await runMigrate(ctx, { interactive: false }); + + resolveEnvBranch.mockResolvedValue(devBranch); + applyPendingMigrations.mockResolvedValue({ + applied: [], + adopted: ["0000_baseline"], + }); + const report = await runMigrate(ctx, { interactive: false }); + + expect(report).toMatchObject({ applied: [], adopted: ["0000_baseline"] }); + }); + + test("an apply failure says the generated migration was written but not applied", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + 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( + 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("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() })`); + resolveEnvBranch.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() })`, + ); + resolveEnvBranch.mockResolvedValue(devBranch); + 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() })`); + resolveEnvBranch.mockResolvedValue(null); + await runMigrate(ctx, { interactive: false }); + + writeSchema(ctx.root, `notes: table("notes", { id: id(), body: text() })`); + resolveEnvBranch.mockResolvedValue(devBranch); + 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() })`); + 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(withBranchPool).not.toHaveBeenCalled(); + }); + + test("a failure with nothing generated propagates unchanged", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + resolveEnvBranch.mockResolvedValue(null); + await runMigrate(ctx, { interactive: false }); + + resolveEnvBranch.mockResolvedValue(devBranch); + 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(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 new file mode 100644 index 000000000..edfdf0fb8 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/migrations.test.ts @@ -0,0 +1,410 @@ +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 { + breakingStatements, + discardMigrationFiles, + 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\\.$`, + ), + ); + }); +}); + +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(); + 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/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..a94438635 100644 --- a/packages/appkit/src/plugins/database/load-schema.ts +++ b/packages/appkit/src/plugins/database/load-schema.ts @@ -6,11 +6,17 @@ 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. */ + 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/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/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..b133e7871 --- /dev/null +++ b/packages/shared/src/cli/commands/db/database-tooling.d.ts @@ -0,0 +1,4 @@ +// `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/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/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.test.ts b/packages/shared/src/cli/commands/db/migrate.test.ts new file mode 100644 index 000000000..d1fe7cd8c --- /dev/null +++ b/packages/shared/src/cli/commands/db/migrate.test.ts @@ -0,0 +1,175 @@ +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("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, + 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..7096022ba --- /dev/null +++ b/packages/shared/src/cli/commands/db/migrate.ts @@ -0,0 +1,32 @@ +import { Command } from "commander"; + +import { loadDatabaseTooling } from "./load-tooling"; +import { fail, isInteractive, printMigrateReport } from "./output"; + +async function runDbMigrate(): Promise { + try { + const tooling = await loadDatabaseTooling(); + 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..f3dc540a4 --- /dev/null +++ b/packages/shared/src/cli/commands/db/output.ts @@ -0,0 +1,75 @@ +import path from "node:path"; +import process from "node:process"; + +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); +} + +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.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( + `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/commands/db/tooling-contract.ts b/packages/shared/src/cli/commands/db/tooling-contract.ts new file mode 100644 index 000000000..06e73e359 --- /dev/null +++ b/packages/shared/src/cli/commands/db/tooling-contract.ts @@ -0,0 +1,38 @@ +/** 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 breaking?: 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; +} 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