@clivly/core 0.9.1 → 0.9.2

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.
@@ -3,7 +3,13 @@ declare const INTEGRATION_STEPS: readonly ["credential", "application", "schema"
3
3
  type StepId = (typeof INTEGRATION_STEPS)[number];
4
4
  /** `blocked` means "prerequisite unmet", never "broken" — see C-19. */
5
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";
6
+ type IntegrationStatus = "not_configured" | "sdk_installed" | "awaiting_connection" | "connected" | "schema_discovered" | "mapping_required" | "trigger_required" | "ready_to_sync" | "syncing" | "healthy" | "warning" | "error" |
7
+ /**
8
+ * @deprecated No longer derived. Liveness is its own dimension — read
9
+ * `ConnectionHealth` from `deriveLiveness`. Setup progress does not move
10
+ * backwards just because an app stopped running.
11
+ */
12
+ "disconnected";
7
13
  declare const INTEGRATION_STEP_LABELS: Record<StepId, string>;
8
14
  /** Each step's prerequisites. Ordering is enforced by a test, not convention. */
9
15
  declare const STEP_PREREQUISITES: Record<StepId, readonly StepId[]>;
@@ -13,8 +19,20 @@ interface RunFact {
13
19
  message?: string;
14
20
  ok: boolean;
15
21
  }
22
+ /**
23
+ * Where the reporting app is running, as the app itself understands it.
24
+ *
25
+ * `unknown` is a real value, not an absence: `detectHostEnvironment` refuses to
26
+ * guess when nothing places the app, and a bare `node server.js` with no
27
+ * NODE_ENV must still be able to connect and be shown as itself. Defaulting
28
+ * such a host to `local_dev` would file a production box's faults under local
29
+ * development, where nobody would look for them.
30
+ */
31
+ type HostEnvironment = "local_dev" | "preview" | "production" | "unknown";
16
32
  interface IntegrationFacts {
17
33
  discoveredTableCount: number;
34
+ /** The environment the app reported on its last heartbeat. */
35
+ environment?: HostEnvironment | null;
18
36
  /**
19
37
  * Has this app ever checked in? Distinguishes "never installed" from "was
20
38
  * working, now silent" — the same absent heartbeat means different things,
@@ -22,10 +40,27 @@ interface IntegrationFacts {
22
40
  */
23
41
  everConnected: boolean;
24
42
  hasCredential: boolean;
43
+ /**
44
+ * The cadence the app said it heartbeats at. Liveness windows are multiples
45
+ * of this rather than a fixed constant, so an app that deliberately reports
46
+ * slowly is not called dead for obeying its own configuration.
47
+ */
48
+ heartbeatIntervalMs?: number | null;
25
49
  lastHeartbeatAt: number | null;
26
50
  lastRun: RunFact | null;
27
51
  mappedEntityCount: number;
28
52
  now: number;
53
+ /**
54
+ * Deployment presence, when this deployment has one. Absent or `http`
55
+ * transport keeps the cadence-derived behaviour every SDK in the field uses.
56
+ */
57
+ presence?: DeploymentPresence | null;
58
+ /**
59
+ * Faults the running app diagnosed about itself on its last heartbeat — an
60
+ * unreadable database, a secret missing from the process environment. These
61
+ * are what make a reachable app `live_with_issues`; unfinished setup is not.
62
+ */
63
+ reportedIssues?: StepProblem[];
29
64
  syncInFlight: boolean;
30
65
  /** A trigger URL and secret both exist, so Clivly can ask the app to sync. */
31
66
  triggerConfigured: boolean;
@@ -46,6 +81,52 @@ interface IntegrationStep {
46
81
  state: StepState;
47
82
  }
48
83
  declare function formatAge(ms: number): string;
84
+ /** The cadence `createClivlySDK` uses when the host app configures none. */
85
+ declare const DEFAULT_HEARTBEAT_INTERVAL_MS = 60000;
86
+ type ConnectionHealth = "live" | "live_with_issues" | "stale" | "offline";
87
+ /** Which algorithm decides whether this deployment is up. */
88
+ type PresenceTransport = "websocket" | "http";
89
+ type PresenceConnectionState = "connected" | "reconnecting" | "disconnected";
90
+ /**
91
+ * A deployment's connection state as its `PresenceRoom` last persisted it.
92
+ *
93
+ * This exists because a socket has an explicit state and a timestamp does not.
94
+ * Deriving WebSocket liveness from heartbeat age would leave a disconnected app
95
+ * shown as live for 150 seconds and offline only after ten minutes, which is
96
+ * the whole reason this transport exists.
97
+ */
98
+ interface DeploymentPresence {
99
+ /** Sockets currently accepted in the room. */
100
+ activeInstanceCount: number;
101
+ connectionState: PresenceConnectionState;
102
+ transport: PresenceTransport;
103
+ }
104
+ interface ConnectionLiveness {
105
+ /** `"8s ago"`, or `"No heartbeat yet"` when the app has never reported. */
106
+ ageLabel: string;
107
+ ageMs: number | null;
108
+ environment: HostEnvironment | null;
109
+ health: ConnectionHealth;
110
+ /**
111
+ * Faults on a reachable app. Empty unless `health` is `live_with_issues`.
112
+ */
113
+ issues: StepProblem[];
114
+ /** Present when a deployment-presence record supplied this liveness result. */
115
+ presence?: Pick<DeploymentPresence, "connectionState" | "transport">;
116
+ /** Is a heartbeat recent enough to speak for the app as it is right now? */
117
+ reachable: boolean;
118
+ }
119
+ /**
120
+ * The two boundaries that separate live from stale from offline, in terms of
121
+ * the app's own reported cadence. A cadence we cannot believe — absent, zero,
122
+ * negative — falls back to the SDK default rather than producing a window of
123
+ * zero that would call every app offline.
124
+ */
125
+ declare function livenessWindows(intervalMs: number | null | undefined): {
126
+ liveMs: number;
127
+ staleMs: number;
128
+ };
129
+ declare function deriveLiveness(facts: IntegrationFacts): ConnectionLiveness;
49
130
  /**
50
131
  * Walk the ladder once. A step is `blocked` when an earlier step it needs has
51
132
  * not been satisfied — a prerequisite statement, not a verdict about this step
@@ -54,10 +135,9 @@ declare function formatAge(ms: number): string;
54
135
  */
55
136
  declare function resolveSteps(facts: IntegrationFacts): IntegrationStep[];
56
137
  /**
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.
138
+ * Collapse the ladder to the single status the surfaces switch on. This is the
139
+ * setup dimension only: it answers how far configuration got, never whether the
140
+ * app is up. Pair it with `deriveLiveness` for the second half of the picture.
61
141
  */
62
142
  declare function deriveStatus(steps: IntegrationStep[], facts: IntegrationFacts): IntegrationStatus;
63
143
  interface NextAction {
@@ -66,6 +146,13 @@ interface NextAction {
66
146
  label: string;
67
147
  }
68
148
  interface IntegrationState {
149
+ /**
150
+ * Is the app running right now. Deliberately alongside `status` rather than
151
+ * folded into it: a surface must be able to say "setup reached mapping" and
152
+ * "the app is offline" in the same breath without either overwriting the
153
+ * other.
154
+ */
155
+ liveness: ConnectionLiveness;
69
156
  next: NextAction | null;
70
157
  status: IntegrationStatus;
71
158
  steps: IntegrationStep[];
@@ -82,7 +169,7 @@ declare const INSTALL_COMMAND = "npx clivly init";
82
169
  * as the fix for a write problem (C-05). If you need a command for the
83
170
  * next-action slot, call this — do not write another one.
84
171
  */
85
- declare function nextAction(steps: IntegrationStep[], status: IntegrationStatus): NextAction | null;
172
+ declare function nextAction(steps: IntegrationStep[], status: IntegrationStatus, liveness: ConnectionLiveness): NextAction | null;
86
173
  declare function computeIntegrationState(facts: IntegrationFacts): IntegrationState;
87
174
  //#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 };
175
+ export { ConnectionHealth, ConnectionLiveness, DEFAULT_HEARTBEAT_INTERVAL_MS, DeploymentPresence, HostEnvironment, INSTALL_COMMAND, INTEGRATION_STEPS, INTEGRATION_STEP_LABELS, IntegrationFacts, IntegrationState, IntegrationStatus, IntegrationStep, NextAction, PresenceConnectionState, PresenceTransport, RunFact, STEP_PREREQUISITES, StepId, StepProblem, StepState, computeIntegrationState, deriveLiveness, deriveStatus, formatAge, livenessWindows, nextAction, resolveSteps, stepIndex };
@@ -3,7 +3,13 @@ declare const INTEGRATION_STEPS: readonly ["credential", "application", "schema"
3
3
  type StepId = (typeof INTEGRATION_STEPS)[number];
4
4
  /** `blocked` means "prerequisite unmet", never "broken" — see C-19. */
5
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";
6
+ type IntegrationStatus = "not_configured" | "sdk_installed" | "awaiting_connection" | "connected" | "schema_discovered" | "mapping_required" | "trigger_required" | "ready_to_sync" | "syncing" | "healthy" | "warning" | "error" |
7
+ /**
8
+ * @deprecated No longer derived. Liveness is its own dimension — read
9
+ * `ConnectionHealth` from `deriveLiveness`. Setup progress does not move
10
+ * backwards just because an app stopped running.
11
+ */
12
+ "disconnected";
7
13
  declare const INTEGRATION_STEP_LABELS: Record<StepId, string>;
8
14
  /** Each step's prerequisites. Ordering is enforced by a test, not convention. */
9
15
  declare const STEP_PREREQUISITES: Record<StepId, readonly StepId[]>;
@@ -13,8 +19,20 @@ interface RunFact {
13
19
  message?: string;
14
20
  ok: boolean;
15
21
  }
22
+ /**
23
+ * Where the reporting app is running, as the app itself understands it.
24
+ *
25
+ * `unknown` is a real value, not an absence: `detectHostEnvironment` refuses to
26
+ * guess when nothing places the app, and a bare `node server.js` with no
27
+ * NODE_ENV must still be able to connect and be shown as itself. Defaulting
28
+ * such a host to `local_dev` would file a production box's faults under local
29
+ * development, where nobody would look for them.
30
+ */
31
+ type HostEnvironment = "local_dev" | "preview" | "production" | "unknown";
16
32
  interface IntegrationFacts {
17
33
  discoveredTableCount: number;
34
+ /** The environment the app reported on its last heartbeat. */
35
+ environment?: HostEnvironment | null;
18
36
  /**
19
37
  * Has this app ever checked in? Distinguishes "never installed" from "was
20
38
  * working, now silent" — the same absent heartbeat means different things,
@@ -22,10 +40,27 @@ interface IntegrationFacts {
22
40
  */
23
41
  everConnected: boolean;
24
42
  hasCredential: boolean;
43
+ /**
44
+ * The cadence the app said it heartbeats at. Liveness windows are multiples
45
+ * of this rather than a fixed constant, so an app that deliberately reports
46
+ * slowly is not called dead for obeying its own configuration.
47
+ */
48
+ heartbeatIntervalMs?: number | null;
25
49
  lastHeartbeatAt: number | null;
26
50
  lastRun: RunFact | null;
27
51
  mappedEntityCount: number;
28
52
  now: number;
53
+ /**
54
+ * Deployment presence, when this deployment has one. Absent or `http`
55
+ * transport keeps the cadence-derived behaviour every SDK in the field uses.
56
+ */
57
+ presence?: DeploymentPresence | null;
58
+ /**
59
+ * Faults the running app diagnosed about itself on its last heartbeat — an
60
+ * unreadable database, a secret missing from the process environment. These
61
+ * are what make a reachable app `live_with_issues`; unfinished setup is not.
62
+ */
63
+ reportedIssues?: StepProblem[];
29
64
  syncInFlight: boolean;
30
65
  /** A trigger URL and secret both exist, so Clivly can ask the app to sync. */
31
66
  triggerConfigured: boolean;
@@ -46,6 +81,52 @@ interface IntegrationStep {
46
81
  state: StepState;
47
82
  }
48
83
  declare function formatAge(ms: number): string;
84
+ /** The cadence `createClivlySDK` uses when the host app configures none. */
85
+ declare const DEFAULT_HEARTBEAT_INTERVAL_MS = 60000;
86
+ type ConnectionHealth = "live" | "live_with_issues" | "stale" | "offline";
87
+ /** Which algorithm decides whether this deployment is up. */
88
+ type PresenceTransport = "websocket" | "http";
89
+ type PresenceConnectionState = "connected" | "reconnecting" | "disconnected";
90
+ /**
91
+ * A deployment's connection state as its `PresenceRoom` last persisted it.
92
+ *
93
+ * This exists because a socket has an explicit state and a timestamp does not.
94
+ * Deriving WebSocket liveness from heartbeat age would leave a disconnected app
95
+ * shown as live for 150 seconds and offline only after ten minutes, which is
96
+ * the whole reason this transport exists.
97
+ */
98
+ interface DeploymentPresence {
99
+ /** Sockets currently accepted in the room. */
100
+ activeInstanceCount: number;
101
+ connectionState: PresenceConnectionState;
102
+ transport: PresenceTransport;
103
+ }
104
+ interface ConnectionLiveness {
105
+ /** `"8s ago"`, or `"No heartbeat yet"` when the app has never reported. */
106
+ ageLabel: string;
107
+ ageMs: number | null;
108
+ environment: HostEnvironment | null;
109
+ health: ConnectionHealth;
110
+ /**
111
+ * Faults on a reachable app. Empty unless `health` is `live_with_issues`.
112
+ */
113
+ issues: StepProblem[];
114
+ /** Present when a deployment-presence record supplied this liveness result. */
115
+ presence?: Pick<DeploymentPresence, "connectionState" | "transport">;
116
+ /** Is a heartbeat recent enough to speak for the app as it is right now? */
117
+ reachable: boolean;
118
+ }
119
+ /**
120
+ * The two boundaries that separate live from stale from offline, in terms of
121
+ * the app's own reported cadence. A cadence we cannot believe — absent, zero,
122
+ * negative — falls back to the SDK default rather than producing a window of
123
+ * zero that would call every app offline.
124
+ */
125
+ declare function livenessWindows(intervalMs: number | null | undefined): {
126
+ liveMs: number;
127
+ staleMs: number;
128
+ };
129
+ declare function deriveLiveness(facts: IntegrationFacts): ConnectionLiveness;
49
130
  /**
50
131
  * Walk the ladder once. A step is `blocked` when an earlier step it needs has
51
132
  * not been satisfied — a prerequisite statement, not a verdict about this step
@@ -54,10 +135,9 @@ declare function formatAge(ms: number): string;
54
135
  */
55
136
  declare function resolveSteps(facts: IntegrationFacts): IntegrationStep[];
56
137
  /**
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.
138
+ * Collapse the ladder to the single status the surfaces switch on. This is the
139
+ * setup dimension only: it answers how far configuration got, never whether the
140
+ * app is up. Pair it with `deriveLiveness` for the second half of the picture.
61
141
  */
62
142
  declare function deriveStatus(steps: IntegrationStep[], facts: IntegrationFacts): IntegrationStatus;
63
143
  interface NextAction {
@@ -66,6 +146,13 @@ interface NextAction {
66
146
  label: string;
67
147
  }
68
148
  interface IntegrationState {
149
+ /**
150
+ * Is the app running right now. Deliberately alongside `status` rather than
151
+ * folded into it: a surface must be able to say "setup reached mapping" and
152
+ * "the app is offline" in the same breath without either overwriting the
153
+ * other.
154
+ */
155
+ liveness: ConnectionLiveness;
69
156
  next: NextAction | null;
70
157
  status: IntegrationStatus;
71
158
  steps: IntegrationStep[];
@@ -82,7 +169,7 @@ declare const INSTALL_COMMAND = "npx clivly init";
82
169
  * as the fix for a write problem (C-05). If you need a command for the
83
170
  * next-action slot, call this — do not write another one.
84
171
  */
85
- declare function nextAction(steps: IntegrationStep[], status: IntegrationStatus): NextAction | null;
172
+ declare function nextAction(steps: IntegrationStep[], status: IntegrationStatus, liveness: ConnectionLiveness): NextAction | null;
86
173
  declare function computeIntegrationState(facts: IntegrationFacts): IntegrationState;
87
174
  //#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 };
175
+ export { ConnectionHealth, ConnectionLiveness, DEFAULT_HEARTBEAT_INTERVAL_MS, DeploymentPresence, HostEnvironment, INSTALL_COMMAND, INTEGRATION_STEPS, INTEGRATION_STEP_LABELS, IntegrationFacts, IntegrationState, IntegrationStatus, IntegrationStep, NextAction, PresenceConnectionState, PresenceTransport, RunFact, STEP_PREREQUISITES, StepId, StepProblem, StepState, computeIntegrationState, deriveLiveness, deriveStatus, formatAge, livenessWindows, nextAction, resolveSteps, stepIndex };
@@ -30,8 +30,6 @@ const STEP_PREREQUISITES = {
30
30
  function stepIndex(id) {
31
31
  return INTEGRATION_STEPS.indexOf(id);
32
32
  }
33
- /** A heartbeat older than this means the app stopped checking in. */
34
- const HEARTBEAT_STALE_MS = 600 * 1e3;
35
33
  function formatAge(ms) {
36
34
  if (ms < 6e4) return `${Math.max(1, Math.round(ms / 1e3))}s ago`;
37
35
  if (ms < 36e5) return `${Math.round(ms / 6e4)}m ago`;
@@ -40,12 +38,96 @@ function formatAge(ms) {
40
38
  function heartbeatAge(facts) {
41
39
  return facts.lastHeartbeatAt === null ? null : facts.now - facts.lastHeartbeatAt;
42
40
  }
41
+ /** The cadence `createClivlySDK` uses when the host app configures none. */
42
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 6e4;
43
+ const LIVE_INTERVALS = 2.5;
44
+ const STALE_INTERVALS = 10;
45
+ /**
46
+ * The two boundaries that separate live from stale from offline, in terms of
47
+ * the app's own reported cadence. A cadence we cannot believe — absent, zero,
48
+ * negative — falls back to the SDK default rather than producing a window of
49
+ * zero that would call every app offline.
50
+ */
51
+ function livenessWindows(intervalMs) {
52
+ const interval = typeof intervalMs === "number" && intervalMs > 0 ? intervalMs : DEFAULT_HEARTBEAT_INTERVAL_MS;
53
+ return {
54
+ liveMs: interval * LIVE_INTERVALS,
55
+ staleMs: interval * STALE_INTERVALS
56
+ };
57
+ }
58
+ /**
59
+ * Faults worth showing on an app that is up. Deliberately excludes anything the
60
+ * developer simply has not done yet — no schema, no mapping, no trigger are all
61
+ * setup stages, and painting a fresh install amber would make the signal
62
+ * meaningless.
63
+ */
64
+ function livenessIssues(facts) {
65
+ const issues = [...facts.reportedIssues ?? []];
66
+ if (facts.lastRun && !facts.lastRun.ok) issues.push({
67
+ code: "sync_failed",
68
+ message: facts.lastRun.message ?? "The last sync run failed.",
69
+ fix: "Open the run log to see how your app responded."
70
+ });
71
+ return issues;
72
+ }
73
+ function websocketLiveness(facts, presence, base) {
74
+ if (!(presence.connectionState === "connected" && presence.activeInstanceCount > 0)) {
75
+ const health = presence.connectionState === "reconnecting" ? "stale" : "offline";
76
+ return {
77
+ ...base,
78
+ health,
79
+ issues: [],
80
+ reachable: false
81
+ };
82
+ }
83
+ const issues = livenessIssues(facts);
84
+ return {
85
+ ...base,
86
+ health: issues.length > 0 ? "live_with_issues" : "live",
87
+ issues,
88
+ reachable: true
89
+ };
90
+ }
91
+ function deriveLiveness(facts) {
92
+ const ageMs = heartbeatAge(facts);
93
+ const environment = facts.environment ?? null;
94
+ const { liveMs, staleMs } = livenessWindows(facts.heartbeatIntervalMs);
95
+ const base = {
96
+ ageMs,
97
+ ageLabel: ageMs === null ? "No heartbeat yet" : formatAge(ageMs),
98
+ environment,
99
+ ...facts.presence ? { presence: {
100
+ connectionState: facts.presence.connectionState,
101
+ transport: facts.presence.transport
102
+ } } : {}
103
+ };
104
+ if (facts.presence && facts.presence.transport === "websocket") return websocketLiveness(facts, facts.presence, base);
105
+ if (ageMs === null || ageMs >= staleMs) return {
106
+ ...base,
107
+ health: "offline",
108
+ issues: [],
109
+ reachable: false
110
+ };
111
+ if (ageMs >= liveMs) return {
112
+ ...base,
113
+ health: "stale",
114
+ issues: [],
115
+ reachable: false
116
+ };
117
+ const issues = livenessIssues(facts);
118
+ return {
119
+ ...base,
120
+ health: issues.length > 0 ? "live_with_issues" : "live",
121
+ issues,
122
+ reachable: true
123
+ };
124
+ }
43
125
  /** Is the raw condition behind each step met, ignoring prerequisites? */
44
126
  function isSatisfied(id, facts) {
45
127
  const age = heartbeatAge(facts);
46
128
  switch (id) {
47
129
  case "credential": return facts.hasCredential;
48
- case "application": return age !== null && age < HEARTBEAT_STALE_MS;
130
+ case "application": return facts.everConnected || age !== null;
49
131
  case "schema": return facts.discoveredTableCount > 0;
50
132
  case "mapping": return facts.mappedEntityCount > 0;
51
133
  case "trigger": return facts.triggerConfigured;
@@ -68,19 +150,6 @@ function problemFor(id, facts) {
68
150
  message: facts.lastRun.message ?? "The last sync run failed.",
69
151
  fix: "Open the run log to see the host response."
70
152
  };
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
153
  return null;
85
154
  }
86
155
  /**
@@ -146,14 +215,12 @@ const STEP_STATUS_WHEN_ACTIVE = {
146
215
  widget: "healthy"
147
216
  };
148
217
  /**
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.
218
+ * Collapse the ladder to the single status the surfaces switch on. This is the
219
+ * setup dimension only: it answers how far configuration got, never whether the
220
+ * app is up. Pair it with `deriveLiveness` for the second half of the picture.
153
221
  */
154
222
  function deriveStatus(steps, facts) {
155
223
  if (facts.syncInFlight) return "syncing";
156
- if (steps.find((step) => step.id === "application")?.problem?.code === "heartbeat_stale") return "disconnected";
157
224
  if (steps.some((step) => step.state === "failed")) return "error";
158
225
  const blocking = steps.find((step) => step.state === "active" || step.state === "blocked");
159
226
  if (blocking) return STEP_STATUS_WHEN_ACTIVE[blocking.id];
@@ -208,13 +275,13 @@ const NEXT_BY_STEP = {
208
275
  * as the fix for a write problem (C-05). If you need a command for the
209
276
  * next-action slot, call this — do not write another one.
210
277
  */
211
- function nextAction(steps, status) {
278
+ function nextAction(steps, status, liveness) {
212
279
  if (status === "healthy" || status === "syncing") return null;
213
280
  const blocking = steps.find((step) => step.state === "failed") ?? steps.find((step) => step.state === "active");
214
281
  if (!blocking) return null;
215
- if (blocking.problem?.code === "heartbeat_stale") return {
216
- label: "Reconnect your app",
217
- command: "npx clivly status",
282
+ if (steps.find((step) => step.id === "application")?.state === "done" && !liveness.reachable) return {
283
+ label: "Start your app",
284
+ command: null,
218
285
  envFile: null
219
286
  };
220
287
  return NEXT_BY_STEP[blocking.id];
@@ -222,11 +289,13 @@ function nextAction(steps, status) {
222
289
  function computeIntegrationState(facts) {
223
290
  const steps = resolveSteps(facts);
224
291
  const status = deriveStatus(steps, facts);
292
+ const liveness = deriveLiveness(facts);
225
293
  return {
226
294
  status,
227
295
  steps,
228
- next: nextAction(steps, status)
296
+ liveness,
297
+ next: nextAction(steps, status, liveness)
229
298
  };
230
299
  }
231
300
  //#endregion
232
- export { INSTALL_COMMAND, INTEGRATION_STEPS, INTEGRATION_STEP_LABELS, STEP_PREREQUISITES, computeIntegrationState, deriveStatus, formatAge, nextAction, resolveSteps, stepIndex };
301
+ export { DEFAULT_HEARTBEAT_INTERVAL_MS, INSTALL_COMMAND, INTEGRATION_STEPS, INTEGRATION_STEP_LABELS, STEP_PREREQUISITES, computeIntegrationState, deriveLiveness, deriveStatus, formatAge, livenessWindows, nextAction, resolveSteps, stepIndex };
@@ -1,4 +1,4 @@
1
- import { b as RelationshipsMap, m as FilterExpr, y as RelationshipVia } from "./entity-config-CLxI8dVb.cjs";
1
+ import { b as RelationshipVia, h as FilterExpr, x as RelationshipsMap } from "./entity-config-buOIVtMM.cjs";
2
2
  //#region src/mapping-form.d.ts
3
3
  /**
4
4
  * A relationship the mapping UI can offer or has configured, in the shape the
@@ -1,4 +1,4 @@
1
- import { b as RelationshipsMap, m as FilterExpr, y as RelationshipVia } from "./entity-config-CLxI8dVb.mjs";
1
+ import { b as RelationshipVia, h as FilterExpr, x as RelationshipsMap } from "./entity-config-buOIVtMM.mjs";
2
2
  //#region src/mapping-form.d.ts
3
3
  /**
4
4
  * A relationship the mapping UI can offer or has configured, in the shape the
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_entity_config = require("./entity-config-Dh8aEcgj.cjs");
2
+ const require_entity_config = require("./entity-config-Bfq3OkCU.cjs");
3
3
  //#region src/mappings-config.ts
4
4
  const RESERVED_FIELD = "id";
5
5
  /**
@@ -122,5 +122,29 @@ function mappingsToEntitiesConfig(rows) {
122
122
  for (const { row, key } of assigned) entities[key] = buildEntity(row, refIndex);
123
123
  return { entities };
124
124
  }
125
+ const CUSTOM_CONCEPT = "custom";
126
+ function entitiesToMappings(config) {
127
+ const rows = [];
128
+ for (const [entityKey, entity] of Object.entries(config.entities)) {
129
+ const row = {
130
+ entityKey,
131
+ sourceTable: entity.projection?.baseTable ?? entity.source,
132
+ targetConcept: entity.concept,
133
+ role: entity.role ?? DEFAULT_ROLE,
134
+ fieldMap: entity.projection ? {} : entity.fields
135
+ };
136
+ if (entity.filter) row.filter = entity.filter;
137
+ if (entity.relationships) row.relationships = entity.relationships;
138
+ if (entity.projection) row.projection = entity.projection;
139
+ if (entity.concept === CUSTOM_CONCEPT) {
140
+ row.objectTypeSlug = entityKey;
141
+ if (entity.objectType) row.objectType = entity.objectType;
142
+ }
143
+ if (entity.targetObjectTypeId) row.targetObjectTypeId = entity.targetObjectTypeId;
144
+ rows.push(row);
145
+ }
146
+ return rows;
147
+ }
125
148
  //#endregion
149
+ exports.entitiesToMappings = entitiesToMappings;
126
150
  exports.mappingsToEntitiesConfig = mappingsToEntitiesConfig;
@@ -1,4 +1,4 @@
1
- import { M as ProjectionMapping, a as ClivlyEntitiesConfig } from "./entity-config-CLxI8dVb.cjs";
1
+ import { P as ProjectionMapping, a as ClivlyEntitiesConfig, h as FilterExpr, x as RelationshipsMap } from "./entity-config-buOIVtMM.cjs";
2
2
  //#region src/mappings-config.d.ts
3
3
  /**
4
4
  * Mappings → Entity Config bridge.
@@ -43,5 +43,43 @@ interface EntityMappingRow {
43
43
  * eligible — a no-op the view compiler and sync engine both tolerate.
44
44
  */
45
45
  declare function mappingsToEntitiesConfig(rows: EntityMappingRow[]): ClivlyEntitiesConfig;
46
+ /**
47
+ * Entity Config → Mappings, the inverse of `mappingsToEntitiesConfig`.
48
+ *
49
+ * `objectType` in config creates the custom object but nothing creates the
50
+ * MAPPING, so config-as-code ended at a created object and a sync that stored
51
+ * nothing. This produces the rows that close that gap.
52
+ *
53
+ * The two shapes align column for column, with one asymmetry that cannot be
54
+ * resolved by naming: the row model has no entity-key column — a row is keyed by
55
+ * `(sourceTable, targetConcept, role)` — while a CUSTOM entity's key *is* its
56
+ * object type slug, and is routinely different from its source table
57
+ * ("client-accounts" derived from "accounts"). So the key travels explicitly on
58
+ * every row rather than being left for the reverse derivation to guess.
59
+ */
60
+ interface MappingFromConfig {
61
+ /**
62
+ * The config entity key this row came from. Not a row column — the caller
63
+ * needs it to resolve a custom object type and to report what it wrote.
64
+ */
65
+ entityKey: string;
66
+ fieldMap: Record<string, string>;
67
+ filter?: FilterExpr;
68
+ /** Declared custom object, when the config declares one rather than pinning an id. */
69
+ objectType?: {
70
+ iconKey?: string;
71
+ name: string;
72
+ pluralName?: string;
73
+ };
74
+ /** The custom object type's slug — the entity key. Custom entities only. */
75
+ objectTypeSlug?: string;
76
+ projection?: ProjectionMapping;
77
+ relationships?: RelationshipsMap;
78
+ role: string;
79
+ sourceTable: string;
80
+ targetConcept: string;
81
+ targetObjectTypeId?: string;
82
+ }
83
+ declare function entitiesToMappings(config: ClivlyEntitiesConfig): MappingFromConfig[];
46
84
  //#endregion
47
- export { EntityMappingRow, mappingsToEntitiesConfig };
85
+ export { EntityMappingRow, MappingFromConfig, entitiesToMappings, mappingsToEntitiesConfig };
@@ -1,4 +1,4 @@
1
- import { M as ProjectionMapping, a as ClivlyEntitiesConfig } from "./entity-config-CLxI8dVb.mjs";
1
+ import { P as ProjectionMapping, a as ClivlyEntitiesConfig, h as FilterExpr, x as RelationshipsMap } from "./entity-config-buOIVtMM.mjs";
2
2
  //#region src/mappings-config.d.ts
3
3
  /**
4
4
  * Mappings → Entity Config bridge.
@@ -43,5 +43,43 @@ interface EntityMappingRow {
43
43
  * eligible — a no-op the view compiler and sync engine both tolerate.
44
44
  */
45
45
  declare function mappingsToEntitiesConfig(rows: EntityMappingRow[]): ClivlyEntitiesConfig;
46
+ /**
47
+ * Entity Config → Mappings, the inverse of `mappingsToEntitiesConfig`.
48
+ *
49
+ * `objectType` in config creates the custom object but nothing creates the
50
+ * MAPPING, so config-as-code ended at a created object and a sync that stored
51
+ * nothing. This produces the rows that close that gap.
52
+ *
53
+ * The two shapes align column for column, with one asymmetry that cannot be
54
+ * resolved by naming: the row model has no entity-key column — a row is keyed by
55
+ * `(sourceTable, targetConcept, role)` — while a CUSTOM entity's key *is* its
56
+ * object type slug, and is routinely different from its source table
57
+ * ("client-accounts" derived from "accounts"). So the key travels explicitly on
58
+ * every row rather than being left for the reverse derivation to guess.
59
+ */
60
+ interface MappingFromConfig {
61
+ /**
62
+ * The config entity key this row came from. Not a row column — the caller
63
+ * needs it to resolve a custom object type and to report what it wrote.
64
+ */
65
+ entityKey: string;
66
+ fieldMap: Record<string, string>;
67
+ filter?: FilterExpr;
68
+ /** Declared custom object, when the config declares one rather than pinning an id. */
69
+ objectType?: {
70
+ iconKey?: string;
71
+ name: string;
72
+ pluralName?: string;
73
+ };
74
+ /** The custom object type's slug — the entity key. Custom entities only. */
75
+ objectTypeSlug?: string;
76
+ projection?: ProjectionMapping;
77
+ relationships?: RelationshipsMap;
78
+ role: string;
79
+ sourceTable: string;
80
+ targetConcept: string;
81
+ targetObjectTypeId?: string;
82
+ }
83
+ declare function entitiesToMappings(config: ClivlyEntitiesConfig): MappingFromConfig[];
46
84
  //#endregion
47
- export { EntityMappingRow, mappingsToEntitiesConfig };
85
+ export { EntityMappingRow, MappingFromConfig, entitiesToMappings, mappingsToEntitiesConfig };