postgresai 0.14.0-dev.74 → 0.14.0-dev.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/bin/postgres-ai.ts +312 -6
  2. package/dist/bin/postgres-ai.js +327 -21
  3. package/dist/sql/02.extensions.sql +8 -0
  4. package/dist/sql/{02.permissions.sql → 03.permissions.sql} +1 -0
  5. package/dist/sql/sql/02.extensions.sql +8 -0
  6. package/dist/sql/sql/{02.permissions.sql → 03.permissions.sql} +1 -0
  7. package/dist/sql/sql/uninit/01.helpers.sql +5 -0
  8. package/dist/sql/sql/uninit/02.permissions.sql +30 -0
  9. package/dist/sql/sql/uninit/03.role.sql +27 -0
  10. package/dist/sql/uninit/01.helpers.sql +5 -0
  11. package/dist/sql/uninit/02.permissions.sql +30 -0
  12. package/dist/sql/uninit/03.role.sql +27 -0
  13. package/lib/init.ts +109 -8
  14. package/lib/metrics-embedded.ts +1 -1
  15. package/lib/supabase.ts +2 -10
  16. package/package.json +1 -1
  17. package/sql/02.extensions.sql +8 -0
  18. package/sql/{02.permissions.sql → 03.permissions.sql} +1 -0
  19. package/sql/uninit/01.helpers.sql +5 -0
  20. package/sql/uninit/02.permissions.sql +30 -0
  21. package/sql/uninit/03.role.sql +27 -0
  22. package/test/init.test.ts +245 -11
  23. package/test/supabase.test.ts +0 -59
  24. /package/dist/sql/{03.optional_rds.sql → 04.optional_rds.sql} +0 -0
  25. /package/dist/sql/{04.optional_self_managed.sql → 05.optional_self_managed.sql} +0 -0
  26. /package/dist/sql/{05.helpers.sql → 06.helpers.sql} +0 -0
  27. /package/dist/sql/sql/{03.optional_rds.sql → 04.optional_rds.sql} +0 -0
  28. /package/dist/sql/sql/{04.optional_self_managed.sql → 05.optional_self_managed.sql} +0 -0
  29. /package/dist/sql/sql/{05.helpers.sql → 06.helpers.sql} +0 -0
  30. /package/sql/{03.optional_rds.sql → 04.optional_rds.sql} +0 -0
  31. /package/sql/{04.optional_self_managed.sql → 05.optional_self_managed.sql} +0 -0
  32. /package/sql/{05.helpers.sql → 06.helpers.sql} +0 -0
package/lib/init.ts CHANGED
@@ -527,7 +527,13 @@ end $$;`;
527
527
  steps.push({ name: "01.role", sql: roleSql });
528
528
  }
529
529
 
530
- let permissionsSql = applyTemplate(loadSqlTemplate("02.permissions.sql"), vars);
530
+ // Extensions should be created before permissions (so we can grant permissions on them)
531
+ steps.push({
532
+ name: "02.extensions",
533
+ sql: loadSqlTemplate("02.extensions.sql"),
534
+ });
535
+
536
+ let permissionsSql = applyTemplate(loadSqlTemplate("03.permissions.sql"), vars);
531
537
 
532
538
  // Some providers restrict ALTER USER - remove those statements.
533
539
  // TODO: Make this more flexible by allowing users to specify which statements to skip via config.
@@ -545,26 +551,26 @@ end $$;`;
545
551
  }
546
552
 
547
553
  steps.push({
548
- name: "02.permissions",
554
+ name: "03.permissions",
549
555
  sql: permissionsSql,
550
556
  });
551
557
 
552
558
  // Helper functions (SECURITY DEFINER) for plan analysis and table info
553
559
  steps.push({
554
- name: "05.helpers",
555
- sql: applyTemplate(loadSqlTemplate("05.helpers.sql"), vars),
560
+ name: "06.helpers",
561
+ sql: applyTemplate(loadSqlTemplate("06.helpers.sql"), vars),
556
562
  });
557
563
 
558
564
  if (params.includeOptionalPermissions) {
559
565
  steps.push(
560
566
  {
561
- name: "03.optional_rds",
562
- sql: applyTemplate(loadSqlTemplate("03.optional_rds.sql"), vars),
567
+ name: "04.optional_rds",
568
+ sql: applyTemplate(loadSqlTemplate("04.optional_rds.sql"), vars),
563
569
  optional: true,
564
570
  },
565
571
  {
566
- name: "04.optional_self_managed",
567
- sql: applyTemplate(loadSqlTemplate("04.optional_self_managed.sql"), vars),
572
+ name: "05.optional_self_managed",
573
+ sql: applyTemplate(loadSqlTemplate("05.optional_self_managed.sql"), vars),
568
574
  optional: true,
569
575
  }
570
576
  );
@@ -657,6 +663,101 @@ export type VerifyInitResult = {
657
663
  missingOptional: string[];
658
664
  };
659
665
 
666
+ export type UninitPlan = {
667
+ monitoringUser: string;
668
+ database: string;
669
+ steps: InitStep[];
670
+ /** If true, also drop the monitoring role. If false, only revoke permissions. */
671
+ dropRole: boolean;
672
+ };
673
+
674
+ export async function buildUninitPlan(params: {
675
+ database: string;
676
+ monitoringUser?: string;
677
+ /** If true, drop the role entirely. If false, only revoke permissions/drop objects. */
678
+ dropRole?: boolean;
679
+ /** Provider type. Affects which steps are included. Defaults to "self-managed". */
680
+ provider?: DbProvider;
681
+ }): Promise<UninitPlan> {
682
+ const monitoringUser = params.monitoringUser || DEFAULT_MONITORING_USER;
683
+ const database = params.database;
684
+ const provider = params.provider ?? "self-managed";
685
+ const dropRole = params.dropRole ?? true;
686
+
687
+ const qRole = quoteIdent(monitoringUser);
688
+ const qDb = quoteIdent(database);
689
+ const qRoleLiteral = quoteLiteral(monitoringUser);
690
+
691
+ const steps: InitStep[] = [];
692
+
693
+ const vars: Record<string, string> = {
694
+ ROLE_IDENT: qRole,
695
+ DB_IDENT: qDb,
696
+ ROLE_LITERAL: qRoleLiteral,
697
+ };
698
+
699
+ // Step 1: Drop helper functions
700
+ steps.push({
701
+ name: "01.drop_helpers",
702
+ sql: applyTemplate(loadSqlTemplate("uninit/01.helpers.sql"), vars),
703
+ });
704
+
705
+ // Step 2: Drop view, revoke permissions, drop schema
706
+ steps.push({
707
+ name: "02.revoke_permissions",
708
+ sql: applyTemplate(loadSqlTemplate("uninit/02.permissions.sql"), vars),
709
+ });
710
+
711
+ // Step 3: Drop the role (only if requested and provider allows it)
712
+ if (dropRole && !SKIP_ROLE_CREATION_PROVIDERS.includes(provider)) {
713
+ steps.push({
714
+ name: "03.drop_role",
715
+ sql: applyTemplate(loadSqlTemplate("uninit/03.role.sql"), vars),
716
+ });
717
+ }
718
+
719
+ return { monitoringUser, database, steps, dropRole };
720
+ }
721
+
722
+ export async function applyUninitPlan(params: {
723
+ client: PgClient;
724
+ plan: UninitPlan;
725
+ }): Promise<{ applied: string[]; errors: string[] }> {
726
+ const applied: string[] = [];
727
+ const errors: string[] = [];
728
+
729
+ // Helper to wrap a step execution in begin/commit
730
+ const executeStep = async (step: InitStep): Promise<void> => {
731
+ await params.client.query("begin;");
732
+ try {
733
+ await params.client.query(step.sql, step.params as any);
734
+ await params.client.query("commit;");
735
+ } catch (e) {
736
+ try {
737
+ await params.client.query("rollback;");
738
+ } catch {
739
+ // ignore
740
+ }
741
+ throw e;
742
+ }
743
+ };
744
+
745
+ // Apply steps in order - unlike init, uninit steps are not optional
746
+ // but we continue on errors to clean up as much as possible
747
+ for (const step of params.plan.steps) {
748
+ try {
749
+ await executeStep(step);
750
+ applied.push(step.name);
751
+ } catch (e) {
752
+ const msg = e instanceof Error ? e.message : String(e);
753
+ errors.push(`${step.name}: ${msg}`);
754
+ // Continue to try other steps
755
+ }
756
+ }
757
+
758
+ return { applied, errors };
759
+ }
760
+
660
761
  export async function verifyInitSetup(params: {
661
762
  client: PgClient;
662
763
  database: string;
@@ -1,6 +1,6 @@
1
1
  // AUTO-GENERATED FILE - DO NOT EDIT
2
2
  // Generated from config/pgwatch-prometheus/metrics.yml by scripts/embed-metrics.ts
3
- // Generated at: 2026-01-09T18:10:39.847Z
3
+ // Generated at: 2026-01-13T04:02:15.672Z
4
4
 
5
5
  /**
6
6
  * Metric definition from metrics.yml
package/lib/supabase.ts CHANGED
@@ -347,14 +347,6 @@ export async function fetchPoolerDatabaseUrl(
347
347
  ): Promise<string | null> {
348
348
  const url = `${SUPABASE_API_BASE}/v1/projects/${encodeURIComponent(config.projectRef)}/config/database/pooler`;
349
349
 
350
- // For Supabase pooler connections, the username must include the project ref:
351
- // <user>.<project_ref>
352
- // Example:
353
- // postgresql://postgres_ai_mon.xhaqmsvczjkkvkgdyast@aws-1-eu-west-1.pooler.supabase.com:6543/postgres
354
- const effectiveUsername = (() => {
355
- const suffix = `.${config.projectRef}`;
356
- return username.endsWith(suffix) ? username : `${username}${suffix}`;
357
- })();
358
350
  try {
359
351
  const response = await fetch(url, {
360
352
  method: "GET",
@@ -375,7 +367,7 @@ export async function fetchPoolerDatabaseUrl(
375
367
  const pooler = data[0];
376
368
  // Build URL from components if available
377
369
  if (pooler.db_host && pooler.db_port && pooler.db_name) {
378
- return `postgresql://${effectiveUsername}@${pooler.db_host}:${pooler.db_port}/${pooler.db_name}`;
370
+ return `postgresql://${username}@${pooler.db_host}:${pooler.db_port}/${pooler.db_name}`;
379
371
  }
380
372
  // Fallback: try to extract from connection_string if present
381
373
  if (typeof pooler.connection_string === "string") {
@@ -383,7 +375,7 @@ export async function fetchPoolerDatabaseUrl(
383
375
  const connUrl = new URL(pooler.connection_string);
384
376
  // Use provided username; handle empty port for default ports (e.g., 5432)
385
377
  const portPart = connUrl.port ? `:${connUrl.port}` : "";
386
- return `postgresql://${effectiveUsername}@${connUrl.hostname}${portPart}${connUrl.pathname}`;
378
+ return `postgresql://${username}@${connUrl.hostname}${portPart}${connUrl.pathname}`;
387
379
  } catch {
388
380
  return null;
389
381
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.14.0-dev.74",
3
+ "version": "0.14.0-dev.76",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -0,0 +1,8 @@
1
+ -- Extensions required for postgres_ai monitoring
2
+
3
+ -- Enable pg_stat_statements for query performance monitoring
4
+ -- Note: Uses IF NOT EXISTS because extension may already be installed.
5
+ -- We do NOT drop this extension in unprepare-db since it may have been pre-existing.
6
+ create extension if not exists pg_stat_statements;
7
+
8
+
@@ -8,6 +8,7 @@ grant pg_monitor to {{ROLE_IDENT}};
8
8
  grant select on pg_catalog.pg_index to {{ROLE_IDENT}};
9
9
 
10
10
  -- Create postgres_ai schema for our objects
11
+ -- Using IF NOT EXISTS for idempotency - prepare-db can be run multiple times
11
12
  create schema if not exists postgres_ai;
12
13
  grant usage on schema postgres_ai to {{ROLE_IDENT}};
13
14
 
@@ -0,0 +1,5 @@
1
+ -- Drop helper functions created by prepare-db (template-filled by cli/lib/init.ts)
2
+ -- Run before dropping the postgres_ai schema.
3
+
4
+ drop function if exists postgres_ai.explain_generic(text, text, text);
5
+ drop function if exists postgres_ai.table_describe(text);
@@ -0,0 +1,30 @@
1
+ -- Revoke permissions and drop objects created by prepare-db (template-filled by cli/lib/init.ts)
2
+
3
+ -- Drop the postgres_ai.pg_statistic view
4
+ drop view if exists postgres_ai.pg_statistic;
5
+
6
+ -- Drop the postgres_ai schema (CASCADE to handle any remaining objects)
7
+ drop schema if exists postgres_ai cascade;
8
+
9
+ -- Revoke permissions from the monitoring role
10
+ -- Use a DO block to handle the case where the role doesn't exist
11
+ do $$ begin
12
+ revoke pg_monitor from {{ROLE_IDENT}};
13
+ exception when undefined_object then
14
+ null; -- Role doesn't exist, nothing to revoke
15
+ end $$;
16
+
17
+ do $$ begin
18
+ revoke select on pg_catalog.pg_index from {{ROLE_IDENT}};
19
+ exception when undefined_object then
20
+ null; -- Role doesn't exist
21
+ end $$;
22
+
23
+ do $$ begin
24
+ revoke connect on database {{DB_IDENT}} from {{ROLE_IDENT}};
25
+ exception when undefined_object then
26
+ null; -- Role doesn't exist
27
+ end $$;
28
+
29
+ -- Note: USAGE on public is typically granted by default; we don't revoke it
30
+ -- to avoid breaking other applications that may rely on it.
@@ -0,0 +1,27 @@
1
+ -- Drop the monitoring role created by prepare-db (template-filled by cli/lib/init.ts)
2
+ -- This must run after revoking all permissions from the role.
3
+
4
+ -- Use a DO block to handle the case where the role doesn't exist
5
+ do $$ begin
6
+ -- Reassign owned objects to current user before dropping
7
+ -- This handles any objects that might have been created by the role
8
+ begin
9
+ execute format('reassign owned by %I to current_user', {{ROLE_LITERAL}});
10
+ exception when undefined_object then
11
+ null; -- Role doesn't exist, nothing to reassign
12
+ end;
13
+
14
+ -- Drop owned objects (in case reassign didn't work for some objects)
15
+ begin
16
+ execute format('drop owned by %I', {{ROLE_LITERAL}});
17
+ exception when undefined_object then
18
+ null; -- Role doesn't exist
19
+ end;
20
+
21
+ -- Drop the role
22
+ begin
23
+ execute format('drop role %I', {{ROLE_LITERAL}});
24
+ exception when undefined_object then
25
+ null; -- Role doesn't exist, that's fine
26
+ end;
27
+ end $$;
package/test/init.test.ts CHANGED
@@ -89,7 +89,7 @@ describe("init module", () => {
89
89
  expect(roleStep.sql).toMatch(/create\s+user\s+"user ""with"" quotes ✓"/i);
90
90
  expect(roleStep.sql).toMatch(/alter\s+user\s+"user ""with"" quotes ✓"/i);
91
91
 
92
- const permStep = plan.steps.find((s: { name: string }) => s.name === "02.permissions");
92
+ const permStep = plan.steps.find((s: { name: string }) => s.name === "03.permissions");
93
93
  expect(permStep).toBeTruthy();
94
94
  expect(permStep.sql).toMatch(/grant connect on database "db name ""with"" quotes ✓" to "user ""with"" quotes ✓"/i);
95
95
  });
@@ -161,7 +161,7 @@ describe("init module", () => {
161
161
  provider: "supabase",
162
162
  });
163
163
  expect(plan.steps.some((s) => s.name === "01.role")).toBe(false);
164
- expect(plan.steps.some((s) => s.name === "02.permissions")).toBe(true);
164
+ expect(plan.steps.some((s) => s.name === "03.permissions")).toBe(true);
165
165
  });
166
166
 
167
167
  test("buildInitPlan removes ALTER USER for supabase provider", async () => {
@@ -172,7 +172,7 @@ describe("init module", () => {
172
172
  includeOptionalPermissions: false,
173
173
  provider: "supabase",
174
174
  });
175
- const permStep = plan.steps.find((s) => s.name === "02.permissions");
175
+ const permStep = plan.steps.find((s) => s.name === "03.permissions");
176
176
  expect(permStep).toBeDefined();
177
177
  expect(permStep!.sql.toLowerCase()).not.toMatch(/alter user/);
178
178
  });
@@ -390,7 +390,7 @@ describe("init module", () => {
390
390
  includeOptionalPermissions: false,
391
391
  provider: "supabase",
392
392
  });
393
- const permStep = plan.steps.find((s) => s.name === "02.permissions");
393
+ const permStep = plan.steps.find((s) => s.name === "03.permissions");
394
394
  expect(permStep).toBeDefined();
395
395
  // Should have removed ALTER USER but kept comments
396
396
  expect(permStep!.sql.toLowerCase()).not.toMatch(/^\s*alter\s+user/m);
@@ -423,6 +423,179 @@ describe("init module", () => {
423
423
  const redacted = init.redactPasswordsInSql(step.sql);
424
424
  expect(redacted).toMatch(/password '<redacted>'/i);
425
425
  });
426
+
427
+ // Tests for buildUninitPlan
428
+ test("buildUninitPlan generates correct steps with dropRole=true", async () => {
429
+ const plan = await init.buildUninitPlan({
430
+ database: "mydb",
431
+ monitoringUser: DEFAULT_MONITORING_USER,
432
+ dropRole: true,
433
+ });
434
+
435
+ expect(plan.database).toBe("mydb");
436
+ expect(plan.monitoringUser).toBe(DEFAULT_MONITORING_USER);
437
+ expect(plan.dropRole).toBe(true);
438
+ expect(plan.steps.length).toBe(3);
439
+ expect(plan.steps.map((s) => s.name)).toEqual([
440
+ "01.drop_helpers",
441
+ "02.revoke_permissions",
442
+ "03.drop_role",
443
+ ]);
444
+ });
445
+
446
+ test("buildUninitPlan skips role drop when dropRole=false", async () => {
447
+ const plan = await init.buildUninitPlan({
448
+ database: "mydb",
449
+ monitoringUser: DEFAULT_MONITORING_USER,
450
+ dropRole: false,
451
+ });
452
+
453
+ expect(plan.dropRole).toBe(false);
454
+ expect(plan.steps.length).toBe(2);
455
+ expect(plan.steps.map((s) => s.name)).toEqual([
456
+ "01.drop_helpers",
457
+ "02.revoke_permissions",
458
+ ]);
459
+ });
460
+
461
+ test("buildUninitPlan skips role drop for supabase provider", async () => {
462
+ const plan = await init.buildUninitPlan({
463
+ database: "mydb",
464
+ monitoringUser: DEFAULT_MONITORING_USER,
465
+ dropRole: true,
466
+ provider: "supabase",
467
+ });
468
+
469
+ // Even with dropRole=true, supabase provider skips role operations
470
+ expect(plan.steps.length).toBe(2);
471
+ expect(plan.steps.some((s) => s.name === "03.drop_role")).toBe(false);
472
+ });
473
+
474
+ test("buildUninitPlan handles special characters in identifiers", async () => {
475
+ const monitoringUser = 'user "with" quotes';
476
+ const database = 'db "name"';
477
+ const plan = await init.buildUninitPlan({
478
+ database,
479
+ monitoringUser,
480
+ dropRole: true,
481
+ });
482
+
483
+ // Check that identifiers are properly quoted in SQL
484
+ const dropHelpersStep = plan.steps.find((s) => s.name === "01.drop_helpers");
485
+ expect(dropHelpersStep).toBeTruthy();
486
+
487
+ const revokeStep = plan.steps.find((s) => s.name === "02.revoke_permissions");
488
+ expect(revokeStep).toBeTruthy();
489
+ expect(revokeStep!.sql).toContain('"user ""with"" quotes"');
490
+ expect(revokeStep!.sql).toContain('"db ""name"""');
491
+
492
+ const dropRoleStep = plan.steps.find((s) => s.name === "03.drop_role");
493
+ expect(dropRoleStep).toBeTruthy();
494
+ // Uses ROLE_LITERAL (single-quoted) for format('%I', ...) in dynamic SQL
495
+ expect(dropRoleStep!.sql).toContain("'user \"with\" quotes'");
496
+ });
497
+
498
+ test("buildUninitPlan rejects identifiers with null bytes", async () => {
499
+ await expect(
500
+ init.buildUninitPlan({
501
+ database: "mydb",
502
+ monitoringUser: "bad\0user",
503
+ dropRole: true,
504
+ })
505
+ ).rejects.toThrow(/Identifier cannot contain null bytes/);
506
+ });
507
+
508
+ test("applyUninitPlan continues on errors and reports them", async () => {
509
+ const plan = {
510
+ monitoringUser: DEFAULT_MONITORING_USER,
511
+ database: "mydb",
512
+ dropRole: true,
513
+ steps: [
514
+ { name: "01.drop_helpers", sql: "drop function if exists postgres_ai.test()" },
515
+ { name: "02.revoke_permissions", sql: "select 1/0" }, // Will fail
516
+ { name: "03.drop_role", sql: "select 1" },
517
+ ],
518
+ };
519
+
520
+ const calls: string[] = [];
521
+ const client = {
522
+ query: async (sql: string) => {
523
+ calls.push(sql);
524
+ if (sql === "begin;") return { rowCount: 1 };
525
+ if (sql === "commit;") return { rowCount: 1 };
526
+ if (sql === "rollback;") return { rowCount: 1 };
527
+ if (sql.includes("1/0")) throw new Error("division by zero");
528
+ return { rowCount: 1 };
529
+ },
530
+ };
531
+
532
+ const result = await init.applyUninitPlan({ client: client as any, plan: plan as any });
533
+
534
+ // Should have applied steps 1 and 3, with step 2 in errors
535
+ expect(result.applied).toContain("01.drop_helpers");
536
+ expect(result.applied).toContain("03.drop_role");
537
+ expect(result.applied).not.toContain("02.revoke_permissions");
538
+ expect(result.errors.length).toBe(1);
539
+ expect(result.errors[0]).toMatch(/02\.revoke_permissions.*division by zero/);
540
+ });
541
+
542
+ test("buildInitPlan includes 02.extensions step with pg_stat_statements", async () => {
543
+ const plan = await init.buildInitPlan({
544
+ database: "mydb",
545
+ monitoringUser: DEFAULT_MONITORING_USER,
546
+ monitoringPassword: "pw",
547
+ includeOptionalPermissions: false,
548
+ });
549
+
550
+ const extStep = plan.steps.find((s) => s.name === "02.extensions");
551
+ expect(extStep).toBeTruthy();
552
+ // Should create pg_stat_statements with IF NOT EXISTS
553
+ expect(extStep!.sql).toMatch(/create extension if not exists pg_stat_statements/i);
554
+ });
555
+
556
+ test("buildInitPlan creates extensions before permissions", async () => {
557
+ const plan = await init.buildInitPlan({
558
+ database: "mydb",
559
+ monitoringUser: DEFAULT_MONITORING_USER,
560
+ monitoringPassword: "pw",
561
+ includeOptionalPermissions: false,
562
+ });
563
+
564
+ const stepNames = plan.steps.map((s) => s.name);
565
+ const extIndex = stepNames.indexOf("02.extensions");
566
+ const permIndex = stepNames.indexOf("03.permissions");
567
+ expect(extIndex).toBeGreaterThanOrEqual(0);
568
+ expect(permIndex).toBeGreaterThanOrEqual(0);
569
+ // Extensions should come before permissions
570
+ expect(extIndex).toBeLessThan(permIndex);
571
+ });
572
+
573
+ test("buildInitPlan uses IF NOT EXISTS for postgres_ai schema (idempotent)", async () => {
574
+ const plan = await init.buildInitPlan({
575
+ database: "mydb",
576
+ monitoringUser: DEFAULT_MONITORING_USER,
577
+ monitoringPassword: "pw",
578
+ includeOptionalPermissions: false,
579
+ });
580
+
581
+ const permStep = plan.steps.find((s) => s.name === "03.permissions");
582
+ expect(permStep).toBeTruthy();
583
+ // Should use IF NOT EXISTS for idempotent behavior
584
+ expect(permStep!.sql).toMatch(/create schema if not exists postgres_ai/i);
585
+ });
586
+
587
+ test("buildUninitPlan does NOT drop pg_stat_statements extension", async () => {
588
+ const plan = await init.buildUninitPlan({
589
+ database: "mydb",
590
+ monitoringUser: DEFAULT_MONITORING_USER,
591
+ dropRole: true,
592
+ });
593
+
594
+ // Check all steps - none should drop pg_stat_statements
595
+ for (const step of plan.steps) {
596
+ expect(step.sql.toLowerCase()).not.toMatch(/drop extension.*pg_stat_statements/);
597
+ }
598
+ });
426
599
  });
427
600
 
428
601
  describe("CLI commands", () => {
@@ -446,8 +619,9 @@ describe("CLI commands", () => {
446
619
  expect(r.stdout).toMatch(/provider: supabase/);
447
620
  // Should not have 01.role step
448
621
  expect(r.stdout).not.toMatch(/-- 01\.role/);
449
- // Should have 02.permissions step
450
- expect(r.stdout).toMatch(/-- 02\.permissions/);
622
+ // Should have 02.extensions and 03.permissions steps
623
+ expect(r.stdout).toMatch(/-- 02\.extensions/);
624
+ expect(r.stdout).toMatch(/-- 03\.permissions/);
451
625
  });
452
626
 
453
627
  test("cli: prepare-db warns about unknown provider", () => {
@@ -571,11 +745,66 @@ describe("CLI commands", () => {
571
745
  expect(r.status).not.toBe(0);
572
746
  expect(r.stderr).toMatch(/Cannot use --api-key with --demo mode/);
573
747
  });
748
+
749
+ // Tests for unprepare-db command
750
+ test("cli: unprepare-db with missing connection prints help/options", () => {
751
+ const r = runCli(["unprepare-db"]);
752
+ expect(r.status).not.toBe(0);
753
+ expect(r.stderr).toMatch(/--print-sql/);
754
+ expect(r.stderr).toMatch(/--monitoring-user/);
755
+ });
756
+
757
+ test("cli: unprepare-db --print-sql works without connection (offline mode)", () => {
758
+ const r = runCli(["unprepare-db", "--print-sql", "-d", "mydb"]);
759
+ expect(r.status).toBe(0);
760
+ expect(r.stdout).toMatch(/SQL plan \(offline; not connected\)/);
761
+ expect(r.stdout).toMatch(/drop schema if exists postgres_ai/i);
762
+ });
763
+
764
+ test("cli: unprepare-db --print-sql with --keep-role skips role drop", () => {
765
+ const r = runCli(["unprepare-db", "--print-sql", "-d", "mydb", "--keep-role"]);
766
+ expect(r.status).toBe(0);
767
+ expect(r.stdout).toMatch(/drop role: false/);
768
+ // Should not have 03.drop_role step
769
+ expect(r.stdout).not.toMatch(/-- 03\.drop_role/);
770
+ // Should have 01 and 02 steps
771
+ expect(r.stdout).toMatch(/-- 01\.drop_helpers/);
772
+ expect(r.stdout).toMatch(/-- 02\.revoke_permissions/);
773
+ });
774
+
775
+ test("cli: unprepare-db --print-sql with --provider supabase skips role step", () => {
776
+ const r = runCli(["unprepare-db", "--print-sql", "-d", "mydb", "--provider", "supabase"]);
777
+ expect(r.status).toBe(0);
778
+ expect(r.stdout).toMatch(/provider: supabase/);
779
+ // Should not have 03.drop_role step
780
+ expect(r.stdout).not.toMatch(/-- 03\.drop_role/);
781
+ });
782
+
783
+ test("cli: unprepare-db command exists and shows help", () => {
784
+ const r = runCli(["unprepare-db", "--help"]);
785
+ expect(r.status).toBe(0);
786
+ expect(r.stdout).toMatch(/--keep-role/);
787
+ expect(r.stdout).toMatch(/--print-sql/);
788
+ expect(r.stdout).toMatch(/--force/);
789
+ });
574
790
  });
575
791
 
576
- describe("imageTag priority behavior", () => {
792
+ // Check if Docker is available for imageTag tests
793
+ function isDockerAvailable(): boolean {
794
+ try {
795
+ const result = Bun.spawnSync(["docker", "info"], { timeout: 5000 });
796
+ return result.exitCode === 0;
797
+ } catch {
798
+ return false;
799
+ }
800
+ }
801
+
802
+ const dockerAvailable = isDockerAvailable();
803
+
804
+ describe.skipIf(!dockerAvailable)("imageTag priority behavior", () => {
577
805
  // Tests for the imageTag priority: --tag flag > PGAI_TAG env var > pkg.version
578
806
  // This verifies the fix that prevents stale .env PGAI_TAG from being used
807
+ // These tests require Docker and spawn subprocesses so need longer timeout
579
808
 
580
809
  let tempDir: string;
581
810
 
@@ -598,11 +827,13 @@ describe("imageTag priority behavior", () => {
598
827
  fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
599
828
 
600
829
  // Run from the test directory (so resolvePaths finds docker-compose.yml)
830
+ // Note: Command may hang on Docker check in CI without Docker, so we use a timeout
601
831
  const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
602
832
  const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
603
833
  const result = Bun.spawnSync([bunBin, cliPath, "mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"], {
604
834
  env: { ...process.env, PGAI_TAG: undefined },
605
835
  cwd: testDir,
836
+ timeout: 30000, // Kill subprocess after 30s if it hangs on Docker
606
837
  });
607
838
 
608
839
  // Read the .env that was written
@@ -612,7 +843,7 @@ describe("imageTag priority behavior", () => {
612
843
  expect(envContent).not.toMatch(/PGAI_TAG=beta/);
613
844
  // It should contain the CLI version (0.0.0-dev.0 in dev)
614
845
  expect(envContent).toMatch(/PGAI_TAG=\d+\.\d+\.\d+|PGAI_TAG=0\.0\.0-dev/);
615
- });
846
+ }, 60000);
616
847
 
617
848
  test("--tag flag takes priority over pkg.version", () => {
618
849
  const testDir = resolve(tempDir, "tag-flag-test");
@@ -624,6 +855,7 @@ describe("imageTag priority behavior", () => {
624
855
  const result = Bun.spawnSync([bunBin, cliPath, "mon", "local-install", "--tag", "v1.2.3-custom", "--db-url", "postgresql://u:p@h:5432/d", "--yes"], {
625
856
  env: { ...process.env, PGAI_TAG: undefined },
626
857
  cwd: testDir,
858
+ timeout: 30000,
627
859
  });
628
860
 
629
861
  const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
@@ -632,7 +864,7 @@ describe("imageTag priority behavior", () => {
632
864
  // Verify stdout confirms the tag being used
633
865
  const stdout = new TextDecoder().decode(result.stdout);
634
866
  expect(stdout).toMatch(/Using image tag: v1\.2\.3-custom/);
635
- });
867
+ }, 60000);
636
868
 
637
869
  test("PGAI_TAG env var is intentionally ignored (Bun auto-loads .env)", () => {
638
870
  // Note: We do NOT use process.env.PGAI_TAG because Bun auto-loads .env files,
@@ -647,13 +879,14 @@ describe("imageTag priority behavior", () => {
647
879
  const result = Bun.spawnSync([bunBin, cliPath, "mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"], {
648
880
  env: { ...process.env, PGAI_TAG: "v2.0.0-from-env" },
649
881
  cwd: testDir,
882
+ timeout: 30000,
650
883
  });
651
884
 
652
885
  const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
653
886
  // PGAI_TAG env var should be IGNORED - uses pkg.version instead
654
887
  expect(envContent).not.toMatch(/PGAI_TAG=v2\.0\.0-from-env/);
655
888
  expect(envContent).toMatch(/PGAI_TAG=\d+\.\d+\.\d+|PGAI_TAG=0\.0\.0-dev/);
656
- });
889
+ }, 60000);
657
890
 
658
891
  test("existing registry and password are preserved while tag is updated", () => {
659
892
  const testDir = resolve(tempDir, "preserve-test");
@@ -668,6 +901,7 @@ describe("imageTag priority behavior", () => {
668
901
  const result = Bun.spawnSync([bunBin, cliPath, "mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"], {
669
902
  env: { ...process.env, PGAI_TAG: undefined },
670
903
  cwd: testDir,
904
+ timeout: 30000,
671
905
  });
672
906
 
673
907
  const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
@@ -678,5 +912,5 @@ describe("imageTag priority behavior", () => {
678
912
  // But registry and password should be preserved
679
913
  expect(envContent).toMatch(/PGAI_REGISTRY=my\.registry\.com/);
680
914
  expect(envContent).toMatch(/GF_SECURITY_ADMIN_PASSWORD=secret123/);
681
- });
915
+ }, 60000);
682
916
  });