@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.
Files changed (73) hide show
  1. package/LICENSE +10 -0
  2. package/README.md +118 -0
  3. package/dist/config/config-store.d.ts +6 -0
  4. package/dist/config/config-store.js +56 -0
  5. package/dist/contracts/common.d.ts +11 -0
  6. package/dist/contracts/common.js +1 -0
  7. package/dist/contracts/config-schema.d.ts +79 -0
  8. package/dist/contracts/config-schema.js +1 -0
  9. package/dist/contracts/index.d.ts +11 -0
  10. package/dist/contracts/index.js +1 -0
  11. package/dist/contracts/lifecycle.d.ts +59 -0
  12. package/dist/contracts/lifecycle.js +1 -0
  13. package/dist/contracts/sdk.d.ts +68 -0
  14. package/dist/contracts/sdk.js +6 -0
  15. package/dist/contracts/widget.d.ts +70 -0
  16. package/dist/contracts/widget.js +1 -0
  17. package/dist/http/routes.d.ts +18 -0
  18. package/dist/http/routes.js +150 -0
  19. package/dist/http/server.d.ts +7 -0
  20. package/dist/http/server.js +19 -0
  21. package/dist/index.d.ts +36 -0
  22. package/dist/index.js +47 -0
  23. package/dist/integration/define-integration.d.ts +16 -0
  24. package/dist/integration/define-integration.js +50 -0
  25. package/dist/integration/types.d.ts +148 -0
  26. package/dist/integration/types.js +1 -0
  27. package/dist/lifecycle/dispatcher.d.ts +33 -0
  28. package/dist/lifecycle/dispatcher.js +315 -0
  29. package/dist/lifecycle/inbound.d.ts +50 -0
  30. package/dist/lifecycle/inbound.js +124 -0
  31. package/dist/logging/logger.d.ts +23 -0
  32. package/dist/logging/logger.js +23 -0
  33. package/dist/manifest.d.ts +52 -0
  34. package/dist/manifest.js +36 -0
  35. package/dist/provisioning/ensure.d.ts +24 -0
  36. package/dist/provisioning/ensure.js +104 -0
  37. package/dist/provisioning/runner.d.ts +16 -0
  38. package/dist/provisioning/runner.js +49 -0
  39. package/dist/runtime/create-app.d.ts +66 -0
  40. package/dist/runtime/create-app.js +211 -0
  41. package/dist/runtime/rate-limiter.d.ts +19 -0
  42. package/dist/runtime/rate-limiter.js +46 -0
  43. package/dist/sdk/send-bulk.d.ts +46 -0
  44. package/dist/sdk/send-bulk.js +40 -0
  45. package/dist/sdk/source-scope.d.ts +38 -0
  46. package/dist/sdk/source-scope.js +34 -0
  47. package/dist/security/crypto.d.ts +19 -0
  48. package/dist/security/crypto.js +82 -0
  49. package/dist/security/redaction.d.ts +15 -0
  50. package/dist/security/redaction.js +56 -0
  51. package/dist/security/signature.d.ts +31 -0
  52. package/dist/security/signature.js +30 -0
  53. package/dist/store/db.d.ts +7 -0
  54. package/dist/store/db.js +22 -0
  55. package/dist/store/migrate.d.ts +10 -0
  56. package/dist/store/migrate.js +35 -0
  57. package/dist/store/migrations.d.ts +27 -0
  58. package/dist/store/migrations.js +128 -0
  59. package/dist/store/repositories.d.ts +102 -0
  60. package/dist/store/repositories.js +281 -0
  61. package/dist/store/types.d.ts +62 -0
  62. package/dist/store/types.js +1 -0
  63. package/dist/sync/concurrency.d.ts +12 -0
  64. package/dist/sync/concurrency.js +30 -0
  65. package/dist/sync/cursor.d.ts +16 -0
  66. package/dist/sync/cursor.js +14 -0
  67. package/dist/sync/engine.d.ts +49 -0
  68. package/dist/sync/engine.js +129 -0
  69. package/dist/sync/paginate.d.ts +14 -0
  70. package/dist/sync/paginate.js +42 -0
  71. package/dist/testing/harness.d.ts +49 -0
  72. package/dist/testing/harness.js +110 -0
  73. package/package.json +51 -0
@@ -0,0 +1,104 @@
1
+ import { SpmDataSources, SpmCustomDataDefinitions, SpmEvents, SpmHelpers, } from '@shopimind/sdk-js';
2
+ /**
3
+ * Idempotent find-or-create for ShopiMind resources, calling the SDK directly
4
+ * (`unwrapOrThrow` unwraps the envelope / throws `SpmApiError` on failure). An
5
+ * integration only declares the shapes.
6
+ */
7
+ /** Reads an id, tolerating either field name the API may return. */
8
+ function numId(o, ...keys) {
9
+ for (const k of keys) {
10
+ if (o[k] != null)
11
+ return Number(o[k]);
12
+ }
13
+ return 0;
14
+ }
15
+ /** Maps a kit field to an SDK/API field (label defaults to name, required defaults to false). */
16
+ function toSdkField(f) {
17
+ return {
18
+ name: f.name,
19
+ label: f.label ?? f.name,
20
+ type: f.type,
21
+ required: f.required ?? false,
22
+ ...(f.description !== undefined ? { description: f.description } : {}),
23
+ ...(f.default !== undefined ? { default: f.default } : {}),
24
+ ...(f.options !== undefined ? { options: f.options } : {}),
25
+ };
26
+ }
27
+ /** Maps a kit definition to an SDK/API DTO. */
28
+ function toSdkDef(def) {
29
+ return {
30
+ name: def.name,
31
+ ...(def.description !== undefined ? { description: def.description } : {}),
32
+ ...(def.unique_keys !== undefined ? { unique_keys: def.unique_keys } : {}),
33
+ fields: def.fields.map(toSdkField),
34
+ ...(def.relationships !== undefined ? { relationships: def.relationships } : {}),
35
+ };
36
+ }
37
+ /** Normalizes a natural key for comparison (trim; nullish -> empty string). */
38
+ function normKey(v) {
39
+ return typeof v === 'string' ? v.trim() : v == null ? '' : String(v).trim();
40
+ }
41
+ /**
42
+ * Finds a data source by `label`, otherwise creates it. Returns its id.
43
+ *
44
+ * Invariant: `label` is expected to be UNIQUE across an account's data sources;
45
+ * the find-or-create matches on it as a natural key. The comparison is trimmed
46
+ * on both sides so trailing/leading whitespace differences do not spawn a
47
+ * duplicate source.
48
+ */
49
+ export async function ensureDataSource(client, input) {
50
+ const existing = SpmHelpers.unwrapOrThrow(await SpmDataSources.list(client), 'listDataSources');
51
+ const wantedLabel = normKey(input.label);
52
+ const found = (existing ?? []).find((s) => normKey(s.label) === wantedLabel);
53
+ if (found)
54
+ return numId(found, 'id_data_source', 'id');
55
+ const created = SpmHelpers.unwrapOrThrow(await SpmDataSources.create(client, input), 'createDataSource');
56
+ return numId(created, 'id_data_source', 'id');
57
+ }
58
+ /**
59
+ * Finds a custom-data definition by its natural key (`name`, falling back to the
60
+ * API's `schema_name`), otherwise creates and activates it. Convergent
61
+ * provisioning: if the integration has evolved, the existing definition is
62
+ * extended (only the missing fields are sent) instead of being left untouched.
63
+ *
64
+ * Invariant: a definition's `name` (alias `schema_name`) is expected to be UNIQUE
65
+ * across an account; it is matched as a natural key. The comparison is trimmed on
66
+ * both sides so incidental whitespace differences do not spawn a duplicate.
67
+ */
68
+ export async function ensureCustomDataDefinition(client, def) {
69
+ const existing = SpmHelpers.unwrapOrThrow(await SpmCustomDataDefinitions.list(client), 'listCustomDataDefinitions');
70
+ const wantedName = normKey(def.name);
71
+ const found = (existing ?? []).find((d) => normKey(d.name ?? d.schema_name) === wantedName);
72
+ if (found) {
73
+ const id = numId(found, 'id_definition', 'id');
74
+ const full = SpmHelpers.unwrapOrThrow(await SpmCustomDataDefinitions.get(client, id), 'getCustomDataDefinition');
75
+ const fields = Array.isArray(full.fields) ? full.fields : [];
76
+ const have = new Set(fields.map((f) => f.name).filter((n) => !!n));
77
+ const missing = def.fields.filter((f) => !have.has(f.name));
78
+ if (missing.length > 0) {
79
+ SpmHelpers.unwrapOrThrow(await SpmCustomDataDefinitions.extend(client, id, {
80
+ fields: missing.map(toSdkField),
81
+ }), 'extendCustomDataDefinition');
82
+ }
83
+ return id;
84
+ }
85
+ const created = SpmHelpers.unwrapOrThrow(await SpmCustomDataDefinitions.create(client, toSdkDef(def)), 'createCustomDataDefinition');
86
+ const id = numId(created, 'id_definition', 'id');
87
+ // Activation is best-effort and idempotent: a 409 means "already active", which
88
+ // is the desired end state, so we tolerate it. Any OTHER non-ok envelope is a
89
+ // genuine surprise (e.g. 5xx, permission) — we surface it as a warning rather
90
+ // than throwing, so a freshly created (but not yet activated) definition does
91
+ // not abort the whole provisioning run, but the operator still gets a signal.
92
+ const activated = await SpmCustomDataDefinitions.activate(client, id);
93
+ if (!activated.ok && activated.statusCode !== 409) {
94
+ console.warn(`ensureCustomDataDefinition: activate(${id}) returned status ${activated.statusCode}; definition created but not confirmed active`);
95
+ }
96
+ return id;
97
+ }
98
+ /** Creates an event type, tolerating a 409 "already exists" (idempotent). */
99
+ export async function ensureEvent(client, event) {
100
+ const res = await SpmEvents.create(client, event);
101
+ if (res.ok || res.statusCode === 409)
102
+ return;
103
+ SpmHelpers.unwrapOrThrow(res, 'createEvent'); // throws SpmApiError for any other error
104
+ }
@@ -0,0 +1,16 @@
1
+ import { type SpmHttpClient } from '@shopimind/sdk-js';
2
+ import type { ProvisioningPlan } from '../integration/types.js';
3
+ /**
4
+ * Runs a `ProvisioningPlan` (idempotent find-or-create). Best-effort per
5
+ * resource: an error is collected without interrupting the others. Returns the
6
+ * resolved ids (sources by `key`, definitions by `name`) that the integration
7
+ * then reuses during sync.
8
+ */
9
+ export interface ProvisioningResult {
10
+ sourceIds: Record<string, number>;
11
+ defIds: Record<string, number>;
12
+ events: number;
13
+ orderStatuses: number;
14
+ errors: string[];
15
+ }
16
+ export declare function runProvisioning(client: SpmHttpClient, plan: ProvisioningPlan): Promise<ProvisioningResult>;
@@ -0,0 +1,49 @@
1
+ import { SpmOrdersStatuses, SpmHelpers } from '@shopimind/sdk-js';
2
+ import { validateProvisioningEvents } from '../integration/define-integration.js';
3
+ import { ensureDataSource, ensureCustomDataDefinition, ensureEvent } from './ensure.js';
4
+ export async function runProvisioning(client, plan) {
5
+ const result = { sourceIds: {}, defIds: {}, events: 0, orderStatuses: 0, errors: [] };
6
+ for (const ds of plan.dataSources ?? []) {
7
+ try {
8
+ const parentId = ds.parentKey ? result.sourceIds[ds.parentKey] : undefined;
9
+ const decl = parentId != null ? { ...ds.decl, parent_id: parentId } : ds.decl;
10
+ result.sourceIds[ds.key] = await ensureDataSource(client, decl);
11
+ }
12
+ catch (e) {
13
+ result.errors.push(`source ${ds.key}: ${errMsg(e)}`);
14
+ }
15
+ }
16
+ for (const def of plan.customData ?? []) {
17
+ try {
18
+ result.defIds[def.name] = await ensureCustomDataDefinition(client, def);
19
+ }
20
+ catch (e) {
21
+ result.errors.push(`def ${def.name}: ${errMsg(e)}`);
22
+ }
23
+ }
24
+ for (const ev of plan.events ?? []) {
25
+ try {
26
+ // Contract guard: reject a malformed event (missing code_name) before the
27
+ // network call. Collected per-resource (best-effort), like other errors.
28
+ validateProvisioningEvents([ev]);
29
+ await ensureEvent(client, ev);
30
+ result.events += 1;
31
+ }
32
+ catch (e) {
33
+ result.errors.push(`event ${ev.code_name}: ${errMsg(e)}`);
34
+ }
35
+ }
36
+ if (plan.orderStatuses && plan.orderStatuses.length > 0) {
37
+ try {
38
+ const env = await SpmOrdersStatuses.bulkSave(client, plan.orderStatuses, { chunk: true });
39
+ result.orderStatuses = SpmHelpers.extractCounts(env).sent;
40
+ }
41
+ catch (e) {
42
+ result.errors.push(`order statuses: ${errMsg(e)}`);
43
+ }
44
+ }
45
+ return result;
46
+ }
47
+ function errMsg(e) {
48
+ return e instanceof Error ? e.message : String(e);
49
+ }
@@ -0,0 +1,66 @@
1
+ import type { Server } from '@hapi/hapi';
2
+ import { type SpmHttpClient } from '@shopimind/sdk-js';
3
+ import type { Integration } from '../integration/types.js';
4
+ import { type Db } from '../store/db.js';
5
+ import { type Repositories } from '../store/repositories.js';
6
+ import { type Logger } from '../logging/logger.js';
7
+ import { type SyncSummary } from '../sync/engine.js';
8
+ export interface CreateAppOptions<S> {
9
+ databasePath: string;
10
+ webhookSecret: string;
11
+ /**
12
+ * Override for the ShopiMind SDK base URL (otherwise env `SHOPIMIND_CORE_API_BASE`,
13
+ * then `https://core.shopimind.com`). Useful in tests / preprod.
14
+ */
15
+ spmBaseUrl?: string;
16
+ /**
17
+ * Override for SDK client construction. Lets a caller inject a client instance
18
+ * instead of the kit building one via `SpmClient.getClient`.
19
+ */
20
+ makeSpmClient?: (accessToken: string) => SpmHttpClient;
21
+ credentialsKey?: string | null;
22
+ /**
23
+ * EXPLICITLY allows storing secrets IN PLAINTEXT when no `credentialsKey` is
24
+ * provided (LOCAL DEVELOPMENT ONLY). Defaults to `false` -> without a key,
25
+ * startup fails. A loud WARN is emitted when enabled.
26
+ */
27
+ allowPlaintextSecrets?: boolean;
28
+ adminToken?: string | null;
29
+ signatureToleranceSeconds?: number;
30
+ backfillDays?: number;
31
+ port?: number;
32
+ host?: string;
33
+ logger?: Logger;
34
+ /** Runs a full backfill automatically on activation (default true). */
35
+ autoBackfillOnActivate?: boolean;
36
+ /** Enables the internal incremental sync scheduler (default true). */
37
+ autoSync?: boolean;
38
+ /**
39
+ * Interval (in MINUTES) between two automatic incremental syncs of each active
40
+ * installation. Default 15. A value <= 0 disables the scheduler.
41
+ */
42
+ syncIntervalMinutes?: number;
43
+ /**
44
+ * Retention (in DAYS) for the webhook log and the anti-replay / inbound-event
45
+ * tables. A daily purge deletes rows older than this. Default 90. A value <= 0
46
+ * disables the purge (tables then grow unbounded).
47
+ */
48
+ retentionDays?: number;
49
+ now?: () => number;
50
+ }
51
+ export interface IntegrationApp {
52
+ server: Server;
53
+ db: Db;
54
+ repos: Repositories;
55
+ start(): Promise<void>;
56
+ stop(): Promise<void>;
57
+ runSyncOnce(installationId: string, opts?: {
58
+ full?: boolean;
59
+ }): Promise<SyncSummary | null>;
60
+ }
61
+ /**
62
+ * Assembles a ready-to-run integration: store + crypto + repositories + logger +
63
+ * dispatcher + sync engine + HTTP server + internal scheduler. The kit builds the
64
+ * SDK client itself (direct dependency on `@shopimind/sdk-js`).
65
+ */
66
+ export declare function createIntegrationApp<S>(integration: Integration<S>, opts: CreateAppOptions<S>): IntegrationApp;
@@ -0,0 +1,211 @@
1
+ import { SpmClient } from '@shopimind/sdk-js';
2
+ import { openDatabase } from '../store/db.js';
3
+ import { createRepositories } from '../store/repositories.js';
4
+ import { SecretCipher } from '../security/crypto.js';
5
+ import { createLogger } from '../logging/logger.js';
6
+ import { loadConfigs } from '../config/config-store.js';
7
+ import { runIntegrationSync } from '../sync/engine.js';
8
+ import { ACCESS_TOKEN_KEY, PROVISIONING_KEY } from '../lifecycle/dispatcher.js';
9
+ import { ensureInboundSecret } from '../lifecycle/inbound.js';
10
+ import { makeWithSource } from '../sdk/source-scope.js';
11
+ import { makeSendBulk } from '../sdk/send-bulk.js';
12
+ import { createRateLimiter } from './rate-limiter.js';
13
+ import { createServer } from '../http/server.js';
14
+ import { buildRoutes } from '../http/routes.js';
15
+ /**
16
+ * Assembles a ready-to-run integration: store + crypto + repositories + logger +
17
+ * dispatcher + sync engine + HTTP server + internal scheduler. The kit builds the
18
+ * SDK client itself (direct dependency on `@shopimind/sdk-js`).
19
+ */
20
+ export function createIntegrationApp(integration, opts) {
21
+ const db = openDatabase(opts.databasePath);
22
+ // Fail-closed by default: without a key, encryption at rest is MANDATORY
23
+ // (the constructor throws), unless explicitly opted out via `allowPlaintextSecrets`.
24
+ const allowPlaintext = opts.allowPlaintextSecrets ?? false;
25
+ const cipher = new SecretCipher({ key: opts.credentialsKey ?? null, production: !allowPlaintext });
26
+ const repos = createRepositories(db, cipher);
27
+ const logger = opts.logger ?? createLogger({ bindings: { integration: integration.slug } });
28
+ if (cipher.insecure) {
29
+ logger.warn('SECRETS STORED IN PLAINTEXT: no CREDENTIALS_KEY (allowPlaintextSecrets enabled). ' +
30
+ 'For local development only -- NEVER use in preprod/production.');
31
+ }
32
+ const backfillDays = opts.backfillDays ?? 365;
33
+ // The kit builds the SDK client itself; an `opts.makeSpmClient` override lets a
34
+ // caller inject a client instance instead.
35
+ const makeSpmClient = opts.makeSpmClient ??
36
+ ((token) => SpmClient.getClient('v1', token, { labelSource: null, ...(opts.spmBaseUrl ? { baseUrl: opts.spmBaseUrl } : {}) }));
37
+ const buildContext = (id) => {
38
+ const token = repos.state.get(id, ACCESS_TOKEN_KEY);
39
+ if (!token)
40
+ return null;
41
+ const configs = loadConfigs(repos.state, id, integration.configSchema);
42
+ const spm = makeSpmClient(token);
43
+ const ctxLogger = logger.child({ installation_id: id });
44
+ const sendBulk = makeSendBulk(spm, ctxLogger);
45
+ return {
46
+ installationId: id,
47
+ settings: integration.parseSettings(configs),
48
+ spm,
49
+ sendBulk,
50
+ state: repos.state,
51
+ logger: ctxLogger,
52
+ setExternalAccount: (acc) => repos.installs.setExternalAccount(id, acc.id, acc.name ?? null),
53
+ inboundSecret: ensureInboundSecret(repos.state, id),
54
+ withSource: makeWithSource(repos.state, id, PROVISIONING_KEY, sendBulk),
55
+ };
56
+ };
57
+ // Per-installation overlap lock: prevents a post-activation backfill, a scheduled
58
+ // sync, and an admin call from running at the same time on the same installation
59
+ // (race on the cursors).
60
+ const running = new Set();
61
+ const runSyncOnce = async (id, o) => {
62
+ if (running.has(id)) {
63
+ logger.warn('sync skipped: already running for this installation', { installation_id: id });
64
+ return null;
65
+ }
66
+ const base = buildContext(id);
67
+ if (!base) {
68
+ logger.warn('sync skipped: no context (unknown installation or no token)', { installation_id: id });
69
+ return null;
70
+ }
71
+ running.add(id);
72
+ try {
73
+ return await runIntegrationSync(integration, base, {
74
+ cursors: repos.cursors,
75
+ runs: repos.runs,
76
+ makeSource: (sb) => makeWithSource(repos.state, id, PROVISIONING_KEY, sb),
77
+ }, { fullBackfill: o?.full ?? false, backfillDays });
78
+ }
79
+ finally {
80
+ running.delete(id);
81
+ }
82
+ };
83
+ const autoBackfill = opts.autoBackfillOnActivate ?? true;
84
+ const dispatcher = {
85
+ integration,
86
+ repos,
87
+ secret: opts.webhookSecret,
88
+ logger,
89
+ makeSpmClient,
90
+ ...(opts.signatureToleranceSeconds != null ? { toleranceSeconds: opts.signatureToleranceSeconds } : {}),
91
+ ...(opts.now ? { now: opts.now } : {}),
92
+ ...(autoBackfill
93
+ ? {
94
+ afterActivate: (id) => {
95
+ setImmediate(() => {
96
+ void runSyncOnce(id, { full: true }).catch((e) => logger.error('post-activation backfill failed', { installation_id: id, error: String(e) }));
97
+ });
98
+ },
99
+ }
100
+ : {}),
101
+ };
102
+ const inboundRateLimit = createRateLimiter(opts.now ? { now: opts.now } : {});
103
+ // Dedicated limiter for /admin/* routes (per IP): bounds token brute-forcing and
104
+ // backfill abuse -- stricter than the inbound limiter.
105
+ const adminRateLimit = createRateLimiter({ capacity: 10, refillPerSec: 1, ...(opts.now ? { now: opts.now } : {}) });
106
+ // Per-IP limiter for POST /webhook/receive: bounds a flood of unsigned/forged
107
+ // requests before the (costly) HMAC verification runs.
108
+ const webhookRateLimit = createRateLimiter(opts.now ? { now: opts.now } : {});
109
+ const server = createServer({ port: opts.port ?? 8080, ...(opts.host ? { host: opts.host } : {}) });
110
+ server.route(buildRoutes({
111
+ dispatcher,
112
+ adminToken: opts.adminToken ?? null,
113
+ adminRateLimit,
114
+ webhookRateLimit,
115
+ runSyncForInstall: (id, full) => runSyncOnce(id, { full }),
116
+ recentRuns: (id) => repos.runs.recent(id),
117
+ inbound: {
118
+ integration,
119
+ repos,
120
+ logger,
121
+ buildContext,
122
+ rateLimit: inboundRateLimit,
123
+ ...(opts.signatureToleranceSeconds != null ? { toleranceSeconds: opts.signatureToleranceSeconds } : {}),
124
+ ...(opts.now ? { now: opts.now } : {}),
125
+ },
126
+ }));
127
+ // ---- Internal scheduler (periodic incremental sync) -----------------------
128
+ const intervalMinutes = opts.syncIntervalMinutes ?? 15;
129
+ const autoSync = (opts.autoSync ?? true) && intervalMinutes > 0;
130
+ let timer = null;
131
+ let sweeping = false;
132
+ /** One pass: incremental sync of all active installations. */
133
+ const sweep = async () => {
134
+ if (sweeping)
135
+ return; // the previous pass has not finished -> skip this tick
136
+ sweeping = true;
137
+ try {
138
+ for (const inst of repos.installs.listActive()) {
139
+ try {
140
+ await runSyncOnce(inst.installation_id, { full: false });
141
+ }
142
+ catch (e) {
143
+ logger.error('scheduled sync failed', {
144
+ installation_id: inst.installation_id,
145
+ error: e instanceof Error ? e.message : String(e),
146
+ });
147
+ }
148
+ }
149
+ }
150
+ finally {
151
+ sweeping = false;
152
+ }
153
+ };
154
+ // ---- Retention: bounded growth of the log / anti-replay tables -------------
155
+ const retentionDays = opts.retentionDays ?? 90;
156
+ const retentionEnabled = retentionDays > 0;
157
+ let retentionTimer = null;
158
+ const purgeOldRecords = () => {
159
+ try {
160
+ const log = repos.webhookLog.purgeOlderThan(retentionDays);
161
+ const seen = repos.webhookSeen.purgeOlderThan(retentionDays);
162
+ const inbound = repos.inboundEvents.purgeOlderThan(retentionDays);
163
+ if (log + seen + inbound > 0) {
164
+ logger.info('retention purge', { webhook_log: log, webhook_seen: seen, inbound_event: inbound, retentionDays });
165
+ }
166
+ }
167
+ catch (e) {
168
+ logger.error('retention purge failed', { error: e instanceof Error ? e.message : String(e) });
169
+ }
170
+ };
171
+ return {
172
+ server,
173
+ db,
174
+ repos,
175
+ start: async () => {
176
+ await server.start();
177
+ logger.info('integration started', { uri: server.info.uri });
178
+ if (autoSync) {
179
+ timer = setInterval(() => {
180
+ void sweep();
181
+ }, intervalMinutes * 60_000);
182
+ timer.unref();
183
+ logger.info('sync scheduler enabled', { intervalMinutes });
184
+ }
185
+ if (retentionEnabled) {
186
+ purgeOldRecords();
187
+ retentionTimer = setInterval(() => purgeOldRecords(), 24 * 60 * 60_000);
188
+ retentionTimer.unref();
189
+ logger.info('retention enabled', { retentionDays });
190
+ }
191
+ },
192
+ stop: async () => {
193
+ if (timer) {
194
+ clearInterval(timer);
195
+ timer = null;
196
+ }
197
+ if (retentionTimer) {
198
+ clearInterval(retentionTimer);
199
+ retentionTimer = null;
200
+ }
201
+ try {
202
+ await server.stop();
203
+ }
204
+ catch {
205
+ /* server not started */
206
+ }
207
+ db.close();
208
+ },
209
+ runSyncOnce,
210
+ };
211
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Token-bucket limiter, keyed (e.g. per installation). In-memory -- sufficient for
3
+ * a single instance. `capacity` max tokens, refilled at `refillPerSec` tokens/second.
4
+ * Returns a function `(key) => boolean`: true if the call is allowed.
5
+ *
6
+ * Eviction: keys are tracked in a Map. To avoid an unbounded memory leak (and a slow
7
+ * DoS via a flood of distinct keys, e.g. one per spoofed IP), the Map is capped at
8
+ * `maxKeys` entries. When the cap is reached, fully-refilled (idle) buckets are pruned
9
+ * first, then -- if still over the cap -- the least-recently-seen buckets are evicted.
10
+ * Evicting a full bucket is harmless: a re-created bucket also starts at `capacity`.
11
+ */
12
+ export interface RateLimiterOptions {
13
+ capacity?: number;
14
+ refillPerSec?: number;
15
+ now?: () => number;
16
+ /** Upper bound on the number of tracked keys before eviction kicks in (default 10000). */
17
+ maxKeys?: number;
18
+ }
19
+ export declare function createRateLimiter(opts?: RateLimiterOptions): (key: string) => boolean;
@@ -0,0 +1,46 @@
1
+ export function createRateLimiter(opts = {}) {
2
+ const capacity = opts.capacity ?? 20;
3
+ const refillPerSec = opts.refillPerSec ?? 5;
4
+ const now = opts.now ?? Date.now;
5
+ const maxKeys = opts.maxKeys ?? 10000;
6
+ const buckets = new Map();
7
+ /**
8
+ * Keep the Map bounded. Called when a brand-new key would push the Map over `maxKeys`.
9
+ * First drop buckets that are fully refilled (idle -- safe to forget, they reset to
10
+ * `capacity` on recreation); if that is not enough, evict in least-recently-seen order.
11
+ * Map preserves insertion order, but `last` is the authoritative recency, so we sort.
12
+ */
13
+ const evict = () => {
14
+ // Pass 1: prune idle (fully-refilled) buckets -- losing them changes nothing.
15
+ for (const [k, b] of buckets) {
16
+ if (b.tokens >= capacity)
17
+ buckets.delete(k);
18
+ if (buckets.size < maxKeys)
19
+ return;
20
+ }
21
+ if (buckets.size < maxKeys)
22
+ return;
23
+ // Pass 2: still full of active buckets -- evict the least-recently-seen ones.
24
+ const entries = [...buckets.entries()].sort((a, b) => a[1].last - b[1].last);
25
+ const toRemove = buckets.size - maxKeys + 1;
26
+ for (let i = 0; i < toRemove && i < entries.length; i += 1) {
27
+ buckets.delete(entries[i][0]);
28
+ }
29
+ };
30
+ return (key) => {
31
+ const t = now();
32
+ const existing = buckets.get(key);
33
+ if (!existing && buckets.size >= maxKeys)
34
+ evict();
35
+ const b = existing ?? { tokens: capacity, last: t };
36
+ b.tokens = Math.min(capacity, b.tokens + ((t - b.last) / 1000) * refillPerSec);
37
+ b.last = t;
38
+ if (b.tokens < 1) {
39
+ buckets.set(key, b);
40
+ return false;
41
+ }
42
+ b.tokens -= 1;
43
+ buckets.set(key, b);
44
+ return true;
45
+ };
46
+ }
@@ -0,0 +1,46 @@
1
+ import { type SpmEnvelope, type SpmHttpClient } from '@shopimind/sdk-js';
2
+ import type { Logger } from '../logging/logger.js';
3
+ /**
4
+ * Normalized result of a safe bulk push. Note there is no `failed` field: a
5
+ * transport-level failure (`failed_count > 0`) THROWS (it must replay), so a
6
+ * resolved result only ever carries successfully-attempted + per-item-rejected items.
7
+ */
8
+ export interface BulkResult {
9
+ sent: number;
10
+ /** Items the API REJECTED (validation, per-item, permanent-ish). Returned, not thrown. */
11
+ rejected: number;
12
+ /** The rejected items (bounded by chunk size) — for logging / targeted retry. */
13
+ rejected_items: unknown[];
14
+ }
15
+ /** Options forwarded to the SDK bulk call (chunking is ON by default). */
16
+ export interface SendBulkOptions {
17
+ chunk?: boolean;
18
+ chunkSize?: number;
19
+ }
20
+ type FlatBulkFn<T> = (client: SpmHttpClient, data: T[], opts?: SendBulkOptions) => Promise<SpmEnvelope>;
21
+ /**
22
+ * Safe bulk push. The SAFETY primitive of the kit, usable everywhere (sync steps
23
+ * AND real-time inbound handlers). Two call forms:
24
+ * - flat: `sendBulk(SpmProducts.bulkSave, items, opts?)` — for the `(client, data, opts)` shape
25
+ * - thunk: `sendBulk(() => SpmProductsVariations.bulkSave(client, productId, items, { chunk: true }))`
26
+ * — for path-param shapes the flat form cannot express.
27
+ *
28
+ * Throws `SpmApiError` on any TRANSPORT failure — a global `!ok` OR `failed_count > 0`
29
+ * (a chunk that errored): transport is transient, so the caller must REPLAY. Per-item
30
+ * REJECTIONS (validation) are NOT a throw (one bad item must not abort the whole batch):
31
+ * they are RETURNED (and surfaced via the optional `onReject` sink + a warn log). The
32
+ * sync engine uses the sink to hold the cursor. Nothing is ever dropped silently.
33
+ */
34
+ export interface SendBulk {
35
+ <T>(fn: FlatBulkFn<T>, items: T[], opts?: SendBulkOptions): Promise<BulkResult>;
36
+ (thunk: () => Promise<SpmEnvelope>): Promise<BulkResult>;
37
+ }
38
+ /**
39
+ * Builds a {@link SendBulk}. `onReject`, when provided, is called with the REJECTED
40
+ * (validation) count + items so the sync engine can hold the cursor. Transport
41
+ * failures (`failed_count`) THROW instead — they can never reach the sink, so
42
+ * `tolerateRejects` cannot tolerate them. A warn log fires regardless, so neither
43
+ * rejections nor failures are ever invisible.
44
+ */
45
+ export declare function makeSendBulk(client: SpmHttpClient, logger: Logger, onReject?: (count: number, items: unknown[]) => void): SendBulk;
46
+ export {};
@@ -0,0 +1,40 @@
1
+ import { SpmApiError, SpmHelpers, } from '@shopimind/sdk-js';
2
+ function rejectedItemsOf(env) {
3
+ const d = env.data && typeof env.data === 'object' ? env.data : {};
4
+ return Array.isArray(d.rejected_items) ? d.rejected_items : [];
5
+ }
6
+ /**
7
+ * Builds a {@link SendBulk}. `onReject`, when provided, is called with the REJECTED
8
+ * (validation) count + items so the sync engine can hold the cursor. Transport
9
+ * failures (`failed_count`) THROW instead — they can never reach the sink, so
10
+ * `tolerateRejects` cannot tolerate them. A warn log fires regardless, so neither
11
+ * rejections nor failures are ever invisible.
12
+ */
13
+ export function makeSendBulk(client, logger, onReject) {
14
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
+ return (async (fnOrThunk, items, opts) => {
16
+ const env = items === undefined
17
+ ? await fnOrThunk()
18
+ : await fnOrThunk(client, items, { chunk: true, ...opts });
19
+ // Transport/HTTP failure -> throw (the caller replays). Never a silent drop.
20
+ if (!env.ok)
21
+ throw new SpmApiError(env, 'sendBulk');
22
+ const { sent, rejected, failed } = SpmHelpers.extractCounts(env);
23
+ const rejected_items = rejectedItemsOf(env);
24
+ // FAILED = transport-level chunk failure (transient). The merged envelope couples
25
+ // failed_count>0 to ok:false (so the !ok throw above usually fires first), but a
26
+ // single bulk could still report failed_count on a 200 — so re-assert it here.
27
+ // Transport must REPLAY -> throw; `tolerateRejects` can therefore never tolerate it.
28
+ if (failed > 0) {
29
+ logger.warn('bulk push had failed chunks (transport) — replay required', { sent, rejected, failed });
30
+ throw new SpmApiError(env, `sendBulk (${failed} transport failure(s))`);
31
+ }
32
+ // REJECTED = per-item validation (permanent-ish): surfaced (warn + sink) and
33
+ // RETURNED, not thrown — one bad item must not abort the whole batch.
34
+ if (rejected > 0) {
35
+ logger.warn('bulk push had rejected items (validation)', { sent, rejected });
36
+ onReject?.(rejected, rejected_items);
37
+ }
38
+ return { sent, rejected, rejected_items };
39
+ });
40
+ }
@@ -0,0 +1,38 @@
1
+ import type { SpmEnvelope, SpmHttpClient } from '@shopimind/sdk-js';
2
+ import type { IntegrationStateRepo } from '../store/repositories.js';
3
+ import type { BulkResult, SendBulk, SendBulkOptions } from './send-bulk.js';
4
+ /**
5
+ * Handle for a PROVISIONED source. Exposes its `id_data_source` plus a `tag()`
6
+ * that injects it into every item — the recommended way to ensure an integration
7
+ * NEVER overwrites the merchant's native catalog. The SDK is not re-wrapped: the
8
+ * integration tags items then pushes them itself via the SDK
9
+ * (`SpmProducts.bulkSave(ctx.spm, tagged)`).
10
+ *
11
+ * Note: the source alone is not enough. On the core side, `id_data_source` is NOT
12
+ * part of the upsert key. You must ALSO namespace your identifiers (prefix/offset)
13
+ * so the upsert lands on rows distinct from the native ones. `tag()` guarantees the
14
+ * tag; namespacing remains the responsibility of your mappers.
15
+ */
16
+ export interface SourceHandle {
17
+ /** `id_data_source` of the provisioned source (resolved by key). */
18
+ readonly id: number;
19
+ /** Tags each item with `id_data_source` (to be pushed afterwards via the SDK). */
20
+ tag<T extends object>(items: T[]): Array<T & {
21
+ id_data_source: number;
22
+ }>;
23
+ /**
24
+ * Tags then pushes via the safe bulk primitive: source tagging + envelope/rejection
25
+ * safety in one call (flat `(client, data, opts)` shape). For path-param bulks
26
+ * (variations/images/addresses), use `ctx.sendBulk(() => fn(ctx.spm, parentId, src.tag(items)))`.
27
+ */
28
+ send<T extends object>(fn: (client: SpmHttpClient, data: Array<T & {
29
+ id_data_source: number;
30
+ }>, opts?: SendBulkOptions) => Promise<SpmEnvelope>, items: T[], opts?: SendBulkOptions): Promise<BulkResult>;
31
+ }
32
+ /**
33
+ * Builds `ctx.withSource`: resolves the `id_data_source` of a PROVISIONED source
34
+ * (from the integration state) and returns a {@link SourceHandle}. Throws if the
35
+ * source was not declared in `provisioning.dataSources` (guard: pushing catalog data
36
+ * without a dedicated source is not allowed).
37
+ */
38
+ export declare function makeWithSource(state: IntegrationStateRepo, installationId: string, provisioningKey: string, sendBulk: SendBulk): (sourceKey: string) => SourceHandle;