@clivly/sdk 0.2.0 → 0.3.0-next.1

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,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 over
5
- outbound HTTPS only (no inbound ports, no tunnel).
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: "updated_at" }),
44
- companies: fromDrizzle(db, accounts, { entity: "accounts", cursorField: "updated_at" }),
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. Rows are keyed by
56
- your source column names; Clivly applies the field map you confirmed during onboarding, so
57
- re-mapping never needs a redeploy. Non-Drizzle hosts can still pass a hand-written
58
- `fetchPage` closure instead.
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` on success, `500` otherwise. `start()` emits a warning
91
- if it detects a serverless runtime. Verify a connection from the terminal any time with
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)
@@ -1,4 +1,4 @@
1
1
  //#region src/drizzle-meta.ts
2
- const DRIZZLE_META = Symbol("clivly.drizzleSourceMeta");
2
+ const DRIZZLE_META = "__clivly_drizzle_source_meta__";
3
3
  //#endregion
4
4
  export { DRIZZLE_META as t };
@@ -1,5 +1,5 @@
1
1
  //#region src/drizzle-meta.ts
2
- const DRIZZLE_META = Symbol("clivly.drizzleSourceMeta");
2
+ const DRIZZLE_META = "__clivly_drizzle_source_meta__";
3
3
  //#endregion
4
4
  Object.defineProperty(exports, "DRIZZLE_META", {
5
5
  enumerable: true,
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-C0T6F3vL.cjs");
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;
@@ -1,4 +1,4 @@
1
- import { u as SyncSource } from "./types-B0aSxJ6V.cjs";
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 };
@@ -1,4 +1,4 @@
1
- import { u as SyncSource } from "./types-DWt-ytBQ.mjs";
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-CAJ43cLc.mjs";
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 };