diff --git a/.changeset/tricky-terms-doubt.md b/.changeset/tricky-terms-doubt.md new file mode 100644 index 0000000..574b023 --- /dev/null +++ b/.changeset/tricky-terms-doubt.md @@ -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 diff --git a/docs/Resources.md b/docs/Resources.md index 53b222f..54081e7 100644 --- a/docs/Resources.md +++ b/docs/Resources.md @@ -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: @@ -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 @@ -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. diff --git a/docs/proposals/Resource-Cache-And-Stale-Semantics.md b/docs/proposals/Resource-Cache-And-Stale-Semantics.md index 779cd53..7309ab3 100644 --- a/docs/proposals/Resource-Cache-And-Stale-Semantics.md +++ b/docs/proposals/Resource-Cache-And-Stale-Semantics.md @@ -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. --- @@ -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), }) ``` @@ -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`. @@ -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). @@ -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. --- @@ -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 | |--------|----------| diff --git a/packages/core/src/core.test.ts b/packages/core/src/core.test.ts index ccf557d..28c9d7c 100644 --- a/packages/core/src/core.test.ts +++ b/packages/core/src/core.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from "vitest" import { screen, inspectScreen, isCondition, createScreenRuntime, type NavigationService, type ActionExecutionContext } from "./index.js" -import { createResourceNode } from "./resource.js" +import { createResourceNode, type ResourceKey } from "./resource.js" async function loginUser(_params: { email: string; password: string }) { await Promise.resolve() @@ -3399,3 +3399,654 @@ describe("resource cache semantics", () => { }) }) }) + +describe("resource cache phase 2 - key", () => { + // === Key derivation === + + it("cache.key derives a key from load context", async () => { + const keys: unknown[] = [] + const resource = createResourceNode("team", "team", async (ctx: { id: string }) => { + keys.push(ctx.id) + return `data-${ctx.id}` + }, true, { + key: (ctx: { id: string }) => ctx.id, + }) + + await resource.load({ id: "abc" }) + expect(keys).toEqual(["abc"]) + expect(resource.value).toBe("data-abc") + + await resource.load({ id: "xyz" }) + expect(keys).toEqual(["abc", "xyz"]) + }) + + it("equivalent array keys map to the same entry", async () => { + let callCount = 0 + const resource = createResourceNode("team", "team", async (ctx: { ids: string[] }) => { + callCount++ + return `data-${ctx.ids.join(",")}` + }, true, { + key: (ctx: { ids: string[] }) => ctx.ids, + }) + + await resource.load({ ids: ["a", "b"] }) + expect(callCount).toBe(1) + expect(resource.value).toBe("data-a,b") + + // Same array content → same entry → deduplicate in-flight should work + await resource.load({ ids: ["a", "b"] }) + // But after first load completes, load() still reloads (always loads). + // The second load calls the loader again since entry is ready. + // However, different reference but same content should be same entry. + // The first load completes fully, so second load starts fresh. + // Actually, load() always invokes the loader when no in-flight promise exists. + // So this should call the loader again. + expect(callCount).toBe(2) + // But the same entry is updated, so value should still be consistent + expect(resource.value).toBe("data-a,b") + }) + + it("different keys create independent entries", async () => { + const resource = createResourceNode("team", "team", async (ctx: { id: string }) => { + return `data-${ctx.id}` + }, true, { + key: (ctx: { id: string }) => ctx.id, + }) + + await resource.load({ id: "abc" }) + expect(resource.value).toBe("data-abc") + + await resource.load({ id: "xyz" }) + expect(resource.value).toBe("data-xyz") + + // Switch back to "abc" — the entry still exists + await resource.load({ id: "abc" }) + expect(resource.value).toBe("data-abc") + }) + + it("resources without cache.key preserve old single-entry behavior", async () => { + let callCount = 0 + const resource = createResourceNode("team", "team", async () => { + callCount++ + return `data${callCount}` + }) + + expect(resource.status).toBe("idle") + await resource.load() + expect(callCount).toBe(1) + expect(resource.status).toBe("ready") + expect(resource.value).toBe("data1") + + await resource.load() + expect(callCount).toBe(2) + expect(resource.value).toBe("data2") + }) + + // === Entry independence === + + it("each key keeps its own value", async () => { + const resource = createResourceNode("team", "team", async (ctx: { id: string }) => { + return `value-${ctx.id}` + }, true, { + key: (ctx: { id: string }) => ctx.id, + }) + + await resource.load({ id: "a" }) + expect(resource.value).toBe("value-a") + + await resource.load({ id: "b" }) + expect(resource.value).toBe("value-b") + + // Switch back to key "a" — old value is preserved + await resource.load({ id: "a" }) + expect(resource.value).toBe("value-a") + }) + + it("each key keeps its own status", async () => { + const resource = createResourceNode("team", "team", async (ctx: { id: string; delay?: number }) => { + if (ctx.delay) await new Promise(r => setTimeout(r, ctx.delay!)) + return `value-${ctx.id}` + }, true, { + key: (ctx: { id: string }) => ctx.id, + }) + + // Start loading key "a" (slow) + const loadA = resource.load({ id: "a", delay: 50 }) + expect(resource.status).toBe("pending") // active key "a" is pending + + // Start loading key "b" (fast) — switches active key + await resource.load({ id: "b" }) + expect(resource.status).toBe("ready") // active key "b" is ready + expect(resource.value).toBe("value-b") + + // Wait for "a" to complete + await loadA + + // Active key is still "b" (last load context) + expect(resource.status).toBe("ready") + expect(resource.value).toBe("value-b") + }) + + it("each key keeps its own error", async () => { + const resource = createResourceNode("team", "team", async (ctx: { id: string; fail?: boolean }) => { + if (ctx.fail) throw new Error(`error-${ctx.id}`) + return `ok-${ctx.id}` + }, true, { + key: (ctx: { id: string }) => ctx.id, + }) + + // Key "a" fails + await resource.load({ id: "a", fail: true }) + expect(resource.status).toBe("failed") + expect((resource.error as Error).message).toBe("error-a") + + // Key "b" succeeds + await resource.load({ id: "b" }) + expect(resource.status).toBe("ready") + expect(resource.value).toBe("ok-b") + + // Switch back to "a" — still failed + await resource.load({ id: "a", fail: true }) + expect(resource.status).toBe("failed") + expect((resource.error as Error).message).toBe("error-a") + }) + + it("failed load for one key does not poison another key", async () => { + const resource = createResourceNode("team", "team", async (ctx: { id: string; fail?: boolean }) => { + if (ctx.fail) throw new Error("fail") + return `ok-${ctx.id}` + }, true, { + key: (ctx: { id: string }) => ctx.id, + }) + + await resource.load({ id: "good" }) + expect(resource.status).toBe("ready") + expect(resource.value).toBe("ok-good") + + // Fail a different key + await resource.load({ id: "bad", fail: true }) + expect(resource.status).toBe("failed") + + // Key "good" still has its value + await resource.load({ id: "good" }) + expect(resource.status).toBe("ready") + expect(resource.value).toBe("ok-good") + }) + + it("stale flag is per key", async () => { + const resource = createResourceNode("team", "team", async (ctx: { id: string }) => { + return `data-${ctx.id}` + }, true, { + key: (ctx: { id: string }) => ctx.id, + }) + + await resource.load({ id: "a" }) + expect(resource.stale.current).toBe(false) + + // Invalidate active key "a" + resource.invalidate() + expect(resource.stale.current).toBe(true) + + // Switch to key "b" — "b" starts fresh, not stale + await resource.load({ id: "b" }) + expect(resource.stale.current).toBe(false) + + // Switch back to "a" — load() always reloads, clearing stale + // The stale flag is per-key, so invalidating "a" didn't affect "b" + await resource.load({ id: "a" }) + // load() clears stale before running the loader (by design) + expect(resource.stale.current).toBe(false) + }) + + it("staleTime timer is per key", async () => { + const resource = createResourceNode("team", "team", async (ctx: { id: string }) => { + return `data-${ctx.id}` + }, true, { + key: (ctx: { id: string }) => ctx.id, + staleTime: 30, + }) + + await resource.load({ id: "a" }) + + // Switch to key "b" and load (starts b's timer) + await resource.load({ id: "b" }) + + // Switch back to "a" quickly — still not stale + await resource.load({ id: "a" }) + expect(resource.stale.current).toBe(false) + + // Wait for "a"'s stale timer (but not "b"'s) + await new Promise(r => setTimeout(r, 20)) + // "a" was loaded at the last switch-back, so timer just started + expect(resource.stale.current).toBe(false) + + // Wait for the remaining time + await new Promise(r => setTimeout(r, 20)) + expect(resource.stale.current).toBe(true) + + // Switch to "b" — its timer is independent and may not have fired yet + await resource.load({ id: "b" }) + // "b" was loaded ~40ms ago, timer may have fired + // This test just verifies timers are independent; exact timing varies + }) + + // === Active key / ref proxying === + + it("ResourceRef proxies the active key value", async () => { + type S = { id: string } + const ref = new (await import("./resource.js")).ResourceRef("r_test", "test", async (ctx: S) => { + return `data-${ctx.id}` + }, false) + + const resource = createResourceNode("test", "test", async (ctx: S) => { + return `data-${ctx.id}` + }, false, { + key: (ctx: S) => ctx.id, + }) + + ref._connect(resource) + + await resource.load({ id: "a" }) + expect(ref.value).toBe("data-a") + expect(ref.status).toBe("ready") + + // Load different key — ref proxies the new active key + await resource.load({ id: "b" }) + expect(ref.value).toBe("data-b") + expect(ref.status).toBe("ready") + + // Switch back + await resource.load({ id: "a" }) + expect(ref.value).toBe("data-a") + }) + + it("switching keys updates visible value/status/error", async () => { + const resource = createResourceNode("team", "team", async (ctx: { id: string; fail?: boolean }) => { + if (ctx.fail) throw new Error(`err-${ctx.id}`) + return `val-${ctx.id}` + }, true, { + key: (ctx: { id: string }) => ctx.id, + }) + + await resource.load({ id: "x" }) + expect(resource.value).toBe("val-x") + expect(resource.status).toBe("ready") + expect(resource.error).toBeUndefined() + + await resource.load({ id: "y", fail: true }) + expect(resource.status).toBe("failed") + expect(resource.value).toBeUndefined() + expect((resource.error as Error).message).toBe("err-y") + + await resource.load({ id: "x" }) + expect(resource.value).toBe("val-x") + expect(resource.status).toBe("ready") + expect(resource.error).toBeUndefined() + }) + + it("invalidate() only invalidates the active key", async () => { + const resource = createResourceNode("team", "team", async (ctx: { id: string }) => { + return `data-${ctx.id}` + }, true, { + key: (ctx: { id: string }) => ctx.id, + }) + + await resource.load({ id: "a" }) + expect(resource.stale.current).toBe(false) + + // Invalidate active key "a" + resource.invalidate() + expect(resource.stale.current).toBe(true) + + // Load key "b" — "b" starts fresh, not stale (invalidate didn't affect it) + await resource.load({ id: "b" }) + expect(resource.stale.current).toBe(false) + + // load() always reloads and clears stale, so switch back clears "a"'s stale too + await resource.load({ id: "a" }) + expect(resource.stale.current).toBe(false) + }) + + it("no-arg reload() uses lastContext and reloads the active/last key", async () => { + let lastContext: unknown = undefined + const resource = createResourceNode("team", "team", async (ctx: { id: string }) => { + lastContext = ctx + return `data-${ctx.id}` + }, true, { + key: (ctx: { id: string }) => ctx.id, + }) + + await resource.load({ id: "abc" }) + expect(resource.value).toBe("data-abc") + + // No-arg reload reuses lastContext → reloads key "abc" + await resource.reload() + expect(lastContext).toHaveProperty("id", "abc") + expect(resource.value).toBe("data-abc") + expect(resource.status).toBe("ready") + }) + + it("reload(context) can switch to a new key", async () => { + const resource = createResourceNode("team", "team", async (ctx: { id: string }) => { + return `data-${ctx.id}` + }, true, { + key: (ctx: { id: string }) => ctx.id, + }) + + await resource.load({ id: "first" }) + expect(resource.value).toBe("data-first") + + // reload with different context → switches to new key + await resource.reload({ id: "second" }) + expect(resource.value).toBe("data-second") + }) + + // === Deduplication === + + it("deduplicate true dedupes concurrent loads for the same key", async () => { + let callCount = 0 + let resolveLoad!: (v: string) => void + const deferred = new Promise(resolve => { resolveLoad = resolve }) + + const resource = createResourceNode("team", "team", async () => { + callCount++ + return deferred + }, true, { + key: (ctx: { id: string }) => ctx.id, + deduplicate: true, + }) + + const p1 = resource.load({ id: "x" }) + const p2 = resource.load({ id: "x" }) + const p3 = resource.load({ id: "x" }) + + resolveLoad("result") + await Promise.all([p1, p2, p3]) + + expect(callCount).toBe(1) + }) + + it("deduplicate true does not dedupe different keys", async () => { + let callCount = 0 + let resolve1!: (v: string) => void + let resolve2!: (v: string) => void + const deferred1 = new Promise(resolve => { resolve1 = resolve }) + const deferred2 = new Promise(resolve => { resolve2 = resolve }) + + const resource = createResourceNode("team", "team", async (ctx: { id: string }) => { + callCount++ + if (ctx.id === "x") return deferred1 + return deferred2 + }, true, { + key: (ctx: { id: string }) => ctx.id, + deduplicate: true, + }) + + const p1 = resource.load({ id: "x" }) + const p2 = resource.load({ id: "y" }) + + resolve1("x-result") + resolve2("y-result") + await Promise.all([p1, p2]) + + // Different keys → two loader calls + expect(callCount).toBe(2) + }) + + it("deduplicate false runs independent loads for the same key", async () => { + let callCount = 0 + let resolve1!: (v: string) => void + let resolve2!: (v: string) => void + const deferred1 = new Promise(resolve => { resolve1 = resolve }) + const deferred2 = new Promise(resolve => { resolve2 = resolve }) + + const resource = createResourceNode("team", "team", async () => { + callCount++ + if (callCount === 1) return deferred1 + return deferred2 + }, true, { + key: (ctx: { id: string }) => ctx.id, + deduplicate: false, + }) + + const p1 = resource.load({ id: "x" }) + const p2 = resource.load({ id: "x" }) + + resolve1("first") + resolve2("second") + await Promise.all([p1, p2]) + + expect(callCount).toBe(2) + }) + + it("failed deduplicated keyed load clears in-flight state and can retry", async () => { + let callCount = 0 + + const resource = createResourceNode("team", "team", async (ctx: { id: string }) => { + callCount++ + throw new Error(`fail-${ctx.id}`) + }, true, { + key: (ctx: { id: string }) => ctx.id, + deduplicate: true, + }) + + const p1 = resource.load({ id: "x" }) + const p2 = resource.load({ id: "x" }) + + // Errors are caught internally; promises resolve (don't reject) + await p1 + await p2 + + expect(callCount).toBe(1) + expect(resource.status).toBe("failed") + + // Can retry after failure + await resource.load({ id: "x" }) + // Same loader, will fail again + expect(resource.status).toBe("failed") + expect(callCount).toBe(2) // retry invokes loader again + }) + + // === Runtime integration === + + it("runtime autoLoad works with cache.key and route/context services", async () => { + type S = { route: { params: { teamId: string } } } + let receivedKey: string | undefined + + const TestScreen = screen("KeyedAutoload", $ => { + $.resource("team", { + load: async ({ route }) => { + receivedKey = route.params.teamId + return { id: route.params.teamId } + }, + autoLoad: true, + cache: { + key: ({ route }) => route.params.teamId, + }, + }) + }) + + const runtime = createScreenRuntime(TestScreen, { + services: { route: { params: { teamId: "team_42" } } } as S, + }) + await runtime.start() + + expect(runtime.resources).toHaveLength(1) + expect(runtime.resources[0]!.status).toBe("ready") + expect(runtime.resources[0]!.value).toEqual({ id: "team_42" }) + expect(receivedKey).toBe("team_42") + }) + + it("dispose clears stale timers for all keyed entries", async () => { + let staleNotified = false + const resource = createResourceNode("team", "team", async (ctx: { id: string }) => { + return `data-${ctx.id}` + }, true, { + key: (ctx: { id: string }) => ctx.id, + staleTime: 10, + }) + + await resource.load({ id: "a" }) + await resource.load({ id: "b" }) + + const unsub = resource.stale.subscribe(() => { staleNotified = true }) + + resource.dispose() + + await new Promise(r => setTimeout(r, 30)) + + // Neither timer should fire after dispose + expect(staleNotified).toBe(false) + unsub() + }) + + // === Key serialization edge cases === + + it("null and undefined are distinct keys", async () => { + const resource = createResourceNode("test", "test", async (ctx: { v: null | undefined }) => { + return `val-${ctx.v}` + }, true, { + key: (ctx: { v: null | undefined }) => ctx.v, + }) + + await resource.load({ v: null }) + expect(resource.value).toBe("val-null") + + await resource.load({ v: undefined }) + expect(resource.value).toBe("val-undefined") + + // Switch back to null — distinct entry + await resource.load({ v: null }) + expect(resource.value).toBe("val-null") + }) + + it('"null" string and null are distinct keys', async () => { + const resource = createResourceNode("test", "test", async (ctx: { v: string | null }) => { + return `val-${ctx.v}` + }, true, { + key: (ctx: { v: string | null }) => ctx.v, + }) + + await resource.load({ v: "null" }) + expect(resource.value).toBe("val-null") + + await resource.load({ v: null }) + expect(resource.value).toBe("val-null") + + // Switch back to "null" — distinct from null + await resource.load({ v: "null" }) + expect(resource.value).toBe("val-null") + }) + + it('"undefined" string and undefined are distinct keys', async () => { + const resource = createResourceNode("test", "test", async (ctx: { v: string | undefined }) => { + return `val-${ctx.v}` + }, true, { + key: (ctx: { v: string | undefined }) => ctx.v, + }) + + await resource.load({ v: "undefined" }) + expect(resource.value).toBe("val-undefined") + + await resource.load({ v: undefined }) + expect(resource.value).toBe("val-undefined") + + // Switch back to "undefined" — distinct from undefined + await resource.load({ v: "undefined" }) + expect(resource.value).toBe("val-undefined") + }) + + it("[null] and [undefined] are distinct keys", async () => { + const resource = createResourceNode("test", "test", async (ctx: { v: (null | undefined)[] }) => { + return `val-${ctx.v[0]}` + }, true, { + key: (ctx: { v: (null | undefined)[] }) => ctx.v, + }) + + await resource.load({ v: [null] }) + expect(resource.value).toBe("val-null") + + await resource.load({ v: [undefined] }) + expect(resource.value).toBe("val-undefined") + + // Switch back to [null] — distinct entry + await resource.load({ v: [null] }) + expect(resource.value).toBe("val-null") + }) + + it("nested arrays with null/undefined distinctions", async () => { + let callCount = 0 + const resource = createResourceNode("test", "test", async () => { + callCount++ + return `val-${callCount}` + }, true, { + key: (ctx: { v: (ResourceKey)[] }) => ctx.v, + }) + + await resource.load({ v: [[null], [undefined]] }) + expect(callCount).toBe(1) + + await resource.load({ v: [[undefined], [null]] }) + // Different nested structure → different key → loader invoked again + expect(callCount).toBe(2) + }) + + it("NaN and null are distinct keys", async () => { + const resource = createResourceNode("test", "test", async (ctx: { v: number | null }) => { + if (ctx.v === null) return "null-value" + return `num-${ctx.v}` + }, true, { + key: (ctx: { v: number | null }) => ctx.v, + }) + + await resource.load({ v: NaN }) + // Note: resource load context delivers NaN as-is + expect(resource.value).toBe("num-NaN") + + await resource.load({ v: null }) + expect(resource.value).toBe("null-value") + + // Switch back to NaN — distinct entry + await resource.load({ v: NaN }) + expect(resource.value).toBe("num-NaN") + }) + + it("Infinity and null are distinct keys", async () => { + const resource = createResourceNode("test", "test", async (ctx: { v: number | null }) => { + if (ctx.v === null) return "null-value" + if (!isFinite(ctx.v as number)) return "inf-value" + return `num-${ctx.v}` + }, true, { + key: (ctx: { v: number | null }) => ctx.v, + }) + + await resource.load({ v: Infinity }) + expect(resource.value).toBe("inf-value") + + await resource.load({ v: null }) + expect(resource.value).toBe("null-value") + + // Switch back to Infinity — distinct entry + await resource.load({ v: Infinity }) + expect(resource.value).toBe("inf-value") + }) + + it("equivalent nested array keys map to the same entry", async () => { + let callCount = 0 + const resource = createResourceNode("test", "test", async (ctx: { v: string[][] }) => { + callCount++ + return `val-${ctx.v[0]![0]}` + }, true, { + key: (ctx: { v: (ResourceKey)[] }) => ctx.v, + }) + + await resource.load({ v: [["a", "b"], ["c"]] }) + expect(callCount).toBe(1) + expect(resource.value).toBe("val-a") + + // Same nested array content → same key → updates same entry + await resource.load({ v: [["a", "b"], ["c"]] }) + expect(callCount).toBe(2) + expect(resource.value).toBe("val-a") + }) +}) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c132fb5..f5fd764 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,7 +7,7 @@ export type { AskNode, AnyAskNode, AskKind, AskBuilder } from "./ask.js" export type { ActNode, ActCondition, ActStatus, FeedbackConfig, ActBuilder, NavigationService, ActionExecutionContext, DefaultScreenServices } from "./act.js" export type { FlowNode, FlowStep, FlowBuilder } from "./flow.js" export type { SurfaceNode, SurfaceBuilder } from "./surface.js" -export type { ResourceNode, ResourceConfig, ResourceCacheOptions, ResourceLoadContext, ResourceStatus, AnyResourceNode } from "./resource.js" +export type { ResourceNode, ResourceConfig, ResourceCacheOptions, ResourceLoadContext, ResourceStatus, ResourceKey, AnyResourceNode } from "./resource.js" export { ResourceRef, createResourceNode } from "./resource.js" export { createScreenRuntime } from "./runtime.js" export type { ScreenRuntime } from "./runtime.js" diff --git a/packages/core/src/resource.ts b/packages/core/src/resource.ts index 8e421ab..3e2e132 100644 --- a/packages/core/src/resource.ts +++ b/packages/core/src/resource.ts @@ -3,6 +3,8 @@ import type { ActionExecutionContext, DefaultScreenServices } from "./act.js" export type ResourceStatus = "idle" | "pending" | "ready" | "failed" +export type ResourceKey = string | number | boolean | null | undefined | ResourceKey[] + export type ResourceLoadContext = ActionExecutionContext @@ -30,7 +32,8 @@ export type ResourceNode -export type ResourceCacheOptions = { +export type ResourceCacheOptions = { + key?: (context: ResourceLoadContext) => ResourceKey staleTime?: number deduplicate?: boolean } @@ -40,7 +43,7 @@ export type ResourceConfig - cache?: ResourceCacheOptions + cache?: ResourceCacheOptions ref?: ResourceRef } @@ -58,20 +61,49 @@ export function createResourceNode, autoLoad = true, - cache?: ResourceCacheOptions, + cache?: ResourceCacheOptions, ): ResourceNode { const statusSignal: Signal = signal(0) const staleSignal: Signal = signal(0) + const hasKey = typeof cache?.key === "function" + const DEFAULT_KEY = "" + + type Entry = { + value: TValue | undefined + status: ResourceStatus + error: unknown + stale: boolean + staleTimer: ReturnType | null + inFlightPromise: Promise | null + } + + function createEntry(): Entry { + return { + value: undefined, + status: "idle", + error: undefined, + stale: false, + staleTimer: null, + inFlightPromise: null, + } + } + + const entries = new Map() + let _activeKey: string = DEFAULT_KEY + + // For non-keyed resources, pre-create the default entry + if (!hasKey) { + entries.set(DEFAULT_KEY, createEntry()) + } + + // Sync-target variables read by public getters and conditions let currentStatus: ResourceStatus = "idle" let currentValue: TValue | undefined = undefined let currentError: unknown = undefined let currentStale = false let lastContext: ResourceLoadContext | undefined = undefined - let _staleTimer: ReturnType | null = null - let _inFlightPromise: Promise | null = null - const notify = () => statusSignal.set(statusSignal.get() + 1) const staleNotify = () => staleSignal.set(staleSignal.get() + 1) @@ -82,6 +114,98 @@ export function createResourceNode): string { + if (!hasKey) return DEFAULT_KEY + const context = ctx ?? lastContext ?? ({} as ResourceLoadContext) + return normalizeKey(cache!.key!(context)) + } + + // --- Stale timer helpers --- + function _clearEntryStaleTimer(entry: Entry): void { + if (entry.staleTimer != null) { + clearTimeout(entry.staleTimer) + entry.staleTimer = null + } + } + + function _startEntryStaleTimer(entry: Entry): void { + _clearEntryStaleTimer(entry) + if (cache?.staleTime != null && isFinite(cache.staleTime)) { + entry.staleTimer = setTimeout(() => { + if (!entry.stale) { + entry.stale = true + if (entry === getActiveEntry()) { + currentStale = true + staleNotify() + } + } + }, cache.staleTime) + } + } + + // Condition getters (read from synced variables) function getReady(): Condition { if (!_ready) { _ready = createCondition( @@ -122,113 +246,90 @@ export function createResourceNode): Promise { + const key = resolveKey(context) + _activeKey = key - function _startStaleTimer(): void { - _clearStaleTimer() - if (cache?.staleTime != null && isFinite(cache.staleTime)) { - _staleTimer = setTimeout(() => { - if (!currentStale) { - currentStale = true - staleNotify() - } - }, cache.staleTime) + let entry = entries.get(key) + if (!entry) { + entry = createEntry() + entries.set(key, entry) } - } - function executeLoad(context?: ResourceLoadContext): Promise { - if (shouldDeduplicate && _inFlightPromise) { - return _inFlightPromise + if (shouldDeduplicate && entry.inFlightPromise) { + return entry.inFlightPromise } - currentStale = false - staleNotify() - currentStatus = "pending" - currentValue = undefined - currentError = undefined - notify() - if (context !== undefined) { lastContext = context } const loadContext = context ?? lastContext ?? ({} as ResourceLoadContext) + entry.stale = false + entry.status = "pending" + entry.value = undefined + entry.error = undefined + syncFromActiveEntry() + const promise = (async (): Promise => { try { const result = await Promise.resolve( - (loader as (ctx: ResourceLoadContext) => TValue | Promise)( - loadContext - ) + (loader as (ctx: ResourceLoadContext) => TValue | Promise)(loadContext) ) - currentValue = result - currentStatus = "ready" - currentStale = false - notify() + entry!.value = result + entry!.status = "ready" + entry!.stale = false + if (entry === getActiveEntry()) syncFromEntry(entry!) staleNotify() - _startStaleTimer() + _startEntryStaleTimer(entry!) } catch (e: unknown) { - currentError = e - currentStatus = "failed" - currentStale = false - notify() + entry!.error = e + entry!.status = "failed" + entry!.stale = false + if (entry === getActiveEntry()) syncFromEntry(entry!) staleNotify() } finally { - _inFlightPromise = null + entry!.inFlightPromise = null } })() - _inFlightPromise = promise + entry.inFlightPromise = promise return promise } function invalidate(): void { - if (!currentStale) { - currentStale = true - staleNotify() + const entry = getActiveEntry() + if (!entry.stale) { + entry.stale = true + if (entry === getActiveEntry()) { + currentStale = true + staleNotify() + } } } function dispose(): void { - _clearStaleTimer() - _inFlightPromise = null + for (const entry of entries.values()) { + _clearEntryStaleTimer(entry) + entry.inFlightPromise = null + } } const node: ResourceNode = { id, name, autoLoad, - get status() { - return currentStatus - }, - get value() { - return currentValue - }, - get error() { - return currentError - }, - get ready() { - return getReady() - }, - get pending() { - return getPending() - }, - get failed() { - return getFailed() - }, - get stale() { - return getStale() - }, + get status() { return currentStatus }, + get value() { return currentValue }, + get error() { return currentError }, + get ready() { return getReady() }, + get pending() { return getPending() }, + get failed() { return getFailed() }, + get stale() { return getStale() }, load: executeLoad, reload: executeLoad, invalidate, - subscribe(fn: () => void) { - return statusSignal.subscribe(fn) - }, + subscribe(fn: () => void) { return statusSignal.subscribe(fn) }, dispose, } diff --git a/packages/core/src/screen.ts b/packages/core/src/screen.ts index 9bb1337..0b3bda1 100644 --- a/packages/core/src/screen.ts +++ b/packages/core/src/screen.ts @@ -26,7 +26,7 @@ export type ScreenBuilder = { config: { load: (() => Promise) | ((context: ResourceLoadContext) => Promise) autoLoad?: boolean - cache?: ResourceCacheOptions + cache?: ResourceCacheOptions }, ) => ResourceRef } @@ -62,7 +62,7 @@ export function screen( act: (label) => new ActBuilder(label), flow: (n) => new FlowBuilder(n), surface: (n) => new SurfaceBuilder(n), - resource: (n: string, config: { load: (() => Promise) | ((context: ResourceLoadContext) => Promise); autoLoad?: boolean; cache?: ResourceCacheOptions }) => { + resource: (n: string, config: { load: (() => Promise) | ((context: ResourceLoadContext) => Promise); autoLoad?: boolean; cache?: ResourceCacheOptions }) => { const baseId = `resource_${n}` const id = configs.some(c => c.id === baseId) ? nextSuffix(baseId, (id) => configs.some(c => c.id === id))