@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,15 +19,15 @@
19
19
  "dependencies": {
20
20
  "@hono/zod-openapi": "^1.2.2",
21
21
  "hono": "^4.12.5",
22
- "@porulle/core": "0.11.0"
22
+ "@porulle/core": "0.13.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^24.5.2",
26
26
  "eslint": "^9.39.1",
27
27
  "typescript": "5.9.2",
28
28
  "vitest": "^3.2.4",
29
- "@porulle/eslint-config": "0.1.0",
30
- "@porulle/typescript-config": "0.1.0"
29
+ "@porulle/typescript-config": "0.1.0",
30
+ "@porulle/eslint-config": "0.1.0"
31
31
  },
32
32
  "publishConfig": {
33
33
  "access": "public"
@@ -0,0 +1,99 @@
1
+ import { resolveOrgIdForCommerce, isValidFieldPath, type FieldOwner } from "@porulle/core";
2
+ import type { HookContext } from "@porulle/core";
3
+ import { and, eq } from "@porulle/core/drizzle";
4
+ import { catalogPushConcurrencyKey } from "./service.js";
5
+ import { channelEntityMap } from "./schema.js";
6
+
7
+ type CatalogUpdateInput = {
8
+ slug?: string;
9
+ status?: string;
10
+ metadata?: Record<string, unknown>;
11
+ customFields?: Record<string, unknown | null>;
12
+ };
13
+
14
+ export const CHANNEL_CONVERGENCE_ORIGIN = "channel-convergence";
15
+
16
+ export const CHANNEL_CONVERGENCE_CTX = {
17
+ hookContext: { origin: CHANNEL_CONVERGENCE_ORIGIN },
18
+ };
19
+
20
+ interface CatalogOwnershipService {
21
+ resolveFieldOwners(entityId: string, storeId: string): Promise<Map<string, FieldOwner>>;
22
+ }
23
+
24
+ function updateInputFieldPaths(input: CatalogUpdateInput): string[] {
25
+ const paths: string[] = [];
26
+ if (input.slug !== undefined) paths.push("entity.slug");
27
+ if (input.status !== undefined) paths.push("entity.status");
28
+ if (input.metadata) {
29
+ for (const key of Object.keys(input.metadata)) paths.push(`entity.metadata.${key}`);
30
+ }
31
+ if (input.customFields) {
32
+ for (const name of Object.keys(input.customFields)) paths.push(`customFields.${name}.en`);
33
+ }
34
+ return paths.filter(isValidFieldPath);
35
+ }
36
+
37
+ export async function maybeEnqueueCatalogPush(args: {
38
+ entityId: string;
39
+ changedFieldPaths: string[];
40
+ context: HookContext;
41
+ }): Promise<void> {
42
+ if (args.context.context.origin === CHANNEL_CONVERGENCE_ORIGIN) return;
43
+ if (args.changedFieldPaths.length === 0) return;
44
+
45
+ const orgId = resolveOrgIdForCommerce(args.context.actor, args.context.commerceConfig);
46
+ const catalog = args.context.services.catalog as CatalogOwnershipService;
47
+ const mappings = await args.context.db
48
+ .select({ storeId: channelEntityMap.storeId })
49
+ .from(channelEntityMap)
50
+ .where(and(
51
+ eq(channelEntityMap.organizationId, orgId),
52
+ eq(channelEntityMap.entityId, args.entityId),
53
+ eq(channelEntityMap.kind, "entity"),
54
+ ));
55
+
56
+ const forceFieldPathsByStore = new Map<string, string[]>();
57
+ for (const mapping of mappings) {
58
+ const owners = await catalog.resolveFieldOwners(args.entityId, mapping.storeId);
59
+ const changedPaths = args.changedFieldPaths.filter((path) => {
60
+ const owner = owners.get(path);
61
+ return owner === "platform" || owner === "shared";
62
+ });
63
+ if (changedPaths.length > 0) forceFieldPathsByStore.set(mapping.storeId, changedPaths);
64
+ }
65
+
66
+ await Promise.all([...forceFieldPathsByStore].map(([storeId, forceFieldPaths]) => args.context.jobs.enqueue(
67
+ "channel/push-catalog",
68
+ {
69
+ organizationId: orgId,
70
+ storeId,
71
+ entityIds: [args.entityId],
72
+ forceFieldPaths: { [args.entityId]: forceFieldPaths },
73
+ },
74
+ {
75
+ organizationId: orgId,
76
+ concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: [args.entityId] }),
77
+ supersedes: true,
78
+ },
79
+ )));
80
+ }
81
+
82
+ export function recordUpdateFieldPaths(input: CatalogUpdateInput, context: HookContext): CatalogUpdateInput {
83
+ context.context.changedFieldPaths = updateInputFieldPaths(input);
84
+ return input;
85
+ }
86
+
87
+ export async function handleCatalogAfterUpdate(args: {
88
+ result: { id: string };
89
+ context: HookContext;
90
+ }): Promise<void> {
91
+ const changedFieldPaths = Array.isArray(args.context.context.changedFieldPaths)
92
+ ? args.context.context.changedFieldPaths.map(String)
93
+ : [];
94
+ await maybeEnqueueCatalogPush({
95
+ entityId: args.result.id,
96
+ changedFieldPaths,
97
+ context: args.context,
98
+ });
99
+ }
package/src/hooks.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { resolveOrgId } from "@porulle/core";
2
- import type { PluginHookRegistration } from "@porulle/core";
1
+ import { resolveOrgIdForCommerce } from "@porulle/core";
2
+ import type { Actor, CommerceConfig, PluginHookRegistration } from "@porulle/core";
3
3
  import { and, eq, inArray } from "@porulle/core/drizzle";
4
4
  import { sellableEntities } from "@porulle/core/schema";
5
5
  import {
@@ -7,6 +7,10 @@ import {
7
7
  type ChannelConnectorPluginOptions,
8
8
  type ChannelStockLine,
9
9
  } from "./service.js";
10
+ import {
11
+ handleCatalogAfterUpdate,
12
+ recordUpdateFieldPaths,
13
+ } from "./catalog-push-trigger.js";
10
14
 
11
15
  export function buildHooks(options: ChannelConnectorPluginOptions): PluginHookRegistration[] {
12
16
  return [{
@@ -15,14 +19,16 @@ export function buildHooks(options: ChannelConnectorPluginOptions): PluginHookRe
15
19
  const { data, context } = args as {
16
20
  data: { lineItems: ChannelStockLine[] };
17
21
  context: {
18
- actor: Parameters<typeof resolveOrgId>[0];
22
+ actor: Actor | null;
23
+ commerceConfig?: CommerceConfig | null;
19
24
  db: ConstructorParameters<typeof ChannelConnectorService>[0];
20
25
  services: Record<string, unknown>;
21
26
  };
22
27
  };
28
+ if (data.lineItems.length === 0) return data;
23
29
  const service = new ChannelConnectorService(context.db, context.services, options);
24
30
  await service.validateLineStock(
25
- resolveOrgId(context.actor),
31
+ resolveOrgIdForCommerce(context.actor, context.commerceConfig),
26
32
  data.lineItems,
27
33
  options.inventoryTimeoutMs,
28
34
  );
@@ -33,12 +39,30 @@ export function buildHooks(options: ChannelConnectorPluginOptions): PluginHookRe
33
39
  async handler(args: unknown) {
34
40
  const { result, context } = args as {
35
41
  result: { id: string; lineItems?: Array<{ entityId: string }> };
36
- context: { actor: Parameters<typeof resolveOrgId>[0]; db: ConstructorParameters<typeof ChannelConnectorService>[0]; services: Record<string, unknown>; jobs: { enqueue(task: string, input: Record<string, unknown>, options: { organizationId: string; concurrencyKey: string; supersedes: boolean }): Promise<string> } };
42
+ context: { actor: Actor | null; commerceConfig?: CommerceConfig | null; db: ConstructorParameters<typeof ChannelConnectorService>[0]; services: Record<string, unknown>; jobs: { enqueue(task: string, input: Record<string, unknown>, options: { organizationId: string; concurrencyKey: string; supersedes: boolean }): Promise<string> } };
37
43
  };
38
- const orgId = resolveOrgId(context.actor);
44
+ const orgId = resolveOrgIdForCommerce(context.actor, context.commerceConfig);
39
45
  const entities = await context.db.select({ id: sellableEntities.id, sourceStoreId: sellableEntities.sourceStoreId }).from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.id, (result.lineItems ?? []).map((line) => line.entityId))));
40
46
  const stores = new Set(entities.map((entity) => entity.sourceStoreId).filter((storeId): storeId is string => storeId !== null));
41
47
  await Promise.all([...stores].map((storeId) => context.jobs.enqueue("channel/push-order", { orgId, storeId, orderId: result.id }, { organizationId: orgId, concurrencyKey: `push:${result.id}:${storeId}`, supersedes: true })));
42
48
  },
49
+ }, {
50
+ key: "catalog.beforeUpdate",
51
+ handler(args: unknown) {
52
+ const { data, context } = args as {
53
+ data: Parameters<typeof recordUpdateFieldPaths>[0];
54
+ context: Parameters<typeof recordUpdateFieldPaths>[1];
55
+ };
56
+ return recordUpdateFieldPaths(data, context);
57
+ },
58
+ }, {
59
+ key: "catalog.afterUpdate",
60
+ async handler(args: unknown) {
61
+ const { result, context } = args as {
62
+ result: { id: string };
63
+ context: Parameters<typeof handleCatalogAfterUpdate>[0]["context"];
64
+ };
65
+ await handleCatalogAfterUpdate({ result, context });
66
+ },
43
67
  }];
44
68
  }
package/src/index.ts CHANGED
@@ -7,12 +7,18 @@ import {
7
7
  defineCommercePlugin,
8
8
  router,
9
9
  createSystemActor,
10
+ isValidFieldPath,
11
+ requireUserId,
10
12
  } from "@porulle/core";
11
- import type { JobsAdapter, PluginResult, PluginRouteRegistration, TaskDefinition } from "@porulle/core";
13
+ import type { FieldPath, JobsAdapter, PluginResult, PluginRouteRegistration, TaskDefinition } from "@porulle/core";
12
14
  import { z } from "@hono/zod-openapi";
13
15
  import { and, eq } from "@porulle/core/drizzle";
14
16
  import { processedWebhookEvents } from "@porulle/core/schema";
15
17
  import {
18
+ channelCatalogPushEvents,
19
+ channelCatalogPushes,
20
+ channelCatalogConflicts,
21
+ channelCatalogConflictEvents,
16
22
  channelEntityMap,
17
23
  channelExportEvents,
18
24
  channelOrderExports,
@@ -22,6 +28,8 @@ import {
22
28
  } from "./schema.js";
23
29
  import {
24
30
  ChannelConnectorService,
31
+ catalogPushConcurrencyKey,
32
+ type CatalogConflictState,
25
33
  type ChannelComplianceData,
26
34
  type ChannelConnectorPluginOptions,
27
35
  } from "./service.js";
@@ -30,16 +38,24 @@ import { oauthStateEventId, signState, verifyState } from "./oauth-state.js";
30
38
 
31
39
  type ChannelRouteContext = {
32
40
  input: unknown;
41
+ query?: unknown;
33
42
  params: Record<string, string>;
34
43
  orgId: string;
35
- actor: { userId: string } | null;
44
+ actor: { userId: string | null } | null;
36
45
  };
37
46
 
38
47
  export { mockChannelConnector } from "./mock-connector.js";
39
48
  export type { MockChannelConnectorOptions } from "./mock-connector.js";
40
49
  export {
41
50
  ChannelConnectorService,
51
+ CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS,
52
+ CATALOG_PUSH_BATCH_SIZES,
53
+ CATALOG_PUSH_MAX_ATTEMPTS,
54
+ canCatalogPushTransition,
42
55
  canExportTransition,
56
+ catalogPushConcurrencyKey,
57
+ catalogPushRetryDelayMs,
58
+ isCatalogPushBreakerOpen,
43
59
  } from "./service.js";
44
60
  export {
45
61
  isValidCatalogMappingFieldPath,
@@ -63,10 +79,22 @@ export type {
63
79
  BackfillCatalogReport,
64
80
  BuildCatalogPushItemsOptions,
65
81
  BuildCatalogPushItemsResult,
82
+ CatalogPushAssemblyField,
83
+ CatalogPushAssemblyImage,
84
+ CatalogPushAssemblyItem,
85
+ CatalogPushPreviewBefore,
86
+ CatalogPushPreviewBeforeStatus,
87
+ CatalogPushPreviewDiff,
88
+ CatalogPushPreviewItem,
89
+ CatalogPushPreviewResult,
90
+ CatalogPushPreviewUnavailable,
91
+ PushCatalogToStoreResult,
92
+ CatalogPushJobResult,
66
93
  CatalogFieldConflict,
67
94
  CatalogFieldSkip,
68
95
  CatalogPushFieldSkip,
69
96
  CatalogPushSkipReason,
97
+ CatalogConflictState,
70
98
  CatalogWriteSettings,
71
99
  ChannelComplianceData,
72
100
  ChannelConnectorPluginOptions,
@@ -77,6 +105,10 @@ export type {
77
105
  } from "./service.js";
78
106
  export type { OAuthStatePayload, OAuthStateResult } from "./oauth-state.js";
79
107
  export type {
108
+ ChannelCatalogPush,
109
+ ChannelCatalogPushEvent,
110
+ ChannelCatalogConflict,
111
+ ChannelCatalogConflictEvent,
80
112
  ChannelEntityMapEntry,
81
113
  ChannelExportEvent,
82
114
  ChannelOrderExport,
@@ -228,6 +260,37 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
228
260
  return { output: { exportId: result.value.id, state: result.value.state } };
229
261
  },
230
262
  },
263
+ {
264
+ slug: "channel/push-catalog",
265
+ concurrency: { key: catalogPushConcurrencyKey, supersedes: true },
266
+ handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
267
+ const service = new ChannelConnectorService(ctx.db, ctx.services, options);
268
+ const orgId = String(input.organizationId ?? input.orgId);
269
+ const storeId = String(input.storeId);
270
+ const entityIds = Array.isArray(input.entityIds)
271
+ ? input.entityIds.map(String)
272
+ : undefined;
273
+ const forceFieldPaths = typeof input.forceFieldPaths === "object" && input.forceFieldPaths !== null
274
+ ? Object.fromEntries(Object.entries(input.forceFieldPaths).flatMap(([entityId, paths]) => [
275
+ [entityId, Array.isArray(paths) ? paths.filter((path): path is FieldPath => typeof path === "string" && isValidFieldPath(path)) : []],
276
+ ]))
277
+ : undefined;
278
+ const cursor = typeof input.cursor === "string" ? input.cursor : undefined;
279
+ const result = await service.executeCatalogPushJob(
280
+ orgId,
281
+ storeId,
282
+ {
283
+ ...(entityIds ? { entityIds } : {}),
284
+ ...(forceFieldPaths ? { forceFieldPaths } : {}),
285
+ ...(cursor ? { cursor } : {}),
286
+ },
287
+ createSystemActor(orgId),
288
+ { jobs: ctx.services.jobs as JobsAdapter },
289
+ );
290
+ if (!result.ok) throw new Error(result.error);
291
+ return { output: result.value };
292
+ },
293
+ },
231
294
  {
232
295
  slug: "channel/reap-exports",
233
296
  handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
@@ -250,6 +313,10 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
250
313
  schema: () => ({
251
314
  connectedStores,
252
315
  channelEntityMap,
316
+ channelCatalogPushes,
317
+ channelCatalogPushEvents,
318
+ channelCatalogConflicts,
319
+ channelCatalogConflictEvents,
253
320
  channelOrderExports,
254
321
  channelExportEvents,
255
322
  channelRefundRequests,
@@ -458,6 +525,25 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
458
525
  .permission("channels:read")
459
526
  .handler(async ({ params, orgId }: ChannelRouteContext) => unwrap(await service.getReconcileStatus(orgId, params.storeId!)));
460
527
 
528
+ channels.get("/conflicts")
529
+ .summary("List channel catalog conflicts")
530
+ .permission("channels:read")
531
+ .query(z.object({ storeId: z.string().min(1).optional(), state: z.enum(["open", "resolved"]).optional() }))
532
+ .handler(async ({ query, orgId }: ChannelRouteContext) => {
533
+ const values = query as { storeId?: string; state?: CatalogConflictState };
534
+ return unwrap(await service.listCatalogConflicts(orgId, values.storeId, values.state));
535
+ });
536
+
537
+ channels.post("/conflicts/{id}/resolve")
538
+ .summary("Resolve a channel catalog conflict")
539
+ .permission("channels:manage")
540
+ .params(z.object({ id: z.string().min(1) }))
541
+ .input(z.object({ choose: z.enum(["platform", "store"]) }))
542
+ .handler(async ({ params, orgId, input, actor }: ChannelRouteContext) => {
543
+ const values = input as { choose: "platform" | "store" };
544
+ return unwrap(await service.resolveCatalogConflict(orgId, params.id!, values.choose, actor!));
545
+ });
546
+
461
547
  channels.post("/stores/{storeId}/backfill")
462
548
  .summary("Backfill a channel catalog into the PIM")
463
549
  .permission("channels:manage")
@@ -480,6 +566,39 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
480
566
  return { enqueued: true, storeId: params.storeId! };
481
567
  });
482
568
 
569
+ channels.post("/stores/{storeId}/push-catalog")
570
+ .summary("Enqueue a catalog push for a connected store")
571
+ .permission("channels:manage")
572
+ .input(z.object({ entityIds: z.array(z.string()).optional() }))
573
+ .handler(async ({ params, orgId, input }: ChannelRouteContext) => {
574
+ unwrap(await service.getStore(orgId, params.storeId!));
575
+ const values = input as { entityIds?: string[] };
576
+ const jobs = ctx.services.jobs as JobsAdapter;
577
+ await jobs.enqueue("channel/push-catalog", {
578
+ organizationId: orgId,
579
+ storeId: params.storeId!,
580
+ ...(values.entityIds ? { entityIds: values.entityIds } : {}),
581
+ }, {
582
+ organizationId: orgId,
583
+ concurrencyKey: catalogPushConcurrencyKey({
584
+ storeId: params.storeId!,
585
+ ...(values.entityIds ? { entityIds: values.entityIds } : {}),
586
+ }),
587
+ supersedes: true,
588
+ });
589
+ return { enqueued: true, storeId: params.storeId! };
590
+ });
591
+
592
+ channels.post("/stores/{storeId}/push-catalog/preview")
593
+ .summary("Preview a catalog push for a connected store")
594
+ .permission("channels:manage")
595
+ .input(z.object({ entityIds: z.array(z.string()).optional() }))
596
+ .handler(async ({ params, orgId, input }: ChannelRouteContext) => {
597
+ unwrap(await service.getStore(orgId, params.storeId!));
598
+ const values = input as { entityIds?: string[] };
599
+ return unwrap(await service.previewCatalogPush(orgId, params.storeId!, values.entityIds));
600
+ });
601
+
483
602
  channels.post("/stores/{id}/disconnect")
484
603
  .summary("Disconnect a channel store")
485
604
  .permission("channels:manage")
@@ -498,12 +617,12 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
498
617
  channels.post("/refund-requests/{id}/approve")
499
618
  .summary("Approve a channel refund request")
500
619
  .permission("channels:manage")
501
- .handler(async ({ params, orgId, actor }: ChannelRouteContext) => unwrap(await service.approveRefund(orgId, params.id!, actor!)));
620
+ .handler(async ({ params, orgId, actor }: ChannelRouteContext) => unwrap(await service.approveRefund(orgId, params.id!, { userId: requireUserId(actor) })));
502
621
 
503
622
  channels.post("/refund-requests/{id}/reject")
504
623
  .summary("Reject a channel refund request")
505
624
  .permission("channels:manage")
506
- .handler(async ({ params, orgId, actor }: ChannelRouteContext) => unwrap(await service.rejectRefund(orgId, params.id!, actor!)));
625
+ .handler(async ({ params, orgId, actor }: ChannelRouteContext) => unwrap(await service.rejectRefund(orgId, params.id!, { userId: requireUserId(actor) })));
507
626
 
508
627
  channels.post("/exports/{id}/retry")
509
628
  .summary("Retry a failed channel order export")
@@ -511,7 +630,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
511
630
  .handler(async ({ params, orgId, actor }: ChannelRouteContext) => unwrap(await service.retryExport(
512
631
  orgId,
513
632
  params.id!,
514
- actor!.userId,
633
+ requireUserId(actor),
515
634
  )));
516
635
 
517
636
  return channels.routes() as PluginRouteRegistration[];
package/src/schema.ts CHANGED
@@ -8,8 +8,9 @@ import {
8
8
  timestamp,
9
9
  uniqueIndex,
10
10
  uuid,
11
+ sql,
11
12
  } from "@porulle/core/drizzle";
12
- import type { FieldPath } from "@porulle/core";
13
+ import type { ChannelPushCatalogItem, FieldPath } from "@porulle/core";
13
14
  import type { CatalogFieldMapping } from "./catalog-field-mapping.js";
14
15
 
15
16
  export const connectedStores = pgTable(
@@ -53,7 +54,11 @@ export const channelEntityMap = pgTable(
53
54
  variantId: uuid("variant_id"),
54
55
  lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }).defaultNow().notNull(),
55
56
  syncHash: text("sync_hash").notNull(),
57
+ outboundHash: text("outbound_hash"),
58
+ outboundPushedAt: timestamp("outbound_pushed_at", { withTimezone: true }),
59
+ outboundFieldPaths: jsonb("outbound_field_paths").$type<FieldPath[]>().notNull().default([]),
56
60
  heldFieldPaths: jsonb("held_field_paths").$type<FieldPath[]>().notNull().default([]),
61
+ forcedPushFieldPaths: jsonb("forced_push_field_paths").$type<FieldPath[]>().notNull().default([]),
57
62
  },
58
63
  (table) => ({
59
64
  orgIdx: index("idx_channel_entity_map_org").on(table.organizationId),
@@ -66,6 +71,48 @@ export const channelEntityMap = pgTable(
66
71
  }),
67
72
  );
68
73
 
74
+ export const channelCatalogConflicts = pgTable(
75
+ "channel_catalog_conflicts",
76
+ {
77
+ id: uuid("id").defaultRandom().primaryKey(),
78
+ organizationId: text("organization_id").notNull(),
79
+ storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
80
+ entityId: uuid("entity_id").notNull(),
81
+ fieldPath: text("field_path").notNull(),
82
+ platformValue: jsonb("platform_value").$type<unknown>().notNull(),
83
+ storeValue: jsonb("store_value").$type<unknown>().notNull(),
84
+ state: text("state", { enum: ["open", "resolved"] }).notNull().default("open"),
85
+ resolvedBy: text("resolved_by"),
86
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
87
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
88
+ },
89
+ (table) => ({
90
+ orgIdx: index("idx_channel_catalog_conflicts_org").on(table.organizationId),
91
+ stateIdx: index("idx_channel_catalog_conflicts_org_state").on(table.organizationId, table.state),
92
+ openUnique: uniqueIndex("channel_catalog_conflicts_open_unique")
93
+ .on(table.storeId, table.entityId, table.fieldPath)
94
+ .where(sql`${table.state} = 'open'`),
95
+ }),
96
+ );
97
+
98
+ export const channelCatalogConflictEvents = pgTable(
99
+ "channel_catalog_conflict_events",
100
+ {
101
+ id: uuid("id").defaultRandom().primaryKey(),
102
+ organizationId: text("organization_id").notNull(),
103
+ conflictId: uuid("conflict_id").references(() => channelCatalogConflicts.id, { onDelete: "cascade" }).notNull(),
104
+ fromState: text("from_state"),
105
+ toState: text("to_state").notNull(),
106
+ reason: text("reason"),
107
+ changedBy: text("changed_by").notNull(),
108
+ changedAt: timestamp("changed_at", { withTimezone: true }).defaultNow().notNull(),
109
+ },
110
+ (table) => ({
111
+ orgIdx: index("idx_channel_catalog_conflict_events_org").on(table.organizationId),
112
+ conflictIdx: index("idx_channel_catalog_conflict_events_conflict").on(table.conflictId),
113
+ }),
114
+ );
115
+
69
116
  export const channelOrderExports = pgTable(
70
117
  "channel_order_exports",
71
118
  {
@@ -97,6 +144,55 @@ export const channelOrderExports = pgTable(
97
144
  }),
98
145
  );
99
146
 
147
+ export const channelCatalogPushes = pgTable(
148
+ "channel_catalog_pushes",
149
+ {
150
+ id: uuid("id").defaultRandom().primaryKey(),
151
+ organizationId: text("organization_id").notNull(),
152
+ storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
153
+ entityId: uuid("entity_id").notNull(),
154
+ payloadSnapshot: jsonb("payload_snapshot").$type<ChannelPushCatalogItem | null>(),
155
+ state: text("state", { enum: ["pending", "exported", "confirmed", "failed", "abandoned"] })
156
+ .notNull()
157
+ .default("pending"),
158
+ failureKind: text("failure_kind", { enum: ["definitive", "transient"] }),
159
+ attempts: integer("attempts").notNull().default(0),
160
+ lastError: text("last_error"),
161
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
162
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
163
+ },
164
+ (table) => ({
165
+ orgIdx: index("idx_channel_catalog_pushes_org").on(table.organizationId),
166
+ storeIdx: index("idx_channel_catalog_pushes_store").on(table.storeId),
167
+ stateIdx: index("idx_channel_catalog_pushes_state").on(table.organizationId, table.state),
168
+ entityIdx: index("idx_channel_catalog_pushes_entity").on(table.organizationId, table.entityId),
169
+ storeEntityUnique: uniqueIndex("channel_catalog_pushes_store_entity_unique").on(
170
+ table.storeId,
171
+ table.entityId,
172
+ ),
173
+ }),
174
+ );
175
+
176
+ export const channelCatalogPushEvents = pgTable(
177
+ "channel_catalog_push_events",
178
+ {
179
+ id: uuid("id").defaultRandom().primaryKey(),
180
+ organizationId: text("organization_id").notNull(),
181
+ pushId: uuid("push_id")
182
+ .references(() => channelCatalogPushes.id, { onDelete: "cascade" })
183
+ .notNull(),
184
+ fromState: text("from_state").notNull(),
185
+ toState: text("to_state").notNull(),
186
+ reason: text("reason"),
187
+ changedBy: text("changed_by").notNull(),
188
+ changedAt: timestamp("changed_at", { withTimezone: true }).defaultNow().notNull(),
189
+ },
190
+ (table) => ({
191
+ orgIdx: index("idx_channel_catalog_push_events_org").on(table.organizationId),
192
+ pushIdx: index("idx_channel_catalog_push_events_push").on(table.pushId),
193
+ }),
194
+ );
195
+
100
196
  export const channelExportEvents = pgTable(
101
197
  "channel_export_events",
102
198
  {
@@ -158,6 +254,10 @@ export const channelRefundEvents = pgTable(
158
254
 
159
255
  export type ConnectedStore = typeof connectedStores.$inferSelect;
160
256
  export type ChannelEntityMapEntry = typeof channelEntityMap.$inferSelect;
257
+ export type ChannelCatalogConflict = typeof channelCatalogConflicts.$inferSelect;
258
+ export type ChannelCatalogConflictEvent = typeof channelCatalogConflictEvents.$inferSelect;
259
+ export type ChannelCatalogPush = typeof channelCatalogPushes.$inferSelect;
260
+ export type ChannelCatalogPushEvent = typeof channelCatalogPushEvents.$inferSelect;
161
261
  export type ChannelOrderExport = typeof channelOrderExports.$inferSelect;
162
262
  export type ChannelExportEvent = typeof channelExportEvents.$inferSelect;
163
263
  export type ChannelRefundRequest = typeof channelRefundRequests.$inferSelect;