@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.
Files changed (152) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/categories/live-counts.d.ts +41 -0
  3. package/dist/_internal/server/features/categories/live-counts.js +72 -0
  4. package/dist/_internal/server/features/checkout/expand-order-items.js +17 -0
  5. package/dist/_internal/server/features/orders/adapters.js +29 -0
  6. package/dist/_internal/server/features/parts/assembly.d.ts +126 -0
  7. package/dist/_internal/server/features/parts/assembly.js +246 -0
  8. package/dist/_internal/server/features/parts/data.d.ts +65 -0
  9. package/dist/_internal/server/features/parts/data.js +170 -0
  10. package/dist/_internal/server/features/parts/og.d.ts +34 -0
  11. package/dist/_internal/server/features/parts/og.js +66 -0
  12. package/dist/_internal/server/features/search/search-everything.d.ts +75 -0
  13. package/dist/_internal/server/features/search/search-everything.js +120 -0
  14. package/dist/_internal/server/features/search/search-sources.d.ts +17 -0
  15. package/dist/_internal/server/features/search/search-sources.js +138 -0
  16. package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.d.ts +70 -0
  17. package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.js +302 -0
  18. package/dist/_internal/server/jobs/core/countersReconcile.js +128 -19
  19. package/dist/_internal/server/jobs/core/jobRunners.js +12 -0
  20. package/dist/_internal/server/jobs/core/onCategoryWrite.js +73 -3
  21. package/dist/_internal/server/jobs/core/onProductWrite.js +93 -43
  22. package/dist/_internal/server/jobs/core/productViewWindows.d.ts +2 -0
  23. package/dist/_internal/server/jobs/core/productViewWindows.js +173 -0
  24. package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
  25. package/dist/_internal/server/jobs/core/testDataCleanup.js +27 -1
  26. package/dist/_internal/server/jobs/handlers/pageViewRollup.d.ts +20 -0
  27. package/dist/_internal/server/jobs/handlers/pageViewRollup.js +25 -1
  28. package/dist/_internal/shared/actions/action-registry.js +36 -0
  29. package/dist/_internal/shared/features/promotions/schema.d.ts +2 -2
  30. package/dist/_internal/shared/features/shipping/protections.d.ts +93 -1
  31. package/dist/_internal/shared/features/shipping/protections.js +127 -0
  32. package/dist/_internal/shared/search/part-aliases.d.ts +44 -0
  33. package/dist/_internal/shared/search/part-aliases.js +104 -0
  34. package/dist/constants/api-endpoints.d.ts +18 -12
  35. package/dist/constants/api-endpoints.js +6 -8
  36. package/dist/constants/field-names.d.ts +0 -2
  37. package/dist/constants/field-names.js +0 -2
  38. package/dist/constants/index.d.ts +1 -1
  39. package/dist/constants/index.js +1 -1
  40. package/dist/core/index.d.ts +0 -3
  41. package/dist/core/index.js +0 -2
  42. package/dist/errors/messages.d.ts +0 -7
  43. package/dist/errors/messages.js +0 -8
  44. package/dist/features/admin/components/AdminSectionsView.js +83 -32
  45. package/dist/features/admin/components/AdminSiteSettingsView.js +6 -2
  46. package/dist/features/admin/components/sections/sectionTemplates.d.ts +117 -0
  47. package/dist/features/admin/components/sections/sectionTemplates.js +213 -0
  48. package/dist/features/admin/schemas/firestore.d.ts +15 -0
  49. package/dist/features/admin/types/index.d.ts +0 -1
  50. package/dist/features/auth/permissions/constants.d.ts +1 -1
  51. package/dist/features/auth/permissions/constants.js +0 -2
  52. package/dist/features/categories/api/[id]/description-template/route.js +40 -1
  53. package/dist/features/categories/components/BrandDetailPageView.js +14 -3
  54. package/dist/features/categories/components/CategoryDetailPageView.js +89 -14
  55. package/dist/features/categories/components/CategoryTemplatePicker.js +9 -1
  56. package/dist/features/categories/repository/categories.repository.d.ts +65 -0
  57. package/dist/features/categories/repository/categories.repository.js +168 -22
  58. package/dist/features/categories/repository/category-counts.d.ts +143 -0
  59. package/dist/features/categories/repository/category-counts.js +224 -0
  60. package/dist/features/categories/schemas/category-counts.d.ts +155 -0
  61. package/dist/features/categories/schemas/category-counts.js +116 -0
  62. package/dist/features/categories/schemas/firestore.d.ts +0 -2
  63. package/dist/features/categories/schemas/firestore.js +0 -1
  64. package/dist/features/categories/utils/breadcrumb-chain.d.ts +194 -0
  65. package/dist/features/categories/utils/breadcrumb-chain.js +202 -0
  66. package/dist/features/corporate/schemas/index.d.ts +4 -4
  67. package/dist/features/orders/schemas/firestore.d.ts +13 -0
  68. package/dist/features/orders/types/index.d.ts +30 -0
  69. package/dist/features/parts/components/AssemblyDrawer.d.ts +107 -0
  70. package/dist/features/parts/components/AssemblyDrawer.js +250 -0
  71. package/dist/features/parts/components/ModelCompositionPanel.d.ts +37 -0
  72. package/dist/features/parts/components/ModelCompositionPanel.js +34 -0
  73. package/dist/features/parts/components/PartDetailPageView.d.ts +48 -0
  74. package/dist/features/parts/components/PartDetailPageView.js +20 -0
  75. package/dist/features/parts/components/PartsIndexPageView.d.ts +33 -0
  76. package/dist/features/parts/components/PartsIndexPageView.js +17 -0
  77. package/dist/features/parts/schemas/firestore.d.ts +257 -0
  78. package/dist/features/parts/schemas/firestore.js +181 -0
  79. package/dist/features/parts/utils/colourway-match.d.ts +62 -0
  80. package/dist/features/parts/utils/colourway-match.js +98 -0
  81. package/dist/features/parts/utils/part-breakdown.d.ts +53 -0
  82. package/dist/features/parts/utils/part-breakdown.js +77 -0
  83. package/dist/features/products/components/ProductDetailPageView.d.ts +14 -1
  84. package/dist/features/products/components/ProductDetailPageView.js +22 -3
  85. package/dist/features/products/components/ProductTabsShell.d.ts +3 -1
  86. package/dist/features/products/components/ProductTabsShell.js +4 -1
  87. package/dist/features/products/repository/category-assignment.d.ts +132 -0
  88. package/dist/features/products/repository/category-assignment.js +304 -0
  89. package/dist/features/products/repository/products.repository.d.ts +58 -1
  90. package/dist/features/products/repository/products.repository.js +255 -28
  91. package/dist/features/products/schemas/firestore.d.ts +147 -6
  92. package/dist/features/products/schemas/firestore.js +49 -0
  93. package/dist/features/search/components/SearchResultsPageView.d.ts +33 -0
  94. package/dist/features/search/components/SearchResultsPageView.js +21 -0
  95. package/dist/features/seller/components/SellerProductsView.js +129 -3
  96. package/dist/features/tester/seed-data/authored/admin__site-system.js +0 -15
  97. package/dist/features/tester/seed-data/authored/content-discovery__parts-database.d.ts +2 -0
  98. package/dist/features/tester/seed-data/authored/content-discovery__parts-database.js +166 -0
  99. package/dist/features/tester/seed-data/authored/index.js +2 -0
  100. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +28 -1
  101. package/dist/index.d.ts +11 -16
  102. package/dist/index.js +26 -18
  103. package/dist/next/routing/route-map.d.ts +6 -2
  104. package/dist/next/routing/route-map.js +14 -1
  105. package/dist/providers/db-firebase/base.repository.d.ts +31 -0
  106. package/dist/providers/db-firebase/base.repository.js +31 -0
  107. package/dist/repositories/index.d.ts +1 -2
  108. package/dist/repositories/index.js +0 -1
  109. package/dist/seed/_helpers/category-content.d.ts +29 -0
  110. package/dist/seed/_helpers/category-content.js +98 -1
  111. package/dist/seed/_helpers/category-forest.js +104 -24
  112. package/dist/seed/_helpers/category-parts.d.ts +9 -0
  113. package/dist/seed/_helpers/category-parts.js +4594 -0
  114. package/dist/seed/carousel-slides-seed-data.js +81 -8
  115. package/dist/seed/categories-seed-data.js +44 -6
  116. package/dist/seed/homepage-sections-seed-data.js +170 -9
  117. package/dist/seed/index.d.ts +2 -0
  118. package/dist/seed/index.js +13 -0
  119. package/dist/seed/model-composition-seed-data.d.ts +15 -0
  120. package/dist/seed/model-composition-seed-data.js +8918 -0
  121. package/dist/seed/part-glossary-seed-data.d.ts +15 -0
  122. package/dist/seed/part-glossary-seed-data.js +10729 -0
  123. package/dist/seed/products-standard-seed-data.js +52 -0
  124. package/dist/seed/seed-purpose.d.ts +103 -4
  125. package/dist/seed/seed-purpose.js +162 -11
  126. package/dist/seo/json-ld.d.ts +10 -1
  127. package/dist/seo/json-ld.js +9 -1
  128. package/dist/server.d.ts +7 -0
  129. package/dist/server.js +11 -0
  130. package/dist/styles.css +1 -1
  131. package/dist/tailwind-utilities.css +1 -1
  132. package/dist/utils/id-generators.d.ts +30 -0
  133. package/dist/utils/id-generators.js +31 -1
  134. package/dist/utils/search-txt-builders.js +22 -0
  135. package/dist/utils/search-txt.d.ts +22 -0
  136. package/dist/utils/search-txt.js +17 -0
  137. package/dist/values/success-messages.d.ts +0 -4
  138. package/dist/values/success-messages.js +0 -5
  139. package/package.json +1 -1
  140. package/scripts/seed-cli.mjs +292 -16
  141. package/dist/core/copilot-log.repository.d.ts +0 -44
  142. package/dist/core/copilot-log.repository.js +0 -61
  143. package/dist/features/copilot/components/AdminCopilotView.d.ts +0 -21
  144. package/dist/features/copilot/components/AdminCopilotView.js +0 -51
  145. package/dist/features/copilot/components/index.d.ts +0 -2
  146. package/dist/features/copilot/components/index.js +0 -1
  147. package/dist/features/copilot/hooks/useCopilotChat.d.ts +0 -25
  148. package/dist/features/copilot/hooks/useCopilotChat.js +0 -65
  149. package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +0 -15
  150. package/dist/features/copilot/hooks/useCopilotFeedback.js +0 -15
  151. package/dist/features/copilot/index.d.ts +0 -3
  152. 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 true loop. Do
35
- * not — a move is deliberately deferred to the nightly reconcile for this reason.
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
- if (beforeParent !== afterParent) {
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
- function getEffectiveCategory(doc) {
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 null;
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
- return doc.category ?? null;
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
- async function getParentIds(categoryId) {
24
- if (!categoryId)
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 (await categoriesRepository.findById(categoryId))?.parentIds ?? [];
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, beforeCategory, afterCategory, beforeBrand, afterBrand, beforeStoreId, afterStoreId, isAuction, beforeIsAuction, wasPublished, isPublished, isDelete, ctx } = p;
50
- /** Stage one category (and, if given, one brand) at `sign` × 1 item. */
51
- const stage = (batch, categoryId, brandId, parentIds, auction, sign) => {
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 (categoryId) {
55
- categoriesRepository.updateMetricsInBatch(batch, categoryId, parentIds, productDelta, auctionDelta, productId);
56
- }
57
- if (brandId) {
58
- categoriesRepository.updateMetricsInBatch(batch, brandId, [], productDelta, auctionDelta, productId);
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 && (beforeCategory || beforeBrand)) {
110
+ if (isDelete && wasPublished && (beforeCategories.length > 0 || beforeBrand)) {
75
111
  const batch = ctx.db.batch();
76
- stage(batch, beforeCategory, beforeBrand, await getParentIds(beforeCategory ?? ""), beforeIsAuction, -1);
77
- await commit(batch, "Decremented counters on hard-delete", { category: beforeCategory, brand: beforeBrand, storeId: beforeStoreId });
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 && (afterCategory || afterBrand)) {
118
+ if (!wasPublished && isPublished && (afterCategories.length > 0 || afterBrand)) {
83
119
  const batch = ctx.db.batch();
84
- stage(batch, afterCategory, afterBrand, await getParentIds(afterCategory ?? ""), isAuction, 1);
85
- await commit(batch, "Incremented counters on publish", { category: afterCategory, brand: afterBrand, storeId: afterStoreId });
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 && (beforeCategory || beforeBrand)) {
126
+ if (wasPublished && !isPublished && (beforeCategories.length > 0 || beforeBrand)) {
91
127
  const batch = ctx.db.batch();
92
- stage(batch, beforeCategory, beforeBrand, await getParentIds(beforeCategory ?? ""), beforeIsAuction, -1);
93
- await commit(batch, "Decremented counters on unpublish", { category: beforeCategory, brand: beforeBrand, storeId: beforeStoreId });
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 = beforeCategory !== afterCategory;
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
- if (afterCategory) {
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
- getParentIds(categoryMoved ? beforeCategory ?? "" : ""),
147
- getParentIds(categoryMoved ? afterCategory ?? "" : ""),
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, categoryMoved ? beforeCategory : null, brandMoved ? beforeBrand : null, beforeParents, beforeIsAuction, -1);
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
- fromCategory: categoryMoved ? beforeCategory : undefined,
154
- toCategory: categoryMoved ? afterCategory : undefined,
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 beforeCategory = getEffectiveCategory(before);
199
- const afterCategory = getEffectiveCategory(after);
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, beforeCategory, afterCategory, beforeBrand, afterBrand,
291
+ productId, beforeCategories, afterCategories, beforeBrand, afterBrand,
242
292
  beforeStoreId, afterStoreId,
243
293
  isAuction, beforeIsAuction, wasPublished, isPublished, isDelete, ctx,
244
294
  });
@@ -0,0 +1,2 @@
1
+ import type { JobContext } from "../runtime/types";
2
+ export declare function runProductViewWindows(ctx: JobContext): Promise<void>;
@@ -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", "offers"];
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
- export const TEST_DATA_COLLECTIONS = ["categories", "stores", "products", "blogPosts", "events", "offers"];
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
- export const pageViewRollupHandler = runPageViewRollup;
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",