@clivly/sdk 0.1.0 → 0.3.0-next.1

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/dist/index.cjs ADDED
@@ -0,0 +1,769 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_drizzle_meta = require("./drizzle-meta-iAu8w1hj.cjs");
3
+ //#region src/namespace-probe.ts
4
+ /**
5
+ * Verify the tables the integration depends on are genuinely reachable:
6
+ * connect, list tables, confirm each required table exists, then prove it's
7
+ * selectable with a sample `SELECT ... LIMIT 1`. Column-shape validation is
8
+ * not this probe's job — `doctor()`'s "Cursor/id columns exist" check
9
+ * validates columns using the correct Drizzle metadata. Never throws —
10
+ * connection/permission problems become `error.kind: "unreachable"`, missing
11
+ * tables become `"schema"`.
12
+ */
13
+ async function probeNamespace(introspector, requiredTables) {
14
+ let present;
15
+ try {
16
+ present = new Set(await introspector.listTables());
17
+ } catch (error) {
18
+ return {
19
+ reachable: false,
20
+ missingTables: requiredTables,
21
+ error: {
22
+ kind: "unreachable",
23
+ message: errorMessage(error)
24
+ }
25
+ };
26
+ }
27
+ const missingTables = requiredTables.filter((t) => !present.has(t));
28
+ if (missingTables.length > 0) return {
29
+ reachable: false,
30
+ missingTables,
31
+ error: {
32
+ kind: "schema",
33
+ message: `missing table(s): ${missingTables.join(", ")}`
34
+ }
35
+ };
36
+ for (const table of requiredTables) try {
37
+ await introspector.sampleSelect(table);
38
+ } catch (error) {
39
+ return {
40
+ reachable: false,
41
+ missingTables: [],
42
+ error: {
43
+ kind: "unreachable",
44
+ message: errorMessage(error)
45
+ }
46
+ };
47
+ }
48
+ return {
49
+ reachable: true,
50
+ missingTables: []
51
+ };
52
+ }
53
+ /** The distinct source tables the integration reads, derived from the entity config. */
54
+ function requiredTablesFromConfig(config) {
55
+ const tables = /* @__PURE__ */ new Set();
56
+ const sources = [
57
+ config.source?.contacts,
58
+ config.source?.companies,
59
+ config.source?.deals,
60
+ ...config.source?.custom ?? []
61
+ ].filter((source) => Boolean(source));
62
+ const tableNameByEntity = new Map(sources.map((source) => [source.entity, source[require_drizzle_meta.DRIZZLE_META]?.tableName]));
63
+ for (const entity of Object.values(config.entities?.entities ?? {})) tables.add(tableNameByEntity.get(entity.source) ?? entity.source);
64
+ return [...tables];
65
+ }
66
+ function errorMessage(error) {
67
+ return error instanceof Error ? error.message : String(error);
68
+ }
69
+ //#endregion
70
+ //#region src/doctor.ts
71
+ function configuredSources(config) {
72
+ const sources = [];
73
+ if (config.source?.contacts) sources.push(config.source.contacts);
74
+ if (config.source?.companies) sources.push(config.source.companies);
75
+ if (config.source?.deals) sources.push(config.source.deals);
76
+ sources.push(...config.source?.custom ?? []);
77
+ return sources;
78
+ }
79
+ async function namespaceCheck(config) {
80
+ if (!config.introspector) return {
81
+ name: "Namespace reachable",
82
+ ok: false,
83
+ skipped: true,
84
+ detail: "No introspector configured — set `introspector` in clivly.config to probe the namespace."
85
+ };
86
+ const probeResult = await probeNamespace(config.introspector, requiredTablesFromConfig(config));
87
+ return {
88
+ name: "Namespace reachable",
89
+ ok: probeResult.reachable,
90
+ detail: probeResult.reachable ? "All required source tables are reachable." : probeResult.error?.message ?? "namespace unreachable"
91
+ };
92
+ }
93
+ /**
94
+ * Run the read-only setup checks and return a structured report. Every check
95
+ * captures its own error into `detail` rather than throwing, so one failure never
96
+ * hides the rest. `ok` is the AND of all non-skipped checks.
97
+ */
98
+ async function runDoctor(args) {
99
+ const { config, connect } = args;
100
+ const checks = [];
101
+ const sources = configuredSources(config);
102
+ const hasKey = Boolean(config.apiKey);
103
+ checks.push({
104
+ name: "API key present",
105
+ ok: hasKey,
106
+ detail: hasKey ? "CLIVLY_API_KEY is set." : "apiKey is empty — set CLIVLY_API_KEY."
107
+ });
108
+ const entityCount = config.entities ? Object.keys(config.entities.entities).length : 0;
109
+ checks.push({
110
+ name: "Entities configured",
111
+ ok: entityCount > 0,
112
+ detail: entityCount > 0 ? `${entityCount} entit${entityCount === 1 ? "y" : "ies"} configured.` : "No `entities` config — pass defineClivlyConfig(...) as `entities`."
113
+ });
114
+ checks.push({
115
+ name: "Sources configured",
116
+ ok: sources.length > 0,
117
+ detail: sources.length > 0 ? `${sources.length} source(s) to mirror.` : "No `source.contacts` — nothing to sync."
118
+ });
119
+ for (const source of sources) {
120
+ const meta = source[require_drizzle_meta.DRIZZLE_META];
121
+ if (!meta) {
122
+ checks.push({
123
+ name: `Cursor/id columns exist (${source.entity})`,
124
+ ok: false,
125
+ skipped: true,
126
+ detail: `"${source.entity}" is a hand-written source — column check skipped.`
127
+ });
128
+ continue;
129
+ }
130
+ const idField = source.idField ?? "id";
131
+ const missing = [meta.cursorField, idField].filter((field) => !meta.columnKeys.includes(field));
132
+ checks.push({
133
+ name: `Cursor/id columns exist (${source.entity})`,
134
+ ok: missing.length === 0,
135
+ detail: missing.length === 0 ? `"${source.entity}": ${meta.cursorField} + ${idField} present.` : `"${source.entity}" missing column(s): ${missing.join(", ")}.`
136
+ });
137
+ }
138
+ for (const source of sources) try {
139
+ const rows = await source.fetchPage({
140
+ since: null,
141
+ limit: 1
142
+ });
143
+ const firstRow = rows[0];
144
+ const missingCursor = firstRow !== void 0 && !(source.cursorField in firstRow);
145
+ checks.push({
146
+ name: `Sample fetch (${source.entity})`,
147
+ ok: !missingCursor,
148
+ detail: missingCursor ? `"${source.entity}" rows lack the cursor key "${source.cursorField}".` : `"${source.entity}" returned ${rows.length} row(s).`
149
+ });
150
+ } catch (error) {
151
+ checks.push({
152
+ name: `Sample fetch (${source.entity})`,
153
+ ok: false,
154
+ detail: `"${source.entity}" fetchPage threw: ${error.message}`
155
+ });
156
+ }
157
+ const result = await connect();
158
+ checks.push({
159
+ name: "Heartbeat",
160
+ ok: result.ok,
161
+ detail: result.ok ? `Reached Clivly — org "${result.org?.name ?? "(unnamed)"}".` : `Could not connect (${result.reason}).`
162
+ });
163
+ checks.push(await namespaceCheck(config));
164
+ return {
165
+ ok: checks.every((check) => check.skipped || check.ok),
166
+ checks
167
+ };
168
+ }
169
+ //#endregion
170
+ //#region src/sync.ts
171
+ const DEFAULT_ID_FIELD = "id";
172
+ function isAfter(candidate, current) {
173
+ const ct = candidate.time.getTime();
174
+ const bt = current.time.getTime();
175
+ if (ct !== bt) return ct > bt;
176
+ return candidate.id > current.id;
177
+ }
178
+ function nextCursor(rows, cursorField, idField, current) {
179
+ let max = current;
180
+ for (const row of rows) {
181
+ const raw = row[cursorField];
182
+ if (raw === null || raw === void 0) continue;
183
+ const time = raw instanceof Date ? raw : new Date(String(raw));
184
+ if (Number.isNaN(time.getTime())) continue;
185
+ const idRaw = row[idField];
186
+ const candidate = {
187
+ time,
188
+ id: idRaw === null || idRaw === void 0 ? "" : String(idRaw)
189
+ };
190
+ if (!max || isAfter(candidate, max)) max = candidate;
191
+ }
192
+ return max;
193
+ }
194
+ async function syncSource(args) {
195
+ const { source, path, since, batchSize, push, objectType, maxRows } = args;
196
+ const idField = source.idField ?? DEFAULT_ID_FIELD;
197
+ let cursor = since;
198
+ let pushed = 0;
199
+ for (;;) {
200
+ const rows = await source.fetchPage({
201
+ since: cursor,
202
+ limit: batchSize
203
+ });
204
+ if (rows.length === 0) break;
205
+ const capped = maxRows === void 0 ? rows : rows.slice(0, maxRows);
206
+ if (!await push(path, {
207
+ source: source.entity,
208
+ rows: capped,
209
+ mode: "delta",
210
+ ...objectType ? { objectType } : {}
211
+ })) return {
212
+ pushed,
213
+ cursor,
214
+ ok: false
215
+ };
216
+ pushed += capped.length;
217
+ cursor = nextCursor(capped, source.cursorField, idField, cursor);
218
+ if (maxRows !== void 0 || rows.length < batchSize) break;
219
+ }
220
+ return {
221
+ pushed,
222
+ cursor,
223
+ ok: true
224
+ };
225
+ }
226
+ async function fullSyncSource(args) {
227
+ const { source, path, batchSize, push, objectType } = args;
228
+ const idField = source.idField ?? DEFAULT_ID_FIELD;
229
+ const all = [];
230
+ let cursor = null;
231
+ for (;;) {
232
+ const rows = await source.fetchPage({
233
+ since: cursor,
234
+ limit: batchSize
235
+ });
236
+ if (rows.length === 0) break;
237
+ all.push(...rows);
238
+ const next = nextCursor(rows, source.cursorField, idField, cursor);
239
+ const stalled = next !== null && cursor !== null && next.time.getTime() === cursor.time.getTime() && next.id === cursor.id;
240
+ cursor = next;
241
+ if (rows.length < batchSize || stalled) break;
242
+ }
243
+ if (all.length === 0) return {
244
+ pushed: 0,
245
+ cursor: null,
246
+ ok: true
247
+ };
248
+ const ok = await push(path, {
249
+ source: source.entity,
250
+ rows: all,
251
+ mode: "full",
252
+ ...objectType ? { objectType } : {}
253
+ });
254
+ return {
255
+ pushed: ok ? all.length : 0,
256
+ cursor,
257
+ ok
258
+ };
259
+ }
260
+ //#endregion
261
+ //#region src/errors.ts
262
+ var ClivlyError = class ClivlyError extends Error {
263
+ /** HTTP status, or null when the request never completed (network error). */
264
+ status;
265
+ reason;
266
+ /** Whether a retry could plausibly succeed (network, 429, 5xx). */
267
+ retryable;
268
+ constructor(result, message) {
269
+ super(message ?? `Clivly connection failed: ${result.reason}`);
270
+ this.name = new.target.name;
271
+ this.status = result.status;
272
+ this.reason = result.reason;
273
+ this.retryable = result.retryable;
274
+ }
275
+ /**
276
+ * Build the error subclass matching a failed result's reason, or `null` when
277
+ * the result is `ok`.
278
+ */
279
+ static from(result) {
280
+ if (result.ok) return null;
281
+ switch (result.reason) {
282
+ case "invalid_api_key":
283
+ case "forbidden": return new ClivlyAuthError(result);
284
+ case "rate_limited": return new ClivlyRateLimitError(result);
285
+ case "network_error": return new ClivlyNetworkError(result);
286
+ default: return new ClivlyError(result);
287
+ }
288
+ }
289
+ };
290
+ /** The API key was rejected (401) or lacks access (403). Not retryable. */
291
+ var ClivlyAuthError = class extends ClivlyError {};
292
+ /** Rate limited (429). Retryable after backoff / `Retry-After`. */
293
+ var ClivlyRateLimitError = class extends ClivlyError {};
294
+ /** The request never reached the server (network/DNS/TLS). Retryable. */
295
+ var ClivlyNetworkError = class extends ClivlyError {};
296
+ //#endregion
297
+ //#region src/index.ts
298
+ const DEFAULT_API_URL = "https://api.clivly.com";
299
+ const DEFAULT_HEARTBEAT_MS = 6e4;
300
+ const DEFAULT_BATCH_SIZE = 500;
301
+ const SAMPLE_BATCH_SIZE = 1;
302
+ const DEFAULT_MAX_ATTEMPTS = 3;
303
+ const DEFAULT_BASE_DELAY_MS = 500;
304
+ const RETRYABLE_STATUS_FLOOR = 500;
305
+ const RATE_LIMITED_STATUS = 429;
306
+ const SYNC_CONTACTS_PATH = "/clivly/sdk/sync/contacts";
307
+ const SYNC_COMPANIES_PATH = "/clivly/sdk/sync/companies";
308
+ const SYNC_DEALS_PATH = "/clivly/sdk/sync/deals";
309
+ const SYNC_CUSTOM_PATH = "/clivly/sdk/sync/custom";
310
+ const TRAILING_SLASH = /\/$/;
311
+ const BEARER_PREFIX = /^Bearer\s+/i;
312
+ function entitiesToTables(entities, byName) {
313
+ if (!entities) return;
314
+ for (const entity of Object.values(entities.entities)) {
315
+ const columns = byName.get(entity.source) ?? /* @__PURE__ */ new Set();
316
+ for (const sourceColumn of Object.values(entity.fields)) columns.add(sourceColumn);
317
+ byName.set(entity.source, columns);
318
+ }
319
+ }
320
+ function resolveContactEntity(config) {
321
+ if (config.contactEntity) return config.contactEntity;
322
+ for (const entity of Object.values(config.entities?.entities ?? {})) if (entity.concept === "contact") return entity.source;
323
+ }
324
+ function collectSchemaMeta(config) {
325
+ const byTable = /* @__PURE__ */ new Map();
326
+ for (const table of config.schema ?? []) if (table.columnsMeta) byTable.set(table.name, table.columnsMeta);
327
+ const sources = [];
328
+ if (config.source) sources.push(config.source.contacts, config.source.companies, config.source.deals, ...config.source.custom ?? []);
329
+ for (const source of sources) {
330
+ const meta = source?.[require_drizzle_meta.DRIZZLE_META];
331
+ if (meta?.tableName && meta.columnsMeta) byTable.set(meta.tableName, meta.columnsMeta);
332
+ }
333
+ return byTable;
334
+ }
335
+ function buildSchema(config) {
336
+ const byName = /* @__PURE__ */ new Map();
337
+ for (const table of config.schema ?? []) {
338
+ const columns = byName.get(table.name) ?? /* @__PURE__ */ new Set();
339
+ for (const column of table.columns) columns.add(column);
340
+ byName.set(table.name, columns);
341
+ }
342
+ entitiesToTables(config.entities, byName);
343
+ if (config.contactEntity && !byName.has(config.contactEntity)) byName.set(config.contactEntity, new Set(config.contactFields ?? []));
344
+ const metaByTable = collectSchemaMeta(config);
345
+ return [...byName].map(([name, columns]) => {
346
+ const columnsMeta = metaByTable.get(name);
347
+ if (columnsMeta) for (const col of columnsMeta) columns.add(col.name);
348
+ return {
349
+ name,
350
+ columns: [...columns],
351
+ ...columnsMeta ? { columnsMeta } : {}
352
+ };
353
+ });
354
+ }
355
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
356
+ function createMemoryCursorStore() {
357
+ const cursors = /* @__PURE__ */ new Map();
358
+ return {
359
+ get: (entity) => cursors.get(entity) ?? null,
360
+ set: (entity, cursor) => {
361
+ cursors.set(entity, cursor);
362
+ }
363
+ };
364
+ }
365
+ function isLikelyServerless() {
366
+ const runtime = globalThis;
367
+ if (runtime.navigator?.userAgent === "Cloudflare-Workers") return true;
368
+ if (runtime.EdgeRuntime !== void 0) return true;
369
+ const env = runtime.process?.env;
370
+ return Boolean(env?.AWS_LAMBDA_FUNCTION_NAME || env?.VERCEL);
371
+ }
372
+ function jsonResponse(body, status) {
373
+ return new Response(JSON.stringify(body), {
374
+ status,
375
+ headers: { "content-type": "application/json" }
376
+ });
377
+ }
378
+ function timingSafeEqual(a, b) {
379
+ if (a.length !== b.length) return false;
380
+ let mismatch = 0;
381
+ for (let i = 0; i < a.length; i++) mismatch += a.charCodeAt(i) === b.charCodeAt(i) ? 0 : 1;
382
+ return mismatch === 0;
383
+ }
384
+ const RUN_ID_HEADER = "x-clivly-run-id";
385
+ const SIGNATURE_HEADER = "x-clivly-signature";
386
+ const SIGNATURE_TOLERANCE_SECONDS = 300;
387
+ const SIGNATURE_HEADER_RE = /^t=(\d+),v1=([0-9a-f]+)$/;
388
+ const SECONDS_PER_MS = 1e3;
389
+ function toHex(buffer) {
390
+ return [...new Uint8Array(buffer)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
391
+ }
392
+ /**
393
+ * Verify that a sync-trigger request really came from Clivly.
394
+ *
395
+ * Clivly signs `${timestamp}.${rawBody}` with the shared secret, so this proves
396
+ * two things at once: the caller holds the secret, and the body wasn't altered in
397
+ * flight. The timestamp is inside the signed material, so an attacker can't
398
+ * rewrite it to extend the replay window.
399
+ *
400
+ * Uses WebCrypto (not node:crypto) so it works unchanged on edge and worker
401
+ * runtimes, where a host app is most likely to mount this.
402
+ */
403
+ async function verifyTriggerSignature(header, rawBody, secret) {
404
+ const match = header ? SIGNATURE_HEADER_RE.exec(header) : null;
405
+ if (!match) return false;
406
+ const [, timestamp, provided] = match;
407
+ if (Math.abs(Math.floor(Date.now() / SECONDS_PER_MS) - Number(timestamp)) > SIGNATURE_TOLERANCE_SECONDS) return false;
408
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), {
409
+ name: "HMAC",
410
+ hash: "SHA-256"
411
+ }, false, ["sign"]);
412
+ return timingSafeEqual(provided, toHex(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${timestamp}.${rawBody}`))));
413
+ }
414
+ function bearerToken(req) {
415
+ const header = req.headers.get("authorization");
416
+ if (!(header && BEARER_PREFIX.test(header))) return null;
417
+ return header.replace(BEARER_PREFIX, "").trim();
418
+ }
419
+ function isRetryableStatus(status) {
420
+ return status === RATE_LIMITED_STATUS || status >= RETRYABLE_STATUS_FLOOR;
421
+ }
422
+ const UNAUTHORIZED_STATUS = 401;
423
+ const FORBIDDEN_STATUS = 403;
424
+ const CLIENT_ERROR_FLOOR = 400;
425
+ const SERVICE_UNAVAILABLE_STATUS = 503;
426
+ function connectReason(status) {
427
+ if (status === UNAUTHORIZED_STATUS) return "invalid_api_key";
428
+ if (status === FORBIDDEN_STATUS) return "forbidden";
429
+ if (status === RATE_LIMITED_STATUS) return "rate_limited";
430
+ if (status >= RETRYABLE_STATUS_FLOOR) return "server_error";
431
+ if (status >= CLIENT_ERROR_FLOOR) return "client_error";
432
+ return "server_error";
433
+ }
434
+ async function resolveOrg(response) {
435
+ try {
436
+ return (await response.json())?.org;
437
+ } catch {
438
+ return;
439
+ }
440
+ }
441
+ function retryAfterMs(res) {
442
+ const header = res.headers.get("retry-after");
443
+ if (!header) return null;
444
+ const seconds = Number(header);
445
+ if (!Number.isNaN(seconds)) return Math.max(0, seconds * 1e3);
446
+ const date = Date.parse(header);
447
+ return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
448
+ }
449
+ function createFetcher(retry) {
450
+ const maxAttempts = Math.max(1, retry?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
451
+ const baseDelayMs = retry?.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
452
+ return async (url, init) => {
453
+ for (let attempt = 1;; attempt++) {
454
+ let res = null;
455
+ try {
456
+ res = await fetch(url, init);
457
+ } catch {
458
+ res = null;
459
+ }
460
+ if (!(res === null || isRetryableStatus(res.status)) || attempt >= maxAttempts) return res;
461
+ const backoff = baseDelayMs * 2 ** (attempt - 1) + Math.random() * baseDelayMs;
462
+ await sleep(res ? retryAfterMs(res) ?? backoff : backoff);
463
+ }
464
+ };
465
+ }
466
+ function resolveSampleSource(config, entityKey) {
467
+ const entity = config.entities?.entities?.[entityKey];
468
+ const src = config.source;
469
+ if (!(entity && src)) return null;
470
+ if (entity.concept === "contact" && src.contacts) return {
471
+ source: src.contacts,
472
+ path: SYNC_CONTACTS_PATH
473
+ };
474
+ if (entity.concept === "company" && src.companies) return {
475
+ source: src.companies,
476
+ path: SYNC_COMPANIES_PATH
477
+ };
478
+ if (entity.concept === "deal" && src.deals) return {
479
+ source: src.deals,
480
+ path: SYNC_DEALS_PATH
481
+ };
482
+ if (entity.concept === "custom") {
483
+ const custom = (src.custom ?? []).find((c) => c.entity === entity.source || c.entity === entityKey);
484
+ return custom ? {
485
+ source: custom,
486
+ path: SYNC_CUSTOM_PATH,
487
+ objectType: custom.objectType
488
+ } : null;
489
+ }
490
+ return null;
491
+ }
492
+ const PREVIEW_LIMIT = 5;
493
+ function resolvePreviewSource(config, sourceTable) {
494
+ const src = config.source;
495
+ if (!src) return null;
496
+ return [
497
+ src.contacts,
498
+ src.companies,
499
+ src.deals,
500
+ ...src.custom ?? []
501
+ ].filter((s) => Boolean(s)).find((s) => s.entity === sourceTable) ?? null;
502
+ }
503
+ function toPreviewCell(value) {
504
+ if (value === null || value === void 0) return null;
505
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
506
+ if (value instanceof Date) return value.toISOString();
507
+ return String(value);
508
+ }
509
+ function slimPreviewRow(row, needed) {
510
+ const out = {};
511
+ for (const key of needed) out[key] = toPreviewCell(row[key]);
512
+ return out;
513
+ }
514
+ async function handlePreviewRequest(config, preview) {
515
+ const source = resolvePreviewSource(config, preview.sourceTable);
516
+ if (!source) return jsonResponse({ error: `No configured source for table "${preview.sourceTable}"` }, 400);
517
+ const idField = source.idField ?? "id";
518
+ const filterColumns = preview.filter ? Object.keys(preview.filter).filter((k) => k !== "$raw") : [];
519
+ const needed = new Set([
520
+ idField,
521
+ ...Object.values(preview.fieldMap),
522
+ ...filterColumns
523
+ ]);
524
+ const rows = (await source.fetchPage({
525
+ since: null,
526
+ limit: PREVIEW_LIMIT
527
+ })).slice(0, PREVIEW_LIMIT).map((row) => slimPreviewRow(row, needed));
528
+ return jsonResponse({
529
+ rows,
530
+ sampledCount: rows.length
531
+ }, 200);
532
+ }
533
+ /**
534
+ * Create the Clivly SDK. The implemented surface — heartbeat/discovery reporting
535
+ * and the auth-verify handler — connects a developer's app to a running Clivly
536
+ * backend today. Tunnel management and CRM write-back are scaffolded (see the
537
+ * clearly-marked TODOs) and land with the Cloudflare infrastructure.
538
+ */
539
+ function createClivlySDK(config) {
540
+ const apiUrl = (config.apiUrl ?? DEFAULT_API_URL).replace(TRAILING_SLASH, "");
541
+ const heartbeatIntervalMs = config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_MS;
542
+ const syncIntervalMs = config.sync?.intervalMs ?? heartbeatIntervalMs;
543
+ const timers = {
544
+ heartbeat: null,
545
+ sync: null
546
+ };
547
+ const batchSize = config.sync?.batchSize ?? DEFAULT_BATCH_SIZE;
548
+ const cursorStore = config.cursorStore ?? createMemoryCursorStore();
549
+ const fetcher = createFetcher(config.retry);
550
+ let warnedUnsignedTrigger = false;
551
+ let lastPushFailure = null;
552
+ const createPush = (runId) => async (path, body) => {
553
+ const response = await fetcher(`${apiUrl}${path}`, {
554
+ method: "POST",
555
+ headers: {
556
+ "content-type": "application/json",
557
+ authorization: `Bearer ${config.apiKey}`,
558
+ ...runId ? { [RUN_ID_HEADER]: runId } : {}
559
+ },
560
+ body: JSON.stringify(body)
561
+ });
562
+ if (response?.ok) return true;
563
+ lastPushFailure ??= { status: response?.status ?? null };
564
+ return false;
565
+ };
566
+ const describePushFailure = (entities) => {
567
+ const base = `Clivly sync: push failed for ${entities.join(", ")}`;
568
+ const status = lastPushFailure?.status ?? null;
569
+ let cause;
570
+ if (status === 401 || status === 403) cause = "the API key was rejected — check CLIVLY_API_KEY is set to a valid key for this org";
571
+ else if (status === null) cause = `couldn't reach Clivly at ${apiUrl} — check network access and CLIVLY_API_URL`;
572
+ else cause = `Clivly returned HTTP ${status}`;
573
+ return `${base}: ${cause}. Run \`clivly doctor\` to diagnose.`;
574
+ };
575
+ const syncOne = async (source, path, mode, objectType, runId, batchSizeOverride, maxRowsOverride, persistCursor = true) => {
576
+ const since = mode === "full" ? null : await cursorStore.get(source.entity) ?? null;
577
+ const push = createPush(runId);
578
+ const effectiveBatchSize = batchSizeOverride ?? batchSize;
579
+ const result = mode === "full" ? await fullSyncSource({
580
+ source,
581
+ path,
582
+ batchSize: effectiveBatchSize,
583
+ push,
584
+ objectType
585
+ }) : await syncSource({
586
+ source,
587
+ path,
588
+ since,
589
+ batchSize: effectiveBatchSize,
590
+ push,
591
+ objectType,
592
+ maxRows: maxRowsOverride
593
+ });
594
+ if (persistCursor) await cursorStore.set(source.entity, result.cursor);
595
+ return result.ok;
596
+ };
597
+ const syncCustomSources = async (customSources, mode, runId) => {
598
+ const failed = [];
599
+ for (const customSource of customSources ?? []) if (!await syncOne(customSource, SYNC_CUSTOM_PATH, mode, customSource.objectType, runId)) failed.push(customSource.entity);
600
+ return failed;
601
+ };
602
+ const runSync = async (options) => {
603
+ if (!config.source) return;
604
+ lastPushFailure = null;
605
+ const mode = options?.mode ?? "delta";
606
+ const runId = options?.runId;
607
+ const failed = [];
608
+ if (config.source.companies) {
609
+ if (!await syncOne(config.source.companies, SYNC_COMPANIES_PATH, mode, void 0, runId)) failed.push(config.source.companies.entity);
610
+ }
611
+ if (!await syncOne(config.source.contacts, SYNC_CONTACTS_PATH, mode, void 0, runId)) failed.push(config.source.contacts.entity);
612
+ if (config.source.deals && !await syncOne(config.source.deals, SYNC_DEALS_PATH, mode, void 0, runId)) failed.push(config.source.deals.entity);
613
+ failed.push(...await syncCustomSources(config.source.custom, mode, runId));
614
+ if (failed.length > 0) throw new Error(describePushFailure(failed));
615
+ };
616
+ const runSampleSync = async (entityKey, runId) => {
617
+ const resolved = resolveSampleSource(config, entityKey);
618
+ if (!resolved) throw new Error(`Clivly sync: sample entityKey "${entityKey}" did not resolve to a configured source`);
619
+ lastPushFailure = null;
620
+ if (!await syncOne(resolved.source, resolved.path, "delta", resolved.objectType, runId, SAMPLE_BATCH_SIZE, SAMPLE_BATCH_SIZE, false)) throw new Error(describePushFailure([resolved.source.entity]));
621
+ };
622
+ const sendHeartbeat = () => fetcher(`${apiUrl}/clivly/sdk/heartbeat`, {
623
+ method: "POST",
624
+ headers: {
625
+ "content-type": "application/json",
626
+ authorization: `Bearer ${config.apiKey}`
627
+ },
628
+ body: JSON.stringify({
629
+ framework: config.framework,
630
+ orm: config.orm,
631
+ sdkVersion: config.sdkVersion,
632
+ contactEntity: resolveContactEntity(config),
633
+ namespaceReady: config.namespaceReady ?? false,
634
+ schema: buildSchema(config),
635
+ ...config.syncTrigger?.url ? { syncTrigger: { url: config.syncTrigger.url } } : {}
636
+ })
637
+ });
638
+ const heartbeat = async () => {
639
+ return (await sendHeartbeat())?.ok ?? false;
640
+ };
641
+ const connect = async () => {
642
+ const response = await sendHeartbeat();
643
+ if (response === null) return {
644
+ ok: false,
645
+ status: null,
646
+ reason: "network_error",
647
+ retryable: true
648
+ };
649
+ if (response.ok) return {
650
+ ok: true,
651
+ status: response.status,
652
+ reason: "ok",
653
+ retryable: false,
654
+ org: await resolveOrg(response)
655
+ };
656
+ return {
657
+ ok: false,
658
+ status: response.status,
659
+ reason: connectReason(response.status),
660
+ retryable: isRetryableStatus(response.status)
661
+ };
662
+ };
663
+ const doctor = () => runDoctor({
664
+ config,
665
+ connect
666
+ });
667
+ const runScheduled = async (options) => {
668
+ await heartbeat();
669
+ if (config.source) await runSync(options);
670
+ };
671
+ const triggerRequestAllowed = async (req, rawBody) => {
672
+ const secret = config.syncTrigger?.secret;
673
+ if (secret) return await verifyTriggerSignature(req.headers.get(SIGNATURE_HEADER), rawBody, secret);
674
+ if (!warnedUnsignedTrigger) {
675
+ warnedUnsignedTrigger = true;
676
+ console.warn("Clivly: the sync trigger route is mounted without `syncTrigger.secret`, so anyone who can reach the URL can start a sync. Set a secret (Integrations → Remote sync in Clivly) to require signed requests.");
677
+ }
678
+ return true;
679
+ };
680
+ const parseTriggerBody = (rawBody) => {
681
+ if (!rawBody) return {};
682
+ try {
683
+ return JSON.parse(rawBody);
684
+ } catch {
685
+ return {};
686
+ }
687
+ };
688
+ const createClivlyHandler = () => async (req) => {
689
+ const rawBody = await req.text().catch(() => "");
690
+ if (!await triggerRequestAllowed(req, rawBody)) return jsonResponse({
691
+ ok: false,
692
+ error: "unauthorized"
693
+ }, UNAUTHORIZED_STATUS);
694
+ const payload = parseTriggerBody(rawBody);
695
+ const runId = payload.runId ?? null;
696
+ const startedAt = Date.now();
697
+ try {
698
+ if (payload.preview) return await handlePreviewRequest(config, payload.preview);
699
+ if (payload.sample) await runSampleSync(payload.sample.entityKey, payload.runId);
700
+ else await runScheduled({
701
+ mode: payload.mode,
702
+ runId: payload.runId
703
+ });
704
+ return jsonResponse({
705
+ ok: true,
706
+ runId,
707
+ durationMs: Date.now() - startedAt
708
+ }, 200);
709
+ } catch (error) {
710
+ return jsonResponse({
711
+ ok: false,
712
+ runId,
713
+ error: error instanceof Error ? error.message : String(error)
714
+ }, 500);
715
+ }
716
+ };
717
+ const start = async () => {
718
+ if (isLikelyServerless()) console.warn("clivly.start() uses a setInterval keep-alive loop, which does not persist on serverless/edge runtimes — it will stop ticking after the first response. Call clivly.runScheduled() from a scheduled/cron trigger, or mount clivly.createClivlyHandler() at a route instead.");
719
+ await heartbeat();
720
+ if (config.sync?.onBoot !== false) await runSync().catch(() => {});
721
+ if (!timers.heartbeat) {
722
+ timers.heartbeat = setInterval(() => {
723
+ heartbeat().catch(() => {});
724
+ }, heartbeatIntervalMs);
725
+ timers.heartbeat.unref?.();
726
+ }
727
+ if (!timers.sync && config.source) {
728
+ timers.sync = setInterval(() => {
729
+ runSync().catch(() => {});
730
+ }, syncIntervalMs);
731
+ timers.sync.unref?.();
732
+ }
733
+ };
734
+ const stop = () => {
735
+ if (timers.heartbeat) {
736
+ clearInterval(timers.heartbeat);
737
+ timers.heartbeat = null;
738
+ }
739
+ if (timers.sync) {
740
+ clearInterval(timers.sync);
741
+ timers.sync = null;
742
+ }
743
+ };
744
+ const createAuthVerifyHandler = (resolveUser) => async (req) => {
745
+ if (!config.verifyToken) return jsonResponse({ error: "Clivly verifyToken is not configured on the host app" }, SERVICE_UNAVAILABLE_STATUS);
746
+ const token = bearerToken(req);
747
+ if (!(token && timingSafeEqual(token, config.verifyToken))) return jsonResponse({ error: "unauthorized" }, 401);
748
+ const user = await resolveUser(req);
749
+ if (!user) return jsonResponse({ error: "unauthorized" }, 401);
750
+ return jsonResponse(user, 200);
751
+ };
752
+ return {
753
+ start,
754
+ stop,
755
+ heartbeat,
756
+ connect,
757
+ doctor,
758
+ runScheduled,
759
+ createClivlyHandler,
760
+ sync: runSync,
761
+ createAuthVerifyHandler
762
+ };
763
+ }
764
+ //#endregion
765
+ exports.ClivlyAuthError = ClivlyAuthError;
766
+ exports.ClivlyError = ClivlyError;
767
+ exports.ClivlyNetworkError = ClivlyNetworkError;
768
+ exports.ClivlyRateLimitError = ClivlyRateLimitError;
769
+ exports.createClivlySDK = createClivlySDK;