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

<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>

Powered by TurnKey Linux.