@mohasinac/appkit 4.66.0 → 4.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/client/features/maintenance/index.d.ts +1 -0
  3. package/dist/_internal/client/features/maintenance/index.js +1 -0
  4. package/dist/_internal/client/features/maintenance/views/CacheInvalidationPanel.d.ts +33 -0
  5. package/dist/_internal/client/features/maintenance/views/CacheInvalidationPanel.js +96 -0
  6. package/dist/_internal/client/features/maintenance/views/MaintenanceDashboardView.js +2 -1
  7. package/dist/_internal/server/features/products/surface-counts.d.ts +26 -0
  8. package/dist/_internal/server/features/products/surface-counts.js +60 -0
  9. package/dist/_internal/server/features/seo/sitemap.js +31 -2
  10. package/dist/_internal/server/features/site-settings/adapters.d.ts +16 -0
  11. package/dist/_internal/server/features/site-settings/adapters.js +21 -0
  12. package/dist/_internal/server/functions/firestore.d.ts +18 -1
  13. package/dist/_internal/server/functions/firestore.js +25 -1
  14. package/dist/_internal/server/jobs/core/onProductWrite.js +33 -0
  15. package/dist/_internal/server/jobs/core/surfaceCountsRollup.d.ts +55 -0
  16. package/dist/_internal/server/jobs/core/surfaceCountsRollup.js +128 -0
  17. package/dist/_internal/server/jobs/handlers/countersReconcile.js +54 -1
  18. package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
  19. package/dist/_internal/server/jobs/handlers/index.js +1 -0
  20. package/dist/_internal/server/jobs/handlers/onSiteSettingsWrite.d.ts +53 -0
  21. package/dist/_internal/server/jobs/handlers/onSiteSettingsWrite.js +63 -0
  22. package/dist/_internal/shared/actions/action-registry.js +28 -0
  23. package/dist/_internal/shared/cache/revalidate-targets.js +66 -47
  24. package/dist/_internal/shared/features/site-settings/types.d.ts +39 -0
  25. package/dist/_internal/shared/listing-types/feature-flags.d.ts +45 -0
  26. package/dist/_internal/shared/listing-types/feature-flags.js +51 -0
  27. package/dist/_internal/shared/listing-types/hidden-surfaces.d.ts +43 -0
  28. package/dist/_internal/shared/listing-types/hidden-surfaces.js +57 -0
  29. package/dist/client.d.ts +4 -2
  30. package/dist/client.js +10 -1
  31. package/dist/constants/api-endpoints.d.ts +3 -0
  32. package/dist/constants/api-endpoints.js +11 -0
  33. package/dist/features/products/schemas/surface-counts.d.ts +83 -0
  34. package/dist/features/products/schemas/surface-counts.js +58 -0
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.js +3 -1
  37. package/dist/react/hooks/useListingSurfaceVisibility.d.ts +44 -0
  38. package/dist/react/hooks/useListingSurfaceVisibility.js +20 -0
  39. package/dist/server.d.ts +2 -0
  40. package/dist/server.js +12 -0
  41. package/package.json +33 -1
@@ -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
- export const countersReconcileHandler = runCountersReconcile;
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
- /** Detail route per listing type. Mirrors `ListingTypePlugin.detailRoute`. */
37
- const PRODUCT_DETAIL_BY_TYPE = {
38
- standard: (id) => `/products/${id}`,
39
- art: (id) => `/products/${id}`,
40
- stickers: (id) => `/products/${id}`,
41
- auction: (id) => `/auctions/${id}`,
42
- "pre-order": (id) => `/pre-orders/${id}`,
43
- "prize-draw": (id) => `/prize-draws/${id}`,
44
- classified: (id) => `/classified/${id}`,
45
- "digital-code": (id) => `/digital-codes/${id}`,
46
- live: (id) => `/live/${id}`,
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
- * The slug prefix system (CLAUDE.md § Slug Prefix System) makes this reliable
52
- * for seeded and generated ids — `auction-…`, `preorder-…`, `prizedraw-…` — and
53
- * it is only a fallback: the Firestore trigger reads the document and passes
54
- * `listingType` explicitly.
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
- if (id.startsWith("auction-"))
58
- return "auction";
59
- if (id.startsWith("preorder-"))
60
- return "pre-order";
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
- return out;
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
- if (type && PRODUCT_DETAIL_BY_TYPE[type]) {
83
- out.push(PRODUCT_DETAIL_BY_TYPE[type](id));
84
- // The type's own browse page, when it has one distinct from /products.
85
- const browse = PRODUCT_DETAIL_BY_TYPE[type](id).replace(/\/[^/]+$/, "");
86
- if (browse && browse !== ROUTES.PUBLIC.PRODUCTS)
87
- out.push(browse);
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 than
92
- * guessing one. At most nine entries marked stale, none of which renders
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 build of Object.values(PRODUCT_DETAIL_BY_TYPE))
96
- out.push(build(id));
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));