pgserve 2.2.0 → 2.2.1

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/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## v2.2.x — Transparent Upgrade
2
+
3
+ **Added:** `autopg upgrade` CLI verb — idempotent migration runner that reconciles port back to canonical 8432, flushes the binary cache against the pinned PG version, re-resolves the plpgsql `.so` path per database, refreshes `~/.autopg/<app>.env` files, signals consumers, and validates final health.
4
+
5
+ **Added:** npm `postinstall` hook (`scripts/postinstall.cjs`) auto-runs `autopg upgrade --quiet` when an existing `~/.autopg/data/` is detected on `bun install`. Soft-fails so package install never breaks; manual `autopg upgrade` remains the explicit escape hatch.
6
+
7
+ **Contract:** Users upgrading from pgserve@2.1.3 to autopg@2.2.x get transparent migration via the postinstall hook. Manual `autopg upgrade` remains as the explicit escape hatch for forced re-runs. Patches the upgrade-path hole left by autopg-v22 partial roll-out (binary moved to `~/.autopg/`, default port silently shifted to 9432, plpgsql extensions referenced stale `$libdir`).
8
+
9
+ **Override:** Set `AUTOPG_SKIP_POSTINSTALL=1` to bypass the hook (CI / containers / install-only flows).
10
+
1
11
  # Changelog
2
12
 
3
13
  All notable changes to `pgserve` are documented here. The format follows
@@ -40,6 +40,7 @@ const __installSubcommands = new Set([
40
40
  // pm2 for restart). They don't need bun, so route them BEFORE the bun
41
41
  // probe — same rationale as the wave-1 install commands.
42
42
  'config',
43
+ 'upgrade',
43
44
  'restart',
44
45
  'ui',
45
46
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgserve",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "Embedded PostgreSQL server with true concurrent connections - zero config, auto-provision databases",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -15,7 +15,8 @@
15
15
  "README.md",
16
16
  "CHANGELOG.md",
17
17
  "LICENSE",
18
- "SECURITY.md"
18
+ "SECURITY.md",
19
+ "scripts/"
19
20
  ],
20
21
  "scripts": {
21
22
  "bench": "bun tests/benchmarks/runner.js",
@@ -31,7 +32,8 @@
31
32
  "test:npx": "scripts/test-npx.sh",
32
33
  "test:bun-self-heal": "scripts/test-bun-self-heal.sh",
33
34
  "prepublishOnly": "npm run lint && npm run deadcode && npm run test:npx && npm run test:bun-self-heal",
34
- "prepare": "husky"
35
+ "prepare": "husky",
36
+ "postinstall": "node scripts/postinstall.cjs"
35
37
  },
36
38
  "keywords": [
37
39
  "postgresql",
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * autopg postinstall — auto-runs `autopg upgrade` on detected upgrade.
4
+ *
5
+ * Behavior:
6
+ * - Fresh install (no ~/.autopg/data/) → exit 0 silently (no upgrade needed)
7
+ * - Upgrade install (data dir exists) → invoke `autopg upgrade --quiet`
8
+ * - Soft-fail: any error logs warning, exits 0 (never breaks `bun install`)
9
+ * - Skip override: AUTOPG_SKIP_POSTINSTALL=1 → exit 0 immediately
10
+ *
11
+ * The escape hatch for forced re-runs is `autopg upgrade` (manual).
12
+ *
13
+ * See: .genie/wishes/autopg-upgrade-command/WISH.md
14
+ */
15
+
16
+ const fs = require('node:fs');
17
+ const path = require('node:path');
18
+ const { spawnSync } = require('node:child_process');
19
+
20
+ function getAutopgRoot() {
21
+ return process.env.AUTOPG_CONFIG_DIR || process.env.PGSERVE_CONFIG_DIR || `${process.env.HOME}/.autopg`;
22
+ }
23
+
24
+ function main() {
25
+ if (process.env.AUTOPG_SKIP_POSTINSTALL === '1') {
26
+ return;
27
+ }
28
+ const dataDir = path.join(getAutopgRoot(), 'data');
29
+ if (!fs.existsSync(dataDir)) {
30
+ // Fresh install — nothing to upgrade
31
+ return;
32
+ }
33
+ // Locate own CLI entry — script is run from the package dir at install time
34
+ const cliEntry = path.join(__dirname, '..', 'bin', 'pgserve-wrapper.cjs');
35
+ if (!fs.existsSync(cliEntry)) {
36
+ process.stderr.write(`[autopg-postinstall] wrapper not found at ${cliEntry}, skipping\n`);
37
+ return;
38
+ }
39
+ const result = spawnSync(process.execPath, [cliEntry, 'upgrade', '--quiet'], {
40
+ stdio: ['ignore', 'inherit', 'inherit'],
41
+ timeout: 60_000,
42
+ });
43
+ if (result.error) {
44
+ process.stderr.write(`[autopg-postinstall] WARNING: upgrade invocation failed: ${result.error.message}\n`);
45
+ process.stderr.write('[autopg-postinstall] Run `autopg upgrade` manually to retry.\n');
46
+ return;
47
+ }
48
+ if (result.status !== 0) {
49
+ process.stderr.write(`[autopg-postinstall] WARNING: \`autopg upgrade\` exited ${result.status}\n`);
50
+ process.stderr.write('[autopg-postinstall] Run `autopg upgrade` manually to investigate.\n');
51
+ }
52
+ }
53
+
54
+ try {
55
+ main();
56
+ } catch (err) {
57
+ process.stderr.write(`[autopg-postinstall] WARNING: unexpected error: ${err.message}\n`);
58
+ }
59
+
60
+ process.exit(0);
@@ -0,0 +1,163 @@
1
+ #!/bin/bash
2
+ # Regression test for https://github.com/namastexlabs/pgserve/issues/22
3
+ #
4
+ # When pgserve is installed via `bun install`, the nested `bun` npm package's
5
+ # postinstall can be skipped, leaving @oven/bun-<platform>/bin/bun empty.
6
+ # The bun stub then refuses to run with "Bun's postinstall script was not run".
7
+ # pgserve-wrapper.cjs must detect this and self-heal via `node install.js`.
8
+ #
9
+ # This test stages a synthetic broken install tree, runs the wrapper, and
10
+ # asserts that it recovers and spawns postgres-server.
11
+
12
+ set -e
13
+
14
+ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
15
+ WRAPPER="$REPO_ROOT/bin/pgserve-wrapper.cjs"
16
+
17
+ if [ ! -f "$WRAPPER" ]; then
18
+ echo "✗ wrapper not found: $WRAPPER"
19
+ exit 1
20
+ fi
21
+
22
+ # Use a real bun binary as the "recovered" payload so the healthy-path
23
+ # assertion is meaningful. Falls back to any bun on PATH.
24
+ REAL_BUN="${BUN_BIN:-$(command -v bun || true)}"
25
+ if [ -z "$REAL_BUN" ] || [ ! -x "$REAL_BUN" ]; then
26
+ echo "✗ bun runtime not found on PATH (set BUN_BIN to override)"
27
+ exit 1
28
+ fi
29
+
30
+ FIXTURE=$(mktemp -d)
31
+ trap "rm -rf $FIXTURE" EXIT
32
+
33
+ mkdir -p "$FIXTURE/node_modules/bun/bin"
34
+ mkdir -p "$FIXTURE/node_modules/@oven/bun-linux-x64/bin" # empty, simulating the bug
35
+ mkdir -p "$FIXTURE/node_modules/.bin"
36
+ mkdir -p "$FIXTURE/node_modules/pgserve/bin"
37
+
38
+ cp "$WRAPPER" "$FIXTURE/node_modules/pgserve/bin/pgserve-wrapper.cjs"
39
+
40
+ # Stub postgres-server so we can detect a successful spawn without needing
41
+ # postgres binaries in the fixture.
42
+ cat > "$FIXTURE/node_modules/pgserve/bin/postgres-server.js" <<'EOF'
43
+ console.log("postgres-server-spawned");
44
+ process.exit(0);
45
+ EOF
46
+
47
+ # Fake bun install.js: copies the real bun into the expected @oven location,
48
+ # mirroring what the real postinstall does.
49
+ cat > "$FIXTURE/node_modules/bun/install.js" <<EOF
50
+ const fs = require('fs');
51
+ const path = require('path');
52
+ const dst = path.resolve(__dirname, '..', '@oven', 'bun-linux-x64', 'bin', 'bun');
53
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
54
+ fs.copyFileSync('$REAL_BUN', dst);
55
+ fs.chmodSync(dst, 0o755);
56
+ console.log('[test] install.js populated', dst);
57
+ EOF
58
+ echo '{"name":"bun","version":"1.3.12"}' > "$FIXTURE/node_modules/bun/package.json"
59
+
60
+ # Broken bun stub: prints the postinstall error unless the @oven binary exists.
61
+ cat > "$FIXTURE/node_modules/bun/bin/bun" <<'EOF'
62
+ #!/bin/sh
63
+ SELF=$(readlink -f "$0")
64
+ TARGET="$(dirname "$SELF")/../../@oven/bun-linux-x64/bin/bun"
65
+ if [ ! -x "$TARGET" ]; then
66
+ echo "Error: Bun's postinstall script was not run." >&2
67
+ echo "" >&2
68
+ echo "To fix this, run the postinstall script manually:" >&2
69
+ echo " cd node_modules/bun && node install.js" >&2
70
+ exit 1
71
+ fi
72
+ exec "$TARGET" "$@"
73
+ EOF
74
+ chmod +x "$FIXTURE/node_modules/bun/bin/bun"
75
+
76
+ ln -s ../bun/bin/bun "$FIXTURE/node_modules/.bin/bun"
77
+
78
+ echo "=== Testing self-heal on broken install ==="
79
+ OUTPUT=$(node "$FIXTURE/node_modules/pgserve/bin/pgserve-wrapper.cjs" 2>&1)
80
+ EXIT=$?
81
+
82
+ if [ $EXIT -ne 0 ]; then
83
+ echo "✗ wrapper exited non-zero: $EXIT"
84
+ echo "$OUTPUT"
85
+ exit 1
86
+ fi
87
+
88
+ if ! echo "$OUTPUT" | grep -q "attempting self-heal"; then
89
+ echo "✗ wrapper did not attempt self-heal"
90
+ echo "$OUTPUT"
91
+ exit 1
92
+ fi
93
+
94
+ if ! echo "$OUTPUT" | grep -q "bun runtime recovered"; then
95
+ echo "✗ wrapper did not report recovery"
96
+ echo "$OUTPUT"
97
+ exit 1
98
+ fi
99
+
100
+ if ! echo "$OUTPUT" | grep -q "postgres-server-spawned"; then
101
+ echo "✗ postgres-server was not spawned after self-heal"
102
+ echo "$OUTPUT"
103
+ exit 1
104
+ fi
105
+
106
+ echo "✓ self-heal path: wrapper detected, repaired, and spawned postgres-server"
107
+
108
+ echo ""
109
+ echo "=== Testing healthy path is unaffected ==="
110
+ OUTPUT=$(node "$FIXTURE/node_modules/pgserve/bin/pgserve-wrapper.cjs" 2>&1)
111
+ EXIT=$?
112
+
113
+ if [ $EXIT -ne 0 ]; then
114
+ echo "✗ wrapper exited non-zero on healthy path: $EXIT"
115
+ echo "$OUTPUT"
116
+ exit 1
117
+ fi
118
+
119
+ if echo "$OUTPUT" | grep -q "self-heal\|recovered"; then
120
+ echo "✗ wrapper logged self-heal messages on a healthy install"
121
+ echo "$OUTPUT"
122
+ exit 1
123
+ fi
124
+
125
+ if ! echo "$OUTPUT" | grep -q "postgres-server-spawned"; then
126
+ echo "✗ postgres-server was not spawned on healthy path"
127
+ echo "$OUTPUT"
128
+ exit 1
129
+ fi
130
+
131
+ echo "✓ healthy path: wrapper was silent and spawned postgres-server directly"
132
+
133
+ echo ""
134
+ echo "=== Testing non-postinstall errors surface raw ==="
135
+ # Replace stub with one that emits an unrelated error.
136
+ cat > "$FIXTURE/node_modules/bun/bin/bun" <<'EOF'
137
+ #!/bin/sh
138
+ echo "Error: GLIBC_2.99 not found (libc mismatch)" >&2
139
+ exit 127
140
+ EOF
141
+ chmod +x "$FIXTURE/node_modules/bun/bin/bun"
142
+
143
+ # Clear the @oven healed binary so the stub is what runs.
144
+ rm -f "$FIXTURE/node_modules/@oven/bun-linux-x64/bin/bun"
145
+
146
+ OUTPUT=$(node "$FIXTURE/node_modules/pgserve/bin/pgserve-wrapper.cjs" 2>&1 || true)
147
+
148
+ if echo "$OUTPUT" | grep -q "self-heal"; then
149
+ echo "✗ wrapper tried self-heal for a non-postinstall error"
150
+ echo "$OUTPUT"
151
+ exit 1
152
+ fi
153
+
154
+ if ! echo "$OUTPUT" | grep -q "GLIBC_2.99"; then
155
+ echo "✗ wrapper did not surface the real error message"
156
+ echo "$OUTPUT"
157
+ exit 1
158
+ fi
159
+
160
+ echo "✓ unrelated-error path: wrapper surfaced the raw error without self-heal"
161
+
162
+ echo ""
163
+ echo "=== bun self-heal test PASSED ==="
@@ -0,0 +1,60 @@
1
+ #!/bin/bash
2
+ # Test that the package works with npx (simulates fresh user install)
3
+ # This catches path resolution issues that static analysis can't detect
4
+
5
+ set -e
6
+
7
+ echo "=== Testing npx compatibility ==="
8
+
9
+ # Create temp directory
10
+ TEST_DIR=$(mktemp -d)
11
+ trap "rm -rf $TEST_DIR" EXIT
12
+
13
+ # Pack the current package
14
+ echo "Packing package..."
15
+ PACK_OUTPUT=$(npm pack --pack-destination "$TEST_DIR" 2>&1)
16
+ PACK_FILE=$(echo "$PACK_OUTPUT" | grep -E '\.tgz$' | tail -1)
17
+
18
+ # If npm pack fails, exit with an error
19
+ if [ -z "$PACK_FILE" ] || [ ! -f "$TEST_DIR/$PACK_FILE" ]; then
20
+ echo "✗ Failed to pack package with npm"
21
+ echo "Pack output: $PACK_OUTPUT"
22
+ exit 1
23
+ fi
24
+ echo "Packed: $PACK_FILE"
25
+
26
+ # Install in isolated environment using npm
27
+ echo "Installing in isolated environment..."
28
+ cd "$TEST_DIR"
29
+ echo '{"name":"test-npx-install","private":true}' > package.json
30
+ npm install "./$PACK_FILE" > /dev/null 2>&1
31
+
32
+ # Test that it starts (with timeout)
33
+ echo "Testing server startup via npx..."
34
+ timeout 30 npx pgserve --no-cluster --port 15432 > output.log 2>&1 &
35
+ PID=$!
36
+
37
+ # Wait for ready signal (Server started successfully!)
38
+ for i in {1..60}; do
39
+ if grep -q "Server started successfully" output.log 2>/dev/null; then
40
+ echo "✓ Server started successfully via npx"
41
+ kill $PID 2>/dev/null || true
42
+ wait $PID 2>/dev/null || true
43
+ echo "=== npx test PASSED ==="
44
+ exit 0
45
+ fi
46
+ if ! kill -0 $PID 2>/dev/null; then
47
+ echo "✗ Server exited unexpectedly"
48
+ cat output.log
49
+ echo "=== npx test FAILED ==="
50
+ exit 1
51
+ fi
52
+ sleep 0.5
53
+ done
54
+
55
+ # Timeout
56
+ kill $PID 2>/dev/null || true
57
+ echo "✗ Server did not start within timeout"
58
+ cat output.log
59
+ echo "=== npx test FAILED ==="
60
+ exit 1
@@ -483,6 +483,20 @@ function dispatch(subcommand, args, ctx) {
483
483
  return cmdUrl();
484
484
  case 'port':
485
485
  return cmdPort();
486
+ case 'upgrade': {
487
+ const opts = {
488
+ quiet: args.includes('--quiet'),
489
+ dryRun: args.includes('--dry-run'),
490
+ skipSteps: (() => {
491
+ const idx = args.indexOf('--skip-steps');
492
+ if (idx === -1) return [];
493
+ return (args[idx + 1] || '').split(',').filter(Boolean);
494
+ })(),
495
+ };
496
+ return import(require('node:path').join(__dirname, 'upgrade', 'index.js'))
497
+ .then((mod) => mod.upgrade(opts))
498
+ .then((r) => process.exit(r.ok ? 0 : 1));
499
+ }
486
500
  case 'config': {
487
501
  const cfg = require('./cli-config.cjs');
488
502
  const [sub, ...rest] = args;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * autopg upgrade — idempotent migration orchestrator.
3
+ *
4
+ * Runs 6 steps in order, each safe to re-run any number of times:
5
+ * 1. port-reconcile — ensure pgserve listens on canonical port (8432)
6
+ * 2. binary-cache-flush — verify binary version matches PINNED_PG_VERSION
7
+ * 3. plpgsql-resolve — DROP+CREATE plpgsql per DB to refresh .so path
8
+ * 4. env-refresh — regenerate ~/.autopg/<app>.env URLs
9
+ * 5. consumer-signal — touch ~/.autopg/state/upgrade.signal
10
+ * 6. health-validate — pg_isready + per-DB plpgsql smoke test
11
+ *
12
+ * Patches the upgrade-path hole left by autopg-v22 partial roll-out.
13
+ * See: .genie/wishes/autopg-upgrade-command/WISH.md
14
+ */
15
+
16
+ import { runStep } from './runner.js';
17
+ import * as portReconcile from './steps/port-reconcile.js';
18
+ import * as binaryCacheFlush from './steps/binary-cache-flush.js';
19
+ import * as plpgsqlResolve from './steps/plpgsql-resolve.js';
20
+ import * as envRefresh from './steps/env-refresh.js';
21
+ import * as consumerSignal from './steps/consumer-signal.js';
22
+ import * as healthValidate from './steps/health-validate.js';
23
+
24
+ export const STEPS = [
25
+ { name: 'port-reconcile', impl: portReconcile },
26
+ { name: 'binary-cache-flush', impl: binaryCacheFlush },
27
+ { name: 'plpgsql-resolve', impl: plpgsqlResolve },
28
+ { name: 'env-refresh', impl: envRefresh },
29
+ { name: 'consumer-signal', impl: consumerSignal },
30
+ { name: 'health-validate', impl: healthValidate },
31
+ ];
32
+
33
+ export async function upgrade(options = {}) {
34
+ const { quiet = false, dryRun = false, skipSteps = [] } = options;
35
+ const log = (msg) => { if (!quiet) process.stderr.write(`${msg}\n`); };
36
+ const warn = (msg) => process.stderr.write(`${msg}\n`);
37
+
38
+ log(`autopg upgrade starting (dryRun=${dryRun}, quiet=${quiet})`);
39
+
40
+ const results = [];
41
+ for (const step of STEPS) {
42
+ if (skipSteps.includes(step.name)) {
43
+ log(`[${step.name}] SKIP (excluded by --skip-steps)`);
44
+ results.push({ name: step.name, status: 'SKIP', detail: 'excluded' });
45
+ continue;
46
+ }
47
+ try {
48
+ const result = await runStep(step.name, step.impl, { dryRun, log, warn });
49
+ results.push(result);
50
+ } catch (err) {
51
+ warn(`[${step.name}] FAIL: ${err.message}`);
52
+ results.push({ name: step.name, status: 'FAIL', detail: err.message });
53
+ }
54
+ }
55
+
56
+ const failed = results.filter((r) => r.status === 'FAIL');
57
+ const summary = `autopg upgrade complete: ${results.length - failed.length}/${results.length} steps OK`;
58
+ log(summary);
59
+ if (failed.length > 0) {
60
+ warn(`Failed steps: ${failed.map((r) => r.name).join(', ')}`);
61
+ warn('Re-run `autopg upgrade` after addressing the above.');
62
+ return { ok: false, results, summary };
63
+ }
64
+ return { ok: true, results, summary };
65
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Step runner — wraps each step with consistent logging + error capture.
3
+ *
4
+ * A step module exports `{ name, plan(ctx), execute(ctx) }`:
5
+ * - plan(ctx) → string describing what would happen (dry-run output)
6
+ * - execute(ctx) → { status: 'OK'|'SKIP'|'FAIL', detail: string }
7
+ */
8
+
9
+ export async function runStep(name, stepImpl, { dryRun, log, warn }) {
10
+ if (typeof stepImpl.plan !== 'function' || typeof stepImpl.execute !== 'function') {
11
+ throw new Error(`step ${name} missing plan() or execute()`);
12
+ }
13
+ if (dryRun) {
14
+ const planned = await stepImpl.plan({ log, warn });
15
+ log(`[${name}] DRY-RUN: ${planned}`);
16
+ return { name, status: 'DRY-RUN', detail: planned };
17
+ }
18
+ const result = await stepImpl.execute({ log, warn });
19
+ const status = result.status || 'OK';
20
+ const detail = result.detail || '';
21
+ log(`[${name}] ${status}: ${detail}`);
22
+ return { name, status, detail };
23
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Step 2 — Binary cache flush against PINNED_PG_VERSION.
3
+ * Re-downloads if version marker missing or mismatch. Idempotent.
4
+ */
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ export const name = 'binary-cache-flush';
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ function getAutopgRoot() {
14
+ return process.env.AUTOPG_CONFIG_DIR || process.env.PGSERVE_CONFIG_DIR || `${process.env.HOME}/.autopg`;
15
+ }
16
+
17
+ function getBinaryCacheDir() {
18
+ return path.join(getAutopgRoot(), 'bin', `${process.platform}-${process.arch}`);
19
+ }
20
+
21
+ function getPinnedVersion() {
22
+ try {
23
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', '..', 'package.json'), 'utf8'));
24
+ if (pkg.autopg && pkg.autopg.pinnedPgVersion) return pkg.autopg.pinnedPgVersion;
25
+ } catch { /* fall through */ }
26
+ return process.env.AUTOPG_PINNED_PG_VERSION || '18.3';
27
+ }
28
+
29
+ function readVersionMarker(cacheDir) {
30
+ try { return fs.readFileSync(path.join(cacheDir, '.version'), 'utf8').trim(); } catch { return null; }
31
+ }
32
+
33
+ export async function plan() {
34
+ const cacheDir = getBinaryCacheDir();
35
+ const pinned = getPinnedVersion();
36
+ const marker = readVersionMarker(cacheDir);
37
+ if (!fs.existsSync(path.join(cacheDir, 'bin', 'postgres'))) {
38
+ return `binary missing at ${cacheDir} — would trigger download for PG ${pinned}`;
39
+ }
40
+ if (marker !== pinned) return `version drift (cached=${marker || 'unknown'}, pinned=${pinned}) — would re-download`;
41
+ return `binary present and matches pinned ${pinned}, no action needed`;
42
+ }
43
+
44
+ export async function execute({ log, warn }) {
45
+ const cacheDir = getBinaryCacheDir();
46
+ const pinned = getPinnedVersion();
47
+ const marker = readVersionMarker(cacheDir);
48
+ const binaryExists = fs.existsSync(path.join(cacheDir, 'bin', 'postgres'));
49
+
50
+ if (binaryExists && marker === pinned) return { status: 'SKIP', detail: `binary OK (PG ${pinned})` };
51
+
52
+ let downloadFn;
53
+ try {
54
+ const postgres = await import('../../postgres.js');
55
+ downloadFn = postgres.ensureBinary || postgres.downloadBinary || postgres.installBinary;
56
+ } catch { /* postgres module not loadable here */ }
57
+
58
+ if (!downloadFn) {
59
+ warn(`binary needs refresh (pinned=${pinned}, cached=${marker || 'missing'}) but autopg postgres module not exposing download API`);
60
+ warn(`operator action: rerun \`bun install -g @automagik/autopg@latest\``);
61
+ return { status: 'FAIL', detail: 'binary refresh needs autopg npm reinstall' };
62
+ }
63
+ log(`re-downloading PG ${pinned} into ${cacheDir}`);
64
+ await downloadFn({ version: pinned, targetDir: cacheDir });
65
+ fs.writeFileSync(path.join(cacheDir, '.version'), pinned, { mode: 0o644 });
66
+ return { status: 'OK', detail: `binary refreshed to PG ${pinned}` };
67
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Step 5 — Consumer reconnect signal. Touches ~/.autopg/state/upgrade.signal
3
+ * with epoch + autopg version. Consumers (omni-api, genie-serve) opt-in via fs.watch.
4
+ */
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ export const name = 'consumer-signal';
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ function getAutopgRoot() {
14
+ return process.env.AUTOPG_CONFIG_DIR || process.env.PGSERVE_CONFIG_DIR || `${process.env.HOME}/.autopg`;
15
+ }
16
+
17
+ function getAutopgVersion() {
18
+ try {
19
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', '..', 'package.json'), 'utf8'));
20
+ return pkg.version || 'unknown';
21
+ } catch { return 'unknown'; }
22
+ }
23
+
24
+ export async function plan() {
25
+ return `would write upgrade signal at ${path.join(getAutopgRoot(), 'state', 'upgrade.signal')}`;
26
+ }
27
+
28
+ export async function execute() {
29
+ const signalDir = path.join(getAutopgRoot(), 'state');
30
+ fs.mkdirSync(signalDir, { recursive: true });
31
+ const payload = {
32
+ timestamp: new Date().toISOString(),
33
+ epoch_ms: Date.now(),
34
+ autopg_version: getAutopgVersion(),
35
+ canonical_port: 8432,
36
+ };
37
+ const signalPath = path.join(signalDir, 'upgrade.signal');
38
+ fs.writeFileSync(signalPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o644 });
39
+ return { status: 'OK', detail: `signal written at ${signalPath}` };
40
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Step 4 — App env file refresh. Regenerates ~/.autopg/<name>.env with
3
+ * canonical port. Verifies SCRAM cred works; warns if rotation needed.
4
+ */
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { execSync } from 'node:child_process';
9
+
10
+ export const name = 'env-refresh';
11
+ const CANONICAL_PORT = 8432;
12
+
13
+ function getAutopgRoot() {
14
+ return process.env.AUTOPG_CONFIG_DIR || process.env.PGSERVE_CONFIG_DIR || `${process.env.HOME}/.autopg`;
15
+ }
16
+
17
+ function listAppEnvFiles() {
18
+ const root = getAutopgRoot();
19
+ if (!fs.existsSync(root)) return [];
20
+ return fs.readdirSync(root)
21
+ .filter((f) => f.endsWith('.env') && !f.startsWith('.'))
22
+ .map((f) => path.join(root, f));
23
+ }
24
+
25
+ function parseEnv(content) {
26
+ const out = {};
27
+ for (const line of content.split('\n')) {
28
+ const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
29
+ if (m) out[m[1]] = m[2].replace(/^"(.*)"$/, '$1');
30
+ }
31
+ return out;
32
+ }
33
+
34
+ function buildUrl({ user, password, port, db }) {
35
+ const enc = (s) => encodeURIComponent(s);
36
+ return `postgresql://${enc(user)}:${enc(password)}@127.0.0.1:${port}/${db}`;
37
+ }
38
+
39
+ function tryConnect(url) {
40
+ try {
41
+ execSync(`psql ${JSON.stringify(url)} -At -c "SELECT 1"`, { stdio: 'pipe', env: process.env });
42
+ return true;
43
+ } catch { return false; }
44
+ }
45
+
46
+ export async function plan() {
47
+ const files = listAppEnvFiles();
48
+ if (files.length === 0) return 'no app .env files found in autopg root';
49
+ return `would verify+rewrite ${files.length} env file(s): ${files.map((f) => path.basename(f)).join(', ')}`;
50
+ }
51
+
52
+ export async function execute({ warn }) {
53
+ const files = listAppEnvFiles();
54
+ if (files.length === 0) return { status: 'SKIP', detail: 'no .env files' };
55
+
56
+ let updated = 0, valid = 0;
57
+ for (const file of files) {
58
+ try {
59
+ const content = fs.readFileSync(file, 'utf8');
60
+ const env = parseEnv(content);
61
+ const url = env.DATABASE_URL || env.PG_URL || env.POSTGRES_URL;
62
+ if (!url) {
63
+ warn(`[env-refresh] ${path.basename(file)}: no DATABASE_URL key`);
64
+ continue;
65
+ }
66
+ const parsed = new URL(url);
67
+ const newUrl = buildUrl({
68
+ user: decodeURIComponent(parsed.username),
69
+ password: decodeURIComponent(parsed.password),
70
+ port: CANONICAL_PORT,
71
+ db: parsed.pathname.replace(/^\//, ''),
72
+ });
73
+
74
+ if (tryConnect(newUrl)) {
75
+ valid++;
76
+ if (newUrl !== url) {
77
+ const newContent = content.replace(/^(DATABASE_URL|PG_URL|POSTGRES_URL)=.*/m, `$1=${newUrl}`);
78
+ fs.writeFileSync(file, newContent, { mode: 0o600 });
79
+ updated++;
80
+ }
81
+ } else {
82
+ warn(`[env-refresh] ${path.basename(file)}: SCRAM cred fails — rotate via \`autopg rotate <app>\``);
83
+ }
84
+ } catch (err) {
85
+ warn(`[env-refresh] ${path.basename(file)} failed: ${err.message}`);
86
+ }
87
+ }
88
+ return { status: 'OK', detail: `validated ${valid}/${files.length}, rewrote ${updated}` };
89
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Step 6 — Health validation. pg_isready + per-DB plpgsql smoke test.
3
+ */
4
+
5
+ import { execSync } from 'node:child_process';
6
+
7
+ export const name = 'health-validate';
8
+ const CANONICAL_PORT = 8432;
9
+ const SYSTEM_DBS = new Set(['template0', 'template1']);
10
+
11
+ function pgIsReady() {
12
+ try { execSync(`pg_isready -h 127.0.0.1 -p ${CANONICAL_PORT}`, { stdio: 'pipe' }); return true; }
13
+ catch { return false; }
14
+ }
15
+
16
+ function listAllDbs() {
17
+ const env = { ...process.env, PGPASSWORD: process.env.PGPASSWORD || 'postgres' };
18
+ const out = execSync(
19
+ `psql -h 127.0.0.1 -p ${CANONICAL_PORT} -U postgres -At -c "SELECT datname FROM pg_database WHERE NOT datistemplate"`,
20
+ { env, stdio: ['ignore', 'pipe', 'pipe'] },
21
+ ).toString().trim();
22
+ return out ? out.split('\n').filter(Boolean) : [];
23
+ }
24
+
25
+ function plpgsqlSmoke(db) {
26
+ try {
27
+ const env = { ...process.env, PGPASSWORD: process.env.PGPASSWORD || 'postgres' };
28
+ execSync(
29
+ `psql -h 127.0.0.1 -p ${CANONICAL_PORT} -U postgres -d ${db} -At -c "DO \\$\\$ BEGIN RAISE NOTICE 'ok'; END; \\$\\$"`,
30
+ { env, stdio: 'pipe' },
31
+ );
32
+ return true;
33
+ } catch { return false; }
34
+ }
35
+
36
+ export async function plan() {
37
+ return `would check pg_isready on :${CANONICAL_PORT} + plpgsql smoke test in each user DB`;
38
+ }
39
+
40
+ export async function execute({ warn }) {
41
+ if (!pgIsReady()) return { status: 'FAIL', detail: `pg_isready failed on port ${CANONICAL_PORT}` };
42
+ let dbs;
43
+ try { dbs = listAllDbs(); } catch (err) { return { status: 'FAIL', detail: `cannot list DBs: ${err.message}` }; }
44
+
45
+ let pass = 0, fail = 0;
46
+ for (const db of dbs) {
47
+ if (SYSTEM_DBS.has(db)) continue;
48
+ if (plpgsqlSmoke(db)) pass++;
49
+ else { fail++; warn(`[health-validate] plpgsql smoke FAIL in ${db}`); }
50
+ }
51
+ if (fail > 0) return { status: 'FAIL', detail: `${pass}/${pass + fail} DBs healthy; ${fail} failure(s)` };
52
+ return { status: 'OK', detail: `pg_isready OK, plpgsql healthy in ${pass}/${pass} DBs` };
53
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Step 3 — plpgsql extension re-resolve.
3
+ * DROP+CREATE plpgsql per user DB to refresh `.so` path against current $libdir.
4
+ * Skips DBs with user-owned plpgsql functions (CASCADE would drop them).
5
+ */
6
+
7
+ import { execSync } from 'node:child_process';
8
+
9
+ export const name = 'plpgsql-resolve';
10
+ const CANONICAL_PORT = 8432;
11
+ const SYSTEM_DBS = new Set(['postgres', 'template0', 'template1']);
12
+
13
+ function pgQuery({ db, sql, captureStdout = false }) {
14
+ const env = { ...process.env, PGPASSWORD: process.env.PGPASSWORD || 'postgres' };
15
+ const cmd = `psql -h 127.0.0.1 -p ${CANONICAL_PORT} -U postgres -d ${db} -At -c ${JSON.stringify(sql)}`;
16
+ return captureStdout
17
+ ? execSync(cmd, { env, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim()
18
+ : execSync(cmd, { env, stdio: 'pipe' });
19
+ }
20
+
21
+ function listUserDbs() {
22
+ const out = pgQuery({
23
+ db: 'postgres',
24
+ sql: "SELECT datname FROM pg_database WHERE NOT datistemplate AND datname != 'postgres' ORDER BY datname",
25
+ captureStdout: true,
26
+ });
27
+ return out ? out.split('\n').filter(Boolean) : [];
28
+ }
29
+
30
+ function hasUserOwnedPlpgsqlFunctions(db) {
31
+ const out = pgQuery({
32
+ db,
33
+ sql: "SELECT count(*) FROM pg_proc p JOIN pg_language l ON p.prolang = l.oid WHERE l.lanname = 'plpgsql' AND p.proowner != 10",
34
+ captureStdout: true,
35
+ });
36
+ return parseInt(out, 10) > 0;
37
+ }
38
+
39
+ export async function plan() {
40
+ let dbs;
41
+ try { dbs = listUserDbs(); } catch (err) { return `cannot enumerate DBs: ${err.message}`; }
42
+ return `would DROP+CREATE plpgsql in ${dbs.length} user DB(s): ${dbs.join(', ')}`;
43
+ }
44
+
45
+ export async function execute({ warn }) {
46
+ let dbs;
47
+ try { dbs = listUserDbs(); } catch (err) { return { status: 'FAIL', detail: `cannot enumerate DBs: ${err.message}` }; }
48
+ if (dbs.length === 0) return { status: 'SKIP', detail: 'no user DBs to refresh' };
49
+
50
+ let refreshed = 0, skipped = 0;
51
+ for (const db of dbs) {
52
+ if (SYSTEM_DBS.has(db)) { skipped++; continue; }
53
+ try {
54
+ if (hasUserOwnedPlpgsqlFunctions(db)) {
55
+ warn(`[plpgsql-resolve] skip ${db}: user-owned plpgsql functions present (DROP CASCADE would lose them)`);
56
+ skipped++; continue;
57
+ }
58
+ pgQuery({ db, sql: 'DROP EXTENSION IF EXISTS plpgsql CASCADE; CREATE EXTENSION plpgsql' });
59
+ refreshed++;
60
+ } catch (err) {
61
+ warn(`[plpgsql-resolve] ${db} failed: ${err.message}`);
62
+ skipped++;
63
+ }
64
+ }
65
+ return { status: 'OK', detail: `refreshed ${refreshed} DB(s), skipped ${skipped}` };
66
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Step 1 — Port reconciliation. Ensures pgserve listens on canonical 8432.
3
+ * If running on a different port, stop and relaunch on 8432. Idempotent.
4
+ */
5
+
6
+ import { execSync } from 'node:child_process';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ export const name = 'port-reconcile';
11
+ const CANONICAL_PORT = 8432;
12
+
13
+ function readPostmasterPid(dataDir) {
14
+ try {
15
+ const content = fs.readFileSync(path.join(dataDir, 'postmaster.pid'), 'utf8');
16
+ const lines = content.trim().split('\n');
17
+ return { pid: parseInt(lines[0], 10), port: parseInt(lines[3], 10) };
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function getDataDir() {
24
+ return process.env.PGSERVE_DATA || `${process.env.HOME}/.pgserve/data`;
25
+ }
26
+
27
+ export async function plan() {
28
+ const info = readPostmasterPid(getDataDir());
29
+ if (!info) return 'no running pgserve detected — nothing to reconcile';
30
+ if (info.port === CANONICAL_PORT) return `already on port ${CANONICAL_PORT}, no action needed`;
31
+ return `would stop pgserve PID ${info.pid} (port ${info.port}) and relaunch on ${CANONICAL_PORT}`;
32
+ }
33
+
34
+ export async function execute({ log, warn }) {
35
+ const info = readPostmasterPid(getDataDir());
36
+ if (!info) return { status: 'SKIP', detail: 'no running pgserve' };
37
+ if (info.port === CANONICAL_PORT) return { status: 'SKIP', detail: `already on ${CANONICAL_PORT}` };
38
+
39
+ log(`stopping pgserve PID ${info.pid} (port ${info.port})`);
40
+ try {
41
+ execSync(`pm2 restart pgserve --update-env -- --port ${CANONICAL_PORT}`, { stdio: 'pipe' });
42
+ return { status: 'OK', detail: `pm2 restart pgserve on port ${CANONICAL_PORT}` };
43
+ } catch (pm2Err) {
44
+ warn(`pm2 restart failed (${pm2Err.message}) — falling back to direct pg_ctl`);
45
+ try {
46
+ execSync(`pg_ctl -D ${getDataDir()} -m fast stop`, { stdio: 'pipe' });
47
+ return { status: 'OK', detail: `stopped pgserve; relaunch via pm2 or autopg install` };
48
+ } catch (ctlErr) {
49
+ throw new Error(`port reconcile failed: pm2 (${pm2Err.message}) and pg_ctl (${ctlErr.message})`);
50
+ }
51
+ }
52
+ }