@porulle/plugin-channel-connector 0.11.0 → 0.14.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 { boolean, 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(),
@@ -33,12 +33,48 @@ export const channelEntityMap = pgTable("channel_entity_map", {
33
33
  variantId: uuid("variant_id"),
34
34
  lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }).defaultNow().notNull(),
35
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([]),
36
39
  heldFieldPaths: jsonb("held_field_paths").$type().notNull().default([]),
40
+ forcedPushFieldPaths: jsonb("forced_push_field_paths").$type().notNull().default([]),
37
41
  }, (table) => ({
38
42
  orgIdx: index("idx_channel_entity_map_org").on(table.organizationId),
39
43
  storeIdx: index("idx_channel_entity_map_store").on(table.storeId),
40
44
  externalUnique: uniqueIndex("channel_entity_map_store_kind_external_unique").on(table.storeId, table.kind, table.externalId),
41
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
+ }));
42
78
  export const channelOrderExports = pgTable("channel_order_exports", {
43
79
  id: uuid("id").defaultRandom().primaryKey(),
44
80
  organizationId: text("organization_id").notNull(),
@@ -61,6 +97,42 @@ export const channelOrderExports = pgTable("channel_order_exports", {
61
97
  stateIdx: index("idx_channel_order_exports_state").on(table.organizationId, table.state),
62
98
  orderIdx: index("idx_channel_order_exports_order").on(table.organizationId, table.orderId),
63
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
+ }));
64
136
  export const channelExportEvents = pgTable("channel_export_events", {
65
137
  id: uuid("id").defaultRandom().primaryKey(),
66
138
  organizationId: text("organization_id").notNull(),
package/dist/service.d.ts CHANGED
@@ -1,14 +1,30 @@
1
- import type { Actor, ChannelConnector, ChannelOrderSlice, ChannelPushCatalogItem, PluginDb, PluginResult, PluginTxFn } from "@porulle/core";
2
- import type { FieldPath } 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";
3
3
  import type { JobsAdapter } from "@porulle/core";
4
- import { type ChannelOrderExport, type ChannelRefundRequest, type ConnectedStore } from "./schema.js";
5
- import { type CatalogFieldMapping } from "./catalog-field-mapping.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";
6
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;
7
22
  export interface ReconcileReport extends Record<string, unknown> {
8
23
  imported: number;
9
24
  converged: number;
10
25
  archived: number;
11
26
  inventoryUpdated: number;
27
+ openConflicts: number;
12
28
  driftAlert: boolean;
13
29
  skipped?: CatalogFieldSkip[];
14
30
  conflicts?: CatalogFieldConflict[];
@@ -25,11 +41,15 @@ export interface CatalogFieldSkip {
25
41
  entityId: string;
26
42
  fieldPath: FieldPath;
27
43
  }
28
- export type CatalogPushSkipReason = "no_mapping" | "held" | "entity_not_active" | "unmapped_entity";
44
+ export type CatalogPushSkipReason = "no_mapping" | "held" | "store_owned" | "entity_not_active" | "unmapped_entity";
29
45
  export interface CatalogPushFieldSkip {
30
46
  entityId: string;
31
47
  fieldPath: FieldPath;
32
48
  reason: CatalogPushSkipReason;
49
+ value?: unknown;
50
+ owner?: FieldOwner;
51
+ target?: CatalogFieldTarget;
52
+ remoteKey?: string;
33
53
  }
34
54
  export type PublicConnectedStore = Omit<ConnectedStore, "credentials" | "webhookSecret"> & {
35
55
  credentials: "[REDACTED]";
@@ -95,13 +115,57 @@ export interface BackfillCatalogOptions {
95
115
  }
96
116
  export interface BuildCatalogPushItemsOptions {
97
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[];
98
131
  }
99
132
  export interface BuildCatalogPushItemsResult {
100
- items: ChannelPushCatalogItem[];
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[];
101
163
  skipped: CatalogPushFieldSkip[];
102
164
  warnings: string[];
103
165
  }
104
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;
105
169
  export declare class ChannelConnectorService {
106
170
  private readonly db;
107
171
  private readonly services;
@@ -118,9 +182,13 @@ export declare class ChannelConnectorService {
118
182
  private filterOwnedFieldsAtPaths;
119
183
  private filterConflictingFields;
120
184
  private remoteFieldValue;
185
+ private isOutboundEcho;
121
186
  private localFieldValue;
187
+ private lastSyncedSnapshot;
122
188
  private detectSharedConflicts;
189
+ private persistCatalogConflicts;
123
190
  private setCatalogAttributes;
191
+ private setCatalogAttributesIfWritable;
124
192
  private upsertOptionAxes;
125
193
  private upsertVariants;
126
194
  private applyTaxonomy;
@@ -130,6 +198,9 @@ export declare class ChannelConnectorService {
130
198
  getStoresByDomain(shopDomain: string): Promise<ConnectedStore[]>;
131
199
  resolveCatalogFieldMapping(store: Pick<ConnectedStore, "provider" | "catalogFieldMapping">, filterableCustomFields?: ReadonlySet<string> | Readonly<Record<string, boolean>>, warnings?: string[]): CatalogFieldMapping;
132
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>>;
133
204
  getCatalogWriteSettings(orgId: string, storeId: string): Promise<PluginResult<CatalogWriteSettings>>;
134
205
  updateCatalogWriteEnabled(orgId: string, storeId: string, enabled: boolean): Promise<PluginResult<CatalogWriteSettings>>;
135
206
  updateCatalogFieldMapping(orgId: string, storeId: string, mapping: unknown): Promise<PluginResult<CatalogWriteSettings>>;
@@ -163,6 +234,9 @@ export declare class ChannelConnectorService {
163
234
  report: ReconcileReport | null;
164
235
  driftAlert: boolean;
165
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;
166
240
  syncInventory(orgId: string, storeId: string, actor: Actor): Promise<PluginResult<{
167
241
  synced: number;
168
242
  }>>;
@@ -208,5 +282,16 @@ export declare class ChannelConnectorService {
208
282
  listFailedExports(orgId: string): Promise<PluginResult<ChannelOrderExport[]>>;
209
283
  retryExport(orgId: string, exportId: string, changedBy: string): Promise<PluginResult<ChannelOrderExport>>;
210
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>>;
211
296
  }
212
297
  export {};