diff --git a/docs/docs/plugins/database.md b/docs/docs/plugins/database.md index 7f8aa77e1..0317bcd5a 100644 --- a/docs/docs/plugins/database.md +++ b/docs/docs/plugins/database.md @@ -84,6 +84,11 @@ public column, list with an explicit non-empty `order`. A keyless table with no sortable columns has no list route. `upsert` is available to server code but has no generated HTTP route. +Tables live in the Postgres schema `app` unless the second argument of +`defineSchema()` names another one, for example `{ schemaName: "public" }`. The +default is not `public` because the deployed app's service principal creates and +owns `app`, but has no `CREATE` privilege on `public`. + ## Schema discovery and overrides `database()` and `database({})` use the same defaults. During setup, the plugin 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 a4e6dd704..b247402e4 100644 --- a/packages/appkit/src/database/migrations/apply.ts +++ b/packages/appkit/src/database/migrations/apply.ts @@ -8,7 +8,7 @@ import { migrate } from "drizzle-orm/node-postgres/migrator"; import type { Pool, PoolClient } from "pg"; import { createLogger } from "../../logging/logger"; -import { ownershipHint } from "./ownership"; +import { ownershipHint, quoteIdentifier } from "./ownership"; import { BASELINE_TAG, type Journal, @@ -99,7 +99,7 @@ export class MigrationBaselineMismatchError extends Error { constructor(schemaName: string, problems: readonly string[]) { super( `Cannot adopt ${BASELINE_TAG}: schema "${schemaName}" has no recorded migrations and only part of the baseline: ${problems.join("; ")}. ` + - "Create the missing tables and columns (or drop the partial ones), or regenerate the baseline against this database.", + "Create the missing tables and columns (or drop the partial ones), or regenerate the baseline with `appkit db introspect` against this database.", ); this.name = "MigrationBaselineMismatchError"; this.problems = Object.freeze([...problems]); @@ -122,10 +122,6 @@ function sqlStateOf(error: unknown): string | undefined { return undefined; } -function quoteIdentifier(name: string): string { - return `"${name.replace(/"/g, '""')}"`; -} - /** One lock per app schema, so concurrent instances apply in turn. */ export function migrationLockKey(schemaName: string): string { return createHash("sha256") @@ -167,8 +163,7 @@ async function lastRecordedMigration( * * Known gap, shared with drizzle-orm: an entry older than the last recorded one * (e.g. merged from a parallel branch) is never applied, and an applied - * migration edited later goes unnoticed. Comparing the recorded hashes with the - * journal would catch both. + * migration edited later goes unnoticed. strandedMigrations reports both. */ function pendingEntries( journal: Journal, @@ -391,6 +386,89 @@ async function recordMigrationsApplied( } } +/** Tags of the committed migrations the database has not recorded, oldest first. */ +export async function pendingMigrationTags( + client: QueryClient, + options: ApplyMigrationsOptions, +): Promise { + const journal = readJournal(options.migrationsFolder); + if (!journal) return []; + const last = await lastRecordedMigration(client, options.schemaName); + return pendingEntries(journal, last).map((entry) => entry.tag); +} + +/** A committed migration drizzle-orm's migrator will never run on this database. */ +export interface StrandedMigration { + readonly tag: string; + /** + * "out of order": older than the last recorded migration but not recorded + * (merged from a parallel branch). "edited": recorded, but the file changed + * since. + */ + readonly reason: "out of order" | "edited"; +} + +/** + * Committed migrations the migrator skips for good: it runs only entries newer + * than the last recorded one, and never compares hashes. Recorded rows carry + * the journal's `when` and the file's sha256, so each entry is matched by its + * `when`. Read-only. + */ +export async function strandedMigrations( + client: QueryClient, + options: ApplyMigrationsOptions, +): Promise { + const journal = readJournal(options.migrationsFolder); + if (!journal) return []; + const last = await lastRecordedMigration(client, options.schemaName); + if (last === null) return []; + assertMigrationFiles(options.migrationsFolder, journal); + // One file per journal entry, in journal order. + const files = readMigrationFiles({ + migrationsFolder: options.migrationsFolder, + }); + const recorded = await client.query<{ + hash: string; + created_at: string | number; + }>( + `select hash, created_at from ${quoteIdentifier(options.schemaName)}.${quoteIdentifier(MIGRATIONS_TABLE)}`, + ); + const hashAt = new Map( + recorded.rows.map((row) => [Number(row.created_at), row.hash]), + ); + const stranded: StrandedMigration[] = []; + journal.entries.forEach((entry, index) => { + // Newer entries are pending: the migrator runs them. + if (entry.when > last) return; + const hash = hashAt.get(entry.when); + if (hash === undefined) { + stranded.push({ tag: entry.tag, reason: "out of order" }); + } else if (hash !== files[index]?.hash) { + stranded.push({ tag: entry.tag, reason: "edited" }); + } + }); + return stranded; +} + +/** recordMigrationsApplied in one transaction under the migration lock (introspect on an AppKit branch). */ +export async function markMigrationsApplied( + pool: Pick, + options: ApplyMigrationsOptions, + tags: readonly string[], +): Promise { + const journal = readJournal(options.migrationsFolder); + if (!journal) { + throw new MigrationFilesError( + `Cannot record ${tags.join(", ")} as applied: ${options.migrationsFolder} has no meta/_journal.json.`, + ); + } + await withMigrationLock(pool, options.schemaName, (client) => + inTransaction(client, () => + recordMigrationsApplied(client, options, journal, tags), + ), + ); +} + /** * Apply every committed migration the database has not recorded yet. * diff --git a/packages/appkit/src/database/migrations/ownership.ts b/packages/appkit/src/database/migrations/ownership.ts index 606efb1d1..76039f342 100644 --- a/packages/appkit/src/database/migrations/ownership.ts +++ b/packages/appkit/src/database/migrations/ownership.ts @@ -1,6 +1,9 @@ // Ownership model: each branch has one owner, the identity its migrations run -// as, and that identity owns the app's objects there. On the deploy branch it -// is the app's service principal, which creates the app schema on first start. +// as, and that identity owns the app's objects there: +// - deploy branch: the app's service principal, which creates the app schema +// on first start; +// - dev / session fork: the developer, once `appkit db init` hands over the +// objects the fork copied from its parent (Lakebase role API, reassign_owned_to). // There is no shared owner role and no SET ROLE. A shared owner role is not // possible: Lakebase rejects `GRANT … TO databricks_superuser`. @@ -13,6 +16,103 @@ export function ownershipHint(code: string | undefined): string | undefined { return ( "The connecting role lacks a privilege this migration needs: CREATE on the database, or ownership of the app schema and its tables. " + "On the deploy branch that role is the app's service principal: bind the database to the app with CAN_CONNECT_AND_CREATE, " + - "and if another role created the schema, use another schemaName in schema.ts or have that role transfer the schema and its objects." + "and if another role created the schema, use another schemaName in schema.ts or have that role transfer the schema and its objects. " + + "On your dev or session branch it is you: `appkit db init` hands you the objects the branch copied from its parent." ); } + +/** A Postgres identifier in double quotes, with embedded quotes doubled. */ +export function quoteIdentifier(name: string): string { + return `"${name.replace(/"/g, '""')}"`; +} + +/** User schemas: everything except pg_* (pg_catalog, pg_toast, …) and information_schema. */ +const USER_SCHEMAS = `n.nspname !~ '^pg_' and n.nspname <> 'information_schema'`; + +/** + * Postgres roles, other than current_user, that own a schema, relation, type or + * function in this database. A fork copies its parent's objects with their + * owners; `appkit db init` hands each of these roles' objects to the developer + * (role API delete with reassign_owned_to). Roles the role API does not manage + * (pg_database_owner, databricks_control_plane, …) are filtered out by the caller. + */ +export const FOREIGN_OWNERS_SQL = `select distinct pg_catalog.pg_get_userbyid(o.owner)::text as owner + from ( + select n.nspowner as owner from pg_catalog.pg_namespace n where ${USER_SCHEMAS} + union all + select c.relowner from pg_catalog.pg_class c + join pg_catalog.pg_namespace n on n.oid = c.relnamespace where ${USER_SCHEMAS} + union all + select t.typowner from pg_catalog.pg_type t + join pg_catalog.pg_namespace n on n.oid = t.typnamespace where ${USER_SCHEMAS} + union all + select p.proowner from pg_catalog.pg_proc p + join pg_catalog.pg_namespace n on n.oid = p.pronamespace where ${USER_SCHEMAS} + ) o + where o.owner <> (select r.oid from pg_catalog.pg_roles r where r.rolname = current_user) + order by 1`; + +/** + * Remove every object in the app schema, including the migration journal. Runs + * as the branch's owner, in one transaction. + * - A non-public schema is dropped as a unit; the migrator recreates it. + * - `public` belongs to pg_database_owner and cannot be dropped, so each + * function, procedure, relation and standalone type in it that current_user + * owns is dropped, routines first. Sequences owned by a column (identity, + * serial) go with their table, and a table's row type goes with the table. + * Objects that belong to an extension are left to it. + */ +export function cleanStatements(schemaName: string): readonly string[] { + if (schemaName !== "public") { + return [`DROP SCHEMA IF EXISTS ${quoteIdentifier(schemaName)} CASCADE`]; + } + return [ + `DO $appkit$ +DECLARE + r record; +BEGIN + FOR r IN + SELECT p.proname, pg_catalog.pg_get_function_identity_arguments(p.oid) AS args + FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' + AND p.prokind IN ('f', 'p') + AND p.proowner = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = current_user) + AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_depend d + WHERE d.classid = 'pg_catalog.pg_proc'::regclass AND d.objid = p.oid AND d.deptype = 'e') + LOOP + -- args is null when an earlier CASCADE already dropped the routine. + CONTINUE WHEN r.args IS NULL; + EXECUTE pg_catalog.format('DROP ROUTINE IF EXISTS public.%I(%s) CASCADE', r.proname, r.args); + END LOOP; + FOR r IN + SELECT c.relname, c.relkind FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND c.relkind IN ('r', 'p', 'v', 'm', 'f', 'S') + AND c.relowner = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = current_user) + AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_depend d + WHERE d.classid = 'pg_catalog.pg_class'::regclass AND d.objid = c.oid AND d.deptype IN ('a', 'i')) + LOOP + EXECUTE pg_catalog.format('DROP %s IF EXISTS public.%I CASCADE', + CASE r.relkind WHEN 'v' THEN 'VIEW' WHEN 'm' THEN 'MATERIALIZED VIEW' + WHEN 'f' THEN 'FOREIGN TABLE' WHEN 'S' THEN 'SEQUENCE' ELSE 'TABLE' END, + r.relname); + END LOOP; + FOR r IN + SELECT t.typname FROM pg_catalog.pg_type t + JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'public' + AND t.typowner = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = current_user) + AND (t.typtype IN ('e', 'd') + OR (t.typtype = 'c' AND EXISTS ( + SELECT 1 FROM pg_catalog.pg_class c WHERE c.oid = t.typrelid AND c.relkind = 'c'))) + LOOP + EXECUTE pg_catalog.format('DROP TYPE IF EXISTS public.%I CASCADE', r.typname); + END LOOP; +END +$appkit$`, + ]; +} diff --git a/packages/appkit/src/database/migrations/paths.ts b/packages/appkit/src/database/migrations/paths.ts index e331080b0..8d5126076 100644 --- a/packages/appkit/src/database/migrations/paths.ts +++ b/packages/appkit/src/database/migrations/paths.ts @@ -34,7 +34,7 @@ export function migrationsFolder(root: string): string { } /** Path of the journal inside a migrations folder. */ -function journalFile(folder: string): string { +export function journalFile(folder: string): string { return path.join(folder, "meta", "_journal.json"); } diff --git a/packages/appkit/src/database/migrations/tests/apply.test.ts b/packages/appkit/src/database/migrations/tests/apply.test.ts index 4fc178d14..f8d4524b4 100644 --- a/packages/appkit/src/database/migrations/tests/apply.test.ts +++ b/packages/appkit/src/database/migrations/tests/apply.test.ts @@ -7,9 +7,12 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { applyPendingMigrations, + markMigrationsApplied, MigrationApplyError, MigrationBaselineMismatchError, migrationLockKey, + pendingMigrationTags, + strandedMigrations, } from "../apply"; import { type Journal, MigrationFilesError } from "../paths"; @@ -87,6 +90,8 @@ afterEach(() => { interface FakeState { journalTableExists: boolean; lastCreatedAt: number | null; + /** Rows of `select hash, created_at` from the journal table. */ + journalRows?: Array<{ hash: string; created_at: string }>; failOn?: RegExp; /** SQLSTATE of the failOn error; defaults to 42P07 (duplicate_table). */ failCode?: string; @@ -146,6 +151,9 @@ class RecordingClient { if (text.startsWith("select max(created_at)")) { return { rows: [{ last: this.state.lastCreatedAt }] }; } + if (text.startsWith("select hash, created_at from")) { + return { rows: this.state.journalRows ?? [] }; + } if (text.includes("order by created_at desc limit 1")) { return { rows: @@ -355,6 +363,7 @@ describe("applyPendingMigrations", () => { expect((error as Error).message).toMatch( /^Applying migrations 0000_migration, 0001_migration failed \(SQLSTATE 42501\); none were applied\. The connecting role lacks a privilege this migration needs: /, ); + expect((error as Error).message).toContain("appkit db init"); }); test("a failed unlock destroys the connection instead of returning it to the pool", async () => { @@ -830,3 +839,199 @@ describe("applyPendingMigrations — failures name their cause", () => { ); }); }); + +describe("pendingMigrationTags", () => { + const options = (migrationsFolder: string) => ({ + migrationsFolder, + schemaName: "app", + }); + + test.each([ + [ + "without a journal table, every committed migration", + false, + null, + ["0000_migration", "0001_migration"], + ], + [ + "with nothing recorded, every committed migration", + true, + null, + ["0000_migration", "0001_migration"], + ], + [ + "only the migrations newer than the last recorded one", + true, + 1000, + ["0001_migration"], + ], + ["nothing once the last one is recorded", true, 2000, []], + ] as const)( + "lists %s", + async (_label, journalTableExists, lastCreatedAt, expected) => { + const { client } = fakePool({ journalTableExists, lastCreatedAt }); + + await expect( + pendingMigrationTags(client as never, options(twoMigrations())), + ).resolves.toEqual(expected); + }, + ); + + test("without a journal file there is nothing to apply, and it does not query", async () => { + const folder = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-apply-")); + folders.push(folder); + const { client } = fakePool({ + journalTableExists: true, + lastCreatedAt: null, + }); + + await expect( + pendingMigrationTags(client as never, options(folder)), + ).resolves.toEqual([]); + expect(client.statements).toEqual([]); + }); +}); + +describe("strandedMigrations", () => { + const options = (migrationsFolder: string) => ({ + migrationsFolder, + schemaName: "app", + }); + /** A journal row as the migrator writes it for that file. */ + const row = (folder: string, tag: string, when: number) => ({ + hash: createHash("sha256") + .update(fs.readFileSync(path.join(folder, `${tag}.sql`), "utf-8")) + .digest("hex"), + created_at: String(when), + }); + const threeMigrations = () => + migrations([ + { tag: "0000_migration", when: 1000, sql: ["select 0;"] }, + { tag: "0001_parallel", when: 1500, sql: ["select 1;"] }, + { tag: "0002_migration", when: 2000, sql: ["select 2;"] }, + ]); + + test("none when every entry up to the last recorded one is recorded unchanged", async () => { + const folder = threeMigrations(); + const { client } = fakePool({ + journalTableExists: true, + lastCreatedAt: 1500, + journalRows: [ + row(folder, "0000_migration", 1000), + row(folder, "0001_parallel", 1500), + ], + }); + + await expect( + strandedMigrations(client as never, options(folder)), + ).resolves.toEqual([]); + }); + + test("an entry older than the last recorded one but never recorded is out of order", async () => { + const folder = threeMigrations(); + const { client } = fakePool({ + journalTableExists: true, + lastCreatedAt: 2000, + journalRows: [ + row(folder, "0000_migration", 1000), + row(folder, "0002_migration", 2000), + ], + }); + + await expect( + strandedMigrations(client as never, options(folder)), + ).resolves.toEqual([{ tag: "0001_parallel", reason: "out of order" }]); + }); + + test("a recorded entry whose file changed since is edited", async () => { + const folder = threeMigrations(); + const rows = [ + row(folder, "0000_migration", 1000), + row(folder, "0001_parallel", 1500), + ]; + fs.appendFileSync(path.join(folder, "0000_migration.sql"), "select 9;"); + const { client } = fakePool({ + journalTableExists: true, + lastCreatedAt: 1500, + journalRows: rows, + }); + + await expect( + strandedMigrations(client as never, options(folder)), + ).resolves.toEqual([{ tag: "0000_migration", reason: "edited" }]); + }); + + test("with nothing recorded nothing is stranded, and the rows are not read", async () => { + const folder = threeMigrations(); + const { client } = fakePool({ + journalTableExists: false, + lastCreatedAt: null, + }); + + await expect( + strandedMigrations(client as never, options(folder)), + ).resolves.toEqual([]); + expect( + client.statements.some((s) => s.startsWith("select hash, created_at")), + ).toBe(false); + }); +}); + +describe("markMigrationsApplied", () => { + test("records the tags with the migrator's hash and when, in one transaction under the lock", async () => { + const folder = baselineThenMigration(); + const { pool, client } = fakePool({ + journalTableExists: false, + lastCreatedAt: null, + }); + + await markMigrationsApplied( + pool, + { migrationsFolder: folder, schemaName: "app" }, + ["0001_migration"], + ); + + const file = fs.readFileSync( + path.join(folder, "0001_migration.sql"), + "utf-8", + ); + expect(client.recorded).toEqual([ + [createHash("sha256").update(file).digest("hex"), 2000], + ]); + expect(steps(client.statements)).toEqual([ + "SET statement_timeout = 0", + "try lock", + "SET lock_timeout = '30s'", + "begin", + `CREATE SCHEMA IF NOT EXISTS "app"`, + `CREATE TABLE IF NOT EXISTS "app"."__drizzle_migrations" ( id SERIAL PRIMARY KEY, hash text NOT NULL, created_at bigint )`, + `insert into "app"."__drizzle_migrations" ("hash", "created_at") values($1, $2)`, + "commit", + "unlock", + "RESET lock_timeout", + "RESET statement_timeout", + ]); + }); + + test("an unknown tag rolls back", async () => { + const folder = baselineThenMigration(); + const { pool, client } = fakePool({ + journalTableExists: false, + lastCreatedAt: null, + }); + + await expect( + markMigrationsApplied( + pool, + { migrationsFolder: folder, schemaName: "app" }, + ["0009_missing"], + ), + ).rejects.toThrow("Migration 0009_missing is not in the journal."); + expect(steps(client.statements).slice(-4)).toEqual([ + "rollback", + "unlock", + "RESET lock_timeout", + "RESET statement_timeout", + ]); + }); +}); diff --git a/packages/appkit/src/database/migrations/tests/ownership.test.ts b/packages/appkit/src/database/migrations/tests/ownership.test.ts index 56ea47022..c0fddfdd2 100644 --- a/packages/appkit/src/database/migrations/tests/ownership.test.ts +++ b/packages/appkit/src/database/migrations/tests/ownership.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "vitest"; -import { ownershipHint } from "../ownership"; +import { + cleanStatements, + FOREIGN_OWNERS_SQL, + ownershipHint, +} from "../ownership"; describe("ownership: one owner per branch", () => { test("insufficient_privilege gets the branch-owner rule and the fix", () => { @@ -12,6 +16,9 @@ describe("ownership: one owner per branch", () => { "On the deploy branch that role is the app's service principal: bind the database to the app with CAN_CONNECT_AND_CREATE", ); expect(hint).toContain("use another schemaName in schema.ts"); + expect(hint).toContain( + "On your dev or session branch it is you: `appkit db init` hands you the objects", + ); }); test("any other failure gets no ownership hint", () => { @@ -19,3 +26,40 @@ describe("ownership: one owner per branch", () => { expect(ownershipHint(undefined)).toBeUndefined(); }); }); + +describe("cleanStatements", () => { + test("a non-public app schema is dropped as a unit, journal included", () => { + expect(cleanStatements("app")).toEqual([ + `DROP SCHEMA IF EXISTS "app" CASCADE`, + ]); + expect(cleanStatements('we"ird')).toEqual([ + `DROP SCHEMA IF EXISTS "we""ird" CASCADE`, + ]); + }); + + test("public is kept; only what current_user owns in it is dropped", () => { + const [statement, ...rest] = cleanStatements("public"); + expect(rest).toEqual([]); + expect(statement).toMatch(/^DO \$appkit\$/); + expect(statement).not.toContain("DROP SCHEMA"); + expect(statement).toContain("n.nspname = 'public'"); + expect(statement).toContain( + "c.relowner = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = current_user)", + ); + // Column-owned sequences go with their table; table row types are not dropped separately. + expect(statement).toContain("d.deptype IN ('a', 'i')"); + expect(statement).toContain("DROP TYPE IF EXISTS public.%I CASCADE"); + }); +}); + +describe("FOREIGN_OWNERS_SQL", () => { + test("lists owners of user schemas, relations, types and functions other than current_user", () => { + for (const catalog of ["pg_namespace", "pg_class", "pg_type", "pg_proc"]) { + expect(FOREIGN_OWNERS_SQL).toContain(`pg_catalog.${catalog}`); + } + expect(FOREIGN_OWNERS_SQL).toContain( + "n.nspname !~ '^pg_' and n.nspname <> 'information_schema'", + ); + expect(FOREIGN_OWNERS_SQL).toContain("where r.rolname = current_user"); + }); +}); diff --git a/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts b/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts index b9d35409b..7c9bc7241 100644 --- a/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts +++ b/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts @@ -23,22 +23,26 @@ import { } from "../engine/drizzle-data-path"; import { returningColumns } from "../engine/translate"; -const schema = defineSchema((builder) => { - const users = builder.table("users", { - id: id(), - email: text().unique(), - name: text(), - active: boolean(), - secret: text().private(), - }); - const posts = builder.table("posts", { - id: id(), - authorId: fk(() => users.id), - title: text(), - secret: text().private(), - }); - return { users, posts }; -}); +// Explicitly public: the order-by assertion expects unqualified SQL ("users"."secret"). +const schema = defineSchema( + (builder) => { + const users = builder.table("users", { + id: id(), + email: text().unique(), + name: text(), + active: boolean(), + secret: text().private(), + }); + const posts = builder.table("posts", { + id: id(), + authorId: fk(() => users.id), + title: text(), + secret: text().private(), + }); + return { users, posts }; + }, + { schemaName: "public" }, +); const users = schema.$tables.users; const naturalKeySchema = defineSchema((builder) => ({ diff --git a/packages/appkit/src/database/runtime/tests/translate.test.ts b/packages/appkit/src/database/runtime/tests/translate.test.ts index cf2c11fa9..cd90de104 100644 --- a/packages/appkit/src/database/runtime/tests/translate.test.ts +++ b/packages/appkit/src/database/runtime/tests/translate.test.ts @@ -35,27 +35,31 @@ import { translateWhere, } from "../engine/translate"; -const schema = defineSchema((builder) => { - const users = builder.table("users", { - id: id(), - name: text(), - age: integer(), - active: boolean().notNull(), - large: bigint().notNull(), - createdAt: timestamp().notNull(), - externalId: uuid().notNull(), - status: enumColumn("user_status", ["active", "disabled"]).notNull(), - metadata: jsonb(), - secret: text().private(), - }); - const posts = builder.table("posts", { - id: id(), - authorId: fk(() => users.id), - title: text(), - secret: text().private(), - }); - return { users, posts }; -}); +// Explicitly public: the assertions expect unqualified column SQL ("users"."name"). +const schema = defineSchema( + (builder) => { + const users = builder.table("users", { + id: id(), + name: text(), + age: integer(), + active: boolean().notNull(), + large: bigint().notNull(), + createdAt: timestamp().notNull(), + externalId: uuid().notNull(), + status: enumColumn("user_status", ["active", "disabled"]).notNull(), + metadata: jsonb(), + secret: text().private(), + }); + const posts = builder.table("posts", { + id: id(), + authorId: fk(() => users.id), + title: text(), + secret: text().private(), + }); + return { users, posts }; + }, + { schemaName: "public" }, +); const users = schema.$tables.users; const dialect = new PgDialect(); diff --git a/packages/appkit/src/database/schema-builder/define-schema.ts b/packages/appkit/src/database/schema-builder/define-schema.ts index 64ed5ff2d..1bab4ca25 100644 --- a/packages/appkit/src/database/schema-builder/define-schema.ts +++ b/packages/appkit/src/database/schema-builder/define-schema.ts @@ -339,6 +339,13 @@ export function assertFinalizedSchema(value: unknown): asserts value is Schema { } } +/** + * Postgres schema of every table when `schemaName` is omitted. Not `public`: + * the deployed app's service principal can create and own a schema of its + * own, but has no CREATE on `public`. + */ +export const DEFAULT_SCHEMA_NAME = "app"; + /** * Compile one declared schema. The returned type keeps the table names the * builder returned, so `api.tables` and `hooks` can name only real tables. @@ -347,7 +354,7 @@ export function defineSchema>( builder: (context: SchemaBuilderContext) => TTables, options?: DefineSchemaOptions, ): Schema> { - const schemaName = options?.schemaName ?? "public"; + const schemaName = options?.schemaName ?? DEFAULT_SCHEMA_NAME; if (!schemaName) throw new SchemaBuildError("Schema name cannot be empty"); const state: DeclarationState = { diff --git a/packages/appkit/src/database/schema-builder/tests/define-schema.test.ts b/packages/appkit/src/database/schema-builder/tests/define-schema.test.ts index 6afa07afe..a8fe2ac0b 100644 --- a/packages/appkit/src/database/schema-builder/tests/define-schema.test.ts +++ b/packages/appkit/src/database/schema-builder/tests/define-schema.test.ts @@ -79,8 +79,10 @@ describe("defineSchema finalization", () => { expect(Object.getPrototypeOf(schema.$tables.users.$columns)).toBeNull(); }); - it("defaults to public and supports a safe custom schema", () => { - expect(schema.$schemaName).toBe("public"); + it("defaults to app and supports a safe custom schema", () => { + // Not public: the deployed app's service principal has no CREATE there. + expect(schema.$schemaName).toBe("app"); + expect(schema.$tables.users.$schemaName).toBe("app"); const options: DefineSchemaOptions = { schemaName: "application" }; const custom = defineSchema( (builder) => ({ widgets: builder.table("widgets", { id: id() }) }), @@ -90,6 +92,26 @@ describe("defineSchema finalization", () => { expect(custom.$tables.widgets.$schemaName).toBe("application"); }); + it.each([ + ["by default, in app", undefined, "app"], + ['with schemaName "public", unqualified', "public", undefined], + ] as const)("creates tables and enums %s", (_label, schemaName, expected) => { + const schema = defineSchema( + (builder) => ({ + tickets: builder.table("tickets", { + status: builder.enum("ticket_status", ["open", "closed"]), + }), + }), + schemaName ? { schemaName } : undefined, + ); + const config = getTableConfig(pgOf(schema.$tables.tickets.$engine)); + expect(config.schema).toBe(expected); + expect( + (config.columns[0] as unknown as { enum?: { schema?: string } }).enum + ?.schema, + ).toBe(expected); + }); + it("accepts the explicit builder context type", () => { const build = (builder: SchemaBuilderContext) => { const users: TableHandle<{ id: ColumnBuilder; email: ColumnBuilder }> = diff --git a/packages/appkit/src/database/schema-builder/types.ts b/packages/appkit/src/database/schema-builder/types.ts index d49a14b02..133cbb77a 100644 --- a/packages/appkit/src/database/schema-builder/types.ts +++ b/packages/appkit/src/database/schema-builder/types.ts @@ -157,6 +157,7 @@ export type TableHandle> = AppKitTable & { }; export interface DefineSchemaOptions { + /** Postgres schema of every table. Default: `"app"`. */ readonly schemaName?: string; } diff --git a/packages/appkit/src/database/tooling/branch-sql.ts b/packages/appkit/src/database/tooling/branch-sql.ts new file mode 100644 index 000000000..b77244dda --- /dev/null +++ b/packages/appkit/src/database/tooling/branch-sql.ts @@ -0,0 +1,32 @@ +import { type DbContext, openBranchPool } from "./context"; + +/** Run statements on the `.env` branch in one transaction, then end the pool. */ +export async function runInTransaction( + ctx: DbContext, + statements: readonly string[], +): Promise { + const pool = await openBranchPool(ctx); + if (!pool) { + throw new Error( + "No branch configured (PGHOST, PGDATABASE, LAKEBASE_ENDPOINT) — run `appkit db init`.", + ); + } + try { + const client = await pool.connect(); + let failed: unknown; + try { + await client.query("BEGIN"); + for (const statement of statements) await client.query(statement); + await client.query("COMMIT"); + } catch (error) { + failed = error; + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + // A connection whose transaction state is unknown is not reused. + client.release(failed instanceof Error ? failed : undefined); + } + } finally { + await pool.end().catch(() => undefined); + } +} diff --git a/packages/appkit/src/database/tooling/clean.ts b/packages/appkit/src/database/tooling/clean.ts new file mode 100644 index 000000000..b18707c2a --- /dev/null +++ b/packages/appkit/src/database/tooling/clean.ts @@ -0,0 +1,26 @@ +import { loadDefaultDatabaseSchema } from "../../plugins/database/load-schema"; +import { cleanStatements } from "../migrations/ownership"; +import { runInTransaction } from "./branch-sql"; +import type { DbContext } from "./context"; +import { assertAppkitBranch } from "./guard"; +import { requireSchemaFile } from "./migrate"; + +interface CleanReport { + readonly branch: string; + readonly schemaName: string; +} + +/** + * `appkit db clean`: remove every app-schema object, including the migration + * journal, on an AppKit dev or session branch. Refuses any other branch + * before connecting. + */ +export async function runClean(ctx: DbContext): Promise { + requireSchemaFile(ctx.root, "clean"); + const branch = await assertAppkitBranch(ctx, "clean"); + const schema = await loadDefaultDatabaseSchema(ctx.root, { + showImportError: true, + }); + await runInTransaction(ctx, cleanStatements(schema.$schemaName)); + return { branch: branch.name, schemaName: schema.$schemaName }; +} diff --git a/packages/appkit/src/database/tooling/context.ts b/packages/appkit/src/database/tooling/context.ts new file mode 100644 index 000000000..c8850dea0 --- /dev/null +++ b/packages/appkit/src/database/tooling/context.ts @@ -0,0 +1,122 @@ +import type { LakebasePoolConfig } from "@databricks/lakebase"; +import type { Pool } from "pg"; + +import { initializeLakebasePool } from "../../connectors/lakebase"; +import { endpointHosts } from "../../connectors/lakebase/endpoint-host"; +import { getClientOptions } from "../../context/client-options"; +import { createWorkspaceClient } from "../../workspace-client"; +import { LakebaseApi, type LakebaseEndpoint } from "./lakebase-api"; + +/** One REST call through the SDK's unified-auth client. */ +export interface ToolingRequest { + path: string; + method: "GET" | "POST" | "PATCH" | "DELETE"; + query?: Record; + headers: Headers; + raw: boolean; + payload?: unknown; +} + +/** + * The slice of the SDK client the tooling uses. It is also everything the + * Lakebase pool needs: token refresh calls `apiClient.request` and the user + * lookup calls `currentUser.me`. + */ +export interface ToolingWorkspaceClient { + readonly apiClient: { request(options: ToolingRequest): Promise }; + readonly currentUser: { me(): Promise<{ userName?: string }> }; +} + +/** Where a command runs: the app root, its environment and its Databricks identity. */ +export interface DbContext { + readonly root: string; + readonly env: NodeJS.ProcessEnv; + /** Lazy; the profile comes from DATABRICKS_CONFIG_PROFILE. */ + workspace(): ToolingWorkspaceClient; + /** Lazy Lakebase REST client over workspace(). */ + api(): LakebaseApi; +} + +export function createDbContext( + options: { root?: string; env?: NodeJS.ProcessEnv } = {}, +): DbContext { + const root = options.root ?? process.cwd(); + const env = options.env ?? process.env; + let client: ToolingWorkspaceClient | undefined; + let api: LakebaseApi | undefined; + const ctx: DbContext = { + root, + env, + workspace() { + if (!client) { + const profile = env.DATABRICKS_CONFIG_PROFILE; + client = createWorkspaceClient({ + ...(profile ? { profile } : {}), + clientOptions: getClientOptions(), + }).toLegacyWorkspaceClient(); + } + return client; + }, + api() { + api ??= new LakebaseApi(ctx.workspace()); + return api; + }, + }; + return ctx; +} + +/** + * The guard checks the branch named by LAKEBASE_ENDPOINT, but the pool connects + * to PGHOST. The runtime only warns when it cannot verify that PGHOST serves that + * endpoint; a command that writes refuses instead. + */ +async function assertHostServesEndpoint( + ctx: DbContext, + endpoint: string, + host: string, +): Promise { + let found: LakebaseEndpoint | null; + try { + found = await ctx.api().getEndpoint(endpoint); + } catch (error) { + throw new Error( + `Could not verify that PGHOST ${host} serves LAKEBASE_ENDPOINT ${endpoint}, so it did not connect.`, + { cause: error }, + ); + } + if (!found) { + throw new Error( + `LAKEBASE_ENDPOINT ${endpoint} does not exist, so it did not connect. Run \`appkit db init\` to point .env at your branch.`, + ); + } + const hosts = endpointHosts(found); + if (!hosts.includes(host.toLowerCase())) { + throw new Error( + `PGHOST ${host} is not a host of LAKEBASE_ENDPOINT ${endpoint} (${hosts.length > 0 ? `expected ${hosts.join(" or ")}` : "it has no hosts"}), so it did not connect. Run \`appkit db init\` to rewrite .env.`, + ); + } +} + +/** null when PGHOST / PGDATABASE / LAKEBASE_ENDPOINT are not configured. */ +export async function openBranchPool(ctx: DbContext): Promise { + const { PGHOST, PGDATABASE, LAKEBASE_ENDPOINT } = ctx.env; + if (!PGHOST || !PGDATABASE || !LAKEBASE_ENDPOINT) return null; + await assertHostServesEndpoint(ctx, LAKEBASE_ENDPOINT, PGHOST); + const port = ctx.env.PGPORT ? Number(ctx.env.PGPORT) : undefined; + const sslMode = ctx.env.PGSSLMODE as LakebasePoolConfig["sslMode"]; + return initializeLakebasePool({ + host: PGHOST, + database: PGDATABASE, + endpoint: LAKEBASE_ENDPOINT, + user: ctx.env.PGUSER || undefined, + ...(port ? { port } : {}), + ...(sslMode ? { sslMode } : {}), + // Only apiClient.request and currentUser.me are used (see ToolingWorkspaceClient). + workspaceClient: ctx.workspace() as unknown as NonNullable< + LakebasePoolConfig["workspaceClient"] + >, + // One-shot CLI pools: a small cap, and no lingering idle connections. + max: 2, + idleTimeoutMillis: 1_000, + }); +} diff --git a/packages/appkit/src/database/tooling/drizzle-exports.ts b/packages/appkit/src/database/tooling/drizzle-exports.ts new file mode 100644 index 000000000..038d0fa84 --- /dev/null +++ b/packages/appkit/src/database/tooling/drizzle-exports.ts @@ -0,0 +1,24 @@ +import { getTableColumns, is, type Table } from "drizzle-orm"; +import { PgEnumColumn } from "drizzle-orm/pg-core"; + +import type { Schema } from "../schema-builder"; + +/** + * The "module exports" drizzle-kit snapshots: every engine table plus every + * enum a column references. The pgSchema object is never included, so + * generated SQL contains no CREATE SCHEMA; the migrator creates the schema as + * the identity that applies the migrations, which then owns it. + */ +export function drizzleKitImports(schema: Schema): Record { + const imports: Record = Object.create(null); + for (const [name, table] of Object.entries(schema.$engine)) { + imports[`table:${name}`] = table; + const columns = getTableColumns(table as unknown as Table); + for (const column of Object.values(columns)) { + if (is(column, PgEnumColumn)) { + imports[`enum:${column.enum.enumName}`] = column.enum; + } + } + } + return imports; +} diff --git a/packages/appkit/src/database/tooling/env-file.ts b/packages/appkit/src/database/tooling/env-file.ts new file mode 100644 index 000000000..de97d66fe --- /dev/null +++ b/packages/appkit/src/database/tooling/env-file.ts @@ -0,0 +1,64 @@ +import fs from "node:fs"; + +/** Captures any indentation and `export ` before the key, so a rewrite keeps them. */ +const KEY_LINE = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)\s*=/; +const PLAIN_VALUE = /^[A-Za-z0-9_./:@+-]*$/; + +/** + * A value dotenv reads back unchanged, always on one line: plain; else + * double-quoted, where dotenv turns `\n` and `\r` back into line breaks; else + * single-quoted, which dotenv takes literally. + */ +function formatValue(key: string, value: string): string { + if (PLAIN_VALUE.test(value)) return value; + if (!/["\\]/.test(value)) { + return `"${value.replace(/\r/g, "\\r").replace(/\n/g, "\\n")}"`; + } + if (!/['\r\n]/.test(value)) return `'${value}'`; + throw new Error( + `Cannot write ${key} to .env: its value mixes quotes, backslashes and line breaks.`, + ); +} + +/** + * Replace `KEY=…` lines in place and append missing keys. Comments, blank + * lines, other keys, their order, `export ` prefixes and CRLF line endings are + * kept. A missing file starts from `seedFrom` (e.g. .env.example) when that + * exists. Written 0600. + */ +export function updateEnvFile( + file: string, + values: Readonly>, + seedFrom?: string, +): void { + const source = fs.existsSync(file) + ? file + : seedFrom && fs.existsSync(seedFrom) + ? seedFrom + : null; + const text = source ? fs.readFileSync(source, "utf-8") : ""; + const eol = text.includes("\r\n") ? "\r\n" : "\n"; + const lines = + text.length > 0 ? text.replace(/\r?\n$/, "").split(/\r?\n/) : []; + + const written = new Set(); + const out: string[] = []; + for (const line of lines) { + const match = KEY_LINE.exec(line); + const key = match?.[2]; + if (key === undefined || !Object.hasOwn(values, key)) { + out.push(line); + continue; + } + // Later duplicates of a managed key are dropped, so the value is unambiguous. + if (written.has(key)) continue; + written.add(key); + out.push(`${match?.[1] ?? ""}${key}=${formatValue(key, values[key])}`); + } + for (const [key, value] of Object.entries(values)) { + if (!written.has(key)) out.push(`${key}=${formatValue(key, value)}`); + } + + fs.writeFileSync(file, `${out.join(eol)}${eol}`, { mode: 0o600 }); + fs.chmodSync(file, 0o600); +} diff --git a/packages/appkit/src/database/tooling/guard.ts b/packages/appkit/src/database/tooling/guard.ts new file mode 100644 index 000000000..830d28e25 --- /dev/null +++ b/packages/appkit/src/database/tooling/guard.ts @@ -0,0 +1,120 @@ +import type { DbContext } from "./context"; +import { currentIdentity } from "./identity"; +import type { LakebaseBranch } from "./lakebase-api"; +import { devBranchId, sessionBranchId } from "./naming"; +import { + parseResourceName, + readDeployBranches, + readLocalTarget, + resourceId, +} from "./targets"; + +// Commands that write to a branch run only on branches `appkit db init` +// created for this developer (dev-…) or this checkout (session-…). + +interface BranchVerdict { + readonly appkitOwned: boolean; + /** Why the branch is not AppKit-owned. */ + readonly reason?: string; +} + +export function classifyBranch(input: { + branch: LakebaseBranch; + userName: string; + sessionId: string | null; + /** Every branch databricks.yml deploys to; the deployed app migrates those itself. */ + deployBranches: readonly string[]; +}): BranchVerdict { + const { branch, userName, sessionId, deployBranches } = input; + if (branch.status?.default) { + return { appkitOwned: false, reason: "it is the project's default branch" }; + } + if (branch.status?.is_protected) { + return { appkitOwned: false, reason: "it is a protected branch" }; + } + if (deployBranches.includes(branch.name)) { + return { + appkitOwned: false, + reason: "it is a branch databricks.yml deploys to", + }; + } + const id = resourceId(branch.name); + if (id === devBranchId(userName)) return { appkitOwned: true }; + if (sessionId && id === sessionBranchId(sessionId)) { + return { appkitOwned: true }; + } + return { + appkitOwned: false, + reason: `it is not your dev branch (${devBranchId(userName)})${ + sessionId + ? ` or this checkout's session branch (${sessionBranchId(sessionId)})` + : "" + }`, + }; +} + +type GuardedAction = "clean" | "seed"; + +export class BranchGuardError extends Error { + constructor(action: GuardedAction, branch: string | null, reason: string) { + super( + branch + ? `Refusing to ${action} ${resourceId(branch)}: ${reason}. \`appkit db ${action}\` only writes to branches \`appkit db init\` created for you (dev-… or session-…).` + : `Cannot ${action}: ${reason}.`, + ); + this.name = "BranchGuardError"; + } +} + +interface BranchCheck { + readonly branch: LakebaseBranch; + readonly verdict: BranchVerdict; +} + +/** Classify the `.env` branch through the API (no database connection); null when none is configured. */ +export async function checkAppkitBranch( + ctx: DbContext, +): Promise { + const local = readLocalTarget(ctx.env); + const name = parseResourceName(local.endpoint ?? undefined)?.branch; + if (!name) return null; + const branch = await ctx.api().getBranch(name); + if (!branch) { + throw new Error( + `The .env branch ${name} does not exist (deleted or expired). Run \`appkit db init\` to create or pick one.`, + ); + } + const { userName } = await currentIdentity(ctx); + return { + branch, + verdict: classifyBranch({ + branch, + userName, + sessionId: local.session, + deployBranches: readDeployBranches(ctx.root), + }), + }; +} + +/** Throws BranchGuardError unless the `.env` branch is AppKit-owned. Never connects to the database. */ +export async function assertAppkitBranch( + ctx: DbContext, + action: GuardedAction, +): Promise { + const check = await checkAppkitBranch(ctx); + if (!check) { + throw new BranchGuardError( + action, + null, + "no branch configured in .env (LAKEBASE_ENDPOINT) — run `appkit db init`", + ); + } + if (!check.verdict.appkitOwned) { + throw new BranchGuardError( + action, + check.branch.name, + check.verdict.reason ?? "it is not an AppKit branch", + ); + } + return check.branch; +} diff --git a/packages/appkit/src/database/tooling/identity.ts b/packages/appkit/src/database/tooling/identity.ts new file mode 100644 index 000000000..6a7308405 --- /dev/null +++ b/packages/appkit/src/database/tooling/identity.ts @@ -0,0 +1,38 @@ +import type { DbContext } from "./context"; +import type { IdentityType } from "./lakebase-api"; + +/** Who runs the command: the Postgres role name is the user name (email or SP client ID). */ +export interface Identity { + readonly userName: string; + readonly identityType: IdentityType; +} + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** A service principal's user name is its application (client) ID. */ +export function identityTypeOf(userName: string): IdentityType { + return UUID.test(userName) ? "SERVICE_PRINCIPAL" : "USER"; +} + +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, identityType: identityTypeOf(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..88b5867fa --- /dev/null +++ b/packages/appkit/src/database/tooling/index.ts @@ -0,0 +1,26 @@ +/** + * `@databricks/appkit/database-tooling` — CLI-only logic behind `appkit db`. + * Loaded lazily by the shared CLI; it may import drizzle-kit, so the runtime + * plugin never imports it. + */ +export { runClean } from "./clean"; +export { createDbContext, type DbContext } from "./context"; +export { + defaultSchemaStart, + type InitChoices, + type InitOptions, + type InitReport, + resolveInitDefaults, + runInit, + type SchemaStart, + schemaStartsFor, +} from "./init"; +export { + type IntrospectOptions, + type IntrospectReport, + runIntrospect, +} from "./introspect"; +export { type MigrateOptions, type MigrateReport, runMigrate } from "./migrate"; +export { runSeed } from "./seed"; +export type { BranchMode } from "./targets"; +export { runVerify, type VerifyOptions, type VerifyReport } from "./verify"; diff --git a/packages/appkit/src/database/tooling/init.ts b/packages/appkit/src/database/tooling/init.ts new file mode 100644 index 000000000..9fc23e28b --- /dev/null +++ b/packages/appkit/src/database/tooling/init.ts @@ -0,0 +1,504 @@ +import fs from "node:fs"; +import path from "node:path"; + +import dotenv from "dotenv"; + +import { FOREIGN_OWNERS_SQL } from "../migrations/ownership"; +import { runClean } from "./clean"; +import { type DbContext, openBranchPool } from "./context"; +import { updateEnvFile } from "./env-file"; +import { classifyBranch } from "./guard"; +import { currentIdentity, type Identity } from "./identity"; +import { type IntrospectReport, runIntrospect } from "./introspect"; +import { + type LakebaseBranch, + LakebaseApiError, + type LakebaseEndpoint, + type LakebaseRole, + READ_WRITE_ENDPOINT, + SUPERUSER_MEMBERSHIP, +} from "./lakebase-api"; +import { + hasSchemaFile, + type MigrateReport, + requireSchemaFile, + runMigrate, +} from "./migrate"; +import { + devBranchId, + newSessionId, + SESSION_BRANCH_TTL, + sessionBranchId, +} from "./naming"; +import { + type BranchMode, + type DeployTarget, + type LocalTarget, + parseResourceName, + readDeployBranches, + readDeployTarget, + readLocalTarget, + resourceId, +} from "./targets"; + +/** How init leaves the schema. */ +export type SchemaStart = "migrate" | "introspect" | "clean"; +/** + * The starts worth offering, the default first. Without schema.ts there is + * nothing to migrate and no schema name to clean, so introspect is the only + * start that can run. + */ +export function schemaStartsFor(hasSchema: boolean): readonly SchemaStart[] { + return hasSchema ? ["migrate", "introspect", "clean"] : ["introspect"]; +} + +export function defaultSchemaStart(hasSchema: boolean): SchemaStart { + return schemaStartsFor(hasSchema)[0] as SchemaStart; +} + +export interface InitChoices { + readonly mode: BranchMode; + /** null leaves the schema as it is. */ + readonly schemaStart: SchemaStart | null; +} + +export interface InitOptions { + /** Passed to migrate: false makes an ambiguous rename fail instead of prompting. */ + readonly interactive: boolean; +} + +type RoleAction = "kept" | "created" | "updated"; + +export interface InitReport { + readonly mode: BranchMode; + readonly branch: string; + readonly created: boolean; + /** The branch a new fork was created from. */ + readonly sourceBranch?: string; + readonly endpoint: string; + readonly endpointCreated: boolean; + readonly host: string; + readonly database: string; + readonly user: string; + readonly role: { readonly name: string; readonly action: RoleAction }; + /** Postgres roles whose objects on this branch were handed to the developer. */ + readonly handedOff: readonly string[]; + readonly envFile: string; + readonly cleaned: boolean; + readonly schema: MigrateReport | null; + readonly introspect: IntrospectReport | null; +} + +const ENV_FILE = ".env"; +const ENV_EXAMPLE = ".env.example"; +const DEFAULT_ENDPOINT_ID = "primary"; +/** Every Lakebase branch has it; used when nothing names another database. */ +const DEFAULT_DATABASE = "databricks_postgres"; + +/** + * The mode `.env`'s branch implies: your dev branch is "dev", this checkout's + * session branch (APPKIT_DB_SESSION) is "session". Null for any other branch, + * without an endpoint, or when it belongs to another project than the + * configured one: init then asks, or uses "dev" without prompts. + */ +export async function currentBranchMode( + ctx: DbContext, +): Promise { + const local = readLocalTarget(ctx.env); + const current = parseResourceName(local.endpoint ?? undefined); + if (!current?.branch) return null; + if (local.project && current.project !== local.project) return null; + const branchId = resourceId(current.branch); + if (local.session && branchId === sessionBranchId(local.session)) { + return "session"; + } + const identity = await currentIdentity(ctx); + return branchId === devBranchId(identity.userName) ? "dev" : null; +} + +/** The mode `.env`'s branch implies (null without one), and whether schema.ts exists. */ +export async function resolveInitDefaults(ctx: DbContext): Promise<{ + mode: BranchMode | null; + hasSchema: boolean; +}> { + return { + mode: await currentBranchMode(ctx), + hasSchema: hasSchemaFile(ctx.root), + }; +} + +/** LAKEBASE_PROJECT, else the project of LAKEBASE_ENDPOINT, else of the bundle's postgres_branch. */ +function resolveProject( + local: LocalTarget, + deploy: DeployTarget | null, +): string { + const project = + local.project ?? + parseResourceName(local.endpoint ?? undefined)?.project ?? + parseResourceName(deploy?.branch)?.project; + if (!project) { + throw new Error( + "No Lakebase project configured. Set LAKEBASE_PROJECT=projects/ in .env, or bind postgres_branch in databricks.yml (`databricks apps init` does).", + ); + } + return project; +} + +/** Drop .env connection values and a deploy binding that belong to another project than LAKEBASE_PROJECT. */ +function scopeToProject( + project: string, + local: LocalTarget, + deploy: DeployTarget | null, +): { local: LocalTarget; deploy: DeployTarget | null } { + const inProject = (name: string | null | undefined) => + parseResourceName(name ?? undefined)?.project === project; + return { + local: inProject(local.endpoint) + ? local + : { ...local, endpoint: null, database: null }, + deploy: deploy && inProject(deploy.branch) ? deploy : null, + }; +} + +function isExpired(branch: LakebaseBranch, now = Date.now()): boolean { + if (branch.status?.current_state === "DELETED") return true; + const expires = branch.status?.expire_time; + return !!expires && Date.parse(expires) <= now; +} + +/** Find dev-…/session-… with one GET, or fork the project's default branch. */ +async function findOrCreateBranch( + ctx: DbContext, + project: string, + mode: BranchMode, + identity: Identity, + session: string | null, +): Promise<{ + branch: LakebaseBranch; + created: boolean; + sourceBranch?: string; + session: string | null; +}> { + const api = ctx.api(); + let sessionId = mode === "session" ? (session ?? newSessionId()) : null; + const idFor = () => + mode === "dev" + ? devBranchId(identity.userName) + : sessionBranchId(sessionId as string); + + let existing = await api.getBranch(`${project}/branches/${idFor()}`); + if (existing && isExpired(existing)) { + if (mode === "dev") { + throw new Error( + `Your dev branch ${existing.name} was deleted. Restore it, or delete it for good, then rerun \`appkit db init\`.`, + ); + } + // An expired session branch is replaced by a fresh session. + sessionId = newSessionId(); + existing = await api.getBranch(`${project}/branches/${idFor()}`); + } + if (existing) return { branch: existing, created: false, session: sessionId }; + + const parent = (await api.listBranches(project)).find( + (b) => b.status?.default, + ); + if (!parent) { + throw new Error(`Project ${project} has no default branch to fork from.`); + } + const id = idFor(); + if (sessionId && sessionId !== session) { + // Written first, so a failed or interrupted run reuses this id instead of forking again. + updateEnvFile( + path.join(ctx.root, ENV_FILE), + { APPKIT_DB_SESSION: sessionId }, + path.join(ctx.root, ENV_EXAMPLE), + ); + } + try { + const branch = await api.createBranch(project, id, { + source_branch: parent.name, + ...(mode === "dev" + ? { no_expiry: true as const } + : { ttl: SESSION_BRANCH_TTL }), + }); + return { + branch: branch?.name ? branch : { name: `${project}/branches/${id}` }, + created: true, + sourceBranch: parent.name, + session: sessionId, + }; + } catch (error) { + if (error instanceof LakebaseApiError && error.statusCode === 403) { + throw new LakebaseApiError( + error.operation, + `creating ${id} needs CAN MANAGE on ${project}. Ask a project owner for it.`, + { statusCode: 403, cause: error }, + ); + } + if (error instanceof LakebaseApiError && error.statusCode === 409) { + // Another run created it first. + const raced = await api.getBranch(`${project}/branches/${id}`); + if (raced) return { branch: raced, created: false, session: sessionId }; + } + throw error; + } +} + +async function ensureReadWriteEndpoint( + ctx: DbContext, + branch: string, +): Promise<{ endpoint: LakebaseEndpoint; created: boolean; host: string }> { + const api = ctx.api(); + const readWrite = (endpoints: LakebaseEndpoint[]) => + endpoints.find((e) => e.status?.endpoint_type === READ_WRITE_ENDPOINT); + + let endpoint = readWrite(await api.listEndpoints(branch)); + let created = false; + if (!endpoint) { + endpoint = await api.createEndpoint(branch, DEFAULT_ENDPOINT_ID); + created = true; + } + let host = endpoint?.status?.hosts?.host; + if (!host) { + endpoint = readWrite(await api.listEndpoints(branch)) ?? endpoint; + host = endpoint?.status?.hosts?.host; + } + if (!endpoint?.name || !host) { + throw new Error( + `The read-write endpoint of ${branch} has no host yet. Rerun \`appkit db init\` in a minute.`, + ); + } + return { endpoint, created, host }; +} + +async function ensureDeveloperRole( + ctx: DbContext, + branch: string, + identity: Identity, + roles: readonly LakebaseRole[], +): Promise<{ role: LakebaseRole; action: RoleAction }> { + const api = ctx.api(); + const existing = roles.find( + (r) => r.status?.postgres_role === identity.userName, + ); + if (!existing) { + const role = await api.createRole(branch, { + identity_type: identity.identityType, + postgres_role: identity.userName, + auth_method: "LAKEBASE_OAUTH_V1", + membership_roles: [SUPERUSER_MEMBERSHIP], + }); + if (!role?.name) { + // The operation response normally carries the role; fall back to a lookup. + const found = (await api.listRoles(branch)).find( + (r) => r.status?.postgres_role === identity.userName, + ); + if (!found) + throw new Error(`Creating your role on ${branch} did not return it.`); + return { role: found, action: "created" }; + } + return { role, action: "created" }; + } + if (existing.status?.membership_roles?.includes(SUPERUSER_MEMBERSHIP)) { + return { role: existing, action: "kept" }; + } + const role = await api.setRoleMemberships(existing.name, [ + SUPERUSER_MEMBERSHIP, + ]); + return { role: role?.name ? role : existing, action: "updated" }; +} + +/** + * A fork copies its parent's objects with their owners. Hand + * every object owned by another role-API role to the developer, by deleting that + * role's copy on this branch with reassign_owned_to. Idempotent: nothing owned + * by others means no API call. + */ +async function handOffOwnership( + ctx: DbContext, + developerRole: LakebaseRole, + roles: readonly LakebaseRole[], +): Promise { + const pool = await openBranchPool(ctx); + if (!pool) + throw new Error("The branch connection settings were not written to .env."); + let owners: Set; + try { + const { rows } = await pool.query<{ owner: string }>(FOREIGN_OWNERS_SQL); + owners = new Set(rows.map((row) => row.owner)); + } finally { + await pool.end().catch(() => undefined); + } + + const handedOff: string[] = []; + for (const role of roles) { + const postgresRole = role.status?.postgres_role; + if ( + !postgresRole || + role.name === developerRole.name || + !owners.has(postgresRole) + ) { + continue; + } + await ctx + .api() + .deleteRole(role.name, { reassignOwnedTo: developerRole.name }); + handedOff.push(postgresRole); + } + return handedOff; +} + +async function resolveDatabase( + ctx: DbContext, + branch: string, + local: LocalTarget, + deploy: DeployTarget | null, +): Promise { + // A real .env endpoint came with its database; forks inherit the parent's databases. + if (local.database) return local.database; + if (deploy?.database) { + const database = await ctx + .api() + .getDatabase(`${branch}/databases/${resourceId(deploy.database)}`); + if (database?.status?.postgres_database) + return database.status.postgres_database; + } + return DEFAULT_DATABASE; +} + +function readEnvValues(file: string): Record { + return fs.existsSync(file) + ? dotenv.parse(fs.readFileSync(file, "utf-8")) + : {}; +} + +/** + * `appkit db init`: find or create the developer's dev branch or this + * checkout's session branch, ensure a read-write endpoint and the developer's + * DATABRICKS_SUPERUSER role, hand copied objects to the developer, point `.env` + * at the branch, then apply the schema start. It refuses a branch that is the + * project's default, protected or one databricks.yml deploys to before + * changing anything on it, and never touches the deploy bundle. + */ +export async function runInit( + ctx: DbContext, + choices: InitChoices, + options: InitOptions = { interactive: false }, +): Promise { + // Before any change: migrate and clean cannot run without schema.ts. + if (choices.schemaStart === "migrate" || choices.schemaStart === "clean") { + requireSchemaFile(ctx.root, choices.schemaStart); + } + const api = ctx.api(); + const configured = { + local: readLocalTarget(ctx.env), + deploy: readDeployTarget(ctx.root), + }; + const project = resolveProject(configured.local, configured.deploy); + const { local, deploy } = scopeToProject( + project, + configured.local, + configured.deploy, + ); + const identity = await currentIdentity(ctx); + + const deployBranches = readDeployBranches(ctx.root); + + // 1. Branch; init never changes a branch the app deploys to. + const found = await findOrCreateBranch( + ctx, + project, + choices.mode, + identity, + local.session, + ); + const { branch, created, sourceBranch } = found; + const session = found.session ?? local.session; + const verdict = classifyBranch({ + branch, + userName: identity.userName, + sessionId: session, + deployBranches, + }); + if (!verdict.appkitOwned) { + throw new Error( + `Refusing to set up ${resourceId(branch.name)}: ${verdict.reason ?? "it is not an AppKit branch"}. \`appkit db init\` only sets up your dev branch or this checkout's session branch, never a branch the app deploys to.`, + ); + } + + // 2. Endpoint and role. + const { + endpoint, + created: endpointCreated, + host, + } = await ensureReadWriteEndpoint(ctx, branch.name); + const roles = await api.listRoles(branch.name); + const { role, action } = await ensureDeveloperRole( + ctx, + branch.name, + identity, + roles, + ); + const database = await resolveDatabase(ctx, branch.name, local, deploy); + + // 3. .env, replaced in place; the running context sees the new branch. + const envFile = path.join(ctx.root, ENV_FILE); + const current = readEnvValues( + fs.existsSync(envFile) ? envFile : path.join(ctx.root, ENV_EXAMPLE), + ); + const values: Record = { + LAKEBASE_PROJECT: project, + PGHOST: host, + LAKEBASE_ENDPOINT: endpoint.name, + PGDATABASE: database, + PGUSER: identity.userName, + ...(choices.mode === "session" && session + ? { APPKIT_DB_SESSION: session } + : {}), + ...(current.PGPORT && /^\d+$/.test(current.PGPORT) + ? {} + : { PGPORT: "5432" }), + ...(current.PGSSLMODE ? {} : { PGSSLMODE: "require" }), + }; + updateEnvFile(envFile, values, path.join(ctx.root, ENV_EXAMPLE)); + Object.assign(ctx.env, values); + + // 4. Ownership hand-off: the fork's copies become the developer's. + const handedOff = await handOffOwnership(ctx, role, roles); + + // 5. Schema start. + let schema: MigrateReport | null = null; + let introspect: IntrospectReport | null = null; + let cleaned = false; + if (choices.schemaStart === "introspect") { + introspect = await runIntrospect(ctx, { interactive: options.interactive }); + } else if (choices.schemaStart === "migrate") { + schema = await runMigrate(ctx, { interactive: options.interactive }); + } else if (choices.schemaStart === "clean") { + await runClean(ctx); + cleaned = true; + // Recreate from the committed migrations, so the startup check passes. + schema = await runMigrate(ctx, { + interactive: options.interactive, + generate: false, + }); + } + + return { + mode: choices.mode, + branch: branch.name, + created, + ...(sourceBranch ? { sourceBranch } : {}), + endpoint: endpoint.name, + endpointCreated, + host, + database, + user: identity.userName, + role: { name: role.name, action }, + handedOff, + envFile, + cleaned, + schema, + introspect, + }; +} diff --git a/packages/appkit/src/database/tooling/introspect/catalog.ts b/packages/appkit/src/database/tooling/introspect/catalog.ts new file mode 100644 index 000000000..49429401b --- /dev/null +++ b/packages/appkit/src/database/tooling/introspect/catalog.ts @@ -0,0 +1,252 @@ +import { MIGRATIONS_TABLE } from "../../migrations/paths"; +import type { ReferentialAction } from "../../schema-builder/types"; + +/** Run one parameterized query and return its rows (a pg Pool fits: `(t, v) => pool.query(t, v).then(r => r.rows)`). */ +export type CatalogQuery = ( + text: string, + values: readonly unknown[], +) => Promise; + +export interface IntrospectedEnum { + readonly name: string; + readonly schema: string; + readonly values: readonly string[]; +} + +export interface IntrospectedColumn { + readonly name: string; + /** format_type(), e.g. "character varying(120)". */ + readonly type: string; + readonly notNull: boolean; + /** attidentity: "" none, "a" always, "d" by default. */ + readonly identity: "" | "a" | "d"; + /** Stored generated column (attgenerated = 's'). */ + readonly generated: boolean; + /** pg_get_expr() of the default (or generation expression). */ + readonly defaultExpr: string | null; + readonly enum?: IntrospectedEnum; +} + +export interface IntrospectedForeignKey { + readonly name: string; + readonly columns: readonly string[]; + readonly schema: string; + readonly table: string; + readonly refColumns: readonly string[]; + readonly onDelete: ReferentialAction; + readonly onUpdate: ReferentialAction; + /** DEFERRABLE or MATCH FULL, neither of which the builder declares. */ + readonly deferrable: boolean; + readonly matchFull: boolean; +} + +export interface IntrospectedUnique { + readonly name: string; + readonly columns: readonly string[]; +} + +export interface IntrospectedTable { + readonly name: string; + /** relkind 'p': partitioned in the database. */ + readonly partitioned: boolean; + readonly columns: readonly IntrospectedColumn[]; + readonly primaryKey: readonly string[]; + readonly unique: readonly IntrospectedUnique[]; + readonly foreignKeys: readonly IntrospectedForeignKey[]; + /** CHECK and EXCLUDE constraint names. */ + readonly checks: readonly string[]; + /** Index names, except the indexes behind primary-key, unique and exclusion constraints. */ + readonly indexes: readonly string[]; +} + +/** pg_constraint.confdeltype / confupdtype. */ +const ACTIONS: Record = { + a: "no action", + r: "restrict", + c: "cascade", + n: "set null", + d: "set default", +}; + +export interface ColumnRow { + table_name: string; + name: string; + type: string; + not_null: boolean; + identity: string; + generated: string; + default_expr: string | null; + type_kind: string; + type_schema: string; + type_name: string; + enum_values: string[] | null; +} + +export interface TableRow { + name: string; + partitioned: boolean; +} + +export interface ConstraintRow { + table_name: string; + name: string; + kind: "p" | "u" | "f" | "c" | "x"; + columns: string[]; + ref_schema: string | null; + ref_table: string | null; + ref_columns: string[] | null; + on_delete: string; + on_update: string; + deferrable?: boolean; + /** confmatchtype: "f" full, "s" simple. */ + match_type?: string; +} + +export interface IndexRow { + table_name: string; + name: string; +} + +// pg_catalog, never information_schema: it hides objects the role cannot access. +// Ordinary and partitioned tables only; partitions belong to their parent. +const TABLES_SQL = ` +select c.relname::text as name, c.relkind = 'p' as partitioned + from pg_catalog.pg_class c + join pg_catalog.pg_namespace n on n.oid = c.relnamespace + where n.nspname = $1 and c.relkind in ('r', 'p') and not c.relispartition + and c.relname <> $2 + order by c.relname`; + +const COLUMNS_SQL = ` +select c.relname::text as table_name, a.attname::text as name, + pg_catalog.format_type(a.atttypid, a.atttypmod) as type, + a.attnotnull as not_null, a.attidentity::text as identity, + a.attgenerated::text as generated, + pg_catalog.pg_get_expr(d.adbin, d.adrelid) as default_expr, + t.typtype::text as type_kind, tn.nspname::text as type_schema, + t.typname::text as type_name, + case when t.typtype = 'e' then array( + select e.enumlabel::text from pg_catalog.pg_enum e + where e.enumtypid = t.oid order by e.enumsortorder) + end as enum_values + from pg_catalog.pg_attribute a + join pg_catalog.pg_class c on c.oid = a.attrelid + join pg_catalog.pg_namespace n on n.oid = c.relnamespace + join pg_catalog.pg_type t on t.oid = a.atttypid + join pg_catalog.pg_namespace tn on tn.oid = t.typnamespace + left join pg_catalog.pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum + where n.nspname = $1 and c.relkind in ('r', 'p') and not c.relispartition + and c.relname <> $2 and a.attnum > 0 and not a.attisdropped + order by c.relname, a.attnum`; + +const CONSTRAINTS_SQL = ` +select c.relname::text as table_name, k.conname::text as name, k.contype::text as kind, + array(select a.attname::text + from unnest(k.conkey) with ordinality as u(attnum, ord) + join pg_catalog.pg_attribute a on a.attrelid = k.conrelid and a.attnum = u.attnum + order by u.ord) as columns, + rn.nspname::text as ref_schema, rc.relname::text as ref_table, + array(select a.attname::text + from unnest(k.confkey) with ordinality as u(attnum, ord) + join pg_catalog.pg_attribute a on a.attrelid = k.confrelid and a.attnum = u.attnum + order by u.ord) as ref_columns, + k.confdeltype::text as on_delete, k.confupdtype::text as on_update, + k.condeferrable as deferrable, k.confmatchtype::text as match_type + from pg_catalog.pg_constraint k + join pg_catalog.pg_class c on c.oid = k.conrelid + join pg_catalog.pg_namespace n on n.oid = c.relnamespace + left join pg_catalog.pg_class rc on rc.oid = k.confrelid + left join pg_catalog.pg_namespace rn on rn.oid = rc.relnamespace + where n.nspname = $1 and k.contype in ('p', 'u', 'f', 'c', 'x') and c.relname <> $2 + -- A foreign key to a partitioned table has one copy per partition; keep the original. + and k.conparentid = 0 + order by c.relname, k.conname`; + +// Indexes no constraint stands for: schema.ts cannot declare them. +const INDEXES_SQL = ` +select c.relname::text as table_name, i.relname::text as name + from pg_catalog.pg_index x + join pg_catalog.pg_class c on c.oid = x.indrelid + join pg_catalog.pg_class i on i.oid = x.indexrelid + join pg_catalog.pg_namespace n on n.oid = c.relnamespace + where n.nspname = $1 and c.relname <> $2 + and not exists ( + select 1 from pg_catalog.pg_constraint k + where k.conrelid = x.indrelid and k.conindid = x.indexrelid + and k.contype in ('p', 'u', 'x')) + order by c.relname, i.relname`; + +function toColumn(row: ColumnRow): IntrospectedColumn { + const identity = + row.identity === "a" || row.identity === "d" ? row.identity : ""; + return { + name: row.name, + type: row.type, + notNull: row.not_null, + identity, + generated: row.generated === "s", + defaultExpr: row.default_expr, + ...(row.type_kind === "e" + ? { + enum: { + name: row.type_name, + schema: row.type_schema, + values: row.enum_values ?? [], + }, + } + : {}), + }; +} + +/** Assemble the model from the catalog result sets (exported for fixture tests). */ +export function buildTables( + tables: readonly TableRow[], + columns: readonly ColumnRow[], + constraints: readonly ConstraintRow[], + indexes: readonly IndexRow[] = [], +): IntrospectedTable[] { + return tables.map(({ name, partitioned }) => { + const own = constraints.filter((row) => row.table_name === name); + return { + name, + partitioned, + columns: columns.filter((row) => row.table_name === name).map(toColumn), + primaryKey: own.find((row) => row.kind === "p")?.columns ?? [], + unique: own + .filter((row) => row.kind === "u") + .map((row) => ({ name: row.name, columns: row.columns })), + foreignKeys: own + .filter((row) => row.kind === "f" && row.ref_table) + .map((row) => ({ + name: row.name, + columns: row.columns, + schema: row.ref_schema ?? "", + table: row.ref_table ?? "", + refColumns: row.ref_columns ?? [], + onDelete: ACTIONS[row.on_delete] ?? "no action", + onUpdate: ACTIONS[row.on_update] ?? "no action", + deferrable: row.deferrable === true, + matchFull: row.match_type === "f", + })), + checks: own + .filter((row) => row.kind === "c" || row.kind === "x") + .map((row) => row.name), + indexes: indexes + .filter((row) => row.table_name === name) + .map((row) => row.name), + }; + }); +} + +/** Tables of one schema from pg_catalog, excluding the migration journal. */ +export async function readCatalog( + query: CatalogQuery, + schemaName: string, +): Promise { + const params = [schemaName, MIGRATIONS_TABLE]; + const tables = await query(TABLES_SQL, params); + const columns = await query(COLUMNS_SQL, params); + const constraints = await query(CONSTRAINTS_SQL, params); + const indexes = await query(INDEXES_SQL, params); + return buildTables(tables, columns, constraints, indexes); +} diff --git a/packages/appkit/src/database/tooling/introspect/index.ts b/packages/appkit/src/database/tooling/introspect/index.ts new file mode 100644 index 000000000..5f315f77c --- /dev/null +++ b/packages/appkit/src/database/tooling/introspect/index.ts @@ -0,0 +1,260 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { loadDefaultDatabaseSchema } from "../../../plugins/database/load-schema"; +import { generateDatabaseTypes } from "../../../type-generator/database"; +import { + markMigrationsApplied, + pendingMigrationTags, +} from "../../migrations/apply"; +import { + journalFile, + migrationsFolder, + readJournal, +} from "../../migrations/paths"; +import { DEFAULT_SCHEMA_NAME } from "../../schema-builder/define-schema"; +import { type DbContext, openBranchPool } from "../context"; +import { checkAppkitBranch } from "../guard"; +import { DATABASE_TYPES_PATH, SCHEMA_FILE } from "../migrate"; +import { + discardMigrationFiles, + type GeneratedMigration, + generateMigrationFiles, +} from "../migrations"; +import { resourceId } from "../targets"; +import { readCatalog } from "./catalog"; +import { renderSchemaSource, type Skip } from "./render"; + +export interface IntrospectOptions { + /** false: an ambiguous rename against existing history throws instead of prompting. */ + readonly interactive: boolean; + /** Schema to read; defaults to DEFAULT_SCHEMA_NAME. The current schema.ts is never read. */ + readonly schemaName?: string; + /** Module the written schema.ts imports the builder from. Default "@databricks/appkit/beta". */ + readonly importSource?: string; +} + +export interface IntrospectReport { + readonly branch: string; + readonly schemaName: string; + /** Tables written to schema.ts. */ + readonly tables: readonly string[]; + readonly schemaFile: string; + /** Where the previous schema.ts went, when this run replaced a different one. */ + readonly backupFile: string | null; + readonly skipped: readonly Skip[]; + readonly warnings: readonly string[]; + /** What schema.ts declares differently from the database, which keeps it as it is. */ + readonly approximated: readonly string[]; + /** 0000_baseline without history, NNNN_introspect with it; null when nothing changed. */ + readonly migration: GeneratedMigration | null; + /** The migration was recorded as applied on the branch (AppKit branches only). */ + readonly recordedApplied: boolean; + /** Why the migration was not recorded. */ + readonly notRecorded?: string; + readonly typesFile: string; +} + +/** Introspect would leave history the deployed migrator cannot adopt. */ +export class IntrospectRefusedError extends Error { + constructor(message: string) { + super(message); + this.name = "IntrospectRefusedError"; + } +} + +/** File contents, or null when it does not exist. */ +function readIfExists(file: string): string | null { + return fs.existsSync(file) ? fs.readFileSync(file, "utf-8") : null; +} + +function writeOrRemove(file: string, contents: string | null): void { + if (contents === null) fs.rmSync(file, { force: true }); + else fs.writeFileSync(file, contents); +} + +/** + * With history, a DROP in the diff runs on deploy. Without skips it is something + * the branch no longer has; with skips it may be something the branch still has + * but schema.ts cannot express, so the migration is refused. + */ +function refuseDropsOfSkipped( + statements: readonly string[], + skipped: readonly Skip[], +): void { + const drops = statements.filter((statement) => /\bDROP\b/i.test(statement)); + if (drops.length === 0 || skipped.length === 0) return; + const what = skipped.map((skip) => + skip.column ? `${skip.table}.${skip.column}` : skip.table, + ); + throw new IntrospectRefusedError( + `Refusing to write a migration that drops what schema.ts cannot express (${[...new Set(what)].join(", ")}): the deployed app would run ${drops.join(" ")}`, + ); +} + +/** + * `appkit db introspect`: read the branch's app schema from pg_catalog, write + * schema.ts (the previous file goes to schema.ts.bak), write the migration that + * brings history up to the database, and record it as applied on AppKit + * branches. On any other branch only a first `0000_baseline` is allowed, which + * the deployed migrator adopts; with existing history the command refuses + * before writing anything. A failure after schema.ts is written puts back + * schema.ts, the backup and the migrations folder. + */ +export async function runIntrospect( + ctx: DbContext, + options: IntrospectOptions, +): Promise { + const schemaFile = path.join(ctx.root, SCHEMA_FILE); + // Introspect replaces schema.ts, so the current file (missing, empty or broken) is never read. + const schemaName = options.schemaName || DEFAULT_SCHEMA_NAME; + const folder = migrationsFolder(ctx.root); + const hasHistory = (readJournal(folder)?.entries.length ?? 0) > 0; + + const check = await checkAppkitBranch(ctx); + if (!check) { + throw new Error( + "No branch configured in .env (LAKEBASE_ENDPOINT) — run `appkit db init`.", + ); + } + const branchId = resourceId(check.branch.name); + const owned = check.verdict.appkitOwned; + if (hasHistory && !owned) { + throw new IntrospectRefusedError( + `Refusing to introspect ${branchId}: config/database/migrations already has history, and ${branchId} is not your dev or session branch (${check.verdict.reason}). ` + + "Only a first 0000_baseline can be adopted by the deployed app. Point .env at your dev or session branch with `appkit db init` and introspect there.", + ); + } + + const pool = await openBranchPool(ctx); + if (!pool) { + throw new Error( + "No branch configured (PGHOST, PGDATABASE, LAKEBASE_ENDPOINT) — run `appkit db init`.", + ); + } + try { + if (hasHistory) { + // The diff starts from the latest committed snapshot: a migration the + // branch lacks would come out reversed, and run reversed on deploy. + const pending = await pendingMigrationTags(pool, { + migrationsFolder: folder, + schemaName, + }); + if (pending.length > 0) { + throw new IntrospectRefusedError( + `Refusing to introspect ${branchId}: it has not applied ${pending.join(", ")} in schema "${schemaName}", so the migration would undo ${pending.length === 1 ? "it" : "them"}. Run \`appkit db migrate\` first.`, + ); + } + } + + const tables = await readCatalog( + async (text, values) => (await pool.query(text, [...values])).rows, + schemaName, + ); + const rendered = renderSchemaSource(tables, schemaName, { + importSource: options.importSource, + }); + // An app on another schema that forgot --schema-name must not lose its + // schema.ts to an empty one; with history, an empty schema drops every table. + if ( + rendered.tables.length === 0 && + (hasHistory || fs.existsSync(schemaFile)) + ) { + const reasons = rendered.skipped + .filter((skip) => !skip.column) + .map((skip) => `${skip.table}: ${skip.reason}`); + throw new IntrospectRefusedError( + `Schema "${schemaName}" on ${branchId} has no tables${tables.length > 0 ? ` introspect can write (${reasons.join("; ")})` : ""}, so introspect would ${hasHistory ? "write a migration that drops every table" : `replace ${SCHEMA_FILE} with an empty one`}. Run \`appkit db introspect --schema-name \` to read another schema.`, + ); + } + + // Write schema.ts, keeping the previous file when it differs. + const backupFile = `${schemaFile}.bak`; + const previous = readIfExists(schemaFile); + const previousBackup = readIfExists(backupFile); + const backsUp = previous !== null && previous !== rendered.source; + const journalBefore = readIfExists(journalFile(folder)); + let migration: GeneratedMigration | null = null; + let recordedApplied = false; + if (backsUp) fs.writeFileSync(backupFile, previous); + fs.mkdirSync(path.dirname(schemaFile), { recursive: true }); + fs.writeFileSync(schemaFile, rendered.source); + try { + let schema: Awaited>; + try { + schema = await loadDefaultDatabaseSchema(ctx.root, { + showImportError: true, + }); + } catch (error) { + throw new Error( + `The schema.ts introspect generated does not load. This is a bug in \`appkit db introspect\`; please report it with the catalog of schema "${schemaName}".`, + { cause: error }, + ); + } + + migration = await generateMigrationFiles({ + folder, + schema, + tag: hasHistory ? "introspect" : "baseline", + interactive: options.interactive, + ...(hasHistory + ? { + beforeWrite: (statements: readonly string[]) => + refuseDropsOfSkipped(statements, rendered.skipped), + } + : {}), + }); + if (migration && owned) { + // The database already holds what the migration describes. + await markMigrationsApplied( + pool, + { migrationsFolder: folder, schemaName }, + [migration.tag], + ); + recordedApplied = true; + } + } catch (error) { + writeOrRemove(schemaFile, previous); + if (backsUp) writeOrRemove(backupFile, previousBackup); + if (migration) discardMigrationFiles(folder, migration, journalBefore); + if (error instanceof IntrospectRefusedError) throw error; + throw new Error( + `Introspect changed nothing: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + + const warnings = [...rendered.warnings]; + const drops = (migration?.statements ?? []).filter((statement) => + /\bDROP\b/i.test(statement), + ); + if (migration && drops.length > 0) { + warnings.push( + `${migration.tag} drops what the branch no longer has, and the deployed app runs it on start: ${drops.join(" ")}`, + ); + } + const notRecorded = + migration && !owned + ? `${branchId} is not your dev or session branch (${check.verdict.reason}); the deployed app adopts ${migration.tag} on start when its tables already exist` + : undefined; + + const typesFile = path.join(ctx.root, DATABASE_TYPES_PATH); + await generateDatabaseTypes({ schemaFile, outFile: typesFile }); + return { + branch: check.branch.name, + schemaName, + tables: rendered.tables, + schemaFile, + backupFile: backsUp ? backupFile : null, + skipped: rendered.skipped, + warnings, + approximated: rendered.approximated, + migration, + recordedApplied, + ...(notRecorded ? { notRecorded } : {}), + typesFile, + }; + } finally { + await pool.end().catch(() => undefined); + } +} diff --git a/packages/appkit/src/database/tooling/introspect/render.ts b/packages/appkit/src/database/tooling/introspect/render.ts new file mode 100644 index 000000000..1b5ef0fdc --- /dev/null +++ b/packages/appkit/src/database/tooling/introspect/render.ts @@ -0,0 +1,775 @@ +import { DEFAULT_SCHEMA_NAME } from "../../schema-builder/define-schema"; +import type { + IntrospectedColumn, + IntrospectedForeignKey, + IntrospectedTable, +} from "./catalog"; + +/** + * Something introspect left out of schema.ts, with an `// appkit: skipped …` comment. + * Display text: catalog names come with control characters escaped (see shown()). + */ +export interface Skip { + readonly table: string; + readonly column?: string; + readonly reason: string; +} + +interface SchemaSource { + readonly source: string; + /** Tables written to schema.ts. */ + readonly tables: readonly string[]; + readonly skipped: readonly Skip[]; + /** Omissions that change behavior, e.g. a NOT NULL column the API can no longer fill. */ + readonly warnings: readonly string[]; + /** What schema.ts declares differently from the database, which keeps it as it is. */ + readonly approximated: readonly string[]; +} + +interface RenderOptions { + /** Module the builder is imported from. Default "@databricks/appkit/beta". */ + readonly importSource?: string; +} + +/** Storage as the builder sees it; id/bigid are server-generated integer/bigint keys. */ +type Storage = + | "id" + | "bigid" + | "integer" + | "bigint" + | "text" + | "varchar" + | "boolean" + | "uuid" + | "timestamp" + | "jsonb" + | "enum"; + +interface BaseType { + /** Builder call, e.g. `varchar(120)`. */ + readonly call: string; + /** Import it needs ("" for the context's enum). */ + readonly importName: string; + readonly storage: Storage; + readonly varcharLength?: number; + /** How the call differs from the database type. */ + readonly approximation?: string; +} + +interface ColumnPlan { + readonly column: IntrospectedColumn; + base: BaseType; + /** Set when `fk(() => .)` replaces the base call. */ + fk?: { + readonly table: string; + readonly column: string; + readonly actions: string[]; + }; + readonly isPrimaryKey: boolean; + readonly isUnique: boolean; + defaultCall?: string; + readonly comments: string[]; +} + +interface TablePlan { + readonly table: IntrospectedTable; + readonly columns: ColumnPlan[]; + /** Table-level comments, rendered first. */ + readonly comments: string[]; + /** Columns and skipped-column comments, in catalog order. */ + readonly body: Array; + variable: string; +} + +const RESERVED_TABLE_NAMES = new Set([ + "sql", + "transaction", + "__proto__", + "prototype", + "constructor", +]); +const RESERVED_COLUMN_NAMES = new Set([ + "and", + "or", + "__proto__", + "prototype", + "constructor", +]); +const JS_RESERVED = new Set( + ( + "break case catch class const continue debugger default delete do else enum export extends false finally for " + + "function if import in instanceof new null return super switch this throw true try typeof var void while with " + + "yield let static implements interface package private protected public await arguments eval undefined" + ).split(" "), +); +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const INT32 = { min: -2147483648, max: 2147483647 }; + +const q = (value: string) => JSON.stringify(value); +/** + * Catalog text for a `//` comment or a terminal line. A quoted Postgres name can + * hold a newline (or U+2028/U+2029, which JSON.stringify keeps), which would end + * the comment and turn the rest of the name into code. + */ +const shown = (text: string) => + Array.from(text, (char) => { + const code = char.charCodeAt(0); + const breaks = + code < 0x20 || code === 0x7f || code === 0x2028 || code === 0x2029; + return breaks ? `\\u${code.toString(16).padStart(4, "0")}` : char; + }).join(""); +/** Postgres keeps the first 63 bytes of a name. */ +const pgName = (name: string) => name.slice(0, 63); +const key = (name: string) => + IDENTIFIER.test(name) && name !== "__proto__" ? name : q(name); +const access = (variable: string, name: string) => + IDENTIFIER.test(name) ? `${variable}.${name}` : `${variable}[${q(name)}]`; +const list = (names: readonly string[]) => names.join(", "); + +function baseType( + column: IntrospectedColumn, + schemaName: string, +): BaseType | string { + const type = column.type; + if (column.enum) { + if (column.enum.schema !== schemaName) { + return `enum type "${column.enum.schema}.${column.enum.name}" lives outside schema "${schemaName}"`; + } + if (column.enum.values.length === 0) { + return `enum type "${column.enum.name}" has no values`; + } + return { + call: `enumType(${q(column.enum.name)}, [${column.enum.values.map(q).join(", ")}])`, + importName: "", + storage: "enum", + }; + } + switch (type) { + case "integer": + return { call: "integer()", importName: "integer", storage: "integer" }; + case "bigint": + return { call: "bigint()", importName: "bigint", storage: "bigint" }; + case "text": + return { call: "text()", importName: "text", storage: "text" }; + case "character varying": + return { + call: "text()", + importName: "text", + storage: "text", + approximation: + "character varying without a length is declared as text()", + }; + case "boolean": + return { call: "boolean()", importName: "boolean", storage: "boolean" }; + case "uuid": + return { call: "uuid()", importName: "uuid", storage: "uuid" }; + case "jsonb": + return { call: "jsonb()", importName: "jsonb", storage: "jsonb" }; + } + const varchar = /^character varying\((\d+)\)$/.exec(type); + if (varchar) { + const length = Number(varchar[1]); + return { + call: `varchar(${length})`, + importName: "varchar", + storage: "varchar", + varcharLength: length, + }; + } + const timestamp = /^timestamp(\(\d\))? (with|without) time zone$/.exec(type); + if (timestamp) { + return { + call: + timestamp[2] === "with" + ? "timestamp({ withTimezone: true })" + : "timestamp()", + importName: "timestamp", + storage: "timestamp", + ...(timestamp[1] + ? { approximation: `${type} is declared without its precision` } + : {}), + }; + } + return `type "${type}" is not supported`; +} + +const isSequenceDefault = (expr: string | null) => + !!expr && /^nextval\(/.test(expr); + +/** A builder default call, or why the database default cannot be expressed. */ +function defaultCall( + expr: string, + base: BaseType, + enumValues: readonly string[] | undefined, +): { call: string } | { skip: string } { + const unsupported = { skip: `default ${expr} is not supported` }; + switch (base.storage) { + case "timestamp": + return /^(now\(\)|CURRENT_TIMESTAMP)$/i.test(expr) + ? { call: ".defaultNow()" } + : unsupported; + case "uuid": + return expr === "gen_random_uuid()" + ? { call: ".defaultRandom()" } + : unsupported; + case "boolean": + return expr === "true" || expr === "false" + ? { call: `.default(${expr})` } + : unsupported; + case "integer": { + const match = /^\(?'?(-?\d+)'?(?:::integer)?\)?$/.exec(expr); + const value = match ? Number(match[1]) : Number.NaN; + return Number.isInteger(value) && value >= INT32.min && value <= INT32.max + ? { call: `.default(${value})` } + : unsupported; + } + case "text": + case "varchar": + case "enum": { + const match = /^'((?:[^']|'')*)'(?:::[\w." ]+)?$/.exec(expr); + if (!match) return unsupported; + const value = (match[1] ?? "").replace(/''/g, "'"); + if ( + base.storage === "varchar" && + value.length > (base.varcharLength ?? 0) + ) { + return unsupported; + } + if (base.storage === "enum" && !enumValues?.includes(value)) { + return unsupported; + } + return { call: `.default(${q(value)})` }; + } + default: + // The builder accepts no literal defaults for bigint and jsonb. + return unsupported; + } +} + +function importLine(names: readonly string[], source: string): string { + const line = `import { ${names.join(", ")} } from ${q(source)};`; + if (line.length <= 80) return line; + return `import {\n${names.map((name) => ` ${name},`).join("\n")}\n} from ${q(source)};`; +} + +function makeSkip( + table: string, + column: string | undefined, + reason: string, +): Skip { + return { + table: shown(table), + ...(column === undefined ? {} : { column: shown(column) }), + reason: shown(reason), + }; +} + +function skipComment(skip: Skip): string { + return `// appkit: skipped ${skip.column ? `column "${skip.column}"` : `table "${skip.table}"`}: ${skip.reason}`; +} + +/** Defaults the builder allows only on uuid / timestamp columns, never on fk(). */ +const TYPED_DEFAULTS = new Set([".defaultRandom()", ".defaultNow()"]); + +/** The storage an fk() column inherits from its target. */ +function mirrored(target: BaseType): string { + if (target.storage === "id") return "integer()"; + if (target.storage === "bigid") return "bigint()"; + return target.call; +} + +/** + * Render pg_catalog tables as a `defineSchema` source the builder accepts. + * What the builder cannot express is left out with an `// appkit: skipped …` + * comment; what it declares differently (a timestamp without its precision, + * say) is listed in `approximated`. + */ +export function renderSchemaSource( + tables: readonly IntrospectedTable[], + schemaName: string, + options: RenderOptions = {}, +): SchemaSource { + const skipped: Skip[] = []; + const warnings: string[] = []; + const approximated: string[] = []; + /** Constraints whose database name is not the one migrations use: "db (migrations)". */ + const renamed: string[] = []; + const tableComments: string[] = []; + const qualified = (table: string, column?: string) => + `${schemaName}.${table}${column ? `.${column}` : ""}`; + const skipTable = (table: string, reason: string) => { + const skip = makeSkip(table, undefined, reason); + skipped.push(skip); + tableComments.push(skipComment(skip)); + }; + + // 1. Tables the builder can key: one primary-key column, a usable name. + const names = new Set(tables.map((table) => table.name)); + const plans = new Map(); + for (const table of tables) { + if (RESERVED_TABLE_NAMES.has(table.name)) { + skipTable(table.name, `the name "${table.name}" is reserved by AppKit`); + } else if ( + table.name.endsWith("Relations") && + names.has(table.name.slice(0, -"Relations".length)) + ) { + skipTable( + table.name, + `the name collides with the relation metadata AppKit generates for "${table.name.slice(0, -"Relations".length)}"`, + ); + } else if (table.primaryKey.length === 0) { + skipTable(table.name, "it has no primary key"); + } else if (table.primaryKey.length > 1) { + skipTable( + table.name, + `composite primary key (${list(table.primaryKey)}) is not supported`, + ); + } else { + plans.set(table.name, { + table, + columns: [], + comments: [], + body: [], + variable: "", + }); + } + } + + // 2. Columns. A table whose primary-key column cannot be expressed is skipped whole. + for (const plan of [...plans.values()]) { + const { table } = plan; + const pk = table.primaryKey[0]; + const tableSkips: Skip[] = []; + const tableWarnings: string[] = []; + const tableApproximated: string[] = []; + const tableRenamed: string[] = []; + const columnReasons = new Map(); + /** Single-column unique constraints: column → constraint name. */ + const singleUnique = new Map( + table.unique + .filter((unique) => unique.columns.length === 1) + .map((unique) => [unique.columns[0] as string, unique.name]), + ); + for (const unique of table.unique.filter((u) => u.columns.length > 1)) { + const skip = makeSkip( + table.name, + undefined, + `unique constraint (${list(unique.columns)}) spans several columns, which is not supported`, + ); + tableSkips.push(skip); + plan.comments.push(`// appkit: skipped ${skip.reason}`); + } + + for (const column of table.columns) { + const skipColumn = (reason: string) => { + const skip = makeSkip(table.name, column.name, reason); + tableSkips.push(skip); + columnReasons.set(column.name, reason); + plan.body.push(skipComment(skip)); + const generatesValue = + column.identity !== "" || column.generated || !!column.defaultExpr; + if (column.notNull && !generatesValue) { + tableWarnings.push( + `${qualified(table.name, column.name)} is NOT NULL with no default but is not in schema.ts (${reason}). Inserts through the API will fail until it gets a default, becomes nullable, or is dropped.`, + ); + } + }; + + if ( + column.name.startsWith("$") || + RESERVED_COLUMN_NAMES.has(column.name) + ) { + skipColumn(`the name "${column.name}" is reserved by AppKit`); + continue; + } + if (column.generated) { + skipColumn("generated columns are not supported"); + continue; + } + const base = baseType(column, schemaName); + if (typeof base === "string") { + skipColumn(base); + continue; + } + const isPrimaryKey = column.name === pk; + if (column.identity !== "" && !isPrimaryKey) { + skipColumn( + "identity columns other than the primary key are not supported", + ); + continue; + } + + const columnPlan: ColumnPlan = { + column, + base, + isPrimaryKey, + isUnique: singleUnique.has(column.name) && !isPrimaryKey, + comments: [], + }; + if (base.approximation) { + tableApproximated.push( + `${qualified(table.name, column.name)}: ${base.approximation}`, + ); + } + const uniqueName = singleUnique.get(column.name); + const migrationsUnique = pgName(`${table.name}_${column.name}_unique`); + if (columnPlan.isUnique && uniqueName !== migrationsUnique) { + tableRenamed.push(`${uniqueName} (${migrationsUnique})`); + } + const generatesKey = + column.identity !== "" || isSequenceDefault(column.defaultExpr); + if ( + isPrimaryKey && + generatesKey && + (base.storage === "integer" || base.storage === "bigint") + ) { + // A serial or identity key is the builder's server-generated id()/bigid(). + columnPlan.base = + base.storage === "integer" + ? { call: "id()", importName: "id", storage: "id" } + : { call: "bigid()", importName: "bigid", storage: "bigid" }; + if (column.identity !== "d") { + tableApproximated.push( + `${qualified(table.name, column.name)}: ${column.identity === "a" ? "GENERATED ALWAYS AS IDENTITY" : "a serial key"} is declared as ${columnPlan.base.call}, an identity generated BY DEFAULT`, + ); + } + } else if (column.defaultExpr) { + const parsed = defaultCall( + column.defaultExpr, + base, + column.enum?.values, + ); + if ("call" in parsed) { + columnPlan.defaultCall = parsed.call; + } else { + const skip = makeSkip(table.name, column.name, parsed.skip); + tableSkips.push(skip); + columnPlan.comments.push(`// appkit: skipped ${skip.reason}`); + if (column.notNull) { + tableWarnings.push( + `${qualified(table.name, column.name)}: its ${parsed.skip.replace(/ is not supported$/, "")} is not in schema.ts, so the API requires a value for it on insert.`, + ); + } + } + } + plan.columns.push(columnPlan); + plan.body.push(columnPlan); + } + + if (!plan.columns.some((c) => c.isPrimaryKey)) { + plans.delete(table.name); + skipTable( + table.name, + `its primary key column "${pk}" cannot be expressed (${columnReasons.get(pk as string) ?? "unsupported"})`, + ); + continue; + } + skipped.push(...tableSkips); + warnings.push(...tableWarnings); + if (table.partitioned) { + tableApproximated.push( + `${qualified(table.name)}: a partitioned table is declared as a plain table`, + ); + } + for (const [one, many, names] of [ + [ + "CHECK or EXCLUDE constraint", + "CHECK or EXCLUDE constraints", + table.checks, + ], + ["index", "indexes", table.indexes], + ] as const) { + if (names.length > 0) { + tableApproximated.push( + `${qualified(table.name)}: ${names.length === 1 ? `${one} ${names[0]} is` : `${many} ${list(names)} are`} not declared`, + ); + } + } + approximated.push(...tableApproximated); + renamed.push(...tableRenamed); + } + + // 3. Foreign keys the builder can express; the rest stay plain columns. + const edges = new Map>(); + const reaches = (from: string, to: string): boolean => { + const seen = new Set(); + const stack = [from]; + while (stack.length > 0) { + const node = stack.pop() as string; + if (node === to) return true; + if (seen.has(node)) continue; + seen.add(node); + stack.push(...(edges.get(node) ?? [])); + } + return false; + }; + /** Why the builder cannot express this FK; null when it can. */ + const fkProblem = ( + plan: TablePlan, + foreignKey: IntrospectedForeignKey, + source: ColumnPlan | undefined, + seenTargets: ReadonlySet, + ): string | null => { + if (foreignKey.columns.length !== 1) { + return "foreign keys over several columns are not supported"; + } + if (!source) return "its column is skipped"; + if (foreignKey.schema !== schemaName) { + return `it references schema "${foreignKey.schema}"`; + } + if (foreignKey.table === plan.table.name) { + // The builder takes one with a TableHandle annotation, which introspect does not write. + return "introspect does not write self-references"; + } + const target = plans.get(foreignKey.table); + if (!target) return `table "${foreignKey.table}" is skipped`; + const targetColumn = target.columns.find( + (c) => c.column.name === foreignKey.refColumns[0], + ); + if (!targetColumn) { + return `column ${foreignKey.table}.${foreignKey.refColumns[0]} is skipped`; + } + if (!targetColumn.isPrimaryKey && !targetColumn.isUnique) { + return `${foreignKey.table}.${foreignKey.refColumns[0]} is not a primary key or single-column unique key`; + } + if (seenTargets.has(foreignKey.table)) { + return `a second foreign key to "${foreignKey.table}" would make the relation ambiguous`; + } + if (plan.table.columns.some((c) => c.name === foreignKey.table)) { + return `the relation "${foreignKey.table}" would clash with the column of the same name`; + } + if (target.table.columns.some((c) => c.name === plan.table.name)) { + return `the reverse relation "${plan.table.name}" would clash with column ${foreignKey.table}.${plan.table.name}`; + } + if (reaches(foreignKey.table, plan.table.name)) { + return "it would close a cycle of foreign keys"; + } + if (mirrored(targetColumn.base) !== source.base.call) { + return `its type ${source.base.call} differs from ${foreignKey.table}.${foreignKey.refColumns[0]}`; + } + const actions = [foreignKey.onDelete, foreignKey.onUpdate]; + if (actions.includes("set null") && source.column.notNull) { + return "SET NULL on a NOT NULL column"; + } + const fkDefault = + source.defaultCall && !TYPED_DEFAULTS.has(source.defaultCall); + if (actions.includes("set default") && !fkDefault) { + return "SET DEFAULT without a default the builder can express"; + } + return null; + }; + + for (const plan of plans.values()) { + const targets = new Set(); + for (const foreignKey of plan.table.foreignKeys) { + const source = plan.columns.find( + (c) => c.column.name === foreignKey.columns[0], + ); + const reason = fkProblem(plan, foreignKey, source, targets); + if (reason) { + // A skipped column already carries its own comment. + if (source || foreignKey.columns.length > 1) { + const skip = makeSkip( + plan.table.name, + foreignKey.columns.join(", "), + `foreign key to ${foreignKey.table}(${list(foreignKey.refColumns)}) is not kept: ${reason}`, + ); + skipped.push(skip); + (source?.comments ?? plan.comments).push( + `// appkit: skipped ${skip.reason}`, + ); + } + continue; + } + const sourceColumn = source as ColumnPlan; + const target = plans.get(foreignKey.table) as TablePlan; + const targetColumn = target.columns.find( + (c) => c.column.name === foreignKey.refColumns[0], + ) as ColumnPlan; + sourceColumn.base = { ...targetColumn.base }; + if ( + sourceColumn.defaultCall && + TYPED_DEFAULTS.has(sourceColumn.defaultCall) + ) { + // fk() takes no defaultRandom()/defaultNow(); the key matters more than the default. + const skip = makeSkip( + plan.table.name, + sourceColumn.column.name, + `default ${sourceColumn.column.defaultExpr} is not supported on a foreign key column`, + ); + skipped.push(skip); + sourceColumn.comments.push(`// appkit: skipped ${skip.reason}`); + delete sourceColumn.defaultCall; + if (sourceColumn.column.notNull) { + warnings.push( + `${qualified(plan.table.name, sourceColumn.column.name)}: its default ${sourceColumn.column.defaultExpr} is not in schema.ts, so the API requires a value for it on insert.`, + ); + } + } + for (const [flag, what] of [ + [foreignKey.deferrable, "DEFERRABLE"], + [foreignKey.matchFull, "MATCH FULL"], + ] as const) { + if (flag) { + approximated.push( + `${qualified(plan.table.name, sourceColumn.column.name)}: the foreign key is declared without ${what}`, + ); + } + } + const migrationsFk = pgName( + `${plan.table.name}_${sourceColumn.column.name}_${foreignKey.table}_${foreignKey.refColumns[0]}_fk`, + ); + if (foreignKey.name !== migrationsFk) { + renamed.push(`${foreignKey.name} (${migrationsFk})`); + } + sourceColumn.fk = { + table: foreignKey.table, + column: foreignKey.refColumns[0] as string, + actions: [ + ...(foreignKey.onDelete !== "no action" + ? [`.onDelete(${q(foreignKey.onDelete)})`] + : []), + ...(foreignKey.onUpdate !== "no action" + ? [`.onUpdate(${q(foreignKey.onUpdate)})`] + : []), + ], + }; + targets.add(foreignKey.table); + const out = edges.get(plan.table.name) ?? new Set(); + out.add(foreignKey.table); + edges.set(plan.table.name, out); + } + } + + // 4. Declaration order: targets before the tables that reference them. + const ordered: TablePlan[] = []; + const placed = new Set(); + const place = (plan: TablePlan) => { + if (placed.has(plan.table.name)) return; + placed.add(plan.table.name); + for (const target of [...(edges.get(plan.table.name) ?? [])].sort()) { + const targetPlan = plans.get(target); + if (targetPlan) place(targetPlan); + } + ordered.push(plan); + }; + for (const plan of plans.values()) place(plan); + + // 5. Identifiers and imports. + const imports = new Set(["defineSchema"]); + for (const plan of ordered) { + for (const column of plan.columns) { + if (column.fk) imports.add("fk"); + else if (column.base.importName) imports.add(column.base.importName); + } + } + const usesEnum = ordered.some((plan) => + plan.columns.some((c) => !c.fk && c.base.storage === "enum"), + ); + const used = new Set([...imports, "table", "enumType", "schema"]); + for (const plan of ordered) { + let candidate = plan.table.name.replace(/[^A-Za-z0-9_$]/g, "_"); + if (!/^[A-Za-z_$]/.test(candidate)) candidate = `_${candidate}`; + if (used.has(candidate) || JS_RESERVED.has(candidate)) { + candidate = `${candidate}Table`; + } + let variable = candidate; + for (let n = 2; used.has(variable); n++) variable = `${candidate}${n}`; + used.add(variable); + plan.variable = variable; + } + + // 6. Source. + const lines: string[] = [ + `// Generated by \`appkit db introspect\` from schema "${shown(schemaName)}". Edit tables here,`, + "// then run `npx appkit db migrate` on your dev branch.", + importLine( + [...imports].sort(), + options.importSource ?? "@databricks/appkit/beta", + ), + "", + ]; + // Only a schema other than the default is passed to defineSchema(). + const schemaOption = + schemaName === DEFAULT_SCHEMA_NAME + ? undefined + : `{ schemaName: ${q(schemaName)} }`; + if (renamed.length > 0) { + warnings.push( + `Constraint names differ from the ones migrations use: ${list(renamed)}. A later migration that drops or changes one of them fails until it is renamed in the database (ALTER TABLE … RENAME CONSTRAINT).`, + ); + } + const result = (source: string): SchemaSource => ({ + source, + tables: ordered.map((plan) => plan.table.name), + skipped, + warnings: warnings.map(shown), + approximated: approximated.map(shown), + }); + if (ordered.length === 0) { + lines.push(...tableComments); + lines.push( + `export const schema = defineSchema(() => ({})${schemaOption ? `, ${schemaOption}` : ""});`, + "", + ); + return result(lines.join("\n")); + } + + // The builder's body, indented one level. + const body: string[] = []; + for (const plan of ordered) { + body.push(` const ${plan.variable} = table(${q(plan.table.name)}, {`); + for (const comment of plan.comments) body.push(` ${comment}`); + for (const entry of plan.body) { + if (typeof entry === "string") { + body.push(` ${entry}`); + continue; + } + const column = entry; + for (const comment of column.comments) body.push(` ${comment}`); + const chain: string[] = []; + const head = column.fk + ? `fk(() => ${access((plans.get(column.fk.table) as TablePlan).variable, column.fk.column)})` + : column.base.call; + const serverKey = + !column.fk && + (column.base.storage === "id" || column.base.storage === "bigid"); + if (column.isPrimaryKey && !serverKey) chain.push(".primaryKey()"); + else if (column.column.notNull && !serverKey) chain.push(".notNull()"); + if (column.isUnique) chain.push(".unique()"); + if (column.defaultCall) chain.push(column.defaultCall); + chain.push(...(column.fk?.actions ?? [])); + body.push(` ${key(column.column.name)}: ${head}${chain.join("")},`); + } + body.push(" });", ""); + } + for (const comment of tableComments) body.push(` ${comment}`); + if (tableComments.length > 0) body.push(""); + const entries = ordered.map((plan) => + plan.variable === plan.table.name + ? plan.variable + : `${key(plan.table.name)}: ${plan.variable}`, + ); + body.push(` return { ${entries.join(", ")} };`); + + const builder = `({ table${usesEnum ? ", enum: enumType" : ""} }) =>`; + if (schemaOption) { + lines.push( + "export const schema = defineSchema(", + ` ${builder} {`, + ...body.map((line) => (line ? ` ${line}` : line)), + " },", + ` ${schemaOption},`, + ");", + "", + ); + } else { + lines.push( + `export const schema = defineSchema(${builder} {`, + ...body, + "});", + "", + ); + } + return result(lines.join("\n")); +} diff --git a/packages/appkit/src/database/tooling/introspect/tests/catalog.test.ts b/packages/appkit/src/database/tooling/introspect/tests/catalog.test.ts new file mode 100644 index 000000000..48831febc --- /dev/null +++ b/packages/appkit/src/database/tooling/introspect/tests/catalog.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "vitest"; + +import { buildTables, readCatalog } from "../catalog"; +import { catalogResponder, catalogRows } from "./fixture"; + +describe("readCatalog", () => { + test("runs four pg_catalog queries for the schema, excluding the migration journal", async () => { + const { calls, respond } = catalogResponder(); + + await readCatalog(respond, "app"); + + expect(calls).toHaveLength(4); + for (const call of calls) { + expect(call.values).toEqual(["app", "__drizzle_migrations"]); + expect(call.text).toContain("pg_catalog."); + expect(call.text).not.toContain("information_schema"); + } + const [, , constraints, indexes] = calls.map((call) => call.text); + // Copies of a foreign key to a partitioned table are left out. + expect(constraints).toContain("k.conparentid = 0"); + expect(constraints).toContain("k.contype in ('p', 'u', 'f', 'c', 'x')"); + // Indexes behind a key or exclusion constraint are not reported twice. + expect(indexes).toContain("from pg_catalog.pg_index x"); + expect(indexes).toContain("k.contype in ('p', 'u', 'x')"); + }); + + test("maps catalog rows to tables, columns, keys, foreign keys and enums", async () => { + const { respond } = catalogResponder(); + const [authors, posts] = await readCatalog(respond, "app"); + + expect(authors).toEqual({ + name: "authors", + partitioned: false, + primaryKey: ["id"], + unique: [{ name: "authors_email_key", columns: ["email"] }], + foreignKeys: [], + checks: [], + indexes: [], + columns: [ + { + name: "id", + type: "integer", + notNull: true, + identity: "", + generated: false, + defaultExpr: "nextval('app.authors_id_seq'::regclass)", + }, + { + name: "email", + type: "character varying(255)", + notNull: true, + identity: "", + generated: false, + defaultExpr: null, + }, + { + name: "created_at", + type: "timestamp with time zone", + notNull: true, + identity: "", + generated: false, + defaultExpr: "now()", + }, + ], + }); + expect(posts?.primaryKey).toEqual(["id"]); + expect(posts?.foreignKeys).toEqual([ + { + name: "posts_author_id_fkey", + columns: ["author_id"], + schema: "app", + table: "authors", + refColumns: ["id"], + onDelete: "cascade", + onUpdate: "no action", + deferrable: false, + matchFull: false, + }, + ]); + expect(posts?.indexes).toEqual(["posts_status_idx"]); + expect(posts?.columns.find((c) => c.name === "id")?.identity).toBe("d"); + expect(posts?.columns.find((c) => c.name === "total")?.generated).toBe( + true, + ); + expect(posts?.columns.find((c) => c.name === "status")?.enum).toEqual({ + name: "ticket_status", + schema: "app", + values: ["open", "closed"], + }); + }); + + test("partitioned tables, CHECK and EXCLUDE constraints, indexes, DEFERRABLE and MATCH FULL are kept for the report", () => { + const row = (kind: "c" | "x" | "f", name: string, extra = {}) => ({ + ...catalogRows.constraints[2], + kind, + name, + ...extra, + }); + const [table] = buildTables( + [{ name: "posts", partitioned: true }], + [], + [ + row("c", "posts_score_check"), + row("x", "posts_no_overlap"), + row("f", "posts_author_id_fkey", { + deferrable: true, + match_type: "f", + }), + ] as never, + [ + { table_name: "posts", name: "posts_status_idx" }, + { table_name: "authors", name: "authors_name_idx" }, + ], + ); + expect(table).toMatchObject({ + partitioned: true, + checks: ["posts_score_check", "posts_no_overlap"], + indexes: ["posts_status_idx"], + foreignKeys: [{ deferrable: true, matchFull: true }], + }); + }); + + test("referential action letters map to builder actions", () => { + const fkRow = (on_delete: string, on_update: string) => ({ + ...catalogRows.constraints[2], + on_delete, + on_update, + }); + const [table] = buildTables( + [{ name: "posts", partitioned: false }], + [], + ["r", "c", "n", "d", "a"].map((letter) => fkRow(letter, letter)) as never, + ); + expect(table?.foreignKeys.map((fk) => [fk.onDelete, fk.onUpdate])).toEqual([ + ["restrict", "restrict"], + ["cascade", "cascade"], + ["set null", "set null"], + ["set default", "set default"], + ["no action", "no action"], + ]); + }); +}); diff --git a/packages/appkit/src/database/tooling/introspect/tests/fixture.ts b/packages/appkit/src/database/tooling/introspect/tests/fixture.ts new file mode 100644 index 000000000..9c87d4c32 --- /dev/null +++ b/packages/appkit/src/database/tooling/introspect/tests/fixture.ts @@ -0,0 +1,283 @@ +import path from "node:path"; + +import type { + CatalogQuery, + ColumnRow, + ConstraintRow, + IndexRow, + IntrospectedColumn, + IntrospectedForeignKey, + IntrospectedTable, + IntrospectedUnique, + TableRow, +} from "../catalog"; + +/** The builder by absolute path, so rendered sources load from a temp root. */ +export const BUILDER = path.resolve( + import.meta.dirname, + "../../../schema-builder/index.ts", +); + +export const col = ( + name: string, + type: string, + extra: Partial = {}, +): IntrospectedColumn => ({ + name, + type, + notNull: false, + identity: "", + generated: false, + defaultExpr: null, + ...extra, +}); + +/** A catalog table; what a test does not set is empty. */ +export const tableOf = ( + table: Pick & + Partial, +): IntrospectedTable => ({ + partitioned: false, + primaryKey: [], + unique: [], + foreignKeys: [], + checks: [], + indexes: [], + ...table, +}); + +export const foreignKey = ( + key: Omit & + Partial, +): IntrospectedForeignKey => ({ deferrable: false, matchFull: false, ...key }); + +/** A unique constraint with the name Postgres gives it. */ +export const uniqueKey = ( + table: string, + ...columns: string[] +): IntrospectedUnique => ({ + name: `${table}_${columns.join("_")}_key`, + columns, +}); + +const status = { + name: "ticket_status", + schema: "app", + values: ["open", "closed"], +}; + +/** Supported types, defaults, FKs and enums, plus one of each skip. Listed out of dependency order. */ +export const richTables: IntrospectedTable[] = [ + tableOf({ + name: "posts", + primaryKey: ["id"], + unique: [], + foreignKeys: [ + foreignKey({ + name: "posts_author_id_fkey", + columns: ["author_id"], + schema: "app", + table: "authors", + refColumns: ["id"], + onDelete: "cascade", + onUpdate: "no action", + }), + foreignKey({ + name: "posts_editor_id_fkey", + columns: ["editor_id"], + schema: "app", + table: "authors", + refColumns: ["id"], + onDelete: "set null", + onUpdate: "no action", + }), + ], + columns: [ + col("id", "uuid", { notNull: true, defaultExpr: "gen_random_uuid()" }), + col("author_id", "integer", { notNull: true }), + col("editor_id", "integer"), + col("status", "app.ticket_status", { + notNull: true, + defaultExpr: "'open'::app.ticket_status", + enum: status, + }), + col("body", "jsonb", { defaultExpr: "'{}'::jsonb" }), + col("views", "bigint", { notNull: true, defaultExpr: "0" }), + col("my col", "character varying"), + ], + }), + tableOf({ + name: "authors", + primaryKey: ["id"], + unique: [uniqueKey("authors", "email")], + foreignKeys: [], + columns: [ + col("id", "integer", { + notNull: true, + defaultExpr: "nextval('app.authors_id_seq'::regclass)", + }), + col("email", "character varying(255)", { notNull: true }), + col("bio", "text", { defaultExpr: "'it''s me'::text" }), + col("active", "boolean", { notNull: true, defaultExpr: "true" }), + col("score", "numeric(10,2)", { notNull: true }), + col("rank", "integer", { defaultExpr: "'-1'::integer" }), + col("created_at", "timestamp with time zone", { + notNull: true, + defaultExpr: "now()", + }), + col("seen_at", "timestamp(3) without time zone"), + ], + }), + tableOf({ + name: "events", + primaryKey: ["a", "b"], + unique: [], + foreignKeys: [], + columns: [ + col("a", "integer", { notNull: true }), + col("b", "integer", { notNull: true }), + ], + }), + tableOf({ + name: "logs", + primaryKey: [], + unique: [], + foreignKeys: [], + columns: [col("line", "text")], + }), + tableOf({ + name: "default", + primaryKey: ["id"], + unique: [], + foreignKeys: [], + columns: [col("id", "bigint", { notNull: true, identity: "d" })], + }), +]; + +export const columnRow = ( + table_name: string, + name: string, + type: string, + extra: Partial = {}, +): ColumnRow => ({ + table_name, + name, + type, + not_null: false, + identity: "", + generated: "", + default_expr: null, + type_kind: "b", + type_schema: "pg_catalog", + type_name: type, + enum_values: null, + ...extra, +}); + +/** What the three pg_catalog queries return for a small hand-made schema. */ +export const catalogRows = { + tables: [ + { name: "authors", partitioned: false }, + { name: "posts", partitioned: false }, + ] satisfies TableRow[], + columns: [ + columnRow("authors", "id", "integer", { + not_null: true, + default_expr: "nextval('app.authors_id_seq'::regclass)", + }), + columnRow("authors", "email", "character varying(255)", { + not_null: true, + }), + columnRow("authors", "created_at", "timestamp with time zone", { + not_null: true, + default_expr: "now()", + }), + columnRow("posts", "id", "integer", { not_null: true, identity: "d" }), + columnRow("posts", "author_id", "integer", { not_null: true }), + columnRow("posts", "status", "app.ticket_status", { + not_null: true, + default_expr: "'open'::app.ticket_status", + type_kind: "e", + type_schema: "app", + type_name: "ticket_status", + enum_values: ["open", "closed"], + }), + columnRow("posts", "score", "numeric(10,2)", { not_null: true }), + columnRow("posts", "total", "integer", { + generated: "s", + default_expr: "1", + }), + ] satisfies ColumnRow[], + constraints: [ + { + table_name: "authors", + name: "authors_email_key", + kind: "u", + columns: ["email"], + ref_schema: null, + ref_table: null, + ref_columns: [], + on_delete: " ", + on_update: " ", + }, + { + table_name: "authors", + name: "authors_pkey", + kind: "p", + columns: ["id"], + ref_schema: null, + ref_table: null, + ref_columns: [], + on_delete: " ", + on_update: " ", + }, + { + table_name: "posts", + name: "posts_author_id_fkey", + kind: "f", + columns: ["author_id"], + ref_schema: "app", + ref_table: "authors", + ref_columns: ["id"], + on_delete: "c", + on_update: "a", + }, + { + table_name: "posts", + name: "posts_pkey", + kind: "p", + columns: ["id"], + ref_schema: null, + ref_table: null, + ref_columns: [], + on_delete: " ", + on_update: " ", + }, + ] satisfies ConstraintRow[], + indexes: [ + { table_name: "posts", name: "posts_status_idx" }, + ] satisfies IndexRow[], +}; + +/** A CatalogQuery (and pg-like pool) answering the four catalog queries from rows. */ +export function catalogResponder(rows = catalogRows) { + const calls: Array<{ text: string; values: readonly unknown[] }> = []; + const respond: CatalogQuery = async ( + text: string, + values: readonly unknown[], + ) => { + calls.push({ text, values }); + // The index query also mentions pg_constraint, so it is matched first. + const result = text.includes("pg_catalog.pg_index") + ? rows.indexes + : text.includes("pg_catalog.pg_constraint") + ? rows.constraints + : text.includes("format_type") + ? rows.columns + : text.includes("select c.relname::text as name") + ? rows.tables + : []; + return result as unknown as T[]; + }; + return { calls, respond }; +} diff --git a/packages/appkit/src/database/tooling/introspect/tests/golden/rich.schema.ts.txt b/packages/appkit/src/database/tooling/introspect/tests/golden/rich.schema.ts.txt new file mode 100644 index 000000000..2fe3e54d5 --- /dev/null +++ b/packages/appkit/src/database/tooling/introspect/tests/golden/rich.schema.ts.txt @@ -0,0 +1,51 @@ +// Generated by `appkit db introspect` from schema "app". Edit tables here, +// then run `npx appkit db migrate` on your dev branch. +import { + bigid, + bigint, + boolean, + defineSchema, + fk, + id, + integer, + jsonb, + text, + timestamp, + uuid, + varchar, +} from "@databricks/appkit/beta"; + +export const schema = defineSchema(({ table, enum: enumType }) => { + const authors = table("authors", { + id: id(), + email: varchar(255).notNull().unique(), + bio: text().default("it's me"), + active: boolean().notNull().default(true), + // appkit: skipped column "score": type "numeric(10,2)" is not supported + rank: integer().default(-1), + created_at: timestamp({ withTimezone: true }).notNull().defaultNow(), + seen_at: timestamp(), + }); + + const posts = table("posts", { + id: uuid().primaryKey().defaultRandom(), + author_id: fk(() => authors.id).notNull().onDelete("cascade"), + // appkit: skipped foreign key to authors(id) is not kept: a second foreign key to "authors" would make the relation ambiguous + editor_id: integer(), + status: enumType("ticket_status", ["open", "closed"]).notNull().default("open"), + // appkit: skipped default '{}'::jsonb is not supported + body: jsonb(), + // appkit: skipped default 0 is not supported + views: bigint().notNull(), + "my col": text(), + }); + + const defaultTable = table("default", { + id: bigid(), + }); + + // appkit: skipped table "events": composite primary key (a, b) is not supported + // appkit: skipped table "logs": it has no primary key + + return { authors, posts, default: defaultTable }; +}); diff --git a/packages/appkit/src/database/tooling/introspect/tests/introspect.test.ts b/packages/appkit/src/database/tooling/introspect/tests/introspect.test.ts new file mode 100644 index 000000000..2e655d269 --- /dev/null +++ b/packages/appkit/src/database/tooling/introspect/tests/introspect.test.ts @@ -0,0 +1,562 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const { checkAppkitBranch, markMigrationsApplied, openBranchPool } = vi.hoisted( + () => ({ + checkAppkitBranch: vi.fn(), + markMigrationsApplied: vi.fn(), + openBranchPool: vi.fn(), + }), +); + +vi.mock("../../context", async (importOriginal) => ({ + ...(await importOriginal()), + openBranchPool, +})); +vi.mock("../../guard", async (importOriginal) => ({ + ...(await importOriginal()), + checkAppkitBranch, +})); +vi.mock("../../../migrations/apply", async (importOriginal) => ({ + ...(await importOriginal()), + markMigrationsApplied, +})); + +import { loadDefaultDatabaseSchema } from "../../../../plugins/database/load-schema"; +import { readJournal } from "../../../migrations/paths"; +import { generateMigrationFiles } from "../../migrations"; +import { cleanupTempApps, fakeContext, tempApp } from "../../tests/fakes"; +import type { ColumnRow } from "../catalog"; +import { IntrospectRefusedError, runIntrospect } from "../index"; +import { BUILDER, catalogResponder, columnRow } from "./fixture"; + +const SCHEMA = "config/database/schema.ts"; +const MIGRATIONS = "config/database/migrations"; +const DEV = "projects/p/branches/dev-alice-3f9a2c"; +const PRODUCTION = "projects/p/branches/production"; + +const previousSchema = `import { defineSchema, id, text } from ${JSON.stringify(BUILDER)}; +export const schema = defineSchema(({ table }) => ({ + authors: table("authors", { id: id(), email: text() }), +})); +`; + +const onDevBranch = { + branch: { name: DEV }, + verdict: { appkitOwned: true }, +}; +const onProduction = { + branch: { name: PRODUCTION, status: { default: true } }, + verdict: { appkitOwned: false, reason: "it is the project's default branch" }, +}; + +let catalog: ReturnType; +/** `when` of the last migration the branch recorded; null: no journal table. */ +let recorded: number | null; +const lastWhen = (root: string) => + readJournal(path.join(root, MIGRATIONS))?.entries.at(-1)?.when ?? null; +const pool = { + query: vi.fn(async (text: string, values: unknown[]) => { + // The branch's migration journal (pendingMigrationTags). + if (text.includes("max(created_at)")) return { rows: [{ last: recorded }] }; + if (text.includes("c.relname = $2")) { + return { rows: recorded === null ? [] : [{ "?column?": 1 }] }; + } + return { rows: await catalog.respond(text, values) }; + }), + end: vi.fn(async () => undefined), +}; + +beforeEach(() => { + catalog = catalogResponder(); + recorded = null; + checkAppkitBranch.mockReset().mockResolvedValue(onDevBranch); + markMigrationsApplied + .mockReset() + .mockImplementation( + async (_pool: unknown, options: { migrationsFolder: string }) => { + recorded = + readJournal(options.migrationsFolder)?.entries.at(-1)?.when ?? null; + }, + ); + openBranchPool.mockReset().mockResolvedValue(pool); + pool.end.mockClear(); +}); +afterEach(cleanupTempApps); + +/** A catalog with only authors(id), plus the given authors columns. */ +const authorsOnly = (...columns: ColumnRow[]) => + catalogResponder({ + tables: [{ name: "authors", partitioned: false }], + columns: [ + columnRow("authors", "id", "integer", { not_null: true, identity: "d" }), + ...columns, + ], + constraints: [ + { + table_name: "authors", + name: "authors_pkey", + kind: "p", + columns: ["id"], + ref_schema: null, + ref_table: null, + ref_columns: [], + on_delete: " ", + on_update: " ", + }, + ], + indexes: [], + }); + +/** Commit a migration of the current schema.ts; `applied`: the branch has recorded it. */ +async function commitMigration(root: string, applied = true): Promise { + await generateMigrationFiles({ + folder: path.join(root, MIGRATIONS), + schema: await loadDefaultDatabaseSchema(root), + interactive: false, + }); + if (applied) recorded = lastWhen(root); +} + +/** Every file under config/, with its contents. */ +function tree(root: string): Record { + const files: Record = {}; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else files[path.relative(root, full)] = fs.readFileSync(full, "utf-8"); + } + }; + if (fs.existsSync(path.join(root, "config"))) walk(path.join(root, "config")); + return files; +} + +const ctxFor = (root: string) => fakeContext({ root }); +const introspect = (root: string) => + runIntrospect(ctxFor(root), { interactive: false, importSource: BUILDER }); + +describe("runIntrospect", () => { + test("on an AppKit branch without history: schema.ts (+ .bak), 0000_baseline recorded as applied, database.d.ts", async () => { + const root = tempApp({ [SCHEMA]: previousSchema }); + + const report = await introspect(root); + + expect(catalog.calls.every((call) => call.values[0] === "app")).toBe(true); + expect(fs.readFileSync(path.join(root, `${SCHEMA}.bak`), "utf-8")).toBe( + previousSchema, + ); + const written = fs.readFileSync(path.join(root, SCHEMA), "utf-8"); + expect(written).toContain('const authors = table("authors", {'); + expect(written).toContain( + 'author_id: fk(() => authors.id).notNull().onDelete("cascade"),', + ); + expect(report).toMatchObject({ + branch: DEV, + schemaName: "app", + tables: ["authors", "posts"], + schemaFile: path.join(root, SCHEMA), + backupFile: path.join(root, `${SCHEMA}.bak`), + recordedApplied: true, + migration: { tag: "0000_baseline" }, + }); + expect(report.skipped.map((skip) => skip.column)).toEqual([ + "score", + "total", + ]); + expect(report.warnings).toEqual([ + expect.stringMatching(/^app\.posts\.score is NOT NULL with no default/), + expect.stringMatching( + /^Constraint names differ from the ones migrations use: authors_email_key \(authors_email_unique\), posts_author_id_fkey \(posts_author_id_authors_id_fk\)\./, + ), + ]); + expect(report.approximated).toEqual([ + "app.authors.id: a serial key is declared as id(), an identity generated BY DEFAULT", + "app.posts: index posts_status_idx is not declared", + ]); + expect( + fs.existsSync(path.join(root, MIGRATIONS, "0000_baseline.sql")), + ).toBe(true); + expect(markMigrationsApplied).toHaveBeenCalledWith( + pool, + { migrationsFolder: path.join(root, MIGRATIONS), schemaName: "app" }, + ["0000_baseline"], + ); + expect(fs.readFileSync(report.typesFile, "utf-8")).toContain("authors"); + expect(pool.end).toHaveBeenCalledOnce(); + // The written schema is what the plugin loads. + await expect(loadDefaultDatabaseSchema(root)).resolves.toMatchObject({ + $schemaName: "app", + }); + }); + + test("on another branch without history: writes 0000_baseline but never records it (the deployed app adopts it)", async () => { + checkAppkitBranch.mockResolvedValue(onProduction); + const root = tempApp({ [SCHEMA]: previousSchema }); + + const report = await introspect(root); + + expect(report.migration?.tag).toBe("0000_baseline"); + expect(report.recordedApplied).toBe(false); + expect(report.notRecorded).toBe( + "production is not your dev or session branch (it is the project's default branch); the deployed app adopts 0000_baseline on start when its tables already exist", + ); + expect(markMigrationsApplied).not.toHaveBeenCalled(); + }); + + test("on another branch with history: refuses before reading or writing anything", async () => { + checkAppkitBranch.mockResolvedValue(onProduction); + const root = tempApp({ [SCHEMA]: previousSchema }); + const schema = await loadDefaultDatabaseSchema(root); + await generateMigrationFiles({ + folder: path.join(root, MIGRATIONS), + schema, + interactive: false, + }); + const before = fs.readdirSync(path.join(root, MIGRATIONS)); + + const error = await introspect(root).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(IntrospectRefusedError); + expect((error as Error).message).toMatch( + /^Refusing to introspect production: config\/database\/migrations already has history/, + ); + expect(openBranchPool).not.toHaveBeenCalled(); + expect(fs.readFileSync(path.join(root, SCHEMA), "utf-8")).toBe( + previousSchema, + ); + expect(fs.existsSync(path.join(root, `${SCHEMA}.bak`))).toBe(false); + expect(fs.readdirSync(path.join(root, MIGRATIONS))).toEqual(before); + }); + + test("on an AppKit branch with history: writes and records the next NNNN_introspect", async () => { + const root = tempApp({ [SCHEMA]: previousSchema }); + const schema = await loadDefaultDatabaseSchema(root); + await generateMigrationFiles({ + folder: path.join(root, MIGRATIONS), + schema, + interactive: false, + }); + recorded = lastWhen(root); + + const report = await introspect(root); + + expect(report.migration?.tag).toBe("0001_introspect"); + expect(report.migration?.statements.join("\n")).toContain( + 'CREATE TABLE "app"."posts"', + ); + expect(markMigrationsApplied).toHaveBeenCalledWith( + pool, + expect.anything(), + ["0001_introspect"], + ); + }); + + test("a second run with no catalog change writes no migration and records nothing", async () => { + const root = tempApp({ [SCHEMA]: previousSchema }); + await introspect(root); + markMigrationsApplied.mockClear(); + + const report = await introspect(root); + + expect(report.migration).toBeNull(); + expect(report.recordedApplied).toBe(false); + expect(markMigrationsApplied).not.toHaveBeenCalled(); + }); + + test("without schema.ts it reads the app schema and creates the file", async () => { + const root = tempApp(); + + const report = await introspect(root); + + expect(catalog.calls.every((call) => call.values[0] === "app")).toBe(true); + expect(report).toMatchObject({ + schemaName: "app", + backupFile: null, + tables: ["authors", "posts"], + migration: { tag: "0000_baseline" }, + recordedApplied: true, + }); + expect(fs.readFileSync(path.join(root, SCHEMA), "utf-8")).toMatch( + /from schema "app"\.[^]*export const schema = defineSchema\(\(\{ table[^}]*\}\) => \{/, + ); + expect(fs.existsSync(path.join(root, `${SCHEMA}.bak`))).toBe(false); + }); + + test("--schema-name picks another schema", async () => { + const root = tempApp(); + + const report = await runIntrospect(ctxFor(root), { + interactive: false, + importSource: BUILDER, + schemaName: "sales", + }); + + expect(catalog.calls.every((call) => call.values[0] === "sales")).toBe( + true, + ); + expect(report.schemaName).toBe("sales"); + }); + + test.each([ + ["a syntax error", "export const schema = ;\n"], + ["a single line", "// todo\n"], + [ + "a missing import", + `import { defineSchema } from ${JSON.stringify(BUILDER)};\nexport const schema = defineSchema(({ table }) => ({ x: table("x", { id: id() }) }));\n`, + ], + ])( + "a schema.ts with %s is never read: it goes to .bak and is replaced from the app schema", + async (_label, broken) => { + const root = tempApp({ [SCHEMA]: broken }); + + const report = await introspect(root); + + expect(catalog.calls.every((call) => call.values[0] === "app")).toBe( + true, + ); + expect(report).toMatchObject({ + schemaName: "app", + tables: ["authors", "posts"], + backupFile: path.join(root, `${SCHEMA}.bak`), + migration: { tag: "0000_baseline" }, + }); + expect(fs.readFileSync(path.join(root, `${SCHEMA}.bak`), "utf-8")).toBe( + broken, + ); + expect(fs.readFileSync(path.join(root, SCHEMA), "utf-8")).toMatch( + /from schema "app"\.[^]*export const schema = defineSchema\(\(\{ table[^}]*\}\) => \{/, + ); + }, + ); + + test("--schema-name public is written explicitly, so the app never looks for the tables in the default app schema", async () => { + const root = tempApp(); + + const report = await runIntrospect(ctxFor(root), { + interactive: false, + importSource: BUILDER, + schemaName: "public", + }); + + expect(report.schemaName).toBe("public"); + expect(fs.readFileSync(path.join(root, SCHEMA), "utf-8")).toContain( + '{ schemaName: "public" }', + ); + expect((await loadDefaultDatabaseSchema(root)).$schemaName).toBe("public"); + }); + + test("the schema schema.ts declares does not change which schema is read", async () => { + const root = tempApp({ + [SCHEMA]: previousSchema.replace("}));", '}), { schemaName: "sales" });'), + }); + + const report = await introspect(root); + + expect(catalog.calls.every((call) => call.values[0] === "app")).toBe(true); + expect(report.schemaName).toBe("app"); + }); + + test("an empty schema next to an existing schema.ts refuses before writing anything", async () => { + catalog = catalogResponder({ + tables: [], + columns: [], + constraints: [], + indexes: [], + }); + const root = tempApp({ [SCHEMA]: previousSchema }); + + const error = await introspect(root).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(IntrospectRefusedError); + expect((error as Error).message).toBe( + 'Schema "app" on dev-alice-3f9a2c has no tables, so introspect would replace config/database/schema.ts with an empty one. Run `appkit db introspect --schema-name ` to read another schema.', + ); + expect(fs.readFileSync(path.join(root, SCHEMA), "utf-8")).toBe( + previousSchema, + ); + expect(fs.existsSync(path.join(root, `${SCHEMA}.bak`))).toBe(false); + expect(fs.existsSync(path.join(root, MIGRATIONS))).toBe(false); + }); + + test("an empty schema without schema.ts still writes an empty one", async () => { + catalog = catalogResponder({ + tables: [], + columns: [], + constraints: [], + indexes: [], + }); + const root = tempApp(); + + const report = await introspect(root); + + expect(report.tables).toEqual([]); + expect(report.migration).toBeNull(); + expect(fs.readFileSync(path.join(root, SCHEMA), "utf-8")).toContain( + "export const schema = defineSchema(() => ({}));", + ); + }); + + test("without a configured branch it fails before writing", async () => { + checkAppkitBranch.mockResolvedValue(null); + const root = tempApp({ [SCHEMA]: previousSchema }); + + await expect(introspect(root)).rejects.toThrow( + /No branch configured.*appkit db init/, + ); + expect(fs.readFileSync(path.join(root, SCHEMA), "utf-8")).toBe( + previousSchema, + ); + }); + + test("with history, a branch that has not applied every committed migration refuses before reading the catalog", async () => { + const root = tempApp({ [SCHEMA]: previousSchema }); + await commitMigration(root); + // A teammate's migration, pulled but not applied on this branch yet. + fs.writeFileSync( + path.join(root, SCHEMA), + previousSchema.replace( + "email: text() }),", + 'email: text() }),\n tags: table("tags", { id: id() }),', + ), + ); + await commitMigration(root, false); + const before = tree(root); + + const error = await introspect(root).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(IntrospectRefusedError); + expect((error as Error).message).toBe( + 'Refusing to introspect dev-alice-3f9a2c: it has not applied 0001_migration in schema "app", so the migration would undo it. Run `appkit db migrate` first.', + ); + expect(catalog.calls).toEqual([]); + expect(tree(root)).toEqual(before); + }); + + test("with history and no schema.ts, an empty schema refuses instead of dropping every table", async () => { + const root = tempApp({ [SCHEMA]: previousSchema }); + await commitMigration(root); + fs.rmSync(path.join(root, SCHEMA)); + catalog = catalogResponder({ + tables: [], + columns: [], + constraints: [], + indexes: [], + }); + const before = tree(root); + + const error = await introspect(root).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(IntrospectRefusedError); + expect((error as Error).message).toBe( + 'Schema "app" on dev-alice-3f9a2c has no tables, so introspect would write a migration that drops every table. Run `appkit db introspect --schema-name ` to read another schema.', + ); + expect(tree(root)).toEqual(before); + }); + + test("with history, a migration that drops what introspect skipped is refused and nothing changes", async () => { + const root = tempApp({ [SCHEMA]: previousSchema }); + await commitMigration(root); + // email still exists, with a type schema.ts cannot declare. + catalog = authorsOnly(columnRow("authors", "email", "numeric")); + const before = tree(root); + + const error = await introspect(root).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(IntrospectRefusedError); + expect((error as Error).message).toBe( + 'Refusing to write a migration that drops what schema.ts cannot express (authors.email): the deployed app would run ALTER TABLE "app"."authors" DROP COLUMN "email";', + ); + expect(tree(root)).toEqual(before); + expect(markMigrationsApplied).not.toHaveBeenCalled(); + }); + + test("with history, dropping what the branch no longer has is written, recorded and warned about", async () => { + const root = tempApp({ [SCHEMA]: previousSchema }); + await commitMigration(root); + catalog = authorsOnly(); + + const report = await introspect(root); + + expect(report.migration?.tag).toBe("0001_introspect"); + expect(report.migration?.statements).toEqual([ + 'ALTER TABLE "app"."authors" DROP COLUMN "email";', + ]); + expect(report.recordedApplied).toBe(true); + expect(report.warnings).toEqual([ + '0001_introspect drops what the branch no longer has, and the deployed app runs it on start: ALTER TABLE "app"."authors" DROP COLUMN "email";', + ]); + }); + + test("when recording fails, schema.ts, its backup and the migrations are put back, so a rerun records", async () => { + const root = tempApp({ + [SCHEMA]: previousSchema, + [`${SCHEMA}.bak`]: "// an older backup\n", + }); + const before = tree(root); + markMigrationsApplied.mockRejectedValueOnce(new Error("lock timeout")); + + const error = await introspect(root).catch((caught: unknown) => caught); + + expect((error as Error).message).toBe( + "Introspect changed nothing: lock timeout", + ); + expect(tree(root)).toEqual(before); + expect(fs.existsSync(path.join(root, MIGRATIONS))).toBe(false); + + const report = await introspect(root); + expect(report).toMatchObject({ + migration: { tag: "0000_baseline" }, + recordedApplied: true, + }); + }); + + test("a second run with the same catalog keeps the original .bak", async () => { + const root = tempApp({ [SCHEMA]: previousSchema }); + await introspect(root); + + const second = await introspect(root); + + expect(second.backupFile).toBeNull(); + expect(fs.readFileSync(path.join(root, `${SCHEMA}.bak`), "utf-8")).toBe( + previousSchema, + ); + }); + + test("when every table is skipped, an existing schema.ts is kept", async () => { + catalog = catalogResponder({ + tables: [{ name: "logs", partitioned: false }], + columns: [columnRow("logs", "line", "text")], + constraints: [], + indexes: [], + }); + const root = tempApp({ [SCHEMA]: previousSchema }); + const before = tree(root); + + const error = await introspect(root).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(IntrospectRefusedError); + expect((error as Error).message).toBe( + 'Schema "app" on dev-alice-3f9a2c has no tables introspect can write (logs: it has no primary key), so introspect would replace config/database/schema.ts with an empty one. Run `appkit db introspect --schema-name ` to read another schema.', + ); + expect(tree(root)).toEqual(before); + }); + + test("a generated schema.ts that does not load is reported as a bug, and schema.ts is put back", async () => { + const root = tempApp({ [SCHEMA]: previousSchema }); + const before = tree(root); + + const error = await runIntrospect(ctxFor(root), { + interactive: false, + importSource: path.join(root, "missing-builder.ts"), + }).catch((caught: unknown) => caught); + + expect((error as Error).message).toBe( + 'Introspect changed nothing: The schema.ts introspect generated does not load. This is a bug in `appkit db introspect`; please report it with the catalog of schema "app".', + ); + expect(tree(root)).toEqual(before); + expect(markMigrationsApplied).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/appkit/src/database/tooling/introspect/tests/render.test.ts b/packages/appkit/src/database/tooling/introspect/tests/render.test.ts new file mode 100644 index 000000000..5ed7e6fb5 --- /dev/null +++ b/packages/appkit/src/database/tooling/introspect/tests/render.test.ts @@ -0,0 +1,538 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { describe, expect, test } from "vitest"; + +import type { IntrospectedForeignKey, IntrospectedTable } from "../catalog"; +import { renderSchemaSource } from "../render"; +import { col, richTables, tableOf, uniqueKey } from "./fixture"; + +const golden = (name: string) => + fs.readFileSync(path.join(import.meta.dirname, "golden", name), "utf-8"); + +const fk = ( + column: string, + table: string, + extra: Partial = {}, +): IntrospectedForeignKey => ({ + name: `${column}_fkey`, + columns: [column], + schema: "app", + table, + refColumns: ["id"], + onDelete: "no action", + onUpdate: "no action", + deferrable: false, + matchFull: false, + ...extra, +}); + +/** A table with an integer identity key plus the given columns and FKs. */ +const keyed = ( + name: string, + columns: IntrospectedTable["columns"] = [], + foreignKeys: IntrospectedForeignKey[] = [], + unique: string[][] = [], +): IntrospectedTable => + tableOf({ + name, + primaryKey: ["id"], + unique: unique.map((columns) => uniqueKey(name, ...columns)), + foreignKeys, + columns: [ + col("id", "integer", { notNull: true, identity: "d" }), + ...columns, + ], + }); + +const body = (source: string) => + source.slice(source.indexOf("export const schema")); + +describe("renderSchemaSource", () => { + test("golden: supported types, defaults, FKs and enums, with skip comments", () => { + const out = renderSchemaSource(richTables, "app"); + + expect(out.source).toBe(golden("rich.schema.ts.txt")); + expect(out.skipped).toEqual( + expect.arrayContaining([ + { + table: "events", + reason: "composite primary key (a, b) is not supported", + }, + { table: "logs", reason: "it has no primary key" }, + { + table: "authors", + column: "score", + reason: 'type "numeric(10,2)" is not supported', + }, + { + table: "posts", + column: "body", + reason: "default '{}'::jsonb is not supported", + }, + { + table: "posts", + column: "views", + reason: "default 0 is not supported", + }, + { + table: "posts", + column: "editor_id", + reason: + 'foreign key to authors(id) is not kept: a second foreign key to "authors" would make the relation ambiguous', + }, + ]), + ); + expect(out.skipped).toHaveLength(6); + }); + + test("a skipped NOT NULL column without a default is a loud warning", () => { + const { warnings } = renderSchemaSource(richTables, "app"); + expect(warnings).toContain( + 'app.authors.score is NOT NULL with no default but is not in schema.ts (type "numeric(10,2)" is not supported). Inserts through the API will fail until it gets a default, becomes nullable, or is dropped.', + ); + expect(warnings).toContain( + "app.posts.views: its default 0 is not in schema.ts, so the API requires a value for it on insert.", + ); + expect( + warnings.filter((warning) => !warning.startsWith("Constraint names")), + ).toHaveLength(2); + }); + + test.each([ + ["app", "export const schema = defineSchema(() => ({}));\n"], + [ + "public", + 'export const schema = defineSchema(() => ({}), { schemaName: "public" });\n', + ], + ])( + "no tables in %s renders an empty schema, naming the schema only when it is not app", + (schemaName, expected) => { + expect(body(renderSchemaSource([], schemaName).source)).toBe(expected); + }, + ); + + test("tables in a schema other than app pass it to defineSchema", () => { + expect(body(renderSchemaSource([keyed("notes")], "public").source)).toBe( + [ + "export const schema = defineSchema(", + " ({ table }) => {", + ' const notes = table("notes", {', + " id: id(),", + " });", + "", + " return { notes };", + " },", + ' { schemaName: "public" },', + ");", + "", + ].join("\n"), + ); + }); + + test("declares FK targets first, whatever the catalog order", () => { + const { source } = renderSchemaSource( + [ + keyed("c", [col("b_id", "integer")], [fk("b_id", "b")]), + keyed("b", [col("a_id", "integer")], [fk("a_id", "a")]), + keyed("a"), + ], + "app", + ); + const order = ["a", "b", "c"].map((name) => + source.indexOf(`const ${name} = table`), + ); + expect(order).toEqual([...order].sort((x, y) => x - y)); + expect(source).toContain("return { a, b, c };"); + }); + + test("FKs the builder cannot express stay plain columns, each with its reason", () => { + const cases: Array<[IntrospectedTable[], string]> = [ + [ + [keyed("t", [col("parent_id", "integer")], [fk("parent_id", "t")])], + "introspect does not write self-references", + ], + [ + [ + keyed("a", [col("b_id", "integer")], [fk("b_id", "b")]), + keyed("b", [col("a_id", "integer")], [fk("a_id", "a")]), + ], + "it would close a cycle of foreign keys", + ], + [ + [ + keyed( + "t", + [col("x", "integer"), col("y", "integer")], + [{ ...fk("x", "u"), columns: ["x", "y"], refColumns: ["id", "k"] }], + ), + keyed("u"), + ], + "foreign keys over several columns are not supported", + ], + [ + [ + keyed( + "t", + [col("u_id", "integer")], + [fk("u_id", "u", { schema: "public" })], + ), + keyed("u"), + ], + 'it references schema "public"', + ], + [ + [ + keyed( + "t", + [col("u_code", "integer")], + [fk("u_code", "u", { refColumns: ["code"] })], + ), + keyed("u", [col("code", "integer")]), + ], + "u.code is not a primary key or single-column unique key", + ], + [ + [keyed("t", [col("u_id", "bigint")], [fk("u_id", "u")]), keyed("u")], + "its type bigint() differs from u.id", + ], + [ + [ + keyed( + "t", + [col("u_id", "integer", { notNull: true })], + [fk("u_id", "u", { onDelete: "set null" })], + ), + keyed("u"), + ], + "SET NULL on a NOT NULL column", + ], + [ + [ + keyed( + "t", + [col("u_id", "integer")], + [fk("u_id", "u", { onDelete: "set default" })], + ), + keyed("u"), + ], + "SET DEFAULT without a default", + ], + [ + [ + keyed( + "t", + [col("u_id", "integer"), col("u", "text")], + [fk("u_id", "u")], + ), + keyed("u"), + ], + 'the relation "u" would clash with the column of the same name', + ], + [ + [ + keyed("t", [col("u_id", "integer")], [fk("u_id", "u")]), + keyed("u", [col("t", "text")]), + ], + 'the reverse relation "t" would clash with column u.t', + ], + [ + [ + keyed("t", [col("u_id", "integer")], [fk("u_id", "u")]), + { ...keyed("u"), primaryKey: ["id", "x"] }, + ], + 'table "u" is skipped', + ], + ]; + for (const [tables, reason] of cases) { + const out = renderSchemaSource(tables, "app"); + expect(out.source, reason).toContain(reason); + const fkSkips = out.skipped.filter((skip) => + skip.reason.startsWith("foreign key"), + ); + expect(fkSkips, reason).toHaveLength(1); + // Exactly one fk() survives only in the cycle case (the first edge). + expect(out.source.includes("fk(() =>"), reason).toBe( + reason.includes("cycle"), + ); + } + }); + + test("an FK keeps NOT NULL, unique, a default and its actions", () => { + const { source } = renderSchemaSource( + [ + keyed( + "t", + [col("u_id", "integer", { notNull: true, defaultExpr: "1" })], + [fk("u_id", "u", { onDelete: "set default", onUpdate: "cascade" })], + [["u_id"]], + ), + keyed("u"), + ], + "app", + ); + expect(source).toContain( + 'u_id: fk(() => u.id).notNull().unique().default(1).onDelete("set default").onUpdate("cascade"),', + ); + }); + + test("a table whose primary key cannot be expressed is skipped whole", () => { + const out = renderSchemaSource( + [ + tableOf({ + name: "t", + primaryKey: ["code"], + unique: [], + foreignKeys: [], + columns: [ + col("code", "smallint", { notNull: true }), + col("x", "numeric", { notNull: true }), + ], + }), + ], + "app", + ); + expect(out.skipped).toEqual([ + { + table: "t", + reason: + 'its primary key column "code" cannot be expressed (type "smallint" is not supported)', + }, + ]); + // The table is gone, so its columns produce no warnings. + expect(out.warnings).toEqual([]); + }); + + test("names AppKit reserves are skipped; unusual names are quoted or renamed", () => { + const out = renderSchemaSource( + [ + keyed("sql"), + keyed("my-table", [ + col("$meta", "text"), + col("and", "text"), + col("select", "text"), + ]), + keyed("users"), + keyed("usersRelations"), + ], + "app", + ); + expect( + out.skipped.map((skip) => [skip.table, skip.column ?? null, skip.reason]), + ).toEqual([ + ["sql", null, 'the name "sql" is reserved by AppKit'], + [ + "usersRelations", + null, + 'the name collides with the relation metadata AppKit generates for "users"', + ], + ["my-table", "$meta", 'the name "$meta" is reserved by AppKit'], + ["my-table", "and", 'the name "and" is reserved by AppKit'], + ]); + expect(out.source).toContain('const my_table = table("my-table", {'); + expect(out.source).toContain(" select: text(),"); + expect(out.source).toContain('return { "my-table": my_table, users };'); + }); + + test("unsupported defaults are dropped with a comment; nextval on a key becomes id()", () => { + const { source, skipped } = renderSchemaSource( + [ + tableOf({ + name: "t", + primaryKey: ["id"], + unique: [], + foreignKeys: [], + columns: [ + col("id", "bigint", { + notNull: true, + defaultExpr: "nextval('t_id_seq'::regclass)", + }), + col("code", "character varying(2)", { + defaultExpr: "'toolong'::character varying", + }), + col("at", "timestamp without time zone", { + defaultExpr: "'2020-01-01 00:00:00'::timestamp without time zone", + }), + col("n", "integer", { defaultExpr: "nextval('n_seq'::regclass)" }), + ], + }), + ], + "app", + ); + expect(source).toContain(" id: bigid(),"); + expect(skipped.map((skip) => skip.column)).toEqual(["code", "at", "n"]); + expect(source).toContain(" code: varchar(2),"); + }); + + test("a catalog name with a line break stays inside its comment", () => { + const evil = "x\nrequire('node:child_process').execSync('touch /tmp/p')//"; + const { source, skipped, warnings } = renderSchemaSource( + [ + keyed("t", [ + col(evil, "numeric", { notNull: true }), + col("y\u2028z", "numeric"), + ]), + ], + "app", + ); + for (const line of source.split("\n")) { + expect(line.trimStart().startsWith("require(")).toBe(false); + } + expect(source).not.toMatch(/[\u2028\u2029]/); + expect(source).toContain( + `// appkit: skipped column "x\\u000arequire('node:child_process').execSync('touch /tmp/p')//": type "numeric" is not supported`, + ); + expect(source).toContain( + '// appkit: skipped column "y\\u2028z": type "numeric" is not supported', + ); + expect(skipped.map((skip) => skip.column)).toEqual([ + "x\\u000arequire('node:child_process').execSync('touch /tmp/p')//", + "y\\u2028z", + ]); + expect(warnings[0]).toMatch(/^app\.t\.x\\u000arequire/); + }); + + test("the schema name in the header comment is escaped too", () => { + const { source } = renderSchemaSource([keyed("t")], "a\nb"); + expect(source.split("\n")[0]).toBe( + '// Generated by `appkit db introspect` from schema "a\\u000ab". Edit tables here,', + ); + expect(source).toContain('{ schemaName: "a\\nb" }'); + }); + + test("returns the tables it wrote", () => { + expect(renderSchemaSource(richTables, "app").tables).toEqual([ + "authors", + "posts", + "default", + ]); + }); + + test("what schema.ts declares differently from the database is listed as approximated", () => { + const { approximated } = renderSchemaSource( + [ + tableOf({ + name: "a", + partitioned: true, + primaryKey: ["id"], + checks: ["a_n_check"], + indexes: ["a_n_idx", "a_m_idx"], + columns: [ + col("id", "integer", { notNull: true, identity: "a" }), + col("n", "character varying"), + col("at", "timestamp(3) with time zone"), + ], + }), + keyed( + "b", + [col("a_id", "integer")], + [ + fk("a_id", "a", { + name: "b_a_id_a_id_fk", + deferrable: true, + matchFull: true, + }), + ], + ), + ], + "app", + ); + expect(approximated).toEqual([ + "app.a.id: GENERATED ALWAYS AS IDENTITY is declared as id(), an identity generated BY DEFAULT", + "app.a.n: character varying without a length is declared as text()", + "app.a.at: timestamp(3) with time zone is declared without its precision", + "app.a: a partitioned table is declared as a plain table", + "app.a: CHECK or EXCLUDE constraint a_n_check is not declared", + "app.a: indexes a_n_idx, a_m_idx are not declared", + "app.b.a_id: the foreign key is declared without DEFERRABLE", + "app.b.a_id: the foreign key is declared without MATCH FULL", + ]); + }); + + test("constraint names that differ from the ones migrations use are one warning", () => { + const { warnings } = renderSchemaSource(richTables, "app"); + expect(warnings).toContain( + "Constraint names differ from the ones migrations use: authors_email_key (authors_email_unique), posts_author_id_fkey (posts_author_id_authors_id_fk). A later migration that drops or changes one of them fails until it is renamed in the database (ALTER TABLE … RENAME CONSTRAINT).", + ); + }); + + test("constraint names migrations would use raise no warning", () => { + const { warnings } = renderSchemaSource( + [ + tableOf({ + ...keyed("u", [col("email", "text")]), + unique: [{ name: "u_email_unique", columns: ["email"] }], + }), + keyed( + "t", + [col("u_id", "integer")], + [fk("u_id", "u", { name: "t_u_id_u_id_fk" })], + ), + ], + "app", + ); + expect(warnings).toEqual([]); + }); + + test("a uuid FK keeps the key and skips gen_random_uuid(), which fk() does not take", () => { + const { source, skipped, warnings } = renderSchemaSource( + [ + tableOf({ + name: "u", + primaryKey: ["id"], + columns: [ + col("id", "uuid", { + notNull: true, + defaultExpr: "gen_random_uuid()", + }), + ], + }), + keyed( + "t", + [ + col("u_id", "uuid", { + notNull: true, + defaultExpr: "gen_random_uuid()", + }), + ], + [fk("u_id", "u", { name: "t_u_id_u_id_fk" })], + ), + ], + "app", + ); + expect(source).toContain(" u_id: fk(() => u.id).notNull(),"); + expect(source).not.toContain("fk(() => u.id).notNull().defaultRandom()"); + expect(skipped).toEqual([ + { + table: "t", + column: "u_id", + reason: + "default gen_random_uuid() is not supported on a foreign key column", + }, + ]); + expect(warnings).toEqual([ + "app.t.u_id: its default gen_random_uuid() is not in schema.ts, so the API requires a value for it on insert.", + ]); + }); + + test("SET DEFAULT needs a default fk() can take", () => { + const { skipped } = renderSchemaSource( + [ + tableOf({ + name: "u", + primaryKey: ["id"], + columns: [col("id", "uuid", { notNull: true })], + }), + keyed( + "t", + [col("u_id", "uuid", { defaultExpr: "gen_random_uuid()" })], + [fk("u_id", "u", { onDelete: "set default" })], + ), + ], + "app", + ); + expect(skipped.map((skip) => skip.reason)).toEqual([ + "foreign key to u(id) is not kept: SET DEFAULT without a default the builder can express", + ]); + }); +}); diff --git a/packages/appkit/src/database/tooling/introspect/tests/roundtrip.test.ts b/packages/appkit/src/database/tooling/introspect/tests/roundtrip.test.ts new file mode 100644 index 000000000..a4909c814 --- /dev/null +++ b/packages/appkit/src/database/tooling/introspect/tests/roundtrip.test.ts @@ -0,0 +1,260 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { loadDefaultDatabaseSchema } from "../../../../plugins/database/load-schema"; +import { drizzleKitImports } from "../../drizzle-exports"; +import { generateMigrationFiles } from "../../migrations"; +import type { IntrospectedTable } from "../catalog"; +import { renderSchemaSource } from "../render"; +import { + BUILDER, + col, + foreignKey, + richTables, + tableOf, + uniqueKey, +} from "./fixture"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) + fs.rmSync(root, { recursive: true, force: true }); +}); + +/** Write the rendered source as an app's schema.ts and load it the way the plugin does. */ +async function load(tables: IntrospectedTable[]) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-introspect-")); + roots.push(root); + const file = path.join(root, "config/database/schema.ts"); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + renderSchemaSource(tables, "app", { importSource: BUILDER }).source, + ); + return { root, schema: await loadDefaultDatabaseSchema(root) }; +} + +interface KitColumn { + name: string; + type: string; + notNull: boolean; + primaryKey: boolean; + default?: unknown; +} +interface KitTable { + name: string; + schema: string; + columns: Record; + foreignKeys: Record< + string, + { + tableTo: string; + columnsFrom: string[]; + columnsTo: string[]; + onDelete?: string; + } + >; +} + +describe("introspect round trip", () => { + test("the rendered source loads through loadDefaultDatabaseSchema, and drizzle-kit sees the same tables", async () => { + const { schema } = await load(richTables); + expect(schema.$schemaName).toBe("app"); + expect(Object.keys(schema.$tables).sort()).toEqual([ + "authors", + "default", + "posts", + ]); + + const { generateDrizzleJson } = await import("drizzle-kit/api"); + const snapshot = generateDrizzleJson(drizzleKitImports(schema), undefined, [ + "app", + ]) as unknown as { + tables: Record; + enums: Record; + }; + const columns = (table: string) => + Object.fromEntries( + Object.values(snapshot.tables[`app.${table}`]?.columns ?? {}).map( + (c) => [ + c.name, + `${c.type}${c.notNull ? " not null" : ""}${c.primaryKey ? " pk" : ""}`, + ], + ), + ); + + expect(columns("authors")).toEqual({ + id: "integer not null pk", + email: "varchar(255) not null", + bio: "text", + active: "boolean not null", + rank: "integer", + created_at: "timestamp with time zone not null", + seen_at: "timestamp", + }); + expect(columns("posts")).toEqual({ + id: "uuid not null pk", + author_id: "integer not null", + editor_id: "integer", + status: "ticket_status not null", + body: "jsonb", + views: "bigint not null", + "my col": "text", + }); + expect(columns("default")).toEqual({ id: "bigint not null pk" }); + expect( + Object.values(snapshot.tables["app.posts"]?.foreignKeys ?? {}), + ).toEqual([ + expect.objectContaining({ + tableTo: "authors", + columnsFrom: ["author_id"], + columnsTo: ["id"], + onDelete: "cascade", + }), + ]); + expect(snapshot.enums["app.ticket_status"]).toMatchObject({ + values: ["open", "closed"], + }); + }); + + test("the baseline is generated from the empty snapshot: it creates every rendered table", async () => { + const { root, schema } = await load(richTables); + const folder = path.join(root, "config/database/migrations"); + + const migration = await generateMigrationFiles({ + folder, + schema, + tag: "baseline", + interactive: false, + }); + + expect(migration?.tag).toBe("0000_baseline"); + const sql = migration?.statements.join("\n") ?? ""; + for (const table of ["authors", "posts", "default"]) { + expect(sql).toContain(`CREATE TABLE "app"."${table}"`); + } + expect(sql).toContain( + `CREATE TYPE "app"."ticket_status" AS ENUM('open', 'closed')`, + ); + expect(sql).not.toContain("CREATE SCHEMA"); + }); + + test("every skip path still yields a loadable schema", async () => { + const tables: IntrospectedTable[] = [ + tableOf({ + name: "a", + primaryKey: ["id"], + unique: [uniqueKey("a", "x", "y")], + foreignKeys: [ + foreignKey({ + name: "self", + columns: ["parent_id"], + schema: "app", + table: "a", + refColumns: ["id"], + onDelete: "no action", + onUpdate: "no action", + }), + foreignKey({ + name: "to_b", + columns: ["b_id"], + schema: "app", + table: "b", + refColumns: ["id"], + onDelete: "set null", + onUpdate: "no action", + }), + ], + columns: [ + col("id", "integer", { notNull: true, identity: "a" }), + col("parent_id", "integer"), + col("b_id", "integer", { notNull: true }), + col("x", "integer"), + col("y", "integer"), + col("g", "integer", { generated: true, defaultExpr: "(x + y)" }), + col("seq", "integer", { identity: "d" }), + col("tags", "text[]"), + ], + }), + tableOf({ + name: "b", + primaryKey: ["id"], + unique: [], + foreignKeys: [ + foreignKey({ + name: "to_a", + columns: ["a_id"], + schema: "app", + table: "a", + refColumns: ["id"], + onDelete: "no action", + onUpdate: "no action", + }), + ], + columns: [ + col("id", "integer", { notNull: true, identity: "d" }), + col("a_id", "integer"), + ], + }), + ]; + const { schema } = await load(tables); + expect(Object.keys(schema.$tables).sort()).toEqual(["a", "b"]); + expect(Object.keys(schema.$tables.a?.$columns ?? {})).toEqual([ + "id", + "parent_id", + "b_id", + "x", + "y", + ]); + }); + + test("a uuid FK with gen_random_uuid() and names with line breaks still load, with no extra code", async () => { + const marker = "__introspect_injected__"; + const { schema } = await load([ + tableOf({ + name: "u", + primaryKey: ["id"], + columns: [ + col("id", "uuid", { + notNull: true, + defaultExpr: "gen_random_uuid()", + }), + ], + }), + tableOf({ + name: "t", + primaryKey: ["id"], + foreignKeys: [ + foreignKey({ + name: "t_u_id_fkey", + columns: ["u_id"], + schema: "app", + table: "u", + refColumns: ["id"], + onDelete: "no action", + onUpdate: "no action", + }), + ], + columns: [ + col("id", "integer", { notNull: true, identity: "d" }), + col("u_id", "uuid", { + notNull: true, + defaultExpr: "gen_random_uuid()", + }), + col(`x\nglobalThis.${marker} = true; //`, "numeric"), + col(`y\u2028globalThis.${marker} = true; //`, "numeric"), + ], + }), + ]); + + expect((globalThis as Record)[marker]).toBeUndefined(); + expect(Object.keys(schema.$tables).sort()).toEqual(["t", "u"]); + expect(Object.keys(schema.$tables.t?.$columns ?? {})).toEqual([ + "id", + "u_id", + ]); + }); +}); 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..b350110ff --- /dev/null +++ b/packages/appkit/src/database/tooling/lakebase-api.ts @@ -0,0 +1,306 @@ +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?: { + endpoint_type?: string; + hosts?: { host?: string }; + }; +} + +export interface LakebaseRole { + name: string; + status?: { + postgres_role?: string; + membership_roles?: string[]; + }; +} + +export interface LakebaseDatabase { + name: string; + status?: { postgres_database?: string }; +} + +export type IdentityType = "USER" | "SERVICE_PRINCIPAL"; + +export const SUPERUSER_MEMBERSHIP = "DATABRICKS_SUPERUSER"; +export const READ_WRITE_ENDPOINT = "ENDPOINT_TYPE_READ_WRITE"; + +export interface RoleSpec { + identity_type: IdentityType; + postgres_role: string; + auth_method: "LAKEBASE_OAUTH_V1"; + membership_roles: [typeof SUPERUSER_MEMBERSHIP]; +} + +export type BranchSpec = { source_branch: string } & ( + | { no_expiry: true } + | { ttl: string } +); + +interface Operation { + name?: string; + done?: boolean; + error?: { message?: string; error_code?: string }; + response?: unknown; +} + +export class LakebaseApiError extends Error { + /** HTTP status of the failing call; absent for a failed long-running operation. */ + readonly statusCode?: number; + /** What was being done, e.g. "create branch projects/p/branches/b". */ + readonly operation: string; + + constructor( + operation: string, + message: string, + options: { statusCode?: number; cause?: unknown } = {}, + ) { + super(`Lakebase: ${operation} failed: ${message}`, { + cause: options.cause, + }); + this.name = "LakebaseApiError"; + this.operation = operation; + this.statusCode = options.statusCode; + } +} + +const API = "/api/2.0/postgres"; + +function statusCodeOf(error: unknown): number | undefined { + const code = (error as { statusCode?: unknown } | undefined)?.statusCode; + return typeof code === "number" ? code : undefined; +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +interface LakebaseApiOptions { + /** First poll delay for long-running operations; doubles up to 5 s. */ + readonly pollIntervalMs?: number; + /** Give up waiting for one long-running operation after this long. */ + readonly timeoutMs?: number; +} + +export class LakebaseApi { + private readonly pollIntervalMs: number; + private readonly timeoutMs: number; + + constructor( + private readonly client: Pick, + options: LakebaseApiOptions = {}, + ) { + this.pollIntervalMs = options.pollIntervalMs ?? 500; + this.timeoutMs = options.timeoutMs ?? 10 * 60_000; + } + + getBranch(name: string): Promise { + return this.getOrNull(`get branch ${name}`, name); + } + + getEndpoint(name: string): Promise { + return this.getOrNull(`get endpoint ${name}`, name); + } + + listBranches(project: string): Promise { + return this.list( + `list branches of ${project}`, + `${project}/branches`, + "branches", + ); + } + + async createBranch( + project: string, + branchId: string, + spec: BranchSpec, + ): Promise { + const operation = `create branch ${project}/branches/${branchId}`; + const op = await this.call(operation, { + path: `${API}/${project}/branches`, + method: "POST", + query: { branch_id: branchId }, + payload: { spec }, + }); + return this.wait(operation, op); + } + + listEndpoints(branch: string): Promise { + return this.list( + `list endpoints of ${branch}`, + `${branch}/endpoints`, + "endpoints", + ); + } + + async createEndpoint( + branch: string, + endpointId: string, + ): Promise { + const operation = `create endpoint ${branch}/endpoints/${endpointId}`; + const op = await this.call(operation, { + path: `${API}/${branch}/endpoints`, + method: "POST", + query: { endpoint_id: endpointId }, + payload: { spec: { endpoint_type: READ_WRITE_ENDPOINT } }, + }); + return this.wait(operation, op); + } + + getDatabase(name: string): Promise { + return this.getOrNull(`get database ${name}`, name); + } + + listRoles(branch: string): Promise { + return this.list(`list roles of ${branch}`, `${branch}/roles`, "roles"); + } + + /** Without a role_id; the API derives one (sidesteps the USER role_id format). */ + async createRole(branch: string, spec: RoleSpec): Promise { + const operation = `create role ${spec.postgres_role} on ${branch}`; + const op = await this.call(operation, { + path: `${API}/${branch}/roles`, + method: "POST", + payload: { spec }, + }); + return this.wait(operation, op); + } + + async setRoleMemberships( + role: string, + memberships: RoleSpec["membership_roles"], + ): Promise { + const operation = `update role ${role}`; + const op = await this.call(operation, { + path: `${API}/${role}`, + method: "PATCH", + query: { update_mask: "spec.membership_roles" }, + payload: { spec: { membership_roles: memberships } }, + }); + return this.wait(operation, op); + } + + /** Every object the role owns is reassigned first (runs SQL on the branch's compute). */ + async deleteRole( + role: string, + options: { reassignOwnedTo: string }, + ): Promise { + const operation = `delete role ${role}`; + const op = await this.call(operation, { + path: `${API}/${role}`, + method: "DELETE", + query: { reassign_owned_to: options.reassignOwnedTo }, + }); + await this.wait(operation, op); + } + + 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; + } + } + + private async list( + operation: string, + collection: string, + key: string, + ): Promise { + const items: T[] = []; + let pageToken: string | undefined; + do { + const page = await this.call>(operation, { + path: `${API}/${collection}`, + method: "GET", + ...(pageToken ? { query: { page_token: pageToken } } : {}), + }); + items.push(...((page?.[key] as T[] | undefined) ?? [])); + pageToken = (page?.next_page_token as string | undefined) || undefined; + } while (pageToken); + return items; + } + + /** Poll GET /api/2.0/postgres/{operation.name} until done. */ + private async wait(operation: string, first: Operation): Promise { + const deadline = Date.now() + this.timeoutMs; + let delay = this.pollIntervalMs; + let current = first; + while (!current?.done) { + if (!current?.name) { + throw new LakebaseApiError( + operation, + "the API returned an unfinished operation without a name", + ); + } + if (Date.now() >= deadline) { + throw new LakebaseApiError( + operation, + `timed out after ${Math.round(this.timeoutMs / 1000)}s waiting for ${current.name}`, + ); + } + await sleep(delay); + delay = Math.min(delay * 2, 5_000); + current = await this.call(operation, { + path: `${API}/${current.name}`, + method: "GET", + }); + } + if (current.error) { + throw new LakebaseApiError( + operation, + current.error.message ?? current.error.error_code ?? "unknown error", + ); + } + return current.response as T; + } +} diff --git a/packages/appkit/src/database/tooling/migrate.ts b/packages/appkit/src/database/tooling/migrate.ts new file mode 100644 index 000000000..49b7a86e5 --- /dev/null +++ b/packages/appkit/src/database/tooling/migrate.ts @@ -0,0 +1,142 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { loadDefaultDatabaseSchema } from "../../plugins/database/load-schema"; +import { + DATABASE_TYPES_FILE, + generateDatabaseTypes, +} from "../../type-generator/database"; +import { applyPendingMigrations } from "../migrations/apply"; +import { migrationsFolder, readJournal } from "../migrations/paths"; +import { type DbContext, openBranchPool } from "./context"; +import { checkAppkitBranch } from "./guard"; +import { type GeneratedMigration, generateMigrationFiles } from "./migrations"; +import { resourceId } from "./targets"; + +/** Same application-relative conventions as the plugin and `appkit generate-types`. */ +export const SCHEMA_FILE = "config/database/schema.ts"; + +/** Whether the checkout has a config/database/schema.ts yet. */ +export function hasSchemaFile(root: string): boolean { + return fs.existsSync(path.join(root, SCHEMA_FILE)); +} + +const WITHOUT_SCHEMA = { + migrate: "there is nothing to migrate", + clean: "clean cannot tell which schema to drop", + verify: "there is nothing to verify", +} as const; + +/** For a command that reads schema.ts: without one, point to introspect instead of a load error. */ +export function requireSchemaFile( + root: string, + command: keyof typeof WITHOUT_SCHEMA, +): void { + if (hasSchemaFile(root)) return; + const what = WITHOUT_SCHEMA[command]; + throw new Error( + `No ${SCHEMA_FILE} yet, so ${what}. Run \`appkit db introspect\` to write it from the branch's tables, or create it.`, + ); +} +export const DATABASE_TYPES_PATH = path.join( + "shared/appkit-types", + DATABASE_TYPES_FILE, +); + +export interface MigrateReport { + /** The migration written by this run; null when schema.ts matches the latest snapshot. */ + readonly generated: GeneratedMigration | null; + /** Tags applied to the `.env` branch by this run. */ + readonly applied: readonly string[]; + /** Tags recorded without running because their tables already existed (0000_baseline). */ + readonly adopted?: readonly string[]; + /** Why nothing was applied, when applying was skipped. */ + readonly notApplied?: string; + readonly typesFile: string; +} + +export interface MigrateOptions { + /** false: an ambiguous rename throws instead of prompting. */ + readonly interactive: boolean; + /** false: only apply committed migrations (init's `clean` start). Default true. */ + readonly generate?: boolean; +} + +type ApplyOutcome = Pick; + +const NO_BRANCH = + "no branch configured (PGHOST, PGDATABASE, LAKEBASE_ENDPOINT) — run appkit db init"; + +/** Why migrations must not be applied to the `.env` branch; undefined when they may. */ +async function applyBlocker(ctx: DbContext): Promise { + const check = await checkAppkitBranch(ctx); + if (!check) return NO_BRANCH; + if (check.verdict.appkitOwned) return undefined; + return `${resourceId(check.branch.name)} is not an AppKit dev or session branch (${check.verdict.reason}). Migrations are applied only to branches \`appkit db init\` created for you; the deployed app applies committed migrations to its own branch`; +} + +/** Apply pending migrations to the `.env` branch, or say why they were not. */ +async function applyToEnvBranch( + ctx: DbContext, + folder: string, + schemaName: string, +): Promise { + const blocker = readJournal(folder) + ? await applyBlocker(ctx) + : "no migrations to apply — schema.ts declares no tables yet"; + if (blocker) return { applied: [], notApplied: blocker }; + const pool = await openBranchPool(ctx); + if (!pool) return { applied: [], notApplied: NO_BRANCH }; + try { + const { applied, adopted } = await applyPendingMigrations(pool, { + migrationsFolder: folder, + schemaName, + }); + return { applied, ...(adopted.length > 0 ? { adopted } : {}) }; + } finally { + await pool.end().catch(() => undefined); + } +} + +/** + * `appkit db migrate`: generate a migration when schema.ts changed, regenerate + * database.d.ts, and apply pending migrations to the `.env` branch (only AppKit + * dev/session branches). + */ +export async function runMigrate( + ctx: DbContext, + options: MigrateOptions, +): Promise { + requireSchemaFile(ctx.root, "migrate"); + const schema = await loadDefaultDatabaseSchema(ctx.root, { + showImportError: true, + }); + const folder = migrationsFolder(ctx.root); + const generated = + options.generate === false + ? null + : await generateMigrationFiles({ + folder, + schema, + interactive: options.interactive, + }); + + // The types follow schema.ts alone, so they are current even if applying fails. + const typesFile = path.join(ctx.root, DATABASE_TYPES_PATH); + await generateDatabaseTypes({ + schemaFile: path.join(ctx.root, SCHEMA_FILE), + outFile: typesFile, + }); + + let outcome: ApplyOutcome; + try { + outcome = await applyToEnvBranch(ctx, folder, schema.$schemaName); + } catch (error) { + if (!generated) throw error; + throw new Error( + `Wrote ${path.relative(ctx.root, generated.file)}, but did not apply it: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + return { generated, ...outcome, typesFile }; +} diff --git a/packages/appkit/src/database/tooling/migrations.ts b/packages/appkit/src/database/tooling/migrations.ts new file mode 100644 index 000000000..c5af81372 --- /dev/null +++ b/packages/appkit/src/database/tooling/migrations.ts @@ -0,0 +1,298 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { + type Journal, + type JournalEntry, + journalFile, + readJournal, + readJsonFile, +} from "../migrations/paths"; +import type { Schema } from "../schema-builder"; +import { drizzleKitImports } from "./drizzle-exports"; + +/** + * The drizzle-kit release the snapshots and SQL are written for; the optional + * peer dependency in package.json pins the same version. + */ +export const DRIZZLE_KIT_VERSION = "0.31.10"; + +interface GenerateOptions { + /** Absolute migrations folder. */ + readonly folder: string; + readonly schema: Schema; + /** Default "migration" → "NNNN_migration". */ + readonly tag?: string; + /** false: an ambiguous rename throws instead of prompting. */ + readonly interactive: boolean; + /** Sees the SQL before anything is written; throwing writes nothing. */ + readonly beforeWrite?: (statements: readonly string[]) => void; +} + +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 the command 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"; +const TAG_NAME = /^[a-z0-9_]+$/; + +/** The parts of a drizzle-kit PostgreSQL snapshot this module reads. */ +interface Snapshot { + id: string; + tables: Record }>; + enums: Record; + [key: string]: unknown; +} + +/** drizzle-kit's empty starting snapshot (its `dryPg`). */ +function originSnapshot(): Snapshot { + return { + id: ORIGIN_ID, + prevId: "", + version: "7", + dialect: "postgresql", + tables: {}, + enums: {}, + schemas: {}, + sequences: {}, + roles: {}, + policies: {}, + views: {}, + _meta: { schemas: {}, tables: {}, columns: {} }, + }; +} + +function prefixOf(idx: number): string { + return String(idx).padStart(4, "0"); +} + +function snapshotFile(folder: string, idx: number): string { + return path.join(folder, "meta", `${prefixOf(idx)}_snapshot.json`); +} + +function latestSnapshot(folder: string, journal: Journal | null): Snapshot { + const last = journal?.entries.at(-1); + if (!last) return originSnapshot(); + const file = snapshotFile(folder, last.idx); + if (!fs.existsSync(file)) { + throw new Error( + `Missing ${file} for migration ${last.tag}. Restore it from version control; it is the baseline for the next migration.`, + ); + } + return readJsonFile(file); +} + +function keyDiff( + before: Record, + after: Record, +): { created: string[]; deleted: string[] } { + return { + created: Object.keys(after).filter((key) => !Object.hasOwn(before, key)), + deleted: Object.keys(before).filter((key) => !Object.hasOwn(after, key)), + }; +} + +/** The same "created and deleted together" conditions that make drizzle-kit prompt. */ +function findRenameAmbiguity( + prev: Snapshot, + cur: Snapshot, + schemaName: string, +): MigrationRenameAmbiguityError | null { + const tables = keyDiff(prev.tables, cur.tables); + if (tables.created.length > 0 && tables.deleted.length > 0) { + return new MigrationRenameAmbiguityError( + "table", + schemaName, + tables.created, + tables.deleted, + ); + } + const enums = keyDiff(prev.enums, cur.enums); + if (enums.created.length > 0 && enums.deleted.length > 0) { + return new MigrationRenameAmbiguityError( + "enum", + schemaName, + enums.created, + enums.deleted, + ); + } + for (const [key, table] of Object.entries(cur.tables)) { + const before = prev.tables[key]; + if (!before) continue; + const columns = keyDiff(before.columns, table.columns); + if (columns.created.length > 0 && columns.deleted.length > 0) { + return new MigrationRenameAmbiguityError( + "column", + key, + columns.created, + columns.deleted, + ); + } + } + return null; +} + +/** Whether drizzle-kit itself is not installed; module loaders may wrap that error. */ +function isDrizzleKitMissing(error: unknown): boolean { + let current = error; + for (let depth = 0; depth < 3 && current instanceof Error; depth++) { + if ( + (current as { code?: unknown }).code === "ERR_MODULE_NOT_FOUND" && + /['"]drizzle-kit['"]/.test(current.message) + ) { + return true; + } + current = current.cause; + } + return false; +} + +/** + * drizzle-kit is large, so apps install it as a dev dependency and it is loaded + * only when a migration is generated. + */ +async function loadDrizzleKit(): Promise { + try { + return await import("drizzle-kit/api"); + } catch (error) { + if (!isDrizzleKitMissing(error)) throw error; + throw new Error( + `Generating migrations needs drizzle-kit, which is not installed. Add it as a dev dependency: npm install --save-dev drizzle-kit@${DRIZZLE_KIT_VERSION}`, + { cause: error }, + ); + } +} + +/** + * Diff the schema against the latest committed snapshot with drizzle-kit and + * write the next v7 migration: `NNNN_.sql`, `meta/NNNN_snapshot.json`, + * and the appended `meta/_journal.json`. null when nothing changed. + */ +export async function generateMigrationFiles( + options: GenerateOptions, +): Promise { + const name = options.tag ?? "migration"; + if (!TAG_NAME.test(name)) { + throw new Error( + `Migration tag "${name}" must use lowercase letters, digits and underscores`, + ); + } + 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; + options.beforeWrite?.(statements); + + const entries = journal?.entries ?? []; + const last = entries.at(-1); + const idx = last ? last.idx + 1 : 0; + const tag = `${prefixOf(idx)}_${name}`; + const entry: JournalEntry = { + idx, + version: "7", + // The migrator applies entries newer than the last recorded one, so `when` must increase. + when: Math.max(Date.now(), (last?.when ?? 0) + 1), + tag, + breakpoints: true, + }; + const next: Journal = { + version: "7", + dialect: "postgresql", + entries: [...entries, entry], + }; + + const file = path.join(options.folder, `${tag}.sql`); + fs.mkdirSync(path.join(options.folder, "meta"), { recursive: true }); + fs.writeFileSync( + snapshotFile(options.folder, idx), + JSON.stringify( + { ...cur, _meta: { columns: {}, schemas: {}, tables: {} } }, + null, + 2, + ), + ); + fs.writeFileSync(file, statements.join(BREAKPOINT)); + // The journal is written last: it is what makes the migration visible. + fs.writeFileSync(journalFile(options.folder), JSON.stringify(next, null, 2)); + return { tag, file, statements }; +} + +/** + * 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) { + fs.writeFileSync(journalFile(folder), journalBefore); + return; + } + fs.rmSync(journalFile(folder), { force: true }); + for (const dir of [path.join(folder, "meta"), folder]) { + if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) + fs.rmdirSync(dir); + } +} diff --git a/packages/appkit/src/database/tooling/naming.ts b/packages/appkit/src/database/tooling/naming.ts new file mode 100644 index 000000000..0c50388f9 --- /dev/null +++ b/packages/appkit/src/database/tooling/naming.ts @@ -0,0 +1,60 @@ +import { createHash, randomInt } 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; + +/** Seven days: a session branch outlives a working week, then expires on its own. */ +export const SESSION_BRANCH_TTL = "604800s"; + +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}`; +} + +const BASE36 = "0123456789abcdefghijklmnopqrstuvwxyz"; + +/** 8 random base36 characters, generated once per checkout and kept in .env. */ +export function newSessionId(): string { + let id = ""; + for (let i = 0; i < 8; i++) id += BASE36[randomInt(BASE36.length)]; + return id; +} diff --git a/packages/appkit/src/database/tooling/seed.ts b/packages/appkit/src/database/tooling/seed.ts new file mode 100644 index 000000000..78226a347 --- /dev/null +++ b/packages/appkit/src/database/tooling/seed.ts @@ -0,0 +1,47 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { loadDefaultDatabaseSchema } from "../../plugins/database/load-schema"; +import { quoteIdentifier } from "../migrations/ownership"; +import { DEFAULT_SCHEMA_NAME } from "../schema-builder/define-schema"; +import { runInTransaction } from "./branch-sql"; +import type { DbContext } from "./context"; +import { assertAppkitBranch } from "./guard"; +import { hasSchemaFile } from "./migrate"; + +export const SEED_FILE = "config/database/seed.sql"; + +interface SeedReport { + readonly branch: string; + readonly file: string; +} + +/** + * `appkit db seed`: run config/database/seed.sql in one transaction on an + * AppKit dev or session branch, with the app schema first on the search_path + * so unqualified names resolve to it. Refuses any other branch before + * connecting. + */ +export async function runSeed(ctx: DbContext): Promise { + const file = path.join(ctx.root, SEED_FILE); + if (!fs.existsSync(file)) { + throw new Error( + `No seed file at ${SEED_FILE}. Create it with the SQL to run.`, + ); + } + const branch = await assertAppkitBranch(ctx, "seed"); + const schemaName = hasSchemaFile(ctx.root) + ? (await loadDefaultDatabaseSchema(ctx.root, { showImportError: true })) + .$schemaName + : DEFAULT_SCHEMA_NAME; + const searchPath = + schemaName === "public" + ? "public" + : `${quoteIdentifier(schemaName)}, public`; + await runInTransaction(ctx, [ + `SET LOCAL search_path TO ${searchPath}`, + // One simple-protocol query: the file may hold several statements. + fs.readFileSync(file, "utf-8"), + ]); + return { branch: branch.name, file }; +} diff --git a/packages/appkit/src/database/tooling/targets.ts b/packages/appkit/src/database/tooling/targets.ts new file mode 100644 index 000000000..08af0ba07 --- /dev/null +++ b/packages/appkit/src/database/tooling/targets.ts @@ -0,0 +1,176 @@ +import fs from "node:fs"; +import path from "node:path"; + +import yaml from "js-yaml"; + +import { isValidSessionId } from "./naming"; + +export type BranchMode = "dev" | "session"; + +/** 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); +} + +const PROJECT_ID = /^[a-z0-9][a-z0-9-]{0,62}$/; + +/** + * LAKEBASE_PROJECT as a project resource name: accepts "projects/" or a bare + * ""; null for anything else (e.g. the .env.example placeholder). + */ +export function parseProject(value: string | undefined): string | null { + const trimmed = value?.trim() ?? ""; + const id = trimmed.startsWith("projects/") + ? trimmed.slice("projects/".length) + : trimmed; + return PROJECT_ID.test(id) ? `projects/${id}` : null; +} + +/** What `.env` says about the local branch. Unparseable values (template placeholders) read as null. */ +export interface LocalTarget { + /** LAKEBASE_PROJECT, normalized to "projects/". */ + readonly project: string | null; + readonly endpoint: string | null; + readonly database: 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 { + project: parseProject(env.LAKEBASE_PROJECT), + endpoint, + // Without a real endpoint PGDATABASE is a template placeholder too. + database: endpoint ? env.PGDATABASE || null : null, + session, + }; +} + +/** The branch the bundle's default target deploys to. */ +export interface DeployTarget { + readonly branch: string; + readonly database: string | null; + readonly appName: string | null; +} + +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; +} + +/** Default target's postgres_branch / postgres_database variables and app name; null without databricks.yml or binding. */ +export function readDeployTarget(root: string): DeployTarget | null { + const bundle = readBundle(root); + if (!bundle) return null; + const targets = record(bundle.targets) ?? {}; + const target = + Object.values(targets) + .map(record) + .find((candidate) => candidate?.default === true) ?? + record(targets.default); + + const branch = parseResourceName( + variableOf(bundle, target, "postgres_branch") ?? undefined, + )?.branch; + if (!branch) return null; + const database = + parseResourceName( + variableOf(bundle, target, "postgres_database") ?? undefined, + )?.database ?? null; + // The default target's override wins over the top-level app definition. + const appName = [target, bundle] + .flatMap((scope) => + Object.values(record(record(scope?.resources)?.apps) ?? {}), + ) + .map((app) => record(app)?.name) + .find((name): name is string => typeof name === "string" && !!name); + return { branch, database, appName: appName ?? 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/clean-seed.test.ts b/packages/appkit/src/database/tooling/tests/clean-seed.test.ts new file mode 100644 index 000000000..0a92134ba --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/clean-seed.test.ts @@ -0,0 +1,187 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const { assertAppkitBranch, openBranchPool } = vi.hoisted(() => ({ + assertAppkitBranch: vi.fn(), + openBranchPool: vi.fn(), +})); + +vi.mock("../guard", async (importOriginal) => ({ + ...(await importOriginal()), + assertAppkitBranch, +})); +vi.mock("../context", async (importOriginal) => ({ + ...(await importOriginal()), + openBranchPool, +})); + +import { runClean } from "../clean"; +import { BranchGuardError } from "../guard"; +import { runSeed, SEED_FILE } from "../seed"; +import { cleanupTempApps, fakeContext, tempApp } from "./fakes"; + +const builder = path.resolve( + import.meta.dirname, + "../../schema-builder/index.ts", +); +const schemaFile = (schemaName: string) => + `import { defineSchema, id } from ${JSON.stringify(builder)}; + export const schema = defineSchema(({ table }) => ({ notes: table("notes", { id: id() }) }), { schemaName: ${JSON.stringify(schemaName)} });`; + +/** A pool whose one client records statements and can fail on one of them. */ +function recordingPool(failOn?: RegExp) { + const statements: string[] = []; + const client = { + released: undefined as { error: unknown } | undefined, + async query(text: string) { + statements.push(text); + if (failOn?.test(text)) throw new Error("boom"); + return { rows: [] }; + }, + release(error?: unknown) { + client.released = { error }; + }, + }; + const pool = { + connect: vi.fn(async () => client), + end: vi.fn(async () => undefined), + }; + return { pool, client, statements }; +} + +const devBranch = { name: "projects/p/branches/dev-alice-3f9a2c" }; + +beforeEach(() => { + assertAppkitBranch.mockReset(); + openBranchPool.mockReset(); +}); +afterEach(cleanupTempApps); + +describe("runClean", () => { + test("drops the app schema in one transaction on an AppKit branch", async () => { + const ctx = fakeContext({ + root: tempApp({ "config/database/schema.ts": schemaFile("app") }), + }); + assertAppkitBranch.mockResolvedValue(devBranch); + const { pool, client, statements } = recordingPool(); + openBranchPool.mockResolvedValue(pool); + + await expect(runClean(ctx)).resolves.toEqual({ + branch: devBranch.name, + schemaName: "app", + }); + expect(assertAppkitBranch).toHaveBeenCalledWith(ctx, "clean"); + expect(statements).toEqual([ + "BEGIN", + `DROP SCHEMA IF EXISTS "app" CASCADE`, + "COMMIT", + ]); + expect(client.released).toEqual({ error: undefined }); + expect(pool.end).toHaveBeenCalledOnce(); + }); + + test("without schema.ts it points to introspect before checking the branch", async () => { + const ctx = fakeContext({ root: tempApp() }); + + await expect(runClean(ctx)).rejects.toThrow( + "No config/database/schema.ts yet, so clean cannot tell which schema to drop. Run `appkit db introspect` to write it from the branch's tables, or create it.", + ); + expect(assertAppkitBranch).not.toHaveBeenCalled(); + expect(openBranchPool).not.toHaveBeenCalled(); + }); + + test("refuses a non-AppKit branch before connecting", async () => { + const ctx = fakeContext({ + root: tempApp({ "config/database/schema.ts": schemaFile("app") }), + }); + assertAppkitBranch.mockRejectedValue( + new BranchGuardError( + "clean", + "projects/p/branches/production", + "it is the project's default branch", + ), + ); + + await expect(runClean(ctx)).rejects.toThrow(/Refusing to clean production/); + expect(openBranchPool).not.toHaveBeenCalled(); + }); + + test("a failing statement rolls back and discards the connection", async () => { + const ctx = fakeContext({ + root: tempApp({ "config/database/schema.ts": schemaFile("app") }), + }); + assertAppkitBranch.mockResolvedValue(devBranch); + const { pool, client, statements } = recordingPool(/DROP SCHEMA/); + openBranchPool.mockResolvedValue(pool); + + await expect(runClean(ctx)).rejects.toThrow("boom"); + expect(statements.slice(-1)).toEqual(["ROLLBACK"]); + expect(client.released?.error).toBeInstanceOf(Error); + expect(pool.end).toHaveBeenCalledOnce(); + }); +}); + +describe("runSeed", () => { + test("runs config/database/seed.sql in one transaction on an AppKit branch, app schema first", async () => { + const sql = + "insert into app.notes default values;\ninsert into app.notes default values;\n"; + const root = tempApp({ [SEED_FILE]: sql }); + assertAppkitBranch.mockResolvedValue(devBranch); + const { pool, statements } = recordingPool(); + openBranchPool.mockResolvedValue(pool); + + await expect(runSeed(fakeContext({ root }))).resolves.toEqual({ + branch: devBranch.name, + file: path.join(root, SEED_FILE), + }); + expect(assertAppkitBranch).toHaveBeenCalledWith(expect.anything(), "seed"); + expect(statements).toEqual([ + "BEGIN", + 'SET LOCAL search_path TO "app", public', + sql, + "COMMIT", + ]); + }); + + test.each([ + ["sales", 'SET LOCAL search_path TO "sales", public'], + ["public", "SET LOCAL search_path TO public"], + ])( + "with schema.ts on %s, unqualified names resolve there", + async (schemaName, searchPath) => { + const root = tempApp({ + [SEED_FILE]: "insert into notes default values;", + "config/database/schema.ts": schemaFile(schemaName), + }); + assertAppkitBranch.mockResolvedValue(devBranch); + const { pool, statements } = recordingPool(); + openBranchPool.mockResolvedValue(pool); + + await runSeed(fakeContext({ root })); + + expect(statements.slice(0, 2)).toEqual(["BEGIN", searchPath]); + }, + ); + + test("a missing seed file is an error, before any API call", async () => { + await expect(runSeed(fakeContext({ root: tempApp() }))).rejects.toThrow( + "No seed file at config/database/seed.sql", + ); + expect(assertAppkitBranch).not.toHaveBeenCalled(); + }); + + test("refuses a non-AppKit branch before connecting", async () => { + const root = tempApp({ [SEED_FILE]: "select 1;" }); + assertAppkitBranch.mockRejectedValue( + new BranchGuardError("seed", "projects/p/branches/staging", "nope"), + ); + + await expect(runSeed(fakeContext({ root }))).rejects.toThrow( + /Refusing to seed staging/, + ); + expect(openBranchPool).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(root, SEED_FILE))).toBe(true); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/context.test.ts b/packages/appkit/src/database/tooling/tests/context.test.ts new file mode 100644 index 000000000..e7ac24dd5 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/context.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const { createWorkspaceClient, initializeLakebasePool } = vi.hoisted(() => ({ + createWorkspaceClient: vi.fn(), + initializeLakebasePool: vi.fn(), +})); + +vi.mock("../../../connectors/lakebase", () => ({ initializeLakebasePool })); +vi.mock("../../../workspace-client", () => ({ createWorkspaceClient })); + +import { getClientOptions } from "../../../context/client-options"; +import { createDbContext, openBranchPool } from "../context"; +import type { LakebaseApi } from "../lakebase-api"; +import { cleanupTempApps, FakeLakebase, fakeContext, tempApp } from "./fakes"; + +const BRANCH = "projects/p/branches/dev-alice-3f9a2c"; +const ENDPOINT = `${BRANCH}/endpoints/primary`; +const HOST = "primary.dev-alice-3f9a2c.example.com"; +const pool = { end: vi.fn() }; + +beforeEach(() => { + initializeLakebasePool.mockReset().mockResolvedValue(pool); + createWorkspaceClient.mockReset(); +}); +afterEach(cleanupTempApps); + +function contextFor(env: NodeJS.ProcessEnv, api: LakebaseApi) { + return fakeContext({ root: tempApp(), env, api }); +} + +describe("openBranchPool", () => { + test("null without PGHOST, PGDATABASE and LAKEBASE_ENDPOINT, before any API call", async () => { + const lakebase = new FakeLakebase(); + + await expect( + openBranchPool( + contextFor({ PGHOST: HOST, PGDATABASE: "db" }, lakebase.api()), + ), + ).resolves.toBeNull(); + expect(lakebase.calls).toEqual([]); + expect(initializeLakebasePool).not.toHaveBeenCalled(); + }); + + test("connects to PGHOST once it is a host of LAKEBASE_ENDPOINT", async () => { + const lakebase = new FakeLakebase(); + lakebase.addBranch(BRANCH); + const ctx = contextFor( + { + PGHOST: HOST.toUpperCase(), + PGDATABASE: "databricks_postgres", + LAKEBASE_ENDPOINT: ENDPOINT, + PGUSER: "alice@example.com", + }, + lakebase.api(), + ); + + await expect(openBranchPool(ctx)).resolves.toBe(pool); + expect(lakebase.calls).toEqual([`getEndpoint ${ENDPOINT}`]); + expect(initializeLakebasePool).toHaveBeenCalledWith( + expect.objectContaining({ + host: HOST.toUpperCase(), + database: "databricks_postgres", + endpoint: ENDPOINT, + user: "alice@example.com", + max: 2, + }), + ); + }); + + test("refuses another branch's host, a missing endpoint, or a failed lookup, and never connects", async () => { + const lakebase = new FakeLakebase(); + lakebase.addBranch(BRANCH); + const env = { + PGDATABASE: "databricks_postgres", + LAKEBASE_ENDPOINT: ENDPOINT, + }; + + await expect( + openBranchPool( + contextFor( + { ...env, PGHOST: "primary.production.example.com" }, + lakebase.api(), + ), + ), + ).rejects.toThrow( + `PGHOST primary.production.example.com is not a host of LAKEBASE_ENDPOINT ${ENDPOINT} (expected ${HOST}), so it did not connect. Run \`appkit db init\` to rewrite .env.`, + ); + await expect( + openBranchPool( + contextFor( + { + ...env, + PGHOST: HOST, + LAKEBASE_ENDPOINT: `${BRANCH}/endpoints/gone`, + }, + lakebase.api(), + ), + ), + ).rejects.toThrow( + `LAKEBASE_ENDPOINT ${BRANCH}/endpoints/gone does not exist, so it did not connect.`, + ); + + const lookupFailure = new Error("Lakebase: get endpoint failed: timeout"); + const failing = { + getEndpoint: async () => { + throw lookupFailure; + }, + } as unknown as LakebaseApi; + const error = (await openBranchPool( + contextFor({ ...env, PGHOST: HOST }, failing), + ).catch((caught: unknown) => caught)) as Error; + expect(error.message).toBe( + `Could not verify that PGHOST ${HOST} serves LAKEBASE_ENDPOINT ${ENDPOINT}, so it did not connect.`, + ); + expect(error.cause).toBe(lookupFailure); + + expect(initializeLakebasePool).not.toHaveBeenCalled(); + }); +}); + +describe("createDbContext", () => { + test("the workspace client uses the .env profile and AppKit's client options, once", () => { + const legacy = { apiClient: {}, currentUser: {} }; + createWorkspaceClient.mockReturnValue({ + toLegacyWorkspaceClient: () => legacy, + }); + const ctx = createDbContext({ + root: tempApp(), + env: { DATABRICKS_CONFIG_PROFILE: "dev" }, + }); + + expect(ctx.workspace()).toBe(legacy); + expect(ctx.workspace()).toBe(legacy); + expect(createWorkspaceClient).toHaveBeenCalledOnce(); + expect(createWorkspaceClient).toHaveBeenCalledWith({ + profile: "dev", + clientOptions: getClientOptions(), + }); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/drizzle-exports.test.ts b/packages/appkit/src/database/tooling/tests/drizzle-exports.test.ts new file mode 100644 index 000000000..cda0e8a9a --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/drizzle-exports.test.ts @@ -0,0 +1,47 @@ +import { is } from "drizzle-orm"; +import { isPgEnum, PgSchema, PgTable } from "drizzle-orm/pg-core"; +import { describe, expect, test } from "vitest"; + +import { defineSchema, enumColumn, fk, id, text } from "../../schema-builder"; +import { drizzleKitImports } from "../drizzle-exports"; + +describe("drizzleKitImports", () => { + const schema = defineSchema(({ table }) => { + const users = table("users", { + id: id(), + role: enumColumn("user_role", ["admin", "member"]), + backup: enumColumn("user_role", ["admin", "member"]), + }); + const posts = table("posts", { + id: id(), + author: fk(() => users.id), + title: text(), + }); + return { users, posts }; + }); + + test("exports every engine table and each referenced enum once", () => { + const values = Object.values(drizzleKitImports(schema)); + expect(values.filter((value) => is(value, PgTable))).toEqual([ + schema.$engine.users, + schema.$engine.posts, + ]); + const enums = values.filter((value) => isPgEnum(value)); + expect(enums).toHaveLength(1); + const [userRole] = enums; + expect(userRole.enumName).toBe("user_role"); + expect(userRole.schema).toBe("app"); + expect(userRole.enumValues).toEqual(["admin", "member"]); + }); + + test("never exports the pgSchema object, so drizzle-kit emits no CREATE SCHEMA", () => { + const values = Object.values(drizzleKitImports(schema)); + expect(values.some((value) => is(value, PgSchema))).toBe(false); + }); + + test("an empty schema has nothing to export", () => { + expect(Object.keys(drizzleKitImports(defineSchema(() => ({}))))).toEqual( + [], + ); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/drizzle-kit-dependency.test.ts b/packages/appkit/src/database/tooling/tests/drizzle-kit-dependency.test.ts new file mode 100644 index 000000000..ec9bcb2d1 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/drizzle-kit-dependency.test.ts @@ -0,0 +1,104 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { defineSchema, id } from "../../schema-builder"; + +const SRC = path.resolve(import.meta.dirname, "../../.."); +const folders: string[] = []; + +afterEach(() => { + vi.doUnmock("drizzle-kit/api"); + vi.resetModules(); + for (const folder of folders.splice(0)) { + fs.rmSync(folder, { recursive: true, force: true }); + } +}); + +/** Load ../migrations with `import("drizzle-kit/api")` failing like Node's resolver does. */ +async function withoutPackage(name: string) { + vi.resetModules(); + vi.doMock("drizzle-kit/api", () => { + throw Object.assign( + new Error( + `Cannot find package '${name}' imported from /app/node_modules/@databricks/appkit/dist/database/tooling/migrations.js`, + ), + { code: "ERR_MODULE_NOT_FOUND" }, + ); + }); + return import("../migrations"); +} + +function generate(migrations: typeof import("../migrations")) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-drizzle-kit-")); + folders.push(root); + return migrations.generateMigrationFiles({ + folder: path.join(root, "migrations"), + schema: defineSchema(({ table }) => ({ + notes: table("notes", { id: id() }), + })), + interactive: false, + }); +} + +/** Source files outside tests, relative to src/. */ +function sourceFiles(): string[] { + return (fs.readdirSync(SRC, { recursive: true }) as string[]).filter( + (file) => + /\.tsx?$/.test(file) && + !/\.test\.tsx?$/.test(file) && + !file.split(path.sep).includes("tests"), + ); +} + +describe("drizzle-kit dependency", () => { + test("without drizzle-kit, generating says how to install it", async () => { + const migrations = await withoutPackage("drizzle-kit"); + + await expect(generate(migrations)).rejects.toThrow( + `Generating migrations needs drizzle-kit, which is not installed. Add it as a dev dependency: npm install --save-dev drizzle-kit@${migrations.DRIZZLE_KIT_VERSION}`, + ); + }); + + test("another missing package is not reported as a missing drizzle-kit", async () => { + const migrations = await withoutPackage("esbuild"); + + const error = (await generate(migrations).catch( + (caught: unknown) => caught, + )) as Error; + expect(error.message).not.toContain("npm install"); + }); + + test("it is an optional peer dependency pinned to DRIZZLE_KIT_VERSION, never a dependency", async () => { + const { DRIZZLE_KIT_VERSION } = await import("../migrations"); + const pkg = JSON.parse( + fs.readFileSync(path.resolve(SRC, "../package.json"), "utf-8"), + ); + + expect(pkg.dependencies["drizzle-kit"]).toBeUndefined(); + expect(pkg.peerDependencies["drizzle-kit"]).toBe(DRIZZLE_KIT_VERSION); + expect(pkg.peerDependenciesMeta["drizzle-kit"]).toEqual({ optional: true }); + expect(pkg.devDependencies["drizzle-kit"]).toBe(DRIZZLE_KIT_VERSION); + }); + + test("only the tooling imports drizzle-kit, and no runtime module imports the tooling", () => { + const imports = (file: string, pattern: RegExp) => + pattern.test(fs.readFileSync(path.join(SRC, file), "utf-8")); + const tooling = path.join("database", "tooling"); + + expect( + sourceFiles().filter((file) => + imports(file, /(?:from\s+|import\s*\(\s*)["']drizzle-kit/), + ), + ).toEqual([path.join(tooling, "migrations.ts")]); + expect( + sourceFiles().filter( + (file) => + !file.startsWith(`${tooling}${path.sep}`) && + imports(file, /from\s+["'][^"']*\/tooling(?:\/[^"']*)?["']/), + ), + ).toEqual([]); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/env-file.test.ts b/packages/appkit/src/database/tooling/tests/env-file.test.ts new file mode 100644 index 000000000..2429c37a1 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/env-file.test.ts @@ -0,0 +1,116 @@ +import fs from "node:fs"; +import path from "node:path"; + +import dotenv from "dotenv"; +import { afterEach, describe, expect, test } from "vitest"; + +import { updateEnvFile } from "../env-file"; +import { cleanupTempApps, tempApp } from "./fakes"; + +afterEach(cleanupTempApps); + +describe("updateEnvFile", () => { + test("replaces values in place, keeps comments and order, appends new keys", () => { + const root = tempApp({ + ".env": [ + "# Databricks", + "DATABRICKS_CONFIG_PROFILE=DEFAULT", + "PGHOST='old-host'", + "", + "# keep me", + "LAKEBASE_ENDPOINT=projects/p/branches/old/endpoints/primary", + "APPKIT_DB_SESSION=abc", + "PGHOST=duplicate", + ].join("\n"), + }); + const file = path.join(root, ".env"); + + updateEnvFile(file, { + PGHOST: "new-host.example.com", + LAKEBASE_ENDPOINT: "projects/p/branches/dev-a/endpoints/primary", + OTHER_KEY: "value", + }); + + expect(fs.readFileSync(file, "utf-8")).toBe( + [ + "# Databricks", + "DATABRICKS_CONFIG_PROFILE=DEFAULT", + "PGHOST=new-host.example.com", + "", + "# keep me", + "LAKEBASE_ENDPOINT=projects/p/branches/dev-a/endpoints/primary", + "APPKIT_DB_SESSION=abc", + "OTHER_KEY=value", + "", + ].join("\n"), + ); + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + }); + + test("a missing .env starts from .env.example", () => { + const root = tempApp({ + ".env.example": "PGHOST=your_postgres_host\nPGPORT=5432\n", + }); + const file = path.join(root, ".env"); + + updateEnvFile( + file, + { PGHOST: "h.example.com" }, + path.join(root, ".env.example"), + ); + + expect(fs.readFileSync(file, "utf-8")).toBe( + "PGHOST=h.example.com\nPGPORT=5432\n", + ); + expect(fs.readFileSync(path.join(root, ".env.example"), "utf-8")).toContain( + "your_postgres_host", + ); + }); + + test("quotes values that need it", () => { + const root = tempApp(); + const file = path.join(root, ".env"); + updateEnvFile(file, { A: "has space", B: "plain@example.com" }); + expect(fs.readFileSync(file, "utf-8")).toBe( + 'A="has space"\nB=plain@example.com\n', + ); + }); + + test("keeps export prefixes and CRLF line endings", () => { + const root = tempApp({ + ".env": "# keep\r\nexport PGHOST=old\r\nPGPORT=5432\r\n", + }); + const file = path.join(root, ".env"); + + updateEnvFile(file, { PGHOST: "new.example.com", PGUSER: "a@example.com" }); + + expect(fs.readFileSync(file, "utf-8")).toBe( + "# keep\r\nexport PGHOST=new.example.com\r\nPGPORT=5432\r\nPGUSER=a@example.com\r\n", + ); + }); + + test("every value stays on one line and reads back unchanged", () => { + const root = tempApp({ ".env": "KEEP=1\n" }); + const file = path.join(root, ".env"); + const values = { + NEWLINE: "line1\nline2\r\nINJECTED=1", + QUOTES: 'say "hi"', + BACKSLASH: "back\\slash", + }; + + updateEnvFile(file, values); + + const text = fs.readFileSync(file, "utf-8"); + expect(text.split("\n").filter(Boolean)).toHaveLength(4); + expect(dotenv.parse(text)).toEqual({ KEEP: "1", ...values }); + }); + + test("a value dotenv cannot read back unchanged is refused", () => { + const root = tempApp(); + const file = path.join(root, ".env"); + expect(() => updateEnvFile(file, { BAD: `it's "x"\ny` })).toThrow( + "Cannot write BAD to .env: its value mixes quotes, backslashes and line breaks.", + ); + expect(fs.existsSync(file)).toBe(false); + }); +}); 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..5df053965 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/fakes.ts @@ -0,0 +1,225 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import type { DbContext } from "../context"; +import type { + BranchSpec, + LakebaseApi, + LakebaseBranch, + LakebaseDatabase, + LakebaseEndpoint, + LakebaseRole, + RoleSpec, +} from "../lakebase-api"; +import { LakebaseApiError } from "../lakebase-api"; + +/** In-memory Lakebase with the semantics init relies on: forks copy roles, endpoints come with new forks. */ +export class FakeLakebase { + readonly branches = new Map(); + readonly endpoints = new Map(); + readonly roles = new Map(); + readonly databases = new Map(); + readonly calls: string[] = []; + /** New forks get a read-write `primary` endpoint, like Lakebase forks do. */ + autoEndpoint = true; + createBranchError?: Error; + + 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")]); + this.roles.set(name, []); + return branch; + } + + addRole( + branch: string, + roleId: string, + postgresRole: string, + memberships: string[] = [], + ) { + const role: LakebaseRole = { + name: `${branch}/roles/${roleId}`, + status: { postgres_role: postgresRole, membership_roles: memberships }, + }; + this.roles.get(branch)?.push(role); + return role; + } + + endpoint(branch: string, id: string): LakebaseEndpoint { + return { + name: `${branch}/endpoints/${id}`, + status: { + endpoint_type: "ENDPOINT_TYPE_READ_WRITE", + 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 + ); + }, + async listBranches(project: string) { + fake.calls.push(`listBranches ${project}`); + return [...fake.branches.values()].filter((b) => + b.name.startsWith(`${project}/`), + ); + }, + async createBranch(project: string, branchId: string, spec: BranchSpec) { + fake.calls.push(`createBranch ${branchId} ${JSON.stringify(spec)}`); + if (fake.createBranchError) throw fake.createBranchError; + const name = `${project}/branches/${branchId}`; + const branch = { + name, + status: { current_state: "READY", source_branch: spec.source_branch }, + }; + fake.branches.set(name, branch); + fake.endpoints.set( + name, + fake.autoEndpoint ? [fake.endpoint(name, "primary")] : [], + ); + // A fork copies the parent's roles. + fake.roles.set( + name, + (fake.roles.get(spec.source_branch) ?? []).map((role) => ({ + ...role, + name: `${name}/roles/${role.name.split("/").pop()}`, + })), + ); + return branch; + }, + async listEndpoints(branch: string) { + fake.calls.push(`listEndpoints ${branch}`); + return fake.endpoints.get(branch) ?? []; + }, + async createEndpoint(branch: string, endpointId: string) { + fake.calls.push(`createEndpoint ${branch} ${endpointId}`); + const endpoint = fake.endpoint(branch, endpointId); + fake.endpoints.set(branch, [ + ...(fake.endpoints.get(branch) ?? []), + endpoint, + ]); + return endpoint; + }, + async getDatabase(name: string) { + fake.calls.push(`getDatabase ${name}`); + return fake.databases.get(name) ?? null; + }, + async listRoles(branch: string) { + fake.calls.push(`listRoles ${branch}`); + return [...(fake.roles.get(branch) ?? [])]; + }, + async createRole(branch: string, spec: RoleSpec) { + fake.calls.push(`createRole ${branch} ${JSON.stringify(spec)}`); + return fake.addRole(branch, "rol-generated", spec.postgres_role, [ + ...spec.membership_roles, + ]); + }, + async setRoleMemberships(role: string, memberships: string[]) { + fake.calls.push(`setRoleMemberships ${role} ${memberships.join(",")}`); + const branch = role.slice(0, role.indexOf("/roles/")); + const found = fake.roles.get(branch)?.find((r) => r.name === role); + if (!found?.status) throw new Error(`no role ${role}`); + found.status.membership_roles = [...memberships]; + return found; + }, + async deleteRole(role: string, options: { reassignOwnedTo: string }) { + fake.calls.push(`deleteRole ${role} → ${options.reassignOwnedTo}`); + const branch = role.slice(0, role.indexOf("/roles/")); + fake.roles.set( + branch, + (fake.roles.get(branch) ?? []).filter((r) => r.name !== role), + ); + }, + }; + return api as unknown as LakebaseApi; + } +} + +export const forbidden = () => + new LakebaseApiError("create branch", "PERMISSION_DENIED", { + statusCode: 403, + }); + +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..23899dc10 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/guard.test.ts @@ -0,0 +1,244 @@ +import { afterEach, describe, expect, test } from "vitest"; + +import { + assertAppkitBranch, + BranchGuardError, + checkAppkitBranch, + classifyBranch, +} from "../guard"; +import { devBranchId } from "../naming"; +import { + cleanupTempApps, + databricksYml, + FakeLakebase, + fakeContext, + tempApp, +} from "./fakes"; + +afterEach(cleanupTempApps); + +const USER = "alice@example.com"; +const P = "projects/p"; +const dev = `${P}/branches/${devBranchId(USER)}`; + +function verdict( + name: string, + status = {}, + sessionId: string | null = "k2x9q0ab", + deployBranches: string[] = [], +) { + return classifyBranch({ + branch: { name, status }, + userName: USER, + sessionId, + deployBranches, + }); +} + +describe("classifyBranch", () => { + test("only this developer's dev branch and this checkout's session branch are AppKit-owned", () => { + expect(verdict(dev)).toEqual({ appkitOwned: true }); + expect(verdict(`${P}/branches/session-k2x9q0ab`)).toEqual({ + appkitOwned: true, + }); + }); + + test("everything else is refused, with the reason", () => { + expect( + verdict(`${P}/branches/production`, { default: true }), + ).toMatchObject({ + appkitOwned: false, + reason: "it is the project's default branch", + }); + expect(verdict(dev, { is_protected: true })).toMatchObject({ + appkitOwned: false, + reason: "it is a protected branch", + }); + expect( + verdict(dev, {}, null, [`${P}/branches/staging`, dev]), + ).toMatchObject({ + appkitOwned: false, + reason: "it is a branch databricks.yml deploys to", + }); + expect( + verdict(`${P}/branches/${devBranchId("bob@example.com")}`).appkitOwned, + ).toBe(false); + expect(verdict(`${P}/branches/session-other`).appkitOwned).toBe(false); + expect( + verdict(`${P}/branches/session-k2x9q0ab`, {}, null).appkitOwned, + ).toBe(false); + expect(verdict(`${P}/branches/staging`).reason).toContain( + devBranchId(USER), + ); + }); +}); + +describe("checkAppkitBranch", () => { + const envFor = (branch: string) => ({ + LAKEBASE_ENDPOINT: `${branch}/endpoints/primary`, + }); + + test("classifies the .env branch through the API, without connecting", async () => { + const lakebase = new FakeLakebase(); + lakebase.addBranch(dev); + lakebase.addBranch(`${P}/branches/production`, { default: true }); + + const onDev = await checkAppkitBranch( + fakeContext({ root: tempApp(), env: envFor(dev), api: lakebase.api() }), + ); + expect(onDev).toMatchObject({ + branch: { name: dev }, + verdict: { appkitOwned: true }, + }); + + const onDefault = await checkAppkitBranch( + fakeContext({ + root: tempApp(), + env: envFor(`${P}/branches/production`), + api: lakebase.api(), + }), + ); + expect(onDefault?.verdict).toEqual({ + appkitOwned: false, + reason: "it is the project's default branch", + }); + expect(lakebase.calls.every((call) => call.startsWith("getBranch"))).toBe( + true, + ); + }); + + test("a branch databricks.yml deploys to is never AppKit-owned", async () => { + const lakebase = new FakeLakebase(); + lakebase.addBranch(dev); + const root = tempApp({ + "databricks.yml": databricksYml( + dev, + `${dev}/databases/databricks-postgres`, + ), + }); + + const check = await checkAppkitBranch( + fakeContext({ root, env: envFor(dev), api: lakebase.api() }), + ); + + expect(check?.verdict).toEqual({ + appkitOwned: false, + reason: "it is a branch databricks.yml deploys to", + }); + }); + + test("a non-default target counts too, and an unreadable databricks.yml refuses", async () => { + const lakebase = new FakeLakebase(); + lakebase.addBranch(dev); + const personalTarget = tempApp({ + "databricks.yml": `${databricksYml(`${P}/branches/production`, `${P}/branches/production/databases/db`)} personal: + variables: + postgres_branch: ${dev} +`, + }); + const broken = tempApp({ "databricks.yml": "targets:\n default: [\n" }); + + const check = await checkAppkitBranch( + fakeContext({ + root: personalTarget, + env: envFor(dev), + api: lakebase.api(), + }), + ); + expect(check?.verdict).toEqual({ + appkitOwned: false, + reason: "it is a branch databricks.yml deploys to", + }); + await expect( + checkAppkitBranch( + fakeContext({ root: broken, env: envFor(dev), api: lakebase.api() }), + ), + ).rejects.toThrow( + /^Cannot read databricks\.yml, so the branches it deploys to are unknown: /, + ); + }); + + test("null without a configured branch; a deleted branch points at init", async () => { + const lakebase = new FakeLakebase(); + await expect( + checkAppkitBranch(fakeContext({ root: tempApp(), api: lakebase.api() })), + ).resolves.toBeNull(); + await expect( + checkAppkitBranch( + fakeContext({ root: tempApp(), env: envFor(dev), api: lakebase.api() }), + ), + ).rejects.toThrow(/does not exist.*appkit db init/); + }); +}); + +describe("assertAppkitBranch", () => { + const envFor = (branch: string) => ({ + LAKEBASE_ENDPOINT: `${branch}/endpoints/primary`, + }); + + test("passes the .env branch when it is the developer's dev branch", async () => { + const lakebase = new FakeLakebase(); + lakebase.addBranch(dev); + const ctx = fakeContext({ + root: tempApp(), + env: envFor(dev), + api: lakebase.api(), + }); + + await expect(assertAppkitBranch(ctx, "clean")).resolves.toMatchObject({ + name: dev, + }); + }); + + test("refuses the default branch and the deploy branch, using only the API", async () => { + const lakebase = new FakeLakebase(); + lakebase.addBranch(`${P}/branches/production`, { default: true }); + lakebase.addBranch(dev); + + const onDefault = fakeContext({ + root: tempApp(), + env: envFor(`${P}/branches/production`), + api: lakebase.api(), + }); + const error = await assertAppkitBranch(onDefault, "clean").catch( + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(BranchGuardError); + expect((error as Error).message).toBe( + "Refusing to clean production: it is the project's default branch. `appkit db clean` only writes to branches `appkit db init` created for you (dev-… or session-…).", + ); + + const deployRoot = tempApp({ + "databricks.yml": databricksYml( + dev, + `${dev}/databases/databricks-postgres`, + ), + }); + const onDeploy = fakeContext({ + root: deployRoot, + env: envFor(dev), + api: lakebase.api(), + }); + await expect(assertAppkitBranch(onDeploy, "seed")).rejects.toThrow( + /deploys to/, + ); + expect(lakebase.calls.every((call) => call.startsWith("getBranch"))).toBe( + true, + ); + }); + + test("without a configured branch it refuses, and a deleted branch points at init", async () => { + const lakebase = new FakeLakebase(); + await expect( + assertAppkitBranch( + fakeContext({ root: tempApp(), api: lakebase.api() }), + "seed", + ), + ).rejects.toThrow(/no branch configured/); + await expect( + checkAppkitBranch( + fakeContext({ root: tempApp(), env: envFor(dev), api: lakebase.api() }), + ), + ).rejects.toThrow(/does not exist.*appkit db init/); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/identity.test.ts b/packages/appkit/src/database/tooling/tests/identity.test.ts new file mode 100644 index 000000000..49bf76fd6 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/identity.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { currentIdentity, identityTypeOf } from "../identity"; +import { cleanupTempApps, fakeContext, tempApp } from "./fakes"; + +afterEach(cleanupTempApps); + +describe("identity", () => { + test("an email is a USER, a client ID is a SERVICE_PRINCIPAL", () => { + expect(identityTypeOf("alice@example.com")).toBe("USER"); + expect(identityTypeOf("11111111-2222-3333-4444-555555555555")).toBe( + "SERVICE_PRINCIPAL", + ); + }); + + 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", + identityType: "USER", + }); + 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/init.test.ts b/packages/appkit/src/database/tooling/tests/init.test.ts new file mode 100644 index 000000000..710a636ca --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/init.test.ts @@ -0,0 +1,656 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const { openBranchPool, runClean, runIntrospect, runMigrate } = vi.hoisted( + () => ({ + openBranchPool: vi.fn(), + runClean: vi.fn(), + runIntrospect: vi.fn(), + runMigrate: vi.fn(), + }), +); + +vi.mock("../context", async (importOriginal) => ({ + ...(await importOriginal()), + openBranchPool, +})); +vi.mock("../migrate", async (importOriginal) => ({ + ...(await importOriginal()), + runMigrate, +})); +vi.mock("../clean", () => ({ runClean })); +vi.mock("../introspect", () => ({ runIntrospect })); + +import { + currentBranchMode, + defaultSchemaStart, + resolveInitDefaults, + runInit, + schemaStartsFor, +} from "../init"; +import { LakebaseApiError } from "../lakebase-api"; +import { devBranchId } from "../naming"; +import { + cleanupTempApps, + databricksYml, + FakeLakebase, + fakeContext, + forbidden, + tempApp, +} from "./fakes"; + +const USER = "alice@example.com"; +const SP = "11111111-2222-3333-4444-555555555555"; +const P = "projects/p"; +const PRODUCTION = `${P}/branches/production`; +const DEV = `${P}/branches/${devBranchId(USER)}`; +const migrateReport = { + generated: null, + applied: [], + typesFile: "/app/shared/appkit-types/database.d.ts", +}; + +/** pg_catalog owners the hand-off sees on the new branch. */ +let owners: string[]; + +function project(): FakeLakebase { + const lakebase = new FakeLakebase(); + lakebase.addBranch(PRODUCTION, { default: true }); + lakebase.addRole(PRODUCTION, "alice", USER, ["DATABRICKS_SUPERUSER"]); + lakebase.addRole(PRODUCTION, `dbrx-apps-${SP}`, SP); + lakebase.addRole(PRODUCTION, "dbrx-apps-user-bob", "bob@example.com"); + return lakebase; +} + +/** An app that deploys to `deploy` (the default branch unless set), with .env pointing at the default branch. */ +function appOn( + lakebase: FakeLakebase, + env: Record = {}, + { + schema = true, + deploy = PRODUCTION, + }: { schema?: boolean; deploy?: string } = {}, +) { + const dotenv = { + DATABRICKS_CONFIG_PROFILE: "DEFAULT", + PGHOST: "primary.production.example.com", + PGDATABASE: "databricks_postgres", + LAKEBASE_ENDPOINT: `${PRODUCTION}/endpoints/primary`, + ...env, + }; + const root = tempApp({ + ".env": `# Lakebase\n${Object.entries(dotenv) + .map(([key, value]) => `${key}=${value}`) + .join("\n")}\n`, + "databricks.yml": databricksYml( + deploy, + `${deploy}/databases/databricks-postgres`, + ), + // migrate and clean (mocked here) need the file to exist. + ...(schema + ? { "config/database/schema.ts": "export const schema = {};\n" } + : {}), + }); + return fakeContext({ + root, + env: dotenv, + userName: USER, + api: lakebase.api(), + }); +} + +function envFile(root: string): Record { + return Object.fromEntries( + fs + .readFileSync(path.join(root, ".env"), "utf-8") + .split("\n") + .filter((line) => line.includes("=")) + .map((line) => [ + line.slice(0, line.indexOf("=")), + line.slice(line.indexOf("=") + 1), + ]), + ); +} + +const pool = { + query: vi.fn(async () => ({ rows: owners.map((owner) => ({ owner })) })), + end: vi.fn(async () => undefined), +}; + +beforeEach(() => { + owners = [SP, "pg_database_owner", "databricks_control_plane"]; + openBranchPool.mockReset().mockResolvedValue(pool); + runMigrate.mockReset().mockResolvedValue(migrateReport); + runClean.mockReset().mockResolvedValue({ branch: DEV, schemaName: "app" }); + runIntrospect.mockReset().mockResolvedValue({ branch: DEV, tables: [] }); + pool.query.mockClear(); + pool.end.mockClear(); +}); +afterEach(cleanupTempApps); + +describe("runInit — dev branch", () => { + test("forks the default branch with no expiry, hands the SP's objects to the developer, rewrites .env, migrates", async () => { + const lakebase = project(); + const ctx = appOn(lakebase); + + const report = await runInit(ctx, { mode: "dev", schemaStart: "migrate" }); + + expect(lakebase.calls).toContain( + `createBranch ${devBranchId(USER)} ${JSON.stringify({ source_branch: PRODUCTION, no_expiry: true })}`, + ); + // Only the role-API role that owns objects is reassigned; system owners are ignored. + expect( + lakebase.calls.filter((call) => call.startsWith("deleteRole")), + ).toEqual([`deleteRole ${DEV}/roles/dbrx-apps-${SP} → ${DEV}/roles/alice`]); + expect(report).toMatchObject({ + mode: "dev", + branch: DEV, + created: true, + sourceBranch: PRODUCTION, + endpoint: `${DEV}/endpoints/primary`, + endpointCreated: false, + host: `primary.${devBranchId(USER)}.example.com`, + database: "databricks_postgres", + user: USER, + role: { name: `${DEV}/roles/alice`, action: "kept" }, + handedOff: [SP], + cleaned: false, + schema: migrateReport, + }); + + const written = envFile(ctx.root); + expect(written).toMatchObject({ + DATABRICKS_CONFIG_PROFILE: "DEFAULT", + LAKEBASE_PROJECT: P, + PGHOST: `primary.${devBranchId(USER)}.example.com`, + LAKEBASE_ENDPOINT: `${DEV}/endpoints/primary`, + PGDATABASE: "databricks_postgres", + PGUSER: USER, + PGPORT: "5432", + PGSSLMODE: "require", + }); + expect(written.APPKIT_DB_SESSION).toBeUndefined(); + expect(fs.readFileSync(path.join(ctx.root, ".env"), "utf-8")).toMatch( + /^# Lakebase\n/, + ); + // The running context now targets the new branch (the hand-off pool and migrate read it). + expect(ctx.env.LAKEBASE_ENDPOINT).toBe(`${DEV}/endpoints/primary`); + expect(runMigrate).toHaveBeenCalledWith(ctx, { interactive: false }); + expect(pool.end).toHaveBeenCalledOnce(); + }); + + test("a re-run reuses the dev branch; with nothing owned by others there is no hand-off", async () => { + const lakebase = project(); + lakebase.addBranch(DEV); + lakebase.addRole(DEV, "alice", USER, ["DATABRICKS_SUPERUSER"]); + owners = ["pg_database_owner"]; + + const report = await runInit(appOn(lakebase), { + mode: "dev", + schemaStart: null, + }); + + expect(report.created).toBe(false); + expect(report.handedOff).toEqual([]); + expect( + lakebase.calls.some((call) => + /^(createBranch|deleteRole|createRole)/.test(call), + ), + ).toBe(false); + expect(runMigrate).not.toHaveBeenCalled(); + }); + + test("creates a read-write endpoint when the branch has none", async () => { + const lakebase = project(); + lakebase.autoEndpoint = false; + + const report = await runInit(appOn(lakebase), { + mode: "dev", + schemaStart: null, + }); + + expect(lakebase.calls).toContain(`createEndpoint ${DEV} primary`); + expect(report).toMatchObject({ + endpoint: `${DEV}/endpoints/primary`, + endpointCreated: true, + }); + }); + + test("creates the developer role without a role_id when missing, and patches a role without the superuser membership", async () => { + const lakebase = project(); + lakebase.addBranch(DEV); + owners = []; + const created = await runInit(appOn(lakebase), { + mode: "dev", + schemaStart: null, + }); + expect(lakebase.calls).toContain( + `createRole ${DEV} ${JSON.stringify({ + identity_type: "USER", + postgres_role: USER, + auth_method: "LAKEBASE_OAUTH_V1", + membership_roles: ["DATABRICKS_SUPERUSER"], + })}`, + ); + expect(created.role.action).toBe("created"); + + const patched = project(); + patched.addBranch(DEV); + patched.addRole(DEV, "alice", USER, []); + const report = await runInit(appOn(patched), { + mode: "dev", + schemaStart: null, + }); + expect(patched.calls).toContain( + `setRoleMemberships ${DEV}/roles/alice DATABRICKS_SUPERUSER`, + ); + expect(report.role.action).toBe("updated"); + }); + + test("a 403 on branch creation names the CAN MANAGE requirement", async () => { + const lakebase = project(); + lakebase.createBranchError = forbidden(); + + await expect( + runInit(appOn(lakebase), { mode: "dev", schemaStart: null }), + ).rejects.toThrow( + /needs CAN MANAGE on projects\/p\. Ask a project owner for it\.$/, + ); + }); + + test("when another run creates the dev branch first, the 409 reuses it", async () => { + const lakebase = project(); + const ctx = fakeContext({ + root: appOn(lakebase).root, + env: { + DATABRICKS_CONFIG_PROFILE: "DEFAULT", + LAKEBASE_ENDPOINT: `${PRODUCTION}/endpoints/primary`, + }, + userName: USER, + api: Object.assign(lakebase.api(), { + async createBranch() { + lakebase.addBranch(DEV); + lakebase.addRole(DEV, "alice", USER, ["DATABRICKS_SUPERUSER"]); + throw new LakebaseApiError(`create branch ${DEV}`, "ALREADY_EXISTS", { + statusCode: 409, + }); + }, + }), + }); + + const report = await runInit(ctx, { mode: "dev", schemaStart: null }); + + expect(report).toMatchObject({ branch: DEV, created: false }); + expect(envFile(ctx.root).LAKEBASE_ENDPOINT).toBe( + `${DEV}/endpoints/primary`, + ); + }); +}); + +describe("runInit — session branch", () => { + test("generates a session id, forks with a 7-day TTL, and stores APPKIT_DB_SESSION", async () => { + const lakebase = project(); + const ctx = appOn(lakebase); + + const report = await runInit(ctx, { mode: "session", schemaStart: null }); + + const sessionId = envFile(ctx.root).APPKIT_DB_SESSION; + expect(sessionId).toMatch(/^[0-9a-z]{8}$/); + expect(report.branch).toBe(`${P}/branches/session-${sessionId}`); + expect(lakebase.calls).toContain( + `createBranch session-${sessionId} ${JSON.stringify({ source_branch: PRODUCTION, ttl: "604800s" })}`, + ); + expect(report.handedOff).toEqual([SP]); + }); + + test("the session id is in .env before the branch is created, so a failed run's rerun reuses it", async () => { + const lakebase = project(); + lakebase.createBranchError = new Error("operation timed out"); + const ctx = appOn(lakebase); + + await expect( + runInit(ctx, { mode: "session", schemaStart: null }), + ).rejects.toThrow("operation timed out"); + const sessionId = envFile(ctx.root).APPKIT_DB_SESSION; + expect(sessionId).toMatch(/^[0-9a-z]{8}$/); + + lakebase.createBranchError = undefined; + const rerun = await runInit( + fakeContext({ + root: ctx.root, + env: envFile(ctx.root), + userName: USER, + api: lakebase.api(), + }), + { mode: "session", schemaStart: null }, + ); + expect(rerun.branch).toBe(`${P}/branches/session-${sessionId}`); + expect( + lakebase.calls.filter((call) => call.startsWith("createBranch")), + ).toEqual([ + `createBranch session-${sessionId} ${JSON.stringify({ source_branch: PRODUCTION, ttl: "604800s" })}`, + `createBranch session-${sessionId} ${JSON.stringify({ source_branch: PRODUCTION, ttl: "604800s" })}`, + ]); + }); + + test("reuses this checkout's session branch, and replaces an expired one with a fresh session", async () => { + const lakebase = project(); + lakebase.addBranch(`${P}/branches/session-k2x9q0ab`); + lakebase.addRole(`${P}/branches/session-k2x9q0ab`, "alice", USER, [ + "DATABRICKS_SUPERUSER", + ]); + const reused = await runInit( + appOn(lakebase, { APPKIT_DB_SESSION: "k2x9q0ab" }), + { + mode: "session", + schemaStart: null, + }, + ); + expect(reused).toMatchObject({ + branch: `${P}/branches/session-k2x9q0ab`, + created: false, + }); + + const expired = project(); + expired.addBranch(`${P}/branches/session-k2x9q0ab`, { + expire_time: "2020-01-01T00:00:00Z", + }); + const ctx = appOn(expired, { APPKIT_DB_SESSION: "k2x9q0ab" }); + const fresh = await runInit(ctx, { mode: "session", schemaStart: null }); + expect(fresh.created).toBe(true); + expect(fresh.branch).not.toBe(`${P}/branches/session-k2x9q0ab`); + expect(envFile(ctx.root).APPKIT_DB_SESSION).not.toBe("k2x9q0ab"); + }); +}); + +describe("runInit — branches it never sets up", () => { + test.each([ + [ + "a dev branch databricks.yml deploys to", + {}, + DEV, + /it is a branch databricks\.yml deploys to/, + ], + [ + "a protected dev branch", + { is_protected: true }, + PRODUCTION, + /it is a protected branch/, + ], + ] as const)( + "%s: refused before any change", + async (_label, status, deploy, reason) => { + const lakebase = project(); + lakebase.addBranch(DEV, status); + // A role without the superuser membership and objects owned by the SP: nothing may change either. + lakebase.addRole(DEV, "alice", USER, []); + lakebase.addRole(DEV, `dbrx-apps-${SP}`, SP); + lakebase.endpoints.set(DEV, []); + const ctx = appOn(lakebase, {}, { deploy }); + const envBefore = fs.readFileSync(path.join(ctx.root, ".env"), "utf-8"); + + const failure = runInit(ctx, { mode: "dev", schemaStart: "clean" }); + + await expect(failure).rejects.toThrow(reason); + await expect(failure).rejects.toThrow( + new RegExp( + `^Refusing to set up ${devBranchId(USER)}: .*\\. \`appkit db init\` only sets up your dev branch or this checkout's session branch, never a branch the app deploys to\\.$`, + ), + ); + expect( + lakebase.calls.filter((call) => + /^(createBranch|createEndpoint|createRole|setRoleMemberships|deleteRole)/.test( + call, + ), + ), + ).toEqual([]); + expect(openBranchPool).not.toHaveBeenCalled(); + expect(runClean).not.toHaveBeenCalled(); + expect(fs.readFileSync(path.join(ctx.root, ".env"), "utf-8")).toBe( + envBefore, + ); + }, + ); +}); + +describe("runInit — introspect start", () => { + test("runs introspect on the branch init pointed .env at", async () => { + const lakebase = project(); + const ctx = appOn(lakebase); + + const report = await runInit( + ctx, + { mode: "dev", schemaStart: "introspect" }, + { interactive: true }, + ); + + expect(runIntrospect).toHaveBeenCalledWith(ctx, { interactive: true }); + expect(ctx.env.LAKEBASE_ENDPOINT).toBe(`${DEV}/endpoints/primary`); + expect(runMigrate).not.toHaveBeenCalled(); + expect(report).toMatchObject({ + introspect: { branch: DEV }, + schema: null, + }); + }); + + test("with schema.ts every start is offered and migrate is the default", () => { + expect(defaultSchemaStart(true)).toBe("migrate"); + expect(schemaStartsFor(true)).toEqual(["migrate", "introspect", "clean"]); + }); + + test("without schema.ts only introspect can run", () => { + expect(schemaStartsFor(false)).toEqual(["introspect"]); + expect(defaultSchemaStart(false)).toBe("introspect"); + }); + + test("resolveInitDefaults reports whether schema.ts exists", async () => { + const lakebase = project(); + expect((await resolveInitDefaults(appOn(lakebase))).hasSchema).toBe(true); + expect( + (await resolveInitDefaults(appOn(lakebase, {}, { schema: false }))) + .hasSchema, + ).toBe(false); + }); + + test("migrate or clean without schema.ts fails before any change", async () => { + for (const schemaStart of ["migrate", "clean"] as const) { + const lakebase = project(); + const ctx = appOn(lakebase, {}, { schema: false }); + const envBefore = fs.readFileSync(path.join(ctx.root, ".env"), "utf-8"); + + await expect( + runInit(ctx, { mode: "dev", schemaStart }, { interactive: false }), + ).rejects.toThrow( + /^No config\/database\/schema\.ts yet, so .*Run `appkit db introspect` to write it from the branch's tables, or create it\.$/, + ); + expect(lakebase.calls).toEqual([]); + expect(fs.readFileSync(path.join(ctx.root, ".env"), "utf-8")).toBe( + envBefore, + ); + } + }); +}); + +describe("currentBranchMode — the mode follows from the .env branch", () => { + const endpointOf = (branch: string) => `${branch}/endpoints/primary`; + + test.each([ + ["your dev branch", { LAKEBASE_ENDPOINT: endpointOf(DEV) }, "dev"], + [ + "this checkout's session branch", + { + LAKEBASE_ENDPOINT: endpointOf(`${P}/branches/session-k2x9q0ab`), + APPKIT_DB_SESSION: "k2x9q0ab", + }, + "session", + ], + [ + "someone else's session branch", + { + LAKEBASE_ENDPOINT: endpointOf(`${P}/branches/session-zzzz0000`), + APPKIT_DB_SESSION: "k2x9q0ab", + }, + null, + ], + ["the default branch", { LAKEBASE_ENDPOINT: endpointOf(PRODUCTION) }, null], + [ + "any other branch", + { LAKEBASE_ENDPOINT: endpointOf(`${P}/branches/staging`) }, + null, + ], + ] as const)("%s → %s", async (_label, env, mode) => { + const lakebase = project(); + expect(await currentBranchMode(appOn(lakebase, env))).toBe(mode); + expect((await resolveInitDefaults(appOn(lakebase, env))).mode).toBe(mode); + }); + + test("null without an endpoint, or with one from another project than LAKEBASE_PROJECT", async () => { + const lakebase = project(); + expect( + await currentBranchMode( + appOn(lakebase, { LAKEBASE_ENDPOINT: "your_postgres_endpointPath" }), + ), + ).toBeNull(); + expect( + await currentBranchMode( + appOn(lakebase, { + LAKEBASE_PROJECT: "projects/q", + LAKEBASE_ENDPOINT: endpointOf(DEV), + }), + ), + ).toBeNull(); + }); +}); + +describe("runInit — clean start", () => { + test("on an AppKit branch: clean, then apply the committed migrations without generating", async () => { + const lakebase = project(); + const ctx = appOn(lakebase); + + const report = await runInit(ctx, { mode: "dev", schemaStart: "clean" }); + + expect(runClean).toHaveBeenCalledWith(ctx); + expect(runMigrate).toHaveBeenCalledWith(ctx, { + interactive: false, + generate: false, + }); + expect(runClean.mock.invocationCallOrder[0]).toBeLessThan( + runMigrate.mock.invocationCallOrder[0] ?? 0, + ); + expect(report).toMatchObject({ cleaned: true, schema: migrateReport }); + }); +}); + +describe("runInit — project and database from databricks.yml", () => { + test("a fresh app (.env from .env.example placeholders) resolves both from the bundle", async () => { + const lakebase = project(); + lakebase.databases.set(`${DEV}/databases/databricks-postgres`, { + name: `${DEV}/databases/databricks-postgres`, + status: { postgres_database: "databricks_postgres" }, + }); + const root = tempApp({ + ".env.example": + "PGHOST=your_postgres_host\nLAKEBASE_ENDPOINT=your_postgres_endpointPath\nPGPORT=5432\nPGSSLMODE=require\n", + "databricks.yml": databricksYml( + PRODUCTION, + `${PRODUCTION}/databases/databricks-postgres`, + ), + }); + const ctx = fakeContext({ root, userName: USER, api: lakebase.api() }); + + const report = await runInit(ctx, { mode: "dev", schemaStart: null }); + + expect(report.branch).toBe(DEV); + expect(lakebase.calls).toContain( + `getDatabase ${DEV}/databases/databricks-postgres`, + ); + expect(envFile(root)).toMatchObject({ + PGHOST: `primary.${devBranchId(USER)}.example.com`, + LAKEBASE_ENDPOINT: `${DEV}/endpoints/primary`, + PGDATABASE: "databricks_postgres", + PGPORT: "5432", + PGSSLMODE: "require", + }); + }); + + test("no project anywhere is a clear error", async () => { + const ctx = fakeContext({ + root: tempApp(), + userName: USER, + api: project().api(), + }); + await expect( + runInit(ctx, { mode: "dev", schemaStart: null }), + ).rejects.toThrow( + /No Lakebase project configured\. Set LAKEBASE_PROJECT=projects\/ in \.env/, + ); + }); +}); + +describe("runInit — LAKEBASE_PROJECT", () => { + test.each([P, "p"])( + "a .env with only the profile and LAKEBASE_PROJECT=%s is enough", + async (value) => { + const lakebase = project(); + const root = tempApp({ + ".env": `DATABRICKS_CONFIG_PROFILE=DEFAULT\nLAKEBASE_PROJECT=${value}\n`, + }); + const ctx = fakeContext({ + root, + env: { DATABRICKS_CONFIG_PROFILE: "DEFAULT", LAKEBASE_PROJECT: value }, + userName: USER, + api: lakebase.api(), + }); + + const report = await runInit(ctx, { mode: "dev", schemaStart: null }); + + expect(report).toMatchObject({ + branch: DEV, + created: true, + sourceBranch: PRODUCTION, + database: "databricks_postgres", + }); + expect(envFile(root)).toEqual({ + DATABRICKS_CONFIG_PROFILE: "DEFAULT", + LAKEBASE_PROJECT: P, + PGHOST: `primary.${devBranchId(USER)}.example.com`, + LAKEBASE_ENDPOINT: `${DEV}/endpoints/primary`, + PGDATABASE: "databricks_postgres", + PGUSER: USER, + PGPORT: "5432", + PGSSLMODE: "require", + }); + }, + ); + + test("it wins over LAKEBASE_ENDPOINT and databricks.yml; their values from another project are ignored", async () => { + const lakebase = project(); + const otherProduction = "projects/q/branches/production"; + const otherDev = `projects/q/branches/${devBranchId(USER)}`; + lakebase.addBranch(otherProduction, { default: true }); + lakebase.addRole(otherProduction, "alice", USER, ["DATABRICKS_SUPERUSER"]); + // .env and the bundle still name project p, with a database q does not have. + const ctx = appOn(lakebase, { + LAKEBASE_PROJECT: "q", + PGDATABASE: "p_only_database", + }); + + const report = await runInit(ctx, { mode: "dev", schemaStart: null }); + + expect(report).toMatchObject({ + branch: otherDev, + sourceBranch: otherProduction, + database: "databricks_postgres", + }); + expect(lakebase.calls.some((call) => call.includes("projects/p/"))).toBe( + false, + ); + expect(envFile(ctx.root)).toMatchObject({ + LAKEBASE_PROJECT: "projects/q", + LAKEBASE_ENDPOINT: `${otherDev}/endpoints/primary`, + PGDATABASE: "databricks_postgres", + }); + }); +}); 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..002d7fb6e --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/lakebase-api.test.ts @@ -0,0 +1,275 @@ +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" }); + +const fast = { pollIntervalMs: 1 }; + +describe("LakebaseApi", () => { + test("getBranch: GET /api/2.0/postgres/{name}; 404 → null; other errors keep their status", async () => { + const fake = client([ + { name: "projects/p/branches/b" }, + apiError(404, "NOT_FOUND"), + apiError(403, "PERMISSION_DENIED"), + ]); + const api = new LakebaseApi(fake, fast); + + await expect(api.getBranch("projects/p/branches/b")).resolves.toEqual({ + name: "projects/p/branches/b", + }); + await expect( + api.getBranch("projects/p/branches/missing"), + ).resolves.toBeNull(); + const error = await api + .getBranch("projects/p/branches/x") + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(LakebaseApiError); + expect(error).toMatchObject({ + statusCode: 403, + operation: "get branch projects/p/branches/x", + }); + + expect(fake.requests[0]).toMatchObject({ + path: "/api/2.0/postgres/projects/p/branches/b", + method: "GET", + raw: false, + }); + expect(fake.requests[0]?.headers.get("Accept")).toBe("application/json"); + expect(fake.requests[0]?.headers.get("Content-Type")).toBeNull(); + }); + + test("getEndpoint: GET /api/2.0/postgres/{name}; 404 → null", async () => { + const endpoint = { + name: "projects/p/branches/b/endpoints/primary", + status: { hosts: { host: "ep-1.example.com" } }, + }; + const fake = client([endpoint, apiError(404, "NOT_FOUND")]); + const api = new LakebaseApi(fake); + + await expect(api.getEndpoint(endpoint.name)).resolves.toEqual(endpoint); + await expect( + api.getEndpoint("projects/p/branches/b/endpoints/gone"), + ).resolves.toBeNull(); + expect( + fake.requests.map((request) => [request.method, request.path]), + ).toEqual([ + ["GET", "/api/2.0/postgres/projects/p/branches/b/endpoints/primary"], + ["GET", "/api/2.0/postgres/projects/p/branches/b/endpoints/gone"], + ]); + }); + + test("lists follow next_page_token", async () => { + const fake = client([ + { branches: [{ name: "a" }], next_page_token: "t2" }, + { branches: [{ name: "b" }] }, + ]); + const branches = await new LakebaseApi(fake, fast).listBranches( + "projects/p", + ); + + expect(branches.map((b) => b.name)).toEqual(["a", "b"]); + expect(fake.requests.map((r) => [r.path, r.query])).toEqual([ + ["/api/2.0/postgres/projects/p/branches", undefined], + ["/api/2.0/postgres/projects/p/branches", { page_token: "t2" }], + ]); + }); + + test("createBranch: POST with branch_id and {spec}; a finished operation returns its response", async () => { + const fake = client([ + { + name: "projects/p/branches/dev-a/operations/1", + done: true, + response: { name: "projects/p/branches/dev-a" }, + }, + ]); + const branch = await new LakebaseApi(fake, fast).createBranch( + "projects/p", + "dev-a", + { + source_branch: "projects/p/branches/production", + no_expiry: true, + }, + ); + + expect(branch).toEqual({ name: "projects/p/branches/dev-a" }); + expect(fake.requests[0]).toMatchObject({ + path: "/api/2.0/postgres/projects/p/branches", + method: "POST", + query: { branch_id: "dev-a" }, + payload: { + spec: { + source_branch: "projects/p/branches/production", + no_expiry: true, + }, + }, + }); + expect(fake.requests[0]?.headers.get("Content-Type")).toBe( + "application/json", + ); + }); + + test("polls GET /api/2.0/postgres/{operation.name} until the operation is done", async () => { + const op = "projects/p/branches/b/operations/7"; + const fake = client([ + { name: op, done: false }, + { name: op, done: false }, + { + name: op, + done: true, + response: { name: "projects/p/branches/b/endpoints/primary" }, + }, + ]); + const endpoint = await new LakebaseApi(fake, fast).createEndpoint( + "projects/p/branches/b", + "primary", + ); + + expect(endpoint.name).toBe("projects/p/branches/b/endpoints/primary"); + expect(fake.requests[0]).toMatchObject({ + path: "/api/2.0/postgres/projects/p/branches/b/endpoints", + method: "POST", + query: { endpoint_id: "primary" }, + payload: { spec: { endpoint_type: "ENDPOINT_TYPE_READ_WRITE" } }, + }); + expect(fake.requests.slice(1).map((r) => [r.method, r.path])).toEqual([ + ["GET", `/api/2.0/postgres/${op}`], + ["GET", `/api/2.0/postgres/${op}`], + ]); + }); + + test("a failed operation becomes a LakebaseApiError with the operation's message", async () => { + const fake = client([ + { + name: "op", + done: true, + error: { + error_code: "RESOURCE_EXHAUSTED", + message: "branch limit reached", + }, + }, + ]); + await expect( + new LakebaseApi(fake, fast).createBranch("projects/p", "dev-a", { + source_branch: "projects/p/branches/production", + no_expiry: true, + }), + ).rejects.toThrow( + "Lakebase: create branch projects/p/branches/dev-a failed: branch limit reached", + ); + }); + + test("an operation that never finishes times out", async () => { + const fake = client( + Array.from({ length: 50 }, () => ({ name: "op", done: false })), + ); + await expect( + new LakebaseApi(fake, { pollIntervalMs: 1, timeoutMs: 5 }).createEndpoint( + "projects/p/branches/b", + "primary", + ), + ).rejects.toThrow(/timed out/); + }); + + test("createRole posts {spec} without a role_id", async () => { + const fake = client([ + { + name: "op", + done: true, + response: { name: "projects/p/branches/b/roles/r" }, + }, + ]); + await new LakebaseApi(fake, fast).createRole("projects/p/branches/b", { + identity_type: "USER", + postgres_role: "alice@example.com", + auth_method: "LAKEBASE_OAUTH_V1", + membership_roles: ["DATABRICKS_SUPERUSER"], + }); + + expect(fake.requests[0]).toEqual( + expect.objectContaining({ + path: "/api/2.0/postgres/projects/p/branches/b/roles", + method: "POST", + payload: { + spec: { + identity_type: "USER", + postgres_role: "alice@example.com", + auth_method: "LAKEBASE_OAUTH_V1", + membership_roles: ["DATABRICKS_SUPERUSER"], + }, + }, + }), + ); + expect(fake.requests[0]?.query).toBeUndefined(); + }); + + test("setRoleMemberships: PATCH with update_mask=spec.membership_roles", async () => { + const fake = client([ + { + name: "op", + done: true, + response: { name: "projects/p/branches/b/roles/r" }, + }, + ]); + await new LakebaseApi(fake, fast).setRoleMemberships( + "projects/p/branches/b/roles/r", + ["DATABRICKS_SUPERUSER"], + ); + + expect(fake.requests[0]).toMatchObject({ + path: "/api/2.0/postgres/projects/p/branches/b/roles/r", + method: "PATCH", + query: { update_mask: "spec.membership_roles" }, + payload: { spec: { membership_roles: ["DATABRICKS_SUPERUSER"] } }, + }); + }); + + test("deleteRole: DELETE with reassign_owned_to = the target role's resource name", async () => { + const fake = client([ + { name: "op", done: false }, + { name: "op", done: true }, + ]); + await new LakebaseApi(fake, fast).deleteRole( + "projects/p/branches/b/roles/sp", + { + reassignOwnedTo: "projects/p/branches/b/roles/alice", + }, + ); + + expect(fake.requests[0]).toMatchObject({ + path: "/api/2.0/postgres/projects/p/branches/b/roles/sp", + method: "DELETE", + query: { reassign_owned_to: "projects/p/branches/b/roles/alice" }, + }); + expect(fake.requests[0]?.payload).toBeUndefined(); + expect(fake.requests).toHaveLength(2); + }); +}); 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..b8db45875 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/migrate.test.ts @@ -0,0 +1,282 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const { applyPendingMigrations, checkAppkitBranch, openBranchPool } = + vi.hoisted(() => ({ + applyPendingMigrations: vi.fn(), + checkAppkitBranch: vi.fn(), + openBranchPool: vi.fn(), + })); + +vi.mock("../../migrations/apply", () => ({ applyPendingMigrations })); +vi.mock("../guard", () => ({ checkAppkitBranch })); +vi.mock("../context", async (importOriginal) => ({ + ...(await importOriginal()), + openBranchPool, +})); + +import type { DbContext } from "../context"; +import { runMigrate } from "../migrate"; + +const builder = path.resolve( + import.meta.dirname, + "../../schema-builder/index.ts", +); +const roots: string[] = []; + +function app(tables: string): DbContext { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-db-migrate-")); + roots.push(root); + const file = path.join(root, "config/database/schema.ts"); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + `import { defineSchema, id, text } from ${JSON.stringify(builder)}; + export const schema = defineSchema(({ table }) => ({ ${tables} }));`, + ); + return { + root, + env: {}, + workspace: () => { + throw new Error("no workspace calls expected"); + }, + api: () => { + throw new Error("no Lakebase API calls expected"); + }, + }; +} + +const devBranch = { + branch: { name: "projects/p/branches/dev-alice-3f9a2c" }, + verdict: { appkitOwned: true }, +}; + +const pool = { end: vi.fn(async () => undefined) }; + +beforeEach(() => { + applyPendingMigrations.mockReset(); + openBranchPool.mockReset(); + checkAppkitBranch.mockReset(); + checkAppkitBranch.mockResolvedValue(devBranch); + pool.end.mockClear(); +}); + +afterEach(() => { + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("runMigrate", () => { + test("without schema.ts it points to introspect before touching anything", async () => { + const ctx = app(""); + fs.rmSync(path.join(ctx.root, "config/database/schema.ts")); + + await expect(runMigrate(ctx, { interactive: false })).rejects.toThrow( + "No config/database/schema.ts yet, so there is nothing to migrate. Run `appkit db introspect` to write it from the branch's tables, or create it.", + ); + expect(checkAppkitBranch).not.toHaveBeenCalled(); + expect( + fs.existsSync(path.join(ctx.root, "config/database/migrations")), + ).toBe(false); + }); + + test("an empty schema generates nothing, applies nothing, and still writes database.d.ts", async () => { + const ctx = app(""); + const report = await runMigrate(ctx, { interactive: false }); + + expect(report.generated).toBeNull(); + expect(report.applied).toEqual([]); + expect(report.notApplied).toMatch(/no migrations/); + expect(openBranchPool).not.toHaveBeenCalled(); + expect(checkAppkitBranch).not.toHaveBeenCalled(); + expect(report.typesFile).toBe( + path.join(ctx.root, "shared/appkit-types/database.d.ts"), + ); + expect(fs.existsSync(report.typesFile)).toBe(true); + }); + + test("generates, regenerates types, applies to the .env branch with the app schema, and ends the pool", async () => { + const ctx = app(`notes: table("notes", { id: id(), body: text() })`); + openBranchPool.mockResolvedValue(pool); + applyPendingMigrations.mockResolvedValue({ + applied: ["0000_migration"], + adopted: [], + }); + + const report = await runMigrate(ctx, { interactive: false }); + + expect(report.generated?.tag).toBe("0000_migration"); + expect(openBranchPool).toHaveBeenCalledWith(ctx); + expect(applyPendingMigrations).toHaveBeenCalledWith(pool, { + migrationsFolder: path.join(ctx.root, "config/database/migrations"), + schemaName: "app", + }); + expect(report.applied).toEqual(["0000_migration"]); + expect(report.notApplied).toBeUndefined(); + expect(pool.end).toHaveBeenCalledOnce(); + expect(fs.readFileSync(report.typesFile, "utf-8")).toContain("notes"); + }); + + test("applies committed migrations even when schema.ts did not change", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + openBranchPool.mockResolvedValue(null); + await runMigrate(ctx, { interactive: false }); + + openBranchPool.mockResolvedValue(pool); + applyPendingMigrations.mockResolvedValue({ + applied: ["0000_migration"], + adopted: [], + }); + const report = await runMigrate(ctx, { interactive: false }); + + expect(report.generated).toBeNull(); + expect(report.applied).toEqual(["0000_migration"]); + }); + + test("without a configured branch it still generates, and says why nothing was applied", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + checkAppkitBranch.mockResolvedValue(null); + + const report = await runMigrate(ctx, { interactive: false }); + + expect(report.generated?.tag).toBe("0000_migration"); + expect(applyPendingMigrations).not.toHaveBeenCalled(); + expect(report.notApplied).toMatch(/appkit db init/); + expect(fs.existsSync(report.typesFile)).toBe(true); + }); + + test("on a branch AppKit did not create for you it generates, never connects, and says why", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + checkAppkitBranch.mockResolvedValue({ + branch: { name: "projects/p/branches/production" }, + verdict: { + appkitOwned: false, + reason: "it is the project's default branch", + }, + }); + + const report = await runMigrate(ctx, { interactive: false }); + + expect(report.generated?.tag).toBe("0000_migration"); + expect(openBranchPool).not.toHaveBeenCalled(); + expect(applyPendingMigrations).not.toHaveBeenCalled(); + expect(report.notApplied).toBe( + "production is not an AppKit dev or session branch (it is the project's default branch). Migrations are applied only to branches `appkit db init` created for you; the deployed app applies committed migrations to its own branch", + ); + expect(fs.existsSync(report.typesFile)).toBe(true); + }); + + test("generate: false applies the committed migrations without writing new ones", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + checkAppkitBranch.mockResolvedValue(null); + await runMigrate(ctx, { interactive: false }); + fs.writeFileSync( + path.join(ctx.root, "config/database/schema.ts"), + fs + .readFileSync(path.join(ctx.root, "config/database/schema.ts"), "utf-8") + .replace("id: id()", "id: id(), body: text()"), + ); + + checkAppkitBranch.mockResolvedValue(devBranch); + openBranchPool.mockResolvedValue(pool); + applyPendingMigrations.mockResolvedValue({ + applied: ["0000_migration"], + adopted: [], + }); + const report = await runMigrate(ctx, { + interactive: false, + generate: false, + }); + + expect(report.generated).toBeNull(); + expect(report.applied).toEqual(["0000_migration"]); + expect( + fs.readdirSync(path.join(ctx.root, "config/database/migrations")), + ).toEqual(["0000_migration.sql", "meta"]); + }); + + test("reports a baseline adopted without running it", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + checkAppkitBranch.mockResolvedValue(null); + await runMigrate(ctx, { interactive: false }); + + checkAppkitBranch.mockResolvedValue(devBranch); + openBranchPool.mockResolvedValue(pool); + applyPendingMigrations.mockResolvedValue({ + applied: [], + adopted: ["0000_baseline"], + }); + const report = await runMigrate(ctx, { interactive: false }); + + expect(report).toMatchObject({ applied: [], adopted: ["0000_baseline"] }); + }); + + test("an apply failure ends the pool and says the generated migration was written but not applied", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + openBranchPool.mockResolvedValue(pool); + const failure = new Error("apply failed"); + applyPendingMigrations.mockRejectedValue(failure); + + const error = (await runMigrate(ctx, { interactive: false }).catch( + (caught: unknown) => caught, + )) as Error; + + expect(error.message).toBe( + `Wrote ${path.join("config/database/migrations", "0000_migration.sql")}, but did not apply it: apply failed`, + ); + expect(error.cause).toBe(failure); + expect(pool.end).toHaveBeenCalledOnce(); + expect( + fs.existsSync( + path.join(ctx.root, "config/database/migrations/0000_migration.sql"), + ), + ).toBe(true); + expect( + fs.readFileSync( + path.join(ctx.root, "shared/appkit-types/database.d.ts"), + "utf-8", + ), + ).toContain("notes"); + }); + + test("a guard failure after generating says so too, and never connects", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + checkAppkitBranch.mockRejectedValue( + new Error("The .env branch projects/p/branches/dev-a does not exist"), + ); + + await expect(runMigrate(ctx, { interactive: false })).rejects.toThrow( + /^Wrote .*0000_migration\.sql, but did not apply it: The \.env branch .* does not exist$/, + ); + expect(openBranchPool).not.toHaveBeenCalled(); + }); + + test("a failure with nothing generated propagates unchanged", async () => { + const ctx = app(`notes: table("notes", { id: id() })`); + checkAppkitBranch.mockResolvedValue(null); + await runMigrate(ctx, { interactive: false }); + + checkAppkitBranch.mockResolvedValue(devBranch); + openBranchPool.mockResolvedValue(pool); + const failure = new Error("apply failed"); + applyPendingMigrations.mockRejectedValue(failure); + + await expect(runMigrate(ctx, { interactive: false })).rejects.toBe(failure); + }); + + test("a schema.ts that fails to import names the import error, not the runtime hint", async () => { + const ctx = app(`notes: table("notes", { id: id( })`); + + const error = (await runMigrate(ctx, { interactive: false }).catch( + (caught: unknown) => caught, + )) as Error; + + expect(error.message).toBe("Could not load config/database/schema.ts."); + expect((error.cause as Error).message).toMatch(/schema\.ts:\d+:\d+/); + expect(checkAppkitBranch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/migrations.test.ts b/packages/appkit/src/database/tooling/tests/migrations.test.ts new file mode 100644 index 000000000..575f8fe5a --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/migrations.test.ts @@ -0,0 +1,414 @@ +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 { + 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("rejects a tag that is not a safe file name", async () => { + await expect( + generateMigrationFiles({ + folder: tempFolder(), + schema: blog(), + tag: "../escape", + interactive: false, + }), + ).rejects.toThrow(/lowercase letters/); + }); + + 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\\.$`, + ), + ); + }); + + test("beforeWrite sees the SQL first, and throwing from it writes nothing", async () => { + const folder = tempFolder(); + const seen: string[][] = []; + + const error = await generateMigrationFiles({ + folder, + schema: blog(), + interactive: false, + beforeWrite: (statements) => { + seen.push([...statements]); + throw new Error("refused"); + }, + }).catch((caught: unknown) => caught); + + expect((error as Error).message).toBe("refused"); + expect(seen).toHaveLength(1); + expect(seen[0]?.join("\n")).toContain('CREATE TABLE "app"."authors"'); + expect(listFiles(folder)).toEqual([]); + }); +}); + +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..3b7c974e1 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/naming.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "vitest"; + +import { + devBranchId, + isValidSessionId, + newSessionId, + SESSION_BRANCH_TTL, + 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", () => { + const sessionId = newSessionId(); + expect(sessionId).toMatch(/^[0-9a-z]{8}$/); + expect(sessionBranchId(sessionId)).toBe(`session-${sessionId}`); + expect(isValidSessionId("Bad-Id")).toBe(false); + expect(() => sessionBranchId("Bad-Id")).toThrow(/APPKIT_DB_SESSION/); + expect(SESSION_BRANCH_TTL).toBe("604800s"); + }); +}); 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..c7e2243b4 --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/targets.test.ts @@ -0,0 +1,191 @@ +import { afterEach, describe, expect, test } from "vitest"; + +import { + parseProject, + parseResourceName, + readDeployBranches, + readDeployTarget, + 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("parseProject", () => { + test("accepts projects/ or a bare , normalized to the resource name", () => { + expect(parseProject("projects/my-app")).toBe("projects/my-app"); + expect(parseProject(" my-app ")).toBe("projects/my-app"); + }); + + test("placeholders, branch paths and junk are null", () => { + expect(parseProject("your_postgres_project")).toBeNull(); + expect(parseProject("projects/p/branches/b")).toBeNull(); + expect(parseProject("")).toBeNull(); + expect(parseProject(undefined)).toBeNull(); + }); +}); + +describe("readLocalTarget", () => { + test("reads a real .env target", () => { + expect( + readLocalTarget({ + LAKEBASE_PROJECT: "p", + LAKEBASE_ENDPOINT: "projects/p/branches/b/endpoints/primary", + PGHOST: "ep.example.com", + PGDATABASE: "databricks_postgres", + PGUSER: "alice@example.com", + APPKIT_DB_SESSION: "k2x9q0ab", + }), + ).toEqual({ + project: "projects/p", + endpoint: "projects/p/branches/b/endpoints/primary", + database: "databricks_postgres", + session: "k2x9q0ab", + }); + }); + + test("placeholders from .env.example read as not configured", () => { + expect( + readLocalTarget({ + LAKEBASE_PROJECT: "your_postgres_project", + LAKEBASE_ENDPOINT: "your_postgres_endpointPath", + PGHOST: "your_postgres_host", + PGDATABASE: "your_postgres_databaseName", + APPKIT_DB_SESSION: "NOT VALID", + }), + ).toEqual({ + project: null, + endpoint: null, + database: 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+\)$/, + ); + }); +}); + +describe("readDeployTarget", () => { + test("reads the default target's postgres variables and the app name", () => { + const root = tempApp({ + "databricks.yml": databricksYml( + "projects/p/branches/production", + "projects/p/branches/production/databases/databricks-postgres", + ), + }); + expect(readDeployTarget(root)).toEqual({ + branch: "projects/p/branches/production", + database: "projects/p/branches/production/databases/databricks-postgres", + appName: "my-app", + }); + }); + + test("falls back to top-level variable defaults", () => { + const root = tempApp({ + "databricks.yml": `variables: + postgres_branch: + default: projects/p/branches/main +targets: + dev: + default: true +`, + }); + expect(readDeployTarget(root)).toEqual({ + branch: "projects/p/branches/main", + database: null, + appName: null, + }); + }); + + test("the default target's app name overrides the top-level one", () => { + const root = tempApp({ + // Appended under targets.default. + "databricks.yml": `${databricksYml( + "projects/p/branches/production", + "projects/p/branches/production/databases/databricks-postgres", + )} resources:\n apps:\n app:\n name: my-app-prod\n`, + }); + expect(readDeployTarget(root)?.appName).toBe("my-app-prod"); + }); + + test("null without databricks.yml or without a postgres binding", () => { + expect(readDeployTarget(tempApp())).toBeNull(); + expect( + readDeployTarget(tempApp({ "databricks.yml": "bundle:\n name: x\n" })), + ).toBeNull(); + }); +}); diff --git a/packages/appkit/src/database/tooling/tests/verify.test.ts b/packages/appkit/src/database/tooling/tests/verify.test.ts new file mode 100644 index 000000000..75ace710c --- /dev/null +++ b/packages/appkit/src/database/tooling/tests/verify.test.ts @@ -0,0 +1,628 @@ +import { createHash } from "node:crypto"; +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 { openBranchPool } = vi.hoisted(() => ({ openBranchPool: vi.fn() })); + +vi.mock("../context", async (importOriginal) => ({ + ...(await importOriginal()), + openBranchPool, +})); + +import type { DbContext, ToolingRequest } from "../context"; +import { runVerify } from "../verify"; +import { databricksYml, FakeLakebase } from "./fakes"; + +const builder = path.resolve( + import.meta.dirname, + "../../schema-builder/index.ts", +); +const BRANCH = "projects/p/branches/dev-alice-3f9a2c"; +const MIGRATIONS = "config/database/migrations"; +const roots: string[] = []; + +type Migration = { tag: string; when: number }; + +/** An app whose schema.ts declares app.notes(id, body), with the given committed migrations. */ +function app( + migrations: Migration[] = [], + env: NodeJS.ProcessEnv = { LAKEBASE_ENDPOINT: `${BRANCH}/endpoints/primary` }, +): DbContext { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-db-verify-")); + roots.push(root); + const file = path.join(root, "config/database/schema.ts"); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + `import { defineSchema, id, text } from ${JSON.stringify(builder)}; + export const schema = defineSchema(({ table }) => ({ + notes: table("notes", { id: id(), body: text() }), + }));`, + ); + if (migrations.length > 0) { + const meta = path.join(root, MIGRATIONS, "meta"); + fs.mkdirSync(meta, { recursive: true }); + fs.writeFileSync( + path.join(meta, "_journal.json"), + JSON.stringify({ + version: "7", + dialect: "postgresql", + entries: migrations.map((m, idx) => ({ + idx, + version: "7", + when: m.when, + tag: m.tag, + breakpoints: true, + })), + }), + ); + for (const m of migrations) { + fs.writeFileSync( + path.join(root, MIGRATIONS, `${m.tag}.sql`), + `-- ${m.tag}\nselect 1;\n`, + ); + } + } + return { + root, + env, + workspace: () => { + throw new Error("no workspace calls expected"); + }, + api: () => { + throw new Error("no Lakebase API calls expected"); + }, + }; +} + +/** Journal rows as the migrator writes them: the file's sha256 and the journal's `when`. */ +function recordedAs(ctx: DbContext, migrations: Migration[]) { + return migrations.map((m) => ({ + hash: createHash("sha256") + .update( + fs.readFileSync( + path.join(ctx.root, MIGRATIONS, `${m.tag}.sql`), + "utf-8", + ), + ) + .digest("hex"), + created_at: String(m.when), + })); +} + +/** + * A connection answering the catalog and journal queries; it records every + * statement. + */ +function branch(state: { + columns: Record; + /** max(created_at) of the journal table, or "absent" without one. */ + journal: number | null | "absent"; + /** The journal table's rows. */ + recorded?: Array<{ hash: string; created_at: string }>; + failOn?: RegExp; +}) { + const statements: string[] = []; + const client = { + query: vi.fn(async (text: string, values?: unknown[]) => { + statements.push(text); + if (state.failOn?.test(text)) throw new Error("connection reset"); + if (/pg_catalog\.pg_attribute/.test(text)) { + expect(values?.[0]).toBe("app"); + return { + rows: Object.entries(state.columns).flatMap(([table, columns]) => + columns.map((column) => ({ + table_name: table, + column_name: column, + relation_kind: "r", + })), + ), + }; + } + if (/from pg_catalog\.pg_class c\s+join/.test(text)) { + return { rows: state.journal === "absent" ? [] : [{ "?column?": 1 }] }; + } + if (/max\(created_at\)/.test(text)) { + return { + rows: [{ last: state.journal === "absent" ? null : state.journal }], + }; + } + if (/select hash, created_at from/.test(text)) { + return { rows: state.recorded ?? [] }; + } + return { rows: [] }; + }), + release: vi.fn(), + }; + const pool = { + connect: vi.fn(async () => client), + end: vi.fn(async () => undefined), + }; + openBranchPool.mockResolvedValue(pool); + return { statements, client, pool }; +} + +const WRITES = /\b(insert|update|delete|create|drop|alter|grant|truncate)\b/i; + +beforeEach(() => { + openBranchPool.mockReset(); +}); + +afterEach(() => { + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("runVerify", () => { + const two = [ + { tag: "0000_migration", when: 1000 }, + { tag: "0001_migration", when: 2000 }, + ]; + + test("a branch that matches schema.ts and has every migration is ok", async () => { + const ctx = app(two); + const { pool } = branch({ + columns: { notes: ["id", "body"] }, + journal: 2000, + recorded: recordedAs(ctx, two), + }); + + const report = await runVerify(ctx); + + expect(report).toEqual({ + kind: "local", + branch: BRANCH, + schemaProblems: [], + pendingMigrations: [], + strandedMigrations: [], + ok: true, + }); + expect(pool.end).toHaveBeenCalledOnce(); + }); + + test("reports schema problems and pending migrations separately", async () => { + const ctx = app(two); + branch({ + columns: { notes: ["id"] }, + journal: 1000, + recorded: recordedAs(ctx, two.slice(0, 1)), + }); + + const report = await runVerify(ctx); + + expect(report).toMatchObject({ + schemaProblems: ["table app.notes is missing column body"], + pendingMigrations: ["0001_migration"], + strandedMigrations: [], + ok: false, + }); + }); + + test("without a journal table every committed migration is pending", async () => { + const ctx = app([{ tag: "0000_baseline", when: 1000 }]); + branch({ columns: {}, journal: "absent" }); + + const report = await runVerify(ctx); + + expect(report).toMatchObject({ + schemaProblems: ["table app.notes does not exist"], + pendingMigrations: ["0000_baseline"], + strandedMigrations: [], + ok: false, + }); + }); + + test("a migration merged out of order, older than the last one the branch ran, never runs", async () => { + const migrations = [ + { tag: "0000_migration", when: 1000 }, + { tag: "0001_parallel", when: 1500 }, + { tag: "0002_migration", when: 2000 }, + ]; + const ctx = app(migrations); + branch({ + columns: { notes: ["id", "body"] }, + journal: 2000, + recorded: recordedAs(ctx, [migrations[0], migrations[2]] as Migration[]), + }); + + const report = await runVerify(ctx); + + expect(report).toMatchObject({ + pendingMigrations: [], + strandedMigrations: [{ tag: "0001_parallel", reason: "out of order" }], + ok: false, + }); + }); + + test("a migration edited after the branch ran it never runs again", async () => { + const ctx = app(two); + const recorded = recordedAs(ctx, two); + fs.appendFileSync( + path.join(ctx.root, MIGRATIONS, "0000_migration.sql"), + "select 2;\n", + ); + branch({ columns: { notes: ["id", "body"] }, journal: 2000, recorded }); + + expect(await runVerify(ctx)).toMatchObject({ + strandedMigrations: [{ tag: "0000_migration", reason: "edited" }], + ok: false, + }); + }); + + test("no committed migrations: only the schema is checked", async () => { + const ctx = app(); + const { statements } = branch({ + columns: { notes: ["id", "body"] }, + journal: "absent", + }); + + expect((await runVerify(ctx)).ok).toBe(true); + expect(statements.some((s) => /created_at/.test(s))).toBe(false); + }); + + test("never writes: everything runs in a READ ONLY transaction that is rolled back", async () => { + const ctx = app([{ tag: "0000_migration", when: 1000 }]); + const { statements, client } = branch({ + columns: { notes: ["id"] }, + journal: 500, + }); + + await runVerify(ctx); + + expect(statements[0]).toBe("BEGIN TRANSACTION READ ONLY"); + expect(statements.at(-1)).toBe("ROLLBACK"); + expect(statements.filter((s) => WRITES.test(s))).toEqual([]); + expect(client.release).toHaveBeenCalledWith(undefined); + }); + + test("a failing query rolls back, discards the connection and ends the pool", async () => { + const ctx = app([{ tag: "0000_migration", when: 1000 }]); + const { statements, client, pool } = branch({ + columns: {}, + journal: 1000, + failOn: /max\(created_at\)/, + }); + + await expect(runVerify(ctx)).rejects.toThrow("connection reset"); + expect(statements.at(-1)).toBe("ROLLBACK"); + expect(client.release).toHaveBeenCalledWith(expect.any(Error)); + expect(pool.end).toHaveBeenCalledOnce(); + }); + + test("without a configured branch it points to init before connecting", async () => { + await expect(runVerify(app([], {}))).rejects.toThrow( + "No branch configured (PGHOST, PGDATABASE, LAKEBASE_ENDPOINT) — run `appkit db init`.", + ); + expect(openBranchPool).not.toHaveBeenCalled(); + }); + + test("without schema.ts it points to introspect", async () => { + const ctx = app(); + fs.rmSync(path.join(ctx.root, "config/database/schema.ts")); + + await expect(runVerify(ctx)).rejects.toThrow( + "No config/database/schema.ts yet, so there is nothing to verify. Run `appkit db introspect` to write it from the branch's tables, or create it.", + ); + expect(openBranchPool).not.toHaveBeenCalled(); + }); +}); + +const PRODUCTION = "projects/p/branches/production"; +const DATABASE = `${PRODUCTION}/databases/db-1`; +const SP = "11111111-2222-3333-4444-555555555555"; + +/** GET /api/2.0/apps/my-app for a deployed, running app bound to the deploy branch. */ +function runningApp(overrides: Record = {}) { + return { + name: "my-app", + service_principal_client_id: SP, + resources: [ + { + name: "postgres", + postgres: { + branch: PRODUCTION, + database: DATABASE, + permission: "CAN_CONNECT_AND_CREATE", + }, + }, + ], + app_status: { state: "RUNNING", message: "App is running." }, + compute_status: { state: "ACTIVE", message: "App compute is running." }, + active_deployment: { + status: { state: "SUCCEEDED", message: "App started successfully" }, + }, + ...overrides, + }; +} + +/** + * An app that databricks.yml deploys to the deploy branch. The developer has + * no Postgres role there: --prod must not need one. + */ +function prodApp( + options: { + app?: Record | "missing" | Error; + yml?: string; + spRole?: boolean; + } = {}, +) { + const base = app([], {}); + fs.writeFileSync( + path.join(base.root, "databricks.yml"), + options.yml ?? databricksYml(PRODUCTION, DATABASE), + ); + const lakebase = new FakeLakebase(); + lakebase.addBranch(PRODUCTION, { default: true }); + if (options.spRole !== false) { + lakebase.addRole(PRODUCTION, `dbrx-apps-${SP}`, SP); + } + lakebase.databases.set(DATABASE, { + name: DATABASE, + status: { postgres_database: "appdb" }, + }); + const requests: string[] = []; + const ctx: DbContext = { + ...base, + workspace: () => ({ + apiClient: { + request: async (request: ToolingRequest) => { + requests.push(`${request.method} ${request.path}`); + const response = options.app ?? runningApp(); + if (response === "missing") { + throw Object.assign(new Error("App my-app does not exist."), { + statusCode: 404, + }); + } + if (response instanceof Error) throw response; + return response; + }, + }, + currentUser: { + me: async () => { + throw new Error("--prod does not need the developer's identity"); + }, + }, + }), + api: () => lakebase.api(), + }; + return { ctx, lakebase, requests }; +} + +/** --prod's problems; fails when the report is not a deploy report. */ +async function deployProblems(ctx: DbContext): Promise { + const report = await runVerify(ctx, { prod: true }); + if (report.kind !== "deploy") throw new Error("expected a deploy report"); + return report.problems; +} + +/** databricks.yml with the given resources block in place of the default app. */ +function ymlWithApps(apps: string): string { + return databricksYml(PRODUCTION, DATABASE).replace( + /resources:\n {2}apps:\n(?: {4}.*\n)+/, + apps, + ); +} + +describe("runVerify --prod", () => { + test("a running app bound to the deploy branch is ok, with no database connection and no role of yours there", async () => { + const { ctx, lakebase, requests } = prodApp(); + + const report = await runVerify(ctx, { prod: true }); + + expect(report).toEqual({ + kind: "deploy", + branch: PRODUCTION, + app: "my-app", + problems: [], + ok: true, + }); + expect(requests).toEqual(["GET /api/2.0/apps/my-app"]); + expect(openBranchPool).not.toHaveBeenCalled(); + expect(lakebase.calls.sort()).toEqual([ + `getBranch ${PRODUCTION}`, + `getDatabase ${DATABASE}`, + `listRoles ${PRODUCTION}`, + ]); + }); + + test("does not need schema.ts", async () => { + const { ctx } = prodApp(); + fs.rmSync(path.join(ctx.root, "config/database/schema.ts")); + + expect((await runVerify(ctx, { prod: true })).ok).toBe(true); + }); + + test("an app that does not exist yet: deploy it", async () => { + const { ctx, lakebase } = prodApp({ app: "missing" }); + + expect(await runVerify(ctx, { prod: true })).toMatchObject({ + problems: [ + "app my-app is not deployed yet: `databricks apps deploy` creates it and gives it access to production", + ], + ok: false, + }); + expect(lakebase.calls).not.toContain(`listRoles ${PRODUCTION}`); + }); + + test.each([ + [ + "another branch", + [ + { + postgres: { + branch: "projects/p/branches/staging", + database: DATABASE, + permission: "CAN_CONNECT_AND_CREATE", + }, + }, + ], + `app my-app uses branch projects/p/branches/staging, not ${PRODUCTION} from databricks.yml: \`databricks apps deploy\` applies databricks.yml`, + ], + [ + "no postgres resource", + [{ sql_warehouse: { id: "w", permission: "CAN_USE" } }], + "app my-app has no postgres resource: `databricks apps deploy` adds the one databricks.yml declares", + ], + [ + "another database", + [ + { + postgres: { + branch: PRODUCTION, + database: `${PRODUCTION}/databases/other`, + permission: "CAN_CONNECT_AND_CREATE", + }, + }, + ], + `app my-app uses database ${PRODUCTION}/databases/other, not ${DATABASE} from databricks.yml: \`databricks apps deploy\` applies databricks.yml`, + ], + [ + "a permission that cannot create the schema", + [ + { + postgres: { + branch: PRODUCTION, + database: DATABASE, + permission: "CAN_CONNECT", + }, + }, + ], + "app my-app's postgres resource has permission CAN_CONNECT, but the app needs CAN_CONNECT_AND_CREATE to create its schema: set it in databricks.yml, then run `databricks apps deploy`", + ], + ])("a postgres resource with %s", async (_label, resources, problem) => { + const { ctx, lakebase } = prodApp({ app: runningApp({ resources }) }); + + expect(await deployProblems(ctx)).toEqual([problem]); + expect(lakebase.calls).not.toContain(`listRoles ${PRODUCTION}`); + }); + + test("a service principal without a role on the branch", async () => { + const { ctx } = prodApp({ spRole: false }); + + expect(await deployProblems(ctx)).toEqual([ + `the app's service principal ${SP} has no Postgres role on production: \`databricks apps deploy\` creates it from the app's postgres resource`, + ]); + }); + + test.each([ + [ + "crashed", + { + app_status: { state: "CRASHED", message: "App crashed unexpectedly." }, + }, + "app my-app is crashed: App crashed unexpectedly. `databricks apps logs` shows why; a migration that fails stops the app on start", + ], + [ + // As the API returns it: a stopped app has no active_deployment. + "stopped", + { + compute_status: { + state: "STOPPED", + message: "Start the app compute to deploy the app.", + }, + app_status: { + state: "UNAVAILABLE", + message: "App status is unavailable.", + }, + active_deployment: undefined, + }, + "app my-app is stopped: `databricks apps start` starts its last deployment, or `databricks apps deploy` deploys the current code; it migrates on start", + ], + [ + "starting", + { compute_status: { state: "STARTING" } }, + "app my-app's compute is starting", + ], + [ + "a failed deployment", + { + pending_deployment: { + status: { state: "FAILED", message: "Build failed." }, + }, + }, + "the last deployment of app my-app failed: Build failed. `databricks apps logs` shows why", + ], + [ + "a deployment in progress", + { pending_deployment: { status: { state: "IN_PROGRESS" } } }, + "a deployment of app my-app is in progress: run verify again when it finishes", + ], + [ + "no deployment", + { active_deployment: undefined, app_status: { state: "UNAVAILABLE" } }, + "app my-app has no deployment yet: `databricks apps deploy` deploys it", + ], + ])("an app that is %s", async (_label, overrides, problem) => { + const { ctx } = prodApp({ app: runningApp(overrides) }); + + expect(await deployProblems(ctx)).toEqual([problem]); + }); + + test("a deploy branch or database that does not exist", async () => { + const { ctx, lakebase } = prodApp(); + lakebase.databases.clear(); + expect(await deployProblems(ctx)).toEqual([ + `databricks.yml binds database ${DATABASE}, which does not exist on production`, + ]); + + lakebase.branches.clear(); + expect(await deployProblems(ctx)).toEqual([ + `databricks.yml deploys to ${PRODUCTION}, which does not exist`, + ]); + }); + + test("databricks.yml without an app, or with an app name verify cannot resolve, before any API call", async () => { + const none = prodApp({ yml: ymlWithApps("") }); + expect(await runVerify(none.ctx, { prod: true })).toMatchObject({ + app: null, + problems: ["databricks.yml declares no app"], + ok: false, + }); + expect(none.requests).toEqual([]); + + const interpolated = prodApp({ + yml: ymlWithApps( + 'resources:\n apps:\n app:\n name: "my-app-${bundle.target}"\n', + ), + }); + expect(await deployProblems(interpolated.ctx)).toEqual([ + "databricks.yml names the app my-app-${bundle.target}, with a variable verify cannot resolve", + ]); + expect(interpolated.requests).toEqual([]); + expect(interpolated.lakebase.calls).toEqual([]); + }); + + test("an error other than 404 from the apps API names the app", async () => { + const { ctx } = prodApp({ + app: Object.assign(new Error("Forbidden"), { statusCode: 403 }), + }); + + await expect(runVerify(ctx, { prod: true })).rejects.toThrow( + "Could not read app my-app: Forbidden", + ); + }); + + test("never writes and never connects to the database", async () => { + const { ctx, lakebase } = prodApp({ spRole: false }); + + await runVerify(ctx, { prod: true }); + + expect(openBranchPool).not.toHaveBeenCalled(); + expect(lakebase.calls.filter((c) => !/^(get|list)[A-Z]/.test(c))).toEqual( + [], + ); + }); + + test("without a deploy binding in databricks.yml it says so", async () => { + const { ctx } = prodApp(); + fs.rmSync(path.join(ctx.root, "databricks.yml")); + + await expect(runVerify(ctx, { prod: true })).rejects.toThrow( + "No deploy branch: the default target in databricks.yml binds no postgres_branch.", + ); + }); +}); diff --git a/packages/appkit/src/database/tooling/verify.ts b/packages/appkit/src/database/tooling/verify.ts new file mode 100644 index 000000000..5a56b8142 --- /dev/null +++ b/packages/appkit/src/database/tooling/verify.ts @@ -0,0 +1,322 @@ +import type { PoolClient } from "pg"; + +import { loadDefaultDatabaseSchema } from "../../plugins/database/load-schema"; +import { findSchemaProblems } from "../../plugins/database/schema-check"; +import { + pendingMigrationTags, + type StrandedMigration, + strandedMigrations, +} from "../migrations/apply"; +import { migrationsFolder } from "../migrations/paths"; +import type { DataPath } from "../runtime"; +import { type DbContext, openBranchPool } from "./context"; +import { requireSchemaFile } from "./migrate"; +import { + type DeployTarget, + parseResourceName, + readDeployTarget, + resourceId, +} from "./targets"; + +export interface VerifyOptions { + /** Check that the deployed app is set up to use the deploy branch, instead of the `.env` branch. */ + readonly prod?: boolean; +} + +/** `appkit db verify`: the `.env` branch against schema.ts and the committed migrations. */ +interface LocalVerifyReport { + readonly kind: "local"; + readonly branch: string; + /** Declared tables or columns the branch lacks (the plugin's startup check). */ + readonly schemaProblems: readonly string[]; + /** Committed migrations the branch has not recorded yet. */ + readonly pendingMigrations: readonly string[]; + /** Committed migrations the migrator will never run on the branch. */ + readonly strandedMigrations: readonly StrandedMigration[]; + readonly ok: boolean; +} + +/** `appkit db verify --prod`: whether the deployed app is set up to use the deploy branch. */ +interface DeployVerifyReport { + readonly kind: "deploy"; + readonly branch: string; + /** The app databricks.yml deploys; null when it declares none. */ + readonly app: string | null; + /** What keeps the deployed app from using the branch, each with its fix. */ + readonly problems: readonly string[]; + readonly ok: boolean; +} + +export type VerifyReport = LocalVerifyReport | DeployVerifyReport; + +const NO_BRANCH = + "No branch configured (PGHOST, PGDATABASE, LAKEBASE_ENDPOINT) — run `appkit db init`."; +const DEPLOY = "`databricks apps deploy`"; +const LOGS = "`databricks apps logs`"; +/** What the app's postgres resource needs to create and own its schema. */ +const POSTGRES_PERMISSION = "CAN_CONNECT_AND_CREATE"; + +/** DataPath.raw over one connection: each interpolation becomes a $n parameter. */ +function rawOn(client: Pick): Pick { + return { + async raw(strings: TemplateStringsArray, ...values: unknown[]) { + const text = strings.reduce( + (sql, part, index) => `${sql}$${index}${part}`, + ); + const result = await client.query(text, values); + return result.rows as T[]; + }, + }; +} + +/** Read-only, as the developer, on the `.env` branch: one READ ONLY transaction, rolled back. */ +async function verifyLocal(ctx: DbContext): Promise { + requireSchemaFile(ctx.root, "verify"); + const schema = await loadDefaultDatabaseSchema(ctx.root, { + showImportError: true, + }); + const branch = parseResourceName(ctx.env.LAKEBASE_ENDPOINT)?.branch; + if (!branch) throw new Error(NO_BRANCH); + const pool = await openBranchPool(ctx); + if (!pool) throw new Error(NO_BRANCH); + + try { + const client = await pool.connect(); + let failed: unknown; + try { + await client.query("BEGIN TRANSACTION READ ONLY"); + const options = { + migrationsFolder: migrationsFolder(ctx.root), + schemaName: schema.$schemaName, + }; + const schemaProblems = await findSchemaProblems(rawOn(client), schema); + const pendingMigrations = await pendingMigrationTags(client, options); + const stranded = await strandedMigrations(client, options); + await client.query("ROLLBACK"); + return { + kind: "local", + branch, + schemaProblems, + pendingMigrations, + strandedMigrations: stranded, + ok: + schemaProblems.length === 0 && + pendingMigrations.length === 0 && + stranded.length === 0, + }; + } catch (error) { + failed = error; + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + // A connection whose transaction state is unknown is not reused. + client.release(failed instanceof Error ? failed : undefined); + } + } finally { + await pool.end().catch(() => undefined); + } +} + +interface DeploymentStatus { + readonly state?: string; + readonly message?: string; +} + +/** The fields of GET /api/2.0/apps/{name} that verify reads. */ +interface DeployedApp { + readonly service_principal_client_id?: string; + readonly resources?: ReadonlyArray<{ + readonly postgres?: { + readonly branch?: string; + readonly database?: string; + readonly permission?: string; + }; + }>; + readonly app_status?: DeploymentStatus; + readonly compute_status?: DeploymentStatus; + readonly active_deployment?: { readonly status?: DeploymentStatus }; + readonly pending_deployment?: { readonly status?: DeploymentStatus }; +} + +/** GET /api/2.0/apps/{name}; null when the app does not exist yet. */ +async function readApp( + ctx: DbContext, + name: string, +): Promise { + try { + return (await ctx.workspace().apiClient.request({ + path: `/api/2.0/apps/${encodeURIComponent(name)}`, + method: "GET", + headers: new Headers({ Accept: "application/json" }), + raw: false, + })) as DeployedApp; + } catch (error) { + if ((error as { statusCode?: unknown } | null)?.statusCode === 404) { + return null; + } + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Could not read app ${name}: ${message}`, { + cause: error, + }); + } +} + +/** The app's postgres resource against databricks.yml: same branch and database, and the permission to create its schema. */ +function bindingProblems( + app: DeployedApp, + name: string, + deploy: DeployTarget, +): string[] { + const bound = (app.resources ?? []).flatMap((resource) => + resource.postgres ? [resource.postgres] : [], + ); + const binding = bound.find((postgres) => postgres.branch === deploy.branch); + if (!binding) { + return [ + bound.length > 0 + ? `app ${name} uses branch ${bound.map((postgres) => postgres.branch).join(", ")}, not ${deploy.branch} from databricks.yml: ${DEPLOY} applies databricks.yml` + : `app ${name} has no postgres resource: ${DEPLOY} adds the one databricks.yml declares`, + ]; + } + const problems: string[] = []; + if (deploy.database && binding.database !== deploy.database) { + problems.push( + `app ${name} uses database ${binding.database ?? "(none)"}, not ${deploy.database} from databricks.yml: ${DEPLOY} applies databricks.yml`, + ); + } + if (binding.permission !== POSTGRES_PERMISSION) { + problems.push( + `app ${name}'s postgres resource has permission ${binding.permission ?? "(none)"}, but the app needs ${POSTGRES_PERMISSION} to create its schema: set it in databricks.yml, then run ${DEPLOY}`, + ); + } + return problems; +} + +/** ": " for an API status message, without its final period; "" without one. */ +function detail(message: string | undefined): string { + const text = message?.trim().replace(/\.+$/, ""); + return text ? `: ${text}` : ""; +} + +/** Whether the app is up: a running app has applied its migrations and checked its schema on start. */ +function runningProblems(app: DeployedApp, name: string): string[] { + const pending = app.pending_deployment?.status; + if (pending?.state === "IN_PROGRESS") { + return [ + `a deployment of app ${name} is in progress: run verify again when it finishes`, + ]; + } + const deployment = app.active_deployment?.status; + if (pending?.state === "FAILED" || deployment?.state === "FAILED") { + const failed = pending?.state === "FAILED" ? pending : deployment; + return [ + `the last deployment of app ${name} failed${detail(failed?.message)}. ${LOGS} shows why`, + ]; + } + const compute = app.compute_status; + // A stopped app's API response has no active_deployment, deployed before or not. + if (compute?.state === "STOPPED") { + return [ + `app ${name} is stopped: \`databricks apps start\` starts its last deployment, or ${DEPLOY} deploys the current code; it migrates on start`, + ]; + } + if (!app.active_deployment) { + return [`app ${name} has no deployment yet: ${DEPLOY} deploys it`]; + } + if (compute?.state && compute.state !== "ACTIVE") { + return [ + `app ${name}'s compute is ${compute.state.toLowerCase()}${detail(compute.message)}`, + ]; + } + const status = app.app_status; + if (status?.state === "RUNNING") return []; + return [ + `app ${name} is ${status?.state?.toLowerCase() ?? "not running"}${detail(status?.message)}. ${LOGS} shows why; a migration that fails stops the app on start`, + ]; +} + +/** + * --prod, through the Databricks API only: no database connection, so no + * Postgres role on the deploy branch is needed. The app must exist, be bound + * to the branch and database databricks.yml names with CAN_CONNECT_AND_CREATE, + * have a role on the branch for its service principal, and be running. + */ +async function verifyDeploy(ctx: DbContext): Promise { + const deploy = readDeployTarget(ctx.root); + if (!deploy) { + throw new Error( + "No deploy branch: the default target in databricks.yml binds no postgres_branch.", + ); + } + const report = ( + app: string | null, + problems: string[], + ): DeployVerifyReport => ({ + kind: "deploy", + branch: deploy.branch, + app, + problems, + ok: problems.length === 0, + }); + const name = deploy.appName; + if (!name) return report(null, ["databricks.yml declares no app"]); + if (name.includes("${")) { + return report(name, [ + `databricks.yml names the app ${name}, with a variable verify cannot resolve`, + ]); + } + + const api = ctx.api(); + const [branch, database, app] = await Promise.all([ + api.getBranch(deploy.branch), + deploy.database ? api.getDatabase(deploy.database) : null, + readApp(ctx, name), + ]); + const branchId = resourceId(deploy.branch); + if (!branch) { + return report(name, [ + `databricks.yml deploys to ${deploy.branch}, which does not exist`, + ]); + } + const problems: string[] = []; + if (deploy.database && !database) { + problems.push( + `databricks.yml binds database ${deploy.database}, which does not exist on ${branchId}`, + ); + } + if (!app) { + problems.push( + `app ${name} is not deployed yet: ${DEPLOY} creates it and gives it access to ${branchId}`, + ); + return report(name, problems); + } + + const binding = bindingProblems(app, name, deploy); + const running = runningProblems(app, name); + problems.push(...binding, ...running); + const sp = app.service_principal_client_id; + // Only meaningful once the app is bound to this branch. + if (sp && binding.length === 0) { + const roles = await api.listRoles(deploy.branch); + if (!roles.some((role) => role.status?.postgres_role === sp)) { + problems.push( + `the app's service principal ${sp} has no Postgres role on ${branchId}: ${DEPLOY} creates it from the app's postgres resource`, + ); + } + } + return report(name, problems); +} + +/** + * `appkit db verify`: check the `.env` branch against schema.ts (the plugin's + * startup check) and the committed migrations, read-only. With `prod`, check + * through the Databricks API that the deployed app is set up to use the deploy + * branch; that reads no database, so it needs no role on the deploy branch. + * Nothing is written or created either way. + */ +export async function runVerify( + ctx: DbContext, + options: VerifyOptions = {}, +): Promise { + return options.prod ? verifyDeploy(ctx) : verifyLocal(ctx); +} diff --git a/packages/appkit/src/plugins/database/load-schema.ts b/packages/appkit/src/plugins/database/load-schema.ts index 9378dfd6f..fa9f3ef0f 100644 --- a/packages/appkit/src/plugins/database/load-schema.ts +++ b/packages/appkit/src/plugins/database/load-schema.ts @@ -8,9 +8,15 @@ import { assertFinalizedSchema } from "../../database/schema-builder/define-sche /** Same application-relative convention used by appkit generate-types. */ const DEFAULT_SCHEMA_FILE = "config/database/schema.ts"; +interface LoadSchemaOptions { + /** Report why the module failed to import. Only for local CLI runs: see the catch below. */ + readonly showImportError?: boolean; +} + /** Load application-owned declarations during setup, never during registration. */ export async function loadDefaultDatabaseSchema( root = process.cwd(), + options: LoadSchemaOptions = {}, ): Promise { const file = path.resolve(root, DEFAULT_SCHEMA_FILE); const label = JSON.stringify(file); @@ -34,8 +40,14 @@ export async function loadDefaultDatabaseSchema( module = await createJiti(import.meta.url, { moduleCache: false }).import( file, ); - } catch { + } catch (error) { // Import errors can contain credentials, row values, or application internals. + // A CLI run shows them to the developer who wrote the file; the runtime never does. + if (options.showImportError) { + throw new Error(`Could not load ${DEFAULT_SCHEMA_FILE}.`, { + cause: error, + }); + } throw databaseSetupFailed( `Could not load the default schema at ${label}. Check the module and its imports, or pass database({ schema }).`, ); diff --git a/packages/appkit/src/plugins/database/schema-check.ts b/packages/appkit/src/plugins/database/schema-check.ts index 9b6f7176a..2cd71820e 100644 --- a/packages/appkit/src/plugins/database/schema-check.ts +++ b/packages/appkit/src/plugins/database/schema-check.ts @@ -9,18 +9,17 @@ interface CatalogColumn { } /** - * Confirm every declared table and column exists before routes are published. - * Even after a deployed app applies its migrations, drift would surface on each - * request as an undefined-table or undefined-column failure, so check up front. + * Every declared table or column the database lacks, and every declared table + * that is a view or another non-table relation, as human-readable problems. * pg_catalog is read rather than information_schema, which hides objects the * role cannot access and would report a privilege gap as a missing column. */ -export async function assertSchemaMatchesDatabase( - dataPath: DataPath, +export async function findSchemaProblems( + dataPath: Pick, schema: Schema, -): Promise { +): Promise { const tables = Object.values(schema.$tables); - if (tables.length === 0) return; + if (tables.length === 0) return []; const schemaName = schema.$schemaName; const tableNames = tables.map((table) => table.$name); // Include non-tables so setup can report their kind instead of claiming they are absent. @@ -74,9 +73,22 @@ export async function assertSchemaMatchesDatabase( ); } } + return problems; +} + +/** + * Confirm every declared table and column exists before routes are published. + * Even after a deployed app applies its migrations, drift would surface on each + * request as an undefined-table or undefined-column failure, so check up front. + */ +export async function assertSchemaMatchesDatabase( + dataPath: DataPath, + schema: Schema, +): Promise { + const problems = await findSchemaProblems(dataPath, schema); if (problems.length > 0) { throw databaseSetupFailed( - `the declared schema does not match the database: ${problems.join("; ")}. Create them, or commit migrations under config/database/migrations so the deployed app applies them on start.`, + `the declared schema does not match the database: ${problems.join("; ")}. Run \`npx appkit db migrate\` locally, or commit migrations under config/database/migrations so the deployed app applies them on start.`, ); } } diff --git a/packages/appkit/src/plugins/database/tests/lifecycle.test.ts b/packages/appkit/src/plugins/database/tests/lifecycle.test.ts index 0401912f6..fe7695dc7 100644 --- a/packages/appkit/src/plugins/database/tests/lifecycle.test.ts +++ b/packages/appkit/src/plugins/database/tests/lifecycle.test.ts @@ -209,7 +209,7 @@ describe("createDatabaseState", () => { const drift = new DatabasePluginError( "SETUP_FAILED", "setup", - "Database setup failed: table public.tags does not exist", + "Database setup failed: table app.tags does not exist", ); mocks.assertSchemaMatchesDatabase.mockRejectedValueOnce(drift); @@ -223,7 +223,7 @@ describe("createDatabaseState", () => { schema, ); expect(error).toBe(drift); - expect(error.message).toContain("table public.tags does not exist"); + expect(error.message).toContain("table app.tags does not exist"); expect(pool.end).toHaveBeenCalledTimes(1); }); diff --git a/packages/appkit/src/plugins/database/tests/migrate-on-start.test.ts b/packages/appkit/src/plugins/database/tests/migrate-on-start.test.ts index cf453a32d..788313830 100644 --- a/packages/appkit/src/plugins/database/tests/migrate-on-start.test.ts +++ b/packages/appkit/src/plugins/database/tests/migrate-on-start.test.ts @@ -20,10 +20,10 @@ import { MigrationFilesError } from "../../../database/migrations/paths"; import { defineSchema, id } from "../../../database/schema-builder"; import { migrateOnStart, shouldMigrateOnStart } from "../migrate-on-start"; -const schema = defineSchema( - ({ table }) => ({ notes: table("notes", { id: id() }) }), - { schemaName: "app" }, -); +// The default schema, app, holds the journal too. +const schema = defineSchema(({ table }) => ({ + notes: table("notes", { id: id() }), +})); const roots: string[] = []; const pool = { connect: vi.fn() }; diff --git a/packages/appkit/src/plugins/database/tests/schema-check.test.ts b/packages/appkit/src/plugins/database/tests/schema-check.test.ts index 6f10836a8..67d3aa8ef 100644 --- a/packages/appkit/src/plugins/database/tests/schema-check.test.ts +++ b/packages/appkit/src/plugins/database/tests/schema-check.test.ts @@ -2,18 +2,25 @@ import { describe, expect, test, vi } from "vitest"; import type { DataPath } from "../../../database/runtime"; import { defineSchema, fk, id, text } from "../../../database/schema-builder"; -import { assertSchemaMatchesDatabase } from "../schema-check"; - -const schema = defineSchema(({ table }) => { - const boards = table("boards", { id: id(), title: text().notNull() }); - const notes = table("notes", { - id: id(), - board_id: fk(() => boards.id).notNull(), - author_email: text().private(), - body: text().notNull(), - }); - return { boards, notes }; -}); +import { + assertSchemaMatchesDatabase, + findSchemaProblems, +} from "../schema-check"; + +// Explicitly public: the assertions name the tables public.boards and public.notes. +const schema = defineSchema( + ({ table }) => { + const boards = table("boards", { id: id(), title: text().notNull() }); + const notes = table("notes", { + id: id(), + board_id: fk(() => boards.id).notNull(), + author_email: text().private(), + body: text().notNull(), + }); + return { boards, notes }; + }, + { schemaName: "public" }, +); type CatalogRow = { table_name: string; @@ -143,3 +150,39 @@ describe("assertSchemaMatchesDatabase", () => { expect(raw).not.toHaveBeenCalled(); }); }); + +describe("findSchemaProblems", () => { + test("returns every problem without throwing; the assert reports the same ones", async () => { + const { path } = catalog( + { boards: ["id", "title"], notes: ["id", "body"] }, + { boards: "v" }, + ); + + const problems = await findSchemaProblems(path, schema); + + expect(problems).toEqual([ + "table public.boards is a view, not a table", + "table public.notes is missing columns board_id, author_email", + ]); + await expect(assertSchemaMatchesDatabase(path, schema)).rejects.toThrow( + `the declared schema does not match the database: ${problems.join("; ")}.`, + ); + }); + + test("returns no problems for a matching database or an empty schema", async () => { + const { path } = catalog({ + boards: ["id", "title"], + notes: ["id", "board_id", "author_email", "body"], + }); + expect(await findSchemaProblems(path, schema)).toEqual([]); + + const empty = catalog({}); + expect( + await findSchemaProblems( + empty.path, + defineSchema(() => ({})), + ), + ).toEqual([]); + expect(empty.raw).not.toHaveBeenCalled(); + }); +}); 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/clean.ts b/packages/shared/src/cli/commands/db/clean.ts new file mode 100644 index 000000000..cee18b0f7 --- /dev/null +++ b/packages/shared/src/cli/commands/db/clean.ts @@ -0,0 +1,51 @@ +import { Command } from "commander"; + +import { fail, info, isInteractive, ok, shortName } from "./output"; + +function envBranchLabel(): string { + const endpoint = process.env.LAKEBASE_ENDPOINT ?? ""; + const match = /\/branches\/([^/]+)/.exec(endpoint); + return match?.[1] ? `branch ${match[1]}` : "the .env branch"; +} + +async function runDbClean(flags: { yes?: boolean }): Promise { + try { + if (!flags.yes) { + if (!isInteractive()) { + throw new Error( + "Refusing to clean without confirmation in a non-interactive shell. Pass --yes.", + ); + } + const { confirm, isCancel } = await import("@clack/prompts"); + const answer = await confirm({ + message: `Remove every object in the app schema of ${envBranchLabel()}, including the migration journal?`, + initialValue: false, + }); + if (isCancel(answer) || !answer) { + info("Cancelled"); + return; + } + } + const tooling = await import("@databricks/appkit/database-tooling"); + const report = await tooling.runClean(tooling.createDbContext()); + ok( + `Removed every object in schema ${report.schemaName} on ${shortName(report.branch)}`, + ); + info("`appkit db migrate` recreates it from the committed migrations"); + } catch (error) { + fail(error); + } +} + +export const dbCleanCommand = new Command("clean") + .description( + "Remove every object in the app schema, including the migration journal, on your dev or session branch", + ) + .option("-y, --yes", "skip the confirmation") + .addHelpText( + "after", + ` +Only branches \`appkit db init\` created for you (dev-… or session-…) can be cleaned; +the default, protected and deploy branches are always refused.`, + ) + .action(runDbClean); 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..df14de9d1 --- /dev/null +++ b/packages/shared/src/cli/commands/db/database-tooling.d.ts @@ -0,0 +1,131 @@ +/** + * Intentionally narrowed mirror of `@databricks/appkit/database-tooling`. + * + * `shared` is a leaf package and must not statically depend on `appkit`, so the + * CLI loads the tooling via a dynamic `import(...)` and this declares its shape + * without a build-time dependency (same pattern as ../type-generator.d.ts). + * There is NO compile-time link to the real exports — if they change, re-sync + * this by hand against `packages/appkit/src/database/tooling/index.ts`. + */ +declare module "@databricks/appkit/database-tooling" { + export interface DbContext { + readonly root: string; + readonly env: NodeJS.ProcessEnv; + } + + export function createDbContext(options?: { + root?: string; + env?: NodeJS.ProcessEnv; + }): DbContext; + + export interface GeneratedMigration { + readonly tag: string; + readonly file: string; + readonly statements: readonly string[]; + } + + export interface MigrateReport { + readonly generated: GeneratedMigration | null; + readonly applied: readonly string[]; + readonly adopted?: readonly string[]; + readonly notApplied?: string; + readonly typesFile: string; + } + + export function runMigrate( + ctx: DbContext, + options: { interactive: boolean }, + ): Promise; + + export type BranchMode = "dev" | "session"; + export type SchemaStart = "migrate" | "introspect" | "clean"; + + export interface InitChoices { + readonly mode: BranchMode; + readonly schemaStart: SchemaStart | null; + } + + export interface InitReport { + readonly mode: BranchMode; + readonly branch: string; + readonly created: boolean; + readonly sourceBranch?: string; + readonly endpoint: string; + readonly endpointCreated: boolean; + readonly host: string; + readonly database: string; + readonly user: string; + readonly role: { readonly name: string; readonly action: "kept" | "created" | "updated" }; + readonly handedOff: readonly string[]; + readonly envFile: string; + readonly cleaned: boolean; + readonly schema: MigrateReport | null; + readonly introspect: IntrospectReport | null; + } + + export interface Skip { + readonly table: string; + readonly column?: string; + readonly reason: string; + } + + export interface IntrospectReport { + readonly branch: string; + readonly schemaName: string; + readonly tables: readonly string[]; + readonly schemaFile: string; + readonly backupFile: string | null; + readonly skipped: readonly Skip[]; + readonly warnings: readonly string[]; + readonly approximated: readonly string[]; + readonly migration: GeneratedMigration | null; + readonly recordedApplied: boolean; + readonly notRecorded?: string; + readonly typesFile: string; + } + + export function runIntrospect( + ctx: DbContext, + options: { interactive: boolean; schemaName?: string }, + ): Promise; + + export function resolveInitDefaults(ctx: DbContext): Promise<{ + mode: BranchMode | null; + hasSchema: boolean; + }>; + export function schemaStartsFor(hasSchema: boolean): readonly SchemaStart[]; + export function defaultSchemaStart(hasSchema: boolean): SchemaStart; + export function runInit( + ctx: DbContext, + choices: InitChoices, + options?: { interactive: boolean }, + ): Promise; + + export function runClean(ctx: DbContext): Promise<{ branch: string; schemaName: string }>; + + export function runSeed(ctx: DbContext): Promise<{ branch: string; file: string }>; + + export type VerifyReport = + | { + readonly kind: "local"; + readonly branch: string; + readonly schemaProblems: readonly string[]; + readonly pendingMigrations: readonly string[]; + readonly strandedMigrations: readonly { + readonly tag: string; + readonly reason: "out of order" | "edited"; + }[]; + readonly ok: boolean; + } + | { + readonly kind: "deploy"; + readonly branch: string; + readonly app: string | null; + readonly problems: readonly string[]; + readonly ok: boolean; + }; + export function runVerify( + ctx: DbContext, + options?: { prod?: boolean }, + ): Promise; +} diff --git a/packages/shared/src/cli/commands/db/index.ts b/packages/shared/src/cli/commands/db/index.ts new file mode 100644 index 000000000..3c5bbcfdf --- /dev/null +++ b/packages/shared/src/cli/commands/db/index.ts @@ -0,0 +1,21 @@ +import { Command } from "commander"; + +import { dbCleanCommand } from "./clean"; +import { dbInitCommand } from "./init"; +import { dbIntrospectCommand } from "./introspect"; +import { dbMigrateCommand } from "./migrate"; +import { dbSeedCommand } from "./seed"; +import { dbVerifyCommand } from "./verify"; + +/** + * 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(dbInitCommand) + .addCommand(dbMigrateCommand) + .addCommand(dbIntrospectCommand) + .addCommand(dbCleanCommand) + .addCommand(dbSeedCommand) + .addCommand(dbVerifyCommand); diff --git a/packages/shared/src/cli/commands/db/init-clean-seed.test.ts b/packages/shared/src/cli/commands/db/init-clean-seed.test.ts new file mode 100644 index 000000000..4de50eaa5 --- /dev/null +++ b/packages/shared/src/cli/commands/db/init-clean-seed.test.ts @@ -0,0 +1,410 @@ +import { + afterEach, + beforeEach, + describe, + expect, + type MockInstance, + test, + vi, +} from "vitest"; + +const tooling = vi.hoisted(() => ({ + createDbContext: vi.fn(() => ({ root: "/app", env: {} })), + resolveInitDefaults: vi.fn(), + schemaStartsFor: vi.fn((hasSchema: boolean) => + hasSchema ? ["migrate", "introspect", "clean"] : ["introspect"], + ), + defaultSchemaStart: vi.fn((hasSchema: boolean) => + hasSchema ? "migrate" : "introspect", + ), + runInit: vi.fn(), + runClean: vi.fn(), + runSeed: vi.fn(), +})); +const prompts = vi.hoisted(() => ({ + select: vi.fn(), + confirm: vi.fn(), + isCancel: vi.fn((value: unknown) => typeof value === "symbol"), +})); + +vi.mock("@databricks/appkit/database-tooling", () => tooling); +vi.mock("@clack/prompts", () => prompts); + +import { dbCommand } from "./index"; + +let log: MockInstance; +let error: MockInstance; +const output = (spy: MockInstance) => + spy.mock.calls.map((call) => call.join(" ")).join("\n"); + +function setTTY(value: boolean | undefined) { + Object.defineProperty(process.stdin, "isTTY", { value, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value, configurable: true }); +} +const ttyBefore = { stdin: process.stdin.isTTY, stdout: process.stdout.isTTY }; + +const report = { + mode: "dev", + branch: "projects/p/branches/dev-alice-3f9a2c", + created: true, + sourceBranch: "projects/p/branches/production", + endpoint: "projects/p/branches/dev-alice-3f9a2c/endpoints/primary", + endpointCreated: false, + host: "ep-1.example.com", + database: "databricks_postgres", + user: "alice@example.com", + role: { + name: "projects/p/branches/dev-alice-3f9a2c/roles/alice", + action: "kept", + }, + handedOff: ["11111111-2222-3333-4444-555555555555"], + envFile: "/app/.env", + cleaned: false, + schema: { + generated: null, + applied: ["0000_migration"], + typesFile: `${process.cwd()}/shared/appkit-types/database.d.ts`, + }, +}; + +/** Commander keeps parsed option values on the command; each test parses afresh. */ +function resetOptionValues() { + for (const command of dbCommand.commands) { + for (const option of command.options) { + command.setOptionValueWithSource( + option.attributeName(), + undefined, + "default", + ); + } + } +} + +beforeEach(() => { + resetOptionValues(); + for (const mock of [ + tooling.createDbContext, + tooling.resolveInitDefaults, + tooling.runInit, + tooling.runClean, + tooling.runSeed, + prompts.select, + prompts.confirm, + ]) + mock.mockReset(); + tooling.createDbContext.mockReturnValue({ root: "/app", env: {} }); + tooling.resolveInitDefaults.mockResolvedValue({ + mode: null, + hasSchema: true, + }); + tooling.runInit.mockResolvedValue(report); + log = vi.spyOn(console, "log").mockImplementation(() => undefined); + error = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); + setTTY(undefined); + Object.defineProperty(process.stdin, "isTTY", { + value: ttyBefore.stdin, + configurable: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + value: ttyBefore.stdout, + configurable: true, + }); + process.exitCode = undefined; +}); + +describe("appkit db init", () => { + test("registers init, migrate, introspect, clean, seed and verify", () => { + expect(dbCommand.commands.map((c) => c.name())).toEqual([ + "init", + "migrate", + "introspect", + "clean", + "seed", + "verify", + ]); + }); + + test("--yes skips every prompt, even on a TTY; flags map to the choices", async () => { + setTTY(true); + await dbCommand.parseAsync(["init", "--new", "session", "--yes"], { + from: "user", + }); + + expect(prompts.select).not.toHaveBeenCalled(); + expect(tooling.runInit).toHaveBeenCalledWith( + { root: "/app", env: {} }, + { mode: "session", schemaStart: "migrate" }, + { interactive: false }, + ); + }); + + test("init only sets up a dev or session branch: there is no option to pick another one", () => { + const init = dbCommand.commands.find((c) => c.name() === "init"); + expect(init?.options.map((o) => o.long)).toEqual([ + "--new", + "--schema", + "--yes", + ]); + }); + + test("--schema none leaves the schema alone", async () => { + await dbCommand.parseAsync( + ["init", "--new", "dev", "--schema", "none", "--yes"], + { from: "user" }, + ); + + expect(tooling.runInit).toHaveBeenCalledWith( + expect.anything(), + { mode: "dev", schemaStart: null }, + { interactive: false }, + ); + }); + + test("without a TTY: the mode of the .env branch, else dev; schema migrate", async () => { + await dbCommand.parseAsync(["init"], { from: "user" }); + expect(tooling.runInit).toHaveBeenLastCalledWith( + expect.anything(), + { mode: "dev", schemaStart: "migrate" }, + { interactive: false }, + ); + + tooling.resolveInitDefaults.mockResolvedValue({ + mode: "session", + hasSchema: true, + }); + await dbCommand.parseAsync(["init"], { from: "user" }); + expect(tooling.runInit).toHaveBeenLastCalledWith( + expect.anything(), + { mode: "session", schemaStart: "migrate" }, + { interactive: false }, + ); + expect(prompts.select).not.toHaveBeenCalled(); + }); + + test("on a TTY it asks for dev or session, then the schema start", async () => { + setTTY(true); + prompts.select + .mockResolvedValueOnce("session") + .mockResolvedValueOnce("none"); + + await dbCommand.parseAsync(["init"], { from: "user" }); + + expect(prompts.select).toHaveBeenCalledTimes(2); + expect( + prompts.select.mock.calls[0]?.[0].options.map( + (o: { value: string }) => o.value, + ), + ).toEqual(["dev", "session"]); + expect(prompts.select.mock.calls[0]?.[0].initialValue).toBe("dev"); + expect(tooling.runInit).toHaveBeenCalledWith( + expect.anything(), + { mode: "session", schemaStart: null }, + { interactive: true }, + ); + }); + + test("a new branch offers migrate, introspect and clean, defaulting to migrate", async () => { + setTTY(true); + prompts.select + .mockResolvedValueOnce("dev") + .mockResolvedValueOnce("introspect"); + + await dbCommand.parseAsync(["init"], { from: "user" }); + + expect( + prompts.select.mock.calls[1]?.[0].options.map( + (o: { value: string }) => o.value, + ), + ).toEqual(["migrate", "introspect", "clean", "none"]); + expect(prompts.select.mock.calls[1]?.[0].initialValue).toBe("migrate"); + expect(tooling.runInit).toHaveBeenCalledWith( + expect.anything(), + { mode: "dev", schemaStart: "introspect" }, + { interactive: true }, + ); + }); + + test("without schema.ts only introspect is offered, and it is the default", async () => { + setTTY(true); + tooling.resolveInitDefaults.mockResolvedValue({ + mode: null, + hasSchema: false, + }); + prompts.select + .mockResolvedValueOnce("session") + .mockResolvedValueOnce("introspect"); + + await dbCommand.parseAsync(["init"], { from: "user" }); + + const schemaPrompt = prompts.select.mock.calls[1]?.[0]; + expect(schemaPrompt.options).toEqual([ + { + value: "introspect", + label: "Introspect", + hint: "there is no schema.ts yet: write it from the branch's tables", + }, + { value: "none", label: "Leave the schema as it is" }, + ]); + expect(schemaPrompt.initialValue).toBe("introspect"); + expect(tooling.runInit).toHaveBeenCalledWith( + expect.anything(), + { mode: "session", schemaStart: "introspect" }, + { interactive: true }, + ); + }); + + test("without schema.ts and without prompts the start is introspect", async () => { + tooling.resolveInitDefaults.mockResolvedValue({ + mode: null, + hasSchema: false, + }); + + await dbCommand.parseAsync(["init", "--new", "dev", "--yes"], { + from: "user", + }); + + expect(tooling.runInit).toHaveBeenCalledWith( + expect.anything(), + { mode: "dev", schemaStart: "introspect" }, + { interactive: false }, + ); + }); + + test("on a TTY, a mode implied by the .env branch is not asked again", async () => { + setTTY(true); + tooling.resolveInitDefaults.mockResolvedValue({ + mode: "session", + hasSchema: true, + }); + prompts.select.mockResolvedValueOnce("migrate"); + + await dbCommand.parseAsync(["init"], { from: "user" }); + + expect(prompts.select).toHaveBeenCalledOnce(); + expect(prompts.select.mock.calls[0]?.[0].message).toBe( + "How should the branch's schema start?", + ); + expect(tooling.runInit).toHaveBeenCalledWith( + expect.anything(), + { mode: "session", schemaStart: "migrate" }, + { interactive: true }, + ); + }); + + test("a cancelled prompt stops before any change", async () => { + setTTY(true); + prompts.select.mockResolvedValueOnce(Symbol("cancel")); + + await dbCommand.parseAsync(["init"], { from: "user" }); + + expect(tooling.runInit).not.toHaveBeenCalled(); + expect(output(log)).toContain("Cancelled"); + expect(process.exitCode).toBeUndefined(); + }); + + test("prints what it did", async () => { + await dbCommand.parseAsync(["init", "--new", "dev", "--yes"], { + from: "user", + }); + + const printed = output(log); + expect(printed).toContain( + "Branch dev-alice-3f9a2c (created from production)", + ); + expect(printed).toContain("Endpoint primary — ep-1.example.com"); + expect(printed).toContain("Role alice@example.com (DATABRICKS_SUPERUSER)"); + expect(printed).toContain( + "Handed the objects owned by 11111111-2222-3333-4444-555555555555 to you", + ); + expect(printed).toContain( + "Pointed .env at dev-alice-3f9a2c (database databricks_postgres)", + ); + expect(printed).toContain("Applied 0000_migration to the .env branch"); + }); + + test("an error is printed and exits 1", async () => { + tooling.runInit.mockRejectedValue( + new Error( + "Lakebase: create branch failed: creating dev-a needs CAN MANAGE on projects/p.", + ), + ); + await dbCommand.parseAsync(["init", "--yes"], { from: "user" }); + expect(output(error)).toContain("needs CAN MANAGE on projects/p"); + expect(process.exitCode).toBe(1); + }); +}); + +describe("appkit db clean", () => { + test("refuses without --yes when there is no TTY, before any API call", async () => { + await dbCommand.parseAsync(["clean"], { from: "user" }); + + expect(output(error)).toContain( + "Refusing to clean without confirmation in a non-interactive shell. Pass --yes.", + ); + expect(tooling.runClean).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + test("asks for confirmation on a TTY; no means nothing is removed", async () => { + setTTY(true); + prompts.confirm.mockResolvedValueOnce(false); + await dbCommand.parseAsync(["clean"], { from: "user" }); + expect(prompts.confirm).toHaveBeenCalledOnce(); + expect(tooling.runClean).not.toHaveBeenCalled(); + + prompts.confirm.mockResolvedValueOnce(true); + tooling.runClean.mockResolvedValue({ + branch: "projects/p/branches/dev-a", + schemaName: "app", + }); + await dbCommand.parseAsync(["clean"], { from: "user" }); + expect(tooling.runClean).toHaveBeenCalledOnce(); + expect(output(log)).toContain( + "Removed every object in schema app on dev-a", + ); + }); + + test("--yes cleans without asking", async () => { + tooling.runClean.mockResolvedValue({ + branch: "projects/p/branches/dev-a", + schemaName: "app", + }); + await dbCommand.parseAsync(["clean", "--yes"], { from: "user" }); + expect(prompts.confirm).not.toHaveBeenCalled(); + expect(tooling.runClean).toHaveBeenCalledOnce(); + }); + + test("the guard's refusal is printed and exits 1", async () => { + tooling.runClean.mockRejectedValue( + new Error( + "Refusing to clean production: it is the project's default branch.", + ), + ); + await dbCommand.parseAsync(["clean", "--yes"], { from: "user" }); + expect(output(error)).toContain("Refusing to clean production"); + expect(process.exitCode).toBe(1); + }); +}); + +describe("appkit db seed", () => { + test("prints the seed file and branch", async () => { + tooling.runSeed.mockResolvedValue({ + branch: "projects/p/branches/dev-a", + file: `${process.cwd()}/config/database/seed.sql`, + }); + await dbCommand.parseAsync(["seed"], { from: "user" }); + expect(output(log)).toContain("Ran config/database/seed.sql on dev-a"); + }); + + test("a missing seed file exits 1", async () => { + tooling.runSeed.mockRejectedValue( + new Error("No seed file at config/database/seed.sql."), + ); + await dbCommand.parseAsync(["seed"], { from: "user" }); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/packages/shared/src/cli/commands/db/init.ts b/packages/shared/src/cli/commands/db/init.ts new file mode 100644 index 000000000..9a925a32f --- /dev/null +++ b/packages/shared/src/cli/commands/db/init.ts @@ -0,0 +1,214 @@ +import type { + BranchMode, + DbContext, + InitChoices, + InitReport, + SchemaStart, +} from "@databricks/appkit/database-tooling"; +import { Command, Option } from "commander"; + +import { + fail, + info, + isInteractive, + ok, + printIntrospectReport, + printMigrateReport, + shortName, +} from "./output"; + +type Tooling = typeof import("@databricks/appkit/database-tooling"); + +export interface InitFlags { + readonly new?: "dev" | "session"; + readonly schema?: SchemaStart | "none"; + readonly yes?: boolean; +} + +/** null: the user cancelled a prompt. */ +type Choice = T | null; + +async function promptMode(): Promise> { + const { isCancel, select } = await import("@clack/prompts"); + const mode = await select({ + message: "Which Lakebase branch should local development use?", + options: [ + { + value: "dev", + label: "New per-developer branch", + hint: "dev--…, forked from the default branch, reused on re-runs", + }, + { + value: "session", + label: "New per-session branch", + hint: "session-…, for this checkout, expires after 7 days", + }, + ], + initialValue: "dev", + }); + return isCancel(mode) ? null : mode; +} + +const START_LABELS: Record = { + migrate: "Apply migrations", + introspect: "Introspect", + clean: "Start clean", +}; + +function startHint(start: SchemaStart, hasSchema: boolean): string { + if (start === "migrate") return "schema.ts → migration → branch"; + if (start === "clean") { + return "drop the app schema, then apply committed migrations"; + } + return hasSchema + ? "write schema.ts from the tables the branch copied" + : "there is no schema.ts yet: write it from the branch's tables"; +} + +/** Only the starts that can run here (schemaStartsFor), the default first, then "leave". */ +async function promptSchemaStart( + tooling: Tooling, + hasSchema: boolean, +): Promise> { + const { isCancel, select } = await import("@clack/prompts"); + const starts = tooling.schemaStartsFor(hasSchema); + const options: { + value: SchemaStart | "none"; + label: string; + hint?: string; + }[] = [ + ...starts.map((value) => ({ + value, + label: START_LABELS[value], + hint: startHint(value, hasSchema), + })), + { value: "none", label: "Leave the schema as it is" }, + ]; + const start = await select({ + message: "How should the branch's schema start?", + options, + initialValue: tooling.defaultSchemaStart(hasSchema), + }); + return isCancel(start) ? null : start; +} + +/** + * Flags first, then the mode .env's branch implies (your dev or this + * checkout's session branch), then prompts, then defaults. With `--yes` or + * without a TTY nothing is prompted: mode falls back to "dev" and the schema + * start to defaultSchemaStart (migrate with schema.ts, introspect without). + * Returns null when a prompt is cancelled. + */ +export async function resolveInitChoices( + tooling: Tooling, + ctx: DbContext, + flags: InitFlags, + interactive: boolean, +): Promise { + const defaults = await tooling.resolveInitDefaults(ctx); + let mode: BranchMode | undefined = flags.new ?? defaults.mode ?? undefined; + if (!mode) { + if (interactive) { + const picked = await promptMode(); + if (!picked) return null; + mode = picked; + } else { + mode = "dev"; + } + } + + let start: SchemaStart | "none" | undefined = flags.schema; + if (!start) { + if (interactive) { + const picked = await promptSchemaStart(tooling, defaults.hasSchema); + if (!picked) return null; + start = picked; + } else { + start = tooling.defaultSchemaStart(defaults.hasSchema); + } + } + return { mode, schemaStart: start === "none" ? null : start }; +} + +function printInitReport(report: InitReport): void { + const branch = shortName(report.branch); + ok( + report.created + ? `Branch ${branch} (created from ${shortName(report.sourceBranch ?? "the default branch")})` + : `Branch ${branch} (reused)`, + ); + ok( + `Endpoint ${shortName(report.endpoint)}${report.endpointCreated ? " (created)" : ""} — ${report.host}`, + ); + const role = { + created: "created", + updated: "added to DATABRICKS_SUPERUSER", + kept: "DATABRICKS_SUPERUSER", + }[report.role.action]; + ok(`Role ${report.user} (${role})`); + if (report.handedOff.length > 0) { + ok(`Handed the objects owned by ${report.handedOff.join(", ")} to you`); + } + ok(`Pointed .env at ${branch} (database ${report.database})`); + if (report.cleaned) ok("Removed every object in the app schema"); + if (report.introspect) printIntrospectReport(report.introspect); + else if (report.schema) printMigrateReport(report.schema); + else info("Left the schema as it is"); +} + +async function runDbInit(flags: InitFlags): Promise { + try { + const tooling = await import("@databricks/appkit/database-tooling"); + const ctx = tooling.createDbContext(); + const interactive = !flags.yes && isInteractive(); + const choices = await resolveInitChoices(tooling, ctx, flags, interactive); + if (!choices) { + info("Cancelled"); + return; + } + printInitReport(await tooling.runInit(ctx, choices, { interactive })); + } catch (error) { + fail(error); + } +} + +export const dbInitCommand = new Command("init") + .description( + "Set up your Lakebase dev or session branch for local development: create or reuse it, ensure an endpoint and your role, point .env at it, then set up the schema", + ) + .addOption( + new Option( + "--new ", + "create or reuse a per-developer or per-session branch", + ).choices(["dev", "session"]), + ) + .addOption( + new Option("--schema ", "how the branch's schema starts").choices([ + "migrate", + "introspect", + "clean", + "none", + ]), + ) + .option( + "-y, --yes", + "no prompts: mode from --new, then from the branch .env points at, then dev; schema from --schema, then migrate with schema.ts, introspect without", + ) + .addHelpText( + "after", + ` +Without --new, init keeps your dev branch or this checkout's session branch when +.env already points at one, and otherwise asks (dev without prompts). New +branches fork the project's default branch. init never changes the default +branch, a protected branch or a branch databricks.yml deploys to, and never edits +databricks.yml. The project comes from LAKEBASE_PROJECT in .env (projects/ +or ), else from LAKEBASE_ENDPOINT, else from the bundle's postgres_branch +variable. A fresh checkout needs only DATABRICKS_CONFIG_PROFILE and +LAKEBASE_PROJECT. + +Examples: + $ appkit db init + $ appkit db init --new dev --yes + $ appkit db init --new session --schema clean --yes`, + ) + .action(runDbInit); diff --git a/packages/shared/src/cli/commands/db/introspect.test.ts b/packages/shared/src/cli/commands/db/introspect.test.ts new file mode 100644 index 000000000..143c2b54e --- /dev/null +++ b/packages/shared/src/cli/commands/db/introspect.test.ts @@ -0,0 +1,173 @@ +import { + afterEach, + beforeEach, + describe, + expect, + type MockInstance, + test, + vi, +} from "vitest"; + +const tooling = vi.hoisted(() => ({ + createDbContext: vi.fn(() => ({ root: "/app", env: {} })), + runIntrospect: vi.fn(), +})); + +vi.mock("@databricks/appkit/database-tooling", () => tooling); + +import { dbCommand } from "./index"; + +let log: MockInstance; +let error: MockInstance; +const output = (spy: MockInstance) => + spy.mock.calls.map((call) => call.join(" ")).join("\n"); + +const report = { + branch: "projects/p/branches/dev-alice-3f9a2c", + schemaName: "app", + tables: ["authors", "posts"], + schemaFile: `${process.cwd()}/config/database/schema.ts`, + backupFile: `${process.cwd()}/config/database/schema.ts.bak`, + skipped: [ + { + table: "events", + reason: "composite primary key (a, b) is not supported", + }, + { + table: "posts", + column: "score", + reason: 'type "numeric(10,2)" is not supported', + }, + ], + warnings: [ + "app.posts.score is NOT NULL with no default but is not in schema.ts.", + ], + approximated: [ + "app.authors.id: a serial key is declared as id(), an identity generated BY DEFAULT", + ], + migration: { + tag: "0000_baseline", + file: `${process.cwd()}/config/database/migrations/0000_baseline.sql`, + statements: ["CREATE TABLE a", "CREATE TABLE b"], + }, + recordedApplied: true, + typesFile: `${process.cwd()}/shared/appkit-types/database.d.ts`, +}; + +beforeEach(() => { + for (const option of dbCommand.commands.flatMap((c) => + c.options.map((o) => [c, o] as const), + )) { + option[0].setOptionValueWithSource( + option[1].attributeName(), + undefined, + "default", + ); + } + tooling.runIntrospect.mockReset().mockResolvedValue(report); + log = vi.spyOn(console, "log").mockImplementation(() => undefined); + error = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; +}); + +describe("appkit db introspect", () => { + test("prints the written schema, each skip, the loud warnings, and the recorded baseline", async () => { + await dbCommand.parseAsync(["introspect"], { from: "user" }); + + expect(tooling.runIntrospect).toHaveBeenCalledWith( + { root: "/app", env: {} }, + { interactive: false }, + ); + const printed = output(log); + expect(printed).toContain( + "Wrote config/database/schema.ts from schema app on dev-alice-3f9a2c (2 tables); the previous file is config/database/schema.ts.bak", + ); + expect(printed).toContain( + "Skipped table events: composite primary key (a, b) is not supported", + ); + expect(printed).toContain( + 'Skipped posts.score: type "numeric(10,2)" is not supported', + ); + expect(printed).toContain("app.posts.score is NOT NULL with no default"); + expect(printed).toContain( + "Approximated app.authors.id: a serial key is declared as id(), an identity generated BY DEFAULT", + ); + expect(printed).toContain( + "Generated config/database/migrations/0000_baseline.sql (2 statements)", + ); + expect(printed).toContain( + "Recorded 0000_baseline as applied on dev-alice-3f9a2c (its tables already exist)", + ); + expect(printed).toContain("Generated shared/appkit-types/database.d.ts"); + expect(process.exitCode).toBeUndefined(); + }); + + test("--schema-name is passed through; an unrecorded baseline says why", async () => { + tooling.runIntrospect.mockResolvedValue({ + ...report, + recordedApplied: false, + notRecorded: "production is not your dev or session branch", + }); + + await dbCommand.parseAsync(["introspect", "--schema-name", "app"], { + from: "user", + }); + + expect(tooling.runIntrospect).toHaveBeenCalledWith(expect.anything(), { + interactive: false, + schemaName: "app", + }); + expect(output(log)).toContain( + "Not recorded: production is not your dev or session branch", + ); + }); + + test("a recorded NNNN_introspect says the branch already has the changes", async () => { + tooling.runIntrospect.mockResolvedValue({ + ...report, + migration: { ...report.migration, tag: "0002_introspect" }, + }); + + await dbCommand.parseAsync(["introspect"], { from: "user" }); + + expect(output(log)).toContain( + "Recorded 0002_introspect as applied on dev-alice-3f9a2c (the branch already has these changes)", + ); + }); + + test.each([ + [[], "No migration needed: schema.ts has no tables"], + [ + ["authors"], + "No migration needed: the migrations already describe this schema", + ], + ])("without a migration, %j tables print %j", async (tables, line) => { + tooling.runIntrospect.mockResolvedValue({ + ...report, + tables, + migration: null, + recordedApplied: false, + }); + + await dbCommand.parseAsync(["introspect"], { from: "user" }); + + expect(output(log)).toContain(line); + }); + + test("a refusal is printed and exits 1", async () => { + tooling.runIntrospect.mockRejectedValue( + new Error( + "Refusing to introspect production: config/database/migrations already has history", + ), + ); + + await dbCommand.parseAsync(["introspect"], { from: "user" }); + + expect(output(error)).toContain("Refusing to introspect production"); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/packages/shared/src/cli/commands/db/introspect.ts b/packages/shared/src/cli/commands/db/introspect.ts new file mode 100644 index 000000000..7c4c81f54 --- /dev/null +++ b/packages/shared/src/cli/commands/db/introspect.ts @@ -0,0 +1,41 @@ +import { Command } from "commander"; + +import { fail, isInteractive, printIntrospectReport } from "./output"; + +async function runDbIntrospect(flags: { schemaName?: string }): Promise { + try { + const tooling = await import("@databricks/appkit/database-tooling"); + const report = await tooling.runIntrospect(tooling.createDbContext(), { + interactive: isInteractive(), + ...(flags.schemaName ? { schemaName: flags.schemaName } : {}), + }); + printIntrospectReport(report); + } catch (error) { + fail(error); + } +} + +export const dbIntrospectCommand = new Command("introspect") + .description( + "Write config/database/schema.ts from the .env branch's tables, plus the migration that brings history up to them", + ) + .option("--schema-name ", "Postgres schema to read (default: app)") + .addHelpText( + "after", + ` +The previous schema.ts is kept as schema.ts.bak. What the schema builder cannot +express (composite keys, unsupported types or defaults, some foreign keys) is left +out with an "// appkit: skipped …" comment. + +Without migration history this writes 0000_baseline; with history, the next +NNNN_introspect, once the branch has applied every committed migration. On your +dev or session branch the migration is recorded as applied. On any other branch +only 0000_baseline is written, and the deployed app adopts it on start when its +tables already exist. If anything fails, schema.ts and the migrations are left +as they were. + +Examples: + $ appkit db introspect + $ appkit db introspect --schema-name sales`, + ) + .action(runDbIntrospect); diff --git a/packages/shared/src/cli/commands/db/migrate.test.ts b/packages/shared/src/cli/commands/db/migrate.test.ts new file mode 100644 index 000000000..1640b5223 --- /dev/null +++ b/packages/shared/src/cli/commands/db/migrate.test.ts @@ -0,0 +1,152 @@ +import path from "node:path"; + +import { + afterEach, + beforeEach, + describe, + expect, + type MockInstance, + test, + vi, +} from "vitest"; + +const { createDbContext, runMigrate } = vi.hoisted(() => ({ + createDbContext: vi.fn(() => ({ root: "/app", env: {} })), + runMigrate: vi.fn(), +})); + +// The tooling is an ambient module loaded with a dynamic import; mock it so the +// command never touches drizzle-kit or a database. +vi.mock("@databricks/appkit/database-tooling", () => ({ + createDbContext, + runMigrate, +})); + +import { dbCommand } from "./index"; + +let log: MockInstance; +let error: MockInstance; + +function output(spy: MockInstance): string { + return spy.mock.calls.map((call) => call.join(" ")).join("\n"); +} + +beforeEach(() => { + runMigrate.mockReset(); + createDbContext.mockClear(); + log = vi.spyOn(console, "log").mockImplementation(() => undefined); + error = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; +}); + +const typesFile = path.join(process.cwd(), "shared/appkit-types/database.d.ts"); + +describe("appkit db migrate", () => { + test("is registered under the db group", () => { + expect(dbCommand.commands.map((command) => command.name())).toContain( + "migrate", + ); + }); + + test("prints the generated file, the applied tags and the types file", async () => { + runMigrate.mockResolvedValue({ + generated: { + tag: "0001_migration", + file: path.join( + process.cwd(), + "config/database/migrations/0001_migration.sql", + ), + statements: ["a", "b"], + }, + applied: ["0001_migration"], + typesFile, + }); + + await dbCommand.parseAsync(["migrate"], { from: "user" }); + + expect(createDbContext).toHaveBeenCalledWith(); + expect(runMigrate).toHaveBeenCalledWith( + { root: "/app", env: {} }, + // Vitest workers have no TTY, so the run is non-interactive. + { interactive: false }, + ); + const printed = output(log); + expect(printed).toContain( + `Generated ${path.join("config/database/migrations/0001_migration.sql")} (2 statements)`, + ); + expect(printed).toContain("Applied 0001_migration to the .env branch"); + expect(printed).toContain( + `Generated ${path.join("shared/appkit-types/database.d.ts")}`, + ); + expect(process.exitCode).toBeUndefined(); + }); + + test("reports an unchanged schema and an up-to-date branch", async () => { + runMigrate.mockResolvedValue({ generated: null, applied: [], typesFile }); + + await dbCommand.parseAsync(["migrate"], { from: "user" }); + + const printed = output(log); + expect(printed).toContain("No schema changes since the last migration"); + expect(printed).toContain("The .env branch is up to date"); + }); + + test("reports a baseline adopted without running it", async () => { + runMigrate.mockResolvedValue({ + generated: null, + applied: ["0001_migration"], + adopted: ["0000_baseline"], + typesFile, + }); + + await dbCommand.parseAsync(["migrate"], { from: "user" }); + + const printed = output(log); + expect(printed).toContain( + "Adopted 0000_baseline: its tables already existed, so it was recorded without running", + ); + expect(printed).toContain("Applied 0001_migration to the .env branch"); + }); + + test("says why nothing was applied", async () => { + runMigrate.mockResolvedValue({ + generated: null, + applied: [], + notApplied: "no branch configured — run appkit db init", + typesFile, + }); + + await dbCommand.parseAsync(["migrate"], { from: "user" }); + + expect(output(log)).toContain( + "Not applied: no branch configured — run appkit db init", + ); + expect(process.exitCode).toBeUndefined(); + }); + + test("prints the error and its root cause, and exits 1", async () => { + const driverError = new Error('relation "notes" already exists'); + const wrapped = new Error("Failed query: CREATE TABLE ...", { + cause: driverError, + }); + runMigrate.mockRejectedValue( + new Error( + "Applying migration 0000_migration failed (SQLSTATE 42P07); none were applied.", + { cause: wrapped }, + ), + ); + + await dbCommand.parseAsync(["migrate"], { from: "user" }); + + const printed = output(error); + expect(printed).toContain( + "Applying migration 0000_migration failed (SQLSTATE 42P07); none were applied.", + ); + expect(printed).toContain('relation "notes" already exists'); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/packages/shared/src/cli/commands/db/migrate.ts b/packages/shared/src/cli/commands/db/migrate.ts new file mode 100644 index 000000000..8d061f6b0 --- /dev/null +++ b/packages/shared/src/cli/commands/db/migrate.ts @@ -0,0 +1,31 @@ +import { Command } from "commander"; + +import { fail, isInteractive, printMigrateReport } from "./output"; + +async function runDbMigrate(): Promise { + try { + const tooling = await import("@databricks/appkit/database-tooling"); + const report = await tooling.runMigrate(tooling.createDbContext(), { + interactive: isInteractive(), + }); + printMigrateReport(report); + } catch (error) { + fail(error); + } +} + +export const dbMigrateCommand = new Command("migrate") + .description( + "Generate a migration from config/database/schema.ts, apply pending migrations to the .env branch, and regenerate database.d.ts", + ) + .addHelpText( + "after", + ` +Migrations are written to config/database/migrations/. Commit them with schema.ts; +the deployed app applies pending committed migrations when it starts. They are +applied locally only on your dev or session branch (see \`appkit db init\`). + +Examples: + $ appkit db migrate`, + ) + .action(runDbMigrate); diff --git a/packages/shared/src/cli/commands/db/output.ts b/packages/shared/src/cli/commands/db/output.ts new file mode 100644 index 000000000..d04d510a3 --- /dev/null +++ b/packages/shared/src/cli/commands/db/output.ts @@ -0,0 +1,176 @@ +import path from "node:path"; +import process from "node:process"; + +import type { + IntrospectReport, + MigrateReport, + VerifyReport, +} from "@databricks/appkit/database-tooling"; +import pc from "picocolors"; + +/** Prompts only when a human can answer them. */ +export function isInteractive(): boolean { + return Boolean(process.stdin.isTTY && process.stdout.isTTY); +} + +export function relative(file: string): string { + return path.relative(process.cwd(), file) || file; +} + +/** The last segment of a Lakebase resource name: projects/p/branches/dev-a → dev-a. */ +export function shortName(name: string): string { + return name.slice(name.lastIndexOf("/") + 1); +} + +export const ok = (message: string) => + console.log(`${pc.green("✔")} ${message}`); +export const info = (message: string) => + console.log(`${pc.dim("•")} ${message}`); +export const warn = (message: string) => + console.log(`${pc.yellow("!")} ${message}`); + +/** Innermost message: drizzle wraps the driver error, whose text names the failing object. */ +function rootCause(error: unknown): string | undefined { + let current = (error as { cause?: unknown } | undefined)?.cause; + let message: string | undefined; + for (let depth = 0; depth < 5 && current instanceof Error; depth++) { + message = current.message; + current = (current as { cause?: unknown }).cause; + } + return message; +} + +/** Print the error and its root cause, and fail the process. */ +export function fail(error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + console.error(`${pc.red("✖")} ${message}`); + const cause = rootCause(error); + if (cause && cause !== message && !message.includes(cause)) + console.error(` ${cause}`); + process.exitCode = 1; +} + +export function printMigrateReport(report: MigrateReport): void { + if (report.generated) { + const count = report.generated.statements.length; + ok( + `Generated ${relative(report.generated.file)} (${count} statement${count === 1 ? "" : "s"})`, + ); + } else { + info("No schema changes since the last migration"); + } + + if (report.adopted?.length) { + ok( + `Adopted ${report.adopted.join(", ")}: its tables already existed, so it was recorded without running`, + ); + } + if (report.applied.length > 0) { + ok(`Applied ${report.applied.join(", ")} to the .env branch`); + } else if (report.notApplied) { + warn(`Not applied: ${report.notApplied}`); + } else if (!report.adopted?.length) { + info("The .env branch is up to date"); + } + + ok(`Generated ${relative(report.typesFile)}`); +} + +type LocalVerifyReport = Extract; +type DeployVerifyReport = Extract; + +const STRANDED: Record<"out of order" | "edited", string> = { + "out of order": + "it is older than a migration the branch already ran (merged out of order); the deployed app skips it too once it ran a newer one", + edited: + "it changed after the branch ran it; restore it from version control and put the change in a new migration", +}; + +/** The one next step for a failed local verify, most fundamental gap first. */ +function localVerifyHint(report: LocalVerifyReport): string { + if (report.strandedMigrations.length > 0) { + return "`appkit db clean` and then `appkit db migrate` rebuild the branch from the migrations, in order."; + } + // Pending migrations explain the gap; without them, schema.ts changed or the branch was edited. + return report.pendingMigrations.length > 0 + ? "Run `appkit db migrate` to apply the pending migrations." + : "If you changed schema.ts, run `appkit db migrate`; if the branch was changed by hand, `appkit db clean` and then `appkit db migrate` rebuild it from the migrations."; +} + +function printLocalVerifyReport(report: LocalVerifyReport): void { + const branch = shortName(report.branch); + if (report.ok) { + ok( + `${branch} matches config/database/schema.ts and has every committed migration`, + ); + return; + } + for (const problem of report.schemaProblems) warn(`Schema: ${problem}`); + if (report.pendingMigrations.length > 0) { + warn(`Pending migrations: ${report.pendingMigrations.join(", ")}`); + } + for (const { tag, reason } of report.strandedMigrations) { + warn(`Never runs: ${tag}: ${STRANDED[reason]}`); + } + console.error( + `${pc.red("✖")} ${branch} does not match config/database/schema.ts and the committed migrations. ${localVerifyHint(report)}`, + ); +} + +function printDeployVerifyReport(report: DeployVerifyReport): void { + const branch = `${shortName(report.branch)} (deploy branch)`; + const app = report.app ? `app ${report.app}` : "The deployed app"; + if (report.ok) { + ok( + `${app} is running on ${branch}: on start it applied its migrations and checked its schema`, + ); + return; + } + for (const problem of report.problems) warn(problem); + console.error( + `${pc.red("✖")} ${app} cannot use ${branch} yet. Fix the above, then run \`appkit db verify --prod\` again.`, + ); +} + +export function printVerifyReport(report: VerifyReport): void { + if (report.kind === "local") printLocalVerifyReport(report); + else printDeployVerifyReport(report); +} + +export function printIntrospectReport(report: IntrospectReport): void { + const count = report.tables.length; + ok( + `Wrote ${relative(report.schemaFile)} from schema ${report.schemaName} on ${shortName(report.branch)} (${count} table${count === 1 ? "" : "s"})${report.backupFile ? `; the previous file is ${relative(report.backupFile)}` : ""}`, + ); + for (const skip of report.skipped) { + warn( + `Skipped ${skip.column ? `${skip.table}.${skip.column}` : `table ${skip.table}`}: ${skip.reason}`, + ); + } + for (const warning of report.warnings) { + console.log(`${pc.bold(pc.red("!"))} ${pc.bold(warning)}`); + } + for (const difference of report.approximated) { + info(`Approximated ${difference}`); + } + if (report.migration) { + const statements = report.migration.statements.length; + ok( + `Generated ${relative(report.migration.file)} (${statements} statement${statements === 1 ? "" : "s"})`, + ); + if (report.recordedApplied) { + ok( + `Recorded ${report.migration.tag} as applied on ${shortName(report.branch)} (${report.migration.tag.endsWith("_baseline") ? "its tables already exist" : "the branch already has these changes"})`, + ); + } else if (report.notRecorded) { + info(`Not recorded: ${report.notRecorded}`); + } + } else { + info( + report.tables.length === 0 + ? "No migration needed: schema.ts has no tables" + : "No migration needed: the migrations already describe this schema", + ); + } + ok(`Generated ${relative(report.typesFile)}`); +} diff --git a/packages/shared/src/cli/commands/db/seed.ts b/packages/shared/src/cli/commands/db/seed.ts new file mode 100644 index 000000000..d69df16e9 --- /dev/null +++ b/packages/shared/src/cli/commands/db/seed.ts @@ -0,0 +1,26 @@ +import { Command } from "commander"; + +import { fail, ok, relative, shortName } from "./output"; + +async function runDbSeed(): Promise { + try { + const tooling = await import("@databricks/appkit/database-tooling"); + const report = await tooling.runSeed(tooling.createDbContext()); + ok(`Ran ${relative(report.file)} on ${shortName(report.branch)}`); + } catch (error) { + fail(error); + } +} + +export const dbSeedCommand = new Command("seed") + .description( + "Run config/database/seed.sql in one transaction on your dev or session branch", + ) + .addHelpText( + "after", + ` +Unqualified names resolve to the app schema first (app, or the schemaName in +config/database/schema.ts), then public. The file already runs in one +transaction, so it must not contain BEGIN or COMMIT.`, + ) + .action(runDbSeed); diff --git a/packages/shared/src/cli/commands/db/verify.test.ts b/packages/shared/src/cli/commands/db/verify.test.ts new file mode 100644 index 000000000..57f6ffdc3 --- /dev/null +++ b/packages/shared/src/cli/commands/db/verify.test.ts @@ -0,0 +1,236 @@ +import { + afterEach, + beforeEach, + describe, + expect, + type MockInstance, + test, + vi, +} from "vitest"; + +const { createDbContext, runVerify } = vi.hoisted(() => ({ + createDbContext: vi.fn(() => ({ root: "/app", env: {} })), + runVerify: vi.fn(), +})); + +// The tooling is an ambient module loaded with a dynamic import; mock it so the +// command never touches a database. +vi.mock("@databricks/appkit/database-tooling", () => ({ + createDbContext, + runVerify, +})); + +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"); +} + +const local = { + kind: "local" as const, + branch: "projects/p/branches/dev-alice-3f9a2c", + schemaProblems: [], + pendingMigrations: [], + strandedMigrations: [], + ok: true, +}; + +beforeEach(() => { + runVerify.mockReset(); + log = vi.spyOn(console, "log").mockImplementation(() => undefined); + error = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; +}); + +describe("appkit db verify", () => { + test("is registered under the db group", () => { + expect(dbCommand.commands.map((command) => command.name())).toContain( + "verify", + ); + }); + + test("a clean branch prints one line and exits 0", async () => { + runVerify.mockResolvedValue({ + ...local, + }); + + await dbCommand.parseAsync(["verify"], { from: "user" }); + + expect(output(log)).toContain( + "dev-alice-3f9a2c matches config/database/schema.ts and has every committed migration", + ); + expect(error).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); + + test("prints every schema problem and pending migration, and exits 1", async () => { + runVerify.mockResolvedValue({ + ...local, + schemaProblems: [ + "table app.notes is missing column body", + "table app.tags does not exist", + ], + pendingMigrations: ["0001_migration", "0002_migration"], + ok: false, + }); + + await dbCommand.parseAsync(["verify"], { from: "user" }); + + const printed = output(log); + expect(printed).toContain("Schema: table app.notes is missing column body"); + expect(printed).toContain("Schema: table app.tags does not exist"); + expect(printed).toContain( + "Pending migrations: 0001_migration, 0002_migration", + ); + expect(output(error)).toContain( + "dev-alice-3f9a2c does not match config/database/schema.ts and the committed migrations. Run `appkit db migrate` to apply the pending migrations.", + ); + expect(process.exitCode).toBe(1); + }); + + test("schema problems without pending migrations point to migrate or clean", async () => { + runVerify.mockResolvedValue({ + ...local, + schemaProblems: ["table app.notes is missing column body"], + ok: false, + }); + + await dbCommand.parseAsync(["verify"], { from: "user" }); + + expect(output(error)).toContain( + "If you changed schema.ts, run `appkit db migrate`; if the branch was changed by hand, `appkit db clean` and then `appkit db migrate` rebuild it from the migrations.", + ); + expect(process.exitCode).toBe(1); + }); + + test("only pending migrations still exit 1", async () => { + runVerify.mockResolvedValue({ + ...local, + pendingMigrations: ["0001_migration"], + ok: false, + }); + + await dbCommand.parseAsync(["verify"], { from: "user" }); + + expect(output(log)).not.toContain("Schema:"); + expect(process.exitCode).toBe(1); + }); + + test("names each migration that never runs and why, and how to rebuild the branch", async () => { + runVerify.mockResolvedValue({ + ...local, + strandedMigrations: [ + { tag: "0001_parallel", reason: "out of order" }, + { tag: "0000_migration", reason: "edited" }, + ], + ok: false, + }); + + await dbCommand.parseAsync(["verify"], { from: "user" }); + + const printed = output(log); + expect(printed).toContain( + "Never runs: 0001_parallel: it is older than a migration the branch already ran (merged out of order); the deployed app skips it too once it ran a newer one", + ); + expect(printed).toContain( + "Never runs: 0000_migration: it changed after the branch ran it; restore it from version control and put the change in a new migration", + ); + expect(output(error)).toContain( + "`appkit db clean` and then `appkit db migrate` rebuild the branch from the migrations, in order.", + ); + expect(process.exitCode).toBe(1); + }); + + test("without --prod it checks the .env branch", async () => { + runVerify.mockResolvedValue({ + ...local, + }); + + await dbCommand.parseAsync(["verify"], { from: "user" }); + + expect(runVerify).toHaveBeenCalledWith(expect.anything(), { prod: false }); + }); + + describe("--prod", () => { + const deploy = { + kind: "deploy" as const, + branch: "projects/p/branches/production", + app: "my-app", + problems: [], + ok: true, + }; + + test("a running app on the deploy branch prints one line and exits 0", async () => { + runVerify.mockResolvedValue(deploy); + + await dbCommand.parseAsync(["verify", "--prod"], { from: "user" }); + + expect(runVerify).toHaveBeenCalledWith(expect.anything(), { prod: true }); + expect(output(log)).toContain( + "app my-app is running on production (deploy branch): on start it applied its migrations and checked its schema", + ); + expect(error).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); + + test("lists each problem with its fix, and exits 1", async () => { + runVerify.mockResolvedValue({ + ...deploy, + problems: [ + "app my-app has no postgres resource: `databricks apps deploy` adds the one databricks.yml declares", + "app my-app is crashed: App crashed unexpectedly. `databricks apps logs` shows why; a migration that fails stops the app on start", + ], + ok: false, + }); + + await dbCommand.parseAsync(["verify", "--prod"], { from: "user" }); + + const printed = output(log); + expect(printed).toContain( + "app my-app has no postgres resource: `databricks apps deploy` adds the one databricks.yml declares", + ); + expect(printed).toContain( + "app my-app is crashed: App crashed unexpectedly.", + ); + expect(output(error)).toContain( + "app my-app cannot use production (deploy branch) yet. Fix the above, then run `appkit db verify --prod` again.", + ); + expect(process.exitCode).toBe(1); + }); + + test("without an app in databricks.yml it names the deployed app generically", async () => { + runVerify.mockResolvedValue({ + ...deploy, + app: null, + problems: ["databricks.yml declares no app"], + ok: false, + }); + + await dbCommand.parseAsync(["verify", "--prod"], { from: "user" }); + + expect(output(error)).toContain( + "The deployed app cannot use production (deploy branch) yet.", + ); + }); + }); + + test("an error is printed and exits 1", async () => { + runVerify.mockRejectedValue( + new Error( + "No branch configured (PGHOST, PGDATABASE, LAKEBASE_ENDPOINT) — run `appkit db init`.", + ), + ); + + await dbCommand.parseAsync(["verify"], { from: "user" }); + + expect(output(error)).toContain("No branch configured"); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/packages/shared/src/cli/commands/db/verify.ts b/packages/shared/src/cli/commands/db/verify.ts new file mode 100644 index 000000000..7fbb83aa3 --- /dev/null +++ b/packages/shared/src/cli/commands/db/verify.ts @@ -0,0 +1,45 @@ +import { Command } from "commander"; + +import { fail, printVerifyReport } from "./output"; + +async function runDbVerify(options: { prod?: boolean }): Promise { + try { + const tooling = await import("@databricks/appkit/database-tooling"); + const report = await tooling.runVerify(tooling.createDbContext(), { + prod: options.prod === true, + }); + printVerifyReport(report); + if (!report.ok) process.exitCode = 1; + } catch (error) { + fail(error); + } +} + +export const dbVerifyCommand = new Command("verify") + .description( + "Check the .env branch against config/database/schema.ts and the committed migrations (read-only)", + ) + .option( + "--prod", + "check instead that the deployed app is set up to use the deploy branch from databricks.yml", + ) + .addHelpText( + "after", + ` +Reports declared tables or columns the branch lacks (what the app checks on +start), committed migrations the branch has not applied, and migrations it will +never run (merged out of order, or edited after they ran). + +--prod reads only the Databricks API, so it needs no access to the deploy +branch's database: the app exists, its postgres resource names the branch and +database in databricks.yml with CAN_CONNECT_AND_CREATE, its service principal +has a role on the branch, and the app is running. A running app has applied +its migrations and checked its schema on start. + +Exits 1 on any finding. Nothing is written or created. + +Examples: + $ appkit db verify + $ appkit db verify --prod`, + ) + .action(runDbVerify); 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