feat(compat-v7): /matchers, v7's website matchers with the sticky random

TL
5h ago
+7093-12

Stacked on #702 (feat/compat-v7-core).

What

@decocms/compat-v7/matchers: v7's website matchers with v7's results, on v8.

  • device, cookie, host, pathname, queryString, location, multi, random. A v8 block function gets only its inputs, so each takes the request as its second argument: a Request, or { request, url?, flags? } (url = the page's URL when the request is a client-side navigation's server-function call; flags = one array per request for A/B decisions). In the block map: "website/matchers/device.ts": (props: DeviceProps) => device(props, request).
  • environment throws with the fix: it read the process environment, which no v8 package does.
  • always, never, date stay v8 built-ins.
  • random is latest 7.x's sticky random (PO decision): the deco_segment cookie, same bytes, re-drawn once when the traffic share changes. random/decideRandom record each decision on the request's flags, and segmentSetCookie(request, flags) gives the Set-Cookie (deco_segment=…; Max-Age=31536000; Path=/; SameSite=Lax, or null when unchanged, merged over the visitor's cookie as v7's route did). decideRandom returns the decision and that cookie. Sending it is the framework subpaths' job (next PRs).
  • Cohort = the saved block's name. v7 keyed the cookie on the name of the saved matcher block the rule pointed at. A v8 block doesn't learn its saved name, so the block carries it in cohort; an inline rule uses website/matchers/random.ts, as v7 did. The test shows a visitor v7 drew keeps their group when cohort is the saved name, and is re-drawn under any other.
  • Also exported: v7's deco_segment helpers (parseSegmentCookie, serializeSegmentCookie, segmentCacheToken, trafficToPct, SEGMENT_COOKIE), parseCookies (h3's parse, as v7's getCookies() read them) and the location matcher's country table. The matchers are only at /matchers (their names are too generic for the root).

Pinned to v7 (latest 7.x, 9131e9d9)

v7's own files are added verbatim to src/__fixtures__/v7/ with pinned hashes: cms/resolve.ts, matchers/builtins.ts, matchers/countryNames.ts, matchers/override.ts, sdk/flags.ts, sdk/experiments.ts, sdk/djb2.ts, tanstack/routes/cmsRoute.ts, plus h3 2.0.1-rc.16's cookie parse/serialize (what v7's TanStack route ran). src/__tests__/loadV7.ts runs them unchanged (esbuild strips types; imports unrelated to matching are no-op stubs), so the tests call v7's real evaluateMatcher (with registerBuiltinMatchers()), evaluateVariantRule and persistFlags.

  • 20k seeded requests Ɨ props per matcher (UAs, hosts, encoded paths, query strings, cookie headers, cf-* headers, request.cf, malformed props), v7 context built as v7's TanStack route built it (decoded splat path, h3-parsed cookies).
  • 10k seeded sticky page requests: several saved/inline random rules per page, cookies from v7's serializer or garbage, with and without flags; decisions, recorded flags and the Set-Cookie all equal v7's.
  • 20k cookie headers vs h3's parse; 20k deco_segment round trips; multi 5k; country table and lookup.
  • Mutations (device regex, legacy host, Greater, cf latitude, negate, empty cookie, path decoding, cookie quotes, default traffic, the -1 legacy fingerprint, accepts, cookie attribute order, re-issue on no change, merge with the existing cookie) each fail the suite.
  • tests/compat-v7.schema.test.ts: a matcher wrapped in the block map keeps its fields in deco schema (random: traffic, cohort).

Differences from v7 (listed)

  • Demo's and storefront's A/B pages become sticky (PO-approved: latest 7.x sticky random for every site).
  • multi receives its rules' results (v8 resolves them first). An inner random is therefore sticky; v7 re-drew it per request.
  • No x-deco-matchers-override: v8 previews force variants with the draft pointer's __variant.
  • The content migration does not yet write cohort into saved random blocks: deco check would reject the field on sites whose random block doesn't declare it. That step belongs with the migrator change that wires these matchers into the block map.

Gates

  • bun run check: pass. bun run build: pass. Plain-Node import of every built export: 11 packages, compat-v7 7/7, deco --help ok. skills:check + skills:readme:check: pass. tests/release.test.ts 41/41.
  • bun run test (2 GB cap): 2515 passed, 1 failed (DS-2, the known flake: fails the same alone, 10/11, in a file this PR doesn't touch), 95 skipped.

The docs site's /next pages that say "copy the matchers into the site" should point here in a follow-up docs PR.

Main refresh (2026-10-17, 3ddc7b5b)

  • Merged C1 183bc4b7 into this branch (normal merge, no conflicts), then merged up the stack to M2 #707.
  • Gates at the top of the stack (M2 5131a3c1): bun run check, bun run build, skills:check, skills:readme:check pass. bun run test (2 GB memory cap): 122 files pass, 1 skipped; 2,745 tests pass, 95 skipped, 0 fail. DS-2 now passes (fixed on main by #709); no timeouts this run.
  • CI on this PR's new head: all checks pass.

šŸ¤– Generated with Claude Code

https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig


Summary by cubic

Adds v7's website matchers to @decocms/compat-v7/matchers so v8 sites get v7's exact matcher results. device, cookie, host, pathname, queryString, location, multi and random each take the request as a second argument, since a v8 block function only receives its inputs; environment throws, as v7 read the process environment. random is latest 7.x's sticky random, persisted via the deco_segment cookie and keyed on cohort — the saved matcher block's name, as v7 did.

Differences from v7

  • multi receives its rules' results (v8 resolves them first), so an inner random is sticky; v7 re-drew it per request.
  • No x-deco-matchers-override: v8 previews force variants with the draft pointer's __variant.
  • The content migration doesn't yet write cohort into saved random blocks.

Testing

  • Every matcher is compared against v7's own code, pinned verbatim under src/__fixtures__/v7/ and run unchanged through a test-only esbuild loader.
  • 20k seeded cases per matcher and 10k sticky page requests match v7's evaluateMatcher, evaluateVariantRule and persistFlags.
  • A matcher wrapped in the site's block map keeps its fields in deco schema, including random's cohort.