@porulle/plugin-channel-connector 0.30.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 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;
@@ -162,10 +162,21 @@ export function channelConnectorPlugin(options = {}) {
162
162
  concurrency: { key: (input) => String(input.storeId) },
163
163
  handler: async ({ input, ctx }) => {
164
164
  const service = new ChannelConnectorService(ctx.db, ctx.services, options);
165
- const result = await service.syncInventory(String(input.orgId), String(input.storeId), createSystemActor(String(input.orgId)));
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
+ });
166
170
  if (!result.ok)
167
171
  throw new Error(result.error);
168
- return { output: { synced: result.value.synced } };
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 } };
169
180
  },
170
181
  },
171
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): Promise<PluginResult<{
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
- for (const level of inventory.value) {
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
- await this.db.update(connectedStores).set({ inventoryCursor: new Date().toISOString(), lastSyncAt: new Date(), updatedAt: new Date() }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
2406
- return Ok({ synced });
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.30.0",
3
+ "version": "0.31.0",
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.30.0"
22
+ "@porulle/core": "0.31.0"
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/typescript-config": "0.1.0",
30
- "@porulle/eslint-config": "0.1.0"
29
+ "@porulle/eslint-config": "0.1.0",
30
+ "@porulle/typescript-config": "0.1.0"
31
31
  },
32
32
  "publishConfig": {
33
33
  "access": "public"
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 {
@@ -265,9 +267,20 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
265
267
  concurrency: { key: (input: Record<string, unknown>) => String(input.storeId) },
266
268
  handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
267
269
  const service = new ChannelConnectorService(ctx.db, ctx.services, options);
268
- const result = await service.syncInventory(String(input.orgId), String(input.storeId), createSystemActor(String(input.orgId)));
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
+ });
269
275
  if (!result.ok) throw new Error(result.error);
270
- return { output: { synced: result.value.synced } };
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 } };
271
284
  },
272
285
  },
273
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
- ): Promise<PluginResult<{ synced: number }>> {
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
- for (const level of inventory.value) {
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
- await this.db.update(connectedStores).set({ inventoryCursor: new Date().toISOString(), lastSyncAt: new Date(), updatedAt: new Date() }).where(and(
3190
- eq(connectedStores.organizationId, orgId),
3191
- eq(connectedStores.id, storeId),
3192
- ));
3193
- return Ok({ synced });
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 }>> {