@mohasinac/appkit 4.66.0 → 4.67.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 (33) 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 +37 -3
  10. package/dist/_internal/server/features/site-settings/adapters.d.ts +16 -0
  11. package/dist/_internal/server/features/site-settings/adapters.js +11 -0
  12. package/dist/_internal/server/jobs/core/onProductWrite.js +33 -0
  13. package/dist/_internal/server/jobs/core/surfaceCountsRollup.d.ts +55 -0
  14. package/dist/_internal/server/jobs/core/surfaceCountsRollup.js +128 -0
  15. package/dist/_internal/server/jobs/handlers/countersReconcile.js +54 -1
  16. package/dist/_internal/shared/actions/action-registry.js +28 -0
  17. package/dist/_internal/shared/cache/revalidate-targets.js +66 -47
  18. package/dist/_internal/shared/features/site-settings/types.d.ts +17 -0
  19. package/dist/_internal/shared/listing-types/feature-flags.d.ts +45 -0
  20. package/dist/_internal/shared/listing-types/feature-flags.js +51 -0
  21. package/dist/client.d.ts +4 -2
  22. package/dist/client.js +10 -1
  23. package/dist/constants/api-endpoints.d.ts +3 -0
  24. package/dist/constants/api-endpoints.js +11 -0
  25. package/dist/features/products/schemas/surface-counts.d.ts +83 -0
  26. package/dist/features/products/schemas/surface-counts.js +58 -0
  27. package/dist/index.d.ts +2 -2
  28. package/dist/index.js +3 -1
  29. package/dist/react/hooks/useListingSurfaceVisibility.d.ts +44 -0
  30. package/dist/react/hooks/useListingSurfaceVisibility.js +20 -0
  31. package/dist/server.d.ts +2 -0
  32. package/dist/server.js +12 -0
  33. 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.67.0",
4
+ "head": "a745f11c80fa634f73acc9013502f5bcc17972db",
5
5
  "branch": "main",
6
6
  "dirty": false,
7
- "builtAt": "2026-10-25T21:20:19.588Z"
7
+ "builtAt": "2026-10-26T11:54:19.893Z"
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
+ }
@@ -16,7 +16,9 @@ import { PRODUCT_FIELDS } from "../../../../constants/field-names.js";
16
16
  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
- import { ALL_BROWSE_PAGES, browsePageForPath, isBrowsePageEnabled, } from "../../../shared/listing-types/feature-flags.js";
19
+ import { ALL_BROWSE_PAGES, ALL_LISTING_TYPES, browsePageForPath, isBrowsePageEnabled, isListingSurfaceVisible, } from "../../../shared/listing-types/feature-flags.js";
20
+ import { pluginFor } from "../../../shared/listing-types/_registry.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,44 @@ 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
+ const hiddenSurfacePaths = new Set(ALL_LISTING_TYPES.filter((t) => !isListingSurfaceVisible(t, settings, counts))
671
+ .map((t) => String(pluginFor(t).browseRoute ?? ""))
672
+ .filter(Boolean));
673
+ /*
674
+ * A route shared by several types survives while ANY of them is visible —
675
+ * `/art` spans art and stickers. Without this, hiding stickers alone would
676
+ * delist a page art still fills.
677
+ */
678
+ for (const t of ALL_LISTING_TYPES) {
679
+ if (isListingSurfaceVisible(t, settings, counts)) {
680
+ hiddenSurfacePaths.delete(String(pluginFor(t).browseRoute ?? ""));
681
+ }
682
+ }
651
683
  const keep = (entry) => {
652
- if (closed.size === 0)
653
- return true;
654
684
  const path = entry.url.startsWith("http")
655
685
  ? new URL(entry.url).pathname
656
686
  : entry.url;
687
+ if (hiddenSurfacePaths.has(path.replace(/\/+$/, "") || "/"))
688
+ return false;
689
+ if (closed.size === 0)
690
+ return true;
657
691
  const page = browsePageForPath(path);
658
692
  return page === null || !closed.has(page);
659
693
  };
@@ -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;
@@ -186,6 +186,17 @@ export function toPublicSiteSettings(doc, opts) {
186
186
  // and leave the next person wondering whether the projection ran at all.
187
187
  disabledRoutes: doc.disabledRoutes ?? [],
188
188
  effectiveWatermark: opts?.effectiveWatermark,
189
+ /*
190
+ * 🛑 Passed through as-is, INCLUDING `undefined` — the exact opposite of
191
+ * the `disabledRoutes` line three above, and the contrast is deliberate.
192
+ *
193
+ * There, defaulting to `[]` is right because the proxy fails open and an
194
+ * absent list and an empty list already mean the same thing. Here they
195
+ * mean OPPOSITE things: absent is "never measured, show everything",
196
+ * `{}` is "measured, and every surface is empty". Defaulting this one
197
+ * would take the entire catalogue off the navbar.
198
+ */
199
+ surfaceCounts: opts?.surfaceCounts,
189
200
  // D9 — undefined reads as OFF, never a default-true `??`.
190
201
  chat: { enabled: doc.chat?.enabled === true },
191
202
  /*
@@ -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>;
@@ -0,0 +1,128 @@
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 { normalizeError } from "../../../../errors/normalize.js";
40
+ import { listPublicProducts } from "../../features/products/list-public.js";
41
+ import { SURFACE_COUNTS_COLLECTION, SURFACE_COUNTS_DOC_ID, } from "../../../../features/products/schemas/surface-counts.js";
42
+ import { ALL_LISTING_TYPES } from "../../../shared/listing-types/feature-flags.js";
43
+ import { REVIEW_COLLECTION, reviewQueryHelpers, } from "../../../../features/reviews/schemas/firestore.js";
44
+ /**
45
+ * Probe one listing type. Returns `undefined` — not 0 — when the query failed.
46
+ *
47
+ * `total` is a FLOOR once `truncated` is set, which does not matter here: we
48
+ * only compare against zero, and a floor of N > 0 is still decisively
49
+ * non-empty.
50
+ */
51
+ async function probeListingType(type) {
52
+ try {
53
+ const res = await listPublicProducts({ listingTypes: [type], pageSize: 1 });
54
+ if (!res)
55
+ return undefined;
56
+ return res.total;
57
+ }
58
+ catch (err) {
59
+ void normalizeError(err);
60
+ return undefined;
61
+ }
62
+ }
63
+ export async function computeSurfaceCounts(ctx, only) {
64
+ const types = only ?? ALL_LISTING_TYPES;
65
+ const listingTypes = {};
66
+ const surfaces = {};
67
+ const failed = [];
68
+ // Parallel: ten bounded queries with no interdependence. Sequential would
69
+ // be ten round-trips for no benefit.
70
+ const probes = await Promise.all(types.map((t) => probeListingType(t)));
71
+ types.forEach((t, i) => {
72
+ const n = probes[i];
73
+ if (n === undefined)
74
+ failed.push(t);
75
+ else
76
+ listingTypes[t] = n;
77
+ });
78
+ // `/reviews` has no listing-type flag, so emptiness is its only signal.
79
+ // A plain `count()` IS correct here: a review has no availability notion —
80
+ // it is approved and public, or it is not in the collection's public view.
81
+ if (!only) {
82
+ try {
83
+ // `reviewQueryHelpers.approved()` rather than a hand-written
84
+ // `["isApproved", "==", true]` — which is what this first read, and the
85
+ // field does not exist. Approval is `status == "approved"`, and a filter
86
+ // on an absent field matches NOTHING, so the hand-written version would
87
+ // have reported every site as having zero reviews and hidden /reviews
88
+ // permanently. The helper is the shipped spelling.
89
+ const [field, op, value] = reviewQueryHelpers.approved();
90
+ const snap = await ctx.db
91
+ .collection(REVIEW_COLLECTION)
92
+ .where(field, op, value)
93
+ .limit(1)
94
+ .get();
95
+ surfaces.reviews = snap.size;
96
+ }
97
+ catch (err) {
98
+ void normalizeError(err);
99
+ failed.push("reviews");
100
+ }
101
+ }
102
+ return { listingTypes, surfaces, failed };
103
+ }
104
+ /**
105
+ * Recompute and persist.
106
+ *
107
+ * `only` narrows to the types a single product write could have moved, so the
108
+ * trigger path costs one query instead of eleven. A narrowed run MERGES — it
109
+ * must never clear the keys it did not measure.
110
+ */
111
+ export async function runSurfaceCountsRollup(ctx, only) {
112
+ const result = await computeSurfaceCounts(ctx, only);
113
+ const payload = { updatedAt: new Date() };
114
+ // Dot-path merge for a narrowed run: `{ listingTypes: {...} }` with
115
+ // `merge: true` would still replace the whole map on some drivers, and we
116
+ // must not drop a key this run did not probe.
117
+ for (const [k, v] of Object.entries(result.listingTypes)) {
118
+ payload[`listingTypes.${k}`] = v;
119
+ }
120
+ for (const [k, v] of Object.entries(result.surfaces)) {
121
+ payload[`surfaces.${k}`] = v;
122
+ }
123
+ await ctx.db
124
+ .collection(SURFACE_COUNTS_COLLECTION)
125
+ .doc(SURFACE_COUNTS_DOC_ID)
126
+ .set(payload, { merge: true });
127
+ return result;
128
+ }