diff --git a/.gitignore b/.gitignore index 66e336f9d..845aae943 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ build tmp node_modules .env +.env.bak +.env.bak.* +.env.tmp.* coverage diff --git a/apps/dev-playground/.gitignore b/apps/dev-playground/.gitignore index 1f4745f52..a399b75c4 100644 --- a/apps/dev-playground/.gitignore +++ b/apps/dev-playground/.gitignore @@ -3,4 +3,8 @@ test-results/ playwright-report/ # Auto-generated types (endpoint-specific, varies per developer) -shared/appkit-types/serving.d.ts \ No newline at end of file +shared/appkit-types/serving.d.ts + +# Database plugin playground artifacts generated by `appkit db introspect` +config/database/schema.ts +config/database/migrations/ \ No newline at end of file diff --git a/apps/dev-playground/config/database/README.md b/apps/dev-playground/config/database/README.md new file mode 100644 index 000000000..0d8f01e3c --- /dev/null +++ b/apps/dev-playground/config/database/README.md @@ -0,0 +1,59 @@ +# Database Plugin Playground Fixture + +This fixture exercises the database plugin against a real Lakebase project. + +## Quick start + +For both new and existing databases: + +```bash +pnpm exec tsx ../../packages/shared/src/cli/index.ts db init +``` + +`db init` will: + +1. Ask which Databricks profile to use (or use `--profile`). +2. Ask which Lakebase project (or use `--project`). +3. Create or reuse your per-user dev branch (`dev-{slug}-{hash}`). +4. Write `.env` (`PGHOST`, `PGDATABASE`, `LAKEBASE_ENDPOINT`, etc.). +5. Detect whether the target schema is empty or populated. +6. Run the right path: `setup:dev` for a new database, `introspect + verify` for an existing one. + +For scripted use (no prompts): + +```bash +pnpm exec tsx ../../packages/shared/src/cli/index.ts db init \ + --profile DEFAULT \ + --project projects/ditadi-taskflow \ + --from introspect \ + --schema public \ + --yes +``` + +`--from` accepts: + +- `migrate` — schema.ts is the source of truth. Generates a migration from + `config/database/schema.ts` and applies it to the dev branch. +- `introspect` — the live branch is the source of truth. Writes + `config/database/schema.ts` from the branch's existing tables. + +Without `--from`, `db init` probes the target schema and suggests one (empty +schema → `migrate`, populated schema → `introspect`). + +## Lower-level commands + +These are what `db init` composes. Use them directly only when `init` is not +the right shape: + +```bash +appkit db introspect --schema public # write schema.ts from live DB +appkit db setup:dev --name init --seed # generate + migrate + seed + verify +appkit db migration generate --name # diff schema.ts → migration SQL +appkit db migrate up # apply pending migrations (CI/prod) +appkit db verify # drift check (CI-friendly) +appkit db seed # run seed.sql against current DB +``` + +In CI and production, prefer `migration generate`, `migrate up`, and `verify` +over `init`/`setup:dev`. Those are the explicit primitives that don't refuse +production. diff --git a/apps/dev-playground/config/database/seed.sql b/apps/dev-playground/config/database/seed.sql new file mode 100644 index 000000000..29ef7e0b7 --- /dev/null +++ b/apps/dev-playground/config/database/seed.sql @@ -0,0 +1,166 @@ +-- AML demo fixture for the AppKit database plugin. +-- Data only. Table structure is created from config/database/schema.ts via +-- `appkit db migration generate` and `appkit db migrate up`. + +INSERT INTO cases ( + case_id, + entity_id, + entity_name, + risk_score, + risk_level, + status, + case_type, + typologies, + alert_ids, + alert_count, + cluster_id, + assigned_to, + is_historical +) VALUES + ( + 'CASE-001', + 'ENT-1001', + 'Acme Trading LLC', + 87, + 'High', + 'In Review', + 'Structuring', + 'smurfing,cash_deposits', + 'ALT-001,ALT-002', + 2, + 'CL-01', + 'Jane Doe', + FALSE + ), + ( + 'CASE-002', + 'ENT-1002', + 'Globex Imports', + 73, + 'Medium', + 'New', + 'Sanctions Screening', + 'sanctions_proximity', + 'ALT-003', + 1, + 'CL-02', + 'John Smith', + FALSE + ) +ON CONFLICT (case_id) DO UPDATE SET + entity_id = EXCLUDED.entity_id, + entity_name = EXCLUDED.entity_name, + risk_score = EXCLUDED.risk_score, + risk_level = EXCLUDED.risk_level, + status = EXCLUDED.status, + case_type = EXCLUDED.case_type, + typologies = EXCLUDED.typologies, + alert_ids = EXCLUDED.alert_ids, + alert_count = EXCLUDED.alert_count, + cluster_id = EXCLUDED.cluster_id, + assigned_to = EXCLUDED.assigned_to, + is_historical = EXCLUDED.is_historical, + updated_at = NOW(); + +INSERT INTO activity_log (log_id, case_id, action, actor, details, metadata) +VALUES + ( + 'LOG-001', + 'CASE-001', + 'case_opened', + 'system', + 'High-risk structuring case generated from AML model output.', + '{"source":"aml_gold.cases","severity":"HIGH"}'::jsonb + ), + ( + 'LOG-002', + 'CASE-001', + 'assigned', + 'supervisor', + 'Assigned to Jane for enhanced due diligence.', + '{"queue":"EDD"}'::jsonb + ) +ON CONFLICT (log_id) DO UPDATE SET + action = EXCLUDED.action, + actor = EXCLUDED.actor, + details = EXCLUDED.details, + metadata = EXCLUDED.metadata; + +INSERT INTO investigation_notes (note_id, case_id, author, content, note_type) +VALUES + ( + 'NOTE-001', + 'CASE-001', + 'Jane Doe', + 'Initial review confirms unusual cash deposit velocity across linked accounts.', + 'analyst_note' + ), + ( + 'NOTE-002', + 'CASE-002', + 'John Smith', + 'Screening match requires business ownership validation before escalation.', + 'triage_note' + ) +ON CONFLICT (note_id) DO UPDATE SET + author = EXCLUDED.author, + content = EXCLUDED.content, + note_type = EXCLUDED.note_type; + +INSERT INTO ai_summaries ( + case_id, + summary, + trigger_reason, + suspicious_patterns, + typology_tags, + recommended_actions, + linked_accounts_count, + previous_alerts_count, + model, + raw_json +) VALUES + ( + 'CASE-001', + 'Customer activity shows repeated cash deposits below reporting thresholds.', + 'High composite risk score and linked alerts.', + '["structured_cash_deposits","rapid_movement"]'::jsonb, + '["structuring","layering"]'::jsonb, + '["request_source_of_funds","review_linked_entities"]'::jsonb, + 4, + 2, + 'aml-briefing-agent-fixture', + '{"confidence":"high"}'::jsonb + ) +ON CONFLICT (case_id) DO UPDATE SET + summary = EXCLUDED.summary, + trigger_reason = EXCLUDED.trigger_reason, + suspicious_patterns = EXCLUDED.suspicious_patterns, + typology_tags = EXCLUDED.typology_tags, + recommended_actions = EXCLUDED.recommended_actions, + linked_accounts_count = EXCLUDED.linked_accounts_count, + previous_alerts_count = EXCLUDED.previous_alerts_count, + model = EXCLUDED.model, + raw_json = EXCLUDED.raw_json; + +INSERT INTO alert_triage (alert_id, decision, reviewer, reason, case_id) +VALUES + ( + 'ALT-001', + 'investigate', + 'Jane Doe', + 'Alert is consistent with structuring typology.', + 'CASE-001' + ), + ( + 'ALT-003', + 'review', + 'John Smith', + 'Potential sanctions proximity requires second-level review.', + 'CASE-002' + ) +ON CONFLICT (alert_id) DO UPDATE SET + decision = EXCLUDED.decision, + reviewer = EXCLUDED.reviewer, + reason = EXCLUDED.reason, + case_id = EXCLUDED.case_id, + decided_at = NOW(); diff --git a/packages/shared/src/cli/commands/db/__tests__/db.test.ts b/packages/shared/src/cli/commands/db/__tests__/db.test.ts index 9d305076c..f6705b0e7 100644 --- a/packages/shared/src/cli/commands/db/__tests__/db.test.ts +++ b/packages/shared/src/cli/commands/db/__tests__/db.test.ts @@ -12,6 +12,7 @@ describe("dbCommand", () => { test("registers database subcommands", () => { expect(dbCommand.name()).toBe("db"); expect(dbCommand.commands.map((command) => command.name())).toEqual([ + "init", "introspect", "migration", "migrate", diff --git a/packages/shared/src/cli/commands/db/__tests__/init.test.ts b/packages/shared/src/cli/commands/db/__tests__/init.test.ts new file mode 100644 index 000000000..c7fa1b69f --- /dev/null +++ b/packages/shared/src/cli/commands/db/__tests__/init.test.ts @@ -0,0 +1,829 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + type CliRunner, + deriveDevBranchName, + type EnvUpdates, + initCommand, + OWNED_ENV_KEYS, + type RunInitDeps, + type RunInitOptions, + runInit, + shortHash, + slugifyPrincipal, +} from "../init"; + +/* ============================================================ */ +/* Typed fixtures */ +/* ============================================================ */ + +interface FakeProfilesResponse { + profiles: Array<{ name: string; host: string }>; +} + +interface FakeUserResponse { + id: string; + userName?: string; + displayName?: string; + emails?: Array<{ value: string; primary?: boolean }>; +} + +interface FakeProjectResponse { + name: string; + status?: { display_name?: string }; +} + +interface FakeBranchResponse { + name: string; + status?: { default?: boolean }; +} + +interface FakeEndpointResponse { + name: string; + status?: { endpoint_type?: string; hosts?: { host?: string } }; +} + +interface FakeDatabaseResponse { + name: string; + status?: { postgres_database?: string }; +} + +interface FakeCliResponses { + profiles?: FakeProfilesResponse; + projects?: FakeProjectResponse[]; + user?: FakeUserResponse; + branches?: FakeBranchResponse[]; + createBranch?: { name: string }; + endpoints?: FakeEndpointResponse[]; + databases?: FakeDatabaseResponse[]; +} + +const DEFAULT_PROFILES: FakeProfilesResponse = { + profiles: [{ name: "DEFAULT", host: "https://h.example" }], +}; + +const DEFAULT_USER: FakeUserResponse = { + id: "u-1", + userName: "alice@example.com", + displayName: "Alice", + emails: [{ value: "alice@example.com", primary: true }], +}; + +const DEFAULT_PROJECTS: FakeProjectResponse[] = [ + { name: "projects/foo", status: { display_name: "Foo" } }, +]; + +const DEFAULT_BRANCHES: FakeBranchResponse[] = [ + { name: "projects/foo/branches/main", status: { default: true } }, +]; + +const DEFAULT_ENDPOINTS: FakeEndpointResponse[] = [ + { + name: "projects/foo/branches/dev-alice-XXXXXX/endpoints/primary", + status: { + endpoint_type: "ENDPOINT_TYPE_READ_WRITE", + hosts: { host: "ep.example" }, + }, + }, +]; + +const DEFAULT_DATABASES: FakeDatabaseResponse[] = [ + { + name: "projects/foo/branches/dev-alice-XXXXXX/databases/db-X", + status: { postgres_database: "databricks_postgres" }, + }, +]; + +const DEFAULT_CREATE_BRANCH = { + name: "projects/foo/branches/dev-alice-XXXXXX", +}; + +/** + * Fake Databricks CLI runner that returns a canned response per subcommand. + * Typed so a fixture typo (e.g. `endpoint_typee`) is a compile error. + */ +function fakeCli(responses: FakeCliResponses = {}) { + const tracked = vi.fn(async (args: string[]): Promise => { + const joined = args.join(" "); + if (joined.startsWith("auth profiles")) { + return responses.profiles ?? DEFAULT_PROFILES; + } + if (joined.startsWith("current-user me")) { + return responses.user ?? DEFAULT_USER; + } + if (joined.startsWith("postgres list-projects")) { + return responses.projects ?? DEFAULT_PROJECTS; + } + if (joined.startsWith("postgres list-branches")) { + return responses.branches ?? DEFAULT_BRANCHES; + } + if (joined.startsWith("postgres create-branch")) { + return responses.createBranch ?? DEFAULT_CREATE_BRANCH; + } + if (joined.startsWith("postgres list-endpoints")) { + return responses.endpoints ?? DEFAULT_ENDPOINTS; + } + if (joined.startsWith("postgres list-databases")) { + return responses.databases ?? DEFAULT_DATABASES; + } + throw new Error(`Unexpected CLI args: ${joined}`); + }); + return tracked as ReturnType & CliRunner; +} + +/* ============================================================ */ +/* Test environment helpers */ +/* ============================================================ */ + +interface TestEnv { + cwd: string; + envPath: string; + cleanup: () => void; +} + +function mkTempProject( + opts: { schema?: boolean; seed?: boolean } = {}, +): TestEnv { + const cwd = mkdtempSync(path.join(tmpdir(), "appkit-init-")); + // databasePaths() walks up from cwd looking for package.json. + writeFileSync(path.join(cwd, "package.json"), '{"name":"fixture"}'); + if (opts.schema) { + const configDir = path.join(cwd, "config", "database"); + mkdirSync(configDir, { recursive: true }); + writeFileSync(path.join(configDir, "schema.ts"), "export default {};\n"); + if (opts.seed) { + writeFileSync( + path.join(configDir, "seed.sql"), + "INSERT INTO foo VALUES (1) ON CONFLICT DO NOTHING;\n", + ); + } + } + return { + cwd, + envPath: path.join(cwd, ".env"), + cleanup: () => rmSync(cwd, { recursive: true, force: true }), + }; +} + +let testEnv: TestEnv | undefined; +let envSnapshot: Record; + +beforeEach(() => { + // Snapshot OWNED keys so the default writer can't bleed state between tests. + envSnapshot = Object.fromEntries( + OWNED_ENV_KEYS.map((key) => [key, process.env[key]]), + ); +}); + +afterEach(() => { + for (const [key, value] of Object.entries(envSnapshot)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + testEnv?.cleanup(); + testEnv = undefined; + vi.unstubAllEnvs(); +}); + +/** + * Build deps with sane defaults: env-update collector, interactive=true, and + * a `loadSchemaFile` fake with non-empty `$tables` so the migrate preflight + * passes. Tests that exercise either path override the relevant field. + */ +function makeDeps( + overrides: Partial & { + tableCount?: number; + interactive?: boolean; + } = {}, +): RunInitDeps & { + setupDev: ReturnType; + runIntrospect: ReturnType; + verifyDatabase: ReturnType; + loadSchemaFile: ReturnType; + dropAllAppTables: ReturnType; + applyEnvUpdates: ReturnType; + capturedEnv: { path?: string; updates?: EnvUpdates }; +} { + const tableCount = overrides.tableCount ?? 0; + const captured: { path?: string; updates?: EnvUpdates } = {}; + const applyEnvUpdates = + overrides.applyEnvUpdates ?? + vi.fn((envPath: string, updates: EnvUpdates) => { + captured.path = envPath; + captured.updates = updates; + }); + return { + databricksCli: overrides.databricksCli ?? fakeCli(), + probeTableCount: overrides.probeTableCount ?? vi.fn(async () => tableCount), + setupDev: + (overrides.setupDev as ReturnType) ?? vi.fn(async () => {}), + runIntrospect: + (overrides.runIntrospect as ReturnType) ?? + vi.fn(async () => {}), + verifyDatabase: + (overrides.verifyDatabase as ReturnType) ?? + vi.fn(async () => {}), + loadSchemaFile: + (overrides.loadSchemaFile as ReturnType) ?? + vi.fn(async () => ({ $drizzle: {}, $tables: { cases: {} } })), + dropAllAppTables: + (overrides.dropAllAppTables as ReturnType) ?? + vi.fn(async () => {}), + applyEnvUpdates: applyEnvUpdates as ReturnType, + isInteractive: + overrides.isInteractive ?? + (() => + overrides.interactive === undefined ? true : overrides.interactive), + capturedEnv: captured, + }; +} + +function scriptedOptions(extra: Partial = {}): RunInitOptions { + // Pass every flag that would otherwise prompt, so tests never render @clack + // on non-TTY stdin and stay deterministic. + return { + profile: "DEFAULT", + project: "projects/foo", + from: "migrate", + schema: "public", + seed: false, + cwd: testEnv?.cwd, + ...extra, + }; +} + +/* ============================================================ */ +/* Pure helpers */ +/* ============================================================ */ + +describe("slugifyPrincipal", () => { + test.each([ + ["jane.doe", "jane-doe"], + ["Alice Smith", "alice-smith"], + ["a".repeat(64), "a".repeat(32)], + ["---weird---", "weird"], + ["", ""], + ])("slugifies %j to %j", (input, expected) => { + expect(slugifyPrincipal(input)).toBe(expected); + }); +}); + +describe("shortHash", () => { + test("is deterministic and 8 hex chars", () => { + expect(shortHash("u-1")).toBe(shortHash("u-1")); + expect(shortHash("u-1")).toMatch(/^[0-9a-f]{8}$/); + }); +}); + +describe("deriveDevBranchName", () => { + test("produces dev-{slug}-{hash} within Lakebase 63-char budget", () => { + const name = deriveDevBranchName({ + id: "u-9", + principal: "a".repeat(200), + }); + expect(name).toMatch(/^dev-a{32}-[0-9a-f]{8}$/); + expect(name.length).toBeLessThanOrEqual(63); + }); +}); + +describe("OWNED_ENV_KEYS", () => { + test("matches the eight keys documented in the JSDoc", () => { + expect(OWNED_ENV_KEYS).toEqual([ + "DATABRICKS_HOST", + "DATABRICKS_CONFIG_PROFILE", + "LAKEBASE_ENDPOINT", + "PGHOST", + "PGDATABASE", + "PGUSER", + "PGPORT", + "PGSSLMODE", + ]); + }); +}); + +/* ============================================================ */ +/* Commander wiring */ +/* ============================================================ */ + +describe("initCommand", () => { + test("is registered as 'init' with the expected flags", () => { + expect(initCommand.name()).toBe("init"); + const flags = initCommand.options.map((opt) => opt.flags); + expect(flags).toEqual( + expect.arrayContaining([ + "--profile ", + "--project ", + "--from ", + "--schema ", + "--seed", + "--no-seed", + "--yes", + ]), + ); + }); + + test("commander parses --no-seed as seed=false; --seed as true; absent as undefined", async () => { + // Guards against a future Commander upgrade silently changing this. + // Assert on opts() directly so we don't have to mock all of runInit. + const cases: Array<[string[], boolean | undefined]> = [ + [[], undefined], + [["--seed"], true], + [["--no-seed"], false], + ]; + for (const [argv, expected] of cases) { + const cmd = new Command("init").option("--seed").option("--no-seed"); + cmd.parse(["node", "init", ...argv], { from: "node" }); + expect(cmd.opts().seed).toBe(expected); + } + }); +}); + +/* ============================================================ */ +/* runInit orchestration */ +/* ============================================================ */ + +describe("runInit — migrate flow", () => { + test("writes .env and calls setupDev with seed=false", async () => { + testEnv = mkTempProject({ schema: true }); + + const deps = makeDeps({ tableCount: 0 }); + await runInit(scriptedOptions({ from: "migrate", seed: false }), deps); + + expect(deps.setupDev).toHaveBeenCalledWith({ + name: "init", + seed: false, + force: false, + }); + expect(deps.runIntrospect).not.toHaveBeenCalled(); + expect(deps.verifyDatabase).not.toHaveBeenCalled(); + + expect(deps.capturedEnv.path).toBe(testEnv.envPath); + expect(deps.capturedEnv.updates).toEqual({ + DATABRICKS_HOST: "https://h.example", + DATABRICKS_CONFIG_PROFILE: "DEFAULT", + LAKEBASE_ENDPOINT: + "projects/foo/branches/dev-alice-XXXXXX/endpoints/primary", + PGHOST: "ep.example", + PGDATABASE: "databricks_postgres", + PGUSER: "alice@example.com", + PGPORT: "5432", + PGSSLMODE: "require", + }); + }); + + test("soft-fails gracefully when config/database/schema.ts is missing", async () => { + // Used to throw; now prints a starter snippet so `db init` is safe to re-run. + testEnv = mkTempProject({ schema: false }); + + const deps = makeDeps({ tableCount: 0 }); + await expect( + runInit(scriptedOptions({ from: "migrate" }), deps), + ).resolves.toBeUndefined(); + expect(deps.setupDev).not.toHaveBeenCalled(); + expect(deps.loadSchemaFile).not.toHaveBeenCalled(); + }); + + test("soft-fails gracefully when schema.ts defines no tables", async () => { + testEnv = mkTempProject({ schema: true }); + const deps = makeDeps({ + loadSchemaFile: vi.fn(async () => ({ $drizzle: {}, $tables: {} })), + }); + + await runInit(scriptedOptions({ from: "migrate", seed: false }), deps); + + expect(deps.setupDev).not.toHaveBeenCalled(); + }); + + test("explicit --from migrate does NOT auto-pivot when schema is populated", async () => { + testEnv = mkTempProject({ schema: true }); + + const deps = makeDeps({ tableCount: 99 }); + await runInit(scriptedOptions({ from: "migrate", seed: false }), deps); + + expect(deps.setupDev).toHaveBeenCalled(); + expect(deps.runIntrospect).not.toHaveBeenCalled(); + }); +}); + +describe("runInit — reset flow", () => { + test("drops all tables then delegates to setupDev", async () => { + testEnv = mkTempProject({ schema: true }); + + const deps = makeDeps({}); + // `--yes` skips the typed-branch confirm (same flag CI uses). + await runInit( + scriptedOptions({ + from: "reset", + seed: false, + yes: true, + allowDestructive: true, + }), + deps, + ); + + expect(deps.dropAllAppTables).toHaveBeenCalledWith({ + schema: "public", + allowDestructive: true, + }); + expect(deps.setupDev).toHaveBeenCalledWith({ + name: "init", + seed: false, + force: false, + }); + // dropAllAppTables MUST precede setupDev: we don't migrate over stale tables. + const dropOrder = deps.dropAllAppTables.mock.invocationCallOrder[0]; + const setupOrder = deps.setupDev.mock.invocationCallOrder[0]; + expect(dropOrder).toBeLessThan(setupOrder); + }); + + test("reset honors --schema for the target pg schema", async () => { + testEnv = mkTempProject({ schema: true }); + + const deps = makeDeps({}); + await runInit( + scriptedOptions({ + from: "reset", + schema: "app", + seed: false, + yes: true, + allowDestructive: true, + }), + deps, + ); + + expect(deps.dropAllAppTables).toHaveBeenCalledWith({ + schema: "app", + allowDestructive: true, + }); + }); + + test("reset short-circuits on missing schema.ts (does NOT drop tables)", async () => { + testEnv = mkTempProject({ schema: false }); + + const deps = makeDeps({}); + await runInit( + scriptedOptions({ from: "reset", yes: true, allowDestructive: true }), + deps, + ); + + expect(deps.dropAllAppTables).not.toHaveBeenCalled(); + expect(deps.setupDev).not.toHaveBeenCalled(); + }); + + test("reset short-circuits on empty $tables (does NOT drop tables)", async () => { + testEnv = mkTempProject({ schema: true }); + + const deps = makeDeps({ + loadSchemaFile: vi.fn(async () => ({ $drizzle: {}, $tables: {} })), + }); + await runInit( + scriptedOptions({ + from: "reset", + seed: false, + yes: true, + allowDestructive: true, + }), + deps, + ); + + expect(deps.dropAllAppTables).not.toHaveBeenCalled(); + expect(deps.setupDev).not.toHaveBeenCalled(); + }); +}); + +describe("runInit — dry-run", () => { + test("dry-run skips applyEnvUpdates and the migrate flow", async () => { + testEnv = mkTempProject({ schema: true }); + + const deps = makeDeps({}); + await runInit( + scriptedOptions({ from: "migrate", seed: false, dryRun: true }), + deps, + ); + + expect(deps.applyEnvUpdates).not.toHaveBeenCalled(); + expect(deps.setupDev).not.toHaveBeenCalled(); + expect(deps.runIntrospect).not.toHaveBeenCalled(); + }); +}); + +describe("runInit — seed gate", () => { + test("--seed with missing seed.sql warns and skips instead of crashing", async () => { + // No seed.sql on disk, but caller passes --seed=true. + testEnv = mkTempProject({ schema: true, seed: false }); + + const deps = makeDeps({}); + await runInit(scriptedOptions({ from: "migrate", seed: true }), deps); + + expect(deps.setupDev).toHaveBeenCalledWith({ + name: "init", + seed: false, + force: false, + }); + }); + + test("--seed with existing seed.sql passes seed=true through", async () => { + testEnv = mkTempProject({ schema: true, seed: true }); + + const deps = makeDeps({}); + await runInit(scriptedOptions({ from: "migrate", seed: true }), deps); + + expect(deps.setupDev).toHaveBeenCalledWith({ + name: "init", + seed: true, + force: false, + }); + }); + + test("no --seed flag + no seed.sql on disk: seed=false, no crash", async () => { + testEnv = mkTempProject({ schema: true, seed: false }); + + const deps = makeDeps({}); + // scriptedOptions defaults seed=false; strip it to test "absent" path. + const opts = scriptedOptions({ from: "migrate" }); + delete (opts as { seed?: unknown }).seed; + await runInit(opts, deps); + + expect(deps.setupDev).toHaveBeenCalledWith({ + name: "init", + seed: false, + force: false, + }); + }); +}); + +describe("runInit — introspect flow", () => { + test("calls runIntrospect then verifyDatabase, no setupDev", async () => { + testEnv = mkTempProject(); + + const deps = makeDeps({ tableCount: 7 }); + await runInit(scriptedOptions({ from: "introspect" }), deps); + + expect(deps.runIntrospect).toHaveBeenCalledWith({ schema: "public" }); + expect(deps.verifyDatabase).toHaveBeenCalledWith({}); + expect(deps.setupDev).not.toHaveBeenCalled(); + }); +}); + +describe("runInit — branch lifecycle", () => { + test("reuses existing dev branch when present", async () => { + testEnv = mkTempProject({ schema: true }); + + const branchName = `projects/foo/branches/${deriveDevBranchName({ + id: DEFAULT_USER.id, + principal: "alice", + })}`; + const cli = fakeCli({ + branches: [ + { name: "projects/foo/branches/main", status: { default: true } }, + { name: branchName, status: { default: false } }, + ], + }); + const deps = makeDeps({ databricksCli: cli, tableCount: 0 }); + + await runInit(scriptedOptions({ from: "migrate", seed: false }), deps); + + const allCalls = cli.mock.calls.map((c) => (c[0] as string[]).join(" ")); + expect(allCalls.some((c) => c.startsWith("postgres create-branch"))).toBe( + false, + ); + expect(deps.setupDev).toHaveBeenCalled(); + }); + + test("creates dev branch from project's default when missing", async () => { + testEnv = mkTempProject({ schema: true }); + + const cli = fakeCli(); + const deps = makeDeps({ databricksCli: cli, tableCount: 0 }); + await runInit(scriptedOptions({ from: "migrate", seed: false }), deps); + + const createCall = cli.mock.calls.find((c) => + (c[0] as string[]).join(" ").startsWith("postgres create-branch"), + ); + expect(createCall).toBeDefined(); + const argsArr = createCall?.[0] as string[]; + const jsonBody = argsArr[argsArr.indexOf("--json") + 1]; + expect(JSON.parse(jsonBody)).toMatchObject({ + spec: { + source_branch: "projects/foo/branches/main", + no_expiry: true, + }, + }); + }); + + test("throws when project has no default branch to clone from", async () => { + testEnv = mkTempProject({ schema: true }); + + const deps = makeDeps({ + databricksCli: fakeCli({ branches: [] }), + tableCount: 0, + }); + + await expect( + runInit(scriptedOptions({ from: "migrate" }), deps), + ).rejects.toThrow(/no default branch to clone from/); + }); +}); + +describe("runInit — endpoint enforcement", () => { + test("rejects when no read-write endpoint exists", async () => { + testEnv = mkTempProject({ schema: true }); + + const cli = fakeCli({ + endpoints: [ + { + name: "projects/foo/branches/main/endpoints/replica", + status: { + endpoint_type: "ENDPOINT_TYPE_READ_ONLY", + hosts: { host: "ro.example" }, + }, + }, + ], + }); + const deps = makeDeps({ databricksCli: cli, tableCount: 0 }); + + await expect( + runInit(scriptedOptions({ from: "migrate" }), deps), + ).rejects.toThrow(/No read-write endpoint/); + }); +}); + +describe("runInit — env writer", () => { + test("preserves non-allow-list keys in .env on rewrite", async () => { + testEnv = mkTempProject({ schema: true }); + writeFileSync( + testEnv.envPath, + [ + "# project secrets", + "OPENAI_API_KEY=sk-secret", + "", + "PGHOST=stale-value", + "# trailing comment", + "", + ].join("\n"), + "utf8", + ); + + // No override → exercise the real file-path writer. + const deps = makeDeps({ tableCount: 0 }); + deps.applyEnvUpdates = undefined as unknown as ReturnType; + await runInit(scriptedOptions({ from: "migrate", seed: false }), { + databricksCli: deps.databricksCli, + probeTableCount: deps.probeTableCount, + setupDev: deps.setupDev, + runIntrospect: deps.runIntrospect, + verifyDatabase: deps.verifyDatabase, + loadSchemaFile: deps.loadSchemaFile, + dropAllAppTables: deps.dropAllAppTables, + isInteractive: deps.isInteractive, + }); + + const env = readFileSync(testEnv.envPath, "utf8"); + expect(env).toContain("# project secrets"); + expect(env).toContain("OPENAI_API_KEY=sk-secret"); + expect(env).toContain("# trailing comment"); + expect(env).toContain("PGHOST=ep.example"); + expect(env).not.toContain("PGHOST=stale-value"); + }); + + test("default writer mirrors values into process.env", async () => { + testEnv = mkTempProject({ schema: true }); + + const deps = makeDeps({ tableCount: 0 }); + deps.applyEnvUpdates = undefined as unknown as ReturnType; + await runInit(scriptedOptions({ from: "migrate", seed: false }), { + databricksCli: deps.databricksCli, + probeTableCount: deps.probeTableCount, + setupDev: deps.setupDev, + runIntrospect: deps.runIntrospect, + verifyDatabase: deps.verifyDatabase, + loadSchemaFile: deps.loadSchemaFile, + dropAllAppTables: deps.dropAllAppTables, + isInteractive: deps.isInteractive, + }); + + expect(process.env.PGHOST).toBe("ep.example"); + expect(process.env.PGDATABASE).toBe("databricks_postgres"); + expect(process.env.DATABRICKS_CONFIG_PROFILE).toBe("DEFAULT"); + }); +}); + +describe("runInit — non-interactive (--yes)", () => { + test("auto-detected mode is honored when probe succeeds", async () => { + testEnv = mkTempProject({ schema: true }); + const deps = makeDeps({ tableCount: 0, interactive: false }); + await runInit( + { + cwd: testEnv.cwd, + profile: "DEFAULT", + project: "projects/foo", + seed: false, + yes: true, + }, + deps, + ); + + expect(deps.setupDev).toHaveBeenCalledWith({ + name: "init", + seed: false, + force: false, + }); + }); + + test("errors with actionable message when probe fails and --from is absent", async () => { + testEnv = mkTempProject({ schema: true }); + const deps = makeDeps({ + probeTableCount: vi.fn(async () => { + throw new Error("connection refused"); + }), + interactive: false, + }); + + await expect( + runInit( + { + cwd: testEnv.cwd, + profile: "DEFAULT", + project: "projects/foo", + yes: true, + }, + deps, + ), + ).rejects.toThrow(/Could not auto-detect setup mode/); + }); + + test("errors when --profile is absent and multiple profiles exist", async () => { + testEnv = mkTempProject({ schema: true }); + const cli = fakeCli({ + profiles: { + profiles: [ + { name: "DEFAULT", host: "https://h.example" }, + { name: "PROD", host: "https://p.example" }, + ], + }, + }); + const deps = makeDeps({ databricksCli: cli, interactive: false }); + + await expect( + runInit( + { + cwd: testEnv.cwd, + project: "projects/foo", + from: "introspect", + yes: true, + }, + deps, + ), + ).rejects.toThrow(/specify --profile/); + }); + + test("errors when --project is absent and multiple projects exist", async () => { + testEnv = mkTempProject({ schema: true }); + const cli = fakeCli({ + projects: [ + { name: "projects/foo", status: { display_name: "Foo" } }, + { name: "projects/bar", status: { display_name: "Bar" } }, + ], + }); + const deps = makeDeps({ databricksCli: cli, interactive: false }); + + await expect( + runInit( + { + cwd: testEnv.cwd, + profile: "DEFAULT", + from: "introspect", + yes: true, + }, + deps, + ), + ).rejects.toThrow(/specify --project/); + }); + + test("rejects --profile that doesn't exist", async () => { + testEnv = mkTempProject({ schema: true }); + const deps = makeDeps({ interactive: false }); + + await expect( + runInit( + { + cwd: testEnv.cwd, + profile: "NOT_A_REAL_PROFILE", + project: "projects/foo", + from: "introspect", + yes: true, + }, + deps, + ), + ).rejects.toThrow(/Profile "NOT_A_REAL_PROFILE" not found/); + }); +}); diff --git a/packages/shared/src/cli/commands/db/index.ts b/packages/shared/src/cli/commands/db/index.ts index 3b4f37997..2c6303e22 100644 --- a/packages/shared/src/cli/commands/db/index.ts +++ b/packages/shared/src/cli/commands/db/index.ts @@ -1,4 +1,5 @@ import { Command } from "commander"; +import { initCommand } from "./init"; import { introspectCommand } from "./introspect"; import { migrateCommand } from "./migrate"; import { migrationCommand } from "./migration"; @@ -11,6 +12,7 @@ import { verifyCommand } from "./verify"; */ export const dbCommand = new Command("db") .description("Database (Lakebase) management commands") + .addCommand(initCommand) .addCommand(introspectCommand) .addCommand(migrationCommand) .addCommand(migrateCommand) @@ -21,6 +23,7 @@ export const dbCommand = new Command("db") "after", ` Examples: + $ appkit db init # one-command onboarding (interactive) $ appkit db introspect $ appkit db migration generate --name init $ appkit db migrate up 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..73b91c03d --- /dev/null +++ b/packages/shared/src/cli/commands/db/init.ts @@ -0,0 +1,1231 @@ +/** + * `appkit db init` — one-command Lakebase onboarding. + * + * Always creates or reuses a per-user dev branch and writes `.env`. The + * schema sync direction differs by mode: + * - migrate: schema.ts → branch (generate + apply, optional seed). + * - introspect: branch → schema.ts (then verify). + * - reset: drop all app tables, then migrate. + * + * Auto-detect picks `migrate` for empty schemas, `introspect` otherwise. + * Pass `--from` to override. + * + * Phase helpers (pickWorkspace, resolveDevBranch, …) are testable via the + * `RunInitDeps` injection point. + */ + +import crypto from "node:crypto"; +import { + chmodSync, + existsSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; +import { + autocomplete, + confirm, + intro, + isCancel, + outro, + select, + spinner, + text, +} from "@clack/prompts"; +import { Command } from "commander"; +import { execa } from "execa"; +import { runIntrospect } from "./introspect"; +import { dropAllAppTables } from "./migrate"; +import { setupDev } from "./setup-dev"; +import { + bullet, + check, + databasePaths, + type LakebasePool, + loadSchemaFile, + openLakebasePool, + runCommandAction, + warn, +} from "./shared"; +import { verifyDatabase } from "./verify"; + +/* ============================================================ */ +/* Public types */ +/* ============================================================ */ + +/** + * Schema sync direction. Names mirror the underlying CLI commands. + * `reset` drops every app table then falls through to migrate — safe on + * dev branches because they're per-user clones that `db init` can recreate. + */ +export type InitMode = "migrate" | "introspect" | "reset"; + +const INIT_MODES: readonly InitMode[] = [ + "migrate", + "introspect", + "reset", +] as const; + +function isInitMode(value: string): value is InitMode { + return (INIT_MODES as readonly string[]).includes(value); +} + +export interface RunInitOptions { + profile?: string; + project?: string; + from?: InitMode; + schema?: string; + seed?: boolean; + /** Skip every confirmation prompt; refuse when a default is unavailable. */ + yes?: boolean; + /** Print env-diff and mode, then stop. Use with `--yes` to preview a CI run. */ + dryRun?: boolean; + /** Required with `--yes` for `--from reset`; otherwise the wipe refuses. */ + allowDestructive?: boolean; + /** Override the project root used to resolve `.env` and `config/database/`. */ + cwd?: string; +} + +export type EnvWriter = (envPath: string, updates: EnvUpdates) => void; + +/** Injection points so tests can run `runInit` without touching Databricks, Lakebase, or `.env`. */ +export interface RunInitDeps { + databricksCli?: CliRunner; + /** Probe used to auto-detect migrate vs introspect. */ + probeTableCount?: (schema: string) => Promise; + setupDev?: typeof setupDev; + runIntrospect?: typeof runIntrospect; + verifyDatabase?: typeof verifyDatabase; + /** Lets tests skip the "file exists but declares no tables" preflight without real Drizzle tables. */ + loadSchemaFile?: (schemaFile: string) => Promise; + dropAllAppTables?: typeof dropAllAppTables; + /** Defaults to file write + `process.env` mutation. */ + applyEnvUpdates?: EnvWriter; + /** Defaults to `process.stdin.isTTY === true`. */ + isInteractive?: () => boolean; +} + +/* ============================================================ */ +/* Constants */ +/* ============================================================ */ + +/** + * Env keys this command owns; keys outside this list are preserved verbatim. + * Increment-only — removing a key is breaking for apps already sourcing `.env`. + */ +export const OWNED_ENV_KEYS = [ + "DATABRICKS_HOST", + "DATABRICKS_CONFIG_PROFILE", + "LAKEBASE_ENDPOINT", + "PGHOST", + "PGDATABASE", + "PGUSER", + "PGPORT", + "PGSSLMODE", +] as const; + +export type OwnedEnvKey = (typeof OWNED_ENV_KEYS)[number]; +export type EnvUpdates = Partial>; + +const PG_PORT = "5432"; +const PG_SSLMODE = "require"; + +/** Switch from plain select to autocomplete once the choice list grows past this. */ +const PROMPT_AUTOCOMPLETE_THRESHOLD = 8; + +/* ============================================================ */ +/* Runner */ +/* ============================================================ */ + +export async function runInit( + options: RunInitOptions = {}, + deps: RunInitDeps = {}, +): Promise { + const cli = deps.databricksCli ?? defaultDatabricksCli; + const fns = { + setupDev: deps.setupDev ?? setupDev, + runIntrospect: deps.runIntrospect ?? runIntrospect, + verifyDatabase: deps.verifyDatabase ?? verifyDatabase, + probeTableCount: deps.probeTableCount ?? defaultProbeTableCount, + loadSchemaFile: deps.loadSchemaFile ?? loadSchemaFile, + dropAllAppTables: deps.dropAllAppTables ?? dropAllAppTables, + applyEnvUpdates: deps.applyEnvUpdates ?? defaultApplyEnvUpdates, + isInteractive: deps.isInteractive ?? defaultIsInteractive, + }; + const cwd = options.cwd ?? process.cwd(); + const interactive = !options.yes && fns.isInteractive(); + + intro("appkit db init"); + + const workspace = await pickWorkspace(cli, options, interactive); + const { user, branch } = await resolveDevBranch(cli, workspace); + const resources = await resolveLakebaseResources( + cli, + workspace.profile, + branch.fullName, + ); + + const envPath = path.join(databasePaths(cwd).root, ".env"); + const envUpdates = buildEnvUpdates({ workspace, user, ...resources }); + printEnvDiff(envPath, envUpdates, cwd); + + if (options.dryRun) { + const mode = await resolveMode(options, fns.probeTableCount, interactive); + console.log(bullet(`Mode (planned): ${mode}`)); + console.log( + warn("Dry run: .env not written, no tables touched, no flow delegated."), + ); + outro("Dry run complete."); + return; + } + + fns.applyEnvUpdates(envPath, envUpdates); + console.log(check(`.env updated (${path.relative(cwd, envPath)})`)); + + const mode = await resolveMode(options, fns.probeTableCount, interactive); + if (mode === "reset") { + await confirmReset(branch.fullName, options, interactive); + } + await delegateToFlow(mode, options, cwd, fns, interactive); + + outro("Database setup complete. Start your dev server."); +} + +/* ============================================================ */ +/* Phase 1: workspace (profile + project + host) */ +/* ============================================================ */ + +interface ResolvedWorkspace { + profile: string; + profileHost: string; + project: string; +} + +/** + * Pick profile + project and return them with the profile's host URL. + * Combined so we list profiles once instead of twice (prompt + host lookup). + */ +async function pickWorkspace( + cli: CliRunner, + options: RunInitOptions, + interactive: boolean, +): Promise { + const profiles = await listProfiles(cli); + const profile = await pickProfile(profiles, options.profile, interactive); + console.log(bullet(`Profile: ${profile}`)); + + const profileHost = profiles.find((p) => p.name === profile)?.host ?? ""; + + const project = await pickProject(cli, profile, options.project, interactive); + console.log(bullet(`Project: ${project}`)); + + return { profile, profileHost, project }; +} + +async function pickProfile( + profiles: ProfileSummary[], + preselected: string | undefined, + interactive: boolean, +): Promise { + if (profiles.length === 0) { + throw new Error( + "No Databricks profiles found. Run `databricks auth login` first.", + ); + } + if (preselected) { + if (!profiles.some((p) => p.name === preselected)) { + throw new Error( + `Profile "${preselected}" not found in ~/.databrickscfg. Available: ${profiles.map((p) => p.name).join(", ")}.`, + ); + } + return preselected; + } + if (profiles.length === 1) return profiles[0].name; + if (!interactive) { + throw new Error( + `Multiple Databricks profiles available; specify --profile (one of: ${profiles.map((p) => p.name).join(", ")}).`, + ); + } + + const choices = profiles.map((p) => ({ + value: p.name, + label: p.name, + hint: p.host, + })); + return promptChoice("Databricks profile", choices); +} + +async function pickProject( + cli: CliRunner, + profile: string, + preselected: string | undefined, + interactive: boolean, +): Promise { + const projects = await withSpinner( + "Loading Lakebase projects", + (list) => `Found ${list.length} Lakebase project(s)`, + async () => listProjects(cli, profile), + ); + + if (projects.length === 0) { + throw new Error( + `No Lakebase projects visible to profile "${profile}". Create one in the Databricks workspace, then re-run.`, + ); + } + if (preselected) { + if (!projects.some((p) => p.name === preselected)) { + throw new Error( + `Project "${preselected}" not visible to profile "${profile}". Available: ${projects.map((p) => p.name).join(", ")}.`, + ); + } + return preselected; + } + if (projects.length === 1) return projects[0].name; + if (!interactive) { + throw new Error( + `Multiple Lakebase projects available; specify --project (one of: ${projects.map((p) => p.name).join(", ")}).`, + ); + } + + const choices = projects.map((p) => ({ + value: p.name, + label: p.displayName, + hint: p.name, + })); + return promptChoice("Lakebase project", choices); +} + +async function promptChoice( + message: string, + choices: Array<{ value: string; label: string; hint?: string }>, +): Promise { + // Long lists benefit from type-to-filter; short lists don't. + const useAutocomplete = choices.length > PROMPT_AUTOCOMPLETE_THRESHOLD; + const choice = useAutocomplete + ? await autocomplete({ + message: `${message} (type to filter)`, + placeholder: "start typing to filter…", + options: choices, + }) + : await select({ message, options: choices }); + + if (isCancel(choice)) throw new Error("Cancelled."); + return String(choice); +} + +/* ============================================================ */ +/* Phase 2: dev branch (per-user) */ +/* ============================================================ */ + +interface ResolvedBranch { + /** Short id used in CLI commands (`dev-{slug}-{hash}`). */ + id: string; + /** Lakebase resource name (`projects/foo/branches/dev-...`). */ + fullName: string; + /** True when created on this run; false when reused. */ + created: boolean; +} + +interface ResolvedUserAndBranch { + user: UserSummary; + branch: ResolvedBranch; +} + +async function resolveDevBranch( + cli: CliRunner, + workspace: ResolvedWorkspace, +): Promise { + const user = await withSpinner( + "Resolving Databricks user", + (u) => `User: ${u.userName}`, + () => getCurrentUser(cli, workspace.profile), + ); + const branchId = deriveDevBranchName(user); + + const branches = await withSpinner( + `Looking up branches in ${workspace.project}`, + (list) => `Found ${list.length} branch(es) in ${workspace.project}`, + () => listBranches(cli, workspace.profile, workspace.project), + ); + + const existing = branches.find((b) => b.id === branchId); + if (existing) { + console.log(bullet(`Reusing dev branch: ${branchId}`)); + return { + user, + branch: { id: branchId, fullName: existing.name, created: false }, + }; + } + + const sourceBranch = branches.find((b) => b.isDefault); + if (!sourceBranch) { + throw new Error( + `Project ${workspace.project} has no default branch to clone from. ` + + `Create one first via the Databricks workspace or CLI.`, + ); + } + const created = await withSpinner( + `Creating dev branch ${branchId} from ${sourceBranch.id} (this can take a minute)`, + () => `Created dev branch: ${branchId}`, + () => + createBranch( + cli, + workspace.profile, + workspace.project, + branchId, + sourceBranch.id, + ), + ); + return { + user, + branch: { id: branchId, fullName: created.name, created: true }, + }; +} + +/* ============================================================ */ +/* Phase 3: Lakebase resources (endpoint + database) */ +/* ============================================================ */ + +interface ResolvedResources { + endpoint: EndpointSummary; + database: DatabaseSummary; +} + +/** Fetch endpoint + database in parallel; each is a ~500ms CLI shellout. */ +async function resolveLakebaseResources( + cli: CliRunner, + profile: string, + branchFullName: string, +): Promise { + return withSpinner( + "Resolving endpoint and database", + (r) => `Endpoint: ${r.endpoint.host}`, + async () => { + const [endpoint, database] = await Promise.all([ + getEndpoint(cli, profile, branchFullName), + getDatabase(cli, profile, branchFullName), + ]); + return { endpoint, database }; + }, + ); +} + +/* ============================================================ */ +/* Phase 4: env updates */ +/* ============================================================ */ + +function buildEnvUpdates(input: { + workspace: ResolvedWorkspace; + user: UserSummary; + endpoint: EndpointSummary; + database: DatabaseSummary; +}): EnvUpdates { + return { + DATABRICKS_HOST: input.workspace.profileHost, + DATABRICKS_CONFIG_PROFILE: input.workspace.profile, + LAKEBASE_ENDPOINT: input.endpoint.name, + PGHOST: input.endpoint.host, + PGDATABASE: input.database.postgresDatabase, + PGUSER: input.user.userName, + PGPORT: PG_PORT, + PGSSLMODE: PG_SSLMODE, + }; +} + +/** + * Persist to disk and mirror into `process.env` so follow-on phases + * (`probeTableCount`, `setupDev`, …) see the new values without re-sourcing. + */ +function defaultApplyEnvUpdates(envPath: string, updates: EnvUpdates): void { + // Snapshot the prior `.env` to `.env.bak.` (chmod 0600 in case it had secrets). + if (existsSync(envPath)) { + try { + const previous = readFileSync(envPath, "utf8"); + const bakPath = `${envPath}.bak.${Date.now()}`; + writeFileSync(bakPath, previous, { encoding: "utf8", mode: 0o600 }); + try { + chmodSync(bakPath, 0o600); + } catch {} + } catch (err) { + // Non-fatal: warn so the user can back up manually. + console.warn( + warn( + `Could not write .env.bak (${(err as Error).message}); proceeding anyway`, + ), + ); + } + } + writeEnvKeys(envPath, updates); + for (const key of OWNED_ENV_KEYS) { + const value = updates[key]; + if (value !== undefined) { + process.env[key] = value; + } + } +} + +/** + * Print a per-key ADD/CHANGE/KEEP plan so the user can catch typos before + * the write lands. Owned-key values are shown verbatim (none are secrets). + */ +function printEnvDiff(envPath: string, updates: EnvUpdates, cwd: string): void { + const existing: Partial> = {}; + if (existsSync(envPath)) { + const previous = readFileSync(envPath, "utf8"); + for (const line of previous.split(/\r?\n/)) { + const match = ENV_KEY_PATTERN.exec(line); + const key = match?.[1]; + if (!key) continue; + // Last-wins matches dotenv loader semantics; previous KEEP/CHANGE diff + // would otherwise contradict what the runtime ends up with. + if ((OWNED_ENV_KEYS as readonly string[]).includes(key)) { + existing[key as OwnedEnvKey] = parseEnvValue(line); + } + } + } + + console.log(bullet(`.env plan (${path.relative(cwd, envPath)})`)); + for (const key of OWNED_ENV_KEYS) { + const next = updates[key]; + if (next === undefined) continue; + const prev = existing[key]; + const tag = prev === undefined ? "ADD" : prev === next ? "KEEP" : "CHANGE"; + console.log(` ${tag.padEnd(7)} ${key}=${next}`); + } +} + +/** + * Require the user to type the branch name before reset drops every table. + * Under `--yes`, the typed-name check is replaced by `--allow-destructive` + * so a fat-fingered command can't silently wipe a branch. + */ +async function confirmReset( + branch: string, + options: RunInitOptions, + interactive: boolean, +): Promise { + if (options.yes) { + if (!options.allowDestructive) { + throw new Error( + `Reset refused: --from reset --yes requires --allow-destructive (would drop every table in "${branch}").`, + ); + } + return; + } + if (!interactive) { + throw new Error( + `Reset refused: non-interactive shell. Re-run with --yes --allow-destructive to confirm dropping every table in "${branch}".`, + ); + } + console.log(); + console.log(warn(`Reset will DROP every table in branch "${branch}".`)); + const typed = await text({ + message: `Type the branch name (${branch}) to confirm`, + placeholder: branch, + }); + if (isCancel(typed) || String(typed).trim() !== branch) { + throw new Error("Reset aborted: branch name did not match."); + } +} + +/* ============================================================ */ +/* Phase 5: mode resolution */ +/* ============================================================ */ + +async function resolveMode( + options: RunInitOptions, + probeTableCount: (schema: string) => Promise, + interactive: boolean, +): Promise { + // Explicit --from short-circuits the probe; no auto-pivot. + if (options.from) return options.from; + + const schemaName = options.schema ?? "public"; + const suggested = await probeMode(schemaName, probeTableCount); + + if (!interactive) { + if (!suggested) { + throw new Error( + `Could not auto-detect setup mode for schema "${schemaName}" and stdin is not interactive. ` + + `Pass --from migrate or --from introspect.`, + ); + } + console.log(bullet(`Auto-detected mode: ${suggested}`)); + return suggested; + } + + return pickMode(suggested); +} + +async function probeMode( + schemaName: string, + probeTableCount: (schema: string) => Promise, +): Promise { + const probeSpin = spinner(); + probeSpin.start(`Probing schema "${schemaName}" for existing tables`); + try { + const tableCount = await probeTableCount(schemaName); + const suggested: InitMode = tableCount === 0 ? "migrate" : "introspect"; + probeSpin.stop( + tableCount === 0 + ? `Schema "${schemaName}" is empty — suggesting "migrate" (apply schema.ts to the branch)` + : `Schema "${schemaName}" has ${tableCount} table(s) — suggesting "introspect" (import the branch into schema.ts)`, + ); + return suggested; + } catch (error) { + probeSpin.stop( + `Could not probe schema "${schemaName}" (${ + error instanceof Error ? error.message : String(error) + })`, + ); + return null; + } +} + +async function pickMode(suggested: InitMode | null): Promise { + const message = suggested + ? `Setup action (suggesting "${suggested}")` + : "Setup action"; + const choice = await select({ + message, + initialValue: suggested ?? "migrate", + options: [ + { + value: "migrate", + label: "Apply schema.ts → branch (migrate)", + hint: "Generate a migration from config/database/schema.ts and run it", + }, + { + value: "introspect", + label: "Import branch tables → schema.ts (introspect)", + hint: "Write config/database/schema.ts from the branch's existing tables", + }, + { + value: "reset", + label: "Drop all tables + apply schema.ts (reset)", + hint: "Wipe the dev branch and re-apply schema.ts from scratch", + }, + ], + }); + if (isCancel(choice)) throw new Error("Cancelled."); + return choice as InitMode; +} + +/* ============================================================ */ +/* Phase 6: delegate to migrate | introspect | reset */ +/* ============================================================ */ + +async function delegateToFlow( + mode: InitMode, + options: RunInitOptions, + cwd: string, + fns: { + setupDev: typeof setupDev; + runIntrospect: typeof runIntrospect; + verifyDatabase: typeof verifyDatabase; + loadSchemaFile: (schemaFile: string) => Promise; + dropAllAppTables: typeof dropAllAppTables; + }, + interactive: boolean, +): Promise { + if (mode === "migrate" || mode === "reset") { + const paths = databasePaths(cwd); + const preflight = await preflightMigrate(paths, fns.loadSchemaFile); + if (!preflight.ok) return; + + if (mode === "reset") { + const schemaName = options.schema ?? "public"; + console.log(bullet(`Dropping all tables in schema "${schemaName}"`)); + await fns.dropAllAppTables({ + schema: schemaName, + allowDestructive: true, + }); + } + + const seedFile = path.join(paths.configDir, "seed.sql"); + const seed = await resolveSeedChoice(options, seedFile, interactive); + await fns.setupDev({ name: "init", seed, force: false }); + return; + } + + const schemaName = options.schema ?? "public"; + await fns.runIntrospect({ schema: schemaName }); + await fns.verifyDatabase({}); +} + +/** + * Soft-fail with guidance (not a stack trace) when schema.ts is missing or + * declares no tables. Real authoring bugs (bad syntax, invalid export) still + * propagate. + */ +async function preflightMigrate( + paths: ReturnType, + loadSchema: (schemaFile: string) => Promise, +): Promise<{ ok: boolean }> { + if (!existsSync(paths.schemaFile)) { + console.log(); + console.log(warn("No config/database/schema.ts found yet.")); + console.log("Define your entities first, for example:"); + console.log(); + console.log(" // config/database/schema.ts"); + console.log( + ' import { defineSchema } from "@databricks/appkit/database";', + ); + console.log(); + console.log(" export default defineSchema(({ table, id, text }) => ({"); + console.log(' user: table("user", {'); + console.log(" id: id(),"); + console.log(" email: text().notNull(),"); + console.log(" }),"); + console.log(" }));"); + console.log(); + console.log("Then re-run `appkit db init`."); + return { ok: false }; + } + + const schema = await loadSchema(paths.schemaFile); + const tables = + (schema as { $tables?: Record } | null)?.$tables ?? {}; + if (Object.keys(tables).length === 0) { + console.log(); + console.log( + warn("config/database/schema.ts exists but defines no tables yet."), + ); + console.log( + "Add at least one table inside `defineSchema({ ... })` and re-run `appkit db init`.", + ); + console.log(); + return { ok: false }; + } + + return { ok: true }; +} + +async function resolveSeedChoice( + options: RunInitOptions, + seedFile: string, + interactive: boolean, +): Promise { + const seedExists = existsSync(seedFile); + + if (options.seed === false) return false; + + // `--seed` without seed.sql: warn and skip rather than crash in runSeed. + if (options.seed === true && !seedExists) { + console.log( + warn( + `seed.sql not found at ${path.relative(process.cwd(), seedFile)}; skipping seed.`, + ), + ); + return false; + } + + if (options.seed === true) return true; + if (!seedExists) return false; + if (!interactive) return true; + + const choice = await confirm({ + message: `Run ${path.basename(seedFile)} after migration?`, + initialValue: true, + }); + if (isCancel(choice)) throw new Error("Cancelled."); + return Boolean(choice); +} + +/* ============================================================ */ +/* Spinner helper */ +/* ============================================================ */ + +/** + * Run `fn` with a clack spinner that always closes (even on throw), so a + * crash can't leave the cursor hidden and the spinner animating. + * `successMessage` may be a function of the resolved value. + */ +async function withSpinner( + startMessage: string, + successMessage: string | ((value: T) => string), + fn: () => Promise, +): Promise { + const spin = spinner(); + spin.start(startMessage); + try { + const result = await fn(); + const finalMessage = + typeof successMessage === "function" + ? successMessage(result) + : successMessage; + spin.stop(finalMessage); + return result; + } catch (error) { + spin.stop(`Failed: ${startMessage}`); + throw error; + } +} + +/* ============================================================ */ +/* Databricks CLI shellouts */ +/* ============================================================ */ + +/** Shape of every Databricks CLI invocation. Args exclude the `databricks` binary. */ +export type CliRunner = (args: string[]) => Promise; + +interface ProfileSummary { + name: string; + host: string; +} + +interface ProjectSummary { + name: string; + displayName: string; +} + +interface BranchSummary { + /** Lakebase resource name (`projects/foo/branches/main`). */ + name: string; + /** Last path segment; used to address the branch in CLI calls. */ + id: string; + isDefault: boolean; +} + +interface EndpointSummary { + name: string; + host: string; +} + +interface DatabaseSummary { + name: string; + postgresDatabase: string; +} + +interface UserSummary { + id: string; + /** Stable identifier used to derive the dev branch slug. */ + principal: string; + /** Postgres role name (typically the email); written verbatim to PGUSER. */ + userName: string; +} + +/** + * Invoke the user's `databricks` binary and parse the JSON response. + * + * `--output json` (response format) is independent of `--json ` + * (request body); both can appear in the same call (e.g. `create-branch`). + */ +async function defaultDatabricksCli(args: string[]): Promise { + const fullArgs = [...args, "--output", "json"]; + let result: Awaited>; + try { + result = await execa("databricks", fullArgs, { reject: false }); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === "ENOENT") { + throw new Error( + "Databricks CLI not found on PATH. Install it from https://docs.databricks.com/aws/en/dev-tools/cli/install.", + ); + } + throw error; + } + if (result.exitCode !== 0) { + const stderr = String(result.stderr ?? result.stdout ?? "").trim(); + throw new Error(`databricks ${args.join(" ")} failed: ${stderr}`); + } + const stdout = String(result.stdout ?? "").trim(); + if (stdout.length === 0) return null; + try { + return JSON.parse(stdout); + } catch (parseError) { + const preview = stdout.length > 200 ? `${stdout.slice(0, 200)}…` : stdout; + throw new Error( + `databricks ${args.join(" ")} returned invalid JSON: ${preview}`, + { cause: parseError instanceof Error ? parseError : undefined }, + ); + } +} + +async function listProfiles(cli: CliRunner): Promise { + const raw = (await cli(["auth", "profiles"])) as { + profiles?: Array<{ name?: string; host?: string }>; + } | null; + const out: ProfileSummary[] = []; + for (const p of raw?.profiles ?? []) { + if (typeof p.name === "string" && typeof p.host === "string") { + out.push({ name: p.name, host: p.host }); + } + } + return out; +} + +async function getCurrentUser( + cli: CliRunner, + profile: string, +): Promise { + const raw = (await cli(["current-user", "me", "--profile", profile])) as { + id?: string; + userName?: string; + displayName?: string; + emails?: Array<{ value?: string; primary?: boolean }>; + } | null; + + if (!raw?.id) { + throw new Error( + `databricks current-user me did not return an id for profile "${profile}".`, + ); + } + + const email = + raw.emails?.find((e) => e.primary)?.value ?? raw.emails?.[0]?.value; + const userName = raw.userName ?? email ?? raw.displayName ?? "user"; + const principal = pickPrincipal({ + userName: raw.userName, + displayName: raw.displayName, + email, + }); + + return { id: raw.id, principal, userName }; +} + +async function listProjects( + cli: CliRunner, + profile: string, +): Promise { + const raw = (await cli([ + "postgres", + "list-projects", + "--profile", + profile, + ])) as Array<{ name?: string; status?: { display_name?: string } }> | null; + const out: ProjectSummary[] = []; + for (const p of raw ?? []) { + if (typeof p.name === "string") { + out.push({ + name: p.name, + displayName: p.status?.display_name ?? p.name, + }); + } + } + return out; +} + +async function listBranches( + cli: CliRunner, + profile: string, + project: string, +): Promise { + const raw = (await cli([ + "postgres", + "list-branches", + project, + "--profile", + profile, + ])) as Array<{ name?: string; status?: { default?: boolean } }> | null; + const out: BranchSummary[] = []; + for (const b of raw ?? []) { + if (typeof b.name === "string") { + out.push({ + name: b.name, + id: b.name.split("/").pop() ?? b.name, + isDefault: b.status?.default === true, + }); + } + } + return out; +} + +/** + * Clone the project's default branch into a per-user dev branch. + * `no_expiry: true` avoids silent mid-development deletion; users who want + * a TTL can `databricks postgres delete-branch` manually. + */ +async function createBranch( + cli: CliRunner, + profile: string, + project: string, + branchId: string, + sourceBranchId: string, +): Promise<{ name: string }> { + const body = JSON.stringify({ + spec: { + source_branch: `${project}/branches/${sourceBranchId}`, + no_expiry: true, + }, + }); + const raw = (await cli([ + "postgres", + "create-branch", + project, + branchId, + "--profile", + profile, + "--json", + body, + ])) as { name?: string } | null; + if (!raw?.name) { + throw new Error( + `create-branch returned no name for ${project}/branches/${branchId}.`, + ); + } + return { name: raw.name }; +} + +/** + * Resolve the read-write endpoint. We require `ENDPOINT_TYPE_READ_WRITE` + * explicitly so a silent fallback to a read-only endpoint can't surface as + * a confusing pg "permission denied" mid-migration. + */ +async function getEndpoint( + cli: CliRunner, + profile: string, + branchName: string, +): Promise { + const raw = (await cli([ + "postgres", + "list-endpoints", + branchName, + "--profile", + profile, + ])) as Array<{ + name?: string; + status?: { endpoint_type?: string; hosts?: { host?: string } }; + }> | null; + const endpoints = raw ?? []; + const chosen = endpoints.find( + (e) => e.status?.endpoint_type === "ENDPOINT_TYPE_READ_WRITE", + ); + if (!chosen?.name || !chosen.status?.hosts?.host) { + const found = endpoints + .map((e) => e.status?.endpoint_type ?? "unknown") + .join(", "); + throw new Error( + `No read-write endpoint on branch ${branchName}. db init requires write access. ` + + `Found endpoint types: ${found || "none"}.`, + ); + } + return { name: chosen.name, host: chosen.status.hosts.host }; +} + +async function getDatabase( + cli: CliRunner, + profile: string, + branchName: string, +): Promise { + const raw = (await cli([ + "postgres", + "list-databases", + branchName, + "--profile", + profile, + ])) as Array<{ + name?: string; + status?: { postgres_database?: string }; + }> | null; + const first = (raw ?? [])[0]; + if (!first?.name || !first.status?.postgres_database) { + throw new Error(`No databases found on branch ${branchName}.`); + } + return { + name: first.name, + postgresDatabase: first.status.postgres_database, + }; +} + +/* ============================================================ */ +/* .env writer (line-oriented, preserves comments + foreign keys) */ +/* ============================================================ */ + +const ENV_KEY_PATTERN = /^([A-Z][A-Z0-9_]*)=/; + +/** + * Read a `.env` line's value, stripping surrounding double or single quotes + * and trailing carriage returns (Windows). Used by `printEnvDiff` so re-runs + * don't show spurious "CHANGE" diffs on hand-quoted values or CRLF files. + */ +function parseEnvValue(line: string): string { + let value = line.slice(line.indexOf("=") + 1); + if (value.endsWith("\r")) value = value.slice(0, -1); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + return value; +} + +function writeEnvKeys(envPath: string, updates: EnvUpdates): void { + const remaining = new Map(); + for (const [key, value] of Object.entries(updates)) { + if (value !== undefined) remaining.set(key, value); + } + const existing = existsSync(envPath) ? readFileSync(envPath, "utf8") : ""; + const lines = existing === "" ? [] : existing.split(/\r?\n/); + // Drop trailing empty line to avoid double-newline on append. + if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + + // Track which keys we've written so duplicate KEY= lines collapse — dotenv + // loads the last occurrence; if we replace only the first, the runtime gets + // a different value than the diff promised. + const written = new Set(); + const out: string[] = []; + for (const line of lines) { + const match = ENV_KEY_PATTERN.exec(line); + const key = match?.[1]; + if (key && remaining.has(key)) { + if (!written.has(key)) { + out.push(`${key}=${remaining.get(key) ?? ""}`); + written.add(key); + remaining.delete(key); + } + // Skip subsequent lines for the same owned key (dedupe). + } else if (key && written.has(key)) { + // Drop duplicate of an owned key we already wrote. + } else { + out.push(line); + } + } + for (const [key, value] of remaining) { + out.push(`${key}=${value}`); + } + out.push(""); + // tmp + rename: POSIX-atomic so Ctrl-C can't truncate `.env`. + const tmpPath = `${envPath}.tmp.${process.pid}.${Date.now()}`; + writeFileSync(tmpPath, out.join("\n"), { encoding: "utf8", mode: 0o600 }); + try { + chmodSync(tmpPath, 0o600); + } catch {} + renameSync(tmpPath, envPath); +} + +/* ============================================================ */ +/* Pure helpers */ +/* ============================================================ */ + +/** + * Pick a stable identifier in this order: email local-part → userName → + * displayName → `"user"`. The literal fallback guarantees a non-empty slug + * (no `dev--abc123`). + */ +function pickPrincipal(input: { + userName?: string; + displayName?: string; + email?: string; +}): string { + const candidate = input.email ?? input.userName; + if (candidate?.includes("@")) { + const local = candidate.split("@")[0]; + if (local) return local; + } + return input.displayName ?? input.userName ?? "user"; +} + +const SLUG_MAX_LEN = 32; + +export function slugifyPrincipal(principal: string): string { + return principal + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, SLUG_MAX_LEN) + .replace(/-+$/g, ""); +} + +/** + * 8 hex chars of SHA-256(user id). Naming only, not security: a collision + * just means two users share a dev branch (Lakebase auth still applies). + * 1-in-4B is enough for any realistic team size. + */ +export function shortHash(id: string): string { + return crypto.createHash("sha256").update(id).digest("hex").slice(0, 8); +} + +export function deriveDevBranchName(user: { + id: string; + principal: string; +}): string { + return `dev-${slugifyPrincipal(user.principal)}-${shortHash(user.id)}`; +} + +function defaultIsInteractive(): boolean { + return Boolean(process.stdin.isTTY); +} + +/** + * Count tables in the target schema to suggest migrate (empty) vs introspect + * (populated). Uses `pg_catalog.pg_tables` to exclude views, materialized + * views, foreign tables, and partitions — only ordinary tables count. + */ +async function defaultProbeTableCount(schemaName: string): Promise { + const pool: LakebasePool | null = await openLakebasePool(); + if (!pool) { + throw new Error("No Lakebase connection. Set LAKEBASE_ENDPOINT or PGHOST."); + } + try { + const result = await pool.query<{ table_count: number | string }>( + "SELECT count(*)::int AS table_count FROM pg_catalog.pg_tables WHERE schemaname = $1", + [schemaName], + ); + const value = result.rows[0]?.table_count ?? 0; + return typeof value === "number" ? value : Number(value); + } finally { + await pool.end().catch(() => { + /* swallow so we don't mask the original error */ + }); + } +} + +/* ============================================================ */ +/* Commander wiring */ +/* ============================================================ */ + +export const initCommand = new Command("init") + .description("One-command Lakebase database onboarding") + .option("--profile ", "Databricks profile to use") + .option("--project ", "Lakebase project resource name") + .option( + "--from ", + "Setup action: migrate | introspect | reset — `reset` is destructive (drops every app table); default auto-detects", + ) + .option("--schema ", "Target Postgres schema (default: public)") + .option( + "--seed", + "Run config/database/seed.sql after migration (no-op when seed.sql is missing; migrate only)", + ) + .option("--no-seed", "Skip seed.sql even if present (migrate only)") + .option("--yes", "Run non-interactively; require flags for ambiguous choices") + .option( + "--dry-run", + "Print env-diff and resolved mode without writing .env or running the flow (still requires a Lakebase connection)", + ) + .option( + "--allow-destructive", + "Required with --yes for --from reset (otherwise the wipe refuses)", + ) + .action((opts) => + runCommandAction(() => + runInit({ + profile: opts.profile ? String(opts.profile) : undefined, + project: opts.project ? String(opts.project) : undefined, + from: parseFromOption(opts.from), + schema: opts.schema ? String(opts.schema) : undefined, + // Commander: --no-seed → false, --seed → true, absent → undefined + // (so resolveSeedChoice can prompt or default per `--yes`). + seed: opts.seed === undefined ? undefined : Boolean(opts.seed), + yes: Boolean(opts.yes), + dryRun: Boolean(opts.dryRun), + allowDestructive: Boolean(opts.allowDestructive), + }), + ), + ); + +/** + * Validate `--from ` against the union so a typo (e.g. `--from forced`) + * fails loudly here rather than slipping into `runInit`. + */ +function parseFromOption(value: unknown): InitMode | undefined { + if (value === undefined || value === null || value === "") return undefined; + const str = String(value); + if (isInitMode(str)) return str; + throw new Error( + `Invalid --from value: "${str}". Expected one of: ${INIT_MODES.join(", ")}.`, + ); +} diff --git a/packages/shared/src/cli/commands/db/migrate.ts b/packages/shared/src/cli/commands/db/migrate.ts index fbf3792d7..09673e847 100644 --- a/packages/shared/src/cli/commands/db/migrate.ts +++ b/packages/shared/src/cli/commands/db/migrate.ts @@ -41,10 +41,9 @@ export const migrateCommand = new Command("migrate") ); /** - * Apply pending migrations under a Postgres session-level advisory lock so - * two concurrent deploys cannot race the same migration. The lock is held on - * the migration client for the lifetime of the migrator; a second runner - * blocks on its own `pg_advisory_lock` call until the first releases. + * Apply pending migrations under a session-level advisory lock so two + * concurrent deploys can't race. A second runner blocks on its own + * `pg_advisory_lock` until the first releases. */ export async function migrateUp( opts: { dryRun?: boolean } = {}, @@ -67,9 +66,8 @@ export async function migrateUp( try { await setMigrationSearchPath(client); console.log(bullet("Applying migrations with drizzle-orm migrator")); - // drizzle-orm typings expect a `pg` PoolClient; the LakebaseClient shape - // we expose is structurally compatible at runtime. Use `never` to opt out - // of the strict positional typing. + // LakebaseClient is structurally pg.PoolClient at runtime; cast `never` + // to bypass drizzle-orm's strict positional typing. const db = drizzle(client as never); await migrate(db, { migrationsFolder: paths.migrationsDir }); } finally { @@ -83,8 +81,7 @@ export async function migrateUp( } async function acquireMigrationLock(client: LakebaseClient): Promise { - // pg_try_advisory_lock + bounded retry so a wedged CI session can't block - // follow-on deploys forever. + // try_advisory_lock + bounded retry so a wedged CI session can't block forever. const LOCK_TIMEOUT_MS = 10 * 60 * 1000; const LOCK_RETRY_MS = 5_000; const lockDeadline = Date.now() + LOCK_TIMEOUT_MS; @@ -110,33 +107,34 @@ async function releaseMigrationLock(client: LakebaseClient): Promise { `SELECT pg_advisory_unlock(hashtext('${ADVISORY_LOCK_NAME}'))`, ); } catch (error) { + // Surface as a real failure with the recovery hint — a stuck lock blocks + // every subsequent deploy. New users won't know to look at pg_locks. console.error( warn( - `Failed to release migration advisory lock: ${(error as Error).message}`, + `Failed to release migration advisory lock: ${(error as Error).message}\n` + + ` → Run \`SELECT pg_advisory_unlock_all()\` from a fresh psql session, ` + + `or check pg_locks for the stuck owner.`, ), ); } } /** - * Check out a dedicated client when the pool supports it; fall back to running - * statements directly on the pool otherwise. - * - * Migrations need a single connection so `SET search_path` and the migrator's - * `BEGIN/COMMIT` see the same session state. + * Dedicated client: `SET search_path`, the advisory lock, and BEGIN/COMMIT + * MUST share one session — running through the pool would scatter them. */ async function getMigrationClient(pool: LakebasePool): Promise { - if (pool.connect) return pool.connect(); - return { - query: pool.query, - release: undefined, - }; + if (!pool.connect) { + throw new Error( + "Migration pool must support `connect()` so the advisory lock, search_path, and migrations share one session.", + ); + } + return pool.connect(); } /** - * Pin the migration session to the schema declared by the user so that the - * generated CREATE TABLE statements (which use unqualified names) land in the - * right schema instead of falling back to `public`. + * Pin the session to the declared schema so unqualified CREATE TABLE statements + * land there instead of falling back to `public`. */ async function setMigrationSearchPath(client: LakebaseClient): Promise { const schemaName = await getDeclaredSchemaName(); @@ -155,7 +153,15 @@ async function getDeclaredSchemaName(): Promise { const { schemaToIntrospection } = await loadIntrospector(); const schemas = schemaToIntrospection(schema).schemas; - return schemas.length === 1 ? schemas[0] : null; + if (schemas.length === 1) return schemas[0]; + if (schemas.length > 1) { + console.warn( + warn( + `Schema declares ${schemas.length} schemas (${schemas.join(", ")}); skipping search_path. Tables will land in the migrator default — pin the schema explicitly to avoid surprises.`, + ), + ); + } + return null; } function quoteIdentifier(value: string): string { @@ -183,9 +189,7 @@ export async function migrateStatus(): Promise { console.log(`[applied] ${row.created_at} ${row.hash}`); } } catch (error) { - // First-time invocation: the drizzle bookkeeping schema does not exist - // yet. Treat it as "no migrations applied" rather than surfacing a - // confusing internal-state error. + // First run: drizzle bookkeeping schema doesn't exist yet → "no migrations". if ( error instanceof Error && /drizzle\.__drizzle_migrations|does not exist/i.test(error.message) @@ -209,18 +213,65 @@ export async function migrateReset(): Promise { }); } -function drizzleArgs( - paths: ReturnType, - command: string[], -): string[] { - return [ - "drizzle-kit", - ...command, - "--out", - path.relative(paths.root, paths.migrationsDir), - "--schema", - path.relative(paths.root, paths.schemaFile), - "--dialect", - "postgresql", - ]; +/** + * Drop every app table + the drizzle bookkeeping schema. Used by + * `db init --from reset` to wipe a dev branch before re-applying schema.ts. + * Dev-only — refuses in `NODE_ENV=production`. + */ +export async function dropAllAppTables(options: { + schema: string; + /** Defense-in-depth so a future caller can't bypass `confirmReset`. */ + allowDestructive?: boolean; +}): Promise { + if (process.env.NODE_ENV === "production") { + throw new Error("db init --from reset is forbidden in production."); + } + if (!options.allowDestructive) { + throw new Error( + "dropAllAppTables refused: caller must pass allowDestructive=true (db init --from reset wires this through confirmReset).", + ); + } + + await withLakebasePool(async (pool) => { + const result = await pool.query<{ tablename: string }>( + "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = $1", + [options.schema], + ); + if (result.rows.length === 0) { + console.log(check(`No tables to drop in schema "${options.schema}".`)); + await pool.query("DROP SCHEMA IF EXISTS drizzle CASCADE"); + console.log(check("Dropped drizzle migration metadata schema.")); + return; + } + // Wrap all drops in a transaction — partial failure leaves the DB + // half-dropped otherwise, which makes the dev branch unrecoverable + // without manually finishing the wipe. + if (!pool.connect) { + throw new Error( + "Reset requires `pool.connect` so drops can run in a single transaction.", + ); + } + const client = await pool.connect(); + try { + await client.query("BEGIN"); + for (const row of result.rows) { + await client.query( + `DROP TABLE IF EXISTS ${quoteIdentifier(options.schema)}.${quoteIdentifier(row.tablename)} CASCADE`, + ); + } + await client.query("DROP SCHEMA IF EXISTS drizzle CASCADE"); + await client.query("COMMIT"); + } catch (err) { + await client.query("ROLLBACK").catch(() => {}); + throw err; + } finally { + client.release?.(); + } + console.log( + check( + `Dropped ${result.rows.length} table(s) in schema "${options.schema}".`, + ), + ); + console.log(check("Dropped drizzle migration metadata schema.")); + }); } diff --git a/packages/shared/src/cli/commands/db/migration.ts b/packages/shared/src/cli/commands/db/migration.ts index 609c78f49..fe3488832 100644 --- a/packages/shared/src/cli/commands/db/migration.ts +++ b/packages/shared/src/cli/commands/db/migration.ts @@ -31,7 +31,15 @@ export async function generateMigration( "--dialect", "postgresql", ]; - if (options.name) args.push("--name", options.name); + if (options.name) { + // Drizzle-kit puts this in a filename; allowlist alphanumerics + `_-`. + if (!/^[a-zA-Z0-9_-]+$/.test(options.name)) { + throw new Error( + `Invalid --name "${options.name}"; allowed: letters, digits, '_', '-'.`, + ); + } + args.push("--name", options.name); + } console.log(bullet(`drizzle-kit ${args.slice(1).join(" ")}`)); await execa(process.execPath, [drizzleKitBinPath(), ...args.slice(1)], { diff --git a/packages/shared/src/cli/commands/db/seed.ts b/packages/shared/src/cli/commands/db/seed.ts index 9b197040b..1dfbef031 100644 --- a/packages/shared/src/cli/commands/db/seed.ts +++ b/packages/shared/src/cli/commands/db/seed.ts @@ -13,6 +13,8 @@ import { export interface SeedOptions { file?: string; allowDdl?: boolean; + /** Opt in to seeding in production; default refuses. */ + force?: boolean; } const DDL_PATTERN = /\b(create|alter|drop|truncate|grant|revoke)\b/i; @@ -21,16 +23,27 @@ export const seedCommand = new Command("seed") .description("Run data-only dev/demo seed SQL against Lakebase") .option("-f, --file ", "SQL seed file to run") .option("--allow-ddl", "Allow DDL in seed SQL for local fixtures") + .option( + "--force", + "Permit seeding when NODE_ENV=production (default: refuse)", + ) .action((opts) => runCommandAction(() => runSeed({ file: opts.file ? String(opts.file) : undefined, allowDdl: Boolean(opts.allowDdl), + force: Boolean(opts.force), }), ), ); export async function runSeed(options: SeedOptions = {}): Promise { + // Seed mutates the live DB; refuse in production unless --force is set. + if (process.env.NODE_ENV === "production" && !options.force) { + throw new Error( + "appkit db seed refuses to run with NODE_ENV=production. Pass --force to override (rare reference-data deploys only).", + ); + } const paths = databasePaths(); const seedFile = options.file ? path.resolve(paths.root, options.file) diff --git a/template/_gitignore b/template/_gitignore index 23adbc24e..2650a8c75 100644 --- a/template/_gitignore +++ b/template/_gitignore @@ -4,6 +4,9 @@ client/dist/ dist/ build/ .env +.env.bak +.env.bak.* +.env.tmp.* .databricks/ .smoke-test/ test-results/