diff --git a/src/arts/active-app/presets/index.ts b/src/arts/active-app/presets/index.ts index 5b6a747..d0b812c 100644 --- a/src/arts/active-app/presets/index.ts +++ b/src/arts/active-app/presets/index.ts @@ -36,4 +36,8 @@ export { applyPermInvalidateOnIdentityChange, type PermInvalidateOnIdentityChangeApp } from './perm-invalidate-on-identity-change.ts'; +export { + applySessionAutoRefresh, + type SessionAutoRefreshApp +} from './session-auto-refresh.ts'; export { applyStandardOrca, type StandardOrcaApp } from './standard.ts'; diff --git a/src/arts/active-app/presets/session-auto-refresh.ts b/src/arts/active-app/presets/session-auto-refresh.ts new file mode 100644 index 0000000..18dce9c --- /dev/null +++ b/src/arts/active-app/presets/session-auto-refresh.ts @@ -0,0 +1,40 @@ +import { withAutoRefresh } from '$session'; +import type { + ActiveSession, + AutoRefreshCleanup, + AutoRefreshOptions +} from '$session/types'; +import type { ActiveAppCore } from '../types.ts'; + +/** + * Shape this preset requires from `App`. Only the `session` slot is + * needed; the core's `Timers` is consumed automatically so that the + * refresh ticker runs through `App.Timers` (one clock for the whole + * ecosystem) instead of the host `setInterval` fallback baked into + * `withAutoRefresh`. + */ +export interface SessionAutoRefreshApp + extends ActiveAppCore { + readonly session: ActiveSession; +} + +/** + * Wraps `App.session` with `withAutoRefresh` and pre-fills the + * `timers` and `now` injectors from the App's core. Apps that need + * deterministic refresh in tests / replay can still override + * `random` (or `now`, `timers`) through `options`. + * + * Returns the same cleanup function `withAutoRefresh` returns — + * idempotent, safe to call from a `disposeStarting` event handler + * or a Svelte teardown. + */ +export function applySessionAutoRefresh( + App: SessionAutoRefreshApp, + options: AutoRefreshOptions = {} +): AutoRefreshCleanup { + return withAutoRefresh(App.session, { + ...options, + timers: options.timers ?? App.Timers, + now: options.now ?? (() => App.Timers.clock.now()) + }); +} diff --git a/src/arts/active-app/test/session-auto-refresh.test.ts b/src/arts/active-app/test/session-auto-refresh.test.ts new file mode 100644 index 0000000..6e9905c --- /dev/null +++ b/src/arts/active-app/test/session-auto-refresh.test.ts @@ -0,0 +1,112 @@ +/** + * Tests for the `applySessionAutoRefresh` preset. The preset's job is + * to wire `App.Timers` and `App.Timers.clock` into `withAutoRefresh` + * so the refresh ticker runs through the App's single time source + * instead of falling back to `setInterval` + `Date.now`. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { applySessionAutoRefresh } from '../presets/index.ts'; +import { createEngineSession } from '$session'; +import { createActiveTimers, type ActiveTimers } from '$timer/active-timers.svelte'; +import { createSvelteEngineBus, type EngineBus } from '$bus'; +import { createEngineLogger, type EngineLogger } from '$logger'; +import { createEngineOrca, type EngineOrca } from '$orca'; +import type { ActiveSession, Session } from '$session/types'; + +interface User { + id: string; +} + +const NOW = 1_700_000_000_000; + +function alice(expiresAt: number): Session { + return { user: { id: 'u1' }, issuedAt: NOW, expiresAt }; +} + +interface Core { + Logger: EngineLogger; + Bus: EngineBus>; + Timers: ActiveTimers; + Orca: EngineOrca; + dispose: () => void; +} + +function buildCore(): Core { + const Logger = createEngineLogger({}); + const Timers = createActiveTimers({ logger: Logger }); + const Bus = createSvelteEngineBus>({ + logger: Logger, + clock: Timers.clock + }); + const Orca = createEngineOrca({ bus: Bus, timers: Timers, logger: Logger }); + return { + Logger, + Bus, + Timers, + Orca, + dispose() { + Orca.dispose(); + Bus.dispose(); + Timers.dispose(); + Logger.dispose(); + } + }; +} + +describe('applySessionAutoRefresh', () => { + let core: Core; + let session: ActiveSession; + let onRefresh: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + core = buildCore(); + onRefresh = vi.fn(async () => null); + session = createEngineSession({ onRefresh }) as unknown as ActiveSession; + }); + + afterEach(() => { + core.dispose(); + vi.useRealTimers(); + }); + + it('routes the refresh ticker through App.Timers (not setInterval)', async () => { + await session.adopt(alice(NOW + 60_000)); // 60s ahead, margin 90s + const stop = applySessionAutoRefresh( + { ...core, session }, + { + tickMs: 30_000, + marginMs: 90_000, + jitterMs: 0, + refreshOnVisible: false + } + ); + // Initial maybeRefresh runs synchronously inside startTicker; expiration < margin → refresh fires. + await vi.advanceTimersByTimeAsync(0); + expect(onRefresh).toHaveBeenCalled(); + stop(); + }); + + it('honors caller-provided `random` when jitter is enabled', async () => { + await session.adopt(alice(NOW + 100_000)); // 100s ahead + const customRandom = vi.fn(() => 0.0); + const stop = applySessionAutoRefresh( + { ...core, session }, + { + tickMs: 30_000, + marginMs: 90_000, + jitterMs: 30_000, + refreshOnVisible: false, + random: customRandom + } + ); + await vi.advanceTimersByTimeAsync(0); + // jitter=30_000, random=0 -> margin stays at 90_000; remaining=100_000 + // → not within margin → no refresh on the first tick. + expect(onRefresh).not.toHaveBeenCalled(); + expect(customRandom).toHaveBeenCalled(); + stop(); + }); +});