@clivly/sdk 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -124,7 +124,7 @@ the heartbeat and sync loop. It is dev-server only; use `runScheduled()` or
124
124
  Put your instance in `clivly.config.ts` (`export default createClivlySDK({...})`), then:
125
125
 
126
126
  ```bash
127
- npx clivly ping # CLIVLY_API_KEY + API reachability
127
+ npx clivly ping # CLIVLY_SECRET_KEY + API reachability
128
128
  npx clivly doctor # env → config → schema → columns → sample fetch → heartbeat
129
129
  ```
130
130
 
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.8.0",
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.8.0"
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": {