Skip to content

Commit 7490a78

Browse files
committed
Update: round-trip withOverrides config and redact secrets in diffs
Config migrations now transform the authored overrides of a withOverrides({...}) file and re-emit the wrapper (instead of flattening it to JSON and inlining defaults); plain object exports are unchanged and the baseline is skipped. Secrets are redacted in read-only/dry-run diffs, undefined-valued keys are preserved, multiple config migrations compose on one file, mutate() gets a context.merged arg, and a non-writable conf dir auto-degrades to report-only.
1 parent cd2ee80 commit 7490a78

13 files changed

Lines changed: 665 additions & 43 deletions

docs/migrations.md

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The migrations module provides a convention-based system for running data and co
88
2. Each file is compared against the `migrations` collection to determine what has already run
99
3. Pending migrations are sorted by version and executed in order
1010
4. Completed migrations are recorded so they never run twice
11-
5. If any config file migrations ran, the app throws a fatal error to force a restart
11+
5. Any config file changes are written to disk, but only take effect on the next restart
1212

1313
## File naming
1414

@@ -31,7 +31,7 @@ Choose versions that correspond to the module release that requires the migratio
3131

3232
## Execution order
3333

34-
Pending migrations are sorted globally by semver version, then alphabetically by module name, then by type (`data` before `conf`). This ensures data migrations take effect in the current boot before config migrations trigger a restart.
34+
Pending migrations are sorted globally by semver version, then alphabetically by module name, then by type (`data` before `conf`). This ensures data migrations take effect in the current boot before config migrations, whose file changes only take effect on the next restart.
3535

3636
## Data migrations
3737

@@ -208,15 +208,25 @@ migration.remove('mute', 'dateFormat')
208208

209209
#### mutate(fn)
210210

211-
Escape hatch for operations not covered by `replace` and `remove`. Receives the full config object and modifies it in place.
211+
Escape hatch for operations not covered by `replace` and `remove`. Receives `(config, context)` and modifies `config` in place.
212212

213213
```javascript
214214
migration.mutate(config => {
215215
config['adapt-authoring-core'].newKey = computeValue()
216216
})
217217
```
218218

219-
Empty module sections are automatically cleaned up after all operations run.
219+
On a [`withOverrides` install](#the-withoverrides-round-trip) `config` is the file's authored **overrides** — a module section that only exists in the baseline defaults will be absent. Write null-safely, and read a baseline value via `context.merged` (a snapshot of the fully-merged config at boot):
220+
221+
```javascript
222+
migration.mutate((config, context) => {
223+
const current = context.merged['adapt-authoring-core'].logLevels
224+
config['adapt-authoring-core'] ??= {}
225+
config['adapt-authoring-core'].logLevels = [...current, 'verbose']
226+
})
227+
```
228+
229+
A `mutate` that throws (e.g. assumes a section that isn't in the overrides) is reported as a per-file warning; that file is skipped and the migration is left pending so it re-runs once the mutate is made null-safe. Empty module sections are automatically cleaned up after all operations run.
220230
221231
### Chaining
222232
@@ -238,24 +248,38 @@ export default function (migration) {
238248
239249
### How config files are processed
240250
241-
For each pending config migration, the framework:
251+
On the first config migration of a boot, every `conf/*.config.js` file is imported once into a shared working-copy cache, keyed by file. Each pending config migration then runs against that cache, so multiple migrations **compose** on the same file instead of each overwriting the last. For each file, the framework:
242252
243-
1. Finds all `conf/*.config.js` files in the application root directory
244-
2. Dynamically imports each file to get the config object
245-
3. Serializes the config before running the migration
246-
4. Runs all registered operations against the config object
247-
5. Compares the serialized output — only writes back if the config actually changed
248-
6. In dry-run mode, logs which files would be written without persisting
253+
1. Runs the migration's operations against the cached working copy
254+
2. Compares the serialized output — only writes back if the config actually changed
255+
3. Preserves `key: undefined` entries (used to unset an inherited default), which plain JSON would drop
256+
4. In dry-run mode, logs which files would be written without persisting
249257
250-
### Restart behaviour
258+
### The withOverrides round-trip
251259
252-
Config files are loaded at startup, so changes won't take effect until the process restarts. After all migrations complete, if any config file migrations ran successfully (non-dry-run), the module throws a fatal error:
260+
An instance may keep its `conf/*.config.js` as a plain object (`export default { ... }`) or layer its settings over a shared baseline:
253261
262+
```javascript
263+
// conf/defaults.config.js — the shared baseline
264+
export function withOverrides (overrides) { /* deep-merge onto defaults */ }
265+
export default defaults
266+
267+
// conf/production.config.js — only this instance's overrides
268+
import { withOverrides } from './defaults.config.js'
269+
export default withOverrides({ 'adapt-authoring-server': { port: 5678 } })
254270
```
255-
Config file(s) modified by N migration(s). Restart required to load updated configuration.
256-
```
257271
258-
Process managers (pm2, systemd, Docker) will automatically restart the app, which then picks up the updated config and boots normally. The config migrations are already recorded as complete and will not re-run.
272+
Config migrations round-trip both styles. Each imported config file is classified by two non-enumerable markers a `withOverrides` helper attaches (`Symbol.for('adapt-authoring:configOverrides')` on the merged result, `Symbol.for('adapt-authoring:configDefaults')` on the baseline):
273+
274+
- **plain** — a plain object export. Operations run against the whole object; re-emitted as `export default { ... }` (the original behaviour).
275+
- **overrides** — a `withOverrides({...})` file. Operations run against the authored **overrides only** (not the merged defaults); re-emitted as `export default withOverrides({ ... })` so the wrapper and inherited defaults are preserved rather than inlined. A key that lives only in the baseline is a no-op here — the baseline carries that change.
276+
- **defaults** — the baseline file itself. **Skipped**: it is maintained by hand in the same release that ships the migration.
277+
278+
A plain instance needs no markers and is unaffected. To adopt the pattern, have your baseline's `withOverrides` attach the markers (non-enumerable, so the config loader and `JSON.stringify` never see them; global `Symbol.for` so this module reads them without importing your config).
279+
280+
### Restart behaviour
281+
282+
Config files are read once at startup — before migrations run — so changes a migration writes to disk **take effect on the next restart**, not the current boot. Nothing is thrown to force this; run under a process manager (pm2, systemd, Docker) if you want an automatic restart after config changes. The migrations are recorded as complete and will not re-run.
259283
260284
### Read-only config
261285
@@ -271,7 +295,22 @@ Some deployments keep `conf/*.config.js` under version control or config managem
271295
272296
When enabled, each config migration that would change a file logs a `[READ-ONLY CONFIG]` warning naming the file and the module@version, followed by the same key-level diff shown in dry-run mode, then skips the write — you apply the change by hand.
273297
274-
Because no file is written, no restart is forced. The migration is also **not** recorded as complete, so it re-runs (and re-warns) on every boot until you make the change manually; once the config matches, the computed diff is empty and the warning stops. This affects config migrations only — data migrations still run and are recorded as normal.
298+
The migration is **not** recorded as complete, so it re-runs (and re-warns) on every boot until you make the change manually; once the config matches, the computed diff is empty and the warning stops. This affects config migrations only — data migrations still run and are recorded as normal.
299+
300+
Report-only mode is also entered **automatically** when the `conf` directory isn't writable — the module checks write access up front, and defensively falls back if a write is denied (`EACCES`/`EROFS`/`EPERM`). So on a deployment with a read-only conf dir you get the same report-and-diff behaviour with no configuration; the warning notes `(conf dir is not writable)`. Set `readOnlyConfig: true` only to force report-only where the conf dir *is* writable but you still don't want the app to touch it (e.g. version-controlled config).
301+
302+
#### Secret redaction
303+
304+
The key-level diff (in read-only and dry-run modes) never prints secret values. A leaf key that looks sensitive — matching `secret`, `password`, `token`, `apiKey`, `credential`, `connectionUri`, `privateKey`, `passphrase` and similar — is shown as `[redacted]` (`~ …auth.tokenSecret: [redacted] -> [redacted]`), and secrets nested inside a non-sensitive key's object value are masked in place. Add extra patterns with `redactKeys` (regex sources, additive to the built-ins — they can never disable them):
305+
306+
```javascript
307+
{
308+
'adapt-authoring-migrations': {
309+
readOnlyConfig: true,
310+
redactKeys: ['licenceKey', 'internalToken']
311+
}
312+
}
313+
```
275314
276315
### Cross-module config moves
277316
@@ -292,9 +331,9 @@ Behaviour differs by deployment:
292331
- **Replica set (transactions available)** — each data migration runs for real inside a transaction that is then aborted. Reads and mutations execute against live data, so `where()` matching and `check()` validation are exercised exactly as in a real run, but nothing is committed.
293332
- **Standalone mongod (no transactions)** — data migrations run through a read-only proxy. Reads execute normally, but write methods (`insertOne`, `updateMany`, `drop`, `createIndex`, `renameCollection`, etc.) are intercepted and logged instead of executed, e.g. `[DRY RUN] courses.updateMany({...})`.
294333
295-
Config file migrations compute the change and log a key-level diff (`+` added, `-` removed, `~` changed) followed by `would write <file>`, but leave the files untouched.
334+
Config file migrations compute the change and log a key-level diff (`+` added, `-` removed, `~` changed, [secrets redacted](#secret-redaction)) followed by `would write <file>`, but leave the files untouched.
296335
297-
A dry run never records anything in the `migrations` collection and never triggers the [restart](#restart-behaviour) that config migrations normally force. Completion state is also ignored, so a dry run reports **every** discovered migration as pending — including ones already applied — giving you the full set that would run against a fresh database.
336+
A dry run never records anything in the `migrations` collection. Completion state is also ignored, so a dry run reports **every** discovered migration as pending — including ones already applied — giving you the full set that would run against a fresh database.
298337
299338
## State tracking
300339

lib/ConfigMigration.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ class ConfigMigration {
4242
return this
4343
}
4444

45-
execute (config) {
45+
execute (config, context = {}) {
4646
const touched = new Set()
4747
for (const op of this.operations) {
4848
if (op.module && !(op.module in config)) continue
@@ -61,7 +61,7 @@ class ConfigMigration {
6161
break
6262
}
6363
case 'mutate': {
64-
op.fn(config)
64+
op.fn(config, context)
6565
this._currentModule = null
6666
break
6767
}

0 commit comments

Comments
 (0)