Add server cache and permissions layers

master
dev 5 months ago
parent a9c58b6cc3
commit 75df0938cf

@ -6,25 +6,30 @@ naming conventions:
- **`Engine*`** — public methods over private state (or no state at all). - **`Engine*`** — public methods over private state (or no state at all).
Pure factory; the locale, logger or any volatile input is passed as Pure factory; the locale, logger or any volatile input is passed as
argument on every call. argument on every call. When an artifact has a true server-authoritative
counterpart (`perm`, `cach`), the engine lives under `src/svrs/`.
- **`Active*`** — an `Engine*` that exposes public reactive state. Lives in a - **`Active*`** — an `Engine*` that exposes public reactive state. Lives in a
`.svelte.ts` file because it owns `$state`. Imports must target the file `.svelte.ts` file because it owns `$state`. Imports must target the file
directly, not the barrel, to keep the rest of the artifact runes-free. directly, not the barrel, to keep the rest of the artifact runes-free.
## Map ## Map
| Artifact | Layer(s) | Purpose | Depends on | | Artifact | Layer(s) | Purpose | Depends on |
| -------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | -------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| [`lang`](./lang/README.md) | `EngineLang`, `ActiveLang`, `ActiveMonoLang` | i18n: type-safe translations, BCP 47 resolution, plurals, refs, JSON round-trip | — | | [`lang`](./lang/README.md) | `EngineLang`, `ActiveLang`, `ActiveMonoLang` | i18n: type-safe translations, BCP 47 resolution, plurals, refs, JSON round-trip | — |
| [`logr`](./logr/README.md) | `EngineLogger` | Structured logger: levels, transports, filters, vitals, dispose | — | | [`logr`](./logr/README.md) | `EngineLogger` | Structured logger: levels, transports, filters, vitals, dispose | — |
| [`fmts`](./fmts/README.md) | `EngineFormats`, `ActiveFormats` | Localized formatting: numbers, currency, units, dates | `$logr` (currency) | | [`timr`](./timr/README.md) | `EngineTimers`, `ActiveTimers` | Deterministic timer scheduler: clock injection, one-shots, intervals, cancellation, snapshots, backoff | `$libs/timers`, `$logr` (optional) |
| [`adom`](./adom/README.md) | `ActiveDom` | Reactive DOM service: viewport, breakpoints, attribute writes, scroll lock | `$libs/dom`, `$reactive` | | [`fmts`](./fmts/README.md) | `EngineFormats`, `ActiveFormats` | Localized formatting: numbers, currency, units, dates | `$logr` (currency) |
| [`fend`](./fend/README.md) | `ActiveFrontend` | Frontend preferences: theme, mode, dir, density, density, applied via DOM attrs | `$adom` | | [`adom`](./adom/README.md) | `ActiveDom` | Reactive DOM service: viewport, breakpoints, attribute writes, scroll lock | `$libs/dom`, `$reactive` |
| [`sium`](./sium/README.md) | `EngineSium` | Validation contracts: schemas, issues, introspection, Standard Schema interop | `$lang` (optional), `$logr` (optional), `$libs/days`, `$libs/color` | | [`fend`](./fend/README.md) | `ActiveFrontend` | Frontend preferences: theme, mode, dir, density, applied via DOM attrs | `$adom` |
| [`stor`](./stor/README.md) | `EngineStorage`, `ActiveStorage` | Reactive sync key/value: pluggable adapters (local/session/memory/cookie + `withBroadcast` wrapper), version+migrate, TTL, validation, intra-tab + cross-tab sync, reactive keys via `dynamicEntry` | `$sium` (Standard Schema interop, optional) | | [`sium`](./sium/README.md) | `EngineSium` | Validation contracts: schemas, issues, introspection, Standard Schema interop | `$lang` (optional), `$logr` (optional), `$libs/days`, `$libs/color` |
| [`http`](./http/README.md) | `EngineHttp` | HTTP client: tagged `HttpResult`, per-call Standard Schema validation (response + body), idempotent-by-default retry with `Retry-After`, per-attempt and total timeouts, hooks lifecycle, SvelteKit `event.fetch` integration | `$libs/standard-schema` (type-only), `$logr` (optional) | | [`stor`](./stor/README.md) | `EngineStorage`, `ActiveStorage` | Reactive sync key/value: pluggable adapters, version+migrate, TTL, validation, intra-tab + cross-tab sync, reactive keys | `$sium` (Standard Schema interop, optional) |
| [`sess`](./sess/README.md) | `EngineSession`, `ActiveSession` | Session lifecycle: adopt/revoke/refresh with generation guard + dedup, tagged `RevokeResult`, per-call Standard Schema validation, auto-refresh wrapper (ticker + visibility + jitter), 401-rescue hook for `$http`, SvelteKit SSR via `adoptServer` + cookie reader | `$libs/standard-schema` (type-only), `$stor` (storage), `$logr` (optional), `$http` (type-only) | | [`http`](./http/README.md) | `EngineHttp` | HTTP client: tagged `HttpResult`, Standard Schema validation, retry, timeouts, hooks, SvelteKit `event.fetch` integration | `$libs/http`, `$libs/standard-schema` (type-only), `$logr` |
| [`aapp`](./aapp/README.md) | `ActiveApp` | App composition: wires Logger + Lang + Formats + Frontend + Dom + Storage + Http + (optional) Sess under a shared locale and logger; provides `App.createSiumEngine()` | every artifact above | | [`sess`](./sess/README.md) | `EngineSession`, `ActiveSession` | Session lifecycle: adopt/revoke/refresh, auto-refresh, 401-rescue hook, SvelteKit SSR via `adoptServer` + cookie reader | `$stor`, `$timr`, `$http`, `$logr` (optional) |
| [`conn`](./conn/README.md) | `EngineConnections`, `ActiveConnections` | Realtime connection registry: transports, reconnect, heartbeat, request/reply, channels, session bridge | `$timr`, `$logr` (optional), `$sess` bridge (optional) |
| [`perm`](./perm/README.md) | `ActivePermissions` (`EnginePermissions` in `$svrs/perm`) | Authorization: policy runtime adapter, HTTP client/handlers, cache snapshot, `<Can />` guard | `$libs/perm`, `$libs/svrs`, `$http`, `$logr` (optional) |
| [`cach`](./cach/README.md) | `ActiveCache` (`EngineCache` in `$svrs/cach`) | Data cache: deterministic keys, policies, scopes, stale/revalidate, tags, memory/storage adapters | `$libs/cach`, `$stor` (adapter), `$logr` (optional) |
| [`aapp`](./aapp/README.md) | `ActiveApp` | App composition: wires Logger + Lang + Formats + Frontend + Dom + Storage + Http + Timers + Cache; factories for Sess, Conn, Perm, Sium | every artifact above |
## Composition ## Composition
@ -50,11 +55,14 @@ adapter (mono lang, console logger, default-locale formats). Call sites stay
uniform: `App.Lang.t(...)` and `App.Formats.currency.format(...)` work uniform: `App.Lang.t(...)` and `App.Formats.currency.format(...)` work
whether or not i18n was configured. whether or not i18n was configured.
`Sium` is **not** part of App because validation is page-scoped — pages with `Sium`, `Connections` and `Permissions` are exposed as **factories** because
forms construct it locally: they are feature/page-scoped: App injects shared services, but construction is
explicit at the call site.
```ts ```ts
const sium = createEngineSium({ lang: App.Lang, logger: App.Logger }); const sium = createEngineSium({ lang: App.Lang, logger: App.Logger });
const Connections = App.createActiveConnections();
const Permissions = App.createActivePermissions({ endpoint: '/permissions' });
``` ```
See `aapp/README.md` for the full composition contract. See `aapp/README.md` for the full composition contract.
@ -62,30 +70,39 @@ See `aapp/README.md` for the full composition contract.
## Cross-artifact dependencies ## Cross-artifact dependencies
``` ```
lang logr lang logr
\ / \ \ \ / | \
\ / \ \ \ / | \
fmts sium http (logr injected when composed via App) fmts http timr
\ : : \ | / \
\ : : adom ─── fend \ | / conn
adom ─── fend \ : : \ \ \ | / \
\ \ \ : : \ \ \ | / perm
\ \ \ : : ──────────────── aapp ─ stor ─ cach
────── aapp ──────── :
│ sium
(consumed by pages)
``` ```
- `lang` and `logr` are the dependency-free roots (`zero-dep` libraries). - `lang` and `logr` are the dependency-free roots (`zero-dep` libraries).
- `fmts` consumes `logr` only inside `currency` (rate fetcher diagnostics). - `fmts` consumes `logr` only inside `currency` (rate fetcher diagnostics).
- `sium` accepts `lang` and `logr` via injection; without them it falls back - `sium` accepts `lang` and `logr` via injection; without them it falls back
to local message interpolation. to local message interpolation.
- `http` accepts `logr` via injection (auto-wired through `aapp`); the - `timr` is the deterministic scheduler consumed by `sess` and `conn`.
artifact only depends on the `StandardSchemaV1` interface from - `http` accepts `logr` via injection (auto-wired through `aapp`) and keeps
`$libs/standard-schema` (type-only) for response validation. shared HTTP literals/types in `$libs/http`.
- `sess` consumes `stor` for persistence, `timr` for auto-refresh, and `http`
for 401-rescue integration.
- `conn` consumes `timr` for reconnect/heartbeat/ack timeouts and accepts the
App session bridge when composed through `aapp`.
- `perm` splits cleanly: `$svrs/perm` owns the authoritative engine/HTTP
handlers, while `$perm` owns the active UI reflector and `<Can />`.
- `cach` splits cleanly: `$svrs/cach` owns the imperative engine, while
`$cach` owns the active Svelte wrapper and can consume `stor` through its
storage adapter.
- `adom` depends only on the pure helpers in `libs/dom` and on `libs/reactive`. - `adom` depends only on the pure helpers in `libs/dom` and on `libs/reactive`.
- `fend` depends on `adom` for DOM attribute writes. - `fend` depends on `adom` for DOM attribute writes.
- `aapp` composes all of the above except `sium`. - `aapp` composes always-present roots and exposes factories for scoped
artifacts (`sium`, `sess`, `conn`, `perm`).
## Shared types ## Shared types
@ -101,14 +118,19 @@ adom ─── fend \ : :
alias: { alias: {
$aapp: 'src/arts/aapp', $aapp: 'src/arts/aapp',
$adom: 'src/arts/adom', $adom: 'src/arts/adom',
$cach: 'src/arts/cach',
$conn: 'src/arts/conn',
$fend: 'src/arts/fend', $fend: 'src/arts/fend',
$fmts: 'src/arts/fmts', $fmts: 'src/arts/fmts',
$http: 'src/arts/http', $http: 'src/arts/http',
$lang: 'src/arts/lang', $lang: 'src/arts/lang',
$logr: 'src/arts/logr', $logr: 'src/arts/logr',
$perm: 'src/arts/perm',
$sess: 'src/arts/sess', $sess: 'src/arts/sess',
$sium: 'src/arts/sium', $sium: 'src/arts/sium',
$stor: 'src/arts/stor', $stor: 'src/arts/stor',
$svrs: 'src/svrs',
$timr: 'src/arts/timr',
$libs: 'src/libs', $libs: 'src/libs',
$locale: 'src/libs/locale', $locale: 'src/libs/locale',
$reactive: 'src/libs/reactive' $reactive: 'src/libs/reactive'
@ -152,5 +174,9 @@ Each artifact ships an interactive page under `src/web/routes/test/<artifact>`:
- `/test/stor` — adapters (memory / local / session / cookie), envelope versioning + migrate, raw mode, TTL, mergeDefaults, cross-tab sync - `/test/stor` — adapters (memory / local / session / cookie), envelope versioning + migrate, raw mode, TTL, mergeDefaults, cross-tab sync
- `/test/http` — GET/POST with Sium validation, retry + Retry-After, timeout, cancellation, lifecycle hooks, tagged `HttpResult` - `/test/http` — GET/POST with Sium validation, retry + Retry-After, timeout, cancellation, lifecycle hooks, tagged `HttpResult`
- `/test/sess` — session lifecycle: adopt/revoke/refresh with generation guard + dedup, auto-refresh, tagged `RevokeResult`, permission checks, event stream - `/test/sess` — session lifecycle: adopt/revoke/refresh with generation guard + dedup, auto-refresh, tagged `RevokeResult`, permission checks, event stream
- `/test/timr` — scheduler snapshots, intervals, cancellation and deterministic clocks
- `/test/conn` — websocket chat and connection/channel lifecycle
- `/test/perm` — authorization checks, `<Can />`, HTTP handlers and client cache
- `/test/cach` — cache policies, scopes, tags and active entries
Index at `/test`. Index at `/test`.

@ -39,9 +39,15 @@ App.dispose();
| `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; `onError` is wired through `Logger.error('storage', ...)` |
| `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.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.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 |
| `App.Sess` | no | created lazily through `App.createActiveSession(...)`. Logger is injected automatically; storage, refresh/revoke handlers and HTTP hooks remain explicit so auth policy does not become hidden magic | | `App.Sess` | no | created lazily through `App.createActiveSession(...)`. Logger is injected automatically; storage, refresh/revoke handlers and HTTP hooks remain explicit so auth policy does not become hidden magic |
| `Connections` | no | created lazily through `App.createActiveConnections(...)`. App injects Logger, Timers and a structural session bridge; each connection opts into session behavior independently | | `Connections` | no | created lazily through `App.createActiveConnections(...)`. App injects Logger, Timers and a structural session bridge; each connection opts into session behavior independently |
Server-authoritative engines that have a browser reflector live under
`$svrs/*`: use `$svrs/perm` for `createEnginePermissions()` and HTTP handlers,
and `$svrs/cach` for `createEngineCache()` in services, repositories or server
load code. `aapp` composes only the active/client side.
`Sium` is **not** a member of App. Validation is page-scoped — pages with `Sium` is **not** a member of App. Validation is page-scoped — pages with
forms construct their own engine via the one-line `App.createSiumEngine()` forms construct their own engine via the one-line `App.createSiumEngine()`
method that wires `App.Lang` and `App.Logger` automatically: method that wires `App.Lang` and `App.Logger` automatically:
@ -115,9 +121,12 @@ option.
`load`, scope to the request via `App.Http.with({ fetch: event.fetch })`. `load`, scope to the request via `App.Http.with({ fetch: event.fetch })`.
8. **Timers** — built with the shared `Logger`. This is the App-owned 8. **Timers** — built with the shared `Logger`. This is the App-owned
scheduler used by long-lived runtime tasks; no module-global singleton. scheduler used by long-lived runtime tasks; no module-global singleton.
9. **Sess** — created lazily via `App.createActiveSession(...)`, not from 9. **Cache** — built with the shared `Logger`. It is always present with a
`createActiveApp(...)` options. App injects Logger, but the consumer keeps memory adapter unless `cache.adapter` is configured. Scopes remain explicit
auth policy explicit (`storage`, `onRefresh`, `onRevoke`, HTTP hooks). through each query and can use `cache.scopeResolver`.
10. **Sess** — created lazily via `App.createActiveSession(...)`, not from
`createActiveApp(...)` options. App injects Logger, but the consumer keeps
auth policy explicit (`storage`, `onRefresh`, `onRevoke`, HTTP hooks).
`dispose()` runs in reverse order. `dispose()` runs in reverse order.
@ -451,6 +460,7 @@ interface ActiveAppOptions<S extends LangNode> {
storage?: ActiveAppStorageOptions; storage?: ActiveAppStorageOptions;
http?: Omit<EngineHttpOptions, 'logger'>; http?: Omit<EngineHttpOptions, 'logger'>;
timers?: Omit<EngineTimersOptions, 'logger'>; timers?: Omit<EngineTimersOptions, 'logger'>;
cache?: Omit<ActiveCacheOptions, 'logger'>;
connections?: Omit<ActiveConnectionsOptions, 'logger' | 'timers' | 'session'>; connections?: Omit<ActiveConnectionsOptions, 'logger' | 'timers' | 'session'>;
} }
@ -463,6 +473,7 @@ interface ActiveApp<S extends LangNode = LangNode> {
readonly Storage: ActiveStorage; readonly Storage: ActiveStorage;
readonly Http: EngineHttp; readonly Http: EngineHttp;
readonly Timers: ActiveTimers; readonly Timers: ActiveTimers;
readonly Cache: ActiveCache;
readonly Sess: ActiveSession<unknown, unknown, unknown> | undefined; readonly Sess: ActiveSession<unknown, unknown, unknown> | undefined;
getLocale(): SupportedLocale; getLocale(): SupportedLocale;

@ -1,4 +1,5 @@
import { createActiveDom } from '$adom'; import { createActiveDom } from '$adom';
import { createActiveCache } from '$cach';
import { createActiveConnections as createActiveConnectionsRegistry } from '$conn'; import { createActiveConnections as createActiveConnectionsRegistry } from '$conn';
import type { ActiveConnections, ActiveConnectionsOptions, ConnectionMap } from '$conn'; import type { ActiveConnections, ActiveConnectionsOptions, ConnectionMap } from '$conn';
import { createActiveFrontend } from '$fend'; import { createActiveFrontend } from '$fend';
@ -9,7 +10,11 @@ import { createActiveLang } from '$lang/active-lang.svelte';
import { createActiveMonoLang } from '$lang/mono-lang.svelte'; import { createActiveMonoLang } from '$lang/mono-lang.svelte';
import { createEngineLogger } from '$logr'; import { createEngineLogger } from '$logr';
import { createActivePermissions as createActivePermissionsClient } from '$perm'; import { createActivePermissions as createActivePermissionsClient } from '$perm';
import type { ActivePermissions, ActivePermissionsOptions } from '$perm'; import {
PermInvalidEndpointError,
type ActivePermissions,
type ActivePermissionsOptions
} from '$perm';
import { import {
createActiveSession, createActiveSession,
SessAlreadyCreatedError, SessAlreadyCreatedError,
@ -23,7 +28,6 @@ import {
type StorageErrorContext type StorageErrorContext
} from '$stor'; } from '$stor';
import { createActiveTimers } from '$timr'; import { createActiveTimers } from '$timr';
import { SvelteSet } from 'svelte/reactivity';
import { import {
applyFrontendPreferenceSnapshot, applyFrontendPreferenceSnapshot,
@ -33,6 +37,7 @@ import {
import { import {
APP_ERROR_ALREADY_CREATED_PERMISSIONS, APP_ERROR_ALREADY_CREATED_PERMISSIONS,
APP_ERROR_ALREADY_CREATED_SESSION, APP_ERROR_ALREADY_CREATED_SESSION,
APP_ERROR_CREATE_PERMISSIONS_ENDPOINT_REQUIRED,
APP_STORAGE_ERROR_MESSAGE APP_STORAGE_ERROR_MESSAGE
} from './consts.ts'; } from './consts.ts';
import type { ActiveApp, ActiveAppOptions } from './types.ts'; import type { ActiveApp, ActiveAppOptions } from './types.ts';
@ -103,12 +108,19 @@ export function createActiveApp<S extends LangNode = LangNode>(
logger: Logger logger: Logger
}); });
const Cache = createActiveCache({
...options.cache,
logger: Logger
});
let disposed = false; let disposed = false;
let Sess: ActiveSession<unknown, unknown, unknown> | undefined; let Sess: ActiveSession<unknown, unknown, unknown> | undefined;
let Permissions: ActivePermissions | undefined; let Permissions: ActivePermissions | undefined;
let detachSessionBridge: (() => void) | undefined; let detachSessionBridge: (() => void) | undefined;
const sessionBridgeListeners = new SvelteSet<(change: { readonly event: string }) => void>(); // eslint-disable-next-line svelte/prefer-svelte-reactivity -- fan-out registries are not rendered state.
const connectionRegistries = new SvelteSet<ActiveConnections>(); const sessionBridgeListeners = new Set<(change: { readonly event: string }) => void>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- disposal registry is not rendered state.
const connectionRegistries = new Set<ActiveConnections>();
const sessionBridge = { const sessionBridge = {
onChange(listener: (change: { readonly event: string }) => void) { onChange(listener: (change: { readonly event: string }) => void) {
@ -135,6 +147,7 @@ export function createActiveApp<S extends LangNode = LangNode>(
Storage, Storage,
Http, Http,
Timers, Timers,
Cache,
get Sess() { get Sess() {
return Sess; return Sess;
@ -195,9 +208,12 @@ export function createActiveApp<S extends LangNode = LangNode>(
...options.permissions, ...options.permissions,
...permissionOptions ...permissionOptions
}; };
if (!merged.endpoint) {
throw new PermInvalidEndpointError(APP_ERROR_CREATE_PERMISSIONS_ENDPOINT_REQUIRED);
}
const built = createActivePermissionsClient({ const built = createActivePermissionsClient({
...merged, ...merged,
endpoint: merged.endpoint ?? '', endpoint: merged.endpoint,
logger: Logger, logger: Logger,
http: Http http: Http
}); });
@ -217,6 +233,7 @@ export function createActiveApp<S extends LangNode = LangNode>(
sessionBridgeListeners.clear(); sessionBridgeListeners.clear();
Sess?.dispose(); Sess?.dispose();
Sess = undefined; Sess = undefined;
Cache.dispose();
Timers.dispose(); Timers.dispose();
teardownPersistence(); teardownPersistence();
Frontend.dispose(); Frontend.dispose();

@ -8,3 +8,6 @@ export const APP_ERROR_ALREADY_CREATED_SESSION =
export const APP_ERROR_ALREADY_CREATED_PERMISSIONS = export const APP_ERROR_ALREADY_CREATED_PERMISSIONS =
'[aapp] App.createActivePermissions() called twice — only one permissions client per App.'; '[aapp] App.createActivePermissions() called twice — only one permissions client per App.';
export const APP_ERROR_CREATE_PERMISSIONS_ENDPOINT_REQUIRED =
'[aapp] App.createActivePermissions() requires endpoint via options.permissions or argument.';

@ -1,4 +1,5 @@
import type { ActiveDom, ActiveDomProps } from '$adom'; import type { ActiveDom, ActiveDomProps } from '$adom';
import type { ActiveCache, ActiveCacheOptions } from '$cach';
import type { ActiveConnections, ActiveConnectionsOptions, ConnectionMap } from '$conn'; import type { ActiveConnections, ActiveConnectionsOptions, ConnectionMap } from '$conn';
import type { ActiveFrontend, ActiveFrontendOptions } from '$fend'; import type { ActiveFrontend, ActiveFrontendOptions } from '$fend';
import type { FrontendPreferenceKey } from '$fend'; import type { FrontendPreferenceKey } from '$fend';
@ -111,6 +112,13 @@ export interface ActiveAppOptions<S extends LangNode = LangNode> {
* resolution flow through the framework. * resolution flow through the framework.
*/ */
http?: Omit<EngineHttpOptions, 'logger'>; http?: Omit<EngineHttpOptions, 'logger'>;
/**
* App-scoped data cache. When omitted, App still exposes `App.Cache`
* backed by an in-memory adapter. Pass a custom adapter/policies/scope
* resolver when data should persist, share across layers or segment by
* tenant/actor/permission.
*/
cache?: Omit<ActiveCacheOptions, 'logger'>;
/** /**
* Defaults for `App.createActiveConnections()`. App injects Logger, * Defaults for `App.createActiveConnections()`. App injects Logger,
* Timers and the session bridge automatically. * Timers and the session bridge automatically.
@ -149,6 +157,7 @@ export interface ActiveApp<S extends LangNode = LangNode> {
readonly Storage: ActiveStorage; readonly Storage: ActiveStorage;
readonly Http: EngineHttp; readonly Http: EngineHttp;
readonly Timers: ActiveTimers; readonly Timers: ActiveTimers;
readonly Cache: ActiveCache;
/** Active locale. Sourced from Lang (real or mono). */ /** Active locale. Sourced from Lang (real or mono). */
getLocale: () => SupportedLocale; getLocale: () => SupportedLocale;
@ -223,8 +232,8 @@ export interface ActiveApp<S extends LangNode = LangNode> {
/** /**
* Build the App-scoped reactive permissions client. The authoritative * Build the App-scoped reactive permissions client. The authoritative
* runtime is `createEnginePermissions()` on the server; this client is * runtime is `createEnginePermissions()` from `$svrs/perm`; this client
* only for UI/UX reflection, snapshots and cache. * is only for UI/UX reflection, snapshots and cache.
*/ */
createActivePermissions( createActivePermissions(
options?: Partial<Omit<ActivePermissionsOptions, 'logger' | 'http'>> options?: Partial<Omit<ActivePermissionsOptions, 'logger' | 'http'>>

@ -0,0 +1,363 @@
# cach
`arts/cach` es la capa de cache de datos del framework. No es un `Map` con TTL:
es un motor de coherencia para decidir si un dato se puede servir, si está fresco,
si debe revalidarse, bajo qué scope de seguridad vive y qué invalidaciones lo
afectan.
El patrón sigue el resto de artefactos:
```ts
import { createEngineCache } from '$svrs/cach';
import { createActiveCache } from '$cach';
```
- `createEngineCache()` vive en `$svrs/cach` y es la API imperativa para server, servicios, repositorios, workers y tests.
- `createActiveCache()` vive en `$cach` y añade estado reactivo para Svelte.
- `App.Cache` está siempre presente cuando usas `createActiveApp()`.
- El core puro vive en `$libs/cach` como `createCacheRuntime()`.
## Qué Resuelve
`cach` responde a preguntas que una cache simple no contesta:
- Si el dato existe, si está `fresh`, `stale`, `expired` o invalidado.
- Si puede servirse stale mientras se refresca en background.
- Si puede servirse stale cuando el origen falla.
- Si pertenece a scope `public`, `tenant`, `actor`, `permission` o `custom`.
- Si un cambio de tag o key prefix invalidó la entrada.
- Si hay otra petición idéntica en vuelo y debe deduplicarse.
- Por qué tomó una decisión, vía `explain()`.
## Uso Mínimo
```ts
import { createEngineCache, memoryCacheAdapter, CACHE_POLICY_INTERACTIVE } from '$svrs/cach';
const Cache = createEngineCache({
adapter: memoryCacheAdapter(),
defaultPolicy: CACHE_POLICY_INTERACTIVE
});
const project = await Cache.query({
key: ['project', projectId],
scope: 'public',
tags: [{ type: 'project', id: projectId }],
fetcher: () => ProjectRepo.findById(projectId)
});
```
La segunda lectura con la misma key/scope/policy servirá el valor cacheado si sigue válido.
## App.Cache
`createActiveApp()` crea `App.Cache` automáticamente con un adapter memory por defecto:
```ts
const App = createActiveApp();
const value = await App.Cache.query({
key: ['settings'],
scope: 'public',
fetcher: loadSettings
});
```
Para apps reales, configura políticas, adapter o scope resolver:
```ts
const App = createActiveApp({
cache: {
scopeResolver: () => ({
tenantId: App.Sess?.current?.data?.tenantId,
actorId: App.Sess?.current?.user?.id,
permissionHash: App.Permissions?.currentSnapshot.version,
locale: App.getLocale()
})
}
});
```
Si usas `scope: 'actor'` o `scope: 'permission'`, el resolver debe aportar los valores
necesarios. Si faltan, el motor falla en vez de mezclar datos de usuarios.
## Keys
Las keys son arrays deterministas:
```ts
['posts', { page: 1, filters: { status: 'published' } }][('tenant', tenantId, 'projects')][
'profile'
];
```
El normalizador:
- Ordena keys de objetos.
- Omite campos `undefined` en objetos.
- Soporta `Date`, `Map`, `Set`, `URLSearchParams` y `BigInt`.
- Rechaza funciones, símbolos, números no finitos y referencias circulares.
## Scopes
Los scopes evitan fugas de datos:
```ts
scope: 'public' // compartible
scope: 'tenant' // requiere tenantId
scope: 'actor' // requiere actorId; tenantId opcional
scope: 'permission' // requiere actorId + permissionHash
scope: { mode: 'custom', values: { tenantId, reportId } }
```
Regla de calidad: datos privados nunca deberían cachearse como `public`.
## Policies
Las políticas expresan intención:
```ts
import {
CACHE_POLICY_INTERACTIVE,
CACHE_POLICY_CATALOG,
CACHE_POLICY_PRIVATE_SESSION
} from '$svrs/cach';
```
Incluidas:
- `interactive`: UI normal, `stale-while-revalidate`.
- `catalog`: datos estables, ventanas largas.
- `privateSession`: datos privados, persistencia desactivada por defecto.
- `realtime`: casi sin cache.
- `immutable`: datos versionados o inmutables.
Puedes definir las tuyas:
```ts
import { createEngineCache } from '$svrs/cach';
const Cache = createEngineCache({
policies: {
dashboard: {
freshFor: '15s',
staleFor: '2m',
staleIfErrorFor: '10m',
gcAfter: '30m',
mode: CACHE_READ_MODE_STALE_WHILE_REVALIDATE,
persist: true
}
}
});
```
## Modos
```ts
CACHE_READ_MODE_CACHE_FIRST;
CACHE_READ_MODE_STALE_WHILE_REVALIDATE;
CACHE_READ_MODE_MUST_REVALIDATE;
CACHE_READ_MODE_BYPASS_CACHE;
CACHE_READ_MODE_NO_STORE;
```
- `cache-first`: sirve caché mientras esté fresh.
- `stale-while-revalidate`: sirve stale dentro de ventana y refresca en background.
- `must-revalidate`: si no está fresh, bloquea y va al origen.
- `bypass-cache`: ignora lectura, llama al origen y escribe resultado.
- `no-store`: ignora lectura, llama al origen y no escribe.
## Tags Y Epochs
Las entradas pueden declarar tags:
```ts
tags: [
{ type: 'project', id: projectId },
{ type: 'project', id: 'LIST' }
];
```
Invalidar un tag no escanea ni borra todas las keys. Incrementa un epoch:
```ts
await Cache.invalidate({
tag: { type: 'project', id: projectId },
scope: 'tenant'
});
```
Cada entry guarda los epochs con los que fue escrita. En la siguiente lectura,
si el epoch actual no coincide, la entry queda invalidada.
También hay invalidación por key exacta y key prefix:
```ts
await Cache.invalidate({ key: ['project', projectId], scope: 'tenant' });
await Cache.invalidate({ keyPrefix: ['projects'], scope: 'tenant' });
```
## ActiveCache
`createActiveCache()` expone la misma API que el engine y añade estado reactivo:
```ts
const Cache = createActiveCache();
const entry = Cache.entry({
key: ['dashboard'],
scope: 'public',
fetcher: loadDashboard
});
await entry.load();
entry.data;
entry.status;
entry.error;
entry.loading;
```
`ActiveCacheEntry` no hace magia en efectos. Cargas explícitamente con `load()` o
`refresh()`, y el estado cambia sin bucles reactivos.
`entry.set(value)` reutiliza por defecto la `policy`, `tags`, `schemaVersion`,
`persist` y `scope` definidos en la entry, y permite sobrescribirlos por llamada.
## Mutate
La mutación v1 es conservadora:
```ts
await Cache.mutate({
commit: () => App.Http.patch(`/projects/${projectId}`, { body: patch }),
invalidate: [{ tag: { type: 'project', id: 'LIST' }, scope: 'tenant' }],
update: [
{
key: ['project', projectId],
scope: 'tenant',
reducer: (project) => ({ ...project, ...patch })
}
]
});
```
Semántica:
1. Ejecuta `commit()`.
2. Si falla, no toca la cache.
3. Si funciona, invalida tags/prefix/keys.
4. Aplica updates exactos.
Optimistic journal queda fuera de v1.
## Explain
`explain()` existe desde v1 porque cache sin introspección se vuelve opaca:
```ts
const info = await Cache.explain(['project', projectId], {
scope: 'tenant',
schemaVersion: 'Project:v1'
});
```
Devuelve adapter, key efectiva, estado, decisión, razón, schema y epochs.
## Adapters
### Memory
```ts
const adapter = memoryCacheAdapter({
maxEntries: 5000,
maxSizeBytes: 64 * 1024 * 1024
});
```
Soporta TTL, LRU aproximado, epochs e introspección.
### Storage
```ts
const adapter = storageCacheAdapter({
storage: App.Storage.adapter,
namespace: 'cach'
});
```
Útil para persistencia cliente. Respeta `persist: false`, por lo que una policy
privada puede impedir escritura persistente.
El adapter mantiene un índice interno para que `Cache.clear()` pueda borrar tanto
entries como epochs persistidos; esto es importante en logout/wipe.
Redis, tiered cache, browser Cache API y adapters edge quedan para fases posteriores.
El contrato está preparado para que Redis se integre sin dependencia dura.
## Eventos Y Logger
El engine emite eventos:
```ts
Cache.on(CACHE_EVENT_ALL, (event) => {
console.log(event.type, event.reason);
});
```
`arts/cach` enruta esos eventos al `EngineLogger` inyectado:
- Hits/misses/sets/invalidation en `debug`.
- Errores de adapter, refresh y stale-if-error en `warn`.
Las categorías y mensajes viven en `consts.ts`, no como strings dispersos.
## Seguridad
Defaults y recomendaciones:
- No uses `public` para datos con `Authorization`, sesión o permisos.
- Usa `tenant`, `actor` o `permission` para datos privados.
- `privateSession` no persiste por defecto.
- Cambios de permisos deben invalidar scope `permission` o cambiar `permissionHash`.
- Logout debería llamar a `Cache.clear()` o invalidar scopes privados.
- El cliente cachea para UX, no para seguridad. Las decisiones autoritativas viven en servidor.
## Página De Prueba
La demo interactiva está en:
```txt
/test/cach
```
Muestra `App.Cache`, entry reactiva, invalidación por tag, scope actor/tenant,
eventos y `explain()`.
## Roadmap
v1 incluido:
- `createEngineCache()`
- `createActiveCache()`
- `memoryCacheAdapter()`
- `storageCacheAdapter()`
- key normalization
- scopes
- policies
- stale-while-revalidate
- stale-if-error
- singleflight
- tag/prefix epochs
- mutate conservador
- explain
- logger integration
- `App.Cache`
Siguiente:
- `tieredCacheAdapter()`
- adapter Redis por interfaz, sin dependencia dura
- integración HTTP explícita
- invalidación por sesión/permisos
- optimistic journal
- browser Cache API para `Request/Response`

@ -0,0 +1,268 @@
import { untrack } from 'svelte';
import { CACHE_EVENT_ALL, type CacheClock, type CacheEvent } from '$libs/cach';
import { createEngineCache } from '$svrs/cach';
import {
CACHE_ACTIVE_ENTRY_EVENT_INVALIDATE,
CACHE_ACTIVE_ENTRY_EVENT_LOAD,
CACHE_ACTIVE_ENTRY_EVENT_REFRESH,
CACHE_ACTIVE_ENTRY_EVENT_SET,
CACHE_ACTIVE_STATUS_ERROR,
CACHE_ACTIVE_STATUS_IDLE,
CACHE_ACTIVE_STATUS_LOADING,
CACHE_ACTIVE_STATUS_REFRESHING,
CACHE_ACTIVE_STATUS_STALE,
CACHE_ACTIVE_STATUS_SUCCESS,
CACHE_METHOD_CLEAR,
CACHE_METHOD_ENTRY,
CACHE_METHOD_EXPLAIN,
CACHE_METHOD_GET,
CACHE_METHOD_INVALIDATE,
CACHE_METHOD_MUTATE,
CACHE_METHOD_ON,
CACHE_METHOD_QUERY,
CACHE_METHOD_SET,
CACHE_METHOD_STATS
} from './consts.ts';
import { CachActiveEntryDisposedError, CachDisposedError } from './errors.ts';
import { disposedCacheMessage } from './helpers.ts';
import type {
ActiveCache,
ActiveCacheEntry,
ActiveCacheEntryEvent,
ActiveCacheEntryListener,
ActiveCacheEntryOptions,
ActiveCacheEntrySnapshot,
ActiveCacheEntryStatus,
ActiveCacheOptions
} from './types.ts';
export function createActiveCache(options: ActiveCacheOptions = {}): ActiveCache {
const engine = createEngineCache(options);
const clock = options.clock ?? systemClock;
let disposed = false;
let lastEventCell = $state<CacheEvent | null>(null);
let eventCountCell = $state(0);
let loadingCount = $state(0);
let lastErrorCell = $state<unknown | null>(null);
const offEvents = engine.on(CACHE_EVENT_ALL, (event) => {
lastEventCell = event;
eventCountCell = untrack(() => eventCountCell) + 1;
});
function updateLoading(delta: number): void {
loadingCount = Math.max(0, untrack(() => loadingCount) + delta);
}
async function track<T>(task: () => Promise<T>): Promise<T> {
updateLoading(1);
try {
const value = await task();
lastErrorCell = null;
return value;
} catch (error) {
lastErrorCell = error;
throw error;
} finally {
updateLoading(-1);
}
}
function entry<T>(entryOptions: ActiveCacheEntryOptions<T>): ActiveCacheEntry<T> {
ensureLive(CACHE_METHOD_ENTRY);
return createActiveCacheEntry(entryOptions, clock, {
query: (queryOptions) => track(() => engine.query(queryOptions)),
set: (key, value, setOptions) => track(() => engine.set(key, value, setOptions)),
invalidate: (invalidateOptions) => track(() => engine.invalidate(invalidateOptions))
});
}
function ensureLive(method: string): void {
if (disposed) throw new CachDisposedError(disposedCacheMessage(method));
}
return {
get lastEvent() {
return lastEventCell;
},
get eventCount() {
return eventCountCell;
},
get loading() {
return loadingCount > 0;
},
get lastError() {
return lastErrorCell;
},
query(queryOptions) {
ensureLive(CACHE_METHOD_QUERY);
return track(() => engine.query(queryOptions));
},
get(key, getOptions) {
ensureLive(CACHE_METHOD_GET);
return track(() => engine.get(key, getOptions));
},
set(key, value, setOptions) {
ensureLive(CACHE_METHOD_SET);
return track(() => engine.set(key, value, setOptions));
},
invalidate(invalidateOptions) {
ensureLive(CACHE_METHOD_INVALIDATE);
return track(() => engine.invalidate(invalidateOptions));
},
mutate(mutateOptions) {
ensureLive(CACHE_METHOD_MUTATE);
return track(() => engine.mutate(mutateOptions));
},
explain(key, explainOptions) {
ensureLive(CACHE_METHOD_EXPLAIN);
return track(() => engine.explain(key, explainOptions));
},
stats() {
ensureLive(CACHE_METHOD_STATS);
return engine.stats();
},
on(type, handler) {
ensureLive(CACHE_METHOD_ON);
return engine.on(type, handler);
},
clear() {
ensureLive(CACHE_METHOD_CLEAR);
return track(() => engine.clear());
},
entry,
dispose() {
if (disposed) return;
disposed = true;
offEvents();
engine.dispose();
}
};
}
function createActiveCacheEntry<T>(
options: ActiveCacheEntryOptions<T>,
clock: CacheClock,
engine: Pick<ActiveCache, 'query' | 'set' | 'invalidate'>
): ActiveCacheEntry<T> {
let dataCell = $state<T | undefined>(undefined);
let errorCell = $state<unknown | null>(null);
let statusCell = $state<ActiveCacheEntryStatus>(CACHE_ACTIVE_STATUS_IDLE);
let updatedAtCell = $state<number | null>(null);
let eventCell = $state<ActiveCacheEntryEvent | null>(null);
let disposed = false;
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- listeners are notified manually, not rendered.
const listeners = new Set<ActiveCacheEntryListener<T>>();
function snapshot(): ActiveCacheEntrySnapshot<T> {
return {
key: options.key,
data: dataCell,
error: errorCell,
status: statusCell,
updatedAt: updatedAtCell,
event: eventCell
};
}
function notify(): void {
const current = snapshot();
for (const listener of [...listeners]) listener(current);
}
function assertActive(): void {
if (disposed) {
throw new CachActiveEntryDisposedError();
}
}
async function runLoad(event: ActiveCacheEntryEvent): Promise<T> {
assertActive();
eventCell = event;
statusCell =
dataCell === undefined ? CACHE_ACTIVE_STATUS_LOADING : CACHE_ACTIVE_STATUS_REFRESHING;
errorCell = null;
notify();
try {
const value = await engine.query(options);
dataCell = value;
updatedAtCell = clock.now();
statusCell = CACHE_ACTIVE_STATUS_SUCCESS;
notify();
return value;
} catch (error) {
errorCell = error;
statusCell = CACHE_ACTIVE_STATUS_ERROR;
notify();
throw error;
}
}
return {
get key() {
return options.key;
},
get data() {
return dataCell;
},
get error() {
return errorCell;
},
get status() {
return statusCell;
},
get loading() {
return (
statusCell === CACHE_ACTIVE_STATUS_LOADING || statusCell === CACHE_ACTIVE_STATUS_REFRESHING
);
},
get updatedAt() {
return updatedAtCell;
},
load: () => runLoad(CACHE_ACTIVE_ENTRY_EVENT_LOAD),
refresh: () => runLoad(CACHE_ACTIVE_ENTRY_EVENT_REFRESH),
async set(value, setOptions = {}) {
assertActive();
eventCell = CACHE_ACTIVE_ENTRY_EVENT_SET;
await engine.set(options.key, value, {
policy: options.policy,
mode: options.mode,
tags: options.tags,
schemaVersion: options.schemaVersion,
persist: options.persist,
...setOptions,
scope: options.scope
});
dataCell = value;
errorCell = null;
statusCell = CACHE_ACTIVE_STATUS_SUCCESS;
updatedAtCell = clock.now();
notify();
},
async invalidate() {
assertActive();
eventCell = CACHE_ACTIVE_ENTRY_EVENT_INVALIDATE;
await engine.invalidate({ key: options.key, scope: options.scope });
statusCell = dataCell === undefined ? CACHE_ACTIVE_STATUS_IDLE : CACHE_ACTIVE_STATUS_STALE;
notify();
},
snapshot,
onChange(listener) {
listeners.add(listener);
listener(snapshot());
return () => {
listeners.delete(listener);
};
},
dispose() {
disposed = true;
listeners.clear();
}
};
}
const systemClock: CacheClock = {
now: () => Date.now()
};

@ -0,0 +1,66 @@
export {
CACHE_ERROR_MSG_DISPOSED_SUFFIX,
CACHE_ERROR_NAME_DISPOSED,
CACHE_LOG_MESSAGE_ADAPTER_ERROR,
CACHE_LOG_MESSAGE_BY_EVENT,
CACHE_LOG_MESSAGE_DELETE,
CACHE_LOG_MESSAGE_EVICTION,
CACHE_LOG_MESSAGE_HIT,
CACHE_LOG_MESSAGE_INVALIDATE,
CACHE_LOG_MESSAGE_MISS,
CACHE_LOG_MESSAGE_REFRESH_ERROR,
CACHE_LOG_MESSAGE_REFRESH_START,
CACHE_LOG_MESSAGE_REFRESH_SUCCESS,
CACHE_LOG_MESSAGE_SCHEMA_MISMATCH,
CACHE_LOG_MESSAGE_SCOPE_ERROR,
CACHE_LOG_MESSAGE_SET,
CACHE_LOG_MESSAGE_SINGLEFLIGHT_JOIN,
CACHE_LOG_MESSAGE_STALE_HIT,
CACHE_LOG_MESSAGE_STALE_IF_ERROR,
CACHE_METHOD_CLEAR,
CACHE_METHOD_EXPLAIN,
CACHE_METHOD_GET,
CACHE_METHOD_INVALIDATE,
CACHE_METHOD_MUTATE,
CACHE_METHOD_ON,
CACHE_METHOD_QUERY,
CACHE_METHOD_SET,
CACHE_METHOD_STATS,
LOGGER_CATEGORY
} from '$svrs/cach';
export const CACHE_ACTIVE_STATUS_IDLE = 'idle';
export const CACHE_ACTIVE_STATUS_LOADING = 'loading';
export const CACHE_ACTIVE_STATUS_SUCCESS = 'success';
export const CACHE_ACTIVE_STATUS_STALE = 'stale';
export const CACHE_ACTIVE_STATUS_REFRESHING = 'refreshing';
export const CACHE_ACTIVE_STATUS_ERROR = 'error';
export const CACHE_ACTIVE_STATUS_DEGRADED = 'degraded';
export const CACHE_ACTIVE_STATUSES = [
CACHE_ACTIVE_STATUS_IDLE,
CACHE_ACTIVE_STATUS_LOADING,
CACHE_ACTIVE_STATUS_SUCCESS,
CACHE_ACTIVE_STATUS_STALE,
CACHE_ACTIVE_STATUS_REFRESHING,
CACHE_ACTIVE_STATUS_ERROR,
CACHE_ACTIVE_STATUS_DEGRADED
] as const;
export const CACHE_ACTIVE_ENTRY_EVENT_LOAD = 'load';
export const CACHE_ACTIVE_ENTRY_EVENT_REFRESH = 'refresh';
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_ERROR_MSG_ACTIVE_ENTRY_DISPOSED =
'[cach] ActiveCacheEntry used after dispose().';
export const CACHE_METHOD_ENTRY = 'entry';
export const CACHE_ACTIVE_ENTRY_EVENTS = [
CACHE_ACTIVE_ENTRY_EVENT_LOAD,
CACHE_ACTIVE_ENTRY_EVENT_REFRESH,
CACHE_ACTIVE_ENTRY_EVENT_INVALIDATE,
CACHE_ACTIVE_ENTRY_EVENT_SET
] as const;

@ -0,0 +1,20 @@
import {
CACHE_ERROR_MSG_ACTIVE_ENTRY_DISPOSED,
CACHE_ERROR_NAME_ACTIVE_ENTRY_DISPOSED
} from './consts.ts';
export { CachDisposedError, isCachDisposedError } from '$svrs/cach';
export class CachActiveEntryDisposedError extends Error {
override readonly name = CACHE_ERROR_NAME_ACTIVE_ENTRY_DISPOSED;
constructor(message = CACHE_ERROR_MSG_ACTIVE_ENTRY_DISPOSED) {
super(message);
}
}
export function isCachActiveEntryDisposedError(
error: unknown
): error is CachActiveEntryDisposedError {
return error instanceof CachActiveEntryDisposedError;
}

@ -0,0 +1 @@
export { disposedCacheMessage } from '$svrs/cach';

@ -0,0 +1,133 @@
export { createActiveCache } from './active-cache.svelte.ts';
export * from './consts.ts';
export * from './errors.ts';
export * from './helpers.ts';
export type {
ActiveCache,
ActiveCacheEntry,
ActiveCacheEntryEvent,
ActiveCacheEntryListener,
ActiveCacheEntryOptions,
ActiveCacheEntrySnapshot,
ActiveCacheEntryStatus,
ActiveCacheOptions,
CacheLogger,
EngineCache,
EngineCacheOptions
} from './types.ts';
export {
CACHE_ADAPTER_MEMORY,
CACHE_ADAPTER_STORAGE,
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_ENVELOPE_STATE_DEGRADED,
CACHE_ENVELOPE_STATE_EXPIRED,
CACHE_ENVELOPE_STATE_FRESH,
CACHE_ENVELOPE_STATE_INVALIDATED,
CACHE_ENVELOPE_STATE_STALE,
CACHE_EVENT_ADAPTER_ERROR,
CACHE_EVENT_ALL,
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_EVENT_EVICTION,
CACHE_POLICY_CATALOG,
CACHE_POLICY_IMMUTABLE,
CACHE_POLICY_INTERACTIVE,
CACHE_POLICY_PRIVATE_SESSION,
CACHE_POLICY_REALTIME,
CACHE_READ_MODE_BYPASS_CACHE,
CACHE_READ_MODE_CACHE_FIRST,
CACHE_READ_MODE_MUST_REVALIDATE,
CACHE_READ_MODE_NO_STORE,
CACHE_READ_MODE_STALE_WHILE_REVALIDATE,
CACHE_SCOPE_ACTOR,
CACHE_SCOPE_CUSTOM,
CACHE_SCOPE_PERMISSION,
CACHE_SCOPE_PUBLIC,
CACHE_SCOPE_TENANT,
CacheKeyError,
CachePolicyError,
CacheScopeError,
createMapStorage,
defaultCachePolicies,
durationToMs,
isCacheKeyError,
isCachePolicyError,
isCacheScopeError,
memoryCacheAdapter,
normalizeKey,
normalizeKeyPrefixes,
normalizeTag,
normalizeTags,
resolveScope,
stableHash,
stableStringify,
storageCacheAdapter
} from '$svrs/cach';
export type {
CacheAdapter,
CacheAdapterSetOptions,
CacheClock,
CacheDecision,
CacheDecisionAction,
CacheDecisionReason,
CacheEnvelope,
CacheEnvelopeState,
CacheEvent,
CacheEventHandler,
CacheEventSelector,
CacheEventType,
CacheExplain,
CacheKey,
CachePolicy,
CacheReadMode,
CacheRuntime,
CacheRuntimeConfig,
CacheScopeInput,
CacheScopeMode,
CacheStats,
CacheTagLike,
CacheTagObject,
CustomCacheScope,
ExplainOptions,
GetOptions,
InvalidateOptions,
MemoryCacheAdapter,
MemoryCacheAdapterOptions,
MemoryCacheEvictReason,
MutateOptions,
QueryOptions,
ResolvedCachePolicy,
ResolvedCacheScope,
ResolvedScopeValues,
ScopeResolver,
SetOptions,
StorageCacheAdapterOptions,
StorageLike
} from '$svrs/cach';

@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import { createActiveApp } from '$aapp';
import {
CACHE_ACTIVE_STATUS_STALE,
CACHE_ACTIVE_STATUS_SUCCESS,
CACHE_POLICY_INTERACTIVE,
CACHE_SCOPE_PUBLIC,
createActiveCache,
isCachActiveEntryDisposedError,
memoryCacheAdapter,
type CacheClock
} from '../index.ts';
function testClock(initial = 0): CacheClock & { advance(ms: number): void } {
let now = initial;
return {
now: () => now,
advance(ms: number) {
now += ms;
}
};
}
describe('createActiveCache', () => {
it('creates reactive entries over the engine cache', async () => {
const clock = testClock();
const Cache = createActiveCache({
clock,
adapter: memoryCacheAdapter({ clock })
});
const entry = Cache.entry({
key: ['dashboard'],
scope: CACHE_SCOPE_PUBLIC,
fetcher: async () => ({ cards: 3 })
});
await entry.load();
expect(entry.data).toEqual({ cards: 3 });
expect(entry.status).toBe(CACHE_ACTIVE_STATUS_SUCCESS);
await entry.invalidate();
expect(entry.status).toBe(CACHE_ACTIVE_STATUS_STALE);
expect(Cache.eventCount).toBeGreaterThan(0);
Cache.dispose();
});
it('entry.set() inherits policy metadata from the entry options', async () => {
const clock = testClock();
const Cache = createActiveCache({
clock,
adapter: memoryCacheAdapter({ clock })
});
const entry = Cache.entry<number>({
key: ['project', 7],
scope: CACHE_SCOPE_PUBLIC,
policy: CACHE_POLICY_INTERACTIVE,
schemaVersion: 'Project:v1',
tags: [{ type: 'project', id: 7 }],
fetcher: async () => 1
});
await entry.set(2);
await expect(
Cache.get<number>(['project', 7], {
scope: CACHE_SCOPE_PUBLIC,
policy: CACHE_POLICY_INTERACTIVE,
schemaVersion: 'Project:v1'
})
).resolves.toBe(2);
Cache.dispose();
});
it('throws typed errors after active entry dispose()', async () => {
expect.assertions(1);
const Cache = createActiveCache();
const entry = Cache.entry({
key: ['entry-disposed'],
scope: CACHE_SCOPE_PUBLIC,
fetcher: async () => 'never'
});
entry.dispose();
try {
await entry.load();
} catch (error) {
expect(isCachActiveEntryDisposedError(error)).toBe(true);
}
Cache.dispose();
});
it('is always present on createActiveApp', () => {
const App = createActiveApp();
expect(App.Cache).toBeTruthy();
expect(App.Cache.stats().counters).toEqual({});
App.dispose();
});
});

@ -0,0 +1,50 @@
import type { CACHE_ACTIVE_ENTRY_EVENTS, CACHE_ACTIVE_STATUSES } from './consts.ts';
import type { CacheClock, CacheEvent, CacheKey, QueryOptions, SetOptions } from '$libs/cach';
import type { EngineCache, EngineCacheOptions } from '$svrs/cach';
export type { CacheLogger, EngineCache, EngineCacheOptions } from '$svrs/cach';
export type ActiveCacheEntryStatus = (typeof CACHE_ACTIVE_STATUSES)[number];
export type ActiveCacheEntryEvent = (typeof CACHE_ACTIVE_ENTRY_EVENTS)[number];
export interface ActiveCacheEntrySnapshot<T> {
readonly key: CacheKey;
readonly data: T | undefined;
readonly error: unknown | null;
readonly status: ActiveCacheEntryStatus;
readonly updatedAt: number | null;
readonly event: ActiveCacheEntryEvent | null;
}
export type ActiveCacheEntryListener<T> = (snapshot: ActiveCacheEntrySnapshot<T>) => void;
export interface ActiveCacheEntry<T> {
readonly key: CacheKey;
readonly data: T | undefined;
readonly error: unknown | null;
readonly status: ActiveCacheEntryStatus;
readonly loading: boolean;
readonly updatedAt: number | null;
load(): Promise<T>;
refresh(): Promise<T>;
set(value: T, options?: Omit<SetOptions, 'scope'>): Promise<void>;
invalidate(): Promise<void>;
snapshot(): ActiveCacheEntrySnapshot<T>;
onChange(listener: ActiveCacheEntryListener<T>): () => void;
dispose(): void;
}
export interface ActiveCacheEntryOptions<T> extends QueryOptions<T> {
clock?: CacheClock;
}
export type ActiveCacheOptions = EngineCacheOptions;
export interface ActiveCache extends EngineCache {
readonly lastEvent: CacheEvent | null;
readonly eventCount: number;
readonly loading: boolean;
readonly lastError: unknown | null;
entry<T>(options: ActiveCacheEntryOptions<T>): ActiveCacheEntry<T>;
}

@ -46,6 +46,11 @@ Connections.createConnection('main', {
}); });
``` ```
When `Timers` is not injected, `createEngineConnections()` creates a private
timer engine with the same logger. Those scheduler diagnostics are emitted
under the `timr` logger category; connection-specific logs stay scoped as
`conn:<name>`.
## Channels ## Channels
Channels are cached by name and route frames by `topic`. Channels are cached by name and route frames by `topic`.

@ -6,7 +6,6 @@ import {
CONNECTION_STATE_OPEN, CONNECTION_STATE_OPEN,
CONNECTION_STATE_RECONNECTING CONNECTION_STATE_RECONNECTING
} from './consts.ts'; } from './consts.ts';
import { SvelteMap } from 'svelte/reactivity';
import type { import type {
ActiveConnections, ActiveConnections,
ActiveConnectionsOptions, ActiveConnectionsOptions,
@ -21,7 +20,8 @@ export function createActiveConnections<TConnections extends ConnectionMap = Con
options: ActiveConnectionsOptions = {} options: ActiveConnectionsOptions = {}
): ActiveConnections<TConnections> { ): ActiveConnections<TConnections> {
const engine = createEngineConnections<TConnections>(options); const engine = createEngineConnections<TConnections>(options);
const detachers = new SvelteMap<string, () => void>(); // eslint-disable-next-line svelte/prefer-svelte-reactivity -- detach callbacks are bookkeeping, not rendered state.
const detachers = new Map<string, () => void>();
let activeNames = $state<readonly string[]>(engine.names()); let activeNames = $state<readonly string[]>(engine.names());
let states = $state<Readonly<Record<string, ConnectionState>>>({}); let states = $state<Readonly<Record<string, ConnectionState>>>({});

@ -1,6 +1,5 @@
import { import {
CONNECTION_CHANNEL_JOIN_REASON_CLOSED, CONNECTION_CHANNEL_JOIN_REASON_CLOSED,
CONNECTION_CHANNEL_JOIN_REASON_REJECTED,
CONNECTION_CHANNEL_JOIN_REASON_TRANSPORT_ERROR, CONNECTION_CHANNEL_JOIN_REASON_TRANSPORT_ERROR,
CONNECTION_CHANNEL_STATE_FAILED, CONNECTION_CHANNEL_STATE_FAILED,
CONNECTION_CHANNEL_STATE_IDLE, CONNECTION_CHANNEL_STATE_IDLE,
@ -47,10 +46,7 @@ function mapSendToJoinResult(result: ConnectionSendResult): ConnectionChannelJoi
} }
return { return {
ok: false, ok: false,
reason: reason: CONNECTION_CHANNEL_JOIN_REASON_TRANSPORT_ERROR,
result.error === undefined
? CONNECTION_CHANNEL_JOIN_REASON_REJECTED
: CONNECTION_CHANNEL_JOIN_REASON_TRANSPORT_ERROR,
error: result.error error: result.error
}; };
} }
@ -167,6 +163,8 @@ export function createConnectionChannel<TEvents extends ConnectionEventMap = Con
return channel.join(); return channel.join();
}, },
dispose() { dispose() {
desiredJoined = false;
setState(CONNECTION_CHANNEL_STATE_LEFT);
disposed = true; disposed = true;
anyListeners.clear(); anyListeners.clear();
typeListeners.clear(); typeListeners.clear();

@ -76,11 +76,9 @@ import {
TIMER_KEY_ACK, TIMER_KEY_ACK,
TIMER_KEY_HEARTBEAT, TIMER_KEY_HEARTBEAT,
TIMER_KEY_HEARTBEAT_TIMEOUT, TIMER_KEY_HEARTBEAT_TIMEOUT,
TIMER_KEY_RECONNECT, TIMER_KEY_RECONNECT
loggerScope,
listenerThrewMessage,
timerKey
} from './consts.ts'; } from './consts.ts';
import { listenerThrewMessage, loggerScope, timerKey } from './helpers.ts';
import { assertConnectionFrame, createFrame } from './serializer.ts'; import { assertConnectionFrame, createFrame } from './serializer.ts';
import { jsonConnectionSerializer } from './serializers/json.ts'; import { jsonConnectionSerializer } from './serializers/json.ts';
import type { import type {
@ -172,7 +170,7 @@ export function createConnection<TChannels extends ConnectionChannelMap = Connec
let connectPromise: Promise<ConnectionConnectResult> | null = null; let connectPromise: Promise<ConnectionConnectResult> | null = null;
function now(): number { function now(): number {
return Date.now(); return runtime.timers.clock.now();
} }
function logDebug(message: string, meta?: unknown): void { function logDebug(message: string, meta?: unknown): void {
@ -324,6 +322,13 @@ export function createConnection<TChannels extends ConnectionChannelMap = Connec
runtime.timers.schedule(timerKey(name, kind, id), delayMs, task, { replace: true }); 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
});
}
function shouldReconnect(): boolean { function shouldReconnect(): boolean {
if (disposed || intentionalClose) return false; if (disposed || intentionalClose) return false;
if (reconnectDisabled) return false; if (reconnectDisabled) return false;
@ -355,14 +360,9 @@ export function createConnection<TChannels extends ConnectionChannelMap = Connec
if (heartbeatOptions === false) return; if (heartbeatOptions === false) return;
if ((heartbeatOptions?.enabled ?? DEFAULT_HEARTBEAT_ENABLED) === false) return; if ((heartbeatOptions?.enabled ?? DEFAULT_HEARTBEAT_ENABLED) === false) return;
const intervalMs = heartbeatOptions?.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS; const intervalMs = heartbeatOptions?.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
runtime.timers.interval( scheduleInterval(TIMER_KEY_HEARTBEAT, intervalMs, () => {
timerKey(name, TIMER_KEY_HEARTBEAT), void sendHeartbeat();
intervalMs, });
() => {
void sendHeartbeat();
},
{ replace: true, awaitTask: false }
);
} }
function stopHeartbeat(): void { function stopHeartbeat(): void {
@ -664,7 +664,7 @@ export function createConnection<TChannels extends ConnectionChannelMap = Connec
resolvePendingAcks({ ok: false, reason: CONNECTION_ACK_REASON_CLOSED }); resolvePendingAcks({ ok: false, reason: CONNECTION_ACK_REASON_CLOSED });
emitState(CONNECTION_STATE_CLOSING); emitState(CONNECTION_STATE_CLOSING);
transport?.close(undefined, reason); transport?.close(undefined, reason);
markClosed(CONNECTION_STATE_CLOSED); if (state !== CONNECTION_STATE_CLOSED) markClosed(CONNECTION_STATE_CLOSED);
} }
function wireBrowserReconnect(): void { function wireBrowserReconnect(): void {

@ -207,43 +207,3 @@ export const ERROR_MSG_JSON_DECODE_FAILED = 'Connection JSON payload is invalid'
export const ERROR_MSG_WEBSOCKET_UNAVAILABLE = 'WebSocket is not available in this runtime'; export const ERROR_MSG_WEBSOCKET_UNAVAILABLE = 'WebSocket is not available in this runtime';
export const ERROR_MSG_CHANNEL_ALREADY_EXISTS_PREFIX = 'channel already exists: '; export const ERROR_MSG_CHANNEL_ALREADY_EXISTS_PREFIX = 'channel already exists: ';
export const ERROR_MSG_CHANNEL_NOT_FOUND_PREFIX = 'channel not found: '; export const ERROR_MSG_CHANNEL_NOT_FOUND_PREFIX = 'channel not found: ';
export function disposedErrorMessage(method: string): string {
return `${ERROR_PREFIX}${method}${ERROR_MSG_DISPOSED_SUFFIX}`;
}
export function alreadyExistsErrorMessage(name: string): string {
return `${ERROR_PREFIX}${ERROR_MSG_ALREADY_EXISTS_PREFIX}${name}`;
}
export function notFoundErrorMessage(name: string): string {
return `${ERROR_PREFIX}${ERROR_MSG_NOT_FOUND_PREFIX}${name}`;
}
export function invalidNameErrorMessage(name: unknown): string {
return `${ERROR_PREFIX}${ERROR_MSG_INVALID_NAME_PREFIX}${String(name)}`;
}
export function channelAlreadyExistsErrorMessage(name: string): string {
return `${ERROR_PREFIX}${ERROR_MSG_CHANNEL_ALREADY_EXISTS_PREFIX}${name}`;
}
export function channelNotFoundErrorMessage(name: string): string {
return `${ERROR_PREFIX}${ERROR_MSG_CHANNEL_NOT_FOUND_PREFIX}${name}`;
}
export function listenerThrewMessage(event: string): string {
return `${LOG_MSG_LISTENER_THREW_PREFIX}${event}`;
}
export function loggerScope(name: string, topic?: string): string {
return topic === undefined
? `${LOGGER_CATEGORY}${LOGGER_SCOPE_SEPARATOR}${name}`
: `${LOGGER_CATEGORY}${LOGGER_SCOPE_SEPARATOR}${name}${LOGGER_SCOPE_SEPARATOR}${topic}`;
}
export function timerKey(connection: string, kind: string, id?: string): string {
return id === undefined
? `${LOGGER_CATEGORY}${LOGGER_SCOPE_SEPARATOR}${connection}${LOGGER_SCOPE_SEPARATOR}${kind}`
: `${LOGGER_CATEGORY}${LOGGER_SCOPE_SEPARATOR}${connection}${LOGGER_SCOPE_SEPARATOR}${kind}${LOGGER_SCOPE_SEPARATOR}${id}`;
}

@ -8,11 +8,7 @@ import {
CONNECTION_METHOD_OPEN_ALL, CONNECTION_METHOD_OPEN_ALL,
CONNECTION_METHOD_OPEN_CONNECTION, CONNECTION_METHOD_OPEN_CONNECTION,
CONNECTION_METHOD_RECONNECT_ALL, CONNECTION_METHOD_RECONNECT_ALL,
CONNECTION_METHOD_RECONNECT_CONNECTION, CONNECTION_METHOD_RECONNECT_CONNECTION
alreadyExistsErrorMessage,
disposedErrorMessage,
invalidNameErrorMessage,
notFoundErrorMessage
} from './consts.ts'; } from './consts.ts';
import { import {
ConnConnectionAlreadyExistsError, ConnConnectionAlreadyExistsError,
@ -20,6 +16,12 @@ import {
ConnDisposedError, ConnDisposedError,
ConnInvalidConnectionNameError ConnInvalidConnectionNameError
} from './errors.ts'; } from './errors.ts';
import {
alreadyExistsErrorMessage,
disposedErrorMessage,
invalidNameErrorMessage,
notFoundErrorMessage
} from './helpers.ts';
import type { import type {
Connection, Connection,
ConnectionChannelMap, ConnectionChannelMap,
@ -80,6 +82,7 @@ export function createEngineConnections<TConnections extends ConnectionMap = Con
const timers = const timers =
options.timers ?? options.timers ??
createEngineTimers({ createEngineTimers({
clock: options.clock,
logger: options.logger logger: options.logger
}); });
const ownsTimers = options.timers === undefined; const ownsTimers = options.timers === undefined;
@ -158,7 +161,7 @@ export function createEngineConnections<TConnections extends ConnectionMap = Con
); );
}, },
close(name, reason) { close(name, reason) {
this.closeConnection(name, reason); getConnection(name, CONNECTION_METHOD_CLOSE_CONNECTION).disconnect(reason);
}, },
dispose() { dispose() {
if (disposed) return; if (disposed) return;

@ -0,0 +1,52 @@
import {
ERROR_MSG_ALREADY_EXISTS_PREFIX,
ERROR_MSG_CHANNEL_ALREADY_EXISTS_PREFIX,
ERROR_MSG_CHANNEL_NOT_FOUND_PREFIX,
ERROR_MSG_DISPOSED_SUFFIX,
ERROR_MSG_INVALID_NAME_PREFIX,
ERROR_MSG_NOT_FOUND_PREFIX,
ERROR_PREFIX,
LOGGER_CATEGORY,
LOGGER_SCOPE_SEPARATOR,
LOG_MSG_LISTENER_THREW_PREFIX
} from './consts.ts';
export function disposedErrorMessage(method: string): string {
return `${ERROR_PREFIX}${method}${ERROR_MSG_DISPOSED_SUFFIX}`;
}
export function alreadyExistsErrorMessage(name: string): string {
return `${ERROR_PREFIX}${ERROR_MSG_ALREADY_EXISTS_PREFIX}${name}`;
}
export function notFoundErrorMessage(name: string): string {
return `${ERROR_PREFIX}${ERROR_MSG_NOT_FOUND_PREFIX}${name}`;
}
export function invalidNameErrorMessage(name: unknown): string {
return `${ERROR_PREFIX}${ERROR_MSG_INVALID_NAME_PREFIX}${String(name)}`;
}
export function channelAlreadyExistsErrorMessage(name: string): string {
return `${ERROR_PREFIX}${ERROR_MSG_CHANNEL_ALREADY_EXISTS_PREFIX}${name}`;
}
export function channelNotFoundErrorMessage(name: string): string {
return `${ERROR_PREFIX}${ERROR_MSG_CHANNEL_NOT_FOUND_PREFIX}${name}`;
}
export function listenerThrewMessage(event: string): string {
return `${LOG_MSG_LISTENER_THREW_PREFIX}${event}`;
}
export function loggerScope(name: string, topic?: string): string {
return topic === undefined
? `${LOGGER_CATEGORY}${LOGGER_SCOPE_SEPARATOR}${name}`
: `${LOGGER_CATEGORY}${LOGGER_SCOPE_SEPARATOR}${name}${LOGGER_SCOPE_SEPARATOR}${topic}`;
}
export function timerKey(connection: string, kind: string, id?: string): string {
return id === undefined
? `${LOGGER_CATEGORY}${LOGGER_SCOPE_SEPARATOR}${connection}${LOGGER_SCOPE_SEPARATOR}${kind}`
: `${LOGGER_CATEGORY}${LOGGER_SCOPE_SEPARATOR}${connection}${LOGGER_SCOPE_SEPARATOR}${kind}${LOGGER_SCOPE_SEPARATOR}${id}`;
}

@ -9,4 +9,5 @@ export { createWebSocketTransport } from './transports/websocket.ts';
export * from './consts.ts'; export * from './consts.ts';
export * from './errors.ts'; export * from './errors.ts';
export * from './helpers.ts';
export * from './types.ts'; export * from './types.ts';

@ -3,6 +3,7 @@ import {
CONNECTION_ACK_REASON_CLOSED, CONNECTION_ACK_REASON_CLOSED,
CONNECTION_ACK_REASON_TIMEOUT, CONNECTION_ACK_REASON_TIMEOUT,
CONNECTION_BUFFER_POLICY_BUFFER, CONNECTION_BUFFER_POLICY_BUFFER,
CONNECTION_CHANNEL_STATE_LEFT,
CONNECTION_CONNECT_REASON_AUTH_FAILED, CONNECTION_CONNECT_REASON_AUTH_FAILED,
CONNECTION_FRAME_TYPE_JOIN, CONNECTION_FRAME_TYPE_JOIN,
CONNECTION_SEND_REASON_CLOSED, CONNECTION_SEND_REASON_CLOSED,
@ -15,6 +16,7 @@ import {
createFrame, createFrame,
createMockTransport createMockTransport
} from '../index.ts'; } from '../index.ts';
import type { TimerClock } from '$timr';
import type { ConnectionFrame, MockConnectionTransport } from '../index.ts'; import type { ConnectionFrame, MockConnectionTransport } from '../index.ts';
function latestFrame(transport: MockConnectionTransport): ConnectionFrame { function latestFrame(transport: MockConnectionTransport): ConnectionFrame {
@ -53,6 +55,29 @@ describe('Connection lifecycle', () => {
Connections.dispose(); Connections.dispose();
}); });
it('uses the injected timer clock for connection timestamps', async () => {
const clock: TimerClock = {
now: () => 4242,
setTimeout: () => 0,
clearTimeout: () => {}
};
const Connections = createEngineConnections({ clock });
const Main = Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
reconnect: false
});
await Main.connect();
expect(Main.openedAt).toBe(4242);
Main.disconnect();
expect(Main.closedAt).toBe(4242);
Connections.dispose();
});
it('does not reconnect after an intentional disconnect', async () => { it('does not reconnect after an intentional disconnect', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const Connections = createEngineConnections(); const Connections = createEngineConnections();
@ -313,6 +338,24 @@ describe('Connection channels', () => {
Connections.dispose(); Connections.dispose();
}); });
it('marks a disposed channel as left', async () => {
const Connections = createEngineConnections();
const Main = Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
reconnect: false
});
const Orders = Main.channel('orders');
await Main.connect();
await Orders.join();
Orders.dispose();
expect(Orders.state).toBe(CONNECTION_CHANNEL_STATE_LEFT);
Connections.dispose();
});
}); });
describe('Connection session bridge', () => { describe('Connection session bridge', () => {

@ -87,4 +87,21 @@ describe('createEngineConnections', () => {
Connections.dispose(); Connections.dispose();
}); });
it('keeps close() safe when the method is destructured', async () => {
const Connections = createEngineConnections();
const Main = Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
reconnect: false
});
const { close } = Connections;
await Main.connect();
close('main');
expect(Main.state).toBe(CONNECTION_STATE_CLOSED);
Connections.dispose();
});
}); });

@ -1,4 +1,4 @@
import type { TimerScheduler } from '$timr'; import type { TimerClock, TimerScheduler } from '$timr';
import type { import type {
CONNECTION_ACK_REASON_CLOSED, CONNECTION_ACK_REASON_CLOSED,
CONNECTION_ACK_REASON_INVALID_REPLY, CONNECTION_ACK_REASON_INVALID_REPLY,
@ -359,6 +359,7 @@ export interface Connection<TChannels extends ConnectionChannelMap = ConnectionC
export interface EngineConnectionsOptions { export interface EngineConnectionsOptions {
readonly logger?: ConnectionLogger; readonly logger?: ConnectionLogger;
readonly timers?: TimerScheduler; readonly timers?: TimerScheduler;
readonly clock?: TimerClock;
readonly defaults?: Partial<ConnectionOptions>; readonly defaults?: Partial<ConnectionOptions>;
readonly session?: ConnectionSessionSource; readonly session?: ConnectionSessionSource;
} }

@ -10,6 +10,7 @@
children?: Snippet; children?: Snippet;
fallback?: Snippet; fallback?: Snippet;
loading?: Snippet; loading?: Snippet;
optimistic?: boolean;
} }
let { let {
@ -18,7 +19,8 @@
context = undefined, context = undefined,
children, children,
fallback, fallback,
loading loading,
optimistic = true
}: Props = $props(); }: Props = $props();
const permissions = getPermissionsContext(); const permissions = getPermissionsContext();
@ -28,6 +30,7 @@
$effect(() => { $effect(() => {
let cancelled = false; let cancelled = false;
pending = true; pending = true;
if (!optimistic) allowed = false;
void permissions void permissions
.can({ action, resource, context }) .can({ action, resource, context })
.then((next) => { .then((next) => {

@ -13,8 +13,8 @@ The most important rule is:
> The server decides. The client reflects. > The server decides. The client reflects.
Use `createEnginePermissions()` in the authoritative runtime: server routes, server actions, Use `createEnginePermissions()` from `$svrs/perm` in the authoritative runtime:
API handlers, command handlers, job processors. server routes, server actions, API handlers, command handlers, job processors.
Use `createActivePermissions()` in Svelte/UI code only to improve UX: hide buttons, show Use `createActivePermissions()` in Svelte/UI code only to improve UX: hide buttons, show
disabled states, hydrate snapshots, cache remote checks and render `<Can />`. disabled states, hydrate snapshots, cache remote checks and render `<Can />`.
@ -46,10 +46,10 @@ Real applications need all of them, often in the same decision.
## Public Surface ## Public Surface
```ts ```ts
import { createEnginePermissions, createPermissionHttpHandlers } from '$svrs/perm';
import { import {
createEnginePermissions,
createActivePermissions, createActivePermissions,
createPermissionHttpHandlers,
definePermSchema, definePermSchema,
definePolicies, definePolicies,
allow, allow,
@ -72,10 +72,10 @@ import {
Main APIs: Main APIs:
- `createEnginePermissions(options)` creates the authoritative engine. - `createEnginePermissions(options)` creates the authoritative engine from `$svrs/perm`.
- `createActivePermissions(options)` creates a reactive client-side reflector. - `createActivePermissions(options)` creates a reactive client-side reflector from `$perm`.
- `App.createActivePermissions(options)` creates an App-wired active client with `App.Http` and `App.Logger`. - `App.createActivePermissions(options)` creates an App-wired active client with `App.Http` and `App.Logger`.
- `createPermissionHttpHandlers(engine, resolveActor)` exposes `check`, `batch`, `what`, `explain`. - `createPermissionHttpHandlers(engine, resolveActor)` exposes `check`, `batch`, `what`, `explain` from `$svrs/perm`.
- `<Can />` renders UI based on `Permissions.can(...)`. - `<Can />` renders UI based on `Permissions.can(...)`.
## Core Concepts ## Core Concepts
@ -390,6 +390,8 @@ actor, resource or context.
Create the server-side runtime: Create the server-side runtime:
```ts ```ts
import { createEnginePermissions } from '$svrs/perm';
export const Permissions = createEnginePermissions({ export const Permissions = createEnginePermissions({
schema, schema,
policies, policies,
@ -460,7 +462,7 @@ Do not rely on `<Can />` or `ActivePermissions.can()` for this.
The active client talks to HTTP handlers. The active client talks to HTTP handlers.
```ts ```ts
import { createPermissionHttpHandlers } from '$perm'; import { createPermissionHttpHandlers } from '$svrs/perm';
import { Permissions } from '$lib/server/permissions'; import { Permissions } from '$lib/server/permissions';
const handlers = createPermissionHttpHandlers(Permissions, async (request) => { const handlers = createPermissionHttpHandlers(Permissions, async (request) => {
@ -564,7 +566,10 @@ The active client has a small decision cache.
const Permissions = createActivePermissions({ const Permissions = createActivePermissions({
endpoint: '/api/permissions', endpoint: '/api/permissions',
initialSnapshot, initialSnapshot,
cacheTtlMs: 10_000 cacheTtlMs: 10_000,
nonAllowCacheTtlMs: 2_000,
remoteFailureBackoffMs: 1_000,
scopeKey: () => App.Sess?.current?.user?.id
}); });
``` ```
@ -586,6 +591,11 @@ Important:
- Cache is for UX. - Cache is for UX.
- Cache is not security. - Cache is not security.
- `allow` decisions use `decision.ttl` when the server provides it, otherwise `cacheTtlMs`.
- `deny` and `not_applicable` decisions use the shorter `nonAllowCacheTtlMs`.
- `indeterminate` decisions are not stored as positive cache entries.
- Remote failures are backoff-cached briefly as `indeterminate` to avoid retry storms in list views.
- `scopeKey` should identify the active actor/session when multiple users can share a tab.
- Mutations still require server-side `assert()`. - Mutations still require server-side `assert()`.
- Call `invalidate()` after actor/session/resource changes. - Call `invalidate()` after actor/session/resource changes.
@ -631,6 +641,7 @@ Props:
- `children`: rendered on allow. - `children`: rendered on allow.
- `fallback`: rendered on deny, indeterminate, not_applicable or request failure. - `fallback`: rendered on deny, indeterminate, not_applicable or request failure.
- `loading`: rendered while the async check is in progress. - `loading`: rendered while the async check is in progress.
- `optimistic`: default `true`; keeps previously allowed content visible while a new check resolves. Set `optimistic={false}` to hide content immediately on prop changes.
Again: `<Can />` is only UI. It prevents confusing affordances; it does not protect data. Again: `<Can />` is only UI. It prevents confusing affordances; it does not protect data.
@ -883,7 +894,7 @@ import {
definePolicies, definePolicies,
deny, deny,
rel rel
} from '$perm'; } from '$svrs/perm';
const schema = definePermSchema({ const schema = definePermSchema({
actors: { actors: {

@ -1,10 +1,11 @@
import { untrack } from 'svelte';
import { createPermissionClient } from './client.ts'; import { createPermissionClient } from './client.ts';
import { PERMISSION_ERROR_MSG_CLIENT_ENDPOINT_REQUIRED } from './consts.ts'; import { PERMISSION_ERROR_MSG_CLIENT_ENDPOINT_REQUIRED } from './consts.ts';
import { PermInvalidEndpointError } from './errors.ts';
import type { ActivePermissions, ActivePermissionsOptions, PermissionSnapshot } from './types.ts'; import type { ActivePermissions, ActivePermissionsOptions, PermissionSnapshot } from './types.ts';
export function createActivePermissions(options: ActivePermissionsOptions): ActivePermissions { export function createActivePermissions(options: ActivePermissionsOptions): ActivePermissions {
if (!options.endpoint) throw new Error(PERMISSION_ERROR_MSG_CLIENT_ENDPOINT_REQUIRED); if (!options.endpoint)
throw new PermInvalidEndpointError(PERMISSION_ERROR_MSG_CLIENT_ENDPOINT_REQUIRED);
const client = createPermissionClient(options); const client = createPermissionClient(options);
let snapshotCell = $state<PermissionSnapshot>(client.snapshot()); let snapshotCell = $state<PermissionSnapshot>(client.snapshot());
@ -16,7 +17,7 @@ export function createActivePermissions(options: ActivePermissionsOptions): Acti
}); });
function updateLoading(delta: number): void { function updateLoading(delta: number): void {
loadingCount = Math.max(0, untrack(() => loadingCount) + delta); loadingCount = Math.max(0, loadingCount + delta);
} }
async function track<T>(task: () => Promise<T>): Promise<T> { async function track<T>(task: () => Promise<T>): Promise<T> {

@ -6,14 +6,17 @@ import {
PERMISSION_FALLBACK_DENY PERMISSION_FALLBACK_DENY
} from '$libs/perm'; } from '$libs/perm';
import { HTTP_CONTENT_TYPE_JSON, HTTP_HEADER_CONTENT_TYPE, HTTP_METHOD_POST } from '$libs/http'; import { HTTP_CONTENT_TYPE_JSON, HTTP_HEADER_CONTENT_TYPE, HTTP_METHOD_POST } from '$libs/http';
import { permissionDecisionKey } from './keys.ts';
import { import {
LOGGER_CATEGORY, LOGGER_CATEGORY,
PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS, PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS,
PERMISSION_CLIENT_DEFAULT_NON_ALLOW_CACHE_TTL_MS,
PERMISSION_CLIENT_DEFAULT_REMOTE_FAILURE_BACKOFF_MS,
PERMISSION_CLIENT_PATH_BATCH, PERMISSION_CLIENT_PATH_BATCH,
PERMISSION_CLIENT_PATH_CHECK, PERMISSION_CLIENT_PATH_CHECK,
PERMISSION_CLIENT_PATH_EXPLAIN, PERMISSION_CLIENT_PATH_EXPLAIN,
PERMISSION_CLIENT_PATH_WHAT, PERMISSION_CLIENT_PATH_WHAT,
PERMISSION_CLIENT_KEY_SEPARATOR,
PERMISSION_CLIENT_SCOPE_PREFIX,
PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX, PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX,
PERMISSION_HTTP_CREDENTIALS_INCLUDE, PERMISSION_HTTP_CREDENTIALS_INCLUDE,
PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED, PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED,
@ -27,6 +30,8 @@ import {
PERMISSION_RESPONSE_FIELD_DECISIONS, PERMISSION_RESPONSE_FIELD_DECISIONS,
PERMISSION_SNAPSHOT_GLOBAL_POLICY PERMISSION_SNAPSHOT_GLOBAL_POLICY
} from './consts.ts'; } from './consts.ts';
import { PermRemoteRequestError } from './errors.ts';
import { permissionDecisionKey, stablePermissionStringify } from './keys.ts';
import type { import type {
PermissionClient, PermissionClient,
PermissionClientBatchInput, PermissionClientBatchInput,
@ -58,7 +63,12 @@ async function postJson<T>(
if (options.http) { if (options.http) {
const response = await options.http.post(url, { body: body as Record<string, unknown> }); const response = await options.http.post(url, { body: body as Record<string, unknown> });
if (response.ok) return response.value as T; if (response.ok) return response.value as T;
throw new Error(`${PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX}${url}`); const status = 'status' in response ? response.status : undefined;
throw new PermRemoteRequestError(
`${PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX}${status ?? url}`,
url,
status
);
} }
const fetcher = options.fetcher ?? fetch.bind(globalThis); const fetcher = options.fetcher ?? fetch.bind(globalThis);
@ -70,8 +80,10 @@ async function postJson<T>(
}); });
if (!response.ok) { if (!response.ok) {
throw new Error( throw new PermRemoteRequestError(
`${PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX}${response.status} ${response.statusText}` `${PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX}${response.status} ${response.statusText}`,
url,
response.status
); );
} }
@ -80,7 +92,12 @@ async function postJson<T>(
export function createPermissionClient(options: PermissionClientOptions): PermissionClient { export function createPermissionClient(options: PermissionClientOptions): PermissionClient {
const cacheTtlMs = options.cacheTtlMs ?? PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS; const cacheTtlMs = options.cacheTtlMs ?? PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS;
const nonAllowCacheTtlMs =
options.nonAllowCacheTtlMs ?? PERMISSION_CLIENT_DEFAULT_NON_ALLOW_CACHE_TTL_MS;
const remoteFailureBackoffMs =
options.remoteFailureBackoffMs ?? PERMISSION_CLIENT_DEFAULT_REMOTE_FAILURE_BACKOFF_MS;
const cache = new Map<string, CacheEntry>(); const cache = new Map<string, CacheEntry>();
const failures = new Map<string, CacheEntry>();
const pending = new Map<string, Promise<PermissionDecision>>(); const pending = new Map<string, Promise<PermissionDecision>>();
const listeners = new Set<(snapshot: PermissionSnapshot) => void>(); const listeners = new Set<(snapshot: PermissionSnapshot) => void>();
let currentSnapshot: PermissionSnapshot = options.initialSnapshot ?? { decisions: {} }; let currentSnapshot: PermissionSnapshot = options.initialSnapshot ?? { decisions: {} };
@ -89,10 +106,27 @@ export function createPermissionClient(options: PermissionClientOptions): Permis
for (const listener of listeners) listener(currentSnapshot); for (const listener of listeners) listener(currentSnapshot);
} }
function decisionKey(input: PermissionClientCheckInput): string { function remoteDecisionKey(input: PermissionClientCheckInput): string {
return permissionDecisionKey(input); return permissionDecisionKey(input);
} }
function resolveScopeKey(): string | undefined {
const configured =
typeof options.scopeKey === 'function' ? options.scopeKey() : options.scopeKey;
if (configured !== undefined && configured.length > 0) return configured;
if (currentSnapshot.actor === undefined) return undefined;
return stablePermissionStringify(currentSnapshot.actor);
}
function decisionKey(input: PermissionClientCheckInput): 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 snapshotStillValid(snapshot: PermissionSnapshot): boolean { function snapshotStillValid(snapshot: PermissionSnapshot): boolean {
return snapshot.expiresAt === undefined || Date.parse(snapshot.expiresAt) > now(); return snapshot.expiresAt === undefined || Date.parse(snapshot.expiresAt) > now();
} }
@ -102,6 +136,8 @@ export function createPermissionClient(options: PermissionClientOptions): Permis
const key = decisionKey(input); const key = decisionKey(input);
const direct = currentSnapshot.decisions?.[key]; const direct = currentSnapshot.decisions?.[key];
if (direct) return direct; if (direct) return direct;
const remote = currentSnapshot.decisions?.[remoteDecisionKey(input)];
if (remote) return remote;
const global = currentSnapshot.global?.[input.action]; const global = currentSnapshot.global?.[input.action];
if (typeof global === 'boolean') { if (typeof global === 'boolean') {
return global return global
@ -117,8 +153,9 @@ export function createPermissionClient(options: PermissionClientOptions): Permis
function setCached(input: PermissionClientCheckInput, decision: PermissionDecision): void { function setCached(input: PermissionClientCheckInput, decision: PermissionDecision): void {
const key = decisionKey(input); const key = decisionKey(input);
const ttl = const ttl = resolveDecisionTtl(decision);
decision.effect === PERMISSION_EFFECT_ALLOW && decision.ttl ? decision.ttl : cacheTtlMs; failures.delete(key);
if (ttl <= 0) return;
cache.set(key, { decision, expiresAt: now() + ttl }); cache.set(key, { decision, expiresAt: now() + ttl });
currentSnapshot = { currentSnapshot = {
...currentSnapshot, ...currentSnapshot,
@ -130,14 +167,32 @@ export function createPermissionClient(options: PermissionClientOptions): Permis
emit(); emit();
} }
function resolveDecisionTtl(decision: PermissionDecision): number {
if (decision.effect === PERMISSION_EFFECT_ALLOW) return decision.ttl ?? cacheTtlMs;
if (decision.effect === PERMISSION_EFFECT_INDETERMINATE) return 0;
return Math.min(cacheTtlMs, nonAllowCacheTtlMs);
}
function fallbackDecision(reason: string, error: unknown): PermissionDecision {
return {
effect: PERMISSION_EFFECT_INDETERMINATE,
reason,
fallback: PERMISSION_FALLBACK_DENY,
errors: [error]
};
}
async function check(input: PermissionClientCheckInput): Promise<PermissionDecision> { async function check(input: PermissionClientCheckInput): Promise<PermissionDecision> {
const key = decisionKey(input); const key = decisionKey(input);
const cached = cache.get(key); const cached = cache.get(key);
if (cached && cached.expiresAt > now()) return cached.decision; if (cached && cached.expiresAt > now()) return cached.decision;
const failed = failures.get(key);
if (failed && failed.expiresAt > now()) return failed.decision;
const snapshotDecision = readSnapshotDecision(input); const snapshotDecision = readSnapshotDecision(input);
if (snapshotDecision) { if (snapshotDecision) {
cache.set(key, { decision: snapshotDecision, expiresAt: now() + cacheTtlMs }); const ttl = resolveDecisionTtl(snapshotDecision);
if (ttl > 0) cache.set(key, { decision: snapshotDecision, expiresAt: now() + ttl });
return snapshotDecision; return snapshotDecision;
} }
@ -159,12 +214,11 @@ export function createPermissionClient(options: PermissionClientOptions): Permis
error, error,
context: { input } context: { input }
}); });
return { const fallback = fallbackDecision(PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED, error);
effect: PERMISSION_EFFECT_INDETERMINATE, if (remoteFailureBackoffMs > 0) {
reason: PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED, failures.set(key, { decision: fallback, expiresAt: now() + remoteFailureBackoffMs });
fallback: PERMISSION_FALLBACK_DENY, }
errors: [error] return fallback;
} satisfies PermissionDecision;
}) })
.finally(() => { .finally(() => {
pending.delete(key); pending.delete(key);
@ -182,12 +236,19 @@ export function createPermissionClient(options: PermissionClientOptions): Permis
PERMISSION_CLIENT_PATH_BATCH, PERMISSION_CLIENT_PATH_BATCH,
{ [PERMISSION_REQUEST_FIELD_CHECKS]: input.checks } { [PERMISSION_REQUEST_FIELD_CHECKS]: input.checks }
); );
const decisions: Record<string, PermissionDecision> = {};
for (const item of input.checks) { for (const item of input.checks) {
const key = decisionKey(item); const key = remoteDecisionKey(item);
const decision = result[PERMISSION_RESPONSE_FIELD_DECISIONS][key]; const localKey = decisionKey(item);
if (decision) setCached(item, decision); const decision =
result[PERMISSION_RESPONSE_FIELD_DECISIONS][key] ??
result[PERMISSION_RESPONSE_FIELD_DECISIONS][localKey];
if (decision) {
setCached(item, decision);
decisions[localKey] = decision;
}
} }
return result[PERMISSION_RESPONSE_FIELD_DECISIONS]; return decisions;
} catch (error) { } catch (error) {
options.onError?.(error); options.onError?.(error);
options.logger?.error?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED, { options.logger?.error?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED, {
@ -196,12 +257,12 @@ export function createPermissionClient(options: PermissionClientOptions): Permis
}); });
const decisions: Record<string, PermissionDecision> = {}; const decisions: Record<string, PermissionDecision> = {};
for (const item of input.checks) { for (const item of input.checks) {
decisions[decisionKey(item)] = { const key = decisionKey(item);
effect: PERMISSION_EFFECT_INDETERMINATE, const fallback = fallbackDecision(PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED, error);
reason: PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED, decisions[key] = fallback;
fallback: PERMISSION_FALLBACK_DENY, if (remoteFailureBackoffMs > 0) {
errors: [error] failures.set(key, { decision: fallback, expiresAt: now() + remoteFailureBackoffMs });
}; }
} }
return decisions; return decisions;
} }
@ -244,8 +305,10 @@ export function createPermissionClient(options: PermissionClientOptions): Permis
function hydrate(snapshot: PermissionSnapshot): void { function hydrate(snapshot: PermissionSnapshot): void {
currentSnapshot = snapshot; currentSnapshot = snapshot;
cache.clear(); cache.clear();
failures.clear();
for (const [key, decision] of Object.entries(snapshot.decisions ?? {})) { for (const [key, decision] of Object.entries(snapshot.decisions ?? {})) {
cache.set(key, { decision, expiresAt: now() + cacheTtlMs }); const ttl = resolveDecisionTtl(decision);
if (ttl > 0) cache.set(key, { decision, expiresAt: now() + ttl });
} }
emit(); emit();
} }
@ -253,11 +316,13 @@ export function createPermissionClient(options: PermissionClientOptions): Permis
function invalidate(scope?: string): void { function invalidate(scope?: string): void {
if (!scope) { if (!scope) {
cache.clear(); cache.clear();
failures.clear();
currentSnapshot = { ...currentSnapshot, decisions: {} }; currentSnapshot = { ...currentSnapshot, decisions: {} };
emit(); emit();
return; return;
} }
for (const key of [...cache.keys()]) if (key.includes(scope)) cache.delete(key); for (const key of [...cache.keys()]) if (key.includes(scope)) cache.delete(key);
for (const key of [...failures.keys()]) if (key.includes(scope)) failures.delete(key);
const decisions = { ...(currentSnapshot.decisions ?? {}) }; const decisions = { ...(currentSnapshot.decisions ?? {}) };
for (const key of Object.keys(decisions)) if (key.includes(scope)) delete decisions[key]; for (const key of Object.keys(decisions)) if (key.includes(scope)) delete decisions[key];
currentSnapshot = { ...currentSnapshot, decisions }; currentSnapshot = { ...currentSnapshot, decisions };

@ -1,25 +1,35 @@
export {
PERMISSION_CLIENT_PATH_BATCH,
PERMISSION_CLIENT_PATH_CHECK,
PERMISSION_CLIENT_PATH_EXPLAIN,
PERMISSION_CLIENT_PATH_WHAT,
PERMISSION_CLIENT_CONTEXT_EMPTY,
PERMISSION_CLIENT_KEY_GLOBAL,
PERMISSION_CLIENT_KEY_NONE,
PERMISSION_CLIENT_KEY_SEPARATOR,
PERMISSION_HTTP_CREDENTIALS_INCLUDE,
PERMISSION_HTTP_STATUS_BAD_REQUEST,
PERMISSION_HTTP_STATUS_FORBIDDEN,
PERMISSION_HTTP_STATUS_OK,
PERMISSION_REQUEST_FIELD_ACTION,
PERMISSION_REQUEST_FIELD_ACTIONS,
PERMISSION_REQUEST_FIELD_CHECKS,
PERMISSION_REQUEST_FIELD_CONTEXT,
PERMISSION_REQUEST_FIELD_RESOURCE,
PERMISSION_RESPONSE_FIELD_ACTIONS,
PERMISSION_RESPONSE_FIELD_DECISIONS
} from '$libs/svrs/perm';
export const LOGGER_CATEGORY = 'perm'; export const LOGGER_CATEGORY = 'perm';
export const PERMISSION_CONTEXT_KEY = 'active.permissions'; export const PERMISSION_CONTEXT_KEY = 'active.permissions';
export const PERMISSION_CLIENT_PATH_CHECK = '/check';
export const PERMISSION_CLIENT_PATH_BATCH = '/batch';
export const PERMISSION_CLIENT_PATH_WHAT = '/what';
export const PERMISSION_CLIENT_PATH_EXPLAIN = '/explain';
export const PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS = 30_000; export const PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS = 30_000;
export const PERMISSION_CLIENT_DEFAULT_NON_ALLOW_CACHE_TTL_MS = 5_000;
export const PERMISSION_CLIENT_DEFAULT_REMOTE_FAILURE_BACKOFF_MS = 1_000;
export const PERMISSION_SNAPSHOT_DECISIONS_KEY = 'decisions'; export const PERMISSION_SNAPSHOT_DECISIONS_KEY = 'decisions';
export const PERMISSION_SNAPSHOT_GLOBAL_POLICY = 'snapshot.global'; export const PERMISSION_SNAPSHOT_GLOBAL_POLICY = 'snapshot.global';
export const PERMISSION_CLIENT_KEY_SEPARATOR = ':'; export const PERMISSION_CLIENT_SCOPE_PREFIX = 'scope';
export const PERMISSION_CLIENT_KEY_GLOBAL = 'global';
export const PERMISSION_CLIENT_KEY_NONE = 'none';
export const PERMISSION_CLIENT_CONTEXT_EMPTY = '';
export const PERMISSION_HTTP_STATUS_OK = 200;
export const PERMISSION_HTTP_STATUS_BAD_REQUEST = 400;
export const PERMISSION_HTTP_STATUS_FORBIDDEN = 403;
export const PERMISSION_HTTP_CREDENTIALS_INCLUDE = 'include';
export const PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED = 'remote authorization check failed'; export const PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED = 'remote authorization check failed';
export const PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED = 'remote authorization batch failed'; export const PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED = 'remote authorization batch failed';
@ -29,18 +39,13 @@ export const PERMISSION_LOG_MSG_DENIED = 'authorization denied';
export const PERMISSION_LOG_MSG_INDETERMINATE = 'authorization indeterminate'; export const PERMISSION_LOG_MSG_INDETERMINATE = 'authorization indeterminate';
export const PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX = 'Authorization request failed: '; export const PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX = 'Authorization request failed: ';
export const PERMISSION_ERROR_MSG_BODY_MUST_BE_OBJECT = 'Request body must be an object';
export const PERMISSION_ERROR_MSG_NO_CONTEXT = 'Permission context is not available'; export const PERMISSION_ERROR_MSG_NO_CONTEXT = 'Permission context is not available';
export const PERMISSION_ERROR_MSG_CLIENT_ENDPOINT_REQUIRED = export const PERMISSION_ERROR_MSG_CLIENT_ENDPOINT_REQUIRED =
'createActivePermissions requires an endpoint'; 'createActivePermissions requires an endpoint';
export const PERMISSION_REQUEST_FIELD_ACTION = 'action'; export const PERMISSION_ERROR_NAME_INVALID_ENDPOINT = 'PermInvalidEndpointError';
export const PERMISSION_REQUEST_FIELD_RESOURCE = 'resource'; export const PERMISSION_ERROR_NAME_NO_CONTEXT = 'PermNoContextError';
export const PERMISSION_REQUEST_FIELD_CONTEXT = 'context'; export const PERMISSION_ERROR_NAME_REMOTE_REQUEST = 'PermRemoteRequestError';
export const PERMISSION_REQUEST_FIELD_CHECKS = 'checks';
export const PERMISSION_REQUEST_FIELD_ACTIONS = 'actions';
export const PERMISSION_RESPONSE_FIELD_DECISIONS = 'decisions';
export const PERMISSION_RESPONSE_FIELD_ACTIONS = 'actions';
export const PERMISSION_ACTIVE_EVENT_HYDRATE = 'hydrate'; export const PERMISSION_ACTIVE_EVENT_HYDRATE = 'hydrate';
export const PERMISSION_ACTIVE_EVENT_CHECK = 'check'; export const PERMISSION_ACTIVE_EVENT_CHECK = 'check';

@ -1,5 +1,6 @@
import { getContext, setContext } from 'svelte'; import { getContext, setContext } from 'svelte';
import { PERMISSION_CONTEXT_KEY, PERMISSION_ERROR_MSG_NO_CONTEXT } from './consts.ts'; import { PERMISSION_CONTEXT_KEY, PERMISSION_ERROR_MSG_NO_CONTEXT } from './consts.ts';
import { PermNoContextError } from './errors.ts';
import type { ActivePermissions } from './types.ts'; import type { ActivePermissions } from './types.ts';
const PERMISSION_CONTEXT = Symbol(PERMISSION_CONTEXT_KEY); const PERMISSION_CONTEXT = Symbol(PERMISSION_CONTEXT_KEY);
@ -11,6 +12,6 @@ export function setPermissionsContext(client: ActivePermissions): ActivePermissi
export function getPermissionsContext(): ActivePermissions { export function getPermissionsContext(): ActivePermissions {
const client = getContext<ActivePermissions | undefined>(PERMISSION_CONTEXT); const client = getContext<ActivePermissions | undefined>(PERMISSION_CONTEXT);
if (!client) throw new Error(PERMISSION_ERROR_MSG_NO_CONTEXT); if (!client) throw new PermNoContextError(PERMISSION_ERROR_MSG_NO_CONTEXT);
return client; return client;
} }

@ -0,0 +1,37 @@
import {
PERMISSION_ERROR_NAME_INVALID_ENDPOINT,
PERMISSION_ERROR_NAME_NO_CONTEXT,
PERMISSION_ERROR_NAME_REMOTE_REQUEST
} from './consts.ts';
export class PermInvalidEndpointError extends Error {
override readonly name = PERMISSION_ERROR_NAME_INVALID_ENDPOINT;
}
export class PermNoContextError extends Error {
override readonly name = PERMISSION_ERROR_NAME_NO_CONTEXT;
}
export class PermRemoteRequestError extends Error {
override readonly name = PERMISSION_ERROR_NAME_REMOTE_REQUEST;
constructor(
message: string,
readonly url?: string,
readonly status?: number
) {
super(message);
}
}
export function isPermInvalidEndpointError(error: unknown): error is PermInvalidEndpointError {
return error instanceof PermInvalidEndpointError;
}
export function isPermNoContextError(error: unknown): error is PermNoContextError {
return error instanceof PermNoContextError;
}
export function isPermRemoteRequestError(error: unknown): error is PermRemoteRequestError {
return error instanceof PermRemoteRequestError;
}

@ -1,11 +1,10 @@
export { createEnginePermissions } from './engine-permissions.ts';
export { createActivePermissions } from './active-permissions.svelte.ts'; export { createActivePermissions } from './active-permissions.svelte.ts';
export { createPermissionClient } from './client.ts'; export { createPermissionClient } from './client.ts';
export { createPermissionHttpHandlers } from './http.ts';
export { getPermissionsContext, setPermissionsContext } from './context.ts'; export { getPermissionsContext, setPermissionsContext } from './context.ts';
export { permissionDecisionKey, stablePermissionStringify } from './keys.ts'; export { permissionDecisionKey, stablePermissionStringify } from './keys.ts';
export * from './consts.ts'; export * from './consts.ts';
export * from './errors.ts';
export * from './types.ts'; export * from './types.ts';
export { export {

@ -1,30 +1 @@
import { export { permissionDecisionKey, stablePermissionStringify } from '$libs/svrs/perm';
PERMISSION_CLIENT_CONTEXT_EMPTY,
PERMISSION_CLIENT_KEY_GLOBAL,
PERMISSION_CLIENT_KEY_NONE,
PERMISSION_CLIENT_KEY_SEPARATOR
} from './consts.ts';
import type { PermissionClientCheckInput } from './types.ts';
export function stablePermissionStringify(input: unknown): string {
if (input === undefined) return PERMISSION_CLIENT_CONTEXT_EMPTY;
if (input === null || typeof input !== 'object') return JSON.stringify(input);
if (Array.isArray(input)) return `[${input.map(stablePermissionStringify).join(',')}]`;
const sorted = Object.entries(input as Record<string, unknown>).sort(([a], [b]) =>
a.localeCompare(b)
);
return `{${sorted
.map(([key, value]) => `${JSON.stringify(key)}:${stablePermissionStringify(value)}`)
.join(',')}}`;
}
export function permissionDecisionKey(input: PermissionClientCheckInput): string {
const resource = input.resource
? `${input.resource.type}${PERMISSION_CLIENT_KEY_SEPARATOR}${input.resource.id ?? PERMISSION_CLIENT_KEY_NONE}`
: PERMISSION_CLIENT_KEY_GLOBAL;
return [
resource,
input.action,
input.context ? stablePermissionStringify(input.context) : PERMISSION_CLIENT_CONTEXT_EMPTY
].join(PERMISSION_CLIENT_KEY_SEPARATOR);
}

@ -3,13 +3,18 @@ import { PERMISSION_EFFECT_ALLOW } from '$libs/perm';
import { import {
allow, allow,
attr, attr,
createEnginePermissions, createActivePermissions,
createPermissionClient, createPermissionClient,
createPermissionHttpHandlers,
definePermSchema, definePermSchema,
definePolicies, definePolicies,
isPermInvalidEndpointError,
permissionDecisionKey permissionDecisionKey
} from '$perm'; } from '$perm';
import {
createEnginePermissions,
createPermissionHttpHandlers,
isPermInvalidBodyError
} from '$svrs/perm';
const schema = definePermSchema({ const schema = definePermSchema({
actors: { actors: {
@ -74,4 +79,58 @@ describe('Permission client + HTTP handlers', () => {
expect(batch[permissionDecisionKey(input)]?.effect).toBe(PERMISSION_EFFECT_ALLOW); expect(batch[permissionDecisionKey(input)]?.effect).toBe(PERMISSION_EFFECT_ALLOW);
expect(calls).toEqual(['/permissions/check', '/permissions/batch']); expect(calls).toEqual(['/permissions/check', '/permissions/batch']);
}); });
it('uses typed errors for invalid active client options and invalid bodies', async () => {
expect.assertions(2);
try {
createActivePermissions({ endpoint: '' });
} catch (error) {
expect(isPermInvalidEndpointError(error)).toBe(true);
}
const runtime = createEnginePermissions({ schema, policies });
const handlers = createPermissionHttpHandlers(runtime, () => ({
type: 'user',
id: 'u1',
status: 'active'
}));
try {
await handlers.check(jsonRequest(null));
} catch (error) {
expect(isPermInvalidBodyError(error)).toBe(true);
}
});
it('backs off repeated remote failures for the same decision key', async () => {
const fetcher = vi.fn(async () => new Response(null, { status: 503 })) as typeof fetch;
const client = createPermissionClient({
endpoint: 'https://perm.test/permissions',
fetcher,
remoteFailureBackoffMs: 10_000
});
const input = {
action: 'post.read',
resource: { type: 'post', id: 'p1' }
};
await client.check(input);
await client.check(input);
expect(fetcher).toHaveBeenCalledTimes(1);
});
it('can include an explicit scope key in local decision keys', () => {
const client = createPermissionClient({
endpoint: 'https://perm.test/permissions',
fetcher: vi.fn() as unknown as typeof fetch,
scopeKey: 'u1'
});
const input = {
action: 'post.read',
resource: { type: 'post', id: 'p1' }
};
expect(client.decisionKey(input)).not.toBe(permissionDecisionKey(input));
});
}); });

@ -1,17 +1,6 @@
import type { EngineHttp } from '$http';
import type { EngineLogger } from '$logr'; import type { EngineLogger } from '$logr';
import type { import type { ExplainResult, PermissionDecision, ResourceRef, SubjectRef } from '$libs/perm';
ExplainResult, import type { EngineHttp } from '$http';
PermSchema,
PermissionCheckInput,
PermissionDecision,
PermissionRuntime,
PermissionRuntimeOptions,
PolicyIR,
QueryCompiler,
ResourceRef,
SubjectRef
} from '$libs/perm';
export type { export type {
AdviceIR, AdviceIR,
@ -27,8 +16,6 @@ export type {
PermissionFallback, PermissionFallback,
PermissionFilterBuilder, PermissionFilterBuilder,
PermissionProviders, PermissionProviders,
PermissionRuntime,
PermissionRuntimeOptions,
PolicyIR, PolicyIR,
QueryCompiler, QueryCompiler,
QueryPlan, QueryPlan,
@ -38,17 +25,6 @@ export type {
SubjectRef SubjectRef
} from '$libs/perm'; } from '$libs/perm';
export interface EnginePermissionsOptions extends PermissionRuntimeOptions {
readonly logger?: EngineLogger;
}
export interface EnginePermissions extends PermissionRuntime {
readonly schema: PermSchema;
readonly policies: readonly PolicyIR[];
readonly compilers: readonly QueryCompiler[];
dispose(): void;
}
export interface PermissionSnapshot { export interface PermissionSnapshot {
readonly actor?: SubjectRef; readonly actor?: SubjectRef;
readonly version?: string; readonly version?: string;
@ -63,6 +39,9 @@ export interface PermissionClientOptions {
readonly http?: EngineHttp; readonly http?: EngineHttp;
readonly initialSnapshot?: PermissionSnapshot; readonly initialSnapshot?: PermissionSnapshot;
readonly cacheTtlMs?: number; readonly cacheTtlMs?: number;
readonly nonAllowCacheTtlMs?: number;
readonly remoteFailureBackoffMs?: number;
readonly scopeKey?: string | (() => string | undefined);
readonly logger?: EngineLogger; readonly logger?: EngineLogger;
readonly onError?: (error: unknown) => void; readonly onError?: (error: unknown) => void;
} }
@ -104,19 +83,3 @@ export interface ActivePermissions extends PermissionClient {
} }
export type ActivePermissionsOptions = PermissionClientOptions; export type ActivePermissionsOptions = PermissionClientOptions;
export interface PermissionHttpRequestLike {
readonly method: string;
readonly url?: string;
json(): Promise<unknown>;
}
export interface PermissionHttpResponse {
readonly status: number;
readonly body: unknown;
}
export type PermissionActorResolver = (
request: PermissionHttpRequestLike,
body: unknown
) => Promise<PermissionCheckInput['actor']> | PermissionCheckInput['actor'];

@ -15,12 +15,7 @@
*/ */
import { createEngineTimers } from './engine-timers.ts'; import { createEngineTimers } from './engine-timers.ts';
import type { import type { EngineTimers, EngineTimersOptions, TimerEntrySnapshot } from './types.ts';
EngineTimers,
EngineTimersOptions,
TimerEntrySnapshot,
TimerScheduler
} from './types.ts';
export interface ActiveTimers extends EngineTimers { export interface ActiveTimers extends EngineTimers {
readonly size: number; readonly size: number;
@ -40,6 +35,9 @@ export function createActiveTimers(options: EngineTimersOptions = {}): ActiveTim
const active: ActiveTimers = { const active: ActiveTimers = {
// ── Engine surface (delegated) ────────────────────────────────────── // ── Engine surface (delegated) ──────────────────────────────────────
get clock() {
return engine.clock;
},
schedule: (key, delayMs, task, opts) => engine.schedule(key, delayMs, task, opts), schedule: (key, delayMs, task, opts) => engine.schedule(key, delayMs, task, opts),
scheduleAt: (key, dueAt, task, opts) => engine.scheduleAt(key, dueAt, task, opts), scheduleAt: (key, dueAt, task, opts) => engine.scheduleAt(key, dueAt, task, opts),
interval: (key, everyMs, task, opts) => engine.interval(key, everyMs, task, opts), interval: (key, everyMs, task, opts) => engine.interval(key, everyMs, task, opts),
@ -82,5 +80,5 @@ export function createActiveTimers(options: EngineTimersOptions = {}): ActiveTim
// Cast: ActiveTimers extends EngineTimers — TypeScript loses the // Cast: ActiveTimers extends EngineTimers — TypeScript loses the
// `size` getter narrowing because we redefine it as reactive above. // `size` getter narrowing because we redefine it as reactive above.
return active as ActiveTimers & TimerScheduler; return active as ActiveTimers;
} }

@ -427,6 +427,10 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
} }
const engine: EngineTimers = { const engine: EngineTimers = {
get clock() {
return clock;
},
get size() { get size() {
return entries.size; return entries.size;
}, },

@ -207,6 +207,7 @@ export interface TimerLogger {
// ============================================================================ // ============================================================================
export interface TimerScheduler { export interface TimerScheduler {
readonly clock: TimerClock;
readonly size: number; readonly size: number;
schedule(key: string, delayMs: number, task: TimerTask, options?: TimerOptions): TimerHandle; schedule(key: string, delayMs: number, task: TimerTask, options?: TimerOptions): TimerHandle;

@ -0,0 +1,215 @@
import {
CACHE_ADAPTER_MEMORY,
CACHE_MEMORY_EVICT_CLEAR,
CACHE_MEMORY_EVICT_DELETE,
CACHE_MEMORY_EVICT_EXPIRED,
CACHE_MEMORY_EVICT_MAX_ENTRIES,
CACHE_MEMORY_EVICT_MAX_SIZE_BYTES
} from '../consts.ts';
import type {
CacheAdapter,
CacheAdapterSetOptions,
CacheClock,
CacheEnvelope,
MemoryCacheEvictReason
} from '../types.ts';
export type MemoryCacheAdapterOptions = {
name?: string;
maxEntries?: number;
maxSizeBytes?: number;
clock?: CacheClock;
onEvict?: (event: { key: string; reason: MemoryCacheEvictReason }) => void;
};
type MemoryEntry = {
envelope: CacheEnvelope<unknown>;
expiresAt: number;
sizeBytes: number;
};
export type MemoryCacheAdapter = CacheAdapter & {
inspect(): {
entries: Array<{
key: string;
keyHash: string;
state: string;
tags: string[];
sizeBytes: number;
expiresAt: number;
lastAccessedAt: number;
}>;
epochs: Record<string, number>;
sizeBytes: number;
};
};
export function memoryCacheAdapter(options: MemoryCacheAdapterOptions = {}): MemoryCacheAdapter {
const maxEntries = options.maxEntries ?? 10_000;
const maxSizeBytes = options.maxSizeBytes ?? 128 * 1024 * 1024;
const clock = options.clock ?? systemClock;
const entries = new Map<string, MemoryEntry>();
const epochs = new Map<string, number>();
let totalSizeBytes = 0;
function evict(key: string, reason: MemoryCacheEvictReason): void {
const existing = entries.get(key);
if (!existing) return;
entries.delete(key);
totalSizeBytes -= existing.sizeBytes;
options.onEvict?.({ key, reason });
}
function purgeExpired(): void {
const timestamp = clock.now();
for (const [key, entry] of entries) {
if (entry.expiresAt <= timestamp || entry.envelope.meta.gcAfter <= timestamp) {
evict(key, CACHE_MEMORY_EVICT_EXPIRED);
}
}
}
function enforceLimits(): void {
purgeExpired();
while (entries.size > maxEntries) {
const oldestKey = findOldestKey();
if (!oldestKey) break;
evict(oldestKey, CACHE_MEMORY_EVICT_MAX_ENTRIES);
}
while (totalSizeBytes > maxSizeBytes) {
const oldestKey = findOldestKey();
if (!oldestKey) break;
evict(oldestKey, CACHE_MEMORY_EVICT_MAX_SIZE_BYTES);
}
}
function findOldestKey(): string | undefined {
let oldestKey: string | undefined;
let oldestAccess = Infinity;
for (const [key, entry] of entries) {
const accessed = entry.envelope.meta.lastAccessedAt;
if (accessed < oldestAccess) {
oldestAccess = accessed;
oldestKey = key;
}
}
return oldestKey;
}
return {
name: options.name ?? CACHE_ADAPTER_MEMORY,
async get<T>(key: string): Promise<CacheEnvelope<T> | null> {
const entry = entries.get(key);
if (!entry) return null;
const timestamp = clock.now();
if (entry.expiresAt <= timestamp || entry.envelope.meta.gcAfter <= timestamp) {
evict(key, CACHE_MEMORY_EVICT_EXPIRED);
return null;
}
entry.envelope.meta.hitCount += 1;
entry.envelope.meta.lastAccessedAt = timestamp;
return cloneEnvelope(entry.envelope) as CacheEnvelope<T>;
},
async set<T>(
key: string,
value: CacheEnvelope<T>,
setOptions?: CacheAdapterSetOptions
): Promise<void> {
const cloned = cloneEnvelope(value) as CacheEnvelope<unknown>;
const sizeBytes = envelopeSize(cloned);
const expiresAt =
setOptions?.ttlMs === undefined
? cloned.meta.gcAfter
: clock.now() + Math.max(0, setOptions.ttlMs);
const existing = entries.get(key);
if (existing) {
totalSizeBytes -= existing.sizeBytes;
}
entries.set(key, {
envelope: cloned,
expiresAt,
sizeBytes
});
totalSizeBytes += sizeBytes;
enforceLimits();
},
async delete(key: string): Promise<void> {
evict(key, CACHE_MEMORY_EVICT_DELETE);
},
async getEpoch(key: string): Promise<number> {
return epochs.get(key) ?? 0;
},
async bumpEpoch(key: string): Promise<number> {
const next = (epochs.get(key) ?? 0) + 1;
epochs.set(key, next);
return next;
},
async getManyEpochs(keys: string[]): Promise<Record<string, number>> {
const result: Record<string, number> = {};
for (const key of keys) {
result[key] = epochs.get(key) ?? 0;
}
return result;
},
async clear(): Promise<void> {
for (const key of entries.keys()) {
options.onEvict?.({ key, reason: CACHE_MEMORY_EVICT_CLEAR });
}
entries.clear();
epochs.clear();
totalSizeBytes = 0;
},
inspect() {
purgeExpired();
return {
entries: Array.from(entries.entries()).map(([key, entry]) => ({
key,
keyHash: entry.envelope.meta.keyHash,
state: entry.envelope.meta.state,
tags: [...entry.envelope.meta.tags],
sizeBytes: entry.sizeBytes,
expiresAt: entry.expiresAt,
lastAccessedAt: entry.envelope.meta.lastAccessedAt
})),
epochs: Object.fromEntries(epochs.entries()),
sizeBytes: totalSizeBytes
};
}
};
}
const systemClock: CacheClock = {
now: () => Date.now()
};
function envelopeSize(envelope: CacheEnvelope<unknown>): number {
try {
return new TextEncoder().encode(JSON.stringify(envelope)).byteLength;
} catch {
return envelope.meta.sizeBytes ?? 0;
}
}
function cloneEnvelope<T>(envelope: CacheEnvelope<T>): CacheEnvelope<T> {
if (typeof structuredClone === 'function') {
return structuredClone(envelope);
}
return JSON.parse(JSON.stringify(envelope)) as CacheEnvelope<T>;
}

@ -0,0 +1,192 @@
import {
CACHE_ADAPTER_STORAGE,
CACHE_KEY_SEPARATOR,
CACHE_STORAGE_PART_ENTRY,
CACHE_STORAGE_PART_EPOCH,
CACHE_STORAGE_PART_INDEX
} from '../consts.ts';
import type { CacheAdapter, CacheAdapterSetOptions, CacheClock, CacheEnvelope } from '../types.ts';
export type StorageLike = {
getItem(key: string): string | null | Promise<string | null>;
setItem(key: string, value: string): void | Promise<void>;
removeItem(key: string): void | Promise<void>;
};
export type StorageCacheAdapterOptions = {
storage: StorageLike;
namespace?: string;
name?: string;
clock?: CacheClock;
};
type StoredEntry = {
expiresAt: number;
envelope: CacheEnvelope<unknown>;
};
export function storageCacheAdapter(options: StorageCacheAdapterOptions): CacheAdapter {
const namespace = options.namespace ?? CACHE_ADAPTER_STORAGE;
const clock = options.clock ?? systemClock;
const keyFor = (key: string) => joinStorageKey(namespace, CACHE_STORAGE_PART_ENTRY, key);
const epochKeyFor = (key: string) => joinStorageKey(namespace, CACHE_STORAGE_PART_EPOCH, key);
const indexKey = joinStorageKey(namespace, CACHE_STORAGE_PART_INDEX);
async function readIndex(): Promise<string[]> {
const raw = await options.storage.getItem(indexKey);
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : [];
} catch {
await options.storage.removeItem(indexKey);
return [];
}
}
async function writeIndex(keys: readonly string[]): Promise<void> {
const unique = Array.from(new Set(keys)).sort();
if (unique.length === 0) {
await options.storage.removeItem(indexKey);
return;
}
await options.storage.setItem(indexKey, JSON.stringify(unique));
}
async function trackStorageKey(storageKey: string): Promise<void> {
await writeIndex([...(await readIndex()), storageKey]);
}
async function untrackStorageKey(storageKey: string): Promise<void> {
await writeIndex((await readIndex()).filter((key) => key !== storageKey));
}
async function readEpoch(key: string): Promise<number> {
const raw = await options.storage.getItem(epochKeyFor(key));
if (!raw) return 0;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
return {
name: options.name ?? CACHE_ADAPTER_STORAGE,
async get<T>(key: string): Promise<CacheEnvelope<T> | null> {
const storageKey = keyFor(key);
const raw = await options.storage.getItem(storageKey);
if (!raw) return null;
let stored: StoredEntry;
try {
stored = JSON.parse(raw) as StoredEntry;
} catch {
await options.storage.removeItem(storageKey);
await untrackStorageKey(storageKey);
return null;
}
const timestamp = clock.now();
if (stored.expiresAt <= timestamp || stored.envelope.meta.gcAfter <= timestamp) {
await options.storage.removeItem(storageKey);
await untrackStorageKey(storageKey);
return null;
}
return stored.envelope as CacheEnvelope<T>;
},
async set<T>(
key: string,
value: CacheEnvelope<T>,
setOptions?: CacheAdapterSetOptions
): Promise<void> {
if (setOptions?.persist === false) {
return;
}
const expiresAt =
setOptions?.ttlMs === undefined
? value.meta.gcAfter
: clock.now() + Math.max(0, setOptions.ttlMs);
const stored: StoredEntry = {
expiresAt,
envelope: value as CacheEnvelope<unknown>
};
const storageKey = keyFor(key);
await options.storage.setItem(storageKey, JSON.stringify(stored));
await trackStorageKey(storageKey);
},
async delete(key: string): Promise<void> {
const storageKey = keyFor(key);
await options.storage.removeItem(storageKey);
await untrackStorageKey(storageKey);
},
async getEpoch(key: string): Promise<number> {
return readEpoch(key);
},
async bumpEpoch(key: string): Promise<number> {
const current = await readEpoch(key);
const next = current + 1;
const storageKey = epochKeyFor(key);
await options.storage.setItem(storageKey, String(next));
await trackStorageKey(storageKey);
return next;
},
async getManyEpochs(keys: string[]): Promise<Record<string, number>> {
const result: Record<string, number> = {};
await Promise.all(
keys.map(async (key) => {
result[key] = await readEpoch(key);
})
);
return result;
},
async clear(): Promise<void> {
const keys = await readIndex();
await Promise.all(keys.map((key) => options.storage.removeItem(key)));
await options.storage.removeItem(indexKey);
}
};
}
export function createMapStorage(
initial?: Record<string, string>
): StorageLike & { dump(): Record<string, string>; clear(): void } {
const map = new Map<string, string>(Object.entries(initial ?? {}));
return {
async getItem(key: string) {
return map.get(key) ?? null;
},
async setItem(key: string, value: string) {
map.set(key, value);
},
async removeItem(key: string) {
map.delete(key);
},
dump() {
return Object.fromEntries(map.entries());
},
clear() {
map.clear();
}
};
}
const systemClock: CacheClock = {
now: () => Date.now()
};
function joinStorageKey(...parts: string[]): string {
return parts.join(CACHE_KEY_SEPARATOR);
}

@ -0,0 +1,234 @@
export const CACHE_DEFAULT_NAMESPACE = 'cach';
export const CACHE_DEFAULT_VERSION = '1';
export const CACHE_DEFAULT_SCHEMA_VERSION = 'default';
export const CACHE_READ_MODE_CACHE_FIRST = 'cache-first';
export const CACHE_READ_MODE_STALE_WHILE_REVALIDATE = 'stale-while-revalidate';
export const CACHE_READ_MODE_MUST_REVALIDATE = 'must-revalidate';
export const CACHE_READ_MODE_BYPASS_CACHE = 'bypass-cache';
export const CACHE_READ_MODE_NO_STORE = 'no-store';
export const CACHE_READ_MODES = [
CACHE_READ_MODE_CACHE_FIRST,
CACHE_READ_MODE_STALE_WHILE_REVALIDATE,
CACHE_READ_MODE_MUST_REVALIDATE,
CACHE_READ_MODE_BYPASS_CACHE,
CACHE_READ_MODE_NO_STORE
] as const;
export const CACHE_SCOPE_PUBLIC = 'public';
export const CACHE_SCOPE_TENANT = 'tenant';
export const CACHE_SCOPE_ACTOR = 'actor';
export const CACHE_SCOPE_PERMISSION = 'permission';
export const CACHE_SCOPE_CUSTOM = 'custom';
export const CACHE_SCOPE_MODES = [
CACHE_SCOPE_PUBLIC,
CACHE_SCOPE_TENANT,
CACHE_SCOPE_ACTOR,
CACHE_SCOPE_PERMISSION
] as const;
export const CACHE_SCOPE_FIELD_TENANT_ID = 'tenantId';
export const CACHE_SCOPE_FIELD_ACTOR_ID = 'actorId';
export const CACHE_SCOPE_FIELD_PERMISSION_HASH = 'permissionHash';
export const CACHE_SCOPE_FIELD_LOCALE = 'locale';
export const CACHE_ENVELOPE_STATE_FRESH = 'fresh';
export const CACHE_ENVELOPE_STATE_STALE = 'stale';
export const CACHE_ENVELOPE_STATE_EXPIRED = 'expired';
export const CACHE_ENVELOPE_STATE_INVALIDATED = 'invalidated';
export const CACHE_ENVELOPE_STATE_DEGRADED = 'degraded';
export const CACHE_ENVELOPE_STATES = [
CACHE_ENVELOPE_STATE_FRESH,
CACHE_ENVELOPE_STATE_STALE,
CACHE_ENVELOPE_STATE_EXPIRED,
CACHE_ENVELOPE_STATE_INVALIDATED,
CACHE_ENVELOPE_STATE_DEGRADED
] as const;
export const CACHE_DECISION_ACTION_SERVE = 'serve';
export const CACHE_DECISION_ACTION_SERVE_AND_REFRESH = 'serve-and-refresh';
export const CACHE_DECISION_ACTION_FETCH = 'fetch';
export const CACHE_DECISION_ACTION_DELETE_AND_FETCH = 'delete-and-fetch';
export const CACHE_DECISION_ACTION_SKIP_CACHE = 'skip-cache';
export const CACHE_DECISION_ACTIONS = [
CACHE_DECISION_ACTION_SERVE,
CACHE_DECISION_ACTION_SERVE_AND_REFRESH,
CACHE_DECISION_ACTION_FETCH,
CACHE_DECISION_ACTION_DELETE_AND_FETCH,
CACHE_DECISION_ACTION_SKIP_CACHE
] as const;
export const CACHE_DECISION_REASON_BYPASS_CACHE = 'bypass-cache';
export const CACHE_DECISION_REASON_NO_STORE = 'no-store';
export const CACHE_DECISION_REASON_FRESH = 'fresh';
export const CACHE_DECISION_REASON_CACHE_MISS = 'cache-miss';
export const CACHE_DECISION_REASON_SCHEMA_VERSION_MISMATCH = 'schema-version-mismatch';
export const CACHE_DECISION_REASON_SCOPE_MISMATCH = 'scope-mismatch';
export const CACHE_DECISION_REASON_TAG_EPOCH_CHANGED = 'tag-epoch-changed';
export const CACHE_DECISION_REASON_PREFIX_EPOCH_CHANGED = 'prefix-epoch-changed';
export const CACHE_DECISION_REASON_STALE_WINDOW_VALID = 'stale-window-valid';
export const CACHE_DECISION_REASON_EXPIRED = 'expired';
export const CACHE_DECISION_REASON_ADAPTER_MISS = 'adapter-miss';
export const CACHE_DECISION_REASONS = [
CACHE_DECISION_REASON_BYPASS_CACHE,
CACHE_DECISION_REASON_NO_STORE,
CACHE_DECISION_REASON_FRESH,
CACHE_DECISION_REASON_CACHE_MISS,
CACHE_DECISION_REASON_SCHEMA_VERSION_MISMATCH,
CACHE_DECISION_REASON_SCOPE_MISMATCH,
CACHE_DECISION_REASON_TAG_EPOCH_CHANGED,
CACHE_DECISION_REASON_PREFIX_EPOCH_CHANGED,
CACHE_DECISION_REASON_STALE_WINDOW_VALID,
CACHE_DECISION_REASON_EXPIRED,
CACHE_DECISION_REASON_ADAPTER_MISS
] as const;
export const CACHE_EVENT_HIT = 'hit';
export const CACHE_EVENT_MISS = 'miss';
export const CACHE_EVENT_STALE_HIT = 'staleHit';
export const CACHE_EVENT_REFRESH_START = 'refreshStart';
export const CACHE_EVENT_REFRESH_SUCCESS = 'refreshSuccess';
export const CACHE_EVENT_REFRESH_ERROR = 'refreshError';
export const CACHE_EVENT_STALE_IF_ERROR = 'staleIfError';
export const CACHE_EVENT_INVALIDATE = 'invalidate';
export const CACHE_EVENT_SCHEMA_MISMATCH = 'schemaMismatch';
export const CACHE_EVENT_SCOPE_ERROR = 'scopeError';
export const CACHE_EVENT_SINGLEFLIGHT_JOIN = 'singleflightJoin';
export const CACHE_EVENT_EVICTION = 'eviction';
export const CACHE_EVENT_ADAPTER_ERROR = 'adapterError';
export const CACHE_EVENT_SET = 'set';
export const CACHE_EVENT_DELETE = 'delete';
export const CACHE_EVENT_ALL = '*';
export const CACHE_EVENTS = [
CACHE_EVENT_HIT,
CACHE_EVENT_MISS,
CACHE_EVENT_STALE_HIT,
CACHE_EVENT_REFRESH_START,
CACHE_EVENT_REFRESH_SUCCESS,
CACHE_EVENT_REFRESH_ERROR,
CACHE_EVENT_STALE_IF_ERROR,
CACHE_EVENT_INVALIDATE,
CACHE_EVENT_SCHEMA_MISMATCH,
CACHE_EVENT_SCOPE_ERROR,
CACHE_EVENT_SINGLEFLIGHT_JOIN,
CACHE_EVENT_EVICTION,
CACHE_EVENT_ADAPTER_ERROR,
CACHE_EVENT_SET,
CACHE_EVENT_DELETE
] as const;
export const CACHE_ADAPTER_MEMORY = 'memory';
export const CACHE_ADAPTER_STORAGE = 'storage';
export const CACHE_MEMORY_EVICT_EXPIRED = 'expired';
export const CACHE_MEMORY_EVICT_MAX_ENTRIES = 'maxEntries';
export const CACHE_MEMORY_EVICT_MAX_SIZE_BYTES = 'maxSizeBytes';
export const CACHE_MEMORY_EVICT_DELETE = 'delete';
export const CACHE_MEMORY_EVICT_CLEAR = 'clear';
export const CACHE_MEMORY_EVICT_REASONS = [
CACHE_MEMORY_EVICT_EXPIRED,
CACHE_MEMORY_EVICT_MAX_ENTRIES,
CACHE_MEMORY_EVICT_MAX_SIZE_BYTES,
CACHE_MEMORY_EVICT_DELETE,
CACHE_MEMORY_EVICT_CLEAR
] as const;
export const CACHE_POLICY_INTERACTIVE = 'interactive';
export const CACHE_POLICY_CATALOG = 'catalog';
export const CACHE_POLICY_PRIVATE_SESSION = 'privateSession';
export const CACHE_POLICY_REALTIME = 'realtime';
export const CACHE_POLICY_IMMUTABLE = 'immutable';
export const CACHE_KEY_TYPE_BIGINT = 'BigInt';
export const CACHE_KEY_TYPE_UNDEFINED = 'Undefined';
export const CACHE_KEY_TYPE_DATE = 'Date';
export const CACHE_KEY_TYPE_URL_SEARCH_PARAMS = 'URLSearchParams';
export const CACHE_KEY_TYPE_SET = 'Set';
export const CACHE_KEY_TYPE_MAP = 'Map';
export const CACHE_KEY_TYPE_FIELD = '__type';
export const CACHE_KEY_VALUE_FIELD = 'value';
export const CACHE_KEY_VALUES_FIELD = 'values';
export const CACHE_KEY_ENTRIES_FIELD = 'entries';
export const CACHE_INTERNAL_PART_ENTRY = 'entry';
export const CACHE_INTERNAL_PART_EPOCH = 'epoch';
export const CACHE_INTERNAL_PART_TAG = 'tag';
export const CACHE_INTERNAL_PART_PREFIX = 'prefix';
export const CACHE_VERSION_PREFIX = 'v';
export const CACHE_KEY_SEPARATOR = ':';
export const CACHE_STORAGE_PART_ENTRY = 'entry';
export const CACHE_STORAGE_PART_EPOCH = 'epoch';
export const CACHE_STORAGE_PART_INDEX = 'index';
export const CACHE_DURATION_UNIT_MS = 'ms';
export const CACHE_DURATION_UNIT_SECOND = 's';
export const CACHE_DURATION_UNIT_MINUTE = 'm';
export const CACHE_DURATION_UNIT_HOUR = 'h';
export const CACHE_DURATION_UNIT_DAY = 'd';
export const CACHE_MS_SECOND = 1_000;
export const CACHE_MS_MINUTE = 60_000;
export const CACHE_MS_HOUR = 3_600_000;
export const CACHE_MS_DAY = 86_400_000;
export const DEFAULT_CACHE_POLICY = {
freshFor: 30_000,
staleFor: 5 * CACHE_MS_MINUTE,
staleIfErrorFor: 30 * CACHE_MS_MINUTE,
gcAfter: 30 * CACHE_MS_MINUTE,
mode: CACHE_READ_MODE_STALE_WHILE_REVALIDATE,
persist: true
} as const;
export const CACHE_ERROR_NAME_KEY = 'CacheKeyError';
export const CACHE_ERROR_NAME_SCOPE = 'CacheScopeError';
export const CACHE_ERROR_NAME_POLICY = 'CachePolicyError';
export const CACHE_ERROR_MESSAGES = {
KEY_MUST_BE_ARRAY: 'Invalid cache key: key must be an array.',
TAG_STRING_EMPTY: 'Invalid cache tag: tag string cannot be empty.',
TAG_SHAPE_INVALID: 'Invalid cache tag: tag must be a string or an object.',
TAG_TYPE_REQUIRED: 'Invalid cache tag: tag.type is required.',
KEY_NUMBER_NOT_FINITE: (path: string) => `Invalid cache key at ${path}: numbers must be finite.`,
KEY_FUNCTION_UNSERIALIZABLE: (path: string) =>
`Invalid cache key at ${path}: functions are not serializable.`,
KEY_SYMBOL_UNSERIALIZABLE: (path: string) =>
`Invalid cache key at ${path}: symbols are not serializable.`,
KEY_UNSUPPORTED_VALUE: (path: string) => `Invalid cache key at ${path}: unsupported value.`,
KEY_CIRCULAR: (path: string) =>
`Invalid cache key at ${path}: circular references are not allowed.`,
KEY_INVALID_DATE: (path: string) => `Invalid cache key at ${path}: invalid Date.`,
KEY_OBJECT_NOT_PLAIN: (path: string) =>
`Invalid cache key at ${path}: only plain objects, arrays, Date, Map, Set and URLSearchParams are supported.`,
SCOPE_CUSTOM_MODE_REQUIRED: 'Invalid cache scope: custom scope must use mode="custom".',
SCOPE_CUSTOM_VALUES_REQUIRED: 'Invalid custom cache scope: values cannot be empty.',
SCOPE_INVALID_MODE: (scope: string) => `Invalid cache scope "${scope}".`,
SCOPE_REQUIRED_VALUE: (mode: string, name: string) => `Cache scope "${mode}" requires ${name}.`,
SCOPE_VALUE_INVALID: (key: string) =>
`Invalid cache scope value for "${key}": expected string, number, boolean, null or undefined.`,
POLICY_UNKNOWN: (policy: string) => `Unknown cache policy "${policy}".`,
POLICY_DURATION_NEGATIVE: 'Cache policy durations cannot be negative.',
POLICY_DURATION_NOT_FINITE: (fieldName: string) => `Invalid ${fieldName}: number must be finite.`,
POLICY_DURATION_INVALID: (fieldName: string) =>
`Invalid ${fieldName}: expected number of ms or a string like "30s", "5m", "1h".`,
POLICY_DURATION_UNIT_INVALID: (fieldName: string) => `Invalid ${fieldName}: unsupported unit.`
} as const;
export const CACHE_CONTEXT_REASON_JOINED_EXISTING_FETCH = 'joined-existing-fetch';
export const CACHE_CONTEXT_REASON_GET = 'get';
export const CACHE_CONTEXT_REASON_DELETE = 'delete';
export const CACHE_CONTEXT_REASON_FETCH_ERROR = 'fetch-error';
export const CACHE_CONTEXT_REASON_KEY = 'key';
export const CACHE_CONTEXT_REASON_KEY_PREFIX = 'keyPrefix';
export const CACHE_CONTEXT_REASON_TAG = 'tag';
export const CACHE_CANONICAL_ROOT_PATH = '$';

@ -0,0 +1,890 @@
import {
CACHE_CONTEXT_REASON_DELETE,
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
} from './consts.ts';
import { CacheEvents } from './events.ts';
import {
normalizeKey,
normalizeKeyPrefixes,
normalizeTag,
normalizeTags,
stableHash
} from './key.ts';
import { resolvePolicy } from './policy.ts';
import { resolveScope } from './scope.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';
export function createCacheRuntime(config: CacheRuntimeConfig): CacheRuntime {
const namespace = config.namespace ?? CACHE_DEFAULT_NAMESPACE;
const version = config.version ?? CACHE_DEFAULT_VERSION;
const adapter = config.adapter;
const clock = config.clock ?? systemCacheClock;
const events = new CacheEvents(config.onEvent);
const singleflight = new Singleflight((key) => {
emit(CACHE_EVENT_SINGLEFLIGHT_JOIN, {
key,
reason: CACHE_CONTEXT_REASON_JOINED_EXISTING_FETCH
});
});
function emit(type: CacheEventType, patch: Partial<CacheEvent> = {}): void {
events.emit({
type,
namespace,
adapter: adapter.name,
at: clock.now(),
...patch
});
}
async function contextFor(input: {
key: CacheKey;
scope: Parameters<typeof resolveScope>[0]['scope'];
policy?: string | CachePolicy;
mode?: CacheReadMode;
persist?: boolean;
schemaVersion?: string;
}): Promise<ResolvedContext> {
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 {
namespace,
version,
scope,
keyParts: [...input.key],
keyCanonical: normalizedKey.canonical,
keyHash: normalizedKey.hash,
fullKey,
schemaVersion: input.schemaVersion ?? CACHE_DEFAULT_SCHEMA_VERSION,
policy
};
}
async function readCurrentEpochs(
entryOrParts:
| CacheEnvelope<unknown>
| {
tags: string[];
prefixes: string[];
}
): Promise<Record<string, number>> {
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<string, number> = {};
await Promise.all(
unique.map(async (key) => {
result[key] = await adapter.getEpoch(key);
})
);
return result;
}
async function createEnvelope<T>(
value: T,
ctx: ResolvedContext,
tags: string[]
): Promise<CacheEnvelope<T>> {
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<string, number> = {};
for (const key of tagEpochKeys) tagEpochs[key] = currentEpochs[key] ?? 0;
const prefixEpochs: Record<string, number> = {};
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,
tagEpochs,
prefixEpochs,
sizeBytes: estimateSizeBytes(value),
hitCount: 0,
lastAccessedAt: now
}
};
}
async function writeEnvelope<T>(ctx: ResolvedContext, envelope: CacheEnvelope<T>): Promise<void> {
await adapter.set(ctx.fullKey, envelope, {
ttlMs: Math.max(0, envelope.meta.gcAfter - clock.now()),
persist: ctx.policy.persist
});
emit(CACHE_EVENT_SET, {
key: ctx.fullKey,
keyHash: ctx.keyHash,
scopeMode: ctx.scope.mode,
policy: ctx.policy.name,
tags: envelope.meta.tags
});
}
async function fetchAndStore<T>(
ctx: ResolvedContext,
options: Pick<QueryOptions<T>, 'fetcher' | 'tags'>
): Promise<T> {
const value = await options.fetcher();
if (ctx.policy.mode !== CACHE_READ_MODE_NO_STORE) {
const tags = normalizeTags(options.tags);
const envelope = await createEnvelope(value, ctx, tags);
await writeEnvelope(ctx, envelope);
}
return value;
}
async function evaluate<T>(
entry: CacheEnvelope<T>,
ctx: ResolvedContext
): Promise<CacheDecision> {
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<unknown>);
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
};
}
async function backgroundRefresh<T>(
ctx: ResolvedContext,
options: QueryOptions<T>
): Promise<void> {
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
});
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<T>(options: QueryOptions<T>): Promise<T> {
const ctx = await contextFor(options);
if (
ctx.policy.mode === CACHE_READ_MODE_BYPASS_CACHE ||
ctx.policy.mode === CACHE_READ_MODE_NO_STORE
) {
const reason =
ctx.policy.mode === CACHE_READ_MODE_BYPASS_CACHE
? CACHE_DECISION_REASON_BYPASS_CACHE
: CACHE_DECISION_REASON_NO_STORE;
emit(CACHE_EVENT_MISS, {
key: ctx.fullKey,
keyHash: ctx.keyHash,
reason,
scopeMode: ctx.scope.mode,
policy: ctx.policy.name
});
const value = await options.fetcher();
if (ctx.policy.mode === CACHE_READ_MODE_BYPASS_CACHE) {
const envelope = await createEnvelope(value, ctx, normalizeTags(options.tags));
await writeEnvelope(ctx, envelope);
}
return value;
}
let cached: CacheEnvelope<T> | null = null;
try {
cached = await adapter.get<T>(ctx.fullKey);
} catch (error) {
emit(CACHE_EVENT_ADAPTER_ERROR, {
key: ctx.fullKey,
keyHash: ctx.keyHash,
error,
reason: CACHE_CONTEXT_REASON_GET
});
throw error;
}
if (cached) {
const decision = await evaluate(cached, ctx);
if (decision.action === CACHE_DECISION_ACTION_SERVE) {
emit(CACHE_EVENT_HIT, {
key: ctx.fullKey,
keyHash: ctx.keyHash,
reason: decision.reason,
scopeMode: ctx.scope.mode,
policy: ctx.policy.name
});
return cached.value;
}
if (decision.action === CACHE_DECISION_ACTION_SERVE_AND_REFRESH) {
emit(CACHE_EVENT_STALE_HIT, {
key: ctx.fullKey,
keyHash: ctx.keyHash,
reason: decision.reason,
scopeMode: ctx.scope.mode,
policy: ctx.policy.name
});
await backgroundRefresh(ctx, options);
return cached.value;
}
if (decision.action === CACHE_DECISION_ACTION_DELETE_AND_FETCH) {
emit(CACHE_EVENT_SCHEMA_MISMATCH, {
key: ctx.fullKey,
keyHash: ctx.keyHash,
reason: decision.reason,
scopeMode: ctx.scope.mode,
policy: ctx.policy.name
});
await safeDelete(ctx.fullKey, ctx.keyHash);
}
} else {
emit(CACHE_EVENT_MISS, {
key: ctx.fullKey,
keyHash: ctx.keyHash,
reason: CACHE_DECISION_REASON_CACHE_MISS,
scopeMode: ctx.scope.mode,
policy: ctx.policy.name
});
}
return singleflight.run(ctx.fullKey, async () => {
try {
return await fetchAndStore(ctx, options);
} catch (error) {
if (cached && canServeStaleIfError(cached)) {
emit(CACHE_EVENT_STALE_IF_ERROR, {
key: ctx.fullKey,
keyHash: ctx.keyHash,
reason: CACHE_CONTEXT_REASON_FETCH_ERROR,
scopeMode: ctx.scope.mode,
policy: ctx.policy.name,
error
});
return cached.value;
}
throw error;
}
});
}
async function get<T>(key: CacheKey, options: GetOptions): Promise<T | undefined> {
const ctx = await contextFor({
key,
scope: options.scope,
policy: options.policy,
mode: options.mode,
schemaVersion: options.schemaVersion
});
const cached = await adapter.get<T>(ctx.fullKey);
if (!cached) {
emit(CACHE_EVENT_MISS, {
key: ctx.fullKey,
keyHash: ctx.keyHash,
reason: CACHE_DECISION_REASON_CACHE_MISS,
scopeMode: ctx.scope.mode,
policy: ctx.policy.name
});
return undefined;
}
const decision = await evaluate(cached, ctx);
if (decision.action === CACHE_DECISION_ACTION_DELETE_AND_FETCH) {
emit(CACHE_EVENT_SCHEMA_MISMATCH, {
key: ctx.fullKey,
keyHash: ctx.keyHash,
reason: decision.reason,
scopeMode: ctx.scope.mode,
policy: ctx.policy.name
});
await safeDelete(ctx.fullKey, ctx.keyHash);
return undefined;
}
if (
decision.action === CACHE_DECISION_ACTION_SERVE ||
decision.action === CACHE_DECISION_ACTION_SERVE_AND_REFRESH
) {
emit(
decision.action === CACHE_DECISION_ACTION_SERVE ? CACHE_EVENT_HIT : CACHE_EVENT_STALE_HIT,
{
key: ctx.fullKey,
keyHash: ctx.keyHash,
reason: decision.reason,
scopeMode: ctx.scope.mode,
policy: ctx.policy.name
}
);
return cached.value;
}
emit(CACHE_EVENT_MISS, {
key: ctx.fullKey,
keyHash: ctx.keyHash,
reason: decision.reason,
scopeMode: ctx.scope.mode,
policy: ctx.policy.name
});
return undefined;
}
async function set<T>(key: CacheKey, value: T, options: SetOptions): Promise<void> {
const ctx = await contextFor({
key,
scope: options.scope,
policy: options.policy,
mode: options.mode,
persist: options.persist,
schemaVersion: options.schemaVersion
});
if (ctx.policy.mode === CACHE_READ_MODE_NO_STORE) return;
const envelope = await createEnvelope(value, ctx, normalizeTags(options.tags));
await writeEnvelope(ctx, envelope);
}
async function invalidate(options: InvalidateOptions): Promise<void> {
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]
});
}
async function mutate<R = unknown>(options: MutateOptions<R>): Promise<R> {
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<unknown>(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;
}
async function explain(
key: CacheKey,
options: ExplainOptions
): Promise<ReturnType<CacheRuntime['explain']> extends Promise<infer T> ? T : never> {
const ctx = await contextFor({
key,
scope: options.scope,
policy: options.policy,
mode: options.mode,
schemaVersion: options.schemaVersion
});
const cached = await adapter.get<unknown>(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
};
}
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
}
};
}
async function clear(): Promise<void> {
if (adapter.clear) {
await adapter.clear();
}
}
async function safeDelete(key: string, keyHash: string): Promise<void> {
try {
await adapter.delete(key);
emit(CACHE_EVENT_DELETE, { key, keyHash });
} catch (error) {
emit(CACHE_EVENT_ADAPTER_ERROR, {
key,
keyHash,
error,
reason: CACHE_CONTEXT_REASON_DELETE
});
throw error;
}
}
return {
query,
get,
set,
invalidate,
mutate,
explain,
stats: () => events.stats(),
on: (type, handler) => events.on(type, handler),
clear
};
function canServeStaleIfError(entry: CacheEnvelope<unknown>): boolean {
return clock.now() <= entry.meta.staleIfErrorUntil;
}
function computeState(entry: CacheEnvelope<unknown>): 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<string, number>,
current?: Record<string, number>
): 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<string, number>,
currentEpochs: Record<string, number>
): 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<string, number>, keys: string[]): Record<string, number> {
const out: Record<string, number> = {};
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;
}

@ -0,0 +1,34 @@
import { CACHE_ERROR_NAME_KEY, CACHE_ERROR_NAME_POLICY, CACHE_ERROR_NAME_SCOPE } from './consts.ts';
export class CacheKeyError extends Error {
constructor(message: string) {
super(message);
this.name = CACHE_ERROR_NAME_KEY;
}
}
export class CacheScopeError extends Error {
constructor(message: string) {
super(message);
this.name = CACHE_ERROR_NAME_SCOPE;
}
}
export class CachePolicyError extends Error {
constructor(message: string) {
super(message);
this.name = CACHE_ERROR_NAME_POLICY;
}
}
export function isCacheKeyError(error: unknown): error is CacheKeyError {
return error instanceof CacheKeyError;
}
export function isCacheScopeError(error: unknown): error is CacheScopeError {
return error instanceof CacheScopeError;
}
export function isCachePolicyError(error: unknown): error is CachePolicyError {
return error instanceof CachePolicyError;
}

@ -0,0 +1,64 @@
import { CACHE_EVENT_ALL } from './consts.ts';
import type {
CacheEvent,
CacheEventHandler,
CacheEventSelector,
CacheEventType,
CacheStats
} from './types.ts';
export class CacheEvents {
readonly #handlers = new Map<CacheEventSelector, Set<CacheEventHandler>>();
readonly #counters: Record<string, number> = Object.create(null);
readonly #externalHandler: CacheEventHandler | undefined;
constructor(externalHandler?: CacheEventHandler) {
this.#externalHandler = externalHandler;
}
emit(event: CacheEvent): void {
this.#counters[event.type] = (this.#counters[event.type] ?? 0) + 1;
try {
this.#externalHandler?.(event);
} catch {
// Event observers must not change cache decisions.
}
for (const handler of this.#handlers.get(event.type) ?? []) {
try {
handler(event);
} catch {
// Event observers must not change cache decisions.
}
}
for (const handler of this.#handlers.get(CACHE_EVENT_ALL) ?? []) {
try {
handler(event);
} catch {
// Event observers must not change cache decisions.
}
}
}
on(type: CacheEventType | typeof CACHE_EVENT_ALL, handler: CacheEventHandler): () => void {
let set = this.#handlers.get(type);
if (!set) {
set = new Set();
this.#handlers.set(type, set);
}
set.add(handler);
return () => {
set?.delete(handler);
if (set?.size === 0) this.#handlers.delete(type);
};
}
stats(): CacheStats {
return {
counters: { ...this.#counters }
};
}
}

@ -0,0 +1,63 @@
export { createCacheRuntime } from './engine.ts';
export { memoryCacheAdapter } from './adapters/memory.ts';
export { storageCacheAdapter, createMapStorage } from './adapters/storage.ts';
export {
normalizeKey,
normalizeKeyPrefixes,
normalizeTag,
normalizeTags,
stableHash,
stableStringify
} from './key.ts';
export { defaultCachePolicies, durationToMs } from './policy.ts';
export { resolveScope } from './scope.ts';
export {
CacheKeyError,
CachePolicyError,
CacheScopeError,
isCacheKeyError,
isCachePolicyError,
isCacheScopeError
} from './errors.ts';
export * from './consts.ts';
export type {
CacheAdapter,
CacheAdapterSetOptions,
CacheClock,
CacheDecision,
CacheDecisionAction,
CacheDecisionReason,
CacheEnvelope,
CacheEnvelopeState,
CacheEvent,
CacheEventHandler,
CacheEventSelector,
CacheEventType,
CacheExplain,
CacheKey,
CachePolicy,
CacheReadMode,
CacheRuntime,
CacheRuntimeConfig,
CacheScopeInput,
CacheScopeMode,
CacheStats,
CacheTagLike,
CacheTagObject,
CustomCacheScope,
ExplainOptions,
GetOptions,
InvalidateOptions,
MemoryCacheEvictReason,
MutateOptions,
QueryOptions,
ResolvedCachePolicy,
ResolvedCacheScope,
ResolvedScopeValues,
ScopeResolver,
SetOptions
} from './types.ts';
export type { MemoryCacheAdapter, MemoryCacheAdapterOptions } from './adapters/memory.ts';
export type { StorageCacheAdapterOptions, StorageLike } from './adapters/storage.ts';

@ -0,0 +1,239 @@
import {
CACHE_CANONICAL_ROOT_PATH,
CACHE_ERROR_MESSAGES,
CACHE_KEY_ENTRIES_FIELD,
CACHE_KEY_TYPE_BIGINT,
CACHE_KEY_TYPE_DATE,
CACHE_KEY_TYPE_FIELD,
CACHE_KEY_TYPE_MAP,
CACHE_KEY_TYPE_SET,
CACHE_KEY_TYPE_UNDEFINED,
CACHE_KEY_TYPE_URL_SEARCH_PARAMS,
CACHE_KEY_VALUE_FIELD,
CACHE_KEY_VALUES_FIELD,
CACHE_SCOPE_CUSTOM
} from './consts.ts';
import { CacheKeyError } from './errors.ts';
import type { CacheKey, CacheTagLike } from './types.ts';
type NormalizedJson =
| null
| string
| number
| boolean
| NormalizedJson[]
| { [key: string]: NormalizedJson };
export type NormalizedKey = {
canonical: string;
hash: string;
normalized: NormalizedJson;
};
export function stableHash(input: string): string {
// cyrb53: compact, deterministic, non-cryptographic 53-bit hash.
let h1 = 0xdeadbeef ^ input.length;
let h2 = 0x41c6ce57 ^ input.length;
for (let i = 0; i < input.length; i += 1) {
const ch = input.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
const n = 4294967296 * (2097151 & h2) + (h1 >>> 0);
return n.toString(36);
}
export function stableStringify(value: unknown): string {
return JSON.stringify(normalizeValue(value, new WeakSet(), CACHE_CANONICAL_ROOT_PATH));
}
export function normalizeKey(key: CacheKey): NormalizedKey {
if (!Array.isArray(key)) {
throw new CacheKeyError(CACHE_ERROR_MESSAGES.KEY_MUST_BE_ARRAY);
}
const normalized = normalizeValue(
key,
new WeakSet(),
CACHE_CANONICAL_ROOT_PATH
) as NormalizedJson;
const canonical = JSON.stringify(normalized);
return {
canonical,
hash: stableHash(canonical),
normalized
};
}
export function normalizeKeyPrefixes(key: CacheKey): NormalizedKey[] {
if (!Array.isArray(key)) {
throw new CacheKeyError(CACHE_ERROR_MESSAGES.KEY_MUST_BE_ARRAY);
}
const prefixes: NormalizedKey[] = [];
for (let i = 1; i <= key.length; i += 1) {
prefixes.push(normalizeKey(key.slice(0, i)));
}
return prefixes;
}
export function normalizeTag(tag: CacheTagLike): string {
if (typeof tag === 'string') {
const trimmed = tag.trim();
if (!trimmed) {
throw new CacheKeyError(CACHE_ERROR_MESSAGES.TAG_STRING_EMPTY);
}
return trimmed;
}
if (!tag || typeof tag !== 'object') {
throw new CacheKeyError(CACHE_ERROR_MESSAGES.TAG_SHAPE_INVALID);
}
const type = String(tag.type ?? '').trim();
if (!type) {
throw new CacheKeyError(CACHE_ERROR_MESSAGES.TAG_TYPE_REQUIRED);
}
if (!Object.prototype.hasOwnProperty.call(tag, 'id') || tag.id === undefined) {
return type;
}
const id = tag.id === null ? 'null' : String(tag.id);
return `${type}:${id}`;
}
export function normalizeTags(tags?: CacheTagLike[]): string[] {
if (!tags?.length) return [];
return Array.from(new Set(tags.map(normalizeTag))).sort();
}
function normalizeValue(value: unknown, seen: WeakSet<object>, path: string): NormalizedJson {
if (value === null) return null;
const type = typeof value;
if (type === 'string' || type === 'boolean') return value as string | boolean;
if (type === 'number') {
if (!Number.isFinite(value)) {
throw new CacheKeyError(CACHE_ERROR_MESSAGES.KEY_NUMBER_NOT_FINITE(path));
}
return value as number;
}
if (type === 'bigint') {
return {
[CACHE_KEY_TYPE_FIELD]: CACHE_KEY_TYPE_BIGINT,
[CACHE_KEY_VALUE_FIELD]: (value as bigint).toString()
};
}
if (type === 'undefined') {
return { [CACHE_KEY_TYPE_FIELD]: CACHE_KEY_TYPE_UNDEFINED };
}
if (type === 'function') {
throw new CacheKeyError(CACHE_ERROR_MESSAGES.KEY_FUNCTION_UNSERIALIZABLE(path));
}
if (type === 'symbol') {
throw new CacheKeyError(CACHE_ERROR_MESSAGES.KEY_SYMBOL_UNSERIALIZABLE(path));
}
if (typeof value !== 'object') {
throw new CacheKeyError(CACHE_ERROR_MESSAGES.KEY_UNSUPPORTED_VALUE(path));
}
if (seen.has(value)) {
throw new CacheKeyError(CACHE_ERROR_MESSAGES.KEY_CIRCULAR(path));
}
seen.add(value);
try {
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) {
throw new CacheKeyError(CACHE_ERROR_MESSAGES.KEY_INVALID_DATE(path));
}
return {
[CACHE_KEY_TYPE_FIELD]: CACHE_KEY_TYPE_DATE,
[CACHE_KEY_VALUE_FIELD]: value.toISOString()
};
}
if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) {
const entries = Array.from(value.entries()).sort(([aKey, aVal], [bKey, bVal]) => {
const keyCmp = aKey.localeCompare(bKey);
return keyCmp === 0 ? aVal.localeCompare(bVal) : keyCmp;
});
return {
[CACHE_KEY_TYPE_FIELD]: CACHE_KEY_TYPE_URL_SEARCH_PARAMS,
[CACHE_KEY_ENTRIES_FIELD]: entries as unknown as NormalizedJson
};
}
if (Array.isArray(value)) {
return value.map((item, index) => normalizeValue(item, seen, `${path}[${index}]`));
}
if (value instanceof Set) {
const items = Array.from(value.values()).map((item, index) =>
normalizeValue(item, seen, `${path}.${CACHE_KEY_TYPE_SET}(${index})`)
);
items.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
return { [CACHE_KEY_TYPE_FIELD]: CACHE_KEY_TYPE_SET, [CACHE_KEY_VALUES_FIELD]: items };
}
if (value instanceof Map) {
const entries = Array.from(value.entries()).map(([entryKey, entryValue], index) => {
const normalizedEntryKey = normalizeValue(
entryKey,
seen,
`${path}.${CACHE_KEY_TYPE_MAP}Key(${index})`
);
const normalizedEntryValue = normalizeValue(
entryValue,
seen,
`${path}.${CACHE_KEY_TYPE_MAP}Value(${index})`
);
return [normalizedEntryKey, normalizedEntryValue] as [NormalizedJson, NormalizedJson];
});
entries.sort(([a], [b]) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
return {
[CACHE_KEY_TYPE_FIELD]: CACHE_KEY_TYPE_MAP,
[CACHE_KEY_ENTRIES_FIELD]: entries as unknown as NormalizedJson
};
}
if (!isPlainObject(value)) {
throw new CacheKeyError(CACHE_ERROR_MESSAGES.KEY_OBJECT_NOT_PLAIN(path));
}
const out: Record<string, NormalizedJson> = {};
for (const key of Object.keys(value).sort()) {
const item = (value as Record<string, unknown>)[key];
if (item === undefined) {
continue;
}
out[key] = normalizeValue(item, seen, `${path}.${key}`);
}
return out;
} finally {
seen.delete(value);
}
}
function isPlainObject(value: object): boolean {
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
export function scopeDebugString(mode: string): string {
return mode === CACHE_SCOPE_CUSTOM ? CACHE_SCOPE_CUSTOM : mode;
}

@ -0,0 +1,156 @@
import {
CACHE_DURATION_UNIT_DAY,
CACHE_DURATION_UNIT_HOUR,
CACHE_DURATION_UNIT_MINUTE,
CACHE_DURATION_UNIT_MS,
CACHE_DURATION_UNIT_SECOND,
CACHE_ERROR_MESSAGES,
CACHE_MS_DAY,
CACHE_MS_HOUR,
CACHE_MS_MINUTE,
CACHE_MS_SECOND,
CACHE_POLICY_CATALOG,
CACHE_POLICY_IMMUTABLE,
CACHE_POLICY_INTERACTIVE,
CACHE_POLICY_PRIVATE_SESSION,
CACHE_POLICY_REALTIME,
CACHE_READ_MODE_CACHE_FIRST,
CACHE_READ_MODE_MUST_REVALIDATE,
CACHE_READ_MODE_STALE_WHILE_REVALIDATE,
DEFAULT_CACHE_POLICY
} from './consts.ts';
import { CachePolicyError } from './errors.ts';
import type { CachePolicy, CacheReadMode, DurationInput, ResolvedCachePolicy } from './types.ts';
export function defaultCachePolicies(): Record<string, CachePolicy> {
return {
[CACHE_POLICY_REALTIME]: {
freshFor: 0,
staleFor: 0,
staleIfErrorFor: 0,
gcAfter: CACHE_MS_MINUTE,
mode: CACHE_READ_MODE_MUST_REVALIDATE,
persist: false
},
[CACHE_POLICY_INTERACTIVE]: {
freshFor: 30 * CACHE_MS_SECOND,
staleFor: 5 * CACHE_MS_MINUTE,
staleIfErrorFor: 30 * CACHE_MS_MINUTE,
gcAfter: 30 * CACHE_MS_MINUTE,
mode: CACHE_READ_MODE_STALE_WHILE_REVALIDATE,
persist: true
},
[CACHE_POLICY_CATALOG]: {
freshFor: 10 * CACHE_MS_MINUTE,
staleFor: CACHE_MS_DAY,
staleIfErrorFor: 7 * CACHE_MS_DAY,
gcAfter: 7 * CACHE_MS_DAY,
mode: CACHE_READ_MODE_STALE_WHILE_REVALIDATE,
persist: true
},
[CACHE_POLICY_PRIVATE_SESSION]: {
freshFor: 10 * CACHE_MS_SECOND,
staleFor: CACHE_MS_MINUTE,
staleIfErrorFor: 0,
gcAfter: 15 * CACHE_MS_MINUTE,
mode: CACHE_READ_MODE_MUST_REVALIDATE,
persist: false
},
[CACHE_POLICY_IMMUTABLE]: {
freshFor: Number.MAX_SAFE_INTEGER,
staleFor: Number.MAX_SAFE_INTEGER,
staleIfErrorFor: Number.MAX_SAFE_INTEGER,
gcAfter: Number.MAX_SAFE_INTEGER,
mode: CACHE_READ_MODE_CACHE_FIRST,
persist: true
}
};
}
export function resolvePolicy(input: {
policy?: string | CachePolicy;
policies?: Record<string, CachePolicy>;
defaultPolicy?: string | CachePolicy;
mode?: CacheReadMode;
persist?: boolean;
}): ResolvedCachePolicy {
const namedPolicies = {
...defaultCachePolicies(),
...(input.policies ?? {})
};
const selected = input.policy ?? input.defaultPolicy ?? DEFAULT_CACHE_POLICY;
let raw: CachePolicy;
let name: string | undefined;
if (typeof selected === 'string') {
name = selected;
const named = namedPolicies[selected];
if (!named) throw new CachePolicyError(CACHE_ERROR_MESSAGES.POLICY_UNKNOWN(selected));
raw = named;
} else {
raw = selected;
}
const merged: CachePolicy = {
...DEFAULT_CACHE_POLICY,
...raw,
mode: input.mode ?? raw.mode ?? DEFAULT_CACHE_POLICY.mode,
persist: input.persist ?? raw.persist ?? DEFAULT_CACHE_POLICY.persist
};
const freshForMs = durationToMs(merged.freshFor, 'freshFor');
const staleForMs = durationToMs(merged.staleFor ?? 0, 'staleFor');
const staleIfErrorForMs = durationToMs(merged.staleIfErrorFor ?? 0, 'staleIfErrorFor');
const gcAfterMs = durationToMs(
merged.gcAfter ?? Math.max(freshForMs + staleForMs, freshForMs + staleIfErrorForMs),
'gcAfter'
);
if (freshForMs < 0 || staleForMs < 0 || staleIfErrorForMs < 0 || gcAfterMs < 0) {
throw new CachePolicyError(CACHE_ERROR_MESSAGES.POLICY_DURATION_NEGATIVE);
}
return {
...(name ? { name } : {}),
freshForMs,
staleForMs,
staleIfErrorForMs,
gcAfterMs,
mode: merged.mode ?? CACHE_READ_MODE_STALE_WHILE_REVALIDATE,
persist: merged.persist ?? true
};
}
export function durationToMs(value: DurationInput, fieldName = 'duration'): number {
if (typeof value === 'number') {
if (!Number.isFinite(value)) {
throw new CachePolicyError(CACHE_ERROR_MESSAGES.POLICY_DURATION_NOT_FINITE(fieldName));
}
return value;
}
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(value);
if (!match) {
throw new CachePolicyError(CACHE_ERROR_MESSAGES.POLICY_DURATION_INVALID(fieldName));
}
const amount = Number(match[1]);
const unit = match[2];
switch (unit) {
case CACHE_DURATION_UNIT_MS:
return amount;
case CACHE_DURATION_UNIT_SECOND:
return amount * CACHE_MS_SECOND;
case CACHE_DURATION_UNIT_MINUTE:
return amount * CACHE_MS_MINUTE;
case CACHE_DURATION_UNIT_HOUR:
return amount * CACHE_MS_HOUR;
case CACHE_DURATION_UNIT_DAY:
return amount * CACHE_MS_DAY;
default:
throw new CachePolicyError(CACHE_ERROR_MESSAGES.POLICY_DURATION_UNIT_INVALID(fieldName));
}
}

@ -0,0 +1,157 @@
import {
CACHE_ERROR_MESSAGES,
CACHE_SCOPE_ACTOR,
CACHE_SCOPE_CUSTOM,
CACHE_SCOPE_FIELD_ACTOR_ID,
CACHE_SCOPE_FIELD_LOCALE,
CACHE_SCOPE_FIELD_PERMISSION_HASH,
CACHE_SCOPE_FIELD_TENANT_ID,
CACHE_SCOPE_PERMISSION,
CACHE_SCOPE_PUBLIC,
CACHE_SCOPE_TENANT
} from './consts.ts';
import { CacheScopeError } from './errors.ts';
import { stableHash, stableStringify } from './key.ts';
import type {
CacheScopeInput,
PrimitiveScopeValue,
ResolvedCacheScope,
ResolvedScopeValues
} from './types.ts';
export async function resolveScope(input: {
scope: CacheScopeInput;
values?: ResolvedScopeValues | Promise<ResolvedScopeValues>;
}): Promise<ResolvedCacheScope> {
const values = await (input.values ?? {});
const scope = input.scope;
if (typeof scope !== 'string') {
if (scope.mode !== CACHE_SCOPE_CUSTOM) {
throw new CacheScopeError(CACHE_ERROR_MESSAGES.SCOPE_CUSTOM_MODE_REQUIRED);
}
const customValues = cleanValues(scope.values);
if (!Object.keys(customValues).length) {
throw new CacheScopeError(CACHE_ERROR_MESSAGES.SCOPE_CUSTOM_VALUES_REQUIRED);
}
const canonical = stableStringify({ mode: CACHE_SCOPE_CUSTOM, values: customValues });
return {
mode: CACHE_SCOPE_CUSTOM,
hash: stableHash(canonical),
values: customValues
};
}
const cleaned = cleanValues(values);
const scopedValues: Record<string, PrimitiveScopeValue> = {};
if (
cleaned[CACHE_SCOPE_FIELD_LOCALE] !== undefined &&
cleaned[CACHE_SCOPE_FIELD_LOCALE] !== null
) {
scopedValues[CACHE_SCOPE_FIELD_LOCALE] = cleaned[CACHE_SCOPE_FIELD_LOCALE];
}
switch (scope) {
case CACHE_SCOPE_PUBLIC: {
const canonical = stableStringify({ mode: CACHE_SCOPE_PUBLIC, values: scopedValues });
return {
mode: CACHE_SCOPE_PUBLIC,
hash: stableHash(canonical),
values: scopedValues
};
}
case CACHE_SCOPE_TENANT: {
requireValue(
cleaned[CACHE_SCOPE_FIELD_TENANT_ID],
CACHE_SCOPE_FIELD_TENANT_ID,
CACHE_SCOPE_TENANT
);
scopedValues[CACHE_SCOPE_FIELD_TENANT_ID] = cleaned[CACHE_SCOPE_FIELD_TENANT_ID];
const canonical = stableStringify({ mode: CACHE_SCOPE_TENANT, values: scopedValues });
return {
mode: CACHE_SCOPE_TENANT,
hash: stableHash(canonical),
values: scopedValues
};
}
case CACHE_SCOPE_ACTOR: {
requireValue(
cleaned[CACHE_SCOPE_FIELD_ACTOR_ID],
CACHE_SCOPE_FIELD_ACTOR_ID,
CACHE_SCOPE_ACTOR
);
if (
cleaned[CACHE_SCOPE_FIELD_TENANT_ID] !== undefined &&
cleaned[CACHE_SCOPE_FIELD_TENANT_ID] !== null
) {
scopedValues[CACHE_SCOPE_FIELD_TENANT_ID] = cleaned[CACHE_SCOPE_FIELD_TENANT_ID];
}
scopedValues[CACHE_SCOPE_FIELD_ACTOR_ID] = cleaned[CACHE_SCOPE_FIELD_ACTOR_ID];
const canonical = stableStringify({ mode: CACHE_SCOPE_ACTOR, values: scopedValues });
return {
mode: CACHE_SCOPE_ACTOR,
hash: stableHash(canonical),
values: scopedValues
};
}
case CACHE_SCOPE_PERMISSION: {
requireValue(
cleaned[CACHE_SCOPE_FIELD_ACTOR_ID],
CACHE_SCOPE_FIELD_ACTOR_ID,
CACHE_SCOPE_PERMISSION
);
requireValue(
cleaned[CACHE_SCOPE_FIELD_PERMISSION_HASH],
CACHE_SCOPE_FIELD_PERMISSION_HASH,
CACHE_SCOPE_PERMISSION
);
if (
cleaned[CACHE_SCOPE_FIELD_TENANT_ID] !== undefined &&
cleaned[CACHE_SCOPE_FIELD_TENANT_ID] !== null
) {
scopedValues[CACHE_SCOPE_FIELD_TENANT_ID] = cleaned[CACHE_SCOPE_FIELD_TENANT_ID];
}
scopedValues[CACHE_SCOPE_FIELD_ACTOR_ID] = cleaned[CACHE_SCOPE_FIELD_ACTOR_ID];
scopedValues[CACHE_SCOPE_FIELD_PERMISSION_HASH] = cleaned[CACHE_SCOPE_FIELD_PERMISSION_HASH];
const canonical = stableStringify({ mode: CACHE_SCOPE_PERMISSION, values: scopedValues });
return {
mode: CACHE_SCOPE_PERMISSION,
hash: stableHash(canonical),
values: scopedValues
};
}
default:
throw new CacheScopeError(CACHE_ERROR_MESSAGES.SCOPE_INVALID_MODE(scope));
}
}
function requireValue(
value: PrimitiveScopeValue,
name: string,
mode: string
): asserts value is Exclude<PrimitiveScopeValue, null | undefined> {
if (value === undefined || value === null || value === '') {
throw new CacheScopeError(CACHE_ERROR_MESSAGES.SCOPE_REQUIRED_VALUE(mode, name));
}
}
function cleanValues(
values: Record<string, PrimitiveScopeValue>
): Record<string, PrimitiveScopeValue> {
const out: Record<string, PrimitiveScopeValue> = {};
for (const key of Object.keys(values).sort()) {
const value = values[key];
if (value === undefined) continue;
if (!['string', 'number', 'boolean'].includes(typeof value) && value !== null) {
throw new CacheScopeError(CACHE_ERROR_MESSAGES.SCOPE_VALUE_INVALID(key));
}
out[key] = value;
}
return out;
}

@ -0,0 +1,27 @@
export class Singleflight {
readonly #inFlight = new Map<string, Promise<unknown>>();
readonly #onJoin: ((key: string) => void) | undefined;
constructor(onJoin?: (key: string) => void) {
this.#onJoin = onJoin;
}
run<T>(key: string, fn: () => Promise<T>): Promise<T> {
const existing = this.#inFlight.get(key);
if (existing) {
this.#onJoin?.(key);
return existing as Promise<T>;
}
const promise = fn().finally(() => {
this.#inFlight.delete(key);
});
this.#inFlight.set(key, promise);
return promise;
}
has(key: string): boolean {
return this.#inFlight.has(key);
}
}

@ -0,0 +1,310 @@
import type {
CACHE_DECISION_ACTIONS,
CACHE_DECISION_REASONS,
CACHE_ENVELOPE_STATES,
CACHE_EVENTS,
CACHE_EVENT_ALL,
CACHE_MEMORY_EVICT_REASONS,
CACHE_READ_MODES,
CACHE_SCOPE_CUSTOM,
CACHE_SCOPE_MODES
} from './consts.ts';
export type CacheKey = readonly unknown[];
export type DurationInput =
| number
| `${number}ms`
| `${number}s`
| `${number}m`
| `${number}h`
| `${number}d`;
export type CacheReadMode = (typeof CACHE_READ_MODES)[number];
export type CacheScopeMode = (typeof CACHE_SCOPE_MODES)[number];
export type PrimitiveScopeValue = string | number | boolean | null | undefined;
export type ResolvedScopeValues = {
tenantId?: string | number | null;
actorId?: string | number | null;
permissionHash?: string | number | null;
locale?: string | number | null;
[key: string]: PrimitiveScopeValue;
};
export type CustomCacheScope = {
mode: typeof CACHE_SCOPE_CUSTOM;
values: Record<string, PrimitiveScopeValue>;
};
export type CacheScopeInput = CacheScopeMode | CustomCacheScope;
export type ResolvedCacheScope = {
mode: CacheScopeMode | typeof CACHE_SCOPE_CUSTOM;
hash: string;
values: Record<string, PrimitiveScopeValue>;
};
export type ScopeResolver = () => ResolvedScopeValues | Promise<ResolvedScopeValues>;
export type CacheTagObject = {
type: string;
id?: string | number | boolean | null;
};
export type CacheTagLike = string | CacheTagObject;
export type CachePolicy = {
freshFor: DurationInput;
staleFor?: DurationInput;
staleIfErrorFor?: DurationInput;
gcAfter?: DurationInput;
mode?: CacheReadMode;
persist?: boolean;
};
export type ResolvedCachePolicy = {
name?: string;
freshForMs: number;
staleForMs: number;
staleIfErrorForMs: number;
gcAfterMs: number;
mode: CacheReadMode;
persist: boolean;
};
export type CacheEnvelopeState = (typeof CACHE_ENVELOPE_STATES)[number];
export type CacheEnvelope<T = unknown> = {
value: T;
meta: {
key: string;
keyHash: string;
keyCanonical: string;
keyParts: unknown[];
namespace: string;
engineVersion: string;
schemaVersion: string;
scope: ResolvedCacheScope;
createdAt: number;
updatedAt: number;
freshUntil: number;
staleUntil: number;
staleIfErrorUntil: number;
gcAfter: number;
state: CacheEnvelopeState;
tags: string[];
tagEpochs: Record<string, number>;
prefixEpochs: Record<string, number>;
sizeBytes?: number;
hitCount: number;
lastAccessedAt: number;
};
};
export type CacheAdapterSetOptions = {
ttlMs?: number;
persist?: boolean;
};
export interface CacheAdapter {
readonly name: string;
get<T>(key: string): Promise<CacheEnvelope<T> | null>;
set<T>(key: string, value: CacheEnvelope<T>, options?: CacheAdapterSetOptions): Promise<void>;
delete(key: string): Promise<void>;
getEpoch(key: string): Promise<number>;
bumpEpoch(key: string): Promise<number>;
getManyEpochs?(keys: string[]): Promise<Record<string, number>>;
clear?(): Promise<void>;
}
export type CacheDecisionAction = (typeof CACHE_DECISION_ACTIONS)[number];
export type CacheDecisionReason = (typeof CACHE_DECISION_REASONS)[number];
export type CacheDecision = {
action: CacheDecisionAction;
reason: CacheDecisionReason;
state?: CacheEnvelopeState;
currentTagEpochs?: Record<string, number>;
currentPrefixEpochs?: Record<string, number>;
changedTags?: string[];
changedPrefixes?: string[];
};
export type CacheEventType = (typeof CACHE_EVENTS)[number];
export type CacheEventSelector = CacheEventType | typeof CACHE_EVENT_ALL;
export type CacheEvent = {
type: CacheEventType;
namespace: string;
adapter: string;
policy?: string;
scopeMode?: string;
key?: string;
keyHash?: string;
reason?: string;
tags?: string[];
error?: unknown;
at: number;
};
export type CacheEventHandler = (event: CacheEvent) => void;
export type CacheStats = {
counters: Record<string, number>;
};
export type QueryOptions<T> = {
key: CacheKey;
fetcher: () => Promise<T>;
scope: CacheScopeInput;
policy?: string | CachePolicy;
mode?: CacheReadMode;
tags?: CacheTagLike[];
schemaVersion?: string;
persist?: boolean;
};
export type GetOptions = {
scope: CacheScopeInput;
policy?: string | CachePolicy;
mode?: CacheReadMode;
schemaVersion?: string;
};
export type SetOptions = {
scope: CacheScopeInput;
policy?: string | CachePolicy;
mode?: CacheReadMode;
tags?: CacheTagLike[];
schemaVersion?: string;
persist?: boolean;
};
export type InvalidateByKey = {
key: CacheKey;
scope: CacheScopeInput;
};
export type InvalidateByKeyPrefix = {
keyPrefix: CacheKey;
scope: CacheScopeInput;
};
export type InvalidateByTag = {
tag: CacheTagLike;
scope: CacheScopeInput;
};
export type InvalidateOptions = InvalidateByKey | InvalidateByKeyPrefix | InvalidateByTag;
export type MutateUpdate<T = unknown> = {
key: CacheKey;
scope: CacheScopeInput;
reducer: (oldValue: T) => T | Promise<T>;
policy?: string | CachePolicy;
mode?: CacheReadMode;
tags?: CacheTagLike[];
schemaVersion?: string;
persist?: boolean;
};
export type MutateInvalidation =
| { key: CacheKey; scope: CacheScopeInput }
| { keyPrefix: CacheKey; scope: CacheScopeInput }
| { tag: CacheTagLike; scope: CacheScopeInput };
export type MutateOptions<R = unknown> = {
commit: () => Promise<R>;
update?: MutateUpdate[];
invalidate?: MutateInvalidation[];
};
export type ExplainOptions = {
scope: CacheScopeInput;
policy?: string | CachePolicy;
mode?: CacheReadMode;
schemaVersion?: string;
};
export type ExplainEpochLine = {
key: string;
entryEpoch: number;
currentEpoch: number;
valid: boolean;
};
export type CacheExplain = {
key: string;
keyHash: string;
exists: boolean;
adapter: string;
state?: CacheEnvelopeState;
decision: CacheDecisionAction;
reason: CacheDecisionReason;
schemaVersion?: {
expected: string;
actual?: string;
valid: boolean;
};
scope?: ResolvedCacheScope;
timing?: {
createdAt: number;
updatedAt: number;
freshUntil: number;
staleUntil: number;
staleIfErrorUntil: number;
gcAfter: number;
ageMs: number;
};
epochs?: {
tags: ExplainEpochLine[];
prefixes: ExplainEpochLine[];
};
};
export interface CacheClock {
now(): number;
}
export type CacheRuntimeConfig = {
namespace?: string;
version?: string;
adapter: CacheAdapter;
scopeResolver?: ScopeResolver;
policies?: Record<string, CachePolicy>;
defaultPolicy?: string | CachePolicy;
clock?: CacheClock;
onEvent?: CacheEventHandler;
};
export type CacheRuntime = {
query<T>(options: QueryOptions<T>): Promise<T>;
get<T>(key: CacheKey, options: GetOptions): Promise<T | undefined>;
set<T>(key: CacheKey, value: T, options: SetOptions): Promise<void>;
invalidate(options: InvalidateOptions): Promise<void>;
mutate<R = unknown>(options: MutateOptions<R>): Promise<R>;
explain(key: CacheKey, options: ExplainOptions): Promise<CacheExplain>;
stats(): CacheStats;
on(type: CacheEventSelector, handler: CacheEventHandler): () => void;
clear(): Promise<void>;
};
export type MemoryCacheEvictReason = (typeof CACHE_MEMORY_EVICT_REASONS)[number];

@ -0,0 +1 @@
export * from './perm.ts';

@ -0,0 +1,54 @@
import type { ResourceRef } from '$libs/perm';
export const PERMISSION_CLIENT_PATH_CHECK = '/check';
export const PERMISSION_CLIENT_PATH_BATCH = '/batch';
export const PERMISSION_CLIENT_PATH_WHAT = '/what';
export const PERMISSION_CLIENT_PATH_EXPLAIN = '/explain';
export const PERMISSION_HTTP_STATUS_OK = 200;
export const PERMISSION_HTTP_STATUS_BAD_REQUEST = 400;
export const PERMISSION_HTTP_STATUS_FORBIDDEN = 403;
export const PERMISSION_HTTP_CREDENTIALS_INCLUDE = 'include';
export const PERMISSION_REQUEST_FIELD_ACTION = 'action';
export const PERMISSION_REQUEST_FIELD_RESOURCE = 'resource';
export const PERMISSION_REQUEST_FIELD_CONTEXT = 'context';
export const PERMISSION_REQUEST_FIELD_CHECKS = 'checks';
export const PERMISSION_REQUEST_FIELD_ACTIONS = 'actions';
export const PERMISSION_RESPONSE_FIELD_DECISIONS = 'decisions';
export const PERMISSION_RESPONSE_FIELD_ACTIONS = 'actions';
export const PERMISSION_CLIENT_KEY_SEPARATOR = ':';
export const PERMISSION_CLIENT_KEY_GLOBAL = 'global';
export const PERMISSION_CLIENT_KEY_NONE = 'none';
export const PERMISSION_CLIENT_CONTEXT_EMPTY = '';
export interface PermissionRemoteCheckInput {
readonly action: string;
readonly resource?: ResourceRef;
readonly context?: Record<string, unknown>;
}
export function stablePermissionStringify(input: unknown): string {
if (input === undefined) return PERMISSION_CLIENT_CONTEXT_EMPTY;
if (input === null || typeof input !== 'object') return JSON.stringify(input);
if (Array.isArray(input)) return `[${input.map(stablePermissionStringify).join(',')}]`;
const sorted = Object.entries(input as Record<string, unknown>).sort(([a], [b]) =>
a.localeCompare(b)
);
return `{${sorted
.map(([key, value]) => `${JSON.stringify(key)}:${stablePermissionStringify(value)}`)
.join(',')}}`;
}
export function permissionDecisionKey(input: PermissionRemoteCheckInput): string {
const resource = input.resource
? `${input.resource.type}${PERMISSION_CLIENT_KEY_SEPARATOR}${input.resource.id ?? PERMISSION_CLIENT_KEY_NONE}`
: PERMISSION_CLIENT_KEY_GLOBAL;
return [
resource,
input.action,
input.context ? stablePermissionStringify(input.context) : PERMISSION_CLIENT_CONTEXT_EMPTY
].join(PERMISSION_CLIENT_KEY_SEPARATOR);
}

@ -0,0 +1,67 @@
import {
CACHE_EVENT_ADAPTER_ERROR,
CACHE_EVENT_DELETE,
CACHE_EVENT_EVICTION,
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,
type CacheEventType
} from '$libs/cach';
export const LOGGER_CATEGORY = 'cach';
export const CACHE_LOG_MESSAGE_HIT = 'cache hit';
export const CACHE_LOG_MESSAGE_MISS = 'cache miss';
export const CACHE_LOG_MESSAGE_STALE_HIT = 'cache stale hit';
export const CACHE_LOG_MESSAGE_REFRESH_START = 'cache refresh started';
export const CACHE_LOG_MESSAGE_REFRESH_SUCCESS = 'cache refresh succeeded';
export const CACHE_LOG_MESSAGE_REFRESH_ERROR = 'cache refresh failed';
export const CACHE_LOG_MESSAGE_STALE_IF_ERROR = 'cache stale fallback after error';
export const CACHE_LOG_MESSAGE_INVALIDATE = 'cache invalidated';
export const CACHE_LOG_MESSAGE_SCHEMA_MISMATCH = 'cache schema mismatch';
export const CACHE_LOG_MESSAGE_SCOPE_ERROR = 'cache scope error';
export const CACHE_LOG_MESSAGE_SINGLEFLIGHT_JOIN = 'cache singleflight joined';
export const CACHE_LOG_MESSAGE_EVICTION = 'cache entry evicted';
export const CACHE_LOG_MESSAGE_ADAPTER_ERROR = 'cache adapter error';
export const CACHE_LOG_MESSAGE_SET = 'cache entry stored';
export const CACHE_LOG_MESSAGE_DELETE = 'cache entry deleted';
export const CACHE_LOG_MESSAGE_BY_EVENT: Record<CacheEventType, string> = {
[CACHE_EVENT_HIT]: CACHE_LOG_MESSAGE_HIT,
[CACHE_EVENT_MISS]: CACHE_LOG_MESSAGE_MISS,
[CACHE_EVENT_STALE_HIT]: CACHE_LOG_MESSAGE_STALE_HIT,
[CACHE_EVENT_REFRESH_START]: CACHE_LOG_MESSAGE_REFRESH_START,
[CACHE_EVENT_REFRESH_SUCCESS]: CACHE_LOG_MESSAGE_REFRESH_SUCCESS,
[CACHE_EVENT_REFRESH_ERROR]: CACHE_LOG_MESSAGE_REFRESH_ERROR,
[CACHE_EVENT_STALE_IF_ERROR]: CACHE_LOG_MESSAGE_STALE_IF_ERROR,
[CACHE_EVENT_INVALIDATE]: CACHE_LOG_MESSAGE_INVALIDATE,
[CACHE_EVENT_SCHEMA_MISMATCH]: CACHE_LOG_MESSAGE_SCHEMA_MISMATCH,
[CACHE_EVENT_SCOPE_ERROR]: CACHE_LOG_MESSAGE_SCOPE_ERROR,
[CACHE_EVENT_SINGLEFLIGHT_JOIN]: CACHE_LOG_MESSAGE_SINGLEFLIGHT_JOIN,
[CACHE_EVENT_EVICTION]: CACHE_LOG_MESSAGE_EVICTION,
[CACHE_EVENT_ADAPTER_ERROR]: CACHE_LOG_MESSAGE_ADAPTER_ERROR,
[CACHE_EVENT_SET]: CACHE_LOG_MESSAGE_SET,
[CACHE_EVENT_DELETE]: CACHE_LOG_MESSAGE_DELETE
};
export const CACHE_ERROR_NAME_DISPOSED = 'CachDisposedError';
export const CACHE_ERROR_MSG_DISPOSED_SUFFIX = '() called on a disposed cache engine';
export const CACHE_METHOD_QUERY = 'query';
export const CACHE_METHOD_GET = 'get';
export const CACHE_METHOD_SET = 'set';
export const CACHE_METHOD_INVALIDATE = 'invalidate';
export const CACHE_METHOD_MUTATE = 'mutate';
export const CACHE_METHOD_EXPLAIN = 'explain';
export const CACHE_METHOD_STATS = 'stats';
export const CACHE_METHOD_ON = 'on';
export const CACHE_METHOD_CLEAR = 'clear';

@ -0,0 +1,116 @@
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,
CACHE_METHOD_INVALIDATE,
CACHE_METHOD_MUTATE,
CACHE_METHOD_ON,
CACHE_METHOD_QUERY,
CACHE_METHOD_SET,
CACHE_METHOD_STATS,
LOGGER_CATEGORY
} from './consts.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 runtime = createCacheRuntime({
...options,
adapter: options.adapter ?? memoryCacheAdapter({ clock: options.clock }),
onEvent(event) {
options.onEvent?.(event);
logCacheEvent(options, event);
}
});
let disposed = false;
function ensureLive(method: string): void {
if (disposed) throw new CachDisposedError(disposedCacheMessage(method));
}
return {
query(queryOptions) {
ensureLive(CACHE_METHOD_QUERY);
return runtime.query(queryOptions);
},
get(key, getOptions) {
ensureLive(CACHE_METHOD_GET);
return runtime.get(key, getOptions);
},
set(key, value, setOptions) {
ensureLive(CACHE_METHOD_SET);
return runtime.set(key, value, setOptions);
},
invalidate(invalidateOptions) {
ensureLive(CACHE_METHOD_INVALIDATE);
return runtime.invalidate(invalidateOptions);
},
mutate(mutateOptions) {
ensureLive(CACHE_METHOD_MUTATE);
return runtime.mutate(mutateOptions);
},
explain(key, explainOptions) {
ensureLive(CACHE_METHOD_EXPLAIN);
return runtime.explain(key, explainOptions);
},
stats() {
ensureLive(CACHE_METHOD_STATS);
return runtime.stats();
},
on(type, handler) {
ensureLive(CACHE_METHOD_ON);
return runtime.on(type, handler);
},
clear() {
ensureLive(CACHE_METHOD_CLEAR);
return runtime.clear();
},
dispose() {
if (disposed) return;
disposed = true;
}
};
}
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?.warn?.(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 });
}

@ -0,0 +1,9 @@
import { CACHE_ERROR_NAME_DISPOSED } from './consts.ts';
export class CachDisposedError extends Error {
override readonly name = CACHE_ERROR_NAME_DISPOSED;
}
export function isCachDisposedError(error: unknown): error is CachDisposedError {
return error instanceof CachDisposedError;
}

@ -0,0 +1,5 @@
import { CACHE_ERROR_MSG_DISPOSED_SUFFIX } from './consts.ts';
export function disposedCacheMessage(method: string): string {
return `${method}${CACHE_ERROR_MSG_DISPOSED_SUFFIX}`;
}

@ -0,0 +1,121 @@
export { createEngineCache } from './engine-cache.ts';
export * from './consts.ts';
export * from './errors.ts';
export * from './helpers.ts';
export type { CacheLogger, EngineCache, EngineCacheOptions } from './types.ts';
export {
CACHE_ADAPTER_MEMORY,
CACHE_ADAPTER_STORAGE,
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_ENVELOPE_STATE_DEGRADED,
CACHE_ENVELOPE_STATE_EXPIRED,
CACHE_ENVELOPE_STATE_FRESH,
CACHE_ENVELOPE_STATE_INVALIDATED,
CACHE_ENVELOPE_STATE_STALE,
CACHE_EVENT_ADAPTER_ERROR,
CACHE_EVENT_ALL,
CACHE_EVENT_DELETE,
CACHE_EVENT_EVICTION,
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_POLICY_CATALOG,
CACHE_POLICY_IMMUTABLE,
CACHE_POLICY_INTERACTIVE,
CACHE_POLICY_PRIVATE_SESSION,
CACHE_POLICY_REALTIME,
CACHE_READ_MODE_BYPASS_CACHE,
CACHE_READ_MODE_CACHE_FIRST,
CACHE_READ_MODE_MUST_REVALIDATE,
CACHE_READ_MODE_NO_STORE,
CACHE_READ_MODE_STALE_WHILE_REVALIDATE,
CACHE_SCOPE_ACTOR,
CACHE_SCOPE_CUSTOM,
CACHE_SCOPE_PERMISSION,
CACHE_SCOPE_PUBLIC,
CACHE_SCOPE_TENANT,
CacheKeyError,
CachePolicyError,
CacheScopeError,
createMapStorage,
defaultCachePolicies,
durationToMs,
isCacheKeyError,
isCachePolicyError,
isCacheScopeError,
memoryCacheAdapter,
normalizeKey,
normalizeKeyPrefixes,
normalizeTag,
normalizeTags,
resolveScope,
stableHash,
stableStringify,
storageCacheAdapter
} from '$libs/cach';
export type {
CacheAdapter,
CacheAdapterSetOptions,
CacheClock,
CacheDecision,
CacheDecisionAction,
CacheDecisionReason,
CacheEnvelope,
CacheEnvelopeState,
CacheEvent,
CacheEventHandler,
CacheEventSelector,
CacheEventType,
CacheExplain,
CacheKey,
CachePolicy,
CacheReadMode,
CacheRuntime,
CacheRuntimeConfig,
CacheScopeInput,
CacheScopeMode,
CacheStats,
CacheTagLike,
CacheTagObject,
CustomCacheScope,
ExplainOptions,
GetOptions,
InvalidateOptions,
MemoryCacheAdapter,
MemoryCacheAdapterOptions,
MemoryCacheEvictReason,
MutateOptions,
QueryOptions,
ResolvedCachePolicy,
ResolvedCacheScope,
ResolvedScopeValues,
ScopeResolver,
SetOptions,
StorageCacheAdapterOptions,
StorageLike
} from '$libs/cach';

@ -0,0 +1,412 @@
import { describe, expect, it } from 'vitest';
import {
CACHE_EVENT_MISS,
CACHE_EVENT_SCOPE_ERROR,
CACHE_POLICY_INTERACTIVE,
CACHE_READ_MODE_MUST_REVALIDATE,
CACHE_READ_MODE_STALE_WHILE_REVALIDATE,
CACHE_SCOPE_ACTOR,
CACHE_SCOPE_PUBLIC,
createEngineCache,
createMapStorage,
isCachDisposedError,
memoryCacheAdapter,
storageCacheAdapter,
type CacheClock,
type CacheEvent
} from '$svrs/cach';
const TEST_POLICY_FAST = 'fast';
const TEST_POLICY_PRIVATE_ONLY = 'privateOnly';
const TEST_POLICY_RESILIENT = 'resilient';
const TEST_POLICY_STRICT = 'strict';
function testClock(initial = 0): CacheClock & { advance(ms: number): void } {
let now = initial;
return {
now: () => now,
advance(ms: number) {
now += ms;
}
};
}
function deferred<T>(): {
promise: Promise<T>;
resolve(value: T): void;
reject(error: unknown): void;
} {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('createEngineCache', () => {
it('serves a fresh cache hit without calling the fetcher again', async () => {
const clock = testClock();
const Cache = createEngineCache({
clock,
adapter: memoryCacheAdapter({ clock }),
defaultPolicy: CACHE_POLICY_INTERACTIVE
});
let calls = 0;
const first = await Cache.query({
key: ['project', 1],
scope: CACHE_SCOPE_PUBLIC,
fetcher: async () => {
calls += 1;
return { id: 1, name: 'Alpha' };
}
});
const second = await Cache.query({
key: ['project', 1],
scope: CACHE_SCOPE_PUBLIC,
fetcher: async () => {
calls += 1;
return { id: 1, name: 'Beta' };
}
});
expect(first).toEqual({ id: 1, name: 'Alpha' });
expect(second).toEqual({ id: 1, name: 'Alpha' });
expect(calls).toBe(1);
expect(Cache.stats().counters.hit).toBe(1);
});
it('deduplicates concurrent misses with singleflight', async () => {
const clock = testClock();
const Cache = createEngineCache({
clock,
adapter: memoryCacheAdapter({ clock }),
policies: {
[TEST_POLICY_STRICT]: {
freshFor: 30_000,
staleFor: 0,
staleIfErrorFor: 0,
gcAfter: 60_000,
mode: CACHE_READ_MODE_MUST_REVALIDATE
}
},
defaultPolicy: TEST_POLICY_STRICT
});
const pending = deferred<string>();
let calls = 0;
const first = Cache.query({
key: ['profile'],
scope: CACHE_SCOPE_PUBLIC,
fetcher: async () => {
calls += 1;
return pending.promise;
}
});
const second = Cache.query({
key: ['profile'],
scope: CACHE_SCOPE_PUBLIC,
fetcher: async () => {
calls += 1;
return pending.promise;
}
});
pending.resolve('ready');
await expect(Promise.all([first, second])).resolves.toEqual(['ready', 'ready']);
expect(calls).toBe(1);
expect(Cache.stats().counters.singleflightJoin).toBe(1);
});
it('serves stale data and refreshes in the background', async () => {
const clock = testClock();
const Cache = createEngineCache({
clock,
adapter: memoryCacheAdapter({ clock }),
policies: {
[TEST_POLICY_FAST]: {
freshFor: 10,
staleFor: 100,
staleIfErrorFor: 100,
gcAfter: 500,
mode: CACHE_READ_MODE_STALE_WHILE_REVALIDATE
}
},
defaultPolicy: TEST_POLICY_FAST
});
let value = 1;
await Cache.query({
key: ['counter'],
scope: CACHE_SCOPE_PUBLIC,
fetcher: async () => value
});
value = 2;
clock.advance(20);
const stale = await Cache.query({
key: ['counter'],
scope: CACHE_SCOPE_PUBLIC,
fetcher: async () => value
});
await Promise.resolve();
await Promise.resolve();
const fresh = await Cache.get<number>(['counter'], {
scope: CACHE_SCOPE_PUBLIC,
policy: TEST_POLICY_FAST
});
expect(stale).toBe(1);
expect(fresh).toBe(2);
expect(Cache.stats().counters.staleHit).toBe(1);
});
it('invalidates by tag epoch without scanning entries', async () => {
const clock = testClock();
const Cache = createEngineCache({
clock,
adapter: memoryCacheAdapter({ clock }),
policies: {
[TEST_POLICY_STRICT]: {
freshFor: 30_000,
staleFor: 0,
staleIfErrorFor: 0,
gcAfter: 60_000,
mode: CACHE_READ_MODE_MUST_REVALIDATE
}
},
defaultPolicy: TEST_POLICY_STRICT
});
let version = 1;
await Cache.query({
key: ['post', 1],
scope: CACHE_SCOPE_PUBLIC,
tags: [{ type: 'post', id: 1 }],
fetcher: async () => version
});
version = 2;
await Cache.invalidate({
tag: { type: 'post', id: 1 },
scope: CACHE_SCOPE_PUBLIC
});
const next = await Cache.query({
key: ['post', 1],
scope: CACHE_SCOPE_PUBLIC,
tags: [{ type: 'post', id: 1 }],
fetcher: async () => version
});
expect(next).toBe(2);
expect(Cache.stats().counters.invalidate).toBe(1);
});
it('invalidates by key prefix epoch', async () => {
const clock = testClock();
const Cache = createEngineCache({
clock,
adapter: memoryCacheAdapter({ clock }),
policies: {
[TEST_POLICY_STRICT]: {
freshFor: 30_000,
staleFor: 0,
staleIfErrorFor: 0,
gcAfter: 60_000,
mode: CACHE_READ_MODE_MUST_REVALIDATE
}
},
defaultPolicy: TEST_POLICY_STRICT
});
let page = 1;
await Cache.query({
key: ['projects', { page: 1 }],
scope: CACHE_SCOPE_PUBLIC,
fetcher: async () => page
});
page = 2;
await Cache.invalidate({
keyPrefix: ['projects'],
scope: CACHE_SCOPE_PUBLIC
});
const next = await Cache.query({
key: ['projects', { page: 1 }],
scope: CACHE_SCOPE_PUBLIC,
fetcher: async () => page
});
expect(next).toBe(2);
});
it('isolates actor scope through the scope resolver', async () => {
const clock = testClock();
let actorId = 'a';
const Cache = createEngineCache({
clock,
adapter: memoryCacheAdapter({ clock }),
scopeResolver: () => ({ actorId })
});
const first = await Cache.query({
key: ['profile'],
scope: CACHE_SCOPE_ACTOR,
fetcher: async () => `profile:${actorId}`
});
actorId = 'b';
const second = await Cache.query({
key: ['profile'],
scope: CACHE_SCOPE_ACTOR,
fetcher: async () => `profile:${actorId}`
});
expect(first).toBe('profile:a');
expect(second).toBe('profile:b');
});
it('serves stale-if-error inside the degraded window', async () => {
const clock = testClock();
const Cache = createEngineCache({
clock,
adapter: memoryCacheAdapter({ clock }),
policies: {
[TEST_POLICY_RESILIENT]: {
freshFor: 10,
staleFor: 0,
staleIfErrorFor: 100,
gcAfter: 200,
mode: CACHE_READ_MODE_STALE_WHILE_REVALIDATE
}
},
defaultPolicy: TEST_POLICY_RESILIENT
});
await Cache.query({
key: ['config'],
scope: CACHE_SCOPE_PUBLIC,
fetcher: async () => 'cached'
});
clock.advance(20);
const value = await Cache.query({
key: ['config'],
scope: CACHE_SCOPE_PUBLIC,
fetcher: async () => {
throw new Error('origin unavailable');
}
});
expect(value).toBe('cached');
expect(Cache.stats().counters.staleIfError).toBe(1);
});
it('storage adapter skips writes when policy persist is false', async () => {
const clock = testClock();
const storage = createMapStorage();
const Cache = createEngineCache({
clock,
adapter: storageCacheAdapter({ storage, clock }),
policies: {
[TEST_POLICY_PRIVATE_ONLY]: {
freshFor: 30_000,
staleFor: 0,
staleIfErrorFor: 0,
gcAfter: 60_000,
mode: CACHE_READ_MODE_MUST_REVALIDATE,
persist: false
}
},
defaultPolicy: TEST_POLICY_PRIVATE_ONLY
});
await Cache.query({
key: ['secret'],
scope: CACHE_SCOPE_PUBLIC,
fetcher: async () => 'classified'
});
expect(storage.dump()).toEqual({});
});
it('clears entries and epochs from the storage adapter index', async () => {
const clock = testClock();
const storage = createMapStorage();
const Cache = createEngineCache({
clock,
adapter: storageCacheAdapter({ storage, clock })
});
await Cache.query({
key: ['settings'],
scope: CACHE_SCOPE_PUBLIC,
tags: [{ type: 'settings' }],
fetcher: async () => 'cached'
});
await Cache.invalidate({ tag: { type: 'settings' }, scope: CACHE_SCOPE_PUBLIC });
expect(Object.keys(storage.dump()).length).toBeGreaterThan(0);
await Cache.clear();
expect(storage.dump()).toEqual({});
});
it('protects cache decisions from throwing event listeners', async () => {
const clock = testClock();
const Cache = createEngineCache({
clock,
adapter: memoryCacheAdapter({ clock })
});
Cache.on(CACHE_EVENT_MISS, () => {
throw new Error('listener failed');
});
await expect(
Cache.query({
key: ['safe-events'],
scope: CACHE_SCOPE_PUBLIC,
fetcher: async () => 'ok'
})
).resolves.toBe('ok');
});
it('emits scope errors before rethrowing invalid private scopes', async () => {
const clock = testClock();
const events: CacheEvent[] = [];
const Cache = createEngineCache({
clock,
adapter: memoryCacheAdapter({ clock }),
onEvent: (event) => events.push(event)
});
await expect(
Cache.query({
key: ['profile'],
scope: CACHE_SCOPE_ACTOR,
fetcher: async () => 'never'
})
).rejects.toThrow();
expect(events.some((event) => event.type === CACHE_EVENT_SCOPE_ERROR)).toBe(true);
});
it('throws a typed error after dispose()', async () => {
expect.assertions(1);
const Cache = createEngineCache();
Cache.dispose();
try {
await Cache.query({
key: ['disposed'],
scope: CACHE_SCOPE_PUBLIC,
fetcher: async () => 'never'
});
} catch (error) {
expect(isCachDisposedError(error)).toBe(true);
}
});
});

@ -0,0 +1,14 @@
import type { CacheAdapter, CacheEventHandler, CacheRuntime, CacheRuntimeConfig } from '$libs/cach';
import type { EngineLogger } from '$logr';
export type CacheLogger = Pick<EngineLogger, 'debug' | 'warn' | 'error'>;
export interface EngineCacheOptions extends Omit<CacheRuntimeConfig, 'adapter' | 'onEvent'> {
adapter?: CacheAdapter;
logger?: CacheLogger;
onEvent?: CacheEventHandler;
}
export interface EngineCache extends CacheRuntime {
dispose(): void;
}

@ -0,0 +1,2 @@
export * as CachServer from './cach/index.ts';
export * as PermServer from './perm/index.ts';

@ -0,0 +1,19 @@
export const LOGGER_CATEGORY = 'perm';
export const PERMISSION_LOG_MSG_DECISION = 'authorization decision';
export const PERMISSION_LOG_MSG_DENIED = 'authorization denied';
export const PERMISSION_LOG_MSG_INDETERMINATE = 'authorization indeterminate';
export const PERMISSION_ERROR_MSG_BODY_MUST_BE_OBJECT = 'Request body must be an object';
export const PERMISSION_ERROR_MSG_DISPOSED_SUFFIX = '() called on a disposed permissions engine';
export const PERMISSION_ERROR_NAME_INVALID_BODY = 'PermInvalidBodyError';
export const PERMISSION_ERROR_NAME_DISPOSED = 'PermDisposedError';
export const PERMISSION_METHOD_CHECK = 'check';
export const PERMISSION_METHOD_CAN = 'can';
export const PERMISSION_METHOD_ASSERT = 'assert';
export const PERMISSION_METHOD_EXPLAIN = 'explain';
export const PERMISSION_METHOD_WHAT = 'what';
export const PERMISSION_METHOD_WHO = 'who';
export const PERMISSION_METHOD_FILTER = 'filter';

@ -9,15 +9,29 @@ import {
LOGGER_CATEGORY, LOGGER_CATEGORY,
PERMISSION_LOG_MSG_DECISION, PERMISSION_LOG_MSG_DECISION,
PERMISSION_LOG_MSG_DENIED, PERMISSION_LOG_MSG_DENIED,
PERMISSION_LOG_MSG_INDETERMINATE PERMISSION_LOG_MSG_INDETERMINATE,
PERMISSION_METHOD_ASSERT,
PERMISSION_METHOD_CAN,
PERMISSION_METHOD_CHECK,
PERMISSION_METHOD_EXPLAIN,
PERMISSION_METHOD_FILTER,
PERMISSION_METHOD_WHAT,
PERMISSION_METHOD_WHO
} from './consts.ts'; } from './consts.ts';
import { PermDisposedError } from './errors.ts';
import { disposedPermissionsMessage } from './helpers.ts';
import type { EnginePermissions, EnginePermissionsOptions } from './types.ts'; import type { EnginePermissions, EnginePermissionsOptions } from './types.ts';
export function createEnginePermissions(options: EnginePermissionsOptions): EnginePermissions { export function createEnginePermissions(options: EnginePermissionsOptions): EnginePermissions {
const runtime = createPermissionRuntime(options); const runtime = createPermissionRuntime(options);
let disposed = false; let disposed = false;
function ensureLive(method: string): void {
if (disposed) throw new PermDisposedError(disposedPermissionsMessage(method));
}
async function check(input: Parameters<typeof runtime.check>[0]) { async function check(input: Parameters<typeof runtime.check>[0]) {
ensureLive(PERMISSION_METHOD_CHECK);
const decision = await runtime.check(input); const decision = await runtime.check(input);
if (decision.effect === PERMISSION_EFFECT_DENY) { if (decision.effect === PERMISSION_EFFECT_DENY) {
options.logger?.warn?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_DENIED, { options.logger?.warn?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_DENIED, {
@ -53,17 +67,31 @@ export function createEnginePermissions(options: EnginePermissionsOptions): Engi
compilers: options.compilers ?? [], compilers: options.compilers ?? [],
check, check,
async can(input) { async can(input) {
ensureLive(PERMISSION_METHOD_CAN);
const decision = await check(input); const decision = await check(input);
return decision.effect === PERMISSION_EFFECT_ALLOW; return decision.effect === PERMISSION_EFFECT_ALLOW;
}, },
async assert(input) { async assert(input) {
ensureLive(PERMISSION_METHOD_ASSERT);
const decision = await check(input); const decision = await check(input);
if (decision.effect !== PERMISSION_EFFECT_ALLOW) throw new PermissionDeniedError(decision); if (decision.effect !== PERMISSION_EFFECT_ALLOW) throw new PermissionDeniedError(decision);
}, },
explain: (input) => runtime.explain(input), explain(input) {
what: (input) => runtime.what(input), ensureLive(PERMISSION_METHOD_EXPLAIN);
who: (input) => runtime.who(input), return runtime.explain(input);
filter: (action) => runtime.filter(action), },
what(input) {
ensureLive(PERMISSION_METHOD_WHAT);
return runtime.what(input);
},
who(input) {
ensureLive(PERMISSION_METHOD_WHO);
return runtime.who(input);
},
filter(action) {
ensureLive(PERMISSION_METHOD_FILTER);
return runtime.filter(action);
},
dispose() { dispose() {
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;

@ -0,0 +1,17 @@
import { PERMISSION_ERROR_NAME_DISPOSED, PERMISSION_ERROR_NAME_INVALID_BODY } from './consts.ts';
export class PermInvalidBodyError extends Error {
override readonly name = PERMISSION_ERROR_NAME_INVALID_BODY;
}
export class PermDisposedError extends Error {
override readonly name = PERMISSION_ERROR_NAME_DISPOSED;
}
export function isPermInvalidBodyError(error: unknown): error is PermInvalidBodyError {
return error instanceof PermInvalidBodyError;
}
export function isPermDisposedError(error: unknown): error is PermDisposedError {
return error instanceof PermDisposedError;
}

@ -0,0 +1,5 @@
import { PERMISSION_ERROR_MSG_DISPOSED_SUFFIX } from './consts.ts';
export function disposedPermissionsMessage(method: string): string {
return `${method}${PERMISSION_ERROR_MSG_DISPOSED_SUFFIX}`;
}

@ -1,5 +1,4 @@
import { import {
PERMISSION_ERROR_MSG_BODY_MUST_BE_OBJECT,
PERMISSION_HTTP_STATUS_OK, PERMISSION_HTTP_STATUS_OK,
PERMISSION_REQUEST_FIELD_ACTION, PERMISSION_REQUEST_FIELD_ACTION,
PERMISSION_REQUEST_FIELD_ACTIONS, PERMISSION_REQUEST_FIELD_ACTIONS,
@ -7,34 +6,39 @@ import {
PERMISSION_REQUEST_FIELD_CONTEXT, PERMISSION_REQUEST_FIELD_CONTEXT,
PERMISSION_REQUEST_FIELD_RESOURCE, PERMISSION_REQUEST_FIELD_RESOURCE,
PERMISSION_RESPONSE_FIELD_ACTIONS, PERMISSION_RESPONSE_FIELD_ACTIONS,
PERMISSION_RESPONSE_FIELD_DECISIONS PERMISSION_RESPONSE_FIELD_DECISIONS,
} from './consts.ts'; permissionDecisionKey,
import { permissionDecisionKey } from './keys.ts'; type PermissionRemoteCheckInput
} from '$libs/svrs/perm';
import { PERMISSION_ERROR_MSG_BODY_MUST_BE_OBJECT } from './consts.ts';
import { PermInvalidBodyError } from './errors.ts';
import type { import type {
EnginePermissions, EnginePermissions,
PermissionActorResolver, PermissionActorResolver,
PermissionClientCheckInput, PermissionHttpHandlers,
PermissionHttpRequestLike, PermissionHttpRequestLike,
PermissionHttpResponse PermissionHttpResponse
} from './types.ts'; } from './types.ts';
import type { PermissionCheckInput } from '$libs/perm'; import type { PermissionCheckInput } from '$libs/perm';
function assertBodyObject(body: unknown): asserts body is Record<string, unknown> { function assertBodyObject(body: unknown): asserts body is Record<string, unknown> {
if (!body || typeof body !== 'object') throw new Error(PERMISSION_ERROR_MSG_BODY_MUST_BE_OBJECT); if (!body || typeof body !== 'object') {
throw new PermInvalidBodyError(PERMISSION_ERROR_MSG_BODY_MUST_BE_OBJECT);
}
} }
function readCheck(body: Record<string, unknown>): PermissionClientCheckInput { function readCheck(body: Record<string, unknown>): PermissionRemoteCheckInput {
return { return {
action: String(body[PERMISSION_REQUEST_FIELD_ACTION]), action: String(body[PERMISSION_REQUEST_FIELD_ACTION]),
resource: body[PERMISSION_REQUEST_FIELD_RESOURCE] as PermissionClientCheckInput['resource'], resource: body[PERMISSION_REQUEST_FIELD_RESOURCE] as PermissionRemoteCheckInput['resource'],
context: body[PERMISSION_REQUEST_FIELD_CONTEXT] as PermissionClientCheckInput['context'] context: body[PERMISSION_REQUEST_FIELD_CONTEXT] as PermissionRemoteCheckInput['context']
}; };
} }
export function createPermissionHttpHandlers( export function createPermissionHttpHandlers(
runtime: EnginePermissions, runtime: EnginePermissions,
resolveActor: PermissionActorResolver resolveActor: PermissionActorResolver
) { ): PermissionHttpHandlers {
return { return {
async check(request: PermissionHttpRequestLike): Promise<PermissionHttpResponse> { async check(request: PermissionHttpRequestLike): Promise<PermissionHttpResponse> {
const body = await request.json(); const body = await request.json();

@ -0,0 +1,47 @@
export { createEnginePermissions } from './engine-permissions.ts';
export { createPermissionHttpHandlers } from './http.ts';
export * from './consts.ts';
export * from './errors.ts';
export * from './helpers.ts';
export * from './types.ts';
export {
actionMatches,
actionResource,
actionsForResource,
actor,
allow,
and,
attr,
audit,
ctx,
definePermSchema,
definePolicies,
deny,
ExprBuilder,
mask,
not,
or,
PERMISSION_EFFECT_ALLOW,
PERMISSION_EFFECT_DENY,
PERMISSION_EFFECT_INDETERMINATE,
PERMISSION_EFFECT_NOT_APPLICABLE,
PERMISSION_QUERY_TARGET_SQL,
PolicyBuilder,
redact,
rel,
RelationBuilder,
requireMfa,
resource,
resourceKey,
val,
createSqlCompiler
} from '$libs/perm';
export type {
CreateSqlCompilerOptions,
SqlCompileResult,
SqlRelationCompiler,
SqlRelationCompilerInput
} from '$libs/perm';

@ -13,8 +13,9 @@ import {
definePermSchema, definePermSchema,
definePolicies, definePolicies,
deny, deny,
isPermDisposedError,
rel rel
} from '$perm'; } from '$svrs/perm';
const schema = definePermSchema({ const schema = definePermSchema({
actors: { actors: {
@ -111,4 +112,20 @@ describe('EnginePermissions', () => {
expect(result.actions['post.read']?.effect).toBe(PERMISSION_EFFECT_ALLOW); expect(result.actions['post.read']?.effect).toBe(PERMISSION_EFFECT_ALLOW);
expect(result.actions['post.update']?.effect).toBe(PERMISSION_EFFECT_ALLOW); expect(result.actions['post.update']?.effect).toBe(PERMISSION_EFFECT_ALLOW);
}); });
it('throws a typed error after dispose()', async () => {
expect.assertions(1);
const policies = definePolicies(schema, [
allow('post.read').id('post.read.public').when(attr('post.visibility').eq('public'))
]);
const Perm = createEnginePermissions({ schema, policies });
Perm.dispose();
try {
await Perm.check({ actor: actorRef, action: 'post.read', resource: publicPost });
} catch (error) {
expect(isPermDisposedError(error)).toBe(true);
}
});
}); });

@ -0,0 +1,54 @@
import type { EngineLogger } from '$logr';
import type {
PermSchema,
PermissionCheckInput,
PermissionRuntime,
PermissionRuntimeOptions,
PolicyIR,
QueryCompiler
} from '$libs/perm';
export interface EnginePermissionsOptions extends PermissionRuntimeOptions {
readonly logger?: EngineLogger;
}
export interface EnginePermissions extends PermissionRuntime {
readonly schema: PermSchema;
readonly policies: readonly PolicyIR[];
readonly compilers: readonly QueryCompiler[];
dispose(): void;
}
export interface PermissionHttpRequestLike {
readonly method: string;
readonly url?: string;
json(): Promise<unknown>;
}
export interface PermissionHttpResponse {
readonly status: number;
readonly body: unknown;
}
export type PermissionActorResolver = (
request: PermissionHttpRequestLike,
body: unknown
) => Promise<PermissionCheckInput['actor']> | PermissionCheckInput['actor'];
export interface PermissionHttpHandlers {
check(request: PermissionHttpRequestLike): Promise<PermissionHttpResponse>;
batch(request: PermissionHttpRequestLike): Promise<PermissionHttpResponse>;
what(request: PermissionHttpRequestLike): Promise<PermissionHttpResponse>;
explain(request: PermissionHttpRequestLike): Promise<PermissionHttpResponse>;
}
export type {
ExplainResult,
PermSchema,
PermissionCheckInput,
PermissionDecision,
PermissionRuntime,
PermissionRuntimeOptions,
PolicyIR,
QueryCompiler
} from '$libs/perm';

@ -74,6 +74,11 @@
<code>Permissions</code>: policies allow/deny, handlers HTTP, cache cliente, <code>Permissions</code>: policies allow/deny, handlers HTTP, cache cliente,
<code>what()</code>, <code>explain()</code>, SQL plan y componente <code>&lt;Can /&gt;</code>. <code>what()</code>, <code>explain()</code>, SQL plan y componente <code>&lt;Can /&gt;</code>.
</li> </li>
<li>
<a href={resolve('/test/cach')}>/test/cach</a> — cache de datos con
<code>App.Cache</code>: query cache, scopes, tags por epoch, entry reactiva,
<code>explain()</code> y eventos.
</li>
</ul> </ul>
</main> </main>

@ -0,0 +1,453 @@
<script lang="ts">
import { createActiveApp } from '$aapp';
import {
CACHE_EVENT_ALL,
CACHE_POLICY_INTERACTIVE,
CACHE_POLICY_PRIVATE_SESSION,
CACHE_READ_MODE_STALE_WHILE_REVALIDATE,
CACHE_SCOPE_ACTOR,
CACHE_SCOPE_TENANT,
type CacheEvent,
type CacheExplain,
type ResolvedScopeValues
} from '$cach';
type Project = {
id: number;
name: string;
tenantId: string;
version: number;
fetchedAt: number;
};
type Profile = {
actorId: string;
tenantId: string;
label: string;
version: number;
};
type LogLine = {
id: number;
text: string;
};
const PROJECT_ID = 42;
const PROJECT_TAG = { type: 'project', id: PROJECT_ID } as const;
const PROJECT_LIST_TAG = { type: 'project', id: 'LIST' } as const;
const PROJECT_SCHEMA_VERSION = 'Project:v1';
const PROFILE_SCHEMA_VERSION = 'Profile:v1';
const MAX_LOGS = 9;
let tenantId = $state('acme');
let actorId = $state('ada');
let permissionHash = $state('perm-editor');
let locale = $state('es-ES');
let projectVersion = $state(1);
let profileVersion = $state(1);
let projectFetches = $state(0);
let profileFetches = $state(0);
let directProject = $state<Project | null>(null);
let actorProfile = $state<Profile | null>(null);
let explanation = $state<CacheExplain | null>(null);
let eventLog = $state<LogLine[]>([]);
let logSeq = 0;
const App = createActiveApp({
cache: {
scopeResolver: (): ResolvedScopeValues => ({
tenantId,
actorId,
permissionHash,
locale
}),
policies: {
[CACHE_POLICY_INTERACTIVE]: {
freshFor: 2_000,
staleFor: 30_000,
staleIfErrorFor: 60_000,
gcAfter: 120_000,
mode: CACHE_READ_MODE_STALE_WHILE_REVALIDATE,
persist: true
},
[CACHE_POLICY_PRIVATE_SESSION]: {
freshFor: 1_000,
staleFor: 3_000,
staleIfErrorFor: 0,
gcAfter: 15_000,
mode: CACHE_READ_MODE_STALE_WHILE_REVALIDATE,
persist: false
}
}
}
});
const projectEntry = App.Cache.entry<Project>({
key: ['project', PROJECT_ID],
scope: CACHE_SCOPE_TENANT,
policy: CACHE_POLICY_INTERACTIVE,
schemaVersion: PROJECT_SCHEMA_VERSION,
tags: [PROJECT_TAG, PROJECT_LIST_TAG],
fetcher: fetchProject
});
const offEvents = App.Cache.on(CACHE_EVENT_ALL, (event: CacheEvent) => {
pushLog(describeEvent(event));
});
$effect(() => {
return () => {
offEvents();
projectEntry.dispose();
App.dispose();
};
});
async function fetchProject(): Promise<Project> {
projectFetches += 1;
return {
id: PROJECT_ID,
name: `Proyecto ${projectVersion}`,
tenantId,
version: projectVersion,
fetchedAt: Date.now()
};
}
async function fetchProfile(): Promise<Profile> {
profileFetches += 1;
return {
actorId,
tenantId,
label: `${actorId}@${tenantId}`,
version: profileVersion
};
}
async function loadEntry(): Promise<void> {
await projectEntry.load();
await explainProject();
}
async function loadDirect(): Promise<void> {
directProject = await App.Cache.query({
key: ['project', PROJECT_ID],
scope: CACHE_SCOPE_TENANT,
policy: CACHE_POLICY_INTERACTIVE,
schemaVersion: PROJECT_SCHEMA_VERSION,
tags: [PROJECT_TAG, PROJECT_LIST_TAG],
fetcher: fetchProject
});
await explainProject();
}
async function bumpOrigin(): Promise<void> {
projectVersion += 1;
pushLog(`origen proyecto -> v${projectVersion}`);
}
async function invalidateProjectTag(): Promise<void> {
await App.Cache.invalidate({
tag: PROJECT_TAG,
scope: CACHE_SCOPE_TENANT
});
await explainProject();
}
async function loadActorProfile(): Promise<void> {
actorProfile = await App.Cache.query({
key: ['profile'],
scope: CACHE_SCOPE_ACTOR,
policy: CACHE_POLICY_PRIVATE_SESSION,
schemaVersion: PROFILE_SCHEMA_VERSION,
tags: [{ type: 'profile', id: actorId }],
fetcher: fetchProfile
});
}
async function switchActor(): Promise<void> {
actorId = actorId === 'ada' ? 'linus' : 'ada';
profileVersion += 1;
pushLog(`actor activo -> ${actorId}`);
await loadActorProfile();
}
async function switchTenant(): Promise<void> {
tenantId = tenantId === 'acme' ? 'globex' : 'acme';
projectVersion += 1;
profileVersion += 1;
pushLog(`tenant activo -> ${tenantId}`);
await Promise.all([loadEntry(), loadActorProfile()]);
}
async function explainProject(): Promise<void> {
explanation = await App.Cache.explain(['project', PROJECT_ID], {
scope: CACHE_SCOPE_TENANT,
policy: CACHE_POLICY_INTERACTIVE,
schemaVersion: PROJECT_SCHEMA_VERSION
});
}
async function clearCache(): Promise<void> {
await App.Cache.clear();
directProject = null;
actorProfile = null;
explanation = null;
pushLog('cache limpiada');
}
function pushLog(text: string): void {
eventLog = [{ id: ++logSeq, text }, ...eventLog].slice(0, MAX_LOGS);
}
function describeEvent(event: CacheEvent): string {
const key = event.keyHash ? ` · ${event.keyHash}` : '';
const reason = event.reason ? ` · ${event.reason}` : '';
return `${new Date(event.at).toLocaleTimeString()} · ${event.type}${reason}${key}`;
}
</script>
<svelte:head>
<title>cach — test</title>
</svelte:head>
<main class="shell">
<section class="hero">
<p class="eyebrow">arts/cach</p>
<h1>Cache de datos con scopes, tags y explicación</h1>
<p>
Esta página usa <code>App.Cache</code> real: memory adapter, políticas,
<code>stale-while-revalidate</code>, invalidación por epoch y estado reactivo.
</p>
</section>
<section class="grid">
<article class="card">
<h2>Entry Reactiva</h2>
<p>La entry sirve el dato cacheado y mantiene estado UI.</p>
<div class="actions">
<button onclick={loadEntry}>load()</button>
<button onclick={projectEntry.refresh}>refresh()</button>
<button onclick={projectEntry.invalidate}>invalidate key</button>
</div>
<dl>
<div>
<dt>Status</dt>
<dd>{projectEntry.status}</dd>
</div>
<div>
<dt>Fetches</dt>
<dd>{projectFetches}</dd>
</div>
</dl>
<pre>{JSON.stringify(projectEntry.snapshot(), null, 2)}</pre>
</article>
<article class="card">
<h2>Query Directa</h2>
<p>Dos lecturas seguidas deberían reutilizar caché si no invalidas.</p>
<div class="actions">
<button onclick={loadDirect}>query()</button>
<button onclick={bumpOrigin}>subir origen</button>
<button onclick={invalidateProjectTag}>invalidate tag</button>
</div>
<pre>{JSON.stringify(directProject, null, 2)}</pre>
</article>
<article class="card">
<h2>Scope Seguro</h2>
<p>El perfil usa scope <code>actor</code>; cambiar actor/tenant cambia la key efectiva.</p>
<div class="actions">
<button onclick={loadActorProfile}>profile()</button>
<button onclick={switchActor}>cambiar actor</button>
<button onclick={switchTenant}>cambiar tenant</button>
</div>
<dl>
<div>
<dt>Tenant</dt>
<dd>{tenantId}</dd>
</div>
<div>
<dt>Actor</dt>
<dd>{actorId}</dd>
</div>
<div>
<dt>Profile fetches</dt>
<dd>{profileFetches}</dd>
</div>
</dl>
<pre>{JSON.stringify(actorProfile, null, 2)}</pre>
</article>
<article class="card">
<h2>Explain + Eventos</h2>
<p><code>explain()</code> muestra decisión, estado, schema y epochs.</p>
<div class="actions">
<button onclick={explainProject}>explain()</button>
<button onclick={clearCache}>clear()</button>
</div>
<pre>{JSON.stringify(explanation, null, 2)}</pre>
<ul class="events">
{#each eventLog as line (line.id)}
<li>{line.text}</li>
{/each}
</ul>
</article>
</section>
</main>
<style>
:global(body) {
margin: 0;
background:
radial-gradient(circle at 15% 10%, rgba(75, 127, 82, 0.18), transparent 32rem),
linear-gradient(135deg, #101714 0%, #17221d 48%, #f3efe2 48.2%, #faf7ec 100%);
color: #17221d;
font-family:
Atkinson Hyperlegible,
Charter,
serif;
}
.shell {
width: min(1180px, calc(100vw - 2rem));
margin: 0 auto;
padding: 3rem 0 4rem;
}
.hero {
max-width: 760px;
padding: 2rem;
border: 1px solid rgba(243, 239, 226, 0.22);
border-radius: 28px;
background: rgba(16, 23, 20, 0.78);
color: #faf7ec;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(18px);
}
.eyebrow {
margin: 0 0 0.6rem;
color: #d6a044;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(2.4rem, 7vw, 5.6rem);
line-height: 0.9;
letter-spacing: -0.07em;
}
h2 {
margin: 0;
font-size: 1.3rem;
}
code {
border-radius: 0.35rem;
background: rgba(214, 160, 68, 0.18);
padding: 0.05rem 0.35rem;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.card {
min-width: 0;
border: 1px solid rgba(23, 34, 29, 0.12);
border-radius: 24px;
background: rgba(250, 247, 236, 0.9);
padding: 1.25rem;
box-shadow: 0 18px 50px rgba(16, 23, 20, 0.14);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
margin: 1rem 0;
}
button {
border: 0;
border-radius: 999px;
background: #17221d;
color: #faf7ec;
cursor: pointer;
font: inherit;
font-weight: 800;
padding: 0.7rem 1rem;
}
button:hover {
background: #2f5f43;
}
dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
dl div {
border-radius: 16px;
background: #efe6cf;
padding: 0.75rem;
}
dt {
color: #6d6250;
font-size: 0.75rem;
font-weight: 800;
text-transform: uppercase;
}
dd {
margin: 0.2rem 0 0;
font-size: 1.05rem;
font-weight: 900;
}
pre {
max-height: 280px;
overflow: auto;
border-radius: 18px;
background: #101714;
color: #d7f5dd;
font-size: 0.82rem;
padding: 1rem;
white-space: pre-wrap;
}
.events {
display: grid;
gap: 0.35rem;
margin: 0;
padding: 0;
list-style: none;
}
.events li {
border-radius: 12px;
background: #efe6cf;
padding: 0.6rem 0.75rem;
font-size: 0.85rem;
}
@media (max-width: 820px) {
.grid {
grid-template-columns: 1fr;
}
dl {
grid-template-columns: 1fr;
}
}
</style>

@ -15,8 +15,6 @@
and, and,
attr, attr,
audit, audit,
createEnginePermissions,
createPermissionHttpHandlers,
createSqlCompiler, createSqlCompiler,
definePermSchema, definePermSchema,
definePolicies, definePolicies,
@ -31,6 +29,7 @@
type ResourceRef, type ResourceRef,
type SubjectRef type SubjectRef
} from '$perm'; } from '$perm';
import { createEnginePermissions, createPermissionHttpHandlers } from '$svrs/perm';
const PERM_ENDPOINT = 'https://perm.local/api/permissions'; const PERM_ENDPOINT = 'https://perm.local/api/permissions';
const ACTION_READ = 'post.read'; const ACTION_READ = 'post.read';

@ -14,6 +14,7 @@ const config = {
alias: { alias: {
$aapp: 'src/arts/aapp', $aapp: 'src/arts/aapp',
$adom: 'src/arts/adom', $adom: 'src/arts/adom',
$cach: 'src/arts/cach',
$conn: 'src/arts/conn', $conn: 'src/arts/conn',
$fend: 'src/arts/fend', $fend: 'src/arts/fend',
$libs: 'src/libs', $libs: 'src/libs',
@ -27,6 +28,7 @@ const config = {
$sess: 'src/arts/sess', $sess: 'src/arts/sess',
$sium: 'src/arts/sium', $sium: 'src/arts/sium',
$stor: 'src/arts/stor', $stor: 'src/arts/stor',
$svrs: 'src/svrs',
$timr: 'src/arts/timr' $timr: 'src/arts/timr'
} }
} }

@ -11,7 +11,8 @@
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"moduleResolution": "bundler" "moduleResolution": "bundler"
} },
"exclude": ["src/cach-v1/**"]
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
// //

Loading…
Cancel
Save

Powered by TurnKey Linux.