pgserve 2.2.0 → 2.2.2

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 (38) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/bin/pgserve-wrapper.cjs +1 -0
  3. package/console/dist/app.js +121 -0
  4. package/console/dist/index.html +13 -0
  5. package/package.json +12 -6
  6. package/scripts/postinstall.cjs +60 -0
  7. package/scripts/test-bun-self-heal.sh +163 -0
  8. package/scripts/test-npx.sh +60 -0
  9. package/src/cli-install.cjs +14 -0
  10. package/src/cli-ui.cjs +19 -2
  11. package/src/upgrade/index.js +65 -0
  12. package/src/upgrade/runner.js +23 -0
  13. package/src/upgrade/steps/binary-cache-flush.js +67 -0
  14. package/src/upgrade/steps/consumer-signal.js +40 -0
  15. package/src/upgrade/steps/env-refresh.js +89 -0
  16. package/src/upgrade/steps/health-validate.js +53 -0
  17. package/src/upgrade/steps/plpgsql-resolve.js +66 -0
  18. package/src/upgrade/steps/port-reconcile.js +52 -0
  19. package/console/README.md +0 -131
  20. package/console/api.js +0 -173
  21. package/console/app.jsx +0 -483
  22. package/console/components.jsx +0 -167
  23. package/console/data.jsx +0 -350
  24. package/console/index.html +0 -31
  25. package/console/screens/databases.jsx +0 -5
  26. package/console/screens/health.jsx +0 -5
  27. package/console/screens/ingress.jsx +0 -5
  28. package/console/screens/optimizer.jsx +0 -5
  29. package/console/screens/rlm-sim.jsx +0 -5
  30. package/console/screens/rlm-trace.jsx +0 -5
  31. package/console/screens/security.jsx +0 -5
  32. package/console/screens/settings.jsx +0 -611
  33. package/console/screens/sql.jsx +0 -5
  34. package/console/screens/sync.jsx +0 -5
  35. package/console/screens/tables.jsx +0 -5
  36. package/console/tweaks-panel.jsx +0 -425
  37. /package/console/{colors_and_type.css → dist/colors_and_type.css} +0 -0
  38. /package/console/{console.css → dist/console.css} +0 -0
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Step 4 — App env file refresh. Regenerates ~/.autopg/<name>.env with
3
+ * canonical port. Verifies SCRAM cred works; warns if rotation needed.
4
+ */
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { execSync } from 'node:child_process';
9
+
10
+ export const name = 'env-refresh';
11
+ const CANONICAL_PORT = 8432;
12
+
13
+ function getAutopgRoot() {
14
+ return process.env.AUTOPG_CONFIG_DIR || process.env.PGSERVE_CONFIG_DIR || `${process.env.HOME}/.autopg`;
15
+ }
16
+
17
+ function listAppEnvFiles() {
18
+ const root = getAutopgRoot();
19
+ if (!fs.existsSync(root)) return [];
20
+ return fs.readdirSync(root)
21
+ .filter((f) => f.endsWith('.env') && !f.startsWith('.'))
22
+ .map((f) => path.join(root, f));
23
+ }
24
+
25
+ function parseEnv(content) {
26
+ const out = {};
27
+ for (const line of content.split('\n')) {
28
+ const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
29
+ if (m) out[m[1]] = m[2].replace(/^"(.*)"$/, '$1');
30
+ }
31
+ return out;
32
+ }
33
+
34
+ function buildUrl({ user, password, port, db }) {
35
+ const enc = (s) => encodeURIComponent(s);
36
+ return `postgresql://${enc(user)}:${enc(password)}@127.0.0.1:${port}/${db}`;
37
+ }
38
+
39
+ function tryConnect(url) {
40
+ try {
41
+ execSync(`psql ${JSON.stringify(url)} -At -c "SELECT 1"`, { stdio: 'pipe', env: process.env });
42
+ return true;
43
+ } catch { return false; }
44
+ }
45
+
46
+ export async function plan() {
47
+ const files = listAppEnvFiles();
48
+ if (files.length === 0) return 'no app .env files found in autopg root';
49
+ return `would verify+rewrite ${files.length} env file(s): ${files.map((f) => path.basename(f)).join(', ')}`;
50
+ }
51
+
52
+ export async function execute({ warn }) {
53
+ const files = listAppEnvFiles();
54
+ if (files.length === 0) return { status: 'SKIP', detail: 'no .env files' };
55
+
56
+ let updated = 0, valid = 0;
57
+ for (const file of files) {
58
+ try {
59
+ const content = fs.readFileSync(file, 'utf8');
60
+ const env = parseEnv(content);
61
+ const url = env.DATABASE_URL || env.PG_URL || env.POSTGRES_URL;
62
+ if (!url) {
63
+ warn(`[env-refresh] ${path.basename(file)}: no DATABASE_URL key`);
64
+ continue;
65
+ }
66
+ const parsed = new URL(url);
67
+ const newUrl = buildUrl({
68
+ user: decodeURIComponent(parsed.username),
69
+ password: decodeURIComponent(parsed.password),
70
+ port: CANONICAL_PORT,
71
+ db: parsed.pathname.replace(/^\//, ''),
72
+ });
73
+
74
+ if (tryConnect(newUrl)) {
75
+ valid++;
76
+ if (newUrl !== url) {
77
+ const newContent = content.replace(/^(DATABASE_URL|PG_URL|POSTGRES_URL)=.*/m, `$1=${newUrl}`);
78
+ fs.writeFileSync(file, newContent, { mode: 0o600 });
79
+ updated++;
80
+ }
81
+ } else {
82
+ warn(`[env-refresh] ${path.basename(file)}: SCRAM cred fails — rotate via \`autopg rotate <app>\``);
83
+ }
84
+ } catch (err) {
85
+ warn(`[env-refresh] ${path.basename(file)} failed: ${err.message}`);
86
+ }
87
+ }
88
+ return { status: 'OK', detail: `validated ${valid}/${files.length}, rewrote ${updated}` };
89
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Step 6 — Health validation. pg_isready + per-DB plpgsql smoke test.
3
+ */
4
+
5
+ import { execSync } from 'node:child_process';
6
+
7
+ export const name = 'health-validate';
8
+ const CANONICAL_PORT = 8432;
9
+ const SYSTEM_DBS = new Set(['template0', 'template1']);
10
+
11
+ function pgIsReady() {
12
+ try { execSync(`pg_isready -h 127.0.0.1 -p ${CANONICAL_PORT}`, { stdio: 'pipe' }); return true; }
13
+ catch { return false; }
14
+ }
15
+
16
+ function listAllDbs() {
17
+ const env = { ...process.env, PGPASSWORD: process.env.PGPASSWORD || 'postgres' };
18
+ const out = execSync(
19
+ `psql -h 127.0.0.1 -p ${CANONICAL_PORT} -U postgres -At -c "SELECT datname FROM pg_database WHERE NOT datistemplate"`,
20
+ { env, stdio: ['ignore', 'pipe', 'pipe'] },
21
+ ).toString().trim();
22
+ return out ? out.split('\n').filter(Boolean) : [];
23
+ }
24
+
25
+ function plpgsqlSmoke(db) {
26
+ try {
27
+ const env = { ...process.env, PGPASSWORD: process.env.PGPASSWORD || 'postgres' };
28
+ execSync(
29
+ `psql -h 127.0.0.1 -p ${CANONICAL_PORT} -U postgres -d ${db} -At -c "DO \\$\\$ BEGIN RAISE NOTICE 'ok'; END; \\$\\$"`,
30
+ { env, stdio: 'pipe' },
31
+ );
32
+ return true;
33
+ } catch { return false; }
34
+ }
35
+
36
+ export async function plan() {
37
+ return `would check pg_isready on :${CANONICAL_PORT} + plpgsql smoke test in each user DB`;
38
+ }
39
+
40
+ export async function execute({ warn }) {
41
+ if (!pgIsReady()) return { status: 'FAIL', detail: `pg_isready failed on port ${CANONICAL_PORT}` };
42
+ let dbs;
43
+ try { dbs = listAllDbs(); } catch (err) { return { status: 'FAIL', detail: `cannot list DBs: ${err.message}` }; }
44
+
45
+ let pass = 0, fail = 0;
46
+ for (const db of dbs) {
47
+ if (SYSTEM_DBS.has(db)) continue;
48
+ if (plpgsqlSmoke(db)) pass++;
49
+ else { fail++; warn(`[health-validate] plpgsql smoke FAIL in ${db}`); }
50
+ }
51
+ if (fail > 0) return { status: 'FAIL', detail: `${pass}/${pass + fail} DBs healthy; ${fail} failure(s)` };
52
+ return { status: 'OK', detail: `pg_isready OK, plpgsql healthy in ${pass}/${pass} DBs` };
53
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Step 3 — plpgsql extension re-resolve.
3
+ * DROP+CREATE plpgsql per user DB to refresh `.so` path against current $libdir.
4
+ * Skips DBs with user-owned plpgsql functions (CASCADE would drop them).
5
+ */
6
+
7
+ import { execSync } from 'node:child_process';
8
+
9
+ export const name = 'plpgsql-resolve';
10
+ const CANONICAL_PORT = 8432;
11
+ const SYSTEM_DBS = new Set(['postgres', 'template0', 'template1']);
12
+
13
+ function pgQuery({ db, sql, captureStdout = false }) {
14
+ const env = { ...process.env, PGPASSWORD: process.env.PGPASSWORD || 'postgres' };
15
+ const cmd = `psql -h 127.0.0.1 -p ${CANONICAL_PORT} -U postgres -d ${db} -At -c ${JSON.stringify(sql)}`;
16
+ return captureStdout
17
+ ? execSync(cmd, { env, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim()
18
+ : execSync(cmd, { env, stdio: 'pipe' });
19
+ }
20
+
21
+ function listUserDbs() {
22
+ const out = pgQuery({
23
+ db: 'postgres',
24
+ sql: "SELECT datname FROM pg_database WHERE NOT datistemplate AND datname != 'postgres' ORDER BY datname",
25
+ captureStdout: true,
26
+ });
27
+ return out ? out.split('\n').filter(Boolean) : [];
28
+ }
29
+
30
+ function hasUserOwnedPlpgsqlFunctions(db) {
31
+ const out = pgQuery({
32
+ db,
33
+ sql: "SELECT count(*) FROM pg_proc p JOIN pg_language l ON p.prolang = l.oid WHERE l.lanname = 'plpgsql' AND p.proowner != 10",
34
+ captureStdout: true,
35
+ });
36
+ return parseInt(out, 10) > 0;
37
+ }
38
+
39
+ export async function plan() {
40
+ let dbs;
41
+ try { dbs = listUserDbs(); } catch (err) { return `cannot enumerate DBs: ${err.message}`; }
42
+ return `would DROP+CREATE plpgsql in ${dbs.length} user DB(s): ${dbs.join(', ')}`;
43
+ }
44
+
45
+ export async function execute({ warn }) {
46
+ let dbs;
47
+ try { dbs = listUserDbs(); } catch (err) { return { status: 'FAIL', detail: `cannot enumerate DBs: ${err.message}` }; }
48
+ if (dbs.length === 0) return { status: 'SKIP', detail: 'no user DBs to refresh' };
49
+
50
+ let refreshed = 0, skipped = 0;
51
+ for (const db of dbs) {
52
+ if (SYSTEM_DBS.has(db)) { skipped++; continue; }
53
+ try {
54
+ if (hasUserOwnedPlpgsqlFunctions(db)) {
55
+ warn(`[plpgsql-resolve] skip ${db}: user-owned plpgsql functions present (DROP CASCADE would lose them)`);
56
+ skipped++; continue;
57
+ }
58
+ pgQuery({ db, sql: 'DROP EXTENSION IF EXISTS plpgsql CASCADE; CREATE EXTENSION plpgsql' });
59
+ refreshed++;
60
+ } catch (err) {
61
+ warn(`[plpgsql-resolve] ${db} failed: ${err.message}`);
62
+ skipped++;
63
+ }
64
+ }
65
+ return { status: 'OK', detail: `refreshed ${refreshed} DB(s), skipped ${skipped}` };
66
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Step 1 — Port reconciliation. Ensures pgserve listens on canonical 8432.
3
+ * If running on a different port, stop and relaunch on 8432. Idempotent.
4
+ */
5
+
6
+ import { execSync } from 'node:child_process';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ export const name = 'port-reconcile';
11
+ const CANONICAL_PORT = 8432;
12
+
13
+ function readPostmasterPid(dataDir) {
14
+ try {
15
+ const content = fs.readFileSync(path.join(dataDir, 'postmaster.pid'), 'utf8');
16
+ const lines = content.trim().split('\n');
17
+ return { pid: parseInt(lines[0], 10), port: parseInt(lines[3], 10) };
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function getDataDir() {
24
+ return process.env.PGSERVE_DATA || `${process.env.HOME}/.pgserve/data`;
25
+ }
26
+
27
+ export async function plan() {
28
+ const info = readPostmasterPid(getDataDir());
29
+ if (!info) return 'no running pgserve detected — nothing to reconcile';
30
+ if (info.port === CANONICAL_PORT) return `already on port ${CANONICAL_PORT}, no action needed`;
31
+ return `would stop pgserve PID ${info.pid} (port ${info.port}) and relaunch on ${CANONICAL_PORT}`;
32
+ }
33
+
34
+ export async function execute({ log, warn }) {
35
+ const info = readPostmasterPid(getDataDir());
36
+ if (!info) return { status: 'SKIP', detail: 'no running pgserve' };
37
+ if (info.port === CANONICAL_PORT) return { status: 'SKIP', detail: `already on ${CANONICAL_PORT}` };
38
+
39
+ log(`stopping pgserve PID ${info.pid} (port ${info.port})`);
40
+ try {
41
+ execSync(`pm2 restart pgserve --update-env -- --port ${CANONICAL_PORT}`, { stdio: 'pipe' });
42
+ return { status: 'OK', detail: `pm2 restart pgserve on port ${CANONICAL_PORT}` };
43
+ } catch (pm2Err) {
44
+ warn(`pm2 restart failed (${pm2Err.message}) — falling back to direct pg_ctl`);
45
+ try {
46
+ execSync(`pg_ctl -D ${getDataDir()} -m fast stop`, { stdio: 'pipe' });
47
+ return { status: 'OK', detail: `stopped pgserve; relaunch via pm2 or autopg install` };
48
+ } catch (ctlErr) {
49
+ throw new Error(`port reconcile failed: pm2 (${pm2Err.message}) and pg_ctl (${ctlErr.message})`);
50
+ }
51
+ }
52
+ }
package/console/README.md DELETED
@@ -1,131 +0,0 @@
1
- # `console/` — autopg console
2
-
3
- Local web console served by `autopg ui`. React + Babel via CDN, no build
4
- step. Single-user dev tool — binds 127.0.0.1 only, no auth, no TLS.
5
-
6
- ## Run
7
-
8
- ```bash
9
- autopg ui # walks 8433–8533 picking the first free port
10
- autopg ui --port 8500 # bind exactly 8500 or fail
11
- autopg ui --no-open # skip browser launch (CI / headless)
12
- ```
13
-
14
- `pgserve ui …` is a forever alias of the same command.
15
-
16
- The server boots in-process via `node:http` and serves this directory as
17
- its document root. Four helper endpoints are mounted alongside the static
18
- assets — every mutation shells out to the CLI rather than calling the
19
- daemon directly, so the console works with or without a running daemon.
20
-
21
- | Endpoint | Backed by |
22
- |----------|-----------|
23
- | `GET /api/settings` | `loadEffectiveConfig()` → `{ settings, sources, etag }` |
24
- | `PUT /api/settings` | `writeSettings(body, { ifMatch })` (409 on stale `If-Match`) |
25
- | `POST /api/restart` | `cli-restart.cjs` (pm2-aware) |
26
- | `GET /api/status` | shells out to `autopg status --json` |
27
-
28
- ## Layout
29
-
30
- ```
31
- console/
32
- ├── README.md # this file
33
- ├── index.html # entry — pinned React + Babel CDN scripts
34
- ├── app.jsx # shell + sidebar router (11 routes)
35
- ├── api.js # fetch wrapper, holds latest etag, surfaces ETAG_MISMATCH
36
- ├── components.jsx # shared widgets (Seg, Toggle, Field, …)
37
- ├── data.jsx # demo data fixtures (used by placeholder screens)
38
- ├── tweaks-panel.jsx # theme/phosphor/density/CRT toggles (persists to settings.ui)
39
- ├── colors_and_type.css # design tokens
40
- ├── console.css # layout + screen styles
41
- └── screens/
42
- ├── settings.jsx # ✅ functional — 6-section schema editor
43
- ├── databases.jsx # [ coming soon ]
44
- ├── tables.jsx # [ coming soon ]
45
- ├── sql.jsx # [ coming soon ]
46
- ├── optimizer.jsx # [ coming soon ]
47
- ├── security.jsx # [ coming soon ]
48
- ├── ingress.jsx # [ coming soon ]
49
- ├── health.jsx # [ coming soon ] — next wish
50
- ├── sync.jsx # [ coming soon ]
51
- ├── rlm-trace.jsx # [ coming soon ]
52
- └── rlm-sim.jsx # [ coming soon ]
53
- ```
54
-
55
- ## Screen rollout
56
-
57
- | Screen | Status | Notes |
58
- |--------|--------|-------|
59
- | Settings | ✅ functional | 6 sections, type-aware controls, raw GUC passthrough, etag concurrency, env-override chip |
60
- | Health | 🟡 next | Live cluster health metrics — next wish |
61
- | Databases | ⚪ placeholder | List + create + drop |
62
- | Tables | ⚪ placeholder | Per-DB table inspector |
63
- | SQL | ⚪ placeholder | Ad-hoc query runner |
64
- | Optimizer | ⚪ placeholder | Plan inspector / GUC tuner suggestions |
65
- | Security | ⚪ placeholder | Roles, RLS, audit log |
66
- | Ingress | ⚪ placeholder | Listener / TLS / token surface |
67
- | Sync | ⚪ placeholder | Replication-slot status |
68
- | RLM-trace | ⚪ placeholder | RLM agent trace viewer (depends on rlmx) |
69
- | RLM-sim | ⚪ placeholder | RLM scenario simulator (depends on rlmx) |
70
-
71
- ## Local dev loop
72
-
73
- The console is shipped as static files — no build, no bundler. Edit the
74
- `.jsx` files in place; refresh the browser tab to pick up the change
75
- (Babel transpiles in the browser at load time). The CDN scripts are
76
- pinned with SRI integrity hashes — bumping React or Babel requires
77
- re-pinning the matching `integrity="sha384-…"` attribute in
78
- [`index.html`](./index.html).
79
-
80
- ```bash
81
- autopg ui --no-open --port 8500 &
82
- open http://127.0.0.1:8500
83
- # … edit screens/settings.jsx, refresh browser …
84
- kill %1
85
- ```
86
-
87
- The Settings screen reads live state from `~/.autopg/settings.json`
88
- through the helper endpoints, so changes survive reload and round-trip
89
- through `autopg config get` from another shell.
90
-
91
- ### Concurrency model
92
-
93
- `api.js` stores the etag returned by every successful GET and sends it
94
- back as `If-Match` on the next PUT. If a parallel `autopg config set` (or
95
- another browser tab) drifts the file, the PUT comes back as
96
- `409 ETAG_MISMATCH` with a fresh `currentEtag`. The Settings screen
97
- catches this and shows a "settings changed, reload?" banner instead of
98
- overwriting the operator's other changes.
99
-
100
- ### Env-override chip
101
-
102
- `GET /api/settings` returns a `sources` map (one entry per leaf:
103
- `'default' | 'file' | 'env:<NAME>'`). Rows whose source starts with
104
- `env:` render a yellow `OVERRIDDEN BY ENV` chip — Save still writes the
105
- file, but `loadEffectiveConfig()` will keep returning the env value
106
- until the env var is unset or the daemon is restarted with a clean
107
- environment.
108
-
109
- ## Design system
110
-
111
- The console UI is derived from the `pgserve-console` design kit at
112
- `namastex-design-system/ui_kits/pgserve-console`. The CSS files
113
- (`colors_and_type.css`, `console.css`) and the shared widgets
114
- (`components.jsx`, `tweaks-panel.jsx`) are copied verbatim — the
115
- soft rename only touches the topbar identity (`pgserve` → `autopg`)
116
- and the Settings screen, which was rewritten to match the
117
- 6-section schema documented in
118
- [`docs/settings-schema.md`](../docs/settings-schema.md).
119
-
120
- ## What's deliberately not here
121
-
122
- - **No build step.** Pre-bundling is a future optimization. The CDN +
123
- Babel-in-browser path is intentional for v1 — zero infrastructure.
124
- - **No daemon HTTP API.** The CLI is the source of truth; every UI
125
- mutation shells out. This means the UI works ahead of `autopg
126
- install` (you can configure before the daemon ever runs) and
127
- cannot leak privileges through a long-lived listening socket.
128
- - **No multi-user / multi-machine access.** 127.0.0.1 only, by
129
- design.
130
- - **No telemetry, no analytics.** Static page + four endpoints, all
131
- local.
package/console/api.js DELETED
@@ -1,173 +0,0 @@
1
- /* autopg console · API client.
2
- *
3
- * Wraps the four helper endpoints exposed by `autopg ui`:
4
- * GET /api/settings → { settings, sources, etag, path }
5
- * PUT /api/settings → { ok, etag } | { error: { code, message, field? } }
6
- * POST /api/restart → { ok } | { error }
7
- * GET /api/status → whatever `pgserve status --json` returns
8
- *
9
- * The latest etag from a successful GET is cached on the module so PUTs can
10
- * send `If-Match` without the caller threading it through manually. PUT
11
- * replies update the cached etag too so successive saves chain cleanly.
12
- *
13
- * Errors from the server come back as `{ error: { code, message, field? } }`.
14
- * The wrapper raises a structured `ApiError` (with `.code`, `.field`,
15
- * `.message`, `.status`, `.currentEtag?`) so screens can branch on the code
16
- * without parsing strings. ETAG_MISMATCH is surfaced as a normal rejection
17
- * with `error.code === 'ETAG_MISMATCH'` plus `error.currentEtag` so the
18
- * Settings screen can show a "settings changed, reload?" banner.
19
- */
20
- (function (root) {
21
- 'use strict';
22
-
23
- const STATE = { etag: null };
24
-
25
- class ApiError extends Error {
26
- constructor({ code, message, field, status, currentEtag }) {
27
- super(message || code || 'api error');
28
- this.name = 'ApiError';
29
- this.code = code || 'UNKNOWN';
30
- if (field) this.field = field;
31
- if (typeof status === 'number') this.status = status;
32
- if (currentEtag) this.currentEtag = currentEtag;
33
- }
34
- }
35
-
36
- async function parseJson(res) {
37
- const text = await res.text();
38
- if (!text) return {};
39
- try {
40
- return JSON.parse(text);
41
- } catch {
42
- return { _raw: text };
43
- }
44
- }
45
-
46
- async function getSettings() {
47
- const res = await fetch('/api/settings', {
48
- method: 'GET',
49
- headers: { accept: 'application/json' },
50
- cache: 'no-store',
51
- });
52
- const body = await parseJson(res);
53
- if (!res.ok) {
54
- throw new ApiError({
55
- code: body?.error?.code,
56
- message: body?.error?.message,
57
- field: body?.error?.field,
58
- status: res.status,
59
- });
60
- }
61
- if (body && body.etag) STATE.etag = body.etag;
62
- return body;
63
- }
64
-
65
- async function putSettings(patch, { ifMatch } = {}) {
66
- const etag = ifMatch ?? STATE.etag;
67
- if (!etag) {
68
- throw new ApiError({
69
- code: 'PRECONDITION_REQUIRED',
70
- message: 'no etag cached — call getSettings() before putSettings()',
71
- status: 428,
72
- });
73
- }
74
- const res = await fetch('/api/settings', {
75
- method: 'PUT',
76
- headers: {
77
- 'content-type': 'application/json',
78
- 'if-match': etag,
79
- accept: 'application/json',
80
- },
81
- body: JSON.stringify(patch ?? {}),
82
- });
83
- const body = await parseJson(res);
84
- if (res.status === 409) {
85
- // Update the cached etag so the next reload has the latest.
86
- if (body && body.currentEtag) STATE.etag = body.currentEtag;
87
- throw new ApiError({
88
- code: body?.error?.code || 'ETAG_MISMATCH',
89
- message: body?.error?.message || 'settings changed on disk',
90
- status: 409,
91
- currentEtag: body?.currentEtag,
92
- });
93
- }
94
- if (!res.ok) {
95
- throw new ApiError({
96
- code: body?.error?.code,
97
- message: body?.error?.message,
98
- field: body?.error?.field,
99
- status: res.status,
100
- });
101
- }
102
- if (body && body.etag) STATE.etag = body.etag;
103
- return body;
104
- }
105
-
106
- async function restart() {
107
- const res = await fetch('/api/restart', {
108
- method: 'POST',
109
- headers: { accept: 'application/json' },
110
- });
111
- const body = await parseJson(res);
112
- if (!res.ok) {
113
- throw new ApiError({
114
- code: body?.error?.code,
115
- message: body?.error?.message,
116
- status: res.status,
117
- });
118
- }
119
- return body;
120
- }
121
-
122
- async function getStatus() {
123
- const res = await fetch('/api/status', {
124
- method: 'GET',
125
- headers: { accept: 'application/json' },
126
- cache: 'no-store',
127
- });
128
- const body = await parseJson(res);
129
- if (!res.ok) {
130
- throw new ApiError({
131
- code: body?.error?.code,
132
- message: body?.error?.message,
133
- status: res.status,
134
- });
135
- }
136
- return body;
137
- }
138
-
139
- // Live pgserve stats — connections + databases. Always returns a body
140
- // (status 200) even when the daemon is unreachable; check `body.ok`.
141
- // Safe to poll from the topbar without try/catch error handling.
142
- async function getStats() {
143
- try {
144
- const res = await fetch('/api/stats', {
145
- method: 'GET',
146
- headers: { accept: 'application/json' },
147
- cache: 'no-store',
148
- });
149
- return await parseJson(res);
150
- } catch (err) {
151
- return { ok: false, reason: 'fetch-failed', message: err.message };
152
- }
153
- }
154
-
155
- function getCachedEtag() {
156
- return STATE.etag;
157
- }
158
-
159
- function setCachedEtag(etag) {
160
- STATE.etag = etag || null;
161
- }
162
-
163
- root.AutopgApi = {
164
- getSettings,
165
- putSettings,
166
- restart,
167
- getStatus,
168
- getStats,
169
- getCachedEtag,
170
- setCachedEtag,
171
- ApiError,
172
- };
173
- })(typeof window !== 'undefined' ? window : globalThis);