@porulle/plugin-channel-connector 0.44.0 → 0.46.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
@@ -22,7 +22,7 @@ export declare const CHANNEL_IMPORT_MAX_ITEMS_PER_INVOCATION = 20;
22
22
  */
23
23
  export declare const CHANNEL_MAX_BATCHES_PER_SWEEP = 5000;
24
24
  export { signState, verifyState } from "./oauth-state.js";
25
- export type { BackfillCatalogOptions, BackfillCatalogReport, BuildCatalogPushItemsOptions, BuildCatalogPushItemsResult, CatalogPushAssemblyField, CatalogPushAssemblyImage, CatalogPushAssemblyItem, CatalogPushPreviewBefore, CatalogPushPreviewBeforeStatus, CatalogPushPreviewDiff, CatalogPushPreviewItem, CatalogPushPreviewResult, CatalogPushPreviewUnavailable, PushCatalogToStoreResult, CatalogPushJobResult, CatalogFieldConflict, CatalogFieldSkip, CatalogPushFieldSkip, CatalogPushSkipReason, CatalogConflictState, CatalogWriteSettings, ChannelComplianceData, ChannelConnectorPluginOptions, ChannelStockLine, ExportState, PublicConnectedStore, ReconcileReport, } from "./service.js";
25
+ export type { BackfillCatalogOptions, BackfillCatalogReport, BuildCatalogPushItemsOptions, BuildCatalogPushItemsResult, CatalogPushAssemblyField, CatalogPushAssemblyImage, CatalogPushAssemblyItem, CatalogPushPreviewBefore, CatalogPushPreviewBeforeStatus, CatalogPushPreviewDiff, CatalogPushPreviewItem, CatalogPushPreviewResult, CatalogPushPreviewUnavailable, PushCatalogToStoreResult, CatalogPushJobResult, CatalogConvergenceFailure, CatalogFieldConflict, CatalogFieldSkip, CatalogPushFieldSkip, CatalogPushSkipReason, CatalogConflictState, CatalogWriteSettings, ChannelComplianceData, ChannelConnectorPluginOptions, ChannelStockLine, ExportState, PublicConnectedStore, ReconcileReport, } from "./service.js";
26
26
  export type { OAuthStatePayload, OAuthStateResult } from "./oauth-state.js";
27
27
  export type { ChannelCatalogPush, ChannelCatalogPushEvent, ChannelCatalogConflict, ChannelCatalogConflictEvent, ChannelEntityMapEntry, ChannelExportEvent, ChannelOrderExport, ChannelRefundEvent, ChannelRefundRequest, ConnectedStore, } from "./schema.js";
28
28
  export declare function channelConnectorPlugin(options?: ChannelConnectorPluginOptions): import("@porulle/core").CommercePlugin;
package/dist/schema.d.ts CHANGED
@@ -1000,7 +1000,7 @@ export declare const channelOrderExports: import("drizzle-orm/pg-core/table").Pg
1000
1000
  tableName: "channel_order_exports";
1001
1001
  dataType: "string";
1002
1002
  columnType: "PgText";
1003
- data: "pending" | "abandoned" | "failed" | "confirmed" | "exported";
1003
+ data: "failed" | "pending" | "abandoned" | "confirmed" | "exported";
1004
1004
  driverParam: string;
1005
1005
  notNull: true;
1006
1006
  hasDefault: true;
@@ -1230,7 +1230,7 @@ export declare const channelCatalogPushes: import("drizzle-orm/pg-core/table").P
1230
1230
  tableName: "channel_catalog_pushes";
1231
1231
  dataType: "string";
1232
1232
  columnType: "PgText";
1233
- data: "pending" | "abandoned" | "failed" | "confirmed" | "exported";
1233
+ data: "failed" | "pending" | "abandoned" | "confirmed" | "exported";
1234
1234
  driverParam: string;
1235
1235
  notNull: true;
1236
1236
  hasDefault: true;
package/dist/service.d.ts CHANGED
@@ -165,6 +165,14 @@ export interface BackfillCatalogReport extends BackfillCounts, Record<string, un
165
165
  conflicts?: CatalogFieldConflict[];
166
166
  warnings?: string[];
167
167
  }
168
+ /**
169
+ * One item the converge loop could not finish. `externalId` is the merchant's id, because the
170
+ * entity may not exist yet and a caller reporting the failure has nothing else to name it by.
171
+ */
172
+ export interface CatalogConvergenceFailure {
173
+ externalId: string;
174
+ error: string;
175
+ }
168
176
  export interface BackfillCatalogOptions {
169
177
  dryRun?: boolean;
170
178
  resume?: boolean;
@@ -282,6 +290,7 @@ export declare class ChannelConnectorService {
282
290
  skipped?: CatalogFieldSkip[];
283
291
  conflicts?: CatalogFieldConflict[];
284
292
  warnings?: string[];
293
+ failures?: CatalogConvergenceFailure[];
285
294
  }>>;
286
295
  importCatalog(orgId: string, storeId: string, actor: Actor, options?: undefined): Promise<PluginResult<{
287
296
  imported: number;
@@ -289,6 +298,7 @@ export declare class ChannelConnectorService {
289
298
  skipped?: CatalogFieldSkip[];
290
299
  conflicts?: CatalogFieldConflict[];
291
300
  warnings?: string[];
301
+ failures?: CatalogConvergenceFailure[];
292
302
  }>>;
293
303
  private promoteLegacyAttributes;
294
304
  private saveBackfillState;
package/dist/service.js CHANGED
@@ -1722,6 +1722,7 @@ export class ChannelConnectorService {
1722
1722
  ...(result.value.skipped.length > 0 ? { skipped: uniqueSkipped(result.value.skipped) } : {}),
1723
1723
  ...(result.value.conflicts.length > 0 ? { conflicts: result.value.conflicts } : {}),
1724
1724
  ...(result.value.warnings.length > 0 ? { warnings: result.value.warnings } : {}),
1725
+ ...(result.value.failures.length > 0 ? { failures: result.value.failures } : {}),
1725
1726
  });
1726
1727
  }
1727
1728
  let { pageCursor, offset } = parseImportResumePosition(store.catalogCursor);
@@ -1731,6 +1732,7 @@ export class ChannelConnectorService {
1731
1732
  const skipped = [];
1732
1733
  const conflicts = [];
1733
1734
  const warnings = [];
1735
+ const failures = [];
1734
1736
  while (remaining > 0) {
1735
1737
  const page = await connector.importCatalog(store, pageCursor ?? undefined);
1736
1738
  if (!page.ok)
@@ -1756,6 +1758,7 @@ export class ChannelConnectorService {
1756
1758
  skipped.push(...result.value.skipped);
1757
1759
  conflicts.push(...result.value.conflicts);
1758
1760
  warnings.push(...result.value.warnings);
1761
+ failures.push(...result.value.failures);
1759
1762
  offset += result.value.consumed;
1760
1763
  if (offset >= pageItems.length) {
1761
1764
  if (page.value.nextCursor) {
@@ -1787,6 +1790,9 @@ export class ChannelConnectorService {
1787
1790
  ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
1788
1791
  ...(conflicts.length > 0 ? { conflicts } : {}),
1789
1792
  ...(warnings.length > 0 ? { warnings } : {}),
1793
+ // Always surfaced when non-empty. A silently dropped product is worse than the halt this
1794
+ // replaced: the caller must be able to see which externalIds did not land.
1795
+ ...(failures.length > 0 ? { failures } : {}),
1790
1796
  });
1791
1797
  }
1792
1798
  async promoteLegacyAttributes(orgId, storeId, actor, dryRun) {
@@ -1951,6 +1957,7 @@ export class ChannelConnectorService {
1951
1957
  skipped: [],
1952
1958
  conflicts: [],
1953
1959
  warnings: [],
1960
+ failures: [], // a dry run converges nothing, so it can fail nothing
1954
1961
  };
1955
1962
  const assets = await this.db.select().from(mediaAssets).where(eq(mediaAssets.organizationId, orgId));
1956
1963
  for (const item of items) {
@@ -2082,196 +2089,212 @@ export class ChannelConnectorService {
2082
2089
  const skipped = [];
2083
2090
  const conflicts = [];
2084
2091
  const warnings = [];
2092
+ const failures = [];
2085
2093
  for (const item of items) {
2086
2094
  consumed += 1;
2087
- const remoteHash = hash(item);
2088
- const existing = await this.db
2089
- .select()
2090
- .from(channelEntityMap)
2091
- .where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, item.externalId)));
2092
- let entityMapping = existing.find((entry) => entry.kind === "entity");
2093
- let entityId;
2094
- let isNew = false;
2095
- let adoptedOrphan = false;
2096
- let entityTouched = false;
2097
- let existingEntity;
2098
- if (entityMapping) {
2099
- const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entityMapping.entityId)));
2100
- if (!entity) {
2101
- await this.db.delete(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.entityId, entityMapping.entityId)));
2102
- entityMapping = undefined;
2095
+ try {
2096
+ const remoteHash = hash(item);
2097
+ const existing = await this.db
2098
+ .select()
2099
+ .from(channelEntityMap)
2100
+ .where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, item.externalId)));
2101
+ let entityMapping = existing.find((entry) => entry.kind === "entity");
2102
+ let entityId;
2103
+ let isNew = false;
2104
+ let adoptedOrphan = false;
2105
+ let entityTouched = false;
2106
+ let existingEntity;
2107
+ if (entityMapping) {
2108
+ const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entityMapping.entityId)));
2109
+ if (!entity) {
2110
+ await this.db.delete(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.entityId, entityMapping.entityId)));
2111
+ entityMapping = undefined;
2112
+ }
2113
+ else {
2114
+ entityId = entityMapping.entityId;
2115
+ existingEntity = entity;
2116
+ }
2103
2117
  }
2104
- else {
2105
- entityId = entityMapping.entityId;
2106
- existingEntity = entity;
2118
+ if (entityId === undefined) {
2119
+ const [orphan] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.sourceStoreId, storeId), eq(sellableEntities.slug, item.slug)));
2120
+ if (orphan) {
2121
+ entityId = orphan.id;
2122
+ existingEntity = orphan;
2123
+ adoptedOrphan = true;
2124
+ }
2125
+ else {
2126
+ const status = item.status;
2127
+ try {
2128
+ entityId = await this.transact(async (tx) => {
2129
+ const txContext = createTxContext(tx, { actor });
2130
+ const created = await this.catalog.create({
2131
+ type: "product",
2132
+ slug: item.slug,
2133
+ sourceStoreId: storeId,
2134
+ metadata: mergeMetadata(undefined, item.metadata ?? {}),
2135
+ ...(status !== undefined ? { status, isVisible: status === "active" } : {}),
2136
+ }, actor, txContext);
2137
+ if (!created.ok)
2138
+ throw new Error(created.error.message);
2139
+ await tx.insert(channelEntityMap).values({
2140
+ organizationId: orgId,
2141
+ storeId,
2142
+ kind: "entity",
2143
+ externalId: item.externalId,
2144
+ entityId: created.value.id,
2145
+ syncHash: PENDING_ENTITY_MAP_SYNC_HASH,
2146
+ heldFieldPaths: [],
2147
+ forcedPushFieldPaths: [],
2148
+ });
2149
+ return created.value.id;
2150
+ });
2151
+ }
2152
+ catch (error) {
2153
+ failures.push({ externalId: item.externalId, error: error instanceof Error ? error.message : "Failed to create catalog entity." });
2154
+ continue;
2155
+ }
2156
+ isNew = true;
2157
+ imported += 1;
2158
+ entityTouched = true;
2159
+ }
2107
2160
  }
2108
- }
2109
- if (entityId === undefined) {
2110
- const [orphan] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.sourceStoreId, storeId), eq(sellableEntities.slug, item.slug)));
2111
- if (orphan) {
2112
- entityId = orphan.id;
2113
- existingEntity = orphan;
2114
- adoptedOrphan = true;
2161
+ const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
2162
+ const seedPaths = importedFieldPaths(item).filter((path) => !ownershipBeforeSeed.has(path));
2163
+ const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
2164
+ if (!seeded.ok) {
2165
+ failures.push({ externalId: item.externalId, error: seeded.error.message });
2166
+ continue;
2115
2167
  }
2116
- else {
2117
- const status = item.status;
2118
- try {
2119
- entityId = await this.transact(async (tx) => {
2120
- const txContext = createTxContext(tx, { actor });
2121
- const created = await this.catalog.create({
2122
- type: "product",
2123
- slug: item.slug,
2124
- sourceStoreId: storeId,
2125
- metadata: mergeMetadata(undefined, item.metadata ?? {}),
2126
- ...(status !== undefined ? { status, isVisible: status === "active" } : {}),
2127
- }, actor, txContext);
2128
- if (!created.ok)
2129
- throw new Error(created.error.message);
2130
- await tx.insert(channelEntityMap).values({
2131
- organizationId: orgId,
2132
- storeId,
2133
- kind: "entity",
2134
- externalId: item.externalId,
2135
- entityId: created.value.id,
2136
- syncHash: PENDING_ENTITY_MAP_SYNC_HASH,
2137
- heldFieldPaths: [],
2138
- forcedPushFieldPaths: [],
2139
- });
2140
- return created.value.id;
2141
- });
2168
+ for (const path of seedPaths)
2169
+ ownershipBeforeSeed.set(path, "store");
2170
+ const owners = ownershipBeforeSeed;
2171
+ const outboundEcho = entityMapping ? this.isOutboundEcho(entityMapping, item) : false;
2172
+ const remoteChanged = entityMapping === undefined || entityMapping.syncHash !== remoteHash;
2173
+ // An unchanged remote item writes nothing and advances no baseline:
2174
+ // converging a stale replay would revert local edits to shared and
2175
+ // unowned fields that the store never actually changed.
2176
+ if (!force && !remoteChanged && existingEntity && existingEntity.status !== "archived") {
2177
+ continue;
2178
+ }
2179
+ const shared = existingEntity
2180
+ ? await this.detectSharedConflicts(entityId, storeId, existingEntity, entityMapping, item, owners, importedFieldPaths(item), remoteHash, outboundEcho ? { certifiedPaths: new Set(entityMapping?.outboundFieldPaths ?? []) } : undefined)
2181
+ : { paths: [], conflicts: [] };
2182
+ const persistedConflicts = await this.persistCatalogConflicts(orgId, shared.conflicts, requireUserId(actor));
2183
+ if (!persistedConflicts.ok)
2184
+ return persistedConflicts;
2185
+ const owned = this.filterOwnedFields(item, owners);
2186
+ const heldSharedPaths = [...new Set([...(entityMapping?.heldFieldPaths ?? []), ...shared.paths])];
2187
+ // A newly held path revokes any force left from an earlier resolution of
2188
+ // that same path: the force was the operator's answer to a question that
2189
+ // has since been asked again, and it must not pre-empt the new one.
2190
+ const survivingForcedPaths = (entityMapping?.forcedPushFieldPaths ?? []).filter((path) => !heldSharedPaths.includes(path));
2191
+ const held = this.filterConflictingFields(owned.writable, heldSharedPaths);
2192
+ const writable = held.writable;
2193
+ const blockedPaths = new Set([...owned.skipped, ...heldSharedPaths]);
2194
+ skipped.push(...owned.skipped.map((fieldPath) => ({ entityId, fieldPath })));
2195
+ conflicts.push(...shared.conflicts.map(({ platformValue: _platformValue, storeValue: _storeValue, ...conflict }) => conflict));
2196
+ for (const conflict of shared.conflicts) {
2197
+ warnings.push(`Held shared field conflict for entity "${conflict.entityId}", store "${conflict.storeId}", field "${conflict.fieldPath}" (local ${conflict.localValueSummary}, remote ${conflict.remoteValueSummary}).`);
2198
+ }
2199
+ if (existingEntity && entityMapping) {
2200
+ const remoteMetadata = mergeMetadata(existingEntity.metadata, writable.metadata ?? {});
2201
+ const remoteStatus = ownerAllows(owners, "entity.status") && !blockedPaths.has("entity.status")
2202
+ ? writable.status ?? (existingEntity.status === "archived" ? "active" : undefined)
2203
+ : undefined;
2204
+ const updateInput = {};
2205
+ if (ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && existingEntity.slug !== writable.slug) {
2206
+ updateInput.slug = writable.slug;
2207
+ }
2208
+ if (hash(remoteMetadata) !== hash(existingEntity.metadata ?? {}))
2209
+ updateInput.metadata = remoteMetadata;
2210
+ if (remoteStatus !== undefined && !blockedPaths.has("entity.status") && remoteStatus !== existingEntity.status) {
2211
+ updateInput.status = remoteStatus;
2212
+ updateInput.isVisible = remoteStatus === "active";
2142
2213
  }
2143
- catch (error) {
2144
- return PluginErr(error instanceof Error ? error.message : "Failed to create catalog entity.");
2214
+ const shouldUpdate = force
2215
+ ? Object.keys(updateInput).length > 0
2216
+ : remoteChanged || existingEntity.status === "archived";
2217
+ if (shouldUpdate) {
2218
+ converged += 1;
2219
+ if (Object.keys(updateInput).length > 0) {
2220
+ const updated = await this.catalog.update(entityMapping.entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
2221
+ if (!updated.ok) {
2222
+ failures.push({ externalId: item.externalId, error: updated.error.message });
2223
+ continue;
2224
+ }
2225
+ entityTouched = true;
2226
+ }
2145
2227
  }
2146
- isNew = true;
2147
- imported += 1;
2148
- entityTouched = true;
2149
2228
  }
2150
- }
2151
- const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
2152
- const seedPaths = importedFieldPaths(item).filter((path) => !ownershipBeforeSeed.has(path));
2153
- const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
2154
- if (!seeded.ok)
2155
- return PluginErr(seeded.error.message);
2156
- for (const path of seedPaths)
2157
- ownershipBeforeSeed.set(path, "store");
2158
- const owners = ownershipBeforeSeed;
2159
- const outboundEcho = entityMapping ? this.isOutboundEcho(entityMapping, item) : false;
2160
- const remoteChanged = entityMapping === undefined || entityMapping.syncHash !== remoteHash;
2161
- // An unchanged remote item writes nothing and advances no baseline:
2162
- // converging a stale replay would revert local edits to shared and
2163
- // unowned fields that the store never actually changed.
2164
- if (!force && !remoteChanged && existingEntity && existingEntity.status !== "archived") {
2165
- continue;
2166
- }
2167
- const shared = existingEntity
2168
- ? await this.detectSharedConflicts(entityId, storeId, existingEntity, entityMapping, item, owners, importedFieldPaths(item), remoteHash, outboundEcho ? { certifiedPaths: new Set(entityMapping?.outboundFieldPaths ?? []) } : undefined)
2169
- : { paths: [], conflicts: [] };
2170
- const persistedConflicts = await this.persistCatalogConflicts(orgId, shared.conflicts, requireUserId(actor));
2171
- if (!persistedConflicts.ok)
2172
- return persistedConflicts;
2173
- const owned = this.filterOwnedFields(item, owners);
2174
- const heldSharedPaths = [...new Set([...(entityMapping?.heldFieldPaths ?? []), ...shared.paths])];
2175
- // A newly held path revokes any force left from an earlier resolution of
2176
- // that same path: the force was the operator's answer to a question that
2177
- // has since been asked again, and it must not pre-empt the new one.
2178
- const survivingForcedPaths = (entityMapping?.forcedPushFieldPaths ?? []).filter((path) => !heldSharedPaths.includes(path));
2179
- const held = this.filterConflictingFields(owned.writable, heldSharedPaths);
2180
- const writable = held.writable;
2181
- const blockedPaths = new Set([...owned.skipped, ...heldSharedPaths]);
2182
- skipped.push(...owned.skipped.map((fieldPath) => ({ entityId, fieldPath })));
2183
- conflicts.push(...shared.conflicts.map(({ platformValue: _platformValue, storeValue: _storeValue, ...conflict }) => conflict));
2184
- for (const conflict of shared.conflicts) {
2185
- warnings.push(`Held shared field conflict for entity "${conflict.entityId}", store "${conflict.storeId}", field "${conflict.fieldPath}" (local ${conflict.localValueSummary}, remote ${conflict.remoteValueSummary}).`);
2186
- }
2187
- if (existingEntity && entityMapping) {
2188
- const remoteMetadata = mergeMetadata(existingEntity.metadata, writable.metadata ?? {});
2189
- const remoteStatus = ownerAllows(owners, "entity.status") && !blockedPaths.has("entity.status")
2190
- ? writable.status ?? (existingEntity.status === "archived" ? "active" : undefined)
2191
- : undefined;
2192
- const updateInput = {};
2193
- if (ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && existingEntity.slug !== writable.slug) {
2194
- updateInput.slug = writable.slug;
2229
+ const optionAxes = await this.upsertOptionAxes(entityId, writable, actor);
2230
+ if (!optionAxes.ok)
2231
+ return optionAxes;
2232
+ const attributes = await this.setCatalogAttributesIfWritable(entityId, writable, actor, blockedPaths, CHANNEL_CONVERGENCE_CTX);
2233
+ if (!attributes.ok)
2234
+ return attributes;
2235
+ const variantIds = await this.upsertVariants(orgId, storeId, entityId, writable, optionAxes.value.value, actor, warnings, !heldSharedPaths.includes("options") && owners.get("options") !== "platform", item);
2236
+ if (!variantIds.ok)
2237
+ return variantIds;
2238
+ const taxonomy = await this.applyTaxonomy(orgId, entityId, writable, actor, warnings);
2239
+ if (!taxonomy.ok)
2240
+ return taxonomy;
2241
+ const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
2242
+ if (!media.ok)
2243
+ return media;
2244
+ attributesCreated += attributes.value.created;
2245
+ mediaImported += media.value.imported;
2246
+ variantsGivenOptionValues += variantIds.value.repaired;
2247
+ skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
2248
+ entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || media.value.changed || attributes.value.changed;
2249
+ if (entityTouched)
2250
+ entitiesTouched += 1;
2251
+ if (entityTouched) {
2252
+ const revision = await this.catalog.recordEntityRevision(entityId, actor, "import");
2253
+ if (!revision.ok) {
2254
+ failures.push({ externalId: item.externalId, error: revision.error.message });
2255
+ continue;
2256
+ }
2195
2257
  }
2196
- if (hash(remoteMetadata) !== hash(existingEntity.metadata ?? {}))
2197
- updateInput.metadata = remoteMetadata;
2198
- if (remoteStatus !== undefined && !blockedPaths.has("entity.status") && remoteStatus !== existingEntity.status) {
2199
- updateInput.status = remoteStatus;
2200
- updateInput.isVisible = remoteStatus === "active";
2258
+ const revisionMarkers = await this.catalog.repository.findRevisionMarkers(entityId);
2259
+ const latestRevisionAt = revisionMarkers.at(-1)?.createdAt;
2260
+ const lastSyncedAt = latestRevisionAt ?? entityMapping?.lastSyncedAt ?? new Date();
2261
+ if (isNew) {
2262
+ await this.db.update(channelEntityMap).set({
2263
+ syncHash: remoteHash,
2264
+ lastSyncedAt,
2265
+ heldFieldPaths: heldSharedPaths,
2266
+ forcedPushFieldPaths: survivingForcedPaths,
2267
+ }).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, item.externalId), eq(channelEntityMap.entityId, entityId)));
2201
2268
  }
2202
- const shouldUpdate = force
2203
- ? Object.keys(updateInput).length > 0
2204
- : remoteChanged || existingEntity.status === "archived";
2205
- if (shouldUpdate) {
2206
- converged += 1;
2207
- if (Object.keys(updateInput).length > 0) {
2208
- const updated = await this.catalog.update(entityMapping.entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
2209
- if (!updated.ok)
2210
- return PluginErr(updated.error.message);
2211
- entityTouched = true;
2212
- }
2269
+ else if (adoptedOrphan) {
2270
+ await this.db.insert(channelEntityMap).values({
2271
+ organizationId: orgId,
2272
+ storeId,
2273
+ kind: "entity",
2274
+ externalId: item.externalId,
2275
+ entityId,
2276
+ syncHash: remoteHash,
2277
+ lastSyncedAt,
2278
+ heldFieldPaths: heldSharedPaths,
2279
+ forcedPushFieldPaths: survivingForcedPaths,
2280
+ });
2213
2281
  }
2282
+ else if (entityMapping) {
2283
+ await this.db.update(channelEntityMap).set({
2284
+ syncHash: remoteHash,
2285
+ lastSyncedAt,
2286
+ heldFieldPaths: heldSharedPaths,
2287
+ forcedPushFieldPaths: survivingForcedPaths,
2288
+ }).where(eq(channelEntityMap.id, entityMapping.id));
2289
+ }
2290
+ await this.db.update(channelEntityMap).set({ lastSyncedAt }).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.entityId, entityId), eq(channelEntityMap.kind, "variant")));
2214
2291
  }
2215
- const optionAxes = await this.upsertOptionAxes(entityId, writable, actor);
2216
- if (!optionAxes.ok)
2217
- return optionAxes;
2218
- const attributes = await this.setCatalogAttributesIfWritable(entityId, writable, actor, blockedPaths, CHANNEL_CONVERGENCE_CTX);
2219
- if (!attributes.ok)
2220
- return attributes;
2221
- const variantIds = await this.upsertVariants(orgId, storeId, entityId, writable, optionAxes.value.value, actor, warnings, !heldSharedPaths.includes("options") && owners.get("options") !== "platform", item);
2222
- if (!variantIds.ok)
2223
- return variantIds;
2224
- const taxonomy = await this.applyTaxonomy(orgId, entityId, writable, actor, warnings);
2225
- if (!taxonomy.ok)
2226
- return taxonomy;
2227
- const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
2228
- if (!media.ok)
2229
- return media;
2230
- attributesCreated += attributes.value.created;
2231
- mediaImported += media.value.imported;
2232
- variantsGivenOptionValues += variantIds.value.repaired;
2233
- skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
2234
- entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || media.value.changed || attributes.value.changed;
2235
- if (entityTouched)
2236
- entitiesTouched += 1;
2237
- if (entityTouched) {
2238
- const revision = await this.catalog.recordEntityRevision(entityId, actor, "import");
2239
- if (!revision.ok)
2240
- return PluginErr(revision.error.message);
2241
- }
2242
- const revisionMarkers = await this.catalog.repository.findRevisionMarkers(entityId);
2243
- const latestRevisionAt = revisionMarkers.at(-1)?.createdAt;
2244
- const lastSyncedAt = latestRevisionAt ?? entityMapping?.lastSyncedAt ?? new Date();
2245
- if (isNew) {
2246
- await this.db.update(channelEntityMap).set({
2247
- syncHash: remoteHash,
2248
- lastSyncedAt,
2249
- heldFieldPaths: heldSharedPaths,
2250
- forcedPushFieldPaths: survivingForcedPaths,
2251
- }).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, item.externalId), eq(channelEntityMap.entityId, entityId)));
2252
- }
2253
- else if (adoptedOrphan) {
2254
- await this.db.insert(channelEntityMap).values({
2255
- organizationId: orgId,
2256
- storeId,
2257
- kind: "entity",
2258
- externalId: item.externalId,
2259
- entityId,
2260
- syncHash: remoteHash,
2261
- lastSyncedAt,
2262
- heldFieldPaths: heldSharedPaths,
2263
- forcedPushFieldPaths: survivingForcedPaths,
2264
- });
2265
- }
2266
- else if (entityMapping) {
2267
- await this.db.update(channelEntityMap).set({
2268
- syncHash: remoteHash,
2269
- lastSyncedAt,
2270
- heldFieldPaths: heldSharedPaths,
2271
- forcedPushFieldPaths: survivingForcedPaths,
2272
- }).where(eq(channelEntityMap.id, entityMapping.id));
2292
+ catch (error) {
2293
+ // Anything the stages throw rather than returning. Same disposition: record it against the
2294
+ // item and carry on, so an unforeseen throw costs one product and not the remaining page.
2295
+ failures.push({ externalId: item.externalId, error: error instanceof Error ? error.message : String(error) });
2296
+ continue;
2273
2297
  }
2274
- await this.db.update(channelEntityMap).set({ lastSyncedAt }).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.entityId, entityId), eq(channelEntityMap.kind, "variant")));
2275
2298
  }
2276
2299
  return Ok({
2277
2300
  imported,
@@ -2284,6 +2307,7 @@ export class ChannelConnectorService {
2284
2307
  skipped,
2285
2308
  conflicts,
2286
2309
  warnings,
2310
+ failures,
2287
2311
  });
2288
2312
  }
2289
2313
  async reconcile(orgId, storeId, actor) {