@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,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
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * G3b — LISTING COUNTS, maintained incrementally leaf→root.
3
+ *
4
+ * 🛑 WHY THIS IS A SIBLING COLLECTION AND NOT A FIELD ON `categories`.
5
+ *
6
+ * `handleCategoryWrite` calls `notifyRevalidate({ collection: "categories" })`
7
+ * as its FIRST statement, unconditionally, before any branch — so ANY write to
8
+ * a category document drops that category page's ISR entry. Under leaf→root,
9
+ * publishing one listing touches up to five ancestors, so it would drop five
10
+ * ISR entries per publish. This project was paused with **ISR writes at 3.1M
11
+ * against a 200K cap (15.5×)**, which settles the question.
12
+ *
13
+ * It also removes the Root Cause #92 hazard outright: a trigger that writes
14
+ * back to the collection it watches produced 1,017,548 invocations in 24 hours
15
+ * and a billing denial that blocked `firebase deploy`.
16
+ *
17
+ * 🛑 AND `metrics.*` ON `categories` STAYS — a constraint the plan did not
18
+ * record. Two Sieve SORTS order by `metrics.productCount` through Firestore
19
+ * (`CategoriesIndexListing`'s "Most Products" and
20
+ * `AdminSublistingCategoriesView`'s "Most listings"), and a sibling collection
21
+ * cannot serve an `orderBy` on the categories query. So:
22
+ *
23
+ * - `metrics.*` — written ONLY by the nightly `countersReconcile`, as
24
+ * today. The two sorts keep working at exactly today's
25
+ * accuracy, so there is no regression, and the ISR cost
26
+ * is unchanged from the status quo (one drop per
27
+ * category per night, already happening).
28
+ * - `categoryCounts` — the LIVE value, written per product mutation. Fires no
29
+ * revalidation, because nothing triggers on it.
30
+ *
31
+ * Readers prefer `categoryCounts` and fall back to `metrics`. That ordering is
32
+ * what makes the feature additive: a category with no counts document yet
33
+ * renders exactly as it does today rather than as zero.
34
+ *
35
+ * 🛑 HOW FRESH IT ACTUALLY IS, stated precisely rather than as "live".
36
+ *
37
+ * The count is of AVAILABLE rows — `isListingRowAvailable`, the same predicate
38
+ * `countersReconcile` and the listing query use. That predicate is partly
39
+ * TIME-dependent: `auction` is the one type with an inequality clause
40
+ * (`auctionEndDate`), so an auction becomes unavailable by the clock with no
41
+ * product write, and no incremental counter can observe that.
42
+ *
43
+ * In practice the window is minutes, not a day: `auctionSettlement` writes
44
+ * `isSold: true` when it settles, and that write re-evaluates the counter.
45
+ * Every other type's unavailability is write-driven (stock, `isSold`,
46
+ * `digitalCode.codesAvailable`, the prize-draw reveal status).
47
+ *
48
+ * So: instant for every write-driven change, and bounded by the settlement
49
+ * cadence for a just-ended auction. That is strictly better than today, where
50
+ * every change is up to 24h stale. The nightly reconciler stays as the
51
+ * authority and the audit — its `drifted` counter becomes a real signal that
52
+ * an increment was missed.
53
+ */
54
+ import type { ListingType } from "../../products/types";
55
+ export declare const CATEGORY_COUNTS_COLLECTION = "categoryCounts";
56
+ /**
57
+ * Per-listing-type counters.
58
+ *
59
+ * 🛑 `Partial` on purpose: an absent key means "zero observed". Writing nine
60
+ * explicit zeros on every row is noise, and it would also make an untouched
61
+ * category indistinguishable from one measured as empty.
62
+ *
63
+ * A MAP rather than scalar fields, because own+total across nine listing types
64
+ * is 18 fields that grow with the union — and the union has grown twice
65
+ * already. A map needs no schema change for a tenth type.
66
+ */
67
+ export type ListingTypeCounts = Partial<Record<ListingType, number>>;
68
+ /**
69
+ * 🛑 THE INVARIANT, and the one way to get this wrong:
70
+ *
71
+ * total = own + (every descendant's own)
72
+ *
73
+ * **`total` ALREADY INCLUDES `own`. Never add them.** `own + total` double-counts
74
+ * every item filed directly under the row, and on a leaf — where `total === own`
75
+ * — it doubles the whole number.
76
+ *
77
+ * So there are three questions and only two stored fields, because the third is
78
+ * a subtraction and a stored mirror of it would drift the first time one half
79
+ * was written without the other (Root Cause #42):
80
+ *
81
+ * | Question | Read |
82
+ * |---|---|
83
+ * | "how many are filed right here?" | `own` |
84
+ * | "how many are under my subcategories?" | **`descendantCountsFor()`** — `total − own`, derived |
85
+ * | "how many will clicking this show me?" | `total` |
86
+ *
87
+ * The third is the one a buyer means, which is why `liveTotalFor` returns it
88
+ * and why the category chips and grid read it. The second exists because the
89
+ * distinction is genuinely useful to an admin — `CategoryTableColumns` already
90
+ * renders "own (total)" explicitly — and because deriving it is the only way to
91
+ * show it without a third counter to keep in step.
92
+ */
93
+ export interface CategoryCountsDocument {
94
+ /** === the category's document id. */
95
+ id: string;
96
+ /** Listings filed DIRECTLY under this category, by type. */
97
+ own: ListingTypeCounts;
98
+ /**
99
+ * Own + every descendant's, by type. Equals `own` on a leaf.
100
+ *
101
+ * 🛑 INCLUDES `own` — see the invariant above. Never `own + total`.
102
+ */
103
+ total: ListingTypeCounts;
104
+ /**
105
+ * Multi-item LOTS, counted separately from singles.
106
+ *
107
+ * 🛑 This separation is what makes a count mean one thing, and it is why the
108
+ * filing rule in `deriveTaxonomy` only has to refuse a lot at a LEAF rather
109
+ * than also refusing a single at a non-leaf. A lot and a single are never
110
+ * summed into one number, so "12 listings here" is unambiguous wherever they
111
+ * are filed. (Measured: 58 of 70 live products file at a non-leaf, so the
112
+ * symmetric rule the plan first specified would have rejected most of the
113
+ * catalogue — see the `isLot` docstring on `ProductDocument`.)
114
+ *
115
+ * Scalars rather than a per-type map: a lot is not type-specific in any way
116
+ * a buyer or a seller filters on, and there is no reader for "lots of
117
+ * auctions".
118
+ */
119
+ lots: {
120
+ own: number;
121
+ total: number;
122
+ };
123
+ updatedAt: Date;
124
+ }
125
+ /** Sum a counts map — what a category page renders as "N listings". */
126
+ export declare function sumCounts(counts: ListingTypeCounts | undefined): number;
127
+ /**
128
+ * The display count for a category, preferring the live document.
129
+ *
130
+ * 🛑 `undefined` means "no counts document yet", NOT zero — so the caller falls
131
+ * back to `metrics`. Returning 0 here would render a confident zero on every
132
+ * category until the first mutation, which is Root Cause #72's lesson (a count
133
+ * that failed must never be indistinguishable from a count of nothing).
134
+ */
135
+ export declare function liveTotalFor(doc: Pick<CategoryCountsDocument, "total" | "lots"> | null | undefined): number | undefined;
136
+ /** Filed directly here, lots included. The "N here" half of "N here, M below". */
137
+ export declare function ownTotalFor(doc: Pick<CategoryCountsDocument, "own" | "lots"> | null | undefined): number | undefined;
138
+ /**
139
+ * How many sit under this row's DESCENDANTS and not directly on it — `total − own`.
140
+ *
141
+ * 🛑 DERIVED, never stored, and that is the point: a third stored counter would
142
+ * be a mirror of a subtraction, and a mirror drifts the moment one half is
143
+ * written without the other (Root Cause #42). It is also the function that
144
+ * makes the invariant un-missable — anyone reaching for "own plus children"
145
+ * finds this instead of writing `own + total`, which double-counts every item
146
+ * filed directly on the row.
147
+ *
148
+ * Clamped at 0. A negative value is real and means the counters drifted (an
149
+ * increment lost between the leaf write and an ancestor's), and surfacing
150
+ * "−3 in subcategories" to a visitor is worse than showing none — the nightly
151
+ * reconciler's `countsWritten` is where that drift is meant to be seen.
152
+ */
153
+ export declare function descendantTotalFor(doc: Pick<CategoryCountsDocument, "own" | "total" | "lots"> | null | undefined): number | undefined;
154
+ /** The per-type descendant split, same derivation and the same clamp. */
155
+ export declare function descendantCountsFor(doc: Pick<CategoryCountsDocument, "own" | "total"> | null | undefined): ListingTypeCounts | undefined;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * G3b — LISTING COUNTS, maintained incrementally leaf→root.
3
+ *
4
+ * 🛑 WHY THIS IS A SIBLING COLLECTION AND NOT A FIELD ON `categories`.
5
+ *
6
+ * `handleCategoryWrite` calls `notifyRevalidate({ collection: "categories" })`
7
+ * as its FIRST statement, unconditionally, before any branch — so ANY write to
8
+ * a category document drops that category page's ISR entry. Under leaf→root,
9
+ * publishing one listing touches up to five ancestors, so it would drop five
10
+ * ISR entries per publish. This project was paused with **ISR writes at 3.1M
11
+ * against a 200K cap (15.5×)**, which settles the question.
12
+ *
13
+ * It also removes the Root Cause #92 hazard outright: a trigger that writes
14
+ * back to the collection it watches produced 1,017,548 invocations in 24 hours
15
+ * and a billing denial that blocked `firebase deploy`.
16
+ *
17
+ * 🛑 AND `metrics.*` ON `categories` STAYS — a constraint the plan did not
18
+ * record. Two Sieve SORTS order by `metrics.productCount` through Firestore
19
+ * (`CategoriesIndexListing`'s "Most Products" and
20
+ * `AdminSublistingCategoriesView`'s "Most listings"), and a sibling collection
21
+ * cannot serve an `orderBy` on the categories query. So:
22
+ *
23
+ * - `metrics.*` — written ONLY by the nightly `countersReconcile`, as
24
+ * today. The two sorts keep working at exactly today's
25
+ * accuracy, so there is no regression, and the ISR cost
26
+ * is unchanged from the status quo (one drop per
27
+ * category per night, already happening).
28
+ * - `categoryCounts` — the LIVE value, written per product mutation. Fires no
29
+ * revalidation, because nothing triggers on it.
30
+ *
31
+ * Readers prefer `categoryCounts` and fall back to `metrics`. That ordering is
32
+ * what makes the feature additive: a category with no counts document yet
33
+ * renders exactly as it does today rather than as zero.
34
+ *
35
+ * 🛑 HOW FRESH IT ACTUALLY IS, stated precisely rather than as "live".
36
+ *
37
+ * The count is of AVAILABLE rows — `isListingRowAvailable`, the same predicate
38
+ * `countersReconcile` and the listing query use. That predicate is partly
39
+ * TIME-dependent: `auction` is the one type with an inequality clause
40
+ * (`auctionEndDate`), so an auction becomes unavailable by the clock with no
41
+ * product write, and no incremental counter can observe that.
42
+ *
43
+ * In practice the window is minutes, not a day: `auctionSettlement` writes
44
+ * `isSold: true` when it settles, and that write re-evaluates the counter.
45
+ * Every other type's unavailability is write-driven (stock, `isSold`,
46
+ * `digitalCode.codesAvailable`, the prize-draw reveal status).
47
+ *
48
+ * So: instant for every write-driven change, and bounded by the settlement
49
+ * cadence for a just-ended auction. That is strictly better than today, where
50
+ * every change is up to 24h stale. The nightly reconciler stays as the
51
+ * authority and the audit — its `drifted` counter becomes a real signal that
52
+ * an increment was missed.
53
+ */
54
+ export const CATEGORY_COUNTS_COLLECTION = "categoryCounts";
55
+ /** Sum a counts map — what a category page renders as "N listings". */
56
+ export function sumCounts(counts) {
57
+ if (!counts)
58
+ return 0;
59
+ let n = 0;
60
+ for (const v of Object.values(counts))
61
+ n += v ?? 0;
62
+ return n;
63
+ }
64
+ /**
65
+ * The display count for a category, preferring the live document.
66
+ *
67
+ * 🛑 `undefined` means "no counts document yet", NOT zero — so the caller falls
68
+ * back to `metrics`. Returning 0 here would render a confident zero on every
69
+ * category until the first mutation, which is Root Cause #72's lesson (a count
70
+ * that failed must never be indistinguishable from a count of nothing).
71
+ */
72
+ export function liveTotalFor(doc) {
73
+ if (!doc)
74
+ return undefined;
75
+ return sumCounts(doc.total) + (doc.lots?.total ?? 0);
76
+ }
77
+ /** Filed directly here, lots included. The "N here" half of "N here, M below". */
78
+ export function ownTotalFor(doc) {
79
+ if (!doc)
80
+ return undefined;
81
+ return sumCounts(doc.own) + (doc.lots?.own ?? 0);
82
+ }
83
+ /**
84
+ * How many sit under this row's DESCENDANTS and not directly on it — `total − own`.
85
+ *
86
+ * 🛑 DERIVED, never stored, and that is the point: a third stored counter would
87
+ * be a mirror of a subtraction, and a mirror drifts the moment one half is
88
+ * written without the other (Root Cause #42). It is also the function that
89
+ * makes the invariant un-missable — anyone reaching for "own plus children"
90
+ * finds this instead of writing `own + total`, which double-counts every item
91
+ * filed directly on the row.
92
+ *
93
+ * Clamped at 0. A negative value is real and means the counters drifted (an
94
+ * increment lost between the leaf write and an ancestor's), and surfacing
95
+ * "−3 in subcategories" to a visitor is worse than showing none — the nightly
96
+ * reconciler's `countsWritten` is where that drift is meant to be seen.
97
+ */
98
+ export function descendantTotalFor(doc) {
99
+ if (!doc)
100
+ return undefined;
101
+ const total = liveTotalFor(doc) ?? 0;
102
+ const own = ownTotalFor(doc) ?? 0;
103
+ return Math.max(0, total - own);
104
+ }
105
+ /** The per-type descendant split, same derivation and the same clamp. */
106
+ export function descendantCountsFor(doc) {
107
+ if (!doc)
108
+ return undefined;
109
+ const out = {};
110
+ for (const k of Object.keys(doc.total ?? {})) {
111
+ const d = (doc.total[k] ?? 0) - (doc.own?.[k] ?? 0);
112
+ if (d > 0)
113
+ out[k] = d;
114
+ }
115
+ return out;
116
+ }
@@ -108,7 +108,6 @@ export interface CategoryDocument extends BaseDocument {
108
108
  */
109
109
  subtreeSize: number;
110
110
  metrics: CategoryDocumentMetrics;
111
- viewCount?: number;
112
111
  isFeatured: boolean;
113
112
  featuredPriority?: number;
114
113
  /** @deprecated Use categoryType === "brand". Will be removed once seed + admin form fully migrate. */
@@ -329,7 +328,6 @@ export declare const CATEGORY_FIELDS: {
329
328
  readonly FEATURED_PRIORITY: "featuredPriority";
330
329
  readonly SEO: "seo";
331
330
  readonly DISPLAY: "display";
332
- readonly VIEW_COUNT: "viewCount";
333
331
  readonly IS_ACTIVE: "isActive";
334
332
  readonly IS_SEARCHABLE: "isSearchable";
335
333
  readonly SEARCH_TXT: "searchTxt";
@@ -210,7 +210,6 @@ export const CATEGORY_FIELDS = {
210
210
  FEATURED_PRIORITY: "featuredPriority",
211
211
  SEO: "seo",
212
212
  DISPLAY: "display",
213
- VIEW_COUNT: "viewCount",
214
213
  IS_ACTIVE: "isActive",
215
214
  IS_SEARCHABLE: "isSearchable",
216
215
  SEARCH_TXT: "searchTxt",