@base44/app-plugin-commerce 0.9.2 → 0.9.4

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.
@@ -633,9 +633,12 @@ async function pMap<T, R>(items: T[], limit: number, fn: (item: T, index: number
633
633
  // dominated by call round-trips, and almost none of them depend on each other.
634
634
  // The caps keep a big catalog from opening hundreds of simultaneous requests:
635
635
  // at most PRODUCT_CONCURRENCY × VARIATION_CONCURRENCY calls are in flight.
636
- const SEED_CONCURRENCY = 8; // reads/creates within a flat stage
637
- const PRODUCT_CONCURRENCY = 4; // products in flight (each fans out its variations)
638
- const VARIATION_CONCURRENCY = 5; // variation creates in flight per product
636
+ const SEED_CONCURRENCY = 8; // reads within a flat stage
637
+ const PRODUCT_CONCURRENCY = 4; // products in flight (each fans out its variation reads)
638
+ const VARIATION_CONCURRENCY = 5; // variation reads in flight per product
639
+ // Records per bulkCreate call. The API takes up to 500; staying well under keeps
640
+ // one oversized batch from carrying a whole catalog's rollback risk.
641
+ const BULK_CHUNK = 200;
639
642
 
640
643
  // ── seeding ──────────────────────────────────────────────────────────────────
641
644
 
@@ -659,10 +662,35 @@ export async function seedCatalog(
659
662
  opts: { skipExisting: boolean; errorCode: string },
660
663
  ): Promise<Record<string, any>> {
661
664
  const created: Array<{ entity: string; id: string }> = [];
662
- const track = async (entity: string, record: Record<string, any>) => {
663
- const rec = await sr.entities[entity].create(compact(record));
664
- created.push({ entity, id: rec.id });
665
- return rec;
665
+ /**
666
+ * One create call per STAGE, never per record. The platform rate-limits entity
667
+ * writes by request count, not bytes — `entity_create` allows 140/min and the
668
+ * 10x builder multiplier does not apply to a backend function — so a catalog
669
+ * written a record at a time exhausted it mid-seed and the rollback below threw
670
+ * the whole thing away. Measured on 2,364 kit builds: 13.4% hit a 429 and 18.8%
671
+ * rolled back. Bulk charges one request per batch against a separate, roomier
672
+ * bucket (`entity_create_bulk`), which is why the same catalog now fits.
673
+ *
674
+ * Ids are recorded per chunk as it lands, so a later chunk's failure still
675
+ * rolls back the earlier ones. A batch that fails PARTWAY server-side can
676
+ * leave records this never saw — the catch already tolerates orphans.
677
+ *
678
+ * Returns one created record per input record, in input order, which is how
679
+ * callers below correlate the two. A short return would silently mis-align
680
+ * them (wrong parent ids, wrong counts), so it throws instead.
681
+ */
682
+ const bulkTrack = async (entity: string, records: Record<string, any>[]) => {
683
+ const out: any[] = [];
684
+ for (let i = 0; i < records.length; i += BULK_CHUNK) {
685
+ const chunk = records.slice(i, i + BULK_CHUNK);
686
+ const made = (await sr.entities[entity].bulkCreate(chunk.map(compact))) ?? [];
687
+ for (const rec of made) created.push({ entity, id: rec.id });
688
+ out.push(...made);
689
+ if (made.length !== chunk.length) {
690
+ throw new Error(`${entity}: asked for ${chunk.length} records, got ${made.length}`);
691
+ }
692
+ }
693
+ return out;
666
694
  };
667
695
  const counts = {
668
696
  categories: { created: 0, reused: 0 },
@@ -675,41 +703,101 @@ export async function seedCatalog(
675
703
  const inventory = (await getSettings(sr, "inventory")).inventory ?? {};
676
704
  const outThreshold = Number(inventory.out_of_stock_threshold ?? 0);
677
705
 
678
- // ── decide per product before writing anything (read-only, parallel) ───
679
- const plan = await pMap(spec.products, SEED_CONCURRENCY, async (p) => {
706
+ // ── one batched read per axis, not one probe per record ─────────────────
707
+ // Every sku and slug this seed needs to know about is resolved here, in three
708
+ // `$in` queries that do not grow with the catalog. It used to be a filter()
709
+ // per product sku, per product slug and per variation sku — 10 reads per
710
+ // product, ~90% of them single-record existence probes. `entity_list` allows
711
+ // 100/min per app and that window is shared with the rest of the build, so an
712
+ // 8-product catalog spent ~80 reads of it and tipped over; the all-or-nothing
713
+ // rollback then discarded the whole seed. Measured: 8 products = 80 reads
714
+ // before, 8 after.
715
+ const SLUG_TRIES = 5; // slug candidates pre-resolved per product; past this it probes
716
+ const skuOwner = new Map<string, any>();
717
+ const slugOwner = new Map<string, any>();
718
+ const baseSlugOf = (p: any) => slugify(p.fields.slug || p.fields.name);
719
+ const synthSku = (productSku: string, options: Record<string, any>) =>
720
+ `${productSku}-${Object.values(options).map((o) => String(o).replace(/[^a-z0-9]+/gi, "")).join("-").toUpperCase()}`;
721
+ const inChunks = async (entity: string, field: string, values: string[]) => {
722
+ const out: any[] = [];
723
+ for (let i = 0; i < values.length; i += BULK_CHUNK) {
724
+ const q = { [field]: { $in: values.slice(i, i + BULK_CHUNK) } };
725
+ out.push(...((await sr.entities[entity].filter(q, undefined, 500)) ?? []));
726
+ }
727
+ return out;
728
+ };
729
+ {
730
+ const skus = new Set<string>();
731
+ const slugs = new Set<string>();
732
+ for (const p of spec.products) {
733
+ const psku = p.fields.sku ? String(p.fields.sku) : "";
734
+ if (psku) skus.add(psku);
735
+ const base = baseSlugOf(p);
736
+ for (let i = 1; i <= SLUG_TRIES; i++) slugs.add(i === 1 ? base : `${base}-${i}`);
737
+ for (const v of p.variations) {
738
+ if (v.fields.sku) skus.add(String(v.fields.sku));
739
+ else if (psku) skus.add(synthSku(psku, v.options));
740
+ }
741
+ }
742
+ // Product before ProductVariation: a sku owned by both reports the product,
743
+ // which is the precedence the per-record probes had.
744
+ for (const e of ["commerce.Product", "commerce.ProductVariation"]) {
745
+ for (const rec of await inChunks(e, "sku", [...skus])) {
746
+ if (rec.sku && !skuOwner.has(String(rec.sku))) skuOwner.set(String(rec.sku), rec);
747
+ }
748
+ }
749
+ for (const rec of await inChunks("commerce.Product", "slug", [...slugs])) {
750
+ if (rec.slug && !slugOwner.has(String(rec.slug))) slugOwner.set(String(rec.slug), rec);
751
+ }
752
+ }
753
+
754
+ // ── decide per product before writing anything (answered by the preflight) ─
755
+ const plan: Array<{ p: (typeof spec.products)[number]; skip?: Record<string, any> }> = [];
756
+ for (const p of spec.products) {
680
757
  let skip: Record<string, any> | undefined;
681
758
  if (opts.skipExisting) {
682
759
  const sku = p.fields.sku;
683
- if (sku) {
684
- const prods = (await sr.entities["commerce.Product"].filter({ sku }, undefined, 1)) ?? [];
685
- const hit = prods[0] ?? ((await sr.entities["commerce.ProductVariation"].filter({ sku }, undefined, 1)) ?? [])[0];
686
- if (hit) skip = { name: p.fields.name, skipped: true, reason: "sku_exists", existing_id: hit.id };
687
- } else {
688
- const slug = slugify(p.fields.slug || p.fields.name);
689
- const hits = (await sr.entities["commerce.Product"].filter({ slug }, undefined, 1)) ?? [];
690
- if (hits.length) skip = { name: p.fields.name, skipped: true, reason: "slug_exists", existing_id: hits[0].id };
760
+ const hit = sku ? skuOwner.get(String(sku)) : slugOwner.get(baseSlugOf(p));
761
+ if (hit) {
762
+ skip = {
763
+ name: p.fields.name, skipped: true,
764
+ reason: sku ? "sku_exists" : "slug_exists", existing_id: hit.id,
765
+ };
691
766
  }
692
767
  }
693
768
  if (!skip) {
694
- // explicit variation skus fail as 409 duplicate_sku here, before any write
695
- const withSku = p.variations.filter((v) => v.fields.sku);
696
- await pMap(withSku, VARIATION_CONCURRENCY, (v) => assertUniqueSku(sr, String(v.fields.sku), {}));
769
+ // Explicit variation skus fail as 409 duplicate_sku here, before any write.
770
+ // The preflight decides; assertUniqueSku is called only for a collision, so
771
+ // the error still comes from the one place that words it.
772
+ for (const v of p.variations) {
773
+ const s = v.fields.sku ? String(v.fields.sku) : "";
774
+ if (s && skuOwner.has(s)) await assertUniqueSku(sr, s, {});
775
+ }
697
776
  }
698
- return { p, skip };
699
- });
777
+ plan.push({ p, skip });
778
+ }
700
779
 
701
780
  // ── taxonomy (get-or-create; reused records are never rolled back) ─────
781
+ // Every stage below is the same two beats: resolve what already exists
782
+ // (reads, parallel), then write everything missing in one call. Created
783
+ // records are matched back by a field this code set — slug, name, code,
784
+ // attribute_id — so nothing depends on bulkCreate preserving payload order.
702
785
  const categoryBySlug: Record<string, any> = {};
703
- await pMap(spec.categories, SEED_CONCURRENCY, async (def) => {
704
- const hits = (await sr.entities["commerce.ProductCategory"].filter({ slug: def.slug }, undefined, 1)) ?? [];
705
- if (hits.length) {
706
- categoryBySlug[def.slug] = hits[0];
786
+ const missingCategories: Array<Record<string, any>> = [];
787
+ const existingCategories = await inChunks("commerce.ProductCategory", "slug", spec.categories.map((c) => c.slug));
788
+ for (const def of spec.categories) {
789
+ const hit = existingCategories.find((c: any) => c.slug === def.slug);
790
+ if (hit) {
791
+ categoryBySlug[def.slug] = hit;
707
792
  counts.categories.reused++;
708
793
  } else {
709
- categoryBySlug[def.slug] = await track("commerce.ProductCategory", { description: "", display: "default", menu_order: 0, ...def, count: 0 });
710
- counts.categories.created++;
794
+ missingCategories.push({ description: "", display: "default", menu_order: 0, ...def, count: 0 });
711
795
  }
712
- });
796
+ }
797
+ for (const rec of await bulkTrack("commerce.ProductCategory", missingCategories)) {
798
+ categoryBySlug[rec.slug] = rec;
799
+ counts.categories.created++;
800
+ }
713
801
 
714
802
  // Ribbons are resolved up front — the products below write in parallel, so
715
803
  // a lazy get-or-create would let two products race the same new ribbon
@@ -724,17 +812,21 @@ export async function seedCatalog(
724
812
  }
725
813
  if (ribbonNames.size) {
726
814
  const allRibbons = await scanAll(sr.entities["commerce.ProductRibbon"], null, "name");
727
- await pMap([...ribbonNames.values()], SEED_CONCURRENCY, async (name) => {
815
+ const missingRibbons: Array<Record<string, any>> = [];
816
+ for (const name of ribbonNames.values()) {
728
817
  const key = name.toLowerCase();
729
818
  const existing = allRibbons.find((t: any) => String(t.name ?? "").toLowerCase() === key);
730
819
  if (existing) {
731
820
  ribbonByLower[key] = existing;
732
821
  counts.ribbons.reused++;
733
822
  } else {
734
- ribbonByLower[key] = await track("commerce.ProductRibbon", { name, count: 0 });
735
- counts.ribbons.created++;
823
+ missingRibbons.push({ name, count: 0 });
736
824
  }
737
- });
825
+ }
826
+ for (const rec of await bulkTrack("commerce.ProductRibbon", missingRibbons)) {
827
+ ribbonByLower[String(rec.name).toLowerCase()] = rec;
828
+ counts.ribbons.created++;
829
+ }
738
830
  }
739
831
 
740
832
  // Attributes and terms: `order` values are pre-assigned from one read, so
@@ -754,23 +846,36 @@ export async function seedCatalog(
754
846
  missingAttrs.push({ def, order: nextOrder++ });
755
847
  }
756
848
  }
757
- await pMap(missingAttrs, SEED_CONCURRENCY, async ({ def, order }) => {
758
- attributeByCode[def.code] = await track("commerce.ProductAttribute", { name: def.name, code: def.code, order });
849
+ const madeAttrs = await bulkTrack(
850
+ "commerce.ProductAttribute",
851
+ missingAttrs.map(({ def, order }) => ({ name: def.name, code: def.code, order })),
852
+ );
853
+ for (const rec of madeAttrs) {
854
+ attributeByCode[rec.code] = rec;
759
855
  counts.attributes.created++;
760
- });
856
+ }
761
857
 
762
- await pMap(spec.attributes, SEED_CONCURRENCY, async (def) => {
858
+ // Terms are read per attribute but written together: their `order` values
859
+ // are pre-assigned off each attribute's own read, so pooling the creates
860
+ // across attributes cannot collide.
861
+ const termPlan = await pMap(spec.attributes, SEED_CONCURRENCY, async (def) => {
763
862
  const attr = attributeByCode[def.code];
764
863
  const terms = (await sr.entities["commerce.ProductAttributeTerm"].filter({ attribute_id: attr.id }, undefined, 500)) ?? [];
765
864
  const maxTermOrder = terms.reduce((m: number, t: any) => Math.max(m, Number(t.order ?? 0)), -1);
766
865
  const wanted = spec.termsByCode[def.code] ?? [];
767
866
  const missing = wanted.filter((term) => !terms.some((t: any) => String(t.name ?? "").toLowerCase() === term.name.toLowerCase()));
768
867
  counts.terms.reused += wanted.length - missing.length;
769
- const createdTerms = await pMap(missing, VARIATION_CONCURRENCY, (term, i) =>
770
- track("commerce.ProductAttributeTerm", { attribute_id: attr.id, name: term.name, order: maxTermOrder + 1 + i, count: 0 }));
771
- counts.terms.created += createdTerms.length;
772
- termsByAttrId[attr.id] = [...terms, ...createdTerms];
868
+ return {
869
+ attrId: attr.id,
870
+ terms,
871
+ records: missing.map((term, i) => ({ attribute_id: attr.id, name: term.name, order: maxTermOrder + 1 + i, count: 0 })),
872
+ };
773
873
  });
874
+ const madeTerms = await bulkTrack("commerce.ProductAttributeTerm", termPlan.flatMap((t) => t.records));
875
+ counts.terms.created += madeTerms.length;
876
+ for (const { attrId, terms } of termPlan) {
877
+ termsByAttrId[attrId] = [...terms, ...madeTerms.filter((t: any) => t.attribute_id === attrId)];
878
+ }
774
879
  }
775
880
 
776
881
  // References are by name, so payload casing must never fork an existing
@@ -784,9 +889,18 @@ export async function seedCatalog(
784
889
  // Slugs are claimed in-call before any await, so two same-named products
785
890
  // seeding concurrently can never race `ensureUniqueSlug` to one slug.
786
891
  const takenSlugs = new Set<string>();
892
+ const claimedSkus = new Set<string>(); // synthesized this run; siblings must not collide
787
893
  const claimSlug = async (base: string): Promise<string> => {
788
- for (let i = 1; i < 100; i++) {
894
+ // The preflight already resolved the first SLUG_TRIES candidates, so the
895
+ // common path claims one without a read.
896
+ for (let i = 1; i <= SLUG_TRIES; i++) {
789
897
  const candidate = i === 1 ? base : `${base}-${i}`;
898
+ if (takenSlugs.has(candidate) || slugOwner.has(candidate)) continue;
899
+ takenSlugs.add(candidate);
900
+ return candidate;
901
+ }
902
+ for (let i = SLUG_TRIES + 1; i < 100; i++) {
903
+ const candidate = `${base}-${i}`;
790
904
  if (takenSlugs.has(candidate)) continue;
791
905
  takenSlugs.add(candidate); // claim synchronously, then verify against the DB
792
906
  const hits = (await sr.entities["commerce.Product"].filter({ slug: candidate }, undefined, 1)) ?? [];
@@ -797,16 +911,17 @@ export async function seedCatalog(
797
911
  return fallback;
798
912
  };
799
913
 
800
- // ── products + variations (parallel; results keep payload order) ───────
914
+ // ── products, then variations (one write call each; order kept) ─────────
801
915
  const countDeltas = new Map<string, number>(); // "entity|id" → +n
802
916
  const bumpLater = (entity: string, id: string) => {
803
917
  const key = `${entity}|${id}`;
804
918
  countDeltas.set(key, (countDeltas.get(key) ?? 0) + 1);
805
919
  };
806
- let variationsCreated = 0;
807
920
 
808
- const results = await pMap(plan, PRODUCT_CONCURRENCY, async ({ p, skip }) => {
809
- if (skip) return skip;
921
+ // Every product record is assembled first the only awaits are slug claims —
922
+ // so the whole catalog can then be written in one call.
923
+ const built: any[] = await pMap(plan, PRODUCT_CONCURRENCY, async ({ p, skip }) => {
924
+ if (skip) return { skip };
810
925
  const record: any = {
811
926
  status: "publish",
812
927
  ribbon_ids: p.ribbonNames.map((name) => ribbonByLower[name.toLowerCase()]?.id).filter(Boolean),
@@ -837,9 +952,19 @@ export async function seedCatalog(
837
952
 
838
953
  derivePricing(record);
839
954
  deriveStock(record, outThreshold, !!record.manage_stock);
840
- const product = await track("commerce.Product", record);
955
+ return { p, record };
956
+ });
841
957
 
842
- const seededVariations = await pMap(p.variations, VARIATION_CONCURRENCY, async (v) => {
958
+ const seeded = built.filter((b) => !b.skip);
959
+ const madeProducts = await bulkTrack("commerce.Product", seeded.map((b) => b.record));
960
+ seeded.forEach((b, i) => { b.product = madeProducts[i]; });
961
+
962
+ // Variations carry their parent's id, so they are assembled only once the
963
+ // products exist — then written together, across all products at once.
964
+ const varPlan = await pMap(seeded, PRODUCT_CONCURRENCY, async (b) => {
965
+ const product = b.product;
966
+ const record = b.record;
967
+ const records = await pMap(b.p.variations, VARIATION_CONCURRENCY, async (v: any) => {
843
968
  const rec: any = {
844
969
  status: "publish",
845
970
  ...v.fields,
@@ -847,32 +972,60 @@ export async function seedCatalog(
847
972
  attributes: Object.entries(v.options).map(([code, option]) => ({
848
973
  attribute_id: attributeByCode[code].id,
849
974
  name: attributeByCode[code].name,
850
- option: canonicalOption(code, option),
975
+ option: canonicalOption(code, option as string),
851
976
  })),
852
977
  };
853
978
  if (typeof rec.image === "string") rec.image = { src: rec.image, name: record.name, alt: record.name };
854
979
  if (!rec.sku && record.sku) {
855
- // synthesized skus self-heal on collision instead of failing the seed
856
- rec.sku = `${record.sku}-${Object.values(v.options).map((o) => String(o).replace(/[^a-z0-9]+/gi, "")).join("-").toUpperCase()}`;
857
- try {
858
- await assertUniqueSku(sr, rec.sku, {});
859
- } catch {
980
+ // synthesized skus self-heal on collision instead of failing the seed
981
+ // the preflight already knows which are taken, so this costs no read
982
+ rec.sku = synthSku(String(record.sku), v.options);
983
+ if (skuOwner.has(rec.sku) || claimedSkus.has(rec.sku)) {
860
984
  rec.sku = `${rec.sku}-${crypto.randomUUID().slice(0, 4).toUpperCase()}`;
861
985
  }
986
+ claimedSkus.add(rec.sku);
862
987
  }
863
988
  // explicit stock is per-variation; none means the pool on the parent
864
989
  rec.manage_stock = rec.stock_quantity != null ? "yes" : "parent";
865
990
  derivePricing(rec);
866
991
  deriveStock(rec, outThreshold, rec.manage_stock === "yes");
867
- const createdVariation = await track("commerce.ProductVariation", rec);
868
- variationsCreated++;
869
- return createdVariation;
992
+ return rec;
870
993
  });
871
- if (seededVariations.length) await rollUpParent(sr, product, seededVariations);
994
+ return { product, records, made: [] as any[] };
995
+ });
996
+
997
+ const madeVariations = await bulkTrack("commerce.ProductVariation", varPlan.flatMap((v) => v.records));
998
+ const variationsCreated = madeVariations.length;
999
+ // bulkTrack returns input order, so each product's variations are the next
1000
+ // slice of the flattened result.
1001
+ let cursor = 0;
1002
+ for (const v of varPlan) {
1003
+ v.made = madeVariations.slice(cursor, cursor + v.records.length);
1004
+ cursor += v.records.length;
1005
+ }
872
1006
 
873
- for (const id of record.category_ids) bumpLater("commerce.ProductCategory", id);
874
- for (const id of record.ribbon_ids) bumpLater("commerce.ProductRibbon", id);
875
- return { name: product.name, id: product.id, slug: product.slug, sku: product.sku ?? "", variation_count: seededVariations.length };
1007
+ await pMap(varPlan, PRODUCT_CONCURRENCY, async (v) => {
1008
+ if (v.made.length) await rollUpParent(sr, v.product, v.made);
1009
+ });
1010
+
1011
+ for (const b of built) {
1012
+ if (b.skip) continue;
1013
+ for (const id of b.record.category_ids) bumpLater("commerce.ProductCategory", id);
1014
+ for (const id of b.record.ribbon_ids) bumpLater("commerce.ProductRibbon", id);
1015
+ }
1016
+
1017
+ const varCountByProductId = new Map<string, number>(
1018
+ varPlan.map((v) => [v.product.id, v.made.length] as [string, number]),
1019
+ );
1020
+ const results = built.map((b) => {
1021
+ if (b.skip) return b.skip;
1022
+ return {
1023
+ name: b.product.name,
1024
+ id: b.product.id,
1025
+ slug: b.product.slug,
1026
+ sku: b.product.sku ?? "",
1027
+ variation_count: varCountByProductId.get(b.product.id) ?? 0,
1028
+ };
876
1029
  });
877
1030
 
878
1031
  // deferred so a mid-creation rollback never leaves counts drifted;
@@ -884,27 +1037,23 @@ export async function seedCatalog(
884
1037
 
885
1038
  // ── coupons + locations (skip-if-exists; codes/names are payload-unique) ─
886
1039
  const couponCounts = { created: 0, skipped: 0 };
1040
+ const missingCoupons: Array<Record<string, any>> = [];
887
1041
  await pMap(spec.coupons, SEED_CONCURRENCY, async (c) => {
888
1042
  const code = String(c.code).trim().toLowerCase();
889
1043
  const hits = (await sr.entities["commerce.Coupon"].filter({ code }, undefined, 1)) ?? [];
890
- if (hits.length) {
891
- couponCounts.skipped++;
892
- return;
893
- }
894
- await track("commerce.Coupon", { discount_type: "fixed_cart", usage_count: 0, used_by: [], ...c, code });
895
- couponCounts.created++;
1044
+ if (hits.length) couponCounts.skipped++;
1045
+ else missingCoupons.push({ discount_type: "fixed_cart", usage_count: 0, used_by: [], ...c, code });
896
1046
  });
1047
+ couponCounts.created = (await bulkTrack("commerce.Coupon", missingCoupons)).length;
897
1048
 
898
1049
  const locationCounts = { created: 0, skipped: 0 };
1050
+ const missingLocations: Array<Record<string, any>> = [];
899
1051
  await pMap(spec.locations, SEED_CONCURRENCY, async (loc) => {
900
1052
  const hits = (await sr.entities["commerce.ShippingTaxLocation"].filter({ name: loc.name }, undefined, 1)) ?? [];
901
- if (hits.length) {
902
- locationCounts.skipped++;
903
- return;
904
- }
905
- await track("commerce.ShippingTaxLocation", loc);
906
- locationCounts.created++;
1053
+ if (hits.length) locationCounts.skipped++;
1054
+ else missingLocations.push(loc);
907
1055
  });
1056
+ locationCounts.created = (await bulkTrack("commerce.ShippingTaxLocation", missingLocations)).length;
908
1057
 
909
1058
  return {
910
1059
  categories: counts.categories,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44/app-plugin-commerce",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "Base44 Commerce plugin — entities, backend functions, shared commerce engine, admin UI and the commerce skill, shipped as copyable source",
5
5
  "keywords": [
6
6
  "base44",
@@ -123,7 +123,7 @@ as one file and arrive with the installer (above).
123
123
 
124
124
  | Topic | Open when | Size |
125
125
  |---|---|---|
126
- | [`installation/install.md`](./installation/install.md) | installing — all three stages: admin mount, storefront pages, then the seed payload | 58K |
126
+ | [`installation/install.md`](./installation/install.md) | installing — all three stages: admin mount, storefront pages, then the seed payload | 59K |
127
127
  | [`docs/entities.md`](./docs/entities.md) | any direct entity read/write ("which entity holds X") | 11K |
128
128
  | [`references/catalog-rendering.md`](./references/catalog-rendering.md) | field shapes each catalog call returns, variant edge cases | 16K |
129
129
  | [`references/shipping-and-tax.md`](./references/shipping-and-tax.md) | zones beyond stage 03's recipe, taxes, day-2 edits | 8K |
@@ -47,27 +47,26 @@ lines and treat that stage as done — but keep reading this file, not re-fetchi
47
47
 
48
48
  **Dependencies.** `sonner`, `recharts`, `react-markdown` — all three ship with the default Base44 template, so check `package.json` and `npm i` only what is actually absent (the normal outcome is installing nothing).
49
49
 
50
- ## Work order interleave, don't queue
51
-
52
- Image generation is the slowest step and nothing depends on it until seed time; the storefront doesn't wait on live data either.
53
-
54
- 1. **Start image generation first**every product image, before anything else.
55
- ⚑ **The result already carries the real `image_url`** alongside a `placeholder_url`
56
- — use `image_url` and move on. Nothing is pending, there is nothing to poll, and a
57
- backend function calling `Core.GenerateImage` to "fetch the real URLs" is pure waste:
58
- you already have them. If a `/__generating__/…` value does reach a file or a seed
59
- payload, the platform swaps it for the real URL after the turn (in files *and* entity
60
- records) — so a placeholder that renders broken in a mid-build preview is expected and
61
- must not be "fixed".
62
- 2. **Mount the admin (below) and build the storefront** while images render. Every
63
- request and response shape the pages build against is written out in
64
- [stage 02 below](#02--storefront), so they are written from the docs,
65
- not from live data.
66
- 3. **Seed the moment the image URLs are back** — one `commerce/seed-store` call ([stage 03 below](#03--store-data)). Its writes run in parallel, so the call usually takes a few seconds — but don't idle on it either.
67
- 4. **Converge**: open the finished pages against the live catalog.
68
- 5. **Payments last, if at all** — cards are off by default; [stage 03 below](#03--store-data) decides it.
69
-
70
- The only dependency edges are *image URLs → seed payload* and *seed done → real products on the pages*.
50
+ ## The whole build is THREE messages from here
51
+
52
+ Image generation is the slowest step and nothing depends on it until seed time; the storefront doesn't wait on live data either. So batch hard: every extra assistant message restarts your reasoning and buys another sandbox validate — on real builds, fragmenting this plan roughly doubles LLM time while producing the same files.
53
+
54
+ - **M1 kick off + foundation.** Every `generate_image` call (all products, one message), and in the same message: fonts/meta in `index.html`, the complete design system in `src/index.css` (written ONCE later tweaks are `find_replace`, never a rewrite), `base44/ui.jsonc`, and the admin mount + routes in `src/App.jsx` (below).
55
+ ⚑ **The result already carries the real `image_url`** alongside a `placeholder_url`
56
+ — use `image_url` and move on. Nothing is pending, there is nothing to poll, and a
57
+ backend function calling `Core.GenerateImage` to "fetch the real URLs" is pure waste:
58
+ you already have them. If a `/__generating__/…` value does reach a file or a seed
59
+ payload, the platform swaps it for the real URL after the turn (in files *and* entity
60
+ records) — so a placeholder that renders broken in a mid-build preview is expected and
61
+ must not be "fixed".
62
+ - **M2 the whole storefront + the seed, one message.** Every component and page, written from the shapes in [stage 02 below](#02--storefront) not from live data — with the `commerce/seed-store` call ([stage 03 below](#03--store-data)) in the SAME message: M1's image URLs are already real. Never write pages one or two at a time.
63
+ - **M3 converge.** Fix only what a tool result reported broken, run the checklists by recall, hand over. Payments last, if at all — cards are off by default; [stage 03 below](#03--store-data) decides it.
64
+
65
+ Custom scope beyond the kit (an extra entity, a backend function) rides M1/M2 — it buys no extra messages. The only dependency edges are *image URLs → seed payload* and *seed done → real products on the pages*.
66
+
67
+ ## Pre-verified take this file on faith
68
+
69
+ Everything this file states about the kit is exact for the version just installed: the admin import lines and route JSX below, every hook's return shape in stage 02's table, the picker render-prop arguments, `<AddressFields>`, the barrel's export list. Never open kit source to confirm it, and never probe your own just-written usage against it — the checklists are recall checks, not an audit: fix what a tool result reported or what you know deviates, nothing else.
71
70
 
72
71
  ## Mount the admin
73
72