@timber-js/app 0.2.0-alpha.147 → 0.2.0-alpha.149
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/LICENSE +8 -0
- package/dist/_chunks/{actions-OIZ8siQu.js → actions-a0RczDnB.js} +8 -34
- package/dist/_chunks/{actions-OIZ8siQu.js.map → actions-a0RczDnB.js.map} +1 -1
- package/dist/_chunks/cache-api-Co-yI-gP.js +424 -0
- package/dist/_chunks/cache-api-Co-yI-gP.js.map +1 -0
- package/dist/_chunks/{metadata-routes-BjepjefT.js → canonicalize-P41GR6tY.js} +65 -2
- package/dist/_chunks/canonicalize-P41GR6tY.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-CQaxupHv.js → cli-schema-sync-DJDdyEae.js} +111 -260
- package/dist/_chunks/cli-schema-sync-DJDdyEae.js.map +1 -0
- package/dist/_chunks/cli-schema-sync-D_IhHEUW.js +2 -0
- package/dist/_chunks/{define-DMVhsipD.js → define-Bssfp6ot.js} +3 -33
- package/dist/_chunks/define-Bssfp6ot.js.map +1 -0
- package/dist/_chunks/{define-C3RvHQPK.js → define-CFmvb4Bt.js} +15 -7
- package/dist/_chunks/define-CFmvb4Bt.js.map +1 -0
- package/dist/_chunks/{href-validation-DyZIzfMJ.js → href-validation-FiA_rToW.js} +2 -1
- package/dist/_chunks/{href-validation-DyZIzfMJ.js.map → href-validation-FiA_rToW.js.map} +1 -1
- package/dist/_chunks/{tracing-M_zKubhk.js → logger-CM0clugc.js} +394 -107
- package/dist/_chunks/logger-CM0clugc.js.map +1 -0
- package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -1
- package/dist/_chunks/plugin-context-DurqFBuY.js +2 -0
- package/dist/_chunks/plugin-context-GfDWsdd2.js +268 -0
- package/dist/_chunks/plugin-context-GfDWsdd2.js.map +1 -0
- package/dist/_chunks/{resolve-schema-Dj8tQloi.js → resolve-schema-3iUvBV5T.js} +3 -30
- package/dist/_chunks/resolve-schema-3iUvBV5T.js.map +1 -0
- package/dist/_chunks/{schema-bridge-DK_89h8O.js → schema-bridge-BY3QLBL7.js} +35 -2
- package/dist/_chunks/schema-bridge-BY3QLBL7.js.map +1 -0
- package/dist/_chunks/tree-match-D2l830j2.js +102 -0
- package/dist/_chunks/tree-match-D2l830j2.js.map +1 -0
- package/dist/_chunks/use-segment-params-D1CzlgBo.js.map +1 -1
- package/dist/_chunks/{walkers-Ck4Y45XN.js → walkers-DvWDj453.js} +107 -82
- package/dist/_chunks/walkers-DvWDj453.js.map +1 -0
- package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +36 -14
- package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
- package/dist/adapters/compress-module.d.ts +5 -4
- package/dist/adapters/compress-module.d.ts.map +1 -1
- package/dist/adapters/nitro.d.ts +1 -3
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +14 -134
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/cache-api.d.ts.map +1 -1
- package/dist/cache/index.d.ts +3 -1
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +20 -235
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/invalidation-epoch.d.ts +43 -0
- package/dist/cache/invalidation-epoch.d.ts.map +1 -0
- package/dist/cache/redis-handler.d.ts +13 -0
- package/dist/cache/redis-handler.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts +17 -9
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/cli-schema-sync.d.ts +21 -1
- package/dist/cli-schema-sync.d.ts.map +1 -1
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +26 -31
- package/dist/cli.js.map +1 -1
- package/dist/client/browser-entry/hmr.d.ts.map +1 -1
- package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
- package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/history.d.ts +10 -17
- package/dist/client/history.d.ts.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +30 -33
- package/dist/client/internal.js.map +1 -1
- package/dist/client/navigation-api.d.ts +9 -2
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/navigation-context.d.ts +0 -11
- package/dist/client/navigation-context.d.ts.map +1 -1
- package/dist/client/navigation-root.d.ts +0 -5
- package/dist/client/navigation-root.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/use-search-params.d.ts +10 -5
- package/dist/client/use-search-params.d.ts.map +1 -1
- package/dist/codec.js +1 -1
- package/dist/config-validation.d.ts.map +1 -1
- package/dist/cookies/define-cookie.d.ts.map +1 -1
- package/dist/cookies/index.js +2 -4
- package/dist/cookies/index.js.map +1 -1
- package/dist/dev-tools/dev-page-shell.d.ts +1 -0
- package/dist/dev-tools/dev-page-shell.d.ts.map +1 -1
- package/dist/dev-tools/hydration-overlay.d.ts +11 -0
- package/dist/dev-tools/hydration-overlay.d.ts.map +1 -0
- package/dist/dev-tools/index.d.ts +1 -2
- package/dist/dev-tools/index.d.ts.map +1 -1
- package/dist/fonts/ast-walk.d.ts +68 -0
- package/dist/fonts/ast-walk.d.ts.map +1 -0
- package/dist/fonts/ast.d.ts +23 -11
- package/dist/fonts/ast.d.ts.map +1 -1
- package/dist/fonts/bundle.d.ts +3 -1
- package/dist/fonts/bundle.d.ts.map +1 -1
- package/dist/fonts/dev-middleware.d.ts +1 -1
- package/dist/fonts/dev-middleware.d.ts.map +1 -1
- package/dist/fonts/fallbacks.d.ts +0 -7
- package/dist/fonts/fallbacks.d.ts.map +1 -1
- package/dist/fonts/google.d.ts +4 -2
- package/dist/fonts/google.d.ts.map +1 -1
- package/dist/fonts/local.d.ts +3 -3
- package/dist/fonts/local.d.ts.map +1 -1
- package/dist/fonts/paths.d.ts +43 -0
- package/dist/fonts/paths.d.ts.map +1 -0
- package/dist/fonts/pipeline.d.ts +7 -21
- package/dist/fonts/pipeline.d.ts.map +1 -1
- package/dist/fonts/transform.d.ts.map +1 -1
- package/dist/fonts/virtual-modules.d.ts +1 -18
- package/dist/fonts/virtual-modules.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +610 -406
- package/dist/index.js.map +1 -1
- package/dist/params/index.js +1 -1
- package/dist/params/resolve-schema.d.ts.map +1 -1
- package/dist/plugin-context.d.ts +28 -5
- package/dist/plugin-context.d.ts.map +1 -1
- package/dist/plugins/cache.d.ts +13 -5
- package/dist/plugins/cache.d.ts.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts +1 -2
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/routing/codegen.d.ts.map +1 -1
- package/dist/routing/convention-lint.d.ts.map +1 -1
- package/dist/routing/file-cache.d.ts +8 -0
- package/dist/routing/file-cache.d.ts.map +1 -0
- package/dist/routing/index.js +2 -2
- package/dist/routing/interception.d.ts +0 -5
- package/dist/routing/interception.d.ts.map +1 -1
- package/dist/routing/manifest-codegen.d.ts +32 -0
- package/dist/routing/manifest-codegen.d.ts.map +1 -0
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/schema-bridge.d.ts +15 -0
- package/dist/schema-bridge.d.ts.map +1 -1
- package/dist/search-params/define.d.ts.map +1 -1
- package/dist/search-params/index.js +7 -3
- package/dist/search-params/index.js.map +1 -1
- package/dist/search-params/wrappers.d.ts.map +1 -1
- package/dist/segment-params/define.d.ts.map +1 -1
- package/dist/segment-params/index.js +1 -1
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/actions.d.ts +0 -14
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/build-manifest.d.ts +0 -29
- package/dist/server/build-manifest.d.ts.map +1 -1
- package/dist/server/canonicalize.d.ts +10 -0
- package/dist/server/canonicalize.d.ts.map +1 -1
- package/dist/server/deny-renderer.d.ts +1 -1
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/html-injector-core.d.ts +8 -0
- package/dist/server/html-injector-core.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/internal.d.ts +1 -5
- package/dist/server/internal.d.ts.map +1 -1
- package/dist/server/internal.js +12 -171
- package/dist/server/internal.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +0 -11
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/render-utils.d.ts +25 -0
- package/dist/server/render-utils.d.ts.map +1 -0
- package/dist/server/route-element-builder.d.ts +11 -25
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +2 -18
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/dist/shared/href-validation.d.ts.map +1 -1
- package/dist/shims/image.d.ts +15 -15
- package/package.json +7 -8
- package/src/adapters/cloudflare-kv-cache.ts +56 -19
- package/src/adapters/compress-module.ts +27 -118
- package/src/adapters/nitro.ts +3 -41
- package/src/adapters/raw-imports.d.ts +5 -0
- package/src/cache/cache-api.ts +9 -1
- package/src/cache/index.ts +5 -2
- package/src/cache/invalidation-epoch.ts +93 -0
- package/src/cache/redis-handler.ts +33 -2
- package/src/cache/timber-cache.ts +109 -53
- package/src/cli-schema-sync.ts +123 -30
- package/src/cli.ts +33 -37
- package/src/client/browser-entry/hmr.ts +0 -11
- package/src/client/browser-entry/hydrate.ts +39 -5
- package/src/client/browser-entry/post-hydration.ts +6 -15
- package/src/client/browser-entry/router-init.ts +98 -3
- package/src/client/history.ts +10 -24
- package/src/client/navigation-api.ts +14 -9
- package/src/client/navigation-context.ts +1 -21
- package/src/client/navigation-root.tsx +0 -8
- package/src/client/router.ts +18 -8
- package/src/client/rsc-fetch.ts +7 -2
- package/src/client/use-search-params.ts +10 -5
- package/src/config-validation.ts +33 -25
- package/src/cookies/define-cookie.ts +2 -18
- package/src/dev-tools/dev-page-shell.ts +6 -2
- package/src/dev-tools/hydration-overlay.ts +158 -0
- package/src/dev-tools/index.ts +3 -16
- package/src/fonts/ast-walk.ts +235 -0
- package/src/fonts/ast.ts +91 -249
- package/src/fonts/bundle.ts +16 -9
- package/src/fonts/dev-middleware.ts +16 -5
- package/src/fonts/fallbacks.ts +0 -23
- package/src/fonts/google.ts +7 -3
- package/src/fonts/local.ts +7 -6
- package/src/fonts/paths.ts +58 -0
- package/src/fonts/pipeline.ts +27 -48
- package/src/fonts/transform.ts +27 -0
- package/src/fonts/virtual-modules.ts +1 -57
- package/src/index.ts +5 -11
- package/src/params/resolve-schema.ts +2 -34
- package/src/plugin-context.ts +95 -17
- package/src/plugins/cache.ts +64 -17
- package/src/plugins/dev-server.ts +61 -9
- package/src/plugins/fonts.ts +21 -6
- package/src/plugins/routing.ts +5 -348
- package/src/plugins/server-bundle.ts +1 -0
- package/src/routing/codegen.ts +3 -2
- package/src/routing/convention-lint.ts +4 -3
- package/src/routing/file-cache.ts +23 -0
- package/src/routing/interception.ts +2 -21
- package/src/routing/manifest-codegen.ts +290 -0
- package/src/routing/scanner.ts +5 -17
- package/src/routing/schema-validation.ts +2 -2
- package/src/schema-bridge.ts +63 -2
- package/src/search-params/define.ts +34 -2
- package/src/search-params/wrappers.ts +21 -3
- package/src/segment-params/define.ts +2 -62
- package/src/server/access-gate.tsx +8 -0
- package/src/server/actions.ts +8 -31
- package/src/server/build-manifest.ts +0 -56
- package/src/server/canonicalize.ts +6 -3
- package/src/server/deny-renderer.ts +6 -0
- package/src/server/html-injector-core.ts +19 -0
- package/src/server/html-injectors.ts +14 -4
- package/src/server/internal.ts +1 -18
- package/src/server/node-stream-transforms.ts +5 -72
- package/src/server/render-utils.ts +31 -0
- package/src/server/route-element-builder.ts +77 -47
- package/src/server/rsc-entry/error-renderer.ts +1 -0
- package/src/server/rsc-entry/helpers.ts +6 -34
- package/src/server/rsc-entry/index.ts +11 -0
- package/src/server/rsc-entry/render-route.ts +7 -9
- package/src/server/rsc-entry/rsc-payload.ts +2 -1
- package/src/server/rsc-entry/ssr-renderer.ts +2 -2
- package/src/server/ssr-render.ts +1 -11
- package/src/shared/href-validation.ts +8 -0
- package/dist/_chunks/cli-schema-sync-CBiMEtqa.js +0 -2
- package/dist/_chunks/cli-schema-sync-CQaxupHv.js.map +0 -1
- package/dist/_chunks/define-C3RvHQPK.js.map +0 -1
- package/dist/_chunks/define-DMVhsipD.js.map +0 -1
- package/dist/_chunks/handler-store-BVDgyTh7.js +0 -78
- package/dist/_chunks/handler-store-BVDgyTh7.js.map +0 -1
- package/dist/_chunks/logger-Dt-jQaBO.js +0 -291
- package/dist/_chunks/logger-Dt-jQaBO.js.map +0 -1
- package/dist/_chunks/metadata-routes-BjepjefT.js.map +0 -1
- package/dist/_chunks/resolve-schema-Dj8tQloi.js.map +0 -1
- package/dist/_chunks/schema-bridge-DK_89h8O.js.map +0 -1
- package/dist/_chunks/tracing-M_zKubhk.js.map +0 -1
- package/dist/_chunks/walkers-Ck4Y45XN.js.map +0 -1
- package/dist/_chunks/warnings-CNPXaypM.js +0 -125
- package/dist/_chunks/warnings-CNPXaypM.js.map +0 -1
- package/dist/client/link-pending-store.d.ts +0 -65
- package/dist/client/link-pending-store.d.ts.map +0 -1
- package/dist/client/nav-link-store.d.ts +0 -27
- package/dist/client/nav-link-store.d.ts.map +0 -1
- package/dist/dev-tools/warnings.d.ts +0 -109
- package/dist/dev-tools/warnings.d.ts.map +0 -1
- package/dist/server/flush.d.ts +0 -74
- package/dist/server/flush.d.ts.map +0 -1
- package/src/client/link-pending-store.ts +0 -116
- package/src/client/nav-link-store.ts +0 -37
- package/src/dev-tools/warnings.ts +0 -238
- package/src/server/flush.ts +0 -183
package/src/adapters/nitro.ts
CHANGED
|
@@ -200,7 +200,7 @@ export function nitro(options: NitroAdapterOptions = {}): TimberPlatformAdapter
|
|
|
200
200
|
// Write the compression helper module for runtime use.
|
|
201
201
|
// See design/25-production-deployments.md — self-hosted deployments
|
|
202
202
|
// need application-level compression (Cloudflare handles it at the edge).
|
|
203
|
-
await writeFile(join(outDir, '_compress.mjs'), generateCompressModule());
|
|
203
|
+
await writeFile(join(outDir, '_compress.mjs'), await generateCompressModule());
|
|
204
204
|
|
|
205
205
|
// Prepend the manifest assignment directly into the RSC entry so
|
|
206
206
|
// globalThis.__TIMBER_BUILD_MANIFEST__ is set before any module reads it.
|
|
@@ -257,8 +257,7 @@ export function generateNitroEntry(
|
|
|
257
257
|
buildDir: string,
|
|
258
258
|
outDir: string,
|
|
259
259
|
preset: NitroPreset,
|
|
260
|
-
compress = true
|
|
261
|
-
hasManifestInit = false
|
|
260
|
+
compress = true
|
|
262
261
|
): string {
|
|
263
262
|
// The RSC entry is the main request handler — it exports the fetch handler as default.
|
|
264
263
|
// rsc/ is copied into the nitro dir so the import is local.
|
|
@@ -315,11 +314,6 @@ export function generateNitroEntry(
|
|
|
315
314
|
handlerCall = ` const webResponse = await handler(webRequest)`;
|
|
316
315
|
}
|
|
317
316
|
|
|
318
|
-
// Build manifest init must be imported before the handler so that
|
|
319
|
-
// globalThis.__TIMBER_BUILD_MANIFEST__ is set when the virtual module evaluates.
|
|
320
|
-
// ESM guarantees imports are evaluated in order.
|
|
321
|
-
const manifestImport = hasManifestInit ? "import './_timber-manifest-init.js'\n" : '';
|
|
322
|
-
|
|
323
317
|
// Import runWithWaitUntil only when the preset supports it.
|
|
324
318
|
const waitUntilImport = supportsWaitUntil ? ', runWithWaitUntil' : '';
|
|
325
319
|
|
|
@@ -329,7 +323,7 @@ export function generateNitroEntry(
|
|
|
329
323
|
return `// Generated by @timber-js/app/adapters/nitro
|
|
330
324
|
// Do not edit — this file is regenerated on each build.
|
|
331
325
|
|
|
332
|
-
|
|
326
|
+
import handler, { runWithEarlyHintsSender${waitUntilImport} } from '${serverEntryRelative}'
|
|
333
327
|
${compressImport}
|
|
334
328
|
// Set TIMBER_RUNTIME for instrumentation.ts conditional SDK initialization.
|
|
335
329
|
// See design/25-production-deployments.md §"TIMBER_RUNTIME".
|
|
@@ -349,38 +343,6 @@ ${handlerCall}
|
|
|
349
343
|
`;
|
|
350
344
|
}
|
|
351
345
|
|
|
352
|
-
/** @internal Exported for testing. */
|
|
353
|
-
export function generateNitroConfig(
|
|
354
|
-
preset: NitroPreset,
|
|
355
|
-
userConfig?: Record<string, unknown>
|
|
356
|
-
): string {
|
|
357
|
-
const presetConfig = PRESET_CONFIGS[preset];
|
|
358
|
-
|
|
359
|
-
const config: Record<string, unknown> = {
|
|
360
|
-
preset: presetConfig.nitroPreset,
|
|
361
|
-
entry: './entry.ts',
|
|
362
|
-
output: { dir: presetConfig.outputDir },
|
|
363
|
-
// Static asset cache headers — hashed assets are immutable, others get 1h.
|
|
364
|
-
// See design/25-production-deployments.md §"CDN / Edge Cache"
|
|
365
|
-
routeRules: {
|
|
366
|
-
'/assets/**': { headers: { 'Cache-Control': IMMUTABLE_CACHE } },
|
|
367
|
-
'/_timber/fonts/**': { headers: { 'Cache-Control': IMMUTABLE_CACHE } },
|
|
368
|
-
},
|
|
369
|
-
...presetConfig.extraConfig,
|
|
370
|
-
...userConfig,
|
|
371
|
-
};
|
|
372
|
-
|
|
373
|
-
const configJson = JSON.stringify(config, null, 2);
|
|
374
|
-
|
|
375
|
-
return `// Generated by @timber-js/app/adapters/nitro
|
|
376
|
-
// Do not edit — this file is regenerated on each build.
|
|
377
|
-
|
|
378
|
-
import { defineNitroConfig } from 'nitro/config'
|
|
379
|
-
|
|
380
|
-
export default defineNitroConfig(${configJson})
|
|
381
|
-
`;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
346
|
// ─── Preview ─────────────────────────────────────────────────────────────────
|
|
385
347
|
|
|
386
348
|
/** Presets that produce a locally-runnable server entry. */
|
package/src/cache/cache-api.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CacheOptions } from './index';
|
|
2
2
|
import { createCache } from './timber-cache';
|
|
3
3
|
import { getCacheHandler } from './handler-store';
|
|
4
|
+
import { recordInvalidation } from './invalidation-epoch';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Public caching API: `cache(fn, opts)`.
|
|
@@ -23,7 +24,11 @@ export function cache<Fn extends (...args: any[]) => Promise<any>>(
|
|
|
23
24
|
opts: CacheOptions<Fn>,
|
|
24
25
|
stableId?: string
|
|
25
26
|
): Fn {
|
|
26
|
-
|
|
27
|
+
// Pass the getter, not a resolved handler: the wrapper re-resolves it on
|
|
28
|
+
// every call, so module-scope wrappers created before the framework's boot
|
|
29
|
+
// wiring calls setCacheHandler() still pick up the configured handler
|
|
30
|
+
// instead of permanently binding the in-memory fallback (TIM-1029).
|
|
31
|
+
return createCache(fn, opts, getCacheHandler, stableId);
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
/**
|
|
@@ -35,5 +40,8 @@ export function cache<Fn extends (...args: any[]) => Promise<any>>(
|
|
|
35
40
|
* ```
|
|
36
41
|
*/
|
|
37
42
|
cache.invalidate = async function invalidate(opts: { key?: string; tag?: string }): Promise<void> {
|
|
43
|
+
// Record before the handler delete so a cached fn in flight skips its
|
|
44
|
+
// post-resolve set instead of resurrecting the invalidated value (TIM-1028).
|
|
45
|
+
recordInvalidation(opts);
|
|
38
46
|
await getCacheHandler().invalidate(opts);
|
|
39
47
|
};
|
package/src/cache/index.ts
CHANGED
|
@@ -42,12 +42,14 @@ export class MemoryCacheHandler implements CacheHandler {
|
|
|
42
42
|
private maxBytes: number | undefined;
|
|
43
43
|
private maxEntryBytes: number | undefined;
|
|
44
44
|
private currentBytes = 0;
|
|
45
|
+
private readonly _trackBytes: boolean;
|
|
45
46
|
|
|
46
47
|
constructor(opts?: MemoryCacheHandlerOptions) {
|
|
47
48
|
// maxEntries takes precedence over deprecated maxSize
|
|
48
49
|
this.maxEntries = opts?.maxEntries ?? opts?.maxSize ?? 1000;
|
|
49
50
|
this.maxBytes = opts?.maxBytes;
|
|
50
51
|
this.maxEntryBytes = opts?.maxEntryBytes;
|
|
52
|
+
this._trackBytes = this.maxBytes !== undefined || this.maxEntryBytes !== undefined;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
async get(key: string) {
|
|
@@ -63,7 +65,7 @@ export class MemoryCacheHandler implements CacheHandler {
|
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
async set(key: string, value: unknown, opts: { ttl: number; tags: string[] }) {
|
|
66
|
-
const byteSize = estimateByteSize(value);
|
|
68
|
+
const byteSize = this._trackBytes ? estimateByteSize(value) : 0;
|
|
67
69
|
|
|
68
70
|
// Reject entries exceeding per-entry byte limit
|
|
69
71
|
if (this.maxEntryBytes !== undefined && byteSize > this.maxEntryBytes) {
|
|
@@ -125,7 +127,7 @@ export class MemoryCacheHandler implements CacheHandler {
|
|
|
125
127
|
return this.store.size;
|
|
126
128
|
}
|
|
127
129
|
|
|
128
|
-
/** Estimated total byte size of all cached values. */
|
|
130
|
+
/** Estimated total byte size of all cached values. Only tracked when maxBytes or maxEntryBytes is configured; returns 0 otherwise. */
|
|
129
131
|
get bytes(): number {
|
|
130
132
|
return this.currentBytes;
|
|
131
133
|
}
|
|
@@ -145,6 +147,7 @@ export { RedisCacheHandler } from './redis-handler';
|
|
|
145
147
|
export type { RedisClient } from './redis-handler';
|
|
146
148
|
export { cache } from './cache-api';
|
|
147
149
|
export { setCacheHandler, getCacheHandler } from './handler-store';
|
|
150
|
+
export { setDefaultSingleflightTimeout } from './timber-cache';
|
|
148
151
|
// NOTE: registerCachedFunction (runtime for 'use cache' directive) removed.
|
|
149
152
|
// Future feature pending design doc. See design/06-caching.md.
|
|
150
153
|
export { estimateByteSize } from './sizeof';
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-process invalidation epoch tracking for timber.cache (TIM-1028).
|
|
3
|
+
*
|
|
4
|
+
* handler.set for a cache miss runs after `fn()` resolves. An explicit
|
|
5
|
+
* `cache.invalidate({ tag })` / `revalidateTag` that lands while fn is in
|
|
6
|
+
* flight would otherwise be silently undone — the just-invalidated value
|
|
7
|
+
* re-stored as fresh for the full TTL, resurrecting pre-mutation data.
|
|
8
|
+
*
|
|
9
|
+
* Every invalidation bumps a monotonic epoch and records it against the
|
|
10
|
+
* invalidated key/tag. Before storing a result, the cache wrapper checks
|
|
11
|
+
* whether its key or any of its tags were invalidated after fn started and
|
|
12
|
+
* skips the write if so. Skipping is always safe: the only cost is one
|
|
13
|
+
* extra cache miss on the next call.
|
|
14
|
+
*
|
|
15
|
+
* Scope: per-process, like the singleflight map. An invalidation issued by
|
|
16
|
+
* another instance against a shared handler (Redis/KV) during fn flight is
|
|
17
|
+
* not observable here — closing that window fully would require store-level
|
|
18
|
+
* versioning, which no CacheHandler backend offers.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
let epoch = 0;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Last-invalidated epoch per `key:<k>` / `tag:<t>` entry. Insertion order is
|
|
25
|
+
* epoch order (entries are re-inserted on update), so the oldest entry is
|
|
26
|
+
* always first — pruning evicts lowest epochs.
|
|
27
|
+
*/
|
|
28
|
+
const lastInvalidated = new Map<string, number>();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Bound on tracked entries so high-cardinality tag invalidation (e.g.
|
|
32
|
+
* per-user tags) cannot grow this map without limit.
|
|
33
|
+
*/
|
|
34
|
+
const MAX_TRACKED_ENTRIES = 10_000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Highest epoch evicted from the map. Any execution that started at or
|
|
38
|
+
* before this epoch conservatively counts as invalidated — history was
|
|
39
|
+
* truncated, so we can no longer prove it wasn't.
|
|
40
|
+
*/
|
|
41
|
+
let evictedEpochFloor = 0;
|
|
42
|
+
|
|
43
|
+
/** The current epoch. Capture before running fn(), pass to wasInvalidatedSince. */
|
|
44
|
+
export function currentInvalidationEpoch(): number {
|
|
45
|
+
return epoch;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Record an invalidation. Call BEFORE handler.invalidate() so an execution
|
|
50
|
+
* whose set-check races the invalidation errs toward skipping the write.
|
|
51
|
+
*/
|
|
52
|
+
export function recordInvalidation(opts: { key?: string; tag?: string }): void {
|
|
53
|
+
epoch++;
|
|
54
|
+
if (opts.key) track(`key:${opts.key}`);
|
|
55
|
+
if (opts.tag) track(`tag:${opts.tag}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function track(entry: string): void {
|
|
59
|
+
lastInvalidated.delete(entry);
|
|
60
|
+
lastInvalidated.set(entry, epoch);
|
|
61
|
+
while (lastInvalidated.size > MAX_TRACKED_ENTRIES) {
|
|
62
|
+
const oldest = lastInvalidated.entries().next().value;
|
|
63
|
+
if (oldest === undefined) break;
|
|
64
|
+
evictedEpochFloor = Math.max(evictedEpochFloor, oldest[1]);
|
|
65
|
+
lastInvalidated.delete(oldest[0]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether the key or any tag was invalidated after `startEpoch` (the epoch
|
|
71
|
+
* captured when fn started executing).
|
|
72
|
+
*/
|
|
73
|
+
export function wasInvalidatedSince(startEpoch: number, key: string, tags: string[]): boolean {
|
|
74
|
+
return lastInvalidationEpochFor(key, tags) > startEpoch;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The highest epoch at which the key or any of the tags was invalidated
|
|
79
|
+
* (0 if never). Used to scope the singleflight key: callers arriving after
|
|
80
|
+
* an invalidation must not coalesce onto a flight that started before it —
|
|
81
|
+
* they would receive the pre-invalidation result the invalidation was
|
|
82
|
+
* issued to discard.
|
|
83
|
+
*/
|
|
84
|
+
export function lastInvalidationEpochFor(key: string, tags: string[]): number {
|
|
85
|
+
let last = evictedEpochFloor;
|
|
86
|
+
const keyEpoch = lastInvalidated.get(`key:${key}`);
|
|
87
|
+
if (keyEpoch !== undefined && keyEpoch > last) last = keyEpoch;
|
|
88
|
+
for (const tag of tags) {
|
|
89
|
+
const tagEpoch = lastInvalidated.get(`tag:${tag}`);
|
|
90
|
+
if (tagEpoch !== undefined && tagEpoch > last) last = tagEpoch;
|
|
91
|
+
}
|
|
92
|
+
return last;
|
|
93
|
+
}
|
|
@@ -23,7 +23,9 @@ import type { CacheHandler } from './index';
|
|
|
23
23
|
* expire: (k, s) => redis.expire(k, s),
|
|
24
24
|
* del: (k) => redis.del(k),
|
|
25
25
|
* sadd: (k, ...m) => redis.sadd(k, ...m),
|
|
26
|
+
* srem: (k, ...m) => redis.srem(k, ...m),
|
|
26
27
|
* smembers: (k) => redis.smembers(k),
|
|
28
|
+
* ttl: (k) => redis.ttl(k),
|
|
27
29
|
* };
|
|
28
30
|
* ```
|
|
29
31
|
*
|
|
@@ -38,7 +40,9 @@ import type { CacheHandler } from './index';
|
|
|
38
40
|
* expire: (k, s) => redis.expire(k, s),
|
|
39
41
|
* del: (k) => redis.del(k),
|
|
40
42
|
* sadd: (k, ...m) => redis.sAdd(k, m),
|
|
43
|
+
* srem: (k, ...m) => redis.sRem(k, m),
|
|
41
44
|
* smembers: (k) => redis.sMembers(k),
|
|
45
|
+
* ttl: (k) => redis.ttl(k),
|
|
42
46
|
* };
|
|
43
47
|
* ```
|
|
44
48
|
*
|
|
@@ -54,7 +58,9 @@ import type { CacheHandler } from './index';
|
|
|
54
58
|
* expire: (k, s) => redis.expire(k, s),
|
|
55
59
|
* del: (k) => redis.del(k),
|
|
56
60
|
* sadd: (k, ...m) => redis.sadd(k, m[0], ...m.slice(1)),
|
|
61
|
+
* srem: (k, ...m) => redis.srem(k, m[0], ...m.slice(1)),
|
|
57
62
|
* smembers: (k) => redis.smembers(k),
|
|
63
|
+
* ttl: (k) => redis.ttl(k),
|
|
58
64
|
* };
|
|
59
65
|
* ```
|
|
60
66
|
*/
|
|
@@ -72,8 +78,15 @@ export interface RedisClient {
|
|
|
72
78
|
del(key: string): Promise<unknown>;
|
|
73
79
|
/** `SADD key member [member ...]` — add members to a set. */
|
|
74
80
|
sadd(key: string, ...members: string[]): Promise<unknown>;
|
|
81
|
+
/** `SREM key member [member ...]` — remove members from a set. */
|
|
82
|
+
srem(key: string, ...members: string[]): Promise<unknown>;
|
|
75
83
|
/** `SMEMBERS key` — all members of a set. */
|
|
76
84
|
smembers(key: string): Promise<string[]>;
|
|
85
|
+
/**
|
|
86
|
+
* `TTL key` — remaining time-to-live in seconds. Returns -2 if the key does
|
|
87
|
+
* not exist, -1 if the key exists but has no expiry.
|
|
88
|
+
*/
|
|
89
|
+
ttl(key: string): Promise<number>;
|
|
77
90
|
}
|
|
78
91
|
|
|
79
92
|
const KEY_PREFIX = 'timber:cache:';
|
|
@@ -119,7 +132,7 @@ export class RedisCacheHandler implements CacheHandler {
|
|
|
119
132
|
async set(key: string, value: unknown, opts: { ttl: number; tags: string[] }): Promise<void> {
|
|
120
133
|
const ck = this.cacheKey(key);
|
|
121
134
|
const expiresAt = Date.now() + opts.ttl * 1000;
|
|
122
|
-
const payload = JSON.stringify({ value, expiresAt });
|
|
135
|
+
const payload = JSON.stringify({ value, expiresAt, tags: opts.tags });
|
|
123
136
|
|
|
124
137
|
// Redis TTL with generous margin beyond the logical TTL to allow SWR reads
|
|
125
138
|
// on stale entries. The logical staleness is determined by expiresAt.
|
|
@@ -128,14 +141,32 @@ export class RedisCacheHandler implements CacheHandler {
|
|
|
128
141
|
const redisTtlSeconds = Math.max(opts.ttl * 2 + 60, 120);
|
|
129
142
|
await this.client.set(ck, payload, redisTtlSeconds);
|
|
130
143
|
|
|
131
|
-
// Track key membership in each tag set
|
|
144
|
+
// Track key membership in each tag set, and expire the set so it doesn't
|
|
145
|
+
// outlive all its members. Only extend the TTL, never shorten — a short-TTL
|
|
146
|
+
// entry must not shrink a tag set that already contains long-TTL entries,
|
|
147
|
+
// otherwise invalidate({tag}) would miss the long-lived entries.
|
|
132
148
|
for (const tag of opts.tags) {
|
|
133
149
|
await this.client.sadd(this.tagKey(tag), key);
|
|
150
|
+
const currentTtl = await this.client.ttl(this.tagKey(tag));
|
|
151
|
+
// Only extend, never shorten. -2 = key doesn't exist (rare race after
|
|
152
|
+
// sadd), -1 = no expiry (new set or legacy — set initial TTL), positive
|
|
153
|
+
// but shorter = extend. A longer current TTL means a longer-lived entry
|
|
154
|
+
// set it first — don't shorten.
|
|
155
|
+
if (currentTtl < redisTtlSeconds) {
|
|
156
|
+
await this.client.expire(this.tagKey(tag), redisTtlSeconds);
|
|
157
|
+
}
|
|
134
158
|
}
|
|
135
159
|
}
|
|
136
160
|
|
|
137
161
|
async invalidate(opts: { key?: string; tag?: string }): Promise<void> {
|
|
138
162
|
if (opts.key) {
|
|
163
|
+
const raw = await this.client.get(this.cacheKey(opts.key));
|
|
164
|
+
if (raw !== null) {
|
|
165
|
+
const entry = JSON.parse(raw) as { tags?: string[] };
|
|
166
|
+
if (entry.tags) {
|
|
167
|
+
await Promise.all(entry.tags.map((tag) => this.client.srem(this.tagKey(tag), opts.key!)));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
139
170
|
await this.client.del(this.cacheKey(opts.key));
|
|
140
171
|
}
|
|
141
172
|
|
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
import type { CacheHandler, CacheOptions } from './index';
|
|
2
2
|
import { stableStringify } from './stable-stringify';
|
|
3
3
|
import { createSingleflight } from './singleflight';
|
|
4
|
+
import {
|
|
5
|
+
currentInvalidationEpoch,
|
|
6
|
+
lastInvalidationEpochFor,
|
|
7
|
+
wasInvalidatedSince,
|
|
8
|
+
} from './invalidation-epoch';
|
|
4
9
|
import { addSpanEventSync } from '../server/tracing.js';
|
|
5
10
|
import { logSwrRefetchFailed } from '../server/logger.js';
|
|
6
11
|
import { getWaitUntil } from '../server/waituntil-bridge.js';
|
|
7
12
|
import { fnv1aHash } from './fast-hash.js';
|
|
8
13
|
|
|
9
|
-
|
|
14
|
+
let defaultSingleflight = createSingleflight();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Set the timeout for the module-level default singleflight.
|
|
18
|
+
* Called at framework boot with renderTimeoutMs from timber.config.ts so that
|
|
19
|
+
* cache calls without a per-call timeoutMs still get deadlock protection.
|
|
20
|
+
* See design/06-caching.md §"Singleflight Timeout — Deadlock Risk".
|
|
21
|
+
*/
|
|
22
|
+
export function setDefaultSingleflightTimeout(timeoutMs: number): void {
|
|
23
|
+
defaultSingleflight = createSingleflight({ timeoutMs });
|
|
24
|
+
}
|
|
10
25
|
|
|
11
26
|
/**
|
|
12
27
|
* Generate a cache key from function identity and serialized args.
|
|
@@ -35,16 +50,59 @@ function resolveTags<Fn extends (...args: any[]) => any>(
|
|
|
35
50
|
return opts.tags(...args);
|
|
36
51
|
}
|
|
37
52
|
|
|
38
|
-
|
|
39
|
-
|
|
53
|
+
/** Per-process count of byte-identical fn sources seen by deriveFallbackFnId. */
|
|
54
|
+
const fallbackIdOccurrences = new Map<string, number>();
|
|
55
|
+
let hasWarnedFallbackId = false;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Derive a fallback fnId for callsites the timber-cache-transform Vite plugin
|
|
59
|
+
* could not statically detect (aliased bindings, user re-export wrappers).
|
|
60
|
+
*
|
|
61
|
+
* The base is fnv1a(fn.toString()) — deterministic across instances and cold
|
|
62
|
+
* starts of the same build, so two *different* functions can never swap fnIds
|
|
63
|
+
* by module-evaluation order (the TIM-997 poisoning scenario that the old
|
|
64
|
+
* process-local counter allowed; see TIM-1054). A per-process occurrence
|
|
65
|
+
* suffix disambiguates byte-identical duplicate sources. That residue is the
|
|
66
|
+
* one remaining hazard: closures produced by the same factory share source
|
|
67
|
+
* text but capture different values, and their occurrence order may differ
|
|
68
|
+
* across instances — such callsites need an explicit `key` option.
|
|
69
|
+
*/
|
|
70
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
71
|
+
function deriveFallbackFnId(fn: (...args: any[]) => any): string {
|
|
72
|
+
const sourceHash = fnv1aHash(fn.toString());
|
|
73
|
+
const occurrence = fallbackIdOccurrences.get(sourceHash) ?? 0;
|
|
74
|
+
fallbackIdOccurrences.set(sourceHash, occurrence + 1);
|
|
75
|
+
|
|
76
|
+
if (!hasWarnedFallbackId && typeof process !== 'undefined' && process.env?.NODE_ENV !== 'test') {
|
|
77
|
+
hasWarnedFallbackId = true;
|
|
78
|
+
console.warn(
|
|
79
|
+
'[timber] cache() callsite has no build-time stable ID and no explicit `key` option — ' +
|
|
80
|
+
'the call is not statically visible to the timber Vite plugin (e.g. an aliased ' +
|
|
81
|
+
'binding like `const c = cache` or a re-export wrapper module). Falling back to a ' +
|
|
82
|
+
'function-source hash for the cache key. With a shared CacheHandler (Redis/KV) this ' +
|
|
83
|
+
'is safe for distinct functions, but byte-identical closures from a factory can swap ' +
|
|
84
|
+
'cache entries across instances. Provide an explicit `key` option to make the cache ' +
|
|
85
|
+
'key contract stable and auditable.'
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return `timber-cache:fn:${sourceHash}:${occurrence}`;
|
|
90
|
+
}
|
|
40
91
|
|
|
41
92
|
/**
|
|
42
93
|
* Creates a cached wrapper around an async function.
|
|
43
94
|
*
|
|
44
|
-
* -
|
|
45
|
-
* - Singleflight: concurrent misses → single execution
|
|
95
|
+
* - FNV-1a default keys with normalized JSON args
|
|
96
|
+
* - Singleflight: concurrent misses → single execution AND single
|
|
97
|
+
* handler.set — the write happens inside the coalesced callback (TIM-1028)
|
|
98
|
+
* - Writes are skipped when the key/tags were invalidated while fn was in
|
|
99
|
+
* flight, so explicit invalidation is never silently undone (TIM-1028)
|
|
46
100
|
* - SWR: serve stale immediately, background refetch
|
|
47
101
|
* - Tags as string[] or function of args
|
|
102
|
+
* - The handler is resolved per invocation via `getHandler`, never captured
|
|
103
|
+
* at wrapper-creation time — module-scope cache() wrappers created before
|
|
104
|
+
* the framework's boot wiring calls setCacheHandler() still use the
|
|
105
|
+
* configured handler once it arrives (TIM-1029)
|
|
48
106
|
* - No ALS dependency for correctness — cache() works outside a request
|
|
49
107
|
* context. The waitUntil ALS bridge is read optionally so the SWR
|
|
50
108
|
* background refetch survives handler return on Workers/Lambda.
|
|
@@ -56,44 +114,30 @@ let hasWarnedUnstableId = false;
|
|
|
56
114
|
export function createCache<Fn extends (...args: any[]) => Promise<any>>(
|
|
57
115
|
fn: Fn,
|
|
58
116
|
opts: CacheOptions<Fn>,
|
|
59
|
-
|
|
117
|
+
getHandler: () => CacheHandler,
|
|
60
118
|
stableId?: string
|
|
61
119
|
): Fn {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
typeof process !== 'undefined' &&
|
|
74
|
-
process.env?.NODE_ENV !== 'test'
|
|
75
|
-
) {
|
|
76
|
-
hasWarnedUnstableId = true;
|
|
77
|
-
console.warn(
|
|
78
|
-
'[timber] cache() called without a stable ID or explicit key function. ' +
|
|
79
|
-
'Cache keys will use a process-local counter which is unstable across ' +
|
|
80
|
-
'cold starts. Run through the timber Vite plugin or provide an explicit ' +
|
|
81
|
-
'`key` option to fix this.'
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
const sf = opts.timeoutMs
|
|
86
|
-
? createSingleflight({ timeoutMs: opts.timeoutMs })
|
|
87
|
-
: defaultSingleflight;
|
|
120
|
+
// With an explicit `key` option the fnId never reaches key generation, so
|
|
121
|
+
// no fallback derivation (or warning) is needed in that case.
|
|
122
|
+
const fnId = stableId ?? (opts.key ? undefined : deriveFallbackFnId(fn));
|
|
123
|
+
|
|
124
|
+
// Per-call timeoutMs gets a dedicated singleflight instance. Otherwise
|
|
125
|
+
// resolve defaultSingleflight per invocation (not captured at creation
|
|
126
|
+
// time) so wrappers created before boot pick up the timeout once
|
|
127
|
+
// setDefaultSingleflightTimeout is called (TIM-1032, same rationale as
|
|
128
|
+
// getCacheHandler for TIM-1029).
|
|
129
|
+
const perCallSf =
|
|
130
|
+
opts.timeoutMs !== undefined ? createSingleflight({ timeoutMs: opts.timeoutMs }) : undefined;
|
|
88
131
|
|
|
89
132
|
// Cast to Fn to preserve the original function's generic call signature.
|
|
90
133
|
// Without this, generic type parameters (e.g. <T> in apiFetch<T>) are
|
|
91
134
|
// erased and callers lose type safety on the return type.
|
|
92
135
|
return (async (...args: Parameters<Fn>): Promise<Awaited<ReturnType<Fn>>> => {
|
|
93
|
-
const
|
|
136
|
+
const sf = perCallSf ?? defaultSingleflight;
|
|
137
|
+
const key = opts.key ? opts.key(...args) : defaultKeyGenerator(fnId as string, args);
|
|
94
138
|
|
|
95
139
|
const cacheStart = performance.now();
|
|
96
|
-
const cached = await
|
|
140
|
+
const cached = await getHandler().get(key);
|
|
97
141
|
|
|
98
142
|
if (cached && !cached.stale) {
|
|
99
143
|
// Record as OTEL span event on enclosing span (not a child span).
|
|
@@ -105,6 +149,30 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
|
|
|
105
149
|
return cached.value as Awaited<ReturnType<Fn>>;
|
|
106
150
|
}
|
|
107
151
|
|
|
152
|
+
// From here fn() will execute (SWR refetch or miss). Scope the
|
|
153
|
+
// singleflight key by the last invalidation epoch affecting this
|
|
154
|
+
// key/tags: a caller arriving after an invalidation must not coalesce
|
|
155
|
+
// onto a flight that started before it — it would receive the
|
|
156
|
+
// pre-invalidation result the invalidation was issued to discard.
|
|
157
|
+
// NUL separator: cannot collide with user-provided key strings.
|
|
158
|
+
const tags = resolveTags(opts, args);
|
|
159
|
+
const flightKey = `${key}\u0000${lastInvalidationEpochFor(key, tags)}`;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Run fn and store the result — exactly once per coalesced execution,
|
|
163
|
+
* inside the singleflight callback. The write is skipped when the key or
|
|
164
|
+
* any resolved tag was invalidated after fn started: storing would
|
|
165
|
+
* resurrect just-invalidated data as fresh for the full TTL.
|
|
166
|
+
*/
|
|
167
|
+
const executeAndStore = async (): Promise<Awaited<ReturnType<Fn>>> => {
|
|
168
|
+
const startEpoch = currentInvalidationEpoch();
|
|
169
|
+
const result = await fn(...args);
|
|
170
|
+
if (!wasInvalidatedSince(startEpoch, key, tags)) {
|
|
171
|
+
await getHandler().set(key, result, { ttl: opts.ttl, tags });
|
|
172
|
+
}
|
|
173
|
+
return result;
|
|
174
|
+
};
|
|
175
|
+
|
|
108
176
|
if (cached && cached.stale && opts.staleWhileRevalidate) {
|
|
109
177
|
// Record stale cache hit as OTEL span event (fire-and-forget).
|
|
110
178
|
addSpanEventSync('timber.cache.hit', {
|
|
@@ -114,11 +182,9 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
|
|
|
114
182
|
});
|
|
115
183
|
// Serve stale immediately, trigger background refetch
|
|
116
184
|
const refetch = sf
|
|
117
|
-
.do(`swr:${
|
|
185
|
+
.do(`swr:${flightKey}`, async () => {
|
|
118
186
|
try {
|
|
119
|
-
|
|
120
|
-
const tags = resolveTags(opts, args);
|
|
121
|
-
await handler.set(key, fresh, { ttl: opts.ttl, tags });
|
|
187
|
+
await executeAndStore();
|
|
122
188
|
} catch (err) {
|
|
123
189
|
// Failed refetch — stale entry continues to be served.
|
|
124
190
|
logSwrRefetchFailed({ cacheKey: key, error: err });
|
|
@@ -137,10 +203,10 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
|
|
|
137
203
|
return cached.value as Awaited<ReturnType<Fn>>;
|
|
138
204
|
}
|
|
139
205
|
|
|
140
|
-
// Cache miss (or stale without SWR) — execute with singleflight
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
206
|
+
// Cache miss (or stale without SWR) — execute with singleflight.
|
|
207
|
+
// The handler.set lives inside the coalesced callback, so N concurrent
|
|
208
|
+
// misses produce exactly one write (TIM-1028).
|
|
209
|
+
const result = await sf.do(flightKey, executeAndStore);
|
|
144
210
|
|
|
145
211
|
// Record cache miss as OTEL span event (fire-and-forget).
|
|
146
212
|
addSpanEventSync('timber.cache.miss', {
|
|
@@ -148,16 +214,6 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
|
|
|
148
214
|
duration_ms: Math.round(performance.now() - cacheStart),
|
|
149
215
|
});
|
|
150
216
|
|
|
151
|
-
return result
|
|
217
|
+
return result;
|
|
152
218
|
}) as unknown as Fn;
|
|
153
219
|
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Invalidate cache entries by tag or key.
|
|
157
|
-
*/
|
|
158
|
-
createCache.invalidate = async function invalidate(
|
|
159
|
-
handler: CacheHandler,
|
|
160
|
-
opts: { key?: string; tag?: string }
|
|
161
|
-
): Promise<void> {
|
|
162
|
-
await handler.invalidate(opts);
|
|
163
|
-
};
|