@porulle/plugin-channel-connector 0.10.8 → 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/schema.js CHANGED
@@ -1,4 +1,4 @@
1
- import { index, integer, jsonb, pgTable, text, timestamp, uniqueIndex, uuid, } from "@porulle/core/drizzle";
1
+ import { boolean, index, integer, jsonb, pgTable, text, timestamp, uniqueIndex, uuid, sql, } from "@porulle/core/drizzle";
2
2
  export const connectedStores = pgTable("connected_stores", {
3
3
  id: uuid("id").defaultRandom().primaryKey(),
4
4
  organizationId: text("organization_id").notNull(),
@@ -8,6 +8,8 @@ export const connectedStores = pgTable("connected_stores", {
8
8
  status: text("status", { enum: ["connected", "disconnected", "error"] })
9
9
  .notNull()
10
10
  .default("connected"),
11
+ catalogWriteEnabled: boolean("catalog_write_enabled").notNull().default(false),
12
+ catalogFieldMapping: jsonb("catalog_field_mapping").$type().notNull().default([]),
11
13
  catalogCursor: text("catalog_cursor"),
12
14
  inventoryCursor: text("inventory_cursor"),
13
15
  lastSyncAt: timestamp("last_sync_at", { withTimezone: true }),
@@ -31,11 +33,48 @@ export const channelEntityMap = pgTable("channel_entity_map", {
31
33
  variantId: uuid("variant_id"),
32
34
  lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }).defaultNow().notNull(),
33
35
  syncHash: text("sync_hash").notNull(),
36
+ outboundHash: text("outbound_hash"),
37
+ outboundPushedAt: timestamp("outbound_pushed_at", { withTimezone: true }),
38
+ outboundFieldPaths: jsonb("outbound_field_paths").$type().notNull().default([]),
39
+ heldFieldPaths: jsonb("held_field_paths").$type().notNull().default([]),
40
+ forcedPushFieldPaths: jsonb("forced_push_field_paths").$type().notNull().default([]),
34
41
  }, (table) => ({
35
42
  orgIdx: index("idx_channel_entity_map_org").on(table.organizationId),
36
43
  storeIdx: index("idx_channel_entity_map_store").on(table.storeId),
37
44
  externalUnique: uniqueIndex("channel_entity_map_store_kind_external_unique").on(table.storeId, table.kind, table.externalId),
38
45
  }));
46
+ export const channelCatalogConflicts = pgTable("channel_catalog_conflicts", {
47
+ id: uuid("id").defaultRandom().primaryKey(),
48
+ organizationId: text("organization_id").notNull(),
49
+ storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
50
+ entityId: uuid("entity_id").notNull(),
51
+ fieldPath: text("field_path").notNull(),
52
+ platformValue: jsonb("platform_value").$type().notNull(),
53
+ storeValue: jsonb("store_value").$type().notNull(),
54
+ state: text("state", { enum: ["open", "resolved"] }).notNull().default("open"),
55
+ resolvedBy: text("resolved_by"),
56
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
57
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
58
+ }, (table) => ({
59
+ orgIdx: index("idx_channel_catalog_conflicts_org").on(table.organizationId),
60
+ stateIdx: index("idx_channel_catalog_conflicts_org_state").on(table.organizationId, table.state),
61
+ openUnique: uniqueIndex("channel_catalog_conflicts_open_unique")
62
+ .on(table.storeId, table.entityId, table.fieldPath)
63
+ .where(sql `${table.state} = 'open'`),
64
+ }));
65
+ export const channelCatalogConflictEvents = pgTable("channel_catalog_conflict_events", {
66
+ id: uuid("id").defaultRandom().primaryKey(),
67
+ organizationId: text("organization_id").notNull(),
68
+ conflictId: uuid("conflict_id").references(() => channelCatalogConflicts.id, { onDelete: "cascade" }).notNull(),
69
+ fromState: text("from_state"),
70
+ toState: text("to_state").notNull(),
71
+ reason: text("reason"),
72
+ changedBy: text("changed_by").notNull(),
73
+ changedAt: timestamp("changed_at", { withTimezone: true }).defaultNow().notNull(),
74
+ }, (table) => ({
75
+ orgIdx: index("idx_channel_catalog_conflict_events_org").on(table.organizationId),
76
+ conflictIdx: index("idx_channel_catalog_conflict_events_conflict").on(table.conflictId),
77
+ }));
39
78
  export const channelOrderExports = pgTable("channel_order_exports", {
40
79
  id: uuid("id").defaultRandom().primaryKey(),
41
80
  organizationId: text("organization_id").notNull(),
@@ -58,6 +97,42 @@ export const channelOrderExports = pgTable("channel_order_exports", {
58
97
  stateIdx: index("idx_channel_order_exports_state").on(table.organizationId, table.state),
59
98
  orderIdx: index("idx_channel_order_exports_order").on(table.organizationId, table.orderId),
60
99
  }));
100
+ export const channelCatalogPushes = pgTable("channel_catalog_pushes", {
101
+ id: uuid("id").defaultRandom().primaryKey(),
102
+ organizationId: text("organization_id").notNull(),
103
+ storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
104
+ entityId: uuid("entity_id").notNull(),
105
+ payloadSnapshot: jsonb("payload_snapshot").$type(),
106
+ state: text("state", { enum: ["pending", "exported", "confirmed", "failed", "abandoned"] })
107
+ .notNull()
108
+ .default("pending"),
109
+ failureKind: text("failure_kind", { enum: ["definitive", "transient"] }),
110
+ attempts: integer("attempts").notNull().default(0),
111
+ lastError: text("last_error"),
112
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
113
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
114
+ }, (table) => ({
115
+ orgIdx: index("idx_channel_catalog_pushes_org").on(table.organizationId),
116
+ storeIdx: index("idx_channel_catalog_pushes_store").on(table.storeId),
117
+ stateIdx: index("idx_channel_catalog_pushes_state").on(table.organizationId, table.state),
118
+ entityIdx: index("idx_channel_catalog_pushes_entity").on(table.organizationId, table.entityId),
119
+ storeEntityUnique: uniqueIndex("channel_catalog_pushes_store_entity_unique").on(table.storeId, table.entityId),
120
+ }));
121
+ export const channelCatalogPushEvents = pgTable("channel_catalog_push_events", {
122
+ id: uuid("id").defaultRandom().primaryKey(),
123
+ organizationId: text("organization_id").notNull(),
124
+ pushId: uuid("push_id")
125
+ .references(() => channelCatalogPushes.id, { onDelete: "cascade" })
126
+ .notNull(),
127
+ fromState: text("from_state").notNull(),
128
+ toState: text("to_state").notNull(),
129
+ reason: text("reason"),
130
+ changedBy: text("changed_by").notNull(),
131
+ changedAt: timestamp("changed_at", { withTimezone: true }).defaultNow().notNull(),
132
+ }, (table) => ({
133
+ orgIdx: index("idx_channel_catalog_push_events_org").on(table.organizationId),
134
+ pushIdx: index("idx_channel_catalog_push_events_push").on(table.pushId),
135
+ }));
61
136
  export const channelExportEvents = pgTable("channel_export_events", {
62
137
  id: uuid("id").defaultRandom().primaryKey(),
63
138
  organizationId: text("organization_id").notNull(),
package/dist/service.d.ts CHANGED
@@ -1,18 +1,66 @@
1
- import type { Actor, ChannelConnector, ChannelOrderSlice, PluginDb, PluginResult, PluginTxFn } from "@porulle/core";
1
+ import type { Actor, ChannelConnector, ChannelOrderSlice, ChannelPushCatalogField, ChannelPushCatalogImage, ChannelPushCatalogItem, ChannelPushCatalogItemOutcome, ChannelPushCatalogResult, PluginDb, PluginResult, PluginTxFn } from "@porulle/core";
2
+ import type { FieldOwner, FieldPath } from "@porulle/core";
2
3
  import type { JobsAdapter } from "@porulle/core";
3
- import { type ChannelOrderExport, type ChannelRefundRequest, type ConnectedStore } from "./schema.js";
4
+ import { type ChannelCatalogPush, type ChannelCatalogConflict, type ChannelOrderExport, type ChannelRefundRequest, type ConnectedStore } from "./schema.js";
5
+ import { type CatalogFieldMapping, type CatalogFieldTarget } from "./catalog-field-mapping.js";
4
6
  export type ExportState = ChannelOrderExport["state"];
7
+ export type CatalogPushState = ChannelCatalogPush["state"];
8
+ export type CatalogConflictState = ChannelCatalogConflict["state"];
9
+ export declare const CATALOG_PUSH_BATCH_SIZES: Record<string, number>;
10
+ export declare const CATALOG_PUSH_MAX_ATTEMPTS = 8;
11
+ export declare function catalogPushRetryDelayMs(attempts: number): number;
12
+ export interface CatalogPushJobResult extends Record<string, unknown> {
13
+ noop?: boolean;
14
+ rescheduled?: boolean;
15
+ complete?: boolean;
16
+ cursor?: string;
17
+ pushed?: number;
18
+ failed?: number;
19
+ }
20
+ export declare function catalogPushConcurrencyKey(input: Record<string, unknown>): string;
21
+ export declare function isCatalogPushBreakerOpen(breakerState: Record<string, unknown>): boolean;
5
22
  export interface ReconcileReport extends Record<string, unknown> {
6
23
  imported: number;
7
24
  converged: number;
8
25
  archived: number;
9
26
  inventoryUpdated: number;
27
+ openConflicts: number;
10
28
  driftAlert: boolean;
29
+ skipped?: CatalogFieldSkip[];
30
+ conflicts?: CatalogFieldConflict[];
31
+ warnings?: string[];
32
+ }
33
+ export interface CatalogFieldConflict {
34
+ entityId: string;
35
+ storeId: string;
36
+ fieldPath: FieldPath;
37
+ localValueSummary: string;
38
+ remoteValueSummary: string;
39
+ }
40
+ export interface CatalogFieldSkip {
41
+ entityId: string;
42
+ fieldPath: FieldPath;
43
+ }
44
+ export type CatalogPushSkipReason = "no_mapping" | "held" | "store_owned" | "entity_not_active" | "unmapped_entity";
45
+ export interface CatalogPushFieldSkip {
46
+ entityId: string;
47
+ fieldPath: FieldPath;
48
+ reason: CatalogPushSkipReason;
49
+ value?: unknown;
50
+ owner?: FieldOwner;
51
+ target?: CatalogFieldTarget;
52
+ remoteKey?: string;
11
53
  }
12
54
  export type PublicConnectedStore = Omit<ConnectedStore, "credentials" | "webhookSecret"> & {
13
55
  credentials: "[REDACTED]";
14
56
  webhookSecret: "[REDACTED]";
15
57
  };
58
+ export interface CatalogWriteSettings {
59
+ enabled: boolean;
60
+ overrides: CatalogFieldMapping;
61
+ merged: CatalogFieldMapping;
62
+ warnings?: string[];
63
+ }
16
64
  export interface ChannelComplianceData {
17
65
  customer: {
18
66
  id?: string;
@@ -47,7 +95,77 @@ export interface ChannelStockLine {
47
95
  title?: string;
48
96
  quantity: number;
49
97
  }
98
+ interface BackfillCounts {
99
+ entitiesTouched: number;
100
+ attributesCreated: number;
101
+ mediaImported: number;
102
+ variantsGivenOptionValues: number;
103
+ }
104
+ export interface BackfillCatalogReport extends BackfillCounts, Record<string, unknown> {
105
+ cursor: string | null;
106
+ complete: boolean;
107
+ skipped?: CatalogFieldSkip[];
108
+ conflicts?: CatalogFieldConflict[];
109
+ warnings?: string[];
110
+ }
111
+ export interface BackfillCatalogOptions {
112
+ dryRun?: boolean;
113
+ resume?: boolean;
114
+ maxPages?: number;
115
+ }
116
+ export interface BuildCatalogPushItemsOptions {
117
+ recordRevision?: boolean;
118
+ forceFieldPaths?: Record<string, FieldPath[]>;
119
+ }
120
+ export interface CatalogPushAssemblyField extends ChannelPushCatalogField {
121
+ target: CatalogFieldTarget;
122
+ }
123
+ export interface CatalogPushAssemblyImage extends ChannelPushCatalogImage {
124
+ fieldPath: FieldPath;
125
+ target: CatalogFieldTarget;
126
+ remoteKey: string;
127
+ }
128
+ export interface CatalogPushAssemblyItem extends Omit<ChannelPushCatalogItem, "fields" | "images"> {
129
+ fields: CatalogPushAssemblyField[];
130
+ images?: CatalogPushAssemblyImage[];
131
+ }
132
+ export interface BuildCatalogPushItemsResult {
133
+ items: CatalogPushAssemblyItem[];
134
+ skipped: CatalogPushFieldSkip[];
135
+ warnings: string[];
136
+ }
137
+ export interface PushCatalogToStoreResult extends ChannelPushCatalogResult {
138
+ skipped: CatalogPushFieldSkip[];
139
+ warnings: string[];
140
+ }
141
+ export interface CatalogPushPreviewUnavailable {
142
+ status: "unavailable";
143
+ }
144
+ export type CatalogPushPreviewBefore = unknown | CatalogPushPreviewUnavailable;
145
+ export type CatalogPushPreviewBeforeStatus = "value" | "missing" | "unavailable";
146
+ export interface CatalogPushPreviewDiff {
147
+ fieldPath: FieldPath;
148
+ target: CatalogFieldTarget | null;
149
+ remoteKey: string | null;
150
+ before: CatalogPushPreviewBefore;
151
+ beforeStatus: CatalogPushPreviewBeforeStatus;
152
+ after: unknown;
153
+ owner: FieldOwner;
154
+ willWrite: boolean;
155
+ reason?: CatalogPushSkipReason;
156
+ }
157
+ export interface CatalogPushPreviewItem {
158
+ externalId: string;
159
+ diffs: CatalogPushPreviewDiff[];
160
+ }
161
+ export interface CatalogPushPreviewResult {
162
+ items: CatalogPushPreviewItem[];
163
+ skipped: CatalogPushFieldSkip[];
164
+ warnings: string[];
165
+ }
50
166
  export declare function canExportTransition(from: ExportState, to: ExportState): boolean;
167
+ export declare function canCatalogPushTransition(from: CatalogPushState, to: CatalogPushState): boolean;
168
+ export declare const CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS: number;
51
169
  export declare class ChannelConnectorService {
52
170
  private readonly db;
53
171
  private readonly services;
@@ -58,9 +176,34 @@ export declare class ChannelConnectorService {
58
176
  constructor(db: PluginDb, services: Record<string, unknown>, options?: ChannelConnectorPluginOptions, transaction?: PluginTxFn);
59
177
  getConnector(providerId: string): ChannelConnector | undefined;
60
178
  private get catalog();
179
+ private get media();
180
+ private get pricing();
181
+ private filterOwnedFields;
182
+ private filterOwnedFieldsAtPaths;
183
+ private filterConflictingFields;
184
+ private remoteFieldValue;
185
+ private isOutboundEcho;
186
+ private localFieldValue;
187
+ private lastSyncedSnapshot;
188
+ private detectSharedConflicts;
189
+ private persistCatalogConflicts;
190
+ private setCatalogAttributes;
191
+ private setCatalogAttributesIfWritable;
192
+ private upsertOptionAxes;
193
+ private upsertVariants;
194
+ private applyTaxonomy;
195
+ private applyMedia;
61
196
  private getStoreRecord;
62
197
  getStoreByDomain(shopDomain: string): Promise<ConnectedStore | undefined>;
63
198
  getStoresByDomain(shopDomain: string): Promise<ConnectedStore[]>;
199
+ resolveCatalogFieldMapping(store: Pick<ConnectedStore, "provider" | "catalogFieldMapping">, filterableCustomFields?: ReadonlySet<string> | Readonly<Record<string, boolean>>, warnings?: string[]): CatalogFieldMapping;
200
+ buildCatalogPushItems(orgId: string, storeId: string, entityIds: string[], options?: BuildCatalogPushItemsOptions): Promise<PluginResult<BuildCatalogPushItemsResult>>;
201
+ recordOutboundPush(orgId: string, storeId: string, outcomes: ChannelPushCatalogItemOutcome[], items: ChannelPushCatalogItem[], phase?: "write-ahead" | "settle"): Promise<PluginResult<void>>;
202
+ pushCatalogToStore(orgId: string, storeId: string, entityIds: string[]): Promise<PluginResult<PushCatalogToStoreResult>>;
203
+ previewCatalogPush(orgId: string, storeId: string, entityIds?: string[]): Promise<PluginResult<CatalogPushPreviewResult>>;
204
+ getCatalogWriteSettings(orgId: string, storeId: string): Promise<PluginResult<CatalogWriteSettings>>;
205
+ updateCatalogWriteEnabled(orgId: string, storeId: string, enabled: boolean): Promise<PluginResult<CatalogWriteSettings>>;
206
+ updateCatalogFieldMapping(orgId: string, storeId: string, mapping: unknown): Promise<PluginResult<CatalogWriteSettings>>;
64
207
  connectStore(orgId: string, input: {
65
208
  provider: string;
66
209
  credentials: Record<string, unknown>;
@@ -76,7 +219,14 @@ export declare class ChannelConnectorService {
76
219
  importCatalog(orgId: string, storeId: string, actor: Actor): Promise<PluginResult<{
77
220
  imported: number;
78
221
  cursor: string | null;
222
+ skipped?: CatalogFieldSkip[];
223
+ conflicts?: CatalogFieldConflict[];
224
+ warnings?: string[];
79
225
  }>>;
226
+ private promoteLegacyAttributes;
227
+ private saveBackfillState;
228
+ backfillCatalog(orgId: string, storeId: string, actor: Actor, options?: BackfillCatalogOptions): Promise<PluginResult<BackfillCatalogReport>>;
229
+ private estimateCatalogItems;
80
230
  private convergeCatalogItems;
81
231
  reconcile(orgId: string, storeId: string, actor: Actor): Promise<PluginResult<ReconcileReport>>;
82
232
  getReconcileStatus(orgId: string, storeId: string): Promise<PluginResult<{
@@ -84,6 +234,9 @@ export declare class ChannelConnectorService {
84
234
  report: ReconcileReport | null;
85
235
  driftAlert: boolean;
86
236
  }>>;
237
+ listCatalogConflicts(orgId: string, storeId?: string, state?: ChannelCatalogConflict["state"]): Promise<PluginResult<ChannelCatalogConflict[]>>;
238
+ resolveCatalogConflict(orgId: string, id: string, choose: "platform" | "store", actor: Pick<Actor, "userId">): Promise<PluginResult<ChannelCatalogConflict>>;
239
+ private applyStoreConflictValue;
87
240
  syncInventory(orgId: string, storeId: string, actor: Actor): Promise<PluginResult<{
88
241
  synced: number;
89
242
  }>>;
@@ -129,4 +282,16 @@ export declare class ChannelConnectorService {
129
282
  listFailedExports(orgId: string): Promise<PluginResult<ChannelOrderExport[]>>;
130
283
  retryExport(orgId: string, exportId: string, changedBy: string): Promise<PluginResult<ChannelOrderExport>>;
131
284
  abandonExport(orgId: string, exportId: string, changedBy: string, reason?: string): Promise<PluginResult<ChannelOrderExport>>;
285
+ resolveCatalogPushEntityIds(orgId: string, storeId: string, entityIds?: string[]): Promise<string[]>;
286
+ createCatalogPush(orgId: string, storeId: string, entityId: string): Promise<PluginResult<ChannelCatalogPush>>;
287
+ transitionCatalogPush(orgId: string, pushId: string, toState: CatalogPushState, changedBy: string, reason?: string, failureKind?: "definitive" | "transient", payloadSnapshot?: ChannelPushCatalogItem | null): Promise<PluginResult<ChannelCatalogPush>>;
288
+ private recordCatalogPushRevisions;
289
+ executeCatalogPushJob(orgId: string, storeId: string, options: {
290
+ entityIds?: string[];
291
+ cursor?: string;
292
+ forceFieldPaths?: Record<string, FieldPath[]>;
293
+ }, actor: Actor, runtime: {
294
+ jobs: JobsAdapter;
295
+ }): Promise<PluginResult<CatalogPushJobResult>>;
132
296
  }
297
+ export {};