@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 +122 -122
- package/dist/index.cjs +158 -23
- package/dist/index.mjs +158 -23
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,36 +1,66 @@
|
|
|
1
1
|
# @clivly/sdk
|
|
2
2
|
|
|
3
|
-
Connect
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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!,
|
|
80
|
+
apiKey: process.env.CLIVLY_SECRET_KEY!,
|
|
51
81
|
entities,
|
|
52
82
|
source: {
|
|
53
|
-
contacts: fromDrizzle(db, participants, {
|
|
54
|
-
|
|
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();
|
|
60
|
-
if (!status.ok)
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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,
|
|
121
|
+
secret: process.env.CLIVLY_SYNC_TRIGGER_SECRET,
|
|
72
122
|
},
|
|
73
123
|
});
|
|
74
124
|
|
|
75
125
|
export async function POST(request: Request) {
|
|
76
|
-
return
|
|
126
|
+
return clivly.createClivlyHandler()(request);
|
|
77
127
|
}
|
|
78
128
|
```
|
|
79
129
|
|
|
80
|
-
`
|
|
81
|
-
|
|
82
|
-
`
|
|
83
|
-
|
|
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
|
|
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
|
|
115
|
-
|
|
116
|
-
`createClivlyHandler()` for
|
|
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
|
-
|
|
155
|
+
If your instance lives in `clivly.config.ts`, the CLI can validate the setup:
|
|
125
156
|
|
|
126
157
|
```bash
|
|
127
|
-
npx clivly ping
|
|
128
|
-
npx clivly doctor
|
|
158
|
+
npx clivly ping
|
|
159
|
+
npx clivly doctor
|
|
129
160
|
```
|
|
130
161
|
|
|
131
|
-
In
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
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(),
|
|
150
|
-
fetch: clivly.createClivlyHandler(),
|
|
172
|
+
scheduled: () => clivly.runScheduled(),
|
|
173
|
+
fetch: clivly.createClivlyHandler(),
|
|
151
174
|
};
|
|
152
175
|
```
|
|
153
176
|
|
|
154
|
-
`runScheduled()` throws
|
|
155
|
-
`createClivlyHandler()` returns `200`
|
|
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
|
-
|
|
165
|
-
|
|
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(
|
|
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
|
|
187
|
-
|
|
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.
|
|
214
|
-
|
|
215
|
-
- `
|
|
216
|
-
|
|
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
|
-
-
|
|
223
|
-
[
|
|
224
|
-
- CLI
|
|
225
|
-
|
|
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
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
if (
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
if (
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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.
|
|
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.
|
|
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": {
|