@porulle/plugin-channel-connector 0.49.0 → 0.50.1
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.
- package/dist/service.d.ts +27 -0
- package/dist/service.js +264 -62
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +2 -2
- package/src/service.ts +262 -59
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/plugin-channel-connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@hono/zod-openapi": "^1.2.2",
|
|
21
21
|
"hono": "^4.12.5",
|
|
22
|
-
"@porulle/core": "0.
|
|
22
|
+
"@porulle/core": "0.50.1"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/node": "^24.5.2",
|
package/src/service.ts
CHANGED
|
@@ -40,6 +40,8 @@ import {
|
|
|
40
40
|
customerAddresses,
|
|
41
41
|
customers,
|
|
42
42
|
entityMedia,
|
|
43
|
+
entityBrands,
|
|
44
|
+
entityCategories,
|
|
43
45
|
entityTags,
|
|
44
46
|
inventoryLevels,
|
|
45
47
|
mediaAssets,
|
|
@@ -636,6 +638,39 @@ function hash(value: unknown): string {
|
|
|
636
638
|
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
637
639
|
}
|
|
638
640
|
|
|
641
|
+
/**
|
|
642
|
+
* The suffix a store's product takes when its handle is already another store's slug: the first
|
|
643
|
+
* label of the store domain (`kelly-felder.myshopify.com` → `kelly-felder`), slugified.
|
|
644
|
+
*/
|
|
645
|
+
export function storeSlugSuffix(storeDomain: string): string {
|
|
646
|
+
const label = storeDomain.trim().toLowerCase().split(".")[0] ?? "";
|
|
647
|
+
return label.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const SLUG_CONFLICT = /sellable_entities_org_slug_unique|Entity with slug .+ already exists|Slug ".+" already exists/;
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Whether a create or update lost a slug to another writer — core's pre-check message, or the
|
|
654
|
+
* unique index itself, which a driver error can carry several `cause`s deep.
|
|
655
|
+
*/
|
|
656
|
+
export function isSlugConflict(error: unknown): boolean {
|
|
657
|
+
let cursor: unknown = error;
|
|
658
|
+
for (let depth = 0; depth < 5; depth += 1) {
|
|
659
|
+
if (typeof cursor === "string") return SLUG_CONFLICT.test(cursor);
|
|
660
|
+
if (typeof cursor !== "object" || cursor === null) return false;
|
|
661
|
+
if ("constraint" in cursor && cursor.constraint === "sellable_entities_org_slug_unique") return true;
|
|
662
|
+
if ("message" in cursor && typeof cursor.message === "string" && SLUG_CONFLICT.test(cursor.message)) return true;
|
|
663
|
+
cursor = "cause" in cursor ? cursor.cause : undefined;
|
|
664
|
+
}
|
|
665
|
+
return false;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function errorMessage(error: unknown): string {
|
|
669
|
+
if (typeof error === "string") return error;
|
|
670
|
+
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") return error.message;
|
|
671
|
+
return String(error);
|
|
672
|
+
}
|
|
673
|
+
|
|
639
674
|
/** Mid-import map rows carry this until convergence finishes; must not equal any real remote hash. */
|
|
640
675
|
const PENDING_ENTITY_MAP_SYNC_HASH = "";
|
|
641
676
|
|
|
@@ -1600,6 +1635,22 @@ export class ChannelConnectorService {
|
|
|
1600
1635
|
existingOptionValues.set(row.variantId, list);
|
|
1601
1636
|
}
|
|
1602
1637
|
}
|
|
1638
|
+
// The product's plain base prices, read once. `setBasePrice` upserts and fires its hook even at
|
|
1639
|
+
// the same amount, so an unchanged price is skipped here — and a price that IS written is what
|
|
1640
|
+
// makes the variant "changed", which is what `converged` counts.
|
|
1641
|
+
const storedPrices = item.variants.some((variant) => (variant.prices ?? []).length > 0)
|
|
1642
|
+
? await this.db.select({ variantId: prices.variantId, currency: prices.currency, amount: prices.amount, compareAtAmount: prices.compareAtAmount })
|
|
1643
|
+
.from(prices)
|
|
1644
|
+
.where(and(
|
|
1645
|
+
eq(prices.organizationId, orgId),
|
|
1646
|
+
eq(prices.entityId, entityId),
|
|
1647
|
+
isNull(prices.customerGroupId),
|
|
1648
|
+
isNull(prices.minQuantity),
|
|
1649
|
+
isNull(prices.maxQuantity),
|
|
1650
|
+
isNull(prices.validFrom),
|
|
1651
|
+
isNull(prices.validUntil),
|
|
1652
|
+
))
|
|
1653
|
+
: [];
|
|
1603
1654
|
for (const sourceVariant of item.variants) {
|
|
1604
1655
|
const fullSourceVariant = fullItem.variants.find((variant) => variant.externalId === sourceVariant.externalId) ?? sourceVariant;
|
|
1605
1656
|
let mapping = mappings.find((row) => row.externalId === sourceVariant.externalId);
|
|
@@ -1705,6 +1756,9 @@ export class ChannelConnectorService {
|
|
|
1705
1756
|
}
|
|
1706
1757
|
}
|
|
1707
1758
|
for (const price of sourceVariant.prices ?? []) {
|
|
1759
|
+
const currency = price.currency.trim().toUpperCase();
|
|
1760
|
+
const stored = storedPrices.find((row) => row.variantId === variantId && row.currency === currency);
|
|
1761
|
+
if (stored && stored.amount === price.amount && stored.compareAtAmount === (price.compareAtAmount ?? null)) continue;
|
|
1708
1762
|
const priced = await this.pricing.setBasePrice({
|
|
1709
1763
|
entityId,
|
|
1710
1764
|
variantId,
|
|
@@ -1713,6 +1767,7 @@ export class ChannelConnectorService {
|
|
|
1713
1767
|
compareAtAmount: price.compareAtAmount ?? null,
|
|
1714
1768
|
}, actor);
|
|
1715
1769
|
if (!priced.ok) return PluginErr(priced.error.message);
|
|
1770
|
+
changed = true;
|
|
1716
1771
|
}
|
|
1717
1772
|
// Was UNCONDITIONAL: one UPDATE per variant on every import, including a re-sync where the
|
|
1718
1773
|
// variant is byte-identical. `mapping.syncHash` is already in hand from the select above, so
|
|
@@ -1779,8 +1834,15 @@ export class ChannelConnectorService {
|
|
|
1779
1834
|
item: ChannelCatalogItem,
|
|
1780
1835
|
actor: Actor,
|
|
1781
1836
|
warnings: string[],
|
|
1782
|
-
): Promise<PluginResult<
|
|
1837
|
+
): Promise<PluginResult<{ changed: boolean }>> {
|
|
1783
1838
|
const taxonomy = await this.taxonomyFor(orgId);
|
|
1839
|
+
// The links this entity already has, read only for the classes the item names. A link already
|
|
1840
|
+
// there is not re-written, and a link that is added is what reports the taxonomy as changed.
|
|
1841
|
+
const linkedCategories = new Set((item.categories ?? []).length === 0 ? [] : (await this.db
|
|
1842
|
+
.select({ id: entityCategories.categoryId }).from(entityCategories).where(eq(entityCategories.entityId, entityId))).map((row) => row.id));
|
|
1843
|
+
const linkedBrands = new Set(!item.brand ? [] : (await this.db
|
|
1844
|
+
.select({ id: entityBrands.brandId }).from(entityBrands).where(eq(entityBrands.entityId, entityId))).map((row) => row.id));
|
|
1845
|
+
let changed = false;
|
|
1784
1846
|
const categoryRows = taxonomy.categories;
|
|
1785
1847
|
for (const slug of new Set(item.categories ?? [])) {
|
|
1786
1848
|
let category = categoryRows.find((row) => row.slug === slug);
|
|
@@ -1796,8 +1858,10 @@ export class ChannelConnectorService {
|
|
|
1796
1858
|
category = createdCategory;
|
|
1797
1859
|
categoryRows.push(category);
|
|
1798
1860
|
}
|
|
1861
|
+
if (linkedCategories.has(category.id)) continue;
|
|
1799
1862
|
const linked = await this.catalog.addToCategory(entityId, category.id, actor);
|
|
1800
1863
|
if (!linked.ok) return PluginErr(linked.error.message);
|
|
1864
|
+
changed = true;
|
|
1801
1865
|
}
|
|
1802
1866
|
|
|
1803
1867
|
const brandRows = taxonomy.brands;
|
|
@@ -1811,8 +1875,11 @@ export class ChannelConnectorService {
|
|
|
1811
1875
|
brand = createdBrand;
|
|
1812
1876
|
brandRows.push(brand);
|
|
1813
1877
|
}
|
|
1814
|
-
|
|
1815
|
-
|
|
1878
|
+
if (!linkedBrands.has(brand.id)) {
|
|
1879
|
+
const linked = await this.catalog.addToBrand(entityId, brand.id, actor);
|
|
1880
|
+
if (!linked.ok) return PluginErr(linked.error.message);
|
|
1881
|
+
changed = true;
|
|
1882
|
+
}
|
|
1816
1883
|
}
|
|
1817
1884
|
|
|
1818
1885
|
const tagRows = taxonomy.tags;
|
|
@@ -1827,9 +1894,10 @@ export class ChannelConnectorService {
|
|
|
1827
1894
|
if (!tag) return PluginErr(`Tag "${slug}" was not persisted.`);
|
|
1828
1895
|
tagRows.push(tag);
|
|
1829
1896
|
}
|
|
1830
|
-
await this.db.insert(entityTags).values({ entityId, tagId: tag.id }).onConflictDoNothing();
|
|
1897
|
+
const added = await this.db.insert(entityTags).values({ entityId, tagId: tag.id }).onConflictDoNothing().returning({ tagId: entityTags.tagId });
|
|
1898
|
+
if (added.length > 0) changed = true;
|
|
1831
1899
|
}
|
|
1832
|
-
return Ok(
|
|
1900
|
+
return Ok({ changed });
|
|
1833
1901
|
}
|
|
1834
1902
|
|
|
1835
1903
|
private async applyMedia(
|
|
@@ -2050,6 +2118,65 @@ export class ChannelConnectorService {
|
|
|
2050
2118
|
return rows[0] as ConnectedStore | undefined;
|
|
2051
2119
|
}
|
|
2052
2120
|
|
|
2121
|
+
/**
|
|
2122
|
+
* The slug each wanted handle takes for THIS store. Slugs stay unique across the organization —
|
|
2123
|
+
* the storefront resolves `/:idOrSlug` org-wide — but one platform organization holds many
|
|
2124
|
+
* merchants, and two of them may sell the same handle.
|
|
2125
|
+
*
|
|
2126
|
+
* A handle's FAMILY for a store is, in order: the bare handle, `<handle>-<store suffix>`, and
|
|
2127
|
+
* `<handle>-<store suffix>-<store id prefix>`. The last is unique per store, so a third store with
|
|
2128
|
+
* the same domain label still gets a slug of its own. A new product takes the first member no
|
|
2129
|
+
* other store holds. An existing product whose slug is already in its handle's family KEEPS it
|
|
2130
|
+
* (see `slugToKeep`): a slug, once assigned, is a shared link and is never recomputed.
|
|
2131
|
+
*/
|
|
2132
|
+
private async resolveStoreSlugs(
|
|
2133
|
+
orgId: string,
|
|
2134
|
+
storeId: string,
|
|
2135
|
+
handles: readonly string[],
|
|
2136
|
+
): Promise<Map<string, { slug: string; family: string[] }>> {
|
|
2137
|
+
const wanted = [...new Set(handles)];
|
|
2138
|
+
const resolved = new Map<string, { slug: string; family: string[] }>();
|
|
2139
|
+
if (wanted.length === 0) return resolved;
|
|
2140
|
+
const suffix = await this.storeSlugSuffixFor(orgId, storeId);
|
|
2141
|
+
const idPart = storeId.replace(/[^a-z0-9]/gi, "").slice(0, 8).toLowerCase();
|
|
2142
|
+
const familyOf = (handle: string): string[] => suffix
|
|
2143
|
+
? [handle, `${handle}-${suffix}`, `${handle}-${suffix}-${idPart}`]
|
|
2144
|
+
: [handle, `${handle}-${idPart}`];
|
|
2145
|
+
const families = new Map(wanted.map((handle) => [handle, familyOf(handle)]));
|
|
2146
|
+
const owners = await this.db.select({ slug: sellableEntities.slug, sourceStoreId: sellableEntities.sourceStoreId })
|
|
2147
|
+
.from(sellableEntities)
|
|
2148
|
+
.where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.slug, [...families.values()].flat())));
|
|
2149
|
+
// Only ANOTHER STORE's product moves this one to a qualified slug. A product a person made in
|
|
2150
|
+
// Merchant Center (no source store) holding the handle stays a loud slug conflict, as before.
|
|
2151
|
+
const heldElsewhere = new Set(owners.filter((row) => row.sourceStoreId !== null && row.sourceStoreId !== storeId).map((row) => row.slug));
|
|
2152
|
+
for (const [handle, family] of families) {
|
|
2153
|
+
// Every member held by another store is only reachable through a hand-made slug; the last
|
|
2154
|
+
// member is then used anyway and the create fails loudly on the unique index.
|
|
2155
|
+
const slug = family.find((candidate) => !heldElsewhere.has(candidate)) ?? family[family.length - 1] ?? handle;
|
|
2156
|
+
resolved.set(handle, { slug, family });
|
|
2157
|
+
}
|
|
2158
|
+
return resolved;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// ponytail: cached for the service instance's lifetime (one task invocation), so a batched import
|
|
2162
|
+
// reads the store once. A store whose domain changes mid-invocation keeps the old suffix until the
|
|
2163
|
+
// next one — a new slug family only, never a rename of a slug already assigned.
|
|
2164
|
+
private readonly slugSuffixByStore = new Map<string, string>();
|
|
2165
|
+
|
|
2166
|
+
private async storeSlugSuffixFor(orgId: string, storeId: string): Promise<string> {
|
|
2167
|
+
const cached = this.slugSuffixByStore.get(storeId);
|
|
2168
|
+
if (cached !== undefined) return cached;
|
|
2169
|
+
const suffix = storeSlugSuffix((await this.getStoreRecord(orgId, storeId))?.storeDomain ?? "");
|
|
2170
|
+
this.slugSuffixByStore.set(storeId, suffix);
|
|
2171
|
+
return suffix;
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
/** The slug an existing product converges to: its current one while that is still in the family
|
|
2175
|
+
* of the handle the store sends, so a slug is never recomputed out from under a shared link. */
|
|
2176
|
+
private slugToKeep(currentSlug: string, resolved: { slug: string; family: string[] }): string {
|
|
2177
|
+
return resolved.family.includes(currentSlug) ? currentSlug : resolved.slug;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2053
2180
|
async getStoreByDomain(shopDomain: string): Promise<ConnectedStore | undefined> {
|
|
2054
2181
|
const rows = await this.db
|
|
2055
2182
|
.select()
|
|
@@ -2796,13 +2923,15 @@ export class ChannelConnectorService {
|
|
|
2796
2923
|
inArray(channelEntityMap.externalId, externalIds),
|
|
2797
2924
|
));
|
|
2798
2925
|
const mappingByExternalId = new Map(mappings.map((row) => [row.externalId, row]));
|
|
2799
|
-
const
|
|
2926
|
+
const slugFor = await this.resolveStoreSlugs(orgId, storeId, items.map((item) => item.slug));
|
|
2927
|
+
const slugs = [...new Set([...slugFor.values()].flatMap((resolved) => resolved.family))];
|
|
2800
2928
|
const orphans = slugs.length === 0 ? [] : await this.db.select({ slug: sellableEntities.slug }).from(sellableEntities).where(and(
|
|
2801
2929
|
eq(sellableEntities.organizationId, orgId),
|
|
2802
2930
|
eq(sellableEntities.sourceStoreId, storeId),
|
|
2803
2931
|
inArray(sellableEntities.slug, slugs),
|
|
2804
2932
|
));
|
|
2805
2933
|
const orphanSlugs = new Set(orphans.map((row) => row.slug));
|
|
2934
|
+
const isOrphan = (handle: string): boolean => (slugFor.get(handle)?.family ?? [handle]).some((slug) => orphanSlugs.has(slug));
|
|
2806
2935
|
|
|
2807
2936
|
const fresh: ChannelCatalogItem[] = [];
|
|
2808
2937
|
const editor: ChannelCatalogItem[] = [];
|
|
@@ -2817,7 +2946,7 @@ export class ChannelConnectorService {
|
|
|
2817
2946
|
if (mapping && mapping.syncHash === hash(item)) {
|
|
2818
2947
|
entityByExternalId.set(item.externalId, mapping.entityId);
|
|
2819
2948
|
unchanged += 1;
|
|
2820
|
-
} else if (mapping ||
|
|
2949
|
+
} else if (mapping || isOrphan(item.slug)) {
|
|
2821
2950
|
editor.push(item);
|
|
2822
2951
|
} else {
|
|
2823
2952
|
fresh.push(item);
|
|
@@ -2839,31 +2968,58 @@ export class ChannelConnectorService {
|
|
|
2839
2968
|
}
|
|
2840
2969
|
|
|
2841
2970
|
const createdItems: Array<{ item: ChannelCatalogItem; entityId: string; variantIds: Record<string, string> }> = [];
|
|
2842
|
-
|
|
2843
|
-
|
|
2971
|
+
// Two stores onboarding at once can both resolve a handle as free and then both create it. The
|
|
2972
|
+
// loser's collision is transient: re-resolve those items once (the winner is now visible, so they
|
|
2973
|
+
// take the store-qualified slug) and try again. A second collision is reported as the item's.
|
|
2974
|
+
let pending = fresh;
|
|
2975
|
+
for (let attempt = 0; pending.length > 0; attempt += 1) {
|
|
2976
|
+
// The slug is resolved on the import row only: the map row's hash stays the hash of the item
|
|
2977
|
+
// as the store sent it, so the next page still reads it as unchanged.
|
|
2978
|
+
const report = await this.catalog.importProducts(
|
|
2979
|
+
pending.map((item) => toImportProduct({ ...item, slug: slugFor.get(item.slug)?.slug ?? item.slug })),
|
|
2980
|
+
{ sourceStoreId: storeId, errorPolicy: "reject-failed-rows" },
|
|
2981
|
+
actor,
|
|
2982
|
+
);
|
|
2844
2983
|
if (!report.ok) return PluginErr(report.error.message, report.error.code);
|
|
2984
|
+
const collided: Array<{ item: ChannelCatalogItem; failure: CatalogConvergenceFailure }> = [];
|
|
2845
2985
|
for (const [index, row] of report.value.rows.entries()) {
|
|
2846
|
-
const item =
|
|
2986
|
+
const item = pending[index];
|
|
2847
2987
|
if (!item) continue;
|
|
2848
2988
|
if (row.status === "failed") {
|
|
2849
|
-
|
|
2989
|
+
const failure = { externalId: row.ref, error: `${row.code}: ${row.error}` };
|
|
2990
|
+
const lostSlug = row.code === "slug-conflict" || (row.code === "conflict" && isSlugConflict(row.error));
|
|
2991
|
+
if (attempt === 0 && lostSlug) collided.push({ item, failure });
|
|
2992
|
+
else failures.push(failure);
|
|
2850
2993
|
continue;
|
|
2851
2994
|
}
|
|
2852
2995
|
warnings.push(...row.warnings);
|
|
2853
2996
|
entityByExternalId.set(item.externalId, row.entityId);
|
|
2854
2997
|
createdItems.push({ item, entityId: row.entityId, variantIds: row.variantIds });
|
|
2855
2998
|
}
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
await this.
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2999
|
+
pending = [];
|
|
3000
|
+
if (collided.length > 0) {
|
|
3001
|
+
const again = await this.resolveStoreSlugs(orgId, storeId, collided.map(({ item }) => item.slug));
|
|
3002
|
+
for (const { item, failure } of collided) {
|
|
3003
|
+
const before = slugFor.get(item.slug)?.slug ?? item.slug;
|
|
3004
|
+
const after = again.get(item.slug);
|
|
3005
|
+
// Retry only when another STORE took the handle: a slug held by a product made in
|
|
3006
|
+
// Merchant Center resolves to the same slug again, and stays the loud conflict it was.
|
|
3007
|
+
if (after === undefined || after.slug === before) { failures.push(failure); continue; }
|
|
3008
|
+
slugFor.set(item.slug, after);
|
|
3009
|
+
pending.push(item);
|
|
3010
|
+
}
|
|
2865
3011
|
}
|
|
2866
3012
|
}
|
|
3013
|
+
if (createdItems.length > 0) {
|
|
3014
|
+
const now = new Date();
|
|
3015
|
+
await this.db.insert(channelEntityMap).values(createdItems.flatMap(({ item, entityId, variantIds }) => [
|
|
3016
|
+
{ organizationId: orgId, storeId, kind: "entity" as const, externalId: item.externalId, entityId, syncHash: hash(item), lastSyncedAt: now },
|
|
3017
|
+
...item.variants.flatMap((variant) => {
|
|
3018
|
+
const variantId = variantIds[variant.externalId];
|
|
3019
|
+
return variantId === undefined ? [] : [{ organizationId: orgId, storeId, kind: "variant" as const, externalId: variant.externalId, entityId, variantId, syncHash: hash(variant), lastSyncedAt: now }];
|
|
3020
|
+
}),
|
|
3021
|
+
])).onConflictDoNothing();
|
|
3022
|
+
}
|
|
2867
3023
|
|
|
2868
3024
|
const media = await this.importHeroes(orgId, createdItems, actor);
|
|
2869
3025
|
const entityIds: string[] = [];
|
|
@@ -3486,6 +3642,8 @@ export class ChannelConnectorService {
|
|
|
3486
3642
|
const failures: CatalogConvergenceFailure[] = [];
|
|
3487
3643
|
const entityIds: string[] = [];
|
|
3488
3644
|
const committed = new Set<string>();
|
|
3645
|
+
// One resolution for the whole batch, not two round trips per changed product.
|
|
3646
|
+
const slugFor = await this.resolveStoreSlugs(orgId, storeId, items.map((item) => item.slug));
|
|
3489
3647
|
for (const item of items) {
|
|
3490
3648
|
consumed += 1;
|
|
3491
3649
|
try {
|
|
@@ -3522,43 +3680,61 @@ export class ChannelConnectorService {
|
|
|
3522
3680
|
existingEntity = entity;
|
|
3523
3681
|
}
|
|
3524
3682
|
}
|
|
3683
|
+
let resolvedSlug = slugFor.get(item.slug) ?? { slug: item.slug, family: [item.slug] };
|
|
3684
|
+
// A slug lost to a concurrent writer (another store onboarding the same handle) is transient:
|
|
3685
|
+
// re-resolve once, and the winner being visible now moves this item to its qualified slug.
|
|
3686
|
+
const reresolveSlug = async (): Promise<void> => {
|
|
3687
|
+
resolvedSlug = (await this.resolveStoreSlugs(orgId, storeId, [item.slug])).get(item.slug) ?? resolvedSlug;
|
|
3688
|
+
slugFor.set(item.slug, resolvedSlug);
|
|
3689
|
+
};
|
|
3525
3690
|
if (entityId === undefined) {
|
|
3526
3691
|
const [orphan] = await this.db.select().from(sellableEntities).where(and(
|
|
3527
3692
|
eq(sellableEntities.organizationId, orgId),
|
|
3528
3693
|
eq(sellableEntities.sourceStoreId, storeId),
|
|
3529
|
-
|
|
3530
|
-
));
|
|
3694
|
+
inArray(sellableEntities.slug, resolvedSlug.family),
|
|
3695
|
+
)).limit(1);
|
|
3531
3696
|
if (orphan) {
|
|
3532
3697
|
entityId = orphan.id;
|
|
3533
3698
|
existingEntity = orphan;
|
|
3534
3699
|
adoptedOrphan = true;
|
|
3535
3700
|
} else {
|
|
3536
3701
|
const status = item.status;
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
forcedPushFieldPaths: [],
|
|
3557
|
-
});
|
|
3558
|
-
return created.value.id;
|
|
3702
|
+
const createEntity = (slug: string): Promise<string> => this.transact(async (tx) => {
|
|
3703
|
+
const txContext = createTxContext(tx, { actor });
|
|
3704
|
+
const created = await this.catalog.create({
|
|
3705
|
+
type: "product",
|
|
3706
|
+
slug,
|
|
3707
|
+
sourceStoreId: storeId,
|
|
3708
|
+
metadata: mergeMetadata(undefined, item.metadata ?? {}),
|
|
3709
|
+
...(status !== undefined ? { status, isVisible: status === "active" } : {}),
|
|
3710
|
+
}, actor, txContext);
|
|
3711
|
+
if (!created.ok) throw new Error(created.error.message);
|
|
3712
|
+
await tx.insert(channelEntityMap).values({
|
|
3713
|
+
organizationId: orgId,
|
|
3714
|
+
storeId,
|
|
3715
|
+
kind: "entity",
|
|
3716
|
+
externalId: item.externalId,
|
|
3717
|
+
entityId: created.value.id,
|
|
3718
|
+
syncHash: PENDING_ENTITY_MAP_SYNC_HASH,
|
|
3719
|
+
heldFieldPaths: [],
|
|
3720
|
+
forcedPushFieldPaths: [],
|
|
3559
3721
|
});
|
|
3560
|
-
|
|
3561
|
-
|
|
3722
|
+
return created.value.id;
|
|
3723
|
+
});
|
|
3724
|
+
let createError: unknown;
|
|
3725
|
+
for (let attempt = 0; attempt < 2 && entityId === undefined; attempt += 1) {
|
|
3726
|
+
try {
|
|
3727
|
+
entityId = await createEntity(resolvedSlug.slug);
|
|
3728
|
+
} catch (error) {
|
|
3729
|
+
createError = error;
|
|
3730
|
+
if (attempt > 0 || !isSlugConflict(error)) break;
|
|
3731
|
+
const before = resolvedSlug.slug;
|
|
3732
|
+
await reresolveSlug();
|
|
3733
|
+
if (resolvedSlug.slug === before) break;
|
|
3734
|
+
}
|
|
3735
|
+
}
|
|
3736
|
+
if (entityId === undefined) {
|
|
3737
|
+
failures.push({ externalId: item.externalId, error: createError instanceof Error ? createError.message : "Failed to create catalog entity." });
|
|
3562
3738
|
continue;
|
|
3563
3739
|
}
|
|
3564
3740
|
isNew = true;
|
|
@@ -3618,8 +3794,9 @@ export class ChannelConnectorService {
|
|
|
3618
3794
|
status?: string;
|
|
3619
3795
|
isVisible?: boolean;
|
|
3620
3796
|
} = {};
|
|
3621
|
-
|
|
3622
|
-
|
|
3797
|
+
const keptSlug = this.slugToKeep(existingEntity.slug, resolvedSlug);
|
|
3798
|
+
if (ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && existingEntity.slug !== keptSlug) {
|
|
3799
|
+
updateInput.slug = keptSlug;
|
|
3623
3800
|
}
|
|
3624
3801
|
if (hash(remoteMetadata) !== hash(existingEntity.metadata ?? {})) updateInput.metadata = remoteMetadata;
|
|
3625
3802
|
if (remoteStatus !== undefined && !blockedPaths.has("entity.status") && remoteStatus !== existingEntity.status) {
|
|
@@ -3630,10 +3807,25 @@ export class ChannelConnectorService {
|
|
|
3630
3807
|
? Object.keys(updateInput).length > 0
|
|
3631
3808
|
: remoteChanged || existingEntity.status === "archived";
|
|
3632
3809
|
if (shouldUpdate) {
|
|
3633
|
-
converged += 1;
|
|
3634
3810
|
if (Object.keys(updateInput).length > 0) {
|
|
3635
|
-
const
|
|
3636
|
-
|
|
3811
|
+
const mappedEntityId = entityMapping.entityId;
|
|
3812
|
+
const update = async (): Promise<unknown> => {
|
|
3813
|
+
try {
|
|
3814
|
+
const updated = await this.catalog.update(mappedEntityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
|
|
3815
|
+
return updated.ok ? undefined : updated.error;
|
|
3816
|
+
} catch (error) {
|
|
3817
|
+
return error;
|
|
3818
|
+
}
|
|
3819
|
+
};
|
|
3820
|
+
let updateError = await update();
|
|
3821
|
+
if (updateError !== undefined && updateInput.slug !== undefined && isSlugConflict(updateError)) {
|
|
3822
|
+
await reresolveSlug();
|
|
3823
|
+
const retrySlug = this.slugToKeep(existingEntity.slug, resolvedSlug);
|
|
3824
|
+
if (retrySlug === existingEntity.slug) delete updateInput.slug;
|
|
3825
|
+
else updateInput.slug = retrySlug;
|
|
3826
|
+
updateError = Object.keys(updateInput).length > 0 ? await update() : undefined;
|
|
3827
|
+
}
|
|
3828
|
+
if (updateError !== undefined) { failures.push({ externalId: item.externalId, error: errorMessage(updateError) }); continue; }
|
|
3637
3829
|
entityTouched = true;
|
|
3638
3830
|
}
|
|
3639
3831
|
}
|
|
@@ -3654,7 +3846,9 @@ export class ChannelConnectorService {
|
|
|
3654
3846
|
!heldSharedPaths.includes("options") && owners.get("options") !== "platform",
|
|
3655
3847
|
item,
|
|
3656
3848
|
);
|
|
3657
|
-
|
|
3849
|
+
// A variant the store's own data makes unwritable (a sku another of its products holds) is
|
|
3850
|
+
// that item's failure, reported with its error — not a page error the caller would retry.
|
|
3851
|
+
if (!variantIds.ok) { failures.push({ externalId: item.externalId, error: variantIds.error }); continue; }
|
|
3658
3852
|
const taxonomy = await this.applyTaxonomy(orgId, entityId, writable, actor, warnings);
|
|
3659
3853
|
if (!taxonomy.ok) return taxonomy;
|
|
3660
3854
|
const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
|
|
@@ -3663,8 +3857,11 @@ export class ChannelConnectorService {
|
|
|
3663
3857
|
mediaImported += media.value.imported;
|
|
3664
3858
|
variantsGivenOptionValues += variantIds.value.repaired;
|
|
3665
3859
|
skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
|
|
3666
|
-
entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || media.value.changed || attributes.value.changed;
|
|
3860
|
+
entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || taxonomy.value.changed || media.value.changed || attributes.value.changed;
|
|
3667
3861
|
if (entityTouched) entitiesTouched += 1;
|
|
3862
|
+
// Counted from what was written, not from the stored hash: a hash that moved while the
|
|
3863
|
+
// product did not (a blanked map row, a change in how an item serialises) is not drift.
|
|
3864
|
+
if (entityTouched && entityMapping && !isNew) converged += 1;
|
|
3668
3865
|
|
|
3669
3866
|
if (entityTouched) {
|
|
3670
3867
|
const revision = await this.catalog.recordEntityRevision(entityId, actor, "import");
|
|
@@ -3800,11 +3997,13 @@ export class ChannelConnectorService {
|
|
|
3800
3997
|
const mapping = mappings.find((entry) => entry.externalId === level.externalId);
|
|
3801
3998
|
if (!mapping) continue;
|
|
3802
3999
|
const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
|
|
3803
|
-
|
|
4000
|
+
// Stock cannot sit below zero here, so negative remote stock compares as the zero it is stored as.
|
|
4001
|
+
const quantity = Math.max(0, level.available);
|
|
4002
|
+
if (current?.quantityOnHand === quantity) continue;
|
|
3804
4003
|
const result = await inventoryService.setAbsolute({
|
|
3805
4004
|
entityId: mapping.entityId,
|
|
3806
4005
|
...(mapping.variantId ? { variantId: mapping.variantId } : {}),
|
|
3807
|
-
quantity
|
|
4006
|
+
quantity,
|
|
3808
4007
|
reason: `Inventory reconciliation from ${store.provider}`,
|
|
3809
4008
|
}, actor);
|
|
3810
4009
|
if (!result.ok) return PluginErr(result.error?.message ?? "Inventory reconciliation failed.");
|
|
@@ -4043,11 +4242,13 @@ export class ChannelConnectorService {
|
|
|
4043
4242
|
const mapping = mappings.find((entry) => entry.externalId === level.externalId);
|
|
4044
4243
|
if (!mapping) continue;
|
|
4045
4244
|
const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
|
|
4046
|
-
|
|
4245
|
+
// Stock cannot sit below zero here, so negative remote stock compares as the zero it is stored as.
|
|
4246
|
+
const quantity = Math.max(0, level.available);
|
|
4247
|
+
if (current?.quantityOnHand === quantity) continue;
|
|
4047
4248
|
const result = await inventoryService.setAbsolute({
|
|
4048
4249
|
entityId: mapping.entityId,
|
|
4049
4250
|
...(mapping.variantId ? { variantId: mapping.variantId } : {}),
|
|
4050
|
-
quantity
|
|
4251
|
+
quantity,
|
|
4051
4252
|
reason: `Inventory sync from ${store.provider}`,
|
|
4052
4253
|
}, actor);
|
|
4053
4254
|
if (!result.ok) return PluginErr(result.error?.message ?? "Inventory sync failed.");
|
|
@@ -4327,8 +4528,10 @@ export class ChannelConnectorService {
|
|
|
4327
4528
|
status?: string;
|
|
4328
4529
|
isVisible?: boolean;
|
|
4329
4530
|
} = {};
|
|
4330
|
-
if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") &&
|
|
4331
|
-
|
|
4531
|
+
if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && typeof writable.slug === "string") {
|
|
4532
|
+
const resolved = (await this.resolveStoreSlugs(orgId, storeId, [writable.slug])).get(writable.slug);
|
|
4533
|
+
const slug = resolved ? this.slugToKeep(entity.slug, resolved) : writable.slug;
|
|
4534
|
+
if (entity.slug !== slug) updateInput.slug = slug;
|
|
4332
4535
|
}
|
|
4333
4536
|
if (Object.keys(writable.metadata ?? {}).length > 0) {
|
|
4334
4537
|
const remoteEntityMetadata = mergeMetadata(entity.metadata, writable.metadata ?? {});
|