@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
@@ -115,7 +115,15 @@ export function CategoryTemplatePicker({ categoryId, condition, context, isDescr
115
115
  * accurate BEFORE the seller commits. A note that appears only after the
116
116
  * text lands is a report on something they can no longer choose about.
117
117
  */
118
- const filled = React.useMemo(() => (template ? interpolate(template.body, context) : null), [template, context]);
118
+ /*
119
+ * 🛑 Server slots merge UNDERNEATH the form's context, so a draft-derived
120
+ * value always wins. The two key spaces are disjoint today — the form knows
121
+ * the listing, the server knows the category — and this ordering is what
122
+ * keeps it safe if they ever overlap: the more specific source is the one
123
+ * holding the item in front of the seller.
124
+ */
125
+ const resolved = React.useMemo(() => ({ ...(data?.slots ?? {}), ...context }), [data?.slots, context]);
126
+ const filled = React.useMemo(() => (template ? interpolate(template.body, resolved) : null), [template, resolved]);
119
127
  /*
120
128
  * Auto-fill when the seller owns nothing. Deliberately an effect rather than
121
129
  * a render-time write: the body has to reach the draft, which is the
@@ -1,3 +1,4 @@
1
+ import { type CategoryCountsDocument } from "../schemas/category-counts";
1
2
  import { BaseRepository, type FirebaseSieveFields, type FirebaseSieveResult, type SieveModel } from "../../../providers/db-firebase";
2
3
  import { type CategoryCreateInput, type CategoryDocument, type CategoryMoveInput, type CategoryTreeNode } from "../schemas";
3
4
  import type { FirestoreDocument, JsonValue } from "@mohasinac/appkit";
@@ -157,6 +158,36 @@ export declare class CategoriesRepository extends BaseRepository<CategoryDocumen
157
158
  * The caller must have already fetched `parentIds` for the category.
158
159
  */
159
160
  updateMetricsInBatch(batch: import("firebase-admin/firestore").WriteBatch, categoryId: string, parentIds: string[], productDelta: number, auctionDelta: number, productId?: string): void;
161
+ /**
162
+ * I3 — the same live metric update, for a product filed under SEVERAL
163
+ * categories at once.
164
+ *
165
+ * 🛑 WHY THIS EXISTS RATHER THAN CALLING THE SINGLE VERSION IN A LOOP.
166
+ * **Firestore rejects a batch containing two writes to the same document.**
167
+ * Two assignments whose chains share an ancestor would each stage a
168
+ * `batch.update` on that ancestor, and the whole batch throws — losing every
169
+ * increment in it, not just the duplicate.
170
+ *
171
+ * Under the one-per-root rule the chains are disjoint and it cannot happen.
172
+ * That is not sufficient grounds to skip the aggregation: the rule is
173
+ * enforced at ONE write path (`deriveTaxonomy`), while this trigger fires for
174
+ * whatever reached Firestore — including a seeded row and the admin
175
+ * `.passthrough()` route, neither of which passes through it. Aggregating
176
+ * costs a Map; being wrong costs a silently dropped counter.
177
+ *
178
+ * So: one `batch.update` per DISTINCT document, with the own-deltas and the
179
+ * rollup-deltas summed per id first.
180
+ *
181
+ * 🛑 `own` lands on every assignment and `total` lands once per distinct id
182
+ * across all their chains — which is the arithmetic `CategoryCountsDocument`
183
+ * states: `total` already includes `own`, and the two are never summed.
184
+ */
185
+ updateMetricsForAssignmentsInBatch(batch: import("firebase-admin/firestore").WriteBatch, assignments: Array<{
186
+ id: string;
187
+ parentIds: string[];
188
+ }>, productDelta: number, auctionDelta: number, productId?: string,
189
+ /** A brand row: flat, so own and rollup move together. */
190
+ brandId?: string | null): void;
160
191
  /**
161
192
  * Cloud Functions: full-overwrite category metrics (nightly reconciliation).
162
193
  *
@@ -182,6 +213,19 @@ export declare class CategoriesRepository extends BaseRepository<CategoryDocumen
182
213
  * a true 65. Nothing errored, and no page looked broken; a wrong number is
183
214
  * simply a number.
184
215
  */
216
+ /**
217
+ * G3b — this category's OWN and ROLLUP counts, as one document read.
218
+ *
219
+ * 🛑 Returns `null` when no counts document exists, NEVER a zeroed object.
220
+ * An unknown count must stay distinguishable from a count of nothing (Root
221
+ * Cause #72), so the caller falls back to `metrics` and renders exactly as
222
+ * it did before — which is what makes `categoryCounts` additive rather than
223
+ * a regression on every category until its first product mutation.
224
+ *
225
+ * One doc by id: no index, no query, and safe inside a page already at Rule
226
+ * #6's round-trip budget provided the caller runs it in parallel.
227
+ */
228
+ getLiveCounts(categoryId: string): Promise<CategoryCountsDocument | null>;
185
229
  setMetrics(categoryId: string, m: {
186
230
  /** Items filed DIRECTLY under this category. */
187
231
  productCount: number;
@@ -206,6 +250,27 @@ export declare class CategoriesRepository extends BaseRepository<CategoryDocumen
206
250
  * Used by /api/sublisting-categories/[slug]/listings.
207
251
  */
208
252
  getSublistingListings(sublistingId: string, limit?: number): Promise<FirestoreDocument[]>;
253
+ /**
254
+ * I4 — re-cache a renamed node's name into every descendant's `ancestors[]`,
255
+ * and rebuild their `searchTxt`.
256
+ *
257
+ * 🛑 WHY A RENAME NEEDS THIS AT ALL. `ancestors[]` is `{id, name, tier}` —
258
+ * a denormalised copy of a name that lives on another document. Nothing
259
+ * re-synced it, so renaming "Beyblade Burst" left every node beneath it
260
+ * holding the old string, and `buildCategorySearchTxt` indexes those names
261
+ * deliberately: typing "burst" reaches `category-lost-longinus` BECAUSE
262
+ * Beyblade Burst is one of its ancestors. So a rename silently broke lineage
263
+ * search for the whole subtree while the node's own page looked perfect.
264
+ *
265
+ * Mirror-drift (Root Cause #42) on the taxonomy's own axis, and the same
266
+ * class as `storeName` on a product — which already has a backfill job for
267
+ * exactly this reason.
268
+ *
269
+ * Returns the ids actually written. A node already carrying the right names
270
+ * is skipped, so a re-run costs nothing — which is what lets an
271
+ * at-least-once trigger start the job that calls this.
272
+ */
273
+ refreshSubtreeAncestors(categoryId: string): Promise<string[]>;
209
274
  /**
210
275
  * SB-UNI-B — delete a sublisting category and unlink all products
211
276
  * that referenced it. Mirrors the cascade behavior of the old
@@ -1,4 +1,5 @@
1
1
  import { normalizeError } from "../../../errors/normalize.js";
2
+ import { CATEGORY_COUNTS_COLLECTION, } from "../schemas/category-counts.js";
2
3
  import { increment, arrayUnion, arrayRemove, } from "../../../contracts/field-ops.js";
3
4
  import { DatabaseError } from "../../../errors/index.js";
4
5
  import { BaseRepository, prepareForFirestore, parseSieveDateValue, applySieveToFirestore, } from "../../../providers/db-firebase/index.js";
@@ -701,35 +702,81 @@ export class CategoriesRepository extends BaseRepository {
701
702
  * The caller must have already fetched `parentIds` for the category.
702
703
  */
703
704
  updateMetricsInBatch(batch, categoryId, parentIds, productDelta, auctionDelta, productId) {
705
+ this.updateMetricsForAssignmentsInBatch(batch, [{ id: categoryId, parentIds }], productDelta, auctionDelta, productId);
706
+ }
707
+ /**
708
+ * I3 — the same live metric update, for a product filed under SEVERAL
709
+ * categories at once.
710
+ *
711
+ * 🛑 WHY THIS EXISTS RATHER THAN CALLING THE SINGLE VERSION IN A LOOP.
712
+ * **Firestore rejects a batch containing two writes to the same document.**
713
+ * Two assignments whose chains share an ancestor would each stage a
714
+ * `batch.update` on that ancestor, and the whole batch throws — losing every
715
+ * increment in it, not just the duplicate.
716
+ *
717
+ * Under the one-per-root rule the chains are disjoint and it cannot happen.
718
+ * That is not sufficient grounds to skip the aggregation: the rule is
719
+ * enforced at ONE write path (`deriveTaxonomy`), while this trigger fires for
720
+ * whatever reached Firestore — including a seeded row and the admin
721
+ * `.passthrough()` route, neither of which passes through it. Aggregating
722
+ * costs a Map; being wrong costs a silently dropped counter.
723
+ *
724
+ * So: one `batch.update` per DISTINCT document, with the own-deltas and the
725
+ * rollup-deltas summed per id first.
726
+ *
727
+ * 🛑 `own` lands on every assignment and `total` lands once per distinct id
728
+ * across all their chains — which is the arithmetic `CategoryCountsDocument`
729
+ * states: `total` already includes `own`, and the two are never summed.
730
+ */
731
+ updateMetricsForAssignmentsInBatch(batch, assignments, productDelta, auctionDelta, productId,
732
+ /** A brand row: flat, so own and rollup move together. */
733
+ brandId) {
704
734
  const now = new Date();
705
735
  const colRef = this.db.collection(this.collection);
706
- const directRef = colRef.doc(categoryId);
707
- const directUpdate = {
708
- "metrics.productCount": increment(productDelta),
709
- "metrics.auctionCount": increment(auctionDelta),
710
- "metrics.totalProductCount": increment(productDelta),
711
- "metrics.totalAuctionCount": increment(auctionDelta),
712
- "metrics.totalItemCount": increment(productDelta + auctionDelta),
713
- "metrics.lastUpdated": now,
714
- updatedAt: now,
715
- };
716
- if (productId && productDelta !== 0) {
717
- directUpdate["metrics.productIds"] =
718
- productDelta > 0 ? arrayUnion(productId) : arrayRemove(productId);
719
- }
720
- if (productId && auctionDelta !== 0) {
721
- directUpdate["metrics.auctionIds"] =
722
- auctionDelta > 0 ? arrayUnion(productId) : arrayRemove(productId);
723
- }
724
- batch.update(directRef, directUpdate);
725
- for (const ancestorId of parentIds) {
726
- batch.update(colRef.doc(ancestorId), {
736
+ /** id → how many times `own` moves, and whether the rollup moves. */
737
+ const own = new Map();
738
+ const rollup = new Set();
739
+ for (const a of assignments) {
740
+ if (!a.id)
741
+ continue;
742
+ own.set(a.id, (own.get(a.id) ?? 0) + 1);
743
+ rollup.add(a.id);
744
+ for (const ancestorId of a.parentIds)
745
+ if (ancestorId)
746
+ rollup.add(ancestorId);
747
+ }
748
+ if (brandId) {
749
+ own.set(brandId, (own.get(brandId) ?? 0) + 1);
750
+ rollup.add(brandId);
751
+ }
752
+ for (const id of rollup) {
753
+ /*
754
+ * `ownTimes` is 0 for a pure ancestor and 1 for an assignment. It is only
755
+ * ever >1 if the SAME id was assigned twice, which `readAssignments`
756
+ * dedupes — kept as a multiplier rather than a boolean so that case
757
+ * stays arithmetically honest instead of silently collapsing.
758
+ */
759
+ const ownTimes = own.get(id) ?? 0;
760
+ const update = {
727
761
  "metrics.totalProductCount": increment(productDelta),
728
762
  "metrics.totalAuctionCount": increment(auctionDelta),
729
763
  "metrics.totalItemCount": increment(productDelta + auctionDelta),
730
764
  "metrics.lastUpdated": now,
731
765
  updatedAt: now,
732
- });
766
+ };
767
+ if (ownTimes > 0) {
768
+ update["metrics.productCount"] = increment(productDelta * ownTimes);
769
+ update["metrics.auctionCount"] = increment(auctionDelta * ownTimes);
770
+ if (productId && productDelta !== 0) {
771
+ update["metrics.productIds"] =
772
+ productDelta > 0 ? arrayUnion(productId) : arrayRemove(productId);
773
+ }
774
+ if (productId && auctionDelta !== 0) {
775
+ update["metrics.auctionIds"] =
776
+ auctionDelta > 0 ? arrayUnion(productId) : arrayRemove(productId);
777
+ }
778
+ }
779
+ batch.update(colRef.doc(id), update);
733
780
  }
734
781
  }
735
782
  /**
@@ -757,6 +804,25 @@ export class CategoriesRepository extends BaseRepository {
757
804
  * a true 65. Nothing errored, and no page looked broken; a wrong number is
758
805
  * simply a number.
759
806
  */
807
+ /**
808
+ * G3b — this category's OWN and ROLLUP counts, as one document read.
809
+ *
810
+ * 🛑 Returns `null` when no counts document exists, NEVER a zeroed object.
811
+ * An unknown count must stay distinguishable from a count of nothing (Root
812
+ * Cause #72), so the caller falls back to `metrics` and renders exactly as
813
+ * it did before — which is what makes `categoryCounts` additive rather than
814
+ * a regression on every category until its first product mutation.
815
+ *
816
+ * One doc by id: no index, no query, and safe inside a page already at Rule
817
+ * #6's round-trip budget provided the caller runs it in parallel.
818
+ */
819
+ async getLiveCounts(categoryId) {
820
+ const snap = await this.db
821
+ .collection(CATEGORY_COUNTS_COLLECTION)
822
+ .doc(categoryId)
823
+ .get();
824
+ return snap.exists ? snap.data() : null;
825
+ }
760
826
  async setMetrics(categoryId, m) {
761
827
  await this.db
762
828
  .collection(this.collection)
@@ -813,6 +879,86 @@ export class CategoriesRepository extends BaseRepository {
813
879
  .get();
814
880
  return snap.docs.map((d) => ({ id: d.id, ...d.data() }));
815
881
  }
882
+ /**
883
+ * I4 — re-cache a renamed node's name into every descendant's `ancestors[]`,
884
+ * and rebuild their `searchTxt`.
885
+ *
886
+ * 🛑 WHY A RENAME NEEDS THIS AT ALL. `ancestors[]` is `{id, name, tier}` —
887
+ * a denormalised copy of a name that lives on another document. Nothing
888
+ * re-synced it, so renaming "Beyblade Burst" left every node beneath it
889
+ * holding the old string, and `buildCategorySearchTxt` indexes those names
890
+ * deliberately: typing "burst" reaches `category-lost-longinus` BECAUSE
891
+ * Beyblade Burst is one of its ancestors. So a rename silently broke lineage
892
+ * search for the whole subtree while the node's own page looked perfect.
893
+ *
894
+ * Mirror-drift (Root Cause #42) on the taxonomy's own axis, and the same
895
+ * class as `storeName` on a product — which already has a backfill job for
896
+ * exactly this reason.
897
+ *
898
+ * Returns the ids actually written. A node already carrying the right names
899
+ * is skipped, so a re-run costs nothing — which is what lets an
900
+ * at-least-once trigger start the job that calls this.
901
+ */
902
+ async refreshSubtreeAncestors(categoryId) {
903
+ const descendants = await this.getDescendants(categoryId);
904
+ if (descendants.length === 0)
905
+ return [];
906
+ /*
907
+ * Every id named by any descendant's chain, resolved once. `parentIds`
908
+ * holds the FULL chain, so this is a single pass rather than a walk — and
909
+ * the ids are deduped across the subtree, so a 200-node subtree sharing
910
+ * four ancestors costs four reads, not 800.
911
+ */
912
+ const needed = new Set();
913
+ for (const d of descendants)
914
+ for (const id of d.parentIds ?? [])
915
+ needed.add(id);
916
+ const byId = new Map();
917
+ const ids = [...needed];
918
+ for (let i = 0; i < ids.length; i += 30) {
919
+ const refs = ids.slice(i, i + 30).map((id) => this.db.collection(this.collection).doc(id));
920
+ const snaps = await this.db.getAll(...refs);
921
+ for (const snap of snaps) {
922
+ if (snap.exists)
923
+ byId.set(snap.id, { id: snap.id, ...snap.data() });
924
+ }
925
+ }
926
+ const writes = [];
927
+ for (const d of descendants) {
928
+ /*
929
+ * `ancestors` is root-first, matching `parentIds`. Rebuilt from the live
930
+ * documents rather than patched in place, so a tier that also changed is
931
+ * picked up in the same pass.
932
+ */
933
+ const ancestors = (d.parentIds ?? [])
934
+ .map((id) => byId.get(id))
935
+ .filter((c) => !!c)
936
+ .map((c) => ({ id: c.id, name: c.name, tier: c.tier }));
937
+ const same = (d.ancestors ?? []).length === ancestors.length &&
938
+ (d.ancestors ?? []).every((a, i) => a?.id === ancestors[i]?.id && a?.name === ancestors[i]?.name);
939
+ if (same)
940
+ continue;
941
+ /*
942
+ * `searchTxt` is rebuilt with the NEW ancestors, not the stored ones —
943
+ * `buildSearchTxtFor` reads the document it is handed, so passing the
944
+ * stale row would re-index the stale names and the rename would appear
945
+ * to have worked while changing nothing searchable.
946
+ */
947
+ const searchTxt = buildCategorySearchTxt({ ...d, ancestors });
948
+ writes.push({
949
+ id: d.id,
950
+ data: {
951
+ ancestors,
952
+ ...(searchTxt ? { searchTxt } : {}),
953
+ updatedAt: new Date(),
954
+ },
955
+ });
956
+ }
957
+ if (writes.length === 0)
958
+ return [];
959
+ await this.commitChunked(writes);
960
+ return writes.map((w) => w.id);
961
+ }
816
962
  /**
817
963
  * SB-UNI-B — delete a sublisting category and unlink all products
818
964
  * that referenced it. Mirrors the cascade behavior of the old
@@ -0,0 +1,143 @@
1
+ /**
2
+ * G3b — the incremental leaf→root counter.
3
+ *
4
+ * 🛑 MAINTAINED FROM THE REPOSITORY, NOT A FIRESTORE TRIGGER, and this is the
5
+ * decisive constraint rather than a preference. `FieldValue.increment` is
6
+ * atomic but **not idempotent**, and a Firestore trigger is delivered
7
+ * at-least-once — so a redelivered trigger double-counts permanently and
8
+ * invisibly, which is the worst failure available here (a wrong number is just
9
+ * a number; nothing errors). From `ProductRepository` a retry is the caller's
10
+ * and the increments are one batch.
11
+ *
12
+ * It walks the STORED chain, which is what makes it duplicate-free and acyclic
13
+ * by construction rather than by care:
14
+ *
15
+ * - `product.categorySlugs` is already `[leaf, …ancestors, root]`, derived by
16
+ * `deriveTaxonomy` on create and update. Each ancestor appears exactly
17
+ * once, so incrementing every element once cannot double-count.
18
+ * - It comes from `buildCategoryTree`'s DFS over a forest, so it is acyclic
19
+ * by construction and bounded at 5 (tiers 0–4).
20
+ *
21
+ * 🛑 Do NOT walk parent-by-parent at write time. That is N reads per mutation
22
+ * and it reintroduces the cycle question the stored chain already answers.
23
+ */
24
+ import type { ListingType } from "../../products/types";
25
+ /** The only product fields this module reads. */
26
+ export interface CountableProductRow {
27
+ /** I3 — the ASSIGNED categories. Absent on every product written before I1. */
28
+ categoryIds?: string[] | null;
29
+ categorySlugs?: string[] | null;
30
+ category?: string | null;
31
+ brandSlug?: string | null;
32
+ listingType?: ListingType | string | null;
33
+ isLot?: boolean;
34
+ }
35
+ /**
36
+ * One product's contribution to the counters, or `null` when it contributes
37
+ * nothing.
38
+ *
39
+ * `countsNow` is the caller's availability verdict — this module deliberately
40
+ * does NOT compute it, so there is exactly one implementation of "is this row
41
+ * available" in the codebase (`isListingRowAvailable`) and no chance of a
42
+ * second, disagreeing copy. Root Cause #73 is what four partial copies of that
43
+ * predicate cost.
44
+ */
45
+ export interface CountDelta {
46
+ /**
47
+ * The ASSIGNED categories — where `own` moves. One entry per assignment.
48
+ *
49
+ * 🛑 I3: this was a single `leaf`. A product can now be filed under several
50
+ * categories (at most one per root), and it is genuinely "here" in each of
51
+ * them — so `own` bumps at every one, not just the default.
52
+ *
53
+ * `owns[0]` is the DEFAULT assignment, because the closure is ordered
54
+ * default-chain-first and this is read from its head.
55
+ */
56
+ owns: string[];
57
+ /**
58
+ * The whole chain — where `total` moves. INCLUDES the leaf.
59
+ *
60
+ * 🛑 `total` therefore includes `own` and the two must never be summed; see
61
+ * the invariant on `CategoryCountsDocument`. The leaf ends up with own=1 and
62
+ * total=1 for one listing, which is one count in each of two fields
63
+ * answering two different questions — not the same item counted twice.
64
+ */
65
+ chain: string[];
66
+ /**
67
+ * The brand row, counted as a FLAT dimension: own AND total both move,
68
+ * because a brand has no category children of its own beyond what points at
69
+ * it — so `own === total` by construction and `descendantTotalFor` is 0.
70
+ *
71
+ * 🛑 This existed in `countersReconcile` and NOT here until the own/children
72
+ * review. Without it the incremental path never touched a brand, so every
73
+ * brand's counter stayed stale until the nightly pass and then "drifted" in
74
+ * the log every single night — an audit signal permanently stuck on,
75
+ * which is the same as no signal at all.
76
+ */
77
+ brand: string | null;
78
+ listingType: ListingType;
79
+ isLot: boolean;
80
+ }
81
+ /**
82
+ * Read a product's countable identity.
83
+ *
84
+ * Mirrors `countersReconcile.tallyProduct`'s own field selection exactly —
85
+ * `categorySlugs[0]` first, then the `@deprecated` scalar `category` — so the
86
+ * incremental and the nightly values can agree. Two different leaf selections
87
+ * would drift silently and the reconciler would "correct" a count that was
88
+ * right.
89
+ */
90
+ export declare function describeCountable(row: CountableProductRow): CountDelta | null;
91
+ /**
92
+ * Whether a mutation can possibly have changed the counters.
93
+ *
94
+ * 🛑 THE BUG THIS EXISTS TO PREVENT, and the plan called it in advance: a guard
95
+ * that asks only "did the taxonomy change?" MISSES EVERY STATUS TRANSITION.
96
+ * `draft → published` changes no `categorySlugs` at all, so the count would
97
+ * never move — silently, on a page that looks correct. Same for
98
+ * `published → archived`, a sell-out, and a soft delete.
99
+ *
100
+ * So the comparison is over (chain, leaf, listingType, isLot, countsNow), and
101
+ * the availability verdict is a first-class input rather than an afterthought.
102
+ */
103
+ export declare function countsChanged(before: {
104
+ delta: CountDelta | null;
105
+ countsNow: boolean;
106
+ }, after: {
107
+ delta: CountDelta | null;
108
+ countsNow: boolean;
109
+ }): boolean;
110
+ /** A single field path → increment, keyed so the batch can merge them. */
111
+ export type CountPatch = Map<string, Map<string, number>>;
112
+ /**
113
+ * Build the field-path increments for one product transition.
114
+ *
115
+ * `sign` is +1 for an addition and −1 for a removal; an update is expressed as
116
+ * a removal of the old shape followed by an addition of the new, which is why
117
+ * a move between leaves nets to −1 on the old chain and +1 on the new with no
118
+ * node double-counted.
119
+ */
120
+ export declare function buildCountPatch(deltas: Array<{
121
+ delta: CountDelta | null;
122
+ sign: 1 | -1;
123
+ }>): CountPatch;
124
+ /**
125
+ * Apply a patch.
126
+ *
127
+ * `set(..., { merge: true })` rather than `update`, because the counts document
128
+ * may not exist yet: `update` on a missing document throws, and a counter that
129
+ * refuses to start is worse than one that starts at the first mutation. Merge
130
+ * also leaves the OTHER listing types' keys untouched, which is what keeps the
131
+ * map `Partial`.
132
+ *
133
+ * 🛑 Failure is swallowed deliberately, and this is the one place in this
134
+ * codebase where that is right: a count is a display number, and failing a
135
+ * seller's publish because an ancestor counter could not be bumped would trade
136
+ * a cosmetic staleness for a lost listing. The nightly reconciler is the
137
+ * backstop — its `drifted` count is how a missed increment becomes visible.
138
+ */
139
+ export declare function applyCountPatch(db: {
140
+ collection(name: string): any;
141
+ }, patch: CountPatch, onError?: (err: {
142
+ message: string;
143
+ }) => void): Promise<void>;