Skip to content

Added @tryghost/memoize internal package - #30886

Closed
acburdine wants to merge 1 commit into
mainfrom
claude/add-tryghost-memoize-package-a56x0w
Closed

acburdine wants to merge 1 commit into
mainfrom
claude/add-tryghost-memoize-package-a56x0w

Conversation

@acburdine

@acburdine acburdine commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

Why are you making it?

Several profile follow-ups memoize a pure derivation of an immutable input: compiled ICU messages by locale and text, parsed NQL filter trees, an Intl formatter per timezone. Each of those would otherwise grow its own Map, with its own (usually absent) bound.

One small helper gives them the same bound and the same semantics.

What does it do?

Adds @tryghost/memoize under packages/memoize: a private, TypeScript-only ESM internal package, scaffolded from packages/_template. It is one ~100-line source file with no global state.

API

  • memoize(compute, key, {max}) — keyed memo backed by lru-cache (11.5.2, added to the catalog). Carries reset() and a readonly size. max defaults to 500 and must be a positive integer. No TTL: entries are pure derivations of immutable inputs, so an entry is never stale, only evicted to stay inside the bound.
  • once(compute) — computes on first call, returns the same value afterwards. Carries reset().

A compute that throws is not memoized. A promise is stored like any other value; the helpers are meant for synchronous derivations.

This is a memo helper, not a cache. Nothing derived from mutable state (settings, a database row) goes through it — that needs real invalidation, which this deliberately does not have.

No consumers yet, and no changes to Ghost core. Follow-ups adopt it one memo at a time.

One deviation worth a reviewer's eye: memoize throws errors.IncorrectUsageError rather than TypeError for an invalid max, because the no-native-error lint rules rule out every native *Error form. That adds @tryghost/errors as a runtime dependency.

Changes since the first review

  • Removed the optimization.memoize config key, configure(), the process-wide registry and resetAll(), and the boot.js / configUtils wiring. The package now holds no global state; a test that needs a clean memo calls its reset().
  • Removed the separate dependency-free entry point. It existed to keep lru-cache off the boot path, which no longer loads the package.
  • Removed the rejected-promise eviction; promises are stored as-is.
  • Removed the lazy-require() consumers (and closed Changed repeated lazy requires to use once() #30887): a warm require() costs ~180 ns, so wrapping it bought nothing measurable.
  • README trimmed and spelling standardized on "memoize".

Verification

Check Result
pnpm build / pnpm lint in packages/memoize pass
pnpm test in packages/memoize 14 tests, 100% coverage
pnpm lint:packages pass
pnpm install --frozen-lockfile clean

Checklist

  • 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

@coderabbitai

coderabbitai Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: TryGhost/Ghost/.coderabbit.yaml

Review profile: QUIET

Plan: Essentials

Run ID: aac7593b-1a99-48f7-81be-0c38116623c2

📥 Commits

Reviewing files that changed from the base of the PR and between 80d5cda and 3287437.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (5)
  • packages/memoize/README.md
  • packages/memoize/package.json
  • packages/memoize/src/index.ts
  • packages/memoize/test/index.test.ts
  • packages/memoize/vitest.config.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

📜 Recent review details
⏰ Context from checks skipped due to timeout. (19)
  • GitHub Check: E2E Tests (Analytics 2/2)
  • GitHub Check: E2E Tests (Main 10/10)
  • GitHub Check: E2E Tests (Main 1/10)
  • GitHub Check: E2E Tests (Main 2/10)
  • GitHub Check: E2E Tests (Analytics 1/2)
  • GitHub Check: E2E Tests (Main 6/10)
  • GitHub Check: E2E Tests (Main 4/10)
  • GitHub Check: E2E Tests (Main 5/10)
  • GitHub Check: E2E Tests (Main 7/10)
  • GitHub Check: E2E Tests (Main 8/10)
  • GitHub Check: E2E Tests (Main 3/10)
  • GitHub Check: E2E Tests (Main 9/10)
  • GitHub Check: App Playwright Acceptance Tests (@tryghost/admin 2/2)
  • GitHub Check: App Playwright Acceptance Tests (@tryghost/admin 1/2)
  • GitHub Check: Unit tests (Node 24.20.0)
  • GitHub Check: Unit tests (Node 22.23.3)
  • GitHub Check: Acceptance tests (Node 22.23.3, mysql8)
  • GitHub Check: Acceptance tests (Node 24.20.0, mysql8)
  • GitHub Check: Lint
🧰 Additional context used
📓 Path-based instructions (6)
Review whether tests prove changed behaviour, meaningful error/edge paths, and externally observable contracts without coupling to implementation details.

⚙️ CodeRabbit configuration file

Files:

  • packages/memoize/test/index.test.ts
Review lens: "where does this data become trusted?" Boundary data (HTTP input, external API/SDK responses, env/config, DB/filesystem reads, queue/webhook/event payloads) is `unknown` until validated — Zod by default.

⚙️ CodeRabbit configuration file

Files:

  • packages/memoize/vitest.config.ts
  • packages/memoize/test/index.test.ts
  • packages/memoize/src/index.ts
Review package boundaries and production consumption: minimal explicit exports, declared runtime dependencies, source-condition versus built-output parity, copied runtime assets, ESM/NodeNext compatibility, and consumer-facing release impac...

⚙️ CodeRabbit configuration file

Files:

  • packages/memoize/vitest.config.ts
  • packages/memoize/package.json
  • packages/memoize/test/index.test.ts
  • packages/memoize/src/index.ts
  • packages/memoize/README.md
Prioritise concrete correctness, security, data-integrity, compatibility, and regression risks.

⚙️ CodeRabbit configuration file

Files:

  • packages/memoize/vitest.config.ts
  • packages/memoize/package.json
  • packages/memoize/test/index.test.ts
  • packages/memoize/src/index.ts
  • packages/memoize/README.md
Source excerpt: Built Admin assets are copied into `ghost/core/core/built/admin/` for the Ghost release.

📄 CodeRabbit inference engine (docs/codebase/monorepo-structure.md)

Files:

  • packages/memoize/vitest.config.ts
  • packages/memoize/package.json
  • packages/memoize/test/index.test.ts
  • packages/memoize/src/index.ts
  • packages/memoize/README.md
Source excerpt: Ghost has several test suites across the monorepo.

📄 CodeRabbit inference engine (docs/contributing/testing.md)

Files:

  • packages/memoize/test/index.test.ts
🧠 Learnings (1)
📚 Learning: 2026-08-03T21:09:05.797Z
Learnt from: troyciesco
Repo: TryGhost/Ghost PR: 29723
File: ghost/core/test/unit/server/services/automations/automations-repository.test.ts:2117-2117
Timestamp: 2026-08-03T21:09:05.797Z
Learning: In TypeScript test files, treat each `it(...)` or `test(...)` callback as a separate function scope. Identically named local declarations, such as `queries` or `recordQuery`, in separate test callbacks are valid and should not be reported as duplicate block-scoped declarations.

Applied to files:

  • packages/memoize/test/index.test.ts
🪛 LanguageTool
packages/memoize/README.md

[locale-violation] ~43-~43: In American English, ‘afterward’ is the preferred variant. ‘Afterwards’ is more commonly used in British English and other dialects.
Context: ...e first call and returns the same value afterwards. The returned function carries `reset()...

(AFTERWARDS_US)

🔇 Additional comments (4)
packages/memoize/package.json (1)

19-25: LGTM!

Also applies to: 35-48

packages/memoize/test/index.test.ts (1)

6-61: LGTM!

Also applies to: 78-240

packages/memoize/vitest.config.ts (1)

4-4: LGTM!

packages/memoize/src/index.ts (1)

29-30: 🎯 Functional Correctness

The concern is refuted. The package contract explicitly stores promises, including rejected promises. The tests also require once to return the same rejected promise. No inspected objective overrides this contract or requires retries after rejection.


Walkthrough

The change adds the @tryghost/memoize package. It exports once and bounded, LRU-backed memoize, with reset methods and cache size reporting for keyed memoization. It adds package build and test configuration, dependencies, unit tests, and usage documentation. The tests cover caching, reset behavior, thrown computations, undefined results, LRU eviction, and option validation.

Priority: ➖ Normal

Change: Feature

Merge Risk: ⚪ Minimal · up to 32874

This adds an internal package with no consumers yet, so there is no production impact. The author should reconcile the PR description with the documented behavior of keeping rejected promises.

🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Type-Safe Boundaries ✅ Passed The changed package does not read HTTP, SDK, environment/config, database/filesystem, queue, webhook, or event data. Its source accepts typed callbacks, arguments, and options for an internal memoizat…
New Files Are Typescript ✅ Passed The PR adds one JavaScript-extension file: packages/memoize/eslint.config.mjs. Its contents export ESLint configuration, so it is an exempt tool/config file. The package source and tests are TypeScr…
Title check ✅ Passed The title clearly identifies the main change: adding the internal @tryghost/memoize package.
Description check ✅ Passed The description explains the package purpose, API, design constraints, dependency changes, removed scope, and verification results. It is directly related to the changeset.
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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

@nx-cloud

nx-cloud Bot commented Sep 17, 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 3287437

Command Status Duration Result
nx run-many -t test:unit -p @tryghost/memoize,@... ✅ Succeeded 10m 54s View ↗
nx run ghost:test:ci:integration ✅ Succeeded 5m 13s View ↗
nx run @tryghost/admin:test:acceptance --shard=2/2 ✅ Succeeded 8m 10s View ↗
nx run @tryghost/admin:test:acceptance --shard=1/2 ✅ Succeeded 8m 12s View ↗
nx run ghost:test:integration ✅ Succeeded 3m 1s View ↗
nx run ghost-monorepo:lint:boundaries ✅ Succeeded 32s View ↗
nx run ghost:test:ci:e2e ✅ Succeeded 4m 40s View ↗
nx run-many -t lint -p @tryghost/memoize,ghost-... ✅ Succeeded 4m 37s View ↗
Additional runs (13) ✅ Succeeded ... View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-09-30 13:56:36 UTC

@codecov

codecov Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 69.28%. Comparing base (6df192e) to head (3287437).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #30886      +/-   ##
==========================================
+ Coverage   69.19%   69.28%   +0.08%     
==========================================
  Files        1622     1620       -2     
  Lines       59346    59336      -10     
  Branches    10253    10253              
==========================================
+ Hits        41067    41112      +45     
+ Misses      15990    15941      -49     
+ Partials     2289     2283       -6     
Flag Coverage Δ
admin-tests 63.17% <ø> (-0.05%) ⬇️
e2e-tests 70.76% <ø> (+0.11%) ⬆️

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.

Copy link
Copy Markdown
Member Author

Benchmark note: this stack contributes nothing measurable to CPU, by design

Flagging this so nobody infers a performance justification the data doesn't support.

This PR and #30887 were measured on the Moya Pro benchmark as part of a six-PR integration branch (with #30704, #30753, #30874 and #30722). That branch came in at −11.5% CPU for a fixed 5,000-request workload — but essentially all of it is attributable to the nconf freeze and the theme translation cache. In the profile lane, neither @tryghost/memoize nor lru-cache appears anywhere in any table — CPU self time, allocation, or require_cost resident memory by package — across all four runs.

That matches what I measured before any of this ran: a warm require() costs ~284 ns and a once() call ~5 ns, so the 47 converted call sites in #30887 save roughly 1–2 µs per request against a multi-millisecond request. About 0.01%. The benchmark's noise floor on load.cpu_s is 4.2%, so this was never going to be visible and isn't.

The case for this package is unchanged and is not about speed: one bound instead of per-site unbounded Maps, one kill switch (optimization.memoize), one test reset (resetAll from configUtils.restore), and one place to review the semantics. The require() saving is a rounding error and shouldn't be cited as a reason to merge.

One thing the benchmark did confirm: the /once entry point does its job. once has no use for lru-cache, and Core reaches configure from boot.js before loading anything else, so the split keeps it off the boot path — require_cost shows no trace of either package at boot.


Generated by Claude Code

@acburdine
acburdine force-pushed the claude/add-tryghost-memoize-package-a56x0w branch from c3da528 to 588c84d Compare September 29, 2026 22:00
@acburdine
acburdine requested a review from EvanHahn September 29, 2026 22:13
@acburdine
acburdine marked this pull request as ready for review September 29, 2026 22:13
@acburdine

Copy link
Copy Markdown
Member Author

@EvanHahn ccing you on this PR since you were asking about memoization helper bits last week 😄

@acburdine

Copy link
Copy Markdown
Member Author

Review findings for commit 588c84dc2c8ff34c25aaf9d12592e36eb425df8a:

  1. Lockfile references missing dependency records. The new packages/memoize importer pins vitest and @vitest/coverage-v8 to 4.1.10, while the catalog specifies 4.1.11. The referenced 4.1.10 package and snapshot records are absent from the lockfile. Please regenerate the lockfile and verify a clean frozen install without cached dependencies. CI is green, so I am flagging the concrete inconsistency rather than claiming to have reproduced an installation failure. Lockfile importer.

  2. The registry permanently retains abandoned memos. Every memo enters a strong-reference array and is never removed. The claimed bound by number of call sites does not hold when factories run repeatedly or tests reload consumer modules: old functions, captured inputs, and caches remain reachable. A focused GC probe confirmed an otherwise unreferenced memo survives collection, including after resetAll(). A weak registry would still reset every live instance reliably; alternatively, provide explicit disposal. This becomes a memory-growth risk when consumers adopt the helper. Registry.

  3. Test restoration leaves package enablement behind. A test that boots with optimization.memoize: false changes the package's global flag. configUtils.restore() restores config and clears values, but does not restore that flag, so subsequent tests create disabled memos despite config being enabled. A focused probe confirmed that disablement persists through resetAll(). Please restore package enablement alongside config and cover this sequence. Existing disabled passthroughs also cannot be re-enabled, so tests that exercise boot configuration should account for consumer-module lifetime. Restoration hook.

Additional API risk: async computations are accepted by the generic signatures, but rejected promises are cached indefinitely by once, and until eviction/reset by memoize. A focused once(async () => { throw ... }) probe made only one attempt across two calls. Please explicitly constrain/document synchronous use, or handle rejection eviction.

Validation: reviewed the diff and relevant integration code and ran focused lifecycle probes. I did not rerun the full suite. There are no consumers yet, so most runtime risks concern adoption in follow-up PRs.

@acburdine
acburdine force-pushed the claude/add-tryghost-memoize-package-a56x0w branch from 588c84d to 5051627 Compare September 29, 2026 22:43
@acburdine

Copy link
Copy Markdown
Member Author

Addressed the review findings for 588c84dc2c in 5051627c36:

  1. Lockfile — fixed. The rebase left the memoize importer on vitest / @vitest/coverage-v8 4.1.10; both now resolve to 4.1.11 like the other 25 importers, and a frozen install is clean. (pnpm install --fix-lockfile was not used: it rewrote unrelated resolutions across the lockfile.)
  2. Registry retains abandoned memos — fixed. The registry now holds WeakRefs pruned by a FinalizationRegistry. The earlier "strong refs keep the reset reliable" reasoning didn't hold: a collected memo can't be called, so resetAll() loses nothing by skipping it. The weak ref targets the memo function, so a live memo's LRUCache is unaffected; an unreachable one is collected along with its cache. Covered by a --expose-gc test.
  3. configUtils.restore() leaves enablement behind — fixed. It now re-applies configure({enabled}) from the restored config before resetAll(). Passthroughs created while disabled stay passthroughs for their module's lifetime, as documented.
  4. Rejected promises cached — fixed. once and memoize drop a stored promise when it rejects (unless a newer value has replaced it), so an async compute retries like a throwing sync one. Tests cover both helpers, including the stale-rejection case.

🤖 Generated with Claude Code

@acburdine
acburdine force-pushed the claude/add-tryghost-memoize-package-a56x0w branch 2 times, most recently from 250e50b to 80d5cda Compare September 29, 2026 23:04

@EvanHahn EvanHahn left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a little hard to review this without knowing how it will be used. That said:

  • I was struck by the global "is memoization enabled" state here. It seems like that will make things harder to test and less reliable. (Ghost is already riddled with global state, which I generally think causes problems.)
  • The way promises are handled is nice in theory, but is a leaky abstraction in practice. If I call a memoized function twice in rapid succession, I might get two promises that will reject. IMO, it feels clearer to have it just return whatever the output was, promise or not.
  • I don't like the idea of adding a new config option, because it doubles the code paths. Now, if I want to test something that uses @tryghost/memoize, I need to test it twice: once with memoization on, and once with it off.

Again, I don't know the intended use cases, so maybe this all makes sense given that. But my hunch is that this could be a single ~100-line file with no global state.

Comment thread packages/memoize/README.md Outdated
Comment on lines +5 to +7
This is an internal workspace package. See the
[internal package golden path](../README.md) for its standing architecture and
maintenance rules.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I don't think this is necessary.

Comment thread packages/memoize/README.md Outdated

## What this is for

Several places in Ghost memoise a pure derivation of an input that never

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: some docs say "memoise" and others say "memoize". I'd stick with the latter, as it's more standard (e.g., Lodash).

Comment thread packages/memoize/README.md Outdated
Comment on lines +114 to +116
Clears every memo created so far. Ghost's test config helper (`configUtils.restore`)
calls it, so tests that rewrite config or swap modules in the require cache are
not served values derived from the previous state.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: in general, I'd audit this readme for internal things like this. I don't think we need to mention anything about Ghost's global test config helper.

no ref

Several profile follow-ups memoize a pure derivation of an immutable input:
compiled ICU messages by locale and text, parsed NQL filter trees, an Intl
formatter per timezone. Each of those would otherwise grow its own Map, with
its own (usually absent) bound.

One small helper gives them the same bound and the same semantics:

- memoize(compute, key, {max}) is a keyed memo backed by lru-cache, bounded at
  500 entries by default. No TTL: entries are pure derivations of immutable
  inputs, so an entry is never stale, only evicted.
- once(compute) computes on first call and returns the same value afterwards.

Both carry reset(), and neither memoizes a throwing compute. Promises are
stored like any other value; the helpers are meant for synchronous work.

This is a memo helper, not a cache. Nothing derived from mutable state goes
through it, because that needs real invalidation, which this deliberately does
not have.

The package holds no global state: no kill switch, no config key and no
process-wide registry. A switch would double the code paths every consumer has
to test, and a test that needs a clean memo can call its reset(). Nothing in
core uses the package yet; consumers adopt it in follow-ups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@acburdine
acburdine force-pushed the claude/add-tryghost-memoize-package-a56x0w branch from 80d5cda to 3287437 Compare September 30, 2026 13:43
@acburdine

Copy link
Copy Markdown
Member Author

Closing in favour of a plain library file in core.

After review this shrank to one ~100-line file with no global state and no config option, and every planned consumer turned out to be inside ghost/core. A workspace package (its own build, lint and test lanes, plus require(esm) from CommonJS core) is more than that needs.

The same memoize / once helpers now live at ghost/core/core/shared/memoize.ts, added in #30704 alongside their first real consumer (the theme translation cache), and #30753 uses them for the date helper's locale candidates. If another workspace package needs them later, promoting the file to a package is cheap.

🤖 Generated with Claude Code

@acburdine acburdine closed this Sep 30, 2026
@acburdine
acburdine deleted the claude/add-tryghost-memoize-package-a56x0w branch October 1, 2026 16:59
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.

3 participants