@clivly/sdk 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -24
- package/dist/drizzle-meta-C0T6F3vL.cjs +9 -0
- package/dist/drizzle-meta-CAJ43cLc.mjs +4 -0
- package/dist/drizzle.cjs +50 -0
- package/dist/drizzle.d.cts +22 -0
- package/dist/drizzle.d.mts +22 -0
- package/dist/drizzle.mjs +49 -0
- package/dist/index.cjs +494 -0
- package/dist/index.d.cts +33 -0
- package/dist/index.d.mts +19 -124
- package/dist/index.mjs +223 -20
- package/dist/types-B0aSxJ6V.d.cts +224 -0
- package/dist/types-DWt-ytBQ.d.mts +224 -0
- package/package.json +34 -8
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,87 @@
|
|
|
1
|
+
import { t as DRIZZLE_META } from "./drizzle-meta-CAJ43cLc.mjs";
|
|
2
|
+
//#region src/doctor.ts
|
|
3
|
+
function configuredSources(config) {
|
|
4
|
+
const sources = [];
|
|
5
|
+
if (config.source?.contacts) sources.push(config.source.contacts);
|
|
6
|
+
if (config.source?.companies) sources.push(config.source.companies);
|
|
7
|
+
return sources;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Run the six read-only setup checks and return a structured report. Every check
|
|
11
|
+
* captures its own error into `detail` rather than throwing, so one failure never
|
|
12
|
+
* hides the rest. `ok` is the AND of all non-skipped checks.
|
|
13
|
+
*/
|
|
14
|
+
async function runDoctor(args) {
|
|
15
|
+
const { config, connect } = args;
|
|
16
|
+
const checks = [];
|
|
17
|
+
const sources = configuredSources(config);
|
|
18
|
+
const hasKey = Boolean(config.apiKey);
|
|
19
|
+
checks.push({
|
|
20
|
+
name: "API key present",
|
|
21
|
+
ok: hasKey,
|
|
22
|
+
detail: hasKey ? "CLIVLY_API_KEY is set." : "apiKey is empty — set CLIVLY_API_KEY."
|
|
23
|
+
});
|
|
24
|
+
const entityCount = config.entities ? Object.keys(config.entities.entities).length : 0;
|
|
25
|
+
checks.push({
|
|
26
|
+
name: "Entities configured",
|
|
27
|
+
ok: entityCount > 0,
|
|
28
|
+
detail: entityCount > 0 ? `${entityCount} entit${entityCount === 1 ? "y" : "ies"} configured.` : "No `entities` config — pass defineClivlyConfig(...) as `entities`."
|
|
29
|
+
});
|
|
30
|
+
checks.push({
|
|
31
|
+
name: "Sources configured",
|
|
32
|
+
ok: sources.length > 0,
|
|
33
|
+
detail: sources.length > 0 ? `${sources.length} source(s) to mirror.` : "No `source.contacts` — nothing to sync."
|
|
34
|
+
});
|
|
35
|
+
for (const source of sources) {
|
|
36
|
+
const meta = source[DRIZZLE_META];
|
|
37
|
+
if (!meta) {
|
|
38
|
+
checks.push({
|
|
39
|
+
name: `Cursor/id columns exist (${source.entity})`,
|
|
40
|
+
ok: false,
|
|
41
|
+
skipped: true,
|
|
42
|
+
detail: `"${source.entity}" is a hand-written source — column check skipped.`
|
|
43
|
+
});
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const idField = source.idField ?? "id";
|
|
47
|
+
const missing = [meta.cursorField, idField].filter((field) => !meta.columnKeys.includes(field));
|
|
48
|
+
checks.push({
|
|
49
|
+
name: `Cursor/id columns exist (${source.entity})`,
|
|
50
|
+
ok: missing.length === 0,
|
|
51
|
+
detail: missing.length === 0 ? `"${source.entity}": ${meta.cursorField} + ${idField} present.` : `"${source.entity}" missing column(s): ${missing.join(", ")}.`
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
for (const source of sources) try {
|
|
55
|
+
const rows = await source.fetchPage({
|
|
56
|
+
since: null,
|
|
57
|
+
limit: 1
|
|
58
|
+
});
|
|
59
|
+
const firstRow = rows[0];
|
|
60
|
+
const missingCursor = firstRow !== void 0 && !(source.cursorField in firstRow);
|
|
61
|
+
checks.push({
|
|
62
|
+
name: `Sample fetch (${source.entity})`,
|
|
63
|
+
ok: !missingCursor,
|
|
64
|
+
detail: missingCursor ? `"${source.entity}" rows lack the cursor key "${source.cursorField}".` : `"${source.entity}" returned ${rows.length} row(s).`
|
|
65
|
+
});
|
|
66
|
+
} catch (error) {
|
|
67
|
+
checks.push({
|
|
68
|
+
name: `Sample fetch (${source.entity})`,
|
|
69
|
+
ok: false,
|
|
70
|
+
detail: `"${source.entity}" fetchPage threw: ${error.message}`
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const result = await connect();
|
|
74
|
+
checks.push({
|
|
75
|
+
name: "Heartbeat",
|
|
76
|
+
ok: result.ok,
|
|
77
|
+
detail: result.ok ? `Reached Clivly — org "${result.org?.name ?? "(unnamed)"}".` : `Could not connect (${result.reason}).`
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
ok: checks.every((check) => check.skipped || check.ok),
|
|
81
|
+
checks
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
1
85
|
//#region src/sync.ts
|
|
2
86
|
const DEFAULT_ID_FIELD = "id";
|
|
3
87
|
function isAfter(candidate, current) {
|
|
@@ -86,6 +170,42 @@ async function fullSyncSource(args) {
|
|
|
86
170
|
};
|
|
87
171
|
}
|
|
88
172
|
//#endregion
|
|
173
|
+
//#region src/errors.ts
|
|
174
|
+
var ClivlyError = class ClivlyError extends Error {
|
|
175
|
+
/** HTTP status, or null when the request never completed (network error). */
|
|
176
|
+
status;
|
|
177
|
+
reason;
|
|
178
|
+
/** Whether a retry could plausibly succeed (network, 429, 5xx). */
|
|
179
|
+
retryable;
|
|
180
|
+
constructor(result, message) {
|
|
181
|
+
super(message ?? `Clivly connection failed: ${result.reason}`);
|
|
182
|
+
this.name = new.target.name;
|
|
183
|
+
this.status = result.status;
|
|
184
|
+
this.reason = result.reason;
|
|
185
|
+
this.retryable = result.retryable;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Build the error subclass matching a failed result's reason, or `null` when
|
|
189
|
+
* the result is `ok`.
|
|
190
|
+
*/
|
|
191
|
+
static from(result) {
|
|
192
|
+
if (result.ok) return null;
|
|
193
|
+
switch (result.reason) {
|
|
194
|
+
case "invalid_api_key":
|
|
195
|
+
case "forbidden": return new ClivlyAuthError(result);
|
|
196
|
+
case "rate_limited": return new ClivlyRateLimitError(result);
|
|
197
|
+
case "network_error": return new ClivlyNetworkError(result);
|
|
198
|
+
default: return new ClivlyError(result);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
/** The API key was rejected (401) or lacks access (403). Not retryable. */
|
|
203
|
+
var ClivlyAuthError = class extends ClivlyError {};
|
|
204
|
+
/** Rate limited (429). Retryable after backoff / `Retry-After`. */
|
|
205
|
+
var ClivlyRateLimitError = class extends ClivlyError {};
|
|
206
|
+
/** The request never reached the server (network/DNS/TLS). Retryable. */
|
|
207
|
+
var ClivlyNetworkError = class extends ClivlyError {};
|
|
208
|
+
//#endregion
|
|
89
209
|
//#region src/index.ts
|
|
90
210
|
const DEFAULT_API_URL = "https://api.clivly.com";
|
|
91
211
|
const DEFAULT_HEARTBEAT_MS = 6e4;
|
|
@@ -125,6 +245,22 @@ function buildSchema(config) {
|
|
|
125
245
|
}));
|
|
126
246
|
}
|
|
127
247
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
248
|
+
function createMemoryCursorStore() {
|
|
249
|
+
const cursors = /* @__PURE__ */ new Map();
|
|
250
|
+
return {
|
|
251
|
+
get: (entity) => cursors.get(entity) ?? null,
|
|
252
|
+
set: (entity, cursor) => {
|
|
253
|
+
cursors.set(entity, cursor);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function isLikelyServerless() {
|
|
258
|
+
const runtime = globalThis;
|
|
259
|
+
if (runtime.navigator?.userAgent === "Cloudflare-Workers") return true;
|
|
260
|
+
if (runtime.EdgeRuntime !== void 0) return true;
|
|
261
|
+
const env = runtime.process?.env;
|
|
262
|
+
return Boolean(env?.AWS_LAMBDA_FUNCTION_NAME || env?.VERCEL);
|
|
263
|
+
}
|
|
128
264
|
function jsonResponse(body, status) {
|
|
129
265
|
return new Response(JSON.stringify(body), {
|
|
130
266
|
status,
|
|
@@ -145,6 +281,25 @@ function bearerToken(req) {
|
|
|
145
281
|
function isRetryableStatus(status) {
|
|
146
282
|
return status === RATE_LIMITED_STATUS || status >= RETRYABLE_STATUS_FLOOR;
|
|
147
283
|
}
|
|
284
|
+
const UNAUTHORIZED_STATUS = 401;
|
|
285
|
+
const FORBIDDEN_STATUS = 403;
|
|
286
|
+
const CLIENT_ERROR_FLOOR = 400;
|
|
287
|
+
const SERVICE_UNAVAILABLE_STATUS = 503;
|
|
288
|
+
function connectReason(status) {
|
|
289
|
+
if (status === UNAUTHORIZED_STATUS) return "invalid_api_key";
|
|
290
|
+
if (status === FORBIDDEN_STATUS) return "forbidden";
|
|
291
|
+
if (status === RATE_LIMITED_STATUS) return "rate_limited";
|
|
292
|
+
if (status >= RETRYABLE_STATUS_FLOOR) return "server_error";
|
|
293
|
+
if (status >= CLIENT_ERROR_FLOOR) return "client_error";
|
|
294
|
+
return "server_error";
|
|
295
|
+
}
|
|
296
|
+
async function resolveOrg(response) {
|
|
297
|
+
try {
|
|
298
|
+
return (await response.json())?.org;
|
|
299
|
+
} catch {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
148
303
|
function retryAfterMs(res) {
|
|
149
304
|
const header = res.headers.get("retry-after");
|
|
150
305
|
if (!header) return null;
|
|
@@ -185,7 +340,7 @@ function createClivlySDK(config) {
|
|
|
185
340
|
sync: null
|
|
186
341
|
};
|
|
187
342
|
const batchSize = config.sync?.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
188
|
-
const
|
|
343
|
+
const cursorStore = config.cursorStore ?? createMemoryCursorStore();
|
|
189
344
|
const fetcher = createFetcher(config.retry);
|
|
190
345
|
const push = async (path, body) => {
|
|
191
346
|
return (await fetcher(`${apiUrl}${path}`, {
|
|
@@ -198,6 +353,7 @@ function createClivlySDK(config) {
|
|
|
198
353
|
}))?.ok ?? false;
|
|
199
354
|
};
|
|
200
355
|
const syncOne = async (source, path, mode) => {
|
|
356
|
+
const since = mode === "full" ? null : await cursorStore.get(source.entity) ?? null;
|
|
201
357
|
const result = mode === "full" ? await fullSyncSource({
|
|
202
358
|
source,
|
|
203
359
|
path,
|
|
@@ -206,11 +362,11 @@ function createClivlySDK(config) {
|
|
|
206
362
|
}) : await syncSource({
|
|
207
363
|
source,
|
|
208
364
|
path,
|
|
209
|
-
since
|
|
365
|
+
since,
|
|
210
366
|
batchSize,
|
|
211
367
|
push
|
|
212
368
|
});
|
|
213
|
-
|
|
369
|
+
await cursorStore.set(source.entity, result.cursor);
|
|
214
370
|
return result.ok;
|
|
215
371
|
};
|
|
216
372
|
const runSync = async (options) => {
|
|
@@ -223,24 +379,67 @@ function createClivlySDK(config) {
|
|
|
223
379
|
if (!await syncOne(config.source.contacts, SYNC_CONTACTS_PATH, mode)) failed.push(config.source.contacts.entity);
|
|
224
380
|
if (failed.length > 0) throw new Error(`Clivly sync: push failed for ${failed.join(", ")}`);
|
|
225
381
|
};
|
|
382
|
+
const sendHeartbeat = () => fetcher(`${apiUrl}/clivly/sdk/heartbeat`, {
|
|
383
|
+
method: "POST",
|
|
384
|
+
headers: {
|
|
385
|
+
"content-type": "application/json",
|
|
386
|
+
authorization: `Bearer ${config.apiKey}`
|
|
387
|
+
},
|
|
388
|
+
body: JSON.stringify({
|
|
389
|
+
framework: config.framework,
|
|
390
|
+
orm: config.orm,
|
|
391
|
+
sdkVersion: config.sdkVersion,
|
|
392
|
+
contactEntity: resolveContactEntity(config),
|
|
393
|
+
namespaceReady: config.namespaceReady ?? false,
|
|
394
|
+
schema: buildSchema(config)
|
|
395
|
+
})
|
|
396
|
+
});
|
|
226
397
|
const heartbeat = async () => {
|
|
227
|
-
return (await
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
398
|
+
return (await sendHeartbeat())?.ok ?? false;
|
|
399
|
+
};
|
|
400
|
+
const connect = async () => {
|
|
401
|
+
const response = await sendHeartbeat();
|
|
402
|
+
if (response === null) return {
|
|
403
|
+
ok: false,
|
|
404
|
+
status: null,
|
|
405
|
+
reason: "network_error",
|
|
406
|
+
retryable: true
|
|
407
|
+
};
|
|
408
|
+
if (response.ok) return {
|
|
409
|
+
ok: true,
|
|
410
|
+
status: response.status,
|
|
411
|
+
reason: "ok",
|
|
412
|
+
retryable: false,
|
|
413
|
+
org: await resolveOrg(response)
|
|
414
|
+
};
|
|
415
|
+
return {
|
|
416
|
+
ok: false,
|
|
417
|
+
status: response.status,
|
|
418
|
+
reason: connectReason(response.status),
|
|
419
|
+
retryable: isRetryableStatus(response.status)
|
|
420
|
+
};
|
|
421
|
+
};
|
|
422
|
+
const doctor = () => runDoctor({
|
|
423
|
+
config,
|
|
424
|
+
connect
|
|
425
|
+
});
|
|
426
|
+
const runScheduled = async (options) => {
|
|
427
|
+
await heartbeat();
|
|
428
|
+
if (config.source) await runSync(options);
|
|
429
|
+
};
|
|
430
|
+
const createClivlyHandler = () => async (_req) => {
|
|
431
|
+
try {
|
|
432
|
+
await runScheduled();
|
|
433
|
+
return jsonResponse({ ok: true }, 200);
|
|
434
|
+
} catch (error) {
|
|
435
|
+
return jsonResponse({
|
|
436
|
+
ok: false,
|
|
437
|
+
error: error instanceof Error ? error.message : String(error)
|
|
438
|
+
}, 500);
|
|
439
|
+
}
|
|
242
440
|
};
|
|
243
441
|
const start = async () => {
|
|
442
|
+
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
443
|
await heartbeat();
|
|
245
444
|
if (config.sync?.onBoot !== false) await runSync().catch(() => {});
|
|
246
445
|
if (!timers.heartbeat) {
|
|
@@ -267,7 +466,7 @@ function createClivlySDK(config) {
|
|
|
267
466
|
}
|
|
268
467
|
};
|
|
269
468
|
const createAuthVerifyHandler = (resolveUser) => async (req) => {
|
|
270
|
-
if (!config.verifyToken) return jsonResponse({ error: "
|
|
469
|
+
if (!config.verifyToken) return jsonResponse({ error: "Clivly verifyToken is not configured on the host app" }, SERVICE_UNAVAILABLE_STATUS);
|
|
271
470
|
const token = bearerToken(req);
|
|
272
471
|
if (!(token && timingSafeEqual(token, config.verifyToken))) return jsonResponse({ error: "unauthorized" }, 401);
|
|
273
472
|
const user = await resolveUser(req);
|
|
@@ -278,9 +477,13 @@ function createClivlySDK(config) {
|
|
|
278
477
|
start,
|
|
279
478
|
stop,
|
|
280
479
|
heartbeat,
|
|
480
|
+
connect,
|
|
481
|
+
doctor,
|
|
482
|
+
runScheduled,
|
|
483
|
+
createClivlyHandler,
|
|
281
484
|
sync: runSync,
|
|
282
485
|
createAuthVerifyHandler
|
|
283
486
|
};
|
|
284
487
|
}
|
|
285
488
|
//#endregion
|
|
286
|
-
export { createClivlySDK };
|
|
489
|
+
export { ClivlyAuthError, ClivlyError, ClivlyNetworkError, ClivlyRateLimitError, createClivlySDK };
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { ClivlyEntitiesConfig } from "@clivly/core";
|
|
2
|
+
|
|
3
|
+
//#region src/drizzle-meta.d.ts
|
|
4
|
+
declare const DRIZZLE_META: unique symbol;
|
|
5
|
+
interface DrizzleSourceMeta {
|
|
6
|
+
/** Drizzle property keys of every column on the source table. */
|
|
7
|
+
columnKeys: string[];
|
|
8
|
+
cursorField: string;
|
|
9
|
+
idField: string;
|
|
10
|
+
}
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/types.d.ts
|
|
13
|
+
interface DiscoveredTable {
|
|
14
|
+
columns: string[];
|
|
15
|
+
name: string;
|
|
16
|
+
}
|
|
17
|
+
interface ClivlySDKConfig {
|
|
18
|
+
/** Org-scoped API token (sk_live_…) from Settings → API keys. */
|
|
19
|
+
apiKey: string;
|
|
20
|
+
/** Clivly API base URL. Defaults to https://api.clivly.com. */
|
|
21
|
+
apiUrl?: string;
|
|
22
|
+
/**
|
|
23
|
+
* The host table Clivly treats as the contact entity.
|
|
24
|
+
* @deprecated Prefer `entities` — the same mapping-derived shape
|
|
25
|
+
* `crm_entity_mappings` (and `@clivly/core`'s `mappingsToEntitiesConfig`)
|
|
26
|
+
* produce, so the SDK, the mappings table, and the sync engine share one
|
|
27
|
+
* model. A bare `contactEntity` is treated as a single contact entity.
|
|
28
|
+
*/
|
|
29
|
+
contactEntity?: string;
|
|
30
|
+
/** Which columns of the contact entity Clivly may read. */
|
|
31
|
+
contactFields?: string[];
|
|
32
|
+
/**
|
|
33
|
+
* Where per-entity delta cursors are persisted between syncs. Defaults to an
|
|
34
|
+
* in-memory store (fine for a long-lived process). On serverless/edge, pass a
|
|
35
|
+
* store backed by your own KV/DB so a cold start resumes delta sync instead of
|
|
36
|
+
* re-paging the whole entity. See `CursorStore`.
|
|
37
|
+
*/
|
|
38
|
+
cursorStore?: CursorStore;
|
|
39
|
+
/**
|
|
40
|
+
* Declarative entity config — the single source of truth shared with
|
|
41
|
+
* `crm_entity_mappings`. When set, discovery and heartbeat are derived from
|
|
42
|
+
* it (each entity's `source` table + mapped columns), superseding the flat
|
|
43
|
+
* `contactEntity`.
|
|
44
|
+
*/
|
|
45
|
+
entities?: ClivlyEntitiesConfig;
|
|
46
|
+
/**
|
|
47
|
+
* 🚧 Not implemented in 0.1.x. Reserved options for tunnel mode and CRM
|
|
48
|
+
* write-back — they have NO effect yet and land with the Cloudflare
|
|
49
|
+
* infrastructure. Grouped under `experimental` so the stable config surface
|
|
50
|
+
* above is unambiguous; nothing here is part of the supported API.
|
|
51
|
+
*/
|
|
52
|
+
experimental?: {
|
|
53
|
+
/** Cloudflare Tunnel token — reserved for tunnel mode. Not wired up yet. */tunnelToken?: string; /** CRM write-back target — reserved. Not wired up yet. */
|
|
54
|
+
writeBack?: {
|
|
55
|
+
enabled?: boolean; /** Only crm_* tables are ever accepted; this narrows further. */
|
|
56
|
+
tables?: string[];
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
/** Reported to the portal so it can show the detected stack. */
|
|
60
|
+
framework?: string;
|
|
61
|
+
/** Heartbeat cadence in ms. Defaults to 60s. */
|
|
62
|
+
heartbeatIntervalMs?: number;
|
|
63
|
+
/** Whether the crm_* namespace exists in the host DB (self-reported). */
|
|
64
|
+
namespaceReady?: boolean;
|
|
65
|
+
orm?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Network retry policy for outbound calls (heartbeat + sync push). Transient
|
|
68
|
+
* failures (network errors, HTTP 429, HTTP 5xx) are retried with exponential
|
|
69
|
+
* backoff + jitter; a `Retry-After` header on a 429 is honoured. Non-transient
|
|
70
|
+
* responses (e.g. 4xx other than 429) are returned immediately, not retried.
|
|
71
|
+
*/
|
|
72
|
+
retry?: {
|
|
73
|
+
/** Total attempts including the first. Defaults to 3. Min 1. */maxAttempts?: number; /** Base backoff in ms (doubled each attempt). Defaults to 500. */
|
|
74
|
+
baseDelayMs?: number;
|
|
75
|
+
};
|
|
76
|
+
/** Tables/columns to report for entity discovery + mapping. */
|
|
77
|
+
schema?: DiscoveredTable[];
|
|
78
|
+
sdkVersion?: string;
|
|
79
|
+
/** Host entities to mirror into Clivly Cloud. */
|
|
80
|
+
source?: {
|
|
81
|
+
contacts: SyncSource;
|
|
82
|
+
companies?: SyncSource;
|
|
83
|
+
};
|
|
84
|
+
sync?: {
|
|
85
|
+
onBoot?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Sync cadence in ms for the background keep-alive loop. Defaults to
|
|
88
|
+
* `heartbeatIntervalMs` (60s) so sync and heartbeat share a tick unless you
|
|
89
|
+
* decouple them — e.g. a slow full-mirror alongside frequent heartbeats.
|
|
90
|
+
*/
|
|
91
|
+
intervalMs?: number;
|
|
92
|
+
batchSize?: number;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Shared secret Clivly Cloud presents (as `Authorization: Bearer <token>`) when
|
|
96
|
+
* calling the `/clivly/auth/verify` endpoint. Required for the verify handler to
|
|
97
|
+
* authenticate the caller; without it the handler fails closed (HTTP 503).
|
|
98
|
+
*/
|
|
99
|
+
verifyToken?: string;
|
|
100
|
+
}
|
|
101
|
+
interface ClivlyUser {
|
|
102
|
+
email: string;
|
|
103
|
+
id: string;
|
|
104
|
+
name?: string;
|
|
105
|
+
}
|
|
106
|
+
interface DoctorCheck {
|
|
107
|
+
/** Human-readable outcome — what passed, or why it failed. */
|
|
108
|
+
detail: string;
|
|
109
|
+
/** Short label, e.g. "API key present". */
|
|
110
|
+
name: string;
|
|
111
|
+
/** True when the check passed. Ignored when `skipped` is true. */
|
|
112
|
+
ok: boolean;
|
|
113
|
+
/** True when the check could not run (e.g. a hand-written source). */
|
|
114
|
+
skipped?: boolean;
|
|
115
|
+
}
|
|
116
|
+
interface DoctorReport {
|
|
117
|
+
checks: DoctorCheck[];
|
|
118
|
+
/** AND of every non-skipped check's `ok`. */
|
|
119
|
+
ok: boolean;
|
|
120
|
+
}
|
|
121
|
+
type ClivlyConnectReason = "ok" | "invalid_api_key" | "forbidden" | "rate_limited" | "server_error" | "client_error" | "network_error";
|
|
122
|
+
interface ClivlyConnectResult {
|
|
123
|
+
ok: boolean;
|
|
124
|
+
/** Org resolved from a successful heartbeat, when the server returns it. */
|
|
125
|
+
org?: {
|
|
126
|
+
id?: string;
|
|
127
|
+
name?: string;
|
|
128
|
+
};
|
|
129
|
+
reason: ClivlyConnectReason;
|
|
130
|
+
/** Whether a retry could plausibly succeed (network, 429, 5xx). */
|
|
131
|
+
retryable: boolean;
|
|
132
|
+
/** HTTP status, or null when the request never completed (network error). */
|
|
133
|
+
status: number | null;
|
|
134
|
+
}
|
|
135
|
+
interface SyncCursor {
|
|
136
|
+
/** The row's id, breaking ties when timestamps collide. */
|
|
137
|
+
id: string;
|
|
138
|
+
/** The row's cursor-field value (typically `updated_at`). */
|
|
139
|
+
time: Date;
|
|
140
|
+
}
|
|
141
|
+
interface SyncSource {
|
|
142
|
+
cursorField: string;
|
|
143
|
+
entity: string;
|
|
144
|
+
fetchPage(args: {
|
|
145
|
+
since: SyncCursor | null;
|
|
146
|
+
limit: number;
|
|
147
|
+
}): Promise<Record<string, unknown>[]>;
|
|
148
|
+
/** Stable, unique tie-breaker column for keyset paging. Defaults to "id". */
|
|
149
|
+
idField?: string;
|
|
150
|
+
/** Set by `fromDrizzle`; lets `doctor` introspect the backing table. */
|
|
151
|
+
[DRIZZLE_META]?: DrizzleSourceMeta;
|
|
152
|
+
}
|
|
153
|
+
type SyncMode = "delta" | "full";
|
|
154
|
+
interface CursorStore {
|
|
155
|
+
get(entity: string): Promise<SyncCursor | null> | SyncCursor | null;
|
|
156
|
+
set(entity: string, cursor: SyncCursor | null): Promise<void> | void;
|
|
157
|
+
}
|
|
158
|
+
interface ClivlySDK {
|
|
159
|
+
/**
|
|
160
|
+
* Send a single heartbeat and report the outcome richly: `ok`, the HTTP
|
|
161
|
+
* `status` (null on network failure), a coarse `reason`, whether it's
|
|
162
|
+
* `retryable`, and the resolved `org` when the server returns it. Never throws
|
|
163
|
+
* — inspect the result, or turn a failure into an exception with
|
|
164
|
+
* `ClivlyError.from(result)`. Prefer this over `heartbeat()` for onboarding,
|
|
165
|
+
* health checks, and one-shot serverless calls.
|
|
166
|
+
*/
|
|
167
|
+
connect(): Promise<ClivlyConnectResult>;
|
|
168
|
+
/**
|
|
169
|
+
* Build the `/clivly/auth/verify` handler the developer mounts in their app.
|
|
170
|
+
* Clivly's cloud calls it to validate a CRM user's identity against the host
|
|
171
|
+
* app's session. Pass a resolver that reads the host session from the request.
|
|
172
|
+
*
|
|
173
|
+
* The handler authenticates the caller against `config.verifyToken` (presented
|
|
174
|
+
* as `Authorization: Bearer <token>`) before invoking the resolver, so the
|
|
175
|
+
* endpoint can't be used by third parties to probe host sessions. It fails
|
|
176
|
+
* closed with HTTP 503 if `verifyToken` is unset (a missing config value, not a
|
|
177
|
+
* server fault — so it won't trip the host's 5xx error alerting).
|
|
178
|
+
*/
|
|
179
|
+
createAuthVerifyHandler(resolveUser: (req: Request) => Promise<ClivlyUser | null> | ClivlyUser | null): (req: Request) => Promise<Response>;
|
|
180
|
+
/**
|
|
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()`.
|
|
185
|
+
*/
|
|
186
|
+
createClivlyHandler(): (req: Request) => Promise<Response>;
|
|
187
|
+
/** Validate this setup end-to-end (read-only) before the first sync. */
|
|
188
|
+
doctor(): Promise<DoctorReport>;
|
|
189
|
+
/**
|
|
190
|
+
* Send a single heartbeat now. Returns true on success. Kept for the keep-alive
|
|
191
|
+
* loop and back-compat; use `connect()` when you need the failure reason.
|
|
192
|
+
*/
|
|
193
|
+
heartbeat(): Promise<boolean>;
|
|
194
|
+
/**
|
|
195
|
+
* One-shot heartbeat + a single sync, with no keep-alive timers. The correct
|
|
196
|
+
* entry point on serverless/edge (Workers, Vercel, Lambda), where `start()`'s
|
|
197
|
+
* `setInterval` loop can't persist between invocations — call this from a
|
|
198
|
+
* scheduled/cron trigger. Throws if a sync push fails (so the run is marked
|
|
199
|
+
* failed); heartbeat is best-effort.
|
|
200
|
+
*/
|
|
201
|
+
runScheduled(options?: {
|
|
202
|
+
mode?: SyncMode;
|
|
203
|
+
}): Promise<void>;
|
|
204
|
+
/**
|
|
205
|
+
* Send the initial heartbeat and start the keep-alive loop. For long-lived
|
|
206
|
+
* hosts only — on serverless/edge the interval can't persist, so prefer
|
|
207
|
+
* `runScheduled()` / `createClivlyHandler()`. Emits a warning if a serverless
|
|
208
|
+
* runtime is detected.
|
|
209
|
+
*/
|
|
210
|
+
start(): Promise<void>;
|
|
211
|
+
/** Stop the keep-alive loop. */
|
|
212
|
+
stop(): void;
|
|
213
|
+
/**
|
|
214
|
+
* Sync all configured sources now. `delta` (default) pages changed rows since
|
|
215
|
+
* the last cursor; `full` pushes each complete entity so the server can
|
|
216
|
+
* reconcile leavers (archive-on-leave). Throws if a push fails, so callers see
|
|
217
|
+
* the failure — the background keep-alive loop swallows and retries instead.
|
|
218
|
+
*/
|
|
219
|
+
sync(options?: {
|
|
220
|
+
mode?: SyncMode;
|
|
221
|
+
}): Promise<void>;
|
|
222
|
+
}
|
|
223
|
+
//#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 };
|