pgserve 2.6.0 → 2.6.4

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.
@@ -29,6 +29,8 @@ export const AUDIT_DIR_NAME = 'audit';
29
29
  export const AUDIT_FILE_PREFIX = 'gc-';
30
30
  export const AUDIT_FILE_MODE = 0o600;
31
31
  export const AUDIT_DIR_MODE = 0o700;
32
+ export const AUDIT_DEFAULT_RETENTION_DAYS = 90;
33
+ const AUDIT_FILENAME_RE = /^gc-(\d{4})-(\d{2})-(\d{2})\.log$/;
32
34
 
33
35
  /**
34
36
  * @typedef {Object} GcAuditEvent
@@ -147,4 +149,94 @@ export function readGcAuditDay({ homeDir = os.homedir(), date = new Date() } = {
147
149
  return out;
148
150
  }
149
151
 
152
+ /**
153
+ * Rotate audit logs by deleting `gc-<YYYY-MM-DD>.log` files older than
154
+ * `retentionDays` (default 90). Each deletion writes a `rotate` audit event
155
+ * to today's log so the rotation itself is auditable.
156
+ *
157
+ * Boundary guard: NEVER deletes the current day's log file, even if the
158
+ * retention math would otherwise include it (clock skew, manual mtime edits,
159
+ * timezone-confusion regressions). The current day is determined from
160
+ * `today` (UTC) — the same date the next `writeGcAudit` call would use.
161
+ *
162
+ * Files whose names do not match `gc-<YYYY-MM-DD>.log` are skipped — the
163
+ * rotator only touches its own log files.
164
+ *
165
+ * Returns `{ deleted: string[], kept: string[], errors: Array<{file,error}> }`
166
+ * so callers can surface a summary line without re-walking the directory.
167
+ */
168
+ export function rotateGcAuditLogs({
169
+ homeDir = os.homedir(),
170
+ retentionDays = AUDIT_DEFAULT_RETENTION_DAYS,
171
+ today = new Date(),
172
+ } = {}) {
173
+ if (!Number.isFinite(retentionDays) || retentionDays < 0) {
174
+ throw new TypeError('rotateGcAuditLogs: retentionDays must be a non-negative number');
175
+ }
176
+ if (!(today instanceof Date) || Number.isNaN(today.getTime())) {
177
+ throw new TypeError('rotateGcAuditLogs: today must be a valid Date');
178
+ }
179
+ const dir = getAuditDir({ homeDir });
180
+ const result = { deleted: [], kept: [], errors: [] };
181
+ let entries;
182
+ try {
183
+ entries = fs.readdirSync(dir);
184
+ } catch (err) {
185
+ if (err.code === 'ENOENT') return result;
186
+ throw err;
187
+ }
188
+ const todayStr = formatUtcDate(today);
189
+ const cutoffMs = Date.UTC(
190
+ today.getUTCFullYear(),
191
+ today.getUTCMonth(),
192
+ today.getUTCDate(),
193
+ ) - retentionDays * 24 * 60 * 60 * 1000;
194
+ for (const name of entries) {
195
+ const match = AUDIT_FILENAME_RE.exec(name);
196
+ if (!match) continue;
197
+ const [, yyyy, mm, dd] = match;
198
+ const dateStr = `${yyyy}-${mm}-${dd}`;
199
+ if (dateStr === todayStr) {
200
+ // Boundary guard — the current day's log is always retained.
201
+ result.kept.push(name);
202
+ continue;
203
+ }
204
+ const fileMs = Date.UTC(Number(yyyy), Number(mm) - 1, Number(dd));
205
+ if (fileMs >= cutoffMs) {
206
+ result.kept.push(name);
207
+ continue;
208
+ }
209
+ const full = path.join(dir, name);
210
+ try {
211
+ fs.unlinkSync(full);
212
+ result.deleted.push(name);
213
+ writeGcAudit(
214
+ {
215
+ action: 'rotate',
216
+ detail: `deleted ${name} (>${retentionDays} days old)`,
217
+ },
218
+ { homeDir, date: today },
219
+ );
220
+ } catch (err) {
221
+ result.errors.push({ file: name, error: err.message });
222
+ // Record the per-file failure in today's audit log so an operator
223
+ // investigating "why is gc-2026-01-01.log still here?" can find the
224
+ // exact reason (permission denied / file in use / etc.) instead of
225
+ // only seeing the rotate-summary count.
226
+ try {
227
+ writeGcAudit(
228
+ {
229
+ action: 'rotate-error',
230
+ detail: `failed to delete ${name}: ${err.message}`,
231
+ },
232
+ { homeDir, date: today },
233
+ );
234
+ } catch {
235
+ /* best-effort — never let audit-write failure abort the rotation pass */
236
+ }
237
+ }
238
+ }
239
+ return result;
240
+ }
241
+
150
242
  export const __testInternals = Object.freeze({ formatUtcDate });
package/src/postgres.js CHANGED
@@ -1049,8 +1049,23 @@ export class PostgresManager extends EventEmitter {
1049
1049
  if (!expected) {
1050
1050
  this.socketDir = null;
1051
1051
  this.databaseDir = null;
1052
+ // B5 (v2.6.3): include the captured postgres stderr/stdout tail
1053
+ // in the WARN so operators tailing pm2 logs see the actual cause
1054
+ // (e.g. "FATAL: could not bind IPv4 address ... Address already
1055
+ // in use", "FATAL: data directory ... has wrong ownership", etc.)
1056
+ // instead of just "subprocess exited unexpectedly". The tail is
1057
+ // capped at 4 KB so a runaway log spam can't bloat the WARN
1058
+ // payload; the bottom of startupOutput is where the fatal
1059
+ // diagnostic typically lands. Empty string when postgres exited
1060
+ // before producing any output (rare; preserved as 'no postgres
1061
+ // output captured' so the field stays present in structured
1062
+ // logs).
1063
+ const STDERR_TAIL_BUDGET = 4096;
1064
+ const tail = startupOutput.length > STDERR_TAIL_BUDGET
1065
+ ? startupOutput.slice(-STDERR_TAIL_BUDGET)
1066
+ : startupOutput;
1052
1067
  this.logger?.warn(
1053
- { code },
1068
+ { code, postgresStderrTail: tail.trim() || 'no postgres output captured' },
1054
1069
  'PostgreSQL subprocess exited unexpectedly — socketDir/databaseDir reset'
1055
1070
  );
1056
1071
  }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * `autopg_meta` table bootstrap.
3
+ *
4
+ * pgserve singleton (v2.6) — `autopg-distribution-cutover-finalize`
5
+ * wish, Group 3 (`pgserve create-app` + manifest LOCK 1).
6
+ *
7
+ * Source-of-truth split (per wish Decision #2 / G3 deliverable 4):
8
+ *
9
+ * `autopg_meta` is the single source of truth for "which apps are
10
+ * registered with this pgserve instance + what cosign trust roots are
11
+ * locked at create-app time." The per-consumer manifest file at
12
+ * `~/.autopg/<sanitized-slug>/manifest.json` (and the sibling
13
+ * `admin.json`) are derived caches; on divergence, the table wins.
14
+ *
15
+ * The `--fix` mutation modes that would regenerate the cache files
16
+ * from the table are NOT implemented in v2.4 read-only V1
17
+ * (src/commands/doctor.js:440-442 prints "--fix tiered modes are not
18
+ * implemented in v2.4"). Until those land, the cache-recovery story is
19
+ * manual: operator deletes the per-consumer dir + re-runs
20
+ * `pgserve create-app <slug>`. The verb is idempotent and preserves
21
+ * the locked_roots already on the row (idempotent re-run touches
22
+ * `last_updated` ONLY).
23
+ *
24
+ * Why a separate table from `pgserve_meta`: different lifecycle.
25
+ * `pgserve_meta` is per-database (provision/gc cohort, fingerprint as
26
+ * PK). `autopg_meta` is per-consumer-app (slug as PK), and an app can
27
+ * exist before any of its DBs do. Splitting the tables keeps each
28
+ * bootstrap genuinely additive + lets the wish Group 4/5 work
29
+ * (per-consumer doctor surface) reach for autopg_meta without
30
+ * crossing into pgserve_meta's invariants.
31
+ *
32
+ * Idempotency: every statement uses `IF NOT EXISTS` (table, indexes).
33
+ * Re-running on an already-bootstrapped database is a no-op.
34
+ */
35
+
36
+ export const AUTOPG_META_TABLE = 'autopg_meta';
37
+
38
+ /**
39
+ * Base columns owned by this module.
40
+ *
41
+ * - slug: PRIMARY KEY — the sanitized consumer slug
42
+ * (sanitizeSlug from src/provision/db-naming.js)
43
+ * - manifest_path: NOT NULL — absolute path to the cache manifest
44
+ * file at ~/.autopg/<slug>/manifest.json
45
+ * - locked_roots: NOT NULL — JSONB array shaped like
46
+ * TRUSTED_IDENTITIES entries, frozen-at-create
47
+ * - created_at: NOT NULL DEFAULT now() — set on insert
48
+ * - last_updated: NOT NULL DEFAULT now() — touched by every
49
+ * create-app re-run; locked_roots stays untouched
50
+ */
51
+ export const AUTOPG_META_COLUMNS = Object.freeze([
52
+ 'slug',
53
+ 'manifest_path',
54
+ 'locked_roots',
55
+ 'created_at',
56
+ 'last_updated',
57
+ ]);
58
+
59
+ // Schema-qualified name. The doctor / verifier read paths probe
60
+ // `to_regclass('public.autopg_meta')`; an unqualified CREATE TABLE
61
+ // could land in a non-public schema if a non-default search_path is
62
+ // configured on the active role, leaving subsequent reads unable to
63
+ // find it. Match the qualification convention pgserve_meta uses.
64
+ const QUALIFIED = `public.${AUTOPG_META_TABLE}`;
65
+
66
+ /**
67
+ * Idempotent statements that CREATE the table + supporting indexes.
68
+ * Returned as an array so callers can run each one individually for
69
+ * clear error reporting (mirrors src/schema/pgserve-meta.js shape).
70
+ */
71
+ export function getBootstrapStatements() {
72
+ return [
73
+ [
74
+ `CREATE TABLE IF NOT EXISTS ${QUALIFIED} (`,
75
+ ' slug TEXT PRIMARY KEY,',
76
+ ' manifest_path TEXT NOT NULL,',
77
+ ' locked_roots JSONB NOT NULL,',
78
+ ' created_at TIMESTAMPTZ NOT NULL DEFAULT now(),',
79
+ ' last_updated TIMESTAMPTZ NOT NULL DEFAULT now()',
80
+ ')',
81
+ ].join('\n'),
82
+ `CREATE INDEX IF NOT EXISTS ${AUTOPG_META_TABLE}_last_updated_idx ON ${QUALIFIED} (last_updated)`,
83
+ ];
84
+ }
85
+
86
+ /**
87
+ * Single SQL string variant — convenient for embedding the bootstrap in
88
+ * a transaction or pg-init script.
89
+ */
90
+ export function getBootstrapSQL() {
91
+ return `${getBootstrapStatements().join(';\n\n')};\n`;
92
+ }
93
+
94
+ /**
95
+ * Apply the bootstrap via a node-postgres-compatible client. The client
96
+ * must expose an async `query(sql)` method (matches both `pg.Client` and
97
+ * `pg.PoolClient`). Returns the list of statements executed.
98
+ *
99
+ * Statements run sequentially so a failure on the index half doesn't
100
+ * masquerade as success after the table half ran.
101
+ */
102
+ export async function bootstrapAutopgMeta(client) {
103
+ if (!client || typeof client.query !== 'function') {
104
+ throw new TypeError('bootstrapAutopgMeta: client must expose an async query() method');
105
+ }
106
+ const statements = getBootstrapStatements();
107
+ for (const sql of statements) {
108
+ await client.query(sql);
109
+ }
110
+ return statements;
111
+ }
112
+
113
+ /**
114
+ * Predicate the doctor / verify read paths can call before deciding
115
+ * whether to query the table. Callers pass the result of
116
+ * `SELECT to_regclass('public.autopg_meta') IS NOT NULL`.
117
+ */
118
+ export function tableExistsFromRegclass(toRegclassResult) {
119
+ return toRegclassResult === true;
120
+ }