@porulle/plugin-channel-connector 0.11.0 → 0.13.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/catalog-push-trigger.d.ts +26 -0
- package/dist/catalog-push-trigger.js +70 -0
- package/dist/hooks.js +18 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +85 -7
- package/dist/schema.d.ts +812 -55
- package/dist/schema.js +73 -1
- package/dist/service.d.ts +91 -6
- package/dist/service.js +1080 -45
- package/package.json +4 -4
- package/src/catalog-push-trigger.ts +99 -0
- package/src/hooks.ts +30 -6
- package/src/index.ts +124 -5
- package/src/schema.ts +101 -1
- package/src/service.ts +1417 -49
package/dist/service.js
CHANGED
|
@@ -1,10 +1,53 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { CommerceInvalidTransitionError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, } from "@porulle/core";
|
|
3
|
-
import { isValidFieldPath } from "@porulle/core";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
3
|
+
import { isValidFieldPath, requireUserId } from "@porulle/core";
|
|
4
|
+
import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
|
|
5
|
+
import { and, desc, eq, inArray, isNull, lte } from "@porulle/core/drizzle";
|
|
6
|
+
import { brands, categories, customerAddresses, customers, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
|
|
7
|
+
import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, } from "./schema.js";
|
|
7
8
|
import { mergeCatalogFieldMapping, normalizeCatalogFieldMapping, selectCatalogFieldMapping, } from "./catalog-field-mapping.js";
|
|
9
|
+
export const CATALOG_PUSH_BATCH_SIZES = {
|
|
10
|
+
mock: 100,
|
|
11
|
+
shopify: 50,
|
|
12
|
+
woocommerce: 100,
|
|
13
|
+
};
|
|
14
|
+
const DEFAULT_CATALOG_PUSH_BATCH_SIZE = 50;
|
|
15
|
+
const CATALOG_PUSH_BREAKER_RETRY_MS = 60_000;
|
|
16
|
+
export const CATALOG_PUSH_MAX_ATTEMPTS = 8;
|
|
17
|
+
const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
|
|
18
|
+
const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
|
|
19
|
+
export function catalogPushRetryDelayMs(attempts) {
|
|
20
|
+
const exponent = Math.max(0, attempts - 1);
|
|
21
|
+
return Math.min(CATALOG_PUSH_RETRY_BASE_MS * (2 ** exponent), CATALOG_PUSH_RETRY_MAX_MS);
|
|
22
|
+
}
|
|
23
|
+
export function catalogPushConcurrencyKey(input) {
|
|
24
|
+
const storeId = String(input.storeId);
|
|
25
|
+
const entityIds = input.entityIds;
|
|
26
|
+
if (Array.isArray(entityIds) && entityIds.length === 1) {
|
|
27
|
+
return `push:${String(entityIds[0])}:${storeId}`;
|
|
28
|
+
}
|
|
29
|
+
return `push-catalog:${storeId}`;
|
|
30
|
+
}
|
|
31
|
+
export function isCatalogPushBreakerOpen(breakerState) {
|
|
32
|
+
if (breakerState.open === true) {
|
|
33
|
+
const openUntil = breakerState.openUntil;
|
|
34
|
+
if (typeof openUntil === "string" && new Date(openUntil) <= new Date())
|
|
35
|
+
return false;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
const catalogPush = breakerState.catalogPush;
|
|
39
|
+
if (!catalogPush || typeof catalogPush !== "object")
|
|
40
|
+
return false;
|
|
41
|
+
const state = catalogPush;
|
|
42
|
+
if (state.open !== true)
|
|
43
|
+
return false;
|
|
44
|
+
if (typeof state.openUntil === "string" && new Date(state.openUntil) <= new Date())
|
|
45
|
+
return false;
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
function catalogPushBatchSize(provider) {
|
|
49
|
+
return CATALOG_PUSH_BATCH_SIZES[provider] ?? DEFAULT_CATALOG_PUSH_BATCH_SIZE;
|
|
50
|
+
}
|
|
8
51
|
const exportTransitions = {
|
|
9
52
|
pending: ["exported", "abandoned"],
|
|
10
53
|
exported: ["confirmed", "failed", "abandoned"],
|
|
@@ -15,9 +58,107 @@ const exportTransitions = {
|
|
|
15
58
|
export function canExportTransition(from, to) {
|
|
16
59
|
return exportTransitions[from].includes(to);
|
|
17
60
|
}
|
|
61
|
+
// Catalog pushes recur; confirmed/failed rows re-arm through exported, and rows with nothing to push resolve directly.
|
|
62
|
+
const catalogPushTransitions = {
|
|
63
|
+
pending: ["exported", "confirmed", "abandoned"],
|
|
64
|
+
exported: ["confirmed", "failed", "abandoned"],
|
|
65
|
+
confirmed: ["exported", "abandoned"],
|
|
66
|
+
failed: ["exported", "confirmed", "abandoned"],
|
|
67
|
+
abandoned: [],
|
|
68
|
+
};
|
|
69
|
+
export function canCatalogPushTransition(from, to) {
|
|
70
|
+
return catalogPushTransitions[from].includes(to);
|
|
71
|
+
}
|
|
18
72
|
function hash(value) {
|
|
19
73
|
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
20
74
|
}
|
|
75
|
+
export const CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS = 15 * 60 * 1000;
|
|
76
|
+
function normalizeCanonicalValue(value) {
|
|
77
|
+
if (typeof value === "string")
|
|
78
|
+
return value.replace(/\r\n?/g, "\n").replace(/\s+/g, " ").trim();
|
|
79
|
+
if (Array.isArray(value))
|
|
80
|
+
return value.map(normalizeCanonicalValue).sort((left, right) => (JSON.stringify(left) ?? "").localeCompare(JSON.stringify(right) ?? ""));
|
|
81
|
+
if (value && typeof value === "object") {
|
|
82
|
+
return Object.fromEntries(Object.entries(value)
|
|
83
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
84
|
+
.map(([key, nested]) => [key, normalizeCanonicalValue(nested)]));
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
function normalizedValuesEqual(left, right) {
|
|
89
|
+
return JSON.stringify(normalizeCanonicalValue(left)) === JSON.stringify(normalizeCanonicalValue(right));
|
|
90
|
+
}
|
|
91
|
+
function snapshotCustomFieldValue(field) {
|
|
92
|
+
if (field.textValue !== null && field.textValue !== undefined)
|
|
93
|
+
return field.textValue;
|
|
94
|
+
if (field.numberValue !== null && field.numberValue !== undefined)
|
|
95
|
+
return field.numberValue;
|
|
96
|
+
if (field.booleanValue !== null && field.booleanValue !== undefined)
|
|
97
|
+
return field.booleanValue;
|
|
98
|
+
if (field.dateValue !== null && field.dateValue !== undefined)
|
|
99
|
+
return field.dateValue;
|
|
100
|
+
return field.jsonValue;
|
|
101
|
+
}
|
|
102
|
+
function snapshotFieldValue(snapshot, path) {
|
|
103
|
+
const [root, segment, field] = path.split(".");
|
|
104
|
+
if (root === "entity" && segment === "slug")
|
|
105
|
+
return { found: true, value: snapshot.entity.slug };
|
|
106
|
+
if (root === "entity" && segment === "status")
|
|
107
|
+
return { found: true, value: snapshot.entity.status };
|
|
108
|
+
if (root === "entity" && segment === "metadata") {
|
|
109
|
+
const metadata = snapshot.entity.metadata;
|
|
110
|
+
return {
|
|
111
|
+
found: true,
|
|
112
|
+
value: metadata && typeof metadata === "object" ? metadata[field ?? ""] : undefined,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (root === "attributes" && segment && field) {
|
|
116
|
+
const attribute = snapshot.attributes.find((row) => row.locale === segment);
|
|
117
|
+
return { found: true, value: attribute?.[field] };
|
|
118
|
+
}
|
|
119
|
+
if (root === "customFields" && segment && field) {
|
|
120
|
+
const customField = snapshot.customFields.find((row) => row.fieldName === segment && row.locale === field && row.status === "approved");
|
|
121
|
+
return { found: true, value: customField ? snapshotCustomFieldValue(customField) : undefined };
|
|
122
|
+
}
|
|
123
|
+
if (root === "media" && segment) {
|
|
124
|
+
return {
|
|
125
|
+
found: true,
|
|
126
|
+
value: snapshot.media.filter((row) => row.role === segment).map((row) => row.mediaAssetId),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return { found: false, value: undefined };
|
|
130
|
+
}
|
|
131
|
+
function canonicalHash(externalId, fieldPaths, valueAtPath) {
|
|
132
|
+
const fields = fieldPaths.flatMap((fieldPath) => {
|
|
133
|
+
const value = valueAtPath(fieldPath);
|
|
134
|
+
return value === undefined ? [] : [{ fieldPath, value: normalizeCanonicalValue(value) }];
|
|
135
|
+
});
|
|
136
|
+
return hash({
|
|
137
|
+
externalId,
|
|
138
|
+
fields: fields.sort((left, right) => left.fieldPath.localeCompare(right.fieldPath)),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function outboundFieldPaths(item) {
|
|
142
|
+
const paths = new Set(item.fields.flatMap((field) => isValidFieldPath(field.fieldPath) ? [field.fieldPath] : []));
|
|
143
|
+
for (const image of item.images ?? [])
|
|
144
|
+
paths.add(`media.${image.role}`);
|
|
145
|
+
return [...paths].sort();
|
|
146
|
+
}
|
|
147
|
+
function pushFieldValue(item, fieldPath) {
|
|
148
|
+
if (fieldPath.startsWith("media.")) {
|
|
149
|
+
const role = fieldPath.slice("media.".length);
|
|
150
|
+
return (item.images ?? [])
|
|
151
|
+
.filter((image) => image.role === role)
|
|
152
|
+
.map((image) => ({ url: image.url, role: image.role }));
|
|
153
|
+
}
|
|
154
|
+
return item.fields.find((field) => field.fieldPath === fieldPath)?.value;
|
|
155
|
+
}
|
|
156
|
+
function canonicalOutboundHash(externalId, item, fieldPaths) {
|
|
157
|
+
return canonicalHash(externalId, fieldPaths, (fieldPath) => pushFieldValue(item, fieldPath));
|
|
158
|
+
}
|
|
159
|
+
function canonicalInboundHash(externalId, fieldPaths, remoteFieldValue) {
|
|
160
|
+
return canonicalHash(externalId, fieldPaths, remoteFieldValue);
|
|
161
|
+
}
|
|
21
162
|
function mergeMetadata(existing, remote) {
|
|
22
163
|
return { ...(existing ?? {}), ...remote };
|
|
23
164
|
}
|
|
@@ -61,6 +202,7 @@ function pushCatalogField(fieldPath, value, mapping) {
|
|
|
61
202
|
value,
|
|
62
203
|
...(locale !== undefined ? { locale } : {}),
|
|
63
204
|
remoteKey: mapping.remoteKey,
|
|
205
|
+
target: mapping.target,
|
|
64
206
|
};
|
|
65
207
|
}
|
|
66
208
|
function pushCatalogImageRole(value) {
|
|
@@ -84,6 +226,16 @@ function importedFieldPaths(item) {
|
|
|
84
226
|
paths.add(`attributes.${attribute.locale}.${field}`);
|
|
85
227
|
}
|
|
86
228
|
}
|
|
229
|
+
const customFields = item.customFields;
|
|
230
|
+
for (const [name, locales] of Object.entries(customFields ?? {})) {
|
|
231
|
+
if (!locales || typeof locales !== "object" || Array.isArray(locales))
|
|
232
|
+
continue;
|
|
233
|
+
for (const locale of Object.keys(locales)) {
|
|
234
|
+
const path = `customFields.${name}.${locale}`;
|
|
235
|
+
if (isValidFieldPath(path))
|
|
236
|
+
paths.add(path);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
87
239
|
for (const image of item.images ?? [])
|
|
88
240
|
paths.add(`media.${image.role}`);
|
|
89
241
|
if (item.options?.length)
|
|
@@ -271,6 +423,13 @@ export class ChannelConnectorService {
|
|
|
271
423
|
return item.status;
|
|
272
424
|
if (root === "entity" && segment === "metadata")
|
|
273
425
|
return item.metadata?.[field ?? ""];
|
|
426
|
+
if (root === "customFields" && segment && field) {
|
|
427
|
+
const customFields = item.customFields;
|
|
428
|
+
const customField = customFields?.[segment];
|
|
429
|
+
if (customField && typeof customField === "object" && !Array.isArray(customField)) {
|
|
430
|
+
return customField[field];
|
|
431
|
+
}
|
|
432
|
+
}
|
|
274
433
|
if (root === "attributes" && segment && field) {
|
|
275
434
|
const attributes = item.attributes?.length
|
|
276
435
|
? item.attributes
|
|
@@ -278,8 +437,11 @@ export class ChannelConnectorService {
|
|
|
278
437
|
const attribute = attributes.find((row) => row.locale === segment);
|
|
279
438
|
return attribute?.[field];
|
|
280
439
|
}
|
|
281
|
-
if (root === "media" && segment)
|
|
282
|
-
return (item.images ?? [])
|
|
440
|
+
if (root === "media" && segment) {
|
|
441
|
+
return (item.images ?? [])
|
|
442
|
+
.filter((image) => image.role === segment)
|
|
443
|
+
.map((image) => ({ url: image.url, role: image.role }));
|
|
444
|
+
}
|
|
283
445
|
if (path === "options")
|
|
284
446
|
return item.options;
|
|
285
447
|
if (path === "variants.sku")
|
|
@@ -290,6 +452,15 @@ export class ChannelConnectorService {
|
|
|
290
452
|
return item.variants.flatMap((variant) => variant.prices ?? []).filter((price) => price.currency === segment);
|
|
291
453
|
return undefined;
|
|
292
454
|
}
|
|
455
|
+
isOutboundEcho(mapping, item) {
|
|
456
|
+
if (!mapping.outboundHash || !mapping.outboundPushedAt || mapping.outboundFieldPaths.length === 0)
|
|
457
|
+
return false;
|
|
458
|
+
const age = Date.now() - mapping.outboundPushedAt.getTime();
|
|
459
|
+
if (age < 0 || age > CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS)
|
|
460
|
+
return false;
|
|
461
|
+
const inboundHash = canonicalInboundHash(mapping.externalId, mapping.outboundFieldPaths, (fieldPath) => this.remoteFieldValue(item, fieldPath));
|
|
462
|
+
return inboundHash === mapping.outboundHash;
|
|
463
|
+
}
|
|
293
464
|
async localFieldValue(entityId, entity, path) {
|
|
294
465
|
const [root, segment, field] = path.split(".");
|
|
295
466
|
if (root === "entity" && segment === "slug")
|
|
@@ -312,6 +483,10 @@ export class ChannelConnectorService {
|
|
|
312
483
|
: {};
|
|
313
484
|
return values[field];
|
|
314
485
|
}
|
|
486
|
+
if (root === "customFields" && segment && field) {
|
|
487
|
+
const [customField] = await this.db.select().from(sellableCustomFields).where(and(eq(sellableCustomFields.entityId, entityId), eq(sellableCustomFields.fieldName, segment), eq(sellableCustomFields.locale, field), eq(sellableCustomFields.status, "approved")));
|
|
488
|
+
return customField ? customFieldValue(customField) : undefined;
|
|
489
|
+
}
|
|
315
490
|
if (root === "media" && segment) {
|
|
316
491
|
const links = await this.db.select({ id: entityMedia.mediaAssetId }).from(entityMedia).where(and(eq(entityMedia.entityId, entityId), eq(entityMedia.role, segment)));
|
|
317
492
|
return links.map((link) => link.id);
|
|
@@ -334,24 +509,105 @@ export class ChannelConnectorService {
|
|
|
334
509
|
}
|
|
335
510
|
return undefined;
|
|
336
511
|
}
|
|
337
|
-
async
|
|
512
|
+
async lastSyncedSnapshot(entityId, lastSyncedAt) {
|
|
513
|
+
const [revision] = await this.db.select({ snapshot: sellableEntityRevisions.snapshot }).from(sellableEntityRevisions).where(and(eq(sellableEntityRevisions.entityId, entityId), lte(sellableEntityRevisions.createdAt, lastSyncedAt))).orderBy(desc(sellableEntityRevisions.createdAt)).limit(1);
|
|
514
|
+
return revision?.snapshot;
|
|
515
|
+
}
|
|
516
|
+
async detectSharedConflicts(entityId, storeId, entity, mapping, item, owners, fieldPaths = importedFieldPaths(item), remoteHash = hash(item), echo) {
|
|
338
517
|
if (!mapping || mapping.syncHash === remoteHash)
|
|
339
518
|
return { paths: [], conflicts: [] };
|
|
340
519
|
const revisions = await this.catalog.repository.findRevisionMarkers(entityId, mapping.lastSyncedAt);
|
|
341
520
|
const localChanged = revisions.some((revision) => revision.reason !== "import");
|
|
342
|
-
if (!localChanged)
|
|
343
|
-
return { paths: [], conflicts: [] };
|
|
344
521
|
const paths = fieldPaths.filter((path) => owners.get(path) === "shared");
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
522
|
+
const openRows = await this.db.select({
|
|
523
|
+
fieldPath: channelCatalogConflicts.fieldPath,
|
|
524
|
+
storeValue: channelCatalogConflicts.storeValue,
|
|
525
|
+
}).from(channelCatalogConflicts).where(and(eq(channelCatalogConflicts.storeId, storeId), eq(channelCatalogConflicts.entityId, entityId), eq(channelCatalogConflicts.state, "open")));
|
|
526
|
+
if (!localChanged && openRows.length === 0)
|
|
527
|
+
return { paths: [], conflicts: [] };
|
|
528
|
+
const openByPath = new Map(openRows.map((row) => [row.fieldPath, row.storeValue]));
|
|
529
|
+
const baseline = await this.lastSyncedSnapshot(entityId, mapping.lastSyncedAt);
|
|
530
|
+
const changed = [];
|
|
531
|
+
for (const path of paths) {
|
|
532
|
+
const localValue = await this.localFieldValue(entityId, entity, path);
|
|
533
|
+
const remoteValue = this.remoteFieldValue(item, path);
|
|
534
|
+
let diverged = false;
|
|
535
|
+
if (echo) {
|
|
536
|
+
// The outbound hash certifies only the pushed paths; a shared path
|
|
537
|
+
// outside that set carrying a genuinely different remote value is a
|
|
538
|
+
// real store edit even inside an echo payload.
|
|
539
|
+
diverged = !echo.certifiedPaths.has(path) && !normalizedValuesEqual(remoteValue, localValue);
|
|
540
|
+
}
|
|
541
|
+
else if (openByPath.has(path)) {
|
|
542
|
+
diverged = !normalizedValuesEqual(remoteValue, openByPath.get(path));
|
|
543
|
+
}
|
|
544
|
+
else if (baseline) {
|
|
545
|
+
const baselineValue = snapshotFieldValue(baseline, path);
|
|
546
|
+
diverged = baselineValue.found
|
|
547
|
+
&& !normalizedValuesEqual(localValue, baselineValue.value)
|
|
548
|
+
&& !normalizedValuesEqual(remoteValue, baselineValue.value)
|
|
549
|
+
&& !normalizedValuesEqual(localValue, remoteValue);
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
diverged = !normalizedValuesEqual(remoteValue, localValue);
|
|
553
|
+
}
|
|
554
|
+
if (diverged)
|
|
555
|
+
changed.push(path);
|
|
556
|
+
}
|
|
557
|
+
const conflicts = await Promise.all(changed.map(async (fieldPath) => {
|
|
558
|
+
const platformValue = await this.localFieldValue(entityId, entity, fieldPath);
|
|
559
|
+
const storeValue = this.remoteFieldValue(item, fieldPath);
|
|
560
|
+
return {
|
|
561
|
+
entityId,
|
|
562
|
+
storeId,
|
|
563
|
+
fieldPath,
|
|
564
|
+
platformValue: platformValue === undefined ? null : platformValue,
|
|
565
|
+
storeValue: storeValue === undefined ? null : storeValue,
|
|
566
|
+
localValueSummary: summarizeValue(platformValue),
|
|
567
|
+
remoteValueSummary: summarizeValue(storeValue),
|
|
568
|
+
};
|
|
569
|
+
}));
|
|
570
|
+
return { paths: changed, conflicts };
|
|
571
|
+
}
|
|
572
|
+
async persistCatalogConflicts(orgId, conflicts, changedBy) {
|
|
573
|
+
for (const conflict of conflicts) {
|
|
574
|
+
const [inserted] = await this.db.insert(channelCatalogConflicts).values({
|
|
575
|
+
organizationId: orgId,
|
|
576
|
+
storeId: conflict.storeId,
|
|
577
|
+
entityId: conflict.entityId,
|
|
578
|
+
fieldPath: conflict.fieldPath,
|
|
579
|
+
platformValue: conflict.platformValue,
|
|
580
|
+
storeValue: conflict.storeValue,
|
|
581
|
+
}).onConflictDoNothing().returning();
|
|
582
|
+
if (!inserted) {
|
|
583
|
+
const [existing] = await this.db.select({
|
|
584
|
+
id: channelCatalogConflicts.id,
|
|
585
|
+
storeValue: channelCatalogConflicts.storeValue,
|
|
586
|
+
platformValue: channelCatalogConflicts.platformValue,
|
|
587
|
+
}).from(channelCatalogConflicts).where(and(eq(channelCatalogConflicts.organizationId, orgId), eq(channelCatalogConflicts.storeId, conflict.storeId), eq(channelCatalogConflicts.entityId, conflict.entityId), eq(channelCatalogConflicts.fieldPath, conflict.fieldPath), eq(channelCatalogConflicts.state, "open")));
|
|
588
|
+
const storeMoved = existing !== undefined && !normalizedValuesEqual(existing.storeValue, conflict.storeValue);
|
|
589
|
+
const platformMoved = existing !== undefined && !normalizedValuesEqual(existing.platformValue, conflict.platformValue);
|
|
590
|
+
if (existing && (storeMoved || platformMoved)) {
|
|
591
|
+
await this.db.update(channelCatalogConflicts).set({
|
|
592
|
+
storeValue: conflict.storeValue,
|
|
593
|
+
platformValue: conflict.platformValue,
|
|
594
|
+
updatedAt: new Date(),
|
|
595
|
+
}).where(eq(channelCatalogConflicts.id, existing.id));
|
|
596
|
+
}
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
await this.db.insert(channelCatalogConflictEvents).values({
|
|
600
|
+
organizationId: orgId,
|
|
601
|
+
conflictId: inserted.id,
|
|
602
|
+
fromState: null,
|
|
603
|
+
toState: "open",
|
|
604
|
+
reason: "Shared catalog field changed on both sides.",
|
|
605
|
+
changedBy,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
return Ok(undefined);
|
|
609
|
+
}
|
|
610
|
+
async setCatalogAttributes(entityId, item, actor, blockedPaths = new Set(), catalogCtx) {
|
|
355
611
|
const attributes = item.attributes ?? [{
|
|
356
612
|
locale: "en",
|
|
357
613
|
title: item.title,
|
|
@@ -383,12 +639,23 @@ export class ChannelConnectorService {
|
|
|
383
639
|
else if (attributeFields.some((field) => (current[field] == null ? null : current[field]) !== (writeAttribute[field] == null ? null : writeAttribute[field]))) {
|
|
384
640
|
changed = true;
|
|
385
641
|
}
|
|
386
|
-
const result = await this.catalog.setAttributes(entityId, attribute.locale, writeAttribute, actor);
|
|
642
|
+
const result = await this.catalog.setAttributes(entityId, attribute.locale, writeAttribute, actor, catalogCtx);
|
|
387
643
|
if (!result.ok)
|
|
388
644
|
return PluginErr(result.error.message);
|
|
389
645
|
}
|
|
390
646
|
return Ok({ created, changed });
|
|
391
647
|
}
|
|
648
|
+
async setCatalogAttributesIfWritable(entityId, item, actor, blockedPaths, catalogCtx) {
|
|
649
|
+
const attributes = item.attributes ?? [{
|
|
650
|
+
locale: "en",
|
|
651
|
+
title: item.title,
|
|
652
|
+
...(item.description !== undefined ? { description: item.description } : {}),
|
|
653
|
+
}];
|
|
654
|
+
const writable = attributes.some((attribute) => attributeFields.some((field) => (attribute[field] !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.${field}`))));
|
|
655
|
+
if (!writable)
|
|
656
|
+
return Ok({ created: 0, changed: false });
|
|
657
|
+
return this.setCatalogAttributes(entityId, item, actor, blockedPaths, catalogCtx);
|
|
658
|
+
}
|
|
392
659
|
async upsertOptionAxes(entityId, item, actor) {
|
|
393
660
|
const optionValueIds = new Map();
|
|
394
661
|
const existingTypes = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entityId));
|
|
@@ -747,17 +1014,45 @@ export class ChannelConnectorService {
|
|
|
747
1014
|
}
|
|
748
1015
|
const fieldMapping = this.resolveCatalogFieldMapping(store, filterableCustomFields, warnings);
|
|
749
1016
|
const heldPaths = new Set(entityMapping.heldFieldPaths ?? []);
|
|
1017
|
+
const forcedPushPaths = new Set([
|
|
1018
|
+
...(entityMapping.forcedPushFieldPaths ?? []),
|
|
1019
|
+
...(options.forceFieldPaths?.[entity.id] ?? []),
|
|
1020
|
+
]);
|
|
750
1021
|
const fields = [];
|
|
751
1022
|
const appendField = (fieldPath, value) => {
|
|
752
|
-
if (value === undefined
|
|
1023
|
+
if (value === undefined)
|
|
1024
|
+
return;
|
|
1025
|
+
const owner = owners.get(fieldPath);
|
|
1026
|
+
const mapping = selectCatalogFieldMapping(fieldMapping, fieldPath);
|
|
1027
|
+
if (owner === "store") {
|
|
1028
|
+
skipped.push({
|
|
1029
|
+
entityId,
|
|
1030
|
+
fieldPath,
|
|
1031
|
+
reason: "store_owned",
|
|
1032
|
+
value,
|
|
1033
|
+
owner,
|
|
1034
|
+
...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
|
|
1035
|
+
});
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
if (owner === undefined)
|
|
1039
|
+
return;
|
|
1040
|
+
const forced = forcedPushPaths.has(fieldPath);
|
|
1041
|
+
if (owner !== "platform" && !forced)
|
|
753
1042
|
return;
|
|
754
1043
|
if (heldPaths.has(fieldPath)) {
|
|
755
|
-
skipped.push({
|
|
1044
|
+
skipped.push({
|
|
1045
|
+
entityId,
|
|
1046
|
+
fieldPath,
|
|
1047
|
+
reason: "held",
|
|
1048
|
+
value,
|
|
1049
|
+
owner,
|
|
1050
|
+
...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
|
|
1051
|
+
});
|
|
756
1052
|
return;
|
|
757
1053
|
}
|
|
758
|
-
const mapping = selectCatalogFieldMapping(fieldMapping, fieldPath);
|
|
759
1054
|
if (!mapping) {
|
|
760
|
-
skipped.push({ entityId, fieldPath, reason: "no_mapping" });
|
|
1055
|
+
skipped.push({ entityId, fieldPath, reason: "no_mapping", value, owner });
|
|
761
1056
|
return;
|
|
762
1057
|
}
|
|
763
1058
|
fields.push(pushCatalogField(fieldPath, value, mapping));
|
|
@@ -786,17 +1081,44 @@ export class ChannelConnectorService {
|
|
|
786
1081
|
if (!role)
|
|
787
1082
|
continue;
|
|
788
1083
|
const fieldPath = `media.${role}`;
|
|
789
|
-
|
|
1084
|
+
const owner = owners.get(fieldPath);
|
|
1085
|
+
const mapping = selectCatalogFieldMapping(fieldMapping, fieldPath);
|
|
1086
|
+
const imageValue = [{ url: attached.url, role }];
|
|
1087
|
+
if (owner === "store") {
|
|
1088
|
+
skipped.push({
|
|
1089
|
+
entityId,
|
|
1090
|
+
fieldPath,
|
|
1091
|
+
reason: "store_owned",
|
|
1092
|
+
value: imageValue,
|
|
1093
|
+
owner,
|
|
1094
|
+
...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
|
|
1095
|
+
});
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
if (owner === undefined)
|
|
1099
|
+
continue;
|
|
1100
|
+
const forced = forcedPushPaths.has(fieldPath);
|
|
1101
|
+
if (owner !== "platform" && !forced)
|
|
790
1102
|
continue;
|
|
791
1103
|
if (heldPaths.has(fieldPath)) {
|
|
792
|
-
skipped.push({
|
|
1104
|
+
skipped.push({
|
|
1105
|
+
entityId,
|
|
1106
|
+
fieldPath,
|
|
1107
|
+
reason: "held",
|
|
1108
|
+
value: imageValue,
|
|
1109
|
+
owner,
|
|
1110
|
+
...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
|
|
1111
|
+
});
|
|
793
1112
|
continue;
|
|
794
1113
|
}
|
|
795
|
-
if (!
|
|
796
|
-
skipped.push({ entityId, fieldPath, reason: "no_mapping" });
|
|
1114
|
+
if (!mapping) {
|
|
1115
|
+
skipped.push({ entityId, fieldPath, reason: "no_mapping", value: imageValue, owner });
|
|
797
1116
|
continue;
|
|
798
1117
|
}
|
|
799
1118
|
images.push({
|
|
1119
|
+
fieldPath,
|
|
1120
|
+
target: mapping.target,
|
|
1121
|
+
remoteKey: mapping.remoteKey,
|
|
800
1122
|
url: attached.url,
|
|
801
1123
|
role,
|
|
802
1124
|
sortOrder: attached.sortOrder,
|
|
@@ -831,6 +1153,201 @@ export class ChannelConnectorService {
|
|
|
831
1153
|
}
|
|
832
1154
|
return Ok({ items, skipped, warnings: [...new Set(warnings)] });
|
|
833
1155
|
}
|
|
1156
|
+
async recordOutboundPush(orgId, storeId, outcomes, items, phase = "settle") {
|
|
1157
|
+
const outcomeByExternalId = new Map(outcomes.map((outcome) => [outcome.externalId, outcome]));
|
|
1158
|
+
const now = new Date();
|
|
1159
|
+
for (const item of items) {
|
|
1160
|
+
const outcome = outcomeByExternalId.get(item.externalId);
|
|
1161
|
+
const mapping = await this.db.select({
|
|
1162
|
+
id: channelEntityMap.id,
|
|
1163
|
+
externalId: channelEntityMap.externalId,
|
|
1164
|
+
forcedPushFieldPaths: channelEntityMap.forcedPushFieldPaths,
|
|
1165
|
+
}).from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, item.externalId)));
|
|
1166
|
+
if (!mapping[0])
|
|
1167
|
+
continue;
|
|
1168
|
+
if (outcome?.ok === true) {
|
|
1169
|
+
const fieldPaths = outboundFieldPaths(item);
|
|
1170
|
+
await this.db.update(channelEntityMap).set({
|
|
1171
|
+
outboundHash: canonicalOutboundHash(mapping[0].externalId, item, fieldPaths),
|
|
1172
|
+
outboundPushedAt: now,
|
|
1173
|
+
outboundFieldPaths: fieldPaths,
|
|
1174
|
+
// A force is an operator's conflict resolution. The write-ahead runs
|
|
1175
|
+
// before the connector is called and its outcomes are optimistic, so
|
|
1176
|
+
// consuming the force there would discard the resolution on a failed
|
|
1177
|
+
// push and the retry would silently omit the field.
|
|
1178
|
+
...(phase === "settle"
|
|
1179
|
+
? { forcedPushFieldPaths: (mapping[0].forcedPushFieldPaths ?? []).filter((path) => !fieldPaths.includes(path)) }
|
|
1180
|
+
: {}),
|
|
1181
|
+
}).where(eq(channelEntityMap.id, mapping[0].id));
|
|
1182
|
+
}
|
|
1183
|
+
else {
|
|
1184
|
+
await this.db.update(channelEntityMap).set({
|
|
1185
|
+
outboundHash: null,
|
|
1186
|
+
outboundPushedAt: null,
|
|
1187
|
+
outboundFieldPaths: [],
|
|
1188
|
+
syncHash: "",
|
|
1189
|
+
}).where(eq(channelEntityMap.id, mapping[0].id));
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
return Ok(undefined);
|
|
1193
|
+
}
|
|
1194
|
+
async pushCatalogToStore(orgId, storeId, entityIds) {
|
|
1195
|
+
const assembled = await this.buildCatalogPushItems(orgId, storeId, entityIds);
|
|
1196
|
+
if (!assembled.ok)
|
|
1197
|
+
return assembled;
|
|
1198
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
1199
|
+
if (!store || store.status !== "connected")
|
|
1200
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
1201
|
+
const connector = this.connectors.get(store.provider);
|
|
1202
|
+
if (!connector?.pushCatalog)
|
|
1203
|
+
return PluginErr(`Catalog push is not supported by provider "${store.provider}".`);
|
|
1204
|
+
if (assembled.value.items.length === 0)
|
|
1205
|
+
return Ok({
|
|
1206
|
+
outcomes: [],
|
|
1207
|
+
skipped: assembled.value.skipped,
|
|
1208
|
+
warnings: assembled.value.warnings,
|
|
1209
|
+
});
|
|
1210
|
+
const writeAhead = await this.recordOutboundPush(orgId, storeId, assembled.value.items.map((item) => ({ externalId: item.externalId, ok: true })), assembled.value.items, "write-ahead");
|
|
1211
|
+
if (!writeAhead.ok)
|
|
1212
|
+
return writeAhead;
|
|
1213
|
+
let result;
|
|
1214
|
+
try {
|
|
1215
|
+
result = await connector.pushCatalog(store, assembled.value.items);
|
|
1216
|
+
}
|
|
1217
|
+
catch (error) {
|
|
1218
|
+
const connectorError = {
|
|
1219
|
+
code: "CATALOG_PUSH_THROWN",
|
|
1220
|
+
message: error instanceof Error ? error.message : "Catalog push failed.",
|
|
1221
|
+
};
|
|
1222
|
+
const cleared = await this.recordOutboundPush(orgId, storeId, assembled.value.items.map((item) => ({ externalId: item.externalId, ok: false, error: connectorError })), assembled.value.items);
|
|
1223
|
+
if (!cleared.ok)
|
|
1224
|
+
return cleared;
|
|
1225
|
+
return PluginErr(connectorError.message, connectorError.code);
|
|
1226
|
+
}
|
|
1227
|
+
if (!result.ok) {
|
|
1228
|
+
const cleared = await this.recordOutboundPush(orgId, storeId, assembled.value.items.map((item) => ({ externalId: item.externalId, ok: false, error: result.error })), assembled.value.items);
|
|
1229
|
+
if (!cleared.ok)
|
|
1230
|
+
return cleared;
|
|
1231
|
+
return PluginErr(result.error.message, result.error.code);
|
|
1232
|
+
}
|
|
1233
|
+
const recorded = await this.recordOutboundPush(orgId, storeId, result.value.outcomes, assembled.value.items);
|
|
1234
|
+
if (!recorded.ok)
|
|
1235
|
+
return recorded;
|
|
1236
|
+
const successfulEntityIds = assembled.value.items
|
|
1237
|
+
.filter((item) => result.value.outcomes.some((outcome) => outcome.externalId === item.externalId && outcome.ok))
|
|
1238
|
+
.map((item) => item.externalId);
|
|
1239
|
+
if (successfulEntityIds.length > 0) {
|
|
1240
|
+
const mappings = await this.db.select({ entityId: channelEntityMap.entityId }).from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), inArray(channelEntityMap.externalId, successfulEntityIds)));
|
|
1241
|
+
const actor = createSystemActor(orgId);
|
|
1242
|
+
try {
|
|
1243
|
+
await this.transact(async (tx) => {
|
|
1244
|
+
const txContext = createTxContext(tx, { actor });
|
|
1245
|
+
for (const entityId of [...new Set(mappings.map((mapping) => mapping.entityId))]) {
|
|
1246
|
+
const revision = await this.catalog.recordEntityRevision(entityId, actor, "push", txContext);
|
|
1247
|
+
if (!revision.ok)
|
|
1248
|
+
throw new Error(revision.error.message);
|
|
1249
|
+
}
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
catch (error) {
|
|
1253
|
+
return PluginErr(error instanceof Error ? error.message : "Failed to record catalog push revisions.");
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
return Ok({
|
|
1257
|
+
...result.value,
|
|
1258
|
+
skipped: assembled.value.skipped,
|
|
1259
|
+
warnings: assembled.value.warnings,
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
async previewCatalogPush(orgId, storeId, entityIds) {
|
|
1263
|
+
const assembledEntityIds = await this.resolveCatalogPushEntityIds(orgId, storeId, entityIds);
|
|
1264
|
+
const assembled = await this.buildCatalogPushItems(orgId, storeId, assembledEntityIds);
|
|
1265
|
+
if (!assembled.ok)
|
|
1266
|
+
return assembled;
|
|
1267
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
1268
|
+
if (!store || store.status !== "connected")
|
|
1269
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
1270
|
+
const connector = this.connectors.get(store.provider);
|
|
1271
|
+
if (!connector?.pushCatalog)
|
|
1272
|
+
return PluginErr(`Catalog push is not supported by provider "${store.provider}".`);
|
|
1273
|
+
if (assembled.value.items.length === 0) {
|
|
1274
|
+
return Ok({ items: [], skipped: assembled.value.skipped, warnings: assembled.value.warnings });
|
|
1275
|
+
}
|
|
1276
|
+
let result;
|
|
1277
|
+
try {
|
|
1278
|
+
result = await connector.pushCatalog(store, assembled.value.items, { dryRun: true });
|
|
1279
|
+
}
|
|
1280
|
+
catch (error) {
|
|
1281
|
+
return PluginErr(error instanceof Error ? error.message : "Catalog push preview failed.", "CATALOG_PREVIEW_THROWN");
|
|
1282
|
+
}
|
|
1283
|
+
if (!result.ok)
|
|
1284
|
+
return PluginErr(result.error.message, result.error.code);
|
|
1285
|
+
const failed = result.value.outcomes.find((outcome) => !outcome.ok);
|
|
1286
|
+
if (failed)
|
|
1287
|
+
return PluginErr(failed.error?.message ?? `Catalog push preview failed for item "${failed.externalId}".`, failed.error?.code ?? "CATALOG_PREVIEW_FAILED");
|
|
1288
|
+
const mappings = assembledEntityIds.length === 0
|
|
1289
|
+
? []
|
|
1290
|
+
: await this.db.select({ entityId: channelEntityMap.entityId, externalId: channelEntityMap.externalId }).from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), inArray(channelEntityMap.entityId, assembledEntityIds)));
|
|
1291
|
+
const externalByEntity = new Map(mappings.map((mapping) => [mapping.entityId, mapping.externalId]));
|
|
1292
|
+
const skippedByExternal = new Map();
|
|
1293
|
+
for (const skipped of assembled.value.skipped) {
|
|
1294
|
+
const externalId = externalByEntity.get(skipped.entityId);
|
|
1295
|
+
if (!externalId)
|
|
1296
|
+
continue;
|
|
1297
|
+
const existing = skippedByExternal.get(externalId) ?? [];
|
|
1298
|
+
existing.push(skipped);
|
|
1299
|
+
skippedByExternal.set(externalId, existing);
|
|
1300
|
+
}
|
|
1301
|
+
const outcomeByExternalId = new Map(result.value.outcomes.map((outcome) => [outcome.externalId, outcome]));
|
|
1302
|
+
const beforeFor = (externalId, fieldPath) => {
|
|
1303
|
+
const previousFields = outcomeByExternalId.get(externalId)?.previousFields;
|
|
1304
|
+
if (previousFields === undefined) {
|
|
1305
|
+
return { before: { status: "unavailable" }, beforeStatus: "unavailable" };
|
|
1306
|
+
}
|
|
1307
|
+
const previous = previousFields.find((field) => field.fieldPath === fieldPath);
|
|
1308
|
+
if (!previous)
|
|
1309
|
+
return { before: null, beforeStatus: "missing" };
|
|
1310
|
+
return { before: previous.value, beforeStatus: "value" };
|
|
1311
|
+
};
|
|
1312
|
+
const items = assembled.value.items.map((item) => {
|
|
1313
|
+
const diffs = item.fields.map((field) => ({
|
|
1314
|
+
fieldPath: field.fieldPath,
|
|
1315
|
+
target: field.target,
|
|
1316
|
+
remoteKey: field.remoteKey ?? null,
|
|
1317
|
+
...beforeFor(item.externalId, field.fieldPath),
|
|
1318
|
+
after: field.value,
|
|
1319
|
+
owner: "platform",
|
|
1320
|
+
willWrite: true,
|
|
1321
|
+
}));
|
|
1322
|
+
for (const image of item.images ?? []) {
|
|
1323
|
+
diffs.push({
|
|
1324
|
+
fieldPath: image.fieldPath,
|
|
1325
|
+
target: image.target,
|
|
1326
|
+
remoteKey: image.remoteKey,
|
|
1327
|
+
...beforeFor(item.externalId, image.fieldPath),
|
|
1328
|
+
after: pushFieldValue(item, image.fieldPath),
|
|
1329
|
+
owner: "platform",
|
|
1330
|
+
willWrite: true,
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
for (const skipped of skippedByExternal.get(item.externalId) ?? []) {
|
|
1334
|
+
if (skipped.value === undefined || skipped.owner === undefined)
|
|
1335
|
+
continue;
|
|
1336
|
+
diffs.push({
|
|
1337
|
+
fieldPath: skipped.fieldPath,
|
|
1338
|
+
target: skipped.target ?? null,
|
|
1339
|
+
remoteKey: skipped.remoteKey ?? null,
|
|
1340
|
+
...beforeFor(item.externalId, skipped.fieldPath),
|
|
1341
|
+
after: skipped.value,
|
|
1342
|
+
owner: skipped.owner,
|
|
1343
|
+
willWrite: false,
|
|
1344
|
+
reason: skipped.reason,
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
return { externalId: item.externalId, diffs };
|
|
1348
|
+
});
|
|
1349
|
+
return Ok({ items, skipped: assembled.value.skipped, warnings: assembled.value.warnings });
|
|
1350
|
+
}
|
|
834
1351
|
async getCatalogWriteSettings(orgId, storeId) {
|
|
835
1352
|
const store = await this.getStoreRecord(orgId, storeId);
|
|
836
1353
|
if (!store)
|
|
@@ -1074,7 +1591,7 @@ export class ChannelConnectorService {
|
|
|
1074
1591
|
const promoted = await this.catalog.setAttributes(entity.id, "en", {
|
|
1075
1592
|
title: metadata.title,
|
|
1076
1593
|
...(typeof metadata.description === "string" ? { description: metadata.description } : {}),
|
|
1077
|
-
}, actor);
|
|
1594
|
+
}, actor, CHANNEL_CONVERGENCE_CTX);
|
|
1078
1595
|
if (!promoted.ok)
|
|
1079
1596
|
return PluginErr(promoted.error.message);
|
|
1080
1597
|
const [confirmed] = await this.db.select({ id: sellableAttributes.id, title: sellableAttributes.title, description: sellableAttributes.description }).from(sellableAttributes).where(and(eq(sellableAttributes.entityId, entity.id), eq(sellableAttributes.locale, "en")));
|
|
@@ -1385,6 +1902,7 @@ export class ChannelConnectorService {
|
|
|
1385
1902
|
for (const path of seedPaths)
|
|
1386
1903
|
ownershipBeforeSeed.set(path, "store");
|
|
1387
1904
|
const owners = ownershipBeforeSeed;
|
|
1905
|
+
const outboundEcho = entityMapping ? this.isOutboundEcho(entityMapping, item) : false;
|
|
1388
1906
|
const remoteChanged = entityMapping === undefined || entityMapping.syncHash !== remoteHash;
|
|
1389
1907
|
// An unchanged remote item writes nothing and advances no baseline:
|
|
1390
1908
|
// converging a stale replay would revert local edits to shared and
|
|
@@ -1393,15 +1911,22 @@ export class ChannelConnectorService {
|
|
|
1393
1911
|
continue;
|
|
1394
1912
|
}
|
|
1395
1913
|
const shared = existingEntity
|
|
1396
|
-
? await this.detectSharedConflicts(entityId, storeId, existingEntity, entityMapping, item, owners)
|
|
1914
|
+
? await this.detectSharedConflicts(entityId, storeId, existingEntity, entityMapping, item, owners, importedFieldPaths(item), remoteHash, outboundEcho ? { certifiedPaths: new Set(entityMapping?.outboundFieldPaths ?? []) } : undefined)
|
|
1397
1915
|
: { paths: [], conflicts: [] };
|
|
1916
|
+
const persistedConflicts = await this.persistCatalogConflicts(orgId, shared.conflicts, requireUserId(actor));
|
|
1917
|
+
if (!persistedConflicts.ok)
|
|
1918
|
+
return persistedConflicts;
|
|
1398
1919
|
const owned = this.filterOwnedFields(item, owners);
|
|
1399
1920
|
const heldSharedPaths = [...new Set([...(entityMapping?.heldFieldPaths ?? []), ...shared.paths])];
|
|
1921
|
+
// A newly held path revokes any force left from an earlier resolution of
|
|
1922
|
+
// that same path: the force was the operator's answer to a question that
|
|
1923
|
+
// has since been asked again, and it must not pre-empt the new one.
|
|
1924
|
+
const survivingForcedPaths = (entityMapping?.forcedPushFieldPaths ?? []).filter((path) => !heldSharedPaths.includes(path));
|
|
1400
1925
|
const held = this.filterConflictingFields(owned.writable, heldSharedPaths);
|
|
1401
1926
|
const writable = held.writable;
|
|
1402
1927
|
const blockedPaths = new Set([...owned.skipped, ...heldSharedPaths]);
|
|
1403
1928
|
skipped.push(...owned.skipped.map((fieldPath) => ({ entityId, fieldPath })));
|
|
1404
|
-
conflicts.push(...shared.conflicts);
|
|
1929
|
+
conflicts.push(...shared.conflicts.map(({ platformValue: _platformValue, storeValue: _storeValue, ...conflict }) => conflict));
|
|
1405
1930
|
for (const conflict of shared.conflicts) {
|
|
1406
1931
|
warnings.push(`Held shared field conflict for entity "${conflict.entityId}", store "${conflict.storeId}", field "${conflict.fieldPath}" (local ${conflict.localValueSummary}, remote ${conflict.remoteValueSummary}).`);
|
|
1407
1932
|
}
|
|
@@ -1426,7 +1951,7 @@ export class ChannelConnectorService {
|
|
|
1426
1951
|
if (shouldUpdate) {
|
|
1427
1952
|
converged += 1;
|
|
1428
1953
|
if (Object.keys(updateInput).length > 0) {
|
|
1429
|
-
const updated = await this.catalog.update(entityMapping.entityId, updateInput, actor);
|
|
1954
|
+
const updated = await this.catalog.update(entityMapping.entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
|
|
1430
1955
|
if (!updated.ok)
|
|
1431
1956
|
return PluginErr(updated.error.message);
|
|
1432
1957
|
entityTouched = true;
|
|
@@ -1436,7 +1961,7 @@ export class ChannelConnectorService {
|
|
|
1436
1961
|
const optionAxes = await this.upsertOptionAxes(entityId, writable, actor);
|
|
1437
1962
|
if (!optionAxes.ok)
|
|
1438
1963
|
return optionAxes;
|
|
1439
|
-
const attributes = await this.
|
|
1964
|
+
const attributes = await this.setCatalogAttributesIfWritable(entityId, writable, actor, blockedPaths, CHANNEL_CONVERGENCE_CTX);
|
|
1440
1965
|
if (!attributes.ok)
|
|
1441
1966
|
return attributes;
|
|
1442
1967
|
const variantIds = await this.upsertVariants(orgId, storeId, entityId, writable, optionAxes.value.value, actor, warnings, !heldSharedPaths.includes("options") && owners.get("options") !== "platform", item);
|
|
@@ -1473,6 +1998,7 @@ export class ChannelConnectorService {
|
|
|
1473
1998
|
syncHash: remoteHash,
|
|
1474
1999
|
lastSyncedAt,
|
|
1475
2000
|
heldFieldPaths: heldSharedPaths,
|
|
2001
|
+
forcedPushFieldPaths: survivingForcedPaths,
|
|
1476
2002
|
});
|
|
1477
2003
|
}
|
|
1478
2004
|
else if (entityMapping) {
|
|
@@ -1480,6 +2006,7 @@ export class ChannelConnectorService {
|
|
|
1480
2006
|
syncHash: remoteHash,
|
|
1481
2007
|
lastSyncedAt,
|
|
1482
2008
|
heldFieldPaths: heldSharedPaths,
|
|
2009
|
+
forcedPushFieldPaths: survivingForcedPaths,
|
|
1483
2010
|
}).where(eq(channelEntityMap.id, entityMapping.id));
|
|
1484
2011
|
}
|
|
1485
2012
|
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")));
|
|
@@ -1560,11 +2087,13 @@ export class ChannelConnectorService {
|
|
|
1560
2087
|
inventoryUpdated += 1;
|
|
1561
2088
|
}
|
|
1562
2089
|
const threshold = this.options.driftAlertThreshold ?? 25;
|
|
2090
|
+
const openConflictRows = await this.db.select({ id: channelCatalogConflicts.id }).from(channelCatalogConflicts).where(and(eq(channelCatalogConflicts.organizationId, orgId), eq(channelCatalogConflicts.storeId, storeId), eq(channelCatalogConflicts.state, "open")));
|
|
1563
2091
|
const report = {
|
|
1564
2092
|
imported: converged.value.imported,
|
|
1565
2093
|
converged: converged.value.converged,
|
|
1566
2094
|
archived,
|
|
1567
2095
|
inventoryUpdated,
|
|
2096
|
+
openConflicts: openConflictRows.length,
|
|
1568
2097
|
driftAlert: converged.value.imported + converged.value.converged + archived > threshold,
|
|
1569
2098
|
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
1570
2099
|
...(converged.value.conflicts.length > 0 ? { conflicts: converged.value.conflicts } : {}),
|
|
@@ -1585,6 +2114,134 @@ export class ChannelConnectorService {
|
|
|
1585
2114
|
const report = store.lastReconcileReport;
|
|
1586
2115
|
return Ok({ lastReconcileAt: store.lastReconcileAt, report, driftAlert: report?.driftAlert ?? false });
|
|
1587
2116
|
}
|
|
2117
|
+
async listCatalogConflicts(orgId, storeId, state = "open") {
|
|
2118
|
+
const conditions = [eq(channelCatalogConflicts.organizationId, orgId), eq(channelCatalogConflicts.state, state)];
|
|
2119
|
+
if (storeId !== undefined)
|
|
2120
|
+
conditions.push(eq(channelCatalogConflicts.storeId, storeId));
|
|
2121
|
+
return Ok(await this.db.select().from(channelCatalogConflicts).where(and(...conditions)));
|
|
2122
|
+
}
|
|
2123
|
+
async resolveCatalogConflict(orgId, id, choose, actor) {
|
|
2124
|
+
const [conflict] = await this.db.select().from(channelCatalogConflicts).where(and(eq(channelCatalogConflicts.organizationId, orgId), eq(channelCatalogConflicts.id, id), eq(channelCatalogConflicts.state, "open")));
|
|
2125
|
+
if (!conflict)
|
|
2126
|
+
return PluginErr("Catalog conflict not found or already resolved.", "NOT_FOUND");
|
|
2127
|
+
if (choose === "platform" && !this.jobs)
|
|
2128
|
+
return PluginErr("Jobs are not configured.", "JOBS_UNAVAILABLE");
|
|
2129
|
+
const [mapping] = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, conflict.storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.entityId, conflict.entityId)));
|
|
2130
|
+
if (!mapping)
|
|
2131
|
+
return PluginErr("Catalog conflict mapping not found.", "NOT_FOUND");
|
|
2132
|
+
const systemActor = createSystemActor(orgId);
|
|
2133
|
+
let resolutionBaselineAt;
|
|
2134
|
+
if (choose === "store") {
|
|
2135
|
+
const applied = await this.applyStoreConflictValue(orgId, conflict, systemActor);
|
|
2136
|
+
if (!applied.ok)
|
|
2137
|
+
return PluginErr(applied.error, applied.code);
|
|
2138
|
+
const revisions = await this.catalog.repository.findRevisionMarkers(conflict.entityId);
|
|
2139
|
+
resolutionBaselineAt = revisions.at(-1)?.createdAt;
|
|
2140
|
+
}
|
|
2141
|
+
const heldFieldPaths = (mapping.heldFieldPaths ?? []).filter((path) => path !== conflict.fieldPath);
|
|
2142
|
+
const forcedPushFieldPaths = choose === "platform"
|
|
2143
|
+
? [...new Set([...(mapping.forcedPushFieldPaths ?? []), conflict.fieldPath])]
|
|
2144
|
+
: mapping.forcedPushFieldPaths ?? [];
|
|
2145
|
+
const [resolved] = await this.db.update(channelCatalogConflicts).set({
|
|
2146
|
+
state: "resolved",
|
|
2147
|
+
resolvedBy: requireUserId(actor),
|
|
2148
|
+
updatedAt: new Date(),
|
|
2149
|
+
}).where(and(eq(channelCatalogConflicts.organizationId, orgId), eq(channelCatalogConflicts.id, id), eq(channelCatalogConflicts.state, "open"))).returning();
|
|
2150
|
+
if (!resolved)
|
|
2151
|
+
return PluginErr("Catalog conflict not found or already resolved.", "NOT_FOUND");
|
|
2152
|
+
await this.db.update(channelEntityMap).set({
|
|
2153
|
+
heldFieldPaths,
|
|
2154
|
+
forcedPushFieldPaths,
|
|
2155
|
+
...(resolutionBaselineAt ? { lastSyncedAt: resolutionBaselineAt } : {}),
|
|
2156
|
+
}).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.id, mapping.id)));
|
|
2157
|
+
await this.db.insert(channelCatalogConflictEvents).values({
|
|
2158
|
+
organizationId: orgId,
|
|
2159
|
+
conflictId: conflict.id,
|
|
2160
|
+
fromState: "open",
|
|
2161
|
+
toState: "resolved",
|
|
2162
|
+
reason: `Operator chose the ${choose} value.`,
|
|
2163
|
+
changedBy: requireUserId(actor),
|
|
2164
|
+
});
|
|
2165
|
+
if (choose === "platform") {
|
|
2166
|
+
await this.jobs.enqueue("channel/push-catalog", {
|
|
2167
|
+
organizationId: orgId,
|
|
2168
|
+
storeId: conflict.storeId,
|
|
2169
|
+
entityIds: [conflict.entityId],
|
|
2170
|
+
}, {
|
|
2171
|
+
organizationId: orgId,
|
|
2172
|
+
concurrencyKey: catalogPushConcurrencyKey({ storeId: conflict.storeId, entityIds: [conflict.entityId] }),
|
|
2173
|
+
supersedes: true,
|
|
2174
|
+
});
|
|
2175
|
+
}
|
|
2176
|
+
return Ok(resolved);
|
|
2177
|
+
}
|
|
2178
|
+
async applyStoreConflictValue(orgId, conflict, actor) {
|
|
2179
|
+
const [root, segment, field] = conflict.fieldPath.split(".");
|
|
2180
|
+
if (root === "entity" && segment === "slug") {
|
|
2181
|
+
if (typeof conflict.storeValue !== "string")
|
|
2182
|
+
return PluginErr("The stored catalog value is not a valid slug.", "INVALID_CONFLICT_VALUE");
|
|
2183
|
+
const updated = await this.catalog.update(conflict.entityId, { slug: conflict.storeValue }, actor, CHANNEL_CONVERGENCE_CTX);
|
|
2184
|
+
return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
|
|
2185
|
+
}
|
|
2186
|
+
if (root === "entity" && segment === "status") {
|
|
2187
|
+
if (typeof conflict.storeValue !== "string")
|
|
2188
|
+
return PluginErr("The stored catalog value is not a valid status.", "INVALID_CONFLICT_VALUE");
|
|
2189
|
+
const status = ["draft", "active", "archived", "discontinued"].find((value) => value === conflict.storeValue);
|
|
2190
|
+
if (!status)
|
|
2191
|
+
return PluginErr("The stored catalog value is not a valid status.", "INVALID_CONFLICT_VALUE");
|
|
2192
|
+
const updated = await this.catalog.update(conflict.entityId, { status, isVisible: status === "active" }, actor, CHANNEL_CONVERGENCE_CTX);
|
|
2193
|
+
return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
|
|
2194
|
+
}
|
|
2195
|
+
if (root === "entity" && segment === "metadata" && field) {
|
|
2196
|
+
const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, conflict.entityId)));
|
|
2197
|
+
if (!entity)
|
|
2198
|
+
return PluginErr("Catalog entity not found.", "NOT_FOUND");
|
|
2199
|
+
const updated = await this.catalog.update(conflict.entityId, {
|
|
2200
|
+
metadata: { ...(entity.metadata ?? {}), [field]: conflict.storeValue },
|
|
2201
|
+
}, actor, CHANNEL_CONVERGENCE_CTX);
|
|
2202
|
+
return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
|
|
2203
|
+
}
|
|
2204
|
+
if (root === "attributes" && segment && field && attributeFields.some((attributeField) => attributeField === field)) {
|
|
2205
|
+
const [attribute] = await this.db.select().from(sellableAttributes).where(and(eq(sellableAttributes.entityId, conflict.entityId), eq(sellableAttributes.locale, segment)));
|
|
2206
|
+
const title = attribute?.title ?? "";
|
|
2207
|
+
const attrs = { title };
|
|
2208
|
+
if (field === "title") {
|
|
2209
|
+
if (typeof conflict.storeValue !== "string")
|
|
2210
|
+
return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
|
|
2211
|
+
attrs.title = conflict.storeValue;
|
|
2212
|
+
}
|
|
2213
|
+
else if (field === "subtitle") {
|
|
2214
|
+
if (typeof conflict.storeValue !== "string")
|
|
2215
|
+
return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
|
|
2216
|
+
attrs.subtitle = conflict.storeValue;
|
|
2217
|
+
}
|
|
2218
|
+
else if (field === "description") {
|
|
2219
|
+
if (typeof conflict.storeValue !== "string")
|
|
2220
|
+
return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
|
|
2221
|
+
attrs.description = conflict.storeValue;
|
|
2222
|
+
}
|
|
2223
|
+
else if (field === "richDescription") {
|
|
2224
|
+
attrs.richDescription = conflict.storeValue;
|
|
2225
|
+
}
|
|
2226
|
+
else if (field === "seoTitle") {
|
|
2227
|
+
if (typeof conflict.storeValue !== "string")
|
|
2228
|
+
return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
|
|
2229
|
+
attrs.seoTitle = conflict.storeValue;
|
|
2230
|
+
}
|
|
2231
|
+
else if (field === "seoDescription") {
|
|
2232
|
+
if (typeof conflict.storeValue !== "string")
|
|
2233
|
+
return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
|
|
2234
|
+
attrs.seoDescription = conflict.storeValue;
|
|
2235
|
+
}
|
|
2236
|
+
const updated = await this.catalog.setAttributes(conflict.entityId, segment, attrs, actor, CHANNEL_CONVERGENCE_CTX);
|
|
2237
|
+
return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
|
|
2238
|
+
}
|
|
2239
|
+
if (root === "customFields" && segment && field === "en") {
|
|
2240
|
+
const updated = await this.catalog.update(conflict.entityId, { customFields: { [segment]: conflict.storeValue } }, actor, CHANNEL_CONVERGENCE_CTX);
|
|
2241
|
+
return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
|
|
2242
|
+
}
|
|
2243
|
+
return PluginErr(`Conflict field path "${conflict.fieldPath}" cannot be resolved to the store value.`, "UNSUPPORTED_CONFLICT_FIELD");
|
|
2244
|
+
}
|
|
1588
2245
|
async syncInventory(orgId, storeId, actor) {
|
|
1589
2246
|
const store = await this.getStoreRecord(orgId, storeId);
|
|
1590
2247
|
if (!store || store.status !== "connected")
|
|
@@ -1784,6 +2441,19 @@ export class ChannelConnectorService {
|
|
|
1784
2441
|
const status = typeof product.status === "string" && ["draft", "active", "archived", "discontinued"].includes(product.status)
|
|
1785
2442
|
? product.status
|
|
1786
2443
|
: undefined;
|
|
2444
|
+
const customFields = product.customFields && typeof product.customFields === "object" && !Array.isArray(product.customFields)
|
|
2445
|
+
? product.customFields
|
|
2446
|
+
: undefined;
|
|
2447
|
+
const images = Array.isArray(product.images)
|
|
2448
|
+
? product.images.flatMap((raw) => {
|
|
2449
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
2450
|
+
return [];
|
|
2451
|
+
const image = raw;
|
|
2452
|
+
const role = typeof image.role === "string" ? pushCatalogImageRole(image.role) : undefined;
|
|
2453
|
+
const url = typeof image.url === "string" ? image.url : typeof image.src === "string" ? image.src : undefined;
|
|
2454
|
+
return role && url ? [{ role, url }] : [];
|
|
2455
|
+
})
|
|
2456
|
+
: [];
|
|
1787
2457
|
const remoteItem = {
|
|
1788
2458
|
externalId: mapping.externalId,
|
|
1789
2459
|
slug: typeof product.slug === "string" ? product.slug : entity.slug,
|
|
@@ -1792,6 +2462,8 @@ export class ChannelConnectorService {
|
|
|
1792
2462
|
...(status !== undefined ? { status } : {}),
|
|
1793
2463
|
attributes: [{ locale: "en", title, ...(description !== undefined ? { description } : {}) }],
|
|
1794
2464
|
...(Object.keys(remoteMetadata).length > 0 ? { metadata: remoteMetadata } : {}),
|
|
2465
|
+
...(customFields !== undefined ? { customFields } : {}),
|
|
2466
|
+
...(images.length > 0 ? { images } : {}),
|
|
1795
2467
|
variants: [],
|
|
1796
2468
|
};
|
|
1797
2469
|
const fieldPaths = [];
|
|
@@ -1808,6 +2480,17 @@ export class ChannelConnectorService {
|
|
|
1808
2480
|
fieldPaths.push("attributes.en.title");
|
|
1809
2481
|
if (product.description !== undefined)
|
|
1810
2482
|
fieldPaths.push("attributes.en.description");
|
|
2483
|
+
for (const [name, locales] of Object.entries(customFields ?? {})) {
|
|
2484
|
+
if (!locales || typeof locales !== "object" || Array.isArray(locales))
|
|
2485
|
+
continue;
|
|
2486
|
+
for (const locale of Object.keys(locales)) {
|
|
2487
|
+
const path = `customFields.${name}.${locale}`;
|
|
2488
|
+
if (isValidFieldPath(path))
|
|
2489
|
+
fieldPaths.push(path);
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
for (const image of images)
|
|
2493
|
+
fieldPaths.push(`media.${image.role}`);
|
|
1811
2494
|
const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
|
|
1812
2495
|
const seedPaths = fieldPaths.filter((path) => !ownershipBeforeSeed.has(path));
|
|
1813
2496
|
const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
|
|
@@ -1817,9 +2500,17 @@ export class ChannelConnectorService {
|
|
|
1817
2500
|
ownershipBeforeSeed.set(path, "store");
|
|
1818
2501
|
const owners = ownershipBeforeSeed;
|
|
1819
2502
|
const remoteHash = hash(product);
|
|
1820
|
-
const
|
|
2503
|
+
const outboundEcho = this.isOutboundEcho(mapping, remoteItem);
|
|
2504
|
+
const shared = await this.detectSharedConflicts(entityId, storeId, entity, mapping, remoteItem, owners, fieldPaths, remoteHash, outboundEcho ? { certifiedPaths: new Set(mapping.outboundFieldPaths ?? []) } : undefined);
|
|
2505
|
+
const persistedConflicts = await this.persistCatalogConflicts(orgId, shared.conflicts, requireUserId(actor));
|
|
2506
|
+
if (!persistedConflicts.ok)
|
|
2507
|
+
return persistedConflicts;
|
|
1821
2508
|
const owned = this.filterOwnedFieldsAtPaths(remoteItem, owners, fieldPaths);
|
|
1822
2509
|
const heldPaths = [...new Set([...(mapping.heldFieldPaths ?? []), ...shared.paths])];
|
|
2510
|
+
// Same revocation as the reconcile path: a newly held path cancels any force
|
|
2511
|
+
// left from an earlier resolution, so a webhook-raised conflict cannot be
|
|
2512
|
+
// pre-empted by an operator's answer to a previous one.
|
|
2513
|
+
const survivingForcedPaths = (mapping.forcedPushFieldPaths ?? []).filter((path) => !heldPaths.includes(path));
|
|
1823
2514
|
const held = this.filterConflictingFields(owned.writable, heldPaths);
|
|
1824
2515
|
const blockedPaths = new Set([
|
|
1825
2516
|
...owned.skipped,
|
|
@@ -1827,7 +2518,7 @@ export class ChannelConnectorService {
|
|
|
1827
2518
|
...(!fieldPaths.includes("attributes.en.title") ? ["attributes.en.title"] : []),
|
|
1828
2519
|
]);
|
|
1829
2520
|
const skipped = owned.skipped.map((fieldPath) => ({ entityId, fieldPath }));
|
|
1830
|
-
const conflicts = shared.conflicts;
|
|
2521
|
+
const conflicts = shared.conflicts.map(({ platformValue: _platformValue, storeValue: _storeValue, ...conflict }) => conflict);
|
|
1831
2522
|
const warnings = conflicts.map((conflict) => `Held shared field conflict for entity "${conflict.entityId}", store "${conflict.storeId}", field "${conflict.fieldPath}" (local ${conflict.localValueSummary}, remote ${conflict.remoteValueSummary}).`);
|
|
1832
2523
|
const writable = held.writable;
|
|
1833
2524
|
const updateInput = {};
|
|
@@ -1844,11 +2535,11 @@ export class ChannelConnectorService {
|
|
|
1844
2535
|
updateInput.isVisible = writable.status === "active";
|
|
1845
2536
|
}
|
|
1846
2537
|
if (Object.keys(updateInput).length > 0) {
|
|
1847
|
-
const updated = await this.catalog.update(entityId, updateInput, actor);
|
|
2538
|
+
const updated = await this.catalog.update(entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
|
|
1848
2539
|
if (!updated.ok)
|
|
1849
2540
|
return PluginErr(updated.error.message);
|
|
1850
2541
|
}
|
|
1851
|
-
const attributes = await this.
|
|
2542
|
+
const attributes = await this.setCatalogAttributesIfWritable(entityId, writable, actor, blockedPaths, CHANNEL_CONVERGENCE_CTX);
|
|
1852
2543
|
if (!attributes.ok)
|
|
1853
2544
|
return attributes;
|
|
1854
2545
|
const levels = Array.isArray(product.variants) ? product.variants : [];
|
|
@@ -1864,6 +2555,7 @@ export class ChannelConnectorService {
|
|
|
1864
2555
|
syncHash: remoteHash,
|
|
1865
2556
|
lastSyncedAt,
|
|
1866
2557
|
heldFieldPaths: heldPaths,
|
|
2558
|
+
forcedPushFieldPaths: survivingForcedPaths,
|
|
1867
2559
|
}).where(eq(channelEntityMap.id, mapping.id));
|
|
1868
2560
|
return Ok({ skipped, conflicts, warnings });
|
|
1869
2561
|
}
|
|
@@ -1901,9 +2593,9 @@ export class ChannelConnectorService {
|
|
|
1901
2593
|
const max = this.options.refundAutoMax ?? order.amountCaptured ?? order.grandTotal;
|
|
1902
2594
|
const ageOk = Date.now() - store.createdAt.getTime() >= (this.options.newStoreDays ?? 7) * 86_400_000;
|
|
1903
2595
|
const auto = clean && amount > 0 && ageOk && amount <= max;
|
|
1904
|
-
const rows = await this.db.insert(channelRefundRequests).values({ organizationId: orgId, storeId: store.id, orderId, remoteRefundId, amount, state: auto ? "approved" : "requested", approvedBy: auto ? actor
|
|
2596
|
+
const rows = await this.db.insert(channelRefundRequests).values({ organizationId: orgId, storeId: store.id, orderId, remoteRefundId, amount, state: auto ? "approved" : "requested", approvedBy: auto ? requireUserId(actor) : null }).returning();
|
|
1905
2597
|
const request = rows[0];
|
|
1906
|
-
await this.db.insert(channelRefundEvents).values({ organizationId: orgId, requestId: request.id, fromState: null, toState: request.state, reason: auto ? "Automatic guarded refund" : "Operator approval required", changedBy: actor
|
|
2598
|
+
await this.db.insert(channelRefundEvents).values({ organizationId: orgId, requestId: request.id, fromState: null, toState: request.state, reason: auto ? "Automatic guarded refund" : "Operator approval required", changedBy: requireUserId(actor) });
|
|
1907
2599
|
if (auto) {
|
|
1908
2600
|
const result = await this.executeRefund(request, refundLines, actor);
|
|
1909
2601
|
if (!result.ok)
|
|
@@ -1917,7 +2609,7 @@ export class ChannelConnectorService {
|
|
|
1917
2609
|
if (!result.ok)
|
|
1918
2610
|
return PluginErr(result.error?.message ?? "Refund execution failed.");
|
|
1919
2611
|
const [updated] = await this.db.update(channelRefundRequests).set({ state: "executed", updatedAt: new Date() }).where(and(eq(channelRefundRequests.organizationId, request.organizationId), eq(channelRefundRequests.id, request.id), eq(channelRefundRequests.state, "approved"))).returning();
|
|
1920
|
-
await this.db.insert(channelRefundEvents).values({ organizationId: request.organizationId, requestId: request.id, fromState: "approved", toState: "executed", reason: "Platform refund executed", changedBy: actor
|
|
2612
|
+
await this.db.insert(channelRefundEvents).values({ organizationId: request.organizationId, requestId: request.id, fromState: "approved", toState: "executed", reason: "Platform refund executed", changedBy: requireUserId(actor) });
|
|
1921
2613
|
return Ok(updated);
|
|
1922
2614
|
}
|
|
1923
2615
|
async listRefundRequests(orgId) {
|
|
@@ -2016,7 +2708,7 @@ export class ChannelConnectorService {
|
|
|
2016
2708
|
if (created.value.state === "confirmed")
|
|
2017
2709
|
return created;
|
|
2018
2710
|
if (created.value.state !== "exported") {
|
|
2019
|
-
const exported = await this.transitionExport(orgId, created.value.id, "exported", actor
|
|
2711
|
+
const exported = await this.transitionExport(orgId, created.value.id, "exported", requireUserId(actor), "Export attempt started.");
|
|
2020
2712
|
if (!exported.ok)
|
|
2021
2713
|
return exported;
|
|
2022
2714
|
}
|
|
@@ -2026,7 +2718,7 @@ export class ChannelConnectorService {
|
|
|
2026
2718
|
.where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.id, created.value.id)));
|
|
2027
2719
|
const pushed = await connector.pushOrder(store, slice);
|
|
2028
2720
|
if (!pushed.ok) {
|
|
2029
|
-
return this.transitionExport(orgId, created.value.id, "failed", actor
|
|
2721
|
+
return this.transitionExport(orgId, created.value.id, "failed", requireUserId(actor), pushed.error.message, pushed.error.retriable === true ? "transient" : "definitive");
|
|
2030
2722
|
}
|
|
2031
2723
|
await this.db
|
|
2032
2724
|
.update(channelOrderExports)
|
|
@@ -2038,13 +2730,13 @@ export class ChannelConnectorService {
|
|
|
2038
2730
|
.where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.id, created.value.id)));
|
|
2039
2731
|
const remoteStatus = await connector.fetchOrderStatus(store, pushed.value.remoteOrderId);
|
|
2040
2732
|
if (!remoteStatus.ok) {
|
|
2041
|
-
return this.transitionExport(orgId, created.value.id, "failed", actor
|
|
2733
|
+
return this.transitionExport(orgId, created.value.id, "failed", requireUserId(actor), remoteStatus.error.message, remoteStatus.error.retriable === true ? "transient" : "definitive");
|
|
2042
2734
|
}
|
|
2043
2735
|
if (remoteStatus.value.status === "confirmed") {
|
|
2044
|
-
return this.transitionExport(orgId, created.value.id, "confirmed", actor
|
|
2736
|
+
return this.transitionExport(orgId, created.value.id, "confirmed", requireUserId(actor), "Remote order confirmed.");
|
|
2045
2737
|
}
|
|
2046
2738
|
if (remoteStatus.value.status === "failed" || remoteStatus.value.status === "cancelled") {
|
|
2047
|
-
return this.transitionExport(orgId, created.value.id, "failed", actor
|
|
2739
|
+
return this.transitionExport(orgId, created.value.id, "failed", requireUserId(actor), `Remote order status: ${remoteStatus.value.status}.`);
|
|
2048
2740
|
}
|
|
2049
2741
|
const refreshed = await this.getExport(orgId, created.value.id);
|
|
2050
2742
|
return refreshed;
|
|
@@ -2133,4 +2825,347 @@ export class ChannelConnectorService {
|
|
|
2133
2825
|
abandonExport(orgId, exportId, changedBy, reason) {
|
|
2134
2826
|
return this.transitionExport(orgId, exportId, "abandoned", changedBy, reason);
|
|
2135
2827
|
}
|
|
2828
|
+
async resolveCatalogPushEntityIds(orgId, storeId, entityIds) {
|
|
2829
|
+
if (entityIds !== undefined)
|
|
2830
|
+
return [...new Set(entityIds)].sort();
|
|
2831
|
+
const mappings = await this.db.select({ entityId: channelEntityMap.entityId }).from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity")));
|
|
2832
|
+
return [...new Set(mappings.map((mapping) => mapping.entityId))].sort();
|
|
2833
|
+
}
|
|
2834
|
+
async createCatalogPush(orgId, storeId, entityId) {
|
|
2835
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
2836
|
+
if (!store || store.status !== "connected") {
|
|
2837
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
2838
|
+
}
|
|
2839
|
+
const rows = await this.db
|
|
2840
|
+
.insert(channelCatalogPushes)
|
|
2841
|
+
.values({ organizationId: orgId, storeId, entityId })
|
|
2842
|
+
.onConflictDoNothing({ target: [channelCatalogPushes.storeId, channelCatalogPushes.entityId] })
|
|
2843
|
+
.returning();
|
|
2844
|
+
if (rows[0])
|
|
2845
|
+
return Ok(rows[0]);
|
|
2846
|
+
const existing = await this.db
|
|
2847
|
+
.select()
|
|
2848
|
+
.from(channelCatalogPushes)
|
|
2849
|
+
.where(and(eq(channelCatalogPushes.organizationId, orgId), eq(channelCatalogPushes.storeId, storeId), eq(channelCatalogPushes.entityId, entityId)));
|
|
2850
|
+
if (!existing[0])
|
|
2851
|
+
return PluginErr("Failed to create channel catalog push.");
|
|
2852
|
+
return Ok(existing[0]);
|
|
2853
|
+
}
|
|
2854
|
+
async transitionCatalogPush(orgId, pushId, toState, changedBy, reason, failureKind, payloadSnapshot) {
|
|
2855
|
+
return this.transact(async (tx) => {
|
|
2856
|
+
const currentRows = await tx
|
|
2857
|
+
.select()
|
|
2858
|
+
.from(channelCatalogPushes)
|
|
2859
|
+
.where(and(eq(channelCatalogPushes.organizationId, orgId), eq(channelCatalogPushes.id, pushId)));
|
|
2860
|
+
const current = currentRows[0];
|
|
2861
|
+
if (!current)
|
|
2862
|
+
return PluginErr("Channel catalog push not found.", "NOT_FOUND");
|
|
2863
|
+
if (!canCatalogPushTransition(current.state, toState)) {
|
|
2864
|
+
const error = new CommerceInvalidTransitionError(`Cannot transition channel catalog push from ${current.state} to ${toState}.`);
|
|
2865
|
+
return PluginErr(error.message, error.code);
|
|
2866
|
+
}
|
|
2867
|
+
const updatedRows = await tx
|
|
2868
|
+
.update(channelCatalogPushes)
|
|
2869
|
+
.set({
|
|
2870
|
+
state: toState,
|
|
2871
|
+
updatedAt: new Date(),
|
|
2872
|
+
...(payloadSnapshot !== undefined ? { payloadSnapshot } : {}),
|
|
2873
|
+
...(toState === "exported" ? { attempts: current.attempts + 1, lastError: null, failureKind: null } : {}),
|
|
2874
|
+
...(toState === "failed" ? { lastError: reason ?? "Catalog push failed." } : {}),
|
|
2875
|
+
...(toState === "failed" ? { failureKind: failureKind ?? "definitive" } : {}),
|
|
2876
|
+
...(toState === "confirmed" ? { lastError: null, failureKind: null } : {}),
|
|
2877
|
+
})
|
|
2878
|
+
.where(and(eq(channelCatalogPushes.organizationId, orgId), eq(channelCatalogPushes.id, pushId), eq(channelCatalogPushes.state, current.state)))
|
|
2879
|
+
.returning();
|
|
2880
|
+
const updated = updatedRows[0];
|
|
2881
|
+
if (!updated)
|
|
2882
|
+
return PluginErr("Channel catalog push changed concurrently.", "CONFLICT");
|
|
2883
|
+
await tx.insert(channelCatalogPushEvents).values({
|
|
2884
|
+
organizationId: orgId,
|
|
2885
|
+
pushId,
|
|
2886
|
+
fromState: current.state,
|
|
2887
|
+
toState,
|
|
2888
|
+
reason: reason ?? null,
|
|
2889
|
+
changedBy,
|
|
2890
|
+
});
|
|
2891
|
+
return Ok(updated);
|
|
2892
|
+
});
|
|
2893
|
+
}
|
|
2894
|
+
async recordCatalogPushRevisions(orgId, entityIds, actor) {
|
|
2895
|
+
if (entityIds.length === 0)
|
|
2896
|
+
return Ok(undefined);
|
|
2897
|
+
try {
|
|
2898
|
+
await this.transact(async (tx) => {
|
|
2899
|
+
const txContext = createTxContext(tx, { actor });
|
|
2900
|
+
for (const entityId of [...new Set(entityIds)]) {
|
|
2901
|
+
const revision = await this.catalog.recordEntityRevision(entityId, actor, "push", txContext);
|
|
2902
|
+
if (!revision.ok)
|
|
2903
|
+
throw new Error(revision.error.message);
|
|
2904
|
+
}
|
|
2905
|
+
});
|
|
2906
|
+
}
|
|
2907
|
+
catch (error) {
|
|
2908
|
+
return PluginErr(error instanceof Error ? error.message : "Failed to record catalog push revisions.");
|
|
2909
|
+
}
|
|
2910
|
+
return Ok(undefined);
|
|
2911
|
+
}
|
|
2912
|
+
async executeCatalogPushJob(orgId, storeId, options, actor, runtime) {
|
|
2913
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
2914
|
+
if (!store || store.status !== "connected")
|
|
2915
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
2916
|
+
if (!store.catalogWriteEnabled)
|
|
2917
|
+
return Ok({ noop: true });
|
|
2918
|
+
const connector = this.connectors.get(store.provider);
|
|
2919
|
+
if (!connector?.pushCatalog)
|
|
2920
|
+
return Ok({ noop: true });
|
|
2921
|
+
if (isCatalogPushBreakerOpen(store.breakerState)) {
|
|
2922
|
+
await runtime.jobs.enqueue("channel/push-catalog", {
|
|
2923
|
+
organizationId: orgId,
|
|
2924
|
+
storeId,
|
|
2925
|
+
...(options.entityIds ? { entityIds: options.entityIds } : {}),
|
|
2926
|
+
...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
|
|
2927
|
+
...(options.cursor ? { cursor: options.cursor } : {}),
|
|
2928
|
+
}, {
|
|
2929
|
+
organizationId: orgId,
|
|
2930
|
+
concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
|
|
2931
|
+
supersedes: false,
|
|
2932
|
+
delayMs: CATALOG_PUSH_BREAKER_RETRY_MS,
|
|
2933
|
+
});
|
|
2934
|
+
return Ok({ rescheduled: true });
|
|
2935
|
+
}
|
|
2936
|
+
const allEntityIds = await this.resolveCatalogPushEntityIds(orgId, storeId, options.entityIds);
|
|
2937
|
+
const batchSize = catalogPushBatchSize(store.provider);
|
|
2938
|
+
const pageEntityIds = options.cursor
|
|
2939
|
+
? allEntityIds.filter((entityId) => entityId > options.cursor).slice(0, batchSize)
|
|
2940
|
+
: allEntityIds.slice(0, batchSize);
|
|
2941
|
+
if (pageEntityIds.length === 0)
|
|
2942
|
+
return Ok({ complete: true, pushed: 0, failed: 0 });
|
|
2943
|
+
// Abandoned is terminal: a row that exhausted its attempts stays out of
|
|
2944
|
+
// every later sweep until an operator re-arms it.
|
|
2945
|
+
const abandonedRows = await this.db.select({ entityId: channelCatalogPushes.entityId }).from(channelCatalogPushes).where(and(eq(channelCatalogPushes.organizationId, orgId), eq(channelCatalogPushes.storeId, storeId), eq(channelCatalogPushes.state, "abandoned"), inArray(channelCatalogPushes.entityId, pageEntityIds)));
|
|
2946
|
+
const abandonedEntityIds = new Set(abandonedRows.map((row) => row.entityId));
|
|
2947
|
+
const batchEntityIds = pageEntityIds.filter((entityId) => !abandonedEntityIds.has(entityId));
|
|
2948
|
+
if (batchEntityIds.length === 0) {
|
|
2949
|
+
const batchCursor = pageEntityIds[pageEntityIds.length - 1];
|
|
2950
|
+
const hasMore = allEntityIds.some((entityId) => entityId > batchCursor);
|
|
2951
|
+
if (!hasMore)
|
|
2952
|
+
return Ok({ complete: true, pushed: 0, failed: 0 });
|
|
2953
|
+
await runtime.jobs.enqueue("channel/push-catalog", {
|
|
2954
|
+
organizationId: orgId,
|
|
2955
|
+
storeId,
|
|
2956
|
+
...(options.entityIds ? { entityIds: options.entityIds } : {}),
|
|
2957
|
+
...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
|
|
2958
|
+
cursor: batchCursor,
|
|
2959
|
+
}, {
|
|
2960
|
+
organizationId: orgId,
|
|
2961
|
+
concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
|
|
2962
|
+
supersedes: false,
|
|
2963
|
+
});
|
|
2964
|
+
return Ok({ complete: false, cursor: batchCursor, pushed: 0, failed: 0 });
|
|
2965
|
+
}
|
|
2966
|
+
const assembled = await this.buildCatalogPushItems(orgId, storeId, batchEntityIds, {
|
|
2967
|
+
...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
|
|
2968
|
+
});
|
|
2969
|
+
if (!assembled.ok)
|
|
2970
|
+
return assembled;
|
|
2971
|
+
const mappings = await this.db.select({
|
|
2972
|
+
entityId: channelEntityMap.entityId,
|
|
2973
|
+
externalId: channelEntityMap.externalId,
|
|
2974
|
+
}).from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), inArray(channelEntityMap.entityId, batchEntityIds)));
|
|
2975
|
+
const externalByEntity = new Map(mappings.map((mapping) => [mapping.entityId, mapping.externalId]));
|
|
2976
|
+
const entityByExternal = new Map(mappings.map((mapping) => [mapping.externalId, mapping.entityId]));
|
|
2977
|
+
const itemByExternal = new Map(assembled.value.items.map((item) => [item.externalId, item]));
|
|
2978
|
+
let pushed = 0;
|
|
2979
|
+
let failed = 0;
|
|
2980
|
+
if (assembled.value.items.length === 0) {
|
|
2981
|
+
for (const entityId of batchEntityIds) {
|
|
2982
|
+
const created = await this.createCatalogPush(orgId, storeId, entityId);
|
|
2983
|
+
if (!created.ok)
|
|
2984
|
+
return created;
|
|
2985
|
+
if (created.value.state === "confirmed" || created.value.state === "abandoned") {
|
|
2986
|
+
if (created.value.state === "confirmed")
|
|
2987
|
+
pushed += 1;
|
|
2988
|
+
continue;
|
|
2989
|
+
}
|
|
2990
|
+
const confirmed = await this.transitionCatalogPush(orgId, created.value.id, "confirmed", requireUserId(actor), "No platform-owned fields to push.", undefined, null);
|
|
2991
|
+
if (!confirmed.ok)
|
|
2992
|
+
return confirmed;
|
|
2993
|
+
pushed += 1;
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
else {
|
|
2997
|
+
const pushIds = new Map();
|
|
2998
|
+
const pushAttempts = new Map();
|
|
2999
|
+
for (const entityId of batchEntityIds) {
|
|
3000
|
+
const externalId = externalByEntity.get(entityId);
|
|
3001
|
+
const item = externalId ? itemByExternal.get(externalId) : undefined;
|
|
3002
|
+
if (!item)
|
|
3003
|
+
continue;
|
|
3004
|
+
const created = await this.createCatalogPush(orgId, storeId, entityId);
|
|
3005
|
+
if (!created.ok)
|
|
3006
|
+
return created;
|
|
3007
|
+
pushIds.set(item.externalId, created.value.id);
|
|
3008
|
+
pushAttempts.set(item.externalId, created.value.attempts);
|
|
3009
|
+
if (created.value.state === "pending" || created.value.state === "confirmed" || created.value.state === "failed") {
|
|
3010
|
+
const exported = await this.transitionCatalogPush(orgId, created.value.id, "exported", requireUserId(actor), "Catalog push attempt started.", undefined, item);
|
|
3011
|
+
if (!exported.ok)
|
|
3012
|
+
return exported;
|
|
3013
|
+
pushAttempts.set(item.externalId, exported.value.attempts);
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
const items = assembled.value.items;
|
|
3017
|
+
const writeAhead = await this.recordOutboundPush(orgId, storeId, items.map((item) => ({ externalId: item.externalId, ok: true })), items, "write-ahead");
|
|
3018
|
+
if (!writeAhead.ok)
|
|
3019
|
+
return writeAhead;
|
|
3020
|
+
let result;
|
|
3021
|
+
try {
|
|
3022
|
+
result = await connector.pushCatalog(store, items);
|
|
3023
|
+
}
|
|
3024
|
+
catch (error) {
|
|
3025
|
+
const connectorError = {
|
|
3026
|
+
code: "CATALOG_PUSH_THROWN",
|
|
3027
|
+
message: error instanceof Error ? error.message : "Catalog push failed.",
|
|
3028
|
+
};
|
|
3029
|
+
const cleared = await this.recordOutboundPush(orgId, storeId, items.map((item) => ({ externalId: item.externalId, ok: false, error: connectorError })), items);
|
|
3030
|
+
if (!cleared.ok)
|
|
3031
|
+
return cleared;
|
|
3032
|
+
for (const item of items) {
|
|
3033
|
+
const pushId = pushIds.get(item.externalId);
|
|
3034
|
+
if (!pushId)
|
|
3035
|
+
continue;
|
|
3036
|
+
const attempts = pushAttempts.get(item.externalId) ?? 0;
|
|
3037
|
+
if (attempts >= CATALOG_PUSH_MAX_ATTEMPTS) {
|
|
3038
|
+
await this.transitionCatalogPush(orgId, pushId, "abandoned", requireUserId(actor), connectorError.message);
|
|
3039
|
+
}
|
|
3040
|
+
else {
|
|
3041
|
+
await this.transitionCatalogPush(orgId, pushId, "failed", requireUserId(actor), connectorError.message, "transient");
|
|
3042
|
+
}
|
|
3043
|
+
failed += 1;
|
|
3044
|
+
}
|
|
3045
|
+
const maxAttempts = Math.max(0, ...items.map((item) => pushAttempts.get(item.externalId) ?? 0));
|
|
3046
|
+
if (maxAttempts < CATALOG_PUSH_MAX_ATTEMPTS) {
|
|
3047
|
+
await runtime.jobs.enqueue("channel/push-catalog", {
|
|
3048
|
+
organizationId: orgId,
|
|
3049
|
+
storeId,
|
|
3050
|
+
...(options.entityIds ? { entityIds: options.entityIds } : {}),
|
|
3051
|
+
...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
|
|
3052
|
+
...(options.cursor ? { cursor: options.cursor } : {}),
|
|
3053
|
+
}, {
|
|
3054
|
+
organizationId: orgId,
|
|
3055
|
+
concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
|
|
3056
|
+
supersedes: false,
|
|
3057
|
+
delayMs: catalogPushRetryDelayMs(maxAttempts),
|
|
3058
|
+
});
|
|
3059
|
+
}
|
|
3060
|
+
return Ok({ rescheduled: true, pushed, failed });
|
|
3061
|
+
}
|
|
3062
|
+
if (!result.ok) {
|
|
3063
|
+
const cleared = await this.recordOutboundPush(orgId, storeId, items.map((item) => ({ externalId: item.externalId, ok: false, error: result.error })), items);
|
|
3064
|
+
if (!cleared.ok)
|
|
3065
|
+
return cleared;
|
|
3066
|
+
for (const item of items) {
|
|
3067
|
+
const pushId = pushIds.get(item.externalId);
|
|
3068
|
+
if (!pushId)
|
|
3069
|
+
continue;
|
|
3070
|
+
const attempts = pushAttempts.get(item.externalId) ?? 0;
|
|
3071
|
+
const failureKind = result.error.retriable === true ? "transient" : "definitive";
|
|
3072
|
+
if (failureKind === "transient" && attempts >= CATALOG_PUSH_MAX_ATTEMPTS) {
|
|
3073
|
+
await this.transitionCatalogPush(orgId, pushId, "abandoned", requireUserId(actor), result.error.message);
|
|
3074
|
+
}
|
|
3075
|
+
else {
|
|
3076
|
+
await this.transitionCatalogPush(orgId, pushId, "failed", requireUserId(actor), result.error.message, failureKind);
|
|
3077
|
+
}
|
|
3078
|
+
failed += 1;
|
|
3079
|
+
}
|
|
3080
|
+
if (result.error.retriable === true) {
|
|
3081
|
+
const maxAttempts = Math.max(0, ...items.map((item) => pushAttempts.get(item.externalId) ?? 0));
|
|
3082
|
+
if (maxAttempts < CATALOG_PUSH_MAX_ATTEMPTS) {
|
|
3083
|
+
await runtime.jobs.enqueue("channel/push-catalog", {
|
|
3084
|
+
organizationId: orgId,
|
|
3085
|
+
storeId,
|
|
3086
|
+
...(options.entityIds ? { entityIds: options.entityIds } : {}),
|
|
3087
|
+
...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
|
|
3088
|
+
...(options.cursor ? { cursor: options.cursor } : {}),
|
|
3089
|
+
}, {
|
|
3090
|
+
organizationId: orgId,
|
|
3091
|
+
concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
|
|
3092
|
+
supersedes: false,
|
|
3093
|
+
delayMs: catalogPushRetryDelayMs(maxAttempts),
|
|
3094
|
+
});
|
|
3095
|
+
}
|
|
3096
|
+
return Ok({ rescheduled: true, pushed, failed });
|
|
3097
|
+
}
|
|
3098
|
+
const batchCursor = pageEntityIds[pageEntityIds.length - 1];
|
|
3099
|
+
const hasMore = allEntityIds.some((entityId) => entityId > batchCursor);
|
|
3100
|
+
return Ok({ complete: !hasMore, pushed, failed });
|
|
3101
|
+
}
|
|
3102
|
+
const recorded = await this.recordOutboundPush(orgId, storeId, result.value.outcomes, items);
|
|
3103
|
+
if (!recorded.ok)
|
|
3104
|
+
return recorded;
|
|
3105
|
+
const successfulEntityIds = [];
|
|
3106
|
+
for (const outcome of result.value.outcomes) {
|
|
3107
|
+
const pushId = pushIds.get(outcome.externalId);
|
|
3108
|
+
const entityId = entityByExternal.get(outcome.externalId);
|
|
3109
|
+
if (!pushId || !entityId)
|
|
3110
|
+
continue;
|
|
3111
|
+
const item = itemByExternal.get(outcome.externalId);
|
|
3112
|
+
if (outcome.ok) {
|
|
3113
|
+
const confirmed = await this.transitionCatalogPush(orgId, pushId, "confirmed", requireUserId(actor), "Remote catalog confirmed item.", undefined, item ?? null);
|
|
3114
|
+
if (!confirmed.ok)
|
|
3115
|
+
return confirmed;
|
|
3116
|
+
successfulEntityIds.push(entityId);
|
|
3117
|
+
pushed += 1;
|
|
3118
|
+
continue;
|
|
3119
|
+
}
|
|
3120
|
+
const failureKind = outcome.error?.retriable === true ? "transient" : "definitive";
|
|
3121
|
+
const attempts = pushAttempts.get(outcome.externalId) ?? 0;
|
|
3122
|
+
if (failureKind === "transient" && attempts >= CATALOG_PUSH_MAX_ATTEMPTS) {
|
|
3123
|
+
const abandoned = await this.transitionCatalogPush(orgId, pushId, "abandoned", requireUserId(actor), outcome.error?.message ?? "Catalog push failed.", undefined, item ?? null);
|
|
3124
|
+
if (!abandoned.ok)
|
|
3125
|
+
return abandoned;
|
|
3126
|
+
}
|
|
3127
|
+
else {
|
|
3128
|
+
const failedPush = await this.transitionCatalogPush(orgId, pushId, "failed", requireUserId(actor), outcome.error?.message ?? "Catalog push failed.", failureKind, item ?? null);
|
|
3129
|
+
if (!failedPush.ok)
|
|
3130
|
+
return failedPush;
|
|
3131
|
+
if (failureKind === "transient") {
|
|
3132
|
+
await runtime.jobs.enqueue("channel/push-catalog", {
|
|
3133
|
+
organizationId: orgId,
|
|
3134
|
+
storeId,
|
|
3135
|
+
entityIds: [entityId],
|
|
3136
|
+
...(options.forceFieldPaths?.[entityId]
|
|
3137
|
+
? { forceFieldPaths: { [entityId]: options.forceFieldPaths[entityId] } }
|
|
3138
|
+
: {}),
|
|
3139
|
+
}, {
|
|
3140
|
+
organizationId: orgId,
|
|
3141
|
+
concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: [entityId] }),
|
|
3142
|
+
supersedes: true,
|
|
3143
|
+
delayMs: catalogPushRetryDelayMs(attempts),
|
|
3144
|
+
});
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
failed += 1;
|
|
3148
|
+
}
|
|
3149
|
+
const revisions = await this.recordCatalogPushRevisions(orgId, successfulEntityIds, actor);
|
|
3150
|
+
if (!revisions.ok)
|
|
3151
|
+
return revisions;
|
|
3152
|
+
}
|
|
3153
|
+
const batchCursor = pageEntityIds[pageEntityIds.length - 1];
|
|
3154
|
+
const hasMore = allEntityIds.some((entityId) => entityId > batchCursor);
|
|
3155
|
+
if (hasMore) {
|
|
3156
|
+
await runtime.jobs.enqueue("channel/push-catalog", {
|
|
3157
|
+
organizationId: orgId,
|
|
3158
|
+
storeId,
|
|
3159
|
+
...(options.entityIds ? { entityIds: options.entityIds } : {}),
|
|
3160
|
+
...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
|
|
3161
|
+
cursor: batchCursor,
|
|
3162
|
+
}, {
|
|
3163
|
+
organizationId: orgId,
|
|
3164
|
+
concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
|
|
3165
|
+
supersedes: false,
|
|
3166
|
+
});
|
|
3167
|
+
return Ok({ complete: false, cursor: batchCursor, pushed, failed });
|
|
3168
|
+
}
|
|
3169
|
+
return Ok({ complete: true, pushed, failed });
|
|
3170
|
+
}
|
|
2136
3171
|
}
|