@porulle/plugin-channel-connector 0.48.1 → 0.50.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,8 +1,9 @@
1
1
  import { type ChannelConnectorPluginOptions } from "./service.js";
2
2
  export { mockChannelConnector } from "./mock-connector.js";
3
3
  export type { MockChannelConnectorOptions } from "./mock-connector.js";
4
- export { ChannelConnectorService, 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";
4
+ 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";
5
5
  export { isValidCatalogMappingFieldPath, matchFieldPath, mergeCatalogFieldMapping, normalizeCatalogFieldMapping, compareCatalogFieldMappingSpecificity, providerCatalogFieldMappingDefaults, selectCatalogFieldMapping, validateCatalogMappingRow, } from "./catalog-field-mapping.js";
6
+ export type { CatalogDeferredMedia, CatalogMediaFailure, CatalogMediaFailureReason, CatalogPageConvergence, ImportImageSelection, } from "./service.js";
6
7
  export type { CatalogFieldMapping, CatalogFieldMappingInput, CatalogFieldMappingRow, CatalogFieldTarget, } from "./catalog-field-mapping.js";
7
8
  /** ~320 Neon HTTP subrequests per product against a 10,000 per-invocation cap → hard ceiling near 31; 20 leaves margin for heavier products. */
8
9
  export declare const CHANNEL_IMPORT_MAX_ITEMS_PER_INVOCATION = 20;
package/dist/index.js CHANGED
@@ -8,7 +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 { mockChannelConnector } from "./mock-connector.js";
11
- export { ChannelConnectorService, 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";
11
+ 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";
12
12
  export { isValidCatalogMappingFieldPath, matchFieldPath, mergeCatalogFieldMapping, normalizeCatalogFieldMapping, compareCatalogFieldMappingSpecificity, providerCatalogFieldMappingDefaults, selectCatalogFieldMapping, validateCatalogMappingRow, } from "./catalog-field-mapping.js";
13
13
  /** ~320 Neon HTTP subrequests per product against a 10,000 per-invocation cap → hard ceiling near 31; 20 leaves margin for heavier products. */
14
14
  export const CHANNEL_IMPORT_MAX_ITEMS_PER_INVOCATION = 20;
package/dist/service.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Actor, ChannelConnector, ChannelOrderSlice, ChannelPushCatalogField, ChannelPushCatalogImage, ChannelPushCatalogItem, ChannelPushCatalogItemOutcome, ChannelPushCatalogResult, PluginDb, PluginResult, PluginTxFn } from "@porulle/core";
1
+ import type { Actor, ChannelCatalogItem, ChannelConnector, ChannelOrderSlice, ChannelPushCatalogField, ChannelPushCatalogImage, ChannelPushCatalogItem, ChannelPushCatalogItemOutcome, ChannelPushCatalogResult, PluginDb, PluginResult, PluginTxFn } from "@porulle/core";
2
+ import type { ChannelCatalogImage } from "@porulle/core";
2
3
  import type { FieldOwner, FieldPath } from "@porulle/core";
3
4
  import type { JobsAdapter } from "@porulle/core";
4
5
  import { type ChannelCatalogPush, type ChannelCatalogConflict, type ChannelOrderExport, type ChannelRefundRequest, type ConnectedStore } from "./schema.js";
@@ -230,7 +231,61 @@ export interface CatalogPushPreviewResult {
230
231
  }
231
232
  export declare function canExportTransition(from: ExportState, to: ExportState): boolean;
232
233
  export declare function canCatalogPushTransition(from: CatalogPushState, to: CatalogPushState): boolean;
234
+ /**
235
+ * The suffix a store's product takes when its handle is already another store's slug: the first
236
+ * label of the store domain (`kelly-felder.myshopify.com` → `kelly-felder`), slugified.
237
+ */
238
+ export declare function storeSlugSuffix(storeDomain: string): string;
239
+ /**
240
+ * Whether a create or update lost a slug to another writer — core's pre-check message, or the
241
+ * unique index itself, which a driver error can carry several `cause`s deep.
242
+ */
243
+ export declare function isSlugConflict(error: unknown): boolean;
233
244
  export declare const CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS: number;
245
+ /**
246
+ * A hero is streamed inside the page's own invocation, so it is bounded: a 30 MB TIFF a merchant
247
+ * uploaded by mistake must not buffer into a 128 MiB isolate. Anything larger is reported, not
248
+ * stored, and the product still lands — the index reads text first and media later.
249
+ */
250
+ export declare const HERO_IMAGE_BYTE_CAP: number;
251
+ export type CatalogMediaFailureReason = "too-large" | "download-failed" | "unsupported" | "storage";
252
+ export interface CatalogMediaFailure {
253
+ externalId: string;
254
+ imageExternalId?: string;
255
+ url: string;
256
+ reason: CatalogMediaFailureReason;
257
+ detail: string;
258
+ }
259
+ /** Media the page did NOT fetch: the first photo of each variant the hero does not show. */
260
+ export interface CatalogDeferredMedia {
261
+ externalId: string;
262
+ entityId: string;
263
+ images: ChannelCatalogImage[];
264
+ }
265
+ export interface CatalogPageConvergence extends Record<string, unknown> {
266
+ created: number;
267
+ unchanged: number;
268
+ updated: number;
269
+ /** Input order, failures excluded, no duplicates — the page message is rebuilt from this. */
270
+ entityIds: string[];
271
+ failures: CatalogConvergenceFailure[];
272
+ heroesImported: number;
273
+ mediaFailures: CatalogMediaFailure[];
274
+ deferredMedia: CatalogDeferredMedia[];
275
+ warnings: string[];
276
+ }
277
+ export interface ImportImageSelection {
278
+ hero: ChannelCatalogImage | null;
279
+ /** In variant order; one image per variant the hero does not cover; no url twice. */
280
+ perVariant: ChannelCatalogImage[];
281
+ }
282
+ /**
283
+ * Ruling 2026-09-22: import the hero plus the FIRST photo of each other variant, nothing more.
284
+ * A "blue long dress" query must be able to show the blue variant, and a fourth photo of the red
285
+ * one adds nothing the index can use. Variants are read off the images' own variant references,
286
+ * so a connector that lists images against variants it does not enumerate still gets one each.
287
+ */
288
+ export declare function selectImportImages(item: ChannelCatalogItem): ImportImageSelection;
234
289
  export declare class ChannelConnectorService {
235
290
  private readonly db;
236
291
  private readonly services;
@@ -256,9 +311,48 @@ export declare class ChannelConnectorService {
256
311
  private setCatalogAttributesIfWritable;
257
312
  private upsertOptionAxes;
258
313
  private upsertVariants;
314
+ /**
315
+ * The organization's categories, brands and tags, read ONCE per converge run instead of once per
316
+ * product.
317
+ *
318
+ * `applyTaxonomy` takes an `entityId` and is called unconditionally for every item, and each of
319
+ * its three lookups was a whole-table read filtered by `organization_id`. Measured on the deployed
320
+ * Worker: 1.0 call per product per class, three classes, every product — an organization's whole
321
+ * category list re-read for each of twenty products in a batch that cannot have changed it.
322
+ *
323
+ * Rows created DURING the run are appended by the callers below, exactly as they were appended to
324
+ * the per-product arrays before, so an item that introduces a category is still seen by the next
325
+ * item. The cache is cleared at the top of `convergeCatalogItems`, so its lifetime is one converge
326
+ * rather than the lifetime of the service.
327
+ *
328
+ * The staleness window widens from one product to one batch: a category created by ANOTHER process
329
+ * mid-batch is not seen here. That was already true within a product — these lists were always a
330
+ * snapshot — and the create paths below go through `this.catalog`, which refuses a duplicate slug
331
+ * rather than writing one. So the failure mode is unchanged in kind and wider in window, which is
332
+ * the trade this comment exists to state rather than hide.
333
+ */
334
+ private taxonomyCache;
335
+ private taxonomyFor;
259
336
  private applyTaxonomy;
260
337
  private applyMedia;
261
338
  private getStoreRecord;
339
+ /**
340
+ * The slug each wanted handle takes for THIS store. Slugs stay unique across the organization —
341
+ * the storefront resolves `/:idOrSlug` org-wide — but one platform organization holds many
342
+ * merchants, and two of them may sell the same handle.
343
+ *
344
+ * A handle's FAMILY for a store is, in order: the bare handle, `<handle>-<store suffix>`, and
345
+ * `<handle>-<store suffix>-<store id prefix>`. The last is unique per store, so a third store with
346
+ * the same domain label still gets a slug of its own. A new product takes the first member no
347
+ * other store holds. An existing product whose slug is already in its handle's family KEEPS it
348
+ * (see `slugToKeep`): a slug, once assigned, is a shared link and is never recomputed.
349
+ */
350
+ private resolveStoreSlugs;
351
+ private readonly slugSuffixByStore;
352
+ private storeSlugSuffixFor;
353
+ /** The slug an existing product converges to: its current one while that is still in the family
354
+ * of the handle the store sends, so a slug is never recomputed out from under a shared link. */
355
+ private slugToKeep;
262
356
  getStoreByDomain(shopDomain: string): Promise<ConnectedStore | undefined>;
263
357
  getStoresByDomain(shopDomain: string): Promise<ConnectedStore[]>;
264
358
  resolveCatalogFieldMapping(store: Pick<ConnectedStore, "provider" | "catalogFieldMapping">, filterableCustomFields?: ReadonlySet<string> | Readonly<Record<string, boolean>>, warnings?: string[]): CatalogFieldMapping;
@@ -281,6 +375,27 @@ export declare class ChannelConnectorService {
281
375
  getStore(orgId: string, id: string): Promise<PluginResult<PublicConnectedStore>>;
282
376
  listStores(orgId: string, context?: StoreReadContext): Promise<PluginResult<PublicConnectedStore[]>>;
283
377
  validateLineStock(orgId: string, lines: ChannelStockLine[], timeoutMs?: number): Promise<void>;
378
+ /**
379
+ * One page from the connector, nothing written. The host lands the page durably (R2 + its
380
+ * ledger) and hands it to `convergeCatalogPage` from a queue consumer; the two halves are
381
+ * separate so a consumer retry never re-fetches the merchant's API.
382
+ */
383
+ fetchCatalogPage(orgId: string, storeId: string, cursor: string | null): Promise<PluginResult<{
384
+ items: ChannelCatalogItem[];
385
+ nextCursor: string | null;
386
+ }>>;
387
+ /**
388
+ * Converges a page: items this store has never mapped take the import fast path
389
+ * (`catalog.importProducts`, one transaction, multi-row writes); items already mapped and
390
+ * unchanged cost nothing; items mapped-but-changed, and orphans (an entity of this store with
391
+ * the item's slug but no map row), take the editor path, which owns ownership and conflicts.
392
+ *
393
+ * Media: only each new item's hero is fetched here, streamed under `HERO_IMAGE_BYTE_CAP`, and
394
+ * linked at entity level as `primary` plus to the variants it shows. The first photo of every
395
+ * other variant comes back in `deferredMedia` for the host to land later.
396
+ */
397
+ convergeCatalogPage(orgId: string, storeId: string, items: ChannelCatalogItem[], actor: Actor): Promise<PluginResult<CatalogPageConvergence>>;
398
+ private importHeroes;
284
399
  importCatalog(orgId: string, storeId: string, actor: Actor, options: {
285
400
  maxItems: number;
286
401
  }): Promise<PluginResult<{