Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions packages/memoize/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# @tryghost/memoize

Bounded memoization helpers for pure derivations of immutable inputs

## What this is for

Memoizing a pure function of inputs that never change: compiled ICU messages
per locale and text, a parsed NQL filter tree, an `Intl` formatter per timezone.
Sharing one helper gives each of those the same bound and the same semantics,
instead of every call site growing its own unbounded `Map`.

## What this is not for

This is a memo helper, not a cache. Anything derived from mutable state, such as
settings or a database row, needs real invalidation, which this deliberately does
not have. There is no TTL for the same reason: an entry is never stale, only
evicted to stay inside the bound.

## Usage

```ts
import { memoize, once } from '@tryghost/memoize';
```

### `memoize(compute, key, options?)`

Memoizes `compute` against a string key derived from its arguments, backed by
`lru-cache`:

```ts
const formatterFor = memoize(
(timezone: string) => new Intl.DateTimeFormat('en', { timeZone: timezone }),
(timezone) => timezone,
{ max: 100 },
);
```

The returned function carries `reset()` and a readonly `size`. `options.max`
defaults to 500 and must be a positive integer.

### `once(compute)`

Computes on the first call and returns the same value afterwards. The returned
function carries `reset()`.

```ts
const getCollator = once(() => new Intl.Collator('en', { numeric: true }));
```

### What gets stored

- A `compute` that throws is not memoized: the next call retries.
- `memoize` does not store `undefined`, because `lru-cache` reads it as a miss,
so an `undefined` result is recomputed.
- A promise is stored like any other value, rejected or not. These helpers are
meant for synchronous derivations.

## Develop

This is a workspace package in the Ghost monorepo. From the package directory:

```bash
pnpm build # compile to build/ with tsc (ESM)
pnpm test # type-check + unit tests
pnpm lint # lint source and tests
```
10 changes: 10 additions & 0 deletions packages/memoize/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { nodeLibConfig } from '@internal/cfg-eslint';

export default nodeLibConfig({
extraTestRules: {
// Tests throw plain errors to prove that a throwing compute is not memoised.
// A Ghost error would misrepresent what is being modelled: an arbitrary
// failure inside someone else's compute function.
'ghost/ghost-custom/no-native-error': 'off',
},
});
62 changes: 62 additions & 0 deletions packages/memoize/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
{
"name": "@tryghost/memoize",
"version": "0.0.0",
"private": true,
"description": "Bounded memoization helpers for pure derivations of immutable inputs",
"license": "MIT",
"author": "Ghost Foundation",
"repository": {
"type": "git",
"url": "git+https://github.com/TryGhost/Ghost.git",
"directory": "packages/memoize"
},
"files": [
"build"
],
"type": "module",
"main": "build/index.js",
"types": "build/index.d.ts",
"exports": {
".": {
"source": "./src/index.ts",
"types": "./build/index.d.ts",
"default": "./build/index.js"
}
},
"scripts": {
"build": "tsc",
"test:unit": "NODE_ENV=testing vitest run --coverage",
"test": "pnpm run '/^test:/'",
"test:types": "tsc --noEmit -p test/tsconfig.json",
"lint:code": "eslint src/ --cache",
"lint:test": "eslint test/ --cache",
"lint": "pnpm run '/^lint:/'"
},
"dependencies": {
"@tryghost/errors": "catalog:",
"lru-cache": "catalog:"
},
"devDependencies": {
"@internal/cfg-eslint": "workspace:*",
"@internal/cfg-typescript": "workspace:*",
"@internal/cfg-vitest": "workspace:*",
"@types/node": "catalog:",
"@typescript/native": "catalog:",
"@vitest/coverage-v8": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"ghostPackage": {
"goldenPath": "compliant"
},
"nx": {
"targets": {
"build": {
"outputs": [
"{projectRoot}/build"
]
}
}
}
}
102 changes: 102 additions & 0 deletions packages/memoize/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Memoization helpers for pure derivations of immutable inputs.
//
// This is a memo helper, not a cache. Everything that goes through it must be a
// pure function of arguments that never change underneath us. Anything derived
// from mutable state needs real invalidation, which this deliberately does not
// have.

import errors from '@tryghost/errors';
import { LRUCache } from 'lru-cache';

/**
* A memoized no-argument function. Calling `reset()` drops the stored value so
* the next call recomputes.
*/
export type Once<T> = (() => T) & { reset(): void };

/**
* Compute a value once, then return the same value forever.
*
* A throwing `compute` is not memoized: the next call retries.
*/
export function once<T>(compute: () => T): Once<T> {
let computed = false;
let value: T;

const memoized = (() => {
if (!computed) {
// Assigned before `computed` flips, so a throw leaves the memo empty.
value = compute();
computed = true;
}
return value;
}) as Once<T>;

memoized.reset = () => {
computed = false;
value = undefined as T;
};

return memoized;
}

/**
* A keyed memoized function. `reset()` empties it, `size` reports how many
* entries are currently held.
*/
export type Memoized<A extends unknown[], R> = ((...args: A) => R) & {
reset(): void;
readonly size: number;
};

export interface MemoizeOptions {
/** Maximum number of entries to retain. Must be a positive integer. */
max?: number;
}

const DEFAULT_MAX = 500;

/**
* Memoize `compute` against a string key derived from its arguments, bounded by
* `options.max` entries in LRU order.
*
* There is deliberately no TTL: entries are pure derivations of immutable
* inputs, so an entry is never stale, only evicted to stay inside the bound.
*
* A throwing `compute` is not memoized: the next call with the same key retries.
*/
export function memoize<A extends unknown[], R>(
compute: (...args: A) => R,
key: (...args: A) => string,
options: MemoizeOptions = {},
): Memoized<A, R> {
const max = options.max ?? DEFAULT_MAX;
if (!Number.isInteger(max) || max < 1) {
throw new errors.IncorrectUsageError({
message: `memoize: options.max must be a positive integer, got ${String(max)}`,
});
}

// lru-cache cannot store `undefined` (it reads as a miss), so an `undefined`
// result is recomputed rather than served from the memo.
const cache = new LRUCache<string, R & {}>({ max });

const memoized = ((...args: A) => {
const cacheKey = key(...args);
const cached = cache.get(cacheKey);
if (cached !== undefined) {
return cached;
}

const value = compute(...args);
if (value !== undefined) {
cache.set(cacheKey, value as R & {});
}
return value;
}) as Memoized<A, R>;

memoized.reset = () => cache.clear();
Object.defineProperty(memoized, 'size', { get: () => cache.size });

return memoized;
}
Loading
Loading