diff --git a/README.md b/README.md index a671364..ffc5a20 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ The current repository proves the core shape: - Runtime-scoped resources - Resource reload and invalidation - Resource cache phase 2 (staleTime, deduplicate, cache.key, ResourceKey) +- Resource cache phase 3 (cacheTime — single-runtime stale value retention and eviction) - Independent executable actions - Keyboard Enter default action - Accessible Enter hints @@ -40,17 +41,17 @@ It is not production-ready yet. The goal right now is to keep the foundation sma Intent is available as experimental alpha packages on npm: ```sh -pnpm add @intent-framework/core@0.1.0-alpha.9 @intent-framework/dom@0.1.0-alpha.9 @intent-framework/router@0.1.0-alpha.9 @intent-framework/testing@0.1.0-alpha.9 +pnpm add @intent-framework/core@0.1.0-alpha.10 @intent-framework/dom@0.1.0-alpha.10 @intent-framework/router@0.1.0-alpha.10 @intent-framework/testing@0.1.0-alpha.10 ``` ```sh -npm install @intent-framework/core@0.1.0-alpha.9 @intent-framework/dom@0.1.0-alpha.9 @intent-framework/router@0.1.0-alpha.9 @intent-framework/testing@0.1.0-alpha.9 +npm install @intent-framework/core@0.1.0-alpha.10 @intent-framework/dom@0.1.0-alpha.10 @intent-framework/router@0.1.0-alpha.10 @intent-framework/testing@0.1.0-alpha.10 ``` The quickstart pins the current alpha version so examples match the published APIs. ```txt -Current alpha: v0.1.0-alpha.9 +Current alpha: v0.1.0-alpha.10 First public alpha: v0.1.0-alpha.0 GitHub Releases: https://github.com/intent-framework/intent/releases ``` @@ -255,7 +256,7 @@ The repository includes five runnable examples, each demonstrating a different s |---------|---------------------| | [`canonical-invite`](examples/canonical-invite) | Minimal screen: text ask, primary action, validation, blocked reasons, feedback, `inspectScreen()`. Matches the Quickstart one-to-one. | | [`choice-form`](examples/choice-form) | All state kinds (text, choice, boolean) and ask kinds (`asChoice()`, `asSecret()`, `hint()`), multiple surfaces per screen, flow diagnostics, full test coverage. | -| [`resource-lifecycle`](examples/resource-lifecycle) | Full resource lifecycle: autoLoad, manual load, reload, invalidation, stale detection, failed state, action-driven invalidation with `.invalidates()`. | +| [`resource-lifecycle`](examples/resource-lifecycle) | Full resource lifecycle: autoLoad, manual load, reload, invalidation, stale detection, failed state, action-driven invalidation with `.invalidates()`, staleTime, cache.key, cacheTime. | | [`secret-vault`](examples/secret-vault) | Multi-step flows (3+ steps), `asSecret()` + `private()`, `BooleanState` toggles, multi-screen router app, flow diagnostics. | | [`web-basic`](examples/web-basic) | Multi-screen team invite demo with router, resources, diagnostics panel, feedback, and keyboard default action. | @@ -347,7 +348,7 @@ Current limitations include: * No native renderer yet * No SSR story yet * No backend persistence yet -* Resource cache is phase 2 (staleTime, deduplicate, cache.key); cacheTime, swr, and cross-navigation cache are future +* Resource cache is phase 3 (staleTime, deduplicate, cache.key, cacheTime); swr and cross-navigation cache are future * No DevTools package yet * Automated version PR workflow and manual publish workflow are set up (Changesets configured) * Demo side panels use manual DOM diff --git a/docs/Quickstart.md b/docs/Quickstart.md index 7be9b94..6c9c1d9 100644 --- a/docs/Quickstart.md +++ b/docs/Quickstart.md @@ -7,16 +7,16 @@ This guide shows the fastest path from zero to a semantic screen using published ## 1. Install ```sh -pnpm add @intent-framework/core@0.1.0-alpha.9 @intent-framework/dom@0.1.0-alpha.9 @intent-framework/testing@0.1.0-alpha.9 +pnpm add @intent-framework/core@0.1.0-alpha.10 @intent-framework/dom@0.1.0-alpha.10 @intent-framework/testing@0.1.0-alpha.10 ``` Or with npm: ```sh -npm install @intent-framework/core@0.1.0-alpha.9 @intent-framework/dom@0.1.0-alpha.9 @intent-framework/testing@0.1.0-alpha.9 +npm install @intent-framework/core@0.1.0-alpha.10 @intent-framework/dom@0.1.0-alpha.10 @intent-framework/testing@0.1.0-alpha.10 ``` -The quickstart pins `0.1.0-alpha.9` so the examples match the APIs shown below. +The quickstart pins `0.1.0-alpha.10` so the examples match the APIs shown below. You also need `typescript` and `vitest` for type checking and tests. diff --git a/docs/agent/RELEASE-STATE.md b/docs/agent/RELEASE-STATE.md index 9122e1f..e8c74ca 100644 --- a/docs/agent/RELEASE-STATE.md +++ b/docs/agent/RELEASE-STATE.md @@ -4,10 +4,10 @@ | Package | Version | Published | Notes | |---------|---------|-----------|-------| -| `@intent-framework/core` | `0.1.0-alpha.9` | Yes | alpha prerelease | -| `@intent-framework/dom` | `0.1.0-alpha.9` | Yes | alpha prerelease | -| `@intent-framework/router` | `0.1.0-alpha.9` | Yes | alpha prerelease | -| `@intent-framework/testing` | `0.1.0-alpha.9` | Yes | alpha prerelease | +| `@intent-framework/core` | `0.1.0-alpha.10` | Yes | alpha prerelease | +| `@intent-framework/dom` | `0.1.0-alpha.10` | Yes | alpha prerelease | +| `@intent-framework/router` | `0.1.0-alpha.10` | Yes | alpha prerelease | +| `@intent-framework/testing` | `0.1.0-alpha.10` | Yes | alpha prerelease | | `@intent-framework/server` | `0.1.0` | No | private workspace package | `@intent-framework/server` must remain `"private": true`. Never publish it. diff --git a/examples/resource-lifecycle/index.html b/examples/resource-lifecycle/index.html index 1838a65..de35299 100644 --- a/examples/resource-lifecycle/index.html +++ b/examples/resource-lifecycle/index.html @@ -20,7 +20,7 @@

Intent: Resource Lifecycle

-

Demonstrates autoLoad, manual load, reload, invalidation, stale state, failed state, and action-driven invalidation.

+

Demonstrates autoLoad, manual load, reload, invalidation, stale state, failed state, action-driven invalidation, staleTime, cache.key, and cacheTime.

Resource Status

Loading...
@@ -39,6 +39,9 @@

Semantic Graph (inspectScreen())

  • A failed loader produces failed status and error.
  • A failed action does not invalidate its targeted resources.
  • Route context is available to resource loaders.
  • +
  • cache.cacheTime retains stale value until eviction timeout.
  • +
  • After cacheTime expiry, stale resource transitions from ready to idle.
  • +
  • Keyed resources each have independent cacheTime timers.
  • diff --git a/examples/resource-lifecycle/src/ResourceDemo.test.ts b/examples/resource-lifecycle/src/ResourceDemo.test.ts index 4f6da16..79e521b 100644 --- a/examples/resource-lifecycle/src/ResourceDemo.test.ts +++ b/examples/resource-lifecycle/src/ResourceDemo.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest" import { testScreen } from "@intent-framework/testing" import { createScreenRuntime, inspectScreen } from "@intent-framework/core" -import { ResourceDemo, teamLoadCount, cachedTeamLoadCount, dedupeReportLoadCount, keyedTeamLoadCount } from "./ResourceDemo.js" +import { ResourceDemo, teamLoadCount, cachedTeamLoadCount, dedupeReportLoadCount, keyedTeamLoadCount, timeEvictTeamLoadCount } from "./ResourceDemo.js" const testServices = { route: { name: "demo", path: "/:id", params: { id: "team_1" } }, @@ -157,6 +157,65 @@ describe("ResourceDemo", () => { runtime.dispose() }) + // === cacheTime tests === + + it("invalidate keeps stale value before cacheTime expires", async () => { + const runtime = createScreenRuntime(ResourceDemo, { services: testServices as any }) + await runtime.start() + const evict = runtime.resources.find(r => r.name === "timeEvictTeam")! + expect(evict.status).toBe("ready") + expect(evict.stale.current).toBe(false) + expect(evict.value).toBeDefined() + const val = evict.value + evict.invalidate() + expect(evict.stale.current).toBe(true) + expect(evict.value).toBe(val) + expect(evict.status).toBe("ready") + runtime.dispose() + }) + + it("cacheTime expiry resets active entry to idle", async () => { + const runtime = createScreenRuntime(ResourceDemo, { services: testServices as any }) + await runtime.start() + const evict = runtime.resources.find(r => r.name === "timeEvictTeam")! + evict.invalidate() + expect(evict.status).toBe("ready") + expect(evict.stale.current).toBe(true) + await new Promise(r => setTimeout(r, 1100)) + expect(evict.status).toBe("idle") + expect(evict.stale.current).toBe(false) + expect(evict.value).toBeUndefined() + runtime.dispose() + }) + + it("cacheTime works per key for keyed resource", async () => { + const runtime = createScreenRuntime(ResourceDemo, { services: testServices as any }) + await runtime.start() + const evict = runtime.resources.find(r => r.name === "keyedTimeEvict")! + await evict.load({ route: { name: "demo", path: "/:id", params: { id: "team_b" } } }) + expect(evict.status).toBe("ready") + evict.invalidate() + expect(evict.stale.current).toBe(true) + await new Promise(r => setTimeout(r, 1100)) + expect(evict.status).toBe("idle") + expect(evict.stale.current).toBe(false) + runtime.dispose() + }) + + it("no-arg reload after cacheTime eviction reuses last context", async () => { + const runtime = createScreenRuntime(ResourceDemo, { services: testServices as any }) + await runtime.start() + const evict = runtime.resources.find(r => r.name === "timeEvictTeam")! + const before = timeEvictTeamLoadCount + evict.invalidate() + await new Promise(r => setTimeout(r, 1100)) + expect(evict.status).toBe("idle") + await evict.reload() + expect(timeEvictTeamLoadCount).toBe(before + 1) + expect(evict.status).toBe("ready") + runtime.dispose() + }) + it("cache.key: no-arg reload reuses last active key", async () => { const runtime = createScreenRuntime(ResourceDemo, { services: testServices as any }) await runtime.start() @@ -176,7 +235,7 @@ describe("ResourceDemo", () => { await runtime.start() const graph = runtime.graph const resources = graph.resources - expect(resources).toHaveLength(6) + expect(resources).toHaveLength(8) const teamRes = resources.find(r => r.name === "team")! expect(teamRes.status).toBe("ready") expect(teamRes.hasValue).toBe(true) diff --git a/examples/resource-lifecycle/src/ResourceDemo.ts b/examples/resource-lifecycle/src/ResourceDemo.ts index bdb724f..e0115a6 100644 --- a/examples/resource-lifecycle/src/ResourceDemo.ts +++ b/examples/resource-lifecycle/src/ResourceDemo.ts @@ -6,6 +6,8 @@ export let auditLogLoadCount = 0 export let cachedTeamLoadCount = 0 export let dedupeReportLoadCount = 0 export let keyedTeamLoadCount = 0 +export let timeEvictTeamLoadCount = 0 +export let keyedTimeEvictLoadCount = 0 export type Team = { id: string @@ -77,6 +79,32 @@ export const ResourceDemo = screen("Resource Demo", $ => { cache: { key: ({ route }) => route.params.id }, }) + const timeEvictTeam = $.resource("timeEvictTeam", { + load: async ({ route }) => { + timeEvictTeamLoadCount++ + return { + id: route.params.id ?? "default", + name: "TimeEvict", + members: 7, + version: timeEvictTeamLoadCount, + } satisfies Team + }, + cache: { cacheTime: 1000 }, + }) + + const keyedTimeEvict = $.resource("keyedTimeEvict", { + load: async ({ route }) => { + keyedTimeEvictLoadCount++ + return { + id: route.params.id, + name: `Evict-${route.params.id}`, + members: 2, + version: keyedTimeEvictLoadCount, + } satisfies Team + }, + cache: { key: ({ route }) => route.params.id, cacheTime: 1000 }, + }) + const reloadTeam = $.act("Reload team") .does(async () => { await team.reload() @@ -125,6 +153,24 @@ export const ResourceDemo = screen("Resource Demo", $ => { await keyedTeam.reload() }) + const invalidateTimeEvict = $.act("Invalidate time-evict team") + .does(() => { + timeEvictTeam.invalidate() + }) + + const loadKeyedTimeEvictB = $.act("Load keyed time-evict (team_b)") + .does(async ({ navigate }) => { + await keyedTimeEvict.load({ + route: { name: "demo", path: "/:id", params: { id: "team_b" } }, + navigate, + }) + }) + + const invalidateKeyedTimeEvict = $.act("Invalidate keyed time-evict") + .does(() => { + keyedTimeEvict.invalidate() + }) + $.surface("main").contains( reloadTeam, invalidateTeam, @@ -135,5 +181,8 @@ export const ResourceDemo = screen("Resource Demo", $ => { loadDedupeReport, loadKeyedTeamB, reloadKeyedTeam, + invalidateTimeEvict, + loadKeyedTimeEvictB, + invalidateKeyedTimeEvict, ) })