@mohasinac/appkit 4.45.0 → 4.47.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 (160) 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/BreadcrumbTrail.d.ts +24 -1
  55. package/dist/features/categories/components/BreadcrumbTrail.js +45 -4
  56. package/dist/features/categories/components/CategoryDetailPageView.js +89 -14
  57. package/dist/features/categories/components/CategoryTemplatePicker.js +9 -1
  58. package/dist/features/categories/repository/categories.repository.d.ts +65 -0
  59. package/dist/features/categories/repository/categories.repository.js +168 -22
  60. package/dist/features/categories/repository/category-counts.d.ts +143 -0
  61. package/dist/features/categories/repository/category-counts.js +224 -0
  62. package/dist/features/categories/schemas/category-counts.d.ts +155 -0
  63. package/dist/features/categories/schemas/category-counts.js +116 -0
  64. package/dist/features/categories/schemas/firestore.d.ts +0 -2
  65. package/dist/features/categories/schemas/firestore.js +0 -1
  66. package/dist/features/categories/utils/breadcrumb-chain.d.ts +194 -0
  67. package/dist/features/categories/utils/breadcrumb-chain.js +202 -0
  68. package/dist/features/corporate/schemas/index.d.ts +4 -4
  69. package/dist/features/orders/schemas/firestore.d.ts +13 -0
  70. package/dist/features/orders/types/index.d.ts +30 -0
  71. package/dist/features/parts/components/AssemblyDrawer.d.ts +107 -0
  72. package/dist/features/parts/components/AssemblyDrawer.js +250 -0
  73. package/dist/features/parts/components/ModelCompositionPanel.d.ts +37 -0
  74. package/dist/features/parts/components/ModelCompositionPanel.js +34 -0
  75. package/dist/features/parts/components/PartDetailPageView.d.ts +48 -0
  76. package/dist/features/parts/components/PartDetailPageView.js +20 -0
  77. package/dist/features/parts/components/PartsIndexPageView.d.ts +33 -0
  78. package/dist/features/parts/components/PartsIndexPageView.js +17 -0
  79. package/dist/features/parts/schemas/firestore.d.ts +257 -0
  80. package/dist/features/parts/schemas/firestore.js +181 -0
  81. package/dist/features/parts/utils/colourway-match.d.ts +62 -0
  82. package/dist/features/parts/utils/colourway-match.js +98 -0
  83. package/dist/features/parts/utils/part-breakdown.d.ts +53 -0
  84. package/dist/features/parts/utils/part-breakdown.js +77 -0
  85. package/dist/features/products/components/ProductDetailPageView.d.ts +14 -1
  86. package/dist/features/products/components/ProductDetailPageView.js +22 -3
  87. package/dist/features/products/components/ProductTabsShell.d.ts +3 -1
  88. package/dist/features/products/components/ProductTabsShell.js +4 -1
  89. package/dist/features/products/repository/category-assignment.d.ts +132 -0
  90. package/dist/features/products/repository/category-assignment.js +304 -0
  91. package/dist/features/products/repository/products.repository.d.ts +58 -1
  92. package/dist/features/products/repository/products.repository.js +255 -28
  93. package/dist/features/products/schemas/firestore.d.ts +147 -6
  94. package/dist/features/products/schemas/firestore.js +49 -0
  95. package/dist/features/search/components/SearchResultsPageView.d.ts +33 -0
  96. package/dist/features/search/components/SearchResultsPageView.js +21 -0
  97. package/dist/features/seller/components/CategoryInlineSelect.d.ts +17 -1
  98. package/dist/features/seller/components/CategoryInlineSelect.js +14 -3
  99. package/dist/features/seller/components/SellerProductShell.d.ts +26 -1
  100. package/dist/features/seller/components/SellerProductShell.js +30 -1
  101. package/dist/features/seller/components/SellerProductsView.js +129 -3
  102. package/dist/features/tester/seed-data/authored/admin__site-system.js +0 -15
  103. package/dist/features/tester/seed-data/authored/content-discovery__parts-database.d.ts +2 -0
  104. package/dist/features/tester/seed-data/authored/content-discovery__parts-database.js +166 -0
  105. package/dist/features/tester/seed-data/authored/index.js +2 -0
  106. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +28 -1
  107. package/dist/index.d.ts +11 -16
  108. package/dist/index.js +26 -18
  109. package/dist/next/routing/route-map.d.ts +6 -2
  110. package/dist/next/routing/route-map.js +14 -1
  111. package/dist/providers/db-firebase/base.repository.d.ts +31 -0
  112. package/dist/providers/db-firebase/base.repository.js +31 -0
  113. package/dist/repositories/index.d.ts +1 -2
  114. package/dist/repositories/index.js +0 -1
  115. package/dist/seed/_helpers/category-content-generated.d.ts +24 -0
  116. package/dist/seed/_helpers/category-content-generated.js +1837 -0
  117. package/dist/seed/_helpers/category-content.d.ts +29 -0
  118. package/dist/seed/_helpers/category-content.js +98 -1
  119. package/dist/seed/_helpers/category-forest.js +104 -24
  120. package/dist/seed/_helpers/category-parts.d.ts +9 -0
  121. package/dist/seed/_helpers/category-parts.js +4594 -0
  122. package/dist/seed/carousel-slides-seed-data.js +81 -8
  123. package/dist/seed/categories-seed-data.js +44 -6
  124. package/dist/seed/homepage-sections-seed-data.js +170 -9
  125. package/dist/seed/index.d.ts +2 -0
  126. package/dist/seed/index.js +13 -0
  127. package/dist/seed/model-composition-seed-data.d.ts +15 -0
  128. package/dist/seed/model-composition-seed-data.js +8918 -0
  129. package/dist/seed/part-glossary-seed-data.d.ts +15 -0
  130. package/dist/seed/part-glossary-seed-data.js +10729 -0
  131. package/dist/seed/products-standard-seed-data.js +52 -0
  132. package/dist/seed/seed-purpose.d.ts +103 -4
  133. package/dist/seed/seed-purpose.js +162 -11
  134. package/dist/seo/json-ld.d.ts +10 -1
  135. package/dist/seo/json-ld.js +9 -1
  136. package/dist/server.d.ts +7 -0
  137. package/dist/server.js +11 -0
  138. package/dist/styles.css +1 -1
  139. package/dist/tailwind-utilities.css +1 -1
  140. package/dist/utils/id-generators.d.ts +30 -0
  141. package/dist/utils/id-generators.js +31 -1
  142. package/dist/utils/search-txt-builders.js +22 -0
  143. package/dist/utils/search-txt.d.ts +22 -0
  144. package/dist/utils/search-txt.js +17 -0
  145. package/dist/values/success-messages.d.ts +0 -4
  146. package/dist/values/success-messages.js +0 -5
  147. package/package.json +1 -1
  148. package/scripts/seed-cli.mjs +292 -16
  149. package/dist/core/copilot-log.repository.d.ts +0 -44
  150. package/dist/core/copilot-log.repository.js +0 -61
  151. package/dist/features/copilot/components/AdminCopilotView.d.ts +0 -21
  152. package/dist/features/copilot/components/AdminCopilotView.js +0 -51
  153. package/dist/features/copilot/components/index.d.ts +0 -2
  154. package/dist/features/copilot/components/index.js +0 -1
  155. package/dist/features/copilot/hooks/useCopilotChat.d.ts +0 -25
  156. package/dist/features/copilot/hooks/useCopilotChat.js +0 -65
  157. package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +0 -15
  158. package/dist/features/copilot/hooks/useCopilotFeedback.js +0 -15
  159. package/dist/features/copilot/index.d.ts +0 -3
  160. package/dist/features/copilot/index.js +0 -3
@@ -0,0 +1,70 @@
1
+ /**
2
+ * categoryTaxonomyBackfill — re-derive a moved or renamed category's subtree.
3
+ *
4
+ * ── 🛑 THE GAP THIS CLOSES, measured 2026-10-13 ────────────────────────────
5
+ *
6
+ * A category RENAME did nothing at all. `PUT /api/admin/categories/[id]` calls
7
+ * `categoriesRepository.update(id, fields)` and stops, so after renaming a node:
8
+ *
9
+ * - every product under it keeps the old `categoryNames`, and that array is
10
+ * what `buildProductSearchTxt` tokenises — so the listing stops being
11
+ * findable by its own category's NEW name and stays findable by the old
12
+ * one. Both halves are wrong and neither errors;
13
+ * - every DESCENDANT category keeps the old name inside its `ancestors[]`,
14
+ * and `buildCategorySearchTxt` indexes those deliberately ("typing burst
15
+ * reaches category-lost-longinus because Beyblade Burst is one of its
16
+ * ancestors") — so the subtree's own search breaks too.
17
+ *
18
+ * A REPARENT was already half-handled: `moveCategory` -> `reparentSubtree` ->
19
+ * `rewriteProductChains` does re-derive the chains. 🛑 But it runs UNBOUNDED and
20
+ * SYNCHRONOUSLY inside the request — one `.get()` over every product in the
21
+ * subtree plus batched writes, against a 10s route ceiling (Rule #6) — and it
22
+ * nets `metrics.*` only, never `categoryCounts`. So the LIVE count was stale
23
+ * until the nightly recount while the grid was already correct, which produces
24
+ * the worst symptom of the pair: a page reading "54 listings" above an empty
25
+ * grid. A disagreement between two surfaces is worse than either being wrong.
26
+ *
27
+ * ── Why a JOB and not the trigger body ────────────────────────────────────
28
+ *
29
+ * Reparenting `category-beyblade-burst` touches hundreds of products. A trigger
30
+ * is at-least-once, and `FieldValue.increment` is atomic but NOT idempotent —
31
+ * which is exactly why counting lives in the repository rather than in a
32
+ * trigger (Root Cause #92's neighbourhood).
33
+ *
34
+ * 🛑 A RE-DERIVE, by contrast, IS idempotent: it is a pure function of the live
35
+ * tree, so running it twice writes the same values and the second run performs
36
+ * zero writes. That property is the whole licence for a trigger to start it.
37
+ *
38
+ * ── 🛑 THE ISR COST, stated rather than discovered ────────────────────────
39
+ *
40
+ * Every product write fires `onProductWrite` -> `notifyRevalidate`, and this
41
+ * project was paused with ISR writes at 3.1M against a 200K cap (15.5x). A
42
+ * rename of one tier-2 node could therefore drop hundreds of ISR entries.
43
+ * `productCacheFieldsChanged` already exists as the gate for exactly this
44
+ * question, and `categoryNames` IS a cache field — a renamed category must
45
+ * reach the rendered card, so the revalidation is correct rather than wasteful.
46
+ * What would be wasteful is revalidating when only the ancestor IDS moved, which
47
+ * no card renders; that case writes `categorySlugs` and nothing else.
48
+ *
49
+ * EXPORTS: runCategoryTaxonomyBackfill, CategoryTaxonomyBackfillPayload
50
+ *
51
+ * @tag domain:categories
52
+ * @tag layer:job
53
+ * @tag pattern:none
54
+ * @tag access:server-only
55
+ * @tag consumers:JOB_RUNNERS via onJobCreated
56
+ * @tag sideEffects:rewrites products.categorySlugs/categoryNames/searchTxt and categories.ancestors/searchTxt
57
+ */
58
+ import type { JobContext } from "../runtime/types";
59
+ import type { JobRunResult } from "./jobRunners";
60
+ export interface CategoryTaxonomyBackfillPayload {
61
+ /** The node that moved or was renamed. */
62
+ categoryId: string;
63
+ /**
64
+ * What changed. Both re-derive the product closure; only `"rename"` also
65
+ * rewrites the subtree's own `ancestors[]`, because only a rename changes a
66
+ * name a descendant has cached.
67
+ */
68
+ kind: "rename" | "reparent";
69
+ }
70
+ export declare function runCategoryTaxonomyBackfill(payload: CategoryTaxonomyBackfillPayload, ctx: JobContext): Promise<JobRunResult>;
@@ -0,0 +1,302 @@
1
+ /**
2
+ * categoryTaxonomyBackfill — re-derive a moved or renamed category's subtree.
3
+ *
4
+ * ── 🛑 THE GAP THIS CLOSES, measured 2026-10-13 ────────────────────────────
5
+ *
6
+ * A category RENAME did nothing at all. `PUT /api/admin/categories/[id]` calls
7
+ * `categoriesRepository.update(id, fields)` and stops, so after renaming a node:
8
+ *
9
+ * - every product under it keeps the old `categoryNames`, and that array is
10
+ * what `buildProductSearchTxt` tokenises — so the listing stops being
11
+ * findable by its own category's NEW name and stays findable by the old
12
+ * one. Both halves are wrong and neither errors;
13
+ * - every DESCENDANT category keeps the old name inside its `ancestors[]`,
14
+ * and `buildCategorySearchTxt` indexes those deliberately ("typing burst
15
+ * reaches category-lost-longinus because Beyblade Burst is one of its
16
+ * ancestors") — so the subtree's own search breaks too.
17
+ *
18
+ * A REPARENT was already half-handled: `moveCategory` -> `reparentSubtree` ->
19
+ * `rewriteProductChains` does re-derive the chains. 🛑 But it runs UNBOUNDED and
20
+ * SYNCHRONOUSLY inside the request — one `.get()` over every product in the
21
+ * subtree plus batched writes, against a 10s route ceiling (Rule #6) — and it
22
+ * nets `metrics.*` only, never `categoryCounts`. So the LIVE count was stale
23
+ * until the nightly recount while the grid was already correct, which produces
24
+ * the worst symptom of the pair: a page reading "54 listings" above an empty
25
+ * grid. A disagreement between two surfaces is worse than either being wrong.
26
+ *
27
+ * ── Why a JOB and not the trigger body ────────────────────────────────────
28
+ *
29
+ * Reparenting `category-beyblade-burst` touches hundreds of products. A trigger
30
+ * is at-least-once, and `FieldValue.increment` is atomic but NOT idempotent —
31
+ * which is exactly why counting lives in the repository rather than in a
32
+ * trigger (Root Cause #92's neighbourhood).
33
+ *
34
+ * 🛑 A RE-DERIVE, by contrast, IS idempotent: it is a pure function of the live
35
+ * tree, so running it twice writes the same values and the second run performs
36
+ * zero writes. That property is the whole licence for a trigger to start it.
37
+ *
38
+ * ── 🛑 THE ISR COST, stated rather than discovered ────────────────────────
39
+ *
40
+ * Every product write fires `onProductWrite` -> `notifyRevalidate`, and this
41
+ * project was paused with ISR writes at 3.1M against a 200K cap (15.5x). A
42
+ * rename of one tier-2 node could therefore drop hundreds of ISR entries.
43
+ * `productCacheFieldsChanged` already exists as the gate for exactly this
44
+ * question, and `categoryNames` IS a cache field — a renamed category must
45
+ * reach the rendered card, so the revalidation is correct rather than wasteful.
46
+ * What would be wasteful is revalidating when only the ancestor IDS moved, which
47
+ * no card renders; that case writes `categorySlugs` and nothing else.
48
+ *
49
+ * EXPORTS: runCategoryTaxonomyBackfill, CategoryTaxonomyBackfillPayload
50
+ *
51
+ * @tag domain:categories
52
+ * @tag layer:job
53
+ * @tag pattern:none
54
+ * @tag access:server-only
55
+ * @tag consumers:JOB_RUNNERS via onJobCreated
56
+ * @tag sideEffects:rewrites products.categorySlugs/categoryNames/searchTxt and categories.ancestors/searchTxt
57
+ */
58
+ import { categoriesRepository } from "../../../../features/categories/repository/categories.repository.js";
59
+ import { PRODUCT_COLLECTION } from "../../../../features/products/schemas/firestore.js";
60
+ import { PRODUCT_FIELDS } from "../../../../constants/field-names.js";
61
+ import { buildProductSearchTxt } from "../../../../utils/search-txt-builders.js";
62
+ import { normalizeError } from "../../../../errors/normalize.js";
63
+ /** Firestore caps a batched write at 500 operations. */
64
+ const BATCH_LIMIT = 400;
65
+ /**
66
+ * One page of products per read.
67
+ *
68
+ * 🛑 A bound is the point of this job existing — `rewriteProductChains` has
69
+ * none, which is what makes it unsafe inside a request. Paged by document id so
70
+ * the cursor needs no index.
71
+ */
72
+ const PAGE_SIZE = 200;
73
+ /**
74
+ * A hard ceiling on how much one run will touch.
75
+ *
76
+ * Saturating it is REPORTED and the run stops rather than silently truncating:
77
+ * a half-backfilled subtree is a set of products that disagree with their own
78
+ * tree, and `countersReconcile`'s `drifted` is the signal that says so. The
79
+ * alternative — carrying on unbounded — is what the 540s function budget
80
+ * cannot promise.
81
+ */
82
+ const MAX_PRODUCTS = 20000;
83
+ /**
84
+ * Commit one page of product rewrites, chunked at Firestore's batch limit.
85
+ *
86
+ * Extracted because inline it reached brace-depth 6 and `audit-code-quality`
87
+ * refuses that — correctly: the loop over a page, the chunking, and the
88
+ * per-chunk failure handling are three separate concerns that read better apart.
89
+ *
90
+ * 🛑 A failed chunk is recorded per ROW rather than aborting the run. The rest
91
+ * of the subtree is still worth fixing, and a partially-backfilled subtree is
92
+ * exactly what `countersReconcile` reports as drift — so the failure is visible
93
+ * either way, and stopping would make it larger.
94
+ */
95
+ async function commitProductWrites(ctx, categoryId, writes, succeeded, failed) {
96
+ for (let i = 0; i < writes.length; i += BATCH_LIMIT) {
97
+ const chunk = writes.slice(i, i + BATCH_LIMIT);
98
+ const batch = ctx.db.batch();
99
+ for (const w of chunk) {
100
+ batch.update(ctx.db.collection(PRODUCT_COLLECTION).doc(w.id), {
101
+ ...w.data,
102
+ updatedAt: new Date(),
103
+ });
104
+ }
105
+ try {
106
+ await batch.commit();
107
+ succeeded.push(...chunk.map((w) => w.id));
108
+ }
109
+ catch (err) {
110
+ const e = normalizeError(err);
111
+ for (const w of chunk)
112
+ failed.push({ id: w.id, reason: e.message });
113
+ ctx.logger.error("categoryTaxonomyBackfill: batch failed", err, { categoryId });
114
+ }
115
+ }
116
+ }
117
+ export async function runCategoryTaxonomyBackfill(payload, ctx) {
118
+ const { categoryId, kind } = payload;
119
+ const empty = {
120
+ summary: { total: 0, succeeded: 0, skipped: 0, failed: 0 },
121
+ succeeded: [],
122
+ skipped: [],
123
+ failed: [],
124
+ };
125
+ if (!categoryId) {
126
+ return {
127
+ ...empty,
128
+ failed: [{ id: "-", reason: "categoryId is required" }],
129
+ summary: { total: 0, succeeded: 0, skipped: 0, failed: 1 },
130
+ };
131
+ }
132
+ const moved = await categoriesRepository.findById(categoryId);
133
+ if (!moved) {
134
+ /*
135
+ * Deleted between the write and this run. Not a fault: the delete route
136
+ * cascades products to the parent itself, so there is nothing here to fix.
137
+ */
138
+ ctx.logger.warn("categoryTaxonomyBackfill: category not found", { categoryId });
139
+ return { ...empty, skipped: [categoryId], summary: { total: 0, succeeded: 0, skipped: 1, failed: 0 } };
140
+ }
141
+ /*
142
+ * The whole subtree, in one read. `getDescendants` is a single
143
+ * `array-contains` over `parentIds` — which holds the FULL ancestor chain —
144
+ * so depth costs nothing extra.
145
+ */
146
+ const descendants = await categoriesRepository.getDescendants(categoryId);
147
+ const subtree = new Map([moved, ...descendants].map((c) => [c.id, c]));
148
+ const succeeded = [];
149
+ const skipped = [];
150
+ const failed = [];
151
+ /* ── 1. the subtree's own `ancestors[]`, on a rename only ──────────────── */
152
+ if (kind === "rename") {
153
+ try {
154
+ const refreshed = await categoriesRepository.refreshSubtreeAncestors(categoryId);
155
+ succeeded.push(...refreshed.map((id) => `category:${id}`));
156
+ }
157
+ catch (err) {
158
+ const e = normalizeError(err);
159
+ failed.push({ id: `category:${categoryId}`, reason: e.message });
160
+ ctx.logger.error("categoryTaxonomyBackfill: ancestor refresh failed", err, { categoryId });
161
+ }
162
+ }
163
+ /* ── 2. every product whose closure contains the moved node ────────────── */
164
+ let scanned = 0;
165
+ let cursor = null;
166
+ let truncated = false;
167
+ for (;;) {
168
+ let q = ctx.db
169
+ .collection(PRODUCT_COLLECTION)
170
+ .where(PRODUCT_FIELDS.CATEGORY_SLUGS, "array-contains", categoryId)
171
+ .orderBy("__name__")
172
+ .limit(PAGE_SIZE);
173
+ if (cursor)
174
+ q = q.startAfter(cursor);
175
+ const page = await q.get();
176
+ if (page.empty)
177
+ break;
178
+ const writes = [];
179
+ for (const doc of page.docs) {
180
+ scanned += 1;
181
+ if (scanned > MAX_PRODUCTS) {
182
+ truncated = true;
183
+ break;
184
+ }
185
+ const data = doc.data();
186
+ /*
187
+ * Read the ASSIGNED list the same way every other consumer does —
188
+ * `categoryIds`, then the closure head, then the scalar. A different
189
+ * selection here would re-derive a chain from a leaf nothing else
190
+ * considers the leaf.
191
+ */
192
+ const assignments = Array.isArray(data.categoryIds) && data.categoryIds.length > 0
193
+ ? data.categoryIds
194
+ : [((Array.isArray(data.categorySlugs) && data.categorySlugs[0]) || data.category || "")].filter(Boolean);
195
+ /*
196
+ * 🛑 Resolve each assignment against the LIVE tree, falling back to the
197
+ * subtree map we already hold. An assignment OUTSIDE the moved subtree is
198
+ * unaffected by the move but still contributes its chain to the closure,
199
+ * so it has to be resolved too — dropping it would shorten the closure
200
+ * and un-list the product from a category it is genuinely in.
201
+ */
202
+ const resolved = [];
203
+ let unresolved = false;
204
+ for (const id of assignments) {
205
+ const cat = subtree.get(id) ?? (await categoriesRepository.findById(id));
206
+ if (!cat) {
207
+ unresolved = true;
208
+ continue;
209
+ }
210
+ resolved.push({
211
+ id,
212
+ parentIds: cat.parentIds ?? [],
213
+ name: cat.name,
214
+ ancestorNames: (cat.ancestors ?? []).map((a) => a?.name ?? ""),
215
+ });
216
+ }
217
+ if (resolved.length === 0) {
218
+ // Never blank a chain we cannot improve on.
219
+ skipped.push(doc.id);
220
+ continue;
221
+ }
222
+ /*
223
+ * The default is the DEEPEST assignment, and the tie-break is deliberately
224
+ * NOT consulted here: reading counts per product would be one extra read
225
+ * per row on a job that already pages thousands. A tie therefore falls to
226
+ * the stored order, which is the order `deriveTaxonomy` already chose —
227
+ * so a backfill never REORDERS a product's closure, it only repairs the
228
+ * ids and names inside it.
229
+ */
230
+ const deepest = resolved.reduce((best, c) => {
231
+ const tier = (subtree.get(c.id)?.tier ?? -1);
232
+ const bestTier = (subtree.get(best.id)?.tier ?? -1);
233
+ return tier > bestTier ? c : best;
234
+ }, resolved[0]);
235
+ const ordered = [deepest, ...resolved.filter((c) => c.id !== deepest.id)];
236
+ const categorySlugs = [];
237
+ const seen = new Set();
238
+ for (const c of ordered) {
239
+ for (const id of [c.id, ...c.parentIds.slice().reverse()]) {
240
+ if (!id || seen.has(id))
241
+ continue;
242
+ seen.add(id);
243
+ categorySlugs.push(id);
244
+ }
245
+ }
246
+ const categoryNames = [deepest.name, ...deepest.ancestorNames.slice().reverse()].filter((n) => !!n);
247
+ const slugsSame = (data.categorySlugs ?? []).join(">") === categorySlugs.join(">");
248
+ const namesSame = (data.categoryNames ?? []).join(">") === categoryNames.join(">");
249
+ if (slugsSame && namesSame) {
250
+ // Idempotent: a synced row costs no write. This is what makes an
251
+ // at-least-once trigger safe to start the job.
252
+ skipped.push(doc.id);
253
+ continue;
254
+ }
255
+ /*
256
+ * 🛑 `searchTxt` is rebuilt whenever the NAMES moved, and that is the half
257
+ * the reparent path never did. `buildProductSearchTxt` tokenises
258
+ * `categoryNames`, so a rename that stopped there would leave every
259
+ * product in the subtree findable by the old name and not the new one.
260
+ */
261
+ const next = { categorySlugs, categoryNames };
262
+ if (!namesSame) {
263
+ next.searchTxt = buildProductSearchTxt({ ...data, categoryNames });
264
+ }
265
+ writes.push({ id: doc.id, data: next });
266
+ }
267
+ if (writes.length > 0) {
268
+ await commitProductWrites(ctx, categoryId, writes, succeeded, failed);
269
+ }
270
+ if (truncated)
271
+ break;
272
+ cursor = page.docs[page.docs.length - 1] ?? null;
273
+ if (page.size < PAGE_SIZE)
274
+ break;
275
+ }
276
+ if (truncated) {
277
+ failed.push({
278
+ id: categoryId,
279
+ reason: `Stopped at the ${MAX_PRODUCTS}-product ceiling. The rest of the subtree still carries a stale chain — re-run, and expect countersReconcile to report drift.`,
280
+ });
281
+ }
282
+ ctx.logger.info("categoryTaxonomyBackfill: complete", {
283
+ categoryId,
284
+ kind,
285
+ subtreeSize: subtree.size,
286
+ productsScanned: scanned,
287
+ productsWritten: succeeded.filter((id) => !id.startsWith("category:")).length,
288
+ productsSkipped: skipped.length,
289
+ truncated,
290
+ });
291
+ return {
292
+ summary: {
293
+ total: succeeded.length + skipped.length + failed.length,
294
+ succeeded: succeeded.length,
295
+ skipped: skipped.length,
296
+ failed: failed.length,
297
+ },
298
+ succeeded,
299
+ skipped,
300
+ failed,
301
+ };
302
+ }
@@ -2,6 +2,7 @@ import { normalizeError } from "../../../../errors/normalize.js";
2
2
  import { categoriesRepository, reviewRepository, storeRepository, } from "../../../../repositories/index.js";
3
3
  import { ProductStatusValues, PRODUCT_COLLECTION } from "../../../../features/products/schemas/firestore.js";
4
4
  import { CATEGORIES_COLLECTION } from "../../../../features/categories/schemas/firestore.js";
5
+ import { CATEGORY_COUNTS_COLLECTION } from "../../../../features/categories/schemas/category-counts.js";
5
6
  import { ORDER_FIELDS, PRODUCT_FIELDS } from "../../../../constants/field-names.js";
6
7
  import { isListingRowAvailable } from "../../../shared/listing-types/_registry.js";
7
8
  import { QUERY_LIMIT } from "../handlers/messages.js";
@@ -14,22 +15,36 @@ const emptyTally = () => ({
14
15
  auctionIds: [],
15
16
  totalProducts: 0,
16
17
  totalAuctions: 0,
18
+ own: {},
19
+ total: {},
20
+ lotsOwn: 0,
21
+ lotsTotal: 0,
17
22
  });
18
23
  /** Filed DIRECTLY under this row — `metrics.productCount` / `auctionCount`. */
19
- function countOwn(t, productId, isAuction) {
24
+ function countOwn(t, productId, isAuction, listingType, isLot) {
20
25
  if (isAuction)
21
26
  t.auctionIds.push(productId);
22
27
  else
23
28
  t.productIds.push(productId);
29
+ // G3b: lots are counted SEPARATELY from singles, which is what lets a leaf's
30
+ // own count mean one thing wherever a lot is filed.
31
+ if (isLot)
32
+ t.lotsOwn++;
33
+ else
34
+ t.own[listingType] = (t.own[listingType] ?? 0) + 1;
24
35
  }
25
36
  /** This row or any ancestor — `metrics.totalProductCount` / `totalAuctionCount`. */
26
- function countRollup(t, isAuction) {
37
+ function countRollup(t, isAuction, listingType, isLot) {
27
38
  if (!t)
28
39
  return;
29
40
  if (isAuction)
30
41
  t.totalAuctions++;
31
42
  else
32
43
  t.totalProducts++;
44
+ if (isLot)
45
+ t.lotsTotal++;
46
+ else
47
+ t.total[listingType] = (t.total[listingType] ?? 0) + 1;
33
48
  }
34
49
  /**
35
50
  * Fold one published product into the tallies.
@@ -41,6 +56,11 @@ function countRollup(t, isAuction) {
41
56
  */
42
57
  function tallyProduct(productId, data, tallies, parentsOf) {
43
58
  const isAuction = data.listingType === PRODUCT_FIELDS.LISTING_TYPE_VALUES.AUCTION;
59
+ // G3b. Default to "standard" for a row written before `listingType` was
60
+ // required — the same default `describeCountable` applies, so the nightly
61
+ // and the incremental value land on the same key rather than on two.
62
+ const listingType = data.listingType || "standard";
63
+ const isLot = data.isLot === true;
44
64
  /*
45
65
  * Count only what a visitor can actually see. A published-but-sold, ended,
46
66
  * closed or code-depleted row is excluded here for the same reason the listing
@@ -48,29 +68,59 @@ function tallyProduct(productId, data, tallies, parentsOf) {
48
68
  */
49
69
  if (!isListingRowAvailable(data, new Date()))
50
70
  return false;
51
- // `category` is @deprecated in favour of `categorySlugs[]`; mirror the live
52
- // trigger's selection exactly so the two agree (onProductWrite.ts).
53
- const leaf = (Array.isArray(data.categorySlugs) && data.categorySlugs[0]) ||
54
- data.category ||
55
- null;
71
+ /*
72
+ * I3 — the ASSIGNED categories. `categoryIds` first, falling back to the old
73
+ * single-leaf selection for every row written before I1 added the field.
74
+ *
75
+ * 🛑 This selection must stay byte-identical to `describeCountable`'s. Two
76
+ * different ones drift silently, and the drift is invisible in the worst
77
+ * possible way: this job RECOUNTS FROM SCRATCH and overwrites, so it would
78
+ * "correct" a count the live trigger had right.
79
+ */
80
+ const owns = Array.isArray(data.categoryIds) && data.categoryIds.length > 0
81
+ ? data.categoryIds.filter(Boolean)
82
+ : [((Array.isArray(data.categorySlugs) && data.categorySlugs[0]) || data.category || "")].filter(Boolean);
56
83
  let unknown = false;
57
- if (leaf && !tallies.has(leaf)) {
58
- unknown = true;
59
- }
60
- else if (leaf) {
61
- countOwn(tallies.get(leaf), productId, isAuction);
62
- // Roll up through self + every ancestor. `parentIds` already holds the
63
- // WHOLE chain, so this is one pass rather than a walk.
64
- for (const id of [leaf, ...(parentsOf.get(leaf) ?? [])]) {
65
- countRollup(tallies.get(id), isAuction);
84
+ /*
85
+ * ✅ Ancestors come from `parentsOf` — the LIVE tree, seeded from every
86
+ * category document — and NOT from the product's stored closure. Do not
87
+ * "simplify" that: it is what makes a reparent self-heal overnight. A version
88
+ * reading the stored chain would agree with a stale one, and then the drift
89
+ * this job exists to catch would be undetectable by it.
90
+ *
91
+ * 🛑 `own` bumps at EVERY assignment; `total` bumps once per distinct id
92
+ * across all their chains. Without the dedupe, two assignments sharing an
93
+ * ancestor would roll that ancestor up twice for one listing — which under
94
+ * one-per-root cannot happen, but this job reads whatever is in Firestore
95
+ * rather than whatever the write path allowed.
96
+ */
97
+ const rolledUp = new Set();
98
+ for (const own of owns) {
99
+ if (!tallies.has(own)) {
100
+ unknown = true;
101
+ continue;
102
+ }
103
+ countOwn(tallies.get(own), productId, isAuction, listingType, isLot);
104
+ for (const id of [own, ...(parentsOf.get(own) ?? [])]) {
105
+ if (rolledUp.has(id))
106
+ continue;
107
+ rolledUp.add(id);
108
+ countRollup(tallies.get(id), isAuction, listingType, isLot);
66
109
  }
67
110
  }
68
111
  // Brands are a flat dimension: a brand row has no product children of its own
69
112
  // beyond what points at it, so own === total by construction — which is why
70
113
  // it is counted on both axes at once.
71
- if (data.brandSlug && tallies.has(data.brandSlug)) {
72
- countOwn(tallies.get(data.brandSlug), productId, isAuction);
73
- countRollup(tallies.get(data.brandSlug), isAuction);
114
+ // 🛑 ...and never on the same row the chain already counted. If a leaf id were
115
+ // also a brand id, `countOwn` would fire twice for one listing. Today's
116
+ // `category-` / `brand-` prefixes make it impossible, which is precisely why
117
+ // it is guarded rather than trusted — the prefixes are a convention and this
118
+ // is an arithmetic invariant. Mirrors `describeCountable`'s own guard so the
119
+ // nightly and incremental values cannot disagree here.
120
+ const countedByChain = rolledUp;
121
+ if (data.brandSlug && !countedByChain.has(data.brandSlug) && tallies.has(data.brandSlug)) {
122
+ countOwn(tallies.get(data.brandSlug), productId, isAuction, listingType, isLot);
123
+ countRollup(tallies.get(data.brandSlug), isAuction, listingType, isLot);
74
124
  }
75
125
  return unknown;
76
126
  }
@@ -156,11 +206,66 @@ async function reconcileCategories(ctx) {
156
206
  if (page.size < PAGE_SIZE)
157
207
  break;
158
208
  }
209
+ /*
210
+ * G3b — the live `categoryCounts` documents, read so `drifted` stays an
211
+ * honest signal across BOTH stores of the count.
212
+ *
213
+ * 🛑 Read rather than blind-written. Writing only when `metrics` drifts would
214
+ * leave a counts document wrong forever in the narrow case where the
215
+ * product/auction aggregate happens to be right while a per-type split or a
216
+ * lot count is not — and that case is precisely what an incremental counter
217
+ * gets wrong, so it is the one the audit must see.
218
+ *
219
+ * One extra read per category row, once a night: 416 reads against a
220
+ * 50,000/day budget, under 1%.
221
+ */
222
+ const countsSnap = await ctx.db.collection(CATEGORY_COUNTS_COLLECTION).get();
223
+ const liveCounts = new Map();
224
+ for (const d of countsSnap.docs)
225
+ liveCounts.set(d.id, d.data());
226
+ /** Field-by-field, because a key present as 0 and a key absent are the same count. */
227
+ const sameMap = (a, b) => {
228
+ const keys = new Set([...Object.keys(a), ...Object.keys(b ?? {})]);
229
+ for (const k of keys)
230
+ if ((a[k] ?? 0) !== ((b ?? {})[k] ?? 0))
231
+ return false;
232
+ return true;
233
+ };
159
234
  let written = 0;
160
235
  let drifted = 0;
236
+ let countsWritten = 0;
161
237
  for (const doc of catSnap.docs) {
162
238
  const t = tallies.get(doc.id) ?? emptyTally();
163
239
  const m = doc.data().metrics ?? {};
240
+ const c = liveCounts.get(doc.id);
241
+ const countsSame = !!c &&
242
+ sameMap(t.own, c.own) &&
243
+ sameMap(t.total, c.total) &&
244
+ (c.lots?.own ?? 0) === t.lotsOwn &&
245
+ (c.lots?.total ?? 0) === t.lotsTotal;
246
+ if (!countsSame) {
247
+ try {
248
+ /*
249
+ * 🛑 A FULL OVERWRITE, not a merge. The reconciler is the authority and
250
+ * it enumerates the CATEGORIES rather than the products — which is what
251
+ * lets a row whose last listing was deleted reset to zero. Root Cause
252
+ * #102 is what a reconciler that could only ever add cost: 19 of 65
253
+ * rows wrong, every one low.
254
+ */
255
+ await ctx.db.collection(CATEGORY_COUNTS_COLLECTION).doc(doc.id).set({
256
+ id: doc.id,
257
+ own: t.own,
258
+ total: t.total,
259
+ lots: { own: t.lotsOwn, total: t.lotsTotal },
260
+ updatedAt: new Date(),
261
+ });
262
+ countsWritten++;
263
+ }
264
+ catch (err) {
265
+ void normalizeError(err);
266
+ ctx.logger.error(`[categories] categoryCounts write failed for ${doc.id}`, err);
267
+ }
268
+ }
164
269
  const same = (m.productCount ?? 0) === t.productIds.length &&
165
270
  (m.auctionCount ?? 0) === t.auctionIds.length &&
166
271
  (m.totalProductCount ?? 0) === t.totalProducts &&
@@ -193,6 +298,10 @@ async function reconcileCategories(ctx) {
193
298
  publishedProductsScanned: scanned,
194
299
  drifted,
195
300
  written,
301
+ // G3b — a non-zero value here on a quiet day means the repository's
302
+ // incremental counter missed something. That is the whole reason the
303
+ // nightly pass survives an incremental model.
304
+ countsWritten,
196
305
  unknownCategory,
197
306
  });
198
307
  }
@@ -15,6 +15,7 @@ import { runWhatsAppNotify } from "./whatsappNotify.js";
15
15
  import { runNewsletterExport } from "./newsletterExport.js";
16
16
  import { runWhatsAppCatalogImport } from "./whatsappCatalogImport.js";
17
17
  import { runStoreNameBackfill } from "./storeNameBackfill.js";
18
+ import { runCategoryTaxonomyBackfill } from "./categoryTaxonomyBackfill.js";
18
19
  async function runPayoutsWeeklyJob(_payload, ctx) {
19
20
  const { payoutsCreated, ordersProcessed, payoutIds } = await runWeeklyPayoutEligibility(ctx);
20
21
  return {
@@ -53,6 +54,16 @@ async function runWhatsAppCatalogImportJob(payload, ctx) {
53
54
  async function runStoreNameBackfillJob(payload, ctx) {
54
55
  return runStoreNameBackfill({ storeId: String(payload.storeId ?? "") }, ctx);
55
56
  }
57
+ async function runCategoryTaxonomyBackfillJob(payload, ctx) {
58
+ /*
59
+ * `kind` is narrowed here rather than cast. An unrecognised value falls to
60
+ * "reparent", which is the SAFE default: it re-derives the product closure
61
+ * and skips the subtree ancestor refresh. The reverse default would run a
62
+ * subtree-wide write for a move that changed no name.
63
+ */
64
+ const kind = payload.kind === "rename" ? "rename" : "reparent";
65
+ return runCategoryTaxonomyBackfill({ categoryId: String(payload.categoryId ?? ""), kind }, ctx);
66
+ }
56
67
  export const JOB_RUNNERS = {
57
68
  payoutsWeekly: runPayoutsWeeklyJob,
58
69
  hardBanCascade: runHardBanCascadeJob,
@@ -61,4 +72,5 @@ export const JOB_RUNNERS = {
61
72
  newsletterExport: runNewsletterExportJob,
62
73
  whatsappCatalogImport: runWhatsAppCatalogImportJob,
63
74
  storeNameBackfill: runStoreNameBackfillJob,
75
+ categoryTaxonomyBackfill: runCategoryTaxonomyBackfillJob,
64
76
  };