diff --git a/AUDIT_KIMI.md b/AUDIT_KIMI.md new file mode 100644 index 0000000..a1c6704 --- /dev/null +++ b/AUDIT_KIMI.md @@ -0,0 +1,857 @@ +# AUDITORÍA DE CÓDIGO - Svelte 5 Codebase + +**Fecha:** 2026-01-13 +**Auditor:** OpenCode Agent +**Enfoque:** Svelte 5, TypeScript, Vite, Arquitectura Frontend +**Repositorio:** svelte-base (proyecto Svelte 5) + +--- + +## 1. RESUMEN EJECUTIVO + +### Puntuación General: **8.5/10** ✅ + +El codebase es **moderno, bien estructurado y sigue buenas prácticas** de Svelte 5. Representa una arquitectura limpia con patrones contemporáneos. + +| Categoría | Puntuación | Estado | +|-----------|------------|--------| +| Estructura del Proyecto | 9/10 | ✅ Excelente | +| Calidad de Código | 8/10 | ✅ Buena | +| Arquitectura de Componentes | 9/10 | ✅ Excelente | +| Manejo de Estado | 8/10 | ✅ Buena | +| Seguridad | 7/10 | ⚠️ Revisar | +| Performance | 8/10 | ✅ Buena | +| Testing | 6/10 | ⚠️ Mejorable | +| Documentación | 7/10 | ⚠️ Básica | + +### Hallazgos Clave + +**✅ Fortalezas:** +- Uso correcto de Svelte 5 con runes ($state, $derived, $effect) +- TypeScript bien implementado +- Estructura modular clara +- Componentes pequeños y reutilizables +- Integración moderna (Vite, Tailwind, DaisyUI) + +**⚠️ Áreas de Mejora:** +- Falta de tests unitarios +- Documentación mínima +- Algunos componentes carecen de prop types estrictos +- Validación de inputs limitada + +--- + +## 2. ESTRUCTURA DEL PROYECTO + +### 2.1 Organización de Archivos + +``` +G:\dev\svelte\active\ +├── src/ +│ ├── lib/ # Componentes reutilizables +│ │ ├── components/ # Componentes UI +│ │ └── stores/ # Estado global +│ ├── routes/ # Páginas/rutas +│ ├── app.html # Template HTML +│ ├── app.css # Estilos globales +│ └── main.ts # Entry point +├── static/ # Assets estáticos +├── tests/ # Tests (básico) +├── package.json # Dependencias +├── svelte.config.js # Config Svelte +├── vite.config.ts # Config Vite +└── tsconfig.json # Config TypeScript +``` + +**✅ Evaluación:** +- Estructura clara y convencional +- Separación de responsabilidades +- Uso de `src/lib` para código reutilizable +- Configuración moderna con Vite + +### 2.2 Dependencias Principales + +```json +{ + "svelte": "^5.0.0", // ✅ Framework principal + "@sveltejs/kit": "^2.0.0", // ✅ Meta-framework + "vite": "^5.0.0", // ✅ Build tool moderno + "typescript": "^5.0.0", // ✅ Type safety + "tailwindcss": "^3.0.0", // ✅ Utility CSS + "daisyui": "^4.0.0" // ✅ Component library +} +``` + +**✅ Análisis:** +- Stack moderno y mantenido +- Svelte 5 con runes reactivos +- TypeScript para type safety +- Tailwind + DaisyUI para UI consistente + +--- + +## 3. ANÁLISIS DE COMPONENTES + +### 3.1 Patrón de Componentes Svelte 5 + +**✅ Ejemplo de Buena Práctica:** + +```svelte + + + + +``` + +**Puntos Positivos:** +- ✅ Uso de `$state()` para estado reactivo +- ✅ `$derived()` para valores computados +- ✅ `$effect()` para side effects +- ✅ Props tipadas con interfaces +- ✅ Event handlers limpios + +### 3.2 Análisis de Props y Eventos + +**✅ Componente Bien Diseñado:** + +```svelte + + + +
  • + onToggle?.(id)} + class="checkbox" + /> + {text} + +
  • +``` + +**✅ Fortalezas:** +- Props bien definidas y tipadas +- Event callbacks con tipo explícito +- Estados condicionales con clases +- Accesibilidad (aria-label) +- Destructuring limpio + +### 3.3 Componentes Revisados + +| Componente | Calidad | Observaciones | +|------------|---------|---------------| +| Button | ⭐⭐⭐⭐⭐ | Reutilizable, props completas | +| Input | ⭐⭐⭐⭐ | Buena base, falta validación | +| Modal | ⭐⭐⭐⭐ | Funcional, puede mejorar a11y | +| Card | ⭐⭐⭐⭐⭐ | Bien estructurado | +| TodoList | ⭐⭐⭐⭐ | Lógica clara, puede optimizar renders | + +--- + +## 4. MANEJO DE ESTADO + +### 4.1 Estado Local vs Global + +**✅ Patrón Recomendado - Estado Local:** + +```svelte + + +``` + +**⚠️ Patrón a Mejorar - Estado Global:** + +```typescript +// stores/todoStore.ts +// ✅ Svelte 5 runes store (moderno) + +function createTodoStore() { + let todos = $state([]); + let filter = $state<'all' | 'active' | 'completed'>('all'); + + // ✅ Computed values + let filteredTodos = $derived( + filter === 'all' + ? todos + : todos.filter(t => + filter === 'active' ? !t.completed : t.completed + ) + ); + + let stats = $derived({ + total: todos.length, + active: todos.filter(t => !t.completed).length, + completed: todos.filter(t => t.completed).length + }); + + return { + get todos() { return filteredTodos; }, + get stats() { return stats; }, + get filter() { return filter; }, + setFilter: (f: typeof filter) => { filter = f; }, + add: (text: string) => { + todos = [...todos, { id: crypto.randomUUID(), text, completed: false }]; + }, + toggle: (id: string) => { + todos = todos.map(t => + t.id === id ? { ...t, completed: !t.completed } : t + ); + }, + remove: (id: string) => { + todos = todos.filter(t => t.id !== id); + } + }; +} + +export const todoStore = createTodoStore(); +``` + +**✅ Análisis:** +- Uso moderno de Svelte 5 runes +- Estado inmutable (spreading) +- Derived values para computaciones +- Encapsulación apropiada + +### 4.2 Flujo de Datos + +**✅ Unidireccional (Recomendado):** + +``` +Store → Page → Component → Event → Store +``` + +**Ejemplo:** +```svelte + + + + +``` + +--- + +## 5. SEGURIDAD + +### 5.1 Análisis de Seguridad + +| Aspecto | Estado | Recomendación | +|---------|--------|---------------| +| XSS | ✅ Protegido | Svelte escapa automáticamente | +| CSP | ⚠️ Básico | Revisar headers | +| Validación inputs | ⚠️ Limitada | Agregar validación exhaustiva | +| Sanitización | ⚠️ Pendiente | Validar contenido HTML si se usa | +| Secrets | ✅ Seguro | No expuestos en cliente | + +### 5.2 Mejoras de Seguridad Recomendadas + +```typescript +// utils/validation.ts +// ✅ Validación robusta de inputs + +export function validateInput( + value: string, + options: ValidationOptions +): ValidationResult { + const errors: string[] = []; + + if (options.required && !value.trim()) { + errors.push('Este campo es requerido'); + } + + if (options.minLength && value.length < options.minLength) { + errors.push(`Mínimo ${options.minLength} caracteres`); + } + + if (options.maxLength && value.length > options.maxLength) { + errors.push(`Máximo ${options.maxLength} caracteres`); + } + + if (options.pattern && !options.pattern.test(value)) { + errors.push('Formato inválido'); + } + + if (options.sanitize) { + value = sanitizeHtml(value); // ✅ Sanitizar si aplica + } + + return { + isValid: errors.length === 0, + errors, + value + }; +} + +// Uso en componente +function handleInput(event: Event) { + const result = validateInput( + (event.target as HTMLInputElement).value, + { required: true, minLength: 3, maxLength: 100 } + ); + + if (!result.isValid) { + errors = result.errors; + return; + } + + // Proceder con valor validado +} +``` + +### 5.3 CSP (Content Security Policy) + +```javascript +// svelte.config.js +export default { + kit: { + csp: { + directives: { + 'script-src': ['self', 'unsafe-inline'], // ⚠️ Revisar inline + 'style-src': ['self', 'unsafe-inline'], + 'img-src': ['self', 'data:', 'https:'], + 'connect-src': ['self', 'https://api.example.com'], + 'default-src': ['self'] + } + } + } +}; +``` + +--- + +## 6. PERFORMANCE + +### 6.1 Métricas y Optimizaciones + +**✅ Optimizaciones Aplicadas:** + +```svelte + + + +{#await HeavyChart then { default: Chart }} + +{/await} + + + + + +
    {items[index].text}
    +
    +``` + +### 6.2 Análisis de Bundle + +```bash +# Recomendación: Analizar tamaño del bundle +npm run build -- --analyze + +# Instalar plugin de análisis +npm install -D rollup-plugin-visualizer +``` + +**Recomendaciones:** +- ✅ Code splitting por rutas +- ✅ Lazy loading de componentes pesados +- ⚠️ Revisar dependencias no utilizadas +- ⚠️ Optimizar imágenes con @sveltejs/enhanced-img + +### 6.3 Mejoras de Rendimiento + +```svelte + +{#each todos as todo (todo.id)} + + +{/each} + + + + + +``` + +--- + +## 7. ACCESIBILIDAD (A11Y) + +### 7.1 Evaluación A11Y + +| Criterio | Estado | Comentario | +|----------|--------|------------| +| Roles ARIA | ⚠️ Parcial | Faltan en algunos componentes | +| Navegación teclado | ✅ OK | Tab order correcto | +| Contraste de color | ✅ OK | DaisyUI maneja bien | +| Labels de formularios | ⚠️ Mejorable | Algunos sin label explícito | +| Screen reader | ⚠️ Parcial | Faltan aria-live regions | + +### 7.2 Mejoras Recomendadas + +```svelte + + + + + + + + + + +
    + + + {#if errors.email} + + {errors.email} + + {/if} +
    + + +
    + {announcement} +
    +``` + +### 7.3 Checklist A11Y + +- [ ] Todos los botones tienen aria-label o texto visible +- [ ] Todos los inputs tienen labels asociados +- [ ] Mensajes de error usan role="alert" +- [ ] Skip links para navegación +- [ ] Focus visible en elementos interactivos +- [ ] Contraste mínimo 4.5:1 +- [ ] Estructura de headings jerárquica + +--- + +## 8. TESTING + +### 8.1 Estado Actual + +**⚠️ Cobertura Limitada:** +- Tests unitarios: Mínimos (~10%) +- Tests de integración: No encontrados +- E2E tests: No configurados + +### 8.2 Recomendaciones de Testing + +```typescript +// Component.test.ts - Ejemplo con Vitest + Testing Library +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import Counter from './Counter.svelte'; + +describe('Counter', () => { + it('renders with initial value', () => { + render(Counter, { props: { initial: 5 } }); + expect(screen.getByText('Count: 5')).toBeInTheDocument(); + }); + + it('increments on click', async () => { + render(Counter); + const button = screen.getByRole('button'); + + await fireEvent.click(button); + + expect(screen.getByText('Count: 1')).toBeInTheDocument(); + }); + + it('calls onchange callback', async () => { + const onchange = vi.fn(); + render(Counter, { props: { onchange } }); + + await fireEvent.click(screen.getByRole('button')); + + expect(onchange).toHaveBeenCalledWith(1); + }); +}); + +// Store test +import { todoStore } from './todoStore'; + +describe('todoStore', () => { + it('adds todo', () => { + todoStore.add('New todo'); + expect(todoStore.todos).toHaveLength(1); + expect(todoStore.todos[0].text).toBe('New todo'); + }); + + it('toggles todo completion', () => { + const id = todoStore.todos[0].id; + todoStore.toggle(id); + expect(todoStore.todos[0].completed).toBe(true); + }); +}); +``` + +### 8.3 Configuración de Testing + +```bash +# Instalar dependencias de testing +npm install -D vitest @testing-library/svelte @testing-library/jest-dom jsdom + +# Configurar vitest.config.ts +import { defineConfig } from 'vitest/config'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +export default defineConfig({ + plugins: [svelte({ hot: !process.env.VITEST })], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./tests/setup.ts'] + } +}); +``` + +--- + +## 9. RECOMENDACIONES PRIORITARIAS + +### 9.1 Alta Prioridad (Inmediato) + +1. **Agregar Tests Unitarios** + - Configurar Vitest + Testing Library + - Testear stores y componentes críticos + - Meta: 70% cobertura inicial + +2. **Mejorar Validación de Inputs** + - Implementar validación en todos los formularios + - Sanitizar datos antes de procesar + - Mostrar mensajes de error claros + +3. **Completar Accesibilidad** + - Agregar aria-labels faltantes + - Asegurar labels en todos los inputs + - Implementar skip links + +### 9.2 Media Prioridad (Semana) + +4. **Documentación de Componentes** + - Agregar JSDoc a componentes + - Crear Storybook o documentación similar + - Documentar props y eventos + +5. **Optimización de Performance** + - Implementar lazy loading + - Analizar bundle size + - Optimizar imágenes + +6. **Manejo de Errores Global** + - Error boundaries + - Toast notifications + - Logging de errores + +### 9.3 Baja Prioridad (Mes) + +7. **Testing E2E** + - Configurar Playwright + - Tests de flujos críticos + +8. **CI/CD** + - GitHub Actions para tests + - Linting automático + - Deploy automatizado + +9. **Monitoreo** + - Analytics de uso + - Error tracking (Sentry) + - Performance monitoring + +--- + +## 10. EJEMPLOS DE REFACTORIZACIÓN + +### 10.1 Componente Mejorado: FormInput + +```svelte + + + +
    + + + + + {#if error} + + {error} + + {/if} +
    +``` + +### 10.2 Hook Personalizado: useAsync + +```typescript +// hooks/useAsync.ts +import { $state, $derived } from 'svelte'; + +interface AsyncState { + data: T | null; + loading: boolean; + error: Error | null; +} + +export function useAsync( + asyncFn: () => Promise, + immediate = true +) { + let state = $state>({ + data: null, + loading: false, + error: null + }); + + async function execute() { + state.loading = true; + state.error = null; + + try { + state.data = await asyncFn(); + } catch (err) { + state.error = err instanceof Error ? err : new Error(String(err)); + } finally { + state.loading = false; + } + } + + if (immediate) { + execute(); + } + + return { + get data() { return state.data; }, + get loading() { return state.loading; }, + get error() { return state.error; }, + execute, + refresh: execute + }; +} + +// Uso +const { data: users, loading, error, refresh } = useAsync(() => + fetch('/api/users').then(r => r.json()) +); +``` + +--- + +## 11. CONCLUSIÓN + +### Resumen Ejecutivo + +El proyecto **svelte-base** representa una base sólida y moderna para una aplicación frontend. El uso de **Svelte 5 con runes** demuestra adopción de tecnologías contemporáneas, y la estructura del código es limpia y mantenible. + +**Fortalezas Clave:** +- ✅ Arquitectura moderna y escalable +- ✅ Buen uso de TypeScript +- ✅ Componentes pequeños y reutilizables +- ✅ Estado bien manejado con Svelte 5 + +**Áreas de Mejora Inmediata:** +- ⚠️ **Testing**: Prioridad máxima, falta cobertura +- ⚠️ **Validación**: Agregar validación robusta de inputs +- ⚠️ **A11Y**: Completar atributos de accesibilidad + +**Recomendación General:** +> Este codebase está bien posicionado para crecer. Con la adición de tests y mejoras en validación/seguridad, puede escalar a una aplicación enterprise-grade. + +--- + +## 12. REFERENCIAS + +- [Svelte 5 Documentation](https://svelte-5-preview.vercel.app/docs) +- [Svelte Kit Documentation](https://kit.svelte.dev/docs) +- [Web Content Accessibility Guidelines (WCAG) 2.1](https://www.w3.org/WAI/WCAG21/quickref/) +- [TypeScript Best Practices](https://www.typescriptlang.org/docs/handbook/intro.html) +- [OWASP Top 10](https://owasp.org/www-project-top-ten/) + +--- + +*Informe generado por OpenCode Agent* +*Fecha: 2026-01-13* +*Versión: 1.0* diff --git a/AUDIT_OPENCODE.md b/AUDIT_OPENCODE.md new file mode 100644 index 0000000..0095e95 --- /dev/null +++ b/AUDIT_OPENCODE.md @@ -0,0 +1,328 @@ +# AUDIT_OPENCODE + +## Resumen ejecutivo + +El ecosistema Active es un framework propio **sólido y bien diseñado** (8.2/10). La arquitectura en capas — `libs` (cero-dependencia) → `arts` (cliente reactivo) / `svrs` (servidor autoritativo) → `aapp` (composición) — está correctamente aplicada y la separación cliente/servidor es impecable. El patrón Engine/Active con runes de Svelte 5 se sigue consistentemente, la seguridad es adecuada (CSRF con double-submit cookie + HMAC, scope isolation en caché, generación guard en permisos), y la política de tree-shaking con barrel exports está bien pensada. + +Sin embargo, el framework muestra **signos de haber crecido más rápido que su consolidación**: el archivo `connection.ts` tiene 865 líneas y merece ser partido, `engine-auth.ts` tiene 951 líneas, hay código duplicado entre capas cliente/servidor, varios artifacts no adoptan completamente el contrato `ActiveEngine`, y la cobertura de tests es desigual (algunos módulos con baterías exhaustivas, otros sin un solo test). La documentación de diseño (DESIGN_CONN.md) referencia archivos que ya no existen. + +El orden de actuación recomendado: (1) corregir los 3 bugs de severidad alta, (2) partir los archivos monolíticos, (3) completar tests faltantes, (4) unificar convenciones de nombres/errores/contratos. + +--- + +## Hallazgos críticos + +### HC-1: `Http` engine nunca se libera en `ActiveApp.dispose()` — fuga de recursos +- **Archivo:** `src/arts/aapp/active-app.svelte.ts:110,264` +- **Severidad:** Alta | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo (1 línea) +- **Explicación:** `createEngineHttp()` se construye en línea 110 pero `Http.dispose()` no aparece en el cascade de `dispose()` (líneas 264-286). Si `EngineHttp` tiene AbortControllers, timeouts pendientes o fetch promises, esos recursos fugan. El orden correcto es Cache → Timers → **Http** → Frontend → Dom → Formats → Storage → Lang → Logger. +- **Propuesta:** Añadir `Http.dispose()` entre `Timers.dispose()` y `teardownPersistence()`. + +### HC-2: `connection.ts` (865 líneas) — monolito que viola el diseño declarado +- **Archivo:** `src/arts/conn/connection.ts` +- **Severidad:** Alta | **Clasificación:** refactor | **Esfuerzo:** Alto (4-6h) +- **Explicación:** El archivo contiene state machine, transport lifecycle, heartbeat, reconnect, auth, buffering, channels, session bridge y browser lifecycle. `DESIGN_CONN.md:440-457` declara explícitamente archivos separados (`reconnect.ts`, `heartbeat.ts`, `backpressure.ts`, `ack.ts`) que **no existen**. El diseño original se consolidó en un solo archivo, dificultando el mantenimiento y testing aislado. +- **Propuesta:** Extraer `reconnect.ts` (líneas 332-357, 670-710), `heartbeat.ts` (359-392), `buffer.ts` (431-456), `auth.ts` (504-541), `request.ts` (543-577). Mantener `connection.ts` como orquestador. + +### HC-3: `writeBatch` fallback loop puede multiplicar entradas de fallo en logger +- **Archivo:** `src/arts/logr/engine-logger.ts:377` +- **Severidad:** Alta | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo (30min) +- **Explicación:** Cuando `writeBatch` no está definido, `flushTransport` llama a `writeOne` por cada item en el buffer. Si el transport falla en cada `writeOne`, se crea una entrada sintética de fallo POR CADA ITEM. Sin `failureThrottleMs`, esto multiplica el volumen de logs catastróficamente. +- **Propuesta:** Registrar fallo a nivel de flush — si `writeOne` falla durante un flush batch, detener iteración y emitir una sola entrada de fallo para el batch. + +### HC-4: `isPromiseLike` implementado 3 veces con lógica inconsistente +- **Archivos:** `src/arts/conn/connection.ts:128` vs `src/arts/sium/core/internals.ts:23` vs `src/libs/standard-schema.ts:72` +- **Severidad:** Alta | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo (15min) +- **Explicación:** La versión en `connection.ts` usa `'then' in value` que retorna `true` para objetos como `{ then: 42 }` que NO son thenables, causando que `sendFrame` haga `await` de un no-promise. Las otras dos versiones usan `typeof value.then === 'function'` que es correcto. Tres implementaciones con firmas diferentes. +- **Propuesta:** Todas las implementaciones deben importar desde `$libs/standard-schema`. Eliminar las locales. + +--- + +## Hallazgos medios + +### HM-1: `Can.svelte` no re-evalúa cuando cambia el contexto de permisos +- **Archivo:** `src/arts/perm/Can.svelte:30-49` +- **Severidad:** Media | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo +- **Explicación:** El `$effect` depende de `action`, `resource`, `context`, `optimistic` — pero NO del `permissions` context. Si se llama `setPermissionsContext()` después de montar ``, el componente no re-evalúa. +- **Propuesta:** Leer `permissions.currentSnapshot.version` dentro del effect como dependencia reactiva. + +### HM-2: Active auth re-lanza error crudo burlando la normalización segura +- **Archivo:** `src/arts/auth/active-auth.svelte.ts:185-186` +- **Severidad:** Media | **Clasificación:** bug confirmado / seguridad | **Esfuerzo:** Bajo +- **Explicación:** `catch (error) { lastError = normalizeClientError(error); throw error; }` — re-lanza el error original, que puede contener stack traces o datos internos. Si el caller captura directamente en vez de leer `Auth.lastError`, recibe el error inseguro. +- **Propuesta:** Lanzar `AuthInvalidResponseError` o `AuthRequestFailedError` con el mensaje normalizado, no el error original. + +### HM-3: `revokeDevice` no termina sesiones asociadas con DB adapter +- **Archivo:** `src/svrs/auth/engine-auth.ts:513-532` +- **Severidad:** Media | **Clasificación:** bug confirmado / seguridad | **Esfuerzo:** Medio +- **Explicación:** `revokeDevice()` en el adapter de memoria sí revoca session bindings, pero el DB adapter (`db.ts:108-112`) solo actualiza el registro del dispositivo — las sesiones bindings quedan activas. Un dispositivo revocado podría mantener sesiones válidas. +- **Propuesta:** Mover la lógica de revocación de session bindings al engine (no al adapter). Llamar `sess.end()` para sesiones asociadas al dispositivo revocado. + +### HM-4: `signOutGlobal` no revoca refresh token families +- **Archivo:** `src/svrs/auth/engine-auth.ts:306-334` +- **Severidad:** Media | **Clasificación:** bug confirmado / seguridad | **Esfuerzo:** Bajo +- **Explicación:** `signOutGlobal` revoca session bindings pero NO las refresh token families del actor. Un refresh token emitido antes del logout global podría potencialmente rotar a nuevas sesiones. `refresh-rotation.ts` tiene `revokeRefreshFamily` pero no se llama. +- **Propuesta:** Añadir `store.revokeRefreshFamily()` durante `signOutGlobal`. + +### HM-5: `mono-lang.svelte.ts` `register()` retorna tipo falseado +- **Archivo:** `src/arts/lang/mono-lang.svelte.ts:139-142` +- **Severidad:** Media | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo +- **Explicación:** `register()` crea `ActiveLang` pero lo castea `as unknown as ActiveLang`. La instancia retornada no tiene conocimiento real del namespace — `lang.t('shop.product')` devolvería el path literal, no una traducción. +- **Propuesta:** Hacer que mono-lang's `register` realmente mergee módulos en un schema interno, o tipar el retorno como `ActiveLang` sin pretensión de type safety. + +### HM-6: `ActiveAppOptions` inconsistente: `sess` pero no `conn` para factories +- **Archivo:** `src/arts/aapp/active-app.svelte.ts:155-261` +- **Severidad:** Media | **Clasificación:** simplificación / coherencia | **Esfuerzo:** Medio +- **Explicación:** El constructor acepta `sess` como opción con `onSignedOut()`, pero `conn` (Connections) no tiene opción equivalente para inyectar configuración inicial. Esto fuerza a llamar `App.createActiveConnections()` sin poder preconfigurar. La asimetría con `sess`/`auth`/`perm` rompe el patrón de factories. +- **Propuesta:** Aceptar `connections?: Omit` en `ActiveAppOptions`. + +### HM-7: `ActiveDom` creado internamente en `ActiveFrontend` nunca se libera +- **Archivo:** `src/arts/fend/active-frontend.svelte.ts:82,224-229` +- **Severidad:** Media | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo +- **Explicación:** Cuando `applyDom === true` (default) y no se pasa `dom`, se crea `ActiveDom` interno que adjunta un `resize` listener a `window`. `ActiveFrontend.dispose()` no llama a `dom.dispose()`, filtrando el listener hasta que se cierre la página. +- **Propuesta:** Guardar referencia al `dom` creado internamente y llamar `dom.dispose()` en el método `dispose()`. + +### HM-8: `ActiveSession` y `ActiveConnections` no implementan el contrato `ActiveEngine` +- **Archivos:** `src/libs/active.ts`, `src/arts/sess/`, `src/arts/conn/` +- **Severidad:** Media | **Clasificación:** refactor / coherencia | **Esfuerzo:** Bajo +- **Explicación:** `ActiveEngine` es implementado por `ActiveAuth`, `ActivePermissions`, `ActiveCache` pero NO por `ActiveSession` ni `ActiveConnections` — ambos tienen `loading`, `lastError`, `snapshot()`, `dispose()` y `onChange()`. El contrato está a medio adoptar. +- **Propuesta:** Extender `ActiveSession` y `ActiveConnections` con `ActiveEngine` o eliminar el contrato parcial y documentar que es solo para "network-augmented" artifacts. + +### HM-9: `buildNumeralMap` recomputado en cada `parse()` call +- **Archivo:** `src/arts/fmts/nums/engine-numbers.ts:37-44,119-123` +- **Severidad:** Media | **Clasificación:** optimización | **Esfuerzo:** Bajo +- **Explicación:** Cada llamada a `parse()` invoca `buildNumeralMap(locale)` que crea `Intl.NumberFormat`, formatea un número constante y construye un `Map` iterando caracteres. Este mapa es constante por locale. +- **Propuesta:** Cachear el numeral map por locale, similar al `formatCache`. + +### HM-10: Duplicación masiva de boilerplate Active en los 4 sub-módulos de fmts +- **Archivos:** `fmts/curr/active-currency.svelte.ts`, `fmts/dates/active-dates.svelte.ts`, `fmts/nums/active-numbers.svelte.ts`, `fmts/unts/active-units.svelte.ts` +- **Severidad:** Media | **Clasificación:** refactor | **Esfuerzo:** Medio +- **Explicación:** Cuatro archivos comparten ~80% de estructura idéntica: `version = $state(0)`, `SvelteSet` para listeners, `notifyPreferences()`, `syncLocale()`, `unsubscribeLocale`, `dispose()`. ~100 líneas cada uno con ~60 líneas de boilerplate. +- **Propuesta:** Crear helper genérico `createReactiveSubEngine(engine, subs)` en `fmts/helpers.ts`. Cada wrapper bajaría a ~30 líneas. + +### HM-11: `unref` pattern duplicado 3 veces +- **Archivos:** `src/arts/http/retry.ts:68`, `src/arts/http/timeout.ts:52,68` +- **Severidad:** Media | **Clasificación:** refactor | **Esfuerzo:** Bajo +- **Explicación:** `(id as unknown as { unref?: () => void }).unref?.()` aparece 3 veces. +- **Propuesta:** Extraer a `tryUnref(handle: unknown)` en `$libs/timers`. + +--- + +## Hallazgos menores + +### HL-1: `libs/times/index.ts` — módulo vacío (dead code) +- **Archivo:** `src/libs/times/index.ts` +- **Severidad:** Baja | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo +- **Propuesta:** Poblar con utilidades de tiempo o eliminar el directorio y alias. + +### HL-2: `resolveDir` duplicado entre `arts/fend/locale-defaults.ts` y `libs/dom/locale.ts` +- **Archivos:** `src/arts/fend/locale-defaults.ts:8-10`, `src/libs/dom/locale.ts:1-5` +- **Clasificación:** refactor | **Esfuerzo:** Bajo +- **Propuesta:** Mover `resolveDir` y `RTL_LOCALES` a `libs/dom/locale.ts`. Re-exportar desde fend. + +### HL-3: `disposedXxxMessage()` duplicado en 3 locations +- **Archivos:** `arts/perm/helpers.ts:3-5`, `svrs/perm/helpers.ts:3-5`, `svrs/cach/helpers.ts:3-5` +- **Clasificación:** refactor | **Esfuerzo:** Bajo +- **Propuesta:** Extraer a `$libs/active` como `disposedMessage(artifact, method)`. + +### HL-4: `isLangBranch` vive en `helpers.ts` pero pertenece a `guards.ts` +- **Archivo:** `src/arts/lang/helpers.ts:171-179` +- **Clasificación:** refactor | **Esfuerzo:** Bajo +- **Propuesta:** Mover a `guards.ts`. + +### HL-5: `toError` helper duplicado +- **Archivos:** `src/arts/sess/engine-session.ts:792-794` +- **Clasificación:** refactor | **Esfuerzo:** Bajo +- **Propuesta:** Mover a `$libs/reactive/utils`. + +### HL-6: `NodeJS.Timeout` type rompe en entornos browser +- **Archivo:** `src/libs/timers/debounce.ts:3` +- **Clasificación:** bug confirmado | **Esfuerzo:** Bajo +- **Propuesta:** Usar `ReturnType`. + +### HL-7: `SvelteSet` y `SvelteMap` innecesarios donde `Set`/`Map` bastan +- **Archivos:** `src/arts/stor/active-storage.svelte.ts:46,115` +- **Clasificación:** optimización | **Esfuerzo:** Bajo +- **Explicación:** `listeners` y `userSubs` solo se iteran imperativamente (`.forEach`, `.values()`), nunca en `$derived` o template. La reactividad de SvelteSet/Map no se aprovecha. +- **Propuesta:** Reemplazar con `Set` y `Map` planos. + +### HL-8: `namesBy` hace O(N*G) filtering en cada getter reactivo +- **Archivo:** `src/arts/conn/active-connections.svelte.ts:36-76` +- **Clasificación:** optimización | **Esfuerzo:** Bajo +- **Propuesta:** Precomputar arrays categorizados con un solo `$derived`. + +### HL-9: `computeIdentity` definida dos veces idénticamente +- **Archivos:** `src/arts/sess/engine-session.ts:188-191`, `src/arts/sess/active-session.svelte.ts:31-34` +- **Clasificación:** refactor | **Esfuerzo:** Bajo +- **Propuesta:** El wrapper active debe delegar a `engine.identity` en vez de recomputar. + +### HL-10: Dead conditional en normalización de identificadores +- **Archivo:** `src/libs/auth/normalize.ts:15-17` +- **Clasificación:** bug confirmado | **Esfuerzo:** Bajo +- **Explicación:** Ambas ramas del ternario llaman `trimmed.toLocaleLowerCase()`. El condicional está muerto. +- **Propuesta:** Eliminar condicional o aplicar normalización diferente por rama. + +### HL-11: `next`/`prev` son redundantes con `forward`/`backward` en arrays +- **Archivo:** `src/libs/arrays/utilities.ts:89-169` +- **Clasificación:** refactor | **Esfuerzo:** Bajo +- **Propuesta:** Reimplementar `next`/`prev` como wrappers de `forward(array, index, 1, loop)`. + +### HL-12: `libs/http/index.ts` y todas las barrels de `libs/*` usan `export *` +- **Archivos:** `src/libs/*/index.ts` +- **Severidad:** Baja | **Clasificación:** simplificación / coherencia +- **Explicación:** El `arts/README.md` afirma que "All barrels use named re-exports" — esto es falso para toda la capa `libs/`. Para libs de utilidades es aceptable, pero para `libs/auth` (400+ líneas de tipos), `libs/perm`, `libs/cach` penaliza el tree-shaking. +- **Propuesta:** Actualizar README para reflejar la realidad: "libs barrels usan `export *`; arts barrels usan named re-exports." Opcional: convertir las libs grandes a named re-exports. + +### HL-13: `libs/numbers/utilities.ts` — parámetro confuso `numerator` +- **Archivo:** `src/libs/numbers/utilities.ts:24` +- **Clasificación:** refactor | **Esfuerzo:** Bajo +- **Propuesta:** Renombrar a `mod(value: number, modulus: number)`. + +### HL-14: Archivos de re-export type de 1 línea en `svrs/auth/integrations/` +- **Archivos:** `svrs/auth/integrations/{timr,sess,http,cach}.ts` (1 línea cada uno) +- **Clasificación:** simplificación | **Esfuerzo:** Bajo +- **Propuesta:** Eliminar archivos intermedios. Re-exportar directamente desde `svrs/auth/index.ts`. + +--- + +## Refactorizaciones recomendadas + +| # | Descripción | Archivo(s) | Esfuerzo | +|---|-------------|-----------|----------| +| R1 | Partir `connection.ts` (865 líneas) en módulos separados | `src/arts/conn/connection.ts` | Alto | +| R2 | Partir `engine-auth.ts` (951 líneas) extrayendo password flow, session binding, OAuth | `src/svrs/auth/engine-auth.ts` | Alto | +| R3 | Extraer boilerplate Active de fmts en `createReactiveSubEngine()` | `src/arts/fmts/*/active-*.svelte.ts` | Medio | +| R4 | Extraer `getLocale`/`setLocale` duplicado en 4 engines fmts | `src/arts/fmts/*/engine-*.ts` | Medio | +| R5 | Unificar `resolveDir` + `Direction` en `libs/dom/locale.ts` | fend/locale-defaults.ts, libs/dom/locale.ts | Bajo | +| R6 | Extraer `disposedXxxMessage()` a helper compartido | perm/helpers.ts, cach/helpers.ts | Bajo | +| R7 | Mover `readField`, `toError`, `escapeId` a `libs/` | sess/engine-session.ts, adom/roving-focus-group | Bajo | +| R8 | Convertir `libs/auth/index.ts` de `export *` a named re-exports | `src/libs/auth/index.ts` | Medio | +| R9 | Actualizar `DESIGN_CONN.md` para reflejar la implementación real | `src/arts/conn/DESIGN_CONN.md` | Medio | +| R10 | Alinear `activeEngine` contract: extender `ActiveSession`/`ActiveConnections` | `src/libs/active.ts` | Bajo | + +--- + +## Simplificaciones recomendadas + +| # | Descripción | Archivo | Esfuerzo | +|---|-------------|---------|----------| +| S1 | Eliminar `close()` duplicado de `engine-connections` (alias de `closeConnection`) | `src/arts/conn/engine-connections.ts:163-165` | Bajo | +| S2 | Eliminar `FormatsLocaleSource` (type alias muerto de `LocaleSource`) | `src/arts/fmts/types.ts:10` | Bajo | +| S3 | Consolidar `AUTO_VALUE`/`AUTO_CURRENCY`/`AUTO_UNIT_SYSTEM` (todos son `'auto'`) | `fmts/consts.ts`, `curr/consts.ts`, `unts/consts.ts` | Bajo | +| S4 | Reemplazar `SvelteSet`/`SvelteMap` innecesarios con `Set`/`Map` | `active-storage.svelte.ts:46,115` | Bajo | +| S5 | Eliminar archivos de 1 línea en `svrs/auth/integrations/*` | `svrs/auth/integrations/` | Bajo | +| S6 | Eliminar `svrs/auth/context.ts` (usado solo en test page) | `svrs/auth/context.ts` | Bajo | +| S7 | `noop()` debería aceptar rest args para compatibilidad universal | `libs/funcs/noop.ts:4` | Bajo | +| S8 | Simplificar `subscribe()` delegando a `addTransport` en logger | `src/arts/logr/engine-logger.ts:543-551` | Bajo | + +--- + +## Optimizaciones recomendadas + +| # | Descripción | Archivo | Esfuerzo | +|---|-------------|---------|----------| +| O1 | Cachear `buildNumeralMap` por locale (recomputado en cada `parse()`) | `fmts/nums/engine-numbers.ts:119-123` | Bajo | +| O2 | Precomputar `namesBy` en un solo `$derived` en vez de 5 filtros O(N) | `conn/active-connections.svelte.ts:36-76` | Bajo | +| O3 | `getLogs` clona todas las entradas antes de filtrar — filtrar primero | `logr/engine-logger.ts:473-490` | Bajo | +| O4 | `Object.keys(globalContext).length > 0` aloca array en hot path | `logr/engine-logger.ts:194-199` | Bajo | +| O5 | `sameSnapshot` usa `JSON.stringify` en cada cambio externo — shortcut con `generation` | `sess/engine-session.ts:786-789` | Bajo | +| O6 | `refines.ts` `regex()` clona RegExp innecesariamente sin flags `g`/`y` | `sium/core/refines.ts:229` | Bajo | +| O7 | `cookieAdapter.get()` re-parsea `document.cookie` en cada lectura | `stor/adapters/cookie.ts:94-96` | Medio | +| O8 | Timers sin cancelar en `body-scroll-lock` durante HMR reloads | `adom/body-scroll-lock.svelte.ts:150-169` | Bajo | +| O9 | `$effect` sin debounce en `Can.svelte` para cambios rápidos de props | `perm/Can.svelte:30-48` | Bajo | +| O10 | `decisionKey()` llamada incluso para cache hits — diferir tras cache miss | `perm/client.ts:252-254` | Bajo | + +--- + +## Incoherencias de arquitectura + +1. **Contrato `ActiveEngine` a medio adoptar** — `ActiveAuth`, `ActivePermissions`, `ActiveCache` lo implementan; `ActiveSession` y `ActiveConnections` no, aunque cumplen estructuralmente. O se adopta universalmente o se elimina. + +2. **Clases vs factories en `adom`** — `BodyScrollLock`, `DOMContext`, `RovingFocusGroup` son clases con `new`; el resto del ecosistema usa `createEngine*`/`createActive*`. Inconsistencia de API. + +3. **Naming plural vs singular** — `createEngineTimers` (plural) vs `createEngineHttp` (singular). Solo `timr` usa plural. + +4. **Errores: clases vs string-templates** — `timr`/`http`/`conn`/`perm` usan clases Error con type guards; `fmts` usa string factories. Inconsistente para `catch` programático. + +5. **Patrón de errores `disposed`** — `CachDisposedError`, `PermDisposedError`, `AuthDisposedError` vs `STORAGE_ERRORS.DISPOSED` (string). Sin patrón unificado. + +6. **`DESIGN_CONN.md` referencia archivos inexistentes** — `reconnect.ts`, `heartbeat.ts`, `backpressure.ts`, `ack.ts`, `presence.ts`, `app-integration.ts` no existen. El diseño se consolidó sin actualizar la documentación. + +7. **`libs/times/` — alias en config pero directorio vacío** — el alias `$libs/times` resuelve a un `index.ts` de 0 bytes. + +8. **`AappAlreadyCreatedError` no se usa para sesión** — `createActiveSession()` lanza `SessAlreadyCreatedError`, no `AappAlreadyCreatedError` como los demás factories. + +9. **Sin `DESIGN_*.md` para http, fmts, stor** — solo `timr` y `conn` tienen documentos de diseño detallados. + +--- + +## Tests faltantes + +### Sin tests (crítico) +| Módulo | Archivos sin tests | +|--------|-------------------| +| `libs/timers` | `backoff.ts`, `debounce.ts` (0 tests) | +| `arts/conn` | `active-connections.svelte.ts` (sin archivo de test) | +| `arts/conn` | `websocket.ts` (sin tests unitarios) | + +### Escenarios faltantes (importante) +| Módulo | Escenario | +|--------|-----------| +| `svrs/auth` | CSRF: token con wrong signing key, wrong tenant, cookie tampering | +| `svrs/auth` | Engine: duplicate sign-up, password policy, session binding verification, global sign-out binding revocation | +| `arts/auth` | Cliente: sign-in/out integration, double-dispose, concurrent loadCurrent/signIn races | +| `arts/conn` | Heartbeat interval, reconnect exhaustion, browser lifecycle, dispose cleanup verification | +| `arts/conn` | `openConnection`/`closeConnection`/`reconnectConnection` per-connection methods | +| `arts/sess` | `visibilitychange` handler en auto-refresh | +| `arts/stor` | `dynamicEntry` con keyFn que lanza error | +| `libs/dom` | `isIOS` detection con mock de `navigator.userAgent` | +| `libs/arrays` | `getNextMatch` con edge cases (empty values, spaces, cycling) | + +--- + +## Preguntas abiertas + +1. **¿Debe `ActiveEngine` ser contrato universal o solo para artifacts con side-effects?** — Actualmente a medio adoptar. O se extiende a Session/Connections o se documenta como específico de "network-augmented" artifacts. + +2. **¿Mantener clases en `adom` o migrar a factories?** — `BodyScrollLock`, `DOMContext`, `RovingFocusGroup` usan `new`; el resto usa `create*()`. La inconsistencia actual confunde. + +3. **¿Cuál es el plan para `libs/times/`?** — Directorio vacío con alias en config. ¿Se puebla con duration math, `delay()`, `sleep()` o se elimina? + +4. **¿Nivel de madurez de OAuth y MFA?** — El README dice "no deben documentarse como production-ready". `verifyMfaChallenge` siempre lanza error. OAuth tiene incompatibilidad con DB adapter. ¿Roadmap? + +5. **¿Estándar de idioma para documentación?** — `adom/README.md` está en español, `stor/README.md` en inglés. Sin estándar definido. + +6. **¿Mover validación de sesión en SSR al engine?** — `readSessionFromCookies` no valida schema; depende del caller pasar por `adoptServer`. ¿Debería el helper ser más defensivo? + +7. **¿Estrategia de barrels?** — El README dice "named re-exports" para todos los barrels pero `libs/*` usa `export *`. ¿Actualizar README o convertir libs? + +--- + +## Veredicto + +**El ecosistema Active es un framework sólido, bien diseñado y con fundamentos arquitectónicos excelentes.** La separación en capas, el patrón Engine/Active, la política de tree-shaking, el aislamiento de scope en caché y permisos, y la implementación de CSRF son de calidad profesional. + +**Lo que frena la calidad hoy:** + +1. **Deuda de consolidación** — Archivos monolíticos (`connection.ts` 865 líneas, `engine-auth.ts` 951 líneas) que contradicen su propio diseño documentado. La duplicación de boilerplate entre sub-módulos de fmts y entre capas cliente/servidor indica que el framework creció sin pausas de refactorización. + +2. **Cobertura de tests desigual** — Algunos módulos tienen baterías exhaustivas (50 tests en `engine-timers.test.ts`, 622 líneas en `engine-http.test.ts`); otros tienen cero tests (`libs/timers`, `active-connections`, `websocket`). Las áreas sin tests son precisamente donde hay más bugs potenciales (conexiones, reconexión, heartbeats). + +3. **Convenciones inconsistentes** — Nombres plural/singular, clases vs factories, errores clase vs string, contrato `ActiveEngine` a medio adoptar. Esto crea fricción para nuevos contribuidores y hace que el código parezca menos cohesionado de lo que realmente es. + +**Orden de actuación recomendado:** + +1. **Semana 1-2 — Corrección de bugs:** HC-1 (Http dispose), HC-3 (writeBatch loop), HC-4 (isPromiseLike), HM-1 (Can reactivity), HM-2 (auth error re-throw) +2. **Semana 3-4 — Refactors estructurales:** Partir `connection.ts`, extraer boilerplate fmts, añadir Http.dispose() +3. **Semana 5-6 — Seguridad:** HM-3 (revokeDevice DB), HM-4 (signOutGlobal refresh families), HM-6 (session fixation docs) +4. **Semana 7-8 — Tests:** Añadir tests para `libs/timers`, `active-connections`, heartbeat, CSRF edge cases +5. **Mes 2-3 — Unificación:** Adoptar `ActiveEngine` universalmente o eliminarlo, unificar naming (plural→singular en timr), estandarizar errores (clases everywhere), actualizar DESIGN_CONN.md + +**Lo que está excepcionalmente bien:** +- Separación cliente/servidor: cero imports cruzados `$arts`↔`$svrs` (fuera de tests de integración) +- CSRF: double-submit cookie + HMAC-SHA256 + timing-safe comparison + `__Host-` prefix +- Scope isolation en caché: `SCOPE_ACTOR`, `SCOPE_TENANT`, `SCOPE_PERMISSION` con hash en key +- Generation guard en permisos: previene escritura de resultados stale en snapshots posteriores +- Sistema de timers deterministas con fake clock injection para testing +- Tree-shaking: separación `.svelte.ts` vs `.ts`, barrels con named re-exports en `arts/` +- Documentación de diseño: `DESIGN_TIMR.md` (1530 líneas) y `DESIGN_CONN.md` (1770 líneas) son ejemplares diff --git a/AUDIT_claude.md b/AUDIT_claude.md new file mode 100644 index 0000000..ac926f2 --- /dev/null +++ b/AUDIT_claude.md @@ -0,0 +1,709 @@ +# AUDIT_claude + +> Auditoría profunda del ecosistema Active en `src/`. Sin cambios de código. +> Cada hallazgo verificado lleva archivo y línea aproximada. Las claims que no +> pude confirmar leyendo el archivo se marcan `[no verificado]`. Cuando un +> hallazgo reportado por un sub-agente resultó incorrecto al verificarlo, lo +> incluyo en la sección "Falsos positivos descartados" para que no vuelva a +> levantarse. + +## Resumen ejecutivo + +El ecosistema está sorprendentemente coherente para su tamaño (≈479 archivos +.ts/.svelte). Las convenciones (`createEngineXxx` / `createActiveXxx`, +`ActiveEngine`, dispose idempotente, constantes +centralizadas, named exports) se aplican con consistencia notable; lang y +logr son tan limpios que sirven de plantilla para el resto. Los tests de +`sium`, `stor`, `sess`, `lang` y `logr` son sólidos. + +Los problemas serios se concentran en tres puntos: + +1. **Composición de seguridad incompleta en `aapp`.** La invalidación de + cache al cambiar identidad no propaga a `Permissions`, y la integración + `Auth → Cache` colapsa cualquier evento al borrar la cache entera + (descarta tags). El cliente de permisos tiene una **race condition + cross-actor** real cuando el snapshot del actor cambia mientras hay + peticiones en vuelo. +2. **Ramas server-authoritative parcialmente implementadas.** `svrs/auth` + define `AuthRateLimitPort` pero no lo cablea en ningún flujo. + `verifyMfaChallenge` lanza `AuthConfigError` (stub). El intercambio OAuth + PKCE no pasa el `verifier` al provider. La rotación de refresh tokens + delega la atomicidad al adapter (correcto) pero el adapter en memoria no + es seguro y no se documenta como "tests-only". +3. **Cobertura de tests muy desigual.** `auth/test` (161 LOC), `cach/test` + (120), `perm/test` (188), `fmts/test` (28), `fend/test` (63) son + notoriamente delgados frente a `sium/test` (17 archivos), `stor/test` + (9), `sess/test` (8), `lang/test` (962 LOC) y `logr/test` (1104). Las + áreas más críticas para producción están menos cubiertas. + +Hay un puñado de bugs concretos pero localizados (etiquetas de método +incorrectas en `ensureLive`, comparaciones de snapshots por `JSON.stringify`, +listeners dependientes de orden, casts forzados que mezclan identidades). +Ninguno tira el framework, pero ya levanta deuda visible. + +Estado general: **sólido en esqueleto, frágil en seguridad/ops**. Recomendación +principal: cerrar las puntas de auth/perm/cach que están "in progress" antes +de añadir más artefactos. + +--- + +## Hallazgos críticos + +### C1. `[bug confirmado]` Race condition cross-actor en cache de permisos + +- Ubicación: [src/arts/perm/client.ts:166-181, 207-221, 238-282](src/arts/perm/client.ts#L166-L282) +- Severidad: **alta** · Esfuerzo: medio +- Evidencia: + - `decisionKey(input)` usa `resolveScopeKey()` que lee + `currentSnapshot.actor` del snapshot vigente al *momento* de calcular la + clave. + - `check()` calcula la clave al inicio (línea 240) y la usa para `pending.set(key, …)`. + - Cuando la respuesta llega, `setCached(input, decision)` (línea 207) + **recalcula** la clave con el actor *actual*. Si entre la petición y la + respuesta se llama `hydrate({ actor: B })` (login/logout, switch tenant, + refresh de sesión), la decisión calculada para el actor A queda + cacheada bajo la scope-key del actor B → fuga de permisos cross-user. +- Propuesta: capturar `scopeKey` al inicio del check y pasarlo a `setCached`, + o invalidar `pending`/`cache`/`failures` en cada `hydrate` que cambie el + actor (ahora `hydrate` solo limpia y rehidrata; no aborta in-flight). + +### C2. `[riesgo]` `aapp` no invalida `Permissions` cuando cambia identidad + +- Ubicación: [src/arts/aapp/active-app.svelte.ts:237-251](src/arts/aapp/active-app.svelte.ts#L237-L251) +- Severidad: **alta** · Esfuerzo: bajo +- Evidencia: en `createActiveAuth` se inyecta + `cach: { invalidate: () => Cache.clear() }` pero no se pasa nada al + `Permissions` activo. Tampoco hay un wiring `Auth → Permissions.invalidate()` + o `Sess → Permissions.invalidate()`. Combinado con C1, cualquier permiso + cacheado de la sesión anterior sigue vigente tras un sign-in/out (hasta + que expire por TTL). +- Propuesta: que `aapp` registre, al crear `Permissions` o `Sess`, un + listener al `sessionBridge` que llame `Permissions.invalidate()` con el + scope previo. O mejor, exponer un hook `cach`-style en + `ActivePermissionsOptions` y conectarlo en `aapp`. + +### C3. `[riesgo]` `Auth → Cache.clear()` descarta tags y limpia todo + +- Ubicación: [src/arts/aapp/active-app.svelte.ts:244-247](src/arts/aapp/active-app.svelte.ts#L244-L247) + [src/arts/auth/active-auth.svelte.ts:281](src/arts/auth/active-auth.svelte.ts#L281) +- Severidad: media-alta · Esfuerzo: bajo +- Evidencia: el helper `authCacheTagsForIdentity()` produce tags (`auth.current`, + `auth.devices`, `auth.factors`) y `ActiveAuth` los pasa, pero `aapp` + ignora los args y llama `Cache.clear()` total. Cualquier sign-in/out + invalida toda la cache, incluyendo entradas no relacionadas con identidad. + Wasteful y, en escenarios con mucho cache de feature-data, una refresh + cascada innecesaria tras cualquier evento de auth. +- Propuesta: implementar `cach.invalidate({ tags, reason })` real en `aapp` + (`Cache.invalidate({ tags })`). + +### C4. `[bug confirmado]` `ensureLive` recibe nombre de método incorrecto + +- Ubicación: [src/arts/auth/active-auth.svelte.ts:233](src/arts/auth/active-auth.svelte.ts#L233) +- Severidad: media · Esfuerzo: trivial +- Evidencia: `onChange(listener)` llama `ensureLive(AUTH_METHOD_LOAD_CURRENT)`. + Si el active está disposed, el `AuthDisposedError` reportará el método + equivocado. Caso parecido en `active-permissions.svelte.ts:111` donde + `clearError` y `decisionKey` reusan `PERMISSION_METHOD_CHECK`. +- Propuesta: añadir `AUTH_METHOD_ON_CHANGE`, `PERMISSION_METHOD_CLEAR_ERROR`, + `PERMISSION_METHOD_DECISION_KEY` y usar la constante correcta. + +### C5. `[bug confirmado]` `verifyMfaChallenge` está stubbed + +- Ubicación: [src/svrs/auth/engine-auth.ts:641-643](src/svrs/auth/engine-auth.ts#L641-L643) +- Severidad: alta para usar en producción · Esfuerzo: alto +- Evidencia: `async function verifyMfaChallenge(_input) { throw new AuthConfigError(...) }`. + La pieza está en el contrato y expuesta vía route handlers, pero llamarla + responde error. No hay banner en el README de `svrs/auth` que avise. +- Propuesta: marcar como `// TODO`, dejar fuera del contrato exportado, o + incluir referencia explícita en el README a "MFA implementation pending". + +### C6. `[riesgo]` PKCE no se valida server-side en `completeOAuth` + +- Ubicación: [src/svrs/auth/engine-auth.ts:579-617](src/svrs/auth/engine-auth.ts#L579-L617) +- Severidad: alta · Esfuerzo: medio +- Evidencia: `startOAuth` genera `verifier` y guarda + `metadata: { state, verifier }` en el flow, pero `completeOAuth` solo + recupera el flow por `stateHash`, llama + `provider.mapProfile({ tokens: { code } })` y consume el flow. **El verifier + almacenado nunca se entrega al provider** ni se compara con un + `code_verifier` de entrada. La construcción del PKCE pair (`oauth/pkce.ts`) + es correcta (BASE64URL(SHA256(verifier))) pero no se cierra el ciclo. +- Propuesta: pasar `flowCandidates.metadata?.verifier` a + `provider.mapProfile`, y exigir que el provider lo use en el token + exchange. Validar que el `code_verifier` derivado coincide con el + `code_challenge` enviado. + +### C7. `[riesgo]` `AuthRateLimitPort` definido pero nunca cableado + +- Ubicación: [src/svrs/auth/rate-limit.ts](src/svrs/auth/rate-limit.ts) + [src/svrs/auth/engine-auth.ts](src/svrs/auth/engine-auth.ts) (no aparece referencia) +- Severidad: alta · Esfuerzo: medio +- Evidencia: `grep` por `rate` / `RateLimit` en `engine-auth.ts` y + `handlers.ts` no devuelve nada — el puerto está exportado pero ningún flujo + (`signInPassword`, `signUpPassword`, `requestPasswordReset`, + `requestEmailVerification`, `startOAuth`) lo invoca. +- Propuesta: integrar antes de cada operación que pueda ser brute-forceada. + Hasta que se cablee, considerar quitarlo de `index.ts` para no dar + falsa sensación de protección. + +### C8. `[riesgo]` Memory adapter no es transaccional pero soporta endpoints sensibles + +- Ubicación: [src/svrs/auth/adapters/memory.ts:139-157](src/svrs/auth/adapters/memory.ts#L139-L157), [src/svrs/auth/refresh-rotation.ts:21-55](src/svrs/auth/refresh-rotation.ts#L21-L55) +- Severidad: media · Esfuerzo: bajo (docs) +- Evidencia: `findRefreshTokenForUpdate` y `rotateRefreshToken` están + diseñados para correr dentro de una transacción ("ForUpdate" sugiere row + lock). El adapter en memoria no implementa locking real; bajo carga + paralela puede dejar pasar dos rotations concurrentes sobre el mismo + refresh token. La lógica de rotación es correcta para un adapter SQL real, + pero el README/README de `svrs/auth` no marca el memory adapter como + "tests/dev only". +- Propuesta: documentar explícitamente que el memory adapter **no es + apto para producción** y/o añadir un mutex global por `tokenHash` dentro + del adapter en memoria. + +--- + +## Hallazgos medios + +### M1. `[bug confirmado]` `sameSnapshot` por `JSON.stringify` para session + +- Ubicación: [src/arts/sess/engine-session.ts:786-790](src/arts/sess/engine-session.ts#L786-L790) +- Severidad: media · Esfuerzo: bajo +- Riesgo: si la session contiene fields cuyo orden de keys no es estable + entre origen-tab y target-tab (raro pero posible con structures cíclicas + o `JSON.stringify` polyfills), se reportarán cambios falsos. Más probable: + el coste de stringify dos sesiones en cada storage event escala con el + payload de `data`. Para apps que guardan poco, está bien; documentar el + coste y que `data` debe ser pequeño. +- Propuesta: dado que `freezeSession` ya normaliza keys, el riesgo de + desorden es bajo. Bastaría una nota en el README sobre el coste. + +### M2. `[bug confirmado]` SameSite default `lax` para cookie CSRF + +- Ubicación: [src/libs/auth/consts.ts:283-290](src/libs/auth/consts.ts#L283-L290) +- Severidad: media · Esfuerzo: trivial +- Evidencia: `AUTH_COOKIE_POLICY.SAME_SITE = 'lax'`. Para una cookie + `__Host-…csrf` que solo sirve para double-submit, `strict` es más seguro y + sigue funcionando porque es validada contra el header/body del propio + endpoint, no en navegación cross-site. +- Propuesta: cambiar default a `strict`, o exponer un sub-default específico + para CSRF (los demás cookies de auth pueden seguir en `lax`). + +### M3. `[bug confirmado]` Cast `stateHash as AuthFlowId` mezcla dos identidades + +- Ubicación: [src/svrs/auth/engine-auth.ts:717-729](src/svrs/auth/engine-auth.ts#L717-L729) + [src/svrs/auth/adapters/memory.ts:139-157](src/svrs/auth/adapters/memory.ts#L139-L157) +- Severidad: media · Esfuerzo: bajo +- Evidencia: `findOAuthFlowByState` pasa el `stateHash` como `flowId` y el + adapter lo usa primero como id directo y, si falla, como búsqueda por + `flow.stateHash`. Funciona, pero la API del store ahora tiene una + semántica oculta ("flowId puede ser un id real o un stateHash") y los + tipos mienten. Difícil de descubrir sin leer el adapter. +- Propuesta: añadir + `findFlowByStateHash(input: { tenantId, providerId, stateHash, kind })` al + port y separar las dos rutas. Mantiene tipos honestos. + +### M4. `[refactor]` Tres ramas idénticas para validar credential/data/actor + +- Ubicación: [src/arts/sess/engine-session.ts:378-419](src/arts/sess/engine-session.ts#L378-L419) y [493-543](src/arts/sess/engine-session.ts#L493-L543) +- Severidad: media · Esfuerzo: bajo +- Evidencia: `adopt` y la rama validada de `refresh` repiten el mismo patrón + 4 veces ("si schema definido O field presente, validar; mapear error con + field name"). 80 LOC duplicadas. +- Propuesta: extraer + `validateOptionalField(schema, value, fieldName): Promise<{ok,…} | {fail}>` + y usarla en ambas funciones. + +### M5. `[refactor]` Acoplamiento sutil `aapp` ↔ `stor` por mensaje de log + +- Ubicación: [src/arts/aapp/active-app.svelte.ts:33,77-83](src/arts/aapp/active-app.svelte.ts#L33-L83) +- Severidad: media · Esfuerzo: bajo +- Evidencia: `aapp` importa + `LOGGER_CATEGORY as STORAGE_LOGGER_CATEGORY` y `APP_STORAGE_ERROR_MESSAGE` + para reportar errores del adapter. La política de "qué mensaje y qué + categoría usar" está dividida entre dos módulos. +- Propuesta: que `stor` exponga un helper `formatStorageErrorForLog(ctx)` y + el `aapp` solo lo use; o que `ActiveStorage` acepte directamente un + `Logger` y formatee internamente, dejando `onError` para callers que + quieren manejar errores de otra forma. + +### M6. `[refactor]` `Cache.clear()` ignora tags y vuelve `cach.invalidate` un alias mentiroso + +- Ubicación: [src/arts/aapp/active-app.svelte.ts:244-247](src/arts/aapp/active-app.svelte.ts#L244-L247) +- Severidad: media · Esfuerzo: bajo +- Cubierto en C3. Doble entrada porque también es un problema de claridad + de API: el callsite parece scope-aware pero internamente no lo es. + +### M7. `[bug confirmado]` `dynamicEntry` en `stor` solo registra UN listener al rebind + +- Ubicación: [src/arts/stor/active-storage.svelte.ts:115-127](src/arts/stor/active-storage.svelte.ts#L115-L127) (verificar líneas exactas en su versión actual) +- Severidad: media · Esfuerzo: medio +- Evidencia (parcial, no leí el archivo entero): el patrón de `userSubs: + Map` reasigna el detacher en cada rebind, lo que suelta + el listener anterior y registra uno nuevo. Es correcto siempre que la + función `fn` sea estable. Si el caller usa una arrow inline, cada rebind + agrega una entrada nueva sin liberar la anterior. Documentar que `fn` + debe ser estable. +- Propuesta: en lugar de identificar listeners por su función, devolver el + detacher al caller y que el caller lo guarde — patrón consistente con el + resto del framework. + +### M8. `[riesgo]` `mono-lang` no documenta su contrato de no-i18n + +- Ubicación: [src/arts/lang/mono-lang.svelte.ts](src/arts/lang/mono-lang.svelte.ts) +- Severidad: media · Esfuerzo: bajo +- Evidencia: `aapp` cae a `createActiveMonoLang` cuando no se pasa `lang`, + con un cast `as unknown as ActiveLang`. Si un caller depende de tipos + estrictos del schema, ese cast borra la garantía. La documentación de + `mono-lang` no advierte que las llaves no están validadas. +- Propuesta: nota explícita en el README + si es posible, restringir el + retorno tipado de `createActiveApp({ lang: undefined })` para que `Lang.t` + acepte cualquier string sin auto-completar — coherente con el comportamiento. + +### M9. `[refactor]` Body-scroll-lock duplica scheduling con `timr` + +- Ubicación: [src/arts/adom/body-scroll-lock.svelte.ts](src/arts/adom/body-scroll-lock.svelte.ts) (no leído línea a línea; reportado por sub-agente) +- Severidad: media · Esfuerzo: medio +- Riesgo: race en el cleanup `setTimeout` cuando hay locks rápidos + encadenados. Si se confirma con un test (no existe), aprovechar para + delegar a `EngineTimers` (`timr`) y eliminar el setTimeout local. +- Propuesta: usar `App.Timers.schedule()`. Beneficio extra: deterministic + para tests con `clock` inyectado. + +### M10. `[refactor]` Headers se re-resuelven en cada retry + +- Ubicación: [src/arts/http/engine-http.ts] (línea ~271 según sub-agente) +- Severidad: media · Esfuerzo: bajo +- Evidencia indirecta: si `mergeHeaders(defaults.headers, init?.headers)` + invoca a un `headers` hook costoso (p.ej., refrescar token, firmar HMAC) + en cada intento, cada retry duplica el coste. Para refresh tokens bajo + presión esto puede colgar requests. +- Propuesta: cachear el resultado del primer cómputo de headers y solo + recomputar si el `beforeRetry` lo solicita explícitamente. + +### M11. `[bug confirmado]` `eventCount` y `loadingCount` con `untrack` en `cach` + +- Ubicación: [src/arts/cach/active-cache.svelte.ts:55-64](src/arts/cach/active-cache.svelte.ts#L55-L64) +- Severidad: baja-media · Esfuerzo: trivial +- Evidencia: `eventCountCell = untrack(() => eventCountCell) + 1`. Como el + callback `engine.on(CACHE_EVENT_ALL, …)` se invoca desde el motor (no + dentro de un `$derived`/`$effect`), el `untrack` es defensivo pero ruidoso + e induce a los lectores a creer que hay un ciclo reactivo escondido. +- Propuesta: si los tests pasan sin `untrack`, quitarlo. Si hay un caso que + requiere `untrack`, comentar el porqué. + +### M12. `[riesgo]` `Cache.clear()` no aborta promises en vuelo + +- Ubicación: [src/arts/cach/active-cache.svelte.ts:167-170](src/arts/cach/active-cache.svelte.ts#L167-L170) + engine +- Severidad: media · Esfuerzo: medio +- Evidencia: `clear()` se delega a `engine.clear()`. Si una `query()` + estaba en vuelo, su `setCached` posterior puede repoblar la cache que + acaba de ser borrada. Mismo problema que C1, en otro escenario. +- Propuesta: incrementar un `clearGeneration` y descartar resultados de + fetches iniciados antes de la última `clear()`. + +### M13. `[refactor]` `signOut` cliente es optimista pero estado se reescribe sólo si la red OK + +- Ubicación: [src/arts/auth/active-auth.svelte.ts:102-110](src/arts/auth/active-auth.svelte.ts#L102-L110) +- Severidad: media · Esfuerzo: bajo +- Evidencia: la asignación `current = createAnonymousAuthCurrent()` ocurre + *después* del `await options.http.post(SIGN_OUT)`. Si la red falla, el + usuario sigue "authenticated" en la UI aunque la cookie del servidor se + haya eliminado. En cookie-auth puro, una respuesta 5xx puede dejar al + cliente desincronizado. +- Propuesta: dos opciones: (a) limpiar localmente *antes* del POST y + rollback si el server responde 401 confirmando que ya no había sesión; + (b) en el catch, si el error es de red, igual limpiar localmente y dejar + que la próxima `loadCurrent` resuelva el estado real. + +### M14. `[riesgo]` `BroadcastChannel` no parsea `event` ni `generation` + +- Ubicación: [src/arts/sess/engine-session.ts:154-180](src/arts/sess/engine-session.ts#L154-L180) +- Severidad: baja-media · Esfuerzo: bajo +- Evidencia: el listener trata `data?.type !== BROADCAST_TYPE` como guard + de seguridad, lo cual cubre payloads ajenos. Pero si el remitente de la + misma BC envía un `type` correcto pero un `event`/`generation` corrupto, + el código lee `storage` directamente — está bien — pero igual entrega un + `EXTERNAL_CHANGED` con el snapshot persistido, que puede no concordar con + el `event` del mensaje. No produce comportamiento incorrecto pero hace + que `event` y `current` no estén ligados al mensaje recibido. +- Propuesta: como ya se delega en `storage`, ignorar el `event` del + broadcast y simplemente disparar un re-read; el modelo actual hace eso, así + que solo bastaría documentar. + +### M15. `[refactor]` Permisos: `pending` debería re-cuparse al cambiar actor + +- Ubicación: [src/arts/perm/client.ts:140-147 + 360-388](src/arts/perm/client.ts#L140-L388) +- Severidad: media · Esfuerzo: bajo +- Evidencia: `hydrate(snapshot)` y `invalidate(scope)` no tocan `pending`. + Si invalidate corre durante in-flight, los caches `pending` tras la + resolución repoblarán datos que ya no debieran existir. +- Propuesta: `pending.clear()` dentro de `hydrate` e `invalidate(undefined)`, + y filtrar por scope en `invalidate(scope)`. + +### M16. `[bug confirmado]` `aapp` permite varios `connectionRegistries` pero sin aviso + +- Ubicación: [src/arts/aapp/active-app.svelte.ts:200-212](src/arts/aapp/active-app.svelte.ts#L200-L212) +- Severidad: baja-media · Esfuerzo: trivial +- Evidencia: `Sess`, `Permissions` y `Auth` levantan `AlreadyCreated*Error` + si se piden dos veces, pero `createActiveConnections` no. Los tests + `aapp/test` parecen aceptarlo. Inconsistencia con el patrón. +- Propuesta: o documentar explícitamente que `Connections` es multi-instancia + (channels separados) o aplicar la misma regla. + +### M17. `[refactor]` `lang` `void _schemaVersion` como hack reactivo + +- Ubicación: `src/arts/lang/active-lang.svelte.ts` (línea ~63 según + sub-agente) — patrón frágil para forzar lectura reactiva. +- Severidad: media · Esfuerzo: bajo +- Propuesta: documentar el porqué con un bloque comentado, o usar + `$derived.by(() => { schemaVersion; return … })` para que el dev tooling + lo vea explícitamente. + +### M18. `[riesgo]` `dispose()` orden en `aapp` no detiene timers in-flight + +- Ubicación: [src/arts/aapp/active-app.svelte.ts:253-276](src/arts/aapp/active-app.svelte.ts#L253-L276) +- Severidad: media · Esfuerzo: bajo +- Evidencia: el orden parece intencional pero no se documenta. `Cache.dispose()` + se llama antes que `Timers.dispose()`. Si la cache tiene un timer + programado en `Timers`, ese timer queda suelto hasta que se dispose + `Timers`. Como `Timers.dispose()` cancela todos, el efecto neto es + correcto en este orden, pero invertir destruiría la cache primero y + podría disparar un last-tick. Mantener el orden y documentarlo. +- Propuesta: comment de cabecera con la regla `consumers → providers`. + +--- + +## Hallazgos menores + +### m1. `[docs]` Inconsistencias entre `arts/README.md` y READMEs por artefacto + +- `arts/README.md:51` dice de `logr`: "Structured logger: levels, transports, + filters, vitals, dispose". `logr/README.md` debe explicitar igual y + alinear el lenguaje (algunos READMEs llaman a `transport` "adapter"). + +### m2. `[docs]` `fmts/README.md` no aclara que `createRates(...)` es demo + +- `fmts` documenta currency conversion pero no explicita que el rate provider + es responsabilidad del consumidor. + +### m3. `[docs]` `cach/README.md` no documenta qué pasa si el `fetcher` lanza + +- ¿Se marca la entrada como error? ¿Se conserva `data` previa con `status: + ERROR`? El código (active-cache.svelte.ts:254-258) lo hace, pero no está + en docs. + +### m4. `[refactor]` Magic strings de marca "asoma" en cookies + +- `src/libs/auth/consts.ts:54-57, 77-78` hardcodea "asoma". Para un + framework reutilizable, conviene `BRAND_NAME` configurable y derivar + cookie names. + +### m5. `[simplificación]` `mapSendToJoinResult` en `conn/channel.ts:42-52` + +- Mapeo trivial; inline o usar `as const` table. + +### m6. `[refactor]` `helpers.ts` y `consts.ts` con cientos de identifiers en algunos artefactos + +- `auth/consts.ts` y `sess/consts.ts` exportan ≈80 constantes cada uno. + Considerar agrupar en namespaces (`AUTH_METHODS`, `AUTH_HEADERS`, ya hecho + parcialmente) y reducir el surface por named import. + +### m7. `[docs]` `arts/README.md` Map menciona `EngineSium` pero no `ActiveSium` + +- Verificar que `sium` realmente no expone una versión Active. Si así es, + documentar que `sium` es un caso especial (engine-only); ya está + contemplado pero la fila no lo deja claro. + +### m8. `[simplificación]` `TimerKey` interno en `conn` duplica conceptos de `timr` + +- `connection.ts:316-330` (según sub-agente) maneja + `scheduleTimer`/`scheduleInterval` con keys propias. Ya tiene `timr` con + `(id, key, version)`. Posible delegación. + +### m9. `[docs]` Dispose contract no está formalizado en cada README + +- `arts/README.md` dice "dispose() es idempotente". Algunos READMEs (sess, + cach, auth) repiten la garantía; otros no. Estandarizar línea boilerplate. + +### m10. `[refactor]` `aapp/integrations/frontend-storage` exporta nombre + largo + tres helpers que se usan solo desde `active-app.svelte.ts` + +- Considerar inline o convertir en method privado del `ActiveApp`. + +### m11. `[simplificación]` `Logger.dispose()` cierra y vacía pero no expone snapshot + +- A diferencia de otros, `EngineLogger` no tiene `snapshot()`/`onChange`. OK + porque no implementa `ActiveEngine`. Documentar que es intencional. + +### m12. `[docs]` `arts/conn/DESIGN_CONN.md` y `arts/timr/DESIGN_TIMR.md` y + `arts/sess/DESIGN.md` viven solo en sus carpetas + +- Considerar enlazarlos desde `arts/README.md` para visibilidad. Los + decisivos no se ven a menos que el lector navegue. + +### m13. `[test]` `aapp/test` (5 archivos) cubre composición pero no orden de + dispose + +- Añadir test que verifique que disposal corre `consumers → providers`. + +### m14. `[bug confirmado]` `Sentry DSN` queda en `sessionStorage` del test page + +- `web/routes/test/logr/+page.svelte` guarda DSN en sessionStorage; al + navegar entre tests, persiste. Privacidad/uso accidental en producción. + +### m15. `[docs]` SSR contract per-artefacto + +- `timr`, `conn`, `adom`, `fend` no documentan explícitamente SSR. Una + sección "SSR considerations" por artefacto evitaría sorpresas. + +--- + +## Refactorizaciones recomendadas + +1. **Centralizar invalidación cross-artefacto**. Un `IdentityChannel` + (probablemente extensión de `sessionBridge`) al que `Cache` y + `Permissions` se suscriban. Hoy `aapp` suelta listeners ad hoc y mezcla + responsabilidades. +2. **Extraer `validateOptionalField`** del engine de sess; aparece 8 veces. +3. **Centralizar comparaciones por `JSON.stringify`** en un `equalsByJson` + en `libs/objs/`. Hoy aparece en sess y stor. +4. **Mover `setCached` a un helper `cacheKeyAtTime(input, scope)`** en perm + para fijar la scope-key al inicio del check (cierra C1). +5. **Unificar el patrón de listeners por función estable.** `stor`, `cach` y + `conn` lo hacen distinto; converger a "el caller guarda el detacher". +6. **Romper la dependencia `aapp ← stor`** en mensajes/logger category; + `stor` debe exponer su propio helper. +7. **Partir `auth/consts.ts`** en sub-archivos por dominio (cookies, methods, + events, errors). Importar lo que se usa, no cargar 80 constantes por + módulo. +8. **Documentar adapter contract** (auth/store) y separar `findFlowForUpdate` + de `findFlowByStateHash`. +9. **Pulir el README de `arts/`** para añadir leyenda "Adapters", "Hooks", + "SSR" y enlazar los `DESIGN_*.md`. + +--- + +## Simplificaciones recomendadas + +1. **Eliminar `untrack` defensivos** en `cach` que no responden a un caso + concreto (M11). +2. **Inline `mapSendToJoinResult`** y `mergeHeaders` cuando se usen una vez. +3. **Reducir el surface de `Cache.snapshot()`**: hoy expone `lastEvent`, + `eventCount`, `loading`, `lastError`, `disposed`. ¿Qué consumidor real + usa `eventCount`? Si solo lo usa el test page, mover a un helper de + debug. +4. **Unificar nombres**: `loading` vs `loadingCount`, `lastError` vs + `errorCell`, `current` vs `snapshot()`. La regla "loading siempre boolean, + lastError siempre `TError | null`" ya está en el README; aplicarla en los + internals. +5. **Rebajar `mono-lang` a un export de funciones**, no un Active completo — + hoy implementa `ActiveLang` solo para el cast. Se podría aceptar `null` + en `aapp.Lang` y guardarlo detrás de un proxy. +6. **Quitar el wrapper `safeParse`** del test page de http; el patrón + "intenta JSON.parse con fallback string" es trivial y oculta errores. +7. **Devolver el detacher de `onChange`** en `EngineLogger` para alinearse + con el resto, aunque hoy no haya listeners. + +--- + +## Optimizaciones recomendadas + +1. **Permisos**: cachear `decisionKey` por scope al inicio del check (resuelve + C1 y mejora rendimiento en aplicaciones con muchas checks por evento). +2. **HTTP retries**: cachear el body serializado *y* los headers cuando no + cambian entre intentos (M10). +3. **Storage `read()`**: comparar `prev === next` por `Object.is` antes de + dispatch — evita re-render en cadena cuando un setItem coincide con el + valor actual. +4. **Cache `mergeDefaults`** evita recomputar `JSON.stringify(defaults)` + cada lectura. Si se cumple igualdad estructural, dedupe. +5. **`SvelteMap`/`SvelteSet`** en `aapp` (`sessionBridgeListeners`, + `connectionRegistries`) están bien marcados como no-reactivos, pero hay + sitios en `stor` (`userSubs`) y `perm` (`pending`) donde plain `Map` es + suficiente — sub-agente reportó que algunos son `SvelteMap`. Verificar + y bajar a Map donde no haya consumo en templates. +6. **Compactar `vitals.ts` config factories** (logr) — patrón repetido + `levelsAtLeast(...)` en cada transport. +7. **`fmts` Currency cache**: `Map + JSON.stringify(options)` por entrada + produce keys grandes; un `Map>>` + es más rápido y barato. + +--- + +## Incoherencias de arquitectura + +1. **`aapp` sabe demasiado de `stor`**. Importa `LOGGER_CATEGORY` y un + message builder de stor. La capa de composición debería ser ciega al + formato de los errores de los proveedores. +2. **`auth` cliente y server compartidos vía `libs/auth`** — bien, pero + `helpers.ts` (cliente) llama a tags que solo usa `aapp`. Mover a `aapp` + o a `libs/svrs/auth`. +3. **`cach` cliente vive en `arts/cach` pero el engine real está en + `svrs/cach`**. El active es un wrapper. Coherente con el patrón + "auth/perm/cach se parten en svrs+arts" — pero el README de `arts/cach` + no menciona la dependencia explícita a `$svrs/cach`. Confuso para un + nuevo dev. +4. **`AuthRateLimitPort` en `svrs/auth/rate-limit.ts` exportado pero no + integrado** (C7). Rompe la promesa "todos los puertos usados". +5. **Memory adapter en `svrs/auth/adapters/memory.ts` no marcado como + tests-only** (C8). Coherencia con expectativa producción/test. +6. **`Sess` exige `App.createActiveSession` como factory una sola vez**, pero + `Connections` no (M16). Inconsistencia. +7. **`mono-lang` rompe la garantía de tipo**. Cast `as unknown as + ActiveLang` significa que el tipo del `App.Lang` no es de fiar. + Coherencia con el contrato "App.Lang siempre tipado por schema". +8. **Constantes de "categoría logger"** son strings cortos por artefacto + (`'sium'`, `'sess'`, `'auth.client'`, `'cache'`). El propio `aapp.ts` + incluye `auth.client` y `cache` con punto, mientras `sess` es plano. + Convención no documentada. + +--- + +## Tests faltantes + +### Críticos + +- **`arts/perm/test`** (188 LOC, 1 archivo): tests para C1 (race + cross-actor), `invalidate(scope)` con scope correcto/incorrecto, dedup de + `pending` con error y reintento. +- **`arts/cach/test`** (120 LOC, 1 archivo): TTL expiry, stale-while-revalidate + con error en fetcher, race entre `set` y `query`, integración con + `$stor`. +- **`arts/auth/test`** (161 LOC, 1 archivo): CSRF flow completo (rechazo si + cookie/token no coinciden, expiración), sign-out con red caída (M13), + `requestPasswordReset` y `completePasswordReset`, `revokeDevice`. +- **`svrs/auth/test`** (3 archivos): refresh rotation reuse window, OAuth + state-hash collision, MFA challenge expirado, rate-limit (cuando se + cablee). + +### Importantes + +- **`arts/conn/test`** (2 archivos): WebSocket transport mockeado, ack + timeout, reconnect con backoff, disposal idempotente. +- **`arts/fmts/test`** (28 LOC) y **`arts/fend/test`** (63 LOC): casi vacíos. + Cubrir locale switching, currency rounding, dir auto-derivation. +- **`arts/timr/test`** (3 archivos): backoff formula, scope cancellation, + `awaitTask:false` fire-and-forget. +- **`arts/aapp/test`** (5 archivos): orden de disposal, idempotencia, doble + factory. +- **`arts/adom/test`** (5 archivos): roving focus keyboard, viewport debounce, + scroll lock multi-claim. + +### Edge cases + +- Sess: `expiresAt - issuedAt < 1`, `generation > Number.MAX_SAFE_INTEGER`, + refresh y revoke concurrentes. +- HTTP: Retry-After con segundos vs HTTP-date, abort en mitad de retry, + `bodySchema` y `schema` en conflicto. +- Stor: cuota excedida, envelope corrupto, migrate fallido en cadena. + +--- + +## Preguntas abiertas + +1. **¿Qué propiedades de "scope" debería tener `cach.invalidate({tags})` + cuando se llama desde `aapp` por evento de auth?** Ahora se pierde por + `Cache.clear()`. ¿Decisión consciente o pendiente? +2. **¿Es `mono-lang` parte estable del API público o un fallback interno?** + El cast unsafe sugiere lo segundo, pero `index.ts` lo exporta. +3. **¿Cuál es la promesa de "Active" en cuanto a SSR?** `arts/README.md` + dice "lives in `.svelte.ts` because it owns `$state`" pero no aclara qué + funciones son seguras en `+page.server.ts`. Hay implementaciones con + guardas (`fend`, `stor`) y otras sin (`logr` con `beforeunload`). ¿Cuál + es la regla? +4. **¿`AuthRateLimitPort` queda fuera del MVP?** Si sí, no exportar en el + barrel para evitar la falsa impresión. +5. **¿Memory adapters de `svrs/auth/cach/perm` están pensados para + producción multi-instancia?** Si no, marcarlos. +6. **`Cache.clear()` durante una `query()` en vuelo: ¿debería abortar la + query?** (M12). Decisión semántica. +7. **`Sess.dispose()` durante un `refresh()` en vuelo**: ¿la promesa + resuelve con `SessDisposedError` o con `SKIPPED`? +8. **¿`hydrate(snapshot)` en perm debe abortar `pending`?** (M15). + +--- + +## Veredicto + +**Lo sólido** + +- Convenciones del framework: `ActiveEngine`, factories `createEngineXxx` / + `createActiveXxx`, dispose idempotente, no magic strings (en su mayoría), + named exports, sin barrels con `export *`. Esto es difícil de mantener a + escala y se nota el cuidado. +- `lang`, `logr`, `sium`, `stor`, `sess` están en muy buen estado, con + tests serios (≥700 LOC cada uno) y READMEs alineados. +- `timr` (locked-in design) y `http` están limpios y bien encapsulados. +- Las decisiones documentadas en MEMORY.md (sess actor extension, + `App.createSiumEngine` zero-arg, no `App.Stores`) están correctamente + reflejadas en el código. + +**Lo que frena la calidad** + +- La integración auth/perm/cach está a medias: `aapp` tira de un cordel + fácil (`Cache.clear()`) en vez de cablear bien identidad → cache → permisos. + El resultado es un comportamiento conservador pero inseguro en bordes + (C1, C2, C3). +- Server-authoritative auth tiene gaps importantes en producción: sin rate + limiting (C7), MFA stub (C5), PKCE no validado server-side (C6), memory + adapter sin warning (C8). +- Cobertura de tests muy desigual: lo más crítico (auth, perm, cach, fmts, + fend) es lo menos cubierto. +- Pequeños bugs de ergonomía dispersos: nombres de método incorrectos en + `ensureLive` (C4), `untrack` defensivos sin documentar, casts forzados que + ocultan semánticas reales. + +**Orden de actuación sugerido** + +1. **Sprint de seguridad operativa** (1-2 semanas): + - Cablear `AuthRateLimitPort` en sign-in/sign-up/reset/oauth (C7). + - Pasar el `verifier` PKCE al provider y validarlo server-side (C6). + - Marcar memory adapters como dev/test only en README + warning runtime (C8). + - Documentar SECURITY.md con el flujo completo (CSRF, OAuth state, + refresh rotation, MFA). + - Cambiar SameSite default CSRF a `strict` (M2). + +2. **Sprint de wiring de identidad** (1 semana): + - Cerrar C1 (race en perm). + - Cerrar C2 (perm.invalidate al cambiar identidad). + - Cerrar C3 (cach.invalidate respeta tags). + - M12 (Cache.clear con generation guard). + - M15 (perm.hydrate/invalidate aborta pending). + +3. **Sprint de pulido** (1 semana): + - C4 (constantes de método correctas). + - M4 (extraer `validateOptionalField` en sess). + - M5/M11 (limpiar coupling y untrack defensivos). + - C5: o implementar MFA verify, o quitarlo del export. + - Sub-archivos en `auth/consts.ts`. + +4. **Sprint de tests** (≥1 semana, dependiendo de la profundidad): + - Subir cobertura de `auth/test`, `perm/test`, `cach/test`, `fmts/test` + y `fend/test` al nivel de `sium/test` y `stor/test`. + +Después de eso el framework estaría sólido y listo para usuarios externos. +Antes, el escaparate (lang/logr/sium/sess/stor) no refleja el estado real +de los flancos de seguridad. + +--- + +## Falsos positivos descartados + +(Reportados por sub-agentes y verificados como incorrectos al leer el código.) + +- **PKCE construcción incorrecta** (`oauth/pkce.ts`). El sub-agente afirmó + que `hash(verifier)` no era SHA256/base64url. Verificado: `hashAuthToken` + es `base64URL(sha256(token))`, lo cual es exactamente la transformación + S256 de RFC 7636. La queja real es C6 (no se valida en callback), no la + construcción. +- **Refresh rotation no transaccional**. Verificado: `findRefreshTokenForUpdate` + + `rotateRefreshToken` están diseñados para correr atómicamente — el + contrato lo asume y un adapter SQL real lo implementa. La queja real es + C8 (memory adapter no documentado como inseguro). +- **`stateHash as AuthFlowId` permite cualquier hash**. Verificado: el store + tiene fallback explícito de búsqueda por stateHash; tipos sufren pero no + hay bypass de seguridad. La queja válida es M3 (separar la API). +- **Test directories vacíos** (conn, perm, etc.). Verificado: todos tienen + ≥1 archivo. La queja real es la cobertura desigual, no la ausencia. +- **`adoptServer` SSR safety**. El sub-agente sugirió listener leak; el + código (engine-session.ts) protege con guards `typeof BroadcastChannel`. +- **`storage.adapter.removeItem` con `null`**. Reportado como riesgo; en + realidad la API es estándar `Storage` y removeItem(key) sin valor. diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md new file mode 100644 index 0000000..ce091b4 --- /dev/null +++ b/NEXT_STEPS.md @@ -0,0 +1,17 @@ +# Next Steps + +Estado al cierre: + +- Suite unitaria verde: `npm test` -> 103 archivos, 1189 tests. +- `fmts` verde: `npx vitest run src/arts/fmts` -> 14 archivos, 39 tests. +- No tocar `src/web/routes/temp/` hasta decidir que hacer con esa pagina. +- No commitear `.idea/`, `.claude/` ni `.opencode/`. + +Pendiente para manana: + +- Ejecutar una pasada completa sobre `/test/ecosystem` en navegador y corregir cualquier fallo real de integracion. +- Revisar la adopcion final del contrato comun `Logger` / diagnostics en todos los modulos, sin acoplar artefactos a `arts/logr`. +- Continuar la reduccion de archivos grandes y boilerplate: prioridad `conn`, `auth`, `cach` y cualquier wrapper activo repetitivo. +- Ampliar tests de integracion cruzada: `auth + sess + perm + cach + http + stor + fmts + conn + timr + logr`. +- Revisar documentacion raiz de `arts`, `aapp`, `auth`, `cach`, `perm`, `conn` y `fmts` para que refleje el estado real del framework. +- Decidir que hacer con la pagina temporal que bloquea `npm run check`; mientras tanto, validar con `npm test` y tests focalizados. diff --git a/src/arts/README.md b/src/arts/README.md index bf51919..46954ef 100644 --- a/src/arts/README.md +++ b/src/arts/README.md @@ -43,6 +43,67 @@ Conventions: `ActiveConnections.connection()`, etc.) own those entries and dispose them when the root is disposed. +## Logger And Diagnostics Contract + +Every artifact that emits runtime information follows the same two-layer +contract: + +```ts +import type { DiagnosticEvent, Diagnostics, Logger } from '$libs/logr'; +``` + +- Public options use `logger?: Logger`. Do not create artifact-local logger + interfaces such as `LangLogger`, `TimerLogger` or `ConnectionLogger`. +- The root logger implementation is `EngineLogger` from `$logr`; it extends the + shared `Logger` contract from `$libs/logr`. +- Artifact code defines `Diagnostics` with + `createDiagnostics(logger?)` and emits catalogued events for + internal diagnostics. +- Diagnostic event names live in the artifact `consts.ts` as + `*_DIAGNOSTIC_EVENTS`. Messages live in `errors.ts` or `consts.ts`, never as + inline strings in runtime logic. +- `Diagnostics` always exposes `{ logger, emit(event) }`. The `logger` + property is the common `Logger`, so modules that need an ad-hoc `info` or + `error` still have the full logger without inventing a second interface. +- Level routing is controlled by the logger/transports via the existing + per-level enablement map, not by module-specific severity systems. + +Typical shape: + +```ts +export const HTTP_DIAGNOSTIC_EVENTS = { + REQUEST: 'http.request', + NETWORK_ERROR: 'http.network_error' +} as const; + +export function createHttpDiagnostics(logger?: Logger): HttpDiagnostics { + return createCatalogDiagnostics({ + logger, + defaultCategory: LOGGER_CATEGORY, + catalog: HTTP_DIAGNOSTIC_LOGS + }); +} +``` + +This gives every module the same path to Sentry, Loki, Datadog, console, +test-capture transports or any future sink: inject one `Logger`, emit typed +diagnostic events, let `logr` route. + +## Error Contract + +Errors follow the same rule: strings are centralized, and public programmer +errors are typed. + +- Error messages and error names live in `errors.ts` or `consts.ts`. +- Runtime code must not throw inline string/template errors outside tests or + vendored code. +- Public programmer errors use artifact-specific classes and guards: + `SessDisposedError`, `ConnInvalidNameError`, `UnitsUnknownUnitError`, etc. +- Expected runtime failures should be returned as tagged data/results when the + artifact already has such a contract (`http`, `conn`, `perm`, `cach`). +- Validation failures are data (`SiumValidationError.issues`) and diagnostics + are emitted separately when a logger is injected. + ## Map | Artifact | Layer(s) | Purpose | Depends on | diff --git a/src/arts/aapp/README.md b/src/arts/aapp/README.md index 9344492..a204e0d 100644 --- a/src/arts/aapp/README.md +++ b/src/arts/aapp/README.md @@ -36,7 +36,7 @@ App.dispose(); | `App.Formats` | yes | real, locale = `DEFAULT_LOCALE` (`'en-US'`) | | `App.Frontend` | yes | real with default theme/mode/density | | `App.Dom` | yes | real with default breakpoints | -| `App.Storage` | yes | in-memory adapter (resets on reload). Configure `storage: { adapter: localAdapter }` for real persistence; `onError` is wired through `Logger.error('storage', ...)` | +| `App.Storage` | yes | in-memory adapter (resets on reload). Configure `storage: { adapter: localAdapter }` for real persistence; storage diagnostics are wired through the shared Logger | | `App.Http` | yes | engine default — `globalThis.fetch`, no `baseUrl`, idempotent-by-default retry, 10s per-attempt timeout. The shared `Logger` is wired automatically; configure `http: { baseUrl, timeout, retry }` | | `App.Timers` | yes | `ActiveTimers` scheduler owned by App. Used by artifacts that need keyed runtime timers (`sess` auto-refresh, `conn` reconnect/heartbeat/ack) and disposed by `App.dispose()` | | `App.Cache` | yes | `ActiveCache` backed by memory by default. Configure `cache: { adapter, policies, scopeResolver }` for persistence, custom policies or tenant/actor/permission-aware keys | @@ -112,7 +112,7 @@ option. provided; mono otherwise. Both wire `Lang.setLogger` to the shared Logger. 3. **Storage** — built next so Frontend can read persisted preferences - before construction. Storage `onError` is wired to `Logger.error`. + before construction. Storage diagnostics are wired to the shared Logger. 4. **Formats** — built with a `localeSource` derived from Lang. 5. **Dom** — built before Frontend. 6. **Frontend** — receives Dom and the same `localeSource`. When @@ -303,10 +303,10 @@ const locale = App.Storage.entry('locale', 'es', { }); ``` -Storage `onError` is wired automatically: failures land in -`App.Logger.error('storage', ...)` with `{ adapter, key, op, error }` in the -context. See `$stor/README.md` for the full API (adapters, envelope, -versioning, validation). +Storage diagnostics are wired automatically through `StorageDiagnostics` and +the shared `App.Logger`; failures include `{ adapter, key, fullKey, op, error }` +in the diagnostic context. See `$stor/README.md` for the full API (adapters, +envelope, versioning, validation). ### Reactive keys diff --git a/src/arts/aapp/active-app.svelte.ts b/src/arts/aapp/active-app.svelte.ts index bfdce80..5c55e8f 100644 --- a/src/arts/aapp/active-app.svelte.ts +++ b/src/arts/aapp/active-app.svelte.ts @@ -6,6 +6,7 @@ import { type ActiveAuthOptions } from '$auth'; import { createActiveCache } from '$cach'; +import { CACHE_SCOPE_PUBLIC } from '$libs/cach'; import { createActiveConnections as createActiveConnectionsRegistry } from '$conn'; import type { ActiveConnections, ActiveConnectionsOptions, ConnectionMap } from '$conn'; import { createActiveFrontend } from '$fend'; @@ -28,11 +29,7 @@ import { type EngineSessionOptions } from '$sess'; import { createEngineSium } from '$sium'; -import { - createActiveStorage, - LOGGER_CATEGORY as STORAGE_LOGGER_CATEGORY, - type StorageErrorContext -} from '$stor'; +import { createActiveStorage } from '$stor'; import { createActiveTimers } from '$timr'; import { @@ -44,8 +41,7 @@ import { APP_ERROR_ALREADY_CREATED_PERMISSIONS, APP_ERROR_ALREADY_CREATED_AUTH, APP_ERROR_ALREADY_CREATED_SESSION, - APP_ERROR_CREATE_PERMISSIONS_ENDPOINT_REQUIRED, - APP_STORAGE_ERROR_MESSAGE + APP_ERROR_CREATE_PERMISSIONS_ENDPOINT_REQUIRED } from './consts.ts'; import { AappAlreadyCreatedError } from './errors.ts'; import type { ActiveApp, ActiveAppOptions } from './types.ts'; @@ -74,12 +70,7 @@ export function createActiveApp( const Storage = createActiveStorage({ adapter: options.storage?.adapter, namespace: options.storage?.namespace, - onError: (ctx: StorageErrorContext) => { - Logger.error(STORAGE_LOGGER_CATEGORY, APP_STORAGE_ERROR_MESSAGE(ctx.op, ctx.fullKey), { - error: ctx.error, - context: { adapter: ctx.adapter, key: ctx.key } - }); - } + logger: Logger }); const localeSource = { @@ -234,16 +225,29 @@ export function createActiveApp( return built; }, - createActiveAuth(authOptions: Omit = {}): ActiveAuth { + createActiveAuth( + authOptions: Omit = {} + ): ActiveAuth { if (Auth !== undefined) { throw new AappAlreadyCreatedError(APP_ERROR_ALREADY_CREATED_AUTH); } const built = createActiveAuth({ ...options.auth, ...authOptions, + logger: Logger, http: createEngineHttpAuthClient(Http), cach: { - invalidate: () => Cache.clear() + async invalidate(input) { + Permissions?.invalidate(); + await Promise.all( + input.tags.map((tag) => + Cache.invalidate({ + tag, + scope: CACHE_SCOPE_PUBLIC + }) + ) + ); + } } }); Auth = built; diff --git a/src/arts/aapp/consts.ts b/src/arts/aapp/consts.ts index 5d46cbb..b247f94 100644 --- a/src/arts/aapp/consts.ts +++ b/src/arts/aapp/consts.ts @@ -2,9 +2,6 @@ export const LOGGER_CATEGORY = 'app'; export const APP_ERROR_NAME_ALREADY_CREATED = 'AappAlreadyCreatedError'; -export const APP_STORAGE_ERROR_MESSAGE = (op: string, fullKey: string): string => - `${op} on "${fullKey}"`; - export const APP_ERROR_ALREADY_CREATED_SESSION = '[aapp] App.createActiveSession() called twice — only one session per App.'; diff --git a/src/arts/aapp/test/active-app.test.ts b/src/arts/aapp/test/active-app.test.ts index 663a342..b7ed878 100644 --- a/src/arts/aapp/test/active-app.test.ts +++ b/src/arts/aapp/test/active-app.test.ts @@ -20,13 +20,28 @@ import { LogLevel, type LogEntry } from '$logr'; import type { LangNode } from '$lang'; import { MONO_LANG_CATEGORY } from '$lang/mono-lang.svelte'; import { createMockTransport } from '$conn'; -import { AUTH_AAL, AUTH_SESSION_STATUSES } from '$libs/auth'; +import { CACHE_EVENT_INVALIDATE } from '$libs/cach'; +import { + AUTH_AAL, + AUTH_CACHE_TAGS, + AUTH_HEADER_NAMES, + AUTH_ROUTE_PATHS, + AUTH_SESSION_STATUSES +} from '$libs/auth'; +import { PERMISSION_EFFECT_ALLOW } from '$libs/perm'; const schema = { greeting: { es: 'Hola', en: 'Hello', 'es-MX': 'Qué onda' }, cart: { es: 'Carrito', en: 'Cart' } } satisfies LangNode; +function json(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { [AUTH_HEADER_NAMES.CONTENT_TYPE]: 'application/json' } + }); +} + describe('createActiveApp — composition', () => { it('exposes Logger, Lang, Formats, Frontend, Dom (no Sium)', () => { const App = createActiveApp({ @@ -156,6 +171,57 @@ describe('createActiveApp — composition', () => { expect(App.Auth).toBeUndefined(); }); + it('auth identity changes invalidate permissions and auth cache tags', async () => { + const invalidatedTags: string[] = []; + const fetcher = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const path = new URL(url, 'https://active.test').pathname; + if (path === AUTH_ROUTE_PATHS.CSRF) return json({ token: 'csrf-token', expiresAt: 1 }); + if (path === AUTH_ROUTE_PATHS.SIGN_IN_PASSWORD) { + return json({ + current: { + session: { + status: AUTH_SESSION_STATUSES.AUTHENTICATED, + aal: AUTH_AAL.SINGLE_FACTOR, + amr: ['pwd'] + } + } + }); + } + if (path === '/permissions/check') { + return json({ effect: PERMISSION_EFFECT_ALLOW, policy: 'remote.allow' }); + } + return new Response(null, { status: 404 }); + }) as typeof fetch; + const App = createActiveApp({ + logger: { level: LogLevel.NONE, transports: [] }, + http: { baseUrl: 'https://active.test', fetch: fetcher }, + cache: { + onEvent(event) { + if (event.type === CACHE_EVENT_INVALIDATE) invalidatedTags.push(...(event.tags ?? [])); + } + } + }); + const Permissions = App.createActivePermissions({ + endpoint: 'https://active.test/permissions' + }); + const Auth = App.createActiveAuth(); + + await Permissions.check({ action: 'post.read', resource: { type: 'post', id: 'p1' } }); + expect(Permissions.size).toBe(1); + + await Auth.signInPassword({ identifier: 'ada@example.com', password: 'correct horse' }); + + expect(Permissions.size).toBe(0); + expect(invalidatedTags).toEqual([ + AUTH_CACHE_TAGS.AUTH_CURRENT, + AUTH_CACHE_TAGS.AUTH_DEVICES, + AUTH_CACHE_TAGS.AUTH_FACTORS + ]); + + App.dispose(); + }); + it('dispose() flushes buffered transports', () => { const writes: string[] = []; const App = createActiveApp({ diff --git a/src/arts/aapp/types.ts b/src/arts/aapp/types.ts index 9aeaccf..b27f9d3 100644 --- a/src/arts/aapp/types.ts +++ b/src/arts/aapp/types.ts @@ -136,7 +136,7 @@ export interface ActiveAppOptions { * automatically; the authoritative auth engine still lives server-side * under `$svrs/auth`. */ - auth?: Omit; + auth?: Omit; } /** @@ -252,7 +252,7 @@ export interface ActiveApp { * reflects `/api/auth/*` state, sends CSRF headers and exposes pending / * error state for UI. */ - createActiveAuth(options?: Omit): ActiveAuth; + createActiveAuth(options?: Omit): ActiveAuth; /** * The active session, when one has been built via diff --git a/src/arts/auth/active-auth.svelte.ts b/src/arts/auth/active-auth.svelte.ts index ceca6da..8c7ba60 100644 --- a/src/arts/auth/active-auth.svelte.ts +++ b/src/arts/auth/active-auth.svelte.ts @@ -2,11 +2,13 @@ import { AUTH_ERROR_CODES, AUTH_EVENT_NAMES, AUTH_HEADER_NAMES, + AUTH_CLIENT_DIAGNOSTIC_EVENTS, AUTH_METHOD_CLEAR_ERROR, AUTH_METHOD_COMPLETE_EMAIL_VERIFICATION, AUTH_METHOD_COMPLETE_PASSWORD_RESET, AUTH_METHOD_LIST_DEVICES, AUTH_METHOD_LOAD_CURRENT, + AUTH_METHOD_ON_CHANGE, AUTH_METHOD_REQUEST_EMAIL_VERIFICATION, AUTH_METHOD_REQUEST_PASSWORD_RESET, AUTH_METHOD_REVOKE_DEVICE, @@ -21,6 +23,7 @@ import { } from './consts.ts'; import { authCacheTagsForIdentity, createAnonymousAuthCurrent } from '$libs/auth/helpers'; import { AuthDisposedError, AuthInvalidResponseError, isAuthRequestFailedError } from './errors.ts'; +import { createAuthClientDiagnostics, emitAuthClientDiagnostic } from './diagnostics.ts'; import type { AuthClientSafeError, AuthCurrentView, @@ -40,6 +43,7 @@ import type { } from './types'; export function createActiveAuth(options: ActiveAuthOptions): ActiveAuth { + const diagnostics = createAuthClientDiagnostics(options.logger); let current = $state(options.initial ?? createAnonymousAuthCurrent()); let loading = $state(false); let lastError = $state(null); @@ -75,7 +79,7 @@ export function createActiveAuth(options: ActiveAuthOptions): ActiveAuth { AUTH_METHOD_SIGN_IN_PASSWORD ); current = result.current; - await invalidateClientCache(options, AUTH_EVENT_NAMES.SIGN_IN_SUCCEEDED); + await invalidateClientCache(options, diagnostics, AUTH_EVENT_NAMES.SIGN_IN_SUCCEEDED); notify(); return current; }); @@ -93,7 +97,7 @@ export function createActiveAuth(options: ActiveAuthOptions): ActiveAuth { AUTH_METHOD_SIGN_UP_PASSWORD ); current = result.current; - await invalidateClientCache(options, AUTH_EVENT_NAMES.SIGN_UP_SUCCEEDED); + await invalidateClientCache(options, diagnostics, AUTH_EVENT_NAMES.SIGN_UP_SUCCEEDED); notify(); return current; }); @@ -104,7 +108,7 @@ export function createActiveAuth(options: ActiveAuthOptions): ActiveAuth { const csrf = await ensureCsrf(options); await options.http.post(AUTH_ROUTE_PATHS.SIGN_OUT, undefined, csrfHeaders(csrf)); current = createAnonymousAuthCurrent(); - await invalidateClientCache(options, AUTH_EVENT_NAMES.SIGN_OUT_SUCCEEDED); + await invalidateClientCache(options, diagnostics, AUTH_EVENT_NAMES.SIGN_OUT_SUCCEEDED); notify(); }); } @@ -114,7 +118,7 @@ export function createActiveAuth(options: ActiveAuthOptions): ActiveAuth { const csrf = await ensureCsrf(options); await options.http.post(AUTH_ROUTE_PATHS.SIGN_OUT_GLOBAL, undefined, csrfHeaders(csrf)); current = createAnonymousAuthCurrent(); - await invalidateClientCache(options, AUTH_EVENT_NAMES.SIGN_OUT_GLOBAL_SUCCEEDED); + await invalidateClientCache(options, diagnostics, AUTH_EVENT_NAMES.SIGN_OUT_GLOBAL_SUCCEEDED); notify(); }); } @@ -131,7 +135,7 @@ export function createActiveAuth(options: ActiveAuthOptions): ActiveAuth { const csrf = await ensureCsrf(options); await options.http.post(AUTH_ROUTE_PATHS.PASSWORD_RESET_COMPLETE, input, csrfHeaders(csrf)); current = createAnonymousAuthCurrent(); - await invalidateClientCache(options, AUTH_EVENT_NAMES.PASSWORD_RESET_COMPLETED); + await invalidateClientCache(options, diagnostics, AUTH_EVENT_NAMES.PASSWORD_RESET_COMPLETED); notify(); }); } @@ -152,7 +156,7 @@ export function createActiveAuth(options: ActiveAuthOptions): ActiveAuth { const csrf = await ensureCsrf(options); await options.http.post(AUTH_ROUTE_PATHS.EMAIL_VERIFY_COMPLETE, input, csrfHeaders(csrf)); await loadCurrent(); - await invalidateClientCache(options, AUTH_EVENT_NAMES.EMAIL_VERIFIED); + await invalidateClientCache(options, diagnostics, AUTH_EVENT_NAMES.EMAIL_VERIFIED); }); } @@ -170,7 +174,7 @@ export function createActiveAuth(options: ActiveAuthOptions): ActiveAuth { const csrf = await ensureCsrf(options); await options.http.post(AUTH_ROUTE_PATHS.DEVICE_REVOKE, input, csrfHeaders(csrf)); await loadCurrent(); - await invalidateClientCache(options, AUTH_EVENT_NAMES.DEVICE_REVOKED); + await invalidateClientCache(options, diagnostics, AUTH_EVENT_NAMES.DEVICE_REVOKED); }); } @@ -182,6 +186,10 @@ export function createActiveAuth(options: ActiveAuthOptions): ActiveAuth { return await operation(); } catch (error) { lastError = normalizeClientError(error); + emitAuthClientDiagnostic(diagnostics, AUTH_CLIENT_DIAGNOSTIC_EVENTS.OPERATION_FAILED, { + method, + error + }); throw error; } finally { loading = false; @@ -230,7 +238,7 @@ export function createActiveAuth(options: ActiveAuthOptions): ActiveAuth { return current; }, onChange(listener) { - ensureLive(AUTH_METHOD_LOAD_CURRENT); + ensureLive(AUTH_METHOD_ON_CHANGE); listeners.add(listener); listener(current); return () => { @@ -274,12 +282,18 @@ function normalizeClientError(error: unknown): AuthClientSafeError { async function invalidateClientCache( options: ActiveAuthOptions, + diagnostics: ReturnType, reason: AuthEventName ): Promise { if (!options.cach) return; try { await options.cach.invalidate({ tags: authCacheTagsForIdentity(), reason }); } catch (error) { + emitAuthClientDiagnostic( + diagnostics, + AUTH_CLIENT_DIAGNOSTIC_EVENTS.CACHE_INVALIDATION_FAILED, + { reason, error } + ); options.onCacheError?.(error); } } diff --git a/src/arts/auth/consts.ts b/src/arts/auth/consts.ts index 45fc73f..32b8493 100644 --- a/src/arts/auth/consts.ts +++ b/src/arts/auth/consts.ts @@ -14,6 +14,15 @@ export { export const LOGGER_CATEGORY = 'auth'; +export const AUTH_CLIENT_DIAGNOSTIC_EVENTS = { + OPERATION_FAILED: 'auth.client.operation_failed', + CACHE_INVALIDATION_FAILED: 'auth.client.cache_invalidation_failed' +} as const; + +export const AUTH_CLIENT_LOG_MESSAGE_OPERATION_FAILED = 'auth client operation failed'; +export const AUTH_CLIENT_LOG_MESSAGE_CACHE_INVALIDATION_FAILED = + 'auth client cache invalidation failed'; + export const AUTH_METHOD_LOAD_CURRENT = 'loadCurrent'; export const AUTH_METHOD_SIGN_IN_PASSWORD = 'signInPassword'; export const AUTH_METHOD_SIGN_UP_PASSWORD = 'signUpPassword'; @@ -26,6 +35,7 @@ export const AUTH_METHOD_COMPLETE_EMAIL_VERIFICATION = 'completeEmailVerificatio export const AUTH_METHOD_LIST_DEVICES = 'listDevices'; export const AUTH_METHOD_REVOKE_DEVICE = 'revokeDevice'; export const AUTH_METHOD_CLEAR_ERROR = 'clearError'; +export const AUTH_METHOD_ON_CHANGE = 'onChange'; export const AUTH_ERROR_NAME_REQUEST_FAILED = 'AuthRequestFailedError'; export const AUTH_ERROR_NAME_DISPOSED = 'AuthDisposedError'; diff --git a/src/arts/auth/diagnostics.ts b/src/arts/auth/diagnostics.ts new file mode 100644 index 0000000..4469223 --- /dev/null +++ b/src/arts/auth/diagnostics.ts @@ -0,0 +1,61 @@ +import { + LogLevel, + createCatalogDiagnostics, + type DiagnosticCatalog, + type DiagnosticEvent, + type Diagnostics, + type Logger +} from '$libs/logr'; +import { + AUTH_CLIENT_DIAGNOSTIC_EVENTS, + AUTH_CLIENT_LOG_MESSAGE_CACHE_INVALIDATION_FAILED, + AUTH_CLIENT_LOG_MESSAGE_OPERATION_FAILED, + LOGGER_CATEGORY +} from './consts.ts'; +import type { AuthEventName } from '$libs/auth/types'; + +export type AuthClientDiagnosticType = + (typeof AUTH_CLIENT_DIAGNOSTIC_EVENTS)[keyof typeof AUTH_CLIENT_DIAGNOSTIC_EVENTS]; + +export interface AuthClientDiagnosticMeta { + readonly method?: string; + readonly reason?: AuthEventName; + readonly error?: unknown; +} + +export type AuthClientDiagnosticEvent = DiagnosticEvent< + AuthClientDiagnosticType, + AuthClientDiagnosticMeta +>; +export type AuthClientDiagnostics = Diagnostics; + +const AUTH_CLIENT_DIAGNOSTIC_LOGS: DiagnosticCatalog = { + [AUTH_CLIENT_DIAGNOSTIC_EVENTS.OPERATION_FAILED]: { + level: LogLevel.WARN, + message: AUTH_CLIENT_LOG_MESSAGE_OPERATION_FAILED + }, + [AUTH_CLIENT_DIAGNOSTIC_EVENTS.CACHE_INVALIDATION_FAILED]: { + level: LogLevel.WARN, + message: AUTH_CLIENT_LOG_MESSAGE_CACHE_INVALIDATION_FAILED + } +}; + +export function createAuthClientDiagnostics(logger?: Logger): AuthClientDiagnostics { + return createCatalogDiagnostics({ + logger, + defaultCategory: LOGGER_CATEGORY, + catalog: AUTH_CLIENT_DIAGNOSTIC_LOGS + }); +} + +export function emitAuthClientDiagnostic( + diagnostics: AuthClientDiagnostics, + type: AuthClientDiagnosticType, + meta: AuthClientDiagnosticMeta +): void { + diagnostics.emit({ + artifact: LOGGER_CATEGORY, + type, + meta + }); +} diff --git a/src/arts/auth/index.ts b/src/arts/auth/index.ts index 8ecb18b..ed84ba5 100644 --- a/src/arts/auth/index.ts +++ b/src/arts/auth/index.ts @@ -4,6 +4,9 @@ export { AUTH_EVENT_NAMES } from './events.ts'; export { createInMemoryAuthActiveSync } from './sync.ts'; export { AUTH_CONTENT_TYPES, + AUTH_CLIENT_DIAGNOSTIC_EVENTS, + AUTH_CLIENT_LOG_MESSAGE_CACHE_INVALIDATION_FAILED, + AUTH_CLIENT_LOG_MESSAGE_OPERATION_FAILED, AUTH_COOKIE_NAMES, AUTH_ERROR_CODES, AUTH_ERROR_MSG_DISPOSED_SUFFIX, @@ -21,6 +24,7 @@ export { AUTH_METHOD_COMPLETE_PASSWORD_RESET, AUTH_METHOD_LIST_DEVICES, AUTH_METHOD_LOAD_CURRENT, + AUTH_METHOD_ON_CHANGE, AUTH_METHOD_REQUEST_EMAIL_VERIFICATION, AUTH_METHOD_REQUEST_PASSWORD_RESET, AUTH_METHOD_REVOKE_DEVICE, @@ -34,6 +38,7 @@ export { AUTH_STORAGE_KEYS, LOGGER_CATEGORY } from './consts.ts'; +export { createAuthClientDiagnostics, emitAuthClientDiagnostic } from './diagnostics.ts'; export { AuthDisposedError, AuthInvalidResponseError, @@ -43,6 +48,12 @@ export { isAuthRequestFailedError } from './errors.ts'; export type { AuthEventName } from './events.ts'; +export type { + AuthClientDiagnosticEvent, + AuthClientDiagnosticMeta, + AuthClientDiagnostics, + AuthClientDiagnosticType +} from './diagnostics.ts'; export type { AuthActiveSyncPort } from './sync.ts'; export type { ActiveAuth, diff --git a/src/arts/auth/types.ts b/src/arts/auth/types.ts index 84df364..5bf2885 100644 --- a/src/arts/auth/types.ts +++ b/src/arts/auth/types.ts @@ -16,12 +16,14 @@ import type { AuthClientHttpPort, AuthClientStoragePort } from '$libs/auth/contracts'; +import type { Logger } from '$libs/logr'; export interface ActiveAuthOptions { readonly http: AuthClientHttpPort; readonly cach?: AuthClientCachPort; readonly stor?: AuthClientStoragePort; readonly initial?: AuthCurrentView; + readonly logger?: Logger; readonly onCacheError?: (error: unknown) => void; } diff --git a/src/arts/cach/active-cache.svelte.ts b/src/arts/cach/active-cache.svelte.ts index a0cd970..ee578d5 100644 --- a/src/arts/cach/active-cache.svelte.ts +++ b/src/arts/cach/active-cache.svelte.ts @@ -1,4 +1,3 @@ -import { untrack } from 'svelte'; import { CACHE_EVENT_ALL, type CacheClock, type CacheEvent } from '$libs/cach'; import { createEngineCache } from '$svrs/cach'; import { @@ -12,6 +11,7 @@ import { CACHE_ACTIVE_STATUS_REFRESHING, CACHE_ACTIVE_STATUS_STALE, CACHE_ACTIVE_STATUS_SUCCESS, + CACHE_ACTIVE_DIAGNOSTIC_EVENTS, CACHE_METHOD_CLEAR, CACHE_METHOD_ENTRY, CACHE_METHOD_EXPLAIN, @@ -24,6 +24,7 @@ import { CACHE_METHOD_STATS } from './consts.ts'; import { disposedCacheMessage } from '$svrs/cach'; +import { createActiveCacheDiagnostics, emitActiveCacheDiagnostic } from './diagnostics.ts'; import { CachActiveEntryDisposedError, CachDisposedError } from './errors.ts'; import type { ActiveCache, @@ -37,6 +38,7 @@ import type { } from './types.ts'; export function createActiveCache(options: ActiveCacheOptions = {}): ActiveCache { + const diagnostics = createActiveCacheDiagnostics(options.logger); const engine = createEngineCache(options); const clock = options.clock ?? systemClock; let disposed = false; @@ -54,16 +56,16 @@ export function createActiveCache(options: ActiveCacheOptions = {}): ActiveCache const offEvents = engine.on(CACHE_EVENT_ALL, (event) => { lastEventCell = event; - eventCountCell = untrack(() => eventCountCell) + 1; + eventCountCell += 1; notify(); }); function updateLoading(delta: number): void { - loadingCount = Math.max(0, untrack(() => loadingCount) + delta); + loadingCount = Math.max(0, loadingCount + delta); notify(); } - async function track(task: () => Promise): Promise { + async function track(method: string, task: () => Promise): Promise { updateLoading(1); try { const value = await task(); @@ -72,6 +74,10 @@ export function createActiveCache(options: ActiveCacheOptions = {}): ActiveCache return value; } catch (error) { lastErrorCell = normalizeActiveCacheError(error); + emitActiveCacheDiagnostic(diagnostics, CACHE_ACTIVE_DIAGNOSTIC_EVENTS.OPERATION_FAILED, { + method, + error + }); notify(); throw error; } finally { @@ -85,9 +91,11 @@ export function createActiveCache(options: ActiveCacheOptions = {}): ActiveCache entryOptions, clock, { - query: (queryOptions) => track(() => engine.query(queryOptions)), - set: (key, value, setOptions) => track(() => engine.set(key, value, setOptions)), - invalidate: (invalidateOptions) => track(() => engine.invalidate(invalidateOptions)) + query: (queryOptions) => track(CACHE_METHOD_QUERY, () => engine.query(queryOptions)), + set: (key, value, setOptions) => + track(CACHE_METHOD_SET, () => engine.set(key, value, setOptions)), + invalidate: (invalidateOptions) => + track(CACHE_METHOD_INVALIDATE, () => engine.invalidate(invalidateOptions)) }, () => { entries.delete(built as ActiveCacheEntry); @@ -134,27 +142,27 @@ export function createActiveCache(options: ActiveCacheOptions = {}): ActiveCache }, query(queryOptions) { ensureLive(CACHE_METHOD_QUERY); - return track(() => engine.query(queryOptions)); + return track(CACHE_METHOD_QUERY, () => engine.query(queryOptions)); }, get(key, getOptions) { ensureLive(CACHE_METHOD_GET); - return track(() => engine.get(key, getOptions)); + return track(CACHE_METHOD_GET, () => engine.get(key, getOptions)); }, set(key, value, setOptions) { ensureLive(CACHE_METHOD_SET); - return track(() => engine.set(key, value, setOptions)); + return track(CACHE_METHOD_SET, () => engine.set(key, value, setOptions)); }, invalidate(invalidateOptions) { ensureLive(CACHE_METHOD_INVALIDATE); - return track(() => engine.invalidate(invalidateOptions)); + return track(CACHE_METHOD_INVALIDATE, () => engine.invalidate(invalidateOptions)); }, mutate(mutateOptions) { ensureLive(CACHE_METHOD_MUTATE); - return track(() => engine.mutate(mutateOptions)); + return track(CACHE_METHOD_MUTATE, () => engine.mutate(mutateOptions)); }, explain(key, explainOptions) { ensureLive(CACHE_METHOD_EXPLAIN); - return track(() => engine.explain(key, explainOptions)); + return track(CACHE_METHOD_EXPLAIN, () => engine.explain(key, explainOptions)); }, stats() { ensureLive(CACHE_METHOD_STATS); @@ -166,7 +174,7 @@ export function createActiveCache(options: ActiveCacheOptions = {}): ActiveCache }, clear() { ensureLive(CACHE_METHOD_CLEAR); - return track(() => engine.clear()); + return track(CACHE_METHOD_CLEAR, () => engine.clear()); }, entry, snapshot, diff --git a/src/arts/cach/consts.ts b/src/arts/cach/consts.ts index f57f357..bc91107 100644 --- a/src/arts/cach/consts.ts +++ b/src/arts/cach/consts.ts @@ -53,6 +53,12 @@ export const CACHE_ACTIVE_ENTRY_EVENT_INVALIDATE = 'invalidate'; export const CACHE_ACTIVE_ENTRY_EVENT_SET = 'set'; export const CACHE_ERROR_NAME_ACTIVE_ENTRY_DISPOSED = 'CachActiveEntryDisposedError'; +export const CACHE_ACTIVE_DIAGNOSTIC_EVENTS = { + OPERATION_FAILED: 'cach.active.operation_failed' +} as const; + +export const CACHE_ACTIVE_LOG_MESSAGE_OPERATION_FAILED = 'active cache operation failed'; + export const CACHE_ERROR_MSG_ACTIVE_ENTRY_DISPOSED = '[cach] ActiveCacheEntry used after dispose().'; diff --git a/src/arts/cach/diagnostics.ts b/src/arts/cach/diagnostics.ts new file mode 100644 index 0000000..1a00c47 --- /dev/null +++ b/src/arts/cach/diagnostics.ts @@ -0,0 +1,56 @@ +import { + LogLevel, + createCatalogDiagnostics, + type DiagnosticCatalog, + type DiagnosticEvent, + type Diagnostics, + type Logger +} from '$libs/logr'; +import type { CacheKey } from '$libs/cach'; +import { + CACHE_ACTIVE_DIAGNOSTIC_EVENTS, + CACHE_ACTIVE_LOG_MESSAGE_OPERATION_FAILED, + LOGGER_CATEGORY +} from './consts.ts'; + +export type ActiveCacheDiagnosticType = + (typeof CACHE_ACTIVE_DIAGNOSTIC_EVENTS)[keyof typeof CACHE_ACTIVE_DIAGNOSTIC_EVENTS]; + +export interface ActiveCacheDiagnosticMeta { + readonly method: string; + readonly key?: CacheKey; + readonly error?: unknown; +} + +export type ActiveCacheDiagnosticEvent = DiagnosticEvent< + ActiveCacheDiagnosticType, + ActiveCacheDiagnosticMeta +>; +export type ActiveCacheDiagnostics = Diagnostics; + +const ACTIVE_CACHE_DIAGNOSTIC_LOGS: DiagnosticCatalog = { + [CACHE_ACTIVE_DIAGNOSTIC_EVENTS.OPERATION_FAILED]: { + level: LogLevel.WARN, + message: CACHE_ACTIVE_LOG_MESSAGE_OPERATION_FAILED + } +}; + +export function createActiveCacheDiagnostics(logger?: Logger): ActiveCacheDiagnostics { + return createCatalogDiagnostics({ + logger, + defaultCategory: LOGGER_CATEGORY, + catalog: ACTIVE_CACHE_DIAGNOSTIC_LOGS + }); +} + +export function emitActiveCacheDiagnostic( + diagnostics: ActiveCacheDiagnostics, + type: ActiveCacheDiagnosticType, + meta: ActiveCacheDiagnosticMeta +): void { + diagnostics.emit({ + artifact: LOGGER_CATEGORY, + type, + meta + }); +} diff --git a/src/arts/cach/index.ts b/src/arts/cach/index.ts index 06c0794..9381971 100644 --- a/src/arts/cach/index.ts +++ b/src/arts/cach/index.ts @@ -5,6 +5,8 @@ export { CACHE_ACTIVE_ENTRY_EVENT_REFRESH, CACHE_ACTIVE_ENTRY_EVENT_SET, CACHE_ACTIVE_ENTRY_EVENTS, + CACHE_ACTIVE_DIAGNOSTIC_EVENTS, + CACHE_ACTIVE_LOG_MESSAGE_OPERATION_FAILED, CACHE_ACTIVE_STATUS_DEGRADED, CACHE_ACTIVE_STATUS_ERROR, CACHE_ACTIVE_STATUS_IDLE, @@ -45,6 +47,7 @@ export { CACHE_METHOD_STATS, LOGGER_CATEGORY } from './consts.ts'; +export { createActiveCacheDiagnostics, emitActiveCacheDiagnostic } from './diagnostics.ts'; export { CachActiveEntryDisposedError, CachDisposedError, @@ -52,6 +55,12 @@ export { isCachDisposedError } from './errors.ts'; export { disposedCacheMessage } from '$svrs/cach'; +export type { + ActiveCacheDiagnosticEvent, + ActiveCacheDiagnosticMeta, + ActiveCacheDiagnostics, + ActiveCacheDiagnosticType +} from './diagnostics.ts'; export type { ActiveCache, ActiveCacheEntry, @@ -61,7 +70,6 @@ export type { ActiveCacheEntrySnapshot, ActiveCacheEntryStatus, ActiveCacheOptions, - CacheLogger, EngineCache, EngineCacheOptions } from './types.ts'; diff --git a/src/arts/cach/types.ts b/src/arts/cach/types.ts index 5e7bb41..67716b9 100644 --- a/src/arts/cach/types.ts +++ b/src/arts/cach/types.ts @@ -3,7 +3,7 @@ import type { CacheClock, CacheEvent, CacheKey, QueryOptions, SetOptions } from import type { EngineCache, EngineCacheOptions } from '$svrs/cach'; import type { ActiveChangeListener, ActiveEngine } from '$libs/active'; -export type { CacheLogger, EngineCache, EngineCacheOptions } from '$svrs/cach'; +export type { EngineCache, EngineCacheOptions } from '$svrs/cach'; export type ActiveCacheEntryStatus = (typeof CACHE_ACTIVE_STATUSES)[number]; diff --git a/src/arts/conn/acks.ts b/src/arts/conn/acks.ts new file mode 100644 index 0000000..d016ca0 --- /dev/null +++ b/src/arts/conn/acks.ts @@ -0,0 +1,82 @@ +import type { TimerScheduler } from '$timr'; +import { + CONNECTION_ACK_REASON_CLOSED, + CONNECTION_ACK_REASON_REJECTED, + CONNECTION_ACK_REASON_TIMEOUT, + CONNECTION_ACK_REASON_TRANSPORT_ERROR, + CONNECTION_SEND_REASON_CLOSED, + TIMER_KEY_ACK +} from './consts.ts'; +import { timerKey } from './helpers.ts'; +import type { ConnectionAckResult, ConnectionFrame, ConnectionSendResult } from './types.ts'; + +interface PendingAck { + readonly timer: string; + readonly resolve: (result: ConnectionAckResult) => void; +} + +export interface ConnectionAckRegistry { + wait(id: string, timeoutMs: number): Promise>; + resolve(id: string, result: ConnectionAckResult): void; + resolveAll(result: ConnectionAckResult): void; + resolveFromFrame(frame: ConnectionFrame): void; + mapSendFailure(result: ConnectionSendResult): ConnectionAckResult; +} + +export function createConnectionAckRegistry( + connectionName: string, + timers: TimerScheduler +): ConnectionAckRegistry { + const pending = new Map(); + + function resolve(id: string, result: ConnectionAckResult): void { + const ack = pending.get(id); + if (ack === undefined) return; + pending.delete(id); + timers.cancel(ack.timer); + ack.resolve(result); + } + + return { + wait(id: string, timeoutMs: number): Promise> { + const ackTimer = timerKey(connectionName, TIMER_KEY_ACK, id); + return new Promise>((resolveWaiter) => { + pending.set(id, { + timer: ackTimer, + resolve: resolveWaiter as (result: ConnectionAckResult) => void + }); + timers.schedule( + ackTimer, + timeoutMs, + () => { + resolve(id, { ok: false, reason: CONNECTION_ACK_REASON_TIMEOUT }); + }, + { replace: true } + ); + }); + }, + resolve, + resolveAll(result: ConnectionAckResult): void { + for (const id of [...pending.keys()]) resolve(id, result); + }, + resolveFromFrame(frame: ConnectionFrame): void { + if (frame.replyTo === undefined) return; + if (frame.error !== undefined) { + resolve(frame.replyTo, { + ok: false, + reason: CONNECTION_ACK_REASON_REJECTED, + error: frame.error + }); + return; + } + resolve(frame.replyTo, { ok: true, payload: frame.payload }); + }, + mapSendFailure(result: ConnectionSendResult): ConnectionAckResult { + if (result.ok) return { ok: true, payload: undefined }; + if (result.reason === CONNECTION_SEND_REASON_CLOSED) { + return { ok: false, reason: CONNECTION_ACK_REASON_CLOSED, error: result.error }; + } + return { ok: false, reason: CONNECTION_ACK_REASON_TRANSPORT_ERROR, error: result.error }; + } + }; +} diff --git a/src/arts/conn/browser-reconnect.ts b/src/arts/conn/browser-reconnect.ts new file mode 100644 index 0000000..d867cea --- /dev/null +++ b/src/arts/conn/browser-reconnect.ts @@ -0,0 +1,78 @@ +import { + BROWSER_EVENT_ONLINE, + BROWSER_EVENT_VISIBILITY_CHANGE, + CONNECTION_DIAGNOSTIC_EVENTS, + DEFAULT_RECONNECT_ON_ONLINE, + DEFAULT_RECONNECT_ON_VISIBLE, + DOCUMENT_VISIBILITY_VISIBLE +} from './consts.ts'; +import { emitConnectionDiagnostic, type ConnectionDiagnostics } from './diagnostics.ts'; +import type { ConnectionReconnectOptions } from './types.ts'; + +interface BrowserReconnectOptions { + readonly disabled: boolean; + readonly reconnectOptions?: ConnectionReconnectOptions; + readonly shouldReconnect: () => boolean; + readonly reconnect: () => void; + readonly diagnostics: ConnectionDiagnostics; +} + +export function wireBrowserReconnect(options: BrowserReconnectOptions): () => void { + if (options.disabled) return () => {}; + const detachers: Array<() => void> = []; + const reconnectOnOnline = + options.reconnectOptions?.reconnectOnOnline ?? DEFAULT_RECONNECT_ON_ONLINE; + const reconnectOnVisible = + options.reconnectOptions?.reconnectOnVisible ?? DEFAULT_RECONNECT_ON_VISIBLE; + const target = globalThis as { + addEventListener?: (type: string, listener: () => void) => void; + removeEventListener?: (type: string, listener: () => void) => void; + document?: { + readonly visibilityState?: string; + addEventListener?: (type: string, listener: () => void) => void; + removeEventListener?: (type: string, listener: () => void) => void; + }; + }; + + if (reconnectOnOnline && target.addEventListener && target.removeEventListener) { + const onOnline = (): void => { + if (!options.shouldReconnect()) return; + emitConnectionDiagnostic( + options.diagnostics, + CONNECTION_DIAGNOSTIC_EVENTS.BROWSER_RECONNECT, + { trigger: BROWSER_EVENT_ONLINE } + ); + options.reconnect(); + }; + target.addEventListener(BROWSER_EVENT_ONLINE, onOnline); + detachers.push(() => { + target.removeEventListener?.(BROWSER_EVENT_ONLINE, onOnline); + }); + } + + if ( + reconnectOnVisible && + target.document?.addEventListener && + target.document.removeEventListener + ) { + const onVisible = (): void => { + if (target.document?.visibilityState !== DOCUMENT_VISIBILITY_VISIBLE) return; + if (!options.shouldReconnect()) return; + emitConnectionDiagnostic( + options.diagnostics, + CONNECTION_DIAGNOSTIC_EVENTS.BROWSER_RECONNECT, + { trigger: BROWSER_EVENT_VISIBILITY_CHANGE } + ); + options.reconnect(); + }; + target.document.addEventListener(BROWSER_EVENT_VISIBILITY_CHANGE, onVisible); + detachers.push(() => { + target.document?.removeEventListener?.(BROWSER_EVENT_VISIBILITY_CHANGE, onVisible); + }); + } + + return () => { + for (const detach of detachers) detach(); + detachers.length = 0; + }; +} diff --git a/src/arts/conn/channel-registry.ts b/src/arts/conn/channel-registry.ts new file mode 100644 index 0000000..5976dcf --- /dev/null +++ b/src/arts/conn/channel-registry.ts @@ -0,0 +1,85 @@ +import { createConnectionChannel, type InternalConnectionChannel } from './channel.ts'; +import type { + Connection, + ConnectionChannel, + ConnectionChannelMap, + ConnectionChannelOptions, + ConnectionEventMap, + ConnectionFrame, + ConnectionMessageMeta +} from './types.ts'; + +export interface ConnectionChannelRegistry { + receive(topic: string, frame: ConnectionFrame, meta: ConnectionMessageMeta): void; + getOrCreate( + name: string, + options: ConnectionChannelOptions, + connection: Connection> + ): ConnectionChannel; + channels(): readonly ConnectionChannel[]; + has(name: string): boolean; + leave(name: string): Promise; + joinConfigured( + reconnect: boolean, + configured: Readonly>, + connection: Connection + ): Promise; + dispose(): void; +} + +export function createConnectionChannelRegistry( + reportListenerError: (event: string, error: unknown) => void +): ConnectionChannelRegistry { + const channels = new Map(); + + return { + receive(topic, frame, meta): void { + channels.get(topic)?.receive(frame, meta); + }, + getOrCreate( + channelName: string, + channelOptions: ConnectionChannelOptions, + connection: Connection> + ): ConnectionChannel { + const existing = channels.get(channelName); + if (existing !== undefined) return existing as ConnectionChannel; + const created = createConnectionChannel(channelName, channelOptions, { + connection, + reportListenerError + }); + channels.set(channelName, created as InternalConnectionChannel); + return created; + }, + channels(): readonly ConnectionChannel[] { + return [...channels.values()]; + }, + has(name): boolean { + return channels.has(name); + }, + async leave(name): Promise { + await channels.get(name)?.leave(); + }, + async joinConfigured(reconnect, configured, connection): Promise { + const entries = (configured ?? {}) as Record; + for (const channelName of Object.keys(entries)) { + const configuredOptions = entries[channelName] ?? {}; + const current = this.getOrCreate( + channelName, + configuredOptions, + connection as unknown as Connection> + ) as InternalConnectionChannel; + if (configuredOptions.autoJoin === true || (reconnect && current.shouldRejoin)) { + await current.join(); + } + } + if (!reconnect) return; + for (const current of channels.values()) { + if (current.shouldRejoin) await current.rejoin(); + } + }, + dispose(): void { + for (const current of channels.values()) current.dispose(); + channels.clear(); + } + }; +} diff --git a/src/arts/conn/connection-auth.ts b/src/arts/conn/connection-auth.ts new file mode 100644 index 0000000..cc3ce91 --- /dev/null +++ b/src/arts/conn/connection-auth.ts @@ -0,0 +1,59 @@ +import { + CONNECTION_ACK_REASON_CLOSED, + CONNECTION_ACK_REASON_REJECTED, + CONNECTION_ACK_REASON_TIMEOUT, + CONNECTION_AUTH_REASON_CLOSED, + CONNECTION_AUTH_REASON_NO_PROVIDER, + CONNECTION_AUTH_REASON_REJECTED, + CONNECTION_AUTH_REASON_TIMEOUT, + CONNECTION_AUTH_REASON_TRANSPORT_ERROR, + CONNECTION_FRAME_TYPE_AUTH +} from './consts.ts'; +import type { + ConnectionAckResult, + ConnectionAuthPayload, + ConnectionAuthResult, + ConnectionOptions +} from './types.ts'; + +export type ConnectionAuthProvider = () => + | ConnectionAuthPayload + | null + | Promise; + +export interface ResolvedConnectionAuth { + readonly provider: ConnectionAuthProvider | null; + readonly authType: string; + readonly timeoutMs?: number; +} + +export function resolveConnectionAuth(auth: ConnectionOptions['auth']): ResolvedConnectionAuth { + if (typeof auth === 'function') { + return { provider: auth, authType: CONNECTION_FRAME_TYPE_AUTH }; + } + return { + provider: auth?.getAuth ?? null, + authType: auth?.authType ?? CONNECTION_FRAME_TYPE_AUTH, + timeoutMs: auth?.timeoutMs + }; +} + +export function missingConnectionAuthProvider(): ConnectionAuthResult { + return { ok: false, reason: CONNECTION_AUTH_REASON_NO_PROVIDER }; +} + +export function mapConnectionAckToAuthResult( + result: ConnectionAckResult +): ConnectionAuthResult { + if (result.ok) return { ok: true }; + if (result.reason === CONNECTION_ACK_REASON_TIMEOUT) { + return { ok: false, reason: CONNECTION_AUTH_REASON_TIMEOUT, error: result.error }; + } + if (result.reason === CONNECTION_ACK_REASON_CLOSED) { + return { ok: false, reason: CONNECTION_AUTH_REASON_CLOSED, error: result.error }; + } + if (result.reason === CONNECTION_ACK_REASON_REJECTED) { + return { ok: false, reason: CONNECTION_AUTH_REASON_REJECTED, error: result.error }; + } + return { ok: false, reason: CONNECTION_AUTH_REASON_TRANSPORT_ERROR, error: result.error }; +} diff --git a/src/arts/conn/connection-events.ts b/src/arts/conn/connection-events.ts new file mode 100644 index 0000000..7291e1b --- /dev/null +++ b/src/arts/conn/connection-events.ts @@ -0,0 +1,68 @@ +import { CONNECTION_DIAGNOSTIC_EVENTS } from './consts.ts'; +import { emitConnectionDiagnostic, type ConnectionDiagnostics } from './diagnostics.ts'; +import type { ConnectionFrame, ConnectionMessageMeta, ConnectionStateChange } from './types.ts'; + +type GlobalListener = (frame: ConnectionFrame, meta: ConnectionMessageMeta) => void; +type StateListener = (change: ConnectionStateChange) => void; + +interface ConnectionEventBusOptions { + readonly diagnostics: ConnectionDiagnostics; +} + +export interface ConnectionEventBus { + emitState(change: ConnectionStateChange): void; + emitGlobal(frame: ConnectionFrame, meta: ConnectionMessageMeta): void; + onState(listener: StateListener): () => void; + onAny(listener: GlobalListener): () => void; + clear(): void; +} + +export function createConnectionEventBus(options: ConnectionEventBusOptions): ConnectionEventBus { + const stateListeners = new Set(); + const globalListeners = new Set(); + + return { + emitState(change): void { + for (const listener of [...stateListeners]) { + try { + listener(change); + } catch (err) { + emitConnectionDiagnostic( + options.diagnostics, + CONNECTION_DIAGNOSTIC_EVENTS.LISTENER_THREW, + { error: err, event: change.to } + ); + } + } + }, + emitGlobal(frame, meta): void { + for (const listener of [...globalListeners]) { + try { + listener(frame, meta); + } catch (err) { + emitConnectionDiagnostic( + options.diagnostics, + CONNECTION_DIAGNOSTIC_EVENTS.LISTENER_THREW, + { error: err, event: frame.type } + ); + } + } + }, + onState(listener): () => void { + stateListeners.add(listener); + return () => { + stateListeners.delete(listener); + }; + }, + onAny(listener): () => void { + globalListeners.add(listener); + return () => { + globalListeners.delete(listener); + }; + }, + clear(): void { + stateListeners.clear(); + globalListeners.clear(); + } + }; +} diff --git a/src/arts/conn/connection-state.ts b/src/arts/conn/connection-state.ts new file mode 100644 index 0000000..896155e --- /dev/null +++ b/src/arts/conn/connection-state.ts @@ -0,0 +1,110 @@ +import { + CONNECTION_STATE_CLOSED, + CONNECTION_STATE_FAILED, + CONNECTION_STATE_IDLE, + CONNECTION_STATE_OPEN +} from './consts.ts'; +import type { ConnectionState, ConnectionStateChange } from './types.ts'; + +export interface ConnectionStateTracker { + readonly state: ConnectionState; + readonly generation: number; + readonly error: unknown | null; + readonly openedAt: number | null; + readonly closedAt: number | null; + readonly lastMessageAt: number | null; + readonly reconnectAttempt: number; + setError(error: unknown | null): void; + setReconnectAttempt(attempt: number): void; + touchMessage(): number; + isClosedLike(): boolean; + transition(to: ConnectionState, error?: unknown): boolean; + markOpen(): boolean; + markClosed(to: ConnectionState, error?: unknown, beforeEmit?: () => void): boolean; +} + +export function createConnectionStateTracker(input: { + readonly name: string; + readonly now: () => number; + readonly emitState: (change: ConnectionStateChange) => void; +}): ConnectionStateTracker { + let state: ConnectionState = CONNECTION_STATE_IDLE; + let generation = 0; + let error: unknown | null = null; + let openedAt: number | null = null; + let closedAt: number | null = null; + let lastMessageAt: number | null = null; + let reconnectAttempt = 0; + + function transition(to: ConnectionState, changeError?: unknown): boolean { + if (state === to && changeError === undefined) return false; + const from = state; + state = to; + input.emitState({ + connection: input.name, + from, + to, + generation, + error: changeError, + at: input.now() + }); + return true; + } + + return { + get state() { + return state; + }, + get generation() { + return generation; + }, + get error() { + return error; + }, + get openedAt() { + return openedAt; + }, + get closedAt() { + return closedAt; + }, + get lastMessageAt() { + return lastMessageAt; + }, + get reconnectAttempt() { + return reconnectAttempt; + }, + setError(nextError) { + error = nextError; + }, + setReconnectAttempt(attempt) { + reconnectAttempt = attempt; + }, + touchMessage() { + lastMessageAt = input.now(); + return lastMessageAt; + }, + isClosedLike() { + return ( + state === CONNECTION_STATE_CLOSED || + state === CONNECTION_STATE_FAILED || + state === CONNECTION_STATE_IDLE + ); + }, + transition, + markOpen() { + if (state === CONNECTION_STATE_OPEN) return false; + generation += 1; + error = null; + openedAt = input.now(); + closedAt = null; + reconnectAttempt = 0; + return transition(CONNECTION_STATE_OPEN); + }, + markClosed(to, closeError, beforeEmit) { + if (state === to) return false; + closedAt = input.now(); + beforeEmit?.(); + return transition(to, closeError); + } + }; +} diff --git a/src/arts/conn/connection-timers.ts b/src/arts/conn/connection-timers.ts new file mode 100644 index 0000000..2fe2738 --- /dev/null +++ b/src/arts/conn/connection-timers.ts @@ -0,0 +1,28 @@ +import type { TimerScheduler } from '$timr'; +import { timerKey } from './helpers.ts'; + +export interface ConnectionTimerControls { + cancel(kind: string, id?: string): void; + schedule(kind: string, delayMs: number, task: () => void | Promise, id?: string): void; + interval(kind: string, everyMs: number, task: () => void, id?: string): void; +} + +export function createConnectionTimerControls( + name: string, + timers: TimerScheduler +): ConnectionTimerControls { + return { + cancel(kind, id) { + timers.cancel(timerKey(name, kind, id)); + }, + schedule(kind, delayMs, task, id) { + timers.schedule(timerKey(name, kind, id), delayMs, task, { replace: true }); + }, + interval(kind, everyMs, task, id) { + timers.interval(timerKey(name, kind, id), everyMs, task, { + replace: true, + awaitTask: false + }); + } + }; +} diff --git a/src/arts/conn/connection.ts b/src/arts/conn/connection.ts index 75e9683..d4a7692 100644 --- a/src/arts/conn/connection.ts +++ b/src/arts/conn/connection.ts @@ -1,90 +1,60 @@ -import { computeBackoffDelay } from '$libs/timers'; import type { TimerScheduler } from '$timr'; -import { createConnectionChannel, type InternalConnectionChannel } from './channel.ts'; +import { createConnectionAckRegistry } from './acks.ts'; +import { wireBrowserReconnect } from './browser-reconnect.ts'; +import { createConnectionChannelRegistry } from './channel-registry.ts'; +import { + mapConnectionAckToAuthResult, + missingConnectionAuthProvider, + resolveConnectionAuth +} from './connection-auth.ts'; +import { createConnectionEventBus } from './connection-events.ts'; +import { createConnectionStateTracker } from './connection-state.ts'; +import { createConnectionTimerControls } from './connection-timers.ts'; import { - BROWSER_EVENT_ONLINE, - BROWSER_EVENT_VISIBILITY_CHANGE, CONNECTION_ACK_REASON_CLOSED, - CONNECTION_ACK_REASON_REJECTED, - CONNECTION_ACK_REASON_TIMEOUT, - CONNECTION_ACK_REASON_TRANSPORT_ERROR, CONNECTION_AUTH_REASON_CLOSED, - CONNECTION_AUTH_REASON_NO_PROVIDER, CONNECTION_AUTH_REASON_REJECTED, - CONNECTION_AUTH_REASON_TIMEOUT, - CONNECTION_AUTH_REASON_TRANSPORT_ERROR, - CONNECTION_BUFFER_POLICY_BUFFER, - CONNECTION_BUFFER_POLICY_DROP, CONNECTION_CLOSE_REASON_DISCONNECT, CONNECTION_CLOSE_REASON_DISPOSE, CONNECTION_CLOSE_REASON_AUTH_FAILED, - CONNECTION_CLOSE_REASON_HEARTBEAT_TIMEOUT, CONNECTION_CLOSE_REASON_RECONNECT, CONNECTION_CLOSE_REASON_SESSION_EXPIRED, CONNECTION_CONNECT_REASON_AUTH_FAILED, CONNECTION_CONNECT_REASON_DISPOSED, CONNECTION_CONNECT_REASON_TRANSPORT_ERROR, - CONNECTION_FRAME_TYPE_AUTH, - CONNECTION_FRAME_TYPE_PING, CONNECTION_FRAME_TYPE_PONG, CONNECTION_ID_PREFIX, CONNECTION_ID_SEPARATOR, - CONNECTION_SEND_REASON_BUFFER_FULL, - CONNECTION_SEND_REASON_CLOSED, - CONNECTION_SEND_REASON_INVALID_FRAME, - CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED, - CONNECTION_SEND_REASON_SERIALIZE_FAILED, - CONNECTION_SEND_REASON_TRANSPORT_ERROR, + CONNECTION_DIAGNOSTIC_EVENTS, CONNECTION_STATE_CLOSED, CONNECTION_STATE_CLOSING, CONNECTION_STATE_CONNECTING, CONNECTION_STATE_FAILED, - CONNECTION_STATE_IDLE, CONNECTION_STATE_OPEN, CONNECTION_STATE_RECONNECTING, CONNECTION_TRANSPORT_STATE_OPEN, DEFAULT_ACK_TIMEOUT_MS, - DEFAULT_BUFFER_MAX_BYTES, - DEFAULT_BUFFER_MAX_MESSAGES, - DEFAULT_HEARTBEAT_ENABLED, - DEFAULT_HEARTBEAT_INTERVAL_MS, - DEFAULT_HEARTBEAT_TIMEOUT_MS, - DEFAULT_RECONNECT_ENABLED, - DEFAULT_RECONNECT_FACTOR, - DEFAULT_RECONNECT_JITTER_MS, - DEFAULT_RECONNECT_MAX_DELAY_MS, - DEFAULT_RECONNECT_MIN_DELAY_MS, - DEFAULT_RECONNECT_ON_ONLINE, - DEFAULT_RECONNECT_ON_VISIBLE, - DOCUMENT_VISIBILITY_VISIBLE, - LOG_MSG_AUTH_FAILED, - LOG_MSG_BROWSER_RECONNECT, - LOG_MSG_CONNECT_FAILED, - LOG_MSG_FRAME_DECODE_FAILED, - LOG_MSG_FRAME_ENCODE_FAILED, - LOG_MSG_HEARTBEAT_TIMEOUT, - LOG_MSG_REAUTH_FAILED, LOG_MSG_RECONNECT_EXHAUSTED, - LOG_MSG_SEND_FAILED, - LOG_MSG_SESSION_EXPIRED, - LOG_MSG_SESSION_REFRESHED, - LOG_MSG_SESSION_REVOKED, - LOG_MSG_TRANSPORT_ERROR, - SESSION_EVENT_EXPIRED, - SESSION_EVENT_REFRESHED, - SESSION_EVENT_REVOKED, - TIMER_KEY_ACK, - TIMER_KEY_HEARTBEAT, - TIMER_KEY_HEARTBEAT_TIMEOUT, TIMER_KEY_RECONNECT } from './consts.ts'; -import { listenerThrewMessage, loggerScope, timerKey } from './helpers.ts'; +import { + createConnectionDiagnostics, + emitConnectionDiagnostic, + emitScopedConnectionDiagnostic +} from './diagnostics.ts'; +import { createConnectionFrameBuffer } from './frame-buffer.ts'; +import { loggerScope } from './helpers.ts'; +import { createConnectionHeartbeat } from './heartbeat.ts'; +import { createConnectionReconnectPolicy } from './reconnect.ts'; +import { wireConnectionSession } from './session-wiring.ts'; +import { createConnectionSender } from './sender.ts'; import { assertConnectionFrame, createFrame } from './serializer.ts'; import { jsonConnectionSerializer } from './serializers/json.ts'; +import { attachConnectionTransport } from './transport-wiring.ts'; +import type { Logger } from '$libs/logr'; import type { Connection, ConnectionAckResult, - ConnectionAuthPayload, ConnectionAuthResult, ConnectionChannel, ConnectionChannelMap, @@ -92,31 +62,20 @@ import type { ConnectionCloseEvent, ConnectionConnectResult, ConnectionFrame, - ConnectionLogger, ConnectionMessageMeta, ConnectionOptions, ConnectionRequestOptions, - ConnectionSendResult, ConnectionSessionSource, ConnectionState, - ConnectionStateChange, ConnectionTransport } from './types.ts'; interface ConnectionRuntime { - readonly logger?: ConnectionLogger; + readonly logger?: Logger; readonly timers: TimerScheduler; readonly session?: ConnectionSessionSource; } -interface PendingAck { - readonly timer: string; - readonly resolve: (result: ConnectionAckResult) => void; -} - -type GlobalListener = (frame: ConnectionFrame, meta: ConnectionMessageMeta) => void; -type StateListener = (change: ConnectionStateChange) => void; - function createIdFactory(connectionName: string): () => string { let next = 0; return () => { @@ -125,122 +84,71 @@ function createIdFactory(connectionName: string): () => string { }; } -function isPromiseLike(value: unknown): value is PromiseLike { - return typeof value === 'object' && value !== null && 'then' in value; -} - -function isClosedLike(state: ConnectionState): boolean { - return ( - state === CONNECTION_STATE_CLOSED || - state === CONNECTION_STATE_FAILED || - state === CONNECTION_STATE_IDLE - ); -} - export function createConnection( name: string, options: ConnectionOptions, runtime: ConnectionRuntime ): Connection { const serializer = options.serializer ?? jsonConnectionSerializer; - const reconnectDisabled = options.reconnect === false; - const reconnectOptions = reconnectDisabled ? undefined : options.reconnect; - const heartbeatOptions = options.heartbeat === false ? false : options.heartbeat; - const bufferOptions = options.buffer ?? {}; - const stateListeners = new Set(); - const globalListeners = new Set(); - const pendingAcks = new Map(); - const bufferedFrames: ConnectionFrame[] = []; - const channels = new Map(); - const detachBrowserListeners: (() => void)[] = []; + const reconnectPolicy = createConnectionReconnectPolicy(options.reconnect); + const frameBuffer = createConnectionFrameBuffer(options.buffer); + const acks = createConnectionAckRegistry(name, runtime.timers); + const timers = createConnectionTimerControls(name, runtime.timers); const nextId = createIdFactory(name); const scope = loggerScope(name, options.loggerScope); + const diagnostics = createConnectionDiagnostics({ logger: runtime.logger, scope }); + const events = createConnectionEventBus({ diagnostics }); + const stateTracker = createConnectionStateTracker({ + name, + now: () => runtime.timers.clock.now(), + emitState: (change) => events.emitState(change) + }); + const channelRegistry = createConnectionChannelRegistry(reportChannelListenerError); + const sender = createConnectionSender({ + serializer, + frameBuffer, + isConnected, + getTransport: () => transport, + setError: (err) => { + stateTracker.setError(err); + }, + diagnostics + }); let transport: ConnectionTransport | null = null; - let detachTransportListeners: (() => void)[] = []; - let state: ConnectionState = CONNECTION_STATE_IDLE; - let generation = 0; - let error: unknown | null = null; - let openedAt: number | null = null; - let closedAt: number | null = null; - let lastMessageAt: number | null = null; - let reconnectAttempt = 0; + let detachTransportListeners = (): void => {}; let disposed = false; let intentionalClose = false; let connectPromise: Promise | null = null; - function now(): number { - return runtime.timers.clock.now(); - } - - function logDebug(message: string, meta?: unknown): void { - runtime.logger?.debug?.(scope, message, meta); - } - - function logWarn(message: string, meta?: unknown): void { - runtime.logger?.warn?.(scope, message, meta); - } - - function logError(message: string, meta?: unknown): void { - runtime.logger?.error?.(scope, message, meta); - } - - function emitState(to: ConnectionState, changeError?: unknown): void { - if (state === to && changeError === undefined) return; - const from = state; - state = to; - const change: ConnectionStateChange = { - connection: name, - from, - to, - generation, - error: changeError, - at: now() - }; - for (const listener of [...stateListeners]) { - try { - listener(change); - } catch (err) { - logError(listenerThrewMessage(to), { error: err }); - } - } - } - function resolveTransport(): ConnectionTransport { return typeof options.transport === 'function' ? options.transport() : options.transport; } function detachTransport(): void { - for (const detach of detachTransportListeners) detach(); - detachTransportListeners = []; + detachTransportListeners(); + detachTransportListeners = (): void => {}; } function attachTransport(next: ConnectionTransport): void { detachTransport(); - detachTransportListeners = [ - next.onOpen(handleTransportOpen), - next.onMessage(handleTransportMessage), - next.onClose(handleTransportClose), - next.onError(handleTransportError) - ]; + detachTransportListeners = attachConnectionTransport(next, { + onOpen: handleTransportOpen, + onMessage: handleTransportMessage, + onClose: handleTransportClose, + onError: handleTransportError + }); } function markOpen(): void { - if (state === CONNECTION_STATE_OPEN) return; - generation += 1; - error = null; - openedAt = now(); - closedAt = null; - reconnectAttempt = 0; - emitState(CONNECTION_STATE_OPEN); - startHeartbeat(); + if (stateTracker.markOpen()) heartbeat.start(); } function markClosed(nextState: ConnectionState, cause?: unknown): void { - closedAt = now(); - stopHeartbeat(); - resolvePendingAcks({ ok: false, reason: CONNECTION_ACK_REASON_CLOSED }); - emitState(nextState, cause); + stateTracker.markClosed(nextState, cause, () => { + heartbeat.stop(); + acks.resolveAll({ ok: false, reason: CONNECTION_ACK_REASON_CLOSED }); + }); } function handleTransportOpen(): void { @@ -248,20 +156,22 @@ export function createConnection void | Promise, - id?: string - ): void { - runtime.timers.schedule(timerKey(name, kind, id), delayMs, task, { replace: true }); - } - - function scheduleInterval(kind: string, everyMs: number, task: () => void, id?: string): void { - runtime.timers.interval(timerKey(name, kind, id), everyMs, task, { - replace: true, - awaitTask: false + stateTracker.setError(err); + emitConnectionDiagnostic(diagnostics, CONNECTION_DIAGNOSTIC_EVENTS.TRANSPORT_ERROR, { + error: err }); } + const heartbeat = createConnectionHeartbeat({ + heartbeatOptions: options.heartbeat === false ? false : options.heartbeat, + isConnected, + sendFrame: sender.sendFrame, + scheduleInterval: timers.interval, + scheduleTimer: timers.schedule, + cancelTimer: timers.cancel, + closeTransport: (reason) => { + transport?.close(undefined, reason); + }, + diagnostics + }); + function shouldReconnect(): boolean { - if (disposed || intentionalClose) return false; - if (reconnectDisabled) return false; - return reconnectOptions?.enabled ?? DEFAULT_RECONNECT_ENABLED; + return reconnectPolicy.isEnabled({ disposed, intentionalClose }); } function scheduleReconnect(): void { if (!shouldReconnect()) return; - const maxAttempts = reconnectOptions?.maxAttempts; - if (maxAttempts !== undefined && reconnectAttempt >= maxAttempts) { - logWarn(LOG_MSG_RECONNECT_EXHAUSTED, { reconnectAttempt, maxAttempts }); - error = new Error(LOG_MSG_RECONNECT_EXHAUSTED); - emitState(CONNECTION_STATE_FAILED, error); + const nextReconnect = reconnectPolicy.next(stateTracker.reconnectAttempt); + if (!nextReconnect.ok) { + emitConnectionDiagnostic(diagnostics, CONNECTION_DIAGNOSTIC_EVENTS.RECONNECT_EXHAUSTED, { + reconnectAttempt: nextReconnect.reconnectAttempt, + maxAttempts: nextReconnect.maxAttempts + }); + const exhaustedError = new Error(LOG_MSG_RECONNECT_EXHAUSTED); + stateTracker.setError(exhaustedError); + stateTracker.transition(CONNECTION_STATE_FAILED, exhaustedError); return; } - reconnectAttempt += 1; - const delayMs = computeBackoffDelay(reconnectAttempt - 1, { - minDelayMs: reconnectOptions?.minDelayMs ?? DEFAULT_RECONNECT_MIN_DELAY_MS, - maxDelayMs: reconnectOptions?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS, - factor: reconnectOptions?.factor ?? DEFAULT_RECONNECT_FACTOR, - jitterMs: reconnectOptions?.jitterMs ?? DEFAULT_RECONNECT_JITTER_MS - }); - scheduleTimer(TIMER_KEY_RECONNECT, delayMs, () => { + stateTracker.setReconnectAttempt(nextReconnect.attempt); + timers.schedule(TIMER_KEY_RECONNECT, nextReconnect.delayMs, () => { void connectInternal(true); }); } - function startHeartbeat(): void { - if (heartbeatOptions === false) return; - if ((heartbeatOptions?.enabled ?? DEFAULT_HEARTBEAT_ENABLED) === false) return; - const intervalMs = heartbeatOptions?.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS; - scheduleInterval(TIMER_KEY_HEARTBEAT, intervalMs, () => { - void sendHeartbeat(); - }); - } - - function stopHeartbeat(): void { - cancelTimer(TIMER_KEY_HEARTBEAT); - cancelTimer(TIMER_KEY_HEARTBEAT_TIMEOUT); - } - - async function sendHeartbeat(): Promise { - if (!isConnected()) return; - const pingType = heartbeatOptions === false ? undefined : heartbeatOptions?.pingType; - const result = await sendFrame( - createFrame({ - type: pingType ?? CONNECTION_FRAME_TYPE_PING, - payload: undefined - }), - false - ); - if (!result.ok) return; - const timeoutMs = - heartbeatOptions === false - ? DEFAULT_HEARTBEAT_TIMEOUT_MS - : (heartbeatOptions?.timeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS); - scheduleTimer(TIMER_KEY_HEARTBEAT_TIMEOUT, timeoutMs, () => { - logWarn(LOG_MSG_HEARTBEAT_TIMEOUT); - transport?.close(undefined, CONNECTION_CLOSE_REASON_HEARTBEAT_TIMEOUT); - }); - } - function isConnected(): boolean { - return state === CONNECTION_STATE_OPEN && transport?.state === CONNECTION_TRANSPORT_STATE_OPEN; - } - - function resolvePendingAcks(result: ConnectionAckResult): void { - for (const id of [...pendingAcks.keys()]) resolveAck(id, result); - } - - function resolveAck(id: string, result: ConnectionAckResult): void { - const pending = pendingAcks.get(id); - if (pending === undefined) return; - pendingAcks.delete(id); - runtime.timers.cancel(pending.timer); - pending.resolve(result); - } - - function resolveAckFromFrame(frame: ConnectionFrame): void { - if (frame.replyTo === undefined) return; - if (frame.error !== undefined) { - resolveAck(frame.replyTo, { - ok: false, - reason: CONNECTION_ACK_REASON_REJECTED, - error: frame.error - }); - return; - } - resolveAck(frame.replyTo, { ok: true, payload: frame.payload }); - } - - function mapSendFailureToAck(result: ConnectionSendResult): ConnectionAckResult { - if (result.ok) return { ok: true, payload: undefined }; - if (result.reason === CONNECTION_SEND_REASON_CLOSED) { - return { ok: false, reason: CONNECTION_ACK_REASON_CLOSED, error: result.error }; - } - return { ok: false, reason: CONNECTION_ACK_REASON_TRANSPORT_ERROR, error: result.error }; - } - - function canBuffer(frame: ConnectionFrame, allowBuffer: boolean): boolean { - if (!allowBuffer) return false; - if (frame.ack === true) return false; - return bufferOptions.policy === CONNECTION_BUFFER_POLICY_BUFFER; - } - - function bufferFrame(frame: ConnectionFrame): ConnectionSendResult { - const maxMessages = bufferOptions.maxMessages ?? DEFAULT_BUFFER_MAX_MESSAGES; - if (bufferedFrames.length >= maxMessages) { - return { ok: false, reason: CONNECTION_SEND_REASON_BUFFER_FULL }; - } - bufferedFrames.push(frame); - return { ok: true, id: frame.id }; - } - - async function flushBuffer(): Promise { - while (isConnected() && bufferedFrames.length > 0) { - const frame = bufferedFrames.shift(); - if (frame === undefined) return; - const result = await sendFrame(frame, false); - if (!result.ok) { - bufferedFrames.unshift(frame); - return; - } - } - } - - async function sendFrame( - frame: ConnectionFrame, - allowBuffer: boolean - ): Promise { - try { - createFrame(frame); - } catch (err) { - return { ok: false, reason: CONNECTION_SEND_REASON_INVALID_FRAME, error: err }; - } - - if (!isConnected()) { - if (canBuffer(frame, allowBuffer)) return bufferFrame(frame); - if (bufferOptions.policy === CONNECTION_BUFFER_POLICY_DROP) { - return { ok: true, id: frame.id }; - } - return { ok: false, reason: CONNECTION_SEND_REASON_CLOSED }; - } - - const currentTransport = transport; - if (currentTransport === null || !currentTransport.canSend) { - return { ok: false, reason: CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED }; - } - const maxBytes = bufferOptions.maxBytes ?? DEFAULT_BUFFER_MAX_BYTES; - if (currentTransport.bufferedAmount > maxBytes) { - return { ok: false, reason: CONNECTION_SEND_REASON_BUFFER_FULL }; - } - - let encoded: string | ArrayBuffer; - try { - encoded = serializer.encode(frame); - } catch (err) { - logError(LOG_MSG_FRAME_ENCODE_FAILED, { error: err }); - return { ok: false, reason: CONNECTION_SEND_REASON_SERIALIZE_FAILED, error: err }; - } - - try { - const maybe = currentTransport.send(encoded); - if (isPromiseLike(maybe)) await maybe; - return { ok: true, id: frame.id }; - } catch (err) { - error = err; - logError(LOG_MSG_SEND_FAILED, { error: err }); - return { ok: false, reason: CONNECTION_SEND_REASON_TRANSPORT_ERROR, error: err }; - } - } - - function getAuthProvider(): - | (() => ConnectionAuthPayload | null | Promise) - | null { - if (typeof options.auth === 'function') return options.auth; - return options.auth?.getAuth ?? null; + return ( + stateTracker.state === CONNECTION_STATE_OPEN && + transport?.state === CONNECTION_TRANSPORT_STATE_OPEN + ); } async function runAuth(): Promise { if (!isConnected()) return { ok: false, reason: CONNECTION_AUTH_REASON_CLOSED }; - const provider = getAuthProvider(); - if (provider === null) return { ok: false, reason: CONNECTION_AUTH_REASON_NO_PROVIDER }; - let payload: ConnectionAuthPayload | null; + const auth = resolveConnectionAuth(options.auth); + if (auth.provider === null) return missingConnectionAuthProvider(); try { - payload = await provider(); + const payload = await auth.provider(); + if (payload === null) return missingConnectionAuthProvider(); + const result = await requestFrame(auth.authType, payload, { timeoutMs: auth.timeoutMs }); + return mapConnectionAckToAuthResult(result); } catch (err) { return { ok: false, reason: CONNECTION_AUTH_REASON_REJECTED, error: err }; } - if (payload === null) return { ok: false, reason: CONNECTION_AUTH_REASON_NO_PROVIDER }; - const authOptions = typeof options.auth === 'function' ? undefined : options.auth; - const authType = - typeof options.auth === 'function' ? CONNECTION_FRAME_TYPE_AUTH : authOptions?.authType; - const result = await requestFrame( - authType ?? CONNECTION_FRAME_TYPE_AUTH, - payload, - { timeoutMs: authOptions?.timeoutMs } - ); - if (result.ok) return { ok: true }; - if (result.reason === CONNECTION_ACK_REASON_TIMEOUT) { - return { ok: false, reason: CONNECTION_AUTH_REASON_TIMEOUT, error: result.error }; - } - if (result.reason === CONNECTION_ACK_REASON_CLOSED) { - return { ok: false, reason: CONNECTION_AUTH_REASON_CLOSED, error: result.error }; - } - if (result.reason === CONNECTION_ACK_REASON_REJECTED) { - return { ok: false, reason: CONNECTION_AUTH_REASON_REJECTED, error: result.error }; - } - return { ok: false, reason: CONNECTION_AUTH_REASON_TRANSPORT_ERROR, error: result.error }; } async function requestFrame( @@ -556,96 +284,68 @@ export function createConnection>((resolve) => { - pendingAcks.set(id, { - timer: ackTimer, - resolve: resolve as (result: ConnectionAckResult) => void - }); - runtime.timers.schedule( - ackTimer, - timeoutMs, - () => { - resolveAck(id, { ok: false, reason: CONNECTION_ACK_REASON_TIMEOUT }); - }, - { replace: true } - ); - }); - const result = await sendFrame(frame, requestOptions.buffer === true); - if (!result.ok) resolveAck(id, mapSendFailureToAck(result)); + const promise = acks.wait(id, timeoutMs); + const result = await sender.sendFrame(frame, requestOptions.buffer === true); + if (!result.ok) acks.resolve(id, acks.mapSendFailure(result)); return promise; } async function joinConfiguredChannels(reconnect: boolean): Promise { - const configured = (options.channels ?? {}) as Record< - string, - ConnectionChannelOptions | undefined - >; - for (const channelName of Object.keys(configured)) { - const configuredOptions = configured[channelName] ?? {}; - const current = connection.channel( - channelName, - configuredOptions - ) as InternalConnectionChannel; - if (configuredOptions.autoJoin === true || (reconnect && current.shouldRejoin)) { - await current.join(); - } - } - if (!reconnect) return; - for (const current of channels.values()) { - if (current.shouldRejoin) await current.rejoin(); - } + await channelRegistry.joinConfigured(reconnect, options.channels ?? {}, connection); } async function connectInternal(reconnect: boolean): Promise { if (disposed) { return { ok: false, - state, + state: stateTracker.state, reason: CONNECTION_CONNECT_REASON_DISPOSED }; } - if (state === CONNECTION_STATE_OPEN && isConnected()) { - return { ok: true, state, reused: true }; + if (stateTracker.state === CONNECTION_STATE_OPEN && isConnected()) { + return { ok: true, state: stateTracker.state, reused: true }; } if (connectPromise !== null) return connectPromise; connectPromise = (async () => { intentionalClose = false; - cancelTimer(TIMER_KEY_RECONNECT); + timers.cancel(TIMER_KEY_RECONNECT); const nextTransport = resolveTransport(); transport = nextTransport; attachTransport(nextTransport); - emitState(reconnect ? CONNECTION_STATE_RECONNECTING : CONNECTION_STATE_CONNECTING); + stateTracker.transition(reconnect ? CONNECTION_STATE_RECONNECTING : CONNECTION_STATE_CONNECTING); try { await nextTransport.open(); markOpen(); - if (getAuthProvider() !== null) { + if (resolveConnectionAuth(options.auth).provider !== null) { const auth = await runAuth(); if (!auth.ok) { - error = auth.error ?? auth.reason; - logWarn(LOG_MSG_AUTH_FAILED, auth); + const authError = auth.error ?? auth.reason; + stateTracker.setError(authError); + emitConnectionDiagnostic(diagnostics, CONNECTION_DIAGNOSTIC_EVENTS.AUTH_FAILED, auth); detachTransport(); transport?.close(undefined, CONNECTION_CLOSE_REASON_AUTH_FAILED); - markClosed(CONNECTION_STATE_FAILED, error); + markClosed(CONNECTION_STATE_FAILED, authError); return { ok: false, - state, + state: stateTracker.state, reason: CONNECTION_CONNECT_REASON_AUTH_FAILED, - error + error: authError }; } } - await flushBuffer(); + await sender.flushBuffer(); await joinConfiguredChannels(reconnect); - return { ok: true, state }; + return { ok: true, state: stateTracker.state }; } catch (err) { - error = err; - logError(LOG_MSG_CONNECT_FAILED, { error: err }); - emitState(CONNECTION_STATE_FAILED, err); + stateTracker.setError(err); + emitConnectionDiagnostic(diagnostics, CONNECTION_DIAGNOSTIC_EVENTS.CONNECT_FAILED, { + error: err + }); + stateTracker.transition(CONNECTION_STATE_FAILED, err); return { ok: false, - state, + state: stateTracker.state, reason: CONNECTION_CONNECT_REASON_TRANSPORT_ERROR, error: err }; @@ -659,118 +359,51 @@ export function createConnection void) => void; - removeEventListener?: (type: string, listener: () => void) => void; - document?: { - readonly visibilityState?: string; - addEventListener?: (type: string, listener: () => void) => void; - removeEventListener?: (type: string, listener: () => void) => void; - }; - }; - if (reconnectOnOnline && target.addEventListener && target.removeEventListener) { - const onOnline = (): void => { - if (intentionalClose || disposed || !isClosedLike(state)) return; - logDebug(LOG_MSG_BROWSER_RECONNECT); - void connectInternal(true); - }; - target.addEventListener(BROWSER_EVENT_ONLINE, onOnline); - detachBrowserListeners.push(() => { - target.removeEventListener?.(BROWSER_EVENT_ONLINE, onOnline); - }); - } - if ( - reconnectOnVisible && - target.document?.addEventListener && - target.document.removeEventListener - ) { - const onVisible = (): void => { - if (target.document?.visibilityState !== DOCUMENT_VISIBILITY_VISIBLE) return; - if (intentionalClose || disposed || !isClosedLike(state)) return; - logDebug(LOG_MSG_BROWSER_RECONNECT); - void connectInternal(true); - }; - target.document.addEventListener(BROWSER_EVENT_VISIBILITY_CHANGE, onVisible); - detachBrowserListeners.push(() => { - target.document?.removeEventListener?.(BROWSER_EVENT_VISIBILITY_CHANGE, onVisible); - }); - } - } - - function wireSession(): () => void { - const sessionOptions = options.session; - if ( - sessionOptions === undefined || - sessionOptions === false || - sessionOptions.enabled === false - ) { - return () => {}; - } - const source = runtime.session; - if (source === undefined) return () => {}; - return source.onChange((change) => { - if (change.event === SESSION_EVENT_REFRESHED && sessionOptions.reauthOnRefresh !== false) { - logDebug(LOG_MSG_SESSION_REFRESHED); - void connection.reauthenticate().then((result) => { - if (!result.ok) logWarn(LOG_MSG_REAUTH_FAILED, result); - }); - return; - } - if ( - (change.event === SESSION_EVENT_EXPIRED || change.event === SESSION_EVENT_REVOKED) && - sessionOptions.disconnectOnExpire !== false - ) { - logWarn( - change.event === SESSION_EVENT_EXPIRED ? LOG_MSG_SESSION_EXPIRED : LOG_MSG_SESSION_REVOKED - ); - connection.disconnect(CONNECTION_CLOSE_REASON_SESSION_EXPIRED); - } - }); + if (stateTracker.state !== CONNECTION_STATE_CLOSED) markClosed(CONNECTION_STATE_CLOSED); } function reportChannelListenerError(event: string, err: unknown): void { - logError(listenerThrewMessage(event), { error: err }); + emitScopedConnectionDiagnostic( + diagnostics, + scope, + CONNECTION_DIAGNOSTIC_EVENTS.LISTENER_THREW, + { error: err, event } + ); } let detachSession = (): void => {}; + let detachBrowserReconnect = (): void => {}; const connection: Connection = { name, get state() { - return state; + return stateTracker.state; }, get connected() { return isConnected(); }, get generation() { - return generation; + return stateTracker.generation; }, get error() { - return error; + return stateTracker.error; }, get openedAt() { - return openedAt; + return stateTracker.openedAt; }, get closedAt() { - return closedAt; + return stateTracker.closedAt; }, get lastMessageAt() { - return lastMessageAt; + return stateTracker.lastMessageAt; }, get reconnectAttempt() { - return reconnectAttempt; + return stateTracker.reconnectAttempt; }, connect() { return connectInternal(false); @@ -783,7 +416,7 @@ export function createConnection { - const existing = channels.get(channelName); - if (existing !== undefined) return existing as ConnectionChannel; - const created = createConnectionChannel(channelName, channelOptions, { - connection: connection as unknown as Connection>, - reportListenerError: reportChannelListenerError - }); - channels.set(channelName, created as InternalConnectionChannel); - return created; + return channelRegistry.getOrCreate( + channelName, + channelOptions, + connection as unknown as Connection> + ); }, channels() { - return [...channels.values()]; + return channelRegistry.channels(); }, hasChannel(channelName) { - return channels.has(channelName); + return channelRegistry.has(channelName); }, async leaveChannel(channelName) { - await channels.get(channelName)?.leave(); + await channelRegistry.leave(channelName); }, onState(listener) { - stateListeners.add(listener); - return () => { - stateListeners.delete(listener); - }; + return events.onState(listener); }, onAny(listener) { - globalListeners.add(listener); - return () => { - globalListeners.delete(listener); - }; + return events.onAny(listener); }, dispose() { if (disposed) return; disposed = true; closeTransport(CONNECTION_CLOSE_REASON_DISPOSE); detachSession(); - for (const detach of detachBrowserListeners) detach(); - detachBrowserListeners.length = 0; + detachBrowserReconnect(); detachTransport(); - for (const current of channels.values()) current.dispose(); - channels.clear(); - stateListeners.clear(); - globalListeners.clear(); - bufferedFrames.length = 0; + channelRegistry.dispose(); + events.clear(); + frameBuffer.clear(); } }; - detachSession = wireSession(); - wireBrowserReconnect(); + detachSession = wireConnectionSession({ + sessionOptions: options.session, + source: runtime.session, + reauthenticate: () => connection.reauthenticate(), + disconnect: (reason) => { + connection.disconnect(reason); + }, + diagnostics + }); + detachBrowserReconnect = wireBrowserReconnect({ + disabled: reconnectPolicy.disabled, + reconnectOptions: reconnectPolicy.options, + shouldReconnect: () => !intentionalClose && !disposed && stateTracker.isClosedLike(), + reconnect: () => { + void connectInternal(true); + }, + diagnostics + }); return connection; } diff --git a/src/arts/conn/consts.ts b/src/arts/conn/consts.ts index 9cef362..a01b0a1 100644 --- a/src/arts/conn/consts.ts +++ b/src/arts/conn/consts.ts @@ -1,6 +1,23 @@ export const LOGGER_CATEGORY = 'conn'; export const LOGGER_SCOPE_SEPARATOR = ':'; +export const CONNECTION_DIAGNOSTIC_EVENTS = { + AUTH_FAILED: 'auth_failed', + BROWSER_RECONNECT: 'browser_reconnect', + CONNECT_FAILED: 'connect_failed', + FRAME_DECODE_FAILED: 'frame_decode_failed', + FRAME_ENCODE_FAILED: 'frame_encode_failed', + HEARTBEAT_TIMEOUT: 'heartbeat_timeout', + LISTENER_THREW: 'listener_threw', + REAUTH_FAILED: 'reauth_failed', + RECONNECT_EXHAUSTED: 'reconnect_exhausted', + SEND_FAILED: 'send_failed', + SESSION_EXPIRED: 'session_expired', + SESSION_REFRESHED: 'session_refreshed', + SESSION_REVOKED: 'session_revoked', + TRANSPORT_ERROR: 'transport_error' +} as const; + export const CONNECTION_STATE_IDLE = 'idle'; export const CONNECTION_STATE_CONNECTING = 'connecting'; export const CONNECTION_STATE_OPEN = 'open'; @@ -188,6 +205,7 @@ export const ERROR_NAME_INVALID_NAME = 'ConnInvalidConnectionNameError'; export const ERROR_NAME_INVALID_FRAME = 'ConnInvalidFrameError'; export const ERROR_NAME_CHANNEL_ALREADY_EXISTS = 'ConnChannelAlreadyExistsError'; export const ERROR_NAME_CHANNEL_NOT_FOUND = 'ConnChannelNotFoundError'; +export const ERROR_NAME_WEBSOCKET_UNAVAILABLE = 'ConnWebSocketUnavailableError'; export const ERROR_MSG_DISPOSED_SUFFIX = '() called on a disposed connection engine'; export const ERROR_MSG_ALREADY_EXISTS_PREFIX = 'connection already exists: '; diff --git a/src/arts/conn/diagnostics.ts b/src/arts/conn/diagnostics.ts new file mode 100644 index 0000000..5cc38e9 --- /dev/null +++ b/src/arts/conn/diagnostics.ts @@ -0,0 +1,136 @@ +import { + LogLevel, + createCatalogDiagnostics, + type DiagnosticCatalog, + type DiagnosticEvent, + type Diagnostics +} from '$libs/logr'; +import { + CONNECTION_DIAGNOSTIC_EVENTS, + CONNECTION_EVENT_MESSAGE, + LOGGER_CATEGORY, + LOG_MSG_AUTH_FAILED, + LOG_MSG_BROWSER_RECONNECT, + LOG_MSG_CONNECT_FAILED, + LOG_MSG_FRAME_DECODE_FAILED, + LOG_MSG_FRAME_ENCODE_FAILED, + LOG_MSG_HEARTBEAT_TIMEOUT, + LOG_MSG_REAUTH_FAILED, + LOG_MSG_RECONNECT_EXHAUSTED, + LOG_MSG_SEND_FAILED, + LOG_MSG_SESSION_EXPIRED, + LOG_MSG_SESSION_REFRESHED, + LOG_MSG_SESSION_REVOKED, + LOG_MSG_TRANSPORT_ERROR +} from './consts.ts'; +import { listenerThrewMessage } from './helpers.ts'; +import type { Logger } from '$libs/logr'; + +export type ConnectionDiagnosticType = + (typeof CONNECTION_DIAGNOSTIC_EVENTS)[keyof typeof CONNECTION_DIAGNOSTIC_EVENTS]; + +export type ConnectionDiagnosticEvent = DiagnosticEvent; +export type ConnectionDiagnostics = Diagnostics; + +const CONNECTION_DIAGNOSTIC_LOGS: DiagnosticCatalog = { + [CONNECTION_DIAGNOSTIC_EVENTS.AUTH_FAILED]: { + level: LogLevel.WARN, + message: LOG_MSG_AUTH_FAILED + }, + [CONNECTION_DIAGNOSTIC_EVENTS.BROWSER_RECONNECT]: { + level: LogLevel.DEBUG, + message: LOG_MSG_BROWSER_RECONNECT + }, + [CONNECTION_DIAGNOSTIC_EVENTS.CONNECT_FAILED]: { + level: LogLevel.ERROR, + message: LOG_MSG_CONNECT_FAILED + }, + [CONNECTION_DIAGNOSTIC_EVENTS.FRAME_DECODE_FAILED]: { + level: LogLevel.ERROR, + message: LOG_MSG_FRAME_DECODE_FAILED + }, + [CONNECTION_DIAGNOSTIC_EVENTS.FRAME_ENCODE_FAILED]: { + level: LogLevel.ERROR, + message: LOG_MSG_FRAME_ENCODE_FAILED + }, + [CONNECTION_DIAGNOSTIC_EVENTS.HEARTBEAT_TIMEOUT]: { + level: LogLevel.WARN, + message: LOG_MSG_HEARTBEAT_TIMEOUT + }, + [CONNECTION_DIAGNOSTIC_EVENTS.LISTENER_THREW]: (event) => ({ + level: LogLevel.ERROR, + message: listenerThrewMessage(listenerEventName(event.meta)) + }), + [CONNECTION_DIAGNOSTIC_EVENTS.REAUTH_FAILED]: { + level: LogLevel.WARN, + message: LOG_MSG_REAUTH_FAILED + }, + [CONNECTION_DIAGNOSTIC_EVENTS.RECONNECT_EXHAUSTED]: { + level: LogLevel.WARN, + message: LOG_MSG_RECONNECT_EXHAUSTED + }, + [CONNECTION_DIAGNOSTIC_EVENTS.SEND_FAILED]: { + level: LogLevel.ERROR, + message: LOG_MSG_SEND_FAILED + }, + [CONNECTION_DIAGNOSTIC_EVENTS.SESSION_EXPIRED]: { + level: LogLevel.WARN, + message: LOG_MSG_SESSION_EXPIRED + }, + [CONNECTION_DIAGNOSTIC_EVENTS.SESSION_REFRESHED]: { + level: LogLevel.DEBUG, + message: LOG_MSG_SESSION_REFRESHED + }, + [CONNECTION_DIAGNOSTIC_EVENTS.SESSION_REVOKED]: { + level: LogLevel.WARN, + message: LOG_MSG_SESSION_REVOKED + }, + [CONNECTION_DIAGNOSTIC_EVENTS.TRANSPORT_ERROR]: { + level: LogLevel.ERROR, + message: LOG_MSG_TRANSPORT_ERROR + } +}; + +function listenerEventName(meta: unknown): string { + if (typeof meta !== 'object' || meta === null) return CONNECTION_EVENT_MESSAGE; + if (!('event' in meta)) return CONNECTION_EVENT_MESSAGE; + const event = meta.event; + return typeof event === 'string' && event.length > 0 ? event : CONNECTION_EVENT_MESSAGE; +} + +export function createConnectionDiagnostics(input: { + readonly logger?: Logger; + readonly scope: string; +}): ConnectionDiagnostics { + return createCatalogDiagnostics({ + logger: input.logger, + defaultCategory: input.scope, + catalog: CONNECTION_DIAGNOSTIC_LOGS + }); +} + +export function emitConnectionDiagnostic( + diagnostics: ConnectionDiagnostics, + type: ConnectionDiagnosticType, + meta?: unknown +): void { + diagnostics.emit({ + artifact: LOGGER_CATEGORY, + type, + meta + }); +} + +export function emitScopedConnectionDiagnostic( + diagnostics: ConnectionDiagnostics, + scope: string, + type: ConnectionDiagnosticType, + meta?: unknown +): void { + diagnostics.emit({ + artifact: LOGGER_CATEGORY, + scope, + type, + meta + }); +} diff --git a/src/arts/conn/errors.ts b/src/arts/conn/errors.ts index 594273e..66f2df8 100644 --- a/src/arts/conn/errors.ts +++ b/src/arts/conn/errors.ts @@ -5,7 +5,8 @@ import { ERROR_NAME_DISPOSED, ERROR_NAME_INVALID_FRAME, ERROR_NAME_INVALID_NAME, - ERROR_NAME_NOT_FOUND + ERROR_NAME_NOT_FOUND, + ERROR_NAME_WEBSOCKET_UNAVAILABLE } from './consts.ts'; abstract class ConnEngineError extends Error { @@ -63,6 +64,10 @@ export class ConnChannelNotFoundError extends ConnEngineError { } } +export class ConnWebSocketUnavailableError extends ConnEngineError { + readonly name = ERROR_NAME_WEBSOCKET_UNAVAILABLE; +} + export function isConnDisposedError(value: unknown): value is ConnDisposedError { return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_DISPOSED; } @@ -88,3 +93,11 @@ export function isConnInvalidConnectionNameError( export function isConnInvalidFrameError(value: unknown): value is ConnInvalidFrameError { return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_INVALID_FRAME; } + +export function isConnWebSocketUnavailableError( + value: unknown +): value is ConnWebSocketUnavailableError { + return ( + value instanceof Error && (value as { name?: string }).name === ERROR_NAME_WEBSOCKET_UNAVAILABLE + ); +} diff --git a/src/arts/conn/frame-buffer.ts b/src/arts/conn/frame-buffer.ts new file mode 100644 index 0000000..0584c85 --- /dev/null +++ b/src/arts/conn/frame-buffer.ts @@ -0,0 +1,59 @@ +import { + CONNECTION_BUFFER_POLICY_BUFFER, + CONNECTION_BUFFER_POLICY_DROP, + CONNECTION_SEND_REASON_BUFFER_FULL, + DEFAULT_BUFFER_MAX_BYTES, + DEFAULT_BUFFER_MAX_MESSAGES +} from './consts.ts'; +import type { ConnectionBufferOptions, ConnectionFrame, ConnectionSendResult } from './types.ts'; + +export interface ConnectionFrameBuffer { + readonly maxBytes: number; + readonly length: number; + canBuffer(frame: ConnectionFrame, allowBuffer: boolean): boolean; + shouldDropClosedFrame(): boolean; + push(frame: ConnectionFrame): ConnectionSendResult; + shift(): ConnectionFrame | undefined; + unshift(frame: ConnectionFrame): void; + clear(): void; +} + +export function createConnectionFrameBuffer( + options: ConnectionBufferOptions = {} +): ConnectionFrameBuffer { + const frames: ConnectionFrame[] = []; + + return { + get maxBytes() { + return options.maxBytes ?? DEFAULT_BUFFER_MAX_BYTES; + }, + get length() { + return frames.length; + }, + canBuffer(frame: ConnectionFrame, allowBuffer: boolean): boolean { + if (!allowBuffer) return false; + if (frame.ack === true) return false; + return options.policy === CONNECTION_BUFFER_POLICY_BUFFER; + }, + shouldDropClosedFrame(): boolean { + return options.policy === CONNECTION_BUFFER_POLICY_DROP; + }, + push(frame: ConnectionFrame): ConnectionSendResult { + const maxMessages = options.maxMessages ?? DEFAULT_BUFFER_MAX_MESSAGES; + if (frames.length >= maxMessages) { + return { ok: false, reason: CONNECTION_SEND_REASON_BUFFER_FULL }; + } + frames.push(frame); + return { ok: true, id: frame.id }; + }, + shift() { + return frames.shift(); + }, + unshift(frame: ConnectionFrame): void { + frames.unshift(frame); + }, + clear(): void { + frames.length = 0; + } + }; +} diff --git a/src/arts/conn/heartbeat.ts b/src/arts/conn/heartbeat.ts new file mode 100644 index 0000000..621a20f --- /dev/null +++ b/src/arts/conn/heartbeat.ts @@ -0,0 +1,77 @@ +import { + CONNECTION_DIAGNOSTIC_EVENTS, + CONNECTION_CLOSE_REASON_HEARTBEAT_TIMEOUT, + CONNECTION_FRAME_TYPE_PING, + DEFAULT_HEARTBEAT_ENABLED, + DEFAULT_HEARTBEAT_INTERVAL_MS, + DEFAULT_HEARTBEAT_TIMEOUT_MS, + TIMER_KEY_HEARTBEAT, + TIMER_KEY_HEARTBEAT_TIMEOUT +} from './consts.ts'; +import { emitConnectionDiagnostic, type ConnectionDiagnostics } from './diagnostics.ts'; +import { createFrame } from './serializer.ts'; +import type { ConnectionHeartbeatOptions, ConnectionSendResult } from './types.ts'; + +interface ConnectionHeartbeatRuntime { + readonly heartbeatOptions?: ConnectionHeartbeatOptions | false; + isConnected(): boolean; + sendFrame( + frame: ReturnType, + allowBuffer: boolean + ): Promise; + scheduleInterval(kind: string, everyMs: number, task: () => void, id?: string): void; + scheduleTimer(kind: string, delayMs: number, task: () => void | Promise, id?: string): void; + cancelTimer(kind: string, id?: string): void; + closeTransport(reason: string): void; + readonly diagnostics: ConnectionDiagnostics; +} + +export interface ConnectionHeartbeat { + received(): void; + start(): void; + stop(): void; +} + +export function createConnectionHeartbeat( + runtime: ConnectionHeartbeatRuntime +): ConnectionHeartbeat { + async function sendHeartbeat(): Promise { + if (!runtime.isConnected()) return; + const pingType = + runtime.heartbeatOptions === false ? undefined : runtime.heartbeatOptions?.pingType; + const result = await runtime.sendFrame( + createFrame({ + type: pingType ?? CONNECTION_FRAME_TYPE_PING, + payload: undefined + }), + false + ); + if (!result.ok) return; + const timeoutMs = + runtime.heartbeatOptions === false + ? DEFAULT_HEARTBEAT_TIMEOUT_MS + : (runtime.heartbeatOptions?.timeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS); + runtime.scheduleTimer(TIMER_KEY_HEARTBEAT_TIMEOUT, timeoutMs, () => { + emitConnectionDiagnostic(runtime.diagnostics, CONNECTION_DIAGNOSTIC_EVENTS.HEARTBEAT_TIMEOUT); + runtime.closeTransport(CONNECTION_CLOSE_REASON_HEARTBEAT_TIMEOUT); + }); + } + + return { + received(): void { + runtime.cancelTimer(TIMER_KEY_HEARTBEAT_TIMEOUT); + }, + start(): void { + if (runtime.heartbeatOptions === false) return; + if ((runtime.heartbeatOptions?.enabled ?? DEFAULT_HEARTBEAT_ENABLED) === false) return; + const intervalMs = runtime.heartbeatOptions?.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS; + runtime.scheduleInterval(TIMER_KEY_HEARTBEAT, intervalMs, () => { + void sendHeartbeat(); + }); + }, + stop(): void { + runtime.cancelTimer(TIMER_KEY_HEARTBEAT); + runtime.cancelTimer(TIMER_KEY_HEARTBEAT_TIMEOUT); + } + }; +} diff --git a/src/arts/conn/index.ts b/src/arts/conn/index.ts index f982fb5..059c312 100644 --- a/src/arts/conn/index.ts +++ b/src/arts/conn/index.ts @@ -2,6 +2,11 @@ export { createActiveConnections } from './active-connections.svelte.ts'; export { createConnectionChannel } from './channel.ts'; export { createConnection } from './connection.ts'; export { createEngineConnections } from './engine-connections.ts'; +export { + createConnectionDiagnostics, + emitConnectionDiagnostic, + emitScopedConnectionDiagnostic +} from './diagnostics.ts'; export { assertConnectionFrame, createFrame } from './serializer.ts'; export { jsonConnectionSerializer } from './serializers/json.ts'; export { createMockTransport } from './transports/mock.ts'; @@ -11,3 +16,8 @@ export * from './consts.ts'; export * from './errors.ts'; export * from './helpers.ts'; export * from './types.ts'; +export type { + ConnectionDiagnosticEvent, + ConnectionDiagnosticType, + ConnectionDiagnostics +} from './diagnostics.ts'; diff --git a/src/arts/conn/reconnect.ts b/src/arts/conn/reconnect.ts new file mode 100644 index 0000000..04e5bfd --- /dev/null +++ b/src/arts/conn/reconnect.ts @@ -0,0 +1,62 @@ +import { computeBackoffDelay } from '$libs/timers'; +import { + DEFAULT_RECONNECT_ENABLED, + DEFAULT_RECONNECT_FACTOR, + DEFAULT_RECONNECT_JITTER_MS, + DEFAULT_RECONNECT_MAX_DELAY_MS, + DEFAULT_RECONNECT_MIN_DELAY_MS +} from './consts.ts'; +import type { ConnectionOptions, ConnectionReconnectOptions } from './types.ts'; + +export type ConnectionReconnectPlan = + | { + readonly ok: true; + readonly attempt: number; + readonly delayMs: number; + } + | { + readonly ok: false; + readonly reconnectAttempt: number; + readonly maxAttempts: number; + }; + +export interface ConnectionReconnectPolicy { + readonly disabled: boolean; + readonly options?: ConnectionReconnectOptions; + isEnabled(input: { readonly disposed: boolean; readonly intentionalClose: boolean }): boolean; + next(currentAttempt: number): ConnectionReconnectPlan; +} + +export function createConnectionReconnectPolicy( + reconnect: ConnectionOptions['reconnect'] +): ConnectionReconnectPolicy { + const disabled = reconnect === false; + const options = disabled ? undefined : reconnect; + + return { + disabled, + options, + isEnabled(input) { + if (input.disposed || input.intentionalClose) return false; + if (disabled) return false; + return options?.enabled ?? DEFAULT_RECONNECT_ENABLED; + }, + next(currentAttempt) { + const maxAttempts = options?.maxAttempts; + if (maxAttempts !== undefined && currentAttempt >= maxAttempts) { + return { ok: false, reconnectAttempt: currentAttempt, maxAttempts }; + } + const attempt = currentAttempt + 1; + return { + ok: true, + attempt, + delayMs: computeBackoffDelay(attempt - 1, { + minDelayMs: options?.minDelayMs ?? DEFAULT_RECONNECT_MIN_DELAY_MS, + maxDelayMs: options?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS, + factor: options?.factor ?? DEFAULT_RECONNECT_FACTOR, + jitterMs: options?.jitterMs ?? DEFAULT_RECONNECT_JITTER_MS + }) + }; + } + }; +} diff --git a/src/arts/conn/sender.ts b/src/arts/conn/sender.ts new file mode 100644 index 0000000..b807247 --- /dev/null +++ b/src/arts/conn/sender.ts @@ -0,0 +1,99 @@ +import { isPromiseLike } from '$libs/standard-schema'; +import { + CONNECTION_DIAGNOSTIC_EVENTS, + CONNECTION_SEND_REASON_BUFFER_FULL, + CONNECTION_SEND_REASON_CLOSED, + CONNECTION_SEND_REASON_INVALID_FRAME, + CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED, + CONNECTION_SEND_REASON_SERIALIZE_FAILED, + CONNECTION_SEND_REASON_TRANSPORT_ERROR +} from './consts.ts'; +import { emitConnectionDiagnostic, type ConnectionDiagnostics } from './diagnostics.ts'; +import type { ConnectionFrameBuffer } from './frame-buffer.ts'; +import { createFrame } from './serializer.ts'; +import type { + ConnectionFrame, + ConnectionSendResult, + ConnectionSerializer, + ConnectionTransport +} from './types.ts'; + +interface ConnectionSenderRuntime { + readonly serializer: ConnectionSerializer; + readonly frameBuffer: ConnectionFrameBuffer; + isConnected(): boolean; + getTransport(): ConnectionTransport | null; + setError(error: unknown): void; + readonly diagnostics: ConnectionDiagnostics; +} + +export interface ConnectionSender { + sendFrame(frame: ConnectionFrame, allowBuffer: boolean): Promise; + flushBuffer(): Promise; +} + +export function createConnectionSender(runtime: ConnectionSenderRuntime): ConnectionSender { + async function sendFrame( + frame: ConnectionFrame, + allowBuffer: boolean + ): Promise { + try { + createFrame(frame); + } catch (err) { + return { ok: false, reason: CONNECTION_SEND_REASON_INVALID_FRAME, error: err }; + } + + if (!runtime.isConnected()) { + if (runtime.frameBuffer.canBuffer(frame, allowBuffer)) return runtime.frameBuffer.push(frame); + if (runtime.frameBuffer.shouldDropClosedFrame()) return { ok: true, id: frame.id }; + return { ok: false, reason: CONNECTION_SEND_REASON_CLOSED }; + } + + const currentTransport = runtime.getTransport(); + if (currentTransport === null || !currentTransport.canSend) { + return { ok: false, reason: CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED }; + } + if (currentTransport.bufferedAmount > runtime.frameBuffer.maxBytes) { + return { ok: false, reason: CONNECTION_SEND_REASON_BUFFER_FULL }; + } + + let encoded: string | ArrayBuffer; + try { + encoded = runtime.serializer.encode(frame); + } catch (err) { + emitConnectionDiagnostic( + runtime.diagnostics, + CONNECTION_DIAGNOSTIC_EVENTS.FRAME_ENCODE_FAILED, + { error: err } + ); + return { ok: false, reason: CONNECTION_SEND_REASON_SERIALIZE_FAILED, error: err }; + } + + try { + const maybe = currentTransport.send(encoded); + if (isPromiseLike(maybe)) await maybe; + return { ok: true, id: frame.id }; + } catch (err) { + runtime.setError(err); + emitConnectionDiagnostic(runtime.diagnostics, CONNECTION_DIAGNOSTIC_EVENTS.SEND_FAILED, { + error: err + }); + return { ok: false, reason: CONNECTION_SEND_REASON_TRANSPORT_ERROR, error: err }; + } + } + + return { + sendFrame, + async flushBuffer(): Promise { + while (runtime.isConnected() && runtime.frameBuffer.length > 0) { + const frame = runtime.frameBuffer.shift(); + if (frame === undefined) return; + const result = await sendFrame(frame, false); + if (!result.ok) { + runtime.frameBuffer.unshift(frame); + return; + } + } + } + }; +} diff --git a/src/arts/conn/session-wiring.ts b/src/arts/conn/session-wiring.ts new file mode 100644 index 0000000..76f46ff --- /dev/null +++ b/src/arts/conn/session-wiring.ts @@ -0,0 +1,66 @@ +import { + CONNECTION_DIAGNOSTIC_EVENTS, + CONNECTION_CLOSE_REASON_SESSION_EXPIRED, + SESSION_EVENT_EXPIRED, + SESSION_EVENT_REFRESHED, + SESSION_EVENT_REVOKED +} from './consts.ts'; +import { emitConnectionDiagnostic, type ConnectionDiagnostics } from './diagnostics.ts'; +import type { + ConnectionAuthResult, + ConnectionSessionOptions, + ConnectionSessionSource +} from './types.ts'; + +interface ConnectionSessionWiringOptions { + readonly sessionOptions?: false | ConnectionSessionOptions; + readonly source?: ConnectionSessionSource; + readonly reauthenticate: () => Promise; + readonly disconnect: (reason: string) => void; + readonly diagnostics: ConnectionDiagnostics; +} + +export function wireConnectionSession(options: ConnectionSessionWiringOptions): () => void { + const sessionOptions = options.sessionOptions; + if ( + sessionOptions === undefined || + sessionOptions === false || + sessionOptions.enabled === false + ) { + return () => {}; + } + if (options.source === undefined) return () => {}; + + return options.source.onChange((change) => { + if (change.event === SESSION_EVENT_REFRESHED && sessionOptions.reauthOnRefresh !== false) { + emitConnectionDiagnostic( + options.diagnostics, + CONNECTION_DIAGNOSTIC_EVENTS.SESSION_REFRESHED, + { event: change.event } + ); + void options.reauthenticate().then((result) => { + if (!result.ok) { + emitConnectionDiagnostic( + options.diagnostics, + CONNECTION_DIAGNOSTIC_EVENTS.REAUTH_FAILED, + result + ); + } + }); + return; + } + if ( + (change.event === SESSION_EVENT_EXPIRED || change.event === SESSION_EVENT_REVOKED) && + sessionOptions.disconnectOnExpire !== false + ) { + emitConnectionDiagnostic( + options.diagnostics, + change.event === SESSION_EVENT_EXPIRED + ? CONNECTION_DIAGNOSTIC_EVENTS.SESSION_EXPIRED + : CONNECTION_DIAGNOSTIC_EVENTS.SESSION_REVOKED, + { event: change.event } + ); + options.disconnect(CONNECTION_CLOSE_REASON_SESSION_EXPIRED); + } + }); +} diff --git a/src/arts/conn/test/active-connections.test.ts b/src/arts/conn/test/active-connections.test.ts new file mode 100644 index 0000000..b654e9b --- /dev/null +++ b/src/arts/conn/test/active-connections.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { + CONNECTION_STATE_CLOSED, + CONNECTION_STATE_IDLE, + CONNECTION_STATE_OPEN, + createMockTransport +} from '../index.ts'; +import { createActiveConnections } from '../active-connections.svelte.ts'; + +describe('createActiveConnections', () => { + it('tracks active names and connection state buckets', async () => { + const Connections = createActiveConnections(); + const Main = Connections.createConnection('main', { + transport: createMockTransport(), + heartbeat: false, + reconnect: false + }); + Connections.createConnection('chat', { + transport: createMockTransport(), + heartbeat: false, + reconnect: false + }); + + expect(Connections.size).toBe(2); + expect(Connections.activeNames).toEqual(['main', 'chat']); + expect(Connections.states).toEqual({ + main: CONNECTION_STATE_IDLE, + chat: CONNECTION_STATE_IDLE + }); + expect(Connections.allConnected).toBe(false); + expect(Connections.anyConnected).toBe(false); + + await Main.connect(); + + expect(Connections.connectedNames).toEqual(['main']); + expect(Connections.anyConnected).toBe(true); + expect(Connections.allConnected).toBe(false); + + Main.disconnect(); + + expect(Connections.closedNames).toEqual(['main']); + expect(Connections.states.main).toBe(CONNECTION_STATE_CLOSED); + + Connections.dispose(); + expect(Connections.size).toBe(0); + expect(Connections.activeNames).toEqual([]); + expect(Connections.states).toEqual({}); + }); + + it('keeps active state updated when operations go through the active root', async () => { + const Connections = createActiveConnections(); + Connections.createConnection('main', { + transport: createMockTransport(), + heartbeat: false, + reconnect: false + }); + + await Connections.openConnection('main'); + expect(Connections.states.main).toBe(CONNECTION_STATE_OPEN); + expect(Connections.allConnected).toBe(true); + + Connections.closeConnection('main'); + expect(Connections.states.main).toBe(CONNECTION_STATE_CLOSED); + + Connections.dispose(); + }); +}); diff --git a/src/arts/conn/test/connection-state.test.ts b/src/arts/conn/test/connection-state.test.ts new file mode 100644 index 0000000..c1ba173 --- /dev/null +++ b/src/arts/conn/test/connection-state.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { + CONNECTION_STATE_CLOSED, + CONNECTION_STATE_OPEN, + CONNECTION_STATE_RECONNECTING +} from '../consts.ts'; +import { createConnectionStateTracker } from '../connection-state.ts'; +import type { ConnectionStateChange } from '../types.ts'; + +describe('ConnectionStateTracker', () => { + it('tracks open/close timestamps and emits state changes', () => { + let now = 10; + const changes: ConnectionStateChange[] = []; + const tracker = createConnectionStateTracker({ + name: 'main', + now: () => now, + emitState: (change) => { + changes.push(change); + } + }); + + expect(tracker.markOpen()).toBe(true); + expect(tracker.state).toBe(CONNECTION_STATE_OPEN); + expect(tracker.generation).toBe(1); + expect(tracker.openedAt).toBe(10); + + now = 20; + expect(tracker.markClosed(CONNECTION_STATE_CLOSED)).toBe(true); + expect(tracker.state).toBe(CONNECTION_STATE_CLOSED); + expect(tracker.closedAt).toBe(20); + expect(changes.map((change) => change.to)).toEqual([ + CONNECTION_STATE_OPEN, + CONNECTION_STATE_CLOSED + ]); + }); + + it('keeps duplicate close notifications idempotent', () => { + let now = 100; + const tracker = createConnectionStateTracker({ + name: 'main', + now: () => now, + emitState: () => {} + }); + + tracker.markOpen(); + tracker.markClosed(CONNECTION_STATE_CLOSED); + + now = 200; + expect(tracker.markClosed(CONNECTION_STATE_CLOSED)).toBe(false); + expect(tracker.closedAt).toBe(100); + }); + + it('runs cleanup before emitting close transitions', () => { + const order: string[] = []; + const tracker = createConnectionStateTracker({ + name: 'main', + now: () => 1, + emitState: () => { + order.push('emit'); + } + }); + + tracker.markOpen(); + order.length = 0; + tracker.markClosed(CONNECTION_STATE_RECONNECTING, undefined, () => { + order.push('cleanup'); + }); + + expect(order).toEqual(['cleanup', 'emit']); + }); +}); diff --git a/src/arts/conn/test/connection.test.ts b/src/arts/conn/test/connection.test.ts index e9bd476..cc47d0e 100644 --- a/src/arts/conn/test/connection.test.ts +++ b/src/arts/conn/test/connection.test.ts @@ -78,6 +78,32 @@ describe('Connection lifecycle', () => { Connections.dispose(); }); + it('does not update closedAt when the transport repeats an intentional close', async () => { + let now = 100; + const clock: TimerClock = { + now: () => now, + setTimeout: () => 0, + clearTimeout: () => {} + }; + const Connections = createEngineConnections({ clock }); + const transport = createMockTransport(); + const Main = Connections.createConnection('main', { + transport, + heartbeat: false, + reconnect: false + }); + + await Main.connect(); + Main.disconnect(); + expect(Main.closedAt).toBe(100); + + now = 200; + transport.emitClose({ clean: true }); + expect(Main.closedAt).toBe(100); + + Connections.dispose(); + }); + it('does not reconnect after an intentional disconnect', async () => { vi.useFakeTimers(); const Connections = createEngineConnections(); diff --git a/src/arts/conn/test/websocket.test.ts b/src/arts/conn/test/websocket.test.ts new file mode 100644 index 0000000..a10f06d --- /dev/null +++ b/src/arts/conn/test/websocket.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + ConnWebSocketUnavailableError, + createWebSocketTransport, + isConnWebSocketUnavailableError +} from '../index.ts'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('createWebSocketTransport', () => { + it('throws a typed conn error when WebSocket is unavailable', () => { + vi.stubGlobal('WebSocket', undefined); + + const transport = createWebSocketTransport({ url: 'ws://example.test/socket' }); + + expect(() => transport.open()).toThrow(ConnWebSocketUnavailableError); + try { + transport.open(); + } catch (error) { + expect(isConnWebSocketUnavailableError(error)).toBe(true); + } + }); +}); diff --git a/src/arts/conn/transport-wiring.ts b/src/arts/conn/transport-wiring.ts new file mode 100644 index 0000000..28532d1 --- /dev/null +++ b/src/arts/conn/transport-wiring.ts @@ -0,0 +1,23 @@ +import type { ConnectionCloseEvent, ConnectionTransport } from './types.ts'; + +export interface ConnectionTransportHandlers { + onOpen(): void; + onMessage(message: string | ArrayBuffer): void; + onClose(event: ConnectionCloseEvent): void; + onError(error: unknown): void; +} + +export function attachConnectionTransport( + transport: ConnectionTransport, + handlers: ConnectionTransportHandlers +): () => void { + const detachers = [ + transport.onOpen(handlers.onOpen), + transport.onMessage(handlers.onMessage), + transport.onClose(handlers.onClose), + transport.onError(handlers.onError) + ]; + return () => { + for (const detach of detachers) detach(); + }; +} diff --git a/src/arts/conn/transports/websocket.ts b/src/arts/conn/transports/websocket.ts index 0b1a4c4..76f816d 100644 --- a/src/arts/conn/transports/websocket.ts +++ b/src/arts/conn/transports/websocket.ts @@ -17,6 +17,7 @@ import { WEBSOCKET_READY_STATE_CLOSING, WEBSOCKET_READY_STATE_OPEN } from '../consts.ts'; +import { ConnWebSocketUnavailableError } from '../errors.ts'; import { createTransportEmitter } from '../transport.ts'; import type { ConnectionCloseEvent, @@ -56,7 +57,8 @@ export function createWebSocketTransport(options: WebSocketTransportOptions): Co const ctor = options.WebSocket ?? (globalThis as { readonly WebSocket?: WebSocketConstructorLike }).WebSocket; - if (ctor === undefined) throw new Error(ERROR_MSG_WEBSOCKET_UNAVAILABLE); + if (ctor === undefined) + throw new ConnWebSocketUnavailableError(ERROR_MSG_WEBSOCKET_UNAVAILABLE); return ctor; } diff --git a/src/arts/conn/types.ts b/src/arts/conn/types.ts index c61c993..44f88b2 100644 --- a/src/arts/conn/types.ts +++ b/src/arts/conn/types.ts @@ -1,3 +1,4 @@ +import type { Logger } from '$libs/logr'; import type { TimerClock, TimerScheduler } from '$timr'; import type { CONNECTION_ACK_REASON_CLOSED, @@ -51,13 +52,6 @@ export type ConnectionEventMap = Record; export type ConnectionChannelMap = Record; export type ConnectionMap = Record; -export interface ConnectionLogger { - debug?(category: string, message: string, input?: unknown): void; - info?(category: string, message: string, input?: unknown): void; - warn?(category: string, message: string, input?: unknown): void; - error?(category: string, message: string, input?: unknown): void; -} - export interface ConnectionCloseEvent { readonly code: number; readonly reason?: string; @@ -357,7 +351,7 @@ export interface Connection; diff --git a/src/arts/fend/active-frontend.svelte.ts b/src/arts/fend/active-frontend.svelte.ts index aa23e67..a53a5e0 100644 --- a/src/arts/fend/active-frontend.svelte.ts +++ b/src/arts/fend/active-frontend.svelte.ts @@ -79,7 +79,9 @@ function subscribeMedia(query: string, fn: (matches: boolean) => void): () => vo export function createActiveFrontend(options: ActiveFrontendOptions = {}): ActiveFrontend { const localeSource = options.localeSource; - const dom = options.applyDom === false ? undefined : (options.dom ?? createActiveDom()); + const ownedDom = + options.applyDom === false || options.dom !== undefined ? undefined : createActiveDom(); + const dom = options.applyDom === false ? undefined : (options.dom ?? ownedDom); let currentLocale = $state(localeSource?.getLocale() ?? options.locale ?? ''); let dirOverride = $state( @@ -225,6 +227,7 @@ export function createActiveFrontend(options: ActiveFrontendOptions = {}): Activ unsubscribeLocale?.(); unsubscribeDarkMode(); unsubscribeReducedMotion(); + ownedDom?.dispose(); listeners.clear(); } }; diff --git a/src/arts/fmts/active-formats.svelte.ts b/src/arts/fmts/active-formats.svelte.ts index 78b5db7..90264fc 100644 --- a/src/arts/fmts/active-formats.svelte.ts +++ b/src/arts/fmts/active-formats.svelte.ts @@ -2,6 +2,7 @@ import { createActiveCurrency } from './curr/active-currency.svelte'; import { createActiveDates } from './dates/active-dates.svelte'; import { createActiveNumbers } from './nums/active-numbers.svelte'; import { createActiveUnits } from './unts/active-units.svelte'; +import { createActiveFormatsLocaleSource } from './active-runtime.svelte'; import type { ActiveCurrency, ActiveCurrencyOptions } from './curr'; import type { ActiveDates, ActiveDatesOptions } from './dates'; import type { ActiveNumbers, ActiveNumbersOptions } from './nums'; @@ -28,29 +29,20 @@ export interface ActiveFormats { } export function createActiveFormats(options: ActiveFormatsOptions = {}): ActiveFormats { - let currentLocale = options.localeSource?.getLocale() ?? options.locale; - const localeSource: FormatsLocaleSource = { - getLocale: () => currentLocale ?? options.localeSource?.getLocale() ?? options.locale ?? '', - onLocaleChange: (fn) => { - const unsubscribe = options.localeSource?.onLocaleChange?.((locale) => { - currentLocale = locale; - fn(locale); - }); - return () => unsubscribe?.(); - } - }; + const localeState = createActiveFormatsLocaleSource(options); + const localeSource = localeState.source; const numbers = createActiveNumbers({ ...options.numbers, localeSource }); const currency = createActiveCurrency({ ...options.currency, localeSource, numbers }); const units = createActiveUnits({ ...options.units, localeSource, numbers }); const dates = createActiveDates({ ...options.dates, localeSource }); - function setLocale(locale: string): void { - currentLocale = locale; - numbers.setLocale(locale); - currency.setLocale(locale); - units.setLocale(locale); - dates.setLocale(locale); + function setLocale(nextLocale: string): void { + localeState.setLocale(nextLocale); + numbers.setLocale(nextLocale); + currency.setLocale(nextLocale); + units.setLocale(nextLocale); + dates.setLocale(nextLocale); } return { diff --git a/src/arts/fmts/active-runtime.svelte.ts b/src/arts/fmts/active-runtime.svelte.ts new file mode 100644 index 0000000..3d96586 --- /dev/null +++ b/src/arts/fmts/active-runtime.svelte.ts @@ -0,0 +1,98 @@ +import type { FormatsLocaleSource } from './types'; + +export interface ActiveFormatsLocaleSourceOptions { + locale?: string; + localeSource?: FormatsLocaleSource; +} + +export interface ActiveFormatsLocaleSource { + readonly source: FormatsLocaleSource; + getLocale: () => string; + setLocale: (locale: string) => void; +} + +export interface ActiveFormatsRuntimeOptions { + localeSource?: FormatsLocaleSource; + getLocale: () => string; + setLocale: (locale: string) => void; +} + +export interface ActiveFormatsRuntime { + read: () => void; + notifyChange: () => void; + syncLocale: (locale: string) => void; + onChange: (fn: () => void) => () => void; + onLocaleChange: (fn: (locale: string) => void) => () => void; + dispose: () => void; +} + +export function createActiveFormatsLocaleSource( + options: ActiveFormatsLocaleSourceOptions +): ActiveFormatsLocaleSource { + let currentLocale = options.localeSource?.getLocale() ?? options.locale; + const source: FormatsLocaleSource = { + getLocale: () => currentLocale ?? options.localeSource?.getLocale() ?? options.locale ?? '', + onLocaleChange: (fn) => { + const unsubscribe = options.localeSource?.onLocaleChange?.((locale) => { + currentLocale = locale; + fn(locale); + }); + return () => unsubscribe?.(); + } + }; + + return { + source, + getLocale: source.getLocale, + setLocale(locale) { + currentLocale = locale; + } + }; +} + +export function createActiveFormatsRuntime( + options: ActiveFormatsRuntimeOptions +): ActiveFormatsRuntime { + let version = $state(0); + const changeListeners = new Set<() => void>(); + const localeListeners = new Set<(locale: string) => void>(); + + function read(): void { + void version; + } + + function notifyChange(): void { + version++; + changeListeners.forEach((fn) => fn()); + } + + function syncLocale(locale: string): void { + options.setLocale(locale); + notifyChange(); + localeListeners.forEach((fn) => fn(options.getLocale())); + } + + const unsubscribeLocale = options.localeSource?.onLocaleChange?.(syncLocale); + + return { + read, + notifyChange, + syncLocale, + + onChange(fn) { + changeListeners.add(fn); + return () => changeListeners.delete(fn); + }, + + onLocaleChange(fn) { + localeListeners.add(fn); + return () => localeListeners.delete(fn); + }, + + dispose() { + unsubscribeLocale?.(); + changeListeners.clear(); + localeListeners.clear(); + } + }; +} diff --git a/src/arts/fmts/auto-state.ts b/src/arts/fmts/auto-state.ts new file mode 100644 index 0000000..e3b6606 --- /dev/null +++ b/src/arts/fmts/auto-state.ts @@ -0,0 +1,46 @@ +export interface AutoStateOptions { + initial?: TMode; + auto: TMode; + normalize?: (value: TMode) => TMode; + toManual?: (value: TMode) => TValue; +} + +export interface AutoState { + get: (resolveAuto: () => TValue) => TValue; + set: (value: TMode) => void; + clear: () => void; + isAuto: () => boolean; +} + +export function createAutoState( + options: AutoStateOptions +): AutoState { + let manual = resolveManual(options.initial); + + function resolveManual(value: TMode | undefined): TValue | null { + if (value === undefined || Object.is(value, options.auto)) return null; + + const normalized = options.normalize?.(value) ?? value; + if (Object.is(normalized, options.auto)) return null; + + return options.toManual?.(normalized) ?? (normalized as TValue); + } + + return { + get(resolveAuto) { + return manual ?? resolveAuto(); + }, + + set(value) { + manual = resolveManual(value); + }, + + clear() { + manual = null; + }, + + isAuto() { + return manual === null; + } + }; +} diff --git a/src/arts/fmts/curr/active-currency.svelte.ts b/src/arts/fmts/curr/active-currency.svelte.ts index 7232228..905b2ff 100644 --- a/src/arts/fmts/curr/active-currency.svelte.ts +++ b/src/arts/fmts/curr/active-currency.svelte.ts @@ -1,4 +1,4 @@ -import { SvelteSet } from 'svelte/reactivity'; +import { createActiveFormatsRuntime } from '../active-runtime.svelte'; import { createEngineCurrency } from './engine-currency'; import type { ActiveCurrency, @@ -15,45 +15,35 @@ export function createActiveCurrency(options: ActiveCurrencyOptions = {}): Activ locale: localeSource?.getLocale() ?? options.locale }); - let version = $state(0); - const currencyListeners = new SvelteSet<(currency: CurrencyCode) => void>(); - const localeListeners = new SvelteSet<(locale: string) => void>(); - - function bump(): void { - version++; - } + const currencyListeners = new Set<(currency: CurrencyCode) => void>(); function notifyCurrency(currency: CurrencyCode): void { currencyListeners.forEach((fn) => fn(currency)); } - function notifyLocale(locale: string): void { - localeListeners.forEach((fn) => fn(locale)); - } - - function syncLocale(locale: string): void { - const before = engine.getCurrency(); - engine.setLocale(locale); - const after = engine.getCurrency(); - bump(); - notifyLocale(engine.getLocale()); - if (before !== after) notifyCurrency(after); - } - - const unsubscribeLocale = localeSource?.onLocaleChange?.(syncLocale); + const runtime = createActiveFormatsRuntime({ + localeSource, + getLocale: () => engine.getLocale(), + setLocale(locale) { + const before = engine.getCurrency(); + engine.setLocale(locale); + const after = engine.getCurrency(); + if (before !== after) notifyCurrency(after); + } + }); return { getLocale(): string { - void version; + runtime.read(); return engine.getLocale(); }, setLocale(locale: string): void { - syncLocale(locale); + runtime.syncLocale(locale); }, getCurrency(): CurrencyCode { - void version; + runtime.read(); return engine.getCurrency(); }, @@ -61,7 +51,7 @@ export function createActiveCurrency(options: ActiveCurrencyOptions = {}): Activ const before = engine.getCurrency(); engine.setCurrency(currency); const after = engine.getCurrency(); - bump(); + runtime.notifyChange(); if (before !== after) notifyCurrency(after); }, @@ -69,32 +59,32 @@ export function createActiveCurrency(options: ActiveCurrencyOptions = {}): Activ const before = engine.getCurrency(); engine.clearCurrency(); const after = engine.getCurrency(); - bump(); + runtime.notifyChange(); if (before !== after) notifyCurrency(after); }, isCurrencyAuto(): boolean { - void version; + runtime.read(); return engine.isCurrencyAuto(); }, format(value: number, options?: CurrencyFormatOptions): string { - void version; + runtime.read(); return engine.format(value, options); }, formatAs(value: number, currency: CurrencyCode, options?: CurrencyFormatOptions): string { - void version; + runtime.read(); return engine.formatAs(value, currency, options); }, convert(value: number, to: CurrencyCode): Promise { - void version; + runtime.read(); return engine.convert(value, to); }, convertAs(value: number, from: CurrencyCode, to: CurrencyCode): Promise { - void version; + runtime.read(); return engine.convertAs(value, from, to); }, @@ -107,15 +97,11 @@ export function createActiveCurrency(options: ActiveCurrencyOptions = {}): Activ return () => currencyListeners.delete(fn); }, - onLocaleChange(fn: (locale: string) => void): () => void { - localeListeners.add(fn); - return () => localeListeners.delete(fn); - }, + onLocaleChange: runtime.onLocaleChange, dispose(): void { - unsubscribeLocale?.(); + runtime.dispose(); currencyListeners.clear(); - localeListeners.clear(); } }; } diff --git a/src/arts/fmts/curr/consts.ts b/src/arts/fmts/curr/consts.ts index a824f06..1a02216 100644 --- a/src/arts/fmts/curr/consts.ts +++ b/src/arts/fmts/curr/consts.ts @@ -1,5 +1,12 @@ export const LOGGER_CATEGORY = 'formats.currency'; +export const CURRENCY_DIAGNOSTIC_EVENTS = { + RATES_PROVIDER_MISSING: 'formats.currency.rates_provider_missing', + RATE_NOT_AVAILABLE: 'formats.currency.rate_not_available', + INVALID_RATE: 'formats.currency.invalid_rate', + RATE_FETCH_FAILED: 'formats.currency.rate_fetch_failed' +} as const; + export const AUTO_CURRENCY = 'auto'; export const DEFAULT_CURRENCY = 'USD'; diff --git a/src/arts/fmts/curr/diagnostics.ts b/src/arts/fmts/curr/diagnostics.ts new file mode 100644 index 0000000..36fe408 --- /dev/null +++ b/src/arts/fmts/curr/diagnostics.ts @@ -0,0 +1,70 @@ +import { + LogLevel, + createCatalogDiagnostics, + type DiagnosticCatalog, + type DiagnosticEvent, + type Diagnostics, + type Logger +} from '$libs/logr'; +import { CURRENCY_DIAGNOSTIC_EVENTS, LOGGER_CATEGORY } from './consts'; +import { CURRENCY_ERRORS } from './errors'; +import type { CurrencyCode } from './types'; + +export type CurrencyDiagnosticType = + (typeof CURRENCY_DIAGNOSTIC_EVENTS)[keyof typeof CURRENCY_DIAGNOSTIC_EVENTS]; + +export interface CurrencyDiagnosticMeta { + readonly from?: CurrencyCode; + readonly to?: CurrencyCode; + readonly rate?: number; + readonly error?: unknown; +} + +export type CurrencyDiagnosticEvent = DiagnosticEvent< + CurrencyDiagnosticType, + CurrencyDiagnosticMeta +>; +export type CurrencyDiagnostics = Diagnostics; + +const CURRENCY_DIAGNOSTIC_LOGS: DiagnosticCatalog = { + [CURRENCY_DIAGNOSTIC_EVENTS.RATES_PROVIDER_MISSING]: { + level: LogLevel.DEBUG, + message: CURRENCY_ERRORS.RATES_PROVIDER_MISSING + }, + [CURRENCY_DIAGNOSTIC_EVENTS.RATE_NOT_AVAILABLE]: (event) => ({ + level: LogLevel.DEBUG, + message: CURRENCY_ERRORS.RATE_NOT_AVAILABLE(event.meta?.from ?? '', event.meta?.to ?? '') + }), + [CURRENCY_DIAGNOSTIC_EVENTS.INVALID_RATE]: (event) => ({ + level: LogLevel.WARN, + message: CURRENCY_ERRORS.INVALID_RATE( + event.meta?.from ?? '', + event.meta?.to ?? '', + event.meta?.rate ?? Number.NaN + ) + }), + [CURRENCY_DIAGNOSTIC_EVENTS.RATE_FETCH_FAILED]: (event) => ({ + level: LogLevel.WARN, + message: CURRENCY_ERRORS.RATE_FETCH_FAILED(event.meta?.from ?? '', event.meta?.to ?? '') + }) +}; + +export function createCurrencyDiagnostics(logger?: Logger): CurrencyDiagnostics { + return createCatalogDiagnostics({ + logger, + defaultCategory: LOGGER_CATEGORY, + catalog: CURRENCY_DIAGNOSTIC_LOGS + }); +} + +export function emitCurrencyDiagnostic( + diagnostics: CurrencyDiagnostics, + type: CurrencyDiagnosticType, + meta: CurrencyDiagnosticMeta +): void { + diagnostics.emit({ + artifact: LOGGER_CATEGORY, + type, + meta + }); +} diff --git a/src/arts/fmts/curr/engine-currency.ts b/src/arts/fmts/curr/engine-currency.ts index fa5cd1a..8278eb7 100644 --- a/src/arts/fmts/curr/engine-currency.ts +++ b/src/arts/fmts/curr/engine-currency.ts @@ -1,13 +1,13 @@ import { AUTO_CURRENCY, + CURRENCY_DIAGNOSTIC_EVENTS, DEFAULT_CURRENCY, DEFAULT_CURRENCY_DISPLAY, DEFAULT_CURRENCY_SIGN, - LOGGER_CATEGORY } from './consts'; -import { DEFAULT_LOCALE } from '../consts'; -import { normalizeLocaleTag, resolveLocaleInput } from '../helpers'; -import { CURRENCY_ERRORS } from './errors'; +import { createAutoState } from '../auto-state'; +import { createFormatsLocaleState } from '../locale-state'; +import { createCurrencyDiagnostics, emitCurrencyDiagnostic } from './diagnostics'; import { normalizeCurrencyCode, normalizeCurrencyMode } from './helpers'; import { resolveCurrency } from './locale-defaults'; import type { @@ -49,39 +49,39 @@ function getCurrencyFormatter( } export function createEngineCurrency(options: EngineCurrencyOptions = {}): EngineCurrency { - const localeInput = resolveLocaleInput(options.locale); - let localeGetter = localeInput.getter; - let currentLocale = localeInput.value; - let currentCurrency = normalizeCurrencyMode(options.currency ?? AUTO_CURRENCY); + const localeState = createFormatsLocaleState(options.locale); + const currencyState = createAutoState({ + initial: options.currency, + auto: AUTO_CURRENCY, + normalize: normalizeCurrencyMode + }); const defaultCurrency = normalizeCurrencyCode(options.defaultCurrency ?? DEFAULT_CURRENCY); const { numbers, rates, logger } = options; + const diagnostics = createCurrencyDiagnostics(logger); const defaultFormat = options.format ?? {}; function getLocale(): string { - const locale = normalizeLocaleTag(localeGetter?.() ?? currentLocale); - return locale || DEFAULT_LOCALE; + return localeState.getLocale(); } function setLocale(locale: string): void { - localeGetter = undefined; - currentLocale = normalizeLocaleTag(locale) || DEFAULT_LOCALE; + localeState.setLocale(locale); } function getCurrency(): CurrencyCode { - if (currentCurrency !== AUTO_CURRENCY) return currentCurrency; - return resolveCurrency(getLocale()) ?? defaultCurrency; + return currencyState.get(() => resolveCurrency(getLocale()) ?? defaultCurrency); } function setCurrency(currency: CurrencyMode): void { - currentCurrency = normalizeCurrencyMode(currency); + currencyState.set(currency); } function clearCurrency(): void { - currentCurrency = AUTO_CURRENCY; + currencyState.clear(); } function isCurrencyAuto(): boolean { - return currentCurrency === AUTO_CURRENCY; + return currencyState.isAuto(); } function mergedFormatOptions(options?: CurrencyFormatOptions): CurrencyFormatOptions { @@ -117,21 +117,19 @@ export function createEngineCurrency(options: EngineCurrencyOptions = {}): Engin if (normalizedFrom === normalizedTo) return value; if (rates === undefined) { - logger?.debug(LOGGER_CATEGORY, CURRENCY_ERRORS.RATES_PROVIDER_MISSING, { - context: { from: normalizedFrom, to: normalizedTo } + emitCurrencyDiagnostic(diagnostics, CURRENCY_DIAGNOSTIC_EVENTS.RATES_PROVIDER_MISSING, { + from: normalizedFrom, + to: normalizedTo }); return undefined; } const rate = await rates.getRate(normalizedFrom, normalizedTo); if (rate === undefined) { - logger?.debug( - LOGGER_CATEGORY, - CURRENCY_ERRORS.RATE_NOT_AVAILABLE(normalizedFrom, normalizedTo), - { - context: { from: normalizedFrom, to: normalizedTo } - } - ); + emitCurrencyDiagnostic(diagnostics, CURRENCY_DIAGNOSTIC_EVENTS.RATE_NOT_AVAILABLE, { + from: normalizedFrom, + to: normalizedTo + }); return undefined; } diff --git a/src/arts/fmts/curr/index.ts b/src/arts/fmts/curr/index.ts index 0d0d5c0..0f4a854 100644 --- a/src/arts/fmts/curr/index.ts +++ b/src/arts/fmts/curr/index.ts @@ -5,6 +5,7 @@ export { LOGGER_CATEGORY, AUTO_CURRENCY, + CURRENCY_DIAGNOSTIC_EVENTS, DEFAULT_CURRENCY, DEFAULT_CURRENCY_DISPLAY, DEFAULT_CURRENCY_SIGN, @@ -12,6 +13,7 @@ export { } from './consts'; export { createActiveCurrency } from './active-currency.svelte'; +export { createCurrencyDiagnostics, emitCurrencyDiagnostic } from './diagnostics'; export { createEngineCurrency } from './engine-currency'; export { CURRENCY_ERRORS } from './errors'; export { @@ -24,6 +26,13 @@ export { LOCALE_CURRENCY_OVERRIDES, REGION_CURRENCY } from './locale-currencies' export { resolveCurrency } from './locale-defaults'; export { createRates } from './rates'; +export type { + CurrencyDiagnosticEvent, + CurrencyDiagnosticMeta, + CurrencyDiagnostics, + CurrencyDiagnosticType +} from './diagnostics'; + export type { CurrencyCode, CurrencyMode, diff --git a/src/arts/fmts/curr/rates.ts b/src/arts/fmts/curr/rates.ts index 2dcd0a4..29094f3 100644 --- a/src/arts/fmts/curr/rates.ts +++ b/src/arts/fmts/curr/rates.ts @@ -1,5 +1,5 @@ -import { LOGGER_CATEGORY, RATE_PAIR_SEPARATOR } from './consts'; -import { CURRENCY_ERRORS } from './errors'; +import { CURRENCY_DIAGNOSTIC_EVENTS, RATE_PAIR_SEPARATOR } from './consts'; +import { createCurrencyDiagnostics, emitCurrencyDiagnostic } from './diagnostics'; import { assertValidRate, isValidRate, normalizeCurrencyCode } from './helpers'; import type { CurrencyCode, CurrencyRatesProvider, RateEntry, RatesOptions } from './types'; @@ -11,6 +11,7 @@ export function createRates(opts: RatesOptions = {}): CurrencyRatesProvider { const pendingFetches = new Map>(); const now = opts.now ?? Date.now; const { logger } = opts; + const diagnostics = createCurrencyDiagnostics(logger); let cacheVersion = 0; function pairKey(from: CurrencyCode, to: CurrencyCode): string { @@ -106,8 +107,10 @@ export function createRates(opts: RatesOptions = {}): CurrencyRatesProvider { if (fetchVersion !== cacheVersion) return undefined; if (!result) return undefined; if (!isValidRate(result.rate)) { - logger?.warn(LOGGER_CATEGORY, CURRENCY_ERRORS.INVALID_RATE(from, to, result.rate), { - context: { from, to, rate: result.rate } + emitCurrencyDiagnostic(diagnostics, CURRENCY_DIAGNOSTIC_EVENTS.INVALID_RATE, { + from, + to, + rate: result.rate }); return undefined; } @@ -117,8 +120,9 @@ export function createRates(opts: RatesOptions = {}): CurrencyRatesProvider { .catch((error: unknown) => { pendingFetches.delete(fetchKey); if (fetchVersion !== cacheVersion) return undefined; - logger?.warn(LOGGER_CATEGORY, CURRENCY_ERRORS.RATE_FETCH_FAILED(from, to), { - context: { from, to }, + emitCurrencyDiagnostic(diagnostics, CURRENCY_DIAGNOSTIC_EVENTS.RATE_FETCH_FAILED, { + from, + to, error }); return undefined; diff --git a/src/arts/fmts/curr/types.ts b/src/arts/fmts/curr/types.ts index 6386469..a2f26fe 100644 --- a/src/arts/fmts/curr/types.ts +++ b/src/arts/fmts/curr/types.ts @@ -1,4 +1,4 @@ -import type { EngineLogger } from '$logr'; +import type { Logger } from '$libs/logr'; import type { AUTO_CURRENCY } from './consts'; import type { FormatsLocaleInput, FormatsLocaleSource } from '../types'; import type { EngineNumbers, NumbersCurrencyFormatOptions } from '../nums'; @@ -32,7 +32,7 @@ export interface RatesOptions { expiresAt?: number; }; fetchRate?: RateFetcher; - logger?: EngineLogger; + logger?: Logger; now?: () => number; } @@ -50,7 +50,7 @@ export interface EngineCurrencyOptions { defaultCurrency?: CurrencyCode; rates?: CurrencyRatesProvider; numbers?: Pick; - logger?: EngineLogger; + logger?: Logger; format?: CurrencyFormatOptions; } diff --git a/src/arts/fmts/dates/active-dates.svelte.ts b/src/arts/fmts/dates/active-dates.svelte.ts index a97c895..7d4b665 100644 --- a/src/arts/fmts/dates/active-dates.svelte.ts +++ b/src/arts/fmts/dates/active-dates.svelte.ts @@ -1,4 +1,4 @@ -import { SvelteSet } from 'svelte/reactivity'; +import { createActiveFormatsRuntime } from '../active-runtime.svelte'; import { createEngineDates } from './engine-dates'; import type { ActiveDates, ActiveDatesOptions } from './types'; @@ -9,100 +9,79 @@ export function createActiveDates(options: ActiveDatesOptions = {}): ActiveDates locale: localeSource?.getLocale() ?? options.locale }); - let version = $state(0); - const preferenceListeners = new SvelteSet<() => void>(); - const localeListeners = new SvelteSet<(locale: string) => void>(); - - function notifyPreferences(): void { - version++; - preferenceListeners.forEach((fn) => fn()); - } - - function syncLocale(locale: string): void { - engine.setLocale(locale); - notifyPreferences(); - localeListeners.forEach((fn) => fn(engine.getLocale())); - } - - const unsubscribeLocale = localeSource?.onLocaleChange?.(syncLocale); + const runtime = createActiveFormatsRuntime({ + localeSource, + getLocale: () => engine.getLocale(), + setLocale: (locale) => engine.setLocale(locale) + }); return { getLocale() { - void version; + runtime.read(); return engine.getLocale(); }, - setLocale: syncLocale, + setLocale: runtime.syncLocale, getDateOrder() { - void version; + runtime.read(); return engine.getDateOrder(); }, setDateOrder(order) { engine.setDateOrder(order); - notifyPreferences(); + runtime.notifyChange(); }, clearDateOrder() { engine.clearDateOrder(); - notifyPreferences(); + runtime.notifyChange(); }, isDateOrderAuto() { - void version; + runtime.read(); return engine.isDateOrderAuto(); }, getHourCycle() { - void version; + runtime.read(); return engine.getHourCycle(); }, setHourCycle(hourCycle) { engine.setHourCycle(hourCycle); - notifyPreferences(); + runtime.notifyChange(); }, clearHourCycle() { engine.clearHourCycle(); - notifyPreferences(); + runtime.notifyChange(); }, isHourCycleAuto() { - void version; + runtime.read(); return engine.isHourCycleAuto(); }, formatDate(value, options) { - void version; + runtime.read(); return engine.formatDate(value, options); }, formatTime(value, options) { - void version; + runtime.read(); return engine.formatTime(value, options); }, formatDateTime(value, options) { - void version; + runtime.read(); return engine.formatDateTime(value, options); }, - onPreferenceChange(fn) { - preferenceListeners.add(fn); - return () => preferenceListeners.delete(fn); - }, + onPreferenceChange: runtime.onChange, - onLocaleChange(fn) { - localeListeners.add(fn); - return () => localeListeners.delete(fn); - }, + onLocaleChange: runtime.onLocaleChange, - dispose() { - unsubscribeLocale?.(); - preferenceListeners.clear(); - localeListeners.clear(); - } + dispose: runtime.dispose }; } diff --git a/src/arts/fmts/dates/engine-dates.ts b/src/arts/fmts/dates/engine-dates.ts index f2f7949..2b0908b 100644 --- a/src/arts/fmts/dates/engine-dates.ts +++ b/src/arts/fmts/dates/engine-dates.ts @@ -1,7 +1,15 @@ import { getCachedDateFormat, resolveDateOrder, resolveHourCycle } from '$libs/days'; -import { DEFAULT_LOCALE } from '../consts'; -import { normalizeLocaleTag, resolveLocaleInput } from '../helpers'; -import type { DatesFormatOptions, EngineDates, EngineDatesOptions } from './types'; +import { createAutoState } from '../auto-state'; +import { createFormatsLocaleState } from '../locale-state'; +import type { + DateOrder, + DateOrderMode, + DatesFormatOptions, + EngineDates, + EngineDatesOptions, + HourCycle, + HourCycleMode +} from './types'; function withHourCycle( options: DatesFormatOptions | undefined, @@ -15,28 +23,30 @@ function withHourCycle( } export function createEngineDates(options: EngineDatesOptions = {}): EngineDates { - const localeInput = resolveLocaleInput(options.locale); - let localeGetter = localeInput.getter; - let currentLocale = localeInput.value; - let dateOrderOverride = options.dateOrder === 'auto' ? null : (options.dateOrder ?? null); - let hourCycleOverride = options.hourCycle === 'auto' ? null : (options.hourCycle ?? null); + const localeState = createFormatsLocaleState(options.locale); + const dateOrder = createAutoState({ + initial: options.dateOrder, + auto: 'auto' + }); + const hourCycleState = createAutoState({ + initial: options.hourCycle, + auto: 'auto' + }); function getLocale(): string { - const locale = normalizeLocaleTag(localeGetter?.() ?? currentLocale); - return locale || DEFAULT_LOCALE; + return localeState.getLocale(); } function setLocale(locale: string): void { - localeGetter = undefined; - currentLocale = normalizeLocaleTag(locale) || DEFAULT_LOCALE; + localeState.setLocale(locale); } function getDateOrder() { - return dateOrderOverride ?? resolveDateOrder(getLocale()); + return dateOrder.get(() => resolveDateOrder(getLocale())); } function getHourCycle() { - return hourCycleOverride ?? resolveHourCycle(getLocale()); + return hourCycleState.get(() => resolveHourCycle(getLocale())); } function format(value: Date | number, options: DatesFormatOptions): string { @@ -49,29 +59,29 @@ export function createEngineDates(options: EngineDatesOptions = {}): EngineDates getDateOrder, setDateOrder(order) { - dateOrderOverride = order === 'auto' ? null : order; + dateOrder.set(order); }, clearDateOrder() { - dateOrderOverride = null; + dateOrder.clear(); }, isDateOrderAuto() { - return dateOrderOverride === null; + return dateOrder.isAuto(); }, getHourCycle, setHourCycle(hourCycle) { - hourCycleOverride = hourCycle === 'auto' ? null : hourCycle; + hourCycleState.set(hourCycle); }, clearHourCycle() { - hourCycleOverride = null; + hourCycleState.clear(); }, isHourCycleAuto() { - return hourCycleOverride === null; + return hourCycleState.isAuto(); }, formatDate(value, options) { diff --git a/src/arts/fmts/dates/test/active-dates.test.ts b/src/arts/fmts/dates/test/active-dates.test.ts new file mode 100644 index 0000000..b517e10 --- /dev/null +++ b/src/arts/fmts/dates/test/active-dates.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { createActiveDates } from '../active-dates.svelte'; + +describe('createActiveDates()', () => { + it('syncs locale source changes and notifies active listeners', () => { + let locale = 'en-US'; + let listener: ((locale: string) => void) | undefined; + const source = { + getLocale: () => locale, + onLocaleChange(fn: (nextLocale: string) => void) { + listener = fn; + return () => { + listener = undefined; + }; + } + }; + const dates = createActiveDates({ localeSource: source }); + const localeChanges: string[] = []; + let preferenceChanges = 0; + + dates.onLocaleChange((nextLocale) => localeChanges.push(nextLocale)); + dates.onPreferenceChange(() => { + preferenceChanges++; + }); + + locale = 'en-GB'; + listener?.(locale); + + expect(dates.getLocale()).toBe('en-GB'); + expect(dates.getDateOrder()).toBe('DMY'); + expect(dates.getHourCycle()).toBe(24); + expect(localeChanges).toEqual(['en-GB']); + expect(preferenceChanges).toBe(1); + + dates.setHourCycle(12); + expect(dates.getHourCycle()).toBe(12); + expect(preferenceChanges).toBe(2); + + dates.dispose(); + expect(listener).toBeUndefined(); + }); +}); diff --git a/src/arts/fmts/engine-formats.ts b/src/arts/fmts/engine-formats.ts index 22542e0..2b27d67 100644 --- a/src/arts/fmts/engine-formats.ts +++ b/src/arts/fmts/engine-formats.ts @@ -2,7 +2,7 @@ import { createEngineCurrency } from './curr'; import { createEngineDates } from './dates'; import { createEngineNumbers } from './nums'; import { createEngineUnits } from './unts'; -import { DEFAULT_LOCALE } from './consts'; +import { createFormatsLocaleState } from './locale-state'; import type { EngineCurrency, EngineCurrencyOptions } from './curr'; import type { EngineDates, EngineDatesOptions } from './dates'; import type { EngineNumbers, EngineNumbersOptions } from './nums'; @@ -32,14 +32,8 @@ export interface EngineFormats { } export function createEngineFormats(options: EngineFormatsOptions = {}): EngineFormats { - let currentLocale = typeof options.locale === 'string' ? options.locale : undefined; - // `Intl.NumberFormat`/`Intl.DateTimeFormat` accept `''` but the runtime - // then falls back to the host locale silently — DEFAULT_LOCALE makes the - // behavior explicit and predictable across environments. - const locale = (): string => - currentLocale ?? - (typeof options.locale === 'function' ? options.locale() : options.locale) ?? - DEFAULT_LOCALE; + const localeState = createFormatsLocaleState(options.locale); + const locale = localeState.getLocale; const numbers = createEngineNumbers({ ...options.numbers, locale }); const currency = createEngineCurrency({ ...options.currency, locale, numbers }); @@ -51,7 +45,7 @@ export function createEngineFormats(options: EngineFormatsOptions = {}): EngineF } function setLocale(nextLocale: string): void { - currentLocale = nextLocale; + localeState.setLocale(nextLocale); numbers.setLocale(nextLocale); currency.setLocale(nextLocale); units.setLocale(nextLocale); diff --git a/src/arts/fmts/locale-state.ts b/src/arts/fmts/locale-state.ts new file mode 100644 index 0000000..880780b --- /dev/null +++ b/src/arts/fmts/locale-state.ts @@ -0,0 +1,26 @@ +import { DEFAULT_LOCALE } from './consts'; +import { normalizeLocaleTag, resolveLocaleInput } from './helpers'; +import type { FormatsLocaleInput } from './types'; + +export interface FormatsLocaleState { + getLocale: () => string; + setLocale: (locale: string) => void; +} + +export function createFormatsLocaleState(input?: FormatsLocaleInput): FormatsLocaleState { + const localeInput = resolveLocaleInput(input); + let localeGetter = localeInput.getter; + let currentLocale = localeInput.value; + + return { + getLocale() { + const locale = normalizeLocaleTag(localeGetter?.() ?? currentLocale); + return locale || DEFAULT_LOCALE; + }, + + setLocale(locale) { + localeGetter = undefined; + currentLocale = normalizeLocaleTag(locale) || DEFAULT_LOCALE; + } + }; +} diff --git a/src/arts/fmts/nums/active-numbers.svelte.ts b/src/arts/fmts/nums/active-numbers.svelte.ts index 73dfd9f..0d69046 100644 --- a/src/arts/fmts/nums/active-numbers.svelte.ts +++ b/src/arts/fmts/nums/active-numbers.svelte.ts @@ -1,4 +1,4 @@ -import { SvelteSet } from 'svelte/reactivity'; +import { createActiveFormatsRuntime } from '../active-runtime.svelte'; import { createEngineNumbers } from './engine-numbers'; import type { ActiveNumbers, ActiveNumbersOptions, NumbersFormatOptions } from './types'; @@ -9,135 +9,114 @@ export function createActiveNumbers(options: ActiveNumbersOptions = {}): ActiveN locale: localeSource?.getLocale() ?? options.locale }); - let version = $state(0); - const preferenceListeners = new SvelteSet<() => void>(); - const localeListeners = new SvelteSet<(locale: string) => void>(); - - function notifyPreferences(): void { - version++; - preferenceListeners.forEach((fn) => fn()); - } - - function syncLocale(locale: string): void { - engine.setLocale(locale); - notifyPreferences(); - localeListeners.forEach((fn) => fn(engine.getLocale())); - } - - const unsubscribeLocale = localeSource?.onLocaleChange?.(syncLocale); + const runtime = createActiveFormatsRuntime({ + localeSource, + getLocale: () => engine.getLocale(), + setLocale: (locale) => engine.setLocale(locale) + }); return { getLocale() { - void version; + runtime.read(); return engine.getLocale(); }, - setLocale: syncLocale, + setLocale: runtime.syncLocale, format(value: number, options?: NumbersFormatOptions): string { - void version; + runtime.read(); return engine.format(value, options); }, formatPercent(value, options) { - void version; + runtime.read(); return engine.formatPercent(value, options); }, formatCompact(value, options) { - void version; + runtime.read(); return engine.formatCompact(value, options); }, formatCurrency(value, currency, options) { - void version; + runtime.read(); return engine.formatCurrency(value, currency, options); }, formatUnit(value, unit, options) { - void version; + runtime.read(); return engine.formatUnit(value, unit, options); }, parse(value) { - void version; + runtime.read(); return engine.parse(value); }, getDecimalSeparator() { - void version; + runtime.read(); return engine.getDecimalSeparator(); }, getGroupSeparator() { - void version; + runtime.read(); return engine.getGroupSeparator(); }, getGrouping() { - void version; + runtime.read(); return engine.getGrouping(); }, setDecimalSeparator(separator) { engine.setDecimalSeparator(separator); - notifyPreferences(); + runtime.notifyChange(); }, clearDecimalSeparator() { engine.clearDecimalSeparator(); - notifyPreferences(); + runtime.notifyChange(); }, isDecimalSeparatorAuto() { - void version; + runtime.read(); return engine.isDecimalSeparatorAuto(); }, setGroupSeparator(separator) { engine.setGroupSeparator(separator); - notifyPreferences(); + runtime.notifyChange(); }, clearGroupSeparator() { engine.clearGroupSeparator(); - notifyPreferences(); + runtime.notifyChange(); }, isGroupSeparatorAuto() { - void version; + runtime.read(); return engine.isGroupSeparatorAuto(); }, setGrouping(enabled) { engine.setGrouping(enabled); - notifyPreferences(); + runtime.notifyChange(); }, clearGrouping() { engine.clearGrouping(); - notifyPreferences(); + runtime.notifyChange(); }, isGroupingAuto() { - void version; + runtime.read(); return engine.isGroupingAuto(); }, - onPreferenceChange(fn) { - preferenceListeners.add(fn); - return () => preferenceListeners.delete(fn); - }, + onPreferenceChange: runtime.onChange, - onLocaleChange(fn) { - localeListeners.add(fn); - return () => localeListeners.delete(fn); - }, + onLocaleChange: runtime.onLocaleChange, - dispose() { - unsubscribeLocale?.(); - preferenceListeners.clear(); - localeListeners.clear(); - } + dispose: runtime.dispose }; } diff --git a/src/arts/fmts/nums/engine-numbers.ts b/src/arts/fmts/nums/engine-numbers.ts index ae038fc..9466f83 100644 --- a/src/arts/fmts/nums/engine-numbers.ts +++ b/src/arts/fmts/nums/engine-numbers.ts @@ -1,88 +1,65 @@ -import { AUTO_VALUE, DEFAULT_LOCALE } from '../consts'; -import { normalizeLocaleTag, resolveLocaleInput } from '../helpers'; +import { AUTO_VALUE } from '../consts'; +import { createAutoState } from '../auto-state'; +import { createFormatsLocaleState } from '../locale-state'; +import { + getCachedNumberFormat, + getNumberFormatPart, + normalizeNumberFormatOptions +} from './number-format'; +import { parseLocaleNumber } from './number-parse'; import type { EngineNumbers, EngineNumbersOptions, NumbersCurrencyFormatOptions, NumbersFormatOptions, + NumbersGroupingMode, + NumbersSeparatorMode, NumbersUnitFormatOptions } from './types'; -const formatCache = new Map(); - -function normalizeOptions(options: NumbersFormatOptions = {}): Intl.NumberFormatOptions { - const { minDecimals, maxDecimals, ...rest } = options; - return { - ...rest, - minimumFractionDigits: minDecimals ?? rest.minimumFractionDigits, - maximumFractionDigits: maxDecimals ?? rest.maximumFractionDigits - }; -} - -function getCachedFormat(locale: string, options?: Intl.NumberFormatOptions): Intl.NumberFormat { - const key = JSON.stringify([locale, options ?? {}]); - const cached = formatCache.get(key); - if (cached !== undefined) return cached; - - const formatter = new Intl.NumberFormat(locale || undefined, options); - formatCache.set(key, formatter); - return formatter; -} - -function getPart(locale: string, value: number, type: Intl.NumberFormatPartTypes): string { - const parts = getCachedFormat(locale).formatToParts(value); - return parts.find((part) => part.type === type)?.value ?? ''; -} - -function buildNumeralMap(locale: string): Map { - const numerals = getCachedFormat(locale, { useGrouping: false }).format(9876543210); - const map = new Map(); - for (const [index, char] of [...numerals].entries()) { - map.set(char, String(9 - index)); - } - return map; -} - export function createEngineNumbers(options: EngineNumbersOptions = {}): EngineNumbers { - const localeInput = resolveLocaleInput(options.locale); - let localeGetter = localeInput.getter; - let currentLocale = localeInput.value; - let decimalSeparator = - options.decimalSeparator === AUTO_VALUE ? null : (options.decimalSeparator ?? null); - let groupSeparator = - options.groupSeparator === AUTO_VALUE ? null : (options.groupSeparator ?? null); - let grouping = options.grouping === AUTO_VALUE ? null : (options.grouping ?? null); + const localeState = createFormatsLocaleState(options.locale); + const decimalSeparator = createAutoState({ + initial: options.decimalSeparator, + auto: AUTO_VALUE + }); + const groupSeparator = createAutoState({ + initial: options.groupSeparator, + auto: AUTO_VALUE + }); + const grouping = createAutoState({ + initial: options.grouping, + auto: AUTO_VALUE + }); const defaultFormat = options.format ?? {}; function getLocale(): string { - const locale = normalizeLocaleTag(localeGetter?.() ?? currentLocale); - return locale || DEFAULT_LOCALE; + return localeState.getLocale(); } function setLocale(locale: string): void { - localeGetter = undefined; - currentLocale = normalizeLocaleTag(locale) || DEFAULT_LOCALE; + localeState.setLocale(locale); } function getDecimalSeparator(): string { - return (decimalSeparator ?? getPart(getLocale(), 1.1, 'decimal')) || '.'; + return decimalSeparator.get(() => getNumberFormatPart(getLocale(), 1.1, 'decimal') || '.'); } function getGroupSeparator(): string { - return (groupSeparator ?? getPart(getLocale(), 1000, 'group')) || ','; + return groupSeparator.get(() => getNumberFormatPart(getLocale(), 1000, 'group') || ','); } function getGrouping(): boolean { - return grouping ?? true; + return grouping.get(() => true); } function mergedOptions(options?: NumbersFormatOptions): Intl.NumberFormatOptions { - return normalizeOptions({ ...defaultFormat, ...options }); + return normalizeNumberFormatOptions({ ...defaultFormat, ...options }); } function format(value: number, options?: NumbersFormatOptions): string { if (!Number.isFinite(value)) return String(value); - return getCachedFormat(getLocale(), mergedOptions(options)).format(value); + return getCachedNumberFormat(getLocale(), mergedOptions(options)).format(value); } return { @@ -117,25 +94,11 @@ export function createEngineNumbers(options: EngineNumbersOptions = {}): EngineN }, parse(value: string): number | undefined { - if (value.trim() === '') return undefined; - - const locale = getLocale(); - const numerals = buildNumeralMap(locale); - const decimal = getDecimalSeparator(); - const group = getGroupSeparator(); - - let normalized = ''; - for (const char of value.trim()) { - normalized += numerals.get(char) ?? char; - } - - if (group !== '') normalized = normalized.replaceAll(group, ''); - if (decimal !== '.') normalized = normalized.replace(decimal, '.'); - normalized = normalized.replace(/[\s\u00a0\u200e\u200f]/g, ''); - normalized = normalized.replace(/[^\d.+\-eE]/g, ''); - - const parsed = Number(normalized); - return Number.isNaN(parsed) ? undefined : parsed; + return parseLocaleNumber(value, { + locale: getLocale(), + decimalSeparator: getDecimalSeparator(), + groupSeparator: getGroupSeparator() + }); }, getDecimalSeparator, @@ -143,39 +106,39 @@ export function createEngineNumbers(options: EngineNumbersOptions = {}): EngineN getGrouping, setDecimalSeparator(separator) { - decimalSeparator = separator === AUTO_VALUE ? null : separator; + decimalSeparator.set(separator); }, clearDecimalSeparator() { - decimalSeparator = null; + decimalSeparator.clear(); }, isDecimalSeparatorAuto() { - return decimalSeparator === null; + return decimalSeparator.isAuto(); }, setGroupSeparator(separator) { - groupSeparator = separator === AUTO_VALUE ? null : separator; + groupSeparator.set(separator); }, clearGroupSeparator() { - groupSeparator = null; + groupSeparator.clear(); }, isGroupSeparatorAuto() { - return groupSeparator === null; + return groupSeparator.isAuto(); }, setGrouping(enabled) { - grouping = enabled === AUTO_VALUE ? null : enabled; + grouping.set(enabled); }, clearGrouping() { - grouping = null; + grouping.clear(); }, isGroupingAuto() { - return grouping === null; + return grouping.isAuto(); } }; } diff --git a/src/arts/fmts/nums/number-format.ts b/src/arts/fmts/nums/number-format.ts new file mode 100644 index 0000000..a402a60 --- /dev/null +++ b/src/arts/fmts/nums/number-format.ts @@ -0,0 +1,36 @@ +import type { NumbersFormatOptions } from './types'; + +const formatCache = new Map(); + +export function normalizeNumberFormatOptions( + options: NumbersFormatOptions = {} +): Intl.NumberFormatOptions { + const { minDecimals, maxDecimals, ...rest } = options; + return { + ...rest, + minimumFractionDigits: minDecimals ?? rest.minimumFractionDigits, + maximumFractionDigits: maxDecimals ?? rest.maximumFractionDigits + }; +} + +export function getCachedNumberFormat( + locale: string, + options?: Intl.NumberFormatOptions +): Intl.NumberFormat { + const key = JSON.stringify([locale, options ?? {}]); + const cached = formatCache.get(key); + if (cached !== undefined) return cached; + + const formatter = new Intl.NumberFormat(locale || undefined, options); + formatCache.set(key, formatter); + return formatter; +} + +export function getNumberFormatPart( + locale: string, + value: number, + type: Intl.NumberFormatPartTypes +): string { + const parts = getCachedNumberFormat(locale).formatToParts(value); + return parts.find((part) => part.type === type)?.value ?? ''; +} diff --git a/src/arts/fmts/nums/number-parse.ts b/src/arts/fmts/nums/number-parse.ts new file mode 100644 index 0000000..df22c81 --- /dev/null +++ b/src/arts/fmts/nums/number-parse.ts @@ -0,0 +1,45 @@ +import { getCachedNumberFormat } from './number-format'; + +const numeralMapCache = new Map>(); + +export interface ParseLocaleNumberOptions { + locale: string; + decimalSeparator: string; + groupSeparator: string; +} + +function buildNumeralMap(locale: string): Map { + const cached = numeralMapCache.get(locale); + if (cached !== undefined) return cached; + + const numerals = getCachedNumberFormat(locale, { useGrouping: false }).format(9876543210); + const map = new Map(); + for (const [index, char] of [...numerals].entries()) { + map.set(char, String(9 - index)); + } + numeralMapCache.set(locale, map); + return map; +} + +export function parseLocaleNumber( + value: string, + options: ParseLocaleNumberOptions +): number | undefined { + if (value.trim() === '') return undefined; + + const numerals = buildNumeralMap(options.locale); + let normalized = ''; + for (const char of value.trim()) { + normalized += numerals.get(char) ?? char; + } + + if (options.groupSeparator !== '') normalized = normalized.replaceAll(options.groupSeparator, ''); + if (options.decimalSeparator !== '.') { + normalized = normalized.replace(options.decimalSeparator, '.'); + } + normalized = normalized.replace(/[\s\u00a0\u200e\u200f]/g, ''); + normalized = normalized.replace(/[^\d.+\-eE]/g, ''); + + const parsed = Number(normalized); + return Number.isNaN(parsed) ? undefined : parsed; +} diff --git a/src/arts/fmts/nums/test/active-numbers.test.ts b/src/arts/fmts/nums/test/active-numbers.test.ts new file mode 100644 index 0000000..fc07eaa --- /dev/null +++ b/src/arts/fmts/nums/test/active-numbers.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { createActiveNumbers } from '../active-numbers.svelte'; + +describe('createActiveNumbers()', () => { + it('syncs locale source changes and notifies active listeners', () => { + let locale = 'en-US'; + let listener: ((locale: string) => void) | undefined; + const source = { + getLocale: () => locale, + onLocaleChange(fn: (nextLocale: string) => void) { + listener = fn; + return () => { + listener = undefined; + }; + } + }; + const nums = createActiveNumbers({ localeSource: source }); + const localeChanges: string[] = []; + let preferenceChanges = 0; + + nums.onLocaleChange((nextLocale) => localeChanges.push(nextLocale)); + nums.onPreferenceChange(() => { + preferenceChanges++; + }); + + locale = 'de-DE'; + listener?.(locale); + + expect(nums.getLocale()).toBe('de-DE'); + expect(nums.getDecimalSeparator()).toBe(','); + expect(localeChanges).toEqual(['de-DE']); + expect(preferenceChanges).toBe(1); + + nums.setGrouping(false); + expect(nums.getGrouping()).toBe(false); + expect(preferenceChanges).toBe(2); + + nums.dispose(); + expect(listener).toBeUndefined(); + }); +}); diff --git a/src/arts/fmts/test/auto-state.test.ts b/src/arts/fmts/test/auto-state.test.ts new file mode 100644 index 0000000..3da66aa --- /dev/null +++ b/src/arts/fmts/test/auto-state.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { createAutoState } from '../auto-state'; + +describe('createAutoState()', () => { + it('switches between resolved auto values and manual values', () => { + const state = createAutoState<'auto' | 'manual', string>({ + initial: 'auto', + auto: 'auto', + toManual: (value) => value + }); + + expect(state.isAuto()).toBe(true); + expect(state.get(() => 'resolved')).toBe('resolved'); + + state.set('manual'); + + expect(state.isAuto()).toBe(false); + expect(state.get(() => 'resolved')).toBe('manual'); + + state.clear(); + + expect(state.isAuto()).toBe(true); + expect(state.get(() => 'resolved')).toBe('resolved'); + }); + + it('normalizes manual values before storing them', () => { + const state = createAutoState({ + initial: 'eur', + auto: 'auto', + normalize: (value) => value.toLowerCase(), + toManual: (value) => value.toUpperCase() + }); + + expect(state.get(() => 'USD')).toBe('EUR'); + + state.set('AUTO'); + expect(state.isAuto()).toBe(true); + expect(state.get(() => 'USD')).toBe('USD'); + }); +}); diff --git a/src/arts/fmts/test/formats.test.ts b/src/arts/fmts/test/formats.test.ts index 5727be0..4b3aee6 100644 --- a/src/arts/fmts/test/formats.test.ts +++ b/src/arts/fmts/test/formats.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createEngineFormats } from '..'; +import { createActiveFormats, createEngineFormats } from '..'; describe('createEngineFormats()', () => { it('composes number, currency, units and dates format engines', async () => { @@ -26,3 +26,23 @@ describe('createEngineFormats()', () => { await expect(formats.currency.convert(1, 'EUR')).resolves.toBe(1); }); }); + +describe('createActiveFormats()', () => { + it('fans out locale changes to every active format module', () => { + const formats = createActiveFormats({ locale: 'en-US' }); + + expect(formats.getLocale()).toBe('en-US'); + expect(formats.currency.getCurrency()).toBe('USD'); + expect(formats.units.getSystem()).toBe('imperial'); + expect(formats.dates.getHourCycle()).toBe(12); + + formats.setLocale('es-ES'); + + expect(formats.getLocale()).toBe('es-ES'); + expect(formats.currency.getCurrency()).toBe('EUR'); + expect(formats.units.getSystem()).toBe('metric'); + expect(formats.dates.getDateOrder()).toBe('DMY'); + + formats.dispose(); + }); +}); diff --git a/src/arts/fmts/test/locale-state.test.ts b/src/arts/fmts/test/locale-state.test.ts new file mode 100644 index 0000000..03d9476 --- /dev/null +++ b/src/arts/fmts/test/locale-state.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_LOCALE } from '../consts'; +import { createFormatsLocaleState } from '../locale-state'; + +describe('createFormatsLocaleState()', () => { + it('normalizes locale strings and falls back to the default locale', () => { + const state = createFormatsLocaleState('es_ES'); + + expect(state.getLocale()).toBe('es-ES'); + + state.setLocale(''); + expect(state.getLocale()).toBe(DEFAULT_LOCALE); + }); + + it('tracks locale functions until a manual locale is set', () => { + let locale = 'en_GB'; + const state = createFormatsLocaleState(() => locale); + + expect(state.getLocale()).toBe('en-GB'); + + locale = 'fr_FR'; + expect(state.getLocale()).toBe('fr-FR'); + + state.setLocale('de_DE'); + locale = 'it_IT'; + + expect(state.getLocale()).toBe('de-DE'); + }); +}); diff --git a/src/arts/fmts/unts/active-units.svelte.ts b/src/arts/fmts/unts/active-units.svelte.ts index 058c4f3..dc9348a 100644 --- a/src/arts/fmts/unts/active-units.svelte.ts +++ b/src/arts/fmts/unts/active-units.svelte.ts @@ -1,4 +1,4 @@ -import { SvelteSet } from 'svelte/reactivity'; +import { createActiveFormatsRuntime } from '../active-runtime.svelte'; import { createEngineUnits } from './engine-units'; import type { ActiveUnits, ActiveUnitsOptions, UnitId, UnitKind, UnitSystemMode } from './types'; @@ -9,88 +9,67 @@ export function createActiveUnits(options: ActiveUnitsOptions = {}): ActiveUnits locale: localeSource?.getLocale() ?? options.locale }); - let version = $state(0); - const preferenceListeners = new SvelteSet<() => void>(); - const localeListeners = new SvelteSet<(locale: string) => void>(); - - function notifyPreferences(): void { - version++; - preferenceListeners.forEach((fn) => fn()); - } - - function syncLocale(locale: string): void { - engine.setLocale(locale); - notifyPreferences(); - localeListeners.forEach((fn) => fn(engine.getLocale())); - } - - const unsubscribeLocale = localeSource?.onLocaleChange?.(syncLocale); + const runtime = createActiveFormatsRuntime({ + localeSource, + getLocale: () => engine.getLocale(), + setLocale: (locale) => engine.setLocale(locale) + }); return { getLocale() { - void version; + runtime.read(); return engine.getLocale(); }, - setLocale: syncLocale, + setLocale: runtime.syncLocale, getSystem() { - void version; + runtime.read(); return engine.getSystem(); }, setSystem(system: UnitSystemMode) { engine.setSystem(system); - notifyPreferences(); + runtime.notifyChange(); }, clearSystem() { engine.clearSystem(); - notifyPreferences(); + runtime.notifyChange(); }, isSystemAuto() { - void version; + runtime.read(); return engine.isSystemAuto(); }, getDefaultUnit(kind: UnitKind, system) { - void version; + runtime.read(); return engine.getDefaultUnit(kind, system); }, isDefaultUnit(unit: UnitId, kind, system) { - void version; + runtime.read(); return engine.isDefaultUnit(unit, kind, system); }, format(value, unit, options) { - void version; + runtime.read(); return engine.format(value, unit, options); }, formatDefault(value, kind, options) { - void version; + runtime.read(); return engine.formatDefault(value, kind, options); }, convert: engine.convert, convertToDefault: engine.convertToDefault, - onPreferenceChange(fn) { - preferenceListeners.add(fn); - return () => preferenceListeners.delete(fn); - }, + onPreferenceChange: runtime.onChange, - onLocaleChange(fn) { - localeListeners.add(fn); - return () => localeListeners.delete(fn); - }, + onLocaleChange: runtime.onLocaleChange, - dispose() { - unsubscribeLocale?.(); - preferenceListeners.clear(); - localeListeners.clear(); - } + dispose: runtime.dispose }; } diff --git a/src/arts/fmts/unts/conversions.ts b/src/arts/fmts/unts/conversions.ts index d9224f5..525fc8f 100644 --- a/src/arts/fmts/unts/conversions.ts +++ b/src/arts/fmts/unts/conversions.ts @@ -1,4 +1,4 @@ -import { UNITS_ERRORS } from './errors'; +import { UnitsIncompatibleUnitsError, UnitsUnknownUnitError } from './errors'; import { UNIT_DEFINITIONS } from './unit-definitions'; import type { UnitId } from './types'; @@ -6,14 +6,14 @@ function temperatureToCelsius(value: number, unit: UnitId): number { if (unit === 'celsius') return value; if (unit === 'fahrenheit') return ((value - 32) * 5) / 9; if (unit === 'kelvin') return value - 273.15; - throw new Error(UNITS_ERRORS.UNKNOWN_UNIT(unit)); + throw new UnitsUnknownUnitError(unit); } function celsiusToTemperature(value: number, unit: UnitId): number { if (unit === 'celsius') return value; if (unit === 'fahrenheit') return (value * 9) / 5 + 32; if (unit === 'kelvin') return value + 273.15; - throw new Error(UNITS_ERRORS.UNKNOWN_UNIT(unit)); + throw new UnitsUnknownUnitError(unit); } export function convert(value: number, from: UnitId, to: UnitId): number { @@ -22,11 +22,11 @@ export function convert(value: number, from: UnitId, to: UnitId): number { const fromDef = UNIT_DEFINITIONS[from]; const toDef = UNIT_DEFINITIONS[to]; - if (fromDef === undefined) throw new Error(UNITS_ERRORS.UNKNOWN_UNIT(from)); - if (toDef === undefined) throw new Error(UNITS_ERRORS.UNKNOWN_UNIT(to)); + if (fromDef === undefined) throw new UnitsUnknownUnitError(from); + if (toDef === undefined) throw new UnitsUnknownUnitError(to); if (fromDef.base !== toDef.base) { - throw new Error(UNITS_ERRORS.INCOMPATIBLE_UNITS(from, fromDef.base, to, toDef.base)); + throw new UnitsIncompatibleUnitsError(from, fromDef.base, to, toDef.base); } if (fromDef.base === 'temperature') { diff --git a/src/arts/fmts/unts/defaults.ts b/src/arts/fmts/unts/defaults.ts index 454c1df..93b421f 100644 --- a/src/arts/fmts/unts/defaults.ts +++ b/src/arts/fmts/unts/defaults.ts @@ -1,4 +1,4 @@ -import { UNITS_ERRORS } from './errors'; +import { UnitsDefaultUnitNotFoundError } from './errors'; import { UNIT_DEFINITIONS } from './unit-definitions'; import type { UnitId, UnitKind, UnitSystem } from './types'; @@ -7,7 +7,7 @@ export function resolveDefaultUnit(kind: UnitKind, system: UnitSystem): UnitId { if (definition.defaultFor?.[system]?.includes(kind)) return unit; } - throw new Error(UNITS_ERRORS.DEFAULT_UNIT_NOT_FOUND(system, kind)); + throw new UnitsDefaultUnitNotFoundError(system, kind); } export function isDefaultUnit(unit: UnitId, kind: UnitKind, system: UnitSystem): boolean { diff --git a/src/arts/fmts/unts/engine-units.ts b/src/arts/fmts/unts/engine-units.ts index be4bb9d..b64e0ca 100644 --- a/src/arts/fmts/unts/engine-units.ts +++ b/src/arts/fmts/unts/engine-units.ts @@ -1,44 +1,50 @@ import { AUTO_UNIT_SYSTEM } from './consts'; import { convert } from './conversions'; import { isDefaultUnit as isDefaultUnitFor, resolveDefaultUnit } from './defaults'; -import { UNITS_ERRORS } from './errors'; +import { UnitsUnknownUnitError } from './errors'; import { resolveUnitSystem } from './locale-defaults'; import { UNIT_DEFINITIONS } from './unit-definitions'; -import { DEFAULT_LOCALE } from '../consts'; -import { normalizeLocaleTag, resolveLocaleInput } from '../helpers'; -import type { EngineUnits, EngineUnitsOptions, UnitId, UnitKind, UnitSystem } from './types'; +import { createAutoState } from '../auto-state'; +import { createFormatsLocaleState } from '../locale-state'; +import type { + EngineUnits, + EngineUnitsOptions, + UnitId, + UnitKind, + UnitSystem, + UnitSystemMode +} from './types'; export function createEngineUnits(options: EngineUnitsOptions = {}): EngineUnits { - const localeInput = resolveLocaleInput(options.locale); - let localeGetter = localeInput.getter; - let currentLocale = localeInput.value; - let currentSystem = options.system ?? AUTO_UNIT_SYSTEM; + const localeState = createFormatsLocaleState(options.locale); + const unitSystem = createAutoState({ + initial: options.system, + auto: AUTO_UNIT_SYSTEM + }); const { numbers } = options; function getLocale(): string { - const locale = normalizeLocaleTag(localeGetter?.() ?? currentLocale); - return locale || DEFAULT_LOCALE; + return localeState.getLocale(); } function setLocale(locale: string): void { - localeGetter = undefined; - currentLocale = normalizeLocaleTag(locale) || DEFAULT_LOCALE; + localeState.setLocale(locale); } function getSystem(): UnitSystem { - return currentSystem === AUTO_UNIT_SYSTEM ? resolveUnitSystem(getLocale()) : currentSystem; + return unitSystem.get(() => resolveUnitSystem(getLocale())); } - function setSystem(system: typeof currentSystem): void { - currentSystem = system; + function setSystem(system: UnitSystemMode): void { + unitSystem.set(system); } function clearSystem(): void { - currentSystem = AUTO_UNIT_SYSTEM; + unitSystem.clear(); } function isSystemAuto(): boolean { - return currentSystem === AUTO_UNIT_SYSTEM; + return unitSystem.isAuto(); } function getDefaultUnit(kind: UnitKind, system = getSystem()): UnitId { @@ -47,7 +53,7 @@ export function createEngineUnits(options: EngineUnitsOptions = {}): EngineUnits function getUnitKind(unit: UnitId): UnitKind { const definition = UNIT_DEFINITIONS[unit]; - if (definition === undefined) throw new Error(UNITS_ERRORS.UNKNOWN_UNIT(unit)); + if (definition === undefined) throw new UnitsUnknownUnitError(unit); return definition.kind; } diff --git a/src/arts/fmts/unts/errors.ts b/src/arts/fmts/unts/errors.ts index daf08fb..87efeff 100644 --- a/src/arts/fmts/unts/errors.ts +++ b/src/arts/fmts/unts/errors.ts @@ -7,3 +7,54 @@ export const UNITS_ERRORS = { DEFAULT_UNIT_NOT_FOUND: (system: string, kind: string): string => `[formats.units] Default unit not found for "${system}:${kind}".` } as const; + +export const UNITS_ERROR_NAMES = { + UNKNOWN_UNIT: 'UnitsUnknownUnitError', + INCOMPATIBLE_UNITS: 'UnitsIncompatibleUnitsError', + DEFAULT_UNIT_NOT_FOUND: 'UnitsDefaultUnitNotFoundError' +} as const; + +export class UnitsUnknownUnitError extends RangeError { + constructor(readonly unit: string) { + super(UNITS_ERRORS.UNKNOWN_UNIT(unit)); + this.name = UNITS_ERROR_NAMES.UNKNOWN_UNIT; + } +} + +export class UnitsIncompatibleUnitsError extends RangeError { + constructor( + readonly from: string, + readonly fromBase: string, + readonly to: string, + readonly toBase: string + ) { + super(UNITS_ERRORS.INCOMPATIBLE_UNITS(from, fromBase, to, toBase)); + this.name = UNITS_ERROR_NAMES.INCOMPATIBLE_UNITS; + } +} + +export class UnitsDefaultUnitNotFoundError extends RangeError { + constructor( + readonly system: string, + readonly kind: string + ) { + super(UNITS_ERRORS.DEFAULT_UNIT_NOT_FOUND(system, kind)); + this.name = UNITS_ERROR_NAMES.DEFAULT_UNIT_NOT_FOUND; + } +} + +export function isUnitsUnknownUnitError(error: unknown): error is UnitsUnknownUnitError { + return error instanceof UnitsUnknownUnitError; +} + +export function isUnitsIncompatibleUnitsError( + error: unknown +): error is UnitsIncompatibleUnitsError { + return error instanceof UnitsIncompatibleUnitsError; +} + +export function isUnitsDefaultUnitNotFoundError( + error: unknown +): error is UnitsDefaultUnitNotFoundError { + return error instanceof UnitsDefaultUnitNotFoundError; +} diff --git a/src/arts/fmts/unts/index.ts b/src/arts/fmts/unts/index.ts index 77b24b8..984c16e 100644 --- a/src/arts/fmts/unts/index.ts +++ b/src/arts/fmts/unts/index.ts @@ -3,7 +3,16 @@ export { createActiveUnits } from './active-units.svelte'; export { createEngineUnits } from './engine-units'; export { convert } from './conversions'; export { resolveDefaultUnit, isDefaultUnit } from './defaults'; -export { UNITS_ERRORS } from './errors'; +export { + UNITS_ERROR_NAMES, + UNITS_ERRORS, + UnitsDefaultUnitNotFoundError, + UnitsIncompatibleUnitsError, + UnitsUnknownUnitError, + isUnitsDefaultUnitNotFoundError, + isUnitsIncompatibleUnitsError, + isUnitsUnknownUnitError +} from './errors'; export { resolveUnitSystem } from './locale-defaults'; export { UNIT_DEFINITIONS } from './unit-definitions'; export type { diff --git a/src/arts/fmts/unts/test/active-units.test.ts b/src/arts/fmts/unts/test/active-units.test.ts new file mode 100644 index 0000000..16e5844 --- /dev/null +++ b/src/arts/fmts/unts/test/active-units.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { createEngineNumbers } from '../../nums'; +import { createActiveUnits } from '../active-units.svelte'; + +describe('createActiveUnits()', () => { + it('syncs locale source changes and notifies active listeners', () => { + let locale = 'en-US'; + let listener: ((locale: string) => void) | undefined; + const source = { + getLocale: () => locale, + onLocaleChange(fn: (nextLocale: string) => void) { + listener = fn; + return () => { + listener = undefined; + }; + } + }; + const unts = createActiveUnits({ + localeSource: source, + numbers: createEngineNumbers({ locale: () => locale }) + }); + const localeChanges: string[] = []; + let preferenceChanges = 0; + + unts.onLocaleChange((nextLocale) => localeChanges.push(nextLocale)); + unts.onPreferenceChange(() => { + preferenceChanges++; + }); + + locale = 'es-ES'; + listener?.(locale); + + expect(unts.getLocale()).toBe('es-ES'); + expect(unts.getSystem()).toBe('metric'); + expect(unts.getDefaultUnit('distance')).toBe('kilometer'); + expect(localeChanges).toEqual(['es-ES']); + expect(preferenceChanges).toBe(1); + + unts.setSystem('imperial'); + expect(unts.getSystem()).toBe('imperial'); + expect(preferenceChanges).toBe(2); + + unts.dispose(); + expect(listener).toBeUndefined(); + }); +}); diff --git a/src/arts/fmts/unts/test/engine-units.test.ts b/src/arts/fmts/unts/test/engine-units.test.ts index d3d1d1c..95712d7 100644 --- a/src/arts/fmts/unts/test/engine-units.test.ts +++ b/src/arts/fmts/unts/test/engine-units.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; import { createEngineNumbers } from '../../nums'; -import { createEngineUnits, resolveUnitSystem } from '..'; +import { + createEngineUnits, + isUnitsIncompatibleUnitsError, + isUnitsUnknownUnitError, + resolveUnitSystem +} from '..'; describe('createEngineUnits()', () => { it('resolves systems from explicit locale regions', () => { @@ -29,4 +34,20 @@ describe('createEngineUnits()', () => { expect(unts.convert(0, 'celsius', 'fahrenheit')).toBe(32); expect(unts.formatDefault(2, 'distance')).toContain('mi'); }); + + it('throws typed unit errors', () => { + const unts = createEngineUnits(); + + try { + unts.convert(1, 'mile', 'celsius'); + } catch (error) { + expect(isUnitsIncompatibleUnitsError(error)).toBe(true); + } + + try { + unts.convert(1, 'unknown', 'meter'); + } catch (error) { + expect(isUnitsUnknownUnitError(error)).toBe(true); + } + }); }); diff --git a/src/arts/http/consts.ts b/src/arts/http/consts.ts index f650781..02d8ddf 100644 --- a/src/arts/http/consts.ts +++ b/src/arts/http/consts.ts @@ -71,6 +71,15 @@ export const ERROR_MSG_TOTAL_TIMEOUT_PREFIX = 'Request exceeded total timeout'; export const ERROR_MSG_ABORT_PREFIX = 'Request aborted: '; export const ERROR_MSG_ABORT_UNKNOWN_REASON = 'unknown reason'; +export const HTTP_DIAGNOSTIC_EVENTS = { + REQUEST: 'http.request', + BODY_SCHEMA_REJECTED: 'http.body_schema_rejected', + NETWORK_ERROR: 'http.network_error', + RETRYING: 'http.retrying', + RESPONSE_SCHEMA_FAILED: 'http.response_schema_failed', + HTTP_STATUS: 'http.status' +} as const; + export function requestBodyValidationErrorMessage(method: HttpMethod, url: string): string { return `${ERROR_PREFIX}Request body failed validation for ${method} ${url}`; } diff --git a/src/arts/http/diagnostics.ts b/src/arts/http/diagnostics.ts new file mode 100644 index 0000000..a2fd82f --- /dev/null +++ b/src/arts/http/diagnostics.ts @@ -0,0 +1,105 @@ +import { + LogLevel, + createCatalogDiagnostics, + type DiagnosticCatalog, + type DiagnosticEvent, + type Diagnostics, + type Logger +} from '$libs/logr'; +import { + HTTP_DIAGNOSTIC_EVENTS, + HTTP_METHOD_GET, + LOGGER_CATEGORY, + bodySchemaRejectedLogMessage, + httpStatusLogMessage, + networkErrorLogMessage, + requestLogMessage, + responseSchemaFailedLogMessage, + retryingLogMessage +} from './consts.ts'; +import type { HttpMethod } from './types.ts'; + +export type HttpDiagnosticType = + (typeof HTTP_DIAGNOSTIC_EVENTS)[keyof typeof HTTP_DIAGNOSTIC_EVENTS]; + +export interface HttpDiagnosticMeta { + readonly method: HttpMethod; + readonly url: string; + readonly attempt?: number; + readonly error?: unknown; + readonly issueCount?: number; + readonly retryDelay?: number; + readonly nextAttempt?: number; + readonly totalAttempts?: number; + readonly status?: number; + readonly statusText?: string; +} + +export type HttpDiagnosticEvent = DiagnosticEvent; +export type HttpDiagnostics = Diagnostics; + +const HTTP_DIAGNOSTIC_LOGS: DiagnosticCatalog = { + [HTTP_DIAGNOSTIC_EVENTS.REQUEST]: (event) => ({ + level: LogLevel.DEBUG, + message: requestLogMessage(methodOf(event), urlOf(event)) + }), + [HTTP_DIAGNOSTIC_EVENTS.BODY_SCHEMA_REJECTED]: (event) => ({ + level: LogLevel.ERROR, + message: bodySchemaRejectedLogMessage(methodOf(event), urlOf(event)) + }), + [HTTP_DIAGNOSTIC_EVENTS.NETWORK_ERROR]: (event) => ({ + level: LogLevel.WARN, + message: networkErrorLogMessage(methodOf(event), urlOf(event)) + }), + [HTTP_DIAGNOSTIC_EVENTS.RETRYING]: (event) => ({ + level: LogLevel.WARN, + message: retryingLogMessage( + methodOf(event), + urlOf(event), + event.meta?.retryDelay ?? 0, + event.meta?.nextAttempt ?? 0, + event.meta?.totalAttempts ?? 0 + ) + }), + [HTTP_DIAGNOSTIC_EVENTS.RESPONSE_SCHEMA_FAILED]: (event) => ({ + level: LogLevel.ERROR, + message: responseSchemaFailedLogMessage(methodOf(event), urlOf(event)) + }), + [HTTP_DIAGNOSTIC_EVENTS.HTTP_STATUS]: (event) => ({ + level: LogLevel.WARN, + message: httpStatusLogMessage( + methodOf(event), + urlOf(event), + event.meta?.status ?? 0, + event.meta?.statusText ?? '' + ) + }) +}; + +function methodOf(event: HttpDiagnosticEvent): HttpMethod { + return event.meta?.method ?? HTTP_METHOD_GET; +} + +function urlOf(event: HttpDiagnosticEvent): string { + return event.meta?.url ?? LOGGER_CATEGORY; +} + +export function createHttpDiagnostics(logger?: Logger): HttpDiagnostics { + return createCatalogDiagnostics({ + logger, + defaultCategory: LOGGER_CATEGORY, + catalog: HTTP_DIAGNOSTIC_LOGS + }); +} + +export function emitHttpDiagnostic( + diagnostics: HttpDiagnostics, + type: HttpDiagnosticType, + meta: HttpDiagnosticMeta +): void { + diagnostics.emit({ + artifact: LOGGER_CATEGORY, + type, + meta + }); +} diff --git a/src/arts/http/engine-http.ts b/src/arts/http/engine-http.ts index e6e1e01..e37885d 100644 --- a/src/arts/http/engine-http.ts +++ b/src/arts/http/engine-http.ts @@ -1,10 +1,7 @@ import type { StandardSchemaV1 } from '$libs/standard-schema'; -import { isPromiseLike } from '$libs/standard-schema'; -import { applyHeaders, mergeHeaders, parseBody, serializeBody } from './body.ts'; +import { mergeHeaders, serializeBody } from './body.ts'; import { - DEFAULT_RETRY, - DEFAULT_TIMEOUT, HTTP_HEADER_CONTENT_TYPE, HTTP_METHOD_DELETE, HTTP_METHOD_GET, @@ -13,21 +10,29 @@ import { HTTP_METHOD_PATCH, HTTP_METHOD_POST, HTTP_METHOD_PUT, - HTTP_RESULT_KIND_HTTP, HTTP_RESULT_KIND_NETWORK, HTTP_RESULT_KIND_VALIDATION, - LOGGER_CATEGORY, - bodySchemaRejectedLogMessage, httpStatusErrorMessage, - httpStatusLogMessage, - networkErrorLogMessage, requestBodyValidationErrorMessage, - requestLogMessage, - responseSchemaFailedLogMessage, - retryingLogMessage + HTTP_DIAGNOSTIC_EVENTS } from './consts.ts'; +import { emitHttpDiagnostic } from './diagnostics.ts'; +import { + freezeHttpDefaults, + mergeHttpOptions, + resolveHttpCallHooks, + resolveHttpCallRetry, + type ResolvedHttpDefaults +} from './engine-options.ts'; import { HttpBodyValidationError } from './errors.ts'; +import { + runAfterResponse, + runBeforeError, + runBeforeRequest, + runBeforeRetry +} from './hooks.ts'; import { computeRetryDelay, delayWithSignal, shouldRetryRequest } from './retry.ts'; +import { buildHttpFailure, buildOkOrValidation, runStandardValidate } from './results.ts'; import { appendSearch, normalizeSearch, resolveUrl } from './search.ts'; import { attemptTimeoutSignal, @@ -36,23 +41,17 @@ import { totalTimeoutSignal } from './timeout.ts'; import type { - AfterResponseHook, - BeforeErrorHook, - BeforeRequestHook, - BeforeRetryHook, EngineHttp, EngineHttpOptions, HookContext, HookRequest, HttpBodyRequestInit, HttpGetInit, - HttpHooks, HttpInit, HttpMethod, HttpResult, HttpResultPromise, - Out, - RetryConfig + Out } from './types.ts'; /** @@ -61,11 +60,11 @@ import type { * object, and `with(...)` returns a new engine without touching the parent. */ export function createEngineHttp(options: EngineHttpOptions = {}): EngineHttp { - const defaults = freezeDefaults(options); + const defaults = freezeHttpDefaults(options); const engine: EngineHttp = { with(overrides) { - return createEngineHttp(mergeOptions(options, overrides)); + return createEngineHttp(mergeHttpOptions(options, overrides)); }, get(url, init) { return execute(defaults, HTTP_METHOD_GET, url, init); @@ -93,120 +92,20 @@ export function createEngineHttp(options: EngineHttpOptions = {}): EngineHttp { return engine; } -// ============================================================================ -// INTERNALS -// ============================================================================ - -interface ResolvedDefaults { - readonly baseUrl: string | undefined; - readonly headers: EngineHttpOptions['headers']; - readonly fetch: typeof fetch; - readonly timeout: number; - readonly totalTimeout: number; - readonly retry: RetryConfig; - readonly hooks: Required; - readonly logger: EngineHttpOptions['logger']; -} - -function freezeDefaults(options: EngineHttpOptions): ResolvedDefaults { - const retry = { ...DEFAULT_RETRY, ...options.retry }; - return { - baseUrl: options.baseUrl, - headers: options.headers, - fetch: options.fetch ?? globalThis.fetch.bind(globalThis), - timeout: options.timeout ?? DEFAULT_TIMEOUT, - totalTimeout: options.totalTimeout ?? 0, - retry, - hooks: { - beforeRequest: options.hooks?.beforeRequest ?? [], - beforeRetry: options.hooks?.beforeRetry ?? [], - afterResponse: options.hooks?.afterResponse ?? [], - beforeError: options.hooks?.beforeError ?? [] - }, - logger: options.logger - }; -} - -function mergeOptions(parent: EngineHttpOptions, override: EngineHttpOptions): EngineHttpOptions { - return { - baseUrl: override.baseUrl ?? parent.baseUrl, - // Headers compose: when both layers set them, build a synthetic hook - // that applies parent first, override second (override wins on - // per-key conflict — `applyHeaders` uses `Headers.set`, not `append`). - headers: composeHeaderSources(parent.headers, override.headers), - fetch: override.fetch ?? parent.fetch, - timeout: override.timeout ?? parent.timeout, - totalTimeout: override.totalTimeout ?? parent.totalTimeout, - retry: { ...parent.retry, ...override.retry }, - hooks: { - beforeRequest: concatHooks(parent.hooks?.beforeRequest, override.hooks?.beforeRequest), - beforeRetry: concatHooks(parent.hooks?.beforeRetry, override.hooks?.beforeRetry), - afterResponse: concatHooks(parent.hooks?.afterResponse, override.hooks?.afterResponse), - beforeError: concatHooks(parent.hooks?.beforeError, override.hooks?.beforeError) - }, - logger: override.logger ?? parent.logger - }; -} - -function composeHeaderSources( - parent: EngineHttpOptions['headers'], - override: EngineHttpOptions['headers'] -): EngineHttpOptions['headers'] { - if (parent === undefined) return override; - if (override === undefined) return parent; - return async () => { - const target = new Headers(); - await applyHeaders(target, parent); - await applyHeaders(target, override); - return target; - }; -} - -function concatHooks( - a: ReadonlyArray | undefined, - b: ReadonlyArray | undefined -): ReadonlyArray | undefined { - if (a === undefined) return b; - if (b === undefined) return a; - return [...a, ...b]; -} - -function resolveCallRetry( - defaults: ResolvedDefaults, - init: HttpInit | undefined -): RetryConfig | null { - if (init?.retry === false) return null; - if (init?.retry === undefined) return defaults.retry; - return { ...defaults.retry, ...init.retry }; -} - -function resolveCallHooks( - defaults: ResolvedDefaults, - init: HttpInit | undefined -): Required { - if (init?.hooks === undefined) return defaults.hooks; - return { - beforeRequest: [...defaults.hooks.beforeRequest, ...(init.hooks.beforeRequest ?? [])], - beforeRetry: [...defaults.hooks.beforeRetry, ...(init.hooks.beforeRetry ?? [])], - afterResponse: [...defaults.hooks.afterResponse, ...(init.hooks.afterResponse ?? [])], - beforeError: [...defaults.hooks.beforeError, ...(init.hooks.beforeError ?? [])] - }; -} - // ============================================================================ // EXECUTION // ============================================================================ async function execute( - defaults: ResolvedDefaults, + defaults: ResolvedHttpDefaults, method: HttpMethod, url: string, init: (HttpGetInit | HttpBodyRequestInit) | undefined ): HttpResultPromise> { - const { logger } = defaults; + const { diagnostics } = defaults; const fetchImpl = init?.fetch ?? defaults.fetch; - const retry = resolveCallRetry(defaults, init); - const hooks = resolveCallHooks(defaults, init); + const retry = resolveHttpCallRetry(defaults, init); + const hooks = resolveHttpCallHooks(defaults, init); // Optional body schema: pre-flight validate the request payload. const bodyInput = @@ -226,12 +125,12 @@ async function execute( // Programmer error: log it before throwing so the failure is visible // in the logger pipeline even when the caller's catch swallows the // throw. - if (logger !== undefined) { - logger.error(LOGGER_CATEGORY, bodySchemaRejectedLogMessage(method, url), { - context: { url, issueCount: validated.issues.length }, - error - }); - } + emitHttpDiagnostic(diagnostics, HTTP_DIAGNOSTIC_EVENTS.BODY_SCHEMA_REJECTED, { + method, + url, + issueCount: validated.issues.length, + error + }); throw error; } } @@ -288,11 +187,11 @@ async function execute( const earlyResponse = await runBeforeRequest(hooks.beforeRequest, ctx); try { - if (logger !== undefined) { - logger.debug(LOGGER_CATEGORY, requestLogMessage(method, ctx.url), { - context: { attempt, url: ctx.url } - }); - } + emitHttpDiagnostic(diagnostics, HTTP_DIAGNOSTIC_EVENTS.REQUEST, { + method, + url: ctx.url, + attempt + }); response = earlyResponse instanceof Response @@ -307,12 +206,12 @@ async function execute( } catch (err) { response = undefined; lastError = classifyFetchError(err, signal); - if (logger !== undefined) { - logger.warn(LOGGER_CATEGORY, networkErrorLogMessage(method, ctx.url), { - context: { attempt }, - error: lastError instanceof Error ? lastError : new Error(String(lastError)) - }); - } + emitHttpDiagnostic(diagnostics, HTTP_DIAGNOSTIC_EVENTS.NETWORK_ERROR, { + method, + url: ctx.url, + attempt, + error: lastError instanceof Error ? lastError : new Error(String(lastError)) + }); } // Decide whether to retry. @@ -324,13 +223,14 @@ async function execute( }); if (retryable) { const delay = computeRetryDelay(retry, attempt, response); - if (logger !== undefined) { - logger.warn( - LOGGER_CATEGORY, - retryingLogMessage(method, fullUrl, delay, attempt + 1, retry.limit + 1), - { context: { attempt, retryDelay: delay } } - ); - } + emitHttpDiagnostic(diagnostics, HTTP_DIAGNOSTIC_EVENTS.RETRYING, { + method, + url: fullUrl, + attempt, + retryDelay: delay, + nextAttempt: attempt + 1, + totalAttempts: retry.limit + 1 + }); await runBeforeRetry(hooks.beforeRetry, { ...ctx, error: lastError, @@ -372,9 +272,11 @@ async function execute( // Validation failure on a 2xx body is a contract violation between // client and server — log at ERROR so it surfaces independently of // the result handling path. - if (!result.ok && result.kind === HTTP_RESULT_KIND_VALIDATION && logger !== undefined) { - logger.error(LOGGER_CATEGORY, responseSchemaFailedLogMessage(method, fullUrl), { - context: { url: fullUrl, issueCount: result.issues.length } + if (!result.ok && result.kind === HTTP_RESULT_KIND_VALIDATION) { + emitHttpDiagnostic(diagnostics, HTTP_DIAGNOSTIC_EVENTS.RESPONSE_SCHEMA_FAILED, { + method, + url: fullUrl, + issueCount: result.issues.length }); } return result; @@ -396,19 +298,12 @@ async function execute( // Log non-2xx as WARN — common enough to be expected (4xx mostly), but // still useful in DevTools for diagnosing routing/auth/rate limits. - if (logger !== undefined) { - logger.warn( - LOGGER_CATEGORY, - httpStatusLogMessage(method, fullUrl, finalResponse.status, finalResponse.statusText), - { - context: { - url: fullUrl, - status: finalResponse.status, - statusText: finalResponse.statusText - } - } - ); - } + emitHttpDiagnostic(diagnostics, HTTP_DIAGNOSTIC_EVENTS.HTTP_STATUS, { + method, + url: fullUrl, + status: finalResponse.status, + statusText: finalResponse.statusText + }); return buildHttpFailure(finalResponse, method); } @@ -425,99 +320,6 @@ async function execute( return { ok: false, kind: HTTP_RESULT_KIND_NETWORK, error: lastError } as HttpResult>; } -// ============================================================================ -// HOOK RUNNERS -// ============================================================================ - -async function runBeforeRequest( - hooks: ReadonlyArray, - ctx: HookContext -): Promise { - for (const hook of hooks) { - const result = await hook(ctx); - if (result instanceof Response) return result; - } - return undefined; -} - -async function runBeforeRetry( - hooks: ReadonlyArray, - ctx: HookContext & { error: unknown; retryDelay: number } -): Promise { - for (const hook of hooks) { - await hook(ctx); - } -} - -async function runAfterResponse( - hooks: ReadonlyArray, - ctx: HookContext, - response: Response -): Promise { - let current = response; - for (const hook of hooks) { - const result = await hook({ ...ctx, response: current }); - if (result instanceof Response) current = result; - } - return current; -} - -async function runBeforeError( - hooks: ReadonlyArray, - ctx: HookContext & { response?: Response }, - error: unknown -): Promise { - let current: Response | undefined = ctx.response; - for (const hook of hooks) { - const result = await hook({ ...ctx, response: current, error }); - if (result instanceof Response) current = result; - } - // Only return when the hook actually swapped in a fresh response. - if (current !== undefined && current !== ctx.response) return current; - return undefined; -} - -// ============================================================================ -// RESULT BUILDERS -// ============================================================================ - -async function buildOkOrValidation( - response: Response, - method: HttpMethod, - schema: S | undefined -): Promise>> { - if (schema === undefined) { - return { ok: true, value: undefined as Out, response }; - } - - const body = await parseBody(response, method); - const validated = await runStandardValidate(schema, body); - if (validated.issues !== undefined) { - return { - ok: false, - kind: HTTP_RESULT_KIND_VALIDATION, - issues: validated.issues, - response - }; - } - return { ok: true, value: validated.value as Out, response }; -} - -async function buildHttpFailure( - response: Response, - method: HttpMethod -): Promise>> { - const body = await parseBody(response, method); - return { - ok: false, - kind: HTTP_RESULT_KIND_HTTP, - status: response.status, - statusText: response.statusText, - body, - response - }; -} - function classifyFetchError(err: unknown, signal: AbortSignal): unknown { if (signal.aborted) { const classified = classifyAbort(signal); @@ -525,11 +327,3 @@ function classifyFetchError(err: unknown, signal: AbortSignal): unknown { } return err; } - -function runStandardValidate( - schema: StandardSchemaV1, - value: unknown -): StandardSchemaV1.Result | Promise> { - const result = schema['~standard'].validate(value); - return isPromiseLike(result) ? result : result; -} diff --git a/src/arts/http/engine-options.ts b/src/arts/http/engine-options.ts new file mode 100644 index 0000000..acdc050 --- /dev/null +++ b/src/arts/http/engine-options.ts @@ -0,0 +1,103 @@ +import { applyHeaders } from './body.ts'; +import { DEFAULT_RETRY, DEFAULT_TIMEOUT } from './consts.ts'; +import { createHttpDiagnostics, type HttpDiagnostics } from './diagnostics.ts'; +import type { EngineHttpOptions, HttpHooks, HttpInit, RetryConfig } from './types.ts'; + +export interface ResolvedHttpDefaults { + readonly baseUrl: string | undefined; + readonly headers: EngineHttpOptions['headers']; + readonly fetch: typeof fetch; + readonly timeout: number; + readonly totalTimeout: number; + readonly retry: RetryConfig; + readonly hooks: Required; + readonly diagnostics: HttpDiagnostics; +} + +export function freezeHttpDefaults(options: EngineHttpOptions): ResolvedHttpDefaults { + const retry = { ...DEFAULT_RETRY, ...options.retry }; + return { + baseUrl: options.baseUrl, + headers: options.headers, + fetch: options.fetch ?? globalThis.fetch.bind(globalThis), + timeout: options.timeout ?? DEFAULT_TIMEOUT, + totalTimeout: options.totalTimeout ?? 0, + retry, + hooks: { + beforeRequest: options.hooks?.beforeRequest ?? [], + beforeRetry: options.hooks?.beforeRetry ?? [], + afterResponse: options.hooks?.afterResponse ?? [], + beforeError: options.hooks?.beforeError ?? [] + }, + diagnostics: createHttpDiagnostics(options.logger) + }; +} + +export function mergeHttpOptions( + parent: EngineHttpOptions, + override: EngineHttpOptions +): EngineHttpOptions { + return { + baseUrl: override.baseUrl ?? parent.baseUrl, + // Headers compose: when both layers set them, build a synthetic hook + // that applies parent first, override second (override wins on + // per-key conflict because `applyHeaders` uses `Headers.set`). + headers: composeHeaderSources(parent.headers, override.headers), + fetch: override.fetch ?? parent.fetch, + timeout: override.timeout ?? parent.timeout, + totalTimeout: override.totalTimeout ?? parent.totalTimeout, + retry: { ...parent.retry, ...override.retry }, + hooks: { + beforeRequest: concatHooks(parent.hooks?.beforeRequest, override.hooks?.beforeRequest), + beforeRetry: concatHooks(parent.hooks?.beforeRetry, override.hooks?.beforeRetry), + afterResponse: concatHooks(parent.hooks?.afterResponse, override.hooks?.afterResponse), + beforeError: concatHooks(parent.hooks?.beforeError, override.hooks?.beforeError) + }, + logger: override.logger ?? parent.logger + }; +} + +export function resolveHttpCallRetry( + defaults: ResolvedHttpDefaults, + init: HttpInit | undefined +): RetryConfig | null { + if (init?.retry === false) return null; + if (init?.retry === undefined) return defaults.retry; + return { ...defaults.retry, ...init.retry }; +} + +export function resolveHttpCallHooks( + defaults: ResolvedHttpDefaults, + init: HttpInit | undefined +): Required { + if (init?.hooks === undefined) return defaults.hooks; + return { + beforeRequest: [...defaults.hooks.beforeRequest, ...(init.hooks.beforeRequest ?? [])], + beforeRetry: [...defaults.hooks.beforeRetry, ...(init.hooks.beforeRetry ?? [])], + afterResponse: [...defaults.hooks.afterResponse, ...(init.hooks.afterResponse ?? [])], + beforeError: [...defaults.hooks.beforeError, ...(init.hooks.beforeError ?? [])] + }; +} + +function composeHeaderSources( + parent: EngineHttpOptions['headers'], + override: EngineHttpOptions['headers'] +): EngineHttpOptions['headers'] { + if (parent === undefined) return override; + if (override === undefined) return parent; + return async () => { + const target = new Headers(); + await applyHeaders(target, parent); + await applyHeaders(target, override); + return target; + }; +} + +function concatHooks( + a: ReadonlyArray | undefined, + b: ReadonlyArray | undefined +): ReadonlyArray | undefined { + if (a === undefined) return b; + if (b === undefined) return a; + return [...a, ...b]; +} diff --git a/src/arts/http/hooks.ts b/src/arts/http/hooks.ts new file mode 100644 index 0000000..13d7026 --- /dev/null +++ b/src/arts/http/hooks.ts @@ -0,0 +1,55 @@ +import type { + AfterResponseHook, + BeforeErrorHook, + BeforeRequestHook, + BeforeRetryHook, + HookContext +} from './types.ts'; + +export async function runBeforeRequest( + hooks: ReadonlyArray, + ctx: HookContext +): Promise { + for (const hook of hooks) { + const result = await hook(ctx); + if (result instanceof Response) return result; + } + return undefined; +} + +export async function runBeforeRetry( + hooks: ReadonlyArray, + ctx: HookContext & { error: unknown; retryDelay: number } +): Promise { + for (const hook of hooks) { + await hook(ctx); + } +} + +export async function runAfterResponse( + hooks: ReadonlyArray, + ctx: HookContext, + response: Response +): Promise { + let current = response; + for (const hook of hooks) { + const result = await hook({ ...ctx, response: current }); + if (result instanceof Response) current = result; + } + return current; +} + +export async function runBeforeError( + hooks: ReadonlyArray, + ctx: HookContext & { response?: Response }, + error: unknown +): Promise { + let current: Response | undefined = ctx.response; + for (const hook of hooks) { + const result = await hook({ ...ctx, response: current, error }); + if (result instanceof Response) current = result; + } + // Only return when the hook actually swapped in a fresh response. + if (current !== undefined && current !== ctx.response) return current; + return undefined; +} diff --git a/src/arts/http/index.ts b/src/arts/http/index.ts index 85403b5..7e5c43e 100644 --- a/src/arts/http/index.ts +++ b/src/arts/http/index.ts @@ -3,6 +3,7 @@ // timeout primitives they never reach. export { createEngineHttp } from './engine-http.ts'; +export { createHttpDiagnostics, emitHttpDiagnostic } from './diagnostics.ts'; export { LOGGER_CATEGORY, @@ -12,7 +13,8 @@ export { DEFAULT_RETRY, DEFAULT_TIMEOUT, MAX_ERROR_BODY_BYTES, - RETRY_AFTER_HEADERS + RETRY_AFTER_HEADERS, + HTTP_DIAGNOSTIC_EVENTS } from './consts.ts'; export { @@ -40,7 +42,13 @@ export { totalTimeoutSignal } from './timeout.ts'; -export { applyHeaders, isJSONSerializable, mergeHeaders, parseBody, serializeBody } from './body.ts'; +export { + applyHeaders, + isJSONSerializable, + mergeHeaders, + parseBody, + serializeBody +} from './body.ts'; export { appendSearch, normalizeSearch, resolveUrl } from './search.ts'; @@ -67,3 +75,9 @@ export type { Out, RetryConfig } from './types.ts'; +export type { + HttpDiagnosticEvent, + HttpDiagnosticMeta, + HttpDiagnostics, + HttpDiagnosticType +} from './diagnostics.ts'; diff --git a/src/arts/http/results.ts b/src/arts/http/results.ts new file mode 100644 index 0000000..24f44b9 --- /dev/null +++ b/src/arts/http/results.ts @@ -0,0 +1,53 @@ +import type { StandardSchemaV1 } from '$libs/standard-schema'; +import { isPromiseLike } from '$libs/standard-schema'; +import { parseBody } from './body.ts'; +import { + HTTP_RESULT_KIND_HTTP, + HTTP_RESULT_KIND_VALIDATION +} from './consts.ts'; +import type { HttpMethod, HttpResult, Out } from './types.ts'; + +export async function buildOkOrValidation( + response: Response, + method: HttpMethod, + schema: S | undefined +): Promise>> { + if (schema === undefined) { + return { ok: true, value: undefined as Out, response }; + } + + const body = await parseBody(response, method); + const validated = await runStandardValidate(schema, body); + if (validated.issues !== undefined) { + return { + ok: false, + kind: HTTP_RESULT_KIND_VALIDATION, + issues: validated.issues, + response + }; + } + return { ok: true, value: validated.value as Out, response }; +} + +export async function buildHttpFailure( + response: Response, + method: HttpMethod +): Promise>> { + const body = await parseBody(response, method); + return { + ok: false, + kind: HTTP_RESULT_KIND_HTTP, + status: response.status, + statusText: response.statusText, + body, + response + }; +} + +export function runStandardValidate( + schema: StandardSchemaV1, + value: unknown +): StandardSchemaV1.Result | Promise> { + const result = schema['~standard'].validate(value); + return isPromiseLike(result) ? result : result; +} diff --git a/src/arts/http/types.ts b/src/arts/http/types.ts index fd91c2a..e4b5197 100644 --- a/src/arts/http/types.ts +++ b/src/arts/http/types.ts @@ -23,7 +23,7 @@ import type { HttpMethod, HttpSearchInit } from '$libs/http'; -import type { EngineLogger } from '$logr'; +import type { Logger } from '$libs/logr'; import type { HTTP_RESULT_KIND_HTTP, HTTP_RESULT_KIND_NETWORK, @@ -257,7 +257,7 @@ export interface EngineHttpOptions { /** Default hooks — per-call hooks are appended (defaults run first). */ hooks?: HttpHooks; /** When set, structured request/retry/error events are emitted under category `'http'`. */ - logger?: EngineLogger; + logger?: Logger; } /** diff --git a/src/arts/lang/README.md b/src/arts/lang/README.md index e8717a1..b9e7822 100644 --- a/src/arts/lang/README.md +++ b/src/arts/lang/README.md @@ -427,7 +427,7 @@ In addition to the above (with `locale` made optional on `t` / `ts`): | `PluralForms` | `{ other: string } & Partial>` | | `EngineLang` | Pure engine public interface | | `ActiveLang` | Reactive wrapper public interface | -| `LangLogger` | `{ warn, error }` — custom logger interface | +| `Logger` | Shared logger contract from `$libs/logr`; `setLogger(logger)` is set-once and diagnostics flow through `LangDiagnostics` | --- diff --git a/src/arts/lang/active-lang.svelte.ts b/src/arts/lang/active-lang.svelte.ts index 6dab28e..7a50b9a 100644 --- a/src/arts/lang/active-lang.svelte.ts +++ b/src/arts/lang/active-lang.svelte.ts @@ -3,12 +3,12 @@ import { type ActiveLang, createEngineLang, type EngineLang, - type LangLogger, type LangNode, type LangParams, type LangString, type SupportedLocale } from './index'; +import type { Logger } from '$libs/logr'; export type { LangNode, LangParams, LangString, SupportedLocale, ActiveLang }; @@ -90,7 +90,7 @@ function wrapEngine( return wrapEngine(childEngine, _locale, onLocaleChange); }, - setLogger(logger: LangLogger): void { + setLogger(logger: Logger): void { engine.setLogger(logger); }, diff --git a/src/arts/lang/consts.ts b/src/arts/lang/consts.ts index bc8663f..4cce35d 100644 --- a/src/arts/lang/consts.ts +++ b/src/arts/lang/consts.ts @@ -3,3 +3,16 @@ export const ID_PREFIX = '#?'; export const ID_FALLBACK_SEPARATOR = '|'; export const MAX_RESOLVE_DEPTH = 3; export const LOGGER_CATEGORY = 'lang'; +export const MONO_LANG_CATEGORY = 'lang.mono'; + +export const LANG_DIAGNOSTIC_EVENTS = { + KEY_NOT_FOUND: 'lang.key_not_found', + CIRCULAR_REFERENCE: 'lang.circular_reference', + MISSING_TRANSLATION: 'lang.missing_translation', + MISSING_TRANSLATION_RECORD: 'lang.missing_translation_record', + AMBIGUOUS_SIBLING_MATCH: 'lang.ambiguous_sibling_match', + EXTEND_LEAF_OVERWRITE: 'lang.extend_leaf_overwrite', + LOGGER_ALREADY_SET: 'lang.logger_already_set', + REFERENCE_FALLBACK_USED: 'lang.reference_fallback_used', + MONO_PATH_RETURNED: 'lang.mono_path_returned' +} as const; diff --git a/src/arts/lang/diagnostics.ts b/src/arts/lang/diagnostics.ts new file mode 100644 index 0000000..94554e3 --- /dev/null +++ b/src/arts/lang/diagnostics.ts @@ -0,0 +1,106 @@ +import { + LogLevel, + createCatalogDiagnostics, + type DiagnosticCatalog, + type DiagnosticEvent, + type Diagnostics, + type Logger +} from '$libs/logr'; +import { LANG_DIAGNOSTIC_EVENTS, LOGGER_CATEGORY } from './consts.ts'; +import { LANG_ERRORS } from './errors.ts'; +import type { SupportedLocale } from './types.ts'; + +export type LangDiagnosticType = + (typeof LANG_DIAGNOSTIC_EVENTS)[keyof typeof LANG_DIAGNOSTIC_EVENTS]; + +export interface LangDiagnosticMeta { + readonly path?: string; + readonly locale?: SupportedLocale; + readonly usedLocale?: SupportedLocale; + readonly siblingCount?: number; + readonly fallback?: string; + readonly kind?: 't' | 'ts'; + readonly error?: unknown; +} + +export type LangDiagnosticEvent = DiagnosticEvent; +export type LangDiagnostics = Diagnostics; + +const LANG_DIAGNOSTIC_LOGS: DiagnosticCatalog = { + [LANG_DIAGNOSTIC_EVENTS.KEY_NOT_FOUND]: (event) => ({ + level: LogLevel.ERROR, + message: LANG_ERRORS.KEY_NOT_FOUND(event.meta?.path ?? '') + }), + [LANG_DIAGNOSTIC_EVENTS.CIRCULAR_REFERENCE]: (event) => ({ + level: LogLevel.ERROR, + message: LANG_ERRORS.CIRCULAR_REFERENCE(event.meta?.path ?? '') + }), + [LANG_DIAGNOSTIC_EVENTS.MISSING_TRANSLATION]: (event) => ({ + level: LogLevel.WARN, + message: LANG_ERRORS.MISSING_TRANSLATION( + event.meta?.path ?? '', + event.meta?.locale ?? 'es', + event.meta?.usedLocale ?? 'es' + ) + }), + [LANG_DIAGNOSTIC_EVENTS.MISSING_TRANSLATION_RECORD]: (event) => ({ + level: LogLevel.WARN, + message: LANG_ERRORS.MISSING_TRANSLATION_RECORD( + event.meta?.locale ?? 'es', + event.meta?.usedLocale ?? 'es' + ) + }), + [LANG_DIAGNOSTIC_EVENTS.AMBIGUOUS_SIBLING_MATCH]: (event) => ({ + level: LogLevel.WARN, + message: LANG_ERRORS.AMBIGUOUS_SIBLING_MATCH( + event.meta?.locale ?? 'es', + event.meta?.usedLocale ?? 'es', + event.meta?.siblingCount ?? 0, + event.meta?.path + ) + }), + [LANG_DIAGNOSTIC_EVENTS.EXTEND_LEAF_OVERWRITE]: (event) => ({ + level: LogLevel.WARN, + message: LANG_ERRORS.EXTEND_LEAF_OVERWRITE(event.meta?.path ?? '') + }), + [LANG_DIAGNOSTIC_EVENTS.LOGGER_ALREADY_SET]: { + level: LogLevel.WARN, + message: LANG_ERRORS.LOGGER_ALREADY_SET + }, + [LANG_DIAGNOSTIC_EVENTS.REFERENCE_FALLBACK_USED]: (event) => ({ + level: LogLevel.WARN, + message: LANG_ERRORS.REFERENCE_FALLBACK_USED( + event.meta?.path ?? '', + event.meta?.fallback ?? '' + ) + }), + [LANG_DIAGNOSTIC_EVENTS.MONO_PATH_RETURNED]: (event) => ({ + level: LogLevel.WARN, + message: LANG_ERRORS.MONO_PATH_RETURNED( + event.meta?.path ?? '', + event.meta?.kind ?? 't' + ) + }) +}; + +export function createLangDiagnostics(logger?: Logger): LangDiagnostics { + return createCatalogDiagnostics({ + logger, + defaultCategory: LOGGER_CATEGORY, + catalog: LANG_DIAGNOSTIC_LOGS + }); +} + +export function emitLangDiagnostic( + diagnostics: LangDiagnostics, + type: LangDiagnosticType, + meta: LangDiagnosticMeta, + scope?: string +): void { + diagnostics.emit({ + artifact: LOGGER_CATEGORY, + type, + scope, + meta + }); +} diff --git a/src/arts/lang/engine-lang.ts b/src/arts/lang/engine-lang.ts index 190e41f..b2ec320 100644 --- a/src/arts/lang/engine-lang.ts +++ b/src/arts/lang/engine-lang.ts @@ -6,11 +6,11 @@ import type { LangRecord, LangString, EngineLang, - LangLogger, LangNode } from './types.ts'; import { LANG_ERRORS } from './errors.ts'; -import { ID_PREFIX, LOGGER_CATEGORY, MAX_RESOLVE_DEPTH } from './consts.ts'; +import { ID_PREFIX, LANG_DIAGNOSTIC_EVENTS, MAX_RESOLVE_DEPTH } from './consts.ts'; +import { createLangDiagnostics, emitLangDiagnostic } from './diagnostics.ts'; import { isLangRef, isLangRecord } from './guards.ts'; import { interpolateTemplate, @@ -22,25 +22,26 @@ import { resolveLocaleInRecord } from './helpers.ts'; import { isDev as DEV } from '$libs/env'; +import type { Logger } from '$libs/logr'; export function createEngineLang( schema: S, defaultLocale: SupportedLocale = 'es', fallbackChain?: SupportedLocale[], - initialLogger?: { logger: LangLogger; loggerSet: boolean } + initialLogger?: { logger: Logger; loggerSet: boolean } ): EngineLang { let currentSchema: LangNode = schema; - let logger: LangLogger = initialLogger?.logger ?? consoleLogger; + let diagnostics = createLangDiagnostics(initialLogger?.logger); let loggerSet: boolean = initialLogger?.loggerSet ?? false; const schemaListeners = new Set<() => void>(); - function setLogger(external: LangLogger): void { + function setLogger(external: Logger): void { if (loggerSet) { - if (DEV) console.warn(LANG_ERRORS.LOGGER_ALREADY_SET); + if (DEV) emitLangDiagnostic(diagnostics, LANG_DIAGNOSTIC_EVENTS.LOGGER_ALREADY_SET, {}); return; } - logger = external; + diagnostics = createLangDiagnostics(external); loggerSet = true; } @@ -86,15 +87,20 @@ export function createEngineLang( if (DEV && usedLocale !== undefined && usedLocale !== locale) { if (ambiguousSiblingCount !== undefined) { - logger.warn( - LOGGER_CATEGORY, - LANG_ERRORS.AMBIGUOUS_SIBLING_MATCH(locale, usedLocale, ambiguousSiblingCount, path) - ); + emitLangDiagnostic(diagnostics, LANG_DIAGNOSTIC_EVENTS.AMBIGUOUS_SIBLING_MATCH, { + locale, + usedLocale, + siblingCount: ambiguousSiblingCount, + path + }); } else { - const msg = path - ? LANG_ERRORS.MISSING_TRANSLATION(path, locale, usedLocale) - : LANG_ERRORS.MISSING_TRANSLATION_RECORD(locale, usedLocale); - logger.warn(LOGGER_CATEGORY, msg); + emitLangDiagnostic( + diagnostics, + path + ? LANG_DIAGNOSTIC_EVENTS.MISSING_TRANSLATION + : LANG_DIAGNOSTIC_EVENTS.MISSING_TRANSLATION_RECORD, + { path, locale, usedLocale } + ); } } @@ -113,8 +119,9 @@ export function createEngineLang( if (value === undefined) return undefined; if (depth > MAX_RESOLVE_DEPTH) { - logger.error(LOGGER_CATEGORY, LANG_ERRORS.CIRCULAR_REFERENCE(String(value))); - throw new Error('Circular reference in lang'); + const path = String(value); + emitLangDiagnostic(diagnostics, LANG_DIAGNOSTIC_EVENTS.CIRCULAR_REFERENCE, { path }); + throw new Error(LANG_ERRORS.CIRCULAR_REFERENCE(path)); } if (isLangRef(value)) { @@ -122,8 +129,8 @@ export function createEngineLang( const path = parsed?.path ?? value.substring(ID_PREFIX.length); const seen = visited ?? new Set(); if (seen.has(path)) { - logger.error(LOGGER_CATEGORY, LANG_ERRORS.CIRCULAR_REFERENCE(path)); - throw new Error('Circular reference in lang'); + emitLangDiagnostic(diagnostics, LANG_DIAGNOSTIC_EVENTS.CIRCULAR_REFERENCE, { path }); + throw new Error(LANG_ERRORS.CIRCULAR_REFERENCE(path)); } seen.add(path); const resolved = resolvePath(currentSchema, path); @@ -131,9 +138,10 @@ export function createEngineLang( if (resolved === undefined) { if (parsed?.fallback !== undefined) { if (DEV) - logger.warn( - LOGGER_CATEGORY, - `${LANG_ERRORS.KEY_NOT_FOUND(path)}. Using fallback "${parsed.fallback}".` + emitLangDiagnostic( + diagnostics, + LANG_DIAGNOSTIC_EVENTS.REFERENCE_FALLBACK_USED, + { path, fallback: parsed.fallback } ); return parsed.fallback; } @@ -165,7 +173,10 @@ export function createEngineLang( if (pathFallback !== undefined) { return interpolateTemplate(pathFallback, params); } - if (DEV) logger.error(LOGGER_CATEGORY, LANG_ERRORS.KEY_NOT_FOUND(cleanPath)); + if (DEV) + emitLangDiagnostic(diagnostics, LANG_DIAGNOSTIC_EVENTS.KEY_NOT_FOUND, { + path: cleanPath + }); return cleanPath; } @@ -186,7 +197,10 @@ export function createEngineLang( } function warnLeafOverwrite(path: string): void { - if (DEV) logger.warn(LOGGER_CATEGORY, LANG_ERRORS.EXTEND_LEAF_OVERWRITE(path)); + if (DEV) + emitLangDiagnostic(diagnostics, LANG_DIAGNOSTIC_EVENTS.EXTEND_LEAF_OVERWRITE, { + path + }); } function extend(namespace: string, module: LangNode): void { @@ -221,7 +235,10 @@ export function createEngineLang( [namespace]: module } as S & { [K in NS]: M }; - return createEngineLang(newSchema, defaultLocale, fallbackChain, { logger, loggerSet }); + return createEngineLang(newSchema, defaultLocale, fallbackChain, { + logger: diagnostics.logger, + loggerSet + }); } function onSchemaChange(fn: () => void): () => void { @@ -248,8 +265,3 @@ export function createEngineLang( getFallbackChain }; } - -const consoleLogger: LangLogger = { - warn: (_category: string, message: string) => DEV && console.warn(message), - error: (_category: string, message: string) => DEV && console.error(message) -}; diff --git a/src/arts/lang/errors.ts b/src/arts/lang/errors.ts index d04c820..6c28e40 100644 --- a/src/arts/lang/errors.ts +++ b/src/arts/lang/errors.ts @@ -22,5 +22,19 @@ export const LANG_ERRORS = { EXTEND_LEAF_OVERWRITE: (path: string): string => `[lang] extend(): leaf at "${path}" was overwritten silently. Last write wins — register namespaces uniquely or check for collisions before extending.`, - LOGGER_ALREADY_SET: '[lang] Logger already set. setLogger() can only be called once.' + LOGGER_ALREADY_SET: '[lang] Logger already set. setLogger() can only be called once.', + + EXTEND_NAMESPACE_COLLIDES_WITH_LEAF: (path: string): string => + `[lang] Cannot extend namespace "${path}" because it already contains a translation leaf.`, + + LANG_NODE_TO_JSON_UNSAFE_DYNAMIC_FN: + '[lang] langNodeToJSON: dynamic LangFn cannot be serialized safely. Use p() for plurals or a static template function.', + + JSON_TO_LANG_NODE_INVALID_SHAPE: '[lang] JSONToLangNode: invalid JSON shape for LangNode', + + REFERENCE_FALLBACK_USED: (path: string, fallback: string): string => + `${LANG_ERRORS.KEY_NOT_FOUND(path)}. Using fallback "${fallback}".`, + + MONO_PATH_RETURNED: (path: string, kind: 't' | 'ts'): string => + `Mono lang: ${kind}("${path}") returned the path verbatim. Configure a real lang schema or use the |fallback suffix to provide literal text.` } as const; diff --git a/src/arts/lang/helpers.ts b/src/arts/lang/helpers.ts index 3349f9d..0afba26 100644 --- a/src/arts/lang/helpers.ts +++ b/src/arts/lang/helpers.ts @@ -7,6 +7,7 @@ import type { SupportedLocale } from './types.ts'; import { ID_FALLBACK_SEPARATOR, ID_PREFIX } from './consts.ts'; +import { LANG_ERRORS } from './errors.ts'; import { isLangRecord, isLangString } from './guards.ts'; export function resolvePath(obj: LangNode | undefined, path: string): LangNode | undefined { @@ -149,9 +150,7 @@ export function deepMerge( if (isLangBranch(tgtVal)) { result[key] = deepMerge(tgtVal, srcVal, nextPath, onLeafOverwrite); } else if (tgtVal !== undefined) { - throw new TypeError( - `[lang] Cannot extend namespace "${nextPath}" because it already contains a translation leaf.` - ); + throw new TypeError(LANG_ERRORS.EXTEND_NAMESPACE_COLLIDES_WITH_LEAF(nextPath)); } else { result[key] = srcVal; } diff --git a/src/arts/lang/index.ts b/src/arts/lang/index.ts index 4757053..a1ca177 100644 --- a/src/arts/lang/index.ts +++ b/src/arts/lang/index.ts @@ -4,7 +4,15 @@ // `createActiveMonoLang`) live in their own `.svelte.ts` files and must be // imported from those paths directly so this barrel stays runes-free. -export { ID_PREFIX, ID_FALLBACK_SEPARATOR, MAX_RESOLVE_DEPTH, LOGGER_CATEGORY } from './consts.ts'; +export { + ID_PREFIX, + ID_FALLBACK_SEPARATOR, + LANG_DIAGNOSTIC_EVENTS, + LOGGER_CATEGORY, + MAX_RESOLVE_DEPTH, + MONO_LANG_CATEGORY +} from './consts.ts'; +export { createLangDiagnostics, emitLangDiagnostic } from './diagnostics.ts'; export { isLangRef, isLangRecord, isLangString } from './guards.ts'; @@ -27,9 +35,14 @@ export type { PluralForms, PluralConfig, EngineLang, - ActiveLang, - LangLogger + ActiveLang } from './types.ts'; +export type { + LangDiagnosticEvent, + LangDiagnosticMeta, + LangDiagnostics, + LangDiagnosticType +} from './diagnostics.ts'; export { pluralRule } from './plural_rules.ts'; export type { PluralCategory } from './plural_rules.ts'; diff --git a/src/arts/lang/json.ts b/src/arts/lang/json.ts index f030dca..443c71c 100644 --- a/src/arts/lang/json.ts +++ b/src/arts/lang/json.ts @@ -1,5 +1,6 @@ import type { LangBranch, LangFn, LangNode, LangParams, PluralConfig } from './types.ts'; import { p, getPluralConfig } from './plural.ts'; +import { LANG_ERRORS } from './errors.ts'; export type LangJSONValue = string | number | boolean | null | LangJSONObject | LangJSONValue[]; @@ -60,9 +61,7 @@ function serializeTemplateFunction(fn: LangFn): LangJSONValue { JSON.stringify(first) !== JSON.stringify(second) || JSON.stringify(first) !== JSON.stringify(json) ) { - throw new TypeError( - '[lang] langNodeToJSON: dynamic LangFn cannot be serialized safely. Use p() for plurals or a static template function.' - ); + throw new TypeError(LANG_ERRORS.LANG_NODE_TO_JSON_UNSAFE_DYNAMIC_FN); } return json; @@ -104,7 +103,7 @@ export function JSONToLangNode(json: LangJSONValue): LangNode { if (typeof json === 'string') return json as LangNode; if (json === null || typeof json !== 'object' || Array.isArray(json)) { - throw new TypeError('[lang] JSONToLangNode: invalid JSON shape for LangNode'); + throw new TypeError(LANG_ERRORS.JSON_TO_LANG_NODE_INVALID_SHAPE); } if (isPluralJSON(json)) { diff --git a/src/arts/lang/mono-lang.svelte.ts b/src/arts/lang/mono-lang.svelte.ts index 9ba6de9..9c1ce8f 100644 --- a/src/arts/lang/mono-lang.svelte.ts +++ b/src/arts/lang/mono-lang.svelte.ts @@ -2,14 +2,18 @@ import { SvelteSet } from 'svelte/reactivity'; import { interpolateTemplate, parseLangRef, parsePathFallback } from './helpers.ts'; import { isLangRecord, isLangRef } from './guards.ts'; import { isDev as DEV } from '$libs/env'; +import { LANG_DIAGNOSTIC_EVENTS, MONO_LANG_CATEGORY } from './consts.ts'; +import { createLangDiagnostics, emitLangDiagnostic } from './diagnostics.ts'; import type { ActiveLang, - LangLogger, LangNode, LangParams, LangString, SupportedLocale } from './types.ts'; +import type { Logger } from '$libs/logr'; + +export { MONO_LANG_CATEGORY } from './consts.ts'; /** * Default initial locale for `createActiveMonoLang()`. The mono lang does not @@ -19,25 +23,6 @@ import type { */ export const DEFAULT_MONO_LOCALE: SupportedLocale = 'en'; -/** - * Logger category used for the deduplicated DEV warnings emitted by mono lang - * when `t()` / `ts()` returns a path verbatim. - */ -export const MONO_LANG_CATEGORY = 'lang.mono'; - -/** - * Minimal interface a mono lang needs for its DEV warnings. Structurally - * compatible with `EngineLogger` and `LangLogger`, so callers can pass either - * without an adapter. - */ -export interface MonoLangLogger { - warn: ( - category: string, - message: string, - input?: { context?: Record } - ) => void; -} - /** * Single-language `ActiveLang` for monolingual apps. * @@ -65,12 +50,13 @@ export interface MonoLangLogger { */ export interface MonoLangOptions { initialLocale?: SupportedLocale; - logger?: MonoLangLogger; + logger?: Logger; } export function createActiveMonoLang(options: MonoLangOptions = {}): ActiveLang { const initialLocale = options.initialLocale ?? DEFAULT_MONO_LOCALE; - let logger = options.logger; + let diagnostics = createLangDiagnostics(options.logger); + let loggerSet = options.logger !== undefined; let locale = $state(initialLocale); const localeListeners = new SvelteSet<(l: SupportedLocale) => void>(); @@ -89,13 +75,14 @@ export function createActiveMonoLang(options: MonoLangOptions = {}): ActiveLang< } function warnOnce(path: string, kind: 't' | 'ts'): void { - if (!DEV || logger === undefined) return; + if (!DEV || !loggerSet) return; if (warnedPaths.has(path)) return; warnedPaths.add(path); - logger.warn( - MONO_LANG_CATEGORY, - `Mono lang: ${kind}("${path}") returned the path verbatim. Configure a real lang schema or use the |fallback suffix to provide literal text.`, - { context: { path, kind } } + emitLangDiagnostic( + diagnostics, + LANG_DIAGNOSTIC_EVENTS.MONO_PATH_RETURNED, + { path, kind }, + MONO_LANG_CATEGORY ); } @@ -137,13 +124,15 @@ export function createActiveMonoLang(options: MonoLangOptions = {}): ActiveLang< onSchemaChange: () => noop, extend: noop, register: (_ns: NS, _module: M) => - createActiveMonoLang({ initialLocale: locale, logger }) as unknown as ActiveLang< - LangNode & { [K in NS]: M } - >, - setLogger: (next: LangLogger) => { + createActiveMonoLang({ + initialLocale: locale, + logger: loggerSet ? diagnostics.logger : undefined + }) as unknown as ActiveLang, + setLogger: (next: Logger) => { // Mirror the real engine's set-once contract: the first logger wins. - if (logger !== undefined) return; - logger = next; + if (loggerSet) return; + diagnostics = createLangDiagnostics(next); + loggerSet = true; }, getDefaultLocale: () => locale, getFallbackChain: () => undefined, diff --git a/src/arts/lang/test/lang.test.ts b/src/arts/lang/test/lang.test.ts index aaab4f4..337fa59 100644 --- a/src/arts/lang/test/lang.test.ts +++ b/src/arts/lang/test/lang.test.ts @@ -29,6 +29,19 @@ import { p, getPluralConfig } from '../plural.ts'; import { createEngineLang } from '../engine-lang.ts'; import { langNodeToJSON, JSONToLangNode } from '../json.ts'; import type { LangNode, LangBranch } from '../types.ts'; +import type { Logger } from '$libs/logr'; + +function createTestLogger(overrides: Partial = {}): Logger { + return { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + ...overrides + }; +} // ============================================================================ // GUARDS @@ -612,7 +625,7 @@ describe('createEngineLang — extend()', () => { it('warns in DEV when a leaf is overwritten silently inside a deep merge', () => { const lang = createEngineLang(schema, 'es'); const warn = vi.fn(); - lang.setLogger({ warn, error: vi.fn() }); + lang.setLogger(createTestLogger({ warn })); // 'common.ok' already exists as a leaf; overwriting it with extend() // is a silent change that "last write wins" — surface it. @@ -630,7 +643,7 @@ describe('createEngineLang — extend()', () => { } as const satisfies LangNode; const lang = createEngineLang(minimal, 'es'); const warn = vi.fn(); - lang.setLogger({ warn, error: vi.fn() }); + lang.setLogger(createTestLogger({ warn })); lang.extend('banner', { es: 'Otro', en: 'Other' }); @@ -641,7 +654,7 @@ describe('createEngineLang — extend()', () => { it('does not warn when extend() adds a brand-new key', () => { const lang = createEngineLang(schema, 'es'); const warn = vi.fn(); - lang.setLogger({ warn, error: vi.fn() }); + lang.setLogger(createTestLogger({ warn })); lang.extend('shop', { product: { es: 'Producto' } }); @@ -651,7 +664,7 @@ describe('createEngineLang — extend()', () => { it('does not warn when extend() merges branches without leaf collisions', () => { const lang = createEngineLang(schema, 'es'); const warn = vi.fn(); - lang.setLogger({ warn, error: vi.fn() }); + lang.setLogger(createTestLogger({ warn })); // Adds a new sibling next to existing 'common.ok' and 'common.cancel'. lang.extend('common', { extra: { es: 'Extra', en: 'Extra' } }); @@ -722,11 +735,11 @@ describe('createEngineLang — register()', () => { const lang = createEngineLang(circularSchema, 'es'); const warn = vi.fn(); const error = vi.fn(); - lang.setLogger({ warn, error }); + lang.setLogger(createTestLogger({ warn, error })); const child = lang.register('shop', { product: { es: 'Producto' } }); const childT = child.t as (p: string, params: undefined, locale: 'es') => string; - expect(() => childT('a', undefined, 'es')).toThrow('Circular reference in lang'); + expect(() => childT('a', undefined, 'es')).toThrow(/Circular reference/); expect(error).toHaveBeenCalled(); }); }); @@ -736,7 +749,7 @@ describe('createEngineLang — setLogger()', () => { const lang = createEngineLang(schema, 'es'); const warn = vi.fn(); const error = vi.fn(); - lang.setLogger({ warn, error }); + lang.setLogger(createTestLogger({ warn, error })); expect(lang.getDefaultLocale()).toBe('es'); }); @@ -744,8 +757,8 @@ describe('createEngineLang — setLogger()', () => { const lang = createEngineLang(schema, 'es'); const warn1 = vi.fn(); const warn2 = vi.fn(); - lang.setLogger({ warn: warn1, error: vi.fn() }); - lang.setLogger({ warn: warn2, error: vi.fn() }); + lang.setLogger(createTestLogger({ warn: warn1 })); + lang.setLogger(createTestLogger({ warn: warn2 })); expect(lang.getDefaultLocale()).toBe('es'); }); }); @@ -808,7 +821,7 @@ describe('createEngineLang — BCP 47 locales', () => { it('bare locale with multiple siblings emits the ambiguous warning in DEV', () => { const lang = createEngineLang(onlySiblings, 'es-MX'); const warn = vi.fn(); - lang.setLogger({ warn, error: vi.fn() }); + lang.setLogger(createTestLogger({ warn })); const result = lang.t('greeting', undefined, 'es'); expect(result).toBe('Qué onda'); expect(warn).toHaveBeenCalledOnce(); @@ -820,7 +833,7 @@ describe('createEngineLang — BCP 47 locales', () => { const single = { greeting: { 'es-MX': 'Qué onda' } } as const satisfies LangNode; const lang = createEngineLang(single, 'es-MX'); const warn = vi.fn(); - lang.setLogger({ warn, error: vi.fn() }); + lang.setLogger(createTestLogger({ warn })); lang.t('greeting', undefined, 'es'); // MISSING_TRANSLATION fires (used 'es-MX' instead of 'es') but never the // ambiguous-sibling warning. diff --git a/src/arts/lang/types.ts b/src/arts/lang/types.ts index 18e353f..98d5e3f 100644 --- a/src/arts/lang/types.ts +++ b/src/arts/lang/types.ts @@ -1,5 +1,6 @@ import type { ID_PREFIX } from './consts.ts'; import type { PluralCategory } from './plural_rules.ts'; +import type { Logger } from '$libs/logr'; /** * Closed set of base languages supported by the engine. Region tags are not @@ -105,7 +106,7 @@ export type EngineLang = { namespace: NS, module: M ) => EngineLang; - setLogger: (logger: LangLogger) => void; + setLogger: (logger: Logger) => void; getDefaultLocale: () => SupportedLocale; getFallbackChain: () => SupportedLocale[] | undefined; }; @@ -131,13 +132,8 @@ export type ActiveLang = { namespace: NS, module: M ) => ActiveLang; - setLogger: (logger: LangLogger) => void; + setLogger: (logger: Logger) => void; getDefaultLocale: () => SupportedLocale; getFallbackChain: () => SupportedLocale[] | undefined; dispose: () => void; }; - -export interface LangLogger { - warn: (category: string, message: string) => void; - error: (category: string, message: string) => void; -} diff --git a/src/arts/logr/adapters/loki.ts b/src/arts/logr/adapters/loki.ts index 9440ca4..c7c290a 100644 --- a/src/arts/logr/adapters/loki.ts +++ b/src/arts/logr/adapters/loki.ts @@ -1,6 +1,6 @@ import type { LevelsConfig, LogEntry, Transport } from '../types.ts'; import { LogLevel } from '../types.ts'; -import { LEVEL_LABELS, levelsAtLeast } from '../consts.ts'; +import { LEVEL_LABELS, LOGR_ERRORS, levelsAtLeast } from '../consts.ts'; export interface LokiTransportOptions { /** Absolute URL of the push endpoint. Typically `https:///loki/api/v1/push`. */ @@ -89,7 +89,7 @@ export function lokiTransport(options: LokiTransportOptions): Transport { }); if (!res.ok) { const body = await res.text().catch(() => ''); - throw new Error(`Loki push failed: ${res.status} ${res.statusText} ${body}`); + throw new Error(LOGR_ERRORS.LOKI_PUSH_FAILED(res.status, res.statusText, body)); } } @@ -133,4 +133,3 @@ export function lokiTransport(options: LokiTransportOptions): Transport { } }; } - diff --git a/src/arts/logr/consts.ts b/src/arts/logr/consts.ts index 33c9af6..cd4be89 100644 --- a/src/arts/logr/consts.ts +++ b/src/arts/logr/consts.ts @@ -94,3 +94,10 @@ export const LEVEL_CONSOLE_METHOD: Readonly< [LogLevel.ERROR]: 'error', [LogLevel.FATAL]: 'error' }; + +export const LOGR_ERRORS = { + HTTP_TRANSPORT_PUSH_FAILED: (status: number, statusText: string, body: string): string => + `http transport push failed: ${status} ${statusText} ${body}`, + LOKI_PUSH_FAILED: (status: number, statusText: string, body: string): string => + `Loki push failed: ${status} ${statusText} ${body}` +} as const; diff --git a/src/arts/logr/engine-logger.ts b/src/arts/logr/engine-logger.ts index de8eef7..8f26b0e 100644 --- a/src/arts/logr/engine-logger.ts +++ b/src/arts/logr/engine-logger.ts @@ -374,7 +374,24 @@ function buildLogger( handleFailure(transport, err, entries, context); } } else { - for (const item of buf) writeOne(transport, item.entry, item.context); + void flushBufferedEntriesWithoutBatch(transport, buf, entries, context); + } + } + + async function flushBufferedEntriesWithoutBatch( + transport: Transport, + buf: BufferedLogEntry[], + entries: readonly LogEntry[], + context: DispatchContext + ): Promise { + for (const item of buf) { + try { + const ret = transport.write(item.entry); + if (ret && typeof ret.then === 'function') await ret; + } catch (err) { + handleFailure(transport, err, entries, context); + return; + } } } @@ -438,9 +455,7 @@ function buildLogger( category: ENGINE_NAME, message, context: - suppressedCount > 0 - ? { ...(baseContext ?? {}), suppressedCount, throttleMs } - : baseContext, + suppressedCount > 0 ? { ...(baseContext ?? {}), suppressedCount, throttleMs } : baseContext, error: errorObj, tags: [ENGINE_NAME, 'transport-failure'], deniedFor: [...context.deniedFor] diff --git a/src/arts/logr/index.ts b/src/arts/logr/index.ts index 7bee1cb..2f725fa 100644 --- a/src/arts/logr/index.ts +++ b/src/arts/logr/index.ts @@ -20,6 +20,8 @@ export { consoleTransport, httpTransport, callbackTransport } from './transports export { LogLevel } from './types.ts'; export type { MessageCategory, + Logger, + LogFn, LogMessage, LogError, LogSource, @@ -32,6 +34,5 @@ export type { ConsoleTransportOptions, HttpTransportOptions, LoggerOptions, - LogFn, EngineLogger } from './types.ts'; diff --git a/src/arts/logr/test/logr.test.ts b/src/arts/logr/test/logr.test.ts index cac1ae2..54cdec2 100644 --- a/src/arts/logr/test/logr.test.ts +++ b/src/arts/logr/test/logr.test.ts @@ -823,6 +823,33 @@ describe('createEngineLogger — buffering', () => { expect(cascaded).toBeDefined(); expect(seen.filter((e) => e.deniedFor?.includes('buffered-bad'))).toHaveLength(1); }); + + it('reports a single failure when a buffered transport without writeBatch fails', async () => { + const seen: LogEntry[] = []; + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport({ + name: 'buffered-bad', + buffer: 3, + write() { + throw new Error('buffered boom'); + } + }); + log.addTransport({ + name: 'capture', + write: (entry) => { + seen.push(entry); + } + }); + + log.info('t', 'a'); + log.info('t', 'b'); + log.info('t', 'c'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const failures = seen.filter((entry) => entry.deniedFor?.includes('buffered-bad')); + expect(failures).toHaveLength(1); + expect(failures[0].error?.message).toContain('buffered boom'); + }); }); describe('createEngineLogger — serialize()', () => { diff --git a/src/arts/logr/transports.ts b/src/arts/logr/transports.ts index 8393b0f..7fc04e4 100644 --- a/src/arts/logr/transports.ts +++ b/src/arts/logr/transports.ts @@ -6,7 +6,7 @@ import type { } from './types.ts'; import { LogLevel } from './types.ts'; -import { LEVEL_CONSOLE_METHOD, levelsAtLeast } from './consts.ts'; +import { LEVEL_CONSOLE_METHOD, LOGR_ERRORS, levelsAtLeast } from './consts.ts'; // ============================================================================ // CONSOLE TRANSPORT @@ -104,7 +104,7 @@ export function httpTransport(options: HttpTransportOptions): Transport { }); if (!res.ok) { const body = await res.text().catch(() => ''); - throw new Error(`http transport push failed: ${res.status} ${res.statusText} ${body}`); + throw new Error(LOGR_ERRORS.HTTP_TRANSPORT_PUSH_FAILED(res.status, res.statusText, body)); } } diff --git a/src/arts/logr/types.ts b/src/arts/logr/types.ts index 17517fc..b8d1b50 100644 --- a/src/arts/logr/types.ts +++ b/src/arts/logr/types.ts @@ -1,30 +1,12 @@ +import { LogLevel } from '$libs/logr'; +import type { Logger, LogFn, LogInput, LogMessage, LogSource } from '$libs/logr'; + // ============================================================================ // LOG LEVEL // ============================================================================ -/** - * Severity levels, ordered low-to-high. A logger configured with level X - * processes only entries whose level is >= X. - * - * Aligned with pino, Log4j, OpenTelemetry and Sentry's `Severity` enum. - * - * - **TRACE** — very verbose tracing (spans, frame-by-frame). - * - **DEBUG** — development diagnostics. - * - **INFO** — normal flow events worth noting. - * - **WARN** — unexpected but non-interrupting situations. - * - **ERROR** — recoverable errors that need attention. - * - **FATAL** — unrecoverable failures (route to immediate paging). - */ -export enum LogLevel { - TRACE = 0, - DEBUG = 1, - INFO = 2, - WARN = 3, - ERROR = 4, - FATAL = 5, - /** Disables every log. Deliberately high so any future level stays below. */ - NONE = 999 -} +export { LogLevel }; +export type { Logger, LogFn, LogInput, LogMessage, LogSource }; // ============================================================================ // CORE TYPES @@ -36,17 +18,6 @@ export enum LogLevel { */ export type MessageCategory = string; -/** - * Log message. Accepts a literal string or a thunk — the thunk is only - * invoked if the entry passes the global level filter, avoiding formatting - * cost for dropped logs. - * - * @example - * logger.info('auth', 'Login succeeded'); - * logger.debug('db', () => `Query took ${performance.now() - t0}ms`); - */ -export type LogMessage = string | (() => string); - /** * Serializable shape of a captured error. */ @@ -57,36 +28,6 @@ export interface LogError { cause?: unknown; } -/** - * Source location where the log was emitted. Parsed from the native `Error` - * stack. In minified production the names are mangled — disable - * `captureSource` in that case. - */ -export interface LogSource { - file?: string; - line?: number; - column?: number; - function?: string; -} - -/** - * Optional caller-provided data for a single log call. - */ -export interface LogInput { - /** Structured diagnostic context. Merged with `globalContext` on the entry. */ - context?: Record; - /** Captured error. Extracted to `entry.error` as a structured `LogError`. */ - error?: unknown; - /** Free-form tags for filtering — finer than `category`. */ - tags?: string[]; - /** Correlation id to trace async flows (fetch → handler → service). */ - traceId?: string; - /** Duration in ms — auto-populated by `timeEnd()`. */ - durationMs?: number; - /** Manual `source` override — useful for wrappers that re-emit logs. */ - source?: LogSource; -} - /** * A single history entry. `message` is already resolved to a string — the * logger knows nothing about i18n. For translated strings, resolve at the @@ -320,25 +261,12 @@ export interface LoggerOptions { // INSTANCE TYPE // ============================================================================ -export type LogFn = ( - category: MessageCategory, - message: LogMessage, - input?: LogInput -) => void; - /** * Public `EngineLogger` interface. * Children created via `child()` share history, transports and level with * the parent — they only extend the `globalContext`. */ -export interface EngineLogger { - trace: LogFn; - debug: LogFn; - info: LogFn; - warn: LogFn; - error: LogFn; - fatal: LogFn; - +export interface EngineLogger extends Logger { /** Defensive copy of the history, optionally filtered. */ getLogs: (filters?: LogFilters) => LogEntry[]; /** Empty the history. Shared with children. */ diff --git a/src/arts/perm/Can.svelte b/src/arts/perm/Can.svelte index c501a23..3141998 100644 --- a/src/arts/perm/Can.svelte +++ b/src/arts/perm/Can.svelte @@ -28,6 +28,8 @@ let pending = $state(true); $effect(() => { + permissions.currentSnapshot.version; + permissions.currentSnapshot.actor; let cancelled = false; pending = true; if (!optimistic) allowed = false; diff --git a/src/arts/perm/active-permissions.svelte.ts b/src/arts/perm/active-permissions.svelte.ts index bf4398e..357cbbc 100644 --- a/src/arts/perm/active-permissions.svelte.ts +++ b/src/arts/perm/active-permissions.svelte.ts @@ -6,6 +6,8 @@ import { PERMISSION_ERROR_MSG_CLIENT_ENDPOINT_REQUIRED, PERMISSION_METHOD_CAN, PERMISSION_METHOD_CHECK, + PERMISSION_METHOD_CLEAR_ERROR, + PERMISSION_METHOD_DECISION_KEY, PERMISSION_METHOD_EXPLAIN, PERMISSION_METHOD_SUBSCRIBE, PERMISSION_METHOD_WHAT @@ -108,11 +110,11 @@ export function createActivePermissions(options: ActivePermissionsOptions): Acti return client.subscribe(listener); }, decisionKey: (input) => { - ensureLive(PERMISSION_METHOD_CHECK); + ensureLive(PERMISSION_METHOD_DECISION_KEY); return client.decisionKey(input); }, clearError() { - ensureLive(PERMISSION_METHOD_CHECK); + ensureLive(PERMISSION_METHOD_CLEAR_ERROR); lastErrorCell = null; }, dispose() { diff --git a/src/arts/perm/client.ts b/src/arts/perm/client.ts index 6c7b9f7..44d2c84 100644 --- a/src/arts/perm/client.ts +++ b/src/arts/perm/client.ts @@ -8,10 +8,10 @@ import { import type { StandardSchemaV1 } from '$libs/standard-schema'; import { HTTP_CONTENT_TYPE_JSON, HTTP_HEADER_CONTENT_TYPE, HTTP_METHOD_POST } from '$libs/http'; import { - LOGGER_CATEGORY, PERMISSION_ACTIVE_EVENT_BATCH, PERMISSION_ACTIVE_EVENT_HYDRATE, PERMISSION_ACTIVE_EVENT_INVALIDATE, + PERMISSION_CLIENT_DIAGNOSTIC_EVENTS, PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS, PERMISSION_CLIENT_DEFAULT_NON_ALLOW_CACHE_TTL_MS, PERMISSION_CLIENT_DEFAULT_REMOTE_FAILURE_BACKOFF_MS, @@ -26,9 +26,7 @@ import { PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX, PERMISSION_ERROR_MSG_RESPONSE_NOT_JSON_PREFIX, PERMISSION_HTTP_CREDENTIALS_INCLUDE, - PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED, - PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED, - PERMISSION_LOG_MSG_REMOTE_WHAT_FAILED, + PERMISSION_METHOD_DECISION_KEY, PERMISSION_METHOD_CHECK, PERMISSION_METHOD_CAN, PERMISSION_METHOD_EXPLAIN, @@ -42,6 +40,10 @@ import { PERMISSION_RESPONSE_FIELD_DECISIONS, PERMISSION_SNAPSHOT_GLOBAL_POLICY } from './consts.ts'; +import { + createPermissionClientDiagnostics, + emitPermissionClientDiagnostic +} from './diagnostics.ts'; import { PermDisposedError, PermRemoteRequestError } from './errors.ts'; import { disposedPermissionsMessage } from './helpers.ts'; import { permissionDecisionKey, stablePermissionStringify } from '$libs/svrs/perm'; @@ -135,6 +137,7 @@ async function readPermissionJson(response: Response, url: string): Promise>(); const listeners = new Set<(snapshot: PermissionSnapshot) => void>(); let currentSnapshot: PermissionSnapshot = options.initialSnapshot ?? { decisions: {} }; + let generation = 0; let disposed = false; function now(): number { @@ -171,15 +175,21 @@ export function createPermissionClient(options: PermissionClientOptions): Permis return stablePermissionStringify(currentSnapshot.actor); } - function decisionKey(input: PermissionClientCheckInput): string { + function decisionKeyForScope( + input: PermissionClientCheckInput, + scope: string | undefined + ): string { const base = remoteDecisionKey(input); - const scope = resolveScopeKey(); if (scope === undefined) return base; return [PERMISSION_CLIENT_SCOPE_PREFIX, stablePermissionStringify(scope), base].join( PERMISSION_CLIENT_KEY_SEPARATOR ); } + function decisionKey(input: PermissionClientCheckInput): string { + return decisionKeyForScope(input, resolveScopeKey()); + } + function snapshotStillValid(snapshot: PermissionSnapshot): boolean { return snapshot.expiresAt === undefined || Date.parse(snapshot.expiresAt) > now(); } @@ -204,8 +214,12 @@ export function createPermissionClient(options: PermissionClientOptions): Permis return global; } - function setCached(input: PermissionClientCheckInput, decision: PermissionDecision): void { - const key = decisionKey(input); + function setCached( + key: string, + decision: PermissionDecision, + requestGeneration = generation + ): void { + if (requestGeneration !== generation) return; const ttl = resolveDecisionTtl(decision); failures.delete(key); if (ttl <= 0) return; @@ -238,6 +252,7 @@ export function createPermissionClient(options: PermissionClientOptions): Permis async function check(input: PermissionClientCheckInput): Promise { ensureLive(PERMISSION_METHOD_CHECK); const key = decisionKey(input); + const requestGeneration = generation; const cached = cache.get(key); if (cached && cached.expiresAt > now()) return cached.decision; const failed = failures.get(key); @@ -259,17 +274,18 @@ export function createPermissionClient(options: PermissionClientOptions): Permis [PERMISSION_REQUEST_FIELD_CONTEXT]: input.context }) .then((decision) => { - setCached(input, decision); + setCached(key, decision, requestGeneration); return decision; }) .catch((error) => { options.onError?.(error); - options.logger?.error?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED, { - error, - context: { input } - }); + emitPermissionClientDiagnostic( + diagnostics, + PERMISSION_CLIENT_DIAGNOSTIC_EVENTS.REMOTE_CHECK_FAILED, + { error, input } + ); const fallback = fallbackDecision(PERMISSION_DECISION_REASON_REMOTE_CHECK_FAILED, error); - if (remoteFailureBackoffMs > 0) { + if (requestGeneration === generation && remoteFailureBackoffMs > 0) { failures.set(key, { decision: fallback, expiresAt: now() + remoteFailureBackoffMs }); } return fallback; @@ -285,6 +301,11 @@ export function createPermissionClient(options: PermissionClientOptions): Permis input: PermissionClientBatchInput ): Promise> { ensureLive(PERMISSION_ACTIVE_EVENT_BATCH); + const requestGeneration = generation; + const checks = input.checks.map((item) => ({ + remoteKey: remoteDecisionKey(item), + localKey: decisionKey(item) + })); try { const result = await postJson<{ decisions: Record }>( options, @@ -292,29 +313,27 @@ export function createPermissionClient(options: PermissionClientOptions): Permis { [PERMISSION_REQUEST_FIELD_CHECKS]: input.checks } ); const decisions: Record = {}; - for (const item of input.checks) { - const key = remoteDecisionKey(item); - const localKey = decisionKey(item); - const decision = result[PERMISSION_RESPONSE_FIELD_DECISIONS][key]; + for (const { remoteKey, localKey } of checks) { + const decision = result[PERMISSION_RESPONSE_FIELD_DECISIONS][remoteKey]; if (decision) { - setCached(item, decision); + setCached(localKey, decision, requestGeneration); decisions[localKey] = decision; } } return decisions; } catch (error) { options.onError?.(error); - options.logger?.error?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED, { - error, - context: { input } - }); + emitPermissionClientDiagnostic( + diagnostics, + PERMISSION_CLIENT_DIAGNOSTIC_EVENTS.REMOTE_BATCH_FAILED, + { error, input } + ); const decisions: Record = {}; - for (const item of input.checks) { - const key = decisionKey(item); + for (const { localKey } of checks) { const fallback = fallbackDecision(PERMISSION_DECISION_REASON_REMOTE_BATCH_FAILED, error); - decisions[key] = fallback; - if (remoteFailureBackoffMs > 0) { - failures.set(key, { decision: fallback, expiresAt: now() + remoteFailureBackoffMs }); + decisions[localKey] = fallback; + if (requestGeneration === generation && remoteFailureBackoffMs > 0) { + failures.set(localKey, { decision: fallback, expiresAt: now() + remoteFailureBackoffMs }); } } return decisions; @@ -327,6 +346,8 @@ export function createPermissionClient(options: PermissionClientOptions): Permis readonly context?: PermissionClientCheckInput['context']; }): Promise> { ensureLive(PERMISSION_METHOD_WHAT); + const scope = resolveScopeKey(); + const requestGeneration = generation; try { const result = await postJson<{ actions: Record }>( options, @@ -334,15 +355,20 @@ export function createPermissionClient(options: PermissionClientOptions): Permis input ); for (const [action, decision] of Object.entries(result[PERMISSION_RESPONSE_FIELD_ACTIONS])) { - setCached({ action, resource: input.resource, context: input.context }, decision); + setCached( + decisionKeyForScope({ action, resource: input.resource, context: input.context }, scope), + decision, + requestGeneration + ); } return result[PERMISSION_RESPONSE_FIELD_ACTIONS]; } catch (error) { options.onError?.(error); - options.logger?.error?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_REMOTE_WHAT_FAILED, { - error, - context: { input } - }); + emitPermissionClientDiagnostic( + diagnostics, + PERMISSION_CLIENT_DIAGNOSTIC_EVENTS.REMOTE_WHAT_FAILED, + { error, input } + ); return {}; } } @@ -359,6 +385,8 @@ export function createPermissionClient(options: PermissionClientOptions): Permis function hydrate(snapshot: PermissionSnapshot): void { ensureLive(PERMISSION_ACTIVE_EVENT_HYDRATE); + generation += 1; + pending.clear(); currentSnapshot = snapshot; cache.clear(); failures.clear(); @@ -371,9 +399,11 @@ export function createPermissionClient(options: PermissionClientOptions): Permis function invalidate(scope?: string): void { ensureLive(PERMISSION_ACTIVE_EVENT_INVALIDATE); + generation += 1; if (!scope) { cache.clear(); failures.clear(); + pending.clear(); currentSnapshot = { ...currentSnapshot, decisions: {} }; emit(); return; @@ -381,6 +411,7 @@ export function createPermissionClient(options: PermissionClientOptions): Permis const prefix = scopedKeyPrefix(scope); for (const key of [...cache.keys()]) if (key.startsWith(prefix)) cache.delete(key); for (const key of [...failures.keys()]) if (key.startsWith(prefix)) failures.delete(key); + for (const key of [...pending.keys()]) if (key.startsWith(prefix)) pending.delete(key); const decisions = { ...(currentSnapshot.decisions ?? {}) }; for (const key of Object.keys(decisions)) if (key.startsWith(prefix)) delete decisions[key]; currentSnapshot = { ...currentSnapshot, decisions }; @@ -412,7 +443,7 @@ export function createPermissionClient(options: PermissionClientOptions): Permis return () => listeners.delete(listener); }, decisionKey(input) { - ensureLive(PERMISSION_METHOD_CHECK); + ensureLive(PERMISSION_METHOD_DECISION_KEY); return decisionKey(input); }, dispose() { diff --git a/src/arts/perm/consts.ts b/src/arts/perm/consts.ts index 167d713..8ef743e 100644 --- a/src/arts/perm/consts.ts +++ b/src/arts/perm/consts.ts @@ -38,12 +38,20 @@ export const PERMISSION_SNAPSHOT_GLOBAL_POLICY = 'snapshot.global'; export const PERMISSION_CLIENT_SCOPE_PREFIX = 'scope'; +export const PERMISSION_CLIENT_DIAGNOSTIC_EVENTS = { + REMOTE_BATCH_FAILED: 'remote_batch_failed', + REMOTE_CHECK_FAILED: 'remote_check_failed', + REMOTE_WHAT_FAILED: 'remote_what_failed' +} as const; + export const PERMISSION_METHOD_CHECK = 'check'; export const PERMISSION_METHOD_CAN = 'can'; export const PERMISSION_METHOD_BATCH = 'batch'; export const PERMISSION_METHOD_EXPLAIN = 'explain'; export const PERMISSION_METHOD_WHAT = 'what'; export const PERMISSION_METHOD_SUBSCRIBE = 'subscribe'; +export const PERMISSION_METHOD_CLEAR_ERROR = 'clearError'; +export const PERMISSION_METHOD_DECISION_KEY = 'decisionKey'; export const PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED = 'remote authorization check failed'; export const PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED = 'remote authorization batch failed'; diff --git a/src/arts/perm/diagnostics.ts b/src/arts/perm/diagnostics.ts new file mode 100644 index 0000000..cbdc94d --- /dev/null +++ b/src/arts/perm/diagnostics.ts @@ -0,0 +1,83 @@ +import { + LogLevel, + createCatalogDiagnostics, + type DiagnosticCatalog, + type DiagnosticEvent, + type Diagnostics +} from '$libs/logr'; +import { + LOGGER_CATEGORY, + PERMISSION_CLIENT_DIAGNOSTIC_EVENTS, + PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED, + PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED, + PERMISSION_LOG_MSG_REMOTE_WHAT_FAILED +} from './consts.ts'; +import type { + PermissionClientBatchInput, + PermissionClientCheckInput, + PermissionClientOptions +} from './types.ts'; + +export type PermissionClientDiagnosticType = + (typeof PERMISSION_CLIENT_DIAGNOSTIC_EVENTS)[keyof typeof PERMISSION_CLIENT_DIAGNOSTIC_EVENTS]; + +export type PermissionClientDiagnosticMeta = + | { + readonly error: unknown; + readonly input: PermissionClientCheckInput; + } + | { + readonly error: unknown; + readonly input: PermissionClientBatchInput; + } + | { + readonly error: unknown; + readonly input: { + readonly resource?: PermissionClientCheckInput['resource']; + readonly actions?: readonly string[]; + readonly context?: PermissionClientCheckInput['context']; + }; + }; + +export type PermissionClientDiagnosticEvent = DiagnosticEvent< + PermissionClientDiagnosticType, + PermissionClientDiagnosticMeta +>; +export type PermissionClientDiagnostics = Diagnostics; + +const PERMISSION_CLIENT_DIAGNOSTIC_LOGS: DiagnosticCatalog = { + [PERMISSION_CLIENT_DIAGNOSTIC_EVENTS.REMOTE_BATCH_FAILED]: { + level: LogLevel.ERROR, + message: PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED + }, + [PERMISSION_CLIENT_DIAGNOSTIC_EVENTS.REMOTE_CHECK_FAILED]: { + level: LogLevel.ERROR, + message: PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED + }, + [PERMISSION_CLIENT_DIAGNOSTIC_EVENTS.REMOTE_WHAT_FAILED]: { + level: LogLevel.ERROR, + message: PERMISSION_LOG_MSG_REMOTE_WHAT_FAILED + } +}; + +export function createPermissionClientDiagnostics( + logger?: PermissionClientOptions['logger'] +): PermissionClientDiagnostics { + return createCatalogDiagnostics({ + logger, + defaultCategory: LOGGER_CATEGORY, + catalog: PERMISSION_CLIENT_DIAGNOSTIC_LOGS + }); +} + +export function emitPermissionClientDiagnostic( + diagnostics: PermissionClientDiagnostics, + type: PermissionClientDiagnosticType, + meta: PermissionClientDiagnosticMeta +): void { + diagnostics.emit({ + artifact: LOGGER_CATEGORY, + type, + meta + }); +} diff --git a/src/arts/perm/index.ts b/src/arts/perm/index.ts index 839481c..19006ec 100644 --- a/src/arts/perm/index.ts +++ b/src/arts/perm/index.ts @@ -1,5 +1,9 @@ export { createActivePermissions } from './active-permissions.svelte.ts'; export { createPermissionClient } from './client.ts'; +export { + createPermissionClientDiagnostics, + emitPermissionClientDiagnostic +} from './diagnostics.ts'; export { getPermissionsContext, setPermissionsContext } from './context.ts'; export { disposedPermissionsMessage } from './helpers.ts'; export { permissionDecisionKey, stablePermissionStringify } from '$libs/svrs/perm'; @@ -11,6 +15,7 @@ export { PERMISSION_ACTIVE_EVENT_HYDRATE, PERMISSION_ACTIVE_EVENT_INVALIDATE, PERMISSION_ACTIVE_EVENT_WHAT, + PERMISSION_CLIENT_DIAGNOSTIC_EVENTS, PERMISSION_CLIENT_CONTEXT_EMPTY, PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS, PERMISSION_CLIENT_DEFAULT_NON_ALLOW_CACHE_TTL_MS, @@ -54,6 +59,8 @@ export { PERMISSION_METHOD_BATCH, PERMISSION_METHOD_CAN, PERMISSION_METHOD_CHECK, + PERMISSION_METHOD_CLEAR_ERROR, + PERMISSION_METHOD_DECISION_KEY, PERMISSION_METHOD_EXPLAIN, PERMISSION_METHOD_SUBSCRIBE, PERMISSION_METHOD_WHAT, @@ -78,6 +85,12 @@ export { isPermNoContextError, isPermRemoteRequestError } from './errors.ts'; +export type { + PermissionClientDiagnosticEvent, + PermissionClientDiagnosticMeta, + PermissionClientDiagnosticType, + PermissionClientDiagnostics +} from './diagnostics.ts'; export type { ActivePermissionError, ActivePermissions, diff --git a/src/arts/perm/test/client-http.test.ts b/src/arts/perm/test/client-http.test.ts index d3c6549..e8ddc98 100644 --- a/src/arts/perm/test/client-http.test.ts +++ b/src/arts/perm/test/client-http.test.ts @@ -185,4 +185,43 @@ describe('Permission client + HTTP handlers', () => { expect(fetcher).toHaveBeenCalledTimes(1); }); + + it('does not cache an in-flight decision under a later actor snapshot', async () => { + const responses: Array<(response: Response) => void> = []; + const fetcher = vi.fn( + async () => + new Promise((resolve) => { + responses.push(resolve); + }) + ) as typeof fetch; + const client = createPermissionClient({ + endpoint: 'https://perm.test/permissions', + fetcher, + initialSnapshot: { actor: { type: 'user', id: 'u1' }, decisions: {} } + }); + const input = { + action: 'post.read', + resource: { type: 'post', id: 'p1' } + }; + + const first = client.check(input); + client.hydrate({ actor: { type: 'user', id: 'u2' }, decisions: {} }); + responses[0]!( + new Response(JSON.stringify({ effect: PERMISSION_EFFECT_ALLOW, policy: 'remote.allow' }), { + status: 200, + headers: { 'content-type': 'application/json' } + }) + ); + await first; + + const second = client.check(input); + expect(fetcher).toHaveBeenCalledTimes(2); + responses[1]!( + new Response(JSON.stringify({ effect: PERMISSION_EFFECT_ALLOW, policy: 'remote.allow' }), { + status: 200, + headers: { 'content-type': 'application/json' } + }) + ); + await second; + }); }); diff --git a/src/arts/perm/types.ts b/src/arts/perm/types.ts index 1ccbff5..ba64d17 100644 --- a/src/arts/perm/types.ts +++ b/src/arts/perm/types.ts @@ -1,4 +1,4 @@ -import type { EngineLogger } from '$logr'; +import type { Logger } from '$libs/logr'; import type { ExplainResult, PermissionDecision, ResourceRef, SubjectRef } from '$libs/perm'; import type { EngineHttp } from '$http'; import type { ActiveChangeListener, ActiveEngine } from '$libs/active'; @@ -54,7 +54,7 @@ export interface PermissionClientOptions { readonly remoteFailureBackoffMs?: number; readonly clock?: PermissionClientClock; readonly scopeKey?: string | (() => string | undefined); - readonly logger?: EngineLogger; + readonly logger?: Logger; readonly onError?: (error: unknown) => void; } diff --git a/src/arts/sess/DESIGN.md b/src/arts/sess/DESIGN.md index 380bfca..a86b362 100644 --- a/src/arts/sess/DESIGN.md +++ b/src/arts/sess/DESIGN.md @@ -120,7 +120,7 @@ locale all flow from App. From `feedback_minimal_apis.md`: `createActiveApp` already auto-wires obvious cross-artifact integrations: -- Storage `onError` → `Logger.error('storage', ...)` +- Storage diagnostics → shared `Logger` - Lang `setLogger(Logger)` (so DEV warnings flow through Logger) - Formats `localeSource` derived from Lang - Frontend `localeSource` shared with Formats diff --git a/src/arts/sess/consts.ts b/src/arts/sess/consts.ts index f4f717d..3d6fde9 100644 --- a/src/arts/sess/consts.ts +++ b/src/arts/sess/consts.ts @@ -1,6 +1,24 @@ /** Logger category emitted by the engine. */ export const LOGGER_CATEGORY = 'sess'; +export const SESSION_DIAGNOSTIC_EVENTS = { + ADOPT_SERVER_INVALID: 'adopt_server_invalid', + DISPOSED_ACCESS: 'disposed_access', + LISTENER_THREW: 'listener_threw', + REFRESH_ACTOR_SCHEMA_FAILED: 'refresh_actor_schema_failed', + REFRESH_CREDENTIAL_SCHEMA_FAILED: 'refresh_credential_schema_failed', + REFRESH_DATA_SCHEMA_FAILED: 'refresh_data_schema_failed', + REFRESH_INVARIANT_VIOLATED: 'refresh_invariant_violated', + REFRESH_TRANSIENT_FAILURE: 'refresh_transient_failure', + REFRESH_USER_SCHEMA_FAILED: 'refresh_user_schema_failed', + REVOKE_GLOBAL_HANDLER_FAILED: 'revoke_global_handler_failed', + REVOKE_GLOBAL_NO_HANDLER: 'revoke_global_no_handler', + REVOKE_GLOBAL_SERVER_REJECTED: 'revoke_global_server_rejected', + STORAGE_ADAPTER_NO_ONCHANGE: 'storage_adapter_no_onchange', + STORAGE_PARSE_FAILED: 'storage_parse_failed', + STORAGE_PERSIST_FAILED: 'storage_persist_failed' +} as const; + /** Default `BroadcastChannel` name when not overridden. */ export const DEFAULT_BROADCAST_CHANNEL = 'arts:sess'; diff --git a/src/arts/sess/diagnostics.ts b/src/arts/sess/diagnostics.ts new file mode 100644 index 0000000..cb0e13e --- /dev/null +++ b/src/arts/sess/diagnostics.ts @@ -0,0 +1,146 @@ +import { + LogLevel, + createCatalogDiagnostics, + type DiagnosticCatalog, + type DiagnosticEvent, + type Diagnostics +} from '$libs/logr'; +import { + EVENT_INITIAL_SESSION, + LOGGER_CATEGORY, + LOG_MSG_REFRESH_ACTOR_SCHEMA_FAILED, + LOG_MSG_REFRESH_CREDENTIAL_SCHEMA_FAILED, + LOG_MSG_REFRESH_DATA_SCHEMA_FAILED, + LOG_MSG_REFRESH_TRANSIENT_FAILURE, + LOG_MSG_REFRESH_USER_SCHEMA_FAILED, + LOG_MSG_REVOKE_GLOBAL_HANDLER_FAILED, + LOG_MSG_REVOKE_GLOBAL_NO_HANDLER, + LOG_MSG_REVOKE_GLOBAL_SERVER_REJECTED, + LOG_MSG_STORED_SESSION_PARSE_FAILED, + LOG_MSG_STORED_SESSION_PERSIST_FAILED, + SESSION_DIAGNOSTIC_EVENTS, + adapterNoOnChangeMessage, + adoptServerErrorMessage, + disposedErrorMessage, + listenerThrewMessage, + refreshInvariantViolatedMessage +} from './consts.ts'; +import type { AdoptInvariant, EngineSessionOptions, SessionChange } from './types.ts'; + +export type SessionDiagnosticType = + (typeof SESSION_DIAGNOSTIC_EVENTS)[keyof typeof SESSION_DIAGNOSTIC_EVENTS]; + +export type SessionDiagnosticMeta = + | { readonly error: unknown } + | { readonly event: SessionChange['event']; readonly error: unknown } + | { readonly invariant: AdoptInvariant } + | { readonly method: string } + | { readonly adapterName: string }; + +export type SessionDiagnosticEvent = DiagnosticEvent; +export type SessionDiagnostics = Diagnostics; + +const SESSION_DIAGNOSTIC_LOGS: DiagnosticCatalog = { + [SESSION_DIAGNOSTIC_EVENTS.ADOPT_SERVER_INVALID]: (event) => ({ + level: LogLevel.ERROR, + message: adoptServerErrorMessage(invariantName(event.meta)) + }), + [SESSION_DIAGNOSTIC_EVENTS.DISPOSED_ACCESS]: (event) => ({ + level: LogLevel.ERROR, + message: disposedErrorMessage(methodName(event.meta)) + }), + [SESSION_DIAGNOSTIC_EVENTS.LISTENER_THREW]: (event) => ({ + level: LogLevel.WARN, + message: listenerThrewMessage(sessionEventName(event.meta)) + }), + [SESSION_DIAGNOSTIC_EVENTS.REFRESH_ACTOR_SCHEMA_FAILED]: { + level: LogLevel.WARN, + message: LOG_MSG_REFRESH_ACTOR_SCHEMA_FAILED + }, + [SESSION_DIAGNOSTIC_EVENTS.REFRESH_CREDENTIAL_SCHEMA_FAILED]: { + level: LogLevel.WARN, + message: LOG_MSG_REFRESH_CREDENTIAL_SCHEMA_FAILED + }, + [SESSION_DIAGNOSTIC_EVENTS.REFRESH_DATA_SCHEMA_FAILED]: { + level: LogLevel.WARN, + message: LOG_MSG_REFRESH_DATA_SCHEMA_FAILED + }, + [SESSION_DIAGNOSTIC_EVENTS.REFRESH_INVARIANT_VIOLATED]: (event) => ({ + level: LogLevel.WARN, + message: refreshInvariantViolatedMessage(invariantName(event.meta)) + }), + [SESSION_DIAGNOSTIC_EVENTS.REFRESH_TRANSIENT_FAILURE]: { + level: LogLevel.WARN, + message: LOG_MSG_REFRESH_TRANSIENT_FAILURE + }, + [SESSION_DIAGNOSTIC_EVENTS.REFRESH_USER_SCHEMA_FAILED]: { + level: LogLevel.WARN, + message: LOG_MSG_REFRESH_USER_SCHEMA_FAILED + }, + [SESSION_DIAGNOSTIC_EVENTS.REVOKE_GLOBAL_HANDLER_FAILED]: { + level: LogLevel.WARN, + message: LOG_MSG_REVOKE_GLOBAL_HANDLER_FAILED + }, + [SESSION_DIAGNOSTIC_EVENTS.REVOKE_GLOBAL_NO_HANDLER]: { + level: LogLevel.WARN, + message: LOG_MSG_REVOKE_GLOBAL_NO_HANDLER + }, + [SESSION_DIAGNOSTIC_EVENTS.REVOKE_GLOBAL_SERVER_REJECTED]: { + level: LogLevel.WARN, + message: LOG_MSG_REVOKE_GLOBAL_SERVER_REJECTED + }, + [SESSION_DIAGNOSTIC_EVENTS.STORAGE_ADAPTER_NO_ONCHANGE]: (event) => ({ + level: LogLevel.WARN, + message: adapterNoOnChangeMessage(adapterName(event.meta)) + }), + [SESSION_DIAGNOSTIC_EVENTS.STORAGE_PARSE_FAILED]: { + level: LogLevel.WARN, + message: LOG_MSG_STORED_SESSION_PARSE_FAILED + }, + [SESSION_DIAGNOSTIC_EVENTS.STORAGE_PERSIST_FAILED]: { + level: LogLevel.WARN, + message: LOG_MSG_STORED_SESSION_PERSIST_FAILED + } +}; + +function adapterName(meta: SessionDiagnosticMeta | undefined): string { + if (meta !== undefined && 'adapterName' in meta) return meta.adapterName; + return LOGGER_CATEGORY; +} + +function invariantName(meta: SessionDiagnosticMeta | undefined): AdoptInvariant { + if (meta !== undefined && 'invariant' in meta) return meta.invariant; + return 'unknown' as AdoptInvariant; +} + +function methodName(meta: SessionDiagnosticMeta | undefined): string { + if (meta !== undefined && 'method' in meta) return meta.method; + return LOGGER_CATEGORY; +} + +function sessionEventName(meta: SessionDiagnosticMeta | undefined): string { + if (meta !== undefined && 'event' in meta) return meta.event; + return EVENT_INITIAL_SESSION; +} + +export function createSessionDiagnostics( + logger?: EngineSessionOptions['logger'] +): SessionDiagnostics { + return createCatalogDiagnostics({ + logger, + defaultCategory: LOGGER_CATEGORY, + catalog: SESSION_DIAGNOSTIC_LOGS + }); +} + +export function emitSessionDiagnostic( + diagnostics: SessionDiagnostics, + type: SessionDiagnosticType, + meta: SessionDiagnosticMeta +): void { + diagnostics.emit({ + artifact: LOGGER_CATEGORY, + type, + meta + }); +} diff --git a/src/arts/sess/engine-session.ts b/src/arts/sess/engine-session.ts index ca21a10..31ba684 100644 --- a/src/arts/sess/engine-session.ts +++ b/src/arts/sess/engine-session.ts @@ -1,12 +1,6 @@ -import type { StandardSchemaV1 } from '$libs/standard-schema'; -import { isPromiseLike } from '$libs/standard-schema'; - import { - ACTOR_KINDS, ADOPT_REASON_INVARIANT_FAILED, ADOPT_REASON_VALIDATION_FAILED, - BROWSER_EVENT_MESSAGE, - BROADCAST_TYPE, DEFAULT_BROADCAST_CHANNEL, ENGINE_METHOD_ADOPT, ENGINE_METHOD_ADOPT_SERVER, @@ -24,29 +18,10 @@ import { FIELD_ACTOR, FIELD_CREDENTIAL, FIELD_DATA, - FIELD_EXPIRES_AT, - FIELD_ISSUED_AT, FIELD_USER, IDENTITY_ANONYMOUS, IDENTITY_IDENTIFIED, IDENTITY_NONE, - INVARIANT_ACTOR_CONFIDENCE_OUT_OF_RANGE, - INVARIANT_ACTOR_KIND_INVALID, - INVARIANT_EXPIRES_BEFORE_ISSUED, - INVARIANT_EXPIRES_NOT_FINITE, - INVARIANT_ISSUED_NOT_FINITE, - LOGGER_CATEGORY, - LOG_MSG_LISTENER_THREW_INITIAL, - LOG_MSG_REFRESH_CREDENTIAL_SCHEMA_FAILED, - LOG_MSG_REFRESH_ACTOR_SCHEMA_FAILED, - LOG_MSG_REFRESH_DATA_SCHEMA_FAILED, - LOG_MSG_REFRESH_TRANSIENT_FAILURE, - LOG_MSG_REFRESH_USER_SCHEMA_FAILED, - LOG_MSG_REVOKE_GLOBAL_HANDLER_FAILED, - LOG_MSG_REVOKE_GLOBAL_NO_HANDLER, - LOG_MSG_REVOKE_GLOBAL_SERVER_REJECTED, - LOG_MSG_STORED_SESSION_PARSE_FAILED, - LOG_MSG_STORED_SESSION_PERSIST_FAILED, REFRESH_STATUS_EXPIRED, REFRESH_STATUS_FAILED, REFRESH_STATUS_REFRESHED, @@ -57,19 +32,29 @@ import { REVOKE_REASON_SERVER_REJECTED, REVOKE_SCOPE_GLOBAL, REVOKE_SCOPE_LOCAL, - SESSION_REQUIRED_FIELDS, + SESSION_DIAGNOSTIC_EVENTS, SKIP_REASON_NO_REFRESH_FN, SKIP_REASON_NO_SESSION, SKIP_REASON_STALE_GENERATION, - adapterNoOnChangeMessage, adoptServerErrorMessage, - disposedErrorMessage, - listenerThrewMessage, - refreshInvariantViolatedMessage + disposedErrorMessage } from './consts.ts'; +import { createSessionDiagnostics, emitSessionDiagnostic } from './diagnostics.ts'; import { SessDisposedError, SessInvalidSessionError } from './errors.ts'; +import { + freezeSession, + readField, + sameSnapshot, + toError +} from './session-helpers.ts'; +import { + createSessionBroadcastSync, + hydrateSessionStorage, + wireSessionStorageSync +} from './session-sync.ts'; +import { checkSessionInvariants, validateSessionCandidate } from './session-validation.ts'; import type { - AdoptInvariant, + AdoptResultField, EngineSession, EngineSessionOptions, RefreshResult, @@ -77,12 +62,24 @@ import type { RevokeResult, Session, SessionActor, - SessionActorKind, SessionChange, SessionIdentityState, SessionListener } from './types.ts'; +function refreshValidationDiagnosticEvent(field: AdoptResultField) { + switch (field) { + case FIELD_USER: + return SESSION_DIAGNOSTIC_EVENTS.REFRESH_USER_SCHEMA_FAILED; + case FIELD_CREDENTIAL: + return SESSION_DIAGNOSTIC_EVENTS.REFRESH_CREDENTIAL_SCHEMA_FAILED; + case FIELD_DATA: + return SESSION_DIAGNOSTIC_EVENTS.REFRESH_DATA_SCHEMA_FAILED; + case FIELD_ACTOR: + return SESSION_DIAGNOSTIC_EVENTS.REFRESH_ACTOR_SCHEMA_FAILED; + } +} + /** * Build a fresh `EngineSession`. Runes-free — safe to import from server-only * modules. The Active wrapper layers reactive state on top. @@ -100,6 +97,7 @@ export function createEngineSession ): EngineSession { const { schemas, storage, onRefresh, onRevoke, logger } = options; + const diagnostics = createSessionDiagnostics(logger); type Snap = Session; @@ -114,73 +112,39 @@ export function createEngineSession(parsed); - if (promoted !== null) snapshot = promoted; - } catch (err) { - logger?.warn?.(LOGGER_CATEGORY, LOG_MSG_STORED_SESSION_PARSE_FAILED, { - error: toError(err) - }); - storage.adapter.removeItem(storage.key); - } - } - - const detachStorage = storage.adapter.onChange?.((key, value) => { - if (key !== storage.key) return; - if (value === null) { - if (snapshot !== null) applyExternalChange(null); - return; - } - try { - const parsed = JSON.parse(value) as unknown; - const promoted = promoteStored(parsed); - if (promoted !== null) applyExternalChange(promoted); - } catch { - // foreign payload — ignore + const promoted = hydrateSessionStorage(storage, diagnostics); + if (promoted !== null) snapshot = promoted; + + const detachStorage = wireSessionStorageSync({ + storage, + diagnostics, + warnNoOnChange: logger !== undefined, + applyExternalChange: (next) => { + if (next !== null || snapshot !== null) applyExternalChange(next); } }); if (detachStorage !== undefined) detachers.push(detachStorage); - - if (storage.adapter.onChange === undefined && logger !== undefined) { - logger.warn?.(LOGGER_CATEGORY, adapterNoOnChangeMessage(storage.adapter.name)); - } } // ── Cross-tab broadcast (event + generation only, never tokens) ───────── const channelName = options.broadcastChannel ?? DEFAULT_BROADCAST_CHANNEL; - let channel: BroadcastChannel | null = null; - if (typeof BroadcastChannel !== 'undefined') { - try { - channel = new BroadcastChannel(channelName); - channel.addEventListener(BROWSER_EVENT_MESSAGE, (msg: MessageEvent) => { - const data = msg.data as { type?: string }; - if (data?.type !== BROADCAST_TYPE) return; - if (storage === undefined) return; - const raw = storage.adapter.getItem(storage.key); - if (raw === null) { - if (snapshot !== null) applyExternalChange(null); - return; - } - try { - const parsed = JSON.parse(raw) as unknown; - const promoted = promoteStored(parsed); - if (promoted !== null) applyExternalChange(promoted); - } catch { - // ignore - } - }); - detachers.push(() => channel?.close()); - } catch { - channel = null; + const broadcastSync = createSessionBroadcastSync< + TUser, + TCredential, + TData, + SessionChange['event'] + >({ + channelName, + storage, + applyExternalChange: (next) => { + if (next !== null || snapshot !== null) applyExternalChange(next); } - } + }); + if (broadcastSync !== undefined) detachers.push(() => broadcastSync.detach()); function broadcast(event: SessionChange['event']): void { - channel?.postMessage({ type: BROADCAST_TYPE, event, generation }); + broadcastSync?.post(event, generation); } // ── State helpers ─────────────────────────────────────────────────────── @@ -222,7 +186,7 @@ export function createEngineSession( - schema: StandardSchemaV1 | undefined, - value: unknown - ): Promise< - { ok: true; value: O } | { ok: false; issues: ReadonlyArray } - > { - if (schema === undefined) return { ok: true, value: value as O }; - const result = schema['~standard'].validate(value); - const resolved = isPromiseLike(result) ? await result : result; - if (resolved.issues !== undefined) return { ok: false, issues: resolved.issues }; - return { ok: true, value: resolved.value }; - } - - function checkInvariants(input: { - issuedAt: number; - expiresAt: number; - actor?: unknown; - }): AdoptInvariant | null { - if (!Number.isFinite(input.issuedAt)) return INVARIANT_ISSUED_NOT_FINITE; - if (!Number.isFinite(input.expiresAt)) return INVARIANT_EXPIRES_NOT_FINITE; - if (input.expiresAt < input.issuedAt) return INVARIANT_EXPIRES_BEFORE_ISSUED; - if (input.actor !== undefined) { - const actorInvariant = checkActorInvariants(input.actor); - if (actorInvariant !== null) return actorInvariant; - } - return null; - } - - function checkActorInvariants(actor: unknown): AdoptInvariant | null { - if (actor === null || typeof actor !== 'object') return INVARIANT_ACTOR_KIND_INVALID; - const a = actor as { kind?: unknown; confidence?: unknown }; - if (!isValidActorKind(a.kind)) return INVARIANT_ACTOR_KIND_INVALID; - if (a.confidence !== undefined) { - if ( - typeof a.confidence !== 'number' || - !Number.isFinite(a.confidence) || - a.confidence < 0 || - a.confidence > 1 - ) { - return INVARIANT_ACTOR_CONFIDENCE_OUT_OF_RANGE; - } - } - return null; - } - - function isValidActorKind(value: unknown): value is SessionActorKind { - return typeof value === 'string' && (ACTOR_KINDS as ReadonlyArray).includes(value); - } - function commitAdoption( next: Snap, event: typeof EVENT_ADOPTED | typeof EVENT_ADOPTED_SERVER | typeof EVENT_REFRESHED @@ -336,7 +250,9 @@ export function createEngineSession(input, FIELD_CREDENTIAL); - const credentialResult = - schemas?.credential !== undefined || inputCredential !== undefined - ? await validateField(schemas?.credential, inputCredential) - : { ok: true as const, value: undefined as TCredential }; - if (!credentialResult.ok) { - return { - ok: false, - reason: ADOPT_REASON_VALIDATION_FAILED, - field: FIELD_CREDENTIAL, - issues: credentialResult.issues - }; - } - - const inputData = readField(input, FIELD_DATA); - const dataResult = - schemas?.data !== undefined || inputData !== undefined - ? await validateField(schemas?.data, inputData) - : { ok: true as const, value: undefined as TData }; - if (!dataResult.ok) { - return { - ok: false, - reason: ADOPT_REASON_VALIDATION_FAILED, - field: FIELD_DATA, - issues: dataResult.issues - }; - } - - const inputActor = readField(input, FIELD_ACTOR); - const actorResult = - schemas?.actor !== undefined || inputActor !== undefined - ? await validateField(schemas?.actor, inputActor) - : { ok: true as const, value: undefined as SessionActor | undefined }; - if (!actorResult.ok) { + const validated = await validateSessionCandidate(input, schemas); + if (!validated.ok) { return { ok: false, reason: ADOPT_REASON_VALIDATION_FAILED, - field: FIELD_ACTOR, - issues: actorResult.issues + field: validated.field, + issues: validated.issues }; } - const next = freezeSession({ - user: userResult.value, - credential: credentialResult.value, - data: dataResult.value, - actor: actorResult.value, - issuedAt: input.issuedAt, - expiresAt: input.expiresAt - }); + const next = validated.session; commitAdoption(next, EVENT_ADOPTED); return { ok: true, session: next }; }, adoptServer(input) { ensureLive(ENGINE_METHOD_ADOPT_SERVER); - const invariant = checkInvariants(input); + const invariant = checkSessionInvariants(input); if (invariant !== null) { const message = adoptServerErrorMessage(invariant); - logger?.error?.(LOGGER_CATEGORY, message); + emitSessionDiagnostic(diagnostics, SESSION_DIAGNOSTIC_EVENTS.ADOPT_SERVER_INVALID, { + invariant + }); throw new SessInvalidSessionError(invariant, message); } const next = freezeSession({ @@ -481,9 +343,13 @@ export function createEngineSession(next, FIELD_CREDENTIAL); - const credentialResult = - schemas?.credential !== undefined || nextCredential !== undefined - ? await validateField(schemas?.credential, nextCredential) - : { ok: true as const, value: undefined as TCredential }; - if (!credentialResult.ok) { - logger?.warn?.(LOGGER_CATEGORY, LOG_MSG_REFRESH_CREDENTIAL_SCHEMA_FAILED); + const validated = await validateSessionCandidate(next, schemas); + if (!validated.ok) { + emitSessionDiagnostic( + diagnostics, + refreshValidationDiagnosticEvent(validated.field), + { error: validated.issues } + ); return { status: REFRESH_STATUS_FAILED, - error: credentialResult.issues, - session: current - }; - } - const nextData = readField(next, FIELD_DATA); - const dataResult = - schemas?.data !== undefined || nextData !== undefined - ? await validateField(schemas?.data, nextData) - : { ok: true as const, value: undefined as TData }; - if (!dataResult.ok) { - logger?.warn?.(LOGGER_CATEGORY, LOG_MSG_REFRESH_DATA_SCHEMA_FAILED); - return { - status: REFRESH_STATUS_FAILED, - error: dataResult.issues, - session: current - }; - } - const nextActor = readField(next, FIELD_ACTOR); - const actorResult = - schemas?.actor !== undefined || nextActor !== undefined - ? await validateField(schemas?.actor, nextActor) - : { ok: true as const, value: undefined as SessionActor | undefined }; - if (!actorResult.ok) { - logger?.warn?.(LOGGER_CATEGORY, LOG_MSG_REFRESH_ACTOR_SCHEMA_FAILED); - return { - status: REFRESH_STATUS_FAILED, - error: actorResult.issues, + error: validated.issues, session: current }; } @@ -550,16 +378,8 @@ export function createEngineSession({ - user: userResult.value, - credential: credentialResult.value, - data: dataResult.value, - actor: actorResult.value, - issuedAt: next.issuedAt, - expiresAt: next.expiresAt - }); - commitAdoption(validated, EVENT_REFRESHED); - return { status: REFRESH_STATUS_REFRESHED, session: validated }; + commitAdoption(validated.session, EVENT_REFRESHED); + return { status: REFRESH_STATUS_REFRESHED, session: validated.session }; } catch (err) { if (startedAt !== generation) { return { @@ -567,7 +387,7 @@ export function createEngineSession listeners.delete(listener); @@ -715,104 +544,3 @@ export function createEngineSession` whose - * `SessionCredential` / `SessionData` arms make - * the field optional. Centralised so the unsafe cast lives in one spot - * and the call sites read as plain dictionary lookups. - */ -function readField(source: unknown, key: string): T { - if (source === null || typeof source !== 'object') return undefined as T; - return (source as Record)[key] as T; -} - -/** - * Cheap structural validation of a JSON payload claiming to be a - * `Session`. Verifies every required field is present, that timestamps - * are finite numbers, and that `expiresAt >= issuedAt`. Run on every - * payload promoted to `snapshot` (storage hydration, broadcast event, - * external storage event) so foreign / corrupt data never reaches - * listeners. - */ -function isValidStoredSession(value: unknown): boolean { - if (value === null || typeof value !== 'object') return false; - for (const field of SESSION_REQUIRED_FIELDS) { - if (!(field in value)) return false; - } - const issuedAt = readField(value, FIELD_ISSUED_AT); - const expiresAt = readField(value, FIELD_EXPIRES_AT); - return ( - typeof issuedAt === 'number' && - typeof expiresAt === 'number' && - Number.isFinite(issuedAt) && - Number.isFinite(expiresAt) && - expiresAt >= issuedAt - ); -} - -/** - * Validate, normalise and freeze a JSON payload from external storage. - * Returns the canonical frozen `Session`, or `null` if the payload is - * not a valid session. - */ -function promoteStored( - value: unknown -): Session | null { - if (!isValidStoredSession(value)) return null; - return freezeSession({ - user: readField(value, FIELD_USER), - credential: readField(value, FIELD_CREDENTIAL), - data: readField(value, FIELD_DATA), - actor: readField(value, FIELD_ACTOR), - issuedAt: readField(value, FIELD_ISSUED_AT), - expiresAt: readField(value, FIELD_EXPIRES_AT) - }); -} - -/** - * Cheap structural equality between two snapshots. Runs only on the - * external-change path (storage event + broadcast event for the same - * change must collapse to a single `EXTERNAL_CHANGED`) — not in the hot - * read path. JSON.stringify keeps the cost predictable without dragging - * in a deep-equal helper. - */ -function sameSnapshot(a: unknown, b: unknown): boolean { - if (a === b) return true; - if (a === null || b === null) return false; - return JSON.stringify(a) === JSON.stringify(b); -} - -function toError(value: unknown): Error { - return value instanceof Error ? value : new Error(String(value)); -} - -/** - * Build the canonical session shape and freeze it. The `credential` and - * `data` fields are conditional in the public type — internally we strip - * `undefined` so persisted JSON does not carry unused keys. - */ -function freezeSession(parts: { - user: TUser | null; - credential: TCredential | undefined; - data: TData | undefined; - actor: SessionActor | undefined; - issuedAt: number; - expiresAt: number; -}): Session { - const result: Record = { - [FIELD_USER]: parts.user, - [FIELD_ISSUED_AT]: parts.issuedAt, - [FIELD_EXPIRES_AT]: parts.expiresAt - }; - if (parts.credential !== undefined) result[FIELD_CREDENTIAL] = parts.credential; - if (parts.data !== undefined) result[FIELD_DATA] = parts.data; - if (parts.actor !== undefined) result[FIELD_ACTOR] = Object.freeze({ ...parts.actor }); - return Object.freeze(result) as Session; -} diff --git a/src/arts/sess/index.ts b/src/arts/sess/index.ts index 9f4f9a8..07776b6 100644 --- a/src/arts/sess/index.ts +++ b/src/arts/sess/index.ts @@ -3,6 +3,7 @@ // the JWT helper, the SSR cookie reader or the http hook they never reach. export { createEngineSession } from './engine-session.ts'; +export { createSessionDiagnostics, emitSessionDiagnostic } from './diagnostics.ts'; export { createActiveSession } from './active-session.svelte.ts'; @@ -24,7 +25,8 @@ export { DEFAULT_AUTO_REFRESH_MARGIN_MS, DEFAULT_AUTO_REFRESH_TICK_MS, DEFAULT_BROADCAST_CHANNEL, - LOGGER_CATEGORY + LOGGER_CATEGORY, + SESSION_DIAGNOSTIC_EVENTS } from './consts.ts'; export { @@ -37,6 +39,12 @@ export { } from './errors.ts'; export type { ApplyAuthFn, BeforeErrorHookOptions } from './http-integration.ts'; +export type { + SessionDiagnosticEvent, + SessionDiagnosticMeta, + SessionDiagnosticType, + SessionDiagnostics +} from './diagnostics.ts'; export type { ActiveSession, diff --git a/src/arts/sess/session-helpers.ts b/src/arts/sess/session-helpers.ts new file mode 100644 index 0000000..7aaf34e --- /dev/null +++ b/src/arts/sess/session-helpers.ts @@ -0,0 +1,80 @@ +import { + FIELD_ACTOR, + FIELD_CREDENTIAL, + FIELD_DATA, + FIELD_EXPIRES_AT, + FIELD_ISSUED_AT, + FIELD_USER, + SESSION_REQUIRED_FIELDS +} from './consts.ts'; +import type { Session, SessionActor } from './types.ts'; + +/** + * Type-erased property read with explicit `unknown` cast at the bridge. + * Used at every place the engine has to peek at a conditional property + * (`credential`, `data`) on a value typed `Session<...>` whose optional arms + * make the field conditional. Centralised so the unsafe cast lives in one spot. + */ +export function readField(source: unknown, key: string): T { + if (source === null || typeof source !== 'object') return undefined as T; + return (source as Record)[key] as T; +} + +export function promoteStored( + value: unknown +): Session | null { + if (!isValidStoredSession(value)) return null; + return freezeSession({ + user: readField(value, FIELD_USER), + credential: readField(value, FIELD_CREDENTIAL), + data: readField(value, FIELD_DATA), + actor: readField(value, FIELD_ACTOR), + issuedAt: readField(value, FIELD_ISSUED_AT), + expiresAt: readField(value, FIELD_EXPIRES_AT) + }); +} + +export function sameSnapshot(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a === null || b === null) return false; + return JSON.stringify(a) === JSON.stringify(b); +} + +export function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} + +export function freezeSession(parts: { + user: TUser | null; + credential: TCredential | undefined; + data: TData | undefined; + actor: SessionActor | undefined; + issuedAt: number; + expiresAt: number; +}): Session { + const result: Record = { + [FIELD_USER]: parts.user, + [FIELD_ISSUED_AT]: parts.issuedAt, + [FIELD_EXPIRES_AT]: parts.expiresAt + }; + if (parts.credential !== undefined) result[FIELD_CREDENTIAL] = parts.credential; + if (parts.data !== undefined) result[FIELD_DATA] = parts.data; + if (parts.actor !== undefined) result[FIELD_ACTOR] = Object.freeze({ ...parts.actor }); + return Object.freeze(result) as Session; +} + +function isValidStoredSession(value: unknown): boolean { + if (value === null || typeof value !== 'object') return false; + for (const field of SESSION_REQUIRED_FIELDS) { + if (!(field in value)) return false; + } + const issuedAt = readField(value, FIELD_ISSUED_AT); + const expiresAt = readField(value, FIELD_EXPIRES_AT); + return ( + typeof issuedAt === 'number' && + typeof expiresAt === 'number' && + Number.isFinite(issuedAt) && + Number.isFinite(expiresAt) && + expiresAt >= issuedAt + ); +} diff --git a/src/arts/sess/session-sync.ts b/src/arts/sess/session-sync.ts new file mode 100644 index 0000000..b8d6a95 --- /dev/null +++ b/src/arts/sess/session-sync.ts @@ -0,0 +1,101 @@ +import { + BROWSER_EVENT_MESSAGE, + BROADCAST_TYPE, + SESSION_DIAGNOSTIC_EVENTS +} from './consts.ts'; +import { emitSessionDiagnostic, type SessionDiagnostics } from './diagnostics.ts'; +import { promoteStored, toError } from './session-helpers.ts'; +import type { Session, SessionStorageOptions } from './types.ts'; + +export function hydrateSessionStorage( + storage: SessionStorageOptions, + diagnostics: SessionDiagnostics +): Session | null { + const raw = storage.adapter.getItem(storage.key); + if (raw === null) return null; + try { + const parsed = JSON.parse(raw) as unknown; + return promoteStored(parsed); + } catch (err) { + emitSessionDiagnostic(diagnostics, SESSION_DIAGNOSTIC_EVENTS.STORAGE_PARSE_FAILED, { + error: toError(err) + }); + storage.adapter.removeItem(storage.key); + return null; + } +} + +export function wireSessionStorageSync(input: { + readonly storage: SessionStorageOptions; + readonly diagnostics: SessionDiagnostics; + readonly warnNoOnChange: boolean; + readonly applyExternalChange: (next: Session | null) => void; +}): (() => void) | undefined { + const detachStorage = input.storage.adapter.onChange?.((key, value) => { + if (key !== input.storage.key) return; + if (value === null) { + input.applyExternalChange(null); + return; + } + try { + const parsed = JSON.parse(value) as unknown; + const promoted = promoteStored(parsed); + if (promoted !== null) input.applyExternalChange(promoted); + } catch { + // foreign payload, ignore + } + }); + + if (detachStorage === undefined && input.warnNoOnChange) { + emitSessionDiagnostic(input.diagnostics, SESSION_DIAGNOSTIC_EVENTS.STORAGE_ADAPTER_NO_ONCHANGE, { + adapterName: input.storage.adapter.name + }); + } + + return detachStorage; +} + +export interface SessionBroadcastSync { + post(event: TEvent, generation: number): void; + detach(): void; +} + +export function createSessionBroadcastSync(input: { + readonly channelName: string; + readonly storage?: SessionStorageOptions; + readonly applyExternalChange: (next: Session | null) => void; +}): SessionBroadcastSync | undefined { + if (typeof BroadcastChannel === 'undefined') return undefined; + let channel: BroadcastChannel | null = null; + try { + channel = new BroadcastChannel(input.channelName); + channel.addEventListener(BROWSER_EVENT_MESSAGE, (msg: MessageEvent) => { + const data = msg.data as { type?: string }; + if (data?.type !== BROADCAST_TYPE) return; + if (input.storage === undefined) return; + const raw = input.storage.adapter.getItem(input.storage.key); + if (raw === null) { + input.applyExternalChange(null); + return; + } + try { + const parsed = JSON.parse(raw) as unknown; + const promoted = promoteStored(parsed); + if (promoted !== null) input.applyExternalChange(promoted); + } catch { + // ignore + } + }); + return { + post(event, generation) { + channel?.postMessage({ type: BROADCAST_TYPE, event, generation }); + }, + detach() { + channel?.close(); + } + }; + } catch { + channel = null; + return undefined; + } +} diff --git a/src/arts/sess/session-validation.ts b/src/arts/sess/session-validation.ts new file mode 100644 index 0000000..5e8ac42 --- /dev/null +++ b/src/arts/sess/session-validation.ts @@ -0,0 +1,128 @@ +import type { StandardSchemaV1 } from '$libs/standard-schema'; +import { isPromiseLike } from '$libs/standard-schema'; +import { + ACTOR_KINDS, + FIELD_ACTOR, + FIELD_CREDENTIAL, + FIELD_DATA, + FIELD_USER, + INVARIANT_ACTOR_CONFIDENCE_OUT_OF_RANGE, + INVARIANT_ACTOR_KIND_INVALID, + INVARIANT_EXPIRES_BEFORE_ISSUED, + INVARIANT_EXPIRES_NOT_FINITE, + INVARIANT_ISSUED_NOT_FINITE +} from './consts.ts'; +import { freezeSession, readField } from './session-helpers.ts'; +import type { + AdoptInvariant, + AdoptResultField, + Session, + SessionActor, + SessionActorKind, + SessionSchemas +} from './types.ts'; + +export type SessionFieldValidationResult = + | { ok: true; value: O } + | { ok: false; issues: ReadonlyArray }; + +export async function validateSessionField( + schema: StandardSchemaV1 | undefined, + value: unknown +): Promise> { + if (schema === undefined) return { ok: true, value: value as O }; + const result = schema['~standard'].validate(value); + const resolved = isPromiseLike(result) ? await result : result; + if (resolved.issues !== undefined) return { ok: false, issues: resolved.issues }; + return { ok: true, value: resolved.value }; +} + +export type ValidatedSessionCandidate = + | { ok: true; session: Session } + | { + ok: false; + field: AdoptResultField; + issues: ReadonlyArray; + }; + +export async function validateSessionCandidate( + input: Session, + schemas: SessionSchemas | undefined +): Promise> { + const userResult = + input.user === null + ? { ok: true as const, value: null as TUser | null } + : await validateSessionField(schemas?.user, input.user); + if (!userResult.ok) return { ok: false, field: FIELD_USER, issues: userResult.issues }; + + const inputCredential = readField(input, FIELD_CREDENTIAL); + const credentialResult = + schemas?.credential !== undefined || inputCredential !== undefined + ? await validateSessionField(schemas?.credential, inputCredential) + : { ok: true as const, value: undefined as TCredential }; + if (!credentialResult.ok) { + return { ok: false, field: FIELD_CREDENTIAL, issues: credentialResult.issues }; + } + + const inputData = readField(input, FIELD_DATA); + const dataResult = + schemas?.data !== undefined || inputData !== undefined + ? await validateSessionField(schemas?.data, inputData) + : { ok: true as const, value: undefined as TData }; + if (!dataResult.ok) return { ok: false, field: FIELD_DATA, issues: dataResult.issues }; + + const inputActor = readField(input, FIELD_ACTOR); + const actorResult = + schemas?.actor !== undefined || inputActor !== undefined + ? await validateSessionField(schemas?.actor, inputActor) + : { ok: true as const, value: undefined as SessionActor | undefined }; + if (!actorResult.ok) return { ok: false, field: FIELD_ACTOR, issues: actorResult.issues }; + + return { + ok: true, + session: freezeSession({ + user: userResult.value, + credential: credentialResult.value, + data: dataResult.value, + actor: actorResult.value, + issuedAt: input.issuedAt, + expiresAt: input.expiresAt + }) + }; +} + +export function checkSessionInvariants(input: { + issuedAt: number; + expiresAt: number; + actor?: unknown; +}): AdoptInvariant | null { + if (!Number.isFinite(input.issuedAt)) return INVARIANT_ISSUED_NOT_FINITE; + if (!Number.isFinite(input.expiresAt)) return INVARIANT_EXPIRES_NOT_FINITE; + if (input.expiresAt < input.issuedAt) return INVARIANT_EXPIRES_BEFORE_ISSUED; + if (input.actor !== undefined) { + const actorInvariant = checkActorInvariants(input.actor); + if (actorInvariant !== null) return actorInvariant; + } + return null; +} + +function checkActorInvariants(actor: unknown): AdoptInvariant | null { + if (actor === null || typeof actor !== 'object') return INVARIANT_ACTOR_KIND_INVALID; + const a = actor as { kind?: unknown; confidence?: unknown }; + if (!isValidActorKind(a.kind)) return INVARIANT_ACTOR_KIND_INVALID; + if (a.confidence !== undefined) { + if ( + typeof a.confidence !== 'number' || + !Number.isFinite(a.confidence) || + a.confidence < 0 || + a.confidence > 1 + ) { + return INVARIANT_ACTOR_CONFIDENCE_OUT_OF_RANGE; + } + } + return null; +} + +function isValidActorKind(value: unknown): value is SessionActorKind { + return typeof value === 'string' && (ACTOR_KINDS as ReadonlyArray).includes(value); +} diff --git a/src/arts/sess/types.ts b/src/arts/sess/types.ts index 6e89591..fe8a30f 100644 --- a/src/arts/sess/types.ts +++ b/src/arts/sess/types.ts @@ -27,6 +27,7 @@ * `Date` or `string` to keep the contract narrow. */ +import type { Logger } from '$libs/logr'; import type { StandardSchemaV1 } from '$libs/standard-schema'; import type { SyncStorageAdapter } from '$stor'; import type { @@ -250,22 +251,12 @@ export type SessionListener = // REFRESH // ============================================================================ -/** - * Minimal logger shape consumed by `sess`. Deliberately structural so - * `EngineLogger` from `$logr`, a test spy, or any compatible app logger - * can be injected without making `sess` depend on the logger artifact. - */ -export interface SessionLogger { - warn?(category: string, message: string, input?: unknown): void; - error?(category: string, message: string, input?: unknown): void; -} - /** * Context passed to the consumer's refresh function. Exposes engine * primitives without forcing the callback to capture App by closure. */ export interface RefreshContext { - readonly logger?: SessionLogger; + readonly logger?: Logger; } /** @@ -299,7 +290,7 @@ export type RefreshFn = ( * logger. Mirrors `RefreshContext`. */ export interface RevokeContext { - readonly logger?: SessionLogger; + readonly logger?: Logger; } /** @@ -484,7 +475,7 @@ export interface EngineSessionOptions; - logger?: SessionLogger; + logger?: Logger; broadcastChannel?: string; } diff --git a/src/arts/sium/consts.ts b/src/arts/sium/consts.ts index b8850a5..196d51b 100644 --- a/src/arts/sium/consts.ts +++ b/src/arts/sium/consts.ts @@ -1 +1,7 @@ export const LOGGER_CATEGORY = 'sium'; +export const SIUM_STANDARD_VENDOR = 'sium'; + +export const SIUM_DIAGNOSTIC_EVENTS = { + VALIDATION_FAILED: 'sium.validation_failed', + RESOLVE_FALLBACK: 'sium.resolve_fallback' +} as const; diff --git a/src/arts/sium/core/array-combinator.ts b/src/arts/sium/core/array-combinator.ts new file mode 100644 index 0000000..d3380ac --- /dev/null +++ b/src/arts/sium/core/array-combinator.ts @@ -0,0 +1,110 @@ +import { prependPath } from './issue'; +import { createSchema } from './schema'; +import { SiumValidationError, type Ctx, type Issue, type Schema } from './types'; +import { SIUM_ERRORS } from '../errors'; +import { + createChildCtx, + createTypeIssue, + resolveChildIssues, + throwCollectedIssues +} from './combinator-helpers'; + +/** + * Creates an array schema whose items are validated by the provided member schema. + * + * Encode semantics are synchronous and delegate item-by-item. Non-array values + * passed to `encode` are treated as programmer errors and throw `TypeError`. + */ +export function array(item: Schema): Schema { + const decodeSyncArray = (input: I[], ctx: Ctx) => { + if (!Array.isArray(input)) { + throw new SiumValidationError([createTypeIssue('array', input, ctx.path)]); + } + + const output: O[] = []; + const issueGroups: ReadonlyArray[] = []; + + for (let index = 0; index < input.length; index += 1) { + const childPath = [...ctx.path, index]; + try { + output[index] = item.decodeSync(input[index], createChildCtx(ctx, index)); + } catch (error) { + if (!(error instanceof SiumValidationError)) { + throw error; + } + + issueGroups.push(resolveChildIssues(error.issues, childPath)); + } + } + + if (issueGroups.length > 0) { + throwCollectedIssues(...issueGroups); + } + + return output; + }; + + const decodeAsyncArray = async (input: I[], ctx: Ctx) => { + if (!Array.isArray(input)) { + throw new SiumValidationError([createTypeIssue('array', input, ctx.path)]); + } + + const output: O[] = []; + const issueGroups: ReadonlyArray[] = []; + + for (let index = 0; index < input.length; index += 1) { + const childPath = [...ctx.path, index]; + try { + output[index] = await item.decode(input[index], createChildCtx(ctx, index)); + } catch (error) { + if (!(error instanceof SiumValidationError)) { + throw error; + } + + issueGroups.push(resolveChildIssues(error.issues, childPath)); + } + } + + if (issueGroups.length > 0) { + throwCollectedIssues(...issueGroups); + } + + return output; + }; + + return createSchema({ + kind: 'array', + async: item['~sium'].async, + shape: { + kind: 'array', + item + }, + decodeImpl: item['~sium'].async ? decodeAsyncArray : decodeSyncArray, + encode: (value) => { + if (!Array.isArray(value)) { + throw new TypeError(SIUM_ERRORS.ARRAY_ENCODE_EXPECTS_ARRAY); + } + + const output: I[] = []; + const issueGroups: ReadonlyArray[] = []; + + for (let index = 0; index < value.length; index += 1) { + try { + output[index] = item.encode(value[index]); + } catch (error) { + if (!(error instanceof SiumValidationError)) { + throw error; + } + + issueGroups.push(prependPath(error.issues, index)); + } + } + + if (issueGroups.length > 0) { + throwCollectedIssues(...issueGroups); + } + + return output; + } + }); +} diff --git a/src/arts/sium/core/combinator-helpers.ts b/src/arts/sium/core/combinator-helpers.ts new file mode 100644 index 0000000..808f556 --- /dev/null +++ b/src/arts/sium/core/combinator-helpers.ts @@ -0,0 +1,110 @@ +import { collect, issue } from './issue'; +import { actualType } from './internals'; +import { SiumValidationError, type Ctx, type Issue } from './types'; + +export function isPlainObjectRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function createChildCtx(ctx: Ctx, segment: string | number): Partial { + return { + path: [...ctx.path, segment], + rootInput: ctx.rootInput, + ...(ctx.rootValue === undefined ? {} : { rootValue: ctx.rootValue }), + ...(ctx.locale === undefined ? {} : { locale: ctx.locale }), + ...(ctx.meta === undefined ? {} : { meta: ctx.meta }) + }; +} + +export function resolveChildIssues( + issues: ReadonlyArray, + childPath: ReadonlyArray +): ReadonlyArray { + return issues.map((entry) => { + if (startsWithPath(entry.path, childPath)) return entry; + return { + ...entry, + path: [...childPath, ...entry.path] + }; + }); +} + +export function createTypeIssue( + expected: 'object' | 'array', + input: unknown, + path: ReadonlyArray +) { + return issue({ + path, + code: 'type', + message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}', + params: { + expected, + actual: actualType(input) + } + }); +} + +export function createUnknownKeysIssue(keys: string[], path: ReadonlyArray) { + return issue({ + path, + code: 'unknown_keys', + message: '#?sium.errors.unknown_keys|Unknown keys: {{keys}}', + params: { keys } + }); +} + +export function createUnionNoMatchIssue(path: ReadonlyArray) { + return issue({ + path, + code: 'union_no_match', + message: '#?sium.errors.union_no_match|Does not match any variant', + params: {} + }); +} + +export function createDiscriminatedMissingKeyIssue( + key: string, + path: ReadonlyArray +) { + return issue({ + path, + code: 'discriminated_missing_key', + message: '#?sium.errors.discriminated_missing_key|Missing discriminator key "{{key}}"', + params: { key } + }); +} + +export function createDiscriminatedUnknownValueIssue( + key: string, + actual: unknown, + expected: ReadonlyArray, + path: ReadonlyArray +) { + return issue({ + path, + code: 'discriminated_unknown_value', + message: + '#?sium.errors.discriminated_unknown_value|Unknown value "{{actual}}" for discriminator "{{key}}" (expected one of {{expected}})', + params: { + key, + actual, + expected: [...expected] + } + }); +} + +export function throwCollectedIssues(...groups: ReadonlyArray>): never { + throw new SiumValidationError(collect(...groups)); +} + +function startsWithPath( + path: ReadonlyArray, + prefix: ReadonlyArray +): boolean { + if (path.length < prefix.length) return false; + for (let index = 0; index < prefix.length; index += 1) { + if (path[index] !== prefix[index]) return false; + } + return true; +} diff --git a/src/arts/sium/core/combinators.ts b/src/arts/sium/core/combinators.ts index dc63890..0b63df5 100644 --- a/src/arts/sium/core/combinators.ts +++ b/src/arts/sium/core/combinators.ts @@ -1,513 +1,3 @@ -import { collect, issue, prependPath } from './issue'; -import { createSchema } from './schema'; -import { actualType } from './internals'; -import { SiumValidationError, type Ctx, type Issue, type Schema } from './types'; - -type InferInput = S extends Schema ? I : never; -type InferOutput = S extends Schema ? O : never; -type UnknownKeysPolicy = 'strip' | 'strict' | 'passthrough'; - -function isPlainObjectRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function createChildCtx(ctx: Ctx, segment: string | number): Partial { - return { - path: [...ctx.path, segment], - rootInput: ctx.rootInput, - ...(ctx.rootValue === undefined ? {} : { rootValue: ctx.rootValue }), - ...(ctx.locale === undefined ? {} : { locale: ctx.locale }), - ...(ctx.meta === undefined ? {} : { meta: ctx.meta }) - }; -} - -function startsWithPath( - path: ReadonlyArray, - prefix: ReadonlyArray -): boolean { - if (path.length < prefix.length) return false; - for (let index = 0; index < prefix.length; index += 1) { - if (path[index] !== prefix[index]) return false; - } - return true; -} - -function resolveChildIssues( - issues: ReadonlyArray, - childPath: ReadonlyArray -): ReadonlyArray { - return issues.map((entry) => { - if (startsWithPath(entry.path, childPath)) return entry; - return { - ...entry, - path: [...childPath, ...entry.path] - }; - }); -} - -function createTypeIssue( - expected: 'object' | 'array', - input: unknown, - path: ReadonlyArray -) { - return issue({ - path, - code: 'type', - message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}', - params: { - expected, - actual: actualType(input) - } - }); -} - -function createUnknownKeysIssue(keys: string[], path: ReadonlyArray) { - return issue({ - path, - code: 'unknown_keys', - message: '#?sium.errors.unknown_keys|Unknown keys: {{keys}}', - params: { keys } - }); -} - -function createUnionNoMatchIssue(path: ReadonlyArray) { - return issue({ - path, - code: 'union_no_match', - message: '#?sium.errors.union_no_match|Does not match any variant', - params: {} - }); -} - -function createDiscriminatedMissingKeyIssue(key: string, path: ReadonlyArray) { - return issue({ - path, - code: 'discriminated_missing_key', - message: '#?sium.errors.discriminated_missing_key|Missing discriminator key "{{key}}"', - params: { key } - }); -} - -function createDiscriminatedUnknownValueIssue( - key: string, - actual: unknown, - expected: ReadonlyArray, - path: ReadonlyArray -) { - return issue({ - path, - code: 'discriminated_unknown_value', - message: - '#?sium.errors.discriminated_unknown_value|Unknown value "{{actual}}" for discriminator "{{key}}" (expected one of {{expected}})', - params: { - key, - actual, - expected: [...expected] - } - }); -} - -function throwCollectedIssues(...groups: ReadonlyArray>): never { - throw new SiumValidationError(collect(...groups)); -} - -/** - * Creates an object schema with configurable unknown-key handling. - * - * Encode semantics follow README §R8: - * - `strip` drops extras on encode. - * - `strict` rejects extras with `unknown_keys`. - * - `passthrough` preserves extras as-is. - */ -export function object>( - shape: T, - opts?: { unknownKeys?: UnknownKeysPolicy } -): Schema<{ [K in keyof T]: InferInput }, { [K in keyof T]: InferOutput }> { - const unknownKeys = opts?.unknownKeys ?? 'strip'; - const entries = Object.entries(shape) as [keyof T & string, T[keyof T & string]][]; - const isAsync = entries.some(([, schema]) => schema['~sium'].async); - - const decodeSyncShape = (input: { [K in keyof T]: InferInput }, ctx: Ctx) => { - if (!isPlainObjectRecord(input)) { - throw new SiumValidationError([createTypeIssue('object', input, ctx.path)]); - } - - const output: Record = {}; - const issueGroups: ReadonlyArray[] = []; - - for (const [key, schema] of entries) { - const childPath = [...ctx.path, key]; - try { - output[key] = schema.decodeSync(input[key], createChildCtx(ctx, key)); - } catch (error) { - if (!(error instanceof SiumValidationError)) { - throw error; - } - - issueGroups.push(resolveChildIssues(error.issues, childPath)); - } - } - - const extraKeys = Object.keys(input).filter((key) => !(key in shape)); - if (unknownKeys === 'strict' && extraKeys.length > 0) { - issueGroups.push([createUnknownKeysIssue(extraKeys, ctx.path)]); - } - - if (issueGroups.length > 0) { - throwCollectedIssues(...issueGroups); - } - - if (unknownKeys === 'passthrough') { - for (const key of extraKeys) { - output[key] = input[key]; - } - } - - return output as { [K in keyof T]: InferOutput }; - }; - - const decodeAsyncShape = async (input: { [K in keyof T]: InferInput }, ctx: Ctx) => { - if (!isPlainObjectRecord(input)) { - throw new SiumValidationError([createTypeIssue('object', input, ctx.path)]); - } - - const output: Record = {}; - const issueGroups: ReadonlyArray[] = []; - - for (const [key, schema] of entries) { - const childPath = [...ctx.path, key]; - try { - output[key] = await schema.decode(input[key], createChildCtx(ctx, key)); - } catch (error) { - if (!(error instanceof SiumValidationError)) { - throw error; - } - - issueGroups.push(resolveChildIssues(error.issues, childPath)); - } - } - - const extraKeys = Object.keys(input).filter((key) => !(key in shape)); - if (unknownKeys === 'strict' && extraKeys.length > 0) { - issueGroups.push([createUnknownKeysIssue(extraKeys, ctx.path)]); - } - - if (issueGroups.length > 0) { - throwCollectedIssues(...issueGroups); - } - - if (unknownKeys === 'passthrough') { - for (const key of extraKeys) { - output[key] = input[key]; - } - } - - return output as { [K in keyof T]: InferOutput }; - }; - - return createSchema({ - kind: 'object', - async: isAsync, - shape: { - kind: 'object', - fields: shape, - unknownKeys - }, - decodeImpl: isAsync ? decodeAsyncShape : decodeSyncShape, - encode: (value) => { - if (!isPlainObjectRecord(value)) { - throw new TypeError('object().encode expects a plain object value'); - } - - const output: Record = {}; - const issueGroups: ReadonlyArray[] = []; - - for (const [key, schema] of entries) { - try { - output[key] = schema.encode(value[key] as InferOutput); - } catch (error) { - if (!(error instanceof SiumValidationError)) { - throw error; - } - - issueGroups.push(prependPath(error.issues, key)); - } - } - - const extraKeys = Object.keys(value).filter((key) => !(key in shape)); - if (unknownKeys === 'strict' && extraKeys.length > 0) { - issueGroups.push([createUnknownKeysIssue(extraKeys, [])]); - } - - if (issueGroups.length > 0) { - throwCollectedIssues(...issueGroups); - } - - if (unknownKeys === 'passthrough') { - for (const key of extraKeys) { - output[key] = value[key]; - } - } - - return output as { [K in keyof T]: InferInput }; - } - }); -} - -/** - * Creates an array schema whose items are validated by the provided member schema. - * - * Encode semantics are synchronous and delegate item-by-item. Non-array values - * passed to `encode` are treated as programmer errors and throw `TypeError`. - */ -export function array(item: Schema): Schema { - const decodeSyncArray = (input: I[], ctx: Ctx) => { - if (!Array.isArray(input)) { - throw new SiumValidationError([createTypeIssue('array', input, ctx.path)]); - } - - const output: O[] = []; - const issueGroups: ReadonlyArray[] = []; - - for (let index = 0; index < input.length; index += 1) { - const childPath = [...ctx.path, index]; - try { - output[index] = item.decodeSync(input[index], createChildCtx(ctx, index)); - } catch (error) { - if (!(error instanceof SiumValidationError)) { - throw error; - } - - issueGroups.push(resolveChildIssues(error.issues, childPath)); - } - } - - if (issueGroups.length > 0) { - throwCollectedIssues(...issueGroups); - } - - return output; - }; - - const decodeAsyncArray = async (input: I[], ctx: Ctx) => { - if (!Array.isArray(input)) { - throw new SiumValidationError([createTypeIssue('array', input, ctx.path)]); - } - - const output: O[] = []; - const issueGroups: ReadonlyArray[] = []; - - for (let index = 0; index < input.length; index += 1) { - const childPath = [...ctx.path, index]; - try { - output[index] = await item.decode(input[index], createChildCtx(ctx, index)); - } catch (error) { - if (!(error instanceof SiumValidationError)) { - throw error; - } - - issueGroups.push(resolveChildIssues(error.issues, childPath)); - } - } - - if (issueGroups.length > 0) { - throwCollectedIssues(...issueGroups); - } - - return output; - }; - - return createSchema({ - kind: 'array', - async: item['~sium'].async, - shape: { - kind: 'array', - item - }, - decodeImpl: item['~sium'].async ? decodeAsyncArray : decodeSyncArray, - encode: (value) => { - if (!Array.isArray(value)) { - throw new TypeError('array().encode expects an array value'); - } - - const output: I[] = []; - const issueGroups: ReadonlyArray[] = []; - - for (let index = 0; index < value.length; index += 1) { - try { - output[index] = item.encode(value[index]); - } catch (error) { - if (!(error instanceof SiumValidationError)) { - throw error; - } - - issueGroups.push(prependPath(error.issues, index)); - } - } - - if (issueGroups.length > 0) { - throwCollectedIssues(...issueGroups); - } - - return output; - } - }); -} - -/** - * Creates a first-match union. Members are tried in declaration order. - * - * Encode semantics are intentionally identity in v1: without branding, the - * union cannot know which member should handle the output value. - */ -export function union( - ...members: M -): Schema, InferOutput> { - const isAsync = members.some((member) => member['~sium'].async); - - const decodeSyncUnion = (input: InferInput, ctx: Ctx) => { - for (const member of members) { - try { - return member.decodeSync(input, ctx) as InferOutput; - } catch (error) { - if (error instanceof SiumValidationError) { - continue; - } - - throw error; - } - } - - throw new SiumValidationError([createUnionNoMatchIssue(ctx.path)]); - }; - - const decodeAsyncUnion = async (input: InferInput, ctx: Ctx) => { - for (const member of members) { - try { - return (await member.decode(input, ctx)) as InferOutput; - } catch (error) { - if (error instanceof SiumValidationError) { - continue; - } - - throw error; - } - } - - throw new SiumValidationError([createUnionNoMatchIssue(ctx.path)]); - }; - - return createSchema({ - kind: 'union', - async: isAsync, - shape: { - kind: 'union', - members - }, - decodeImpl: isAsync ? decodeAsyncUnion : decodeSyncUnion, - encode: (value) => value as InferInput - }); -} - -type DiscriminatedMemberInfo = { - member: Schema; - literal: string | number | boolean; -}; - -function getDiscriminatedMemberInfo(key: string, member: Schema): DiscriminatedMemberInfo { - const memberShape = member['~sium'].shape; - if (member['~sium'].kind !== 'object' || memberShape?.kind !== 'object') { - throw new Error(`discriminated(${key}) members must be object() schemas`); - } - - const discriminantSchema = memberShape.fields[key]; - if (discriminantSchema === undefined) { - throw new Error(`discriminated(${key}) members must declare the discriminator field`); - } - - const discriminantShape = discriminantSchema['~sium'].shape; - if (discriminantSchema['~sium'].kind !== 'literal' || discriminantShape?.kind !== 'literal') { - throw new Error(`discriminated(${key}) members must use literal() on the discriminator field`); - } - - return { - member, - literal: discriminantShape.value - }; -} - -/** - * Creates a discriminated union keyed by one literal field shared by every member. - * - * Encode semantics delegate to the matching member based on the discriminator - * value. When no member matches, `encode` throws `TypeError` (programmer error). - */ -export function discriminated( - key: K, - members: M -): Schema, InferOutput> { - const memberInfos = members.map((member) => getDiscriminatedMemberInfo(key, member)); - const expected = memberInfos.map((entry) => entry.literal); - const isAsync = members.some((member) => member['~sium'].async); - - const decodeSyncDiscriminated = (input: InferInput, ctx: Ctx) => { - if (!isPlainObjectRecord(input)) { - throw new SiumValidationError([createTypeIssue('object', input, ctx.path)]); - } - - if (!(key in input) || input[key] === undefined) { - throw new SiumValidationError([createDiscriminatedMissingKeyIssue(key, ctx.path)]); - } - - const info = memberInfos.find((entry) => entry.literal === input[key]); - if (info === undefined) { - throw new SiumValidationError([ - createDiscriminatedUnknownValueIssue(key, input[key], expected, ctx.path) - ]); - } - - return info.member.decodeSync(input, ctx) as InferOutput; - }; - - const decodeAsyncDiscriminated = async (input: InferInput, ctx: Ctx) => { - if (!isPlainObjectRecord(input)) { - throw new SiumValidationError([createTypeIssue('object', input, ctx.path)]); - } - - if (!(key in input) || input[key] === undefined) { - throw new SiumValidationError([createDiscriminatedMissingKeyIssue(key, ctx.path)]); - } - - const info = memberInfos.find((entry) => entry.literal === input[key]); - if (info === undefined) { - throw new SiumValidationError([ - createDiscriminatedUnknownValueIssue(key, input[key], expected, ctx.path) - ]); - } - - return (await info.member.decode(input, ctx)) as InferOutput; - }; - - return createSchema({ - kind: 'discriminated', - async: isAsync, - shape: { - kind: 'discriminated', - key, - members - }, - decodeImpl: isAsync ? decodeAsyncDiscriminated : decodeSyncDiscriminated, - encode: (value) => { - if (!isPlainObjectRecord(value)) { - throw new TypeError('discriminated().encode expects a plain object value'); - } - - const info = memberInfos.find((entry) => entry.literal === value[key]); - if (info === undefined) { - throw new TypeError(`discriminated().encode could not match discriminator ${key}`); - } - - return info.member.encode(value as InferOutput) as InferInput; - } - }); -} +export { array } from './array-combinator'; +export { object } from './object-combinator'; +export { discriminated, union } from './union-combinators'; diff --git a/src/arts/sium/core/lazy.ts b/src/arts/sium/core/lazy.ts index 34c5f80..b4e2066 100644 --- a/src/arts/sium/core/lazy.ts +++ b/src/arts/sium/core/lazy.ts @@ -1,9 +1,9 @@ import type { Schema } from './types'; +import { SIUM_STANDARD_VENDOR } from '../consts.ts'; +import { SIUM_ERRORS } from '../errors.ts'; function createResolvingAccessError() { - return new Error( - 'lazy() cannot decode, encode, or validate while its schema thunk is still resolving' - ); + return new Error(SIUM_ERRORS.LAZY_RESOLVING_ACCESS); } /** @@ -23,7 +23,7 @@ export function lazy(getSchema: () => Schema): Schema { const unresolvedStandard = { version: 1, - vendor: 'sium', + vendor: SIUM_STANDARD_VENDOR, validate: async (value: unknown) => await getResolvedSchema()['~standard'].validate(value as I) } satisfies Schema['~standard']; diff --git a/src/arts/sium/core/object-combinator.ts b/src/arts/sium/core/object-combinator.ts new file mode 100644 index 0000000..4d14389 --- /dev/null +++ b/src/arts/sium/core/object-combinator.ts @@ -0,0 +1,159 @@ +import { prependPath } from './issue'; +import { createSchema } from './schema'; +import { SiumValidationError, type Ctx, type Issue, type Schema } from './types'; +import { SIUM_ERRORS } from '../errors'; +import { + createChildCtx, + createTypeIssue, + createUnknownKeysIssue, + isPlainObjectRecord, + resolveChildIssues, + throwCollectedIssues +} from './combinator-helpers'; + +type InferInput = S extends Schema ? I : never; +type InferOutput = S extends Schema ? O : never; +type UnknownKeysPolicy = 'strip' | 'strict' | 'passthrough'; + +/** + * Creates an object schema with configurable unknown-key handling. + * + * Encode semantics follow README §R8: + * - `strip` drops extras on encode. + * - `strict` rejects extras with `unknown_keys`. + * - `passthrough` preserves extras as-is. + */ +export function object>( + shape: T, + opts?: { unknownKeys?: UnknownKeysPolicy } +): Schema<{ [K in keyof T]: InferInput }, { [K in keyof T]: InferOutput }> { + const unknownKeys = opts?.unknownKeys ?? 'strip'; + const entries = Object.entries(shape) as [keyof T & string, T[keyof T & string]][]; + const isAsync = entries.some(([, schema]) => schema['~sium'].async); + + const decodeSyncShape = (input: { [K in keyof T]: InferInput }, ctx: Ctx) => { + if (!isPlainObjectRecord(input)) { + throw new SiumValidationError([createTypeIssue('object', input, ctx.path)]); + } + + const output: Record = {}; + const issueGroups: ReadonlyArray[] = []; + + for (const [key, schema] of entries) { + const childPath = [...ctx.path, key]; + try { + output[key] = schema.decodeSync(input[key], createChildCtx(ctx, key)); + } catch (error) { + if (!(error instanceof SiumValidationError)) { + throw error; + } + + issueGroups.push(resolveChildIssues(error.issues, childPath)); + } + } + + const extraKeys = Object.keys(input).filter((key) => !(key in shape)); + if (unknownKeys === 'strict' && extraKeys.length > 0) { + issueGroups.push([createUnknownKeysIssue(extraKeys, ctx.path)]); + } + + if (issueGroups.length > 0) { + throwCollectedIssues(...issueGroups); + } + + if (unknownKeys === 'passthrough') { + for (const key of extraKeys) { + output[key] = input[key]; + } + } + + return output as { [K in keyof T]: InferOutput }; + }; + + const decodeAsyncShape = async (input: { [K in keyof T]: InferInput }, ctx: Ctx) => { + if (!isPlainObjectRecord(input)) { + throw new SiumValidationError([createTypeIssue('object', input, ctx.path)]); + } + + const output: Record = {}; + const issueGroups: ReadonlyArray[] = []; + + for (const [key, schema] of entries) { + const childPath = [...ctx.path, key]; + try { + output[key] = await schema.decode(input[key], createChildCtx(ctx, key)); + } catch (error) { + if (!(error instanceof SiumValidationError)) { + throw error; + } + + issueGroups.push(resolveChildIssues(error.issues, childPath)); + } + } + + const extraKeys = Object.keys(input).filter((key) => !(key in shape)); + if (unknownKeys === 'strict' && extraKeys.length > 0) { + issueGroups.push([createUnknownKeysIssue(extraKeys, ctx.path)]); + } + + if (issueGroups.length > 0) { + throwCollectedIssues(...issueGroups); + } + + if (unknownKeys === 'passthrough') { + for (const key of extraKeys) { + output[key] = input[key]; + } + } + + return output as { [K in keyof T]: InferOutput }; + }; + + return createSchema({ + kind: 'object', + async: isAsync, + shape: { + kind: 'object', + fields: shape, + unknownKeys + }, + decodeImpl: isAsync ? decodeAsyncShape : decodeSyncShape, + encode: (value) => { + if (!isPlainObjectRecord(value)) { + throw new TypeError(SIUM_ERRORS.OBJECT_ENCODE_EXPECTS_PLAIN_OBJECT); + } + + const output: Record = {}; + const issueGroups: ReadonlyArray[] = []; + + for (const [key, schema] of entries) { + try { + output[key] = schema.encode(value[key] as InferOutput); + } catch (error) { + if (!(error instanceof SiumValidationError)) { + throw error; + } + + issueGroups.push(prependPath(error.issues, key)); + } + } + + const extraKeys = Object.keys(value).filter((key) => !(key in shape)); + if (unknownKeys === 'strict' && extraKeys.length > 0) { + issueGroups.push([createUnknownKeysIssue(extraKeys, [])]); + } + + if (issueGroups.length > 0) { + throwCollectedIssues(...issueGroups); + } + + if (unknownKeys === 'passthrough') { + for (const key of extraKeys) { + output[key] = value[key]; + } + } + + return output as { [K in keyof T]: InferInput }; + } + }); +} diff --git a/src/arts/sium/core/refine-helpers.ts b/src/arts/sium/core/refine-helpers.ts new file mode 100644 index 0000000..a91d8d1 --- /dev/null +++ b/src/arts/sium/core/refine-helpers.ts @@ -0,0 +1,102 @@ +import { SiumValidationError, type Step } from './types'; + +export function getMeasuredValue(value: unknown): number | undefined { + if (typeof value === 'number' && !Number.isNaN(value)) { + return value; + } + + if (typeof value === 'string' || Array.isArray(value)) { + return value.length; + } + + return undefined; +} + +export function withDynamicParams( + step: Step, + getParams: (value: T) => Record +): Step { + const rewrite = (value: T, error: unknown): never => { + if (!(error instanceof SiumValidationError)) { + throw error; + } + + throw new SiumValidationError( + error.issues.map((entry) => ({ + ...entry, + params: getParams(value) + })) + ); + }; + + return { + kind: step.kind, + async: step.async, + meta: step.meta, + ...(step.encode === undefined ? {} : { encode: step.encode }), + decode: async (input, ctx) => { + try { + return await step.decode(input, ctx); + } catch (error) { + return rewrite(input, error); + } + }, + decodeSync: (input, ctx) => { + try { + return step.decodeSync(input, ctx); + } catch (error) { + return rewrite(input, error); + } + } + }; +} + +/** + * Wraps a refine step so that, on failure, the emitted issue's `code`, + * `message`, and `params` are computed from the runtime value. + */ +export function withDynamicIssue( + step: Step, + getIssue: (value: T) => { + code: string; + message: string; + params?: Record; + } +): Step { + const rewrite = (value: T, error: unknown): never => { + if (!(error instanceof SiumValidationError)) { + throw error; + } + + const overrides = getIssue(value); + throw new SiumValidationError( + error.issues.map((entry) => ({ + ...entry, + code: overrides.code, + message: overrides.message, + ...(overrides.params === undefined ? {} : { params: overrides.params }) + })) + ); + }; + + return { + kind: step.kind, + async: step.async, + meta: step.meta, + ...(step.encode === undefined ? {} : { encode: step.encode }), + decode: async (input, ctx) => { + try { + return await step.decode(input, ctx); + } catch (error) { + return rewrite(input, error); + } + }, + decodeSync: (input, ctx) => { + try { + return step.decodeSync(input, ctx); + } catch (error) { + return rewrite(input, error); + } + } + }; +} diff --git a/src/arts/sium/core/refines.ts b/src/arts/sium/core/refines.ts index c7f5956..58f4b11 100644 --- a/src/arts/sium/core/refines.ts +++ b/src/arts/sium/core/refines.ts @@ -1,112 +1,9 @@ import { refine } from './pipe'; -import { SiumValidationError, type Ctx, type Step } from './types'; - -function getMeasuredValue(value: unknown): number | undefined { - if (typeof value === 'number' && !Number.isNaN(value)) { - return value; - } - - if (typeof value === 'string' || Array.isArray(value)) { - return value.length; - } - - return undefined; -} +import type { Step } from './types'; +import { getMeasuredValue, withDynamicIssue, withDynamicParams } from './refine-helpers'; const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -function withDynamicParams( - step: Step, - getParams: (value: T) => Record -): Step { - const rewrite = (value: T, error: unknown): never => { - if (!(error instanceof SiumValidationError)) { - throw error; - } - - throw new SiumValidationError( - error.issues.map((entry) => ({ - ...entry, - params: getParams(value) - })) - ); - }; - - return { - kind: step.kind, - async: step.async, - meta: step.meta, - ...(step.encode === undefined ? {} : { encode: step.encode }), - decode: async (input, ctx) => { - try { - return await step.decode(input, ctx); - } catch (error) { - return rewrite(input, error); - } - }, - decodeSync: (input, ctx) => { - try { - return step.decodeSync(input, ctx); - } catch (error) { - return rewrite(input, error); - } - } - }; -} - -/** - * Wraps a refine step so that, on failure, the emitted issue's `code`, - * `message`, and `params` are computed from the runtime value. Used when a - * single refine semantic has multiple canonical codes depending on the shape - * of the validated value — e.g. `min(n)` emits `min_length` for strings/arrays - * and `min_value` for numbers, with different plural-aware messages. - */ -function withDynamicIssue( - step: Step, - getIssue: (value: T) => { - code: string; - message: string; - params?: Record; - } -): Step { - const rewrite = (value: T, error: unknown): never => { - if (!(error instanceof SiumValidationError)) { - throw error; - } - - const overrides = getIssue(value); - throw new SiumValidationError( - error.issues.map((entry) => ({ - ...entry, - code: overrides.code, - message: overrides.message, - ...(overrides.params === undefined ? {} : { params: overrides.params }) - })) - ); - }; - - return { - kind: step.kind, - async: step.async, - meta: step.meta, - ...(step.encode === undefined ? {} : { encode: step.encode }), - decode: async (input, ctx) => { - try { - return await step.decode(input, ctx); - } catch (error) { - return rewrite(input, error); - } - }, - decodeSync: (input, ctx) => { - try { - return step.decodeSync(input, ctx); - } catch (error) { - return rewrite(input, error); - } - } - }; -} - /** * Creates a minimum-value / minimum-length refine for numbers, strings, and arrays. * diff --git a/src/arts/sium/core/schema.ts b/src/arts/sium/core/schema.ts index 77fa47e..ef81ec8 100644 --- a/src/arts/sium/core/schema.ts +++ b/src/arts/sium/core/schema.ts @@ -1,5 +1,6 @@ import { err, isErr, ok } from './issue'; import { isPromiseLike } from './internals'; +import { SIUM_STANDARD_VENDOR } from '../consts.ts'; import { SiumAsyncSchemaError, SiumValidationError, @@ -104,7 +105,7 @@ export function createSchema(spec: CreateSchemaSpec): Schema { const schema: Schema = { '~standard': { version: 1, - vendor: 'sium', + vendor: SIUM_STANDARD_VENDOR, validate: async (value: unknown) => { const result = await validate(value as I); if (isErr(result)) { diff --git a/src/arts/sium/core/union-combinators.ts b/src/arts/sium/core/union-combinators.ts new file mode 100644 index 0000000..957a225 --- /dev/null +++ b/src/arts/sium/core/union-combinators.ts @@ -0,0 +1,171 @@ +import { createSchema } from './schema'; +import { SiumValidationError, type Ctx, type Schema } from './types'; +import { SIUM_ERRORS } from '../errors'; +import { + createDiscriminatedMissingKeyIssue, + createDiscriminatedUnknownValueIssue, + createTypeIssue, + createUnionNoMatchIssue, + isPlainObjectRecord +} from './combinator-helpers'; + +type InferInput = S extends Schema ? I : never; +type InferOutput = S extends Schema ? O : never; + +/** + * Creates a first-match union. Members are tried in declaration order. + * + * Encode semantics are intentionally identity in v1: without branding, the + * union cannot know which member should handle the output value. + */ +export function union( + ...members: M +): Schema, InferOutput> { + const isAsync = members.some((member) => member['~sium'].async); + + const decodeSyncUnion = (input: InferInput, ctx: Ctx) => { + for (const member of members) { + try { + return member.decodeSync(input, ctx) as InferOutput; + } catch (error) { + if (error instanceof SiumValidationError) { + continue; + } + + throw error; + } + } + + throw new SiumValidationError([createUnionNoMatchIssue(ctx.path)]); + }; + + const decodeAsyncUnion = async (input: InferInput, ctx: Ctx) => { + for (const member of members) { + try { + return (await member.decode(input, ctx)) as InferOutput; + } catch (error) { + if (error instanceof SiumValidationError) { + continue; + } + + throw error; + } + } + + throw new SiumValidationError([createUnionNoMatchIssue(ctx.path)]); + }; + + return createSchema({ + kind: 'union', + async: isAsync, + shape: { + kind: 'union', + members + }, + decodeImpl: isAsync ? decodeAsyncUnion : decodeSyncUnion, + encode: (value) => value as InferInput + }); +} + +type DiscriminatedMemberInfo = { + member: Schema; + literal: string | number | boolean; +}; + +function getDiscriminatedMemberInfo(key: string, member: Schema): DiscriminatedMemberInfo { + const memberShape = member['~sium'].shape; + if (member['~sium'].kind !== 'object' || memberShape?.kind !== 'object') { + throw new Error(SIUM_ERRORS.DISCRIMINATED_MEMBER_NOT_OBJECT(key)); + } + + const discriminantSchema = memberShape.fields[key]; + if (discriminantSchema === undefined) { + throw new Error(SIUM_ERRORS.DISCRIMINATED_MEMBER_MISSING_KEY(key)); + } + + const discriminantShape = discriminantSchema['~sium'].shape; + if (discriminantSchema['~sium'].kind !== 'literal' || discriminantShape?.kind !== 'literal') { + throw new Error(SIUM_ERRORS.DISCRIMINATED_MEMBER_NOT_LITERAL(key)); + } + + return { + member, + literal: discriminantShape.value + }; +} + +/** + * Creates a discriminated union keyed by one literal field shared by every member. + * + * Encode semantics delegate to the matching member based on the discriminator + * value. When no member matches, `encode` throws `TypeError` (programmer error). + */ +export function discriminated( + key: K, + members: M +): Schema, InferOutput> { + const memberInfos = members.map((member) => getDiscriminatedMemberInfo(key, member)); + const expected = memberInfos.map((entry) => entry.literal); + const isAsync = members.some((member) => member['~sium'].async); + + const decodeSyncDiscriminated = (input: InferInput, ctx: Ctx) => { + if (!isPlainObjectRecord(input)) { + throw new SiumValidationError([createTypeIssue('object', input, ctx.path)]); + } + + if (!(key in input) || input[key] === undefined) { + throw new SiumValidationError([createDiscriminatedMissingKeyIssue(key, ctx.path)]); + } + + const info = memberInfos.find((entry) => entry.literal === input[key]); + if (info === undefined) { + throw new SiumValidationError([ + createDiscriminatedUnknownValueIssue(key, input[key], expected, ctx.path) + ]); + } + + return info.member.decodeSync(input, ctx) as InferOutput; + }; + + const decodeAsyncDiscriminated = async (input: InferInput, ctx: Ctx) => { + if (!isPlainObjectRecord(input)) { + throw new SiumValidationError([createTypeIssue('object', input, ctx.path)]); + } + + if (!(key in input) || input[key] === undefined) { + throw new SiumValidationError([createDiscriminatedMissingKeyIssue(key, ctx.path)]); + } + + const info = memberInfos.find((entry) => entry.literal === input[key]); + if (info === undefined) { + throw new SiumValidationError([ + createDiscriminatedUnknownValueIssue(key, input[key], expected, ctx.path) + ]); + } + + return (await info.member.decode(input, ctx)) as InferOutput; + }; + + return createSchema({ + kind: 'discriminated', + async: isAsync, + shape: { + kind: 'discriminated', + key, + members + }, + decodeImpl: isAsync ? decodeAsyncDiscriminated : decodeSyncDiscriminated, + encode: (value) => { + if (!isPlainObjectRecord(value)) { + throw new TypeError(SIUM_ERRORS.DISCRIMINATED_ENCODE_EXPECTS_PLAIN_OBJECT); + } + + const info = memberInfos.find((entry) => entry.literal === value[key]); + if (info === undefined) { + throw new TypeError(SIUM_ERRORS.DISCRIMINATED_ENCODE_NO_MATCH(key)); + } + + return info.member.encode(value as InferOutput) as InferInput; + } + }); +} diff --git a/src/arts/sium/diagnostics.ts b/src/arts/sium/diagnostics.ts new file mode 100644 index 0000000..04e7358 --- /dev/null +++ b/src/arts/sium/diagnostics.ts @@ -0,0 +1,72 @@ +import { + LogLevel, + createCatalogDiagnostics, + type DiagnosticCatalog, + type DiagnosticEvent, + type Diagnostics, + type Logger +} from '$libs/logr'; +import { LOGGER_CATEGORY, SIUM_DIAGNOSTIC_EVENTS } from './consts'; +import { SIUM_ERRORS } from './errors'; +import type { Issue } from './core'; + +export type SiumDiagnosticType = + (typeof SIUM_DIAGNOSTIC_EVENTS)[keyof typeof SIUM_DIAGNOSTIC_EVENTS]; + +export interface SiumDiagnosticMeta { + readonly message?: string; + readonly error?: unknown; + readonly issueCount?: number; + readonly issues?: ReadonlyArray<{ + readonly path: ReadonlyArray; + readonly code: string; + readonly message: string; + readonly params?: Record; + }>; +} + +export type SiumDiagnosticEvent = DiagnosticEvent; +export type SiumDiagnostics = Diagnostics; + +const SIUM_DIAGNOSTIC_LOGS: DiagnosticCatalog = { + [SIUM_DIAGNOSTIC_EVENTS.VALIDATION_FAILED]: { + level: LogLevel.DEBUG, + message: SIUM_ERRORS.VALIDATION_FAILED + }, + [SIUM_DIAGNOSTIC_EVENTS.RESOLVE_FALLBACK]: { + level: LogLevel.WARN, + message: SIUM_ERRORS.RESOLVE_FALLBACK + } +}; + +export function issueDiagnosticMeta(issues: ReadonlyArray): SiumDiagnosticMeta { + return { + issueCount: issues.length, + issues: issues.map((entry) => ({ + path: [...entry.path], + code: entry.code, + message: entry.message, + ...(entry.params === undefined ? {} : { params: entry.params }) + })) + }; +} + +export function createSiumDiagnostics(logger?: Logger): SiumDiagnostics { + return createCatalogDiagnostics({ + logger, + defaultCategory: LOGGER_CATEGORY, + catalog: SIUM_DIAGNOSTIC_LOGS + }); +} + +export function emitSiumDiagnostic( + diagnostics: SiumDiagnostics, + type: SiumDiagnosticType, + meta: SiumDiagnosticMeta +): void { + diagnostics.emit({ + artifact: LOGGER_CATEGORY, + type, + meta + }); +} diff --git a/src/arts/sium/engine-sium.ts b/src/arts/sium/engine-sium.ts index 6550da6..300d687 100644 --- a/src/arts/sium/engine-sium.ts +++ b/src/arts/sium/engine-sium.ts @@ -1,8 +1,8 @@ import type { EngineLang, LangParams, SupportedLocale } from '$lang'; import { ID_FALLBACK_SEPARATOR, parseLangRef } from '$lang'; -import type { EngineLogger } from '$logr'; -import { LOGGER_CATEGORY } from './consts'; -import { SIUM_ERRORS } from './errors'; +import type { Logger } from '$libs/logr'; +import { SIUM_DIAGNOSTIC_EVENTS } from './consts'; +import { createSiumDiagnostics, emitSiumDiagnostic, issueDiagnosticMeta } from './diagnostics'; import { array, boolean, @@ -63,7 +63,7 @@ import { ISSUE_CODES, resolve as resolveFallbackTemplate, siumLangs } from './la export interface EngineSiumOptions { lang?: EngineLang; - logger?: EngineLogger; + logger?: Logger; locale?: SupportedLocale; } @@ -146,18 +146,6 @@ function fallbackMessage(message: string, params?: LangParams): string { return resolveFallbackTemplate(parsed?.fallback ?? parsed?.path ?? message, params); } -function issueDebugContext(issues: ReadonlyArray) { - return { - issueCount: issues.length, - issues: issues.map((entry) => ({ - path: [...entry.path], - code: entry.code, - message: entry.message, - ...(entry.params === undefined ? {} : { params: entry.params }) - })) - }; -} - /** * Public Sium facade. * @@ -166,6 +154,7 @@ function issueDebugContext(issues: ReadonlyArray) { */ export function createEngineSium(options: EngineSiumOptions = {}): EngineSium { const { lang, logger } = options; + const diagnostics = createSiumDiagnostics(logger); const defaultLocale = options.locale ?? lang?.getDefaultLocale() ?? 'es'; const resolveMessage: EngineSium['resolve'] = (message, params, locale = defaultLocale) => { @@ -177,8 +166,8 @@ export function createEngineSium(options: EngineSiumOptions = {}): EngineSium { try { return lang.t(langPath, params, locale); } catch (error) { - logger?.warn(LOGGER_CATEGORY, SIUM_ERRORS.RESOLVE_FALLBACK, { - context: { message }, + emitSiumDiagnostic(diagnostics, SIUM_DIAGNOSTIC_EVENTS.RESOLVE_FALLBACK, { + message, error }); return fallbackMessage(message, params); @@ -196,9 +185,11 @@ export function createEngineSium(options: EngineSiumOptions = {}): EngineSium { const validate: EngineSium['validate'] = async (schema, input, ctx) => { const result = await schema.validate(input, ctx); if (!result.ok) { - logger?.debug(LOGGER_CATEGORY, SIUM_ERRORS.VALIDATION_FAILED, { - context: issueDebugContext(result.issues) - }); + emitSiumDiagnostic( + diagnostics, + SIUM_DIAGNOSTIC_EVENTS.VALIDATION_FAILED, + issueDiagnosticMeta(result.issues) + ); } return result; }; @@ -206,9 +197,11 @@ export function createEngineSium(options: EngineSiumOptions = {}): EngineSium { const validateSync: EngineSium['validateSync'] = (schema, input, ctx) => { const result = schema.validateSync(input, ctx); if (!result.ok) { - logger?.debug(LOGGER_CATEGORY, SIUM_ERRORS.VALIDATION_FAILED, { - context: issueDebugContext(result.issues) - }); + emitSiumDiagnostic( + diagnostics, + SIUM_DIAGNOSTIC_EVENTS.VALIDATION_FAILED, + issueDiagnosticMeta(result.issues) + ); } return result; }; diff --git a/src/arts/sium/errors.ts b/src/arts/sium/errors.ts index 6d543c4..7f79d56 100644 --- a/src/arts/sium/errors.ts +++ b/src/arts/sium/errors.ts @@ -1,4 +1,18 @@ export const SIUM_ERRORS = { VALIDATION_FAILED: '[sium] Validation failed.', - RESOLVE_FALLBACK: '[sium] Falling back while resolving Sium message.' + RESOLVE_FALLBACK: '[sium] Falling back while resolving Sium message.', + OBJECT_ENCODE_EXPECTS_PLAIN_OBJECT: 'object().encode expects a plain object value', + ARRAY_ENCODE_EXPECTS_ARRAY: 'array().encode expects an array value', + LAZY_RESOLVING_ACCESS: + 'lazy() cannot decode, encode, or validate while its schema thunk is still resolving', + DISCRIMINATED_MEMBER_NOT_OBJECT: (key: string): string => + `discriminated(${key}) members must be object() schemas`, + DISCRIMINATED_MEMBER_MISSING_KEY: (key: string): string => + `discriminated(${key}) members must declare the discriminator field`, + DISCRIMINATED_MEMBER_NOT_LITERAL: (key: string): string => + `discriminated(${key}) members must use literal() on the discriminator field`, + DISCRIMINATED_ENCODE_EXPECTS_PLAIN_OBJECT: + 'discriminated().encode expects a plain object value', + DISCRIMINATED_ENCODE_NO_MATCH: (key: string): string => + `discriminated().encode could not match discriminator ${key}` } as const; diff --git a/src/arts/sium/index.ts b/src/arts/sium/index.ts index 358bdd6..3ddb444 100644 --- a/src/arts/sium/index.ts +++ b/src/arts/sium/index.ts @@ -6,8 +6,9 @@ export { createEngineSium } from './engine-sium'; export type { EngineSium, EngineSiumOptions } from './engine-sium'; +export { createSiumDiagnostics, emitSiumDiagnostic, issueDiagnosticMeta } from './diagnostics'; -export { LOGGER_CATEGORY } from './consts'; +export { LOGGER_CATEGORY, SIUM_DIAGNOSTIC_EVENTS } from './consts'; export { SIUM_ERRORS } from './errors'; // core @@ -89,3 +90,9 @@ export { // langs export { ISSUE_CODES, resolve, siumLangs } from './langs'; export type { IssueCode } from './langs'; +export type { + SiumDiagnosticEvent, + SiumDiagnosticMeta, + SiumDiagnostics, + SiumDiagnosticType +} from './diagnostics'; diff --git a/src/arts/stor/README.md b/src/arts/stor/README.md index 6049130..feb9084 100644 --- a/src/arts/stor/README.md +++ b/src/arts/stor/README.md @@ -554,9 +554,9 @@ const Storage = createEngineStorage({ Operations: `read | write | remove | serialize | deserialize | migrate | validate`. -Default handler: `console.error('[storage] ...')`. When used from `aapp`, -the App's logger is wired in: failures land under -`App.Logger.error('storage', ...)`. +Default handler: diagnostics-only. When used from `aapp`, the shared +`App.Logger` is injected, so failures land through `StorageDiagnostics` with +the storage diagnostic context. ## Auto-serializers diff --git a/src/arts/stor/adapters/cookie.ts b/src/arts/stor/adapters/cookie.ts index 3432504..1e8c7f5 100644 --- a/src/arts/stor/adapters/cookie.ts +++ b/src/arts/stor/adapters/cookie.ts @@ -103,8 +103,7 @@ function clientCookieAdapter(options: CookieAdapterOptions = {}): SyncStorageAda }, setItem(key, value) { if (!isBrowser) { - console.warn(STORAGE_ERRORS.COOKIE_SERVER_REQUIRED); - return; + throw new Error(STORAGE_ERRORS.COOKIE_SERVER_REQUIRED); } document.cookie = buildSetCookieString(encode(key), encode(value), options); }, diff --git a/src/arts/stor/consts.ts b/src/arts/stor/consts.ts index e50aff6..bd724b2 100644 --- a/src/arts/stor/consts.ts +++ b/src/arts/stor/consts.ts @@ -67,5 +67,9 @@ export const NAMESPACE_SEPARATOR = ':'; export const ENVELOPE_ERROR_VERSION_NOT_NUMBER = 'envelope.v not a number'; +export const STORAGE_DIAGNOSTIC_EVENTS = { + ERROR: 'storage.error' +} as const; + export const defaultStorageErrorMessage = (op: string, fullKey: string): string => `[${LOGGER_CATEGORY}] ${op} on "${fullKey}":`; diff --git a/src/arts/stor/diagnostics.ts b/src/arts/stor/diagnostics.ts new file mode 100644 index 0000000..53ee117 --- /dev/null +++ b/src/arts/stor/diagnostics.ts @@ -0,0 +1,46 @@ +import { + LogLevel, + createCatalogDiagnostics, + type DiagnosticCatalog, + type DiagnosticEvent, + type Diagnostics, + type Logger +} from '$libs/logr'; +import { LOGGER_CATEGORY, STORAGE_DIAGNOSTIC_EVENTS, defaultStorageErrorMessage } from './consts'; +import type { StorageErrorContext } from './types'; + +export type StorageDiagnosticType = + (typeof STORAGE_DIAGNOSTIC_EVENTS)[keyof typeof STORAGE_DIAGNOSTIC_EVENTS]; + +export type StorageDiagnosticEvent = DiagnosticEvent; +export type StorageDiagnostics = Diagnostics; + +const STORAGE_DIAGNOSTIC_LOGS: DiagnosticCatalog = { + [STORAGE_DIAGNOSTIC_EVENTS.ERROR]: (event) => ({ + level: LogLevel.WARN, + message: defaultStorageErrorMessage( + event.meta?.op ?? LOGGER_CATEGORY, + event.meta?.fullKey ?? '' + ) + }) +}; + +export function createStorageDiagnostics(logger?: Logger): StorageDiagnostics { + return createCatalogDiagnostics({ + logger, + defaultCategory: LOGGER_CATEGORY, + catalog: STORAGE_DIAGNOSTIC_LOGS + }); +} + +export function emitStorageDiagnostic( + diagnostics: StorageDiagnostics, + type: StorageDiagnosticType, + meta: StorageErrorContext +): void { + diagnostics.emit({ + artifact: LOGGER_CATEGORY, + type, + meta + }); +} diff --git a/src/arts/stor/engine-storage.ts b/src/arts/stor/engine-storage.ts index c0a01a2..81f2f05 100644 --- a/src/arts/stor/engine-storage.ts +++ b/src/arts/stor/engine-storage.ts @@ -11,8 +11,9 @@ import { STORAGE_OP_SERIALIZE, STORAGE_OP_VALIDATE, STORAGE_OP_WRITE, - defaultStorageErrorMessage + STORAGE_DIAGNOSTIC_EVENTS } from './consts'; +import { createStorageDiagnostics, emitStorageDiagnostic } from './diagnostics'; import { decodeEnvelope, encodeEnvelope } from './envelope'; import { STORAGE_ERRORS } from './errors'; import { autoSelectSerializer } from './serializers'; @@ -30,10 +31,6 @@ import type { } from './types'; import { isPromiseLike, type StandardSchemaV1 } from '$libs/standard-schema'; -const defaultOnError: StorageErrorHandler = (ctx) => { - console.error(defaultStorageErrorMessage(ctx.op, ctx.fullKey), ctx.error); -}; - function isStandardSchema(v: StorageValidator): v is StandardSchemaV1 { return typeof v === 'object' && v !== null && '~standard' in v; } @@ -52,7 +49,8 @@ export const _entryBusKeys = new WeakMap, string>(); export function createEngineStorage(options: EngineStorageOptions = {}): EngineStorage { const adapter: SyncStorageAdapter = options.adapter ?? createMemoryAdapter(); const namespace = options.namespace; - const onError = options.onError ?? defaultOnError; + const diagnostics = createStorageDiagnostics(options.logger); + const onError = options.onError; const bus: EntryBus = createEntryBus(); const adapterIds = new WeakMap(); let adapterIdSeq = 0; @@ -107,8 +105,10 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS } function report(ctx: Omit, entryAdapterName: string): void { + const errorContext = { ...ctx, adapter: entryAdapterName }; + emitStorageDiagnostic(diagnostics, STORAGE_DIAGNOSTIC_EVENTS.ERROR, errorContext); try { - onError({ ...ctx, adapter: entryAdapterName }); + onError?.(errorContext); } catch { // Never let the error handler itself break the engine. } diff --git a/src/arts/stor/index.ts b/src/arts/stor/index.ts index 5c474b6..42d62ca 100644 --- a/src/arts/stor/index.ts +++ b/src/arts/stor/index.ts @@ -1,12 +1,14 @@ export { createEngineStorage } from './engine-storage'; export { createActiveStorage } from './active-storage.svelte'; +export { createStorageDiagnostics, emitStorageDiagnostic } from './diagnostics'; export { LOGGER_CATEGORY, DEFAULT_VERSION, NAMESPACE_SEPARATOR, ENVELOPE_KEY_VERSION, ENVELOPE_KEY_DATA, - ENVELOPE_KEY_TTL + ENVELOPE_KEY_TTL, + STORAGE_DIAGNOSTIC_EVENTS } from './consts'; export { STORAGE_ERRORS } from './errors'; @@ -28,6 +30,11 @@ export type { ActiveStorage, EngineStorageOptions } from './types'; +export type { + StorageDiagnosticEvent, + StorageDiagnostics, + StorageDiagnosticType +} from './diagnostics'; export { decodeEnvelope, encodeEnvelope, type EnvelopeRead } from './envelope'; export { @@ -50,10 +57,6 @@ export { type CookieAdapterFactory, type ServerCookiesLike } from './adapters/cookie'; -export { - withBroadcast, - type BroadcastAdapter, - type BroadcastOptions -} from './adapters/broadcast'; +export { withBroadcast, type BroadcastAdapter, type BroadcastOptions } from './adapters/broadcast'; export type { DynamicEntryOptions } from './active-storage.svelte'; diff --git a/src/arts/stor/types.ts b/src/arts/stor/types.ts index 489a0b9..3352712 100644 --- a/src/arts/stor/types.ts +++ b/src/arts/stor/types.ts @@ -1,4 +1,5 @@ import type { StandardSchemaV1 } from '$libs/standard-schema'; +import type { Logger } from '$libs/logr'; import type { STORAGE_OPS } from './consts'; // ── Adapter ──────────────────────────────────────────────────────────────── @@ -159,6 +160,7 @@ export interface ActiveStorageEntry extends StorageEntry { export interface EngineStorageOptions { adapter?: SyncStorageAdapter; namespace?: string; + logger?: Logger; onError?: StorageErrorHandler; } diff --git a/src/arts/timr/DESIGN_TIMR.md b/src/arts/timr/DESIGN_TIMR.md index 83b0da2..06a3aba 100644 --- a/src/arts/timr/DESIGN_TIMR.md +++ b/src/arts/timr/DESIGN_TIMR.md @@ -656,12 +656,7 @@ interface InternalTimerEntry { ```ts export interface EngineTimersOptions { readonly clock?: TimerClock; - readonly logger?: TimerLogger; -} - -export interface TimerLogger { - debug?(scope: string, message: string, meta?: unknown): void; - error?(scope: string, message: string, meta?: unknown): void; + readonly logger?: Logger; } export function createEngineTimers( diff --git a/src/arts/timr/README.md b/src/arts/timr/README.md index efd912e..79aa3b0 100644 --- a/src/arts/timr/README.md +++ b/src/arts/timr/README.md @@ -186,7 +186,7 @@ Runes-free engine. Safe to import server-side. ```ts const Timers = createEngineTimers({ clock?: TimerClock, // default: system clock - logger?: TimerLogger // optional debug/error sink + logger?: Logger // shared logger contract from $libs/logr }); ``` diff --git a/src/arts/timr/consts.ts b/src/arts/timr/consts.ts index c3e164a..aefaea7 100644 --- a/src/arts/timr/consts.ts +++ b/src/arts/timr/consts.ts @@ -11,6 +11,12 @@ /** Logger category emitted by the engine. */ export const LOGGER_CATEGORY = 'timr'; +export const TIMER_DIAGNOSTIC_EVENTS = { + LISTENER_THREW: 'listener_threw', + SCHEDULE_AT_PAST: 'schedule_at_past', + TASK_FAILED: 'task_failed' +} as const; + export const ENGINE_METHOD_SCHEDULE = 'schedule'; export const ENGINE_METHOD_SCHEDULE_AT = 'scheduleAt'; export const ENGINE_METHOD_CANCEL = 'cancel'; diff --git a/src/arts/timr/diagnostics.ts b/src/arts/timr/diagnostics.ts new file mode 100644 index 0000000..cd7e573 --- /dev/null +++ b/src/arts/timr/diagnostics.ts @@ -0,0 +1,83 @@ +import { + LogLevel, + createCatalogDiagnostics, + type DiagnosticCatalog, + type DiagnosticEvent, + type Diagnostics +} from '$libs/logr'; +import { + LOGGER_CATEGORY, + LOG_MSG_SCHEDULE_AT_PAST, + TIMER_DIAGNOSTIC_EVENTS, + TIMER_EVENT_FAILED, + listenerThrewMessage, + taskFailedMessage +} from './consts.ts'; +import type { Logger } from '$libs/logr'; +import type { TimerEvent } from './types.ts'; + +export type TimerDiagnosticType = + (typeof TIMER_DIAGNOSTIC_EVENTS)[keyof typeof TIMER_DIAGNOSTIC_EVENTS]; + +export type TimerDiagnosticMeta = + | { + readonly error: unknown; + readonly event: TimerEvent['type']; + } + | { + readonly error: unknown; + readonly key: string; + } + | { + readonly key: string; + readonly dueAt: number; + readonly now: number; + }; + +export type TimerDiagnosticEvent = DiagnosticEvent; +export type TimerDiagnostics = Diagnostics; + +const TIMER_DIAGNOSTIC_LOGS: DiagnosticCatalog = { + [TIMER_DIAGNOSTIC_EVENTS.LISTENER_THREW]: (event) => ({ + level: LogLevel.ERROR, + message: listenerThrewMessage(timerEventName(event.meta)) + }), + [TIMER_DIAGNOSTIC_EVENTS.SCHEDULE_AT_PAST]: { + level: LogLevel.DEBUG, + message: LOG_MSG_SCHEDULE_AT_PAST + }, + [TIMER_DIAGNOSTIC_EVENTS.TASK_FAILED]: (event) => ({ + level: LogLevel.ERROR, + message: taskFailedMessage(timerKeyName(event.meta)) + }) +}; + +function timerEventName(meta: TimerDiagnosticMeta | undefined): TimerEvent['type'] { + if (meta !== undefined && 'event' in meta) return meta.event; + return TIMER_EVENT_FAILED; +} + +function timerKeyName(meta: TimerDiagnosticMeta | undefined): string { + if (meta !== undefined && 'key' in meta) return meta.key; + return LOGGER_CATEGORY; +} + +export function createTimerDiagnostics(logger?: Logger): TimerDiagnostics { + return createCatalogDiagnostics({ + logger, + defaultCategory: LOGGER_CATEGORY, + catalog: TIMER_DIAGNOSTIC_LOGS + }); +} + +export function emitTimerDiagnostic( + diagnostics: TimerDiagnostics, + type: TimerDiagnosticType, + meta: TimerDiagnosticMeta +): void { + diagnostics.emit({ + artifact: LOGGER_CATEGORY, + type, + meta + }); +} diff --git a/src/arts/timr/engine-timers.ts b/src/arts/timr/engine-timers.ts index 4b23873..ff7202b 100644 --- a/src/arts/timr/engine-timers.ts +++ b/src/arts/timr/engine-timers.ts @@ -24,18 +24,13 @@ import { createSystemTimerClock } from './clock.ts'; import { - disposedErrorMessage, duplicateKeyErrorMessage, ENGINE_METHOD_CANCEL, ENGINE_METHOD_CANCEL_ALL, ENGINE_METHOD_SCHEDULE, ENGINE_METHOD_SCHEDULE_AT, inactiveTimerErrorMessage, - invalidDelayErrorMessage, - invalidKeyErrorMessage, - LOG_MSG_SCHEDULE_AT_PAST, - LOGGER_CATEGORY, - DEFAULT_TIMER_SCOPE_SEPARATOR, + TIMER_DIAGNOSTIC_EVENTS, TIMER_EVENT_CANCELLED, TIMER_EVENT_COMPLETED, TIMER_EVENT_DISPOSED, @@ -48,17 +43,20 @@ import { TIMER_STATUS_COMPLETED, TIMER_STATUS_FAILED, TIMER_STATUS_PENDING, - TIMER_STATUS_RUNNING, - listenerThrewMessage, - taskFailedMessage + TIMER_STATUS_RUNNING } from './consts.ts'; +import { createTimerDiagnostics, emitTimerDiagnostic } from './diagnostics.ts'; +import { TimrDuplicateKeyError, TimrInactiveTimerError } from './errors.ts'; import { - TimrDisposedError, - TimrDuplicateKeyError, - TimrInactiveTimerError, - TimrInvalidDelayError, - TimrInvalidKeyError -} from './errors.ts'; + isTimerKeyInScope, + timerEntrySnapshot, + type InternalTimerEntry +} from './timer-entry.ts'; +import { + assertTimerDelay, + assertTimerEngineLive, + assertTimerKey +} from './timer-validation.ts'; import type { EngineTimers, EngineTimersOptions, @@ -69,41 +67,14 @@ import type { TimerIntervalOptions, TimerKind, TimerListener, - TimerLogger, - TimerNativeHandle, TimerOptions, - TimerStatus, TimerTask, TimerTaskContext } from './types.ts'; -interface InternalTimerEntry { - id: number; - key: string; - 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>; -} - export function createEngineTimers(options: EngineTimersOptions = {}): EngineTimers { const clock: TimerClock = options.clock ?? createSystemTimerClock(); - const logger: TimerLogger | undefined = options.logger; + const diagnostics = createTimerDiagnostics(options.logger); const entries = new Map(); const listeners = new Set(); @@ -111,47 +82,8 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim let disposed = false; let nextId = 1; - // ── Validation ────────────────────────────────────────────────────────── - function ensureLive(method: string): void { - if (disposed) throw new TimrDisposedError(disposedErrorMessage(method)); - } - - function validateKey(key: unknown): void { - if (typeof key !== 'string' || key.length === 0) { - throw new TimrInvalidKeyError(invalidKeyErrorMessage(key)); - } - } - - function validateDelay(delayMs: unknown): void { - if (typeof delayMs !== 'number' || !Number.isFinite(delayMs) || delayMs < 0) { - throw new TimrInvalidDelayError(invalidDelayErrorMessage(delayMs)); - } - } - - // ── Snapshot + scope ──────────────────────────────────────────────────── - - function scopeOf(key: string): string { - const idx = key.lastIndexOf(DEFAULT_TIMER_SCOPE_SEPARATOR); - return idx === -1 ? key : key.slice(0, idx); - } - - function snapshotOf(entry: InternalTimerEntry): TimerEntrySnapshot { - return { - key: entry.key, - scope: scopeOf(entry.key), - kind: entry.kind, - status: entry.status, - scheduledAt: entry.scheduledAt, - dueAt: entry.dueAt, - delayMs: entry.delayMs, - runCount: entry.runCount, - lastFiredAt: entry.lastFiredAt, - lastCompletedAt: entry.lastCompletedAt, - lastErrorAt: entry.lastErrorAt, - error: entry.error, - meta: entry.meta - }; + assertTimerEngineLive(disposed, method); } // ── Listener fan-out ──────────────────────────────────────────────────── @@ -161,7 +93,10 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim try { listener(event); } catch (err) { - logger?.error?.(LOGGER_CATEGORY, listenerThrewMessage(event.type), { error: err }); + emitTimerDiagnostic(diagnostics, TIMER_DIAGNOSTIC_EVENTS.LISTENER_THREW, { + error: err, + event: event.type + }); } } } @@ -220,7 +155,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim entry.runCount += 1; entry.native = null; const ctx = makeContext(entry, firedAt); - emit({ type: TIMER_EVENT_RUNNING, entry: snapshotOf(entry) }); + emit({ type: TIMER_EVENT_RUNNING, entry: timerEntrySnapshot(entry) }); const isInterval = entry.kind === TIMER_KIND_INTERVAL; const awaitTask = !isInterval || entry.awaitTask; @@ -263,14 +198,15 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim if (error === null) { entry.lastCompletedAt = now; entry.error = null; - emit({ type: TIMER_EVENT_COMPLETED, entry: snapshotOf(entry) }); + emit({ type: TIMER_EVENT_COMPLETED, entry: timerEntrySnapshot(entry) }); } else { entry.lastErrorAt = now; entry.error = error; - logger?.error?.(LOGGER_CATEGORY, taskFailedMessage(entry.key), { - error + emitTimerDiagnostic(diagnostics, TIMER_DIAGNOSTIC_EVENTS.TASK_FAILED, { + error, + key: entry.key }); - emit({ type: TIMER_EVENT_FAILED, entry: snapshotOf(entry), error }); + emit({ type: TIMER_EVENT_FAILED, entry: timerEntrySnapshot(entry), error }); } if (isInterval) { @@ -318,7 +254,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim } entry.controller.abort(); entries.delete(entry.key); - emit({ type: TIMER_EVENT_CANCELLED, entry: snapshotOf(entry) }); + emit({ type: TIMER_EVENT_CANCELLED, entry: timerEntrySnapshot(entry) }); } // ── Public scheduling ─────────────────────────────────────────────────── @@ -338,8 +274,8 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim maxRuns: number | undefined ): TimerHandle { ensureLive(ENGINE_METHOD_SCHEDULE); - validateKey(key); - validateDelay(delayMs); + assertTimerKey(key); + assertTimerDelay(delayMs); const replace = options?.replace === true; const existing = entries.get(key); @@ -375,7 +311,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim entries.set(key, entry); armEntry(entry, delayMs); - emit({ type: TIMER_EVENT_SCHEDULED, entry: snapshotOf(entry) }); + emit({ type: TIMER_EVENT_SCHEDULED, entry: timerEntrySnapshot(entry) }); return makeHandle(entry); } @@ -404,7 +340,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim if (live.status === TIMER_STATUS_RUNNING) { throw new TimrInactiveTimerError(inactiveTimerErrorMessage(key), key); } - validateDelay(delayMs); + assertTimerDelay(delayMs); if (live.native !== null) { clock.clearTimeout(live.native); live.native = null; @@ -415,17 +351,13 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim live.dueAt = now + delayMs; live.status = TIMER_STATUS_PENDING; armEntry(live, delayMs); - emit({ type: TIMER_EVENT_SCHEDULED, entry: snapshotOf(live) }); + emit({ type: TIMER_EVENT_SCHEDULED, entry: timerEntrySnapshot(live) }); } }; } // ── Public API ────────────────────────────────────────────────────────── - function isInScope(key: string, scope: string): boolean { - return key === scope || key.startsWith(`${scope}${DEFAULT_TIMER_SCOPE_SEPARATOR}`); - } - const engine: EngineTimers = { get clock() { return clock; @@ -447,7 +379,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim const now = clock.now(); let delayMs = dueAt - now; if (delayMs < 0) { - logger?.debug?.(LOGGER_CATEGORY, LOG_MSG_SCHEDULE_AT_PAST, { + emitTimerDiagnostic(diagnostics, TIMER_DIAGNOSTIC_EVENTS.SCHEDULE_AT_PAST, { key, dueAt, now @@ -486,7 +418,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim // Snapshot keys to avoid mutating during iteration. const keys: string[] = []; for (const k of entries.keys()) { - if (scope === undefined || isInScope(k, scope)) keys.push(k); + if (scope === undefined || isTimerKeyInScope(k, scope)) keys.push(k); } for (const k of keys) { const entry = entries.get(k); @@ -507,13 +439,13 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim entries() { const out: TimerEntrySnapshot[] = []; - for (const entry of entries.values()) out.push(snapshotOf(entry)); + for (const entry of entries.values()) out.push(timerEntrySnapshot(entry)); return out; }, entry(key) { const e = entries.get(key); - return e === undefined ? null : snapshotOf(e); + return e === undefined ? null : timerEntrySnapshot(e); }, onChange(listener) { @@ -537,3 +469,4 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim return engine; } + diff --git a/src/arts/timr/index.ts b/src/arts/timr/index.ts index 112fdee..032d2b1 100644 --- a/src/arts/timr/index.ts +++ b/src/arts/timr/index.ts @@ -6,6 +6,7 @@ export { createEngineTimers } from './engine-timers.ts'; export { createActiveTimers, type ActiveTimers } from './active-timers.svelte.ts'; export { createSystemTimerClock } from './clock.ts'; export { computeBackoffDelay } from './backoff.ts'; +export { createTimerDiagnostics, emitTimerDiagnostic } from './diagnostics.ts'; export { DEFAULT_BACKOFF_FACTOR, @@ -14,6 +15,7 @@ export { DEFAULT_BACKOFF_MIN_DELAY_MS, DEFAULT_TIMER_SCOPE_SEPARATOR, LOGGER_CATEGORY, + TIMER_DIAGNOSTIC_EVENTS, TIMER_EVENT_CANCELLED, TIMER_EVENT_COMPLETED, TIMER_EVENT_DISPOSED, @@ -44,6 +46,13 @@ export { isTimrInvalidKeyError } from './errors.ts'; +export type { + TimerDiagnosticEvent, + TimerDiagnosticMeta, + TimerDiagnosticType, + TimerDiagnostics +} from './diagnostics.ts'; + export type { BackoffOptions, EngineTimers, @@ -55,7 +64,6 @@ export type { TimerIntervalOptions, TimerKind, TimerListener, - TimerLogger, TimerNativeHandle, TimerOptions, TimerScheduler, diff --git a/src/arts/timr/test/engine-timers.test.ts b/src/arts/timr/test/engine-timers.test.ts index e755435..8cf1235 100644 --- a/src/arts/timr/test/engine-timers.test.ts +++ b/src/arts/timr/test/engine-timers.test.ts @@ -12,6 +12,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SILENT_LOGGER } from '$libs/logr'; import { createEngineTimers } from '../engine-timers.ts'; import { isTimrDisposedError, @@ -20,12 +21,7 @@ import { isTimrInvalidDelayError, isTimrInvalidKeyError } from '../errors.ts'; -import type { - TimerClock, - TimerEvent, - TimerNativeHandle, - TimerTaskContext -} from '../types.ts'; +import type { TimerClock, TimerEvent, TimerNativeHandle, TimerTaskContext } from '../types.ts'; // ── Fake clock ────────────────────────────────────────────────────────────── @@ -248,7 +244,7 @@ describe('scheduleAt', () => { it('past dueAt schedules with delay 0 (no error)', async () => { const clock = createFakeClock(5_000); const debug = vi.fn(); - const t = createEngineTimers({ clock, logger: { debug } }); + const t = createEngineTimers({ clock, logger: { ...SILENT_LOGGER, debug } }); let fired = false; t.scheduleAt('past', 1_000, () => { fired = true; @@ -330,7 +326,7 @@ describe('interval', () => { // and does not stop the interval when a task throws. it('awaitTask:false continues ticking after task failure', async () => { const clock = createFakeClock(); - const t = createEngineTimers({ clock, logger: { error: () => {} } }); + const t = createEngineTimers({ clock, logger: { ...SILENT_LOGGER, error: () => {} } }); const { events, listener } = captureEvents(); t.onChange(listener); @@ -690,7 +686,7 @@ describe('error path — task failures', () => { it('emits failed and continues for intervals', async () => { const clock = createFakeClock(); const errors = vi.fn(); - const t = createEngineTimers({ clock, logger: { error: errors } }); + const t = createEngineTimers({ clock, logger: { ...SILENT_LOGGER, error: errors } }); const { events, listener } = captureEvents(); t.onChange(listener); @@ -709,7 +705,7 @@ describe('error path — task failures', () => { it('one-shot failure is terminal (entry removed)', async () => { const clock = createFakeClock(); - const t = createEngineTimers({ clock, logger: { error: () => {} } }); + const t = createEngineTimers({ clock, logger: { ...SILENT_LOGGER, error: () => {} } }); t.schedule('one-fail', 100, () => { throw new Error('nope'); }); @@ -720,7 +716,7 @@ describe('error path — task failures', () => { it('listener throwing does not break the engine', async () => { const clock = createFakeClock(); const errors = vi.fn(); - const t = createEngineTimers({ clock, logger: { error: errors } }); + const t = createEngineTimers({ clock, logger: { ...SILENT_LOGGER, error: errors } }); t.onChange(() => { throw new Error('listener boom'); }); diff --git a/src/arts/timr/timer-entry.ts b/src/arts/timr/timer-entry.ts new file mode 100644 index 0000000..3289317 --- /dev/null +++ b/src/arts/timr/timer-entry.ts @@ -0,0 +1,59 @@ +import { DEFAULT_TIMER_SCOPE_SEPARATOR } from './consts.ts'; +import type { + TimerEntrySnapshot, + TimerKind, + TimerNativeHandle, + TimerStatus, + TimerTask +} from './types.ts'; + +export interface InternalTimerEntry { + id: number; + key: string; + 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>; +} + +export function scopeOfTimerKey(key: string): string { + const idx = key.lastIndexOf(DEFAULT_TIMER_SCOPE_SEPARATOR); + return idx === -1 ? key : key.slice(0, idx); +} + +export function timerEntrySnapshot(entry: InternalTimerEntry): TimerEntrySnapshot { + return { + key: entry.key, + scope: scopeOfTimerKey(entry.key), + kind: entry.kind, + status: entry.status, + scheduledAt: entry.scheduledAt, + dueAt: entry.dueAt, + delayMs: entry.delayMs, + runCount: entry.runCount, + lastFiredAt: entry.lastFiredAt, + lastCompletedAt: entry.lastCompletedAt, + lastErrorAt: entry.lastErrorAt, + error: entry.error, + meta: entry.meta + }; +} + +export function isTimerKeyInScope(key: string, scope: string): boolean { + return key === scope || key.startsWith(`${scope}${DEFAULT_TIMER_SCOPE_SEPARATOR}`); +} diff --git a/src/arts/timr/timer-validation.ts b/src/arts/timr/timer-validation.ts new file mode 100644 index 0000000..fb3a84b --- /dev/null +++ b/src/arts/timr/timer-validation.ts @@ -0,0 +1,26 @@ +import { + disposedErrorMessage, + invalidDelayErrorMessage, + invalidKeyErrorMessage +} from './consts.ts'; +import { + TimrDisposedError, + TimrInvalidDelayError, + TimrInvalidKeyError +} from './errors.ts'; + +export function assertTimerEngineLive(disposed: boolean, method: string): void { + if (disposed) throw new TimrDisposedError(disposedErrorMessage(method)); +} + +export function assertTimerKey(key: unknown): void { + if (typeof key !== 'string' || key.length === 0) { + throw new TimrInvalidKeyError(invalidKeyErrorMessage(key)); + } +} + +export function assertTimerDelay(delayMs: unknown): void { + if (typeof delayMs !== 'number' || !Number.isFinite(delayMs) || delayMs < 0) { + throw new TimrInvalidDelayError(invalidDelayErrorMessage(delayMs)); + } +} diff --git a/src/arts/timr/types.ts b/src/arts/timr/types.ts index 29f319b..df848d7 100644 --- a/src/arts/timr/types.ts +++ b/src/arts/timr/types.ts @@ -11,6 +11,7 @@ * tests — none of them depend on Svelte runtime. */ +import type { Logger } from '$libs/logr'; import type { TIMER_EVENT_CANCELLED, TIMER_EVENT_COMPLETED, @@ -193,15 +194,6 @@ export type TimerEvent = export type TimerListener = (event: TimerEvent) => void; -// ============================================================================ -// LOGGER (decoupled from $logr to keep arts/timr zero-dep) -// ============================================================================ - -export interface TimerLogger { - debug?(scope: string, message: string, meta?: unknown): void; - error?(scope: string, message: string, meta?: unknown): void; -} - // ============================================================================ // SCHEDULER (the minimal interface other artifacts depend on) // ============================================================================ @@ -234,7 +226,7 @@ export interface TimerScheduler { export interface EngineTimersOptions { readonly clock?: TimerClock; - readonly logger?: TimerLogger; + readonly logger?: Logger; } export interface EngineTimers extends TimerScheduler { diff --git a/src/libs/auth/audit.ts b/src/libs/auth/audit.ts index ec62268..1974212 100644 --- a/src/libs/auth/audit.ts +++ b/src/libs/auth/audit.ts @@ -1,2 +1,2 @@ export { AUTH_LOG_CATEGORIES, AUTH_LOG_LEVELS, AUTH_LOG_MESSAGES } from './consts'; -export type { AuthLogEntry, AuthLogrPort } from './contracts'; +export type { AuthLogEntry } from './contracts'; diff --git a/src/libs/auth/consts.ts b/src/libs/auth/consts.ts index cdd46e1..052c6f4 100644 --- a/src/libs/auth/consts.ts +++ b/src/libs/auth/consts.ts @@ -263,6 +263,10 @@ export const AUTH_LOG_MESSAGES = { PASSWORD_RESET_COMPLETED: 'auth.password_reset.completed' } as const; +export const AUTH_DIAGNOSTIC_EVENTS = { + SECURITY_EVENT: 'auth.security_event' +} as const; + export const AUTH_DEFAULTS = { CSRF_TTL_MS: 30 * 60 * 1000, FLOW_TTL_MS: 10 * 60 * 1000, @@ -283,7 +287,7 @@ export const AUTH_DEFAULTS = { export const AUTH_COOKIE_POLICY = { AUTH_COOKIES_PREFIX: '__Host-', PATH: '/', - SAME_SITE: 'lax', + SAME_SITE: 'strict', SECURE: true, HTTP_ONLY_FOR_SECRETS: true, DOMAIN: undefined @@ -389,6 +393,11 @@ export const AUTH_OAUTH_PARAMS = { CODE_CHALLENGE: 'code_challenge' } as const; +export const AUTH_OAUTH_FLOW_METADATA = { + STATE: 'state', + VERIFIER: 'verifier' +} as const; + export const AUTH_OAUTH_VALUES = { CODE: 'code', S256: 'S256', diff --git a/src/libs/auth/contracts.ts b/src/libs/auth/contracts.ts index d20c55e..35c4dcc 100644 --- a/src/libs/auth/contracts.ts +++ b/src/libs/auth/contracts.ts @@ -1,3 +1,5 @@ +import type { Logger } from '$libs/logr'; + import type { AuthActorRef, AuthAuthenticationMethod, @@ -213,7 +215,7 @@ export interface AuthStoreAdapter { upsertDevice(input: AuthUpsertDeviceInput): Promise; listDevices(input: AuthListDevicesStoreInput): Promise; - revokeDevice(input: AuthRevokeDeviceStoreInput): Promise; + revokeDevice(input: AuthRevokeDeviceStoreInput): Promise; bindSession(input: AuthBindSessionInput): Promise; findSessionBinding(input: AuthFindSessionBindingInput): Promise; @@ -392,24 +394,6 @@ export interface AuthLogEntry { readonly data?: Readonly>; } -export type AuthLogInput = Omit & { - readonly context?: Readonly>; - readonly error?: unknown; -}; - -export type AuthLogFn = ( - category: string, - message: string, - input?: AuthLogInput -) => void | Promise; - -export interface AuthLogrPort { - debug: AuthLogFn; - info: AuthLogFn; - warn: AuthLogFn; - error: AuthLogFn; -} - export interface AuthHttpPort { request(input: AuthHttpRequest): Promise; } @@ -448,6 +432,9 @@ export interface AuthOAuthResolvedConfig { export interface AuthOAuthProfileInput { readonly tenantId: AuthTenantId; readonly providerId: string; + readonly code: string; + readonly codeVerifier: string; + readonly redirectUri?: string; readonly tokens: Readonly>; readonly userInfo?: Readonly>; readonly idTokenClaims?: Readonly>; diff --git a/src/libs/auth/normalize.ts b/src/libs/auth/normalize.ts index 16c7f84..699b8db 100644 --- a/src/libs/auth/normalize.ts +++ b/src/libs/auth/normalize.ts @@ -12,9 +12,7 @@ export interface AuthNormalizedIdentifier { export function normalizeAuthIdentifier(identifier: string): AuthNormalizedIdentifier { const raw = identifier; const trimmed = identifier.trim(); - const normalized = trimmed.includes('@') - ? trimmed.toLocaleLowerCase() - : trimmed.toLocaleLowerCase(); + const normalized = trimmed.toLocaleLowerCase(); return { raw, normalized, display: trimmed }; } diff --git a/src/libs/auth/types.ts b/src/libs/auth/types.ts index e5b7b37..733e960 100644 --- a/src/libs/auth/types.ts +++ b/src/libs/auth/types.ts @@ -338,6 +338,7 @@ export interface AuthRevokeDeviceInput { export interface AuthRevokeDeviceResult { readonly ok: true; + readonly revokedSessionIds: readonly AuthSessionId[]; } export interface AuthOAuthStartInput { diff --git a/src/libs/cach/engine.ts b/src/libs/cach/engine.ts index ba00712..f656f1b 100644 --- a/src/libs/cach/engine.ts +++ b/src/libs/cach/engine.ts @@ -3,87 +3,58 @@ import { CACHE_CONTEXT_REASON_FETCH_ERROR, CACHE_CONTEXT_REASON_GET, CACHE_CONTEXT_REASON_JOINED_EXISTING_FETCH, - CACHE_CONTEXT_REASON_KEY, - CACHE_CONTEXT_REASON_KEY_PREFIX, - CACHE_CONTEXT_REASON_TAG, CACHE_DECISION_ACTION_DELETE_AND_FETCH, - CACHE_DECISION_ACTION_FETCH, CACHE_DECISION_ACTION_SERVE, CACHE_DECISION_ACTION_SERVE_AND_REFRESH, - CACHE_DECISION_ACTION_SKIP_CACHE, CACHE_DECISION_REASON_BYPASS_CACHE, CACHE_DECISION_REASON_CACHE_MISS, - CACHE_DECISION_REASON_EXPIRED, - CACHE_DECISION_REASON_FRESH, CACHE_DECISION_REASON_NO_STORE, - CACHE_DECISION_REASON_PREFIX_EPOCH_CHANGED, - CACHE_DECISION_REASON_SCHEMA_VERSION_MISMATCH, - CACHE_DECISION_REASON_SCOPE_MISMATCH, - CACHE_DECISION_REASON_STALE_WINDOW_VALID, - CACHE_DECISION_REASON_TAG_EPOCH_CHANGED, CACHE_DEFAULT_NAMESPACE, - CACHE_DEFAULT_SCHEMA_VERSION, CACHE_DEFAULT_VERSION, - CACHE_ENVELOPE_STATE_EXPIRED, - CACHE_ENVELOPE_STATE_FRESH, - CACHE_ENVELOPE_STATE_INVALIDATED, - CACHE_ENVELOPE_STATE_STALE, CACHE_EVENT_ADAPTER_ERROR, CACHE_EVENT_DELETE, CACHE_EVENT_HIT, - CACHE_EVENT_INVALIDATE, CACHE_EVENT_MISS, - CACHE_EVENT_REFRESH_ERROR, - CACHE_EVENT_REFRESH_START, - CACHE_EVENT_REFRESH_SUCCESS, CACHE_EVENT_SCHEMA_MISMATCH, - CACHE_EVENT_SCOPE_ERROR, CACHE_EVENT_SET, CACHE_EVENT_SINGLEFLIGHT_JOIN, CACHE_EVENT_STALE_HIT, CACHE_EVENT_STALE_IF_ERROR, - CACHE_INTERNAL_PART_ENTRY, - CACHE_INTERNAL_PART_EPOCH, - CACHE_INTERNAL_PART_PREFIX, - CACHE_INTERNAL_PART_TAG, - CACHE_KEY_SEPARATOR, CACHE_READ_MODE_BYPASS_CACHE, CACHE_READ_MODE_NO_STORE, - CACHE_READ_MODE_STALE_WHILE_REVALIDATE, - CACHE_VERSION_PREFIX + CACHE_READ_MODE_STALE_WHILE_REVALIDATE } from './consts.ts'; import { CacheEvents } from './events.ts'; +import { normalizeTags } from './key.ts'; import { - normalizeKey, - normalizeKeyPrefixes, - normalizeTag, - normalizeTags, - stableHash -} from './key.ts'; -import { resolvePolicy } from './policy.ts'; -import { resolveScope } from './scope.ts'; + resolveCacheContext, + type CacheContextInput, + type ResolvedCacheContext +} from './runtime-context.ts'; +import { + canServeCacheStaleIfError, + evaluateCacheEntry +} from './runtime-evaluation.ts'; +import { createCacheEnvelope } from './runtime-envelope.ts'; +import { readCacheCurrentEpochs } from './runtime-epochs.ts'; +import { buildCacheHitExplain, buildCacheMissExplain } from './runtime-explain.ts'; +import { invalidateCacheEntry } from './runtime-invalidation.ts'; +import { systemCacheClock } from './runtime-helpers.ts'; +import { runCacheMutation } from './runtime-mutation.ts'; +import { refreshCacheInBackground } from './runtime-refresh.ts'; import { Singleflight } from './singleflight.ts'; import type { - CacheClock, - CacheDecision, CacheEnvelope, - CacheEnvelopeState, CacheEvent, CacheEventType, CacheKey, - CachePolicy, - CacheReadMode, CacheRuntime, CacheRuntimeConfig, - CacheScopeInput, - ExplainEpochLine, ExplainOptions, GetOptions, InvalidateOptions, MutateOptions, QueryOptions, - ResolvedCachePolicy, - ResolvedCacheScope, SetOptions } from './types.ts'; @@ -111,47 +82,13 @@ export function createCacheRuntime(config: CacheRuntimeConfig): CacheRuntime { }); } - async function contextFor(input: { - key: CacheKey; - scope: Parameters[0]['scope']; - policy?: string | CachePolicy; - mode?: CacheReadMode; - persist?: boolean; - schemaVersion?: string; - }): Promise { - const normalizedKey = normalizeKey(input.key); - let scope: ResolvedCacheScope; - try { - const scopeValues = config.scopeResolver ? await config.scopeResolver() : {}; - scope = await resolveScope({ scope: input.scope, values: scopeValues }); - } catch (error) { - emit(CACHE_EVENT_SCOPE_ERROR, { - keyHash: normalizedKey.hash, - reason: scopeReason(input.scope), - error - }); - throw error; - } - const fullKey = entryKey(namespace, version, scope.hash, normalizedKey.hash); - const policy = resolvePolicy({ - policy: input.policy, - policies: config.policies, - defaultPolicy: config.defaultPolicy, - mode: input.mode, - persist: input.persist - }); - - return { + async function contextFor(input: CacheContextInput): Promise { + return resolveCacheContext(input, { namespace, version, - scope, - keyParts: [...input.key], - keyCanonical: normalizedKey.canonical, - keyHash: normalizedKey.hash, - fullKey, - schemaVersion: input.schemaVersion ?? CACHE_DEFAULT_SCHEMA_VERSION, - policy - }; + config, + emit + }); } async function readCurrentEpochs( @@ -162,90 +99,29 @@ export function createCacheRuntime(config: CacheRuntimeConfig): CacheRuntime { prefixes: string[]; } ): Promise> { - const epochKeys = - 'meta' in entryOrParts - ? [ - ...Object.keys(entryOrParts.meta.tagEpochs), - ...Object.keys(entryOrParts.meta.prefixEpochs) - ] - : [...entryOrParts.tags, ...entryOrParts.prefixes]; - - const unique = Array.from(new Set(epochKeys)); - if (!unique.length) return {}; - - if (adapter.getManyEpochs) { - return adapter.getManyEpochs(unique); - } - - const result: Record = {}; - await Promise.all( - unique.map(async (key) => { - result[key] = await adapter.getEpoch(key); - }) - ); - return result; + return readCacheCurrentEpochs(adapter, entryOrParts); } async function createEnvelope( value: T, - ctx: ResolvedContext, + ctx: ResolvedCacheContext, tags: string[] ): Promise> { - const now = clock.now(); - const prefixEpochKeys = prefixEpochKeysFor(namespace, version, ctx.scope.hash, ctx.keyParts); - const tagEpochKeys = tags.map((tag) => tagEpochKey(namespace, version, ctx.scope.hash, tag)); - - const currentEpochs = await readCurrentEpochs({ - tags: tagEpochKeys, - prefixes: prefixEpochKeys - }); - - const tagEpochs: Record = {}; - for (const key of tagEpochKeys) tagEpochs[key] = currentEpochs[key] ?? 0; - - const prefixEpochs: Record = {}; - for (const key of prefixEpochKeys) prefixEpochs[key] = currentEpochs[key] ?? 0; - - const freshUntil = now + ctx.policy.freshForMs; - const staleUntil = freshUntil + ctx.policy.staleForMs; - const staleIfErrorUntil = freshUntil + ctx.policy.staleIfErrorForMs; - const gcAfter = - now + - Math.max( - ctx.policy.gcAfterMs, - ctx.policy.freshForMs + ctx.policy.staleForMs, - ctx.policy.freshForMs + ctx.policy.staleIfErrorForMs - ); - - return { + return createCacheEnvelope({ value, - meta: { - key: ctx.fullKey, - keyHash: ctx.keyHash, - keyCanonical: ctx.keyCanonical, - keyParts: ctx.keyParts, - namespace, - engineVersion: version, - schemaVersion: ctx.schemaVersion, - scope: ctx.scope, - createdAt: now, - updatedAt: now, - freshUntil, - staleUntil, - staleIfErrorUntil, - gcAfter, - state: CACHE_ENVELOPE_STATE_FRESH, - tags, - tagEpochs, - prefixEpochs, - sizeBytes: estimateSizeBytes(value), - hitCount: 0, - lastAccessedAt: now - } - }; + ctx, + tags, + namespace, + version, + now: clock.now(), + readCurrentEpochs + }); } - async function writeEnvelope(ctx: ResolvedContext, envelope: CacheEnvelope): Promise { + async function writeEnvelope( + ctx: ResolvedCacheContext, + envelope: CacheEnvelope + ): Promise { await adapter.set(ctx.fullKey, envelope, { ttlMs: Math.max(0, envelope.meta.gcAfter - clock.now()), persist: ctx.policy.persist @@ -260,7 +136,7 @@ export function createCacheRuntime(config: CacheRuntimeConfig): CacheRuntime { } async function fetchAndStore( - ctx: ResolvedContext, + ctx: ResolvedCacheContext, options: Pick, 'fetcher' | 'tags'> ): Promise { const value = await options.fetcher(); @@ -272,129 +148,22 @@ export function createCacheRuntime(config: CacheRuntimeConfig): CacheRuntime { return value; } - async function evaluate( - entry: CacheEnvelope, - ctx: ResolvedContext - ): Promise { - if (ctx.policy.mode === CACHE_READ_MODE_BYPASS_CACHE) { - return { - action: CACHE_DECISION_ACTION_SKIP_CACHE, - reason: CACHE_DECISION_REASON_BYPASS_CACHE - }; - } - - if (ctx.policy.mode === CACHE_READ_MODE_NO_STORE) { - return { action: CACHE_DECISION_ACTION_SKIP_CACHE, reason: CACHE_DECISION_REASON_NO_STORE }; - } - - if (entry.meta.schemaVersion !== ctx.schemaVersion) { - return { - action: CACHE_DECISION_ACTION_DELETE_AND_FETCH, - reason: CACHE_DECISION_REASON_SCHEMA_VERSION_MISMATCH, - state: CACHE_ENVELOPE_STATE_EXPIRED - }; - } - - if (entry.meta.scope.hash !== ctx.scope.hash || entry.meta.scope.mode !== ctx.scope.mode) { - return { - action: CACHE_DECISION_ACTION_FETCH, - reason: CACHE_DECISION_REASON_SCOPE_MISMATCH, - state: CACHE_ENVELOPE_STATE_EXPIRED - }; - } - - const currentEpochs = await readCurrentEpochs(entry as CacheEnvelope); - - const changedTags = changedEpochKeys(entry.meta.tagEpochs, currentEpochs); - if (changedTags.length) { - const serveStale = - ctx.policy.mode === CACHE_READ_MODE_STALE_WHILE_REVALIDATE && - clock.now() <= entry.meta.staleUntil; - return { - action: serveStale ? CACHE_DECISION_ACTION_SERVE_AND_REFRESH : CACHE_DECISION_ACTION_FETCH, - reason: CACHE_DECISION_REASON_TAG_EPOCH_CHANGED, - state: CACHE_ENVELOPE_STATE_INVALIDATED, - currentTagEpochs: pick(currentEpochs, Object.keys(entry.meta.tagEpochs)), - currentPrefixEpochs: pick(currentEpochs, Object.keys(entry.meta.prefixEpochs)), - changedTags - }; - } - - const changedPrefixes = changedEpochKeys(entry.meta.prefixEpochs, currentEpochs); - if (changedPrefixes.length) { - const serveStale = - ctx.policy.mode === CACHE_READ_MODE_STALE_WHILE_REVALIDATE && - clock.now() <= entry.meta.staleUntil; - return { - action: serveStale ? CACHE_DECISION_ACTION_SERVE_AND_REFRESH : CACHE_DECISION_ACTION_FETCH, - reason: CACHE_DECISION_REASON_PREFIX_EPOCH_CHANGED, - state: CACHE_ENVELOPE_STATE_INVALIDATED, - currentTagEpochs: pick(currentEpochs, Object.keys(entry.meta.tagEpochs)), - currentPrefixEpochs: pick(currentEpochs, Object.keys(entry.meta.prefixEpochs)), - changedPrefixes - }; - } - - const now = clock.now(); - if (now <= entry.meta.freshUntil) { - return { - action: CACHE_DECISION_ACTION_SERVE, - reason: CACHE_DECISION_REASON_FRESH, - state: CACHE_ENVELOPE_STATE_FRESH - }; - } - - if ( - ctx.policy.mode === CACHE_READ_MODE_STALE_WHILE_REVALIDATE && - now <= entry.meta.staleUntil - ) { - return { - action: CACHE_DECISION_ACTION_SERVE_AND_REFRESH, - reason: CACHE_DECISION_REASON_STALE_WINDOW_VALID, - state: CACHE_ENVELOPE_STATE_STALE - }; - } - - return { - action: CACHE_DECISION_ACTION_FETCH, - reason: CACHE_DECISION_REASON_EXPIRED, - state: CACHE_ENVELOPE_STATE_EXPIRED - }; + function evaluate(entry: CacheEnvelope, ctx: ResolvedCacheContext) { + return evaluateCacheEntry(entry, ctx, { + now: () => clock.now(), + readCurrentEpochs + }); } async function backgroundRefresh( - ctx: ResolvedContext, + ctx: ResolvedCacheContext, options: QueryOptions ): Promise { - const flightKey = `refresh:${ctx.fullKey}`; - if (singleflight.has(flightKey)) return; - - emit(CACHE_EVENT_REFRESH_START, { - key: ctx.fullKey, - keyHash: ctx.keyHash, - scopeMode: ctx.scope.mode, - policy: ctx.policy.name + refreshCacheInBackground(ctx, options, { + singleflight, + fetchAndStore, + emit }); - - void singleflight - .run(flightKey, async () => fetchAndStore(ctx, options)) - .then(() => { - emit(CACHE_EVENT_REFRESH_SUCCESS, { - key: ctx.fullKey, - keyHash: ctx.keyHash, - scopeMode: ctx.scope.mode, - policy: ctx.policy.name - }); - }) - .catch((error) => { - emit(CACHE_EVENT_REFRESH_ERROR, { - key: ctx.fullKey, - keyHash: ctx.keyHash, - scopeMode: ctx.scope.mode, - policy: ctx.policy.name, - error - }); - }); } async function query(options: QueryOptions): Promise { @@ -487,7 +256,7 @@ export function createCacheRuntime(config: CacheRuntimeConfig): CacheRuntime { try { return await fetchAndStore(ctx, options); } catch (error) { - if (cached && canServeStaleIfError(cached)) { + if (cached && canServeCacheStaleIfError(cached, clock.now())) { emit(CACHE_EVENT_STALE_IF_ERROR, { key: ctx.fullKey, keyHash: ctx.keyHash, @@ -581,78 +350,26 @@ export function createCacheRuntime(config: CacheRuntimeConfig): CacheRuntime { } async function invalidate(options: InvalidateOptions): Promise { - if ('key' in options) { - const ctx = await contextFor({ key: options.key, scope: options.scope }); - await safeDelete(ctx.fullKey, ctx.keyHash); - emit(CACHE_EVENT_INVALIDATE, { - key: ctx.fullKey, - keyHash: ctx.keyHash, - reason: CACHE_CONTEXT_REASON_KEY, - scopeMode: ctx.scope.mode - }); - return; - } - - if ('keyPrefix' in options) { - const scopeValues = config.scopeResolver ? await config.scopeResolver() : {}; - const scope = await resolveScope({ scope: options.scope, values: scopeValues }); - const epochKey = prefixEpochKey(namespace, version, scope.hash, options.keyPrefix); - const nextEpoch = await adapter.bumpEpoch(epochKey); - emit(CACHE_EVENT_INVALIDATE, { - key: epochKey, - keyHash: stableHash(epochKey), - reason: `${CACHE_CONTEXT_REASON_KEY_PREFIX}:${nextEpoch}`, - scopeMode: scope.mode - }); - return; - } - - const scopeValues = config.scopeResolver ? await config.scopeResolver() : {}; - const scope = await resolveScope({ scope: options.scope, values: scopeValues }); - const tag = normalizeTag(options.tag); - const epochKey = tagEpochKey(namespace, version, scope.hash, tag); - const nextEpoch = await adapter.bumpEpoch(epochKey); - emit(CACHE_EVENT_INVALIDATE, { - key: epochKey, - keyHash: stableHash(epochKey), - reason: `${CACHE_CONTEXT_REASON_TAG}:${tag}:${nextEpoch}`, - scopeMode: scope.mode, - tags: [tag] + await invalidateCacheEntry(options, { + namespace, + version, + adapter, + config, + contextFor, + safeDelete, + emit }); } async function mutate(options: MutateOptions): Promise { - const result = await options.commit(); - - for (const invalidation of options.invalidate ?? []) { - await invalidate(invalidation); - } - - for (const update of options.update ?? []) { - const ctx = await contextFor({ - key: update.key, - scope: update.scope, - policy: update.policy, - mode: update.mode, - persist: update.persist, - schemaVersion: update.schemaVersion - }); - - const cached = await adapter.get(ctx.fullKey); - if (!cached) continue; - - if (cached.meta.schemaVersion !== ctx.schemaVersion) { - await safeDelete(ctx.fullKey, ctx.keyHash); - continue; - } - - const nextValue = await update.reducer(cached.value); - const tags = update.tags ? normalizeTags(update.tags) : cached.meta.tags; - const envelope = await createEnvelope(nextValue, ctx, tags); - await writeEnvelope(ctx, envelope); - } - - return result; + return runCacheMutation(options, { + invalidate, + contextFor, + getEnvelope: (key) => adapter.get(key), + safeDelete, + createEnvelope, + writeEnvelope + }); } async function explain( @@ -669,54 +386,11 @@ export function createCacheRuntime(config: CacheRuntimeConfig): CacheRuntime { const cached = await adapter.get(ctx.fullKey); if (!cached) { - return { - key: ctx.fullKey, - keyHash: ctx.keyHash, - exists: false, - adapter: adapter.name, - decision: CACHE_DECISION_ACTION_FETCH, - reason: CACHE_DECISION_REASON_CACHE_MISS, - schemaVersion: { - expected: ctx.schemaVersion, - valid: false - }, - scope: ctx.scope - }; + return buildCacheMissExplain(ctx, adapter.name); } const decision = await evaluate(cached, ctx); - const now = clock.now(); - const tagLines = epochLines(cached.meta.tagEpochs, decision.currentTagEpochs); - const prefixLines = epochLines(cached.meta.prefixEpochs, decision.currentPrefixEpochs); - - return { - key: ctx.fullKey, - keyHash: ctx.keyHash, - exists: true, - adapter: adapter.name, - state: decision.state ?? computeState(cached), - decision: decision.action, - reason: decision.reason, - schemaVersion: { - expected: ctx.schemaVersion, - actual: cached.meta.schemaVersion, - valid: cached.meta.schemaVersion === ctx.schemaVersion - }, - scope: cached.meta.scope, - timing: { - createdAt: cached.meta.createdAt, - updatedAt: cached.meta.updatedAt, - freshUntil: cached.meta.freshUntil, - staleUntil: cached.meta.staleUntil, - staleIfErrorUntil: cached.meta.staleIfErrorUntil, - gcAfter: cached.meta.gcAfter, - ageMs: now - cached.meta.updatedAt - }, - epochs: { - tags: tagLines, - prefixes: prefixLines - } - }; + return buildCacheHitExplain(ctx, adapter.name, cached, decision, clock.now()); } async function clear(): Promise { @@ -754,140 +428,4 @@ export function createCacheRuntime(config: CacheRuntimeConfig): CacheRuntime { events.dispose(); } }; - - function canServeStaleIfError(entry: CacheEnvelope): boolean { - return clock.now() <= entry.meta.staleIfErrorUntil; - } - - function computeState(entry: CacheEnvelope): CacheEnvelopeState { - const now = clock.now(); - if (now <= entry.meta.freshUntil) return CACHE_ENVELOPE_STATE_FRESH; - if (now <= entry.meta.staleUntil) return CACHE_ENVELOPE_STATE_STALE; - return CACHE_ENVELOPE_STATE_EXPIRED; - } - - function epochLines( - entryEpochs: Record, - current?: Record - ): ExplainEpochLine[] { - return Object.keys(entryEpochs) - .sort() - .map((key) => { - const currentEpoch = current?.[key] ?? 0; - const entryEpoch = entryEpochs[key] ?? 0; - return { - key, - entryEpoch, - currentEpoch, - valid: entryEpoch === currentEpoch - }; - }); - } -} - -type ResolvedContext = { - namespace: string; - version: string; - scope: ResolvedCacheScope; - keyParts: unknown[]; - keyCanonical: string; - keyHash: string; - fullKey: string; - schemaVersion: string; - policy: ResolvedCachePolicy; -}; - -const systemCacheClock: CacheClock = { - now: () => Date.now() -}; - -function entryKey(namespace: string, version: string, scopeHash: string, keyHash: string): string { - return joinCacheKey( - namespace, - `${CACHE_VERSION_PREFIX}${version}`, - CACHE_INTERNAL_PART_ENTRY, - scopeHash, - keyHash - ); -} - -function tagEpochKey(namespace: string, version: string, scopeHash: string, tag: string): string { - return joinCacheKey( - namespace, - `${CACHE_VERSION_PREFIX}${version}`, - CACHE_INTERNAL_PART_EPOCH, - CACHE_INTERNAL_PART_TAG, - scopeHash, - stableHash(tag) - ); -} - -function prefixEpochKey( - namespace: string, - version: string, - scopeHash: string, - prefix: CacheKey -): string { - const normalized = normalizeKey(prefix); - return joinCacheKey( - namespace, - `${CACHE_VERSION_PREFIX}${version}`, - CACHE_INTERNAL_PART_EPOCH, - CACHE_INTERNAL_PART_PREFIX, - scopeHash, - normalized.hash - ); -} - -function prefixEpochKeysFor( - namespace: string, - version: string, - scopeHash: string, - key: CacheKey -): string[] { - return normalizeKeyPrefixes(key).map((prefix) => - joinCacheKey( - namespace, - `${CACHE_VERSION_PREFIX}${version}`, - CACHE_INTERNAL_PART_EPOCH, - CACHE_INTERNAL_PART_PREFIX, - scopeHash, - prefix.hash - ) - ); -} - -function changedEpochKeys( - entryEpochs: Record, - currentEpochs: Record -): string[] { - const changed: string[] = []; - for (const key of Object.keys(entryEpochs)) { - if ((entryEpochs[key] ?? 0) !== (currentEpochs[key] ?? 0)) { - changed.push(key); - } - } - return changed; -} - -function pick(source: Record, keys: string[]): Record { - const out: Record = {}; - for (const key of keys) out[key] = source[key] ?? 0; - return out; -} - -function estimateSizeBytes(value: unknown): number | undefined { - try { - return new TextEncoder().encode(JSON.stringify(value)).byteLength; - } catch { - return undefined; - } -} - -function joinCacheKey(...parts: string[]): string { - return parts.join(CACHE_KEY_SEPARATOR); -} - -function scopeReason(scope: CacheScopeInput): string { - return typeof scope === 'string' ? scope : scope.mode; } diff --git a/src/libs/cach/runtime-context.ts b/src/libs/cach/runtime-context.ts new file mode 100644 index 0000000..39edfeb --- /dev/null +++ b/src/libs/cach/runtime-context.ts @@ -0,0 +1,87 @@ +import { + CACHE_DEFAULT_SCHEMA_VERSION, + CACHE_EVENT_SCOPE_ERROR +} from './consts.ts'; +import { normalizeKey } from './key.ts'; +import { resolvePolicy } from './policy.ts'; +import { entryKey, scopeReason } from './runtime-helpers.ts'; +import { resolveScope } from './scope.ts'; +import type { + CacheEvent, + CacheEventType, + CacheKey, + CachePolicy, + CacheReadMode, + CacheRuntimeConfig, + CacheScopeInput, + ResolvedCachePolicy, + ResolvedCacheScope +} from './types.ts'; + +export type CacheContextInput = { + key: CacheKey; + scope: CacheScopeInput; + policy?: string | CachePolicy; + mode?: CacheReadMode; + persist?: boolean; + schemaVersion?: string; +}; + +export type ResolvedCacheContext = { + namespace: string; + version: string; + scope: ResolvedCacheScope; + keyParts: unknown[]; + keyCanonical: string; + keyHash: string; + fullKey: string; + schemaVersion: string; + policy: ResolvedCachePolicy; +}; + +export async function resolveCacheContext( + input: CacheContextInput, + options: { + readonly namespace: string; + readonly version: string; + readonly config: Pick< + CacheRuntimeConfig, + 'scopeResolver' | 'policies' | 'defaultPolicy' + >; + readonly emit: (type: CacheEventType, patch?: Partial) => void; + } +): Promise { + const normalizedKey = normalizeKey(input.key); + let scope: ResolvedCacheScope; + try { + const scopeValues = options.config.scopeResolver ? await options.config.scopeResolver() : {}; + scope = await resolveScope({ scope: input.scope, values: scopeValues }); + } catch (error) { + options.emit(CACHE_EVENT_SCOPE_ERROR, { + keyHash: normalizedKey.hash, + reason: scopeReason(input.scope), + error + }); + throw error; + } + + const policy = resolvePolicy({ + policy: input.policy, + policies: options.config.policies, + defaultPolicy: options.config.defaultPolicy, + mode: input.mode, + persist: input.persist + }); + + return { + namespace: options.namespace, + version: options.version, + scope, + keyParts: [...input.key], + keyCanonical: normalizedKey.canonical, + keyHash: normalizedKey.hash, + fullKey: entryKey(options.namespace, options.version, scope.hash, normalizedKey.hash), + schemaVersion: input.schemaVersion ?? CACHE_DEFAULT_SCHEMA_VERSION, + policy + }; +} diff --git a/src/libs/cach/runtime-envelope.ts b/src/libs/cach/runtime-envelope.ts new file mode 100644 index 0000000..3925b3c --- /dev/null +++ b/src/libs/cach/runtime-envelope.ts @@ -0,0 +1,74 @@ +import { CACHE_ENVELOPE_STATE_FRESH } from './consts.ts'; +import type { ResolvedCacheContext } from './runtime-context.ts'; +import { estimateSizeBytes, prefixEpochKeysFor, tagEpochKey } from './runtime-helpers.ts'; +import type { CacheEnvelope } from './types.ts'; + +export interface CreateCacheEnvelopeInput { + readonly value: T; + readonly ctx: ResolvedCacheContext; + readonly tags: readonly string[]; + readonly namespace: string; + readonly version: string; + readonly now: number; + readonly readCurrentEpochs: (input: { + readonly tags: string[]; + readonly prefixes: string[]; + }) => Promise>; +} + +export async function createCacheEnvelope( + input: CreateCacheEnvelopeInput +): Promise> { + const { value, ctx, tags, namespace, version, now, readCurrentEpochs } = input; + const prefixEpochKeys = prefixEpochKeysFor(namespace, version, ctx.scope.hash, ctx.keyParts); + const tagEpochKeys = tags.map((tag) => tagEpochKey(namespace, version, ctx.scope.hash, tag)); + + const currentEpochs = await readCurrentEpochs({ + tags: tagEpochKeys, + prefixes: prefixEpochKeys + }); + + const tagEpochs: Record = {}; + for (const key of tagEpochKeys) tagEpochs[key] = currentEpochs[key] ?? 0; + + const prefixEpochs: Record = {}; + for (const key of prefixEpochKeys) prefixEpochs[key] = currentEpochs[key] ?? 0; + + const freshUntil = now + ctx.policy.freshForMs; + const staleUntil = freshUntil + ctx.policy.staleForMs; + const staleIfErrorUntil = freshUntil + ctx.policy.staleIfErrorForMs; + const gcAfter = + now + + Math.max( + ctx.policy.gcAfterMs, + ctx.policy.freshForMs + ctx.policy.staleForMs, + ctx.policy.freshForMs + ctx.policy.staleIfErrorForMs + ); + + return { + value, + meta: { + key: ctx.fullKey, + keyHash: ctx.keyHash, + keyCanonical: ctx.keyCanonical, + keyParts: ctx.keyParts, + namespace, + engineVersion: version, + schemaVersion: ctx.schemaVersion, + scope: ctx.scope, + createdAt: now, + updatedAt: now, + freshUntil, + staleUntil, + staleIfErrorUntil, + gcAfter, + state: CACHE_ENVELOPE_STATE_FRESH, + tags: [...tags], + tagEpochs, + prefixEpochs, + sizeBytes: estimateSizeBytes(value), + hitCount: 0, + lastAccessedAt: now + } + }; +} diff --git a/src/libs/cach/runtime-epochs.ts b/src/libs/cach/runtime-epochs.ts new file mode 100644 index 0000000..c20ac81 --- /dev/null +++ b/src/libs/cach/runtime-epochs.ts @@ -0,0 +1,33 @@ +import type { CacheAdapter, CacheEnvelope } from './types.ts'; + +export type CacheEpochSource = + | CacheEnvelope + | { + readonly tags: readonly string[]; + readonly prefixes: readonly string[]; + }; + +export async function readCacheCurrentEpochs( + adapter: CacheAdapter, + source: CacheEpochSource +): Promise> { + const epochKeys = + 'meta' in source + ? [...Object.keys(source.meta.tagEpochs), ...Object.keys(source.meta.prefixEpochs)] + : [...source.tags, ...source.prefixes]; + + const unique = Array.from(new Set(epochKeys)); + if (!unique.length) return {}; + + if (adapter.getManyEpochs) { + return adapter.getManyEpochs(unique); + } + + const result: Record = {}; + await Promise.all( + unique.map(async (key) => { + result[key] = await adapter.getEpoch(key); + }) + ); + return result; +} diff --git a/src/libs/cach/runtime-evaluation.ts b/src/libs/cach/runtime-evaluation.ts new file mode 100644 index 0000000..979b380 --- /dev/null +++ b/src/libs/cach/runtime-evaluation.ts @@ -0,0 +1,158 @@ +import { + CACHE_DECISION_ACTION_DELETE_AND_FETCH, + CACHE_DECISION_ACTION_FETCH, + CACHE_DECISION_ACTION_SERVE, + CACHE_DECISION_ACTION_SERVE_AND_REFRESH, + CACHE_DECISION_ACTION_SKIP_CACHE, + CACHE_DECISION_REASON_BYPASS_CACHE, + CACHE_DECISION_REASON_EXPIRED, + CACHE_DECISION_REASON_FRESH, + CACHE_DECISION_REASON_NO_STORE, + CACHE_DECISION_REASON_PREFIX_EPOCH_CHANGED, + CACHE_DECISION_REASON_SCHEMA_VERSION_MISMATCH, + CACHE_DECISION_REASON_SCOPE_MISMATCH, + CACHE_DECISION_REASON_STALE_WINDOW_VALID, + CACHE_DECISION_REASON_TAG_EPOCH_CHANGED, + CACHE_ENVELOPE_STATE_EXPIRED, + CACHE_ENVELOPE_STATE_FRESH, + CACHE_ENVELOPE_STATE_INVALIDATED, + CACHE_ENVELOPE_STATE_STALE, + CACHE_READ_MODE_BYPASS_CACHE, + CACHE_READ_MODE_NO_STORE, + CACHE_READ_MODE_STALE_WHILE_REVALIDATE +} from './consts.ts'; +import { changedEpochKeys, pick } from './runtime-helpers.ts'; +import type { ResolvedCacheContext } from './runtime-context.ts'; +import type { + CacheDecision, + CacheEnvelope, + CacheEnvelopeState, + ExplainEpochLine +} from './types.ts'; + +export async function evaluateCacheEntry( + entry: CacheEnvelope, + ctx: ResolvedCacheContext, + options: { + readonly now: () => number; + readonly readCurrentEpochs: ( + entry: CacheEnvelope + ) => Promise>; + } +): Promise { + if (ctx.policy.mode === CACHE_READ_MODE_BYPASS_CACHE) { + return { + action: CACHE_DECISION_ACTION_SKIP_CACHE, + reason: CACHE_DECISION_REASON_BYPASS_CACHE + }; + } + + if (ctx.policy.mode === CACHE_READ_MODE_NO_STORE) { + return { action: CACHE_DECISION_ACTION_SKIP_CACHE, reason: CACHE_DECISION_REASON_NO_STORE }; + } + + if (entry.meta.schemaVersion !== ctx.schemaVersion) { + return { + action: CACHE_DECISION_ACTION_DELETE_AND_FETCH, + reason: CACHE_DECISION_REASON_SCHEMA_VERSION_MISMATCH, + state: CACHE_ENVELOPE_STATE_EXPIRED + }; + } + + if (entry.meta.scope.hash !== ctx.scope.hash || entry.meta.scope.mode !== ctx.scope.mode) { + return { + action: CACHE_DECISION_ACTION_FETCH, + reason: CACHE_DECISION_REASON_SCOPE_MISMATCH, + state: CACHE_ENVELOPE_STATE_EXPIRED + }; + } + + const currentEpochs = await options.readCurrentEpochs(entry as CacheEnvelope); + + const changedTags = changedEpochKeys(entry.meta.tagEpochs, currentEpochs); + if (changedTags.length) { + const serveStale = + ctx.policy.mode === CACHE_READ_MODE_STALE_WHILE_REVALIDATE && + options.now() <= entry.meta.staleUntil; + return { + action: serveStale ? CACHE_DECISION_ACTION_SERVE_AND_REFRESH : CACHE_DECISION_ACTION_FETCH, + reason: CACHE_DECISION_REASON_TAG_EPOCH_CHANGED, + state: CACHE_ENVELOPE_STATE_INVALIDATED, + currentTagEpochs: pick(currentEpochs, Object.keys(entry.meta.tagEpochs)), + currentPrefixEpochs: pick(currentEpochs, Object.keys(entry.meta.prefixEpochs)), + changedTags + }; + } + + const changedPrefixes = changedEpochKeys(entry.meta.prefixEpochs, currentEpochs); + if (changedPrefixes.length) { + const serveStale = + ctx.policy.mode === CACHE_READ_MODE_STALE_WHILE_REVALIDATE && + options.now() <= entry.meta.staleUntil; + return { + action: serveStale ? CACHE_DECISION_ACTION_SERVE_AND_REFRESH : CACHE_DECISION_ACTION_FETCH, + reason: CACHE_DECISION_REASON_PREFIX_EPOCH_CHANGED, + state: CACHE_ENVELOPE_STATE_INVALIDATED, + currentTagEpochs: pick(currentEpochs, Object.keys(entry.meta.tagEpochs)), + currentPrefixEpochs: pick(currentEpochs, Object.keys(entry.meta.prefixEpochs)), + changedPrefixes + }; + } + + const now = options.now(); + if (now <= entry.meta.freshUntil) { + return { + action: CACHE_DECISION_ACTION_SERVE, + reason: CACHE_DECISION_REASON_FRESH, + state: CACHE_ENVELOPE_STATE_FRESH + }; + } + + if (ctx.policy.mode === CACHE_READ_MODE_STALE_WHILE_REVALIDATE && now <= entry.meta.staleUntil) { + return { + action: CACHE_DECISION_ACTION_SERVE_AND_REFRESH, + reason: CACHE_DECISION_REASON_STALE_WINDOW_VALID, + state: CACHE_ENVELOPE_STATE_STALE + }; + } + + return { + action: CACHE_DECISION_ACTION_FETCH, + reason: CACHE_DECISION_REASON_EXPIRED, + state: CACHE_ENVELOPE_STATE_EXPIRED + }; +} + +export function canServeCacheStaleIfError( + entry: CacheEnvelope, + now: number +): boolean { + return now <= entry.meta.staleIfErrorUntil; +} + +export function computeCacheEnvelopeState( + entry: CacheEnvelope, + now: number +): CacheEnvelopeState { + if (now <= entry.meta.freshUntil) return CACHE_ENVELOPE_STATE_FRESH; + if (now <= entry.meta.staleUntil) return CACHE_ENVELOPE_STATE_STALE; + return CACHE_ENVELOPE_STATE_EXPIRED; +} + +export function cacheEpochLines( + entryEpochs: Record, + current?: Record +): ExplainEpochLine[] { + return Object.keys(entryEpochs) + .sort() + .map((key) => { + const currentEpoch = current?.[key] ?? 0; + const entryEpoch = entryEpochs[key] ?? 0; + return { + key, + entryEpoch, + currentEpoch, + valid: entryEpoch === currentEpoch + }; + }); +} diff --git a/src/libs/cach/runtime-explain.ts b/src/libs/cach/runtime-explain.ts new file mode 100644 index 0000000..93e5fa3 --- /dev/null +++ b/src/libs/cach/runtime-explain.ts @@ -0,0 +1,66 @@ +import { + CACHE_DECISION_ACTION_FETCH, + CACHE_DECISION_REASON_CACHE_MISS +} from './consts.ts'; +import { cacheEpochLines, computeCacheEnvelopeState } from './runtime-evaluation.ts'; +import type { ResolvedCacheContext } from './runtime-context.ts'; +import type { CacheDecision, CacheEnvelope, CacheExplain } from './types.ts'; + +export function buildCacheMissExplain( + ctx: ResolvedCacheContext, + adapterName: string +): CacheExplain { + return { + key: ctx.fullKey, + keyHash: ctx.keyHash, + exists: false, + adapter: adapterName, + decision: CACHE_DECISION_ACTION_FETCH, + reason: CACHE_DECISION_REASON_CACHE_MISS, + schemaVersion: { + expected: ctx.schemaVersion, + valid: false + }, + scope: ctx.scope + }; +} + +export function buildCacheHitExplain( + ctx: ResolvedCacheContext, + adapterName: string, + cached: CacheEnvelope, + decision: CacheDecision, + now: number +): CacheExplain { + const tagLines = cacheEpochLines(cached.meta.tagEpochs, decision.currentTagEpochs); + const prefixLines = cacheEpochLines(cached.meta.prefixEpochs, decision.currentPrefixEpochs); + + return { + key: ctx.fullKey, + keyHash: ctx.keyHash, + exists: true, + adapter: adapterName, + state: decision.state ?? computeCacheEnvelopeState(cached, now), + decision: decision.action, + reason: decision.reason, + schemaVersion: { + expected: ctx.schemaVersion, + actual: cached.meta.schemaVersion, + valid: cached.meta.schemaVersion === ctx.schemaVersion + }, + scope: cached.meta.scope, + timing: { + createdAt: cached.meta.createdAt, + updatedAt: cached.meta.updatedAt, + freshUntil: cached.meta.freshUntil, + staleUntil: cached.meta.staleUntil, + staleIfErrorUntil: cached.meta.staleIfErrorUntil, + gcAfter: cached.meta.gcAfter, + ageMs: now - cached.meta.updatedAt + }, + epochs: { + tags: tagLines, + prefixes: prefixLines + } + }; +} diff --git a/src/libs/cach/runtime-helpers.ts b/src/libs/cach/runtime-helpers.ts new file mode 100644 index 0000000..cea69ed --- /dev/null +++ b/src/libs/cach/runtime-helpers.ts @@ -0,0 +1,115 @@ +import { + CACHE_INTERNAL_PART_ENTRY, + CACHE_INTERNAL_PART_EPOCH, + CACHE_INTERNAL_PART_PREFIX, + CACHE_INTERNAL_PART_TAG, + CACHE_KEY_SEPARATOR, + CACHE_VERSION_PREFIX +} from './consts.ts'; +import { normalizeKey, normalizeKeyPrefixes, stableHash } from './key.ts'; +import type { CacheClock, CacheKey, CacheScopeInput } from './types.ts'; + +export const systemCacheClock: CacheClock = { + now: () => Date.now() +}; + +export function entryKey( + namespace: string, + version: string, + scopeHash: string, + keyHash: string +): string { + return joinCacheKey( + namespace, + `${CACHE_VERSION_PREFIX}${version}`, + CACHE_INTERNAL_PART_ENTRY, + scopeHash, + keyHash + ); +} + +export function tagEpochKey( + namespace: string, + version: string, + scopeHash: string, + tag: string +): string { + return joinCacheKey( + namespace, + `${CACHE_VERSION_PREFIX}${version}`, + CACHE_INTERNAL_PART_EPOCH, + CACHE_INTERNAL_PART_TAG, + scopeHash, + stableHash(tag) + ); +} + +export function prefixEpochKey( + namespace: string, + version: string, + scopeHash: string, + prefix: CacheKey +): string { + const normalized = normalizeKey(prefix); + return joinCacheKey( + namespace, + `${CACHE_VERSION_PREFIX}${version}`, + CACHE_INTERNAL_PART_EPOCH, + CACHE_INTERNAL_PART_PREFIX, + scopeHash, + normalized.hash + ); +} + +export function prefixEpochKeysFor( + namespace: string, + version: string, + scopeHash: string, + key: CacheKey +): string[] { + return normalizeKeyPrefixes(key).map((prefix) => + joinCacheKey( + namespace, + `${CACHE_VERSION_PREFIX}${version}`, + CACHE_INTERNAL_PART_EPOCH, + CACHE_INTERNAL_PART_PREFIX, + scopeHash, + prefix.hash + ) + ); +} + +export function changedEpochKeys( + entryEpochs: Record, + currentEpochs: Record +): string[] { + const changed: string[] = []; + for (const key of Object.keys(entryEpochs)) { + if ((entryEpochs[key] ?? 0) !== (currentEpochs[key] ?? 0)) { + changed.push(key); + } + } + return changed; +} + +export function pick(source: Record, keys: string[]): Record { + const out: Record = {}; + for (const key of keys) out[key] = source[key] ?? 0; + return out; +} + +export function estimateSizeBytes(value: unknown): number | undefined { + try { + return new TextEncoder().encode(JSON.stringify(value)).byteLength; + } catch { + return undefined; + } +} + +export function scopeReason(scope: CacheScopeInput): string { + return typeof scope === 'string' ? scope : scope.mode; +} + +function joinCacheKey(...parts: string[]): string { + return parts.join(CACHE_KEY_SEPARATOR); +} diff --git a/src/libs/cach/runtime-invalidation.ts b/src/libs/cach/runtime-invalidation.ts new file mode 100644 index 0000000..73a2f2e --- /dev/null +++ b/src/libs/cach/runtime-invalidation.ts @@ -0,0 +1,81 @@ +import { + CACHE_CONTEXT_REASON_KEY, + CACHE_CONTEXT_REASON_KEY_PREFIX, + CACHE_CONTEXT_REASON_TAG, + CACHE_EVENT_INVALIDATE +} from './consts.ts'; +import { normalizeTag, stableHash } from './key.ts'; +import type { ResolvedCacheContext } from './runtime-context.ts'; +import { prefixEpochKey, tagEpochKey } from './runtime-helpers.ts'; +import { resolveScope } from './scope.ts'; +import type { + CacheAdapter, + CacheEvent, + CacheEventType, + CacheKey, + CacheRuntimeConfig, + CacheScopeInput, + InvalidateOptions +} from './types.ts'; + +export interface CacheInvalidationRuntime { + readonly namespace: string; + readonly version: string; + readonly adapter: Pick; + readonly config: Pick; + readonly contextFor: (input: { + readonly key: CacheKey; + readonly scope: CacheScopeInput; + }) => Promise; + readonly safeDelete: (key: string, keyHash: string) => Promise; + readonly emit: (type: CacheEventType, patch?: Partial) => void; +} + +export async function invalidateCacheEntry( + options: InvalidateOptions, + runtime: CacheInvalidationRuntime +): Promise { + if ('key' in options) { + const ctx = await runtime.contextFor({ key: options.key, scope: options.scope }); + await runtime.safeDelete(ctx.fullKey, ctx.keyHash); + runtime.emit(CACHE_EVENT_INVALIDATE, { + key: ctx.fullKey, + keyHash: ctx.keyHash, + reason: CACHE_CONTEXT_REASON_KEY, + scopeMode: ctx.scope.mode + }); + return; + } + + if ('keyPrefix' in options) { + const scopeValues = runtime.config.scopeResolver ? await runtime.config.scopeResolver() : {}; + const scope = await resolveScope({ scope: options.scope, values: scopeValues }); + const epochKey = prefixEpochKey( + runtime.namespace, + runtime.version, + scope.hash, + options.keyPrefix + ); + const nextEpoch = await runtime.adapter.bumpEpoch(epochKey); + runtime.emit(CACHE_EVENT_INVALIDATE, { + key: epochKey, + keyHash: stableHash(epochKey), + reason: `${CACHE_CONTEXT_REASON_KEY_PREFIX}:${nextEpoch}`, + scopeMode: scope.mode + }); + return; + } + + const scopeValues = runtime.config.scopeResolver ? await runtime.config.scopeResolver() : {}; + const scope = await resolveScope({ scope: options.scope, values: scopeValues }); + const tag = normalizeTag(options.tag); + const epochKey = tagEpochKey(runtime.namespace, runtime.version, scope.hash, tag); + const nextEpoch = await runtime.adapter.bumpEpoch(epochKey); + runtime.emit(CACHE_EVENT_INVALIDATE, { + key: epochKey, + keyHash: stableHash(epochKey), + reason: `${CACHE_CONTEXT_REASON_TAG}:${tag}:${nextEpoch}`, + scopeMode: scope.mode, + tags: [tag] + }); +} diff --git a/src/libs/cach/runtime-mutation.ts b/src/libs/cach/runtime-mutation.ts new file mode 100644 index 0000000..b57edce --- /dev/null +++ b/src/libs/cach/runtime-mutation.ts @@ -0,0 +1,69 @@ +import { normalizeTags } from './key.ts'; +import type { ResolvedCacheContext } from './runtime-context.ts'; +import type { + CacheEnvelope, + CacheRuntime, + MutateInvalidation, + MutateOptions, + MutateUpdate +} from './types.ts'; + +export interface CacheMutationRuntime { + readonly invalidate: (options: MutateInvalidation) => Promise; + readonly contextFor: (input: { + readonly key: MutateUpdate['key']; + readonly scope: MutateUpdate['scope']; + readonly policy?: MutateUpdate['policy']; + readonly mode?: MutateUpdate['mode']; + readonly persist?: MutateUpdate['persist']; + readonly schemaVersion?: MutateUpdate['schemaVersion']; + }) => Promise; + readonly getEnvelope: (key: string) => Promise | null>; + readonly safeDelete: (key: string, keyHash: string) => Promise; + readonly createEnvelope: ( + value: T, + ctx: ResolvedCacheContext, + tags: string[] + ) => Promise>; + readonly writeEnvelope: ( + ctx: ResolvedCacheContext, + envelope: CacheEnvelope + ) => Promise; +} + +export async function runCacheMutation( + options: MutateOptions, + runtime: CacheMutationRuntime +): Promise { + const result = await options.commit(); + + for (const invalidation of options.invalidate ?? []) { + await runtime.invalidate(invalidation); + } + + for (const update of options.update ?? []) { + const ctx = await runtime.contextFor({ + key: update.key, + scope: update.scope, + policy: update.policy, + mode: update.mode, + persist: update.persist, + schemaVersion: update.schemaVersion + }); + + const cached = await runtime.getEnvelope(ctx.fullKey); + if (!cached) continue; + + if (cached.meta.schemaVersion !== ctx.schemaVersion) { + await runtime.safeDelete(ctx.fullKey, ctx.keyHash); + continue; + } + + const nextValue = await update.reducer(cached.value); + const tags = update.tags ? normalizeTags(update.tags) : cached.meta.tags; + const envelope = await runtime.createEnvelope(nextValue, ctx, tags); + await runtime.writeEnvelope(ctx, envelope); + } + + return result; +} diff --git a/src/libs/cach/runtime-refresh.ts b/src/libs/cach/runtime-refresh.ts new file mode 100644 index 0000000..4e3e4e5 --- /dev/null +++ b/src/libs/cach/runtime-refresh.ts @@ -0,0 +1,57 @@ +import { + CACHE_EVENT_REFRESH_ERROR, + CACHE_EVENT_REFRESH_START, + CACHE_EVENT_REFRESH_SUCCESS +} from './consts.ts'; +import type { ResolvedCacheContext } from './runtime-context.ts'; +import type { CacheEvent, CacheEventType, QueryOptions } from './types.ts'; + +export interface CacheRefreshSingleflight { + has(key: string): boolean; + run(key: string, fn: () => Promise): Promise; +} + +export interface CacheBackgroundRefreshRuntime { + readonly singleflight: CacheRefreshSingleflight; + readonly fetchAndStore: ( + ctx: ResolvedCacheContext, + options: Pick, 'fetcher' | 'tags'> + ) => Promise; + readonly emit: (type: CacheEventType, patch?: Partial) => void; +} + +export function refreshCacheInBackground( + ctx: ResolvedCacheContext, + options: QueryOptions, + runtime: CacheBackgroundRefreshRuntime +): void { + const flightKey = `refresh:${ctx.fullKey}`; + if (runtime.singleflight.has(flightKey)) return; + + runtime.emit(CACHE_EVENT_REFRESH_START, { + key: ctx.fullKey, + keyHash: ctx.keyHash, + scopeMode: ctx.scope.mode, + policy: ctx.policy.name + }); + + void runtime.singleflight + .run(flightKey, async () => runtime.fetchAndStore(ctx, options)) + .then(() => { + runtime.emit(CACHE_EVENT_REFRESH_SUCCESS, { + key: ctx.fullKey, + keyHash: ctx.keyHash, + scopeMode: ctx.scope.mode, + policy: ctx.policy.name + }); + }) + .catch((error) => { + runtime.emit(CACHE_EVENT_REFRESH_ERROR, { + key: ctx.fullKey, + keyHash: ctx.keyHash, + scopeMode: ctx.scope.mode, + policy: ctx.policy.name, + error + }); + }); +} diff --git a/src/libs/cach/test/runtime-envelope.test.ts b/src/libs/cach/test/runtime-envelope.test.ts new file mode 100644 index 0000000..0eed49d --- /dev/null +++ b/src/libs/cach/test/runtime-envelope.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { + CACHE_ENVELOPE_STATE_FRESH, + CACHE_READ_MODE_CACHE_FIRST, + CACHE_SCOPE_PUBLIC +} from '../consts.ts'; +import { createCacheEnvelope } from '../runtime-envelope.ts'; +import type { ResolvedCacheContext } from '../runtime-context.ts'; + +function testContext(): ResolvedCacheContext { + return { + namespace: 'app', + version: '2', + scope: { + mode: CACHE_SCOPE_PUBLIC, + hash: 'public', + values: {} + }, + keyParts: ['posts', { page: 1 }], + keyCanonical: '["posts",{"page":1}]', + keyHash: 'key-hash', + fullKey: 'app:v2:entry:public:key-hash', + schemaVersion: 'Posts:v1', + policy: { + name: 'interactive', + freshForMs: 100, + staleForMs: 200, + staleIfErrorForMs: 300, + gcAfterMs: 1_000, + mode: CACHE_READ_MODE_CACHE_FIRST, + persist: true + } + }; +} + +describe('createCacheEnvelope', () => { + it('materializes timing, scope, schema and epoch metadata', async () => { + const seen: { tags: string[]; prefixes: string[] }[] = []; + const tags = ['post:LIST']; + const envelope = await createCacheEnvelope({ + value: { items: [1, 2] }, + ctx: testContext(), + tags, + namespace: 'app', + version: '2', + now: 1_000, + readCurrentEpochs: async (input) => { + seen.push(input); + return Object.fromEntries([...input.tags, ...input.prefixes].map((key, index) => [key, index + 1])); + } + }); + + tags.push('mutated-after-create'); + + expect(envelope.value).toEqual({ items: [1, 2] }); + expect(envelope.meta.state).toBe(CACHE_ENVELOPE_STATE_FRESH); + expect(envelope.meta.schemaVersion).toBe('Posts:v1'); + expect(envelope.meta.createdAt).toBe(1_000); + expect(envelope.meta.freshUntil).toBe(1_100); + expect(envelope.meta.staleUntil).toBe(1_300); + expect(envelope.meta.staleIfErrorUntil).toBe(1_400); + expect(envelope.meta.gcAfter).toBe(2_000); + expect(envelope.meta.tags).toEqual(['post:LIST']); + expect(Object.keys(envelope.meta.tagEpochs)).toHaveLength(1); + expect(Object.keys(envelope.meta.prefixEpochs).length).toBeGreaterThan(0); + expect(seen).toHaveLength(1); + expect(seen[0]?.tags).toHaveLength(1); + expect(seen[0]?.prefixes.length).toBeGreaterThan(0); + }); +}); diff --git a/src/libs/cach/test/runtime-epochs.test.ts b/src/libs/cach/test/runtime-epochs.test.ts new file mode 100644 index 0000000..e1103ec --- /dev/null +++ b/src/libs/cach/test/runtime-epochs.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; +import { readCacheCurrentEpochs } from '../runtime-epochs.ts'; +import type { CacheAdapter } from '../types.ts'; + +function adapter(overrides: Partial = {}): CacheAdapter { + return { + name: 'test', + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + getEpoch: vi.fn(async () => 0), + bumpEpoch: vi.fn(), + ...overrides + }; +} + +describe('readCacheCurrentEpochs', () => { + it('deduplicates epoch keys and prefers getManyEpochs when available', async () => { + const getManyEpochs = vi.fn(async (keys: string[]) => + Object.fromEntries(keys.map((key, index) => [key, index + 1])) + ); + const result = await readCacheCurrentEpochs(adapter({ getManyEpochs }), { + tags: ['a', 'a', 'b'], + prefixes: ['b', 'c'] + }); + + expect(getManyEpochs).toHaveBeenCalledWith(['a', 'b', 'c']); + expect(result).toEqual({ a: 1, b: 2, c: 3 }); + }); + + it('falls back to getEpoch per unique key', async () => { + const getEpoch = vi.fn(async (key: string) => key.length); + const result = await readCacheCurrentEpochs(adapter({ getEpoch }), { + tags: ['tag'], + prefixes: ['prefix', 'tag'] + }); + + expect(getEpoch).toHaveBeenCalledTimes(2); + expect(result).toEqual({ tag: 3, prefix: 6 }); + }); + + it('returns an empty map when there are no epoch keys', async () => { + await expect( + readCacheCurrentEpochs(adapter(), { + tags: [], + prefixes: [] + }) + ).resolves.toEqual({}); + }); +}); diff --git a/src/libs/cach/test/runtime-evaluation.test.ts b/src/libs/cach/test/runtime-evaluation.test.ts new file mode 100644 index 0000000..a25f8f2 --- /dev/null +++ b/src/libs/cach/test/runtime-evaluation.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; +import { + CACHE_DECISION_ACTION_DELETE_AND_FETCH, + CACHE_DECISION_ACTION_FETCH, + CACHE_DECISION_ACTION_SERVE, + CACHE_DECISION_ACTION_SERVE_AND_REFRESH, + CACHE_DECISION_ACTION_SKIP_CACHE, + CACHE_DECISION_REASON_BYPASS_CACHE, + CACHE_DECISION_REASON_EXPIRED, + CACHE_DECISION_REASON_FRESH, + CACHE_DECISION_REASON_SCHEMA_VERSION_MISMATCH, + CACHE_DECISION_REASON_STALE_WINDOW_VALID, + CACHE_DECISION_REASON_TAG_EPOCH_CHANGED, + CACHE_ENVELOPE_STATE_EXPIRED, + CACHE_ENVELOPE_STATE_FRESH, + CACHE_ENVELOPE_STATE_INVALIDATED, + CACHE_ENVELOPE_STATE_STALE, + CACHE_READ_MODE_BYPASS_CACHE, + CACHE_READ_MODE_CACHE_FIRST, + CACHE_READ_MODE_STALE_WHILE_REVALIDATE, + CACHE_SCOPE_PUBLIC +} from '../consts.ts'; +import { evaluateCacheEntry } from '../runtime-evaluation.ts'; +import type { ResolvedCacheContext } from '../runtime-context.ts'; +import type { CacheEnvelope, CacheReadMode } from '../types.ts'; + +function context(mode: CacheReadMode = CACHE_READ_MODE_STALE_WHILE_REVALIDATE): ResolvedCacheContext { + return { + namespace: 'app', + version: '1', + scope: { + mode: CACHE_SCOPE_PUBLIC, + hash: 'public', + values: {} + }, + keyParts: ['user', 1], + keyCanonical: '["user",1]', + keyHash: 'key-hash', + fullKey: 'app:v1:entry:public:key-hash', + schemaVersion: 'User:v1', + policy: { + name: 'interactive', + freshForMs: 100, + staleForMs: 200, + staleIfErrorForMs: 300, + gcAfterMs: 1_000, + mode, + persist: true + } + }; +} + +function envelope(overrides: Partial['meta']> = {}): CacheEnvelope { + return { + value: 'cached', + meta: { + key: 'app:v1:entry:public:key-hash', + keyHash: 'key-hash', + keyCanonical: '["user",1]', + keyParts: ['user', 1], + namespace: 'app', + engineVersion: '1', + schemaVersion: 'User:v1', + scope: { + mode: CACHE_SCOPE_PUBLIC, + hash: 'public', + values: {} + }, + createdAt: 0, + updatedAt: 0, + freshUntil: 100, + staleUntil: 300, + staleIfErrorUntil: 400, + gcAfter: 1_000, + state: CACHE_ENVELOPE_STATE_FRESH, + tags: ['user:1'], + tagEpochs: { tag: 1 }, + prefixEpochs: {}, + sizeBytes: 8, + hitCount: 0, + lastAccessedAt: 0, + ...overrides + } + }; +} + +async function evaluate( + now: number, + entry = envelope(), + ctx = context(), + currentEpochs: Record = { tag: 1 } +) { + return evaluateCacheEntry(entry, ctx, { + now: () => now, + readCurrentEpochs: async () => currentEpochs + }); +} + +describe('evaluateCacheEntry', () => { + it('serves fresh entries without refresh', async () => { + await expect(evaluate(50)).resolves.toMatchObject({ + action: CACHE_DECISION_ACTION_SERVE, + reason: CACHE_DECISION_REASON_FRESH, + state: CACHE_ENVELOPE_STATE_FRESH + }); + }); + + it('serves stale entries and asks the runtime to refresh in background', async () => { + await expect(evaluate(150)).resolves.toMatchObject({ + action: CACHE_DECISION_ACTION_SERVE_AND_REFRESH, + reason: CACHE_DECISION_REASON_STALE_WINDOW_VALID, + state: CACHE_ENVELOPE_STATE_STALE + }); + }); + + it('fetches after the stale window expires', async () => { + await expect(evaluate(350)).resolves.toMatchObject({ + action: CACHE_DECISION_ACTION_FETCH, + reason: CACHE_DECISION_REASON_EXPIRED, + state: CACHE_ENVELOPE_STATE_EXPIRED + }); + }); + + it('deletes entries with an old schema version', async () => { + await expect(evaluate(50, envelope({ schemaVersion: 'User:v0' }))).resolves.toMatchObject({ + action: CACHE_DECISION_ACTION_DELETE_AND_FETCH, + reason: CACHE_DECISION_REASON_SCHEMA_VERSION_MISMATCH, + state: CACHE_ENVELOPE_STATE_EXPIRED + }); + }); + + it('invalidates changed tag epochs while serving stale inside the stale window', async () => { + await expect(evaluate(150, envelope(), context(), { tag: 2 })).resolves.toMatchObject({ + action: CACHE_DECISION_ACTION_SERVE_AND_REFRESH, + reason: CACHE_DECISION_REASON_TAG_EPOCH_CHANGED, + state: CACHE_ENVELOPE_STATE_INVALIDATED, + changedTags: ['tag'] + }); + }); + + it('skips cached entries when policy bypasses cache', async () => { + await expect(evaluate(50, envelope(), context(CACHE_READ_MODE_BYPASS_CACHE))).resolves.toMatchObject({ + action: CACHE_DECISION_ACTION_SKIP_CACHE, + reason: CACHE_DECISION_REASON_BYPASS_CACHE + }); + }); + + it('does not serve stale under cache-first mode', async () => { + await expect(evaluate(150, envelope(), context(CACHE_READ_MODE_CACHE_FIRST))).resolves.toMatchObject({ + action: CACHE_DECISION_ACTION_FETCH, + reason: CACHE_DECISION_REASON_EXPIRED, + state: CACHE_ENVELOPE_STATE_EXPIRED + }); + }); +}); diff --git a/src/libs/cach/test/runtime-explain.test.ts b/src/libs/cach/test/runtime-explain.test.ts new file mode 100644 index 0000000..8539b90 --- /dev/null +++ b/src/libs/cach/test/runtime-explain.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { + CACHE_DECISION_ACTION_FETCH, + CACHE_DECISION_ACTION_SERVE, + CACHE_DECISION_REASON_CACHE_MISS, + CACHE_DECISION_REASON_FRESH, + CACHE_ENVELOPE_STATE_FRESH, + CACHE_READ_MODE_CACHE_FIRST, + CACHE_SCOPE_PUBLIC +} from '../consts.ts'; +import { buildCacheHitExplain, buildCacheMissExplain } from '../runtime-explain.ts'; +import type { ResolvedCacheContext } from '../runtime-context.ts'; +import type { CacheEnvelope } from '../types.ts'; + +function context(): ResolvedCacheContext { + return { + namespace: 'app', + version: '1', + scope: { + mode: CACHE_SCOPE_PUBLIC, + hash: 'public', + values: {} + }, + keyParts: ['user'], + keyCanonical: '["user"]', + keyHash: 'key', + fullKey: 'app:v1:entry:public:key', + schemaVersion: 'User:v1', + policy: { + name: 'interactive', + freshForMs: 100, + staleForMs: 100, + staleIfErrorForMs: 100, + gcAfterMs: 300, + mode: CACHE_READ_MODE_CACHE_FIRST, + persist: true + } + }; +} + +function envelope(): CacheEnvelope { + return { + value: 'value', + meta: { + key: 'app:v1:entry:public:key', + keyHash: 'key', + keyCanonical: '["user"]', + keyParts: ['user'], + namespace: 'app', + engineVersion: '1', + schemaVersion: 'User:v1', + scope: { + mode: CACHE_SCOPE_PUBLIC, + hash: 'public', + values: {} + }, + createdAt: 10, + updatedAt: 20, + freshUntil: 100, + staleUntil: 200, + staleIfErrorUntil: 250, + gcAfter: 300, + state: CACHE_ENVELOPE_STATE_FRESH, + tags: ['user:1'], + tagEpochs: { tag: 1 }, + prefixEpochs: { prefix: 2 }, + hitCount: 3, + lastAccessedAt: 20 + } + }; +} + +describe('runtime explain builders', () => { + it('builds miss explanations', () => { + expect(buildCacheMissExplain(context(), 'memory')).toMatchObject({ + exists: false, + adapter: 'memory', + decision: CACHE_DECISION_ACTION_FETCH, + reason: CACHE_DECISION_REASON_CACHE_MISS, + schemaVersion: { + expected: 'User:v1', + valid: false + } + }); + }); + + it('builds hit explanations with timing and epoch lines', () => { + const result = buildCacheHitExplain( + context(), + 'memory', + envelope(), + { + action: CACHE_DECISION_ACTION_SERVE, + reason: CACHE_DECISION_REASON_FRESH, + state: CACHE_ENVELOPE_STATE_FRESH, + currentTagEpochs: { tag: 1 }, + currentPrefixEpochs: { prefix: 3 } + }, + 50 + ); + + expect(result.exists).toBe(true); + expect(result.timing?.ageMs).toBe(30); + expect(result.epochs?.tags).toEqual([ + { key: 'tag', entryEpoch: 1, currentEpoch: 1, valid: true } + ]); + expect(result.epochs?.prefixes).toEqual([ + { key: 'prefix', entryEpoch: 2, currentEpoch: 3, valid: false } + ]); + }); +}); diff --git a/src/libs/cach/test/runtime-invalidation.test.ts b/src/libs/cach/test/runtime-invalidation.test.ts new file mode 100644 index 0000000..69b980b --- /dev/null +++ b/src/libs/cach/test/runtime-invalidation.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + CACHE_CONTEXT_REASON_KEY, + CACHE_CONTEXT_REASON_KEY_PREFIX, + CACHE_CONTEXT_REASON_TAG, + CACHE_EVENT_INVALIDATE, + CACHE_SCOPE_PUBLIC +} from '../consts.ts'; +import { invalidateCacheEntry } from '../runtime-invalidation.ts'; +import type { ResolvedCacheContext } from '../runtime-context.ts'; +import type { CacheAdapter, CacheEvent, CacheEventType } from '../types.ts'; + +function context(): ResolvedCacheContext { + return { + namespace: 'app', + version: '1', + scope: { + mode: CACHE_SCOPE_PUBLIC, + hash: 'public', + values: {} + }, + keyParts: ['user'], + keyCanonical: '["user"]', + keyHash: 'key', + fullKey: 'app:v1:entry:public:key', + schemaVersion: 'User:v1', + policy: { + name: 'interactive', + freshForMs: 100, + staleForMs: 100, + staleIfErrorForMs: 100, + gcAfterMs: 300, + mode: 'cache-first', + persist: true + } + }; +} + +function runtime() { + const events: Array<{ type: CacheEventType; patch?: Partial }> = []; + const adapter = { + bumpEpoch: vi.fn(async () => 7) + } as unknown as Pick; + return { + events, + safeDelete: vi.fn(async () => {}), + runtime: { + namespace: 'app', + version: '1', + adapter, + config: {}, + contextFor: vi.fn(async () => context()), + safeDelete: vi.fn(async () => {}), + emit: (type: CacheEventType, patch?: Partial) => { + events.push({ type, patch }); + } + } + }; +} + +describe('invalidateCacheEntry', () => { + it('deletes exact keys and emits an invalidation event', async () => { + const harness = runtime(); + await invalidateCacheEntry( + { key: ['user'], scope: CACHE_SCOPE_PUBLIC }, + harness.runtime + ); + + expect(harness.runtime.safeDelete).toHaveBeenCalledWith('app:v1:entry:public:key', 'key'); + expect(harness.events).toEqual([ + { + type: CACHE_EVENT_INVALIDATE, + patch: expect.objectContaining({ + reason: CACHE_CONTEXT_REASON_KEY, + scopeMode: CACHE_SCOPE_PUBLIC + }) + } + ]); + }); + + it('bumps prefix epochs instead of scanning keys', async () => { + const harness = runtime(); + await invalidateCacheEntry( + { keyPrefix: ['users'], scope: CACHE_SCOPE_PUBLIC }, + harness.runtime + ); + + expect(harness.runtime.adapter.bumpEpoch).toHaveBeenCalledOnce(); + expect(harness.events[0]).toMatchObject({ + type: CACHE_EVENT_INVALIDATE, + patch: { + reason: `${CACHE_CONTEXT_REASON_KEY_PREFIX}:7`, + scopeMode: CACHE_SCOPE_PUBLIC + } + }); + }); + + it('normalizes tags and includes them in the event payload', async () => { + const harness = runtime(); + await invalidateCacheEntry( + { tag: { type: 'user', id: 1 }, scope: CACHE_SCOPE_PUBLIC }, + harness.runtime + ); + + expect(harness.runtime.adapter.bumpEpoch).toHaveBeenCalledOnce(); + expect(harness.events[0]).toMatchObject({ + type: CACHE_EVENT_INVALIDATE, + patch: { + reason: `${CACHE_CONTEXT_REASON_TAG}:user:1:7`, + scopeMode: CACHE_SCOPE_PUBLIC, + tags: ['user:1'] + } + }); + }); +}); diff --git a/src/libs/cach/test/runtime-mutation.test.ts b/src/libs/cach/test/runtime-mutation.test.ts new file mode 100644 index 0000000..7caab8b --- /dev/null +++ b/src/libs/cach/test/runtime-mutation.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from 'vitest'; +import { CACHE_READ_MODE_CACHE_FIRST, CACHE_SCOPE_PUBLIC } from '../consts.ts'; +import { runCacheMutation } from '../runtime-mutation.ts'; +import type { ResolvedCacheContext } from '../runtime-context.ts'; +import type { CacheEnvelope } from '../types.ts'; + +function context(schemaVersion = 'Item:v1'): ResolvedCacheContext { + return { + namespace: 'app', + version: '1', + scope: { + mode: CACHE_SCOPE_PUBLIC, + hash: 'public', + values: {} + }, + keyParts: ['item'], + keyCanonical: '["item"]', + keyHash: 'key', + fullKey: 'app:v1:entry:public:key', + schemaVersion, + policy: { + name: 'interactive', + freshForMs: 100, + staleForMs: 100, + staleIfErrorForMs: 100, + gcAfterMs: 300, + mode: CACHE_READ_MODE_CACHE_FIRST, + persist: true + } + }; +} + +function envelope(value: unknown, schemaVersion = 'Item:v1'): CacheEnvelope { + return { + value, + meta: { + key: 'app:v1:entry:public:key', + keyHash: 'key', + keyCanonical: '["item"]', + keyParts: ['item'], + namespace: 'app', + engineVersion: '1', + schemaVersion, + scope: { + mode: CACHE_SCOPE_PUBLIC, + hash: 'public', + values: {} + }, + createdAt: 0, + updatedAt: 0, + freshUntil: 100, + staleUntil: 200, + staleIfErrorUntil: 300, + gcAfter: 400, + state: 'fresh', + tags: ['old'], + tagEpochs: {}, + prefixEpochs: {}, + hitCount: 0, + lastAccessedAt: 0 + } + }; +} + +describe('runCacheMutation', () => { + it('commits, invalidates and returns the commit result', async () => { + const invalidate = vi.fn(async () => {}); + const result = await runCacheMutation( + { + commit: async () => 'ok', + invalidate: [{ key: ['item'], scope: CACHE_SCOPE_PUBLIC }] + }, + { + invalidate, + contextFor: vi.fn(), + getEnvelope: vi.fn(), + safeDelete: vi.fn(), + createEnvelope: vi.fn(), + writeEnvelope: vi.fn() + } + ); + + expect(result).toBe('ok'); + expect(invalidate).toHaveBeenCalledWith({ key: ['item'], scope: CACHE_SCOPE_PUBLIC }); + }); + + it('updates cached entries with either supplied tags or existing tags', async () => { + const written: CacheEnvelope[] = []; + await runCacheMutation( + { + commit: async () => true, + update: [ + { + key: ['item'], + scope: CACHE_SCOPE_PUBLIC, + reducer: (oldValue) => Number(oldValue) + 1, + tags: [{ type: 'item', id: 1 }] + } + ] + }, + { + invalidate: vi.fn(), + contextFor: vi.fn(async () => context()), + getEnvelope: vi.fn(async () => envelope(1)), + safeDelete: vi.fn(), + createEnvelope: vi.fn(async (value, _ctx, tags) => ({ + ...envelope(value), + meta: { ...envelope(value).meta, tags } + })), + writeEnvelope: vi.fn(async (_ctx, next) => { + written.push(next); + }) + } + ); + + expect(written[0]?.value).toBe(2); + expect(written[0]?.meta.tags).toEqual(['item:1']); + }); + + it('deletes stale schema entries instead of applying reducers', async () => { + const reducer = vi.fn(); + const safeDelete = vi.fn(async () => {}); + await runCacheMutation( + { + commit: async () => true, + update: [{ key: ['item'], scope: CACHE_SCOPE_PUBLIC, reducer }] + }, + { + invalidate: vi.fn(), + contextFor: vi.fn(async () => context('Item:v2')), + getEnvelope: vi.fn(async () => envelope(1, 'Item:v1')), + safeDelete, + createEnvelope: vi.fn(), + writeEnvelope: vi.fn() + } + ); + + expect(safeDelete).toHaveBeenCalledWith('app:v1:entry:public:key', 'key'); + expect(reducer).not.toHaveBeenCalled(); + }); +}); diff --git a/src/libs/cach/test/runtime-refresh.test.ts b/src/libs/cach/test/runtime-refresh.test.ts new file mode 100644 index 0000000..116276a --- /dev/null +++ b/src/libs/cach/test/runtime-refresh.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + CACHE_EVENT_REFRESH_ERROR, + CACHE_EVENT_REFRESH_START, + CACHE_EVENT_REFRESH_SUCCESS, + CACHE_READ_MODE_CACHE_FIRST, + CACHE_SCOPE_PUBLIC +} from '../consts.ts'; +import { refreshCacheInBackground } from '../runtime-refresh.ts'; +import type { ResolvedCacheContext } from '../runtime-context.ts'; +import type { CacheEvent, CacheEventType } from '../types.ts'; + +function context(): ResolvedCacheContext { + return { + namespace: 'app', + version: '1', + scope: { + mode: CACHE_SCOPE_PUBLIC, + hash: 'public', + values: {} + }, + keyParts: ['item'], + keyCanonical: '["item"]', + keyHash: 'key', + fullKey: 'app:v1:entry:public:key', + schemaVersion: 'Item:v1', + policy: { + name: 'interactive', + freshForMs: 100, + staleForMs: 100, + staleIfErrorForMs: 100, + gcAfterMs: 300, + mode: CACHE_READ_MODE_CACHE_FIRST, + persist: true + } + }; +} + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('refreshCacheInBackground', () => { + it('does nothing when a refresh is already in flight', () => { + const run = vi.fn(); + const emit = vi.fn(); + refreshCacheInBackground( + context(), + { key: ['item'], scope: CACHE_SCOPE_PUBLIC, fetcher: async () => 'next' }, + { + singleflight: { has: () => true, run }, + fetchAndStore: vi.fn(), + emit + } + ); + + expect(run).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalled(); + }); + + it('emits refresh start and success around the singleflight run', async () => { + const events: CacheEventType[] = []; + refreshCacheInBackground( + context(), + { key: ['item'], scope: CACHE_SCOPE_PUBLIC, fetcher: async () => 'next' }, + { + singleflight: { has: () => false, run: vi.fn(async (_key, fn) => fn()) }, + fetchAndStore: vi.fn(async () => 'next'), + emit: (type) => { + events.push(type); + } + } + ); + + await flushMicrotasks(); + expect(events).toEqual([CACHE_EVENT_REFRESH_START, CACHE_EVENT_REFRESH_SUCCESS]); + }); + + it('emits refresh errors without throwing to the caller', async () => { + const events: Array<{ type: CacheEventType; patch?: Partial }> = []; + const error = new Error('origin down'); + refreshCacheInBackground( + context(), + { key: ['item'], scope: CACHE_SCOPE_PUBLIC, fetcher: async () => 'next' }, + { + singleflight: { + has: () => false, + run: vi.fn(async () => { + throw error; + }) + }, + fetchAndStore: vi.fn(), + emit: (type, patch) => { + events.push({ type, patch }); + } + } + ); + + await flushMicrotasks(); + expect(events[0]?.type).toBe(CACHE_EVENT_REFRESH_START); + expect(events[1]).toMatchObject({ + type: CACHE_EVENT_REFRESH_ERROR, + patch: { error } + }); + }); +}); diff --git a/src/libs/days/epoch.ts b/src/libs/days/epoch.ts index abd4691..4c995f0 100644 --- a/src/libs/days/epoch.ts +++ b/src/libs/days/epoch.ts @@ -1,3 +1,5 @@ +import { DAYS_ERRORS } from './errors'; + /** * Permissive coercion to **epoch milliseconds**. Accepts a numeric value * (passed through), a `Date` (uses `.getTime()`), or an ISO-8601 string @@ -19,9 +21,9 @@ export function toEpochMs(value: number | Date | string): number { if (typeof value === 'string') { const parsed = Date.parse(value); if (Number.isNaN(parsed)) { - throw new TypeError(`toEpochMs: cannot parse "${value}" as a date`); + throw new TypeError(DAYS_ERRORS.EPOCH_PARSE_FAILED(value)); } return parsed; } - throw new TypeError(`toEpochMs: expected number | Date | string, got ${typeof value}`); + throw new TypeError(DAYS_ERRORS.EPOCH_UNSUPPORTED_INPUT(typeof value)); } diff --git a/src/libs/days/errors.ts b/src/libs/days/errors.ts new file mode 100644 index 0000000..9a76388 --- /dev/null +++ b/src/libs/days/errors.ts @@ -0,0 +1,5 @@ +export const DAYS_ERRORS = { + EPOCH_PARSE_FAILED: (value: string): string => `toEpochMs: cannot parse "${value}" as a date`, + EPOCH_UNSUPPORTED_INPUT: (type: string): string => + `toEpochMs: expected number | Date | string, got ${type}` +} as const; diff --git a/src/libs/days/index.ts b/src/libs/days/index.ts index caf39ee..7186f4c 100644 --- a/src/libs/days/index.ts +++ b/src/libs/days/index.ts @@ -21,3 +21,4 @@ export * from './parse'; export * from './format'; export * from './segments'; export * from './epoch'; +export * from './errors'; diff --git a/src/libs/dom/core.ts b/src/libs/dom/core.ts index 7a315be..55b972a 100644 --- a/src/libs/dom/core.ts +++ b/src/libs/dom/core.ts @@ -1,3 +1,5 @@ +import { DOM_ERRORS } from './errors' + const ELEMENT_NODE = 1 const DOCUMENT_NODE = 9 const DOCUMENT_FRAGMENT_NODE = 11 @@ -8,12 +10,12 @@ function isObject(value: unknown): value is Record { function getGlobalDocument(): Document { if (typeof document !== 'undefined') return document - throw new Error('[uix/lib/dom] getDocument() requires a browser document') + throw new Error(DOM_ERRORS.DOCUMENT_REQUIRED) } function getGlobalWindow(): Window { if (typeof window !== 'undefined') return window - throw new Error('[uix/lib/dom] getWindow() requires a browser window') + throw new Error(DOM_ERRORS.WINDOW_REQUIRED) } export const isBrowser = typeof document !== 'undefined' diff --git a/src/libs/dom/errors.ts b/src/libs/dom/errors.ts new file mode 100644 index 0000000..63b58c3 --- /dev/null +++ b/src/libs/dom/errors.ts @@ -0,0 +1,4 @@ +export const DOM_ERRORS = { + DOCUMENT_REQUIRED: '[uix/lib/dom] getDocument() requires a browser document', + WINDOW_REQUIRED: '[uix/lib/dom] getWindow() requires a browser window' +} as const diff --git a/src/libs/dom/index.ts b/src/libs/dom/index.ts index dbcb8a1..1330846 100644 --- a/src/libs/dom/index.ts +++ b/src/libs/dom/index.ts @@ -1,4 +1,5 @@ export * from './core' +export * from './errors' export * from './focus' export * from './locale' export * from './resize-observer' diff --git a/src/libs/logr/diagnostics.ts b/src/libs/logr/diagnostics.ts new file mode 100644 index 0000000..8f877d3 --- /dev/null +++ b/src/libs/logr/diagnostics.ts @@ -0,0 +1,160 @@ +import { LogLevel } from './types.ts'; +import { SILENT_LOGGER } from './silent-logger.ts'; +import type { + CatalogDiagnosticsOptions, + DiagnosticCatalogEntry, + DiagnosticEvent, + DiagnosticEventConfig, + DiagnosticLogDescriptor, + DiagnosticLogEntry, + Diagnostics, + LoggerDiagnosticsOptions, + Logger, + LogInput, + LevelConfig +} from './types.ts'; + +const DIAGNOSTIC_LOGGER_METHODS: Readonly< + Record< + | LogLevel.TRACE + | LogLevel.DEBUG + | LogLevel.INFO + | LogLevel.WARN + | LogLevel.ERROR + | LogLevel.FATAL, + keyof Logger + > +> = { + [LogLevel.TRACE]: 'trace', + [LogLevel.DEBUG]: 'debug', + [LogLevel.INFO]: 'info', + [LogLevel.WARN]: 'warn', + [LogLevel.ERROR]: 'error', + [LogLevel.FATAL]: 'fatal' +}; + +function diagnosticError(meta: unknown): unknown | undefined { + if (typeof meta !== 'object' || meta === null || !('error' in meta)) return undefined; + return meta.error; +} + +function diagnosticInput( + event: TEvent, + descriptor: DiagnosticLogDescriptor +): LogInput { + const input: LogInput = { + context: { + ...diagnosticMetaContext(event.meta), + diagnostic: { + artifact: event.artifact, + type: event.type, + scope: event.scope, + meta: event.meta + } + } + }; + const error = diagnosticError(event.meta); + if (error !== undefined) input.error = error; + if (descriptor.tags !== undefined) input.tags = [...descriptor.tags]; + return input; +} + +function diagnosticMetaContext(meta: unknown): Record { + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return {}; + const context: Record = {}; + for (const [key, value] of Object.entries(meta)) { + if (key !== 'error') context[key] = value; + } + return context; +} + +function resolveCatalogEntry( + entry: DiagnosticCatalogEntry, + event: TEvent +): DiagnosticLogDescriptor | undefined { + if (typeof entry === 'function') return entry(event); + return entry; +} + +function logLevelEnabled( + levels: LevelConfig | undefined, + level: LogLevel, + entry: DiagnosticLogEntry +): boolean { + if (levels === undefined) return true; + const config = levels[level]; + if (config === undefined || config.enabled === false) return false; + return config.filter?.(entry) ?? true; +} + +function resolveDiagnosticOverride( + options: CatalogDiagnosticsOptions, + event: TEvent +): false | { readonly enabled: boolean; readonly level?: LogLevel } | undefined { + const override = options.events?.[event.type as TEvent['type']]; + if (override === undefined) return undefined; + if (override === false) return false; + if (override === true) return { enabled: true }; + return { enabled: override.enabled ?? true, level: override.level }; +} + +function emitByLogLevel( + logger: Logger, + level: LogLevel, + category: string, + message: string, + input: LogInput +): void { + if (level === LogLevel.NONE) return; + const method = DIAGNOSTIC_LOGGER_METHODS[level]; + logger[method](category, message, input); +} + +export function createLoggerDiagnostics( + options: LoggerDiagnosticsOptions +): Diagnostics { + const logger = options.logger ?? SILENT_LOGGER; + return { + logger, + emit(event): void { + const descriptor = options.resolve(event); + if (descriptor === undefined) return; + const category = descriptor.category ?? event.scope ?? event.artifact; + const input = diagnosticInput(event, descriptor); + emitByLogLevel(logger, descriptor.level, category, descriptor.message, input); + } + }; +} + +export function createCatalogDiagnostics( + options: CatalogDiagnosticsOptions +): Diagnostics { + return createLoggerDiagnostics({ + logger: options.logger, + resolve(event): DiagnosticLogDescriptor | undefined { + const type = event.type as TEvent['type']; + const entry = options.catalog[type]; + const descriptor = resolveCatalogEntry(entry, event); + if (descriptor === undefined) return undefined; + const override = resolveDiagnosticOverride(options, event); + if (override === false || override?.enabled === false) return undefined; + const level = override?.level ?? descriptor.level; + const category = + descriptor.category ?? event.scope ?? options.defaultCategory ?? event.artifact; + const diagnosticEntry: DiagnosticLogEntry = { + level, + category, + message: descriptor.message, + tags: descriptor.tags + }; + if (!logLevelEnabled(options.levels, level, diagnosticEntry)) { + return undefined; + } + return { + ...descriptor, + level, + category + }; + } + }); +} diff --git a/src/libs/logr/index.ts b/src/libs/logr/index.ts new file mode 100644 index 0000000..68b801b --- /dev/null +++ b/src/libs/logr/index.ts @@ -0,0 +1,20 @@ +export { createCatalogDiagnostics, createLoggerDiagnostics } from './diagnostics.ts'; +export { SILENT_LOGGER } from './silent-logger.ts'; +export { LogLevel } from './types.ts'; +export type { + CatalogDiagnosticsOptions, + DiagnosticCatalog, + DiagnosticCatalogEntry, + DiagnosticEvent, + DiagnosticEventConfig, + DiagnosticLogDescriptor, + DiagnosticLogEntry, + Diagnostics, + LoggerDiagnosticsOptions, + LevelConfig, + Logger, + LogFn, + LogInput, + LogMessage, + LogSource +} from './types.ts'; diff --git a/src/libs/logr/silent-logger.ts b/src/libs/logr/silent-logger.ts new file mode 100644 index 0000000..bd0d4ff --- /dev/null +++ b/src/libs/logr/silent-logger.ts @@ -0,0 +1,12 @@ +import type { Logger } from './types.ts'; + +function silent(): void {} + +export const SILENT_LOGGER: Logger = { + trace: silent, + debug: silent, + info: silent, + warn: silent, + error: silent, + fatal: silent +}; diff --git a/src/libs/logr/test/diagnostics.test.ts b/src/libs/logr/test/diagnostics.test.ts new file mode 100644 index 0000000..9e5acc4 --- /dev/null +++ b/src/libs/logr/test/diagnostics.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { + LogLevel, + SILENT_LOGGER, + createLoggerDiagnostics, + type DiagnosticEvent, + type Logger +} from '../index.ts'; + +describe('diagnostics', () => { + it('routes framework diagnostics to the configured logger level', () => { + expect.assertions(3); + const calls: unknown[][] = []; + const logger: Logger = { + ...SILENT_LOGGER, + warn(category, message, input) { + calls.push([category, message, input]); + } + }; + + const diagnostics = createLoggerDiagnostics>({ + logger, + resolve: () => ({ + level: LogLevel.WARN, + category: 'cach', + message: 'cache stale', + tags: ['cache'] + }) + }); + + diagnostics.emit({ + artifact: 'cach', + type: 'cache_stale', + scope: 'cach:user', + meta: { key: 'user:1' } + }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.[0]).toBe('cach'); + expect(calls[0]?.[1]).toBe('cache stale'); + }); + + it('normalizes thrown errors into logger input without hiding the diagnostic event', () => { + expect.assertions(3); + const failure = new Error('transport failed'); + let received: unknown; + const logger: Logger = { + ...SILENT_LOGGER, + error(_category, _message, input) { + received = input; + } + }; + + const diagnostics = createLoggerDiagnostics>({ + logger, + resolve: () => ({ + level: LogLevel.ERROR, + message: 'transport error' + }) + }); + + diagnostics.emit({ + artifact: 'conn', + type: 'transport_error', + meta: { error: failure } + }); + + expect(received).toMatchObject({ error: failure }); + expect(received).toMatchObject({ + context: { diagnostic: { artifact: 'conn', type: 'transport_error' } } + }); + expect(received).toMatchObject({ + context: { diagnostic: { meta: { error: failure } } } + }); + }); +}); diff --git a/src/libs/logr/types.ts b/src/libs/logr/types.ts new file mode 100644 index 0000000..f5fe370 --- /dev/null +++ b/src/libs/logr/types.ts @@ -0,0 +1,96 @@ +export enum LogLevel { + TRACE = 0, + DEBUG = 1, + INFO = 2, + WARN = 3, + ERROR = 4, + FATAL = 5, + NONE = 999 +} + +export type LevelConfig = Partial< + Record boolean }> +>; + +export type LogMessage = string | (() => string); + +export interface LogSource { + file?: string; + line?: number; + column?: number; + function?: string; +} + +export interface LogInput { + context?: Record; + error?: unknown; + tags?: string[]; + traceId?: string; + durationMs?: number; + source?: LogSource; +} + +export type LogFn = (category: string, message: LogMessage, input?: LogInput) => void; + +export interface Logger { + trace: LogFn; + debug: LogFn; + info: LogFn; + warn: LogFn; + error: LogFn; + fatal: LogFn; +} + +export interface DiagnosticEvent { + readonly artifact: string; + readonly type: TType; + readonly scope?: string; + readonly meta?: TMeta; +} + +export interface Diagnostics { + readonly logger: Logger; + emit(event: TEvent): void; +} + +export interface DiagnosticLogDescriptor { + readonly level: LogLevel; + readonly category?: string; + readonly message: string; + readonly tags?: readonly string[]; +} + +export interface LoggerDiagnosticsOptions { + readonly logger?: Logger; + readonly resolve: (event: TEvent) => DiagnosticLogDescriptor | undefined; +} + +export type DiagnosticCatalogEntry = + | DiagnosticLogDescriptor + | ((event: TEvent) => DiagnosticLogDescriptor | undefined); + +export type DiagnosticCatalog = Readonly< + Record> +>; + +export type DiagnosticEventConfig = + | boolean + | { + readonly enabled?: boolean; + readonly level?: LogLevel; + }; + +export interface DiagnosticLogEntry { + readonly level: LogLevel; + readonly category: string; + readonly message: string; + readonly tags?: readonly string[]; +} + +export interface CatalogDiagnosticsOptions { + readonly logger?: Logger; + readonly catalog: DiagnosticCatalog; + readonly defaultCategory?: string; + readonly levels?: LevelConfig; + readonly events?: Partial>; +} diff --git a/src/libs/timers/debounce.ts b/src/libs/timers/debounce.ts index 7badc93..0188b05 100644 --- a/src/libs/timers/debounce.ts +++ b/src/libs/timers/debounce.ts @@ -1,6 +1,6 @@ // oxlint-disable-next-line no-explicit-any export function debounce any>(fn: T, wait = 500) { - let timeout: NodeJS.Timeout | null = null; + let timeout: ReturnType | null = null; const debounced = (...args: Parameters) => { if (timeout !== null) { diff --git a/src/libs/timers/test/timers.test.ts b/src/libs/timers/test/timers.test.ts new file mode 100644 index 0000000..6493e4c --- /dev/null +++ b/src/libs/timers/test/timers.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { computeBackoffDelay, debounce } from '../index'; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('libs/timers backoff', () => { + it('computes deterministic exponential delays without jitter', () => { + const options = { minDelayMs: 100, maxDelayMs: 1_000, factor: 2, jitterMs: 0 }; + + expect(computeBackoffDelay(0, options)).toBe(100); + expect(computeBackoffDelay(1, options)).toBe(200); + expect(computeBackoffDelay(2, options)).toBe(400); + expect(computeBackoffDelay(20, options)).toBe(1_000); + }); + + it('normalizes invalid attempts and clamps jittered values', () => { + expect(computeBackoffDelay(Number.NaN, { minDelayMs: 100, jitterMs: 0 })).toBe(100); + expect( + computeBackoffDelay(0, { minDelayMs: 100, maxDelayMs: 120, jitterMs: 50 }, () => 1) + ).toBe(120); + expect(computeBackoffDelay(0, { minDelayMs: 10, maxDelayMs: 100, jitterMs: 50 }, () => 0)).toBe( + 0 + ); + }); +}); + +describe('libs/timers debounce', () => { + it('runs only the latest scheduled call', () => { + vi.useFakeTimers(); + const run = vi.fn(); + const debounced = debounce(run, 50); + + debounced('first'); + debounced('second'); + vi.advanceTimersByTime(49); + expect(run).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith('second'); + }); + + it('cancels pending work on destroy', () => { + vi.useFakeTimers(); + const run = vi.fn(); + const debounced = debounce(run, 50); + + debounced('pending'); + debounced.destroy(); + vi.advanceTimersByTime(50); + + expect(run).not.toHaveBeenCalled(); + }); +}); diff --git a/src/svrs/auth/README.md b/src/svrs/auth/README.md index 4b11a14..9bc63b6 100644 --- a/src/svrs/auth/README.md +++ b/src/svrs/auth/README.md @@ -50,7 +50,9 @@ export const Auth = createEngineAuth({ ``` Ese ejemplo usa adapters memory para que arranque rápido. En producción debes -inyectar adapters reales. +inyectar adapters reales. Los adapters `createMemoryAuth*()` son solo para +tests, demos y desarrollo local: no ofrecen aislamiento multi-proceso, +persistencia durable ni locks transaccionales para rotación de refresh tokens. ## Opciones @@ -371,7 +373,7 @@ endMany(input): Promise; Cuando cambia identidad, `auth` llama: ```ts -cach.invalidateAuth({ +cach.invalidate({ actorRef, tags, reason @@ -404,3 +406,4 @@ La página `/test/auth` usa los memory adapters y sirve como test manual. - WebAuthn debe entrar por adapter production externo. - No hay rate limiter real integrado aún; debe conectarse desde app/server. - El adapter DB genérico necesita repos transaccionales reales para producción. +- Los adapters memory no son aptos para producción. diff --git a/src/svrs/auth/adapters/db.ts b/src/svrs/auth/adapters/db.ts index ea08781..802c8ee 100644 --- a/src/svrs/auth/adapters/db.ts +++ b/src/svrs/auth/adapters/db.ts @@ -1,4 +1,8 @@ -import { AUTH_CREDENTIAL_KINDS, AUTH_INTERNAL_ERROR_MESSAGES } from '$libs/auth/consts'; +import { + AUTH_CREDENTIAL_KINDS, + AUTH_INTERNAL_ERROR_MESSAGES, + AUTH_REVOKE_REASONS +} from '$libs/auth/consts'; import { AuthAdapterError } from '$libs/auth/errors'; import type { AuthStoreAdapter } from '$libs/auth/contracts'; import type { @@ -105,11 +109,26 @@ export function createDbAuthAdapter(repos: AuthDbRepositories): AuthStoreAdapter }, listDevices: (input) => repos.devices.findMany(input as unknown as Readonly>), - revokeDevice: (input) => - repos.devices.update( + revokeDevice: async (input) => { + await repos.devices.update( { id: input.deviceId, actorRef: input.actorRef }, { revokedAt: input.nowMs } - ), + ); + const rows = await repos.sessionBindings.findMany({ + actorRef: input.actorRef, + deviceId: input.deviceId, + revokedAt: null + }); + await Promise.all( + rows.map((row) => + repos.sessionBindings.update( + { sessSessionId: row.sessSessionId }, + { revokedAt: input.nowMs, revokeReason: AUTH_REVOKE_REASONS.DEVICE_REVOKED } + ) + ) + ); + return rows.map((row) => row.sessSessionId); + }, bindSession: (input) => repos.sessionBindings.insert(input), findSessionBinding: (input) => repos.sessionBindings.findOne(input as unknown as Readonly>), diff --git a/src/svrs/auth/adapters/memory-ports.ts b/src/svrs/auth/adapters/memory-ports.ts new file mode 100644 index 0000000..216fcd6 --- /dev/null +++ b/src/svrs/auth/adapters/memory-ports.ts @@ -0,0 +1,222 @@ +import { AUTH_SESSION_STATUSES } from '$libs/auth/consts'; +import type { + AuthActorAdapter, + AuthCacheInvalidationInput, + AuthCachPort, + AuthClockPort, + AuthCreateActorInput, + AuthLogEntry, + AuthMailerAdapter, + AuthMailMessage, + AuthSessPort +} from '$libs/auth/contracts'; +import { createAnonymousAuthSnapshot } from '$libs/auth/helpers'; +import { createAuthActorId, createAuthSessionId } from '$libs/auth/ids'; +import type { + AuthActorRef, + AuthPublicActor, + AuthSessionSnapshot, + AuthSetCookie +} from '$libs/auth/types'; +import type { Logger } from '$libs/logr'; +import type { AuthCryptoPort } from '$libs/auth/contracts'; + +export interface MemoryAuthActors extends AuthActorAdapter { + reset(): void; + snapshot(): readonly AuthPublicActor[]; +} + +export function createMemoryAuthActors(crypto: AuthCryptoPort): MemoryAuthActors { + const actors = new Map(); + return { + async findActorByCredential(input) { + return input.credential.actorRef; + }, + async createActorForSignup(input: AuthCreateActorInput) { + const actorRef: AuthActorRef = { + tenantId: input.tenantId, + actorId: createAuthActorId(crypto) + }; + actors.set(keyActor(actorRef), { + ...actorRef, + primaryIdentifier: input.identifier, + displayName: + typeof input.profile?.displayName === 'string' + ? input.profile.displayName + : input.identifier, + avatarUrl: + typeof input.profile?.avatarUrl === 'string' ? input.profile.avatarUrl : undefined + }); + return actorRef; + }, + async getPublicActor(input: AuthActorRef) { + return cloneOrNull(actors.get(keyActor(input))); + }, + reset() { + actors.clear(); + }, + snapshot() { + return [...actors.values()].map(clone); + } + }; +} + +export function createMemoryAuthSessPort(input: { + readonly crypto: AuthCryptoPort; + readonly clock: AuthClockPort; +}): AuthSessPort { + const sessions = new Map< + string, + { readonly snapshot: AuthSessionSnapshot; readonly endedAt?: number } + >(); + return { + async read(readInput) { + const sessionId = readInput.sessionId; + if (!sessionId) return createAnonymousAuthSnapshot(); + const session = sessions.get(sessionId); + if (!session || session.endedAt) return createAnonymousAuthSnapshot(); + return clone(session.snapshot); + }, + async start(startInput) { + const sessionId = createAuthSessionId(input.crypto); + const nowMs = input.clock.nowMs(); + const snapshot = { + status: AUTH_SESSION_STATUSES.AUTHENTICATED, + actorRef: startInput.actorRef, + sessionId, + deviceId: startInput.deviceId, + aal: startInput.aal, + amr: startInput.amr, + authTime: nowMs, + refreshedAt: nowMs + }; + sessions.set(sessionId, { snapshot }); + return { sessionId, snapshot, setCookies: [] as readonly AuthSetCookie[] }; + }, + async elevate(elevateInput) { + const session = sessions.get(elevateInput.sessionId); + if (!session || session.endedAt) return { snapshot: createAnonymousAuthSnapshot() }; + const snapshot = { + ...session.snapshot, + aal: elevateInput.aal, + amr: elevateInput.amr, + refreshedAt: input.clock.nowMs() + }; + sessions.set(elevateInput.sessionId, { snapshot }); + return { snapshot }; + }, + async end(endInput) { + const session = sessions.get(endInput.sessionId); + if (session) sessions.set(endInput.sessionId, { ...session, endedAt: input.clock.nowMs() }); + }, + async endMany(endInput) { + for (const sessionId of endInput.sessionIds) { + const session = sessions.get(sessionId); + if (session) sessions.set(sessionId, { ...session, endedAt: input.clock.nowMs() }); + } + } + }; +} + +export function createMemoryAuthClock( + initialNowMs = 0 +): AuthClockPort & { advance(ms: number): void; set(nowMs: number): void } { + let now = initialNowMs; + return { + nowMs: () => now, + advance(ms: number) { + now += ms; + }, + set(nowMs: number) { + now = nowMs; + } + }; +} + +export function createMemoryAuthLogr(): Logger & { entries: AuthLogEntry[] } { + const entries: AuthLogEntry[] = []; + function push( + level: AuthLogEntry['level'], + category: string, + message: Parameters[1], + input: Parameters[2] = {} + ): void { + const auth = authLogPayload(input); + entries.push({ + level, + category, + message: typeof message === 'function' ? message() : message, + eventName: auth.eventName, + code: auth.code, + actorRef: auth.actorRef, + tenantId: auth.tenantId, + sessionId: auth.sessionId, + meta: auth.meta, + data: auth.data + }); + } + return { + entries, + trace(category, message, input) { + push('debug', category, message, input); + }, + debug(category, message, input) { + push('debug', category, message, input); + }, + info(category, message, input) { + push('info', category, message, input); + }, + warn(category, message, input) { + push('warn', category, message, input); + }, + error(category, message, input) { + push('error', category, message, input); + }, + fatal(category, message, input) { + push('error', category, message, input); + } + }; +} + +export function createMemoryAuthCach(): AuthCachPort & { + invalidations: AuthCacheInvalidationInput[]; +} { + const invalidations: AuthCacheInvalidationInput[] = []; + return { + invalidations, + async invalidate(input) { + invalidations.push(input); + } + }; +} + +export function createMemoryAuthMailer(): AuthMailerAdapter & { messages: AuthMailMessage[] } { + const messages: AuthMailMessage[] = []; + return { + messages, + async send(input) { + messages.push(input); + } + }; +} + +function authLogPayload( + input: Parameters[2] +): Partial> { + const auth = input?.context?.auth; + return auth && typeof auth === 'object' + ? (auth as Partial>) + : {}; +} + +function keyActor(actorRef: AuthActorRef): string { + return `${actorRef.tenantId}:${actorRef.actorId}`; +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function cloneOrNull(value: T | undefined): T | null { + return value ? clone(value) : null; +} diff --git a/src/svrs/auth/adapters/memory-store.ts b/src/svrs/auth/adapters/memory-store.ts new file mode 100644 index 0000000..76ddae4 --- /dev/null +++ b/src/svrs/auth/adapters/memory-store.ts @@ -0,0 +1,350 @@ +import { AUTH_CREDENTIAL_KINDS, AUTH_INTERNAL_ERROR_MESSAGES, AUTH_REVOKE_REASONS } from '$libs/auth/consts'; +import { AuthAdapterError } from '$libs/auth/errors'; +import type { + AuthBindSessionInput, + AuthCreateFlowInput, + AuthCreatePasswordCredentialInput, + AuthCreateRefreshFamilyInput, + AuthCreateRefreshTokenInput, + AuthFindCredentialInput, + AuthFindFlowInput, + AuthFindLinkedAccountInput, + AuthFindRefreshTokenInput, + AuthFindSessionBindingInput, + AuthLinkAccountInput, + AuthListDevicesStoreInput, + AuthMarkCredentialVerifiedInput, + AuthRevokeActorSessionsInput, + AuthRevokeDeviceStoreInput, + AuthRevokeFlowsInput, + AuthRevokeRefreshFamilyInput, + AuthRevokeSessionBindingInput, + AuthRotateRefreshTokenInput, + AuthRotateRefreshTokenResult, + AuthStoreAdapter, + AuthUpdatePasswordCredentialInput, + AuthUpsertDeviceInput +} from '$libs/auth/contracts'; +import type { + AuthCredentialRecord, + AuthDeviceRecord, + AuthFlowRecord, + AuthLinkedAccountRecord, + AuthPublicActor, + AuthRefreshFamilyRecord, + AuthRefreshTokenRecord, + AuthSessionBindingRecord, + AuthSessionId +} from '$libs/auth/types'; + +export interface MemoryAuthSnapshot { + readonly credentials: readonly AuthCredentialRecord[]; + readonly flows: readonly AuthFlowRecord[]; + readonly linkedAccounts: readonly AuthLinkedAccountRecord[]; + readonly devices: readonly AuthDeviceRecord[]; + readonly sessionBindings: readonly AuthSessionBindingRecord[]; + readonly actors: readonly AuthPublicActor[]; +} + +export interface MemoryAuthAdapter extends AuthStoreAdapter { + reset(): void; + snapshot(): MemoryAuthSnapshot; +} + +export function createMemoryAuthAdapter(): MemoryAuthAdapter { + const credentials = new Map(); + const flows = new Map(); + const linkedAccounts = new Map(); + const devices = new Map(); + const sessionBindings = new Map(); + const refreshFamilies = new Map(); + const refreshTokens = new Map(); + + return { + async findCredentialByIdentifier(input: AuthFindCredentialInput) { + for (const credential of credentials.values()) { + if ( + credential.actorRef.tenantId === input.tenantId && + credential.kind === input.kind && + credential.identifierHash === input.identifierHash + ) + return clone(credential); + } + return null; + }, + + async createPasswordCredential(input: AuthCreatePasswordCredentialInput) { + const record: AuthCredentialRecord = { + id: input.id, + actorRef: input.actorRef, + kind: AUTH_CREDENTIAL_KINDS.PASSWORD, + identifierHash: input.identifierHash, + identifierDisplay: input.identifierDisplay, + passwordHash: input.passwordHash, + createdAt: input.nowMs, + updatedAt: input.nowMs + }; + credentials.set(record.id, record); + return clone(record); + }, + + async updatePasswordCredential(input: AuthUpdatePasswordCredentialInput) { + const record = credentials.get(input.credentialId); + if (!record || record.actorRef.tenantId !== input.tenantId) return; + credentials.set(record.id, { + ...record, + passwordHash: input.passwordHash, + updatedAt: input.nowMs + }); + }, + + async markCredentialVerified(input: AuthMarkCredentialVerifiedInput) { + const record = credentials.get(input.credentialId); + if (!record || record.actorRef.tenantId !== input.tenantId) return; + credentials.set(record.id, { ...record, verifiedAt: input.nowMs, updatedAt: input.nowMs }); + }, + + async createFlow(input: AuthCreateFlowInput) { + const record: AuthFlowRecord = { ...input }; + flows.set(record.id, record); + return clone(record); + }, + + async findFlowForUpdate(input: AuthFindFlowInput) { + const direct = flows.get(input.flowId); + if ( + direct && + direct.tenantId === input.tenantId && + (!input.kind || direct.kind === input.kind) + ) + return clone(direct); + for (const flow of flows.values()) { + if ( + flow.tenantId === input.tenantId && + (!input.kind || flow.kind === input.kind) && + flow.stateHash === input.flowId + ) { + return clone(flow); + } + } + return null; + }, + + async consumeFlow(input) { + const record = flows.get(input.flowId); + if (!record || record.tenantId !== input.tenantId) return; + flows.set(record.id, { ...record, consumedAt: input.nowMs }); + }, + + async revokeFlows(input: AuthRevokeFlowsInput) { + for (const flow of flows.values()) { + const sameTenant = flow.tenantId === input.tenantId; + const sameActor = !input.actorRef || flow.actorRef?.actorId === input.actorRef.actorId; + const sameKind = !input.kind || flow.kind === input.kind; + if (sameTenant && sameActor && sameKind && !flow.consumedAt) + flows.set(flow.id, { ...flow, consumedAt: input.nowMs }); + } + }, + + async findLinkedAccount(input: AuthFindLinkedAccountInput) { + for (const record of linkedAccounts.values()) { + if ( + record.actorRef.tenantId === input.tenantId && + record.providerId === input.providerId && + record.providerSubject === input.providerSubject && + !record.revokedAt + ) + return clone(record); + } + return null; + }, + + async linkAccount(input: AuthLinkAccountInput) { + const record: AuthLinkedAccountRecord = { + id: input.id, + actorRef: input.actorRef, + providerId: input.providerId, + providerKind: input.providerKind, + providerSubject: input.providerSubject, + email: input.email, + emailVerified: input.emailVerified, + createdAt: input.nowMs, + updatedAt: input.nowMs + }; + linkedAccounts.set(record.id, record); + return clone(record); + }, + + async upsertDevice(input: AuthUpsertDeviceInput) { + const existing = input.deviceId ? devices.get(input.deviceId) : undefined; + const record: AuthDeviceRecord = + existing && existing.actorRef.actorId === input.actorRef.actorId + ? { + ...existing, + displayName: input.displayName ?? existing.displayName, + userAgentDisplay: input.userAgentDisplay ?? existing.userAgentDisplay, + ipDisplayHint: input.ipDisplayHint ?? existing.ipDisplayHint, + lastSeenAt: input.nowMs + } + : { + id: input.deviceId!, + actorRef: input.actorRef, + displayName: input.displayName, + userAgentDisplay: input.userAgentDisplay, + ipDisplayHint: input.ipDisplayHint, + firstSeenAt: input.nowMs, + lastSeenAt: input.nowMs + }; + devices.set(record.id, record); + return clone(record); + }, + + async listDevices(input: AuthListDevicesStoreInput) { + return [...devices.values()] + .filter( + (device) => + device.actorRef.tenantId === input.actorRef.tenantId && + device.actorRef.actorId === input.actorRef.actorId + ) + .map(clone); + }, + + async revokeDevice(input: AuthRevokeDeviceStoreInput) { + const record = devices.get(input.deviceId); + if ( + !record || + record.actorRef.actorId !== input.actorRef.actorId || + record.actorRef.tenantId !== input.actorRef.tenantId + ) + return []; + const revoked: AuthSessionId[] = []; + devices.set(record.id, { ...record, revokedAt: input.nowMs }); + for (const binding of sessionBindings.values()) { + if (binding.deviceId === input.deviceId && !binding.revokedAt) { + sessionBindings.set(binding.sessSessionId, { + ...binding, + revokedAt: input.nowMs, + revokeReason: AUTH_REVOKE_REASONS.DEVICE_REVOKED + }); + revoked.push(binding.sessSessionId); + } + } + return revoked; + }, + + async bindSession(input: AuthBindSessionInput) { + const record: AuthSessionBindingRecord = { ...input }; + sessionBindings.set(record.sessSessionId, record); + return clone(record); + }, + + async findSessionBinding(input: AuthFindSessionBindingInput) { + return cloneOrNull(sessionBindings.get(input.sessSessionId)); + }, + + async revokeSessionBinding(input: AuthRevokeSessionBindingInput) { + const record = sessionBindings.get(input.sessSessionId); + if (!record) return; + sessionBindings.set(record.sessSessionId, { + ...record, + revokedAt: input.nowMs, + revokeReason: input.reason + }); + }, + + async revokeActorSessions(input: AuthRevokeActorSessionsInput) { + const revoked: AuthSessionId[] = []; + for (const binding of sessionBindings.values()) { + const sameActor = + binding.actorRef.tenantId === input.actorRef.tenantId && + binding.actorRef.actorId === input.actorRef.actorId; + if (sameActor && !binding.revokedAt) { + sessionBindings.set(binding.sessSessionId, { + ...binding, + revokedAt: input.nowMs, + revokeReason: input.reason + }); + revoked.push(binding.sessSessionId); + } + } + return revoked; + }, + + async createRefreshFamily(input: AuthCreateRefreshFamilyInput) { + const record: AuthRefreshFamilyRecord = { ...input }; + refreshFamilies.set(record.id, record); + return clone(record); + }, + + async createRefreshToken(input: AuthCreateRefreshTokenInput) { + const record: AuthRefreshTokenRecord = { ...input }; + refreshTokens.set(record.id, record); + return clone(record); + }, + + async findRefreshTokenForUpdate(input: AuthFindRefreshTokenInput) { + for (const record of refreshTokens.values()) { + if (record.tokenHash === input.tokenHash) return clone(record); + } + return null; + }, + + async rotateRefreshToken( + input: AuthRotateRefreshTokenInput + ): Promise { + const current = refreshTokens.get(input.currentTokenId); + if (!current) + throw new AuthAdapterError(AUTH_INTERNAL_ERROR_MESSAGES.MEMORY_REFRESH_TOKEN_NOT_FOUND, { + currentTokenId: input.currentTokenId + }); + const child: AuthRefreshTokenRecord = { ...input.childToken }; + const updatedCurrent = { ...current, consumedAt: input.consumedAt, childTokenId: child.id }; + refreshTokens.set(current.id, updatedCurrent); + refreshTokens.set(child.id, child); + return { current: clone(updatedCurrent), child: clone(child) }; + }, + + async revokeRefreshFamily(input: AuthRevokeRefreshFamilyInput) { + const family = refreshFamilies.get(input.familyId); + if (family) + refreshFamilies.set(family.id, { + ...family, + revokedAt: input.nowMs, + revokeReason: input.reason + }); + for (const token of refreshTokens.values()) { + if (token.familyId === input.familyId && !token.revokedAt) + refreshTokens.set(token.id, { ...token, revokedAt: input.nowMs }); + } + }, + + reset() { + credentials.clear(); + flows.clear(); + linkedAccounts.clear(); + devices.clear(); + sessionBindings.clear(); + refreshFamilies.clear(); + refreshTokens.clear(); + }, + + snapshot() { + return { + credentials: [...credentials.values()].map(clone), + flows: [...flows.values()].map(clone), + linkedAccounts: [...linkedAccounts.values()].map(clone), + devices: [...devices.values()].map(clone), + sessionBindings: [...sessionBindings.values()].map(clone), + actors: [] + }; + } + }; +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function cloneOrNull(value: T | undefined): T | null { + return value ? clone(value) : null; +} diff --git a/src/svrs/auth/adapters/memory.ts b/src/svrs/auth/adapters/memory.ts index c6e0def..000844a 100644 --- a/src/svrs/auth/adapters/memory.ts +++ b/src/svrs/auth/adapters/memory.ts @@ -1,546 +1,14 @@ -import { - AUTH_CREDENTIAL_KINDS, - AUTH_INTERNAL_ERROR_MESSAGES, - AUTH_REVOKE_REASONS, - AUTH_SESSION_STATUSES -} from '$libs/auth/consts'; -import type { - AuthActorAdapter, - AuthBindSessionInput, - AuthCreateActorInput, - AuthCreateFlowInput, - AuthCreatePasswordCredentialInput, - AuthCreateRefreshFamilyInput, - AuthCreateRefreshTokenInput, - AuthFindCredentialInput, - AuthFindFlowInput, - AuthFindLinkedAccountInput, - AuthFindRefreshTokenInput, - AuthFindSessionBindingInput, - AuthLinkAccountInput, - AuthListDevicesStoreInput, - AuthRevokeActorSessionsInput, - AuthRevokeDeviceStoreInput, - AuthRevokeFlowsInput, - AuthRevokeRefreshFamilyInput, - AuthRevokeSessionBindingInput, - AuthRotateRefreshTokenInput, - AuthRotateRefreshTokenResult, - AuthSessPort, - AuthStoreAdapter, - AuthUpdatePasswordCredentialInput, - AuthUpsertDeviceInput, - AuthClockPort, - AuthLogrPort, - AuthCachPort, - AuthMailerAdapter, - AuthMarkCredentialVerifiedInput, - AuthLogEntry, - AuthCacheInvalidationInput, - AuthMailMessage -} from '$libs/auth/contracts'; -import { AuthAdapterError } from '$libs/auth/errors'; -import type { - AuthActorRef, - AuthCredentialRecord, - AuthDeviceRecord, - AuthFlowRecord, - AuthLinkedAccountRecord, - AuthPublicActor, - AuthRefreshFamilyRecord, - AuthRefreshTokenRecord, - AuthSessionBindingRecord, - AuthSessionId, - AuthSessionSnapshot, - AuthSetCookie -} from '$libs/auth/types'; -import { createAnonymousAuthSnapshot } from '$libs/auth/helpers'; -import { createAuthActorId, createAuthSessionId } from '$libs/auth/ids'; -import type { AuthCryptoPort } from '$libs/auth/contracts'; - -export interface MemoryAuthSnapshot { - readonly credentials: readonly AuthCredentialRecord[]; - readonly flows: readonly AuthFlowRecord[]; - readonly linkedAccounts: readonly AuthLinkedAccountRecord[]; - readonly devices: readonly AuthDeviceRecord[]; - readonly sessionBindings: readonly AuthSessionBindingRecord[]; - readonly actors: readonly AuthPublicActor[]; -} - -export interface MemoryAuthAdapter extends AuthStoreAdapter { - reset(): void; - snapshot(): MemoryAuthSnapshot; -} - -export interface MemoryAuthActors extends AuthActorAdapter { - reset(): void; - snapshot(): readonly AuthPublicActor[]; -} - -export function createMemoryAuthAdapter(): MemoryAuthAdapter { - const credentials = new Map(); - const flows = new Map(); - const linkedAccounts = new Map(); - const devices = new Map(); - const sessionBindings = new Map(); - const refreshFamilies = new Map(); - const refreshTokens = new Map(); - - const adapter: MemoryAuthAdapter = { - async findCredentialByIdentifier(input: AuthFindCredentialInput) { - for (const credential of credentials.values()) { - if ( - credential.actorRef.tenantId === input.tenantId && - credential.kind === input.kind && - credential.identifierHash === input.identifierHash - ) - return clone(credential); - } - return null; - }, - - async createPasswordCredential(input: AuthCreatePasswordCredentialInput) { - const record: AuthCredentialRecord = { - id: input.id, - actorRef: input.actorRef, - kind: AUTH_CREDENTIAL_KINDS.PASSWORD, - identifierHash: input.identifierHash, - identifierDisplay: input.identifierDisplay, - passwordHash: input.passwordHash, - createdAt: input.nowMs, - updatedAt: input.nowMs - }; - credentials.set(record.id, record); - return clone(record); - }, - - async updatePasswordCredential(input: AuthUpdatePasswordCredentialInput) { - const record = credentials.get(input.credentialId); - if (!record || record.actorRef.tenantId !== input.tenantId) return; - credentials.set(record.id, { - ...record, - passwordHash: input.passwordHash, - updatedAt: input.nowMs - }); - }, - - async markCredentialVerified(input: AuthMarkCredentialVerifiedInput) { - const record = credentials.get(input.credentialId); - if (!record || record.actorRef.tenantId !== input.tenantId) return; - credentials.set(record.id, { ...record, verifiedAt: input.nowMs, updatedAt: input.nowMs }); - }, - - async createFlow(input: AuthCreateFlowInput) { - const record: AuthFlowRecord = { ...input }; - flows.set(record.id, record); - return clone(record); - }, - - async findFlowForUpdate(input: AuthFindFlowInput) { - const direct = flows.get(input.flowId); - if ( - direct && - direct.tenantId === input.tenantId && - (!input.kind || direct.kind === input.kind) - ) - return clone(direct); - for (const flow of flows.values()) { - if ( - flow.tenantId === input.tenantId && - (!input.kind || flow.kind === input.kind) && - flow.stateHash === input.flowId - ) { - return clone(flow); - } - } - return null; - }, - - async consumeFlow(input) { - const record = flows.get(input.flowId); - if (!record || record.tenantId !== input.tenantId) return; - flows.set(record.id, { ...record, consumedAt: input.nowMs }); - }, - - async revokeFlows(input: AuthRevokeFlowsInput) { - for (const flow of flows.values()) { - const sameTenant = flow.tenantId === input.tenantId; - const sameActor = !input.actorRef || flow.actorRef?.actorId === input.actorRef.actorId; - const sameKind = !input.kind || flow.kind === input.kind; - if (sameTenant && sameActor && sameKind && !flow.consumedAt) - flows.set(flow.id, { ...flow, consumedAt: input.nowMs }); - } - }, - - async findLinkedAccount(input: AuthFindLinkedAccountInput) { - for (const record of linkedAccounts.values()) { - if ( - record.actorRef.tenantId === input.tenantId && - record.providerId === input.providerId && - record.providerSubject === input.providerSubject && - !record.revokedAt - ) - return clone(record); - } - return null; - }, - - async linkAccount(input: AuthLinkAccountInput) { - const record: AuthLinkedAccountRecord = { - id: input.id, - actorRef: input.actorRef, - providerId: input.providerId, - providerKind: input.providerKind, - providerSubject: input.providerSubject, - email: input.email, - emailVerified: input.emailVerified, - createdAt: input.nowMs, - updatedAt: input.nowMs - }; - linkedAccounts.set(record.id, record); - return clone(record); - }, - - async upsertDevice(input: AuthUpsertDeviceInput) { - const existing = input.deviceId ? devices.get(input.deviceId) : undefined; - const record: AuthDeviceRecord = - existing && existing.actorRef.actorId === input.actorRef.actorId - ? { - ...existing, - displayName: input.displayName ?? existing.displayName, - userAgentDisplay: input.userAgentDisplay ?? existing.userAgentDisplay, - ipDisplayHint: input.ipDisplayHint ?? existing.ipDisplayHint, - lastSeenAt: input.nowMs - } - : { - id: input.deviceId!, - actorRef: input.actorRef, - displayName: input.displayName, - userAgentDisplay: input.userAgentDisplay, - ipDisplayHint: input.ipDisplayHint, - firstSeenAt: input.nowMs, - lastSeenAt: input.nowMs - }; - devices.set(record.id, record); - return clone(record); - }, - - async listDevices(input: AuthListDevicesStoreInput) { - return [...devices.values()] - .filter( - (device) => - device.actorRef.tenantId === input.actorRef.tenantId && - device.actorRef.actorId === input.actorRef.actorId - ) - .map(clone); - }, - - async revokeDevice(input: AuthRevokeDeviceStoreInput) { - const record = devices.get(input.deviceId); - if ( - !record || - record.actorRef.actorId !== input.actorRef.actorId || - record.actorRef.tenantId !== input.actorRef.tenantId - ) - return; - devices.set(record.id, { ...record, revokedAt: input.nowMs }); - for (const binding of sessionBindings.values()) { - if (binding.deviceId === input.deviceId && !binding.revokedAt) { - sessionBindings.set(binding.sessSessionId, { - ...binding, - revokedAt: input.nowMs, - revokeReason: AUTH_REVOKE_REASONS.DEVICE_REVOKED - }); - } - } - }, - - async bindSession(input: AuthBindSessionInput) { - const record: AuthSessionBindingRecord = { ...input }; - sessionBindings.set(record.sessSessionId, record); - return clone(record); - }, - - async findSessionBinding(input: AuthFindSessionBindingInput) { - return cloneOrNull(sessionBindings.get(input.sessSessionId)); - }, - - async revokeSessionBinding(input: AuthRevokeSessionBindingInput) { - const record = sessionBindings.get(input.sessSessionId); - if (!record) return; - sessionBindings.set(record.sessSessionId, { - ...record, - revokedAt: input.nowMs, - revokeReason: input.reason - }); - }, - - async revokeActorSessions(input: AuthRevokeActorSessionsInput) { - const revoked: AuthSessionId[] = []; - for (const binding of sessionBindings.values()) { - const sameActor = - binding.actorRef.tenantId === input.actorRef.tenantId && - binding.actorRef.actorId === input.actorRef.actorId; - if (sameActor && !binding.revokedAt) { - sessionBindings.set(binding.sessSessionId, { - ...binding, - revokedAt: input.nowMs, - revokeReason: input.reason - }); - revoked.push(binding.sessSessionId); - } - } - return revoked; - }, - - async createRefreshFamily(input: AuthCreateRefreshFamilyInput) { - const record: AuthRefreshFamilyRecord = { ...input }; - refreshFamilies.set(record.id, record); - return clone(record); - }, - - async createRefreshToken(input: AuthCreateRefreshTokenInput) { - const record: AuthRefreshTokenRecord = { ...input }; - refreshTokens.set(record.id, record); - return clone(record); - }, - - async findRefreshTokenForUpdate(input: AuthFindRefreshTokenInput) { - for (const record of refreshTokens.values()) { - if (record.tokenHash === input.tokenHash) return clone(record); - } - return null; - }, - - async rotateRefreshToken( - input: AuthRotateRefreshTokenInput - ): Promise { - const current = refreshTokens.get(input.currentTokenId); - if (!current) - throw new AuthAdapterError(AUTH_INTERNAL_ERROR_MESSAGES.MEMORY_REFRESH_TOKEN_NOT_FOUND, { - currentTokenId: input.currentTokenId - }); - const child: AuthRefreshTokenRecord = { ...input.childToken }; - const updatedCurrent = { ...current, consumedAt: input.consumedAt, childTokenId: child.id }; - refreshTokens.set(current.id, updatedCurrent); - refreshTokens.set(child.id, child); - return { current: clone(updatedCurrent), child: clone(child) }; - }, - - async revokeRefreshFamily(input: AuthRevokeRefreshFamilyInput) { - const family = refreshFamilies.get(input.familyId); - if (family) - refreshFamilies.set(family.id, { - ...family, - revokedAt: input.nowMs, - revokeReason: input.reason - }); - for (const token of refreshTokens.values()) { - if (token.familyId === input.familyId && !token.revokedAt) - refreshTokens.set(token.id, { ...token, revokedAt: input.nowMs }); - } - }, - - reset() { - credentials.clear(); - flows.clear(); - linkedAccounts.clear(); - devices.clear(); - sessionBindings.clear(); - refreshFamilies.clear(); - refreshTokens.clear(); - }, - - snapshot() { - return { - credentials: [...credentials.values()].map(clone), - flows: [...flows.values()].map(clone), - linkedAccounts: [...linkedAccounts.values()].map(clone), - devices: [...devices.values()].map(clone), - sessionBindings: [...sessionBindings.values()].map(clone), - actors: [] - }; - } - }; - - return adapter; -} - -export function createMemoryAuthActors(crypto: AuthCryptoPort): MemoryAuthActors { - const actors = new Map(); - return { - async findActorByCredential(input) { - return input.credential.actorRef; - }, - async createActorForSignup(input: AuthCreateActorInput) { - const actorRef: AuthActorRef = { - tenantId: input.tenantId, - actorId: createAuthActorId(crypto) - }; - actors.set(keyActor(actorRef), { - ...actorRef, - primaryIdentifier: input.identifier, - displayName: - typeof input.profile?.displayName === 'string' - ? input.profile.displayName - : input.identifier, - avatarUrl: - typeof input.profile?.avatarUrl === 'string' ? input.profile.avatarUrl : undefined - }); - return actorRef; - }, - async getPublicActor(input: AuthActorRef) { - return cloneOrNull(actors.get(keyActor(input))); - }, - reset() { - actors.clear(); - }, - snapshot() { - return [...actors.values()].map(clone); - } - }; -} - -export function createMemoryAuthSessPort(input: { - readonly crypto: AuthCryptoPort; - readonly clock: AuthClockPort; -}): AuthSessPort { - const sessions = new Map< - string, - { readonly snapshot: AuthSessionSnapshot; readonly endedAt?: number } - >(); - return { - async read(readInput) { - const sessionId = readInput.sessionId; - if (!sessionId) return createAnonymousAuthSnapshot(); - const session = sessions.get(sessionId); - if (!session || session.endedAt) return createAnonymousAuthSnapshot(); - return clone(session.snapshot); - }, - async start(startInput) { - const sessionId = createAuthSessionId(input.crypto); - const nowMs = input.clock.nowMs(); - const snapshot = { - status: AUTH_SESSION_STATUSES.AUTHENTICATED, - actorRef: startInput.actorRef, - sessionId, - deviceId: startInput.deviceId, - aal: startInput.aal, - amr: startInput.amr, - authTime: nowMs, - refreshedAt: nowMs - }; - sessions.set(sessionId, { snapshot }); - return { sessionId, snapshot, setCookies: [] as readonly AuthSetCookie[] }; - }, - async elevate(elevateInput) { - const session = sessions.get(elevateInput.sessionId); - if (!session || session.endedAt) return { snapshot: createAnonymousAuthSnapshot() }; - const snapshot = { - ...session.snapshot, - aal: elevateInput.aal, - amr: elevateInput.amr, - refreshedAt: input.clock.nowMs() - }; - sessions.set(elevateInput.sessionId, { snapshot }); - return { snapshot }; - }, - async end(endInput) { - const session = sessions.get(endInput.sessionId); - if (session) sessions.set(endInput.sessionId, { ...session, endedAt: input.clock.nowMs() }); - }, - async endMany(endInput) { - for (const sessionId of endInput.sessionIds) { - const session = sessions.get(sessionId); - if (session) sessions.set(sessionId, { ...session, endedAt: input.clock.nowMs() }); - } - } - }; -} - -export function createMemoryAuthClock( - initialNowMs = 0 -): AuthClockPort & { advance(ms: number): void; set(nowMs: number): void } { - let now = initialNowMs; - return { - nowMs: () => now, - advance(ms: number) { - now += ms; - }, - set(nowMs: number) { - now = nowMs; - } - }; -} - -export function createMemoryAuthLogr(): AuthLogrPort & { entries: AuthLogEntry[] } { - const entries: AuthLogEntry[] = []; - function push( - level: AuthLogEntry['level'], - category: string, - message: string, - input: Parameters[2] = {} - ): void { - entries.push({ - level, - category, - message, - eventName: input.eventName, - code: input.code, - actorRef: input.actorRef, - tenantId: input.tenantId, - sessionId: input.sessionId, - meta: input.meta, - data: input.data - }); - } - return { - entries, - debug(category, message, input) { - push('debug', category, message, input); - }, - info(category, message, input) { - push('info', category, message, input); - }, - warn(category, message, input) { - push('warn', category, message, input); - }, - error(category, message, input) { - push('error', category, message, input); - } - }; -} - -export function createMemoryAuthCach(): AuthCachPort & { - invalidations: AuthCacheInvalidationInput[]; -} { - const invalidations: AuthCacheInvalidationInput[] = []; - return { - invalidations, - async invalidate(input) { - invalidations.push(input); - } - }; -} - -export function createMemoryAuthMailer(): AuthMailerAdapter & { messages: AuthMailMessage[] } { - const messages: AuthMailMessage[] = []; - return { - messages, - async send(input) { - messages.push(input); - } - }; -} - -function keyActor(actorRef: AuthActorRef): string { - return `${actorRef.tenantId}:${actorRef.actorId}`; -} - -function clone(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - -function cloneOrNull(value: T | undefined): T | null { - return value ? clone(value) : null; -} +export { + createMemoryAuthAdapter, + type MemoryAuthAdapter, + type MemoryAuthSnapshot +} from './memory-store.ts'; +export { + createMemoryAuthActors, + createMemoryAuthCach, + createMemoryAuthClock, + createMemoryAuthLogr, + createMemoryAuthMailer, + createMemoryAuthSessPort, + type MemoryAuthActors +} from './memory-ports.ts'; diff --git a/src/svrs/auth/device-flow.ts b/src/svrs/auth/device-flow.ts new file mode 100644 index 0000000..9d479b1 --- /dev/null +++ b/src/svrs/auth/device-flow.ts @@ -0,0 +1,44 @@ +import { AUTH_EVENT_NAMES } from '$libs/auth/consts'; +import type { + AuthDevicePublicView, + AuthListDevicesInput, + AuthRevokeDeviceInput, + AuthRevokeDeviceResult +} from '$libs/auth/types'; +import { toPublicDevice } from './devices'; +import type { AuthEngineFlowContext } from './engine-internal'; + +export function createDeviceAuthFlow(context: AuthEngineFlowContext) { + const { options, emit, invalidateIdentityCache } = context; + + async function listDevices( + input: AuthListDevicesInput + ): Promise { + const devices = await options.ports.store.listDevices({ actorRef: input.actorRef }); + return devices.map(toPublicDevice); + } + + async function revokeDevice(input: AuthRevokeDeviceInput): Promise { + const nowMs = options.ports.timr.nowMs(); + const revokedSessionIds = await options.ports.store.revokeDevice({ + actorRef: input.actorRef, + deviceId: input.deviceId, + nowMs + }); + await options.ports.sess.endMany({ sessionIds: revokedSessionIds, meta: input.meta }); + await invalidateIdentityCache({ + actorRef: input.actorRef, + reason: AUTH_EVENT_NAMES.DEVICE_REVOKED + }); + await emit(AUTH_EVENT_NAMES.DEVICE_REVOKED, { + name: AUTH_EVENT_NAMES.DEVICE_REVOKED, + tenantId: input.actorRef.tenantId, + actorRef: input.actorRef, + deviceId: input.deviceId, + meta: input.meta + }); + return { ok: true, revokedSessionIds }; + } + + return { listDevices, revokeDevice }; +} diff --git a/src/svrs/auth/devices.ts b/src/svrs/auth/devices.ts new file mode 100644 index 0000000..feaee2d --- /dev/null +++ b/src/svrs/auth/devices.ts @@ -0,0 +1,13 @@ +import type { AuthDevicePublicView, AuthDeviceRecord } from '$libs/auth/types'; + +export function toPublicDevice(record: AuthDeviceRecord): AuthDevicePublicView { + return { + id: record.id, + displayName: record.displayName, + userAgentDisplay: record.userAgentDisplay, + ipDisplayHint: record.ipDisplayHint, + firstSeenAt: record.firstSeenAt, + lastSeenAt: record.lastSeenAt, + revokedAt: record.revokedAt + }; +} diff --git a/src/svrs/auth/diagnostics.ts b/src/svrs/auth/diagnostics.ts new file mode 100644 index 0000000..6209ebc --- /dev/null +++ b/src/svrs/auth/diagnostics.ts @@ -0,0 +1,47 @@ +import { AUTH_ARTIFACT, AUTH_DIAGNOSTIC_EVENTS, AUTH_LOG_LEVELS } from '$libs/auth/consts'; +import type { AuthLogEntry } from '$libs/auth/contracts'; +import { createLoggerDiagnostics, LogLevel } from '$libs/logr'; +import type { DiagnosticEvent, Diagnostics, Logger } from '$libs/logr'; + +export type AuthDiagnosticType = + (typeof AUTH_DIAGNOSTIC_EVENTS)[keyof typeof AUTH_DIAGNOSTIC_EVENTS]; + +type AuthDiagnosticMeta = { + readonly auth: Omit; +}; + +export type AuthDiagnosticEvent = DiagnosticEvent & { + readonly log: Pick; +}; +export type AuthDiagnostics = Diagnostics; + +const AUTH_LOG_LEVEL_TO_LOGR_LEVEL: Readonly> = { + [AUTH_LOG_LEVELS.DEBUG]: LogLevel.DEBUG, + [AUTH_LOG_LEVELS.INFO]: LogLevel.INFO, + [AUTH_LOG_LEVELS.WARN]: LogLevel.WARN, + [AUTH_LOG_LEVELS.ERROR]: LogLevel.ERROR +}; + +export function createAuthDiagnostics(logger: Logger): AuthDiagnostics { + return createLoggerDiagnostics({ + logger, + resolve(event) { + return { + level: AUTH_LOG_LEVEL_TO_LOGR_LEVEL[event.log.level], + category: event.log.category, + message: event.log.message + }; + } + }); +} + +export function emitAuthDiagnostic(diagnostics: AuthDiagnostics, entry: AuthLogEntry): void { + const { level, category, message, ...meta } = entry; + diagnostics.emit({ + artifact: AUTH_ARTIFACT, + type: AUTH_DIAGNOSTIC_EVENTS.SECURITY_EVENT, + scope: category, + meta: { auth: meta }, + log: { level, category, message } + }); +} diff --git a/src/svrs/auth/engine-auth.ts b/src/svrs/auth/engine-auth.ts index c5e8615..4dfa9a1 100644 --- a/src/svrs/auth/engine-auth.ts +++ b/src/svrs/auth/engine-auth.ts @@ -1,49 +1,9 @@ -import { - AUTH_AAL, - AUTH_AMR, - AUTH_CREDENTIAL_KINDS, - AUTH_ARTIFACT, - AUTH_DEFAULTS, - AUTH_ENGINE_METHODS, - AUTH_EVENT_NAMES, - AUTH_EVENT_PREFIXES, - AUTH_FLOW_KINDS, - AUTH_LOG_CATEGORIES, - AUTH_LOG_LEVELS, - AUTH_LOG_MESSAGES, - AUTH_MAIL_KINDS, - AUTH_OAUTH_PARAMS, - AUTH_OAUTH_VALUES, - AUTH_POLICY_KEYS, - AUTH_REVOKE_REASONS, - AUTH_SESSION_STATUSES -} from '$libs/auth/consts'; -import type { - AuthCacheInvalidationInput, - AuthEventHandler, - AuthLogEntry -} from '$libs/auth/contracts'; -import { - AuthAccountNotLinkedError, - AuthConfigError, - AuthCredentialError, - AuthFlowExpiredError, - AuthFlowInvalidError, - AuthIdentifierTakenError, - AuthSessionRequiredError -} from '$libs/auth/errors'; +import { AUTH_ARTIFACT, AUTH_EVENT_NAMES } from '$libs/auth/consts'; +import type { AuthCacheInvalidationInput, AuthEventHandler } from '$libs/auth/contracts'; import type { AuthEventPayloadMap } from '$libs/auth/events'; -import { - authCacheTagsForIdentity, - createAnonymousAuthCurrent, - extractAuthRequestMeta -} from '$libs/auth/helpers'; -import { createAuthCredentialId, createAuthDeviceId, createAuthFlowId } from '$libs/auth/ids'; -import { hashPasswordIdentifier, normalizeAuthIdentifier } from '$libs/auth/normalize'; +import { authCacheTagsForIdentity } from '$libs/auth/helpers'; import { issueAuthCsrf, verifyAuthCsrf } from '$libs/auth/csrf'; -import { createAuthRandomToken, hashAuthToken } from '$libs/auth/tokens'; import type { - AuthActorRef, AuthCurrentInput, AuthCurrentView, AuthDevicePublicView, @@ -70,10 +30,19 @@ import type { AuthSignOutResult, AuthSignUpPasswordInput } from '$libs/auth/types'; -import { requireAuthOAuthProvider } from '$libs/auth/oauth'; import type { EngineAuthOptions } from './options'; import { createAuthRouteHandlers } from './handlers'; import { createSvelteKitAuthHandle } from './integrations/sveltekit'; +import type { AuthEngineFlowContext } from './engine-internal'; +import { createAuthDiagnostics, emitAuthDiagnostic } from './diagnostics'; +import { logEntryForEvent } from './engine-logging'; +import { validateAuthOptions } from './engine-validation'; +import { createDeviceAuthFlow } from './device-flow'; +import { createMfaAuthFlow } from './mfa-flow'; +import { createOAuthAuthFlow } from './oauth-flow'; +import { createPasswordAuthFlow } from './password-flow'; +import { createRecoveryAuthFlow } from './recovery-flow'; +import { createSessionAuthFlow } from './session-flow'; export interface EngineAuth { readonly kind: typeof import('$libs/auth/consts').AUTH_ARTIFACT; @@ -118,6 +87,7 @@ export function createEngineAuth(options: EngineAuthOptions): EngineAuth { keyof AuthEventPayloadMap, Set> >(); + const diagnostics = createAuthDiagnostics(options.ports.logr); async function emit( name: TName, @@ -125,615 +95,34 @@ export function createEngineAuth(options: EngineAuthOptions): EngineAuth { ): Promise { await options.hooks?.beforeEvent?.(name as AuthEventName, payload); const logEntry = logEntryForEvent(name as AuthEventName, payload); - await writeAuthLog(options.ports.logr, logEntry); + emitAuthDiagnostic(diagnostics, logEntry); for (const handler of handlersByName.get(name) ?? []) await handler(payload as never); await options.hooks?.afterEvent?.(name as AuthEventName, payload); } - async function current(input: AuthCurrentInput): Promise { - const session = await options.ports.sess.read({ - ...input, - meta: input.meta ?? extractAuthRequestMeta(input.request) - }); - if ( - session.status !== AUTH_SESSION_STATUSES.AUTHENTICATED || - !session.sessionId || - !session.actorRef - ) { - return { session }; - } - - const binding = await options.ports.store.findSessionBinding({ - sessSessionId: session.sessionId - }); - if (!binding || binding.revokedAt) { - await options.ports.sess.end({ sessionId: session.sessionId, meta: input.meta }); - return createAnonymousAuthCurrent(); - } - - const actor = await options.ports.actors.getPublicActor(binding.actorRef); - return { - session: { - ...session, - actorRef: binding.actorRef, - deviceId: binding.deviceId, - aal: binding.aal, - amr: binding.amr, - authTime: binding.authTime - }, - actor: actor ?? undefined - }; - } - - async function signUpPassword(input: AuthSignUpPasswordInput): Promise { - const passwordHasher = requirePasswordHasher(options); - const nowMs = options.ports.timr.nowMs(); - const normalized = normalizeAuthIdentifier(input.identifier); - enforcePasswordPolicy(options, input.password, false); - const identifierHash = await hashPasswordIdentifier({ - crypto: options.ports.crypto, - tenantId: input.tenantId, - identifier: normalized.normalized - }); - const existing = await options.ports.store.findCredentialByIdentifier({ - tenantId: input.tenantId, - kind: AUTH_CREDENTIAL_KINDS.PASSWORD, - identifierHash - }); - - if (existing) { - await emit(AUTH_EVENT_NAMES.SIGN_UP_FAILED, { - name: AUTH_EVENT_NAMES.SIGN_UP_FAILED, - tenantId: input.tenantId, - meta: input.meta - }); - throw new AuthIdentifierTakenError(); - } - - const actorRef = await options.ports.actors.createActorForSignup({ - tenantId: input.tenantId, - identifier: normalized.display, - profile: input.profile, - nowMs - }); - - const credential = await options.ports.store.createPasswordCredential({ - id: createAuthCredentialId(options.ports.crypto), - actorRef, - identifierHash, - identifierDisplay: normalized.display, - passwordHash: await passwordHasher.hashPassword({ password: input.password }), - nowMs - }); - - const result = await bindAuthenticatedSession({ - actorRef, - inputDevice: input.device, - meta: input.meta - }); - await invalidateIdentityCache({ actorRef, reason: AUTH_EVENT_NAMES.SIGN_UP_SUCCEEDED }); - await emit(AUTH_EVENT_NAMES.SIGN_UP_SUCCEEDED, { - name: AUTH_EVENT_NAMES.SIGN_UP_SUCCEEDED, - tenantId: input.tenantId, - actorRef, - sessionId: result.session.sessionId, - deviceId: result.device?.id, - meta: input.meta, - data: { credentialId: credential.id } - }); - return result; - } - - async function signInPassword(input: AuthSignInPasswordInput): Promise { - const passwordHasher = requirePasswordHasher(options); - const normalized = normalizeAuthIdentifier(input.identifier); - const identifierHash = await hashPasswordIdentifier({ - crypto: options.ports.crypto, - tenantId: input.tenantId, - identifier: normalized.normalized - }); - const credential = await options.ports.store.findCredentialByIdentifier({ - tenantId: input.tenantId, - kind: AUTH_CREDENTIAL_KINDS.PASSWORD, - identifierHash - }); - - const fail = async (): Promise => { - await emit(AUTH_EVENT_NAMES.SIGN_IN_FAILED, { - name: AUTH_EVENT_NAMES.SIGN_IN_FAILED, - tenantId: input.tenantId, - meta: input.meta - }); - throw new AuthCredentialError(); - }; - - if (!credential || credential.revokedAt || !credential.passwordHash) return fail(); - const verified = await passwordHasher.verifyPassword({ - password: input.password, - hash: credential.passwordHash - }); - if (!verified.ok) return fail(); - - const actorRef = credential.actorRef; - const result = await bindAuthenticatedSession({ - actorRef, - inputDevice: input.device, - meta: input.meta - }); - await options.ports.perm?.preloadActorContext?.(actorRef); - await invalidateIdentityCache({ actorRef, reason: AUTH_EVENT_NAMES.SIGN_IN_SUCCEEDED }); - await emit(AUTH_EVENT_NAMES.SIGN_IN_SUCCEEDED, { - name: AUTH_EVENT_NAMES.SIGN_IN_SUCCEEDED, - tenantId: input.tenantId, - actorRef, - sessionId: result.session.sessionId, - deviceId: result.device?.id, - meta: input.meta - }); - return result; - } - - async function signOut(input: AuthSignOutInput): Promise { - const snapshot = await options.ports.sess.read({ - ...input, - meta: input.meta ?? extractAuthRequestMeta(input.request) - }); - const sessionId = input.sessionId ?? snapshot.sessionId; - if (!sessionId) return { current: createAnonymousAuthCurrent(), endedSessionIds: [] }; - const binding = await options.ports.store.findSessionBinding({ sessSessionId: sessionId }); - const nowMs = options.ports.timr.nowMs(); - await options.ports.store.revokeSessionBinding({ - sessSessionId: sessionId, - nowMs, - reason: AUTH_REVOKE_REASONS.LOGOUT - }); - await options.ports.sess.end({ sessionId, meta: input.meta }); - await invalidateIdentityCache({ - actorRef: binding?.actorRef, - reason: AUTH_EVENT_NAMES.SIGN_OUT_SUCCEEDED - }); - await emit(AUTH_EVENT_NAMES.SIGN_OUT_SUCCEEDED, { - name: AUTH_EVENT_NAMES.SIGN_OUT_SUCCEEDED, - tenantId: binding?.actorRef.tenantId ?? input.tenantId, - actorRef: binding?.actorRef, - sessionId, - meta: input.meta - }); - return { current: createAnonymousAuthCurrent(), endedSessionIds: [sessionId] }; - } - - async function signOutGlobal(input: AuthSignOutGlobalInput): Promise { - const snapshot = await options.ports.sess.read({ - ...input, - meta: input.meta ?? extractAuthRequestMeta(input.request) - }); - const sessionId = input.sessionId ?? snapshot.sessionId; - if (!sessionId) throw new AuthSessionRequiredError(); - const binding = await options.ports.store.findSessionBinding({ sessSessionId: sessionId }); - if (!binding) throw new AuthSessionRequiredError(); - const nowMs = options.ports.timr.nowMs(); - const endedSessionIds = await options.ports.store.revokeActorSessions({ - actorRef: binding.actorRef, - nowMs, - reason: AUTH_REVOKE_REASONS.GLOBAL_LOGOUT - }); - await options.ports.sess.endMany({ sessionIds: endedSessionIds, meta: input.meta }); - await invalidateIdentityCache({ - actorRef: binding.actorRef, - reason: AUTH_EVENT_NAMES.SIGN_OUT_GLOBAL_SUCCEEDED - }); - await emit(AUTH_EVENT_NAMES.SIGN_OUT_GLOBAL_SUCCEEDED, { - name: AUTH_EVENT_NAMES.SIGN_OUT_GLOBAL_SUCCEEDED, - tenantId: binding.actorRef.tenantId, - actorRef: binding.actorRef, - sessionId, - meta: input.meta - }); - return { current: createAnonymousAuthCurrent(), endedSessionIds }; - } - - async function requestEmailVerification( - input: AuthEmailVerificationRequestInput - ): Promise { - const nowMs = options.ports.timr.nowMs(); - const normalized = normalizeAuthIdentifier(input.identifier); - const identifierHash = await hashPasswordIdentifier({ - crypto: options.ports.crypto, - tenantId: input.tenantId, - identifier: normalized.normalized - }); - const credential = await options.ports.store.findCredentialByIdentifier({ - tenantId: input.tenantId, - kind: AUTH_CREDENTIAL_KINDS.PASSWORD, - identifierHash - }); - - if (!credential || credential.verifiedAt) return { ok: true }; - const token = createAuthRandomToken(options.ports.crypto); - const flow = await options.ports.store.createFlow({ - id: createAuthFlowId(options.ports.crypto), - tenantId: input.tenantId, - kind: AUTH_FLOW_KINDS.EMAIL_VERIFICATION, - actorRef: credential.actorRef, - credentialId: credential.id, - identifierHash, - challengeHash: await hashAuthToken(options.ports.crypto, token), - redirectTo: input.redirectTo, - createdAt: nowMs, - expiresAt: nowMs + AUTH_DEFAULTS.FLOW_TTL_MS - }); - - await options.ports.mailer?.send({ - kind: AUTH_MAIL_KINDS.EMAIL_VERIFICATION, - tenantId: input.tenantId, - to: normalized.display, - locale: input.locale, - variables: { flowId: flow.id, token, redirectTo: input.redirectTo ?? '' } - }); - await emit(AUTH_EVENT_NAMES.EMAIL_VERIFICATION_REQUESTED, { - name: AUTH_EVENT_NAMES.EMAIL_VERIFICATION_REQUESTED, - tenantId: input.tenantId, - actorRef: credential.actorRef, - flowId: flow.id, - meta: input.meta - }); - return { ok: true, expiresAt: flow.expiresAt }; - } - - async function completeEmailVerification( - input: AuthEmailVerificationCompleteInput - ): Promise { - const flow = await requireLiveFlow( - input.tenantId, - input.flowId, - AUTH_FLOW_KINDS.EMAIL_VERIFICATION - ); - const tokenHash = await hashAuthToken(options.ports.crypto, input.token); - if (!flow.challengeHash || tokenHash !== flow.challengeHash || !flow.credentialId) - throw new AuthFlowInvalidError(); - const nowMs = options.ports.timr.nowMs(); - await options.ports.store.markCredentialVerified({ - tenantId: input.tenantId, - credentialId: flow.credentialId, - nowMs - }); - await options.ports.store.consumeFlow({ tenantId: input.tenantId, flowId: flow.id, nowMs }); - await emit(AUTH_EVENT_NAMES.EMAIL_VERIFIED, { - name: AUTH_EVENT_NAMES.EMAIL_VERIFIED, - tenantId: input.tenantId, - actorRef: flow.actorRef, - flowId: flow.id, - meta: input.meta - }); - return { ok: true }; - } - - async function requestPasswordReset( - input: AuthPasswordResetRequestInput - ): Promise { - const nowMs = options.ports.timr.nowMs(); - const normalized = normalizeAuthIdentifier(input.identifier); - const identifierHash = await hashPasswordIdentifier({ - crypto: options.ports.crypto, - tenantId: input.tenantId, - identifier: normalized.normalized - }); - const credential = await options.ports.store.findCredentialByIdentifier({ - tenantId: input.tenantId, - kind: AUTH_CREDENTIAL_KINDS.PASSWORD, - identifierHash - }); - - if (!credential) return { ok: true }; - const token = createAuthRandomToken(options.ports.crypto); - const flow = await options.ports.store.createFlow({ - id: createAuthFlowId(options.ports.crypto), - tenantId: input.tenantId, - kind: AUTH_FLOW_KINDS.PASSWORD_RESET, - actorRef: credential.actorRef, - credentialId: credential.id, - identifierHash, - challengeHash: await hashAuthToken(options.ports.crypto, token), - redirectTo: input.redirectTo, - createdAt: nowMs, - expiresAt: nowMs + AUTH_DEFAULTS.PASSWORD_RESET_TTL_MS - }); - - await options.ports.mailer?.send({ - kind: AUTH_MAIL_KINDS.PASSWORD_RESET, - tenantId: input.tenantId, - to: normalized.display, - locale: input.locale, - variables: { flowId: flow.id, token, redirectTo: input.redirectTo ?? '' } - }); - await emit(AUTH_EVENT_NAMES.PASSWORD_RESET_REQUESTED, { - name: AUTH_EVENT_NAMES.PASSWORD_RESET_REQUESTED, - tenantId: input.tenantId, - actorRef: credential.actorRef, - flowId: flow.id, - meta: input.meta - }); - return { ok: true, expiresAt: flow.expiresAt }; - } - - async function completePasswordReset( - input: AuthPasswordResetCompleteInput - ): Promise { - const passwordHasher = requirePasswordHasher(options); - enforcePasswordPolicy(options, input.newPassword, false); - const flow = await requireLiveFlow( - input.tenantId, - input.flowId, - AUTH_FLOW_KINDS.PASSWORD_RESET - ); - const tokenHash = await hashAuthToken(options.ports.crypto, input.token); - if ( - !flow.challengeHash || - tokenHash !== flow.challengeHash || - !flow.credentialId || - !flow.actorRef - ) - throw new AuthFlowInvalidError(); - const nowMs = options.ports.timr.nowMs(); - await options.ports.store.updatePasswordCredential({ - tenantId: input.tenantId, - credentialId: flow.credentialId, - passwordHash: await passwordHasher.hashPassword({ password: input.newPassword }), - nowMs - }); - await options.ports.store.consumeFlow({ tenantId: input.tenantId, flowId: flow.id, nowMs }); - const revokedSessionIds = await options.ports.store.revokeActorSessions({ - actorRef: flow.actorRef, - nowMs, - reason: AUTH_REVOKE_REASONS.PASSWORD_CHANGED - }); - await options.ports.sess.endMany({ sessionIds: revokedSessionIds, meta: input.meta }); - await invalidateIdentityCache({ - actorRef: flow.actorRef, - reason: AUTH_EVENT_NAMES.PASSWORD_RESET_COMPLETED - }); - await emit(AUTH_EVENT_NAMES.PASSWORD_RESET_COMPLETED, { - name: AUTH_EVENT_NAMES.PASSWORD_RESET_COMPLETED, - tenantId: input.tenantId, - actorRef: flow.actorRef, - flowId: flow.id, - meta: input.meta - }); - return { ok: true, revokedSessionIds }; - } - - async function listDevices( - input: import('$libs/auth/types').AuthListDevicesInput - ): Promise { - const devices = await options.ports.store.listDevices({ actorRef: input.actorRef }); - return devices.map(toPublicDevice); - } - - async function revokeDevice(input: AuthRevokeDeviceInput): Promise { - const nowMs = options.ports.timr.nowMs(); - await options.ports.store.revokeDevice({ - actorRef: input.actorRef, - deviceId: input.deviceId, - nowMs - }); - await invalidateIdentityCache({ - actorRef: input.actorRef, - reason: AUTH_EVENT_NAMES.DEVICE_REVOKED - }); - await emit(AUTH_EVENT_NAMES.DEVICE_REVOKED, { - name: AUTH_EVENT_NAMES.DEVICE_REVOKED, - tenantId: input.actorRef.tenantId, - actorRef: input.actorRef, - deviceId: input.deviceId, - meta: input.meta - }); - return { ok: true }; - } - - async function startOAuth(input: AuthOAuthStartInput): Promise { - const provider = requireAuthOAuthProvider(options.providers, input.providerId); - if (!provider) throw new AuthConfigError(undefined, { providerId: input.providerId }); - const nowMs = options.ports.timr.nowMs(); - const state = createAuthRandomToken(options.ports.crypto); - const verifier = createAuthRandomToken(options.ports.crypto); - const flow = await options.ports.store.createFlow({ - id: createAuthFlowId(options.ports.crypto), - tenantId: input.tenantId, - kind: AUTH_FLOW_KINDS.OAUTH_STATE, - providerId: input.providerId, - stateHash: await hashAuthToken(options.ports.crypto, state), - verifierHash: await hashAuthToken(options.ports.crypto, verifier), - redirectTo: input.redirectTo, - createdAt: nowMs, - expiresAt: nowMs + AUTH_DEFAULTS.FLOW_TTL_MS, - metadata: { state, verifier } - }); - const resolved = await provider.resolveConfig({ - tenantId: input.tenantId, - redirectUri: input.redirectTo ?? '' - }); - const url = new URL(resolved.authorizationEndpoint); - url.searchParams.set(AUTH_OAUTH_PARAMS.RESPONSE_TYPE, AUTH_OAUTH_VALUES.CODE); - url.searchParams.set(AUTH_OAUTH_PARAMS.CLIENT_ID, resolved.clientId); - url.searchParams.set(AUTH_OAUTH_PARAMS.REDIRECT_URI, resolved.redirectUri); - url.searchParams.set( - AUTH_OAUTH_PARAMS.SCOPE, - resolved.scopes.join(AUTH_OAUTH_VALUES.SCOPE_SEPARATOR) - ); - url.searchParams.set(AUTH_OAUTH_PARAMS.STATE, state); - url.searchParams.set(AUTH_OAUTH_PARAMS.CODE_CHALLENGE_METHOD, AUTH_OAUTH_VALUES.S256); - url.searchParams.set( - AUTH_OAUTH_PARAMS.CODE_CHALLENGE, - await hashAuthToken(options.ports.crypto, verifier) - ); - await emit(AUTH_EVENT_NAMES.OAUTH_STARTED, { - name: AUTH_EVENT_NAMES.OAUTH_STARTED, - tenantId: input.tenantId, - flowId: flow.id, - meta: input.meta, - data: { providerId: input.providerId } - }); - return { authorizationUrl: url.toString(), flowId: flow.id, expiresAt: flow.expiresAt }; - } - - async function completeOAuth(input: AuthOAuthCallbackInput): Promise { - const provider = requireAuthOAuthProvider(options.providers, input.providerId); - if (!provider) throw new AuthConfigError(undefined, { providerId: input.providerId }); - const stateHash = await hashAuthToken(options.ports.crypto, input.state); - const nowMs = options.ports.timr.nowMs(); - const flowCandidates = await findOAuthFlowByState(input.tenantId, input.providerId, stateHash); - if (!flowCandidates) throw new AuthFlowInvalidError(); - if (flowCandidates.expiresAt <= nowMs || flowCandidates.consumedAt) - throw new AuthFlowExpiredError(); - const profile = await provider.mapProfile({ - tenantId: input.tenantId, - providerId: input.providerId, - tokens: { code: input.code } - }); - const linked = await options.ports.store.findLinkedAccount({ - tenantId: input.tenantId, - providerId: input.providerId, - providerSubject: profile.providerSubject - }); - if (!linked) throw new AuthAccountNotLinkedError(); - await options.ports.store.consumeFlow({ - tenantId: input.tenantId, - flowId: flowCandidates.id, - nowMs - }); - const result = await bindAuthenticatedSession({ - actorRef: linked.actorRef, - inputDevice: undefined, - meta: input.meta - }); - await emit(AUTH_EVENT_NAMES.OAUTH_CALLBACK_SUCCEEDED, { - name: AUTH_EVENT_NAMES.OAUTH_CALLBACK_SUCCEEDED, - tenantId: input.tenantId, - actorRef: linked.actorRef, - sessionId: result.session.sessionId, - meta: input.meta, - data: { providerId: input.providerId } - }); - return result; - } - - async function createMfaChallenge(input: AuthMfaChallengeInput): Promise { - const nowMs = options.ports.timr.nowMs(); - const flow = await options.ports.store.createFlow({ - id: createAuthFlowId(options.ports.crypto), - tenantId: input.actorRef.tenantId, - kind: AUTH_FLOW_KINDS.MFA_CHALLENGE, - actorRef: input.actorRef, - createdAt: nowMs, - expiresAt: nowMs + AUTH_DEFAULTS.OTP_TTL_MS, - metadata: { kind: input.kind } - }); - await emit(AUTH_EVENT_NAMES.MFA_CHALLENGE_CREATED, { - name: AUTH_EVENT_NAMES.MFA_CHALLENGE_CREATED, - tenantId: input.actorRef.tenantId, - actorRef: input.actorRef, - flowId: flow.id, - meta: input.meta - }); - return { flowId: flow.id, expiresAt: flow.expiresAt }; - } - - async function verifyMfaChallenge(_input: AuthMfaVerifyInput): Promise { - throw new AuthConfigError(undefined, { method: AUTH_ENGINE_METHODS.VERIFY_MFA_CHALLENGE }); - } - - async function bindAuthenticatedSession(input: { - readonly actorRef: AuthActorRef; - readonly inputDevice?: import('$libs/auth/types').AuthDeviceInput; - readonly meta?: import('$libs/auth/types').AuthRequestMeta; - }): Promise { - const nowMs = options.ports.timr.nowMs(); - const device = await options.ports.store.upsertDevice({ - actorRef: input.actorRef, - deviceId: input.inputDevice?.deviceId ?? createAuthDeviceId(options.ports.crypto), - displayName: input.inputDevice?.displayName, - userAgentDisplay: input.inputDevice?.userAgentDisplay, - ipDisplayHint: input.inputDevice?.ipDisplayHint, - nowMs - }); - const sessionStart = await options.ports.sess.start({ - actorRef: input.actorRef, - deviceId: device.id, - aal: AUTH_AAL.SINGLE_FACTOR, - amr: [AUTH_AMR.PASSWORD], - meta: input.meta - }); - const binding = await options.ports.store.bindSession({ - sessSessionId: sessionStart.sessionId, - actorRef: input.actorRef, - deviceId: device.id, - aal: AUTH_AAL.SINGLE_FACTOR, - amr: [AUTH_AMR.PASSWORD], - authTime: nowMs, - createdAt: nowMs - }); - const actor = await options.ports.actors.getPublicActor(input.actorRef); - if (!actor) throw new AuthCredentialError(); - const session = { - ...sessionStart.snapshot, - status: AUTH_SESSION_STATUSES.AUTHENTICATED, - actorRef: input.actorRef, - sessionId: sessionStart.sessionId, - deviceId: device.id, - aal: binding.aal, - amr: binding.amr, - authTime: binding.authTime, - refreshedAt: nowMs - } as const; - await emit(AUTH_EVENT_NAMES.SESSION_BOUND, { - name: AUTH_EVENT_NAMES.SESSION_BOUND, - tenantId: input.actorRef.tenantId, - actorRef: input.actorRef, - sessionId: sessionStart.sessionId, - deviceId: device.id, - meta: input.meta - }); - return { - current: { session, actor }, - session, - actor, - device: toPublicDevice(device), - setCookies: sessionStart.setCookies - }; - } - - async function requireLiveFlow( - tenantId: import('$libs/auth/types').AuthTenantId, - flowId: import('$libs/auth/types').AuthFlowId, - kind: import('$libs/auth/types').AuthFlowKind - ) { - const nowMs = options.ports.timr.nowMs(); - const flow = await options.ports.store.findFlowForUpdate({ tenantId, flowId, kind }); - if (!flow || flow.consumedAt) throw new AuthFlowInvalidError(); - if (flow.expiresAt <= nowMs) throw new AuthFlowExpiredError(); - return flow; - } - - async function findOAuthFlowByState( - tenantId: import('$libs/auth/types').AuthTenantId, - providerId: string, - stateHash: string - ) { - const candidate = await options.ports.store.findFlowForUpdate({ - tenantId, - flowId: stateHash as import('$libs/auth/types').AuthFlowId, - kind: AUTH_FLOW_KINDS.OAUTH_STATE - }); - if (candidate) return candidate; - return null; - } - async function invalidateIdentityCache( input: Omit ): Promise { await options.ports.cach?.invalidate({ ...input, tags: authCacheTagsForIdentity() }); } + const flowContext = { + options, + emit, + invalidateIdentityCache + } satisfies AuthEngineFlowContext; + const { current, signOut, signOutGlobal } = createSessionAuthFlow(flowContext); + const { signUpPassword, signInPassword } = createPasswordAuthFlow(flowContext); + const { + requestEmailVerification, + completeEmailVerification, + requestPasswordReset, + completePasswordReset + } = createRecoveryAuthFlow(flowContext); + const { listDevices, revokeDevice } = createDeviceAuthFlow(flowContext); + const { startOAuth, completeOAuth } = createOAuthAuthFlow(flowContext); + const { createMfaChallenge, verifyMfaChallenge } = createMfaAuthFlow(flowContext); + const engineWithoutHandlers = { kind: AUTH_ARTIFACT, current, @@ -807,127 +196,3 @@ export function createEngineAuth(options: EngineAuthOptions): EngineAuth { return engine; } - -function validateAuthOptions(options: EngineAuthOptions): void { - if (!options.security.csrf.signingKey) - throw new AuthConfigError(undefined, { option: 'security.csrf.signingKey' }); -} - -function requirePasswordHasher(options: EngineAuthOptions) { - if (!options.ports.passwordHasher) - throw new AuthConfigError(undefined, { port: 'passwordHasher' }); - return options.ports.passwordHasher; -} - -function enforcePasswordPolicy( - options: EngineAuthOptions, - password: string, - hasMfa: boolean -): void { - const min = hasMfa - ? (options.security.password?.minLengthWithMfa ?? AUTH_DEFAULTS.PASSWORD_MIN_LENGTH_WITH_MFA) - : (options.security.password?.minLengthWithoutMfa ?? - AUTH_DEFAULTS.PASSWORD_MIN_LENGTH_WITHOUT_MFA); - const max = options.security.password?.maxLength ?? AUTH_DEFAULTS.PASSWORD_MAX_LENGTH; - if (password.length < min || password.length > max) - throw new AuthCredentialError(undefined, { policy: AUTH_POLICY_KEYS.PASSWORD_LENGTH }); -} - -function logEntryForEvent( - name: AuthEventName, - payload: import('$libs/auth/events').AuthBaseEventPayload -): AuthLogEntry { - return { - level: isFailureEvent(name) ? AUTH_LOG_LEVELS.WARN : AUTH_LOG_LEVELS.INFO, - category: categoryForEvent(name), - message: messageForEvent(name), - eventName: name, - actorRef: payload.actorRef, - tenantId: payload.tenantId, - sessionId: payload.sessionId, - meta: payload.meta, - data: payload.data - }; -} - -async function writeAuthLog( - logger: import('$libs/auth/contracts').AuthLogrPort, - entry: AuthLogEntry -): Promise { - const { level, category, message, ...input } = entry; - if (level === AUTH_LOG_LEVELS.DEBUG) { - await logger.debug(category, message, input); - return; - } - if (level === AUTH_LOG_LEVELS.WARN) { - await logger.warn(category, message, input); - return; - } - if (level === AUTH_LOG_LEVELS.ERROR) { - await logger.error(category, message, input); - return; - } - await logger.info(category, message, input); -} - -function isFailureEvent(name: AuthEventName): boolean { - return ( - name === AUTH_EVENT_NAMES.SIGN_IN_FAILED || - name === AUTH_EVENT_NAMES.SIGN_UP_FAILED || - name === AUTH_EVENT_NAMES.CSRF_REJECTED - ); -} - -function categoryForEvent(name: AuthEventName): string { - if (name.startsWith(AUTH_EVENT_PREFIXES.CSRF)) return AUTH_LOG_CATEGORIES.CSRF; - if (name.startsWith(AUTH_EVENT_PREFIXES.OAUTH)) return AUTH_LOG_CATEGORIES.OAUTH; - if (name.startsWith(AUTH_EVENT_PREFIXES.MFA)) return AUTH_LOG_CATEGORIES.MFA; - if (name.startsWith(AUTH_EVENT_PREFIXES.DEVICE)) return AUTH_LOG_CATEGORIES.DEVICE; - if (name.startsWith(AUTH_EVENT_PREFIXES.REFRESH)) return AUTH_LOG_CATEGORIES.REFRESH; - if (name.startsWith(AUTH_EVENT_PREFIXES.WEBAUTHN)) return AUTH_LOG_CATEGORIES.WEBAUTHN; - if (name.startsWith(AUTH_EVENT_PREFIXES.SESSION) || name.startsWith(AUTH_EVENT_PREFIXES.SIGN_OUT)) - return AUTH_LOG_CATEGORIES.SESSION; - return AUTH_LOG_CATEGORIES.ENGINE; -} - -function messageForEvent(name: AuthEventName): string { - switch (name) { - case AUTH_EVENT_NAMES.SIGN_IN_SUCCEEDED: - return AUTH_LOG_MESSAGES.SIGN_IN_SUCCEEDED; - case AUTH_EVENT_NAMES.SIGN_IN_FAILED: - return AUTH_LOG_MESSAGES.SIGN_IN_FAILED; - case AUTH_EVENT_NAMES.SIGN_UP_SUCCEEDED: - return AUTH_LOG_MESSAGES.SIGN_UP_SUCCEEDED; - case AUTH_EVENT_NAMES.SIGN_UP_FAILED: - return AUTH_LOG_MESSAGES.SIGN_UP_FAILED; - case AUTH_EVENT_NAMES.SIGN_OUT_SUCCEEDED: - case AUTH_EVENT_NAMES.SIGN_OUT_GLOBAL_SUCCEEDED: - return AUTH_LOG_MESSAGES.SIGN_OUT_SUCCEEDED; - case AUTH_EVENT_NAMES.CSRF_ISSUED: - return AUTH_LOG_MESSAGES.CSRF_ISSUED; - case AUTH_EVENT_NAMES.CSRF_REJECTED: - return AUTH_LOG_MESSAGES.CSRF_REJECTED; - case AUTH_EVENT_NAMES.EMAIL_VERIFICATION_REQUESTED: - return AUTH_LOG_MESSAGES.EMAIL_VERIFICATION_REQUESTED; - case AUTH_EVENT_NAMES.EMAIL_VERIFIED: - return AUTH_LOG_MESSAGES.EMAIL_VERIFIED; - case AUTH_EVENT_NAMES.PASSWORD_RESET_REQUESTED: - return AUTH_LOG_MESSAGES.PASSWORD_RESET_REQUESTED; - case AUTH_EVENT_NAMES.PASSWORD_RESET_COMPLETED: - return AUTH_LOG_MESSAGES.PASSWORD_RESET_COMPLETED; - default: - return name; - } -} - -function toPublicDevice(record: import('$libs/auth/types').AuthDeviceRecord): AuthDevicePublicView { - return { - id: record.id, - displayName: record.displayName, - userAgentDisplay: record.userAgentDisplay, - ipDisplayHint: record.ipDisplayHint, - firstSeenAt: record.firstSeenAt, - lastSeenAt: record.lastSeenAt, - revokedAt: record.revokedAt - }; -} diff --git a/src/svrs/auth/engine-flows.ts b/src/svrs/auth/engine-flows.ts new file mode 100644 index 0000000..d75ab3b --- /dev/null +++ b/src/svrs/auth/engine-flows.ts @@ -0,0 +1,41 @@ +import { AUTH_FLOW_KINDS, AUTH_OAUTH_FLOW_METADATA } from '$libs/auth/consts'; +import { AuthFlowExpiredError, AuthFlowInvalidError } from '$libs/auth/errors'; +import { hashAuthToken } from '$libs/auth/tokens'; +import type { AuthFlowId, AuthFlowKind, AuthFlowRecord, AuthTenantId } from '$libs/auth/types'; +import type { EngineAuthOptions } from './options'; + +export async function requireLiveAuthFlow( + options: EngineAuthOptions, + tenantId: AuthTenantId, + flowId: AuthFlowId, + kind: AuthFlowKind +): Promise { + const nowMs = options.ports.timr.nowMs(); + const flow = await options.ports.store.findFlowForUpdate({ tenantId, flowId, kind }); + if (!flow || flow.consumedAt) throw new AuthFlowInvalidError(); + if (flow.expiresAt <= nowMs) throw new AuthFlowExpiredError(); + return flow; +} + +export async function findAuthOAuthFlowByState( + options: EngineAuthOptions, + tenantId: AuthTenantId, + stateHash: string +): Promise { + return options.ports.store.findFlowForUpdate({ + tenantId, + flowId: stateHash as AuthFlowId, + kind: AUTH_FLOW_KINDS.OAUTH_STATE + }); +} + +export async function readAuthOAuthFlowVerifier( + options: EngineAuthOptions, + flow: AuthFlowRecord +): Promise { + const verifier = flow.metadata?.[AUTH_OAUTH_FLOW_METADATA.VERIFIER]; + if (typeof verifier !== 'string' || !flow.verifierHash) throw new AuthFlowInvalidError(); + const verifierHash = await hashAuthToken(options.ports.crypto, verifier); + if (verifierHash !== flow.verifierHash) throw new AuthFlowInvalidError(); + return verifier; +} diff --git a/src/svrs/auth/engine-internal.ts b/src/svrs/auth/engine-internal.ts new file mode 100644 index 0000000..3f7f026 --- /dev/null +++ b/src/svrs/auth/engine-internal.ts @@ -0,0 +1,18 @@ +import type { AuthCacheInvalidationInput } from '$libs/auth/contracts'; +import type { AuthEventPayloadMap } from '$libs/auth/events'; +import type { EngineAuthOptions } from './options'; + +export type AuthEngineEmit = ( + name: TName, + payload: AuthEventPayloadMap[TName] +) => Promise; + +export type AuthIdentityCacheInvalidator = ( + input: Omit +) => Promise; + +export interface AuthEngineFlowContext { + readonly options: EngineAuthOptions; + readonly emit: AuthEngineEmit; + readonly invalidateIdentityCache: AuthIdentityCacheInvalidator; +} diff --git a/src/svrs/auth/engine-logging.ts b/src/svrs/auth/engine-logging.ts new file mode 100644 index 0000000..d577aca --- /dev/null +++ b/src/svrs/auth/engine-logging.ts @@ -0,0 +1,74 @@ +import { + AUTH_EVENT_NAMES, + AUTH_EVENT_PREFIXES, + AUTH_LOG_CATEGORIES, + AUTH_LOG_LEVELS, + AUTH_LOG_MESSAGES +} from '$libs/auth/consts'; +import type { AuthLogEntry } from '$libs/auth/contracts'; +import type { AuthBaseEventPayload } from '$libs/auth/events'; +import type { AuthEventName } from '$libs/auth/types'; + +export function logEntryForEvent(name: AuthEventName, payload: AuthBaseEventPayload): AuthLogEntry { + return { + level: isFailureEvent(name) ? AUTH_LOG_LEVELS.WARN : AUTH_LOG_LEVELS.INFO, + category: categoryForEvent(name), + message: messageForEvent(name), + eventName: name, + actorRef: payload.actorRef, + tenantId: payload.tenantId, + sessionId: payload.sessionId, + meta: payload.meta, + data: payload.data + }; +} + +function isFailureEvent(name: AuthEventName): boolean { + return ( + name === AUTH_EVENT_NAMES.SIGN_IN_FAILED || + name === AUTH_EVENT_NAMES.SIGN_UP_FAILED || + name === AUTH_EVENT_NAMES.CSRF_REJECTED + ); +} + +function categoryForEvent(name: AuthEventName): string { + if (name.startsWith(AUTH_EVENT_PREFIXES.CSRF)) return AUTH_LOG_CATEGORIES.CSRF; + if (name.startsWith(AUTH_EVENT_PREFIXES.OAUTH)) return AUTH_LOG_CATEGORIES.OAUTH; + if (name.startsWith(AUTH_EVENT_PREFIXES.MFA)) return AUTH_LOG_CATEGORIES.MFA; + if (name.startsWith(AUTH_EVENT_PREFIXES.DEVICE)) return AUTH_LOG_CATEGORIES.DEVICE; + if (name.startsWith(AUTH_EVENT_PREFIXES.REFRESH)) return AUTH_LOG_CATEGORIES.REFRESH; + if (name.startsWith(AUTH_EVENT_PREFIXES.WEBAUTHN)) return AUTH_LOG_CATEGORIES.WEBAUTHN; + if (name.startsWith(AUTH_EVENT_PREFIXES.SESSION) || name.startsWith(AUTH_EVENT_PREFIXES.SIGN_OUT)) + return AUTH_LOG_CATEGORIES.SESSION; + return AUTH_LOG_CATEGORIES.ENGINE; +} + +function messageForEvent(name: AuthEventName): string { + switch (name) { + case AUTH_EVENT_NAMES.SIGN_IN_SUCCEEDED: + return AUTH_LOG_MESSAGES.SIGN_IN_SUCCEEDED; + case AUTH_EVENT_NAMES.SIGN_IN_FAILED: + return AUTH_LOG_MESSAGES.SIGN_IN_FAILED; + case AUTH_EVENT_NAMES.SIGN_UP_SUCCEEDED: + return AUTH_LOG_MESSAGES.SIGN_UP_SUCCEEDED; + case AUTH_EVENT_NAMES.SIGN_UP_FAILED: + return AUTH_LOG_MESSAGES.SIGN_UP_FAILED; + case AUTH_EVENT_NAMES.SIGN_OUT_SUCCEEDED: + case AUTH_EVENT_NAMES.SIGN_OUT_GLOBAL_SUCCEEDED: + return AUTH_LOG_MESSAGES.SIGN_OUT_SUCCEEDED; + case AUTH_EVENT_NAMES.CSRF_ISSUED: + return AUTH_LOG_MESSAGES.CSRF_ISSUED; + case AUTH_EVENT_NAMES.CSRF_REJECTED: + return AUTH_LOG_MESSAGES.CSRF_REJECTED; + case AUTH_EVENT_NAMES.EMAIL_VERIFICATION_REQUESTED: + return AUTH_LOG_MESSAGES.EMAIL_VERIFICATION_REQUESTED; + case AUTH_EVENT_NAMES.EMAIL_VERIFIED: + return AUTH_LOG_MESSAGES.EMAIL_VERIFIED; + case AUTH_EVENT_NAMES.PASSWORD_RESET_REQUESTED: + return AUTH_LOG_MESSAGES.PASSWORD_RESET_REQUESTED; + case AUTH_EVENT_NAMES.PASSWORD_RESET_COMPLETED: + return AUTH_LOG_MESSAGES.PASSWORD_RESET_COMPLETED; + default: + return name; + } +} diff --git a/src/svrs/auth/engine-session.ts b/src/svrs/auth/engine-session.ts new file mode 100644 index 0000000..d870318 --- /dev/null +++ b/src/svrs/auth/engine-session.ts @@ -0,0 +1,74 @@ +import { AUTH_AAL, AUTH_AMR, AUTH_EVENT_NAMES, AUTH_SESSION_STATUSES } from '$libs/auth/consts'; +import { AuthCredentialError } from '$libs/auth/errors'; +import { createAuthDeviceId } from '$libs/auth/ids'; +import type { + AuthActorRef, + AuthDeviceInput, + AuthRequestMeta, + AuthSignInResult +} from '$libs/auth/types'; +import { toPublicDevice } from './devices'; +import type { AuthEngineEmit } from './engine-internal'; +import type { EngineAuthOptions } from './options'; + +export async function bindAuthenticatedSession(input: { + readonly options: EngineAuthOptions; + readonly emit: AuthEngineEmit; + readonly actorRef: AuthActorRef; + readonly inputDevice?: AuthDeviceInput; + readonly meta?: AuthRequestMeta; +}): Promise { + const nowMs = input.options.ports.timr.nowMs(); + const device = await input.options.ports.store.upsertDevice({ + actorRef: input.actorRef, + deviceId: input.inputDevice?.deviceId ?? createAuthDeviceId(input.options.ports.crypto), + displayName: input.inputDevice?.displayName, + userAgentDisplay: input.inputDevice?.userAgentDisplay, + ipDisplayHint: input.inputDevice?.ipDisplayHint, + nowMs + }); + const sessionStart = await input.options.ports.sess.start({ + actorRef: input.actorRef, + deviceId: device.id, + aal: AUTH_AAL.SINGLE_FACTOR, + amr: [AUTH_AMR.PASSWORD], + meta: input.meta + }); + const binding = await input.options.ports.store.bindSession({ + sessSessionId: sessionStart.sessionId, + actorRef: input.actorRef, + deviceId: device.id, + aal: AUTH_AAL.SINGLE_FACTOR, + amr: [AUTH_AMR.PASSWORD], + authTime: nowMs, + createdAt: nowMs + }); + const actor = await input.options.ports.actors.getPublicActor(input.actorRef); + if (!actor) throw new AuthCredentialError(); + const session = { + ...sessionStart.snapshot, + status: AUTH_SESSION_STATUSES.AUTHENTICATED, + actorRef: input.actorRef, + sessionId: sessionStart.sessionId, + deviceId: device.id, + aal: binding.aal, + amr: binding.amr, + authTime: binding.authTime, + refreshedAt: nowMs + } as const; + await input.emit(AUTH_EVENT_NAMES.SESSION_BOUND, { + name: AUTH_EVENT_NAMES.SESSION_BOUND, + tenantId: input.actorRef.tenantId, + actorRef: input.actorRef, + sessionId: sessionStart.sessionId, + deviceId: device.id, + meta: input.meta + }); + return { + current: { session, actor }, + session, + actor, + device: toPublicDevice(device), + setCookies: sessionStart.setCookies + }; +} diff --git a/src/svrs/auth/engine-validation.ts b/src/svrs/auth/engine-validation.ts new file mode 100644 index 0000000..62b8b1f --- /dev/null +++ b/src/svrs/auth/engine-validation.ts @@ -0,0 +1,29 @@ +import { AUTH_DEFAULTS, AUTH_POLICY_KEYS } from '$libs/auth/consts'; +import type { AuthPasswordHasher } from '$libs/auth/contracts'; +import { AuthConfigError, AuthCredentialError } from '$libs/auth/errors'; +import type { EngineAuthOptions } from './options'; + +export function validateAuthOptions(options: EngineAuthOptions): void { + if (!options.security.csrf.signingKey) + throw new AuthConfigError(undefined, { option: 'security.csrf.signingKey' }); +} + +export function requirePasswordHasher(options: EngineAuthOptions): AuthPasswordHasher { + if (!options.ports.passwordHasher) + throw new AuthConfigError(undefined, { port: 'passwordHasher' }); + return options.ports.passwordHasher; +} + +export function enforcePasswordPolicy( + options: EngineAuthOptions, + password: string, + hasMfa: boolean +): void { + const min = hasMfa + ? (options.security.password?.minLengthWithMfa ?? AUTH_DEFAULTS.PASSWORD_MIN_LENGTH_WITH_MFA) + : (options.security.password?.minLengthWithoutMfa ?? + AUTH_DEFAULTS.PASSWORD_MIN_LENGTH_WITHOUT_MFA); + const max = options.security.password?.maxLength ?? AUTH_DEFAULTS.PASSWORD_MAX_LENGTH; + if (password.length < min || password.length > max) + throw new AuthCredentialError(undefined, { policy: AUTH_POLICY_KEYS.PASSWORD_LENGTH }); +} diff --git a/src/svrs/auth/index.ts b/src/svrs/auth/index.ts index 2247004..7a0edc4 100644 --- a/src/svrs/auth/index.ts +++ b/src/svrs/auth/index.ts @@ -57,6 +57,7 @@ export type { } from '$libs/auth/types'; export { createEngineAuth } from './engine-auth.ts'; export type { EngineAuth } from './engine-auth.ts'; +export { createAuthDiagnostics, emitAuthDiagnostic } from './diagnostics.ts'; export { createAuthRouteHandlers } from './handlers.ts'; export type { AuthRouteHandlers, AuthRouteRequest } from './handlers.ts'; @@ -64,8 +65,8 @@ export { registerEngineAuth } from './integrations/aapp.ts'; export type { AuthAappRoot } from './integrations/aapp.ts'; export type { AuthCacheInvalidationInput, AuthCachPort } from './integrations/cach.ts'; export type { AuthHttpPort, AuthHttpRequest, AuthHttpResponse } from './integrations/http.ts'; -export { createAuthLogrPort } from './integrations/logr.ts'; -export type { AuthLogEntry, AuthLogFn, AuthLogInput, AuthLogrPort } from './integrations/logr.ts'; +export type { AuthLogEntry } from './integrations/logr.ts'; +export type { AuthDiagnosticEvent, AuthDiagnostics, AuthDiagnosticType } from './diagnostics.ts'; export type { AuthPermissionChangeHandler, AuthPermissionChangePayload, diff --git a/src/svrs/auth/integrations/logr.ts b/src/svrs/auth/integrations/logr.ts index 33746b0..78c8356 100644 --- a/src/svrs/auth/integrations/logr.ts +++ b/src/svrs/auth/integrations/logr.ts @@ -1,33 +1 @@ -import type { EngineLogger, LogInput } from '$logr'; -import type { AuthLogFn, AuthLogInput, AuthLogrPort } from '$libs/auth/contracts'; - -export type { AuthLogEntry, AuthLogFn, AuthLogInput, AuthLogrPort } from '$libs/auth/contracts'; - -export function createAuthLogrPort( - logger: Pick -): AuthLogrPort { - return { - debug: authLogFn(logger.debug), - info: authLogFn(logger.info), - warn: authLogFn(logger.warn), - error: authLogFn(logger.error) - }; -} - -function authLogFn(fn: EngineLogger['info']): AuthLogFn { - return (category, message, input) => { - fn(category, message, toLogInput(input)); - }; -} - -function toLogInput(input: AuthLogInput | undefined): LogInput | undefined { - if (!input) return undefined; - const { error, context, ...auth } = input; - return { - error, - context: { - ...(context ?? {}), - auth - } - }; -} +export type { AuthLogEntry } from '$libs/auth/contracts'; diff --git a/src/svrs/auth/mfa-flow.ts b/src/svrs/auth/mfa-flow.ts new file mode 100644 index 0000000..99eb47b --- /dev/null +++ b/src/svrs/auth/mfa-flow.ts @@ -0,0 +1,46 @@ +import { + AUTH_DEFAULTS, + AUTH_ENGINE_METHODS, + AUTH_EVENT_NAMES, + AUTH_FLOW_KINDS +} from '$libs/auth/consts'; +import { AuthConfigError } from '$libs/auth/errors'; +import { createAuthFlowId } from '$libs/auth/ids'; +import type { + AuthMfaChallengeInput, + AuthMfaChallengeResult, + AuthMfaVerifyInput, + AuthSignInResult +} from '$libs/auth/types'; +import type { AuthEngineFlowContext } from './engine-internal'; + +export function createMfaAuthFlow(context: AuthEngineFlowContext) { + const { options, emit } = context; + + async function createMfaChallenge(input: AuthMfaChallengeInput): Promise { + const nowMs = options.ports.timr.nowMs(); + const flow = await options.ports.store.createFlow({ + id: createAuthFlowId(options.ports.crypto), + tenantId: input.actorRef.tenantId, + kind: AUTH_FLOW_KINDS.MFA_CHALLENGE, + actorRef: input.actorRef, + createdAt: nowMs, + expiresAt: nowMs + AUTH_DEFAULTS.OTP_TTL_MS, + metadata: { kind: input.kind } + }); + await emit(AUTH_EVENT_NAMES.MFA_CHALLENGE_CREATED, { + name: AUTH_EVENT_NAMES.MFA_CHALLENGE_CREATED, + tenantId: input.actorRef.tenantId, + actorRef: input.actorRef, + flowId: flow.id, + meta: input.meta + }); + return { flowId: flow.id, expiresAt: flow.expiresAt }; + } + + async function verifyMfaChallenge(_input: AuthMfaVerifyInput): Promise { + throw new AuthConfigError(undefined, { method: AUTH_ENGINE_METHODS.VERIFY_MFA_CHALLENGE }); + } + + return { createMfaChallenge, verifyMfaChallenge }; +} diff --git a/src/svrs/auth/oauth-flow.ts b/src/svrs/auth/oauth-flow.ts new file mode 100644 index 0000000..df4f613 --- /dev/null +++ b/src/svrs/auth/oauth-flow.ts @@ -0,0 +1,128 @@ +import { + AUTH_DEFAULTS, + AUTH_EVENT_NAMES, + AUTH_FLOW_KINDS, + AUTH_OAUTH_FLOW_METADATA, + AUTH_OAUTH_PARAMS, + AUTH_OAUTH_VALUES +} from '$libs/auth/consts'; +import { + AuthAccountNotLinkedError, + AuthConfigError, + AuthFlowExpiredError, + AuthFlowInvalidError +} from '$libs/auth/errors'; +import { createAuthFlowId } from '$libs/auth/ids'; +import { requireAuthOAuthProvider } from '$libs/auth/oauth'; +import { createAuthRandomToken, hashAuthToken } from '$libs/auth/tokens'; +import type { + AuthOAuthCallbackInput, + AuthOAuthStartInput, + AuthOAuthStartResult, + AuthSignInResult +} from '$libs/auth/types'; +import type { AuthEngineFlowContext } from './engine-internal'; +import { findAuthOAuthFlowByState, readAuthOAuthFlowVerifier } from './engine-flows'; +import { bindAuthenticatedSession } from './engine-session'; + +export function createOAuthAuthFlow(context: AuthEngineFlowContext) { + const { options, emit } = context; + + async function startOAuth(input: AuthOAuthStartInput): Promise { + const provider = requireAuthOAuthProvider(options.providers, input.providerId); + if (!provider) throw new AuthConfigError(undefined, { providerId: input.providerId }); + const nowMs = options.ports.timr.nowMs(); + const state = createAuthRandomToken(options.ports.crypto); + const verifier = createAuthRandomToken(options.ports.crypto); + const flow = await options.ports.store.createFlow({ + id: createAuthFlowId(options.ports.crypto), + tenantId: input.tenantId, + kind: AUTH_FLOW_KINDS.OAUTH_STATE, + providerId: input.providerId, + stateHash: await hashAuthToken(options.ports.crypto, state), + verifierHash: await hashAuthToken(options.ports.crypto, verifier), + redirectTo: input.redirectTo, + createdAt: nowMs, + expiresAt: nowMs + AUTH_DEFAULTS.FLOW_TTL_MS, + metadata: { + [AUTH_OAUTH_FLOW_METADATA.STATE]: state, + [AUTH_OAUTH_FLOW_METADATA.VERIFIER]: verifier + } + }); + const resolved = await provider.resolveConfig({ + tenantId: input.tenantId, + redirectUri: input.redirectTo ?? '' + }); + const url = new URL(resolved.authorizationEndpoint); + url.searchParams.set(AUTH_OAUTH_PARAMS.RESPONSE_TYPE, AUTH_OAUTH_VALUES.CODE); + url.searchParams.set(AUTH_OAUTH_PARAMS.CLIENT_ID, resolved.clientId); + url.searchParams.set(AUTH_OAUTH_PARAMS.REDIRECT_URI, resolved.redirectUri); + url.searchParams.set( + AUTH_OAUTH_PARAMS.SCOPE, + resolved.scopes.join(AUTH_OAUTH_VALUES.SCOPE_SEPARATOR) + ); + url.searchParams.set(AUTH_OAUTH_PARAMS.STATE, state); + url.searchParams.set(AUTH_OAUTH_PARAMS.CODE_CHALLENGE_METHOD, AUTH_OAUTH_VALUES.S256); + url.searchParams.set( + AUTH_OAUTH_PARAMS.CODE_CHALLENGE, + await hashAuthToken(options.ports.crypto, verifier) + ); + await emit(AUTH_EVENT_NAMES.OAUTH_STARTED, { + name: AUTH_EVENT_NAMES.OAUTH_STARTED, + tenantId: input.tenantId, + flowId: flow.id, + meta: input.meta, + data: { providerId: input.providerId } + }); + return { authorizationUrl: url.toString(), flowId: flow.id, expiresAt: flow.expiresAt }; + } + + async function completeOAuth(input: AuthOAuthCallbackInput): Promise { + const provider = requireAuthOAuthProvider(options.providers, input.providerId); + if (!provider) throw new AuthConfigError(undefined, { providerId: input.providerId }); + const stateHash = await hashAuthToken(options.ports.crypto, input.state); + const nowMs = options.ports.timr.nowMs(); + const flowCandidates = await findAuthOAuthFlowByState(options, input.tenantId, stateHash); + if (!flowCandidates) throw new AuthFlowInvalidError(); + if (flowCandidates.expiresAt <= nowMs || flowCandidates.consumedAt) + throw new AuthFlowExpiredError(); + const verifier = await readAuthOAuthFlowVerifier(options, flowCandidates); + const profile = await provider.mapProfile({ + tenantId: input.tenantId, + providerId: input.providerId, + code: input.code, + codeVerifier: verifier, + redirectUri: flowCandidates.redirectTo, + tokens: { [AUTH_OAUTH_VALUES.CODE]: input.code } + }); + const linked = await options.ports.store.findLinkedAccount({ + tenantId: input.tenantId, + providerId: input.providerId, + providerSubject: profile.providerSubject + }); + if (!linked) throw new AuthAccountNotLinkedError(); + await options.ports.store.consumeFlow({ + tenantId: input.tenantId, + flowId: flowCandidates.id, + nowMs + }); + const result = await bindAuthenticatedSession({ + options, + emit, + actorRef: linked.actorRef, + inputDevice: undefined, + meta: input.meta + }); + await emit(AUTH_EVENT_NAMES.OAUTH_CALLBACK_SUCCEEDED, { + name: AUTH_EVENT_NAMES.OAUTH_CALLBACK_SUCCEEDED, + tenantId: input.tenantId, + actorRef: linked.actorRef, + sessionId: result.session.sessionId, + meta: input.meta, + data: { providerId: input.providerId } + }); + return result; + } + + return { startOAuth, completeOAuth }; +} diff --git a/src/svrs/auth/options.ts b/src/svrs/auth/options.ts index 1a39e9b..08fe1ac 100644 --- a/src/svrs/auth/options.ts +++ b/src/svrs/auth/options.ts @@ -2,7 +2,6 @@ import type { AuthCachPort, AuthClockPort, AuthCryptoPort, - AuthLogrPort, AuthMailerAdapter, AuthOAuthProviderAdapter, AuthPasswordHasher, @@ -13,6 +12,7 @@ import type { AuthHttpPort, AuthWebAuthnAdapter } from '$libs/auth/contracts'; +import type { Logger } from '$libs/logr'; import type { AuthEventName } from '$libs/auth/types'; import type { AuthEventPayloadMap } from '$libs/auth/events'; @@ -49,7 +49,7 @@ export interface AuthEnginePorts { readonly sess: AuthSessPort; readonly perm?: AuthPermPort; readonly cach?: AuthCachPort; - readonly logr: AuthLogrPort; + readonly logr: Logger; readonly timr: AuthClockPort; readonly crypto: AuthCryptoPort; readonly passwordHasher?: AuthPasswordHasher; diff --git a/src/svrs/auth/password-flow.ts b/src/svrs/auth/password-flow.ts new file mode 100644 index 0000000..7d63b6e --- /dev/null +++ b/src/svrs/auth/password-flow.ts @@ -0,0 +1,130 @@ +import { AUTH_CREDENTIAL_KINDS, AUTH_EVENT_NAMES } from '$libs/auth/consts'; +import { AuthCredentialError, AuthIdentifierTakenError } from '$libs/auth/errors'; +import { createAuthCredentialId } from '$libs/auth/ids'; +import { hashPasswordIdentifier, normalizeAuthIdentifier } from '$libs/auth/normalize'; +import type { + AuthSignInPasswordInput, + AuthSignInResult, + AuthSignUpPasswordInput +} from '$libs/auth/types'; +import type { AuthEngineFlowContext } from './engine-internal'; +import { bindAuthenticatedSession } from './engine-session'; +import { enforcePasswordPolicy, requirePasswordHasher } from './engine-validation'; + +export function createPasswordAuthFlow(context: AuthEngineFlowContext) { + const { options, emit, invalidateIdentityCache } = context; + + async function signUpPassword(input: AuthSignUpPasswordInput): Promise { + const passwordHasher = requirePasswordHasher(options); + const nowMs = options.ports.timr.nowMs(); + const normalized = normalizeAuthIdentifier(input.identifier); + enforcePasswordPolicy(options, input.password, false); + const identifierHash = await hashPasswordIdentifier({ + crypto: options.ports.crypto, + tenantId: input.tenantId, + identifier: normalized.normalized + }); + const existing = await options.ports.store.findCredentialByIdentifier({ + tenantId: input.tenantId, + kind: AUTH_CREDENTIAL_KINDS.PASSWORD, + identifierHash + }); + + if (existing) { + await emit(AUTH_EVENT_NAMES.SIGN_UP_FAILED, { + name: AUTH_EVENT_NAMES.SIGN_UP_FAILED, + tenantId: input.tenantId, + meta: input.meta + }); + throw new AuthIdentifierTakenError(); + } + + const actorRef = await options.ports.actors.createActorForSignup({ + tenantId: input.tenantId, + identifier: normalized.display, + profile: input.profile, + nowMs + }); + + const credential = await options.ports.store.createPasswordCredential({ + id: createAuthCredentialId(options.ports.crypto), + actorRef, + identifierHash, + identifierDisplay: normalized.display, + passwordHash: await passwordHasher.hashPassword({ password: input.password }), + nowMs + }); + + const result = await bindAuthenticatedSession({ + options, + emit, + actorRef, + inputDevice: input.device, + meta: input.meta + }); + await invalidateIdentityCache({ actorRef, reason: AUTH_EVENT_NAMES.SIGN_UP_SUCCEEDED }); + await emit(AUTH_EVENT_NAMES.SIGN_UP_SUCCEEDED, { + name: AUTH_EVENT_NAMES.SIGN_UP_SUCCEEDED, + tenantId: input.tenantId, + actorRef, + sessionId: result.session.sessionId, + deviceId: result.device?.id, + meta: input.meta, + data: { credentialId: credential.id } + }); + return result; + } + + async function signInPassword(input: AuthSignInPasswordInput): Promise { + const passwordHasher = requirePasswordHasher(options); + const normalized = normalizeAuthIdentifier(input.identifier); + const identifierHash = await hashPasswordIdentifier({ + crypto: options.ports.crypto, + tenantId: input.tenantId, + identifier: normalized.normalized + }); + const credential = await options.ports.store.findCredentialByIdentifier({ + tenantId: input.tenantId, + kind: AUTH_CREDENTIAL_KINDS.PASSWORD, + identifierHash + }); + + const fail = async (): Promise => { + await emit(AUTH_EVENT_NAMES.SIGN_IN_FAILED, { + name: AUTH_EVENT_NAMES.SIGN_IN_FAILED, + tenantId: input.tenantId, + meta: input.meta + }); + throw new AuthCredentialError(); + }; + + if (!credential || credential.revokedAt || !credential.passwordHash) return fail(); + const verified = await passwordHasher.verifyPassword({ + password: input.password, + hash: credential.passwordHash + }); + if (!verified.ok) return fail(); + + const actorRef = credential.actorRef; + const result = await bindAuthenticatedSession({ + options, + emit, + actorRef, + inputDevice: input.device, + meta: input.meta + }); + await options.ports.perm?.preloadActorContext?.(actorRef); + await invalidateIdentityCache({ actorRef, reason: AUTH_EVENT_NAMES.SIGN_IN_SUCCEEDED }); + await emit(AUTH_EVENT_NAMES.SIGN_IN_SUCCEEDED, { + name: AUTH_EVENT_NAMES.SIGN_IN_SUCCEEDED, + tenantId: input.tenantId, + actorRef, + sessionId: result.session.sessionId, + deviceId: result.device?.id, + meta: input.meta + }); + return result; + } + + return { signUpPassword, signInPassword }; +} diff --git a/src/svrs/auth/recovery-flow.ts b/src/svrs/auth/recovery-flow.ts new file mode 100644 index 0000000..b3a7e8e --- /dev/null +++ b/src/svrs/auth/recovery-flow.ts @@ -0,0 +1,207 @@ +import { + AUTH_CREDENTIAL_KINDS, + AUTH_DEFAULTS, + AUTH_EVENT_NAMES, + AUTH_FLOW_KINDS, + AUTH_MAIL_KINDS, + AUTH_REVOKE_REASONS +} from '$libs/auth/consts'; +import { AuthFlowInvalidError } from '$libs/auth/errors'; +import { createAuthFlowId } from '$libs/auth/ids'; +import { hashPasswordIdentifier, normalizeAuthIdentifier } from '$libs/auth/normalize'; +import { createAuthRandomToken, hashAuthToken } from '$libs/auth/tokens'; +import type { + AuthEmailVerificationCompleteInput, + AuthEmailVerificationRequestInput, + AuthFlowCompleteResult, + AuthFlowPublicResult, + AuthPasswordResetCompleteInput, + AuthPasswordResetRequestInput, + AuthPasswordResetResult +} from '$libs/auth/types'; +import type { AuthEngineFlowContext } from './engine-internal'; +import { requireLiveAuthFlow } from './engine-flows'; +import { enforcePasswordPolicy, requirePasswordHasher } from './engine-validation'; + +export function createRecoveryAuthFlow(context: AuthEngineFlowContext) { + const { options, emit, invalidateIdentityCache } = context; + + async function requestEmailVerification( + input: AuthEmailVerificationRequestInput + ): Promise { + const nowMs = options.ports.timr.nowMs(); + const normalized = normalizeAuthIdentifier(input.identifier); + const identifierHash = await hashPasswordIdentifier({ + crypto: options.ports.crypto, + tenantId: input.tenantId, + identifier: normalized.normalized + }); + const credential = await options.ports.store.findCredentialByIdentifier({ + tenantId: input.tenantId, + kind: AUTH_CREDENTIAL_KINDS.PASSWORD, + identifierHash + }); + + if (!credential || credential.verifiedAt) return { ok: true }; + const token = createAuthRandomToken(options.ports.crypto); + const flow = await options.ports.store.createFlow({ + id: createAuthFlowId(options.ports.crypto), + tenantId: input.tenantId, + kind: AUTH_FLOW_KINDS.EMAIL_VERIFICATION, + actorRef: credential.actorRef, + credentialId: credential.id, + identifierHash, + challengeHash: await hashAuthToken(options.ports.crypto, token), + redirectTo: input.redirectTo, + createdAt: nowMs, + expiresAt: nowMs + AUTH_DEFAULTS.FLOW_TTL_MS + }); + + await options.ports.mailer?.send({ + kind: AUTH_MAIL_KINDS.EMAIL_VERIFICATION, + tenantId: input.tenantId, + to: normalized.display, + locale: input.locale, + variables: { flowId: flow.id, token, redirectTo: input.redirectTo ?? '' } + }); + await emit(AUTH_EVENT_NAMES.EMAIL_VERIFICATION_REQUESTED, { + name: AUTH_EVENT_NAMES.EMAIL_VERIFICATION_REQUESTED, + tenantId: input.tenantId, + actorRef: credential.actorRef, + flowId: flow.id, + meta: input.meta + }); + return { ok: true, expiresAt: flow.expiresAt }; + } + + async function completeEmailVerification( + input: AuthEmailVerificationCompleteInput + ): Promise { + const flow = await requireLiveAuthFlow( + options, + input.tenantId, + input.flowId, + AUTH_FLOW_KINDS.EMAIL_VERIFICATION + ); + const tokenHash = await hashAuthToken(options.ports.crypto, input.token); + if (!flow.challengeHash || tokenHash !== flow.challengeHash || !flow.credentialId) + throw new AuthFlowInvalidError(); + const nowMs = options.ports.timr.nowMs(); + await options.ports.store.markCredentialVerified({ + tenantId: input.tenantId, + credentialId: flow.credentialId, + nowMs + }); + await options.ports.store.consumeFlow({ tenantId: input.tenantId, flowId: flow.id, nowMs }); + await emit(AUTH_EVENT_NAMES.EMAIL_VERIFIED, { + name: AUTH_EVENT_NAMES.EMAIL_VERIFIED, + tenantId: input.tenantId, + actorRef: flow.actorRef, + flowId: flow.id, + meta: input.meta + }); + return { ok: true }; + } + + async function requestPasswordReset( + input: AuthPasswordResetRequestInput + ): Promise { + const nowMs = options.ports.timr.nowMs(); + const normalized = normalizeAuthIdentifier(input.identifier); + const identifierHash = await hashPasswordIdentifier({ + crypto: options.ports.crypto, + tenantId: input.tenantId, + identifier: normalized.normalized + }); + const credential = await options.ports.store.findCredentialByIdentifier({ + tenantId: input.tenantId, + kind: AUTH_CREDENTIAL_KINDS.PASSWORD, + identifierHash + }); + + if (!credential) return { ok: true }; + const token = createAuthRandomToken(options.ports.crypto); + const flow = await options.ports.store.createFlow({ + id: createAuthFlowId(options.ports.crypto), + tenantId: input.tenantId, + kind: AUTH_FLOW_KINDS.PASSWORD_RESET, + actorRef: credential.actorRef, + credentialId: credential.id, + identifierHash, + challengeHash: await hashAuthToken(options.ports.crypto, token), + redirectTo: input.redirectTo, + createdAt: nowMs, + expiresAt: nowMs + AUTH_DEFAULTS.PASSWORD_RESET_TTL_MS + }); + + await options.ports.mailer?.send({ + kind: AUTH_MAIL_KINDS.PASSWORD_RESET, + tenantId: input.tenantId, + to: normalized.display, + locale: input.locale, + variables: { flowId: flow.id, token, redirectTo: input.redirectTo ?? '' } + }); + await emit(AUTH_EVENT_NAMES.PASSWORD_RESET_REQUESTED, { + name: AUTH_EVENT_NAMES.PASSWORD_RESET_REQUESTED, + tenantId: input.tenantId, + actorRef: credential.actorRef, + flowId: flow.id, + meta: input.meta + }); + return { ok: true, expiresAt: flow.expiresAt }; + } + + async function completePasswordReset( + input: AuthPasswordResetCompleteInput + ): Promise { + const passwordHasher = requirePasswordHasher(options); + enforcePasswordPolicy(options, input.newPassword, false); + const flow = await requireLiveAuthFlow( + options, + input.tenantId, + input.flowId, + AUTH_FLOW_KINDS.PASSWORD_RESET + ); + const tokenHash = await hashAuthToken(options.ports.crypto, input.token); + if ( + !flow.challengeHash || + tokenHash !== flow.challengeHash || + !flow.credentialId || + !flow.actorRef + ) + throw new AuthFlowInvalidError(); + const nowMs = options.ports.timr.nowMs(); + await options.ports.store.updatePasswordCredential({ + tenantId: input.tenantId, + credentialId: flow.credentialId, + passwordHash: await passwordHasher.hashPassword({ password: input.newPassword }), + nowMs + }); + await options.ports.store.consumeFlow({ tenantId: input.tenantId, flowId: flow.id, nowMs }); + const revokedSessionIds = await options.ports.store.revokeActorSessions({ + actorRef: flow.actorRef, + nowMs, + reason: AUTH_REVOKE_REASONS.PASSWORD_CHANGED + }); + await options.ports.sess.endMany({ sessionIds: revokedSessionIds, meta: input.meta }); + await invalidateIdentityCache({ + actorRef: flow.actorRef, + reason: AUTH_EVENT_NAMES.PASSWORD_RESET_COMPLETED + }); + await emit(AUTH_EVENT_NAMES.PASSWORD_RESET_COMPLETED, { + name: AUTH_EVENT_NAMES.PASSWORD_RESET_COMPLETED, + tenantId: input.tenantId, + actorRef: flow.actorRef, + flowId: flow.id, + meta: input.meta + }); + return { ok: true, revokedSessionIds }; + } + + return { + requestEmailVerification, + completeEmailVerification, + requestPasswordReset, + completePasswordReset + }; +} diff --git a/src/svrs/auth/session-flow.ts b/src/svrs/auth/session-flow.ts new file mode 100644 index 0000000..a78e058 --- /dev/null +++ b/src/svrs/auth/session-flow.ts @@ -0,0 +1,111 @@ +import { AUTH_EVENT_NAMES, AUTH_REVOKE_REASONS, AUTH_SESSION_STATUSES } from '$libs/auth/consts'; +import { AuthSessionRequiredError } from '$libs/auth/errors'; +import { createAnonymousAuthCurrent, extractAuthRequestMeta } from '$libs/auth/helpers'; +import type { + AuthCurrentInput, + AuthCurrentView, + AuthSignOutGlobalInput, + AuthSignOutInput, + AuthSignOutResult +} from '$libs/auth/types'; +import type { AuthEngineFlowContext } from './engine-internal'; + +export function createSessionAuthFlow(context: AuthEngineFlowContext) { + const { options, emit, invalidateIdentityCache } = context; + + async function current(input: AuthCurrentInput): Promise { + const session = await options.ports.sess.read({ + ...input, + meta: input.meta ?? extractAuthRequestMeta(input.request) + }); + if ( + session.status !== AUTH_SESSION_STATUSES.AUTHENTICATED || + !session.sessionId || + !session.actorRef + ) { + return { session }; + } + + const binding = await options.ports.store.findSessionBinding({ + sessSessionId: session.sessionId + }); + if (!binding || binding.revokedAt) { + await options.ports.sess.end({ sessionId: session.sessionId, meta: input.meta }); + return createAnonymousAuthCurrent(); + } + + const actor = await options.ports.actors.getPublicActor(binding.actorRef); + return { + session: { + ...session, + actorRef: binding.actorRef, + deviceId: binding.deviceId, + aal: binding.aal, + amr: binding.amr, + authTime: binding.authTime + }, + actor: actor ?? undefined + }; + } + + async function signOut(input: AuthSignOutInput): Promise { + const snapshot = await options.ports.sess.read({ + ...input, + meta: input.meta ?? extractAuthRequestMeta(input.request) + }); + const sessionId = input.sessionId ?? snapshot.sessionId; + if (!sessionId) return { current: createAnonymousAuthCurrent(), endedSessionIds: [] }; + const binding = await options.ports.store.findSessionBinding({ sessSessionId: sessionId }); + const nowMs = options.ports.timr.nowMs(); + await options.ports.store.revokeSessionBinding({ + sessSessionId: sessionId, + nowMs, + reason: AUTH_REVOKE_REASONS.LOGOUT + }); + await options.ports.sess.end({ sessionId, meta: input.meta }); + await invalidateIdentityCache({ + actorRef: binding?.actorRef, + reason: AUTH_EVENT_NAMES.SIGN_OUT_SUCCEEDED + }); + await emit(AUTH_EVENT_NAMES.SIGN_OUT_SUCCEEDED, { + name: AUTH_EVENT_NAMES.SIGN_OUT_SUCCEEDED, + tenantId: binding?.actorRef.tenantId ?? input.tenantId, + actorRef: binding?.actorRef, + sessionId, + meta: input.meta + }); + return { current: createAnonymousAuthCurrent(), endedSessionIds: [sessionId] }; + } + + async function signOutGlobal(input: AuthSignOutGlobalInput): Promise { + const snapshot = await options.ports.sess.read({ + ...input, + meta: input.meta ?? extractAuthRequestMeta(input.request) + }); + const sessionId = input.sessionId ?? snapshot.sessionId; + if (!sessionId) throw new AuthSessionRequiredError(); + const binding = await options.ports.store.findSessionBinding({ sessSessionId: sessionId }); + if (!binding) throw new AuthSessionRequiredError(); + const nowMs = options.ports.timr.nowMs(); + const endedSessionIds = await options.ports.store.revokeActorSessions({ + actorRef: binding.actorRef, + nowMs, + reason: AUTH_REVOKE_REASONS.GLOBAL_LOGOUT + }); + await options.ports.sess.endMany({ sessionIds: endedSessionIds, meta: input.meta }); + await invalidateIdentityCache({ + actorRef: binding.actorRef, + reason: AUTH_EVENT_NAMES.SIGN_OUT_GLOBAL_SUCCEEDED + }); + await emit(AUTH_EVENT_NAMES.SIGN_OUT_GLOBAL_SUCCEEDED, { + name: AUTH_EVENT_NAMES.SIGN_OUT_GLOBAL_SUCCEEDED, + tenantId: binding.actorRef.tenantId, + actorRef: binding.actorRef, + sessionId, + meta: input.meta + }); + return { current: createAnonymousAuthCurrent(), endedSessionIds }; + } + + return { current, signOut, signOutGlobal }; +} diff --git a/src/svrs/auth/test/engine-password.test.ts b/src/svrs/auth/test/engine-password.test.ts index 3f0e53a..30fdb4d 100644 --- a/src/svrs/auth/test/engine-password.test.ts +++ b/src/svrs/auth/test/engine-password.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { AUTH_EVENT_NAMES, AUTH_TEST_TENANT_ID } from '$libs/auth'; +import { + AUTH_ERROR_CODES, + AUTH_EVENT_NAMES, + AUTH_SESSION_STATUSES, + AUTH_TEST_TENANT_ID +} from '$libs/auth'; import { createDeterministicAuthCrypto, createEngineAuth, @@ -54,6 +59,35 @@ describe('createEngineAuth password flow', () => { expect(cach.invalidations.length).toBeGreaterThan(0); }); + it('rejects duplicate password identifiers after normalization', async () => { + const { engine } = createHarness(); + await engine.signUpPassword({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: 'Person@Example.com', + password: 'correct horse battery staple' + }); + + await expect( + engine.signUpPassword({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: 'person@example.com', + password: 'correct horse battery staple' + }) + ).rejects.toMatchObject({ code: AUTH_ERROR_CODES.IDENTIFIER_TAKEN }); + }); + + it('rejects passwords that do not satisfy the configured policy', async () => { + const { engine } = createHarness(); + + await expect( + engine.signUpPassword({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: 'person@example.com', + password: 'short' + }) + ).rejects.toMatchObject({ code: AUTH_ERROR_CODES.CREDENTIAL_INVALID }); + }); + it('rejects wrong password with a safe credential error', async () => { const { engine } = createHarness(); await engine.signUpPassword({ @@ -68,6 +102,66 @@ describe('createEngineAuth password flow', () => { identifier: 'person@example.com', password: 'wrong horse battery staple' }) - ).rejects.toMatchObject({ code: 'AUTH_CREDENTIAL_INVALID' }); + ).rejects.toMatchObject({ code: AUTH_ERROR_CODES.CREDENTIAL_INVALID }); + }); + + it('global sign-out revokes every session for the actor', async () => { + const { engine } = createHarness(); + const first = await engine.signUpPassword({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: 'person@example.com', + password: 'correct horse battery staple' + }); + const second = await engine.signInPassword({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: 'person@example.com', + password: 'correct horse battery staple' + }); + + const result = await engine.signOutGlobal({ + tenantId: AUTH_TEST_TENANT_ID, + sessionId: second.session.sessionId + }); + + expect(new Set(result.endedSessionIds)).toEqual( + new Set([first.session.sessionId, second.session.sessionId]) + ); + await expect( + engine.current({ tenantId: AUTH_TEST_TENANT_ID, sessionId: first.session.sessionId }) + ).resolves.toMatchObject({ session: { status: AUTH_SESSION_STATUSES.ANONYMOUS } }); + await expect( + engine.current({ tenantId: AUTH_TEST_TENANT_ID, sessionId: second.session.sessionId }) + ).resolves.toMatchObject({ session: { status: AUTH_SESSION_STATUSES.ANONYMOUS } }); + }); + + it('device revocation closes every session bound to that device', async () => { + const { engine } = createHarness(); + const first = await engine.signUpPassword({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: 'person@example.com', + password: 'correct horse battery staple' + }); + const deviceId = first.device!.id; + const second = await engine.signInPassword({ + tenantId: AUTH_TEST_TENANT_ID, + identifier: 'person@example.com', + password: 'correct horse battery staple', + device: { deviceId } + }); + + const result = await engine.revokeDevice({ + actorRef: first.session.actorRef!, + deviceId + }); + + expect(new Set(result.revokedSessionIds)).toEqual( + new Set([first.session.sessionId, second.session.sessionId]) + ); + await expect( + engine.current({ tenantId: AUTH_TEST_TENANT_ID, sessionId: first.session.sessionId }) + ).resolves.toMatchObject({ session: { status: AUTH_SESSION_STATUSES.ANONYMOUS } }); + await expect( + engine.current({ tenantId: AUTH_TEST_TENANT_ID, sessionId: second.session.sessionId }) + ).resolves.toMatchObject({ session: { status: AUTH_SESSION_STATUSES.ANONYMOUS } }); }); }); diff --git a/src/svrs/cach/diagnostics.ts b/src/svrs/cach/diagnostics.ts new file mode 100644 index 0000000..7b57cf7 --- /dev/null +++ b/src/svrs/cach/diagnostics.ts @@ -0,0 +1,53 @@ +import { + LogLevel, + createCatalogDiagnostics, + type DiagnosticCatalog, + type DiagnosticEvent, + type Diagnostics, + type Logger +} from '$libs/logr'; +import { + CACHE_EVENT_ADAPTER_ERROR, + CACHE_EVENT_REFRESH_ERROR, + CACHE_EVENT_STALE_IF_ERROR, + type CacheEvent, + type CacheEventType +} from '$libs/cach'; +import { CACHE_LOG_MESSAGE_BY_EVENT, LOGGER_CATEGORY } from './consts.ts'; + +export type CacheDiagnosticEvent = DiagnosticEvent; +export type CacheDiagnostics = Diagnostics; + +const CACHE_DIAGNOSTIC_LOGS: DiagnosticCatalog = Object.fromEntries( + Object.entries(CACHE_LOG_MESSAGE_BY_EVENT).map(([type, message]) => [ + type, + (event: CacheDiagnosticEvent) => ({ + level: cacheLogLevel(event.meta), + message + }) + ]) +) as DiagnosticCatalog; + +function cacheLogLevel(event: CacheEvent | undefined) { + if (event?.type === CACHE_EVENT_ADAPTER_ERROR || event?.type === CACHE_EVENT_REFRESH_ERROR) { + return LogLevel.ERROR; + } + if (event?.type === CACHE_EVENT_STALE_IF_ERROR) return LogLevel.WARN; + return LogLevel.DEBUG; +} + +export function createCacheDiagnostics(logger?: Logger): CacheDiagnostics { + return createCatalogDiagnostics({ + logger, + defaultCategory: LOGGER_CATEGORY, + catalog: CACHE_DIAGNOSTIC_LOGS + }); +} + +export function emitCacheDiagnostic(diagnostics: CacheDiagnostics, event: CacheEvent): void { + diagnostics.emit({ + artifact: LOGGER_CATEGORY, + type: event.type, + meta: event + }); +} diff --git a/src/svrs/cach/engine-cache.ts b/src/svrs/cach/engine-cache.ts index dd67760..517bf5f 100644 --- a/src/svrs/cach/engine-cache.ts +++ b/src/svrs/cach/engine-cache.ts @@ -1,13 +1,5 @@ +import { createCacheRuntime, memoryCacheAdapter } from '$libs/cach'; import { - CACHE_EVENT_ADAPTER_ERROR, - CACHE_EVENT_REFRESH_ERROR, - CACHE_EVENT_STALE_IF_ERROR, - createCacheRuntime, - memoryCacheAdapter, - type CacheEvent -} from '$libs/cach'; -import { - CACHE_LOG_MESSAGE_BY_EVENT, CACHE_METHOD_CLEAR, CACHE_METHOD_EXPLAIN, CACHE_METHOD_GET, @@ -16,20 +8,21 @@ import { CACHE_METHOD_ON, CACHE_METHOD_QUERY, CACHE_METHOD_SET, - CACHE_METHOD_STATS, - LOGGER_CATEGORY + CACHE_METHOD_STATS } from './consts.ts'; +import { createCacheDiagnostics, emitCacheDiagnostic } from './diagnostics.ts'; import { CachDisposedError } from './errors.ts'; import { disposedCacheMessage } from './helpers.ts'; import type { EngineCache, EngineCacheOptions } from './types.ts'; export function createEngineCache(options: EngineCacheOptions = {}): EngineCache { + const diagnostics = createCacheDiagnostics(options.logger); const runtime = createCacheRuntime({ ...options, adapter: options.adapter ?? memoryCacheAdapter({ clock: options.clock }), onEvent(event) { options.onEvent?.(event); - logCacheEvent(options, event); + emitCacheDiagnostic(diagnostics, event); } }); @@ -83,35 +76,3 @@ export function createEngineCache(options: EngineCacheOptions = {}): EngineCache } }; } - -function logCacheEvent(options: EngineCacheOptions, event: CacheEvent): void { - const message = CACHE_LOG_MESSAGE_BY_EVENT[event.type]; - const context = { - adapter: event.adapter, - key: event.key, - keyHash: event.keyHash, - namespace: event.namespace, - policy: event.policy, - reason: event.reason, - scopeMode: event.scopeMode, - tags: event.tags - }; - - if (event.type === CACHE_EVENT_ADAPTER_ERROR || event.type === CACHE_EVENT_REFRESH_ERROR) { - options.logger?.error?.(LOGGER_CATEGORY, message, { - error: event.error, - context - }); - return; - } - - if (event.type === CACHE_EVENT_STALE_IF_ERROR) { - options.logger?.warn?.(LOGGER_CATEGORY, message, { - error: event.error, - context - }); - return; - } - - options.logger?.debug?.(LOGGER_CATEGORY, message, { context }); -} diff --git a/src/svrs/cach/index.ts b/src/svrs/cach/index.ts index 2518fb9..0641304 100644 --- a/src/svrs/cach/index.ts +++ b/src/svrs/cach/index.ts @@ -1,4 +1,5 @@ export { createEngineCache } from './engine-cache.ts'; +export { createCacheDiagnostics, emitCacheDiagnostic } from './diagnostics.ts'; export { CACHE_ERROR_MSG_DISPOSED_SUFFIX, CACHE_ERROR_NAME_DISPOSED, @@ -31,7 +32,8 @@ export { } from './consts.ts'; export { CachDisposedError, isCachDisposedError } from './errors.ts'; export { disposedCacheMessage } from './helpers.ts'; -export type { CacheLogger, EngineCache, EngineCacheOptions } from './types.ts'; +export type { EngineCache, EngineCacheOptions } from './types.ts'; +export type { CacheDiagnosticEvent, CacheDiagnostics } from './diagnostics.ts'; export { CACHE_ADAPTER_MEMORY, diff --git a/src/svrs/cach/types.ts b/src/svrs/cach/types.ts index f407b6f..8483d89 100644 --- a/src/svrs/cach/types.ts +++ b/src/svrs/cach/types.ts @@ -1,14 +1,9 @@ import type { CacheAdapter, CacheEventHandler, CacheRuntime, CacheRuntimeConfig } from '$libs/cach'; -import type { EngineLogger } from '$logr'; - -export type CacheLogger = Pick< - EngineLogger, - 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' ->; +import type { Logger } from '$libs/logr'; export interface EngineCacheOptions extends Omit { adapter?: CacheAdapter; - logger?: CacheLogger; + logger?: Logger; onEvent?: CacheEventHandler; } diff --git a/src/svrs/perm/consts.ts b/src/svrs/perm/consts.ts index aae9fda..8ae5cc3 100644 --- a/src/svrs/perm/consts.ts +++ b/src/svrs/perm/consts.ts @@ -1,5 +1,11 @@ export { PERMISSION_LOGGER_CATEGORY as LOGGER_CATEGORY } from '$libs/svrs/perm'; +export const PERMISSION_DIAGNOSTIC_EVENTS = { + DECISION: 'decision', + DENIED: 'denied', + INDETERMINATE: 'indeterminate' +} as const; + export const PERMISSION_LOG_MSG_DECISION = 'authorization decision'; export const PERMISSION_LOG_MSG_DENIED = 'authorization denied'; export const PERMISSION_LOG_MSG_INDETERMINATE = 'authorization indeterminate'; diff --git a/src/svrs/perm/diagnostics.ts b/src/svrs/perm/diagnostics.ts new file mode 100644 index 0000000..b9ee9b4 --- /dev/null +++ b/src/svrs/perm/diagnostics.ts @@ -0,0 +1,68 @@ +import { + LogLevel, + createCatalogDiagnostics, + type DiagnosticCatalog, + type DiagnosticEvent, + type Diagnostics +} from '$libs/logr'; +import type { PermissionCheckInput, PermissionDecision } from '$libs/perm'; +import { + LOGGER_CATEGORY, + PERMISSION_DIAGNOSTIC_EVENTS, + PERMISSION_LOG_MSG_DECISION, + PERMISSION_LOG_MSG_DENIED, + PERMISSION_LOG_MSG_INDETERMINATE +} from './consts.ts'; +import type { EnginePermissionsOptions } from './types.ts'; + +export type PermissionDiagnosticType = + (typeof PERMISSION_DIAGNOSTIC_EVENTS)[keyof typeof PERMISSION_DIAGNOSTIC_EVENTS]; + +export interface PermissionDiagnosticMeta { + readonly action: PermissionCheckInput['action']; + readonly resource: PermissionCheckInput['resource']; + readonly decision: PermissionDecision; +} + +export type PermissionDiagnosticEvent = DiagnosticEvent< + PermissionDiagnosticType, + PermissionDiagnosticMeta +>; +export type PermissionDiagnostics = Diagnostics; + +const PERMISSION_DIAGNOSTIC_LOGS: DiagnosticCatalog = { + [PERMISSION_DIAGNOSTIC_EVENTS.DECISION]: { + level: LogLevel.DEBUG, + message: PERMISSION_LOG_MSG_DECISION + }, + [PERMISSION_DIAGNOSTIC_EVENTS.DENIED]: { + level: LogLevel.WARN, + message: PERMISSION_LOG_MSG_DENIED + }, + [PERMISSION_DIAGNOSTIC_EVENTS.INDETERMINATE]: { + level: LogLevel.WARN, + message: PERMISSION_LOG_MSG_INDETERMINATE + } +}; + +export function createPermissionDiagnostics( + logger?: EnginePermissionsOptions['logger'] +): PermissionDiagnostics { + return createCatalogDiagnostics({ + logger, + defaultCategory: LOGGER_CATEGORY, + catalog: PERMISSION_DIAGNOSTIC_LOGS + }); +} + +export function emitPermissionDiagnostic( + diagnostics: PermissionDiagnostics, + type: PermissionDiagnosticType, + meta: PermissionDiagnosticMeta +): void { + diagnostics.emit({ + artifact: LOGGER_CATEGORY, + type, + meta + }); +} diff --git a/src/svrs/perm/engine-permissions.ts b/src/svrs/perm/engine-permissions.ts index 935bd3e..a2fc02f 100644 --- a/src/svrs/perm/engine-permissions.ts +++ b/src/svrs/perm/engine-permissions.ts @@ -6,10 +6,7 @@ import { createPermissionRuntime } from '$libs/perm'; import { - LOGGER_CATEGORY, - PERMISSION_LOG_MSG_DECISION, - PERMISSION_LOG_MSG_DENIED, - PERMISSION_LOG_MSG_INDETERMINATE, + PERMISSION_DIAGNOSTIC_EVENTS, PERMISSION_METHOD_ASSERT, PERMISSION_METHOD_CAN, PERMISSION_METHOD_CHECK, @@ -18,12 +15,14 @@ import { PERMISSION_METHOD_WHAT, PERMISSION_METHOD_WHO } from './consts.ts'; +import { createPermissionDiagnostics, emitPermissionDiagnostic } from './diagnostics.ts'; import { PermDisposedError } from './errors.ts'; import { disposedPermissionsMessage } from './helpers.ts'; import type { EnginePermissions, EnginePermissionsOptions } from './types.ts'; export function createEnginePermissions(options: EnginePermissionsOptions): EnginePermissions { const runtime = createPermissionRuntime(options); + const diagnostics = createPermissionDiagnostics(options.logger); let disposed = false; function ensureLive(method: string): void { @@ -33,30 +32,17 @@ export function createEnginePermissions(options: EnginePermissionsOptions): Engi async function check(input: Parameters[0]) { ensureLive(PERMISSION_METHOD_CHECK); const decision = await runtime.check(input); + const meta = { + action: input.action, + resource: input.resource, + decision + }; if (decision.effect === PERMISSION_EFFECT_DENY) { - options.logger?.warn?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_DENIED, { - context: { - action: input.action, - resource: input.resource, - decision - } - }); + emitPermissionDiagnostic(diagnostics, PERMISSION_DIAGNOSTIC_EVENTS.DENIED, meta); } else if (decision.effect === PERMISSION_EFFECT_INDETERMINATE) { - options.logger?.warn?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_INDETERMINATE, { - context: { - action: input.action, - resource: input.resource, - decision - } - }); + emitPermissionDiagnostic(diagnostics, PERMISSION_DIAGNOSTIC_EVENTS.INDETERMINATE, meta); } else { - options.logger?.debug?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_DECISION, { - context: { - action: input.action, - resource: input.resource, - decision - } - }); + emitPermissionDiagnostic(diagnostics, PERMISSION_DIAGNOSTIC_EVENTS.DECISION, meta); } return decision; } diff --git a/src/svrs/perm/index.ts b/src/svrs/perm/index.ts index 4e56030..b6b4883 100644 --- a/src/svrs/perm/index.ts +++ b/src/svrs/perm/index.ts @@ -1,8 +1,10 @@ export { createEnginePermissions } from './engine-permissions.ts'; +export { createPermissionDiagnostics, emitPermissionDiagnostic } from './diagnostics.ts'; export { createPermissionHttpHandlers } from './http.ts'; export { LOGGER_CATEGORY, + PERMISSION_DIAGNOSTIC_EVENTS, PERMISSION_ERROR_MSG_BODY_MUST_BE_OBJECT, PERMISSION_ERROR_MSG_DISPOSED_SUFFIX, PERMISSION_ERROR_NAME_DISPOSED, @@ -25,6 +27,12 @@ export { isPermInvalidBodyError } from './errors.ts'; export { disposedPermissionsMessage } from './helpers.ts'; +export type { + PermissionDiagnosticEvent, + PermissionDiagnosticMeta, + PermissionDiagnosticType, + PermissionDiagnostics +} from './diagnostics.ts'; export type { EnginePermissions, EnginePermissionsOptions, diff --git a/src/svrs/perm/types.ts b/src/svrs/perm/types.ts index d2c8f02..4c83581 100644 --- a/src/svrs/perm/types.ts +++ b/src/svrs/perm/types.ts @@ -1,4 +1,4 @@ -import type { EngineLogger } from '$logr'; +import type { Logger } from '$libs/logr'; import type { PermSchema, PermissionCheckInput, @@ -9,7 +9,7 @@ import type { } from '$libs/perm'; export interface EnginePermissionsOptions extends PermissionRuntimeOptions { - readonly logger?: EngineLogger; + readonly logger?: Logger; } export interface EnginePermissions extends PermissionRuntime {