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
15 changes: 15 additions & 0 deletions .changeset/tricky-terms-doubt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@intent-framework/core": patch
---

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
73 changes: 65 additions & 8 deletions docs/Resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,20 +251,74 @@ On failure, `error` contains the error message and `status` is `"failed"`.

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

## Cache options (Phase 1)
## Cache options (Phase 1 + Phase 2)

Resources support optional `cache` configuration:

```ts
const team = $.resource("team", {
load: async () => loadTeam(),
load: async ({ route }) => loadTeam(route.params.teamId),
cache: {
staleTime: 5_000, // ms (optional, default: Infinity — no time-based stale)
deduplicate: true, // boolean (optional, default: true when cache is set)
key: ({ route }) => route.params.teamId, // optional, derive cache key from context
staleTime: 5_000, // ms (optional, default: Infinity)
deduplicate: true, // boolean (optional, default: true when cache is set)
},
})
```

### cache.key (Phase 2)

`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:

- value
- error
- status (idle/pending/ready/failed)
- stale flag
- staleTime timer
- in-flight promise (for deduplication)

The `ResourceRef` always reflects the **active key** entry — the key from the most recent `load()` or `reload()` call.

```ts
const team = $.resource("team", {
cache: {
key: ({ route }) => route.params.teamId,
},
load: async ({ route }) => loadTeam(route.params.teamId),
})

// Navigating to /teams/abc loads with key "abc"
await team.load({ route: { params: { teamId: "abc" } } })
team.value // team data for "abc"

// Navigating to /teams/xyz loads with key "xyz"
await team.load({ route: { params: { teamId: "xyz" } } })
team.value // team data for "xyz"

// Back to /teams/abc — the cached entry is reused
await team.load({ route: { params: { teamId: "abc" } } })
team.value // team data for "abc" (reloaded)
```

Key semantics:

- `ResourceKey` type: `string | number | boolean | null | undefined | ResourceKey[]`
- 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.
- Equivalent array content (e.g. `["a", "b"]`) maps to the same entry.
- `no-arg reload()` uses the last context, therefore reloads the last active key.
- `invalidate()` marks only the active entry stale.
- `cache.deduplicate` deduplicates per active key.
- `cache.staleTime` timers are per key.
- Resources without `cache.key` behave exactly as before (single-entry behavior).

Scope limitations (Phase 2):

- **Single-runtime only.** Keyed entries live in one `ResourceNode` within one `ScreenRuntime`.
- **No `cacheTime`.** Entries persist for the node's lifetime. No time-based eviction.
- **No SWR.** Stale data must be explicitly reloaded.
- **No cross-navigation cache.** Disposing the runtime clears all entries.
- **No dependency-tracked keys.** Key is derived from context at load time; automatically reacting to context changes is future work.

### staleTime

`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:
Expand All @@ -279,7 +333,8 @@ Behavior:
- **Timer resets** on every successful `load()` or `reload()`.
- **`invalidate()`** always marks stale immediately, regardless of `staleTime`.
- **Failed loads** do not start a stale timer.
- **`dispose()`** clears the stale timer and prevents late notifications.
- **`dispose()`** clears all stale timers and prevents late notifications.
- **With `cache.key`**, each key has an independent stale timer.

### deduplicate

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

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

**With `cache.key`:** Deduplication is per-key. Concurrent `load()` calls with the same key share one promise. Different keys each invoke the loader independently.

### Future cache options

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

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

## Current boundaries

Resources are intentionally limited:

- **No global cache.** Each runtime owns its resource nodes. There is no shared cache between runtimes.
- **No cache keys.** Resource identity is by name within a screen. There is no key-based deduplication or caching.
- **No `cacheTime`.** There is no time-based retention of stale values beyond `staleTime` transitions.
- **No `cacheTime`.** There is no time-based retention of stale values beyond `staleTime` transitions. Keyed entries persist for the node's lifetime.
- **No stale-while-revalidate (SWR).** Stale resources stay stale until explicitly reloaded.
- **No Suspense integration.** Resources do not integrate with React Suspense or any other framework's loading boundaries.
- **No server framework integration yet.** Resources live on screen definitions and runtimes. Server-side resource hydration is not implemented.
- **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.
- **No dependency-tracked keys.** The `cache.key` function runs at load/reload time only. Automatically reacting to context changes is future work.
23 changes: 15 additions & 8 deletions docs/proposals/Resource-Cache-And-Stale-Semantics.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# Resource Cache and Stale Semantics — Design Proposal

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

> **Phase 1** (PR #119, `@intent-framework/core@0.1.0-alpha.8`): `cache.staleTime` and `cache.deduplicate`.
> **Phase 2 design** (this document): `cache.key` only, scoped to one runtime and one `ResourceNode`.
> **Phase 2** (PR #123, `@intent-framework/core@0.1.0-alpha.9`): `cache.key` only, scoped to one runtime and one `ResourceNode`.
> **Phase 3+**: `cacheTime`, SWR, cross-navigation cache store, dependency-tracked keys. These remain design-only until implementation begins.

---
Expand Down Expand Up @@ -119,7 +119,9 @@ the same cached entry. When they produce different keys, they are independent.

```ts
const team = $.resource("team", {
key: ({ route }) => route.params.teamId,
cache: {
key: ({ route }) => route.params.teamId,
},
load: async ({ route }) => loadTeam(route.params.teamId),
})
```
Expand Down Expand Up @@ -432,7 +434,7 @@ type ResourceCacheOptions = {
- No new exports from the package
- PR #119

### Phase 2 — `cache.key` (recommended next slice)
### Phase 2 — `cache.key` (implemented)

**Scope:** `cache.key` only, scoped to one runtime and one `ResourceNode`.

Expand Down Expand Up @@ -722,7 +724,7 @@ When no `key` option is provided:
#### Migration

- **No migration required** for existing resources — the `key` option is opt-in.
- Users who want parameterized resources add `key: (ctx) => ctx.route.params.id` to their resource config.
- Users who want parameterized resources add `cache: { key: (ctx) => ctx.route.params.id }` to their resource config.
- Users who had workarounds (e.g., creating separate resources for each parameter) can consolidate into a single keyed resource.
- `deduplicate` defaults to `true` when `cache` is set (already the case from Phase 1).

Expand All @@ -736,10 +738,10 @@ When no `key` option is provided:

#### Open Questions (Deferred from Phase 2)

- **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.
- **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.
- **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.
- **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.
- **`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.
- **`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.
- **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.

---
Expand Down Expand Up @@ -861,7 +863,12 @@ No changeset is needed for this proposal — it is design-only.
- `cache.deduplicate` — in-flight load deduplication
- Resources without `cache` options behave exactly as before

### Phase 2 (next implementation — `cache.key`)
### Phase 2 (implemented in `@intent-framework/core@0.1.0-alpha.9`)

- `cache.key` — per-key resource entries within a single ResourceNode
- Per-key value, status, error, stale flag, staleTime timer, and in-flight promise
- ResourceRef proxies the active key entry
- Non-keyed resources preserve backward compatibility

| Aspect | Decision |
|--------|----------|
Expand Down
Loading