Skip to content

Added a freeze helper that makes config read-only once loaded - #30722

Draft
acburdine wants to merge 3 commits into
mainfrom
claude/nconf-config-freeze-ay169g
Draft

acburdine wants to merge 3 commits into
mainfrom
claude/nconf-config-freeze-ay169g

Conversation

@acburdine

@acburdine acburdine commented Sep 14, 2026

Copy link
Copy Markdown
Member

Follows #30721, now merged — that PR moved the global asset hash into the asset hash service, removing the last two runtime config.set calls, 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._execute walks all nine stores on every get(), and for an object-valued key it collects a hit from each store and deep-merges them (common.merge builds a fresh Memory store and recursively re-merges the subtree) — every single call. There are 353 config.get call sites in core/, some on per-request paths.

Measured against the real config:

before after
get('database') 3526 ns 15 ns
get('url') 1254 ns 37 ns
get('paths:contentPath') 334 ns 29 ns
getSiteUrl() (several gets) 1276 ns 63 ns

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 memoises get() 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:

  • Mutators throw, they don't no-op. nconf's own readOnly flag on each store looks like the obvious lever, but _execute skips read-only stores for a destructive action and returns undefined — 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 on MUTATORS recording why it's deliberately absent.
  • Cached objects are deep-frozen. Object reads are cached by reference, so a caller mutating what it got would otherwise rewrite the cache for every later reader. Freezing makes that a TypeError naming 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.
  • A keyless 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_KEY included. Caching that would mean a long-lived object holding every env var. Nothing in core/ calls get() keyless anyway.

One caller had to change. configure() in data/db/connection.js assembles the knex config by mutating the object it's handed, and it's called as configure(config.get('database')). Under caching that made the two ways of reading a key disagree — get('database').pool{} while get('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's merge shares subtrees by reference.

Where it freezes: loadNconf freezes 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 via configUtils.

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

  • 16 unit tests in test/unit/shared/config/freeze.test.ts: cache hits, cached misses, keyless get() 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.
  • Both immutability tests were checked against the bug — they fail without deepFreeze, and one reproduces the exact get('database').pool vs get('database:pool') divergence.
  • Booted Ghost for real (GHOST_CI_SHUTDOWN_AFTER_BOOT=1) with freeze-at-load and deep-frozen config: exit 0, zero frozen-write errors and zero TypeErrors. This matters because freeze is skipped under test, so boot is the only thing that exercises the frozen path end to end.
  • Validated against the real config in both envs: development freezes (526 key paths resolve, all bound helpers agree, set() throws, the rejected write left config unchanged); NODE_ENV=testing does not freeze and writes still work.
  • Three adapter-manager test fixtures build a ConfigInstance by hand and now call bindFreeze too, so they still satisfy the widened type.
  • Full unit suite on this head: 8711 passing.
  • CI green on 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.


  • I've read and followed the Contributor Guide
  • I've explained my change
  • I've written an automated test to prove my change works

🤖 Generated with Claude Code

https://claude.ai/code/session_011s9ZdgxjVXMr7YTh4UtmtH

@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

@acburdine
acburdine added this pull request to stack #30723 September 14, 2026 03:51
@nx-cloud

nx-cloud Bot commented Sep 14, 2026

Copy link
Copy Markdown

🤖 Nx Cloud AI Fix

Ensure the fix-ci command is configured to always run in your CI pipeline to get automatic fixes in future runs. For more information, please see https://nx.dev/ci/features/self-healing-ci


View your CI Pipeline Execution ↗ for commit f8de8e6

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

codecov Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 74.41860% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.70%. Comparing base (899ead6) to head (f8de8e6).

Files with missing lines Patch % Lines
ghost/core/core/shared/config/freeze.ts 71.05% 9 Missing and 2 partials ⚠️
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     
Flag Coverage Δ
e2e-tests 70.46% <74.41%> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@acburdine
acburdine force-pushed the claude/nconf-config-freeze-ay169g branch from c8c3b7b to ba52337 Compare September 14, 2026 08:46
@acburdine acburdine changed the title Added a freeze helper that makes config read-only after boot Added a freeze helper that makes config read-only once loaded Sep 14, 2026
@acburdine
acburdine force-pushed the claude/nconf-config-freeze-ay169g branch from 53bcb20 to 97f5b22 Compare September 14, 2026 13:03
Comment thread ghost/core/core/shared/config/freeze.ts Outdated
Comment thread ghost/core/core/shared/config/freeze.ts Outdated
Base automatically changed from claude/asset-hash-global-ay169g to main September 14, 2026 18:53
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
@acburdine
acburdine force-pushed the claude/nconf-config-freeze-ay169g branch from 12b416a to f8de8e6 Compare September 14, 2026 18:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants