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
package/CHANGE_LOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # hazo_env — Change Log
2
2
 
3
+ ## 0.5.0 — 2026-06-25
4
+
5
+ ### Added
6
+ - `clearEnv()` — delete all non-preserved tables via PostgREST (reverse FK order) + optional file-dir wipe. Exported from `src/index.ts`. CLI: `hazo-env clear <env>` (prod-target guard: `--allow-prod-target --confirm=<token>`).
7
+ - `ClearRequest` / `ClearResult` types in `src/types/index.ts`; `'clear'` added to `MigrationProgress.phase` union.
8
+ - rsync-over-SSH file transport — `copyFiles()` auto-dispatches to rsync when `[transport.ssh.<fromEnv>]` is configured. `copyFilesRsync()` in `src/migrate/files.ts` spawns `rsync -az --partial --info=progress2`, parses `to-chk=N/TOTAL` for live percent. `onProgress` signature extended with optional `percent` arg.
9
+ - `resolveSshConfig(env)` in `src/resolve/ssh.ts` — reads `[transport.ssh.<env>]` section (host/user/key/path) with `${VAR}` expansion.
10
+ - `resolveTransport()` return type widened to `'local' | 'api' | 'rsync'`; `'ssh'` input now returns `'rsync'` instead of throwing.
11
+ - Per-table DB progress — `DbCopyOptions.onTableProgress` callback fires once per table during PostgREST copy pass; `run.ts` maps it to the 20→70% band with a `"table N/M: <name>"` message.
12
+ - `[transport.ssh.<env>]` section added to `config/hazo_env_config.ini.sample`.
13
+
14
+ ### Test-app
15
+ - `POST /api/clear` route added (mirrors `/api/migrate`).
16
+ - `'clear-env'` autotest scenario added (dry-run guard: verifies prod-target refuses without confirm).
17
+
18
+ ## 0.4.0 — 2026-06-25
19
+
20
+ ### Added
21
+ - `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.
22
+ - `VerifyOptions.adapter` — pass a `HazoConnectAdapter` to enable DB-backed checks; without it verify falls back to sentinel-only (disk) and sets `report.skippedReason`.
23
+ - `VerifyReport` — new fields: `hashed?: number`, `skippedReason?: string`.
24
+ - `runMigration` — step 7 verify now passes `tgtAdapter`; emits detailed warning on `!ok` (counts + snapshotId) but continues (G2: warns-not-aborts).
25
+ - CLI `hazo-env verify` — new `--hash full|sample|none` flag (default `sample`); exits 1 on failure (CI-safe).
26
+ - `doctor()` — section 7a: `_migrations` parity check across all SQLite envs (`--probe --all`); section 7b: masking ruleset column validation against live schema.
27
+ - `DoctorOptions.probe` + `DoctorOptions.all` — enable live DB reachability probes + multi-env checks.
28
+ - `hazo_files` added as optional peer dependency (`^3.5.0`).
29
+
30
+ ### Changed
31
+ - `DoctorReport.passed` replaces `DoctorReport.ok` (previously typo in docs).
32
+ - `DoctorCheck.status` is `'ok'|'warn'|'error'` (not `boolean`).
33
+
34
+ ### Test-app
35
+ - 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/`.
36
+ - `/api/verify` route + `/verify` report page added.
37
+ - `'verify-fixtures'` autotest scenario added (5 cases).
38
+
3
39
  ## 0.3.0 — 2026-06-10
4
40
 
5
41
  ### 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
@@ -62,3 +62,21 @@ location = remote
62
62
 
63
63
  [host.prod]
64
64
  location = remote
65
+
66
+ ; ─── SSH transport for cross-host file rsync ─────────────────────────────────
67
+ ; When [transport.ssh.<env>] is present, copyFiles will use rsync-over-SSH
68
+ ; to pull files from that environment's file server instead of local copy.
69
+ ; This enables prod→dev migration when both envs run on separate hosts.
70
+
71
+ [transport.ssh.prod]
72
+ ; SSH connection to the prod file server (used when pulling prod→dev).
73
+ ; The worker runs rsync FROM prod TO dev — prod stays read-only.
74
+ host = ${SSH_HOST_PROD}
75
+ user = ${SSH_USER_PROD}
76
+ key = ${SSH_KEY_PATH_PROD}
77
+ path = /home/pubs/kinstripe/var/uploads
78
+ ; Optional: pin the server's host key (output of `ssh-keyscan <host>` minus the
79
+ ; leading hostname, e.g. "ssh-ed25519 AAAA..."). When set, host-key checking is
80
+ ; strict and pinned — no trust-on-first-use. When omitted, the first connection
81
+ ; is accepted (accept-new) and persisted to config/hazo_env_known_hosts.
82
+ ; host_key = ${SSH_HOST_KEY_PROD}
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() {
@@ -155,6 +167,39 @@ async function runRestore() {
155
167
  restoreSnapshot(toConfig, snapshotId);
156
168
  console.log(` ${pc.green('✓')} Restored\n`);
157
169
  }
170
+ async function runClear() {
171
+ const targetEnv = args[0] ?? getEnv();
172
+ const allowProd = args.includes('--allow-prod-target');
173
+ const confirm = args.find((a) => a.startsWith('--confirm='))?.split('=')[1];
174
+ console.log(`\n${pc.bold('hazo-env clear')} ${pc.dim(targetEnv)}\n`);
175
+ const { clearEnv } = await import('./migrate/clear.js');
176
+ try {
177
+ let lastMsg = '';
178
+ const result = await clearEnv({
179
+ env: targetEnv,
180
+ allowProdTarget: allowProd,
181
+ confirmToken: confirm,
182
+ onProgress: (p) => {
183
+ if (p.message !== lastMsg) {
184
+ const pctStr = p.percent != null ? ` [${p.percent}%]` : '';
185
+ console.log(` ${pc.dim('·')} ${p.message}${pctStr}`);
186
+ lastMsg = p.message;
187
+ }
188
+ },
189
+ });
190
+ if (result.ok) {
191
+ console.log(` ${pc.green('✓')} Cleared ${result.tablesCleared} tables${result.filesCleared ? ' + files' : ''} in ${result.durationMs}ms\n`);
192
+ if (result.warnings.length)
193
+ result.warnings.forEach((w) => console.log(` ${pc.yellow('⚠')} ${w}`));
194
+ }
195
+ }
196
+ catch (e) {
197
+ const msg = e instanceof Error ? e.message : String(e);
198
+ console.error(`\n ${pc.red('✗')} ${msg}\n`);
199
+ process.exit(1);
200
+ }
201
+ console.log('');
202
+ }
158
203
  async function runMask() {
159
204
  const subCmd = args[0];
160
205
  if (subCmd === 'sync') {
@@ -221,10 +266,14 @@ Usage:
221
266
  --no-db / --no-files Skip DB or file copy
222
267
  --allow-prod-target --confirm <token> Override prod-target safety
223
268
  hazo-env verify <env> Verify files for an environment
269
+ --hash full|sample|none Hash-check all / sampled / none (default: sample)
270
+ --no-orphans Skip orphan detection
224
271
  hazo-env restore <env> --snapshot <id> Restore a snapshot
225
272
  hazo-env snapshot <env> Take a snapshot of an environment
226
273
  hazo-env mask sync Load masking ruleset from INI into DB
227
274
  hazo-env mask list List registered mask transforms
275
+ hazo-env clear <env> Wipe DB tables + files for an environment
276
+ --allow-prod-target --confirm=<token> Override prod-target safety
228
277
  `);
229
278
  }
230
279
  (async () => {
@@ -250,6 +299,9 @@ Usage:
250
299
  else if (command === 'mask') {
251
300
  await runMask();
252
301
  }
302
+ else if (command === 'clear') {
303
+ await runClear();
304
+ }
253
305
  else {
254
306
  printHelp();
255
307
  }
@@ -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
  }
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export * from './resolve/connect.js';
6
6
  export * from './resolve/files.js';
7
7
  export * from './doctor.js';
8
8
  export { runMigration } from './migrate/run.js';
9
+ export { clearEnv } from './migrate/clear.js';
9
10
  export { verifyFiles } from './migrate/verify.js';
10
11
  export { takeSnapshot, restoreSnapshot } from './migrate/snapshot.js';
11
12
  export { writeMigrationProgress, readMigrationProgress, clearMigrationProgress } from './migrate/progress.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAE7E,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAEvG,cAAc,kBAAkB,CAAC;AAEjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,oBAAoB,CAAC;AAEnC,cAAc,aAAa,CAAC;AAE5B,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACtE,OAAO,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AAE9G,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAElD,OAAO,EAAE,WAAW,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AACnF,YAAY,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAClD,YAAY,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,cAAc,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAE7E,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAEvG,cAAc,kBAAkB,CAAC;AAEjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,oBAAoB,CAAC;AAEnC,cAAc,aAAa,CAAC;AAE5B,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACtE,OAAO,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AAE9G,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAElD,OAAO,EAAE,WAAW,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AACnF,YAAY,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAClD,YAAY,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,cAAc,gBAAgB,CAAC"}
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ export * from './resolve/files.js';
13
13
  export * from './doctor.js';
14
14
  // Migration engine (Phase 2)
15
15
  export { runMigration } from './migrate/run.js';
16
+ export { clearEnv } from './migrate/clear.js';
16
17
  export { verifyFiles } from './migrate/verify.js';
17
18
  export { takeSnapshot, restoreSnapshot } from './migrate/snapshot.js';
18
19
  export { writeMigrationProgress, readMigrationProgress, clearMigrationProgress } from './migrate/progress.js';
@@ -0,0 +1,3 @@
1
+ import type { ClearRequest, ClearResult } from '../types/index.js';
2
+ export declare function clearEnv(req: ClearRequest): Promise<ClearResult>;
3
+ //# sourceMappingURL=clear.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"clear.d.ts","sourceRoot":"","sources":["../../src/migrate/clear.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAqB,MAAM,mBAAmB,CAAC;AAYtF,wBAAsB,QAAQ,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC,CAoHtE"}
@@ -0,0 +1,117 @@
1
+ // hazo_env/src/migrate/clear.ts — clearEnv: wipe DB tables + files for a target environment
2
+ import { HazoError, optional_import } from 'hazo_core';
3
+ import { getEnvRole } from '../env.server.js';
4
+ import { resolveMigrateConfig } from '../resolve/migrate.js';
5
+ import { resolveConnectConfig } from '../resolve/connect.js';
6
+ import { resolveFilesRoot } from '../resolve/files.js';
7
+ import { writeMigrationProgress } from './progress.js';
8
+ import fs from 'node:fs';
9
+ function emitProgress(req, p) {
10
+ req.onProgress?.(p);
11
+ if (req.jobId && req.progressDir) {
12
+ writeMigrationProgress(req.progressDir, req.jobId, p);
13
+ }
14
+ }
15
+ export async function clearEnv(req) {
16
+ const startMs = Date.now();
17
+ const warnings = [];
18
+ // Guard: refuse production target unless explicitly unlocked with a confirm token
19
+ const role = getEnvRole(req.env);
20
+ if (role === 'production' && !req.allowProdTarget) {
21
+ throw new HazoError({
22
+ code: 'HAZO_ENV_PROD_TARGET_REFUSED',
23
+ pkg: 'hazo_env',
24
+ message: `Refusing to clear production environment "${req.env}". Pass allowProdTarget:true and a confirmToken to override.`,
25
+ });
26
+ }
27
+ if (role === 'production' && req.allowProdTarget && !req.confirmToken) {
28
+ throw new HazoError({
29
+ code: 'HAZO_ENV_PROD_TARGET_REFUSED',
30
+ pkg: 'hazo_env',
31
+ message: 'Production target requires a confirmToken for safety.',
32
+ });
33
+ }
34
+ emitProgress(req, { phase: 'validate', message: 'Validating clear request', percent: 0 });
35
+ const migrateConfig = resolveMigrateConfig();
36
+ const { tables, preserve = [], pkOverrides = {} } = migrateConfig;
37
+ if (!tables || tables.length === 0) {
38
+ throw new HazoError({
39
+ code: 'HAZO_ENV_MISSING_TABLE_LIST',
40
+ pkg: 'hazo_env',
41
+ message: 'clearEnv requires an explicit table list. ' +
42
+ 'Set [migrate] tables = ... in hazo_env_config.ini.',
43
+ });
44
+ }
45
+ const tablesToClear = [...tables.filter((t) => !preserve.includes(t))].reverse();
46
+ const dbConfig = resolveConnectConfig({ env: req.env, allowOtherEnv: true });
47
+ if (dbConfig.type !== 'postgrest') {
48
+ throw new HazoError({
49
+ code: 'HAZO_ENV_NOT_IMPLEMENTED',
50
+ pkg: 'hazo_env',
51
+ message: `clearEnv only supports PostgREST connections (got "${dbConfig.type}").`,
52
+ });
53
+ }
54
+ if (!dbConfig.postgrest) {
55
+ throw new HazoError({
56
+ code: 'HAZO_ENV_NOT_IMPLEMENTED',
57
+ pkg: 'hazo_env',
58
+ message: 'PostgREST config missing base_url/api_key.',
59
+ });
60
+ }
61
+ const connect = await optional_import('hazo_connect/server');
62
+ if (!connect) {
63
+ throw new HazoError({
64
+ code: 'HAZO_ENV_MISSING_DEPENDENCY',
65
+ pkg: 'hazo_env',
66
+ message: 'hazo_connect is required for clearEnv. Install hazo_connect to continue.',
67
+ });
68
+ }
69
+ const tgtAdapter = connect.createHazoConnect({
70
+ type: 'postgrest',
71
+ postgrest: {
72
+ base_url: dbConfig.postgrest.base_url,
73
+ api_key: dbConfig.postgrest.api_key,
74
+ },
75
+ });
76
+ // DB clear (only if not explicitly disabled)
77
+ let tablesCleared = 0;
78
+ if (req.include?.db !== false) {
79
+ for (let idx = 0; idx < tablesToClear.length; idx++) {
80
+ const table = tablesToClear[idx];
81
+ const percent = Math.round(((idx + 1) / tablesToClear.length) * 90);
82
+ emitProgress(req, {
83
+ phase: 'clear',
84
+ message: `Clearing table ${idx + 1}/${tablesToClear.length}: ${table}`,
85
+ percent,
86
+ });
87
+ const pk = pkOverrides[table] ?? 'id';
88
+ try {
89
+ await tgtAdapter.rawQuery(`/${table}?${pk}=not.is.null`, { method: 'DELETE' });
90
+ tablesCleared++;
91
+ }
92
+ catch (err) {
93
+ const msg = err instanceof Error ? err.message : String(err);
94
+ warnings.push(`Warning: could not clear ${table}: ${msg}`);
95
+ }
96
+ }
97
+ }
98
+ // File clear
99
+ let filesCleared = false;
100
+ if (req.include?.files !== false) {
101
+ emitProgress(req, { phase: 'clear', message: 'Clearing files...', percent: 92 });
102
+ const filesRoot = resolveFilesRoot(req.env);
103
+ if (fs.existsSync(filesRoot)) {
104
+ fs.rmSync(filesRoot, { recursive: true, force: true });
105
+ }
106
+ fs.mkdirSync(filesRoot, { recursive: true });
107
+ filesCleared = true;
108
+ }
109
+ emitProgress(req, { phase: 'done', message: 'Clear complete', percent: 100 });
110
+ return {
111
+ ok: true,
112
+ tablesCleared,
113
+ filesCleared,
114
+ durationMs: Date.now() - startMs,
115
+ warnings,
116
+ };
117
+ }
@@ -1,8 +1,12 @@
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;
9
+ onTableProgress?: (tableIdx: number, totalTables: number, tableName: string) => void;
6
10
  }
7
11
  export interface DbCopyResult {
8
12
  tables: number;
@@ -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;IACnC,eAAe,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;CACtF;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,CA+GvB"}