@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.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/client/features/maintenance/index.d.ts +1 -0
- package/dist/_internal/client/features/maintenance/index.js +1 -0
- package/dist/_internal/client/features/maintenance/views/CacheInvalidationPanel.d.ts +33 -0
- package/dist/_internal/client/features/maintenance/views/CacheInvalidationPanel.js +96 -0
- package/dist/_internal/client/features/maintenance/views/MaintenanceDashboardView.js +2 -1
- package/dist/_internal/server/features/products/surface-counts.d.ts +26 -0
- package/dist/_internal/server/features/products/surface-counts.js +60 -0
- package/dist/_internal/server/features/seo/sitemap.js +37 -3
- package/dist/_internal/server/features/site-settings/adapters.d.ts +16 -0
- package/dist/_internal/server/features/site-settings/adapters.js +11 -0
- package/dist/_internal/server/jobs/core/onProductWrite.js +33 -0
- package/dist/_internal/server/jobs/core/surfaceCountsRollup.d.ts +55 -0
- package/dist/_internal/server/jobs/core/surfaceCountsRollup.js +128 -0
- package/dist/_internal/server/jobs/handlers/countersReconcile.js +54 -1
- package/dist/_internal/shared/actions/action-registry.js +28 -0
- package/dist/_internal/shared/cache/revalidate-targets.js +66 -47
- package/dist/_internal/shared/features/site-settings/types.d.ts +17 -0
- package/dist/_internal/shared/listing-types/feature-flags.d.ts +45 -0
- package/dist/_internal/shared/listing-types/feature-flags.js +51 -0
- package/dist/client.d.ts +4 -2
- package/dist/client.js +10 -1
- package/dist/constants/api-endpoints.d.ts +3 -0
- package/dist/constants/api-endpoints.js +11 -0
- package/dist/features/products/schemas/surface-counts.d.ts +83 -0
- package/dist/features/products/schemas/surface-counts.js +58 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +3 -1
- package/dist/react/hooks/useListingSurfaceVisibility.d.ts +44 -0
- package/dist/react/hooks/useListingSurfaceVisibility.js +20 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +12 -0
- package/package.json +33 -1
package/dist/.build-stamp.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"stampVersion": 1,
|
|
3
|
-
"version": "4.
|
|
4
|
-
"head": "
|
|
3
|
+
"version": "4.67.0",
|
|
4
|
+
"head": "a745f11c80fa634f73acc9013502f5bcc17972db",
|
|
5
5
|
"branch": "main",
|
|
6
6
|
"dirty": false,
|
|
7
|
-
"builtAt": "2026-10-
|
|
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
|
+
}
|