@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 CHANGED
@@ -14,39 +14,107 @@ npm install @clivly/sdk
14
14
 
15
15
  ```ts
16
16
  import { createClivlySDK } from "@clivly/sdk";
17
- import { and, gt } from "drizzle-orm";
17
+ import { fromDrizzle } from "@clivly/sdk/drizzle";
18
+ import { defineClivlyConfig } from "@clivly/core";
18
19
  import { db, participants, accounts } from "./db";
19
20
 
21
+ // Declarative entity config — the non-deprecated source of truth. It maps your
22
+ // host tables/columns onto CRM concepts, and `defineClivlyConfig` validates it
23
+ // at load (warning on any non-canonical field key that would mirror as empty).
24
+ const entities = defineClivlyConfig({
25
+ entities: {
26
+ participants: {
27
+ concept: "contact",
28
+ source: "participants",
29
+ fields: { name: "full_name", email: "email" },
30
+ },
31
+ accounts: {
32
+ concept: "company",
33
+ source: "accounts",
34
+ fields: { name: "legal_name", domain: "website" },
35
+ },
36
+ },
37
+ });
38
+
20
39
  const clivly = createClivlySDK({
21
40
  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"],
41
+ entities,
24
42
  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
+ contacts: fromDrizzle(db, participants, { entity: "participants", cursorField: "updated_at" }),
44
+ companies: fromDrizzle(db, accounts, { entity: "accounts", cursorField: "updated_at" }),
43
45
  },
44
46
  sync: { onBoot: true, intervalMs: 30_000, batchSize: 500 },
45
47
  });
46
48
 
49
+ const status = await clivly.connect(); // rich result: { ok, status, reason, retryable, org? }
50
+ if (!status.ok) throw new Error(`Clivly: ${status.reason}`);
47
51
  await clivly.start(); // heartbeat + backfill + interval delta sync
48
- await clivly.sync(); // force a delta sync now
49
52
  ```
50
53
 
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.
54
+ `fromDrizzle` generates the keyset-paginated `fetchPage` the SDK needs, inferring the
55
+ tie-breaker id from the table's primary key pass `idField` to override. Rows are keyed by
56
+ your source column names; Clivly applies the field map you confirmed during onboarding, so
57
+ re-mapping never needs a redeploy. Non-Drizzle hosts can still pass a hand-written
58
+ `fetchPage` closure instead.
59
+
60
+ > **Legacy:** the flat `contactEntity` / `contactFields` form is still accepted but
61
+ > **deprecated** — prefer the `entities` config above, which is the same model the
62
+ > mapping table and sync engine share.
63
+
64
+ ## Verify your setup
65
+
66
+ Put your instance in `clivly.config.ts` (`export default createClivlySDK({...})`), then:
67
+
68
+ ```bash
69
+ npx clivly doctor # env → config → schema → columns → sample fetch → heartbeat
70
+ ```
71
+
72
+ Or in code: `await clivly.doctor()` returns `{ ok, checks }` — the same six read-only checks.
73
+
74
+ ## Serverless / edge
75
+
76
+ On request-scoped runtimes (Cloudflare Workers, Vercel, Lambda) there is no long-lived
77
+ process, so `start()`'s `setInterval` keep-alive loop can't persist — it stops ticking after
78
+ the first response. **Don't call `start()` there.** Instead drive one-shot runs from a
79
+ scheduled trigger with `runScheduled()`, or mount `createClivlyHandler()` at a route:
80
+
81
+ ```ts
82
+ // Cloudflare Workers
83
+ export default {
84
+ scheduled: () => clivly.runScheduled(), // cron: one heartbeat + one sync
85
+ fetch: clivly.createClivlyHandler(), // or drive via an uptime ping / external scheduler
86
+ };
87
+ ```
88
+
89
+ `runScheduled()` throws if a sync push fails (so a cron run is marked failed);
90
+ `createClivlyHandler()` returns `200` on success, `500` otherwise. `start()` emits a warning
91
+ if it detects a serverless runtime. Verify a connection from the terminal any time with
92
+ `npx clivly ping`.
93
+
94
+ ### Persisting the delta cursor
95
+
96
+ Delta sync tracks a per-entity cursor so each run only pages rows changed since the last one.
97
+ By default that cursor lives in memory — fine for a long-lived process, but **lost on every
98
+ serverless cold start**, so a fresh isolate would re-page the whole entity. Pass a
99
+ `cursorStore` backed by your own KV/DB to persist it:
100
+
101
+ ```ts
102
+ const clivly = createClivlySDK({
103
+ apiKey: process.env.CLIVLY_API_KEY!,
104
+ entities,
105
+ source: { /* … */ },
106
+ cursorStore: {
107
+ // `SyncCursor` is { id: string; time: Date } — serialize `time` (e.g. ISO string).
108
+ get: async (entity) => {
109
+ const raw = await kv.get(`clivly:cursor:${entity}`);
110
+ return raw ? { id: raw.id, time: new Date(raw.time) } : null;
111
+ },
112
+ set: (entity, cursor) =>
113
+ kv.put(`clivly:cursor:${entity}`, cursor && { id: cursor.id, time: cursor.time.toISOString() }),
114
+ },
115
+ });
116
+ ```
117
+
118
+ Delta sync is **at-least-once**: after a cold start with no persisted cursor, rows since the
119
+ last push may be re-sent (the server dedupes by id) — it never silently skips rows. Use
120
+ `sync({ mode: "full" })` when you need the server to reconcile deletions (archive-on-leave).
@@ -0,0 +1,9 @@
1
+ //#region src/drizzle-meta.ts
2
+ const DRIZZLE_META = Symbol("clivly.drizzleSourceMeta");
3
+ //#endregion
4
+ Object.defineProperty(exports, "DRIZZLE_META", {
5
+ enumerable: true,
6
+ get: function() {
7
+ return DRIZZLE_META;
8
+ }
9
+ });
@@ -0,0 +1,4 @@
1
+ //#region src/drizzle-meta.ts
2
+ const DRIZZLE_META = Symbol("clivly.drizzleSourceMeta");
3
+ //#endregion
4
+ export { DRIZZLE_META as t };
@@ -0,0 +1,50 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_drizzle_meta = require("./drizzle-meta-C0T6F3vL.cjs");
3
+ let drizzle_orm = require("drizzle-orm");
4
+ //#region src/drizzle.ts
5
+ function inferIdField(table) {
6
+ const columns = (0, drizzle_orm.getTableColumns)(table);
7
+ const primaryKeys = Object.entries(columns).filter(([, column]) => column.primary);
8
+ if (primaryKeys.length === 1) {
9
+ const primaryKeyField = primaryKeys[0]?.[0];
10
+ if (!primaryKeyField) throw new Error(`fromDrizzle: unable to extract primary key field name from table "${(0, drizzle_orm.getTableName)(table)}".`);
11
+ return primaryKeyField;
12
+ }
13
+ const kind = primaryKeys.length === 0 ? "no" : "a composite";
14
+ throw new Error(`fromDrizzle: table "${(0, drizzle_orm.getTableName)(table)}" has ${kind} single-column primary key — pass { idField } explicitly.`);
15
+ }
16
+ /**
17
+ * Build a `SyncSource` from a Drizzle table, generating the keyset-paginated
18
+ * `fetchPage` the SDK needs. `cursorField`/`idField` are drizzle property keys;
19
+ * `idField` defaults to the table's single-column primary key.
20
+ *
21
+ * `drizzle-orm` is an optional peer dependency — installed only when you import
22
+ * `@clivly/sdk/drizzle`.
23
+ */
24
+ function fromDrizzle(db, table, opts) {
25
+ const drizzleDb = db;
26
+ const idField = opts.idField ?? inferIdField(table);
27
+ const columns = (0, drizzle_orm.getTableColumns)(table);
28
+ const cursorColumn = columns[opts.cursorField];
29
+ const idColumn = columns[idField];
30
+ if (!cursorColumn) throw new Error(`fromDrizzle: cursorField "${opts.cursorField}" is not a column on "${(0, drizzle_orm.getTableName)(table)}".`);
31
+ if (!idColumn) throw new Error(`fromDrizzle: idField "${idField}" is not a column on "${(0, drizzle_orm.getTableName)(table)}".`);
32
+ const source = {
33
+ entity: opts.entity,
34
+ cursorField: opts.cursorField,
35
+ idField,
36
+ fetchPage: ({ since, limit }) => drizzleDb.select().from(table).where(since ? drizzle_orm.sql`(${cursorColumn}, ${idColumn}) > (${since.time}, ${since.id})` : void 0).orderBy((0, drizzle_orm.asc)(cursorColumn), (0, drizzle_orm.asc)(idColumn)).limit(limit)
37
+ };
38
+ const meta = {
39
+ columnKeys: Object.keys(columns),
40
+ cursorField: opts.cursorField,
41
+ idField
42
+ };
43
+ Object.defineProperty(source, require_drizzle_meta.DRIZZLE_META, {
44
+ value: meta,
45
+ enumerable: false
46
+ });
47
+ return source;
48
+ }
49
+ //#endregion
50
+ exports.fromDrizzle = fromDrizzle;
@@ -0,0 +1,22 @@
1
+ import { u as SyncSource } from "./types-B0aSxJ6V.cjs";
2
+ import { Table } from "drizzle-orm";
3
+
4
+ //#region src/drizzle.d.ts
5
+ interface DrizzleDb {
6
+ select: (...args: any[]) => any;
7
+ }
8
+ /**
9
+ * Build a `SyncSource` from a Drizzle table, generating the keyset-paginated
10
+ * `fetchPage` the SDK needs. `cursorField`/`idField` are drizzle property keys;
11
+ * `idField` defaults to the table's single-column primary key.
12
+ *
13
+ * `drizzle-orm` is an optional peer dependency — installed only when you import
14
+ * `@clivly/sdk/drizzle`.
15
+ */
16
+ declare function fromDrizzle(db: DrizzleDb, table: Table, opts: {
17
+ entity: string;
18
+ cursorField: string;
19
+ idField?: string;
20
+ }): SyncSource;
21
+ //#endregion
22
+ export { fromDrizzle };
@@ -0,0 +1,22 @@
1
+ import { u as SyncSource } from "./types-DWt-ytBQ.mjs";
2
+ import { Table } from "drizzle-orm";
3
+
4
+ //#region src/drizzle.d.ts
5
+ interface DrizzleDb {
6
+ select: (...args: any[]) => any;
7
+ }
8
+ /**
9
+ * Build a `SyncSource` from a Drizzle table, generating the keyset-paginated
10
+ * `fetchPage` the SDK needs. `cursorField`/`idField` are drizzle property keys;
11
+ * `idField` defaults to the table's single-column primary key.
12
+ *
13
+ * `drizzle-orm` is an optional peer dependency — installed only when you import
14
+ * `@clivly/sdk/drizzle`.
15
+ */
16
+ declare function fromDrizzle(db: DrizzleDb, table: Table, opts: {
17
+ entity: string;
18
+ cursorField: string;
19
+ idField?: string;
20
+ }): SyncSource;
21
+ //#endregion
22
+ export { fromDrizzle };
@@ -0,0 +1,49 @@
1
+ import { t as DRIZZLE_META } from "./drizzle-meta-CAJ43cLc.mjs";
2
+ import { asc, getTableColumns, getTableName, sql } from "drizzle-orm";
3
+ //#region src/drizzle.ts
4
+ function inferIdField(table) {
5
+ const columns = getTableColumns(table);
6
+ const primaryKeys = Object.entries(columns).filter(([, column]) => column.primary);
7
+ if (primaryKeys.length === 1) {
8
+ const primaryKeyField = primaryKeys[0]?.[0];
9
+ if (!primaryKeyField) throw new Error(`fromDrizzle: unable to extract primary key field name from table "${getTableName(table)}".`);
10
+ return primaryKeyField;
11
+ }
12
+ const kind = primaryKeys.length === 0 ? "no" : "a composite";
13
+ throw new Error(`fromDrizzle: table "${getTableName(table)}" has ${kind} single-column primary key — pass { idField } explicitly.`);
14
+ }
15
+ /**
16
+ * Build a `SyncSource` from a Drizzle table, generating the keyset-paginated
17
+ * `fetchPage` the SDK needs. `cursorField`/`idField` are drizzle property keys;
18
+ * `idField` defaults to the table's single-column primary key.
19
+ *
20
+ * `drizzle-orm` is an optional peer dependency — installed only when you import
21
+ * `@clivly/sdk/drizzle`.
22
+ */
23
+ function fromDrizzle(db, table, opts) {
24
+ const drizzleDb = db;
25
+ const idField = opts.idField ?? inferIdField(table);
26
+ const columns = getTableColumns(table);
27
+ const cursorColumn = columns[opts.cursorField];
28
+ const idColumn = columns[idField];
29
+ if (!cursorColumn) throw new Error(`fromDrizzle: cursorField "${opts.cursorField}" is not a column on "${getTableName(table)}".`);
30
+ if (!idColumn) throw new Error(`fromDrizzle: idField "${idField}" is not a column on "${getTableName(table)}".`);
31
+ const source = {
32
+ entity: opts.entity,
33
+ cursorField: opts.cursorField,
34
+ idField,
35
+ fetchPage: ({ since, limit }) => drizzleDb.select().from(table).where(since ? sql`(${cursorColumn}, ${idColumn}) > (${since.time}, ${since.id})` : void 0).orderBy(asc(cursorColumn), asc(idColumn)).limit(limit)
36
+ };
37
+ const meta = {
38
+ columnKeys: Object.keys(columns),
39
+ cursorField: opts.cursorField,
40
+ idField
41
+ };
42
+ Object.defineProperty(source, DRIZZLE_META, {
43
+ value: meta,
44
+ enumerable: false
45
+ });
46
+ return source;
47
+ }
48
+ //#endregion
49
+ export { fromDrizzle };