@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 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
- For the end-to-end onboarding flow, start with
9
- [`docs/guides/connecting-to-clivly-cloud.md`](../../docs/guides/connecting-to-clivly-cloud.md).
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
- ## Install
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
- Clivly's stable packages publish on npm's `latest` tag. Use `@next` only when
19
- you are intentionally validating a prerelease build.
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
- Add `drizzle-orm` when you use `clivly/drizzle`, and
22
- `vite` when you use the Vite/TanStack Start plugin.
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
- ## Usage
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 { db, participants, accounts } from "./db";
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
- participants: {
54
+ users: {
37
55
  concept: "contact",
38
- source: "participants",
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
- const clivly = createClivlySDK({
50
- apiKey: process.env.CLIVLY_SECRET_KEY!, // sk_live_… from Settings → API keys
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, participants, { entity: "participants", cursorField: "updatedAt" }),
54
- companies: fromDrizzle(db, accounts, { entity: "accounts", cursorField: "updatedAt" }),
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
- const status = await clivly.connect(); // rich result: { ok, status, reason, retryable, org? }
60
- if (!status.ok) throw new Error(`Clivly: ${status.reason}`);
61
- await clivly.start(); // heartbeat + backfill + interval delta sync
75
+ ```ts
76
+ // somewhere that runs at boot
77
+ import clivly from "./clivly.config";
62
78
 
63
- // Optional: let Clivly's dashboard start a sync in this app.
64
- const remote = createClivlySDK({
65
- apiKey: process.env.CLIVLY_SECRET_KEY!,
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
- syncTrigger: {
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
- export async function POST(request: Request) {
76
- return remote.createClivlyHandler()(request);
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
- `fromDrizzle` generates the keyset-paginated `fetchPage` the SDK needs, inferring the
81
- tie-breaker id from the table's primary key — pass `idField` to override.
82
- `cursorField` and `idField` are Drizzle property keys (`updatedAt`), not database
83
- column names (`updated_at`). Rows are keyed by your source column names; Clivly
84
- applies the field map you confirmed during onboarding, so re-mapping never needs
85
- a redeploy. Non-Drizzle hosts can still pass a hand-written `fetchPage` closure
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
- With `syncTrigger.url` + `syncTrigger.secret` configured, the SDK reports the
89
- trigger URL on each heartbeat and rejects unsigned or badly-signed trigger
90
- requests. Without `syncTrigger`, cloud mode is push-only: Clivly can observe
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
- If you rotate a remote-sync secret from Clivly's dashboard after the encryption
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
- ## Vite / TanStack Start
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
- For long-lived Vite dev servers, wire the SDK lifecycle through the Vite plugin:
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 only; use `runScheduled()` or
116
- `createClivlyHandler()` for serverless production runtimes.
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
- > **Legacy:** the flat `contactEntity` / `contactFields` form is still accepted but
119
- > **deprecated** — prefer the `entities` config above, which is the same model the
120
- > mapping table and sync engine share.
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
- ## Verify your setup
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
- Put your instance in `clivly.config.ts` (`export default createClivlySDK({...})`), then:
250
+ ## Verify the integration
125
251
 
126
252
  ```bash
127
- npx clivly ping # CLIVLY_SECRET_KEY + API reachability
128
- npx clivly doctor # env → config → schema → columns → sample fetch → heartbeat
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
- In a monorepo, pass the app config explicitly:
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
- ```bash
134
- npx clivly doctor --config ./apps/web/clivly.config.ts
286
+ ```ts
287
+ import { drizzleIntrospector } from "clivly/drizzle";
288
+
289
+ createClivlySDK({ /* … */, introspector: drizzleIntrospector(db) });
135
290
  ```
136
291
 
137
- Or in code: `await clivly.doctor()` returns `{ ok, checks }` — the same six read-only checks.
292
+ ## Common errors and troubleshooting
138
293
 
139
- ## Serverless / edge
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
- On request-scoped runtimes (Cloudflare Workers, Vercel, Lambda) there is no long-lived
142
- process, so `start()`'s `setInterval` keep-alive loop can't persist — it stops ticking after
143
- the first response. **Don't call `start()` there.** Instead drive one-shot runs from a
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
- ```ts
147
- // Cloudflare Workers
148
- export default {
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
- `runScheduled()` throws if a sync push fails (so a cron run is marked failed);
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 the last one.
165
- By default that cursor lives in memory — fine for a long-lived process, but **lost on every
166
- serverless cold start**, so a fresh isolate would re-page the whole entity. Pass a
167
- `cursorStore` backed by your own KV/DB to persist it:
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` (e.g. ISO string).
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(`clivly:cursor:${entity}`, cursor && { id: cursor.id, time: cursor.time.toISOString() }),
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, rows since the
187
- last push may be re-sent (the server dedupes by id) — it never silently skips rows. Use
188
- `sync({ mode: "full" })` when you need the server to reconcile deletions (archive-on-leave).
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
- ## Many-to-many company membership
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 first-class generated source path today. Non-Drizzle hosts
216
- should provide a hand-written `fetchPage`.
217
- - The flat `contactEntity` / `contactFields` config still works but is deprecated
218
- in favor of `defineClivlyConfig({ entities: ... })`.
219
-
220
- ## More docs
221
-
222
- - Onboarding guide:
223
- [`docs/guides/connecting-to-clivly-cloud.md`](../../docs/guides/connecting-to-clivly-cloud.md)
224
- - CLI docs: [`../cli/README.md`](../cli/README.md)
225
- - Release notes: [`../../CHANGELOG.md`](../../CHANGELOG.md)
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: opts.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: opts.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",
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.3"
58
+ "@clivly/core": "0.7.4"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "drizzle-orm": ">=0.30 || >=1.0.0-rc.0",