@decocms/start 2.28.2 → 2.30.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.
Files changed (49) hide show
  1. package/.agents/skills/deco-to-tanstack-migration/SKILL.md +1 -1
  2. package/.agents/skills/deco-to-tanstack-migration/references/vtex-commerce.md +5 -1
  3. package/.agents/skills/deco-to-tanstack-migration/references/worker-cloudflare.md +107 -10
  4. package/.agents/skills/deco-to-tanstack-migration/templates/package-json.md +5 -1
  5. package/.cursor/rules/migration-tooling-policy.mdc +22 -2
  6. package/.github/workflows/deploy.yml +115 -0
  7. package/.github/workflows/preview.yml +143 -0
  8. package/.github/workflows/regen-blocks.yml +56 -0
  9. package/.github/workflows/release.yml +26 -0
  10. package/.github/workflows/sync-secrets.yml +173 -0
  11. package/CODEOWNERS +16 -0
  12. package/MIGRATION_TOOLING_PLAN.md +16 -4
  13. package/README.md +178 -79
  14. package/deploy/README.md +85 -0
  15. package/deploy/sites/als-tanstack.jsonc +7 -0
  16. package/deploy/sites/americanas-tanstack.jsonc +4 -0
  17. package/deploy/sites/baggagio-tanstack.jsonc +4 -0
  18. package/deploy/sites/casaevideo-storefront.jsonc +11 -0
  19. package/deploy/sites/lebiscuit-tanstack.jsonc +19 -0
  20. package/deploy/sites/miess-01-tanstack.jsonc +8 -0
  21. package/deploy/wrangler-template.jsonc +28 -0
  22. package/package.json +18 -15
  23. package/scripts/deploy/build-wrangler-config.mjs +49 -0
  24. package/scripts/deploy/jsonc.mjs +76 -0
  25. package/scripts/deploy/resolve-site.mjs +58 -0
  26. package/scripts/deploy/site-registry.mjs +142 -0
  27. package/scripts/deploy/wrangler-wrapper.mjs +126 -0
  28. package/scripts/migrate/phase-scaffold.ts +13 -3
  29. package/scripts/migrate/phase-verify.ts +6 -1
  30. package/scripts/migrate/templates/github-workflows.ts +98 -0
  31. package/scripts/migrate/templates/package-json.ts +9 -2
  32. package/src/cms/resolve.ts +81 -63
  33. package/src/cms/sectionLoaders.ts +11 -0
  34. package/src/index.ts +3 -0
  35. package/src/sdk/cachedLoader.ts +36 -13
  36. package/src/sdk/composite.test.ts +121 -0
  37. package/src/sdk/composite.ts +114 -0
  38. package/src/sdk/instrumentedFetch.ts +56 -0
  39. package/src/sdk/logger.test.ts +135 -0
  40. package/src/sdk/logger.ts +166 -0
  41. package/src/sdk/observability.ts +75 -0
  42. package/src/sdk/otel.test.ts +59 -0
  43. package/src/sdk/otel.ts +270 -29
  44. package/src/sdk/otelAdapters.test.ts +135 -0
  45. package/src/sdk/otelAdapters.ts +401 -0
  46. package/src/sdk/sampler.test.ts +127 -0
  47. package/src/sdk/sampler.ts +183 -0
  48. package/src/sdk/workerEntry.ts +541 -476
  49. package/scripts/migrate/templates/wrangler.ts +0 -30
@@ -0,0 +1,98 @@
1
+ // Caller workflow stubs for new sites. Each stub delegates to a reusable
2
+ // workflow under `decocms/deco-start/.github/workflows/` -- the customer repo
3
+ // holds no deploy/build logic of its own. See D6 in
4
+ // `.cursor/rules/migration-tooling-policy.mdc` and the `deploy/` directory
5
+ // for the central registry contract.
6
+
7
+ const DEPLOY_YML = `name: Deploy
8
+
9
+ # Thin caller for decocms/deco-start's central deploy workflow.
10
+
11
+ on:
12
+ push:
13
+ branches: [main]
14
+
15
+ permissions:
16
+ contents: write
17
+
18
+ jobs:
19
+ deploy:
20
+ uses: decocms/deco-start/.github/workflows/deploy.yml@v2
21
+ secrets: inherit
22
+ `;
23
+
24
+ const PREVIEW_YML = `name: Preview
25
+
26
+ # Thin caller for decocms/deco-start's central preview workflow.
27
+
28
+ on:
29
+ repository_dispatch:
30
+ types: [preview-deploy]
31
+ pull_request:
32
+ types: [opened, synchronize, reopened]
33
+ push:
34
+ branches: ['env/**']
35
+
36
+ permissions:
37
+ contents: read
38
+ pull-requests: write
39
+ statuses: write
40
+
41
+ jobs:
42
+ preview:
43
+ uses: decocms/deco-start/.github/workflows/preview.yml@v2
44
+ secrets: inherit
45
+ `;
46
+
47
+ const REGEN_BLOCKS_YML = `name: Regenerate blocks.gen.json
48
+
49
+ # Thin caller for decocms/deco-start's central regen-blocks workflow.
50
+
51
+ on:
52
+ push:
53
+ branches: [main]
54
+ paths:
55
+ - ".deco/blocks/**"
56
+
57
+ permissions:
58
+ contents: write
59
+
60
+ jobs:
61
+ regen:
62
+ uses: decocms/deco-start/.github/workflows/regen-blocks.yml@v2
63
+ secrets: inherit
64
+ `;
65
+
66
+ const SYNC_SECRETS_YML = `name: Sync worker secrets
67
+
68
+ # Thin caller for decocms/deco-start's central sync-secrets workflow.
69
+
70
+ on:
71
+ workflow_dispatch:
72
+ inputs:
73
+ mode:
74
+ description: "dry-run = print diff only | apply = set secrets on worker"
75
+ required: true
76
+ default: "dry-run"
77
+ type: choice
78
+ options: [dry-run, apply]
79
+
80
+ permissions:
81
+ contents: read
82
+
83
+ jobs:
84
+ sync:
85
+ uses: decocms/deco-start/.github/workflows/sync-secrets.yml@v2
86
+ with:
87
+ mode: \${{ inputs.mode }}
88
+ secrets: inherit
89
+ `;
90
+
91
+ export function generateGithubWorkflows(): Record<string, string> {
92
+ return {
93
+ ".github/workflows/deploy.yml": DEPLOY_YML,
94
+ ".github/workflows/preview.yml": PREVIEW_YML,
95
+ ".github/workflows/regen-blocks.yml": REGEN_BLOCKS_YML,
96
+ ".github/workflows/sync-secrets.yml": SYNC_SECRETS_YML,
97
+ };
98
+ }
@@ -109,8 +109,15 @@ export function generatePackageJson(ctx: MigrationContext): string {
109
109
  build:
110
110
  "npm run generate:blocks && npm run generate:sections && npm run generate:loaders && npm run generate:schema && npm run generate:invoke && tsr generate && vite build",
111
111
  preview: "vite preview",
112
- deploy: "npm run build && wrangler deploy",
113
- types: "wrangler types",
112
+ // wrangler.jsonc is generated by `deco-wrangler gen` from the central
113
+ // registry in @decocms/start (D6). Predev/prebuild ensure it is up to
114
+ // date before vite runs the @cloudflare/vite-plugin which reads it.
115
+ "gen:wrangler": "deco-wrangler gen",
116
+ predev: "deco-wrangler gen",
117
+ prebuild: "deco-wrangler gen",
118
+ deploy:
119
+ "echo 'Production deploys are managed by .github/workflows/deploy.yml on push to main. For an emergency manual deploy run: npx deco-wrangler deploy'; exit 1",
120
+ types: "deco-wrangler types",
114
121
  typecheck: "tsc --noEmit",
115
122
  format: 'prettier --write "src/**/*.{ts,tsx}"',
116
123
  "format:check": 'prettier --check "src/**/*.{ts,tsx}"',
@@ -1,9 +1,15 @@
1
+ import {
2
+ type ActionConfig,
3
+ type LoaderConfig,
4
+ registerActionSchemas,
5
+ registerLoaderSchemas,
6
+ } from "../admin/schema";
7
+ import { getMeter, MetricNames, withTracing } from "../middleware/observability";
8
+ import { djb2Hex } from "../sdk/djb2";
9
+ import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
1
10
  import { findPageByPath, loadBlocks } from "./loader";
2
11
  import { getOnBeforeResolveProps, getSection, registerOnBeforeResolveProps } from "./registry";
3
12
  import { isLayoutSection, runSingleSectionLoader } from "./sectionLoaders";
4
- import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
5
- import { djb2Hex } from "../sdk/djb2";
6
- import { registerLoaderSchemas, registerActionSchemas, type LoaderConfig, type ActionConfig } from "../admin/schema";
7
13
 
8
14
  // globalThis-backed: share state across Vite server function split modules
9
15
  const G = globalThis as any;
@@ -134,10 +140,7 @@ export function setAsyncRenderingConfig(config?: {
134
140
  respectCmsLazy?: boolean;
135
141
  }): void {
136
142
  const existing = getAsyncConfig();
137
- const merged = new Set([
138
- ...(existing?.alwaysEager ?? []),
139
- ...(config?.alwaysEager ?? []),
140
- ]);
143
+ const merged = new Set([...(existing?.alwaysEager ?? []), ...(config?.alwaysEager ?? [])]);
141
144
  G.__deco.asyncConfig = {
142
145
  respectCmsLazy: config?.respectCmsLazy ?? existing?.respectCmsLazy ?? true,
143
146
  foldThreshold: config?.foldThreshold ?? existing?.foldThreshold ?? Infinity,
@@ -321,7 +324,13 @@ export function registerCommerceLoaders(loaders: Record<string, CommerceLoader>)
321
324
  if (key.includes("/actions/")) {
322
325
  actionConfigs.push({ key, title: key, namespace, propsSchema: schema });
323
326
  } else {
324
- loaderConfigs.push({ key, title: key, namespace, propsSchema: schema, tags: inferLoaderTags(key) });
327
+ loaderConfigs.push({
328
+ key,
329
+ title: key,
330
+ namespace,
331
+ propsSchema: schema,
332
+ tags: inferLoaderTags(key),
333
+ });
325
334
  }
326
335
  }
327
336
 
@@ -374,18 +383,24 @@ export function registerMatcher(
374
383
  if (!G.__deco._builtinMatchersRegistered) {
375
384
  G.__deco._builtinMatchersRegistered = true;
376
385
 
377
- const builtinMatchers: Record<string, (rule: Record<string, unknown>, ctx: MatcherContext) => boolean> = {
386
+ const builtinMatchers: Record<
387
+ string,
388
+ (rule: Record<string, unknown>, ctx: MatcherContext) => boolean
389
+ > = {
378
390
  "website/matchers/always.ts": () => true,
379
391
  "$live/matchers/MatchAlways.ts": () => true,
380
392
  "website/matchers/never.ts": () => false,
381
393
  "website/matchers/device.ts": (rule, ctx) => {
382
394
  const ua = (ctx.userAgent || "").toLowerCase();
383
395
  const isTablet = /ipad|android(?!.*mobile)|tablet/i.test(ua);
384
- const isMobile = !isTablet && /mobile|android|iphone|ipod|webos|blackberry|opera mini|iemobile/i.test(ua);
396
+ const isMobile =
397
+ !isTablet && /mobile|android|iphone|ipod|webos|blackberry|opera mini|iemobile/i.test(ua);
385
398
  const isDesktop = !isMobile && !isTablet;
386
399
  // If no flags are set, match everything (permissive default)
387
400
  if (!rule.mobile && !rule.tablet && !rule.desktop) return true;
388
- return !!(rule.mobile && isMobile) || !!(rule.tablet && isTablet) || !!(rule.desktop && isDesktop);
401
+ return (
402
+ !!(rule.mobile && isMobile) || !!(rule.tablet && isTablet) || !!(rule.desktop && isDesktop)
403
+ );
389
404
  },
390
405
  "website/matchers/random.ts": (rule) => {
391
406
  const traffic = typeof rule.traffic === "number" ? rule.traffic : 0.5;
@@ -457,7 +472,10 @@ function ensureInitialized() {
457
472
  // Matcher evaluation
458
473
  // ---------------------------------------------------------------------------
459
474
 
460
- export function evaluateMatcher(rule: Record<string, unknown> | undefined, ctx: MatcherContext): boolean {
475
+ export function evaluateMatcher(
476
+ rule: Record<string, unknown> | undefined,
477
+ ctx: MatcherContext,
478
+ ): boolean {
461
479
  if (!rule) return true;
462
480
 
463
481
  const resolveType = rule.__resolveType as string | undefined;
@@ -828,10 +846,7 @@ export async function resolvePageSeoBlock(
828
846
  }
829
847
 
830
848
  // Multivariate flag — evaluate matcher and follow matched variant
831
- if (
832
- rt === "website/flags/multivariate.ts" ||
833
- rt === "website/flags/multivariate/section.ts"
834
- ) {
849
+ if (rt === "website/flags/multivariate.ts" || rt === "website/flags/multivariate/section.ts") {
835
850
  const variants = current.variants as Array<{ value: unknown; rule?: unknown }> | undefined;
836
851
  if (!variants?.length) return null;
837
852
  let matched: unknown = null;
@@ -909,10 +924,7 @@ function isRawSectionLayout(section: unknown): string | null {
909
924
  * unwrapping Lazy/Deferred wrappers, and evaluating multivariate flags.
910
925
  * Returns null if not determinable.
911
926
  */
912
- function resolveFinalSectionKey(
913
- section: unknown,
914
- matcherCtx?: MatcherContext,
915
- ): string | null {
927
+ function resolveFinalSectionKey(section: unknown, matcherCtx?: MatcherContext): string | null {
916
928
  if (!section || typeof section !== "object") return null;
917
929
 
918
930
  const blocks = loadBlocks();
@@ -946,13 +958,8 @@ function resolveFinalSectionKey(
946
958
  continue;
947
959
  }
948
960
 
949
- if (
950
- rt === WELL_KNOWN_TYPES.MULTIVARIATE ||
951
- rt === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION
952
- ) {
953
- const variants = current.variants as
954
- | Array<{ value: unknown; rule?: unknown }>
955
- | undefined;
961
+ if (rt === WELL_KNOWN_TYPES.MULTIVARIATE || rt === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION) {
962
+ const variants = current.variants as Array<{ value: unknown; rule?: unknown }> | undefined;
956
963
  if (!variants?.length) return null;
957
964
 
958
965
  let matched: unknown = null;
@@ -999,21 +1006,13 @@ function isCmsDeferralWrapped(section: unknown, matcherCtx?: MatcherContext): bo
999
1006
  const rt = current.__resolveType as string | undefined;
1000
1007
  if (!rt) return false;
1001
1008
 
1002
- if (
1003
- rt === WELL_KNOWN_TYPES.LAZY ||
1004
- rt === WELL_KNOWN_TYPES.DEFERRED
1005
- ) {
1009
+ if (rt === WELL_KNOWN_TYPES.LAZY || rt === WELL_KNOWN_TYPES.DEFERRED) {
1006
1010
  return true;
1007
1011
  }
1008
1012
 
1009
1013
  // Walk through multivariate flags to check the matched variant
1010
- if (
1011
- rt === WELL_KNOWN_TYPES.MULTIVARIATE ||
1012
- rt === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION
1013
- ) {
1014
- const variants = current.variants as
1015
- | Array<{ value: unknown; rule?: unknown }>
1016
- | undefined;
1014
+ if (rt === WELL_KNOWN_TYPES.MULTIVARIATE || rt === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION) {
1015
+ const variants = current.variants as Array<{ value: unknown; rule?: unknown }> | undefined;
1017
1016
  if (!variants?.length) return false;
1018
1017
 
1019
1018
  let matched: unknown = null;
@@ -1125,13 +1124,8 @@ function resolveSectionShallow(
1125
1124
  }
1126
1125
 
1127
1126
  // Multivariate flags — evaluate matchers and continue with matched variant
1128
- if (
1129
- rt === WELL_KNOWN_TYPES.MULTIVARIATE ||
1130
- rt === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION
1131
- ) {
1132
- const variants = current.variants as
1133
- | Array<{ value: unknown; rule?: unknown }>
1134
- | undefined;
1127
+ if (rt === WELL_KNOWN_TYPES.MULTIVARIATE || rt === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION) {
1128
+ const variants = current.variants as Array<{ value: unknown; rule?: unknown }> | undefined;
1135
1129
  if (!variants?.length) return null;
1136
1130
 
1137
1131
  let matched: unknown = null;
@@ -1331,6 +1325,30 @@ export interface DecoPageResult {
1331
1325
  export async function resolveDecoPage(
1332
1326
  targetPath: string,
1333
1327
  matcherCtx?: MatcherContext,
1328
+ ): Promise<DecoPageResult | null> {
1329
+ const startedAt = performance.now();
1330
+ return withTracing(
1331
+ "deco.cms.resolvePage",
1332
+ async () => {
1333
+ const result = await resolveDecoPageImpl(targetPath, matcherCtx);
1334
+ try {
1335
+ getMeter()?.histogramRecord?.(
1336
+ MetricNames.RESOLVE_DURATION_MS,
1337
+ performance.now() - startedAt,
1338
+ { path: targetPath },
1339
+ );
1340
+ } catch {
1341
+ /* observability never fails the request */
1342
+ }
1343
+ return result;
1344
+ },
1345
+ { "deco.route": targetPath },
1346
+ );
1347
+ }
1348
+
1349
+ async function resolveDecoPageImpl(
1350
+ targetPath: string,
1351
+ matcherCtx?: MatcherContext,
1334
1352
  ): Promise<DecoPageResult | null> {
1335
1353
  ensureInitialized();
1336
1354
 
@@ -1378,7 +1396,12 @@ export async function resolveDecoPage(
1378
1396
  // Cache rawProps server-side and strip from the deferred object
1379
1397
  // so they are NOT serialized into the HTML payload.
1380
1398
  if (deferred.rawProps) {
1381
- cacheDeferredRawProps(targetPath, deferred.component, currentFlatIndex, deferred.rawProps);
1399
+ cacheDeferredRawProps(
1400
+ targetPath,
1401
+ deferred.component,
1402
+ currentFlatIndex,
1403
+ deferred.rawProps,
1404
+ );
1382
1405
  delete deferred.rawProps;
1383
1406
  }
1384
1407
 
@@ -1430,10 +1453,12 @@ export async function resolveDecoPage(
1430
1453
  })();
1431
1454
 
1432
1455
  const idx = currentFlatIndex;
1433
- eagerResults.push(promise.then((sections) => {
1434
- for (const s of sections) s.index = idx;
1435
- return sections;
1436
- }));
1456
+ eagerResults.push(
1457
+ promise.then((sections) => {
1458
+ for (const s of sections) s.index = idx;
1459
+ return sections;
1460
+ }),
1461
+ );
1437
1462
  flatIndex++;
1438
1463
  }
1439
1464
  }
@@ -1445,10 +1470,7 @@ export async function resolveDecoPage(
1445
1470
  let seoSection: ResolvedSection | null = null;
1446
1471
  if (page.seo) {
1447
1472
  try {
1448
- seoSection = await resolvePageSeoBlock(
1449
- page.seo as Record<string, unknown>,
1450
- rctx,
1451
- );
1473
+ seoSection = await resolvePageSeoBlock(page.seo as Record<string, unknown>, rctx);
1452
1474
  } catch (e) {
1453
1475
  onResolveError(e, "page.seo", "Page SEO block resolution");
1454
1476
  }
@@ -1588,18 +1610,14 @@ export async function resolveDeferredSectionFull(
1588
1610
  matcherCtx?: MatcherContext,
1589
1611
  ): Promise<ResolvedSection | null> {
1590
1612
  // rawProps may be stripped from the client payload — resolve from cache or page
1591
- const rawProps = ds.rawProps
1592
- ?? getDeferredRawProps(pagePath, ds.component, ds.index)
1593
- ?? await reExtractRawProps(pagePath, ds.component, ds.index, matcherCtx);
1613
+ const rawProps =
1614
+ ds.rawProps ??
1615
+ getDeferredRawProps(pagePath, ds.component, ds.index) ??
1616
+ (await reExtractRawProps(pagePath, ds.component, ds.index, matcherCtx));
1594
1617
 
1595
1618
  if (!rawProps) return null;
1596
1619
 
1597
- const section = await resolveDeferredSection(
1598
- ds.component,
1599
- rawProps,
1600
- pagePath,
1601
- matcherCtx,
1602
- );
1620
+ const section = await resolveDeferredSection(ds.component, rawProps, pagePath, matcherCtx);
1603
1621
  if (!section) return null;
1604
1622
  section.index = ds.index;
1605
1623
  const enriched = await runSingleSectionLoader(section, request);
@@ -8,6 +8,8 @@
8
8
  * This runs AFTER resolveDecoPage and BEFORE React rendering,
9
9
  * inside the TanStack Start server function.
10
10
  */
11
+
12
+ import { withTracing } from "../middleware/observability";
11
13
  import { getCacheProfile } from "../sdk/cacheHeaders";
12
14
  import { djb2 } from "../sdk/djb2";
13
15
  import type { ResolvedSection } from "./resolve";
@@ -326,6 +328,15 @@ function withPageContext(loader: SectionLoaderFn): SectionLoaderFn {
326
328
  export async function runSingleSectionLoader(
327
329
  section: ResolvedSection,
328
330
  request: Request,
331
+ ): Promise<ResolvedSection> {
332
+ return withTracing("deco.section.loader", () => runSingleSectionLoaderImpl(section, request), {
333
+ "deco.section": section.component,
334
+ });
335
+ }
336
+
337
+ async function runSingleSectionLoaderImpl(
338
+ section: ResolvedSection,
339
+ request: Request,
329
340
  ): Promise<ResolvedSection> {
330
341
  const loader = loaderRegistry.get(section.component);
331
342
 
package/src/index.ts CHANGED
@@ -3,4 +3,7 @@ export * from "./admin/index";
3
3
  export * from "./cms/index";
4
4
  export * from "./hooks/index";
5
5
  export * from "./middleware/index";
6
+ // Observability surface — logger + instrumentWorker live behind their own
7
+ // granular imports too (see `@decocms/start/sdk/logger`, `.../observability`).
8
+ export { type Logger, type LogLevel, logger, setLogLevel } from "./sdk/logger";
6
9
  export * from "./types/index";
@@ -11,7 +11,8 @@
11
11
  * (e.g. "product") which derives timing from the unified profile system.
12
12
  */
13
13
 
14
- import { loaderCacheOptions, type CacheProfileName } from "./cacheHeaders";
14
+ import { recordCacheMetric, withTracing } from "../middleware/observability";
15
+ import { type CacheProfileName, loaderCacheOptions } from "./cacheHeaders";
15
16
 
16
17
  export type CachePolicy = "no-store" | "no-cache" | "stale-while-revalidate";
17
18
 
@@ -85,12 +86,7 @@ export function createCachedLoader<TProps, TResult>(
85
86
  optionsOrProfile: CachedLoaderOptions | CacheProfileName,
86
87
  ): (props: TProps) => Promise<TResult> {
87
88
  const resolved = resolveOptions(optionsOrProfile);
88
- const {
89
- policy,
90
- maxAge = DEFAULT_MAX_AGE,
91
- staleIfError = 0,
92
- keyFn = JSON.stringify,
93
- } = resolved;
89
+ const { policy, maxAge = DEFAULT_MAX_AGE, staleIfError = 0, keyFn = JSON.stringify } = resolved;
94
90
 
95
91
  const env = typeof globalThis.process !== "undefined" ? globalThis.process.env : undefined;
96
92
  const isDev = env?.DECO_CACHE_DISABLE === "true" || env?.NODE_ENV === "development";
@@ -101,10 +97,20 @@ export function createCachedLoader<TProps, TResult>(
101
97
  const cacheKey = `${name}::${keyFn(props)}`;
102
98
 
103
99
  const inflight = inflightRequests.get(cacheKey);
104
- if (inflight) return inflight as Promise<TResult>;
100
+ if (inflight) {
101
+ // Treat in-flight dedup as a cache hit — avoided the origin call.
102
+ recordCacheMetric(true, name);
103
+ return inflight as Promise<TResult>;
104
+ }
105
105
 
106
106
  if (isDev) {
107
- const promise = loaderFn(props).finally(() => inflightRequests.delete(cacheKey));
107
+ // Dev mode: no caching, but still useful to count attempts.
108
+ recordCacheMetric(false, name);
109
+ const promise = withTracing(
110
+ "deco.cachedLoader",
111
+ () => loaderFn(props).finally(() => inflightRequests.delete(cacheKey)),
112
+ { "deco.loader": name, "deco.cache.policy": "no-cache-dev" },
113
+ );
108
114
  inflightRequests.set(cacheKey, promise);
109
115
  return promise;
110
116
  }
@@ -114,13 +120,21 @@ export function createCachedLoader<TProps, TResult>(
114
120
  const isStale = entry ? now - entry.createdAt > maxAge : true;
115
121
 
116
122
  if (policy === "no-cache") {
117
- if (entry && !isStale) return entry.value;
123
+ if (entry && !isStale) {
124
+ recordCacheMetric(true, name);
125
+ return entry.value;
126
+ }
118
127
  }
119
128
 
120
129
  if (policy === "stale-while-revalidate") {
121
- if (entry && !isStale) return entry.value;
130
+ if (entry && !isStale) {
131
+ recordCacheMetric(true, name);
132
+ return entry.value;
133
+ }
122
134
 
123
135
  if (entry && isStale && !entry.refreshing) {
136
+ // Stale-while-revalidate hit: serve stale, refresh in background.
137
+ recordCacheMetric(true, name);
124
138
  entry.refreshing = true;
125
139
  loaderFn(props)
126
140
  .then((result) => {
@@ -141,10 +155,19 @@ export function createCachedLoader<TProps, TResult>(
141
155
  return entry.value;
142
156
  }
143
157
 
144
- if (entry) return entry.value;
158
+ if (entry) {
159
+ recordCacheMetric(true, name);
160
+ return entry.value;
161
+ }
145
162
  }
146
163
 
147
- const promise = loaderFn(props)
164
+ // Cache miss — emit metric, then run loader inside a span so individual
165
+ // slow loaders are visible in traces.
166
+ recordCacheMetric(false, name);
167
+ const promise = withTracing("deco.cachedLoader", () => loaderFn(props), {
168
+ "deco.loader": name,
169
+ "deco.cache.policy": policy,
170
+ })
148
171
  .then((result) => {
149
172
  cache.set(cacheKey, {
150
173
  value: result,
@@ -0,0 +1,121 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import type { MeterAdapter } from "../middleware/observability";
3
+ import { createCompositeLogger, createCompositeMeter } from "./composite";
4
+ import type { LoggerAdapter } from "./logger";
5
+
6
+ describe("createCompositeLogger", () => {
7
+ afterEach(() => vi.restoreAllMocks());
8
+
9
+ it("fans calls out to every adapter", () => {
10
+ const aCalls: any[][] = [];
11
+ const bCalls: any[][] = [];
12
+ const a: LoggerAdapter = { log: (...args) => void aCalls.push(args) };
13
+ const b: LoggerAdapter = { log: (...args) => void bCalls.push(args) };
14
+ const composite = createCompositeLogger([a, b]);
15
+
16
+ composite.log("info", "hello", { foo: 1 });
17
+
18
+ expect(aCalls).toEqual([["info", "hello", { foo: 1 }]]);
19
+ expect(bCalls).toEqual([["info", "hello", { foo: 1 }]]);
20
+ });
21
+
22
+ it("filters falsy entries (null/undefined/false)", () => {
23
+ const calls: any[] = [];
24
+ const a: LoggerAdapter = { log: (...args) => void calls.push(args) };
25
+ const composite = createCompositeLogger([null, a, undefined, false]);
26
+ composite.log("info", "ok");
27
+ // adapter receives the call with no third arg (undefined is omitted)
28
+ expect(calls.length).toBe(1);
29
+ expect(calls[0][0]).toBe("info");
30
+ expect(calls[0][1]).toBe("ok");
31
+ });
32
+
33
+ it("isolates errors so one bad adapter does not block others", () => {
34
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
35
+ const okCalls: any[][] = [];
36
+ const broken: LoggerAdapter = {
37
+ log() {
38
+ throw new Error("boom");
39
+ },
40
+ };
41
+ const ok: LoggerAdapter = { log: (...args) => void okCalls.push(args) };
42
+ const composite = createCompositeLogger([broken, ok]);
43
+
44
+ expect(() => composite.log("error", "still goes through")).not.toThrow();
45
+ expect(okCalls.length).toBe(1);
46
+ expect(okCalls[0][0]).toBe("error");
47
+ expect(okCalls[0][1]).toBe("still goes through");
48
+ expect(errSpy).toHaveBeenCalled(); // composite reports the failure
49
+ });
50
+
51
+ it("returns the single adapter directly when only one is provided", () => {
52
+ const a: LoggerAdapter = { log: () => {} };
53
+ expect(createCompositeLogger([a])).toBe(a);
54
+ });
55
+ });
56
+
57
+ describe("createCompositeMeter", () => {
58
+ afterEach(() => vi.restoreAllMocks());
59
+
60
+ function recorder() {
61
+ const counter: any[] = [];
62
+ const gauge: any[] = [];
63
+ const histo: any[] = [];
64
+ const meter: MeterAdapter = {
65
+ counterInc: (n, v, l) => void counter.push([n, v, l]),
66
+ gaugeSet: (n, v, l) => void gauge.push([n, v, l]),
67
+ histogramRecord: (n, v, l) => void histo.push([n, v, l]),
68
+ };
69
+ return { meter, counter, gauge, histo };
70
+ }
71
+
72
+ it("fans counter/gauge/histogram across meters", () => {
73
+ const a = recorder();
74
+ const b = recorder();
75
+ const composite = createCompositeMeter([a.meter, b.meter]);
76
+
77
+ composite.counterInc("c", 1, { p: "/" });
78
+ composite.gaugeSet?.("g", 5);
79
+ composite.histogramRecord?.("h", 100, { route: "/p" });
80
+
81
+ expect(a.counter[0].slice(0, 2)).toEqual(["c", 1]);
82
+ expect(a.counter[0][2]).toEqual({ p: "/" });
83
+ expect(b.counter[0].slice(0, 2)).toEqual(["c", 1]);
84
+ expect(a.gauge[0].slice(0, 2)).toEqual(["g", 5]);
85
+ expect(b.gauge[0].slice(0, 2)).toEqual(["g", 5]);
86
+ expect(a.histo[0]).toEqual(["h", 100, { route: "/p" }]);
87
+ expect(b.histo[0]).toEqual(["h", 100, { route: "/p" }]);
88
+ });
89
+
90
+ it("isolates errors per meter and per call type", () => {
91
+ vi.spyOn(console, "error").mockImplementation(() => {});
92
+ const broken: MeterAdapter = {
93
+ counterInc: () => {
94
+ throw new Error("c");
95
+ },
96
+ gaugeSet: () => {
97
+ throw new Error("g");
98
+ },
99
+ histogramRecord: () => {
100
+ throw new Error("h");
101
+ },
102
+ };
103
+ const ok = recorder();
104
+ const composite = createCompositeMeter([broken, ok.meter]);
105
+
106
+ composite.counterInc("c", 1);
107
+ composite.gaugeSet?.("g", 5);
108
+ composite.histogramRecord?.("h", 100);
109
+
110
+ expect(ok.counter[0].slice(0, 2)).toEqual(["c", 1]);
111
+ expect(ok.gauge[0].slice(0, 2)).toEqual(["g", 5]);
112
+ expect(ok.histo[0].slice(0, 2)).toEqual(["h", 100]);
113
+ });
114
+
115
+ it("skips meters that don't implement optional ops", () => {
116
+ const onlyCounter: MeterAdapter = { counterInc: () => {} };
117
+ const composite = createCompositeMeter([onlyCounter]);
118
+ expect(() => composite.gaugeSet?.("g", 1)).not.toThrow();
119
+ expect(() => composite.histogramRecord?.("h", 1)).not.toThrow();
120
+ });
121
+ });