@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.
- 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 +31 -2
- package/dist/_internal/server/features/site-settings/adapters.d.ts +16 -0
- package/dist/_internal/server/features/site-settings/adapters.js +21 -0
- package/dist/_internal/server/functions/firestore.d.ts +18 -1
- package/dist/_internal/server/functions/firestore.js +25 -1
- 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/server/jobs/handlers/index.d.ts +1 -0
- package/dist/_internal/server/jobs/handlers/index.js +1 -0
- package/dist/_internal/server/jobs/handlers/onSiteSettingsWrite.d.ts +53 -0
- package/dist/_internal/server/jobs/handlers/onSiteSettingsWrite.js +63 -0
- 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 +39 -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/_internal/shared/listing-types/hidden-surfaces.d.ts +43 -0
- package/dist/_internal/shared/listing-types/hidden-surfaces.js +57 -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.68.0",
|
|
4
|
+
"head": "d9da5b62aa583840cb50fa07288dc03177d1d991",
|
|
5
5
|
"branch": "main",
|
|
6
6
|
"dirty": false,
|
|
7
|
-
"builtAt": "2026-10-
|
|
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>;
|