@shopimind/integration-kit-js 1.0.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/LICENSE +10 -0
- package/README.md +118 -0
- package/dist/config/config-store.d.ts +6 -0
- package/dist/config/config-store.js +56 -0
- package/dist/contracts/common.d.ts +11 -0
- package/dist/contracts/common.js +1 -0
- package/dist/contracts/config-schema.d.ts +79 -0
- package/dist/contracts/config-schema.js +1 -0
- package/dist/contracts/index.d.ts +11 -0
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/lifecycle.d.ts +59 -0
- package/dist/contracts/lifecycle.js +1 -0
- package/dist/contracts/sdk.d.ts +68 -0
- package/dist/contracts/sdk.js +6 -0
- package/dist/contracts/widget.d.ts +70 -0
- package/dist/contracts/widget.js +1 -0
- package/dist/http/routes.d.ts +18 -0
- package/dist/http/routes.js +150 -0
- package/dist/http/server.d.ts +7 -0
- package/dist/http/server.js +19 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +47 -0
- package/dist/integration/define-integration.d.ts +16 -0
- package/dist/integration/define-integration.js +50 -0
- package/dist/integration/types.d.ts +148 -0
- package/dist/integration/types.js +1 -0
- package/dist/lifecycle/dispatcher.d.ts +33 -0
- package/dist/lifecycle/dispatcher.js +315 -0
- package/dist/lifecycle/inbound.d.ts +50 -0
- package/dist/lifecycle/inbound.js +124 -0
- package/dist/logging/logger.d.ts +23 -0
- package/dist/logging/logger.js +23 -0
- package/dist/manifest.d.ts +52 -0
- package/dist/manifest.js +36 -0
- package/dist/provisioning/ensure.d.ts +24 -0
- package/dist/provisioning/ensure.js +104 -0
- package/dist/provisioning/runner.d.ts +16 -0
- package/dist/provisioning/runner.js +49 -0
- package/dist/runtime/create-app.d.ts +66 -0
- package/dist/runtime/create-app.js +211 -0
- package/dist/runtime/rate-limiter.d.ts +19 -0
- package/dist/runtime/rate-limiter.js +46 -0
- package/dist/sdk/send-bulk.d.ts +46 -0
- package/dist/sdk/send-bulk.js +40 -0
- package/dist/sdk/source-scope.d.ts +38 -0
- package/dist/sdk/source-scope.js +34 -0
- package/dist/security/crypto.d.ts +19 -0
- package/dist/security/crypto.js +82 -0
- package/dist/security/redaction.d.ts +15 -0
- package/dist/security/redaction.js +56 -0
- package/dist/security/signature.d.ts +31 -0
- package/dist/security/signature.js +30 -0
- package/dist/store/db.d.ts +7 -0
- package/dist/store/db.js +22 -0
- package/dist/store/migrate.d.ts +10 -0
- package/dist/store/migrate.js +35 -0
- package/dist/store/migrations.d.ts +27 -0
- package/dist/store/migrations.js +128 -0
- package/dist/store/repositories.d.ts +102 -0
- package/dist/store/repositories.js +281 -0
- package/dist/store/types.d.ts +62 -0
- package/dist/store/types.js +1 -0
- package/dist/sync/concurrency.d.ts +12 -0
- package/dist/sync/concurrency.js +30 -0
- package/dist/sync/cursor.d.ts +16 -0
- package/dist/sync/cursor.js +14 -0
- package/dist/sync/engine.d.ts +49 -0
- package/dist/sync/engine.js +129 -0
- package/dist/sync/paginate.d.ts +14 -0
- package/dist/sync/paginate.js +42 -0
- package/dist/testing/harness.d.ts +49 -0
- package/dist/testing/harness.js +110 -0
- package/package.json +51 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
const nn = (v) => (v === undefined ? null : v);
|
|
2
|
+
/** GCM AAD binding an encrypted secret to its exact location (anti-relocation). */
|
|
3
|
+
const aadFor = (installationId, key) => `${installationId}:${key}`;
|
|
4
|
+
/** Installs (one row per installation). COALESCE upsert: a null field does not overwrite. */
|
|
5
|
+
export class InstallRepo {
|
|
6
|
+
db;
|
|
7
|
+
constructor(db) {
|
|
8
|
+
this.db = db;
|
|
9
|
+
}
|
|
10
|
+
upsert(u) {
|
|
11
|
+
this.db
|
|
12
|
+
.prepare(`INSERT INTO installs
|
|
13
|
+
(installation_id, shop_domain, shop_name, status, installed_at, updated_at)
|
|
14
|
+
VALUES
|
|
15
|
+
(@installation_id, @shop_domain, @shop_name, @status, @installed_at, datetime('now'))
|
|
16
|
+
ON CONFLICT(installation_id) DO UPDATE SET
|
|
17
|
+
shop_domain = COALESCE(excluded.shop_domain, shop_domain),
|
|
18
|
+
shop_name = COALESCE(excluded.shop_name, shop_name),
|
|
19
|
+
status = excluded.status,
|
|
20
|
+
installed_at = COALESCE(excluded.installed_at, installed_at),
|
|
21
|
+
updated_at = datetime('now')`)
|
|
22
|
+
.run({
|
|
23
|
+
installation_id: u.installation_id,
|
|
24
|
+
shop_domain: nn(u.shop_domain),
|
|
25
|
+
shop_name: nn(u.shop_name),
|
|
26
|
+
status: u.status,
|
|
27
|
+
installed_at: nn(u.installed_at),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
setStatus(installationId, status, stamps = {}) {
|
|
31
|
+
this.db
|
|
32
|
+
.prepare(`UPDATE installs SET
|
|
33
|
+
status = @status,
|
|
34
|
+
activated_at = @activated_at,
|
|
35
|
+
deactivated_at = @deactivated_at,
|
|
36
|
+
uninstalled_at = @uninstalled_at,
|
|
37
|
+
updated_at = datetime('now')
|
|
38
|
+
WHERE installation_id = @installation_id`)
|
|
39
|
+
.run({
|
|
40
|
+
installation_id: installationId,
|
|
41
|
+
status,
|
|
42
|
+
activated_at: nn(stamps.activated_at),
|
|
43
|
+
deactivated_at: nn(stamps.deactivated_at),
|
|
44
|
+
uninstalled_at: nn(stamps.uninstalled_at),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/** Sets the INTEGRATOR account associated with the installation (the correlation bridge). */
|
|
48
|
+
setExternalAccount(installationId, ref, name = null) {
|
|
49
|
+
this.db
|
|
50
|
+
.prepare(`UPDATE installs SET
|
|
51
|
+
external_account_ref = @ref,
|
|
52
|
+
external_account_name = @name,
|
|
53
|
+
updated_at = datetime('now')
|
|
54
|
+
WHERE installation_id = @installation_id`)
|
|
55
|
+
.run({ installation_id: installationId, ref: nn(ref), name: nn(name) });
|
|
56
|
+
}
|
|
57
|
+
find(installationId) {
|
|
58
|
+
return this.db
|
|
59
|
+
.prepare('SELECT * FROM installs WHERE installation_id = ?')
|
|
60
|
+
.get(installationId);
|
|
61
|
+
}
|
|
62
|
+
listActive() {
|
|
63
|
+
return this.db.prepare(`SELECT * FROM installs WHERE status = 'active'`).all();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Webhook log. The `payload_json` MUST already be redacted by the runtime. */
|
|
67
|
+
export class WebhookLogRepo {
|
|
68
|
+
db;
|
|
69
|
+
constructor(db) {
|
|
70
|
+
this.db = db;
|
|
71
|
+
}
|
|
72
|
+
log(entry) {
|
|
73
|
+
this.db
|
|
74
|
+
.prepare(`INSERT INTO webhook_log (event, installation_id, signature_ok, payload_json)
|
|
75
|
+
VALUES (@event, @installation_id, @signature_ok, @payload_json)`)
|
|
76
|
+
.run({
|
|
77
|
+
event: nn(entry.event),
|
|
78
|
+
installation_id: nn(entry.installation_id),
|
|
79
|
+
signature_ok: entry.signature_ok ? 1 : 0,
|
|
80
|
+
payload_json: entry.payload_json,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/** Retention: deletes log rows older than `days` days. Returns the number of rows removed. */
|
|
84
|
+
purgeOlderThan(days) {
|
|
85
|
+
return this.db
|
|
86
|
+
.prepare(`DELETE FROM webhook_log WHERE created_at < datetime('now', @cutoff)`)
|
|
87
|
+
.run({ cutoff: `-${days} days` }).changes;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Replay protection for lifecycle webhooks (key derived from the signature). */
|
|
91
|
+
export class WebhookSeenRepo {
|
|
92
|
+
db;
|
|
93
|
+
constructor(db) {
|
|
94
|
+
this.db = db;
|
|
95
|
+
}
|
|
96
|
+
/** Atomically claims processing; `false` if the signature was already seen (replay). */
|
|
97
|
+
claim(installationId, dedupKey) {
|
|
98
|
+
const info = this.db
|
|
99
|
+
.prepare(`INSERT INTO webhook_seen (installation_id, dedup_key) VALUES (?, ?)
|
|
100
|
+
ON CONFLICT(installation_id, dedup_key) DO NOTHING`)
|
|
101
|
+
.run(installationId, dedupKey);
|
|
102
|
+
return info.changes === 1;
|
|
103
|
+
}
|
|
104
|
+
/** Releases a claim (failed processing) -> an identical resend can retry. */
|
|
105
|
+
release(installationId, dedupKey) {
|
|
106
|
+
this.db
|
|
107
|
+
.prepare('DELETE FROM webhook_seen WHERE installation_id = ? AND dedup_key = ?')
|
|
108
|
+
.run(installationId, dedupKey);
|
|
109
|
+
}
|
|
110
|
+
/** Retention: deletes dedup rows older than `days` days. Returns the number of rows removed. */
|
|
111
|
+
purgeOlderThan(days) {
|
|
112
|
+
return this.db
|
|
113
|
+
.prepare(`DELETE FROM webhook_seen WHERE created_at < datetime('now', @cutoff)`)
|
|
114
|
+
.run({ cutoff: `-${days} days` }).changes;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Sync cursors, scoped by (installation, entity, source_key). */
|
|
118
|
+
export class CursorRepo {
|
|
119
|
+
db;
|
|
120
|
+
constructor(db) {
|
|
121
|
+
this.db = db;
|
|
122
|
+
}
|
|
123
|
+
get(installationId, entity, sourceKey = '') {
|
|
124
|
+
return this.db
|
|
125
|
+
.prepare('SELECT * FROM sync_cursor WHERE installation_id = ? AND entity = ? AND source_key = ?')
|
|
126
|
+
.get(installationId, entity, sourceKey);
|
|
127
|
+
}
|
|
128
|
+
set(installationId, entity, sourceKey, w) {
|
|
129
|
+
this.db
|
|
130
|
+
.prepare(`INSERT INTO sync_cursor
|
|
131
|
+
(installation_id, entity, source_key, last_synced_at, last_status, last_error, items, updated_at)
|
|
132
|
+
VALUES
|
|
133
|
+
(@installation_id, @entity, @source_key, @last_synced_at, @last_status, @last_error, @items, datetime('now'))
|
|
134
|
+
ON CONFLICT(installation_id, entity, source_key) DO UPDATE SET
|
|
135
|
+
last_synced_at = excluded.last_synced_at,
|
|
136
|
+
last_status = excluded.last_status,
|
|
137
|
+
last_error = excluded.last_error,
|
|
138
|
+
items = excluded.items,
|
|
139
|
+
updated_at = datetime('now')`)
|
|
140
|
+
.run({
|
|
141
|
+
installation_id: installationId,
|
|
142
|
+
entity,
|
|
143
|
+
source_key: sourceKey,
|
|
144
|
+
last_synced_at: w.last_synced_at,
|
|
145
|
+
last_status: nn(w.last_status),
|
|
146
|
+
last_error: nn(w.last_error),
|
|
147
|
+
items: w.items ?? 0,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** History of sync runs. */
|
|
152
|
+
export class RunRepo {
|
|
153
|
+
db;
|
|
154
|
+
constructor(db) {
|
|
155
|
+
this.db = db;
|
|
156
|
+
}
|
|
157
|
+
start(installationId) {
|
|
158
|
+
const info = this.db
|
|
159
|
+
.prepare(`INSERT INTO sync_run (installation_id, status) VALUES (?, 'running')`)
|
|
160
|
+
.run(installationId);
|
|
161
|
+
return Number(info.lastInsertRowid);
|
|
162
|
+
}
|
|
163
|
+
finish(runId, status, summary) {
|
|
164
|
+
// Never let a non-serializable summary (circular reference, BigInt, …) throw and
|
|
165
|
+
// leave the run stuck in 'running': fall back to a safe placeholder instead.
|
|
166
|
+
let summaryJson;
|
|
167
|
+
try {
|
|
168
|
+
summaryJson = JSON.stringify(summary);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
summaryJson = '{"error":"unserializable summary"}';
|
|
172
|
+
}
|
|
173
|
+
this.db
|
|
174
|
+
.prepare(`UPDATE sync_run SET status = @status, summary_json = @summary_json, finished_at = datetime('now') WHERE id = @id`)
|
|
175
|
+
.run({ id: runId, status, summary_json: summaryJson });
|
|
176
|
+
}
|
|
177
|
+
recent(installationId, limit = 10) {
|
|
178
|
+
return this.db
|
|
179
|
+
.prepare('SELECT * FROM sync_run WHERE installation_id = ? ORDER BY id DESC LIMIT ?')
|
|
180
|
+
.all(installationId, limit);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Private integration state (KV per installation). Sensitive values are encrypted
|
|
185
|
+
* at rest via the kit's `SecretCipher`.
|
|
186
|
+
*/
|
|
187
|
+
export class IntegrationStateRepo {
|
|
188
|
+
db;
|
|
189
|
+
cipher;
|
|
190
|
+
constructor(db, cipher) {
|
|
191
|
+
this.db = db;
|
|
192
|
+
this.cipher = cipher;
|
|
193
|
+
}
|
|
194
|
+
write(installationId, key, value, encrypted) {
|
|
195
|
+
this.db
|
|
196
|
+
.prepare(`INSERT INTO integration_state (installation_id, key, value, encrypted, updated_at)
|
|
197
|
+
VALUES (@id, @key, @value, @encrypted, datetime('now'))
|
|
198
|
+
ON CONFLICT(installation_id, key) DO UPDATE SET
|
|
199
|
+
value = excluded.value, encrypted = excluded.encrypted, updated_at = datetime('now')`)
|
|
200
|
+
.run({ id: installationId, key, value, encrypted: encrypted ? 1 : 0 });
|
|
201
|
+
}
|
|
202
|
+
set(installationId, key, value) {
|
|
203
|
+
this.write(installationId, key, value, false);
|
|
204
|
+
}
|
|
205
|
+
setSecret(installationId, key, value) {
|
|
206
|
+
this.write(installationId, key, this.cipher.encrypt(value, aadFor(installationId, key)), true);
|
|
207
|
+
}
|
|
208
|
+
get(installationId, key) {
|
|
209
|
+
const row = this.db
|
|
210
|
+
.prepare('SELECT value, encrypted FROM integration_state WHERE installation_id = ? AND key = ?')
|
|
211
|
+
.get(installationId, key);
|
|
212
|
+
if (!row || row.value === null)
|
|
213
|
+
return null;
|
|
214
|
+
return row.encrypted ? this.cipher.decrypt(row.value, aadFor(installationId, key)) : row.value;
|
|
215
|
+
}
|
|
216
|
+
delete(installationId, key) {
|
|
217
|
+
this.db
|
|
218
|
+
.prepare('DELETE FROM integration_state WHERE installation_id = ? AND key = ?')
|
|
219
|
+
.run(installationId, key);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** Log of inbound calls (middleware idempotency + audit). */
|
|
223
|
+
export class InboundEventRepo {
|
|
224
|
+
db;
|
|
225
|
+
constructor(db) {
|
|
226
|
+
this.db = db;
|
|
227
|
+
}
|
|
228
|
+
find(installationId, idempotencyKey) {
|
|
229
|
+
return this.db
|
|
230
|
+
.prepare('SELECT * FROM inbound_event WHERE installation_id = ? AND idempotency_key = ?')
|
|
231
|
+
.get(installationId, idempotencyKey);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* ATOMICALLY claims the processing of an inbound call (anti-TOCTOU). The INSERT
|
|
235
|
+
* `ON CONFLICT DO NOTHING` relies on the UNIQUE index (installation_id, idempotency_key):
|
|
236
|
+
* - `fresh: true` => row created by THIS call -> it is responsible for processing;
|
|
237
|
+
* - `fresh: false` => a row already existed -> `status` tells where it stands
|
|
238
|
+
* ('received' = in progress/already claimed, 'done' = processed, 'failed' = retryable).
|
|
239
|
+
*/
|
|
240
|
+
claim(installationId, idempotencyKey, action) {
|
|
241
|
+
const info = this.db
|
|
242
|
+
.prepare(`INSERT INTO inbound_event (installation_id, idempotency_key, action, status)
|
|
243
|
+
VALUES (?, ?, ?, 'received')
|
|
244
|
+
ON CONFLICT(installation_id, idempotency_key) DO NOTHING`)
|
|
245
|
+
.run(installationId, idempotencyKey, nn(action));
|
|
246
|
+
if (info.changes === 1)
|
|
247
|
+
return { rowId: Number(info.lastInsertRowid), fresh: true, status: 'received' };
|
|
248
|
+
const existing = this.find(installationId, idempotencyKey);
|
|
249
|
+
if (!existing) {
|
|
250
|
+
// Extreme race (row deleted between the INSERT and the SELECT): retry once.
|
|
251
|
+
const retry = this.db
|
|
252
|
+
.prepare(`INSERT INTO inbound_event (installation_id, idempotency_key, action, status) VALUES (?, ?, ?, 'received') ON CONFLICT(installation_id, idempotency_key) DO NOTHING`)
|
|
253
|
+
.run(installationId, idempotencyKey, nn(action));
|
|
254
|
+
const row = this.find(installationId, idempotencyKey);
|
|
255
|
+
return { rowId: row ? row.id : Number(retry.lastInsertRowid), fresh: retry.changes === 1, status: row ? row.status : 'received' };
|
|
256
|
+
}
|
|
257
|
+
return { rowId: existing.id, fresh: false, status: existing.status };
|
|
258
|
+
}
|
|
259
|
+
finish(id, status, error = null) {
|
|
260
|
+
this.db
|
|
261
|
+
.prepare(`UPDATE inbound_event SET status = @status, error = @error, processed_at = datetime('now') WHERE id = @id`)
|
|
262
|
+
.run({ id, status, error: nn(error) });
|
|
263
|
+
}
|
|
264
|
+
/** Retention: deletes inbound rows older than `days` days. Returns the number of rows removed. */
|
|
265
|
+
purgeOlderThan(days) {
|
|
266
|
+
return this.db
|
|
267
|
+
.prepare(`DELETE FROM inbound_event WHERE received_at < datetime('now', @cutoff)`)
|
|
268
|
+
.run({ cutoff: `-${days} days` }).changes;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
export function createRepositories(db, cipher) {
|
|
272
|
+
return {
|
|
273
|
+
installs: new InstallRepo(db),
|
|
274
|
+
webhookLog: new WebhookLogRepo(db),
|
|
275
|
+
webhookSeen: new WebhookSeenRepo(db),
|
|
276
|
+
cursors: new CursorRepo(db),
|
|
277
|
+
runs: new RunRepo(db),
|
|
278
|
+
state: new IntegrationStateRepo(db, cipher),
|
|
279
|
+
inboundEvents: new InboundEventRepo(db),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export type InstallStatus = 'inactive' | 'active' | 'uninstalled';
|
|
2
|
+
/**
|
|
3
|
+
* An installation. `installation_id` = OPAQUE token issued by ShopiMind.
|
|
4
|
+
* `external_account_ref`/`_name` = the reference of the INTEGRATOR's internal
|
|
5
|
+
* account (the correlation bridge, set by the integration).
|
|
6
|
+
*/
|
|
7
|
+
export interface InstallRow {
|
|
8
|
+
installation_id: string;
|
|
9
|
+
shop_domain: string | null;
|
|
10
|
+
shop_name: string | null;
|
|
11
|
+
external_account_ref: string | null;
|
|
12
|
+
external_account_name: string | null;
|
|
13
|
+
status: string;
|
|
14
|
+
installed_at: string | null;
|
|
15
|
+
activated_at: string | null;
|
|
16
|
+
deactivated_at: string | null;
|
|
17
|
+
uninstalled_at: string | null;
|
|
18
|
+
created_at: string;
|
|
19
|
+
updated_at: string;
|
|
20
|
+
}
|
|
21
|
+
export interface InstallUpsert {
|
|
22
|
+
installation_id: string;
|
|
23
|
+
shop_domain?: string | null;
|
|
24
|
+
shop_name?: string | null;
|
|
25
|
+
status: InstallStatus;
|
|
26
|
+
installed_at?: string | null;
|
|
27
|
+
}
|
|
28
|
+
export interface CursorRow {
|
|
29
|
+
installation_id: string;
|
|
30
|
+
entity: string;
|
|
31
|
+
source_key: string;
|
|
32
|
+
last_synced_at: string | null;
|
|
33
|
+
last_status: string | null;
|
|
34
|
+
last_error: string | null;
|
|
35
|
+
items: number;
|
|
36
|
+
updated_at: string;
|
|
37
|
+
}
|
|
38
|
+
export interface CursorWrite {
|
|
39
|
+
last_synced_at: string;
|
|
40
|
+
last_status?: string;
|
|
41
|
+
last_error?: string | null;
|
|
42
|
+
items?: number;
|
|
43
|
+
}
|
|
44
|
+
export interface SyncRunRow {
|
|
45
|
+
id: number;
|
|
46
|
+
installation_id: string;
|
|
47
|
+
status: string;
|
|
48
|
+
summary_json: string | null;
|
|
49
|
+
started_at: string;
|
|
50
|
+
finished_at: string | null;
|
|
51
|
+
}
|
|
52
|
+
/** A row of the inbound call log (inbound routes / middleware). */
|
|
53
|
+
export interface InboundEventRow {
|
|
54
|
+
id: number;
|
|
55
|
+
installation_id: string;
|
|
56
|
+
idempotency_key: string;
|
|
57
|
+
action: string | null;
|
|
58
|
+
status: string;
|
|
59
|
+
error: string | null;
|
|
60
|
+
received_at: string;
|
|
61
|
+
processed_at: string | null;
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BOUNDED-concurrency map (avoids 429s). At most `limit` promises in flight at a
|
|
3
|
+
* time, with result order preserved - avoids bursts of calls that trigger a 429
|
|
4
|
+
* on the third-party API side.
|
|
5
|
+
*
|
|
6
|
+
* IMPORTANT — this MATERIALIZES the input iterable eagerly (`[...items]`) and
|
|
7
|
+
* returns a fully-buffered result array. Unlike `paginate`/`streamPages`, it is
|
|
8
|
+
* NOT streaming: both the input and the output are held in memory at once. Do not
|
|
9
|
+
* pass a huge lazy stream (e.g. a long backfill) to it — page it with `paginate`
|
|
10
|
+
* first and map each bounded batch instead.
|
|
11
|
+
*/
|
|
12
|
+
export declare function mapWithConcurrency<I, O>(items: Iterable<I>, limit: number, fn: (item: I, index: number) => Promise<O>): Promise<O[]>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BOUNDED-concurrency map (avoids 429s). At most `limit` promises in flight at a
|
|
3
|
+
* time, with result order preserved - avoids bursts of calls that trigger a 429
|
|
4
|
+
* on the third-party API side.
|
|
5
|
+
*
|
|
6
|
+
* IMPORTANT — this MATERIALIZES the input iterable eagerly (`[...items]`) and
|
|
7
|
+
* returns a fully-buffered result array. Unlike `paginate`/`streamPages`, it is
|
|
8
|
+
* NOT streaming: both the input and the output are held in memory at once. Do not
|
|
9
|
+
* pass a huge lazy stream (e.g. a long backfill) to it — page it with `paginate`
|
|
10
|
+
* first and map each bounded batch instead.
|
|
11
|
+
*/
|
|
12
|
+
export async function mapWithConcurrency(items, limit, fn) {
|
|
13
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
14
|
+
throw new Error('concurrency limit must be an integer >= 1');
|
|
15
|
+
}
|
|
16
|
+
const all = [...items];
|
|
17
|
+
const results = new Array(all.length);
|
|
18
|
+
let cursor = 0;
|
|
19
|
+
const worker = async () => {
|
|
20
|
+
for (;;) {
|
|
21
|
+
const index = cursor++;
|
|
22
|
+
if (index >= all.length)
|
|
23
|
+
return;
|
|
24
|
+
results[index] = await fn(all[index], index);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const workerCount = Math.min(limit, all.length);
|
|
28
|
+
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
29
|
+
return results;
|
|
30
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor semantics that are SAFE by construction.
|
|
3
|
+
*
|
|
4
|
+
* Golden rule (enforced by the ENGINE, never by the integration): the cursor
|
|
5
|
+
* only advances if the step had NO error. A partial failure replays its window
|
|
6
|
+
* on the next run - no silent data loss.
|
|
7
|
+
*/
|
|
8
|
+
export type CursorScope = 'global' | 'per-source';
|
|
9
|
+
export interface SyncStepOutcome {
|
|
10
|
+
errors: string[];
|
|
11
|
+
/** Upper bound to advance the cursor to IF the run is clean. */
|
|
12
|
+
advanceCursorTo?: Date;
|
|
13
|
+
}
|
|
14
|
+
export declare function shouldAdvanceCursor(outcome: SyncStepOutcome): boolean;
|
|
15
|
+
/** Returns the new cursor value, or `null` if it should not advance. */
|
|
16
|
+
export declare function nextCursorValue(outcome: SyncStepOutcome): Date | null;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor semantics that are SAFE by construction.
|
|
3
|
+
*
|
|
4
|
+
* Golden rule (enforced by the ENGINE, never by the integration): the cursor
|
|
5
|
+
* only advances if the step had NO error. A partial failure replays its window
|
|
6
|
+
* on the next run - no silent data loss.
|
|
7
|
+
*/
|
|
8
|
+
export function shouldAdvanceCursor(outcome) {
|
|
9
|
+
return outcome.errors.length === 0 && outcome.advanceCursorTo != null;
|
|
10
|
+
}
|
|
11
|
+
/** Returns the new cursor value, or `null` if it should not advance. */
|
|
12
|
+
export function nextCursorValue(outcome) {
|
|
13
|
+
return shouldAdvanceCursor(outcome) ? outcome.advanceCursorTo : null;
|
|
14
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { CursorRepo, RunRepo } from '../store/repositories.js';
|
|
2
|
+
import type { Integration, IntegrationContext, SyncWindow } from '../integration/types.js';
|
|
3
|
+
import type { SourceHandle } from '../sdk/source-scope.js';
|
|
4
|
+
import { type SendBulk } from '../sdk/send-bulk.js';
|
|
5
|
+
export interface SyncOptions {
|
|
6
|
+
fullBackfill?: boolean;
|
|
7
|
+
backfillDays?: number;
|
|
8
|
+
/** Injectable for testing; defaults to `() => new Date()`. */
|
|
9
|
+
now?: () => Date;
|
|
10
|
+
}
|
|
11
|
+
export interface SyncStepSummary {
|
|
12
|
+
entity: string;
|
|
13
|
+
sourceKey: string;
|
|
14
|
+
items: number;
|
|
15
|
+
/** Items the API rejected during the step (data NOT persisted on the ShopiMind side). */
|
|
16
|
+
rejected: number;
|
|
17
|
+
errors: string[];
|
|
18
|
+
advanced: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface SyncSummary {
|
|
21
|
+
runId: number;
|
|
22
|
+
status: 'ok' | 'partial';
|
|
23
|
+
steps: SyncStepSummary[];
|
|
24
|
+
errors: string[];
|
|
25
|
+
}
|
|
26
|
+
export interface SyncDeps {
|
|
27
|
+
cursors: CursorRepo;
|
|
28
|
+
runs: RunRepo;
|
|
29
|
+
/**
|
|
30
|
+
* Builds `withSource` bound to a given `sendBulk`, so source-scoped pushes
|
|
31
|
+
* (`withSource(k).send(...)`) feed the step's reject accumulator. Provided by the
|
|
32
|
+
* runtime, which knows the installation + provisioning state.
|
|
33
|
+
*/
|
|
34
|
+
makeSource: (sendBulk: SendBulk) => (sourceKey: string) => SourceHandle;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Runs the enabled sync steps of an integration. The cursor is managed HERE,
|
|
38
|
+
* never by the integration:
|
|
39
|
+
* - scope 'global' -> one cursor (entity, '')
|
|
40
|
+
* - scope 'per-source' -> one cursor per source (entity, sourceKey)
|
|
41
|
+
* GOLDEN RULE: the cursor only advances if the step had NO error.
|
|
42
|
+
*/
|
|
43
|
+
export declare function runIntegrationSync<S>(integration: Pick<Integration<S>, 'syncSteps'>, base: IntegrationContext<S>, deps: SyncDeps, opts?: SyncOptions): Promise<SyncSummary>;
|
|
44
|
+
/** Sync window: backfill on the first run / in full mode, otherwise from the cursor. */
|
|
45
|
+
export declare function computeWindow(lastSyncedAt: string | null, opts: {
|
|
46
|
+
now: () => Date;
|
|
47
|
+
full: boolean;
|
|
48
|
+
backfillDays: number;
|
|
49
|
+
}): SyncWindow;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { makeSendBulk } from '../sdk/send-bulk.js';
|
|
2
|
+
import { paginate } from './paginate.js';
|
|
3
|
+
import { mapWithConcurrency } from './concurrency.js';
|
|
4
|
+
import { shouldAdvanceCursor } from './cursor.js';
|
|
5
|
+
/**
|
|
6
|
+
* Runs the enabled sync steps of an integration. The cursor is managed HERE,
|
|
7
|
+
* never by the integration:
|
|
8
|
+
* - scope 'global' -> one cursor (entity, '')
|
|
9
|
+
* - scope 'per-source' -> one cursor per source (entity, sourceKey)
|
|
10
|
+
* GOLDEN RULE: the cursor only advances if the step had NO error.
|
|
11
|
+
*/
|
|
12
|
+
export async function runIntegrationSync(integration, base, deps, opts = {}) {
|
|
13
|
+
const now = opts.now ?? (() => new Date());
|
|
14
|
+
const backfillDays = opts.backfillDays ?? 365;
|
|
15
|
+
const full = opts.fullBackfill ?? false;
|
|
16
|
+
const runId = deps.runs.start(base.installationId);
|
|
17
|
+
const summary = { runId, status: 'ok', steps: [], errors: [] };
|
|
18
|
+
try {
|
|
19
|
+
for (const step of integration.syncSteps) {
|
|
20
|
+
if (!step.enabled(base.settings))
|
|
21
|
+
continue;
|
|
22
|
+
const resolved = await resolveSources(step, base);
|
|
23
|
+
if (resolved.error) {
|
|
24
|
+
// A misconfigured per-source step (no sources()) is NOT skipped silently:
|
|
25
|
+
// surface it so the run is marked partial instead of hiding missing data.
|
|
26
|
+
summary.errors.push(resolved.error);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
for (const sourceKey of resolved.sourceKeys) {
|
|
30
|
+
const stepSummary = await runOneSource(step, base, deps, sourceKey, { now, full, backfillDays });
|
|
31
|
+
summary.steps.push(stepSummary);
|
|
32
|
+
summary.errors.push(...stepSummary.errors);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
summary.status = summary.errors.length > 0 ? 'partial' : 'ok';
|
|
36
|
+
deps.runs.finish(runId, summary.status, summary);
|
|
37
|
+
return summary;
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
deps.runs.finish(runId, 'failed', { errors: [errMsg(e)] });
|
|
41
|
+
throw e;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function resolveSources(step, base) {
|
|
45
|
+
if (step.cursorScope === 'global')
|
|
46
|
+
return { sourceKeys: [''] };
|
|
47
|
+
if (!step.sources) {
|
|
48
|
+
return { sourceKeys: [], error: `per-source step '${step.entity}' has no sources()` };
|
|
49
|
+
}
|
|
50
|
+
return { sourceKeys: await step.sources(base) };
|
|
51
|
+
}
|
|
52
|
+
async function runOneSource(step, base, deps, sourceKey, win) {
|
|
53
|
+
const cursor = deps.cursors.get(base.installationId, step.entity, sourceKey) ?? null;
|
|
54
|
+
const window = computeWindow(cursor?.last_synced_at ?? null, win);
|
|
55
|
+
// Per-step-run reject accumulator: `ctx.sendBulk` and `withSource(k).send` feed it,
|
|
56
|
+
// so the engine can HOLD the cursor on data loss EVEN IF the step result omits the
|
|
57
|
+
// count — safe by construction (the dev cannot forget to surface rejections).
|
|
58
|
+
const rejects = { count: 0 };
|
|
59
|
+
const stepSendBulk = makeSendBulk(base.spm, base.logger, (n) => {
|
|
60
|
+
rejects.count += n;
|
|
61
|
+
});
|
|
62
|
+
const ctx = {
|
|
63
|
+
...base,
|
|
64
|
+
entity: step.entity,
|
|
65
|
+
sourceKey,
|
|
66
|
+
window,
|
|
67
|
+
cursor,
|
|
68
|
+
paginate,
|
|
69
|
+
mapConcurrent: mapWithConcurrency,
|
|
70
|
+
sendBulk: stepSendBulk,
|
|
71
|
+
withSource: deps.makeSource(stepSendBulk),
|
|
72
|
+
};
|
|
73
|
+
let result;
|
|
74
|
+
try {
|
|
75
|
+
result = await step.run(ctx);
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
result = { items: 0, errors: [`fatal: ${errMsg(e)}`] };
|
|
79
|
+
}
|
|
80
|
+
// GOLDEN RULE: do not advance the cursor on (a) a step error OR (b) unhandled
|
|
81
|
+
// rejections (data the API did NOT persist). `tolerateRejects` only lifts (b) — for
|
|
82
|
+
// a windowed stream a PERMANENT rejection ("poison pill") would otherwise freeze the
|
|
83
|
+
// window forever — but rejections stay visible (the warn log + the summary count).
|
|
84
|
+
const cleanRun = shouldAdvanceCursor(result);
|
|
85
|
+
const blockedByRejects = rejects.count > 0 && !step.tolerateRejects;
|
|
86
|
+
const advanced = cleanRun && !blockedByRejects;
|
|
87
|
+
const errors = [...result.errors];
|
|
88
|
+
if (blockedByRejects) {
|
|
89
|
+
errors.push(`${rejects.count} item(s) rejected (cursor held; set tolerateRejects to advance)`);
|
|
90
|
+
}
|
|
91
|
+
if (advanced) {
|
|
92
|
+
const advanceTo = result.advanceCursorTo;
|
|
93
|
+
// Never advance past the window upper bound: a future cursor would make the
|
|
94
|
+
// next window empty/inverted and silently skip data.
|
|
95
|
+
const clamped = advanceTo.getTime() > window.until.getTime() ? window.until : advanceTo;
|
|
96
|
+
deps.cursors.set(base.installationId, step.entity, sourceKey, {
|
|
97
|
+
last_synced_at: clamped.toISOString(),
|
|
98
|
+
last_status: 'ok',
|
|
99
|
+
items: result.items,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
else if (errors.length > 0) {
|
|
103
|
+
// Failed/blocked step: record the failure WITHOUT advancing the cursor, so the
|
|
104
|
+
// same window is replayed next run (no silent data loss). The CursorWrite API
|
|
105
|
+
// requires a last_synced_at, so we keep the previous value (or null if none yet).
|
|
106
|
+
deps.cursors.set(base.installationId, step.entity, sourceKey, {
|
|
107
|
+
// Keep the OLD cursor value (or null on a never-synced source); the column is
|
|
108
|
+
// nullable at the DB level even though CursorWrite types it as a string.
|
|
109
|
+
last_synced_at: (cursor?.last_synced_at ?? null),
|
|
110
|
+
last_status: 'error',
|
|
111
|
+
last_error: errors.join('; '),
|
|
112
|
+
items: result.items,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return { entity: step.entity, sourceKey, items: result.items, rejected: rejects.count, errors, advanced };
|
|
116
|
+
}
|
|
117
|
+
/** Sync window: backfill on the first run / in full mode, otherwise from the cursor. */
|
|
118
|
+
export function computeWindow(lastSyncedAt, opts) {
|
|
119
|
+
const until = opts.now();
|
|
120
|
+
if (opts.full || !lastSyncedAt) {
|
|
121
|
+
const since = new Date(until);
|
|
122
|
+
since.setDate(since.getDate() - opts.backfillDays);
|
|
123
|
+
return { since, until };
|
|
124
|
+
}
|
|
125
|
+
return { since: new Date(lastSyncedAt), until };
|
|
126
|
+
}
|
|
127
|
+
function errMsg(e) {
|
|
128
|
+
return e instanceof Error ? e.message : String(e);
|
|
129
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STREAMING pagination (avoids OOM). Iterates page by page without accumulating
|
|
3
|
+
* the whole dataset in memory - safe even for a long backfill over large volumes.
|
|
4
|
+
*/
|
|
5
|
+
export interface PaginateOptions {
|
|
6
|
+
/** First page (defaults to 1). */
|
|
7
|
+
startPage?: number;
|
|
8
|
+
/** Page size: a shorter page ends the stream. */
|
|
9
|
+
pageSize?: number;
|
|
10
|
+
}
|
|
11
|
+
/** Iterates items one by one, page after page, without accumulating the dataset. */
|
|
12
|
+
export declare function paginate<T>(fetchPage: (page: number) => Promise<T[]>, opts?: PaginateOptions): AsyncGenerator<T, void, void>;
|
|
13
|
+
/** Variant that yields PAGES (useful for pushing in batches). */
|
|
14
|
+
export declare function streamPages<T>(fetchPage: (page: number) => Promise<T[]>, opts?: PaginateOptions): AsyncGenerator<T[], void, void>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STREAMING pagination (avoids OOM). Iterates page by page without accumulating
|
|
3
|
+
* the whole dataset in memory - safe even for a long backfill over large volumes.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Hard upper bound on the number of pages a single pagination may walk through.
|
|
7
|
+
* A safety net against an infinite loop when `fetchPage` keeps returning full
|
|
8
|
+
* pages without ever advancing (e.g. a misconfigured backfill that ignores the
|
|
9
|
+
* page argument). 100k pages is far beyond any legitimate sync.
|
|
10
|
+
*/
|
|
11
|
+
const MAX_PAGES = 100000;
|
|
12
|
+
/** Iterates items one by one, page after page, without accumulating the dataset. */
|
|
13
|
+
export async function* paginate(fetchPage, opts = {}) {
|
|
14
|
+
const start = opts.startPage ?? 1;
|
|
15
|
+
for (let page = start;; page++) {
|
|
16
|
+
if (page - start >= MAX_PAGES) {
|
|
17
|
+
throw new Error('pagination exceeded MAX_PAGES — fetchPage likely not advancing');
|
|
18
|
+
}
|
|
19
|
+
const rows = await fetchPage(page);
|
|
20
|
+
if (!Array.isArray(rows) || rows.length === 0)
|
|
21
|
+
return;
|
|
22
|
+
for (const row of rows)
|
|
23
|
+
yield row;
|
|
24
|
+
if (opts.pageSize != null && rows.length < opts.pageSize)
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Variant that yields PAGES (useful for pushing in batches). */
|
|
29
|
+
export async function* streamPages(fetchPage, opts = {}) {
|
|
30
|
+
const start = opts.startPage ?? 1;
|
|
31
|
+
for (let page = start;; page++) {
|
|
32
|
+
if (page - start >= MAX_PAGES) {
|
|
33
|
+
throw new Error('pagination exceeded MAX_PAGES — fetchPage likely not advancing');
|
|
34
|
+
}
|
|
35
|
+
const rows = await fetchPage(page);
|
|
36
|
+
if (!Array.isArray(rows) || rows.length === 0)
|
|
37
|
+
return;
|
|
38
|
+
yield rows;
|
|
39
|
+
if (opts.pageSize != null && rows.length < opts.pageSize)
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
}
|