@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
|
@@ -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
|
+
}
|
|
@@ -1,2 +1,55 @@
|
|
|
1
|
+
import { normalizeError } from "../../../../errors/normalize.js";
|
|
1
2
|
import { runCountersReconcile } from "../core/countersReconcile.js";
|
|
2
|
-
|
|
3
|
+
import { runSurfaceCountsRollup } from "../core/surfaceCountsRollup.js";
|
|
4
|
+
/*
|
|
5
|
+
* 🛑 The surface-count rollup rides THIS job rather than taking a Scheduler
|
|
6
|
+
* slot of its own.
|
|
7
|
+
*
|
|
8
|
+
* Two reasons, and the second is the one that matters:
|
|
9
|
+
*
|
|
10
|
+
* 1. Cost — every registered Scheduler job is billed (~$0.10/mo beyond the
|
|
11
|
+
* free 3), and the Firebase budget table in CLAUDE.md says to fold new
|
|
12
|
+
* periodic work into an existing cadence when it is a natural fit.
|
|
13
|
+
* 2. Semantics — `countersReconcile` IS the nightly healer for incrementally
|
|
14
|
+
* maintained counters. `surfaceCounts` is incrementally maintained by
|
|
15
|
+
* `onProductWrite` under exactly the same "non-fatal, the nightly job will
|
|
16
|
+
* heal it" contract. Putting the healer somewhere else would mean two jobs
|
|
17
|
+
* owning one guarantee.
|
|
18
|
+
*
|
|
19
|
+
* Sequential, not `Promise.all`: both walk products, and running them together
|
|
20
|
+
* doubles the peak read rate for no wall-clock benefit on a 540s budget.
|
|
21
|
+
*
|
|
22
|
+
* 🛑 Its failure must not fail the reconcile. The counters this job exists for
|
|
23
|
+
* are the ones two Sieve SORTS order by; a surface count is a display decision
|
|
24
|
+
* that degrades to "unknown", i.e. to showing the surface. Never let the
|
|
25
|
+
* cosmetic half take down the load-bearing half.
|
|
26
|
+
*/
|
|
27
|
+
export const countersReconcileHandler = async (ctx) => {
|
|
28
|
+
const result = await runCountersReconcile(ctx);
|
|
29
|
+
try {
|
|
30
|
+
const surfaces = await runSurfaceCountsRollup(ctx);
|
|
31
|
+
/*
|
|
32
|
+
* Serialised to `"type=n"` pairs rather than logged as the maps
|
|
33
|
+
* themselves: the logger's value type is `JsonValue`, and a
|
|
34
|
+
* `Partial<Record<K, number>>` is not assignable to it (an optional
|
|
35
|
+
* property can be `undefined`, which is not JSON). Flattening is also the
|
|
36
|
+
* more readable log line.
|
|
37
|
+
*/
|
|
38
|
+
ctx.logger.info("Surface counts rolled up", {
|
|
39
|
+
listingTypes: Object.entries(surfaces.listingTypes)
|
|
40
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
41
|
+
.join(" "),
|
|
42
|
+
surfaces: Object.entries(surfaces.surfaces)
|
|
43
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
44
|
+
.join(" "),
|
|
45
|
+
// Named rather than counted — a surface left unmeasured stays visible,
|
|
46
|
+
// and an operator needs to know WHICH, not how many.
|
|
47
|
+
failed: surfaces.failed.join(",") || "none",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
void normalizeError(err);
|
|
52
|
+
ctx.logger.error("Surface-count rollup failed (non-fatal)", err);
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
};
|
|
@@ -45,6 +45,7 @@ export { weeklyPayoutEligibilityHandler } from "./weeklyPayoutEligibility";
|
|
|
45
45
|
export { onCategoryWriteHandler } from "./onCategoryWrite";
|
|
46
46
|
export { onProductWriteHandler } from "./onProductWrite";
|
|
47
47
|
export { onStoreWriteHandler } from "./onStoreWrite";
|
|
48
|
+
export { onSiteSettingsWriteHandler } from "./onSiteSettingsWrite";
|
|
48
49
|
export { adminAnalyticsHandler, type AdminAnalyticsResult } from "./adminAnalytics";
|
|
49
50
|
export { countersReconcileNowHandler, type CountersReconcileNowResult, } from "./countersReconcileNow";
|
|
50
51
|
export { storeAnalyticsHandler, type StoreAnalyticsInput, type StoreAnalyticsResult, } from "./storeAnalytics";
|
|
@@ -45,6 +45,7 @@ export { weeklyPayoutEligibilityHandler } from "./weeklyPayoutEligibility.js";
|
|
|
45
45
|
export { onCategoryWriteHandler } from "./onCategoryWrite.js";
|
|
46
46
|
export { onProductWriteHandler } from "./onProductWrite.js";
|
|
47
47
|
export { onStoreWriteHandler } from "./onStoreWrite.js";
|
|
48
|
+
export { onSiteSettingsWriteHandler } from "./onSiteSettingsWrite.js";
|
|
48
49
|
export { adminAnalyticsHandler } from "./adminAnalytics.js";
|
|
49
50
|
export { countersReconcileNowHandler, } from "./countersReconcileNow.js";
|
|
50
51
|
export { storeAnalyticsHandler, } from "./storeAnalytics.js";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { FirestoreTriggerHandler } from "../runtime/types";
|
|
2
|
+
import type { JsonValue } from "@mohasinac/appkit";
|
|
3
|
+
/**
|
|
4
|
+
* A site-settings write invalidates the whole page cache.
|
|
5
|
+
*
|
|
6
|
+
* ## Why this trigger had to exist
|
|
7
|
+
*
|
|
8
|
+
* `siteSettings/global` decides the navbar, the footer, the theme tokens, the
|
|
9
|
+
* guest price gate, every listing-type kill switch and `hiddenSurfacePaths` —
|
|
10
|
+
* i.e. something on every page of the site. Nothing observed writes to it.
|
|
11
|
+
*
|
|
12
|
+
* Measured against production 2026-10-27, which is what prompted this: with
|
|
13
|
+
* `listings.listingTypes.auction` set to `false` in Firestore, `/auctions` kept
|
|
14
|
+
* answering **200** from the CDN with `Age` climbing past its own 60-second
|
|
15
|
+
* `revalidate` window, because no writer ever marked the page stale. The flag
|
|
16
|
+
* was correct and invisible. `revalidateTargetsFor("siteSettings")` returned
|
|
17
|
+
* `["/"]` — the homepage alone — so even the one caller that existed could not
|
|
18
|
+
* have fixed it.
|
|
19
|
+
*
|
|
20
|
+
* ## 🛑 Performs NO Firestore write, by construction
|
|
21
|
+
*
|
|
22
|
+
* Root Cause #92 is this project's record of a trigger writing back to the
|
|
23
|
+
* collection it watched, producing 1.02M invocations/day and taking production
|
|
24
|
+
* down. `notifyRevalidate` performs exactly one `fetch` and touches no
|
|
25
|
+
* document, so this cannot re-trigger itself. If it ever needs to persist
|
|
26
|
+
* anything, it needs a different trigger path — not a write.
|
|
27
|
+
*
|
|
28
|
+
* ## 🛑 No field-level diff, deliberately — and that is the opposite of
|
|
29
|
+
* `onProductWrite`
|
|
30
|
+
*
|
|
31
|
+
* `onProductWrite` guards on `productCacheFieldsChanged` because products are
|
|
32
|
+
* written at MACHINE rate: every checkout stock decrement and every bid fires
|
|
33
|
+
* it, so an unguarded full invalidation there is the ISR-write amplification
|
|
34
|
+
* that got this project suspended with HTTP 402 (Root Cause #104).
|
|
35
|
+
*
|
|
36
|
+
* `siteSettings` is the opposite case. It is written by `PUT /api/admin/site`
|
|
37
|
+
* and two operator scripts and by nothing else — every scheduled job that names
|
|
38
|
+
* it only READS it (measured 2026-10-27). So this fires when a human saves
|
|
39
|
+
* settings, which is rare enough that a diff would add a maintained field list
|
|
40
|
+
* to protect against nothing. A settings save that changed only `updatedAt` is
|
|
41
|
+
* not worth a list that goes stale the next time a group is added.
|
|
42
|
+
*
|
|
43
|
+
* 🛑 If a job is ever given a reason to WRITE `siteSettings`, this guard has to
|
|
44
|
+
* come back before that ships — the amplification shape returns the moment the
|
|
45
|
+
* write rate stops being human.
|
|
46
|
+
*
|
|
47
|
+
* ## Best-effort
|
|
48
|
+
*
|
|
49
|
+
* `notifyRevalidate` swallows and logs every failure. A missed invalidation
|
|
50
|
+
* leaves a stale page until its TTL rolls; failing here would achieve nothing
|
|
51
|
+
* because the settings document is already saved.
|
|
52
|
+
*/
|
|
53
|
+
export declare const onSiteSettingsWriteHandler: FirestoreTriggerHandler<Record<string, JsonValue>, Record<string, JsonValue>>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { notifyRevalidate } from "../core/revalidateNotify.js";
|
|
2
|
+
/**
|
|
3
|
+
* A site-settings write invalidates the whole page cache.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this trigger had to exist
|
|
6
|
+
*
|
|
7
|
+
* `siteSettings/global` decides the navbar, the footer, the theme tokens, the
|
|
8
|
+
* guest price gate, every listing-type kill switch and `hiddenSurfacePaths` —
|
|
9
|
+
* i.e. something on every page of the site. Nothing observed writes to it.
|
|
10
|
+
*
|
|
11
|
+
* Measured against production 2026-10-27, which is what prompted this: with
|
|
12
|
+
* `listings.listingTypes.auction` set to `false` in Firestore, `/auctions` kept
|
|
13
|
+
* answering **200** from the CDN with `Age` climbing past its own 60-second
|
|
14
|
+
* `revalidate` window, because no writer ever marked the page stale. The flag
|
|
15
|
+
* was correct and invisible. `revalidateTargetsFor("siteSettings")` returned
|
|
16
|
+
* `["/"]` — the homepage alone — so even the one caller that existed could not
|
|
17
|
+
* have fixed it.
|
|
18
|
+
*
|
|
19
|
+
* ## 🛑 Performs NO Firestore write, by construction
|
|
20
|
+
*
|
|
21
|
+
* Root Cause #92 is this project's record of a trigger writing back to the
|
|
22
|
+
* collection it watched, producing 1.02M invocations/day and taking production
|
|
23
|
+
* down. `notifyRevalidate` performs exactly one `fetch` and touches no
|
|
24
|
+
* document, so this cannot re-trigger itself. If it ever needs to persist
|
|
25
|
+
* anything, it needs a different trigger path — not a write.
|
|
26
|
+
*
|
|
27
|
+
* ## 🛑 No field-level diff, deliberately — and that is the opposite of
|
|
28
|
+
* `onProductWrite`
|
|
29
|
+
*
|
|
30
|
+
* `onProductWrite` guards on `productCacheFieldsChanged` because products are
|
|
31
|
+
* written at MACHINE rate: every checkout stock decrement and every bid fires
|
|
32
|
+
* it, so an unguarded full invalidation there is the ISR-write amplification
|
|
33
|
+
* that got this project suspended with HTTP 402 (Root Cause #104).
|
|
34
|
+
*
|
|
35
|
+
* `siteSettings` is the opposite case. It is written by `PUT /api/admin/site`
|
|
36
|
+
* and two operator scripts and by nothing else — every scheduled job that names
|
|
37
|
+
* it only READS it (measured 2026-10-27). So this fires when a human saves
|
|
38
|
+
* settings, which is rare enough that a diff would add a maintained field list
|
|
39
|
+
* to protect against nothing. A settings save that changed only `updatedAt` is
|
|
40
|
+
* not worth a list that goes stale the next time a group is added.
|
|
41
|
+
*
|
|
42
|
+
* 🛑 If a job is ever given a reason to WRITE `siteSettings`, this guard has to
|
|
43
|
+
* come back before that ships — the amplification shape returns the moment the
|
|
44
|
+
* write rate stops being human.
|
|
45
|
+
*
|
|
46
|
+
* ## Best-effort
|
|
47
|
+
*
|
|
48
|
+
* `notifyRevalidate` swallows and logs every failure. A missed invalidation
|
|
49
|
+
* leaves a stale page until its TTL rolls; failing here would achieve nothing
|
|
50
|
+
* because the settings document is already saved.
|
|
51
|
+
*/
|
|
52
|
+
export const onSiteSettingsWriteHandler = async (event, ctx) => {
|
|
53
|
+
await notifyRevalidate({
|
|
54
|
+
collection: "siteSettings",
|
|
55
|
+
/*
|
|
56
|
+
* The singleton id, from the path params rather than hardcoded "global".
|
|
57
|
+
* The trigger pattern is `siteSettings/{settingsId}` and a second
|
|
58
|
+
* document in that collection would otherwise invalidate under the wrong
|
|
59
|
+
* id — and the endpoint logs this value.
|
|
60
|
+
*/
|
|
61
|
+
id: event.params.settingsId,
|
|
62
|
+
}, ctx);
|
|
63
|
+
};
|
|
@@ -1895,6 +1895,34 @@ export const ACTIONS = {
|
|
|
1895
1895
|
kind: "primary",
|
|
1896
1896
|
permissions: ["admin"],
|
|
1897
1897
|
},
|
|
1898
|
+
"invalidate-cache": {
|
|
1899
|
+
// `retry` — the icon registry has no `refresh`/`reload`/`sync` key, and
|
|
1900
|
+
// inventing one means a new glyph in a catalogued set. `retry` is the
|
|
1901
|
+
// registered "do this again" mark and reads correctly for a re-render.
|
|
1902
|
+
iconKey: "retry",
|
|
1903
|
+
id: "admin.invalidate-cache",
|
|
1904
|
+
label: "Invalidate all cached pages",
|
|
1905
|
+
ariaLabel: "Mark every cached public page stale",
|
|
1906
|
+
description: "Mark every cached public page stale so the next visitor gets a freshly rendered one. Use after a bulk data change the Firestore triggers could not see — a seed load, a direct Firestore edit, or a catalogue purge.",
|
|
1907
|
+
kind: "secondary",
|
|
1908
|
+
permissions: ["admin"],
|
|
1909
|
+
/*
|
|
1910
|
+
* 🛑 A confirmation on a NON-destructive action, because the cost is
|
|
1911
|
+
* real and invisible at the click.
|
|
1912
|
+
*
|
|
1913
|
+
* This marks entries stale; it renders nothing and destroys nothing. But
|
|
1914
|
+
* every marked entry regenerates on its next request, and this project
|
|
1915
|
+
* was SUSPENDED (HTTP 402) for exceeding 200K ISR writes with 3.1M —
|
|
1916
|
+
* writes outnumbering reads 3:1. Rule #6's whole cache posture exists to
|
|
1917
|
+
* keep that from recurring, so a button that re-renders the public site
|
|
1918
|
+
* on demand must not be reachable by a stray click.
|
|
1919
|
+
*/
|
|
1920
|
+
confirmation: {
|
|
1921
|
+
title: "Invalidate every cached page?",
|
|
1922
|
+
body: "Every public page is marked stale and re-renders on its next visit. Nothing is deleted and no data changes, but the re-render costs function invocations against the plan quota — so use it after a bulk change, not as a routine refresh.",
|
|
1923
|
+
confirmLabel: "Invalidate all",
|
|
1924
|
+
},
|
|
1925
|
+
},
|
|
1898
1926
|
"update-pricing": {
|
|
1899
1927
|
iconKey: "save",
|
|
1900
1928
|
id: "admin.update-pricing",
|
|
@@ -33,67 +33,86 @@
|
|
|
33
33
|
*/
|
|
34
34
|
/** What the caller knows about the written document, when it knows anything. */
|
|
35
35
|
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
36
|
+
import { LISTING_TYPE_REGISTRY } from "../listing-types/_registry.js";
|
|
37
|
+
/*
|
|
38
|
+
* 🛑 DERIVED from `LISTING_TYPE_REGISTRY`, never hand-written.
|
|
39
|
+
*
|
|
40
|
+
* This file carried its own nine-entry `PRODUCT_DETAIL_BY_TYPE` literal and its
|
|
41
|
+
* own `listingTypeFromId` prefix ladder, and both had already drifted by the
|
|
42
|
+
* time the union reached ten — exactly the tenth-enumeration failure Root Cause
|
|
43
|
+
* #61 is about. Measured 2026-10-26, while invalidating after a catalogue purge
|
|
44
|
+
* that deleted 7 `spares` and 2 `stickers`:
|
|
45
|
+
*
|
|
46
|
+
* - **`spares` was absent from both.** A `spare-…` id fell through to the
|
|
47
|
+
* "unknown type" branch, which marks every OTHER type's detail route and
|
|
48
|
+
* therefore never marks `/products/spare-…` — the one page that existed.
|
|
49
|
+
* - **`/art`, `/stickers` and `/spares` were never marked at all.** The browse
|
|
50
|
+
* path was derived by stripping the last segment off the detail route, and
|
|
51
|
+
* all three types detail at `/products/{slug}`, so the strip yielded
|
|
52
|
+
* `/products` and the `!== ROUTES.PUBLIC.PRODUCTS` test discarded it. Their
|
|
53
|
+
* dedicated browse pages are in `MAIN_NAV_ITEMS` and went stale on a clock.
|
|
54
|
+
*
|
|
55
|
+
* `browseRoute` and `slugPrefix` are required plugin fields, so a type cannot
|
|
56
|
+
* join the union without answering both — which is the property the literal
|
|
57
|
+
* could never have.
|
|
58
|
+
*/
|
|
59
|
+
const PLUGINS = Object.values(LISTING_TYPE_REGISTRY);
|
|
48
60
|
/**
|
|
49
61
|
* Infer the listing type from the id prefix when no hint was supplied.
|
|
50
62
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* `
|
|
63
|
+
* Calls the shipped `slugPrefix` on each plugin rather than re-deriving the
|
|
64
|
+
* prefix map — the same decision `audit-seed-listing-type`'s R3 makes, and the
|
|
65
|
+
* reason that audit caught five `stickers-` ids against a registered
|
|
66
|
+
* `sticker-` prefix. Returns `undefined` rather than `detectListingTypeFromSlug`'s
|
|
67
|
+
* `"standard"` default: here, "no prefix matched" must widen to every detail
|
|
68
|
+
* route, not silently narrow to one.
|
|
55
69
|
*/
|
|
56
70
|
function listingTypeFromId(id) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (id.startsWith("prizedraw-"))
|
|
62
|
-
return "prize-draw";
|
|
63
|
-
if (id.startsWith("classified-"))
|
|
64
|
-
return "classified";
|
|
65
|
-
if (id.startsWith("digitalcode-"))
|
|
66
|
-
return "digital-code";
|
|
67
|
-
if (id.startsWith("live-"))
|
|
68
|
-
return "live";
|
|
69
|
-
if (id.startsWith("art-"))
|
|
70
|
-
return "art";
|
|
71
|
-
if (id.startsWith("sticker-"))
|
|
72
|
-
return "stickers";
|
|
73
|
-
if (id.startsWith("product-"))
|
|
74
|
-
return "standard";
|
|
71
|
+
for (const p of PLUGINS) {
|
|
72
|
+
if (p.slugPrefix && id.startsWith(p.slugPrefix))
|
|
73
|
+
return p.listingType;
|
|
74
|
+
}
|
|
75
75
|
return undefined;
|
|
76
76
|
}
|
|
77
77
|
function productTargets(id, hints) {
|
|
78
78
|
const out = [ROUTES.HOME, ROUTES.PUBLIC.PRODUCTS];
|
|
79
|
-
if (!id)
|
|
80
|
-
|
|
79
|
+
if (!id) {
|
|
80
|
+
/*
|
|
81
|
+
* Bulk / unknown write — a seed load, a purge, or the admin "invalidate
|
|
82
|
+
* everything" button. `/products` alone is not "every listing page": five
|
|
83
|
+
* types are deliberately OUT of the general catalogue
|
|
84
|
+
* (`inGeneralCatalogue: false`) and are reachable only at their own browse
|
|
85
|
+
* route, so a bulk invalidation that marks `/products` leaves `/auctions`,
|
|
86
|
+
* `/pre-orders`, `/prize-draws`, `/art`, `/stickers` and `/spares` serving
|
|
87
|
+
* rows that no longer exist.
|
|
88
|
+
*/
|
|
89
|
+
for (const p of PLUGINS)
|
|
90
|
+
if (p.browseRoute)
|
|
91
|
+
out.push(String(p.browseRoute));
|
|
92
|
+
return Array.from(new Set(out));
|
|
93
|
+
}
|
|
81
94
|
const type = hints?.listingType ?? listingTypeFromId(id);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
95
|
+
const plugin = type ? LISTING_TYPE_REGISTRY[type] : undefined;
|
|
96
|
+
if (plugin) {
|
|
97
|
+
out.push(String(plugin.detailRoute(id)));
|
|
98
|
+
// The type's own browse page. Read off the plugin, NOT derived by stripping
|
|
99
|
+
// the detail route's last segment: art/stickers/spares all detail at
|
|
100
|
+
// `/products/{slug}`, so stripping yields `/products` and loses `/art`,
|
|
101
|
+
// `/stickers` and `/spares` entirely.
|
|
102
|
+
if (plugin.browseRoute)
|
|
103
|
+
out.push(String(plugin.browseRoute));
|
|
88
104
|
}
|
|
89
105
|
else {
|
|
90
106
|
/*
|
|
91
|
-
* Unknown type: revalidate EVERY detail route for this id rather
|
|
92
|
-
* guessing one.
|
|
93
|
-
* until requested — strictly cheaper than leaving the real one live.
|
|
107
|
+
* Unknown type: revalidate EVERY detail and browse route for this id rather
|
|
108
|
+
* than guessing one. A couple of dozen entries marked stale, none of which
|
|
109
|
+
* renders until requested — strictly cheaper than leaving the real one live.
|
|
94
110
|
*/
|
|
95
|
-
for (const
|
|
96
|
-
out.push(
|
|
111
|
+
for (const p of PLUGINS) {
|
|
112
|
+
out.push(String(p.detailRoute(id)));
|
|
113
|
+
if (p.browseRoute)
|
|
114
|
+
out.push(String(p.browseRoute));
|
|
115
|
+
}
|
|
97
116
|
}
|
|
98
117
|
// A product's price/stock/status is rendered on every taxonomy page that
|
|
99
118
|
// lists it. `categorySlugs` is the full ancestor chain, so this covers the
|
|
@@ -117,4 +117,43 @@ export interface PublicSiteSettings {
|
|
|
117
117
|
disabledRoutes: string[];
|
|
118
118
|
/** Derived, not a stored field — the resolved marker → wordmark → text chain. */
|
|
119
119
|
effectiveWatermark?: MediaVideoWatermark;
|
|
120
|
+
/**
|
|
121
|
+
* Which public browse surfaces currently have something in them. Derived
|
|
122
|
+
* from the `surfaceCounts/global` document, not a `siteSettings` field —
|
|
123
|
+
* carried here because this payload is already the channel the navbar,
|
|
124
|
+
* footer and sidebar read their visibility rules from, so adding a second
|
|
125
|
+
* client fetch for one more rule would be a request per page load.
|
|
126
|
+
*
|
|
127
|
+
* 🛑 OMITTED ENTIRELY when the counts document has not been written yet,
|
|
128
|
+
* and every reader must treat that as "show everything". A present-but-empty
|
|
129
|
+
* object would be indistinguishable from "measured, and all zero" — which is
|
|
130
|
+
* the shape that hides the whole catalogue. `isListingSurfaceVisible`
|
|
131
|
+
* encodes the rule; do not re-derive it at a call site.
|
|
132
|
+
*/
|
|
133
|
+
surfaceCounts?: {
|
|
134
|
+
listingTypes?: Record<string, number>;
|
|
135
|
+
surfaces?: Record<string, number>;
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* Browse routes that must answer 404 — derived from `listings.listingTypes`
|
|
139
|
+
* and `surfaceCounts` by `hiddenSurfaceRoutes`, not a stored field.
|
|
140
|
+
*
|
|
141
|
+
* 🛑 THIS EXISTS BECAUSE A PAGE-LEVEL `notFound()` CANNOT SET THE STATUS.
|
|
142
|
+
* Every one of these pages already calls `guardListingSurface`, and measured
|
|
143
|
+
* against production on 2026-10-27 all seven rendered the "404 — Page not
|
|
144
|
+
* found" body with HTTP **200**: the layout shell has already streamed by the
|
|
145
|
+
* time the guard throws, so the status is no longer mutable. `/reviews` was
|
|
146
|
+
* the one real 404 on the site, and only because it was in `disabledRoutes`
|
|
147
|
+
* and therefore caught at the edge.
|
|
148
|
+
*
|
|
149
|
+
* So the edge is the only place that can answer correctly, and `src/proxy.ts`
|
|
150
|
+
* consumes this exactly as it consumes `disabledRoutes` — same rewrite, same
|
|
151
|
+
* cached fetch, no extra request. The page guards stay as defence in depth
|
|
152
|
+
* (they are what hides the content if this payload never arrives).
|
|
153
|
+
*
|
|
154
|
+
* 🛑 Always emitted, defaulted to `[]`, for the reason spelled out on
|
|
155
|
+
* `disabledRoutes`: the proxy fails OPEN, so "nothing hidden" and "the field
|
|
156
|
+
* never arrived" are otherwise indistinguishable on the wire.
|
|
157
|
+
*/
|
|
158
|
+
hiddenSurfacePaths: string[];
|
|
120
159
|
}
|
|
@@ -97,5 +97,50 @@ export declare const ALL_LISTING_TYPES: ListingType[];
|
|
|
97
97
|
export declare function isListingType(value: unknown): value is ListingType;
|
|
98
98
|
/** Pull the full list of enabled listing types in canonical iteration order. */
|
|
99
99
|
export declare function enabledListingTypes(settings: FeatureFlagSnapshot | null | undefined): ListingType[];
|
|
100
|
+
/**
|
|
101
|
+
* The measured counts, as the public site-settings payload carries them.
|
|
102
|
+
*
|
|
103
|
+
* Structurally typed rather than importing `SurfaceCountsDocument` so this
|
|
104
|
+
* module stays a pure constants file with no feature import — the same reason
|
|
105
|
+
* it imports only `ROUTES`.
|
|
106
|
+
*/
|
|
107
|
+
export interface SurfaceCountSnapshot {
|
|
108
|
+
listingTypes?: Partial<Record<ListingType, number>>;
|
|
109
|
+
surfaces?: Partial<Record<string, number>>;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Should this listing type's public surface be shown at all?
|
|
113
|
+
*
|
|
114
|
+
* 🛑 THE PRECEDENCE, and why it is not just `count > 0`:
|
|
115
|
+
*
|
|
116
|
+
* | admin flag | measured count | result | because |
|
|
117
|
+
* |---|---|---|---|
|
|
118
|
+
* | `false` | anything | **hidden** | "we do not sell live animals" is a standing catalogue decision, not a statement about today's stock. An admin who closed a type must not see it reopen because someone listed one. |
|
|
119
|
+
* | `true` | `0` | **hidden** | auto-hide: the thing this function exists for. |
|
|
120
|
+
* | `true` | `> 0` | shown | |
|
|
121
|
+
* | `true` | **absent** | **shown** | 🛑 unknown is NOT zero. A fresh install, a failed job, or a type added since the last recompute must render exactly as it does today, never as hidden. Root Cause #72. |
|
|
122
|
+
*
|
|
123
|
+
* The flag therefore stays the authority and the count only ever SUBTRACTS
|
|
124
|
+
* from what the flag already permits. Two mechanisms, one direction — which is
|
|
125
|
+
* what stops them becoming two switches that disagree.
|
|
126
|
+
*/
|
|
127
|
+
export declare function isListingSurfaceVisible(type: ListingType, settings: FeatureFlagSnapshot | null | undefined, counts: SurfaceCountSnapshot | null | undefined): boolean;
|
|
128
|
+
/**
|
|
129
|
+
* The same question for a surface with no listing-type flag behind it
|
|
130
|
+
* (`/reviews` today).
|
|
131
|
+
*
|
|
132
|
+
* No admin-flag half: these have no switch to consult, so emptiness is the
|
|
133
|
+
* only input. Absent still means shown.
|
|
134
|
+
*/
|
|
135
|
+
export declare function isCountedSurfaceVisible(surface: string, counts: SurfaceCountSnapshot | null | undefined): boolean;
|
|
136
|
+
/**
|
|
137
|
+
* Visible listing types — the flag list, minus the ones measured empty.
|
|
138
|
+
*
|
|
139
|
+
* This is what nav, footer and the sitemap filter on. `enabledListingTypes`
|
|
140
|
+
* above is deliberately left alone: it answers "what does this marketplace
|
|
141
|
+
* sell", which `/api/products`' post-filter and the seller forms still need
|
|
142
|
+
* even for a type with zero rows today.
|
|
143
|
+
*/
|
|
144
|
+
export declare function visibleListingTypes(settings: FeatureFlagSnapshot | null | undefined, counts: SurfaceCountSnapshot | null | undefined): ListingType[];
|
|
100
145
|
export declare function enabledCategoryTypes(settings: FeatureFlagSnapshot | null | undefined): CategoryType[];
|
|
101
146
|
export {};
|
|
@@ -140,6 +140,57 @@ export function isListingType(value) {
|
|
|
140
140
|
export function enabledListingTypes(settings) {
|
|
141
141
|
return ALL_LISTING_TYPES.filter((t) => isListingTypeEnabled(t, settings));
|
|
142
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Should this listing type's public surface be shown at all?
|
|
145
|
+
*
|
|
146
|
+
* 🛑 THE PRECEDENCE, and why it is not just `count > 0`:
|
|
147
|
+
*
|
|
148
|
+
* | admin flag | measured count | result | because |
|
|
149
|
+
* |---|---|---|---|
|
|
150
|
+
* | `false` | anything | **hidden** | "we do not sell live animals" is a standing catalogue decision, not a statement about today's stock. An admin who closed a type must not see it reopen because someone listed one. |
|
|
151
|
+
* | `true` | `0` | **hidden** | auto-hide: the thing this function exists for. |
|
|
152
|
+
* | `true` | `> 0` | shown | |
|
|
153
|
+
* | `true` | **absent** | **shown** | 🛑 unknown is NOT zero. A fresh install, a failed job, or a type added since the last recompute must render exactly as it does today, never as hidden. Root Cause #72. |
|
|
154
|
+
*
|
|
155
|
+
* The flag therefore stays the authority and the count only ever SUBTRACTS
|
|
156
|
+
* from what the flag already permits. Two mechanisms, one direction — which is
|
|
157
|
+
* what stops them becoming two switches that disagree.
|
|
158
|
+
*/
|
|
159
|
+
export function isListingSurfaceVisible(type, settings, counts) {
|
|
160
|
+
if (!isListingTypeEnabled(type, settings))
|
|
161
|
+
return false;
|
|
162
|
+
const n = counts?.listingTypes?.[type];
|
|
163
|
+
// `undefined` -> true. Written as an explicit test rather than `(n ?? 1) > 0`
|
|
164
|
+
// so the unknown case is readable, and so nobody "simplifies" it into a
|
|
165
|
+
// falsy check that folds 0 and undefined together — which is the bug.
|
|
166
|
+
if (n === undefined)
|
|
167
|
+
return true;
|
|
168
|
+
return n > 0;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The same question for a surface with no listing-type flag behind it
|
|
172
|
+
* (`/reviews` today).
|
|
173
|
+
*
|
|
174
|
+
* No admin-flag half: these have no switch to consult, so emptiness is the
|
|
175
|
+
* only input. Absent still means shown.
|
|
176
|
+
*/
|
|
177
|
+
export function isCountedSurfaceVisible(surface, counts) {
|
|
178
|
+
const n = counts?.surfaces?.[surface];
|
|
179
|
+
if (n === undefined)
|
|
180
|
+
return true;
|
|
181
|
+
return n > 0;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Visible listing types — the flag list, minus the ones measured empty.
|
|
185
|
+
*
|
|
186
|
+
* This is what nav, footer and the sitemap filter on. `enabledListingTypes`
|
|
187
|
+
* above is deliberately left alone: it answers "what does this marketplace
|
|
188
|
+
* sell", which `/api/products`' post-filter and the seller forms still need
|
|
189
|
+
* even for a type with zero rows today.
|
|
190
|
+
*/
|
|
191
|
+
export function visibleListingTypes(settings, counts) {
|
|
192
|
+
return ALL_LISTING_TYPES.filter((t) => isListingSurfaceVisible(t, settings, counts));
|
|
193
|
+
}
|
|
143
194
|
export function enabledCategoryTypes(settings) {
|
|
144
195
|
const all = ["category", "sublisting", "brand", "bundle"];
|
|
145
196
|
return all.filter((t) => isCategoryTypeEnabled(t, settings));
|