@shopimind/integration-kit-js 1.3.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.
- package/README.md +1 -1
- package/dist/contracts/lifecycle.d.ts +31 -1
- package/dist/contracts/sdk.d.ts +24 -4
- package/dist/http/routes.d.ts +11 -0
- package/dist/http/routes.js +53 -13
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/integration/define-bulk-step.d.ts +41 -0
- package/dist/integration/define-bulk-step.js +44 -0
- package/dist/integration/define-integration.d.ts +23 -0
- package/dist/integration/define-integration.js +23 -0
- package/dist/integration/types.d.ts +9 -1
- package/dist/lifecycle/dispatcher.d.ts +6 -1
- package/dist/lifecycle/dispatcher.js +8 -9
- package/dist/provisioning/ensure.d.ts +11 -5
- package/dist/provisioning/ensure.js +44 -8
- package/dist/provisioning/runner.d.ts +11 -1
- package/dist/provisioning/runner.js +85 -13
- package/dist/runtime/create-app.d.ts +12 -1
- package/dist/runtime/create-app.js +22 -3
- package/dist/runtime/health.d.ts +62 -0
- package/dist/runtime/health.js +70 -0
- package/dist/runtime/outbound.d.ts +71 -0
- package/dist/runtime/outbound.js +90 -0
- package/dist/security/signature.d.ts +9 -0
- package/dist/security/signature.js +25 -0
- package/dist/store/migrations.js +37 -0
- package/dist/store/repositories.d.ts +35 -1
- package/dist/store/repositories.js +66 -2
- package/dist/store/types.d.ts +25 -1
- package/dist/sync/engine.d.ts +44 -2
- package/dist/sync/engine.js +154 -10
- package/package.json +1 -1
|
@@ -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,
|
|
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,
|
|
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
|
}
|
package/dist/store/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/sync/engine.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/sync/engine.js
CHANGED
|
@@ -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, {
|
|
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
|
|
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).
|
|
106
|
-
//
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
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": {
|