@mohasinac/appkit 4.66.0 → 4.68.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 (41) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/client/features/maintenance/index.d.ts +1 -0
  3. package/dist/_internal/client/features/maintenance/index.js +1 -0
  4. package/dist/_internal/client/features/maintenance/views/CacheInvalidationPanel.d.ts +33 -0
  5. package/dist/_internal/client/features/maintenance/views/CacheInvalidationPanel.js +96 -0
  6. package/dist/_internal/client/features/maintenance/views/MaintenanceDashboardView.js +2 -1
  7. package/dist/_internal/server/features/products/surface-counts.d.ts +26 -0
  8. package/dist/_internal/server/features/products/surface-counts.js +60 -0
  9. package/dist/_internal/server/features/seo/sitemap.js +31 -2
  10. package/dist/_internal/server/features/site-settings/adapters.d.ts +16 -0
  11. package/dist/_internal/server/features/site-settings/adapters.js +21 -0
  12. package/dist/_internal/server/functions/firestore.d.ts +18 -1
  13. package/dist/_internal/server/functions/firestore.js +25 -1
  14. package/dist/_internal/server/jobs/core/onProductWrite.js +33 -0
  15. package/dist/_internal/server/jobs/core/surfaceCountsRollup.d.ts +55 -0
  16. package/dist/_internal/server/jobs/core/surfaceCountsRollup.js +128 -0
  17. package/dist/_internal/server/jobs/handlers/countersReconcile.js +54 -1
  18. package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
  19. package/dist/_internal/server/jobs/handlers/index.js +1 -0
  20. package/dist/_internal/server/jobs/handlers/onSiteSettingsWrite.d.ts +53 -0
  21. package/dist/_internal/server/jobs/handlers/onSiteSettingsWrite.js +63 -0
  22. package/dist/_internal/shared/actions/action-registry.js +28 -0
  23. package/dist/_internal/shared/cache/revalidate-targets.js +66 -47
  24. package/dist/_internal/shared/features/site-settings/types.d.ts +39 -0
  25. package/dist/_internal/shared/listing-types/feature-flags.d.ts +45 -0
  26. package/dist/_internal/shared/listing-types/feature-flags.js +51 -0
  27. package/dist/_internal/shared/listing-types/hidden-surfaces.d.ts +43 -0
  28. package/dist/_internal/shared/listing-types/hidden-surfaces.js +57 -0
  29. package/dist/client.d.ts +4 -2
  30. package/dist/client.js +10 -1
  31. package/dist/constants/api-endpoints.d.ts +3 -0
  32. package/dist/constants/api-endpoints.js +11 -0
  33. package/dist/features/products/schemas/surface-counts.d.ts +83 -0
  34. package/dist/features/products/schemas/surface-counts.js +58 -0
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.js +3 -1
  37. package/dist/react/hooks/useListingSurfaceVisibility.d.ts +44 -0
  38. package/dist/react/hooks/useListingSurfaceVisibility.js +20 -0
  39. package/dist/server.d.ts +2 -0
  40. package/dist/server.js +12 -0
  41. package/package.json +33 -1
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.66.0",
4
- "head": "e06ffbfe125e5c77504aa8a6a1a3c2e553244321",
3
+ "version": "4.68.0",
4
+ "head": "d9da5b62aa583840cb50fa07288dc03177d1d991",
5
5
  "branch": "main",
6
6
  "dirty": false,
7
- "builtAt": "2026-10-25T21:20:19.588Z"
7
+ "builtAt": "2026-10-26T21:11:44.047Z"
8
8
  }
@@ -5,5 +5,6 @@ export type { ServerErrorDetailViewProps } from "./views/ServerErrorDetailView";
5
5
  export { MaintenanceDashboardView } from "./views/MaintenanceDashboardView";
6
6
  export type { MaintenanceDashboardViewProps } from "./views/MaintenanceDashboardView";
7
7
  export { AnalysisRunnerView } from "./views/AnalysisRunnerView";
8
+ export { CacheInvalidationPanel } from "./views/CacheInvalidationPanel";
8
9
  export { CloudLogsListView } from "./views/CloudLogsListView";
9
10
  export type { CloudLogsListViewProps } from "./views/CloudLogsListView";
@@ -2,4 +2,5 @@ export { ServerErrorsListView } from "./views/ServerErrorsListView.js";
2
2
  export { ServerErrorDetailView } from "./views/ServerErrorDetailView.js";
3
3
  export { MaintenanceDashboardView } from "./views/MaintenanceDashboardView.js";
4
4
  export { AnalysisRunnerView } from "./views/AnalysisRunnerView.js";
5
+ export { CacheInvalidationPanel } from "./views/CacheInvalidationPanel.js";
5
6
  export { CloudLogsListView } from "./views/CloudLogsListView.js";
@@ -0,0 +1,33 @@
1
+ import * as React from "react";
2
+ /**
3
+ * "Invalidate all cached pages" — the manual door into ISR invalidation.
4
+ *
5
+ * ## Why a human needs this at all
6
+ *
7
+ * The Firestore triggers (`onProductWrite`, `onCategoryWrite`, `onReviewWrite`,
8
+ * `onStoreWrite`) invalidate on their own, so the common case needs no button.
9
+ * What they cannot see is a change that did not pass through them: an
10
+ * `appkit-seed load`, a console edit, a bulk script, or a trigger that failed
11
+ * its one best-effort `fetch`. Before this the only recovery was a redeploy or
12
+ * waiting out a TTL — and most detail routes have no TTL at all (they are
13
+ * absent from `prerender-manifest.json`, i.e. cached indefinitely), so "wait"
14
+ * was not actually an option.
15
+ *
16
+ * ## 🛑 Confirmed, and the confirmation is about COST, not damage
17
+ *
18
+ * Nothing is deleted and no document is written — this marks entries stale.
19
+ * The risk is the re-render: this project was suspended with HTTP 402 for 3.1M
20
+ * ISR writes against a 200K allowance. The dialog comes from
21
+ * `ACTIONS.ADMIN["invalidate-cache"].confirmation`, so Rule #7 owns the copy
22
+ * and there is no `window.confirm` here.
23
+ *
24
+ * ## The result is rendered, not toasted
25
+ *
26
+ * `revalidatePath` does not report whether an entry existed, so the only
27
+ * honest feedback is *which paths were marked*. A green toast saying "done"
28
+ * would read identically whether seven paths were marked or zero — and
29
+ * "marked nothing, silently" is the documented failure mode of this whole
30
+ * subsystem (the locale-prefix trap in `revalidate-page-targets.ts`). Showing
31
+ * the list means a wrong prefix is visible rather than inferred.
32
+ */
33
+ export declare function CacheInvalidationPanel(): React.JSX.Element;
@@ -0,0 +1,96 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { Button, Div, Heading, Span, Text } from "@mohasinac/appkit/client";
5
+ import { normalizeError } from "../../../../../errors/normalize.js";
6
+ import { toUserMessage } from "../../../../../errors/error-display-map.js";
7
+ import { ADMIN_ENDPOINTS } from "../../../../../constants/api-endpoints.js";
8
+ import { ACTIONS } from "../../../../shared/actions/action-registry.js";
9
+ /**
10
+ * "Invalidate all cached pages" — the manual door into ISR invalidation.
11
+ *
12
+ * ## Why a human needs this at all
13
+ *
14
+ * The Firestore triggers (`onProductWrite`, `onCategoryWrite`, `onReviewWrite`,
15
+ * `onStoreWrite`) invalidate on their own, so the common case needs no button.
16
+ * What they cannot see is a change that did not pass through them: an
17
+ * `appkit-seed load`, a console edit, a bulk script, or a trigger that failed
18
+ * its one best-effort `fetch`. Before this the only recovery was a redeploy or
19
+ * waiting out a TTL — and most detail routes have no TTL at all (they are
20
+ * absent from `prerender-manifest.json`, i.e. cached indefinitely), so "wait"
21
+ * was not actually an option.
22
+ *
23
+ * ## 🛑 Confirmed, and the confirmation is about COST, not damage
24
+ *
25
+ * Nothing is deleted and no document is written — this marks entries stale.
26
+ * The risk is the re-render: this project was suspended with HTTP 402 for 3.1M
27
+ * ISR writes against a 200K allowance. The dialog comes from
28
+ * `ACTIONS.ADMIN["invalidate-cache"].confirmation`, so Rule #7 owns the copy
29
+ * and there is no `window.confirm` here.
30
+ *
31
+ * ## The result is rendered, not toasted
32
+ *
33
+ * `revalidatePath` does not report whether an entry existed, so the only
34
+ * honest feedback is *which paths were marked*. A green toast saying "done"
35
+ * would read identically whether seven paths were marked or zero — and
36
+ * "marked nothing, silently" is the documented failure mode of this whole
37
+ * subsystem (the locale-prefix trap in `revalidate-page-targets.ts`). Showing
38
+ * the list means a wrong prefix is visible rather than inferred.
39
+ */
40
+ export function CacheInvalidationPanel() {
41
+ const [running, setRunning] = React.useState(false);
42
+ const [error, setError] = React.useState(null);
43
+ const [result, setResult] = React.useState(null);
44
+ async function invalidateAll() {
45
+ setRunning(true);
46
+ setError(null);
47
+ setResult(null);
48
+ try {
49
+ const res = await fetch(ADMIN_ENDPOINTS.CACHE_REVALIDATE, {
50
+ method: "POST",
51
+ headers: { "Content-Type": "application/json" },
52
+ // No `collections` key — the server reads that as "every known
53
+ // collection", which is what this button means.
54
+ body: JSON.stringify({}),
55
+ });
56
+ const body = (await res.json());
57
+ if (!res.ok || !body.ok) {
58
+ /*
59
+ * 🛑 `toUserMessage`, never `?? body.error`. The server's own message
60
+ * is server text; piping it into the UI is how a Node MODULE_NOT_FOUND
61
+ * with a full require stack was once rendered inside a buyer-facing
62
+ * modal (Rule #9 §6, Root Cause #86).
63
+ */
64
+ setError(toUserMessage("code" in body ? String(body.code) : undefined, undefined, {
65
+ fallback: `Cache invalidation failed (HTTP ${res.status}).`,
66
+ }));
67
+ return;
68
+ }
69
+ setResult(body.data);
70
+ }
71
+ catch (err) {
72
+ const e = normalizeError(err);
73
+ setError(toUserMessage(e.code, undefined, {
74
+ fallback: "Could not reach the cache endpoint. Check the server logs for detail.",
75
+ }));
76
+ }
77
+ finally {
78
+ setRunning(false);
79
+ }
80
+ }
81
+ return (_jsxs(Div, { style: {
82
+ padding: "1rem 1.25rem",
83
+ border: "1px solid var(--appkit-color-border)",
84
+ borderRadius: 8,
85
+ background: "var(--appkit-color-surface)",
86
+ marginBottom: "1.5rem",
87
+ }, children: [_jsx(Heading, { level: 2, style: { fontSize: "1rem", fontWeight: 600 }, children: "Page cache" }), _jsx(Text, { as: "p", style: { color: "var(--appkit-color-text-muted)", margin: "0.35rem 0 0.9rem" }, children: "Writes made through the app invalidate their own pages automatically. Use this after a change the triggers could not see \u2014 a seed load, a direct Firestore edit, or a bulk script." }), _jsx(Button, { action: ACTIONS.ADMIN["invalidate-cache"], isLoading: running, disabled: running, onClick: invalidateAll }), error ? (_jsx(Text, { as: "p", role: "alert", style: { color: "var(--appkit-color-error)", marginTop: "0.75rem" }, children: error })) : null, result ? (_jsxs(Div, { style: { marginTop: "0.9rem" }, children: [_jsxs(Text, { as: "p", style: { margin: 0 }, children: ["Marked ", _jsx(Span, { weight: "bold", children: result.pagesRevalidated.length }), " page", result.pagesRevalidated.length === 1 ? "" : "s", " stale across", " ", _jsx(Span, { weight: "bold", children: result.collections.length }), " collection", result.collections.length === 1 ? "" : "s", "."] }), _jsx(Text, { as: "p", size: "xs", style: { color: "var(--appkit-color-text-muted)", margin: "0.3rem 0 0" }, children: "They re-render on their next visit; nothing was deleted." }), _jsx(Div, { style: {
88
+ marginTop: "0.5rem",
89
+ maxHeight: 180,
90
+ overflowY: "auto",
91
+ fontFamily: "monospace",
92
+ fontSize: "0.75rem",
93
+ lineHeight: 1.6,
94
+ color: "var(--appkit-color-text-muted)",
95
+ }, children: result.pagesRevalidated.map((p) => (_jsx(Div, { children: p }, p))) })] })) : null] }));
96
+ }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Anchor, Div, Heading, Li, Nav, Section, Span, Text, Ul } from "@mohasinac/appkit/client";
3
+ import { CacheInvalidationPanel } from "./CacheInvalidationPanel.js";
3
4
  const card = {
4
5
  padding: "1rem 1.25rem",
5
6
  border: "1px solid var(--appkit-color-border)",
@@ -7,7 +8,7 @@ const card = {
7
8
  background: "var(--appkit-color-surface)",
8
9
  };
9
10
  export function MaintenanceDashboardView({ counts, basePath, }) {
10
- return (_jsxs(Div, { style: { padding: "1.5rem", maxWidth: "1200px", margin: "0 auto" }, children: [_jsx(Heading, { level: 1, style: { fontSize: "1.5rem", fontWeight: 600 }, children: "Maintenance" }), _jsx(Text, { as: "p", style: { color: "var(--appkit-color-text-muted)", marginBottom: "1.25rem" }, children: "Errors and failures across the platform \u2014 server, client, and Cloud Functions." }), _jsxs(Div, { style: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(170px, 1fr))", gap: "0.75rem", marginBottom: "1.5rem" }, children: [_jsxs(Div, { style: card, children: [_jsx(Div, { style: { fontSize: "0.78rem", color: "var(--appkit-color-text-muted)" }, children: "Last 24 hours" }), _jsx(Div, { style: { fontSize: "1.75rem", fontWeight: 700 }, children: counts.last24h })] }), _jsxs(Div, { style: card, children: [_jsx(Div, { style: { fontSize: "0.78rem", color: "var(--appkit-color-text-muted)" }, children: "Last 7 days" }), _jsx(Div, { style: { fontSize: "1.75rem", fontWeight: 700 }, children: counts.last7d })] }), _jsxs(Div, { style: card, children: [_jsx(Div, { style: { fontSize: "0.78rem", color: "var(--appkit-color-text-muted)" }, children: "Last 30 days" }), _jsx(Div, { style: { fontSize: "1.75rem", fontWeight: 700 }, children: counts.last30d })] }), _jsxs(Div, { style: card, children: [_jsx(Div, { style: { fontSize: "0.78rem", color: "var(--appkit-color-text-muted)" }, children: "Server (Vercel)" }), _jsx(Div, { style: { fontSize: "1.5rem", fontWeight: 700 }, children: counts.bySource.vercel })] }), _jsxs(Div, { style: card, children: [_jsx(Div, { style: { fontSize: "0.78rem", color: "var(--appkit-color-text-muted)" }, children: "Client" }), _jsx(Div, { style: { fontSize: "1.5rem", fontWeight: 700 }, children: counts.bySource.client })] }), _jsxs(Div, { style: card, children: [_jsx(Div, { style: { fontSize: "0.78rem", color: "var(--appkit-color-text-muted)" }, children: "Cloud Functions" }), _jsx(Div, { style: { fontSize: "1.5rem", fontWeight: 700 }, children: counts.bySource.function })] })] }), _jsxs(Section, { style: { marginBottom: "1.5rem" }, children: [_jsx(Heading, { level: 2, style: { fontSize: "1rem", fontWeight: 600 }, children: "Top codes (30d)" }), _jsx(Ul, { style: { listStyle: "none", padding: 0 }, children: counts.topCodes.length === 0 ? _jsx(Li, { style: { color: "var(--appkit-color-text-muted)" }, children: "No errors recorded." }) :
11
+ return (_jsxs(Div, { style: { padding: "1.5rem", maxWidth: "1200px", margin: "0 auto" }, children: [_jsx(Heading, { level: 1, style: { fontSize: "1.5rem", fontWeight: 600 }, children: "Maintenance" }), _jsx(Text, { as: "p", style: { color: "var(--appkit-color-text-muted)", marginBottom: "1.25rem" }, children: "Errors and failures across the platform \u2014 server, client, and Cloud Functions." }), _jsxs(Div, { style: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(170px, 1fr))", gap: "0.75rem", marginBottom: "1.5rem" }, children: [_jsxs(Div, { style: card, children: [_jsx(Div, { style: { fontSize: "0.78rem", color: "var(--appkit-color-text-muted)" }, children: "Last 24 hours" }), _jsx(Div, { style: { fontSize: "1.75rem", fontWeight: 700 }, children: counts.last24h })] }), _jsxs(Div, { style: card, children: [_jsx(Div, { style: { fontSize: "0.78rem", color: "var(--appkit-color-text-muted)" }, children: "Last 7 days" }), _jsx(Div, { style: { fontSize: "1.75rem", fontWeight: 700 }, children: counts.last7d })] }), _jsxs(Div, { style: card, children: [_jsx(Div, { style: { fontSize: "0.78rem", color: "var(--appkit-color-text-muted)" }, children: "Last 30 days" }), _jsx(Div, { style: { fontSize: "1.75rem", fontWeight: 700 }, children: counts.last30d })] }), _jsxs(Div, { style: card, children: [_jsx(Div, { style: { fontSize: "0.78rem", color: "var(--appkit-color-text-muted)" }, children: "Server (Vercel)" }), _jsx(Div, { style: { fontSize: "1.5rem", fontWeight: 700 }, children: counts.bySource.vercel })] }), _jsxs(Div, { style: card, children: [_jsx(Div, { style: { fontSize: "0.78rem", color: "var(--appkit-color-text-muted)" }, children: "Client" }), _jsx(Div, { style: { fontSize: "1.5rem", fontWeight: 700 }, children: counts.bySource.client })] }), _jsxs(Div, { style: card, children: [_jsx(Div, { style: { fontSize: "0.78rem", color: "var(--appkit-color-text-muted)" }, children: "Cloud Functions" }), _jsx(Div, { style: { fontSize: "1.5rem", fontWeight: 700 }, children: counts.bySource.function })] })] }), _jsx(CacheInvalidationPanel, {}), _jsxs(Section, { style: { marginBottom: "1.5rem" }, children: [_jsx(Heading, { level: 2, style: { fontSize: "1rem", fontWeight: 600 }, children: "Top codes (30d)" }), _jsx(Ul, { style: { listStyle: "none", padding: 0 }, children: counts.topCodes.length === 0 ? _jsx(Li, { style: { color: "var(--appkit-color-text-muted)" }, children: "No errors recorded." }) :
11
12
  counts.topCodes.map((c) => (_jsxs(Li, { style: { display: "flex", justifyContent: "space-between", padding: "0.25rem 0", borderBottom: "1px solid var(--appkit-color-border-subtle)" }, children: [_jsx(Span, { style: { fontFamily: "monospace" }, children: c.code }), _jsx(Span, { children: c.count })] }, c.code))) })] }), _jsxs(Section, { style: { marginBottom: "1.5rem" }, children: [_jsx(Heading, { level: 2, style: { fontSize: "1rem", fontWeight: 600 }, children: "Top routes (30d)" }), _jsx(Ul, { style: { listStyle: "none", padding: 0 }, children: counts.topRoutes.length === 0 ? _jsx(Li, { style: { color: "var(--appkit-color-text-muted)" }, children: "No errors recorded." }) :
12
13
  counts.topRoutes.map((r) => (_jsxs(Li, { style: { display: "flex", justifyContent: "space-between", padding: "0.25rem 0", borderBottom: "1px solid var(--appkit-color-border-subtle)" }, children: [_jsxs(Span, { style: { fontFamily: "monospace" }, children: ["[", r.source, "] ", r.route] }), _jsx(Span, { children: r.count })] }, `${r.source}:${r.route}`))) })] }), _jsxs(Nav, { "aria-label": "Maintenance sections", style: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: "0.5rem" }, children: [_jsxs(Anchor, { href: `${basePath}/server-errors`, tone: "default", underline: "none", style: { ...card }, children: [_jsx(Span, { weight: "bold", children: "Server errors" }), _jsx(Div, { style: { color: "var(--appkit-color-text-muted)", fontSize: "0.85rem" }, children: "Vercel function failures" })] }), _jsxs(Anchor, { href: `${basePath}/client-errors`, tone: "default", underline: "none", style: { ...card }, children: [_jsx(Span, { weight: "bold", children: "Client errors" }), _jsx(Div, { style: { color: "var(--appkit-color-text-muted)", fontSize: "0.85rem" }, children: "window.onerror + unhandledrejection + boundaries" })] }), _jsxs(Anchor, { href: `${basePath}/function-errors`, tone: "default", underline: "none", style: { ...card }, children: [_jsx(Span, { weight: "bold", children: "Function errors" }), _jsx(Div, { style: { color: "var(--appkit-color-text-muted)", fontSize: "0.85rem" }, children: "Cloud Functions handler exceptions" })] }), _jsxs(Anchor, { href: `${basePath}/cloud-logs`, tone: "default", underline: "none", style: { ...card }, children: [_jsx(Span, { weight: "bold", children: "Cloud Logging" }), _jsx(Div, { style: { color: "var(--appkit-color-text-muted)", fontSize: "0.85rem" }, children: "Live stream from Cloud Logging API" })] }), _jsxs(Anchor, { href: `${basePath}/payment-rollbacks`, tone: "default", underline: "none", style: { ...card }, children: [_jsx(Span, { weight: "bold", children: "Payment rollbacks" }), _jsx(Div, { style: { color: "var(--appkit-color-text-muted)", fontSize: "0.85rem" }, children: "PhonePe refund + upstream-unavailable rows" })] }), _jsxs(Anchor, { href: `${basePath}/analysis`, tone: "default", underline: "none", style: { ...card }, children: [_jsx(Span, { weight: "bold", children: "Analysis" }), _jsx(Div, { style: { color: "var(--appkit-color-text-muted)", fontSize: "0.85rem" }, children: "Run the maintenance analyzer + recommendations" })] })] })] }));
13
14
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Read `surfaceCounts/global` — one document, for the public settings payload
3
+ * and the server-side page guards.
4
+ *
5
+ * 🛑 FAILS OPEN, AND "OPEN" HERE MEANS `undefined`, NOT `{}`.
6
+ *
7
+ * Every failure path — missing document, unreadable document, malformed
8
+ * payload — returns `undefined`, which `isListingSurfaceVisible` reads as
9
+ * "unknown, show it". Returning an empty object instead would read as
10
+ * "measured, everything is empty" and would 404 every browse page on the site
11
+ * the first time Firestore hiccuped.
12
+ *
13
+ * This is the same failure posture as `guardBrowsePage`'s settings read ("a
14
+ * Firestore blip must not 404 the catalogue") and `getLiveCategoryCounts`
15
+ * ("a missing document is `undefined`, never 0").
16
+ *
17
+ * @tag domain:products
18
+ * @tag layer:server-feature
19
+ * @tag access:server-only
20
+ * @tag sideEffects:firestore-read
21
+ */
22
+ export interface PublicSurfaceCounts {
23
+ listingTypes?: Record<string, number>;
24
+ surfaces?: Record<string, number>;
25
+ }
26
+ export declare function getSurfaceCounts(): Promise<PublicSurfaceCounts | undefined>;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Read `surfaceCounts/global` — one document, for the public settings payload
3
+ * and the server-side page guards.
4
+ *
5
+ * 🛑 FAILS OPEN, AND "OPEN" HERE MEANS `undefined`, NOT `{}`.
6
+ *
7
+ * Every failure path — missing document, unreadable document, malformed
8
+ * payload — returns `undefined`, which `isListingSurfaceVisible` reads as
9
+ * "unknown, show it". Returning an empty object instead would read as
10
+ * "measured, everything is empty" and would 404 every browse page on the site
11
+ * the first time Firestore hiccuped.
12
+ *
13
+ * This is the same failure posture as `guardBrowsePage`'s settings read ("a
14
+ * Firestore blip must not 404 the catalogue") and `getLiveCategoryCounts`
15
+ * ("a missing document is `undefined`, never 0").
16
+ *
17
+ * @tag domain:products
18
+ * @tag layer:server-feature
19
+ * @tag access:server-only
20
+ * @tag sideEffects:firestore-read
21
+ */
22
+ import { normalizeError } from "../../../../errors/normalize.js";
23
+ import { serverLogger } from "../../../../monitoring/server-logger.js";
24
+ import { getAdminDb } from "../../../../providers/db-firebase/index.js";
25
+ import { SURFACE_COUNTS_COLLECTION, SURFACE_COUNTS_DOC_ID, } from "../../../../features/products/schemas/surface-counts.js";
26
+ /** Keep only numeric entries — a malformed value must not read as a zero. */
27
+ function sanitize(input) {
28
+ if (!input || typeof input !== "object")
29
+ return undefined;
30
+ const out = {};
31
+ for (const [k, v] of Object.entries(input)) {
32
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0)
33
+ out[k] = v;
34
+ }
35
+ return Object.keys(out).length > 0 ? out : undefined;
36
+ }
37
+ export async function getSurfaceCounts() {
38
+ try {
39
+ const db = getAdminDb();
40
+ const snap = await db
41
+ .collection(SURFACE_COUNTS_COLLECTION)
42
+ .doc(SURFACE_COUNTS_DOC_ID)
43
+ .get();
44
+ if (!snap.exists)
45
+ return undefined;
46
+ const data = snap.data() ?? {};
47
+ const listingTypes = sanitize(data.listingTypes);
48
+ const surfaces = sanitize(data.surfaces);
49
+ // Both empty -> `undefined`, not `{}`. A document that exists but holds
50
+ // nothing usable is not a measurement.
51
+ if (!listingTypes && !surfaces)
52
+ return undefined;
53
+ return { listingTypes, surfaces };
54
+ }
55
+ catch (err) {
56
+ void normalizeError(err);
57
+ serverLogger.warn("surfaceCounts read failed — surfaces will all render visible");
58
+ return undefined;
59
+ }
60
+ }
@@ -17,6 +17,8 @@ import { PRODUCT_DETAIL_LISTING_TYPES } from "./product-detail-types.js";
17
17
  import { safeRead } from "../../../../errors/safe-read.js";
18
18
  import { siteSettingsRepository } from "../../../../features/admin/repository/site-settings.repository.js";
19
19
  import { ALL_BROWSE_PAGES, browsePageForPath, isBrowsePageEnabled, } from "../../../shared/listing-types/feature-flags.js";
20
+ import { hiddenSurfaceRoutes } from "../../../shared/listing-types/hidden-surfaces.js";
21
+ import { getSurfaceCounts } from "../products/surface-counts.js";
20
22
  // Product field strings — matches consumer field-names.ts
21
23
  const PRODUCT_STATUS = "status";
22
24
  const PRODUCT_STATUS_PUBLISHED = "published";
@@ -648,12 +650,39 @@ export async function buildSitemap({ baseUrl }) {
648
650
  fallback: null,
649
651
  });
650
652
  const closed = new Set(ALL_BROWSE_PAGES.filter((p) => !isBrowsePageEnabled(p, settings)));
653
+ /*
654
+ * The same treatment for a listing-type surface that is switched off or
655
+ * measured as holding nothing — `/auctions`, `/art`, `/spares`, …
656
+ *
657
+ * 🛑 BROWSE ROUTES ONLY, matched on the exact path. Detail URLs are NOT
658
+ * dropped here and that is deliberate: art, stickers, spares and standard
659
+ * all detail at `/products/{slug}`, so a path test cannot tell them apart,
660
+ * and a prefix rule would silently delist the entire standard catalogue the
661
+ * day `spares` emptied. An empty type contributes no detail URLs anyway —
662
+ * its fetcher returns none — so the exact-path rule loses nothing real.
663
+ *
664
+ * 🛑 Fails OPEN twice: `settings` is already `safeRead`-wrapped above, and
665
+ * `getSurfaceCounts` returns `undefined` on any failure, which
666
+ * `isListingSurfaceVisible` reads as visible. An unmeasured surface stays in
667
+ * the sitemap rather than being withdrawn by a job that has not run.
668
+ */
669
+ const counts = await getSurfaceCounts();
670
+ /*
671
+ * 🛑 ONE definition, shared with the edge gate. These two loops used to live
672
+ * here and are now `hiddenSurfaceRoutes` — because `toPublicSiteSettings`
673
+ * needs the same answer to tell `src/proxy.ts` which browse routes to 404,
674
+ * and a sitemap that advertises a URL the edge 404s is a crawl error on a
675
+ * page we asked Google to fetch.
676
+ */
677
+ const hiddenSurfacePaths = new Set(hiddenSurfaceRoutes(settings, counts));
651
678
  const keep = (entry) => {
652
- if (closed.size === 0)
653
- return true;
654
679
  const path = entry.url.startsWith("http")
655
680
  ? new URL(entry.url).pathname
656
681
  : entry.url;
682
+ if (hiddenSurfacePaths.has(path.replace(/\/+$/, "") || "/"))
683
+ return false;
684
+ if (closed.size === 0)
685
+ return true;
657
686
  const page = browsePageForPath(path);
658
687
  return page === null || !closed.has(page);
659
688
  };
@@ -47,5 +47,21 @@ export interface ToPublicSiteSettingsOptions {
47
47
  * (`src/lib/watermark/resolve-effective-watermark.ts`), not in appkit.
48
48
  */
49
49
  effectiveWatermark?: MediaVideoWatermark;
50
+ /**
51
+ * The `surfaceCounts/global` document, when one exists.
52
+ *
53
+ * Injected rather than read here for the same reason as the watermark: this
54
+ * adapter is a pure projection over a document it is handed, and giving it
55
+ * a Firestore read of its own would make every caller pay for it.
56
+ *
57
+ * 🛑 Pass `undefined` when the document is missing OR the read failed. Those
58
+ * two must not be distinguished here and must never become `{}`: an absent
59
+ * key means "unknown, show it", and an empty object measured as all-zero
60
+ * would hide every browse surface on the site.
61
+ */
62
+ surfaceCounts?: {
63
+ listingTypes?: Record<string, number>;
64
+ surfaces?: Record<string, number>;
65
+ };
50
66
  }
51
67
  export declare function toPublicSiteSettings(doc: SiteSettingsDocument, opts?: ToPublicSiteSettingsOptions): PublicSiteSettings;
@@ -1,3 +1,4 @@
1
+ import { hiddenSurfaceRoutes } from "../../../shared/listing-types/hidden-surfaces.js";
1
2
  /**
2
3
  * Document fields with a proven `useSiteSettings()` reader. Every entry below
3
4
  * was traced to a call site; anything without one belongs in the private list,
@@ -186,6 +187,26 @@ export function toPublicSiteSettings(doc, opts) {
186
187
  // and leave the next person wondering whether the projection ran at all.
187
188
  disabledRoutes: doc.disabledRoutes ?? [],
188
189
  effectiveWatermark: opts?.effectiveWatermark,
190
+ /*
191
+ * 🛑 Passed through as-is, INCLUDING `undefined` — the exact opposite of
192
+ * the `disabledRoutes` line three above, and the contrast is deliberate.
193
+ *
194
+ * There, defaulting to `[]` is right because the proxy fails open and an
195
+ * absent list and an empty list already mean the same thing. Here they
196
+ * mean OPPOSITE things: absent is "never measured, show everything",
197
+ * `{}` is "measured, and every surface is empty". Defaulting this one
198
+ * would take the entire catalogue off the navbar.
199
+ */
200
+ surfaceCounts: opts?.surfaceCounts,
201
+ /*
202
+ * Derived from the two fields above it — the admin flags on `doc` and the
203
+ * measured counts in `opts`. Computed here rather than in the proxy because
204
+ * the proxy runs on the EDGE: `hiddenSurfaceRoutes` reaches the listing-type
205
+ * registry, and pulling that into the middleware bundle to re-derive an
206
+ * answer the server already holds would be the wrong trade. The edge gets a
207
+ * list of strings to match, exactly like `disabledRoutes`.
208
+ */
209
+ hiddenSurfacePaths: hiddenSurfaceRoutes(doc, opts?.surfaceCounts),
189
210
  // D9 — undefined reads as OFF, never a default-true `??`.
190
211
  chat: { enabled: doc.chat?.enabled === true },
191
212
  /*
@@ -18,6 +18,23 @@ export declare const onPrizeDrawPaymentConfirmed: import("./types").DocumentTrig
18
18
  export declare const prizeDrawSoldOutReveal: import("./types").DocumentTriggerFunctionDefinition<import("../jobs/core/prizeDrawSoldOutReveal").PrizeDrawStockSnapshot, import("../jobs/core/prizeDrawSoldOutReveal").PrizeDrawStockSnapshot>;
19
19
  export declare const onReviewWrite: import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>;
20
20
  export declare const onCategoryWrite: import("./types").DocumentTriggerFunctionDefinition<import("../jobs").CategoryDoc, import("../jobs").CategoryDoc>;
21
+ /**
22
+ * 🛑 The ONE trigger that invalidates the whole page cache.
23
+ *
24
+ * `documentWritten` and not `documentUpdated`: a settings document that is
25
+ * CREATED on first access (`getSingleton` writes defaults when it is missing)
26
+ * changes the site exactly as much as an edit does.
27
+ *
28
+ * `siteSettings` is a singleton, so the `{settingsId}` wildcard matches one
29
+ * document in practice — it is a wildcard because a trigger path must be, and
30
+ * the handler reads the id from it rather than hardcoding `"global"`.
31
+ *
32
+ * See the handler for why this carries no field-level diff while
33
+ * `onProductWrite` must, and for the measurement that prompted it: a listing
34
+ * type switched off in Firestore while its browse page kept serving a cached
35
+ * 200 because nothing marked it stale.
36
+ */
37
+ export declare const onSiteSettingsWrite: import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>;
21
38
  export declare const onStoreWrite: import("./types").DocumentTriggerFunctionDefinition<import("../jobs").StoreDoc, import("../jobs").StoreDoc>;
22
39
  export declare const onSupportTicketCreate: import("./types").DocumentTriggerFunctionDefinition<null, Record<string, import("../../..").JsonValue>>;
23
40
  export declare const onSupportTicketUpdate: import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>;
@@ -54,7 +71,7 @@ export declare const FIRESTORE_TRIGGER_FUNCTIONS: readonly [import("./types").Do
54
71
  [x: string]: import("../../..").JsonValue;
55
72
  }, {
56
73
  [x: string]: import("../../..").JsonValue;
57
- }>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs/core/onPrizeDrawPaymentConfirmed").PrizeDrawOrderSnapshot, import("../jobs/core/onPrizeDrawPaymentConfirmed").PrizeDrawOrderSnapshot>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs/core/prizeDrawSoldOutReveal").PrizeDrawStockSnapshot, import("../jobs/core/prizeDrawSoldOutReveal").PrizeDrawStockSnapshot>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs").CategoryDoc, import("../jobs").CategoryDoc>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs").StoreDoc, import("../jobs").StoreDoc>, import("./types").DocumentTriggerFunctionDefinition<null, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<null, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<{
74
+ }>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs/core/onPrizeDrawPaymentConfirmed").PrizeDrawOrderSnapshot, import("../jobs/core/onPrizeDrawPaymentConfirmed").PrizeDrawOrderSnapshot>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs/core/prizeDrawSoldOutReveal").PrizeDrawStockSnapshot, import("../jobs/core/prizeDrawSoldOutReveal").PrizeDrawStockSnapshot>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs").CategoryDoc, import("../jobs").CategoryDoc>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs").StoreDoc, import("../jobs").StoreDoc>, import("./types").DocumentTriggerFunctionDefinition<null, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<null, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<{
58
75
  [x: string]: import("../../..").JsonValue;
59
76
  }, {
60
77
  [x: string]: import("../../..").JsonValue;
@@ -6,7 +6,7 @@
6
6
  * The path patterns mirror the original `functions/src/index.ts` declarations
7
7
  * to preserve identical deploy behavior.
8
8
  */
9
- import { onCategoryWriteHandler, onOrderCreateHandler, onOrderStatusChangeHandler, onProductStockChangeHandler, onProductWriteHandler, onReviewWriteHandler, onScamReportCreateHandler, onScamReportUpdateHandler, onStoreWriteHandler, onSupportTicketCreateHandler, onSupportTicketUpdateHandler, onUserBanChangeHandler, onShipmentItemWriteHandler, onShipmentLotWriteHandler, onShipmentHeaderWriteHandler, onShipmentDeletedHandler, onCatalogueSubmittedForApprovalHandler, onJobCreatedHandler, onPrizeDrawPaymentConfirmedHandler, prizeDrawSoldOutRevealHandler, } from "../jobs/handlers/index.js";
9
+ import { onCategoryWriteHandler, onOrderCreateHandler, onOrderStatusChangeHandler, onProductStockChangeHandler, onProductWriteHandler, onReviewWriteHandler, onScamReportCreateHandler, onScamReportUpdateHandler, onSiteSettingsWriteHandler, onStoreWriteHandler, onSupportTicketCreateHandler, onSupportTicketUpdateHandler, onUserBanChangeHandler, onShipmentItemWriteHandler, onShipmentLotWriteHandler, onShipmentHeaderWriteHandler, onShipmentDeletedHandler, onCatalogueSubmittedForApprovalHandler, onJobCreatedHandler, onPrizeDrawPaymentConfirmedHandler, prizeDrawSoldOutRevealHandler, } from "../jobs/handlers/index.js";
10
10
  import { defineFunction } from "./define.js";
11
11
  const REGION = "asia-south1";
12
12
  export const onOrderCreate = defineFunction({
@@ -65,6 +65,29 @@ export const onCategoryWrite = defineFunction({
65
65
  handler: onCategoryWriteHandler,
66
66
  options: { region: REGION },
67
67
  });
68
+ /**
69
+ * 🛑 The ONE trigger that invalidates the whole page cache.
70
+ *
71
+ * `documentWritten` and not `documentUpdated`: a settings document that is
72
+ * CREATED on first access (`getSingleton` writes defaults when it is missing)
73
+ * changes the site exactly as much as an edit does.
74
+ *
75
+ * `siteSettings` is a singleton, so the `{settingsId}` wildcard matches one
76
+ * document in practice — it is a wildcard because a trigger path must be, and
77
+ * the handler reads the id from it rather than hardcoding `"global"`.
78
+ *
79
+ * See the handler for why this carries no field-level diff while
80
+ * `onProductWrite` must, and for the measurement that prompted it: a listing
81
+ * type switched off in Firestore while its browse page kept serving a cached
82
+ * 200 because nothing marked it stale.
83
+ */
84
+ export const onSiteSettingsWrite = defineFunction({
85
+ name: "onSiteSettingsWrite",
86
+ description: "Invalidate the whole page cache when site settings change.",
87
+ trigger: { kind: "documentWritten", pathPattern: "siteSettings/{settingsId}" },
88
+ handler: onSiteSettingsWriteHandler,
89
+ options: { region: REGION },
90
+ });
68
91
  export const onStoreWrite = defineFunction({
69
92
  name: "onStoreWrite",
70
93
  description: "Side-effects on store writes (status mirror, owner uid index).",
@@ -165,6 +188,7 @@ export const FIRESTORE_TRIGGER_FUNCTIONS = [
165
188
  prizeDrawSoldOutReveal,
166
189
  onReviewWrite,
167
190
  onCategoryWrite,
191
+ onSiteSettingsWrite,
168
192
  onStoreWrite,
169
193
  onSupportTicketCreate,
170
194
  onSupportTicketUpdate,
@@ -8,7 +8,9 @@ import { storeRepository } from "../../../../features/stores/repository/store.re
8
8
  import { ProductStatusValues } from "../../../../features/products/schemas/firestore.js";
9
9
  import { PRODUCT_FIELDS } from "../../../../constants/field-names.js";
10
10
  import { isListingRowAvailable } from "../../../shared/listing-types/_registry.js";
11
+ import { isListingType } from "../../../shared/listing-types/feature-flags.js";
11
12
  import { notifyRevalidate, productCacheFieldsChanged } from "./revalidateNotify.js";
13
+ import { runSurfaceCountsRollup } from "./surfaceCountsRollup.js";
12
14
  const UNAVAILABLE_PRODUCT_STATUSES = new Set([
13
15
  ProductStatusValues.ARCHIVED,
14
16
  ]);
@@ -301,4 +303,35 @@ export async function handleProductWrite(input, ctx) {
301
303
  // reports how many rows it had to correct.
302
304
  ctx.logger.error("Counter update failed (non-fatal — countersReconcile will heal it tonight)", err, { productId });
303
305
  }
306
+ /*
307
+ * Refresh the site-wide "is this surface empty" counters.
308
+ *
309
+ * 🛑 Gated on `wasCounted !== isCounted`, reusing the availability-boundary
310
+ * signals computed just above rather than deriving a second opinion. Those
311
+ * two booleans already mean "did this row enter or leave the set a public
312
+ * browse page would show", which is exactly and only when a surface can
313
+ * cross zero. A stock decrement from 9 to 8 recomputes nothing.
314
+ *
315
+ * 🛑 Scoped to the AFFECTED types, never all ten. A product can change
316
+ * `listingType`, which empties the old surface and fills the new one, so
317
+ * both are probed — and `only` makes `runSurfaceCountsRollup` merge rather
318
+ * than replace, so the eight types this write cannot have touched keep
319
+ * their measured values instead of being dropped to unknown.
320
+ *
321
+ * Non-fatal, same contract as the counters above: the nightly rollup is the
322
+ * healer, and an unwritten counter degrades to "unknown", which every
323
+ * reader shows rather than hides.
324
+ */
325
+ if (wasCounted !== isCounted) {
326
+ const touched = Array.from(new Set([before?.listingType, after?.listingType].filter((t) => typeof t === "string" && isListingType(t))));
327
+ if (touched.length > 0) {
328
+ try {
329
+ await runSurfaceCountsRollup(ctx, touched);
330
+ }
331
+ catch (err) {
332
+ void normalizeError(err);
333
+ ctx.logger.error("Surface-count refresh failed (non-fatal — the nightly rollup will heal it)", err, { productId, touched });
334
+ }
335
+ }
336
+ }
304
337
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Recompute `surfaceCounts/global` — "does each public browse surface have
3
+ * anything in it?"
4
+ *
5
+ * ## 🛑 It asks `listPublicProducts`, not Firestore
6
+ *
7
+ * The temptation is a `count()` aggregation per listing type. That would be
8
+ * cheaper and it would be WRONG, because "available" is not expressible as a
9
+ * Firestore predicate: it is a negation (`NOT (sold OR ended OR closed OR
10
+ * depleted)`) evaluated per row, which is exactly why `listPublicProducts`
11
+ * runs one bounded query per unavailable-clause and filters in memory.
12
+ *
13
+ * A bespoke count would therefore answer a DIFFERENT question from the page it
14
+ * governs, and the failure mode is the bad one: hiding `/spares` while
15
+ * `/spares` would have rendered rows. Routing through the one function every
16
+ * browse page already uses makes that disagreement impossible by construction
17
+ * — the same argument CLAUDE.md makes for never calling
18
+ * `productRepository.list()` directly from a listing view.
19
+ *
20
+ * ## Cost
21
+ *
22
+ * `pageSize: 1` — we need existence, not a figure. Ten listing types plus one
23
+ * review query, each bounded, and only from a job or a boundary-crossing
24
+ * trigger. Never from a request path: the public reader gets the single
25
+ * pre-computed document via `/api/site-settings`.
26
+ *
27
+ * ## 🛑 A failed probe writes NOTHING for that type
28
+ *
29
+ * `listPublicProducts` returns `null` on failure. Recording that as `0` would
30
+ * hide a surface because a query timed out, so a failed probe leaves the key
31
+ * ABSENT — which every reader treats as "show" (Root Cause #72). Partial
32
+ * results are written: a surface we did measure is still worth recording.
33
+ *
34
+ * @tag domain:products
35
+ * @tag layer:job
36
+ * @tag access:server-only
37
+ * @tag sideEffects:firestore-read,firestore-write
38
+ */
39
+ import type { ListingType } from "../../../../features/products/types/index";
40
+ import type { JobContext } from "../runtime/types";
41
+ export interface SurfaceCountsResult {
42
+ listingTypes: Partial<Record<ListingType, number>>;
43
+ surfaces: Partial<Record<string, number>>;
44
+ /** Types whose probe failed — recorded so a silent partial is visible. */
45
+ failed: string[];
46
+ }
47
+ export declare function computeSurfaceCounts(ctx: Pick<JobContext, "db">, only?: readonly ListingType[]): Promise<SurfaceCountsResult>;
48
+ /**
49
+ * Recompute and persist.
50
+ *
51
+ * `only` narrows to the types a single product write could have moved, so the
52
+ * trigger path costs one query instead of eleven. A narrowed run MERGES — it
53
+ * must never clear the keys it did not measure.
54
+ */
55
+ export declare function runSurfaceCountsRollup(ctx: Pick<JobContext, "db">, only?: readonly ListingType[]): Promise<SurfaceCountsResult>;