@ultimat3/cache 27.6.1 → 27.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -42,6 +42,9 @@ Tier 1. Tagged caching + THE invalidation graph.
42
42
  `TIER_ORDER` alias (`X_HELPER_COPY` refuses a value of core's under a second name). Adding a
43
43
  rung is an edit to `packages/core/src/cache-vocabulary.ts` plus a factory here; `scripts/render-modes.ts` refuses a second declaration of the set. **`isr` is
44
44
  not a tier** — the `'isr'` in `invalidate.ts` is an ISR route (`DependentKind = 'isr-route'`).
45
+ - **`flushProcessTiers(source)` is for a process that cannot name what it missed** (its bus was
46
+ away): `CacheTier.clear()` on in-process tiers only, tagged ISR pages stale, fills fenced out
47
+ (`markAllInvalidated`). Never re-emits. A shared tier must NOT implement `clear`.
45
48
  - **`bestEffort()` is the only sanctioned way to swallow a cache refusal.** Its label is `TierLabel`
46
49
  (`TierName` plus `'query-read'`), deliberately not a widening of `TierName`.
47
50
  - **A refusal is rendered with `renderThrowable()`, never `error.message`** — at all five absorbing
package/README.md CHANGED
@@ -304,8 +304,17 @@ bus.subscribe('cache.invalidate', (wireTags) => receiveInvalidationBroadcast(wir
304
304
  The inbound half **cannot** re-emit, and that is structural rather than a flag: `emit` lives on the
305
305
  private fan-out options and `receiveInvalidationBroadcast` is the only caller that passes `false`.
306
306
  A receiver that re-broadcast would be a storm bounded by nothing. A failed send lands in
307
- `report.errors` under `tier: "broadcast"` — the other pods then clear on TTL, and the write that
308
- triggered the bust still succeeds. An inbound tag this process has not declared is dropped and
307
+ `report.errors` under `tier: "broadcast"` and the write that triggered the bust still succeeds.
308
+ What happens to that bust is the sender's (`@ultimat3/cli` keeps the refused tags, bounded, and
309
+ publishes them when the bus returns); with no such sender the other pods clear on TTL.
310
+
311
+ **`flushProcessTiers(source)` is the repair for a process that cannot know what it missed** — its
312
+ bus connection was down for a window, or a peer says it had more refused busts than it could keep.
313
+ It calls `CacheTier.clear()` on every tier that has one (only a tier whose store is this process's
314
+ heap implements it: `lruTier` does, `redisTier` and `cdnTier` do not), marks every ISR page that
315
+ depends on a tag stale through the registered revalidator, and fences out every fill in flight
316
+ (`markAllInvalidated`). It never re-emits and never throws; its report and its `/_x` event carry
317
+ `tags: ['*']` (`FLUSH_ALL_TAG`). An inbound tag this process has not declared is dropped and
309
318
  reported rather than thrown, because mid-deploy the new pods know an entity the old ones do not and
310
319
  a throw would kill the subscriber loop that delivered it.
311
320
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cache",
3
- "version": "27.6.1",
3
+ "version": "27.7.0",
4
4
  "description": "Tagged caching: request memo, LRU, Redis, CDN — one invalidation graph",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,6 +32,6 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "27.6.1"
35
+ "@ultimat3/core": "27.7.0"
36
36
  }
37
37
  }
package/src/fence.ts CHANGED
@@ -70,6 +70,17 @@ export function markInvalidated(scope: FenceScope): void {
70
70
  }
71
71
  }
72
72
 
73
+ /**
74
+ * Everything, invalidated: every fence sampled before this call answers `false`. For a process
75
+ * that cannot name what it missed (`flushProcessTiers`) — the same "older than the ring remembers:
76
+ * unprovable, so refused" rule, reached on purpose. One refetch per in-flight fill.
77
+ */
78
+ export function markAllInvalidated(): void {
79
+ generation += 1;
80
+ forgottenThrough = generation;
81
+ marks.length = 0;
82
+ }
83
+
73
84
  function hits(mark: Mark, scope: FenceScope): boolean {
74
85
  if (mark.key !== undefined) return scope.key !== undefined && mark.key === scope.key;
75
86
  const owned = mark.tag;
package/src/index.ts CHANGED
@@ -41,6 +41,8 @@ export type {
41
41
  Revalidator,
42
42
  } from './invalidate';
43
43
  export {
44
+ FLUSH_ALL_TAG,
45
+ flushProcessTiers,
44
46
  invalidateTags,
45
47
  invalidateWireTags,
46
48
  isolateTiers,
package/src/invalidate.ts CHANGED
@@ -12,8 +12,8 @@ import {
12
12
  systemClock,
13
13
  withSpan,
14
14
  } from '@ultimat3/core';
15
- import { markInvalidated } from './fence';
16
- import { dependentsOfKind } from './graph';
15
+ import { markAllInvalidated, markInvalidated } from './fence';
16
+ import { dependentsOfKind, graphSnapshot } from './graph';
17
17
  import type { CacheTag } from './tags';
18
18
  import { assertKnownTags, knownTags, parseTag, serializeTags } from './tags';
19
19
  import { isolateTierFailures, resetTierFailures } from './tier-failures';
@@ -292,6 +292,70 @@ function fanOut(tags: readonly CacheTag[], options: FanOutOptions): Promise<Inva
292
292
  });
293
293
  }
294
294
 
295
+ /** What a flush reports and records as its tags: not a wire tag, and never parsed as one. */
296
+ export const FLUSH_ALL_TAG = '*';
297
+
298
+ /**
299
+ * Drop everything this PROCESS holds, because it cannot know what it missed.
300
+ *
301
+ * A peer's bust reaches this process by the broadcast and by nothing else: its in-process tier and
302
+ * its tag-revalidated ISR pages are cleared by `receiveInvalidationBroadcast`, or they wait for
303
+ * their TTL — and a `revalidate: { tags }` page has none. So a process whose bus connection was
304
+ * down for a window, or a sender that had more refused busts than it could keep, has one correct
305
+ * answer left. Every in-process tier is cleared (`CacheTier.clear`), every ISR page that depends
306
+ * on a tag is marked stale, and every fill in flight is fenced out. Shared tiers and the CDN are
307
+ * untouched — the bust's sender cleared those — and nothing is re-emitted: this is local repair.
308
+ * Never throws; the cost is one cold cache.
309
+ */
310
+ export function flushProcessTiers(source: string): Promise<InvalidationReport> {
311
+ return withSpan('cache.flush', async (): Promise<InvalidationReport> => {
312
+ const startedAt = performance.now();
313
+ markAllInvalidated();
314
+ const tiers: TierInvalidation[] = [];
315
+ const errors: { tier: string; message: string }[] = [];
316
+ for (const tier of sortTiers(registry)) {
317
+ if (tier.clear === undefined) continue;
318
+ try {
319
+ await tier.clear();
320
+ tiers.push({ tier: tier.name, keys: [] });
321
+ } catch (error) {
322
+ errors.push({ tier: tier.name, message: renderThrowable(error) });
323
+ }
324
+ }
325
+ const isr = dedupe(
326
+ graphSnapshot().flatMap((entry) =>
327
+ entry.dependents.filter((dep) => dep.kind === 'isr-route').map((dep) => dep.id),
328
+ ),
329
+ );
330
+ for (const path of isr) {
331
+ try {
332
+ await revalidator?.(path);
333
+ } catch (error) {
334
+ errors.push({ tier: 'isr', message: renderThrowable(error) });
335
+ }
336
+ }
337
+ const report: InvalidationReport = {
338
+ tags: [FLUSH_ALL_TAG],
339
+ tiers,
340
+ isr,
341
+ cdn: [],
342
+ liveQueries: [],
343
+ durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
344
+ errors,
345
+ };
346
+ recordInvalidation({
347
+ at: systemClock.now().toISOString(),
348
+ tags: report.tags,
349
+ busted: [...isr],
350
+ source,
351
+ durationMs: report.durationMs,
352
+ errors,
353
+ });
354
+ if (errors.length > 0) logger.warn('cache.flush.partial', { ...report });
355
+ return report;
356
+ });
357
+ }
358
+
295
359
  /** First-seen order kept — a union of what actually changed, not a sorted report. */
296
360
  function dedupe(values: readonly string[]): readonly string[] {
297
361
  return [...new Set(values)];
package/src/lru.ts CHANGED
@@ -305,5 +305,8 @@ export function lruTier(options: LruOptions = {}): CacheTier & { readonly cache:
305
305
  invalidateTags(tags: readonly CacheTag[]): Promise<TierInvalidation> {
306
306
  return Promise.resolve({ tier: 'lru', keys: cache.invalidateTags(tags) });
307
307
  },
308
+ clear(): void {
309
+ cache.clear();
310
+ },
308
311
  };
309
312
  }
package/src/tiers.ts CHANGED
@@ -195,6 +195,12 @@ export interface CacheTier {
195
195
  * may never be told about — still stops the fill (`tier-fence.ts`). Omitted by in-process tiers.
196
196
  */
197
197
  fence?(scope: FenceScope): Promise<TierFence>;
198
+ /**
199
+ * Drop every entry — implemented ONLY by a tier whose store is this process's own heap (`lru`).
200
+ * `flushProcessTiers()` calls it when the process cannot know which busts it missed. A shared
201
+ * tier omits it: whoever ran a bust cleared that store for everyone.
202
+ */
203
+ clear?(): Promise<void> | void;
198
204
  }
199
205
 
200
206
  export interface CacheStack {