@porulle/plugin-channel-connector 0.56.0 → 0.57.1

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/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type ChannelConnectorPluginOptions } from "./service.js";
2
2
  export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
3
+ export { channelSyncHash } from "./service.js";
3
4
  export type { AbsentArchivePlan } from "./deletion-policy.js";
4
5
  export { mockChannelConnector } from "./mock-connector.js";
5
6
  export type { MockChannelConnectorOptions } from "./mock-connector.js";
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { ChannelConnectorService, catalogPushConcurrencyKey, CHANNEL_INVENTORY_M
8
8
  import { buildHooks } from "./hooks.js";
9
9
  import { oauthStateEventId, signState, verifyState } from "./oauth-state.js";
10
10
  export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
11
+ export { channelSyncHash } from "./service.js";
11
12
  export { mockChannelConnector } from "./mock-connector.js";
12
13
  export { ChannelConnectorService, HERO_IMAGE_BYTE_CAP, selectImportImages, CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS, CATALOG_PUSH_BATCH_SIZES, CATALOG_PUSH_MAX_ATTEMPTS, canCatalogPushTransition, canExportTransition, catalogPushConcurrencyKey, catalogPushRetryDelayMs, CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION, isCatalogPushBreakerOpen, } from "./service.js";
13
14
  export { isValidCatalogMappingFieldPath, matchFieldPath, mergeCatalogFieldMapping, normalizeCatalogFieldMapping, compareCatalogFieldMappingSpecificity, providerCatalogFieldMappingDefaults, selectCatalogFieldMapping, validateCatalogMappingRow, } from "./catalog-field-mapping.js";
package/dist/service.d.ts CHANGED
@@ -236,6 +236,8 @@ export interface CatalogPushPreviewResult {
236
236
  }
237
237
  export declare function canExportTransition(from: ExportState, to: ExportState): boolean;
238
238
  export declare function canCatalogPushTransition(from: CatalogPushState, to: CatalogPushState): boolean;
239
+ /** THE sync hash: every path that records or compares a `channel_entity_map.sync_hash` uses this. */
240
+ export declare function channelSyncHash(value: unknown): string;
239
241
  /**
240
242
  * The suffix a store's product takes when its handle is already another store's slug: the first
241
243
  * label of the store domain (`kelly-felder.myshopify.com` → `kelly-felder`), slugified.
package/dist/service.js CHANGED
@@ -123,9 +123,30 @@ const catalogPushTransitions = {
123
123
  export function canCatalogPushTransition(from, to) {
124
124
  return catalogPushTransitions[from].includes(to);
125
125
  }
126
- function hash(value) {
127
- return createHash("sha256").update(JSON.stringify(value)).digest("hex");
126
+ /**
127
+ * JSON with every object's keys sorted, recursively — the one form every hash here is taken over.
128
+ *
129
+ * The sync hash was `sha256(JSON.stringify(item))`, and JSON.stringify writes keys in insertion
130
+ * order. The host's page consumer rebuilds each landed item in its own key order while reconcile
131
+ * hashes the adapter's objects in theirs, so the fast path and reconcile hashed the SAME content
132
+ * differently and reconcile re-converged every freshly imported product. Content, not key order,
133
+ * is what a hash here must answer. `undefined` values are dropped exactly as JSON.stringify drops
134
+ * them; array order is kept (it carries meaning: images, variants, options).
135
+ */
136
+ function canonicalJson(value) {
137
+ if (Array.isArray(value))
138
+ return `[${value.map((entry) => (entry === undefined ? "null" : canonicalJson(entry))).join(",")}]`;
139
+ if (value !== null && typeof value === "object" && !(value instanceof Date)) {
140
+ const entries = Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
141
+ return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
142
+ }
143
+ return JSON.stringify(value) ?? "null";
144
+ }
145
+ /** THE sync hash: every path that records or compares a `channel_entity_map.sync_hash` uses this. */
146
+ export function channelSyncHash(value) {
147
+ return createHash("sha256").update(canonicalJson(value)).digest("hex");
128
148
  }
149
+ const hash = channelSyncHash;
129
150
  /**
130
151
  * The suffix a store's product takes when its handle is already another store's slug: the first
131
152
  * label of the store domain (`kelly-felder.myshopify.com` → `kelly-felder`), slugified.
@@ -1446,7 +1467,13 @@ export class ChannelConnectorService {
1446
1467
  }
1447
1468
  async applyMedia(orgId, entityId, item, variantIds, actor, warnings, owners) {
1448
1469
  // Uploads what is missing and PLANS the entity's media links; `commitEntityLinks` writes them.
1449
- const images = item.images ?? [];
1470
+ //
1471
+ // Only the images the import ruling allows — the SAME `selectImportImages` the page fast path
1472
+ // uses: the hero plus the first photo of each other variant. This path attached EVERY image
1473
+ // the item listed, so the first converge of a product whose price changed pulled in the whole
1474
+ // gallery the fast path had deliberately left out: an upload, an embed and a bump per photo.
1475
+ const selection = selectImportImages(item);
1476
+ const images = selection.hero === null ? [] : [selection.hero, ...selection.perVariant];
1450
1477
  const externalIds = [...new Set(images.map((image) => image.externalId).filter((id) => id != null))];
1451
1478
  const urlHashes = [...new Set(images.map((image) => hash(image.url)))];
1452
1479
  const keyPredicates = [];
@@ -2923,7 +2950,9 @@ export class ChannelConnectorService {
2923
2950
  * products remain.
2924
2951
  */
2925
2952
  async claimUnrecordedLinks(orgId, storeId, items) {
2926
- if (items.length === 0)
2953
+ // Nothing listed, nothing to claim: no query. Otherwise one select per converge call (a batch),
2954
+ // never one per product.
2955
+ if (!items.some((item) => (item.tags?.length ?? 0) > 0 || (item.categories?.length ?? 0) > 0 || item.brand !== undefined))
2927
2956
  return;
2928
2957
  const unclaimed = await this.db.select({ externalId: channelEntityMap.externalId, entityId: channelEntityMap.entityId }).from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), inArray(channelEntityMap.externalId, items.map((item) => item.externalId)), sql `not exists (select 1 from ${channelEntityLinks} where ${channelEntityLinks.storeId} = ${channelEntityMap.storeId} and ${channelEntityLinks.entityId} = ${channelEntityMap.entityId})`));
2929
2958
  if (unclaimed.length === 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.56.0",
3
+ "version": "0.57.1",
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.56.0"
22
+ "@porulle/core": "0.57.1"
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"
package/src/index.ts CHANGED
@@ -55,6 +55,7 @@ type ChannelRouteContext = {
55
55
  };
56
56
 
57
57
  export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
58
+ export { channelSyncHash } from "./service.js";
58
59
  export type { AbsentArchivePlan } from "./deletion-policy.js";
59
60
  export { mockChannelConnector } from "./mock-connector.js";
60
61
  export type { MockChannelConnectorOptions } from "./mock-connector.js";
package/src/service.ts CHANGED
@@ -637,10 +637,32 @@ export function canCatalogPushTransition(from: CatalogPushState, to: CatalogPush
637
637
  return catalogPushTransitions[from].includes(to);
638
638
  }
639
639
 
640
- function hash(value: unknown): string {
641
- return createHash("sha256").update(JSON.stringify(value)).digest("hex");
640
+ /**
641
+ * JSON with every object's keys sorted, recursively — the one form every hash here is taken over.
642
+ *
643
+ * The sync hash was `sha256(JSON.stringify(item))`, and JSON.stringify writes keys in insertion
644
+ * order. The host's page consumer rebuilds each landed item in its own key order while reconcile
645
+ * hashes the adapter's objects in theirs, so the fast path and reconcile hashed the SAME content
646
+ * differently and reconcile re-converged every freshly imported product. Content, not key order,
647
+ * is what a hash here must answer. `undefined` values are dropped exactly as JSON.stringify drops
648
+ * them; array order is kept (it carries meaning: images, variants, options).
649
+ */
650
+ function canonicalJson(value: unknown): string {
651
+ if (Array.isArray(value)) return `[${value.map((entry) => (entry === undefined ? "null" : canonicalJson(entry))).join(",")}]`;
652
+ if (value !== null && typeof value === "object" && !(value instanceof Date)) {
653
+ const entries = Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
654
+ return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
655
+ }
656
+ return JSON.stringify(value) ?? "null";
642
657
  }
643
658
 
659
+ /** THE sync hash: every path that records or compares a `channel_entity_map.sync_hash` uses this. */
660
+ export function channelSyncHash(value: unknown): string {
661
+ return createHash("sha256").update(canonicalJson(value)).digest("hex");
662
+ }
663
+
664
+ const hash = channelSyncHash;
665
+
644
666
  /**
645
667
  * The suffix a store's product takes when its handle is already another store's slug: the first
646
668
  * label of the store domain (`kelly-felder.myshopify.com` → `kelly-felder`), slugified.
@@ -2206,7 +2228,13 @@ export class ChannelConnectorService {
2206
2228
  previousRoles: Map<string, string>;
2207
2229
  }>> {
2208
2230
  // Uploads what is missing and PLANS the entity's media links; `commitEntityLinks` writes them.
2209
- const images = item.images ?? [];
2231
+ //
2232
+ // Only the images the import ruling allows — the SAME `selectImportImages` the page fast path
2233
+ // uses: the hero plus the first photo of each other variant. This path attached EVERY image
2234
+ // the item listed, so the first converge of a product whose price changed pulled in the whole
2235
+ // gallery the fast path had deliberately left out: an upload, an embed and a bump per photo.
2236
+ const selection = selectImportImages(item);
2237
+ const images = selection.hero === null ? [] : [selection.hero, ...selection.perVariant];
2210
2238
  const externalIds = [...new Set(images.map((image) => image.externalId).filter((id): id is string => id != null))];
2211
2239
  const urlHashes = [...new Set(images.map((image) => hash(image.url)))];
2212
2240
  const keyPredicates = [];
@@ -3914,7 +3942,9 @@ export class ChannelConnectorService {
3914
3942
  * products remain.
3915
3943
  */
3916
3944
  private async claimUnrecordedLinks(orgId: string, storeId: string, items: ChannelCatalogItem[]): Promise<void> {
3917
- if (items.length === 0) return;
3945
+ // Nothing listed, nothing to claim: no query. Otherwise one select per converge call (a batch),
3946
+ // never one per product.
3947
+ if (!items.some((item) => (item.tags?.length ?? 0) > 0 || (item.categories?.length ?? 0) > 0 || item.brand !== undefined)) return;
3918
3948
  const unclaimed = await this.db.select({ externalId: channelEntityMap.externalId, entityId: channelEntityMap.entityId }).from(channelEntityMap).where(and(
3919
3949
  eq(channelEntityMap.organizationId, orgId),
3920
3950
  eq(channelEntityMap.storeId, storeId),