@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/README.md +197 -27
- package/dist/drizzle-meta-DMx_9nlj.mjs +4 -0
- package/dist/drizzle-meta-iAu8w1hj.cjs +9 -0
- package/dist/drizzle.cjs +107 -0
- package/dist/drizzle.d.cts +28 -0
- package/dist/drizzle.d.mts +28 -0
- package/dist/drizzle.mjs +105 -0
- package/dist/index.cjs +769 -0
- package/dist/index.d.cts +33 -0
- package/dist/index.d.mts +19 -124
- package/dist/index.mjs +522 -44
- package/dist/namespace-probe-DFpvfl7T.d.mts +299 -0
- package/dist/namespace-probe-DIBgCQca.d.cts +299 -0
- package/dist/vite.cjs +38 -0
- package/dist/vite.d.cts +23 -0
- package/dist/vite.d.mts +23 -0
- package/dist/vite.mjs +37 -0
- package/package.json +49 -8
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 +=
|
|
46
|
-
cursor = nextCursor(
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
547
|
+
const cursorStore = config.cursorStore ?? createMemoryCursorStore();
|
|
189
548
|
const fetcher = createFetcher(config.retry);
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
})
|
|
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
|
|
210
|
-
batchSize,
|
|
211
|
-
push
|
|
587
|
+
since,
|
|
588
|
+
batchSize: effectiveBatchSize,
|
|
589
|
+
push,
|
|
590
|
+
objectType,
|
|
591
|
+
maxRows: maxRowsOverride
|
|
212
592
|
});
|
|
213
|
-
|
|
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 (
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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: "
|
|
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 };
|