@porulle/plugin-channel-connector 0.51.0 → 0.53.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/index.d.ts CHANGED
@@ -5,8 +5,6 @@ export { ChannelConnectorService, HERO_IMAGE_BYTE_CAP, selectImportImages, CATAL
5
5
  export { isValidCatalogMappingFieldPath, matchFieldPath, mergeCatalogFieldMapping, normalizeCatalogFieldMapping, compareCatalogFieldMappingSpecificity, providerCatalogFieldMappingDefaults, selectCatalogFieldMapping, validateCatalogMappingRow, } from "./catalog-field-mapping.js";
6
6
  export type { CatalogDeferredMedia, CatalogMediaFailure, CatalogMediaFailureReason, CatalogPageConvergence, ImportImageSelection, } from "./service.js";
7
7
  export type { CatalogFieldMapping, CatalogFieldMappingInput, CatalogFieldMappingRow, CatalogFieldTarget, } from "./catalog-field-mapping.js";
8
- /** ~320 Neon HTTP subrequests per product against a 10,000 per-invocation cap → hard ceiling near 31; 20 leaves margin for heavier products. */
9
- export declare const CHANNEL_IMPORT_MAX_ITEMS_PER_INVOCATION = 20;
10
8
  /**
11
9
  * How many bounded batches one sweep may walk before it refuses rather than loops.
12
10
  *
package/dist/index.js CHANGED
@@ -10,8 +10,6 @@ import { oauthStateEventId, signState, verifyState } from "./oauth-state.js";
10
10
  export { mockChannelConnector } from "./mock-connector.js";
11
11
  export { ChannelConnectorService, HERO_IMAGE_BYTE_CAP, selectImportImages, CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS, CATALOG_PUSH_BATCH_SIZES, CATALOG_PUSH_MAX_ATTEMPTS, canCatalogPushTransition, canExportTransition, catalogPushConcurrencyKey, catalogPushRetryDelayMs, CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION, isCatalogPushBreakerOpen, } from "./service.js";
12
12
  export { isValidCatalogMappingFieldPath, matchFieldPath, mergeCatalogFieldMapping, normalizeCatalogFieldMapping, compareCatalogFieldMappingSpecificity, providerCatalogFieldMappingDefaults, selectCatalogFieldMapping, validateCatalogMappingRow, } from "./catalog-field-mapping.js";
13
- /** ~320 Neon HTTP subrequests per product against a 10,000 per-invocation cap → hard ceiling near 31; 20 leaves margin for heavier products. */
14
- export const CHANNEL_IMPORT_MAX_ITEMS_PER_INVOCATION = 20;
15
13
  /**
16
14
  * How many bounded batches one sweep may walk before it refuses rather than loops.
17
15
  *
@@ -169,67 +167,6 @@ export function channelConnectorPlugin(options = {}) {
169
167
  return { output: { enqueued: stores.length } };
170
168
  },
171
169
  },
172
- {
173
- slug: "channel/import-catalog",
174
- concurrency: { key: (input) => String(input.storeId), supersedes: true },
175
- durableSteps: true,
176
- handler: async ({ input, ctx }) => {
177
- const service = new ChannelConnectorService(ctx.db, ctx.services, options);
178
- const orgId = String(input.orgId);
179
- const storeId = String(input.storeId);
180
- const result = await walkBatches(ctx, "import-catalog", storeId, async () => {
181
- const page = await service.importCatalog(orgId, storeId, createSystemActor(orgId), {
182
- maxItems: CHANNEL_IMPORT_MAX_ITEMS_PER_INVOCATION,
183
- });
184
- if (!page.ok)
185
- throw new Error(page.error);
186
- return {
187
- exhausted: page.value.exhausted,
188
- counted: page.value.imported,
189
- cursor: page.value.cursor ?? null,
190
- ...(page.value.warnings ? { warnings: page.value.warnings } : {}),
191
- // UNCONDITIONAL, unlike `warnings` and `failures` beside it. The step's return value is
192
- // the only per-batch seam a host application has — `walkBatches` keeps just `last` — so
193
- // this is where a page gets its identity. Omitting it when empty would make "this batch
194
- // committed nothing" and "this plugin build does not report entities" the same
195
- // `undefined` at the seam, and a caller that collapses those enqueues nothing and
196
- // reports success. The service's own return declares it unconditional for the same
197
- // reason; dropping it here would have undone that one line later.
198
- entityIds: page.value.entityIds,
199
- ...(page.value.failures ? { failures: page.value.failures } : {}),
200
- };
201
- });
202
- const jobs = ctx.services.jobs;
203
- // The catalog is always exhausted by the time the walk above returns, so this hand-off is
204
- // unconditional. It is the ONE enqueue this task is allowed: it starts a DIFFERENT task
205
- // once, spending a single level of the request chain's 32, rather than one per page the
206
- // way the continuation it replaced did.
207
- //
208
- // A finished catalog is not a usable one. `importCatalog` writes entities, variants and
209
- // prices and never touches `inventory_levels`, so a store whose sweep ends here has a
210
- // catalog in which every variant rolls up as out of stock — which is what a consumer
211
- // projection publishes and what a shopper is shown.
212
- //
213
- // Inventory used to arrive from `reconcile`, reachable only through the
214
- // `channel/reconcile-sweep` cron. A deployment that removes its crons therefore loses a
215
- // data-plane write silently, with every suite still green. Levelling inventory here keeps
216
- // it inside the one operator action — "import this store" — instead of behind a second
217
- // one somebody has to remember.
218
- await jobs.enqueue("channel/sync-inventory", { orgId, storeId }, {
219
- organizationId: orgId,
220
- concurrencyKey: storeId,
221
- });
222
- return {
223
- output: {
224
- imported: result.counted,
225
- cursor: result.last.cursor ?? null,
226
- exhausted: true,
227
- batches: result.batches,
228
- ...(result.warnings.length > 0 ? { warnings: result.warnings } : {}),
229
- },
230
- };
231
- },
232
- },
233
170
  {
234
171
  slug: "channel/backfill-catalog",
235
172
  concurrency: { key: (input) => String(input.storeId) },
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;
@@ -378,7 +392,6 @@ export declare class ChannelConnectorService {
378
392
  storeDomain: string;
379
393
  webhookSecret?: string;
380
394
  }): Promise<PluginResult<PublicConnectedStore>>;
381
- private get optionsJobs();
382
395
  disconnectStore(orgId: string, id: string): Promise<PluginResult<PublicConnectedStore>>;
383
396
  disconnectStoreSystem(orgId: string, id: string, redactDomain?: boolean): Promise<PluginResult<PublicConnectedStore>>;
384
397
  getStore(orgId: string, id: string): Promise<PluginResult<PublicConnectedStore>>;
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
- async persistCatalogConflicts(orgId, conflicts, changedBy) {
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: "Shared catalog field changed on both sides.",
967
+ reason,
820
968
  changedBy,
821
969
  });
822
970
  }
@@ -1978,19 +2126,10 @@ export class ChannelConnectorService {
1978
2126
  return PluginErr(registration.error.message, "CONNECTOR_REGISTRATION_FAILED");
1979
2127
  }
1980
2128
  }
1981
- const jobs = this.optionsJobs;
1982
- if (jobs) {
1983
- await jobs.enqueue("channel/import-catalog", { orgId, storeId: rows[0].id }, {
1984
- organizationId: orgId,
1985
- concurrencyKey: rows[0].id,
1986
- supersedes: true,
1987
- });
1988
- }
2129
+ // Connecting starts no import. The host's operator route starts one (and levels inventory after
2130
+ // it); connect used to enqueue a second, sequential walk that ran beside the host's own.
1989
2131
  return Ok(redactStore(store));
1990
2132
  }
1991
- get optionsJobs() {
1992
- return this.jobs;
1993
- }
1994
2133
  async disconnectStore(orgId, id) {
1995
2134
  return this.disconnectStoreSystem(orgId, id);
1996
2135
  }
@@ -2743,6 +2882,9 @@ export class ChannelConnectorService {
2743
2882
  const committed = new Set();
2744
2883
  // One resolution for the whole batch, not two round trips per changed product.
2745
2884
  const slugFor = await this.resolveStoreSlugs(orgId, storeId, items.map((item) => item.slug));
2885
+ // Upstream SKU/barcode changes on already-mapped variants, for the whole batch at once, so that
2886
+ // variants exchanging SKUs across products in this batch land together.
2887
+ const identity = await this.applyUpstreamVariantIdentity(orgId, storeId, items);
2746
2888
  for (const item of items) {
2747
2889
  consumed += 1;
2748
2890
  try {
@@ -2846,7 +2988,11 @@ export class ChannelConnectorService {
2846
2988
  // An unchanged remote item writes nothing and advances no baseline:
2847
2989
  // converging a stale replay would revert local edits to shared and
2848
2990
  // unowned fields that the store never actually changed.
2849
- if (!force && !remoteChanged && existingEntity && existingEntity.status !== "archived") {
2991
+ // ...unless its SKUs moved this batch, clashed, or were waiting on an open conflict: a swap that
2992
+ // spanned two import pages leaves both map hashes current, and only this pass can close it.
2993
+ const identityPending = identity.written.has(item.externalId) || identity.clashes.has(item.externalId)
2994
+ || identity.openSkuConflicts.has(entityId);
2995
+ if (!force && !remoteChanged && !identityPending && existingEntity && existingEntity.status !== "archived") {
2850
2996
  continue;
2851
2997
  }
2852
2998
  const shared = existingEntity
@@ -2943,7 +3089,33 @@ export class ChannelConnectorService {
2943
3089
  mediaImported += media.value.imported;
2944
3090
  variantsGivenOptionValues += variantIds.value.repaired;
2945
3091
  skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
2946
- entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || taxonomy.value.changed || media.value.changed || attributes.value.changed;
3092
+ entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || taxonomy.value.changed || media.value.changed || attributes.value.changed
3093
+ || identity.written.has(item.externalId);
3094
+ const skuClashes = identity.clashes.get(item.externalId);
3095
+ if (skuClashes) {
3096
+ // Loud, not fatal: the product converges everything else, its SKU keeps the old value, and
3097
+ // the next reconcile — which sees the whole catalogue in one batch — resolves a swap that
3098
+ // spanned two import pages and closes this conflict.
3099
+ const conflict = {
3100
+ entityId,
3101
+ storeId,
3102
+ fieldPath: "variants.sku",
3103
+ localValueSummary: skuClashes.map((clash) => `${clash.variantExternalId}=${clash.fromSku ?? "(none)"}`).join(", "),
3104
+ remoteValueSummary: skuClashes.map((clash) => `${clash.variantExternalId}=${clash.toSku} (held by ${clash.heldByVariantId ?? "unknown"})`).join(", "),
3105
+ platformValue: Object.fromEntries(skuClashes.map((clash) => [clash.variantExternalId, { sku: clash.fromSku, heldByVariantId: clash.heldByVariantId }])),
3106
+ storeValue: Object.fromEntries(skuClashes.map((clash) => [clash.variantExternalId, clash.toSku])),
3107
+ };
3108
+ const recorded = await this.persistCatalogConflicts(orgId, [conflict], requireUserId(actor), "Upstream SKU is held by another variant of this store.");
3109
+ if (!recorded.ok) {
3110
+ failures.push({ externalId: item.externalId, error: recorded.error });
3111
+ continue;
3112
+ }
3113
+ conflicts.push({ entityId, storeId, fieldPath: "variants.sku", localValueSummary: conflict.localValueSummary, remoteValueSummary: conflict.remoteValueSummary });
3114
+ warnings.push(`Kept the local SKU for entity "${entityId}": ${conflict.remoteValueSummary}.`);
3115
+ }
3116
+ else if (identity.openSkuConflicts.has(entityId)) {
3117
+ await this.resolveSkuConflict(orgId, storeId, entityId, requireUserId(actor));
3118
+ }
2947
3119
  if (entityTouched)
2948
3120
  entitiesTouched += 1;
2949
3121
  // Counted from what was written, not from the stored hash: a hash that moved while the