# @clivly/core

ORM- and auth-agnostic contracts and shared domain types for
[Clivly](https://clivly.com) — the CRM that lives inside your app.

This package defines the interfaces that ORM and auth adapters implement, the
entity-config schema shared by the SDK and the dashboard's mapping table, and
the view compiler that turns a mapping into SQL. Its only runtime dependency is
`zod`.

> **Most integrators never install this directly.** `defineClivlyConfig` is
> re-exported from `clivly/sdk`, which is where the
> [integration guide](../../docs/guides/connecting-to-clivly-cloud.md) tells you
> to import it from. Reach for `@clivly/core` when you are building an adapter
> or working with the entity config outside an SDK instance.

Connection and presence transport live in `@clivly/sdk`, not this package.
That includes the opt-in WebSocket presence transport and its HTTP fallback.

## Prerequisites

| Requirement | Version / note |
| --- | --- |
| Node.js | **≥ 22** |
| `drizzle-orm` | **Optional peer** — only needed for the `/drizzle` subpath |

## Installation

```bash
npm install @clivly/core
```

## Exports

Every subpath below is importable independently, so you pull in only what you
use. The root (`.`) re-exports the contracts, entity config, mapping form, sync
engine and view compiler — but **not** `/drizzle` (which needs the optional
peer), `/entity-heuristics`, `/mapping-score` or `/env-file`.

`/integration-state` is a deliberate half-exception: the root re-exports its
**types only** (`IntegrationState`, `IntegrationStep`, `IntegrationFacts`,
`IntegrationStatus`, `ConnectionLiveness`, `NextAction`, `StepId`, `StepState`,
`StepProblem`), so a consumer can describe a setup ladder without reaching for
the subpath. `computeIntegrationState` stays behind `/integration-state`, so
there is one implementation of the ladder rather than a second one growing in a
client.

| Entry | Contents |
| --- | --- |
| `@clivly/core` | The re-exported surface: contracts, entity config, mapping form, sync engine, view compiler, projections, shared types |
| `@clivly/core/adapter` | `CRMAdapter` — the interface every ORM implementation fulfils (org-scoped; contacts, companies, deals, tasks, activities) |
| `@clivly/core/auth-adapter` | `ClivlyAuthAdapter`, `ClivlyUser`, `authCustom()`, `MembershipResolver`, `CrmMembership` — pluggable host-app identity + the `crm_members` authorization layer |
| `@clivly/core/entity-config` | `defineClivlyConfig`, `validateEntitiesConfig`, `entitiesConfigSchema`, `CANONICAL_FIELDS`, `CRM_CONCEPTS`, `ClivlyConfigError`, and the config types |
| `@clivly/core/types` | Shared domain types (`Contact`, `Company`, `Deal`, `Task`, `Note`, `Activity`, inputs, filters, `CrmRole`) |
| `@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 |
| `@clivly/core/view-compiler` | `compileEntityView`, `compileEntityViews`, `explainEntitiesConfig`, `DEFAULT_VIEW_PREFIX` |
| `@clivly/core/mappings-config` | `mappingsToEntitiesConfig`, `EntityMappingRow` — turn `crm_entity_mappings` rows into an entities config; `entitiesToMappings`, `MappingFromConfig` — the inverse, turning a config into the mapping rows `clivly push-schema` writes |
| `@clivly/core/mapping-form` | `buildFilterFromDiscriminator`, `buildRelationships`, `relationshipViaSignature` — mapping-UI helpers |
| `@clivly/core/mapping-score` | Scoring helpers for ranking candidate mappings |
| `@clivly/core/entity-heuristics` | Table-name heuristics that guess which table holds contacts/companies |
| `@clivly/core/sync-engine` | `reconcile`, `runSync`, `SyncStore` and friends — the mirror reconciliation engine |
| `@clivly/core/integration-state` | `INSTALL_COMMAND` and the shared onboarding state machine |
| `@clivly/core/env-file` | `envFileFor(framework)` — the conventional env filename per framework |

## Five-minute quick start

### Schema discovery (Drizzle)

`discoverFromDrizzle` turns your Drizzle schema into Clivly's discovery shape,
so you don't hand-author table/column arrays:

```ts
import { discoverFromDrizzle } from "@clivly/core/drizzle";
import * as schema from "./db/schema";

const tables = discoverFromDrizzle(schema);
// → [
//     {
//       name: "participants",
//       columns: ["id", "full_name", "email", …],
//       columnsMeta: [
//         { name: "id", type: "uuid", nullable: false, isPrimaryKey: true, isForeignKey: false },
//         { name: "org_id", type: "uuid", nullable: false, isPrimaryKey: false,
//           isForeignKey: true, references: { table: "organizations", column: "id" } },
//         …
//       ],
//       uniqueConstraints: [["id"], ["email"]],
//     },
//     …
//   ]
```

Column names are the **real DB names** (`full_name`), not the Drizzle property
keys (`fullName`).

`columnsMeta` and `uniqueConstraints` are what [derived
objects](#derived-objects-projections) validate against — without them a
projection cannot be authored, so keep this wired up rather than hand-writing
`{ name, columns }` pairs.

### Defining an entity config

```ts
import { defineClivlyConfig } from "@clivly/core/entity-config";

const entities = defineClivlyConfig({
  entities: {
    participants: {
      concept: "contact",
      source: "participants",                        // the host table name
      fields: { name: "full_name", email: "email" }, // canonical key → source column
    },
  },
});
```

`defineClivlyConfig` validates at load and warns on any non-canonical field key
that would mirror as empty. `CANONICAL_FIELDS` lists the accepted keys per
concept; `CRM_CONCEPTS` lists the concepts.

### Implementing an adapter

```ts
import type { CRMAdapter } from "@clivly/core";

// packages/drizzle implements this; Prisma/Kysely can follow.
function useAdapter(crm: CRMAdapter) {
  return crm.getContacts(orgId);
}
```

## Derived objects (projections)

An ordinary entity maps one table. A **projection** derives a custom object from
a base table plus explicit joins, so an object that spans tables — an
`Enrollment` built from `enrollments`, `students` and `courses` — can be
expressed at all.

```ts
import { validateProjection, type ProjectionMapping } from "@clivly/core";

const enrollment: ProjectionMapping = {
  version: 1,
  kind: "projection",
  entityKey: "enrollment",
  baseTable: "enrollments",
  joins: [
    {
      key: "stu",
      table: "students",
      type: "left",
      on: [{ left: { table: "stu", column: "id" }, op: "eq",
             right: { table: "base", column: "student_id" } }],
    },
  ],
  fieldBindings: {
    student_name: { from: { table: "stu", column: "full_name" } },
    term: { from: { table: "base", column: "term" } },
  },
  identity: { kind: "base_pk" },
};

const errors = validateProjection(enrollment, tables, "projection");
```

Refer to the base table as `base` and each join by its `key`. Validation is
**fail-closed**: a table whose discovery payload lacks `columnsMeta` /
`uniqueConstraints` is rejected rather than assumed safe, because identity
uniqueness cannot be proven without them.

`validateProjection` is also the SDK's guard when it runs an object authored in
Clivly's dashboard: the definition arrives over the network, and the host
revalidates it **against the schema the host itself discovered** before any SQL
is compiled. Because `checkTablesExist` and `checkRefs` bound every table and
column reference to the schema array you pass in, that call is what limits a
remote definition to the namespace the developer handed to `discoverFromDrizzle`
— pass a different schema and you move that boundary.

Since `0.9.7`, **every** identity kind must be provably unique, not just
composite ones: a single-column identity is rejected unless a unique key covers
it. The identity is what the sync pages by and what decides which rows survive a
snapshot, so an unproven one can silently skip and then archive real records.

### Fan-out

A join that does not cover a unique key of the joined table can match many rows
per base row. That is allowed — but the base table's primary key is then no
longer unique per output row, so validation enforces: at most one fan-out join,
no `base_pk` identity on a fanning join, and an identity that includes a unique
key of the fanning table. `fanOutJoins` reports which joins fan out.

Composite identities concatenate their parts with `::`, so every part must be
`NOT NULL` (`CONCAT_WS` skips nulls) and non-text (a value containing `::` could
collide).

### Building one interactively

The builder engine lives on the **`@clivly/core/builder`** subpath, deliberately
kept off the root barrel so the published `clivly` package does not pull it into
the CLI's declaration emit.

It exists so a builder UI stays *presentational*: the UI renders and collects,
and every structural judgement — what can start a list, what is reachable from
there, what a change costs — is a pure function of schema + picks answered here.
A component that walks the graph or classifies cardinality itself is a bug.

| Helper | Purpose |
| --- | --- |
| `listStartingRecordOptions(schema)` | What can be the "one row per…" of a list, each offerable or not with a machine-readable reason (no primary key, composite key, thin discovery metadata) |
| `buildRelationshipGraph(schema)` / `edgesFrom(graph, table)` | A **bidirectional** graph: a table's own foreign keys (child → parent) *and* every other table's keys pointing at it (parent → child) |
| `enumeratePaths(graph, startTable, options?)` | Ranked routes outward from a starting record, each flagged `isCollection` when it crosses a to-many edge. Budgeted by `MAX_HOPS` / `MAX_CANDIDATES` / `MAX_RESULTS`, and it reports *why* it truncated |
| `compilePaths({schema, startTable, pickedFields, entityKey})` | Picked fields → a validated `ProjectionMapping`, allocating join keys and bindings. Throws `PathCompileError` for a shape it cannot express |
| `classifyStartingRecordChange({schema, from, to, picks})` | Which picks survive changing the starting record, which are lost, and how many connections fall away. Survivors come back **repointed** to routes from the new starting record |

Reachability is *directional*, which is why that last one cannot be approximated
by intersecting path keys: a field reachable as a single value from one starting
record can be a collection from another, and a collection is not pickable at all.

`compileProjectionPreview(projection, limit)` compiles the same object to a
capped, read-only `SELECT` — used by both sides of the dashboard's preview, so
an embedded org and a cloud org's host app cannot disagree about what the user
is being shown.

Full guide, including how to choose an identity and the current limits:
[`docs/guides/derived-objects.md`](https://github.com/amani-joseph/clivly.com/blob/master/docs/guides/derived-objects.md).

## Advanced: the `$raw` escape hatch

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):

```ts
defineClivlyConfig({
  entities: {
    owner: {
      concept: "contact",
      source: "users",
      fields: { name: "full_name", email: "email" },
      filter: { $raw: "role = 'owner' AND deleted_at IS NULL" },
    },
  },
});
```

**`$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:

- **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.
- **Mistakes surface at sync time, not config time** — unless you dry-run it.

### Dry-run with `explainEntitiesConfig`

`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:

```ts
import { explainEntitiesConfig } from "@clivly/core";

const results = await explainEntitiesConfig(config, (sql) =>
  // Run somewhere side-effect-free — e.g. a transaction you roll back.
  db.transaction(async (tx) => {
    await tx.execute(sql);
    throw new Rollback();
  }).catch(swallowRollback)
);

for (const r of results) {
  if (!r.ok) console.error(`${r.entityKey}: ${r.error}`);
}
```

`clivly status` runs the same mapping dry-run as one of its setup checks, so you
get this during normal verification without wiring it up yourself.

## Related documentation

- **[Connecting to Clivly Cloud](../../docs/guides/connecting-to-clivly-cloud.md)** — the canonical end-to-end guide
- [`@clivly/sdk` README](../sdk/README.md) — the runtime that consumes this config
- [`clivly` CLI README](../cli/README.md) — every command and flag
- [`STABILITY.md`](../../STABILITY.md) — import tiers before 1.0

## License

MIT
