@porulle/plugin-channel-connector 0.50.3 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/service.d.ts +14 -0
- package/dist/service.js +197 -8
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/service.ts +223 -8
package/dist/service.d.ts
CHANGED
|
@@ -309,6 +309,20 @@ export declare class ChannelConnectorService {
|
|
|
309
309
|
private localFieldValue;
|
|
310
310
|
private lastSyncedSnapshot;
|
|
311
311
|
private detectSharedConflicts;
|
|
312
|
+
/**
|
|
313
|
+
* Apply upstream SKU and barcode changes to variants this store already maps, for a whole batch.
|
|
314
|
+
*
|
|
315
|
+
* SKU is unique per source store, so variants EXCHANGING SKUs cannot be updated one at a time.
|
|
316
|
+
* One transaction first releases every SKU that is changing (NULL is outside the unique index),
|
|
317
|
+
* then sets the new values, so any swap inside the batch lands atomically. If that fails on a
|
|
318
|
+
* unique violation — a new SKU is held by a variant whose upstream did not change, or by a
|
|
319
|
+
* variant in a batch not converged yet — each entity is retried alone, then each variant alone,
|
|
320
|
+
* and the SKU that still cannot be taken is returned as a clash. A clash is the caller's to
|
|
321
|
+
* record as a conflict; it never fails the item, which still converges every other field.
|
|
322
|
+
*/
|
|
323
|
+
private applyUpstreamVariantIdentity;
|
|
324
|
+
/** Close this entity's open `variants.sku` conflict: the SKUs it was waiting on have now landed. */
|
|
325
|
+
private resolveSkuConflict;
|
|
312
326
|
private persistCatalogConflicts;
|
|
313
327
|
private setCatalogAttributes;
|
|
314
328
|
private setCatalogAttributesIfWritable;
|
package/dist/service.js
CHANGED
|
@@ -153,6 +153,18 @@ export function isSlugConflict(error) {
|
|
|
153
153
|
}
|
|
154
154
|
return false;
|
|
155
155
|
}
|
|
156
|
+
/** Postgres 23505 anywhere in a driver error's `cause` chain. */
|
|
157
|
+
function isUniqueViolation(error) {
|
|
158
|
+
let cursor = error;
|
|
159
|
+
for (let depth = 0; depth < 5; depth += 1) {
|
|
160
|
+
if (typeof cursor !== "object" || cursor === null)
|
|
161
|
+
return false;
|
|
162
|
+
if ("code" in cursor && cursor.code === "23505")
|
|
163
|
+
return true;
|
|
164
|
+
cursor = "cause" in cursor ? cursor.cause : undefined;
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
156
168
|
function errorMessage(error) {
|
|
157
169
|
if (typeof error === "string")
|
|
158
170
|
return error;
|
|
@@ -784,7 +796,143 @@ export class ChannelConnectorService {
|
|
|
784
796
|
}));
|
|
785
797
|
return { paths: changed, conflicts };
|
|
786
798
|
}
|
|
787
|
-
|
|
799
|
+
/**
|
|
800
|
+
* Apply upstream SKU and barcode changes to variants this store already maps, for a whole batch.
|
|
801
|
+
*
|
|
802
|
+
* SKU is unique per source store, so variants EXCHANGING SKUs cannot be updated one at a time.
|
|
803
|
+
* One transaction first releases every SKU that is changing (NULL is outside the unique index),
|
|
804
|
+
* then sets the new values, so any swap inside the batch lands atomically. If that fails on a
|
|
805
|
+
* unique violation — a new SKU is held by a variant whose upstream did not change, or by a
|
|
806
|
+
* variant in a batch not converged yet — each entity is retried alone, then each variant alone,
|
|
807
|
+
* and the SKU that still cannot be taken is returned as a clash. A clash is the caller's to
|
|
808
|
+
* record as a conflict; it never fails the item, which still converges every other field.
|
|
809
|
+
*/
|
|
810
|
+
async applyUpstreamVariantIdentity(orgId, storeId, items) {
|
|
811
|
+
const outcome = { written: new Set(), clashes: new Map(), openSkuConflicts: new Set() };
|
|
812
|
+
const externalIds = [...new Set(items.flatMap((item) => item.variants.map((variant) => variant.externalId)))];
|
|
813
|
+
if (externalIds.length === 0)
|
|
814
|
+
return outcome;
|
|
815
|
+
const mapped = await this.db.select({
|
|
816
|
+
externalId: channelEntityMap.externalId,
|
|
817
|
+
variantId: variants.id,
|
|
818
|
+
entityId: variants.entityId,
|
|
819
|
+
sku: variants.sku,
|
|
820
|
+
barcode: variants.barcode,
|
|
821
|
+
}).from(channelEntityMap)
|
|
822
|
+
.innerJoin(variants, eq(variants.id, channelEntityMap.variantId))
|
|
823
|
+
.where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), inArray(channelEntityMap.externalId, externalIds)));
|
|
824
|
+
if (mapped.length === 0)
|
|
825
|
+
return outcome;
|
|
826
|
+
const byExternalId = new Map(mapped.map((row) => [row.externalId, row]));
|
|
827
|
+
const entityIds = [...new Set(mapped.map((row) => row.entityId))];
|
|
828
|
+
const openRows = await this.db.select({ entityId: channelCatalogConflicts.entityId }).from(channelCatalogConflicts).where(and(eq(channelCatalogConflicts.storeId, storeId), eq(channelCatalogConflicts.fieldPath, "variants.sku"), eq(channelCatalogConflicts.state, "open"), inArray(channelCatalogConflicts.entityId, entityIds)));
|
|
829
|
+
for (const row of openRows)
|
|
830
|
+
outcome.openSkuConflicts.add(row.entityId);
|
|
831
|
+
const candidates = [];
|
|
832
|
+
for (const item of items) {
|
|
833
|
+
for (const variant of item.variants) {
|
|
834
|
+
const row = byExternalId.get(variant.externalId);
|
|
835
|
+
if (!row)
|
|
836
|
+
continue;
|
|
837
|
+
const toSku = variant.sku !== undefined && variant.sku !== row.sku ? variant.sku : undefined;
|
|
838
|
+
const toBarcode = variant.barcode !== undefined && variant.barcode !== row.barcode ? variant.barcode : undefined;
|
|
839
|
+
if (toSku === undefined && toBarcode === undefined)
|
|
840
|
+
continue;
|
|
841
|
+
candidates.push({
|
|
842
|
+
itemExternalId: item.externalId, entityId: row.entityId, variantId: row.variantId, variantExternalId: variant.externalId, fromSku: row.sku,
|
|
843
|
+
...(toSku !== undefined ? { toSku } : {}), ...(toBarcode !== undefined ? { toBarcode } : {}),
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
if (candidates.length === 0)
|
|
848
|
+
return outcome;
|
|
849
|
+
// Ownership, per entity that has a change: a platform-owned or held path stays local.
|
|
850
|
+
const candidateEntities = [...new Set(candidates.map((write) => write.entityId))];
|
|
851
|
+
const held = new Map((await this.db.select({ entityId: channelEntityMap.entityId, held: channelEntityMap.heldFieldPaths }).from(channelEntityMap).where(and(eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), inArray(channelEntityMap.entityId, candidateEntities)))).map((row) => [row.entityId, new Set(row.held ?? [])]));
|
|
852
|
+
const allowed = new Map();
|
|
853
|
+
for (const entityId of candidateEntities) {
|
|
854
|
+
const owners = await this.catalog.resolveFieldOwners(entityId, storeId);
|
|
855
|
+
const entityHeld = held.get(entityId) ?? new Set();
|
|
856
|
+
allowed.set(entityId, {
|
|
857
|
+
sku: ownerAllows(owners, "variants.sku") && !entityHeld.has("variants.sku"),
|
|
858
|
+
barcode: ownerAllows(owners, "variants.barcode") && !entityHeld.has("variants.barcode"),
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
const writes = candidates.flatMap((write) => {
|
|
862
|
+
const permitted = allowed.get(write.entityId) ?? { sku: false, barcode: false };
|
|
863
|
+
const next = {
|
|
864
|
+
itemExternalId: write.itemExternalId, entityId: write.entityId, variantId: write.variantId, variantExternalId: write.variantExternalId, fromSku: write.fromSku,
|
|
865
|
+
...(permitted.sku && write.toSku !== undefined ? { toSku: write.toSku } : {}),
|
|
866
|
+
...(permitted.barcode && write.toBarcode !== undefined ? { toBarcode: write.toBarcode } : {}),
|
|
867
|
+
};
|
|
868
|
+
return next.toSku === undefined && next.toBarcode === undefined ? [] : [next];
|
|
869
|
+
});
|
|
870
|
+
if (writes.length === 0)
|
|
871
|
+
return outcome;
|
|
872
|
+
const apply = (group) => this.transact(async (tx) => {
|
|
873
|
+
const releasing = group.filter((write) => write.toSku !== undefined).map((write) => write.variantId);
|
|
874
|
+
if (releasing.length > 0)
|
|
875
|
+
await tx.update(variants).set({ sku: null }).where(inArray(variants.id, releasing));
|
|
876
|
+
for (const write of group) {
|
|
877
|
+
await tx.update(variants).set({
|
|
878
|
+
...(write.toSku !== undefined ? { sku: write.toSku } : {}),
|
|
879
|
+
...(write.toBarcode !== undefined ? { barcode: write.toBarcode } : {}),
|
|
880
|
+
updatedAt: sql `now()`,
|
|
881
|
+
}).where(eq(variants.id, write.variantId));
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
const landed = (group) => { for (const write of group)
|
|
885
|
+
outcome.written.add(write.itemExternalId); };
|
|
886
|
+
const attempt = async (group) => {
|
|
887
|
+
try {
|
|
888
|
+
await apply(group);
|
|
889
|
+
landed(group);
|
|
890
|
+
return true;
|
|
891
|
+
}
|
|
892
|
+
catch (error) {
|
|
893
|
+
if (!isUniqueViolation(error))
|
|
894
|
+
throw error;
|
|
895
|
+
return false;
|
|
896
|
+
}
|
|
897
|
+
};
|
|
898
|
+
if (await attempt(writes))
|
|
899
|
+
return outcome;
|
|
900
|
+
for (const entityId of [...new Set(writes.map((write) => write.entityId))]) {
|
|
901
|
+
const group = writes.filter((write) => write.entityId === entityId);
|
|
902
|
+
if (await attempt(group))
|
|
903
|
+
continue;
|
|
904
|
+
for (const write of group) {
|
|
905
|
+
if (await attempt([write]))
|
|
906
|
+
continue;
|
|
907
|
+
// The SKU cannot be taken this batch. The barcode, which nothing else can hold, still lands.
|
|
908
|
+
if (write.toBarcode !== undefined) {
|
|
909
|
+
await attempt([{ itemExternalId: write.itemExternalId, entityId: write.entityId, variantId: write.variantId, variantExternalId: write.variantExternalId, fromSku: write.fromSku, toBarcode: write.toBarcode }]);
|
|
910
|
+
}
|
|
911
|
+
if (write.toSku === undefined)
|
|
912
|
+
continue;
|
|
913
|
+
const [holder] = await this.db.select({ id: variants.id }).from(variants).where(and(eq(variants.sourceStoreId, storeId), eq(variants.sku, write.toSku)));
|
|
914
|
+
const clashes = outcome.clashes.get(write.itemExternalId) ?? [];
|
|
915
|
+
clashes.push({ variantExternalId: write.variantExternalId, fromSku: write.fromSku, toSku: write.toSku, heldByVariantId: holder?.id ?? null });
|
|
916
|
+
outcome.clashes.set(write.itemExternalId, clashes);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return outcome;
|
|
920
|
+
}
|
|
921
|
+
/** Close this entity's open `variants.sku` conflict: the SKUs it was waiting on have now landed. */
|
|
922
|
+
async resolveSkuConflict(orgId, storeId, entityId, changedBy) {
|
|
923
|
+
const resolved = await this.db.update(channelCatalogConflicts).set({ state: "resolved", resolvedBy: changedBy, updatedAt: new Date() }).where(and(eq(channelCatalogConflicts.storeId, storeId), eq(channelCatalogConflicts.entityId, entityId), eq(channelCatalogConflicts.fieldPath, "variants.sku"), eq(channelCatalogConflicts.state, "open"))).returning({ id: channelCatalogConflicts.id });
|
|
924
|
+
if (resolved.length === 0)
|
|
925
|
+
return;
|
|
926
|
+
await this.db.insert(channelCatalogConflictEvents).values(resolved.map((row) => ({
|
|
927
|
+
organizationId: orgId,
|
|
928
|
+
conflictId: row.id,
|
|
929
|
+
fromState: "open",
|
|
930
|
+
toState: "resolved",
|
|
931
|
+
reason: "The upstream SKUs this product was waiting on have converged.",
|
|
932
|
+
changedBy,
|
|
933
|
+
})));
|
|
934
|
+
}
|
|
935
|
+
async persistCatalogConflicts(orgId, conflicts, changedBy, reason = "Shared catalog field changed on both sides.") {
|
|
788
936
|
for (const conflict of conflicts) {
|
|
789
937
|
const [inserted] = await this.db.insert(channelCatalogConflicts).values({
|
|
790
938
|
organizationId: orgId,
|
|
@@ -816,7 +964,7 @@ export class ChannelConnectorService {
|
|
|
816
964
|
conflictId: inserted.id,
|
|
817
965
|
fromState: null,
|
|
818
966
|
toState: "open",
|
|
819
|
-
reason
|
|
967
|
+
reason,
|
|
820
968
|
changedBy,
|
|
821
969
|
});
|
|
822
970
|
}
|
|
@@ -1073,11 +1221,19 @@ export class ChannelConnectorService {
|
|
|
1073
1221
|
const currentIds = [...(existingOptionValues.get(variantId) ?? [])].sort();
|
|
1074
1222
|
const desiredIds = [...new Set(desiredOptionValueIds)].sort();
|
|
1075
1223
|
if (currentIds.length !== desiredIds.length || currentIds.some((id, index) => id !== desiredIds[index])) {
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1224
|
+
// Rewrite and bump in ONE transaction: an option-value change is a variant-only change
|
|
1225
|
+
// (the entity row is not touched), and `variants.updated_at` is the only trace a consumer
|
|
1226
|
+
// can version it from. Neither may commit without the other.
|
|
1227
|
+
const optionVariantId = variantId;
|
|
1228
|
+
await this.transact(async (tx) => {
|
|
1229
|
+
await tx.delete(variantOptionValues).where(eq(variantOptionValues.variantId, optionVariantId));
|
|
1230
|
+
if (desiredIds.length > 0) {
|
|
1231
|
+
await tx.insert(variantOptionValues).values(desiredIds.map((optionValueId) => ({ variantId: optionVariantId, optionValueId }))).onConflictDoNothing();
|
|
1232
|
+
}
|
|
1233
|
+
await tx.update(variants).set({ updatedAt: sql `now()` }).where(eq(variants.id, optionVariantId));
|
|
1234
|
+
});
|
|
1235
|
+
if (desiredIds.length > 0)
|
|
1079
1236
|
repaired += 1;
|
|
1080
|
-
}
|
|
1081
1237
|
// The map is the read model for this pass; a payload repeating an externalId must not see
|
|
1082
1238
|
// the pre-fetched state after this write.
|
|
1083
1239
|
existingOptionValues.set(variantId, [...desiredIds]);
|
|
@@ -2735,6 +2891,9 @@ export class ChannelConnectorService {
|
|
|
2735
2891
|
const committed = new Set();
|
|
2736
2892
|
// One resolution for the whole batch, not two round trips per changed product.
|
|
2737
2893
|
const slugFor = await this.resolveStoreSlugs(orgId, storeId, items.map((item) => item.slug));
|
|
2894
|
+
// Upstream SKU/barcode changes on already-mapped variants, for the whole batch at once, so that
|
|
2895
|
+
// variants exchanging SKUs across products in this batch land together.
|
|
2896
|
+
const identity = await this.applyUpstreamVariantIdentity(orgId, storeId, items);
|
|
2738
2897
|
for (const item of items) {
|
|
2739
2898
|
consumed += 1;
|
|
2740
2899
|
try {
|
|
@@ -2838,7 +2997,11 @@ export class ChannelConnectorService {
|
|
|
2838
2997
|
// An unchanged remote item writes nothing and advances no baseline:
|
|
2839
2998
|
// converging a stale replay would revert local edits to shared and
|
|
2840
2999
|
// unowned fields that the store never actually changed.
|
|
2841
|
-
|
|
3000
|
+
// ...unless its SKUs moved this batch, clashed, or were waiting on an open conflict: a swap that
|
|
3001
|
+
// spanned two import pages leaves both map hashes current, and only this pass can close it.
|
|
3002
|
+
const identityPending = identity.written.has(item.externalId) || identity.clashes.has(item.externalId)
|
|
3003
|
+
|| identity.openSkuConflicts.has(entityId);
|
|
3004
|
+
if (!force && !remoteChanged && !identityPending && existingEntity && existingEntity.status !== "archived") {
|
|
2842
3005
|
continue;
|
|
2843
3006
|
}
|
|
2844
3007
|
const shared = existingEntity
|
|
@@ -2935,7 +3098,33 @@ export class ChannelConnectorService {
|
|
|
2935
3098
|
mediaImported += media.value.imported;
|
|
2936
3099
|
variantsGivenOptionValues += variantIds.value.repaired;
|
|
2937
3100
|
skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
|
|
2938
|
-
entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || taxonomy.value.changed || media.value.changed || attributes.value.changed
|
|
3101
|
+
entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || taxonomy.value.changed || media.value.changed || attributes.value.changed
|
|
3102
|
+
|| identity.written.has(item.externalId);
|
|
3103
|
+
const skuClashes = identity.clashes.get(item.externalId);
|
|
3104
|
+
if (skuClashes) {
|
|
3105
|
+
// Loud, not fatal: the product converges everything else, its SKU keeps the old value, and
|
|
3106
|
+
// the next reconcile — which sees the whole catalogue in one batch — resolves a swap that
|
|
3107
|
+
// spanned two import pages and closes this conflict.
|
|
3108
|
+
const conflict = {
|
|
3109
|
+
entityId,
|
|
3110
|
+
storeId,
|
|
3111
|
+
fieldPath: "variants.sku",
|
|
3112
|
+
localValueSummary: skuClashes.map((clash) => `${clash.variantExternalId}=${clash.fromSku ?? "(none)"}`).join(", "),
|
|
3113
|
+
remoteValueSummary: skuClashes.map((clash) => `${clash.variantExternalId}=${clash.toSku} (held by ${clash.heldByVariantId ?? "unknown"})`).join(", "),
|
|
3114
|
+
platformValue: Object.fromEntries(skuClashes.map((clash) => [clash.variantExternalId, { sku: clash.fromSku, heldByVariantId: clash.heldByVariantId }])),
|
|
3115
|
+
storeValue: Object.fromEntries(skuClashes.map((clash) => [clash.variantExternalId, clash.toSku])),
|
|
3116
|
+
};
|
|
3117
|
+
const recorded = await this.persistCatalogConflicts(orgId, [conflict], requireUserId(actor), "Upstream SKU is held by another variant of this store.");
|
|
3118
|
+
if (!recorded.ok) {
|
|
3119
|
+
failures.push({ externalId: item.externalId, error: recorded.error });
|
|
3120
|
+
continue;
|
|
3121
|
+
}
|
|
3122
|
+
conflicts.push({ entityId, storeId, fieldPath: "variants.sku", localValueSummary: conflict.localValueSummary, remoteValueSummary: conflict.remoteValueSummary });
|
|
3123
|
+
warnings.push(`Kept the local SKU for entity "${entityId}": ${conflict.remoteValueSummary}.`);
|
|
3124
|
+
}
|
|
3125
|
+
else if (identity.openSkuConflicts.has(entityId)) {
|
|
3126
|
+
await this.resolveSkuConflict(orgId, storeId, entityId, requireUserId(actor));
|
|
3127
|
+
}
|
|
2939
3128
|
if (entityTouched)
|
|
2940
3129
|
entitiesTouched += 1;
|
|
2941
3130
|
// Counted from what was written, not from the stored hash: a hash that moved while the
|