@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.cjs
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_drizzle_meta = require("./drizzle-meta-C0T6F3vL.cjs");
|
|
3
|
+
//#region src/doctor.ts
|
|
4
|
+
function configuredSources(config) {
|
|
5
|
+
const sources = [];
|
|
6
|
+
if (config.source?.contacts) sources.push(config.source.contacts);
|
|
7
|
+
if (config.source?.companies) sources.push(config.source.companies);
|
|
8
|
+
return sources;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Run the six read-only setup checks and return a structured report. Every check
|
|
12
|
+
* captures its own error into `detail` rather than throwing, so one failure never
|
|
13
|
+
* hides the rest. `ok` is the AND of all non-skipped checks.
|
|
14
|
+
*/
|
|
15
|
+
async function runDoctor(args) {
|
|
16
|
+
const { config, connect } = args;
|
|
17
|
+
const checks = [];
|
|
18
|
+
const sources = configuredSources(config);
|
|
19
|
+
const hasKey = Boolean(config.apiKey);
|
|
20
|
+
checks.push({
|
|
21
|
+
name: "API key present",
|
|
22
|
+
ok: hasKey,
|
|
23
|
+
detail: hasKey ? "CLIVLY_API_KEY is set." : "apiKey is empty — set CLIVLY_API_KEY."
|
|
24
|
+
});
|
|
25
|
+
const entityCount = config.entities ? Object.keys(config.entities.entities).length : 0;
|
|
26
|
+
checks.push({
|
|
27
|
+
name: "Entities configured",
|
|
28
|
+
ok: entityCount > 0,
|
|
29
|
+
detail: entityCount > 0 ? `${entityCount} entit${entityCount === 1 ? "y" : "ies"} configured.` : "No `entities` config — pass defineClivlyConfig(...) as `entities`."
|
|
30
|
+
});
|
|
31
|
+
checks.push({
|
|
32
|
+
name: "Sources configured",
|
|
33
|
+
ok: sources.length > 0,
|
|
34
|
+
detail: sources.length > 0 ? `${sources.length} source(s) to mirror.` : "No `source.contacts` — nothing to sync."
|
|
35
|
+
});
|
|
36
|
+
for (const source of sources) {
|
|
37
|
+
const meta = source[require_drizzle_meta.DRIZZLE_META];
|
|
38
|
+
if (!meta) {
|
|
39
|
+
checks.push({
|
|
40
|
+
name: `Cursor/id columns exist (${source.entity})`,
|
|
41
|
+
ok: false,
|
|
42
|
+
skipped: true,
|
|
43
|
+
detail: `"${source.entity}" is a hand-written source — column check skipped.`
|
|
44
|
+
});
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const idField = source.idField ?? "id";
|
|
48
|
+
const missing = [meta.cursorField, idField].filter((field) => !meta.columnKeys.includes(field));
|
|
49
|
+
checks.push({
|
|
50
|
+
name: `Cursor/id columns exist (${source.entity})`,
|
|
51
|
+
ok: missing.length === 0,
|
|
52
|
+
detail: missing.length === 0 ? `"${source.entity}": ${meta.cursorField} + ${idField} present.` : `"${source.entity}" missing column(s): ${missing.join(", ")}.`
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
for (const source of sources) try {
|
|
56
|
+
const rows = await source.fetchPage({
|
|
57
|
+
since: null,
|
|
58
|
+
limit: 1
|
|
59
|
+
});
|
|
60
|
+
const firstRow = rows[0];
|
|
61
|
+
const missingCursor = firstRow !== void 0 && !(source.cursorField in firstRow);
|
|
62
|
+
checks.push({
|
|
63
|
+
name: `Sample fetch (${source.entity})`,
|
|
64
|
+
ok: !missingCursor,
|
|
65
|
+
detail: missingCursor ? `"${source.entity}" rows lack the cursor key "${source.cursorField}".` : `"${source.entity}" returned ${rows.length} row(s).`
|
|
66
|
+
});
|
|
67
|
+
} catch (error) {
|
|
68
|
+
checks.push({
|
|
69
|
+
name: `Sample fetch (${source.entity})`,
|
|
70
|
+
ok: false,
|
|
71
|
+
detail: `"${source.entity}" fetchPage threw: ${error.message}`
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const result = await connect();
|
|
75
|
+
checks.push({
|
|
76
|
+
name: "Heartbeat",
|
|
77
|
+
ok: result.ok,
|
|
78
|
+
detail: result.ok ? `Reached Clivly — org "${result.org?.name ?? "(unnamed)"}".` : `Could not connect (${result.reason}).`
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
ok: checks.every((check) => check.skipped || check.ok),
|
|
82
|
+
checks
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/sync.ts
|
|
87
|
+
const DEFAULT_ID_FIELD = "id";
|
|
88
|
+
function isAfter(candidate, current) {
|
|
89
|
+
const ct = candidate.time.getTime();
|
|
90
|
+
const bt = current.time.getTime();
|
|
91
|
+
if (ct !== bt) return ct > bt;
|
|
92
|
+
return candidate.id > current.id;
|
|
93
|
+
}
|
|
94
|
+
function nextCursor(rows, cursorField, idField, current) {
|
|
95
|
+
let max = current;
|
|
96
|
+
for (const row of rows) {
|
|
97
|
+
const raw = row[cursorField];
|
|
98
|
+
if (raw === null || raw === void 0) continue;
|
|
99
|
+
const time = raw instanceof Date ? raw : new Date(String(raw));
|
|
100
|
+
if (Number.isNaN(time.getTime())) continue;
|
|
101
|
+
const idRaw = row[idField];
|
|
102
|
+
const candidate = {
|
|
103
|
+
time,
|
|
104
|
+
id: idRaw === null || idRaw === void 0 ? "" : String(idRaw)
|
|
105
|
+
};
|
|
106
|
+
if (!max || isAfter(candidate, max)) max = candidate;
|
|
107
|
+
}
|
|
108
|
+
return max;
|
|
109
|
+
}
|
|
110
|
+
async function syncSource(args) {
|
|
111
|
+
const { source, path, since, batchSize, push } = args;
|
|
112
|
+
const idField = source.idField ?? DEFAULT_ID_FIELD;
|
|
113
|
+
let cursor = since;
|
|
114
|
+
let pushed = 0;
|
|
115
|
+
for (;;) {
|
|
116
|
+
const rows = await source.fetchPage({
|
|
117
|
+
since: cursor,
|
|
118
|
+
limit: batchSize
|
|
119
|
+
});
|
|
120
|
+
if (rows.length === 0) break;
|
|
121
|
+
if (!await push(path, {
|
|
122
|
+
source: source.entity,
|
|
123
|
+
rows,
|
|
124
|
+
mode: "delta"
|
|
125
|
+
})) return {
|
|
126
|
+
pushed,
|
|
127
|
+
cursor,
|
|
128
|
+
ok: false
|
|
129
|
+
};
|
|
130
|
+
pushed += rows.length;
|
|
131
|
+
cursor = nextCursor(rows, source.cursorField, idField, cursor);
|
|
132
|
+
if (rows.length < batchSize) break;
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
pushed,
|
|
136
|
+
cursor,
|
|
137
|
+
ok: true
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
async function fullSyncSource(args) {
|
|
141
|
+
const { source, path, batchSize, push } = args;
|
|
142
|
+
const idField = source.idField ?? DEFAULT_ID_FIELD;
|
|
143
|
+
const all = [];
|
|
144
|
+
let cursor = null;
|
|
145
|
+
for (;;) {
|
|
146
|
+
const rows = await source.fetchPage({
|
|
147
|
+
since: cursor,
|
|
148
|
+
limit: batchSize
|
|
149
|
+
});
|
|
150
|
+
if (rows.length === 0) break;
|
|
151
|
+
all.push(...rows);
|
|
152
|
+
const next = nextCursor(rows, source.cursorField, idField, cursor);
|
|
153
|
+
const stalled = next !== null && cursor !== null && next.time.getTime() === cursor.time.getTime() && next.id === cursor.id;
|
|
154
|
+
cursor = next;
|
|
155
|
+
if (rows.length < batchSize || stalled) break;
|
|
156
|
+
}
|
|
157
|
+
if (all.length === 0) return {
|
|
158
|
+
pushed: 0,
|
|
159
|
+
cursor: null,
|
|
160
|
+
ok: true
|
|
161
|
+
};
|
|
162
|
+
const ok = await push(path, {
|
|
163
|
+
source: source.entity,
|
|
164
|
+
rows: all,
|
|
165
|
+
mode: "full"
|
|
166
|
+
});
|
|
167
|
+
return {
|
|
168
|
+
pushed: ok ? all.length : 0,
|
|
169
|
+
cursor,
|
|
170
|
+
ok
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
//#endregion
|
|
174
|
+
//#region src/errors.ts
|
|
175
|
+
var ClivlyError = class ClivlyError extends Error {
|
|
176
|
+
/** HTTP status, or null when the request never completed (network error). */
|
|
177
|
+
status;
|
|
178
|
+
reason;
|
|
179
|
+
/** Whether a retry could plausibly succeed (network, 429, 5xx). */
|
|
180
|
+
retryable;
|
|
181
|
+
constructor(result, message) {
|
|
182
|
+
super(message ?? `Clivly connection failed: ${result.reason}`);
|
|
183
|
+
this.name = new.target.name;
|
|
184
|
+
this.status = result.status;
|
|
185
|
+
this.reason = result.reason;
|
|
186
|
+
this.retryable = result.retryable;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Build the error subclass matching a failed result's reason, or `null` when
|
|
190
|
+
* the result is `ok`.
|
|
191
|
+
*/
|
|
192
|
+
static from(result) {
|
|
193
|
+
if (result.ok) return null;
|
|
194
|
+
switch (result.reason) {
|
|
195
|
+
case "invalid_api_key":
|
|
196
|
+
case "forbidden": return new ClivlyAuthError(result);
|
|
197
|
+
case "rate_limited": return new ClivlyRateLimitError(result);
|
|
198
|
+
case "network_error": return new ClivlyNetworkError(result);
|
|
199
|
+
default: return new ClivlyError(result);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
/** The API key was rejected (401) or lacks access (403). Not retryable. */
|
|
204
|
+
var ClivlyAuthError = class extends ClivlyError {};
|
|
205
|
+
/** Rate limited (429). Retryable after backoff / `Retry-After`. */
|
|
206
|
+
var ClivlyRateLimitError = class extends ClivlyError {};
|
|
207
|
+
/** The request never reached the server (network/DNS/TLS). Retryable. */
|
|
208
|
+
var ClivlyNetworkError = class extends ClivlyError {};
|
|
209
|
+
//#endregion
|
|
210
|
+
//#region src/index.ts
|
|
211
|
+
const DEFAULT_API_URL = "https://api.clivly.com";
|
|
212
|
+
const DEFAULT_HEARTBEAT_MS = 6e4;
|
|
213
|
+
const DEFAULT_BATCH_SIZE = 500;
|
|
214
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
215
|
+
const DEFAULT_BASE_DELAY_MS = 500;
|
|
216
|
+
const RETRYABLE_STATUS_FLOOR = 500;
|
|
217
|
+
const RATE_LIMITED_STATUS = 429;
|
|
218
|
+
const SYNC_CONTACTS_PATH = "/clivly/sdk/sync/contacts";
|
|
219
|
+
const SYNC_COMPANIES_PATH = "/clivly/sdk/sync/companies";
|
|
220
|
+
const TRAILING_SLASH = /\/$/;
|
|
221
|
+
const BEARER_PREFIX = /^Bearer\s+/i;
|
|
222
|
+
function entitiesToTables(entities, byName) {
|
|
223
|
+
if (!entities) return;
|
|
224
|
+
for (const entity of Object.values(entities.entities)) {
|
|
225
|
+
const columns = byName.get(entity.source) ?? /* @__PURE__ */ new Set();
|
|
226
|
+
for (const sourceColumn of Object.values(entity.fields)) columns.add(sourceColumn);
|
|
227
|
+
byName.set(entity.source, columns);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function resolveContactEntity(config) {
|
|
231
|
+
if (config.contactEntity) return config.contactEntity;
|
|
232
|
+
for (const entity of Object.values(config.entities?.entities ?? {})) if (entity.concept === "contact") return entity.source;
|
|
233
|
+
}
|
|
234
|
+
function buildSchema(config) {
|
|
235
|
+
const byName = /* @__PURE__ */ new Map();
|
|
236
|
+
for (const table of config.schema ?? []) {
|
|
237
|
+
const columns = byName.get(table.name) ?? /* @__PURE__ */ new Set();
|
|
238
|
+
for (const column of table.columns) columns.add(column);
|
|
239
|
+
byName.set(table.name, columns);
|
|
240
|
+
}
|
|
241
|
+
entitiesToTables(config.entities, byName);
|
|
242
|
+
if (config.contactEntity && !byName.has(config.contactEntity)) byName.set(config.contactEntity, new Set(config.contactFields ?? []));
|
|
243
|
+
return [...byName].map(([name, columns]) => ({
|
|
244
|
+
name,
|
|
245
|
+
columns: [...columns]
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
249
|
+
function createMemoryCursorStore() {
|
|
250
|
+
const cursors = /* @__PURE__ */ new Map();
|
|
251
|
+
return {
|
|
252
|
+
get: (entity) => cursors.get(entity) ?? null,
|
|
253
|
+
set: (entity, cursor) => {
|
|
254
|
+
cursors.set(entity, cursor);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function isLikelyServerless() {
|
|
259
|
+
const runtime = globalThis;
|
|
260
|
+
if (runtime.navigator?.userAgent === "Cloudflare-Workers") return true;
|
|
261
|
+
if (runtime.EdgeRuntime !== void 0) return true;
|
|
262
|
+
const env = runtime.process?.env;
|
|
263
|
+
return Boolean(env?.AWS_LAMBDA_FUNCTION_NAME || env?.VERCEL);
|
|
264
|
+
}
|
|
265
|
+
function jsonResponse(body, status) {
|
|
266
|
+
return new Response(JSON.stringify(body), {
|
|
267
|
+
status,
|
|
268
|
+
headers: { "content-type": "application/json" }
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
function timingSafeEqual(a, b) {
|
|
272
|
+
if (a.length !== b.length) return false;
|
|
273
|
+
let mismatch = 0;
|
|
274
|
+
for (let i = 0; i < a.length; i++) mismatch += a.charCodeAt(i) === b.charCodeAt(i) ? 0 : 1;
|
|
275
|
+
return mismatch === 0;
|
|
276
|
+
}
|
|
277
|
+
function bearerToken(req) {
|
|
278
|
+
const header = req.headers.get("authorization");
|
|
279
|
+
if (!(header && BEARER_PREFIX.test(header))) return null;
|
|
280
|
+
return header.replace(BEARER_PREFIX, "").trim();
|
|
281
|
+
}
|
|
282
|
+
function isRetryableStatus(status) {
|
|
283
|
+
return status === RATE_LIMITED_STATUS || status >= RETRYABLE_STATUS_FLOOR;
|
|
284
|
+
}
|
|
285
|
+
const UNAUTHORIZED_STATUS = 401;
|
|
286
|
+
const FORBIDDEN_STATUS = 403;
|
|
287
|
+
const CLIENT_ERROR_FLOOR = 400;
|
|
288
|
+
const SERVICE_UNAVAILABLE_STATUS = 503;
|
|
289
|
+
function connectReason(status) {
|
|
290
|
+
if (status === UNAUTHORIZED_STATUS) return "invalid_api_key";
|
|
291
|
+
if (status === FORBIDDEN_STATUS) return "forbidden";
|
|
292
|
+
if (status === RATE_LIMITED_STATUS) return "rate_limited";
|
|
293
|
+
if (status >= RETRYABLE_STATUS_FLOOR) return "server_error";
|
|
294
|
+
if (status >= CLIENT_ERROR_FLOOR) return "client_error";
|
|
295
|
+
return "server_error";
|
|
296
|
+
}
|
|
297
|
+
async function resolveOrg(response) {
|
|
298
|
+
try {
|
|
299
|
+
return (await response.json())?.org;
|
|
300
|
+
} catch {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function retryAfterMs(res) {
|
|
305
|
+
const header = res.headers.get("retry-after");
|
|
306
|
+
if (!header) return null;
|
|
307
|
+
const seconds = Number(header);
|
|
308
|
+
if (!Number.isNaN(seconds)) return Math.max(0, seconds * 1e3);
|
|
309
|
+
const date = Date.parse(header);
|
|
310
|
+
return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
|
|
311
|
+
}
|
|
312
|
+
function createFetcher(retry) {
|
|
313
|
+
const maxAttempts = Math.max(1, retry?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
|
|
314
|
+
const baseDelayMs = retry?.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
|
|
315
|
+
return async (url, init) => {
|
|
316
|
+
for (let attempt = 1;; attempt++) {
|
|
317
|
+
let res = null;
|
|
318
|
+
try {
|
|
319
|
+
res = await fetch(url, init);
|
|
320
|
+
} catch {
|
|
321
|
+
res = null;
|
|
322
|
+
}
|
|
323
|
+
if (!(res === null || isRetryableStatus(res.status)) || attempt >= maxAttempts) return res;
|
|
324
|
+
const backoff = baseDelayMs * 2 ** (attempt - 1) + Math.random() * baseDelayMs;
|
|
325
|
+
await sleep(res ? retryAfterMs(res) ?? backoff : backoff);
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Create the Clivly SDK. The implemented surface — heartbeat/discovery reporting
|
|
331
|
+
* and the auth-verify handler — connects a developer's app to a running Clivly
|
|
332
|
+
* backend today. Tunnel management and CRM write-back are scaffolded (see the
|
|
333
|
+
* clearly-marked TODOs) and land with the Cloudflare infrastructure.
|
|
334
|
+
*/
|
|
335
|
+
function createClivlySDK(config) {
|
|
336
|
+
const apiUrl = (config.apiUrl ?? DEFAULT_API_URL).replace(TRAILING_SLASH, "");
|
|
337
|
+
const heartbeatIntervalMs = config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_MS;
|
|
338
|
+
const syncIntervalMs = config.sync?.intervalMs ?? heartbeatIntervalMs;
|
|
339
|
+
const timers = {
|
|
340
|
+
heartbeat: null,
|
|
341
|
+
sync: null
|
|
342
|
+
};
|
|
343
|
+
const batchSize = config.sync?.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
344
|
+
const cursorStore = config.cursorStore ?? createMemoryCursorStore();
|
|
345
|
+
const fetcher = createFetcher(config.retry);
|
|
346
|
+
const push = async (path, body) => {
|
|
347
|
+
return (await fetcher(`${apiUrl}${path}`, {
|
|
348
|
+
method: "POST",
|
|
349
|
+
headers: {
|
|
350
|
+
"content-type": "application/json",
|
|
351
|
+
authorization: `Bearer ${config.apiKey}`
|
|
352
|
+
},
|
|
353
|
+
body: JSON.stringify(body)
|
|
354
|
+
}))?.ok ?? false;
|
|
355
|
+
};
|
|
356
|
+
const syncOne = async (source, path, mode) => {
|
|
357
|
+
const since = mode === "full" ? null : await cursorStore.get(source.entity) ?? null;
|
|
358
|
+
const result = mode === "full" ? await fullSyncSource({
|
|
359
|
+
source,
|
|
360
|
+
path,
|
|
361
|
+
batchSize,
|
|
362
|
+
push
|
|
363
|
+
}) : await syncSource({
|
|
364
|
+
source,
|
|
365
|
+
path,
|
|
366
|
+
since,
|
|
367
|
+
batchSize,
|
|
368
|
+
push
|
|
369
|
+
});
|
|
370
|
+
await cursorStore.set(source.entity, result.cursor);
|
|
371
|
+
return result.ok;
|
|
372
|
+
};
|
|
373
|
+
const runSync = async (options) => {
|
|
374
|
+
if (!config.source) return;
|
|
375
|
+
const mode = options?.mode ?? "delta";
|
|
376
|
+
const failed = [];
|
|
377
|
+
if (config.source.companies) {
|
|
378
|
+
if (!await syncOne(config.source.companies, SYNC_COMPANIES_PATH, mode)) failed.push(config.source.companies.entity);
|
|
379
|
+
}
|
|
380
|
+
if (!await syncOne(config.source.contacts, SYNC_CONTACTS_PATH, mode)) failed.push(config.source.contacts.entity);
|
|
381
|
+
if (failed.length > 0) throw new Error(`Clivly sync: push failed for ${failed.join(", ")}`);
|
|
382
|
+
};
|
|
383
|
+
const sendHeartbeat = () => fetcher(`${apiUrl}/clivly/sdk/heartbeat`, {
|
|
384
|
+
method: "POST",
|
|
385
|
+
headers: {
|
|
386
|
+
"content-type": "application/json",
|
|
387
|
+
authorization: `Bearer ${config.apiKey}`
|
|
388
|
+
},
|
|
389
|
+
body: JSON.stringify({
|
|
390
|
+
framework: config.framework,
|
|
391
|
+
orm: config.orm,
|
|
392
|
+
sdkVersion: config.sdkVersion,
|
|
393
|
+
contactEntity: resolveContactEntity(config),
|
|
394
|
+
namespaceReady: config.namespaceReady ?? false,
|
|
395
|
+
schema: buildSchema(config)
|
|
396
|
+
})
|
|
397
|
+
});
|
|
398
|
+
const heartbeat = async () => {
|
|
399
|
+
return (await sendHeartbeat())?.ok ?? false;
|
|
400
|
+
};
|
|
401
|
+
const connect = async () => {
|
|
402
|
+
const response = await sendHeartbeat();
|
|
403
|
+
if (response === null) return {
|
|
404
|
+
ok: false,
|
|
405
|
+
status: null,
|
|
406
|
+
reason: "network_error",
|
|
407
|
+
retryable: true
|
|
408
|
+
};
|
|
409
|
+
if (response.ok) return {
|
|
410
|
+
ok: true,
|
|
411
|
+
status: response.status,
|
|
412
|
+
reason: "ok",
|
|
413
|
+
retryable: false,
|
|
414
|
+
org: await resolveOrg(response)
|
|
415
|
+
};
|
|
416
|
+
return {
|
|
417
|
+
ok: false,
|
|
418
|
+
status: response.status,
|
|
419
|
+
reason: connectReason(response.status),
|
|
420
|
+
retryable: isRetryableStatus(response.status)
|
|
421
|
+
};
|
|
422
|
+
};
|
|
423
|
+
const doctor = () => runDoctor({
|
|
424
|
+
config,
|
|
425
|
+
connect
|
|
426
|
+
});
|
|
427
|
+
const runScheduled = async (options) => {
|
|
428
|
+
await heartbeat();
|
|
429
|
+
if (config.source) await runSync(options);
|
|
430
|
+
};
|
|
431
|
+
const createClivlyHandler = () => async (_req) => {
|
|
432
|
+
try {
|
|
433
|
+
await runScheduled();
|
|
434
|
+
return jsonResponse({ ok: true }, 200);
|
|
435
|
+
} catch (error) {
|
|
436
|
+
return jsonResponse({
|
|
437
|
+
ok: false,
|
|
438
|
+
error: error instanceof Error ? error.message : String(error)
|
|
439
|
+
}, 500);
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
const start = async () => {
|
|
443
|
+
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.");
|
|
444
|
+
await heartbeat();
|
|
445
|
+
if (config.sync?.onBoot !== false) await runSync().catch(() => {});
|
|
446
|
+
if (!timers.heartbeat) {
|
|
447
|
+
timers.heartbeat = setInterval(() => {
|
|
448
|
+
heartbeat().catch(() => {});
|
|
449
|
+
}, heartbeatIntervalMs);
|
|
450
|
+
timers.heartbeat.unref?.();
|
|
451
|
+
}
|
|
452
|
+
if (!timers.sync && config.source) {
|
|
453
|
+
timers.sync = setInterval(() => {
|
|
454
|
+
runSync().catch(() => {});
|
|
455
|
+
}, syncIntervalMs);
|
|
456
|
+
timers.sync.unref?.();
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
const stop = () => {
|
|
460
|
+
if (timers.heartbeat) {
|
|
461
|
+
clearInterval(timers.heartbeat);
|
|
462
|
+
timers.heartbeat = null;
|
|
463
|
+
}
|
|
464
|
+
if (timers.sync) {
|
|
465
|
+
clearInterval(timers.sync);
|
|
466
|
+
timers.sync = null;
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
const createAuthVerifyHandler = (resolveUser) => async (req) => {
|
|
470
|
+
if (!config.verifyToken) return jsonResponse({ error: "Clivly verifyToken is not configured on the host app" }, SERVICE_UNAVAILABLE_STATUS);
|
|
471
|
+
const token = bearerToken(req);
|
|
472
|
+
if (!(token && timingSafeEqual(token, config.verifyToken))) return jsonResponse({ error: "unauthorized" }, 401);
|
|
473
|
+
const user = await resolveUser(req);
|
|
474
|
+
if (!user) return jsonResponse({ error: "unauthorized" }, 401);
|
|
475
|
+
return jsonResponse(user, 200);
|
|
476
|
+
};
|
|
477
|
+
return {
|
|
478
|
+
start,
|
|
479
|
+
stop,
|
|
480
|
+
heartbeat,
|
|
481
|
+
connect,
|
|
482
|
+
doctor,
|
|
483
|
+
runScheduled,
|
|
484
|
+
createClivlyHandler,
|
|
485
|
+
sync: runSync,
|
|
486
|
+
createAuthVerifyHandler
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
//#endregion
|
|
490
|
+
exports.ClivlyAuthError = ClivlyAuthError;
|
|
491
|
+
exports.ClivlyError = ClivlyError;
|
|
492
|
+
exports.ClivlyNetworkError = ClivlyNetworkError;
|
|
493
|
+
exports.ClivlyRateLimitError = ClivlyRateLimitError;
|
|
494
|
+
exports.createClivlySDK = createClivlySDK;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { a as ClivlyUser, c as DoctorCheck, i as ClivlySDKConfig, l as DoctorReport, n as ClivlyConnectResult, o as CursorStore, r as ClivlySDK, s as DiscoveredTable, t as ClivlyConnectReason } from "./types-B0aSxJ6V.cjs";
|
|
2
|
+
|
|
3
|
+
//#region src/errors.d.ts
|
|
4
|
+
declare class ClivlyError extends Error {
|
|
5
|
+
/** HTTP status, or null when the request never completed (network error). */
|
|
6
|
+
readonly status: number | null;
|
|
7
|
+
readonly reason: ClivlyConnectResult["reason"];
|
|
8
|
+
/** Whether a retry could plausibly succeed (network, 429, 5xx). */
|
|
9
|
+
readonly retryable: boolean;
|
|
10
|
+
constructor(result: ClivlyConnectResult, message?: string);
|
|
11
|
+
/**
|
|
12
|
+
* Build the error subclass matching a failed result's reason, or `null` when
|
|
13
|
+
* the result is `ok`.
|
|
14
|
+
*/
|
|
15
|
+
static from(result: ClivlyConnectResult): ClivlyError | null;
|
|
16
|
+
}
|
|
17
|
+
/** The API key was rejected (401) or lacks access (403). Not retryable. */
|
|
18
|
+
declare class ClivlyAuthError extends ClivlyError {}
|
|
19
|
+
/** Rate limited (429). Retryable after backoff / `Retry-After`. */
|
|
20
|
+
declare class ClivlyRateLimitError extends ClivlyError {}
|
|
21
|
+
/** The request never reached the server (network/DNS/TLS). Retryable. */
|
|
22
|
+
declare class ClivlyNetworkError extends ClivlyError {}
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/index.d.ts
|
|
25
|
+
/**
|
|
26
|
+
* Create the Clivly SDK. The implemented surface — heartbeat/discovery reporting
|
|
27
|
+
* and the auth-verify handler — connects a developer's app to a running Clivly
|
|
28
|
+
* backend today. Tunnel management and CRM write-back are scaffolded (see the
|
|
29
|
+
* clearly-marked TODOs) and land with the Cloudflare infrastructure.
|
|
30
|
+
*/
|
|
31
|
+
declare function createClivlySDK(config: ClivlySDKConfig): ClivlySDK;
|
|
32
|
+
//#endregion
|
|
33
|
+
export { ClivlyAuthError, type ClivlyConnectReason, type ClivlyConnectResult, ClivlyError, ClivlyNetworkError, ClivlyRateLimitError, type ClivlySDK, type ClivlySDKConfig, type ClivlyUser, type CursorStore, type DiscoveredTable, type DoctorCheck, type DoctorReport, createClivlySDK };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,130 +1,25 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as ClivlyUser, c as DoctorCheck, i as ClivlySDKConfig, l as DoctorReport, n as ClivlyConnectResult, o as CursorStore, r as ClivlySDK, s as DiscoveredTable, t as ClivlyConnectReason } from "./types-DWt-ytBQ.mjs";
|
|
2
2
|
|
|
3
|
-
//#region src/
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
/** Clivly API base URL. Defaults to https://api.clivly.com. */
|
|
12
|
-
apiUrl?: string;
|
|
13
|
-
/**
|
|
14
|
-
* The host table Clivly treats as the contact entity.
|
|
15
|
-
* @deprecated Prefer `entities` — the same mapping-derived shape
|
|
16
|
-
* `crm_entity_mappings` (and `@clivly/core`'s `mappingsToEntitiesConfig`)
|
|
17
|
-
* produce, so the SDK, the mappings table, and the sync engine share one
|
|
18
|
-
* model. A bare `contactEntity` is treated as a single contact entity.
|
|
19
|
-
*/
|
|
20
|
-
contactEntity?: string;
|
|
21
|
-
/** Which columns of the contact entity Clivly may read. */
|
|
22
|
-
contactFields?: string[];
|
|
23
|
-
/**
|
|
24
|
-
* Declarative entity config — the single source of truth shared with
|
|
25
|
-
* `crm_entity_mappings`. When set, discovery and heartbeat are derived from
|
|
26
|
-
* it (each entity's `source` table + mapped columns), superseding the flat
|
|
27
|
-
* `contactEntity`.
|
|
28
|
-
*/
|
|
29
|
-
entities?: ClivlyEntitiesConfig;
|
|
30
|
-
/** Reported to the portal so it can show the detected stack. */
|
|
31
|
-
framework?: string;
|
|
32
|
-
/** Heartbeat cadence in ms. Defaults to 60s. */
|
|
33
|
-
heartbeatIntervalMs?: number;
|
|
34
|
-
/** Whether the crm_* namespace exists in the host DB (self-reported). */
|
|
35
|
-
namespaceReady?: boolean;
|
|
36
|
-
orm?: string;
|
|
37
|
-
/**
|
|
38
|
-
* Network retry policy for outbound calls (heartbeat + sync push). Transient
|
|
39
|
-
* failures (network errors, HTTP 429, HTTP 5xx) are retried with exponential
|
|
40
|
-
* backoff + jitter; a `Retry-After` header on a 429 is honoured. Non-transient
|
|
41
|
-
* responses (e.g. 4xx other than 429) are returned immediately, not retried.
|
|
42
|
-
*/
|
|
43
|
-
retry?: {
|
|
44
|
-
/** Total attempts including the first. Defaults to 3. Min 1. */maxAttempts?: number; /** Base backoff in ms (doubled each attempt). Defaults to 500. */
|
|
45
|
-
baseDelayMs?: number;
|
|
46
|
-
};
|
|
47
|
-
/** Tables/columns to report for entity discovery + mapping. */
|
|
48
|
-
schema?: DiscoveredTable[];
|
|
49
|
-
sdkVersion?: string;
|
|
50
|
-
/** Host entities to mirror into Clivly Cloud. */
|
|
51
|
-
source?: {
|
|
52
|
-
contacts: SyncSource;
|
|
53
|
-
companies?: SyncSource;
|
|
54
|
-
};
|
|
55
|
-
sync?: {
|
|
56
|
-
onBoot?: boolean;
|
|
57
|
-
/**
|
|
58
|
-
* Sync cadence in ms for the background keep-alive loop. Defaults to
|
|
59
|
-
* `heartbeatIntervalMs` (60s) so sync and heartbeat share a tick unless you
|
|
60
|
-
* decouple them — e.g. a slow full-mirror alongside frequent heartbeats.
|
|
61
|
-
*/
|
|
62
|
-
intervalMs?: number;
|
|
63
|
-
batchSize?: number;
|
|
64
|
-
};
|
|
65
|
-
/** Cloudflare Tunnel token — reserved for write-back/tunnel mode. */
|
|
66
|
-
tunnelToken?: string;
|
|
67
|
-
/**
|
|
68
|
-
* Shared secret Clivly Cloud presents (as `Authorization: Bearer <token>`) when
|
|
69
|
-
* calling the `/clivly/auth/verify` endpoint. Required for the verify handler to
|
|
70
|
-
* authenticate the caller; without it the handler fails closed (HTTP 500).
|
|
71
|
-
*/
|
|
72
|
-
verifyToken?: string;
|
|
73
|
-
writeBack?: {
|
|
74
|
-
enabled?: boolean; /** Only crm_* tables are ever accepted; this narrows further. */
|
|
75
|
-
tables?: string[];
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
interface ClivlyUser {
|
|
79
|
-
email: string;
|
|
80
|
-
id: string;
|
|
81
|
-
name?: string;
|
|
82
|
-
}
|
|
83
|
-
interface SyncCursor {
|
|
84
|
-
/** The row's id, breaking ties when timestamps collide. */
|
|
85
|
-
id: string;
|
|
86
|
-
/** The row's cursor-field value (typically `updated_at`). */
|
|
87
|
-
time: Date;
|
|
88
|
-
}
|
|
89
|
-
interface SyncSource {
|
|
90
|
-
cursorField: string;
|
|
91
|
-
entity: string;
|
|
92
|
-
fetchPage(args: {
|
|
93
|
-
since: SyncCursor | null;
|
|
94
|
-
limit: number;
|
|
95
|
-
}): Promise<Record<string, unknown>[]>;
|
|
96
|
-
/** Stable, unique tie-breaker column for keyset paging. Defaults to "id". */
|
|
97
|
-
idField?: string;
|
|
98
|
-
}
|
|
99
|
-
type SyncMode = "delta" | "full";
|
|
100
|
-
interface ClivlySDK {
|
|
101
|
-
/**
|
|
102
|
-
* Build the `/clivly/auth/verify` handler the developer mounts in their app.
|
|
103
|
-
* Clivly's cloud calls it to validate a CRM user's identity against the host
|
|
104
|
-
* app's session. Pass a resolver that reads the host session from the request.
|
|
105
|
-
*
|
|
106
|
-
* The handler authenticates the caller against `config.verifyToken` (presented
|
|
107
|
-
* as `Authorization: Bearer <token>`) before invoking the resolver, so the
|
|
108
|
-
* endpoint can't be used by third parties to probe host sessions. It fails
|
|
109
|
-
* closed with HTTP 500 if `verifyToken` is unset.
|
|
110
|
-
*/
|
|
111
|
-
createAuthVerifyHandler(resolveUser: (req: Request) => Promise<ClivlyUser | null> | ClivlyUser | null): (req: Request) => Promise<Response>;
|
|
112
|
-
/** Send a single heartbeat now. Returns true on success. */
|
|
113
|
-
heartbeat(): Promise<boolean>;
|
|
114
|
-
/** Connect: send the initial heartbeat and start the keep-alive loop. */
|
|
115
|
-
start(): Promise<void>;
|
|
116
|
-
/** Stop the keep-alive loop. */
|
|
117
|
-
stop(): void;
|
|
3
|
+
//#region src/errors.d.ts
|
|
4
|
+
declare class ClivlyError extends Error {
|
|
5
|
+
/** HTTP status, or null when the request never completed (network error). */
|
|
6
|
+
readonly status: number | null;
|
|
7
|
+
readonly reason: ClivlyConnectResult["reason"];
|
|
8
|
+
/** Whether a retry could plausibly succeed (network, 429, 5xx). */
|
|
9
|
+
readonly retryable: boolean;
|
|
10
|
+
constructor(result: ClivlyConnectResult, message?: string);
|
|
118
11
|
/**
|
|
119
|
-
*
|
|
120
|
-
* the
|
|
121
|
-
* reconcile leavers (archive-on-leave). Throws if a push fails, so callers see
|
|
122
|
-
* the failure — the background keep-alive loop swallows and retries instead.
|
|
12
|
+
* Build the error subclass matching a failed result's reason, or `null` when
|
|
13
|
+
* the result is `ok`.
|
|
123
14
|
*/
|
|
124
|
-
|
|
125
|
-
mode?: SyncMode;
|
|
126
|
-
}): Promise<void>;
|
|
15
|
+
static from(result: ClivlyConnectResult): ClivlyError | null;
|
|
127
16
|
}
|
|
17
|
+
/** The API key was rejected (401) or lacks access (403). Not retryable. */
|
|
18
|
+
declare class ClivlyAuthError extends ClivlyError {}
|
|
19
|
+
/** Rate limited (429). Retryable after backoff / `Retry-After`. */
|
|
20
|
+
declare class ClivlyRateLimitError extends ClivlyError {}
|
|
21
|
+
/** The request never reached the server (network/DNS/TLS). Retryable. */
|
|
22
|
+
declare class ClivlyNetworkError extends ClivlyError {}
|
|
128
23
|
//#endregion
|
|
129
24
|
//#region src/index.d.ts
|
|
130
25
|
/**
|
|
@@ -135,4 +30,4 @@ interface ClivlySDK {
|
|
|
135
30
|
*/
|
|
136
31
|
declare function createClivlySDK(config: ClivlySDKConfig): ClivlySDK;
|
|
137
32
|
//#endregion
|
|
138
|
-
export { type ClivlySDK, type ClivlySDKConfig, type ClivlyUser, type DiscoveredTable, createClivlySDK };
|
|
33
|
+
export { ClivlyAuthError, type ClivlyConnectReason, type ClivlyConnectResult, ClivlyError, ClivlyNetworkError, ClivlyRateLimitError, type ClivlySDK, type ClivlySDKConfig, type ClivlyUser, type CursorStore, type DiscoveredTable, type DoctorCheck, type DoctorReport, createClivlySDK };
|