You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
458 lines
10 KiB
458 lines
10 KiB
<script lang="ts">
|
|
import { createActiveApp } from '$active-app';
|
|
import { defineActiveCache } from '$active-app/services';
|
|
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 '$cache';
|
|
|
|
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({
|
|
services: {
|
|
cache: defineActiveCache({
|
|
defaultMemoryAdapter: { suppressProductionWarning: true },
|
|
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>
|