clivly 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,34 +1,40 @@
1
1
  # clivly
2
2
 
3
- Configure and integrate [Clivly](https://clivly.com) — the CRM that lives inside
4
- your app.
3
+ Clivly's consolidated public package.
5
4
 
6
- For the full integration path, including when to use `start()` versus
7
- `runScheduled()` / `createClivlyHandler()`, see the
8
- [connecting to Clivly Cloud guide](https://github.com/amani-joseph/clivly.com/blob/master/docs/guides/connecting-to-clivly-cloud.md).
5
+ It serves two roles:
9
6
 
10
- ## Usage
7
+ - the CLI you run as `npx clivly ...`
8
+ - the package that exposes the stable runtime subpaths such as `clivly/sdk`
9
+
10
+ ## Install
11
11
 
12
12
  ```bash
13
13
  npm install clivly
14
+ ```
15
+
16
+ Stable releases publish on npm's `latest` tag. Use `@next` only when you are
17
+ intentionally testing a prerelease build.
14
18
 
15
- npx clivly init # scaffold config, routes and .env, then pair this machine
16
- npx clivly login # pair this machine and write your credentials to .env
19
+ This package requires Node `>=22`.
20
+
21
+ ## CLI commands
22
+
23
+ ```bash
24
+ npx clivly init # scaffold config, route files, and .env placeholders
25
+ npx clivly add widget # scaffold the chat-session route for the widget
26
+ npx clivly login # pair this machine and write credentials to .env
17
27
  npx clivly status # check your setup, step by step
18
28
  npx clivly dev # tunnel localhost so remote sync can reach you
19
29
  npx clivly push-schema # report your schema to Clivly without deploying
20
30
  ```
21
31
 
22
- `ping` and `doctor` remain as aliases that run subsets of the `status` ladder.
32
+ `ping` and `doctor` remain available as focused aliases around parts of the
33
+ `status` ladder.
23
34
 
24
- Clivly's stable packages publish on npm's `latest` tag. Once installed, the
25
- `npx clivly …` commands above use your local copy. Use `@next` only when you
26
- are intentionally testing a prerelease cut.
35
+ ## Runtime imports
27
36
 
28
- ## Imports
29
-
30
- `clivly` is the consolidated public package. The CLI remains available as
31
- `npx clivly`, while application code imports from typed subpaths:
37
+ Application code should import from subpaths, not from the package root:
32
38
 
33
39
  ```ts
34
40
  import { createClivlySDK, defineClivlyConfig } from "clivly/sdk";
@@ -39,71 +45,43 @@ import { createWorkOSAuthAdapter } from "clivly/auth/workos";
39
45
  import { createBetterAuthAdapter } from "clivly/auth/better-auth";
40
46
  ```
41
47
 
42
- Provider and framework integrations use optional peer dependencies.
43
- **Supported auth providers: Clerk, WorkOS, and BetterAuth.** Install only
48
+ Do **not** import application code from `clivly` (the package root). The root
49
+ entry is the CLI.
50
+
51
+ Provider and framework integrations use optional peer dependencies. Install only
44
52
  what your app actually uses, such as `drizzle-orm`, `vite`, `@clerk/backend`,
45
53
  `@workos-inc/node`, or `better-auth`.
46
54
 
47
- `clivly init` detects your framework from `package.json`, locates your Drizzle
48
- schema, and generates a `clivly.config.ts` that maps likely contact and company
49
- tables. The init command currently generates config for Drizzle only; Prisma and
50
- Kysely projects should use a hand-written `fetchPage` source for now.
51
-
52
- Use `--schema` when your Drizzle schema is not in a common location or in
53
- `drizzle.config.ts`. Re-run with `--force` to overwrite an existing config.
54
-
55
- ### Monorepos
55
+ ## `clivly init`
56
56
 
57
- In a workspace, `init` reads your `pnpm-workspace.yaml` (or `workspaces` array)
58
- and imports anything owned by a *sibling* package by **package name** rather than
59
- by a relative path that climbs out of your app:
60
-
61
- ```ts
62
- import * as schema from "@acme/database"; // not "../../packages/database/src/index"
63
- ```
57
+ `clivly init` detects your framework from `package.json`, locates your Drizzle
58
+ schema, and writes:
64
59
 
65
- `--schema` accepts a package specifier as well as a path, so all of these work:
60
+ - `clivly.config.ts`
61
+ - the supported Clivly route files for your framework
62
+ - missing `.env` placeholders
66
63
 
67
- ```bash
68
- clivly init --schema @acme/database # the package entry
69
- clivly init --schema @acme/database/src/schema.ts # a subpath inside it
70
- clivly init --schema ./src/db/schema.ts # a plain path (unchanged)
71
- ```
64
+ It currently targets Drizzle-backed integrations. Prisma and Kysely projects
65
+ can still integrate, but should provide hand-written `fetchPage` sources rather
66
+ than relying on the generated Drizzle path.
72
67
 
73
- A real file always wins over a same-named package, so existing relative paths
74
- keep their meaning.
68
+ Useful flags:
75
69
 
76
70
  | Flag | Purpose |
77
71
  | --- | --- |
78
- | `--schema <path\|package>` | Where your Drizzle tables live. |
79
- | `--db-import <specifier>` | Where the Drizzle instance lives, when it isn't beside the schema. Accepts `@acme/database#database` to name the export. |
80
- | `--auth-import <specifier>` | Where your better-auth instance lives. Same `#exportName` form. Overrides on-disk probing, which cannot see across a package boundary. |
81
- | `--contact-table <name>` | Use this table as the contact entity, skipping the naming heuristics. |
82
- | `--company-table <name>` | Same for companies. |
83
-
84
- `--contact-table` and `--company-table` accept either the SQL table name or the
85
- name it is exported under in your schema module — whichever you have in front of
86
- you.
87
-
88
- ### When no contact entity can be found
72
+ | `--schema <path\|package>` | Where your Drizzle schema lives |
73
+ | `--db-import <specifier>` | Where the Drizzle client lives when it is not beside the schema |
74
+ | `--auth-import <specifier>` | Where your auth instance lives |
75
+ | `--contact-table <name>` | Force the contact entity table |
76
+ | `--company-table <name>` | Force the company entity table |
77
+ | `--force` | Overwrite generated files / `.env` values |
78
+ | `--no-login` | Skip the browser pairing step |
79
+ | `--no-browser` | Print the approval URL instead of opening it |
89
80
 
90
- `init` **writes nothing** rather than scaffolding a config that cannot compile.
91
- Clivly needs one entity before a config will load, and it will not invent one:
92
-
93
- ```
94
- No usable contact entity
81
+ If no usable contact entity can be found, `init` writes nothing rather than
82
+ generating a broken config.
95
83
 
96
- Tables in @acme/database:
97
- widgets
98
- invoices
99
-
100
- Pick the one that holds your people and re-run:
101
- clivly init --schema @acme/database --contact-table widgets
102
-
103
- Nothing was written — Clivly needs one entity before a config can load.
104
- ```
105
-
106
- First-class route scaffolding currently exists for:
84
+ Supported first-class route scaffolding today:
107
85
 
108
86
  - Next.js
109
87
  - TanStack Start
@@ -111,82 +89,50 @@ First-class route scaffolding currently exists for:
111
89
  - Remix
112
90
  - Nuxt
113
91
 
114
- Vue.js apps can still integrate Clivly, but they usually need a manual server
115
- layer for the tick and verify routes unless they are using Nuxt.
116
-
117
- When `init` finishes and `CLIVLY_API_KEY` is still empty, it hands you straight
118
- into `clivly login` so you don't end up staring at a blank `.env`. That step is
119
- interactive, so it is skipped automatically when there is no TTY or when `CI` is
120
- set — and you can always turn it off yourself:
92
+ ## `clivly add widget`
121
93
 
122
94
  ```bash
123
- npx clivly init --no-login # scaffold only; pair later with `clivly login`
124
- npx clivly init --no-browser # pair, but print the URL instead of opening it
95
+ npx clivly add widget
125
96
  ```
126
97
 
98
+ Scaffolds the host-side widget session route that mounts
99
+ `createChatSessionHandler()` from `clivly/sdk`. It is idempotent and supports
100
+ `--dry-run` and `--force`.
101
+
127
102
  ## `clivly login`
128
103
 
129
104
  ```bash
130
105
  npx clivly login [--force] [--rotate] [--api-url <url>] [--no-browser]
131
106
  ```
132
107
 
133
- Pairs this machine with a Clivly workspace and writes `CLIVLY_API_KEY` and
134
- `CLIVLY_SYNC_TRIGGER_SECRET` into your project's `.env`. It creates a pairing
135
- session, prints (and by default opens) an approval link, and waits while you
136
- approve it in a signed-in browser. The approval link is always printed, so an
137
- SSH or headless session can be approved by opening the link elsewhere.
138
-
139
- - `--no-browser` — print the approval URL without opening a browser.
140
- - `--force` — overwrite `.env` values that already have a real value. Without
141
- it, existing values are kept and reported as skipped; an empty placeholder
142
- (`CLIVLY_API_KEY=`) is always filled in.
143
- - `--rotate` — rotate the workspace's remote-sync trigger secret instead of
144
- revealing the current one. Your already-deployed host app will stop receiving
145
- sync triggers until you redeploy it with the new secret.
146
-
147
- Before anything is written, the CLI prints which workspace approved the pairing
148
- and asks you to confirm. Declining writes nothing and exits non-zero. In a
149
- non-interactive run (no TTY, or `CI` set) the workspace is still printed but
150
- you are not prompted, so scripted pairing cannot hang.
151
-
152
- The pairing endpoints are feature-flagged server-side
153
- (`CLIVLY_CLI_PAIRING_ENABLED`); if the backend you point at has not enabled
154
- them, `clivly login` says so and exits non-zero.
155
-
156
- **Clivly Cloud has pairing enabled**, so `clivly login` is the normal path there.
157
- The flag defaults to `false`, so a self-hosted backend must opt in.
158
-
159
- ### Manual credential path
160
-
161
- If pairing is unavailable — a self-hosted backend with the flag off, or a
162
- restricted environment — every credential can be set by hand. Nothing about the
163
- CLI requires pairing.
164
-
165
- | Value | Where to get it | Notes |
166
- | --- | --- | --- |
167
- | `CLIVLY_API_KEY` | Dashboard → **Settings → API keys** → Create | **Shown once.** Copy it immediately; you cannot retrieve it later, only regenerate. Regenerating **revokes the previous key**, so redeploy after rotating. |
168
- | `CLIVLY_SYNC_TRIGGER_SECRET` | Dashboard → **Integrations → Remote sync** | Must match the value in your app's env exactly. A mismatch surfaces as a `401` on the signed trigger. |
169
- | `CLIVLY_SYNC_TRIGGER_URL` | Your own deployment | The public URL of your mounted tick route, e.g. `https://app.example.com/api/clivly/tick`. Optional until you deploy — push-only setups can leave it unset. |
170
- | `CLIVLY_VERIFY_TOKEN` | Dashboard → **Integrations** | Only needed if you mount the auth-verify route. |
171
-
172
- Write them into the `.env` of the package that imports `clivly`, then re-run
173
- `clivly status` to confirm each rung goes green.
174
-
175
- > **Running your own Clivly backend?** `CLIVLY_TRIGGER_SECRET_WRAPPING_KEY` (a
176
- > 32-byte hex key) is a **hard production prerequisite** for pairing. The
177
- > trigger secret is stored encrypted with it, so without the key set on the
178
- > server, `clivly login` fails with a 500 at the moment it would hand over
179
- > credentials. Set it before enabling `CLIVLY_CLI_PAIRING_ENABLED`.
108
+ Pairs this machine with a Clivly workspace and writes:
109
+
110
+ - `CLIVLY_SECRET_KEY`
111
+ - `CLIVLY_SYNC_TRIGGER_SECRET`
112
+
113
+ into your project's `.env`.
114
+
115
+ The approval URL is always printed, so headless or SSH sessions can still be
116
+ approved in another browser.
117
+
118
+ Notes:
119
+
120
+ - `--force` overwrites existing real values
121
+ - `--rotate` rotates the remote-sync trigger secret instead of revealing the
122
+ current one
123
+ - pairing is feature-flagged server-side with `CLIVLY_CLI_PAIRING_ENABLED`
124
+
125
+ If pairing is unavailable, you can still set credentials manually and then run
126
+ `clivly status`.
180
127
 
181
128
  ## `clivly status`
182
129
 
183
130
  ```bash
184
- npx clivly status [--verbose] [--config <path>]
131
+ npx clivly status [--verbose] [--config <path>] [--env-file <path>]
185
132
  ```
186
133
 
187
- Walks your setup one rung at a time — credentials, config, schema, sources,
188
- connectivity — and stops at the first thing that isn't ready, telling you what
189
- to do about it. This is the command to reach for when something isn't working.
134
+ Walks the setup one rung at a time: credentials, config, schema, sources, and
135
+ connectivity. This is the main diagnostic command for integration issues.
190
136
 
191
137
  ## `clivly dev`
192
138
 
@@ -195,52 +141,49 @@ npx clivly dev [--port <n>] [--api-url <url>] [--route-path <path>] [-- <cmd>]
195
141
  ```
196
142
 
197
143
  Opens a `cloudflared` tunnel to your local app and registers it with Clivly for
198
- the length of the session, so remote sync can reach `localhost` while you
199
- develop. Your dev server is detected from `package.json` scripts, or pass it
200
- explicitly after `--`. Needs `cloudflared` on `PATH` and `CLIVLY_API_KEY` set.
201
- The override is cleared when the process exits; remote sync is otherwise
202
- deploy-only.
144
+ the lifetime of the session, so remote sync can reach `localhost` during local
145
+ development. The override is cleared on exit.
203
146
 
204
- ## `clivly ping` / `clivly doctor`
147
+ Requires:
205
148
 
206
- `clivly ping` reads `CLIVLY_API_KEY` from the environment, sends one heartbeat, and prints a clear ✅/❌ with the resolved org or the failure reason — so you can confirm a connection from the terminal without writing any code. Point it at a non-default backend with `--api-url <url>` (or `CLIVLY_API_URL`).
149
+ - `cloudflared` on `PATH`
150
+ - `CLIVLY_SECRET_KEY` set
151
+
152
+ ## `clivly push-schema`
207
153
 
208
154
  ```bash
209
- CLIVLY_API_KEY=sk_live_… npx clivly ping
210
- #
211
- # Clivly ping
212
- # ✅ Connected to Clivly
213
- # Org: Acme (org_1)
155
+ npx clivly push-schema [--config <path>]
214
156
  ```
215
157
 
216
- `clivly doctor` loads your config and runs the same read-only checks as
217
- `await clivly.doctor()`: API key, entity config, sources, Drizzle cursor/id
218
- columns, sample fetch, and heartbeat.
158
+ Reports your discovered schema to Clivly without waiting for a deployed app to
159
+ heartbeat it first.
219
160
 
220
- ```bash
221
- npx clivly doctor
222
- npx clivly doctor --config ./apps/web/clivly.config.ts
223
- ```
161
+ ## `clivly ping` / `clivly doctor`
162
+
163
+ `clivly ping` sends one heartbeat and prints a terse pass/fail result.
164
+
165
+ `clivly doctor` loads your config and runs the same read-only checks exposed by
166
+ `await clivly.doctor()`.
224
167
 
225
168
  ## Telemetry
226
169
 
227
- **Clivly collects no telemetry.** The CLI makes network requests only to the
228
- Clivly API you point it at, and only to do what you asked: pairing, heartbeats,
229
- schema pushes, and status checks. No usage analytics, no crash reporting, no
230
- phone-home. See [SECURITY.md](https://github.com/amani-joseph/clivly.com/blob/master/SECURITY.md#telemetry).
170
+ Clivly collects no telemetry. The CLI makes network requests only to the
171
+ Clivly API you point it at, and only to do the work you asked for: pairing,
172
+ heartbeats, schema pushes, status checks, and tunnel override registration.
173
+
174
+ See the telemetry section in:
175
+ [SECURITY.md](https://github.com/amani-joseph/clivly.com/blob/master/SECURITY.md#telemetry)
231
176
 
232
177
  ## More docs
233
178
 
234
- - Onboarding guide:
235
- [connecting to Clivly Cloud](https://github.com/amani-joseph/clivly.com/blob/master/docs/guides/connecting-to-clivly-cloud.md)
236
- - Adding the chat widget:
237
- [guide](https://github.com/amani-joseph/clivly.com/blob/master/docs/guides/adding-the-chat-widget.md)
238
- - Security policy:
239
- [SECURITY.md](https://github.com/amani-joseph/clivly.com/blob/master/SECURITY.md)
179
+ - Cloud onboarding guide:
180
+ [Connecting to Clivly Cloud](https://github.com/amani-joseph/clivly.com/blob/master/docs/guides/connecting-to-clivly-cloud.md)
181
+ - Chat widget guide:
182
+ [Adding the chat widget](https://github.com/amani-joseph/clivly.com/blob/master/docs/guides/adding-the-chat-widget.md)
240
183
  - SDK docs:
241
- [`@clivly/sdk` README](https://github.com/amani-joseph/clivly.com/blob/master/packages/sdk/README.md)
184
+ [packages/sdk/README.md](https://github.com/amani-joseph/clivly.com/blob/master/packages/sdk/README.md)
242
185
  - Release notes:
243
- [CHANGELOG](https://github.com/amani-joseph/clivly.com/blob/master/CHANGELOG.md)
186
+ [CHANGELOG.md](https://github.com/amani-joseph/clivly.com/blob/master/CHANGELOG.md)
244
187
 
245
188
  ## License
246
189
 
@@ -1,8 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_deprecate_subpath = require("./deprecate-subpath-B9ngvizt.cjs");
3
2
  const require_entity_config = require("./entity-config-D87rV-tE.cjs");
4
3
  //#region src/core-entity-config.ts
5
- require_deprecate_subpath.warnDeprecatedSubpath("clivly/core/entity-config", "clivly/internal/entity-config");
4
+ require("./deprecate-subpath-B9ngvizt.cjs").warnDeprecatedSubpath("clivly/core/entity-config", "clivly/internal/entity-config");
6
5
  //#endregion
7
6
  exports.CANONICAL_FIELDS = require_entity_config.CANONICAL_FIELDS;
8
7
  exports.CRM_CONCEPTS = require_entity_config.CRM_CONCEPTS;
@@ -1,5 +1,5 @@
1
- import { t as warnDeprecatedSubpath } from "./deprecate-subpath-CmvZtCpE.mjs";
2
1
  import { a as MATERIALIZING_CONCEPTS, c as filterSchema, d as validateEntitiesConfig, i as ClivlyConfigError, l as relationshipSchema, n as CRM_CONCEPTS, o as defineClivlyConfig, r as CUSTOM_CONCEPT, s as entitiesConfigSchema, t as CANONICAL_FIELDS, u as relationshipsSchema } from "./entity-config-va2bkH5o.mjs";
2
+ import { t as warnDeprecatedSubpath } from "./deprecate-subpath-CmvZtCpE.mjs";
3
3
  //#region src/core-entity-config.ts
4
4
  warnDeprecatedSubpath("clivly/core/entity-config", "clivly/internal/entity-config");
5
5
  //#endregion
@@ -1,8 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_deprecate_subpath = require("./deprecate-subpath-B9ngvizt.cjs");
3
2
  const require_internal_entity_heuristics = require("./internal-entity-heuristics.cjs");
4
3
  //#region src/core-entity-heuristics.ts
5
- require_deprecate_subpath.warnDeprecatedSubpath("clivly/core/entity-heuristics", "clivly/internal/entity-heuristics");
4
+ require("./deprecate-subpath-B9ngvizt.cjs").warnDeprecatedSubpath("clivly/core/entity-heuristics", "clivly/internal/entity-heuristics");
6
5
  //#endregion
7
6
  exports.TARGET_CONCEPTS = require_internal_entity_heuristics.TARGET_CONCEPTS;
8
7
  exports.matchesConcept = require_internal_entity_heuristics.matchesConcept;
@@ -1,5 +1,5 @@
1
- import { t as warnDeprecatedSubpath } from "./deprecate-subpath-CmvZtCpE.mjs";
2
1
  import { TARGET_CONCEPTS, matchesConcept, requiredFieldsFor, scoreConcepts, scoreFieldMap, scoreRelationships, suggestCursorField, suggestDiscriminatorColumns, toFieldMap, topConcept, validateMapping } from "./internal-entity-heuristics.mjs";
2
+ import { t as warnDeprecatedSubpath } from "./deprecate-subpath-CmvZtCpE.mjs";
3
3
  //#region src/core-entity-heuristics.ts
4
4
  warnDeprecatedSubpath("clivly/core/entity-heuristics", "clivly/internal/entity-heuristics");
5
5
  //#endregion
@@ -1,8 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_deprecate_subpath = require("./deprecate-subpath-B9ngvizt.cjs");
3
2
  const require_mapping_score = require("./mapping-score-CaB3F4Uu.cjs");
4
3
  //#region src/core-mapping-score.ts
5
- require_deprecate_subpath.warnDeprecatedSubpath("clivly/core/mapping-score", "clivly/internal/mapping-score");
4
+ require("./deprecate-subpath-B9ngvizt.cjs").warnDeprecatedSubpath("clivly/core/mapping-score", "clivly/internal/mapping-score");
6
5
  //#endregion
7
6
  exports.AMBIGUITY_MARGIN = require_mapping_score.AMBIGUITY_MARGIN;
8
7
  exports.CONCEPT_TIERS = require_mapping_score.CONCEPT_TIERS;
@@ -1,5 +1,5 @@
1
- import { t as warnDeprecatedSubpath } from "./deprecate-subpath-CmvZtCpE.mjs";
2
1
  import { a as W, c as isTextLike, i as RELATIONSHIP_TIERS, l as isTimestampLike, n as CONCEPT_TIERS, o as isAmbiguous, r as FIELD_TIERS, s as isNumericLike, t as AMBIGUITY_MARGIN, u as tierFor } from "./mapping-score-C68uAqGB.mjs";
2
+ import { t as warnDeprecatedSubpath } from "./deprecate-subpath-CmvZtCpE.mjs";
3
3
  //#region src/core-mapping-score.ts
4
4
  warnDeprecatedSubpath("clivly/core/mapping-score", "clivly/internal/mapping-score");
5
5
  //#endregion
package/core.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_deprecate_subpath = require("./deprecate-subpath-B9ngvizt.cjs");
3
2
  const require_entity_config = require("./entity-config-D87rV-tE.cjs");
3
+ const require_deprecate_subpath = require("./deprecate-subpath-B9ngvizt.cjs");
4
4
  const require_auth_adapter = require("./auth-adapter-CiEXcweZ.cjs");
5
5
  const require_mapping_form = require("./mapping-form-BpfON_ME.cjs");
6
6
  const require_mappings_config = require("./mappings-config-BsRADNRv.cjs");
package/core.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { t as warnDeprecatedSubpath } from "./deprecate-subpath-CmvZtCpE.mjs";
2
1
  import { a as MATERIALIZING_CONCEPTS, c as filterSchema, d as validateEntitiesConfig, i as ClivlyConfigError, l as relationshipSchema, n as CRM_CONCEPTS, o as defineClivlyConfig, s as entitiesConfigSchema, t as CANONICAL_FIELDS, u as relationshipsSchema } from "./entity-config-va2bkH5o.mjs";
2
+ import { t as warnDeprecatedSubpath } from "./deprecate-subpath-CmvZtCpE.mjs";
3
3
  import { t as authCustom } from "./auth-adapter-gfVDxf3d.mjs";
4
4
  import { n as buildRelationships, r as relationshipViaSignature, t as buildFilterFromDiscriminator } from "./mapping-form-DigkuvFc.mjs";
5
5
  import { t as mappingsToEntitiesConfig } from "./mappings-config-Dbgxx3Nv.mjs";
package/index.cjs CHANGED
@@ -1,12 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
3
  const require_entity_config = require("./entity-config-D87rV-tE.cjs");
4
- require("./core-entity-config.cjs");
5
4
  require("./mapping-score-CaB3F4Uu.cjs");
6
5
  const require_internal_entity_heuristics = require("./internal-entity-heuristics.cjs");
7
- require("./core-entity-heuristics.cjs");
8
- require("./core-mapping-score.cjs");
9
- const require_sdk = require("./sdk-CzOf596t.cjs");
6
+ const require_sdk = require("./sdk-iqOXeWIp.cjs");
10
7
  let node_child_process = require("node:child_process");
11
8
  let node_fs = require("node:fs");
12
9
  let node_module = require("node:module");
@@ -720,6 +717,12 @@ function siblingDbCandidates(schemaPath) {
720
717
  const dir = isDirectory(schemaPath) ? schemaPath : (0, node_path.dirname)(schemaPath);
721
718
  return SIBLING_DB_NAMES.map((name) => (0, node_path.join)(dir, name));
722
719
  }
720
+ function parentDbCandidates(schemaPath, cwd) {
721
+ const parent = (0, node_path.dirname)(isDirectory(schemaPath) ? schemaPath : (0, node_path.dirname)(schemaPath));
722
+ const relativeToRoot = (0, node_path.relative)((0, node_path.resolve)(cwd), (0, node_path.resolve)(parent));
723
+ if (!(relativeToRoot === "" || !relativeToRoot.startsWith(".."))) return [];
724
+ return SIBLING_DB_NAMES.map((name) => (0, node_path.join)(parent, name));
725
+ }
723
726
  async function discoverSchema(opts) {
724
727
  const load = opts.load ?? defaultLoader$1(opts.cwd);
725
728
  const mod = await loadSchemaModule(opts.schemaPath, load);
@@ -751,6 +754,13 @@ async function discoverSchema(opts) {
751
754
  load,
752
755
  match: isDrizzleDb
753
756
  });
757
+ if (db === null) db = await probeModule({
758
+ candidates: parentDbCandidates(opts.schemaPath, opts.cwd),
759
+ cwd: opts.cwd,
760
+ graph: opts.graph,
761
+ load,
762
+ match: isDrizzleDb
763
+ });
754
764
  return {
755
765
  tables,
756
766
  importPath,
@@ -1286,10 +1296,6 @@ function renderSource(role, entity, dbExpr) {
1286
1296
  const opts = entity.idField === null ? `{ entity: "${entity.tableName}", cursorField: "${entity.cursorField}" }` : `{ entity: "${entity.tableName}", cursorField: "${entity.cursorField}", idField: "${entity.idField}" }`;
1287
1297
  return ` ${role}: fromDrizzle(${dbExpr}, schema.${entity.moduleKey}, ${opts})`;
1288
1298
  }
1289
- function fallbackDbImportPath(schemaImportPath) {
1290
- const slash = schemaImportPath.lastIndexOf("/");
1291
- return `${slash >= 0 ? schemaImportPath.slice(0, slash) : "."}/db`;
1292
- }
1293
1299
  function dbIsInSchemaModule(plan) {
1294
1300
  return plan.db !== null && plan.db.importPath === plan.importPath;
1295
1301
  }
@@ -1304,7 +1310,7 @@ function renderImports(plan) {
1304
1310
  "import { discoverFromDrizzle } from \"clivly/drizzle\";",
1305
1311
  `import * as schema from "${plan.importPath}";`
1306
1312
  ];
1307
- if (!plan.db) lines.push(`import { db } from "${fallbackDbImportPath(plan.importPath)}"; // TODO: point at your Drizzle instance`);
1313
+ if (!plan.db) lines.push("import { db } from \"<YOUR_DB_MODULE>\"; // TODO(clivly): replace with the path to your Drizzle `db` instance — NOT your schema file");
1308
1314
  else if (!dbIsInSchemaModule(plan)) lines.push(`import { ${plan.db.moduleKey} } from "${plan.db.importPath}";`);
1309
1315
  return lines.join("\n");
1310
1316
  }
@@ -1333,6 +1339,8 @@ export default createClivlySDK({
1333
1339
  // all when you add a mapping — not just the ones wired up as sources below.
1334
1340
  schema: discoverFromDrizzle(schema),
1335
1341
  source: {
1342
+ // Keyed by CRM concept — contacts/companies/deals/custom.
1343
+ // NOT by your entity key. The entity's own \`source:\` above is a table name.
1336
1344
  ${sources.join(",\n")},
1337
1345
  },
1338
1346
  syncTrigger: {
@@ -2133,7 +2141,7 @@ const STATUS_STAGES = [
2133
2141
  return Promise.resolve({
2134
2142
  state: "warn",
2135
2143
  detail: "Not set — remote sync cannot call back into this app.",
2136
- hint: "Set CLIVLY_SYNC_TRIGGER_URL once the app is deployed. Push-only setups can ignore this."
2144
+ hint: "Set CLIVLY_SYNC_TRIGGER_URL once the app is deployed. For local development run `clivly dev` — it opens a tunnel and registers the URL for you. Push-only setups can ignore this."
2137
2145
  });
2138
2146
  }
2139
2147
  }
package/index.mjs CHANGED
@@ -1,11 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { t as CANONICAL_FIELDS } from "./entity-config-va2bkH5o.mjs";
3
- import "./core-entity-config.mjs";
4
3
  import "./mapping-score-C68uAqGB.mjs";
5
4
  import { matchesConcept, requiredFieldsFor, scoreFieldMap, scoreRelationships, suggestCursorField, toFieldMap } from "./internal-entity-heuristics.mjs";
6
- import "./core-entity-heuristics.mjs";
7
- import "./core-mapping-score.mjs";
8
- import { t as createClivlySDK } from "./sdk-C_1sGiH8.mjs";
5
+ import { t as createClivlySDK } from "./sdk-C6WANW8M.mjs";
9
6
  import { createRequire } from "node:module";
10
7
  import { spawn } from "node:child_process";
11
8
  import { accessSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
@@ -719,6 +716,12 @@ function siblingDbCandidates(schemaPath) {
719
716
  const dir = isDirectory(schemaPath) ? schemaPath : dirname(schemaPath);
720
717
  return SIBLING_DB_NAMES.map((name) => join(dir, name));
721
718
  }
719
+ function parentDbCandidates(schemaPath, cwd) {
720
+ const parent = dirname(isDirectory(schemaPath) ? schemaPath : dirname(schemaPath));
721
+ const relativeToRoot = relative(resolve(cwd), resolve(parent));
722
+ if (!(relativeToRoot === "" || !relativeToRoot.startsWith(".."))) return [];
723
+ return SIBLING_DB_NAMES.map((name) => join(parent, name));
724
+ }
722
725
  async function discoverSchema(opts) {
723
726
  const load = opts.load ?? defaultLoader$1(opts.cwd);
724
727
  const mod = await loadSchemaModule(opts.schemaPath, load);
@@ -750,6 +753,13 @@ async function discoverSchema(opts) {
750
753
  load,
751
754
  match: isDrizzleDb
752
755
  });
756
+ if (db === null) db = await probeModule({
757
+ candidates: parentDbCandidates(opts.schemaPath, opts.cwd),
758
+ cwd: opts.cwd,
759
+ graph: opts.graph,
760
+ load,
761
+ match: isDrizzleDb
762
+ });
753
763
  return {
754
764
  tables,
755
765
  importPath,
@@ -1285,10 +1295,6 @@ function renderSource(role, entity, dbExpr) {
1285
1295
  const opts = entity.idField === null ? `{ entity: "${entity.tableName}", cursorField: "${entity.cursorField}" }` : `{ entity: "${entity.tableName}", cursorField: "${entity.cursorField}", idField: "${entity.idField}" }`;
1286
1296
  return ` ${role}: fromDrizzle(${dbExpr}, schema.${entity.moduleKey}, ${opts})`;
1287
1297
  }
1288
- function fallbackDbImportPath(schemaImportPath) {
1289
- const slash = schemaImportPath.lastIndexOf("/");
1290
- return `${slash >= 0 ? schemaImportPath.slice(0, slash) : "."}/db`;
1291
- }
1292
1298
  function dbIsInSchemaModule(plan) {
1293
1299
  return plan.db !== null && plan.db.importPath === plan.importPath;
1294
1300
  }
@@ -1303,7 +1309,7 @@ function renderImports(plan) {
1303
1309
  "import { discoverFromDrizzle } from \"clivly/drizzle\";",
1304
1310
  `import * as schema from "${plan.importPath}";`
1305
1311
  ];
1306
- if (!plan.db) lines.push(`import { db } from "${fallbackDbImportPath(plan.importPath)}"; // TODO: point at your Drizzle instance`);
1312
+ if (!plan.db) lines.push("import { db } from \"<YOUR_DB_MODULE>\"; // TODO(clivly): replace with the path to your Drizzle `db` instance — NOT your schema file");
1307
1313
  else if (!dbIsInSchemaModule(plan)) lines.push(`import { ${plan.db.moduleKey} } from "${plan.db.importPath}";`);
1308
1314
  return lines.join("\n");
1309
1315
  }
@@ -1332,6 +1338,8 @@ export default createClivlySDK({
1332
1338
  // all when you add a mapping — not just the ones wired up as sources below.
1333
1339
  schema: discoverFromDrizzle(schema),
1334
1340
  source: {
1341
+ // Keyed by CRM concept — contacts/companies/deals/custom.
1342
+ // NOT by your entity key. The entity's own \`source:\` above is a table name.
1335
1343
  ${sources.join(",\n")},
1336
1344
  },
1337
1345
  syncTrigger: {
@@ -2132,7 +2140,7 @@ const STATUS_STAGES = [
2132
2140
  return Promise.resolve({
2133
2141
  state: "warn",
2134
2142
  detail: "Not set — remote sync cannot call back into this app.",
2135
- hint: "Set CLIVLY_SYNC_TRIGGER_URL once the app is deployed. Push-only setups can ignore this."
2143
+ hint: "Set CLIVLY_SYNC_TRIGGER_URL once the app is deployed. For local development run `clivly dev` — it opens a tunnel and registers the URL for you. Push-only setups can ignore this."
2136
2144
  });
2137
2145
  }
2138
2146
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clivly",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Clivly CLI — scaffold a CRM into your app (clivly init).",
@@ -367,8 +367,8 @@
367
367
  "peerDependencies": {
368
368
  "@clerk/backend": ">=1",
369
369
  "@workos-inc/node": ">=7",
370
- "better-auth": ">=1",
371
- "drizzle-orm": ">=0.30",
370
+ "better-auth": ">=1.6.22",
371
+ "drizzle-orm": ">=0.30 || >=1.0.0-rc.0",
372
372
  "vite": ">=5"
373
373
  },
374
374
  "peerDependenciesMeta": {
@@ -1,4 +1,83 @@
1
1
  import { t as DRIZZLE_META } from "./drizzle-meta-D-5k3n0i.mjs";
2
+ //#region ../sdk/src/entity-source.ts
3
+ const CONCEPT_KEYS = {
4
+ contact: "contacts",
5
+ company: "companies",
6
+ deal: "deals"
7
+ };
8
+ function resolveEntitySource(config, entityKey) {
9
+ const entity = config.entities?.entities?.[entityKey];
10
+ const src = config.source;
11
+ if (!(entity && src)) return null;
12
+ const conceptKey = CONCEPT_KEYS[entity.concept];
13
+ if (conceptKey) {
14
+ const source = src[conceptKey];
15
+ return source ? {
16
+ concept: entity.concept,
17
+ expectedKey: conceptKey,
18
+ source
19
+ } : null;
20
+ }
21
+ if (entity.concept === "custom") {
22
+ const match = (src.custom ?? []).find((c) => c.entity === entity.source || c.entity === entityKey);
23
+ return match ? {
24
+ concept: "custom",
25
+ expectedKey: "custom",
26
+ source: match
27
+ } : null;
28
+ }
29
+ return null;
30
+ }
31
+ /** The `config.source` key an entity's concept requires, present or not. */
32
+ function expectedSourceKey(concept) {
33
+ return CONCEPT_KEYS[concept] ?? "custom";
34
+ }
35
+ //#endregion
36
+ //#region ../sdk/src/concept-coverage.ts
37
+ const CHECK_NAME = "Entity → source coverage";
38
+ function configuredKeys(config) {
39
+ const src = config.source;
40
+ if (!src) return [];
41
+ return Object.entries(src).filter(([, value]) => Array.isArray(value) ? value.length > 0 : Boolean(value)).map(([key]) => key);
42
+ }
43
+ /**
44
+ * Every configured entity must resolve to a source, or it silently syncs
45
+ * nothing. This is the check that catches `source` keyed by entity name rather
46
+ * than by CRM concept — a mistake that otherwise shows up only as a run that
47
+ * succeeds with zero rows.
48
+ */
49
+ function checkConceptCoverage(config) {
50
+ const entities = config.entities?.entities ?? {};
51
+ const keys = Object.keys(entities);
52
+ if (keys.length === 0) return {
53
+ name: CHECK_NAME,
54
+ ok: false,
55
+ skipped: true,
56
+ detail: "No entities configured — nothing to cross-check."
57
+ };
58
+ const unresolved = keys.filter((key) => resolveEntitySource(config, key) === null);
59
+ if (unresolved.length === 0) return {
60
+ name: CHECK_NAME,
61
+ ok: true,
62
+ detail: `${keys.length} entit${keys.length === 1 ? "y" : "ies"} resolve to a configured source.`
63
+ };
64
+ const available = configuredKeys(config);
65
+ const listed = available.length > 0 ? available.join(", ") : "(none)";
66
+ const problems = unresolved.map((key) => {
67
+ const concept = entities[key]?.concept ?? "unknown";
68
+ if (concept === "custom") {
69
+ const custom = config.source?.custom ?? [];
70
+ 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(", ")}.`;
71
+ }
72
+ return `"${key}" has concept "${concept}" but no \`source.${expectedSourceKey(concept)}\` is configured — it will never sync.`;
73
+ });
74
+ return {
75
+ name: CHECK_NAME,
76
+ ok: false,
77
+ detail: `${problems.join(" ")} Configured source keys: ${listed}. \`source\` is keyed by CRM concept, not by entity key.`
78
+ };
79
+ }
80
+ //#endregion
2
81
  //#region ../sdk/src/custom-slug.ts
3
82
  const NON_ALNUM = /[^a-z0-9]+/g;
4
83
  const EDGE_HYPHENS = /(^-|-$)/g;
@@ -79,6 +158,56 @@ function describeFetchError(entity, error) {
79
158
  return `Couldn't read "${entity}": ${firstLine(root instanceof Error ? root.message : String(root?.message ?? root ?? "unknown error"))}`;
80
159
  }
81
160
  //#endregion
161
+ //#region ../sdk/src/mapping-dry-run.ts
162
+ /**
163
+ * Fetch one real row and push it through the entity's field map.
164
+ *
165
+ * The distinction from "Sample fetch": that proves the query runs, this proves
166
+ * the row that comes back actually carries the columns `fields` names. A
167
+ * mapping that points at a column the row lacks produces empty CRM records
168
+ * rather than an error, so it has to be caught here.
169
+ *
170
+ * An empty table SKIPS rather than fails — a fresh database is not a
171
+ * misconfiguration, and failing on it would train people to ignore the check.
172
+ */
173
+ async function runMappingDryRun(config, entityKey) {
174
+ const name = `Field mapping dry run (${entityKey})`;
175
+ const resolved = resolveEntitySource(config, entityKey);
176
+ if (!resolved) return {
177
+ name,
178
+ ok: false,
179
+ skipped: true,
180
+ detail: `"${entityKey}" resolves to no source — see the coverage check above.`
181
+ };
182
+ const fields = config.entities?.entities?.[entityKey]?.fields ?? {};
183
+ let rows;
184
+ try {
185
+ rows = await resolved.source.fetchPage({
186
+ since: null,
187
+ limit: 1
188
+ });
189
+ } catch (error) {
190
+ return {
191
+ name,
192
+ ok: false,
193
+ detail: describeFetchError(entityKey, error)
194
+ };
195
+ }
196
+ const row = rows[0];
197
+ if (row === void 0) return {
198
+ name,
199
+ ok: false,
200
+ skipped: true,
201
+ detail: `"${resolved.source.entity}" is empty — nothing to map yet.`
202
+ };
203
+ const missing = Object.entries(fields).filter(([, column]) => !(column in row)).map(([field, column]) => `${field} → ${column}`);
204
+ return {
205
+ name,
206
+ ok: missing.length === 0,
207
+ 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(", ")}.`
208
+ };
209
+ }
210
+ //#endregion
82
211
  //#region ../sdk/src/namespace-probe.ts
83
212
  /**
84
213
  * Verify the tables the integration depends on are genuinely reachable:
@@ -169,6 +298,11 @@ async function namespaceCheck(config) {
169
298
  detail: probeResult.reachable ? "All required source tables are reachable." : probeResult.error?.message ?? "namespace unreachable"
170
299
  };
171
300
  }
301
+ async function mappingDryRunChecks(config) {
302
+ const checks = [];
303
+ for (const entityKey of Object.keys(config.entities?.entities ?? {})) checks.push(await runMappingDryRun(config, entityKey));
304
+ return checks;
305
+ }
172
306
  /**
173
307
  * Run the read-only setup checks and return a structured report. Every check
174
308
  * captures its own error into `detail` rather than throwing, so one failure never
@@ -195,6 +329,7 @@ async function runDoctor(args) {
195
329
  ok: sources.length > 0,
196
330
  detail: sources.length > 0 ? `${sources.length} source(s) to mirror.` : "No `source.contacts` — nothing to sync."
197
331
  });
332
+ checks.push(checkConceptCoverage(config));
198
333
  for (const source of sources) {
199
334
  const meta = source[DRIZZLE_META];
200
335
  if (!meta) {
@@ -233,6 +368,7 @@ async function runDoctor(args) {
233
368
  detail: describeFetchError(source.entity, error)
234
369
  });
235
370
  }
371
+ checks.push(...await mappingDryRunChecks(config));
236
372
  const result = await connect();
237
373
  checks.push({
238
374
  name: "Heartbeat",
@@ -276,6 +412,7 @@ async function syncSource(args) {
276
412
  const idField = source.idField ?? DEFAULT_ID_FIELD;
277
413
  let cursor = since;
278
414
  let pushed = 0;
415
+ const resumedFrom = since === null ? null : since.time.toISOString();
279
416
  for (;;) {
280
417
  const rows = await source.fetchPage({
281
418
  since: cursor,
@@ -287,6 +424,8 @@ async function syncSource(args) {
287
424
  source: source.entity,
288
425
  rows: capped,
289
426
  mode: "delta",
427
+ fetched: rows.length,
428
+ cursor: resumedFrom,
290
429
  ...objectType ? { objectType } : {}
291
430
  })) return {
292
431
  pushed,
@@ -329,6 +468,8 @@ async function fullSyncSource(args) {
329
468
  source: source.entity,
330
469
  rows: all,
331
470
  mode: "full",
471
+ fetched: all.length,
472
+ cursor: null,
332
473
  ...objectType ? { objectType } : {}
333
474
  });
334
475
  return {
@@ -708,31 +849,25 @@ function createFetcher(retry) {
708
849
  }
709
850
  };
710
851
  }
852
+ const CONCEPT_PATHS = {
853
+ contacts: SYNC_CONTACTS_PATH,
854
+ companies: SYNC_COMPANIES_PATH,
855
+ deals: SYNC_DEALS_PATH,
856
+ custom: SYNC_CUSTOM_PATH
857
+ };
711
858
  function resolveSampleSource(config, entityKey) {
712
- const entity = config.entities?.entities?.[entityKey];
713
- const src = config.source;
714
- if (!(entity && src)) return null;
715
- if (entity.concept === "contact" && src.contacts) return {
716
- source: src.contacts,
717
- path: SYNC_CONTACTS_PATH
718
- };
719
- if (entity.concept === "company" && src.companies) return {
720
- source: src.companies,
721
- path: SYNC_COMPANIES_PATH
722
- };
723
- if (entity.concept === "deal" && src.deals) return {
724
- source: src.deals,
725
- path: SYNC_DEALS_PATH
859
+ const resolved = resolveEntitySource(config, entityKey);
860
+ if (!resolved) return null;
861
+ const path = CONCEPT_PATHS[resolved.expectedKey];
862
+ if (!path) return null;
863
+ return resolved.expectedKey === "custom" ? {
864
+ source: resolved.source,
865
+ path,
866
+ objectType: resolved.source.objectType
867
+ } : {
868
+ source: resolved.source,
869
+ path
726
870
  };
727
- if (entity.concept === "custom") {
728
- const custom = (src.custom ?? []).find((c) => c.entity === entity.source || c.entity === entityKey);
729
- return custom ? {
730
- source: custom,
731
- path: SYNC_CUSTOM_PATH,
732
- objectType: custom.objectType
733
- } : null;
734
- }
735
- return null;
736
871
  }
737
872
  const PREVIEW_LIMIT = 5;
738
873
  function resolvePreviewSource(config, sourceTable) {
@@ -1,4 +1,83 @@
1
1
  const require_drizzle_meta = require("./drizzle-meta-Bb1JWYqf.cjs");
2
+ //#region ../sdk/src/entity-source.ts
3
+ const CONCEPT_KEYS = {
4
+ contact: "contacts",
5
+ company: "companies",
6
+ deal: "deals"
7
+ };
8
+ function resolveEntitySource(config, entityKey) {
9
+ const entity = config.entities?.entities?.[entityKey];
10
+ const src = config.source;
11
+ if (!(entity && src)) return null;
12
+ const conceptKey = CONCEPT_KEYS[entity.concept];
13
+ if (conceptKey) {
14
+ const source = src[conceptKey];
15
+ return source ? {
16
+ concept: entity.concept,
17
+ expectedKey: conceptKey,
18
+ source
19
+ } : null;
20
+ }
21
+ if (entity.concept === "custom") {
22
+ const match = (src.custom ?? []).find((c) => c.entity === entity.source || c.entity === entityKey);
23
+ return match ? {
24
+ concept: "custom",
25
+ expectedKey: "custom",
26
+ source: match
27
+ } : null;
28
+ }
29
+ return null;
30
+ }
31
+ /** The `config.source` key an entity's concept requires, present or not. */
32
+ function expectedSourceKey(concept) {
33
+ return CONCEPT_KEYS[concept] ?? "custom";
34
+ }
35
+ //#endregion
36
+ //#region ../sdk/src/concept-coverage.ts
37
+ const CHECK_NAME = "Entity → source coverage";
38
+ function configuredKeys(config) {
39
+ const src = config.source;
40
+ if (!src) return [];
41
+ return Object.entries(src).filter(([, value]) => Array.isArray(value) ? value.length > 0 : Boolean(value)).map(([key]) => key);
42
+ }
43
+ /**
44
+ * Every configured entity must resolve to a source, or it silently syncs
45
+ * nothing. This is the check that catches `source` keyed by entity name rather
46
+ * than by CRM concept — a mistake that otherwise shows up only as a run that
47
+ * succeeds with zero rows.
48
+ */
49
+ function checkConceptCoverage(config) {
50
+ const entities = config.entities?.entities ?? {};
51
+ const keys = Object.keys(entities);
52
+ if (keys.length === 0) return {
53
+ name: CHECK_NAME,
54
+ ok: false,
55
+ skipped: true,
56
+ detail: "No entities configured — nothing to cross-check."
57
+ };
58
+ const unresolved = keys.filter((key) => resolveEntitySource(config, key) === null);
59
+ if (unresolved.length === 0) return {
60
+ name: CHECK_NAME,
61
+ ok: true,
62
+ detail: `${keys.length} entit${keys.length === 1 ? "y" : "ies"} resolve to a configured source.`
63
+ };
64
+ const available = configuredKeys(config);
65
+ const listed = available.length > 0 ? available.join(", ") : "(none)";
66
+ const problems = unresolved.map((key) => {
67
+ const concept = entities[key]?.concept ?? "unknown";
68
+ if (concept === "custom") {
69
+ const custom = config.source?.custom ?? [];
70
+ 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(", ")}.`;
71
+ }
72
+ return `"${key}" has concept "${concept}" but no \`source.${expectedSourceKey(concept)}\` is configured — it will never sync.`;
73
+ });
74
+ return {
75
+ name: CHECK_NAME,
76
+ ok: false,
77
+ detail: `${problems.join(" ")} Configured source keys: ${listed}. \`source\` is keyed by CRM concept, not by entity key.`
78
+ };
79
+ }
80
+ //#endregion
2
81
  //#region ../sdk/src/custom-slug.ts
3
82
  const NON_ALNUM = /[^a-z0-9]+/g;
4
83
  const EDGE_HYPHENS = /(^-|-$)/g;
@@ -79,6 +158,56 @@ function describeFetchError(entity, error) {
79
158
  return `Couldn't read "${entity}": ${firstLine(root instanceof Error ? root.message : String(root?.message ?? root ?? "unknown error"))}`;
80
159
  }
81
160
  //#endregion
161
+ //#region ../sdk/src/mapping-dry-run.ts
162
+ /**
163
+ * Fetch one real row and push it through the entity's field map.
164
+ *
165
+ * The distinction from "Sample fetch": that proves the query runs, this proves
166
+ * the row that comes back actually carries the columns `fields` names. A
167
+ * mapping that points at a column the row lacks produces empty CRM records
168
+ * rather than an error, so it has to be caught here.
169
+ *
170
+ * An empty table SKIPS rather than fails — a fresh database is not a
171
+ * misconfiguration, and failing on it would train people to ignore the check.
172
+ */
173
+ async function runMappingDryRun(config, entityKey) {
174
+ const name = `Field mapping dry run (${entityKey})`;
175
+ const resolved = resolveEntitySource(config, entityKey);
176
+ if (!resolved) return {
177
+ name,
178
+ ok: false,
179
+ skipped: true,
180
+ detail: `"${entityKey}" resolves to no source — see the coverage check above.`
181
+ };
182
+ const fields = config.entities?.entities?.[entityKey]?.fields ?? {};
183
+ let rows;
184
+ try {
185
+ rows = await resolved.source.fetchPage({
186
+ since: null,
187
+ limit: 1
188
+ });
189
+ } catch (error) {
190
+ return {
191
+ name,
192
+ ok: false,
193
+ detail: describeFetchError(entityKey, error)
194
+ };
195
+ }
196
+ const row = rows[0];
197
+ if (row === void 0) return {
198
+ name,
199
+ ok: false,
200
+ skipped: true,
201
+ detail: `"${resolved.source.entity}" is empty — nothing to map yet.`
202
+ };
203
+ const missing = Object.entries(fields).filter(([, column]) => !(column in row)).map(([field, column]) => `${field} → ${column}`);
204
+ return {
205
+ name,
206
+ ok: missing.length === 0,
207
+ 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(", ")}.`
208
+ };
209
+ }
210
+ //#endregion
82
211
  //#region ../sdk/src/namespace-probe.ts
83
212
  /**
84
213
  * Verify the tables the integration depends on are genuinely reachable:
@@ -169,6 +298,11 @@ async function namespaceCheck(config) {
169
298
  detail: probeResult.reachable ? "All required source tables are reachable." : probeResult.error?.message ?? "namespace unreachable"
170
299
  };
171
300
  }
301
+ async function mappingDryRunChecks(config) {
302
+ const checks = [];
303
+ for (const entityKey of Object.keys(config.entities?.entities ?? {})) checks.push(await runMappingDryRun(config, entityKey));
304
+ return checks;
305
+ }
172
306
  /**
173
307
  * Run the read-only setup checks and return a structured report. Every check
174
308
  * captures its own error into `detail` rather than throwing, so one failure never
@@ -195,6 +329,7 @@ async function runDoctor(args) {
195
329
  ok: sources.length > 0,
196
330
  detail: sources.length > 0 ? `${sources.length} source(s) to mirror.` : "No `source.contacts` — nothing to sync."
197
331
  });
332
+ checks.push(checkConceptCoverage(config));
198
333
  for (const source of sources) {
199
334
  const meta = source[require_drizzle_meta.DRIZZLE_META];
200
335
  if (!meta) {
@@ -233,6 +368,7 @@ async function runDoctor(args) {
233
368
  detail: describeFetchError(source.entity, error)
234
369
  });
235
370
  }
371
+ checks.push(...await mappingDryRunChecks(config));
236
372
  const result = await connect();
237
373
  checks.push({
238
374
  name: "Heartbeat",
@@ -276,6 +412,7 @@ async function syncSource(args) {
276
412
  const idField = source.idField ?? DEFAULT_ID_FIELD;
277
413
  let cursor = since;
278
414
  let pushed = 0;
415
+ const resumedFrom = since === null ? null : since.time.toISOString();
279
416
  for (;;) {
280
417
  const rows = await source.fetchPage({
281
418
  since: cursor,
@@ -287,6 +424,8 @@ async function syncSource(args) {
287
424
  source: source.entity,
288
425
  rows: capped,
289
426
  mode: "delta",
427
+ fetched: rows.length,
428
+ cursor: resumedFrom,
290
429
  ...objectType ? { objectType } : {}
291
430
  })) return {
292
431
  pushed,
@@ -329,6 +468,8 @@ async function fullSyncSource(args) {
329
468
  source: source.entity,
330
469
  rows: all,
331
470
  mode: "full",
471
+ fetched: all.length,
472
+ cursor: null,
332
473
  ...objectType ? { objectType } : {}
333
474
  });
334
475
  return {
@@ -708,31 +849,25 @@ function createFetcher(retry) {
708
849
  }
709
850
  };
710
851
  }
852
+ const CONCEPT_PATHS = {
853
+ contacts: SYNC_CONTACTS_PATH,
854
+ companies: SYNC_COMPANIES_PATH,
855
+ deals: SYNC_DEALS_PATH,
856
+ custom: SYNC_CUSTOM_PATH
857
+ };
711
858
  function resolveSampleSource(config, entityKey) {
712
- const entity = config.entities?.entities?.[entityKey];
713
- const src = config.source;
714
- if (!(entity && src)) return null;
715
- if (entity.concept === "contact" && src.contacts) return {
716
- source: src.contacts,
717
- path: SYNC_CONTACTS_PATH
718
- };
719
- if (entity.concept === "company" && src.companies) return {
720
- source: src.companies,
721
- path: SYNC_COMPANIES_PATH
722
- };
723
- if (entity.concept === "deal" && src.deals) return {
724
- source: src.deals,
725
- path: SYNC_DEALS_PATH
859
+ const resolved = resolveEntitySource(config, entityKey);
860
+ if (!resolved) return null;
861
+ const path = CONCEPT_PATHS[resolved.expectedKey];
862
+ if (!path) return null;
863
+ return resolved.expectedKey === "custom" ? {
864
+ source: resolved.source,
865
+ path,
866
+ objectType: resolved.source.objectType
867
+ } : {
868
+ source: resolved.source,
869
+ path
726
870
  };
727
- if (entity.concept === "custom") {
728
- const custom = (src.custom ?? []).find((c) => c.entity === entity.source || c.entity === entityKey);
729
- return custom ? {
730
- source: custom,
731
- path: SYNC_CUSTOM_PATH,
732
- objectType: custom.objectType
733
- } : null;
734
- }
735
- return null;
736
871
  }
737
872
  const PREVIEW_LIMIT = 5;
738
873
  function resolvePreviewSource(config, sourceTable) {
package/sdk.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_entity_config = require("./entity-config-D87rV-tE.cjs");
3
- const require_sdk = require("./sdk-CzOf596t.cjs");
3
+ const require_sdk = require("./sdk-iqOXeWIp.cjs");
4
4
  exports.ClivlyAuthError = require_sdk.ClivlyAuthError;
5
5
  exports.ClivlyError = require_sdk.ClivlyError;
6
6
  exports.ClivlyNetworkError = require_sdk.ClivlyNetworkError;
package/sdk.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  import { o as defineClivlyConfig } from "./entity-config-va2bkH5o.mjs";
2
- import { a as ClivlyRateLimitError, i as ClivlyNetworkError, n as ClivlyAuthError, o as createChatSessionHandler, r as ClivlyError, t as createClivlySDK } from "./sdk-C_1sGiH8.mjs";
2
+ import { a as ClivlyRateLimitError, i as ClivlyNetworkError, n as ClivlyAuthError, o as createChatSessionHandler, r as ClivlyError, t as createClivlySDK } from "./sdk-C6WANW8M.mjs";
3
3
  export { ClivlyAuthError, ClivlyError, ClivlyNetworkError, ClivlyRateLimitError, createChatSessionHandler, createClivlySDK, defineClivlyConfig };