@clivly/sdk 0.7.0 → 0.7.2

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
@@ -1,36 +1,66 @@
1
1
  # @clivly/sdk
2
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. If you configure `syncTrigger`, Clivly can also call back into
6
- your app to start a sync from the dashboard.
3
+ Connect an app to Clivly Cloud. The SDK sends heartbeats, reports schema
4
+ discovery data, syncs source rows to Clivly over outbound HTTPS, and can expose
5
+ a signed trigger route so Clivly can start a sync from the dashboard.
7
6
 
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.
7
+ ## Which package should I install?
11
8
 
12
- ## Install
9
+ For most new integrations, install the consolidated public package:
13
10
 
14
11
  ```bash
15
12
  npm install clivly
16
13
  ```
17
14
 
18
- Clivly's stable packages publish on npm's `latest` tag. Use `@next` only when
19
- you are intentionally validating a prerelease build.
20
-
21
- Add `drizzle-orm` when you use `clivly/drizzle`, and
22
- `vite` when you use the Vite/TanStack Start plugin.
23
-
24
- ## Usage
15
+ and import from:
25
16
 
26
17
  ```ts
27
18
  import { createClivlySDK, defineClivlyConfig } from "clivly/sdk";
28
19
  import { fromDrizzle } from "clivly/drizzle";
20
+ ```
21
+
22
+ `@clivly/sdk` remains a public package if you prefer a direct dependency on the
23
+ SDK package itself:
24
+
25
+ ```bash
26
+ npm install @clivly/sdk
27
+ ```
28
+
29
+ with imports from:
30
+
31
+ ```ts
32
+ import { createClivlySDK, defineClivlyConfig } from "@clivly/sdk";
33
+ import { fromDrizzle } from "@clivly/sdk/drizzle";
34
+ ```
35
+
36
+ Stable releases publish on npm's `latest` tag. Use `@next` only when you are
37
+ intentionally validating a prerelease build.
38
+
39
+ Add `drizzle-orm` when you use the Drizzle helpers, and `vite` when you use the
40
+ Vite/TanStack Start plugin.
41
+
42
+ ## Surface
43
+
44
+ Public exports from this package:
45
+
46
+ - `@clivly/sdk`:
47
+ `createClivlySDK`, `defineClivlyConfig`, `createChatSessionHandler`,
48
+ `ClivlyError`, `ClivlyNetworkError`, `ClivlyRateLimitError`,
49
+ `ClivlyAuthError`
50
+ - `@clivly/sdk/drizzle`:
51
+ Drizzle source helpers such as `fromDrizzle`
52
+ - `@clivly/sdk/vite`:
53
+ the Vite/TanStack Start lifecycle plugin
54
+ - `@clivly/sdk/chat-session`:
55
+ chat-session handler helpers when you want that subpath explicitly
56
+
57
+ ## Basic usage
58
+
59
+ ```ts
60
+ import { createClivlySDK, defineClivlyConfig } from "@clivly/sdk";
61
+ import { fromDrizzle } from "@clivly/sdk/drizzle";
29
62
  import { db, participants, accounts } from "./db";
30
63
 
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
64
  const entities = defineClivlyConfig({
35
65
  entities: {
36
66
  participants: {
@@ -47,62 +77,67 @@ const entities = defineClivlyConfig({
47
77
  });
48
78
 
49
79
  const clivly = createClivlySDK({
50
- apiKey: process.env.CLIVLY_SECRET_KEY!, // sk_live_… from Settings → API keys
80
+ apiKey: process.env.CLIVLY_SECRET_KEY!,
51
81
  entities,
52
82
  source: {
53
- contacts: fromDrizzle(db, participants, { entity: "participants", cursorField: "updatedAt" }),
54
- companies: fromDrizzle(db, accounts, { entity: "accounts", cursorField: "updatedAt" }),
83
+ contacts: fromDrizzle(db, participants, {
84
+ entity: "participants",
85
+ cursorField: "updatedAt",
86
+ }),
87
+ companies: fromDrizzle(db, accounts, {
88
+ entity: "accounts",
89
+ cursorField: "updatedAt",
90
+ }),
55
91
  },
56
92
  sync: { onBoot: true, intervalMs: 30_000, batchSize: 500 },
57
93
  });
58
94
 
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
95
+ const status = await clivly.connect();
96
+ if (!status.ok) {
97
+ throw new Error(`Clivly: ${status.reason}`);
98
+ }
99
+
100
+ await clivly.start();
101
+ ```
102
+
103
+ `fromDrizzle` builds the keyset-paginated `fetchPage` function the SDK needs.
104
+ `cursorField` and `idField` are Drizzle property keys such as `updatedAt`, not
105
+ database column names such as `updated_at`. Non-Drizzle apps can provide their
106
+ own `fetchPage` implementation.
62
107
 
63
- // Optional: let Clivly's dashboard start a sync in this app.
64
- const remote = createClivlySDK({
108
+ ## Remote sync trigger
109
+
110
+ If you configure `syncTrigger`, Clivly can ask your app to run a sync from the
111
+ dashboard:
112
+
113
+ ```ts
114
+ const clivly = createClivlySDK({
65
115
  apiKey: process.env.CLIVLY_SECRET_KEY!,
66
116
  entities,
67
- source: { /* … */ },
117
+ source: { /* ... */ },
68
118
  syncTrigger: {
69
119
  path: "/api/clivly/tick",
70
120
  url: process.env.CLIVLY_SYNC_TRIGGER_URL,
71
- secret: process.env.CLIVLY_SYNC_TRIGGER_SECRET, // whsec_… from Integrations → Remote sync
121
+ secret: process.env.CLIVLY_SYNC_TRIGGER_SECRET,
72
122
  },
73
123
  });
74
124
 
75
125
  export async function POST(request: Request) {
76
- return remote.createClivlyHandler()(request);
126
+ return clivly.createClivlyHandler()(request);
77
127
  }
78
128
  ```
79
129
 
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.
87
-
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.
92
-
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`.
130
+ With `syncTrigger.url` and `syncTrigger.secret` configured, the SDK reports the
131
+ trigger URL on each heartbeat and rejects unsigned or badly-signed requests.
132
+ Without `syncTrigger`, cloud mode is push-only: Clivly can observe the app but
133
+ cannot start a sync from the dashboard.
99
134
 
100
135
  ## Vite / TanStack Start
101
136
 
102
- For long-lived Vite dev servers, wire the SDK lifecycle through the Vite plugin:
137
+ For long-lived Vite dev servers, wire the SDK lifecycle through the plugin:
103
138
 
104
139
  ```ts
105
- import { clivlyVite } from "clivly/vite";
140
+ import { clivlyVite } from "@clivly/sdk/vite";
106
141
  import clivly from "./clivly.config";
107
142
  import { defineConfig } from "vite";
108
143
 
@@ -111,115 +146,80 @@ export default defineConfig({
111
146
  });
112
147
  ```
113
148
 
114
- 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.
117
-
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.
149
+ The plugin calls `connect()` for a startup status line, then `start()` for the
150
+ heartbeat and sync loop. It is dev-server only; use `runScheduled()` or
151
+ `createClivlyHandler()` for request-scoped runtimes.
121
152
 
122
153
  ## Verify your setup
123
154
 
124
- Put your instance in `clivly.config.ts` (`export default createClivlySDK({...})`), then:
155
+ If your instance lives in `clivly.config.ts`, the CLI can validate the setup:
125
156
 
126
157
  ```bash
127
- npx clivly ping # CLIVLY_API_KEY + API reachability
128
- npx clivly doctor # env → config → schema → columns → sample fetch → heartbeat
158
+ npx clivly ping
159
+ npx clivly doctor
129
160
  ```
130
161
 
131
- In a monorepo, pass the app config explicitly:
132
-
133
- ```bash
134
- npx clivly doctor --config ./apps/web/clivly.config.ts
135
- ```
136
-
137
- Or in code: `await clivly.doctor()` returns `{ ok, checks }` — the same six read-only checks.
162
+ In code, `await clivly.doctor()` returns the same structured read-only checks.
138
163
 
139
164
  ## Serverless / edge
140
165
 
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:
166
+ On request-scoped runtimes such as Cloudflare Workers, Vercel, or Lambda, do
167
+ not call `start()`. Use `runScheduled()` or mount `createClivlyHandler()`
168
+ instead:
145
169
 
146
170
  ```ts
147
- // Cloudflare Workers
148
171
  export default {
149
- scheduled: () => clivly.runScheduled(), // cron: one heartbeat + one sync
150
- fetch: clivly.createClivlyHandler(), // or drive via an uptime ping / external scheduler
172
+ scheduled: () => clivly.runScheduled(),
173
+ fetch: clivly.createClivlyHandler(),
151
174
  };
152
175
  ```
153
176
 
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`.
177
+ `runScheduled()` throws on a failed push so cron jobs surface failures.
178
+ `createClivlyHandler()` returns `200` on success and `500` on a failed run.
161
179
 
162
180
  ### Persisting the delta cursor
163
181
 
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:
182
+ By default the delta cursor lives in memory. For serverless runtimes, pass a
183
+ persistent `cursorStore`:
168
184
 
169
185
  ```ts
170
186
  const clivly = createClivlySDK({
171
187
  apiKey: process.env.CLIVLY_SECRET_KEY!,
172
188
  entities,
173
- source: { /* … */ },
189
+ source: { /* ... */ },
174
190
  cursorStore: {
175
- // `SyncCursor` is { id: string; time: Date } — serialize `time` (e.g. ISO string).
176
191
  get: async (entity) => {
177
192
  const raw = await kv.get(`clivly:cursor:${entity}`);
178
193
  return raw ? { id: raw.id, time: new Date(raw.time) } : null;
179
194
  },
180
195
  set: (entity, cursor) =>
181
- kv.put(`clivly:cursor:${entity}`, cursor && { id: cursor.id, time: cursor.time.toISOString() }),
196
+ kv.put(
197
+ `clivly:cursor:${entity}`,
198
+ cursor ? { id: cursor.id, time: cursor.time.toISOString() } : null
199
+ ),
182
200
  },
183
201
  });
184
202
  ```
185
203
 
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).
189
-
190
- ## Many-to-many company membership
191
-
192
- If a mapping links contacts to companies through a join table
193
- (`via: { through: … }`), that entity's source must return **one row per
194
- membership**, each row carrying the join table's foreign-key column.
195
-
196
- ```ts
197
- // Ada belongs to two organizations: two rows, differing only in org_id.
198
- { id: "u1", full_name: "Ada", email: "ada@acme.test", org_id: "acme" }
199
- { id: "u1", full_name: "Ada", email: "ada@acme.test", org_id: "globex" }
200
- ```
201
-
202
- This mirrors what the embedded model produces with `LEFT JOIN <join table>`, and
203
- it is what lets Clivly keep one CRM row per membership instead of collapsing the
204
- person onto whichever company happened to come first.
205
-
206
- A person with no membership may omit the column entirely or send it as `null` —
207
- both are accepted. But if **no** pushed row carries the column, the push is
208
- rejected with `400 invalid_config`: silently keeping one membership would be
209
- worse than failing, because the row counts look correct either way.
204
+ Delta sync is at-least-once. Without a persisted cursor after a cold start,
205
+ rows may be re-sent; they are not silently skipped.
210
206
 
211
207
  ## Known limitations
212
208
 
213
- - `start()` is for long-lived runtimes only. On serverless/edge, use
214
- `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: ... })`.
209
+ - `start()` is for long-lived runtimes only.
210
+ - `fromDrizzle` is the first-class generated source path today.
211
+ - The legacy flat `contactEntity` / `contactFields` config is still accepted but
212
+ deprecated in favor of `defineClivlyConfig({ entities: ... })`.
219
213
 
220
214
  ## More docs
221
215
 
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)
216
+ - Cloud onboarding guide:
217
+ [Connecting to Clivly Cloud](https://github.com/amani-joseph/clivly.com/blob/master/docs/guides/connecting-to-clivly-cloud.md)
218
+ - CLI reference:
219
+ [packages/cli/README.md](https://github.com/amani-joseph/clivly.com/blob/master/packages/cli/README.md)
220
+ - Release notes:
221
+ [CHANGELOG.md](https://github.com/amani-joseph/clivly.com/blob/master/CHANGELOG.md)
222
+
223
+ ## License
224
+
225
+ MIT
package/dist/index.cjs CHANGED
@@ -2,6 +2,85 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_drizzle_meta = require("./drizzle-meta-CdCs30q3.cjs");
3
3
  const require_chat_session = require("./chat-session.cjs");
4
4
  let _clivly_core_entity_config = require("@clivly/core/entity-config");
5
+ //#region src/entity-source.ts
6
+ const CONCEPT_KEYS = {
7
+ contact: "contacts",
8
+ company: "companies",
9
+ deal: "deals"
10
+ };
11
+ function resolveEntitySource(config, entityKey) {
12
+ const entity = config.entities?.entities?.[entityKey];
13
+ const src = config.source;
14
+ if (!(entity && src)) return null;
15
+ const conceptKey = CONCEPT_KEYS[entity.concept];
16
+ if (conceptKey) {
17
+ const source = src[conceptKey];
18
+ return source ? {
19
+ concept: entity.concept,
20
+ expectedKey: conceptKey,
21
+ source
22
+ } : null;
23
+ }
24
+ if (entity.concept === "custom") {
25
+ const match = (src.custom ?? []).find((c) => c.entity === entity.source || c.entity === entityKey);
26
+ return match ? {
27
+ concept: "custom",
28
+ expectedKey: "custom",
29
+ source: match
30
+ } : null;
31
+ }
32
+ return null;
33
+ }
34
+ /** The `config.source` key an entity's concept requires, present or not. */
35
+ function expectedSourceKey(concept) {
36
+ return CONCEPT_KEYS[concept] ?? "custom";
37
+ }
38
+ //#endregion
39
+ //#region src/concept-coverage.ts
40
+ const CHECK_NAME = "Entity → source coverage";
41
+ function configuredKeys(config) {
42
+ const src = config.source;
43
+ if (!src) return [];
44
+ return Object.entries(src).filter(([, value]) => Array.isArray(value) ? value.length > 0 : Boolean(value)).map(([key]) => key);
45
+ }
46
+ /**
47
+ * Every configured entity must resolve to a source, or it silently syncs
48
+ * nothing. This is the check that catches `source` keyed by entity name rather
49
+ * than by CRM concept — a mistake that otherwise shows up only as a run that
50
+ * succeeds with zero rows.
51
+ */
52
+ function checkConceptCoverage(config) {
53
+ const entities = config.entities?.entities ?? {};
54
+ const keys = Object.keys(entities);
55
+ if (keys.length === 0) return {
56
+ name: CHECK_NAME,
57
+ ok: false,
58
+ skipped: true,
59
+ detail: "No entities configured — nothing to cross-check."
60
+ };
61
+ const unresolved = keys.filter((key) => resolveEntitySource(config, key) === null);
62
+ if (unresolved.length === 0) return {
63
+ name: CHECK_NAME,
64
+ ok: true,
65
+ detail: `${keys.length} entit${keys.length === 1 ? "y" : "ies"} resolve to a configured source.`
66
+ };
67
+ const available = configuredKeys(config);
68
+ const listed = available.length > 0 ? available.join(", ") : "(none)";
69
+ const problems = unresolved.map((key) => {
70
+ const concept = entities[key]?.concept ?? "unknown";
71
+ if (concept === "custom") {
72
+ const custom = config.source?.custom ?? [];
73
+ if (custom.length > 0) return `"${key}" has concept "custom" but no entry in \`source.custom\` matches it — it will never sync. Entities configured in \`source.custom\`: ${custom.map((c) => c.entity).join(", ")}.`;
74
+ }
75
+ return `"${key}" has concept "${concept}" but no \`source.${expectedSourceKey(concept)}\` is configured — it will never sync.`;
76
+ });
77
+ return {
78
+ name: CHECK_NAME,
79
+ ok: false,
80
+ detail: `${problems.join(" ")} Configured source keys: ${listed}. \`source\` is keyed by CRM concept, not by entity key.`
81
+ };
82
+ }
83
+ //#endregion
5
84
  //#region src/custom-slug.ts
6
85
  const NON_ALNUM = /[^a-z0-9]+/g;
7
86
  const EDGE_HYPHENS = /(^-|-$)/g;
@@ -82,6 +161,56 @@ function describeFetchError(entity, error) {
82
161
  return `Couldn't read "${entity}": ${firstLine(root instanceof Error ? root.message : String(root?.message ?? root ?? "unknown error"))}`;
83
162
  }
84
163
  //#endregion
164
+ //#region src/mapping-dry-run.ts
165
+ /**
166
+ * Fetch one real row and push it through the entity's field map.
167
+ *
168
+ * The distinction from "Sample fetch": that proves the query runs, this proves
169
+ * the row that comes back actually carries the columns `fields` names. A
170
+ * mapping that points at a column the row lacks produces empty CRM records
171
+ * rather than an error, so it has to be caught here.
172
+ *
173
+ * An empty table SKIPS rather than fails — a fresh database is not a
174
+ * misconfiguration, and failing on it would train people to ignore the check.
175
+ */
176
+ async function runMappingDryRun(config, entityKey) {
177
+ const name = `Field mapping dry run (${entityKey})`;
178
+ const resolved = resolveEntitySource(config, entityKey);
179
+ if (!resolved) return {
180
+ name,
181
+ ok: false,
182
+ skipped: true,
183
+ detail: `"${entityKey}" resolves to no source — see the coverage check above.`
184
+ };
185
+ const fields = config.entities?.entities?.[entityKey]?.fields ?? {};
186
+ let rows;
187
+ try {
188
+ rows = await resolved.source.fetchPage({
189
+ since: null,
190
+ limit: 1
191
+ });
192
+ } catch (error) {
193
+ return {
194
+ name,
195
+ ok: false,
196
+ detail: describeFetchError(entityKey, error)
197
+ };
198
+ }
199
+ const row = rows[0];
200
+ if (row === void 0) return {
201
+ name,
202
+ ok: false,
203
+ skipped: true,
204
+ detail: `"${resolved.source.entity}" is empty — nothing to map yet.`
205
+ };
206
+ const missing = Object.entries(fields).filter(([, column]) => !(column in row)).map(([field, column]) => `${field} → ${column}`);
207
+ return {
208
+ name,
209
+ ok: missing.length === 0,
210
+ detail: missing.length === 0 ? `Mapped ${Object.keys(fields).length} field(s) from a real "${resolved.source.entity}" row.` : `Row from "${resolved.source.entity}" is missing mapped column(s): ${missing.join(", ")}.`
211
+ };
212
+ }
213
+ //#endregion
85
214
  //#region src/namespace-probe.ts
86
215
  /**
87
216
  * Verify the tables the integration depends on are genuinely reachable:
@@ -172,6 +301,11 @@ async function namespaceCheck(config) {
172
301
  detail: probeResult.reachable ? "All required source tables are reachable." : probeResult.error?.message ?? "namespace unreachable"
173
302
  };
174
303
  }
304
+ async function mappingDryRunChecks(config) {
305
+ const checks = [];
306
+ for (const entityKey of Object.keys(config.entities?.entities ?? {})) checks.push(await runMappingDryRun(config, entityKey));
307
+ return checks;
308
+ }
175
309
  /**
176
310
  * Run the read-only setup checks and return a structured report. Every check
177
311
  * captures its own error into `detail` rather than throwing, so one failure never
@@ -198,6 +332,7 @@ async function runDoctor(args) {
198
332
  ok: sources.length > 0,
199
333
  detail: sources.length > 0 ? `${sources.length} source(s) to mirror.` : "No `source.contacts` — nothing to sync."
200
334
  });
335
+ checks.push(checkConceptCoverage(config));
201
336
  for (const source of sources) {
202
337
  const meta = source[require_drizzle_meta.DRIZZLE_META];
203
338
  if (!meta) {
@@ -236,6 +371,7 @@ async function runDoctor(args) {
236
371
  detail: describeFetchError(source.entity, error)
237
372
  });
238
373
  }
374
+ checks.push(...await mappingDryRunChecks(config));
239
375
  const result = await connect();
240
376
  checks.push({
241
377
  name: "Heartbeat",
@@ -279,6 +415,7 @@ async function syncSource(args) {
279
415
  const idField = source.idField ?? DEFAULT_ID_FIELD;
280
416
  let cursor = since;
281
417
  let pushed = 0;
418
+ const resumedFrom = since === null ? null : since.time.toISOString();
282
419
  for (;;) {
283
420
  const rows = await source.fetchPage({
284
421
  since: cursor,
@@ -290,6 +427,8 @@ async function syncSource(args) {
290
427
  source: source.entity,
291
428
  rows: capped,
292
429
  mode: "delta",
430
+ fetched: rows.length,
431
+ cursor: resumedFrom,
293
432
  ...objectType ? { objectType } : {}
294
433
  })) return {
295
434
  pushed,
@@ -332,6 +471,8 @@ async function fullSyncSource(args) {
332
471
  source: source.entity,
333
472
  rows: all,
334
473
  mode: "full",
474
+ fetched: all.length,
475
+ cursor: null,
335
476
  ...objectType ? { objectType } : {}
336
477
  });
337
478
  return {
@@ -622,31 +763,25 @@ function createFetcher(retry) {
622
763
  }
623
764
  };
624
765
  }
766
+ const CONCEPT_PATHS = {
767
+ contacts: SYNC_CONTACTS_PATH,
768
+ companies: SYNC_COMPANIES_PATH,
769
+ deals: SYNC_DEALS_PATH,
770
+ custom: SYNC_CUSTOM_PATH
771
+ };
625
772
  function resolveSampleSource(config, entityKey) {
626
- const entity = config.entities?.entities?.[entityKey];
627
- const src = config.source;
628
- if (!(entity && src)) return null;
629
- if (entity.concept === "contact" && src.contacts) return {
630
- source: src.contacts,
631
- path: SYNC_CONTACTS_PATH
632
- };
633
- if (entity.concept === "company" && src.companies) return {
634
- source: src.companies,
635
- path: SYNC_COMPANIES_PATH
636
- };
637
- if (entity.concept === "deal" && src.deals) return {
638
- source: src.deals,
639
- path: SYNC_DEALS_PATH
773
+ const resolved = resolveEntitySource(config, entityKey);
774
+ if (!resolved) return null;
775
+ const path = CONCEPT_PATHS[resolved.expectedKey];
776
+ if (!path) return null;
777
+ return resolved.expectedKey === "custom" ? {
778
+ source: resolved.source,
779
+ path,
780
+ objectType: resolved.source.objectType
781
+ } : {
782
+ source: resolved.source,
783
+ path
640
784
  };
641
- if (entity.concept === "custom") {
642
- const custom = (src.custom ?? []).find((c) => c.entity === entity.source || c.entity === entityKey);
643
- return custom ? {
644
- source: custom,
645
- path: SYNC_CUSTOM_PATH,
646
- objectType: custom.objectType
647
- } : null;
648
- }
649
- return null;
650
785
  }
651
786
  const PREVIEW_LIMIT = 5;
652
787
  function resolvePreviewSource(config, sourceTable) {
package/dist/index.mjs CHANGED
@@ -1,6 +1,85 @@
1
1
  import { t as DRIZZLE_META } from "./drizzle-meta-DNqzkREf.mjs";
2
2
  import { createChatSessionHandler } from "./chat-session.mjs";
3
3
  import { defineClivlyConfig } from "@clivly/core/entity-config";
4
+ //#region src/entity-source.ts
5
+ const CONCEPT_KEYS = {
6
+ contact: "contacts",
7
+ company: "companies",
8
+ deal: "deals"
9
+ };
10
+ function resolveEntitySource(config, entityKey) {
11
+ const entity = config.entities?.entities?.[entityKey];
12
+ const src = config.source;
13
+ if (!(entity && src)) return null;
14
+ const conceptKey = CONCEPT_KEYS[entity.concept];
15
+ if (conceptKey) {
16
+ const source = src[conceptKey];
17
+ return source ? {
18
+ concept: entity.concept,
19
+ expectedKey: conceptKey,
20
+ source
21
+ } : null;
22
+ }
23
+ if (entity.concept === "custom") {
24
+ const match = (src.custom ?? []).find((c) => c.entity === entity.source || c.entity === entityKey);
25
+ return match ? {
26
+ concept: "custom",
27
+ expectedKey: "custom",
28
+ source: match
29
+ } : null;
30
+ }
31
+ return null;
32
+ }
33
+ /** The `config.source` key an entity's concept requires, present or not. */
34
+ function expectedSourceKey(concept) {
35
+ return CONCEPT_KEYS[concept] ?? "custom";
36
+ }
37
+ //#endregion
38
+ //#region src/concept-coverage.ts
39
+ const CHECK_NAME = "Entity → source coverage";
40
+ function configuredKeys(config) {
41
+ const src = config.source;
42
+ if (!src) return [];
43
+ return Object.entries(src).filter(([, value]) => Array.isArray(value) ? value.length > 0 : Boolean(value)).map(([key]) => key);
44
+ }
45
+ /**
46
+ * Every configured entity must resolve to a source, or it silently syncs
47
+ * nothing. This is the check that catches `source` keyed by entity name rather
48
+ * than by CRM concept — a mistake that otherwise shows up only as a run that
49
+ * succeeds with zero rows.
50
+ */
51
+ function checkConceptCoverage(config) {
52
+ const entities = config.entities?.entities ?? {};
53
+ const keys = Object.keys(entities);
54
+ if (keys.length === 0) return {
55
+ name: CHECK_NAME,
56
+ ok: false,
57
+ skipped: true,
58
+ detail: "No entities configured — nothing to cross-check."
59
+ };
60
+ const unresolved = keys.filter((key) => resolveEntitySource(config, key) === null);
61
+ if (unresolved.length === 0) return {
62
+ name: CHECK_NAME,
63
+ ok: true,
64
+ detail: `${keys.length} entit${keys.length === 1 ? "y" : "ies"} resolve to a configured source.`
65
+ };
66
+ const available = configuredKeys(config);
67
+ const listed = available.length > 0 ? available.join(", ") : "(none)";
68
+ const problems = unresolved.map((key) => {
69
+ const concept = entities[key]?.concept ?? "unknown";
70
+ if (concept === "custom") {
71
+ const custom = config.source?.custom ?? [];
72
+ if (custom.length > 0) return `"${key}" has concept "custom" but no entry in \`source.custom\` matches it — it will never sync. Entities configured in \`source.custom\`: ${custom.map((c) => c.entity).join(", ")}.`;
73
+ }
74
+ return `"${key}" has concept "${concept}" but no \`source.${expectedSourceKey(concept)}\` is configured — it will never sync.`;
75
+ });
76
+ return {
77
+ name: CHECK_NAME,
78
+ ok: false,
79
+ detail: `${problems.join(" ")} Configured source keys: ${listed}. \`source\` is keyed by CRM concept, not by entity key.`
80
+ };
81
+ }
82
+ //#endregion
4
83
  //#region src/custom-slug.ts
5
84
  const NON_ALNUM = /[^a-z0-9]+/g;
6
85
  const EDGE_HYPHENS = /(^-|-$)/g;
@@ -81,6 +160,56 @@ function describeFetchError(entity, error) {
81
160
  return `Couldn't read "${entity}": ${firstLine(root instanceof Error ? root.message : String(root?.message ?? root ?? "unknown error"))}`;
82
161
  }
83
162
  //#endregion
163
+ //#region src/mapping-dry-run.ts
164
+ /**
165
+ * Fetch one real row and push it through the entity's field map.
166
+ *
167
+ * The distinction from "Sample fetch": that proves the query runs, this proves
168
+ * the row that comes back actually carries the columns `fields` names. A
169
+ * mapping that points at a column the row lacks produces empty CRM records
170
+ * rather than an error, so it has to be caught here.
171
+ *
172
+ * An empty table SKIPS rather than fails — a fresh database is not a
173
+ * misconfiguration, and failing on it would train people to ignore the check.
174
+ */
175
+ async function runMappingDryRun(config, entityKey) {
176
+ const name = `Field mapping dry run (${entityKey})`;
177
+ const resolved = resolveEntitySource(config, entityKey);
178
+ if (!resolved) return {
179
+ name,
180
+ ok: false,
181
+ skipped: true,
182
+ detail: `"${entityKey}" resolves to no source — see the coverage check above.`
183
+ };
184
+ const fields = config.entities?.entities?.[entityKey]?.fields ?? {};
185
+ let rows;
186
+ try {
187
+ rows = await resolved.source.fetchPage({
188
+ since: null,
189
+ limit: 1
190
+ });
191
+ } catch (error) {
192
+ return {
193
+ name,
194
+ ok: false,
195
+ detail: describeFetchError(entityKey, error)
196
+ };
197
+ }
198
+ const row = rows[0];
199
+ if (row === void 0) return {
200
+ name,
201
+ ok: false,
202
+ skipped: true,
203
+ detail: `"${resolved.source.entity}" is empty — nothing to map yet.`
204
+ };
205
+ const missing = Object.entries(fields).filter(([, column]) => !(column in row)).map(([field, column]) => `${field} → ${column}`);
206
+ return {
207
+ name,
208
+ ok: missing.length === 0,
209
+ detail: missing.length === 0 ? `Mapped ${Object.keys(fields).length} field(s) from a real "${resolved.source.entity}" row.` : `Row from "${resolved.source.entity}" is missing mapped column(s): ${missing.join(", ")}.`
210
+ };
211
+ }
212
+ //#endregion
84
213
  //#region src/namespace-probe.ts
85
214
  /**
86
215
  * Verify the tables the integration depends on are genuinely reachable:
@@ -171,6 +300,11 @@ async function namespaceCheck(config) {
171
300
  detail: probeResult.reachable ? "All required source tables are reachable." : probeResult.error?.message ?? "namespace unreachable"
172
301
  };
173
302
  }
303
+ async function mappingDryRunChecks(config) {
304
+ const checks = [];
305
+ for (const entityKey of Object.keys(config.entities?.entities ?? {})) checks.push(await runMappingDryRun(config, entityKey));
306
+ return checks;
307
+ }
174
308
  /**
175
309
  * Run the read-only setup checks and return a structured report. Every check
176
310
  * captures its own error into `detail` rather than throwing, so one failure never
@@ -197,6 +331,7 @@ async function runDoctor(args) {
197
331
  ok: sources.length > 0,
198
332
  detail: sources.length > 0 ? `${sources.length} source(s) to mirror.` : "No `source.contacts` — nothing to sync."
199
333
  });
334
+ checks.push(checkConceptCoverage(config));
200
335
  for (const source of sources) {
201
336
  const meta = source[DRIZZLE_META];
202
337
  if (!meta) {
@@ -235,6 +370,7 @@ async function runDoctor(args) {
235
370
  detail: describeFetchError(source.entity, error)
236
371
  });
237
372
  }
373
+ checks.push(...await mappingDryRunChecks(config));
238
374
  const result = await connect();
239
375
  checks.push({
240
376
  name: "Heartbeat",
@@ -278,6 +414,7 @@ async function syncSource(args) {
278
414
  const idField = source.idField ?? DEFAULT_ID_FIELD;
279
415
  let cursor = since;
280
416
  let pushed = 0;
417
+ const resumedFrom = since === null ? null : since.time.toISOString();
281
418
  for (;;) {
282
419
  const rows = await source.fetchPage({
283
420
  since: cursor,
@@ -289,6 +426,8 @@ async function syncSource(args) {
289
426
  source: source.entity,
290
427
  rows: capped,
291
428
  mode: "delta",
429
+ fetched: rows.length,
430
+ cursor: resumedFrom,
292
431
  ...objectType ? { objectType } : {}
293
432
  })) return {
294
433
  pushed,
@@ -331,6 +470,8 @@ async function fullSyncSource(args) {
331
470
  source: source.entity,
332
471
  rows: all,
333
472
  mode: "full",
473
+ fetched: all.length,
474
+ cursor: null,
334
475
  ...objectType ? { objectType } : {}
335
476
  });
336
477
  return {
@@ -621,31 +762,25 @@ function createFetcher(retry) {
621
762
  }
622
763
  };
623
764
  }
765
+ const CONCEPT_PATHS = {
766
+ contacts: SYNC_CONTACTS_PATH,
767
+ companies: SYNC_COMPANIES_PATH,
768
+ deals: SYNC_DEALS_PATH,
769
+ custom: SYNC_CUSTOM_PATH
770
+ };
624
771
  function resolveSampleSource(config, entityKey) {
625
- const entity = config.entities?.entities?.[entityKey];
626
- const src = config.source;
627
- if (!(entity && src)) return null;
628
- if (entity.concept === "contact" && src.contacts) return {
629
- source: src.contacts,
630
- path: SYNC_CONTACTS_PATH
631
- };
632
- if (entity.concept === "company" && src.companies) return {
633
- source: src.companies,
634
- path: SYNC_COMPANIES_PATH
635
- };
636
- if (entity.concept === "deal" && src.deals) return {
637
- source: src.deals,
638
- path: SYNC_DEALS_PATH
772
+ const resolved = resolveEntitySource(config, entityKey);
773
+ if (!resolved) return null;
774
+ const path = CONCEPT_PATHS[resolved.expectedKey];
775
+ if (!path) return null;
776
+ return resolved.expectedKey === "custom" ? {
777
+ source: resolved.source,
778
+ path,
779
+ objectType: resolved.source.objectType
780
+ } : {
781
+ source: resolved.source,
782
+ path
639
783
  };
640
- if (entity.concept === "custom") {
641
- const custom = (src.custom ?? []).find((c) => c.entity === entity.source || c.entity === entityKey);
642
- return custom ? {
643
- source: custom,
644
- path: SYNC_CUSTOM_PATH,
645
- objectType: custom.objectType
646
- } : null;
647
- }
648
- return null;
649
784
  }
650
785
  const PREVIEW_LIMIT = 5;
651
786
  function resolvePreviewSource(config, sourceTable) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clivly/sdk",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Clivly SDK — connect your app to Clivly Cloud (heartbeat, discovery, auth bridge).",
@@ -55,10 +55,10 @@
55
55
  "access": "public"
56
56
  },
57
57
  "dependencies": {
58
- "@clivly/core": "0.7.0"
58
+ "@clivly/core": "0.7.2"
59
59
  },
60
60
  "peerDependencies": {
61
- "drizzle-orm": ">=0.30",
61
+ "drizzle-orm": ">=0.30 || >=1.0.0-rc.0",
62
62
  "vite": ">=5"
63
63
  },
64
64
  "peerDependenciesMeta": {