Audit P2: `withAutoRefresh` already accepted `timers`, `now` and
`random` injectors, but `defineActiveSession` could not wire them
because the auto-refresh wrapper is opt-in (the caller decides
when to start the ticker). Result: apps that built sessions
through `App` still fell back to `setInterval` + `Date.now` when
they enabled auto-refresh by hand.
New preset `$active-app/presets/session-auto-refresh.ts` closes
the loop:
- `applySessionAutoRefresh(App, opts?)` calls
`withAutoRefresh(App.session, { ...opts, timers: App.Timers,
now: () => App.Timers.clock.now() })`.
- Caller-provided `timers` / `now` / `random` still win.
- Returns the same idempotent cleanup `withAutoRefresh` returns.
Re-exports through `$active-app/presets`. Two regression tests
verify the preset routes through the App's clock and lets the
caller override `random` when jitter is enabled.
Other determinism in `arts/session` (engine clock for
`expiresAt`, broadcast channel) was already injectable; the
preset is the missing wiring piece for the App composition path.
Suite: 1512 / 1512.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
parent
1a92d280dc
commit
f82e174708
@ -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<TUser, TCredential = undefined, TData = undefined>
|
||||
extends ActiveAppCore {
|
||||
readonly session: ActiveSession<TUser, TCredential, TData>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TUser, TCredential = undefined, TData = undefined>(
|
||||
App: SessionAutoRefreshApp<TUser, TCredential, TData>,
|
||||
options: AutoRefreshOptions = {}
|
||||
): AutoRefreshCleanup {
|
||||
return withAutoRefresh(App.session, {
|
||||
...options,
|
||||
timers: options.timers ?? App.Timers,
|
||||
now: options.now ?? (() => App.Timers.clock.now())
|
||||
});
|
||||
}
|
||||
@ -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<User> {
|
||||
return { user: { id: 'u1' }, issuedAt: NOW, expiresAt };
|
||||
}
|
||||
|
||||
interface Core {
|
||||
Logger: EngineLogger;
|
||||
Bus: EngineBus<Record<string, unknown>>;
|
||||
Timers: ActiveTimers;
|
||||
Orca: EngineOrca;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
function buildCore(): Core {
|
||||
const Logger = createEngineLogger({});
|
||||
const Timers = createActiveTimers({ logger: Logger });
|
||||
const Bus = createSvelteEngineBus<Record<string, unknown>>({
|
||||
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<User>;
|
||||
let onRefresh: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
core = buildCore();
|
||||
onRefresh = vi.fn(async () => null);
|
||||
session = createEngineSession<User>({ onRefresh }) as unknown as ActiveSession<User>;
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue