Conversation
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueComment |
|
| Command | Status | Duration | Result |
|---|---|---|---|
nx run ghost:test:ci:integration |
✅ Succeeded | 4m 36s | View ↗ |
nx run ghost:test:integration |
✅ Succeeded | 3m 47s | View ↗ |
nx run ghost:test:ci:e2e |
✅ Succeeded | 4m 16s | View ↗ |
nx run ghost:test:legacy |
✅ Succeeded | 3m 15s | View ↗ |
nx run ghost:test:e2e |
✅ Succeeded | 3m | View ↗ |
nx run ghost-monorepo:lint:boundaries |
✅ Succeeded | 26s | View ↗ |
nx run-many -t test:unit -p ghost |
✅ Succeeded | 34s | View ↗ |
nx run-many -t lint -p ghost,ghost-monorepo |
✅ Succeeded | 21s | View ↗ |
Additional runs (4) |
✅ Succeeded | ... | View ↗ |
💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗
☁️ Nx Cloud last updated this comment at 2026-09-14 19:04:52 UTC
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #30722 +/- ##
==========================================
+ Coverage 67.66% 67.70% +0.04%
==========================================
Files 1677 1678 +1
Lines 60547 60597 +50
Branches 10474 10481 +7
==========================================
+ Hits 40968 41030 +62
+ Misses 17261 17246 -15
- Partials 2318 2321 +3
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
c8c3b7b to
ba52337
Compare
53bcb20 to
97f5b22
Compare
no ref
Config is fully loaded by the time loadNconf() returns, and nothing in Ghost
writes to it afterwards, but nconf doesn't know that. Every config.get()
walks all nine stores, and for an object-valued key it collects a hit from
each one and deep-merges them - on every single call. There are 353 get call
sites in core, some on per-request paths.
freeze() makes the instance read-only and memoises get() by key. Because each
cached value is whatever nconf itself returned for that key, a frozen lookup
can't disagree with an unfrozen one, and with writes rejected a cache entry
can't go stale - so there's no invalidation to get wrong. Measured against the
real config: get('database') 3526ns -> 15ns, getSiteUrl() 1276ns -> 63ns.
The mutators throw rather than no-op. nconf's own readOnly flag would have
been the obvious lever, but Provider._execute *skips* read-only stores for a
destructive action and returns undefined, which turns a config write into a
silent failure instead of a loud one.
loadNconf freezes as its last step rather than leaving it to boot, so a write
during boot fails loudly instead of quietly working. Nothing in the tree does
that today - the last two runtime writes were the asset hash, moved into the
asset hash service - so this holds the line rather than changing behaviour.
It's skipped under test, where the suites rewrite config between cases on
purpose, and optimization.freezeConfig turns it off entirely.
A keyless get() is left uncached. nconf's env store holds the whole
environment, so the merged tree materialises it - on a boot here, 139 of 194
top-level keys came from env rather than config - and caching that would mean
a long-lived object holding every environment variable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011s9ZdgxjVXMr7YTh4UtmtH
no ref optimization.freezeConfig was added as a kill switch for freezing config, on the grounds that freezing makes config throw in production. On reflection it earns its config surface: nothing in the tree writes to config after load, so the flag only exists to re-enable a behaviour we don't want back, and a config key to control config's own loading is an awkward thing to reason about. defaults.json is now untouched by this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011s9ZdgxjVXMr7YTh4UtmtH
…ects
no ref
Two problems with the freeze helper, both found in review.
required() was in the mutator list, but it isn't one: nconf implements it as a
read - it calls get() for each key and throws when one is missing. Blocking it
would have made post-load config validation throw "Config is frozen" in every
non-test environment. It's out of the list, with a test covering both the
passing and missing-key cases while frozen.
Object-valued reads were cached and handed back by reference, so a caller that
mutated what it got rewrote the cache for every later reader. configure() in
data/db/connection.js does exactly that - it assembles the knex config by
mutating the object it's passed, which is config.get('database'). That made the
two ways of reading a key disagree:
get('database').pool -> {} (whatever knex bootstrap bolted on)
get('database:pool') -> undefined (no store has it)
Cached values are now deep-frozen, so a mutation raises a TypeError naming the
site instead of silently corrupting config, and configure() clones before
mutating. The clone is worth having on its own: mutating the object config
handed back was already writing through to nconf's stores for nested keys,
since nconf's merge shares subtrees by reference.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011s9ZdgxjVXMr7YTh4UtmtH
12b416a to
f8de8e6
Compare

Follows #30721, now merged — that PR moved the global asset hash into the asset hash service, removing the last two runtime
config.setcalls, which this one depends on. Rebased onto main, so the diff here is just the freeze itself.Why are you making it?
Config is fully loaded by the time
loadNconf()returns, and nothing in Ghost writes to it afterwards — but nconf doesn't know that, so it re-derives every answer from scratch.Provider._executewalks all nine stores on everyget(), and for an object-valued key it collects a hit from each store and deep-merges them (common.mergebuilds a freshMemorystore and recursively re-merges the subtree) — every single call. There are 353config.getcall sites incore/, some on per-request paths.Measured against the real config:
get('database')get('url')get('paths:contentPath')getSiteUrl()(several gets)In absolute terms this is small — a few hundred gets per request is well under a millisecond — so the immutability guarantee is at least as much of the point as the speed.
What does it do?
freeze()makes the instance read-only and memoisesget()by key.The key property: every cached value is whatever nconf itself returned for that key. So a frozen lookup can't disagree with an unfrozen one — there's no second implementation of nconf's resolution order to keep correct — and with writes rejected, a cache entry can't go stale, so there's no invalidation logic at all.
Details that matter:
readOnlyflag on each store looks like the obvious lever, but_executeskips read-only stores for a destructive action and returnsundefined— so flipping it would turn a config write into a silent failure rather than a loud one.required()is not a mutator. Despite sitting alongside them on the provider, nconf implements it as a read —get()per key, throw on missing — so it stays usable while frozen. There's a comment onMUTATORSrecording why it's deliberately absent.TypeErrornaming the site. Chosen over cloning deliberately: cloning on read undoes the entire performance win, and cloning once on write still hands the same object to everyone.get()is left uncached. nconf's env store holds the entire environment, so the whole-tree merge materialises it: on a boot here, 139 of 194 top-level keys came from env rather than config,AWS_SECRET_ACCESS_KEYincluded. Caching that would mean a long-lived object holding every env var. Nothing incore/callsget()keyless anyway.One caller had to change.
configure()indata/db/connection.jsassembles the knex config by mutating the object it's handed, and it's called asconfigure(config.get('database')). Under caching that made the two ways of reading a key disagree —get('database').pool→{}whileget('database:pool')→undefined. It now clones first, which is worth having regardless: mutating the object config handed back was already writing through into nconf's stores for nested keys, since nconf'smergeshares subtrees by reference.Where it freezes:
loadNconffreezes as its last step, rather than leaving it to boot. A write during boot then fails loudly instead of quietly working. Nothing in the tree does that today, so this holds the line rather than changing behaviour. Skipped under test, where the suites rewrite config between cases on purpose viaconfigUtils.No opt-out flag: nothing writes to config after load, so a switch would only exist to re-enable a behaviour we don't want back. The diff touches no config defaults.
Why is this something Ghost users or developers need?
No user-facing change. For developers, config's lifecycle becomes honest and enforced — loaded once, read-only thereafter — so "is this value still the one I read at startup?" stops being a question you have to answer by reading the whole codebase. Any future attempt to use config as mutable runtime state fails immediately, at the write, with a message naming the key.
Testing
test/unit/shared/config/freeze.test.ts: cache hits, cached misses, keylessget()staying uncached, nested keys resolving through the store chain, unfreeze dropping the cache, all 9 mutators throwing,required()still validating while frozen, a rejected write leaving config unchanged, and two covering cached-object immutability.deepFreeze, and one reproduces the exactget('database').poolvsget('database:pool')divergence.GHOST_CI_SHUTDOWN_AFTER_BOOT=1) with freeze-at-load and deep-frozen config: exit 0, zero frozen-write errors and zeroTypeErrors. This matters because freeze is skipped under test, so boot is the only thing that exercises the frozen path end to end.set()throws, the rejected write left config unchanged);NODE_ENV=testingdoes not freeze and writes still work.adapter-managertest fixtures build aConfigInstanceby hand and now callbindFreezetoo, so they still satisfy the widened type.f8de8e6, Typecheck included.Known gap
Freeze is skipped under test, so CI doesn't exercise the frozen path — a mutation on a rarely-hit request path would only surface in production. That's how the
configure()case above got through review in the first place. Worth deciding separately whether the freeze path deserves coverage; I'd keep that out of this PR.🤖 Generated with Claude Code
https://claude.ai/code/session_011s9ZdgxjVXMr7YTh4UtmtH