@clivly/core 0.9.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 (38) hide show
  1. package/README.md +88 -3
  2. package/dist/drizzle.d.cts +1 -1
  3. package/dist/drizzle.d.mts +1 -1
  4. package/dist/{entity-config-Tb5bbKLj.mjs → entity-config-8Fgr6IOH.mjs} +47 -2
  5. package/dist/entity-config-CLxI8dVb.d.cts +556 -0
  6. package/dist/entity-config-CLxI8dVb.d.mts +556 -0
  7. package/dist/{entity-config-PoOGzvpN.cjs → entity-config-Dh8aEcgj.cjs} +52 -1
  8. package/dist/entity-config.cjs +2 -1
  9. package/dist/entity-config.d.cts +2 -414
  10. package/dist/entity-config.d.mts +2 -414
  11. package/dist/entity-config.mjs +2 -2
  12. package/dist/entity-heuristics.cjs +1 -1
  13. package/dist/entity-heuristics.d.cts +1 -1
  14. package/dist/entity-heuristics.d.mts +1 -1
  15. package/dist/entity-heuristics.mjs +1 -1
  16. package/dist/index.cjs +2 -2
  17. package/dist/index.d.cts +2 -2
  18. package/dist/index.d.mts +2 -2
  19. package/dist/index.mjs +2 -2
  20. package/dist/mapping-form.d.cts +1 -1
  21. package/dist/mapping-form.d.mts +1 -1
  22. package/dist/mappings-config.cjs +1 -1
  23. package/dist/mappings-config.d.cts +46 -1
  24. package/dist/mappings-config.d.mts +46 -1
  25. package/dist/mappings-config.mjs +1 -1
  26. package/dist/sync-engine.cjs +1 -1
  27. package/dist/sync-engine.d.cts +1 -1
  28. package/dist/sync-engine.d.mts +1 -1
  29. package/dist/sync-engine.mjs +1 -1
  30. package/dist/{view-compiler-BwZ9UgLp.cjs → view-compiler-BpNI8Ttw.cjs} +22 -0
  31. package/dist/{view-compiler-BKrBA_II.mjs → view-compiler-CcqsGxv4.mjs} +17 -1
  32. package/dist/view-compiler.cjs +2 -1
  33. package/dist/view-compiler.d.cts +12 -2
  34. package/dist/view-compiler.d.mts +12 -2
  35. package/dist/view-compiler.mjs +2 -2
  36. package/package.json +1 -1
  37. package/dist/mappings-config-B0W5bVZj.d.mts +0 -168
  38. package/dist/mappings-config-BWo4cRDF.d.cts +0 -168
package/README.md CHANGED
@@ -37,12 +37,12 @@ peer), `/entity-heuristics`, `/mapping-score`, `/integration-state` or
37
37
 
38
38
  | Entry | Contents |
39
39
  | --- | --- |
40
- | `@clivly/core` | The re-exported surface: contracts, entity config, mapping form, sync engine, view compiler, shared types |
40
+ | `@clivly/core` | The re-exported surface: contracts, entity config, mapping form, sync engine, view compiler, projections, shared types |
41
41
  | `@clivly/core/adapter` | `CRMAdapter` — the interface every ORM implementation fulfils (org-scoped; contacts, companies, deals, tasks, activities) |
42
42
  | `@clivly/core/auth-adapter` | `ClivlyAuthAdapter`, `ClivlyUser`, `authCustom()`, `MembershipResolver`, `CrmMembership` — pluggable host-app identity + the `crm_members` authorization layer |
43
43
  | `@clivly/core/entity-config` | `defineClivlyConfig`, `validateEntitiesConfig`, `entitiesConfigSchema`, `CANONICAL_FIELDS`, `CRM_CONCEPTS`, `ClivlyConfigError`, and the config types |
44
44
  | `@clivly/core/types` | Shared domain types (`Contact`, `Company`, `Deal`, `Task`, `Note`, `Activity`, inputs, filters, `CrmRole`) |
45
- | `@clivly/core/drizzle` | `discoverFromDrizzle(schema)` — derive the discovery shape from a Drizzle schema object. Requires the optional `drizzle-orm` peer |
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
46
  | `@clivly/core/view-compiler` | `compileEntityView`, `compileEntityViews`, `explainEntitiesConfig`, `DEFAULT_VIEW_PREFIX` |
47
47
  | `@clivly/core/mappings-config` | `mappingsToEntitiesConfig`, `EntityMappingRow` — turn `crm_entity_mappings` rows into an entities config |
48
48
  | `@clivly/core/mapping-form` | `buildFilterFromDiscriminator`, `buildRelationships`, `relationshipViaSignature` — mapping-UI helpers |
@@ -64,12 +64,30 @@ import { discoverFromDrizzle } from "@clivly/core/drizzle";
64
64
  import * as schema from "./db/schema";
65
65
 
66
66
  const tables = discoverFromDrizzle(schema);
67
- // → [{ 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
+ // ]
68
81
  ```
69
82
 
70
83
  Column names are the **real DB names** (`full_name`), not the Drizzle property
71
84
  keys (`fullName`).
72
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
+
73
91
  ### Defining an entity config
74
92
 
75
93
  ```ts
@@ -101,6 +119,73 @@ function useAdapter(crm: CRMAdapter) {
101
119
  }
102
120
  ```
103
121
 
122
+ ## Derived objects (projections)
123
+
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
+
104
189
  ## Advanced: the `$raw` escape hatch
105
190
 
106
191
  Entity `filter`s and relationship `via`s accept a `$raw` form — an opaque SQL
@@ -1,4 +1,4 @@
1
- import { DiscoveredColumnMeta, DiscoveredSchemaTable } from "./entity-config.cjs";
1
+ import { f as DiscoveredColumnMeta, p as DiscoveredSchemaTable } from "./entity-config-CLxI8dVb.cjs";
2
2
  import { Table } from "drizzle-orm";
3
3
  //#region src/drizzle.d.ts
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { DiscoveredColumnMeta, DiscoveredSchemaTable } from "./entity-config.mjs";
1
+ import { f as DiscoveredColumnMeta, p as DiscoveredSchemaTable } from "./entity-config-CLxI8dVb.mjs";
2
2
  import { Table } from "drizzle-orm";
3
3
  //#region src/drizzle.d.ts
4
4
  /**
@@ -494,8 +494,53 @@ function defaultWarn(message) {
494
494
  * a `ClivlyConfigError` instead. Does NOT validate against the host schema — that
495
495
  * needs the discovered schema; see `validateEntitiesConfig`.
496
496
  */
497
+ /**
498
+ * The `fields` entry a projection binding implies, qualified as `ref.column`.
499
+ * Qualified rather than bare: two bindings can pull the same column name from
500
+ * different tables (`cl.full_name` and `adv.full_name`), which a bare column
501
+ * name cannot tell apart.
502
+ */
503
+ function projectionFields(projection) {
504
+ const fields = {};
505
+ for (const [name, binding] of Object.entries(projection.fieldBindings)) fields[name] = `${binding.from.table}.${binding.from.column}`;
506
+ return fields;
507
+ }
508
+ /**
509
+ * `fields` duplicates `projection.fieldBindings`, and only the bindings are
510
+ * compiled — so a hand-written `fields` that drifts is both silent and
511
+ * invisible. Derive it when omitted, and reject it when it disagrees.
512
+ */
513
+ function normalizeProjectionFields(config) {
514
+ const entities = {};
515
+ for (const [key, entity] of Object.entries(config.entities ?? {})) {
516
+ const projection = entity?.projection;
517
+ if (!projection?.fieldBindings) {
518
+ entities[key] = entity;
519
+ continue;
520
+ }
521
+ const derived = projectionFields(projection);
522
+ const declared = entity.fields;
523
+ if (!declared) {
524
+ entities[key] = {
525
+ ...entity,
526
+ fields: derived
527
+ };
528
+ continue;
529
+ }
530
+ const drifted = [.../* @__PURE__ */ new Set([...Object.keys(derived), ...Object.keys(declared)])].filter((name) => declared[name] !== derived[name]).sort();
531
+ if (drifted.length > 0) {
532
+ const detail = drifted.map((name) => `"${name}": fields has ${declared[name] === void 0 ? "no entry" : `"${declared[name]}"`}, bindings imply ${derived[name] === void 0 ? "no entry" : `"${derived[name]}"`}`).join("; ");
533
+ throw new Error(`Clivly config: entity "${key}" has a \`fields\` map that disagrees with \`projection.fieldBindings\` — ${detail}. Only the bindings are compiled, so the \`fields\` copy would be silently ignored. Omit \`fields\` entirely and it will be derived.`);
534
+ }
535
+ entities[key] = entity;
536
+ }
537
+ return {
538
+ ...config,
539
+ entities
540
+ };
541
+ }
497
542
  function defineClivlyConfig(config, options) {
498
- const parsed = entitiesConfigSchema.parse(config);
543
+ const parsed = entitiesConfigSchema.parse(normalizeProjectionFields(config));
499
544
  const issues = canonicalFieldIssues(parsed);
500
545
  if (issues.length > 0) {
501
546
  if (options?.strict) throw new ClivlyConfigError(issues);
@@ -618,4 +663,4 @@ function validateEntitiesConfig(config, schema) {
618
663
  };
619
664
  }
620
665
  //#endregion
621
- export { hasRichMeta as _, MATERIALIZING_CONCEPTS as a, filterSchema as c, validateEntitiesConfig as d, validateProjection as f, coversUniqueKey as g, columnMetaFor as h, ClivlyConfigError as i, relationshipSchema as l, joinFansOut as m, CRM_CONCEPTS as n, defineClivlyConfig as o, fanOutJoins as p, CUSTOM_CONCEPT as r, entitiesConfigSchema as s, CANONICAL_FIELDS as t, relationshipsSchema as u, primaryKeyColumns as v };
666
+ export { coversUniqueKey as _, MATERIALIZING_CONCEPTS as a, filterSchema as c, relationshipsSchema as d, validateEntitiesConfig as f, columnMetaFor as g, joinFansOut as h, ClivlyConfigError as i, projectionFields as l, fanOutJoins as m, CRM_CONCEPTS as n, defineClivlyConfig as o, validateProjection as p, CUSTOM_CONCEPT as r, entitiesConfigSchema as s, CANONICAL_FIELDS as t, relationshipSchema as u, hasRichMeta as v, primaryKeyColumns as y };