Skip to content
Merged
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
19 changes: 11 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,17 @@ Open: roadmap milestone 11's two-platform deploy proof — 1.1.0 gave a scaffold
deployable artifact (`packages/cli/src/serve.ts`; `x new` writes `apps/web/server.ts`,
`prerender.ts`, a Dockerfile and `docker-compose.prod.yml`; `ROLE=migrate` runs release-phase
migrations), but the demo app on Compose **and** K8s from one image with an invisible rolling
restart is still not demonstrated. Four known gaps ship with 1.1.0 and are named in
[`CHANGELOG.md`](CHANGELOG.md): `x build --target binary` compiled and crashed at import — **fixed**, the
version read is lazy and `x build` passes `--define ULTIMATE_FRAMEWORK_VERSION`, though the target
is still unproven end to end;
`docker-compose.prod.yml` pairs a published host port with `replicas: 3`; the shared cache tier's
Lua invalidation `DEL`s keys it never declares in `KEYS`, so it fails on Dragonfly and Redis
Cluster; `resolveEnvironment` exists in both `core` and `seo` with different return types. Milestone
detail: [`docs/idea/14-roadmap.md`](docs/idea/14-roadmap.md).
restart is still not demonstrated. Of the four known gaps named in
[`CHANGELOG.md`](CHANGELOG.md), **two are now closed and two remain**, `As of 2026-08`:

| Gap | State |
|---|---|
| `x build --target binary` compiled and crashed at import | **fixed** — the version read is lazy and `x build` passes `--define ULTIMATE_FRAMEWORK_VERSION`. The target is still unproven end to end, and `docker/Dockerfile` compiles the binary *without* that define |
| the shared cache tier's Lua invalidation `DEL`s keys it never declares in `KEYS` | **fixed** — the script returns the member list and the tier deletes value keys client-side, one key per `DEL`, so it is slot-local on Redis Cluster and Dragonfly |
| `docker-compose.prod.yml` pairs a published host port with `replicas` above 1 | **open** — and it is `web` *and* `sync`, in the framework's compose file, the demo's, and the one `x new` scaffolds |
| `resolveEnvironment` exists in both `core` and `seo` with different return types | **open** — a real axiom-1 violation, deliberately deferred: both are shipped public APIs with different return unions, so unifying them is a breaking change that needs a major |

Milestone detail: [`docs/idea/14-roadmap.md`](docs/idea/14-roadmap.md).

## Design axioms (override any instinct that conflicts)

Expand Down
7 changes: 6 additions & 1 deletion framework.manifest.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 23 additions & 1 deletion packages/cache/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,29 @@ Tier 1. Tagged caching + THE invalidation graph.
swallowing it would return `undefined` as if it were the value. `LruCache.set` still throws
`X_CACHE_TOO_LARGE` to a direct caller — the stack is the layer that degrades, not the tier.
- `tag.x` typing comes from the `CacheTagRegistry` augmentation, generated by `x manifest`.
- Clocks are injected (`LruOptions.clock`); read them through `nowMs()`.
- Clocks are injected (`LruOptions.clock`, `CacheStackOptions.clock`); read them through `nowMs()`.
- **`ttlMs` is positive and finite, and `assertTtl` (in `tiers.ts`) is the one place that says so.**
Every tier calls it before it writes. `0` used to be "never expires" here and `EX 1` in `redis.ts`,
so one stack answered two ways; the rule lives beside `CacheSetOptions` precisely so a new tier
cannot invent a third reading. `X_CACHE_TTL_INVALID`, never a resolution.
- **A promotion carries the entry's remaining life, not `options.ttlMs`** — `createCacheStack.read`
writes the closer tiers with `hit.expiresAt - now`, and drops a hit that fails `isExpired`. A
fresh full lease per read is a hot key that never goes stale enough to refetch. `isExpired` was
exported and unit-tested and called by nothing; the stack is its one caller.
- **Every tier's `get` therefore reports `expiresAt`, or the promotion above has nothing to carry.**
`redis.ts` reads it from `PTTL`, issued alongside the `GET` so Bun pipelines the pair — the server
owns the clock, so it survives skew between the node that wrote and the node that reads, and no
stored payload shape changes under a running deployment. `-1`/`-2` are sentinels, not durations:
they mean no expiry, never one millisecond ago.
- **`redis.ts`'s script deletes only keys it was handed in `KEYS`.** The members of a tag set are
value keys in slots this node may not own, so `DEL`ing them from Lua is a cross-slot access that
fails on Redis Cluster and Dragonfly strict mode — into `report.errors`, so the bust reads as
partial and stale rows serve until TTL. The script returns the members; the tier deletes them
client-side, one key per `DEL`, which is slot-local under every topology.
- **`report.cdn` is what depends on the tags; `report.tiers` is what cleared.** The `cdn` tier
purges `cdn-path` dependents itself, alongside the tags, so `busted` is built from `tiers` +
`isr` + `liveQueries` and never from `cdn` — folding in a list nothing purged is exactly the
partial-bust-reading-as-clean this log exists to prevent.
- A purge driver is selected by `selectPurgeDriver` from the environment, never from an
`app.config.ts` field — nothing loads that file's contents at runtime. Two CDN credentials at
once is refused, not resolved, and half a pair is refused too: "no CDN" is the one wrong answer,
Expand Down
28 changes: 25 additions & 3 deletions packages/cache/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Reads walk down until a hit, then populate every tier they walked past. Writes p
|---|---|---|---|---|
| 0 | `request-memo` | ALS context (`WeakMap`) | dies with the request | never |
| 1 | `lru` | in-process, byte-budgeted | tag index | never |
| 2 | `redis` | `Bun.redis` | tag→keys set, one `EVAL` | single node |
| 2 | `redis` | `Bun.redis` | tag→keys set, one `EVAL` + slot-local `DEL`s | single node |
| 3 | `cdn` | headers + purge driver | surrogate keys | no CDN |

A tier is a `CacheTier` (`get`/`set`/`del`/`invalidateTags`). Swap or omit any of them
Expand All @@ -45,6 +45,19 @@ const feed = await stack.read('feed:org-1', () => db.posts.recent(), {
});
```

**`ttlMs` is positive and finite, in every tier.** Omit it for the tier's default; anything else
is `X_CACHE_TTL_INVALID`. There is no "never expires" and no "do not cache" — `0` used to mean the
first in the LRU tier and one second in the Redis tier, so a stack holding both answered
differently depending on which one hit, and neither reading was what the caller meant. A value you
do not want held is a value you do not put in the cache.

**A promoted hit carries its own remaining life.** When a read hits a far tier and populates the
closer ones, it writes them with `expiresAt - now`, not with the `ttlMs` the caller passed — a
fresh full lease on every read is a hot key that never gets stale enough to be refetched. An entry
already past its expiry is dropped on the way through and the read falls to `load()`. Each tier
supplies that expiry from its own store, so the number is real: the Redis tier reads `PTTL`
alongside the value, in the same pipelined round trip.

Every tier call the stack makes is best-effort: a tier that throws on `get`, `set` or `del` is a
tier that did not answer, so `read`, `write` and `drop` carry on. A feed too big for the LRU
(`X_CACHE_TOO_LARGE`) or a Redis with no socket costs the entry, never the read. The one call left
Expand Down Expand Up @@ -89,7 +102,7 @@ One function. Returns the report the `/_x` cache panel and `x cache bust --json`
"tags": ["post:1"],
"tiers": [{ "tier": "lru", "keys": ["feed"] }, { "tier": "redis", "keys": ["feed"] }],
"isr": ["/blog", "/blog/hello"],
"cdn": ["post:1"],
"cdn": ["/feed.xml"],
"liveQueries": [],
"durationMs": 1.4,
"errors": []
Expand All @@ -99,6 +112,12 @@ One function. Returns the report the `/_x` cache panel and `x cache bust --json`
A dead tier lands in `errors` and never throws — a Redis outage must not fail the write
that triggered the bust. Entries there expire by TTL instead.

`cdn` is what the dependency graph hangs off these tags, not what cleared: the `cdn` tier purges
those paths (as surrogate keys, alongside the tags), so what actually cleared is that tier's row
in `tiers`. With no `cdn` tier registered the list purges nowhere, which is why
`recentInvalidations()` reports `busted` from `tiers` and never from `cdn` — a partial bust that
reads as a clean one is the failure that log exists to catch.

Every report is also kept: `recentInvalidations()` hands back the last 100, newest first, each
one naming the span that triggered it. That is the log the `/_x` cache panel renders — "did it
actually clear?" is answerable without a log dive because the one fan-out path retained the
Expand All @@ -113,7 +132,9 @@ cacheHeaders({ sMaxAge: 300, staleWhileRevalidate: 86_400, tags: [tag('post', id
```

The surrogate keys **are** the tags, byte for byte, so an edge purge and an app-level
invalidation can never mean different things. Three `PurgeDriver`s ship:
invalidation can never mean different things. A `cdn-path` dependent registered against a tag goes
out in the same purge — as a surrogate key, the one currency `PurgeDriver` has — so a host
registering one must tag that response with its own path. Three `PurgeDriver`s ship:

| Driver | Purge | Purge all | Batch |
|---|---|---|---|
Expand Down Expand Up @@ -155,6 +176,7 @@ cache).
| `X_CACHE_PURGE_FAILED` | the CDN refused a purge, or a key it would split on whitespace |
| `X_CACHE_TAG_UNKNOWN` | a tag no entity declared — usually a typo |
| `X_CACHE_TOO_LARGE` | one entry exceeds a tier's whole byte budget |
| `X_CACHE_TTL_INVALID` | a `ttlMs` that is not a positive, finite number of milliseconds |

## Boundary

Expand Down
13 changes: 12 additions & 1 deletion packages/cache/src/cdn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// the response, and purging by surrogate key when a tag changes. Surrogate keys ARE the
// tags — same strings, so a CDN purge cannot drift from an app-level invalidation.

import { dependentsOfKind } from './graph';
import type { CacheTag } from './tags';
import { serializeTags } from './tags';
import type { CacheEntry, CacheSetOptions, CacheTier, TierInvalidation } from './tiers';
Expand Down Expand Up @@ -96,8 +97,18 @@ export function createCdnTier(options: CdnTierOptions = {}): CacheTier {
if (paths.length > 0) await driver.purge(paths);
},

/**
* The tags themselves plus every `cdn-path` the graph hangs off them — one purge, one list.
*
* Those paths were computed by `invalidate.ts` and reported as busted while nothing ever
* purged them, so `x cache bust --json` named a path the edge still held for its whole
* `s-maxage`: a partial bust reading as a clean one, which is the one failure the report
* exists to prevent. They go out **as surrogate keys**, the single currency `PurgeDriver`
* has — the same convention `pathsForKey` already documents, so a host registering a
* `cdn-path` dependent must tag that response with its own path.
*/
async invalidateTags(tags: readonly CacheTag[]): Promise<TierInvalidation> {
const keys = serializeTags(tags);
const keys = [...new Set([...serializeTags(tags), ...dependentsOfKind(tags, 'cdn-path')])];
if (keys.length === 0) return { tier: 'cdn', keys: [] };
const accepted = await driver.purge(keys);
return { tier: 'cdn', keys: accepted };
Expand Down
25 changes: 25 additions & 0 deletions packages/cache/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const CACHE_OWNED_ERROR_CODES = [
'X_CACHE_PURGE_FAILED',
'X_CACHE_TAG_UNKNOWN',
'X_CACHE_TOO_LARGE',
'X_CACHE_TTL_INVALID',
] as const;

/** Every code cache can throw. It borrows none: every remote driver here is implemented. */
Expand All @@ -21,6 +22,7 @@ export const CACHE_ERROR_TITLES: Readonly<Record<CacheOwnedErrorCode, string>> =
X_CACHE_PURGE_FAILED: 'the CDN refused a purge',
X_CACHE_TAG_UNKNOWN: 'a tag no entity declared',
X_CACHE_TOO_LARGE: "one entry exceeds the tier's byte budget",
X_CACHE_TTL_INVALID: 'a cache TTL that is not a positive number of milliseconds',
};

// One unconditional call, so a second package claiming one of cache's codes throws
Expand Down Expand Up @@ -72,6 +74,29 @@ export class CacheTooLargeError extends UltimateError {
}
}

/**
* A `ttlMs` that is not a positive, finite number of milliseconds.
*
* `0` used to mean two things: "never expires" in the LRU tier and `EX 1` — one second — in the
* Redis tier, so a stack holding both answered differently depending on which one hit. Neither is
* what a caller writing `0` intends, and the third reading ("do not cache") has its own spelling:
* do not call the cache. Refused rather than resolved, so the miswiring is a failure and not a
* behaviour that varies by deployment.
*/
export class CacheTtlInvalidError extends UltimateError {
constructor(input: { key: string; ttlMs: number; tier: string }) {
super({
code: 'X_CACHE_TTL_INVALID',
cause: `entry "${input.key}" was written to the ${input.tier} tier with ttlMs=${String(
input.ttlMs,
)}; a TTL is a positive, finite number of milliseconds`,
fix: `cache.write('${input.key}', value, { ttlMs: 60_000 }) # or drop the option for the tier default; a value you do not want held is one you do not write`,
docs: docsFor('X_CACHE_TTL_INVALID'),
meta: { key: input.key, ttlMs: input.ttlMs, tier: input.tier },
});
}
}

/**
* A remote purge did not happen. Never fatal on its own — `invalidateTags` collects it into
* `report.errors` so a dead CDN cannot fail the write that triggered the bust — which is exactly
Expand Down
4 changes: 3 additions & 1 deletion packages/cache/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export {
CachePurgeFailedError,
CacheTagUnknownError,
CacheTooLargeError,
CacheTtlInvalidError,
} from './errors';
export type { CacheDependent, DependentKind } from './graph';
export {
Expand Down Expand Up @@ -77,8 +78,9 @@ export type {
CacheEntry,
CacheSetOptions,
CacheStack,
CacheStackOptions,
CacheTier,
TierInvalidation,
TierName,
} from './tiers';
export { createCacheStack, isExpired, nowMs, sortTiers, TIER_ORDER } from './tiers';
export { assertTtl, createCacheStack, isExpired, nowMs, sortTiers, TIER_ORDER } from './tiers';
Loading