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/src/arts/timer/DESIGN_TIMR.md

1532 lines
30 KiB

# `arts/timer` — Guía de diseño e implementación
eidos: pilot wrapper pattern + doctrinal API conventions Toggle as the eidos pilot: subdirectory layout (recipe + Svelte wrapper + types + index + README) replacing the flat CSS-only form. Pattern is documented in eidos/components/README.md and the toggle README. Shared types live in eidos/lib/types.ts. First export is `Size` (8 values xxs..xxl + full); components narrow with `Extract<Size, ...>` per the per-component-subset doctrine. No `Eidos` prefix on types — module path already conveys the layer. API doctrine: - soma stays compound (Toggle.Provider) for symmetry with multi-part - eidos exports both default + Provider so single-part components accept both `<Toggle>` (ergonomic) and `<Toggle.Provider>` (compound-style consumers) SoundChannel eager-init fixes the autoplay race: AudioContext is created + resumed synchronously on the first user gesture (capture-phase listener registered in the constructor), avoiding the previous race where the first emit() scheduled the resume in a microtask outside the gesture window. Demo page (web/routes/toggle/+page.svelte) restructured so the live preview renders ALWAYS above the tablist — Sema-tab Play buttons can fire on the real toggle. Motion preview amplifies scale ×8 visually only; doctrinal values stay in the <dl>. Conventions 7-13 added to src/docs/sema-implementation-guide.md covering: directory structure, wrapper composition, no Eidos prefix, soma compound vs eidos flat, iconOnly sr-only body, sound eager-init, docs-preview amplification. CLAUDE.md gets a session hand-off block listing where things stand and next concrete steps (migrate switch/collapsible/dialog/drawer/popover/ toast/avatar; wire topbar sound mute to masterGain; rename theme tokens to drop the success/warning/danger fallback aliases). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
> **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.
>
eidos: pilot wrapper pattern + doctrinal API conventions Toggle as the eidos pilot: subdirectory layout (recipe + Svelte wrapper + types + index + README) replacing the flat CSS-only form. Pattern is documented in eidos/components/README.md and the toggle README. Shared types live in eidos/lib/types.ts. First export is `Size` (8 values xxs..xxl + full); components narrow with `Extract<Size, ...>` per the per-component-subset doctrine. No `Eidos` prefix on types — module path already conveys the layer. API doctrine: - soma stays compound (Toggle.Provider) for symmetry with multi-part - eidos exports both default + Provider so single-part components accept both `<Toggle>` (ergonomic) and `<Toggle.Provider>` (compound-style consumers) SoundChannel eager-init fixes the autoplay race: AudioContext is created + resumed synchronously on the first user gesture (capture-phase listener registered in the constructor), avoiding the previous race where the first emit() scheduled the resume in a microtask outside the gesture window. Demo page (web/routes/toggle/+page.svelte) restructured so the live preview renders ALWAYS above the tablist — Sema-tab Play buttons can fire on the real toggle. Motion preview amplifies scale ×8 visually only; doctrinal values stay in the <dl>. Conventions 7-13 added to src/docs/sema-implementation-guide.md covering: directory structure, wrapper composition, no Eidos prefix, soma compound vs eidos flat, iconOnly sr-only body, sound eager-init, docs-preview amplification. CLAUDE.md gets a session hand-off block listing where things stand and next concrete steps (migrate switch/collapsible/dialog/drawer/popover/ toast/avatar; wire topbar sound mute to masterGain; rename theme tokens to drop the success/warning/danger fallback aliases). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
> **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/).
eidos: pilot wrapper pattern + doctrinal API conventions Toggle as the eidos pilot: subdirectory layout (recipe + Svelte wrapper + types + index + README) replacing the flat CSS-only form. Pattern is documented in eidos/components/README.md and the toggle README. Shared types live in eidos/lib/types.ts. First export is `Size` (8 values xxs..xxl + full); components narrow with `Extract<Size, ...>` per the per-component-subset doctrine. No `Eidos` prefix on types — module path already conveys the layer. API doctrine: - soma stays compound (Toggle.Provider) for symmetry with multi-part - eidos exports both default + Provider so single-part components accept both `<Toggle>` (ergonomic) and `<Toggle.Provider>` (compound-style consumers) SoundChannel eager-init fixes the autoplay race: AudioContext is created + resumed synchronously on the first user gesture (capture-phase listener registered in the constructor), avoiding the previous race where the first emit() scheduled the resume in a microtask outside the gesture window. Demo page (web/routes/toggle/+page.svelte) restructured so the live preview renders ALWAYS above the tablist — Sema-tab Play buttons can fire on the real toggle. Motion preview amplifies scale ×8 visually only; doctrinal values stay in the <dl>. Conventions 7-13 added to src/docs/sema-implementation-guide.md covering: directory structure, wrapper composition, no Eidos prefix, soma compound vs eidos flat, iconOnly sr-only body, sound eager-init, docs-preview amplification. CLAUDE.md gets a session hand-off block listing where things stand and next concrete steps (migrate switch/collapsible/dialog/drawer/popover/ toast/avatar; wire topbar sound mute to masterGain; rename theme tokens to drop the success/warning/danger fallback aliases). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
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/
eidos: pilot wrapper pattern + doctrinal API conventions Toggle as the eidos pilot: subdirectory layout (recipe + Svelte wrapper + types + index + README) replacing the flat CSS-only form. Pattern is documented in eidos/components/README.md and the toggle README. Shared types live in eidos/lib/types.ts. First export is `Size` (8 values xxs..xxl + full); components narrow with `Extract<Size, ...>` per the per-component-subset doctrine. No `Eidos` prefix on types — module path already conveys the layer. API doctrine: - soma stays compound (Toggle.Provider) for symmetry with multi-part - eidos exports both default + Provider so single-part components accept both `<Toggle>` (ergonomic) and `<Toggle.Provider>` (compound-style consumers) SoundChannel eager-init fixes the autoplay race: AudioContext is created + resumed synchronously on the first user gesture (capture-phase listener registered in the constructor), avoiding the previous race where the first emit() scheduled the resume in a microtask outside the gesture window. Demo page (web/routes/toggle/+page.svelte) restructured so the live preview renders ALWAYS above the tablist — Sema-tab Play buttons can fire on the real toggle. Motion preview amplifies scale ×8 visually only; doctrinal values stay in the <dl>. Conventions 7-13 added to src/docs/sema-implementation-guide.md covering: directory structure, wrapper composition, no Eidos prefix, soma compound vs eidos flat, iconOnly sr-only body, sound eager-init, docs-preview amplification. CLAUDE.md gets a session hand-off block listing where things stand and next concrete steps (migrate switch/collapsible/dialog/drawer/popover/ toast/avatar; wire topbar sound mute to masterGain; rename theme tokens to drop the success/warning/danger fallback aliases). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
```
Nombres públicos:
```ts
EngineTimers
ActiveTimers
TimerScheduler
TimerHandle
TimerEntrySnapshot
createEngineTimers
createActiveTimers
```
Evitar:
```txt
TimerManager
TimerService
SchedulerService
```
Estructura objetivo:
```txt
src/arts/timer/
eidos: pilot wrapper pattern + doctrinal API conventions Toggle as the eidos pilot: subdirectory layout (recipe + Svelte wrapper + types + index + README) replacing the flat CSS-only form. Pattern is documented in eidos/components/README.md and the toggle README. Shared types live in eidos/lib/types.ts. First export is `Size` (8 values xxs..xxl + full); components narrow with `Extract<Size, ...>` per the per-component-subset doctrine. No `Eidos` prefix on types — module path already conveys the layer. API doctrine: - soma stays compound (Toggle.Provider) for symmetry with multi-part - eidos exports both default + Provider so single-part components accept both `<Toggle>` (ergonomic) and `<Toggle.Provider>` (compound-style consumers) SoundChannel eager-init fixes the autoplay race: AudioContext is created + resumed synchronously on the first user gesture (capture-phase listener registered in the constructor), avoiding the previous race where the first emit() scheduled the resume in a microtask outside the gesture window. Demo page (web/routes/toggle/+page.svelte) restructured so the live preview renders ALWAYS above the tablist — Sema-tab Play buttons can fire on the real toggle. Motion preview amplifies scale ×8 visually only; doctrinal values stay in the <dl>. Conventions 7-13 added to src/docs/sema-implementation-guide.md covering: directory structure, wrapper composition, no Eidos prefix, soma compound vs eidos flat, iconOnly sr-only body, sound eager-init, docs-preview amplification. CLAUDE.md gets a session hand-off block listing where things stand and next concrete steps (migrate switch/collapsible/dialog/drawer/popover/ toast/avatar; wire topbar sound mute to masterGain; rename theme tokens to drop the success/warning/danger fallback aliases). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
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<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:
```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<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
```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<Record<string, unknown>>;
}
```
---
## 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<typeof globalThis.setTimeout>;
```
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<Record<string, unknown>>;
}
```
### 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<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.
```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<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:
```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.

Powered by TurnKey Linux.