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