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.
- package/CHANGE_LOG.md +36 -0
- package/README.md +10 -6
- package/SETUP_CHECKLIST.md +6 -0
- package/config/hazo_env_config.ini.sample +18 -0
- package/dist/cli.js +56 -4
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +152 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/migrate/clear.d.ts +3 -0
- package/dist/migrate/clear.d.ts.map +1 -0
- package/dist/migrate/clear.js +117 -0
- package/dist/migrate/db.d.ts +4 -0
- package/dist/migrate/db.d.ts.map +1 -1
- package/dist/migrate/db.js +6 -1
- package/dist/migrate/db.postgrest.d.ts +4 -0
- package/dist/migrate/db.postgrest.d.ts.map +1 -0
- package/dist/migrate/db.postgrest.js +95 -0
- package/dist/migrate/files.d.ts +2 -1
- package/dist/migrate/files.d.ts.map +1 -1
- package/dist/migrate/files.js +85 -4
- package/dist/migrate/run.d.ts.map +1 -1
- package/dist/migrate/run.js +55 -15
- package/dist/migrate/snapshot.d.ts.map +1 -1
- package/dist/migrate/snapshot.js +3 -0
- package/dist/migrate/transport.d.ts +1 -1
- package/dist/migrate/transport.d.ts.map +1 -1
- package/dist/migrate/transport.js +2 -9
- package/dist/migrate/verify.d.ts +1 -0
- package/dist/migrate/verify.d.ts.map +1 -1
- package/dist/migrate/verify.js +120 -30
- package/dist/resolve/files.d.ts +6 -0
- package/dist/resolve/files.d.ts.map +1 -1
- package/dist/resolve/files.js +23 -0
- package/dist/resolve/migrate.d.ts +3 -0
- package/dist/resolve/migrate.d.ts.map +1 -0
- package/dist/resolve/migrate.js +29 -0
- package/dist/resolve/ssh.d.ts +15 -0
- package/dist/resolve/ssh.d.ts.map +1 -0
- package/dist/resolve/ssh.js +36 -0
- package/dist/types/index.d.ts +28 -1
- package/dist/types/index.d.ts.map +1 -1
- 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
|
+
}
|
package/dist/migrate/files.d.ts
CHANGED
|
@@ -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":"
|
|
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"}
|
package/dist/migrate/files.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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":"
|
|
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"}
|
package/dist/migrate/run.js
CHANGED
|
@@ -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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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"}
|
package/dist/migrate/snapshot.js
CHANGED
|
@@ -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":"
|
|
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
|
-
|
|
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
|
package/dist/migrate/verify.d.ts
CHANGED
|
@@ -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":"
|
|
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"}
|
package/dist/migrate/verify.js
CHANGED
|
@@ -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
|
|
16
|
-
// Layer 1:
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
75
|
+
if (!fs.existsSync(absPath)) {
|
|
76
|
+
missing.push(row.file_path);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
presentRows.push({ ...row, file_path: relPath });
|
|
39
80
|
}
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
}
|
package/dist/resolve/files.d.ts
CHANGED
|
@@ -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"}
|
package/dist/resolve/files.js
CHANGED
|
@@ -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 @@
|
|
|
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
|
+
}
|