@clivly/sdk 0.7.3 → 0.7.4
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 +354 -113
- package/dist/drizzle.cjs +7 -3
- package/dist/drizzle.mjs +7 -3
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,106 +5,221 @@ for entity mapping, and mirrors your contacts and companies into Clivly over
|
|
|
5
5
|
outbound HTTPS. If you configure `syncTrigger`, Clivly can also call back into
|
|
6
6
|
your app to start a sync from the dashboard.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
This README stays focused on the SDK surface itself.
|
|
8
|
+
Clivly never connects to your database — the SDK runs inside your app and is the
|
|
9
|
+
only thing that touches your data.
|
|
11
10
|
|
|
12
|
-
|
|
11
|
+
> **The canonical end-to-end integration guide is
|
|
12
|
+
> [Connecting to Clivly Cloud](../../docs/guides/connecting-to-clivly-cloud.md).**
|
|
13
|
+
> This README is the API-level reference for the SDK surface itself.
|
|
14
|
+
|
|
15
|
+
## Prerequisites
|
|
16
|
+
|
|
17
|
+
| Requirement | Version / note |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| Node.js | **≥ 22** |
|
|
20
|
+
| `drizzle-orm` | Optional peer — only for `clivly/drizzle` |
|
|
21
|
+
| `vite` | Optional peer — only for `clivly/vite` |
|
|
22
|
+
| A Clivly org + secret key | Dashboard → **Connect app**, or **Settings → API keys** |
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
13
25
|
|
|
14
26
|
```bash
|
|
15
27
|
npm install clivly
|
|
16
28
|
```
|
|
17
29
|
|
|
18
|
-
|
|
19
|
-
you
|
|
30
|
+
Install `clivly`, not `@clivly/sdk`. The `clivly` package re-exports this SDK at
|
|
31
|
+
`clivly/sdk` and is what `clivly init` installs, so you get the CLI and the
|
|
32
|
+
runtime from one dependency at one version.
|
|
20
33
|
|
|
21
|
-
|
|
22
|
-
|
|
34
|
+
> `@clivly/sdk` is published and works standalone if you want the runtime
|
|
35
|
+
> without the CLI. Every import path below then drops the `clivly/` prefix:
|
|
36
|
+
> `@clivly/sdk`, `@clivly/sdk/drizzle`, `@clivly/sdk/vite`,
|
|
37
|
+
> `@clivly/sdk/chat-session`.
|
|
23
38
|
|
|
24
|
-
|
|
39
|
+
> **Never `import` from the bare `clivly` specifier.** That entry is the CLI
|
|
40
|
+
> binary; it pulls `node:fs` and `node:child_process` in at module scope and
|
|
41
|
+
> throws on Workers and other edge runtimes. Use `clivly/sdk`.
|
|
42
|
+
|
|
43
|
+
## Five-minute quick start
|
|
25
44
|
|
|
26
45
|
```ts
|
|
46
|
+
// clivly.config.ts
|
|
27
47
|
import { createClivlySDK, defineClivlyConfig } from "clivly/sdk";
|
|
28
|
-
import { fromDrizzle } from "clivly/drizzle";
|
|
29
|
-
import
|
|
48
|
+
import { discoverFromDrizzle, fromDrizzle } from "clivly/drizzle";
|
|
49
|
+
import * as schema from "./db/schema";
|
|
50
|
+
import { db } from "./db";
|
|
30
51
|
|
|
31
|
-
// Declarative entity config — the non-deprecated source of truth. It maps your
|
|
32
|
-
// host tables/columns onto CRM concepts, and `defineClivlyConfig` validates it
|
|
33
|
-
// at load (warning on any non-canonical field key that would mirror as empty).
|
|
34
52
|
const entities = defineClivlyConfig({
|
|
35
53
|
entities: {
|
|
36
|
-
|
|
54
|
+
users: {
|
|
37
55
|
concept: "contact",
|
|
38
|
-
source: "
|
|
56
|
+
source: "users",
|
|
39
57
|
fields: { name: "full_name", email: "email" },
|
|
40
58
|
},
|
|
41
|
-
accounts: {
|
|
42
|
-
concept: "company",
|
|
43
|
-
source: "accounts",
|
|
44
|
-
fields: { name: "legal_name", domain: "website" },
|
|
45
|
-
},
|
|
46
59
|
},
|
|
47
60
|
});
|
|
48
61
|
|
|
49
|
-
|
|
50
|
-
apiKey: process.env.CLIVLY_SECRET_KEY!, // sk_live_…
|
|
62
|
+
export default createClivlySDK({
|
|
63
|
+
apiKey: process.env.CLIVLY_SECRET_KEY!, // sk_live_… — never ships to a browser
|
|
51
64
|
entities,
|
|
65
|
+
schema: discoverFromDrizzle(schema),
|
|
52
66
|
source: {
|
|
53
|
-
contacts: fromDrizzle(db,
|
|
54
|
-
|
|
67
|
+
contacts: fromDrizzle(db, schema.users, {
|
|
68
|
+
entity: "users",
|
|
69
|
+
cursorField: "updatedAt",
|
|
70
|
+
}),
|
|
55
71
|
},
|
|
56
|
-
sync: { onBoot: true, intervalMs: 30_000, batchSize: 500 },
|
|
57
72
|
});
|
|
73
|
+
```
|
|
58
74
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
75
|
+
```ts
|
|
76
|
+
// somewhere that runs at boot
|
|
77
|
+
import clivly from "./clivly.config";
|
|
62
78
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
79
|
+
const result = await clivly.connect();
|
|
80
|
+
if (!result.ok) throw new Error(`Clivly: ${result.reason}`);
|
|
81
|
+
|
|
82
|
+
await clivly.start(); // long-lived hosts only — see below
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`createClivlySDK(...)` is a **pure factory**. Constructing it — or importing a
|
|
86
|
+
module that constructs it — does nothing on its own: no heartbeat, no sync, no
|
|
87
|
+
network call. That keeps the SDK tree-shakeable (`sideEffects: false`), but it
|
|
88
|
+
means a bare `import "./clivly.config"` will never connect. You must call an
|
|
89
|
+
entry point.
|
|
90
|
+
|
|
91
|
+
## Step-by-step configuration
|
|
92
|
+
|
|
93
|
+
### Choose the runtime entry point
|
|
94
|
+
|
|
95
|
+
The single most consequential decision in the setup.
|
|
96
|
+
|
|
97
|
+
| Host runtime | Use | Why |
|
|
98
|
+
| --- | --- | --- |
|
|
99
|
+
| Long-lived Node server, Vite dev | `clivlyVite(sdk)` or `await sdk.start()` | The `setInterval` keep-alive loop persists |
|
|
100
|
+
| Cloudflare Workers, Vercel, Lambda | `sdk.createClivlyHandler()` at a route, and/or `sdk.runScheduled()` from cron | `setInterval` does **not** survive between invocations |
|
|
101
|
+
| One-off health check | `await sdk.connect()` | Structured `{ ok, status, reason, retryable, org? }` |
|
|
102
|
+
|
|
103
|
+
`start()` emits a console warning when it detects a serverless runtime, because
|
|
104
|
+
there it stops ticking after the first response.
|
|
105
|
+
|
|
106
|
+
### Entities vs. sources — the distinction that bites
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
const entities = defineClivlyConfig({
|
|
110
|
+
entities: {
|
|
111
|
+
participants: { // ← your entity key, arbitrary
|
|
112
|
+
concept: "contact", // ← CRM concept
|
|
113
|
+
source: "participants", // ← a HOST TABLE NAME
|
|
114
|
+
fields: { name: "full_name", email: "email" },
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
createClivlySDK({
|
|
66
120
|
entities,
|
|
67
|
-
source: {
|
|
68
|
-
|
|
69
|
-
path: "/api/clivly/tick",
|
|
70
|
-
url: process.env.CLIVLY_SYNC_TRIGGER_URL,
|
|
71
|
-
secret: process.env.CLIVLY_SYNC_TRIGGER_SECRET, // whsec_… from Integrations → Remote sync
|
|
121
|
+
source: { // ← keyed by CRM CONCEPT, never by entity key
|
|
122
|
+
contacts: fromDrizzle(db, participants, { … }),
|
|
72
123
|
},
|
|
73
124
|
});
|
|
125
|
+
```
|
|
74
126
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
127
|
+
`config.source` accepts only `contacts` / `companies` / `deals` / `custom`.
|
|
128
|
+
Conflating the two axes means an entity resolves to no source and silently syncs
|
|
129
|
+
nothing, without erroring. `clivly status` cross-checks this for you.
|
|
130
|
+
|
|
131
|
+
### Sources
|
|
132
|
+
|
|
133
|
+
`fromDrizzle` generates the keyset-paginated `fetchPage` the SDK needs,
|
|
134
|
+
inferring the tie-breaker id from the table's primary key — pass `idField` to
|
|
135
|
+
override.
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
fromDrizzle(db, schema.users, {
|
|
139
|
+
entity: "users", // the host table name
|
|
140
|
+
cursorField: "updatedAt", // Drizzle PROPERTY key, not the DB column
|
|
141
|
+
idField: "id", // optional; defaults to the primary key
|
|
142
|
+
})
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`cursorField` and `idField` are Drizzle property keys (`updatedAt`), **not**
|
|
146
|
+
database column names (`updated_at`). `entities[].fields` goes the other way:
|
|
147
|
+
its values are real **column** names (`full_name`), which is what
|
|
148
|
+
`discoverFromDrizzle` reports and what the dashboard's mapping table stores.
|
|
149
|
+
Clivly applies that field map on its side, so re-mapping never needs a
|
|
150
|
+
redeploy.
|
|
151
|
+
|
|
152
|
+
`fromDrizzle` projects every column under its real database name, so rows arrive
|
|
153
|
+
column-keyed and the field map resolves. The `SyncSource` it returns therefore
|
|
154
|
+
reports `cursorField` / `idField` as **column names** too — you still pass
|
|
155
|
+
Drizzle property keys in, and it translates once.
|
|
156
|
+
|
|
157
|
+
> **Upgrading from `clivly@0.7.3` or earlier?** Those versions returned rows
|
|
158
|
+
> keyed by Drizzle **property** names, so on a snake_case schema any field whose
|
|
159
|
+
> property key differed from its column name mirrored as empty — silently
|
|
160
|
+
> (`email` worked by coincidence). `clivly status` reports it as
|
|
161
|
+
> `❌ Field mapping dry run … missing mapped column(s): name → full_name`.
|
|
162
|
+
> If you worked around it by mapping to the Drizzle key
|
|
163
|
+
> (`fields: { name: "fullName" }`), **revert that to the column name** on
|
|
164
|
+
> upgrade — the workaround is what breaks now.
|
|
165
|
+
|
|
166
|
+
Non-Drizzle hosts pass a hand-written `fetchPage` instead. Page on the compound
|
|
167
|
+
`(cursorField, idField)` watermark:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
source: {
|
|
171
|
+
contacts: {
|
|
172
|
+
entity: "users",
|
|
173
|
+
cursorField: "updated_at",
|
|
174
|
+
idField: "id",
|
|
175
|
+
async fetchPage({ since, limit }) {
|
|
176
|
+
// WHERE (updated_at, id) > (:since.time, :since.id)
|
|
177
|
+
// ORDER BY updated_at ASC, id ASC LIMIT :limit
|
|
178
|
+
// (when `since` is null, return the first page ordered the same way)
|
|
179
|
+
return await myDb.query(/* … */);
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Remote sync trigger
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
syncTrigger: {
|
|
189
|
+
path: "/api/clivly/tick",
|
|
190
|
+
url: process.env.CLIVLY_SYNC_TRIGGER_URL,
|
|
191
|
+
secret: process.env.CLIVLY_SYNC_TRIGGER_SECRET, // whsec_… from Integrations → Remote sync
|
|
192
|
+
},
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
// mount it — this is a plain (Request) => Promise<Response>
|
|
197
|
+
export const POST = (request: Request) => clivly.createClivlyHandler()(request);
|
|
78
198
|
```
|
|
79
199
|
|
|
80
|
-
`
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
instead.
|
|
200
|
+
With `url` + `secret` configured, the SDK reports the trigger URL on each
|
|
201
|
+
heartbeat and rejects unsigned or badly-signed requests with `401`. **Without a
|
|
202
|
+
secret the route stays open to anyone who can reach it** — always set one if the
|
|
203
|
+
route is publicly routable. Without `syncTrigger` at all, cloud mode is
|
|
204
|
+
push-only: Clivly can observe the app but cannot start a sync from the
|
|
205
|
+
dashboard.
|
|
87
206
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
the app but cannot start a sync from the dashboard.
|
|
207
|
+
`url` is optional — the SDK discovers it from the hosting platform's environment
|
|
208
|
+
or from the first request the route serves. Set it only to pin an address the
|
|
209
|
+
SDK cannot infer.
|
|
92
210
|
|
|
93
|
-
|
|
94
|
-
rollout, Clivly stores it encrypted at rest. The server-side deployment must
|
|
95
|
-
have `CLIVLY_TRIGGER_SECRET_WRAPPING_KEY` configured, while your app continues
|
|
96
|
-
to use `CLIVLY_SYNC_TRIGGER_SECRET`. Existing plaintext rows remain valid during
|
|
97
|
-
rollout, and older rows can be backfilled with
|
|
98
|
-
`node ./scripts/backfill-sync-trigger-secrets.mjs --write`.
|
|
211
|
+
## Complete working example
|
|
99
212
|
|
|
100
|
-
|
|
213
|
+
See [the guide's worked example](../../docs/guides/connecting-to-clivly-cloud.md#6-complete-working-example)
|
|
214
|
+
for a full Next.js + Drizzle + Vercel setup with `.env`, config, and route file.
|
|
101
215
|
|
|
102
|
-
|
|
216
|
+
### Vite / TanStack Start
|
|
103
217
|
|
|
104
218
|
```ts
|
|
219
|
+
// vite.config.ts
|
|
220
|
+
import { defineConfig } from "vite";
|
|
105
221
|
import { clivlyVite } from "clivly/vite";
|
|
106
222
|
import clivly from "./clivly.config";
|
|
107
|
-
import { defineConfig } from "vite";
|
|
108
223
|
|
|
109
224
|
export default defineConfig({
|
|
110
225
|
plugins: [clivlyVite(clivly)],
|
|
@@ -112,59 +227,99 @@ export default defineConfig({
|
|
|
112
227
|
```
|
|
113
228
|
|
|
114
229
|
The plugin calls `connect()` for a clear startup status line, then `start()` for
|
|
115
|
-
the heartbeat and sync loop. It is dev-server
|
|
116
|
-
`createClivlyHandler()` for serverless
|
|
230
|
+
the heartbeat and sync loop, and `stop()` on shutdown. It is **dev-server
|
|
231
|
+
only**; use `runScheduled()` or `createClivlyHandler()` for serverless
|
|
232
|
+
production runtimes.
|
|
233
|
+
|
|
234
|
+
### Serverless / edge
|
|
117
235
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
236
|
+
```ts
|
|
237
|
+
// Cloudflare Workers
|
|
238
|
+
export default {
|
|
239
|
+
scheduled: () => clivly.runScheduled(), // cron: one heartbeat + one sync
|
|
240
|
+
fetch: clivly.createClivlyHandler(), // or drive via an uptime ping
|
|
241
|
+
};
|
|
242
|
+
```
|
|
121
243
|
|
|
122
|
-
|
|
244
|
+
`runScheduled()` throws if a sync push fails, so a cron run is marked failed.
|
|
245
|
+
`createClivlyHandler()` returns `200` when the triggered run finishes
|
|
246
|
+
successfully and `500` otherwise, with `{ ok, runId, durationMs }` or
|
|
247
|
+
`{ ok: false, runId, error }` in the body — which is how Clivly learns the
|
|
248
|
+
outcome of a run it triggered.
|
|
123
249
|
|
|
124
|
-
|
|
250
|
+
## Verify the integration
|
|
125
251
|
|
|
126
252
|
```bash
|
|
127
|
-
npx clivly
|
|
128
|
-
npx clivly
|
|
253
|
+
npx clivly status # the whole ladder
|
|
254
|
+
npx clivly status --config ./apps/web/clivly.config.ts # in a monorepo
|
|
129
255
|
```
|
|
130
256
|
|
|
131
|
-
|
|
257
|
+
Put your instance in `clivly.config.ts` (`export default createClivlySDK({...})`)
|
|
258
|
+
so the CLI can find it. `ping`, `connect` and `doctor` remain as deprecated
|
|
259
|
+
aliases running parts of the same ladder.
|
|
260
|
+
|
|
261
|
+
In code, `await clivly.doctor()` returns `{ ok, checks }` — read-only, never
|
|
262
|
+
mutating. The checks it currently runs:
|
|
263
|
+
|
|
264
|
+
| Check | Verifies |
|
|
265
|
+
| --- | --- |
|
|
266
|
+
| API key present | `apiKey` is set |
|
|
267
|
+
| Entities configured | At least one entity |
|
|
268
|
+
| Sources configured | `source` is present and reachable |
|
|
269
|
+
| Concept coverage | Every configured concept has a source |
|
|
270
|
+
| Cursor / id columns | Drizzle sources page on a column that changes on edit |
|
|
271
|
+
| Sample fetch | `fetchPage` actually returns rows |
|
|
272
|
+
| Mapping dry-run | Each entity's compiled view runs |
|
|
273
|
+
| Custom object slugs | Custom object types are valid |
|
|
274
|
+
| Heartbeat | Clivly is reachable with this key |
|
|
275
|
+
| Namespace reachable | Every mapped table/column exists (needs `introspector`) |
|
|
276
|
+
|
|
277
|
+
`ok` is the AND of every non-skipped check. Checks can also come back
|
|
278
|
+
`warn: true` — a working setup with a risk worth stating, such as paging on a
|
|
279
|
+
column that never changes when a row is edited. **Warnings do not fail the
|
|
280
|
+
report**, because training people to ignore a red ladder on a working setup
|
|
281
|
+
would be worse.
|
|
282
|
+
|
|
283
|
+
`namespaceReady` in your config is *self-reported* and sent as-is on every
|
|
284
|
+
heartbeat; only `doctor()` with an `introspector` performs a live check:
|
|
132
285
|
|
|
133
|
-
```
|
|
134
|
-
|
|
286
|
+
```ts
|
|
287
|
+
import { drizzleIntrospector } from "clivly/drizzle";
|
|
288
|
+
|
|
289
|
+
createClivlySDK({ /* … */, introspector: drizzleIntrospector(db) });
|
|
135
290
|
```
|
|
136
291
|
|
|
137
|
-
|
|
292
|
+
## Common errors and troubleshooting
|
|
138
293
|
|
|
139
|
-
|
|
294
|
+
| Symptom | Cause | Fix |
|
|
295
|
+
| --- | --- | --- |
|
|
296
|
+
| Nothing connects, no error | You imported the config but never called an entry point | Call `start()` / `runScheduled()`, or add `clivlyVite(sdk)` |
|
|
297
|
+
| `reason: "invalid_api_key"` | `CLIVLY_SECRET_KEY` missing, wrong, or revoked by a regeneration | Re-run `npx clivly login` |
|
|
298
|
+
| Sync works once on serverless, then stops | `start()`'s `setInterval` cannot persist | Use `runScheduled()` or `createClivlyHandler()` |
|
|
299
|
+
| Every cold start re-reads everything | The default cursor store is in-memory | Pass a persistent [`cursorStore`](#persisting-the-delta-cursor) |
|
|
300
|
+
| `401` on the tick route | Trigger secret differs between your app and Clivly | Redeploy with the current `CLIVLY_SYNC_TRIGGER_SECRET` |
|
|
301
|
+
| An entity syncs nothing, silently | `source` keyed by entity key instead of CRM concept | Key it `contacts` / `companies` / `deals` / `custom` |
|
|
302
|
+
| Rows sync on insert but never on edit | `cursorField` does not change on edit | Add an `updated_at` column and use it. `doctor()` warns about this |
|
|
303
|
+
| `Field mapping dry run … missing mapped column(s)` | You are on `clivly@0.7.3` or earlier, where `fromDrizzle` returned Drizzle property keys | Upgrade — see the upgrade note above |
|
|
304
|
+
| Push fails for one entity | See the thrown message | It names the HTTP status and what to check |
|
|
305
|
+
| Config throws at import on Workers | Something in `clivly.config.ts` touched the filesystem | See [the edge contract](../../docs/guides/connecting-to-clivly-cloud.md#the-edge-contract-what-may-go-in-clivlyconfigts) |
|
|
140
306
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
scheduled trigger with `runScheduled()`, or mount `createClivlyHandler()` at a route:
|
|
307
|
+
A failed push throws a message naming the cause, not a bare "push failed" — e.g.
|
|
308
|
+
*"the API key was rejected — check CLIVLY_API_KEY is set to a valid key for this
|
|
309
|
+
org"*, or *"couldn't reach Clivly at https://api.clivly.com"*.
|
|
145
310
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
scheduled: () => clivly.runScheduled(), // cron: one heartbeat + one sync
|
|
150
|
-
fetch: clivly.createClivlyHandler(), // or drive via an uptime ping / external scheduler
|
|
151
|
-
};
|
|
152
|
-
```
|
|
311
|
+
Errors are also available as classes: `ClivlyError`, `ClivlyAuthError`,
|
|
312
|
+
`ClivlyNetworkError`, `ClivlyRateLimitError`. `ClivlyError.from(result)` turns a
|
|
313
|
+
failed `connect()` result into a throwable.
|
|
153
314
|
|
|
154
|
-
|
|
155
|
-
`createClivlyHandler()` returns `200` when the triggered run finishes successfully,
|
|
156
|
-
`500` otherwise. If the run carries a Clivly-issued run id, the SDK also reports
|
|
157
|
-
the final outcome back to Clivly so the dashboard reflects `running` vs
|
|
158
|
-
`succeeded` vs `failed` truthfully. `start()` emits a warning if it detects a
|
|
159
|
-
serverless runtime. Verify a connection from the terminal any time with
|
|
160
|
-
`npx clivly ping`.
|
|
315
|
+
## Advanced configuration
|
|
161
316
|
|
|
162
317
|
### Persisting the delta cursor
|
|
163
318
|
|
|
164
|
-
Delta sync tracks a per-entity cursor so each run only pages rows changed since
|
|
165
|
-
By default that cursor lives in memory — fine for a long-lived
|
|
166
|
-
serverless cold start**, so a fresh isolate would
|
|
167
|
-
|
|
319
|
+
Delta sync tracks a per-entity cursor so each run only pages rows changed since
|
|
320
|
+
the last one. By default that cursor lives in memory — fine for a long-lived
|
|
321
|
+
process, but **lost on every serverless cold start**, so a fresh isolate would
|
|
322
|
+
re-page the whole entity.
|
|
168
323
|
|
|
169
324
|
```ts
|
|
170
325
|
const clivly = createClivlySDK({
|
|
@@ -172,22 +327,32 @@ const clivly = createClivlySDK({
|
|
|
172
327
|
entities,
|
|
173
328
|
source: { /* … */ },
|
|
174
329
|
cursorStore: {
|
|
175
|
-
// `SyncCursor` is { id: string; time: Date } — serialize `time`
|
|
330
|
+
// `SyncCursor` is { id: string; time: Date } — serialize `time` yourself.
|
|
176
331
|
get: async (entity) => {
|
|
177
332
|
const raw = await kv.get(`clivly:cursor:${entity}`);
|
|
178
333
|
return raw ? { id: raw.id, time: new Date(raw.time) } : null;
|
|
179
334
|
},
|
|
180
335
|
set: (entity, cursor) =>
|
|
181
|
-
kv.put(
|
|
336
|
+
kv.put(
|
|
337
|
+
`clivly:cursor:${entity}`,
|
|
338
|
+
cursor && { id: cursor.id, time: cursor.time.toISOString() }
|
|
339
|
+
),
|
|
182
340
|
},
|
|
183
341
|
});
|
|
184
342
|
```
|
|
185
343
|
|
|
186
|
-
Delta sync is **at-least-once**: after a cold start with no persisted cursor,
|
|
187
|
-
last push may be re-sent (the server dedupes by id) — it never
|
|
188
|
-
`sync({ mode: "full" })` when you need the server to
|
|
344
|
+
Delta sync is **at-least-once**: after a cold start with no persisted cursor,
|
|
345
|
+
rows since the last push may be re-sent (the server dedupes by id) — it never
|
|
346
|
+
silently skips rows. Use `sync({ mode: "full" })` when you need the server to
|
|
347
|
+
reconcile deletions (archive-on-leave); a delta cannot tell absence from
|
|
348
|
+
"unchanged".
|
|
349
|
+
|
|
350
|
+
### Sync ordering
|
|
189
351
|
|
|
190
|
-
|
|
352
|
+
`runSync` pushes **companies first**, then contacts, then deals, then custom
|
|
353
|
+
objects, so contact→company links resolve on the server.
|
|
354
|
+
|
|
355
|
+
### Many-to-many company membership
|
|
191
356
|
|
|
192
357
|
If a mapping links contacts to companies through a join table
|
|
193
358
|
(`via: { through: … }`), that entity's source must return **one row per
|
|
@@ -208,18 +373,94 @@ both are accepted. But if **no** pushed row carries the column, the push is
|
|
|
208
373
|
rejected with `400 invalid_config`: silently keeping one membership would be
|
|
209
374
|
worse than failing, because the row counts look correct either way.
|
|
210
375
|
|
|
376
|
+
### Retry policy
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
retry: {
|
|
380
|
+
maxAttempts: 3, // total attempts including the first; minimum 1
|
|
381
|
+
baseDelayMs: 500, // doubled each attempt, plus jitter
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Network errors, `429` and `5xx` are retried; a `Retry-After` header on a `429`
|
|
386
|
+
is honoured. Other `4xx` responses return immediately.
|
|
387
|
+
|
|
388
|
+
## Configuration reference
|
|
389
|
+
|
|
390
|
+
### `createClivlySDK(config)`
|
|
391
|
+
|
|
392
|
+
| Field | Type | Default | Notes |
|
|
393
|
+
| --- | --- | --- | --- |
|
|
394
|
+
| `apiKey` | `string` | — | **Required.** `sk_live_…` |
|
|
395
|
+
| `apiUrl` | `string` | `https://api.clivly.com` | Non-default backend |
|
|
396
|
+
| `entities` | `ClivlyEntitiesConfig` | — | From `defineClivlyConfig({ entities })` |
|
|
397
|
+
| `schema` | `DiscoveredTable[]` | — | Use `discoverFromDrizzle(schema)` |
|
|
398
|
+
| `source` | `{ contacts, companies?, deals?, custom? }` | — | Keyed by CRM concept. `contacts` is required when `source` is present |
|
|
399
|
+
| `cursorStore` | `CursorStore` | in-memory | Persist on serverless |
|
|
400
|
+
| `heartbeatIntervalMs` | `number` | `60000` | |
|
|
401
|
+
| `sync.onBoot` | `boolean` | `true` | Only affects `start()` |
|
|
402
|
+
| `sync.intervalMs` | `number` | `heartbeatIntervalMs` | |
|
|
403
|
+
| `sync.batchSize` | `number` | `500` | Rows per page |
|
|
404
|
+
| `retry.maxAttempts` / `retry.baseDelayMs` | `number` | `3` / `500` | |
|
|
405
|
+
| `syncTrigger.path` / `.url` / `.secret` | `string` | — | See above |
|
|
406
|
+
| `verifyToken` | `string` | — | For `createAuthVerifyHandler`. **Nothing calls that endpoint today** |
|
|
407
|
+
| `introspector` | `NamespaceIntrospector` | — | `drizzleIntrospector(db)`. Used only by `doctor()` |
|
|
408
|
+
| `namespaceReady` | `boolean` | `false` | Self-reported; no live check |
|
|
409
|
+
| `env` | `Record<string, string \| undefined>` | `process.env` | For trigger-URL discovery in non-Node runtimes |
|
|
410
|
+
| `framework` / `orm` / `sdkVersion` | `string` | — | Reported for dashboard display |
|
|
411
|
+
| `contactEntity` / `contactFields` | `string` / `string[]` | — | **Deprecated.** Use `entities` |
|
|
412
|
+
| `experimental` | `object` | — | **Not implemented.** `tunnelToken` and `writeBack` are reserved and have no effect |
|
|
413
|
+
|
|
414
|
+
### `ClivlySDK` methods
|
|
415
|
+
|
|
416
|
+
| Method | Returns | Notes |
|
|
417
|
+
| --- | --- | --- |
|
|
418
|
+
| `connect()` | `Promise<ClivlyConnectResult>` | One heartbeat, structured. Never throws |
|
|
419
|
+
| `heartbeat()` | `Promise<boolean>` | One heartbeat, boolean |
|
|
420
|
+
| `start()` | `Promise<void>` | Heartbeat + boot sync + timers. Long-lived hosts only |
|
|
421
|
+
| `stop()` | `void` | Clears the timers |
|
|
422
|
+
| `sync(options?)` | `Promise<void>` | `{ mode?: "delta" \| "full", runId?: string }`. Throws on push failure |
|
|
423
|
+
| `runScheduled(options?)` | `Promise<void>` | One heartbeat + one sync, no timers. Throws on push failure |
|
|
424
|
+
| `createClivlyHandler()` | `(Request) => Promise<Response>` | Mount at a route |
|
|
425
|
+
| `createAuthVerifyHandler(resolveUser)` | `(Request) => Promise<Response>` | `503` unless `verifyToken` is set |
|
|
426
|
+
| `doctor()` | `Promise<DoctorReport>` | `{ ok, checks }` |
|
|
427
|
+
|
|
428
|
+
### `ClivlyConnectResult.reason`
|
|
429
|
+
|
|
430
|
+
`ok` · `invalid_api_key` (401) · `forbidden` (403) · `rate_limited` (429) ·
|
|
431
|
+
`server_error` (5xx) · `client_error` (other 4xx) · `network_error` (no
|
|
432
|
+
response).
|
|
433
|
+
|
|
434
|
+
### Subpath exports
|
|
435
|
+
|
|
436
|
+
| From `clivly` | From `@clivly/sdk` | Contents |
|
|
437
|
+
| --- | --- | --- |
|
|
438
|
+
| `clivly/sdk` | `@clivly/sdk` | `createClivlySDK`, `defineClivlyConfig`, `createChatSessionHandler`, error classes, all types |
|
|
439
|
+
| `clivly/drizzle` | `@clivly/sdk/drizzle` | `fromDrizzle`, `discoverFromDrizzle`, `drizzleIntrospector` |
|
|
440
|
+
| `clivly/vite` | `@clivly/sdk/vite` | `clivlyVite` |
|
|
441
|
+
| — | `@clivly/sdk/chat-session` | `createChatSessionHandler` (also on the root) |
|
|
442
|
+
|
|
211
443
|
## Known limitations
|
|
212
444
|
|
|
213
445
|
- `start()` is for long-lived runtimes only. On serverless/edge, use
|
|
214
446
|
`runScheduled()` or `createClivlyHandler()`.
|
|
215
|
-
- `fromDrizzle` is the
|
|
216
|
-
|
|
217
|
-
- The flat `contactEntity` / `contactFields` config still works but is
|
|
218
|
-
in
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
447
|
+
- `fromDrizzle` is the only generated source path. Non-Drizzle hosts write
|
|
448
|
+
`fetchPage` by hand.
|
|
449
|
+
- The flat `contactEntity` / `contactFields` config still works but is
|
|
450
|
+
deprecated in favour of `defineClivlyConfig({ entities: … })`.
|
|
451
|
+
- `config.experimental` (tunnel token, CRM write-back) is **reserved and does
|
|
452
|
+
nothing today**.
|
|
453
|
+
- `createAuthVerifyHandler` is inert — no Clivly Cloud consumer calls
|
|
454
|
+
`/clivly/auth/verify` yet.
|
|
455
|
+
|
|
456
|
+
## Related documentation
|
|
457
|
+
|
|
458
|
+
- **[Connecting to Clivly Cloud](../../docs/guides/connecting-to-clivly-cloud.md)** — the canonical end-to-end guide
|
|
459
|
+
- [`clivly` CLI README](../cli/README.md) — every command and flag
|
|
460
|
+
- [`@clivly/core` README](../core/README.md) — entity config, `$raw` filters
|
|
461
|
+
- [`STABILITY.md`](../../STABILITY.md) — import tiers before 1.0
|
|
462
|
+
- [`CHANGELOG.md`](../../CHANGELOG.md) — release notes
|
|
463
|
+
|
|
464
|
+
## License
|
|
465
|
+
|
|
466
|
+
MIT
|
package/dist/drizzle.cjs
CHANGED
|
@@ -65,11 +65,15 @@ function fromDrizzle(db, table, opts) {
|
|
|
65
65
|
const idColumn = columns[idField];
|
|
66
66
|
if (!cursorColumn) throw new Error(`fromDrizzle: cursorField "${opts.cursorField}" is not a column on "${(0, drizzle_orm.getTableName)(table)}".`);
|
|
67
67
|
if (!idColumn) throw new Error(`fromDrizzle: idField "${idField}" is not a column on "${(0, drizzle_orm.getTableName)(table)}".`);
|
|
68
|
+
const projection = {};
|
|
69
|
+
for (const column of Object.values(columns)) projection[column.name] = column;
|
|
70
|
+
const cursorColumnName = cursorColumn.name;
|
|
71
|
+
const idColumnName = idColumn.name;
|
|
68
72
|
const source = {
|
|
69
73
|
entity: opts.entity,
|
|
70
|
-
cursorField:
|
|
71
|
-
idField,
|
|
72
|
-
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)
|
|
74
|
+
cursorField: cursorColumnName,
|
|
75
|
+
idField: idColumnName,
|
|
76
|
+
fetchPage: ({ since, limit }) => drizzleDb.select(projection).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)
|
|
73
77
|
};
|
|
74
78
|
const meta = {
|
|
75
79
|
columnKeys: Object.keys(columns),
|
package/dist/drizzle.mjs
CHANGED
|
@@ -64,11 +64,15 @@ function fromDrizzle(db, table, opts) {
|
|
|
64
64
|
const idColumn = columns[idField];
|
|
65
65
|
if (!cursorColumn) throw new Error(`fromDrizzle: cursorField "${opts.cursorField}" is not a column on "${getTableName(table)}".`);
|
|
66
66
|
if (!idColumn) throw new Error(`fromDrizzle: idField "${idField}" is not a column on "${getTableName(table)}".`);
|
|
67
|
+
const projection = {};
|
|
68
|
+
for (const column of Object.values(columns)) projection[column.name] = column;
|
|
69
|
+
const cursorColumnName = cursorColumn.name;
|
|
70
|
+
const idColumnName = idColumn.name;
|
|
67
71
|
const source = {
|
|
68
72
|
entity: opts.entity,
|
|
69
|
-
cursorField:
|
|
70
|
-
idField,
|
|
71
|
-
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)
|
|
73
|
+
cursorField: cursorColumnName,
|
|
74
|
+
idField: idColumnName,
|
|
75
|
+
fetchPage: ({ since, limit }) => drizzleDb.select(projection).from(table).where(since ? sql`(${cursorColumn}, ${idColumn}) > (${since.time}, ${since.id})` : void 0).orderBy(asc(cursorColumn), asc(idColumn)).limit(limit)
|
|
72
76
|
};
|
|
73
77
|
const meta = {
|
|
74
78
|
columnKeys: Object.keys(columns),
|
package/dist/index.cjs
CHANGED
|
@@ -364,7 +364,7 @@ function cursorColumnChecks(sources) {
|
|
|
364
364
|
});
|
|
365
365
|
continue;
|
|
366
366
|
}
|
|
367
|
-
const idField = source.idField ?? "id";
|
|
367
|
+
const idField = meta.idField ?? source.idField ?? "id";
|
|
368
368
|
const missing = [meta.cursorField, idField].filter((field) => !meta.columnKeys.includes(field));
|
|
369
369
|
checks.push({
|
|
370
370
|
name: `Cursor/id columns exist (${source.entity})`,
|
package/dist/index.mjs
CHANGED
|
@@ -363,7 +363,7 @@ function cursorColumnChecks(sources) {
|
|
|
363
363
|
});
|
|
364
364
|
continue;
|
|
365
365
|
}
|
|
366
|
-
const idField = source.idField ?? "id";
|
|
366
|
+
const idField = meta.idField ?? source.idField ?? "id";
|
|
367
367
|
const missing = [meta.cursorField, idField].filter((field) => !meta.columnKeys.includes(field));
|
|
368
368
|
checks.push({
|
|
369
369
|
name: `Cursor/id columns exist (${source.entity})`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clivly/sdk",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Clivly SDK — connect your app to Clivly Cloud (heartbeat, discovery, auth bridge).",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"access": "public"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@clivly/core": "0.7.
|
|
58
|
+
"@clivly/core": "0.7.4"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"drizzle-orm": ">=0.30 || >=1.0.0-rc.0",
|