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