pgserve 2.0.7 → 2.1.0

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.
@@ -0,0 +1,290 @@
1
+ # Wish: Canonical pgserve + PM2 supervision across genie/omni/pgserve
2
+
3
+ | Field | Value |
4
+ |-------|-------|
5
+ | **Status** | DRAFT |
6
+ | **Slug** | `canonical-pgserve-pm2-supervision` |
7
+ | **Date** | 2026-04-30 |
8
+ | **Author** | genie-configure |
9
+ | **Appetite** | medium-large |
10
+ | **Repos touched** | `namastexlabs/pgserve`, `automagik-dev/omni`, `automagik-dev/genie`, `namastexlabs/genie-configure` (brain only) |
11
+ | **Design** | _No brainstorm — direct wish from operational pain (live debugging session 2026-04-30)_ |
12
+
13
+ ## Summary
14
+
15
+ Canonicalize **pgserve as the single, central, pm2-supervised database server** that every service in the stack connects to. Make `genie serve` and `omni-api`/`omni-nats` peer-equal pm2 services that boot under the same hardening, register via their own `*-install` commands, and consume pgserve through its CLI.
16
+
17
+ **End-state pm2 list:**
18
+
19
+ ```
20
+ ┌──────────────────────────────────────────┐
21
+ │ pm2 supervisor │
22
+ ├──────────────────────────────────────────┤
23
+ │ 1. pgserve ← NEW (canonical PG) │
24
+ │ 2. omni-api ← existing, reconfig │
25
+ │ 3. omni-nats ← existing │
26
+ │ 4. genie-serve ← NEW │
27
+ │ │
28
+ │ + pm2-logrotate (module, already there) │
29
+ └──────────────────────────────────────────┘
30
+ ```
31
+
32
+ ## Trigger
33
+
34
+ Live debugging session, 2026-04-30:
35
+
36
+ 1. WhatsApp DM lands at omni-api ✅
37
+ 2. omni dispatches to NATS ✅
38
+ 3. **bridge silently dropped — `genie serve` was running in a foreground bash on `/dev/pts/24` and died when the shell closed** ❌
39
+ 4. Operator (Felipe) sent multiple test messages; nothing came back. Recovery required SSH into the server, kill the orphan, re-launch `genie serve` manually.
40
+
41
+ Earlier in the same session: omni-api was hardened with pm2 + log rotation as part of `omni-lifecycle-hardening` (archived wish). Genie was supposed to follow but never did. The asymmetry is the root cause of every "the bridge is gone again" incident.
42
+
43
+ Same session also revealed: **multiple pgserve instances running in parallel** (3 distinct postgres-server.js processes, each on a different port). Every service that wants Postgres spins its own embedded pgserve. No single source of truth for connection strings; data dirs scattered across `~/.omni/data/pgserve/`, `~/.genie/data/pgserve/`, and `/dev/shm/pgserve-*`.
44
+
45
+ ## Scope
46
+
47
+ ### IN
48
+
49
+ 1. **pgserve gets `install` + `serve` commands.** New subcommands in the pgserve CLI:
50
+ - `pgserve install` — idempotent pm2 registration with hardened defaults (mirror omni's `PM2_HARDENED_DEFAULTS`); creates `~/.pgserve/config.json` with canonical port + data dir.
51
+ - `pgserve serve` — long-lived process pm2 invokes (currently `bin/pgserve-wrapper.cjs daemon`, just renamed for clarity).
52
+ - `pgserve status` / `pgserve url` / `pgserve port` — discovery API for downstream installers.
53
+ - `pgserve uninstall` — `pm2 delete pgserve` + leave data dir intact.
54
+
55
+ 2. **Hardened pm2 defaults shared.** Extract `PM2_HARDENED_DEFAULTS` and `buildPm2StartArgs` from `omni/packages/cli/src/pm2.ts` into a small shared shape every installer copies. Constants stay duplicated across repos (avoids a new shared package), but the values are pinned in this wish:
56
+ ```
57
+ maxRestarts: 10
58
+ restartDelayMs: 5000
59
+ maxMemoryRestart: 2G (api/serve), 1G (nats)
60
+ killTimeoutMs: 20000
61
+ logDateFormat: YYYY-MM-DD HH:mm:ss.SSS
62
+ logs: ~/.<service>/logs/<name>-{out,error}.log
63
+ ```
64
+
65
+ 3. **`genie install` (NEW).** Mirror of `omni install`:
66
+ - Calls `pgserve install` first (no-op when already registered).
67
+ - Reads `pgserve url` to get the canonical connection string.
68
+ - Registers `genie-serve` under pm2 with hardened defaults.
69
+ - Writes `~/.genie/config.json` with `databaseUrl: <pgserve url>`.
70
+ - Idempotent; safe to re-run.
71
+ - Adds `--non-interactive` for CI/install.sh.
72
+
73
+ 4. **`omni install` reconfigured.** Stops embedding pgserve inside `omni-api`'s lifecycle:
74
+ - Calls `pgserve install` first.
75
+ - Migration: pg_dump from current `~/.omni/data/pgserve/` → restore into canonical pgserve. Stop and pm2-delete the embedded pgserve.
76
+ - Update `omni-api`'s `DATABASE_URL` env to point at canonical pgserve.
77
+ - Existing `omni doctor` already audits this; extend it to check connection-string-points-at-canonical-pgserve.
78
+
79
+ 5. **`install.sh` updates.** Both repos' bootstrap scripts route through the new pattern:
80
+ - `omni/install.sh`: install pgserve@latest globally → `pgserve install` → `omni install`.
81
+ - `genie/install.sh`: install pgserve@latest globally → `pgserve install` → `genie install`.
82
+
83
+ 6. **Brain documentation.** Add to genie-configure's brain:
84
+ - `Configuration & Routing/canonical-pgserve-pm2.md` — architecture map: 4 pm2 services, pgserve as central PG, install ordering.
85
+ - `Runbooks/recover-pm2-stack.md` — how to diagnose / restart any of the 4 services; `pm2 resurrect` after reboot.
86
+ - `_decisions/2026-04-30-canonical-pgserve.md` — ADR documenting why one pgserve instead of N embedded.
87
+
88
+ ### OUT
89
+
90
+ - **No replacement of pgserve with vanilla postgres.** pgserve stays; we only canonicalize how it's deployed.
91
+ - **No port migration tooling for third-party consumers.** If someone else's app talks to omni's old pgserve port directly, they update on their own.
92
+ - **No automatic uninstall of legacy embedded pgserve data dirs.** Migration copies forward; the old data stays on disk until operator removes it (avoids accidental data loss).
93
+ - **No multi-host pgserve cluster.** Single host only. Multi-host pgserve is a separate, much larger wish.
94
+ - **No systemd / launchd path.** pm2 is the single supervisor for this iteration. Aegis-runtime wish covers a future systemd-user variant.
95
+
96
+ ## Decisions
97
+
98
+ | # | Decision | Rationale |
99
+ |---|---|---|
100
+ | 1 | pgserve owns the install + serve subcommands | Other services should NOT know how to register pgserve under pm2 — that's pgserve's responsibility. Same pattern as omni owning omni-api/nats. |
101
+ | 2 | Idempotent `*-install` everywhere | Every installer can be re-run without harm. Re-running `pgserve install` after it's already registered exits 0 with "already installed." Same for `omni install` and `genie install`. |
102
+ | 3 | Cross-repo install dependency: pgserve → omni & genie | omni and genie shell out to `pgserve install` first. They DON'T re-implement pgserve registration. Tighter coupling, but simpler than a shared package, and avoids "two installers disagree on hardening defaults." |
103
+ | 4 | `--interpreter none` for pm2 launches | Both genie and omni binaries use `#!/usr/bin/env bun` shebangs. `--interpreter bun` triggers pm2's ESM/require crash on top-level await. Shebang resolution side-steps the issue. **Empirically validated 2026-04-30** during the manual genie-serve pm2 registration. |
104
+ | 5 | `genie serve start --headless --no-tui --no-interactive` for pm2 | TUI requires a real terminal; pm2 child has no tty. Headless + no-tui matches omni-api's mode. **Empirically validated 2026-04-30.** |
105
+ | 6 | Migration via pg_dump + restore (not file-level copy) | Data file format is sensitive to PG version; pg_dump is portable. Even with same pgserve version, dump+restore is the safe path. |
106
+ | 7 | Single config file per service, no shared "canonical config" file | `pgserve install` writes `~/.pgserve/config.json`; consumers read it via `pgserve url`. We don't introduce a `~/.canonical/` directory or similar. The CLI is the contract. |
107
+ | 8 | pm2-logrotate stays as a module, not a pm2 service | It's a pm2 module by design; `omni install` already configures it. `pgserve install` reuses the same pm2-logrotate (no duplicate setup). |
108
+
109
+ ## Success Criteria
110
+
111
+ - [ ] `pgserve install` registers `pgserve` as a pm2 service with hardened defaults; idempotent on second invocation.
112
+ - [ ] `pgserve url` returns a valid connection string that other tools can use without pgserve being CLI-imported.
113
+ - [ ] `omni install` on a clean machine results in: `pgserve` + `omni-api` + `omni-nats` all under pm2 with green status.
114
+ - [ ] `genie install` on a clean machine results in: `pgserve` + `genie-serve` all under pm2 with green status.
115
+ - [ ] On a machine where both omni and genie are installed, exactly **4 pm2 services** are present (pgserve, omni-api, omni-nats, genie-serve), pgserve is shared, and both `omni doctor` and `genie doctor` are green.
116
+ - [ ] On reboot, `pm2 resurrect` brings all 4 services back online with correct env.
117
+ - [ ] Existing omni installs migrate without data loss: pre-migration `omni events list` content matches post-migration content.
118
+ - [ ] `genie serve` running under pm2 survives shell closure (the bug that triggered this wish stays fixed forever).
119
+ - [ ] `omni doctor` and `genie doctor` both gain a check: "process is registered under pm2 with hardened defaults" (yes/no with one-line remediation if no).
120
+ - [ ] Brain entries (architecture map, runbook, ADR) merged in genie-configure.
121
+
122
+ ## Execution Strategy
123
+
124
+ Wave-based; each wave can ship independently. Three repos, four PRs total.
125
+
126
+ ### Wave 1 — `pgserve` foundation (BLOCKS waves 2 & 3)
127
+
128
+ **Goal:** pgserve owns its pm2 lifecycle.
129
+
130
+ - Group 1.1 — `pgserve install` + `pgserve serve` + `pgserve status` + `pgserve url` + `pgserve port`. Add `--non-interactive` for CI/install.sh. New file: `src/commands/install.ts` (mirror omni's structure).
131
+ - Group 1.2 — Tests: install idempotency, status reflects pm2 state, url/port match what install registered.
132
+ - Group 1.3 — README: document the 4 new subcommands.
133
+
134
+ **Validation:**
135
+ ```bash
136
+ bunx pgserve install # green; pm2 list shows `pgserve`
137
+ bunx pgserve install # exits 0, "already installed"
138
+ bunx pgserve url # postgres://localhost:8432/postgres
139
+ bunx pgserve status --json # { name: "pgserve", status: "online", port: 8432, dataDir: "..." }
140
+ pm2 list | grep pgserve # online, max-restarts=10, etc.
141
+ ```
142
+
143
+ **PR:** `namastexlabs/pgserve#???` — `feat(cli): pgserve install + pm2 supervision`.
144
+
145
+ ### Wave 2 — `genie install` (depends on Wave 1)
146
+
147
+ **Goal:** Genie has parity with omni — `genie install` registers `genie-serve` under pm2 by calling `pgserve install` first.
148
+
149
+ - Group 2.1 — New `genie install` command in `src/genie-commands/install.ts`. Calls `pgserve install`, then `pm2 start` for genie-serve with the hardened args validated in this server's manual test (`--interpreter none` + `serve start --headless --no-tui --no-interactive`).
150
+ - Group 2.2 — Update `genie serve start` to detect when genie-serve is already pm2-supervised: print "Already managed by pm2; use `pm2 restart genie-serve`" and exit. Avoid the multi-instance lockfile dance.
151
+ - Group 2.3 — `genie doctor` adds `pm2-supervision` check.
152
+ - Group 2.4 — Tests for install command (with PM2 stubbed).
153
+ - Group 2.5 — `install.sh` updated to call `pgserve install` then `genie install`.
154
+
155
+ **Validation:**
156
+ ```bash
157
+ genie install # green
158
+ pm2 list # includes pgserve + genie-serve
159
+ genie doctor # all green
160
+ genie serve stop && genie install # idempotent
161
+ # kill the shell that ran install — bridge stays alive (the original incident's reproduction)
162
+ ```
163
+
164
+ **PR:** `automagik-dev/genie#???` — `feat(cli): genie install + pm2 supervision`.
165
+
166
+ ### Wave 3 — `omni install` reconfig (depends on Wave 1)
167
+
168
+ **Goal:** Omni's installer routes through canonical pgserve instead of the embedded one.
169
+
170
+ - Group 3.1 — `omni install` calls `pgserve install` before `omni-api` registration.
171
+ - Group 3.2 — Migration handler: detect existing `~/.omni/data/pgserve/` running under omni-api → pg_dump → restore into canonical pgserve → update omni-api `DATABASE_URL` env → delete embedded pgserve from pm2 → preserve old data dir on disk (operator can `rm -rf` later when satisfied).
172
+ - Group 3.3 — `omni doctor` adds `connection-string-canonical` check.
173
+ - Group 3.4 — Tests for migration path (start with embedded, run install, verify omni-api connects to canonical).
174
+ - Group 3.5 — `install.sh` updated to call `pgserve install` first.
175
+
176
+ **Validation:**
177
+ ```bash
178
+ # Fresh machine
179
+ omni install
180
+ pm2 list # pgserve + omni-api + omni-nats
181
+ omni doctor # all green; connection-string-canonical=ok
182
+
183
+ # Existing machine (with embedded pgserve)
184
+ omni install # detects legacy, runs migration
185
+ omni events list --limit 100 # data preserved post-migration
186
+ pm2 list # pgserve + omni-api + omni-nats (no embedded pgserve)
187
+ ```
188
+
189
+ **PR:** `automagik-dev/omni#???` — `feat(install): canonical pgserve + migration from embedded`.
190
+
191
+ ### Wave 4 — Brain ingestion (depends on Waves 1–3 merging)
192
+
193
+ **Goal:** Document the canonical layout so future agents inheriting any of these servers know the pattern by reading a single file.
194
+
195
+ - Group 4.1 — `brain/Configuration & Routing/canonical-pgserve-pm2.md`: architecture map; 4-service ascii diagram; pgserve discovery via `pgserve url`; install ordering.
196
+ - Group 4.2 — `brain/Runbooks/recover-pm2-stack.md`: diagnose/restart any of the 4 services; `pm2 resurrect` after reboot; rollback to embedded pgserve (if migration goes wrong).
197
+ - Group 4.3 — `brain/_decisions/2026-04-30-canonical-pgserve.md`: ADR; alternatives considered (vanilla postgres, systemd-user, embedded-everywhere); consequences.
198
+
199
+ **PR:** `namastexlabs/genie-configure#???` — `chore(brain): canonical pgserve + pm2 supervision`.
200
+
201
+ ## Dependencies
202
+
203
+ ```
204
+ Wave 1 (pgserve) ──┬──→ Wave 2 (genie)
205
+ ├──→ Wave 3 (omni)
206
+ └──→ Wave 4 (brain — also depends on Wave 2 & 3)
207
+ ```
208
+
209
+ Cross-wish: closes the operator-lockout footgun the canonical-genie-omni-wiring + omni-host-fingerprint-trust wishes paved over with workarounds. Doesn't conflict with `aegis-runtime` (separate daemon, separate supervisor).
210
+
211
+ ## QA Criteria
212
+
213
+ - [ ] On a fresh Ubuntu 24 box: `curl … omni/install.sh | bash` results in 3 pm2 services (pgserve + omni-api + omni-nats), green doctor.
214
+ - [ ] On the same box: `curl … genie/install.sh | bash` adds genie-serve = 4 pm2 services. pgserve shared.
215
+ - [ ] Reboot the box: `pm2 resurrect` brings all 4 back; both doctors green; bridge subscribes to NATS without manual intervention.
216
+ - [ ] Kill any one of the 4 services with SIGKILL: pm2 restarts it within 5 s; doctor goes red briefly then green.
217
+ - [ ] On a machine with the OLD embedded pgserve setup: `omni install` (post-Wave-3) migrates without data loss.
218
+ - [ ] `pgserve install` followed by `pgserve install --rotate-port 8433` correctly re-registers pgserve on the new port and updates omni-api/genie-serve env (or refuses cleanly if they're using the old port).
219
+ - [ ] `omni-host-fingerprint-trust` pipeline (the wish that closed two days before this one) keeps working — instances flagged `requireGenieSignature: true` still get gated correctly post-migration.
220
+
221
+ ## Assumptions / Risks
222
+
223
+ | # | Item | Risk | Mitigation |
224
+ |---|---|---|---|
225
+ | 1 | pgserve repo accepts the new install/serve subcommands | Low — author is in the same org | If rejected, fall back to having omni and genie register pgserve directly (loses the "owned by pgserve" property but still gets us to 4 services). |
226
+ | 2 | Migration from embedded pgserve preserves all data | Medium — pg_dump on a live system + connection-string switch is non-trivial | Stage in Wave 3 with `--dry-run` first; document rollback. Take filesystem snapshot before running on production. |
227
+ | 3 | pm2 ESM/await crash with bun on future bun versions | Low | `--interpreter none` is robust; documented Decision 4. |
228
+ | 4 | Operators who customized their existing pgserve port will be confused | Medium | `omni doctor` and `genie doctor` add explicit "this service points at non-canonical pgserve" check with override flag. |
229
+ | 5 | NATS port also needs canonicalization (similar split-brain risk) | Out of scope for this wish | Park as a follow-up wish if it becomes a problem. omni-nats is single-instance today via pm2 so no urgency. |
230
+ | 6 | genie-configure (this brain) is not in the cycle | None | Wave 4 lands the docs in this repo only; no source code changes here. |
231
+
232
+ ## Files to Create / Modify
233
+
234
+ ### `namastexlabs/pgserve` (Wave 1)
235
+ - `src/commands/install.ts` (new)
236
+ - `src/commands/serve.ts` (new — likely a thin wrapper around the existing wrapper)
237
+ - `src/commands/status.ts`, `src/commands/url.ts`, `src/commands/port.ts` (new)
238
+ - `src/lib/pm2-args.ts` (new — shared pm2 launch builder, mirror of omni's)
239
+ - `bin/pgserve-wrapper.cjs` (modify — add subcommand routing)
240
+ - `__tests__/install.test.ts`, `__tests__/url.test.ts` (new)
241
+ - `README.md` (modify)
242
+
243
+ ### `automagik-dev/genie` (Wave 2)
244
+ - `src/genie-commands/install.ts` (new)
245
+ - `src/genie-commands/doctor.ts` (modify — add pm2-supervision check)
246
+ - `src/term-commands/serve.ts` (modify — detect pm2 supervision, defer)
247
+ - `install.sh` (modify — route through `pgserve install` + `genie install`)
248
+ - `src/lib/pm2-args.ts` (new — copy from this wish's spec)
249
+ - Tests for install + doctor changes.
250
+
251
+ ### `automagik-dev/omni` (Wave 3)
252
+ - `packages/cli/src/commands/install.ts` (modify — call `pgserve install` first; remove embedded pgserve registration)
253
+ - `packages/cli/src/lib/migrate-from-embedded-pgserve.ts` (new)
254
+ - `packages/cli/src/commands/doctor.ts` (modify — add canonical-connection-string check)
255
+ - `install.sh` (modify — `pgserve install` step)
256
+ - Tests for migration path.
257
+
258
+ ### `namastexlabs/genie-configure` (Wave 4)
259
+ - `brain/Configuration & Routing/canonical-pgserve-pm2.md` (new)
260
+ - `brain/Runbooks/recover-pm2-stack.md` (new)
261
+ - `brain/_decisions/2026-04-30-canonical-pgserve.md` (new)
262
+
263
+ ## Validated Beachhead (already shipped manually)
264
+
265
+ The genie-serve part is **already running under pm2** on this server as of 2026-04-30 16:08 UTC. Manual command used:
266
+
267
+ ```bash
268
+ pm2 start /home/genie/.bun/bin/genie \
269
+ --name genie-serve \
270
+ --interpreter none \
271
+ --max-restarts 10 \
272
+ --restart-delay 5000 \
273
+ --max-memory-restart 2G \
274
+ --kill-timeout 20000 \
275
+ --log-date-format 'YYYY-MM-DD HH:mm:ss.SSS' \
276
+ --output ~/.genie/logs/genie-serve-out.log \
277
+ --error ~/.genie/logs/genie-serve-error.log \
278
+ -- serve start --headless --no-tui --no-interactive
279
+
280
+ pm2 save
281
+ ```
282
+
283
+ Wave 2 codifies this exact invocation as `genie install`. The args are pinned in Decisions 4 & 5.
284
+
285
+ ## See also
286
+
287
+ - `omni-lifecycle-hardening` (archived) — established the omni-api pm2 hardening pattern this wish extends.
288
+ - `aegis-runtime` (draft) — different daemon, different supervisor (launchd/systemd-user), no conflict.
289
+ - `invincible-genie` (draft) — orthogonal: that wish is about `genie serve` self-healing; this wish is about `genie serve` being supervised in the first place. Both can ship independently.
290
+ - `pgserve-proxy-resilience` — sets up pgserve to exit cleanly when its child dies (so a supervisor can restart it). This wish is the supervisor side of that contract.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to `pgserve` are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres
5
5
  to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 2.0.8
8
+
9
+ ### Changed
10
+
11
+ - Bumped embedded postgres binaries from `18.2.0-beta.16` to
12
+ `18.3.0-beta.17` for all four platforms (linux-x64, darwin-arm64,
13
+ darwin-x64, windows-x64). Picks up upstream PostgreSQL 18.3 fixes
14
+ and the matching `@embedded-postgres` package revision.
15
+ - The hardcoded `pkgVersion` in `src/postgres.js` (used when binaries
16
+ are not yet cached and pgserve fetches them from npm) was updated
17
+ in lockstep with `package.json`.
18
+
7
19
  ## 2.0.7
8
20
 
9
21
  ### Fixed
package/README.md CHANGED
@@ -200,9 +200,45 @@ psql -h "${XDG_RUNTIME_DIR:-/tmp}/pgserve" -d myapp
200
200
  psql "postgresql:///myapp?host=${XDG_RUNTIME_DIR:-/tmp}/pgserve"
201
201
  ```
202
202
 
203
- ### Supervised by PM2
203
+ ### Supervised by PM2 — `pgserve install` (recommended)
204
204
 
205
- `ecosystem.config.cjs` snippet:
205
+ `pgserve install` registers pgserve as a hardened pm2 process in one
206
+ command. Idempotent: re-running it is a no-op when already installed.
207
+
208
+ ```bash
209
+ pgserve install # one-shot register + start under pm2
210
+ pgserve install --port 8442 # custom port
211
+ pgserve install --data /data/pg # custom data dir
212
+
213
+ pgserve url # postgres://localhost:8432/postgres
214
+ pgserve port # 8432
215
+ pgserve status # pm2 + on-disk config snapshot
216
+ pgserve uninstall # remove from pm2; keep data dir
217
+ ```
218
+
219
+ **Hardened defaults** (tuned for production-grade Postgres workloads,
220
+ not toy-machine values):
221
+
222
+ | Flag | Default | Why |
223
+ |------|---------|-----|
224
+ | `--max-memory-restart` | `4G` | Postgres realistic working set: shared_buffers + autovacuum + connection backends. 1G OOM-kills under modest load. Override with `PGSERVE_MAX_MEMORY=8G pgserve install`. |
225
+ | `--max-restarts` | `50` | Tolerates extended outages (NATS reconnect storms, host pressure). Combined with `--min-uptime`, only RAPID failures count. |
226
+ | `--min-uptime` | `10000` ms | Restart counts against the cap only when the process crashed within 10s of starting. Healthy long-uptime crashes don't burn the budget. |
227
+ | `--restart-delay` | `4000` ms | Initial gap between restarts. |
228
+ | `--exp-backoff-restart-delay` | `100` → ~60000 ms | Exponential spread on repeated failures so we don't hammer pm2 + the host on persistent issues. |
229
+ | `--kill-timeout` | `60000` ms | Postgres needs time to flush WAL on graceful shutdown; 60s headroom. |
230
+ | `--log-date-format` | `YYYY-MM-DD HH:mm:ss.SSS` | Operator-friendly timestamps in pm2 logs. |
231
+ | `--output` / `--error` | `~/.pgserve/logs/pgserve-{out,error}.log` | Rotates via pm2-logrotate (install separately). |
232
+
233
+ Config: `~/.pgserve/config.json` (override the directory with
234
+ `PGSERVE_CONFIG_DIR`). Memory ceiling: env-tunable via
235
+ `PGSERVE_MAX_MEMORY` at install time.
236
+
237
+ Downstream services that need a Postgres connection can shell out to
238
+ `pgserve install` (no-op if already running) and read the canonical URL
239
+ from `pgserve url` instead of spinning up their own embedded pgserve.
240
+
241
+ #### Manual ecosystem.config.cjs (legacy)
206
242
 
207
243
  ```javascript
208
244
  module.exports = {
@@ -14,6 +14,37 @@ const { spawn, execSync } = require('child_process');
14
14
  const path = require('path');
15
15
  const fs = require('fs');
16
16
 
17
+ // ────────────────────────────────────────────────────────────────────────
18
+ // canonical-pgserve-pm2-supervision wish (PR #55, issue #56)
19
+ //
20
+ // `pgserve install / uninstall / status / url / port` are pure node + pm2
21
+ // wrappers — they don't need bun at all. Route them BEFORE the bun
22
+ // resolution + health probe so install works on a machine where bun
23
+ // hasn't self-healed yet (the chicken-and-egg case the probe was designed
24
+ // to detect — operators should be able to set up a fresh server even when
25
+ // the bun-postinstall failed).
26
+ //
27
+ // `pgserve serve` is an alias for the existing `pgserve daemon` (the
28
+ // long-lived process pm2 invokes); we rewrite argv so postgres-server.js
29
+ // sees the original `daemon` token.
30
+ // ────────────────────────────────────────────────────────────────────────
31
+ const __subcommand = process.argv[2];
32
+ const __installSubcommands = new Set(['install', 'uninstall', 'status', 'url', 'port']);
33
+ if (__subcommand && __installSubcommands.has(__subcommand)) {
34
+ const cli = require(path.join(__dirname, '..', 'src', 'cli-install.cjs'));
35
+ process.exit(
36
+ cli.dispatch(__subcommand, process.argv.slice(3), {
37
+ scriptPath: path.join(__dirname, 'postgres-server.js'),
38
+ }),
39
+ );
40
+ }
41
+ if (__subcommand === 'serve') {
42
+ // Alias `serve` → `daemon` so the wish's canonical command name maps
43
+ // cleanly to the existing long-lived process. Replacing argv preserves
44
+ // any flags the operator (or pm2) passed after `serve`.
45
+ process.argv[2] = 'daemon';
46
+ }
47
+
17
48
  // Detect platform
18
49
  const isWindows = process.platform === 'win32';
19
50
  const bunBin = isWindows ? 'bun.exe' : 'bun';
@@ -53,6 +84,8 @@ if (!bunPath) {
53
84
  process.exit(1);
54
85
  }
55
86
 
87
+ const scriptPath = path.join(__dirname, 'postgres-server.js');
88
+
56
89
  // Pre-flight health check: verify bun can actually execute.
57
90
  //
58
91
  // When pgserve is installed via `bun install` (as a global or transitive dep),
@@ -67,8 +100,6 @@ if (!bunPath) {
67
100
  // If self-heal also fails, surface the real error instead of hanging later.
68
101
  ensureBunHealthy(bunPath);
69
102
 
70
- const scriptPath = path.join(__dirname, 'postgres-server.js');
71
-
72
103
  /**
73
104
  * Verify the selected bun binary can execute. If it fails with the known
74
105
  * "postinstall script was not run" signature, attempt a one-shot repair via
@@ -310,7 +310,14 @@ pgserve - Embedded PostgreSQL Server
310
310
  True concurrent connections, zero config, auto-provision databases.
311
311
 
312
312
  USAGE:
313
- pgserve [options]
313
+ pgserve [options] # foreground server
314
+ pgserve install [--port N] # register under pm2 (idempotent)
315
+ pgserve serve # alias for "pgserve daemon"
316
+ pgserve status [--json] # report pm2 + config state
317
+ pgserve url # print canonical postgres:// URL
318
+ pgserve port # print canonical port
319
+ pgserve uninstall # remove from pm2 (keep data)
320
+ pgserve daemon [stop] # singleton daemon (Unix socket)
314
321
 
315
322
  OPTIONS:
316
323
  --port <number> PostgreSQL port (default: 8432)
package/bun.lock CHANGED
@@ -16,21 +16,21 @@
16
16
  "pg": "^8.16.3",
17
17
  },
18
18
  "optionalDependencies": {
19
- "@embedded-postgres/darwin-arm64": "18.2.0-beta.16",
20
- "@embedded-postgres/darwin-x64": "18.2.0-beta.16",
21
- "@embedded-postgres/linux-x64": "18.2.0-beta.16",
22
- "@embedded-postgres/windows-x64": "18.2.0-beta.16",
19
+ "@embedded-postgres/darwin-arm64": "18.3.0-beta.17",
20
+ "@embedded-postgres/darwin-x64": "18.3.0-beta.17",
21
+ "@embedded-postgres/linux-x64": "18.3.0-beta.17",
22
+ "@embedded-postgres/windows-x64": "18.3.0-beta.17",
23
23
  },
24
24
  },
25
25
  },
26
26
  "packages": {
27
- "@embedded-postgres/darwin-arm64": ["@embedded-postgres/darwin-arm64@18.2.0-beta.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wnswaF+uDvGeitqajJ8v8xOG4ttFrzixElwKNe2MIxBXSLWPV3xhi6tBY0Sjw8Lmiu6UG9vNLFZSjHPrIeokBg=="],
27
+ "@embedded-postgres/darwin-arm64": ["@embedded-postgres/darwin-arm64@18.3.0-beta.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Pvrej3Xz5flfyVc9mchVfekrKoTJyvPtM3U0vjuXamZkRKmi+inP2zRmnmzYecIVbr7Zhu82xbsCENMXrwMp9Q=="],
28
28
 
29
- "@embedded-postgres/darwin-x64": ["@embedded-postgres/darwin-x64@18.2.0-beta.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-u9WtTPxRuO0uOny5IniXHSDaLmtOujwzDoExIV/jFT0Fu8SzpX7wdoPbsSPBLgyQWdr/nPA77K9QI4r6P1/fKA=="],
29
+ "@embedded-postgres/darwin-x64": ["@embedded-postgres/darwin-x64@18.3.0-beta.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-MVWe+C47pPoMD9LlIWGQkvZ5Xsu3IBo54CYqnIps/Z1byMIUBNc7y/dZ3mfqEwiCbVDVqirG0CU462xnrSEfKA=="],
30
30
 
31
- "@embedded-postgres/linux-x64": ["@embedded-postgres/linux-x64@18.2.0-beta.16", "", { "os": "linux", "cpu": "x64" }, "sha512-BIt485ioL8/AwDgw37IcdraOfRgHNDOtGM6Hh63vnNaUAG4Z0qtJd5zXS5fr2wZTEsYHyC5PC60k7zkCRZXSzg=="],
31
+ "@embedded-postgres/linux-x64": ["@embedded-postgres/linux-x64@18.3.0-beta.17", "", { "os": "linux", "cpu": "x64" }, "sha512-8orSD6NNopSLtjqir4dWQBrj+g8j1eJjWd9mB60A3xbWMzIBIPQpzT7XzbacW9YFSl/DejOLnRXfff+Wr13Tgw=="],
32
32
 
33
- "@embedded-postgres/windows-x64": ["@embedded-postgres/windows-x64@18.2.0-beta.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Sj6GhCZrvtMwchATEtWuEmexEBWpRNMHPTUHsqPuyDrHX/XgKfpIxz2/AMHa4sp7SZ0JOHGouH8AFIVsWQrQsQ=="],
33
+ "@embedded-postgres/windows-x64": ["@embedded-postgres/windows-x64@18.3.0-beta.17", "", { "os": "win32", "cpu": "x64" }, "sha512-kDC5aBsmhWDjeQjj2V4g+Bk+pMeDU27b7l0rBbaKgtt2gsNmCB34ULg/5cqs2kqUKSk/tiGMHKCNE+zQZ+s4rg=="],
34
34
 
35
35
  "@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
36
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgserve",
3
- "version": "2.0.7",
3
+ "version": "2.1.0",
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",
@@ -42,10 +42,10 @@
42
42
  },
43
43
  "homepage": "https://github.com/namastexlabs/pgserve#readme",
44
44
  "optionalDependencies": {
45
- "@embedded-postgres/darwin-arm64": "18.2.0-beta.16",
46
- "@embedded-postgres/darwin-x64": "18.2.0-beta.16",
47
- "@embedded-postgres/linux-x64": "18.2.0-beta.16",
48
- "@embedded-postgres/windows-x64": "18.2.0-beta.16"
45
+ "@embedded-postgres/darwin-arm64": "18.3.0-beta.17",
46
+ "@embedded-postgres/darwin-x64": "18.3.0-beta.17",
47
+ "@embedded-postgres/linux-x64": "18.3.0-beta.17",
48
+ "@embedded-postgres/windows-x64": "18.3.0-beta.17"
49
49
  },
50
50
  "devDependencies": {
51
51
  "eslint": "^9.39.1",
@@ -0,0 +1,412 @@
1
+ /**
2
+ * pgserve install / uninstall / status / url / port subcommands.
3
+ *
4
+ * Wave 1 of the canonical-pgserve-pm2-supervision wish (PR #55, issue #56).
5
+ *
6
+ * These subcommands let pgserve own its pm2 lifecycle. Other services that
7
+ * need a Postgres connection (omni, genie, future) shell out to:
8
+ *
9
+ * pgserve install # idempotent, registers under pm2
10
+ * pgserve url # postgres://localhost:8432/postgres
11
+ *
12
+ * instead of spinning up their own embedded pgserve. End-state: a single
13
+ * shared pgserve under pm2 with hardened defaults, consumed by everyone.
14
+ *
15
+ * This module intentionally lives outside `bin/postgres-server.js` because
16
+ * none of these subcommands need bun (or a running PG backend) — they are
17
+ * filesystem + pm2 wrappers. Keeping them here means `pgserve install`
18
+ * works even when bun isn't healthy yet (the wrapper's bun-probe would
19
+ * otherwise block the install path).
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ const { spawnSync, execFileSync } = require('node:child_process');
25
+ const fs = require('node:fs');
26
+ const os = require('node:os');
27
+ const path = require('node:path');
28
+
29
+ const PM2_PROCESS_NAME = 'pgserve';
30
+ const DEFAULT_PORT = 8432;
31
+
32
+ /**
33
+ * Hardening defaults — tuned for production-grade elasticity, NOT
34
+ * the toy-machine values an initial draft of the wish carried.
35
+ *
36
+ * Earlier draft pinned `maxMemory: 1G` and `maxRestarts: 10`. The
37
+ * operator who reviewed PR #57 caught both as dangerously small for
38
+ * Postgres realistically:
39
+ * - 1G OOM-kills pgserve under modest load (shared_buffers + autovacuum
40
+ * workers + connection backends easily exceed 1G with a working set
41
+ * of any size).
42
+ * - 10 restart cap burns through during transient flakes (NATS reconnect
43
+ * loop, parent-process restart, host pressure spikes) before pm2
44
+ * gives up, leaving the operator with a stopped service in the
45
+ * morning.
46
+ *
47
+ * Revised defaults:
48
+ * - 4G memory ceiling — covers realistic load while still bounded so
49
+ * a runaway query can't eat the host.
50
+ * - 50 max restarts BUT only counted when min_uptime < 10s ("rapid"
51
+ * failures). Healthy long-uptime crashes don't count against the cap.
52
+ * - Exponential backoff on repeated failures (100ms → 60s) so we don't
53
+ * hammer on persistent issues.
54
+ * - 60s graceful shutdown window — Postgres needs time to flush WAL.
55
+ *
56
+ * Override at install time via env:
57
+ * PGSERVE_MAX_MEMORY=8G pgserve install
58
+ *
59
+ * These mirror the values omni and genie will use for their own pm2
60
+ * services. The constants are duplicated across repos (avoids a new
61
+ * shared package) but the values are pinned in the wish.
62
+ */
63
+ const HARDENED_DEFAULTS = {
64
+ maxRestarts: 50,
65
+ minUptimeMs: 10_000,
66
+ restartDelayMs: 4000,
67
+ expBackoffRestartDelayMs: 100,
68
+ // pm2 caps `--exp-backoff-restart-delay` ramp at the current backoff
69
+ // doubling — practical max ~60s. Documented for operator clarity.
70
+ expBackoffMaxMs: 60_000,
71
+ maxMemory: process.env.PGSERVE_MAX_MEMORY || '4G',
72
+ killTimeoutMs: 60_000,
73
+ logDateFormat: 'YYYY-MM-DD HH:mm:ss.SSS',
74
+ };
75
+
76
+ function getConfigDir() {
77
+ return process.env.PGSERVE_CONFIG_DIR || path.join(os.homedir(), '.pgserve');
78
+ }
79
+
80
+ function getConfigPath() {
81
+ return path.join(getConfigDir(), 'config.json');
82
+ }
83
+
84
+ function getLogsDir() {
85
+ return path.join(getConfigDir(), 'logs');
86
+ }
87
+
88
+ function getDataDir() {
89
+ return path.join(getConfigDir(), 'data');
90
+ }
91
+
92
+ function readConfig() {
93
+ const p = getConfigPath();
94
+ if (!fs.existsSync(p)) return null;
95
+ try {
96
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ function writeConfig(config) {
103
+ const dir = getConfigDir();
104
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
105
+ const tmp = `${getConfigPath()}.tmp.${process.pid}`;
106
+ fs.writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o644 });
107
+ fs.renameSync(tmp, getConfigPath());
108
+ }
109
+
110
+ /**
111
+ * Run `pm2 jlist` and return the entry for our process, or null when not
112
+ * registered. Returns null on any failure (pm2 missing, JSON parse error,
113
+ * etc.) — callers should treat that as "not installed" rather than crash.
114
+ */
115
+ function pm2GetProcess(name) {
116
+ try {
117
+ const out = execFileSync('pm2', ['jlist'], {
118
+ encoding: 'utf8',
119
+ timeout: 5000,
120
+ stdio: ['ignore', 'pipe', 'ignore'],
121
+ });
122
+ const list = JSON.parse(out);
123
+ return list.find((p) => p && p.name === name) || null;
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ function pm2IsAvailable() {
130
+ try {
131
+ execFileSync('pm2', ['--version'], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] });
132
+ return true;
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ function buildPm2StartArgs({ scriptPath, port, dataDir }) {
139
+ const logs = {
140
+ out: path.join(getLogsDir(), `${PM2_PROCESS_NAME}-out.log`),
141
+ error: path.join(getLogsDir(), `${PM2_PROCESS_NAME}-error.log`),
142
+ };
143
+ return [
144
+ 'start',
145
+ scriptPath,
146
+ '--name',
147
+ PM2_PROCESS_NAME,
148
+ '--interpreter',
149
+ 'none',
150
+ '--max-restarts',
151
+ String(HARDENED_DEFAULTS.maxRestarts),
152
+ // `--min-uptime` makes `--max-restarts` count only RAPID failures
153
+ // (process crashed within N ms of starting). Healthy long-uptime
154
+ // crashes don't burn the budget.
155
+ '--min-uptime',
156
+ String(HARDENED_DEFAULTS.minUptimeMs),
157
+ '--restart-delay',
158
+ String(HARDENED_DEFAULTS.restartDelayMs),
159
+ // Exponential backoff between successive failures: starts at 100ms,
160
+ // doubles each crash, ramps to ~60s. Avoids hammering pm2 + the host
161
+ // when the underlying issue is persistent.
162
+ '--exp-backoff-restart-delay',
163
+ String(HARDENED_DEFAULTS.expBackoffRestartDelayMs),
164
+ '--max-memory-restart',
165
+ HARDENED_DEFAULTS.maxMemory,
166
+ '--kill-timeout',
167
+ String(HARDENED_DEFAULTS.killTimeoutMs),
168
+ '--log-date-format',
169
+ HARDENED_DEFAULTS.logDateFormat,
170
+ '--output',
171
+ logs.out,
172
+ '--error',
173
+ logs.error,
174
+ '--',
175
+ 'daemon',
176
+ '--port',
177
+ String(port),
178
+ '--data',
179
+ dataDir,
180
+ '--log',
181
+ 'warn',
182
+ ];
183
+ }
184
+
185
+ function ensureLogsDir() {
186
+ const dir = getLogsDir();
187
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
188
+ }
189
+
190
+ function fail(message) {
191
+ process.stderr.write(`pgserve: ${message}\n`);
192
+ process.exit(1);
193
+ }
194
+
195
+ function note(message) {
196
+ process.stderr.write(`pgserve: ${message}\n`);
197
+ }
198
+
199
+ function ok(message) {
200
+ process.stdout.write(`pgserve: ${message}\n`);
201
+ }
202
+
203
+ /**
204
+ * `pgserve install [--port N] [--data PATH]`
205
+ *
206
+ * Idempotent. When the process is already registered, prints a reuse line
207
+ * and exits 0 without touching anything. Otherwise: writes `~/.pgserve/
208
+ * config.json` (creating the dir if needed), then registers the process
209
+ * under pm2 with the hardened defaults.
210
+ *
211
+ * `scriptPath` is the path to `bin/postgres-server.js` resolved by the
212
+ * wrapper before this module is required (avoids re-resolving here).
213
+ */
214
+ function cmdInstall(args, ctx) {
215
+ if (!pm2IsAvailable()) {
216
+ fail('pm2 not found in PATH. Install with: bun add -g pm2 (or npm i -g pm2)');
217
+ }
218
+
219
+ const port = parsePort(args) ?? readConfig()?.port ?? DEFAULT_PORT;
220
+ const dataDir = parseDataDir(args) ?? readConfig()?.dataDir ?? getDataDir();
221
+
222
+ // Idempotent: already-registered = no-op success.
223
+ const existing = pm2GetProcess(PM2_PROCESS_NAME);
224
+ if (existing) {
225
+ ok(`already installed (pm2 process "${PM2_PROCESS_NAME}", status=${existing.pm2_env?.status ?? 'unknown'})`);
226
+ // Refresh config in case install was re-run with new flags — but
227
+ // don't tear down the live process. Operators wanting a port change
228
+ // should `uninstall` then `install`.
229
+ writeConfig({ port, dataDir, registeredAt: readConfig()?.registeredAt ?? new Date().toISOString() });
230
+ return 0;
231
+ }
232
+
233
+ ensureLogsDir();
234
+ if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
235
+
236
+ const pm2Args = buildPm2StartArgs({ scriptPath: ctx.scriptPath, port, dataDir });
237
+ const result = spawnSync('pm2', pm2Args, { stdio: 'inherit' });
238
+ if (result.status !== 0) {
239
+ fail(`pm2 start failed (exit ${result.status}). Logs: ${getLogsDir()}/${PM2_PROCESS_NAME}-error.log`);
240
+ }
241
+
242
+ writeConfig({ port, dataDir, registeredAt: new Date().toISOString() });
243
+ ok(`installed: pm2 process "${PM2_PROCESS_NAME}" on port ${port} (data: ${dataDir})`);
244
+ ok(`url: postgres://localhost:${port}/postgres`);
245
+ return 0;
246
+ }
247
+
248
+ /**
249
+ * `pgserve uninstall`
250
+ *
251
+ * Removes pgserve from pm2. Leaves the data directory and config file
252
+ * intact — operator can `rm -rf ~/.pgserve` after they're satisfied no
253
+ * downstream service still depends on the data.
254
+ */
255
+ function cmdUninstall() {
256
+ const existing = pm2GetProcess(PM2_PROCESS_NAME);
257
+ if (!existing) {
258
+ ok(`not registered under pm2 (nothing to uninstall)`);
259
+ return 0;
260
+ }
261
+ const result = spawnSync('pm2', ['delete', PM2_PROCESS_NAME], { stdio: 'inherit' });
262
+ if (result.status !== 0) {
263
+ fail(`pm2 delete failed (exit ${result.status})`);
264
+ }
265
+ ok(`uninstalled (pm2 process removed; data dir preserved at ${getDataDir()})`);
266
+ return 0;
267
+ }
268
+
269
+ /**
270
+ * `pgserve status [--json]`
271
+ *
272
+ * Reports both pm2 state and on-disk config. Exits 0 with status info
273
+ * regardless of running/stopped — operators script around the JSON output.
274
+ * Non-zero only when the config is missing entirely (i.e. pgserve was
275
+ * never installed).
276
+ */
277
+ function cmdStatus(args) {
278
+ const json = args.includes('--json');
279
+ const config = readConfig();
280
+ if (!config) {
281
+ if (json) {
282
+ process.stdout.write(`${JSON.stringify({ installed: false })}\n`);
283
+ } else {
284
+ ok('not installed (run: pgserve install)');
285
+ }
286
+ return 1;
287
+ }
288
+ const proc = pm2GetProcess(PM2_PROCESS_NAME);
289
+ const status = proc?.pm2_env?.status ?? 'stopped';
290
+ const pid = proc?.pid ?? null;
291
+ const uptimeMs = proc?.pm2_env?.pm_uptime ? Date.now() - proc.pm2_env.pm_uptime : null;
292
+ const restarts = proc?.pm2_env?.restart_time ?? 0;
293
+
294
+ const payload = {
295
+ installed: true,
296
+ name: PM2_PROCESS_NAME,
297
+ status,
298
+ pid,
299
+ port: config.port,
300
+ dataDir: config.dataDir,
301
+ logsDir: getLogsDir(),
302
+ url: `postgres://localhost:${config.port}/postgres`,
303
+ uptimeMs,
304
+ restarts,
305
+ registeredAt: config.registeredAt,
306
+ };
307
+
308
+ if (json) {
309
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
310
+ return 0;
311
+ }
312
+ process.stdout.write(`name ${payload.name}\n`);
313
+ process.stdout.write(`status ${payload.status}${payload.pid ? ` (pid ${payload.pid})` : ''}\n`);
314
+ process.stdout.write(`port ${payload.port}\n`);
315
+ process.stdout.write(`url ${payload.url}\n`);
316
+ process.stdout.write(`dataDir ${payload.dataDir}\n`);
317
+ process.stdout.write(`logsDir ${payload.logsDir}\n`);
318
+ if (payload.uptimeMs != null) {
319
+ const sec = Math.floor(payload.uptimeMs / 1000);
320
+ process.stdout.write(`uptime ${sec}s\n`);
321
+ }
322
+ process.stdout.write(`restarts ${payload.restarts}\n`);
323
+ process.stdout.write(`registered ${payload.registeredAt}\n`);
324
+ return 0;
325
+ }
326
+
327
+ /**
328
+ * `pgserve url`
329
+ *
330
+ * Discovery API. Prints the canonical connection string. Downstream
331
+ * installers (genie install, omni install) call this to learn where to
332
+ * connect, instead of hardcoding a port.
333
+ */
334
+ function cmdUrl() {
335
+ const config = readConfig();
336
+ if (!config) {
337
+ fail('not installed (run: pgserve install)');
338
+ }
339
+ process.stdout.write(`postgres://localhost:${config.port}/postgres\n`);
340
+ return 0;
341
+ }
342
+
343
+ /** `pgserve port` — print the canonical port. */
344
+ function cmdPort() {
345
+ const config = readConfig();
346
+ if (!config) {
347
+ fail('not installed (run: pgserve install)');
348
+ }
349
+ process.stdout.write(`${config.port}\n`);
350
+ return 0;
351
+ }
352
+
353
+ function parsePort(args) {
354
+ const i = args.indexOf('--port');
355
+ if (i < 0) return null;
356
+ const v = args[i + 1];
357
+ if (!v) fail('--port requires a value');
358
+ const n = Number.parseInt(v, 10);
359
+ if (!Number.isInteger(n) || n < 1 || n > 65535) fail(`invalid --port "${v}"`);
360
+ return n;
361
+ }
362
+
363
+ function parseDataDir(args) {
364
+ const i = args.indexOf('--data');
365
+ if (i < 0) return null;
366
+ const v = args[i + 1];
367
+ if (!v) fail('--data requires a value');
368
+ return path.resolve(v);
369
+ }
370
+
371
+ /**
372
+ * Entry point invoked by the wrapper. Returns the exit code. Throws on
373
+ * unknown subcommand so the wrapper's normal flow can take over (the
374
+ * router treats any non-recognized subcommand as "pass through to the
375
+ * postgres-server.js dispatcher").
376
+ */
377
+ function dispatch(subcommand, args, ctx) {
378
+ switch (subcommand) {
379
+ case 'install':
380
+ return cmdInstall(args, ctx);
381
+ case 'uninstall':
382
+ return cmdUninstall();
383
+ case 'status':
384
+ return cmdStatus(args);
385
+ case 'url':
386
+ return cmdUrl();
387
+ case 'port':
388
+ return cmdPort();
389
+ default:
390
+ throw new Error(`pgserve: dispatch called with unknown subcommand "${subcommand}"`);
391
+ }
392
+ }
393
+
394
+ module.exports = {
395
+ // Public API for the wrapper.
396
+ dispatch,
397
+ // Test surface.
398
+ _internals: {
399
+ HARDENED_DEFAULTS,
400
+ PM2_PROCESS_NAME,
401
+ DEFAULT_PORT,
402
+ getConfigDir,
403
+ getConfigPath,
404
+ getLogsDir,
405
+ getDataDir,
406
+ readConfig,
407
+ writeConfig,
408
+ buildPm2StartArgs,
409
+ parsePort,
410
+ parseDataDir,
411
+ },
412
+ };
package/src/postgres.js CHANGED
@@ -67,7 +67,7 @@ async function downloadPostgresBinaries() {
67
67
 
68
68
  const platformKey = getPlatformKey();
69
69
  const pkgName = `@embedded-postgres/${platformKey}`;
70
- const pkgVersion = '18.2.0-beta.16';
70
+ const pkgVersion = '18.3.0-beta.17';
71
71
 
72
72
  console.log(`[pgserve] PostgreSQL binaries not found.`);
73
73
  console.log(`[pgserve] Downloading ${pkgName}@${pkgVersion}...`);
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Tests for src/cli-install.cjs — pgserve install/uninstall/status/url/port.
3
+ *
4
+ * Wave 1 of the canonical-pgserve-pm2-supervision wish (PR #55, issue #56).
5
+ *
6
+ * Strategy: drive the pure paths (config read/write, arg parsing, pm2-args
7
+ * builder) directly. The pm2-spawning paths (install / uninstall) are
8
+ * exercised by spawning the real pgserve binary against a temp HOME so
9
+ * `pm2` is invoked but with no real daemon side-effect when pm2 is
10
+ * either absent OR the test stubs its calls via PATH.
11
+ *
12
+ * No test in this file actually starts pgserve. We only verify the CLI
13
+ * surface — the daemon lifecycle is covered by daemon-control.test.js.
14
+ */
15
+
16
+ import { test, expect, beforeEach, afterEach, describe } from 'bun:test';
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import { spawnSync } from 'node:child_process';
21
+
22
+ const REPO_ROOT = path.resolve(__dirname, '..');
23
+ const BIN = path.join(REPO_ROOT, 'bin', 'pgserve-wrapper.cjs');
24
+
25
+ let tmpHome;
26
+ let stubBin;
27
+ let originalConfigDir;
28
+ let originalPath;
29
+
30
+ function makeStubPm2(mode = 'success') {
31
+ // mode: 'success' | 'failure' | 'missing'
32
+ // Writes a stub `pm2` script into a tempdir we prepend to PATH.
33
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-stub-pm2-'));
34
+ if (mode === 'missing') {
35
+ // Don't create a stub; PATH still has our dir but no pm2 binary.
36
+ return { dir, calls: [] };
37
+ }
38
+ const callLog = path.join(dir, 'calls.log');
39
+ const exitCode = mode === 'failure' ? 1 : 0;
40
+ // jlist returns either an empty list (so install proceeds) or a fake
41
+ // process record (so subsequent install calls hit the idempotent
42
+ // path). We toggle via a sentinel file the test owns.
43
+ const script = `#!/usr/bin/env node
44
+ const fs = require('node:fs');
45
+ const args = process.argv.slice(2);
46
+ fs.appendFileSync(${JSON.stringify(callLog)}, JSON.stringify(args) + '\\n');
47
+ if (args[0] === '--version') { process.stdout.write('5.0.0-stub\\n'); process.exit(0); }
48
+ if (args[0] === 'jlist') {
49
+ const sentinel = ${JSON.stringify(path.join(dir, 'registered'))};
50
+ if (fs.existsSync(sentinel)) {
51
+ process.stdout.write(JSON.stringify([{
52
+ name: 'pgserve',
53
+ pid: 12345,
54
+ pm2_env: { status: 'online', pm_uptime: Date.now() - 1000, restart_time: 0 }
55
+ }]) + '\\n');
56
+ } else {
57
+ process.stdout.write('[]\\n');
58
+ }
59
+ process.exit(0);
60
+ }
61
+ if (args[0] === 'start') {
62
+ fs.writeFileSync(${JSON.stringify(path.join(dir, 'registered'))}, '');
63
+ process.exit(${exitCode});
64
+ }
65
+ if (args[0] === 'delete') {
66
+ try { fs.unlinkSync(${JSON.stringify(path.join(dir, 'registered'))}); } catch {}
67
+ process.exit(${exitCode});
68
+ }
69
+ process.exit(0);
70
+ `;
71
+ const pm2Path = path.join(dir, 'pm2');
72
+ fs.writeFileSync(pm2Path, script, { mode: 0o755 });
73
+ return { dir, calls: callLog };
74
+ }
75
+
76
+ function readCallLog(callsPath) {
77
+ if (!fs.existsSync(callsPath)) return [];
78
+ return fs.readFileSync(callsPath, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
79
+ }
80
+
81
+ function runCli(args, env = {}) {
82
+ return spawnSync('node', [BIN, ...args], {
83
+ encoding: 'utf8',
84
+ env: {
85
+ ...process.env,
86
+ PGSERVE_CONFIG_DIR: tmpHome,
87
+ PATH: `${stubBin.dir}:${process.env.PATH}`,
88
+ ...env,
89
+ },
90
+ });
91
+ }
92
+
93
+ beforeEach(() => {
94
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-cfg-'));
95
+ stubBin = makeStubPm2('success');
96
+ originalConfigDir = process.env.PGSERVE_CONFIG_DIR;
97
+ originalPath = process.env.PATH;
98
+ });
99
+
100
+ afterEach(() => {
101
+ fs.rmSync(tmpHome, { recursive: true, force: true });
102
+ if (stubBin?.dir) fs.rmSync(stubBin.dir, { recursive: true, force: true });
103
+ if (originalConfigDir === undefined) delete process.env.PGSERVE_CONFIG_DIR;
104
+ else process.env.PGSERVE_CONFIG_DIR = originalConfigDir;
105
+ process.env.PATH = originalPath;
106
+ });
107
+
108
+ describe('pgserve install', () => {
109
+ test('first install registers under pm2 and writes config', () => {
110
+ const result = runCli(['install']);
111
+ expect(result.status).toBe(0);
112
+ expect(result.stdout).toContain('installed');
113
+ expect(result.stdout).toContain('postgres://localhost:8432');
114
+
115
+ const config = JSON.parse(fs.readFileSync(path.join(tmpHome, 'config.json'), 'utf8'));
116
+ expect(config.port).toBe(8432);
117
+ expect(config.dataDir).toBe(path.join(tmpHome, 'data'));
118
+ expect(config.registeredAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
119
+
120
+ const calls = readCallLog(stubBin.calls);
121
+ const startCall = calls.find((c) => c[0] === 'start');
122
+ expect(startCall).toBeDefined();
123
+ expect(startCall).toContain('--name');
124
+ expect(startCall).toContain('pgserve');
125
+ expect(startCall).toContain('--max-restarts');
126
+ expect(startCall).toContain('50');
127
+ expect(startCall).toContain('--min-uptime');
128
+ expect(startCall).toContain('--exp-backoff-restart-delay');
129
+ expect(startCall).toContain('--max-memory-restart');
130
+ expect(startCall).toContain('4G');
131
+ expect(startCall).toContain('--kill-timeout');
132
+ expect(startCall).toContain('60000');
133
+ expect(startCall).toContain('--interpreter');
134
+ expect(startCall).toContain('none');
135
+ });
136
+
137
+ test('second install is idempotent (no second pm2 start)', () => {
138
+ runCli(['install']);
139
+ const calls1 = readCallLog(stubBin.calls);
140
+ const startCount1 = calls1.filter((c) => c[0] === 'start').length;
141
+ expect(startCount1).toBe(1);
142
+
143
+ const result2 = runCli(['install']);
144
+ expect(result2.status).toBe(0);
145
+ expect(result2.stdout).toContain('already installed');
146
+
147
+ const calls2 = readCallLog(stubBin.calls);
148
+ const startCount2 = calls2.filter((c) => c[0] === 'start').length;
149
+ expect(startCount2).toBe(1); // no second start
150
+ });
151
+
152
+ test('--port overrides default', () => {
153
+ const result = runCli(['install', '--port', '8442']);
154
+ expect(result.status).toBe(0);
155
+ const config = JSON.parse(fs.readFileSync(path.join(tmpHome, 'config.json'), 'utf8'));
156
+ expect(config.port).toBe(8442);
157
+ expect(result.stdout).toContain('postgres://localhost:8442');
158
+ });
159
+
160
+ test('rejects malformed --port', () => {
161
+ const result = runCli(['install', '--port', 'not-a-number']);
162
+ expect(result.status).not.toBe(0);
163
+ expect(result.stderr).toContain('invalid --port');
164
+ });
165
+
166
+ test('PGSERVE_MAX_MEMORY env overrides the default memory ceiling', () => {
167
+ const result = runCli(['install'], { PGSERVE_MAX_MEMORY: '8G' });
168
+ expect(result.status).toBe(0);
169
+ const calls = readCallLog(stubBin.calls);
170
+ const startCall = calls.find((c) => c[0] === 'start');
171
+ // The env value flows through to pm2's --max-memory-restart flag so
172
+ // operators on big-iron hosts can tune up without a recompile.
173
+ expect(startCall).toContain('8G');
174
+ expect(startCall).not.toContain('4G');
175
+ });
176
+
177
+ test('fails clearly when pm2 is missing', () => {
178
+ // Build a sanitized PATH that has node (so spawnSync can resolve the
179
+ // interpreter) but explicitly NO directory containing pm2. Skipping
180
+ // /usr/bin etc. would make the test brittle on different hosts.
181
+ fs.rmSync(stubBin.dir, { recursive: true, force: true });
182
+ stubBin = makeStubPm2('missing');
183
+ const nodeDir = path.dirname(process.execPath);
184
+ const sanitizedPath = (process.env.PATH || '')
185
+ .split(':')
186
+ .filter((p) => {
187
+ try {
188
+ return !fs.existsSync(path.join(p, 'pm2'));
189
+ } catch {
190
+ return true;
191
+ }
192
+ })
193
+ .concat([nodeDir, stubBin.dir])
194
+ .join(':');
195
+ const result = spawnSync('node', [BIN, 'install'], {
196
+ encoding: 'utf8',
197
+ env: { ...process.env, PGSERVE_CONFIG_DIR: tmpHome, PATH: sanitizedPath },
198
+ });
199
+ expect(result.status).not.toBe(0);
200
+ expect(result.stderr).toContain('pm2 not found');
201
+ });
202
+ });
203
+
204
+ describe('pgserve url / port', () => {
205
+ test('url after install prints canonical connection string', () => {
206
+ runCli(['install']);
207
+ const result = runCli(['url']);
208
+ expect(result.status).toBe(0);
209
+ expect(result.stdout.trim()).toBe('postgres://localhost:8432/postgres');
210
+ });
211
+
212
+ test('port after install prints the registered port', () => {
213
+ runCli(['install', '--port', '8442']);
214
+ const result = runCli(['port']);
215
+ expect(result.status).toBe(0);
216
+ expect(result.stdout.trim()).toBe('8442');
217
+ });
218
+
219
+ test('url before install fails with helpful message', () => {
220
+ const result = runCli(['url']);
221
+ expect(result.status).not.toBe(0);
222
+ expect(result.stderr).toContain('not installed');
223
+ });
224
+ });
225
+
226
+ describe('pgserve status', () => {
227
+ test('status before install reports installed=false (exit 1)', () => {
228
+ const result = runCli(['status', '--json']);
229
+ expect(result.status).toBe(1);
230
+ const out = JSON.parse(result.stdout);
231
+ expect(out.installed).toBe(false);
232
+ });
233
+
234
+ test('status after install reports running with port from config', () => {
235
+ runCli(['install', '--port', '8482']);
236
+ const result = runCli(['status', '--json']);
237
+ expect(result.status).toBe(0);
238
+ const out = JSON.parse(result.stdout);
239
+ expect(out.installed).toBe(true);
240
+ expect(out.name).toBe('pgserve');
241
+ expect(out.status).toBe('online');
242
+ expect(out.port).toBe(8482);
243
+ expect(out.url).toBe('postgres://localhost:8482/postgres');
244
+ });
245
+
246
+ test('status human-readable output includes port + url', () => {
247
+ runCli(['install']);
248
+ const result = runCli(['status']);
249
+ expect(result.status).toBe(0);
250
+ expect(result.stdout).toContain('port');
251
+ expect(result.stdout).toContain('8432');
252
+ expect(result.stdout).toContain('postgres://localhost:8432/postgres');
253
+ });
254
+ });
255
+
256
+ describe('pgserve uninstall', () => {
257
+ test('uninstall removes pm2 process but preserves config', () => {
258
+ runCli(['install']);
259
+ expect(fs.existsSync(path.join(tmpHome, 'config.json'))).toBe(true);
260
+
261
+ const result = runCli(['uninstall']);
262
+ expect(result.status).toBe(0);
263
+ expect(result.stdout).toContain('uninstalled');
264
+
265
+ const calls = readCallLog(stubBin.calls);
266
+ expect(calls.find((c) => c[0] === 'delete' && c[1] === 'pgserve')).toBeDefined();
267
+
268
+ // Config preserved so a re-install reuses port/dataDir.
269
+ expect(fs.existsSync(path.join(tmpHome, 'config.json'))).toBe(true);
270
+ });
271
+
272
+ test('uninstall when not installed is a no-op success', () => {
273
+ const result = runCli(['uninstall']);
274
+ expect(result.status).toBe(0);
275
+ expect(result.stdout).toContain('not registered');
276
+ });
277
+ });
278
+
279
+ describe('serve alias', () => {
280
+ test('pgserve serve --help re-routes to daemon (which postgres-server.js handles)', () => {
281
+ // We can't fully exercise `serve` without starting a real daemon.
282
+ // Instead, verify the wrapper's argv-rewrite happens by passing
283
+ // `serve --bogus-flag` and asserting the wrapper proceeded past the
284
+ // install short-circuit (i.e. stderr mentions bun, not "pgserve: ...").
285
+ // Note: bun probe might fail in tests; we don't assert exit code.
286
+ const result = spawnSync('node', [BIN, 'serve', '--bogus-flag'], {
287
+ encoding: 'utf8',
288
+ env: {
289
+ ...process.env,
290
+ PGSERVE_CONFIG_DIR: tmpHome,
291
+ PATH: `${stubBin.dir}:${process.env.PATH}`,
292
+ },
293
+ });
294
+ // Must NOT have hit the install dispatcher (would print
295
+ // "pgserve: not installed" or similar). Because serve passes through
296
+ // to the bun + postgres-server.js path, we expect EITHER a bun error
297
+ // OR a daemon-mode error — never an install-module error.
298
+ expect(result.stderr).not.toContain('pgserve: not installed');
299
+ expect(result.stderr).not.toContain('pm2 not found');
300
+ });
301
+ });