postgresai 0.14.0-dev.14 → 0.14.0-dev.16

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/lib/init.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  import * as readline from "readline";
2
2
  import { randomBytes } from "crypto";
3
3
  import { URL } from "url";
4
+ import type { ConnectionOptions as TlsConnectionOptions } from "tls";
4
5
  import type { Client as PgClient } from "pg";
5
6
  import * as fs from "fs";
6
7
  import * as path from "path";
7
8
 
9
+ export const DEFAULT_MONITORING_USER = "postgres_ai_mon";
10
+
8
11
  export type PgClientConfig = {
9
12
  connectionString?: string;
10
13
  host?: string;
@@ -12,7 +15,7 @@ export type PgClientConfig = {
12
15
  user?: string;
13
16
  password?: string;
14
17
  database?: string;
15
- ssl?: any;
18
+ ssl?: boolean | TlsConnectionOptions;
16
19
  };
17
20
 
18
21
  export type AdminConnection = {
@@ -57,15 +60,26 @@ function applyTemplate(sql: string, vars: Record<string, string>): string {
57
60
 
58
61
  function quoteIdent(ident: string): string {
59
62
  // Always quote. Escape embedded quotes by doubling.
63
+ if (ident.includes("\0")) {
64
+ throw new Error("Identifier cannot contain null bytes");
65
+ }
60
66
  return `"${ident.replace(/"/g, "\"\"")}"`;
61
67
  }
62
68
 
63
69
  function quoteLiteral(value: string): string {
64
70
  // Single-quote and escape embedded quotes by doubling.
65
71
  // This is used where Postgres grammar requires a literal (e.g., CREATE/ALTER ROLE PASSWORD).
72
+ if (value.includes("\0")) {
73
+ throw new Error("Literal cannot contain null bytes");
74
+ }
66
75
  return `'${value.replace(/'/g, "''")}'`;
67
76
  }
68
77
 
78
+ export function redactPasswordsInSql(sql: string): string {
79
+ // Replace PASSWORD '<literal>' (handles doubled quotes inside).
80
+ return sql.replace(/password\s+'(?:''|[^'])*'/gi, "password '<redacted>'");
81
+ }
82
+
69
83
  export function maskConnectionString(dbUrl: string): string {
70
84
  // Hide password if present (postgresql://user:pass@host/db).
71
85
  try {
@@ -209,19 +223,8 @@ export function resolveAdminConnection(opts: {
209
223
  }
210
224
 
211
225
  if (!hasConnDetails) {
212
- throw new Error(
213
- [
214
- "Connection is required.",
215
- "",
216
- "Examples:",
217
- " postgresai init postgresql://admin@host:5432/dbname",
218
- " postgresai init \"dbname=dbname host=host user=admin\"",
219
- " postgresai init -h host -p 5432 -U admin -d dbname",
220
- "",
221
- "Admin password:",
222
- " --admin-password <password> (or set PGPASSWORD)",
223
- ].join("\n")
224
- );
226
+ // Keep this message short: the CLI prints full help (including examples) on this error.
227
+ throw new Error("Connection is required.");
225
228
  }
226
229
 
227
230
  const cfg: PgClientConfig = {};
@@ -307,8 +310,13 @@ export async function promptHidden(prompt: string): Promise<string> {
307
310
  }
308
311
 
309
312
  function generateMonitoringPassword(): string {
310
- // URL-safe and easy to copy/paste; length ~32 chars.
311
- return randomBytes(24).toString("base64url");
313
+ // URL-safe and easy to copy/paste; 24 bytes => 32 base64url chars (no padding).
314
+ // Note: randomBytes() throws on failure; we add a tiny sanity check for unexpected output.
315
+ const password = randomBytes(24).toString("base64url");
316
+ if (password.length < 30) {
317
+ throw new Error("Password generation failed: unexpected output length");
318
+ }
319
+ return password;
312
320
  }
313
321
 
314
322
  export async function resolveMonitoringPassword(opts: {
@@ -332,9 +340,9 @@ export async function buildInitPlan(params: {
332
340
  monitoringUser?: string;
333
341
  monitoringPassword: string;
334
342
  includeOptionalPermissions: boolean;
335
- roleExists?: boolean;
336
343
  }): Promise<InitPlan> {
337
- const monitoringUser = params.monitoringUser || "postgres_ai_mon";
344
+ // NOTE: kept async for API stability / potential future async template loading.
345
+ const monitoringUser = params.monitoringUser || DEFAULT_MONITORING_USER;
338
346
  const database = params.database;
339
347
 
340
348
  const qRole = quoteIdent(monitoringUser);
@@ -344,27 +352,26 @@ export async function buildInitPlan(params: {
344
352
 
345
353
  const steps: InitStep[] = [];
346
354
 
347
- const vars = {
355
+ const vars: Record<string, string> = {
348
356
  ROLE_IDENT: qRole,
349
357
  DB_IDENT: qDb,
350
358
  };
351
359
 
352
360
  // Role creation/update is done in one template file.
353
- // If roleExists is unknown, use a single DO block to create-or-alter safely.
354
- let roleStmt: string | null = null;
355
- if (params.roleExists === false) {
356
- roleStmt = `create user ${qRole} with password ${qPw};`;
357
- } else if (params.roleExists === true) {
358
- roleStmt = `alter user ${qRole} with password ${qPw};`;
359
- } else {
360
- roleStmt = `do $$ begin
361
+ // Always use a single DO block to avoid race conditions between "role exists?" checks and CREATE USER.
362
+ // We:
363
+ // - create role if missing (and handle duplicate_object in case another session created it concurrently),
364
+ // - then ALTER ROLE to ensure the password is set to the desired value.
365
+ const roleStmt = `do $$ begin
361
366
  if not exists (select 1 from pg_catalog.pg_roles where rolname = ${qRoleNameLit}) then
362
- create user ${qRole} with password ${qPw};
363
- else
364
- alter user ${qRole} with password ${qPw};
367
+ begin
368
+ create user ${qRole} with password ${qPw};
369
+ exception when duplicate_object then
370
+ null;
371
+ end;
365
372
  end if;
373
+ alter user ${qRole} with password ${qPw};
366
374
  end $$;`;
367
- }
368
375
 
369
376
  const roleSql = applyTemplate(loadSqlTemplate("01.role.sql"), { ...vars, ROLE_STMT: roleStmt });
370
377
  steps.push({ name: "01.role", sql: roleSql });
@@ -411,9 +418,31 @@ export async function applyInitPlan(params: {
411
418
  const msg = e instanceof Error ? e.message : String(e);
412
419
  const errAny = e as any;
413
420
  const wrapped: any = new Error(`Failed at step "${step.name}": ${msg}`);
414
- // Preserve Postgres error code so callers can provide better hints (e.g., 42501 insufficient_privilege).
415
- if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
416
- wrapped.code = errAny.code;
421
+ // Preserve useful Postgres error fields so callers can provide better hints / diagnostics.
422
+ const pgErrorFields = [
423
+ "code",
424
+ "detail",
425
+ "hint",
426
+ "position",
427
+ "internalPosition",
428
+ "internalQuery",
429
+ "where",
430
+ "schema",
431
+ "table",
432
+ "column",
433
+ "dataType",
434
+ "constraint",
435
+ "file",
436
+ "line",
437
+ "routine",
438
+ ] as const;
439
+ if (errAny && typeof errAny === "object") {
440
+ for (const field of pgErrorFields) {
441
+ if (errAny[field] !== undefined) wrapped[field] = errAny[field];
442
+ }
443
+ }
444
+ if (e instanceof Error && e.stack) {
445
+ wrapped.stack = e.stack;
417
446
  }
418
447
  throw wrapped;
419
448
  }
@@ -432,11 +461,24 @@ export async function applyInitPlan(params: {
432
461
  // Apply optional steps outside of the transaction so a failure doesn't abort everything.
433
462
  for (const step of params.plan.steps.filter((s) => s.optional)) {
434
463
  try {
435
- await params.client.query(step.sql, step.params as any);
436
- applied.push(step.name);
464
+ // Run each optional step in its own mini-transaction to avoid partial application.
465
+ await params.client.query("begin;");
466
+ try {
467
+ await params.client.query(step.sql, step.params as any);
468
+ await params.client.query("commit;");
469
+ applied.push(step.name);
470
+ } catch {
471
+ try {
472
+ await params.client.query("rollback;");
473
+ } catch {
474
+ // ignore rollback errors
475
+ }
476
+ skippedOptional.push(step.name);
477
+ // best-effort: ignore
478
+ }
437
479
  } catch {
480
+ // If we can't even begin/commit, treat as skipped.
438
481
  skippedOptional.push(step.name);
439
- // best-effort: ignore
440
482
  }
441
483
  }
442
484
 
@@ -455,111 +497,122 @@ export async function verifyInitSetup(params: {
455
497
  monitoringUser: string;
456
498
  includeOptionalPermissions: boolean;
457
499
  }): Promise<VerifyInitResult> {
458
- const missingRequired: string[] = [];
459
- const missingOptional: string[] = [];
460
-
461
- const role = params.monitoringUser;
462
- const db = params.database;
463
-
464
- const roleRes = await params.client.query("select 1 from pg_catalog.pg_roles where rolname = $1", [role]);
465
- const roleExists = (roleRes.rowCount ?? 0) > 0;
466
- if (!roleExists) {
467
- missingRequired.push(`role "${role}" does not exist`);
468
- // If role is missing, other checks will error or be meaningless.
469
- return { ok: false, missingRequired, missingOptional };
470
- }
471
-
472
- const connectRes = await params.client.query(
473
- "select has_database_privilege($1, $2, 'CONNECT') as ok",
474
- [role, db]
475
- );
476
- if (!connectRes.rows?.[0]?.ok) {
477
- missingRequired.push(`CONNECT on database "${db}"`);
478
- }
500
+ // Use a repeatable-read snapshot so all checks see a consistent view.
501
+ await params.client.query("begin isolation level repeatable read;");
502
+ try {
503
+ const missingRequired: string[] = [];
504
+ const missingOptional: string[] = [];
505
+
506
+ const role = params.monitoringUser;
507
+ const db = params.database;
508
+
509
+ const roleRes = await params.client.query("select 1 from pg_catalog.pg_roles where rolname = $1", [role]);
510
+ const roleExists = (roleRes.rowCount ?? 0) > 0;
511
+ if (!roleExists) {
512
+ missingRequired.push(`role "${role}" does not exist`);
513
+ // If role is missing, other checks will error or be meaningless.
514
+ return { ok: false, missingRequired, missingOptional };
515
+ }
479
516
 
480
- const pgMonitorRes = await params.client.query(
481
- "select pg_has_role($1, 'pg_monitor', 'member') as ok",
482
- [role]
483
- );
484
- if (!pgMonitorRes.rows?.[0]?.ok) {
485
- missingRequired.push("membership in role pg_monitor");
486
- }
517
+ const connectRes = await params.client.query(
518
+ "select has_database_privilege($1, $2, 'CONNECT') as ok",
519
+ [role, db]
520
+ );
521
+ if (!connectRes.rows?.[0]?.ok) {
522
+ missingRequired.push(`CONNECT on database "${db}"`);
523
+ }
487
524
 
488
- const pgIndexRes = await params.client.query(
489
- "select has_table_privilege($1, 'pg_catalog.pg_index', 'SELECT') as ok",
490
- [role]
491
- );
492
- if (!pgIndexRes.rows?.[0]?.ok) {
493
- missingRequired.push("SELECT on pg_catalog.pg_index");
494
- }
525
+ const pgMonitorRes = await params.client.query(
526
+ "select pg_has_role($1, 'pg_monitor', 'member') as ok",
527
+ [role]
528
+ );
529
+ if (!pgMonitorRes.rows?.[0]?.ok) {
530
+ missingRequired.push("membership in role pg_monitor");
531
+ }
495
532
 
496
- const viewExistsRes = await params.client.query("select to_regclass('public.pg_statistic') is not null as ok");
497
- if (!viewExistsRes.rows?.[0]?.ok) {
498
- missingRequired.push("view public.pg_statistic exists");
499
- } else {
500
- const viewPrivRes = await params.client.query(
501
- "select has_table_privilege($1, 'public.pg_statistic', 'SELECT') as ok",
533
+ const pgIndexRes = await params.client.query(
534
+ "select has_table_privilege($1, 'pg_catalog.pg_index', 'SELECT') as ok",
502
535
  [role]
503
536
  );
504
- if (!viewPrivRes.rows?.[0]?.ok) {
505
- missingRequired.push("SELECT on view public.pg_statistic");
537
+ if (!pgIndexRes.rows?.[0]?.ok) {
538
+ missingRequired.push("SELECT on pg_catalog.pg_index");
506
539
  }
507
- }
508
540
 
509
- const schemaUsageRes = await params.client.query(
510
- "select has_schema_privilege($1, 'public', 'USAGE') as ok",
511
- [role]
512
- );
513
- if (!schemaUsageRes.rows?.[0]?.ok) {
514
- missingRequired.push("USAGE on schema public");
515
- }
541
+ const viewExistsRes = await params.client.query("select to_regclass('public.pg_statistic') is not null as ok");
542
+ if (!viewExistsRes.rows?.[0]?.ok) {
543
+ missingRequired.push("view public.pg_statistic exists");
544
+ } else {
545
+ const viewPrivRes = await params.client.query(
546
+ "select has_table_privilege($1, 'public.pg_statistic', 'SELECT') as ok",
547
+ [role]
548
+ );
549
+ if (!viewPrivRes.rows?.[0]?.ok) {
550
+ missingRequired.push("SELECT on view public.pg_statistic");
551
+ }
552
+ }
516
553
 
517
- const rolcfgRes = await params.client.query("select rolconfig from pg_catalog.pg_roles where rolname = $1", [role]);
518
- const rolconfig = rolcfgRes.rows?.[0]?.rolconfig;
519
- const spLine = Array.isArray(rolconfig) ? rolconfig.find((v: any) => String(v).startsWith("search_path=")) : undefined;
520
- if (typeof spLine !== "string" || !spLine) {
521
- missingRequired.push("role search_path is set");
522
- } else {
523
- // We accept any ordering as long as public and pg_catalog are included.
524
- const sp = spLine.toLowerCase();
525
- if (!sp.includes("public") || !sp.includes("pg_catalog")) {
526
- missingRequired.push("role search_path includes public and pg_catalog");
554
+ const schemaUsageRes = await params.client.query(
555
+ "select has_schema_privilege($1, 'public', 'USAGE') as ok",
556
+ [role]
557
+ );
558
+ if (!schemaUsageRes.rows?.[0]?.ok) {
559
+ missingRequired.push("USAGE on schema public");
527
560
  }
528
- }
529
561
 
530
- if (params.includeOptionalPermissions) {
531
- // Optional RDS/Aurora extras
532
- {
533
- const extRes = await params.client.query("select 1 from pg_extension where extname = 'rds_tools'");
534
- if ((extRes.rowCount ?? 0) === 0) {
535
- missingOptional.push("extension rds_tools");
536
- } else {
537
- const fnRes = await params.client.query(
538
- "select has_function_privilege($1, 'rds_tools.pg_ls_multixactdir()', 'EXECUTE') as ok",
539
- [role]
540
- );
562
+ const rolcfgRes = await params.client.query("select rolconfig from pg_catalog.pg_roles where rolname = $1", [role]);
563
+ const rolconfig = rolcfgRes.rows?.[0]?.rolconfig;
564
+ const spLine = Array.isArray(rolconfig) ? rolconfig.find((v: any) => String(v).startsWith("search_path=")) : undefined;
565
+ if (typeof spLine !== "string" || !spLine) {
566
+ missingRequired.push("role search_path is set");
567
+ } else {
568
+ // We accept any ordering as long as public and pg_catalog are included.
569
+ const sp = spLine.toLowerCase();
570
+ if (!sp.includes("public") || !sp.includes("pg_catalog")) {
571
+ missingRequired.push("role search_path includes public and pg_catalog");
572
+ }
573
+ }
574
+
575
+ if (params.includeOptionalPermissions) {
576
+ // Optional RDS/Aurora extras
577
+ {
578
+ const extRes = await params.client.query("select 1 from pg_extension where extname = 'rds_tools'");
579
+ if ((extRes.rowCount ?? 0) === 0) {
580
+ missingOptional.push("extension rds_tools");
581
+ } else {
582
+ const fnRes = await params.client.query(
583
+ "select has_function_privilege($1, 'rds_tools.pg_ls_multixactdir()', 'EXECUTE') as ok",
584
+ [role]
585
+ );
586
+ if (!fnRes.rows?.[0]?.ok) {
587
+ missingOptional.push("EXECUTE on rds_tools.pg_ls_multixactdir()");
588
+ }
589
+ }
590
+ }
591
+
592
+ // Optional self-managed extras
593
+ const optionalFns = [
594
+ "pg_catalog.pg_stat_file(text)",
595
+ "pg_catalog.pg_stat_file(text, boolean)",
596
+ "pg_catalog.pg_ls_dir(text)",
597
+ "pg_catalog.pg_ls_dir(text, boolean, boolean)",
598
+ ];
599
+ for (const fn of optionalFns) {
600
+ const fnRes = await params.client.query("select has_function_privilege($1, $2, 'EXECUTE') as ok", [role, fn]);
541
601
  if (!fnRes.rows?.[0]?.ok) {
542
- missingOptional.push("EXECUTE on rds_tools.pg_ls_multixactdir()");
602
+ missingOptional.push(`EXECUTE on ${fn}`);
543
603
  }
544
604
  }
545
605
  }
546
606
 
547
- // Optional self-managed extras
548
- const optionalFns = [
549
- "pg_catalog.pg_stat_file(text)",
550
- "pg_catalog.pg_stat_file(text, boolean)",
551
- "pg_catalog.pg_ls_dir(text)",
552
- "pg_catalog.pg_ls_dir(text, boolean, boolean)",
553
- ];
554
- for (const fn of optionalFns) {
555
- const fnRes = await params.client.query("select has_function_privilege($1, $2, 'EXECUTE') as ok", [role, fn]);
556
- if (!fnRes.rows?.[0]?.ok) {
557
- missingOptional.push(`EXECUTE on ${fn}`);
558
- }
607
+ return { ok: missingRequired.length === 0, missingRequired, missingOptional };
608
+ } finally {
609
+ // Read-only: rollback to release snapshot; do not mask original errors.
610
+ try {
611
+ await params.client.query("rollback;");
612
+ } catch {
613
+ // ignore
559
614
  }
560
615
  }
561
-
562
- return { ok: missingRequired.length === 0, missingRequired, missingOptional };
563
616
  }
564
617
 
565
618
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.14.0-dev.14",
3
+ "version": "0.14.0-dev.16",
4
4
  "description": "postgres_ai CLI (Node.js)",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
package/sql/01.role.sql CHANGED
@@ -1,14 +1,15 @@
1
1
  -- Role creation / password update (template-filled by cli/lib/init.ts)
2
2
  --
3
- -- Example expansions (for readability/review):
4
- -- create user "postgres_ai_mon" with password '...';
5
- -- alter user "postgres_ai_mon" with password '...';
3
+ -- Always uses a race-safe pattern (create if missing, then always alter to set the password):
6
4
  -- do $$ begin
7
- -- if not exists (select 1 from pg_catalog.pg_roles where rolname = 'postgres_ai_mon') then
8
- -- create user "postgres_ai_mon" with password '...';
9
- -- else
10
- -- alter user "postgres_ai_mon" with password '...';
5
+ -- if not exists (select 1 from pg_catalog.pg_roles where rolname = '...') then
6
+ -- begin
7
+ -- create user "..." with password '...';
8
+ -- exception when duplicate_object then
9
+ -- null;
10
+ -- end;
11
11
  -- end if;
12
+ -- alter user "..." with password '...';
12
13
  -- end $$;
13
14
  {{ROLE_STMT}}
14
15
 
@@ -92,25 +92,41 @@ async function withTempPostgres(t) {
92
92
 
93
93
  const port = await getFreePort();
94
94
 
95
- const postgresProc = spawn(postgresBin, ["-D", dataDir, "-k", socketDir, "-h", "127.0.0.1", "-p", String(port)], {
96
- stdio: ["ignore", "pipe", "pipe"],
97
- });
98
-
99
- // Register cleanup immediately so failures below don't leave a running postgres and hang CI.
100
- t.after(async () => {
101
- postgresProc.kill("SIGTERM");
95
+ let postgresProc;
96
+ try {
97
+ postgresProc = spawn(
98
+ postgresBin,
99
+ ["-D", dataDir, "-k", socketDir, "-h", "127.0.0.1", "-p", String(port)],
100
+ {
101
+ stdio: ["ignore", "pipe", "pipe"],
102
+ }
103
+ );
104
+
105
+ // Register cleanup immediately so failures below don't leave a running postgres and hang CI.
106
+ t.after(async () => {
107
+ postgresProc.kill("SIGTERM");
108
+ try {
109
+ await waitFor(
110
+ async () => {
111
+ if (postgresProc.exitCode === null) throw new Error("still running");
112
+ },
113
+ { timeoutMs: 5000, intervalMs: 100 }
114
+ );
115
+ } catch {
116
+ postgresProc.kill("SIGKILL");
117
+ }
118
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
119
+ });
120
+ } catch (e) {
121
+ // If anything goes wrong before cleanup is registered, ensure we don't leak a running postgres.
102
122
  try {
103
- await waitFor(
104
- async () => {
105
- if (postgresProc.exitCode === null) throw new Error("still running");
106
- },
107
- { timeoutMs: 5000, intervalMs: 100 }
108
- );
123
+ if (postgresProc) postgresProc.kill("SIGKILL");
109
124
  } catch {
110
- postgresProc.kill("SIGKILL");
125
+ // ignore
111
126
  }
112
127
  fs.rmSync(tmpRoot, { recursive: true, force: true });
113
- });
128
+ throw e;
129
+ }
114
130
 
115
131
  const { Client } = require("pg");
116
132
 
@@ -208,8 +224,8 @@ test(
208
224
  {
209
225
  const r = await runCliInit([pg.adminUri, "--print-password", "--skip-optional-permissions"]);
210
226
  assert.equal(r.status, 0, r.stderr || r.stdout);
211
- assert.match(r.stdout, /Generated monitoring password for postgres_ai_mon/i);
212
- assert.match(r.stdout, /PGAI_MON_PASSWORD=/);
227
+ assert.match(r.stderr, /Generated monitoring password for postgres_ai_mon/i);
228
+ assert.match(r.stderr, /PGAI_MON_PASSWORD=/);
213
229
  }
214
230
  }
215
231
  );
@@ -294,12 +310,10 @@ test("integration: init reports nicely when lacking permissions", { skip: !haveP
294
310
  const limitedUri = `postgresql://limited:${limitedPw}@127.0.0.1:${pg.port}/testdb`;
295
311
  const r = await runCliInit([limitedUri, "--password", "monpw", "--skip-optional-permissions"]);
296
312
  assert.notEqual(r.status, 0);
297
- assert.match(r.stderr, /init failed:/);
313
+ assert.match(r.stderr, /Error: init:/);
298
314
  // Should include step context and hint.
299
315
  assert.match(r.stderr, /Failed at step "/);
300
- assert.match(r.stderr, /Permission error:/i);
301
- assert.match(r.stderr, /How to fix:/i);
302
- assert.match(r.stderr, /Hint: connect as a superuser/i);
316
+ assert.match(r.stderr, /Fix: connect as a superuser/i);
303
317
  });
304
318
 
305
319
  test("integration: init --verify returns 0 when ok and non-zero when missing", { skip: !havePostgresBinaries() }, async (t) => {