// Tags are the ONLY invalidation currency in Ultimate: actions declare `invalidates`, // routes declare `revalidate.tags`, and every tier speaks the same strings. No tier ever // invents its own key convention, so there is nothing for an agent to keep in sync. import { CacheTagUnknownError } from './errors'; /** An entity-scoped tag. `{ entity: 'post' }` covers the collection, `+ id` a single row. */ export interface CacheTag { readonly entity: E; readonly id?: string; } /** * Apps augment this interface (generated by `x manifest` from their entities) to get * `tag.post` typed and `tag.pots` as a build error: * * declare module '@ultimat3/cache' { * interface CacheTagRegistry { post: true; feed: true } * } */ // biome-ignore lint/suspicious/noEmptyInterface: augmentation target, filled by generated code export interface CacheTagRegistry {} type TagAccessors = { readonly [K in keyof CacheTagRegistry & string]: CacheTag }; export type TagFactory = TagAccessors & ((entity: E, id?: string) => CacheTag); const makeTag = (entity: E, id?: string): CacheTag => id === undefined ? { entity } : { entity, id }; /** * `tag('post', id)` for a row, `tag.post` for the collection. The property form is a Proxy * so generated registry augmentations need no runtime codegen. */ export const tag: TagFactory = new Proxy(makeTag, { get(target, prop, receiver): unknown { if (typeof prop === 'string') return makeTag(prop); return Reflect.get(target, prop, receiver); }, }) as unknown as TagFactory; /** Wire form: `post` for a collection, `post:` for a row. Stable across tiers. */ export function serializeTag(value: CacheTag): string { return value.id === undefined ? value.entity : `${value.entity}:${value.id}`; } export function parseTag(wire: string): CacheTag { const split = wire.indexOf(':'); if (split === -1) return { entity: wire }; return { entity: wire.slice(0, split), id: wire.slice(split + 1) }; } export function serializeTags(tags: readonly CacheTag[]): string[] { return tags.map(serializeTag); } /** * The wire forms as an IDENTITY: sorted and de-duplicated, so two declaration orders of one tag * set are one string. `@ultimat3/action`'s descriptor and OpenAPI `invalidates`, and * `@ultimat3/query`'s `cacheKeyFor`, are built from it — a key that varied with declaration order * would fill two entries for one read, and an action's `invalidates` would drop whichever of them * it happened to name. * * It lives here rather than in either of those packages, which held byte-identical copies of it: * both are tier 3, so neither can import the other and a copy in either is a second answer for the * other. The same move `toBucket` made into `@ultimat3/http`. * * NOT `@ultimat3/render`'s internal `routeTagKeys`, which is `serializeTags` over an optional list * and deliberately preserves declaration order for a route descriptor (`dsl.test.ts` pins it). It * was a second public `tagKeys` until 25.0.0: two behaviours under one name. */ export function tagKeys(tags: readonly CacheTag[]): readonly string[] { return [...new Set(serializeTags(tags))].sort(); } /** * Every tag a row participates in: its collection and its own identity. A row write * therefore busts list caches and detail caches with one call. */ export function tagsFor( entity: { readonly name: string }, row: { readonly id: string }, ): readonly CacheTag[] { return [makeTag(entity.name), makeTag(entity.name, row.id)]; } /** * Invalidation is deliberately asymmetric-tolerant: busting a collection (`post`) must kill * cached rows, and busting a row (`post:1`) must kill the lists that contained it. Anything * narrower leaves an agent hunting a stale list it never thought to tag. */ export function tagMatches(requested: CacheTag, owned: CacheTag): boolean { if (requested.entity !== owned.entity) return false; if (requested.id === undefined || owned.id === undefined) return true; return requested.id === owned.id; } export function tagsIntersect(requested: readonly CacheTag[], owned: readonly CacheTag[]): boolean { return requested.some((r) => owned.some((o) => tagMatches(r, o))); } const declared = new Set(); /** Called once at boot with the entity names from `x.manifest.json`. */ export function declareTags(entities: readonly string[]): void { for (const name of entities) declared.add(name); } export function knownTags(): readonly string[] { return [...declared].sort(); } export function resetDeclaredTags(): void { declared.clear(); } /** * Test seam, and the one `resetDeclaredTags()` cannot be: `declareTags` is additive and * process-wide, so a suite that declares a fixture entity flips `assertKnownTags` on for every * LATER file in the same `bun test` process — and those files fail with X_CACHE_TAG_UNKNOWN for a * reason nothing in them explains. Resetting instead would drop what a neighbour declared, so this * puts back exactly what it found: * * const restoreTags = isolateDeclaredTags(); * afterAll(restoreTags); * * This is the shape every process-global registry in this package is undone with — `isolateGraph()` * in `graph.ts`, `isolateTiers()` in `invalidate.ts`, `isolateTierFailures()` in * `tier-failures.ts` — and one reason covers all four: `@ultimat3/testing`'s leak guard compares * its before/after samples for ADDITIONS only, so a file that DELETES what a neighbour registered * is invisible to it and surfaces instead as a failure in an innocent file with nothing in it to * explain the missing state. A reset in a test file is the one leak no mechanism catches for you. */ export function isolateDeclaredTags(): () => void { const captured = knownTags(); return () => { declared.clear(); for (const name of captured) declared.add(name); }; } /** * Validation is skipped while nothing is declared — `x dev` boots before the manifest * exists, and a hard failure there would be worse than a late one. Once any entity has * declared itself, an undeclared tag is a typo and fails loudly. */ export function assertKnownTags(tags: readonly CacheTag[]): void { if (declared.size === 0) return; for (const value of tags) { if (!declared.has(value.entity)) { throw new CacheTagUnknownError({ tag: serializeTag(value), known: knownTags() }); } } }