You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/docs/decisions/design-timer.md

29 KiB

title type audience authority status source
arts/timer — guía de diseño e implementación (histórico) decision-log human + agent historical design record — the deterministic timer scheduler (clock injection, race-safety §12.8, one-shots, intervals, backoff); superseded by src/arts/timer/README.md as the current reference. Kept in Spanish. historical moved from src/arts/timer/DESIGN_TIMR.md (2026-07-03, arts-docs-reconciliation B2; kept verbatim in Spanish)

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:

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:

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:

lógica duplicada
leaks difíciles de rastrear
tests lentos o frágiles
código no determinista
limpieza incompleta en dispose()

arts/timr centraliza:

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:

const App = createActiveApp({
    timers: {}
});

App.timers.schedule(...);

Incorrecto:

export const Timers = createActiveTimers();

Por qué evitar singleton global:

SSR isolation
test isolation
HMR cleanup
multi-App support
controlled dispose
no cross-user contamination

2.2 Engine primero, Active después

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:

TimerScheduler;

Así pueden recibir:

EngineTimers
ActiveTimers
FakeTimers en tests

2.4 Timers con key

Cada timer debe tener una key estable.

Ejemplos:

sess:auto-refresh
conn:main:reconnect
conn:main:heartbeat
conn:main:ack:msg_123
cache:products:gc
authn:otp:cooldown

Esto permite:

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:

cron
background jobs persistentes
server queue
cross-tab scheduling
ejecución offline

3. Nombre y estructura

Artifact recomendado:

src/arts/timer/

Nombres públicos:

EngineTimers;
ActiveTimers;
TimerScheduler;
TimerHandle;
TimerEntrySnapshot;
createEngineTimers;
createActiveTimers;

Evitar:

TimerManager
TimerService
SchedulerService

Estructura objetivo:

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:

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:

cron
persistencia
cross-tab scheduling
background jobs
colas durables

5. Constantes públicas

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

export type TimerStatus = 'pending' | 'running' | 'cancelled' | 'completed' | 'failed';

export type TimerKind = 'timeout' | 'interval';

6.2 Task

export type TimerTask = (ctx: TimerTaskContext) => void | Promise<void>;

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:

driftMs = firedAt - dueAt
runCount empieza en 1
signal se aborta al cancelar/dispose

6.3 Opciones

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<Record<string, unknown>>;
}

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

export interface TimerHandle {
	readonly key: string;
	readonly active: boolean;

	cancel(): boolean;

	reschedule(delayMs: number): void;
}

Reglas:

active = false después de cancel/complete/dispose
reschedule() solo funciona si active
reschedule() en timer inactivo lanza TimerInactiveError

6.5 Snapshot

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<Record<string, unknown>>;
}

7. Clock abstraction

Para testabilidad, no acoplar directamente a globalThis.setTimeout.

export interface TimerClock {
	now(): number;

	setTimeout(fn: () => void, delayMs: number): TimerNativeHandle;

	clearTimeout(handle: TimerNativeHandle): void;
}

export type TimerNativeHandle = ReturnType<typeof globalThis.setTimeout>;

Default:

export function createSystemTimerClock(): TimerClock;

Tests pueden inyectar fake clock:

const timers = createEngineTimers({ clock: fakeClock });

8. Interfaces principales

8.1 TimerScheduler

Interfaz mínima que consumirán otros artifacts.

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

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

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

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:

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:

sess:auto-refresh
conn:main:heartbeat
conn:main:ack:abc
cache:products:gc

Scope por defecto:

scopeOf('conn:main:ack:abc') === 'conn:main:ack';
scopeOf('conn:main') === 'conn';
scopeOf('sess:auto-refresh') === 'sess';

cancelAll(scope) usa prefijo:

cancelAll('conn:main');

cancela:

conn:main:reconnect
conn:main:heartbeat
conn:main:ack:msg_1

pero no:

conn:market:heartbeat

Helper:

function isInScope(key: string, scope: string): boolean {
	return key === scope || key.startsWith(`${scope}:`);
}

12. Implementación interna

12.1 Entry interna

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<Record<string, unknown>>;
}

12.2 Factory

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:

evita solapamiento de ejecuciones async
permite awaitTask
facilita drift/runCount/maxRuns

Default:

awaitTask = true;

12.5.1 Semántica de awaitTask: false

awaitTask: false produce una cadencia fire-and-forget:

- 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:

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:

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:

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:

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:

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

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í:

async function runEntry(id: number, key: string, version: number): Promise<void> {
	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.

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.

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.

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.

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:

cancel
replace
reschedule
dispose
interval next tick

Implementation rule:

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:

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:

export function createActiveTimers(options?: EngineTimersOptions): ActiveTimers {
	const engine = createEngineTimers(options);

	let entries = $state<readonly TimerEntrySnapshot[]>(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:

No usar $effect para sincronizar timers.
Usar engine.onChange.
Esto evita effect_update_depth_exceeded.

14. Backoff helper

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:

attempt empieza en 0
random inyectable para tests
clamp a maxDelayMs
nunca devolver negativo

Algoritmo:

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

export interface ActiveAppTimersOptions {
	readonly enabled?: boolean;
}

En la práctica App.timers debería estar siempre presente.

const App = createActiveApp({
    timers: {}
});

App.timers.schedule(...);

Si no se configuran timers:

App.timers = createActiveTimers();

15.2 Inyección a otros artifacts

Artifacts deben aceptar:

timers?: TimerScheduler;

Cuando se crean desde App:

timers: App.timers;

No deben importar un scheduler global.


16. Integración con sess

withAutoRefresh debería aceptar timers:

const stop = withAutoRefresh(Sess, {
	timers: App.timers,
	tickMs,
	marginMs,
	jitterMs
});

Keys sugeridas:

sess:auto-refresh
sess:refresh-retry

Cleanup:

timers.cancelAll('sess');

Si en el futuro hay varias sesiones:

sess:main:auto-refresh

17. Integración con conn

Keys sugeridas:

conn:{name}:reconnect
conn:{name}:heartbeat
conn:{name}:heartbeat-timeout
conn:{name}:ack:{messageId}
conn:{name}:channel:{channel}:rejoin

Reconnect:

timers.schedule(`conn:${name}:reconnect`, delay, () => {
	return connection.reconnect('scheduled');
});

Heartbeat:

timers.interval(`conn:${name}:heartbeat`, intervalMs, () => {
	return connection.send('connection.ping', {});
});

Ack timeout:

timers.schedule(`conn:${name}:ack:${id}`, timeoutMs, () => {
	resolveAckTimeout(id);
});

Al recibir reply:

timers.cancel(`conn:${name}:ack:${id}`);

Al hacer dispose de la conexión:

timers.cancelAll(`conn:${name}`);

18. Integración futura con cache

Keys posibles:

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:

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:

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:

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

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

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

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

const Timers = createEngineTimers();

Timers.schedule('demo:hello', 1_000, () => {
	console.log('hello');
});

23.2 Debounce por replace

Timers.schedule('search:debounce', 300, runSearch, {
	replace: true
});

23.3 Intervalo

Timers.interval('conn:main:heartbeat', 25_000, () => {
	return Main.send('connection.ping', {});
});

23.4 Limpieza por scope

Timers.cancelAll('conn:main');

23.5 App singleton

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:

$effect(() => {
    if (Sess.current) {
        timers.schedule(...);
    }
});

Preferir:

Sess.onChange(() => {
    timers.schedule(..., { replace: true });
});

24.2 No crear singleton global

Evitar:

export const Timers = createActiveTimers();

24.3 No dejar raw timers colgados

Cada artifact debe limpiar su scope:

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:

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.

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.

Powered by TurnKey Linux.