migraguard 0.5.2 → 0.5.3

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/README.md CHANGED
@@ -19,7 +19,7 @@ Execution is deliberately simple: plain SQL files executed via `psql`. migraguar
19
19
  - **Tamper detection in CI (offline)** — Only the tail file (linear) or leaf nodes (DAG) are editable. `check` rejects changes to any other file without DB connection
20
20
  - **Regression detection** — If a hotfixed file reverts to an old checksum, `apply` raises an error immediately
21
21
  - **Failure blocking with explicit resolve** — A `failed` migration blocks all progress until a human explicitly judges and resolves it
22
- - **Drift gate + Idempotency proof** — two [verification mechanisms](#verification-two-distinct-mechanisms): `apply --with-drift-check` detects schema divergence before applying; `verify` proves migrations are safely re-executable on a shadow DB
22
+ - **Drift gate + Idempotency proof** — two [verification mechanisms](#verification-two-distinct-mechanisms): `apply --with-drift-check` detects local schema divergence before applying; `diff` verifies post-deploy schema consistency; `verify` proves migrations are safely re-executable on a shadow DB
23
23
  - **Mutual exclusion** — `apply` uses PostgreSQL advisory locks to prevent concurrent execution
24
24
  - **One release at a time** — the next migration cannot be added until the current release is deployed to all environments, ensuring the latest file is always hotfix-ready
25
25
 
@@ -65,6 +65,23 @@ migraguard separates file integrity and application state into two layers.
65
65
 
66
66
  metadata.json represents "which files should exist"; schema_migrations represents "what has been applied." This separation enables correct staged rollout from a single repository to multiple environments (staging, production).
67
67
 
68
+ ### Source of Truth: migrations (SSoT) vs schema.sql
69
+
70
+ migraguard treats **migration SQL files** as the **Single Source of Truth (SSoT)** for schema evolution.
71
+ They capture not only the end state, but also the *intent, ordering, and operational safety tactics* required for production changes.
72
+
73
+ `schema.sql` is a **derived artifact**:
74
+ - Generated from a real database via `dump` (pg_dump), and updated locally by `apply --with-drift-check`
75
+ - Used as an **expected-state snapshot** for drift detection (`diff`) and human review
76
+ - Not intended to be hand-edited or treated as the authoritative desired state
77
+
78
+ This design supports migraguard's incident-prevention model:
79
+ - Offline CI integrity checks (`check`) can reason about history and editability rules without a DB
80
+ - Regression detection can catch "hotfix reversion" back to a previous checksum
81
+ - Drift is treated as a deployment blocker unless explicitly resolved through the normal workflow
82
+
83
+ > If you prefer a "desired state" workflow where the schema definition itself is the SSoT and migrations are generated from it, consider tools like Atlas. migraguard is optimized for teams writing DDL directly with operational guardrails.
84
+
68
85
  ### Checksum Normalization
69
86
 
70
87
  Checksums are computed on **normalized SQL** (SHA-256): comments are stripped (`-- ...` and `/* ... */` including nested), whitespace is collapsed, string literals are preserved as-is. Adding comments, adjusting indentation, or inserting blank lines does not change the checksum; only actual SQL statement changes are detected. `-- migraguard:depends-on` directives are also comments and do not affect the checksum.
@@ -90,10 +107,11 @@ The table is **fully INSERT-only** — no UPDATEs. Every application attempt (in
90
107
 
91
108
  | Mechanism | Purpose | When to use |
92
109
  |-----------|---------|-------------|
93
- | `apply --with-drift-check` | **Drift gate**: detect unauthorized schema changes before apply, auto-update dump after | CI pipeline on merge to release branches |
110
+ | `apply --with-drift-check` | **Local drift gate**: detect unauthorized schema changes before apply, auto-update dump after | Local development before commit |
111
+ | `diff` | **Post-deploy verification**: confirm DB schema matches expected `schema.sql` after apply | CI pipeline on merge to release branches (run after `apply`) |
94
112
  | `verify` | **Idempotency proof**: apply migrations twice on a shadow DB, confirm no errors and no schema change | Before releases or in CI as a final safety net |
95
113
 
96
- `apply --with-drift-check` guards against drift; `verify` proves re-executability. Both are stronger than lint rules — they operate on actual DB state. See [docs/state-model.md](docs/state-model.md) for detailed flows.
114
+ `apply --with-drift-check` guards against drift in local development; `diff` verifies schema consistency after deployment; `verify` proves re-executability. All are stronger than lint rules — they operate on actual DB state. See [docs/state-model.md](docs/state-model.md) for detailed flows.
97
115
 
98
116
  ## Workflow
99
117
 
@@ -117,8 +135,8 @@ CI (PR):
117
135
  migraguard verify (optional) → idempotency proof on shadow DB
118
136
 
119
137
  Deploy:
120
- merge to db_dev → CI: apply --with-drift-check → staging
121
- merge to db_pro → CI: apply --with-drift-check → production
138
+ merge to db_dev → CI: apply diff → staging
139
+ merge to db_pro → CI: apply diff → production
122
140
  ```
123
141
 
124
142
  **Key rule**: Do not add the next migration file until the current release is deployed to all environments. This ensures the latest file can always be modified and re-applied for hotfixes.
@@ -150,7 +168,7 @@ See [docs/state-model.md](docs/state-model.md) for detailed apply, check, resolv
150
168
  | `new <name>` | Generate a new migration SQL file |
151
169
  | `squash` | Merge pending files into one for release |
152
170
  | `apply` | Execute pending migrations via `psql` |
153
- | `apply --with-drift-check` | Drift check → apply → dump update |
171
+ | `apply --with-drift-check` | Local: drift check → apply → dump update |
154
172
  | `resolve <file>` | Mark a failed migration as skipped (explicit judgment) |
155
173
  | `status` | Display migration status per file |
156
174
  | `editable` | List currently editable files (tail / leaf) |
@@ -207,7 +225,13 @@ jobs:
207
225
  node-version: '20'
208
226
  - run: npm ci
209
227
  - run: npx migraguard check
210
- - run: npx migraguard apply --with-drift-check
228
+ - run: npx migraguard apply
229
+ env:
230
+ PGHOST: ${{ secrets.DB_HOST }}
231
+ PGDATABASE: ${{ secrets.DB_NAME }}
232
+ PGUSER: ${{ secrets.DB_USER }}
233
+ PGPASSWORD: ${{ secrets.DB_PASSWORD }}
234
+ - run: npx migraguard diff
211
235
  env:
212
236
  PGHOST: ${{ secrets.DB_HOST }}
213
237
  PGDATABASE: ${{ secrets.DB_NAME }}
@@ -219,6 +243,7 @@ jobs:
219
243
 
220
244
  ```json
221
245
  {
246
+ "model": "dag",
222
247
  "migrationsDirs": ["db/migrations"],
223
248
  "schemaFile": "db/schema.sql",
224
249
  "metadataFile": "db/.migraguard/metadata.json",
@@ -251,6 +276,12 @@ jobs:
251
276
  }
252
277
  ```
253
278
 
279
+ ### Model Configuration
280
+
281
+ | Key | Default | Description |
282
+ |-----|---------|-------------|
283
+ | `model` | _(unset = linear)_ | Set to `"dag"` to enable DAG mode. When set in config, takes precedence over `metadata.json` |
284
+
254
285
  ### Naming Configuration
255
286
 
256
287
  | Key | Default | Description |
@@ -422,7 +453,7 @@ migraguard embeds operational policies into the tool and prevents incidents via
422
453
  |------|-----------|---------|-------|--------|------------------|
423
454
  | **Tamper detection** | checksum + CI gate (offline) | checksum (at apply time) | Merkle hash (atlas.sum) | Merkle tree (sqitch.plan) | none |
424
455
  | **Regression detection** | ✅ | ❌ | ❌ | ❌ | ❌ |
425
- | **Drift detection** | ✅ apply --with-drift-check | ❌ | ✅ schema diff | ❌ | ⚠️ |
456
+ | **Drift detection** | ✅ apply --with-drift-check (local) / diff (CI) | ❌ | ✅ schema diff | ❌ | ⚠️ |
426
457
  | **Idempotency verification** | ✅ verify (double-apply) | ❌ | ❌ | ❌ | ❌ |
427
458
  | **Parallel releases** | ✅ DAG | ❌ | ❌ | ⚠️ | ❌ |
428
459
  | **Offline CI gate** | ✅ check | ❌ | ✅ atlas.sum | ❌ | ❌ |
package/dist/cli.js CHANGED
@@ -112,6 +112,7 @@ function buildConfig(raw, configDir) {
112
112
  };
113
113
  return {
114
114
  configDir,
115
+ ...raw.model ? { model: raw.model } : {},
115
116
  migrationsDirs: resolveMigrationsDirs(raw),
116
117
  schemaFile: raw.schemaFile ?? "db/schema.sql",
117
118
  metadataFile: raw.metadataFile ?? "db/.migraguard/metadata.json",
@@ -342,8 +343,8 @@ function addEntry(metadata, entry) {
342
343
  migrations: [...metadata.migrations, entry]
343
344
  };
344
345
  }
345
- function isDagMode(metadata) {
346
- return metadata.model === "dag";
346
+ function isDagMode(metadata, config) {
347
+ return config?.model === "dag" || metadata.model === "dag";
347
348
  }
348
349
  function isPreModelSince(metadata, fileName) {
349
350
  if (!metadata.model || !metadata.modelSince) return true;
@@ -863,7 +864,7 @@ async function commandCheck(config) {
863
864
  const errors = [];
864
865
  const metadata = await loadMetadata(config);
865
866
  const files = await scanMigrations(config);
866
- const dag = isDagMode(metadata);
867
+ const dag = isDagMode(metadata, config);
867
868
  const metadataMap = new Map(metadata.migrations.map((m) => [m.file, m.checksum]));
868
869
  const recordedFiles = files.filter((f) => metadataMap.has(f.fileName));
869
870
  const newFiles = files.filter((f) => !metadataMap.has(f.fileName));
@@ -973,7 +974,7 @@ async function commandSquash(config) {
973
974
  console.log(chalk6.yellow("No new migration files to squash."));
974
975
  return;
975
976
  }
976
- if (isDagMode(metadata)) {
977
+ if (isDagMode(metadata, config)) {
977
978
  await dagSquash(config, newFiles, metadata);
978
979
  } else {
979
980
  if (newFiles.length === 1) {
@@ -2198,7 +2199,7 @@ async function commandEditable(config) {
2198
2199
  return { editableFiles: [], entries: [] };
2199
2200
  }
2200
2201
  const metadata = await loadMetadata(config);
2201
- const dag = isDagMode(metadata);
2202
+ const dag = isDagMode(metadata, config);
2202
2203
  const metadataFileSet = new Set(metadata.migrations.map((m) => m.file));
2203
2204
  const newFiles = files.filter((f) => !metadataFileSet.has(f.fileName));
2204
2205
  const entries = [];
@@ -2391,7 +2392,7 @@ async function commandApply(config, options) {
2391
2392
  }
2392
2393
  }
2393
2394
  const metadata = await loadMetadata(config);
2394
- const dag = isDagMode(metadata);
2395
+ const dag = isDagMode(metadata, config);
2395
2396
  let graph = null;
2396
2397
  let leafSet = null;
2397
2398
  if (dag) {