@clivly/core 0.8.0 → 0.9.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.
Files changed (48) hide show
  1. package/README.md +182 -16
  2. package/dist/drizzle.cjs +82 -1
  3. package/dist/drizzle.d.cts +23 -2
  4. package/dist/drizzle.d.mts +23 -2
  5. package/dist/drizzle.mjs +81 -2
  6. package/dist/entity-config-8Fgr6IOH.mjs +666 -0
  7. package/dist/entity-config-CLxI8dVb.d.cts +556 -0
  8. package/dist/entity-config-CLxI8dVb.d.mts +556 -0
  9. package/dist/entity-config-Dh8aEcgj.cjs +779 -0
  10. package/dist/entity-config.cjs +13 -349
  11. package/dist/entity-config.d.cts +2 -295
  12. package/dist/entity-config.d.mts +2 -295
  13. package/dist/entity-config.mjs +2 -339
  14. package/dist/entity-heuristics.cjs +28 -8
  15. package/dist/entity-heuristics.d.cts +2 -12
  16. package/dist/entity-heuristics.d.mts +2 -12
  17. package/dist/entity-heuristics.mjs +28 -8
  18. package/dist/env-file.cjs +59 -0
  19. package/dist/env-file.d.cts +23 -0
  20. package/dist/env-file.d.mts +23 -0
  21. package/dist/env-file.mjs +55 -0
  22. package/dist/index.cjs +83 -2
  23. package/dist/index.d.cts +72 -2
  24. package/dist/index.d.mts +72 -2
  25. package/dist/index.mjs +69 -3
  26. package/dist/integration-state.cjs +242 -0
  27. package/dist/integration-state.d.cts +88 -0
  28. package/dist/integration-state.d.mts +88 -0
  29. package/dist/integration-state.mjs +232 -0
  30. package/dist/mapping-form.d.cts +1 -1
  31. package/dist/mapping-form.d.mts +1 -1
  32. package/dist/mappings-config.cjs +32 -6
  33. package/dist/mappings-config.d.cts +7 -1
  34. package/dist/mappings-config.d.mts +7 -1
  35. package/dist/mappings-config.mjs +32 -6
  36. package/dist/projection-BXBkh7qp.cjs +98 -0
  37. package/dist/projection-C_EMRmZ8.mjs +75 -0
  38. package/dist/sync-engine.cjs +9 -2
  39. package/dist/sync-engine.d.cts +1 -1
  40. package/dist/sync-engine.d.mts +1 -1
  41. package/dist/sync-engine.mjs +9 -2
  42. package/dist/view-compiler-BpNI8Ttw.cjs +299 -0
  43. package/dist/view-compiler-CcqsGxv4.mjs +270 -0
  44. package/dist/view-compiler.cjs +6 -188
  45. package/dist/view-compiler.d.cts +12 -2
  46. package/dist/view-compiler.d.mts +12 -2
  47. package/dist/view-compiler.mjs +2 -185
  48. package/package.json +21 -1
package/README.md CHANGED
@@ -1,34 +1,114 @@
1
1
  # @clivly/core
2
2
 
3
- ORM- and auth-agnostic contracts and shared domain types for [Clivly](https://clivly.com) — the CRM that lives inside your app.
3
+ ORM- and auth-agnostic contracts and shared domain types for
4
+ [Clivly](https://clivly.com) — the CRM that lives inside your app.
4
5
 
5
- This package defines the interfaces that ORM and auth adapters implement. It has no runtime dependencies.
6
+ This package defines the interfaces that ORM and auth adapters implement, the
7
+ entity-config schema shared by the SDK and the dashboard's mapping table, and
8
+ the view compiler that turns a mapping into SQL. Its only runtime dependency is
9
+ `zod`.
10
+
11
+ > **Most integrators never install this directly.** `defineClivlyConfig` is
12
+ > re-exported from `clivly/sdk`, which is where the
13
+ > [integration guide](../../docs/guides/connecting-to-clivly-cloud.md) tells you
14
+ > to import it from. Reach for `@clivly/core` when you are building an adapter
15
+ > or working with the entity config outside an SDK instance.
16
+
17
+ ## Prerequisites
18
+
19
+ | Requirement | Version / note |
20
+ | --- | --- |
21
+ | Node.js | **≥ 22** |
22
+ | `drizzle-orm` | **Optional peer** — only needed for the `/drizzle` subpath |
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ npm install @clivly/core
28
+ ```
6
29
 
7
30
  ## Exports
8
31
 
32
+ Every subpath below is importable independently, so you pull in only what you
33
+ use. The root (`.`) re-exports the contracts, entity config, mapping form, sync
34
+ engine and view compiler — but **not** `/drizzle` (which needs the optional
35
+ peer), `/entity-heuristics`, `/mapping-score`, `/integration-state` or
36
+ `/env-file`.
37
+
9
38
  | Entry | Contents |
10
- |---|---|
11
- | `.` | Everything below, re-exported |
39
+ | --- | --- |
40
+ | `@clivly/core` | The re-exported surface: contracts, entity config, mapping form, sync engine, view compiler, projections, shared types |
12
41
  | `@clivly/core/adapter` | `CRMAdapter` — the interface every ORM implementation fulfils (org-scoped; contacts, companies, deals, tasks, activities) |
13
- | `@clivly/core/auth-adapter` | `ClivlyAuthAdapter`, `authCustom()`, `MembershipResolver` — pluggable host-app identity + the `crm_members` authorization layer |
42
+ | `@clivly/core/auth-adapter` | `ClivlyAuthAdapter`, `ClivlyUser`, `authCustom()`, `MembershipResolver`, `CrmMembership` — pluggable host-app identity + the `crm_members` authorization layer |
43
+ | `@clivly/core/entity-config` | `defineClivlyConfig`, `validateEntitiesConfig`, `entitiesConfigSchema`, `CANONICAL_FIELDS`, `CRM_CONCEPTS`, `ClivlyConfigError`, and the config types |
14
44
  | `@clivly/core/types` | Shared domain types (`Contact`, `Company`, `Deal`, `Task`, `Note`, `Activity`, inputs, filters, `CrmRole`) |
15
- | `@clivly/core/drizzle` | `discoverFromDrizzle(schema)` — derive the discovery shape from a Drizzle schema object. Requires the optional `drizzle-orm` peer dependency |
45
+ | `@clivly/core/drizzle` | `discoverFromDrizzle(schema)` — derive the discovery shape (including keys, types, nullability and FK edges) from a Drizzle schema object; plus `buildUniqueConstraints` / `buildColumnsMeta` for reflecting a single table. Requires the optional `drizzle-orm` peer |
46
+ | `@clivly/core/view-compiler` | `compileEntityView`, `compileEntityViews`, `explainEntitiesConfig`, `DEFAULT_VIEW_PREFIX` |
47
+ | `@clivly/core/mappings-config` | `mappingsToEntitiesConfig`, `EntityMappingRow` — turn `crm_entity_mappings` rows into an entities config |
48
+ | `@clivly/core/mapping-form` | `buildFilterFromDiscriminator`, `buildRelationships`, `relationshipViaSignature` — mapping-UI helpers |
49
+ | `@clivly/core/mapping-score` | Scoring helpers for ranking candidate mappings |
50
+ | `@clivly/core/entity-heuristics` | Table-name heuristics that guess which table holds contacts/companies |
51
+ | `@clivly/core/sync-engine` | `reconcile`, `runSync`, `SyncStore` and friends — the mirror reconciliation engine |
52
+ | `@clivly/core/integration-state` | `INSTALL_COMMAND` and the shared onboarding state machine |
53
+ | `@clivly/core/env-file` | `envFileFor(framework)` — the conventional env filename per framework |
54
+
55
+ ## Five-minute quick start
16
56
 
17
- ## Schema discovery (Drizzle)
57
+ ### Schema discovery (Drizzle)
18
58
 
19
- `discoverFromDrizzle` turns your Drizzle schema into Clivly's discovery shape, so you don't hand-author table/column arrays:
59
+ `discoverFromDrizzle` turns your Drizzle schema into Clivly's discovery shape,
60
+ so you don't hand-author table/column arrays:
20
61
 
21
62
  ```ts
22
63
  import { discoverFromDrizzle } from "@clivly/core/drizzle";
23
64
  import * as schema from "./db/schema";
24
65
 
25
66
  const tables = discoverFromDrizzle(schema);
26
- // → [{ name: "participants", columns: ["id", "full_name", "email", …] }, …]
67
+ // → [
68
+ // {
69
+ // name: "participants",
70
+ // columns: ["id", "full_name", "email", …],
71
+ // columnsMeta: [
72
+ // { name: "id", type: "uuid", nullable: false, isPrimaryKey: true, isForeignKey: false },
73
+ // { name: "org_id", type: "uuid", nullable: false, isPrimaryKey: false,
74
+ // isForeignKey: true, references: { table: "organizations", column: "id" } },
75
+ // …
76
+ // ],
77
+ // uniqueConstraints: [["id"], ["email"]],
78
+ // },
79
+ // …
80
+ // ]
81
+ ```
82
+
83
+ Column names are the **real DB names** (`full_name`), not the Drizzle property
84
+ keys (`fullName`).
85
+
86
+ `columnsMeta` and `uniqueConstraints` are what [derived
87
+ objects](#derived-objects-projections) validate against — without them a
88
+ projection cannot be authored, so keep this wired up rather than hand-writing
89
+ `{ name, columns }` pairs.
90
+
91
+ ### Defining an entity config
92
+
93
+ ```ts
94
+ import { defineClivlyConfig } from "@clivly/core/entity-config";
95
+
96
+ const entities = defineClivlyConfig({
97
+ entities: {
98
+ participants: {
99
+ concept: "contact",
100
+ source: "participants", // the host table name
101
+ fields: { name: "full_name", email: "email" }, // canonical key → source column
102
+ },
103
+ },
104
+ });
27
105
  ```
28
106
 
29
- Column names are the real DB names (`full_name`), not the Drizzle keys (`fullName`). `drizzle-orm` is an **optional peer dependency** — only needed when you import this subpath.
107
+ `defineClivlyConfig` validates at load and warns on any non-canonical field key
108
+ that would mirror as empty. `CANONICAL_FIELDS` lists the accepted keys per
109
+ concept; `CRM_CONCEPTS` lists the concepts.
30
110
 
31
- ## Usage
111
+ ### Implementing an adapter
32
112
 
33
113
  ```ts
34
114
  import type { CRMAdapter } from "@clivly/core";
@@ -39,9 +119,78 @@ function useAdapter(crm: CRMAdapter) {
39
119
  }
40
120
  ```
41
121
 
42
- ## The `$raw` escape hatch
122
+ ## Derived objects (projections)
43
123
 
44
- Entity `filter`s and relationship `via`s accept a `$raw` form — an opaque SQL fragment for predicates the operators can't express (e.g. a cross-table condition):
124
+ An ordinary entity maps one table. A **projection** derives a custom object from
125
+ a base table plus explicit joins, so an object that spans tables — an
126
+ `Enrollment` built from `enrollments`, `students` and `courses` — can be
127
+ expressed at all.
128
+
129
+ ```ts
130
+ import { validateProjection, type ProjectionMapping } from "@clivly/core";
131
+
132
+ const enrollment: ProjectionMapping = {
133
+ version: 1,
134
+ kind: "projection",
135
+ entityKey: "enrollment",
136
+ baseTable: "enrollments",
137
+ joins: [
138
+ {
139
+ key: "stu",
140
+ table: "students",
141
+ type: "left",
142
+ on: [{ left: { table: "stu", column: "id" }, op: "eq",
143
+ right: { table: "base", column: "student_id" } }],
144
+ },
145
+ ],
146
+ fieldBindings: {
147
+ student_name: { from: { table: "stu", column: "full_name" } },
148
+ term: { from: { table: "base", column: "term" } },
149
+ },
150
+ identity: { kind: "base_pk" },
151
+ };
152
+
153
+ const errors = validateProjection(enrollment, tables, "projection");
154
+ ```
155
+
156
+ Refer to the base table as `base` and each join by its `key`. Validation is
157
+ **fail-closed**: a table whose discovery payload lacks `columnsMeta` /
158
+ `uniqueConstraints` is rejected rather than assumed safe, because identity
159
+ uniqueness cannot be proven without them.
160
+
161
+ ### Fan-out
162
+
163
+ A join that does not cover a unique key of the joined table can match many rows
164
+ per base row. That is allowed — but the base table's primary key is then no
165
+ longer unique per output row, so validation enforces: at most one fan-out join,
166
+ no `base_pk` identity on a fanning join, and an identity that includes a unique
167
+ key of the fanning table. `fanOutJoins` reports which joins fan out.
168
+
169
+ Composite identities concatenate their parts with `::`, so every part must be
170
+ `NOT NULL` (`CONCAT_WS` skips nulls) and non-text (a value containing `::` could
171
+ collide).
172
+
173
+ ### Building one interactively
174
+
175
+ `ProjectionDraft` and its helpers exist so a builder UI stays presentational —
176
+ every rule is a pure function of draft + schema:
177
+
178
+ | Helper | Purpose |
179
+ | --- | --- |
180
+ | `emptyProjectionDraft(baseTable)` | A blank draft |
181
+ | `suggestJoinConditions(base, joinTable, schema, key)` | Pre-fill a join's `ON` from foreign-key metadata |
182
+ | `draftToProjection(draft)` | `ProjectionMapping`, or `null` while incomplete |
183
+ | `draftErrors(draft, schema)` | Validation errors for the current draft |
184
+ | `describeCardinality(draft, schema)` | Per-join `{ joinKey, fansOut }` for inline hints |
185
+
186
+ Full guide, including how to choose an identity and the current limits:
187
+ [`docs/guides/derived-objects.md`](https://github.com/amani-joseph/clivly.com/blob/master/docs/guides/derived-objects.md).
188
+
189
+ ## Advanced: the `$raw` escape hatch
190
+
191
+ Entity `filter`s and relationship `via`s accept a `$raw` form — an opaque SQL
192
+ fragment for predicates the operators can't express (e.g. a cross-table
193
+ condition):
45
194
 
46
195
  ```ts
47
196
  defineClivlyConfig({
@@ -56,14 +205,21 @@ defineClivlyConfig({
56
205
  });
57
206
  ```
58
207
 
59
- **`$raw` is unchecked.** `validateEntitiesConfig` deliberately **skips** `$raw` filters and joins — it can't know your columns are real or your SQL is valid. Two consequences:
208
+ **`$raw` is unchecked.** `validateEntitiesConfig` deliberately **skips** `$raw`
209
+ filters and joins — it can't know your columns are real or your SQL is valid.
210
+ Two consequences:
60
211
 
61
- - **Injection safety is on you.** Never interpolate untrusted input into a `$raw` string; treat it like handwritten SQL. Everything else in the config is escaped for you; `$raw` is not.
212
+ - **Injection safety is on you.** Never interpolate untrusted input into a
213
+ `$raw` string; treat it like handwritten SQL. Everything else in the config is
214
+ escaped for you; `$raw` is not.
62
215
  - **Mistakes surface at sync time, not config time** — unless you dry-run it.
63
216
 
64
217
  ### Dry-run with `explainEntitiesConfig`
65
218
 
66
- `explainEntitiesConfig(config, run, options?)` compiles each entity's view and executes it via a runner you provide, so a broken `$raw` fragment fails **before** the first sync. It never throws — it returns one `ExplainResult` per entity:
219
+ `explainEntitiesConfig(config, run, options?)` compiles each entity's view and
220
+ executes it via a runner you provide, so a broken `$raw` fragment fails
221
+ **before** the first sync. It never throws — it returns one `ExplainResult` per
222
+ entity:
67
223
 
68
224
  ```ts
69
225
  import { explainEntitiesConfig } from "@clivly/core";
@@ -81,6 +237,16 @@ for (const r of results) {
81
237
  }
82
238
  ```
83
239
 
240
+ `clivly status` runs the same mapping dry-run as one of its setup checks, so you
241
+ get this during normal verification without wiring it up yourself.
242
+
243
+ ## Related documentation
244
+
245
+ - **[Connecting to Clivly Cloud](../../docs/guides/connecting-to-clivly-cloud.md)** — the canonical end-to-end guide
246
+ - [`@clivly/sdk` README](../sdk/README.md) — the runtime that consumes this config
247
+ - [`clivly` CLI README](../cli/README.md) — every command and flag
248
+ - [`STABILITY.md`](../../STABILITY.md) — import tiers before 1.0
249
+
84
250
  ## License
85
251
 
86
252
  MIT
package/dist/drizzle.cjs CHANGED
@@ -1,7 +1,83 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let drizzle_orm = require("drizzle-orm");
3
+ let drizzle_orm_pg_core = require("drizzle-orm/pg-core");
3
4
  //#region src/drizzle.ts
4
5
  /**
6
+ * Every unique key on a table, as DB column-name tuples. Primary keys count —
7
+ * fan-out detection asks "do these join columns cover a unique key?", and a PK
8
+ * answers that as well as any explicit unique constraint.
9
+ *
10
+ * Returns `undefined` if reflection fails (non-pg table, or a drizzle version
11
+ * without config access), meaning "unknown, cannot determine". Returns `[]`
12
+ * when reflection succeeds and the table genuinely has none. That distinction
13
+ * is load-bearing: `hasRichMeta` uses it to tell "metadata unavailable" from
14
+ * "confirmed no constraints", and projection validation fails closed on the
15
+ * former.
16
+ */
17
+ function buildUniqueConstraints(table) {
18
+ const keys = [];
19
+ try {
20
+ const config = (0, drizzle_orm_pg_core.getTableConfig)(table);
21
+ for (const column of config.columns ?? []) if (column.isUnique || column.primary) keys.push([column.name]);
22
+ for (const pk of config.primaryKeys ?? []) keys.push(pk.columns.map((c) => c.name));
23
+ for (const constraint of config.uniqueConstraints ?? []) keys.push(constraint.columns.map((c) => c.name));
24
+ for (const index of config.indexes ?? []) {
25
+ if (!index.config?.unique) continue;
26
+ const columns = (index.config.columns ?? []).map((c) => c?.name).filter((name) => Boolean(name));
27
+ if (columns.length > 0) keys.push(columns);
28
+ }
29
+ } catch {
30
+ return;
31
+ }
32
+ const seen = /* @__PURE__ */ new Set();
33
+ return keys.filter((key) => {
34
+ const id = [...key].sort().join("\0");
35
+ if (seen.has(id) || key.length === 0) return false;
36
+ seen.add(id);
37
+ return true;
38
+ });
39
+ }
40
+ /**
41
+ * Per-column metadata (type, PK, nullability, FK edges) for a Drizzle table.
42
+ * Structural only — no DB round-trip, so it is safe to recompute on every
43
+ * heartbeat. FK reflection is best-effort; a non-Postgres table still gets
44
+ * name/type/PK/nullable.
45
+ */
46
+ function buildColumnsMeta(table) {
47
+ const columns = (0, drizzle_orm.getTableColumns)(table);
48
+ const fkByColumn = /* @__PURE__ */ new Map();
49
+ try {
50
+ for (const fk of (0, drizzle_orm_pg_core.getTableConfig)(table).foreignKeys) {
51
+ const ref = fk.reference();
52
+ const foreignTable = (0, drizzle_orm.getTableName)(ref.foreignTable);
53
+ ref.columns.forEach((col, index) => {
54
+ const foreignCol = ref.foreignColumns[index];
55
+ if (foreignCol) fkByColumn.set(col.name, {
56
+ table: foreignTable,
57
+ column: foreignCol.name
58
+ });
59
+ });
60
+ }
61
+ } catch {}
62
+ return Object.values(columns).map((col) => {
63
+ const references = fkByColumn.get(col.name);
64
+ let type;
65
+ try {
66
+ type = col.getSQLType();
67
+ } catch {
68
+ type = void 0;
69
+ }
70
+ return {
71
+ name: col.name,
72
+ type,
73
+ nullable: !col.notNull,
74
+ isPrimaryKey: Boolean(col.primary),
75
+ isForeignKey: references !== void 0,
76
+ ...references ? { references } : {}
77
+ };
78
+ });
79
+ }
80
+ /**
5
81
  * Derive Clivly's discovery shape (table + column names) from a Drizzle schema
6
82
  * object — the `* as schema` you pass to `drizzle(client, { schema })`. This
7
83
  * delivers the "reads your schema" promise for Drizzle hosts: no hand-authored
@@ -21,12 +97,17 @@ function discoverFromDrizzle(schema) {
21
97
  for (const value of Object.values(schema)) {
22
98
  if (!(0, drizzle_orm.is)(value, drizzle_orm.Table)) continue;
23
99
  const columns = (0, drizzle_orm.getTableColumns)(value);
100
+ const uniqueConstraints = buildUniqueConstraints(value);
24
101
  tables.push({
25
102
  name: (0, drizzle_orm.getTableName)(value),
26
- columns: Object.values(columns).map((column) => column.name)
103
+ columns: Object.values(columns).map((column) => column.name),
104
+ columnsMeta: buildColumnsMeta(value),
105
+ ...uniqueConstraints === void 0 ? {} : { uniqueConstraints }
27
106
  });
28
107
  }
29
108
  return tables;
30
109
  }
31
110
  //#endregion
111
+ exports.buildColumnsMeta = buildColumnsMeta;
112
+ exports.buildUniqueConstraints = buildUniqueConstraints;
32
113
  exports.discoverFromDrizzle = discoverFromDrizzle;
@@ -1,5 +1,26 @@
1
- import { DiscoveredSchemaTable } from "./entity-config.cjs";
1
+ import { f as DiscoveredColumnMeta, p as DiscoveredSchemaTable } from "./entity-config-CLxI8dVb.cjs";
2
+ import { Table } from "drizzle-orm";
2
3
  //#region src/drizzle.d.ts
4
+ /**
5
+ * Every unique key on a table, as DB column-name tuples. Primary keys count —
6
+ * fan-out detection asks "do these join columns cover a unique key?", and a PK
7
+ * answers that as well as any explicit unique constraint.
8
+ *
9
+ * Returns `undefined` if reflection fails (non-pg table, or a drizzle version
10
+ * without config access), meaning "unknown, cannot determine". Returns `[]`
11
+ * when reflection succeeds and the table genuinely has none. That distinction
12
+ * is load-bearing: `hasRichMeta` uses it to tell "metadata unavailable" from
13
+ * "confirmed no constraints", and projection validation fails closed on the
14
+ * former.
15
+ */
16
+ declare function buildUniqueConstraints(table: unknown): string[][] | undefined;
17
+ /**
18
+ * Per-column metadata (type, PK, nullability, FK edges) for a Drizzle table.
19
+ * Structural only — no DB round-trip, so it is safe to recompute on every
20
+ * heartbeat. FK reflection is best-effort; a non-Postgres table still gets
21
+ * name/type/PK/nullable.
22
+ */
23
+ declare function buildColumnsMeta(table: Table): DiscoveredColumnMeta[];
3
24
  /**
4
25
  * Derive Clivly's discovery shape (table + column names) from a Drizzle schema
5
26
  * object — the `* as schema` you pass to `drizzle(client, { schema })`. This
@@ -17,4 +38,4 @@ import { DiscoveredSchemaTable } from "./entity-config.cjs";
17
38
  */
18
39
  declare function discoverFromDrizzle(schema: Record<string, unknown>): DiscoveredSchemaTable[];
19
40
  //#endregion
20
- export { discoverFromDrizzle };
41
+ export { buildColumnsMeta, buildUniqueConstraints, discoverFromDrizzle };
@@ -1,5 +1,26 @@
1
- import { DiscoveredSchemaTable } from "./entity-config.mjs";
1
+ import { f as DiscoveredColumnMeta, p as DiscoveredSchemaTable } from "./entity-config-CLxI8dVb.mjs";
2
+ import { Table } from "drizzle-orm";
2
3
  //#region src/drizzle.d.ts
4
+ /**
5
+ * Every unique key on a table, as DB column-name tuples. Primary keys count —
6
+ * fan-out detection asks "do these join columns cover a unique key?", and a PK
7
+ * answers that as well as any explicit unique constraint.
8
+ *
9
+ * Returns `undefined` if reflection fails (non-pg table, or a drizzle version
10
+ * without config access), meaning "unknown, cannot determine". Returns `[]`
11
+ * when reflection succeeds and the table genuinely has none. That distinction
12
+ * is load-bearing: `hasRichMeta` uses it to tell "metadata unavailable" from
13
+ * "confirmed no constraints", and projection validation fails closed on the
14
+ * former.
15
+ */
16
+ declare function buildUniqueConstraints(table: unknown): string[][] | undefined;
17
+ /**
18
+ * Per-column metadata (type, PK, nullability, FK edges) for a Drizzle table.
19
+ * Structural only — no DB round-trip, so it is safe to recompute on every
20
+ * heartbeat. FK reflection is best-effort; a non-Postgres table still gets
21
+ * name/type/PK/nullable.
22
+ */
23
+ declare function buildColumnsMeta(table: Table): DiscoveredColumnMeta[];
3
24
  /**
4
25
  * Derive Clivly's discovery shape (table + column names) from a Drizzle schema
5
26
  * object — the `* as schema` you pass to `drizzle(client, { schema })`. This
@@ -17,4 +38,4 @@ import { DiscoveredSchemaTable } from "./entity-config.mjs";
17
38
  */
18
39
  declare function discoverFromDrizzle(schema: Record<string, unknown>): DiscoveredSchemaTable[];
19
40
  //#endregion
20
- export { discoverFromDrizzle };
41
+ export { buildColumnsMeta, buildUniqueConstraints, discoverFromDrizzle };
package/dist/drizzle.mjs CHANGED
@@ -1,6 +1,82 @@
1
1
  import { Table, getTableColumns, getTableName, is } from "drizzle-orm";
2
+ import { getTableConfig } from "drizzle-orm/pg-core";
2
3
  //#region src/drizzle.ts
3
4
  /**
5
+ * Every unique key on a table, as DB column-name tuples. Primary keys count —
6
+ * fan-out detection asks "do these join columns cover a unique key?", and a PK
7
+ * answers that as well as any explicit unique constraint.
8
+ *
9
+ * Returns `undefined` if reflection fails (non-pg table, or a drizzle version
10
+ * without config access), meaning "unknown, cannot determine". Returns `[]`
11
+ * when reflection succeeds and the table genuinely has none. That distinction
12
+ * is load-bearing: `hasRichMeta` uses it to tell "metadata unavailable" from
13
+ * "confirmed no constraints", and projection validation fails closed on the
14
+ * former.
15
+ */
16
+ function buildUniqueConstraints(table) {
17
+ const keys = [];
18
+ try {
19
+ const config = getTableConfig(table);
20
+ for (const column of config.columns ?? []) if (column.isUnique || column.primary) keys.push([column.name]);
21
+ for (const pk of config.primaryKeys ?? []) keys.push(pk.columns.map((c) => c.name));
22
+ for (const constraint of config.uniqueConstraints ?? []) keys.push(constraint.columns.map((c) => c.name));
23
+ for (const index of config.indexes ?? []) {
24
+ if (!index.config?.unique) continue;
25
+ const columns = (index.config.columns ?? []).map((c) => c?.name).filter((name) => Boolean(name));
26
+ if (columns.length > 0) keys.push(columns);
27
+ }
28
+ } catch {
29
+ return;
30
+ }
31
+ const seen = /* @__PURE__ */ new Set();
32
+ return keys.filter((key) => {
33
+ const id = [...key].sort().join("\0");
34
+ if (seen.has(id) || key.length === 0) return false;
35
+ seen.add(id);
36
+ return true;
37
+ });
38
+ }
39
+ /**
40
+ * Per-column metadata (type, PK, nullability, FK edges) for a Drizzle table.
41
+ * Structural only — no DB round-trip, so it is safe to recompute on every
42
+ * heartbeat. FK reflection is best-effort; a non-Postgres table still gets
43
+ * name/type/PK/nullable.
44
+ */
45
+ function buildColumnsMeta(table) {
46
+ const columns = getTableColumns(table);
47
+ const fkByColumn = /* @__PURE__ */ new Map();
48
+ try {
49
+ for (const fk of getTableConfig(table).foreignKeys) {
50
+ const ref = fk.reference();
51
+ const foreignTable = getTableName(ref.foreignTable);
52
+ ref.columns.forEach((col, index) => {
53
+ const foreignCol = ref.foreignColumns[index];
54
+ if (foreignCol) fkByColumn.set(col.name, {
55
+ table: foreignTable,
56
+ column: foreignCol.name
57
+ });
58
+ });
59
+ }
60
+ } catch {}
61
+ return Object.values(columns).map((col) => {
62
+ const references = fkByColumn.get(col.name);
63
+ let type;
64
+ try {
65
+ type = col.getSQLType();
66
+ } catch {
67
+ type = void 0;
68
+ }
69
+ return {
70
+ name: col.name,
71
+ type,
72
+ nullable: !col.notNull,
73
+ isPrimaryKey: Boolean(col.primary),
74
+ isForeignKey: references !== void 0,
75
+ ...references ? { references } : {}
76
+ };
77
+ });
78
+ }
79
+ /**
4
80
  * Derive Clivly's discovery shape (table + column names) from a Drizzle schema
5
81
  * object — the `* as schema` you pass to `drizzle(client, { schema })`. This
6
82
  * delivers the "reads your schema" promise for Drizzle hosts: no hand-authored
@@ -20,12 +96,15 @@ function discoverFromDrizzle(schema) {
20
96
  for (const value of Object.values(schema)) {
21
97
  if (!is(value, Table)) continue;
22
98
  const columns = getTableColumns(value);
99
+ const uniqueConstraints = buildUniqueConstraints(value);
23
100
  tables.push({
24
101
  name: getTableName(value),
25
- columns: Object.values(columns).map((column) => column.name)
102
+ columns: Object.values(columns).map((column) => column.name),
103
+ columnsMeta: buildColumnsMeta(value),
104
+ ...uniqueConstraints === void 0 ? {} : { uniqueConstraints }
26
105
  });
27
106
  }
28
107
  return tables;
29
108
  }
30
109
  //#endregion
31
- export { discoverFromDrizzle };
110
+ export { buildColumnsMeta, buildUniqueConstraints, discoverFromDrizzle };