@shopimind/integration-kit-js 1.2.0 → 1.4.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.
@@ -86,6 +86,13 @@ export class WebhookLogRepo {
86
86
  .prepare(`DELETE FROM webhook_log WHERE created_at < datetime('now', @cutoff)`)
87
87
  .run({ cutoff: `-${days} days` }).changes;
88
88
  }
89
+ /** Most recent webhook log entries (newest first) — for /admin/overview (E5). */
90
+ recent(limit = 20) {
91
+ const capped = Math.max(1, Math.min(limit, 200));
92
+ return this.db
93
+ .prepare('SELECT event, installation_id, signature_ok, created_at FROM webhook_log ORDER BY id DESC LIMIT ?')
94
+ .all(capped);
95
+ }
89
96
  }
90
97
  /** Replay protection for lifecycle webhooks (key derived from the signature). */
91
98
  export class WebhookSeenRepo {
@@ -128,14 +135,18 @@ export class CursorRepo {
128
135
  set(installationId, entity, sourceKey, w) {
129
136
  this.db
130
137
  .prepare(`INSERT INTO sync_cursor
131
- (installation_id, entity, source_key, last_synced_at, last_status, last_error, items, updated_at)
138
+ (installation_id, entity, source_key, last_synced_at, last_status, last_error, items,
139
+ consecutive_failures, updated_at)
132
140
  VALUES
133
- (@installation_id, @entity, @source_key, @last_synced_at, @last_status, @last_error, @items, datetime('now'))
141
+ (@installation_id, @entity, @source_key, @last_synced_at, @last_status, @last_error, @items,
142
+ COALESCE(@consecutive_failures, 0), datetime('now'))
134
143
  ON CONFLICT(installation_id, entity, source_key) DO UPDATE SET
135
144
  last_synced_at = excluded.last_synced_at,
136
145
  last_status = excluded.last_status,
137
146
  last_error = excluded.last_error,
138
147
  items = excluded.items,
148
+ -- Omitted (@consecutive_failures IS NULL) -> keep the existing counter.
149
+ consecutive_failures = COALESCE(@consecutive_failures, sync_cursor.consecutive_failures),
139
150
  updated_at = datetime('now')`)
140
151
  .run({
141
152
  installation_id: installationId,
@@ -145,8 +156,22 @@ export class CursorRepo {
145
156
  last_status: nn(w.last_status),
146
157
  last_error: nn(w.last_error),
147
158
  items: w.items ?? 0,
159
+ consecutive_failures: nn(w.consecutive_failures),
148
160
  });
149
161
  }
162
+ /** All cursors for an installation (for /health, /admin/overview). */
163
+ listByInstallation(installationId) {
164
+ return this.db
165
+ .prepare('SELECT * FROM sync_cursor WHERE installation_id = ? ORDER BY entity, source_key')
166
+ .all(installationId);
167
+ }
168
+ /** Number of cursors currently in the `error` status (health signal). */
169
+ countInError() {
170
+ const row = this.db
171
+ .prepare(`SELECT COUNT(*) AS n FROM sync_cursor WHERE last_status = 'error'`)
172
+ .get();
173
+ return row.n;
174
+ }
150
175
  }
151
176
  /** History of sync runs. */
152
177
  export class RunRepo {
@@ -268,6 +293,44 @@ export class InboundEventRepo {
268
293
  .run({ cutoff: `-${days} days` }).changes;
269
294
  }
270
295
  }
296
+ /**
297
+ * Dead-letter of per-item REJECTIONS (E4). The engine records here what the
298
+ * ShopiMind API refused during a bulk push (validation), capped per run so a
299
+ * poison batch cannot flood the store; an operator inspects/replays via the admin
300
+ * endpoint. Subject to the same retention purge as the other log tables.
301
+ */
302
+ export class RejectedItemRepo {
303
+ db;
304
+ constructor(db) {
305
+ this.db = db;
306
+ }
307
+ add(entry) {
308
+ this.db
309
+ .prepare(`INSERT INTO rejected_item (installation_id, run_id, entity, source_key, payload_json, reason)
310
+ VALUES (@installation_id, @run_id, @entity, @source_key, @payload_json, @reason)`)
311
+ .run({
312
+ installation_id: entry.installation_id,
313
+ run_id: nn(entry.run_id),
314
+ entity: nn(entry.entity),
315
+ source_key: nn(entry.source_key),
316
+ payload_json: entry.payload_json,
317
+ reason: nn(entry.reason),
318
+ });
319
+ }
320
+ /** Most recent rejected items for an installation, newest first (bounded). */
321
+ listByInstallation(installationId, limit = 100) {
322
+ const capped = Math.max(1, Math.min(limit, 500));
323
+ return this.db
324
+ .prepare('SELECT * FROM rejected_item WHERE installation_id = ? ORDER BY id DESC LIMIT ?')
325
+ .all(installationId, capped);
326
+ }
327
+ /** Retention: deletes rejected-item rows older than `days` days. Returns rows removed. */
328
+ purgeOlderThan(days) {
329
+ return this.db
330
+ .prepare(`DELETE FROM rejected_item WHERE created_at < datetime('now', @cutoff)`)
331
+ .run({ cutoff: `-${days} days` }).changes;
332
+ }
333
+ }
271
334
  export function createRepositories(db, cipher) {
272
335
  return {
273
336
  installs: new InstallRepo(db),
@@ -277,5 +340,6 @@ export function createRepositories(db, cipher) {
277
340
  runs: new RunRepo(db),
278
341
  state: new IntegrationStateRepo(db, cipher),
279
342
  inboundEvents: new InboundEventRepo(db),
343
+ rejectedItems: new RejectedItemRepo(db),
280
344
  };
281
345
  }
@@ -33,13 +33,26 @@ export interface CursorRow {
33
33
  last_status: string | null;
34
34
  last_error: string | null;
35
35
  items: number;
36
+ /** Consecutive failed/held runs for this cursor (E3). Reset to 0 on a clean advance. */
37
+ consecutive_failures: number;
36
38
  updated_at: string;
37
39
  }
38
40
  export interface CursorWrite {
39
- last_synced_at: string;
41
+ /**
42
+ * Upper bound the cursor advanced to (ISO 8601), or `null` when the cursor is
43
+ * NOT advancing (a failure row that preserves the previous value, possibly
44
+ * never-synced). Nullable by design — do not cast a null away.
45
+ */
46
+ last_synced_at: string | null;
40
47
  last_status?: string;
41
48
  last_error?: string | null;
42
49
  items?: number;
50
+ /**
51
+ * Absolute value to persist in `consecutive_failures` (E3). The engine sets 0 on
52
+ * a clean advance and the incremented count on a failure/hold. Omitted -> left
53
+ * unchanged (COALESCE), so callers that do not track it keep the old value.
54
+ */
55
+ consecutive_failures?: number;
43
56
  }
44
57
  export interface SyncRunRow {
45
58
  id: number;
@@ -60,3 +73,14 @@ export interface InboundEventRow {
60
73
  received_at: string;
61
74
  processed_at: string | null;
62
75
  }
76
+ /** A dead-lettered item the ShopiMind API REJECTED during a bulk push (E4). */
77
+ export interface RejectedItemRow {
78
+ id: number;
79
+ installation_id: string;
80
+ run_id: number | null;
81
+ entity: string | null;
82
+ source_key: string | null;
83
+ payload_json: string | null;
84
+ reason: string | null;
85
+ created_at: string;
86
+ }
@@ -1,4 +1,4 @@
1
- import type { CursorRepo, RunRepo } from '../store/repositories.js';
1
+ import type { CursorRepo, RunRepo, RejectedItemRepo } from '../store/repositories.js';
2
2
  import type { Integration, IntegrationContext, SyncWindow } from '../integration/types.js';
3
3
  import type { SourceHandle } from '../sdk/source-scope.js';
4
4
  import type { CustomDataHandle } from '../sdk/custom-data-scope.js';
@@ -6,6 +6,13 @@ import { type SendBulk } from '../sdk/send-bulk.js';
6
6
  export interface SyncOptions {
7
7
  fullBackfill?: boolean;
8
8
  backfillDays?: number;
9
+ /**
10
+ * Defensive OVERLAP (E9): on an incremental window, shift `since` back by this many
11
+ * seconds so an event that landed exactly on the previous cursor boundary (or a
12
+ * source with slightly skewed clocks) is not missed. Harmless: re-fetched items are
13
+ * idempotent on the ShopiMind side (bulkSave upserts). 0/undefined -> no overlap.
14
+ */
15
+ overlapSeconds?: number;
9
16
  /** Injectable for testing; defaults to `() => new Date()`. */
10
17
  now?: () => Date;
11
18
  }
@@ -17,7 +24,30 @@ export interface SyncStepSummary {
17
24
  rejected: number;
18
25
  errors: string[];
19
26
  advanced: boolean;
27
+ /**
28
+ * True when the step-source was NOT run because its cursor is in exponential
29
+ * backoff after repeated failures (E3). `items`/`rejected` are 0, `errors` empty.
30
+ */
31
+ skippedBackoff?: boolean;
20
32
  }
33
+ /**
34
+ * Exponential backoff window after `n` consecutive failures: 2^(n-1) minutes,
35
+ * capped at 24h. n<=0 -> 0 (no wait). While `now < updated_at + window` the engine
36
+ * SKIPS the step-source; the cursor is untouched (GOLDEN RULE preserved).
37
+ */
38
+ export declare function backoffWindowMs(consecutiveFailures: number): number;
39
+ /**
40
+ * Decides whether per-item rejections should be TOLERATED (cursor may advance),
41
+ * per the step's `tolerateRejects` policy (E8):
42
+ * - `undefined`/`false` -> never tolerate (strict hold);
43
+ * - `true` -> always tolerate (poison-pill escape hatch);
44
+ * - `{ maxRatio }` -> tolerate only while rejected/attempted <= maxRatio.
45
+ * `attempted` = accepted `items` + `rejected` (the reject sink is not counted in
46
+ * `items`, so we add it back to size the denominator).
47
+ */
48
+ export declare function rejectsTolerated(policy: boolean | {
49
+ maxRatio: number;
50
+ } | undefined, rejected: number, items: number): boolean;
21
51
  export interface SyncSummary {
22
52
  runId: number;
23
53
  status: 'ok' | 'partial';
@@ -38,6 +68,12 @@ export interface SyncDeps {
38
68
  * inside a step feeds the step's reject accumulator.
39
69
  */
40
70
  makeCustomData: (sendBulk: SendBulk) => (name: string) => CustomDataHandle;
71
+ /**
72
+ * Optional dead-letter sink (E4). When provided, per-item REJECTIONS reported
73
+ * during a step are recorded here (capped per run) so an operator can inspect and
74
+ * later replay what the API refused. Best-effort: a store failure never aborts sync.
75
+ */
76
+ rejectedItems?: RejectedItemRepo;
41
77
  }
42
78
  /**
43
79
  * Runs the enabled sync steps of an integration. The cursor is managed HERE,
@@ -47,9 +83,15 @@ export interface SyncDeps {
47
83
  * GOLDEN RULE: the cursor only advances if the step had NO error.
48
84
  */
49
85
  export declare function runIntegrationSync<S>(integration: Pick<Integration<S>, 'syncSteps'>, base: IntegrationContext<S>, deps: SyncDeps, opts?: SyncOptions): Promise<SyncSummary>;
50
- /** Sync window: backfill on the first run / in full mode, otherwise from the cursor. */
86
+ /**
87
+ * Sync window: backfill on the first run / in full mode, otherwise from the cursor.
88
+ * On an incremental window, `overlapSeconds` (E9) shifts `since` back defensively so
89
+ * an item on the previous boundary is not missed (re-fetches are idempotent upserts).
90
+ * A backfill window is NOT shifted — it already starts far in the past.
91
+ */
51
92
  export declare function computeWindow(lastSyncedAt: string | null, opts: {
52
93
  now: () => Date;
53
94
  full: boolean;
54
95
  backfillDays: number;
96
+ overlapSeconds?: number;
55
97
  }): SyncWindow;
@@ -2,6 +2,48 @@ import { makeSendBulk } from '../sdk/send-bulk.js';
2
2
  import { paginate } from './paginate.js';
3
3
  import { mapWithConcurrency } from './concurrency.js';
4
4
  import { shouldAdvanceCursor } from './cursor.js';
5
+ /**
6
+ * Log ERROR once this many consecutive failures pile up on a cursor (E3), so a
7
+ * persistently broken source escalates from warn-noise to an actionable signal.
8
+ */
9
+ const ESCALATE_AT_FAILURES = 3;
10
+ /** Backoff is capped at ~24h: 2^(k-1) minutes, never longer than this. */
11
+ const MAX_BACKOFF_MS = 24 * 60 * 60_000;
12
+ /** Base backoff unit (E3): the k-th consecutive failure skips ticks for ~2^(k-1) minutes. */
13
+ const BACKOFF_BASE_MS = 60_000;
14
+ /** Per-run cap on dead-lettered items (E4) — a poison batch must not flood the store. */
15
+ const REJECTED_ITEMS_CAP_PER_RUN = 500;
16
+ /**
17
+ * Exponential backoff window after `n` consecutive failures: 2^(n-1) minutes,
18
+ * capped at 24h. n<=0 -> 0 (no wait). While `now < updated_at + window` the engine
19
+ * SKIPS the step-source; the cursor is untouched (GOLDEN RULE preserved).
20
+ */
21
+ export function backoffWindowMs(consecutiveFailures) {
22
+ if (consecutiveFailures <= 0)
23
+ return 0;
24
+ const ms = BACKOFF_BASE_MS * 2 ** (consecutiveFailures - 1);
25
+ return Math.min(ms, MAX_BACKOFF_MS);
26
+ }
27
+ /**
28
+ * Decides whether per-item rejections should be TOLERATED (cursor may advance),
29
+ * per the step's `tolerateRejects` policy (E8):
30
+ * - `undefined`/`false` -> never tolerate (strict hold);
31
+ * - `true` -> always tolerate (poison-pill escape hatch);
32
+ * - `{ maxRatio }` -> tolerate only while rejected/attempted <= maxRatio.
33
+ * `attempted` = accepted `items` + `rejected` (the reject sink is not counted in
34
+ * `items`, so we add it back to size the denominator).
35
+ */
36
+ export function rejectsTolerated(policy, rejected, items) {
37
+ if (!policy)
38
+ return false;
39
+ if (policy === true)
40
+ return true;
41
+ const attempted = items + rejected;
42
+ if (attempted <= 0)
43
+ return true; // nothing attempted -> nothing to hold on
44
+ const ratio = rejected / attempted;
45
+ return ratio <= policy.maxRatio;
46
+ }
5
47
  /**
6
48
  * Runs the enabled sync steps of an integration. The cursor is managed HERE,
7
49
  * never by the integration:
@@ -13,8 +55,11 @@ export async function runIntegrationSync(integration, base, deps, opts = {}) {
13
55
  const now = opts.now ?? (() => new Date());
14
56
  const backfillDays = opts.backfillDays ?? 365;
15
57
  const full = opts.fullBackfill ?? false;
58
+ const overlapSeconds = opts.overlapSeconds ?? 0;
16
59
  const runId = deps.runs.start(base.installationId);
17
60
  const summary = { runId, status: 'ok', steps: [], errors: [] };
61
+ // Per-run budget shared across all step-sources: caps total dead-lettered items (E4).
62
+ const deadLetterBudget = { remaining: REJECTED_ITEMS_CAP_PER_RUN };
18
63
  try {
19
64
  for (const step of integration.syncSteps) {
20
65
  if (!step.enabled(base.settings))
@@ -27,7 +72,14 @@ export async function runIntegrationSync(integration, base, deps, opts = {}) {
27
72
  continue;
28
73
  }
29
74
  for (const sourceKey of resolved.sourceKeys) {
30
- const stepSummary = await runOneSource(step, base, deps, sourceKey, { now, full, backfillDays });
75
+ const stepSummary = await runOneSource(step, base, deps, sourceKey, {
76
+ now,
77
+ full,
78
+ backfillDays,
79
+ overlapSeconds,
80
+ runId,
81
+ deadLetterBudget,
82
+ });
31
83
  summary.steps.push(stepSummary);
32
84
  summary.errors.push(...stepSummary.errors);
33
85
  }
@@ -51,13 +103,38 @@ async function resolveSources(step, base) {
51
103
  }
52
104
  async function runOneSource(step, base, deps, sourceKey, win) {
53
105
  const cursor = deps.cursors.get(base.installationId, step.entity, sourceKey) ?? null;
106
+ // E3 — EXPONENTIAL BACKOFF. A cursor that keeps failing must not hammer a broken
107
+ // upstream every tick. While inside the backoff window (based on the last failure's
108
+ // `updated_at`), skip this step-source entirely. The cursor is NOT touched (GOLDEN
109
+ // RULE preserved) and no error is added — the source will simply retry once the
110
+ // window elapses. `full` mode (an explicit operator backfill) bypasses backoff.
111
+ const failures = cursor?.consecutive_failures ?? 0;
112
+ if (!win.full && failures > 0 && cursor?.updated_at) {
113
+ const backoffMs = backoffWindowMs(failures);
114
+ const lastAt = Date.parse(cursor.updated_at + 'Z'); // stored UTC (SQLite datetime('now'))
115
+ const readyAt = Number.isNaN(lastAt) ? 0 : lastAt + backoffMs;
116
+ if (win.now().getTime() < readyAt) {
117
+ base.logger.info(`sync step '${step.entity}' in backoff — skipped this tick`, {
118
+ entity: step.entity,
119
+ sourceKey,
120
+ consecutive_failures: failures,
121
+ retry_in_ms: readyAt - win.now().getTime(),
122
+ });
123
+ return { entity: step.entity, sourceKey, items: 0, rejected: 0, errors: [], advanced: false, skippedBackoff: true };
124
+ }
125
+ }
54
126
  const window = computeWindow(cursor?.last_synced_at ?? null, win);
55
127
  // Per-step-run reject accumulator: `ctx.sendBulk` and `withSource(k).send` feed it,
56
128
  // so the engine can HOLD the cursor on data loss EVEN IF the step result omits the
57
129
  // count — safe by construction (the dev cannot forget to surface rejections).
58
130
  const rejects = { count: 0 };
59
- const stepSendBulk = makeSendBulk(base.spm, base.logger, (n) => {
131
+ const stepSendBulk = makeSendBulk(base.spm, base.logger, (n, items) => {
60
132
  rejects.count += n;
133
+ // E4 — DEAD-LETTER. Persist what the API refused (bounded by the per-run budget)
134
+ // so it survives the run for inspection/replay. Best-effort: a store hiccup here
135
+ // must never fail the sync.
136
+ if (deps.rejectedItems)
137
+ recordRejects(deps.rejectedItems, base, step, sourceKey, win, items, rejects.count);
61
138
  });
62
139
  const ctx = {
63
140
  ...base,
@@ -78,12 +155,22 @@ async function runOneSource(step, base, deps, sourceKey, win) {
78
155
  catch (e) {
79
156
  result = { items: 0, errors: [`fatal: ${errMsg(e)}`] };
80
157
  }
158
+ // A step that finished CLEAN (no error) yet returned no `advanceCursorTo` almost
159
+ // always means the author forgot to advance: the window will be replayed forever
160
+ // and the cursor is stuck. This is a silent correctness bug (duplicate work, no
161
+ // progress), so surface it loudly. A step that legitimately never advances (e.g.
162
+ // a pure fan-out) can suppress this by returning `advanceCursorTo: ctx.window.until`.
163
+ if (result.errors.length === 0 && result.advanceCursorTo == null) {
164
+ base.logger.warn(`sync step '${step.entity}' completed clean without advanceCursorTo — cursor not advanced (window will replay)`, { entity: step.entity, sourceKey, items: result.items });
165
+ }
81
166
  // GOLDEN RULE: do not advance the cursor on (a) a step error OR (b) unhandled
82
167
  // rejections (data the API did NOT persist). `tolerateRejects` only lifts (b) — for
83
168
  // a windowed stream a PERMANENT rejection ("poison pill") would otherwise freeze the
84
169
  // window forever — but rejections stay visible (the warn log + the summary count).
170
+ // E8: `{ maxRatio }` tolerates only while the reject ratio stays within budget.
85
171
  const cleanRun = shouldAdvanceCursor(result);
86
- const blockedByRejects = rejects.count > 0 && !step.tolerateRejects;
172
+ const tolerated = rejectsTolerated(step.tolerateRejects, rejects.count, result.items);
173
+ const blockedByRejects = rejects.count > 0 && !tolerated;
87
174
  const advanced = cleanRun && !blockedByRejects;
88
175
  const errors = [...result.errors];
89
176
  if (blockedByRejects) {
@@ -98,24 +185,77 @@ async function runOneSource(step, base, deps, sourceKey, win) {
98
185
  last_synced_at: clamped.toISOString(),
99
186
  last_status: 'ok',
100
187
  items: result.items,
188
+ // E3 — a clean advance clears the failure escalation.
189
+ consecutive_failures: 0,
101
190
  });
102
191
  }
103
192
  else if (errors.length > 0) {
104
193
  // Failed/blocked step: record the failure WITHOUT advancing the cursor, so the
105
- // same window is replayed next run (no silent data loss). The CursorWrite API
106
- // requires a last_synced_at, so we keep the previous value (or null if none yet).
194
+ // same window is replayed next run (no silent data loss). `last_synced_at` is
195
+ // nullable by contract (E11) keep the previous value (or null if never synced).
196
+ const nextFailures = failures + 1;
197
+ if (nextFailures >= ESCALATE_AT_FAILURES) {
198
+ // E3 — escalate to ERROR once failures pile up: a persistently broken source
199
+ // deserves an actionable signal, not just repeated warns.
200
+ base.logger.error(`sync step '${step.entity}' failing repeatedly`, {
201
+ entity: step.entity,
202
+ sourceKey,
203
+ consecutive_failures: nextFailures,
204
+ last_error: errors.join('; '),
205
+ });
206
+ }
107
207
  deps.cursors.set(base.installationId, step.entity, sourceKey, {
108
- // Keep the OLD cursor value (or null on a never-synced source); the column is
109
- // nullable at the DB level even though CursorWrite types it as a string.
110
- last_synced_at: (cursor?.last_synced_at ?? null),
208
+ last_synced_at: cursor?.last_synced_at ?? null,
111
209
  last_status: 'error',
112
210
  last_error: errors.join('; '),
113
211
  items: result.items,
212
+ consecutive_failures: nextFailures,
114
213
  });
115
214
  }
116
215
  return { entity: step.entity, sourceKey, items: result.items, rejected: rejects.count, errors, advanced };
117
216
  }
118
- /** Sync window: backfill on the first run / in full mode, otherwise from the cursor. */
217
+ /**
218
+ * Dead-letters the rejected items reported by a push (E4), honouring the per-run
219
+ * budget. Best-effort: any store error is swallowed (a broken dead-letter must never
220
+ * fail sync) — the rejection is already surfaced via the warn log + cursor hold.
221
+ */
222
+ function recordRejects(repo, base, step, sourceKey, win, items, reasonCount) {
223
+ if (win.deadLetterBudget.remaining <= 0)
224
+ return;
225
+ try {
226
+ for (const item of items) {
227
+ if (win.deadLetterBudget.remaining <= 0)
228
+ break;
229
+ win.deadLetterBudget.remaining -= 1;
230
+ repo.add({
231
+ installation_id: base.installationId,
232
+ run_id: win.runId,
233
+ entity: step.entity,
234
+ source_key: sourceKey,
235
+ payload_json: safeJson(item),
236
+ reason: `rejected during ${step.entity} push`,
237
+ });
238
+ }
239
+ }
240
+ catch (e) {
241
+ base.logger.warn('dead-letter write failed (best-effort)', { entity: step.entity, error: errMsg(e), reasonCount });
242
+ }
243
+ }
244
+ /** JSON-stringifies an item, falling back to a placeholder on a non-serializable value. */
245
+ function safeJson(v) {
246
+ try {
247
+ return JSON.stringify(v);
248
+ }
249
+ catch {
250
+ return '"[unserializable rejected item]"';
251
+ }
252
+ }
253
+ /**
254
+ * Sync window: backfill on the first run / in full mode, otherwise from the cursor.
255
+ * On an incremental window, `overlapSeconds` (E9) shifts `since` back defensively so
256
+ * an item on the previous boundary is not missed (re-fetches are idempotent upserts).
257
+ * A backfill window is NOT shifted — it already starts far in the past.
258
+ */
119
259
  export function computeWindow(lastSyncedAt, opts) {
120
260
  const until = opts.now();
121
261
  if (opts.full || !lastSyncedAt) {
@@ -123,7 +263,11 @@ export function computeWindow(lastSyncedAt, opts) {
123
263
  since.setDate(since.getDate() - opts.backfillDays);
124
264
  return { since, until };
125
265
  }
126
- return { since: new Date(lastSyncedAt), until };
266
+ const since = new Date(lastSyncedAt);
267
+ const overlap = opts.overlapSeconds ?? 0;
268
+ if (overlap > 0)
269
+ since.setTime(since.getTime() - overlap * 1000);
270
+ return { since, until };
127
271
  }
128
272
  function errMsg(e) {
129
273
  return e instanceof Error ? e.message : String(e);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shopimind/integration-kit-js",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Foundation for building ShopiMind integrations: a runtime plus typed, once-tested primitives (HMAC webhook signatures, encryption, log redaction, a safe cursor-based sync engine, pagination/concurrency, persistence, ShopiMind SDK re-export, idempotent provisioning, secured inbound middleware, HTTP server). An integration only writes pure functions and declarations passed to defineIntegration.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "repository": {