@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
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.45.0",
4
- "head": "e3dc5508a7fce3a3a34411ebc9f21fcd1742f070",
3
+ "version": "4.47.0",
4
+ "head": "f72c5f183b70c66b626c7809ffb1a4b0112e7cb1",
5
5
  "branch": "main",
6
- "dirty": false,
7
- "builtAt": "2026-10-12T01:23:27.586Z"
6
+ "dirty": true,
7
+ "builtAt": "2026-10-13T05:22:53.600Z"
8
8
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * G3b β€” read the live `categoryCounts` documents for a BOUNDED set of ids.
3
+ *
4
+ * πŸ›‘ WHO THIS IS FOR, because it is not the category detail page.
5
+ * `CategoryDetailPageView` already computes its own tab counts live
6
+ * (`listing-tab-counts`), so its headline numbers were never stale. The stale
7
+ * readers are the ones that render MANY categories and therefore cannot query
8
+ * per category: the `/categories` grid, the homepage category tiles, the
9
+ * admin table, the tree, and the child chips on a category page. All five read
10
+ * `metrics.totalProductCount`, which only the nightly reconciler writes.
11
+ *
12
+ * πŸ›‘ `db.getAll` β€” ONE round-trip for N ids, not N round-trips. That is what
13
+ * reconciles this with the plan's own warning that `CategoryDetailPageView` is
14
+ * already at Rule #6's ~3-sequential-round-trip ceiling and "a sidecar
15
+ * collection would add an eighth": the existing seven reads are a
16
+ * `Promise.all`, so an eighth PARALLEL read adds no latency. It does add N
17
+ * document reads of quota, bounded by the page size the caller already
18
+ * fetched.
19
+ *
20
+ * πŸ›‘ A missing document is `undefined`, never 0. The caller must fall back to
21
+ * `metrics` β€” rendering a confident zero for a category whose counter has not
22
+ * been written yet is Root Cause #72's lesson (a count that is unknown must
23
+ * never be indistinguishable from a count of nothing), and it would make this
24
+ * change a regression on every category until its first mutation.
25
+ */
26
+ export interface LiveCountsResult {
27
+ /** categoryId β†’ the rolled-up total, when a counts document exists. */
28
+ total: Map<string, number>;
29
+ /** True when the id list was truncated, so the caller can stop claiming completeness. */
30
+ truncated: boolean;
31
+ }
32
+ export declare function getLiveCategoryCounts(db: {
33
+ collection(name: string): {
34
+ doc(id: string): unknown;
35
+ };
36
+ getAll(...refs: unknown[]): Promise<Array<{
37
+ exists: boolean;
38
+ id: string;
39
+ data(): unknown;
40
+ }>>;
41
+ }, ids: readonly string[]): Promise<LiveCountsResult>;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * G3b β€” read the live `categoryCounts` documents for a BOUNDED set of ids.
3
+ *
4
+ * πŸ›‘ WHO THIS IS FOR, because it is not the category detail page.
5
+ * `CategoryDetailPageView` already computes its own tab counts live
6
+ * (`listing-tab-counts`), so its headline numbers were never stale. The stale
7
+ * readers are the ones that render MANY categories and therefore cannot query
8
+ * per category: the `/categories` grid, the homepage category tiles, the
9
+ * admin table, the tree, and the child chips on a category page. All five read
10
+ * `metrics.totalProductCount`, which only the nightly reconciler writes.
11
+ *
12
+ * πŸ›‘ `db.getAll` β€” ONE round-trip for N ids, not N round-trips. That is what
13
+ * reconciles this with the plan's own warning that `CategoryDetailPageView` is
14
+ * already at Rule #6's ~3-sequential-round-trip ceiling and "a sidecar
15
+ * collection would add an eighth": the existing seven reads are a
16
+ * `Promise.all`, so an eighth PARALLEL read adds no latency. It does add N
17
+ * document reads of quota, bounded by the page size the caller already
18
+ * fetched.
19
+ *
20
+ * πŸ›‘ A missing document is `undefined`, never 0. The caller must fall back to
21
+ * `metrics` β€” rendering a confident zero for a category whose counter has not
22
+ * been written yet is Root Cause #72's lesson (a count that is unknown must
23
+ * never be indistinguishable from a count of nothing), and it would make this
24
+ * change a regression on every category until its first mutation.
25
+ */
26
+ import { normalizeError } from "../../../../errors/normalize.js";
27
+ import { serverLogger } from "../../../../monitoring/server-logger.js";
28
+ import { CATEGORY_COUNTS_COLLECTION, liveTotalFor, } from "../../../../features/categories/schemas/category-counts.js";
29
+ /** Hard ceiling on one batch β€” `getAll` is a single RPC but not a free one. */
30
+ const MAX_IDS = 200;
31
+ export async function getLiveCategoryCounts(db, ids) {
32
+ const unique = Array.from(new Set(ids.filter(Boolean)));
33
+ const truncated = unique.length > MAX_IDS;
34
+ const take = truncated ? unique.slice(0, MAX_IDS) : unique;
35
+ const total = new Map();
36
+ if (take.length === 0)
37
+ return { total, truncated };
38
+ try {
39
+ const col = db.collection(CATEGORY_COUNTS_COLLECTION);
40
+ const snaps = await db.getAll(...take.map((id) => col.doc(id)));
41
+ for (const snap of snaps) {
42
+ if (!snap.exists)
43
+ continue;
44
+ const v = liveTotalFor(snap.data());
45
+ if (v !== undefined)
46
+ total.set(snap.id, v);
47
+ }
48
+ }
49
+ catch (err) {
50
+ // Typed FIRST, on the catch's own line, because `audit-catch-normalize`
51
+ // reads a short window after the clause and the explanation below is
52
+ // longer than it.
53
+ const e = normalizeError(err);
54
+ /*
55
+ * Fail OPEN with an empty map, so every caller falls back to `metrics` and
56
+ * renders exactly as it does today. A count is a display number: throwing
57
+ * here would take down a category page over a counter, which is the trade
58
+ * `.catch(() => null)` normally gets wrong β€” the difference is that here
59
+ * the fallback is a REAL alternative value, not an empty page.
60
+ *
61
+ * πŸ›‘ But it is LOGGED, not swallowed. `void normalizeError(err)` as a
62
+ * catch's only statement is as silent as `catch {}` while looking handled,
63
+ * which is what `audit-silent-degrade` calls normalize theatre β€” and it
64
+ * caught exactly that here. Silent to the visitor, loud to the operator.
65
+ */
66
+ serverLogger.warn("categoryCounts batch read failed β€” falling back to metrics", {
67
+ ids: take.length,
68
+ error: e.message,
69
+ });
70
+ }
71
+ return { total, truncated };
72
+ }
@@ -72,6 +72,19 @@ export function expandCartLineToOrderRows(item, representative, productById) {
72
72
  ...(representative.categorySlugs?.[0]
73
73
  ? { categorySlug: representative.categorySlugs[0] }
74
74
  : {}),
75
+ /*
76
+ * I8 β€” the category and brand DISPLAY NAMES, beside the slug.
77
+ *
78
+ * `categoryNames[0]` is the default leaf's name, parallel to
79
+ * `categorySlugs[0]`. Snapshotted for the reason the slug alone is not
80
+ * enough: a slug survives a rename, but a receipt has to show a NAME, and
81
+ * re-resolving it at render time would let a renamed category change what
82
+ * a past invoice says.
83
+ */
84
+ ...(representative.categoryNames?.[0]
85
+ ? { categoryName: representative.categoryNames[0] }
86
+ : {}),
87
+ ...(representative.brand ? { brand: representative.brand } : {}),
75
88
  ...(representative.listingType ? { listingType: representative.listingType } : {}),
76
89
  ...(representative.condition ? { condition: representative.condition } : {}),
77
90
  // Legacy collapsed bundle rows (no groupMembers) keep their old shape so
@@ -122,6 +135,10 @@ export function expandCartLineToOrderRows(item, representative, productById) {
122
135
  * been deleted, which is the whole reason that snapshot exists.
123
136
  */
124
137
  ...(product?.categorySlugs?.[0] ? { categorySlug: product.categorySlugs[0] } : {}),
138
+ // I8 - the same snapshot per member. `product` may be gone by order time,
139
+ // which is exactly why the member snapshot exists, so both are guarded.
140
+ ...(product?.categoryNames?.[0] ? { categoryName: product.categoryNames[0] } : {}),
141
+ ...(product?.brand ? { brand: product.brand } : {}),
125
142
  ...(product?.listingType ? { listingType: product.listingType } : {}),
126
143
  // Per MEMBER β€” a bundle can mix a sealed item with a loose one, and a
127
144
  // blended condition would describe neither.
@@ -66,6 +66,35 @@ export function orderDocumentToOrder(doc) {
66
66
  // only Item / Qty / Price (Root Cause #57).
67
67
  ...(item.hsnCode ? { hsnCode: item.hsnCode } : {}),
68
68
  ...(item.gstRate != null ? { gstRate: item.gstRate } : {}),
69
+ /*
70
+ * I8 β€” the TAXONOMY snapshot, which this adapter dropped.
71
+ *
72
+ * πŸ›‘ Root Cause #57's exact shape, for the THIRD time in this one
73
+ * mapper: the fields exist on the document, are deliberately
74
+ * snapshotted at order time, and never reached any client. `finalSale`
75
+ * and the tax pair above were each added after the same discovery.
76
+ *
77
+ * `categorySlug` has carried a reason since C1 β€” *"a product
78
+ * re-categorised next year must not retroactively move last year's sale
79
+ * into a band it was never part of"* β€” and no order surface could read
80
+ * it. `categoryName` and `brand` are new, because a slug survives a
81
+ * rename and a NAME is what a receipt has to show: the whole point of
82
+ * snapshotting is that the record still reads correctly after the
83
+ * category or product it came from is renamed or deleted.
84
+ */
85
+ ...(item.categorySlug ? { categorySlug: item.categorySlug } : {}),
86
+ ...(item.categoryName ? { categoryName: item.categoryName } : {}),
87
+ ...(item.brand ? { brand: item.brand } : {}),
88
+ ...(item.condition ? { condition: item.condition } : {}),
89
+ /*
90
+ * The grouped-line keys. `groupOrderItemsByLine` collapses a bundle or
91
+ * grouped cart line for the receipt and keys on exactly these β€” so
92
+ * without them the client saw N separate rows where the buyer bought
93
+ * one thing.
94
+ */
95
+ ...(item.groupSlug ? { groupSlug: item.groupSlug } : {}),
96
+ ...(item.groupTitle ? { groupTitle: item.groupTitle } : {}),
97
+ ...(item.groupSource ? { groupSource: item.groupSource } : {}),
69
98
  }))
70
99
  : [
71
100
  {
@@ -0,0 +1,126 @@
1
+ /** One component the assembly consumes. */
2
+ export interface AssemblyComponent {
3
+ slotKey: string;
4
+ /** A part LISTING's id β€” a `ProductDocument`, not a `partGlossary` row. */
5
+ productId: string;
6
+ quantity: number;
7
+ }
8
+ export interface AssembleInput {
9
+ /** The store doing the assembly. Parts are `storeId`-scoped like any listing. */
10
+ storeId: string;
11
+ /** The complete-bey listing being created or restocked. */
12
+ targetProductId: string;
13
+ components: readonly AssemblyComponent[];
14
+ /** How many complete beys to build. */
15
+ units?: number;
16
+ }
17
+ export interface AssemblyResult {
18
+ targetProductId: string;
19
+ unitsBuilt: number;
20
+ /** Per component: what it had available, and what it has now. */
21
+ consumed: Array<{
22
+ productId: string;
23
+ before: number;
24
+ after: number;
25
+ taken: number;
26
+ }>;
27
+ }
28
+ /** The hard cap, matching the write schema's `.max(8)` on `assembledFrom`. */
29
+ export declare const MAX_ASSEMBLY_COMPONENTS = 8;
30
+ /**
31
+ * πŸ›‘ The fields read inside the transaction, named once.
32
+ *
33
+ * `availableQuantity` is the gate (see the header). `stockQuantity` is read
34
+ * only so the reservation can be CARRIED FORWARD rather than discarded β€”
35
+ * writing `availableQuantity` without adjusting `stockQuantity` by the same
36
+ * amount would make `reserved` drift by exactly the units consumed, and
37
+ * `resolveStockResync` would then hand those units back to the shelf on the
38
+ * seller's next edit. That is A4's bug arriving through a new door.
39
+ */
40
+ interface StockSnapshot {
41
+ stockQuantity: number;
42
+ availableQuantity: number;
43
+ storeId?: string;
44
+ }
45
+ /** What a planner decided to write, before anything touches Firestore. */
46
+ export interface StockWrite {
47
+ productId: string;
48
+ stockQuantity: number;
49
+ availableQuantity: number;
50
+ }
51
+ export interface AssemblyPlan {
52
+ writes: StockWrite[];
53
+ consumed: AssemblyResult["consumed"];
54
+ /** The complete-bey listing's own new figures. */
55
+ target: StockWrite;
56
+ }
57
+ /**
58
+ * The whole decision, as a PURE function of the documents and the request.
59
+ *
60
+ * πŸ›‘ Extracted because the arithmetic is the part most likely to be wrong and
61
+ * the transaction is the part hardest to test. A fake Firestore cannot be
62
+ * injected across an ESM namespace boundary β€” measured: `Object.defineProperty`
63
+ * on the imported namespace throws, so a mock-based harness reported every
64
+ * ordering assertion as green having executed none of them. Rather than
65
+ * weaken the module with a test-only injection seam, the thing worth asserting
66
+ * moved OUT of the transaction: which field gates the build, how the
67
+ * reservation is carried, and what is refused.
68
+ *
69
+ * `direction` is `-1` for an assembly (components are consumed) and `+1` for a
70
+ * part-out (components are produced), so there is ONE implementation of the
71
+ * gate and the carry rather than two that must agree.
72
+ */
73
+ export declare function planAssembly(args: {
74
+ storeId: string;
75
+ /** The complete bey: consumed on part-out, produced on assembly. */
76
+ subject: {
77
+ productId: string;
78
+ doc: StockSnapshot | undefined;
79
+ };
80
+ components: ReadonlyArray<{
81
+ request: AssemblyComponent;
82
+ doc: StockSnapshot | undefined;
83
+ }>;
84
+ units: number;
85
+ /** -1 consumes the components (assemble); +1 produces them (part out). */
86
+ direction: -1 | 1;
87
+ }): AssemblyPlan;
88
+ /**
89
+ * The input rules, shared by both directions so they cannot drift.
90
+ *
91
+ * Runs before any Firestore access, which is also why it is separable.
92
+ */
93
+ export declare function assertAssemblyInput(components: readonly AssemblyComponent[], units: number): void;
94
+ /**
95
+ * Build `units` copies of a complete bey, consuming its components.
96
+ *
97
+ * A thin shell over `planAssembly`: read everything, decide purely, write.
98
+ *
99
+ * Throws rather than partially succeeding β€”
100
+ * - `ValidationError` β€” malformed, missing, or another store's listing.
101
+ * - `ConflictError` β€” a component does not have enough available. 409 and
102
+ * not 400: the request is well formed and the seller is not confused, the
103
+ * world changed under them, so the next action is to restock rather than to
104
+ * fix a field. Same distinction as removing a booked lottery slot.
105
+ */
106
+ export declare function assembleFromComponents(input: AssembleInput): Promise<AssemblyResult>;
107
+ export interface PartOutInput {
108
+ storeId: string;
109
+ /** The complete bey being broken down. */
110
+ sourceProductId: string;
111
+ /** Which component listings receive the parts. */
112
+ components: readonly AssemblyComponent[];
113
+ units?: number;
114
+ }
115
+ /**
116
+ * The inverse: decrement the bey, increment each component.
117
+ *
118
+ * πŸ›‘ Spares arrive as whole beys too, so this is not a convenience β€” it is half
119
+ * the workflow. Without it, "part out" would only work on beys this system
120
+ * built, which is none of the current catalogue (GAP-10).
121
+ *
122
+ * Same planner, `direction: 1`. ONE implementation of the gate and the
123
+ * reservation carry rather than two that must agree.
124
+ */
125
+ export declare function partOutToComponents(input: PartOutInput): Promise<AssemblyResult>;
126
+ export {};
@@ -0,0 +1,246 @@
1
+ import { unitOfWork } from "../../../../core/unit-of-work.js";
2
+ import { PRODUCT_COLLECTION } from "../../../../features/products/schemas/firestore.js";
3
+ import { getAdminDb } from "../../../../providers/db-firebase/index.js";
4
+ import { serverLogger } from "../../../../monitoring/server-logger.js";
5
+ import { ValidationError, ConflictError } from "../../../../errors/index.js";
6
+ /** The hard cap, matching the write schema's `.max(8)` on `assembledFrom`. */
7
+ export const MAX_ASSEMBLY_COMPONENTS = 8;
8
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
9
+ /**
10
+ * The whole decision, as a PURE function of the documents and the request.
11
+ *
12
+ * πŸ›‘ Extracted because the arithmetic is the part most likely to be wrong and
13
+ * the transaction is the part hardest to test. A fake Firestore cannot be
14
+ * injected across an ESM namespace boundary β€” measured: `Object.defineProperty`
15
+ * on the imported namespace throws, so a mock-based harness reported every
16
+ * ordering assertion as green having executed none of them. Rather than
17
+ * weaken the module with a test-only injection seam, the thing worth asserting
18
+ * moved OUT of the transaction: which field gates the build, how the
19
+ * reservation is carried, and what is refused.
20
+ *
21
+ * `direction` is `-1` for an assembly (components are consumed) and `+1` for a
22
+ * part-out (components are produced), so there is ONE implementation of the
23
+ * gate and the carry rather than two that must agree.
24
+ */
25
+ export function planAssembly(args) {
26
+ const { storeId, subject, components, units, direction } = args;
27
+ if (!subject.doc) {
28
+ throw new ValidationError(direction === -1
29
+ ? "The listing being assembled no longer exists."
30
+ : "The listing being parted out no longer exists.");
31
+ }
32
+ if (subject.doc.storeId && subject.doc.storeId !== storeId) {
33
+ throw new ValidationError("That listing belongs to another store.");
34
+ }
35
+ /*
36
+ * πŸ›‘ On a PART-OUT the subject is what gets consumed, so it needs the same
37
+ * availability gate as a component: a bey with 2 in stock and 1 reserved can
38
+ * only have ONE parted out. On an assembly the subject only ever gains, so
39
+ * there is nothing to check.
40
+ */
41
+ const subjectAvailable = num(subject.doc.availableQuantity);
42
+ if (direction === 1 && subjectAvailable < units) {
43
+ throw new ConflictError(`Not enough available to part out: needs ${units}, has ${subjectAvailable}.`);
44
+ }
45
+ const consumed = [];
46
+ const writes = [];
47
+ for (const { request, doc } of components) {
48
+ if (!doc) {
49
+ throw new ValidationError(direction === -1
50
+ ? `A component is missing: ${request.productId}`
51
+ : `A destination component is missing: ${request.productId}`);
52
+ }
53
+ /*
54
+ * πŸ›‘ Scoped to the caller's own store. An admin assembling from a seller's
55
+ * bins would be writing another store's inventory; the only legitimate
56
+ * version is `store-letitrip-official` using its OWN stock, which this
57
+ * already allows.
58
+ */
59
+ if (doc.storeId && doc.storeId !== storeId) {
60
+ throw new ValidationError(`A component belongs to another store: ${request.productId}`);
61
+ }
62
+ const delta = request.quantity * units * direction;
63
+ const available = num(doc.availableQuantity);
64
+ // πŸ›‘ THE GATE, and it reads `availableQuantity` β€” see the file header.
65
+ if (direction === -1 && available < -delta) {
66
+ // 409, not 400: the request is well formed and the world changed.
67
+ // Figures in `data`, words in the client β€” see the part-out branch above.
68
+ throw new ConflictError(`component insufficient: needs ${-delta}, has ${available}`, "ASSEMBLY_INSUFFICIENT", { kind: "component", productId: request.productId, needs: -delta, has: available });
69
+ }
70
+ consumed.push({
71
+ productId: request.productId,
72
+ before: available,
73
+ after: available + delta,
74
+ taken: -delta,
75
+ });
76
+ writes.push({
77
+ productId: request.productId,
78
+ // Both fields move by the SAME amount, so an outstanding reservation is
79
+ // carried forward exactly rather than handed back to the shelf.
80
+ stockQuantity: Math.max(0, num(doc.stockQuantity) + delta),
81
+ availableQuantity: available + delta,
82
+ });
83
+ }
84
+ const subjectDelta = units * -direction;
85
+ return {
86
+ writes,
87
+ consumed,
88
+ target: {
89
+ productId: subject.productId,
90
+ stockQuantity: Math.max(0, num(subject.doc.stockQuantity) + subjectDelta),
91
+ availableQuantity: subjectAvailable + subjectDelta,
92
+ },
93
+ };
94
+ }
95
+ /**
96
+ * The input rules, shared by both directions so they cannot drift.
97
+ *
98
+ * Runs before any Firestore access, which is also why it is separable.
99
+ */
100
+ export function assertAssemblyInput(components, units) {
101
+ if (!Number.isInteger(units) || units < 1 || units > 99) {
102
+ throw new ValidationError("Quantity must be a whole number between 1 and 99.");
103
+ }
104
+ if (components.length === 0) {
105
+ throw new ValidationError("An assembly needs at least one component.");
106
+ }
107
+ if (components.length > MAX_ASSEMBLY_COMPONENTS) {
108
+ throw new ValidationError(`An assembly may involve at most ${MAX_ASSEMBLY_COMPONENTS} components.`);
109
+ }
110
+ /*
111
+ * πŸ›‘ Two entries for one product id would each be planned against the SAME
112
+ * read value and each subtract from it, so the second would silently
113
+ * overdraw. Refused rather than summed: a caller sending a product twice is
114
+ * describing something it does not mean, and summing would decide for them.
115
+ */
116
+ const seen = new Set();
117
+ for (const c of components) {
118
+ if (seen.has(c.productId)) {
119
+ throw new ValidationError("The same component appears twice. Combine it into one row with the total quantity.");
120
+ }
121
+ seen.add(c.productId);
122
+ }
123
+ }
124
+ /**
125
+ * Build `units` copies of a complete bey, consuming its components.
126
+ *
127
+ * A thin shell over `planAssembly`: read everything, decide purely, write.
128
+ *
129
+ * Throws rather than partially succeeding β€”
130
+ * - `ValidationError` β€” malformed, missing, or another store's listing.
131
+ * - `ConflictError` β€” a component does not have enough available. 409 and
132
+ * not 400: the request is well formed and the seller is not confused, the
133
+ * world changed under them, so the next action is to restock rather than to
134
+ * fix a field. Same distinction as removing a booked lottery slot.
135
+ */
136
+ export async function assembleFromComponents(input) {
137
+ const units = input.units ?? 1;
138
+ assertAssemblyInput(input.components, units);
139
+ const col = getAdminDb().collection(PRODUCT_COLLECTION);
140
+ return unitOfWork.runTransaction(async (tx) => {
141
+ // ── EVERY READ FIRST. Firestore requires it, and so does correctness. ──
142
+ const subjectRef = col.doc(input.targetProductId);
143
+ const componentRefs = input.components.map((c) => col.doc(c.productId));
144
+ const [subjectSnap, ...componentSnaps] = await tx.getAll(subjectRef, ...componentRefs);
145
+ const plan = planAssembly({
146
+ storeId: input.storeId,
147
+ subject: {
148
+ productId: input.targetProductId,
149
+ doc: subjectSnap.exists ? subjectSnap.data() : undefined,
150
+ },
151
+ components: input.components.map((request, i) => ({
152
+ request,
153
+ doc: componentSnaps[i].exists
154
+ ? componentSnaps[i].data()
155
+ : undefined,
156
+ })),
157
+ units,
158
+ direction: -1,
159
+ });
160
+ // ── WRITES ONLY AFTER EVERY READ ──────────────────────────────────────
161
+ const now = new Date();
162
+ for (const w of plan.writes) {
163
+ tx.update(col.doc(w.productId), {
164
+ stockQuantity: w.stockQuantity,
165
+ availableQuantity: w.availableQuantity,
166
+ updatedAt: now,
167
+ });
168
+ }
169
+ tx.update(subjectRef, {
170
+ stockQuantity: plan.target.stockQuantity,
171
+ availableQuantity: plan.target.availableQuantity,
172
+ assembledFrom: input.components.map((c) => ({ ...c })),
173
+ updatedAt: now,
174
+ });
175
+ serverLogger.info("assembleFromComponents", {
176
+ storeId: input.storeId,
177
+ targetProductId: input.targetProductId,
178
+ units,
179
+ components: plan.consumed.length,
180
+ });
181
+ return {
182
+ targetProductId: input.targetProductId,
183
+ unitsBuilt: units,
184
+ consumed: plan.consumed,
185
+ };
186
+ });
187
+ }
188
+ /**
189
+ * The inverse: decrement the bey, increment each component.
190
+ *
191
+ * πŸ›‘ Spares arrive as whole beys too, so this is not a convenience β€” it is half
192
+ * the workflow. Without it, "part out" would only work on beys this system
193
+ * built, which is none of the current catalogue (GAP-10).
194
+ *
195
+ * Same planner, `direction: 1`. ONE implementation of the gate and the
196
+ * reservation carry rather than two that must agree.
197
+ */
198
+ export async function partOutToComponents(input) {
199
+ const units = input.units ?? 1;
200
+ assertAssemblyInput(input.components, units);
201
+ const col = getAdminDb().collection(PRODUCT_COLLECTION);
202
+ return unitOfWork.runTransaction(async (tx) => {
203
+ const subjectRef = col.doc(input.sourceProductId);
204
+ const componentRefs = input.components.map((c) => col.doc(c.productId));
205
+ const [subjectSnap, ...componentSnaps] = await tx.getAll(subjectRef, ...componentRefs);
206
+ const plan = planAssembly({
207
+ storeId: input.storeId,
208
+ subject: {
209
+ productId: input.sourceProductId,
210
+ doc: subjectSnap.exists ? subjectSnap.data() : undefined,
211
+ },
212
+ components: input.components.map((request, i) => ({
213
+ request,
214
+ doc: componentSnaps[i].exists
215
+ ? componentSnaps[i].data()
216
+ : undefined,
217
+ })),
218
+ units,
219
+ direction: 1,
220
+ });
221
+ const now = new Date();
222
+ for (const w of plan.writes) {
223
+ tx.update(col.doc(w.productId), {
224
+ stockQuantity: w.stockQuantity,
225
+ availableQuantity: w.availableQuantity,
226
+ updatedAt: now,
227
+ });
228
+ }
229
+ tx.update(subjectRef, {
230
+ stockQuantity: plan.target.stockQuantity,
231
+ availableQuantity: plan.target.availableQuantity,
232
+ updatedAt: now,
233
+ });
234
+ serverLogger.info("partOutToComponents", {
235
+ storeId: input.storeId,
236
+ sourceProductId: input.sourceProductId,
237
+ units,
238
+ components: plan.consumed.length,
239
+ });
240
+ return {
241
+ targetProductId: input.sourceProductId,
242
+ unitsBuilt: units,
243
+ consumed: plan.consumed,
244
+ };
245
+ });
246
+ }
@@ -0,0 +1,65 @@
1
+ import { type PartGlossaryDocument, type ModelCompositionDocument, type PartSlot } from "../../../../features/parts/schemas/firestore";
2
+ /** The bucket cap. 87 is the largest real classification, so this clears it. */
3
+ export declare const PART_SLOT_PAGE_SIZE = 120;
4
+ /** One part, by `(slot, code|name)` β€” the only key this collection has. */
5
+ export declare const getPartByKey: (slot: PartSlot, codeOrName: string) => Promise<PartGlossaryDocument | null>;
6
+ /**
7
+ * The models that use a part, hydrated.
8
+ *
9
+ * πŸ›‘ `getAll` over the ids the part ALREADY carries, not a query. `usedBy` is
10
+ * written by the generator, so this is one round trip for N ids with no index
11
+ * and no scan β€” and it is bounded by how many models actually use the part
12
+ * (measured max: 20).
13
+ */
14
+ export declare const getModelsUsingPart: (partId: string) => Promise<ModelCompositionDocument[]>;
15
+ /** One model's composition, by the corpus doc id. */
16
+ export declare const getCompositionById: (id: string) => Promise<ModelCompositionDocument | null>;
17
+ /**
18
+ * Hydrate the glossary rows a composition's slots point at, so a model page can
19
+ * link each slot and show its weight.
20
+ *
21
+ * ONE `getAll` for every slot β€” a per-slot read would be five round trips on a
22
+ * page that already has its own.
23
+ */
24
+ export declare const getPartsForComposition: (compositionId: string) => Promise<Map<string, PartGlossaryDocument>>;
25
+ /**
26
+ * Compositions for a named-model CATEGORY.
27
+ *
28
+ * πŸ›‘ ONE INDEXED EQUALITY on `categoryId`, joined at SEED-GENERATION time.
29
+ *
30
+ * The first version queried `searchTxt` on `modelComposition` β€” a field that
31
+ * collection does not have, so it would have matched NOTHING while looking
32
+ * like a working read. The two id spaces genuinely disagree
33
+ * (`gen1-plastic-dragoon-f` vs `category-dragoon-f`), so a join is required;
34
+ * doing it in the generator makes it deterministic, reviewable in the
35
+ * committed seed, and one equality here instead of a name query.
36
+ *
37
+ * Measured: **210 of 259** compositions join. The join is on the normalised
38
+ * name, and on the name with its parenthetical expansion removed β€” the corpus
39
+ * titles HMS beys `Aero Knight MS (Aero Knight Metal Sky)` where the category
40
+ * is "Aero Knight MS", and without that second form only 150 matched.
41
+ *
42
+ * πŸ›‘ A non-match leaves `categoryId` unset and this returns `[]`, so the model
43
+ * page shows no composition. That is honest: a fuzzy match could attach
44
+ * ANOTHER bey's parts, and a wrong part list is worse than none. Reconciling
45
+ * the remaining 49 is E3's job.
46
+ */
47
+ export declare const getCompositionsForCategory: (categoryId: string) => Promise<ModelCompositionDocument[]>;
48
+ /**
49
+ * Every part in one classification, alphabetical.
50
+ *
51
+ * πŸ›‘ ONE equality on `slot`, bounded. The biggest bucket is 87 rows (attack
52
+ * rings) and the smallest 4 (armour) β€” measured, not assumed β€” so a page size
53
+ * of 120 shows every one of them today without a pager, and `truncated` tells
54
+ * the view to stop claiming a total the moment that stops being true.
55
+ *
56
+ * Firestore needs no composite index for one equality plus an `orderBy` on
57
+ * the SAME field it is already keyed by document id within… except it does
58
+ * for `(slot ==, name asc)`. So the sort is applied IN MEMORY: 87 rows is
59
+ * nothing to sort, and a declared index that exists only to order a bucket
60
+ * this small is an index to deploy, wait on, and remember.
61
+ */
62
+ export declare const listPartsBySlot: (slot: PartSlot) => Promise<{
63
+ parts: PartGlossaryDocument[];
64
+ truncated: boolean;
65
+ }>;