parent
a9c58b6cc3
commit
75df0938cf
@ -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>;
|
||||
}
|
||||
@ -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}`;
|
||||
}
|
||||
@ -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,30 +1 @@
|
||||
import {
|
||||
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);
|
||||
}
|
||||
export { permissionDecisionKey, stablePermissionStringify } from '$libs/svrs/perm';
|
||||
|
||||
@ -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';
|
||||
@ -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}`;
|
||||
}
|
||||
@ -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';
|
||||
@ -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';
|
||||
@ -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>
|
||||
Loading…
Reference in new issue