Bloque C3 — `applySessionAutoRefresh` preset wires App.Timers

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
dev 5 months ago
parent 1a92d280dc
commit f82e174708

@ -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';

@ -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…
Cancel
Save

Powered by TurnKey Linux.