@mohasinac/appkit 4.67.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/server/features/seo/sitemap.js +8 -13
- package/dist/_internal/server/features/site-settings/adapters.js +10 -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/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/features/site-settings/types.d.ts +22 -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/package.json +1 -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
|
}
|
|
@@ -16,8 +16,8 @@ import { PRODUCT_FIELDS } from "../../../../constants/field-names.js";
|
|
|
16
16
|
import { PRODUCT_DETAIL_LISTING_TYPES } from "./product-detail-types.js";
|
|
17
17
|
import { safeRead } from "../../../../errors/safe-read.js";
|
|
18
18
|
import { siteSettingsRepository } from "../../../../features/admin/repository/site-settings.repository.js";
|
|
19
|
-
import { ALL_BROWSE_PAGES,
|
|
20
|
-
import {
|
|
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
21
|
import { getSurfaceCounts } from "../products/surface-counts.js";
|
|
22
22
|
// Product field strings — matches consumer field-names.ts
|
|
23
23
|
const PRODUCT_STATUS = "status";
|
|
@@ -667,19 +667,14 @@ export async function buildSitemap({ baseUrl }) {
|
|
|
667
667
|
* the sitemap rather than being withdrawn by a job that has not run.
|
|
668
668
|
*/
|
|
669
669
|
const counts = await getSurfaceCounts();
|
|
670
|
-
const hiddenSurfacePaths = new Set(ALL_LISTING_TYPES.filter((t) => !isListingSurfaceVisible(t, settings, counts))
|
|
671
|
-
.map((t) => String(pluginFor(t).browseRoute ?? ""))
|
|
672
|
-
.filter(Boolean));
|
|
673
670
|
/*
|
|
674
|
-
*
|
|
675
|
-
*
|
|
676
|
-
*
|
|
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.
|
|
677
676
|
*/
|
|
678
|
-
|
|
679
|
-
if (isListingSurfaceVisible(t, settings, counts)) {
|
|
680
|
-
hiddenSurfacePaths.delete(String(pluginFor(t).browseRoute ?? ""));
|
|
681
|
-
}
|
|
682
|
-
}
|
|
677
|
+
const hiddenSurfacePaths = new Set(hiddenSurfaceRoutes(settings, counts));
|
|
683
678
|
const keep = (entry) => {
|
|
684
679
|
const path = entry.url.startsWith("http")
|
|
685
680
|
? new URL(entry.url).pathname
|
|
@@ -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,
|
|
@@ -197,6 +198,15 @@ export function toPublicSiteSettings(doc, opts) {
|
|
|
197
198
|
* would take the entire catalogue off the navbar.
|
|
198
199
|
*/
|
|
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),
|
|
200
210
|
// D9 — undefined reads as OFF, never a default-true `??`.
|
|
201
211
|
chat: { enabled: doc.chat?.enabled === true },
|
|
202
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,
|
|
@@ -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
|
+
};
|
|
@@ -134,4 +134,26 @@ export interface PublicSiteSettings {
|
|
|
134
134
|
listingTypes?: Record<string, number>;
|
|
135
135
|
surfaces?: Record<string, number>;
|
|
136
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[];
|
|
137
159
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which public BROWSE ROUTES must not be served, given the admin flags and the
|
|
3
|
+
* measured counts.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this is one function and not two copies
|
|
6
|
+
*
|
|
7
|
+
* Two places need this answer and they must never disagree:
|
|
8
|
+
*
|
|
9
|
+
* · `buildSitemap` — decides which browse URLs are advertised.
|
|
10
|
+
* · `toPublicSiteSettings` → `src/proxy.ts` — decides which browse URLs 404.
|
|
11
|
+
*
|
|
12
|
+
* A disagreement is not cosmetic: the sitemap advertising a URL the edge 404s
|
|
13
|
+
* is a crawl error on a page we told Google to fetch, and the reverse serves a
|
|
14
|
+
* surface we have decided is closed. They were two hand-written copies of the
|
|
15
|
+
* same two loops when this was extracted.
|
|
16
|
+
*
|
|
17
|
+
* ## 🛑 EXACT PATHS, never prefixes
|
|
18
|
+
*
|
|
19
|
+
* `art`, `stickers`, `spares` and `standard` all detail at `/products/{slug}`,
|
|
20
|
+
* so a prefix rule would delist — or 404 — the entire standard catalogue the
|
|
21
|
+
* day `spares` emptied. An empty type contributes no detail URLs anyway, since
|
|
22
|
+
* its own fetcher returns none, so the exact-path rule loses nothing real.
|
|
23
|
+
*
|
|
24
|
+
* ## 🛑 A SHARED route survives while ANY of its types is visible
|
|
25
|
+
*
|
|
26
|
+
* `/art` spans `art` and `stickers`; `/products` spans `standard` plus several
|
|
27
|
+
* others. Without the second pass, hiding `stickers` alone would close a page
|
|
28
|
+
* `art` still fills — and hiding `spares` alone would close `/products`.
|
|
29
|
+
*
|
|
30
|
+
* @tag domain:products
|
|
31
|
+
* @tag layer:shared
|
|
32
|
+
* @tag access:isomorphic
|
|
33
|
+
* @tag sideEffects:none
|
|
34
|
+
*/
|
|
35
|
+
import { type SurfaceCountSnapshot } from "./feature-flags";
|
|
36
|
+
/** The snapshot shape `isListingSurfaceVisible` reads its admin flags from. */
|
|
37
|
+
interface SettingsSnapshot {
|
|
38
|
+
listings?: {
|
|
39
|
+
listingTypes?: Partial<Record<string, boolean>>;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export declare function hiddenSurfaceRoutes(settings: SettingsSnapshot | null | undefined, counts: SurfaceCountSnapshot | null | undefined): string[];
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which public BROWSE ROUTES must not be served, given the admin flags and the
|
|
3
|
+
* measured counts.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this is one function and not two copies
|
|
6
|
+
*
|
|
7
|
+
* Two places need this answer and they must never disagree:
|
|
8
|
+
*
|
|
9
|
+
* · `buildSitemap` — decides which browse URLs are advertised.
|
|
10
|
+
* · `toPublicSiteSettings` → `src/proxy.ts` — decides which browse URLs 404.
|
|
11
|
+
*
|
|
12
|
+
* A disagreement is not cosmetic: the sitemap advertising a URL the edge 404s
|
|
13
|
+
* is a crawl error on a page we told Google to fetch, and the reverse serves a
|
|
14
|
+
* surface we have decided is closed. They were two hand-written copies of the
|
|
15
|
+
* same two loops when this was extracted.
|
|
16
|
+
*
|
|
17
|
+
* ## 🛑 EXACT PATHS, never prefixes
|
|
18
|
+
*
|
|
19
|
+
* `art`, `stickers`, `spares` and `standard` all detail at `/products/{slug}`,
|
|
20
|
+
* so a prefix rule would delist — or 404 — the entire standard catalogue the
|
|
21
|
+
* day `spares` emptied. An empty type contributes no detail URLs anyway, since
|
|
22
|
+
* its own fetcher returns none, so the exact-path rule loses nothing real.
|
|
23
|
+
*
|
|
24
|
+
* ## 🛑 A SHARED route survives while ANY of its types is visible
|
|
25
|
+
*
|
|
26
|
+
* `/art` spans `art` and `stickers`; `/products` spans `standard` plus several
|
|
27
|
+
* others. Without the second pass, hiding `stickers` alone would close a page
|
|
28
|
+
* `art` still fills — and hiding `spares` alone would close `/products`.
|
|
29
|
+
*
|
|
30
|
+
* @tag domain:products
|
|
31
|
+
* @tag layer:shared
|
|
32
|
+
* @tag access:isomorphic
|
|
33
|
+
* @tag sideEffects:none
|
|
34
|
+
*/
|
|
35
|
+
import { ALL_LISTING_TYPES, isListingSurfaceVisible, } from "./feature-flags.js";
|
|
36
|
+
import { pluginFor } from "./_registry.js";
|
|
37
|
+
export function hiddenSurfaceRoutes(settings, counts) {
|
|
38
|
+
const hidden = new Set();
|
|
39
|
+
const routeOf = (type) => String(pluginFor(type).browseRoute ?? "");
|
|
40
|
+
for (const type of ALL_LISTING_TYPES) {
|
|
41
|
+
if (isListingSurfaceVisible(type, settings, counts))
|
|
42
|
+
continue;
|
|
43
|
+
const route = routeOf(type);
|
|
44
|
+
if (route)
|
|
45
|
+
hidden.add(route);
|
|
46
|
+
}
|
|
47
|
+
/*
|
|
48
|
+
* Second pass, deliberately not folded into the first: a route is only
|
|
49
|
+
* hidden once EVERY type that browses there is hidden, and the first pass
|
|
50
|
+
* cannot know that until it has seen them all.
|
|
51
|
+
*/
|
|
52
|
+
for (const type of ALL_LISTING_TYPES) {
|
|
53
|
+
if (isListingSurfaceVisible(type, settings, counts))
|
|
54
|
+
hidden.delete(routeOf(type));
|
|
55
|
+
}
|
|
56
|
+
return [...hidden];
|
|
57
|
+
}
|