pgserve 2.1.3 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +105 -1
  3. package/bin/autopg-wrapper.cjs +16 -0
  4. package/bin/pgserve-wrapper.cjs +32 -6
  5. package/bin/postgres-server.js +56 -0
  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 +14 -2
  27. package/scripts/postinstall.cjs +60 -0
  28. package/src/cli-config.cjs +310 -0
  29. package/src/cli-install.cjs +112 -11
  30. package/src/cli-restart.cjs +228 -0
  31. package/src/cli-ui.cjs +580 -0
  32. package/src/cluster.js +43 -38
  33. package/src/postgres.js +141 -19
  34. package/src/settings-loader.cjs +235 -0
  35. package/src/settings-migrate.cjs +212 -0
  36. package/src/settings-pg-args.cjs +146 -0
  37. package/src/settings-schema.cjs +422 -0
  38. package/src/settings-validator.cjs +416 -0
  39. package/src/settings-writer.cjs +288 -0
  40. package/src/upgrade/index.js +65 -0
  41. package/src/upgrade/runner.js +23 -0
  42. package/src/upgrade/steps/binary-cache-flush.js +67 -0
  43. package/src/upgrade/steps/consumer-signal.js +40 -0
  44. package/src/upgrade/steps/env-refresh.js +89 -0
  45. package/src/upgrade/steps/health-validate.js +53 -0
  46. package/src/upgrade/steps/plpgsql-resolve.js +66 -0
  47. package/src/upgrade/steps/port-reconcile.js +52 -0
  48. package/.claude/context/windows-debug.md +0 -119
  49. package/.genie/AGENTS.md +0 -15
  50. package/.genie/agents/README.md +0 -110
  51. package/.genie/agents/analyze.md +0 -176
  52. package/.genie/agents/forge.md +0 -290
  53. package/.genie/agents/garbage-cleaner.md +0 -324
  54. package/.genie/agents/garbage-collector.md +0 -596
  55. package/.genie/agents/github-issue-gc.md +0 -618
  56. package/.genie/agents/review.md +0 -380
  57. package/.genie/agents/semantic-analyzer/find-duplicates.md +0 -90
  58. package/.genie/agents/semantic-analyzer/find-orphans.md +0 -99
  59. package/.genie/agents/semantic-analyzer.md +0 -101
  60. package/.genie/agents/update.md +0 -182
  61. package/.genie/agents/wish.md +0 -357
  62. package/.genie/brainstorms/pgserve-v2/DESIGN.md +0 -174
  63. package/.genie/code/AGENTS.md +0 -694
  64. package/.genie/code/agents/audit/risk.md +0 -173
  65. package/.genie/code/agents/audit/security.md +0 -189
  66. package/.genie/code/agents/audit.md +0 -145
  67. package/.genie/code/agents/challenge.md +0 -230
  68. package/.genie/code/agents/change-reviewer.md +0 -295
  69. package/.genie/code/agents/code-garbage-collector.md +0 -425
  70. package/.genie/code/agents/code-quality.md +0 -410
  71. package/.genie/code/agents/commit-suggester.md +0 -255
  72. package/.genie/code/agents/commit.md +0 -124
  73. package/.genie/code/agents/consensus.md +0 -204
  74. package/.genie/code/agents/daily-standup.md +0 -722
  75. package/.genie/code/agents/docgen.md +0 -48
  76. package/.genie/code/agents/explore.md +0 -79
  77. package/.genie/code/agents/fix.md +0 -100
  78. package/.genie/code/agents/git/commit-advisory.md +0 -219
  79. package/.genie/code/agents/git/workflows/issue.md +0 -244
  80. package/.genie/code/agents/git/workflows/pr.md +0 -179
  81. package/.genie/code/agents/git/workflows/release.md +0 -460
  82. package/.genie/code/agents/git/workflows/report.md +0 -342
  83. package/.genie/code/agents/git.md +0 -432
  84. package/.genie/code/agents/implementor.md +0 -161
  85. package/.genie/code/agents/install.md +0 -515
  86. package/.genie/code/agents/issue-creator.md +0 -344
  87. package/.genie/code/agents/polish.md +0 -116
  88. package/.genie/code/agents/qa.md +0 -653
  89. package/.genie/code/agents/refactor.md +0 -294
  90. package/.genie/code/agents/release.md +0 -1129
  91. package/.genie/code/agents/roadmap.md +0 -885
  92. package/.genie/code/agents/tests.md +0 -557
  93. package/.genie/code/agents/tracer.md +0 -50
  94. package/.genie/code/agents/update/upstream-update.md +0 -85
  95. package/.genie/code/agents/update/versions/generic-update.md +0 -305
  96. package/.genie/code/agents/vibe.md +0 -1317
  97. package/.genie/code/spells/agent-configuration.md +0 -58
  98. package/.genie/code/spells/automated-rc-publishing.md +0 -106
  99. package/.genie/code/spells/branch-tracker-guidance.md +0 -28
  100. package/.genie/code/spells/debug.md +0 -320
  101. package/.genie/code/spells/emoji-naming-convention.md +0 -303
  102. package/.genie/code/spells/evidence-storage.md +0 -26
  103. package/.genie/code/spells/file-naming-rules.md +0 -35
  104. package/.genie/code/spells/forge-code-blueprints.md +0 -195
  105. package/.genie/code/spells/genie-integration.md +0 -153
  106. package/.genie/code/spells/publishing-protocol.md +0 -61
  107. package/.genie/code/spells/team-consultation-protocol.md +0 -284
  108. package/.genie/code/spells/tool-requirements.md +0 -20
  109. package/.genie/code/spells/triad-maintenance-protocol.md +0 -154
  110. package/.genie/code/teams/tech-council/council.md +0 -328
  111. package/.genie/code/teams/tech-council/jt.md +0 -352
  112. package/.genie/code/teams/tech-council/nayr.md +0 -305
  113. package/.genie/code/teams/tech-council/oettam.md +0 -375
  114. package/.genie/neurons/README.md +0 -193
  115. package/.genie/neurons/forge.md +0 -106
  116. package/.genie/neurons/genie.md +0 -63
  117. package/.genie/neurons/review.md +0 -106
  118. package/.genie/neurons/wish.md +0 -104
  119. package/.genie/product/README.md +0 -20
  120. package/.genie/product/cli-automation.md +0 -359
  121. package/.genie/product/environment.md +0 -60
  122. package/.genie/product/mission.md +0 -60
  123. package/.genie/product/roadmap.md +0 -44
  124. package/.genie/product/tech-stack.md +0 -34
  125. package/.genie/product/templates/context-template.md +0 -218
  126. package/.genie/product/templates/qa-done-report-template.md +0 -68
  127. package/.genie/product/templates/review-report-template.md +0 -89
  128. package/.genie/product/templates/wish-template.md +0 -120
  129. package/.genie/scripts/helpers/analyze-commit.js +0 -195
  130. package/.genie/scripts/helpers/bullet-counter.js +0 -194
  131. package/.genie/scripts/helpers/bullet-find.js +0 -289
  132. package/.genie/scripts/helpers/bullet-id.js +0 -244
  133. package/.genie/scripts/helpers/check-secrets.js +0 -237
  134. package/.genie/scripts/helpers/count-tokens.js +0 -200
  135. package/.genie/scripts/helpers/create-frontmatter.js +0 -456
  136. package/.genie/scripts/helpers/detect-markers.js +0 -293
  137. package/.genie/scripts/helpers/detect-todos.js +0 -267
  138. package/.genie/scripts/helpers/detect-unlabeled-blocks.js +0 -135
  139. package/.genie/scripts/helpers/embeddings.js +0 -344
  140. package/.genie/scripts/helpers/find-empty-sections.js +0 -158
  141. package/.genie/scripts/helpers/index.js +0 -319
  142. package/.genie/scripts/helpers/validate-frontmatter.js +0 -578
  143. package/.genie/scripts/helpers/validate-links.js +0 -207
  144. package/.genie/scripts/helpers/validate-paths.js +0 -373
  145. package/.genie/spells/README.md +0 -9
  146. package/.genie/spells/ace-protocol.md +0 -118
  147. package/.genie/spells/ask-one-at-a-time.md +0 -175
  148. package/.genie/spells/backup-analyzer.md +0 -542
  149. package/.genie/spells/blocker.md +0 -12
  150. package/.genie/spells/break-things-move-fast.md +0 -56
  151. package/.genie/spells/context-candidates.md +0 -72
  152. package/.genie/spells/context-critic.md +0 -51
  153. package/.genie/spells/defer-to-expertise.md +0 -278
  154. package/.genie/spells/delegate-dont-do.md +0 -292
  155. package/.genie/spells/error-investigation-protocol.md +0 -328
  156. package/.genie/spells/evidence-based-completion.md +0 -273
  157. package/.genie/spells/experiment.md +0 -65
  158. package/.genie/spells/file-creation-protocol.md +0 -229
  159. package/.genie/spells/forge-integration.md +0 -281
  160. package/.genie/spells/forge-orchestration.md +0 -514
  161. package/.genie/spells/gather-context.md +0 -18
  162. package/.genie/spells/global-health-check.md +0 -34
  163. package/.genie/spells/global-noop-roundtrip.md +0 -25
  164. package/.genie/spells/install-genie.md +0 -1232
  165. package/.genie/spells/install.md +0 -82
  166. package/.genie/spells/investigate-before-commit.md +0 -112
  167. package/.genie/spells/know-yourself.md +0 -288
  168. package/.genie/spells/learn.md +0 -828
  169. package/.genie/spells/mcp-diagnostic-protocol.md +0 -246
  170. package/.genie/spells/mcp-first.md +0 -124
  171. package/.genie/spells/multi-step-execution.md +0 -67
  172. package/.genie/spells/orchestration-boundary-protocol.md +0 -256
  173. package/.genie/spells/orchestrator-not-implementor.md +0 -189
  174. package/.genie/spells/prompt.md +0 -746
  175. package/.genie/spells/reflect.md +0 -404
  176. package/.genie/spells/routing-decision-matrix.md +0 -368
  177. package/.genie/spells/run-in-parallel.md +0 -12
  178. package/.genie/spells/session-state-updater-example.md +0 -196
  179. package/.genie/spells/session-state-updater.md +0 -220
  180. package/.genie/spells/track-long-running-tasks.md +0 -133
  181. package/.genie/spells/troubleshoot-infrastructure.md +0 -176
  182. package/.genie/spells/upgrade-genie.md +0 -415
  183. package/.genie/spells/url-presentation-protocol.md +0 -301
  184. package/.genie/spells/wish-initiation.md +0 -158
  185. package/.genie/spells/wish-issue-linkage.md +0 -410
  186. package/.genie/spells/wish-lifecycle.md +0 -100
  187. package/.genie/state/provider-status.json +0 -3
  188. package/.genie/state/version.json +0 -16
  189. package/.genie/wishes/canonical-pgserve-pm2-supervision/WISH.md +0 -290
  190. package/.genie/wishes/pgserve-v2/BRIEF-from-genie-pgserve.md +0 -99
  191. package/.genie/wishes/pgserve-v2/WISH.md +0 -442
  192. package/.genie/wishes/release-system-genie-pattern/WISH.md +0 -268
  193. package/.genie/wishes/release-system-genie-pattern/validation.md +0 -205
  194. package/.gitguardian.yaml +0 -29
  195. package/.gitguardianignore +0 -16
  196. package/.github/workflows/ci.yml +0 -122
  197. package/.github/workflows/release.yml +0 -289
  198. package/.github/workflows/version.yml +0 -228
  199. package/.husky/pre-commit +0 -2
  200. package/AGENTS.md +0 -433
  201. package/CLAUDE.md +0 -1
  202. package/Makefile +0 -285
  203. package/assets/icon.ico +0 -0
  204. package/bun.lock +0 -435
  205. package/bunfig.toml +0 -28
  206. package/ecosystem.config.cjs +0 -23
  207. package/eslint.config.js +0 -63
  208. package/examples/multi-tenant-demo.js +0 -104
  209. package/install.sh +0 -123
  210. package/knip.json +0 -9
  211. package/tests/audit.test.js +0 -189
  212. package/tests/backpressure.test.js +0 -167
  213. package/tests/benchmarks/runner.js +0 -1197
  214. package/tests/benchmarks/vector-generator.js +0 -368
  215. package/tests/cli-install.test.js +0 -322
  216. package/tests/control-db.test.js +0 -285
  217. package/tests/daemon-args.test.js +0 -86
  218. package/tests/daemon-control.test.js +0 -171
  219. package/tests/daemon-fingerprint-integration.test.js +0 -111
  220. package/tests/daemon-pr24-regression.test.js +0 -198
  221. package/tests/fingerprint.test.js +0 -263
  222. package/tests/fixtures/240-orphan-seed.sql +0 -30
  223. package/tests/multi-tenant.test.js +0 -374
  224. package/tests/orphan-cleanup.test.js +0 -390
  225. package/tests/pg-version-regex.test.js +0 -129
  226. package/tests/quick-bench.js +0 -135
  227. package/tests/router-handshake-retry.test.js +0 -119
  228. package/tests/router-handshake-watchdog.test.js +0 -110
  229. package/tests/sdk.test.js +0 -71
  230. package/tests/stale-postmaster-pid.test.js +0 -85
  231. package/tests/stress-test.js +0 -439
  232. package/tests/sync-perf-test.js +0 -150
  233. package/tests/tcp-listen.test.js +0 -368
  234. package/tests/tenancy.test.js +0 -403
  235. 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
+ };