@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 +1 -0
- package/dist/index.js +1 -0
- package/dist/service.d.ts +2 -0
- package/dist/service.js +33 -4
- package/package.json +4 -4
- package/src/index.ts +1 -0
- package/src/service.ts +34 -4
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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/
|
|
30
|
-
"@porulle/
|
|
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
|
-
|
|
641
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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),
|