@porulle/plugin-channel-connector 0.56.0 → 0.57.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/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.
@@ -2923,7 +2944,9 @@ export class ChannelConnectorService {
2923
2944
  * products remain.
2924
2945
  */
2925
2946
  async claimUnrecordedLinks(orgId, storeId, items) {
2926
- if (items.length === 0)
2947
+ // Nothing listed, nothing to claim: no query. Otherwise one select per converge call (a batch),
2948
+ // never one per product.
2949
+ if (!items.some((item) => (item.tags?.length ?? 0) > 0 || (item.categories?.length ?? 0) > 0 || item.brand !== undefined))
2927
2950
  return;
2928
2951
  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
2952
  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.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,7 +19,7 @@
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.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^24.5.2",
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.
@@ -3914,7 +3936,9 @@ export class ChannelConnectorService {
3914
3936
  * products remain.
3915
3937
  */
3916
3938
  private async claimUnrecordedLinks(orgId: string, storeId: string, items: ChannelCatalogItem[]): Promise<void> {
3917
- if (items.length === 0) return;
3939
+ // Nothing listed, nothing to claim: no query. Otherwise one select per converge call (a batch),
3940
+ // never one per product.
3941
+ if (!items.some((item) => (item.tags?.length ?? 0) > 0 || (item.categories?.length ?? 0) > 0 || item.brand !== undefined)) return;
3918
3942
  const unclaimed = await this.db.select({ externalId: channelEntityMap.externalId, entityId: channelEntityMap.entityId }).from(channelEntityMap).where(and(
3919
3943
  eq(channelEntityMap.organizationId, orgId),
3920
3944
  eq(channelEntityMap.storeId, storeId),