# `arts/timer` — Guía de diseño e implementación > **Nota historica 2026-05-14.** Este documento conserva el vocabulario de la > fase de diseno (`timr`, `sess`, `conn`, `aapp`). El nombre canonico actual > del artefacto es `timer`; los consumidores usan `$timer` → `src/arts/timer`. > Las menciones abreviadas siguientes son contexto historico, no doctrina de > naming nueva. > > **Propósito.** Implementar una infraestructura zero-deps de timers para la App, reutilizable por `sess`, `conn`, futuro `cache` y futuro `authn`. > > **Decisión central.** Debe existir un scheduler singleton **por instancia de App/runtime**, no un singleton global de módulo. --- ## 0. Decisiones cerradas (preceden al resto del documento) Las siguientes reglas son normativas y tienen precedencia sobre cualquier sugerencia en el resto del documento que pudiera quedar ambigua: ```txt 0.1 Artifact path final actual: src/arts/timer/ (historico: src/arts/timr/). 0.2 AbortController es por entry, no por tick. 0.3 Intervalos reutilizan el signal de la entry; el signal NO se renueva entre ticks. Cancelar/reemplazar/dispose aborta para siempre. 0.4 replace:true cancela/aborta la entry anterior y crea una entry nueva con un AbortController nuevo (id distinto). 0.5 scheduleAt(dueAt) con dueAt < now() programa con delayMs = 0 y emite un debug log opcional. NO lanza error. 0.6 reschedule(delayMs) mantiene la misma entry e incrementa version; el AbortController se preserva. 0.7 cancel / replace / dispose / interval-next-tick invalidan callbacks viejos via la tupla guard (id, key, version). Ver §12.8. ``` --- ## 1. Problema Varios artifacts necesitan timers: ```txt sess: auto-refresh refresh margin transient refresh retry visibility recovery conn: reconnect backoff heartbeat ping/pong request/ack timeout buffer flush channel rejoin delay future cache: staleTime gcTime refetch interval future authn: OTP expiry cooldowns step-up timeout ``` Si cada artifact usa directamente `setTimeout`, `setInterval` y `clearTimeout`, acabaremos con: ```txt lógica duplicada leaks difíciles de rastrear tests lentos o frágiles código no determinista limpieza incompleta en dispose() ``` `arts/timr` centraliza: ```txt schedule interval cancel cancelAll dispose scoped keys fake-clock testing runtime observability ``` --- ## 2. Principios de diseño ### 2.1 Singleton por App, no global Correcto: ```ts const App = createActiveApp({ timers: {} }); App.timers.schedule(...); ``` Incorrecto: ```ts export const Timers = createActiveTimers(); ``` Por qué evitar singleton global: ```txt SSR isolation test isolation HMR cleanup multi-App support controlled dispose no cross-user contamination ``` ### 2.2 Engine primero, Active después ```txt EngineTimers TypeScript puro sin runes testeable seguro para importar server-side ActiveTimers wrapper reactivo Svelte usa $state expone entries/keys/scopes para debug/UI ``` ### 2.3 Otros artifacts dependen de una interfaz mínima `sess`, `conn`, `cache`, `authn` no deberían depender de `ActiveTimers`. Dependen de: ```ts TimerScheduler ``` Así pueden recibir: ```txt EngineTimers ActiveTimers FakeTimers en tests ``` ### 2.4 Timers con key Cada timer debe tener una key estable. Ejemplos: ```txt sess:auto-refresh conn:main:reconnect conn:main:heartbeat conn:main:ack:msg_123 cache:products:gc authn:otp:cooldown ``` Esto permite: ```ts timers.cancel('conn:main:reconnect'); timers.cancelAll('conn:main'); ``` ### 2.5 No cron, no jobs durables `arts/timr` es runtime-local. No garantiza ejecución si el navegador se cierra o si el proceso muere. No implementa: ```txt cron background jobs persistentes server queue cross-tab scheduling ejecución offline ``` --- ## 3. Nombre y estructura Artifact recomendado: ```txt src/arts/timer/ ``` Nombres públicos: ```ts EngineTimers ActiveTimers TimerScheduler TimerHandle TimerEntrySnapshot createEngineTimers createActiveTimers ``` Evitar: ```txt TimerManager TimerService SchedulerService ``` Estructura objetivo: ```txt src/arts/timer/ index.ts consts.ts types.ts errors.ts clock.ts backoff.ts engine-timers.ts active-timers.svelte.ts engine-timers.test.ts active-timers.test.ts backoff.test.ts ``` --- ## 4. Alcance obligatorio v1 La primera implementación debe incluir: ```txt EngineTimers ActiveTimers TimerScheduler interface keyed one-shot timers keyed intervals cancel by key cancel by scope/prefix dispose entry snapshots fake clock injection backoff helper tests App integration ``` No incluir en v1: ```txt cron persistencia cross-tab scheduling background jobs colas durables ``` --- ## 5. Constantes públicas ```ts export const TIMER_STATUS_PENDING = 'pending'; export const TIMER_STATUS_RUNNING = 'running'; export const TIMER_STATUS_CANCELLED = 'cancelled'; export const TIMER_STATUS_COMPLETED = 'completed'; export const TIMER_STATUS_FAILED = 'failed'; export const TIMER_KIND_TIMEOUT = 'timeout'; export const TIMER_KIND_INTERVAL = 'interval'; export const DEFAULT_TIMER_SCOPE_SEPARATOR = ':'; export const DEFAULT_BACKOFF_MIN_DELAY_MS = 500; export const DEFAULT_BACKOFF_MAX_DELAY_MS = 15_000; export const DEFAULT_BACKOFF_FACTOR = 1.8; export const DEFAULT_BACKOFF_JITTER_MS = 500; ``` --- ## 6. Tipos base ### 6.1 Estado y tipo de timer ```ts export type TimerStatus = | 'pending' | 'running' | 'cancelled' | 'completed' | 'failed'; export type TimerKind = | 'timeout' | 'interval'; ``` ### 6.2 Task ```ts export type TimerTask = ( ctx: TimerTaskContext ) => void | Promise; export interface TimerTaskContext { readonly key: string; readonly kind: TimerKind; readonly scheduledAt: number; readonly dueAt: number; readonly firedAt: number; readonly driftMs: number; readonly runCount: number; readonly signal: AbortSignal; } ``` Notas: ```txt driftMs = firedAt - dueAt runCount empieza en 1 signal se aborta al cancelar/dispose ``` ### 6.3 Opciones ```ts export interface TimerOptions { /** Reemplaza un timer existente con la misma key. */ readonly replace?: boolean; /** Default true para timeout. */ readonly removeOnComplete?: boolean; /** Debug/devtools only. */ readonly meta?: Readonly>; } export interface TimerIntervalOptions extends TimerOptions { /** * Si true, la siguiente iteración se agenda después de terminar la task. * Default true. */ readonly awaitTask?: boolean; /** Undefined = infinito hasta cancelación. */ readonly maxRuns?: number; } ``` ### 6.4 Handle ```ts export interface TimerHandle { readonly key: string; readonly active: boolean; cancel(): boolean; reschedule(delayMs: number): void; } ``` Reglas: ```txt active = false después de cancel/complete/dispose reschedule() solo funciona si active reschedule() en timer inactivo lanza TimerInactiveError ``` ### 6.5 Snapshot ```ts export interface TimerEntrySnapshot { readonly key: string; readonly scope: string; readonly kind: TimerKind; readonly status: TimerStatus; readonly scheduledAt: number; readonly dueAt: number; readonly delayMs: number; readonly runCount: number; readonly lastFiredAt: number | null; readonly lastCompletedAt: number | null; readonly lastErrorAt: number | null; readonly error: unknown | null; readonly meta?: Readonly>; } ``` --- ## 7. Clock abstraction Para testabilidad, no acoplar directamente a `globalThis.setTimeout`. ```ts export interface TimerClock { now(): number; setTimeout( fn: () => void, delayMs: number ): TimerNativeHandle; clearTimeout(handle: TimerNativeHandle): void; } export type TimerNativeHandle = ReturnType; ``` Default: ```ts export function createSystemTimerClock(): TimerClock; ``` Tests pueden inyectar fake clock: ```ts const timers = createEngineTimers({ clock: fakeClock }); ``` --- ## 8. Interfaces principales ### 8.1 TimerScheduler Interfaz mínima que consumirán otros artifacts. ```ts export interface TimerScheduler { readonly size: number; schedule( key: string, delayMs: number, task: TimerTask, options?: TimerOptions ): TimerHandle; scheduleAt( key: string, dueAt: number, task: TimerTask, options?: TimerOptions ): TimerHandle; interval( key: string, everyMs: number, task: TimerTask, options?: TimerIntervalOptions ): TimerHandle; cancel(key: string): boolean; cancelAll(scope?: string): number; has(key: string): boolean; dispose(): void; } ``` ### 8.2 EngineTimers ```ts export interface EngineTimers extends TimerScheduler { readonly disposed: boolean; keys(): readonly string[]; entries(): readonly TimerEntrySnapshot[]; entry(key: string): TimerEntrySnapshot | null; onChange(listener: (event: TimerEvent) => void): () => void; } ``` ### 8.3 ActiveTimers ```ts export interface ActiveTimers extends EngineTimers { readonly size: number; readonly keysSnapshot: readonly string[]; readonly entriesSnapshot: readonly TimerEntrySnapshot[]; readonly scopes: readonly string[]; } ``` `ActiveTimers` sirve para observabilidad/debug/UI. Los artifacts pueden recibirlo como `TimerScheduler`. --- ## 9. Eventos ```ts export type TimerEvent = | { readonly type: 'scheduled'; readonly entry: TimerEntrySnapshot; } | { readonly type: 'cancelled'; readonly entry: TimerEntrySnapshot; } | { readonly type: 'running'; readonly entry: TimerEntrySnapshot; } | { readonly type: 'completed'; readonly entry: TimerEntrySnapshot; } | { readonly type: 'failed'; readonly entry: TimerEntrySnapshot; readonly error: unknown; } | { readonly type: 'disposed'; }; ``` --- ## 10. Errores Errores custom para errores de programación: ```ts export class TimrError extends Error {} export class TimerDisposedError extends TimrError {} export class TimerInvalidKeyError extends TimrError {} export class TimerDuplicateKeyError extends TimrError {} export class TimerInvalidDelayError extends TimrError {} export class TimerInactiveError extends TimrError {} ``` Fallos de tasks en runtime se reportan con eventos `failed`, no necesariamente como throws hacia fuera. --- ## 11. Keys y scopes Una key es string no vacío. Ejemplos válidos: ```txt sess:auto-refresh conn:main:heartbeat conn:main:ack:abc cache:products:gc ``` Scope por defecto: ```ts scopeOf('conn:main:ack:abc') === 'conn:main:ack' scopeOf('conn:main') === 'conn' scopeOf('sess:auto-refresh') === 'sess' ``` `cancelAll(scope)` usa prefijo: ```ts cancelAll('conn:main') ``` cancela: ```txt conn:main:reconnect conn:main:heartbeat conn:main:ack:msg_1 ``` pero no: ```txt conn:market:heartbeat ``` Helper: ```ts function isInScope(key: string, scope: string): boolean { return key === scope || key.startsWith(`${scope}:`); } ``` --- ## 12. Implementación interna ### 12.1 Entry interna ```ts interface InternalTimerEntry { /** Unique per entry. New replace creates a new id even if key is reused. */ id: number; key: string; /** Incremented whenever this entry future execution is invalidated. */ version: number; kind: TimerKind; status: TimerStatus; task: TimerTask; controller: AbortController; native: TimerNativeHandle | null; scheduledAt: number; dueAt: number; delayMs: number; intervalMs: number | null; awaitTask: boolean; maxRuns: number | undefined; runCount: number; lastFiredAt: number | null; lastCompletedAt: number | null; lastErrorAt: number | null; error: unknown | null; removeOnComplete: boolean; meta?: Readonly>; } ``` ### 12.2 Factory ```ts export interface EngineTimersOptions { readonly clock?: TimerClock; readonly logger?: Logger; } export function createEngineTimers( options?: EngineTimersOptions ): EngineTimers; ``` ### 12.3 Algoritmo de `schedule` 1. Verificar que no está disposed. 2. Validar key. 3. Validar `delayMs`: finito y `>= 0`. 4. Si key existe: - `replace: true` => cancelar anterior. - si no, lanzar `TimerDuplicateKeyError`. 5. Crear entry. 6. Crear `AbortController`. 7. Crear native timeout. 8. Guardar entry en `Map`. 9. Emitir `scheduled`. 10. Devolver `TimerHandle`. ### 12.4 Ejecución de timeout Cuando el timeout dispara: 1. Si la entry ya no existe o está cancelada, salir. 2. `status = 'running'`. 3. Set `lastFiredAt`. 4. Incrementar `runCount`. 5. Emitir `running`. 6. Construir `TimerTaskContext`. 7. Ejecutar task y esperar si devuelve promise. 8. Si ok: - `status = 'completed'` - set `lastCompletedAt` - emitir `completed` - eliminar si `removeOnComplete !== false` 9. Si error: - `status = 'failed'` - set `lastErrorAt` - guardar error - emitir `failed` - eliminar si `removeOnComplete !== false` No reintentar automáticamente. ### 12.5 Intervalos Implementar intervalos con `setTimeout` recursivo, no con `setInterval` nativo. Motivo: ```txt evita solapamiento de ejecuciones async permite awaitTask facilita drift/runCount/maxRuns ``` Default: ```ts awaitTask = true ``` #### 12.5.1 Semántica de `awaitTask: false` `awaitTask: false` produce una **cadencia fire-and-forget**: ```txt - The next tick is scheduled BEFORE the current task settles. - Task failures are emitted as `failed` events but do NOT stop the interval. The next tick fires regardless. - This mode is intended for fire-and-forget cadence (e.g. heartbeat pings where each tick is independent and a missed reply doesn't block the next ping). - Use `awaitTask: true` when task completion or error should control whether the next tick fires. ``` If a consumer needs the interval to stop on failure, they should: ```ts Timers.interval('foo', everyMs, async (ctx) => { try { await doWork(); } catch (err) { // explicit stop — awaitTask:false won't auto-stop on throw ctx.signal.throwIfAborted(); throw err; } }, { awaitTask: true }); ``` …or cancel explicitly from inside the task: ```ts Timers.interval('bar', everyMs, async () => { const ok = await tryWork(); if (!ok) Timers.cancel('bar'); }); ``` This semantic is **frozen** — see the dedicated test `awaitTask:false continues ticking after task failure` in `engine-timers.test.ts`. ### 12.6 Cancelación `cancel(key)`: 1. Buscar entry. 2. Si no existe, devolver false. 3. Abort controller. 4. Clear native timeout. 5. Marcar `cancelled`. 6. Eliminar del map. 7. Emitir `cancelled`. 8. Devolver true. `cancelAll(scope?)`: - Sin scope: cancela todo. - Con scope: cancela keys que matchean prefijo. - Devuelve número de timers cancelados. ### 12.7 Dispose `dispose()`: 1. Si ya está disposed, no-op. 2. Cancelar todos los timers. 3. Emitir `disposed`. 4. Limpiar listeners. 5. Marcar disposed. Después de dispose: ```ts schedule(...) interval(...) ``` lanzan `TimerDisposedError`. ### 12.8 Seguridad de renovación y concurrencia La implementación MUST ser segura cuando `cancel`, `replace`, `reschedule`, `dispose` o una ejecución async coinciden en el tiempo. Aunque JavaScript ejecute en un único hilo, un callback nativo puede estar ya encolado en el event loop cuando se llama a `clearTimeout`. Además, una task async puede terminar después de que el timer haya sido reemplazado por otro con la misma key. Por tanto, `clearTimeout` y `AbortSignal` no son suficientes. Regla normativa: > Toda ejecución de timer está protegida por la tupla `(entryId, key, version)`. Cada callback nativo captura esos tres valores cuando se programa. Antes de ejecutar la task, y también después de que la task termine, el engine debe comprobar que la entry registrada sigue siendo exactamente la misma. #### 12.8.1 Identidad y versión Cada entry interna MUST tener: ```ts interface InternalTimerEntry { /** Unique per entry. New `replace` creates a new id even if key is reused. */ id: number; /** Stable user-facing key. */ key: string; /** Incremented every time this entry's future execution is invalidated. */ version: number; // ...rest of entry } ``` Semántica: - `id` identifica una entry concreta. - `key` identifica el timer desde la API pública. - `version` invalida callbacks viejos de la misma entry. - `replace: true` crea una nueva entry con un nuevo `id`. - `reschedule()` mantiene el mismo `id`, pero incrementa `version`. - `cancel()` incrementa `version`, aborta la señal, limpia el native timeout y elimina la entry. - `dispose()` invalida todas las entries. #### 12.8.2 Programación segura Al programar: ```ts function armEntry(entry: InternalTimerEntry, delayMs: number): void { entry.version += 1; const capturedId = entry.id; const capturedKey = entry.key; const capturedVersion = entry.version; entry.native = clock.setTimeout(() => { void runEntry(capturedId, capturedKey, capturedVersion); }, delayMs); } ``` #### 12.8.3 Guard antes de ejecutar ```ts function getLiveEntry( id: number, key: string, version: number ): InternalTimerEntry | null { const current = entries.get(key); if (!current) return null; if (current.id !== id) return null; if (current.version !== version) return null; if (current.controller.signal.aborted) return null; return current; } ``` `runEntry` debe empezar así: ```ts async function runEntry( id: number, key: string, version: number ): Promise { const entry = getLiveEntry(id, key, version); if (!entry) return; if (entry.status !== 'pending') return; entry.status = 'running'; // ... ejecutar task } ``` #### 12.8.4 Guard después de ejecutar Después de `await task(ctx)`, el engine debe volver a comprobar la misma tupla antes de mutar estado, emitir `completed`/`failed`, eliminar la entry o programar el siguiente tick de un intervalo. ```ts try { await entry.task(ctx); const latest = getLiveEntry(id, key, version); if (!latest) return; completeEntry(latest); } catch (error) { const latest = getLiveEntry(id, key, version); if (!latest) return; failEntry(latest, error); } ``` Esto evita que una ejecución vieja pueda cerrar, fallar, eliminar o reprogramar una entry nueva con la misma key. #### 12.8.5 `replace: true` `replace: true` debe cancelar la entry previa y crear una nueva entry. ```ts function replaceTimer(key: string, config: NewTimerConfig): TimerHandle { const previous = entries.get(key); if (previous) { cancelEntry(previous, 'replaced'); } return createEntry(key, config); } ``` La nueva entry debe tener un `id` nuevo. Si un callback viejo se ejecuta tarde, `getLiveEntry(oldId, key, oldVersion)` devolverá `null`. #### 12.8.6 `reschedule()` `reschedule(delayMs)` mantiene la misma entry pero invalida cualquier callback pendiente incrementando `version`. ```ts function rescheduleEntry(entry: InternalTimerEntry, delayMs: number): void { if (entry.status === 'running') { throw new TimerInactiveError(entry.key); } validateDelay(delayMs); if (entry.native !== null) { clock.clearTimeout(entry.native); entry.native = null; } entry.status = 'pending'; entry.scheduledAt = clock.now(); entry.delayMs = delayMs; entry.dueAt = entry.scheduledAt + delayMs; armEntry(entry, delayMs); emitScheduled(entry); } ``` For v1, `reschedule()` MUST NOT be allowed while the entry is `running`. Use `schedule(key, delay, task, { replace: true })` if running work should be superseded. #### 12.8.7 Intervalos seguros Intervals MUST be implemented with recursive `setTimeout`, never native `setInterval`. After each interval task completes: 1. Re-check `(id, key, version)`. 2. Check `signal.aborted`. 3. Check `maxRuns`. 4. Set status back to `pending`. 5. Increment `version` for the next native callback. 6. Arm the next timeout. A cancelled or replaced interval must never schedule a new tick after its current task completes. #### 12.8.8 AbortSignal Every task receives `ctx.signal`. Cancelling/replacing/disposing aborts it. ```ts Timers.schedule('example:fetch', 1_000, async ({ signal }) => { await fetch('/api', { signal }); }); ``` Important: > `AbortSignal` is cooperative. It helps cancel work that supports aborting, but it does not replace the `(entryId, key, version)` guard. #### 12.8.9 Operations that invalidate callbacks The following operations MUST invalidate old callbacks: ```txt cancel replace reschedule dispose interval next tick ``` Implementation rule: ```txt Any operation that changes the future execution of an entry increments `version` or replaces the entry id. ``` #### 12.8.10 Race scenarios that must be safe The implementation must remain correct when these happen in the same event-loop window: ```txt timeout fires while cancel() is called timeout fires while replace:true is called timeout fires while reschedule() is called async task completes after cancel() async task completes after replace:true interval task completes after dispose() ack timeout fires after reply already arrived reconnect timer fires after manual disconnect ``` In all cases, stale executions must become no-ops. --- ## 13. ActiveTimers `active-timers.svelte.ts` envuelve `EngineTimers`. Patrón recomendado: ```ts export function createActiveTimers(options?: EngineTimersOptions): ActiveTimers { const engine = createEngineTimers(options); let entries = $state(engine.entries()); const off = engine.onChange(() => { entries = engine.entries(); }); return { ...engine, get size() { return entries.length; }, get keysSnapshot() { return entries.map((entry) => entry.key); }, get entriesSnapshot() { return entries; }, get scopes() { return Array.from(new Set(entries.map((entry) => entry.scope))); }, dispose() { off(); engine.dispose(); } }; } ``` Importante: ```txt No usar $effect para sincronizar timers. Usar engine.onChange. Esto evita effect_update_depth_exceeded. ``` --- ## 14. Backoff helper ```ts export interface BackoffOptions { readonly minDelayMs?: number; readonly maxDelayMs?: number; readonly factor?: number; readonly jitterMs?: number; } export function computeBackoffDelay( attempt: number, options?: BackoffOptions, random?: () => number ): number; ``` Reglas: ```txt attempt empieza en 0 random inyectable para tests clamp a maxDelayMs nunca devolver negativo ``` Algoritmo: ```ts const base = Math.min(maxDelayMs, minDelayMs * factor ** attempt); const jitter = randomBetween(-jitterMs, jitterMs); return clamp(0, maxDelayMs, base + jitter); ``` Este helper solo calcula. No agenda timers. --- ## 15. Integración con App ### 15.1 Opciones ```ts export interface ActiveAppTimersOptions { readonly enabled?: boolean; } ``` En la práctica `App.timers` debería estar siempre presente. ```ts const App = createActiveApp({ timers: {} }); App.timers.schedule(...); ``` Si no se configuran timers: ```ts App.timers = createActiveTimers(); ``` ### 15.2 Inyección a otros artifacts Artifacts deben aceptar: ```ts timers?: TimerScheduler; ``` Cuando se crean desde App: ```ts timers: App.timers ``` No deben importar un scheduler global. --- ## 16. Integración con `sess` `withAutoRefresh` debería aceptar timers: ```ts const stop = withAutoRefresh(Sess, { timers: App.timers, tickMs, marginMs, jitterMs }); ``` Keys sugeridas: ```txt sess:auto-refresh sess:refresh-retry ``` Cleanup: ```ts timers.cancelAll('sess'); ``` Si en el futuro hay varias sesiones: ```txt sess:main:auto-refresh ``` --- ## 17. Integración con `conn` Keys sugeridas: ```txt conn:{name}:reconnect conn:{name}:heartbeat conn:{name}:heartbeat-timeout conn:{name}:ack:{messageId} conn:{name}:channel:{channel}:rejoin ``` Reconnect: ```ts timers.schedule(`conn:${name}:reconnect`, delay, () => { return connection.reconnect('scheduled'); }); ``` Heartbeat: ```ts timers.interval(`conn:${name}:heartbeat`, intervalMs, () => { return connection.send('connection.ping', {}); }); ``` Ack timeout: ```ts timers.schedule(`conn:${name}:ack:${id}`, timeoutMs, () => { resolveAckTimeout(id); }); ``` Al recibir reply: ```ts timers.cancel(`conn:${name}:ack:${id}`); ``` Al hacer dispose de la conexión: ```ts timers.cancelAll(`conn:${name}`); ``` --- ## 18. Integración futura con `cache` Keys posibles: ```txt cache:{queryKey}:stale cache:{queryKey}:gc cache:{queryKey}:refetch ``` `TimerQueue` permitirá `staleTime`, `gcTime`, `refetchInterval` sin duplicación. --- ## 19. Integración futura con `authn` Keys posibles: ```txt authn:otp:expiry authn:otp:cooldown authn:step-up:expiry authn:login-attempt:cooldown ``` Esto es solo runtime/UI. Las decisiones de seguridad deben seguir viviendo en servidor. --- ## 20. SSR `EngineTimers` debe ser seguro de importar server-side. Reglas: ```txt crear EngineTimers es seguro agendar timers durante SSR debe evitarse salvo intención explícita App client runtime puede crear ActiveTimers Apps request-scoped server deben hacer dispose si agendan algo no hay singleton global de módulo ``` --- ## 21. Testing ### 21.1 Fake clock Los tests no deben esperar tiempo real. Mínimo fake clock: ```ts const clock = createFakeTimerClock(); clock.now(); clock.advanceBy(ms); clock.advanceTo(ms); clock.runAll(); ``` Si no se implementa como API pública, puede vivir en tests. ### 21.2 Tests de EngineTimers ```txt schedule ejecuta task scheduleAt ejecuta en dueAt duplicate key lanza por defecto replace cancela anterior y agenda nuevo cancel evita ejecución cancel devuelve false en missing key cancelAll cancela todo cancelAll(scope) cancela solo scope dispose cancela todo schedule después de dispose lanza task recibe context driftMs se calcula async task emite completed task error emite failed interval repite interval maxRuns se detiene interval cancel se detiene handle.reschedule cambia dueAt stale callback after reschedule is ignored stale callback after replace:true is ignored async task completion after cancel is ignored async task completion after replace:true cannot mutate new entry interval does not schedule next tick after cancel while running ack-style timeout can be cancelled safely before firing entries actualizan snapshots onChange emite scheduled/running/completed/cancelled/failed ``` ### 21.3 Tests de backoff ```txt attempt 0 usa minDelay + jitter crece por factor clamp a maxDelay random inyectado es determinista jitter negativo no produce delay negativo ``` ### 21.4 Tests de ActiveTimers ```txt size reactivo tras schedule size reactivo tras cancel entriesSnapshot actualiza keysSnapshot actualiza dispose limpia listener no usa $effect para sincronizar ``` --- ## 22. Orden de implementación para Claude Code 1. `consts.ts` 2. `types.ts` 3. `errors.ts` 4. `clock.ts` 5. `backoff.ts` 6. `engine-timers.ts` 7. `engine-timers.test.ts` 8. `active-timers.svelte.ts` 9. `active-timers.test.ts` 10. integración con `aapp` 11. actualizar `sess` auto-refresh para aceptar `timers` 12. actualizar diseño de `conn` para requerir `timers` 13. documentación de ejemplos No empezar integrando todos los artifacts. Primero hacer `arts/timr` sólido y testeado. --- ## 23. Ejemplos ### 23.1 Schedule básico ```ts const Timers = createEngineTimers(); Timers.schedule('demo:hello', 1_000, () => { console.log('hello'); }); ``` ### 23.2 Debounce por replace ```ts Timers.schedule('search:debounce', 300, runSearch, { replace: true }); ``` ### 23.3 Intervalo ```ts Timers.interval('conn:main:heartbeat', 25_000, () => { return Main.send('connection.ping', {}); }); ``` ### 23.4 Limpieza por scope ```ts Timers.cancelAll('conn:main'); ``` ### 23.5 App singleton ```ts const App = createActiveApp({ timers: {} }); App.timers.schedule('sess:auto-refresh', 30_000, () => { return Sess.refresh(); }); ``` --- ## 24. Pitfalls ### 24.1 No usar `$effect` para conducir timers Evitar: ```ts $effect(() => { if (Sess.current) { timers.schedule(...); } }); ``` Preferir: ```ts Sess.onChange(() => { timers.schedule(..., { replace: true }); }); ``` ### 24.2 No crear singleton global Evitar: ```ts export const Timers = createActiveTimers(); ``` ### 24.3 No dejar raw timers colgados Cada artifact debe limpiar su scope: ```ts timers.cancelAll(`conn:${name}`); ``` ### 24.4 No prometer trabajo futuro Los timers son scheduling local de runtime. No son workers persistentes. --- ## 25. Criterios de aceptación La implementación es aceptable si: ```txt EngineTimers pasa tests de timing/cancel/dispose ActiveTimers expone entries reactivas sin loops App expone una instancia Timers por instancia App sess/conn reciben TimerScheduler sin importar ActiveTimers no existe singleton global de módulo no se usan raw setTimeout para lifecycle timers tras integración las keys son scoped y se limpian en dispose backoff es determinista en tests ``` --- ## 26. Regla final de arquitectura `arts/timr` es infraestructura compartida de scheduling runtime. No sabe nada de sesiones, conexiones, cache, usuarios, auth, UI o dominio. Los demás artifacts expresan sus necesidades temporales mediante keys scoped. ```txt App.timers ├── sess:auto-refresh ├── conn:main:reconnect ├── conn:main:heartbeat ├── conn:main:ack:msg_123 ├── cache:products:gc └── authn:otp:cooldown ``` La regla: > Un scheduler por App runtime. > Engine API para artifacts. > Active wrapper para observabilidad. > Nunca singleton global.