hazo_env 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGE_LOG.md +36 -0
  2. package/README.md +10 -6
  3. package/SETUP_CHECKLIST.md +6 -0
  4. package/config/hazo_env_config.ini.sample +18 -0
  5. package/dist/cli.js +56 -4
  6. package/dist/doctor.d.ts.map +1 -1
  7. package/dist/doctor.js +152 -0
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -0
  11. package/dist/migrate/clear.d.ts +3 -0
  12. package/dist/migrate/clear.d.ts.map +1 -0
  13. package/dist/migrate/clear.js +117 -0
  14. package/dist/migrate/db.d.ts +4 -0
  15. package/dist/migrate/db.d.ts.map +1 -1
  16. package/dist/migrate/db.js +6 -1
  17. package/dist/migrate/db.postgrest.d.ts +4 -0
  18. package/dist/migrate/db.postgrest.d.ts.map +1 -0
  19. package/dist/migrate/db.postgrest.js +95 -0
  20. package/dist/migrate/files.d.ts +2 -1
  21. package/dist/migrate/files.d.ts.map +1 -1
  22. package/dist/migrate/files.js +85 -4
  23. package/dist/migrate/run.d.ts.map +1 -1
  24. package/dist/migrate/run.js +55 -15
  25. package/dist/migrate/snapshot.d.ts.map +1 -1
  26. package/dist/migrate/snapshot.js +3 -0
  27. package/dist/migrate/transport.d.ts +1 -1
  28. package/dist/migrate/transport.d.ts.map +1 -1
  29. package/dist/migrate/transport.js +2 -9
  30. package/dist/migrate/verify.d.ts +1 -0
  31. package/dist/migrate/verify.d.ts.map +1 -1
  32. package/dist/migrate/verify.js +120 -30
  33. package/dist/resolve/files.d.ts +6 -0
  34. package/dist/resolve/files.d.ts.map +1 -1
  35. package/dist/resolve/files.js +23 -0
  36. package/dist/resolve/migrate.d.ts +3 -0
  37. package/dist/resolve/migrate.d.ts.map +1 -0
  38. package/dist/resolve/migrate.js +29 -0
  39. package/dist/resolve/ssh.d.ts +15 -0
  40. package/dist/resolve/ssh.d.ts.map +1 -0
  41. package/dist/resolve/ssh.js +36 -0
  42. package/dist/types/index.d.ts +28 -1
  43. package/dist/types/index.d.ts.map +1 -1
  44. package/package.json +9 -9
@@ -0,0 +1,95 @@
1
+ // hazo_env/src/migrate/db.postgrest.ts — PostgREST-based DB copy (REST-only, no raw SQL)
2
+ import { HazoError, optional_import } from 'hazo_core';
3
+ import { isSecretColumn } from '../lib/secret_columns.js';
4
+ const PAGE_SIZE = 500;
5
+ export async function copyDbPostgrest(srcAdapter, tgtAdapter, opts = {}) {
6
+ const connect = await optional_import('hazo_connect/server');
7
+ if (!connect) {
8
+ throw new HazoError({
9
+ code: 'HAZO_ENV_MISSING_DEPENDENCY',
10
+ pkg: 'hazo_env',
11
+ message: 'hazo_connect is required for DB migration. Install hazo_connect to continue.',
12
+ });
13
+ }
14
+ // Table list is required for PostgREST (no auto-discovery — FK ordering unsafe without explicit list)
15
+ if (!opts.tables || opts.tables === '*') {
16
+ throw new HazoError({
17
+ code: 'HAZO_ENV_MISSING_TABLE_LIST',
18
+ pkg: 'hazo_env',
19
+ message: 'PostgREST migration requires an explicit table list. ' +
20
+ 'Set [migrate] tables = ... in hazo_env_config.ini or pass --tables to the CLI.',
21
+ });
22
+ }
23
+ const orderedTables = opts.tables;
24
+ const preserveSet = new Set(opts.preserve ?? []);
25
+ const pkOverrides = opts.pkOverrides ?? {};
26
+ // Tables to actually process (exclude preserve list)
27
+ const tablesToProcess = orderedTables.filter((t) => !preserveSet.has(t));
28
+ // --- Clear pass: reverse dependency order ---
29
+ opts.onProgress?.('Clearing target tables (reverse order for FK safety)...');
30
+ for (const table of [...tablesToProcess].reverse()) {
31
+ const pk = pkOverrides[table] ?? 'id';
32
+ try {
33
+ // PostgREST match-all DELETE: DELETE /table?pk=not.is.null
34
+ await tgtAdapter.rawQuery(`/${table}?${pk}=not.is.null`, { method: 'DELETE' });
35
+ opts.onProgress?.(`Cleared: ${table}`);
36
+ }
37
+ catch (err) {
38
+ const msg = err instanceof Error ? err.message : String(err);
39
+ opts.onProgress?.(`Warning: could not clear ${table}: ${msg}`);
40
+ }
41
+ }
42
+ let totalRows = 0;
43
+ let totalScrubbed = 0;
44
+ // --- Copy pass: forward dependency order ---
45
+ for (const table of tablesToProcess) {
46
+ const tableIdx = tablesToProcess.indexOf(table);
47
+ opts.onProgress?.(`Copying table ${tableIdx + 1}/${tablesToProcess.length}: ${table}`);
48
+ opts.onTableProgress?.(tableIdx + 1, tablesToProcess.length, table);
49
+ const pk = pkOverrides[table] ?? 'id';
50
+ const srcService = connect.createCrudService(srcAdapter, table, { autoId: false });
51
+ const tgtService = connect.createCrudService(tgtAdapter, table, { autoId: false });
52
+ let offset = 0;
53
+ let pageRows = [];
54
+ do {
55
+ pageRows = await srcService.list((qb) =>
56
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
+ qb.order(pk, 'asc').limit(PAGE_SIZE).offset(offset));
58
+ if (pageRows.length === 0)
59
+ break;
60
+ const batch = [];
61
+ for (let row of pageRows) {
62
+ const cleaned = {};
63
+ let didScrub = false;
64
+ for (const [col, val] of Object.entries(row)) {
65
+ if (isSecretColumn(col)) {
66
+ didScrub = true;
67
+ }
68
+ else {
69
+ cleaned[col] = val;
70
+ }
71
+ }
72
+ let processedRow = cleaned;
73
+ if (opts.scrubHook) {
74
+ const hooked = opts.scrubHook(table, cleaned);
75
+ if (hooked !== cleaned)
76
+ didScrub = true;
77
+ processedRow = hooked;
78
+ }
79
+ if (didScrub)
80
+ totalScrubbed++;
81
+ batch.push(processedRow);
82
+ }
83
+ if (batch.length > 0) {
84
+ await tgtService.insert(batch);
85
+ totalRows += batch.length;
86
+ opts.onProgress?.(` inserted ${batch.length} rows into ${table}`);
87
+ }
88
+ offset += PAGE_SIZE;
89
+ } while (pageRows.length === PAGE_SIZE);
90
+ }
91
+ // Note: PostgREST has no REST endpoint for NOTIFY. Schema reload only needed after DDL.
92
+ // If schema DDL changed since last reload, run: NOTIFY pgrst, 'reload schema' in Postgres.
93
+ opts.onProgress?.("DB copy complete. If schema DDL changed, run NOTIFY pgrst, 'reload schema' in Postgres.");
94
+ return { tables: tablesToProcess.length, rows: totalRows, scrubbed: totalScrubbed };
95
+ }
@@ -2,8 +2,9 @@ import type { HazoEnv } from '../types/index.js';
2
2
  export interface FilesCopyOptions {
3
3
  fromEnv: HazoEnv;
4
4
  toEnv: HazoEnv;
5
+ transport?: 'local' | 'rsync';
5
6
  fileScrubHook?: (filePath: string) => Promise<void>;
6
- onProgress?: (msg: string) => void;
7
+ onProgress?: (msg: string, percent?: number) => void;
7
8
  }
8
9
  export interface FilesCopyResult {
9
10
  copied: number;
@@ -1 +1 @@
1
- {"version":3,"file":"files.d.ts","sourceRoot":"","sources":["../../src/migrate/files.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAEjD,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,CAAC;CACvB;AA4BD,wBAAsB,SAAS,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CA4ChF"}
1
+ {"version":3,"file":"files.d.ts","sourceRoot":"","sources":["../../src/migrate/files.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAEjD,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC;IAC9B,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;CACtD;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,CAAC;CACvB;AA6GD,wBAAsB,SAAS,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CAkDhF"}
@@ -6,11 +6,11 @@
6
6
  // Failures are non-fatal warnings — the file copy still completes.
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
- import { resolveFilesConfig } from '../resolve/files.js';
9
+ import { spawn } from 'node:child_process';
10
+ import { resolveFilesRoot } from '../resolve/files.js';
11
+ import { resolveSshConfig } from '../resolve/ssh.js';
10
12
  function resolveEnvFilesRoot(env) {
11
- // resolveFilesConfig returns the data root; env-specific files are at <root>/<env>/files
12
- const base = resolveFilesConfig().local.basePath;
13
- return path.join(base, env, 'files');
13
+ return resolveFilesRoot(env);
14
14
  }
15
15
  /**
16
16
  * Attempt to scrub a PDF file in-place using hazo_pdf/mask_pdf.
@@ -31,7 +31,88 @@ async function scrubPdfFile(filePath) {
31
31
  // Non-fatal — PDF scrubbing failure is a warning, not an abort
32
32
  }
33
33
  }
34
+ /**
35
+ * Reject ssh config values that could smuggle extra ssh options through rsync's
36
+ * `-e` command string. rsync re-splits `-e` on whitespace, so a value starting
37
+ * with `-` (e.g. `-oProxyCommand=...`) or containing whitespace would be parsed
38
+ * as additional ssh flags rather than data. Fail loud instead of running.
39
+ */
40
+ function assertSafeSshField(name, value) {
41
+ if (/^-/.test(value) || /\s/.test(value)) {
42
+ throw new Error(`Unsafe transport.ssh.${name} value: must not start with '-' or contain whitespace`);
43
+ }
44
+ }
45
+ async function copyFilesRsync(opts, ssh) {
46
+ const tgtRoot = resolveEnvFilesRoot(opts.toEnv);
47
+ fs.mkdirSync(tgtRoot, { recursive: true });
48
+ assertSafeSshField('user', ssh.user);
49
+ assertSafeSshField('host', ssh.host);
50
+ assertSafeSshField('key', ssh.key);
51
+ assertSafeSshField('path', ssh.path);
52
+ // Persistent known_hosts so host-key trust survives across runs (no TOFU reset).
53
+ const knownHosts = path.resolve(process.cwd(), 'config', 'hazo_env_known_hosts');
54
+ fs.mkdirSync(path.dirname(knownHosts), { recursive: true });
55
+ if (ssh.hostKey) {
56
+ // Pin the operator-provided host key; strict checking, no first-use prompt.
57
+ const line = `${ssh.host} ${ssh.hostKey}\n`;
58
+ const existing = fs.existsSync(knownHosts) ? fs.readFileSync(knownHosts, 'utf8') : '';
59
+ if (!existing.includes(line.trim()))
60
+ fs.appendFileSync(knownHosts, line);
61
+ }
62
+ // Pinned key → strict verify; otherwise accept-new (trust on first use, then
63
+ // persist) — never disable host-key checking outright.
64
+ const strict = ssh.hostKey ? 'yes' : 'accept-new';
65
+ const remote = `${ssh.user}@${ssh.host}:${ssh.path}/`;
66
+ const sshCmd = `ssh -i ${ssh.key} -o StrictHostKeyChecking=${strict} -o UserKnownHostsFile=${knownHosts}`;
67
+ const args = ['-az', '--partial', '--info=progress2', '-e', sshCmd, remote, tgtRoot + '/'];
68
+ return new Promise((resolve, reject) => {
69
+ const proc = spawn('rsync', args);
70
+ let totalFiles = 0;
71
+ let doneFiles = 0;
72
+ let totalBytes = 0;
73
+ proc.stdout.on('data', (chunk) => {
74
+ const text = chunk.toString();
75
+ // Parse --info=progress2: lines like " 117,504,614 100% 108.64MB/s 0:00:01 (xfr#123, to-chk=0/456)"
76
+ for (const line of text.split('\n')) {
77
+ // Extract to-chk=N/TOTAL
78
+ const chkMatch = line.match(/to-chk=(\d+)\/(\d+)/);
79
+ if (chkMatch) {
80
+ const remaining = parseInt(chkMatch[1], 10);
81
+ const total = parseInt(chkMatch[2], 10);
82
+ if (totalFiles === 0)
83
+ totalFiles = total;
84
+ doneFiles = total - remaining;
85
+ const pct = total > 0 ? Math.round((doneFiles / total) * 100) : 0;
86
+ opts.onProgress?.(`rsync: ${doneFiles}/${total} files (${pct}%)`, pct);
87
+ }
88
+ // Extract bytes: first number on lines with a %
89
+ const bytesMatch = line.match(/^\s+([\d,]+)\s+\d+%/);
90
+ if (bytesMatch) {
91
+ totalBytes = parseInt(bytesMatch[1].replace(/,/g, ''), 10);
92
+ }
93
+ }
94
+ });
95
+ proc.stderr.on('data', (chunk) => {
96
+ opts.onProgress?.(`rsync: ${chunk.toString().trim()}`, undefined);
97
+ });
98
+ proc.on('error', (err) => reject(err));
99
+ proc.on('close', (code) => {
100
+ if (code !== 0) {
101
+ reject(new Error(`rsync exited with code ${code}`));
102
+ }
103
+ else {
104
+ resolve({ copied: doneFiles || totalFiles, bytes: totalBytes, placeholdered: 0 });
105
+ }
106
+ });
107
+ });
108
+ }
34
109
  export async function copyFiles(opts) {
110
+ const ssh = resolveSshConfig(opts.fromEnv);
111
+ if (opts.transport === 'rsync' || (opts.transport !== 'local' && ssh !== null)) {
112
+ if (!ssh)
113
+ throw new Error(`rsync requested but no [transport.ssh.${opts.fromEnv}] config found`);
114
+ return copyFilesRsync(opts, ssh);
115
+ }
35
116
  const srcRoot = resolveEnvFilesRoot(opts.fromEnv);
36
117
  const tgtRoot = resolveEnvFilesRoot(opts.toEnv);
37
118
  if (!fs.existsSync(srcRoot)) {
@@ -1 +1 @@
1
- {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/migrate/run.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAkC,MAAM,mBAAmB,CAAC;AA0D3G,wBAAsB,YAAY,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CAkJlF"}
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,CAwLlF"}
@@ -6,6 +6,7 @@
6
6
  // See: src/migrate/files.ts scrubPdfFile() and hazo_pdf/src/server/mask.ts
7
7
  import { HazoError, optional_import } from 'hazo_core';
8
8
  import { resolveConnectConfig } from '../resolve/connect.js';
9
+ import { resolveMigrateConfig } from '../resolve/migrate.js';
9
10
  import { resolveFilesConfig } from '../resolve/files.js';
10
11
  import { resolveTransport } from './transport.js';
11
12
  import { takeSnapshot } from './snapshot.js';
@@ -122,20 +123,43 @@ export async function runMigration(req) {
122
123
  }
123
124
  // Create adapters
124
125
  function makeAdapter(dbConfig, readOnly = false) {
125
- if (dbConfig.type !== 'sqlite' || !dbConfig.sqlite) {
126
- throw new HazoError({
127
- code: 'HAZO_ENV_NOT_IMPLEMENTED',
128
- pkg: 'hazo_env',
129
- message: `Migration is only implemented for SQLite databases. Got type "${dbConfig.type}".`,
126
+ if (dbConfig.type === 'sqlite') {
127
+ if (!dbConfig.sqlite) {
128
+ throw new HazoError({
129
+ code: 'HAZO_ENV_NOT_IMPLEMENTED',
130
+ pkg: 'hazo_env',
131
+ message: 'SQLite config missing database_path.',
132
+ });
133
+ }
134
+ return connect.createHazoConnect({
135
+ type: 'sqlite',
136
+ sqlite: {
137
+ database_path: dbConfig.sqlite.database_path,
138
+ read_only: readOnly,
139
+ driver: getSqliteDriver(),
140
+ },
130
141
  });
131
142
  }
132
- return connect.createHazoConnect({
133
- type: 'sqlite',
134
- sqlite: {
135
- database_path: dbConfig.sqlite.database_path,
136
- read_only: readOnly,
137
- driver: getSqliteDriver(),
138
- },
143
+ if (dbConfig.type === 'postgrest') {
144
+ if (!dbConfig.postgrest) {
145
+ throw new HazoError({
146
+ code: 'HAZO_ENV_NOT_IMPLEMENTED',
147
+ pkg: 'hazo_env',
148
+ message: 'PostgREST config missing base_url/api_key.',
149
+ });
150
+ }
151
+ return connect.createHazoConnect({
152
+ type: 'postgrest',
153
+ postgrest: {
154
+ base_url: dbConfig.postgrest.base_url,
155
+ api_key: dbConfig.postgrest.api_key,
156
+ },
157
+ });
158
+ }
159
+ throw new HazoError({
160
+ code: 'HAZO_ENV_NOT_IMPLEMENTED',
161
+ pkg: 'hazo_env',
162
+ message: `Migration is not implemented for DB type "${dbConfig.type}".`,
139
163
  });
140
164
  }
141
165
  const srcAdapter = makeAdapter(fromDbConfig, true);
@@ -144,14 +168,23 @@ export async function runMigration(req) {
144
168
  emitProgress(req, { phase: 'schema-check', message: 'Checking schema parity', percent: 15 });
145
169
  let dbResult;
146
170
  let filesResult;
171
+ const migrateConfig = resolveMigrateConfig();
147
172
  // Step 5: DB copy
148
173
  if (includeDb) {
149
174
  emitProgress(req, { phase: 'db', message: 'Copying database tables', percent: 20 });
150
175
  const scrubHook = await buildScrubHook(req.to, scrubMode, tgtAdapter);
151
176
  const result = await copyDb(srcAdapter, tgtAdapter, {
152
- tables: req.tables,
177
+ type: fromDbConfig.type,
178
+ tables: req.tables ?? migrateConfig.tables,
179
+ preserve: migrateConfig.preserve,
180
+ pkOverrides: migrateConfig.pkOverrides,
153
181
  scrubHook,
154
182
  onProgress: (msg) => emitProgress(req, { phase: 'db', message: msg }),
183
+ onTableProgress: (idx, total, name) => emitProgress(req, {
184
+ phase: 'db',
185
+ message: `table ${idx}/${total}: ${name}`,
186
+ percent: 20 + Math.round((idx / total) * 50),
187
+ }),
155
188
  });
156
189
  dbResult = result;
157
190
  emitProgress(req, { phase: 'db', message: `DB copy complete: ${result.tables} tables, ${result.rows} rows`, percent: 70 });
@@ -169,9 +202,16 @@ export async function runMigration(req) {
169
202
  // Step 7: Verify
170
203
  emitProgress(req, { phase: 'verify', message: 'Running post-migration verification', percent: 80 });
171
204
  const dataRoot = resolveFilesConfig().local.basePath;
172
- const verifyReport = await verifyFiles(req.to, dataRoot, { hash: 'sample', checkOrphans: true });
205
+ const verifyReport = await verifyFiles(req.to, dataRoot, { hash: 'sample', checkOrphans: true, adapter: tgtAdapter });
173
206
  if (!verifyReport.ok) {
174
- warnings.push(`Verification found issues: missing=${verifyReport.missing.length}, sizeMismatch=${verifyReport.sizeMismatch.length}`);
207
+ const parts = [];
208
+ if (verifyReport.missing.length)
209
+ parts.push(`missing=${verifyReport.missing.length}`);
210
+ if (verifyReport.sizeMismatch.length)
211
+ parts.push(`sizeMismatch=${verifyReport.sizeMismatch.length}`);
212
+ if (verifyReport.hashMismatch.length)
213
+ parts.push(`hashMismatch=${verifyReport.hashMismatch.length}`);
214
+ warnings.push(`Verify found issues (${parts.join(', ')}) — migration continues; snapshotId=${snapshot.snapshotId}`);
175
215
  }
176
216
  // Step 8: Finalize — audit
177
217
  emitProgress(req, { phase: 'finalize', message: 'Recording audit event', percent: 95 });
@@ -1 +1 @@
1
- {"version":3,"file":"snapshot.d.ts","sourceRoot":"","sources":["../../src/migrate/snapshot.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,YAAY,CAAC,QAAQ,EAAE,WAAW,GAAG,cAAc,CAmBlE;AAED,wBAAgB,eAAe,CAAC,QAAQ,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAuB/E"}
1
+ {"version":3,"file":"snapshot.d.ts","sourceRoot":"","sources":["../../src/migrate/snapshot.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,YAAY,CAAC,QAAQ,EAAE,WAAW,GAAG,cAAc,CAsBlE;AAED,wBAAgB,eAAe,CAAC,QAAQ,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAuB/E"}
@@ -3,6 +3,9 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { HazoError } from 'hazo_core';
5
5
  export function takeSnapshot(toConfig) {
6
+ if (toConfig.type === 'postgrest') {
7
+ return { snapshotId: 'postgrest:no-snapshot', createdAt: new Date().toISOString() };
8
+ }
6
9
  if (toConfig.type !== 'sqlite' || !toConfig.sqlite?.database_path) {
7
10
  throw new HazoError({
8
11
  code: 'HAZO_ENV_NOT_IMPLEMENTED',
@@ -1,3 +1,3 @@
1
1
  import type { TransportMode, DbEnvConfig } from '../types/index.js';
2
- export declare function resolveTransport(fromConfig: DbEnvConfig, toConfig: DbEnvConfig, requested?: TransportMode): 'local' | 'api';
2
+ export declare function resolveTransport(fromConfig: DbEnvConfig, toConfig: DbEnvConfig, requested?: TransportMode): 'local' | 'api' | 'rsync';
3
3
  //# sourceMappingURL=transport.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../../src/migrate/transport.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEpE,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,WAAW,EACvB,QAAQ,EAAE,WAAW,EACrB,SAAS,GAAE,aAAsB,GAChC,OAAO,GAAG,KAAK,CAYjB"}
1
+ {"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../../src/migrate/transport.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEpE,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,WAAW,EACvB,QAAQ,EAAE,WAAW,EACrB,SAAS,GAAE,aAAsB,GAChC,OAAO,GAAG,KAAK,GAAG,OAAO,CAM3B"}
@@ -1,13 +1,6 @@
1
- // hazo_env/src/migrate/transport.ts — resolve transport mode for a migration
2
- import { HazoError } from 'hazo_core';
3
1
  export function resolveTransport(fromConfig, toConfig, requested = 'auto') {
4
- if (requested === 'ssh') {
5
- throw new HazoError({
6
- code: 'HAZO_ENV_NOT_IMPLEMENTED',
7
- pkg: 'hazo_env',
8
- message: 'SSH transport is not yet implemented. Use local or api transport.',
9
- });
10
- }
2
+ if (requested === 'ssh')
3
+ return 'rsync';
11
4
  if (requested === 'api')
12
5
  return 'api';
13
6
  // auto: both sqlite → local
@@ -3,6 +3,7 @@ export interface VerifyOptions {
3
3
  hash?: HashMode;
4
4
  samplePct?: number;
5
5
  checkOrphans?: boolean;
6
+ adapter?: import('hazo_connect').HazoConnectAdapter;
6
7
  }
7
8
  export declare function verifyFiles(env: HazoEnv, dataRoot: string, opts?: VerifyOptions): Promise<VerifyReport>;
8
9
  //# sourceMappingURL=verify.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../../src/migrate/verify.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAEzE,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,EAAE,QAAQ,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAMD,wBAAsB,WAAW,CAC/B,GAAG,EAAE,OAAO,EACZ,QAAQ,EAAE,MAAM,EAChB,IAAI,GAAE,aAAkB,GACvB,OAAO,CAAC,YAAY,CAAC,CAgDvB"}
1
+ {"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../../src/migrate/verify.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAEzE,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,EAAE,QAAQ,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,OAAO,CAAC,EAAE,OAAO,cAAc,EAAE,kBAAkB,CAAC;CACrD;AAqBD,wBAAsB,WAAW,CAC/B,GAAG,EAAE,OAAO,EACZ,QAAQ,EAAE,MAAM,EAChB,IAAI,GAAE,aAAkB,GACvB,OAAO,CAAC,YAAY,CAAC,CA8IvB"}
@@ -1,51 +1,141 @@
1
1
  // hazo_env/src/migrate/verify.ts — post-migration file verification
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { optional_import } from 'hazo_core';
4
5
  function resolveEnvFilesRoot(env, dataRoot) {
5
6
  return path.join(dataRoot, env, 'files');
6
7
  }
8
+ function walkDir(dir) {
9
+ if (!fs.existsSync(dir))
10
+ return [];
11
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
12
+ const absPath = path.join(dir, entry.name);
13
+ if (entry.isDirectory())
14
+ return walkDir(absPath);
15
+ // Return relative to dir's parent so caller can strip filesRoot prefix
16
+ return [absPath];
17
+ });
18
+ }
19
+ function walkRelative(filesRoot) {
20
+ const abs = walkDir(filesRoot);
21
+ return abs.map((p) => p.slice(filesRoot.length + 1)); // strip filesRoot + sep
22
+ }
7
23
  export async function verifyFiles(env, dataRoot, opts = {}) {
8
- const { checkOrphans = true } = opts;
24
+ const { hash = 'sample', checkOrphans = true, adapter } = opts;
9
25
  const filesRoot = resolveEnvFilesRoot(env, dataRoot);
10
26
  const missing = [];
11
27
  const sizeMismatch = [];
12
28
  const hashMismatch = [];
13
29
  const orphans = [];
14
30
  let checked = 0;
15
- let sentinelOk = true;
16
- // Layer 1: check sentinel file (data root exists and is readable)
17
- if (!fs.existsSync(filesRoot)) {
18
- sentinelOk = false;
31
+ let hashed;
32
+ // Layer 1: sentinel filesRoot must exist and be readable
33
+ const sentinelOk = fs.existsSync(filesRoot);
34
+ if (!sentinelOk) {
19
35
  return { ok: false, checked: 0, missing: [], sizeMismatch: [], hashMismatch: [], orphans: [], sentinelOk: false };
20
36
  }
21
- // Layer 2 & 3: walk all files
22
- function walkDir(dir, relBase) {
23
- if (!fs.existsSync(dir))
24
- return [];
25
- return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
26
- const relPath = path.join(relBase, entry.name);
27
- const absPath = path.join(dir, entry.name);
28
- if (entry.isDirectory())
29
- return walkDir(absPath, relPath);
30
- return [relPath];
31
- });
32
- }
33
- const diskFiles = walkDir(filesRoot, '');
34
- for (const relPath of diskFiles) {
37
+ // S3: Fallback detection need both hazo_files module and an adapter
38
+ const hazoFiles = await optional_import('hazo_files');
39
+ if (!hazoFiles || !adapter) {
40
+ // Disk-only fallback
41
+ const diskFiles = walkRelative(filesRoot);
42
+ checked = diskFiles.length;
43
+ const skippedReason = 'no hazo_files tracking — size/hash/orphan skipped';
44
+ const ok = sentinelOk && missing.length === 0;
45
+ return { ok, checked, missing, sizeMismatch, hashMismatch, orphans, sentinelOk, skippedReason };
46
+ }
47
+ // Main path: adapter + hazo_files available
48
+ const connectMod = await optional_import('hazo_connect/server');
49
+ let rows = [];
50
+ let skippedReason;
51
+ try {
52
+ if (!connectMod)
53
+ throw new Error('hazo_connect not available');
54
+ const service = connectMod.createCrudService(adapter, 'hazo_files');
55
+ const all = await service.list();
56
+ rows = all.filter((r) => !r.storage_type || r.storage_type === 'local');
57
+ }
58
+ catch {
59
+ // Table not found or connect unavailable — disk-only fallback
60
+ const diskFiles = walkRelative(filesRoot);
61
+ checked = diskFiles.length;
62
+ skippedReason = 'hazo_files table not found — disk-only fallback';
63
+ const ok = sentinelOk && missing.length === 0;
64
+ return { ok, checked, missing, sizeMismatch, hashMismatch, orphans, sentinelOk, skippedReason };
65
+ }
66
+ // Normalize DB paths: strip leading slash if present
67
+ const normalizeDbPath = (p) => p.startsWith('/') ? p.slice(1) : p;
68
+ const dbPathSet = new Set(rows.map((r) => normalizeDbPath(r.file_path)));
69
+ // Track present rows for hash/size checks
70
+ const presentRows = [];
71
+ // L2 Existence
72
+ for (const row of rows) {
73
+ const relPath = normalizeDbPath(row.file_path);
35
74
  const absPath = path.join(filesRoot, relPath);
36
- try {
37
- fs.statSync(absPath); // existence + size (stat succeeds = file accessible)
38
- checked++;
75
+ if (!fs.existsSync(absPath)) {
76
+ missing.push(row.file_path);
77
+ }
78
+ else {
79
+ presentRows.push({ ...row, file_path: relPath });
39
80
  }
40
- catch {
41
- missing.push(relPath);
81
+ }
82
+ // L3 Size
83
+ for (const row of presentRows) {
84
+ const absPath = path.join(filesRoot, row.file_path);
85
+ const stat = fs.statSync(absPath);
86
+ if (row.file_size != null && stat.size !== row.file_size) {
87
+ sizeMismatch.push(row.file_path);
88
+ }
89
+ checked++;
90
+ }
91
+ // L4 Hash
92
+ if (hash !== 'none') {
93
+ let toHash;
94
+ if (hash === 'full') {
95
+ toHash = presentRows;
96
+ }
97
+ else {
98
+ // sample: 1% random (min 1) + top 10 by file_size DESC + top 10 by file_changed_at DESC
99
+ const n = Math.max(1, Math.floor(presentRows.length * 0.01));
100
+ const shuffled = [...presentRows].sort(() => Math.random() - 0.5).slice(0, n);
101
+ const bySize = [...presentRows]
102
+ .filter((r) => r.file_size != null)
103
+ .sort((a, b) => (b.file_size ?? 0) - (a.file_size ?? 0))
104
+ .slice(0, 10);
105
+ const byChanged = [...presentRows]
106
+ .filter((r) => r.file_changed_at != null)
107
+ .sort((a, b) => (b.file_changed_at ?? '') < (a.file_changed_at ?? '') ? -1 : 1)
108
+ .slice(0, 10);
109
+ // Deduplicate by file_path
110
+ const seen = new Set();
111
+ toHash = [];
112
+ for (const r of [...shuffled, ...bySize, ...byChanged]) {
113
+ if (!seen.has(r.file_path)) {
114
+ seen.add(r.file_path);
115
+ toHash.push(r);
116
+ }
117
+ }
118
+ }
119
+ hashed = 0;
120
+ for (const row of toHash) {
121
+ const absPath = path.join(filesRoot, row.file_path);
122
+ const buf = fs.readFileSync(absPath);
123
+ const computed = hazoFiles.computeFileHashSync(buf);
124
+ if (row.file_hash && computed !== row.file_hash) {
125
+ hashMismatch.push(row.file_path);
126
+ }
127
+ hashed++;
42
128
  }
43
129
  }
44
- // Layer 4: orphan check — files on disk with no corresponding DB tracking row
45
- // In Phase A, hazo_files integration is not wired; just verify directory is readable
46
- if (checkOrphans && !fs.existsSync(filesRoot)) {
47
- orphans.push(filesRoot);
130
+ // L5 Orphans
131
+ if (checkOrphans) {
132
+ const diskFiles = walkRelative(filesRoot);
133
+ for (const diskRel of diskFiles) {
134
+ if (!dbPathSet.has(diskRel)) {
135
+ orphans.push(diskRel);
136
+ }
137
+ }
48
138
  }
49
- const ok = missing.length === 0 && sizeMismatch.length === 0 && hashMismatch.length === 0;
50
- return { ok, checked, missing, sizeMismatch, hashMismatch, orphans, sentinelOk };
139
+ const ok = sentinelOk && missing.length === 0 && sizeMismatch.length === 0 && hashMismatch.length === 0;
140
+ return { ok, checked, missing, sizeMismatch, hashMismatch, orphans, sentinelOk, hashed, skippedReason };
51
141
  }
@@ -5,4 +5,10 @@ import type { FilesEnvConfig } from '../types/index.js';
5
5
  * Falls back to app_data if not configured.
6
6
  */
7
7
  export declare function resolveFilesConfig(appConfigPkg?: string): FilesEnvConfig;
8
+ /**
9
+ * Resolve the file storage root path for a specific environment.
10
+ * Checks [files.<env>] root in hazo_env_config.ini first;
11
+ * falls back to <dataRoot>/<env>/files convention if absent.
12
+ */
13
+ export declare function resolveFilesRoot(env: string): string;
8
14
  //# sourceMappingURL=files.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"files.d.ts","sourceRoot":"","sources":["../../src/resolve/files.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAgBxD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,cAAc,CAoCxE"}
1
+ {"version":3,"file":"files.d.ts","sourceRoot":"","sources":["../../src/resolve/files.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAgBxD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,cAAc,CAoCxE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAiBpD"}
@@ -51,3 +51,26 @@ export function resolveFilesConfig(appConfigPkg) {
51
51
  }
52
52
  return { provider: 'local', local };
53
53
  }
54
+ /**
55
+ * Resolve the file storage root path for a specific environment.
56
+ * Checks [files.<env>] root in hazo_env_config.ini first;
57
+ * falls back to <dataRoot>/<env>/files convention if absent.
58
+ */
59
+ export function resolveFilesRoot(env) {
60
+ const envConfig = tryLoadConfig('hazo_env');
61
+ // Check explicit per-env root: [files.<env>] root = /some/path
62
+ const filesSection = envConfig?.getSection(`files.${env}`);
63
+ const explicitRoot = filesSection?.['root'];
64
+ if (explicitRoot) {
65
+ return path.isAbsolute(explicitRoot)
66
+ ? explicitRoot
67
+ : path.resolve(process.cwd(), explicitRoot);
68
+ }
69
+ // Fall back to <dataRoot>/<env>/files convention
70
+ const dataSection = envConfig?.getSection('data');
71
+ const dataRoot = dataSection?.['root'] ?? DEFAULT_DATA_ROOT;
72
+ const resolvedDataRoot = path.isAbsolute(dataRoot)
73
+ ? dataRoot
74
+ : path.resolve(process.cwd(), dataRoot);
75
+ return path.join(resolvedDataRoot, env, 'files');
76
+ }
@@ -0,0 +1,3 @@
1
+ import type { MigrateConfig } from '../types/index.js';
2
+ export declare function resolveMigrateConfig(): MigrateConfig;
3
+ //# sourceMappingURL=migrate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migrate.d.ts","sourceRoot":"","sources":["../../src/resolve/migrate.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAEvD,wBAAgB,oBAAoB,IAAI,aAAa,CA2BpD"}
@@ -0,0 +1,29 @@
1
+ // hazo_env/src/resolve/migrate.ts — read [migrate] section from hazo_env_config.ini
2
+ import path from 'node:path';
3
+ import { HazoConfig } from 'hazo_config/server';
4
+ export function resolveMigrateConfig() {
5
+ try {
6
+ const filePath = path.resolve(process.cwd(), 'config', 'hazo_env_config.ini');
7
+ const config = new HazoConfig({ filePath });
8
+ const section = config.getSection('migrate');
9
+ if (!section)
10
+ return {};
11
+ const tables = section['tables']
12
+ ? section['tables'].split(',').map((t) => t.trim()).filter(Boolean)
13
+ : undefined;
14
+ const preserve = section['preserve']
15
+ ? section['preserve'].split(',').map((t) => t.trim()).filter(Boolean)
16
+ : undefined;
17
+ // Parse per-table pk overrides: keys like "pk.some_table = custom_col"
18
+ const pkOverrides = {};
19
+ for (const [k, v] of Object.entries(section)) {
20
+ if (k.startsWith('pk.') && typeof v === 'string') {
21
+ pkOverrides[k.slice(3)] = v.trim();
22
+ }
23
+ }
24
+ return { tables, preserve, ...(Object.keys(pkOverrides).length ? { pkOverrides } : {}) };
25
+ }
26
+ catch {
27
+ return {};
28
+ }
29
+ }