@mohasinac/appkit 4.45.0 → 4.46.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 +4 -4
- package/dist/_internal/server/features/categories/live-counts.d.ts +41 -0
- package/dist/_internal/server/features/categories/live-counts.js +72 -0
- package/dist/_internal/server/features/checkout/expand-order-items.js +17 -0
- package/dist/_internal/server/features/orders/adapters.js +29 -0
- package/dist/_internal/server/features/parts/assembly.d.ts +126 -0
- package/dist/_internal/server/features/parts/assembly.js +246 -0
- package/dist/_internal/server/features/parts/data.d.ts +65 -0
- package/dist/_internal/server/features/parts/data.js +170 -0
- package/dist/_internal/server/features/parts/og.d.ts +34 -0
- package/dist/_internal/server/features/parts/og.js +66 -0
- package/dist/_internal/server/features/search/search-everything.d.ts +75 -0
- package/dist/_internal/server/features/search/search-everything.js +120 -0
- package/dist/_internal/server/features/search/search-sources.d.ts +17 -0
- package/dist/_internal/server/features/search/search-sources.js +138 -0
- package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.d.ts +70 -0
- package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.js +302 -0
- package/dist/_internal/server/jobs/core/countersReconcile.js +128 -19
- package/dist/_internal/server/jobs/core/jobRunners.js +12 -0
- package/dist/_internal/server/jobs/core/onCategoryWrite.js +73 -3
- package/dist/_internal/server/jobs/core/onProductWrite.js +93 -43
- package/dist/_internal/server/jobs/core/productViewWindows.d.ts +2 -0
- package/dist/_internal/server/jobs/core/productViewWindows.js +173 -0
- package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
- package/dist/_internal/server/jobs/core/testDataCleanup.js +27 -1
- package/dist/_internal/server/jobs/handlers/pageViewRollup.d.ts +20 -0
- package/dist/_internal/server/jobs/handlers/pageViewRollup.js +25 -1
- package/dist/_internal/shared/actions/action-registry.js +36 -0
- package/dist/_internal/shared/features/promotions/schema.d.ts +2 -2
- package/dist/_internal/shared/features/shipping/protections.d.ts +93 -1
- package/dist/_internal/shared/features/shipping/protections.js +127 -0
- package/dist/_internal/shared/search/part-aliases.d.ts +44 -0
- package/dist/_internal/shared/search/part-aliases.js +104 -0
- package/dist/constants/api-endpoints.d.ts +18 -12
- package/dist/constants/api-endpoints.js +6 -8
- package/dist/constants/field-names.d.ts +0 -2
- package/dist/constants/field-names.js +0 -2
- package/dist/constants/index.d.ts +1 -1
- package/dist/constants/index.js +1 -1
- package/dist/core/index.d.ts +0 -3
- package/dist/core/index.js +0 -2
- package/dist/errors/messages.d.ts +0 -7
- package/dist/errors/messages.js +0 -8
- package/dist/features/admin/components/AdminSectionsView.js +83 -32
- package/dist/features/admin/components/AdminSiteSettingsView.js +6 -2
- package/dist/features/admin/components/sections/sectionTemplates.d.ts +117 -0
- package/dist/features/admin/components/sections/sectionTemplates.js +213 -0
- package/dist/features/admin/schemas/firestore.d.ts +15 -0
- package/dist/features/admin/types/index.d.ts +0 -1
- package/dist/features/auth/permissions/constants.d.ts +1 -1
- package/dist/features/auth/permissions/constants.js +0 -2
- package/dist/features/categories/api/[id]/description-template/route.js +40 -1
- package/dist/features/categories/components/BrandDetailPageView.js +14 -3
- package/dist/features/categories/components/CategoryDetailPageView.js +89 -14
- package/dist/features/categories/components/CategoryTemplatePicker.js +9 -1
- package/dist/features/categories/repository/categories.repository.d.ts +65 -0
- package/dist/features/categories/repository/categories.repository.js +168 -22
- package/dist/features/categories/repository/category-counts.d.ts +143 -0
- package/dist/features/categories/repository/category-counts.js +224 -0
- package/dist/features/categories/schemas/category-counts.d.ts +155 -0
- package/dist/features/categories/schemas/category-counts.js +116 -0
- package/dist/features/categories/schemas/firestore.d.ts +0 -2
- package/dist/features/categories/schemas/firestore.js +0 -1
- package/dist/features/categories/utils/breadcrumb-chain.d.ts +194 -0
- package/dist/features/categories/utils/breadcrumb-chain.js +202 -0
- package/dist/features/corporate/schemas/index.d.ts +4 -4
- package/dist/features/orders/schemas/firestore.d.ts +13 -0
- package/dist/features/orders/types/index.d.ts +30 -0
- package/dist/features/parts/components/AssemblyDrawer.d.ts +107 -0
- package/dist/features/parts/components/AssemblyDrawer.js +250 -0
- package/dist/features/parts/components/ModelCompositionPanel.d.ts +37 -0
- package/dist/features/parts/components/ModelCompositionPanel.js +34 -0
- package/dist/features/parts/components/PartDetailPageView.d.ts +48 -0
- package/dist/features/parts/components/PartDetailPageView.js +20 -0
- package/dist/features/parts/components/PartsIndexPageView.d.ts +33 -0
- package/dist/features/parts/components/PartsIndexPageView.js +17 -0
- package/dist/features/parts/schemas/firestore.d.ts +257 -0
- package/dist/features/parts/schemas/firestore.js +181 -0
- package/dist/features/parts/utils/colourway-match.d.ts +62 -0
- package/dist/features/parts/utils/colourway-match.js +98 -0
- package/dist/features/parts/utils/part-breakdown.d.ts +53 -0
- package/dist/features/parts/utils/part-breakdown.js +77 -0
- package/dist/features/products/components/ProductDetailPageView.d.ts +14 -1
- package/dist/features/products/components/ProductDetailPageView.js +22 -3
- package/dist/features/products/components/ProductTabsShell.d.ts +3 -1
- package/dist/features/products/components/ProductTabsShell.js +4 -1
- package/dist/features/products/repository/category-assignment.d.ts +132 -0
- package/dist/features/products/repository/category-assignment.js +304 -0
- package/dist/features/products/repository/products.repository.d.ts +58 -1
- package/dist/features/products/repository/products.repository.js +255 -28
- package/dist/features/products/schemas/firestore.d.ts +147 -6
- package/dist/features/products/schemas/firestore.js +49 -0
- package/dist/features/search/components/SearchResultsPageView.d.ts +33 -0
- package/dist/features/search/components/SearchResultsPageView.js +21 -0
- package/dist/features/seller/components/SellerProductsView.js +129 -3
- package/dist/features/tester/seed-data/authored/admin__site-system.js +0 -15
- package/dist/features/tester/seed-data/authored/content-discovery__parts-database.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/content-discovery__parts-database.js +166 -0
- package/dist/features/tester/seed-data/authored/index.js +2 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +28 -1
- package/dist/index.d.ts +11 -16
- package/dist/index.js +26 -18
- package/dist/next/routing/route-map.d.ts +6 -2
- package/dist/next/routing/route-map.js +14 -1
- package/dist/providers/db-firebase/base.repository.d.ts +31 -0
- package/dist/providers/db-firebase/base.repository.js +31 -0
- package/dist/repositories/index.d.ts +1 -2
- package/dist/repositories/index.js +0 -1
- package/dist/seed/_helpers/category-content.d.ts +29 -0
- package/dist/seed/_helpers/category-content.js +98 -1
- package/dist/seed/_helpers/category-forest.js +104 -24
- package/dist/seed/_helpers/category-parts.d.ts +9 -0
- package/dist/seed/_helpers/category-parts.js +4594 -0
- package/dist/seed/carousel-slides-seed-data.js +81 -8
- package/dist/seed/categories-seed-data.js +44 -6
- package/dist/seed/homepage-sections-seed-data.js +170 -9
- package/dist/seed/index.d.ts +2 -0
- package/dist/seed/index.js +13 -0
- package/dist/seed/model-composition-seed-data.d.ts +15 -0
- package/dist/seed/model-composition-seed-data.js +8918 -0
- package/dist/seed/part-glossary-seed-data.d.ts +15 -0
- package/dist/seed/part-glossary-seed-data.js +10729 -0
- package/dist/seed/products-standard-seed-data.js +52 -0
- package/dist/seed/seed-purpose.d.ts +103 -4
- package/dist/seed/seed-purpose.js +162 -11
- package/dist/seo/json-ld.d.ts +10 -1
- package/dist/seo/json-ld.js +9 -1
- package/dist/server.d.ts +7 -0
- package/dist/server.js +11 -0
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/utils/id-generators.d.ts +30 -0
- package/dist/utils/id-generators.js +31 -1
- package/dist/utils/search-txt-builders.js +22 -0
- package/dist/utils/search-txt.d.ts +22 -0
- package/dist/utils/search-txt.js +17 -0
- package/dist/values/success-messages.d.ts +0 -4
- package/dist/values/success-messages.js +0 -5
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +292 -16
- package/dist/core/copilot-log.repository.d.ts +0 -44
- package/dist/core/copilot-log.repository.js +0 -61
- package/dist/features/copilot/components/AdminCopilotView.d.ts +0 -21
- package/dist/features/copilot/components/AdminCopilotView.js +0 -51
- package/dist/features/copilot/components/index.d.ts +0 -2
- package/dist/features/copilot/components/index.js +0 -1
- package/dist/features/copilot/hooks/useCopilotChat.d.ts +0 -25
- package/dist/features/copilot/hooks/useCopilotChat.js +0 -65
- package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +0 -15
- package/dist/features/copilot/hooks/useCopilotFeedback.js +0 -15
- package/dist/features/copilot/index.d.ts +0 -3
- package/dist/features/copilot/index.js +0 -3
|
@@ -23,6 +23,13 @@ import { normalizeError } from "../../../../errors/normalize.js";
|
|
|
23
23
|
* equal and does nothing. The cascade stops.
|
|
24
24
|
* MOVE → writes `positionDirty`, which is also not `parentIds`, so its
|
|
25
25
|
* own re-entry likewise does nothing. One extra hop, then stop.
|
|
26
|
+
* RENAME/MOVE → (I4) also ENQUEUES `categoryTaxonomyBackfill`. That write
|
|
27
|
+
* goes to `jobs`, a different collection, so it cannot
|
|
28
|
+
* re-enter here at all. The job then writes `products` and,
|
|
29
|
+
* on a rename, descendant `categories` rows — and those DO
|
|
30
|
+
* re-enter, as updates carrying an unchanged `name` and
|
|
31
|
+
* `parentIds`, so both branches compare equal and nothing
|
|
32
|
+
* cascades. One hop per descendant, then stop.
|
|
26
33
|
*
|
|
27
34
|
* The guard is STRUCTURAL, not a value comparison — that is what makes it stronger
|
|
28
35
|
* than #92's. `onShipmentHeaderWrite` recomputed and rewrote the same derived field
|
|
@@ -31,8 +38,9 @@ import { normalizeError } from "../../../../errors/normalize.js";
|
|
|
31
38
|
* forever. Here there is nothing to compare: the handler cannot write the field it
|
|
32
39
|
* branches on.
|
|
33
40
|
*
|
|
34
|
-
* 🛑 If you ever make this handler write `parentIds`, it becomes a
|
|
35
|
-
*
|
|
41
|
+
* 🛑 If you ever make this handler write `parentIds` OR `name`, it becomes a
|
|
42
|
+
* true loop — `name` joined `parentIds` as a branch condition in I4. Do not; a
|
|
43
|
+
* move is deliberately deferred to the nightly reconcile for this reason.
|
|
36
44
|
*
|
|
37
45
|
* Not free, though: creating one category writes to its siblings, its ancestors and
|
|
38
46
|
* itself, and each of those writes spawns a no-op invocation. A full reseed of the
|
|
@@ -46,6 +54,7 @@ import { normalizeError } from "../../../../errors/normalize.js";
|
|
|
46
54
|
* carries correct DFS coordinates skips the branch entirely.
|
|
47
55
|
*/
|
|
48
56
|
import { FieldValue } from "firebase-admin/firestore";
|
|
57
|
+
import { enqueueJob } from "../../../../features/jobs/actions/enqueue-job.js";
|
|
49
58
|
import { BATCH_LIMIT } from "../handlers/messages.js";
|
|
50
59
|
const CATEGORIES = "categories";
|
|
51
60
|
async function shiftPositions(ctx, threshold, delta, excludeId) {
|
|
@@ -92,6 +101,34 @@ async function getMaxPosition(ctx, excludeId) {
|
|
|
92
101
|
}
|
|
93
102
|
return 0;
|
|
94
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* I4 — hand a rename or a reparent to the backfill job.
|
|
106
|
+
*
|
|
107
|
+
* Extracted to module scope rather than inlined, because inline it sat at
|
|
108
|
+
* brace-depth 6 and `audit-code-quality` is right to refuse that: the
|
|
109
|
+
* interesting part of this function is the self-write argument in the header,
|
|
110
|
+
* and burying a try/catch four levels in obscures it.
|
|
111
|
+
*/
|
|
112
|
+
async function enqueueTaxonomyBackfill(ctx, categoryId, kind) {
|
|
113
|
+
try {
|
|
114
|
+
const { jobId } = await enqueueJob({
|
|
115
|
+
jobType: "categoryTaxonomyBackfill",
|
|
116
|
+
payload: { categoryId, kind },
|
|
117
|
+
requestedBy: "system:onCategoryWrite",
|
|
118
|
+
});
|
|
119
|
+
ctx.logger.info("Category taxonomy backfill enqueued", { categoryId, kind, jobId });
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
void normalizeError(err);
|
|
123
|
+
/*
|
|
124
|
+
* Non-fatal, and deliberately so: a failed enqueue must not fail the
|
|
125
|
+
* category write the admin just made. The cost of the miss is a stale
|
|
126
|
+
* subtree until someone re-saves — which the nightly `countersReconcile`
|
|
127
|
+
* reports as drift rather than hiding.
|
|
128
|
+
*/
|
|
129
|
+
ctx.logger.error("Category taxonomy backfill could not be enqueued", err, { categoryId });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
95
132
|
export async function handleCategoryWrite(input, ctx) {
|
|
96
133
|
const { categoryId, before, after } = input;
|
|
97
134
|
// Drop the ISR entries for this category/brand/bundle page and the listings
|
|
@@ -206,7 +243,10 @@ export async function handleCategoryWrite(input, ctx) {
|
|
|
206
243
|
const afterParents = after.parentIds ?? [];
|
|
207
244
|
const beforeParent = beforeParents[beforeParents.length - 1] ?? null;
|
|
208
245
|
const afterParent = afterParents[afterParents.length - 1] ?? null;
|
|
209
|
-
|
|
246
|
+
const moved = beforeParent !== afterParent;
|
|
247
|
+
const renamed = before.name !== after.name &&
|
|
248
|
+
typeof after.name === "string";
|
|
249
|
+
if (moved) {
|
|
210
250
|
await ctx.db
|
|
211
251
|
.collection(CATEGORIES)
|
|
212
252
|
.doc(categoryId)
|
|
@@ -217,6 +257,36 @@ export async function handleCategoryWrite(input, ctx) {
|
|
|
217
257
|
toParent: afterParent,
|
|
218
258
|
});
|
|
219
259
|
}
|
|
260
|
+
/*
|
|
261
|
+
* I4 — a RENAME had no handler at all, and that was the live gap.
|
|
262
|
+
*
|
|
263
|
+
* `ancestors[]` on every descendant, and `categoryNames` on every product
|
|
264
|
+
* in the subtree, are denormalised copies of THIS node's name. Both feed
|
|
265
|
+
* `searchTxt` — `buildCategorySearchTxt` indexes ancestor names so that
|
|
266
|
+
* typing "burst" reaches a Lost Longinus leaf, and
|
|
267
|
+
* `buildProductSearchTxt` indexes `categoryNames`. So renaming a node
|
|
268
|
+
* left its whole subtree findable by the OLD name and not the new one,
|
|
269
|
+
* with this node's own page looking perfectly correct.
|
|
270
|
+
*
|
|
271
|
+
* A reparent is enqueued too. `reparentSubtree` already calls
|
|
272
|
+
* `rewriteProductChains` synchronously, which fixes the chains — but it is
|
|
273
|
+
* unbounded inside a request, and it does not rebuild `searchTxt`. The job
|
|
274
|
+
* is idempotent, so running after it costs only the rows it still has to
|
|
275
|
+
* fix.
|
|
276
|
+
*
|
|
277
|
+
* 🛑 THE SELF-WRITE GUARD STILL HOLDS, and it must be checked rather than
|
|
278
|
+
* assumed: this handler is a `documentWritten` trigger on `categories`
|
|
279
|
+
* that writes back to `categories` (Root Cause #92's shape, 1,017,548
|
|
280
|
+
* invocations in 24h). `enqueueJob` writes to `jobs` — a DIFFERENT
|
|
281
|
+
* collection — so it cannot re-enter here. The job itself writes
|
|
282
|
+
* `products` and, on a rename, descendant `categories` rows: those DO
|
|
283
|
+
* re-enter, as updates whose `parentIds` and `name` are unchanged, so both
|
|
284
|
+
* branches above compare equal and nothing cascades. The structural rule
|
|
285
|
+
* is unchanged: this handler must never write `parentIds` or `name`.
|
|
286
|
+
*/
|
|
287
|
+
if (moved || renamed) {
|
|
288
|
+
await enqueueTaxonomyBackfill(ctx, categoryId, renamed ? "rename" : "reparent");
|
|
289
|
+
}
|
|
220
290
|
}
|
|
221
291
|
}
|
|
222
292
|
catch (err) {
|
|
@@ -12,18 +12,50 @@ const UNAVAILABLE_PRODUCT_STATUSES = new Set([
|
|
|
12
12
|
ProductStatusValues.ARCHIVED,
|
|
13
13
|
]);
|
|
14
14
|
void UNAVAILABLE_PRODUCT_STATUSES;
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* I3 — the ASSIGNED categories, where `own` moves.
|
|
17
|
+
*
|
|
18
|
+
* 🛑 The selection order must stay byte-identical to `describeCountable` and to
|
|
19
|
+
* `countersReconcile.tallyProduct`. All three read the same two fields in the
|
|
20
|
+
* same order, and if any one of them disagreed the nightly recount would
|
|
21
|
+
* "correct" a live count that was right — drift that is invisible precisely
|
|
22
|
+
* because the job overwrites rather than compares.
|
|
23
|
+
*/
|
|
24
|
+
function getEffectiveCategories(doc) {
|
|
16
25
|
if (!doc)
|
|
17
|
-
return
|
|
26
|
+
return [];
|
|
27
|
+
const ids = doc.categoryIds;
|
|
28
|
+
if (Array.isArray(ids) && ids.length > 0)
|
|
29
|
+
return ids.filter(Boolean);
|
|
18
30
|
const categorySlugs = doc.categorySlugs;
|
|
19
|
-
if (Array.isArray(categorySlugs) && categorySlugs.length > 0)
|
|
20
|
-
return categorySlugs[0];
|
|
21
|
-
|
|
31
|
+
if (Array.isArray(categorySlugs) && categorySlugs.length > 0) {
|
|
32
|
+
return [categorySlugs[0]].filter(Boolean);
|
|
33
|
+
}
|
|
34
|
+
const scalar = doc.category;
|
|
35
|
+
return scalar ? [scalar] : [];
|
|
22
36
|
}
|
|
23
|
-
|
|
24
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Each assignment paired with its ancestor chain, read from the LIVE tree.
|
|
39
|
+
*
|
|
40
|
+
* 🛑 From the category documents, never from the product's stored closure —
|
|
41
|
+
* that is what makes a reparent self-heal here as well as in the nightly job.
|
|
42
|
+
* One read per assignment, in parallel, and at most
|
|
43
|
+
* `MAX_CATEGORY_ASSIGNMENTS` of them.
|
|
44
|
+
*/
|
|
45
|
+
async function withParents(categoryIds) {
|
|
46
|
+
if (categoryIds.length === 0)
|
|
25
47
|
return [];
|
|
26
|
-
return (
|
|
48
|
+
return Promise.all(categoryIds.map(async (id) => ({
|
|
49
|
+
id,
|
|
50
|
+
parentIds: (await categoriesRepository.findById(id))?.parentIds ?? [],
|
|
51
|
+
})));
|
|
52
|
+
}
|
|
53
|
+
/** True when the two assignment lists differ as SETS, not as ordered arrays. */
|
|
54
|
+
function assignmentsMoved(before, after) {
|
|
55
|
+
if (before.length !== after.length)
|
|
56
|
+
return true;
|
|
57
|
+
const a = new Set(after);
|
|
58
|
+
return before.some((id) => !a.has(id));
|
|
27
59
|
}
|
|
28
60
|
/**
|
|
29
61
|
* 🛑 THIS IS A CROSS-COLLECTION TRIGGER AND THAT IS THE SAFETY ARGUMENT.
|
|
@@ -46,17 +78,21 @@ async function getParentIds(categoryId) {
|
|
|
46
78
|
* own count equals its rollup by construction.
|
|
47
79
|
*/
|
|
48
80
|
async function dispatchProductWriteEvent(p) {
|
|
49
|
-
const { productId,
|
|
50
|
-
/** Stage
|
|
51
|
-
const stage = (batch,
|
|
81
|
+
const { productId, beforeCategories, afterCategories, beforeBrand, afterBrand, beforeStoreId, afterStoreId, isAuction, beforeIsAuction, wasPublished, isPublished, isDelete, ctx } = p;
|
|
82
|
+
/** Stage every assignment (and, if given, one brand) at `sign` x 1 item. */
|
|
83
|
+
const stage = (batch, assignments, brandId, auction, sign) => {
|
|
52
84
|
const productDelta = auction ? 0 : sign;
|
|
53
85
|
const auctionDelta = auction ? sign : 0;
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
86
|
+
if (assignments.length === 0 && !brandId)
|
|
87
|
+
return;
|
|
88
|
+
/*
|
|
89
|
+
* ONE call, which aggregates per document id — see
|
|
90
|
+
* `updateMetricsForAssignmentsInBatch`. Looping the single-category version
|
|
91
|
+
* instead would stage two writes on a shared ancestor, and Firestore
|
|
92
|
+
* rejects a batch containing two writes to the same document, losing every
|
|
93
|
+
* increment in it rather than just the duplicate.
|
|
94
|
+
*/
|
|
95
|
+
categoriesRepository.updateMetricsForAssignmentsInBatch(batch, assignments, productDelta, auctionDelta, productId, brandId);
|
|
60
96
|
};
|
|
61
97
|
/*
|
|
62
98
|
* `batch.update()` REJECTS a missing document, and a batch is atomic — so one
|
|
@@ -71,26 +107,26 @@ async function dispatchProductWriteEvent(p) {
|
|
|
71
107
|
await batch.commit();
|
|
72
108
|
ctx.logger.info(what, { productId, ...meta });
|
|
73
109
|
};
|
|
74
|
-
if (isDelete && wasPublished && (
|
|
110
|
+
if (isDelete && wasPublished && (beforeCategories.length > 0 || beforeBrand)) {
|
|
75
111
|
const batch = ctx.db.batch();
|
|
76
|
-
stage(batch,
|
|
77
|
-
await commit(batch, "Decremented counters on hard-delete", {
|
|
112
|
+
stage(batch, await withParents(beforeCategories), beforeBrand, beforeIsAuction, -1);
|
|
113
|
+
await commit(batch, "Decremented counters on hard-delete", { categories: beforeCategories, brand: beforeBrand, storeId: beforeStoreId });
|
|
78
114
|
if (beforeStoreId)
|
|
79
115
|
await storeRepository.incrementTotalProducts(beforeStoreId, -1);
|
|
80
116
|
return;
|
|
81
117
|
}
|
|
82
|
-
if (!wasPublished && isPublished && (
|
|
118
|
+
if (!wasPublished && isPublished && (afterCategories.length > 0 || afterBrand)) {
|
|
83
119
|
const batch = ctx.db.batch();
|
|
84
|
-
stage(batch,
|
|
85
|
-
await commit(batch, "Incremented counters on publish", {
|
|
120
|
+
stage(batch, await withParents(afterCategories), afterBrand, isAuction, 1);
|
|
121
|
+
await commit(batch, "Incremented counters on publish", { categories: afterCategories, brand: afterBrand, storeId: afterStoreId });
|
|
86
122
|
if (afterStoreId)
|
|
87
123
|
await storeRepository.incrementTotalProducts(afterStoreId, 1);
|
|
88
124
|
return;
|
|
89
125
|
}
|
|
90
|
-
if (wasPublished && !isPublished && (
|
|
126
|
+
if (wasPublished && !isPublished && (beforeCategories.length > 0 || beforeBrand)) {
|
|
91
127
|
const batch = ctx.db.batch();
|
|
92
|
-
stage(batch,
|
|
93
|
-
await commit(batch, "Decremented counters on unpublish", {
|
|
128
|
+
stage(batch, await withParents(beforeCategories), beforeBrand, beforeIsAuction, -1);
|
|
129
|
+
await commit(batch, "Decremented counters on unpublish", { categories: beforeCategories, brand: beforeBrand, storeId: beforeStoreId });
|
|
94
130
|
if (beforeStoreId)
|
|
95
131
|
await storeRepository.incrementTotalProducts(beforeStoreId, -1);
|
|
96
132
|
return;
|
|
@@ -104,7 +140,7 @@ async function dispatchProductWriteEvent(p) {
|
|
|
104
140
|
* through every branch and silently leave both brand rows wrong.
|
|
105
141
|
*/
|
|
106
142
|
if (wasPublished && isPublished) {
|
|
107
|
-
const categoryMoved =
|
|
143
|
+
const categoryMoved = assignmentsMoved(beforeCategories, afterCategories);
|
|
108
144
|
const brandMoved = beforeBrand !== afterBrand;
|
|
109
145
|
/*
|
|
110
146
|
* A listing can also change WHICH counter it belongs in without moving at
|
|
@@ -125,16 +161,10 @@ async function dispatchProductWriteEvent(p) {
|
|
|
125
161
|
* correctly does not move — the item did not come or go, it changed shape.
|
|
126
162
|
*/
|
|
127
163
|
if (!categoryMoved && !brandMoved) {
|
|
128
|
-
const parents = await getParentIds(afterCategory ?? "");
|
|
129
164
|
const productDelta = isAuction ? -1 : 1;
|
|
130
165
|
const auctionDelta = isAuction ? 1 : -1;
|
|
131
166
|
const batch = ctx.db.batch();
|
|
132
|
-
|
|
133
|
-
categoriesRepository.updateMetricsInBatch(batch, afterCategory, parents, productDelta, auctionDelta, productId);
|
|
134
|
-
}
|
|
135
|
-
if (afterBrand) {
|
|
136
|
-
categoriesRepository.updateMetricsInBatch(batch, afterBrand, [], productDelta, auctionDelta, productId);
|
|
137
|
-
}
|
|
167
|
+
categoriesRepository.updateMetricsForAssignmentsInBatch(batch, await withParents(afterCategories), productDelta, auctionDelta, productId, afterBrand);
|
|
138
168
|
await commit(batch, "Listing type changed", {
|
|
139
169
|
productId,
|
|
140
170
|
from: beforeIsAuction ? "auction" : "product",
|
|
@@ -143,15 +173,35 @@ async function dispatchProductWriteEvent(p) {
|
|
|
143
173
|
return;
|
|
144
174
|
}
|
|
145
175
|
const [beforeParents, afterParents] = await Promise.all([
|
|
146
|
-
|
|
147
|
-
|
|
176
|
+
withParents(categoryMoved ? beforeCategories : []),
|
|
177
|
+
withParents(categoryMoved ? afterCategories : []),
|
|
148
178
|
]);
|
|
179
|
+
/*
|
|
180
|
+
* TWO BATCHES, not one, and this is the I3 change that matters most.
|
|
181
|
+
*
|
|
182
|
+
* The -1 and the +1 both touch the rows that did NOT move: a listing
|
|
183
|
+
* re-filed from one leaf to a sibling shares every ancestor above them, and
|
|
184
|
+
* a product gaining a SECOND assignment keeps its first one entirely. In
|
|
185
|
+
* one batch those are two writes to the same document, which Firestore
|
|
186
|
+
* rejects outright — so the whole re-file would throw and BOTH counters
|
|
187
|
+
* would be left wrong.
|
|
188
|
+
*
|
|
189
|
+
* It worked before only because a single assignment's -1 and +1 were staged
|
|
190
|
+
* with ids that happened to be disjoint whenever the branch ran at all.
|
|
191
|
+
*
|
|
192
|
+
* Sequential commits are not atomic together: a crash between them leaves
|
|
193
|
+
* the decrement applied and the increment missing. That is the right trade,
|
|
194
|
+
* because it is self-healing — `countersReconcile` rewrites from scratch
|
|
195
|
+
* nightly and reports it as `drifted` — whereas a rejected batch is silent.
|
|
196
|
+
*/
|
|
197
|
+
const outBatch = ctx.db.batch();
|
|
198
|
+
stage(outBatch, beforeParents, brandMoved ? beforeBrand : null, beforeIsAuction, -1);
|
|
199
|
+
await outBatch.commit();
|
|
149
200
|
const batch = ctx.db.batch();
|
|
150
|
-
stage(batch,
|
|
151
|
-
stage(batch, categoryMoved ? afterCategory : null, brandMoved ? afterBrand : null, afterParents, isAuction, 1);
|
|
201
|
+
stage(batch, afterParents, brandMoved ? afterBrand : null, isAuction, 1);
|
|
152
202
|
await commit(batch, "Re-filed product", {
|
|
153
|
-
|
|
154
|
-
|
|
203
|
+
fromCategories: categoryMoved ? beforeCategories : undefined,
|
|
204
|
+
toCategories: categoryMoved ? afterCategories : undefined,
|
|
155
205
|
fromBrand: brandMoved ? beforeBrand : undefined,
|
|
156
206
|
toBrand: brandMoved ? afterBrand : undefined,
|
|
157
207
|
});
|
|
@@ -195,8 +245,8 @@ export async function handleProductWrite(input, ctx) {
|
|
|
195
245
|
// mapDoc() normalizes this on read, but a raw trigger snapshot bypasses that,
|
|
196
246
|
// so a product written via categorySlugs-only paths would otherwise silently
|
|
197
247
|
// no-op category metrics here. Prefer categorySlugs[0], fall back to legacy category.
|
|
198
|
-
const
|
|
199
|
-
const
|
|
248
|
+
const beforeCategories = getEffectiveCategories(before);
|
|
249
|
+
const afterCategories = getEffectiveCategories(after);
|
|
200
250
|
/*
|
|
201
251
|
* `brandSlug` is the brand ROW's document id in this same `categories`
|
|
202
252
|
* collection. `brand` is the display NAME and is not a key — CLAUDE.md's
|
|
@@ -238,7 +288,7 @@ export async function handleProductWrite(input, ctx) {
|
|
|
238
288
|
const isDelete = !after;
|
|
239
289
|
try {
|
|
240
290
|
await dispatchProductWriteEvent({
|
|
241
|
-
productId,
|
|
291
|
+
productId, beforeCategories, afterCategories, beforeBrand, afterBrand,
|
|
242
292
|
beforeStoreId, afterStoreId,
|
|
243
293
|
isAuction, beforeIsAuction, wasPublished, isPublished, isDelete, ctx,
|
|
244
294
|
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* WHY: visit data was collected and thrown away. `PageViewTracker` is mounted
|
|
3
|
+
* on ~30 public surfaces and writes one `pageViews` bucket per entity per
|
|
4
|
+
* day — and NOTHING joined those buckets to a product, so no seller could
|
|
5
|
+
* see how many people looked at their listing. Meanwhile `viewCount` was
|
|
6
|
+
* `canFilter`/`canSort` in the products Sieve config with no writer on any
|
|
7
|
+
* document, so a `?sort=viewCount` returned ZERO rows (Root Cause #100).
|
|
8
|
+
*
|
|
9
|
+
* WHAT: a nightly recompute of each product's trailing 7- and 30-day view
|
|
10
|
+
* counts onto `views7d` / `views30d`.
|
|
11
|
+
*
|
|
12
|
+
* EXPORTS: runProductViewWindows
|
|
13
|
+
*
|
|
14
|
+
* ── 🛑 WHY THIS IS A SEPARATE PASS FROM `pageViewRollup` ───────────────────
|
|
15
|
+
*
|
|
16
|
+
* They cannot share a read. `pageViewRollup` ACCUMULATES: it folds only buckets
|
|
17
|
+
* it has not seen, because the ones it has are then deleted by the prune, so a
|
|
18
|
+
* recompute-from-scratch would make its total walk backwards. A trailing window
|
|
19
|
+
* is the opposite shape — it must re-read the last 30 days EVERY run, because
|
|
20
|
+
* yesterday's 30-day window is not today's. Mixing them would break
|
|
21
|
+
* `throughDate`'s invariant, which is the one thing making that job
|
|
22
|
+
* re-runnable.
|
|
23
|
+
*
|
|
24
|
+
* ── 🛑 IT ENUMERATES PRODUCTS, NOT BUCKETS ────────────────────────────────
|
|
25
|
+
*
|
|
26
|
+
* Root Cause #102's lesson, which cost 19 of 65 category rows: a reconciler
|
|
27
|
+
* that walks the EVENTS can only ever add. A product that stops being viewed
|
|
28
|
+
* must have its figure fall to 0, and that product has no bucket in the window
|
|
29
|
+
* at all — so the pass is driven by the product list and a missing bucket means
|
|
30
|
+
* zero rather than "leave it alone".
|
|
31
|
+
*
|
|
32
|
+
* ── 🛑 ABSENT STILL MEANS "NOT ROLLED UP", NOT ZERO ───────────────────────
|
|
33
|
+
*
|
|
34
|
+
* The first run writes every product including the ones at 0, so after it
|
|
35
|
+
* absence is unambiguous. Before it, a reader must distinguish the two — which
|
|
36
|
+
* is why the renderers check `typeof x === "number"` and not truthiness.
|
|
37
|
+
*
|
|
38
|
+
* @tag domain:analytics
|
|
39
|
+
* @tag layer:job
|
|
40
|
+
* @tag pattern:none
|
|
41
|
+
* @tag access:server-only
|
|
42
|
+
* @tag consumers:pageViewRollup scheduled function
|
|
43
|
+
* @tag sideEffects:writes products.views7d / products.views30d
|
|
44
|
+
*/
|
|
45
|
+
import { pageViewsRepository } from "../../../../features/analytics/repository/page-views.repository.js";
|
|
46
|
+
import { PRODUCT_COLLECTION } from "../../../../features/products/schemas/firestore.js";
|
|
47
|
+
/** The longer of the two windows, so one read serves both. */
|
|
48
|
+
const WINDOW_DAYS = 30;
|
|
49
|
+
const SHORT_WINDOW_DAYS = 7;
|
|
50
|
+
/**
|
|
51
|
+
* 🛑 A bound, because `listInRange` has none.
|
|
52
|
+
*
|
|
53
|
+
* One bucket per product per day: 95 products over 30 days is ~2,850 documents
|
|
54
|
+
* today, which is fine, and 5,000 products would be 150,000 — well past what a
|
|
55
|
+
* 540s function should read in one pass. Saturating this is reported rather
|
|
56
|
+
* than silently truncating the windows, because a truncated window is a wrong
|
|
57
|
+
* number that looks exactly like a right one.
|
|
58
|
+
*/
|
|
59
|
+
const MAX_BUCKETS = 20000;
|
|
60
|
+
/** Matches `countersReconcile`'s scan page, and stays under the 500-op batch. */
|
|
61
|
+
const PAGE_SIZE = 200;
|
|
62
|
+
function dayKey(offsetDays) {
|
|
63
|
+
const d = new Date();
|
|
64
|
+
d.setUTCDate(d.getUTCDate() + offsetDays);
|
|
65
|
+
return d.toISOString().slice(0, 10);
|
|
66
|
+
}
|
|
67
|
+
export async function runProductViewWindows(ctx) {
|
|
68
|
+
/*
|
|
69
|
+
* Through YESTERDAY, not today. Today's bucket is still being written to, so
|
|
70
|
+
* including it makes the figure move during the day for no reason a reader
|
|
71
|
+
* could explain — and the rollup beside this one draws the same line for the
|
|
72
|
+
* same reason.
|
|
73
|
+
*/
|
|
74
|
+
const through = dayKey(-1);
|
|
75
|
+
const from = dayKey(-WINDOW_DAYS);
|
|
76
|
+
const shortFrom = dayKey(-SHORT_WINDOW_DAYS);
|
|
77
|
+
/*
|
|
78
|
+
* No `.catch(() => [])`. An empty range is a legitimate zero, so a swallowed
|
|
79
|
+
* failure would write a confident "nobody looked at this" across the whole
|
|
80
|
+
* catalogue — and that is indistinguishable from the truth. Let it throw.
|
|
81
|
+
*/
|
|
82
|
+
const buckets = await pageViewsRepository.listInRange(from, through, "product");
|
|
83
|
+
if (buckets.length >= MAX_BUCKETS) {
|
|
84
|
+
/*
|
|
85
|
+
* Refuse rather than write. A partial window is a wrong number with a
|
|
86
|
+
* confident shape, and the whole point of this pass is that the figure can
|
|
87
|
+
* be trusted. The prune keeps the collection bounded, so this firing is a
|
|
88
|
+
* signal that the window or the catalogue has outgrown one query.
|
|
89
|
+
*/
|
|
90
|
+
ctx.logger.error("productViewWindows: bucket cap hit, refusing to write", {
|
|
91
|
+
buckets: buckets.length,
|
|
92
|
+
cap: MAX_BUCKETS,
|
|
93
|
+
from,
|
|
94
|
+
through,
|
|
95
|
+
});
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const views30 = new Map();
|
|
99
|
+
const views7 = new Map();
|
|
100
|
+
for (const b of buckets) {
|
|
101
|
+
const n = Number(b.count) || 0;
|
|
102
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
103
|
+
continue;
|
|
104
|
+
views30.set(b.entityId, (views30.get(b.entityId) ?? 0) + n);
|
|
105
|
+
// One pass, two windows: the 7-day figure is the tail of the same read.
|
|
106
|
+
if (b.date >= shortFrom)
|
|
107
|
+
views7.set(b.entityId, (views7.get(b.entityId) ?? 0) + n);
|
|
108
|
+
}
|
|
109
|
+
/*
|
|
110
|
+
* 🛑 Driven by the PRODUCT list. A product with no bucket in the window gets
|
|
111
|
+
* 0 written, which is the half a bucket-driven pass cannot do — see the
|
|
112
|
+
* header.
|
|
113
|
+
*/
|
|
114
|
+
let scanned = 0;
|
|
115
|
+
let written = 0;
|
|
116
|
+
/*
|
|
117
|
+
* `ctx.db` with `orderBy("__name__")` + `startAfter`, which is exactly what
|
|
118
|
+
* `countersReconcile` does over this same collection. Bounded reads,
|
|
119
|
+
* unbounded total coverage — and no new repository method, because the one I
|
|
120
|
+
* first reached for did not exist.
|
|
121
|
+
*/
|
|
122
|
+
let cursor = null;
|
|
123
|
+
for (;;) {
|
|
124
|
+
let q = ctx.db
|
|
125
|
+
.collection(PRODUCT_COLLECTION)
|
|
126
|
+
.orderBy("__name__")
|
|
127
|
+
.limit(PAGE_SIZE);
|
|
128
|
+
if (cursor)
|
|
129
|
+
q = q.startAfter(cursor);
|
|
130
|
+
const page = await q.get();
|
|
131
|
+
if (page.empty)
|
|
132
|
+
break;
|
|
133
|
+
/*
|
|
134
|
+
* One batch per page. `batchUpdateEach` caps at Firestore's 500 operations
|
|
135
|
+
* and PAGE_SIZE is 200, so a page can never exceed it.
|
|
136
|
+
*/
|
|
137
|
+
const batch = ctx.db.batch();
|
|
138
|
+
let inBatch = 0;
|
|
139
|
+
for (const doc of page.docs) {
|
|
140
|
+
scanned += 1;
|
|
141
|
+
const data = doc.data();
|
|
142
|
+
const next7 = views7.get(doc.id) ?? 0;
|
|
143
|
+
const next30 = views30.get(doc.id) ?? 0;
|
|
144
|
+
// Skip a no-op. `countersReconcile` does the same: a product already at
|
|
145
|
+
// these figures costs no write rather than a wasted one.
|
|
146
|
+
if (data.views7d === next7 && data.views30d === next30)
|
|
147
|
+
continue;
|
|
148
|
+
batch.update(doc.ref, { views7d: next7, views30d: next30 });
|
|
149
|
+
inBatch += 1;
|
|
150
|
+
}
|
|
151
|
+
if (inBatch > 0) {
|
|
152
|
+
await batch.commit();
|
|
153
|
+
written += inBatch;
|
|
154
|
+
}
|
|
155
|
+
cursor = page.docs[page.docs.length - 1];
|
|
156
|
+
if (page.size < PAGE_SIZE)
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
ctx.logger.info("productViewWindows: complete", {
|
|
160
|
+
from,
|
|
161
|
+
through,
|
|
162
|
+
buckets: buckets.length,
|
|
163
|
+
productsScanned: scanned,
|
|
164
|
+
productsWritten: written,
|
|
165
|
+
/*
|
|
166
|
+
* Reported because on a quiet day a non-zero `written` with a zero
|
|
167
|
+
* `buckets` would mean the pass is zeroing the whole catalogue — which is
|
|
168
|
+
* correct if nobody visited, and a signal if the tracker has stopped
|
|
169
|
+
* recording. Neither is visible from a success line alone.
|
|
170
|
+
*/
|
|
171
|
+
productsWithViews: views30.size,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
@@ -14,7 +14,7 @@ import type { JobContext } from "../runtime/types";
|
|
|
14
14
|
* (This used to be shared with testerSandboxRefresh.ts, which was removed when
|
|
15
15
|
* the tester runner took over fixture state — see scheduled.ts.)
|
|
16
16
|
*/
|
|
17
|
-
export declare const TEST_DATA_COLLECTIONS: readonly ["categories", "stores", "products", "blogPosts", "events"
|
|
17
|
+
export declare const TEST_DATA_COLLECTIONS: readonly ["categories", "stores", "products", "blogPosts", "events"];
|
|
18
18
|
export interface TestDataCleanupOptions {
|
|
19
19
|
/** true = wipe every isTestData:true doc regardless of testDataExpiresAt (manual force-purge). */
|
|
20
20
|
force?: boolean;
|
|
@@ -15,7 +15,33 @@ import { BID_FIELDS } from "../../../../constants/field-names.js";
|
|
|
15
15
|
* (This used to be shared with testerSandboxRefresh.ts, which was removed when
|
|
16
16
|
* the tester runner took over fixture state — see scheduled.ts.)
|
|
17
17
|
*/
|
|
18
|
-
|
|
18
|
+
/*
|
|
19
|
+
* 🛑 `offers` was in this list and could only ever match ZERO rows.
|
|
20
|
+
*
|
|
21
|
+
* `OfferDocument` (features/seller/schemas/firestore.ts) does not declare
|
|
22
|
+
* `isTestData` at all, and `getTestDataRefs` queries on exactly that field — so
|
|
23
|
+
* the entry read as coverage and swept nothing. Measured live before removal:
|
|
24
|
+
* 10 offers, 0 carrying the field. Removed rather than given the field, because
|
|
25
|
+
* no offer fixture has ever needed hiding from the public.
|
|
26
|
+
*
|
|
27
|
+
* 🛑 And a wider measurement worth recording, because it changes how to read
|
|
28
|
+
* this job: **zero documents in ANY of the five remaining collections carry
|
|
29
|
+
* `isTestData` either** — 416 categories, 70 products, 20 blogPosts, 12 events,
|
|
30
|
+
* 4 stores, all absent. So this sweep is DORMANT in production, which is
|
|
31
|
+
* correct rather than broken: the only two `isTestData` fixtures the seed ever
|
|
32
|
+
* had were deleted 2026-10-10, and nothing has needed hiding since.
|
|
33
|
+
*
|
|
34
|
+
* 🛑 Do NOT confuse this list with `seedPurpose`. They answer different
|
|
35
|
+
* questions and conflating them hides the catalogue:
|
|
36
|
+
*
|
|
37
|
+
* isTestData + testDataExpiresAt -> may the PUBLIC see this row? (this job)
|
|
38
|
+
* seedPurpose -> may a PURGE delete this row?
|
|
39
|
+
*
|
|
40
|
+
* A demo product is `seedPurpose: "test"` and fully visible; a sandbox fixture
|
|
41
|
+
* is both `test` AND `isTestData`. `audit-seed-purpose` R6 now asserts every
|
|
42
|
+
* entry here declares the field, so the `offers` shape cannot return.
|
|
43
|
+
*/
|
|
44
|
+
export const TEST_DATA_COLLECTIONS = ["categories", "stores", "products", "blogPosts", "events"];
|
|
19
45
|
const BID_CHUNK_SIZE = 30; // Firestore `in` query cap
|
|
20
46
|
export async function runTestDataCleanup(ctx, opts = {}) {
|
|
21
47
|
const cutoff = opts.force ? null : new Date();
|
|
@@ -1,2 +1,22 @@
|
|
|
1
1
|
import type { ScheduleHandler } from "../runtime/types";
|
|
2
|
+
/**
|
|
3
|
+
* Two passes over `pageViews`, in one scheduled function.
|
|
4
|
+
*
|
|
5
|
+
* 🛑 One function because the Firebase budget row prices each registered
|
|
6
|
+
* Cloud Scheduler job at ~$0.10/month and says to fold new periodic work into
|
|
7
|
+
* an existing cadence where it is a natural fit. These two read the same
|
|
8
|
+
* collection on the same night, so it is.
|
|
9
|
+
*
|
|
10
|
+
* 🛑 But they are two CORES, not one, because their read shapes are opposites:
|
|
11
|
+
* the rollup ACCUMULATES (it must only see buckets it has not folded, or its
|
|
12
|
+
* total walks backwards as the prune catches up) while the windows RECOMPUTE
|
|
13
|
+
* (yesterday's 30-day window is not today's). Sharing a read would break
|
|
14
|
+
* `throughDate`, which is the only thing making the rollup re-runnable.
|
|
15
|
+
*
|
|
16
|
+
* 🛑 Sequential, and the rollup goes FIRST. It is the one whose failure loses
|
|
17
|
+
* data permanently — a bad read there advances `throughDate` past days it never
|
|
18
|
+
* counted. The windows are a pure recompute and tomorrow's run fixes any miss,
|
|
19
|
+
* so they are the half that can safely be second. `Promise.all` would let a
|
|
20
|
+
* windows failure mask a rollup failure in the same rejection.
|
|
21
|
+
*/
|
|
2
22
|
export declare const pageViewRollupHandler: ScheduleHandler;
|
|
@@ -1,2 +1,26 @@
|
|
|
1
1
|
import { runPageViewRollup } from "../core/pageViewRollup.js";
|
|
2
|
-
|
|
2
|
+
import { runProductViewWindows } from "../core/productViewWindows.js";
|
|
3
|
+
/**
|
|
4
|
+
* Two passes over `pageViews`, in one scheduled function.
|
|
5
|
+
*
|
|
6
|
+
* 🛑 One function because the Firebase budget row prices each registered
|
|
7
|
+
* Cloud Scheduler job at ~$0.10/month and says to fold new periodic work into
|
|
8
|
+
* an existing cadence where it is a natural fit. These two read the same
|
|
9
|
+
* collection on the same night, so it is.
|
|
10
|
+
*
|
|
11
|
+
* 🛑 But they are two CORES, not one, because their read shapes are opposites:
|
|
12
|
+
* the rollup ACCUMULATES (it must only see buckets it has not folded, or its
|
|
13
|
+
* total walks backwards as the prune catches up) while the windows RECOMPUTE
|
|
14
|
+
* (yesterday's 30-day window is not today's). Sharing a read would break
|
|
15
|
+
* `throughDate`, which is the only thing making the rollup re-runnable.
|
|
16
|
+
*
|
|
17
|
+
* 🛑 Sequential, and the rollup goes FIRST. It is the one whose failure loses
|
|
18
|
+
* data permanently — a bad read there advances `throughDate` past days it never
|
|
19
|
+
* counted. The windows are a pure recompute and tomorrow's run fixes any miss,
|
|
20
|
+
* so they are the half that can safely be second. `Promise.all` would let a
|
|
21
|
+
* windows failure mask a rollup failure in the same rejection.
|
|
22
|
+
*/
|
|
23
|
+
export const pageViewRollupHandler = async (ctx) => {
|
|
24
|
+
await runPageViewRollup(ctx);
|
|
25
|
+
await runProductViewWindows(ctx);
|
|
26
|
+
};
|
|
@@ -797,6 +797,42 @@ export const ACTIONS = {
|
|
|
797
797
|
kind: "secondary",
|
|
798
798
|
permissions: ["seller", "admin"],
|
|
799
799
|
},
|
|
800
|
+
/*
|
|
801
|
+
* D13 — assemble a complete bey from component listings, and part one back
|
|
802
|
+
* out. Registered here rather than inlined because Rule #7 is not
|
|
803
|
+
* negotiable and because PART-OUT IS DESTRUCTIVE: it decrements a sellable
|
|
804
|
+
* listing, so it carries a `confirmation` and a `danger` kind. An action
|
|
805
|
+
* with `destructive: true` and no confirmation executes immediately with no
|
|
806
|
+
* warning, which is what has cost data in prior sessions.
|
|
807
|
+
*/
|
|
808
|
+
"assemble-from-parts": {
|
|
809
|
+
iconKey: "create",
|
|
810
|
+
id: "store.assemble-from-parts",
|
|
811
|
+
label: "Assemble from parts",
|
|
812
|
+
ariaLabel: "Build this listing from component parts in your inventory",
|
|
813
|
+
description: "Build one or more of this listing from parts you already hold. Each component's stock is decremented; units already reserved by unpaid orders are never consumed.",
|
|
814
|
+
kind: "secondary",
|
|
815
|
+
permissions: ["seller", "admin"],
|
|
816
|
+
},
|
|
817
|
+
"part-out-listing": {
|
|
818
|
+
iconKey: "delete",
|
|
819
|
+
id: "store.part-out-listing",
|
|
820
|
+
label: "Part out",
|
|
821
|
+
ariaLabel: "Break this listing down into its component parts",
|
|
822
|
+
description: "Break this listing down and add its parts back to inventory. The complete listing's stock is decremented.",
|
|
823
|
+
kind: "danger",
|
|
824
|
+
permissions: ["seller", "admin"],
|
|
825
|
+
confirmation: {
|
|
826
|
+
title: "Part this out?",
|
|
827
|
+
/*
|
|
828
|
+
* Names what is LOST and what is GAINED, because a confirmation that
|
|
829
|
+
* only says "are you sure" leaves the seller to guess which direction
|
|
830
|
+
* the stock moves.
|
|
831
|
+
*/
|
|
832
|
+
body: "This listing's stock goes down and each component's stock goes up. It cannot be undone automatically — re-assembling is a separate action.",
|
|
833
|
+
confirmLabel: "Part out",
|
|
834
|
+
},
|
|
835
|
+
},
|
|
800
836
|
"set-stock": {
|
|
801
837
|
iconKey: "edit",
|
|
802
838
|
id: "store.set-stock",
|