Skip to content

Commit ba62d1d

Browse files
authored
feat(core): resource cache phase 2 — cache.key (#123)
* chore: add changeset for resource cache key * feat(core): resource cache phase 2 - cache.key Adds ResourceKey type and cache.key option for resource cache keying. - cache.key derives a resource entry key from the current load context - One ResourceNode holds multiple internal entries, one per key - Each entry independently tracks value, error, status, stale flag, staleTime timer, and in-flight promise - ResourceRef proxies the active key entry - cache.staleTime timers are per key - cache.deduplicate deduplicates per active key - Resources without cache.key behave exactly as before Reverts .changeset/pre.json to exclude the new changeset from the prerelease consumed list (entries in pre.json changesets are treated as already consumed; the source of truth is the .md file itself). * docs: replace placeholder PR number with actual PR #123 in proposal doc * fix: replace JSON.stringify with type-tagged key serializer, fix docs API mismatch - Replace JSON.stringify normalizeKey with encodeResourceKey (type-tagged) - Tagged approach preserves null vs undefined vs 'null' vs 'undefined', NaN, Infinity, -Infinity, -0, and nested array distinctions - Fix docs/Resources.md: key() -> cache.key() nested API - Fix proposal doc examples to match nested API - Add 8 new tests for key serialization edge cases - Update PR body to remove .changeset/pre.json references
1 parent 91078f1 commit ba62d1d

7 files changed

Lines changed: 926 additions & 95 deletions

File tree

.changeset/tricky-terms-doubt.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@intent-framework/core": patch
3+
---
4+
5+
feat(core): resource cache phase 2 - cache.key
6+
7+
Adds `ResourceKey` type and `cache.key` option for resource cache keying.
8+
9+
- `cache.key` derives a resource entry key from the current load context
10+
- One ResourceNode holds multiple internal entries, one per key
11+
- Each entry independently tracks value, error, status, stale flag, staleTime timer, and in-flight promise
12+
- ResourceRef proxies the active key entry
13+
- cache.staleTime timers are per key
14+
- cache.deduplicate deduplicates per active key
15+
- Resources without cache.key behave exactly as before

docs/Resources.md

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -251,20 +251,74 @@ On failure, `error` contains the error message and `status` is `"failed"`.
251251

252252
See the [Inspect Screen and Diagnostics Guide](Inspect-Screen.md) for more detail.
253253

254-
## Cache options (Phase 1)
254+
## Cache options (Phase 1 + Phase 2)
255255

256256
Resources support optional `cache` configuration:
257257

258258
```ts
259259
const team = $.resource("team", {
260-
load: async () => loadTeam(),
260+
load: async ({ route }) => loadTeam(route.params.teamId),
261261
cache: {
262-
staleTime: 5_000, // ms (optional, default: Infinity — no time-based stale)
263-
deduplicate: true, // boolean (optional, default: true when cache is set)
262+
key: ({ route }) => route.params.teamId, // optional, derive cache key from context
263+
staleTime: 5_000, // ms (optional, default: Infinity)
264+
deduplicate: true, // boolean (optional, default: true when cache is set)
264265
},
265266
})
266267
```
267268

269+
### cache.key (Phase 2)
270+
271+
`cache.key` is an optional function that derives a cache key from the resource's load context. When set, the resource holds multiple internal entries — one per unique key — each with its own:
272+
273+
- value
274+
- error
275+
- status (idle/pending/ready/failed)
276+
- stale flag
277+
- staleTime timer
278+
- in-flight promise (for deduplication)
279+
280+
The `ResourceRef` always reflects the **active key** entry — the key from the most recent `load()` or `reload()` call.
281+
282+
```ts
283+
const team = $.resource("team", {
284+
cache: {
285+
key: ({ route }) => route.params.teamId,
286+
},
287+
load: async ({ route }) => loadTeam(route.params.teamId),
288+
})
289+
290+
// Navigating to /teams/abc loads with key "abc"
291+
await team.load({ route: { params: { teamId: "abc" } } })
292+
team.value // team data for "abc"
293+
294+
// Navigating to /teams/xyz loads with key "xyz"
295+
await team.load({ route: { params: { teamId: "xyz" } } })
296+
team.value // team data for "xyz"
297+
298+
// Back to /teams/abc — the cached entry is reused
299+
await team.load({ route: { params: { teamId: "abc" } } })
300+
team.value // team data for "abc" (reloaded)
301+
```
302+
303+
Key semantics:
304+
305+
- `ResourceKey` type: `string | number | boolean | null | undefined | ResourceKey[]`
306+
- Keys are normalized via a type-tagged serializer for stable map lookups — preserving distinctions between `null`, `undefined`, `"null"`, `"undefined"`, `0`, `-0`, `NaN`, `Infinity`, `-Infinity`, and nested arrays.
307+
- Equivalent array content (e.g. `["a", "b"]`) maps to the same entry.
308+
- `no-arg reload()` uses the last context, therefore reloads the last active key.
309+
- `invalidate()` marks only the active entry stale.
310+
- `cache.deduplicate` deduplicates per active key.
311+
- `cache.staleTime` timers are per key.
312+
- Resources without `cache.key` behave exactly as before (single-entry behavior).
313+
314+
Scope limitations (Phase 2):
315+
316+
- **Single-runtime only.** Keyed entries live in one `ResourceNode` within one `ScreenRuntime`.
317+
- **No `cacheTime`.** Entries persist for the node's lifetime. No time-based eviction.
318+
- **No SWR.** Stale data must be explicitly reloaded.
319+
- **No cross-navigation cache.** Disposing the runtime clears all entries.
320+
- **No dependency-tracked keys.** Key is derived from context at load time; automatically reacting to context changes is future work.
321+
268322
### staleTime
269323

270324
`cache.staleTime` is an optional number in milliseconds. After a successful `load()` or `reload()`, a timer starts. When it fires, the resource transitions to stale automatically:
@@ -279,7 +333,8 @@ Behavior:
279333
- **Timer resets** on every successful `load()` or `reload()`.
280334
- **`invalidate()`** always marks stale immediately, regardless of `staleTime`.
281335
- **Failed loads** do not start a stale timer.
282-
- **`dispose()`** clears the stale timer and prevents late notifications.
336+
- **`dispose()`** clears all stale timers and prevents late notifications.
337+
- **With `cache.key`**, each key has an independent stale timer.
283338

284339
### deduplicate
285340

@@ -293,23 +348,25 @@ When `false`, each call runs independently (preserving existing behavior).
293348

294349
When `cache` is not set at all, deduplication is disabled to preserve backward compatibility. When `cache` is set, `deduplicate` defaults to `true`.
295350

351+
**With `cache.key`:** Deduplication is per-key. Concurrent `load()` calls with the same key share one promise. Different keys each invoke the loader independently.
352+
296353
### Future cache options
297354

298355
The following cache options remain as future work and are **not yet supported**:
299356

300-
- **`cache.key`** — cache key function for parameterized resources
301357
- **`cacheTime`** — retention period for stale values before eviction
302358
- **`swr`** — stale-while-revalidate background refreshing
303359
- **Cross-navigation cache store** — persistent cache across screen navigations
360+
- **Dependency-tracked keys** — automatic key derivation and reload when context changes without explicit load/reload
304361

305362
## Current boundaries
306363

307364
Resources are intentionally limited:
308365

309366
- **No global cache.** Each runtime owns its resource nodes. There is no shared cache between runtimes.
310-
- **No cache keys.** Resource identity is by name within a screen. There is no key-based deduplication or caching.
311-
- **No `cacheTime`.** There is no time-based retention of stale values beyond `staleTime` transitions.
367+
- **No `cacheTime`.** There is no time-based retention of stale values beyond `staleTime` transitions. Keyed entries persist for the node's lifetime.
312368
- **No stale-while-revalidate (SWR).** Stale resources stay stale until explicitly reloaded.
313369
- **No Suspense integration.** Resources do not integrate with React Suspense or any other framework's loading boundaries.
314370
- **No server framework integration yet.** Resources live on screen definitions and runtimes. Server-side resource hydration is not implemented.
315371
- **No full concurrent multi-mount resource ref semantics.** A `ResourceRef` connects to one runtime at a time. The last runtime to start owns the ref.
372+
- **No dependency-tracked keys.** The `cache.key` function runs at load/reload time only. Automatically reacting to context changes is future work.

docs/proposals/Resource-Cache-And-Stale-Semantics.md

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
# Resource Cache and Stale Semantics — Design Proposal
22

3-
**Status:** Phase 1 implemented (staleTime + deduplicate), Phase 2 designed (cache.key)
3+
**Status:** Phase 1 implemented (staleTime + deduplicate), Phase 2 implemented (cache.key)
44
**Date:** 2026-06-27
55
**Author:** Big Pickle
66
**Affected package:** `@intent-framework/core`
77
**Related docs:** `docs/Resources.md`, `docs/Specification.md`
88

99
> **Phase 1** (PR #119, `@intent-framework/core@0.1.0-alpha.8`): `cache.staleTime` and `cache.deduplicate`.
10-
> **Phase 2 design** (this document): `cache.key` only, scoped to one runtime and one `ResourceNode`.
10+
> **Phase 2** (PR #123, `@intent-framework/core@0.1.0-alpha.9`): `cache.key` only, scoped to one runtime and one `ResourceNode`.
1111
> **Phase 3+**: `cacheTime`, SWR, cross-navigation cache store, dependency-tracked keys. These remain design-only until implementation begins.
1212
1313
---
@@ -119,7 +119,9 @@ the same cached entry. When they produce different keys, they are independent.
119119
120120
```ts
121121
const team = $.resource("team", {
122-
key: ({ route }) => route.params.teamId,
122+
cache: {
123+
key: ({ route }) => route.params.teamId,
124+
},
123125
load: async ({ route }) => loadTeam(route.params.teamId),
124126
})
125127
```
@@ -432,7 +434,7 @@ type ResourceCacheOptions = {
432434
- No new exports from the package
433435
- PR #119
434436
435-
### Phase 2 — `cache.key` (recommended next slice)
437+
### Phase 2 — `cache.key` (implemented)
436438
437439
**Scope:** `cache.key` only, scoped to one runtime and one `ResourceNode`.
438440
@@ -722,7 +724,7 @@ When no `key` option is provided:
722724
#### Migration
723725
724726
- **No migration required** for existing resources — the `key` option is opt-in.
725-
- Users who want parameterized resources add `key: (ctx) => ctx.route.params.id` to their resource config.
727+
- Users who want parameterized resources add `cache: { key: (ctx) => ctx.route.params.id }` to their resource config.
726728
- Users who had workarounds (e.g., creating separate resources for each parameter) can consolidate into a single keyed resource.
727729
- `deduplicate` defaults to `true` when `cache` is set (already the case from Phase 1).
728730
@@ -736,10 +738,10 @@ When no `key` option is provided:
736738
737739
#### Open Questions (Deferred from Phase 2)
738740
739-
- **Key equality function** — should we use `JSON.stringify` (fast, no deps) or a deep equality utility (more correct for complex keys like arrays/objects)? Recommendation: use `JSON.stringify` for Phase 2. Array keys are supported by the type but should be used sparingly.
741+
- **Key equality function** — should we use `JSON.stringify` (fast, no deps) or a deep equality utility (more correct for complex keys like arrays/objects)? Recommendation: use `JSON.stringify` with a type-tagged encoder for Phase 2 (see `encodeResourceKey`). This preserves distinctions between `null`, `undefined`, `NaN`, `Infinity`, `-0`, and nested arrays. Array keys are supported by the type but should be used sparingly.
740742
- **Active key change without explicit load** — should the key be reactive (automatically reload when route params change)? This is dependency-tracked keys (Phase 6+). Phase 2 requires an explicit `load()`/`reload()` call to change the active key.
741743
- **Entry eviction policy** — without `cacheTime`, entries accumulate in the map indefinitely. For Phase 2 this is acceptable (the node is disposed when the runtime is disposed). Future phases should add LRU or time-based eviction.
742-
- **`cache.key` as a top-level config property vs nested under `cache`** — the existing convention nests under `cache`. Phase 2 follows this convention for consistency. This can be revisited if the team prefers flat.
744+
- **`cache.key` as a top-level config property vs nested under `cache`** — the existing convention nests under `cache`. Phase 2 follows this convention for consistency. The nested API is the approved design.
743745
- **Key type validation** — `ResourceKey` allows `string | number | boolean | null | undefined | ResourceKey[]`. Should we restrict further (e.g., disallow arrays in Phase 2)? Recommendation: keep the union type but document that string keys are preferred.
744746
745747
---
@@ -861,7 +863,12 @@ No changeset is needed for this proposal — it is design-only.
861863
- `cache.deduplicate` — in-flight load deduplication
862864
- Resources without `cache` options behave exactly as before
863865

864-
### Phase 2 (next implementation — `cache.key`)
866+
### Phase 2 (implemented in `@intent-framework/core@0.1.0-alpha.9`)
867+
868+
- `cache.key` — per-key resource entries within a single ResourceNode
869+
- Per-key value, status, error, stale flag, staleTime timer, and in-flight promise
870+
- ResourceRef proxies the active key entry
871+
- Non-keyed resources preserve backward compatibility
865872

866873
| Aspect | Decision |
867874
|--------|----------|

0 commit comments

Comments
 (0)