@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/README.md +113 -11
- package/dist/{drizzle-meta-CAJ43cLc.mjs → drizzle-meta-DMx_9nlj.mjs} +1 -1
- package/dist/{drizzle-meta-C0T6F3vL.cjs → drizzle-meta-iAu8w1hj.cjs} +1 -1
- package/dist/drizzle.cjs +59 -2
- package/dist/drizzle.d.cts +8 -2
- package/dist/drizzle.d.mts +8 -2
- package/dist/drizzle.mjs +59 -3
- package/dist/index.cjs +306 -31
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +306 -31
- package/dist/{types-B0aSxJ6V.d.cts → namespace-probe-DFpvfl7T.d.mts} +82 -7
- package/dist/{types-DWt-ytBQ.d.mts → namespace-probe-DIBgCQca.d.cts} +82 -7
- 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 +18 -3
package/dist/index.mjs
CHANGED
|
@@ -1,13 +1,96 @@
|
|
|
1
|
-
import { t as DRIZZLE_META } from "./drizzle-meta-
|
|
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
|
|
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 +=
|
|
130
|
-
cursor = nextCursor(
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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
|
-
})
|
|
560
|
+
});
|
|
561
|
+
if (response?.ok) return true;
|
|
562
|
+
lastPushFailure ??= { status: response?.status ?? null };
|
|
563
|
+
return false;
|
|
354
564
|
};
|
|
355
|
-
const
|
|
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 (
|
|
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
|
|
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
|
-
|
|
433
|
-
|
|
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:
|
|
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
|
-
/**
|
|
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
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
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
|
-
|
|
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 };
|