@clivly/sdk 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Clivly
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # @clivly/sdk
2
+
3
+ Connect your app to Clivly Cloud. The SDK sends a heartbeat, reports your schema
4
+ for entity mapping, and mirrors your contacts and companies into Clivly — over
5
+ outbound HTTPS only (no inbound ports, no tunnel).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @clivly/sdk
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import { createClivlySDK } from "@clivly/sdk";
17
+ import { and, gt } from "drizzle-orm";
18
+ import { db, participants, accounts } from "./db";
19
+
20
+ const clivly = createClivlySDK({
21
+ apiKey: process.env.CLIVLY_API_KEY!, // sk_live_… from Settings → API keys
22
+ contactEntity: "participants",
23
+ contactFields: ["id", "full_name", "email", "account_id", "updated_at"],
24
+ source: {
25
+ contacts: {
26
+ entity: "participants",
27
+ cursorField: "updated_at",
28
+ fetchPage: ({ since, limit }) =>
29
+ db.select().from(participants)
30
+ .where(since ? gt(participants.updated_at, since) : undefined)
31
+ .orderBy(participants.updated_at)
32
+ .limit(limit),
33
+ },
34
+ companies: {
35
+ entity: "accounts",
36
+ cursorField: "updated_at",
37
+ fetchPage: ({ since, limit }) =>
38
+ db.select().from(accounts)
39
+ .where(since ? gt(accounts.updated_at, since) : undefined)
40
+ .orderBy(accounts.updated_at)
41
+ .limit(limit),
42
+ },
43
+ },
44
+ sync: { onBoot: true, intervalMs: 30_000, batchSize: 500 },
45
+ });
46
+
47
+ await clivly.start(); // heartbeat + backfill + interval delta sync
48
+ await clivly.sync(); // force a delta sync now
49
+ ```
50
+
51
+ `fetchPage` returns rows keyed by your source column names. Clivly applies the
52
+ field map you confirmed during onboarding, so re-mapping never needs a redeploy.
@@ -0,0 +1,138 @@
1
+ import { ClivlyEntitiesConfig } from "@clivly/core";
2
+
3
+ //#region src/types.d.ts
4
+ interface DiscoveredTable {
5
+ columns: string[];
6
+ name: string;
7
+ }
8
+ interface ClivlySDKConfig {
9
+ /** Org-scoped API token (sk_live_…) from Settings → API keys. */
10
+ apiKey: string;
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;
118
+ /**
119
+ * Sync all configured sources now. `delta` (default) pages changed rows since
120
+ * the last cursor; `full` pushes each complete entity so the server can
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.
123
+ */
124
+ sync(options?: {
125
+ mode?: SyncMode;
126
+ }): Promise<void>;
127
+ }
128
+ //#endregion
129
+ //#region src/index.d.ts
130
+ /**
131
+ * Create the Clivly SDK. The implemented surface — heartbeat/discovery reporting
132
+ * and the auth-verify handler — connects a developer's app to a running Clivly
133
+ * backend today. Tunnel management and CRM write-back are scaffolded (see the
134
+ * clearly-marked TODOs) and land with the Cloudflare infrastructure.
135
+ */
136
+ declare function createClivlySDK(config: ClivlySDKConfig): ClivlySDK;
137
+ //#endregion
138
+ export { type ClivlySDK, type ClivlySDKConfig, type ClivlyUser, type DiscoveredTable, createClivlySDK };
package/dist/index.mjs ADDED
@@ -0,0 +1,286 @@
1
+ //#region src/sync.ts
2
+ const DEFAULT_ID_FIELD = "id";
3
+ function isAfter(candidate, current) {
4
+ const ct = candidate.time.getTime();
5
+ const bt = current.time.getTime();
6
+ if (ct !== bt) return ct > bt;
7
+ return candidate.id > current.id;
8
+ }
9
+ function nextCursor(rows, cursorField, idField, current) {
10
+ let max = current;
11
+ for (const row of rows) {
12
+ const raw = row[cursorField];
13
+ if (raw === null || raw === void 0) continue;
14
+ const time = raw instanceof Date ? raw : new Date(String(raw));
15
+ if (Number.isNaN(time.getTime())) continue;
16
+ const idRaw = row[idField];
17
+ const candidate = {
18
+ time,
19
+ id: idRaw === null || idRaw === void 0 ? "" : String(idRaw)
20
+ };
21
+ if (!max || isAfter(candidate, max)) max = candidate;
22
+ }
23
+ return max;
24
+ }
25
+ async function syncSource(args) {
26
+ const { source, path, since, batchSize, push } = args;
27
+ const idField = source.idField ?? DEFAULT_ID_FIELD;
28
+ let cursor = since;
29
+ let pushed = 0;
30
+ for (;;) {
31
+ const rows = await source.fetchPage({
32
+ since: cursor,
33
+ limit: batchSize
34
+ });
35
+ if (rows.length === 0) break;
36
+ if (!await push(path, {
37
+ source: source.entity,
38
+ rows,
39
+ mode: "delta"
40
+ })) return {
41
+ pushed,
42
+ cursor,
43
+ ok: false
44
+ };
45
+ pushed += rows.length;
46
+ cursor = nextCursor(rows, source.cursorField, idField, cursor);
47
+ if (rows.length < batchSize) break;
48
+ }
49
+ return {
50
+ pushed,
51
+ cursor,
52
+ ok: true
53
+ };
54
+ }
55
+ async function fullSyncSource(args) {
56
+ const { source, path, batchSize, push } = args;
57
+ const idField = source.idField ?? DEFAULT_ID_FIELD;
58
+ const all = [];
59
+ let cursor = null;
60
+ for (;;) {
61
+ const rows = await source.fetchPage({
62
+ since: cursor,
63
+ limit: batchSize
64
+ });
65
+ if (rows.length === 0) break;
66
+ all.push(...rows);
67
+ const next = nextCursor(rows, source.cursorField, idField, cursor);
68
+ const stalled = next !== null && cursor !== null && next.time.getTime() === cursor.time.getTime() && next.id === cursor.id;
69
+ cursor = next;
70
+ if (rows.length < batchSize || stalled) break;
71
+ }
72
+ if (all.length === 0) return {
73
+ pushed: 0,
74
+ cursor: null,
75
+ ok: true
76
+ };
77
+ const ok = await push(path, {
78
+ source: source.entity,
79
+ rows: all,
80
+ mode: "full"
81
+ });
82
+ return {
83
+ pushed: ok ? all.length : 0,
84
+ cursor,
85
+ ok
86
+ };
87
+ }
88
+ //#endregion
89
+ //#region src/index.ts
90
+ const DEFAULT_API_URL = "https://api.clivly.com";
91
+ const DEFAULT_HEARTBEAT_MS = 6e4;
92
+ const DEFAULT_BATCH_SIZE = 500;
93
+ const DEFAULT_MAX_ATTEMPTS = 3;
94
+ const DEFAULT_BASE_DELAY_MS = 500;
95
+ const RETRYABLE_STATUS_FLOOR = 500;
96
+ const RATE_LIMITED_STATUS = 429;
97
+ const SYNC_CONTACTS_PATH = "/clivly/sdk/sync/contacts";
98
+ const SYNC_COMPANIES_PATH = "/clivly/sdk/sync/companies";
99
+ const TRAILING_SLASH = /\/$/;
100
+ const BEARER_PREFIX = /^Bearer\s+/i;
101
+ function entitiesToTables(entities, byName) {
102
+ if (!entities) return;
103
+ for (const entity of Object.values(entities.entities)) {
104
+ const columns = byName.get(entity.source) ?? /* @__PURE__ */ new Set();
105
+ for (const sourceColumn of Object.values(entity.fields)) columns.add(sourceColumn);
106
+ byName.set(entity.source, columns);
107
+ }
108
+ }
109
+ function resolveContactEntity(config) {
110
+ if (config.contactEntity) return config.contactEntity;
111
+ for (const entity of Object.values(config.entities?.entities ?? {})) if (entity.concept === "contact") return entity.source;
112
+ }
113
+ function buildSchema(config) {
114
+ const byName = /* @__PURE__ */ new Map();
115
+ for (const table of config.schema ?? []) {
116
+ const columns = byName.get(table.name) ?? /* @__PURE__ */ new Set();
117
+ for (const column of table.columns) columns.add(column);
118
+ byName.set(table.name, columns);
119
+ }
120
+ entitiesToTables(config.entities, byName);
121
+ if (config.contactEntity && !byName.has(config.contactEntity)) byName.set(config.contactEntity, new Set(config.contactFields ?? []));
122
+ return [...byName].map(([name, columns]) => ({
123
+ name,
124
+ columns: [...columns]
125
+ }));
126
+ }
127
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
128
+ function jsonResponse(body, status) {
129
+ return new Response(JSON.stringify(body), {
130
+ status,
131
+ headers: { "content-type": "application/json" }
132
+ });
133
+ }
134
+ function timingSafeEqual(a, b) {
135
+ if (a.length !== b.length) return false;
136
+ let mismatch = 0;
137
+ for (let i = 0; i < a.length; i++) mismatch += a.charCodeAt(i) === b.charCodeAt(i) ? 0 : 1;
138
+ return mismatch === 0;
139
+ }
140
+ function bearerToken(req) {
141
+ const header = req.headers.get("authorization");
142
+ if (!(header && BEARER_PREFIX.test(header))) return null;
143
+ return header.replace(BEARER_PREFIX, "").trim();
144
+ }
145
+ function isRetryableStatus(status) {
146
+ return status === RATE_LIMITED_STATUS || status >= RETRYABLE_STATUS_FLOOR;
147
+ }
148
+ function retryAfterMs(res) {
149
+ const header = res.headers.get("retry-after");
150
+ if (!header) return null;
151
+ const seconds = Number(header);
152
+ if (!Number.isNaN(seconds)) return Math.max(0, seconds * 1e3);
153
+ const date = Date.parse(header);
154
+ return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
155
+ }
156
+ function createFetcher(retry) {
157
+ const maxAttempts = Math.max(1, retry?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
158
+ const baseDelayMs = retry?.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
159
+ return async (url, init) => {
160
+ for (let attempt = 1;; attempt++) {
161
+ let res = null;
162
+ try {
163
+ res = await fetch(url, init);
164
+ } catch {
165
+ res = null;
166
+ }
167
+ if (!(res === null || isRetryableStatus(res.status)) || attempt >= maxAttempts) return res;
168
+ const backoff = baseDelayMs * 2 ** (attempt - 1) + Math.random() * baseDelayMs;
169
+ await sleep(res ? retryAfterMs(res) ?? backoff : backoff);
170
+ }
171
+ };
172
+ }
173
+ /**
174
+ * Create the Clivly SDK. The implemented surface — heartbeat/discovery reporting
175
+ * and the auth-verify handler — connects a developer's app to a running Clivly
176
+ * backend today. Tunnel management and CRM write-back are scaffolded (see the
177
+ * clearly-marked TODOs) and land with the Cloudflare infrastructure.
178
+ */
179
+ function createClivlySDK(config) {
180
+ const apiUrl = (config.apiUrl ?? DEFAULT_API_URL).replace(TRAILING_SLASH, "");
181
+ const heartbeatIntervalMs = config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_MS;
182
+ const syncIntervalMs = config.sync?.intervalMs ?? heartbeatIntervalMs;
183
+ const timers = {
184
+ heartbeat: null,
185
+ sync: null
186
+ };
187
+ const batchSize = config.sync?.batchSize ?? DEFAULT_BATCH_SIZE;
188
+ const cursors = /* @__PURE__ */ new Map();
189
+ const fetcher = createFetcher(config.retry);
190
+ const push = async (path, body) => {
191
+ return (await fetcher(`${apiUrl}${path}`, {
192
+ method: "POST",
193
+ headers: {
194
+ "content-type": "application/json",
195
+ authorization: `Bearer ${config.apiKey}`
196
+ },
197
+ body: JSON.stringify(body)
198
+ }))?.ok ?? false;
199
+ };
200
+ const syncOne = async (source, path, mode) => {
201
+ const result = mode === "full" ? await fullSyncSource({
202
+ source,
203
+ path,
204
+ batchSize,
205
+ push
206
+ }) : await syncSource({
207
+ source,
208
+ path,
209
+ since: cursors.get(source.entity) ?? null,
210
+ batchSize,
211
+ push
212
+ });
213
+ cursors.set(source.entity, result.cursor);
214
+ return result.ok;
215
+ };
216
+ const runSync = async (options) => {
217
+ if (!config.source) return;
218
+ const mode = options?.mode ?? "delta";
219
+ const failed = [];
220
+ if (config.source.companies) {
221
+ if (!await syncOne(config.source.companies, SYNC_COMPANIES_PATH, mode)) failed.push(config.source.companies.entity);
222
+ }
223
+ if (!await syncOne(config.source.contacts, SYNC_CONTACTS_PATH, mode)) failed.push(config.source.contacts.entity);
224
+ if (failed.length > 0) throw new Error(`Clivly sync: push failed for ${failed.join(", ")}`);
225
+ };
226
+ const heartbeat = async () => {
227
+ return (await fetcher(`${apiUrl}/clivly/sdk/heartbeat`, {
228
+ method: "POST",
229
+ headers: {
230
+ "content-type": "application/json",
231
+ authorization: `Bearer ${config.apiKey}`
232
+ },
233
+ body: JSON.stringify({
234
+ framework: config.framework,
235
+ orm: config.orm,
236
+ sdkVersion: config.sdkVersion,
237
+ contactEntity: resolveContactEntity(config),
238
+ namespaceReady: config.namespaceReady ?? false,
239
+ schema: buildSchema(config)
240
+ })
241
+ }))?.ok ?? false;
242
+ };
243
+ const start = async () => {
244
+ await heartbeat();
245
+ if (config.sync?.onBoot !== false) await runSync().catch(() => {});
246
+ if (!timers.heartbeat) {
247
+ timers.heartbeat = setInterval(() => {
248
+ heartbeat().catch(() => {});
249
+ }, heartbeatIntervalMs);
250
+ timers.heartbeat.unref?.();
251
+ }
252
+ if (!timers.sync && config.source) {
253
+ timers.sync = setInterval(() => {
254
+ runSync().catch(() => {});
255
+ }, syncIntervalMs);
256
+ timers.sync.unref?.();
257
+ }
258
+ };
259
+ const stop = () => {
260
+ if (timers.heartbeat) {
261
+ clearInterval(timers.heartbeat);
262
+ timers.heartbeat = null;
263
+ }
264
+ if (timers.sync) {
265
+ clearInterval(timers.sync);
266
+ timers.sync = null;
267
+ }
268
+ };
269
+ const createAuthVerifyHandler = (resolveUser) => async (req) => {
270
+ if (!config.verifyToken) return jsonResponse({ error: "verify token not configured" }, 500);
271
+ const token = bearerToken(req);
272
+ if (!(token && timingSafeEqual(token, config.verifyToken))) return jsonResponse({ error: "unauthorized" }, 401);
273
+ const user = await resolveUser(req);
274
+ if (!user) return jsonResponse({ error: "unauthorized" }, 401);
275
+ return jsonResponse(user, 200);
276
+ };
277
+ return {
278
+ start,
279
+ stop,
280
+ heartbeat,
281
+ sync: runSync,
282
+ createAuthVerifyHandler
283
+ };
284
+ }
285
+ //#endregion
286
+ export { createClivlySDK };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@clivly/sdk",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Clivly SDK — connect your app to Clivly Cloud (heartbeat, discovery, auth bridge).",
7
+ "license": "MIT",
8
+ "sideEffects": false,
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.mts",
12
+ "import": "./dist/index.mjs"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "dependencies": {
22
+ "@clivly/core": "0.1.0"
23
+ },
24
+ "devDependencies": {
25
+ "tsdown": "^0.21.9",
26
+ "typescript": "^6",
27
+ "vitest": "^3.2.0",
28
+ "@clivly.com/config": "0.0.0"
29
+ },
30
+ "scripts": {
31
+ "build": "tsdown",
32
+ "check-types": "tsc --noEmit",
33
+ "test": "vitest run"
34
+ },
35
+ "main": "./dist/index.mjs",
36
+ "types": "./dist/index.d.mts"
37
+ }