Skip to content

Commit d403398

Browse files
authored
feat(core): implement resource cache phase 1 - staleTime and deduplicate (#119)
Implements resource cache phase 1: - cache.staleTime - time-based automatic staleness - cache.deduplicate - in-flight load deduplication - No cache.key, cacheTime, swr, or cross-navigation cache Closes the cache phase 1 portion of the resource cache proposal.
1 parent 0c84335 commit d403398

8 files changed

Lines changed: 346 additions & 26 deletions

File tree

.changeset/seven-cobras-tan.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@intent-framework/core": patch
3+
---
4+
5+
Implement resource cache semantics phase 1: `cache.staleTime` for time-based staleness and `cache.deduplicate` for in-flight load/reload deduplication. Resources without `cache` options behave exactly as before.

docs/Resources.md

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,14 +251,65 @@ 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)
255+
256+
Resources support optional `cache` configuration:
257+
258+
```ts
259+
const team = $.resource("team", {
260+
load: async () => loadTeam(),
261+
cache: {
262+
staleTime: 5_000, // ms (optional, default: Infinity — no time-based stale)
263+
deduplicate: true, // boolean (optional, default: true when cache is set)
264+
},
265+
})
266+
```
267+
268+
### staleTime
269+
270+
`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:
271+
272+
- `resource.stale` becomes `true`
273+
- `resource.status` remains `"ready"`
274+
- `resource.value` remains available
275+
- Subscribers to the `stale` condition are notified
276+
277+
Behavior:
278+
279+
- **Timer resets** on every successful `load()` or `reload()`.
280+
- **`invalidate()`** always marks stale immediately, regardless of `staleTime`.
281+
- **Failed loads** do not start a stale timer.
282+
- **`dispose()`** clears the stale timer and prevents late notifications.
283+
284+
### deduplicate
285+
286+
`cache.deduplicate` is an optional boolean. When `true`, concurrent `load()` and `reload()` calls while a load is already pending share the same in-flight promise:
287+
288+
- Only one loader invocation runs.
289+
- All callers resolve or fail with the same result.
290+
- The in-flight promise is cleared when the load completes (success or failure).
291+
292+
When `false`, each call runs independently (preserving existing behavior).
293+
294+
When `cache` is not set at all, deduplication is disabled to preserve backward compatibility. When `cache` is set, `deduplicate` defaults to `true`.
295+
296+
### Future cache options
297+
298+
The following cache options remain as future work and are **not yet supported**:
299+
300+
- **`cache.key`** — cache key function for parameterized resources
301+
- **`cacheTime`** — retention period for stale values before eviction
302+
- **`swr`** — stale-while-revalidate background refreshing
303+
- **Cross-navigation cache store** — persistent cache across screen navigations
304+
254305
## Current boundaries
255306

256307
Resources are intentionally limited:
257308

258309
- **No global cache.** Each runtime owns its resource nodes. There is no shared cache between runtimes.
259310
- **No cache keys.** Resource identity is by name within a screen. There is no key-based deduplication or caching.
260-
- **No staleTime.** Invalidation is manual or action-driven. There is no TTL-based staleness.
261-
- **No automatic background refetching.** Stale resources stay stale until explicitly reloaded.
311+
- **No `cacheTime`.** There is no time-based retention of stale values beyond `staleTime` transitions.
312+
- **No stale-while-revalidate (SWR).** Stale resources stay stale until explicitly reloaded.
262313
- **No Suspense integration.** Resources do not integrate with React Suspense or any other framework's loading boundaries.
263314
- **No server framework integration yet.** Resources live on screen definitions and runtimes. Server-side resource hydration is not implemented.
264315
- **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.

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
# Resource Cache and Stale Semantics — Design Proposal
22

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

9+
> **Phase 1 implementation** (PR #119): `cache.staleTime` and `cache.deduplicate` are implemented in `@intent-framework/core@0.1.0-alpha.8`.
10+
> Remaining features (cache.key, cacheTime, swr, cross-navigation cache) remain as proposal/future work.
11+
912
---
1013

1114
## Problem
@@ -572,3 +575,25 @@ No changeset is needed for this proposal — it is design-only.
572575
- `packages/core/src/resource.ts` — Current `ResourceNode`, `ResourceConfig`,
573576
`createResourceNode` implementation.
574577
- `docs/MVP-Checkpoint.md` — "Resource cache policy" listed as unproven.
578+
579+
---
580+
581+
## Implementation Status
582+
583+
### Phase 1 (implemented in `@intent-framework/core@0.1.0-alpha.8`)
584+
585+
- `cache.staleTime` — time-based automatic staleness
586+
- `cache.deduplicate` — in-flight load deduplication
587+
- Resources without `cache` options behave exactly as before
588+
589+
### Future (not yet implemented)
590+
591+
- `cache.key` — cache key function for parameterized resources
592+
- `cacheTime` — retention period for stale values before eviction
593+
- `swr` — stale-while-revalidate background refetching
594+
- Cross-navigation cache store (InMemoryResourceCache)
595+
- Dependency-tracked keys
596+
- Streaming resources
597+
- SSR hydration
598+
- Optimistic updates
599+
- Server resources

packages/core/src/core.test.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3214,3 +3214,188 @@ describe("resource loader context", () => {
32143214
})
32153215
})
32163216
})
3217+
3218+
describe("resource cache semantics", () => {
3219+
describe("staleTime", () => {
3220+
it("resource without cache keeps current manual-stale behavior", () => {
3221+
const resource = createResourceNode("team", "team", async () => "data")
3222+
expect(resource.stale.current).toBe(false)
3223+
resource.invalidate()
3224+
expect(resource.stale.current).toBe(true)
3225+
// No stale timer should have been started
3226+
})
3227+
3228+
it("marks resource stale after staleTime elapses", async () => {
3229+
const resource = createResourceNode("team", "team", async () => "data", true, { staleTime: 10 })
3230+
expect(resource.stale.current).toBe(false)
3231+
await resource.load()
3232+
expect(resource.stale.current).toBe(false)
3233+
await new Promise(resolve => setTimeout(resolve, 20))
3234+
expect(resource.stale.current).toBe(true)
3235+
})
3236+
3237+
it("leaves status ready and value available when stale", async () => {
3238+
const resource = createResourceNode("team", "team", async () => "hello", true, { staleTime: 10 })
3239+
await resource.load()
3240+
expect(resource.status).toBe("ready")
3241+
expect(resource.value).toBe("hello")
3242+
await new Promise(resolve => setTimeout(resolve, 20))
3243+
expect(resource.stale.current).toBe(true)
3244+
expect(resource.status).toBe("ready")
3245+
expect(resource.value).toBe("hello")
3246+
})
3247+
3248+
it("notifies stale subscribers when staleTime fires", async () => {
3249+
const resource = createResourceNode("team", "team", async () => "data", true, { staleTime: 10 })
3250+
await resource.load()
3251+
const values: boolean[] = []
3252+
const unsub = resource.stale.subscribe(() => {
3253+
values.push(resource.stale.current)
3254+
})
3255+
await new Promise(resolve => setTimeout(resolve, 20))
3256+
expect(values).toContain(true)
3257+
unsub()
3258+
})
3259+
3260+
it("reload clears stale and restarts stale timer", async () => {
3261+
const resource = createResourceNode("team", "team", async () => "data", true, { staleTime: 10 })
3262+
await resource.load()
3263+
await new Promise(resolve => setTimeout(resolve, 20))
3264+
expect(resource.stale.current).toBe(true)
3265+
await resource.reload()
3266+
expect(resource.stale.current).toBe(false)
3267+
// Should not be stale right after reload
3268+
await new Promise(resolve => setTimeout(resolve, 5))
3269+
expect(resource.stale.current).toBe(false)
3270+
})
3271+
3272+
it("invalidate marks stale immediately even before staleTime", async () => {
3273+
const resource = createResourceNode("team", "team", async () => "data", true, { staleTime: 10_000 })
3274+
await resource.load()
3275+
expect(resource.stale.current).toBe(false)
3276+
resource.invalidate()
3277+
expect(resource.stale.current).toBe(true)
3278+
})
3279+
3280+
it("failed load does not start stale timer", async () => {
3281+
const resource = createResourceNode("team", "team", async () => { throw new Error("fail") }, true, { staleTime: 10 })
3282+
await resource.load()
3283+
expect(resource.status).toBe("failed")
3284+
await new Promise(resolve => setTimeout(resolve, 20))
3285+
// failed resources should not become stale
3286+
expect(resource.stale.current).toBe(false)
3287+
})
3288+
3289+
it("dispose clears stale timer and prevents later notifications", async () => {
3290+
let notified = false
3291+
const resource = createResourceNode("team", "team", async () => "data", true, { staleTime: 10 })
3292+
await resource.load()
3293+
const unsub = resource.stale.subscribe(() => { notified = true })
3294+
resource.dispose()
3295+
await new Promise(resolve => setTimeout(resolve, 20))
3296+
expect(notified).toBe(false)
3297+
unsub()
3298+
})
3299+
})
3300+
3301+
describe("deduplicate", () => {
3302+
it("deduplicate true shares concurrent load promises", async () => {
3303+
let resolveLoad!: (value: string) => void
3304+
const loadPromise = new Promise<string>(resolve => { resolveLoad = resolve })
3305+
let callCount = 0
3306+
3307+
const resource = createResourceNode("team", "team", async () => {
3308+
callCount++
3309+
return loadPromise
3310+
}, true, { deduplicate: true })
3311+
3312+
const load1 = resource.load()
3313+
const load2 = resource.load()
3314+
const load3 = resource.load()
3315+
3316+
resolveLoad("result")
3317+
await Promise.all([load1, load2, load3])
3318+
3319+
expect(callCount).toBe(1)
3320+
})
3321+
3322+
it("deduplicate true invokes loader only once for concurrent load/reload calls", async () => {
3323+
let resolveLoad!: (value: string) => void
3324+
const loadPromise = new Promise<string>(resolve => { resolveLoad = resolve })
3325+
let callCount = 0
3326+
3327+
const resource = createResourceNode("team", "team", async () => {
3328+
callCount++
3329+
return loadPromise
3330+
}, true, { deduplicate: true })
3331+
3332+
// Concurrent load + reload should share the same in-flight promise
3333+
const p1 = resource.load()
3334+
const p2 = resource.reload()
3335+
resolveLoad("result")
3336+
await Promise.all([p1, p2])
3337+
3338+
expect(callCount).toBe(1)
3339+
})
3340+
3341+
it("deduplicate false preserves independent concurrent loader calls", async () => {
3342+
let callCount = 0
3343+
let resolveLoad!: (value: string) => void
3344+
const deferred = new Promise<string>(resolve => { resolveLoad = resolve })
3345+
3346+
const resource = createResourceNode("team", "team", async () => {
3347+
callCount++
3348+
return deferred
3349+
}, true, { deduplicate: false })
3350+
3351+
const load1 = resource.load()
3352+
const load2 = resource.load()
3353+
3354+
resolveLoad("data")
3355+
await Promise.all([load1, load2])
3356+
3357+
expect(callCount).toBe(2)
3358+
})
3359+
3360+
it("failed deduplicated load marks failed status and clears in-flight promise", async () => {
3361+
let callCount = 0
3362+
3363+
const resource = createResourceNode("team", "team", async () => {
3364+
callCount++
3365+
throw new Error("fail")
3366+
}, true, { deduplicate: true })
3367+
3368+
const load1 = resource.load()
3369+
const load2 = resource.load()
3370+
3371+
await load1
3372+
await load2
3373+
3374+
expect(callCount).toBe(1)
3375+
expect(resource.status).toBe("failed")
3376+
expect(resource.error).toBeInstanceOf(Error)
3377+
expect((resource.error as Error).message).toBe("fail")
3378+
})
3379+
3380+
it("a later load after a deduped failure can retry normally", async () => {
3381+
let callCount = 0
3382+
3383+
const resource = createResourceNode("team", "team", async () => {
3384+
callCount++
3385+
if (callCount === 1) throw new Error("fail")
3386+
return "success"
3387+
}, true, { deduplicate: true })
3388+
3389+
// First load fails
3390+
await resource.load()
3391+
expect(resource.status).toBe("failed")
3392+
expect(callCount).toBe(1)
3393+
3394+
// Second load retries and succeeds (in-flight promise was cleared)
3395+
await resource.load()
3396+
expect(resource.status).toBe("ready")
3397+
expect(resource.value).toBe("success")
3398+
expect(callCount).toBe(2)
3399+
})
3400+
})
3401+
})

packages/core/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export type { AskNode, AnyAskNode, AskKind, AskBuilder } from "./ask.js"
77
export type { ActNode, ActCondition, ActStatus, FeedbackConfig, ActBuilder, NavigationService, ActionExecutionContext, DefaultScreenServices } from "./act.js"
88
export type { FlowNode, FlowStep, FlowBuilder } from "./flow.js"
99
export type { SurfaceNode, SurfaceBuilder } from "./surface.js"
10-
export type { ResourceNode, ResourceConfig, ResourceLoadContext, ResourceStatus, AnyResourceNode } from "./resource.js"
10+
export type { ResourceNode, ResourceConfig, ResourceCacheOptions, ResourceLoadContext, ResourceStatus, AnyResourceNode } from "./resource.js"
1111
export { ResourceRef, createResourceNode } from "./resource.js"
1212
export { createScreenRuntime } from "./runtime.js"
1313
export type { ScreenRuntime } from "./runtime.js"

0 commit comments

Comments
 (0)