@mohasinac/appkit 4.39.1 → 4.40.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.
- package/dist/_internal/server/jobs/core/countersReconcile.js +21 -0
- package/dist/_internal/server/jobs/core/onProductWrite.js +35 -1
- package/dist/features/categories/components/CategoryDetailPageView.js +17 -5
- package/dist/features/categories/repository/categories.repository.d.ts +65 -1
- package/dist/features/categories/repository/categories.repository.js +245 -77
- package/dist/features/categories/types/index.d.ts +2 -2
- package/dist/features/tester/seed-data/authored/admin__admin-detail-round-trips.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/admin__admin-detail-round-trips.js +145 -0
- package/dist/features/tester/seed-data/authored/admin__firebase-function-effects.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/admin__firebase-function-effects.js +234 -0
- package/dist/features/tester/seed-data/authored/buying__buying-checkout.js +32 -0
- package/dist/features/tester/seed-data/authored/buying__cart.js +60 -0
- package/dist/features/tester/seed-data/authored/buying__order-detail-actions.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/buying__order-detail-actions.js +138 -0
- package/dist/features/tester/seed-data/authored/buying__wishlist-history.js +30 -0
- package/dist/features/tester/seed-data/authored/content-discovery__category-counts-and-rollup.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/content-discovery__category-counts-and-rollup.js +145 -0
- package/dist/features/tester/seed-data/authored/content-discovery__event-detail-subroutes.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/content-discovery__event-detail-subroutes.js +154 -0
- package/dist/features/tester/seed-data/authored/content-discovery__store-reviews-aggregate.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/content-discovery__store-reviews-aggregate.js +88 -0
- package/dist/features/tester/seed-data/authored/index.js +22 -0
- package/dist/features/tester/seed-data/authored/public-pages__help-how-it-works.js +134 -7
- package/dist/features/tester/seed-data/authored/public-pages__help-scams-guides-subpages.js +89 -0
- package/dist/features/tester/seed-data/authored/public-pages__newly-wired-browse-indexes.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/public-pages__newly-wired-browse-indexes.js +141 -0
- package/dist/features/tester/seed-data/authored/public-pages__store-tabs.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/public-pages__store-tabs.js +115 -0
- package/dist/features/tester/seed-data/authored/selling__digital-content-delivery.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/selling__digital-content-delivery.js +148 -0
- package/dist/features/tester/seed-data/authored/selling__listing-edit-roundtrip.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/selling__listing-edit-roundtrip.js +118 -0
- package/dist/features/tester/seed-data/authored/selling__seller-orders-at-scale.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/selling__seller-orders-at-scale.js +69 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +471 -0
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/tabs-overflow.js +30 -7
- package/package.json +1 -1
|
@@ -92,6 +92,27 @@ function tallyProduct(productId, data, tallies, parentsOf) {
|
|
|
92
92
|
* Reads are bounded by pagination rather than by `.limit(QUERY_LIMIT)`, which
|
|
93
93
|
* silently recounted a subset the moment the catalogue passed 1,000 published
|
|
94
94
|
* rows — a truncated recount is indistinguishable from a correct one.
|
|
95
|
+
*
|
|
96
|
+
* 🛑 THIS IS THE ONLY RECOUNT. `appkit/scripts/backfill-category-metrics.mjs`
|
|
97
|
+
* was a second implementation of exactly this, reachable as
|
|
98
|
+
* `npm run categories:backfill-metrics`, and it was wrong four ways: it wrote the
|
|
99
|
+
* descendant rollup into `productCount` (the own count — Root Cause 102, the bug
|
|
100
|
+
* this file's own/rollup split exists to fix), blanked `productIds` on every
|
|
101
|
+
* ancestor, enumerated only categories that HAVE products so a row that dropped
|
|
102
|
+
* to zero kept its stale count, and — worst — wrote any row that was both a leaf
|
|
103
|
+
* with products AND an ancestor twice, the ancestor pass silently overwriting its
|
|
104
|
+
* own count. It was deleted 2026-09-14 rather than repaired: a second recount
|
|
105
|
+
* that disagrees with this one is a way to *introduce* drift while believing you
|
|
106
|
+
* are fixing it. If a manual run is ever needed, invoke this job — do not write
|
|
107
|
+
* another script.
|
|
108
|
+
*
|
|
109
|
+
* As of 2026-09-14 this is a BACKSTOP, not the maintainer. `onProductWrite`
|
|
110
|
+
* (products → categories) and `CategoriesRepository.reparentSubtree`
|
|
111
|
+
* (structural moves + deletes) keep the counters correct in real time; this pass
|
|
112
|
+
* exists because a `batch.update()` against a dangling ancestor id throws and
|
|
113
|
+
* loses every increment in that batch. A non-zero `drifted` in its log is
|
|
114
|
+
* therefore a real signal that one of those paths missed something — it is not
|
|
115
|
+
* routine.
|
|
95
116
|
*/
|
|
96
117
|
async function reconcileCategories(ctx) {
|
|
97
118
|
// Every category row, so rows that dropped to zero are still reset.
|
|
@@ -104,8 +104,42 @@ async function dispatchProductWriteEvent(p) {
|
|
|
104
104
|
if (wasPublished && isPublished) {
|
|
105
105
|
const categoryMoved = beforeCategory !== afterCategory;
|
|
106
106
|
const brandMoved = beforeBrand !== afterBrand;
|
|
107
|
-
|
|
107
|
+
/*
|
|
108
|
+
* A listing can also change WHICH counter it belongs in without moving at
|
|
109
|
+
* all: flipping listingType between auction and anything else has to move
|
|
110
|
+
* the item from `auctionCount` to `productCount` on the same rows. That is
|
|
111
|
+
* tested independently for the same reason category and brand are — it
|
|
112
|
+
* happens on its own, and when it did, this branch returned early and left
|
|
113
|
+
* the item counted as an auction forever.
|
|
114
|
+
*/
|
|
115
|
+
const typeChanged = beforeIsAuction !== isAuction;
|
|
116
|
+
if (!categoryMoved && !brandMoved && !typeChanged)
|
|
108
117
|
return;
|
|
118
|
+
/*
|
|
119
|
+
* A pure type flip: nothing moved, so the item leaves one counter and joins
|
|
120
|
+
* the other on the SAME rows. Staged as ONE call carrying both deltas rather
|
|
121
|
+
* than a -1 followed by a +1, because those would be two writes to the same
|
|
122
|
+
* document in one batch. Note the two deltas cancel, so `totalItemCount`
|
|
123
|
+
* correctly does not move — the item did not come or go, it changed shape.
|
|
124
|
+
*/
|
|
125
|
+
if (!categoryMoved && !brandMoved) {
|
|
126
|
+
const parents = await getParentIds(afterCategory ?? "");
|
|
127
|
+
const productDelta = isAuction ? -1 : 1;
|
|
128
|
+
const auctionDelta = isAuction ? 1 : -1;
|
|
129
|
+
const batch = ctx.db.batch();
|
|
130
|
+
if (afterCategory) {
|
|
131
|
+
categoriesRepository.updateMetricsInBatch(batch, afterCategory, parents, productDelta, auctionDelta, productId);
|
|
132
|
+
}
|
|
133
|
+
if (afterBrand) {
|
|
134
|
+
categoriesRepository.updateMetricsInBatch(batch, afterBrand, [], productDelta, auctionDelta, productId);
|
|
135
|
+
}
|
|
136
|
+
await commit(batch, "Listing type changed", {
|
|
137
|
+
productId,
|
|
138
|
+
from: beforeIsAuction ? "auction" : "product",
|
|
139
|
+
to: isAuction ? "auction" : "product",
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
109
143
|
const [beforeParents, afterParents] = await Promise.all([
|
|
110
144
|
getParentIds(categoryMoved ? beforeCategory ?? "" : ""),
|
|
111
145
|
getParentIds(categoryMoved ? afterCategory ?? "" : ""),
|
|
@@ -57,10 +57,18 @@ export async function CategoryDetailPageView({ slug }) {
|
|
|
57
57
|
.listByType("bundle", { activeOnly: true, limit: 50 })
|
|
58
58
|
.then(hidePublicTestData), { route: "/categories/[slug]", key: "category.bundles", fallback: [] })
|
|
59
59
|
: Promise.resolve([]),
|
|
60
|
+
/*
|
|
61
|
+
* Every DESCENDANT, not just the direct children this used to fetch. A
|
|
62
|
+
* four-tier tree showed only tier+1 here, so the whole lower half of the
|
|
63
|
+
* catalogue was unreachable from the page that is supposed to be its index —
|
|
64
|
+
* you had to already know a sub-sub-category existed to navigate to it.
|
|
65
|
+
* `getDescendants` is one array-contains query over `parentIds`, which holds
|
|
66
|
+
* the full ancestor chain, so depth costs nothing extra.
|
|
67
|
+
*/
|
|
60
68
|
category?.id
|
|
61
|
-
? safeRead(() => categoriesRepository.
|
|
69
|
+
? safeRead(() => categoriesRepository.getDescendants(category.id), {
|
|
62
70
|
route: "/categories/[slug]",
|
|
63
|
-
key: "category.
|
|
71
|
+
key: "category.descendants",
|
|
64
72
|
fallback: [],
|
|
65
73
|
})
|
|
66
74
|
: Promise.resolve([]),
|
|
@@ -153,7 +161,11 @@ export async function CategoryDetailPageView({ slug }) {
|
|
|
153
161
|
const preOrderCount = counts["pre-orders"] ?? 0;
|
|
154
162
|
const coverImage = category?.display?.coverImage;
|
|
155
163
|
const hasCover = Boolean(coverImage);
|
|
156
|
-
return (_jsxs(Main, { children: [_jsxs(Section, { className: `relative ${__O.hidden} ${hasCover ? "min-h-[220px] md:min-h-[280px]" : "bg-[var(--appkit-color-bg)]"}`, children: [hasCover && (_jsxs(_Fragment, { children: [_jsx(Div, { className: "absolute inset-0", children: _jsx(MediaImage, { src: coverImage, alt: "", size: "banner" }) }), _jsx(Div, { surface: "overlay-md", className: "absolute inset-0" })] })), _jsxs(Div, { className: "relative z-10 max-w-7xl mx-auto", paddingY: hasCover ? "y-3xl" : "y-xl", padding: "x-md", children: [_jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "sm", className: "mb-4", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Home" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORIES), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Categories" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Span, { weight: "medium", className: hasCover ? "text-white" : "text-[var(--appkit-color-text)]", children: category?.name ?? slug })] }), _jsx(Heading, { color: "inverse", level: 1, className: `mb-2 ${hasCover ? "" : "text-[var(--appkit-color-text)]"}`, size: "3xl", mdSize: "4xl", weight: "bold", children: category?.name ?? slug }), category?.description && typeof category.description === "string" && !category.description.startsWith("{") && (_jsx(Text, { color: hasCover ? "inverse" : "muted", className: `max-w-2xl mb-4 ${hasCover ? "/80" : ""}`, size: "base", children: category.description })), _jsxs(Row, { wrap: true, gap: "sm", children: [productCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: "inverse", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "bg-primary/10 text-primary-700 dark:text-primary-400", rounded: "full", padding: "pill-sm-tall", children: [productCount.toLocaleString(), " ", productCount === 1 ? "product" : "products"] })), auctionCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "warning", surface: hasCover ? undefined : "warning-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [auctionCount.toLocaleString(), " ", auctionCount === 1 ? "auction" : "auctions"] })), preOrderCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "info", surface: hasCover ? undefined : "info-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [preOrderCount.toLocaleString(), " ", preOrderCount === 1 ? "pre-order" : "pre-orders"] })), storeCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "success", surface: hasCover ? undefined : "success-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [storeCount.toLocaleString(), " ", storeCount === 1 ? "store" : "stores"] }))] })] })] }), _jsx(CategoryHighlightsAndFaqSection, { highlights: category?.highlights, faqs: category?.faqs }), childCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", children: _jsx(Div, { className: "max-w-7xl mx-auto", padding: "inline", children:
|
|
157
|
-
|
|
158
|
-
|
|
164
|
+
return (_jsxs(Main, { children: [_jsxs(Section, { className: `relative ${__O.hidden} ${hasCover ? "min-h-[220px] md:min-h-[280px]" : "bg-[var(--appkit-color-bg)]"}`, children: [hasCover && (_jsxs(_Fragment, { children: [_jsx(Div, { className: "absolute inset-0", children: _jsx(MediaImage, { src: coverImage, alt: "", size: "banner" }) }), _jsx(Div, { surface: "overlay-md", className: "absolute inset-0" })] })), _jsxs(Div, { className: "relative z-10 max-w-7xl mx-auto", paddingY: hasCover ? "y-3xl" : "y-xl", padding: "x-md", children: [_jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "sm", className: "mb-4", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Home" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORIES), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Categories" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Span, { weight: "medium", className: hasCover ? "text-white" : "text-[var(--appkit-color-text)]", children: category?.name ?? slug })] }), _jsx(Heading, { color: "inverse", level: 1, className: `mb-2 ${hasCover ? "" : "text-[var(--appkit-color-text)]"}`, size: "3xl", mdSize: "4xl", weight: "bold", children: category?.name ?? slug }), category?.description && typeof category.description === "string" && !category.description.startsWith("{") && (_jsx(Text, { color: hasCover ? "inverse" : "muted", className: `max-w-2xl mb-4 ${hasCover ? "/80" : ""}`, size: "base", children: category.description })), _jsxs(Row, { wrap: true, gap: "sm", children: [productCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: "inverse", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "bg-primary/10 text-primary-700 dark:text-primary-400", rounded: "full", padding: "pill-sm-tall", children: [productCount.toLocaleString(), " ", productCount === 1 ? "product" : "products"] })), auctionCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "warning", surface: hasCover ? undefined : "warning-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [auctionCount.toLocaleString(), " ", auctionCount === 1 ? "auction" : "auctions"] })), preOrderCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "info", surface: hasCover ? undefined : "info-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [preOrderCount.toLocaleString(), " ", preOrderCount === 1 ? "pre-order" : "pre-orders"] })), storeCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "success", surface: hasCover ? undefined : "success-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [storeCount.toLocaleString(), " ", storeCount === 1 ? "store" : "stores"] }))] })] })] }), _jsx(CategoryHighlightsAndFaqSection, { highlights: category?.highlights, faqs: category?.faqs }), childCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", children: _jsx(Div, { className: "max-w-7xl mx-auto", padding: "inline", children: Array.from(new Set(childCategories.map((c) => c.tier)))
|
|
165
|
+
.sort((a, b) => a - b)
|
|
166
|
+
.map((tier) => (_jsxs(Div, { padding: "b-2xs", children: [_jsx(Text, { size: "xs", color: "muted", weight: "medium", className: "mb-[var(--appkit-space-1-5)]", children: tier - (category?.tier ?? 0) === 1
|
|
167
|
+
? "Subcategories"
|
|
168
|
+
: `${tier - (category?.tier ?? 0)} levels down` }), _jsx(Div, { layout: "flex", gap: "2", className: `.5 ${__O.xAuto} [scrollbar-width:none]`, padding: "b-2xs", children: childCategories.filter((c) => c.tier === tier).map((child) => (_jsxs(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(child.slug)),
|
|
169
|
+
// audit-responsive-wrap-ok: deliberate horizontal-scroll chip row (parent has overflow-x-auto) — each chip staying single-line is the intended design, not hidden content
|
|
170
|
+
className: "flex-shrink-0 flex items-center gap-[var(--appkit-space-1-5)] rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-elevated)] px-[var(--appkit-space-4)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-sm)] font-medium text-[var(--appkit-color-text-muted)] hover:border-primary hover:text-primary transition-colors whitespace-nowrap", children: [child.display?.icon && (_jsx(Span, { className: "leading-none", children: child.display.icon })), child.name, (child.metrics?.totalProductCount ?? child.metrics?.productCount ?? 0) > 0 && (_jsx(Span, { size: "xs", color: "muted", children: (child.metrics?.totalProductCount ?? child.metrics?.productCount ?? 0).toLocaleString() }))] }, child.id))) })] }, tier))) }) })), _jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(CategoryDetailTabs, { categorySlug: slug, categoryId: category?.id, initialProductsData: productsResult ?? undefined, initialBundles: bundlesResult ?? [], initialStores: categoryStores, counts: counts, enabledListingTypes: enabledListingTypes(settings), enabledCategoryTypes: enabledCategoryTypes(settings) }) }) }), groupedListings.length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(GroupedListingsCarousel, { groups: groupedListings }) }) })), relatedCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Categories" }), _jsx(CategoryGrid, { categories: relatedCategories, getHref: (c) => String(ROUTES.PUBLIC.CATEGORY_DETAIL(c.slug)) })] }) }))] }));
|
|
159
171
|
}
|
|
@@ -20,9 +20,73 @@ export declare class CategoriesRepository extends BaseRepository<CategoryDocumen
|
|
|
20
20
|
* listing/count to include products filed under any of its children.
|
|
21
21
|
*/
|
|
22
22
|
getDescendantIds(categoryId: string): Promise<string[]>;
|
|
23
|
+
/**
|
|
24
|
+
* Every descendant DOCUMENT at any depth.
|
|
25
|
+
*
|
|
26
|
+
* 🛑 PAGINATED, DELIBERATELY. This query used to carry a bare `.limit(100)`,
|
|
27
|
+
* which silently truncated the subtree — acceptable-ish for a display list,
|
|
28
|
+
* and corrupting for `reparentSubtree`, which rewrites exactly what this
|
|
29
|
+
* returns. A move that saw 100 of 130 descendants would leave the other 30
|
|
30
|
+
* pointing at the old ancestors with no error anywhere. A partial answer to
|
|
31
|
+
* "what is beneath this node" is worse than a slow one.
|
|
32
|
+
*/
|
|
33
|
+
getDescendants(categoryId: string): Promise<CategoryDocument[]>;
|
|
23
34
|
getFeaturedCategories(): Promise<CategoryDocument[]>;
|
|
24
35
|
getBrandCategories(limit?: number): Promise<CategoryDocument[]>;
|
|
25
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Move `categoryId` under `newParentId`, rewriting the WHOLE subtree.
|
|
38
|
+
*
|
|
39
|
+
* 🛑 THE DESCENDANTS ARE THE POINT. The previous `moveCategory` rewrote only
|
|
40
|
+
* the moved node's own hierarchy fields, so after a move every descendant
|
|
41
|
+
* still claimed its OLD ancestors — which breaks the `array-contains` subtree
|
|
42
|
+
* query, the category page's own listing, and `ProductRepository.deriveTaxonomy`
|
|
43
|
+
* (it reads the leaf's `parentIds` to build each product's `categorySlugs`
|
|
44
|
+
* chain). That is a data-corruption bug wearing the costume of a stale count.
|
|
45
|
+
*
|
|
46
|
+
* 🛑 AND THE METRIC DELTAS LIVE HERE, NOT IN A TRIGGER. `onCategoryWrite` is a
|
|
47
|
+
* `categories` trigger that writes `categories`, and it is safe only because
|
|
48
|
+
* every write it makes is gated on `parentIds` CHANGING — a structural guard.
|
|
49
|
+
* A `metrics.*` write is not covered by that gate, so putting rollup
|
|
50
|
+
* maintenance there is the Root Cause 92 shape that recursed 1,017,548 times
|
|
51
|
+
* in 24 hours. Doing it in the same batch as the structural change has no
|
|
52
|
+
* cycle to guard against in the first place.
|
|
53
|
+
*
|
|
54
|
+
* Returns the ids it rewrote, so a caller can log or verify the blast radius.
|
|
55
|
+
*/
|
|
56
|
+
reparentSubtree(categoryId: string, newParentId: string | null): Promise<string[]>;
|
|
57
|
+
/**
|
|
58
|
+
* Re-derive `categorySlugs` for every product inside a moved subtree.
|
|
59
|
+
*
|
|
60
|
+
* 🛑 WITHOUT THIS THE MOVE IS ONLY HALF DONE. `categorySlugs` is a
|
|
61
|
+
* DENORMALISED COPY of the ancestor chain, written by
|
|
62
|
+
* `ProductRepository.deriveTaxonomy` at product-write time. Moving a category
|
|
63
|
+
* changes its descendants' chains but writes no product, so every product in
|
|
64
|
+
* the subtree keeps pointing at the OLD ancestors — it stays listed under the
|
|
65
|
+
* category it left and never appears under the one it joined. That is the same
|
|
66
|
+
* unreachable-listing bug the move is supposed to fix, one level down.
|
|
67
|
+
*
|
|
68
|
+
* Metric-neutral, and that is load-bearing: `onProductWrite` keys its re-file
|
|
69
|
+
* branch on the LEAF (`categorySlugs[0]`), which does not change when an
|
|
70
|
+
* ancestor moves. So these writes early-return in the trigger and apply no
|
|
71
|
+
* delta — the rollup was already moved by `reparentSubtree`. If this ever
|
|
72
|
+
* started changing the leaf, it would double-count.
|
|
73
|
+
*
|
|
74
|
+
* Reads `products` through `this.db` rather than importing ProductRepository:
|
|
75
|
+
* no repository in this codebase imports another feature's, because that is
|
|
76
|
+
* the import chain that drags firebase-admin into a client bundle.
|
|
77
|
+
*/
|
|
78
|
+
private rewriteProductChains;
|
|
79
|
+
/**
|
|
80
|
+
* Commit `writes` in chunks. Firestore caps a batch at 500 operations, and a
|
|
81
|
+
* subtree rewrite plus its ancestor deltas can exceed that on a deep forest.
|
|
82
|
+
*/
|
|
83
|
+
private commitChunked;
|
|
84
|
+
/**
|
|
85
|
+
* The public move. Everything real lives in `reparentSubtree` so the
|
|
86
|
+
* delete-cascade path and this share one implementation — two copies of a
|
|
87
|
+
* subtree rewrite would drift, and only one of them would be the one anybody
|
|
88
|
+
* tested.
|
|
89
|
+
*/
|
|
26
90
|
moveCategory(input: CategoryMoveInput): Promise<CategoryDocument>;
|
|
27
91
|
buildTree(rootId?: string): Promise<CategoryTreeNode[]>;
|
|
28
92
|
toggleFeatured(categoryId: string, featured: boolean): Promise<CategoryDocument>;
|
|
@@ -169,13 +169,42 @@ export class CategoriesRepository extends BaseRepository {
|
|
|
169
169
|
* listing/count to include products filed under any of its children.
|
|
170
170
|
*/
|
|
171
171
|
async getDescendantIds(categoryId) {
|
|
172
|
+
const docs = await this.getDescendants(categoryId);
|
|
173
|
+
return docs.map((d) => d.id);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Every descendant DOCUMENT at any depth.
|
|
177
|
+
*
|
|
178
|
+
* 🛑 PAGINATED, DELIBERATELY. This query used to carry a bare `.limit(100)`,
|
|
179
|
+
* which silently truncated the subtree — acceptable-ish for a display list,
|
|
180
|
+
* and corrupting for `reparentSubtree`, which rewrites exactly what this
|
|
181
|
+
* returns. A move that saw 100 of 130 descendants would leave the other 30
|
|
182
|
+
* pointing at the old ancestors with no error anywhere. A partial answer to
|
|
183
|
+
* "what is beneath this node" is worse than a slow one.
|
|
184
|
+
*/
|
|
185
|
+
async getDescendants(categoryId) {
|
|
172
186
|
try {
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
.
|
|
178
|
-
|
|
187
|
+
const out = [];
|
|
188
|
+
const pageSize = 300;
|
|
189
|
+
let cursor = null;
|
|
190
|
+
for (;;) {
|
|
191
|
+
let q = this.db
|
|
192
|
+
.collection(this.collection)
|
|
193
|
+
.where("parentIds", "array-contains", categoryId)
|
|
194
|
+
.orderBy("__name__")
|
|
195
|
+
.limit(pageSize);
|
|
196
|
+
if (cursor)
|
|
197
|
+
q = q.startAfter(cursor);
|
|
198
|
+
const page = await q.get();
|
|
199
|
+
if (page.empty)
|
|
200
|
+
break;
|
|
201
|
+
for (const doc of page.docs)
|
|
202
|
+
out.push(this.mapDoc(doc));
|
|
203
|
+
if (page.size < pageSize)
|
|
204
|
+
break;
|
|
205
|
+
cursor = page.docs[page.docs.length - 1];
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
179
208
|
}
|
|
180
209
|
catch (error) {
|
|
181
210
|
void normalizeError(error);
|
|
@@ -215,89 +244,228 @@ export class CategoriesRepository extends BaseRepository {
|
|
|
215
244
|
throw new DatabaseError(`Failed to retrieve brand categories: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
216
245
|
}
|
|
217
246
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
247
|
+
/**
|
|
248
|
+
* Move `categoryId` under `newParentId`, rewriting the WHOLE subtree.
|
|
249
|
+
*
|
|
250
|
+
* 🛑 THE DESCENDANTS ARE THE POINT. The previous `moveCategory` rewrote only
|
|
251
|
+
* the moved node's own hierarchy fields, so after a move every descendant
|
|
252
|
+
* still claimed its OLD ancestors — which breaks the `array-contains` subtree
|
|
253
|
+
* query, the category page's own listing, and `ProductRepository.deriveTaxonomy`
|
|
254
|
+
* (it reads the leaf's `parentIds` to build each product's `categorySlugs`
|
|
255
|
+
* chain). That is a data-corruption bug wearing the costume of a stale count.
|
|
256
|
+
*
|
|
257
|
+
* 🛑 AND THE METRIC DELTAS LIVE HERE, NOT IN A TRIGGER. `onCategoryWrite` is a
|
|
258
|
+
* `categories` trigger that writes `categories`, and it is safe only because
|
|
259
|
+
* every write it makes is gated on `parentIds` CHANGING — a structural guard.
|
|
260
|
+
* A `metrics.*` write is not covered by that gate, so putting rollup
|
|
261
|
+
* maintenance there is the Root Cause 92 shape that recursed 1,017,548 times
|
|
262
|
+
* in 24 hours. Doing it in the same batch as the structural change has no
|
|
263
|
+
* cycle to guard against in the first place.
|
|
264
|
+
*
|
|
265
|
+
* Returns the ids it rewrote, so a caller can log or verify the blast radius.
|
|
266
|
+
*/
|
|
267
|
+
async reparentSubtree(categoryId, newParentId) {
|
|
268
|
+
const node = await this.findByIdOrFail(categoryId);
|
|
269
|
+
const newParent = newParentId
|
|
270
|
+
? await this.findByIdOrFail(newParentId)
|
|
271
|
+
: null;
|
|
272
|
+
const oldParentId = node.parentIds[node.parentIds.length - 1] ?? null;
|
|
273
|
+
if (oldParentId === newParentId)
|
|
274
|
+
return [];
|
|
275
|
+
const descendants = await this.getDescendants(categoryId);
|
|
276
|
+
/*
|
|
277
|
+
* Cycle check. `isValidCategoryMove` only catches self-parenting and
|
|
278
|
+
* direct-child-as-parent, so a move onto a DEEPER descendant slips past it
|
|
279
|
+
* and detaches that whole branch from the forest with no error.
|
|
280
|
+
*/
|
|
281
|
+
if (newParentId && descendants.some((d) => d.id === newParentId)) {
|
|
282
|
+
throw new DatabaseError(`Invalid category move: ${newParentId} is a descendant of ${categoryId}`);
|
|
283
|
+
}
|
|
284
|
+
if (!isValidCategoryMove(categoryId, newParentId, node)) {
|
|
285
|
+
throw new DatabaseError("Invalid category move: circular reference detected");
|
|
286
|
+
}
|
|
287
|
+
const now = new Date();
|
|
288
|
+
const writes = [];
|
|
289
|
+
/*
|
|
290
|
+
* Recompute top-down. A descendant's IMMEDIATE parent never changes when an
|
|
291
|
+
* ancestor moves, so each node's new fields derive from its parent's new
|
|
292
|
+
* fields — one pass in tier order, no recursion and no re-reads.
|
|
293
|
+
*/
|
|
294
|
+
const rebuilt = new Map();
|
|
295
|
+
const movedFields = calculateCategoryFields(newParent, node.name, categoryId);
|
|
296
|
+
rebuilt.set(categoryId, { ...node, ...movedFields });
|
|
297
|
+
writes.push({ id: categoryId, data: { ...movedFields, updatedAt: now } });
|
|
298
|
+
for (const child of [...descendants].sort((a, b) => a.tier - b.tier)) {
|
|
299
|
+
const parentId = child.parentIds[child.parentIds.length - 1] ?? "";
|
|
300
|
+
const parent = rebuilt.get(parentId);
|
|
301
|
+
// A descendant whose parent is missing from the subtree means the chain is
|
|
302
|
+
// already broken; leave it alone rather than writing a guess over it.
|
|
303
|
+
if (!parent)
|
|
304
|
+
continue;
|
|
305
|
+
const fields = calculateCategoryFields(parent, child.name, child.id);
|
|
306
|
+
rebuilt.set(child.id, { ...child, ...fields });
|
|
307
|
+
writes.push({ id: child.id, data: { ...fields, updatedAt: now } });
|
|
308
|
+
}
|
|
309
|
+
/*
|
|
310
|
+
* Rollup deltas, NETTED PER ANCESTOR.
|
|
311
|
+
*
|
|
312
|
+
* The old and new chains usually share ancestors (a move within one root),
|
|
313
|
+
* and Firestore rejects two updates to the same document in one batch — so a
|
|
314
|
+
* naive `-total` on the old chain followed by `+total` on the new one either
|
|
315
|
+
* throws or double-counts. Net them first and drop the zeros; for a move
|
|
316
|
+
* between siblings that leaves only two documents to touch.
|
|
317
|
+
*
|
|
318
|
+
* The delta is the moved node's own ROLLUP, not a subtree rescan: O(depth).
|
|
319
|
+
* The moved node's own counts do not change — only its ancestors' rollups.
|
|
320
|
+
*/
|
|
321
|
+
const m = node.metrics ?? {};
|
|
322
|
+
const dProducts = m?.totalProductCount ?? 0;
|
|
323
|
+
const dAuctions = m?.totalAuctionCount ?? 0;
|
|
324
|
+
if (dProducts !== 0 || dAuctions !== 0) {
|
|
325
|
+
const oldChain = node.parentIds;
|
|
326
|
+
const newChain = newParent ? [...newParent.parentIds, newParent.id] : [];
|
|
327
|
+
const net = new Map();
|
|
328
|
+
for (const id of oldChain)
|
|
329
|
+
net.set(id, (net.get(id) ?? 0) - 1);
|
|
330
|
+
for (const id of newChain)
|
|
331
|
+
net.set(id, (net.get(id) ?? 0) + 1);
|
|
332
|
+
for (const [id, sign] of net) {
|
|
333
|
+
if (sign === 0)
|
|
334
|
+
continue;
|
|
335
|
+
writes.push({
|
|
336
|
+
id,
|
|
337
|
+
data: {
|
|
338
|
+
"metrics.totalProductCount": increment(dProducts * sign),
|
|
339
|
+
"metrics.totalAuctionCount": increment(dAuctions * sign),
|
|
340
|
+
"metrics.totalItemCount": increment((dProducts + dAuctions) * sign),
|
|
341
|
+
"metrics.lastUpdated": now,
|
|
342
|
+
updatedAt: now,
|
|
343
|
+
},
|
|
246
344
|
});
|
|
247
345
|
}
|
|
248
|
-
|
|
346
|
+
}
|
|
347
|
+
if (oldParentId) {
|
|
348
|
+
writes.push({
|
|
349
|
+
id: oldParentId,
|
|
350
|
+
data: { childrenIds: arrayRemove(categoryId), updatedAt: now },
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
if (newParentId) {
|
|
354
|
+
writes.push({
|
|
355
|
+
id: newParentId,
|
|
356
|
+
data: {
|
|
357
|
+
childrenIds: arrayUnion(categoryId),
|
|
358
|
+
isLeaf: false,
|
|
359
|
+
updatedAt: now,
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
await this.commitChunked(writes);
|
|
249
365
|
}
|
|
250
366
|
catch (error) {
|
|
251
367
|
void normalizeError(error);
|
|
252
|
-
throw new DatabaseError(`Failed to
|
|
368
|
+
throw new DatabaseError(`Failed to move category ${categoryId}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
369
|
+
}
|
|
370
|
+
/*
|
|
371
|
+
* A parent that just lost its last child becomes a leaf again. Done after the
|
|
372
|
+
* commit because `childrenIds` is an arrayRemove above — reading it before
|
|
373
|
+
* would see the stale array.
|
|
374
|
+
*/
|
|
375
|
+
if (oldParentId) {
|
|
376
|
+
const oldParent = await this.findById(oldParentId);
|
|
377
|
+
if (oldParent && (oldParent.childrenIds?.length ?? 0) === 0) {
|
|
378
|
+
await this.db
|
|
379
|
+
.collection(this.collection)
|
|
380
|
+
.doc(oldParentId)
|
|
381
|
+
.update({ isLeaf: true, updatedAt: new Date() });
|
|
382
|
+
}
|
|
253
383
|
}
|
|
384
|
+
await this.rewriteProductChains(rebuilt);
|
|
385
|
+
return writes.map((w) => w.id);
|
|
254
386
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
387
|
+
/**
|
|
388
|
+
* Re-derive `categorySlugs` for every product inside a moved subtree.
|
|
389
|
+
*
|
|
390
|
+
* 🛑 WITHOUT THIS THE MOVE IS ONLY HALF DONE. `categorySlugs` is a
|
|
391
|
+
* DENORMALISED COPY of the ancestor chain, written by
|
|
392
|
+
* `ProductRepository.deriveTaxonomy` at product-write time. Moving a category
|
|
393
|
+
* changes its descendants' chains but writes no product, so every product in
|
|
394
|
+
* the subtree keeps pointing at the OLD ancestors — it stays listed under the
|
|
395
|
+
* category it left and never appears under the one it joined. That is the same
|
|
396
|
+
* unreachable-listing bug the move is supposed to fix, one level down.
|
|
397
|
+
*
|
|
398
|
+
* Metric-neutral, and that is load-bearing: `onProductWrite` keys its re-file
|
|
399
|
+
* branch on the LEAF (`categorySlugs[0]`), which does not change when an
|
|
400
|
+
* ancestor moves. So these writes early-return in the trigger and apply no
|
|
401
|
+
* delta — the rollup was already moved by `reparentSubtree`. If this ever
|
|
402
|
+
* started changing the leaf, it would double-count.
|
|
403
|
+
*
|
|
404
|
+
* Reads `products` through `this.db` rather than importing ProductRepository:
|
|
405
|
+
* no repository in this codebase imports another feature's, because that is
|
|
406
|
+
* the import chain that drags firebase-admin into a client bundle.
|
|
407
|
+
*/
|
|
408
|
+
async rewriteProductChains(rebuilt) {
|
|
409
|
+
const writes = [];
|
|
410
|
+
for (const [categoryId, cat] of rebuilt) {
|
|
411
|
+
// Self first, root last — byte-identical to what deriveTaxonomy produces.
|
|
412
|
+
const chain = [categoryId, ...(cat.parentIds ?? []).slice().reverse()];
|
|
413
|
+
/*
|
|
414
|
+
* Byte-identical to what deriveTaxonomy produces, INCLUDING the falsy
|
|
415
|
+
* filter — these two must agree, or a product's names chain would differ
|
|
416
|
+
* depending on whether it was last touched by a product write or a
|
|
417
|
+
* category move, and nothing would report the difference.
|
|
418
|
+
*/
|
|
419
|
+
const names = [
|
|
420
|
+
cat.name,
|
|
421
|
+
...(cat.ancestors ?? []).map((a) => a?.name).reverse(),
|
|
422
|
+
].filter((n) => !!n);
|
|
423
|
+
const snap = await this.db
|
|
424
|
+
.collection("products")
|
|
425
|
+
.where(PRODUCT_FIELDS.CATEGORY, "==", categoryId)
|
|
426
|
+
.get();
|
|
427
|
+
for (const doc of snap.docs) {
|
|
428
|
+
writes.push({
|
|
429
|
+
id: doc.id,
|
|
430
|
+
data: { categorySlugs: chain, categoryNames: names, updatedAt: new Date() },
|
|
431
|
+
});
|
|
265
432
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
433
|
+
}
|
|
434
|
+
if (!writes.length)
|
|
435
|
+
return;
|
|
436
|
+
const products = this.db.collection("products");
|
|
437
|
+
for (let i = 0; i < writes.length; i += 400) {
|
|
270
438
|
const batch = this.db.batch();
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
batch.update(categoryRef, {
|
|
274
|
-
...hierarchyFields,
|
|
275
|
-
updatedAt: now,
|
|
276
|
-
});
|
|
277
|
-
if (oldParentId) {
|
|
278
|
-
const oldParentRef = this.db
|
|
279
|
-
.collection(this.collection)
|
|
280
|
-
.doc(oldParentId);
|
|
281
|
-
batch.update(oldParentRef, {
|
|
282
|
-
childrenIds: arrayRemove(categoryId),
|
|
283
|
-
updatedAt: now,
|
|
284
|
-
});
|
|
285
|
-
const oldParent = await this.findById(oldParentId);
|
|
286
|
-
if (oldParent && oldParent.childrenIds.length === 1) {
|
|
287
|
-
batch.update(oldParentRef, { isLeaf: true });
|
|
288
|
-
}
|
|
439
|
+
for (const w of writes.slice(i, i + 400)) {
|
|
440
|
+
batch.update(products.doc(w.id), w.data);
|
|
289
441
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
442
|
+
await batch.commit();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Commit `writes` in chunks. Firestore caps a batch at 500 operations, and a
|
|
447
|
+
* subtree rewrite plus its ancestor deltas can exceed that on a deep forest.
|
|
448
|
+
*/
|
|
449
|
+
async commitChunked(writes, chunkSize = 400) {
|
|
450
|
+
const colRef = this.db.collection(this.collection);
|
|
451
|
+
for (let i = 0; i < writes.length; i += chunkSize) {
|
|
452
|
+
const batch = this.db.batch();
|
|
453
|
+
for (const w of writes.slice(i, i + chunkSize)) {
|
|
454
|
+
batch.update(colRef.doc(w.id), w.data);
|
|
299
455
|
}
|
|
300
456
|
await batch.commit();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* The public move. Everything real lives in `reparentSubtree` so the
|
|
461
|
+
* delete-cascade path and this share one implementation — two copies of a
|
|
462
|
+
* subtree rewrite would drift, and only one of them would be the one anybody
|
|
463
|
+
* tested.
|
|
464
|
+
*/
|
|
465
|
+
async moveCategory(input) {
|
|
466
|
+
try {
|
|
467
|
+
const { categoryId, newParentId } = input;
|
|
468
|
+
await this.reparentSubtree(categoryId, newParentId ?? null);
|
|
301
469
|
return this.findByIdOrFail(categoryId);
|
|
302
470
|
}
|
|
303
471
|
catch (error) {
|
|
@@ -21,11 +21,11 @@ export interface CategoryMetrics {
|
|
|
21
21
|
totalItemCount: number;
|
|
22
22
|
lastUpdated?: string;
|
|
23
23
|
}
|
|
24
|
-
export
|
|
24
|
+
export type CategoryAncestor = {
|
|
25
25
|
id: string;
|
|
26
26
|
name: string;
|
|
27
27
|
tier: number;
|
|
28
|
-
}
|
|
28
|
+
};
|
|
29
29
|
export interface CategoryItem {
|
|
30
30
|
id: string;
|
|
31
31
|
categoryType?: CategoryType;
|