@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.
- package/README.md +1 -1
- package/dist/contracts/lifecycle.d.ts +31 -1
- package/dist/contracts/sdk.d.ts +29 -7
- 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 +55 -12
- package/dist/provisioning/runner.d.ts +11 -1
- package/dist/provisioning/runner.js +95 -5
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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.
|
package/dist/store/migrations.js
CHANGED
|
@@ -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;
|