@porulle/plugin-channel-connector 0.29.0 → 0.31.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 +1 -1
- package/dist/index.js +32 -5
- package/dist/service.d.ts +16 -1
- package/dist/service.js +73 -4
- package/package.json +2 -2
- package/src/index.ts +31 -3
- package/src/service.ts +82 -7
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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, isCatalogPushBreakerOpen, } from "./service.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";
|
|
5
5
|
export { isValidCatalogMappingFieldPath, matchFieldPath, mergeCatalogFieldMapping, normalizeCatalogFieldMapping, compareCatalogFieldMappingSpecificity, providerCatalogFieldMappingDefaults, selectCatalogFieldMapping, validateCatalogMappingRow, } from "./catalog-field-mapping.js";
|
|
6
6
|
export type { CatalogFieldMapping, CatalogFieldMappingInput, CatalogFieldMappingRow, CatalogFieldTarget, } from "./catalog-field-mapping.js";
|
|
7
7
|
/** ~320 Neon HTTP subrequests per product against a 10,000 per-invocation cap → hard ceiling near 31; 20 leaves margin for heavier products. */
|
package/dist/index.js
CHANGED
|
@@ -4,11 +4,11 @@ import { z } from "@hono/zod-openapi";
|
|
|
4
4
|
import { and, eq } from "@porulle/core/drizzle";
|
|
5
5
|
import { processedWebhookEvents } from "@porulle/core/schema";
|
|
6
6
|
import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityMap, channelExportEvents, channelOrderExports, channelRefundEvents, channelRefundRequests, connectedStores, } from "./schema.js";
|
|
7
|
-
import { ChannelConnectorService, catalogPushConcurrencyKey, } from "./service.js";
|
|
7
|
+
import { ChannelConnectorService, catalogPushConcurrencyKey, CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION, } from "./service.js";
|
|
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, isCatalogPushBreakerOpen, } from "./service.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";
|
|
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;
|
|
@@ -97,14 +97,30 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
97
97
|
});
|
|
98
98
|
if (!result.ok)
|
|
99
99
|
throw new Error(result.error);
|
|
100
|
+
const jobs = ctx.services.jobs;
|
|
100
101
|
if (!result.value.exhausted) {
|
|
101
|
-
const jobs = ctx.services.jobs;
|
|
102
102
|
await jobs.enqueue("channel/import-catalog", { orgId, storeId }, {
|
|
103
103
|
organizationId: orgId,
|
|
104
104
|
concurrencyKey: storeId,
|
|
105
105
|
supersedes: true,
|
|
106
106
|
});
|
|
107
107
|
}
|
|
108
|
+
else {
|
|
109
|
+
// A finished catalog is not a usable one. `importCatalog` writes entities, variants and
|
|
110
|
+
// prices and never touches `inventory_levels`, so a store whose sweep ends here has a
|
|
111
|
+
// catalog in which every variant rolls up as out of stock — which is what a consumer
|
|
112
|
+
// projection publishes and what a shopper is shown.
|
|
113
|
+
//
|
|
114
|
+
// Inventory used to arrive from `reconcile`, reachable only through the
|
|
115
|
+
// `channel/reconcile-sweep` cron. A deployment that removes its crons therefore loses a
|
|
116
|
+
// data-plane write silently, with every suite still green. Levelling inventory here keeps
|
|
117
|
+
// it inside the one operator action — "import this store" — instead of behind a second
|
|
118
|
+
// one somebody has to remember.
|
|
119
|
+
await jobs.enqueue("channel/sync-inventory", { orgId, storeId }, {
|
|
120
|
+
organizationId: orgId,
|
|
121
|
+
concurrencyKey: storeId,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
108
124
|
return {
|
|
109
125
|
output: {
|
|
110
126
|
imported: result.value.imported,
|
|
@@ -146,10 +162,21 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
146
162
|
concurrency: { key: (input) => String(input.storeId) },
|
|
147
163
|
handler: async ({ input, ctx }) => {
|
|
148
164
|
const service = new ChannelConnectorService(ctx.db, ctx.services, options);
|
|
149
|
-
const
|
|
165
|
+
const orgId = String(input.orgId);
|
|
166
|
+
const storeId = String(input.storeId);
|
|
167
|
+
const result = await service.syncInventory(orgId, storeId, createSystemActor(orgId), {
|
|
168
|
+
maxItems: CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION,
|
|
169
|
+
});
|
|
150
170
|
if (!result.ok)
|
|
151
171
|
throw new Error(result.error);
|
|
152
|
-
|
|
172
|
+
const jobs = ctx.services.jobs;
|
|
173
|
+
if (!result.value.exhausted) {
|
|
174
|
+
await jobs.enqueue("channel/sync-inventory", { orgId, storeId }, {
|
|
175
|
+
organizationId: orgId,
|
|
176
|
+
concurrencyKey: storeId,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return { output: { synced: result.value.synced, exhausted: result.value.exhausted } };
|
|
153
180
|
},
|
|
154
181
|
},
|
|
155
182
|
{
|
package/dist/service.d.ts
CHANGED
|
@@ -8,6 +8,18 @@ export type CatalogPushState = ChannelCatalogPush["state"];
|
|
|
8
8
|
export type CatalogConflictState = ChannelCatalogConflict["state"];
|
|
9
9
|
export declare const CATALOG_PUSH_BATCH_SIZES: Record<string, number>;
|
|
10
10
|
export declare const CATALOG_PUSH_MAX_ATTEMPTS = 8;
|
|
11
|
+
/**
|
|
12
|
+
* How many inventory levels one `channel/sync-inventory` invocation may write before it persists a
|
|
13
|
+
* resume position and hands off to its own continuation.
|
|
14
|
+
*
|
|
15
|
+
* The catalog's bound exists because a single invocation ran out of subrequests. This one exists
|
|
16
|
+
* because a single invocation runs out of TIME: measured on the deployed Worker on 2026-09-14,
|
|
17
|
+
* instance 1428d7e0, `channel/sync-inventory` was killed by `WorkflowTimeoutError: Execution timed
|
|
18
|
+
* out after 600000ms` having written 232 of ~1,299 levels — 2.6 s per level, so a bound of 20 is
|
|
19
|
+
* roughly 52 s of work and a 1,000-product merchant becomes ~650 bounded invocations instead of one
|
|
20
|
+
* nine-hour one that discards everything if it fails.
|
|
21
|
+
*/
|
|
22
|
+
export declare const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
|
|
11
23
|
export declare function catalogPushRetryDelayMs(attempts: number): number;
|
|
12
24
|
export interface CatalogPushJobResult extends Record<string, unknown> {
|
|
13
25
|
noop?: boolean;
|
|
@@ -247,8 +259,11 @@ export declare class ChannelConnectorService {
|
|
|
247
259
|
listCatalogConflicts(orgId: string, storeId?: string, state?: ChannelCatalogConflict["state"]): Promise<PluginResult<ChannelCatalogConflict[]>>;
|
|
248
260
|
resolveCatalogConflict(orgId: string, id: string, choose: "platform" | "store", actor: Pick<Actor, "userId">): Promise<PluginResult<ChannelCatalogConflict>>;
|
|
249
261
|
private applyStoreConflictValue;
|
|
250
|
-
syncInventory(orgId: string, storeId: string, actor: Actor
|
|
262
|
+
syncInventory(orgId: string, storeId: string, actor: Actor, options?: {
|
|
263
|
+
maxItems?: number;
|
|
264
|
+
}): Promise<PluginResult<{
|
|
251
265
|
synced: number;
|
|
266
|
+
exhausted?: boolean;
|
|
252
267
|
}>>;
|
|
253
268
|
handleWebhook(orgId: string, storeId: string, event: {
|
|
254
269
|
id: string;
|
package/dist/service.js
CHANGED
|
@@ -14,6 +14,18 @@ export const CATALOG_PUSH_BATCH_SIZES = {
|
|
|
14
14
|
const DEFAULT_CATALOG_PUSH_BATCH_SIZE = 50;
|
|
15
15
|
const CATALOG_PUSH_BREAKER_RETRY_MS = 60_000;
|
|
16
16
|
export const CATALOG_PUSH_MAX_ATTEMPTS = 8;
|
|
17
|
+
/**
|
|
18
|
+
* How many inventory levels one `channel/sync-inventory` invocation may write before it persists a
|
|
19
|
+
* resume position and hands off to its own continuation.
|
|
20
|
+
*
|
|
21
|
+
* The catalog's bound exists because a single invocation ran out of subrequests. This one exists
|
|
22
|
+
* because a single invocation runs out of TIME: measured on the deployed Worker on 2026-09-14,
|
|
23
|
+
* instance 1428d7e0, `channel/sync-inventory` was killed by `WorkflowTimeoutError: Execution timed
|
|
24
|
+
* out after 600000ms` having written 232 of ~1,299 levels — 2.6 s per level, so a bound of 20 is
|
|
25
|
+
* roughly 52 s of work and a 1,000-product merchant becomes ~650 bounded invocations instead of one
|
|
26
|
+
* nine-hour one that discards everything if it fails.
|
|
27
|
+
*/
|
|
28
|
+
export const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
|
|
17
29
|
const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
|
|
18
30
|
const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
|
|
19
31
|
export function catalogPushRetryDelayMs(attempts) {
|
|
@@ -68,6 +80,27 @@ function parseImportResumePosition(raw) {
|
|
|
68
80
|
function encodeImportResumePosition(position) {
|
|
69
81
|
return JSON.stringify(position);
|
|
70
82
|
}
|
|
83
|
+
/** Resume position for bounded `syncInventory` — stored on `connected_stores.inventory_cursor`.
|
|
84
|
+
* Last-sync time lives on `connected_stores.lastSyncAt` (set when a run exhausts). */
|
|
85
|
+
function parseInventoryResumePosition(raw) {
|
|
86
|
+
if (!raw)
|
|
87
|
+
return { offset: 0 };
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(raw);
|
|
90
|
+
if (typeof parsed === "object" && parsed !== null && "offset" in parsed) {
|
|
91
|
+
return {
|
|
92
|
+
offset: typeof parsed.offset === "number" && parsed.offset >= 0 ? parsed.offset : 0,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Legacy value was an ISO timestamp written when a sync completed.
|
|
98
|
+
}
|
|
99
|
+
return { offset: 0 };
|
|
100
|
+
}
|
|
101
|
+
function encodeInventoryResumePosition(position) {
|
|
102
|
+
return JSON.stringify(position);
|
|
103
|
+
}
|
|
71
104
|
const exportTransitions = {
|
|
72
105
|
pending: ["exported", "abandoned"],
|
|
73
106
|
exported: ["confirmed", "failed", "abandoned"],
|
|
@@ -2375,7 +2408,7 @@ export class ChannelConnectorService {
|
|
|
2375
2408
|
}
|
|
2376
2409
|
return PluginErr(`Conflict field path "${conflict.fieldPath}" cannot be resolved to the store value.`, "UNSUPPORTED_CONFLICT_FIELD");
|
|
2377
2410
|
}
|
|
2378
|
-
async syncInventory(orgId, storeId, actor) {
|
|
2411
|
+
async syncInventory(orgId, storeId, actor, options) {
|
|
2379
2412
|
const store = await this.getStoreRecord(orgId, storeId);
|
|
2380
2413
|
if (!store || store.status !== "connected")
|
|
2381
2414
|
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
@@ -2387,11 +2420,40 @@ export class ChannelConnectorService {
|
|
|
2387
2420
|
return PluginErr(inventory.error.message);
|
|
2388
2421
|
const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId)));
|
|
2389
2422
|
const inventoryService = this.services.inventory;
|
|
2423
|
+
const maxItems = options?.maxItems ?? CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION;
|
|
2424
|
+
const levels = inventory.value;
|
|
2425
|
+
let { offset } = parseInventoryResumePosition(store.inventoryCursor);
|
|
2426
|
+
const entityIdsForSlice = (from, to) => {
|
|
2427
|
+
const ids = new Set();
|
|
2428
|
+
for (const level of levels.slice(from, to)) {
|
|
2429
|
+
const mapping = mappings.find((entry) => entry.externalId === level.externalId);
|
|
2430
|
+
if (mapping)
|
|
2431
|
+
ids.add(mapping.entityId);
|
|
2432
|
+
}
|
|
2433
|
+
return ids;
|
|
2434
|
+
};
|
|
2435
|
+
const loadedThrough = Math.min(offset + maxItems, levels.length);
|
|
2436
|
+
const batchEntityIds = entityIdsForSlice(offset, loadedThrough);
|
|
2437
|
+
const existingLevels = batchEntityIds.size === 0
|
|
2438
|
+
? []
|
|
2439
|
+
: await this.db.select().from(inventoryLevels).where(and(eq(inventoryLevels.organizationId, orgId), inArray(inventoryLevels.entityId, [...batchEntityIds])));
|
|
2390
2440
|
let synced = 0;
|
|
2391
|
-
|
|
2441
|
+
let levelsWalked = 0;
|
|
2442
|
+
let exhausted = false;
|
|
2443
|
+
// No refill inside the loop: the window loaded above spans exactly `maxItems` levels and the
|
|
2444
|
+
// walk breaks at `maxItems`, so `offset` can never pass `loadedThrough` within one invocation.
|
|
2445
|
+
while (offset < levels.length) {
|
|
2446
|
+
if (levelsWalked >= maxItems)
|
|
2447
|
+
break;
|
|
2448
|
+
const level = levels[offset];
|
|
2449
|
+
offset += 1;
|
|
2450
|
+
levelsWalked += 1;
|
|
2392
2451
|
const mapping = mappings.find((entry) => entry.externalId === level.externalId);
|
|
2393
2452
|
if (!mapping)
|
|
2394
2453
|
continue;
|
|
2454
|
+
const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
|
|
2455
|
+
if (current?.quantityOnHand === level.available)
|
|
2456
|
+
continue;
|
|
2395
2457
|
const result = await inventoryService.setAbsolute({
|
|
2396
2458
|
entityId: mapping.entityId,
|
|
2397
2459
|
...(mapping.variantId ? { variantId: mapping.variantId } : {}),
|
|
@@ -2402,8 +2464,15 @@ export class ChannelConnectorService {
|
|
|
2402
2464
|
return PluginErr(result.error?.message ?? "Inventory sync failed.");
|
|
2403
2465
|
synced += 1;
|
|
2404
2466
|
}
|
|
2405
|
-
|
|
2406
|
-
|
|
2467
|
+
if (offset >= levels.length)
|
|
2468
|
+
exhausted = true;
|
|
2469
|
+
const inventoryCursor = exhausted ? null : encodeInventoryResumePosition({ offset });
|
|
2470
|
+
await this.db.update(connectedStores).set({
|
|
2471
|
+
inventoryCursor,
|
|
2472
|
+
...(exhausted ? { lastSyncAt: new Date() } : {}),
|
|
2473
|
+
updatedAt: new Date(),
|
|
2474
|
+
}).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
|
|
2475
|
+
return Ok({ synced, exhausted });
|
|
2407
2476
|
}
|
|
2408
2477
|
async handleWebhook(orgId, storeId, event) {
|
|
2409
2478
|
const store = await this.getStoreRecord(orgId, storeId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/plugin-channel-connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.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.
|
|
22
|
+
"@porulle/core": "0.31.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/node": "^24.5.2",
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
import {
|
|
30
30
|
ChannelConnectorService,
|
|
31
31
|
catalogPushConcurrencyKey,
|
|
32
|
+
CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION,
|
|
32
33
|
type CatalogConflictState,
|
|
33
34
|
type ChannelComplianceData,
|
|
34
35
|
type ChannelConnectorPluginOptions,
|
|
@@ -55,6 +56,7 @@ export {
|
|
|
55
56
|
canExportTransition,
|
|
56
57
|
catalogPushConcurrencyKey,
|
|
57
58
|
catalogPushRetryDelayMs,
|
|
59
|
+
CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION,
|
|
58
60
|
isCatalogPushBreakerOpen,
|
|
59
61
|
} from "./service.js";
|
|
60
62
|
export {
|
|
@@ -202,13 +204,28 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
202
204
|
maxItems: CHANNEL_IMPORT_MAX_ITEMS_PER_INVOCATION,
|
|
203
205
|
});
|
|
204
206
|
if (!result.ok) throw new Error(result.error);
|
|
207
|
+
const jobs = ctx.services.jobs as JobsAdapter;
|
|
205
208
|
if (!result.value.exhausted) {
|
|
206
|
-
const jobs = ctx.services.jobs as JobsAdapter;
|
|
207
209
|
await jobs.enqueue("channel/import-catalog", { orgId, storeId }, {
|
|
208
210
|
organizationId: orgId,
|
|
209
211
|
concurrencyKey: storeId,
|
|
210
212
|
supersedes: true,
|
|
211
213
|
});
|
|
214
|
+
} else {
|
|
215
|
+
// A finished catalog is not a usable one. `importCatalog` writes entities, variants and
|
|
216
|
+
// prices and never touches `inventory_levels`, so a store whose sweep ends here has a
|
|
217
|
+
// catalog in which every variant rolls up as out of stock — which is what a consumer
|
|
218
|
+
// projection publishes and what a shopper is shown.
|
|
219
|
+
//
|
|
220
|
+
// Inventory used to arrive from `reconcile`, reachable only through the
|
|
221
|
+
// `channel/reconcile-sweep` cron. A deployment that removes its crons therefore loses a
|
|
222
|
+
// data-plane write silently, with every suite still green. Levelling inventory here keeps
|
|
223
|
+
// it inside the one operator action — "import this store" — instead of behind a second
|
|
224
|
+
// one somebody has to remember.
|
|
225
|
+
await jobs.enqueue("channel/sync-inventory", { orgId, storeId }, {
|
|
226
|
+
organizationId: orgId,
|
|
227
|
+
concurrencyKey: storeId,
|
|
228
|
+
});
|
|
212
229
|
}
|
|
213
230
|
return {
|
|
214
231
|
output: {
|
|
@@ -250,9 +267,20 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
250
267
|
concurrency: { key: (input: Record<string, unknown>) => String(input.storeId) },
|
|
251
268
|
handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
|
|
252
269
|
const service = new ChannelConnectorService(ctx.db, ctx.services, options);
|
|
253
|
-
const
|
|
270
|
+
const orgId = String(input.orgId);
|
|
271
|
+
const storeId = String(input.storeId);
|
|
272
|
+
const result = await service.syncInventory(orgId, storeId, createSystemActor(orgId), {
|
|
273
|
+
maxItems: CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION,
|
|
274
|
+
});
|
|
254
275
|
if (!result.ok) throw new Error(result.error);
|
|
255
|
-
|
|
276
|
+
const jobs = ctx.services.jobs as JobsAdapter;
|
|
277
|
+
if (!result.value.exhausted) {
|
|
278
|
+
await jobs.enqueue("channel/sync-inventory", { orgId, storeId }, {
|
|
279
|
+
organizationId: orgId,
|
|
280
|
+
concurrencyKey: storeId,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return { output: { synced: result.value.synced, exhausted: result.value.exhausted } };
|
|
256
284
|
},
|
|
257
285
|
},
|
|
258
286
|
{
|
package/src/service.ts
CHANGED
|
@@ -93,6 +93,20 @@ export const CATALOG_PUSH_BATCH_SIZES: Record<string, number> = {
|
|
|
93
93
|
const DEFAULT_CATALOG_PUSH_BATCH_SIZE = 50;
|
|
94
94
|
const CATALOG_PUSH_BREAKER_RETRY_MS = 60_000;
|
|
95
95
|
export const CATALOG_PUSH_MAX_ATTEMPTS = 8;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* How many inventory levels one `channel/sync-inventory` invocation may write before it persists a
|
|
99
|
+
* resume position and hands off to its own continuation.
|
|
100
|
+
*
|
|
101
|
+
* The catalog's bound exists because a single invocation ran out of subrequests. This one exists
|
|
102
|
+
* because a single invocation runs out of TIME: measured on the deployed Worker on 2026-09-14,
|
|
103
|
+
* instance 1428d7e0, `channel/sync-inventory` was killed by `WorkflowTimeoutError: Execution timed
|
|
104
|
+
* out after 600000ms` having written 232 of ~1,299 levels — 2.6 s per level, so a bound of 20 is
|
|
105
|
+
* roughly 52 s of work and a 1,000-product merchant becomes ~650 bounded invocations instead of one
|
|
106
|
+
* nine-hour one that discards everything if it fails.
|
|
107
|
+
*/
|
|
108
|
+
export const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
|
|
109
|
+
|
|
96
110
|
const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
|
|
97
111
|
const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
|
|
98
112
|
|
|
@@ -269,6 +283,29 @@ function encodeImportResumePosition(position: ImportResumePosition): string {
|
|
|
269
283
|
return JSON.stringify(position);
|
|
270
284
|
}
|
|
271
285
|
|
|
286
|
+
type InventoryResumePosition = { offset: number };
|
|
287
|
+
|
|
288
|
+
/** Resume position for bounded `syncInventory` — stored on `connected_stores.inventory_cursor`.
|
|
289
|
+
* Last-sync time lives on `connected_stores.lastSyncAt` (set when a run exhausts). */
|
|
290
|
+
function parseInventoryResumePosition(raw: string | null | undefined): InventoryResumePosition {
|
|
291
|
+
if (!raw) return { offset: 0 };
|
|
292
|
+
try {
|
|
293
|
+
const parsed = JSON.parse(raw) as Partial<InventoryResumePosition>;
|
|
294
|
+
if (typeof parsed === "object" && parsed !== null && "offset" in parsed) {
|
|
295
|
+
return {
|
|
296
|
+
offset: typeof parsed.offset === "number" && parsed.offset >= 0 ? parsed.offset : 0,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
} catch {
|
|
300
|
+
// Legacy value was an ISO timestamp written when a sync completed.
|
|
301
|
+
}
|
|
302
|
+
return { offset: 0 };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function encodeInventoryResumePosition(position: InventoryResumePosition): string {
|
|
306
|
+
return JSON.stringify(position);
|
|
307
|
+
}
|
|
308
|
+
|
|
272
309
|
export interface BackfillCatalogOptions {
|
|
273
310
|
dryRun?: boolean;
|
|
274
311
|
resume?: boolean;
|
|
@@ -3159,7 +3196,8 @@ export class ChannelConnectorService {
|
|
|
3159
3196
|
orgId: string,
|
|
3160
3197
|
storeId: string,
|
|
3161
3198
|
actor: Actor,
|
|
3162
|
-
|
|
3199
|
+
options?: { maxItems?: number },
|
|
3200
|
+
): Promise<PluginResult<{ synced: number; exhausted?: boolean }>> {
|
|
3163
3201
|
const store = await this.getStoreRecord(orgId, storeId);
|
|
3164
3202
|
if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
3165
3203
|
const connector = this.connectors.get(store.provider);
|
|
@@ -3173,10 +3211,42 @@ export class ChannelConnectorService {
|
|
|
3173
3211
|
const inventoryService = this.services.inventory as {
|
|
3174
3212
|
setAbsolute(input: { entityId: string; variantId?: string; quantity: number; reason?: string }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }>;
|
|
3175
3213
|
};
|
|
3214
|
+
const maxItems = options?.maxItems ?? CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION;
|
|
3215
|
+
const levels = inventory.value;
|
|
3216
|
+
let { offset } = parseInventoryResumePosition(store.inventoryCursor);
|
|
3217
|
+
|
|
3218
|
+
const entityIdsForSlice = (from: number, to: number) => {
|
|
3219
|
+
const ids = new Set<string>();
|
|
3220
|
+
for (const level of levels.slice(from, to)) {
|
|
3221
|
+
const mapping = mappings.find((entry) => entry.externalId === level.externalId);
|
|
3222
|
+
if (mapping) ids.add(mapping.entityId);
|
|
3223
|
+
}
|
|
3224
|
+
return ids;
|
|
3225
|
+
};
|
|
3226
|
+
const loadedThrough = Math.min(offset + maxItems, levels.length);
|
|
3227
|
+
const batchEntityIds = entityIdsForSlice(offset, loadedThrough);
|
|
3228
|
+
const existingLevels = batchEntityIds.size === 0
|
|
3229
|
+
? []
|
|
3230
|
+
: await this.db.select().from(inventoryLevels).where(and(
|
|
3231
|
+
eq(inventoryLevels.organizationId, orgId),
|
|
3232
|
+
inArray(inventoryLevels.entityId, [...batchEntityIds]),
|
|
3233
|
+
));
|
|
3234
|
+
|
|
3176
3235
|
let synced = 0;
|
|
3177
|
-
|
|
3236
|
+
let levelsWalked = 0;
|
|
3237
|
+
let exhausted = false;
|
|
3238
|
+
|
|
3239
|
+
// No refill inside the loop: the window loaded above spans exactly `maxItems` levels and the
|
|
3240
|
+
// walk breaks at `maxItems`, so `offset` can never pass `loadedThrough` within one invocation.
|
|
3241
|
+
while (offset < levels.length) {
|
|
3242
|
+
if (levelsWalked >= maxItems) break;
|
|
3243
|
+
const level = levels[offset]!;
|
|
3244
|
+
offset += 1;
|
|
3245
|
+
levelsWalked += 1;
|
|
3178
3246
|
const mapping = mappings.find((entry) => entry.externalId === level.externalId);
|
|
3179
3247
|
if (!mapping) continue;
|
|
3248
|
+
const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
|
|
3249
|
+
if (current?.quantityOnHand === level.available) continue;
|
|
3180
3250
|
const result = await inventoryService.setAbsolute({
|
|
3181
3251
|
entityId: mapping.entityId,
|
|
3182
3252
|
...(mapping.variantId ? { variantId: mapping.variantId } : {}),
|
|
@@ -3186,11 +3256,16 @@ export class ChannelConnectorService {
|
|
|
3186
3256
|
if (!result.ok) return PluginErr(result.error?.message ?? "Inventory sync failed.");
|
|
3187
3257
|
synced += 1;
|
|
3188
3258
|
}
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
)
|
|
3193
|
-
|
|
3259
|
+
|
|
3260
|
+
if (offset >= levels.length) exhausted = true;
|
|
3261
|
+
const inventoryCursor = exhausted ? null : encodeInventoryResumePosition({ offset });
|
|
3262
|
+
await this.db.update(connectedStores).set({
|
|
3263
|
+
inventoryCursor,
|
|
3264
|
+
...(exhausted ? { lastSyncAt: new Date() } : {}),
|
|
3265
|
+
updatedAt: new Date(),
|
|
3266
|
+
}).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
|
|
3267
|
+
|
|
3268
|
+
return Ok({ synced, exhausted });
|
|
3194
3269
|
}
|
|
3195
3270
|
|
|
3196
3271
|
async handleWebhook(orgId: string, storeId: string, event: { id: string; type: string; data: unknown }): Promise<PluginResult<{ processed: true; data?: ChannelComplianceData; redacted?: number }>> {
|