@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/src/service.ts
CHANGED
|
@@ -16,16 +16,20 @@ import type {
|
|
|
16
16
|
ChannelPushCatalogImage,
|
|
17
17
|
ChannelPushCatalogIntent,
|
|
18
18
|
ChannelPushCatalogItem,
|
|
19
|
+
ChannelPushCatalogItemOutcome,
|
|
20
|
+
ChannelPushCatalogResult,
|
|
19
21
|
ChannelStore,
|
|
20
22
|
PluginDb,
|
|
21
23
|
PluginResult,
|
|
22
24
|
PluginTxFn,
|
|
25
|
+
CatalogWriteContext,
|
|
23
26
|
TxContext,
|
|
24
27
|
} from "@porulle/core";
|
|
25
|
-
import { isValidFieldPath } from "@porulle/core";
|
|
28
|
+
import { isValidFieldPath, requireUserId } from "@porulle/core";
|
|
26
29
|
import type { FieldOwner, FieldPath } from "@porulle/core";
|
|
27
30
|
import type { JobsAdapter } from "@porulle/core";
|
|
28
|
-
import {
|
|
31
|
+
import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
|
|
32
|
+
import { and, desc, eq, inArray, isNull, lte } from "@porulle/core/drizzle";
|
|
29
33
|
import {
|
|
30
34
|
brands,
|
|
31
35
|
categories,
|
|
@@ -43,18 +47,26 @@ import {
|
|
|
43
47
|
sellableAttributes,
|
|
44
48
|
sellableCustomFields,
|
|
45
49
|
sellableEntities,
|
|
50
|
+
sellableEntityRevisions,
|
|
46
51
|
entityFieldDefinitions,
|
|
47
52
|
tags,
|
|
48
53
|
variants,
|
|
49
54
|
variantOptionValues,
|
|
50
55
|
} from "@porulle/core/schema";
|
|
56
|
+
import type { SellableEntityRevisionSnapshot } from "@porulle/core/schema";
|
|
51
57
|
import {
|
|
58
|
+
channelCatalogPushEvents,
|
|
59
|
+
channelCatalogPushes,
|
|
60
|
+
channelCatalogConflicts,
|
|
61
|
+
channelCatalogConflictEvents,
|
|
52
62
|
channelEntityMap,
|
|
53
63
|
channelExportEvents,
|
|
54
64
|
channelOrderExports,
|
|
55
65
|
connectedStores,
|
|
56
66
|
channelRefundEvents,
|
|
57
67
|
channelRefundRequests,
|
|
68
|
+
type ChannelCatalogPush,
|
|
69
|
+
type ChannelCatalogConflict,
|
|
58
70
|
type ChannelOrderExport,
|
|
59
71
|
type ChannelRefundRequest,
|
|
60
72
|
type ConnectedStore,
|
|
@@ -65,15 +77,72 @@ import {
|
|
|
65
77
|
selectCatalogFieldMapping,
|
|
66
78
|
type CatalogFieldMapping,
|
|
67
79
|
type CatalogFieldMappingInput,
|
|
80
|
+
type CatalogFieldTarget,
|
|
68
81
|
} from "./catalog-field-mapping.js";
|
|
69
82
|
|
|
70
83
|
export type ExportState = ChannelOrderExport["state"];
|
|
84
|
+
export type CatalogPushState = ChannelCatalogPush["state"];
|
|
85
|
+
export type CatalogConflictState = ChannelCatalogConflict["state"];
|
|
86
|
+
|
|
87
|
+
export const CATALOG_PUSH_BATCH_SIZES: Record<string, number> = {
|
|
88
|
+
mock: 100,
|
|
89
|
+
shopify: 50,
|
|
90
|
+
woocommerce: 100,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const DEFAULT_CATALOG_PUSH_BATCH_SIZE = 50;
|
|
94
|
+
const CATALOG_PUSH_BREAKER_RETRY_MS = 60_000;
|
|
95
|
+
export const CATALOG_PUSH_MAX_ATTEMPTS = 8;
|
|
96
|
+
const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
|
|
97
|
+
const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
|
|
98
|
+
|
|
99
|
+
export function catalogPushRetryDelayMs(attempts: number): number {
|
|
100
|
+
const exponent = Math.max(0, attempts - 1);
|
|
101
|
+
return Math.min(CATALOG_PUSH_RETRY_BASE_MS * (2 ** exponent), CATALOG_PUSH_RETRY_MAX_MS);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface CatalogPushJobResult extends Record<string, unknown> {
|
|
105
|
+
noop?: boolean;
|
|
106
|
+
rescheduled?: boolean;
|
|
107
|
+
complete?: boolean;
|
|
108
|
+
cursor?: string;
|
|
109
|
+
pushed?: number;
|
|
110
|
+
failed?: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function catalogPushConcurrencyKey(input: Record<string, unknown>): string {
|
|
114
|
+
const storeId = String(input.storeId);
|
|
115
|
+
const entityIds = input.entityIds;
|
|
116
|
+
if (Array.isArray(entityIds) && entityIds.length === 1) {
|
|
117
|
+
return `push:${String(entityIds[0])}:${storeId}`;
|
|
118
|
+
}
|
|
119
|
+
return `push-catalog:${storeId}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function isCatalogPushBreakerOpen(breakerState: Record<string, unknown>): boolean {
|
|
123
|
+
if (breakerState.open === true) {
|
|
124
|
+
const openUntil = breakerState.openUntil;
|
|
125
|
+
if (typeof openUntil === "string" && new Date(openUntil) <= new Date()) return false;
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
const catalogPush = breakerState.catalogPush;
|
|
129
|
+
if (!catalogPush || typeof catalogPush !== "object") return false;
|
|
130
|
+
const state = catalogPush as { open?: boolean; openUntil?: string };
|
|
131
|
+
if (state.open !== true) return false;
|
|
132
|
+
if (typeof state.openUntil === "string" && new Date(state.openUntil) <= new Date()) return false;
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function catalogPushBatchSize(provider: string): number {
|
|
137
|
+
return CATALOG_PUSH_BATCH_SIZES[provider] ?? DEFAULT_CATALOG_PUSH_BATCH_SIZE;
|
|
138
|
+
}
|
|
71
139
|
|
|
72
140
|
export interface ReconcileReport extends Record<string, unknown> {
|
|
73
141
|
imported: number;
|
|
74
142
|
converged: number;
|
|
75
143
|
archived: number;
|
|
76
144
|
inventoryUpdated: number;
|
|
145
|
+
openConflicts: number;
|
|
77
146
|
driftAlert: boolean;
|
|
78
147
|
skipped?: CatalogFieldSkip[];
|
|
79
148
|
conflicts?: CatalogFieldConflict[];
|
|
@@ -88,17 +157,26 @@ export interface CatalogFieldConflict {
|
|
|
88
157
|
remoteValueSummary: string;
|
|
89
158
|
}
|
|
90
159
|
|
|
160
|
+
interface DetectedCatalogFieldConflict extends CatalogFieldConflict {
|
|
161
|
+
platformValue: unknown;
|
|
162
|
+
storeValue: unknown;
|
|
163
|
+
}
|
|
164
|
+
|
|
91
165
|
export interface CatalogFieldSkip {
|
|
92
166
|
entityId: string;
|
|
93
167
|
fieldPath: FieldPath;
|
|
94
168
|
}
|
|
95
169
|
|
|
96
|
-
export type CatalogPushSkipReason = "no_mapping" | "held" | "entity_not_active" | "unmapped_entity";
|
|
170
|
+
export type CatalogPushSkipReason = "no_mapping" | "held" | "store_owned" | "entity_not_active" | "unmapped_entity";
|
|
97
171
|
|
|
98
172
|
export interface CatalogPushFieldSkip {
|
|
99
173
|
entityId: string;
|
|
100
174
|
fieldPath: FieldPath;
|
|
101
175
|
reason: CatalogPushSkipReason;
|
|
176
|
+
value?: unknown;
|
|
177
|
+
owner?: FieldOwner;
|
|
178
|
+
target?: CatalogFieldTarget;
|
|
179
|
+
remoteKey?: string;
|
|
102
180
|
}
|
|
103
181
|
|
|
104
182
|
export type PublicConnectedStore = Omit<ConnectedStore, "credentials" | "webhookSecret"> & {
|
|
@@ -176,10 +254,61 @@ export interface BackfillCatalogOptions {
|
|
|
176
254
|
|
|
177
255
|
export interface BuildCatalogPushItemsOptions {
|
|
178
256
|
recordRevision?: boolean;
|
|
257
|
+
forceFieldPaths?: Record<string, FieldPath[]>;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface CatalogPushAssemblyField extends ChannelPushCatalogField {
|
|
261
|
+
target: CatalogFieldTarget;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface CatalogPushAssemblyImage extends ChannelPushCatalogImage {
|
|
265
|
+
fieldPath: FieldPath;
|
|
266
|
+
target: CatalogFieldTarget;
|
|
267
|
+
remoteKey: string;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface CatalogPushAssemblyItem extends Omit<ChannelPushCatalogItem, "fields" | "images"> {
|
|
271
|
+
fields: CatalogPushAssemblyField[];
|
|
272
|
+
images?: CatalogPushAssemblyImage[];
|
|
179
273
|
}
|
|
180
274
|
|
|
181
275
|
export interface BuildCatalogPushItemsResult {
|
|
182
|
-
items:
|
|
276
|
+
items: CatalogPushAssemblyItem[];
|
|
277
|
+
skipped: CatalogPushFieldSkip[];
|
|
278
|
+
warnings: string[];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface PushCatalogToStoreResult extends ChannelPushCatalogResult {
|
|
282
|
+
skipped: CatalogPushFieldSkip[];
|
|
283
|
+
warnings: string[];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface CatalogPushPreviewUnavailable {
|
|
287
|
+
status: "unavailable";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export type CatalogPushPreviewBefore = unknown | CatalogPushPreviewUnavailable;
|
|
291
|
+
export type CatalogPushPreviewBeforeStatus = "value" | "missing" | "unavailable";
|
|
292
|
+
|
|
293
|
+
export interface CatalogPushPreviewDiff {
|
|
294
|
+
fieldPath: FieldPath;
|
|
295
|
+
target: CatalogFieldTarget | null;
|
|
296
|
+
remoteKey: string | null;
|
|
297
|
+
before: CatalogPushPreviewBefore;
|
|
298
|
+
beforeStatus: CatalogPushPreviewBeforeStatus;
|
|
299
|
+
after: unknown;
|
|
300
|
+
owner: FieldOwner;
|
|
301
|
+
willWrite: boolean;
|
|
302
|
+
reason?: CatalogPushSkipReason;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export interface CatalogPushPreviewItem {
|
|
306
|
+
externalId: string;
|
|
307
|
+
diffs: CatalogPushPreviewDiff[];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export interface CatalogPushPreviewResult {
|
|
311
|
+
items: CatalogPushPreviewItem[];
|
|
183
312
|
skipped: CatalogPushFieldSkip[];
|
|
184
313
|
warnings: string[];
|
|
185
314
|
}
|
|
@@ -199,8 +328,9 @@ interface CatalogService {
|
|
|
199
328
|
};
|
|
200
329
|
update(
|
|
201
330
|
id: string,
|
|
202
|
-
input: { slug?: string; status?: string; metadata?: Record<string, unknown>; isVisible?: boolean },
|
|
331
|
+
input: { slug?: string; status?: string; metadata?: Record<string, unknown>; isVisible?: boolean; customFields?: Record<string, unknown | null> },
|
|
203
332
|
actor: Actor,
|
|
333
|
+
ctx?: CatalogWriteContext,
|
|
204
334
|
): Promise<{ ok: true; value: unknown } | { ok: false; error: { message: string } }>;
|
|
205
335
|
archive(id: string, actor: Actor): Promise<{ ok: true; value: unknown } | { ok: false; error: { message: string } }>;
|
|
206
336
|
create(
|
|
@@ -230,6 +360,7 @@ interface CatalogService {
|
|
|
230
360
|
seoDescription?: string;
|
|
231
361
|
},
|
|
232
362
|
actor: Actor,
|
|
363
|
+
ctx?: CatalogWriteContext,
|
|
233
364
|
): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
|
|
234
365
|
recordEntityRevision(
|
|
235
366
|
entityId: string,
|
|
@@ -238,6 +369,13 @@ interface CatalogService {
|
|
|
238
369
|
ctx?: TxContext<PluginDb>,
|
|
239
370
|
): Promise<{ ok: true; value: unknown } | { ok: false; error: { message: string } }>;
|
|
240
371
|
resolveFieldOwners(entityId: string, storeId: string): Promise<Map<FieldPath, FieldOwner>>;
|
|
372
|
+
setFieldOwner(
|
|
373
|
+
entityId: string,
|
|
374
|
+
fieldPath: FieldPath,
|
|
375
|
+
storeId: string | null,
|
|
376
|
+
owner: FieldOwner,
|
|
377
|
+
actor: Actor | null,
|
|
378
|
+
): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
|
|
241
379
|
seedImportedFieldOwnership(entityId: string, storeId: string, fieldPaths: FieldPath[]): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
|
|
242
380
|
createOptionType(
|
|
243
381
|
input: { entityId: string; name: string; values?: string[] },
|
|
@@ -326,10 +464,127 @@ export function canExportTransition(from: ExportState, to: ExportState): boolean
|
|
|
326
464
|
return exportTransitions[from].includes(to);
|
|
327
465
|
}
|
|
328
466
|
|
|
467
|
+
// Catalog pushes recur; confirmed/failed rows re-arm through exported, and rows with nothing to push resolve directly.
|
|
468
|
+
const catalogPushTransitions: Record<CatalogPushState, readonly CatalogPushState[]> = {
|
|
469
|
+
pending: ["exported", "confirmed", "abandoned"],
|
|
470
|
+
exported: ["confirmed", "failed", "abandoned"],
|
|
471
|
+
confirmed: ["exported", "abandoned"],
|
|
472
|
+
failed: ["exported", "confirmed", "abandoned"],
|
|
473
|
+
abandoned: [],
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
export function canCatalogPushTransition(from: CatalogPushState, to: CatalogPushState): boolean {
|
|
477
|
+
return catalogPushTransitions[from].includes(to);
|
|
478
|
+
}
|
|
479
|
+
|
|
329
480
|
function hash(value: unknown): string {
|
|
330
481
|
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
331
482
|
}
|
|
332
483
|
|
|
484
|
+
export const CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS = 15 * 60 * 1000;
|
|
485
|
+
|
|
486
|
+
function normalizeCanonicalValue(value: unknown): unknown {
|
|
487
|
+
if (typeof value === "string") return value.replace(/\r\n?/g, "\n").replace(/\s+/g, " ").trim();
|
|
488
|
+
if (Array.isArray(value)) return value.map(normalizeCanonicalValue).sort((left, right) => (JSON.stringify(left) ?? "").localeCompare(JSON.stringify(right) ?? ""));
|
|
489
|
+
if (value && typeof value === "object") {
|
|
490
|
+
return Object.fromEntries(Object.entries(value)
|
|
491
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
492
|
+
.map(([key, nested]) => [key, normalizeCanonicalValue(nested)]));
|
|
493
|
+
}
|
|
494
|
+
return value;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function normalizedValuesEqual(left: unknown, right: unknown): boolean {
|
|
498
|
+
return JSON.stringify(normalizeCanonicalValue(left)) === JSON.stringify(normalizeCanonicalValue(right));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function snapshotCustomFieldValue(field: Record<string, unknown>): unknown {
|
|
502
|
+
if (field.textValue !== null && field.textValue !== undefined) return field.textValue;
|
|
503
|
+
if (field.numberValue !== null && field.numberValue !== undefined) return field.numberValue;
|
|
504
|
+
if (field.booleanValue !== null && field.booleanValue !== undefined) return field.booleanValue;
|
|
505
|
+
if (field.dateValue !== null && field.dateValue !== undefined) return field.dateValue;
|
|
506
|
+
return field.jsonValue;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function snapshotFieldValue(
|
|
510
|
+
snapshot: SellableEntityRevisionSnapshot,
|
|
511
|
+
path: FieldPath,
|
|
512
|
+
): { found: boolean; value: unknown } {
|
|
513
|
+
const [root, segment, field] = path.split(".");
|
|
514
|
+
if (root === "entity" && segment === "slug") return { found: true, value: snapshot.entity.slug };
|
|
515
|
+
if (root === "entity" && segment === "status") return { found: true, value: snapshot.entity.status };
|
|
516
|
+
if (root === "entity" && segment === "metadata") {
|
|
517
|
+
const metadata = snapshot.entity.metadata;
|
|
518
|
+
return {
|
|
519
|
+
found: true,
|
|
520
|
+
value: metadata && typeof metadata === "object" ? (metadata as Record<string, unknown>)[field ?? ""] : undefined,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
if (root === "attributes" && segment && field) {
|
|
524
|
+
const attribute = snapshot.attributes.find((row) => row.locale === segment);
|
|
525
|
+
return { found: true, value: attribute?.[field] };
|
|
526
|
+
}
|
|
527
|
+
if (root === "customFields" && segment && field) {
|
|
528
|
+
const customField = snapshot.customFields.find((row) => row.fieldName === segment && row.locale === field && row.status === "approved");
|
|
529
|
+
return { found: true, value: customField ? snapshotCustomFieldValue(customField) : undefined };
|
|
530
|
+
}
|
|
531
|
+
if (root === "media" && segment) {
|
|
532
|
+
return {
|
|
533
|
+
found: true,
|
|
534
|
+
value: snapshot.media.filter((row) => row.role === segment).map((row) => row.mediaAssetId),
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
return { found: false, value: undefined };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
interface CanonicalOutboundField {
|
|
541
|
+
fieldPath: string;
|
|
542
|
+
value: unknown;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function canonicalHash(
|
|
546
|
+
externalId: string,
|
|
547
|
+
fieldPaths: FieldPath[],
|
|
548
|
+
valueAtPath: (fieldPath: FieldPath) => unknown,
|
|
549
|
+
): string {
|
|
550
|
+
const fields: CanonicalOutboundField[] = fieldPaths.flatMap((fieldPath) => {
|
|
551
|
+
const value = valueAtPath(fieldPath);
|
|
552
|
+
return value === undefined ? [] : [{ fieldPath, value: normalizeCanonicalValue(value) }];
|
|
553
|
+
});
|
|
554
|
+
return hash({
|
|
555
|
+
externalId,
|
|
556
|
+
fields: fields.sort((left, right) => left.fieldPath.localeCompare(right.fieldPath)),
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function outboundFieldPaths(item: ChannelPushCatalogItem): FieldPath[] {
|
|
561
|
+
const paths = new Set<FieldPath>(item.fields.flatMap((field) => isValidFieldPath(field.fieldPath) ? [field.fieldPath] : []));
|
|
562
|
+
for (const image of item.images ?? []) paths.add(`media.${image.role}`);
|
|
563
|
+
return [...paths].sort();
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function pushFieldValue(item: ChannelPushCatalogItem, fieldPath: FieldPath): unknown {
|
|
567
|
+
if (fieldPath.startsWith("media.")) {
|
|
568
|
+
const role = fieldPath.slice("media.".length);
|
|
569
|
+
return (item.images ?? [])
|
|
570
|
+
.filter((image) => image.role === role)
|
|
571
|
+
.map((image) => ({ url: image.url, role: image.role }));
|
|
572
|
+
}
|
|
573
|
+
return item.fields.find((field) => field.fieldPath === fieldPath)?.value;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function canonicalOutboundHash(externalId: string, item: ChannelPushCatalogItem, fieldPaths: FieldPath[]): string {
|
|
577
|
+
return canonicalHash(externalId, fieldPaths, (fieldPath) => pushFieldValue(item, fieldPath));
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function canonicalInboundHash(
|
|
581
|
+
externalId: string,
|
|
582
|
+
fieldPaths: FieldPath[],
|
|
583
|
+
remoteFieldValue: (path: FieldPath) => unknown,
|
|
584
|
+
): string {
|
|
585
|
+
return canonicalHash(externalId, fieldPaths, remoteFieldValue);
|
|
586
|
+
}
|
|
587
|
+
|
|
333
588
|
function mergeMetadata(
|
|
334
589
|
existing: Record<string, unknown> | null | undefined,
|
|
335
590
|
remote: Record<string, unknown>,
|
|
@@ -372,7 +627,7 @@ function pushCatalogField(
|
|
|
372
627
|
fieldPath: FieldPath,
|
|
373
628
|
value: unknown,
|
|
374
629
|
mapping: { target: "native" | "attribute" | "meta"; remoteKey: string },
|
|
375
|
-
):
|
|
630
|
+
): CatalogPushAssemblyField {
|
|
376
631
|
const segments = fieldPath.split(".");
|
|
377
632
|
const locale = fieldPath.startsWith("attributes.")
|
|
378
633
|
? segments[1]
|
|
@@ -385,6 +640,7 @@ function pushCatalogField(
|
|
|
385
640
|
value,
|
|
386
641
|
...(locale !== undefined ? { locale } : {}),
|
|
387
642
|
remoteKey: mapping.remoteKey,
|
|
643
|
+
target: mapping.target,
|
|
388
644
|
};
|
|
389
645
|
}
|
|
390
646
|
|
|
@@ -407,6 +663,14 @@ function importedFieldPaths(item: ChannelCatalogItem): FieldPath[] {
|
|
|
407
663
|
if (attribute[field] !== undefined) paths.add(`attributes.${attribute.locale}.${field}`);
|
|
408
664
|
}
|
|
409
665
|
}
|
|
666
|
+
const customFields = (item as ChannelCatalogItem & { customFields?: Record<string, Record<string, unknown>> }).customFields;
|
|
667
|
+
for (const [name, locales] of Object.entries(customFields ?? {})) {
|
|
668
|
+
if (!locales || typeof locales !== "object" || Array.isArray(locales)) continue;
|
|
669
|
+
for (const locale of Object.keys(locales)) {
|
|
670
|
+
const path = `customFields.${name}.${locale}`;
|
|
671
|
+
if (isValidFieldPath(path)) paths.add(path);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
410
674
|
for (const image of item.images ?? []) paths.add(`media.${image.role}`);
|
|
411
675
|
if (item.options?.length) paths.add("options");
|
|
412
676
|
if (item.variants.some((variant) => variant.sku !== undefined)) paths.add("variants.sku");
|
|
@@ -608,6 +872,13 @@ export class ChannelConnectorService {
|
|
|
608
872
|
if (root === "entity" && segment === "slug") return item.slug;
|
|
609
873
|
if (root === "entity" && segment === "status") return item.status;
|
|
610
874
|
if (root === "entity" && segment === "metadata") return item.metadata?.[field ?? ""];
|
|
875
|
+
if (root === "customFields" && segment && field) {
|
|
876
|
+
const customFields = (item as ChannelCatalogItem & { customFields?: Record<string, unknown> }).customFields;
|
|
877
|
+
const customField = customFields?.[segment];
|
|
878
|
+
if (customField && typeof customField === "object" && !Array.isArray(customField)) {
|
|
879
|
+
return (customField as Record<string, unknown>)[field];
|
|
880
|
+
}
|
|
881
|
+
}
|
|
611
882
|
if (root === "attributes" && segment && field) {
|
|
612
883
|
const attributes = item.attributes?.length
|
|
613
884
|
? item.attributes
|
|
@@ -615,7 +886,11 @@ export class ChannelConnectorService {
|
|
|
615
886
|
const attribute = attributes.find((row) => row.locale === segment);
|
|
616
887
|
return attribute?.[field as keyof typeof attribute];
|
|
617
888
|
}
|
|
618
|
-
if (root === "media" && segment)
|
|
889
|
+
if (root === "media" && segment) {
|
|
890
|
+
return (item.images ?? [])
|
|
891
|
+
.filter((image) => image.role === segment)
|
|
892
|
+
.map((image) => ({ url: image.url, role: image.role }));
|
|
893
|
+
}
|
|
619
894
|
if (path === "options") return item.options;
|
|
620
895
|
if (path === "variants.sku") return item.variants.map((variant) => variant.sku);
|
|
621
896
|
if (path === "variants.barcode") return item.variants.map((variant) => variant.barcode);
|
|
@@ -623,6 +898,17 @@ export class ChannelConnectorService {
|
|
|
623
898
|
return undefined;
|
|
624
899
|
}
|
|
625
900
|
|
|
901
|
+
private isOutboundEcho(
|
|
902
|
+
mapping: typeof channelEntityMap.$inferSelect,
|
|
903
|
+
item: ChannelCatalogItem,
|
|
904
|
+
): boolean {
|
|
905
|
+
if (!mapping.outboundHash || !mapping.outboundPushedAt || mapping.outboundFieldPaths.length === 0) return false;
|
|
906
|
+
const age = Date.now() - mapping.outboundPushedAt.getTime();
|
|
907
|
+
if (age < 0 || age > CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS) return false;
|
|
908
|
+
const inboundHash = canonicalInboundHash(mapping.externalId, mapping.outboundFieldPaths, (fieldPath) => this.remoteFieldValue(item, fieldPath));
|
|
909
|
+
return inboundHash === mapping.outboundHash;
|
|
910
|
+
}
|
|
911
|
+
|
|
626
912
|
private async localFieldValue(
|
|
627
913
|
entityId: string,
|
|
628
914
|
entity: typeof sellableEntities.$inferSelect,
|
|
@@ -649,6 +935,15 @@ export class ChannelConnectorService {
|
|
|
649
935
|
: {};
|
|
650
936
|
return values[field];
|
|
651
937
|
}
|
|
938
|
+
if (root === "customFields" && segment && field) {
|
|
939
|
+
const [customField] = await this.db.select().from(sellableCustomFields).where(and(
|
|
940
|
+
eq(sellableCustomFields.entityId, entityId),
|
|
941
|
+
eq(sellableCustomFields.fieldName, segment),
|
|
942
|
+
eq(sellableCustomFields.locale, field),
|
|
943
|
+
eq(sellableCustomFields.status, "approved"),
|
|
944
|
+
));
|
|
945
|
+
return customField ? customFieldValue(customField) : undefined;
|
|
946
|
+
}
|
|
652
947
|
if (root === "media" && segment) {
|
|
653
948
|
const links = await this.db.select({ id: entityMedia.mediaAssetId }).from(entityMedia).where(and(
|
|
654
949
|
eq(entityMedia.entityId, entityId),
|
|
@@ -678,6 +973,17 @@ export class ChannelConnectorService {
|
|
|
678
973
|
return undefined;
|
|
679
974
|
}
|
|
680
975
|
|
|
976
|
+
private async lastSyncedSnapshot(
|
|
977
|
+
entityId: string,
|
|
978
|
+
lastSyncedAt: Date,
|
|
979
|
+
): Promise<SellableEntityRevisionSnapshot | undefined> {
|
|
980
|
+
const [revision] = await this.db.select({ snapshot: sellableEntityRevisions.snapshot }).from(sellableEntityRevisions).where(and(
|
|
981
|
+
eq(sellableEntityRevisions.entityId, entityId),
|
|
982
|
+
lte(sellableEntityRevisions.createdAt, lastSyncedAt),
|
|
983
|
+
)).orderBy(desc(sellableEntityRevisions.createdAt)).limit(1);
|
|
984
|
+
return revision?.snapshot;
|
|
985
|
+
}
|
|
986
|
+
|
|
681
987
|
private async detectSharedConflicts(
|
|
682
988
|
entityId: string,
|
|
683
989
|
storeId: string,
|
|
@@ -687,20 +993,109 @@ export class ChannelConnectorService {
|
|
|
687
993
|
owners: Map<FieldPath, FieldOwner>,
|
|
688
994
|
fieldPaths: FieldPath[] = importedFieldPaths(item),
|
|
689
995
|
remoteHash = hash(item),
|
|
690
|
-
|
|
996
|
+
echo?: { certifiedPaths: ReadonlySet<FieldPath> },
|
|
997
|
+
): Promise<{ paths: FieldPath[]; conflicts: DetectedCatalogFieldConflict[] }> {
|
|
691
998
|
if (!mapping || mapping.syncHash === remoteHash) return { paths: [], conflicts: [] };
|
|
692
999
|
const revisions = await this.catalog.repository.findRevisionMarkers(entityId, mapping.lastSyncedAt);
|
|
693
1000
|
const localChanged = revisions.some((revision) => revision.reason !== "import");
|
|
694
|
-
if (!localChanged) return { paths: [], conflicts: [] };
|
|
695
1001
|
const paths = fieldPaths.filter((path) => owners.get(path) === "shared");
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
1002
|
+
const openRows = await this.db.select({
|
|
1003
|
+
fieldPath: channelCatalogConflicts.fieldPath,
|
|
1004
|
+
storeValue: channelCatalogConflicts.storeValue,
|
|
1005
|
+
}).from(channelCatalogConflicts).where(and(
|
|
1006
|
+
eq(channelCatalogConflicts.storeId, storeId),
|
|
1007
|
+
eq(channelCatalogConflicts.entityId, entityId),
|
|
1008
|
+
eq(channelCatalogConflicts.state, "open"),
|
|
1009
|
+
));
|
|
1010
|
+
if (!localChanged && openRows.length === 0) return { paths: [], conflicts: [] };
|
|
1011
|
+
const openByPath = new Map(openRows.map((row) => [row.fieldPath as FieldPath, row.storeValue]));
|
|
1012
|
+
const baseline = await this.lastSyncedSnapshot(entityId, mapping.lastSyncedAt);
|
|
1013
|
+
const changed: FieldPath[] = [];
|
|
1014
|
+
for (const path of paths) {
|
|
1015
|
+
const localValue = await this.localFieldValue(entityId, entity, path);
|
|
1016
|
+
const remoteValue = this.remoteFieldValue(item, path);
|
|
1017
|
+
let diverged = false;
|
|
1018
|
+
if (echo) {
|
|
1019
|
+
// The outbound hash certifies only the pushed paths; a shared path
|
|
1020
|
+
// outside that set carrying a genuinely different remote value is a
|
|
1021
|
+
// real store edit even inside an echo payload.
|
|
1022
|
+
diverged = !echo.certifiedPaths.has(path) && !normalizedValuesEqual(remoteValue, localValue);
|
|
1023
|
+
} else if (openByPath.has(path)) {
|
|
1024
|
+
diverged = !normalizedValuesEqual(remoteValue, openByPath.get(path));
|
|
1025
|
+
} else if (baseline) {
|
|
1026
|
+
const baselineValue = snapshotFieldValue(baseline, path);
|
|
1027
|
+
diverged = baselineValue.found
|
|
1028
|
+
&& !normalizedValuesEqual(localValue, baselineValue.value)
|
|
1029
|
+
&& !normalizedValuesEqual(remoteValue, baselineValue.value)
|
|
1030
|
+
&& !normalizedValuesEqual(localValue, remoteValue);
|
|
1031
|
+
} else {
|
|
1032
|
+
diverged = !normalizedValuesEqual(remoteValue, localValue);
|
|
1033
|
+
}
|
|
1034
|
+
if (diverged) changed.push(path);
|
|
1035
|
+
}
|
|
1036
|
+
const conflicts = await Promise.all(changed.map(async (fieldPath) => {
|
|
1037
|
+
const platformValue = await this.localFieldValue(entityId, entity, fieldPath);
|
|
1038
|
+
const storeValue = this.remoteFieldValue(item, fieldPath);
|
|
1039
|
+
return {
|
|
1040
|
+
entityId,
|
|
1041
|
+
storeId,
|
|
1042
|
+
fieldPath,
|
|
1043
|
+
platformValue: platformValue === undefined ? null : platformValue,
|
|
1044
|
+
storeValue: storeValue === undefined ? null : storeValue,
|
|
1045
|
+
localValueSummary: summarizeValue(platformValue),
|
|
1046
|
+
remoteValueSummary: summarizeValue(storeValue),
|
|
1047
|
+
};
|
|
1048
|
+
}));
|
|
1049
|
+
return { paths: changed, conflicts };
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
private async persistCatalogConflicts(
|
|
1053
|
+
orgId: string,
|
|
1054
|
+
conflicts: DetectedCatalogFieldConflict[],
|
|
1055
|
+
changedBy: string,
|
|
1056
|
+
): Promise<PluginResult<void>> {
|
|
1057
|
+
for (const conflict of conflicts) {
|
|
1058
|
+
const [inserted] = await this.db.insert(channelCatalogConflicts).values({
|
|
1059
|
+
organizationId: orgId,
|
|
1060
|
+
storeId: conflict.storeId,
|
|
1061
|
+
entityId: conflict.entityId,
|
|
1062
|
+
fieldPath: conflict.fieldPath,
|
|
1063
|
+
platformValue: conflict.platformValue,
|
|
1064
|
+
storeValue: conflict.storeValue,
|
|
1065
|
+
}).onConflictDoNothing().returning();
|
|
1066
|
+
if (!inserted) {
|
|
1067
|
+
const [existing] = await this.db.select({
|
|
1068
|
+
id: channelCatalogConflicts.id,
|
|
1069
|
+
storeValue: channelCatalogConflicts.storeValue,
|
|
1070
|
+
platformValue: channelCatalogConflicts.platformValue,
|
|
1071
|
+
}).from(channelCatalogConflicts).where(and(
|
|
1072
|
+
eq(channelCatalogConflicts.organizationId, orgId),
|
|
1073
|
+
eq(channelCatalogConflicts.storeId, conflict.storeId),
|
|
1074
|
+
eq(channelCatalogConflicts.entityId, conflict.entityId),
|
|
1075
|
+
eq(channelCatalogConflicts.fieldPath, conflict.fieldPath),
|
|
1076
|
+
eq(channelCatalogConflicts.state, "open"),
|
|
1077
|
+
));
|
|
1078
|
+
const storeMoved = existing !== undefined && !normalizedValuesEqual(existing.storeValue, conflict.storeValue);
|
|
1079
|
+
const platformMoved = existing !== undefined && !normalizedValuesEqual(existing.platformValue, conflict.platformValue);
|
|
1080
|
+
if (existing && (storeMoved || platformMoved)) {
|
|
1081
|
+
await this.db.update(channelCatalogConflicts).set({
|
|
1082
|
+
storeValue: conflict.storeValue,
|
|
1083
|
+
platformValue: conflict.platformValue,
|
|
1084
|
+
updatedAt: new Date(),
|
|
1085
|
+
}).where(eq(channelCatalogConflicts.id, existing.id));
|
|
1086
|
+
}
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
await this.db.insert(channelCatalogConflictEvents).values({
|
|
1090
|
+
organizationId: orgId,
|
|
1091
|
+
conflictId: inserted.id,
|
|
1092
|
+
fromState: null,
|
|
1093
|
+
toState: "open",
|
|
1094
|
+
reason: "Shared catalog field changed on both sides.",
|
|
1095
|
+
changedBy,
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
return Ok(undefined);
|
|
704
1099
|
}
|
|
705
1100
|
|
|
706
1101
|
private async setCatalogAttributes(
|
|
@@ -708,6 +1103,7 @@ export class ChannelConnectorService {
|
|
|
708
1103
|
item: ChannelCatalogItem,
|
|
709
1104
|
actor: Actor,
|
|
710
1105
|
blockedPaths: ReadonlySet<FieldPath> = new Set<FieldPath>(),
|
|
1106
|
+
catalogCtx?: CatalogWriteContext,
|
|
711
1107
|
): Promise<PluginResult<{ created: number; changed: boolean }>> {
|
|
712
1108
|
const attributes = item.attributes ?? [{
|
|
713
1109
|
locale: "en",
|
|
@@ -737,12 +1133,31 @@ export class ChannelConnectorService {
|
|
|
737
1133
|
} else if (attributeFields.some((field) => (current[field] == null ? null : current[field]) !== (writeAttribute[field] == null ? null : writeAttribute[field]))) {
|
|
738
1134
|
changed = true;
|
|
739
1135
|
}
|
|
740
|
-
const result = await this.catalog.setAttributes(entityId, attribute.locale, writeAttribute, actor);
|
|
1136
|
+
const result = await this.catalog.setAttributes(entityId, attribute.locale, writeAttribute, actor, catalogCtx);
|
|
741
1137
|
if (!result.ok) return PluginErr(result.error.message);
|
|
742
1138
|
}
|
|
743
1139
|
return Ok({ created, changed });
|
|
744
1140
|
}
|
|
745
1141
|
|
|
1142
|
+
private async setCatalogAttributesIfWritable(
|
|
1143
|
+
entityId: string,
|
|
1144
|
+
item: ChannelCatalogItem,
|
|
1145
|
+
actor: Actor,
|
|
1146
|
+
blockedPaths: ReadonlySet<FieldPath>,
|
|
1147
|
+
catalogCtx?: CatalogWriteContext,
|
|
1148
|
+
): Promise<PluginResult<{ created: number; changed: boolean }>> {
|
|
1149
|
+
const attributes = item.attributes ?? [{
|
|
1150
|
+
locale: "en",
|
|
1151
|
+
title: item.title,
|
|
1152
|
+
...(item.description !== undefined ? { description: item.description } : {}),
|
|
1153
|
+
}];
|
|
1154
|
+
const writable = attributes.some((attribute) => attributeFields.some((field) => (
|
|
1155
|
+
attribute[field] !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.${field}`)
|
|
1156
|
+
)));
|
|
1157
|
+
if (!writable) return Ok({ created: 0, changed: false });
|
|
1158
|
+
return this.setCatalogAttributes(entityId, item, actor, blockedPaths, catalogCtx);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
746
1161
|
private async upsertOptionAxes(
|
|
747
1162
|
entityId: string,
|
|
748
1163
|
item: ChannelCatalogItem,
|
|
@@ -1117,7 +1532,7 @@ export class ChannelConnectorService {
|
|
|
1117
1532
|
inArray(channelEntityMap.entityId, entityIds),
|
|
1118
1533
|
));
|
|
1119
1534
|
const mappingByEntity = new Map(mappings.map((mapping) => [mapping.entityId, mapping]));
|
|
1120
|
-
const items:
|
|
1535
|
+
const items: CatalogPushAssemblyItem[] = [];
|
|
1121
1536
|
const skipped: CatalogPushFieldSkip[] = [];
|
|
1122
1537
|
const warnings: string[] = [];
|
|
1123
1538
|
const revisionEntityIds: string[] = [];
|
|
@@ -1159,16 +1574,42 @@ export class ChannelConnectorService {
|
|
|
1159
1574
|
}
|
|
1160
1575
|
const fieldMapping = this.resolveCatalogFieldMapping(store, filterableCustomFields, warnings);
|
|
1161
1576
|
const heldPaths = new Set(entityMapping.heldFieldPaths ?? []);
|
|
1162
|
-
const
|
|
1577
|
+
const forcedPushPaths = new Set([
|
|
1578
|
+
...(entityMapping.forcedPushFieldPaths ?? []),
|
|
1579
|
+
...(options.forceFieldPaths?.[entity.id] ?? []),
|
|
1580
|
+
]);
|
|
1581
|
+
const fields: CatalogPushAssemblyField[] = [];
|
|
1163
1582
|
const appendField = (fieldPath: FieldPath, value: unknown) => {
|
|
1164
|
-
if (value === undefined
|
|
1583
|
+
if (value === undefined) return;
|
|
1584
|
+
const owner = owners.get(fieldPath);
|
|
1585
|
+
const mapping = selectCatalogFieldMapping(fieldMapping, fieldPath);
|
|
1586
|
+
if (owner === "store") {
|
|
1587
|
+
skipped.push({
|
|
1588
|
+
entityId,
|
|
1589
|
+
fieldPath,
|
|
1590
|
+
reason: "store_owned",
|
|
1591
|
+
value,
|
|
1592
|
+
owner,
|
|
1593
|
+
...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
|
|
1594
|
+
});
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
if (owner === undefined) return;
|
|
1598
|
+
const forced = forcedPushPaths.has(fieldPath);
|
|
1599
|
+
if (owner !== "platform" && !forced) return;
|
|
1165
1600
|
if (heldPaths.has(fieldPath)) {
|
|
1166
|
-
skipped.push({
|
|
1601
|
+
skipped.push({
|
|
1602
|
+
entityId,
|
|
1603
|
+
fieldPath,
|
|
1604
|
+
reason: "held",
|
|
1605
|
+
value,
|
|
1606
|
+
owner,
|
|
1607
|
+
...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
|
|
1608
|
+
});
|
|
1167
1609
|
return;
|
|
1168
1610
|
}
|
|
1169
|
-
const mapping = selectCatalogFieldMapping(fieldMapping, fieldPath);
|
|
1170
1611
|
if (!mapping) {
|
|
1171
|
-
skipped.push({ entityId, fieldPath, reason: "no_mapping" });
|
|
1612
|
+
skipped.push({ entityId, fieldPath, reason: "no_mapping", value, owner });
|
|
1172
1613
|
return;
|
|
1173
1614
|
}
|
|
1174
1615
|
fields.push(pushCatalogField(fieldPath, value, mapping));
|
|
@@ -1190,21 +1631,47 @@ export class ChannelConnectorService {
|
|
|
1190
1631
|
|
|
1191
1632
|
const media = await this.media.listEntityMedia(entity.id, { orgId });
|
|
1192
1633
|
if (!media.ok) return PluginErr(media.error.message);
|
|
1193
|
-
const images:
|
|
1634
|
+
const images: CatalogPushAssemblyImage[] = [];
|
|
1194
1635
|
for (const attached of media.value) {
|
|
1195
1636
|
const role = pushCatalogImageRole(attached.role);
|
|
1196
1637
|
if (!role) continue;
|
|
1197
1638
|
const fieldPath = `media.${role}` as FieldPath;
|
|
1198
|
-
|
|
1639
|
+
const owner = owners.get(fieldPath);
|
|
1640
|
+
const mapping = selectCatalogFieldMapping(fieldMapping, fieldPath);
|
|
1641
|
+
const imageValue = [{ url: attached.url, role }];
|
|
1642
|
+
if (owner === "store") {
|
|
1643
|
+
skipped.push({
|
|
1644
|
+
entityId,
|
|
1645
|
+
fieldPath,
|
|
1646
|
+
reason: "store_owned",
|
|
1647
|
+
value: imageValue,
|
|
1648
|
+
owner,
|
|
1649
|
+
...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
|
|
1650
|
+
});
|
|
1651
|
+
continue;
|
|
1652
|
+
}
|
|
1653
|
+
if (owner === undefined) continue;
|
|
1654
|
+
const forced = forcedPushPaths.has(fieldPath);
|
|
1655
|
+
if (owner !== "platform" && !forced) continue;
|
|
1199
1656
|
if (heldPaths.has(fieldPath)) {
|
|
1200
|
-
skipped.push({
|
|
1657
|
+
skipped.push({
|
|
1658
|
+
entityId,
|
|
1659
|
+
fieldPath,
|
|
1660
|
+
reason: "held",
|
|
1661
|
+
value: imageValue,
|
|
1662
|
+
owner,
|
|
1663
|
+
...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
|
|
1664
|
+
});
|
|
1201
1665
|
continue;
|
|
1202
1666
|
}
|
|
1203
|
-
if (!
|
|
1204
|
-
skipped.push({ entityId, fieldPath, reason: "no_mapping" });
|
|
1667
|
+
if (!mapping) {
|
|
1668
|
+
skipped.push({ entityId, fieldPath, reason: "no_mapping", value: imageValue, owner });
|
|
1205
1669
|
continue;
|
|
1206
1670
|
}
|
|
1207
1671
|
images.push({
|
|
1672
|
+
fieldPath,
|
|
1673
|
+
target: mapping.target,
|
|
1674
|
+
remoteKey: mapping.remoteKey,
|
|
1208
1675
|
url: attached.url,
|
|
1209
1676
|
role,
|
|
1210
1677
|
sortOrder: attached.sortOrder,
|
|
@@ -1212,7 +1679,7 @@ export class ChannelConnectorService {
|
|
|
1212
1679
|
});
|
|
1213
1680
|
}
|
|
1214
1681
|
fields.sort((left, right) => left.fieldPath.localeCompare(right.fieldPath));
|
|
1215
|
-
const item:
|
|
1682
|
+
const item: CatalogPushAssemblyItem = {
|
|
1216
1683
|
externalId: entityMapping.externalId,
|
|
1217
1684
|
fields,
|
|
1218
1685
|
...(images.length > 0 ? { images } : {}),
|
|
@@ -1237,6 +1704,242 @@ export class ChannelConnectorService {
|
|
|
1237
1704
|
return Ok({ items, skipped, warnings: [...new Set(warnings)] });
|
|
1238
1705
|
}
|
|
1239
1706
|
|
|
1707
|
+
async recordOutboundPush(
|
|
1708
|
+
orgId: string,
|
|
1709
|
+
storeId: string,
|
|
1710
|
+
outcomes: ChannelPushCatalogItemOutcome[],
|
|
1711
|
+
items: ChannelPushCatalogItem[],
|
|
1712
|
+
phase: "write-ahead" | "settle" = "settle",
|
|
1713
|
+
): Promise<PluginResult<void>> {
|
|
1714
|
+
const outcomeByExternalId = new Map(outcomes.map((outcome) => [outcome.externalId, outcome]));
|
|
1715
|
+
const now = new Date();
|
|
1716
|
+
for (const item of items) {
|
|
1717
|
+
const outcome = outcomeByExternalId.get(item.externalId);
|
|
1718
|
+
const mapping = await this.db.select({
|
|
1719
|
+
id: channelEntityMap.id,
|
|
1720
|
+
externalId: channelEntityMap.externalId,
|
|
1721
|
+
forcedPushFieldPaths: channelEntityMap.forcedPushFieldPaths,
|
|
1722
|
+
}).from(channelEntityMap).where(and(
|
|
1723
|
+
eq(channelEntityMap.organizationId, orgId),
|
|
1724
|
+
eq(channelEntityMap.storeId, storeId),
|
|
1725
|
+
eq(channelEntityMap.kind, "entity"),
|
|
1726
|
+
eq(channelEntityMap.externalId, item.externalId),
|
|
1727
|
+
));
|
|
1728
|
+
if (!mapping[0]) continue;
|
|
1729
|
+
if (outcome?.ok === true) {
|
|
1730
|
+
const fieldPaths = outboundFieldPaths(item);
|
|
1731
|
+
await this.db.update(channelEntityMap).set({
|
|
1732
|
+
outboundHash: canonicalOutboundHash(mapping[0].externalId, item, fieldPaths),
|
|
1733
|
+
outboundPushedAt: now,
|
|
1734
|
+
outboundFieldPaths: fieldPaths,
|
|
1735
|
+
// A force is an operator's conflict resolution. The write-ahead runs
|
|
1736
|
+
// before the connector is called and its outcomes are optimistic, so
|
|
1737
|
+
// consuming the force there would discard the resolution on a failed
|
|
1738
|
+
// push and the retry would silently omit the field.
|
|
1739
|
+
...(phase === "settle"
|
|
1740
|
+
? { forcedPushFieldPaths: (mapping[0].forcedPushFieldPaths ?? []).filter((path) => !fieldPaths.includes(path)) }
|
|
1741
|
+
: {}),
|
|
1742
|
+
}).where(eq(channelEntityMap.id, mapping[0].id));
|
|
1743
|
+
} else {
|
|
1744
|
+
await this.db.update(channelEntityMap).set({
|
|
1745
|
+
outboundHash: null,
|
|
1746
|
+
outboundPushedAt: null,
|
|
1747
|
+
outboundFieldPaths: [],
|
|
1748
|
+
syncHash: "",
|
|
1749
|
+
}).where(eq(channelEntityMap.id, mapping[0].id));
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
return Ok(undefined);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
async pushCatalogToStore(
|
|
1756
|
+
orgId: string,
|
|
1757
|
+
storeId: string,
|
|
1758
|
+
entityIds: string[],
|
|
1759
|
+
): Promise<PluginResult<PushCatalogToStoreResult>> {
|
|
1760
|
+
const assembled = await this.buildCatalogPushItems(orgId, storeId, entityIds);
|
|
1761
|
+
if (!assembled.ok) return assembled;
|
|
1762
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
1763
|
+
if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
1764
|
+
const connector = this.connectors.get(store.provider);
|
|
1765
|
+
if (!connector?.pushCatalog) return PluginErr(`Catalog push is not supported by provider "${store.provider}".`);
|
|
1766
|
+
if (assembled.value.items.length === 0) return Ok({
|
|
1767
|
+
outcomes: [],
|
|
1768
|
+
skipped: assembled.value.skipped,
|
|
1769
|
+
warnings: assembled.value.warnings,
|
|
1770
|
+
});
|
|
1771
|
+
|
|
1772
|
+
const writeAhead = await this.recordOutboundPush(
|
|
1773
|
+
orgId,
|
|
1774
|
+
storeId,
|
|
1775
|
+
assembled.value.items.map((item) => ({ externalId: item.externalId, ok: true })),
|
|
1776
|
+
assembled.value.items,
|
|
1777
|
+
"write-ahead",
|
|
1778
|
+
);
|
|
1779
|
+
if (!writeAhead.ok) return writeAhead;
|
|
1780
|
+
|
|
1781
|
+
let result: Awaited<ReturnType<NonNullable<typeof connector.pushCatalog>>>;
|
|
1782
|
+
try {
|
|
1783
|
+
result = await connector.pushCatalog(store as ChannelStore, assembled.value.items);
|
|
1784
|
+
} catch (error) {
|
|
1785
|
+
const connectorError = {
|
|
1786
|
+
code: "CATALOG_PUSH_THROWN",
|
|
1787
|
+
message: error instanceof Error ? error.message : "Catalog push failed.",
|
|
1788
|
+
};
|
|
1789
|
+
const cleared = await this.recordOutboundPush(
|
|
1790
|
+
orgId,
|
|
1791
|
+
storeId,
|
|
1792
|
+
assembled.value.items.map((item) => ({ externalId: item.externalId, ok: false, error: connectorError })),
|
|
1793
|
+
assembled.value.items,
|
|
1794
|
+
);
|
|
1795
|
+
if (!cleared.ok) return cleared;
|
|
1796
|
+
return PluginErr(connectorError.message, connectorError.code);
|
|
1797
|
+
}
|
|
1798
|
+
if (!result.ok) {
|
|
1799
|
+
const cleared = await this.recordOutboundPush(
|
|
1800
|
+
orgId,
|
|
1801
|
+
storeId,
|
|
1802
|
+
assembled.value.items.map((item) => ({ externalId: item.externalId, ok: false, error: result.error })),
|
|
1803
|
+
assembled.value.items,
|
|
1804
|
+
);
|
|
1805
|
+
if (!cleared.ok) return cleared;
|
|
1806
|
+
return PluginErr(result.error.message, result.error.code);
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
const recorded = await this.recordOutboundPush(orgId, storeId, result.value.outcomes, assembled.value.items);
|
|
1810
|
+
if (!recorded.ok) return recorded;
|
|
1811
|
+
const successfulEntityIds = assembled.value.items
|
|
1812
|
+
.filter((item) => result.value.outcomes.some((outcome) => outcome.externalId === item.externalId && outcome.ok))
|
|
1813
|
+
.map((item) => item.externalId);
|
|
1814
|
+
if (successfulEntityIds.length > 0) {
|
|
1815
|
+
const mappings = await this.db.select({ entityId: channelEntityMap.entityId }).from(channelEntityMap).where(and(
|
|
1816
|
+
eq(channelEntityMap.organizationId, orgId),
|
|
1817
|
+
eq(channelEntityMap.storeId, storeId),
|
|
1818
|
+
eq(channelEntityMap.kind, "entity"),
|
|
1819
|
+
inArray(channelEntityMap.externalId, successfulEntityIds),
|
|
1820
|
+
));
|
|
1821
|
+
const actor = createSystemActor(orgId);
|
|
1822
|
+
try {
|
|
1823
|
+
await this.transact(async (tx) => {
|
|
1824
|
+
const txContext = createTxContext(tx, { actor });
|
|
1825
|
+
for (const entityId of [...new Set(mappings.map((mapping) => mapping.entityId))]) {
|
|
1826
|
+
const revision = await this.catalog.recordEntityRevision(entityId, actor, "push", txContext);
|
|
1827
|
+
if (!revision.ok) throw new Error(revision.error.message);
|
|
1828
|
+
}
|
|
1829
|
+
});
|
|
1830
|
+
} catch (error) {
|
|
1831
|
+
return PluginErr(error instanceof Error ? error.message : "Failed to record catalog push revisions.");
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
return Ok({
|
|
1835
|
+
...result.value,
|
|
1836
|
+
skipped: assembled.value.skipped,
|
|
1837
|
+
warnings: assembled.value.warnings,
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
async previewCatalogPush(
|
|
1842
|
+
orgId: string,
|
|
1843
|
+
storeId: string,
|
|
1844
|
+
entityIds?: string[],
|
|
1845
|
+
): Promise<PluginResult<CatalogPushPreviewResult>> {
|
|
1846
|
+
const assembledEntityIds = await this.resolveCatalogPushEntityIds(orgId, storeId, entityIds);
|
|
1847
|
+
const assembled = await this.buildCatalogPushItems(orgId, storeId, assembledEntityIds);
|
|
1848
|
+
if (!assembled.ok) return assembled;
|
|
1849
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
1850
|
+
if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
1851
|
+
const connector = this.connectors.get(store.provider);
|
|
1852
|
+
if (!connector?.pushCatalog) return PluginErr(`Catalog push is not supported by provider "${store.provider}".`);
|
|
1853
|
+
if (assembled.value.items.length === 0) {
|
|
1854
|
+
return Ok({ items: [], skipped: assembled.value.skipped, warnings: assembled.value.warnings });
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
let result: Awaited<ReturnType<NonNullable<typeof connector.pushCatalog>>>;
|
|
1858
|
+
try {
|
|
1859
|
+
result = await connector.pushCatalog(store as ChannelStore, assembled.value.items, { dryRun: true });
|
|
1860
|
+
} catch (error) {
|
|
1861
|
+
return PluginErr(
|
|
1862
|
+
error instanceof Error ? error.message : "Catalog push preview failed.",
|
|
1863
|
+
"CATALOG_PREVIEW_THROWN",
|
|
1864
|
+
);
|
|
1865
|
+
}
|
|
1866
|
+
if (!result.ok) return PluginErr(result.error.message, result.error.code);
|
|
1867
|
+
const failed = result.value.outcomes.find((outcome) => !outcome.ok);
|
|
1868
|
+
if (failed) return PluginErr(
|
|
1869
|
+
failed.error?.message ?? `Catalog push preview failed for item "${failed.externalId}".`,
|
|
1870
|
+
failed.error?.code ?? "CATALOG_PREVIEW_FAILED",
|
|
1871
|
+
);
|
|
1872
|
+
|
|
1873
|
+
const mappings = assembledEntityIds.length === 0
|
|
1874
|
+
? []
|
|
1875
|
+
: await this.db.select({ entityId: channelEntityMap.entityId, externalId: channelEntityMap.externalId }).from(channelEntityMap).where(and(
|
|
1876
|
+
eq(channelEntityMap.organizationId, orgId),
|
|
1877
|
+
eq(channelEntityMap.storeId, storeId),
|
|
1878
|
+
eq(channelEntityMap.kind, "entity"),
|
|
1879
|
+
inArray(channelEntityMap.entityId, assembledEntityIds),
|
|
1880
|
+
));
|
|
1881
|
+
const externalByEntity = new Map(mappings.map((mapping) => [mapping.entityId, mapping.externalId]));
|
|
1882
|
+
const skippedByExternal = new Map<string, CatalogPushFieldSkip[]>();
|
|
1883
|
+
for (const skipped of assembled.value.skipped) {
|
|
1884
|
+
const externalId = externalByEntity.get(skipped.entityId);
|
|
1885
|
+
if (!externalId) continue;
|
|
1886
|
+
const existing = skippedByExternal.get(externalId) ?? [];
|
|
1887
|
+
existing.push(skipped);
|
|
1888
|
+
skippedByExternal.set(externalId, existing);
|
|
1889
|
+
}
|
|
1890
|
+
const outcomeByExternalId = new Map(result.value.outcomes.map((outcome) => [outcome.externalId, outcome]));
|
|
1891
|
+
const beforeFor = (externalId: string, fieldPath: FieldPath): {
|
|
1892
|
+
before: CatalogPushPreviewBefore;
|
|
1893
|
+
beforeStatus: CatalogPushPreviewBeforeStatus;
|
|
1894
|
+
} => {
|
|
1895
|
+
const previousFields = outcomeByExternalId.get(externalId)?.previousFields;
|
|
1896
|
+
if (previousFields === undefined) {
|
|
1897
|
+
return { before: { status: "unavailable" }, beforeStatus: "unavailable" };
|
|
1898
|
+
}
|
|
1899
|
+
const previous = previousFields.find((field) => field.fieldPath === fieldPath);
|
|
1900
|
+
if (!previous) return { before: null, beforeStatus: "missing" };
|
|
1901
|
+
return { before: previous.value, beforeStatus: "value" };
|
|
1902
|
+
};
|
|
1903
|
+
|
|
1904
|
+
const items = assembled.value.items.map((item) => {
|
|
1905
|
+
const diffs: CatalogPushPreviewDiff[] = item.fields.map((field) => ({
|
|
1906
|
+
fieldPath: field.fieldPath,
|
|
1907
|
+
target: field.target,
|
|
1908
|
+
remoteKey: field.remoteKey ?? null,
|
|
1909
|
+
...beforeFor(item.externalId, field.fieldPath),
|
|
1910
|
+
after: field.value,
|
|
1911
|
+
owner: "platform",
|
|
1912
|
+
willWrite: true,
|
|
1913
|
+
}));
|
|
1914
|
+
for (const image of item.images ?? []) {
|
|
1915
|
+
diffs.push({
|
|
1916
|
+
fieldPath: image.fieldPath,
|
|
1917
|
+
target: image.target,
|
|
1918
|
+
remoteKey: image.remoteKey,
|
|
1919
|
+
...beforeFor(item.externalId, image.fieldPath),
|
|
1920
|
+
after: pushFieldValue(item, image.fieldPath),
|
|
1921
|
+
owner: "platform",
|
|
1922
|
+
willWrite: true,
|
|
1923
|
+
});
|
|
1924
|
+
}
|
|
1925
|
+
for (const skipped of skippedByExternal.get(item.externalId) ?? []) {
|
|
1926
|
+
if (skipped.value === undefined || skipped.owner === undefined) continue;
|
|
1927
|
+
diffs.push({
|
|
1928
|
+
fieldPath: skipped.fieldPath,
|
|
1929
|
+
target: skipped.target ?? null,
|
|
1930
|
+
remoteKey: skipped.remoteKey ?? null,
|
|
1931
|
+
...beforeFor(item.externalId, skipped.fieldPath),
|
|
1932
|
+
after: skipped.value,
|
|
1933
|
+
owner: skipped.owner,
|
|
1934
|
+
willWrite: false,
|
|
1935
|
+
reason: skipped.reason,
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
return { externalId: item.externalId, diffs };
|
|
1939
|
+
});
|
|
1940
|
+
return Ok({ items, skipped: assembled.value.skipped, warnings: assembled.value.warnings });
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1240
1943
|
async getCatalogWriteSettings(orgId: string, storeId: string): Promise<PluginResult<CatalogWriteSettings>> {
|
|
1241
1944
|
const store = await this.getStoreRecord(orgId, storeId);
|
|
1242
1945
|
if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
@@ -1532,7 +2235,7 @@ export class ChannelConnectorService {
|
|
|
1532
2235
|
const promoted = await this.catalog.setAttributes(entity.id, "en", {
|
|
1533
2236
|
title: metadata.title,
|
|
1534
2237
|
...(typeof metadata.description === "string" ? { description: metadata.description } : {}),
|
|
1535
|
-
}, actor);
|
|
2238
|
+
}, actor, CHANNEL_CONVERGENCE_CTX);
|
|
1536
2239
|
if (!promoted.ok) return PluginErr(promoted.error.message);
|
|
1537
2240
|
const [confirmed] = await this.db.select({ id: sellableAttributes.id, title: sellableAttributes.title, description: sellableAttributes.description }).from(sellableAttributes).where(and(
|
|
1538
2241
|
eq(sellableAttributes.entityId, entity.id),
|
|
@@ -1878,6 +2581,7 @@ export class ChannelConnectorService {
|
|
|
1878
2581
|
if (!seeded.ok) return PluginErr(seeded.error.message);
|
|
1879
2582
|
for (const path of seedPaths) ownershipBeforeSeed.set(path, "store");
|
|
1880
2583
|
const owners = ownershipBeforeSeed;
|
|
2584
|
+
const outboundEcho = entityMapping ? this.isOutboundEcho(entityMapping, item) : false;
|
|
1881
2585
|
const remoteChanged = entityMapping === undefined || entityMapping.syncHash !== remoteHash;
|
|
1882
2586
|
// An unchanged remote item writes nothing and advances no baseline:
|
|
1883
2587
|
// converging a stale replay would revert local edits to shared and
|
|
@@ -1886,15 +2590,27 @@ export class ChannelConnectorService {
|
|
|
1886
2590
|
continue;
|
|
1887
2591
|
}
|
|
1888
2592
|
const shared = existingEntity
|
|
1889
|
-
? await this.detectSharedConflicts(
|
|
2593
|
+
? await this.detectSharedConflicts(
|
|
2594
|
+
entityId, storeId, existingEntity, entityMapping, item, owners,
|
|
2595
|
+
importedFieldPaths(item), remoteHash,
|
|
2596
|
+
outboundEcho ? { certifiedPaths: new Set(entityMapping?.outboundFieldPaths ?? []) } : undefined,
|
|
2597
|
+
)
|
|
1890
2598
|
: { paths: [], conflicts: [] };
|
|
2599
|
+
const persistedConflicts = await this.persistCatalogConflicts(orgId, shared.conflicts, requireUserId(actor));
|
|
2600
|
+
if (!persistedConflicts.ok) return persistedConflicts;
|
|
1891
2601
|
const owned = this.filterOwnedFields(item, owners);
|
|
1892
2602
|
const heldSharedPaths = [...new Set([...(entityMapping?.heldFieldPaths ?? []), ...shared.paths])];
|
|
2603
|
+
// A newly held path revokes any force left from an earlier resolution of
|
|
2604
|
+
// that same path: the force was the operator's answer to a question that
|
|
2605
|
+
// has since been asked again, and it must not pre-empt the new one.
|
|
2606
|
+
const survivingForcedPaths = (entityMapping?.forcedPushFieldPaths ?? []).filter(
|
|
2607
|
+
(path) => !heldSharedPaths.includes(path),
|
|
2608
|
+
);
|
|
1893
2609
|
const held = this.filterConflictingFields(owned.writable, heldSharedPaths);
|
|
1894
2610
|
const writable = held.writable;
|
|
1895
2611
|
const blockedPaths = new Set<FieldPath>([...owned.skipped, ...heldSharedPaths]);
|
|
1896
2612
|
skipped.push(...owned.skipped.map((fieldPath) => ({ entityId, fieldPath })));
|
|
1897
|
-
conflicts.push(...shared.conflicts);
|
|
2613
|
+
conflicts.push(...shared.conflicts.map(({ platformValue: _platformValue, storeValue: _storeValue, ...conflict }) => conflict));
|
|
1898
2614
|
for (const conflict of shared.conflicts) {
|
|
1899
2615
|
warnings.push(`Held shared field conflict for entity "${conflict.entityId}", store "${conflict.storeId}", field "${conflict.fieldPath}" (local ${conflict.localValueSummary}, remote ${conflict.remoteValueSummary}).`);
|
|
1900
2616
|
}
|
|
@@ -1924,7 +2640,7 @@ export class ChannelConnectorService {
|
|
|
1924
2640
|
if (shouldUpdate) {
|
|
1925
2641
|
converged += 1;
|
|
1926
2642
|
if (Object.keys(updateInput).length > 0) {
|
|
1927
|
-
const updated = await this.catalog.update(entityMapping.entityId, updateInput, actor);
|
|
2643
|
+
const updated = await this.catalog.update(entityMapping.entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
|
|
1928
2644
|
if (!updated.ok) return PluginErr(updated.error.message);
|
|
1929
2645
|
entityTouched = true;
|
|
1930
2646
|
}
|
|
@@ -1933,7 +2649,7 @@ export class ChannelConnectorService {
|
|
|
1933
2649
|
|
|
1934
2650
|
const optionAxes = await this.upsertOptionAxes(entityId, writable, actor);
|
|
1935
2651
|
if (!optionAxes.ok) return optionAxes;
|
|
1936
|
-
const attributes = await this.
|
|
2652
|
+
const attributes = await this.setCatalogAttributesIfWritable(entityId, writable, actor, blockedPaths, CHANNEL_CONVERGENCE_CTX);
|
|
1937
2653
|
if (!attributes.ok) return attributes;
|
|
1938
2654
|
const variantIds = await this.upsertVariants(
|
|
1939
2655
|
orgId,
|
|
@@ -1977,12 +2693,14 @@ export class ChannelConnectorService {
|
|
|
1977
2693
|
syncHash: remoteHash,
|
|
1978
2694
|
lastSyncedAt,
|
|
1979
2695
|
heldFieldPaths: heldSharedPaths,
|
|
2696
|
+
forcedPushFieldPaths: survivingForcedPaths,
|
|
1980
2697
|
});
|
|
1981
2698
|
} else if (entityMapping) {
|
|
1982
2699
|
await this.db.update(channelEntityMap).set({
|
|
1983
2700
|
syncHash: remoteHash,
|
|
1984
2701
|
lastSyncedAt,
|
|
1985
2702
|
heldFieldPaths: heldSharedPaths,
|
|
2703
|
+
forcedPushFieldPaths: survivingForcedPaths,
|
|
1986
2704
|
}).where(eq(channelEntityMap.id, entityMapping.id));
|
|
1987
2705
|
}
|
|
1988
2706
|
await this.db.update(channelEntityMap).set({ lastSyncedAt }).where(and(
|
|
@@ -2071,11 +2789,17 @@ export class ChannelConnectorService {
|
|
|
2071
2789
|
inventoryUpdated += 1;
|
|
2072
2790
|
}
|
|
2073
2791
|
const threshold = this.options.driftAlertThreshold ?? 25;
|
|
2792
|
+
const openConflictRows = await this.db.select({ id: channelCatalogConflicts.id }).from(channelCatalogConflicts).where(and(
|
|
2793
|
+
eq(channelCatalogConflicts.organizationId, orgId),
|
|
2794
|
+
eq(channelCatalogConflicts.storeId, storeId),
|
|
2795
|
+
eq(channelCatalogConflicts.state, "open"),
|
|
2796
|
+
));
|
|
2074
2797
|
const report: ReconcileReport = {
|
|
2075
2798
|
imported: converged.value.imported,
|
|
2076
2799
|
converged: converged.value.converged,
|
|
2077
2800
|
archived,
|
|
2078
2801
|
inventoryUpdated,
|
|
2802
|
+
openConflicts: openConflictRows.length,
|
|
2079
2803
|
driftAlert: converged.value.imported + converged.value.converged + archived > threshold,
|
|
2080
2804
|
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
2081
2805
|
...(converged.value.conflicts.length > 0 ? { conflicts: converged.value.conflicts } : {}),
|
|
@@ -2097,6 +2821,152 @@ export class ChannelConnectorService {
|
|
|
2097
2821
|
return Ok({ lastReconcileAt: store.lastReconcileAt, report, driftAlert: report?.driftAlert ?? false });
|
|
2098
2822
|
}
|
|
2099
2823
|
|
|
2824
|
+
async listCatalogConflicts(
|
|
2825
|
+
orgId: string,
|
|
2826
|
+
storeId?: string,
|
|
2827
|
+
state: ChannelCatalogConflict["state"] = "open",
|
|
2828
|
+
): Promise<PluginResult<ChannelCatalogConflict[]>> {
|
|
2829
|
+
const conditions = [eq(channelCatalogConflicts.organizationId, orgId), eq(channelCatalogConflicts.state, state)];
|
|
2830
|
+
if (storeId !== undefined) conditions.push(eq(channelCatalogConflicts.storeId, storeId));
|
|
2831
|
+
return Ok(await this.db.select().from(channelCatalogConflicts).where(and(...conditions)) as ChannelCatalogConflict[]);
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
async resolveCatalogConflict(
|
|
2835
|
+
orgId: string,
|
|
2836
|
+
id: string,
|
|
2837
|
+
choose: "platform" | "store",
|
|
2838
|
+
actor: Pick<Actor, "userId">,
|
|
2839
|
+
): Promise<PluginResult<ChannelCatalogConflict>> {
|
|
2840
|
+
const [conflict] = await this.db.select().from(channelCatalogConflicts).where(and(
|
|
2841
|
+
eq(channelCatalogConflicts.organizationId, orgId),
|
|
2842
|
+
eq(channelCatalogConflicts.id, id),
|
|
2843
|
+
eq(channelCatalogConflicts.state, "open"),
|
|
2844
|
+
));
|
|
2845
|
+
if (!conflict) return PluginErr("Catalog conflict not found or already resolved.", "NOT_FOUND");
|
|
2846
|
+
if (choose === "platform" && !this.jobs) return PluginErr("Jobs are not configured.", "JOBS_UNAVAILABLE");
|
|
2847
|
+
const [mapping] = await this.db.select().from(channelEntityMap).where(and(
|
|
2848
|
+
eq(channelEntityMap.organizationId, orgId),
|
|
2849
|
+
eq(channelEntityMap.storeId, conflict.storeId),
|
|
2850
|
+
eq(channelEntityMap.kind, "entity"),
|
|
2851
|
+
eq(channelEntityMap.entityId, conflict.entityId),
|
|
2852
|
+
));
|
|
2853
|
+
if (!mapping) return PluginErr("Catalog conflict mapping not found.", "NOT_FOUND");
|
|
2854
|
+
const systemActor = createSystemActor(orgId);
|
|
2855
|
+
let resolutionBaselineAt: Date | undefined;
|
|
2856
|
+
if (choose === "store") {
|
|
2857
|
+
const applied = await this.applyStoreConflictValue(orgId, conflict as ChannelCatalogConflict, systemActor);
|
|
2858
|
+
if (!applied.ok) return PluginErr(applied.error, applied.code);
|
|
2859
|
+
const revisions = await this.catalog.repository.findRevisionMarkers(conflict.entityId);
|
|
2860
|
+
resolutionBaselineAt = revisions.at(-1)?.createdAt;
|
|
2861
|
+
}
|
|
2862
|
+
const heldFieldPaths = (mapping.heldFieldPaths ?? []).filter((path) => path !== conflict.fieldPath);
|
|
2863
|
+
const forcedPushFieldPaths = choose === "platform"
|
|
2864
|
+
? [...new Set([...(mapping.forcedPushFieldPaths ?? []), conflict.fieldPath as FieldPath])]
|
|
2865
|
+
: mapping.forcedPushFieldPaths ?? [];
|
|
2866
|
+
const [resolved] = await this.db.update(channelCatalogConflicts).set({
|
|
2867
|
+
state: "resolved",
|
|
2868
|
+
resolvedBy: requireUserId(actor),
|
|
2869
|
+
updatedAt: new Date(),
|
|
2870
|
+
}).where(and(
|
|
2871
|
+
eq(channelCatalogConflicts.organizationId, orgId),
|
|
2872
|
+
eq(channelCatalogConflicts.id, id),
|
|
2873
|
+
eq(channelCatalogConflicts.state, "open"),
|
|
2874
|
+
)).returning();
|
|
2875
|
+
if (!resolved) return PluginErr("Catalog conflict not found or already resolved.", "NOT_FOUND");
|
|
2876
|
+
await this.db.update(channelEntityMap).set({
|
|
2877
|
+
heldFieldPaths,
|
|
2878
|
+
forcedPushFieldPaths,
|
|
2879
|
+
...(resolutionBaselineAt ? { lastSyncedAt: resolutionBaselineAt } : {}),
|
|
2880
|
+
}).where(and(
|
|
2881
|
+
eq(channelEntityMap.organizationId, orgId),
|
|
2882
|
+
eq(channelEntityMap.id, mapping.id),
|
|
2883
|
+
));
|
|
2884
|
+
await this.db.insert(channelCatalogConflictEvents).values({
|
|
2885
|
+
organizationId: orgId,
|
|
2886
|
+
conflictId: conflict.id,
|
|
2887
|
+
fromState: "open",
|
|
2888
|
+
toState: "resolved",
|
|
2889
|
+
reason: `Operator chose the ${choose} value.`,
|
|
2890
|
+
changedBy: requireUserId(actor),
|
|
2891
|
+
});
|
|
2892
|
+
if (choose === "platform") {
|
|
2893
|
+
await this.jobs!.enqueue("channel/push-catalog", {
|
|
2894
|
+
organizationId: orgId,
|
|
2895
|
+
storeId: conflict.storeId,
|
|
2896
|
+
entityIds: [conflict.entityId],
|
|
2897
|
+
}, {
|
|
2898
|
+
organizationId: orgId,
|
|
2899
|
+
concurrencyKey: catalogPushConcurrencyKey({ storeId: conflict.storeId, entityIds: [conflict.entityId] }),
|
|
2900
|
+
supersedes: true,
|
|
2901
|
+
});
|
|
2902
|
+
}
|
|
2903
|
+
return Ok(resolved as ChannelCatalogConflict);
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
private async applyStoreConflictValue(
|
|
2907
|
+
orgId: string,
|
|
2908
|
+
conflict: ChannelCatalogConflict,
|
|
2909
|
+
actor: Actor,
|
|
2910
|
+
): Promise<PluginResult<void>> {
|
|
2911
|
+
const [root, segment, field] = conflict.fieldPath.split(".");
|
|
2912
|
+
if (root === "entity" && segment === "slug") {
|
|
2913
|
+
if (typeof conflict.storeValue !== "string") return PluginErr("The stored catalog value is not a valid slug.", "INVALID_CONFLICT_VALUE");
|
|
2914
|
+
const updated = await this.catalog.update(conflict.entityId, { slug: conflict.storeValue }, actor, CHANNEL_CONVERGENCE_CTX);
|
|
2915
|
+
return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
|
|
2916
|
+
}
|
|
2917
|
+
if (root === "entity" && segment === "status") {
|
|
2918
|
+
if (typeof conflict.storeValue !== "string") return PluginErr("The stored catalog value is not a valid status.", "INVALID_CONFLICT_VALUE");
|
|
2919
|
+
const status = ["draft", "active", "archived", "discontinued"].find((value) => value === conflict.storeValue);
|
|
2920
|
+
if (!status) return PluginErr("The stored catalog value is not a valid status.", "INVALID_CONFLICT_VALUE");
|
|
2921
|
+
const updated = await this.catalog.update(conflict.entityId, { status, isVisible: status === "active" }, actor, CHANNEL_CONVERGENCE_CTX);
|
|
2922
|
+
return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
|
|
2923
|
+
}
|
|
2924
|
+
if (root === "entity" && segment === "metadata" && field) {
|
|
2925
|
+
const [entity] = await this.db.select().from(sellableEntities).where(and(
|
|
2926
|
+
eq(sellableEntities.organizationId, orgId),
|
|
2927
|
+
eq(sellableEntities.id, conflict.entityId),
|
|
2928
|
+
));
|
|
2929
|
+
if (!entity) return PluginErr("Catalog entity not found.", "NOT_FOUND");
|
|
2930
|
+
const updated = await this.catalog.update(conflict.entityId, {
|
|
2931
|
+
metadata: { ...(entity.metadata ?? {}), [field]: conflict.storeValue },
|
|
2932
|
+
}, actor, CHANNEL_CONVERGENCE_CTX);
|
|
2933
|
+
return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
|
|
2934
|
+
}
|
|
2935
|
+
if (root === "attributes" && segment && field && attributeFields.some((attributeField) => attributeField === field)) {
|
|
2936
|
+
const [attribute] = await this.db.select().from(sellableAttributes).where(and(
|
|
2937
|
+
eq(sellableAttributes.entityId, conflict.entityId),
|
|
2938
|
+
eq(sellableAttributes.locale, segment),
|
|
2939
|
+
));
|
|
2940
|
+
const title = attribute?.title ?? "";
|
|
2941
|
+
const attrs: Parameters<CatalogService["setAttributes"]>[2] = { title };
|
|
2942
|
+
if (field === "title") {
|
|
2943
|
+
if (typeof conflict.storeValue !== "string") return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
|
|
2944
|
+
attrs.title = conflict.storeValue;
|
|
2945
|
+
} else if (field === "subtitle") {
|
|
2946
|
+
if (typeof conflict.storeValue !== "string") return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
|
|
2947
|
+
attrs.subtitle = conflict.storeValue;
|
|
2948
|
+
} else if (field === "description") {
|
|
2949
|
+
if (typeof conflict.storeValue !== "string") return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
|
|
2950
|
+
attrs.description = conflict.storeValue;
|
|
2951
|
+
} else if (field === "richDescription") {
|
|
2952
|
+
attrs.richDescription = conflict.storeValue;
|
|
2953
|
+
} else if (field === "seoTitle") {
|
|
2954
|
+
if (typeof conflict.storeValue !== "string") return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
|
|
2955
|
+
attrs.seoTitle = conflict.storeValue;
|
|
2956
|
+
} else if (field === "seoDescription") {
|
|
2957
|
+
if (typeof conflict.storeValue !== "string") return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
|
|
2958
|
+
attrs.seoDescription = conflict.storeValue;
|
|
2959
|
+
}
|
|
2960
|
+
const updated = await this.catalog.setAttributes(conflict.entityId, segment, attrs, actor, CHANNEL_CONVERGENCE_CTX);
|
|
2961
|
+
return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
|
|
2962
|
+
}
|
|
2963
|
+
if (root === "customFields" && segment && field === "en") {
|
|
2964
|
+
const updated = await this.catalog.update(conflict.entityId, { customFields: { [segment]: conflict.storeValue } }, actor, CHANNEL_CONVERGENCE_CTX);
|
|
2965
|
+
return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
|
|
2966
|
+
}
|
|
2967
|
+
return PluginErr(`Conflict field path "${conflict.fieldPath}" cannot be resolved to the store value.`, "UNSUPPORTED_CONFLICT_FIELD");
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2100
2970
|
async syncInventory(
|
|
2101
2971
|
orgId: string,
|
|
2102
2972
|
storeId: string,
|
|
@@ -2318,7 +3188,19 @@ export class ChannelConnectorService {
|
|
|
2318
3188
|
const status = typeof product.status === "string" && ["draft", "active", "archived", "discontinued"].includes(product.status)
|
|
2319
3189
|
? product.status as NonNullable<ChannelCatalogItem["status"]>
|
|
2320
3190
|
: undefined;
|
|
2321
|
-
const
|
|
3191
|
+
const customFields = product.customFields && typeof product.customFields === "object" && !Array.isArray(product.customFields)
|
|
3192
|
+
? product.customFields as Record<string, unknown>
|
|
3193
|
+
: undefined;
|
|
3194
|
+
const images = Array.isArray(product.images)
|
|
3195
|
+
? product.images.flatMap((raw) => {
|
|
3196
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
|
|
3197
|
+
const image = raw as Record<string, unknown>;
|
|
3198
|
+
const role = typeof image.role === "string" ? pushCatalogImageRole(image.role) : undefined;
|
|
3199
|
+
const url = typeof image.url === "string" ? image.url : typeof image.src === "string" ? image.src : undefined;
|
|
3200
|
+
return role && url ? [{ role, url }] : [];
|
|
3201
|
+
})
|
|
3202
|
+
: [];
|
|
3203
|
+
const remoteItem = {
|
|
2322
3204
|
externalId: mapping.externalId,
|
|
2323
3205
|
slug: typeof product.slug === "string" ? product.slug : entity.slug,
|
|
2324
3206
|
title,
|
|
@@ -2326,8 +3208,10 @@ export class ChannelConnectorService {
|
|
|
2326
3208
|
...(status !== undefined ? { status } : {}),
|
|
2327
3209
|
attributes: [{ locale: "en", title, ...(description !== undefined ? { description } : {}) }],
|
|
2328
3210
|
...(Object.keys(remoteMetadata).length > 0 ? { metadata: remoteMetadata } : {}),
|
|
3211
|
+
...(customFields !== undefined ? { customFields } : {}),
|
|
3212
|
+
...(images.length > 0 ? { images } : {}),
|
|
2329
3213
|
variants: [],
|
|
2330
|
-
};
|
|
3214
|
+
} as ChannelCatalogItem & { customFields?: Record<string, unknown> };
|
|
2331
3215
|
const fieldPaths: FieldPath[] = [];
|
|
2332
3216
|
if (typeof product.slug === "string") fieldPaths.push("entity.slug");
|
|
2333
3217
|
if (status !== undefined) fieldPaths.push("entity.status");
|
|
@@ -2337,6 +3221,14 @@ export class ChannelConnectorService {
|
|
|
2337
3221
|
}
|
|
2338
3222
|
if (typeof product.title === "string") fieldPaths.push("attributes.en.title");
|
|
2339
3223
|
if (product.description !== undefined) fieldPaths.push("attributes.en.description");
|
|
3224
|
+
for (const [name, locales] of Object.entries(customFields ?? {})) {
|
|
3225
|
+
if (!locales || typeof locales !== "object" || Array.isArray(locales)) continue;
|
|
3226
|
+
for (const locale of Object.keys(locales as Record<string, unknown>)) {
|
|
3227
|
+
const path = `customFields.${name}.${locale}`;
|
|
3228
|
+
if (isValidFieldPath(path)) fieldPaths.push(path);
|
|
3229
|
+
}
|
|
3230
|
+
}
|
|
3231
|
+
for (const image of images) fieldPaths.push(`media.${image.role}`);
|
|
2340
3232
|
const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
|
|
2341
3233
|
const seedPaths = fieldPaths.filter((path) => !ownershipBeforeSeed.has(path));
|
|
2342
3234
|
const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
|
|
@@ -2344,9 +3236,21 @@ export class ChannelConnectorService {
|
|
|
2344
3236
|
for (const path of seedPaths) ownershipBeforeSeed.set(path, "store");
|
|
2345
3237
|
const owners = ownershipBeforeSeed;
|
|
2346
3238
|
const remoteHash = hash(product);
|
|
2347
|
-
const
|
|
3239
|
+
const outboundEcho = this.isOutboundEcho(mapping, remoteItem);
|
|
3240
|
+
const shared = await this.detectSharedConflicts(
|
|
3241
|
+
entityId, storeId, entity, mapping, remoteItem, owners, fieldPaths, remoteHash,
|
|
3242
|
+
outboundEcho ? { certifiedPaths: new Set(mapping.outboundFieldPaths ?? []) } : undefined,
|
|
3243
|
+
);
|
|
3244
|
+
const persistedConflicts = await this.persistCatalogConflicts(orgId, shared.conflicts, requireUserId(actor));
|
|
3245
|
+
if (!persistedConflicts.ok) return persistedConflicts;
|
|
2348
3246
|
const owned = this.filterOwnedFieldsAtPaths(remoteItem, owners, fieldPaths);
|
|
2349
3247
|
const heldPaths = [...new Set([...(mapping.heldFieldPaths ?? []), ...shared.paths])];
|
|
3248
|
+
// Same revocation as the reconcile path: a newly held path cancels any force
|
|
3249
|
+
// left from an earlier resolution, so a webhook-raised conflict cannot be
|
|
3250
|
+
// pre-empted by an operator's answer to a previous one.
|
|
3251
|
+
const survivingForcedPaths = (mapping.forcedPushFieldPaths ?? []).filter(
|
|
3252
|
+
(path) => !heldPaths.includes(path),
|
|
3253
|
+
);
|
|
2350
3254
|
const held = this.filterConflictingFields(owned.writable, heldPaths);
|
|
2351
3255
|
const blockedPaths = new Set<FieldPath>([
|
|
2352
3256
|
...owned.skipped,
|
|
@@ -2354,7 +3258,7 @@ export class ChannelConnectorService {
|
|
|
2354
3258
|
...(!fieldPaths.includes("attributes.en.title") ? ["attributes.en.title" as FieldPath] : []),
|
|
2355
3259
|
]);
|
|
2356
3260
|
const skipped = owned.skipped.map((fieldPath) => ({ entityId, fieldPath }));
|
|
2357
|
-
const conflicts = shared.conflicts;
|
|
3261
|
+
const conflicts = shared.conflicts.map(({ platformValue: _platformValue, storeValue: _storeValue, ...conflict }) => conflict);
|
|
2358
3262
|
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}).`);
|
|
2359
3263
|
const writable = held.writable;
|
|
2360
3264
|
const updateInput: {
|
|
@@ -2375,10 +3279,10 @@ export class ChannelConnectorService {
|
|
|
2375
3279
|
updateInput.isVisible = writable.status === "active";
|
|
2376
3280
|
}
|
|
2377
3281
|
if (Object.keys(updateInput).length > 0) {
|
|
2378
|
-
const updated = await this.catalog.update(entityId, updateInput, actor);
|
|
3282
|
+
const updated = await this.catalog.update(entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
|
|
2379
3283
|
if (!updated.ok) return PluginErr(updated.error.message);
|
|
2380
3284
|
}
|
|
2381
|
-
const attributes = await this.
|
|
3285
|
+
const attributes = await this.setCatalogAttributesIfWritable(entityId, writable, actor, blockedPaths, CHANNEL_CONVERGENCE_CTX);
|
|
2382
3286
|
if (!attributes.ok) return attributes;
|
|
2383
3287
|
const levels = Array.isArray(product.variants) ? product.variants as Array<Record<string, unknown>> : [];
|
|
2384
3288
|
for (const variant of levels) {
|
|
@@ -2392,6 +3296,7 @@ export class ChannelConnectorService {
|
|
|
2392
3296
|
syncHash: remoteHash,
|
|
2393
3297
|
lastSyncedAt,
|
|
2394
3298
|
heldFieldPaths: heldPaths,
|
|
3299
|
+
forcedPushFieldPaths: survivingForcedPaths,
|
|
2395
3300
|
}).where(eq(channelEntityMap.id, mapping.id));
|
|
2396
3301
|
return Ok({ skipped, conflicts, warnings });
|
|
2397
3302
|
}
|
|
@@ -2425,9 +3330,9 @@ export class ChannelConnectorService {
|
|
|
2425
3330
|
const max = this.options.refundAutoMax ?? order.amountCaptured ?? order.grandTotal;
|
|
2426
3331
|
const ageOk = Date.now() - store.createdAt.getTime() >= (this.options.newStoreDays ?? 7) * 86_400_000;
|
|
2427
3332
|
const auto = clean && amount > 0 && ageOk && amount <= max;
|
|
2428
|
-
const rows = await this.db.insert(channelRefundRequests).values({ organizationId: orgId, storeId: store.id, orderId, remoteRefundId, amount, state: auto ? "approved" : "requested", approvedBy: auto ? actor
|
|
3333
|
+
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();
|
|
2429
3334
|
const request = rows[0] as ChannelRefundRequest;
|
|
2430
|
-
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
|
|
3335
|
+
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) });
|
|
2431
3336
|
if (auto) {
|
|
2432
3337
|
const result = await this.executeRefund(request, refundLines, actor);
|
|
2433
3338
|
if (!result.ok) return PluginErr(result.error);
|
|
@@ -2440,7 +3345,7 @@ export class ChannelConnectorService {
|
|
|
2440
3345
|
const result = await ordersService.refundLines(request.orderId, { lines, reason: `Channel refund ${request.remoteRefundId}` }, actor);
|
|
2441
3346
|
if (!result.ok) return PluginErr(result.error?.message ?? "Refund execution failed.");
|
|
2442
3347
|
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();
|
|
2443
|
-
await this.db.insert(channelRefundEvents).values({ organizationId: request.organizationId, requestId: request.id, fromState: "approved", toState: "executed", reason: "Platform refund executed", changedBy: actor
|
|
3348
|
+
await this.db.insert(channelRefundEvents).values({ organizationId: request.organizationId, requestId: request.id, fromState: "approved", toState: "executed", reason: "Platform refund executed", changedBy: requireUserId(actor) });
|
|
2444
3349
|
return Ok(updated as ChannelRefundRequest);
|
|
2445
3350
|
}
|
|
2446
3351
|
|
|
@@ -2574,7 +3479,7 @@ export class ChannelConnectorService {
|
|
|
2574
3479
|
orgId,
|
|
2575
3480
|
created.value.id,
|
|
2576
3481
|
"exported",
|
|
2577
|
-
actor
|
|
3482
|
+
requireUserId(actor),
|
|
2578
3483
|
"Export attempt started.",
|
|
2579
3484
|
);
|
|
2580
3485
|
if (!exported.ok) return exported;
|
|
@@ -2594,7 +3499,7 @@ export class ChannelConnectorService {
|
|
|
2594
3499
|
orgId,
|
|
2595
3500
|
created.value.id,
|
|
2596
3501
|
"failed",
|
|
2597
|
-
actor
|
|
3502
|
+
requireUserId(actor),
|
|
2598
3503
|
pushed.error.message,
|
|
2599
3504
|
pushed.error.retriable === true ? "transient" : "definitive",
|
|
2600
3505
|
);
|
|
@@ -2621,7 +3526,7 @@ export class ChannelConnectorService {
|
|
|
2621
3526
|
orgId,
|
|
2622
3527
|
created.value.id,
|
|
2623
3528
|
"failed",
|
|
2624
|
-
actor
|
|
3529
|
+
requireUserId(actor),
|
|
2625
3530
|
remoteStatus.error.message,
|
|
2626
3531
|
remoteStatus.error.retriable === true ? "transient" : "definitive",
|
|
2627
3532
|
);
|
|
@@ -2631,7 +3536,7 @@ export class ChannelConnectorService {
|
|
|
2631
3536
|
orgId,
|
|
2632
3537
|
created.value.id,
|
|
2633
3538
|
"confirmed",
|
|
2634
|
-
actor
|
|
3539
|
+
requireUserId(actor),
|
|
2635
3540
|
"Remote order confirmed.",
|
|
2636
3541
|
);
|
|
2637
3542
|
}
|
|
@@ -2640,7 +3545,7 @@ export class ChannelConnectorService {
|
|
|
2640
3545
|
orgId,
|
|
2641
3546
|
created.value.id,
|
|
2642
3547
|
"failed",
|
|
2643
|
-
actor
|
|
3548
|
+
requireUserId(actor),
|
|
2644
3549
|
`Remote order status: ${remoteStatus.value.status}.`,
|
|
2645
3550
|
);
|
|
2646
3551
|
}
|
|
@@ -2746,4 +3651,467 @@ export class ChannelConnectorService {
|
|
|
2746
3651
|
): Promise<PluginResult<ChannelOrderExport>> {
|
|
2747
3652
|
return this.transitionExport(orgId, exportId, "abandoned", changedBy, reason);
|
|
2748
3653
|
}
|
|
3654
|
+
|
|
3655
|
+
async resolveCatalogPushEntityIds(
|
|
3656
|
+
orgId: string,
|
|
3657
|
+
storeId: string,
|
|
3658
|
+
entityIds?: string[],
|
|
3659
|
+
): Promise<string[]> {
|
|
3660
|
+
if (entityIds !== undefined) return [...new Set(entityIds)].sort();
|
|
3661
|
+
const mappings = await this.db.select({ entityId: channelEntityMap.entityId }).from(channelEntityMap).where(and(
|
|
3662
|
+
eq(channelEntityMap.organizationId, orgId),
|
|
3663
|
+
eq(channelEntityMap.storeId, storeId),
|
|
3664
|
+
eq(channelEntityMap.kind, "entity"),
|
|
3665
|
+
));
|
|
3666
|
+
return [...new Set(mappings.map((mapping) => mapping.entityId))].sort();
|
|
3667
|
+
}
|
|
3668
|
+
|
|
3669
|
+
async createCatalogPush(
|
|
3670
|
+
orgId: string,
|
|
3671
|
+
storeId: string,
|
|
3672
|
+
entityId: string,
|
|
3673
|
+
): Promise<PluginResult<ChannelCatalogPush>> {
|
|
3674
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
3675
|
+
if (!store || store.status !== "connected") {
|
|
3676
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
3677
|
+
}
|
|
3678
|
+
const rows = await this.db
|
|
3679
|
+
.insert(channelCatalogPushes)
|
|
3680
|
+
.values({ organizationId: orgId, storeId, entityId })
|
|
3681
|
+
.onConflictDoNothing({ target: [channelCatalogPushes.storeId, channelCatalogPushes.entityId] })
|
|
3682
|
+
.returning();
|
|
3683
|
+
if (rows[0]) return Ok(rows[0] as ChannelCatalogPush);
|
|
3684
|
+
const existing = await this.db
|
|
3685
|
+
.select()
|
|
3686
|
+
.from(channelCatalogPushes)
|
|
3687
|
+
.where(and(
|
|
3688
|
+
eq(channelCatalogPushes.organizationId, orgId),
|
|
3689
|
+
eq(channelCatalogPushes.storeId, storeId),
|
|
3690
|
+
eq(channelCatalogPushes.entityId, entityId),
|
|
3691
|
+
));
|
|
3692
|
+
if (!existing[0]) return PluginErr("Failed to create channel catalog push.");
|
|
3693
|
+
return Ok(existing[0] as ChannelCatalogPush);
|
|
3694
|
+
}
|
|
3695
|
+
|
|
3696
|
+
async transitionCatalogPush(
|
|
3697
|
+
orgId: string,
|
|
3698
|
+
pushId: string,
|
|
3699
|
+
toState: CatalogPushState,
|
|
3700
|
+
changedBy: string,
|
|
3701
|
+
reason?: string,
|
|
3702
|
+
failureKind?: "definitive" | "transient",
|
|
3703
|
+
payloadSnapshot?: ChannelPushCatalogItem | null,
|
|
3704
|
+
): Promise<PluginResult<ChannelCatalogPush>> {
|
|
3705
|
+
return this.transact(async (tx) => {
|
|
3706
|
+
const currentRows = await tx
|
|
3707
|
+
.select()
|
|
3708
|
+
.from(channelCatalogPushes)
|
|
3709
|
+
.where(and(
|
|
3710
|
+
eq(channelCatalogPushes.organizationId, orgId),
|
|
3711
|
+
eq(channelCatalogPushes.id, pushId),
|
|
3712
|
+
));
|
|
3713
|
+
const current = currentRows[0] as ChannelCatalogPush | undefined;
|
|
3714
|
+
if (!current) return PluginErr("Channel catalog push not found.", "NOT_FOUND");
|
|
3715
|
+
if (!canCatalogPushTransition(current.state, toState)) {
|
|
3716
|
+
const error = new CommerceInvalidTransitionError(
|
|
3717
|
+
`Cannot transition channel catalog push from ${current.state} to ${toState}.`,
|
|
3718
|
+
);
|
|
3719
|
+
return PluginErr(error.message, error.code);
|
|
3720
|
+
}
|
|
3721
|
+
|
|
3722
|
+
const updatedRows = await tx
|
|
3723
|
+
.update(channelCatalogPushes)
|
|
3724
|
+
.set({
|
|
3725
|
+
state: toState,
|
|
3726
|
+
updatedAt: new Date(),
|
|
3727
|
+
...(payloadSnapshot !== undefined ? { payloadSnapshot } : {}),
|
|
3728
|
+
...(toState === "exported" ? { attempts: current.attempts + 1, lastError: null, failureKind: null } : {}),
|
|
3729
|
+
...(toState === "failed" ? { lastError: reason ?? "Catalog push failed." } : {}),
|
|
3730
|
+
...(toState === "failed" ? { failureKind: failureKind ?? "definitive" } : {}),
|
|
3731
|
+
...(toState === "confirmed" ? { lastError: null, failureKind: null } : {}),
|
|
3732
|
+
})
|
|
3733
|
+
.where(and(
|
|
3734
|
+
eq(channelCatalogPushes.organizationId, orgId),
|
|
3735
|
+
eq(channelCatalogPushes.id, pushId),
|
|
3736
|
+
eq(channelCatalogPushes.state, current.state),
|
|
3737
|
+
))
|
|
3738
|
+
.returning();
|
|
3739
|
+
const updated = updatedRows[0] as ChannelCatalogPush | undefined;
|
|
3740
|
+
if (!updated) return PluginErr("Channel catalog push changed concurrently.", "CONFLICT");
|
|
3741
|
+
|
|
3742
|
+
await tx.insert(channelCatalogPushEvents).values({
|
|
3743
|
+
organizationId: orgId,
|
|
3744
|
+
pushId,
|
|
3745
|
+
fromState: current.state,
|
|
3746
|
+
toState,
|
|
3747
|
+
reason: reason ?? null,
|
|
3748
|
+
changedBy,
|
|
3749
|
+
});
|
|
3750
|
+
return Ok(updated);
|
|
3751
|
+
});
|
|
3752
|
+
}
|
|
3753
|
+
|
|
3754
|
+
private async recordCatalogPushRevisions(
|
|
3755
|
+
orgId: string,
|
|
3756
|
+
entityIds: string[],
|
|
3757
|
+
actor: Actor,
|
|
3758
|
+
): Promise<PluginResult<void>> {
|
|
3759
|
+
if (entityIds.length === 0) return Ok(undefined);
|
|
3760
|
+
try {
|
|
3761
|
+
await this.transact(async (tx) => {
|
|
3762
|
+
const txContext = createTxContext(tx, { actor });
|
|
3763
|
+
for (const entityId of [...new Set(entityIds)]) {
|
|
3764
|
+
const revision = await this.catalog.recordEntityRevision(entityId, actor, "push", txContext);
|
|
3765
|
+
if (!revision.ok) throw new Error(revision.error.message);
|
|
3766
|
+
}
|
|
3767
|
+
});
|
|
3768
|
+
} catch (error) {
|
|
3769
|
+
return PluginErr(error instanceof Error ? error.message : "Failed to record catalog push revisions.");
|
|
3770
|
+
}
|
|
3771
|
+
return Ok(undefined);
|
|
3772
|
+
}
|
|
3773
|
+
|
|
3774
|
+
async executeCatalogPushJob(
|
|
3775
|
+
orgId: string,
|
|
3776
|
+
storeId: string,
|
|
3777
|
+
options: { entityIds?: string[]; cursor?: string; forceFieldPaths?: Record<string, FieldPath[]> },
|
|
3778
|
+
actor: Actor,
|
|
3779
|
+
runtime: { jobs: JobsAdapter },
|
|
3780
|
+
): Promise<PluginResult<CatalogPushJobResult>> {
|
|
3781
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
3782
|
+
if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
3783
|
+
if (!store.catalogWriteEnabled) return Ok({ noop: true });
|
|
3784
|
+
const connector = this.connectors.get(store.provider);
|
|
3785
|
+
if (!connector?.pushCatalog) return Ok({ noop: true });
|
|
3786
|
+
if (isCatalogPushBreakerOpen(store.breakerState)) {
|
|
3787
|
+
await runtime.jobs.enqueue("channel/push-catalog", {
|
|
3788
|
+
organizationId: orgId,
|
|
3789
|
+
storeId,
|
|
3790
|
+
...(options.entityIds ? { entityIds: options.entityIds } : {}),
|
|
3791
|
+
...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
|
|
3792
|
+
...(options.cursor ? { cursor: options.cursor } : {}),
|
|
3793
|
+
}, {
|
|
3794
|
+
organizationId: orgId,
|
|
3795
|
+
concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
|
|
3796
|
+
supersedes: false,
|
|
3797
|
+
delayMs: CATALOG_PUSH_BREAKER_RETRY_MS,
|
|
3798
|
+
});
|
|
3799
|
+
return Ok({ rescheduled: true });
|
|
3800
|
+
}
|
|
3801
|
+
|
|
3802
|
+
const allEntityIds = await this.resolveCatalogPushEntityIds(orgId, storeId, options.entityIds);
|
|
3803
|
+
const batchSize = catalogPushBatchSize(store.provider);
|
|
3804
|
+
const pageEntityIds = options.cursor
|
|
3805
|
+
? allEntityIds.filter((entityId) => entityId > options.cursor!).slice(0, batchSize)
|
|
3806
|
+
: allEntityIds.slice(0, batchSize);
|
|
3807
|
+
if (pageEntityIds.length === 0) return Ok({ complete: true, pushed: 0, failed: 0 });
|
|
3808
|
+
|
|
3809
|
+
// Abandoned is terminal: a row that exhausted its attempts stays out of
|
|
3810
|
+
// every later sweep until an operator re-arms it.
|
|
3811
|
+
const abandonedRows = await this.db.select({ entityId: channelCatalogPushes.entityId }).from(channelCatalogPushes).where(and(
|
|
3812
|
+
eq(channelCatalogPushes.organizationId, orgId),
|
|
3813
|
+
eq(channelCatalogPushes.storeId, storeId),
|
|
3814
|
+
eq(channelCatalogPushes.state, "abandoned"),
|
|
3815
|
+
inArray(channelCatalogPushes.entityId, pageEntityIds),
|
|
3816
|
+
));
|
|
3817
|
+
const abandonedEntityIds = new Set(abandonedRows.map((row) => row.entityId));
|
|
3818
|
+
const batchEntityIds = pageEntityIds.filter((entityId) => !abandonedEntityIds.has(entityId));
|
|
3819
|
+
if (batchEntityIds.length === 0) {
|
|
3820
|
+
const batchCursor = pageEntityIds[pageEntityIds.length - 1]!;
|
|
3821
|
+
const hasMore = allEntityIds.some((entityId) => entityId > batchCursor);
|
|
3822
|
+
if (!hasMore) return Ok({ complete: true, pushed: 0, failed: 0 });
|
|
3823
|
+
await runtime.jobs.enqueue("channel/push-catalog", {
|
|
3824
|
+
organizationId: orgId,
|
|
3825
|
+
storeId,
|
|
3826
|
+
...(options.entityIds ? { entityIds: options.entityIds } : {}),
|
|
3827
|
+
...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
|
|
3828
|
+
cursor: batchCursor,
|
|
3829
|
+
}, {
|
|
3830
|
+
organizationId: orgId,
|
|
3831
|
+
concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
|
|
3832
|
+
supersedes: false,
|
|
3833
|
+
});
|
|
3834
|
+
return Ok({ complete: false, cursor: batchCursor, pushed: 0, failed: 0 });
|
|
3835
|
+
}
|
|
3836
|
+
|
|
3837
|
+
const assembled = await this.buildCatalogPushItems(orgId, storeId, batchEntityIds, {
|
|
3838
|
+
...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
|
|
3839
|
+
});
|
|
3840
|
+
if (!assembled.ok) return assembled;
|
|
3841
|
+
|
|
3842
|
+
const mappings = await this.db.select({
|
|
3843
|
+
entityId: channelEntityMap.entityId,
|
|
3844
|
+
externalId: channelEntityMap.externalId,
|
|
3845
|
+
}).from(channelEntityMap).where(and(
|
|
3846
|
+
eq(channelEntityMap.organizationId, orgId),
|
|
3847
|
+
eq(channelEntityMap.storeId, storeId),
|
|
3848
|
+
eq(channelEntityMap.kind, "entity"),
|
|
3849
|
+
inArray(channelEntityMap.entityId, batchEntityIds),
|
|
3850
|
+
));
|
|
3851
|
+
const externalByEntity = new Map(mappings.map((mapping) => [mapping.entityId, mapping.externalId]));
|
|
3852
|
+
const entityByExternal = new Map(mappings.map((mapping) => [mapping.externalId, mapping.entityId]));
|
|
3853
|
+
const itemByExternal = new Map(assembled.value.items.map((item) => [item.externalId, item]));
|
|
3854
|
+
|
|
3855
|
+
let pushed = 0;
|
|
3856
|
+
let failed = 0;
|
|
3857
|
+
|
|
3858
|
+
if (assembled.value.items.length === 0) {
|
|
3859
|
+
for (const entityId of batchEntityIds) {
|
|
3860
|
+
const created = await this.createCatalogPush(orgId, storeId, entityId);
|
|
3861
|
+
if (!created.ok) return created;
|
|
3862
|
+
if (created.value.state === "confirmed" || created.value.state === "abandoned") {
|
|
3863
|
+
if (created.value.state === "confirmed") pushed += 1;
|
|
3864
|
+
continue;
|
|
3865
|
+
}
|
|
3866
|
+
const confirmed = await this.transitionCatalogPush(
|
|
3867
|
+
orgId,
|
|
3868
|
+
created.value.id,
|
|
3869
|
+
"confirmed",
|
|
3870
|
+
requireUserId(actor),
|
|
3871
|
+
"No platform-owned fields to push.",
|
|
3872
|
+
undefined,
|
|
3873
|
+
null,
|
|
3874
|
+
);
|
|
3875
|
+
if (!confirmed.ok) return confirmed;
|
|
3876
|
+
pushed += 1;
|
|
3877
|
+
}
|
|
3878
|
+
} else {
|
|
3879
|
+
const pushIds = new Map<string, string>();
|
|
3880
|
+
const pushAttempts = new Map<string, number>();
|
|
3881
|
+
for (const entityId of batchEntityIds) {
|
|
3882
|
+
const externalId = externalByEntity.get(entityId);
|
|
3883
|
+
const item = externalId ? itemByExternal.get(externalId) : undefined;
|
|
3884
|
+
if (!item) continue;
|
|
3885
|
+
const created = await this.createCatalogPush(orgId, storeId, entityId);
|
|
3886
|
+
if (!created.ok) return created;
|
|
3887
|
+
pushIds.set(item.externalId, created.value.id);
|
|
3888
|
+
pushAttempts.set(item.externalId, created.value.attempts);
|
|
3889
|
+
if (created.value.state === "pending" || created.value.state === "confirmed" || created.value.state === "failed") {
|
|
3890
|
+
const exported = await this.transitionCatalogPush(
|
|
3891
|
+
orgId,
|
|
3892
|
+
created.value.id,
|
|
3893
|
+
"exported",
|
|
3894
|
+
requireUserId(actor),
|
|
3895
|
+
"Catalog push attempt started.",
|
|
3896
|
+
undefined,
|
|
3897
|
+
item,
|
|
3898
|
+
);
|
|
3899
|
+
if (!exported.ok) return exported;
|
|
3900
|
+
pushAttempts.set(item.externalId, exported.value.attempts);
|
|
3901
|
+
}
|
|
3902
|
+
}
|
|
3903
|
+
|
|
3904
|
+
const items = assembled.value.items;
|
|
3905
|
+
const writeAhead = await this.recordOutboundPush(
|
|
3906
|
+
orgId,
|
|
3907
|
+
storeId,
|
|
3908
|
+
items.map((item) => ({ externalId: item.externalId, ok: true })),
|
|
3909
|
+
items,
|
|
3910
|
+
"write-ahead",
|
|
3911
|
+
);
|
|
3912
|
+
if (!writeAhead.ok) return writeAhead;
|
|
3913
|
+
|
|
3914
|
+
let result: Awaited<ReturnType<NonNullable<typeof connector.pushCatalog>>>;
|
|
3915
|
+
try {
|
|
3916
|
+
result = await connector.pushCatalog(store as ChannelStore, items);
|
|
3917
|
+
} catch (error) {
|
|
3918
|
+
const connectorError = {
|
|
3919
|
+
code: "CATALOG_PUSH_THROWN",
|
|
3920
|
+
message: error instanceof Error ? error.message : "Catalog push failed.",
|
|
3921
|
+
};
|
|
3922
|
+
const cleared = await this.recordOutboundPush(
|
|
3923
|
+
orgId,
|
|
3924
|
+
storeId,
|
|
3925
|
+
items.map((item) => ({ externalId: item.externalId, ok: false, error: connectorError })),
|
|
3926
|
+
items,
|
|
3927
|
+
);
|
|
3928
|
+
if (!cleared.ok) return cleared;
|
|
3929
|
+
for (const item of items) {
|
|
3930
|
+
const pushId = pushIds.get(item.externalId);
|
|
3931
|
+
if (!pushId) continue;
|
|
3932
|
+
const attempts = pushAttempts.get(item.externalId) ?? 0;
|
|
3933
|
+
if (attempts >= CATALOG_PUSH_MAX_ATTEMPTS) {
|
|
3934
|
+
await this.transitionCatalogPush(
|
|
3935
|
+
orgId,
|
|
3936
|
+
pushId,
|
|
3937
|
+
"abandoned",
|
|
3938
|
+
requireUserId(actor),
|
|
3939
|
+
connectorError.message,
|
|
3940
|
+
);
|
|
3941
|
+
} else {
|
|
3942
|
+
await this.transitionCatalogPush(
|
|
3943
|
+
orgId,
|
|
3944
|
+
pushId,
|
|
3945
|
+
"failed",
|
|
3946
|
+
requireUserId(actor),
|
|
3947
|
+
connectorError.message,
|
|
3948
|
+
"transient",
|
|
3949
|
+
);
|
|
3950
|
+
}
|
|
3951
|
+
failed += 1;
|
|
3952
|
+
}
|
|
3953
|
+
const maxAttempts = Math.max(0, ...items.map((item) => pushAttempts.get(item.externalId) ?? 0));
|
|
3954
|
+
if (maxAttempts < CATALOG_PUSH_MAX_ATTEMPTS) {
|
|
3955
|
+
await runtime.jobs.enqueue("channel/push-catalog", {
|
|
3956
|
+
organizationId: orgId,
|
|
3957
|
+
storeId,
|
|
3958
|
+
...(options.entityIds ? { entityIds: options.entityIds } : {}),
|
|
3959
|
+
...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
|
|
3960
|
+
...(options.cursor ? { cursor: options.cursor } : {}),
|
|
3961
|
+
}, {
|
|
3962
|
+
organizationId: orgId,
|
|
3963
|
+
concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
|
|
3964
|
+
supersedes: false,
|
|
3965
|
+
delayMs: catalogPushRetryDelayMs(maxAttempts),
|
|
3966
|
+
});
|
|
3967
|
+
}
|
|
3968
|
+
return Ok({ rescheduled: true, pushed, failed });
|
|
3969
|
+
}
|
|
3970
|
+
|
|
3971
|
+
if (!result.ok) {
|
|
3972
|
+
const cleared = await this.recordOutboundPush(
|
|
3973
|
+
orgId,
|
|
3974
|
+
storeId,
|
|
3975
|
+
items.map((item) => ({ externalId: item.externalId, ok: false, error: result.error })),
|
|
3976
|
+
items,
|
|
3977
|
+
);
|
|
3978
|
+
if (!cleared.ok) return cleared;
|
|
3979
|
+
for (const item of items) {
|
|
3980
|
+
const pushId = pushIds.get(item.externalId);
|
|
3981
|
+
if (!pushId) continue;
|
|
3982
|
+
const attempts = pushAttempts.get(item.externalId) ?? 0;
|
|
3983
|
+
const failureKind = result.error.retriable === true ? "transient" as const : "definitive" as const;
|
|
3984
|
+
if (failureKind === "transient" && attempts >= CATALOG_PUSH_MAX_ATTEMPTS) {
|
|
3985
|
+
await this.transitionCatalogPush(
|
|
3986
|
+
orgId,
|
|
3987
|
+
pushId,
|
|
3988
|
+
"abandoned",
|
|
3989
|
+
requireUserId(actor),
|
|
3990
|
+
result.error.message,
|
|
3991
|
+
);
|
|
3992
|
+
} else {
|
|
3993
|
+
await this.transitionCatalogPush(
|
|
3994
|
+
orgId,
|
|
3995
|
+
pushId,
|
|
3996
|
+
"failed",
|
|
3997
|
+
requireUserId(actor),
|
|
3998
|
+
result.error.message,
|
|
3999
|
+
failureKind,
|
|
4000
|
+
);
|
|
4001
|
+
}
|
|
4002
|
+
failed += 1;
|
|
4003
|
+
}
|
|
4004
|
+
if (result.error.retriable === true) {
|
|
4005
|
+
const maxAttempts = Math.max(0, ...items.map((item) => pushAttempts.get(item.externalId) ?? 0));
|
|
4006
|
+
if (maxAttempts < CATALOG_PUSH_MAX_ATTEMPTS) {
|
|
4007
|
+
await runtime.jobs.enqueue("channel/push-catalog", {
|
|
4008
|
+
organizationId: orgId,
|
|
4009
|
+
storeId,
|
|
4010
|
+
...(options.entityIds ? { entityIds: options.entityIds } : {}),
|
|
4011
|
+
...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
|
|
4012
|
+
...(options.cursor ? { cursor: options.cursor } : {}),
|
|
4013
|
+
}, {
|
|
4014
|
+
organizationId: orgId,
|
|
4015
|
+
concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
|
|
4016
|
+
supersedes: false,
|
|
4017
|
+
delayMs: catalogPushRetryDelayMs(maxAttempts),
|
|
4018
|
+
});
|
|
4019
|
+
}
|
|
4020
|
+
return Ok({ rescheduled: true, pushed, failed });
|
|
4021
|
+
}
|
|
4022
|
+
const batchCursor = pageEntityIds[pageEntityIds.length - 1]!;
|
|
4023
|
+
const hasMore = allEntityIds.some((entityId) => entityId > batchCursor);
|
|
4024
|
+
return Ok({ complete: !hasMore, pushed, failed });
|
|
4025
|
+
}
|
|
4026
|
+
|
|
4027
|
+
const recorded = await this.recordOutboundPush(orgId, storeId, result.value.outcomes, items);
|
|
4028
|
+
if (!recorded.ok) return recorded;
|
|
4029
|
+
|
|
4030
|
+
const successfulEntityIds: string[] = [];
|
|
4031
|
+
for (const outcome of result.value.outcomes) {
|
|
4032
|
+
const pushId = pushIds.get(outcome.externalId);
|
|
4033
|
+
const entityId = entityByExternal.get(outcome.externalId);
|
|
4034
|
+
if (!pushId || !entityId) continue;
|
|
4035
|
+
const item = itemByExternal.get(outcome.externalId);
|
|
4036
|
+
if (outcome.ok) {
|
|
4037
|
+
const confirmed = await this.transitionCatalogPush(
|
|
4038
|
+
orgId,
|
|
4039
|
+
pushId,
|
|
4040
|
+
"confirmed",
|
|
4041
|
+
requireUserId(actor),
|
|
4042
|
+
"Remote catalog confirmed item.",
|
|
4043
|
+
undefined,
|
|
4044
|
+
item ?? null,
|
|
4045
|
+
);
|
|
4046
|
+
if (!confirmed.ok) return confirmed;
|
|
4047
|
+
successfulEntityIds.push(entityId);
|
|
4048
|
+
pushed += 1;
|
|
4049
|
+
continue;
|
|
4050
|
+
}
|
|
4051
|
+
const failureKind = outcome.error?.retriable === true ? "transient" : "definitive";
|
|
4052
|
+
const attempts = pushAttempts.get(outcome.externalId) ?? 0;
|
|
4053
|
+
if (failureKind === "transient" && attempts >= CATALOG_PUSH_MAX_ATTEMPTS) {
|
|
4054
|
+
const abandoned = await this.transitionCatalogPush(
|
|
4055
|
+
orgId,
|
|
4056
|
+
pushId,
|
|
4057
|
+
"abandoned",
|
|
4058
|
+
requireUserId(actor),
|
|
4059
|
+
outcome.error?.message ?? "Catalog push failed.",
|
|
4060
|
+
undefined,
|
|
4061
|
+
item ?? null,
|
|
4062
|
+
);
|
|
4063
|
+
if (!abandoned.ok) return abandoned;
|
|
4064
|
+
} else {
|
|
4065
|
+
const failedPush = await this.transitionCatalogPush(
|
|
4066
|
+
orgId,
|
|
4067
|
+
pushId,
|
|
4068
|
+
"failed",
|
|
4069
|
+
requireUserId(actor),
|
|
4070
|
+
outcome.error?.message ?? "Catalog push failed.",
|
|
4071
|
+
failureKind,
|
|
4072
|
+
item ?? null,
|
|
4073
|
+
);
|
|
4074
|
+
if (!failedPush.ok) return failedPush;
|
|
4075
|
+
if (failureKind === "transient") {
|
|
4076
|
+
await runtime.jobs.enqueue("channel/push-catalog", {
|
|
4077
|
+
organizationId: orgId,
|
|
4078
|
+
storeId,
|
|
4079
|
+
entityIds: [entityId],
|
|
4080
|
+
...(options.forceFieldPaths?.[entityId]
|
|
4081
|
+
? { forceFieldPaths: { [entityId]: options.forceFieldPaths[entityId] } }
|
|
4082
|
+
: {}),
|
|
4083
|
+
}, {
|
|
4084
|
+
organizationId: orgId,
|
|
4085
|
+
concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: [entityId] }),
|
|
4086
|
+
supersedes: true,
|
|
4087
|
+
delayMs: catalogPushRetryDelayMs(attempts),
|
|
4088
|
+
});
|
|
4089
|
+
}
|
|
4090
|
+
}
|
|
4091
|
+
failed += 1;
|
|
4092
|
+
}
|
|
4093
|
+
|
|
4094
|
+
const revisions = await this.recordCatalogPushRevisions(orgId, successfulEntityIds, actor);
|
|
4095
|
+
if (!revisions.ok) return revisions;
|
|
4096
|
+
}
|
|
4097
|
+
|
|
4098
|
+
const batchCursor = pageEntityIds[pageEntityIds.length - 1]!;
|
|
4099
|
+
const hasMore = allEntityIds.some((entityId) => entityId > batchCursor);
|
|
4100
|
+
if (hasMore) {
|
|
4101
|
+
await runtime.jobs.enqueue("channel/push-catalog", {
|
|
4102
|
+
organizationId: orgId,
|
|
4103
|
+
storeId,
|
|
4104
|
+
...(options.entityIds ? { entityIds: options.entityIds } : {}),
|
|
4105
|
+
...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
|
|
4106
|
+
cursor: batchCursor,
|
|
4107
|
+
}, {
|
|
4108
|
+
organizationId: orgId,
|
|
4109
|
+
concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
|
|
4110
|
+
supersedes: false,
|
|
4111
|
+
});
|
|
4112
|
+
return Ok({ complete: false, cursor: batchCursor, pushed, failed });
|
|
4113
|
+
}
|
|
4114
|
+
|
|
4115
|
+
return Ok({ complete: true, pushed, failed });
|
|
4116
|
+
}
|
|
2749
4117
|
}
|