@base44/app-plugin-commerce 0.9.1 → 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
|
|
637
|
-
const PRODUCT_CONCURRENCY = 4; // products in flight (each fans out its
|
|
638
|
-
const VARIATION_CONCURRENCY = 5; // variation
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
// ──
|
|
679
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
//
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
758
|
-
|
|
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
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
809
|
-
|
|
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
|
-
|
|
955
|
+
return { p, record };
|
|
956
|
+
});
|
|
841
957
|
|
|
842
|
-
|
|
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
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
868
|
-
variationsCreated++;
|
|
869
|
-
return createdVariation;
|
|
992
|
+
return rec;
|
|
870
993
|
});
|
|
871
|
-
|
|
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
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|