@clivly/core 0.7.2 → 0.7.4
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 +112 -50
- package/dist/entity-heuristics.cjs +27 -7
- package/dist/entity-heuristics.mjs +27 -7
- package/dist/env-file.cjs +59 -0
- package/dist/env-file.d.cts +23 -0
- package/dist/env-file.d.mts +23 -0
- package/dist/env-file.mjs +55 -0
- package/dist/integration-state.cjs +242 -0
- package/dist/integration-state.d.cts +88 -0
- package/dist/integration-state.d.mts +88 -0
- package/dist/integration-state.mjs +232 -0
- package/package.json +21 -1
package/README.md
CHANGED
|
@@ -1,77 +1,111 @@
|
|
|
1
1
|
# @clivly/core
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
ORM- and auth-agnostic contracts and shared domain types for
|
|
4
|
+
[Clivly](https://clivly.com) — the CRM that lives inside your app.
|
|
5
5
|
|
|
6
|
-
|
|
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`.
|
|
7
10
|
|
|
8
|
-
Most
|
|
9
|
-
`clivly/sdk
|
|
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.
|
|
10
16
|
|
|
11
|
-
|
|
17
|
+
## Prerequisites
|
|
12
18
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
It has a small runtime surface centered on `zod`. `drizzle-orm` is only needed
|
|
18
|
-
when you use the Drizzle discovery subpath.
|
|
19
|
+
| Requirement | Version / note |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| Node.js | **≥ 22** |
|
|
22
|
+
| `drizzle-orm` | **Optional peer** — only needed for the `/drizzle` subpath |
|
|
19
23
|
|
|
20
|
-
##
|
|
24
|
+
## Installation
|
|
21
25
|
|
|
22
26
|
```bash
|
|
23
27
|
npm install @clivly/core
|
|
24
28
|
```
|
|
25
29
|
|
|
26
|
-
Optional Drizzle integration:
|
|
27
|
-
|
|
28
|
-
```bash
|
|
29
|
-
npm install @clivly/core drizzle-orm
|
|
30
|
-
```
|
|
31
|
-
|
|
32
30
|
## Exports
|
|
33
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
|
+
|
|
34
38
|
| Entry | Contents |
|
|
35
39
|
| --- | --- |
|
|
36
|
-
| `@clivly/core` |
|
|
37
|
-
| `@clivly/core/adapter` | `CRMAdapter` |
|
|
38
|
-
| `@clivly/core/auth-adapter` | `ClivlyAuthAdapter`, `authCustom()`, `MembershipResolver` |
|
|
39
|
-
| `@clivly/core/
|
|
40
|
-
| `@clivly/core/
|
|
41
|
-
| `@clivly/core/
|
|
42
|
-
| `@clivly/core/view-compiler` |
|
|
43
|
-
| `@clivly/core/
|
|
44
|
-
| `@clivly/core/
|
|
45
|
-
| `@clivly/core/mapping-score` | Scoring
|
|
46
|
-
| `@clivly/core/
|
|
47
|
-
| `@clivly/core/
|
|
48
|
-
|
|
49
|
-
|
|
40
|
+
| `@clivly/core` | The re-exported surface: contracts, entity config, mapping form, sync engine, view compiler, shared types |
|
|
41
|
+
| `@clivly/core/adapter` | `CRMAdapter` — the interface every ORM implementation fulfils (org-scoped; contacts, companies, deals, tasks, activities) |
|
|
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 |
|
|
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 |
|
|
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
|
|
56
|
+
|
|
57
|
+
### Schema discovery (Drizzle)
|
|
58
|
+
|
|
59
|
+
`discoverFromDrizzle` turns your Drizzle schema into Clivly's discovery shape,
|
|
60
|
+
so you don't hand-author table/column arrays:
|
|
50
61
|
|
|
51
62
|
```ts
|
|
52
63
|
import { discoverFromDrizzle } from "@clivly/core/drizzle";
|
|
53
64
|
import * as schema from "./db/schema";
|
|
54
65
|
|
|
55
66
|
const tables = discoverFromDrizzle(schema);
|
|
67
|
+
// → [{ name: "participants", columns: ["id", "full_name", "email", …] }, …]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Column names are the **real DB names** (`full_name`), not the Drizzle property
|
|
71
|
+
keys (`fullName`).
|
|
72
|
+
|
|
73
|
+
### Defining an entity config
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { defineClivlyConfig } from "@clivly/core/entity-config";
|
|
77
|
+
|
|
78
|
+
const entities = defineClivlyConfig({
|
|
79
|
+
entities: {
|
|
80
|
+
participants: {
|
|
81
|
+
concept: "contact",
|
|
82
|
+
source: "participants", // the host table name
|
|
83
|
+
fields: { name: "full_name", email: "email" }, // canonical key → source column
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
});
|
|
56
87
|
```
|
|
57
88
|
|
|
58
|
-
|
|
59
|
-
|
|
89
|
+
`defineClivlyConfig` validates at load and warns on any non-canonical field key
|
|
90
|
+
that would mirror as empty. `CANONICAL_FIELDS` lists the accepted keys per
|
|
91
|
+
concept; `CRM_CONCEPTS` lists the concepts.
|
|
60
92
|
|
|
61
|
-
|
|
93
|
+
### Implementing an adapter
|
|
62
94
|
|
|
63
95
|
```ts
|
|
64
|
-
import type { CRMAdapter } from "@clivly/core
|
|
96
|
+
import type { CRMAdapter } from "@clivly/core";
|
|
65
97
|
|
|
98
|
+
// packages/drizzle implements this; Prisma/Kysely can follow.
|
|
66
99
|
function useAdapter(crm: CRMAdapter) {
|
|
67
|
-
return crm.getContacts(
|
|
100
|
+
return crm.getContacts(orgId);
|
|
68
101
|
}
|
|
69
102
|
```
|
|
70
103
|
|
|
71
|
-
## `$raw` escape hatch
|
|
104
|
+
## Advanced: the `$raw` escape hatch
|
|
72
105
|
|
|
73
|
-
Entity `filter`s and relationship `via`s accept a `$raw`
|
|
74
|
-
the
|
|
106
|
+
Entity `filter`s and relationship `via`s accept a `$raw` form — an opaque SQL
|
|
107
|
+
fragment for predicates the operators can't express (e.g. a cross-table
|
|
108
|
+
condition):
|
|
75
109
|
|
|
76
110
|
```ts
|
|
77
111
|
defineClivlyConfig({
|
|
@@ -86,19 +120,47 @@ defineClivlyConfig({
|
|
|
86
120
|
});
|
|
87
121
|
```
|
|
88
122
|
|
|
89
|
-
|
|
123
|
+
**`$raw` is unchecked.** `validateEntitiesConfig` deliberately **skips** `$raw`
|
|
124
|
+
filters and joins — it can't know your columns are real or your SQL is valid.
|
|
125
|
+
Two consequences:
|
|
90
126
|
|
|
91
|
-
-
|
|
92
|
-
|
|
127
|
+
- **Injection safety is on you.** Never interpolate untrusted input into a
|
|
128
|
+
`$raw` string; treat it like handwritten SQL. Everything else in the config is
|
|
129
|
+
escaped for you; `$raw` is not.
|
|
130
|
+
- **Mistakes surface at sync time, not config time** — unless you dry-run it.
|
|
131
|
+
|
|
132
|
+
### Dry-run with `explainEntitiesConfig`
|
|
133
|
+
|
|
134
|
+
`explainEntitiesConfig(config, run, options?)` compiles each entity's view and
|
|
135
|
+
executes it via a runner you provide, so a broken `$raw` fragment fails
|
|
136
|
+
**before** the first sync. It never throws — it returns one `ExplainResult` per
|
|
137
|
+
entity:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { explainEntitiesConfig } from "@clivly/core";
|
|
141
|
+
|
|
142
|
+
const results = await explainEntitiesConfig(config, (sql) =>
|
|
143
|
+
// Run somewhere side-effect-free — e.g. a transaction you roll back.
|
|
144
|
+
db.transaction(async (tx) => {
|
|
145
|
+
await tx.execute(sql);
|
|
146
|
+
throw new Rollback();
|
|
147
|
+
}).catch(swallowRollback)
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
for (const r of results) {
|
|
151
|
+
if (!r.ok) console.error(`${r.entityKey}: ${r.error}`);
|
|
152
|
+
}
|
|
153
|
+
```
|
|
93
154
|
|
|
94
|
-
|
|
155
|
+
`clivly status` runs the same mapping dry-run as one of its setup checks, so you
|
|
156
|
+
get this during normal verification without wiring it up yourself.
|
|
95
157
|
|
|
96
|
-
##
|
|
158
|
+
## Related documentation
|
|
97
159
|
|
|
98
|
-
-
|
|
99
|
-
|
|
100
|
-
-
|
|
101
|
-
|
|
160
|
+
- **[Connecting to Clivly Cloud](../../docs/guides/connecting-to-clivly-cloud.md)** — the canonical end-to-end guide
|
|
161
|
+
- [`@clivly/sdk` README](../sdk/README.md) — the runtime that consumes this config
|
|
162
|
+
- [`clivly` CLI README](../cli/README.md) — every command and flag
|
|
163
|
+
- [`STABILITY.md`](../../STABILITY.md) — import tiers before 1.0
|
|
102
164
|
|
|
103
165
|
## License
|
|
104
166
|
|
|
@@ -101,7 +101,10 @@ const CONCEPT_FIELDS = {
|
|
|
101
101
|
"name",
|
|
102
102
|
"full_name",
|
|
103
103
|
"fullname",
|
|
104
|
-
"display_name"
|
|
104
|
+
"display_name",
|
|
105
|
+
"first_name",
|
|
106
|
+
"firstname",
|
|
107
|
+
"given_name"
|
|
105
108
|
],
|
|
106
109
|
required: false
|
|
107
110
|
}, {
|
|
@@ -224,12 +227,28 @@ function suggestCursorField(columnKeys) {
|
|
|
224
227
|
return null;
|
|
225
228
|
}
|
|
226
229
|
const MATERIALIZING = new Set(require_entity_config.MATERIALIZING_CONCEPTS);
|
|
230
|
+
const ENDS_IN_CONSONANT_Y = /[^aeiou]y$/;
|
|
231
|
+
const NEEDS_ES = /(s|x|z|ch|sh)$/;
|
|
232
|
+
const IRREGULAR_PLURALS = {
|
|
233
|
+
person: "people",
|
|
234
|
+
child: "children",
|
|
235
|
+
man: "men",
|
|
236
|
+
woman: "women"
|
|
237
|
+
};
|
|
238
|
+
function pluralize(name) {
|
|
239
|
+
const irregular = IRREGULAR_PLURALS[name];
|
|
240
|
+
if (irregular) return irregular;
|
|
241
|
+
if (ENDS_IN_CONSONANT_Y.test(name)) return `${name.slice(0, -1)}ies`;
|
|
242
|
+
if (NEEDS_ES.test(name)) return `${name}es`;
|
|
243
|
+
return `${name}s`;
|
|
244
|
+
}
|
|
227
245
|
function matchesConcept(tableName, concept) {
|
|
228
246
|
const normalized = tableName.toLowerCase();
|
|
229
247
|
const entry = TABLE_PATTERNS.find((p) => p.concept === concept);
|
|
230
248
|
if (!entry) return null;
|
|
231
|
-
|
|
232
|
-
if (
|
|
249
|
+
const candidates = [normalized, pluralize(normalized)];
|
|
250
|
+
if (candidates.some((candidate) => entry.names.includes(candidate))) return { exact: true };
|
|
251
|
+
if (entry.names.some((name) => candidates.some((candidate) => candidate.includes(name)))) return { exact: false };
|
|
233
252
|
return null;
|
|
234
253
|
}
|
|
235
254
|
function metaFor(table, column) {
|
|
@@ -245,17 +264,18 @@ function scoreConcepts(table) {
|
|
|
245
264
|
for (const concept of TARGET_CONCEPTS) {
|
|
246
265
|
if (!MATERIALIZING.has(concept)) continue;
|
|
247
266
|
const nameMatch = matchesConcept(table.name, concept);
|
|
248
|
-
|
|
267
|
+
const definingColumns = hasDefiningColumn(concept, table.columns);
|
|
268
|
+
if (!(nameMatch || definingColumns)) continue;
|
|
249
269
|
let score = 0;
|
|
250
270
|
const reasons = [];
|
|
251
|
-
if (nameMatch
|
|
271
|
+
if (nameMatch?.exact) {
|
|
252
272
|
score += require_mapping_score.W.nameExact;
|
|
253
273
|
reasons.push(`name matches "${table.name}"`);
|
|
254
|
-
} else {
|
|
274
|
+
} else if (nameMatch) {
|
|
255
275
|
score += require_mapping_score.W.nameSubstring;
|
|
256
276
|
reasons.push(`name looks like ${concept}`);
|
|
257
277
|
}
|
|
258
|
-
if (
|
|
278
|
+
if (definingColumns) {
|
|
259
279
|
score += require_mapping_score.W.definingColumn;
|
|
260
280
|
reasons.push(`has the columns a ${concept} needs`);
|
|
261
281
|
}
|
|
@@ -100,7 +100,10 @@ const CONCEPT_FIELDS = {
|
|
|
100
100
|
"name",
|
|
101
101
|
"full_name",
|
|
102
102
|
"fullname",
|
|
103
|
-
"display_name"
|
|
103
|
+
"display_name",
|
|
104
|
+
"first_name",
|
|
105
|
+
"firstname",
|
|
106
|
+
"given_name"
|
|
104
107
|
],
|
|
105
108
|
required: false
|
|
106
109
|
}, {
|
|
@@ -223,12 +226,28 @@ function suggestCursorField(columnKeys) {
|
|
|
223
226
|
return null;
|
|
224
227
|
}
|
|
225
228
|
const MATERIALIZING = new Set(MATERIALIZING_CONCEPTS);
|
|
229
|
+
const ENDS_IN_CONSONANT_Y = /[^aeiou]y$/;
|
|
230
|
+
const NEEDS_ES = /(s|x|z|ch|sh)$/;
|
|
231
|
+
const IRREGULAR_PLURALS = {
|
|
232
|
+
person: "people",
|
|
233
|
+
child: "children",
|
|
234
|
+
man: "men",
|
|
235
|
+
woman: "women"
|
|
236
|
+
};
|
|
237
|
+
function pluralize(name) {
|
|
238
|
+
const irregular = IRREGULAR_PLURALS[name];
|
|
239
|
+
if (irregular) return irregular;
|
|
240
|
+
if (ENDS_IN_CONSONANT_Y.test(name)) return `${name.slice(0, -1)}ies`;
|
|
241
|
+
if (NEEDS_ES.test(name)) return `${name}es`;
|
|
242
|
+
return `${name}s`;
|
|
243
|
+
}
|
|
226
244
|
function matchesConcept(tableName, concept) {
|
|
227
245
|
const normalized = tableName.toLowerCase();
|
|
228
246
|
const entry = TABLE_PATTERNS.find((p) => p.concept === concept);
|
|
229
247
|
if (!entry) return null;
|
|
230
|
-
|
|
231
|
-
if (
|
|
248
|
+
const candidates = [normalized, pluralize(normalized)];
|
|
249
|
+
if (candidates.some((candidate) => entry.names.includes(candidate))) return { exact: true };
|
|
250
|
+
if (entry.names.some((name) => candidates.some((candidate) => candidate.includes(name)))) return { exact: false };
|
|
232
251
|
return null;
|
|
233
252
|
}
|
|
234
253
|
function metaFor(table, column) {
|
|
@@ -244,17 +263,18 @@ function scoreConcepts(table) {
|
|
|
244
263
|
for (const concept of TARGET_CONCEPTS) {
|
|
245
264
|
if (!MATERIALIZING.has(concept)) continue;
|
|
246
265
|
const nameMatch = matchesConcept(table.name, concept);
|
|
247
|
-
|
|
266
|
+
const definingColumns = hasDefiningColumn(concept, table.columns);
|
|
267
|
+
if (!(nameMatch || definingColumns)) continue;
|
|
248
268
|
let score = 0;
|
|
249
269
|
const reasons = [];
|
|
250
|
-
if (nameMatch
|
|
270
|
+
if (nameMatch?.exact) {
|
|
251
271
|
score += W.nameExact;
|
|
252
272
|
reasons.push(`name matches "${table.name}"`);
|
|
253
|
-
} else {
|
|
273
|
+
} else if (nameMatch) {
|
|
254
274
|
score += W.nameSubstring;
|
|
255
275
|
reasons.push(`name looks like ${concept}`);
|
|
256
276
|
}
|
|
257
|
-
if (
|
|
277
|
+
if (definingColumns) {
|
|
258
278
|
score += W.definingColumn;
|
|
259
279
|
reasons.push(`has the columns a ${concept} needs`);
|
|
260
280
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/env-file.ts
|
|
3
|
+
const ENV_FRAMEWORKS = [
|
|
4
|
+
"next",
|
|
5
|
+
"tanstack-start",
|
|
6
|
+
"sveltekit",
|
|
7
|
+
"remix",
|
|
8
|
+
"nuxt",
|
|
9
|
+
"hono-workers"
|
|
10
|
+
];
|
|
11
|
+
const ENV_FILES = {
|
|
12
|
+
next: ".env.local",
|
|
13
|
+
"tanstack-start": ".env",
|
|
14
|
+
sveltekit: ".env",
|
|
15
|
+
remix: ".env",
|
|
16
|
+
nuxt: ".env",
|
|
17
|
+
"hono-workers": ".dev.vars"
|
|
18
|
+
};
|
|
19
|
+
/** The conventional env file for a framework. `.env` when we do not know. */
|
|
20
|
+
function envFileFor(framework) {
|
|
21
|
+
return ENV_FILES[normalizeFramework(framework)] ?? ".env";
|
|
22
|
+
}
|
|
23
|
+
const ALIASES = {
|
|
24
|
+
next: "next",
|
|
25
|
+
nextjs: "next",
|
|
26
|
+
"next.js": "next",
|
|
27
|
+
"tanstack-start": "tanstack-start",
|
|
28
|
+
tanstack: "tanstack-start",
|
|
29
|
+
sveltekit: "sveltekit",
|
|
30
|
+
remix: "remix",
|
|
31
|
+
nuxt: "nuxt",
|
|
32
|
+
"hono-workers": "hono-workers",
|
|
33
|
+
hono: "hono-workers",
|
|
34
|
+
workers: "hono-workers"
|
|
35
|
+
};
|
|
36
|
+
/** The canonical id for a framework name, or null when unrecognised. */
|
|
37
|
+
function normalizeFramework(framework) {
|
|
38
|
+
if (!framework) return null;
|
|
39
|
+
return ALIASES[framework.toLowerCase()] ?? null;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Which env file to actually write to.
|
|
43
|
+
*
|
|
44
|
+
* Prefers a file that already holds the key over the conventional one: a
|
|
45
|
+
* project that has been running with credentials in `.env` must not suddenly
|
|
46
|
+
* get a second copy in `.env.local`, leaving two files disagreeing and the
|
|
47
|
+
* stale one still loading. Convention applies to new setups, not to a working
|
|
48
|
+
* one someone already has.
|
|
49
|
+
*/
|
|
50
|
+
function resolveEnvFile(input) {
|
|
51
|
+
const conventional = envFileFor(input.framework);
|
|
52
|
+
if (input.existing.includes(conventional)) return conventional;
|
|
53
|
+
return input.existing[0] ?? conventional;
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
exports.ENV_FRAMEWORKS = ENV_FRAMEWORKS;
|
|
57
|
+
exports.envFileFor = envFileFor;
|
|
58
|
+
exports.normalizeFramework = normalizeFramework;
|
|
59
|
+
exports.resolveEnvFile = resolveEnvFile;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region src/env-file.d.ts
|
|
2
|
+
declare const ENV_FRAMEWORKS: readonly ["next", "tanstack-start", "sveltekit", "remix", "nuxt", "hono-workers"];
|
|
3
|
+
type EnvFramework = (typeof ENV_FRAMEWORKS)[number];
|
|
4
|
+
/** The conventional env file for a framework. `.env` when we do not know. */
|
|
5
|
+
declare function envFileFor(framework: string | null | undefined): string;
|
|
6
|
+
/** The canonical id for a framework name, or null when unrecognised. */
|
|
7
|
+
declare function normalizeFramework(framework: string | null | undefined): EnvFramework | null;
|
|
8
|
+
/**
|
|
9
|
+
* Which env file to actually write to.
|
|
10
|
+
*
|
|
11
|
+
* Prefers a file that already holds the key over the conventional one: a
|
|
12
|
+
* project that has been running with credentials in `.env` must not suddenly
|
|
13
|
+
* get a second copy in `.env.local`, leaving two files disagreeing and the
|
|
14
|
+
* stale one still loading. Convention applies to new setups, not to a working
|
|
15
|
+
* one someone already has.
|
|
16
|
+
*/
|
|
17
|
+
declare function resolveEnvFile(input: {
|
|
18
|
+
/** Candidate filenames that already contain the credential, if any. */
|
|
19
|
+
existing: string[];
|
|
20
|
+
framework: string | null | undefined;
|
|
21
|
+
}): string;
|
|
22
|
+
//#endregion
|
|
23
|
+
export { ENV_FRAMEWORKS, EnvFramework, envFileFor, normalizeFramework, resolveEnvFile };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region src/env-file.d.ts
|
|
2
|
+
declare const ENV_FRAMEWORKS: readonly ["next", "tanstack-start", "sveltekit", "remix", "nuxt", "hono-workers"];
|
|
3
|
+
type EnvFramework = (typeof ENV_FRAMEWORKS)[number];
|
|
4
|
+
/** The conventional env file for a framework. `.env` when we do not know. */
|
|
5
|
+
declare function envFileFor(framework: string | null | undefined): string;
|
|
6
|
+
/** The canonical id for a framework name, or null when unrecognised. */
|
|
7
|
+
declare function normalizeFramework(framework: string | null | undefined): EnvFramework | null;
|
|
8
|
+
/**
|
|
9
|
+
* Which env file to actually write to.
|
|
10
|
+
*
|
|
11
|
+
* Prefers a file that already holds the key over the conventional one: a
|
|
12
|
+
* project that has been running with credentials in `.env` must not suddenly
|
|
13
|
+
* get a second copy in `.env.local`, leaving two files disagreeing and the
|
|
14
|
+
* stale one still loading. Convention applies to new setups, not to a working
|
|
15
|
+
* one someone already has.
|
|
16
|
+
*/
|
|
17
|
+
declare function resolveEnvFile(input: {
|
|
18
|
+
/** Candidate filenames that already contain the credential, if any. */
|
|
19
|
+
existing: string[];
|
|
20
|
+
framework: string | null | undefined;
|
|
21
|
+
}): string;
|
|
22
|
+
//#endregion
|
|
23
|
+
export { ENV_FRAMEWORKS, EnvFramework, envFileFor, normalizeFramework, resolveEnvFile };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
//#region src/env-file.ts
|
|
2
|
+
const ENV_FRAMEWORKS = [
|
|
3
|
+
"next",
|
|
4
|
+
"tanstack-start",
|
|
5
|
+
"sveltekit",
|
|
6
|
+
"remix",
|
|
7
|
+
"nuxt",
|
|
8
|
+
"hono-workers"
|
|
9
|
+
];
|
|
10
|
+
const ENV_FILES = {
|
|
11
|
+
next: ".env.local",
|
|
12
|
+
"tanstack-start": ".env",
|
|
13
|
+
sveltekit: ".env",
|
|
14
|
+
remix: ".env",
|
|
15
|
+
nuxt: ".env",
|
|
16
|
+
"hono-workers": ".dev.vars"
|
|
17
|
+
};
|
|
18
|
+
/** The conventional env file for a framework. `.env` when we do not know. */
|
|
19
|
+
function envFileFor(framework) {
|
|
20
|
+
return ENV_FILES[normalizeFramework(framework)] ?? ".env";
|
|
21
|
+
}
|
|
22
|
+
const ALIASES = {
|
|
23
|
+
next: "next",
|
|
24
|
+
nextjs: "next",
|
|
25
|
+
"next.js": "next",
|
|
26
|
+
"tanstack-start": "tanstack-start",
|
|
27
|
+
tanstack: "tanstack-start",
|
|
28
|
+
sveltekit: "sveltekit",
|
|
29
|
+
remix: "remix",
|
|
30
|
+
nuxt: "nuxt",
|
|
31
|
+
"hono-workers": "hono-workers",
|
|
32
|
+
hono: "hono-workers",
|
|
33
|
+
workers: "hono-workers"
|
|
34
|
+
};
|
|
35
|
+
/** The canonical id for a framework name, or null when unrecognised. */
|
|
36
|
+
function normalizeFramework(framework) {
|
|
37
|
+
if (!framework) return null;
|
|
38
|
+
return ALIASES[framework.toLowerCase()] ?? null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Which env file to actually write to.
|
|
42
|
+
*
|
|
43
|
+
* Prefers a file that already holds the key over the conventional one: a
|
|
44
|
+
* project that has been running with credentials in `.env` must not suddenly
|
|
45
|
+
* get a second copy in `.env.local`, leaving two files disagreeing and the
|
|
46
|
+
* stale one still loading. Convention applies to new setups, not to a working
|
|
47
|
+
* one someone already has.
|
|
48
|
+
*/
|
|
49
|
+
function resolveEnvFile(input) {
|
|
50
|
+
const conventional = envFileFor(input.framework);
|
|
51
|
+
if (input.existing.includes(conventional)) return conventional;
|
|
52
|
+
return input.existing[0] ?? conventional;
|
|
53
|
+
}
|
|
54
|
+
//#endregion
|
|
55
|
+
export { ENV_FRAMEWORKS, envFileFor, normalizeFramework, resolveEnvFile };
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/integration-state.ts
|
|
3
|
+
const INTEGRATION_STEPS = [
|
|
4
|
+
"credential",
|
|
5
|
+
"application",
|
|
6
|
+
"schema",
|
|
7
|
+
"mapping",
|
|
8
|
+
"trigger",
|
|
9
|
+
"sync",
|
|
10
|
+
"widget"
|
|
11
|
+
];
|
|
12
|
+
const INTEGRATION_STEP_LABELS = {
|
|
13
|
+
credential: "Secure credential",
|
|
14
|
+
application: "Application connected",
|
|
15
|
+
schema: "Schema discovered",
|
|
16
|
+
mapping: "Entities mapped",
|
|
17
|
+
trigger: "Clivly can start a sync",
|
|
18
|
+
sync: "First sync observed",
|
|
19
|
+
widget: "Chat widget installed"
|
|
20
|
+
};
|
|
21
|
+
/** Each step's prerequisites. Ordering is enforced by a test, not convention. */
|
|
22
|
+
const STEP_PREREQUISITES = {
|
|
23
|
+
credential: [],
|
|
24
|
+
application: ["credential"],
|
|
25
|
+
schema: ["application"],
|
|
26
|
+
mapping: ["schema"],
|
|
27
|
+
trigger: ["application"],
|
|
28
|
+
sync: ["mapping", "trigger"],
|
|
29
|
+
widget: ["application"]
|
|
30
|
+
};
|
|
31
|
+
function stepIndex(id) {
|
|
32
|
+
return INTEGRATION_STEPS.indexOf(id);
|
|
33
|
+
}
|
|
34
|
+
/** A heartbeat older than this means the app stopped checking in. */
|
|
35
|
+
const HEARTBEAT_STALE_MS = 600 * 1e3;
|
|
36
|
+
function formatAge(ms) {
|
|
37
|
+
if (ms < 6e4) return `${Math.max(1, Math.round(ms / 1e3))}s ago`;
|
|
38
|
+
if (ms < 36e5) return `${Math.round(ms / 6e4)}m ago`;
|
|
39
|
+
return `${Math.round(ms / 36e5)}h ago`;
|
|
40
|
+
}
|
|
41
|
+
function heartbeatAge(facts) {
|
|
42
|
+
return facts.lastHeartbeatAt === null ? null : facts.now - facts.lastHeartbeatAt;
|
|
43
|
+
}
|
|
44
|
+
/** Is the raw condition behind each step met, ignoring prerequisites? */
|
|
45
|
+
function isSatisfied(id, facts) {
|
|
46
|
+
const age = heartbeatAge(facts);
|
|
47
|
+
switch (id) {
|
|
48
|
+
case "credential": return facts.hasCredential;
|
|
49
|
+
case "application": return age !== null && age < HEARTBEAT_STALE_MS;
|
|
50
|
+
case "schema": return facts.discoveredTableCount > 0;
|
|
51
|
+
case "mapping": return facts.mappedEntityCount > 0;
|
|
52
|
+
case "trigger": return facts.triggerConfigured;
|
|
53
|
+
case "sync": return facts.lastRun?.ok === true;
|
|
54
|
+
default: return facts.widgetInstalled;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function evidenceFor(id, facts) {
|
|
58
|
+
const age = heartbeatAge(facts);
|
|
59
|
+
if (id === "application" && age !== null) return `heartbeat ${formatAge(age)}`;
|
|
60
|
+
if (id === "schema" && facts.discoveredTableCount > 0) return `found ${facts.discoveredTableCount} tables`;
|
|
61
|
+
if (id === "mapping" && facts.mappedEntityCount > 0) return `${facts.mappedEntityCount} entity mapped`;
|
|
62
|
+
if (id === "trigger" && facts.triggerConfigured) return "trigger URL and secret set";
|
|
63
|
+
if (id === "sync" && facts.lastRun) return `last run ${formatAge(facts.now - facts.lastRun.at)}`;
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
function problemFor(id, facts) {
|
|
67
|
+
if (id === "sync" && facts.lastRun && !facts.lastRun.ok) return {
|
|
68
|
+
code: "sync_failed",
|
|
69
|
+
message: facts.lastRun.message ?? "The last sync run failed.",
|
|
70
|
+
fix: "Open the run log to see the host response."
|
|
71
|
+
};
|
|
72
|
+
if (id === "application") {
|
|
73
|
+
const age = heartbeatAge(facts);
|
|
74
|
+
if (age !== null && age >= HEARTBEAT_STALE_MS) return {
|
|
75
|
+
code: "heartbeat_stale",
|
|
76
|
+
message: `Your app stopped checking in ${formatAge(age)}.`,
|
|
77
|
+
fix: "Start your app, then run `npx clivly status`."
|
|
78
|
+
};
|
|
79
|
+
if (age === null && facts.everConnected) return {
|
|
80
|
+
code: "heartbeat_stale",
|
|
81
|
+
message: "Your application stopped checking in.",
|
|
82
|
+
fix: "Start your app, then run `npx clivly status`."
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Walk the ladder once. A step is `blocked` when an earlier step it needs has
|
|
89
|
+
* not been satisfied — a prerequisite statement, not a verdict about this step
|
|
90
|
+
* — and only the first unsatisfied step is `active`, so exactly one root cause
|
|
91
|
+
* ever surfaces.
|
|
92
|
+
*/
|
|
93
|
+
function resolveSteps(facts) {
|
|
94
|
+
const satisfied = /* @__PURE__ */ new Set();
|
|
95
|
+
const steps = [];
|
|
96
|
+
let activeTaken = false;
|
|
97
|
+
for (const id of INTEGRATION_STEPS) {
|
|
98
|
+
const unmet = STEP_PREREQUISITES[id].find((need) => !satisfied.has(need));
|
|
99
|
+
const label = INTEGRATION_STEP_LABELS[id];
|
|
100
|
+
if (isSatisfied(id, facts)) {
|
|
101
|
+
satisfied.add(id);
|
|
102
|
+
steps.push({
|
|
103
|
+
id,
|
|
104
|
+
label,
|
|
105
|
+
state: "done",
|
|
106
|
+
blockedBy: null,
|
|
107
|
+
evidence: evidenceFor(id, facts),
|
|
108
|
+
problem: null
|
|
109
|
+
});
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (unmet !== void 0) {
|
|
113
|
+
steps.push({
|
|
114
|
+
id,
|
|
115
|
+
label,
|
|
116
|
+
state: "blocked",
|
|
117
|
+
blockedBy: unmet,
|
|
118
|
+
evidence: null,
|
|
119
|
+
problem: null
|
|
120
|
+
});
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const problem = problemFor(id, facts);
|
|
124
|
+
let state = "active";
|
|
125
|
+
if (problem) state = "failed";
|
|
126
|
+
else if (id === "widget") state = "skipped";
|
|
127
|
+
const claimsAttention = state === "active" || state === "failed";
|
|
128
|
+
steps.push({
|
|
129
|
+
id,
|
|
130
|
+
label,
|
|
131
|
+
state: activeTaken && state === "active" ? "blocked" : state,
|
|
132
|
+
blockedBy: null,
|
|
133
|
+
evidence: evidenceFor(id, facts),
|
|
134
|
+
problem: activeTaken ? null : problem
|
|
135
|
+
});
|
|
136
|
+
if (claimsAttention) activeTaken = true;
|
|
137
|
+
}
|
|
138
|
+
return steps;
|
|
139
|
+
}
|
|
140
|
+
const STEP_STATUS_WHEN_ACTIVE = {
|
|
141
|
+
credential: "not_configured",
|
|
142
|
+
application: "awaiting_connection",
|
|
143
|
+
schema: "connected",
|
|
144
|
+
mapping: "mapping_required",
|
|
145
|
+
trigger: "trigger_required",
|
|
146
|
+
sync: "ready_to_sync",
|
|
147
|
+
widget: "healthy"
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* Collapse the ladder to the single status the surfaces switch on. Live
|
|
151
|
+
* conditions — a run in flight, a failure, a heartbeat gone quiet — win over
|
|
152
|
+
* ladder position, because they describe the integration as it is now rather
|
|
153
|
+
* than how far setup once got.
|
|
154
|
+
*/
|
|
155
|
+
function deriveStatus(steps, facts) {
|
|
156
|
+
if (facts.syncInFlight) return "syncing";
|
|
157
|
+
if (steps.find((step) => step.id === "application")?.problem?.code === "heartbeat_stale") return "disconnected";
|
|
158
|
+
if (steps.some((step) => step.state === "failed")) return "error";
|
|
159
|
+
const blocking = steps.find((step) => step.state === "active" || step.state === "blocked");
|
|
160
|
+
if (blocking) return STEP_STATUS_WHEN_ACTIVE[blocking.id];
|
|
161
|
+
return facts.warnings.length > 0 ? "warning" : "healthy";
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The command that installs Clivly into a host app. Exported because the connect
|
|
165
|
+
* dialog prints it too, and it must be the same string as the one the ladder
|
|
166
|
+
* recommends — that pairing is what C-05 broke.
|
|
167
|
+
*/
|
|
168
|
+
const INSTALL_COMMAND = "npx clivly init";
|
|
169
|
+
const NEXT_BY_STEP = {
|
|
170
|
+
credential: {
|
|
171
|
+
label: "Create a credential",
|
|
172
|
+
command: null,
|
|
173
|
+
envFile: null
|
|
174
|
+
},
|
|
175
|
+
application: {
|
|
176
|
+
label: "Install Clivly in your app",
|
|
177
|
+
command: INSTALL_COMMAND,
|
|
178
|
+
envFile: ".env.local"
|
|
179
|
+
},
|
|
180
|
+
schema: {
|
|
181
|
+
label: "Check what the SDK reported",
|
|
182
|
+
command: "npx clivly status",
|
|
183
|
+
envFile: null
|
|
184
|
+
},
|
|
185
|
+
mapping: {
|
|
186
|
+
label: "Choose the table holding your people",
|
|
187
|
+
command: null,
|
|
188
|
+
envFile: null
|
|
189
|
+
},
|
|
190
|
+
trigger: {
|
|
191
|
+
label: "Let Clivly start a sync",
|
|
192
|
+
command: null,
|
|
193
|
+
envFile: null
|
|
194
|
+
},
|
|
195
|
+
sync: {
|
|
196
|
+
label: "Run the first sync",
|
|
197
|
+
command: "npx clivly sync",
|
|
198
|
+
envFile: null
|
|
199
|
+
},
|
|
200
|
+
widget: {
|
|
201
|
+
label: "Install the chat widget",
|
|
202
|
+
command: null,
|
|
203
|
+
envFile: null
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
/**
|
|
207
|
+
* THE one producer of `next.command`. Six surfaces previously each invented
|
|
208
|
+
* their own "run this next" string, and one of them named a read-only command
|
|
209
|
+
* as the fix for a write problem (C-05). If you need a command for the
|
|
210
|
+
* next-action slot, call this — do not write another one.
|
|
211
|
+
*/
|
|
212
|
+
function nextAction(steps, status) {
|
|
213
|
+
if (status === "healthy" || status === "syncing") return null;
|
|
214
|
+
const blocking = steps.find((step) => step.state === "failed") ?? steps.find((step) => step.state === "active");
|
|
215
|
+
if (!blocking) return null;
|
|
216
|
+
if (blocking.problem?.code === "heartbeat_stale") return {
|
|
217
|
+
label: "Reconnect your app",
|
|
218
|
+
command: "npx clivly status",
|
|
219
|
+
envFile: null
|
|
220
|
+
};
|
|
221
|
+
return NEXT_BY_STEP[blocking.id];
|
|
222
|
+
}
|
|
223
|
+
function computeIntegrationState(facts) {
|
|
224
|
+
const steps = resolveSteps(facts);
|
|
225
|
+
const status = deriveStatus(steps, facts);
|
|
226
|
+
return {
|
|
227
|
+
status,
|
|
228
|
+
steps,
|
|
229
|
+
next: nextAction(steps, status)
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
//#endregion
|
|
233
|
+
exports.INSTALL_COMMAND = INSTALL_COMMAND;
|
|
234
|
+
exports.INTEGRATION_STEPS = INTEGRATION_STEPS;
|
|
235
|
+
exports.INTEGRATION_STEP_LABELS = INTEGRATION_STEP_LABELS;
|
|
236
|
+
exports.STEP_PREREQUISITES = STEP_PREREQUISITES;
|
|
237
|
+
exports.computeIntegrationState = computeIntegrationState;
|
|
238
|
+
exports.deriveStatus = deriveStatus;
|
|
239
|
+
exports.formatAge = formatAge;
|
|
240
|
+
exports.nextAction = nextAction;
|
|
241
|
+
exports.resolveSteps = resolveSteps;
|
|
242
|
+
exports.stepIndex = stepIndex;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
//#region src/integration-state.d.ts
|
|
2
|
+
declare const INTEGRATION_STEPS: readonly ["credential", "application", "schema", "mapping", "trigger", "sync", "widget"];
|
|
3
|
+
type StepId = (typeof INTEGRATION_STEPS)[number];
|
|
4
|
+
/** `blocked` means "prerequisite unmet", never "broken" — see C-19. */
|
|
5
|
+
type StepState = "done" | "active" | "blocked" | "failed" | "skipped";
|
|
6
|
+
type IntegrationStatus = "not_configured" | "sdk_installed" | "awaiting_connection" | "connected" | "schema_discovered" | "mapping_required" | "trigger_required" | "ready_to_sync" | "syncing" | "healthy" | "warning" | "error" | "disconnected";
|
|
7
|
+
declare const INTEGRATION_STEP_LABELS: Record<StepId, string>;
|
|
8
|
+
/** Each step's prerequisites. Ordering is enforced by a test, not convention. */
|
|
9
|
+
declare const STEP_PREREQUISITES: Record<StepId, readonly StepId[]>;
|
|
10
|
+
declare function stepIndex(id: StepId): number;
|
|
11
|
+
interface RunFact {
|
|
12
|
+
at: number;
|
|
13
|
+
message?: string;
|
|
14
|
+
ok: boolean;
|
|
15
|
+
}
|
|
16
|
+
interface IntegrationFacts {
|
|
17
|
+
discoveredTableCount: number;
|
|
18
|
+
/**
|
|
19
|
+
* Has this app ever checked in? Distinguishes "never installed" from "was
|
|
20
|
+
* working, now silent" — the same absent heartbeat means different things,
|
|
21
|
+
* and only one of them is diagnosed rather than set up.
|
|
22
|
+
*/
|
|
23
|
+
everConnected: boolean;
|
|
24
|
+
hasCredential: boolean;
|
|
25
|
+
lastHeartbeatAt: number | null;
|
|
26
|
+
lastRun: RunFact | null;
|
|
27
|
+
mappedEntityCount: number;
|
|
28
|
+
now: number;
|
|
29
|
+
syncInFlight: boolean;
|
|
30
|
+
/** A trigger URL and secret both exist, so Clivly can ask the app to sync. */
|
|
31
|
+
triggerConfigured: boolean;
|
|
32
|
+
warnings: string[];
|
|
33
|
+
widgetInstalled: boolean;
|
|
34
|
+
}
|
|
35
|
+
interface StepProblem {
|
|
36
|
+
code: string;
|
|
37
|
+
fix: string;
|
|
38
|
+
message: string;
|
|
39
|
+
}
|
|
40
|
+
interface IntegrationStep {
|
|
41
|
+
blockedBy: StepId | null;
|
|
42
|
+
evidence: string | null;
|
|
43
|
+
id: StepId;
|
|
44
|
+
label: string;
|
|
45
|
+
problem: StepProblem | null;
|
|
46
|
+
state: StepState;
|
|
47
|
+
}
|
|
48
|
+
declare function formatAge(ms: number): string;
|
|
49
|
+
/**
|
|
50
|
+
* Walk the ladder once. A step is `blocked` when an earlier step it needs has
|
|
51
|
+
* not been satisfied — a prerequisite statement, not a verdict about this step
|
|
52
|
+
* — and only the first unsatisfied step is `active`, so exactly one root cause
|
|
53
|
+
* ever surfaces.
|
|
54
|
+
*/
|
|
55
|
+
declare function resolveSteps(facts: IntegrationFacts): IntegrationStep[];
|
|
56
|
+
/**
|
|
57
|
+
* Collapse the ladder to the single status the surfaces switch on. Live
|
|
58
|
+
* conditions — a run in flight, a failure, a heartbeat gone quiet — win over
|
|
59
|
+
* ladder position, because they describe the integration as it is now rather
|
|
60
|
+
* than how far setup once got.
|
|
61
|
+
*/
|
|
62
|
+
declare function deriveStatus(steps: IntegrationStep[], facts: IntegrationFacts): IntegrationStatus;
|
|
63
|
+
interface NextAction {
|
|
64
|
+
command: string | null;
|
|
65
|
+
envFile: string | null;
|
|
66
|
+
label: string;
|
|
67
|
+
}
|
|
68
|
+
interface IntegrationState {
|
|
69
|
+
next: NextAction | null;
|
|
70
|
+
status: IntegrationStatus;
|
|
71
|
+
steps: IntegrationStep[];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The command that installs Clivly into a host app. Exported because the connect
|
|
75
|
+
* dialog prints it too, and it must be the same string as the one the ladder
|
|
76
|
+
* recommends — that pairing is what C-05 broke.
|
|
77
|
+
*/
|
|
78
|
+
declare const INSTALL_COMMAND = "npx clivly init";
|
|
79
|
+
/**
|
|
80
|
+
* THE one producer of `next.command`. Six surfaces previously each invented
|
|
81
|
+
* their own "run this next" string, and one of them named a read-only command
|
|
82
|
+
* as the fix for a write problem (C-05). If you need a command for the
|
|
83
|
+
* next-action slot, call this — do not write another one.
|
|
84
|
+
*/
|
|
85
|
+
declare function nextAction(steps: IntegrationStep[], status: IntegrationStatus): NextAction | null;
|
|
86
|
+
declare function computeIntegrationState(facts: IntegrationFacts): IntegrationState;
|
|
87
|
+
//#endregion
|
|
88
|
+
export { INSTALL_COMMAND, INTEGRATION_STEPS, INTEGRATION_STEP_LABELS, IntegrationFacts, IntegrationState, IntegrationStatus, IntegrationStep, NextAction, RunFact, STEP_PREREQUISITES, StepId, StepProblem, StepState, computeIntegrationState, deriveStatus, formatAge, nextAction, resolveSteps, stepIndex };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
//#region src/integration-state.d.ts
|
|
2
|
+
declare const INTEGRATION_STEPS: readonly ["credential", "application", "schema", "mapping", "trigger", "sync", "widget"];
|
|
3
|
+
type StepId = (typeof INTEGRATION_STEPS)[number];
|
|
4
|
+
/** `blocked` means "prerequisite unmet", never "broken" — see C-19. */
|
|
5
|
+
type StepState = "done" | "active" | "blocked" | "failed" | "skipped";
|
|
6
|
+
type IntegrationStatus = "not_configured" | "sdk_installed" | "awaiting_connection" | "connected" | "schema_discovered" | "mapping_required" | "trigger_required" | "ready_to_sync" | "syncing" | "healthy" | "warning" | "error" | "disconnected";
|
|
7
|
+
declare const INTEGRATION_STEP_LABELS: Record<StepId, string>;
|
|
8
|
+
/** Each step's prerequisites. Ordering is enforced by a test, not convention. */
|
|
9
|
+
declare const STEP_PREREQUISITES: Record<StepId, readonly StepId[]>;
|
|
10
|
+
declare function stepIndex(id: StepId): number;
|
|
11
|
+
interface RunFact {
|
|
12
|
+
at: number;
|
|
13
|
+
message?: string;
|
|
14
|
+
ok: boolean;
|
|
15
|
+
}
|
|
16
|
+
interface IntegrationFacts {
|
|
17
|
+
discoveredTableCount: number;
|
|
18
|
+
/**
|
|
19
|
+
* Has this app ever checked in? Distinguishes "never installed" from "was
|
|
20
|
+
* working, now silent" — the same absent heartbeat means different things,
|
|
21
|
+
* and only one of them is diagnosed rather than set up.
|
|
22
|
+
*/
|
|
23
|
+
everConnected: boolean;
|
|
24
|
+
hasCredential: boolean;
|
|
25
|
+
lastHeartbeatAt: number | null;
|
|
26
|
+
lastRun: RunFact | null;
|
|
27
|
+
mappedEntityCount: number;
|
|
28
|
+
now: number;
|
|
29
|
+
syncInFlight: boolean;
|
|
30
|
+
/** A trigger URL and secret both exist, so Clivly can ask the app to sync. */
|
|
31
|
+
triggerConfigured: boolean;
|
|
32
|
+
warnings: string[];
|
|
33
|
+
widgetInstalled: boolean;
|
|
34
|
+
}
|
|
35
|
+
interface StepProblem {
|
|
36
|
+
code: string;
|
|
37
|
+
fix: string;
|
|
38
|
+
message: string;
|
|
39
|
+
}
|
|
40
|
+
interface IntegrationStep {
|
|
41
|
+
blockedBy: StepId | null;
|
|
42
|
+
evidence: string | null;
|
|
43
|
+
id: StepId;
|
|
44
|
+
label: string;
|
|
45
|
+
problem: StepProblem | null;
|
|
46
|
+
state: StepState;
|
|
47
|
+
}
|
|
48
|
+
declare function formatAge(ms: number): string;
|
|
49
|
+
/**
|
|
50
|
+
* Walk the ladder once. A step is `blocked` when an earlier step it needs has
|
|
51
|
+
* not been satisfied — a prerequisite statement, not a verdict about this step
|
|
52
|
+
* — and only the first unsatisfied step is `active`, so exactly one root cause
|
|
53
|
+
* ever surfaces.
|
|
54
|
+
*/
|
|
55
|
+
declare function resolveSteps(facts: IntegrationFacts): IntegrationStep[];
|
|
56
|
+
/**
|
|
57
|
+
* Collapse the ladder to the single status the surfaces switch on. Live
|
|
58
|
+
* conditions — a run in flight, a failure, a heartbeat gone quiet — win over
|
|
59
|
+
* ladder position, because they describe the integration as it is now rather
|
|
60
|
+
* than how far setup once got.
|
|
61
|
+
*/
|
|
62
|
+
declare function deriveStatus(steps: IntegrationStep[], facts: IntegrationFacts): IntegrationStatus;
|
|
63
|
+
interface NextAction {
|
|
64
|
+
command: string | null;
|
|
65
|
+
envFile: string | null;
|
|
66
|
+
label: string;
|
|
67
|
+
}
|
|
68
|
+
interface IntegrationState {
|
|
69
|
+
next: NextAction | null;
|
|
70
|
+
status: IntegrationStatus;
|
|
71
|
+
steps: IntegrationStep[];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The command that installs Clivly into a host app. Exported because the connect
|
|
75
|
+
* dialog prints it too, and it must be the same string as the one the ladder
|
|
76
|
+
* recommends — that pairing is what C-05 broke.
|
|
77
|
+
*/
|
|
78
|
+
declare const INSTALL_COMMAND = "npx clivly init";
|
|
79
|
+
/**
|
|
80
|
+
* THE one producer of `next.command`. Six surfaces previously each invented
|
|
81
|
+
* their own "run this next" string, and one of them named a read-only command
|
|
82
|
+
* as the fix for a write problem (C-05). If you need a command for the
|
|
83
|
+
* next-action slot, call this — do not write another one.
|
|
84
|
+
*/
|
|
85
|
+
declare function nextAction(steps: IntegrationStep[], status: IntegrationStatus): NextAction | null;
|
|
86
|
+
declare function computeIntegrationState(facts: IntegrationFacts): IntegrationState;
|
|
87
|
+
//#endregion
|
|
88
|
+
export { INSTALL_COMMAND, INTEGRATION_STEPS, INTEGRATION_STEP_LABELS, IntegrationFacts, IntegrationState, IntegrationStatus, IntegrationStep, NextAction, RunFact, STEP_PREREQUISITES, StepId, StepProblem, StepState, computeIntegrationState, deriveStatus, formatAge, nextAction, resolveSteps, stepIndex };
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
//#region src/integration-state.ts
|
|
2
|
+
const INTEGRATION_STEPS = [
|
|
3
|
+
"credential",
|
|
4
|
+
"application",
|
|
5
|
+
"schema",
|
|
6
|
+
"mapping",
|
|
7
|
+
"trigger",
|
|
8
|
+
"sync",
|
|
9
|
+
"widget"
|
|
10
|
+
];
|
|
11
|
+
const INTEGRATION_STEP_LABELS = {
|
|
12
|
+
credential: "Secure credential",
|
|
13
|
+
application: "Application connected",
|
|
14
|
+
schema: "Schema discovered",
|
|
15
|
+
mapping: "Entities mapped",
|
|
16
|
+
trigger: "Clivly can start a sync",
|
|
17
|
+
sync: "First sync observed",
|
|
18
|
+
widget: "Chat widget installed"
|
|
19
|
+
};
|
|
20
|
+
/** Each step's prerequisites. Ordering is enforced by a test, not convention. */
|
|
21
|
+
const STEP_PREREQUISITES = {
|
|
22
|
+
credential: [],
|
|
23
|
+
application: ["credential"],
|
|
24
|
+
schema: ["application"],
|
|
25
|
+
mapping: ["schema"],
|
|
26
|
+
trigger: ["application"],
|
|
27
|
+
sync: ["mapping", "trigger"],
|
|
28
|
+
widget: ["application"]
|
|
29
|
+
};
|
|
30
|
+
function stepIndex(id) {
|
|
31
|
+
return INTEGRATION_STEPS.indexOf(id);
|
|
32
|
+
}
|
|
33
|
+
/** A heartbeat older than this means the app stopped checking in. */
|
|
34
|
+
const HEARTBEAT_STALE_MS = 600 * 1e3;
|
|
35
|
+
function formatAge(ms) {
|
|
36
|
+
if (ms < 6e4) return `${Math.max(1, Math.round(ms / 1e3))}s ago`;
|
|
37
|
+
if (ms < 36e5) return `${Math.round(ms / 6e4)}m ago`;
|
|
38
|
+
return `${Math.round(ms / 36e5)}h ago`;
|
|
39
|
+
}
|
|
40
|
+
function heartbeatAge(facts) {
|
|
41
|
+
return facts.lastHeartbeatAt === null ? null : facts.now - facts.lastHeartbeatAt;
|
|
42
|
+
}
|
|
43
|
+
/** Is the raw condition behind each step met, ignoring prerequisites? */
|
|
44
|
+
function isSatisfied(id, facts) {
|
|
45
|
+
const age = heartbeatAge(facts);
|
|
46
|
+
switch (id) {
|
|
47
|
+
case "credential": return facts.hasCredential;
|
|
48
|
+
case "application": return age !== null && age < HEARTBEAT_STALE_MS;
|
|
49
|
+
case "schema": return facts.discoveredTableCount > 0;
|
|
50
|
+
case "mapping": return facts.mappedEntityCount > 0;
|
|
51
|
+
case "trigger": return facts.triggerConfigured;
|
|
52
|
+
case "sync": return facts.lastRun?.ok === true;
|
|
53
|
+
default: return facts.widgetInstalled;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function evidenceFor(id, facts) {
|
|
57
|
+
const age = heartbeatAge(facts);
|
|
58
|
+
if (id === "application" && age !== null) return `heartbeat ${formatAge(age)}`;
|
|
59
|
+
if (id === "schema" && facts.discoveredTableCount > 0) return `found ${facts.discoveredTableCount} tables`;
|
|
60
|
+
if (id === "mapping" && facts.mappedEntityCount > 0) return `${facts.mappedEntityCount} entity mapped`;
|
|
61
|
+
if (id === "trigger" && facts.triggerConfigured) return "trigger URL and secret set";
|
|
62
|
+
if (id === "sync" && facts.lastRun) return `last run ${formatAge(facts.now - facts.lastRun.at)}`;
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
function problemFor(id, facts) {
|
|
66
|
+
if (id === "sync" && facts.lastRun && !facts.lastRun.ok) return {
|
|
67
|
+
code: "sync_failed",
|
|
68
|
+
message: facts.lastRun.message ?? "The last sync run failed.",
|
|
69
|
+
fix: "Open the run log to see the host response."
|
|
70
|
+
};
|
|
71
|
+
if (id === "application") {
|
|
72
|
+
const age = heartbeatAge(facts);
|
|
73
|
+
if (age !== null && age >= HEARTBEAT_STALE_MS) return {
|
|
74
|
+
code: "heartbeat_stale",
|
|
75
|
+
message: `Your app stopped checking in ${formatAge(age)}.`,
|
|
76
|
+
fix: "Start your app, then run `npx clivly status`."
|
|
77
|
+
};
|
|
78
|
+
if (age === null && facts.everConnected) return {
|
|
79
|
+
code: "heartbeat_stale",
|
|
80
|
+
message: "Your application stopped checking in.",
|
|
81
|
+
fix: "Start your app, then run `npx clivly status`."
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Walk the ladder once. A step is `blocked` when an earlier step it needs has
|
|
88
|
+
* not been satisfied — a prerequisite statement, not a verdict about this step
|
|
89
|
+
* — and only the first unsatisfied step is `active`, so exactly one root cause
|
|
90
|
+
* ever surfaces.
|
|
91
|
+
*/
|
|
92
|
+
function resolveSteps(facts) {
|
|
93
|
+
const satisfied = /* @__PURE__ */ new Set();
|
|
94
|
+
const steps = [];
|
|
95
|
+
let activeTaken = false;
|
|
96
|
+
for (const id of INTEGRATION_STEPS) {
|
|
97
|
+
const unmet = STEP_PREREQUISITES[id].find((need) => !satisfied.has(need));
|
|
98
|
+
const label = INTEGRATION_STEP_LABELS[id];
|
|
99
|
+
if (isSatisfied(id, facts)) {
|
|
100
|
+
satisfied.add(id);
|
|
101
|
+
steps.push({
|
|
102
|
+
id,
|
|
103
|
+
label,
|
|
104
|
+
state: "done",
|
|
105
|
+
blockedBy: null,
|
|
106
|
+
evidence: evidenceFor(id, facts),
|
|
107
|
+
problem: null
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (unmet !== void 0) {
|
|
112
|
+
steps.push({
|
|
113
|
+
id,
|
|
114
|
+
label,
|
|
115
|
+
state: "blocked",
|
|
116
|
+
blockedBy: unmet,
|
|
117
|
+
evidence: null,
|
|
118
|
+
problem: null
|
|
119
|
+
});
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const problem = problemFor(id, facts);
|
|
123
|
+
let state = "active";
|
|
124
|
+
if (problem) state = "failed";
|
|
125
|
+
else if (id === "widget") state = "skipped";
|
|
126
|
+
const claimsAttention = state === "active" || state === "failed";
|
|
127
|
+
steps.push({
|
|
128
|
+
id,
|
|
129
|
+
label,
|
|
130
|
+
state: activeTaken && state === "active" ? "blocked" : state,
|
|
131
|
+
blockedBy: null,
|
|
132
|
+
evidence: evidenceFor(id, facts),
|
|
133
|
+
problem: activeTaken ? null : problem
|
|
134
|
+
});
|
|
135
|
+
if (claimsAttention) activeTaken = true;
|
|
136
|
+
}
|
|
137
|
+
return steps;
|
|
138
|
+
}
|
|
139
|
+
const STEP_STATUS_WHEN_ACTIVE = {
|
|
140
|
+
credential: "not_configured",
|
|
141
|
+
application: "awaiting_connection",
|
|
142
|
+
schema: "connected",
|
|
143
|
+
mapping: "mapping_required",
|
|
144
|
+
trigger: "trigger_required",
|
|
145
|
+
sync: "ready_to_sync",
|
|
146
|
+
widget: "healthy"
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* Collapse the ladder to the single status the surfaces switch on. Live
|
|
150
|
+
* conditions — a run in flight, a failure, a heartbeat gone quiet — win over
|
|
151
|
+
* ladder position, because they describe the integration as it is now rather
|
|
152
|
+
* than how far setup once got.
|
|
153
|
+
*/
|
|
154
|
+
function deriveStatus(steps, facts) {
|
|
155
|
+
if (facts.syncInFlight) return "syncing";
|
|
156
|
+
if (steps.find((step) => step.id === "application")?.problem?.code === "heartbeat_stale") return "disconnected";
|
|
157
|
+
if (steps.some((step) => step.state === "failed")) return "error";
|
|
158
|
+
const blocking = steps.find((step) => step.state === "active" || step.state === "blocked");
|
|
159
|
+
if (blocking) return STEP_STATUS_WHEN_ACTIVE[blocking.id];
|
|
160
|
+
return facts.warnings.length > 0 ? "warning" : "healthy";
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The command that installs Clivly into a host app. Exported because the connect
|
|
164
|
+
* dialog prints it too, and it must be the same string as the one the ladder
|
|
165
|
+
* recommends — that pairing is what C-05 broke.
|
|
166
|
+
*/
|
|
167
|
+
const INSTALL_COMMAND = "npx clivly init";
|
|
168
|
+
const NEXT_BY_STEP = {
|
|
169
|
+
credential: {
|
|
170
|
+
label: "Create a credential",
|
|
171
|
+
command: null,
|
|
172
|
+
envFile: null
|
|
173
|
+
},
|
|
174
|
+
application: {
|
|
175
|
+
label: "Install Clivly in your app",
|
|
176
|
+
command: INSTALL_COMMAND,
|
|
177
|
+
envFile: ".env.local"
|
|
178
|
+
},
|
|
179
|
+
schema: {
|
|
180
|
+
label: "Check what the SDK reported",
|
|
181
|
+
command: "npx clivly status",
|
|
182
|
+
envFile: null
|
|
183
|
+
},
|
|
184
|
+
mapping: {
|
|
185
|
+
label: "Choose the table holding your people",
|
|
186
|
+
command: null,
|
|
187
|
+
envFile: null
|
|
188
|
+
},
|
|
189
|
+
trigger: {
|
|
190
|
+
label: "Let Clivly start a sync",
|
|
191
|
+
command: null,
|
|
192
|
+
envFile: null
|
|
193
|
+
},
|
|
194
|
+
sync: {
|
|
195
|
+
label: "Run the first sync",
|
|
196
|
+
command: "npx clivly sync",
|
|
197
|
+
envFile: null
|
|
198
|
+
},
|
|
199
|
+
widget: {
|
|
200
|
+
label: "Install the chat widget",
|
|
201
|
+
command: null,
|
|
202
|
+
envFile: null
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
/**
|
|
206
|
+
* THE one producer of `next.command`. Six surfaces previously each invented
|
|
207
|
+
* their own "run this next" string, and one of them named a read-only command
|
|
208
|
+
* as the fix for a write problem (C-05). If you need a command for the
|
|
209
|
+
* next-action slot, call this — do not write another one.
|
|
210
|
+
*/
|
|
211
|
+
function nextAction(steps, status) {
|
|
212
|
+
if (status === "healthy" || status === "syncing") return null;
|
|
213
|
+
const blocking = steps.find((step) => step.state === "failed") ?? steps.find((step) => step.state === "active");
|
|
214
|
+
if (!blocking) return null;
|
|
215
|
+
if (blocking.problem?.code === "heartbeat_stale") return {
|
|
216
|
+
label: "Reconnect your app",
|
|
217
|
+
command: "npx clivly status",
|
|
218
|
+
envFile: null
|
|
219
|
+
};
|
|
220
|
+
return NEXT_BY_STEP[blocking.id];
|
|
221
|
+
}
|
|
222
|
+
function computeIntegrationState(facts) {
|
|
223
|
+
const steps = resolveSteps(facts);
|
|
224
|
+
const status = deriveStatus(steps, facts);
|
|
225
|
+
return {
|
|
226
|
+
status,
|
|
227
|
+
steps,
|
|
228
|
+
next: nextAction(steps, status)
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
//#endregion
|
|
232
|
+
export { INSTALL_COMMAND, INTEGRATION_STEPS, INTEGRATION_STEP_LABELS, STEP_PREREQUISITES, computeIntegrationState, deriveStatus, formatAge, nextAction, resolveSteps, stepIndex };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clivly/core",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "ORM/auth-agnostic CRM adapter contracts and shared domain types for Clivly.",
|
|
@@ -47,6 +47,26 @@
|
|
|
47
47
|
"default": "./dist/mapping-score.cjs"
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
|
+
"./integration-state": {
|
|
51
|
+
"import": {
|
|
52
|
+
"types": "./dist/integration-state.d.mts",
|
|
53
|
+
"default": "./dist/integration-state.mjs"
|
|
54
|
+
},
|
|
55
|
+
"require": {
|
|
56
|
+
"types": "./dist/integration-state.d.cts",
|
|
57
|
+
"default": "./dist/integration-state.cjs"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"./env-file": {
|
|
61
|
+
"import": {
|
|
62
|
+
"types": "./dist/env-file.d.mts",
|
|
63
|
+
"default": "./dist/env-file.mjs"
|
|
64
|
+
},
|
|
65
|
+
"require": {
|
|
66
|
+
"types": "./dist/env-file.d.cts",
|
|
67
|
+
"default": "./dist/env-file.cjs"
|
|
68
|
+
}
|
|
69
|
+
},
|
|
50
70
|
"./auth-adapter": {
|
|
51
71
|
"import": {
|
|
52
72
|
"types": "./dist/auth-adapter.d.mts",
|