@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.10.8",
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.10.8"
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,211 @@
1
+ import type { FieldPath } from "@porulle/core";
2
+
3
+ export const catalogFieldTargets = ["native", "attribute", "meta"] as const;
4
+ export type CatalogFieldTarget = (typeof catalogFieldTargets)[number];
5
+
6
+ export interface CatalogFieldMappingRow {
7
+ fieldPath: FieldPath;
8
+ provider: string;
9
+ target: CatalogFieldTarget;
10
+ remoteKey: string;
11
+ }
12
+
13
+ export type CatalogFieldMapping = CatalogFieldMappingRow[];
14
+
15
+ export type CatalogFieldMappingInput =
16
+ | Array<{ fieldPath: string; provider?: string; target: CatalogFieldTarget; remoteKey: string }>
17
+ | Record<string, { provider?: string; target: CatalogFieldTarget; remoteKey: string }>;
18
+
19
+ const fieldSegment = /^[A-Za-z0-9_-]+$/;
20
+ const forbiddenFieldPaths = ["variants.sku", "variants.barcode", "options", "prices", "entity.status"];
21
+
22
+ export const providerCatalogFieldMappingDefaults: Record<string, CatalogFieldMapping> = {
23
+ shopify: [
24
+ { fieldPath: "attributes.*.title", provider: "shopify", target: "native", remoteKey: "title" },
25
+ { fieldPath: "attributes.*.description", provider: "shopify", target: "native", remoteKey: "body_html" },
26
+ { fieldPath: "attributes.*.seoTitle", provider: "shopify", target: "meta", remoteKey: "seo_title" },
27
+ { fieldPath: "attributes.*.seoDescription", provider: "shopify", target: "meta", remoteKey: "seo_description" },
28
+ { fieldPath: "customFields.*.*", provider: "shopify", target: "meta", remoteKey: "metafields" },
29
+ { fieldPath: "media.*", provider: "shopify", target: "native", remoteKey: "images" },
30
+ { fieldPath: "entity.metadata.*", provider: "shopify", target: "meta", remoteKey: "metafields" },
31
+ ],
32
+ woocommerce: [
33
+ { fieldPath: "attributes.*.title", provider: "woocommerce", target: "native", remoteKey: "name" },
34
+ { fieldPath: "attributes.*.description", provider: "woocommerce", target: "native", remoteKey: "description" },
35
+ { fieldPath: "attributes.*.seoTitle", provider: "woocommerce", target: "meta", remoteKey: "yoast_wpseo_title" },
36
+ { fieldPath: "attributes.*.seoDescription", provider: "woocommerce", target: "meta", remoteKey: "yoast_wpseo_metadesc" },
37
+ { fieldPath: "customFields.*.*", provider: "woocommerce", target: "meta", remoteKey: "porulle_meta_data" },
38
+ { fieldPath: "media.*", provider: "woocommerce", target: "native", remoteKey: "images" },
39
+ { fieldPath: "entity.metadata.*", provider: "woocommerce", target: "meta", remoteKey: "porulle_meta_data" },
40
+ ],
41
+ };
42
+
43
+ export function matchFieldPath(pattern: string, path: string): boolean {
44
+ const patternSegments = pattern.split(".");
45
+ const pathSegments = path.split(".");
46
+ return patternSegments.length === pathSegments.length
47
+ && patternSegments.every((segment, index) => pathSegments[index] !== "" && (segment === "*" || segment === pathSegments[index]));
48
+ }
49
+
50
+ export function isValidCatalogMappingFieldPath(value: string): boolean {
51
+ const segments = value.split(".");
52
+ return segments.length > 0 && segments.every((segment) => segment === "*" || fieldSegment.test(segment));
53
+ }
54
+
55
+ function couldCoverForbiddenSubtree(fieldPath: string, root: string): boolean {
56
+ const fieldSegments = fieldPath.split(".");
57
+ const rootSegments = root.split(".");
58
+ return fieldSegments.length >= rootSegments.length
59
+ && rootSegments.every((segment, index) => fieldSegments[index] === "*" || fieldSegments[index] === segment);
60
+ }
61
+
62
+ function isForbiddenMappingFieldPath(fieldPath: string): boolean {
63
+ return forbiddenFieldPaths.some((root) => couldCoverForbiddenSubtree(fieldPath, root));
64
+ }
65
+
66
+ export function validateCatalogMappingRow(
67
+ input: Partial<CatalogFieldMappingRow> & { fieldPath: string; target: CatalogFieldTarget; remoteKey: string },
68
+ provider: string,
69
+ ): CatalogFieldMappingRow {
70
+ if (!isValidCatalogMappingFieldPath(input.fieldPath)) {
71
+ throw new Error("Catalog mapping field paths must contain dot-separated alphanumeric, underscore, hyphen, or wildcard segments.");
72
+ }
73
+ if (isForbiddenMappingFieldPath(input.fieldPath)) {
74
+ throw new Error(`Catalog mapping cannot write the forbidden field path "${input.fieldPath}".`);
75
+ }
76
+ if (!catalogFieldTargets.includes(input.target)) {
77
+ throw new Error(`Catalog mapping target "${input.target}" is invalid.`);
78
+ }
79
+ const remoteKey = input.remoteKey.trim();
80
+ if (remoteKey.length === 0) {
81
+ throw new Error("Catalog mapping remoteKey must not be empty.");
82
+ }
83
+ const rowProvider = input.provider ?? provider;
84
+ if (rowProvider !== provider) {
85
+ throw new Error(`Catalog mapping provider must be "${provider}" for this store.`);
86
+ }
87
+ if (rowProvider === "woocommerce" && input.target === "meta" && remoteKey.startsWith("_")) {
88
+ throw new Error("WooCommerce meta keys must not start with an underscore.");
89
+ }
90
+ return {
91
+ fieldPath: input.fieldPath,
92
+ provider: rowProvider,
93
+ target: input.target,
94
+ remoteKey,
95
+ };
96
+ }
97
+
98
+ function mappingEntries(input: unknown): unknown[] {
99
+ if (Array.isArray(input)) return input;
100
+ if (!input || typeof input !== "object") throw new Error("Catalog mapping must be an array or an object.");
101
+ return Object.entries(input).map(([fieldPath, value]) => {
102
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Catalog mapping values must be objects.");
103
+ return { ...(value as Record<string, unknown>), fieldPath };
104
+ });
105
+ }
106
+
107
+ function normalizeCatalogMappingRow(row: unknown, provider: string): CatalogFieldMappingRow {
108
+ if (!row || typeof row !== "object" || Array.isArray(row)) throw new Error("Catalog mapping rows must be objects.");
109
+ const value = row as Record<string, unknown>;
110
+ if (typeof value.fieldPath !== "string" || typeof value.target !== "string" || typeof value.remoteKey !== "string") {
111
+ throw new Error("Catalog mapping rows require fieldPath, target, and remoteKey.");
112
+ }
113
+ return validateCatalogMappingRow(
114
+ {
115
+ fieldPath: value.fieldPath,
116
+ ...(typeof value.provider === "string" ? { provider: value.provider } : {}),
117
+ target: value.target as CatalogFieldTarget,
118
+ remoteKey: value.remoteKey,
119
+ },
120
+ provider,
121
+ );
122
+ }
123
+
124
+ export function normalizeCatalogFieldMapping(input: unknown, provider: string): CatalogFieldMapping {
125
+ return mappingEntries(input).map((row) => normalizeCatalogMappingRow(row, provider));
126
+ }
127
+
128
+ function normalizeStoredCatalogFieldMapping(input: unknown, provider: string, warnings: string[]): CatalogFieldMapping {
129
+ let entries: unknown[];
130
+ try {
131
+ entries = mappingEntries(input ?? []);
132
+ } catch (error) {
133
+ warnings.push(error instanceof Error ? error.message : "Stored catalog mapping is invalid.");
134
+ return [];
135
+ }
136
+ return entries.flatMap((row, index) => {
137
+ try {
138
+ return [normalizeCatalogMappingRow(row, provider)];
139
+ } catch (error) {
140
+ const message = error instanceof Error ? error.message : "Stored catalog mapping row is invalid.";
141
+ warnings.push(`Skipped catalog mapping row ${index}: ${message}`);
142
+ return [];
143
+ }
144
+ });
145
+ }
146
+
147
+ function filterableEntries(hint: ReadonlySet<string> | Readonly<Record<string, boolean>>): Array<[string, boolean]> {
148
+ if (hint instanceof Set) return [...hint].map((path) => [path, true]);
149
+ return Object.entries(hint);
150
+ }
151
+
152
+ export function mergeCatalogFieldMapping(
153
+ provider: string,
154
+ overrides: unknown,
155
+ filterableCustomFields?: ReadonlySet<string> | Readonly<Record<string, boolean>>,
156
+ warnings: string[] = [],
157
+ ): CatalogFieldMapping {
158
+ const defaults = (providerCatalogFieldMappingDefaults[provider] ?? []).map((row) => ({ ...row }));
159
+ const rows = normalizeStoredCatalogFieldMapping(overrides, provider, warnings);
160
+ const merged = [...defaults];
161
+ for (const row of rows) {
162
+ const index = merged.findIndex((defaultRow) => defaultRow.fieldPath === row.fieldPath && defaultRow.provider === row.provider);
163
+ if (index === -1) merged.push(row);
164
+ else merged[index] = row;
165
+ }
166
+ if (!filterableCustomFields) return merged;
167
+ const hints = filterableEntries(filterableCustomFields).filter(([path]) => isValidCatalogMappingFieldPath(path));
168
+ if (hints.length === 0) return merged;
169
+ const customFieldPattern = "customFields.*.*";
170
+ const customFieldDefault = merged.find((row) => row.fieldPath === customFieldPattern);
171
+ if (!customFieldDefault) return merged;
172
+ const expanded = hints
173
+ .filter(([path]) => matchFieldPath(customFieldPattern, path))
174
+ .map(([fieldPath, filterable]) => ({
175
+ ...customFieldDefault,
176
+ fieldPath,
177
+ target: filterable ? "attribute" as const : "meta" as const,
178
+ remoteKey: fieldPath.split(".")[1] ?? customFieldDefault.remoteKey,
179
+ }));
180
+ return [...merged.filter((row) => row.fieldPath !== customFieldPattern), ...expanded];
181
+ }
182
+
183
+ function wildcardCount(fieldPath: string): number {
184
+ return fieldPath.split(".").filter((segment) => segment === "*").length;
185
+ }
186
+
187
+ function compareStrings(left: string, right: string): number {
188
+ if (left < right) return -1;
189
+ if (left > right) return 1;
190
+ return 0;
191
+ }
192
+
193
+ export function compareCatalogFieldMappingSpecificity(
194
+ left: CatalogFieldMappingRow,
195
+ right: CatalogFieldMappingRow,
196
+ ): number {
197
+ const wildcardDifference = wildcardCount(left.fieldPath) - wildcardCount(right.fieldPath);
198
+ if (wildcardDifference !== 0) return wildcardDifference;
199
+ const fieldPathDifference = compareStrings(left.fieldPath, right.fieldPath);
200
+ if (fieldPathDifference !== 0) return fieldPathDifference;
201
+ const providerDifference = compareStrings(left.provider, right.provider);
202
+ if (providerDifference !== 0) return providerDifference;
203
+ return compareStrings(left.remoteKey, right.remoteKey);
204
+ }
205
+
206
+ export function selectCatalogFieldMapping(
207
+ mapping: CatalogFieldMapping,
208
+ path: string,
209
+ ): CatalogFieldMappingRow | undefined {
210
+ return mapping.filter((row) => matchFieldPath(row.fieldPath, path)).sort(compareCatalogFieldMappingSpecificity)[0];
211
+ }
@@ -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
  }