@base44/app-plugin-commerce 0.9.2 → 0.9.3

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.3",
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",