@clivly/sdk 0.9.1 → 0.9.3

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/dist/index.mjs CHANGED
@@ -1,6 +1,49 @@
1
1
  import { t as DRIZZLE_META } from "./drizzle-meta-DNqzkREf.mjs";
2
2
  import { createChatSessionHandler } from "./chat-session.mjs";
3
- import { defineClivlyConfig } from "@clivly/core/entity-config";
3
+ import { customObjectDeclarations, customObjectDeclarations as customObjectDeclarations$1, defineClivlyConfig, validateEntitiesConfig } from "@clivly/core/entity-config";
4
+ import { entitiesToMappings } from "@clivly/core/mappings-config";
5
+ //#region src/deployment-identity.ts
6
+ const MAX_DEPLOYMENT_ID_LENGTH = 128;
7
+ const PLATFORM_DEPLOYMENT_KEYS = [
8
+ "VERCEL_DEPLOYMENT_ID",
9
+ "RAILWAY_DEPLOYMENT_ID",
10
+ "FLY_MACHINE_VERSION",
11
+ "RENDER_GIT_COMMIT"
12
+ ];
13
+ const COMMIT_KEYS = [
14
+ "VERCEL_GIT_COMMIT_SHA",
15
+ "GIT_COMMIT_SHA",
16
+ "GITHUB_SHA"
17
+ ];
18
+ function firstNonBlank(values) {
19
+ for (const value of values) {
20
+ const trimmed = value?.trim();
21
+ if (trimmed) return trimmed;
22
+ }
23
+ return null;
24
+ }
25
+ function hashLocalIdentity(value) {
26
+ let hash = 0;
27
+ for (const character of value) hash = (hash * 31 + (character.codePointAt(0) ?? 0)) % Number.MAX_SAFE_INTEGER;
28
+ return Math.floor(hash).toString(36);
29
+ }
30
+ /**
31
+ * Resolve the release identity used to choose a presence room.
32
+ *
33
+ * The order is intentionally explicit: a deployment id distinguishes two
34
+ * rolling revisions more precisely than their shared commit, while the local
35
+ * fallback keeps developers' machines out of a shared room.
36
+ */
37
+ function resolveDeploymentId(env, configured) {
38
+ const deploymentId = firstNonBlank([
39
+ configured,
40
+ ...PLATFORM_DEPLOYMENT_KEYS.map((key) => env[key]),
41
+ ...COMMIT_KEYS.map((key) => env[key])
42
+ ]);
43
+ if (deploymentId) return deploymentId.slice(0, MAX_DEPLOYMENT_ID_LENGTH);
44
+ return `local-${hashLocalIdentity(firstNonBlank([env.HOSTNAME]) ?? "unidentified-host")}`;
45
+ }
46
+ //#endregion
4
47
  //#region src/entity-source.ts
5
48
  const CONCEPT_KEYS = {
6
49
  contact: "contacts",
@@ -36,7 +79,7 @@ function expectedSourceKey(concept) {
36
79
  }
37
80
  //#endregion
38
81
  //#region src/concept-coverage.ts
39
- const CHECK_NAME = "Entity → source coverage";
82
+ const CHECK_NAME$1 = "Entity → source coverage";
40
83
  function configuredKeys(config) {
41
84
  const src = config.source;
42
85
  if (!src) return [];
@@ -52,14 +95,14 @@ function checkConceptCoverage(config) {
52
95
  const entities = config.entities?.entities ?? {};
53
96
  const keys = Object.keys(entities);
54
97
  if (keys.length === 0) return {
55
- name: CHECK_NAME,
98
+ name: CHECK_NAME$1,
56
99
  ok: false,
57
100
  skipped: true,
58
101
  detail: "No entities configured — nothing to cross-check."
59
102
  };
60
103
  const unresolved = keys.filter((key) => resolveEntitySource(config, key) === null);
61
104
  if (unresolved.length === 0) return {
62
- name: CHECK_NAME,
105
+ name: CHECK_NAME$1,
63
106
  ok: true,
64
107
  detail: `${keys.length} entit${keys.length === 1 ? "y" : "ies"} resolve to a configured source.`
65
108
  };
@@ -74,7 +117,7 @@ function checkConceptCoverage(config) {
74
117
  return `"${key}" has concept "${concept}" but no \`source.${expectedSourceKey(concept)}\` is configured — it will never sync.`;
75
118
  });
76
119
  return {
77
- name: CHECK_NAME,
120
+ name: CHECK_NAME$1,
78
121
  ok: false,
79
122
  detail: `${problems.join(" ")} Configured source keys: ${listed}. \`source\` is keyed by CRM concept, not by entity key.`
80
123
  };
@@ -88,6 +131,7 @@ function slugify(value) {
88
131
  }
89
132
  function checkCustomObjectSlugs(config) {
90
133
  const custom = config.source?.custom ?? [];
134
+ const declared = config.entities ? customObjectDeclarations$1(config.entities) : [];
91
135
  if (custom.length === 0) return {
92
136
  name: "Custom object slugs",
93
137
  ok: false,
@@ -110,10 +154,17 @@ function checkCustomObjectSlugs(config) {
110
154
  ok: false,
111
155
  detail: problems.join(" ")
112
156
  };
157
+ const declaredSlugs = new Set(declared.map((d) => d.slug));
158
+ const undeclared = custom.map((source) => source.objectType).filter((slug) => !declaredSlugs.has(slug));
159
+ if (undeclared.length === 0) return {
160
+ name: "Custom object slugs",
161
+ ok: true,
162
+ detail: "Declared in clivly.config — `clivly push-schema` creates them."
163
+ };
113
164
  return {
114
165
  name: "Custom object slugs",
115
166
  ok: true,
116
- detail: `Ensure these object types exist in Clivly: ${custom.map((source) => source.objectType).join(", ")}.`
167
+ detail: `Ensure these object types exist in Clivly: ${undeclared.join(", ")}. Declare them with \`objectType\` in clivly.config and \`clivly push-schema\` will create them for you.`
117
168
  };
118
169
  }
119
170
  //#endregion
@@ -277,6 +328,75 @@ function errorMessage(error) {
277
328
  return error instanceof Error ? error.message : String(error);
278
329
  }
279
330
  //#endregion
331
+ //#region src/readiness.ts
332
+ /**
333
+ * Ask Clivly whether it can actually receive what this config pushes.
334
+ *
335
+ * Every other doctor check is local — the config loads, the tables are readable,
336
+ * the key is set — and a workspace can pass all of them while storing nothing,
337
+ * because whether a mapping exists to receive an entity is a fact only the
338
+ * server holds. `clivly status` reported "7 of 7 passed" against exactly that
339
+ * workspace. This is the rung that asks.
340
+ */
341
+ /**
342
+ * The entities to ask about, in the exact terms the sync pushes them.
343
+ *
344
+ * `source` is the resolved `SyncSource.entity` — the value that arrives at
345
+ * `ingestPush` as `input.source` and is compared against `mapping.sourceTable`.
346
+ * It is NOT the entity config's own `source` field, which names a host table;
347
+ * the two are routinely conflated, and using the wrong one would report every
348
+ * correctly mapped workspace as unmapped.
349
+ */
350
+ function readinessEntitiesFromConfig(config) {
351
+ const entities = [];
352
+ for (const key of Object.keys(config.entities?.entities ?? {})) {
353
+ const resolved = resolveEntitySource(config, key);
354
+ if (!resolved) continue;
355
+ const objectTypeSlug = resolved.source.objectType;
356
+ entities.push({
357
+ source: resolved.source.entity,
358
+ concept: resolved.concept,
359
+ ...resolved.concept === "custom" && objectTypeSlug ? { objectTypeSlug } : {}
360
+ });
361
+ }
362
+ return entities;
363
+ }
364
+ const CHECK_NAME = "Entities mapped in Clivly";
365
+ /**
366
+ * Turn a readiness answer into a doctor check.
367
+ *
368
+ * `null` means the server did not answer — older, unreachable, or the key was
369
+ * rejected — and is reported as skipped, not failed. Reporting unknown as broken
370
+ * would fail doctor for a workspace that is fine, which is the crying-wolf
371
+ * failure mode that trains people to stop reading the ladder.
372
+ */
373
+ function mappingReadinessCheck(requested, result) {
374
+ if (requested.length === 0) return {
375
+ name: CHECK_NAME,
376
+ ok: false,
377
+ skipped: true,
378
+ detail: "No entities to check."
379
+ };
380
+ if (!result) return {
381
+ name: CHECK_NAME,
382
+ ok: false,
383
+ skipped: true,
384
+ detail: "Clivly did not answer — this workspace's mappings could not be checked."
385
+ };
386
+ const unmapped = result.entities.filter((entity) => !entity.mapped);
387
+ if (unmapped.length === 0) return {
388
+ name: CHECK_NAME,
389
+ ok: true,
390
+ detail: `${result.entities.length} entit${result.entities.length === 1 ? "y has" : "ies have"} an active mapping.`
391
+ };
392
+ const names = unmapped.map((entity) => entity.objectTypeSlug ? `"${entity.source}" → "${entity.objectTypeSlug}"` : `"${entity.source}"`).join(", ");
393
+ return {
394
+ name: CHECK_NAME,
395
+ ok: false,
396
+ detail: `No active mapping receives ${names}, so rows pushed for ${unmapped.length === 1 ? "it" : "them"} are dropped. Map them in Integrations → Mapping in Clivly, then sync again.`
397
+ };
398
+ }
399
+ //#endregion
280
400
  //#region src/doctor.ts
281
401
  const TEMPORAL_CURSOR_TYPES = [
282
402
  "timestamp",
@@ -406,7 +526,7 @@ async function sampleFetchChecks(sources) {
406
526
  * hides the rest. `ok` is the AND of all non-skipped checks.
407
527
  */
408
528
  async function runDoctor(args) {
409
- const { config, probe } = args;
529
+ const { config, probe, readiness } = args;
410
530
  const checks = [];
411
531
  const sources = configuredSources(config);
412
532
  const hasKey = Boolean(config.apiKey);
@@ -442,12 +562,409 @@ async function runDoctor(args) {
442
562
  });
443
563
  checks.push(checkCustomObjectSlugs(config));
444
564
  checks.push(await namespaceCheck(config));
565
+ const requested = readinessEntitiesFromConfig(config);
566
+ const readinessResult = readiness && requested.length > 0 ? await readiness(requested) : null;
567
+ checks.push(mappingReadinessCheck(requested, readinessResult));
445
568
  return {
446
569
  ok: checks.every((check) => check.skipped || check.ok),
447
570
  checks
448
571
  };
449
572
  }
450
573
  //#endregion
574
+ //#region src/host-environment.ts
575
+ const KNOWN = [
576
+ "local_dev",
577
+ "preview",
578
+ "production"
579
+ ];
580
+ const PLATFORM_ENVIRONMENTS = [
581
+ {
582
+ key: "VERCEL_ENV",
583
+ toEnvironment: (value) => {
584
+ if (value === "production") return "production";
585
+ if (value === "preview") return "preview";
586
+ return value === "development" ? "local_dev" : void 0;
587
+ }
588
+ },
589
+ {
590
+ key: "CF_PAGES_BRANCH",
591
+ toEnvironment: (value) => value === "main" || value === "master" ? "production" : "preview"
592
+ },
593
+ {
594
+ key: "RAILWAY_ENVIRONMENT_NAME",
595
+ toEnvironment: (value) => value === "production" ? "production" : "preview"
596
+ }
597
+ ];
598
+ function fromNodeEnv(value) {
599
+ if (value === "production") return "production";
600
+ return value === "development" || value === "test" ? "local_dev" : void 0;
601
+ }
602
+ /**
603
+ * Resolve the environment to report. An explicitly configured value wins, since
604
+ * someone stated it on purpose; otherwise infer from the platform, then from
605
+ * `NODE_ENV`. Returns `undefined` when nothing places the app.
606
+ */
607
+ function detectHostEnvironment(env, configured) {
608
+ const stated = KNOWN.find((known) => known === configured);
609
+ if (stated) return stated;
610
+ for (const source of PLATFORM_ENVIRONMENTS) {
611
+ const raw = env[source.key]?.trim();
612
+ if (raw) {
613
+ const resolved = source.toEnvironment(raw);
614
+ if (resolved) return resolved;
615
+ }
616
+ }
617
+ return fromNodeEnv(env.NODE_ENV?.trim());
618
+ }
619
+ //#endregion
620
+ //#region src/presence-machine.ts
621
+ const PRESENCE_BACKOFF_BASE_MS = 1e3;
622
+ const PRESENCE_BACKOFF_MAX_MS = 3e4;
623
+ function effectsFor(state, overrides = {}) {
624
+ return {
625
+ connect: false,
626
+ httpHeartbeat: state !== "connected" && state !== "stopped",
627
+ retryInMs: null,
628
+ sendGoodbye: false,
629
+ ...overrides
630
+ };
631
+ }
632
+ function retryDelay(attempt, jitter) {
633
+ const capped = Math.min(PRESENCE_BACKOFF_BASE_MS * 2 ** attempt, PRESENCE_BACKOFF_MAX_MS);
634
+ return Math.round(capped * (1 - Math.max(0, Math.min(1, jitter)) * .5));
635
+ }
636
+ /**
637
+ * Decide the next presence action without performing I/O.
638
+ *
639
+ * `stopped` is absorbing, and fallback is derived from state rather than from
640
+ * individual branches, so a delayed event cannot resurrect a stopped client or
641
+ * silence HTTP while the socket is unconfirmed.
642
+ */
643
+ function presenceReduce(context, event, jitter) {
644
+ if (context.state === "stopped") return {
645
+ context,
646
+ effects: effectsFor("stopped")
647
+ };
648
+ if (context.state === "unsupported" && event.type !== "stop") return {
649
+ context,
650
+ effects: effectsFor("unsupported")
651
+ };
652
+ if (event.type === "stop") return {
653
+ context: {
654
+ attempt: context.attempt,
655
+ state: "stopped"
656
+ },
657
+ effects: effectsFor("stopped", { sendGoodbye: context.state === "connected" })
658
+ };
659
+ if (event.type === "start" && context.state === "idle") return {
660
+ context: {
661
+ attempt: context.attempt,
662
+ state: "connecting"
663
+ },
664
+ effects: effectsFor("connecting", { connect: true })
665
+ };
666
+ if (event.type === "unsupportedServer") return {
667
+ context: {
668
+ attempt: context.attempt,
669
+ state: "unsupported"
670
+ },
671
+ effects: effectsFor("unsupported")
672
+ };
673
+ if (event.type === "ready" && context.state === "connecting") return {
674
+ context: {
675
+ attempt: 0,
676
+ state: "connected"
677
+ },
678
+ effects: effectsFor("connected")
679
+ };
680
+ if (event.type === "retryDue" && context.state === "backoff") return {
681
+ context: {
682
+ attempt: context.attempt,
683
+ state: "connecting"
684
+ },
685
+ effects: effectsFor("connecting", { connect: true })
686
+ };
687
+ if ((event.type === "ticketFailed" || event.type === "socketClosed") && (context.state === "connecting" || context.state === "connected")) {
688
+ const attempt = context.attempt + 1;
689
+ return {
690
+ context: {
691
+ attempt,
692
+ state: "backoff"
693
+ },
694
+ effects: effectsFor("backoff", { retryInMs: retryDelay(attempt, jitter) })
695
+ };
696
+ }
697
+ return {
698
+ context,
699
+ effects: effectsFor(context.state)
700
+ };
701
+ }
702
+ //#endregion
703
+ //#region src/presence-client.ts
704
+ const PRESENCE_PING_INTERVAL_MS = 3e4;
705
+ const UNSUPPORTED_TICKET_STATUSES = /* @__PURE__ */ new Set([404, 501]);
706
+ function ticketUrl(options) {
707
+ return new URL("/clivly/sdk/presence/ticket", options.apiUrl).toString();
708
+ }
709
+ function metricsUrl(options) {
710
+ return new URL("/clivly/sdk/presence/events", options.apiUrl).toString();
711
+ }
712
+ function socketUrl(options, ticket) {
713
+ const url = new URL("/clivly/sdk/presence", options.apiUrl);
714
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
715
+ url.searchParams.set("deploymentId", options.deploymentId);
716
+ url.searchParams.set("ticket", ticket);
717
+ if (options.environment) url.searchParams.set("environment", options.environment);
718
+ return url.toString();
719
+ }
720
+ /**
721
+ * Run the pure presence machine against the network and timers it requests.
722
+ *
723
+ * Every listener captures a connection sequence. Once a socket is replaced or
724
+ * the client stops, events from that socket are ignored before reaching the
725
+ * reducer, mirroring the room's generation fence on the client side.
726
+ */
727
+ function createPresenceClient(options) {
728
+ let context = {
729
+ attempt: 0,
730
+ state: "idle"
731
+ };
732
+ let connectionSequence = 0;
733
+ let socket = null;
734
+ let pingTimer = null;
735
+ let retryTimer = null;
736
+ let fallbackStartedAt = null;
737
+ const instanceId = crypto.randomUUID();
738
+ const reportMetric = (event, fields) => {
739
+ try {
740
+ options.fetcher(metricsUrl(options), {
741
+ body: JSON.stringify({
742
+ event,
743
+ ...fields
744
+ }),
745
+ headers: {
746
+ "content-type": "application/json",
747
+ authorization: `Bearer ${options.token}`
748
+ },
749
+ method: "POST"
750
+ }).catch(() => {});
751
+ } catch {}
752
+ };
753
+ const clearPingTimer = () => {
754
+ if (pingTimer) {
755
+ clearInterval(pingTimer);
756
+ pingTimer = null;
757
+ }
758
+ };
759
+ const clearRetryTimer = () => {
760
+ if (retryTimer) {
761
+ clearTimeout(retryTimer);
762
+ retryTimer = null;
763
+ }
764
+ };
765
+ const startPinging = () => {
766
+ if (pingTimer) return;
767
+ pingTimer = setInterval(() => {
768
+ if (context.state === "connected") socket?.send(JSON.stringify({ type: "ping" }));
769
+ }, PRESENCE_PING_INTERVAL_MS);
770
+ };
771
+ const reportFallbackTransition = (previousState, event) => {
772
+ const enteredFallback = previousState !== "backoff" && context.state === "backoff";
773
+ const enteredUnsupported = previousState !== "unsupported" && context.state === "unsupported";
774
+ if (enteredFallback && fallbackStartedAt === null) {
775
+ fallbackStartedAt = Date.now();
776
+ reportMetric("fallback_started", { transport: "http" });
777
+ }
778
+ if (enteredUnsupported) {
779
+ clearRetryTimer();
780
+ reportMetric("fallback_started", {
781
+ outcome: "unsupported",
782
+ transport: "http"
783
+ });
784
+ }
785
+ if (event.type === "ready" && fallbackStartedAt !== null) {
786
+ reportMetric("fallback_duration", {
787
+ durationMs: Date.now() - fallbackStartedAt,
788
+ transport: "http"
789
+ });
790
+ fallbackStartedAt = null;
791
+ }
792
+ };
793
+ const dispatch = async (event) => {
794
+ const previousState = context.state;
795
+ const next = presenceReduce(context, event, Math.random());
796
+ context = next.context;
797
+ reportFallbackTransition(previousState, event);
798
+ options.onHttpHeartbeat(next.effects.httpHeartbeat);
799
+ if (previousState === "connected" && context.state !== "connected") clearPingTimer();
800
+ if (previousState !== "connected" && context.state === "connected") startPinging();
801
+ if (next.effects.sendGoodbye) socket?.send(JSON.stringify({ type: "goodbye" }));
802
+ if (next.effects.retryInMs !== null) {
803
+ clearRetryTimer();
804
+ retryTimer = setTimeout(async () => {
805
+ retryTimer = null;
806
+ await dispatch({ type: "retryDue" });
807
+ }, next.effects.retryInMs);
808
+ }
809
+ if (next.effects.connect) await connect();
810
+ };
811
+ const failTicketIfCurrent = async (sequence, event = "ticketFailed") => {
812
+ if (sequence === connectionSequence) await dispatch({ type: event });
813
+ };
814
+ const requestTicket = async (sequence) => {
815
+ try {
816
+ const response = await options.fetcher(ticketUrl(options), {
817
+ headers: { authorization: `Bearer ${options.token}` },
818
+ method: "POST"
819
+ });
820
+ if (sequence !== connectionSequence) return null;
821
+ if (!response?.ok) {
822
+ await failTicketIfCurrent(sequence, response && UNSUPPORTED_TICKET_STATUSES.has(response.status) ? "unsupportedServer" : "ticketFailed");
823
+ return null;
824
+ }
825
+ if (sequence !== connectionSequence) return null;
826
+ const body = await response.json();
827
+ if (sequence !== connectionSequence) return null;
828
+ if (typeof body.ticket === "string" && body.ticket !== "") return body.ticket;
829
+ await failTicketIfCurrent(sequence);
830
+ return null;
831
+ } catch {
832
+ await failTicketIfCurrent(sequence);
833
+ return null;
834
+ }
835
+ };
836
+ const connect = async () => {
837
+ const sequence = connectionSequence + 1;
838
+ connectionSequence = sequence;
839
+ reportMetric("connection_attempt", {
840
+ attempt: context.attempt,
841
+ transport: "websocket"
842
+ });
843
+ if (context.attempt > 0) reportMetric("reconnect", {
844
+ attempt: context.attempt,
845
+ transport: "websocket"
846
+ });
847
+ const ticket = await requestTicket(sequence);
848
+ if (!(ticket && sequence === connectionSequence && context.state !== "stopped")) return;
849
+ const nextSocket = options.socketFactory(socketUrl(options, ticket));
850
+ socket = nextSocket;
851
+ nextSocket.addEventListener("open", async () => {
852
+ if (sequence !== connectionSequence) return;
853
+ const hello = options.getHello();
854
+ nextSocket.send(JSON.stringify({
855
+ deploymentId: options.deploymentId,
856
+ issues: hello.issues,
857
+ instanceId,
858
+ sdk: hello.sdk,
859
+ type: "hello"
860
+ }));
861
+ await dispatch({ type: "socketOpen" });
862
+ });
863
+ nextSocket.addEventListener("message", async (event) => {
864
+ if (sequence !== connectionSequence) return;
865
+ const data = event.data;
866
+ if (typeof data !== "string") return;
867
+ try {
868
+ if (JSON.parse(data).type === "ready") await dispatch({ type: "ready" });
869
+ } catch {}
870
+ });
871
+ nextSocket.addEventListener("close", async () => {
872
+ if (sequence !== connectionSequence) return;
873
+ connectionSequence += 1;
874
+ socket = null;
875
+ await dispatch({ type: "socketClosed" });
876
+ });
877
+ };
878
+ return {
879
+ start: async () => {
880
+ await dispatch({ type: "start" });
881
+ },
882
+ stop: async () => {
883
+ connectionSequence += 1;
884
+ clearRetryTimer();
885
+ await dispatch({ type: "stop" });
886
+ if (fallbackStartedAt !== null) {
887
+ reportMetric("fallback_duration", {
888
+ durationMs: Date.now() - fallbackStartedAt,
889
+ transport: "http"
890
+ });
891
+ fallbackStartedAt = null;
892
+ }
893
+ clearPingTimer();
894
+ socket?.close(1e3, "goodbye");
895
+ socket = null;
896
+ }
897
+ };
898
+ }
899
+ //#endregion
900
+ //#region src/presence-support.ts
901
+ /**
902
+ * This stays HTTP until the production gate in docs/presence-rollout-gate.md
903
+ * has recorded a passing seven-day window. The later default-on rollout is a
904
+ * one-line change here; all capability and escape-hatch behavior is stable.
905
+ */
906
+ const DEFAULT_TRANSPORT = "http";
907
+ function resolvePresenceTransport(host, configured) {
908
+ if (configured) return {
909
+ reason: "explicit configuration",
910
+ transport: configured
911
+ };
912
+ const environmentTransport = host.env.CLIVLY_PRESENCE_TRANSPORT;
913
+ if (environmentTransport === "http" || environmentTransport === "websocket") return {
914
+ reason: "CLIVLY_PRESENCE_TRANSPORT environment override",
915
+ transport: environmentTransport
916
+ };
917
+ if (host.serverless) return {
918
+ reason: "serverless hosts cannot retain a socket",
919
+ transport: "http"
920
+ };
921
+ if (!host.hasWebSocket) return {
922
+ reason: "WebSocket is unavailable in this host",
923
+ transport: "http"
924
+ };
925
+ return {
926
+ reason: "SDK default",
927
+ transport: DEFAULT_TRANSPORT
928
+ };
929
+ }
930
+ //#endregion
931
+ //#region src/push-outcome.ts
932
+ function isCount(value) {
933
+ return typeof value === "number" && Number.isFinite(value);
934
+ }
935
+ /**
936
+ * A message describing a push that stored nothing, or `null` when there is
937
+ * nothing to report.
938
+ *
939
+ * Deliberately silent unless the server reported all three counts as numbers: an
940
+ * older cloud answers 200 without them, and warning on an unrecognised shape
941
+ * would fire on every push against it.
942
+ */
943
+ function describeSilentPush(target, body) {
944
+ if (typeof body !== "object" || body === null) return null;
945
+ const { source, objectType } = target;
946
+ const { received, upserted, archived } = body;
947
+ if (!(isCount(received) && isCount(upserted) && isCount(archived))) return null;
948
+ if (received === 0 || upserted > 0 || archived > 0) return null;
949
+ return `Clivly sync: pushed ${received} row(s) for ${objectType ? `"${source}" → "${objectType}"` : `"${source}"`} and none were stored — no active mapping in this workspace targets it, so the rows were dropped. Map it in Integrations → Mapping, then sync again.`;
950
+ }
951
+ //#endregion
952
+ //#region src/self-check.ts
953
+ function selfCheck(input) {
954
+ const issues = [];
955
+ if (input.reportsTriggerUrl && !input.hasTriggerSecret) issues.push({
956
+ code: "trigger_route_unauthenticated",
957
+ message: "Your app's sync route is reachable but is not checking signatures.",
958
+ fix: "Set CLIVLY_SYNC_TRIGGER_SECRET in the environment your app runs in, then restart it."
959
+ });
960
+ if (input.lastPushFailed) issues.push({
961
+ code: "sync_push_failing",
962
+ message: input.lastPushError ? `Your app could not send its data: ${input.lastPushError}` : "Your app could not send its data to Clivly.",
963
+ fix: "Check your app's logs for the failing sync, then run `npx clivly doctor`."
964
+ });
965
+ return issues;
966
+ }
967
+ //#endregion
451
968
  //#region src/sync.ts
452
969
  const DEFAULT_ID_FIELD = "id";
453
970
  function isAfter(candidate, current) {
@@ -901,16 +1418,41 @@ function createClivlySDK(config) {
901
1418
  const apiUrl = (config.apiUrl ?? DEFAULT_API_URL).replace(TRAILING_SLASH, "");
902
1419
  const heartbeatIntervalMs = config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_MS;
903
1420
  const syncIntervalMs = config.sync?.intervalMs ?? heartbeatIntervalMs;
1421
+ const serverless = isLikelyServerless();
1422
+ const presenceEnabled = resolvePresenceTransport({
1423
+ env: config.env ?? process.env,
1424
+ hasWebSocket: typeof WebSocket === "function",
1425
+ serverless
1426
+ }, config.presence?.transport).transport === "websocket";
1427
+ const deploymentId = presenceEnabled ? resolveDeploymentId(config.env ?? process.env, config.presence?.deploymentId) : null;
904
1428
  const timers = {
905
1429
  heartbeat: null,
906
1430
  sync: null
907
1431
  };
1432
+ let presenceClient = null;
1433
+ let httpHeartbeatActive = true;
908
1434
  const batchSize = config.sync?.batchSize ?? DEFAULT_BATCH_SIZE;
909
1435
  const cursorStore = config.cursorStore ?? createMemoryCursorStore();
910
1436
  const fetcher = createFetcher(config.retry);
911
1437
  let warnedUnsignedTrigger = false;
912
1438
  let observedUrl;
913
1439
  let lastPushFailure = null;
1440
+ let lastPush = { failed: false };
1441
+ const warnedSilentTargets = /* @__PURE__ */ new Set();
1442
+ const warnIfSilent = async (target, response) => {
1443
+ if (typeof response.json !== "function") return;
1444
+ let outcome;
1445
+ try {
1446
+ outcome = describeSilentPush(target, await response.json());
1447
+ } catch {
1448
+ return;
1449
+ }
1450
+ if (!outcome) return;
1451
+ const key = `${target.source} ${target.objectType ?? ""}`;
1452
+ if (warnedSilentTargets.has(key)) return;
1453
+ warnedSilentTargets.add(key);
1454
+ console.warn(outcome);
1455
+ };
914
1456
  const createPush = (runId) => async (path, body) => {
915
1457
  const response = await fetcher(`${apiUrl}${path}`, {
916
1458
  method: "POST",
@@ -921,7 +1463,13 @@ function createClivlySDK(config) {
921
1463
  },
922
1464
  body: JSON.stringify(body)
923
1465
  });
924
- if (response?.ok) return true;
1466
+ if (response?.ok) {
1467
+ await warnIfSilent({
1468
+ source: body.source,
1469
+ objectType: body.objectType
1470
+ }, response);
1471
+ return true;
1472
+ }
925
1473
  lastPushFailure ??= { status: response?.status ?? null };
926
1474
  return false;
927
1475
  };
@@ -973,7 +1521,15 @@ function createClivlySDK(config) {
973
1521
  if (!await syncOne(config.source.contacts, SYNC_CONTACTS_PATH, mode, void 0, runId)) failed.push(config.source.contacts.entity);
974
1522
  if (config.source.deals && !await syncOne(config.source.deals, SYNC_DEALS_PATH, mode, void 0, runId)) failed.push(config.source.deals.entity);
975
1523
  failed.push(...await syncCustomSources(config.source.custom, mode, runId));
976
- if (failed.length > 0) throw new Error(describePushFailure(failed));
1524
+ if (failed.length > 0) {
1525
+ const message = describePushFailure(failed);
1526
+ lastPush = {
1527
+ failed: true,
1528
+ error: message
1529
+ };
1530
+ throw new Error(message);
1531
+ }
1532
+ lastPush = { failed: false };
977
1533
  };
978
1534
  const runSampleSync = async (entityKey, runId) => {
979
1535
  const resolved = resolveSampleSource(config, entityKey);
@@ -981,33 +1537,129 @@ function createClivlySDK(config) {
981
1537
  lastPushFailure = null;
982
1538
  if (!await syncOne(resolved.source, resolved.path, "delta", resolved.objectType, runId, SAMPLE_BATCH_SIZE, SAMPLE_BATCH_SIZE, false)) throw new Error(describePushFailure([resolved.source.entity]));
983
1539
  };
984
- const sendHeartbeat = () => fetcher(`${apiUrl}/clivly/sdk/heartbeat`, {
985
- method: "POST",
986
- headers: {
987
- "content-type": "application/json",
988
- authorization: `Bearer ${config.apiKey}`
989
- },
990
- body: JSON.stringify({
991
- framework: config.framework,
992
- orm: config.orm,
993
- sdkVersion: config.sdkVersion,
994
- contactEntity: resolveContactEntity(config),
995
- namespaceReady: config.namespaceReady ?? false,
996
- schema: buildSchema(config),
997
- ...(() => {
998
- const url = resolveTriggerUrl({
999
- configuredUrl: config.syncTrigger?.url,
1000
- configuredPath: config.syncTrigger?.path,
1001
- env: config.env ?? process.env,
1002
- observedUrl
1003
- });
1004
- return url ? { syncTrigger: { url } } : {};
1005
- })()
1006
- })
1007
- });
1540
+ const sendHeartbeat = () => {
1541
+ const triggerUrl = resolveTriggerUrl({
1542
+ configuredUrl: config.syncTrigger?.url,
1543
+ configuredPath: config.syncTrigger?.path,
1544
+ env: config.env ?? process.env,
1545
+ observedUrl
1546
+ });
1547
+ return fetcher(`${apiUrl}/clivly/sdk/heartbeat`, {
1548
+ method: "POST",
1549
+ headers: {
1550
+ "content-type": "application/json",
1551
+ authorization: `Bearer ${config.apiKey}`
1552
+ },
1553
+ body: JSON.stringify({
1554
+ ...deploymentId ? { deploymentId } : {},
1555
+ framework: config.framework,
1556
+ orm: config.orm,
1557
+ sdkVersion: config.sdkVersion,
1558
+ contactEntity: resolveContactEntity(config),
1559
+ namespaceReady: config.namespaceReady ?? false,
1560
+ schema: buildSchema(config),
1561
+ heartbeatIntervalMs,
1562
+ environment: detectHostEnvironment(config.env ?? process.env, config.environment),
1563
+ issues: selfCheck({
1564
+ hasTriggerSecret: Boolean(config.syncTrigger?.secret),
1565
+ reportsTriggerUrl: Boolean(triggerUrl),
1566
+ lastPushFailed: lastPush.failed,
1567
+ lastPushError: lastPush.error
1568
+ }),
1569
+ ...triggerUrl ? { syncTrigger: { url: triggerUrl } } : {}
1570
+ })
1571
+ });
1572
+ };
1008
1573
  const heartbeat = async () => {
1009
1574
  return (await sendHeartbeat())?.ok ?? false;
1010
1575
  };
1576
+ const startHeartbeatInterval = () => {
1577
+ if (timers.heartbeat) return;
1578
+ timers.heartbeat = setInterval(() => {
1579
+ heartbeat().catch(() => {});
1580
+ }, heartbeatIntervalMs);
1581
+ timers.heartbeat.unref?.();
1582
+ };
1583
+ const stopHeartbeatInterval = () => {
1584
+ if (timers.heartbeat) {
1585
+ clearInterval(timers.heartbeat);
1586
+ timers.heartbeat = null;
1587
+ }
1588
+ };
1589
+ /**
1590
+ * Create any custom object types this config declares that the workspace does
1591
+ * not have yet, so `clivly.config.ts` is self-contained — no dashboard visit
1592
+ * and no server-generated id pasted into the file. Keyed by entity slug.
1593
+ *
1594
+ * Create-only and idempotent server-side: existing slugs resolve to their
1595
+ * current id untouched, and nothing is renamed or removed. Returns `null` if
1596
+ * the request never landed, so callers can distinguish "nothing to do" (an
1597
+ * empty array) from "could not reach Clivly".
1598
+ */
1599
+ const ensureCustomObjects = async () => {
1600
+ const objects = config.entities ? customObjectDeclarations$1(config.entities) : [];
1601
+ if (objects.length === 0) return [];
1602
+ const response = await fetcher(`${apiUrl}/clivly/sdk/object-types`, {
1603
+ method: "POST",
1604
+ headers: {
1605
+ "content-type": "application/json",
1606
+ authorization: `Bearer ${config.apiKey}`
1607
+ },
1608
+ body: JSON.stringify({ objects })
1609
+ });
1610
+ if (!response?.ok) return null;
1611
+ return (await response.json()).objectTypes ?? [];
1612
+ };
1613
+ const readSchemaError = async (response) => {
1614
+ if (response?.status !== 400) return null;
1615
+ try {
1616
+ const body = await response.json();
1617
+ return typeof body.message === "string" ? body.message : null;
1618
+ } catch {
1619
+ return null;
1620
+ }
1621
+ };
1622
+ /**
1623
+ * Declare this host's whole schema: its custom object types AND the mappings
1624
+ * that make them receive rows.
1625
+ *
1626
+ * `ensureCustomObjects` above creates the object and nothing else, which left
1627
+ * config-as-code ending at an empty container and a sync that stored nothing.
1628
+ * This is the call that finishes it.
1629
+ *
1630
+ * Returns `null` when the request never landed — including against a server
1631
+ * old enough not to have the route — so a caller can tell "nothing to do"
1632
+ * from "could not reach Clivly", and fall back to the objects-only endpoint.
1633
+ */
1634
+ const ensureSchema = async () => {
1635
+ const objects = config.entities ? customObjectDeclarations$1(config.entities) : [];
1636
+ const mappings = config.entities ? entitiesToMappings(config.entities) : [];
1637
+ if (objects.length === 0 && mappings.length === 0) return {
1638
+ objectTypes: [],
1639
+ mappings: []
1640
+ };
1641
+ const response = await fetcher(`${apiUrl}/clivly/sdk/schema`, {
1642
+ method: "POST",
1643
+ headers: {
1644
+ "content-type": "application/json",
1645
+ authorization: `Bearer ${config.apiKey}`
1646
+ },
1647
+ body: JSON.stringify({
1648
+ objects,
1649
+ mappings
1650
+ })
1651
+ });
1652
+ if (!response?.ok) {
1653
+ const message = await readSchemaError(response);
1654
+ if (message) throw new Error(message);
1655
+ return null;
1656
+ }
1657
+ const body = await response.json();
1658
+ return {
1659
+ objectTypes: body.objectTypes ?? [],
1660
+ mappings: body.mappings ?? []
1661
+ };
1662
+ };
1011
1663
  const connect = async () => {
1012
1664
  const response = await sendHeartbeat();
1013
1665
  if (response === null) return {
@@ -1049,6 +1701,7 @@ function createClivlySDK(config) {
1049
1701
  reason: "ok",
1050
1702
  retryable: false,
1051
1703
  org: body?.org,
1704
+ workspace: body?.workspace ?? null,
1052
1705
  ..."appLastSeenAt" in body ? { appLastSeenAt: body.appLastSeenAt ?? null } : {}
1053
1706
  };
1054
1707
  }
@@ -1059,9 +1712,31 @@ function createClivlySDK(config) {
1059
1712
  retryable: isRetryableStatus(response.status)
1060
1713
  };
1061
1714
  };
1715
+ const probeReadiness = async (entities) => {
1716
+ const response = await fetcher(`${apiUrl}/clivly/sdk/readiness`, {
1717
+ method: "POST",
1718
+ headers: {
1719
+ "content-type": "application/json",
1720
+ authorization: `Bearer ${config.apiKey}`
1721
+ },
1722
+ body: JSON.stringify({ entities })
1723
+ });
1724
+ if (!response?.ok) return null;
1725
+ try {
1726
+ const body = await response.json();
1727
+ if (typeof body?.ready !== "boolean" || !Array.isArray(body.entities)) return null;
1728
+ return {
1729
+ ready: body.ready,
1730
+ entities: body.entities
1731
+ };
1732
+ } catch {
1733
+ return null;
1734
+ }
1735
+ };
1062
1736
  const doctor = () => runDoctor({
1063
1737
  config,
1064
- probe: ping
1738
+ probe: ping,
1739
+ readiness: probeReadiness
1065
1740
  });
1066
1741
  const runScheduled = async (options) => {
1067
1742
  await heartbeat();
@@ -1115,14 +1790,48 @@ function createClivlySDK(config) {
1115
1790
  }
1116
1791
  };
1117
1792
  const start = async () => {
1118
- if (isLikelyServerless()) console.warn("clivly.start() uses a setInterval keep-alive loop, which does not persist on serverless/edge runtimes — it will stop ticking after the first response. Call clivly.runScheduled() from a scheduled/cron trigger, or mount clivly.createClivlyHandler() at a route instead.");
1793
+ if (serverless) console.warn("clivly.start() uses a setInterval keep-alive loop, which does not persist on serverless/edge runtimes — it will stop ticking after the first response. Call clivly.runScheduled() from a scheduled/cron trigger, or mount clivly.createClivlyHandler() at a route instead.");
1119
1794
  await heartbeat();
1120
1795
  if (config.sync?.onBoot !== false) await runSync().catch(() => {});
1121
- if (!timers.heartbeat) {
1122
- timers.heartbeat = setInterval(() => {
1123
- heartbeat().catch(() => {});
1124
- }, heartbeatIntervalMs);
1125
- timers.heartbeat.unref?.();
1796
+ startHeartbeatInterval();
1797
+ if (presenceEnabled && deploymentId && !serverless && !presenceClient) try {
1798
+ presenceClient = createPresenceClient({
1799
+ apiUrl,
1800
+ deploymentId,
1801
+ environment: detectHostEnvironment(config.env ?? process.env, config.environment),
1802
+ fetcher,
1803
+ getHello: () => ({
1804
+ issues: selfCheck({
1805
+ hasTriggerSecret: Boolean(config.syncTrigger?.secret),
1806
+ reportsTriggerUrl: Boolean(resolveTriggerUrl({
1807
+ configuredUrl: config.syncTrigger?.url,
1808
+ configuredPath: config.syncTrigger?.path,
1809
+ env: config.env ?? process.env,
1810
+ observedUrl
1811
+ })),
1812
+ lastPushFailed: lastPush.failed,
1813
+ lastPushError: lastPush.error
1814
+ }),
1815
+ sdk: {
1816
+ framework: config.framework ?? null,
1817
+ orm: config.orm ?? null,
1818
+ version: config.sdkVersion ?? null
1819
+ }
1820
+ }),
1821
+ onHttpHeartbeat: (active) => {
1822
+ if (active) {
1823
+ startHeartbeatInterval();
1824
+ if (!httpHeartbeatActive) heartbeat().catch(() => {});
1825
+ } else stopHeartbeatInterval();
1826
+ httpHeartbeatActive = active;
1827
+ },
1828
+ socketFactory: (url) => new WebSocket(url),
1829
+ token: config.apiKey
1830
+ });
1831
+ await presenceClient.start();
1832
+ } catch (error) {
1833
+ presenceClient = null;
1834
+ console.warn("Clivly presence socket unavailable; using HTTP heartbeat", error);
1126
1835
  }
1127
1836
  if (!timers.sync && config.source) {
1128
1837
  timers.sync = setInterval(() => {
@@ -1131,11 +1840,10 @@ function createClivlySDK(config) {
1131
1840
  timers.sync.unref?.();
1132
1841
  }
1133
1842
  };
1134
- const stop = () => {
1135
- if (timers.heartbeat) {
1136
- clearInterval(timers.heartbeat);
1137
- timers.heartbeat = null;
1138
- }
1843
+ const stop = async () => {
1844
+ await presenceClient?.stop();
1845
+ presenceClient = null;
1846
+ stopHeartbeatInterval();
1139
1847
  if (timers.sync) {
1140
1848
  clearInterval(timers.sync);
1141
1849
  timers.sync = null;
@@ -1153,6 +1861,8 @@ function createClivlySDK(config) {
1153
1861
  start,
1154
1862
  stop,
1155
1863
  heartbeat,
1864
+ ensureCustomObjects,
1865
+ ensureSchema,
1156
1866
  connect,
1157
1867
  ping,
1158
1868
  doctor,
@@ -1163,4 +1873,4 @@ function createClivlySDK(config) {
1163
1873
  };
1164
1874
  }
1165
1875
  //#endregion
1166
- export { ClivlyAuthError, ClivlyError, ClivlyNetworkError, ClivlyRateLimitError, createChatSessionHandler, createClivlySDK, defineClivlyConfig };
1876
+ export { ClivlyAuthError, ClivlyError, ClivlyNetworkError, ClivlyRateLimitError, createChatSessionHandler, createClivlySDK, customObjectDeclarations, defineClivlyConfig, validateEntitiesConfig };