@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.50.3",
3
+ "version": "0.52.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,15 +19,15 @@
19
19
  "dependencies": {
20
20
  "@hono/zod-openapi": "^1.2.2",
21
21
  "hono": "^4.12.5",
22
- "@porulle/core": "0.50.3"
22
+ "@porulle/core": "0.52.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^24.5.2",
26
26
  "eslint": "^9.39.1",
27
27
  "typescript": "5.9.2",
28
28
  "vitest": "^3.2.4",
29
- "@porulle/typescript-config": "0.1.0",
30
- "@porulle/eslint-config": "0.1.0"
29
+ "@porulle/eslint-config": "0.1.0",
30
+ "@porulle/typescript-config": "0.1.0"
31
31
  },
32
32
  "publishConfig": {
33
33
  "access": "public"
package/src/service.ts CHANGED
@@ -668,6 +668,34 @@ export function isSlugConflict(error: unknown): boolean {
668
668
  return false;
669
669
  }
670
670
 
671
+ /** Postgres 23505 anywhere in a driver error's `cause` chain. */
672
+ function isUniqueViolation(error: unknown): boolean {
673
+ let cursor: unknown = error;
674
+ for (let depth = 0; depth < 5; depth += 1) {
675
+ if (typeof cursor !== "object" || cursor === null) return false;
676
+ if ("code" in cursor && cursor.code === "23505") return true;
677
+ cursor = "cause" in cursor ? cursor.cause : undefined;
678
+ }
679
+ return false;
680
+ }
681
+
682
+ /** An upstream variant SKU the store could not take because another of its variants holds it. */
683
+ interface VariantSkuClash {
684
+ variantExternalId: string;
685
+ fromSku: string | null;
686
+ toSku: string;
687
+ heldByVariantId: string | null;
688
+ }
689
+
690
+ interface VariantIdentityOutcome {
691
+ /** Items (by externalId) at least one of whose variants had its sku or barcode written. */
692
+ written: Set<string>;
693
+ /** Items (by externalId) with an upstream SKU that could not be taken, per variant. */
694
+ clashes: Map<string, VariantSkuClash[]>;
695
+ /** Entities that had an OPEN `variants.sku` conflict before this batch. */
696
+ openSkuConflicts: Set<string>;
697
+ }
698
+
671
699
  function errorMessage(error: unknown): string {
672
700
  if (typeof error === "string") return error;
673
701
  if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") return error.message;
@@ -1406,10 +1434,161 @@ export class ChannelConnectorService {
1406
1434
  return { paths: changed, conflicts };
1407
1435
  }
1408
1436
 
1437
+ /**
1438
+ * Apply upstream SKU and barcode changes to variants this store already maps, for a whole batch.
1439
+ *
1440
+ * SKU is unique per source store, so variants EXCHANGING SKUs cannot be updated one at a time.
1441
+ * One transaction first releases every SKU that is changing (NULL is outside the unique index),
1442
+ * then sets the new values, so any swap inside the batch lands atomically. If that fails on a
1443
+ * unique violation — a new SKU is held by a variant whose upstream did not change, or by a
1444
+ * variant in a batch not converged yet — each entity is retried alone, then each variant alone,
1445
+ * and the SKU that still cannot be taken is returned as a clash. A clash is the caller's to
1446
+ * record as a conflict; it never fails the item, which still converges every other field.
1447
+ */
1448
+ private async applyUpstreamVariantIdentity(
1449
+ orgId: string,
1450
+ storeId: string,
1451
+ items: readonly ChannelCatalogItem[],
1452
+ ): Promise<VariantIdentityOutcome> {
1453
+ const outcome: VariantIdentityOutcome = { written: new Set(), clashes: new Map(), openSkuConflicts: new Set() };
1454
+ const externalIds = [...new Set(items.flatMap((item) => item.variants.map((variant) => variant.externalId)))];
1455
+ if (externalIds.length === 0) return outcome;
1456
+ const mapped = await this.db.select({
1457
+ externalId: channelEntityMap.externalId,
1458
+ variantId: variants.id,
1459
+ entityId: variants.entityId,
1460
+ sku: variants.sku,
1461
+ barcode: variants.barcode,
1462
+ }).from(channelEntityMap)
1463
+ .innerJoin(variants, eq(variants.id, channelEntityMap.variantId))
1464
+ .where(and(
1465
+ eq(channelEntityMap.organizationId, orgId),
1466
+ eq(channelEntityMap.storeId, storeId),
1467
+ eq(channelEntityMap.kind, "variant"),
1468
+ inArray(channelEntityMap.externalId, externalIds),
1469
+ ));
1470
+ if (mapped.length === 0) return outcome;
1471
+ const byExternalId = new Map(mapped.map((row) => [row.externalId, row]));
1472
+ const entityIds = [...new Set(mapped.map((row) => row.entityId))];
1473
+ const openRows = await this.db.select({ entityId: channelCatalogConflicts.entityId }).from(channelCatalogConflicts).where(and(
1474
+ eq(channelCatalogConflicts.storeId, storeId),
1475
+ eq(channelCatalogConflicts.fieldPath, "variants.sku"),
1476
+ eq(channelCatalogConflicts.state, "open"),
1477
+ inArray(channelCatalogConflicts.entityId, entityIds),
1478
+ ));
1479
+ for (const row of openRows) outcome.openSkuConflicts.add(row.entityId);
1480
+
1481
+ type Write = { itemExternalId: string; entityId: string; variantId: string; variantExternalId: string; fromSku: string | null; toSku?: string; toBarcode?: string };
1482
+ const candidates: Write[] = [];
1483
+ for (const item of items) {
1484
+ for (const variant of item.variants) {
1485
+ const row = byExternalId.get(variant.externalId);
1486
+ if (!row) continue;
1487
+ const toSku = variant.sku !== undefined && variant.sku !== row.sku ? variant.sku : undefined;
1488
+ const toBarcode = variant.barcode !== undefined && variant.barcode !== row.barcode ? variant.barcode : undefined;
1489
+ if (toSku === undefined && toBarcode === undefined) continue;
1490
+ candidates.push({
1491
+ itemExternalId: item.externalId, entityId: row.entityId, variantId: row.variantId, variantExternalId: variant.externalId, fromSku: row.sku,
1492
+ ...(toSku !== undefined ? { toSku } : {}), ...(toBarcode !== undefined ? { toBarcode } : {}),
1493
+ });
1494
+ }
1495
+ }
1496
+ if (candidates.length === 0) return outcome;
1497
+
1498
+ // Ownership, per entity that has a change: a platform-owned or held path stays local.
1499
+ const candidateEntities = [...new Set(candidates.map((write) => write.entityId))];
1500
+ const held = new Map((await this.db.select({ entityId: channelEntityMap.entityId, held: channelEntityMap.heldFieldPaths }).from(channelEntityMap).where(and(
1501
+ eq(channelEntityMap.storeId, storeId),
1502
+ eq(channelEntityMap.kind, "entity"),
1503
+ inArray(channelEntityMap.entityId, candidateEntities),
1504
+ ))).map((row) => [row.entityId, new Set(row.held ?? [])]));
1505
+ const allowed = new Map<string, { sku: boolean; barcode: boolean }>();
1506
+ for (const entityId of candidateEntities) {
1507
+ const owners = await this.catalog.resolveFieldOwners(entityId, storeId);
1508
+ const entityHeld = held.get(entityId) ?? new Set<string>();
1509
+ allowed.set(entityId, {
1510
+ sku: ownerAllows(owners, "variants.sku") && !entityHeld.has("variants.sku"),
1511
+ barcode: ownerAllows(owners, "variants.barcode") && !entityHeld.has("variants.barcode"),
1512
+ });
1513
+ }
1514
+ const writes: Write[] = candidates.flatMap((write) => {
1515
+ const permitted = allowed.get(write.entityId) ?? { sku: false, barcode: false };
1516
+ const next: Write = {
1517
+ itemExternalId: write.itemExternalId, entityId: write.entityId, variantId: write.variantId, variantExternalId: write.variantExternalId, fromSku: write.fromSku,
1518
+ ...(permitted.sku && write.toSku !== undefined ? { toSku: write.toSku } : {}),
1519
+ ...(permitted.barcode && write.toBarcode !== undefined ? { toBarcode: write.toBarcode } : {}),
1520
+ };
1521
+ return next.toSku === undefined && next.toBarcode === undefined ? [] : [next];
1522
+ });
1523
+ if (writes.length === 0) return outcome;
1524
+
1525
+ const apply = (group: readonly Write[]) => this.transact(async (tx) => {
1526
+ const releasing = group.filter((write) => write.toSku !== undefined).map((write) => write.variantId);
1527
+ if (releasing.length > 0) await tx.update(variants).set({ sku: null }).where(inArray(variants.id, releasing));
1528
+ for (const write of group) {
1529
+ await tx.update(variants).set({
1530
+ ...(write.toSku !== undefined ? { sku: write.toSku } : {}),
1531
+ ...(write.toBarcode !== undefined ? { barcode: write.toBarcode } : {}),
1532
+ updatedAt: sql`now()`,
1533
+ }).where(eq(variants.id, write.variantId));
1534
+ }
1535
+ });
1536
+ const landed = (group: readonly Write[]) => { for (const write of group) outcome.written.add(write.itemExternalId); };
1537
+ const attempt = async (group: readonly Write[]): Promise<boolean> => {
1538
+ try {
1539
+ await apply(group);
1540
+ landed(group);
1541
+ return true;
1542
+ } catch (error) {
1543
+ if (!isUniqueViolation(error)) throw error;
1544
+ return false;
1545
+ }
1546
+ };
1547
+
1548
+ if (await attempt(writes)) return outcome;
1549
+ for (const entityId of [...new Set(writes.map((write) => write.entityId))]) {
1550
+ const group = writes.filter((write) => write.entityId === entityId);
1551
+ if (await attempt(group)) continue;
1552
+ for (const write of group) {
1553
+ if (await attempt([write])) continue;
1554
+ // The SKU cannot be taken this batch. The barcode, which nothing else can hold, still lands.
1555
+ if (write.toBarcode !== undefined) {
1556
+ await attempt([{ itemExternalId: write.itemExternalId, entityId: write.entityId, variantId: write.variantId, variantExternalId: write.variantExternalId, fromSku: write.fromSku, toBarcode: write.toBarcode }]);
1557
+ }
1558
+ if (write.toSku === undefined) continue;
1559
+ const [holder] = await this.db.select({ id: variants.id }).from(variants).where(and(eq(variants.sourceStoreId, storeId), eq(variants.sku, write.toSku)));
1560
+ const clashes = outcome.clashes.get(write.itemExternalId) ?? [];
1561
+ clashes.push({ variantExternalId: write.variantExternalId, fromSku: write.fromSku, toSku: write.toSku, heldByVariantId: holder?.id ?? null });
1562
+ outcome.clashes.set(write.itemExternalId, clashes);
1563
+ }
1564
+ }
1565
+ return outcome;
1566
+ }
1567
+
1568
+ /** Close this entity's open `variants.sku` conflict: the SKUs it was waiting on have now landed. */
1569
+ private async resolveSkuConflict(orgId: string, storeId: string, entityId: string, changedBy: string): Promise<void> {
1570
+ const resolved = await this.db.update(channelCatalogConflicts).set({ state: "resolved", resolvedBy: changedBy, updatedAt: new Date() }).where(and(
1571
+ eq(channelCatalogConflicts.storeId, storeId),
1572
+ eq(channelCatalogConflicts.entityId, entityId),
1573
+ eq(channelCatalogConflicts.fieldPath, "variants.sku"),
1574
+ eq(channelCatalogConflicts.state, "open"),
1575
+ )).returning({ id: channelCatalogConflicts.id });
1576
+ if (resolved.length === 0) return;
1577
+ await this.db.insert(channelCatalogConflictEvents).values(resolved.map((row) => ({
1578
+ organizationId: orgId,
1579
+ conflictId: row.id,
1580
+ fromState: "open",
1581
+ toState: "resolved",
1582
+ reason: "The upstream SKUs this product was waiting on have converged.",
1583
+ changedBy,
1584
+ })));
1585
+ }
1586
+
1409
1587
  private async persistCatalogConflicts(
1410
1588
  orgId: string,
1411
1589
  conflicts: DetectedCatalogFieldConflict[],
1412
1590
  changedBy: string,
1591
+ reason = "Shared catalog field changed on both sides.",
1413
1592
  ): Promise<PluginResult<void>> {
1414
1593
  for (const conflict of conflicts) {
1415
1594
  const [inserted] = await this.db.insert(channelCatalogConflicts).values({
@@ -1448,7 +1627,7 @@ export class ChannelConnectorService {
1448
1627
  conflictId: inserted.id,
1449
1628
  fromState: null,
1450
1629
  toState: "open",
1451
- reason: "Shared catalog field changed on both sides.",
1630
+ reason,
1452
1631
  changedBy,
1453
1632
  });
1454
1633
  }
@@ -1743,11 +1922,18 @@ export class ChannelConnectorService {
1743
1922
  const currentIds = [...(existingOptionValues.get(variantId) ?? [])].sort();
1744
1923
  const desiredIds = [...new Set(desiredOptionValueIds)].sort();
1745
1924
  if (currentIds.length !== desiredIds.length || currentIds.some((id, index) => id !== desiredIds[index])) {
1746
- await this.db.delete(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
1747
- if (desiredIds.length > 0) {
1748
- await this.db.insert(variantOptionValues).values(desiredIds.map((optionValueId) => ({ variantId, optionValueId }))).onConflictDoNothing();
1749
- repaired += 1;
1750
- }
1925
+ // Rewrite and bump in ONE transaction: an option-value change is a variant-only change
1926
+ // (the entity row is not touched), and `variants.updated_at` is the only trace a consumer
1927
+ // can version it from. Neither may commit without the other.
1928
+ const optionVariantId = variantId;
1929
+ await this.transact(async (tx) => {
1930
+ await tx.delete(variantOptionValues).where(eq(variantOptionValues.variantId, optionVariantId));
1931
+ if (desiredIds.length > 0) {
1932
+ await tx.insert(variantOptionValues).values(desiredIds.map((optionValueId) => ({ variantId: optionVariantId, optionValueId }))).onConflictDoNothing();
1933
+ }
1934
+ await tx.update(variants).set({ updatedAt: sql`now()` }).where(eq(variants.id, optionVariantId));
1935
+ });
1936
+ if (desiredIds.length > 0) repaired += 1;
1751
1937
  // The map is the read model for this pass; a payload repeating an externalId must not see
1752
1938
  // the pre-fetched state after this write.
1753
1939
  existingOptionValues.set(variantId, [...desiredIds]);
@@ -3681,6 +3867,9 @@ export class ChannelConnectorService {
3681
3867
  const committed = new Set<string>();
3682
3868
  // One resolution for the whole batch, not two round trips per changed product.
3683
3869
  const slugFor = await this.resolveStoreSlugs(orgId, storeId, items.map((item) => item.slug));
3870
+ // Upstream SKU/barcode changes on already-mapped variants, for the whole batch at once, so that
3871
+ // variants exchanging SKUs across products in this batch land together.
3872
+ const identity = await this.applyUpstreamVariantIdentity(orgId, storeId, items);
3684
3873
  for (const item of items) {
3685
3874
  consumed += 1;
3686
3875
  try {
@@ -3791,7 +3980,11 @@ export class ChannelConnectorService {
3791
3980
  // An unchanged remote item writes nothing and advances no baseline:
3792
3981
  // converging a stale replay would revert local edits to shared and
3793
3982
  // unowned fields that the store never actually changed.
3794
- if (!force && !remoteChanged && existingEntity && existingEntity.status !== "archived") {
3983
+ // ...unless its SKUs moved this batch, clashed, or were waiting on an open conflict: a swap that
3984
+ // spanned two import pages leaves both map hashes current, and only this pass can close it.
3985
+ const identityPending = identity.written.has(item.externalId) || identity.clashes.has(item.externalId)
3986
+ || identity.openSkuConflicts.has(entityId);
3987
+ if (!force && !remoteChanged && !identityPending && existingEntity && existingEntity.status !== "archived") {
3795
3988
  continue;
3796
3989
  }
3797
3990
  const shared = existingEntity
@@ -3894,7 +4087,29 @@ export class ChannelConnectorService {
3894
4087
  mediaImported += media.value.imported;
3895
4088
  variantsGivenOptionValues += variantIds.value.repaired;
3896
4089
  skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
3897
- entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || taxonomy.value.changed || media.value.changed || attributes.value.changed;
4090
+ entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || taxonomy.value.changed || media.value.changed || attributes.value.changed
4091
+ || identity.written.has(item.externalId);
4092
+ const skuClashes = identity.clashes.get(item.externalId);
4093
+ if (skuClashes) {
4094
+ // Loud, not fatal: the product converges everything else, its SKU keeps the old value, and
4095
+ // the next reconcile — which sees the whole catalogue in one batch — resolves a swap that
4096
+ // spanned two import pages and closes this conflict.
4097
+ const conflict: DetectedCatalogFieldConflict = {
4098
+ entityId,
4099
+ storeId,
4100
+ fieldPath: "variants.sku",
4101
+ localValueSummary: skuClashes.map((clash) => `${clash.variantExternalId}=${clash.fromSku ?? "(none)"}`).join(", "),
4102
+ remoteValueSummary: skuClashes.map((clash) => `${clash.variantExternalId}=${clash.toSku} (held by ${clash.heldByVariantId ?? "unknown"})`).join(", "),
4103
+ platformValue: Object.fromEntries(skuClashes.map((clash) => [clash.variantExternalId, { sku: clash.fromSku, heldByVariantId: clash.heldByVariantId }])),
4104
+ storeValue: Object.fromEntries(skuClashes.map((clash) => [clash.variantExternalId, clash.toSku])),
4105
+ };
4106
+ const recorded = await this.persistCatalogConflicts(orgId, [conflict], requireUserId(actor), "Upstream SKU is held by another variant of this store.");
4107
+ if (!recorded.ok) { failures.push({ externalId: item.externalId, error: recorded.error }); continue; }
4108
+ conflicts.push({ entityId, storeId, fieldPath: "variants.sku", localValueSummary: conflict.localValueSummary, remoteValueSummary: conflict.remoteValueSummary });
4109
+ warnings.push(`Kept the local SKU for entity "${entityId}": ${conflict.remoteValueSummary}.`);
4110
+ } else if (identity.openSkuConflicts.has(entityId)) {
4111
+ await this.resolveSkuConflict(orgId, storeId, entityId, requireUserId(actor));
4112
+ }
3898
4113
  if (entityTouched) entitiesTouched += 1;
3899
4114
  // Counted from what was written, not from the stored hash: a hash that moved while the
3900
4115
  // product did not (a blanked map row, a change in how an item serialises) is not drift.