@gamesheet-inc/cache-invalidation-contract 0.1.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/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # Cache invalidation contract
2
+
3
+ `@gamesheet-inc/cache-invalidation-contract` is this contract's only copy. The edge-router and the
4
+ widget app import it through the app-stats-widget-next workspace; cache-invalidation-service and
5
+ project-forge pin a published version. The data-write-gateway's Go struct
6
+ (`src/Shared/Invalidation/message.go`) mirrors §3 by hand, so a message change lists it among the
7
+ consumers to update. Design: `docs/superpowers/specs/2026-09-28-cache-invalidation-contract-package-design.md`
8
+ in app-stats-widget-next, which builds on the cache-invalidation design spec §7.
9
+
10
+ ## 1. Cache tags (edge-router ↔ service)
11
+
12
+ Every response the edge Worker caches carries `cf.cacheTags`, derived from the public URL only
13
+ (the Worker's own 60 s build-id lookup is the single untagged entry; see the edge-router
14
+ constraints doc):
15
+
16
+ - `<env>-season-<id>`
17
+ - `<env>-league-<id>`
18
+ - `<env>-game-<id>`
19
+ - `<env>-team-<id>`
20
+ - `<env>-player-<id>`
21
+ - `<env>-widget-config-<id>`
22
+ - constant `<env>-stats-widget` on every cached response
23
+
24
+ `<env>` is one of `dev | qa | prod` — the Worker's `TAG_ENV` var and the service's `TAG_ENV`
25
+ var, which must match per env (the apex and `next.` prod Workers both use `prod`). Ids are decimal
26
+ integers exactly as they appear in URLs. Tags are emitted lower-case; Cloudflare matches them
27
+ case-insensitively. A Worker with no valid `TAG_ENV` emits no tags at all — never an unprefixed one.
28
+
29
+ Why the prefix: Cloudflare cache tags are zone-wide, dev/qa/prod share `gamesheetstats.com`, and the
30
+ three databases have overlapping numeric id spaces. Without it a dev purge of `season-19483` would
31
+ evict prod's season 19483.
32
+
33
+ `<env>-stats-widget` is a break-glass lever (purge one env's whole widget edge cache) with its own
34
+ runbook in the service repo. It is never exposed in a UI.
35
+
36
+ ## 2. Generation keys (widget ↔ service; the widget's Upstash instance, per env)
37
+
38
+ - `gen:season:<id>`
39
+ - `gen:league:<id>`
40
+ - `gen:game:<id>`
41
+ - `gen:player:<id>`
42
+ - `gen:widget-config:<id>`
43
+
44
+ Integer counters. A missing key reads as `0`. The service is the only writer (`INCR` + `EXPIRE`
45
+ 30 days on every bump); the widget only reads. There is no `gen:team:*` — every team-scoped widget
46
+ key also carries its season, so the season counter covers it.
47
+
48
+ ## 3. `cache-invalidate` topic payload v1 (publishers ↔ service)
49
+
50
+ ```json
51
+ {
52
+ "v": 1,
53
+ "source": "data-write-gateway.ScheduleGame.update",
54
+ "reason": "optional free text",
55
+ "seasons": [19483],
56
+ "games": [123],
57
+ "teams": [456, 789],
58
+ "leagues": [],
59
+ "players": [],
60
+ "widgetConfigs": []
61
+ }
62
+ ```
63
+
64
+ Message attributes: `schema: cache-invalidate.v1`, `source`. All arrays optional. Publishers send
65
+ integer ids; the schema also accepts numeric strings of up to 19 digits and parses them. Every
66
+ id must parse to a positive integer of at most 9007199254740991 (`Number.MAX_SAFE_INTEGER`);
67
+ anything else is refused.
68
+ Publishers never send tags or keys.
69
+
70
+ ## Exports
71
+
72
+ - Tags: `TAG_ENVS`, `TagEnv`, `parseTagEnv`, `SCOPE_TAG_KINDS`, `ScopeTagKind`, `STATS_WIDGET_TAG`,
73
+ `tagFor`, `breakGlassTag`.
74
+ - URL rules: `ScopeRef`, `resolveScopes`, `resolveCacheTags`.
75
+ - Generation keys: `GEN_KINDS`, `GenKind`, `GEN_KEY_PREFIX`, `genKeyFor`, `GEN_TTL_SECONDS`.
76
+ - Message v1: `CACHE_INVALIDATE_TOPIC`, `CACHE_INVALIDATE_SCHEMA`, `MESSAGE_FIELDS`, `MessageField`,
77
+ `ScopeIdSchema`, `CacheInvalidateV1Schema`, `CacheInvalidateV1`. zod is a required peer.
78
+
79
+ ## Changelog
80
+
81
+ A change to any exported value or rule is a new version. Each entry names the consumers that must
82
+ move: the edge-router and the widget app (same PR, through the workspace), cache-invalidation-service
83
+ and project-forge (a pin bump), and the data-write-gateway's Go struct (by hand, for a message
84
+ change).
85
+
86
+ - **0.1.0** — First release. Moves the edge-router's `cache-tags.ts`, the widget's generation-key
87
+ names and the service's `contract.ts`, `idSchema` and `cacheInvalidateV1Schema` here, with no
88
+ change to any tag, key or accepted message.
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The generation counters the service bumps and the widget reads (contract
3
+ * §2), keyed `gen:<kind>:<id>`; a missing key reads as 0. There is no team
4
+ * counter: every team-scoped widget key also carries its season.
5
+ */
6
+ export declare const GEN_KINDS: readonly ["season", "league", "game", "player", "widget-config"];
7
+ export type GenKind = (typeof GEN_KINDS)[number];
8
+ export declare const GEN_KEY_PREFIX = "gen";
9
+ /** The service refreshes this expiry on every bump. */
10
+ export declare const GEN_TTL_SECONDS: number;
11
+ export declare function genKeyFor(kind: GenKind, id: number | string): string;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The generation counters the service bumps and the widget reads (contract
3
+ * §2), keyed `gen:<kind>:<id>`; a missing key reads as 0. There is no team
4
+ * counter: every team-scoped widget key also carries its season.
5
+ */
6
+ export const GEN_KINDS = [
7
+ "season",
8
+ "league",
9
+ "game",
10
+ "player",
11
+ "widget-config",
12
+ ];
13
+ export const GEN_KEY_PREFIX = "gen";
14
+ /** The service refreshes this expiry on every bump. */
15
+ export const GEN_TTL_SECONDS = 30 * 24 * 60 * 60;
16
+ export function genKeyFor(kind, id) {
17
+ return `${GEN_KEY_PREFIX}:${kind}:${id}`;
18
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./tags.ts";
2
+ export * from "./url-rules.ts";
3
+ export * from "./gen-keys.ts";
4
+ export * from "./message.ts";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./tags.js";
2
+ export * from "./url-rules.js";
3
+ export * from "./gen-keys.js";
4
+ export * from "./message.js";
@@ -0,0 +1,29 @@
1
+ import { z } from "zod";
2
+ import type { ScopeTagKind } from "./tags.ts";
3
+ export declare const CACHE_INVALIDATE_TOPIC = "cache-invalidate";
4
+ export declare const CACHE_INVALIDATE_SCHEMA = "cache-invalidate.v1";
5
+ /** Each scope kind's id-list field in the message (contract §3). */
6
+ export declare const MESSAGE_FIELDS: {
7
+ readonly season: "seasons";
8
+ readonly league: "leagues";
9
+ readonly game: "games";
10
+ readonly team: "teams";
11
+ readonly player: "players";
12
+ readonly "widget-config": "widgetConfigs";
13
+ };
14
+ export type MessageField = (typeof MESSAGE_FIELDS)[ScopeTagKind];
15
+ /** An id: a positive safe integer, or a numeric string of up to 19 digits that parses to one. */
16
+ export declare const ScopeIdSchema: z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>]>, z.ZodNumber>;
17
+ /** The `cache-invalidate` message v1 (contract §3). Every id list defaults to empty. */
18
+ export declare const CacheInvalidateV1Schema: z.ZodObject<{
19
+ v: z.ZodLiteral<1>;
20
+ source: z.ZodString;
21
+ reason: z.ZodOptional<z.ZodString>;
22
+ seasons: z.ZodDefault<z.ZodArray<z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>]>, z.ZodNumber>>>;
23
+ games: z.ZodDefault<z.ZodArray<z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>]>, z.ZodNumber>>>;
24
+ teams: z.ZodDefault<z.ZodArray<z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>]>, z.ZodNumber>>>;
25
+ leagues: z.ZodDefault<z.ZodArray<z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>]>, z.ZodNumber>>>;
26
+ players: z.ZodDefault<z.ZodArray<z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>]>, z.ZodNumber>>>;
27
+ widgetConfigs: z.ZodDefault<z.ZodArray<z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>]>, z.ZodNumber>>>;
28
+ }, z.core.$strip>;
29
+ export type CacheInvalidateV1 = z.infer<typeof CacheInvalidateV1Schema>;
@@ -0,0 +1,34 @@
1
+ import { z } from "zod";
2
+ export const CACHE_INVALIDATE_TOPIC = "cache-invalidate";
3
+ export const CACHE_INVALIDATE_SCHEMA = "cache-invalidate.v1";
4
+ /** Each scope kind's id-list field in the message (contract §3). */
5
+ export const MESSAGE_FIELDS = {
6
+ season: "seasons",
7
+ league: "leagues",
8
+ game: "games",
9
+ team: "teams",
10
+ player: "players",
11
+ "widget-config": "widgetConfigs",
12
+ };
13
+ /** An id: a positive safe integer, or a numeric string of up to 19 digits that parses to one. */
14
+ export const ScopeIdSchema = z
15
+ .union([
16
+ z.number().int().positive(),
17
+ z
18
+ .string()
19
+ .regex(/^\d{1,19}$/)
20
+ .transform((value) => Number.parseInt(value, 10)),
21
+ ])
22
+ .pipe(z.number().int().positive());
23
+ /** The `cache-invalidate` message v1 (contract §3). Every id list defaults to empty. */
24
+ export const CacheInvalidateV1Schema = z.object({
25
+ v: z.literal(1),
26
+ source: z.string().min(1),
27
+ reason: z.string().optional(),
28
+ seasons: z.array(ScopeIdSchema).default([]),
29
+ games: z.array(ScopeIdSchema).default([]),
30
+ teams: z.array(ScopeIdSchema).default([]),
31
+ leagues: z.array(ScopeIdSchema).default([]),
32
+ players: z.array(ScopeIdSchema).default([]),
33
+ widgetConfigs: z.array(ScopeIdSchema).default([]),
34
+ });
package/dist/tags.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Cache-tag vocabulary for purge-by-tag invalidation (contract §1). Every tag
3
+ * is prefixed with its env, because Cloudflare tags are zone-wide and dev, qa
4
+ * and prod share one zone with overlapping numeric id spaces.
5
+ */
6
+ export declare const TAG_ENVS: readonly ["dev", "qa", "prod"];
7
+ export type TagEnv = (typeof TAG_ENVS)[number];
8
+ export declare const STATS_WIDGET_TAG = "stats-widget";
9
+ export declare const SCOPE_TAG_KINDS: readonly ["season", "league", "game", "team", "player", "widget-config"];
10
+ export type ScopeTagKind = (typeof SCOPE_TAG_KINDS)[number];
11
+ export declare function parseTagEnv(raw: string | undefined): TagEnv | null;
12
+ /** `<env>-<kind>-<id>`, the id as given. */
13
+ export declare function tagFor(env: TagEnv, kind: ScopeTagKind, id: number | string): string;
14
+ /** The whole-widget tag: a break-glass lever with its own runbook, never exposed in a UI. */
15
+ export declare function breakGlassTag(env: TagEnv): string;
package/dist/tags.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Cache-tag vocabulary for purge-by-tag invalidation (contract §1). Every tag
3
+ * is prefixed with its env, because Cloudflare tags are zone-wide and dev, qa
4
+ * and prod share one zone with overlapping numeric id spaces.
5
+ */
6
+ export const TAG_ENVS = ["dev", "qa", "prod"];
7
+ export const STATS_WIDGET_TAG = "stats-widget";
8
+ export const SCOPE_TAG_KINDS = [
9
+ "season",
10
+ "league",
11
+ "game",
12
+ "team",
13
+ "player",
14
+ "widget-config",
15
+ ];
16
+ export function parseTagEnv(raw) {
17
+ return raw === "dev" || raw === "qa" || raw === "prod" ? raw : null;
18
+ }
19
+ /** `<env>-<kind>-<id>`, the id as given. */
20
+ export function tagFor(env, kind, id) {
21
+ return `${env}-${kind}-${id}`;
22
+ }
23
+ /** The whole-widget tag: a break-glass lever with its own runbook, never exposed in a UI. */
24
+ export function breakGlassTag(env) {
25
+ return `${env}-${STATS_WIDGET_TAG}`;
26
+ }
@@ -0,0 +1,15 @@
1
+ import { type ScopeTagKind, type TagEnv } from "./tags.ts";
2
+ /** A scope a public widget URL is cached under: its tag kind, and its id exactly as the URL spells it. */
3
+ export type ScopeRef = {
4
+ kind: ScopeTagKind;
5
+ id: string;
6
+ };
7
+ /**
8
+ * The edge-router's rules (contract §1): every scope a public URL is cached
9
+ * under, from its path and query only, never from origin headers. The result
10
+ * is deduplicated and in the order the scopes' tags sort, so
11
+ * `resolveCacheTags` keeps the router's exact output.
12
+ */
13
+ export declare function resolveScopes(url: URL): ScopeRef[];
14
+ /** The break-glass tag first, then each scope's tag in sorted order, ids exactly as the URL spells them. */
15
+ export declare function resolveCacheTags(url: URL, env: TagEnv): string[];
@@ -0,0 +1,107 @@
1
+ import { breakGlassTag, tagFor, } from "./tags.js";
2
+ const NUMERIC_ID = /^\d{1,19}$/;
3
+ const PAGE_PREFIX_KINDS = new Map([
4
+ ["seasons", "season"],
5
+ ["leagues", "league"],
6
+ ]);
7
+ const PAGE_SUBSEGMENT_KINDS = new Map([
8
+ ["teams", "team"],
9
+ ["games", "game"],
10
+ ["players", "player"],
11
+ ["goalies", "player"],
12
+ ]);
13
+ const API_SEASON_AT_2 = new Set([
14
+ "standings",
15
+ "season-info",
16
+ "season-frozen",
17
+ "season-divisions",
18
+ "unified-games",
19
+ "brackets",
20
+ ]);
21
+ const API_PLAYER_GROUPS = new Set(["players", "goalies"]);
22
+ const API_PLAYER_SUBROUTES = new Set([
23
+ "career",
24
+ "career-raw",
25
+ "gamelog",
26
+ "overview",
27
+ ]);
28
+ function scopeRef(kind, id) {
29
+ return id !== undefined && NUMERIC_ID.test(id) ? { kind, id } : null;
30
+ }
31
+ function pageScopes(segments, search) {
32
+ const rootKind = PAGE_PREFIX_KINDS.get(segments[0] ?? "");
33
+ if (!rootKind)
34
+ return [];
35
+ const refs = [
36
+ scopeRef(rootKind, segments[1]),
37
+ scopeRef("widget-config", search.get("configuration") ?? undefined),
38
+ ];
39
+ const subKind = PAGE_SUBSEGMENT_KINDS.get(segments[2] ?? "");
40
+ if (subKind)
41
+ refs.push(scopeRef(subKind, segments[3]));
42
+ return refs;
43
+ }
44
+ function apiScopes(segments, search) {
45
+ const [, group, a, b] = segments;
46
+ if (group === undefined)
47
+ return [];
48
+ if (API_SEASON_AT_2.has(group))
49
+ return [scopeRef("season", a)];
50
+ if (group === "teams")
51
+ return [scopeRef("season", a), scopeRef("team", b)];
52
+ if (group === "widget-config") {
53
+ return a === "season-default"
54
+ ? [scopeRef("season", b)]
55
+ : [scopeRef("widget-config", a)];
56
+ }
57
+ if (group === "leagues")
58
+ return [scopeRef("league", a)];
59
+ if (group === "games") {
60
+ if (a === "game")
61
+ return [scopeRef("game", b)];
62
+ if (a === "season")
63
+ return [scopeRef("season", b)];
64
+ return [];
65
+ }
66
+ if (API_PLAYER_GROUPS.has(group)) {
67
+ if (a === "standings")
68
+ return [scopeRef("season", b)];
69
+ if (a !== undefined && API_PLAYER_SUBROUTES.has(a)) {
70
+ return [
71
+ scopeRef("player", b),
72
+ scopeRef("season", search.get("seasonId") ?? undefined),
73
+ ];
74
+ }
75
+ return [];
76
+ }
77
+ return [];
78
+ }
79
+ function scopeKey(ref) {
80
+ return `${ref.kind}-${ref.id}`;
81
+ }
82
+ /**
83
+ * The edge-router's rules (contract §1): every scope a public URL is cached
84
+ * under, from its path and query only, never from origin headers. The result
85
+ * is deduplicated and in the order the scopes' tags sort, so
86
+ * `resolveCacheTags` keeps the router's exact output.
87
+ */
88
+ export function resolveScopes(url) {
89
+ const segments = url.pathname.split("/").filter(Boolean);
90
+ const found = segments[0] === "api"
91
+ ? apiScopes(segments, url.searchParams)
92
+ : pageScopes(segments, url.searchParams);
93
+ const unique = new Map();
94
+ for (const ref of found)
95
+ if (ref)
96
+ unique.set(scopeKey(ref), ref);
97
+ return [...unique.entries()]
98
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
99
+ .map(([, ref]) => ref);
100
+ }
101
+ /** The break-glass tag first, then each scope's tag in sorted order, ids exactly as the URL spells them. */
102
+ export function resolveCacheTags(url, env) {
103
+ return [
104
+ breakGlassTag(env),
105
+ ...resolveScopes(url).map((ref) => tagFor(env, ref.kind, ref.id)),
106
+ ];
107
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@gamesheet-inc/cache-invalidation-contract",
3
+ "version": "0.1.0",
4
+ "description": "The stats widget's cache-invalidation contract: cache tags and URL rules, generation keys, and the cache-invalidate message v1.",
5
+ "license": "UNLICENSED",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/GameSheet-Inc/app-stats-widget-next.git",
9
+ "directory": "packages/cache-invalidation-contract"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md"
14
+ ],
15
+ "type": "module",
16
+ "sideEffects": false,
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ }
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^24.13.2",
28
+ "oxfmt": "^0.36.0",
29
+ "oxlint": "^1.79.0",
30
+ "typescript": "^5.9.3",
31
+ "vitest": "^4.0.18",
32
+ "zod": "^4.4.3"
33
+ },
34
+ "peerDependencies": {
35
+ "zod": "^4.3.6"
36
+ },
37
+ "engines": {
38
+ "node": ">=22"
39
+ },
40
+ "scripts": {
41
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
42
+ "type-check": "tsc -p tsconfig.json",
43
+ "test": "vitest run",
44
+ "lint": "oxlint",
45
+ "fmt": "oxfmt",
46
+ "fmt:check": "oxfmt --check"
47
+ }
48
+ }