diff --git a/.changeset/good-pugs-sleep.md b/.changeset/good-pugs-sleep.md new file mode 100644 index 0000000..e44b17f --- /dev/null +++ b/.changeset/good-pugs-sleep.md @@ -0,0 +1,5 @@ +--- +"@codebelt/classy-store": patch +--- + +add TTL expiration and related features to persistence utility diff --git a/src/utils/persist/persist.test.ts b/src/utils/persist/persist.test.ts index 846d15b..ac0c8af 100644 --- a/src/utils/persist/persist.test.ts +++ b/src/utils/persist/persist.test.ts @@ -750,6 +750,192 @@ describe('persist()', () => { }); }); + // ── expireIn / TTL ───────────────────────────────────────────────────── + + describe('expireIn / TTL', () => { + it('hydrates normally when TTL has not elapsed', async () => { + const storage = createMockStorage(); + storage.data.set( + 'test', + JSON.stringify({ + version: 0, + state: {count: 42}, + expiresAt: Date.now() + 60_000, + }), + ); + + const s = store({count: 0}); + const handle = persist(s, {name: 'test', storage, expireIn: 60_000}); + await handle.hydrated; + + expect(s.count).toBe(42); + expect(handle.isExpired).toBe(false); + }); + + it('skips hydration when TTL has elapsed and sets isExpired', async () => { + const storage = createMockStorage(); + storage.data.set( + 'test', + JSON.stringify({ + version: 0, + state: {count: 42}, + expiresAt: Date.now() - 1000, + }), + ); + + const s = store({count: 0}); + const handle = persist(s, {name: 'test', storage, expireIn: 60_000}); + await handle.hydrated; + + expect(s.count).toBe(0); // not hydrated + expect(handle.isExpired).toBe(true); + }); + + it('clearOnExpire: true removes the key from storage', async () => { + const storage = createMockStorage(); + storage.data.set( + 'test', + JSON.stringify({ + version: 0, + state: {count: 42}, + expiresAt: Date.now() - 1000, + }), + ); + + const s = store({count: 0}); + const handle = persist(s, { + name: 'test', + storage, + expireIn: 60_000, + clearOnExpire: true, + }); + await handle.hydrated; + await tick(); + + expect(storage.data.has('test')).toBe(false); + }); + + it('clearOnExpire: false (default) leaves the key in storage', async () => { + const storage = createMockStorage(); + storage.data.set( + 'test', + JSON.stringify({ + version: 0, + state: {count: 42}, + expiresAt: Date.now() - 1000, + }), + ); + + const s = store({count: 0}); + const handle = persist(s, {name: 'test', storage, expireIn: 60_000}); + await handle.hydrated; + await tick(); + + expect(storage.data.has('test')).toBe(true); + }); + + it('cross-tab sync rejects expired envelopes', async () => { + const storage = createMockStorage(); + const s = store({count: 0}); + const handle = persist(s, { + name: 'test', + storage, + expireIn: 60_000, + syncTabs: true, + }); + await handle.hydrated; + + const event = new StorageEvent('storage', { + key: 'test', + newValue: JSON.stringify({ + version: 0, + state: {count: 999}, + expiresAt: Date.now() - 1000, + }), + }); + globalThis.dispatchEvent(event); + + expect(s.count).toBe(0); // expired — rejected + expect(handle.isExpired).toBe(true); + + handle.unsubscribe(); + }); + + it('data without expiresAt hydrates normally when expireIn is set', async () => { + const storage = createMockStorage(); + storage.data.set( + 'test', + JSON.stringify({version: 0, state: {count: 77}}), + ); + + const s = store({count: 0}); + const handle = persist(s, {name: 'test', storage, expireIn: 60_000}); + await handle.hydrated; + + expect(s.count).toBe(77); + expect(handle.isExpired).toBe(false); + }); + + it('TTL resets on every write (envelope timestamp refreshes)', async () => { + const storage = createMockStorage(); + const s = store({count: 0}); + persist(s, {name: 'test', storage, expireIn: 30_000}); + + const before = Date.now(); + s.count = 1; + await tick(); + + const stored1 = parseStored(storage, 'test') as unknown as { + expiresAt: number; + }; + expect(stored1.expiresAt).toBeGreaterThanOrEqual(before + 30_000); + + // Second write should bump the timestamp. + const betweenWrites = Date.now(); + s.count = 2; + await tick(); + + const stored2 = parseStored(storage, 'test') as unknown as { + expiresAt: number; + }; + expect(stored2.expiresAt).toBeGreaterThanOrEqual(betweenWrites + 30_000); + expect(stored2.expiresAt).toBeGreaterThanOrEqual(stored1.expiresAt); + }); + + it('rehydrate() re-checks expiry', async () => { + const storage = createMockStorage(); + // Start with valid data. + storage.data.set( + 'test', + JSON.stringify({ + version: 0, + state: {count: 42}, + expiresAt: Date.now() + 60_000, + }), + ); + + const s = store({count: 0}); + const handle = persist(s, {name: 'test', storage, expireIn: 60_000}); + await handle.hydrated; + expect(s.count).toBe(42); + expect(handle.isExpired).toBe(false); + + // Simulate data becoming expired. + storage.data.set( + 'test', + JSON.stringify({ + version: 0, + state: {count: 99}, + expiresAt: Date.now() - 1000, + }), + ); + + await handle.rehydrate(); + expect(s.count).toBe(42); // unchanged — expired data not applied + expect(handle.isExpired).toBe(true); + }); + }); + // ── Edge cases ────────────────────────────────────────────────────────── describe('edge cases', () => { diff --git a/src/utils/persist/persist.ts b/src/utils/persist/persist.ts index 2fb5c0e..a9d04db 100644 --- a/src/utils/persist/persist.ts +++ b/src/utils/persist/persist.ts @@ -122,6 +122,20 @@ export type PersistOptions = { * Default: `true` when storage is `localStorage`, `false` otherwise. */ syncTabs?: boolean; + + /** + * Time-to-live in milliseconds. After this duration, stored data is + * considered expired and skipped during hydration. The TTL resets on + * every write (active sessions stay fresh as long as mutations happen). + */ + expireIn?: number; + + /** + * When `true`, automatically remove the storage key if data is found + * expired during hydration. Default: `false` (expired data is skipped + * but left in storage). + */ + clearOnExpire?: boolean; }; /** @@ -145,6 +159,9 @@ export type PersistHandle = { /** Manually re-hydrate the store from storage. */ rehydrate: () => Promise; + + /** True if the last hydration found expired data (requires `expireIn`). */ + isExpired: boolean; }; // ── Storage envelope ───────────────────────────────────────────────────────── @@ -153,6 +170,7 @@ export type PersistHandle = { type PersistEnvelope = { version: number; state: Record; + expiresAt?: number; }; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -262,6 +280,8 @@ export function persist( merge = 'shallow', skipHydration = false, syncTabs: syncTabsOption, + expireIn, + clearOnExpire = false, } = options; const maybeStorage = options.storage ?? getDefaultStorage(); @@ -290,6 +310,7 @@ export function persist( let disposed = false; let debounceTimer: ReturnType | null = null; let hydratedFlag = false; + let expiredFlag = false; // Hydration promise + resolver. let resolveHydrated: () => void; @@ -316,6 +337,9 @@ export function persist( } const envelope: PersistEnvelope = {version, state}; + if (expireIn != null) { + envelope.expiresAt = Date.now() + expireIn; + } return JSON.stringify(envelope); } @@ -367,6 +391,16 @@ export function persist( return; } + // Expiry check — skip hydration if data has expired. + if ( + typeof envelope.expiresAt === 'number' && + Date.now() >= envelope.expiresAt + ) { + expiredFlag = true; + if (clearOnExpire) void storage.removeItem(name); + return; + } + let {state} = envelope; // Version migration. @@ -465,6 +499,10 @@ export function persist( return hydratedFlag; }, + get isExpired() { + return expiredFlag; + }, + hydrated: hydratedPromise, unsubscribe() { diff --git a/website/docs/PERSIST_ARCHITECTURE.md b/website/docs/PERSIST_ARCHITECTURE.md index d191035..0b9ed76 100644 --- a/website/docs/PERSIST_ARCHITECTURE.md +++ b/website/docs/PERSIST_ARCHITECTURE.md @@ -54,7 +54,7 @@ packages/store/ index.ts # Barrel: export { persist } from './persist/persist' persist/ persist.ts # persist(), types, and all logic - persist.test.ts # 38 tests with mock storage adapters + persist.test.ts # tests with mock storage adapters package.json # "./utils" export entry tsdown.config.ts # 'src/utils/index.ts' in entry array ``` @@ -68,6 +68,7 @@ Each `persist()` call creates a closure with the following internal state: | `disposed` | `boolean` | Guards against writes after `unsubscribe()` | | `debounceTimer` | `ReturnType \| null` | Active debounce timer (cancelled on save/unsubscribe) | | `hydratedFlag` | `boolean` | Synchronous hydration status | +| `expiredFlag` | `boolean` | Set to `true` when hydration encounters expired data | | `resolveHydrated` | `() => void` | Resolves the `hydrated` promise | | `rejectHydrated` | `(error) => void` | Rejects the `hydrated` promise on error | | `resolvedProps` | `Array<{key, transform?}>` | Normalized property list with optional transforms | @@ -94,8 +95,9 @@ Triggered by `subscribe(proxy, callback)` firing after each batched mutation: if transform exists → call transform.serialize(value) assign to state object 6. wrap in envelope: { version, state } -7. JSON.stringify(envelope) -8. await storage.setItem(name, json) +7. if expireIn is set → stamp expiresAt = Date.now() + expireIn +8. JSON.stringify(envelope) +9. await storage.setItem(name, json) ``` ### Property Resolution @@ -119,11 +121,12 @@ Data is stored as a JSON string with a version wrapper: "state": { "todos": [{"text": "Buy milk", "done": false}], "filter": "all" - } + }, + "expiresAt": 1700000000000 } ``` -The `version` field is always present (default `0`). The `state` field contains only the selected properties, post-serialization-transform. +The `version` field is always present (default `0`). The `state` field contains only the selected properties, post-serialization-transform. The `expiresAt` field is a Unix epoch timestamp (milliseconds) and is only present when `expireIn` is set. It is refreshed on every write. Envelopes without `expiresAt` are treated as "never expires". ## Restore Flow (Hydration) @@ -138,18 +141,21 @@ All three call the same `applyPersistedState(raw)` function: 1. JSON.parse(raw) → envelope 2. Validate envelope shape (object with .state) if invalid → return silently (corrupted data) -3. if envelope.version !== options.version AND migrate exists: +3. if envelope.expiresAt exists AND Date.now() >= expiresAt: + set expiredFlag = true + if clearOnExpire → storage.removeItem(name) + return (skip hydration) +4. if envelope.version !== options.version AND migrate exists: state = migrate(state, envelope.version) -4. for each key in state: if transform exists → state[key] = transform.deserialize(state[key]) -5. Build currentState from snapshot (only propKeys) -6. Apply merge strategy: +6. Build currentState from snapshot (only propKeys) +7. Apply merge strategy: 'shallow' / 'replace': { ...currentState, ...persistedState } custom function: merge(persistedState, currentState) -7. for each key in propKeys: +8. for each key in propKeys: if key in mergedState → proxy[key] = mergedState[key] (goes through SET trap → reactivity → batched notification) -8. Set hydratedFlag = true, resolve hydrated promise +9. Set hydratedFlag = true, resolve hydrated promise ``` ### Merge Strategies @@ -223,6 +229,7 @@ After `unsubscribe()`: | `unsubscribe()` | Full cleanup: unsubscribe + cancel debounce + remove storage listener. Idempotent. | | `hydrated` | Promise that resolves when initial hydration completes. Rejects on async storage errors. | | `isHydrated` | Getter returning `boolean`. Becomes `true` after hydration resolves or rejects. | +| `isExpired` | Getter returning `boolean`. `true` when the last hydration found expired data (requires `expireIn`). Re-evaluated on every `rehydrate()` call. | | `save()` | Cancel pending debounce, write current state immediately. No-op if disposed. | | `clear()` | Call `storage.removeItem(name)`. Works even after unsubscribe. | | `rehydrate()` | Re-read from storage, apply to store. Also resolves `hydrated` if not yet resolved (for `skipHydration` flows). | @@ -276,22 +283,19 @@ Zustand uses `onRehydrateStorage` (a callback), Pinia uses `beforeHydrate`/`afte Debounce waits for a quiet period after the last mutation. Throttle writes at regular intervals. For persistence, debounce is more appropriate: it coalesces bursts of mutations (like typing) into a single write, while throttle would write mid-burst with potentially incomplete state. `save()` provides the escape hatch for "write right now" scenarios. -## Test Coverage - -38 tests across 9 test groups: - -| Group | Tests | What's covered | -|---|---|---| -| Basic round-trip | 5 | Save/restore, versioned envelope, getter exclusion, method exclusion | -| Properties option | 2 | Persist specific properties, restore only specified properties | -| Per-property transforms | 2 | Date round-trip, ReactiveMap round-trip | -| Debounce | 2 | Coalescing, save bypass | -| Version migration | 2 | Migration call, version match skip | -| Merge strategies | 3 | Shallow, replace, custom function | -| skipHydration | 3 | No auto-hydrate, manual rehydrate, promise resolution | -| Unsubscribe | 2 | Stop writes, cancel debounce | -| Flush/clear/rehydrate | 4 | Immediate write, remove data, in-memory preservation, re-read | -| Hydration state | 2 | Promise instance, resolution timing | -| Async storage | 2 | Async write, async hydrate | -| Cross-tab sync | 4 | Correct key, wrong key, post-unsubscribe, disabled | -| Edge cases | 4 | Empty storage, corrupted JSON, invalid envelope, no-storage error, multiple persists | +| Group | What's covered | +|---|---| +| Basic round-trip | Save/restore, versioned envelope, getter exclusion, method exclusion | +| Properties option | Persist specific properties, restore only specified properties | +| Per-property transforms | Date round-trip, ReactiveMap round-trip | +| Debounce | Coalescing, save bypass | +| Version migration | Migration call, version match skip | +| Merge strategies | Shallow, replace, custom function | +| skipHydration | No auto-hydrate, manual rehydrate, promise resolution | +| Unsubscribe | Stop writes, cancel debounce | +| Flush/clear/rehydrate | Immediate write, remove data, in-memory preservation, re-read | +| Hydration state | Promise instance, resolution timing | +| Async storage | Async write, async hydrate | +| Cross-tab sync | Correct key, wrong key, post-unsubscribe, disabled | +| expireIn / TTL | Normal hydration, expired skip, clearOnExpire, default leave-in-storage, cross-tab reject, no-expiresAt envelope, TTL reset on write, rehydrate re-check | +| Edge cases | Empty storage, corrupted JSON, invalid envelope, no-storage error, multiple persists | diff --git a/website/docs/PERSIST_TUTORIAL.md b/website/docs/PERSIST_TUTORIAL.md index 50d26c9..927148b 100644 --- a/website/docs/PERSIST_TUTORIAL.md +++ b/website/docs/PERSIST_TUTORIAL.md @@ -324,6 +324,55 @@ persist(uiStore, { Cross-tab sync only works with `localStorage`. It does not fire for `sessionStorage`, `AsyncStorage`, or custom adapters. +## Expiration / TTL + +Use `expireIn` to set a time-to-live (in milliseconds) on persisted data. After the TTL elapses, stored data is skipped during hydration and the store keeps its class defaults. + +```ts +persist(sessionStore, { + name: 'session', + expireIn: 900_000, // 15 minutes +}); +``` + +**The TTL resets on every write.** As long as mutations keep happening (an active user session), the data stays fresh. The countdown only matters when the store is hydrated from storage after a period of inactivity (e.g., a page reload). + +### Checking expiration + +The handle exposes an `isExpired` flag that becomes `true` when hydration encounters expired data: + +```ts +const handle = persist(sessionStore, { + name: 'session', + expireIn: 900_000, +}); + +await handle.hydrated; + +if (handle.isExpired) { + // Stored session expired — redirect to login + router.push('/login'); +} +``` + +`isExpired` is re-evaluated on every `rehydrate()` call, so you can poll or re-check after cross-tab sync events. + +### Auto-clearing expired data + +By default, expired data is skipped but left in storage. Set `clearOnExpire: true` to automatically remove the key: + +```ts +persist(sessionStore, { + name: 'session', + expireIn: 900_000, + clearOnExpire: true, // Remove key from storage when expired +}); +``` + +### Cross-tab sync and expiration + +Expired envelopes received from other tabs via `window.storage` events are also rejected. The same expiry check runs on every hydration path — init, `rehydrate()`, and cross-tab sync. + ## SSR / Next.js Support Server-side rendering creates a hydration mismatch: the server renders with the store's default state, but the client would hydrate from `localStorage` before React reconciles. `skipHydration` defers persistence to the client: @@ -537,6 +586,9 @@ console.log(`Welcome back! Theme: ${appStore.theme}, Bookmarks: ${appStore.bookm | Disable cross-tab sync | `syncTabs: false` | | Use sessionStorage | `storage: sessionStorage` | | Use AsyncStorage | `storage: AsyncStorage, syncTabs: false` | +| Expire after 15 minutes | `expireIn: 900_000` | +| Auto-clear expired data | `clearOnExpire: true` | +| Check if data expired | `handle.isExpired` | | Force immediate save | `handle.save()` | | Clear stored data | `handle.clear()` | | Stop persisting | `handle.unsubscribe()` | diff --git a/website/docs/TUTORIAL.md b/website/docs/TUTORIAL.md index b3bb76e..54a065e 100644 --- a/website/docs/TUTORIAL.md +++ b/website/docs/TUTORIAL.md @@ -470,6 +470,6 @@ const handle = persist(todoStore, { // Cross-tab sync is enabled by default. ``` -The persist utility supports per-property transforms (for `Date`, `ReactiveMap`, etc.), debounced writes, schema versioning with migration, merge strategies, SSR-safe `skipHydration`, and cross-tab synchronization. +The persist utility supports per-property transforms (for `Date`, `ReactiveMap`, etc.), debounced writes, schema versioning with migration, merge strategies, TTL expiration (`expireIn`), SSR-safe `skipHydration`, and cross-tab synchronization. For the full walkthrough, see the **[Persist Tutorial](./PERSIST_TUTORIAL.md)**. diff --git a/website/docs/index.md b/website/docs/index.md index f232ae0..b7668d7 100644 --- a/website/docs/index.md +++ b/website/docs/index.md @@ -17,7 +17,7 @@ Class-based reactive state management for React. Write plain TypeScript classes - **Type-safe** — full TypeScript inference from your class definitions - **Two hook modes** — explicit selector or automatic property tracking - **Reactive collections** — `reactiveMap()` and `reactiveSet()` for Map/Set-like state -- **Persistence** — `persist()` utility with transforms, versioning, migration, debounce, cross-tab sync, and SSR support +- **Persistence** — `persist()` utility with transforms, versioning, migration, debounce, cross-tab sync, TTL expiration, and SSR support ## Installation @@ -314,6 +314,8 @@ const handle = persist(todoStore, { | `merge` | `'shallow' \| 'replace' \| fn` | `'shallow'` | How to merge persisted state with current store state | | `skipHydration` | `boolean` | `false` | Defer hydration for SSR (call `handle.rehydrate()` manually) | | `syncTabs` | `boolean` | auto | Sync state across browser tabs via `window.storage` event | +| `expireIn` | `number` | — | TTL in milliseconds; stored data older than this is skipped during hydration. Resets on every write. | +| `clearOnExpire` | `boolean` | `false` | Remove expired key from storage automatically during hydration | **Return value (`PersistHandle`):** @@ -325,6 +327,7 @@ const handle = persist(todoStore, { | `save()` | `() => Promise` | Immediate write to storage (bypasses debounce) | | `clear()` | `() => Promise` | Remove this store's data from storage | | `rehydrate()` | `() => Promise` | Manually re-read from storage and apply | +| `isExpired` | `boolean` | Whether the last hydration found expired data (requires `expireIn`) | **Per-property transforms** handle non-JSON types like `Date` or `ReactiveMap`: