@mohasinac/appkit 4.60.1 → 4.61.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 (119) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/auctions/actions.d.ts +1 -1
  3. package/dist/_internal/server/features/auctions/actions.js +12 -1
  4. package/dist/_internal/server/features/blog/actions.d.ts +1 -1
  5. package/dist/_internal/server/features/blog/actions.js +12 -1
  6. package/dist/_internal/server/features/brands/actions.d.ts +1 -1
  7. package/dist/_internal/server/features/brands/actions.js +12 -1
  8. package/dist/_internal/server/features/cart/actions.d.ts +1 -1
  9. package/dist/_internal/server/features/cart/actions.js +12 -1
  10. package/dist/_internal/server/features/checkout/actions.js +0 -1
  11. package/dist/_internal/server/features/events/actions.d.ts +1 -1
  12. package/dist/_internal/server/features/events/actions.js +12 -1
  13. package/dist/_internal/server/features/gateway-events/redact.js +14 -2
  14. package/dist/_internal/server/features/history/actions.d.ts +1 -1
  15. package/dist/_internal/server/features/history/actions.js +12 -1
  16. package/dist/_internal/server/features/lottery/actions.d.ts +1 -1
  17. package/dist/_internal/server/features/lottery/actions.js +12 -1
  18. package/dist/_internal/server/features/orders/actions.d.ts +1 -1
  19. package/dist/_internal/server/features/orders/actions.js +12 -1
  20. package/dist/_internal/server/features/orders/cancellation-refund.js +0 -1
  21. package/dist/_internal/server/features/orders/service.js +31 -1
  22. package/dist/_internal/server/features/payouts/actions.d.ts +1 -1
  23. package/dist/_internal/server/features/payouts/actions.js +12 -1
  24. package/dist/_internal/server/features/points/award.js +9 -1
  25. package/dist/_internal/server/features/pre-orders/actions.d.ts +1 -1
  26. package/dist/_internal/server/features/pre-orders/actions.js +12 -1
  27. package/dist/_internal/server/features/prize-draws/actions.d.ts +1 -1
  28. package/dist/_internal/server/features/prize-draws/actions.js +12 -1
  29. package/dist/_internal/server/features/products/actions.d.ts +1 -1
  30. package/dist/_internal/server/features/products/actions.js +12 -1
  31. package/dist/_internal/server/features/promotions/actions.d.ts +1 -1
  32. package/dist/_internal/server/features/promotions/actions.js +12 -1
  33. package/dist/_internal/server/features/protection-claims/actions.d.ts +2 -2
  34. package/dist/_internal/server/features/protection-claims/actions.js +21 -12
  35. package/dist/_internal/server/features/raffle/actions.d.ts +1 -1
  36. package/dist/_internal/server/features/raffle/actions.js +12 -1
  37. package/dist/_internal/server/features/refunds/actions.d.ts +1 -3
  38. package/dist/_internal/server/features/refunds/actions.js +14 -2
  39. package/dist/_internal/server/features/reviews/actions.d.ts +1 -1
  40. package/dist/_internal/server/features/reviews/actions.js +12 -1
  41. package/dist/_internal/server/features/wishlist/actions.d.ts +1 -1
  42. package/dist/_internal/server/features/wishlist/actions.js +12 -1
  43. package/dist/_internal/server/jobs/core/obligationSweep.js +0 -1
  44. package/dist/_internal/server/jobs/core/paymentReconcile.js +84 -57
  45. package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
  46. package/dist/_internal/server/jobs/core/testDataCleanup.js +22 -1
  47. package/dist/_internal/server/jobs/handlers/onShipmentAllocationSync.js +16 -4
  48. package/dist/_internal/shared/features/promotions/schema.d.ts +66 -0
  49. package/dist/_internal/shared/features/promotions/schema.js +7 -0
  50. package/dist/_internal/shared/obligations/deadlines.js +11 -1
  51. package/dist/_internal/shared/tester/window.d.ts +25 -0
  52. package/dist/_internal/shared/tester/window.js +66 -0
  53. package/dist/core/unit-of-work.js +15 -1
  54. package/dist/errors/firestore-codes.d.ts +37 -0
  55. package/dist/errors/firestore-codes.js +52 -0
  56. package/dist/errors/index.d.ts +0 -1
  57. package/dist/errors/index.js +12 -2
  58. package/dist/features/admin/components/AdminCouponEditorView.js +8 -0
  59. package/dist/features/admin/components/AdminGatewayEventsView.js +2 -2
  60. package/dist/features/admin/components/DataListingView.js +2 -0
  61. package/dist/features/admin/components/analytics/AdminStatCard.js +4 -1
  62. package/dist/features/admin/repository/site-settings.repository.d.ts +2 -1
  63. package/dist/features/admin/repository/site-settings.repository.js +7 -0
  64. package/dist/features/auctions/components/MarketplaceAuctionCard.js +1 -1
  65. package/dist/features/catalogue/actions/approve-catalogue-listing.d.ts +1 -1
  66. package/dist/features/catalogue/actions/approve-catalogue-listing.js +12 -1
  67. package/dist/features/catalogue/actions/list-from-catalogue.d.ts +1 -1
  68. package/dist/features/catalogue/actions/list-from-catalogue.js +12 -1
  69. package/dist/features/catalogue/actions/submit-for-approval.d.ts +1 -1
  70. package/dist/features/catalogue/actions/submit-for-approval.js +12 -1
  71. package/dist/features/categories/repository/price-observation.repository.d.ts +19 -0
  72. package/dist/features/categories/repository/price-observation.repository.js +21 -2
  73. package/dist/features/events/actions/event-actions.js +4 -1
  74. package/dist/features/events/components/AdminEventEditorView.js +72 -45
  75. package/dist/features/events/components/EventsListPageView.js +2 -1
  76. package/dist/features/events/schemas/firestore.d.ts +17 -0
  77. package/dist/features/events/schemas/firestore.js +1 -0
  78. package/dist/features/events/utils/sort-pinned.d.ts +33 -0
  79. package/dist/features/events/utils/sort-pinned.js +46 -0
  80. package/dist/features/gateway-events/repository/gateway-events.repository.js +3 -40
  81. package/dist/features/gateway-events/schemas/index.d.ts +2 -2
  82. package/dist/features/inventory/components/AdminStorageBoxesView.js +55 -39
  83. package/dist/features/inventory/components/ReceiveStationView.js +61 -17
  84. package/dist/features/inventory/repository/storage-boxes.repository.d.ts +3 -1
  85. package/dist/features/inventory/repository/storage-boxes.repository.js +36 -3
  86. package/dist/features/obligations/repository/obligations.repository.js +10 -1
  87. package/dist/features/orders/schemas/firestore.d.ts +19 -0
  88. package/dist/features/orders/schemas/firestore.js +19 -0
  89. package/dist/features/payment-cases/repository/payment-cases.repository.d.ts +1 -0
  90. package/dist/features/payment-cases/repository/payment-cases.repository.js +3 -14
  91. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +1 -1
  92. package/dist/features/products/components/MarketplaceBundleCard.js +1 -1
  93. package/dist/features/products/components/MarketplacePrizeDrawCard.js +1 -1
  94. package/dist/features/products/components/ProductForm.js +2 -2
  95. package/dist/features/products/components/ProductGrid.js +1 -1
  96. package/dist/features/promotions/repository/claimed-coupons.repository.js +31 -1
  97. package/dist/features/promotions/schemas/firestore.d.ts +22 -0
  98. package/dist/features/promotions/schemas/firestore.js +22 -0
  99. package/dist/features/promotions/schemas/index.d.ts +479 -6
  100. package/dist/features/promotions/schemas/index.js +153 -0
  101. package/dist/features/protection-claims/repository/protection-claims.repository.js +22 -15
  102. package/dist/features/seller/schemas/coupon-form.d.ts +5 -0
  103. package/dist/features/seller/schemas/coupon-form.js +18 -0
  104. package/dist/features/shell/SectionForm.js +31 -9
  105. package/dist/features/sticker-trace/server.d.ts +2 -0
  106. package/dist/http/api-handler.js +4 -1
  107. package/dist/index.d.ts +3 -3
  108. package/dist/index.js +3 -3
  109. package/dist/next/routing/route-map.d.ts +0 -2
  110. package/dist/next/routing/route-map.js +10 -1
  111. package/dist/schemas/registry.d.ts +11 -6
  112. package/dist/server.d.ts +0 -1
  113. package/dist/server.js +5 -3
  114. package/dist/styles.css +51 -0
  115. package/dist/ui/components/PaginatedSelect.js +0 -0
  116. package/package.json +1 -1
  117. package/scripts/seed-cli.mjs +1822 -1812
  118. package/dist/features/tester/utils/phases.d.ts +0 -48
  119. package/dist/features/tester/utils/phases.js +0 -139
@@ -1,1812 +1,1822 @@
1
- #!/usr/bin/env node
2
- /**
3
- * seed-cli — standalone Firestore seed loader / deleter for LetItRip.
4
- *
5
- * Talks directly to Firestore via firebase-admin — does not require the Next.js
6
- * dev server to be running. Reads Firebase credentials and PII secrets from the
7
- * consumer project's `.env.local` (resolved against process.cwd()).
8
- *
9
- * This mirrors the logic in src/app/api/demo/seed/route.ts. Keep them in sync.
10
- *
11
- * Usage (from consumer project root, e.g. d:/proj/letitrip.in):
12
- * npx appkit-seed load # load all collections
13
- * npx appkit-seed load --collections users,categories
14
- * npx appkit-seed delete --yes # purge all (skip confirmation)
15
- * npx appkit-seed load --dry-run # plan without writing
16
- * npx appkit-seed status # show seed/existing counts
17
- *
18
- * Flags:
19
- * --collections <a,b,c> subset of collections (default: all)
20
- * --dry-run compute plan only, no writes
21
- * --yes skip the destructive-confirm prompt for `delete`
22
- * --verbose log every doc id
23
- * --service-account <p> path to a JSON key file (overrides env vars)
24
- *
25
- * Exit: 0 = success, 1 = errors during run, 2 = misconfiguration.
26
- */
27
-
28
- import { existsSync, readFileSync } from "node:fs";
29
- import { resolve, basename, dirname } from "node:path";
30
- import { createRequire, register } from "node:module";
31
- import { fileURLToPath, pathToFileURL } from "node:url";
32
- import readline from "node:readline";
33
-
34
- // Register an ESM resolver hook that retries with `.js` / `/index.js` when
35
- // appkit's bundler-target dist emits extension-less relative imports.
36
- const __dirname = dirname(fileURLToPath(import.meta.url));
37
- register("./node-esm-loader.mjs", pathToFileURL(__dirname + "/"));
38
-
39
- // ---------------------------------------------------------------------------
40
- // CLI parsing — entry point detects bin name so the same script powers
41
- // `appkit-seed <verb>`, `appkit-seed-add`, and `appkit-seed-remove`.
42
- // ---------------------------------------------------------------------------
43
- const rawArgs = process.argv.slice(2);
44
- const POSITIONAL = rawArgs.filter((a) => !a.startsWith("--"));
45
- function flag(name, fallback = undefined) {
46
- const i = rawArgs.indexOf(`--${name}`);
47
- if (i === -1) return fallback;
48
- const v = rawArgs[i + 1];
49
- return v && !v.startsWith("--") ? v : true;
50
- }
51
-
52
- const binName = basename(process.argv[1] || "").replace(/\.(mjs|js|cmd|exe|ps1)$/i, "");
53
- const BIN_ACTION_MAP = {
54
- "appkit-seed-add": "load",
55
- "appkit-seed-remove": "delete",
56
- "appkit-seed-status": "status",
57
- };
58
- const ACTION = BIN_ACTION_MAP[binName] ?? POSITIONAL[0];
59
- if (!["load", "delete", "status", "purge"].includes(ACTION)) {
60
- console.error("usage:");
61
- console.error(" appkit-seed <load|delete|status|purge> [flags]");
62
- console.error(" appkit-seed-add [flags] # alias for `appkit-seed load`");
63
- console.error(" appkit-seed-remove [flags] # alias for `appkit-seed delete`");
64
- console.error("");
65
- console.error(" purge delete every document whose seedPurpose is \"test\", and NOTHING else.");
66
- console.error(" Unlike `delete`, which empties whole collections, this keeps real");
67
- console.error(" content (faqs, categories, taxCodes, productFeatures, blogPosts,");
68
- console.error(" carousels, homepageSections, siteSettings, the tester checklist) and");
69
- console.error(" keeps the two keep-list rows: the admin account and store-letitrip-official.");
70
- console.error(" Organic rows carry no seedPurpose at all and are unreachable by it.");
71
- console.error("");
72
- console.error("flags: --collections a,b --dry-run --yes --verbose --service-account <path>");
73
- process.exit(2);
74
- }
75
-
76
- const ONLY = flag("collections");
77
- const DRY_RUN = flag("dry-run") === true;
78
- const SKIP_CONFIRM = flag("yes") === true;
79
- const VERBOSE = flag("verbose") === true;
80
- const SERVICE_ACCOUNT_OVERRIDE = typeof flag("service-account") === "string" ? flag("service-account") : null;
81
-
82
- const ONLY_SET = ONLY && typeof ONLY === "string"
83
- ? new Set(ONLY.split(",").map((s) => s.trim()))
84
- : null;
85
-
86
- // ---------------------------------------------------------------------------
87
- // .env.local loader (avoids a dotenv dep, same shape as firebase-reset.mjs)
88
- // ---------------------------------------------------------------------------
89
- const repoRoot = process.cwd();
90
- const envLocalPath = resolve(repoRoot, ".env.local");
91
- if (existsSync(envLocalPath)) {
92
- for (const line of readFileSync(envLocalPath, "utf8").split("\n")) {
93
- const t = line.trim();
94
- if (!t || t.startsWith("#")) continue;
95
- const eq = t.indexOf("=");
96
- if (eq === -1) continue;
97
- const k = t.slice(0, eq).trim();
98
- const raw = t.slice(eq + 1).trim();
99
- const v = raw.replace(/^["']|["']$/g, "");
100
- if (!(k in process.env)) process.env[k] = v;
101
- }
102
- if (VERBOSE) console.log(`✓ Loaded ${envLocalPath}`);
103
- } else {
104
- console.warn(`⚠ .env.local not found at ${envLocalPath} — relying on existing process.env`);
105
- }
106
-
107
- // Validate config
108
- const required = ["FIREBASE_ADMIN_PROJECT_ID", "FIREBASE_ADMIN_CLIENT_EMAIL", "FIREBASE_ADMIN_PRIVATE_KEY"];
109
- const missing = required.filter((k) => !process.env[k]);
110
- if (!SERVICE_ACCOUNT_OVERRIDE && missing.length > 0) {
111
- console.error(`✗ Missing required env vars: ${missing.join(", ")}`);
112
- console.error(" Either set them in .env.local or pass --service-account <path-to-key.json>");
113
- process.exit(2);
114
- }
115
-
116
- // PII keys are mandatory for collections that encrypt fields
117
- const PII_REQUIRED = ["users", "addresses", "storeAddresses", "products", "orders", "reviews", "bids", "payouts", "eventEntries"];
118
- // PII_ENCRYPTION_KEY ONLY. `PII_SECRET` used to be accepted here as an
119
- // alternative and is read by no runtime code — so a machine with only that set
120
- // passed this check and then threw at the first encrypt, mid-load, with some
121
- // collections already written.
122
- const PII_KEY_PRESENT = Boolean(process.env.PII_ENCRYPTION_KEY);
123
- if (ACTION === "load" && !PII_KEY_PRESENT) {
124
- // A hard stop, not a warning. Loading without the key does not skip the PII
125
- // collections — it writes some and then throws, leaving a half-seeded
126
- // database that looks like a successful run until someone reads it.
127
- console.error("✖ PII_ENCRYPTION_KEY is not set — users, addresses, orders, reviews, bids, payouts and eventEntries all encrypt fields at write time.");
128
- process.exit(2);
129
- }
130
-
131
- // ---------------------------------------------------------------------------
132
- // firebase-admin init
133
- // ---------------------------------------------------------------------------
134
- const require = createRequire(import.meta.url);
135
- // appkit's pii-encrypt.ts calls a bare `require("crypto")` at runtime (not a
136
- // static import — deliberately, so bundlers like Turbopack never see a
137
- // node:module dependency and refuse to build the client bundle). A bare
138
- // `require` identifier resolves via the global scope in Node ESM, so it must
139
- // exist as a global before the dynamic `import("@mohasinac/appkit")` below
140
- // ever triggers that code path. Only this standalone-script environment
141
- // needs it — Next.js's own server bundle (webpack/Turbopack) provides an
142
- // ambient `require` automatically.
143
- globalThis.require = require;
144
- const admin = require("firebase-admin");
145
-
146
- function parsePrivateKey(raw) {
147
- if (!raw) return raw;
148
- return raw.includes("\\n") ? raw.replace(/\\n/g, "\n") : raw;
149
- }
150
-
151
- if (!admin.apps.length) {
152
- if (SERVICE_ACCOUNT_OVERRIDE) {
153
- const key = JSON.parse(readFileSync(resolve(repoRoot, SERVICE_ACCOUNT_OVERRIDE), "utf8"));
154
- admin.initializeApp({
155
- credential: admin.credential.cert(key),
156
- databaseURL: process.env.FIREBASE_ADMIN_DATABASE_URL || process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
157
- });
158
- } else {
159
- admin.initializeApp({
160
- credential: admin.credential.cert({
161
- projectId: process.env.FIREBASE_ADMIN_PROJECT_ID.trim(),
162
- clientEmail: process.env.FIREBASE_ADMIN_CLIENT_EMAIL.trim(),
163
- privateKey: parsePrivateKey(process.env.FIREBASE_ADMIN_PRIVATE_KEY),
164
- }),
165
- databaseURL:
166
- process.env.FIREBASE_ADMIN_DATABASE_URL ||
167
- process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
168
- });
169
- }
170
- }
171
- const db = admin.firestore();
172
- const auth = admin.auth();
173
- console.log(`✓ Connected to project: ${process.env.FIREBASE_ADMIN_PROJECT_ID || "(service-account)"}`);
174
-
175
- // ---------------------------------------------------------------------------
176
- // Pull seed data + helpers from @mohasinac/appkit
177
- // ---------------------------------------------------------------------------
178
- /*
179
- * ── 🛑 SEED DATA COMES FROM ./seed, NOT THE MAIN BARREL ──────────────────
180
- *
181
- * `appkit/src/seed/` is 61% of this package's source bytes and 60% of the
182
- * published dist — 540 files of pure fixture data. While the main barrel
183
- * re-exported it, every consumer importing `@mohasinac/appkit` pulled that
184
- * graph into scope and Turbopack traced all 24 MB of it for nothing.
185
- *
186
- * The re-exports are gone from `index.ts`/`server.ts`, so this CLI — the
187
- * only real consumer of the data — reads it from a dedicated subpath.
188
- *
189
- * 🛑 Two imports, deliberately. The seed ARRAYS come from ./seed; the
190
- * HELPERS (id generators, token builders, PII field maps) stay on the main
191
- * barrel because they are real library code that the app uses too. Merging
192
- * them would pull the helpers back into the seed graph and undo the split.
193
- */
194
- /*
195
- * ── 🛑 TWO PLACES THE SEED CAN LIVE, AND A CLEAR ERROR IF NEITHER ────────
196
- *
197
- * `dist/seed/**` is 27 MB of the 52 MB dist and is EXCLUDED from the
198
- * published tarball — a package consumer has no business carrying fixture
199
- * data. So the `./seed` subpath resolves only where the data actually is:
200
- *
201
- * · an installed copy that was resynced from this repo (the normal local
202
- * case — `cp -r appkit/dist node_modules/@mohasinac/appkit/dist`)
203
- * · this repo's own `appkit/dist/seed`, when run from the source tree
204
- *
205
- * 🛑 A fresh `npm install` from the registry has NEITHER, and that is
206
- * deliberate: seeding needs the generators and the 18 MB of research JSON
207
- * that only exist in this repo, so it is a repo operation by nature. What
208
- * must not happen is the SILENT version — an import that resolves to nothing
209
- * and a loader that then reports `no seed data, skipping` with exit 0, which
210
- * is Root Cause #90 and has already cost this project six empty collections.
211
- * Hence an explicit throw naming both paths.
212
- */
213
- async function importSeedModule() {
214
- const attempts = [];
215
- /*
216
- * 🛑 A RELATIVE path, not the `@mohasinac/appkit/seed` subpath.
217
- *
218
- * That subpath was added and then REMOVED in the same change: once
219
- * `dist/seed` is excluded from the tarball, a declared export pointing at a
220
- * file the package does not contain is a landmine for anyone who imports it.
221
- * Attempting it here would also be dead code — it can never resolve now — and
222
- * a dead attempt whose throw is swallowed is exactly what makes the next
223
- * reader think the subpath still works.
224
- *
225
- * Relative resolution covers both real cases: run from this repo it finds
226
- * `appkit/dist/seed`; run as an installed copy that was resynced from the
227
- * repo it finds `node_modules/@mohasinac/appkit/dist/seed`.
228
- */
229
- const local = pathToFileURL(resolve(__dirname, "../dist/seed/index.js")).href;
230
- try {
231
- return await import(local);
232
- } catch (err) {
233
- attempts.push(`${local} — ${err?.code ?? err?.message ?? err}`);
234
- }
235
- console.error(
236
- "\u001b[31m✗ Could not load the seed data.\u001b[0m\n" +
237
- " The seed is excluded from the published package on purpose — it is a repo\n" +
238
- " operation, not something a consumer does. Build it first:\n\n" +
239
- " npm --prefix ./appkit run build\n\n" +
240
- " Tried:\n" +
241
- attempts.map((a) => ` ${a}`).join("\n"),
242
- );
243
- process.exit(1);
244
- }
245
- const appkitSeed = await importSeedModule();
246
- const appkit = { ...(await import("@mohasinac/appkit")), ...appkitSeed };
247
- // PII crypto functions moved server-only in appkit 3.8.1 (they touch
248
- // firebase-admin's crypto chain) — no longer exported from the main
249
- // client-safe barrel, only from "@mohasinac/appkit/server". The *_PII_FIELDS
250
- // / *_PII_INDEX_MAP constants are plain data and stay in the main barrel.
251
- const appkitServer = await import("@mohasinac/appkit/server");
252
- const { encryptPiiFields, piiIndicesFor, encryptPayoutDetails, encryptPayoutBankAccount, encryptShippingConfig,
253
- buildTokensIndex, BLIND_INDEX_KEY_VERSION_FIELD, CURRENT_BLIND_INDEX_KEY_VERSION } = appkitServer;
254
-
255
- const {
256
- // seed data
257
- usersSeedData, addressesSeedData, storeAddressesSeedData,
258
- // store-extensions — see the SEED_DATA_MAP note below (Root Cause #90, 7-16).
259
- payoutMethodsSeedData, shippingConfigsSeedData, analyticsCardsSeedData, analyticsAlertsSeedData,
260
- storeCategoriesSeedData, listingTemplatesSeedData, moderationQueueSeedData, reportsSeedData,
261
- itemRequestsSeedData, storeGoogleConfigSeedData,
262
- categoriesSeedData, storesSeedData, sessionsSeedData,
263
- productsStandardSeedData, productsBeybladeXSeedData, productsAuctionsSeedData, productsPreordersSeedData,
264
- productsPrizeDrawsSeedData, productsClassifiedsSeedData, productsDigitalCodesSeedData, productsLiveItemsSeedData,
265
- productsArtSeedData, productsStickersSeedData, productsSparesSeedData,
266
- productsCatalogueSeedData,
267
- FRANCHISE_BY_CATEGORY_ROOT,
268
- ordersSeedData, reviewsSeedData, cartsSeedData, bidsSeedData,
269
- couponsSeedData, couponUsageSeedData,
270
- eventsSeedData, eventEntriesSeedData, payoutsSeedData, offersSeedData,
271
- notificationsSeedData, blogPostsSeedData,
272
- carouselsSeedData, carouselSlidesSeedData, homepageSectionsSeedData,
273
- siteSettingsSeedData, faqSeedData,
274
- wishlistsSeedData, historySeedData,
275
- groupedListingsSeedData,
276
- mediaAssetsSeedData, mediaAssetsEditorialSeedData,
277
- scammersSeedData, productFeaturesSeedData, taxCodesSeedData,
278
- authenticityKnowledgeSeedData,
279
- partGlossarySeedData, modelCompositionSeedData,
280
- beysSeedData, beyProductsSeedData,
281
- BEYS_COLLECTION, BEY_PRODUCTS_COLLECTION,
282
- testerChecklistSeedData, testerResponsesSeedData,
283
- supportTicketsSeedData, catalogueSeedData,
284
- shipmentsSeedData, shipmentLotsSeedData, shipmentItemsSeedData,
285
- // collection constants
286
- USER_COLLECTION,
287
- CATEGORIES_COLLECTION, STORE_COLLECTION,
288
- PRODUCT_COLLECTION, ORDER_COLLECTION, REVIEW_COLLECTION, BID_COLLECTION,
289
- COUPONS_COLLECTION, CAROUSELS_COLLECTION, CAROUSEL_SLIDES_COLLECTION,
290
- HOMEPAGE_SECTIONS_COLLECTION, SITE_SETTINGS_COLLECTION, FAQS_COLLECTION,
291
- NOTIFICATIONS_COLLECTION, PAYOUT_COLLECTION, OFFER_COLLECTION, BLOG_POSTS_COLLECTION,
292
- EVENTS_COLLECTION, EVENT_ENTRIES_COLLECTION, SESSION_COLLECTION,
293
- CART_COLLECTION,
294
- GROUPED_LISTINGS_COLLECTION, SCAMMER_COLLECTION,
295
- WISHLIST_COLLECTION, HISTORY_COLLECTION, PRODUCT_FEATURES_COLLECTION,
296
- TAX_CODES_COLLECTION,
297
- PART_GLOSSARY_COLLECTION,
298
- MODEL_COMPOSITION_COLLECTION,
299
- TESTER_CHECKLIST_ITEM_COLLECTION,
300
- TESTER_CHECKLIST_RESPONSE_COLLECTION,
301
- // PII field/index constants (plain data, still in the main barrel — the
302
- // crypto functions themselves come from appkitServer above)
303
- USER_PII_FIELDS, USER_PII_INDEX_MAP,
304
- ADDRESS_PII_FIELDS, ORDER_PII_FIELDS, BID_PII_FIELDS, PAYOUT_PII_FIELDS,
305
- REVIEW_PII_FIELDS, OFFER_PII_FIELDS, EVENT_ENTRY_PII_FIELDS,
306
- // misc
307
- generateFAQId,
308
- /*
309
- * Seed provenance. `stampSeedPurpose` is the ONE place a `seedPurpose` (and
310
- * the `isTestData` / `testDataExpiresAt` derived from it) is produced, shared
311
- * with `seed/runner.ts` so the two loaders cannot disagree about what a
312
- * purpose means — Root Cause #75's shape on the seed axis.
313
- */
314
- stampSeedPurpose,
315
- resolveSeedPurpose,
316
- SEED_COLLECTION_PURPOSE,
317
- ALL_SEED_PURPOSES,
318
- } = appkit;
319
-
320
- // ---------------------------------------------------------------------------
321
- // Collection registry
322
- // ---------------------------------------------------------------------------
323
- const COLLECTION_MAP = {
324
- users: USER_COLLECTION,
325
- addresses: "addresses",
326
- storeAddresses: "storeAddresses",
327
- couponUsage: "couponUsage",
328
- categories: CATEGORIES_COLLECTION,
329
- stores: STORE_COLLECTION,
330
- products: PRODUCT_COLLECTION,
331
- orders: ORDER_COLLECTION,
332
- reviews: REVIEW_COLLECTION,
333
- bids: BID_COLLECTION,
334
- coupons: COUPONS_COLLECTION,
335
- carousels: CAROUSELS_COLLECTION,
336
- carouselSlides: CAROUSEL_SLIDES_COLLECTION,
337
- homepageSections: HOMEPAGE_SECTIONS_COLLECTION,
338
- siteSettings: SITE_SETTINGS_COLLECTION,
339
- faqs: FAQS_COLLECTION,
340
- notifications: NOTIFICATIONS_COLLECTION,
341
- payouts: PAYOUT_COLLECTION,
342
- offers: OFFER_COLLECTION,
343
- /*
344
- * String literals, not constants, because SUPPORT_TICKET_COLLECTION,
345
- * CATALOGUE_COLLECTION and SHIPMENT_COLLECTION are NOT re-exported from
346
- * appkit's barrel -- only their features' own schema files declare them.
347
- * Adding three barrel exports to fix a seeding gap is a public-API change
348
- * for a private need, so the names are duplicated here with the source
349
- * named beside each. If one is ever renamed, this is the site that breaks.
350
- */
351
- supportTickets: "supportTickets", // features/support/schemas/firestore.ts:279
352
- catalogueItems: "catalogueItems", // features/catalogue/schemas/firestore.ts:18
353
- procurementShipments: "procurementShipments", // features/shipments/schemas/firestore.ts:26
354
- shipmentLots: "shipmentLots",
355
- shipmentItems: "shipmentItems",
356
- /*
357
- * store-extensions. Ten collections with seed fixtures in manifest.ts that
358
- * were NEVER in this map, so ALL_COLLECTIONS omitted them and they held ZERO
359
- * documents in every run ever -- Root Cause #90, instances 7-16. Found via
360
- * /store/analytics/cards rendering "No cards" under copy promising built-ins.
361
- * String literals for the same reason as the block above: none of these is
362
- * re-exported from appkit's barrel.
363
- */
364
- payoutMethods: "payoutMethods", // features/store-extensions/schemas/firestore.ts:46
365
- shippingConfigs: "shippingConfigs", // :84
366
- analyticsCards: "analyticsCards", // :120
367
- analyticsAlerts: "analyticsAlerts", // :152
368
- storeCategories: "storeCategories", // :179
369
- listingTemplates: "listingTemplates", // :240
370
- moderationQueue: "moderationQueue", // :280
371
- reports: "reports", // :372
372
- itemRequests: "itemRequests",
373
- storeGoogleConfig: "storeGoogleConfig",
374
- blogPosts: BLOG_POSTS_COLLECTION,
375
- events: EVENTS_COLLECTION,
376
- eventEntries: EVENT_ENTRIES_COLLECTION,
377
- sessions: SESSION_COLLECTION,
378
- carts: CART_COLLECTION,
379
- wishlists: WISHLIST_COLLECTION,
380
- history: HISTORY_COLLECTION,
381
- groupedListings: GROUPED_LISTINGS_COLLECTION,
382
- /*
383
- * `/media/<shortId>` resolves ONLY through a mediaAssets document, so without
384
- * these rows every seeded cover image 404s while its object sits untouched in
385
- * the bucket. Seeded rather than left as a side effect of the upload script
386
- * precisely so a Firestore wipe cannot orphan Storage.
387
- */
388
- mediaAssets: "mediaAssets",
389
- scammerProfiles: SCAMMER_COLLECTION,
390
- productFeatures: PRODUCT_FEATURES_COLLECTION,
391
- authenticityKnowledge: "authenticityKnowledge",
392
- taxCodes: TAX_CODES_COLLECTION,
393
- // E4/D12 — extracted from the local corpus, generated into the seed.
394
- partGlossary: PART_GLOSSARY_COLLECTION,
395
- /*
396
- * The BEY reference database. Mirrors `partGlossary`: extracted from a
397
- * corpus by committed scripts, shares its id with the paired category leaf,
398
- * and keeps measured data OFF `categories` so a reseed cannot drop every
399
- * bey page's ISR entry.
400
- */
401
- beys: BEYS_COLLECTION,
402
- beyProducts: BEY_PRODUCTS_COLLECTION,
403
- modelComposition: MODEL_COMPOSITION_COLLECTION,
404
- testerChecklistItems: TESTER_CHECKLIST_ITEM_COLLECTION,
405
- /*
406
- * Tester submissions. DERIVED tier in the tester's own map (wiped at setup so
407
- * a prior run's verdicts never leak into the next), AND listed in
408
- * SEED_TRANSACTIONAL so these two demo fixtures are restored afterwards —
409
- * that list is a RESTORE list, orthogonal to the delete tiers.
410
- *
411
- * Without the fixtures, three admin/bug-hunter-rewards cases have nothing to
412
- * confirm or reopen and can only report blocked. Measured 2026-09-11:
413
- * 0 pass / 0 fail / 5 blocked, both controls correct.
414
- */
415
- testerChecklistResponses: TESTER_CHECKLIST_RESPONSE_COLLECTION,
416
- };
417
-
418
- /*
419
- * B6 — stamp `gstRate` + `hsnCode` onto every seeded product.
420
- *
421
- * 🛑 WITHOUT THIS, EVERY SEEDED PRODUCT IS TAXED AT ZERO, and the Tax row can
422
- * never render no matter what the GSTIN says.
423
- *
424
- * The resolution order C0c settled lives in `ProductRepository.deriveTaxonomy`
425
- * — product rate, else the CATEGORY's via `taxCodeId`, else a warned floor —
426
- * and it runs on a repository write. **The seed writes through
427
- * `set(..., { merge: true })` and never touches the repository**, so no seeded
428
- * row has ever carried a rate: measured 2026-10-12, `gstRate` was absent on
429
- * all 70 live products. At checkout `sumGroupGst` reads `product.gstRate`
430
- * directly with a `?? 0`, so the whole catalogue would have computed GST = 0
431
- * with a valid GSTIN and `gstSettings.enabled: true`.
432
- *
433
- * That is the same class as Root Cause #101 (a schema that strips a field plus
434
- * a read-side backfill equals a field no query can see) — here it is a
435
- * write-side derivation the seed path does not run.
436
- *
437
- * 🛑 This is NOT a second implementation of the resolution. The product seed
438
- * already hand-writes the FULL `categorySlugs` chain, so there is no walk to
439
- * re-derive: take the chain, find the first element whose category declares a
440
- * `taxCodeId`, and read the rate off the `taxCodes` seed. Nearest-first, which
441
- * is the order `resolveCategoryDefaults` uses, so a more specific category
442
- * still wins.
443
- *
444
- * 🛑 An explicit rate on a product WINS and is never overwritten — including
445
- * `gstRate: 0`, which is a deliberate exemption and not an absence
446
- * (`ProductDocument.gstRate`'s own JSDoc: "undefined and 0 MEAN DIFFERENT
447
- * THINGS"). The test is `== null`, never falsy.
448
- *
449
- * 🛑 And it REFUSES rather than defaulting. A product whose chain resolves no
450
- * tax code throws at load time: C0c defines reaching the floor as a triage
451
- * signal, so a silent 5% here would hide exactly what it is meant to surface —
452
- * and a wrong rate on a real invoice is worse than a failed load.
453
- */
454
- /**
455
- * Stamp `franchise` / `franchiseSlug` from the product's category ROOT.
456
- *
457
- * 🛑 Its OWN pass, deliberately NOT folded into `withDerivedCategoryFields`.
458
- * That function early-returns on `gstRate && hsnCode && authenticity`, which
459
- * every one of the 1,237 generated catalogue rows already satisfies — so a
460
- * franchise derived in there would be silently skipped for 95% of the
461
- * catalogue. That is the exact bug its own comment records happening when
462
- * `authenticity` was added beside `gstRate`; adding a third field to the same
463
- * condition is how it happens a third time.
464
- *
465
- * A root with no mapping yields NO franchise (living collectibles are not an
466
- * IP) — see `FRANCHISE_BY_CATEGORY_ROOT`.
467
- */
468
- /**
469
- * The real, sellable catalogue — everything else in the product seed is demo
470
- * furniture and is DROPPED HERE, before anything is written.
471
- *
472
- * ── 🛑 WHY A FILTER AND NOT A DELETION ────────────────────────────────────
473
- *
474
- * The fixtures stay in their files on purpose: they are the only coverage
475
- * several features have (every `preOrderProductionStatus` value, a sold-out
476
- * reservation, a depleted digital-code pool, an ended-unsold auction), and
477
- * the tester checklist deep-links to them by id. Deleting them would silently
478
- * remove that coverage. Not LOADING them is the part that was wanted.
479
- *
480
- * ── 🛑 WHY THIS HAD TO EXIST ──────────────────────────────────────────────
481
- *
482
- * `appkit-seed load` writes every registered row and `purge` deletes
483
- * `seedPurpose: "test"` afterwards — so the demo listings came BACK on every
484
- * reseed, and anyone who ran `load` to fix imagery silently republished 76
485
- * fake products to the live storefront. That happened twice in one session.
486
- * The owner's instruction was "delete them from the seed itself and make sure
487
- * they do not return"; this is the point where that can be guaranteed, since
488
- * every write path funnels through here.
489
- *
490
- * ── The discriminator is the STORE, not the listing type ──────────────────
491
- *
492
- * `store-beyblade-arena` is the fictional demo seller; `store-letitrip-
493
- * official` is the real one. Keying on the store rather than on a per-file
494
- * allow-list means a fixture added to ANY product seed file later is excluded
495
- * automatically, which is the failure mode this is replacing — a list that
496
- * has to be remembered is a list that drifts.
497
- *
498
- * Set `SEED_INCLUDE_DEMO=1` to load them anyway, for local demo work.
499
- */
500
- const REAL_STORE_IDS = new Set(["store-letitrip-official"]);
501
-
502
- function realListingsOnly(products) {
503
- if (process.env.SEED_INCLUDE_DEMO === "1") {
504
- console.log("▌ SEED_INCLUDE_DEMO=1 — demo listings INCLUDED");
505
- return products;
506
- }
507
- /*
508
- * 🛑 TWO rules, because the store alone is not enough. Six prize-draw
509
- * fixtures live under the REAL store and are still demo furniture — their
510
- * imagery is a 460-byte generated colour tile from /images/seed-tiles/,
511
- * which is the giveaway: a listing nobody photographed is a listing nobody
512
- * intends to sell. Measured before writing this: every one of the six, and
513
- * nothing real, matches.
514
- */
515
- const isPlaceholder = (p) => {
516
- const urls = [p?.mainImage, ...(Array.isArray(p?.images) ? p.images : [])]
517
- .map((v) => (typeof v === "string" ? v : v?.url))
518
- .filter(Boolean);
519
- return urls.length > 0 && urls.every((u) => String(u).includes("/images/seed-tiles/"));
520
- };
521
- const kept = products.filter((p) => REAL_STORE_IDS.has(p?.storeId) && !isPlaceholder(p));
522
- const dropped = products.length - kept.length;
523
- if (dropped > 0) {
524
- console.log(` • ${dropped} demo listing(s) not loaded (set SEED_INCLUDE_DEMO=1 to include)`);
525
- }
526
- return kept;
527
- }
528
-
529
- /**
530
- * Drop bundle categories whose members are not in the loaded catalogue.
531
- *
532
- * 🛑 A bundle is ALL-OR-NOTHING — `canAddToCart` buys every member together —
533
- * so a bundle missing even one member is unsellable, and one missing ALL of
534
- * them is a card that opens an empty page. Measured right after the demo
535
- * listings stopped loading: of the five seeded bundles, three had zero
536
- * surviving members and the other two had 1-of-4 and 2-of-3. All five were
537
- * still `isActive: true`, and all five rendered on the HOMEPAGE as collage
538
- * cards built from `display.mosaicImages` — which are baked at seed time from
539
- * the members' own imagery, so they kept showing pictures of products that no
540
- * longer exist. That is 92 placeholder-tile references on the homepage.
541
- *
542
- * Derived rather than hand-maintained for the same reason as the product
543
- * filter: a bundle that becomes valid later starts loading again on its own,
544
- * and one that breaks stops, with nobody having to remember either.
545
- *
546
- * Only `categoryType: "bundle"` rows are considered. Every other category is
547
- * taxonomy and is never touched — rebuilding the tree is expensive and is
548
- * explicitly not what this is for.
549
- */
550
- function loadableBundlesOnly(categories, loadedProducts) {
551
- const live = new Set(loadedProducts.map((p) => p?.id).filter(Boolean));
552
- let dropped = 0;
553
- const kept = categories.filter((c) => {
554
- if (c?.categoryType !== "bundle") return true;
555
- const ids = Array.isArray(c.bundleProductIds) ? c.bundleProductIds : [];
556
- const ok = ids.length > 0 && ids.every((id) => live.has(id));
557
- if (!ok) dropped++;
558
- return ok;
559
- });
560
- if (dropped > 0) {
561
- console.log(` • ${dropped} bundle(s) not loaded — members missing from the catalogue`);
562
- }
563
- return kept;
564
- }
565
-
566
- function withFranchise(products) {
567
- let stamped = 0;
568
- const out = products.map((p) => {
569
- if (p.franchiseSlug) return p;
570
- const chain = Array.isArray(p.categorySlugs) ? p.categorySlugs : [];
571
- const root = chain.length > 0 ? String(chain[chain.length - 1]) : null;
572
- const f = root ? FRANCHISE_BY_CATEGORY_ROOT[root] : null;
573
- if (!f) return p;
574
- stamped++;
575
- return { ...p, franchise: f.name, franchiseSlug: f.slug };
576
- });
577
- console.log(` • franchise stamped on ${stamped}/${products.length} products`);
578
- return out;
579
- }
580
-
581
- function withDerivedCategoryFields(products) {
582
- const catById = new Map((categoriesSeedData || []).map((c) => [String(c.id), c]));
583
- const taxById = new Map((taxCodesSeedData || []).map((t) => [String(t.id), t]));
584
- const unresolved = [];
585
- const out = products.map((p) => {
586
- // 🛑 Must NOT early-exit on tax alone now that authenticity rides along —
587
- // a product with a hand-written rate would silently keep no authenticity.
588
- if (p.gstRate != null && p.hsnCode && p.authenticity) return p;
589
- const chain = Array.isArray(p.categorySlugs) && p.categorySlugs.length
590
- ? p.categorySlugs
591
- : (p.category ? [p.category] : []);
592
- let code = null;
593
- for (const id of chain) {
594
- const c = catById.get(String(id));
595
- const t = c?.productDefaults?.taxCodeId;
596
- if (t) { code = t; break; }
597
- }
598
- const tax = code ? taxById.get(String(code)) : null;
599
- if (!tax) { unresolved.push(`${p.id} (leaf ${chain[0] ?? "none"})`); return p; }
600
-
601
- /*
602
- * 🛑 AUTHENTICITY, the same gap one field over — and it was making a HERO
603
- * CLAIM false.
604
- *
605
- * `productDefaults.defaultAuthenticity` is declared on 122 categories and
606
- * `authenticity` was absent on **all 71 live products**, because it derives
607
- * on a repository write and the seed does not make one. The homepage
608
- * authenticity slide says "the seller has to pick one before it can go
609
- * live" — which was untrue in two ways at once: the write schema has it
610
- * `.optional()`, and not one product carried a value.
611
- *
612
- * `unverified` is the floor, never `original`: `ProductAuthenticity`'s own
613
- * docstring says that without it "an honest seller who does not know would
614
- * have to pick 'original', and silence would be indistinguishable from a
615
- * genuine claim." The platform must not make an authenticity claim nobody
616
- * checked.
617
- *
618
- * An explicit product value always wins, so the 6 categories that declare
619
- * `original` still reach their products.
620
- */
621
- let authenticity = null;
622
- for (const id of chain) {
623
- const c = catById.get(String(id));
624
- const a = c?.productDefaults?.defaultAuthenticity;
625
- if (a) { authenticity = a; break; }
626
- }
627
-
628
- return {
629
- ...p,
630
- gstRate: p.gstRate != null ? p.gstRate : tax.gstRate,
631
- // An exempt code has no HSN, and `hsnCode: ""` would read as a declared
632
- // empty code rather than as an inapplicable one.
633
- ...(p.hsnCode || !tax.hsnCode ? {} : { hsnCode: tax.hsnCode }),
634
- ...(p.authenticity || !authenticity ? {} : { authenticity }),
635
- };
636
- });
637
- if (unresolved.length) {
638
- const detail = unresolved.slice(0, 10).join(", ");
639
- throw new Error(
640
- `seed: ${unresolved.length} product(s) resolve NO taxCodeId from their category chain, ` +
641
- "so they would be taxed at zero. Give the category a taxCodeId " +
642
- `(the roots carry a floor - see ROOT_TAX_DEFAULTS): ${detail}`,
643
- );
644
- }
645
- return out;
646
- }
647
-
648
- /**
649
- * The real catalogue, computed ONCE so the bundle filter and the product
650
- * loader cannot disagree about what "loaded" means. Deriving it twice is
651
- * Root Cause #75's shape — two copies of one rule, one of which learns about
652
- * a change and the other does not.
653
- */
654
- const REAL_PRODUCTS = realListingsOnly([
655
- ...(productsStandardSeedData || []),
656
- // Real, wiki-verified Beyblade X listings under store-letitrip-official —
657
- // added 2026-10-19 for PhonePe merchant registration, which requires at
658
- // least one genuinely purchasable product. See the file's own header.
659
- ...(productsBeybladeXSeedData || []),
660
- ...(productsAuctionsSeedData || []), ...(productsPreordersSeedData || []),
661
- ...(productsPrizeDrawsSeedData || []), ...(productsClassifiedsSeedData || []), ...(productsDigitalCodesSeedData || []), ...(productsLiveItemsSeedData || []),
662
- // art + stickers were written, exported from the seed barrel, and then never
663
- // spread here — so 13 fixtures had NEVER reached the database, /art and
664
- // /stickers were permanently empty, and listingType=art|stickers returned 0.
665
- // Root Cause #58's family one layer down: added everywhere except the runner.
666
- ...(productsArtSeedData || []), ...(productsStickersSeedData || []),
667
- // 🛑 BOTH sites, every time. This file's own header records 13 art/sticker
668
- // fixtures that never reached the database because only the import was
669
- // edited and this spread was not — the loader then wrote every OTHER
670
- // product and reported success.
671
- ...(productsSparesSeedData || []),
672
- /*
673
- * 🛑 DEREGISTERED 2026-10-19 — productsCatalogueSeedData (1,237 rows, one
674
- * per taxonomy leaf) is NOT spread here, on purpose.
675
- *
676
- * This is a reversal of the comment that used to be here, which said the
677
- * opposite: that this spread was "what makes `products` reproducible
678
- * after a full wipe." That was true when the catalogue was the only
679
- * content under its leaf categories. It stopped being true the moment
680
- * real curated listings exist for the SAME leaves — four of them,
681
- * confirmed by grep: `category-aeropegasus-3-70a`, `category-
682
- * valkyrievolt-s4-70v`, `category-wizardarrow-4-80b`, `category-
683
- * knightshield-3-80n` each already carry one generated row. Re-enabling
684
- * this spread would not just add filler back — it would put a
685
- * hash-priced synthetic duplicate (one measured as a NEGATIVE price,
686
- * `-61`) into the exact categories the real BX-53 fixture now occupies,
687
- * silently resurrecting the thing a full `appkit-seed purge` was just run
688
- * to remove.
689
- *
690
- * The generator (`scripts/gen-catalogue-listings.mjs`) and the committed
691
- * file itself are both left untouched — this is deliberately a one-line
692
- * "stop loading it" change, not a deletion. Re-enable only after deciding
693
- * what should happen to a leaf that now has BOTH a real listing and a
694
- * generated one.
695
- */
696
- ]);
697
-
698
- const SEED_DATA_MAP = {
699
- users: usersSeedData,
700
- /*
701
- * store-extensions. COLLECTION_MAP was only HALF the wiring: load() reads
702
- * SEED_DATA_MAP and short-circuits on an empty array, so adding the ten
703
- * collection names alone produced "no seed data, skipping" for all ten.
704
- * Same shape as the art/stickers note further down -- added everywhere
705
- * except the map that actually runs.
706
- */
707
- payoutMethods: payoutMethodsSeedData,
708
- shippingConfigs: shippingConfigsSeedData,
709
- analyticsCards: analyticsCardsSeedData,
710
- analyticsAlerts: analyticsAlertsSeedData,
711
- storeCategories: storeCategoriesSeedData,
712
- listingTemplates: listingTemplatesSeedData,
713
- moderationQueue: moderationQueueSeedData,
714
- reports: reportsSeedData,
715
- itemRequests: itemRequestsSeedData,
716
- storeGoogleConfig: storeGoogleConfigSeedData,
717
- addresses: addressesSeedData,
718
- storeAddresses: storeAddressesSeedData,
719
- // SB-UNI-C: brands were merged into categories (categoryType:"brand") — no
720
- // standalone brands collection/seed exists anymore.
721
- categories: loadableBundlesOnly(categoriesSeedData, REAL_PRODUCTS),
722
- stores: storesSeedData,
723
- products: withFranchise(withDerivedCategoryFields(REAL_PRODUCTS)),
724
- orders: ordersSeedData,
725
- reviews: reviewsSeedData,
726
- bids: bidsSeedData,
727
- coupons: couponsSeedData,
728
- couponUsage: couponUsageSeedData,
729
- carousels: carouselsSeedData,
730
- carouselSlides: carouselSlidesSeedData,
731
- homepageSections: homepageSectionsSeedData,
732
- siteSettings: [siteSettingsSeedData],
733
- faqs: faqSeedData,
734
- notifications: notificationsSeedData,
735
- payouts: payoutsSeedData,
736
- // The LOADER was missing for a long time, so `offers` never appeared in
737
- // `appkit-seed status` and no run had an offer in Firestore. OFFER_PII_FIELDS
738
- // was already imported and mapped below -- the PII half was wired and the
739
- // loading half never was, which is why the gap looked like support rather
740
- // than an omission.
741
- offers: offersSeedData,
742
- // All four were unregistered like offers, all measured at 0 documents live.
743
- // They are UNLISTED in the tester's tier map, which means PRESERVE -- so
744
- // seeding them once persists across every future run rather than being
745
- // wiped by the next tester:setup.
746
- supportTickets: supportTicketsSeedData,
747
- catalogueItems: catalogueSeedData,
748
- procurementShipments: shipmentsSeedData,
749
- shipmentLots: shipmentLotsSeedData,
750
- shipmentItems: shipmentItemsSeedData,
751
- blogPosts: blogPostsSeedData,
752
- events: eventsSeedData,
753
- eventEntries: eventEntriesSeedData,
754
- sessions: sessionsSeedData,
755
- carts: cartsSeedData,
756
- wishlists: wishlistsSeedData,
757
- history: historySeedData,
758
- groupedListings: groupedListingsSeedData,
759
- // The other half of the wiring — load() short-circuits on an empty array, so
760
- // the COLLECTION_MAP entry above does nothing on its own (Root Cause #90).
761
- // Two source files, one collection: the taxonomy rows are GENERATED and
762
- // rewritten wholesale by upload-taxonomy-images.mjs, so the editorial rows
763
- // are kept beside them rather than inside that file.
764
- mediaAssets: [...mediaAssetsSeedData, ...mediaAssetsEditorialSeedData],
765
- scammerProfiles: scammersSeedData,
766
- productFeatures: productFeaturesSeedData,
767
- authenticityKnowledge: authenticityKnowledgeSeedData,
768
- taxCodes: taxCodesSeedData,
769
- partGlossary: partGlossarySeedData,
770
- // 🛑 Both halves are mandatory: load() short-circuits on an empty array, so a
771
- // COLLECTION_MAP entry alone logs "no seed data, skipping" and exits 0.
772
- beys: beysSeedData,
773
- beyProducts: beyProductsSeedData,
774
- modelComposition: modelCompositionSeedData,
775
- testerChecklistItems: testerChecklistSeedData,
776
- testerChecklistResponses: testerResponsesSeedData,
777
- };
778
-
779
- const ALL_COLLECTIONS = Object.keys(COLLECTION_MAP);
780
- const REPLACE_ON_LOAD = new Set(["homepageSections", "carouselSlides"]);
781
-
782
- const collectionsToProcess = ONLY_SET
783
- ? ALL_COLLECTIONS.filter((c) => ONLY_SET.has(c))
784
- : ALL_COLLECTIONS;
785
-
786
- if (ONLY_SET && collectionsToProcess.length === 0) {
787
- console.error(`✗ No matching collections. Valid: ${ALL_COLLECTIONS.join(", ")}`);
788
- process.exit(2);
789
- }
790
-
791
- // ---------------------------------------------------------------------------
792
- // Generic helpers
793
- // ---------------------------------------------------------------------------
794
- function stripUndefined(obj) {
795
- if (Array.isArray(obj)) return obj.map(stripUndefined);
796
- if (obj instanceof Date) return obj;
797
- if (obj !== null && typeof obj === "object") {
798
- return Object.fromEntries(
799
- Object.entries(obj).filter(([, v]) => v !== undefined).map(([k, v]) => [k, stripUndefined(v)]),
800
- );
801
- }
802
- return obj;
803
- }
804
-
805
- async function purgeCollection(path) {
806
- const BATCH = 500;
807
- let snap = await db.collection(path).limit(BATCH).get();
808
- let removed = 0;
809
- while (!snap.empty) {
810
- const batch = db.batch();
811
- snap.docs.forEach((d) => batch.delete(d.ref));
812
- await batch.commit();
813
- removed += snap.docs.length;
814
- if (snap.docs.length < BATCH) break;
815
- snap = await db.collection(path).limit(BATCH).get();
816
- }
817
- return removed;
818
- }
819
-
820
- /*
821
- * 🛑 `TOKENS_INDEX_MAP` holds ONLY `payouts`, and that is a measured fact
822
- * about this file, not an oversight.
823
- *
824
- * `encryptSeedPii` is called from exactly one place — `loadGeneric`'s
825
- * per-doc loop. `users` and `addresses` are seeded by their OWN dedicated
826
- * loaders (`loadUsers`, the addresses block near line 1029) that write
827
- * straight to Firestore and never call this function — so a `users`/
828
- * `addresses` entry here would be dead code, silently unreachable, which is
829
- * worse than no entry: it would read as "handled" to the next person who
830
- * greps for it.
831
- *
832
- * It is also the right scope rather than a gap, because `users` and
833
- * `addresses` are PRESERVE-tier in the tester harness — never wiped, never
834
- * reseeded — so the only two things that can write their `*TokensIndex`
835
- * are a REAL write (covered by `UserRepository`/`AddressesRepository`'s
836
- * `buildTokensIndexFor`, wired separately) and the one-time bootstrap seed
837
- * load `appkit-seed load --collections users` documented in CLAUDE.md's
838
- * § "Claude Tester" — which `backfill-blind-index.mjs` exists to catch up
839
- * after. `payouts` has no such safety net: it is SEED_TRANSACTIONAL, so it
840
- * cascades and RESEEDS on every tester run that touches its store, through
841
- * exactly this function — which is why it is the one entry that matters.
842
- */
843
- const TOKENS_INDEX_MAP = {
844
- payouts: { field: "sellerTokensIndex", from: ["sellerName", "sellerEmail"] },
845
- };
846
-
847
- function encryptSeedPii(collection, data) {
848
- const PII_MAP = {
849
- orders: ORDER_PII_FIELDS,
850
- bids: BID_PII_FIELDS,
851
- payouts: PAYOUT_PII_FIELDS,
852
- reviews: REVIEW_PII_FIELDS,
853
- products: ["sellerName", "sellerEmail"],
854
- offers: OFFER_PII_FIELDS,
855
- eventEntries: EVENT_ENTRY_PII_FIELDS,
856
- };
857
- const fields = PII_MAP[collection];
858
- let result = fields ? encryptPiiFields(data, [...fields]) : { ...data };
859
- if (collection === "payouts" && result.bankAccount) {
860
- result.bankAccount = encryptPayoutBankAccount(result.bankAccount);
861
- }
862
-
863
- /*
864
- * 🛑 THIS IS WHAT MAKES `payouts` SURVIVE A TESTER WIPE WITH A WORKING
865
- * SEARCH INDEX. `payouts` is SEED_TRANSACTIONAL in the tester harness —
866
- * it cascades on `storeId` whenever its store is wiped, then gets
867
- * RE-SEEDED by exactly this function. Without this block, the reseeded
868
- * documents would carry ciphertext and no `sellerTokensIndex` at all,
869
- * silently undoing a one-shot backfill on the very next tester run.
870
- */
871
- const tokensIndex = TOKENS_INDEX_MAP[collection];
872
- if (tokensIndex) {
873
- const values = tokensIndex.from.map((f) => data[f]).filter((v) => typeof v === "string");
874
- if (values.length > 0) {
875
- result[tokensIndex.field] = buildTokensIndex(...values);
876
- result[BLIND_INDEX_KEY_VERSION_FIELD] = CURRENT_BLIND_INDEX_KEY_VERSION;
877
- }
878
- }
879
-
880
- return result;
881
- }
882
-
883
- async function countExisting(colName) {
884
- const seed = SEED_DATA_MAP[colName] || [];
885
- if (seed.length === 0) return 0;
886
-
887
- if (colName === "addresses") {
888
- // SB-UNI-A: top-level `addresses` collection, not users/{uid}/addresses.
889
- const refs = seed.filter((d) => d.id).map((d) => db.collection("addresses").doc(d.id));
890
- if (refs.length === 0) return 0;
891
- const snaps = await db.getAll(...refs);
892
- return snaps.filter((s) => s.exists).length;
893
- }
894
- if (colName === "storeAddresses") {
895
- // Also lands in the top-level `addresses` collection (see loadStoreAddresses).
896
- const refs = seed.filter((d) => d.id).map((d) => db.collection("addresses").doc(d.id));
897
- if (refs.length === 0) return 0;
898
- const snaps = await db.getAll(...refs);
899
- return snaps.filter((s) => s.exists).length;
900
- }
901
- if (colName === "couponUsage") {
902
- const refs = seed.filter((d) => d.userId && d.couponId).map((d) =>
903
- db.collection(USER_COLLECTION).doc(d.userId).collection("couponUsage").doc(d.couponId));
904
- if (refs.length === 0) return 0;
905
- const snaps = await db.getAll(...refs);
906
- return snaps.filter((s) => s.exists).length;
907
- }
908
- if (colName === "wishlists" || colName === "history") {
909
- const refs = seed.filter((d) => d.id).map((d) => db.collection(COLLECTION_MAP[colName]).doc(d.id));
910
- if (refs.length === 0) return 0;
911
- const snaps = await db.getAll(...refs);
912
- return snaps.filter((s) => s.exists).length;
913
- }
914
- if (colName === "siteSettings") {
915
- const snap = await db.collection(COLLECTION_MAP[colName]).doc("global").get();
916
- return snap.exists ? 1 : 0;
917
- }
918
- if (colName === "users") {
919
- const refs = seed.filter((d) => d.uid).map((d) => db.collection(COLLECTION_MAP[colName]).doc(d.uid));
920
- if (refs.length === 0) return 0;
921
- const snaps = await db.getAll(...refs);
922
- return snaps.filter((s) => s.exists).length;
923
- }
924
- if (colName === "faqs") {
925
- // Mirrors loadGeneric: prefer the explicit id (every current faq record has
926
- // one), only derive via generateFAQId for a record that genuinely lacks one.
927
- const refs = seed.map((faq) => {
928
- const id = faq.id ?? generateFAQId({ category: faq.category, question: faq.question });
929
- return db.collection(COLLECTION_MAP[colName]).doc(id);
930
- });
931
- if (refs.length === 0) return 0;
932
- const snaps = await db.getAll(...refs);
933
- return snaps.filter((s) => s.exists).length;
934
- }
935
- // Mirrors loadGeneric's docId = id ?? slug resolution.
936
- const refs = seed.filter((d) => d.id ?? d.slug).map((d) => db.collection(COLLECTION_MAP[colName]).doc(d.id ?? d.slug));
937
- if (refs.length === 0) return 0;
938
- const snaps = await db.getAll(...refs);
939
- return snaps.filter((s) => s.exists).length;
940
- }
941
-
942
- // ---------------------------------------------------------------------------
943
- // Status
944
- // ---------------------------------------------------------------------------
945
- async function cmdStatus() {
946
- console.log(`\n${"collection".padEnd(22)} ${"seed".padStart(6)} ${"in db".padStart(6)}`);
947
- console.log("─".repeat(40));
948
- for (const c of collectionsToProcess) {
949
- const seedCount = (SEED_DATA_MAP[c] || []).length;
950
- const existing = await countExisting(c).catch((e) => { console.error(` err: ${c}: ${e.message}`); return 0; });
951
- const marker = existing === seedCount ? "✓" : existing === 0 ? " " : "~";
952
- console.log(`${marker} ${c.padEnd(20)} ${String(seedCount).padStart(6)} ${String(existing).padStart(6)}`);
953
- }
954
- console.log();
955
- }
956
-
957
- // ---------------------------------------------------------------------------
958
- // Confirm prompt
959
- // ---------------------------------------------------------------------------
960
- async function confirm(msg) {
961
- if (SKIP_CONFIRM) return true;
962
- return new Promise((resolveP) => {
963
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
964
- rl.question(`${msg} [y/N] `, (a) => { rl.close(); resolveP(/^y(es)?$/i.test(a.trim())); });
965
- });
966
- }
967
-
968
- // ---------------------------------------------------------------------------
969
- // Per-collection writers (mirror of /api/demo/seed POST)
970
- // ---------------------------------------------------------------------------
971
- async function resolveAuthConflicts(uid, authUserData) {
972
- if (authUserData.email) {
973
- try {
974
- const conflict = await auth.getUserByEmail(authUserData.email);
975
- if (conflict.uid !== uid) await auth.deleteUser(conflict.uid);
976
- } catch (e) { if (e?.code !== "auth/user-not-found") throw e; }
977
- }
978
- if (authUserData.phoneNumber) {
979
- try {
980
- const conflict = await auth.getUserByPhoneNumber(authUserData.phoneNumber);
981
- if (conflict.uid !== uid) await auth.deleteUser(conflict.uid);
982
- } catch (e) { if (e?.code !== "auth/user-not-found") throw e; }
983
- }
984
- }
985
-
986
- async function loadUsers(seed, stats) {
987
- for (const u of seed) {
988
- try {
989
- const { uid, email, phoneNumber, displayName, photoURL, emailVerified, disabled } = u;
990
- const docRef = db.collection(USER_COLLECTION).doc(uid);
991
- let userExists = false;
992
- try { await auth.getUser(uid); userExists = true; }
993
- catch (e) { if (e.code !== "auth/user-not-found") throw e; }
994
-
995
- const authUserData = { displayName, emailVerified, disabled };
996
- if (email && typeof email === "string") authUserData.email = email;
997
- if (phoneNumber && typeof phoneNumber === "string") authUserData.phoneNumber = phoneNumber;
998
- // Firebase Auth's photoURL field requires an absolute URL. Seed data's
999
- // photoURL is wrapped by seedExtMedia() into a relative /media/ext?url=...
1000
- // app-proxy path — valid for Firestore + the app's own avatar rendering,
1001
- // but auth.createUser()/updateUser() reject it with "must be a valid URL"
1002
- // and abort the WHOLE user record (Auth + Firestore both skipped) before
1003
- // ever reaching the Firestore write below. The app never reads Auth's own
1004
- // photoURL copy anyway — it always resolves avatars via the Firestore
1005
- // users doc — so just skip setting it on the Auth record for relative
1006
- // paths instead of failing the entire seed row.
1007
- if (photoURL && typeof photoURL === "string" && /^https?:\/\//.test(photoURL)) {
1008
- authUserData.photoURL = photoURL;
1009
- }
1010
-
1011
- if (!userExists) {
1012
- await resolveAuthConflicts(uid, authUserData);
1013
- await auth.createUser({ uid, ...authUserData, password: "TempPass123!" });
1014
- }
1015
- if (u.role && u.role !== "user") {
1016
- await auth.setCustomUserClaims(uid, { role: u.role });
1017
- }
1018
-
1019
- // Both arguments read the PLAINTEXT `plain`: indices must be hashed from
1020
- // the source text, and encryption must not see an already-hashed value.
1021
- // The previous form relied on ordering alone (indices first, THEN
1022
- // encrypt) — correct, but one line-swap away from spreading plaintext
1023
- // back over ciphertext, which is why `addPiiIndices` no longer exists.
1024
- /*
1025
- * 🛑 Provenance is stamped here too, and `users` is the collection the
1026
- * whole scheme exists for.
1027
- *
1028
- * It had no permanence marker of any kind, so a seeded persona could not
1029
- * be told apart from a real signup — and a seeded user is the most
1030
- * expensive thing to delete wrongly: it owns an **Auth record**, a
1031
- * `userSlugs` index entry, addresses, a cart, orders, bids and reviews,
1032
- * and `users` sits in the tester harness's PRESERVE tier precisely
1033
- * because a wrong sweep there is the only permanent damage a run can do.
1034
- *
1035
- * Stamped before the PII pass so `plain` carries it: the indices are
1036
- * hashed from plaintext and the encryption must not see a hashed value,
1037
- * so nothing may be added to `docData` after that pair runs.
1038
- */
1039
- const plain = stripUndefined(stampSeedPurpose("users", { ...u }));
1040
- let docData = {
1041
- ...encryptPiiFields(plain, [...USER_PII_FIELDS]),
1042
- ...piiIndicesFor(plain, USER_PII_INDEX_MAP),
1043
- };
1044
- if (docData.payoutDetails) docData.payoutDetails = encryptPayoutDetails(docData.payoutDetails);
1045
- if (docData.shippingConfig) docData.shippingConfig = encryptShippingConfig(docData.shippingConfig);
1046
- await docRef.set(docData, { merge: true });
1047
- stats.created++;
1048
- if (VERBOSE) console.log(` + ${uid}`);
1049
- } catch (err) {
1050
- console.error(` ✗ user ${u.uid}: ${err.message}`);
1051
- stats.errors++;
1052
- }
1053
- }
1054
- }
1055
-
1056
- async function loadSubcollection(seed, parentColl, parentKey, subColl, idKey, piiFields, stats) {
1057
- for (const d of seed) {
1058
- try {
1059
- const parent = d[parentKey];
1060
- const id = d[idKey];
1061
- if (!parent || !id) { stats.errors++; continue; }
1062
- const { [parentKey]: _p, [idKey]: _i, ...data } = d;
1063
- const docRef = db.collection(parentColl).doc(parent).collection(subColl).doc(id);
1064
- /*
1065
- * Subcollection rows inherit the SUBCOLLECTION's purpose, not the
1066
- * parent's — `subColl` is the key in `SEED_COLLECTION_PURPOSE`. A
1067
- * `couponUsage` row under a permanent user is still its own record, and
1068
- * conflating the two would make a parent's classification silently
1069
- * govern children it knows nothing about.
1070
- */
1071
- const stamped = stripUndefined(stampSeedPurpose(subColl, data));
1072
- const payload = piiFields ? encryptPiiFields(stamped, [...piiFields]) : stamped;
1073
- await docRef.set(payload, { merge: true });
1074
- stats.created++;
1075
- } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
1076
- }
1077
- }
1078
-
1079
- async function loadOneDocPerUser(seed, coll, stats) {
1080
- for (const d of seed) {
1081
- try {
1082
- const { id, userId, items, updatedAt } = d;
1083
- if (!id || !userId) { stats.errors++; continue; }
1084
- /*
1085
- * 🛑 `merge: false` — this loader REPLACES the document, which is correct
1086
- * for a wishlist or a history list (the array IS the record), and is
1087
- * exactly why the stamp has to be inside the payload rather than added
1088
- * by a later merge-write that would be wiped on the next load.
1089
- *
1090
- * `coll` is the real Firestore collection name here rather than the seed
1091
- * key, and for `wishlists`/`history` the two are identical — the only two
1092
- * callers. `resolveSeedPurpose` falls back to `permanent` if that ever
1093
- * stops being true, which is the safe direction.
1094
- */
1095
- await db.collection(coll).doc(id).set(
1096
- stripUndefined(
1097
- stampSeedPurpose(coll, {
1098
- userId,
1099
- items: items ?? [],
1100
- updatedAt: updatedAt ?? new Date(),
1101
- }),
1102
- ),
1103
- { merge: false },
1104
- );
1105
- stats.created++;
1106
- } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
1107
- }
1108
- }
1109
-
1110
- /**
1111
- * Collections whose rows a human may hand-correct, with the correction
1112
- * protected from the next reseed.
1113
- *
1114
- * 🛑 AN EXPLICIT LIST, not "every collection". Protection costs one query per
1115
- * load, and more importantly it is only meaningful for GENERATED data: a row
1116
- * an admin authored from scratch (a coupon, an event) is not competing with a
1117
- * generator for ownership of its fields, so there is nothing to protect it
1118
- * from. Adding a collection here is a statement that a generator owns the row
1119
- * and a human may override named fields of it.
1120
- */
1121
- /**
1122
- * Drop the keys a human owns from one payload.
1123
- *
1124
- * 🛑 A LOCAL copy of `stripAdminOwnedFields` from
1125
- * `appkit/src/features/parts/schemas/admin-edit.ts`, and the duplication is
1126
- * deliberate: this CLI must run without a built appkit. Importing it from
1127
- * `dist` would reintroduce exactly the build dependency the catalogue
1128
- * generator just shed, and would make a reseed impossible under a memory cap.
1129
- * The function is five lines; the dependency would not be.
1130
- */
1131
- function stripAdminOwned(payload, ownedFields) {
1132
- if (!ownedFields?.length) return payload;
1133
- const out = {};
1134
- for (const [k, v] of Object.entries(payload)) {
1135
- if (ownedFields.includes(k)) continue;
1136
- out[k] = v;
1137
- }
1138
- return out;
1139
- }
1140
-
1141
- const ADMIN_EDIT_PROTECTED = new Set(["partGlossary", "beys"]);
1142
-
1143
- /**
1144
- * Fields a seed may CREATE but must never OVERWRITE — derived counters.
1145
- *
1146
- * 🛑 This exists because seeding silently zeroed the whole taxonomy. Every row
1147
- * in `categories-seed-data.ts` hard-codes
1148
- * `metrics: { productCount: 0, totalProductCount: 0, … }`, and `seed-cli`
1149
- * merge-writes — a merge overwrites any key PRESENT in the payload. So
1150
- * `appkit-seed load --collections categories` set every counter on every
1151
- * category to zero.
1152
- *
1153
- * Measured 2026-10-21: after a demo purge, all 17 categories that should have
1154
- * carried stock read `total=0` while their products were filed perfectly --
1155
- * `category-beyblade-x` stored 0 against an actual 14. Every parent looked
1156
- * empty. Reseeding to add four bundles then zeroed them a SECOND time,
1157
- * minutes after they had been repaired.
1158
- *
1159
- * Nothing errors, because a wrong number is just a number. The symptom reaches
1160
- * only a human looking at a page that says a category has nothing in it.
1161
- *
1162
- * 🛑 CREATE but not OVERWRITE, not "always strip" — `CategoryDocument.metrics`
1163
- * is a REQUIRED field with three unguarded readers (`CategoryTableColumns`,
1164
- * `CategoryTree`, `isFeaturedEligible`). Stripping it unconditionally would
1165
- * leave a freshly seeded document violating its own type until the reconciler
1166
- * first ran. Establishing zeros on create is correct; overwriting live counts
1167
- * is not.
1168
- *
1169
- * The authority for these fields is `runCountersReconcile` plus the live
1170
- * write triggers — see the `countersReconcileNow` Function for the on-demand
1171
- * run that belongs immediately after a seed.
1172
- */
1173
- const DERIVED_COUNTER_FIELDS = {
1174
- categories: ["metrics"],
1175
- stores: ["stats"],
1176
- };
1177
-
1178
- /**
1179
- * The ids a collection already holds, when it has derived counters to protect.
1180
- *
1181
- * 🛑 A `.select()` projection — ids only, no document bodies — so this costs
1182
- * reads but transfers almost nothing. It runs ONLY for the two collections in
1183
- * `DERIVED_COUNTER_FIELDS`, and only when that collection is actually being
1184
- * loaded; every other collection pays nothing.
1185
- *
1186
- * Fails OPEN to an empty set: if the existence probe itself fails we would
1187
- * rather write the seed's zeros (recoverable by one reconcile) than abort a
1188
- * load. The reconciler is the backstop either way.
1189
- */
1190
- async function fetchExistingIds(db, collection) {
1191
- const fields = DERIVED_COUNTER_FIELDS[collection];
1192
- if (!fields) return null;
1193
- try {
1194
- const snap = await db.collection(collection).select().get();
1195
- return new Set(snap.docs.map((d) => d.id));
1196
- } catch (err) {
1197
- console.log(
1198
- ` ⚠ could not probe existing ${collection} ids (${err?.message ?? err}) — derived counters may be reset; run countersReconcileNow after this load`,
1199
- );
1200
- return null;
1201
- }
1202
- }
1203
-
1204
- /** Drop derived-counter keys when the document already exists. */
1205
- function stripDerivedCounters(collection, payload, docId, existingIds) {
1206
- const fields = DERIVED_COUNTER_FIELDS[collection];
1207
- if (!fields || !existingIds || !existingIds.has(docId)) return payload;
1208
- const out = {};
1209
- for (const [k, v] of Object.entries(payload)) {
1210
- if (fields.includes(k)) continue;
1211
- out[k] = v;
1212
- }
1213
- return out;
1214
- }
1215
-
1216
- /**
1217
- * The fields a human owns, per document id, for one collection.
1218
- *
1219
- * 🛑 ONE INDEXED EQUALITY, not a read per document. The alternative is
1220
- * fetching all 2,488 parts and 1,988 beys to discover the handful that are
1221
- * protected — 4,476 reads against a 50,000/day free tier, every load, to find
1222
- * what is usually a dozen rows.
1223
- *
1224
- * 🛑 It must be an EQUALITY on a boolean, not an `orderBy` or `!=` on
1225
- * `adminEditedFields`. Both of those silently exclude every document that
1226
- * LACKS the field, which is nearly all of them — the trap that made a store's
1227
- * whole review set return zero (Root Cause #100) and the one `isTestData`
1228
- * warning in the Known TS Patterns table.
1229
- *
1230
- * Fails OPEN to an empty map: if this query errors the load proceeds
1231
- * unprotected, which loses a hand-edit. The alternative — aborting the load —
1232
- * loses the entire reseed. A warning names the collection either way.
1233
- */
1234
- async function fetchAdminOwnedFields(colName, firestoreCollection) {
1235
- if (!ADMIN_EDIT_PROTECTED.has(colName)) return new Map();
1236
- try {
1237
- const snap = await db
1238
- .collection(firestoreCollection)
1239
- .where("adminEdited", "==", true)
1240
- .select("adminEditedFields")
1241
- .get();
1242
- const map = new Map();
1243
- for (const d of snap.docs) {
1244
- const fields = d.get("adminEditedFields");
1245
- if (Array.isArray(fields) && fields.length > 0) map.set(d.id, fields);
1246
- }
1247
- if (map.size > 0) {
1248
- console.log(
1249
- ` ${map.size} row(s) carry admin-owned fields — those keys will not be overwritten`,
1250
- );
1251
- }
1252
- return map;
1253
- } catch (err) {
1254
- console.warn(
1255
- ` ⚠ could not read admin-owned fields for ${colName} (${err?.message ?? err}).
1256
- ` +
1257
- ` Proceeding WITHOUT protection — a hand-correction in this collection may be lost.`,
1258
- );
1259
- return new Map();
1260
- }
1261
- }
1262
-
1263
- async function loadGeneric(colName, seed, firestoreCollection, stats) {
1264
- const adminOwned = await fetchAdminOwnedFields(colName, firestoreCollection);
1265
- if (REPLACE_ON_LOAD.has(colName)) {
1266
- const removed = await purgeCollection(firestoreCollection);
1267
- if (VERBOSE) console.log(` purged ${removed} pre-existing docs`);
1268
- }
1269
- /*
1270
- * 🛑 Probed AFTER any replace-purge, never before. On a `REPLACE_ON_LOAD`
1271
- * collection the purge removes every document, so an id set taken earlier
1272
- * would claim rows exist that no longer do — and their derived counters
1273
- * would then be stripped from a payload that is now a CREATE, leaving the
1274
- * field absent on a required type. Probing here means a purged collection
1275
- * correctly reports nothing existing.
1276
- */
1277
- const existingIds = await fetchExistingIds(db, colName);
1278
- if (existingIds && existingIds.size > 0) {
1279
- console.log(
1280
- ` • ${colName}: preserving live ${DERIVED_COUNTER_FIELDS[colName].join("/")} on ${existingIds.size} existing doc(s)`,
1281
- );
1282
- }
1283
- const items = [];
1284
- for (const docData of seed) {
1285
- let { id, ...data } = docData;
1286
- if (!id && colName === "faqs") {
1287
- id = generateFAQId({ category: docData.category, question: docData.question });
1288
- if (!id) { stats.errors++; continue; }
1289
- } else if (!id) { stats.errors++; continue; }
1290
- // `id` already carries the project's mandatory slug prefix (blog-, event-,
1291
- // faq-, etc. — see CLAUDE.md's Slug Prefix System). Preferring the bare
1292
- // `slug` field here (as this used to) wrote every blogPosts/events doc
1293
- // under its unprefixed slug instead, e.g. "auction-feature-launch"
1294
- // instead of "blog-auction-feature-launch" — found 2026-08-17 auditing
1295
- // real Firestore doc IDs after a full reseed.
1296
- const docId = id ?? docData.slug;
1297
- if (!data.slug) data.slug = docData.slug ?? docId;
1298
- items.push({
1299
- ref: db.collection(firestoreCollection).doc(docId),
1300
- /*
1301
- * 🛑 Stamp provenance BEFORE `stripUndefined`, not after.
1302
- *
1303
- * `seedPurposeFields` deliberately OMITS `isTestData` for a `permanent`
1304
- * row rather than writing `false` — so stamping afterwards would be
1305
- * indistinguishable from not stamping, and stripping afterwards is what
1306
- * keeps the absence genuine instead of writing `undefined` keys.
1307
- *
1308
- * One call, one choke point: every one of the ~2,500 documents this
1309
- * loader writes gets a `seedPurpose`, so the purge is a query rather than
1310
- * a heuristic, and `users`/`orders` are reachable by it for the first
1311
- * time.
1312
- */
1313
- /*
1314
- * 🛑 `docId` is passed EXPLICITLY because this loader destructures `id`
1315
- * out (`let { id, ...data } = docData`), so the row it hands over has no
1316
- * id for the per-row overrides to match on. Measured without it:
1317
- * `store-letitrip-official` loaded as `test` — the platform's own store,
1318
- * which is one of exactly two rows the keep-list exists to protect.
1319
- */
1320
- /*
1321
- * 🛑 Admin-owned keys dropped LAST, after every other transform.
1322
- *
1323
- * Stripping earlier would let `stampSeedPurpose` or `encryptSeedPii`
1324
- * re-add a key a human owns — both of them write fields — and the whole
1325
- * point is that what reaches Firestore carries none of them. A
1326
- * merge-write leaves an absent key exactly as it was, which is how the
1327
- * correction survives.
1328
- */
1329
- /*
1330
- * 🛑 Derived counters dropped on an EXISTING document, after everything
1331
- * else, for the same reason admin-owned keys are: a merge-write leaves
1332
- * an absent key exactly as it was, which is what preserves the live
1333
- * value. Stripping earlier would let a later transform re-add it.
1334
- */
1335
- data: stripDerivedCounters(
1336
- colName,
1337
- stripAdminOwned(
1338
- encryptSeedPii(colName, stripUndefined(stampSeedPurpose(colName, data, docId))),
1339
- adminOwned.get(docId),
1340
- ),
1341
- docId,
1342
- existingIds,
1343
- ),
1344
- });
1345
- }
1346
- /*
1347
- * 🛑 Chunk by INDEX ENTRIES, not just by operation count.
1348
- *
1349
- * This loop capped at 500 operations and nothing else. That was invisible for
1350
- * years because every seeded collection had small documents; B5's 416
1351
- * categories are the first that do not. Measured 2026-10-11: one 416-op
1352
- * commit failed with `INVALID_ARGUMENT: Transaction too big` and wrote
1353
- * **0 of 416** — not a partial load, nothing at all.
1354
- *
1355
- * 🛑 The failure mode is what makes this worth a real fix rather than a
1356
- * retry: `✓ Load complete — created 2097, errors 1` reads as a near-complete
1357
- * run, and the ONE missing collection was the taxonomy that every product's
1358
- * `categorySlugs` chain points into. A purge-then-load left 70 products
1359
- * referencing ids that did not exist, with a cheerful summary line.
1360
- *
1361
- * ## What actually binds, measured by bisection rather than assumed
1362
- *
1363
- * My first fix capped PAYLOAD BYTES, on the theory that these documents are
1364
- * large. They are not: 416 categories are **1.51 MiB total**, mean 3.7 KiB,
1365
- * largest 7.9 KiB — nowhere near the 10 MiB request limit, and the byte cap
1366
- * never once triggered. The commit failed anyway.
1367
- *
1368
- * Bisecting the op count found the real boundary, and counting array elements
1369
- * explains it exactly:
1370
- *
1371
- * 416 ops -> 60,787 array elements -> FAILS
1372
- * 250 ops -> 42,912 -> FAILS
1373
- * 100 ops -> 17,578 -> works
1374
- * 50 ops -> 8,791 -> works
1375
- *
1376
- * The threshold sits right at **40,000**, which is Firestore's documented
1377
- * maximum index entries — applied per COMMIT here, not per document. Every
1378
- * element of an indexed array is an index entry, and a category carries ~146
1379
- * of them (`searchTxt` alone is ~150 tokens, plus `parentIds`, `ancestors`,
1380
- * `categoryNames`, `faqs`, `highlights`, `descriptionTemplates`).
1381
- *
1382
- * So the cap is on array elements at **20,000** — half the real limit, the
1383
- * same margin the byte cap keeps — and it is kept alongside the op and byte
1384
- * caps rather than replacing them, because all three are real Firestore
1385
- * limits and only one of them has bitten so far.
1386
- *
1387
- * 🛑 Do NOT "simplify" this back to a lower fixed op count. 100 would work
1388
- * for categories and silently triple the commit count for `faqs` and the
1389
- * 1,337 tester checklist items, which carry almost no arrays. The cap has to
1390
- * key on the thing that varies.
1391
- */
1392
- const MAX_OPS = Number(process.env.SEED_BATCH_OPS ?? 500);
1393
- const MAX_BYTES = 4 * 1024 * 1024;
1394
- const MAX_INDEX_ENTRIES = Number(process.env.SEED_BATCH_INDEX_ENTRIES ?? 20_000);
1395
-
1396
- /** Array elements, recursively — a proxy for index entries written. */
1397
- const countArrayElements = (v) => {
1398
- if (Array.isArray(v)) return v.length + v.reduce((s, x) => s + countArrayElements(x), 0);
1399
- if (v && typeof v === "object" && !(v instanceof Date)) {
1400
- return Object.values(v).reduce((s, x) => s + countArrayElements(x), 0);
1401
- }
1402
- return 0;
1403
- };
1404
-
1405
- let chunk = [];
1406
- let chunkBytes = 0;
1407
- let chunkEntries = 0;
1408
- const flush = async () => {
1409
- if (chunk.length === 0) return;
1410
- const batch = db.batch();
1411
- for (const { ref, data } of chunk) batch.set(ref, data, { merge: true });
1412
- await batch.commit();
1413
- stats.created += chunk.length;
1414
- chunk = [];
1415
- chunkBytes = 0;
1416
- chunkEntries = 0;
1417
- };
1418
-
1419
- for (const item of items) {
1420
- let size = 0;
1421
- try {
1422
- size = Buffer.byteLength(JSON.stringify(item.data), "utf8");
1423
- } catch {
1424
- /*
1425
- * A payload that cannot be serialised cannot be measured. Treat it as a
1426
- * whole chunk's worth rather than as zero — guessing small is how a
1427
- * commit silently grows past a limit again, and a document that resists
1428
- * measurement is the one most likely to be large.
1429
- */
1430
- size = MAX_BYTES;
1431
- }
1432
- const entries = countArrayElements(item.data);
1433
- /*
1434
- * A single document over any cap still has to be written, and alone: a
1435
- * one-document commit is the smallest thing committable, so if Firestore
1436
- * rejects THAT the document itself is the problem and the error says so,
1437
- * rather than the failure being absorbed into a chunking bug.
1438
- */
1439
- if (
1440
- chunk.length > 0 &&
1441
- (chunk.length >= MAX_OPS ||
1442
- chunkBytes + size > MAX_BYTES ||
1443
- chunkEntries + entries > MAX_INDEX_ENTRIES)
1444
- ) {
1445
- await flush();
1446
- }
1447
- chunk.push(item);
1448
- chunkBytes += size;
1449
- chunkEntries += entries;
1450
- }
1451
- await flush();
1452
- }
1453
-
1454
- // ---------------------------------------------------------------------------
1455
- // Load
1456
- // ---------------------------------------------------------------------------
1457
- async function cmdLoad() {
1458
- if (DRY_RUN) return cmdDryRun("load");
1459
-
1460
- const stats = { created: 0, deleted: 0, skipped: 0, errors: 0 };
1461
- for (const colName of collectionsToProcess) {
1462
- const seed = SEED_DATA_MAP[colName];
1463
- if (!seed || seed.length === 0) { console.log(`▌ ${colName} — no seed data, skipping`); continue; }
1464
- console.log(`▌ ${colName} (${seed.length} docs)`);
1465
-
1466
- try {
1467
- if (colName === "users") {
1468
- await loadUsers(seed, stats);
1469
- } else if (colName === "addresses") {
1470
- // SB-UNI-A (2026-05-13) unified addresses into one top-level collection
1471
- // with an ownerType/ownerId discriminator, replacing the two prior
1472
- // subcollections (users/{uid}/addresses, stores/{slug}/addresses) this
1473
- // loader used to write to. addressesSeedData already carries
1474
- // ownerType/ownerId directly — write it straight to the top-level
1475
- // collection instead of nesting under USER_COLLECTION's old subcollection.
1476
- for (const d of seed) {
1477
- try {
1478
- const { id, ownerType, ownerId, ...data } = d;
1479
- if (!id || !ownerType || !ownerId) { stats.errors++; continue; }
1480
- const payload = encryptPiiFields(
1481
- stripUndefined(stampSeedPurpose("addresses", { ownerType, ownerId, ...data }, id)),
1482
- [...ADDRESS_PII_FIELDS],
1483
- );
1484
- await db.collection("addresses").doc(id).set(payload, { merge: true });
1485
- stats.created++;
1486
- } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
1487
- }
1488
- } else if (colName === "storeAddresses") {
1489
- // storeAddressesSeedData predates the SB-UNI-A unification and still
1490
- // carries the old storeSlug field instead of ownerType/ownerId — derive
1491
- // the unified shape at load time rather than writing to the deleted
1492
- // stores/{slug}/addresses subcollection.
1493
- for (const d of seed) {
1494
- try {
1495
- const { id, storeSlug, ...data } = d;
1496
- if (!id || !storeSlug) { stats.errors++; continue; }
1497
- /*
1498
- * Stamped under the SEED KEY `storeAddresses`, not the target
1499
- * collection `addresses`. Both resolve `test`, so nothing differs
1500
- * today — but the purpose table is keyed on the seed key, and
1501
- * stamping under the write target would make the two disagree the
1502
- * day one of them changes.
1503
- */
1504
- const payload = encryptPiiFields(
1505
- stripUndefined(
1506
- stampSeedPurpose("storeAddresses", { ownerType: "store", ownerId: storeSlug, ...data }, id),
1507
- ),
1508
- [...ADDRESS_PII_FIELDS],
1509
- );
1510
- await db.collection("addresses").doc(id).set(payload, { merge: true });
1511
- stats.created++;
1512
- } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
1513
- }
1514
- } else if (colName === "couponUsage") {
1515
- // shape: { userId, couponId, ... } — no PII
1516
- for (const d of seed) {
1517
- try {
1518
- const { userId, couponId, ...data } = d;
1519
- if (!userId || !couponId) { stats.errors++; continue; }
1520
- await db.collection(USER_COLLECTION).doc(userId).collection("couponUsage").doc(couponId)
1521
- .set(
1522
- stripUndefined(stampSeedPurpose("couponUsage", { couponId, ...data }, couponId)),
1523
- { merge: true },
1524
- );
1525
- stats.created++;
1526
- } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
1527
- }
1528
- } else if (colName === "wishlists") {
1529
- await loadOneDocPerUser(seed, WISHLIST_COLLECTION, stats);
1530
- } else if (colName === "history") {
1531
- await loadOneDocPerUser(seed, HISTORY_COLLECTION, stats);
1532
- } else if (colName === "siteSettings") {
1533
- const settings = seed[0];
1534
- if (settings) {
1535
- await db.collection(SITE_SETTINGS_COLLECTION).doc("global").set(
1536
- stripUndefined(stampSeedPurpose("siteSettings", settings, "global")), { merge: true });
1537
- stats.created++;
1538
- }
1539
- } else {
1540
- await loadGeneric(colName, seed, COLLECTION_MAP[colName], stats);
1541
- }
1542
- } catch (err) {
1543
- console.error(`✗ ${colName}: ${err.message}`);
1544
- stats.errors++;
1545
- }
1546
- }
1547
-
1548
- console.log(`\n✓ Load complete — created ${stats.created}, errors ${stats.errors}\n`);
1549
- process.exit(stats.errors > 0 ? 1 : 0);
1550
- }
1551
-
1552
- // ---------------------------------------------------------------------------
1553
- // Delete
1554
- // ---------------------------------------------------------------------------
1555
- async function cmdDelete() {
1556
- if (DRY_RUN) return cmdDryRun("delete");
1557
-
1558
- if (!(await confirm(`This will purge ${collectionsToProcess.length} collection(s) in project '${process.env.FIREBASE_ADMIN_PROJECT_ID}'. Continue?`))) {
1559
- console.log("Aborted."); process.exit(0);
1560
- }
1561
-
1562
- const PROTECTED_USER_UIDS = new Set(["user-admin-letitrip"]);
1563
- const stats = { deleted: 0, skipped: 0, errors: 0 };
1564
-
1565
- for (const colName of collectionsToProcess) {
1566
- console.log(`▌ ${colName}`);
1567
- const seed = SEED_DATA_MAP[colName] || [];
1568
- try {
1569
- if (colName === "users") {
1570
- for (const u of seed) {
1571
- if (PROTECTED_USER_UIDS.has(u.uid)) { stats.skipped++; continue; }
1572
- try { await auth.deleteUser(u.uid); }
1573
- catch (e) { if (e?.code !== "auth/user-not-found") console.error(` auth: ${e.message}`); }
1574
- const ref = db.collection(USER_COLLECTION).doc(u.uid);
1575
- if ((await ref.get()).exists) { await ref.delete(); stats.deleted++; }
1576
- }
1577
- } else if (colName === "addresses" || colName === "storeAddresses") {
1578
- // SB-UNI-A (2026-05-13): both user and store addresses live in the
1579
- // single top-level `addresses` collection now, keyed by `id` — the
1580
- // old per-parent subcollection paths (users/{uid}/addresses,
1581
- // stores/{slug}/addresses) no longer exist, so deleting from them was
1582
- // always a silent no-op. Delete by doc id straight from `addresses`.
1583
- for (const d of seed) {
1584
- if (!d.id) { stats.skipped++; continue; }
1585
- const ref = db.collection("addresses").doc(d.id);
1586
- if ((await ref.get()).exists) { await ref.delete(); stats.deleted++; }
1587
- else stats.skipped++;
1588
- }
1589
- } else if (colName === "couponUsage") {
1590
- const userIds = [...new Set(seed.map((d) => d.userId).filter(Boolean))];
1591
- for (const uid of userIds) {
1592
- stats.deleted += await purgeCollection(`${USER_COLLECTION}/${uid}/couponUsage`);
1593
- }
1594
- } else if (colName === "siteSettings") {
1595
- const ref = db.collection(SITE_SETTINGS_COLLECTION).doc("global");
1596
- if ((await ref.get()).exists) { await ref.delete(); stats.deleted++; } else stats.skipped++;
1597
- } else {
1598
- stats.deleted += await purgeCollection(COLLECTION_MAP[colName]);
1599
- }
1600
- } catch (err) {
1601
- console.error(`✗ ${colName}: ${err.message}`);
1602
- stats.errors++;
1603
- }
1604
- }
1605
-
1606
- console.log(`\n✓ Delete complete — removed ${stats.deleted}, errors ${stats.errors}\n`);
1607
- process.exit(stats.errors > 0 ? 1 : 0);
1608
- }
1609
-
1610
- // ---------------------------------------------------------------------------
1611
- // Dry run
1612
- // ---------------------------------------------------------------------------
1613
- async function cmdDryRun(intendedAction) {
1614
- console.log(`\nDry run (${intendedAction}) — no writes will happen.\n`);
1615
- console.log(`${"collection".padEnd(22)} ${"seed".padStart(6)} ${"in db".padStart(6)} ${"would " + (intendedAction === "load" ? "create" : "delete")}`);
1616
- console.log("─".repeat(56));
1617
- let totalCreate = 0, totalDelete = 0;
1618
- for (const c of collectionsToProcess) {
1619
- const seedCount = (SEED_DATA_MAP[c] || []).length;
1620
- const existing = await countExisting(c).catch(() => 0);
1621
- const wouldCreate = intendedAction === "load" ? Math.max(seedCount - existing, 0) : 0;
1622
- const wouldDelete = intendedAction === "delete" ? existing : 0;
1623
- totalCreate += wouldCreate; totalDelete += wouldDelete;
1624
- console.log(` ${c.padEnd(20)} ${String(seedCount).padStart(6)} ${String(existing).padStart(6)} ${String(intendedAction === "load" ? wouldCreate : wouldDelete).padStart(12)}`);
1625
- }
1626
- console.log("─".repeat(56));
1627
- console.log(`Total would ${intendedAction === "load" ? "create" : "delete"}: ${intendedAction === "load" ? totalCreate : totalDelete}\n`);
1628
- }
1629
-
1630
- // ---------------------------------------------------------------------------
1631
- // Main
1632
- /**
1633
- * `appkit-seed purge` — delete every `seedPurpose: "test"` document and NOTHING
1634
- * else.
1635
- *
1636
- * ## Why this is not `delete`
1637
- *
1638
- * `delete` empties whole COLLECTIONS. That is the right tool for a full reset
1639
- * and the wrong one for "clear the test data": it would take the 85 authored
1640
- * FAQs, the 416-node taxonomy every product's `categorySlugs` chain points
1641
- * into, the real HSN tax codes, the 1,337 tester cases and the AES-encrypted
1642
- * site credentials with it.
1643
- *
1644
- * This is a QUERY over a declaration — `where("seedPurpose","==","test")` —
1645
- * which gives four properties no heuristic can:
1646
- *
1647
- * 1. **A row the seeder never wrote is unreachable.** Organic documents (a
1648
- * real buyer's cart, a runtime notification, a real signup) carry no
1649
- * `seedPurpose` at all, so an equality on it cannot match them. Measured
1650
- * live: 64 of 84 users are organic. That is the single most important
1651
- * safety property here, and it comes from the field's ABSENCE rather than
1652
- * from a list someone has to maintain.
1653
- * 2. **The keep-list is data, not code.** `store-letitrip-official` and
1654
- * `user-admin-letitrip` resolve `permanent`, so the same query skips them.
1655
- * This replaces `cmdDelete`'s private `PROTECTED_USER_UIDS` set.
1656
- * 3. **No composite index.** A single-field equality is served by Firestore's
1657
- * automatic index, so this adds nothing to `firestore.indexes.json` — the
1658
- * gap that left `priceIndex` queryless.
1659
- * 4. **The cascade is mostly automatic.** Orders, bids, reviews, offers,
1660
- * carts, payouts and notifications are all `test`, so a purged product's
1661
- * dependents go in the same pass rather than needing a reference walk.
1662
- *
1663
- * 🛑 **Auth is a SECOND delete and `delete`/`purge` are the only places that
1664
- * know it.** A Firestore user row and its Firebase Auth record are two objects;
1665
- * removing the row and leaving the login is the worst of the three outcomes,
1666
- * because the account then looks absent and can still authenticate.
1667
- *
1668
- * 🛑 **What it deliberately does NOT do**: delete organic rows that reference a
1669
- * purged one. An organic notification pointing at a purged test order becomes a
1670
- * dangling reference — and it is a real person's record, so it is REPORTED
1671
- * rather than removed. Guessing in the destructive direction on someone's own
1672
- * data is not a trade this command gets to make.
1673
- */
1674
- async function cmdPurge() {
1675
- const targets = collectionsToProcess;
1676
-
1677
- /* Subcollections are not reachable by a top-level query, so they are walked
1678
- * explicitly. `couponUsage` lives at `users/{uid}/couponUsage`. */
1679
- const SUBCOLLECTIONS = [{ key: "couponUsage", parent: USER_COLLECTION, name: "couponUsage" }];
1680
-
1681
- const plan = [];
1682
- for (const colName of targets) {
1683
- const path = COLLECTION_MAP[colName];
1684
- if (!path) continue;
1685
- const sub = SUBCOLLECTIONS.find((s) => s.key === colName);
1686
- try {
1687
- if (sub) {
1688
- /*
1689
- * 🛑 Unfiltered collection-group read, filtered in memory — NOT
1690
- * `.where("seedPurpose", "==", "test")` on the group.
1691
- *
1692
- * A single-field equality is auto-indexed per COLLECTION, but a
1693
- * COLLECTION-GROUP query needs an explicit composite/group index that
1694
- * this project has never deployed, and `purge` must not require a
1695
- * Firebase deploy just to run (it is a read-then-delete script, not an
1696
- * infra change). Measured live: `3 FAILED_PRECONDITION: ... requires a
1697
- * COLLECTION_GROUP_ASC index`. `couponUsage` is small (single digits
1698
- * per store), so reading the whole group and filtering client-side
1699
- * costs nothing here — this is not a pattern to reuse on a large
1700
- * subcollection.
1701
- */
1702
- const snap = await db.collectionGroup(sub.name).get();
1703
- const docs = snap.docs.filter((d) => d.data()?.seedPurpose === "test");
1704
- if (docs.length > 0) plan.push({ colName, docs, isSub: true });
1705
- } else {
1706
- const snap = await db.collection(path).where("seedPurpose", "==", "test").get();
1707
- if (snap.size > 0) plan.push({ colName, docs: snap.docs, isSub: false });
1708
- }
1709
- } catch (err) {
1710
- console.error(`✗ ${colName}: ${err.message}`);
1711
- }
1712
- }
1713
-
1714
- const total = plan.reduce((n, p) => n + p.docs.length, 0);
1715
- console.log(`\n${"collection".padEnd(26)} ${"purge".padStart(6)}`);
1716
- console.log("─".repeat(36));
1717
- for (const p of plan) {
1718
- console.log(` ${p.colName.padEnd(24)} ${String(p.docs.length).padStart(6)}`);
1719
- }
1720
- console.log("─".repeat(36));
1721
- console.log(` ${"TOTAL".padEnd(24)} ${String(total).padStart(6)}\n`);
1722
-
1723
- if (total === 0) {
1724
- console.log("Nothing to purge — no document carries seedPurpose \"test\".");
1725
- return;
1726
- }
1727
-
1728
- if (DRY_RUN) {
1729
- console.log("--dry-run — nothing was deleted.");
1730
- return;
1731
- }
1732
-
1733
- if (
1734
- !(await confirm(
1735
- `Delete ${total} test document(s) in project '${process.env.FIREBASE_ADMIN_PROJECT_ID}'? Permanent content and organic rows are untouched.`,
1736
- ))
1737
- ) {
1738
- console.log("Aborted.");
1739
- process.exit(0);
1740
- }
1741
-
1742
- const stats = { deleted: 0, auth: 0, errors: 0 };
1743
- for (const p of plan) {
1744
- /*
1745
- * 🛑 `users` first deletes the AUTH record, then the Firestore row — in that
1746
- * order, because the reverse can leave an authenticating account whose
1747
- * profile is gone, and this loop is the only thing that knows both exist.
1748
- */
1749
- if (p.colName === "users") {
1750
- for (const d of p.docs) {
1751
- try {
1752
- await auth.deleteUser(d.id);
1753
- stats.auth++;
1754
- } catch (e) {
1755
- if (e?.code !== "auth/user-not-found") console.error(` auth ${d.id}: ${e.message}`);
1756
- }
1757
- }
1758
- }
1759
- /*
1760
- * 🛑 BulkWriter, never a manually-chunked `db.batch()`.
1761
- *
1762
- * A 450-document chunk stays under Firestore's 500-OPERATION limit but
1763
- * says nothing about the 10 MiB per-request PAYLOAD limit — and a rich
1764
- * document (a product's `searchTxt` array, `customFields`,
1765
- * `customSections`, `images[]`) can blow that well before 450 count.
1766
- * Measured live purging this exact `products` collection: `3
1767
- * INVALID_ARGUMENT: Transaction too big` partway through, which aborted
1768
- * the whole chunk's commit and silently left every doc in it undeleted —
1769
- * `stats.deleted` kept counting the chunk as done regardless, so the
1770
- * printed total was wrong by exactly the size of the failed chunk.
1771
- *
1772
- * `db.bulkWriter()` sizes and retries its own requests, so it is correct
1773
- * regardless of document size — CLAUDE.md documents the identical failure
1774
- * for a `categories` wipe (1,177 rows, ~100-entry `searchTxt` each) dying
1775
- * at a 400-doc batch for the same reason.
1776
- */
1777
- const writer = db.bulkWriter();
1778
- let chunkErrors = 0;
1779
- writer.onWriteError((err) => {
1780
- chunkErrors += 1;
1781
- console.error(`✗ ${p.colName}/${err.documentRef.id}: ${err.message}`);
1782
- // Retry up to the default limit; beyond that, give up on this one doc
1783
- // rather than retrying forever.
1784
- return err.failedAttempts < 3;
1785
- });
1786
- for (const d of p.docs) {
1787
- writer.delete(d.ref);
1788
- if (VERBOSE) console.log(` - ${p.colName}/${d.id}`);
1789
- }
1790
- await writer.close();
1791
- stats.deleted += p.docs.length - chunkErrors;
1792
- stats.errors += chunkErrors;
1793
- }
1794
-
1795
- console.log(
1796
- `\n${stats.errors === 0 ? "✓" : "✗"} Purge complete — deleted ${stats.deleted}, auth records ${stats.auth}, errors ${stats.errors}`,
1797
- );
1798
- if (stats.errors > 0) process.exit(1);
1799
- }
1800
-
1801
- // ---------------------------------------------------------------------------
1802
- try {
1803
- if (ACTION === "status") await cmdStatus();
1804
- else if (ACTION === "load") await cmdLoad();
1805
- else if (ACTION === "delete") await cmdDelete();
1806
- else if (ACTION === "purge") await cmdPurge();
1807
- process.exit(0);
1808
- } catch (err) {
1809
- console.error("✗ Fatal:", err.message);
1810
- if (VERBOSE) console.error(err.stack);
1811
- process.exit(1);
1812
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * seed-cli — standalone Firestore seed loader / deleter for LetItRip.
4
+ *
5
+ * Talks directly to Firestore via firebase-admin — does not require the Next.js
6
+ * dev server to be running. Reads Firebase credentials and PII secrets from the
7
+ * consumer project's `.env.local` (resolved against process.cwd()).
8
+ *
9
+ * This mirrors the logic in src/app/api/demo/seed/route.ts. Keep them in sync.
10
+ *
11
+ * Usage (from consumer project root, e.g. d:/proj/letitrip.in):
12
+ * npx appkit-seed load # load all collections
13
+ * npx appkit-seed load --collections users,categories
14
+ * npx appkit-seed delete --yes # purge all (skip confirmation)
15
+ * npx appkit-seed load --dry-run # plan without writing
16
+ * npx appkit-seed status # show seed/existing counts
17
+ *
18
+ * Flags:
19
+ * --collections <a,b,c> subset of collections (default: all)
20
+ * --dry-run compute plan only, no writes
21
+ * --yes skip the destructive-confirm prompt for `delete`
22
+ * --verbose log every doc id
23
+ * --service-account <p> path to a JSON key file (overrides env vars)
24
+ *
25
+ * Exit: 0 = success, 1 = errors during run, 2 = misconfiguration.
26
+ */
27
+
28
+ import { existsSync, readFileSync } from "node:fs";
29
+ import { resolve, basename, dirname } from "node:path";
30
+ import { createRequire, register } from "node:module";
31
+ import { fileURLToPath, pathToFileURL } from "node:url";
32
+ import readline from "node:readline";
33
+
34
+ // Register an ESM resolver hook that retries with `.js` / `/index.js` when
35
+ // appkit's bundler-target dist emits extension-less relative imports.
36
+ const __dirname = dirname(fileURLToPath(import.meta.url));
37
+ register("./node-esm-loader.mjs", pathToFileURL(__dirname + "/"));
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // CLI parsing — entry point detects bin name so the same script powers
41
+ // `appkit-seed <verb>`, `appkit-seed-add`, and `appkit-seed-remove`.
42
+ // ---------------------------------------------------------------------------
43
+ const rawArgs = process.argv.slice(2);
44
+ const POSITIONAL = rawArgs.filter((a) => !a.startsWith("--"));
45
+ function flag(name, fallback = undefined) {
46
+ const i = rawArgs.indexOf(`--${name}`);
47
+ if (i === -1) return fallback;
48
+ const v = rawArgs[i + 1];
49
+ return v && !v.startsWith("--") ? v : true;
50
+ }
51
+
52
+ const binName = basename(process.argv[1] || "").replace(/\.(mjs|js|cmd|exe|ps1)$/i, "");
53
+ const BIN_ACTION_MAP = {
54
+ "appkit-seed-add": "load",
55
+ "appkit-seed-remove": "delete",
56
+ "appkit-seed-status": "status",
57
+ };
58
+ const ACTION = BIN_ACTION_MAP[binName] ?? POSITIONAL[0];
59
+ if (!["load", "delete", "status", "purge"].includes(ACTION)) {
60
+ console.error("usage:");
61
+ console.error(" appkit-seed <load|delete|status|purge> [flags]");
62
+ console.error(" appkit-seed-add [flags] # alias for `appkit-seed load`");
63
+ console.error(" appkit-seed-remove [flags] # alias for `appkit-seed delete`");
64
+ console.error("");
65
+ console.error(" purge delete every document whose seedPurpose is \"test\", and NOTHING else.");
66
+ console.error(" Unlike `delete`, which empties whole collections, this keeps real");
67
+ console.error(" content (faqs, categories, taxCodes, productFeatures, blogPosts,");
68
+ console.error(" carousels, homepageSections, siteSettings, the tester checklist) and");
69
+ console.error(" keeps the two keep-list rows: the admin account and store-letitrip-official.");
70
+ console.error(" Organic rows carry no seedPurpose at all and are unreachable by it.");
71
+ console.error("");
72
+ console.error("flags: --collections a,b --dry-run --yes --verbose --service-account <path>");
73
+ process.exit(2);
74
+ }
75
+
76
+ const ONLY = flag("collections");
77
+ const DRY_RUN = flag("dry-run") === true;
78
+ const SKIP_CONFIRM = flag("yes") === true;
79
+ const VERBOSE = flag("verbose") === true;
80
+ const SERVICE_ACCOUNT_OVERRIDE = typeof flag("service-account") === "string" ? flag("service-account") : null;
81
+
82
+ const ONLY_SET = ONLY && typeof ONLY === "string"
83
+ ? new Set(ONLY.split(",").map((s) => s.trim()))
84
+ : null;
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // .env.local loader (avoids a dotenv dep, same shape as firebase-reset.mjs)
88
+ // ---------------------------------------------------------------------------
89
+ const repoRoot = process.cwd();
90
+ const envLocalPath = resolve(repoRoot, ".env.local");
91
+ if (existsSync(envLocalPath)) {
92
+ for (const line of readFileSync(envLocalPath, "utf8").split("\n")) {
93
+ const t = line.trim();
94
+ if (!t || t.startsWith("#")) continue;
95
+ const eq = t.indexOf("=");
96
+ if (eq === -1) continue;
97
+ const k = t.slice(0, eq).trim();
98
+ const raw = t.slice(eq + 1).trim();
99
+ const v = raw.replace(/^["']|["']$/g, "");
100
+ if (!(k in process.env)) process.env[k] = v;
101
+ }
102
+ if (VERBOSE) console.log(`✓ Loaded ${envLocalPath}`);
103
+ } else {
104
+ console.warn(`⚠ .env.local not found at ${envLocalPath} — relying on existing process.env`);
105
+ }
106
+
107
+ // Validate config
108
+ const required = ["FIREBASE_ADMIN_PROJECT_ID", "FIREBASE_ADMIN_CLIENT_EMAIL", "FIREBASE_ADMIN_PRIVATE_KEY"];
109
+ const missing = required.filter((k) => !process.env[k]);
110
+ if (!SERVICE_ACCOUNT_OVERRIDE && missing.length > 0) {
111
+ console.error(`✗ Missing required env vars: ${missing.join(", ")}`);
112
+ console.error(" Either set them in .env.local or pass --service-account <path-to-key.json>");
113
+ process.exit(2);
114
+ }
115
+
116
+ // PII keys are mandatory for collections that encrypt fields
117
+ const PII_REQUIRED = ["users", "addresses", "storeAddresses", "products", "orders", "reviews", "bids", "payouts", "eventEntries"];
118
+ // PII_ENCRYPTION_KEY ONLY. `PII_SECRET` used to be accepted here as an
119
+ // alternative and is read by no runtime code — so a machine with only that set
120
+ // passed this check and then threw at the first encrypt, mid-load, with some
121
+ // collections already written.
122
+ const PII_KEY_PRESENT = Boolean(process.env.PII_ENCRYPTION_KEY);
123
+ if (ACTION === "load" && !PII_KEY_PRESENT) {
124
+ // A hard stop, not a warning. Loading without the key does not skip the PII
125
+ // collections — it writes some and then throws, leaving a half-seeded
126
+ // database that looks like a successful run until someone reads it.
127
+ console.error("✖ PII_ENCRYPTION_KEY is not set — users, addresses, orders, reviews, bids, payouts and eventEntries all encrypt fields at write time.");
128
+ process.exit(2);
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // firebase-admin init
133
+ // ---------------------------------------------------------------------------
134
+ const require = createRequire(import.meta.url);
135
+ // appkit's pii-encrypt.ts calls a bare `require("crypto")` at runtime (not a
136
+ // static import — deliberately, so bundlers like Turbopack never see a
137
+ // node:module dependency and refuse to build the client bundle). A bare
138
+ // `require` identifier resolves via the global scope in Node ESM, so it must
139
+ // exist as a global before the dynamic `import("@mohasinac/appkit")` below
140
+ // ever triggers that code path. Only this standalone-script environment
141
+ // needs it — Next.js's own server bundle (webpack/Turbopack) provides an
142
+ // ambient `require` automatically.
143
+ globalThis.require = require;
144
+ const admin = require("firebase-admin");
145
+
146
+ function parsePrivateKey(raw) {
147
+ if (!raw) return raw;
148
+ return raw.includes("\\n") ? raw.replace(/\\n/g, "\n") : raw;
149
+ }
150
+
151
+ if (!admin.apps.length) {
152
+ if (SERVICE_ACCOUNT_OVERRIDE) {
153
+ const key = JSON.parse(readFileSync(resolve(repoRoot, SERVICE_ACCOUNT_OVERRIDE), "utf8"));
154
+ admin.initializeApp({
155
+ credential: admin.credential.cert(key),
156
+ databaseURL: process.env.FIREBASE_ADMIN_DATABASE_URL || process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
157
+ });
158
+ } else {
159
+ admin.initializeApp({
160
+ credential: admin.credential.cert({
161
+ projectId: process.env.FIREBASE_ADMIN_PROJECT_ID.trim(),
162
+ clientEmail: process.env.FIREBASE_ADMIN_CLIENT_EMAIL.trim(),
163
+ privateKey: parsePrivateKey(process.env.FIREBASE_ADMIN_PRIVATE_KEY),
164
+ }),
165
+ databaseURL:
166
+ process.env.FIREBASE_ADMIN_DATABASE_URL ||
167
+ process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
168
+ });
169
+ }
170
+ }
171
+ const db = admin.firestore();
172
+ const auth = admin.auth();
173
+ console.log(`✓ Connected to project: ${process.env.FIREBASE_ADMIN_PROJECT_ID || "(service-account)"}`);
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Pull seed data + helpers from @mohasinac/appkit
177
+ // ---------------------------------------------------------------------------
178
+ /*
179
+ * ── 🛑 SEED DATA COMES FROM ./seed, NOT THE MAIN BARREL ──────────────────
180
+ *
181
+ * `appkit/src/seed/` is 61% of this package's source bytes and 60% of the
182
+ * published dist — 540 files of pure fixture data. While the main barrel
183
+ * re-exported it, every consumer importing `@mohasinac/appkit` pulled that
184
+ * graph into scope and Turbopack traced all 24 MB of it for nothing.
185
+ *
186
+ * The re-exports are gone from `index.ts`/`server.ts`, so this CLI — the
187
+ * only real consumer of the data — reads it from a dedicated subpath.
188
+ *
189
+ * 🛑 Two imports, deliberately. The seed ARRAYS come from ./seed; the
190
+ * HELPERS (id generators, token builders, PII field maps) stay on the main
191
+ * barrel because they are real library code that the app uses too. Merging
192
+ * them would pull the helpers back into the seed graph and undo the split.
193
+ */
194
+ /*
195
+ * ── 🛑 TWO PLACES THE SEED CAN LIVE, AND A CLEAR ERROR IF NEITHER ────────
196
+ *
197
+ * `dist/seed/**` is 27 MB of the 52 MB dist and is EXCLUDED from the
198
+ * published tarball — a package consumer has no business carrying fixture
199
+ * data. So the `./seed` subpath resolves only where the data actually is:
200
+ *
201
+ * · an installed copy that was resynced from this repo (the normal local
202
+ * case — `cp -r appkit/dist node_modules/@mohasinac/appkit/dist`)
203
+ * · this repo's own `appkit/dist/seed`, when run from the source tree
204
+ *
205
+ * 🛑 A fresh `npm install` from the registry has NEITHER, and that is
206
+ * deliberate: seeding needs the generators and the 18 MB of research JSON
207
+ * that only exist in this repo, so it is a repo operation by nature. What
208
+ * must not happen is the SILENT version — an import that resolves to nothing
209
+ * and a loader that then reports `no seed data, skipping` with exit 0, which
210
+ * is Root Cause #90 and has already cost this project six empty collections.
211
+ * Hence an explicit throw naming both paths.
212
+ */
213
+ async function importSeedModule() {
214
+ const attempts = [];
215
+ /*
216
+ * 🛑 A RELATIVE path, not the `@mohasinac/appkit/seed` subpath.
217
+ *
218
+ * That subpath was added and then REMOVED in the same change: once
219
+ * `dist/seed` is excluded from the tarball, a declared export pointing at a
220
+ * file the package does not contain is a landmine for anyone who imports it.
221
+ * Attempting it here would also be dead code — it can never resolve now — and
222
+ * a dead attempt whose throw is swallowed is exactly what makes the next
223
+ * reader think the subpath still works.
224
+ *
225
+ * Relative resolution covers both real cases: run from this repo it finds
226
+ * `appkit/dist/seed`; run as an installed copy that was resynced from the
227
+ * repo it finds `node_modules/@mohasinac/appkit/dist/seed`.
228
+ */
229
+ const local = pathToFileURL(resolve(__dirname, "../dist/seed/index.js")).href;
230
+ try {
231
+ return await import(local);
232
+ } catch (err) {
233
+ attempts.push(`${local} — ${err?.code ?? err?.message ?? err}`);
234
+ }
235
+ console.error(
236
+ "\u001b[31m✗ Could not load the seed data.\u001b[0m\n" +
237
+ " The seed is excluded from the published package on purpose — it is a repo\n" +
238
+ " operation, not something a consumer does. Build it first:\n\n" +
239
+ " npm --prefix ./appkit run build\n\n" +
240
+ " Tried:\n" +
241
+ attempts.map((a) => ` ${a}`).join("\n"),
242
+ );
243
+ process.exit(1);
244
+ }
245
+ const appkitSeed = await importSeedModule();
246
+ const appkit = { ...(await import("@mohasinac/appkit")), ...appkitSeed };
247
+ // PII crypto functions moved server-only in appkit 3.8.1 (they touch
248
+ // firebase-admin's crypto chain) — no longer exported from the main
249
+ // client-safe barrel, only from "@mohasinac/appkit/server". The *_PII_FIELDS
250
+ // / *_PII_INDEX_MAP constants are plain data and stay in the main barrel.
251
+ const appkitServer = await import("@mohasinac/appkit/server");
252
+ const { encryptPiiFields, piiIndicesFor, encryptPayoutDetails, encryptPayoutBankAccount, encryptShippingConfig,
253
+ buildTokensIndex, BLIND_INDEX_KEY_VERSION_FIELD, CURRENT_BLIND_INDEX_KEY_VERSION } = appkitServer;
254
+
255
+ const {
256
+ // seed data
257
+ usersSeedData, addressesSeedData, storeAddressesSeedData,
258
+ // store-extensions — see the SEED_DATA_MAP note below (Root Cause #90, 7-16).
259
+ payoutMethodsSeedData, shippingConfigsSeedData, analyticsCardsSeedData, analyticsAlertsSeedData,
260
+ storeCategoriesSeedData, listingTemplatesSeedData, moderationQueueSeedData, reportsSeedData,
261
+ itemRequestsSeedData, storeGoogleConfigSeedData,
262
+ categoriesSeedData, storesSeedData, sessionsSeedData,
263
+ productsStandardSeedData, productsBeybladeXSeedData, productsAuctionsSeedData, productsPreordersSeedData,
264
+ productsPrizeDrawsSeedData, productsClassifiedsSeedData, productsDigitalCodesSeedData, productsLiveItemsSeedData,
265
+ productsArtSeedData, productsStickersSeedData, productsSparesSeedData,
266
+ productsCatalogueSeedData,
267
+ FRANCHISE_BY_CATEGORY_ROOT,
268
+ ordersSeedData, reviewsSeedData, cartsSeedData, bidsSeedData,
269
+ couponsSeedData, couponUsageSeedData,
270
+ eventsSeedData, eventEntriesSeedData, payoutsSeedData, offersSeedData,
271
+ notificationsSeedData, blogPostsSeedData,
272
+ carouselsSeedData, carouselSlidesSeedData, homepageSectionsSeedData,
273
+ siteSettingsSeedData, faqSeedData,
274
+ wishlistsSeedData, historySeedData,
275
+ groupedListingsSeedData,
276
+ mediaAssetsSeedData, mediaAssetsEditorialSeedData,
277
+ scammersSeedData, productFeaturesSeedData, taxCodesSeedData,
278
+ authenticityKnowledgeSeedData,
279
+ partGlossarySeedData, modelCompositionSeedData,
280
+ beysSeedData, beyProductsSeedData,
281
+ BEYS_COLLECTION, BEY_PRODUCTS_COLLECTION,
282
+ supportTicketsSeedData, catalogueSeedData,
283
+ shipmentsSeedData, shipmentLotsSeedData, shipmentItemsSeedData,
284
+ // collection constants
285
+ USER_COLLECTION,
286
+ CATEGORIES_COLLECTION, STORE_COLLECTION,
287
+ PRODUCT_COLLECTION, ORDER_COLLECTION, REVIEW_COLLECTION, BID_COLLECTION,
288
+ COUPONS_COLLECTION, CAROUSELS_COLLECTION, CAROUSEL_SLIDES_COLLECTION,
289
+ HOMEPAGE_SECTIONS_COLLECTION, SITE_SETTINGS_COLLECTION, FAQS_COLLECTION,
290
+ NOTIFICATIONS_COLLECTION, PAYOUT_COLLECTION, OFFER_COLLECTION, BLOG_POSTS_COLLECTION,
291
+ EVENTS_COLLECTION, EVENT_ENTRIES_COLLECTION, SESSION_COLLECTION,
292
+ CART_COLLECTION,
293
+ GROUPED_LISTINGS_COLLECTION, SCAMMER_COLLECTION,
294
+ WISHLIST_COLLECTION, HISTORY_COLLECTION, PRODUCT_FEATURES_COLLECTION,
295
+ TAX_CODES_COLLECTION,
296
+ PART_GLOSSARY_COLLECTION,
297
+ MODEL_COMPOSITION_COLLECTION,
298
+ TESTER_CHECKLIST_ITEM_COLLECTION,
299
+ TESTER_CHECKLIST_RESPONSE_COLLECTION,
300
+ // PII field/index constants (plain data, still in the main barrel — the
301
+ // crypto functions themselves come from appkitServer above)
302
+ USER_PII_FIELDS, USER_PII_INDEX_MAP,
303
+ ADDRESS_PII_FIELDS, ORDER_PII_FIELDS, BID_PII_FIELDS, PAYOUT_PII_FIELDS,
304
+ REVIEW_PII_FIELDS, OFFER_PII_FIELDS, EVENT_ENTRY_PII_FIELDS,
305
+ // misc
306
+ generateFAQId,
307
+ /*
308
+ * Seed provenance. `stampSeedPurpose` is the ONE place a `seedPurpose` (and
309
+ * the `isTestData` / `testDataExpiresAt` derived from it) is produced, shared
310
+ * with `seed/runner.ts` so the two loaders cannot disagree about what a
311
+ * purpose means — Root Cause #75's shape on the seed axis.
312
+ */
313
+ stampSeedPurpose,
314
+ resolveSeedPurpose,
315
+ SEED_COLLECTION_PURPOSE,
316
+ ALL_SEED_PURPOSES,
317
+ } = appkit;
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // Collection registry
321
+ // ---------------------------------------------------------------------------
322
+ const COLLECTION_MAP = {
323
+ users: USER_COLLECTION,
324
+ addresses: "addresses",
325
+ storeAddresses: "storeAddresses",
326
+ couponUsage: "couponUsage",
327
+ categories: CATEGORIES_COLLECTION,
328
+ stores: STORE_COLLECTION,
329
+ products: PRODUCT_COLLECTION,
330
+ orders: ORDER_COLLECTION,
331
+ reviews: REVIEW_COLLECTION,
332
+ bids: BID_COLLECTION,
333
+ coupons: COUPONS_COLLECTION,
334
+ carousels: CAROUSELS_COLLECTION,
335
+ carouselSlides: CAROUSEL_SLIDES_COLLECTION,
336
+ homepageSections: HOMEPAGE_SECTIONS_COLLECTION,
337
+ siteSettings: SITE_SETTINGS_COLLECTION,
338
+ faqs: FAQS_COLLECTION,
339
+ notifications: NOTIFICATIONS_COLLECTION,
340
+ payouts: PAYOUT_COLLECTION,
341
+ offers: OFFER_COLLECTION,
342
+ /*
343
+ * String literals, not constants, because SUPPORT_TICKET_COLLECTION,
344
+ * CATALOGUE_COLLECTION and SHIPMENT_COLLECTION are NOT re-exported from
345
+ * appkit's barrel -- only their features' own schema files declare them.
346
+ * Adding three barrel exports to fix a seeding gap is a public-API change
347
+ * for a private need, so the names are duplicated here with the source
348
+ * named beside each. If one is ever renamed, this is the site that breaks.
349
+ */
350
+ supportTickets: "supportTickets", // features/support/schemas/firestore.ts:279
351
+ catalogueItems: "catalogueItems", // features/catalogue/schemas/firestore.ts:18
352
+ procurementShipments: "procurementShipments", // features/shipments/schemas/firestore.ts:26
353
+ shipmentLots: "shipmentLots",
354
+ shipmentItems: "shipmentItems",
355
+ /*
356
+ * store-extensions. Ten collections with seed fixtures in manifest.ts that
357
+ * were NEVER in this map, so ALL_COLLECTIONS omitted them and they held ZERO
358
+ * documents in every run ever -- Root Cause #90, instances 7-16. Found via
359
+ * /store/analytics/cards rendering "No cards" under copy promising built-ins.
360
+ * String literals for the same reason as the block above: none of these is
361
+ * re-exported from appkit's barrel.
362
+ */
363
+ payoutMethods: "payoutMethods", // features/store-extensions/schemas/firestore.ts:46
364
+ shippingConfigs: "shippingConfigs", // :84
365
+ analyticsCards: "analyticsCards", // :120
366
+ analyticsAlerts: "analyticsAlerts", // :152
367
+ storeCategories: "storeCategories", // :179
368
+ listingTemplates: "listingTemplates", // :240
369
+ moderationQueue: "moderationQueue", // :280
370
+ reports: "reports", // :372
371
+ itemRequests: "itemRequests",
372
+ storeGoogleConfig: "storeGoogleConfig",
373
+ blogPosts: BLOG_POSTS_COLLECTION,
374
+ events: EVENTS_COLLECTION,
375
+ eventEntries: EVENT_ENTRIES_COLLECTION,
376
+ sessions: SESSION_COLLECTION,
377
+ carts: CART_COLLECTION,
378
+ wishlists: WISHLIST_COLLECTION,
379
+ history: HISTORY_COLLECTION,
380
+ groupedListings: GROUPED_LISTINGS_COLLECTION,
381
+ /*
382
+ * `/media/<shortId>` resolves ONLY through a mediaAssets document, so without
383
+ * these rows every seeded cover image 404s while its object sits untouched in
384
+ * the bucket. Seeded rather than left as a side effect of the upload script
385
+ * precisely so a Firestore wipe cannot orphan Storage.
386
+ */
387
+ mediaAssets: "mediaAssets",
388
+ scammerProfiles: SCAMMER_COLLECTION,
389
+ productFeatures: PRODUCT_FEATURES_COLLECTION,
390
+ authenticityKnowledge: "authenticityKnowledge",
391
+ taxCodes: TAX_CODES_COLLECTION,
392
+ // E4/D12 — extracted from the local corpus, generated into the seed.
393
+ partGlossary: PART_GLOSSARY_COLLECTION,
394
+ /*
395
+ * The BEY reference database. Mirrors `partGlossary`: extracted from a
396
+ * corpus by committed scripts, shares its id with the paired category leaf,
397
+ * and keeps measured data OFF `categories` so a reseed cannot drop every
398
+ * bey page's ISR entry.
399
+ */
400
+ beys: BEYS_COLLECTION,
401
+ beyProducts: BEY_PRODUCTS_COLLECTION,
402
+ modelComposition: MODEL_COMPOSITION_COLLECTION,
403
+ testerChecklistItems: TESTER_CHECKLIST_ITEM_COLLECTION,
404
+ /*
405
+ * Tester submissions. DERIVED tier in the tester's own map (wiped at setup so
406
+ * a prior run's verdicts never leak into the next), AND listed in
407
+ * SEED_TRANSACTIONAL so these two demo fixtures are restored afterwards —
408
+ * that list is a RESTORE list, orthogonal to the delete tiers.
409
+ *
410
+ * Without the fixtures, three admin/bug-hunter-rewards cases have nothing to
411
+ * confirm or reopen and can only report blocked. Measured 2026-09-11:
412
+ * 0 pass / 0 fail / 5 blocked, both controls correct.
413
+ */
414
+ testerChecklistResponses: TESTER_CHECKLIST_RESPONSE_COLLECTION,
415
+ };
416
+
417
+ /*
418
+ * B6 — stamp `gstRate` + `hsnCode` onto every seeded product.
419
+ *
420
+ * 🛑 WITHOUT THIS, EVERY SEEDED PRODUCT IS TAXED AT ZERO, and the Tax row can
421
+ * never render no matter what the GSTIN says.
422
+ *
423
+ * The resolution order C0c settled lives in `ProductRepository.deriveTaxonomy`
424
+ * — product rate, else the CATEGORY's via `taxCodeId`, else a warned floor —
425
+ * and it runs on a repository write. **The seed writes through
426
+ * `set(..., { merge: true })` and never touches the repository**, so no seeded
427
+ * row has ever carried a rate: measured 2026-10-12, `gstRate` was absent on
428
+ * all 70 live products. At checkout `sumGroupGst` reads `product.gstRate`
429
+ * directly with a `?? 0`, so the whole catalogue would have computed GST = 0
430
+ * with a valid GSTIN and `gstSettings.enabled: true`.
431
+ *
432
+ * That is the same class as Root Cause #101 (a schema that strips a field plus
433
+ * a read-side backfill equals a field no query can see) — here it is a
434
+ * write-side derivation the seed path does not run.
435
+ *
436
+ * 🛑 This is NOT a second implementation of the resolution. The product seed
437
+ * already hand-writes the FULL `categorySlugs` chain, so there is no walk to
438
+ * re-derive: take the chain, find the first element whose category declares a
439
+ * `taxCodeId`, and read the rate off the `taxCodes` seed. Nearest-first, which
440
+ * is the order `resolveCategoryDefaults` uses, so a more specific category
441
+ * still wins.
442
+ *
443
+ * 🛑 An explicit rate on a product WINS and is never overwritten — including
444
+ * `gstRate: 0`, which is a deliberate exemption and not an absence
445
+ * (`ProductDocument.gstRate`'s own JSDoc: "undefined and 0 MEAN DIFFERENT
446
+ * THINGS"). The test is `== null`, never falsy.
447
+ *
448
+ * 🛑 And it REFUSES rather than defaulting. A product whose chain resolves no
449
+ * tax code throws at load time: C0c defines reaching the floor as a triage
450
+ * signal, so a silent 5% here would hide exactly what it is meant to surface —
451
+ * and a wrong rate on a real invoice is worse than a failed load.
452
+ */
453
+ /**
454
+ * Stamp `franchise` / `franchiseSlug` from the product's category ROOT.
455
+ *
456
+ * 🛑 Its OWN pass, deliberately NOT folded into `withDerivedCategoryFields`.
457
+ * That function early-returns on `gstRate && hsnCode && authenticity`, which
458
+ * every one of the 1,237 generated catalogue rows already satisfies — so a
459
+ * franchise derived in there would be silently skipped for 95% of the
460
+ * catalogue. That is the exact bug its own comment records happening when
461
+ * `authenticity` was added beside `gstRate`; adding a third field to the same
462
+ * condition is how it happens a third time.
463
+ *
464
+ * A root with no mapping yields NO franchise (living collectibles are not an
465
+ * IP) — see `FRANCHISE_BY_CATEGORY_ROOT`.
466
+ */
467
+ /**
468
+ * The real, sellable catalogue — everything else in the product seed is demo
469
+ * furniture and is DROPPED HERE, before anything is written.
470
+ *
471
+ * ── 🛑 WHY A FILTER AND NOT A DELETION ────────────────────────────────────
472
+ *
473
+ * The fixtures stay in their files on purpose: they are the only coverage
474
+ * several features have (every `preOrderProductionStatus` value, a sold-out
475
+ * reservation, a depleted digital-code pool, an ended-unsold auction), and
476
+ * the tester checklist deep-links to them by id. Deleting them would silently
477
+ * remove that coverage. Not LOADING them is the part that was wanted.
478
+ *
479
+ * ── 🛑 WHY THIS HAD TO EXIST ──────────────────────────────────────────────
480
+ *
481
+ * `appkit-seed load` writes every registered row and `purge` deletes
482
+ * `seedPurpose: "test"` afterwards — so the demo listings came BACK on every
483
+ * reseed, and anyone who ran `load` to fix imagery silently republished 76
484
+ * fake products to the live storefront. That happened twice in one session.
485
+ * The owner's instruction was "delete them from the seed itself and make sure
486
+ * they do not return"; this is the point where that can be guaranteed, since
487
+ * every write path funnels through here.
488
+ *
489
+ * ── The discriminator is the STORE, not the listing type ──────────────────
490
+ *
491
+ * `store-beyblade-arena` is the fictional demo seller; `store-letitrip-
492
+ * official` is the real one. Keying on the store rather than on a per-file
493
+ * allow-list means a fixture added to ANY product seed file later is excluded
494
+ * automatically, which is the failure mode this is replacing — a list that
495
+ * has to be remembered is a list that drifts.
496
+ *
497
+ * Set `SEED_INCLUDE_DEMO=1` to load them anyway, for local demo work.
498
+ */
499
+ const REAL_STORE_IDS = new Set(["store-letitrip-official"]);
500
+
501
+ function realListingsOnly(products) {
502
+ if (process.env.SEED_INCLUDE_DEMO === "1") {
503
+ console.log("▌ SEED_INCLUDE_DEMO=1 — demo listings INCLUDED");
504
+ return products;
505
+ }
506
+ /*
507
+ * 🛑 TWO rules, because the store alone is not enough. Six prize-draw
508
+ * fixtures live under the REAL store and are still demo furniture — their
509
+ * imagery is a 460-byte generated colour tile from /images/seed-tiles/,
510
+ * which is the giveaway: a listing nobody photographed is a listing nobody
511
+ * intends to sell. Measured before writing this: every one of the six, and
512
+ * nothing real, matches.
513
+ */
514
+ const isPlaceholder = (p) => {
515
+ const urls = [p?.mainImage, ...(Array.isArray(p?.images) ? p.images : [])]
516
+ .map((v) => (typeof v === "string" ? v : v?.url))
517
+ .filter(Boolean);
518
+ return urls.length > 0 && urls.every((u) => String(u).includes("/images/seed-tiles/"));
519
+ };
520
+ const kept = products.filter((p) => REAL_STORE_IDS.has(p?.storeId) && !isPlaceholder(p));
521
+ const dropped = products.length - kept.length;
522
+ if (dropped > 0) {
523
+ console.log(` • ${dropped} demo listing(s) not loaded (set SEED_INCLUDE_DEMO=1 to include)`);
524
+ }
525
+ return kept;
526
+ }
527
+
528
+ /**
529
+ * Drop bundle categories whose members are not in the loaded catalogue.
530
+ *
531
+ * 🛑 A bundle is ALL-OR-NOTHING — `canAddToCart` buys every member together —
532
+ * so a bundle missing even one member is unsellable, and one missing ALL of
533
+ * them is a card that opens an empty page. Measured right after the demo
534
+ * listings stopped loading: of the five seeded bundles, three had zero
535
+ * surviving members and the other two had 1-of-4 and 2-of-3. All five were
536
+ * still `isActive: true`, and all five rendered on the HOMEPAGE as collage
537
+ * cards built from `display.mosaicImages` — which are baked at seed time from
538
+ * the members' own imagery, so they kept showing pictures of products that no
539
+ * longer exist. That is 92 placeholder-tile references on the homepage.
540
+ *
541
+ * Derived rather than hand-maintained for the same reason as the product
542
+ * filter: a bundle that becomes valid later starts loading again on its own,
543
+ * and one that breaks stops, with nobody having to remember either.
544
+ *
545
+ * Only `categoryType: "bundle"` rows are considered. Every other category is
546
+ * taxonomy and is never touched — rebuilding the tree is expensive and is
547
+ * explicitly not what this is for.
548
+ */
549
+ function loadableBundlesOnly(categories, loadedProducts) {
550
+ const live = new Set(loadedProducts.map((p) => p?.id).filter(Boolean));
551
+ let dropped = 0;
552
+ const kept = categories.filter((c) => {
553
+ if (c?.categoryType !== "bundle") return true;
554
+ const ids = Array.isArray(c.bundleProductIds) ? c.bundleProductIds : [];
555
+ const ok = ids.length > 0 && ids.every((id) => live.has(id));
556
+ if (!ok) dropped++;
557
+ return ok;
558
+ });
559
+ if (dropped > 0) {
560
+ console.log(` • ${dropped} bundle(s) not loaded — members missing from the catalogue`);
561
+ }
562
+ return kept;
563
+ }
564
+
565
+ function withFranchise(products) {
566
+ let stamped = 0;
567
+ const out = products.map((p) => {
568
+ if (p.franchiseSlug) return p;
569
+ const chain = Array.isArray(p.categorySlugs) ? p.categorySlugs : [];
570
+ const root = chain.length > 0 ? String(chain[chain.length - 1]) : null;
571
+ const f = root ? FRANCHISE_BY_CATEGORY_ROOT[root] : null;
572
+ if (!f) return p;
573
+ stamped++;
574
+ return { ...p, franchise: f.name, franchiseSlug: f.slug };
575
+ });
576
+ console.log(` • franchise stamped on ${stamped}/${products.length} products`);
577
+ return out;
578
+ }
579
+
580
+ function withDerivedCategoryFields(products) {
581
+ const catById = new Map((categoriesSeedData || []).map((c) => [String(c.id), c]));
582
+ const taxById = new Map((taxCodesSeedData || []).map((t) => [String(t.id), t]));
583
+ const unresolved = [];
584
+ const out = products.map((p) => {
585
+ // 🛑 Must NOT early-exit on tax alone now that authenticity rides along —
586
+ // a product with a hand-written rate would silently keep no authenticity.
587
+ if (p.gstRate != null && p.hsnCode && p.authenticity) return p;
588
+ const chain = Array.isArray(p.categorySlugs) && p.categorySlugs.length
589
+ ? p.categorySlugs
590
+ : (p.category ? [p.category] : []);
591
+ let code = null;
592
+ for (const id of chain) {
593
+ const c = catById.get(String(id));
594
+ const t = c?.productDefaults?.taxCodeId;
595
+ if (t) { code = t; break; }
596
+ }
597
+ const tax = code ? taxById.get(String(code)) : null;
598
+ if (!tax) { unresolved.push(`${p.id} (leaf ${chain[0] ?? "none"})`); return p; }
599
+
600
+ /*
601
+ * 🛑 AUTHENTICITY, the same gap one field over — and it was making a HERO
602
+ * CLAIM false.
603
+ *
604
+ * `productDefaults.defaultAuthenticity` is declared on 122 categories and
605
+ * `authenticity` was absent on **all 71 live products**, because it derives
606
+ * on a repository write and the seed does not make one. The homepage
607
+ * authenticity slide says "the seller has to pick one before it can go
608
+ * live" — which was untrue in two ways at once: the write schema has it
609
+ * `.optional()`, and not one product carried a value.
610
+ *
611
+ * `unverified` is the floor, never `original`: `ProductAuthenticity`'s own
612
+ * docstring says that without it "an honest seller who does not know would
613
+ * have to pick 'original', and silence would be indistinguishable from a
614
+ * genuine claim." The platform must not make an authenticity claim nobody
615
+ * checked.
616
+ *
617
+ * An explicit product value always wins, so the 6 categories that declare
618
+ * `original` still reach their products.
619
+ */
620
+ let authenticity = null;
621
+ for (const id of chain) {
622
+ const c = catById.get(String(id));
623
+ const a = c?.productDefaults?.defaultAuthenticity;
624
+ if (a) { authenticity = a; break; }
625
+ }
626
+
627
+ return {
628
+ ...p,
629
+ gstRate: p.gstRate != null ? p.gstRate : tax.gstRate,
630
+ // An exempt code has no HSN, and `hsnCode: ""` would read as a declared
631
+ // empty code rather than as an inapplicable one.
632
+ ...(p.hsnCode || !tax.hsnCode ? {} : { hsnCode: tax.hsnCode }),
633
+ ...(p.authenticity || !authenticity ? {} : { authenticity }),
634
+ };
635
+ });
636
+ if (unresolved.length) {
637
+ const detail = unresolved.slice(0, 10).join(", ");
638
+ throw new Error(
639
+ `seed: ${unresolved.length} product(s) resolve NO taxCodeId from their category chain, ` +
640
+ "so they would be taxed at zero. Give the category a taxCodeId " +
641
+ `(the roots carry a floor - see ROOT_TAX_DEFAULTS): ${detail}`,
642
+ );
643
+ }
644
+ return out;
645
+ }
646
+
647
+ /**
648
+ * The real catalogue, computed ONCE so the bundle filter and the product
649
+ * loader cannot disagree about what "loaded" means. Deriving it twice is
650
+ * Root Cause #75's shape — two copies of one rule, one of which learns about
651
+ * a change and the other does not.
652
+ */
653
+ const REAL_PRODUCTS = realListingsOnly([
654
+ ...(productsStandardSeedData || []),
655
+ // Real, wiki-verified Beyblade X listings under store-letitrip-official —
656
+ // added 2026-10-19 for PhonePe merchant registration, which requires at
657
+ // least one genuinely purchasable product. See the file's own header.
658
+ ...(productsBeybladeXSeedData || []),
659
+ ...(productsAuctionsSeedData || []), ...(productsPreordersSeedData || []),
660
+ ...(productsPrizeDrawsSeedData || []), ...(productsClassifiedsSeedData || []), ...(productsDigitalCodesSeedData || []), ...(productsLiveItemsSeedData || []),
661
+ // art + stickers were written, exported from the seed barrel, and then never
662
+ // spread here — so 13 fixtures had NEVER reached the database, /art and
663
+ // /stickers were permanently empty, and listingType=art|stickers returned 0.
664
+ // Root Cause #58's family one layer down: added everywhere except the runner.
665
+ ...(productsArtSeedData || []), ...(productsStickersSeedData || []),
666
+ // 🛑 BOTH sites, every time. This file's own header records 13 art/sticker
667
+ // fixtures that never reached the database because only the import was
668
+ // edited and this spread was not — the loader then wrote every OTHER
669
+ // product and reported success.
670
+ ...(productsSparesSeedData || []),
671
+ /*
672
+ * 🛑 DEREGISTERED 2026-10-19 — productsCatalogueSeedData (1,237 rows, one
673
+ * per taxonomy leaf) is NOT spread here, on purpose.
674
+ *
675
+ * This is a reversal of the comment that used to be here, which said the
676
+ * opposite: that this spread was "what makes `products` reproducible
677
+ * after a full wipe." That was true when the catalogue was the only
678
+ * content under its leaf categories. It stopped being true the moment
679
+ * real curated listings exist for the SAME leaves — four of them,
680
+ * confirmed by grep: `category-aeropegasus-3-70a`, `category-
681
+ * valkyrievolt-s4-70v`, `category-wizardarrow-4-80b`, `category-
682
+ * knightshield-3-80n` each already carry one generated row. Re-enabling
683
+ * this spread would not just add filler back — it would put a
684
+ * hash-priced synthetic duplicate (one measured as a NEGATIVE price,
685
+ * `-61`) into the exact categories the real BX-53 fixture now occupies,
686
+ * silently resurrecting the thing a full `appkit-seed purge` was just run
687
+ * to remove.
688
+ *
689
+ * The generator (`scripts/gen-catalogue-listings.mjs`) and the committed
690
+ * file itself are both left untouched — this is deliberately a one-line
691
+ * "stop loading it" change, not a deletion. Re-enable only after deciding
692
+ * what should happen to a leaf that now has BOTH a real listing and a
693
+ * generated one.
694
+ */
695
+ ]);
696
+
697
+ const SEED_DATA_MAP = {
698
+ users: usersSeedData,
699
+ /*
700
+ * store-extensions. COLLECTION_MAP was only HALF the wiring: load() reads
701
+ * SEED_DATA_MAP and short-circuits on an empty array, so adding the ten
702
+ * collection names alone produced "no seed data, skipping" for all ten.
703
+ * Same shape as the art/stickers note further down -- added everywhere
704
+ * except the map that actually runs.
705
+ */
706
+ payoutMethods: payoutMethodsSeedData,
707
+ shippingConfigs: shippingConfigsSeedData,
708
+ analyticsCards: analyticsCardsSeedData,
709
+ analyticsAlerts: analyticsAlertsSeedData,
710
+ storeCategories: storeCategoriesSeedData,
711
+ listingTemplates: listingTemplatesSeedData,
712
+ moderationQueue: moderationQueueSeedData,
713
+ reports: reportsSeedData,
714
+ itemRequests: itemRequestsSeedData,
715
+ storeGoogleConfig: storeGoogleConfigSeedData,
716
+ addresses: addressesSeedData,
717
+ storeAddresses: storeAddressesSeedData,
718
+ // SB-UNI-C: brands were merged into categories (categoryType:"brand") — no
719
+ // standalone brands collection/seed exists anymore.
720
+ categories: loadableBundlesOnly(categoriesSeedData, REAL_PRODUCTS),
721
+ stores: storesSeedData,
722
+ products: withFranchise(withDerivedCategoryFields(REAL_PRODUCTS)),
723
+ orders: ordersSeedData,
724
+ reviews: reviewsSeedData,
725
+ bids: bidsSeedData,
726
+ coupons: couponsSeedData,
727
+ couponUsage: couponUsageSeedData,
728
+ carousels: carouselsSeedData,
729
+ carouselSlides: carouselSlidesSeedData,
730
+ homepageSections: homepageSectionsSeedData,
731
+ siteSettings: [siteSettingsSeedData],
732
+ faqs: faqSeedData,
733
+ notifications: notificationsSeedData,
734
+ payouts: payoutsSeedData,
735
+ // The LOADER was missing for a long time, so `offers` never appeared in
736
+ // `appkit-seed status` and no run had an offer in Firestore. OFFER_PII_FIELDS
737
+ // was already imported and mapped below -- the PII half was wired and the
738
+ // loading half never was, which is why the gap looked like support rather
739
+ // than an omission.
740
+ offers: offersSeedData,
741
+ // All four were unregistered like offers, all measured at 0 documents live.
742
+ // They are UNLISTED in the tester's tier map, which means PRESERVE -- so
743
+ // seeding them once persists across every future run rather than being
744
+ // wiped by the next tester:setup.
745
+ supportTickets: supportTicketsSeedData,
746
+ catalogueItems: catalogueSeedData,
747
+ procurementShipments: shipmentsSeedData,
748
+ shipmentLots: shipmentLotsSeedData,
749
+ shipmentItems: shipmentItemsSeedData,
750
+ blogPosts: blogPostsSeedData,
751
+ events: eventsSeedData,
752
+ eventEntries: eventEntriesSeedData,
753
+ sessions: sessionsSeedData,
754
+ carts: cartsSeedData,
755
+ wishlists: wishlistsSeedData,
756
+ history: historySeedData,
757
+ groupedListings: groupedListingsSeedData,
758
+ // The other half of the wiring — load() short-circuits on an empty array, so
759
+ // the COLLECTION_MAP entry above does nothing on its own (Root Cause #90).
760
+ // Two source files, one collection: the taxonomy rows are GENERATED and
761
+ // rewritten wholesale by upload-taxonomy-images.mjs, so the editorial rows
762
+ // are kept beside them rather than inside that file.
763
+ mediaAssets: [...mediaAssetsSeedData, ...mediaAssetsEditorialSeedData],
764
+ scammerProfiles: scammersSeedData,
765
+ productFeatures: productFeaturesSeedData,
766
+ authenticityKnowledge: authenticityKnowledgeSeedData,
767
+ taxCodes: taxCodesSeedData,
768
+ partGlossary: partGlossarySeedData,
769
+ // 🛑 Both halves are mandatory: load() short-circuits on an empty array, so a
770
+ // COLLECTION_MAP entry alone logs "no seed data, skipping" and exits 0.
771
+ beys: beysSeedData,
772
+ beyProducts: beyProductsSeedData,
773
+ modelComposition: modelCompositionSeedData,
774
+ /*
775
+ * 🛑 ABSENT ON PURPOSE — the checklist corpus moved to `tester/cases/`.
776
+ *
777
+ * `node tester/scripts/seed-cases.mjs` writes `testerChecklistItems` now.
778
+ * The cases are authoring data for that harness, and nothing in the app read
779
+ * them: the admin surfaces and the API route all read the COLLECTION through
780
+ * repositories that stay in appkit.
781
+ *
782
+ * Leaving the keys out of SEED_DATA_MAP is what makes `load` skip them —
783
+ * `status` will report them as `0 seed`, which is correct and not the
784
+ * "no seed data, skipping" trap of Root Cause #90, because the data is not
785
+ * missing, it is owned elsewhere.
786
+ */
787
+ };
788
+
789
+ const ALL_COLLECTIONS = Object.keys(COLLECTION_MAP);
790
+ const REPLACE_ON_LOAD = new Set(["homepageSections", "carouselSlides"]);
791
+
792
+ const collectionsToProcess = ONLY_SET
793
+ ? ALL_COLLECTIONS.filter((c) => ONLY_SET.has(c))
794
+ : ALL_COLLECTIONS;
795
+
796
+ if (ONLY_SET && collectionsToProcess.length === 0) {
797
+ console.error(`✗ No matching collections. Valid: ${ALL_COLLECTIONS.join(", ")}`);
798
+ process.exit(2);
799
+ }
800
+
801
+ // ---------------------------------------------------------------------------
802
+ // Generic helpers
803
+ // ---------------------------------------------------------------------------
804
+ function stripUndefined(obj) {
805
+ if (Array.isArray(obj)) return obj.map(stripUndefined);
806
+ if (obj instanceof Date) return obj;
807
+ if (obj !== null && typeof obj === "object") {
808
+ return Object.fromEntries(
809
+ Object.entries(obj).filter(([, v]) => v !== undefined).map(([k, v]) => [k, stripUndefined(v)]),
810
+ );
811
+ }
812
+ return obj;
813
+ }
814
+
815
+ async function purgeCollection(path) {
816
+ const BATCH = 500;
817
+ let snap = await db.collection(path).limit(BATCH).get();
818
+ let removed = 0;
819
+ while (!snap.empty) {
820
+ const batch = db.batch();
821
+ snap.docs.forEach((d) => batch.delete(d.ref));
822
+ await batch.commit();
823
+ removed += snap.docs.length;
824
+ if (snap.docs.length < BATCH) break;
825
+ snap = await db.collection(path).limit(BATCH).get();
826
+ }
827
+ return removed;
828
+ }
829
+
830
+ /*
831
+ * 🛑 `TOKENS_INDEX_MAP` holds ONLY `payouts`, and that is a measured fact
832
+ * about this file, not an oversight.
833
+ *
834
+ * `encryptSeedPii` is called from exactly one place — `loadGeneric`'s
835
+ * per-doc loop. `users` and `addresses` are seeded by their OWN dedicated
836
+ * loaders (`loadUsers`, the addresses block near line 1029) that write
837
+ * straight to Firestore and never call this function — so a `users`/
838
+ * `addresses` entry here would be dead code, silently unreachable, which is
839
+ * worse than no entry: it would read as "handled" to the next person who
840
+ * greps for it.
841
+ *
842
+ * It is also the right scope rather than a gap, because `users` and
843
+ * `addresses` are PRESERVE-tier in the tester harness — never wiped, never
844
+ * reseeded — so the only two things that can write their `*TokensIndex`
845
+ * are a REAL write (covered by `UserRepository`/`AddressesRepository`'s
846
+ * `buildTokensIndexFor`, wired separately) and the one-time bootstrap seed
847
+ * load `appkit-seed load --collections users` documented in CLAUDE.md's
848
+ * § "Claude Tester" — which `backfill-blind-index.mjs` exists to catch up
849
+ * after. `payouts` has no such safety net: it is SEED_TRANSACTIONAL, so it
850
+ * cascades and RESEEDS on every tester run that touches its store, through
851
+ * exactly this function — which is why it is the one entry that matters.
852
+ */
853
+ const TOKENS_INDEX_MAP = {
854
+ payouts: { field: "sellerTokensIndex", from: ["sellerName", "sellerEmail"] },
855
+ };
856
+
857
+ function encryptSeedPii(collection, data) {
858
+ const PII_MAP = {
859
+ orders: ORDER_PII_FIELDS,
860
+ bids: BID_PII_FIELDS,
861
+ payouts: PAYOUT_PII_FIELDS,
862
+ reviews: REVIEW_PII_FIELDS,
863
+ products: ["sellerName", "sellerEmail"],
864
+ offers: OFFER_PII_FIELDS,
865
+ eventEntries: EVENT_ENTRY_PII_FIELDS,
866
+ };
867
+ const fields = PII_MAP[collection];
868
+ let result = fields ? encryptPiiFields(data, [...fields]) : { ...data };
869
+ if (collection === "payouts" && result.bankAccount) {
870
+ result.bankAccount = encryptPayoutBankAccount(result.bankAccount);
871
+ }
872
+
873
+ /*
874
+ * 🛑 THIS IS WHAT MAKES `payouts` SURVIVE A TESTER WIPE WITH A WORKING
875
+ * SEARCH INDEX. `payouts` is SEED_TRANSACTIONAL in the tester harness —
876
+ * it cascades on `storeId` whenever its store is wiped, then gets
877
+ * RE-SEEDED by exactly this function. Without this block, the reseeded
878
+ * documents would carry ciphertext and no `sellerTokensIndex` at all,
879
+ * silently undoing a one-shot backfill on the very next tester run.
880
+ */
881
+ const tokensIndex = TOKENS_INDEX_MAP[collection];
882
+ if (tokensIndex) {
883
+ const values = tokensIndex.from.map((f) => data[f]).filter((v) => typeof v === "string");
884
+ if (values.length > 0) {
885
+ result[tokensIndex.field] = buildTokensIndex(...values);
886
+ result[BLIND_INDEX_KEY_VERSION_FIELD] = CURRENT_BLIND_INDEX_KEY_VERSION;
887
+ }
888
+ }
889
+
890
+ return result;
891
+ }
892
+
893
+ async function countExisting(colName) {
894
+ const seed = SEED_DATA_MAP[colName] || [];
895
+ if (seed.length === 0) return 0;
896
+
897
+ if (colName === "addresses") {
898
+ // SB-UNI-A: top-level `addresses` collection, not users/{uid}/addresses.
899
+ const refs = seed.filter((d) => d.id).map((d) => db.collection("addresses").doc(d.id));
900
+ if (refs.length === 0) return 0;
901
+ const snaps = await db.getAll(...refs);
902
+ return snaps.filter((s) => s.exists).length;
903
+ }
904
+ if (colName === "storeAddresses") {
905
+ // Also lands in the top-level `addresses` collection (see loadStoreAddresses).
906
+ const refs = seed.filter((d) => d.id).map((d) => db.collection("addresses").doc(d.id));
907
+ if (refs.length === 0) return 0;
908
+ const snaps = await db.getAll(...refs);
909
+ return snaps.filter((s) => s.exists).length;
910
+ }
911
+ if (colName === "couponUsage") {
912
+ const refs = seed.filter((d) => d.userId && d.couponId).map((d) =>
913
+ db.collection(USER_COLLECTION).doc(d.userId).collection("couponUsage").doc(d.couponId));
914
+ if (refs.length === 0) return 0;
915
+ const snaps = await db.getAll(...refs);
916
+ return snaps.filter((s) => s.exists).length;
917
+ }
918
+ if (colName === "wishlists" || colName === "history") {
919
+ const refs = seed.filter((d) => d.id).map((d) => db.collection(COLLECTION_MAP[colName]).doc(d.id));
920
+ if (refs.length === 0) return 0;
921
+ const snaps = await db.getAll(...refs);
922
+ return snaps.filter((s) => s.exists).length;
923
+ }
924
+ if (colName === "siteSettings") {
925
+ const snap = await db.collection(COLLECTION_MAP[colName]).doc("global").get();
926
+ return snap.exists ? 1 : 0;
927
+ }
928
+ if (colName === "users") {
929
+ const refs = seed.filter((d) => d.uid).map((d) => db.collection(COLLECTION_MAP[colName]).doc(d.uid));
930
+ if (refs.length === 0) return 0;
931
+ const snaps = await db.getAll(...refs);
932
+ return snaps.filter((s) => s.exists).length;
933
+ }
934
+ if (colName === "faqs") {
935
+ // Mirrors loadGeneric: prefer the explicit id (every current faq record has
936
+ // one), only derive via generateFAQId for a record that genuinely lacks one.
937
+ const refs = seed.map((faq) => {
938
+ const id = faq.id ?? generateFAQId({ category: faq.category, question: faq.question });
939
+ return db.collection(COLLECTION_MAP[colName]).doc(id);
940
+ });
941
+ if (refs.length === 0) return 0;
942
+ const snaps = await db.getAll(...refs);
943
+ return snaps.filter((s) => s.exists).length;
944
+ }
945
+ // Mirrors loadGeneric's docId = id ?? slug resolution.
946
+ const refs = seed.filter((d) => d.id ?? d.slug).map((d) => db.collection(COLLECTION_MAP[colName]).doc(d.id ?? d.slug));
947
+ if (refs.length === 0) return 0;
948
+ const snaps = await db.getAll(...refs);
949
+ return snaps.filter((s) => s.exists).length;
950
+ }
951
+
952
+ // ---------------------------------------------------------------------------
953
+ // Status
954
+ // ---------------------------------------------------------------------------
955
+ async function cmdStatus() {
956
+ console.log(`\n${"collection".padEnd(22)} ${"seed".padStart(6)} ${"in db".padStart(6)}`);
957
+ console.log("─".repeat(40));
958
+ for (const c of collectionsToProcess) {
959
+ const seedCount = (SEED_DATA_MAP[c] || []).length;
960
+ const existing = await countExisting(c).catch((e) => { console.error(` err: ${c}: ${e.message}`); return 0; });
961
+ const marker = existing === seedCount ? "✓" : existing === 0 ? " " : "~";
962
+ console.log(`${marker} ${c.padEnd(20)} ${String(seedCount).padStart(6)} ${String(existing).padStart(6)}`);
963
+ }
964
+ console.log();
965
+ }
966
+
967
+ // ---------------------------------------------------------------------------
968
+ // Confirm prompt
969
+ // ---------------------------------------------------------------------------
970
+ async function confirm(msg) {
971
+ if (SKIP_CONFIRM) return true;
972
+ return new Promise((resolveP) => {
973
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
974
+ rl.question(`${msg} [y/N] `, (a) => { rl.close(); resolveP(/^y(es)?$/i.test(a.trim())); });
975
+ });
976
+ }
977
+
978
+ // ---------------------------------------------------------------------------
979
+ // Per-collection writers (mirror of /api/demo/seed POST)
980
+ // ---------------------------------------------------------------------------
981
+ async function resolveAuthConflicts(uid, authUserData) {
982
+ if (authUserData.email) {
983
+ try {
984
+ const conflict = await auth.getUserByEmail(authUserData.email);
985
+ if (conflict.uid !== uid) await auth.deleteUser(conflict.uid);
986
+ } catch (e) { if (e?.code !== "auth/user-not-found") throw e; }
987
+ }
988
+ if (authUserData.phoneNumber) {
989
+ try {
990
+ const conflict = await auth.getUserByPhoneNumber(authUserData.phoneNumber);
991
+ if (conflict.uid !== uid) await auth.deleteUser(conflict.uid);
992
+ } catch (e) { if (e?.code !== "auth/user-not-found") throw e; }
993
+ }
994
+ }
995
+
996
+ async function loadUsers(seed, stats) {
997
+ for (const u of seed) {
998
+ try {
999
+ const { uid, email, phoneNumber, displayName, photoURL, emailVerified, disabled } = u;
1000
+ const docRef = db.collection(USER_COLLECTION).doc(uid);
1001
+ let userExists = false;
1002
+ try { await auth.getUser(uid); userExists = true; }
1003
+ catch (e) { if (e.code !== "auth/user-not-found") throw e; }
1004
+
1005
+ const authUserData = { displayName, emailVerified, disabled };
1006
+ if (email && typeof email === "string") authUserData.email = email;
1007
+ if (phoneNumber && typeof phoneNumber === "string") authUserData.phoneNumber = phoneNumber;
1008
+ // Firebase Auth's photoURL field requires an absolute URL. Seed data's
1009
+ // photoURL is wrapped by seedExtMedia() into a relative /media/ext?url=...
1010
+ // app-proxy path — valid for Firestore + the app's own avatar rendering,
1011
+ // but auth.createUser()/updateUser() reject it with "must be a valid URL"
1012
+ // and abort the WHOLE user record (Auth + Firestore both skipped) before
1013
+ // ever reaching the Firestore write below. The app never reads Auth's own
1014
+ // photoURL copy anyway — it always resolves avatars via the Firestore
1015
+ // users doc — so just skip setting it on the Auth record for relative
1016
+ // paths instead of failing the entire seed row.
1017
+ if (photoURL && typeof photoURL === "string" && /^https?:\/\//.test(photoURL)) {
1018
+ authUserData.photoURL = photoURL;
1019
+ }
1020
+
1021
+ if (!userExists) {
1022
+ await resolveAuthConflicts(uid, authUserData);
1023
+ await auth.createUser({ uid, ...authUserData, password: "TempPass123!" });
1024
+ }
1025
+ if (u.role && u.role !== "user") {
1026
+ await auth.setCustomUserClaims(uid, { role: u.role });
1027
+ }
1028
+
1029
+ // Both arguments read the PLAINTEXT `plain`: indices must be hashed from
1030
+ // the source text, and encryption must not see an already-hashed value.
1031
+ // The previous form relied on ordering alone (indices first, THEN
1032
+ // encrypt) — correct, but one line-swap away from spreading plaintext
1033
+ // back over ciphertext, which is why `addPiiIndices` no longer exists.
1034
+ /*
1035
+ * 🛑 Provenance is stamped here too, and `users` is the collection the
1036
+ * whole scheme exists for.
1037
+ *
1038
+ * It had no permanence marker of any kind, so a seeded persona could not
1039
+ * be told apart from a real signup — and a seeded user is the most
1040
+ * expensive thing to delete wrongly: it owns an **Auth record**, a
1041
+ * `userSlugs` index entry, addresses, a cart, orders, bids and reviews,
1042
+ * and `users` sits in the tester harness's PRESERVE tier precisely
1043
+ * because a wrong sweep there is the only permanent damage a run can do.
1044
+ *
1045
+ * Stamped before the PII pass so `plain` carries it: the indices are
1046
+ * hashed from plaintext and the encryption must not see a hashed value,
1047
+ * so nothing may be added to `docData` after that pair runs.
1048
+ */
1049
+ const plain = stripUndefined(stampSeedPurpose("users", { ...u }));
1050
+ let docData = {
1051
+ ...encryptPiiFields(plain, [...USER_PII_FIELDS]),
1052
+ ...piiIndicesFor(plain, USER_PII_INDEX_MAP),
1053
+ };
1054
+ if (docData.payoutDetails) docData.payoutDetails = encryptPayoutDetails(docData.payoutDetails);
1055
+ if (docData.shippingConfig) docData.shippingConfig = encryptShippingConfig(docData.shippingConfig);
1056
+ await docRef.set(docData, { merge: true });
1057
+ stats.created++;
1058
+ if (VERBOSE) console.log(` + ${uid}`);
1059
+ } catch (err) {
1060
+ console.error(` ✗ user ${u.uid}: ${err.message}`);
1061
+ stats.errors++;
1062
+ }
1063
+ }
1064
+ }
1065
+
1066
+ async function loadSubcollection(seed, parentColl, parentKey, subColl, idKey, piiFields, stats) {
1067
+ for (const d of seed) {
1068
+ try {
1069
+ const parent = d[parentKey];
1070
+ const id = d[idKey];
1071
+ if (!parent || !id) { stats.errors++; continue; }
1072
+ const { [parentKey]: _p, [idKey]: _i, ...data } = d;
1073
+ const docRef = db.collection(parentColl).doc(parent).collection(subColl).doc(id);
1074
+ /*
1075
+ * Subcollection rows inherit the SUBCOLLECTION's purpose, not the
1076
+ * parent's — `subColl` is the key in `SEED_COLLECTION_PURPOSE`. A
1077
+ * `couponUsage` row under a permanent user is still its own record, and
1078
+ * conflating the two would make a parent's classification silently
1079
+ * govern children it knows nothing about.
1080
+ */
1081
+ const stamped = stripUndefined(stampSeedPurpose(subColl, data));
1082
+ const payload = piiFields ? encryptPiiFields(stamped, [...piiFields]) : stamped;
1083
+ await docRef.set(payload, { merge: true });
1084
+ stats.created++;
1085
+ } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
1086
+ }
1087
+ }
1088
+
1089
+ async function loadOneDocPerUser(seed, coll, stats) {
1090
+ for (const d of seed) {
1091
+ try {
1092
+ const { id, userId, items, updatedAt } = d;
1093
+ if (!id || !userId) { stats.errors++; continue; }
1094
+ /*
1095
+ * 🛑 `merge: false` — this loader REPLACES the document, which is correct
1096
+ * for a wishlist or a history list (the array IS the record), and is
1097
+ * exactly why the stamp has to be inside the payload rather than added
1098
+ * by a later merge-write that would be wiped on the next load.
1099
+ *
1100
+ * `coll` is the real Firestore collection name here rather than the seed
1101
+ * key, and for `wishlists`/`history` the two are identical — the only two
1102
+ * callers. `resolveSeedPurpose` falls back to `permanent` if that ever
1103
+ * stops being true, which is the safe direction.
1104
+ */
1105
+ await db.collection(coll).doc(id).set(
1106
+ stripUndefined(
1107
+ stampSeedPurpose(coll, {
1108
+ userId,
1109
+ items: items ?? [],
1110
+ updatedAt: updatedAt ?? new Date(),
1111
+ }),
1112
+ ),
1113
+ { merge: false },
1114
+ );
1115
+ stats.created++;
1116
+ } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
1117
+ }
1118
+ }
1119
+
1120
+ /**
1121
+ * Collections whose rows a human may hand-correct, with the correction
1122
+ * protected from the next reseed.
1123
+ *
1124
+ * 🛑 AN EXPLICIT LIST, not "every collection". Protection costs one query per
1125
+ * load, and more importantly it is only meaningful for GENERATED data: a row
1126
+ * an admin authored from scratch (a coupon, an event) is not competing with a
1127
+ * generator for ownership of its fields, so there is nothing to protect it
1128
+ * from. Adding a collection here is a statement that a generator owns the row
1129
+ * and a human may override named fields of it.
1130
+ */
1131
+ /**
1132
+ * Drop the keys a human owns from one payload.
1133
+ *
1134
+ * 🛑 A LOCAL copy of `stripAdminOwnedFields` from
1135
+ * `appkit/src/features/parts/schemas/admin-edit.ts`, and the duplication is
1136
+ * deliberate: this CLI must run without a built appkit. Importing it from
1137
+ * `dist` would reintroduce exactly the build dependency the catalogue
1138
+ * generator just shed, and would make a reseed impossible under a memory cap.
1139
+ * The function is five lines; the dependency would not be.
1140
+ */
1141
+ function stripAdminOwned(payload, ownedFields) {
1142
+ if (!ownedFields?.length) return payload;
1143
+ const out = {};
1144
+ for (const [k, v] of Object.entries(payload)) {
1145
+ if (ownedFields.includes(k)) continue;
1146
+ out[k] = v;
1147
+ }
1148
+ return out;
1149
+ }
1150
+
1151
+ const ADMIN_EDIT_PROTECTED = new Set(["partGlossary", "beys"]);
1152
+
1153
+ /**
1154
+ * Fields a seed may CREATE but must never OVERWRITE — derived counters.
1155
+ *
1156
+ * 🛑 This exists because seeding silently zeroed the whole taxonomy. Every row
1157
+ * in `categories-seed-data.ts` hard-codes
1158
+ * `metrics: { productCount: 0, totalProductCount: 0, … }`, and `seed-cli`
1159
+ * merge-writes — a merge overwrites any key PRESENT in the payload. So
1160
+ * `appkit-seed load --collections categories` set every counter on every
1161
+ * category to zero.
1162
+ *
1163
+ * Measured 2026-10-21: after a demo purge, all 17 categories that should have
1164
+ * carried stock read `total=0` while their products were filed perfectly --
1165
+ * `category-beyblade-x` stored 0 against an actual 14. Every parent looked
1166
+ * empty. Reseeding to add four bundles then zeroed them a SECOND time,
1167
+ * minutes after they had been repaired.
1168
+ *
1169
+ * Nothing errors, because a wrong number is just a number. The symptom reaches
1170
+ * only a human looking at a page that says a category has nothing in it.
1171
+ *
1172
+ * 🛑 CREATE but not OVERWRITE, not "always strip" — `CategoryDocument.metrics`
1173
+ * is a REQUIRED field with three unguarded readers (`CategoryTableColumns`,
1174
+ * `CategoryTree`, `isFeaturedEligible`). Stripping it unconditionally would
1175
+ * leave a freshly seeded document violating its own type until the reconciler
1176
+ * first ran. Establishing zeros on create is correct; overwriting live counts
1177
+ * is not.
1178
+ *
1179
+ * The authority for these fields is `runCountersReconcile` plus the live
1180
+ * write triggers — see the `countersReconcileNow` Function for the on-demand
1181
+ * run that belongs immediately after a seed.
1182
+ */
1183
+ const DERIVED_COUNTER_FIELDS = {
1184
+ categories: ["metrics"],
1185
+ stores: ["stats"],
1186
+ };
1187
+
1188
+ /**
1189
+ * The ids a collection already holds, when it has derived counters to protect.
1190
+ *
1191
+ * 🛑 A `.select()` projection — ids only, no document bodies — so this costs
1192
+ * reads but transfers almost nothing. It runs ONLY for the two collections in
1193
+ * `DERIVED_COUNTER_FIELDS`, and only when that collection is actually being
1194
+ * loaded; every other collection pays nothing.
1195
+ *
1196
+ * Fails OPEN to an empty set: if the existence probe itself fails we would
1197
+ * rather write the seed's zeros (recoverable by one reconcile) than abort a
1198
+ * load. The reconciler is the backstop either way.
1199
+ */
1200
+ async function fetchExistingIds(db, collection) {
1201
+ const fields = DERIVED_COUNTER_FIELDS[collection];
1202
+ if (!fields) return null;
1203
+ try {
1204
+ const snap = await db.collection(collection).select().get();
1205
+ return new Set(snap.docs.map((d) => d.id));
1206
+ } catch (err) {
1207
+ console.log(
1208
+ ` ⚠ could not probe existing ${collection} ids (${err?.message ?? err}) — derived counters may be reset; run countersReconcileNow after this load`,
1209
+ );
1210
+ return null;
1211
+ }
1212
+ }
1213
+
1214
+ /** Drop derived-counter keys when the document already exists. */
1215
+ function stripDerivedCounters(collection, payload, docId, existingIds) {
1216
+ const fields = DERIVED_COUNTER_FIELDS[collection];
1217
+ if (!fields || !existingIds || !existingIds.has(docId)) return payload;
1218
+ const out = {};
1219
+ for (const [k, v] of Object.entries(payload)) {
1220
+ if (fields.includes(k)) continue;
1221
+ out[k] = v;
1222
+ }
1223
+ return out;
1224
+ }
1225
+
1226
+ /**
1227
+ * The fields a human owns, per document id, for one collection.
1228
+ *
1229
+ * 🛑 ONE INDEXED EQUALITY, not a read per document. The alternative is
1230
+ * fetching all 2,488 parts and 1,988 beys to discover the handful that are
1231
+ * protected — 4,476 reads against a 50,000/day free tier, every load, to find
1232
+ * what is usually a dozen rows.
1233
+ *
1234
+ * 🛑 It must be an EQUALITY on a boolean, not an `orderBy` or `!=` on
1235
+ * `adminEditedFields`. Both of those silently exclude every document that
1236
+ * LACKS the field, which is nearly all of them — the trap that made a store's
1237
+ * whole review set return zero (Root Cause #100) and the one `isTestData`
1238
+ * warning in the Known TS Patterns table.
1239
+ *
1240
+ * Fails OPEN to an empty map: if this query errors the load proceeds
1241
+ * unprotected, which loses a hand-edit. The alternative — aborting the load —
1242
+ * loses the entire reseed. A warning names the collection either way.
1243
+ */
1244
+ async function fetchAdminOwnedFields(colName, firestoreCollection) {
1245
+ if (!ADMIN_EDIT_PROTECTED.has(colName)) return new Map();
1246
+ try {
1247
+ const snap = await db
1248
+ .collection(firestoreCollection)
1249
+ .where("adminEdited", "==", true)
1250
+ .select("adminEditedFields")
1251
+ .get();
1252
+ const map = new Map();
1253
+ for (const d of snap.docs) {
1254
+ const fields = d.get("adminEditedFields");
1255
+ if (Array.isArray(fields) && fields.length > 0) map.set(d.id, fields);
1256
+ }
1257
+ if (map.size > 0) {
1258
+ console.log(
1259
+ ` ${map.size} row(s) carry admin-owned fields — those keys will not be overwritten`,
1260
+ );
1261
+ }
1262
+ return map;
1263
+ } catch (err) {
1264
+ console.warn(
1265
+ ` ⚠ could not read admin-owned fields for ${colName} (${err?.message ?? err}).
1266
+ ` +
1267
+ ` Proceeding WITHOUT protection — a hand-correction in this collection may be lost.`,
1268
+ );
1269
+ return new Map();
1270
+ }
1271
+ }
1272
+
1273
+ async function loadGeneric(colName, seed, firestoreCollection, stats) {
1274
+ const adminOwned = await fetchAdminOwnedFields(colName, firestoreCollection);
1275
+ if (REPLACE_ON_LOAD.has(colName)) {
1276
+ const removed = await purgeCollection(firestoreCollection);
1277
+ if (VERBOSE) console.log(` purged ${removed} pre-existing docs`);
1278
+ }
1279
+ /*
1280
+ * 🛑 Probed AFTER any replace-purge, never before. On a `REPLACE_ON_LOAD`
1281
+ * collection the purge removes every document, so an id set taken earlier
1282
+ * would claim rows exist that no longer do — and their derived counters
1283
+ * would then be stripped from a payload that is now a CREATE, leaving the
1284
+ * field absent on a required type. Probing here means a purged collection
1285
+ * correctly reports nothing existing.
1286
+ */
1287
+ const existingIds = await fetchExistingIds(db, colName);
1288
+ if (existingIds && existingIds.size > 0) {
1289
+ console.log(
1290
+ ` • ${colName}: preserving live ${DERIVED_COUNTER_FIELDS[colName].join("/")} on ${existingIds.size} existing doc(s)`,
1291
+ );
1292
+ }
1293
+ const items = [];
1294
+ for (const docData of seed) {
1295
+ let { id, ...data } = docData;
1296
+ if (!id && colName === "faqs") {
1297
+ id = generateFAQId({ category: docData.category, question: docData.question });
1298
+ if (!id) { stats.errors++; continue; }
1299
+ } else if (!id) { stats.errors++; continue; }
1300
+ // `id` already carries the project's mandatory slug prefix (blog-, event-,
1301
+ // faq-, etc. — see CLAUDE.md's Slug Prefix System). Preferring the bare
1302
+ // `slug` field here (as this used to) wrote every blogPosts/events doc
1303
+ // under its unprefixed slug instead, e.g. "auction-feature-launch"
1304
+ // instead of "blog-auction-feature-launch" — found 2026-08-17 auditing
1305
+ // real Firestore doc IDs after a full reseed.
1306
+ const docId = id ?? docData.slug;
1307
+ if (!data.slug) data.slug = docData.slug ?? docId;
1308
+ items.push({
1309
+ ref: db.collection(firestoreCollection).doc(docId),
1310
+ /*
1311
+ * 🛑 Stamp provenance BEFORE `stripUndefined`, not after.
1312
+ *
1313
+ * `seedPurposeFields` deliberately OMITS `isTestData` for a `permanent`
1314
+ * row rather than writing `false` — so stamping afterwards would be
1315
+ * indistinguishable from not stamping, and stripping afterwards is what
1316
+ * keeps the absence genuine instead of writing `undefined` keys.
1317
+ *
1318
+ * One call, one choke point: every one of the ~2,500 documents this
1319
+ * loader writes gets a `seedPurpose`, so the purge is a query rather than
1320
+ * a heuristic, and `users`/`orders` are reachable by it for the first
1321
+ * time.
1322
+ */
1323
+ /*
1324
+ * 🛑 `docId` is passed EXPLICITLY because this loader destructures `id`
1325
+ * out (`let { id, ...data } = docData`), so the row it hands over has no
1326
+ * id for the per-row overrides to match on. Measured without it:
1327
+ * `store-letitrip-official` loaded as `test` — the platform's own store,
1328
+ * which is one of exactly two rows the keep-list exists to protect.
1329
+ */
1330
+ /*
1331
+ * 🛑 Admin-owned keys dropped LAST, after every other transform.
1332
+ *
1333
+ * Stripping earlier would let `stampSeedPurpose` or `encryptSeedPii`
1334
+ * re-add a key a human owns — both of them write fields — and the whole
1335
+ * point is that what reaches Firestore carries none of them. A
1336
+ * merge-write leaves an absent key exactly as it was, which is how the
1337
+ * correction survives.
1338
+ */
1339
+ /*
1340
+ * 🛑 Derived counters dropped on an EXISTING document, after everything
1341
+ * else, for the same reason admin-owned keys are: a merge-write leaves
1342
+ * an absent key exactly as it was, which is what preserves the live
1343
+ * value. Stripping earlier would let a later transform re-add it.
1344
+ */
1345
+ data: stripDerivedCounters(
1346
+ colName,
1347
+ stripAdminOwned(
1348
+ encryptSeedPii(colName, stripUndefined(stampSeedPurpose(colName, data, docId))),
1349
+ adminOwned.get(docId),
1350
+ ),
1351
+ docId,
1352
+ existingIds,
1353
+ ),
1354
+ });
1355
+ }
1356
+ /*
1357
+ * 🛑 Chunk by INDEX ENTRIES, not just by operation count.
1358
+ *
1359
+ * This loop capped at 500 operations and nothing else. That was invisible for
1360
+ * years because every seeded collection had small documents; B5's 416
1361
+ * categories are the first that do not. Measured 2026-10-11: one 416-op
1362
+ * commit failed with `INVALID_ARGUMENT: Transaction too big` and wrote
1363
+ * **0 of 416** — not a partial load, nothing at all.
1364
+ *
1365
+ * 🛑 The failure mode is what makes this worth a real fix rather than a
1366
+ * retry: `✓ Load complete — created 2097, errors 1` reads as a near-complete
1367
+ * run, and the ONE missing collection was the taxonomy that every product's
1368
+ * `categorySlugs` chain points into. A purge-then-load left 70 products
1369
+ * referencing ids that did not exist, with a cheerful summary line.
1370
+ *
1371
+ * ## What actually binds, measured by bisection rather than assumed
1372
+ *
1373
+ * My first fix capped PAYLOAD BYTES, on the theory that these documents are
1374
+ * large. They are not: 416 categories are **1.51 MiB total**, mean 3.7 KiB,
1375
+ * largest 7.9 KiB — nowhere near the 10 MiB request limit, and the byte cap
1376
+ * never once triggered. The commit failed anyway.
1377
+ *
1378
+ * Bisecting the op count found the real boundary, and counting array elements
1379
+ * explains it exactly:
1380
+ *
1381
+ * 416 ops -> 60,787 array elements -> FAILS
1382
+ * 250 ops -> 42,912 -> FAILS
1383
+ * 100 ops -> 17,578 -> works
1384
+ * 50 ops -> 8,791 -> works
1385
+ *
1386
+ * The threshold sits right at **40,000**, which is Firestore's documented
1387
+ * maximum index entries — applied per COMMIT here, not per document. Every
1388
+ * element of an indexed array is an index entry, and a category carries ~146
1389
+ * of them (`searchTxt` alone is ~150 tokens, plus `parentIds`, `ancestors`,
1390
+ * `categoryNames`, `faqs`, `highlights`, `descriptionTemplates`).
1391
+ *
1392
+ * So the cap is on array elements at **20,000** — half the real limit, the
1393
+ * same margin the byte cap keeps — and it is kept alongside the op and byte
1394
+ * caps rather than replacing them, because all three are real Firestore
1395
+ * limits and only one of them has bitten so far.
1396
+ *
1397
+ * 🛑 Do NOT "simplify" this back to a lower fixed op count. 100 would work
1398
+ * for categories and silently triple the commit count for `faqs` and the
1399
+ * 1,337 tester checklist items, which carry almost no arrays. The cap has to
1400
+ * key on the thing that varies.
1401
+ */
1402
+ const MAX_OPS = Number(process.env.SEED_BATCH_OPS ?? 500);
1403
+ const MAX_BYTES = 4 * 1024 * 1024;
1404
+ const MAX_INDEX_ENTRIES = Number(process.env.SEED_BATCH_INDEX_ENTRIES ?? 20_000);
1405
+
1406
+ /** Array elements, recursively — a proxy for index entries written. */
1407
+ const countArrayElements = (v) => {
1408
+ if (Array.isArray(v)) return v.length + v.reduce((s, x) => s + countArrayElements(x), 0);
1409
+ if (v && typeof v === "object" && !(v instanceof Date)) {
1410
+ return Object.values(v).reduce((s, x) => s + countArrayElements(x), 0);
1411
+ }
1412
+ return 0;
1413
+ };
1414
+
1415
+ let chunk = [];
1416
+ let chunkBytes = 0;
1417
+ let chunkEntries = 0;
1418
+ const flush = async () => {
1419
+ if (chunk.length === 0) return;
1420
+ const batch = db.batch();
1421
+ for (const { ref, data } of chunk) batch.set(ref, data, { merge: true });
1422
+ await batch.commit();
1423
+ stats.created += chunk.length;
1424
+ chunk = [];
1425
+ chunkBytes = 0;
1426
+ chunkEntries = 0;
1427
+ };
1428
+
1429
+ for (const item of items) {
1430
+ let size = 0;
1431
+ try {
1432
+ size = Buffer.byteLength(JSON.stringify(item.data), "utf8");
1433
+ } catch {
1434
+ /*
1435
+ * A payload that cannot be serialised cannot be measured. Treat it as a
1436
+ * whole chunk's worth rather than as zero — guessing small is how a
1437
+ * commit silently grows past a limit again, and a document that resists
1438
+ * measurement is the one most likely to be large.
1439
+ */
1440
+ size = MAX_BYTES;
1441
+ }
1442
+ const entries = countArrayElements(item.data);
1443
+ /*
1444
+ * A single document over any cap still has to be written, and alone: a
1445
+ * one-document commit is the smallest thing committable, so if Firestore
1446
+ * rejects THAT the document itself is the problem and the error says so,
1447
+ * rather than the failure being absorbed into a chunking bug.
1448
+ */
1449
+ if (
1450
+ chunk.length > 0 &&
1451
+ (chunk.length >= MAX_OPS ||
1452
+ chunkBytes + size > MAX_BYTES ||
1453
+ chunkEntries + entries > MAX_INDEX_ENTRIES)
1454
+ ) {
1455
+ await flush();
1456
+ }
1457
+ chunk.push(item);
1458
+ chunkBytes += size;
1459
+ chunkEntries += entries;
1460
+ }
1461
+ await flush();
1462
+ }
1463
+
1464
+ // ---------------------------------------------------------------------------
1465
+ // Load
1466
+ // ---------------------------------------------------------------------------
1467
+ async function cmdLoad() {
1468
+ if (DRY_RUN) return cmdDryRun("load");
1469
+
1470
+ const stats = { created: 0, deleted: 0, skipped: 0, errors: 0 };
1471
+ for (const colName of collectionsToProcess) {
1472
+ const seed = SEED_DATA_MAP[colName];
1473
+ if (!seed || seed.length === 0) { console.log(`▌ ${colName} — no seed data, skipping`); continue; }
1474
+ console.log(`▌ ${colName} (${seed.length} docs)`);
1475
+
1476
+ try {
1477
+ if (colName === "users") {
1478
+ await loadUsers(seed, stats);
1479
+ } else if (colName === "addresses") {
1480
+ // SB-UNI-A (2026-05-13) unified addresses into one top-level collection
1481
+ // with an ownerType/ownerId discriminator, replacing the two prior
1482
+ // subcollections (users/{uid}/addresses, stores/{slug}/addresses) this
1483
+ // loader used to write to. addressesSeedData already carries
1484
+ // ownerType/ownerId directly — write it straight to the top-level
1485
+ // collection instead of nesting under USER_COLLECTION's old subcollection.
1486
+ for (const d of seed) {
1487
+ try {
1488
+ const { id, ownerType, ownerId, ...data } = d;
1489
+ if (!id || !ownerType || !ownerId) { stats.errors++; continue; }
1490
+ const payload = encryptPiiFields(
1491
+ stripUndefined(stampSeedPurpose("addresses", { ownerType, ownerId, ...data }, id)),
1492
+ [...ADDRESS_PII_FIELDS],
1493
+ );
1494
+ await db.collection("addresses").doc(id).set(payload, { merge: true });
1495
+ stats.created++;
1496
+ } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
1497
+ }
1498
+ } else if (colName === "storeAddresses") {
1499
+ // storeAddressesSeedData predates the SB-UNI-A unification and still
1500
+ // carries the old storeSlug field instead of ownerType/ownerId — derive
1501
+ // the unified shape at load time rather than writing to the deleted
1502
+ // stores/{slug}/addresses subcollection.
1503
+ for (const d of seed) {
1504
+ try {
1505
+ const { id, storeSlug, ...data } = d;
1506
+ if (!id || !storeSlug) { stats.errors++; continue; }
1507
+ /*
1508
+ * Stamped under the SEED KEY `storeAddresses`, not the target
1509
+ * collection `addresses`. Both resolve `test`, so nothing differs
1510
+ * today — but the purpose table is keyed on the seed key, and
1511
+ * stamping under the write target would make the two disagree the
1512
+ * day one of them changes.
1513
+ */
1514
+ const payload = encryptPiiFields(
1515
+ stripUndefined(
1516
+ stampSeedPurpose("storeAddresses", { ownerType: "store", ownerId: storeSlug, ...data }, id),
1517
+ ),
1518
+ [...ADDRESS_PII_FIELDS],
1519
+ );
1520
+ await db.collection("addresses").doc(id).set(payload, { merge: true });
1521
+ stats.created++;
1522
+ } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
1523
+ }
1524
+ } else if (colName === "couponUsage") {
1525
+ // shape: { userId, couponId, ... } — no PII
1526
+ for (const d of seed) {
1527
+ try {
1528
+ const { userId, couponId, ...data } = d;
1529
+ if (!userId || !couponId) { stats.errors++; continue; }
1530
+ await db.collection(USER_COLLECTION).doc(userId).collection("couponUsage").doc(couponId)
1531
+ .set(
1532
+ stripUndefined(stampSeedPurpose("couponUsage", { couponId, ...data }, couponId)),
1533
+ { merge: true },
1534
+ );
1535
+ stats.created++;
1536
+ } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
1537
+ }
1538
+ } else if (colName === "wishlists") {
1539
+ await loadOneDocPerUser(seed, WISHLIST_COLLECTION, stats);
1540
+ } else if (colName === "history") {
1541
+ await loadOneDocPerUser(seed, HISTORY_COLLECTION, stats);
1542
+ } else if (colName === "siteSettings") {
1543
+ const settings = seed[0];
1544
+ if (settings) {
1545
+ await db.collection(SITE_SETTINGS_COLLECTION).doc("global").set(
1546
+ stripUndefined(stampSeedPurpose("siteSettings", settings, "global")), { merge: true });
1547
+ stats.created++;
1548
+ }
1549
+ } else {
1550
+ await loadGeneric(colName, seed, COLLECTION_MAP[colName], stats);
1551
+ }
1552
+ } catch (err) {
1553
+ console.error(`✗ ${colName}: ${err.message}`);
1554
+ stats.errors++;
1555
+ }
1556
+ }
1557
+
1558
+ console.log(`\n✓ Load complete — created ${stats.created}, errors ${stats.errors}\n`);
1559
+ process.exit(stats.errors > 0 ? 1 : 0);
1560
+ }
1561
+
1562
+ // ---------------------------------------------------------------------------
1563
+ // Delete
1564
+ // ---------------------------------------------------------------------------
1565
+ async function cmdDelete() {
1566
+ if (DRY_RUN) return cmdDryRun("delete");
1567
+
1568
+ if (!(await confirm(`This will purge ${collectionsToProcess.length} collection(s) in project '${process.env.FIREBASE_ADMIN_PROJECT_ID}'. Continue?`))) {
1569
+ console.log("Aborted."); process.exit(0);
1570
+ }
1571
+
1572
+ const PROTECTED_USER_UIDS = new Set(["user-admin-letitrip"]);
1573
+ const stats = { deleted: 0, skipped: 0, errors: 0 };
1574
+
1575
+ for (const colName of collectionsToProcess) {
1576
+ console.log(`▌ ${colName}`);
1577
+ const seed = SEED_DATA_MAP[colName] || [];
1578
+ try {
1579
+ if (colName === "users") {
1580
+ for (const u of seed) {
1581
+ if (PROTECTED_USER_UIDS.has(u.uid)) { stats.skipped++; continue; }
1582
+ try { await auth.deleteUser(u.uid); }
1583
+ catch (e) { if (e?.code !== "auth/user-not-found") console.error(` auth: ${e.message}`); }
1584
+ const ref = db.collection(USER_COLLECTION).doc(u.uid);
1585
+ if ((await ref.get()).exists) { await ref.delete(); stats.deleted++; }
1586
+ }
1587
+ } else if (colName === "addresses" || colName === "storeAddresses") {
1588
+ // SB-UNI-A (2026-05-13): both user and store addresses live in the
1589
+ // single top-level `addresses` collection now, keyed by `id` — the
1590
+ // old per-parent subcollection paths (users/{uid}/addresses,
1591
+ // stores/{slug}/addresses) no longer exist, so deleting from them was
1592
+ // always a silent no-op. Delete by doc id straight from `addresses`.
1593
+ for (const d of seed) {
1594
+ if (!d.id) { stats.skipped++; continue; }
1595
+ const ref = db.collection("addresses").doc(d.id);
1596
+ if ((await ref.get()).exists) { await ref.delete(); stats.deleted++; }
1597
+ else stats.skipped++;
1598
+ }
1599
+ } else if (colName === "couponUsage") {
1600
+ const userIds = [...new Set(seed.map((d) => d.userId).filter(Boolean))];
1601
+ for (const uid of userIds) {
1602
+ stats.deleted += await purgeCollection(`${USER_COLLECTION}/${uid}/couponUsage`);
1603
+ }
1604
+ } else if (colName === "siteSettings") {
1605
+ const ref = db.collection(SITE_SETTINGS_COLLECTION).doc("global");
1606
+ if ((await ref.get()).exists) { await ref.delete(); stats.deleted++; } else stats.skipped++;
1607
+ } else {
1608
+ stats.deleted += await purgeCollection(COLLECTION_MAP[colName]);
1609
+ }
1610
+ } catch (err) {
1611
+ console.error(`✗ ${colName}: ${err.message}`);
1612
+ stats.errors++;
1613
+ }
1614
+ }
1615
+
1616
+ console.log(`\n✓ Delete complete — removed ${stats.deleted}, errors ${stats.errors}\n`);
1617
+ process.exit(stats.errors > 0 ? 1 : 0);
1618
+ }
1619
+
1620
+ // ---------------------------------------------------------------------------
1621
+ // Dry run
1622
+ // ---------------------------------------------------------------------------
1623
+ async function cmdDryRun(intendedAction) {
1624
+ console.log(`\nDry run (${intendedAction}) — no writes will happen.\n`);
1625
+ console.log(`${"collection".padEnd(22)} ${"seed".padStart(6)} ${"in db".padStart(6)} ${"would " + (intendedAction === "load" ? "create" : "delete")}`);
1626
+ console.log("─".repeat(56));
1627
+ let totalCreate = 0, totalDelete = 0;
1628
+ for (const c of collectionsToProcess) {
1629
+ const seedCount = (SEED_DATA_MAP[c] || []).length;
1630
+ const existing = await countExisting(c).catch(() => 0);
1631
+ const wouldCreate = intendedAction === "load" ? Math.max(seedCount - existing, 0) : 0;
1632
+ const wouldDelete = intendedAction === "delete" ? existing : 0;
1633
+ totalCreate += wouldCreate; totalDelete += wouldDelete;
1634
+ console.log(` ${c.padEnd(20)} ${String(seedCount).padStart(6)} ${String(existing).padStart(6)} ${String(intendedAction === "load" ? wouldCreate : wouldDelete).padStart(12)}`);
1635
+ }
1636
+ console.log("─".repeat(56));
1637
+ console.log(`Total would ${intendedAction === "load" ? "create" : "delete"}: ${intendedAction === "load" ? totalCreate : totalDelete}\n`);
1638
+ }
1639
+
1640
+ // ---------------------------------------------------------------------------
1641
+ // Main
1642
+ /**
1643
+ * `appkit-seed purge` — delete every `seedPurpose: "test"` document and NOTHING
1644
+ * else.
1645
+ *
1646
+ * ## Why this is not `delete`
1647
+ *
1648
+ * `delete` empties whole COLLECTIONS. That is the right tool for a full reset
1649
+ * and the wrong one for "clear the test data": it would take the 85 authored
1650
+ * FAQs, the 416-node taxonomy every product's `categorySlugs` chain points
1651
+ * into, the real HSN tax codes, the 1,337 tester cases and the AES-encrypted
1652
+ * site credentials with it.
1653
+ *
1654
+ * This is a QUERY over a declaration — `where("seedPurpose","==","test")` —
1655
+ * which gives four properties no heuristic can:
1656
+ *
1657
+ * 1. **A row the seeder never wrote is unreachable.** Organic documents (a
1658
+ * real buyer's cart, a runtime notification, a real signup) carry no
1659
+ * `seedPurpose` at all, so an equality on it cannot match them. Measured
1660
+ * live: 64 of 84 users are organic. That is the single most important
1661
+ * safety property here, and it comes from the field's ABSENCE rather than
1662
+ * from a list someone has to maintain.
1663
+ * 2. **The keep-list is data, not code.** `store-letitrip-official` and
1664
+ * `user-admin-letitrip` resolve `permanent`, so the same query skips them.
1665
+ * This replaces `cmdDelete`'s private `PROTECTED_USER_UIDS` set.
1666
+ * 3. **No composite index.** A single-field equality is served by Firestore's
1667
+ * automatic index, so this adds nothing to `firestore.indexes.json` — the
1668
+ * gap that left `priceIndex` queryless.
1669
+ * 4. **The cascade is mostly automatic.** Orders, bids, reviews, offers,
1670
+ * carts, payouts and notifications are all `test`, so a purged product's
1671
+ * dependents go in the same pass rather than needing a reference walk.
1672
+ *
1673
+ * 🛑 **Auth is a SECOND delete and `delete`/`purge` are the only places that
1674
+ * know it.** A Firestore user row and its Firebase Auth record are two objects;
1675
+ * removing the row and leaving the login is the worst of the three outcomes,
1676
+ * because the account then looks absent and can still authenticate.
1677
+ *
1678
+ * 🛑 **What it deliberately does NOT do**: delete organic rows that reference a
1679
+ * purged one. An organic notification pointing at a purged test order becomes a
1680
+ * dangling reference — and it is a real person's record, so it is REPORTED
1681
+ * rather than removed. Guessing in the destructive direction on someone's own
1682
+ * data is not a trade this command gets to make.
1683
+ */
1684
+ async function cmdPurge() {
1685
+ const targets = collectionsToProcess;
1686
+
1687
+ /* Subcollections are not reachable by a top-level query, so they are walked
1688
+ * explicitly. `couponUsage` lives at `users/{uid}/couponUsage`. */
1689
+ const SUBCOLLECTIONS = [{ key: "couponUsage", parent: USER_COLLECTION, name: "couponUsage" }];
1690
+
1691
+ const plan = [];
1692
+ for (const colName of targets) {
1693
+ const path = COLLECTION_MAP[colName];
1694
+ if (!path) continue;
1695
+ const sub = SUBCOLLECTIONS.find((s) => s.key === colName);
1696
+ try {
1697
+ if (sub) {
1698
+ /*
1699
+ * 🛑 Unfiltered collection-group read, filtered in memory — NOT
1700
+ * `.where("seedPurpose", "==", "test")` on the group.
1701
+ *
1702
+ * A single-field equality is auto-indexed per COLLECTION, but a
1703
+ * COLLECTION-GROUP query needs an explicit composite/group index that
1704
+ * this project has never deployed, and `purge` must not require a
1705
+ * Firebase deploy just to run (it is a read-then-delete script, not an
1706
+ * infra change). Measured live: `3 FAILED_PRECONDITION: ... requires a
1707
+ * COLLECTION_GROUP_ASC index`. `couponUsage` is small (single digits
1708
+ * per store), so reading the whole group and filtering client-side
1709
+ * costs nothing here — this is not a pattern to reuse on a large
1710
+ * subcollection.
1711
+ */
1712
+ const snap = await db.collectionGroup(sub.name).get();
1713
+ const docs = snap.docs.filter((d) => d.data()?.seedPurpose === "test");
1714
+ if (docs.length > 0) plan.push({ colName, docs, isSub: true });
1715
+ } else {
1716
+ const snap = await db.collection(path).where("seedPurpose", "==", "test").get();
1717
+ if (snap.size > 0) plan.push({ colName, docs: snap.docs, isSub: false });
1718
+ }
1719
+ } catch (err) {
1720
+ console.error(`✗ ${colName}: ${err.message}`);
1721
+ }
1722
+ }
1723
+
1724
+ const total = plan.reduce((n, p) => n + p.docs.length, 0);
1725
+ console.log(`\n${"collection".padEnd(26)} ${"purge".padStart(6)}`);
1726
+ console.log("─".repeat(36));
1727
+ for (const p of plan) {
1728
+ console.log(` ${p.colName.padEnd(24)} ${String(p.docs.length).padStart(6)}`);
1729
+ }
1730
+ console.log("─".repeat(36));
1731
+ console.log(` ${"TOTAL".padEnd(24)} ${String(total).padStart(6)}\n`);
1732
+
1733
+ if (total === 0) {
1734
+ console.log("Nothing to purge — no document carries seedPurpose \"test\".");
1735
+ return;
1736
+ }
1737
+
1738
+ if (DRY_RUN) {
1739
+ console.log("--dry-run — nothing was deleted.");
1740
+ return;
1741
+ }
1742
+
1743
+ if (
1744
+ !(await confirm(
1745
+ `Delete ${total} test document(s) in project '${process.env.FIREBASE_ADMIN_PROJECT_ID}'? Permanent content and organic rows are untouched.`,
1746
+ ))
1747
+ ) {
1748
+ console.log("Aborted.");
1749
+ process.exit(0);
1750
+ }
1751
+
1752
+ const stats = { deleted: 0, auth: 0, errors: 0 };
1753
+ for (const p of plan) {
1754
+ /*
1755
+ * 🛑 `users` first deletes the AUTH record, then the Firestore row — in that
1756
+ * order, because the reverse can leave an authenticating account whose
1757
+ * profile is gone, and this loop is the only thing that knows both exist.
1758
+ */
1759
+ if (p.colName === "users") {
1760
+ for (const d of p.docs) {
1761
+ try {
1762
+ await auth.deleteUser(d.id);
1763
+ stats.auth++;
1764
+ } catch (e) {
1765
+ if (e?.code !== "auth/user-not-found") console.error(` auth ${d.id}: ${e.message}`);
1766
+ }
1767
+ }
1768
+ }
1769
+ /*
1770
+ * 🛑 BulkWriter, never a manually-chunked `db.batch()`.
1771
+ *
1772
+ * A 450-document chunk stays under Firestore's 500-OPERATION limit but
1773
+ * says nothing about the 10 MiB per-request PAYLOAD limit — and a rich
1774
+ * document (a product's `searchTxt` array, `customFields`,
1775
+ * `customSections`, `images[]`) can blow that well before 450 count.
1776
+ * Measured live purging this exact `products` collection: `3
1777
+ * INVALID_ARGUMENT: Transaction too big` partway through, which aborted
1778
+ * the whole chunk's commit and silently left every doc in it undeleted —
1779
+ * `stats.deleted` kept counting the chunk as done regardless, so the
1780
+ * printed total was wrong by exactly the size of the failed chunk.
1781
+ *
1782
+ * `db.bulkWriter()` sizes and retries its own requests, so it is correct
1783
+ * regardless of document size — CLAUDE.md documents the identical failure
1784
+ * for a `categories` wipe (1,177 rows, ~100-entry `searchTxt` each) dying
1785
+ * at a 400-doc batch for the same reason.
1786
+ */
1787
+ const writer = db.bulkWriter();
1788
+ let chunkErrors = 0;
1789
+ writer.onWriteError((err) => {
1790
+ chunkErrors += 1;
1791
+ console.error(`✗ ${p.colName}/${err.documentRef.id}: ${err.message}`);
1792
+ // Retry up to the default limit; beyond that, give up on this one doc
1793
+ // rather than retrying forever.
1794
+ return err.failedAttempts < 3;
1795
+ });
1796
+ for (const d of p.docs) {
1797
+ writer.delete(d.ref);
1798
+ if (VERBOSE) console.log(` - ${p.colName}/${d.id}`);
1799
+ }
1800
+ await writer.close();
1801
+ stats.deleted += p.docs.length - chunkErrors;
1802
+ stats.errors += chunkErrors;
1803
+ }
1804
+
1805
+ console.log(
1806
+ `\n${stats.errors === 0 ? "✓" : "✗"} Purge complete — deleted ${stats.deleted}, auth records ${stats.auth}, errors ${stats.errors}`,
1807
+ );
1808
+ if (stats.errors > 0) process.exit(1);
1809
+ }
1810
+
1811
+ // ---------------------------------------------------------------------------
1812
+ try {
1813
+ if (ACTION === "status") await cmdStatus();
1814
+ else if (ACTION === "load") await cmdLoad();
1815
+ else if (ACTION === "delete") await cmdDelete();
1816
+ else if (ACTION === "purge") await cmdPurge();
1817
+ process.exit(0);
1818
+ } catch (err) {
1819
+ console.error("✗ Fatal:", err.message);
1820
+ if (VERBOSE) console.error(err.stack);
1821
+ process.exit(1);
1822
+ }