@clivly/sdk 0.2.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,13 +1,96 @@
1
- import { t as DRIZZLE_META } from "./drizzle-meta-CAJ43cLc.mjs";
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
2
69
  //#region src/doctor.ts
3
70
  function configuredSources(config) {
4
71
  const sources = [];
5
72
  if (config.source?.contacts) sources.push(config.source.contacts);
6
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 ?? []);
7
76
  return sources;
8
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
+ }
9
92
  /**
10
- * Run the six read-only setup checks and return a structured report. Every check
93
+ * Run the read-only setup checks and return a structured report. Every check
11
94
  * captures its own error into `detail` rather than throwing, so one failure never
12
95
  * hides the rest. `ok` is the AND of all non-skipped checks.
13
96
  */
@@ -76,6 +159,7 @@ async function runDoctor(args) {
76
159
  ok: result.ok,
77
160
  detail: result.ok ? `Reached Clivly — org "${result.org?.name ?? "(unnamed)"}".` : `Could not connect (${result.reason}).`
78
161
  });
162
+ checks.push(await namespaceCheck(config));
79
163
  return {
80
164
  ok: checks.every((check) => check.skipped || check.ok),
81
165
  checks
@@ -107,7 +191,7 @@ function nextCursor(rows, cursorField, idField, current) {
107
191
  return max;
108
192
  }
109
193
  async function syncSource(args) {
110
- const { source, path, since, batchSize, push } = args;
194
+ const { source, path, since, batchSize, push, objectType, maxRows } = args;
111
195
  const idField = source.idField ?? DEFAULT_ID_FIELD;
112
196
  let cursor = since;
113
197
  let pushed = 0;
@@ -117,18 +201,20 @@ async function syncSource(args) {
117
201
  limit: batchSize
118
202
  });
119
203
  if (rows.length === 0) break;
204
+ const capped = maxRows === void 0 ? rows : rows.slice(0, maxRows);
120
205
  if (!await push(path, {
121
206
  source: source.entity,
122
- rows,
123
- mode: "delta"
207
+ rows: capped,
208
+ mode: "delta",
209
+ ...objectType ? { objectType } : {}
124
210
  })) return {
125
211
  pushed,
126
212
  cursor,
127
213
  ok: false
128
214
  };
129
- pushed += rows.length;
130
- cursor = nextCursor(rows, source.cursorField, idField, cursor);
131
- 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;
132
218
  }
133
219
  return {
134
220
  pushed,
@@ -137,7 +223,7 @@ async function syncSource(args) {
137
223
  };
138
224
  }
139
225
  async function fullSyncSource(args) {
140
- const { source, path, batchSize, push } = args;
226
+ const { source, path, batchSize, push, objectType } = args;
141
227
  const idField = source.idField ?? DEFAULT_ID_FIELD;
142
228
  const all = [];
143
229
  let cursor = null;
@@ -161,7 +247,8 @@ async function fullSyncSource(args) {
161
247
  const ok = await push(path, {
162
248
  source: source.entity,
163
249
  rows: all,
164
- mode: "full"
250
+ mode: "full",
251
+ ...objectType ? { objectType } : {}
165
252
  });
166
253
  return {
167
254
  pushed: ok ? all.length : 0,
@@ -210,12 +297,15 @@ var ClivlyNetworkError = class extends ClivlyError {};
210
297
  const DEFAULT_API_URL = "https://api.clivly.com";
211
298
  const DEFAULT_HEARTBEAT_MS = 6e4;
212
299
  const DEFAULT_BATCH_SIZE = 500;
300
+ const SAMPLE_BATCH_SIZE = 1;
213
301
  const DEFAULT_MAX_ATTEMPTS = 3;
214
302
  const DEFAULT_BASE_DELAY_MS = 500;
215
303
  const RETRYABLE_STATUS_FLOOR = 500;
216
304
  const RATE_LIMITED_STATUS = 429;
217
305
  const SYNC_CONTACTS_PATH = "/clivly/sdk/sync/contacts";
218
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";
219
309
  const TRAILING_SLASH = /\/$/;
220
310
  const BEARER_PREFIX = /^Bearer\s+/i;
221
311
  function entitiesToTables(entities, byName) {
@@ -230,6 +320,17 @@ function resolveContactEntity(config) {
230
320
  if (config.contactEntity) return config.contactEntity;
231
321
  for (const entity of Object.values(config.entities?.entities ?? {})) if (entity.concept === "contact") return entity.source;
232
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
+ }
233
334
  function buildSchema(config) {
234
335
  const byName = /* @__PURE__ */ new Map();
235
336
  for (const table of config.schema ?? []) {
@@ -239,10 +340,16 @@ function buildSchema(config) {
239
340
  }
240
341
  entitiesToTables(config.entities, byName);
241
342
  if (config.contactEntity && !byName.has(config.contactEntity)) byName.set(config.contactEntity, new Set(config.contactFields ?? []));
242
- return [...byName].map(([name, columns]) => ({
243
- name,
244
- columns: [...columns]
245
- }));
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
+ });
246
353
  }
247
354
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
248
355
  function createMemoryCursorStore() {
@@ -273,6 +380,36 @@ function timingSafeEqual(a, b) {
273
380
  for (let i = 0; i < a.length; i++) mismatch += a.charCodeAt(i) === b.charCodeAt(i) ? 0 : 1;
274
381
  return mismatch === 0;
275
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
+ }
276
413
  function bearerToken(req) {
277
414
  const header = req.headers.get("authorization");
278
415
  if (!(header && BEARER_PREFIX.test(header))) return null;
@@ -325,6 +462,73 @@ function createFetcher(retry) {
325
462
  }
326
463
  };
327
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
+ }
328
532
  /**
329
533
  * Create the Clivly SDK. The implemented surface — heartbeat/discovery reporting
330
534
  * and the auth-verify handler — connects a developer's app to a running Clivly
@@ -342,42 +546,77 @@ function createClivlySDK(config) {
342
546
  const batchSize = config.sync?.batchSize ?? DEFAULT_BATCH_SIZE;
343
547
  const cursorStore = config.cursorStore ?? createMemoryCursorStore();
344
548
  const fetcher = createFetcher(config.retry);
345
- const push = async (path, body) => {
346
- 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}`, {
347
553
  method: "POST",
348
554
  headers: {
349
555
  "content-type": "application/json",
350
- authorization: `Bearer ${config.apiKey}`
556
+ authorization: `Bearer ${config.apiKey}`,
557
+ ...runId ? { [RUN_ID_HEADER]: runId } : {}
351
558
  },
352
559
  body: JSON.stringify(body)
353
- }))?.ok ?? false;
560
+ });
561
+ if (response?.ok) return true;
562
+ lastPushFailure ??= { status: response?.status ?? null };
563
+ return false;
354
564
  };
355
- const syncOne = async (source, path, mode) => {
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.`;
573
+ };
574
+ const syncOne = async (source, path, mode, objectType, runId, batchSizeOverride, maxRowsOverride, persistCursor = true) => {
356
575
  const since = mode === "full" ? null : await cursorStore.get(source.entity) ?? null;
576
+ const push = createPush(runId);
577
+ const effectiveBatchSize = batchSizeOverride ?? batchSize;
357
578
  const result = mode === "full" ? await fullSyncSource({
358
579
  source,
359
580
  path,
360
- batchSize,
361
- push
581
+ batchSize: effectiveBatchSize,
582
+ push,
583
+ objectType
362
584
  }) : await syncSource({
363
585
  source,
364
586
  path,
365
587
  since,
366
- batchSize,
367
- push
588
+ batchSize: effectiveBatchSize,
589
+ push,
590
+ objectType,
591
+ maxRows: maxRowsOverride
368
592
  });
369
- await cursorStore.set(source.entity, result.cursor);
593
+ if (persistCursor) await cursorStore.set(source.entity, result.cursor);
370
594
  return result.ok;
371
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
+ };
372
601
  const runSync = async (options) => {
373
602
  if (!config.source) return;
603
+ lastPushFailure = null;
374
604
  const mode = options?.mode ?? "delta";
605
+ const runId = options?.runId;
375
606
  const failed = [];
376
607
  if (config.source.companies) {
377
- 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);
378
609
  }
379
- if (!await syncOne(config.source.contacts, SYNC_CONTACTS_PATH, mode)) failed.push(config.source.contacts.entity);
380
- 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]));
381
620
  };
382
621
  const sendHeartbeat = () => fetcher(`${apiUrl}/clivly/sdk/heartbeat`, {
383
622
  method: "POST",
@@ -391,7 +630,8 @@ function createClivlySDK(config) {
391
630
  sdkVersion: config.sdkVersion,
392
631
  contactEntity: resolveContactEntity(config),
393
632
  namespaceReady: config.namespaceReady ?? false,
394
- schema: buildSchema(config)
633
+ schema: buildSchema(config),
634
+ ...config.syncTrigger?.url ? { syncTrigger: { url: config.syncTrigger.url } } : {}
395
635
  })
396
636
  });
397
637
  const heartbeat = async () => {
@@ -427,13 +667,48 @@ function createClivlySDK(config) {
427
667
  await heartbeat();
428
668
  if (config.source) await runSync(options);
429
669
  };
430
- const createClivlyHandler = () => async (_req) => {
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 {};
431
681
  try {
432
- await runScheduled();
433
- return jsonResponse({ ok: true }, 200);
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);
434
708
  } catch (error) {
435
709
  return jsonResponse({
436
710
  ok: false,
711
+ runId,
437
712
  error: error instanceof Error ? error.message : String(error)
438
713
  }, 500);
439
714
  }
@@ -1,18 +1,39 @@
1
1
  import { ClivlyEntitiesConfig } from "@clivly/core";
2
2
 
3
3
  //#region src/drizzle-meta.d.ts
4
- declare const DRIZZLE_META: unique symbol;
4
+ declare const DRIZZLE_META: "__clivly_drizzle_source_meta__";
5
5
  interface DrizzleSourceMeta {
6
6
  /** Drizzle property keys of every column on the source table. */
7
7
  columnKeys: string[];
8
+ /** Schema-v2 typed column metadata derived from the Drizzle table. */
9
+ columnsMeta?: DiscoveredColumn[];
8
10
  cursorField: string;
9
11
  idField: string;
12
+ /** The backing table's real name, so discovery can key metadata by table. */
13
+ tableName?: string;
10
14
  }
11
15
  //#endregion
12
16
  //#region src/types.d.ts
17
+ interface DiscoveredColumn {
18
+ isForeignKey?: boolean;
19
+ isPrimaryKey?: boolean;
20
+ name: string;
21
+ nullable?: boolean;
22
+ /** The column this FK points at, when it is a foreign key. */
23
+ references?: {
24
+ table: string;
25
+ column: string;
26
+ };
27
+ /** SQL/driver type, e.g. "text", "integer", "timestamp". */
28
+ type?: string;
29
+ }
13
30
  interface DiscoveredTable {
14
31
  columns: string[];
32
+ /** Schema-v2 typed column metadata (types, PK/FK, nullability). */
33
+ columnsMeta?: DiscoveredColumn[];
15
34
  name: string;
35
+ /** Approximate source row count, when cheaply derivable. */
36
+ rowCount?: number;
16
37
  }
17
38
  interface ClivlySDKConfig {
18
39
  /** Org-scoped API token (sk_live_…) from Settings → API keys. */
@@ -60,7 +81,18 @@ interface ClivlySDKConfig {
60
81
  framework?: string;
61
82
  /** Heartbeat cadence in ms. Defaults to 60s. */
62
83
  heartbeatIntervalMs?: number;
63
- /** Whether the crm_* namespace exists in the host DB (self-reported). */
84
+ /**
85
+ * Optional live namespace probe, used on-demand by `doctor()`'s "Namespace
86
+ * reachable" check to verify every required source table + column is
87
+ * genuinely reachable. Not consulted by the heartbeat. Build one with
88
+ * `drizzleIntrospector(db)` from `@clivly/sdk/drizzle`.
89
+ */
90
+ introspector?: NamespaceIntrospector;
91
+ /**
92
+ * Self-reported readiness sent on every heartbeat as-is (no live check).
93
+ * For a real reachability check, configure `introspector` and run
94
+ * `doctor()`.
95
+ */
64
96
  namespaceReady?: boolean;
65
97
  orm?: string;
66
98
  /**
@@ -80,6 +112,10 @@ interface ClivlySDKConfig {
80
112
  source?: {
81
113
  contacts: SyncSource;
82
114
  companies?: SyncSource;
115
+ deals?: SyncSource; /** Org-defined custom objects to mirror. Each carries its object-type slug. */
116
+ custom?: Array<SyncSource & {
117
+ objectType: string;
118
+ }>;
83
119
  };
84
120
  sync?: {
85
121
  onBoot?: boolean;
@@ -91,6 +127,29 @@ interface ClivlySDKConfig {
91
127
  intervalMs?: number;
92
128
  batchSize?: number;
93
129
  };
130
+ /**
131
+ * The remote sync trigger: how Clivly Cloud asks this app to run a sync.
132
+ *
133
+ * Without it, cloud mode is push-only — Clivly can watch the app but can't
134
+ * start anything, so the dashboard's "Run sync" has nothing to call. With it,
135
+ * the SDK reports `url` on each heartbeat and requires every trigger request to
136
+ * be signed with `secret`.
137
+ */
138
+ syncTrigger?: {
139
+ /**
140
+ * Public URL of the route where `createClivlyHandler()` is mounted, e.g.
141
+ * `https://app.example.com/api/clivly/tick`. Reported to Clivly on each
142
+ * heartbeat; a URL set in the dashboard overrides it.
143
+ */
144
+ url?: string;
145
+ /**
146
+ * Signing secret (`whsec_…`) issued by Clivly under Integrations → Remote
147
+ * sync. When set, `createClivlyHandler()` rejects any request that isn't
148
+ * validly signed. Leave unset only if the route is already unreachable from
149
+ * the public internet.
150
+ */
151
+ secret?: string;
152
+ };
94
153
  /**
95
154
  * Shared secret Clivly Cloud presents (as `Authorization: Bearer <token>`) when
96
155
  * calling the `/clivly/auth/verify` endpoint. Required for the verify handler to
@@ -178,10 +237,16 @@ interface ClivlySDK {
178
237
  */
179
238
  createAuthVerifyHandler(resolveUser: (req: Request) => Promise<ClivlyUser | null> | ClivlyUser | null): (req: Request) => Promise<Response>;
180
239
  /**
181
- * A fetch handler to mount at a route and drive from an external scheduler
182
- * (Cloudflare Workers cron, an uptime ping, etc.). Each request runs one
183
- * `runScheduled()` and returns JSON: 200 when the sync succeeded, 500 otherwise.
184
- * The ergonomic serverless entry point alongside `runScheduled()`.
240
+ * A fetch handler to mount at a route. Each request runs one `runScheduled()`
241
+ * and returns JSON: 200 when the sync succeeded, 500 otherwise.
242
+ *
243
+ * Two callers use it: Clivly Cloud (when someone clicks "Run sync" — those
244
+ * requests are HMAC-signed and carry a run id), and the host's own scheduler
245
+ * (Workers cron, an uptime ping — no body).
246
+ *
247
+ * When `syncTrigger.secret` is set the handler rejects unsigned or
248
+ * badly-signed requests with 401. When it isn't, the route stays open to
249
+ * anyone who can reach it — set a secret if it's publicly routable.
185
250
  */
186
251
  createClivlyHandler(): (req: Request) => Promise<Response>;
187
252
  /** Validate this setup end-to-end (read-only) before the first sync. */
@@ -200,6 +265,7 @@ interface ClivlySDK {
200
265
  */
201
266
  runScheduled(options?: {
202
267
  mode?: SyncMode;
268
+ runId?: string;
203
269
  }): Promise<void>;
204
270
  /**
205
271
  * Send the initial heartbeat and start the keep-alive loop. For long-lived
@@ -218,7 +284,16 @@ interface ClivlySDK {
218
284
  */
219
285
  sync(options?: {
220
286
  mode?: SyncMode;
287
+ runId?: string;
221
288
  }): Promise<void>;
222
289
  }
223
290
  //#endregion
224
- export { ClivlyUser as a, DoctorCheck as c, ClivlySDKConfig as i, DoctorReport as l, ClivlyConnectResult as n, CursorStore as o, ClivlySDK as r, DiscoveredTable as s, ClivlyConnectReason as t, SyncSource as u };
291
+ //#region src/namespace-probe.d.ts
292
+ interface NamespaceIntrospector {
293
+ /** All base table names in the reachable schema. */
294
+ listTables(): Promise<string[]>;
295
+ /** Prove a table is genuinely selectable (SELECT ... LIMIT 1). Throws if unreachable/denied. */
296
+ sampleSelect(table: string): Promise<void>;
297
+ }
298
+ //#endregion
299
+ export { ClivlySDKConfig as a, DiscoveredTable as c, SyncSource as d, ClivlySDK as i, DoctorCheck as l, ClivlyConnectReason as n, ClivlyUser as o, ClivlyConnectResult as r, CursorStore as s, NamespaceIntrospector as t, DoctorReport as u };