hazo_env 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,406 @@
1
+ // hazo_env/src/envsync/service.ts — HTTP front-end for envsync ("hazo-env serve")
2
+ //
3
+ // Plain node:http server exposing downloadDb/uploadDb/downloadFiles/uploadFiles
4
+ // over a tiny JSON API plus a self-contained HTML control page. This package
5
+ // only depends on dotenv + picocolors (see package.json) — no framework, so
6
+ // http.createServer is the right level of effort here, matching the
7
+ // "self-contained ops tool" goal from the envsync plan.
8
+ //
9
+ // The CLI (`hazo-env sync ...`) and this service are two independent
10
+ // front-ends over the same envsync/engine.ts functions — neither wraps the
11
+ // other.
12
+ //
13
+ // Security model:
14
+ // - Every route requires `Authorization: Bearer <HAZO_ENVSYNC_TOKEN>`,
15
+ // including GET /status and GET / — simpler and safer than deciding
16
+ // which routes are "safe" to leave open for a single-operator ops tool.
17
+ // (Caveat: this means the control page's *own* initial load also needs
18
+ // the header, which a plain browser navigation can't set — operators are
19
+ // expected to reach it via a client that can set headers, e.g. curl, an
20
+ // HTTP client, or a browser extension. The page's client-side JS only
21
+ // needs the token for the fetch() calls it makes after that.)
22
+ // - Binds to 127.0.0.1 by default (HAZO_ENVSYNC_BIND to override).
23
+ // - Every write op runs inside envsync/lock.ts's withLock — a second
24
+ // concurrent request gets 409, never a racing second pg_dump/tar.
25
+ // - Destructive ops (uploadDb, uploadFiles when confirm:true) go through
26
+ // the same assertConfirmed/assertNotProd guards the CLI uses — the HTTP
27
+ // layer adds no bypass.
28
+ import http from 'node:http';
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import { resolveEnvsyncConfig } from '../resolve/envsync.js';
32
+ import { downloadDb, uploadDb, downloadFiles, uploadFiles } from './engine.js';
33
+ import { withLock, EnvsyncLockError } from './lock.js';
34
+ import { pruneWorkDir } from './retention.js';
35
+ // Arbitrary high port in the range unlikely to collide with other local
36
+ // services; no particular significance beyond "pick one and document it".
37
+ const DEFAULT_PORT = 7845;
38
+ const DEFAULT_BIND = '127.0.0.1';
39
+ // Caps a JSON request body at 1MB — these bodies are a couple of string
40
+ // fields at most, this is purely an abuse/DoS guard.
41
+ const MAX_BODY_BYTES = 1024 * 1024;
42
+ // ────────────────────────────────────────────────────────────────────────────
43
+ // small HTTP helpers
44
+ // ────────────────────────────────────────────────────────────────────────────
45
+ function sendJson(res, status, body) {
46
+ const text = JSON.stringify(body);
47
+ res.writeHead(status, {
48
+ 'Content-Type': 'application/json; charset=utf-8',
49
+ 'Content-Length': Buffer.byteLength(text),
50
+ });
51
+ res.end(text);
52
+ }
53
+ function sendHtml(res, status, html) {
54
+ res.writeHead(status, {
55
+ 'Content-Type': 'text/html; charset=utf-8',
56
+ 'Content-Length': Buffer.byteLength(html),
57
+ });
58
+ res.end(html);
59
+ }
60
+ /**
61
+ * Errors thrown by the guard helpers (assertConfirmed/assertNotProd) always
62
+ * start with "Refusing" — that's the cheapest reliable way to tell a
63
+ * validation-shaped guard error apart from an unexpected one without a
64
+ * dedicated error class, so guard errors map to 400 and everything else
65
+ * (including real pg_dump/tar failures) maps to 500.
66
+ */
67
+ function sendEngineError(res, err) {
68
+ if (err instanceof EnvsyncLockError) {
69
+ sendJson(res, 409, { error: 'locked', message: err.message });
70
+ return;
71
+ }
72
+ const message = err instanceof Error ? err.message : String(err);
73
+ if (message.startsWith('Refusing')) {
74
+ sendJson(res, 400, { error: message });
75
+ return;
76
+ }
77
+ sendJson(res, 500, { error: message });
78
+ }
79
+ function readJsonBody(req) {
80
+ return new Promise((resolve, reject) => {
81
+ let size = 0;
82
+ const chunks = [];
83
+ req.on('data', (chunk) => {
84
+ size += chunk.length;
85
+ if (size > MAX_BODY_BYTES) {
86
+ req.destroy();
87
+ reject(new Error('request body too large'));
88
+ return;
89
+ }
90
+ chunks.push(chunk);
91
+ });
92
+ req.on('end', () => {
93
+ const text = Buffer.concat(chunks).toString('utf8').trim();
94
+ if (!text) {
95
+ resolve({});
96
+ return;
97
+ }
98
+ try {
99
+ const parsed = JSON.parse(text);
100
+ resolve(parsed && typeof parsed === 'object' ? parsed : {});
101
+ }
102
+ catch {
103
+ reject(new Error('invalid JSON request body'));
104
+ }
105
+ });
106
+ req.on('error', reject);
107
+ });
108
+ }
109
+ function requireConfig(res) {
110
+ const cfg = resolveEnvsyncConfig();
111
+ if (!cfg) {
112
+ sendJson(res, 500, { error: 'envsync not configured — missing [envsync] section in hazo_env_config.ini' });
113
+ return null;
114
+ }
115
+ return cfg;
116
+ }
117
+ function progressLogger(op) {
118
+ return (msg) => console.log(`[envsync:${op}] ${msg}`);
119
+ }
120
+ // ────────────────────────────────────────────────────────────────────────────
121
+ // routes
122
+ // ────────────────────────────────────────────────────────────────────────────
123
+ function handleStatus(res) {
124
+ const cfg = resolveEnvsyncConfig();
125
+ if (!cfg) {
126
+ sendJson(res, 200, { status: 'unconfigured' });
127
+ return;
128
+ }
129
+ const lockPath = path.join(cfg.work_dir, '.envsync.lock');
130
+ if (!fs.existsSync(lockPath)) {
131
+ sendJson(res, 200, { status: 'idle' });
132
+ return;
133
+ }
134
+ try {
135
+ const data = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
136
+ sendJson(res, 200, { status: 'busy', op: data.op, since: data.timestamp });
137
+ }
138
+ catch {
139
+ // Missing/unreadable lockfile — treat as idle. Actual staleness
140
+ // enforcement (dead-PID clearing) happens inside withLock when an op is
141
+ // attempted; this is just a best-effort status read.
142
+ sendJson(res, 200, { status: 'idle' });
143
+ }
144
+ }
145
+ async function handleDbDownload(res) {
146
+ const cfg = requireConfig(res);
147
+ if (!cfg)
148
+ return;
149
+ try {
150
+ const result = await withLock(cfg.work_dir, 'download_db', () => downloadDb(cfg, { onProgress: progressLogger('download_db') }));
151
+ pruneWorkDir(cfg.work_dir, { keep: cfg.keep });
152
+ sendJson(res, 200, { ok: true, result });
153
+ }
154
+ catch (err) {
155
+ sendEngineError(res, err);
156
+ }
157
+ }
158
+ async function handleDbUpload(req, res) {
159
+ const cfg = requireConfig(res);
160
+ if (!cfg)
161
+ return;
162
+ let body;
163
+ try {
164
+ body = await readJsonBody(req);
165
+ }
166
+ catch (err) {
167
+ sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
168
+ return;
169
+ }
170
+ const dumpPath = body['dumpPath'];
171
+ if (typeof dumpPath !== 'string' || !dumpPath) {
172
+ sendJson(res, 400, { error: 'dumpPath is required' });
173
+ return;
174
+ }
175
+ try {
176
+ const result = await withLock(cfg.work_dir, 'upload_db', () => uploadDb(cfg, dumpPath, {
177
+ confirm: body['confirm'] === true,
178
+ allowProd: body['allowProd'] === true,
179
+ onProgress: progressLogger('upload_db'),
180
+ }));
181
+ sendJson(res, 200, result);
182
+ }
183
+ catch (err) {
184
+ sendEngineError(res, err);
185
+ }
186
+ }
187
+ async function handleFilesArchive(res) {
188
+ const cfg = requireConfig(res);
189
+ if (!cfg)
190
+ return;
191
+ try {
192
+ const result = await withLock(cfg.work_dir, 'download_files', () => downloadFiles(cfg, { onProgress: progressLogger('download_files') }));
193
+ pruneWorkDir(cfg.work_dir, { keep: cfg.keep });
194
+ sendJson(res, 200, { ok: true, result });
195
+ }
196
+ catch (err) {
197
+ sendEngineError(res, err);
198
+ }
199
+ }
200
+ async function handleFilesRestore(req, res) {
201
+ const cfg = requireConfig(res);
202
+ if (!cfg)
203
+ return;
204
+ let body;
205
+ try {
206
+ body = await readJsonBody(req);
207
+ }
208
+ catch (err) {
209
+ sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
210
+ return;
211
+ }
212
+ const archivePath = body['archivePath'];
213
+ if (typeof archivePath !== 'string' || !archivePath) {
214
+ sendJson(res, 400, { error: 'archivePath is required' });
215
+ return;
216
+ }
217
+ try {
218
+ // uploadFiles itself returns { ok: false, diff } for the no-confirm
219
+ // dry-run preview — that's a normal 200, not an error.
220
+ const result = await withLock(cfg.work_dir, 'upload_files', () => uploadFiles(cfg, archivePath, {
221
+ confirm: body['confirm'] === true,
222
+ allowProd: body['allowProd'] === true,
223
+ onProgress: progressLogger('upload_files'),
224
+ }));
225
+ sendJson(res, 200, result);
226
+ }
227
+ catch (err) {
228
+ sendEngineError(res, err);
229
+ }
230
+ }
231
+ function controlPageHtml() {
232
+ return `<!doctype html>
233
+ <html lang="en">
234
+ <head>
235
+ <meta charset="utf-8" />
236
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
237
+ <title>hazo_env — envsync control</title>
238
+ <style>
239
+ :root { color-scheme: light dark; }
240
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; }
241
+ h1 { font-size: 1.25rem; }
242
+ h2 { font-size: 1rem; }
243
+ section { margin-bottom: 1.5rem; padding: 1rem; border: 1px solid #8884; border-radius: 8px; }
244
+ button { padding: 0.5rem 1rem; margin-right: 0.5rem; cursor: pointer; }
245
+ input[type="text"], input[type="password"] { padding: 0.4rem; width: 100%; box-sizing: border-box; margin-bottom: 0.5rem; }
246
+ pre { background: #8881; padding: 0.75rem; border-radius: 6px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
247
+ label { font-weight: 600; }
248
+ .row { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; flex-wrap: wrap; }
249
+ </style>
250
+ </head>
251
+ <body>
252
+ <h1>hazo_env — envsync control</h1>
253
+
254
+ <section>
255
+ <label for="token">Bearer token</label>
256
+ <div class="row">
257
+ <input type="password" id="token" placeholder="HAZO_ENVSYNC_TOKEN" />
258
+ <button id="saveToken" type="button">Save</button>
259
+ </div>
260
+ <div id="status">status: (unknown)</div>
261
+ </section>
262
+
263
+ <section>
264
+ <h2>Database</h2>
265
+ <div class="row">
266
+ <button id="dbDownload" type="button">Download DB</button>
267
+ </div>
268
+ <div class="row">
269
+ <input type="text" id="dumpPath" placeholder="/path/to/db-&lt;id&gt;.pgdump" />
270
+ </div>
271
+ <div class="row">
272
+ <label><input type="checkbox" id="dbConfirm" /> confirm</label>
273
+ <label><input type="checkbox" id="dbAllowProd" /> allow prod</label>
274
+ <button id="dbUpload" type="button">Upload DB</button>
275
+ </div>
276
+ </section>
277
+
278
+ <section>
279
+ <h2>Files</h2>
280
+ <div class="row">
281
+ <button id="filesArchive" type="button">Archive Files</button>
282
+ </div>
283
+ <div class="row">
284
+ <input type="text" id="archivePath" placeholder="/path/to/files-&lt;id&gt;.tar.gz" />
285
+ </div>
286
+ <div class="row">
287
+ <label><input type="checkbox" id="filesConfirm" /> confirm</label>
288
+ <label><input type="checkbox" id="filesAllowProd" /> allow prod</label>
289
+ <button id="filesRestore" type="button">Restore Files</button>
290
+ </div>
291
+ </section>
292
+
293
+ <pre id="output">(no output yet)</pre>
294
+
295
+ <script>
296
+ function getToken() { return sessionStorage.getItem('hazo_envsync_token') || ''; }
297
+ document.getElementById('token').value = getToken();
298
+ document.getElementById('saveToken').addEventListener('click', function () {
299
+ sessionStorage.setItem('hazo_envsync_token', document.getElementById('token').value);
300
+ refreshStatus();
301
+ });
302
+
303
+ function call(method, urlPath, body) {
304
+ var headers = { 'Content-Type': 'application/json' };
305
+ var token = getToken();
306
+ if (token) headers['Authorization'] = 'Bearer ' + token;
307
+ return fetch(urlPath, { method: method, headers: headers, body: body ? JSON.stringify(body) : undefined })
308
+ .then(function (res) { return res.json().catch(function () { return { error: 'invalid response' }; }); })
309
+ .then(function (json) {
310
+ document.getElementById('output').textContent = JSON.stringify(json, null, 2);
311
+ return json;
312
+ })
313
+ .catch(function (err) {
314
+ document.getElementById('output').textContent = 'request failed: ' + err;
315
+ });
316
+ }
317
+
318
+ function refreshStatus() {
319
+ call('GET', '/status').then(function (json) {
320
+ document.getElementById('status').textContent = 'status: ' + JSON.stringify(json);
321
+ });
322
+ }
323
+
324
+ document.getElementById('dbDownload').addEventListener('click', function () { call('POST', '/db/download'); });
325
+ document.getElementById('dbUpload').addEventListener('click', function () {
326
+ call('POST', '/db/upload', {
327
+ dumpPath: document.getElementById('dumpPath').value,
328
+ confirm: document.getElementById('dbConfirm').checked,
329
+ allowProd: document.getElementById('dbAllowProd').checked,
330
+ });
331
+ });
332
+ document.getElementById('filesArchive').addEventListener('click', function () { call('POST', '/files/archive'); });
333
+ document.getElementById('filesRestore').addEventListener('click', function () {
334
+ call('POST', '/files/restore', {
335
+ archivePath: document.getElementById('archivePath').value,
336
+ confirm: document.getElementById('filesConfirm').checked,
337
+ allowProd: document.getElementById('filesAllowProd').checked,
338
+ });
339
+ });
340
+
341
+ refreshStatus();
342
+ </script>
343
+ </body>
344
+ </html>`;
345
+ }
346
+ // ────────────────────────────────────────────────────────────────────────────
347
+ // server bootstrap
348
+ // ────────────────────────────────────────────────────────────────────────────
349
+ async function dispatch(req, res, token) {
350
+ const auth = req.headers['authorization'];
351
+ if (auth !== `Bearer ${token}`) {
352
+ sendJson(res, 401, { error: 'unauthorized' });
353
+ return;
354
+ }
355
+ const url = new URL(req.url ?? '/', 'http://localhost');
356
+ const method = req.method ?? 'GET';
357
+ if (method === 'GET' && url.pathname === '/status')
358
+ return handleStatus(res);
359
+ if (method === 'GET' && url.pathname === '/')
360
+ return sendHtml(res, 200, controlPageHtml());
361
+ if (method === 'POST' && url.pathname === '/db/download')
362
+ return handleDbDownload(res);
363
+ if (method === 'POST' && url.pathname === '/db/upload')
364
+ return handleDbUpload(req, res);
365
+ if (method === 'POST' && url.pathname === '/files/archive')
366
+ return handleFilesArchive(res);
367
+ if (method === 'POST' && url.pathname === '/files/restore')
368
+ return handleFilesRestore(req, res);
369
+ sendJson(res, 404, { error: 'not_found' });
370
+ }
371
+ /**
372
+ * Start the envsync HTTP service. Throws synchronously if no bearer token
373
+ * is configured (via opts.token or HAZO_ENVSYNC_TOKEN) — this service must
374
+ * never run unauthenticated.
375
+ *
376
+ * opts override env vars, which makes this testable without real env vars
377
+ * or real ports: pass { port: 0 } for an OS-assigned ephemeral port and read
378
+ * the actual port off `server.address()`.
379
+ */
380
+ export function startEnvsyncService(opts = {}) {
381
+ const token = opts.token ?? process.env['HAZO_ENVSYNC_TOKEN'];
382
+ if (!token) {
383
+ throw new Error('envsync: HAZO_ENVSYNC_TOKEN is required to start the envsync service — refusing to run with no auth');
384
+ }
385
+ const envPort = process.env['HAZO_ENVSYNC_PORT'];
386
+ const port = opts.port ?? (envPort ? Number(envPort) : DEFAULT_PORT);
387
+ const bind = opts.bind ?? process.env['HAZO_ENVSYNC_BIND'] ?? DEFAULT_BIND;
388
+ const server = http.createServer((req, res) => {
389
+ dispatch(req, res, token).catch((err) => {
390
+ const message = err instanceof Error ? err.message : String(err);
391
+ if (!res.headersSent)
392
+ sendJson(res, 500, { error: message });
393
+ else
394
+ res.end();
395
+ });
396
+ });
397
+ server.listen(port, bind);
398
+ return {
399
+ server,
400
+ close() {
401
+ return new Promise((resolve, reject) => {
402
+ server.close((err) => (err ? reject(err) : resolve()));
403
+ });
404
+ },
405
+ };
406
+ }
package/dist/index.d.ts CHANGED
@@ -5,18 +5,6 @@ export * from './resolve/secrets.js';
5
5
  export * from './resolve/connect.js';
6
6
  export * from './resolve/files.js';
7
7
  export * from './doctor.js';
8
- export { runMigration } from './migrate/run.js';
9
- export { clearEnv } from './migrate/clear.js';
10
- export { verifyFiles } from './migrate/verify.js';
11
- export { takeSnapshot, restoreSnapshot } from './migrate/snapshot.js';
12
- export { writeMigrationProgress, readMigrationProgress, clearMigrationProgress } from './migrate/progress.js';
13
- export { registerMask } from './mask/registry.js';
14
- export { loadRuleset, syncRulesetFromIni, parseIniRules } from './mask/ruleset.js';
15
- export type { MaskRule } from './mask/ruleset.js';
16
- export type { MaskTransform } from './types/index.js';
17
8
  export * from './lib/index.js';
18
- export { resolveBackupConfig } from './resolve/backup.js';
19
- export type { BackupConfig } from './resolve/backup.js';
20
9
  export { runSsh, assertSafeSshField } from './migrate/ssh-exec.js';
21
- export { restoreDbViaDump } from './migrate/db-dump-restore.js';
22
10
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAE7E,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAEvG,cAAc,kBAAkB,CAAC;AAEjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,oBAAoB,CAAC;AAEnC,cAAc,aAAa,CAAC;AAE5B,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACtE,OAAO,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AAE9G,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAElD,OAAO,EAAE,WAAW,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AACnF,YAAY,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAClD,YAAY,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,cAAc,gBAAgB,CAAC;AAE/B,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC1D,YAAY,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,EAAE,MAAM,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AACnE,OAAO,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAE7E,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAEvG,cAAc,kBAAkB,CAAC;AAEjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,oBAAoB,CAAC;AAEnC,cAAc,aAAa,CAAC;AAE5B,cAAc,gBAAgB,CAAC;AAE/B,OAAO,EAAE,MAAM,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC"}
package/dist/index.js CHANGED
@@ -11,19 +11,7 @@ export * from './resolve/connect.js';
11
11
  export * from './resolve/files.js';
12
12
  // Doctor
13
13
  export * from './doctor.js';
14
- // Migration engine (Phase 2)
15
- export { runMigration } from './migrate/run.js';
16
- export { clearEnv } from './migrate/clear.js';
17
- export { verifyFiles } from './migrate/verify.js';
18
- export { takeSnapshot, restoreSnapshot } from './migrate/snapshot.js';
19
- export { writeMigrationProgress, readMigrationProgress, clearMigrationProgress } from './migrate/progress.js';
20
- // Masking registry
21
- export { registerMask } from './mask/registry.js';
22
- // Masking ruleset
23
- export { loadRuleset, syncRulesetFromIni, parseIniRules } from './mask/ruleset.js';
24
14
  // Lib
25
15
  export * from './lib/index.js';
26
- // dump-restore transport
27
- export { resolveBackupConfig } from './resolve/backup.js';
16
+ // Generic SSH-exec utilities (retained: shared infra, not migration-specific)
28
17
  export { runSsh, assertSafeSshField } from './migrate/ssh-exec.js';
29
- export { restoreDbViaDump } from './migrate/db-dump-restore.js';
@@ -1,3 +1,16 @@
1
1
  import type { MigrationRequest, MigrationResult } from '../types/index.js';
2
+ /**
3
+ * Resolve the effective table list handed to copyDb.
4
+ *
5
+ * `'*'` (and an unset request) both mean "all tables". The SQL path can
6
+ * auto-discover that set, but the PostgREST path cannot (no FK ordering without
7
+ * an explicit list) and rejects `'*'`. So when the request asks for "all", we
8
+ * fall back to the configured `[migrate] tables` list — the app's canonical
9
+ * FK-ordered definition of "all". Only when no config list exists do we pass
10
+ * `'*'` through (SQL auto-discovers; PostgREST then errors as designed).
11
+ *
12
+ * An explicit `string[]` from the request always wins.
13
+ */
14
+ export declare function resolveTableList(reqTables: '*' | string[] | undefined, configTables: string[] | undefined): '*' | string[];
2
15
  export declare function runMigration(req: MigrationRequest): Promise<MigrationResult>;
3
16
  //# sourceMappingURL=run.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/migrate/run.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAkC,MAAM,mBAAmB,CAAC;AA0D3G,wBAAsB,YAAY,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CAyMlF"}
1
+ {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/migrate/run.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAkC,MAAM,mBAAmB,CAAC;AAU3G;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,GAAG,GAAG,MAAM,EAAE,GAAG,SAAS,EACrC,YAAY,EAAE,MAAM,EAAE,GAAG,SAAS,GACjC,GAAG,GAAG,MAAM,EAAE,CAGhB;AAkDD,wBAAsB,YAAY,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CAyMlF"}
@@ -22,6 +22,23 @@ function emitProgress(req, p) {
22
22
  writeMigrationProgress(req.progressDir, req.jobId, p);
23
23
  }
24
24
  }
25
+ /**
26
+ * Resolve the effective table list handed to copyDb.
27
+ *
28
+ * `'*'` (and an unset request) both mean "all tables". The SQL path can
29
+ * auto-discover that set, but the PostgREST path cannot (no FK ordering without
30
+ * an explicit list) and rejects `'*'`. So when the request asks for "all", we
31
+ * fall back to the configured `[migrate] tables` list — the app's canonical
32
+ * FK-ordered definition of "all". Only when no config list exists do we pass
33
+ * `'*'` through (SQL auto-discovers; PostgREST then errors as designed).
34
+ *
35
+ * An explicit `string[]` from the request always wins.
36
+ */
37
+ export function resolveTableList(reqTables, configTables) {
38
+ if (reqTables && reqTables !== '*')
39
+ return reqTables;
40
+ return configTables ?? '*';
41
+ }
25
42
  // Resolve the SQLite driver — prefer better-sqlite3 in tests when env var is set
26
43
  function getSqliteDriver() {
27
44
  if (process.env['HAZO_ENV_TEST_SQLITE_DRIVER'] === 'better-sqlite3')
@@ -192,7 +209,7 @@ export async function runMigration(req) {
192
209
  const scrubHook = await buildScrubHook(req.to, scrubMode, tgtAdapter);
193
210
  const result = await copyDb(srcAdapter, tgtAdapter, {
194
211
  type: fromDbConfig.type,
195
- tables: req.tables ?? migrateConfig.tables,
212
+ tables: resolveTableList(req.tables, migrateConfig.tables),
196
213
  preserve: migrateConfig.preserve,
197
214
  pkOverrides: migrateConfig.pkOverrides,
198
215
  scrubHook,
@@ -0,0 +1,35 @@
1
+ export interface EnvsyncConfig {
2
+ /** Postgres database that downloadDb/downloadFiles pull FROM */
3
+ source_db: string;
4
+ /** Postgres database that uploadDb drops/recreates/restores INTO */
5
+ target_db: string;
6
+ /** Owner passed to createdb -O */
7
+ owner: string;
8
+ /** Local files root that downloadFiles tars and uploadFiles overwrites */
9
+ files_root: string;
10
+ /** Local scratch dir for dumps/archives + the .envsync.lock file */
11
+ work_dir: string;
12
+ /** How many of each file kind (db dump, files archive) to retain */
13
+ keep: number;
14
+ /** Shell command run before dropdb/createdb/pg_restore, if configured */
15
+ pre_restore_cmd?: string;
16
+ /** Shell command run after a successful pg_restore, if configured */
17
+ post_restore_cmd?: string;
18
+ /**
19
+ * Names guarded by assertNotProd. Defaults to just [source_db]: the
20
+ * assumption is that the environment this config downloads FROM is the
21
+ * one that must never be accidentally overwritten by uploadDb/uploadFiles.
22
+ * (No separate INI knob for this in Phase 1 — not required by spec.)
23
+ */
24
+ prodDbNames: string[];
25
+ /** Every KEY=value pair from [migrate.env_overrides], ${VAR}-expanded */
26
+ envOverrides: Record<string, string>;
27
+ }
28
+ /**
29
+ * Resolve the [envsync] section from hazo_env_config.ini.
30
+ * Returns null when the section is missing, the config file is missing, or
31
+ * any required field (source_db, target_db, owner, files_root, work_dir) is
32
+ * absent — mirrors resolveBackupConfig's null-on-missing behavior exactly.
33
+ */
34
+ export declare function resolveEnvsyncConfig(): EnvsyncConfig | null;
35
+ //# sourceMappingURL=envsync.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"envsync.d.ts","sourceRoot":"","sources":["../../src/resolve/envsync.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,aAAa;IAC5B,gEAAgE;IAChE,SAAS,EAAE,MAAM,CAAC;IAClB,oEAAoE;IACpE,SAAS,EAAE,MAAM,CAAC;IAClB,kCAAkC;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,0EAA0E;IAC1E,UAAU,EAAE,MAAM,CAAC;IACnB,oEAAoE;IACpE,QAAQ,EAAE,MAAM,CAAC;IACjB,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,qEAAqE;IACrE,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;OAKG;IACH,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,yEAAyE;IACzE,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACtC;AAgBD;;;;;GAKG;AACH,wBAAgB,oBAAoB,IAAI,aAAa,GAAG,IAAI,CAuD3D"}
@@ -0,0 +1,77 @@
1
+ // hazo_env/src/resolve/envsync.ts — local envsync (pg_dump/pg_restore/tar) config resolver
2
+ //
3
+ // Reads [envsync] and [migrate.env_overrides] from config/hazo_env_config.ini.
4
+ // Follows the exact null-on-missing pattern of resolve/backup.ts.
5
+ import path from 'node:path';
6
+ import { HazoConfig } from 'hazo_config/server';
7
+ import { resolveFilesConfig } from './files.js';
8
+ const DEFAULT_KEEP = 3;
9
+ function tryLoadEnvConfig() {
10
+ try {
11
+ return new HazoConfig({ filePath: path.resolve(process.cwd(), 'config', 'hazo_env_config.ini') });
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ function expandEnvVars(value, extraVars) {
18
+ return value.replace(/\$\{([^}]+)\}/g, (_, name) => extraVars[name] ?? process.env[name] ?? '');
19
+ }
20
+ /**
21
+ * Resolve the [envsync] section from hazo_env_config.ini.
22
+ * Returns null when the section is missing, the config file is missing, or
23
+ * any required field (source_db, target_db, owner, files_root, work_dir) is
24
+ * absent — mirrors resolveBackupConfig's null-on-missing behavior exactly.
25
+ */
26
+ export function resolveEnvsyncConfig() {
27
+ const config = tryLoadEnvConfig();
28
+ if (!config)
29
+ return null;
30
+ const section = config.getSection('envsync');
31
+ if (!section)
32
+ return null;
33
+ const source_db_raw = section['source_db'];
34
+ const target_db_raw = section['target_db'];
35
+ const owner_raw = section['owner'];
36
+ const files_root_raw = section['files_root'];
37
+ const work_dir_raw = section['work_dir'];
38
+ const keep_raw = section['keep'];
39
+ const pre_restore_cmd_raw = section['pre_restore_cmd'];
40
+ const post_restore_cmd_raw = section['post_restore_cmd'];
41
+ if (!source_db_raw || !target_db_raw || !owner_raw || !files_root_raw || !work_dir_raw)
42
+ return null;
43
+ // Resolve DATA_ROOT for ${DATA_ROOT} substitution in files_root, the same
44
+ // way resolve/connect.ts does for db.<env>.sqlite.database_path.
45
+ let dataRoot = 'app_data';
46
+ try {
47
+ dataRoot = resolveFilesConfig().local.basePath;
48
+ }
49
+ catch {
50
+ // fallback: use relative default
51
+ }
52
+ const extraVars = { DATA_ROOT: dataRoot };
53
+ const source_db = expandEnvVars(source_db_raw, extraVars);
54
+ const keepParsed = keep_raw ? parseInt(keep_raw, 10) : NaN;
55
+ const keep = Number.isFinite(keepParsed) && keepParsed > 0 ? keepParsed : DEFAULT_KEEP;
56
+ const envOverrides = {};
57
+ const overridesSection = config.getSection('migrate.env_overrides');
58
+ if (overridesSection) {
59
+ for (const [key, value] of Object.entries(overridesSection)) {
60
+ if (typeof value === 'string') {
61
+ envOverrides[key] = expandEnvVars(value, extraVars);
62
+ }
63
+ }
64
+ }
65
+ return {
66
+ source_db,
67
+ target_db: expandEnvVars(target_db_raw, extraVars),
68
+ owner: expandEnvVars(owner_raw, extraVars),
69
+ files_root: expandEnvVars(files_root_raw, extraVars),
70
+ work_dir: expandEnvVars(work_dir_raw, extraVars),
71
+ keep,
72
+ pre_restore_cmd: pre_restore_cmd_raw ? expandEnvVars(pre_restore_cmd_raw, extraVars) : undefined,
73
+ post_restore_cmd: post_restore_cmd_raw ? expandEnvVars(post_restore_cmd_raw, extraVars) : undefined,
74
+ prodDbNames: [source_db],
75
+ envOverrides,
76
+ };
77
+ }