pgserve 2.1.2 → 2.2.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.
Files changed (227) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +105 -1
  3. package/bin/autopg-wrapper.cjs +16 -0
  4. package/bin/pgserve-wrapper.cjs +31 -6
  5. package/bin/postgres-server.js +80 -7
  6. package/console/README.md +131 -0
  7. package/console/api.js +173 -0
  8. package/console/app.jsx +483 -0
  9. package/console/colors_and_type.css +227 -0
  10. package/console/components.jsx +167 -0
  11. package/console/console.css +1666 -0
  12. package/console/data.jsx +350 -0
  13. package/console/index.html +31 -0
  14. package/console/screens/databases.jsx +5 -0
  15. package/console/screens/health.jsx +5 -0
  16. package/console/screens/ingress.jsx +5 -0
  17. package/console/screens/optimizer.jsx +5 -0
  18. package/console/screens/rlm-sim.jsx +5 -0
  19. package/console/screens/rlm-trace.jsx +5 -0
  20. package/console/screens/security.jsx +5 -0
  21. package/console/screens/settings.jsx +611 -0
  22. package/console/screens/sql.jsx +5 -0
  23. package/console/screens/sync.jsx +5 -0
  24. package/console/screens/tables.jsx +5 -0
  25. package/console/tweaks-panel.jsx +425 -0
  26. package/package.json +11 -1
  27. package/src/cli-config.cjs +310 -0
  28. package/src/cli-install.cjs +98 -11
  29. package/src/cli-restart.cjs +228 -0
  30. package/src/cli-ui.cjs +580 -0
  31. package/src/cluster.js +43 -38
  32. package/src/postgres.js +141 -19
  33. package/src/settings-loader.cjs +235 -0
  34. package/src/settings-migrate.cjs +212 -0
  35. package/src/settings-pg-args.cjs +146 -0
  36. package/src/settings-schema.cjs +422 -0
  37. package/src/settings-validator.cjs +416 -0
  38. package/src/settings-writer.cjs +288 -0
  39. package/.claude/context/windows-debug.md +0 -119
  40. package/.genie/AGENTS.md +0 -15
  41. package/.genie/agents/README.md +0 -110
  42. package/.genie/agents/analyze.md +0 -176
  43. package/.genie/agents/forge.md +0 -290
  44. package/.genie/agents/garbage-cleaner.md +0 -324
  45. package/.genie/agents/garbage-collector.md +0 -596
  46. package/.genie/agents/github-issue-gc.md +0 -618
  47. package/.genie/agents/review.md +0 -380
  48. package/.genie/agents/semantic-analyzer/find-duplicates.md +0 -90
  49. package/.genie/agents/semantic-analyzer/find-orphans.md +0 -99
  50. package/.genie/agents/semantic-analyzer.md +0 -101
  51. package/.genie/agents/update.md +0 -182
  52. package/.genie/agents/wish.md +0 -357
  53. package/.genie/brainstorms/pgserve-v2/DESIGN.md +0 -174
  54. package/.genie/code/AGENTS.md +0 -694
  55. package/.genie/code/agents/audit/risk.md +0 -173
  56. package/.genie/code/agents/audit/security.md +0 -189
  57. package/.genie/code/agents/audit.md +0 -145
  58. package/.genie/code/agents/challenge.md +0 -230
  59. package/.genie/code/agents/change-reviewer.md +0 -295
  60. package/.genie/code/agents/code-garbage-collector.md +0 -425
  61. package/.genie/code/agents/code-quality.md +0 -410
  62. package/.genie/code/agents/commit-suggester.md +0 -255
  63. package/.genie/code/agents/commit.md +0 -124
  64. package/.genie/code/agents/consensus.md +0 -204
  65. package/.genie/code/agents/daily-standup.md +0 -722
  66. package/.genie/code/agents/docgen.md +0 -48
  67. package/.genie/code/agents/explore.md +0 -79
  68. package/.genie/code/agents/fix.md +0 -100
  69. package/.genie/code/agents/git/commit-advisory.md +0 -219
  70. package/.genie/code/agents/git/workflows/issue.md +0 -244
  71. package/.genie/code/agents/git/workflows/pr.md +0 -179
  72. package/.genie/code/agents/git/workflows/release.md +0 -460
  73. package/.genie/code/agents/git/workflows/report.md +0 -342
  74. package/.genie/code/agents/git.md +0 -432
  75. package/.genie/code/agents/implementor.md +0 -161
  76. package/.genie/code/agents/install.md +0 -515
  77. package/.genie/code/agents/issue-creator.md +0 -344
  78. package/.genie/code/agents/polish.md +0 -116
  79. package/.genie/code/agents/qa.md +0 -653
  80. package/.genie/code/agents/refactor.md +0 -294
  81. package/.genie/code/agents/release.md +0 -1129
  82. package/.genie/code/agents/roadmap.md +0 -885
  83. package/.genie/code/agents/tests.md +0 -557
  84. package/.genie/code/agents/tracer.md +0 -50
  85. package/.genie/code/agents/update/upstream-update.md +0 -85
  86. package/.genie/code/agents/update/versions/generic-update.md +0 -305
  87. package/.genie/code/agents/vibe.md +0 -1317
  88. package/.genie/code/spells/agent-configuration.md +0 -58
  89. package/.genie/code/spells/automated-rc-publishing.md +0 -106
  90. package/.genie/code/spells/branch-tracker-guidance.md +0 -28
  91. package/.genie/code/spells/debug.md +0 -320
  92. package/.genie/code/spells/emoji-naming-convention.md +0 -303
  93. package/.genie/code/spells/evidence-storage.md +0 -26
  94. package/.genie/code/spells/file-naming-rules.md +0 -35
  95. package/.genie/code/spells/forge-code-blueprints.md +0 -195
  96. package/.genie/code/spells/genie-integration.md +0 -153
  97. package/.genie/code/spells/publishing-protocol.md +0 -61
  98. package/.genie/code/spells/team-consultation-protocol.md +0 -284
  99. package/.genie/code/spells/tool-requirements.md +0 -20
  100. package/.genie/code/spells/triad-maintenance-protocol.md +0 -154
  101. package/.genie/code/teams/tech-council/council.md +0 -328
  102. package/.genie/code/teams/tech-council/jt.md +0 -352
  103. package/.genie/code/teams/tech-council/nayr.md +0 -305
  104. package/.genie/code/teams/tech-council/oettam.md +0 -375
  105. package/.genie/neurons/README.md +0 -193
  106. package/.genie/neurons/forge.md +0 -106
  107. package/.genie/neurons/genie.md +0 -63
  108. package/.genie/neurons/review.md +0 -106
  109. package/.genie/neurons/wish.md +0 -104
  110. package/.genie/product/README.md +0 -20
  111. package/.genie/product/cli-automation.md +0 -359
  112. package/.genie/product/environment.md +0 -60
  113. package/.genie/product/mission.md +0 -60
  114. package/.genie/product/roadmap.md +0 -44
  115. package/.genie/product/tech-stack.md +0 -34
  116. package/.genie/product/templates/context-template.md +0 -218
  117. package/.genie/product/templates/qa-done-report-template.md +0 -68
  118. package/.genie/product/templates/review-report-template.md +0 -89
  119. package/.genie/product/templates/wish-template.md +0 -120
  120. package/.genie/scripts/helpers/analyze-commit.js +0 -195
  121. package/.genie/scripts/helpers/bullet-counter.js +0 -194
  122. package/.genie/scripts/helpers/bullet-find.js +0 -289
  123. package/.genie/scripts/helpers/bullet-id.js +0 -244
  124. package/.genie/scripts/helpers/check-secrets.js +0 -237
  125. package/.genie/scripts/helpers/count-tokens.js +0 -200
  126. package/.genie/scripts/helpers/create-frontmatter.js +0 -456
  127. package/.genie/scripts/helpers/detect-markers.js +0 -293
  128. package/.genie/scripts/helpers/detect-todos.js +0 -267
  129. package/.genie/scripts/helpers/detect-unlabeled-blocks.js +0 -135
  130. package/.genie/scripts/helpers/embeddings.js +0 -344
  131. package/.genie/scripts/helpers/find-empty-sections.js +0 -158
  132. package/.genie/scripts/helpers/index.js +0 -319
  133. package/.genie/scripts/helpers/validate-frontmatter.js +0 -578
  134. package/.genie/scripts/helpers/validate-links.js +0 -207
  135. package/.genie/scripts/helpers/validate-paths.js +0 -373
  136. package/.genie/spells/README.md +0 -9
  137. package/.genie/spells/ace-protocol.md +0 -118
  138. package/.genie/spells/ask-one-at-a-time.md +0 -175
  139. package/.genie/spells/backup-analyzer.md +0 -542
  140. package/.genie/spells/blocker.md +0 -12
  141. package/.genie/spells/break-things-move-fast.md +0 -56
  142. package/.genie/spells/context-candidates.md +0 -72
  143. package/.genie/spells/context-critic.md +0 -51
  144. package/.genie/spells/defer-to-expertise.md +0 -278
  145. package/.genie/spells/delegate-dont-do.md +0 -292
  146. package/.genie/spells/error-investigation-protocol.md +0 -328
  147. package/.genie/spells/evidence-based-completion.md +0 -273
  148. package/.genie/spells/experiment.md +0 -65
  149. package/.genie/spells/file-creation-protocol.md +0 -229
  150. package/.genie/spells/forge-integration.md +0 -281
  151. package/.genie/spells/forge-orchestration.md +0 -514
  152. package/.genie/spells/gather-context.md +0 -18
  153. package/.genie/spells/global-health-check.md +0 -34
  154. package/.genie/spells/global-noop-roundtrip.md +0 -25
  155. package/.genie/spells/install-genie.md +0 -1232
  156. package/.genie/spells/install.md +0 -82
  157. package/.genie/spells/investigate-before-commit.md +0 -112
  158. package/.genie/spells/know-yourself.md +0 -288
  159. package/.genie/spells/learn.md +0 -828
  160. package/.genie/spells/mcp-diagnostic-protocol.md +0 -246
  161. package/.genie/spells/mcp-first.md +0 -124
  162. package/.genie/spells/multi-step-execution.md +0 -67
  163. package/.genie/spells/orchestration-boundary-protocol.md +0 -256
  164. package/.genie/spells/orchestrator-not-implementor.md +0 -189
  165. package/.genie/spells/prompt.md +0 -746
  166. package/.genie/spells/reflect.md +0 -404
  167. package/.genie/spells/routing-decision-matrix.md +0 -368
  168. package/.genie/spells/run-in-parallel.md +0 -12
  169. package/.genie/spells/session-state-updater-example.md +0 -196
  170. package/.genie/spells/session-state-updater.md +0 -220
  171. package/.genie/spells/track-long-running-tasks.md +0 -133
  172. package/.genie/spells/troubleshoot-infrastructure.md +0 -176
  173. package/.genie/spells/upgrade-genie.md +0 -415
  174. package/.genie/spells/url-presentation-protocol.md +0 -301
  175. package/.genie/spells/wish-initiation.md +0 -158
  176. package/.genie/spells/wish-issue-linkage.md +0 -410
  177. package/.genie/spells/wish-lifecycle.md +0 -100
  178. package/.genie/state/provider-status.json +0 -3
  179. package/.genie/state/version.json +0 -16
  180. package/.genie/wishes/canonical-pgserve-pm2-supervision/WISH.md +0 -290
  181. package/.genie/wishes/pgserve-v2/BRIEF-from-genie-pgserve.md +0 -99
  182. package/.genie/wishes/pgserve-v2/WISH.md +0 -442
  183. package/.genie/wishes/release-system-genie-pattern/WISH.md +0 -268
  184. package/.genie/wishes/release-system-genie-pattern/validation.md +0 -205
  185. package/.gitguardian.yaml +0 -29
  186. package/.gitguardianignore +0 -16
  187. package/.github/workflows/ci.yml +0 -122
  188. package/.github/workflows/release.yml +0 -289
  189. package/.github/workflows/version.yml +0 -228
  190. package/.husky/pre-commit +0 -2
  191. package/AGENTS.md +0 -433
  192. package/CLAUDE.md +0 -1
  193. package/Makefile +0 -285
  194. package/assets/icon.ico +0 -0
  195. package/bun.lock +0 -435
  196. package/bunfig.toml +0 -28
  197. package/ecosystem.config.cjs +0 -23
  198. package/eslint.config.js +0 -63
  199. package/examples/multi-tenant-demo.js +0 -104
  200. package/install.sh +0 -123
  201. package/knip.json +0 -9
  202. package/scripts/test-bun-self-heal.sh +0 -163
  203. package/scripts/test-npx.sh +0 -60
  204. package/tests/audit.test.js +0 -189
  205. package/tests/backpressure.test.js +0 -167
  206. package/tests/benchmarks/runner.js +0 -1197
  207. package/tests/benchmarks/vector-generator.js +0 -368
  208. package/tests/cli-install.test.js +0 -322
  209. package/tests/control-db.test.js +0 -285
  210. package/tests/daemon-control.test.js +0 -171
  211. package/tests/daemon-fingerprint-integration.test.js +0 -111
  212. package/tests/daemon-pr24-regression.test.js +0 -198
  213. package/tests/fingerprint.test.js +0 -263
  214. package/tests/fixtures/240-orphan-seed.sql +0 -30
  215. package/tests/multi-tenant.test.js +0 -374
  216. package/tests/orphan-cleanup.test.js +0 -390
  217. package/tests/pg-version-regex.test.js +0 -129
  218. package/tests/quick-bench.js +0 -135
  219. package/tests/router-handshake-retry.test.js +0 -119
  220. package/tests/router-handshake-watchdog.test.js +0 -110
  221. package/tests/sdk.test.js +0 -71
  222. package/tests/stale-postmaster-pid.test.js +0 -85
  223. package/tests/stress-test.js +0 -439
  224. package/tests/sync-perf-test.js +0 -150
  225. package/tests/tcp-listen.test.js +0 -368
  226. package/tests/tenancy.test.js +0 -403
  227. package/tests/wrapper-supervision.test.js +0 -107
package/src/postgres.js CHANGED
@@ -19,6 +19,8 @@ import os from 'os';
19
19
  import path from 'path';
20
20
  import fs from 'fs';
21
21
  import crypto from 'crypto';
22
+ import { loadEffectiveConfig } from './settings-loader.cjs';
23
+ import { buildPostgresArgs } from './settings-pg-args.cjs';
22
24
 
23
25
  /**
24
26
  * Get platform key for binary lookup (e.g., 'windows-x64', 'linux-x64', 'darwin-arm64')
@@ -38,12 +40,107 @@ function getPlatformKey() {
38
40
  }
39
41
 
40
42
  /**
41
- * Get the directory where extracted binaries are cached
43
+ * Pinned PostgreSQL major.minor.patch we expect in the cache. Bump alongside
44
+ * `package.json` `optionalDependencies.@embedded-postgres/*`. Used both as
45
+ * the download target AND as the cache-validity check — see `isCachedValid`.
46
+ */
47
+ const PINNED_PG_VERSION = '18.3.0-beta.17';
48
+
49
+ const VERSION_MARKER_FILENAME = '.version';
50
+
51
+ /**
52
+ * Resolve the binary cache root, honouring the same env-var precedence
53
+ * as `getConfigDir()` in `src/cli-install.cjs`. Defaults to `~/.autopg/`
54
+ * (post-rename) so config and binary cache live in the same tree.
55
+ *
56
+ * Legacy `~/.pgserve/bin/<platform>` is migrated by `migrateLegacyBinaryCache`
57
+ * on first call; users with a 2.1.x cache still get a one-time move.
58
+ */
59
+ function getAutopgRoot() {
60
+ return process.env.AUTOPG_CONFIG_DIR
61
+ || process.env.PGSERVE_CONFIG_DIR
62
+ || path.join(os.homedir(), '.autopg');
63
+ }
64
+
65
+ /**
66
+ * Get the directory where extracted binaries are cached.
42
67
  * @returns {string} Cache directory path
43
68
  */
44
69
  function getBinaryCacheDir() {
45
70
  const platformKey = getPlatformKey();
46
- return path.join(os.homedir(), '.pgserve', 'bin', platformKey);
71
+ return path.join(getAutopgRoot(), 'bin', platformKey);
72
+ }
73
+
74
+ /**
75
+ * Read the cached version marker (the `.version` file) written next to
76
+ * the bin/lib trees on a successful extract. Returns the trimmed string
77
+ * or null when missing/unreadable.
78
+ */
79
+ function readCachedVersion(cacheDir) {
80
+ try {
81
+ return fs.readFileSync(path.join(cacheDir, VERSION_MARKER_FILENAME), 'utf8').trim();
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Write the version marker after a successful extract so future cache
89
+ * checks can compare against `PINNED_PG_VERSION` and re-download when
90
+ * the package.json has bumped to a new release.
91
+ */
92
+ function writeCachedVersion(cacheDir, version) {
93
+ try {
94
+ fs.writeFileSync(
95
+ path.join(cacheDir, VERSION_MARKER_FILENAME),
96
+ `${version}\n`,
97
+ { mode: 0o644 },
98
+ );
99
+ } catch {
100
+ // best-effort — failure here just means next boot re-downloads
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Cache hit when BOTH:
106
+ * - initdb + postgres exist in the bin/ subtree
107
+ * - the `.version` marker matches `PINNED_PG_VERSION`
108
+ *
109
+ * The legacy presence-only check (no version marker) deliberately FAILS
110
+ * here so users carrying a pre-rename cache get a fresh, version-correct
111
+ * tree on the next daemon boot.
112
+ */
113
+ function isCachedValid(cacheBinDir, expectedVersion) {
114
+ const platform = os.platform();
115
+ const initdbName = platform === 'win32' ? 'initdb.exe' : 'initdb';
116
+ const postgresName = platform === 'win32' ? 'postgres.exe' : 'postgres';
117
+ if (!fs.existsSync(path.join(cacheBinDir, initdbName))) return false;
118
+ if (!fs.existsSync(path.join(cacheBinDir, postgresName))) return false;
119
+ const cached = readCachedVersion(path.dirname(cacheBinDir));
120
+ return cached === expectedVersion;
121
+ }
122
+
123
+ /**
124
+ * One-time best-effort migration of `~/.pgserve/bin/<platform>` into the
125
+ * new `~/.autopg/bin/<platform>` location. We RENAME (atomic on the same
126
+ * fs) rather than copy to keep the operation cheap. On failure we fall
127
+ * back silently — the download path will recreate the cache.
128
+ */
129
+ function migrateLegacyBinaryCache() {
130
+ const platformKey = getPlatformKey();
131
+ const newDir = getBinaryCacheDir();
132
+ if (fs.existsSync(newDir)) return;
133
+ const oldDir = path.join(os.homedir(), '.pgserve', 'bin', platformKey);
134
+ if (!fs.existsSync(oldDir)) return;
135
+ try {
136
+ fs.mkdirSync(path.dirname(newDir), { recursive: true });
137
+ fs.renameSync(oldDir, newDir);
138
+ console.log(`[pgserve] Migrated binary cache: ${oldDir} → ${newDir}`);
139
+ } catch (err) {
140
+ // EXDEV (cross-device) or permission errors fall through; the
141
+ // downloader will repopulate the new location.
142
+ console.log(`[pgserve] Could not migrate legacy binary cache (${err.code || err.message}); will re-download`);
143
+ }
47
144
  }
48
145
 
49
146
  /**
@@ -54,20 +151,32 @@ function getBinaryCacheDir() {
54
151
  */
55
152
  async function downloadPostgresBinaries() {
56
153
  const platform = os.platform();
154
+
155
+ // Carry over a legacy ~/.pgserve/bin cache the first time we run under
156
+ // the autopg path. After this, the new path is canonical.
157
+ migrateLegacyBinaryCache();
158
+
57
159
  const cacheDir = getBinaryCacheDir();
58
160
  const cacheBinDir = path.join(cacheDir, 'bin');
59
- const initdbName = platform === 'win32' ? 'initdb.exe' : 'initdb';
60
- const postgresName = platform === 'win32' ? 'postgres.exe' : 'postgres';
61
161
 
62
- // Check if already downloaded
63
- if (fs.existsSync(path.join(cacheBinDir, initdbName)) &&
64
- fs.existsSync(path.join(cacheBinDir, postgresName))) {
162
+ // Cache hit: bin/initdb + bin/postgres present AND .version matches
163
+ // PINNED_PG_VERSION. Mismatch (e.g. user upgraded the npm package) or
164
+ // absence triggers a fresh download.
165
+ if (isCachedValid(cacheBinDir, PINNED_PG_VERSION)) {
65
166
  return cacheDir;
66
167
  }
67
168
 
169
+ const cachedVersion = readCachedVersion(cacheDir);
170
+ if (cachedVersion && cachedVersion !== PINNED_PG_VERSION) {
171
+ console.log(`[pgserve] Cached binaries are version ${cachedVersion}; pinned is ${PINNED_PG_VERSION} — re-downloading`);
172
+ // Wipe the stale tree before re-extracting to avoid mixing files
173
+ // from two different PG majors.
174
+ try { fs.rmSync(cacheDir, { recursive: true, force: true }); } catch { /* ignore */ }
175
+ }
176
+
68
177
  const platformKey = getPlatformKey();
69
178
  const pkgName = `@embedded-postgres/${platformKey}`;
70
- const pkgVersion = '18.3.0-beta.17';
179
+ const pkgVersion = PINNED_PG_VERSION;
71
180
 
72
181
  console.log(`[pgserve] PostgreSQL binaries not found.`);
73
182
  console.log(`[pgserve] Downloading ${pkgName}@${pkgVersion}...`);
@@ -145,6 +254,10 @@ async function downloadPostgresBinaries() {
145
254
  // Ignore cleanup errors
146
255
  }
147
256
 
257
+ // Persist the version marker so the next boot can detect package-version
258
+ // bumps and re-download instead of silently running the old major.
259
+ writeCachedVersion(cacheDir, pkgVersion);
260
+
148
261
  console.log(`[pgserve] PostgreSQL binaries installed to ${cacheDir}`);
149
262
  return cacheDir;
150
263
  }
@@ -763,12 +876,22 @@ export class PostgresManager extends EventEmitter {
763
876
  async _startPostgres() {
764
877
  await this._ensureNoStalePostmasterLock();
765
878
  return new Promise((resolve, reject) => {
879
+ // Resolve effective postgres settings (defaults < ~/.autopg/settings.json
880
+ // < env). Curated GUCs land first; `postgres._extra` is layered in
881
+ // beneath them so curated values win on conflict. Invalid entries are
882
+ // dropped with a logger.warn — postgres still starts.
883
+ const { settings } = loadEffectiveConfig({ logger: this.logger });
884
+ const { args: gucArgs, applied: appliedGucs } = buildPostgresArgs(
885
+ settings.postgres,
886
+ { logger: this.logger },
887
+ );
888
+
766
889
  // Build PostgreSQL arguments
767
890
  const pgArgs = [
768
891
  this.binaries.postgres,
769
892
  '-D', this.databaseDir,
770
893
  '-p', this.port.toString(),
771
- '-c', 'max_connections=1000', // Support high connection counts for stress testing
894
+ ...gucArgs,
772
895
  ];
773
896
 
774
897
  // Enable Unix socket for faster local connections (Linux/macOS)
@@ -779,17 +902,16 @@ export class PostgresManager extends EventEmitter {
779
902
  pgArgs.push('-k', ''); // Disable Unix socket on Windows
780
903
  }
781
904
 
782
- // Add logical replication settings when sync is enabled
783
- // These settings enable PostgreSQL's native WAL-based replication
784
- // with ZERO hot path impact (handled by PostgreSQL's WAL writer process)
785
- if (this.syncEnabled) {
786
- pgArgs.push(
787
- '-c', 'wal_level=logical', // Enable logical decoding
788
- '-c', 'max_replication_slots=10', // Support multiple subscriptions
789
- '-c', 'max_wal_senders=10', // Parallel replication streams
790
- '-c', 'wal_keep_size=512MB', // Retain WAL for catchup
905
+ // Surface the WAL block as an info log when sync is enabled, the same
906
+ // signal the previous hardcoded path emitted. The actual GUCs are now
907
+ // schema defaults (wal_level=logical, max_replication_slots=10,
908
+ // max_wal_senders=10, wal_keep_size=512MB) so they ship in `gucArgs`
909
+ // already — this log just preserves the operator-visible breadcrumb.
910
+ if (this.syncEnabled || settings.sync?.enabled) {
911
+ this.logger.info(
912
+ { walLevel: appliedGucs.wal_level },
913
+ 'Logical replication enabled for sync',
791
914
  );
792
- this.logger.info('Logical replication enabled for sync');
793
915
  }
794
916
 
795
917
  this.process = Bun.spawn(buildCommand(pgArgs, this.binaries.libDir), {
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Settings loader — reads `~/.autopg/settings.json`, merges defaults < file
3
+ * < env, returns `{ settings, sources, etag }`.
4
+ *
5
+ * Precedence:
6
+ * default (lowest) — from settings-schema.js
7
+ * file — `~/.autopg/settings.json` (or AUTOPG_CONFIG_DIR override)
8
+ * env (highest) — process.env.AUTOPG_<X> beats process.env.PGSERVE_<X>
9
+ *
10
+ * `sources` is a flat map of dotted keys → 'default' | 'file' | 'env:<NAME>'.
11
+ * `etag` is sha256 of the raw file bytes (or 'sha256:empty' when no file
12
+ * exists). Deterministic for unchanged files, used for optimistic
13
+ * concurrency control on the UI helper's PUT path.
14
+ *
15
+ * The PGSERVE_<X>-only fall-through path emits a one-time deprecation
16
+ * note via `logger.warn` (suppressed on subsequent calls within the
17
+ * same process).
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const crypto = require('node:crypto');
23
+ const fs = require('node:fs');
24
+ const os = require('node:os');
25
+ const path = require('node:path');
26
+
27
+ const { SCHEMA, buildDefaults } = require('./settings-schema.cjs');
28
+
29
+ const SETTINGS_FILENAME = 'settings.json';
30
+ const EMPTY_FILE_ETAG = 'sha256:empty';
31
+
32
+ let _legacyEnvWarningEmitted = false;
33
+
34
+ /**
35
+ * Where settings.json lives. AUTOPG_CONFIG_DIR wins over PGSERVE_CONFIG_DIR
36
+ * (the legacy var) which wins over the default `~/.autopg/`.
37
+ *
38
+ * NOTE: when only PGSERVE_CONFIG_DIR is set we fall back to `~/.pgserve/`
39
+ * (the legacy directory), not `~/.autopg/`. This is the migration-bridge
40
+ * path so existing operators keep working until they migrate. The migrate
41
+ * helper is what decouples the two.
42
+ */
43
+ function getConfigDir() {
44
+ if (process.env.AUTOPG_CONFIG_DIR) return process.env.AUTOPG_CONFIG_DIR;
45
+ if (process.env.PGSERVE_CONFIG_DIR) return process.env.PGSERVE_CONFIG_DIR;
46
+ return path.join(os.homedir(), '.autopg');
47
+ }
48
+
49
+ function getSettingsPath() {
50
+ return path.join(getConfigDir(), SETTINGS_FILENAME);
51
+ }
52
+
53
+ /**
54
+ * Read raw file bytes and parse JSON. Returns `{ raw, parsed }` where
55
+ * `raw` is the bytes used to compute the etag and `parsed` is the JSON
56
+ * tree. Returns `{ raw: null, parsed: null }` when the file is missing.
57
+ *
58
+ * Throws SyntaxError when the file exists but doesn't parse — callers
59
+ * (CLI dispatch) should surface the path so operators can fix or
60
+ * re-init.
61
+ */
62
+ function readSettingsFile(settingsPath = getSettingsPath()) {
63
+ if (!fs.existsSync(settingsPath)) return { raw: null, parsed: null };
64
+ const raw = fs.readFileSync(settingsPath);
65
+ try {
66
+ const parsed = JSON.parse(raw.toString('utf8'));
67
+ return { raw, parsed };
68
+ } catch (err) {
69
+ const wrapped = new SyntaxError(
70
+ `Failed to parse ${settingsPath}: ${err.message}`,
71
+ );
72
+ wrapped.cause = err;
73
+ wrapped.path = settingsPath;
74
+ throw wrapped;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Compute sha256 etag of the raw file bytes. Stable for unchanged
80
+ * files. `EMPTY_FILE_ETAG` for the missing-file case so callers can
81
+ * still pass an `If-Match` (and a CLI write that creates the file
82
+ * round-trips deterministically).
83
+ */
84
+ function computeEtag(rawBytes) {
85
+ if (!rawBytes || rawBytes.length === 0) return EMPTY_FILE_ETAG;
86
+ const hash = crypto.createHash('sha256').update(rawBytes).digest('hex');
87
+ return `sha256:${hash}`;
88
+ }
89
+
90
+ /**
91
+ * Cast an env var string into the descriptor's runtime type. Mirrors
92
+ * `coerce` in settings-validator but without throwing — env vars are
93
+ * trusted by definition (the operator set them) and any garbage value
94
+ * surfaces at runtime via the validator on the next write.
95
+ */
96
+ function castEnv(descriptor, raw) {
97
+ switch (descriptor.type) {
98
+ case 'int': {
99
+ const n = Number.parseInt(raw, 10);
100
+ return Number.isFinite(n) ? n : descriptor.default;
101
+ }
102
+ case 'bool': {
103
+ if (raw === 'true' || raw === '1') return true;
104
+ if (raw === 'false' || raw === '0') return false;
105
+ return descriptor.default;
106
+ }
107
+ default:
108
+ return raw;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Pick the env var that wins for a leaf, in priority order. Returns
114
+ * `{ envName, raw }` on hit, `null` on miss.
115
+ *
116
+ * The first AUTOPG_<X> that is set wins outright. Falling through to
117
+ * a PGSERVE_<X>-only setting trips the one-time deprecation note.
118
+ */
119
+ function resolveEnv(descriptor, env, logger) {
120
+ if (!Array.isArray(descriptor.env) || descriptor.env.length === 0) return null;
121
+ for (const name of descriptor.env) {
122
+ if (env[name] !== undefined && env[name] !== '') {
123
+ const isLegacy = name.startsWith('PGSERVE_');
124
+ if (isLegacy && !_legacyEnvWarningEmitted) {
125
+ _legacyEnvWarningEmitted = true;
126
+ logger?.warn?.(
127
+ { env: name },
128
+ `${name} is deprecated; prefer ${descriptor.env[0]}`,
129
+ );
130
+ }
131
+ return { envName: name, raw: env[name] };
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * Merge defaults < file < env, building up `sources` in lockstep so
139
+ * each leaf's origin is recorded. `postgres._extra` is treated
140
+ * specially: file value wins as a whole map (no per-key env overrides).
141
+ */
142
+ function mergeWithSources({ defaults, fileSettings, env, logger, schema = SCHEMA }) {
143
+ const settings = {};
144
+ const sources = {};
145
+
146
+ for (const [section, fields] of Object.entries(schema)) {
147
+ settings[section] = {};
148
+ for (const [field, descriptor] of Object.entries(fields)) {
149
+ const dotted = `${section}.${field}`;
150
+ let value = clone(descriptor.default);
151
+ let source = 'default';
152
+
153
+ const fileSection = fileSettings && fileSettings[section];
154
+ if (fileSection && Object.prototype.hasOwnProperty.call(fileSection, field)) {
155
+ value = clone(fileSection[field]);
156
+ source = 'file';
157
+ }
158
+
159
+ const envHit = resolveEnv(descriptor, env, logger);
160
+ if (envHit) {
161
+ value = castEnv(descriptor, envHit.raw);
162
+ source = `env:${envHit.envName}`;
163
+ }
164
+
165
+ settings[section][field] = value;
166
+ sources[dotted] = source;
167
+ }
168
+ }
169
+ return { settings, sources };
170
+ }
171
+
172
+ function clone(value) {
173
+ if (value === null || value === undefined) return value;
174
+ if (typeof value !== 'object') return value;
175
+ if (Array.isArray(value)) return value.map(clone);
176
+ const out = {};
177
+ for (const [k, v] of Object.entries(value)) out[k] = clone(v);
178
+ return out;
179
+ }
180
+
181
+ /**
182
+ * Load + merge entry point. Returns `{ settings, sources, etag, path }`.
183
+ *
184
+ * `logger` is optional — when omitted, deprecation notes go to
185
+ * stderr via console.warn so the CLI surface still has visibility.
186
+ *
187
+ * `env` defaults to process.env so callers can inject a frozen snapshot
188
+ * for tests.
189
+ */
190
+ function loadEffectiveConfig({
191
+ schema = SCHEMA,
192
+ env = process.env,
193
+ logger,
194
+ settingsPath = getSettingsPath(),
195
+ } = {}) {
196
+ const fallbackLogger = logger || {
197
+ warn: (data, msg) => console.warn(`[autopg] ${msg ?? ''} ${JSON.stringify(data ?? {})}`),
198
+ };
199
+ const { raw, parsed } = readSettingsFile(settingsPath);
200
+ const defaults = buildDefaults(schema);
201
+ const { settings, sources } = mergeWithSources({
202
+ defaults,
203
+ fileSettings: parsed,
204
+ env,
205
+ logger: fallbackLogger,
206
+ schema,
207
+ });
208
+ const etag = computeEtag(raw);
209
+ return { settings, sources, etag, path: settingsPath };
210
+ }
211
+
212
+ /**
213
+ * Test helper: reset the once-flag so multiple test cases can each
214
+ * observe the deprecation log line.
215
+ */
216
+ function resetLegacyEnvWarning() {
217
+ _legacyEnvWarningEmitted = false;
218
+ }
219
+
220
+ module.exports = {
221
+ loadEffectiveConfig,
222
+ computeEtag,
223
+ readSettingsFile,
224
+ getConfigDir,
225
+ getSettingsPath,
226
+ EMPTY_FILE_ETAG,
227
+ SETTINGS_FILENAME,
228
+ // Test surface
229
+ _internals: {
230
+ castEnv,
231
+ resolveEnv,
232
+ mergeWithSources,
233
+ resetLegacyEnvWarning,
234
+ },
235
+ };
@@ -0,0 +1,212 @@
1
+ /**
2
+ * One-shot migration from `~/.pgserve/` to `~/.autopg/`.
3
+ *
4
+ * Trigger: dispatcher pre-flight on every CLI entry point.
5
+ * Behavior:
6
+ * - If `~/.autopg/` already exists OR `~/.pgserve/` does not exist → no-op.
7
+ * - Else: copy the legacy directory contents to `~/.autopg/` preserving
8
+ * mtimes, then drop `MIGRATED-FROM-PGSERVE.md` in the old dir as the
9
+ * idempotency marker.
10
+ * - Skips if the marker already exists, even if the new dir was later
11
+ * deleted (so the migration is permanent — operators who want to redo
12
+ * it must remove the marker manually).
13
+ *
14
+ * The legacy dir is left in place (not removed) so operators can A/B and
15
+ * roll back if anything goes wrong. This is intentional: this code runs
16
+ * unattended on every `autopg <subcommand>` invocation, so we err on the
17
+ * side of preserving the user's data.
18
+ *
19
+ * The migration also normalizes the legacy `config.json` (just port +
20
+ * dataDir + registeredAt) into the new `settings.json` shape: the legacy
21
+ * fields populate `server.port` and `runtime.dataDir`, everything else is
22
+ * filled with defaults.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const fs = require('node:fs');
28
+ const os = require('node:os');
29
+ const path = require('node:path');
30
+
31
+ const { buildDefaults, SCHEMA_VERSION } = require('./settings-schema.cjs');
32
+ const { serializeSettings } = require('./settings-writer.cjs');
33
+
34
+ const MARKER_FILENAME = 'MIGRATED-FROM-PGSERVE.md';
35
+ const MARKER_BODY = `# Migrated to ~/.autopg/
36
+
37
+ This directory has been migrated to \`~/.autopg/\` as part of the
38
+ soft-rename to autopg. Settings, data, and logs were copied verbatim;
39
+ the legacy location is preserved so you can roll back if needed.
40
+
41
+ To complete the cutover (after verifying autopg works):
42
+
43
+ rm -rf ~/.pgserve
44
+
45
+ This file is the migration's idempotency marker — its presence prevents
46
+ re-migration on subsequent autopg invocations.
47
+ `;
48
+
49
+ /**
50
+ * Resolve the legacy and new config directories.
51
+ *
52
+ * Migration only runs against the *default* `~/.pgserve` → `~/.autopg`
53
+ * pair. When the user has either env override set, they're in
54
+ * custom-config-dir mode (CI, tests, multi-instance setups) and we
55
+ * never auto-migrate — that's a foot-gun. Operators in custom mode
56
+ * who want to migrate can:
57
+ *
58
+ * 1. unset AUTOPG_CONFIG_DIR/PGSERVE_CONFIG_DIR
59
+ * 2. run `autopg config init`
60
+ * 3. copy whatever they need by hand
61
+ *
62
+ * The override-skip path is the reason `legacy`/`fresh` may be null
63
+ * here; `migrateIfNeeded` short-circuits in that case.
64
+ *
65
+ * Tests opt in to the default-path flow by passing an explicit `home`
66
+ * (a tempdir) instead of relying on env vars.
67
+ */
68
+ function resolveDirs({ home = os.homedir(), env = process.env, allowOverrides = false } = {}) {
69
+ if (!allowOverrides && (env.AUTOPG_CONFIG_DIR || env.PGSERVE_CONFIG_DIR)) {
70
+ return { legacy: null, fresh: null, skipped: true };
71
+ }
72
+ const legacy = path.join(home, '.pgserve');
73
+ const fresh = path.join(home, '.autopg');
74
+ return { legacy, fresh };
75
+ }
76
+
77
+ /**
78
+ * Top-level directory names under the legacy `~/.pgserve/` that we DON'T
79
+ * carry over during the soft-rename migration:
80
+ *
81
+ * - `bin/` — embedded-postgres binary cache, ~120 MB. Postgres binaries
82
+ * are version-specific; the new daemon re-fetches the pinned
83
+ * version on first boot via the version-aware cache check
84
+ * in `src/postgres.js`. Copying defeats that AND wastes disk.
85
+ *
86
+ * Persistent `data/` and pm2 `logs/` ARE migrated — users with stateful
87
+ * setups expect them to survive the rename.
88
+ */
89
+ const LEGACY_SKIP_TOPLEVEL = new Set(['bin']);
90
+
91
+ /**
92
+ * Recursively copy `src` to `dest` preserving mtimes. Skips the marker
93
+ * file (so a re-migration after marker removal doesn't carry it over)
94
+ * and skips any path under `dest` that already exists (we never
95
+ * overwrite during migration). At the top level, also skips the
96
+ * heavy/version-specific directories listed in `LEGACY_SKIP_TOPLEVEL`.
97
+ */
98
+ function copyTree(src, dest, depth = 0) {
99
+ const entries = fs.readdirSync(src, { withFileTypes: true });
100
+ if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true, mode: 0o700 });
101
+
102
+ for (const entry of entries) {
103
+ if (entry.name === MARKER_FILENAME) continue;
104
+ if (depth === 0 && LEGACY_SKIP_TOPLEVEL.has(entry.name)) continue;
105
+ const srcPath = path.join(src, entry.name);
106
+ const destPath = path.join(dest, entry.name);
107
+ if (entry.isDirectory()) {
108
+ copyTree(srcPath, destPath, depth + 1);
109
+ } else if (entry.isFile()) {
110
+ if (fs.existsSync(destPath)) continue;
111
+ fs.copyFileSync(srcPath, destPath);
112
+ const stat = fs.statSync(srcPath);
113
+ try {
114
+ fs.utimesSync(destPath, stat.atime, stat.mtime);
115
+ } catch {
116
+ // best-effort; don't fail migration over an mtime preserve hiccup
117
+ }
118
+ }
119
+ // Symlinks / sockets are skipped; the legacy dir never contains them.
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Translate the legacy `config.json` shape ({ port, dataDir, registeredAt })
125
+ * into the new `settings.json` shape, filling everything else with defaults.
126
+ *
127
+ * Returns the serialized JSON string ready to write.
128
+ */
129
+ function buildSettingsFromLegacyConfig(legacyConfig) {
130
+ const tree = buildDefaults();
131
+ if (legacyConfig && typeof legacyConfig === 'object') {
132
+ if (Number.isInteger(legacyConfig.port)) {
133
+ tree.server.port = legacyConfig.port;
134
+ }
135
+ if (typeof legacyConfig.dataDir === 'string' && legacyConfig.dataDir.length) {
136
+ tree.runtime.dataDir = legacyConfig.dataDir;
137
+ }
138
+ }
139
+ // Marker the migration even within the file payload so a future
140
+ // dump-with-context tool can identify it.
141
+ tree._migratedFrom = '~/.pgserve';
142
+ tree._schemaVersion = SCHEMA_VERSION;
143
+ return serializeSettings(tree);
144
+ }
145
+
146
+ /**
147
+ * Run the migration. Idempotent — safe to call from the dispatcher
148
+ * pre-flight on every command. Returns `{ migrated: bool, reason }`
149
+ * for callers (and tests) that want to log the outcome.
150
+ */
151
+ function migrateIfNeeded(opts = {}) {
152
+ const dirs = resolveDirs(opts);
153
+ if (dirs.skipped) {
154
+ return { migrated: false, reason: 'env-override-set', ...dirs };
155
+ }
156
+ const { legacy, fresh } = dirs;
157
+ const markerPath = path.join(legacy, MARKER_FILENAME);
158
+
159
+ if (!fs.existsSync(legacy)) {
160
+ return { migrated: false, reason: 'no-legacy-dir', legacy, fresh };
161
+ }
162
+ if (fs.existsSync(markerPath)) {
163
+ return { migrated: false, reason: 'already-migrated', legacy, fresh };
164
+ }
165
+ if (fs.existsSync(fresh)) {
166
+ // Both exist, no marker — operator may have created `~/.autopg/`
167
+ // independently. Don't touch either; leave the marker in place so
168
+ // we don't try again.
169
+ fs.writeFileSync(markerPath, MARKER_BODY, { mode: 0o644 });
170
+ return { migrated: false, reason: 'both-exist-marker-set', legacy, fresh };
171
+ }
172
+
173
+ // Copy the directory tree first so any failure leaves the legacy
174
+ // dir untouched.
175
+ copyTree(legacy, fresh);
176
+
177
+ // Translate config.json → settings.json if present and the new file
178
+ // wasn't already created via copyTree (which would have copied any
179
+ // existing settings.json verbatim).
180
+ const legacyConfigPath = path.join(legacy, 'config.json');
181
+ const freshSettingsPath = path.join(fresh, 'settings.json');
182
+ if (fs.existsSync(legacyConfigPath) && !fs.existsSync(freshSettingsPath)) {
183
+ let parsed = null;
184
+ try {
185
+ parsed = JSON.parse(fs.readFileSync(legacyConfigPath, 'utf8'));
186
+ } catch {
187
+ parsed = null;
188
+ }
189
+ const bytes = buildSettingsFromLegacyConfig(parsed);
190
+ fs.writeFileSync(freshSettingsPath, bytes, { mode: 0o600 });
191
+ try {
192
+ fs.chmodSync(freshSettingsPath, 0o600);
193
+ } catch {
194
+ // ignore on platforms without chmod support
195
+ }
196
+ }
197
+
198
+ fs.writeFileSync(markerPath, MARKER_BODY, { mode: 0o644 });
199
+ return { migrated: true, reason: 'copied', legacy, fresh };
200
+ }
201
+
202
+ module.exports = {
203
+ migrateIfNeeded,
204
+ resolveDirs,
205
+ buildSettingsFromLegacyConfig,
206
+ MARKER_FILENAME,
207
+ MARKER_BODY,
208
+ // Test surface
209
+ _internals: {
210
+ copyTree,
211
+ },
212
+ };