postgresai 0.14.0-dev.75 → 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.
- package/bin/postgres-ai.ts +312 -6
- package/dist/bin/postgres-ai.js +325 -15
- package/dist/sql/02.extensions.sql +8 -0
- package/dist/sql/{02.permissions.sql → 03.permissions.sql} +1 -0
- package/dist/sql/sql/02.extensions.sql +8 -0
- package/dist/sql/sql/{02.permissions.sql → 03.permissions.sql} +1 -0
- package/dist/sql/sql/uninit/01.helpers.sql +5 -0
- package/dist/sql/sql/uninit/02.permissions.sql +30 -0
- package/dist/sql/sql/uninit/03.role.sql +27 -0
- package/dist/sql/uninit/01.helpers.sql +5 -0
- package/dist/sql/uninit/02.permissions.sql +30 -0
- package/dist/sql/uninit/03.role.sql +27 -0
- package/lib/init.ts +109 -8
- package/lib/metrics-embedded.ts +1 -1
- package/package.json +1 -1
- package/sql/02.extensions.sql +8 -0
- package/sql/{02.permissions.sql → 03.permissions.sql} +1 -0
- package/sql/uninit/01.helpers.sql +5 -0
- package/sql/uninit/02.permissions.sql +30 -0
- package/sql/uninit/03.role.sql +27 -0
- package/test/init.test.ts +245 -11
- /package/dist/sql/{03.optional_rds.sql → 04.optional_rds.sql} +0 -0
- /package/dist/sql/{04.optional_self_managed.sql → 05.optional_self_managed.sql} +0 -0
- /package/dist/sql/{05.helpers.sql → 06.helpers.sql} +0 -0
- /package/dist/sql/sql/{03.optional_rds.sql → 04.optional_rds.sql} +0 -0
- /package/dist/sql/sql/{04.optional_self_managed.sql → 05.optional_self_managed.sql} +0 -0
- /package/dist/sql/sql/{05.helpers.sql → 06.helpers.sql} +0 -0
- /package/sql/{03.optional_rds.sql → 04.optional_rds.sql} +0 -0
- /package/sql/{04.optional_self_managed.sql → 05.optional_self_managed.sql} +0 -0
- /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
|
-
|
|
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: "
|
|
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: "
|
|
555
|
-
sql: applyTemplate(loadSqlTemplate("
|
|
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: "
|
|
562
|
-
sql: applyTemplate(loadSqlTemplate("
|
|
567
|
+
name: "04.optional_rds",
|
|
568
|
+
sql: applyTemplate(loadSqlTemplate("04.optional_rds.sql"), vars),
|
|
563
569
|
optional: true,
|
|
564
570
|
},
|
|
565
571
|
{
|
|
566
|
-
name: "
|
|
567
|
-
sql: applyTemplate(loadSqlTemplate("
|
|
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;
|
package/lib/metrics-embedded.ts
CHANGED
package/package.json
CHANGED
|
@@ -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,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 === "
|
|
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 === "
|
|
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 === "
|
|
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 === "
|
|
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
|
|
450
|
-
expect(r.stdout).toMatch(/-- 02\.
|
|
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
|
-
|
|
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
|
});
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|