@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
@@ -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>;
@@ -0,0 +1,224 @@
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 { increment, serverTimestamp } from "../../../contracts/field-ops.js";
25
+ import { normalizeError } from "../../../errors/normalize.js";
26
+ import { CATEGORY_COUNTS_COLLECTION } from "../schemas/category-counts.js";
27
+ const DEFAULT_LISTING_TYPE = "standard";
28
+ /**
29
+ * Read a product's countable identity.
30
+ *
31
+ * Mirrors `countersReconcile.tallyProduct`'s own field selection exactly —
32
+ * `categorySlugs[0]` first, then the `@deprecated` scalar `category` — so the
33
+ * incremental and the nightly values can agree. Two different leaf selections
34
+ * would drift silently and the reconciler would "correct" a count that was
35
+ * right.
36
+ */
37
+ export function describeCountable(row) {
38
+ const chainRaw = Array.isArray(row.categorySlugs) ? row.categorySlugs : [];
39
+ /*
40
+ * I3 — the ASSIGNED list is what `own` keys on, and it is read from
41
+ * `categoryIds` with the old single-leaf selection as the fallback.
42
+ *
43
+ * 🛑 The fallback is not legacy tidiness, it is required: `categoryIds` was
44
+ * added in I1, so every product written before it has only `categorySlugs`,
45
+ * and the nightly reconciler reads the same two fields in the same order. Two
46
+ * different selections would drift silently and the reconciler would
47
+ * "correct" a count that was right.
48
+ */
49
+ const ownsRaw = Array.isArray(row.categoryIds) && row.categoryIds.length > 0
50
+ ? row.categoryIds
51
+ : [chainRaw[0] || row.category || ""];
52
+ const ownSeen = new Set();
53
+ const owns = [];
54
+ for (const id of ownsRaw) {
55
+ if (!id || ownSeen.has(id))
56
+ continue;
57
+ ownSeen.add(id);
58
+ owns.push(id);
59
+ }
60
+ if (owns.length === 0)
61
+ return null;
62
+ /*
63
+ * Dedupe the chain. The one real duplicate risk the stored closure does NOT
64
+ * remove is a product filed under two leaves that share an ancestor — the
65
+ * shared ancestor would otherwise be incremented twice for one listing.
66
+ *
67
+ * 🛑 Under the one-per-root rule that cannot happen, and the dedupe stays
68
+ * anyway: the rule is enforced at ONE write path, while this runs on every
69
+ * row that reaches Firestore — including a seeded row and an admin
70
+ * `.passthrough()` write, neither of which passes through it. A belt, and the
71
+ * cheaper of the two places to be wrong.
72
+ */
73
+ const seen = new Set();
74
+ const chain = [];
75
+ for (const id of [...owns, ...chainRaw]) {
76
+ if (!id || seen.has(id))
77
+ continue;
78
+ seen.add(id);
79
+ chain.push(id);
80
+ }
81
+ /*
82
+ * 🛑 Never count the brand twice. If a row's leaf id were also its brand id,
83
+ * the brand bump below would land on a category already bumped by the chain.
84
+ * It cannot happen with today's `category-` / `brand-` prefixes, which is
85
+ * exactly why it is worth guarding rather than trusting: the prefixes are a
86
+ * convention and this is an arithmetic invariant.
87
+ */
88
+ const brandSlug = row.brandSlug || null;
89
+ const brand = brandSlug && !seen.has(brandSlug) ? brandSlug : null;
90
+ return {
91
+ owns,
92
+ chain,
93
+ brand,
94
+ listingType: row.listingType || DEFAULT_LISTING_TYPE,
95
+ isLot: row.isLot === true,
96
+ };
97
+ }
98
+ /**
99
+ * Whether a mutation can possibly have changed the counters.
100
+ *
101
+ * 🛑 THE BUG THIS EXISTS TO PREVENT, and the plan called it in advance: a guard
102
+ * that asks only "did the taxonomy change?" MISSES EVERY STATUS TRANSITION.
103
+ * `draft → published` changes no `categorySlugs` at all, so the count would
104
+ * never move — silently, on a page that looks correct. Same for
105
+ * `published → archived`, a sell-out, and a soft delete.
106
+ *
107
+ * So the comparison is over (chain, leaf, listingType, isLot, countsNow), and
108
+ * the availability verdict is a first-class input rather than an afterthought.
109
+ */
110
+ export function countsChanged(before, after) {
111
+ const b = before.countsNow ? before.delta : null;
112
+ const a = after.countsNow ? after.delta : null;
113
+ if (!b && !a)
114
+ return false;
115
+ if (!b || !a)
116
+ return true;
117
+ return (b.owns.length !== a.owns.length ||
118
+ b.owns.some((id, i) => id !== a.owns[i]) ||
119
+ b.brand !== a.brand ||
120
+ b.listingType !== a.listingType ||
121
+ b.isLot !== a.isLot ||
122
+ b.chain.length !== a.chain.length ||
123
+ b.chain.some((id, i) => id !== a.chain[i]));
124
+ }
125
+ function bump(patch, categoryId, field, by) {
126
+ let doc = patch.get(categoryId);
127
+ if (!doc) {
128
+ doc = new Map();
129
+ patch.set(categoryId, doc);
130
+ }
131
+ doc.set(field, (doc.get(field) ?? 0) + by);
132
+ }
133
+ /**
134
+ * Build the field-path increments for one product transition.
135
+ *
136
+ * `sign` is +1 for an addition and −1 for a removal; an update is expressed as
137
+ * a removal of the old shape followed by an addition of the new, which is why
138
+ * a move between leaves nets to −1 on the old chain and +1 on the new with no
139
+ * node double-counted.
140
+ */
141
+ export function buildCountPatch(deltas) {
142
+ const patch = new Map();
143
+ for (const { delta, sign } of deltas) {
144
+ if (!delta)
145
+ continue;
146
+ const ownField = delta.isLot ? "lots.own" : `own.${delta.listingType}`;
147
+ const totalField = delta.isLot ? "lots.total" : `total.${delta.listingType}`;
148
+ /*
149
+ * Every ASSIGNMENT gets `own`; the whole deduped closure gets `total`.
150
+ *
151
+ * 🛑 `own` at each one is the point of I3: a product assigned to both
152
+ * category-dragoon-g and category-beyblade is genuinely filed in both, and
153
+ * each page's "N here" must say so. `total` is still one bump per closure
154
+ * entry, so a shared ancestor moves by 1 for the listing rather than by the
155
+ * number of assignments that reach it.
156
+ */
157
+ for (const id of delta.owns)
158
+ bump(patch, id, ownField, sign);
159
+ for (const id of delta.chain)
160
+ bump(patch, id, totalField, sign);
161
+ // A brand is flat: own and total move together, so its descendant count
162
+ // derives to 0 rather than to a negative number.
163
+ if (delta.brand) {
164
+ bump(patch, delta.brand, ownField, sign);
165
+ bump(patch, delta.brand, totalField, sign);
166
+ }
167
+ }
168
+ /*
169
+ * Drop net-zero fields. A move WITHIN one leaf (a title edit on a row whose
170
+ * chain did not change) cancels to nothing, and writing `increment(0)` would
171
+ * still be a write — one per ancestor, on the hottest path in the app.
172
+ */
173
+ for (const [catId, fields] of patch) {
174
+ for (const [field, by] of fields)
175
+ if (by === 0)
176
+ fields.delete(field);
177
+ if (fields.size === 0)
178
+ patch.delete(catId);
179
+ }
180
+ return patch;
181
+ }
182
+ /**
183
+ * Apply a patch.
184
+ *
185
+ * `set(..., { merge: true })` rather than `update`, because the counts document
186
+ * may not exist yet: `update` on a missing document throws, and a counter that
187
+ * refuses to start is worse than one that starts at the first mutation. Merge
188
+ * also leaves the OTHER listing types' keys untouched, which is what keeps the
189
+ * map `Partial`.
190
+ *
191
+ * 🛑 Failure is swallowed deliberately, and this is the one place in this
192
+ * codebase where that is right: a count is a display number, and failing a
193
+ * seller's publish because an ancestor counter could not be bumped would trade
194
+ * a cosmetic staleness for a lost listing. The nightly reconciler is the
195
+ * backstop — its `drifted` count is how a missed increment becomes visible.
196
+ */
197
+ export async function applyCountPatch(db, patch, onError) {
198
+ if (patch.size === 0)
199
+ return;
200
+ try {
201
+ const col = db.collection(CATEGORY_COUNTS_COLLECTION);
202
+ await Promise.all(Array.from(patch, ([categoryId, fields]) => {
203
+ /*
204
+ * `FieldSentinel` rather than `unknown`: every value here is either
205
+ * the id string or a Firestore sentinel (`increment` / `serverTimestamp`),
206
+ * and `Record<string, unknown>` would be a dodge that `audit-unknown-leakage`
207
+ * correctly refuses — it says nothing about what may be written.
208
+ */
209
+ const payload = {
210
+ id: categoryId,
211
+ updatedAt: serverTimestamp(),
212
+ };
213
+ for (const [field, by] of fields)
214
+ payload[field] = increment(by);
215
+ return col.doc(categoryId).set(payload, { merge: true });
216
+ }));
217
+ }
218
+ catch (err) {
219
+ // Typed before anything downstream touches it (Root Cause #31) — the
220
+ // caller logs `.message`, and an `unknown` escaping a catch is how a
221
+ // non-Error throw becomes "[object Object]" in a log line.
222
+ onError?.(normalizeError(err));
223
+ }
224
+ }