hazo_env 0.3.0 → 0.4.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 CHANGED
@@ -1,5 +1,26 @@
1
1
  # hazo_env — Change Log
2
2
 
3
+ ## 0.4.0 — 2026-06-25
4
+
5
+ ### Added
6
+ - `verifyFiles()` L2–L5 — DB-backed file existence (L2), size check (L3), hash check L4 (`full|sample|none`; sample = 1% + top-10 by size + top-10 by changed_at), orphan detection L5. Previously all layers were stubs.
7
+ - `VerifyOptions.adapter` — pass a `HazoConnectAdapter` to enable DB-backed checks; without it verify falls back to sentinel-only (disk) and sets `report.skippedReason`.
8
+ - `VerifyReport` — new fields: `hashed?: number`, `skippedReason?: string`.
9
+ - `runMigration` — step 7 verify now passes `tgtAdapter`; emits detailed warning on `!ok` (counts + snapshotId) but continues (G2: warns-not-aborts).
10
+ - CLI `hazo-env verify` — new `--hash full|sample|none` flag (default `sample`); exits 1 on failure (CI-safe).
11
+ - `doctor()` — section 7a: `_migrations` parity check across all SQLite envs (`--probe --all`); section 7b: masking ruleset column validation against live schema.
12
+ - `DoctorOptions.probe` + `DoctorOptions.all` — enable live DB reachability probes + multi-env checks.
13
+ - `hazo_files` added as optional peer dependency (`^3.5.0`).
14
+
15
+ ### Changed
16
+ - `DoctorReport.passed` replaces `DoctorReport.ok` (previously typo in docs).
17
+ - `DoctorCheck.status` is `'ok'|'warn'|'error'` (not `boolean`).
18
+
19
+ ### Test-app
20
+ - Seed script creates 7 `hazo_files` fixture rows (3 clean, 1 size-mismatch, 1 hash-mismatch, 1 missing, 1 orphan) + physical files under `data/test/files/`.
21
+ - `/api/verify` route + `/verify` report page added.
22
+ - `'verify-fixtures'` autotest scenario added (5 cases).
23
+
3
24
  ## 0.3.0 — 2026-06-10
4
25
 
5
26
  ### Added
package/README.md CHANGED
@@ -10,7 +10,7 @@ Canonical environment resolver for hazo apps. Typed env names, per-env DB/file/s
10
10
  - **Secrets** — `getSecret()` resolves from `.env.local` only; placeholders in `hazo_env_config.ini` are substituted at runtime without storing secrets.
11
11
  - **Migration engine** — `runMigration({ from, to })` copies a DB + files between envs. Validates → snapshots target → copies tables (paged, schema-checked) → copies files → verifies → writes hazo_audit entry. Prod target refused without an explicit confirm token.
12
12
  - **PII masking** — masking rules declared per table/column in `hazo_env_masking.ini` (seed) and synced to `hazo_app_config` at runtime. Built-in transforms: `mask_email`, `fake_name`, `mask_phone`, `jitter_date`, `hash`, `tokenize`, `drop`, `nullify`. Custom transforms via `registerMask()`. Applied automatically when the migration target role is `test` or `staging`.
13
- - **Doctor** — `doctor()` / `hazo-env doctor` validates pattern, DB reachability, required secrets (no values printed), data_root writability, and schema level.
13
+ - **Doctor** — `doctor()` / `hazo-env doctor` validates pattern, DB reachability, required secrets (no values printed), data_root writability, schema level, and (with `--probe --all`) migration parity across envs + masking ruleset column validation.
14
14
  - **CLI** — `hazo-env current | doctor | snapshot | migrate | verify | restore | mask`.
15
15
 
16
16
  ## Installation
@@ -147,9 +147,11 @@ getSecret('POSTGREST_API_KEY', { required: true })
147
147
  ```ts
148
148
  import { doctor } from 'hazo_env';
149
149
 
150
- const report = await doctor();
151
- // report.ok boolean
152
- // report.checks DoctorCheck[] — { label, ok, message }
150
+ const report = await doctor({ probe: true, all: true });
151
+ // report.passed boolean
152
+ // report.checks DoctorCheck[] — { label, status: 'ok'|'warn'|'error', detail? }
153
+ // Migration-readiness checks (--probe --all): _migrations parity across envs,
154
+ // masking ruleset column validation against live schema.
153
155
  ```
154
156
 
155
157
  ### Migration engine (server-only)
@@ -171,8 +173,10 @@ const result = await runMigration({
171
173
  });
172
174
  // result.ok, result.db, result.files, result.snapshotId, result.warnings, result.durationMs
173
175
 
174
- // Stand-alone verification
175
- await verifyFiles('staging', dataRoot, { hash: 'sample' });
176
+ // Stand-alone verification (with optional hazo_connect adapter for L2–L5 checks)
177
+ await verifyFiles('staging', dataRoot, { hash: 'sample', checkOrphans: true, adapter });
178
+ // report.ok, report.checked, report.hashed, report.missing[], report.sizeMismatch[],
179
+ // report.hashMismatch[], report.orphans[], report.skippedReason?
176
180
 
177
181
  // Manual snapshot / restore
178
182
  const snap = takeSnapshot(connectConfig); // returns { snapshotId }
@@ -94,6 +94,12 @@ All checks should be green before going to production. The doctor validates:
94
94
  - All required secrets are present in `.env.local` (values are never printed)
95
95
  - `data_root` exists and is writable
96
96
 
97
+ Add `--probe --all` for migration-readiness checks:
98
+ ```bash
99
+ npx hazo-env doctor --probe --all
100
+ ```
101
+ This additionally checks: live DB reachability for all envs, `_migrations` table parity across envs, masking ruleset column validation against live schema.
102
+
97
103
  ## 8. (Next.js) Ensure `hazo_env` is in `transpilePackages`
98
104
 
99
105
  ```js
package/dist/cli.js CHANGED
@@ -122,12 +122,22 @@ async function runMigrate() {
122
122
  }
123
123
  async function runVerify() {
124
124
  const targetEnv = args[0] ?? getEnv();
125
- console.log(`\n${pc.bold('hazo-env verify')} ${pc.dim(targetEnv)}\n`);
125
+ // Parse --hash full|sample|none (default: sample) and --no-orphans
126
+ const hashIdx = args.indexOf('--hash');
127
+ const hashArg = hashIdx >= 0 ? args[hashIdx + 1] : 'sample';
128
+ const hash = (hashArg === 'full' || hashArg === 'none' || hashArg === 'sample') ? hashArg : 'sample';
129
+ const checkOrphans = !args.includes('--no-orphans');
130
+ console.log(`\n${pc.bold('hazo-env verify')} ${pc.dim(targetEnv)} ${pc.dim(`--hash ${hash}`)}\n`);
126
131
  const { verifyFiles } = await import('./migrate/verify.js');
127
132
  const { resolveFilesConfig } = await import('./resolve/files.js');
128
133
  const dataRoot = resolveFilesConfig().local.basePath;
129
- const report = await verifyFiles(targetEnv, dataRoot, { hash: 'sample', checkOrphans: true });
130
- console.log(` Checked: ${report.checked} files`);
134
+ const report = await verifyFiles(targetEnv, dataRoot, { hash, checkOrphans });
135
+ if (report.skippedReason) {
136
+ console.log(` ${pc.yellow('⚠')} ${report.skippedReason}`);
137
+ }
138
+ console.log(` Checked: ${report.checked} files${report.hashed != null ? `, hashed: ${report.hashed}` : ''}`);
139
+ if (report.orphans.length)
140
+ console.log(` ${pc.dim('ℹ')} Orphans (no DB row): ${report.orphans.join(', ')}`);
131
141
  if (report.ok) {
132
142
  console.log(` ${pc.green('✓')} All files verified\n`);
133
143
  }
@@ -136,8 +146,10 @@ async function runVerify() {
136
146
  console.log(` ${pc.red('✗')} Missing: ${report.missing.join(', ')}`);
137
147
  if (report.sizeMismatch.length)
138
148
  console.log(` ${pc.yellow('⚠')} Size mismatch: ${report.sizeMismatch.join(', ')}`);
149
+ if (report.hashMismatch.length)
150
+ console.log(` ${pc.yellow('⚠')} Hash mismatch: ${report.hashMismatch.join(', ')}`);
139
151
  console.log('');
140
- process.exit(1);
152
+ process.exit(1); // S4: standalone verify exits 1 on failure (CI-friendly)
141
153
  }
142
154
  }
143
155
  async function runRestore() {
@@ -221,6 +233,8 @@ Usage:
221
233
  --no-db / --no-files Skip DB or file copy
222
234
  --allow-prod-target --confirm <token> Override prod-target safety
223
235
  hazo-env verify <env> Verify files for an environment
236
+ --hash full|sample|none Hash-check all / sampled / none (default: sample)
237
+ --no-orphans Skip orphan detection
224
238
  hazo-env restore <env> --snapshot <id> Restore a snapshot
225
239
  hazo-env snapshot <env> Take a snapshot of an environment
226
240
  hazo-env mask sync Load masking ruleset from INI into DB
@@ -1 +1 @@
1
- {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../src/doctor.ts"],"names":[],"mappings":"AAcA,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,IAAI,GAAG,MAAM,GAAG,OAAO,CAAC;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,uEAAuE;IACvE,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,kEAAkE;IAClE,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAMD;;;GAGG;AACH,wBAAsB,MAAM,CAAC,IAAI,GAAE,aAAkB,GAAG,OAAO,CAAC,YAAY,CAAC,CA2J5E"}
1
+ {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../src/doctor.ts"],"names":[],"mappings":"AAcA,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,IAAI,GAAG,MAAM,GAAG,OAAO,CAAC;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,uEAAuE;IACvE,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,kEAAkE;IAClE,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAMD;;;GAGG;AACH,wBAAsB,MAAM,CAAC,IAAI,GAAE,aAAkB,GAAG,OAAO,CAAC,YAAY,CAAC,CA8R5E"}
package/dist/doctor.js CHANGED
@@ -166,6 +166,158 @@ export async function doctor(opts = {}) {
166
166
  });
167
167
  }
168
168
  }
169
+ // 7. Migration readiness checks
170
+ // 7a. _migrations parity (probe mode, --all): open each SQLite env and compare name-sets
171
+ if (opts.probe && opts.all) {
172
+ const migSets = new Map();
173
+ for (const env of envsToCheck) {
174
+ let cfg;
175
+ try {
176
+ cfg = resolveConnectConfig({ env, allowOtherEnv: true });
177
+ }
178
+ catch {
179
+ continue;
180
+ }
181
+ if (cfg.type !== 'sqlite' || !cfg.sqlite)
182
+ continue;
183
+ try {
184
+ const Database = require('better-sqlite3');
185
+ const db = Database(cfg.sqlite.database_path);
186
+ let names;
187
+ try {
188
+ names = db.prepare('SELECT name FROM _migrations').all().map((r) => r.name);
189
+ }
190
+ catch {
191
+ names = [];
192
+ }
193
+ db.close();
194
+ migSets.set(env, new Set(names));
195
+ }
196
+ catch {
197
+ // DB not openable — skip (probe check already reported error)
198
+ }
199
+ }
200
+ if (migSets.size >= 2) {
201
+ const envList = [...migSets.keys()];
202
+ const referenceSet = migSets.get(envList[0]);
203
+ const referenceEnv = envList[0];
204
+ for (const env of envList.slice(1)) {
205
+ const s = migSets.get(env);
206
+ const onlyInRef = [...referenceSet].filter((n) => !s.has(n));
207
+ const onlyInEnv = [...s].filter((n) => !referenceSet.has(n));
208
+ if (onlyInRef.length === 0 && onlyInEnv.length === 0) {
209
+ checks.push({ label: `Migration parity (${referenceEnv} ↔ ${env})`, status: 'ok', detail: `${s.size} applied` });
210
+ }
211
+ else {
212
+ const detail = [
213
+ onlyInRef.length ? `only in ${referenceEnv}: ${onlyInRef.join(', ')}` : '',
214
+ onlyInEnv.length ? `only in ${env}: ${onlyInEnv.join(', ')}` : '',
215
+ ].filter(Boolean).join('; ');
216
+ checks.push({ label: `Migration parity (${referenceEnv} ↔ ${env})`, status: 'warn', detail });
217
+ }
218
+ }
219
+ }
220
+ }
221
+ // 7b. Masking ruleset column validation: every (table, column) in the masking INI must
222
+ // exist in at least one SQLite env's schema. Skip silently for PostgREST envs.
223
+ const maskingConfigPath = path.resolve(process.cwd(), 'config', 'hazo_env_masking.ini');
224
+ if (fs.existsSync(maskingConfigPath)) {
225
+ try {
226
+ // Parse the masking INI directly — HazoConfig doesn't expose section enumeration.
227
+ // Format: each [section] is a table name; keys are column names.
228
+ const iniContent = fs.readFileSync(maskingConfigPath, 'utf-8');
229
+ const rules = [];
230
+ let currentTable = '';
231
+ for (const rawLine of iniContent.split('\n')) {
232
+ const line = rawLine.trim();
233
+ if (!line || line.startsWith(';') || line.startsWith('#'))
234
+ continue;
235
+ const sectionMatch = line.match(/^\[([^\]]+)\]$/);
236
+ if (sectionMatch) {
237
+ currentTable = sectionMatch[1].trim();
238
+ continue;
239
+ }
240
+ if (!currentTable)
241
+ continue;
242
+ const eqIdx = line.indexOf('=');
243
+ if (eqIdx < 0)
244
+ continue;
245
+ const column = line.slice(0, eqIdx).trim();
246
+ if (column)
247
+ rules.push({ table: currentTable, column });
248
+ }
249
+ if (rules.length > 0) {
250
+ // Collect SQLite schemas for checked envs
251
+ const schemas = new Map(); // "table.column" → present
252
+ for (const env of envsToCheck) {
253
+ let cfg;
254
+ try {
255
+ cfg = resolveConnectConfig({ env, allowOtherEnv: true });
256
+ }
257
+ catch {
258
+ continue;
259
+ }
260
+ if (cfg.type !== 'sqlite' || !cfg.sqlite)
261
+ continue;
262
+ try {
263
+ const Database = require('better-sqlite3');
264
+ const db = Database(cfg.sqlite.database_path);
265
+ try {
266
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name);
267
+ for (const tbl of tables) {
268
+ const cols = db.prepare(`PRAGMA table_info(${tbl})`).all();
269
+ for (const col of cols) {
270
+ schemas.set(`${tbl}.${col.name}`, new Set());
271
+ }
272
+ }
273
+ }
274
+ catch { /* DB empty or unreadable */ }
275
+ db.close();
276
+ }
277
+ catch { /* DB not openable */ }
278
+ }
279
+ if (schemas.size > 0) {
280
+ const badRules = [];
281
+ for (const { table, column } of rules) {
282
+ if (!schemas.has(`${table}.${column}`)) {
283
+ badRules.push(`${table}.${column}`);
284
+ }
285
+ }
286
+ if (badRules.length === 0) {
287
+ checks.push({ label: 'Masking ruleset columns', status: 'ok', detail: `${rules.length} rule(s) validated` });
288
+ }
289
+ else {
290
+ checks.push({
291
+ label: 'Masking ruleset columns',
292
+ status: 'warn',
293
+ detail: `Column(s) not found in schema: ${badRules.join(', ')} (PostgREST envs skipped)`,
294
+ });
295
+ }
296
+ }
297
+ else {
298
+ // No SQLite envs probed — can't validate columns; skip silently
299
+ const hasPostgrest = envsToCheck.some((env) => {
300
+ try {
301
+ return resolveConnectConfig({ env, allowOtherEnv: true }).type === 'postgrest';
302
+ }
303
+ catch {
304
+ return false;
305
+ }
306
+ });
307
+ if (hasPostgrest) {
308
+ checks.push({
309
+ label: 'Masking ruleset columns',
310
+ status: 'ok',
311
+ detail: 'PostgREST env — column validation skipped',
312
+ });
313
+ }
314
+ }
315
+ }
316
+ }
317
+ catch {
318
+ // Masking config parse failure — non-fatal, skip
319
+ }
320
+ }
169
321
  const passed = checks.every((c) => c.status !== 'error');
170
322
  return { env: targetEnv, checks, passed };
171
323
  }
@@ -1,6 +1,9 @@
1
1
  import type { HazoConnectAdapter } from 'hazo_connect';
2
2
  export interface DbCopyOptions {
3
+ type?: 'sqlite' | 'postgrest';
3
4
  tables?: '*' | string[];
5
+ preserve?: string[];
6
+ pkOverrides?: Record<string, string>;
4
7
  scrubHook?: (tableName: string, row: Record<string, unknown>) => Record<string, unknown>;
5
8
  onProgress?: (msg: string) => void;
6
9
  }
@@ -1 +1 @@
1
- {"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/migrate/db.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEvD,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,EAAE,GAAG,GAAG,MAAM,EAAE,CAAC;IACxB,SAAS,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACzF,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;CAClB;AA6BD,wBAAsB,MAAM,CAC1B,UAAU,EAAE,kBAAkB,EAC9B,UAAU,EAAE,kBAAkB,EAC9B,IAAI,GAAE,aAAkB,GACvB,OAAO,CAAC,YAAY,CAAC,CA+GvB"}
1
+ {"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/migrate/db.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEvD,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,EAAE,QAAQ,GAAG,WAAW,CAAC;IAC9B,MAAM,CAAC,EAAE,GAAG,GAAG,MAAM,EAAE,CAAC;IACxB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,SAAS,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACzF,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;CAClB;AA6BD,wBAAsB,MAAM,CAC1B,UAAU,EAAE,kBAAkB,EAC9B,UAAU,EAAE,kBAAkB,EAC9B,IAAI,GAAE,aAAkB,GACvB,OAAO,CAAC,YAAY,CAAC,CAqHvB"}
@@ -21,6 +21,11 @@ async function getMigrationNames(adapter) {
21
21
  return rows.map((r) => r.name);
22
22
  }
23
23
  export async function copyDb(srcAdapter, tgtAdapter, opts = {}) {
24
+ // PostgREST path: dispatch to dedicated implementation
25
+ if (opts.type === 'postgrest') {
26
+ const { copyDbPostgrest } = await import('./db.postgrest.js');
27
+ return copyDbPostgrest(srcAdapter, tgtAdapter, opts);
28
+ }
24
29
  const connect = await optional_import('hazo_connect/server');
25
30
  if (!connect) {
26
31
  throw new HazoError({
@@ -29,7 +34,7 @@ export async function copyDb(srcAdapter, tgtAdapter, opts = {}) {
29
34
  message: 'hazo_connect is required for DB migration. Install hazo_connect to continue.',
30
35
  });
31
36
  }
32
- // Schema parity check via _migrations
37
+ // Schema parity check via _migrations (SQLite only — PostgREST apps may not use _migrations)
33
38
  const srcMigrations = await getMigrationNames(srcAdapter);
34
39
  const tgtMigrations = await getMigrationNames(tgtAdapter);
35
40
  if (srcMigrations === null || tgtMigrations === null) {
@@ -0,0 +1,4 @@
1
+ import type { HazoConnectAdapter } from 'hazo_connect';
2
+ import type { DbCopyOptions, DbCopyResult } from './db.js';
3
+ export declare function copyDbPostgrest(srcAdapter: HazoConnectAdapter, tgtAdapter: HazoConnectAdapter, opts?: DbCopyOptions): Promise<DbCopyResult>;
4
+ //# sourceMappingURL=db.postgrest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"db.postgrest.d.ts","sourceRoot":"","sources":["../../src/migrate/db.postgrest.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEvD,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAM3D,wBAAsB,eAAe,CACnC,UAAU,EAAE,kBAAkB,EAC9B,UAAU,EAAE,kBAAkB,EAC9B,IAAI,GAAE,aAAkB,GACvB,OAAO,CAAC,YAAY,CAAC,CA6GvB"}
@@ -0,0 +1,93 @@
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
+ opts.onProgress?.(`Copying table: ${table}`);
47
+ const pk = pkOverrides[table] ?? 'id';
48
+ const srcService = connect.createCrudService(srcAdapter, table, { autoId: false });
49
+ const tgtService = connect.createCrudService(tgtAdapter, table, { autoId: false });
50
+ let offset = 0;
51
+ let pageRows = [];
52
+ do {
53
+ pageRows = await srcService.list((qb) =>
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
+ qb.order(pk, 'asc').limit(PAGE_SIZE).offset(offset));
56
+ if (pageRows.length === 0)
57
+ break;
58
+ const batch = [];
59
+ for (let row of pageRows) {
60
+ const cleaned = {};
61
+ let didScrub = false;
62
+ for (const [col, val] of Object.entries(row)) {
63
+ if (isSecretColumn(col)) {
64
+ didScrub = true;
65
+ }
66
+ else {
67
+ cleaned[col] = val;
68
+ }
69
+ }
70
+ let processedRow = cleaned;
71
+ if (opts.scrubHook) {
72
+ const hooked = opts.scrubHook(table, cleaned);
73
+ if (hooked !== cleaned)
74
+ didScrub = true;
75
+ processedRow = hooked;
76
+ }
77
+ if (didScrub)
78
+ totalScrubbed++;
79
+ batch.push(processedRow);
80
+ }
81
+ if (batch.length > 0) {
82
+ await tgtService.insert(batch);
83
+ totalRows += batch.length;
84
+ opts.onProgress?.(` inserted ${batch.length} rows into ${table}`);
85
+ }
86
+ offset += PAGE_SIZE;
87
+ } while (pageRows.length === PAGE_SIZE);
88
+ }
89
+ // Note: PostgREST has no REST endpoint for NOTIFY. Schema reload only needed after DDL.
90
+ // If schema DDL changed since last reload, run: NOTIFY pgrst, 'reload schema' in Postgres.
91
+ opts.onProgress?.("DB copy complete. If schema DDL changed, run NOTIFY pgrst, 'reload schema' in Postgres.");
92
+ return { tables: tablesToProcess.length, rows: totalRows, scrubbed: totalScrubbed };
93
+ }
@@ -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":"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;AA0BD,wBAAsB,SAAS,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CA4ChF"}
@@ -6,11 +6,9 @@
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 { resolveFilesRoot } from '../resolve/files.js';
10
10
  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');
11
+ return resolveFilesRoot(env);
14
12
  }
15
13
  /**
16
14
  * Attempt to scrub a PDF file in-place using hazo_pdf/mask_pdf.
@@ -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,CAkLlF"}
@@ -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,12 +168,16 @@ 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 }),
155
183
  });
@@ -169,9 +197,16 @@ export async function runMigration(req) {
169
197
  // Step 7: Verify
170
198
  emitProgress(req, { phase: 'verify', message: 'Running post-migration verification', percent: 80 });
171
199
  const dataRoot = resolveFilesConfig().local.basePath;
172
- const verifyReport = await verifyFiles(req.to, dataRoot, { hash: 'sample', checkOrphans: true });
200
+ const verifyReport = await verifyFiles(req.to, dataRoot, { hash: 'sample', checkOrphans: true, adapter: tgtAdapter });
173
201
  if (!verifyReport.ok) {
174
- warnings.push(`Verification found issues: missing=${verifyReport.missing.length}, sizeMismatch=${verifyReport.sizeMismatch.length}`);
202
+ const parts = [];
203
+ if (verifyReport.missing.length)
204
+ parts.push(`missing=${verifyReport.missing.length}`);
205
+ if (verifyReport.sizeMismatch.length)
206
+ parts.push(`sizeMismatch=${verifyReport.sizeMismatch.length}`);
207
+ if (verifyReport.hashMismatch.length)
208
+ parts.push(`hashMismatch=${verifyReport.hashMismatch.length}`);
209
+ warnings.push(`Verify found issues (${parts.join(', ')}) — migration continues; snapshotId=${snapshot.snapshotId}`);
175
210
  }
176
211
  // Step 8: Finalize — audit
177
212
  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',
@@ -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
+ }
@@ -93,6 +93,14 @@ export interface VerifyReport {
93
93
  hashMismatch: string[];
94
94
  orphans: string[];
95
95
  sentinelOk: boolean;
96
+ hashed?: number;
97
+ skippedReason?: string;
98
+ }
99
+ /** Parsed [migrate] section from hazo_env_config.ini */
100
+ export interface MigrateConfig {
101
+ tables?: string[];
102
+ preserve?: string[];
103
+ pkOverrides?: Record<string, string>;
96
104
  }
97
105
  export type MaskTransform = (value: unknown, key: string, maskKey: string) => unknown;
98
106
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hazo_env",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Canonical environment resolver — typed env names, per-env DB/file/secret config, doctor and CLI for hazo apps",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -38,10 +38,10 @@
38
38
  "picocolors": "^1.1.1"
39
39
  },
40
40
  "peerDependencies": {
41
- "hazo_core": "^1.1.0",
42
- "hazo_config": "^2.1.10",
43
- "hazo_connect": "^3.4.1",
44
- "hazo_files": "^3.0.0",
41
+ "hazo_core": "^1.2.0",
42
+ "hazo_config": "^2.4.1",
43
+ "hazo_connect": "^3.9.0",
44
+ "hazo_files": "^3.1.1",
45
45
  "hazo_secure": "^1.2.0",
46
46
  "hazo_audit": "^2.1.0",
47
47
  "hazo_pdf": "^2.0.0",
@@ -84,10 +84,10 @@
84
84
  "@types/node": "^22.10.0",
85
85
  "@types/react": "^19.0.0",
86
86
  "@types/react-dom": "^19.0.0",
87
- "hazo_core": "^1.1.0",
88
- "hazo_config": "^2.1.10",
89
- "hazo_connect": "^3.4.1",
90
- "hazo_files": "^3.0.0",
87
+ "hazo_core": "^1.2.1",
88
+ "hazo_config": "^2.4.1",
89
+ "hazo_connect": "^3.9.0",
90
+ "hazo_files": "^3.1.1",
91
91
  "next": "^16.0.10",
92
92
  "react": "^19.0.0",
93
93
  "react-dom": "^19.0.0",