@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.
@@ -1,7 +1,7 @@
1
1
  import { SpmOrdersStatuses, SpmHelpers } from '@shopimind/sdk-js';
2
- import { validateProvisioningEvents } from '../integration/define-integration.js';
2
+ import { validateProvisioningEvents, validateCustomDataDefinition } from '../integration/define-integration.js';
3
3
  import { ensureDataSource, ensureCustomDataDefinition, ensureEvent } from './ensure.js';
4
- export async function runProvisioning(client, plan) {
4
+ export async function runProvisioning(client, plan, logger) {
5
5
  const result = { sourceIds: {}, defIds: {}, events: 0, orderStatuses: 0, errors: [] };
6
6
  for (const ds of plan.dataSources ?? []) {
7
7
  try {
@@ -13,9 +13,19 @@ export async function runProvisioning(client, plan) {
13
13
  result.errors.push(`source ${ds.key}: ${errMsg(e)}`);
14
14
  }
15
15
  }
16
- for (const def of plan.customData ?? []) {
16
+ // E10 order the custom-data plan so every custom→custom target is CREATED BEFORE
17
+ // the definition that references it (its id must exist to resolve the relationship).
18
+ // A topological sort removes the "declare X before Y" foot-gun entirely; a genuine
19
+ // dependency CYCLE is a hard error (unresolvable). A relationship to an out-of-plan
20
+ // custom name is left as-is and warned about (see resolveCustomRelationTargets).
21
+ const orderedCustomData = topoSortCustomData(plan.customData ?? []);
22
+ for (const def of orderedCustomData) {
17
23
  try {
18
- result.defIds[def.name] = await ensureCustomDataDefinition(client, def);
24
+ // E10 structural guards before the network call: unique_keys ⊆ fields and
25
+ // relationships.sourceField ∈ fields. A misconfig fails here with a precise
26
+ // message instead of an opaque API rejection.
27
+ validateCustomDataDefinition(def);
28
+ result.defIds[def.name] = await ensureCustomDataDefinition(client, resolveCustomRelationTargets(def, result.defIds, logger));
19
29
  }
20
30
  catch (e) {
21
31
  result.errors.push(`def ${def.name}: ${errMsg(e)}`);
@@ -35,7 +45,16 @@ export async function runProvisioning(client, plan) {
35
45
  }
36
46
  if (plan.orderStatuses && plan.orderStatuses.length > 0) {
37
47
  try {
38
- const env = await SpmOrdersStatuses.bulkSave(client, plan.orderStatuses, { chunk: true });
48
+ // E11 fill the technical bookkeeping fields the API needs but the author
49
+ // should not have to hand-write. Explicit values are preserved.
50
+ const nowIso = new Date().toISOString();
51
+ const statuses = plan.orderStatuses.map((s) => ({
52
+ ...s,
53
+ is_deleted: s.is_deleted ?? false,
54
+ created_at: s.created_at ?? nowIso,
55
+ updated_at: s.updated_at ?? nowIso,
56
+ }));
57
+ const env = await SpmOrdersStatuses.bulkSave(client, statuses, { chunk: true });
39
58
  result.orderStatuses = SpmHelpers.extractCounts(env).sent;
40
59
  }
41
60
  catch (e) {
@@ -44,6 +63,77 @@ export async function runProvisioning(client, plan) {
44
63
  }
45
64
  return result;
46
65
  }
66
+ /**
67
+ * Resolves a custom relationship's `targetSchema` declared by NAME (a sibling
68
+ * definition in the same plan) to the sibling's numeric id — mirroring how
69
+ * dataSources resolve `parentKey` -> `parent_id`. Thanks to the topological sort
70
+ * (E10) the sibling is always created before this definition. A `targetSchema` that
71
+ * is already numeric is left untouched; a custom target that is NON-NUMERIC and NOT
72
+ * in the plan cannot be resolved to an id — it is left as-is and WARNED about (E10).
73
+ */
74
+ function resolveCustomRelationTargets(def, defIds, logger) {
75
+ if (!def.relationships?.length)
76
+ return def;
77
+ return {
78
+ ...def,
79
+ relationships: def.relationships.map((r) => {
80
+ if (r.targetSchemaType !== 'custom')
81
+ return r;
82
+ if (defIds[r.targetSchema] != null)
83
+ return { ...r, targetSchema: String(defIds[r.targetSchema]) };
84
+ // Not resolved by name. If it is not already a numeric id, it references an
85
+ // out-of-plan definition we cannot resolve here — surface it rather than
86
+ // silently shipping an unresolvable target to the API.
87
+ if (!/^\d+$/.test(String(r.targetSchema))) {
88
+ logger?.warn(`custom data '${def.name}': relationship target '${r.targetSchema}' is not in this plan and not a numeric id — left unresolved`, { definition: def.name, sourceField: r.sourceField, targetSchema: r.targetSchema });
89
+ }
90
+ return r;
91
+ }),
92
+ };
93
+ }
94
+ /**
95
+ * Topologically sorts the custom-data plan so a definition is always emitted AFTER
96
+ * the sibling definitions it references via custom→custom relationships (E10). Only
97
+ * intra-plan custom targets create an edge; system targets and out-of-plan/numeric
98
+ * targets do not. Throws on a dependency cycle (unresolvable ordering). Definitions
99
+ * without in-plan dependencies keep their declaration order (stable).
100
+ */
101
+ export function topoSortCustomData(defs) {
102
+ if (defs.length <= 1)
103
+ return defs;
104
+ const byName = new Map(defs.map((d) => [d.name, d]));
105
+ const deps = new Map();
106
+ for (const d of defs) {
107
+ const targets = (d.relationships ?? [])
108
+ .filter((r) => r.targetSchemaType === 'custom' && byName.has(r.targetSchema) && r.targetSchema !== d.name)
109
+ .map((r) => r.targetSchema);
110
+ deps.set(d.name, [...new Set(targets)]);
111
+ }
112
+ const sorted = [];
113
+ const state = new Map();
114
+ const stack = [];
115
+ const visit = (name) => {
116
+ const s = state.get(name);
117
+ if (s === 'done')
118
+ return;
119
+ if (s === 'visiting') {
120
+ throw new Error(`custom data: dependency cycle detected (${[...stack, name].join(' -> ')})`);
121
+ }
122
+ state.set(name, 'visiting');
123
+ stack.push(name);
124
+ for (const dep of deps.get(name) ?? [])
125
+ visit(dep);
126
+ stack.pop();
127
+ state.set(name, 'done');
128
+ const def = byName.get(name);
129
+ if (def)
130
+ sorted.push(def);
131
+ };
132
+ // Iterate in declaration order so independent definitions keep their relative order.
133
+ for (const d of defs)
134
+ visit(d.name);
135
+ return sorted;
136
+ }
47
137
  function errMsg(e) {
48
138
  return e instanceof Error ? e.message : String(e);
49
139
  }
@@ -7,7 +7,12 @@ import { type Logger } from '../logging/logger.js';
7
7
  import { type SyncSummary } from '../sync/engine.js';
8
8
  export interface CreateAppOptions<S> {
9
9
  databasePath: string;
10
- webhookSecret: string;
10
+ /**
11
+ * Webhook signing secret(s). A single string is the common case; pass an array to
12
+ * open a secret ROTATION window (E6) — a webhook signed with ANY listed secret is
13
+ * accepted while you swap `current` -> `next`. Backward compatible with a string.
14
+ */
15
+ webhookSecret: string | string[];
11
16
  /**
12
17
  * Override for the ShopiMind SDK base URL (otherwise env `SHOPIMIND_CORE_API_BASE`,
13
18
  * then `https://core.shopimind.com`). Useful in tests / preprod.
@@ -28,6 +33,12 @@ export interface CreateAppOptions<S> {
28
33
  adminToken?: string | null;
29
34
  signatureToleranceSeconds?: number;
30
35
  backfillDays?: number;
36
+ /**
37
+ * Defensive overlap (E9) applied to every INCREMENTAL sync window: shifts `since`
38
+ * back by this many seconds so a boundary item is not missed. Idempotent (upserts).
39
+ * Default 0 (no overlap).
40
+ */
41
+ overlapSeconds?: number;
31
42
  port?: number;
32
43
  host?: string;
33
44
  logger?: Logger;
@@ -11,6 +11,7 @@ import { makeWithSource } from '../sdk/source-scope.js';
11
11
  import { makeCustomData } from '../sdk/custom-data-scope.js';
12
12
  import { makeSendBulk } from '../sdk/send-bulk.js';
13
13
  import { createRateLimiter } from './rate-limiter.js';
14
+ import { buildHealthReport, buildOverview } from './health.js';
14
15
  import { createServer } from '../http/server.js';
15
16
  import { buildRoutes } from '../http/routes.js';
16
17
  /**
@@ -77,7 +78,13 @@ export function createIntegrationApp(integration, opts) {
77
78
  runs: repos.runs,
78
79
  makeSource: (sb) => makeWithSource(repos.state, id, PROVISIONING_KEY, sb),
79
80
  makeCustomData: (sb) => makeCustomData(repos.state, id, PROVISIONING_KEY, sb, base.spm),
80
- }, { fullBackfill: o?.full ?? false, backfillDays });
81
+ // E4 feed the dead-letter sink so rejected items survive the run.
82
+ rejectedItems: repos.rejectedItems,
83
+ }, {
84
+ fullBackfill: o?.full ?? false,
85
+ backfillDays,
86
+ ...(opts.overlapSeconds != null ? { overlapSeconds: opts.overlapSeconds } : {}),
87
+ });
81
88
  }
82
89
  finally {
83
90
  running.delete(id);
@@ -117,6 +124,11 @@ export function createIntegrationApp(integration, opts) {
117
124
  webhookRateLimit,
118
125
  runSyncForInstall: (id, full) => runSyncOnce(id, { full }),
119
126
  recentRuns: (id) => repos.runs.recent(id),
127
+ // E4 — dead-lettered rejects for an installation (admin, bounded).
128
+ rejectedItems: (id, limit) => repos.rejectedItems.listByInstallation(id, limit),
129
+ // E5 — enriched health probe + admin overview.
130
+ healthReport: () => buildHealthReport(db, repos, opts.now ? opts.now() : Date.now()),
131
+ overview: () => buildOverview(repos, opts.now ? opts.now() : Date.now()),
120
132
  inbound: {
121
133
  integration,
122
134
  repos,
@@ -163,8 +175,15 @@ export function createIntegrationApp(integration, opts) {
163
175
  const log = repos.webhookLog.purgeOlderThan(retentionDays);
164
176
  const seen = repos.webhookSeen.purgeOlderThan(retentionDays);
165
177
  const inbound = repos.inboundEvents.purgeOlderThan(retentionDays);
166
- if (log + seen + inbound > 0) {
167
- logger.info('retention purge', { webhook_log: log, webhook_seen: seen, inbound_event: inbound, retentionDays });
178
+ const rejected = repos.rejectedItems.purgeOlderThan(retentionDays); // E4 dead-letter (90j)
179
+ if (log + seen + inbound + rejected > 0) {
180
+ logger.info('retention purge', {
181
+ webhook_log: log,
182
+ webhook_seen: seen,
183
+ inbound_event: inbound,
184
+ rejected_item: rejected,
185
+ retentionDays,
186
+ });
168
187
  }
169
188
  }
170
189
  catch (e) {
@@ -0,0 +1,62 @@
1
+ import type { Db } from '../store/db.js';
2
+ import type { Repositories } from '../store/repositories.js';
3
+ /**
4
+ * Health & overview reports (E5).
5
+ *
6
+ * `/health` is an UNAUTHENTICATED probe endpoint: it must stay coarse (no secrets,
7
+ * no PII beyond opaque installation ids) and cheap. It answers three questions an
8
+ * orchestrator / on-call needs:
9
+ * - is the DB reachable? (a `SELECT 1` ping)
10
+ * - is any active installation stalled? (age of its last finished run)
11
+ * - are cursors stuck? (count of cursors in the `error` status)
12
+ * The snapshot is `degraded` (HTTP 503) when the DB is unreachable, or a run is
13
+ * older than `staleRunThresholdMs`, or cursors-in-error exceeds `maxCursorsInError`.
14
+ *
15
+ * `/admin/overview` is the AUTHENTICATED, richer JSON counterpart (installations,
16
+ * their latest run, recent webhooks) for a human/dashboard.
17
+ */
18
+ export interface HealthThresholds {
19
+ /** A last run older than this marks the installation (and the snapshot) stale. Default 6h. */
20
+ staleRunThresholdMs?: number;
21
+ /** More cursors-in-error than this degrades the snapshot. Default 10. */
22
+ maxCursorsInError?: number;
23
+ }
24
+ export interface HealthReport {
25
+ status: 'ok' | 'degraded';
26
+ db: 'ok' | 'error';
27
+ active_installations: number;
28
+ cursors_in_error: number;
29
+ /** Per active installation: age (ms) of the last finished run (null if never run). */
30
+ installations: Array<{
31
+ installation_id: string;
32
+ last_run_at: string | null;
33
+ last_run_age_ms: number | null;
34
+ last_run_status: string | null;
35
+ stale: boolean;
36
+ }>;
37
+ checked_at: string;
38
+ }
39
+ export declare function buildHealthReport(db: Db, repos: Repositories, nowMs: number, thresholds?: HealthThresholds): HealthReport;
40
+ export interface OverviewReport {
41
+ active_installations: number;
42
+ cursors_in_error: number;
43
+ installations: Array<{
44
+ installation_id: string;
45
+ status: string;
46
+ shop_domain: string | null;
47
+ last_run: {
48
+ id: number;
49
+ status: string;
50
+ started_at: string;
51
+ finished_at: string | null;
52
+ } | null;
53
+ }>;
54
+ recent_webhooks: Array<{
55
+ event: string | null;
56
+ installation_id: string | null;
57
+ signature_ok: number;
58
+ created_at: string;
59
+ }>;
60
+ generated_at: string;
61
+ }
62
+ export declare function buildOverview(repos: Repositories, nowMs: number): OverviewReport;
@@ -0,0 +1,70 @@
1
+ export function buildHealthReport(db, repos, nowMs, thresholds = {}) {
2
+ const staleThreshold = thresholds.staleRunThresholdMs ?? 6 * 60 * 60_000;
3
+ const maxInError = thresholds.maxCursorsInError ?? 10;
4
+ let dbOk = true;
5
+ try {
6
+ db.prepare('SELECT 1').get();
7
+ }
8
+ catch {
9
+ dbOk = false;
10
+ }
11
+ // If the DB is down, everything else is unknowable — report degraded immediately
12
+ // rather than throwing (the probe must always answer).
13
+ if (!dbOk) {
14
+ return {
15
+ status: 'degraded',
16
+ db: 'error',
17
+ active_installations: 0,
18
+ cursors_in_error: 0,
19
+ installations: [],
20
+ checked_at: new Date(nowMs).toISOString(),
21
+ };
22
+ }
23
+ const active = repos.installs.listActive();
24
+ const cursorsInError = repos.cursors.countInError();
25
+ const installations = active.map((inst) => {
26
+ const last = repos.runs.recent(inst.installation_id, 1)[0];
27
+ const finishedAt = last?.finished_at ?? null;
28
+ // SQLite datetime('now') stores UTC without a zone suffix; append 'Z' to parse.
29
+ const ageMs = finishedAt ? nowMs - Date.parse(finishedAt + 'Z') : null;
30
+ const stale = ageMs != null && ageMs > staleThreshold;
31
+ return {
32
+ installation_id: inst.installation_id,
33
+ last_run_at: finishedAt,
34
+ last_run_age_ms: ageMs != null && Number.isFinite(ageMs) ? ageMs : null,
35
+ last_run_status: last?.status ?? null,
36
+ stale,
37
+ };
38
+ });
39
+ const anyStale = installations.some((i) => i.stale);
40
+ const degraded = cursorsInError > maxInError || anyStale;
41
+ return {
42
+ status: degraded ? 'degraded' : 'ok',
43
+ db: 'ok',
44
+ active_installations: active.length,
45
+ cursors_in_error: cursorsInError,
46
+ installations,
47
+ checked_at: new Date(nowMs).toISOString(),
48
+ };
49
+ }
50
+ export function buildOverview(repos, nowMs) {
51
+ const active = repos.installs.listActive();
52
+ const installations = active.map((inst) => {
53
+ const last = repos.runs.recent(inst.installation_id, 1)[0];
54
+ return {
55
+ installation_id: inst.installation_id,
56
+ status: inst.status,
57
+ shop_domain: inst.shop_domain,
58
+ last_run: last
59
+ ? { id: last.id, status: last.status, started_at: last.started_at, finished_at: last.finished_at }
60
+ : null,
61
+ };
62
+ });
63
+ return {
64
+ active_installations: active.length,
65
+ cursors_in_error: repos.cursors.countInError(),
66
+ installations,
67
+ recent_webhooks: repos.webhookLog.recent(20),
68
+ generated_at: new Date(nowMs).toISOString(),
69
+ };
70
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Outbound-call helpers (E7) — for a connector talking to its PARTNER's API
3
+ * (Hiboutik, etc.), not to ShopiMind. They are the outbound counterparts of the
4
+ * inbound rate-limiter the runtime already uses for its own routes.
5
+ *
6
+ * - `makeOutboundLimiter` wraps the kit's token-bucket into an async gate that
7
+ * RESOLVES when a token is free (instead of returning a boolean), so a caller
8
+ * just `await limiter()` before each partner request — bounding request rate to
9
+ * stay under the partner's quota.
10
+ * - `fetchWithRetry` retries transient failures (429 / 5xx / network) with
11
+ * EXPONENTIAL BACKOFF + JITTER and honours a `Retry-After` header when present —
12
+ * the correct, server-directed way to back off, instead of a fixed linear sleep.
13
+ */
14
+ export interface OutboundLimiterOptions {
15
+ /** Max burst (tokens). Default 20. */
16
+ capacity?: number;
17
+ /** Sustained rate (tokens/second). Default 5. */
18
+ refillPerSec?: number;
19
+ /** Injectable clock (ms). Defaults to `Date.now`. */
20
+ now?: () => number;
21
+ /** Injectable sleep (ms). Defaults to a real timer. */
22
+ sleep?: (ms: number) => Promise<void>;
23
+ /** Poll interval (ms) while waiting for a token. Default 25. */
24
+ pollMs?: number;
25
+ }
26
+ /**
27
+ * Builds an async rate gate keyed by an optional string (default a single shared
28
+ * key). `await limiter()` resolves once a token is available. Reuses the kit's
29
+ * token-bucket so behaviour matches the inbound limiter.
30
+ */
31
+ export declare function makeOutboundLimiter(opts?: OutboundLimiterOptions): (key?: string) => Promise<void>;
32
+ export interface FetchRetryOptions {
33
+ /** Max attempts (including the first). Default 4. */
34
+ maxAttempts?: number;
35
+ /** Base backoff (ms) for the exponential schedule. Default 500. */
36
+ baseDelayMs?: number;
37
+ /** Cap on any single backoff (ms). Default 30_000. */
38
+ maxDelayMs?: number;
39
+ /** Injectable sleep (ms). Defaults to a real timer. */
40
+ sleep?: (ms: number) => Promise<void>;
41
+ /** Injectable jitter in [0,1). Defaults to `Math.random`. */
42
+ random?: () => number;
43
+ /** Predicate: should this HTTP status be retried? Default 429 or >=500. */
44
+ retryOnStatus?: (status: number) => boolean;
45
+ }
46
+ /** Minimal response shape `fetchWithRetry` needs (compatible with the WHATWG `Response`). */
47
+ export interface RetriableResponse {
48
+ status: number;
49
+ headers: {
50
+ get(name: string): string | null;
51
+ };
52
+ }
53
+ /**
54
+ * Parses a `Retry-After` header: either a delay in SECONDS, or an HTTP date. Returns
55
+ * the delay in ms, or `null` if absent/unparseable. `nowMs` lets the date form be tested.
56
+ */
57
+ export declare function parseRetryAfterMs(value: string | null, nowMs?: number): number | null;
58
+ /** Exponential backoff with full jitter, capped — bounded by `Retry-After` when the server sent one. */
59
+ export declare function backoffDelayMs(attempt: number, opts?: {
60
+ baseDelayMs?: number;
61
+ maxDelayMs?: number;
62
+ random?: () => number;
63
+ retryAfterMs?: number | null;
64
+ }): number;
65
+ /**
66
+ * Runs `doFetch` (returning any WHATWG-`Response`-like object) with retry on 429 /
67
+ * 5xx / network errors, honouring `Retry-After` + exponential backoff with jitter.
68
+ * The final response (or thrown error) after the last attempt is returned/rethrown.
69
+ * `doFetch` is a thunk so the caller controls the URL/options/`fetch` implementation.
70
+ */
71
+ export declare function fetchWithRetry<R extends RetriableResponse>(doFetch: () => Promise<R>, opts?: FetchRetryOptions): Promise<R>;
@@ -0,0 +1,90 @@
1
+ import { createRateLimiter } from './rate-limiter.js';
2
+ const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
3
+ /**
4
+ * Builds an async rate gate keyed by an optional string (default a single shared
5
+ * key). `await limiter()` resolves once a token is available. Reuses the kit's
6
+ * token-bucket so behaviour matches the inbound limiter.
7
+ */
8
+ export function makeOutboundLimiter(opts = {}) {
9
+ const tryTake = createRateLimiter({
10
+ capacity: opts.capacity ?? 20,
11
+ refillPerSec: opts.refillPerSec ?? 5,
12
+ ...(opts.now ? { now: opts.now } : {}),
13
+ });
14
+ const sleep = opts.sleep ?? realSleep;
15
+ const pollMs = opts.pollMs ?? 25;
16
+ return async (key = 'default') => {
17
+ // Spin-wait with a short sleep: the bucket refills over time, so a blocked call
18
+ // eventually gets a token. Cheap and dependency-free (no queue bookkeeping).
19
+ while (!tryTake(key)) {
20
+ await sleep(pollMs);
21
+ }
22
+ };
23
+ }
24
+ const defaultRetryOnStatus = (status) => status === 429 || status >= 500;
25
+ /**
26
+ * Parses a `Retry-After` header: either a delay in SECONDS, or an HTTP date. Returns
27
+ * the delay in ms, or `null` if absent/unparseable. `nowMs` lets the date form be tested.
28
+ */
29
+ export function parseRetryAfterMs(value, nowMs = Date.now()) {
30
+ if (!value)
31
+ return null;
32
+ const seconds = Number(value);
33
+ if (Number.isFinite(seconds))
34
+ return Math.max(0, seconds * 1000);
35
+ const dateMs = Date.parse(value);
36
+ if (!Number.isNaN(dateMs))
37
+ return Math.max(0, dateMs - nowMs);
38
+ return null;
39
+ }
40
+ /** Exponential backoff with full jitter, capped — bounded by `Retry-After` when the server sent one. */
41
+ export function backoffDelayMs(attempt, opts = {}) {
42
+ const base = opts.baseDelayMs ?? 500;
43
+ const max = opts.maxDelayMs ?? 30_000;
44
+ const random = opts.random ?? Math.random;
45
+ // Server-directed wait wins when present (respect its explicit instruction).
46
+ if (opts.retryAfterMs != null)
47
+ return Math.min(opts.retryAfterMs, max);
48
+ const exp = Math.min(base * 2 ** attempt, max);
49
+ return Math.floor(random() * exp); // full jitter
50
+ }
51
+ /**
52
+ * Runs `doFetch` (returning any WHATWG-`Response`-like object) with retry on 429 /
53
+ * 5xx / network errors, honouring `Retry-After` + exponential backoff with jitter.
54
+ * The final response (or thrown error) after the last attempt is returned/rethrown.
55
+ * `doFetch` is a thunk so the caller controls the URL/options/`fetch` implementation.
56
+ */
57
+ export async function fetchWithRetry(doFetch, opts = {}) {
58
+ const maxAttempts = Math.max(1, opts.maxAttempts ?? 4);
59
+ const sleep = opts.sleep ?? realSleep;
60
+ const retryOn = opts.retryOnStatus ?? defaultRetryOnStatus;
61
+ let lastError;
62
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
63
+ const isLast = attempt === maxAttempts - 1;
64
+ try {
65
+ const res = await doFetch();
66
+ if (!retryOn(res.status) || isLast)
67
+ return res;
68
+ const retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after'));
69
+ await sleep(backoffDelayMs(attempt, {
70
+ ...(opts.baseDelayMs != null ? { baseDelayMs: opts.baseDelayMs } : {}),
71
+ ...(opts.maxDelayMs != null ? { maxDelayMs: opts.maxDelayMs } : {}),
72
+ ...(opts.random ? { random: opts.random } : {}),
73
+ retryAfterMs,
74
+ }));
75
+ }
76
+ catch (e) {
77
+ // Network-level error (no response): retry unless this was the last attempt.
78
+ lastError = e;
79
+ if (isLast)
80
+ throw e;
81
+ await sleep(backoffDelayMs(attempt, {
82
+ ...(opts.baseDelayMs != null ? { baseDelayMs: opts.baseDelayMs } : {}),
83
+ ...(opts.maxDelayMs != null ? { maxDelayMs: opts.maxDelayMs } : {}),
84
+ ...(opts.random ? { random: opts.random } : {}),
85
+ }));
86
+ }
87
+ }
88
+ // Unreachable (the loop returns or throws), but satisfies the type checker.
89
+ throw lastError ?? new Error('fetchWithRetry: exhausted attempts');
90
+ }
@@ -22,6 +22,15 @@ export interface SignatureOptions {
22
22
  }
23
23
  /** Verifies a ShopiMind -> integration webhook (`x-shopimind-*` headers). */
24
24
  export declare function verifyShopimindSignature(rawBody: string, headers: Record<string, string | string[] | undefined>, opts: SignatureOptions): SignatureCheck;
25
+ /**
26
+ * Verifies a ShopiMind -> integration webhook against ONE OR SEVERAL secrets (E6).
27
+ * During a secret ROTATION the connector runs `[current, next]` (or `[next, current]`)
28
+ * for a window: a request signed with EITHER passes. The check succeeds on the first
29
+ * secret that verifies; if all fail, the LAST failure reason is returned (the timestamp
30
+ * checks are secret-independent, so the reason is representative). A single string
31
+ * behaves exactly like {@link verifyShopimindSignature} — fully backward compatible.
32
+ */
33
+ export declare function verifyShopimindSignatureMulti(rawBody: string, headers: Record<string, string | string[] | undefined>, secrets: string | string[], opts?: Omit<SignatureOptions, 'secret'>): SignatureCheck;
25
34
  /**
26
35
  * Verifies an inbound integrator-app -> integration call (`x-integration-*` headers).
27
36
  * The `secret` is resolved PER-INSTALLATION by the caller.
@@ -12,6 +12,31 @@ export function verifyShopimindSignature(rawBody, headers, opts) {
12
12
  now: opts.now,
13
13
  });
14
14
  }
15
+ /**
16
+ * Verifies a ShopiMind -> integration webhook against ONE OR SEVERAL secrets (E6).
17
+ * During a secret ROTATION the connector runs `[current, next]` (or `[next, current]`)
18
+ * for a window: a request signed with EITHER passes. The check succeeds on the first
19
+ * secret that verifies; if all fail, the LAST failure reason is returned (the timestamp
20
+ * checks are secret-independent, so the reason is representative). A single string
21
+ * behaves exactly like {@link verifyShopimindSignature} — fully backward compatible.
22
+ */
23
+ export function verifyShopimindSignatureMulti(rawBody, headers, secrets, opts = {}) {
24
+ const list = (Array.isArray(secrets) ? secrets : [secrets]).filter((s) => s !== '' && s != null);
25
+ if (list.length === 0)
26
+ return { ok: false, reason: 'no_secret_configured' };
27
+ let last = { ok: false, reason: 'unverified' };
28
+ for (const secret of list) {
29
+ const check = verifyShopimindSignature(rawBody, headers, {
30
+ secret,
31
+ ...(opts.toleranceSeconds != null ? { toleranceSeconds: opts.toleranceSeconds } : {}),
32
+ ...(opts.now ? { now: opts.now } : {}),
33
+ });
34
+ if (check.ok)
35
+ return check;
36
+ last = check;
37
+ }
38
+ return last;
39
+ }
15
40
  /**
16
41
  * Verifies an inbound integrator-app -> integration call (`x-integration-*` headers).
17
42
  * The `secret` is resolved PER-INSTALLATION by the caller.
@@ -125,4 +125,41 @@ export const MIGRATIONS = [
125
125
  );
126
126
  `,
127
127
  },
128
+ {
129
+ version: 5,
130
+ name: 'cursor_failure_escalation',
131
+ sql: `
132
+ -- Repeated-failure escalation (E3). A run-level counter per cursor:
133
+ -- - incremented every time the step fails/holds (last_status = 'error'),
134
+ -- - reset to 0 on a clean advance.
135
+ -- The engine uses it for EXPONENTIAL backoff (skip 2^(k-1) ticks, capped ~24h)
136
+ -- so a persistently-failing source stops hammering a broken upstream every tick,
137
+ -- and escalates to an ERROR log at the 3rd consecutive failure. The GOLDEN RULE
138
+ -- is untouched: this only decides WHEN to retry, never whether the cursor moves.
139
+ ALTER TABLE sync_cursor ADD COLUMN consecutive_failures INTEGER NOT NULL DEFAULT 0;
140
+ `,
141
+ },
142
+ {
143
+ version: 6,
144
+ name: 'rejected_item_dead_letter',
145
+ sql: `
146
+ -- Dead-letter of per-item REJECTIONS (E4). When a bulk push reports rejected
147
+ -- items (validation, permanent-ish), the engine records them here instead of
148
+ -- letting the warn scroll away — so an operator can inspect what the API refused
149
+ -- and, later, replay it. Bounded per run (the engine caps inserts at 500/run) to
150
+ -- keep a poison batch from flooding the store. Subject to the same retention purge
151
+ -- as the other log tables.
152
+ CREATE TABLE rejected_item (
153
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
154
+ installation_id TEXT NOT NULL,
155
+ run_id INTEGER,
156
+ entity TEXT,
157
+ source_key TEXT,
158
+ payload_json TEXT,
159
+ reason TEXT,
160
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
161
+ );
162
+ CREATE INDEX idx_rejected_item_install ON rejected_item(installation_id, created_at);
163
+ `,
164
+ },
128
165
  ];
@@ -1,6 +1,6 @@
1
1
  import type DatabaseT from 'better-sqlite3';
2
2
  import type { SecretCipher } from '../security/crypto.js';
3
- import type { InstallRow, InstallUpsert, CursorRow, CursorWrite, SyncRunRow, InboundEventRow } from './types.js';
3
+ import type { InstallRow, InstallUpsert, CursorRow, CursorWrite, SyncRunRow, InboundEventRow, RejectedItemRow } from './types.js';
4
4
  /** Installs (one row per installation). COALESCE upsert: a null field does not overwrite. */
5
5
  export declare class InstallRepo {
6
6
  private readonly db;
@@ -28,6 +28,13 @@ export declare class WebhookLogRepo {
28
28
  }): void;
29
29
  /** Retention: deletes log rows older than `days` days. Returns the number of rows removed. */
30
30
  purgeOlderThan(days: number): number;
31
+ /** Most recent webhook log entries (newest first) — for /admin/overview (E5). */
32
+ recent(limit?: number): Array<{
33
+ event: string | null;
34
+ installation_id: string | null;
35
+ signature_ok: number;
36
+ created_at: string;
37
+ }>;
31
38
  }
32
39
  /** Replay protection for lifecycle webhooks (key derived from the signature). */
33
40
  export declare class WebhookSeenRepo {
@@ -46,6 +53,10 @@ export declare class CursorRepo {
46
53
  constructor(db: DatabaseT.Database);
47
54
  get(installationId: string, entity: string, sourceKey?: string): CursorRow | undefined;
48
55
  set(installationId: string, entity: string, sourceKey: string, w: CursorWrite): void;
56
+ /** All cursors for an installation (for /health, /admin/overview). */
57
+ listByInstallation(installationId: string): CursorRow[];
58
+ /** Number of cursors currently in the `error` status (health signal). */
59
+ countInError(): number;
49
60
  }
50
61
  /** History of sync runs. */
51
62
  export declare class RunRepo {
@@ -90,6 +101,28 @@ export declare class InboundEventRepo {
90
101
  /** Retention: deletes inbound rows older than `days` days. Returns the number of rows removed. */
91
102
  purgeOlderThan(days: number): number;
92
103
  }
104
+ /**
105
+ * Dead-letter of per-item REJECTIONS (E4). The engine records here what the
106
+ * ShopiMind API refused during a bulk push (validation), capped per run so a
107
+ * poison batch cannot flood the store; an operator inspects/replays via the admin
108
+ * endpoint. Subject to the same retention purge as the other log tables.
109
+ */
110
+ export declare class RejectedItemRepo {
111
+ private readonly db;
112
+ constructor(db: DatabaseT.Database);
113
+ add(entry: {
114
+ installation_id: string;
115
+ run_id?: number | null;
116
+ entity?: string | null;
117
+ source_key?: string | null;
118
+ payload_json: string;
119
+ reason?: string | null;
120
+ }): void;
121
+ /** Most recent rejected items for an installation, newest first (bounded). */
122
+ listByInstallation(installationId: string, limit?: number): RejectedItemRow[];
123
+ /** Retention: deletes rejected-item rows older than `days` days. Returns rows removed. */
124
+ purgeOlderThan(days: number): number;
125
+ }
93
126
  export interface Repositories {
94
127
  installs: InstallRepo;
95
128
  webhookLog: WebhookLogRepo;
@@ -98,5 +131,6 @@ export interface Repositories {
98
131
  runs: RunRepo;
99
132
  state: IntegrationStateRepo;
100
133
  inboundEvents: InboundEventRepo;
134
+ rejectedItems: RejectedItemRepo;
101
135
  }
102
136
  export declare function createRepositories(db: DatabaseT.Database, cipher: SecretCipher): Repositories;