@clivly/sdk 0.2.0 → 0.3.0-next.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 +113 -11
- package/dist/{drizzle-meta-CAJ43cLc.mjs → drizzle-meta-DMx_9nlj.mjs} +1 -1
- package/dist/{drizzle-meta-C0T6F3vL.cjs → drizzle-meta-iAu8w1hj.cjs} +1 -1
- package/dist/drizzle.cjs +59 -2
- package/dist/drizzle.d.cts +8 -2
- package/dist/drizzle.d.mts +8 -2
- package/dist/drizzle.mjs +59 -3
- package/dist/index.cjs +306 -31
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +306 -31
- package/dist/{types-B0aSxJ6V.d.cts → namespace-probe-DFpvfl7T.d.mts} +82 -7
- package/dist/{types-DWt-ytBQ.d.mts → namespace-probe-DIBgCQca.d.cts} +82 -7
- package/dist/vite.cjs +38 -0
- package/dist/vite.d.cts +23 -0
- package/dist/vite.d.mts +23 -0
- package/dist/vite.mjs +37 -0
- package/package.json +18 -3
package/README.md
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
# @clivly/sdk
|
|
2
2
|
|
|
3
3
|
Connect your app to Clivly Cloud. The SDK sends a heartbeat, reports your schema
|
|
4
|
-
for entity mapping, and mirrors your contacts and companies into Clivly
|
|
5
|
-
outbound HTTPS
|
|
4
|
+
for entity mapping, and mirrors your contacts and companies into Clivly over
|
|
5
|
+
outbound HTTPS. If you configure `syncTrigger`, Clivly can also call back into
|
|
6
|
+
your app to start a sync from the dashboard.
|
|
7
|
+
|
|
8
|
+
For the end-to-end onboarding flow, start with
|
|
9
|
+
[`docs/guides/connecting-to-clivly-cloud.md`](../../docs/guides/connecting-to-clivly-cloud.md).
|
|
10
|
+
This README stays focused on the SDK surface itself.
|
|
6
11
|
|
|
7
12
|
## Install
|
|
8
13
|
|
|
9
14
|
```bash
|
|
10
|
-
npm install @clivly/sdk
|
|
15
|
+
npm install @clivly/sdk @clivly/core clivly
|
|
11
16
|
```
|
|
12
17
|
|
|
18
|
+
Add `drizzle-orm` when you use `@clivly/sdk/drizzle`, and `vite` when you use
|
|
19
|
+
the Vite/TanStack Start plugin.
|
|
20
|
+
|
|
13
21
|
## Usage
|
|
14
22
|
|
|
15
23
|
```ts
|
|
@@ -40,8 +48,8 @@ const clivly = createClivlySDK({
|
|
|
40
48
|
apiKey: process.env.CLIVLY_API_KEY!, // sk_live_… from Settings → API keys
|
|
41
49
|
entities,
|
|
42
50
|
source: {
|
|
43
|
-
contacts: fromDrizzle(db, participants, { entity: "participants", cursorField: "
|
|
44
|
-
companies: fromDrizzle(db, accounts, { entity: "accounts", cursorField: "
|
|
51
|
+
contacts: fromDrizzle(db, participants, { entity: "participants", cursorField: "updatedAt" }),
|
|
52
|
+
companies: fromDrizzle(db, accounts, { entity: "accounts", cursorField: "updatedAt" }),
|
|
45
53
|
},
|
|
46
54
|
sync: { onBoot: true, intervalMs: 30_000, batchSize: 500 },
|
|
47
55
|
});
|
|
@@ -49,13 +57,60 @@ const clivly = createClivlySDK({
|
|
|
49
57
|
const status = await clivly.connect(); // rich result: { ok, status, reason, retryable, org? }
|
|
50
58
|
if (!status.ok) throw new Error(`Clivly: ${status.reason}`);
|
|
51
59
|
await clivly.start(); // heartbeat + backfill + interval delta sync
|
|
60
|
+
|
|
61
|
+
// Optional: let Clivly's dashboard start a sync in this app.
|
|
62
|
+
const remote = createClivlySDK({
|
|
63
|
+
apiKey: process.env.CLIVLY_API_KEY!,
|
|
64
|
+
entities,
|
|
65
|
+
source: { /* … */ },
|
|
66
|
+
syncTrigger: {
|
|
67
|
+
url: "https://yourapp.com/api/clivly/tick",
|
|
68
|
+
secret: process.env.CLIVLY_TRIGGER_SECRET, // whsec_… from Integrations → Remote sync
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export async function POST(request: Request) {
|
|
73
|
+
return remote.createClivlyHandler()(request);
|
|
74
|
+
}
|
|
52
75
|
```
|
|
53
76
|
|
|
54
77
|
`fromDrizzle` generates the keyset-paginated `fetchPage` the SDK needs, inferring the
|
|
55
|
-
tie-breaker id from the table's primary key — pass `idField` to override.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
78
|
+
tie-breaker id from the table's primary key — pass `idField` to override.
|
|
79
|
+
`cursorField` and `idField` are Drizzle property keys (`updatedAt`), not database
|
|
80
|
+
column names (`updated_at`). Rows are keyed by your source column names; Clivly
|
|
81
|
+
applies the field map you confirmed during onboarding, so re-mapping never needs
|
|
82
|
+
a redeploy. Non-Drizzle hosts can still pass a hand-written `fetchPage` closure
|
|
83
|
+
instead.
|
|
84
|
+
|
|
85
|
+
With `syncTrigger.url` + `syncTrigger.secret` configured, the SDK reports the
|
|
86
|
+
trigger URL on each heartbeat and rejects unsigned or badly-signed trigger
|
|
87
|
+
requests. Without `syncTrigger`, cloud mode is push-only: Clivly can observe
|
|
88
|
+
the app but cannot start a sync from the dashboard.
|
|
89
|
+
|
|
90
|
+
If you rotate a remote-sync secret from Clivly's dashboard after the encryption
|
|
91
|
+
rollout, Clivly stores it encrypted at rest. The server-side deployment must
|
|
92
|
+
have `CLIVLY_TRIGGER_SECRET_WRAPPING_KEY` configured, while your app continues
|
|
93
|
+
to use `CLIVLY_TRIGGER_SECRET` unchanged. Existing plaintext rows remain valid
|
|
94
|
+
during rollout, and older rows can be backfilled with
|
|
95
|
+
`node ./scripts/backfill-sync-trigger-secrets.mjs --write`.
|
|
96
|
+
|
|
97
|
+
## Vite / TanStack Start
|
|
98
|
+
|
|
99
|
+
For long-lived Vite dev servers, wire the SDK lifecycle through the Vite plugin:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { clivlyVite } from "@clivly/sdk/vite";
|
|
103
|
+
import clivly from "./clivly.config";
|
|
104
|
+
import { defineConfig } from "vite";
|
|
105
|
+
|
|
106
|
+
export default defineConfig({
|
|
107
|
+
plugins: [clivlyVite(clivly)],
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The plugin calls `connect()` for a clear startup status line, then `start()` for
|
|
112
|
+
the heartbeat and sync loop. It is dev-server only; use `runScheduled()` or
|
|
113
|
+
`createClivlyHandler()` for serverless production runtimes.
|
|
59
114
|
|
|
60
115
|
> **Legacy:** the flat `contactEntity` / `contactFields` form is still accepted but
|
|
61
116
|
> **deprecated** — prefer the `entities` config above, which is the same model the
|
|
@@ -66,9 +121,16 @@ re-mapping never needs a redeploy. Non-Drizzle hosts can still pass a hand-writt
|
|
|
66
121
|
Put your instance in `clivly.config.ts` (`export default createClivlySDK({...})`), then:
|
|
67
122
|
|
|
68
123
|
```bash
|
|
124
|
+
npx clivly ping # CLIVLY_API_KEY + API reachability
|
|
69
125
|
npx clivly doctor # env → config → schema → columns → sample fetch → heartbeat
|
|
70
126
|
```
|
|
71
127
|
|
|
128
|
+
In a monorepo, pass the app config explicitly:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
npx clivly doctor --config ./apps/web/clivly.config.ts
|
|
132
|
+
```
|
|
133
|
+
|
|
72
134
|
Or in code: `await clivly.doctor()` returns `{ ok, checks }` — the same six read-only checks.
|
|
73
135
|
|
|
74
136
|
## Serverless / edge
|
|
@@ -87,8 +149,11 @@ export default {
|
|
|
87
149
|
```
|
|
88
150
|
|
|
89
151
|
`runScheduled()` throws if a sync push fails (so a cron run is marked failed);
|
|
90
|
-
`createClivlyHandler()` returns `200`
|
|
91
|
-
|
|
152
|
+
`createClivlyHandler()` returns `200` when the triggered run finishes successfully,
|
|
153
|
+
`500` otherwise. If the run carries a Clivly-issued run id, the SDK also reports
|
|
154
|
+
the final outcome back to Clivly so the dashboard reflects `running` vs
|
|
155
|
+
`succeeded` vs `failed` truthfully. `start()` emits a warning if it detects a
|
|
156
|
+
serverless runtime. Verify a connection from the terminal any time with
|
|
92
157
|
`npx clivly ping`.
|
|
93
158
|
|
|
94
159
|
### Persisting the delta cursor
|
|
@@ -118,3 +183,40 @@ const clivly = createClivlySDK({
|
|
|
118
183
|
Delta sync is **at-least-once**: after a cold start with no persisted cursor, rows since the
|
|
119
184
|
last push may be re-sent (the server dedupes by id) — it never silently skips rows. Use
|
|
120
185
|
`sync({ mode: "full" })` when you need the server to reconcile deletions (archive-on-leave).
|
|
186
|
+
|
|
187
|
+
## Many-to-many company membership
|
|
188
|
+
|
|
189
|
+
If a mapping links contacts to companies through a join table
|
|
190
|
+
(`via: { through: … }`), that entity's source must return **one row per
|
|
191
|
+
membership**, each row carrying the join table's foreign-key column.
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
// Ada belongs to two organizations: two rows, differing only in org_id.
|
|
195
|
+
{ id: "u1", full_name: "Ada", email: "ada@acme.test", org_id: "acme" }
|
|
196
|
+
{ id: "u1", full_name: "Ada", email: "ada@acme.test", org_id: "globex" }
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
This mirrors what the embedded model produces with `LEFT JOIN <join table>`, and
|
|
200
|
+
it is what lets Clivly keep one CRM row per membership instead of collapsing the
|
|
201
|
+
person onto whichever company happened to come first.
|
|
202
|
+
|
|
203
|
+
A person with no membership may omit the column entirely or send it as `null` —
|
|
204
|
+
both are accepted. But if **no** pushed row carries the column, the push is
|
|
205
|
+
rejected with `400 invalid_config`: silently keeping one membership would be
|
|
206
|
+
worse than failing, because the row counts look correct either way.
|
|
207
|
+
|
|
208
|
+
## Known limitations
|
|
209
|
+
|
|
210
|
+
- `start()` is for long-lived runtimes only. On serverless/edge, use
|
|
211
|
+
`runScheduled()` or `createClivlyHandler()`.
|
|
212
|
+
- `fromDrizzle` is the first-class generated source path today. Non-Drizzle hosts
|
|
213
|
+
should provide a hand-written `fetchPage`.
|
|
214
|
+
- The flat `contactEntity` / `contactFields` config still works but is deprecated
|
|
215
|
+
in favor of `defineClivlyConfig({ entities: ... })`.
|
|
216
|
+
|
|
217
|
+
## More docs
|
|
218
|
+
|
|
219
|
+
- Onboarding guide:
|
|
220
|
+
[`docs/guides/connecting-to-clivly-cloud.md`](../../docs/guides/connecting-to-clivly-cloud.md)
|
|
221
|
+
- CLI docs: [`../cli/README.md`](../cli/README.md)
|
|
222
|
+
- Release notes: [`../../CHANGELOG.md`](../../CHANGELOG.md)
|
package/dist/drizzle.cjs
CHANGED
|
@@ -1,7 +1,42 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_drizzle_meta = require("./drizzle-meta-
|
|
2
|
+
const require_drizzle_meta = require("./drizzle-meta-iAu8w1hj.cjs");
|
|
3
3
|
let drizzle_orm = require("drizzle-orm");
|
|
4
|
+
let drizzle_orm_pg_core = require("drizzle-orm/pg-core");
|
|
4
5
|
//#region src/drizzle.ts
|
|
6
|
+
function buildColumnsMeta(table) {
|
|
7
|
+
const columns = (0, drizzle_orm.getTableColumns)(table);
|
|
8
|
+
const fkByColumn = /* @__PURE__ */ new Map();
|
|
9
|
+
try {
|
|
10
|
+
for (const fk of (0, drizzle_orm_pg_core.getTableConfig)(table).foreignKeys) {
|
|
11
|
+
const ref = fk.reference();
|
|
12
|
+
const foreignTable = (0, drizzle_orm.getTableName)(ref.foreignTable);
|
|
13
|
+
ref.columns.forEach((col, i) => {
|
|
14
|
+
const foreignCol = ref.foreignColumns[i];
|
|
15
|
+
if (foreignCol) fkByColumn.set(col.name, {
|
|
16
|
+
table: foreignTable,
|
|
17
|
+
column: foreignCol.name
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
} catch {}
|
|
22
|
+
return Object.values(columns).map((col) => {
|
|
23
|
+
const references = fkByColumn.get(col.name);
|
|
24
|
+
let type;
|
|
25
|
+
try {
|
|
26
|
+
type = col.getSQLType();
|
|
27
|
+
} catch {
|
|
28
|
+
type = void 0;
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
name: col.name,
|
|
32
|
+
type,
|
|
33
|
+
nullable: !col.notNull,
|
|
34
|
+
isPrimaryKey: Boolean(col.primary),
|
|
35
|
+
isForeignKey: references !== void 0,
|
|
36
|
+
...references ? { references } : {}
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
}
|
|
5
40
|
function inferIdField(table) {
|
|
6
41
|
const columns = (0, drizzle_orm.getTableColumns)(table);
|
|
7
42
|
const primaryKeys = Object.entries(columns).filter(([, column]) => column.primary);
|
|
@@ -38,7 +73,9 @@ function fromDrizzle(db, table, opts) {
|
|
|
38
73
|
const meta = {
|
|
39
74
|
columnKeys: Object.keys(columns),
|
|
40
75
|
cursorField: opts.cursorField,
|
|
41
|
-
idField
|
|
76
|
+
idField,
|
|
77
|
+
tableName: (0, drizzle_orm.getTableName)(table),
|
|
78
|
+
columnsMeta: buildColumnsMeta(table)
|
|
42
79
|
};
|
|
43
80
|
Object.defineProperty(source, require_drizzle_meta.DRIZZLE_META, {
|
|
44
81
|
value: meta,
|
|
@@ -46,5 +83,25 @@ function fromDrizzle(db, table, opts) {
|
|
|
46
83
|
});
|
|
47
84
|
return source;
|
|
48
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Build a `NamespaceIntrospector` from any Drizzle client that exposes
|
|
88
|
+
* `.execute()`. Reads `information_schema` in the connection's default schema
|
|
89
|
+
* (`current_schema()`), so it reports exactly the tables the app can reach.
|
|
90
|
+
*/
|
|
91
|
+
function drizzleIntrospector(db) {
|
|
92
|
+
const client = db;
|
|
93
|
+
return {
|
|
94
|
+
async listTables() {
|
|
95
|
+
return (await client.execute(drizzle_orm.sql`
|
|
96
|
+
select table_name from information_schema.tables
|
|
97
|
+
where table_schema = current_schema()
|
|
98
|
+
`)).rows.map((r) => String(r.table_name));
|
|
99
|
+
},
|
|
100
|
+
async sampleSelect(table) {
|
|
101
|
+
await client.execute(drizzle_orm.sql`select 1 from ${drizzle_orm.sql.identifier(table)} limit 1`);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
49
105
|
//#endregion
|
|
106
|
+
exports.drizzleIntrospector = drizzleIntrospector;
|
|
50
107
|
exports.fromDrizzle = fromDrizzle;
|
package/dist/drizzle.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { d as SyncSource, t as NamespaceIntrospector } from "./namespace-probe-DIBgCQca.cjs";
|
|
2
2
|
import { Table } from "drizzle-orm";
|
|
3
3
|
|
|
4
4
|
//#region src/drizzle.d.ts
|
|
@@ -18,5 +18,11 @@ declare function fromDrizzle(db: DrizzleDb, table: Table, opts: {
|
|
|
18
18
|
cursorField: string;
|
|
19
19
|
idField?: string;
|
|
20
20
|
}): SyncSource;
|
|
21
|
+
/**
|
|
22
|
+
* Build a `NamespaceIntrospector` from any Drizzle client that exposes
|
|
23
|
+
* `.execute()`. Reads `information_schema` in the connection's default schema
|
|
24
|
+
* (`current_schema()`), so it reports exactly the tables the app can reach.
|
|
25
|
+
*/
|
|
26
|
+
declare function drizzleIntrospector(db: unknown): NamespaceIntrospector;
|
|
21
27
|
//#endregion
|
|
22
|
-
export { fromDrizzle };
|
|
28
|
+
export { drizzleIntrospector, fromDrizzle };
|
package/dist/drizzle.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { d as SyncSource, t as NamespaceIntrospector } from "./namespace-probe-DFpvfl7T.mjs";
|
|
2
2
|
import { Table } from "drizzle-orm";
|
|
3
3
|
|
|
4
4
|
//#region src/drizzle.d.ts
|
|
@@ -18,5 +18,11 @@ declare function fromDrizzle(db: DrizzleDb, table: Table, opts: {
|
|
|
18
18
|
cursorField: string;
|
|
19
19
|
idField?: string;
|
|
20
20
|
}): SyncSource;
|
|
21
|
+
/**
|
|
22
|
+
* Build a `NamespaceIntrospector` from any Drizzle client that exposes
|
|
23
|
+
* `.execute()`. Reads `information_schema` in the connection's default schema
|
|
24
|
+
* (`current_schema()`), so it reports exactly the tables the app can reach.
|
|
25
|
+
*/
|
|
26
|
+
declare function drizzleIntrospector(db: unknown): NamespaceIntrospector;
|
|
21
27
|
//#endregion
|
|
22
|
-
export { fromDrizzle };
|
|
28
|
+
export { drizzleIntrospector, fromDrizzle };
|
package/dist/drizzle.mjs
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
|
-
import { t as DRIZZLE_META } from "./drizzle-meta-
|
|
1
|
+
import { t as DRIZZLE_META } from "./drizzle-meta-DMx_9nlj.mjs";
|
|
2
2
|
import { asc, getTableColumns, getTableName, sql } from "drizzle-orm";
|
|
3
|
+
import { getTableConfig } from "drizzle-orm/pg-core";
|
|
3
4
|
//#region src/drizzle.ts
|
|
5
|
+
function buildColumnsMeta(table) {
|
|
6
|
+
const columns = getTableColumns(table);
|
|
7
|
+
const fkByColumn = /* @__PURE__ */ new Map();
|
|
8
|
+
try {
|
|
9
|
+
for (const fk of getTableConfig(table).foreignKeys) {
|
|
10
|
+
const ref = fk.reference();
|
|
11
|
+
const foreignTable = getTableName(ref.foreignTable);
|
|
12
|
+
ref.columns.forEach((col, i) => {
|
|
13
|
+
const foreignCol = ref.foreignColumns[i];
|
|
14
|
+
if (foreignCol) fkByColumn.set(col.name, {
|
|
15
|
+
table: foreignTable,
|
|
16
|
+
column: foreignCol.name
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
} catch {}
|
|
21
|
+
return Object.values(columns).map((col) => {
|
|
22
|
+
const references = fkByColumn.get(col.name);
|
|
23
|
+
let type;
|
|
24
|
+
try {
|
|
25
|
+
type = col.getSQLType();
|
|
26
|
+
} catch {
|
|
27
|
+
type = void 0;
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
name: col.name,
|
|
31
|
+
type,
|
|
32
|
+
nullable: !col.notNull,
|
|
33
|
+
isPrimaryKey: Boolean(col.primary),
|
|
34
|
+
isForeignKey: references !== void 0,
|
|
35
|
+
...references ? { references } : {}
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
}
|
|
4
39
|
function inferIdField(table) {
|
|
5
40
|
const columns = getTableColumns(table);
|
|
6
41
|
const primaryKeys = Object.entries(columns).filter(([, column]) => column.primary);
|
|
@@ -37,7 +72,9 @@ function fromDrizzle(db, table, opts) {
|
|
|
37
72
|
const meta = {
|
|
38
73
|
columnKeys: Object.keys(columns),
|
|
39
74
|
cursorField: opts.cursorField,
|
|
40
|
-
idField
|
|
75
|
+
idField,
|
|
76
|
+
tableName: getTableName(table),
|
|
77
|
+
columnsMeta: buildColumnsMeta(table)
|
|
41
78
|
};
|
|
42
79
|
Object.defineProperty(source, DRIZZLE_META, {
|
|
43
80
|
value: meta,
|
|
@@ -45,5 +82,24 @@ function fromDrizzle(db, table, opts) {
|
|
|
45
82
|
});
|
|
46
83
|
return source;
|
|
47
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Build a `NamespaceIntrospector` from any Drizzle client that exposes
|
|
87
|
+
* `.execute()`. Reads `information_schema` in the connection's default schema
|
|
88
|
+
* (`current_schema()`), so it reports exactly the tables the app can reach.
|
|
89
|
+
*/
|
|
90
|
+
function drizzleIntrospector(db) {
|
|
91
|
+
const client = db;
|
|
92
|
+
return {
|
|
93
|
+
async listTables() {
|
|
94
|
+
return (await client.execute(sql`
|
|
95
|
+
select table_name from information_schema.tables
|
|
96
|
+
where table_schema = current_schema()
|
|
97
|
+
`)).rows.map((r) => String(r.table_name));
|
|
98
|
+
},
|
|
99
|
+
async sampleSelect(table) {
|
|
100
|
+
await client.execute(sql`select 1 from ${sql.identifier(table)} limit 1`);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
48
104
|
//#endregion
|
|
49
|
-
export { fromDrizzle };
|
|
105
|
+
export { drizzleIntrospector, fromDrizzle };
|