clivly 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.
Files changed (84) hide show
  1. package/README.md +19 -7
  2. package/core-drizzle.d.cts +1 -1
  3. package/core-drizzle.d.mts +1 -1
  4. package/core-entity-config.cjs +2 -1
  5. package/core-entity-config.d.cts +2 -2
  6. package/core-entity-config.d.mts +2 -2
  7. package/core-entity-config.mjs +2 -2
  8. package/core-entity-heuristics.d.cts +1 -1
  9. package/core-entity-heuristics.d.mts +1 -1
  10. package/core-mapping-form.d.cts +1 -1
  11. package/core-mapping-form.d.mts +1 -1
  12. package/core-mappings-config.cjs +2 -1
  13. package/core-mappings-config.d.cts +2 -2
  14. package/core-mappings-config.d.mts +2 -2
  15. package/core-mappings-config.mjs +2 -2
  16. package/core-sync-engine.d.cts +1 -1
  17. package/core-sync-engine.d.mts +1 -1
  18. package/core-view-compiler.d.cts +1 -1
  19. package/core-view-compiler.d.mts +1 -1
  20. package/core.cjs +3 -3
  21. package/core.d.cts +6 -6
  22. package/core.d.mts +6 -6
  23. package/core.mjs +3 -3
  24. package/{drizzle-DooDUGqO.d.mts → drizzle-CiK8MMEZ.d.mts} +1 -1
  25. package/{drizzle-jz0lrAgl.d.cts → drizzle-DEtRrqtc.d.cts} +1 -1
  26. package/drizzle.d.cts +2 -2
  27. package/drizzle.d.mts +2 -2
  28. package/{entity-config-fKQhimtD.d.mts → entity-config-B2RA53nw.d.cts} +25 -1
  29. package/{entity-config-fKQhimtD.d.cts → entity-config-B2RA53nw.d.mts} +25 -1
  30. package/{entity-config-nuXge6rY.cjs → entity-config-DtBsl2MQ.cjs} +36 -12
  31. package/{entity-config-D08zAeFY.mjs → entity-config-kUHri9yA.mjs} +31 -13
  32. package/index.cjs +371 -27
  33. package/index.d.cts +1 -0
  34. package/index.d.mts +1 -0
  35. package/index.mjs +371 -27
  36. package/{internal-C-YBW2DB.d.cts → internal-CDY8g9Ic.d.mts} +1 -1
  37. package/{internal-D4HDi2GC.cjs → internal-CwnEG6Wl.cjs} +1 -1
  38. package/{internal-BLCK81kB.d.mts → internal-Da_dlcQ_.d.cts} +1 -1
  39. package/{internal-BGVUG312.mjs → internal-Df-PtOH_.mjs} +1 -1
  40. package/internal-drizzle.d.cts +1 -1
  41. package/internal-drizzle.d.mts +1 -1
  42. package/internal-entity-config.cjs +2 -1
  43. package/internal-entity-config.d.cts +2 -2
  44. package/internal-entity-config.d.mts +2 -2
  45. package/internal-entity-config.mjs +2 -2
  46. package/internal-entity-heuristics.cjs +1 -1
  47. package/internal-entity-heuristics.d.cts +1 -1
  48. package/internal-entity-heuristics.d.mts +1 -1
  49. package/internal-entity-heuristics.mjs +1 -1
  50. package/internal-mapping-form.d.cts +1 -1
  51. package/internal-mapping-form.d.mts +1 -1
  52. package/internal-mappings-config.cjs +2 -1
  53. package/internal-mappings-config.d.cts +2 -2
  54. package/internal-mappings-config.d.mts +2 -2
  55. package/internal-mappings-config.mjs +2 -2
  56. package/internal-sync-engine.d.cts +1 -1
  57. package/internal-sync-engine.d.mts +1 -1
  58. package/internal-view-compiler.d.cts +1 -1
  59. package/internal-view-compiler.d.mts +1 -1
  60. package/internal.cjs +3 -3
  61. package/internal.d.cts +6 -6
  62. package/internal.d.mts +6 -6
  63. package/internal.mjs +3 -3
  64. package/{mapping-form-Du36VZr1.d.mts → mapping-form-DH77BXLv.d.mts} +1 -1
  65. package/{mapping-form-DhY0iFmN.d.cts → mapping-form-J0aU6O7l.d.cts} +1 -1
  66. package/{mappings-config-C8ynKmDK.cjs → mappings-config-B0RqFLxu.cjs} +30 -1
  67. package/{mappings-config-DNaYGyKN.mjs → mappings-config-B1b1HVBg.mjs} +25 -2
  68. package/{mappings-config-B1qNM8VL.d.cts → mappings-config-D9dsonoi.d.mts} +40 -2
  69. package/{mappings-config-BChZsany.d.mts → mappings-config-RQLGx8ub.d.cts} +40 -2
  70. package/{namespace-probe-BkUs8raG.d.mts → namespace-probe-C6aHjedO.d.cts} +65 -3
  71. package/{namespace-probe-aRFmpU85.d.cts → namespace-probe-Dj-0Q3Hb.d.mts} +65 -3
  72. package/package.json +1 -1
  73. package/{sdk-Bj-ZDf4u.cjs → sdk-D4Wp_uxb.cjs} +754 -44
  74. package/{sdk-BvRD2jAp.mjs → sdk-DXV8M_Io.mjs} +754 -44
  75. package/sdk.cjs +4 -2
  76. package/sdk.d.cts +3 -3
  77. package/sdk.d.mts +3 -3
  78. package/sdk.mjs +3 -3
  79. package/{sync-engine-DHQ7rXZe.d.mts → sync-engine-BG2O1an2.d.cts} +2 -2
  80. package/{sync-engine-DiPjH53O.d.cts → sync-engine-DgQbrJON.d.mts} +2 -2
  81. package/{view-compiler-CcUwzZut.d.mts → view-compiler-Bo7pqXW3.d.mts} +1 -1
  82. package/{view-compiler-DqAdVxLq.d.cts → view-compiler-CmEdAMwj.d.cts} +1 -1
  83. package/vite.d.cts +1 -1
  84. package/vite.d.mts +1 -1
@@ -1,4 +1,48 @@
1
+ import { o as customObjectDeclarations } from "./entity-config-kUHri9yA.mjs";
2
+ import { t as entitiesToMappings } from "./mappings-config-B1b1HVBg.mjs";
1
3
  import { t as DRIZZLE_META } from "./drizzle-meta-D-5k3n0i.mjs";
4
+ //#region ../sdk/src/deployment-identity.ts
5
+ const MAX_DEPLOYMENT_ID_LENGTH = 128;
6
+ const PLATFORM_DEPLOYMENT_KEYS = [
7
+ "VERCEL_DEPLOYMENT_ID",
8
+ "RAILWAY_DEPLOYMENT_ID",
9
+ "FLY_MACHINE_VERSION",
10
+ "RENDER_GIT_COMMIT"
11
+ ];
12
+ const COMMIT_KEYS = [
13
+ "VERCEL_GIT_COMMIT_SHA",
14
+ "GIT_COMMIT_SHA",
15
+ "GITHUB_SHA"
16
+ ];
17
+ function firstNonBlank(values) {
18
+ for (const value of values) {
19
+ const trimmed = value?.trim();
20
+ if (trimmed) return trimmed;
21
+ }
22
+ return null;
23
+ }
24
+ function hashLocalIdentity(value) {
25
+ let hash = 0;
26
+ for (const character of value) hash = (hash * 31 + (character.codePointAt(0) ?? 0)) % Number.MAX_SAFE_INTEGER;
27
+ return Math.floor(hash).toString(36);
28
+ }
29
+ /**
30
+ * Resolve the release identity used to choose a presence room.
31
+ *
32
+ * The order is intentionally explicit: a deployment id distinguishes two
33
+ * rolling revisions more precisely than their shared commit, while the local
34
+ * fallback keeps developers' machines out of a shared room.
35
+ */
36
+ function resolveDeploymentId(env, configured) {
37
+ const deploymentId = firstNonBlank([
38
+ configured,
39
+ ...PLATFORM_DEPLOYMENT_KEYS.map((key) => env[key]),
40
+ ...COMMIT_KEYS.map((key) => env[key])
41
+ ]);
42
+ if (deploymentId) return deploymentId.slice(0, MAX_DEPLOYMENT_ID_LENGTH);
43
+ return `local-${hashLocalIdentity(firstNonBlank([env.HOSTNAME]) ?? "unidentified-host")}`;
44
+ }
45
+ //#endregion
2
46
  //#region ../sdk/src/entity-source.ts
3
47
  const CONCEPT_KEYS = {
4
48
  contact: "contacts",
@@ -34,7 +78,7 @@ function expectedSourceKey(concept) {
34
78
  }
35
79
  //#endregion
36
80
  //#region ../sdk/src/concept-coverage.ts
37
- const CHECK_NAME = "Entity → source coverage";
81
+ const CHECK_NAME$1 = "Entity → source coverage";
38
82
  function configuredKeys(config) {
39
83
  const src = config.source;
40
84
  if (!src) return [];
@@ -50,14 +94,14 @@ function checkConceptCoverage(config) {
50
94
  const entities = config.entities?.entities ?? {};
51
95
  const keys = Object.keys(entities);
52
96
  if (keys.length === 0) return {
53
- name: CHECK_NAME,
97
+ name: CHECK_NAME$1,
54
98
  ok: false,
55
99
  skipped: true,
56
100
  detail: "No entities configured — nothing to cross-check."
57
101
  };
58
102
  const unresolved = keys.filter((key) => resolveEntitySource(config, key) === null);
59
103
  if (unresolved.length === 0) return {
60
- name: CHECK_NAME,
104
+ name: CHECK_NAME$1,
61
105
  ok: true,
62
106
  detail: `${keys.length} entit${keys.length === 1 ? "y" : "ies"} resolve to a configured source.`
63
107
  };
@@ -72,7 +116,7 @@ function checkConceptCoverage(config) {
72
116
  return `"${key}" has concept "${concept}" but no \`source.${expectedSourceKey(concept)}\` is configured — it will never sync.`;
73
117
  });
74
118
  return {
75
- name: CHECK_NAME,
119
+ name: CHECK_NAME$1,
76
120
  ok: false,
77
121
  detail: `${problems.join(" ")} Configured source keys: ${listed}. \`source\` is keyed by CRM concept, not by entity key.`
78
122
  };
@@ -86,6 +130,7 @@ function slugify(value) {
86
130
  }
87
131
  function checkCustomObjectSlugs(config) {
88
132
  const custom = config.source?.custom ?? [];
133
+ const declared = config.entities ? customObjectDeclarations(config.entities) : [];
89
134
  if (custom.length === 0) return {
90
135
  name: "Custom object slugs",
91
136
  ok: false,
@@ -108,10 +153,17 @@ function checkCustomObjectSlugs(config) {
108
153
  ok: false,
109
154
  detail: problems.join(" ")
110
155
  };
156
+ const declaredSlugs = new Set(declared.map((d) => d.slug));
157
+ const undeclared = custom.map((source) => source.objectType).filter((slug) => !declaredSlugs.has(slug));
158
+ if (undeclared.length === 0) return {
159
+ name: "Custom object slugs",
160
+ ok: true,
161
+ detail: "Declared in clivly.config — `clivly push-schema` creates them."
162
+ };
111
163
  return {
112
164
  name: "Custom object slugs",
113
165
  ok: true,
114
- detail: `Ensure these object types exist in Clivly: ${custom.map((source) => source.objectType).join(", ")}.`
166
+ 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.`
115
167
  };
116
168
  }
117
169
  //#endregion
@@ -275,6 +327,75 @@ function errorMessage(error) {
275
327
  return error instanceof Error ? error.message : String(error);
276
328
  }
277
329
  //#endregion
330
+ //#region ../sdk/src/readiness.ts
331
+ /**
332
+ * Ask Clivly whether it can actually receive what this config pushes.
333
+ *
334
+ * Every other doctor check is local — the config loads, the tables are readable,
335
+ * the key is set — and a workspace can pass all of them while storing nothing,
336
+ * because whether a mapping exists to receive an entity is a fact only the
337
+ * server holds. `clivly status` reported "7 of 7 passed" against exactly that
338
+ * workspace. This is the rung that asks.
339
+ */
340
+ /**
341
+ * The entities to ask about, in the exact terms the sync pushes them.
342
+ *
343
+ * `source` is the resolved `SyncSource.entity` — the value that arrives at
344
+ * `ingestPush` as `input.source` and is compared against `mapping.sourceTable`.
345
+ * It is NOT the entity config's own `source` field, which names a host table;
346
+ * the two are routinely conflated, and using the wrong one would report every
347
+ * correctly mapped workspace as unmapped.
348
+ */
349
+ function readinessEntitiesFromConfig(config) {
350
+ const entities = [];
351
+ for (const key of Object.keys(config.entities?.entities ?? {})) {
352
+ const resolved = resolveEntitySource(config, key);
353
+ if (!resolved) continue;
354
+ const objectTypeSlug = resolved.source.objectType;
355
+ entities.push({
356
+ source: resolved.source.entity,
357
+ concept: resolved.concept,
358
+ ...resolved.concept === "custom" && objectTypeSlug ? { objectTypeSlug } : {}
359
+ });
360
+ }
361
+ return entities;
362
+ }
363
+ const CHECK_NAME = "Entities mapped in Clivly";
364
+ /**
365
+ * Turn a readiness answer into a doctor check.
366
+ *
367
+ * `null` means the server did not answer — older, unreachable, or the key was
368
+ * rejected — and is reported as skipped, not failed. Reporting unknown as broken
369
+ * would fail doctor for a workspace that is fine, which is the crying-wolf
370
+ * failure mode that trains people to stop reading the ladder.
371
+ */
372
+ function mappingReadinessCheck(requested, result) {
373
+ if (requested.length === 0) return {
374
+ name: CHECK_NAME,
375
+ ok: false,
376
+ skipped: true,
377
+ detail: "No entities to check."
378
+ };
379
+ if (!result) return {
380
+ name: CHECK_NAME,
381
+ ok: false,
382
+ skipped: true,
383
+ detail: "Clivly did not answer — this workspace's mappings could not be checked."
384
+ };
385
+ const unmapped = result.entities.filter((entity) => !entity.mapped);
386
+ if (unmapped.length === 0) return {
387
+ name: CHECK_NAME,
388
+ ok: true,
389
+ detail: `${result.entities.length} entit${result.entities.length === 1 ? "y has" : "ies have"} an active mapping.`
390
+ };
391
+ const names = unmapped.map((entity) => entity.objectTypeSlug ? `"${entity.source}" → "${entity.objectTypeSlug}"` : `"${entity.source}"`).join(", ");
392
+ return {
393
+ name: CHECK_NAME,
394
+ ok: false,
395
+ 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.`
396
+ };
397
+ }
398
+ //#endregion
278
399
  //#region ../sdk/src/doctor.ts
279
400
  const TEMPORAL_CURSOR_TYPES = [
280
401
  "timestamp",
@@ -404,7 +525,7 @@ async function sampleFetchChecks(sources) {
404
525
  * hides the rest. `ok` is the AND of all non-skipped checks.
405
526
  */
406
527
  async function runDoctor(args) {
407
- const { config, probe } = args;
528
+ const { config, probe, readiness } = args;
408
529
  const checks = [];
409
530
  const sources = configuredSources(config);
410
531
  const hasKey = Boolean(config.apiKey);
@@ -440,12 +561,409 @@ async function runDoctor(args) {
440
561
  });
441
562
  checks.push(checkCustomObjectSlugs(config));
442
563
  checks.push(await namespaceCheck(config));
564
+ const requested = readinessEntitiesFromConfig(config);
565
+ const readinessResult = readiness && requested.length > 0 ? await readiness(requested) : null;
566
+ checks.push(mappingReadinessCheck(requested, readinessResult));
443
567
  return {
444
568
  ok: checks.every((check) => check.skipped || check.ok),
445
569
  checks
446
570
  };
447
571
  }
448
572
  //#endregion
573
+ //#region ../sdk/src/host-environment.ts
574
+ const KNOWN = [
575
+ "local_dev",
576
+ "preview",
577
+ "production"
578
+ ];
579
+ const PLATFORM_ENVIRONMENTS = [
580
+ {
581
+ key: "VERCEL_ENV",
582
+ toEnvironment: (value) => {
583
+ if (value === "production") return "production";
584
+ if (value === "preview") return "preview";
585
+ return value === "development" ? "local_dev" : void 0;
586
+ }
587
+ },
588
+ {
589
+ key: "CF_PAGES_BRANCH",
590
+ toEnvironment: (value) => value === "main" || value === "master" ? "production" : "preview"
591
+ },
592
+ {
593
+ key: "RAILWAY_ENVIRONMENT_NAME",
594
+ toEnvironment: (value) => value === "production" ? "production" : "preview"
595
+ }
596
+ ];
597
+ function fromNodeEnv(value) {
598
+ if (value === "production") return "production";
599
+ return value === "development" || value === "test" ? "local_dev" : void 0;
600
+ }
601
+ /**
602
+ * Resolve the environment to report. An explicitly configured value wins, since
603
+ * someone stated it on purpose; otherwise infer from the platform, then from
604
+ * `NODE_ENV`. Returns `undefined` when nothing places the app.
605
+ */
606
+ function detectHostEnvironment(env, configured) {
607
+ const stated = KNOWN.find((known) => known === configured);
608
+ if (stated) return stated;
609
+ for (const source of PLATFORM_ENVIRONMENTS) {
610
+ const raw = env[source.key]?.trim();
611
+ if (raw) {
612
+ const resolved = source.toEnvironment(raw);
613
+ if (resolved) return resolved;
614
+ }
615
+ }
616
+ return fromNodeEnv(env.NODE_ENV?.trim());
617
+ }
618
+ //#endregion
619
+ //#region ../sdk/src/presence-machine.ts
620
+ const PRESENCE_BACKOFF_BASE_MS = 1e3;
621
+ const PRESENCE_BACKOFF_MAX_MS = 3e4;
622
+ function effectsFor(state, overrides = {}) {
623
+ return {
624
+ connect: false,
625
+ httpHeartbeat: state !== "connected" && state !== "stopped",
626
+ retryInMs: null,
627
+ sendGoodbye: false,
628
+ ...overrides
629
+ };
630
+ }
631
+ function retryDelay(attempt, jitter) {
632
+ const capped = Math.min(PRESENCE_BACKOFF_BASE_MS * 2 ** attempt, PRESENCE_BACKOFF_MAX_MS);
633
+ return Math.round(capped * (1 - Math.max(0, Math.min(1, jitter)) * .5));
634
+ }
635
+ /**
636
+ * Decide the next presence action without performing I/O.
637
+ *
638
+ * `stopped` is absorbing, and fallback is derived from state rather than from
639
+ * individual branches, so a delayed event cannot resurrect a stopped client or
640
+ * silence HTTP while the socket is unconfirmed.
641
+ */
642
+ function presenceReduce(context, event, jitter) {
643
+ if (context.state === "stopped") return {
644
+ context,
645
+ effects: effectsFor("stopped")
646
+ };
647
+ if (context.state === "unsupported" && event.type !== "stop") return {
648
+ context,
649
+ effects: effectsFor("unsupported")
650
+ };
651
+ if (event.type === "stop") return {
652
+ context: {
653
+ attempt: context.attempt,
654
+ state: "stopped"
655
+ },
656
+ effects: effectsFor("stopped", { sendGoodbye: context.state === "connected" })
657
+ };
658
+ if (event.type === "start" && context.state === "idle") return {
659
+ context: {
660
+ attempt: context.attempt,
661
+ state: "connecting"
662
+ },
663
+ effects: effectsFor("connecting", { connect: true })
664
+ };
665
+ if (event.type === "unsupportedServer") return {
666
+ context: {
667
+ attempt: context.attempt,
668
+ state: "unsupported"
669
+ },
670
+ effects: effectsFor("unsupported")
671
+ };
672
+ if (event.type === "ready" && context.state === "connecting") return {
673
+ context: {
674
+ attempt: 0,
675
+ state: "connected"
676
+ },
677
+ effects: effectsFor("connected")
678
+ };
679
+ if (event.type === "retryDue" && context.state === "backoff") return {
680
+ context: {
681
+ attempt: context.attempt,
682
+ state: "connecting"
683
+ },
684
+ effects: effectsFor("connecting", { connect: true })
685
+ };
686
+ if ((event.type === "ticketFailed" || event.type === "socketClosed") && (context.state === "connecting" || context.state === "connected")) {
687
+ const attempt = context.attempt + 1;
688
+ return {
689
+ context: {
690
+ attempt,
691
+ state: "backoff"
692
+ },
693
+ effects: effectsFor("backoff", { retryInMs: retryDelay(attempt, jitter) })
694
+ };
695
+ }
696
+ return {
697
+ context,
698
+ effects: effectsFor(context.state)
699
+ };
700
+ }
701
+ //#endregion
702
+ //#region ../sdk/src/presence-client.ts
703
+ const PRESENCE_PING_INTERVAL_MS = 3e4;
704
+ const UNSUPPORTED_TICKET_STATUSES = /* @__PURE__ */ new Set([404, 501]);
705
+ function ticketUrl(options) {
706
+ return new URL("/clivly/sdk/presence/ticket", options.apiUrl).toString();
707
+ }
708
+ function metricsUrl(options) {
709
+ return new URL("/clivly/sdk/presence/events", options.apiUrl).toString();
710
+ }
711
+ function socketUrl(options, ticket) {
712
+ const url = new URL("/clivly/sdk/presence", options.apiUrl);
713
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
714
+ url.searchParams.set("deploymentId", options.deploymentId);
715
+ url.searchParams.set("ticket", ticket);
716
+ if (options.environment) url.searchParams.set("environment", options.environment);
717
+ return url.toString();
718
+ }
719
+ /**
720
+ * Run the pure presence machine against the network and timers it requests.
721
+ *
722
+ * Every listener captures a connection sequence. Once a socket is replaced or
723
+ * the client stops, events from that socket are ignored before reaching the
724
+ * reducer, mirroring the room's generation fence on the client side.
725
+ */
726
+ function createPresenceClient(options) {
727
+ let context = {
728
+ attempt: 0,
729
+ state: "idle"
730
+ };
731
+ let connectionSequence = 0;
732
+ let socket = null;
733
+ let pingTimer = null;
734
+ let retryTimer = null;
735
+ let fallbackStartedAt = null;
736
+ const instanceId = crypto.randomUUID();
737
+ const reportMetric = (event, fields) => {
738
+ try {
739
+ options.fetcher(metricsUrl(options), {
740
+ body: JSON.stringify({
741
+ event,
742
+ ...fields
743
+ }),
744
+ headers: {
745
+ "content-type": "application/json",
746
+ authorization: `Bearer ${options.token}`
747
+ },
748
+ method: "POST"
749
+ }).catch(() => {});
750
+ } catch {}
751
+ };
752
+ const clearPingTimer = () => {
753
+ if (pingTimer) {
754
+ clearInterval(pingTimer);
755
+ pingTimer = null;
756
+ }
757
+ };
758
+ const clearRetryTimer = () => {
759
+ if (retryTimer) {
760
+ clearTimeout(retryTimer);
761
+ retryTimer = null;
762
+ }
763
+ };
764
+ const startPinging = () => {
765
+ if (pingTimer) return;
766
+ pingTimer = setInterval(() => {
767
+ if (context.state === "connected") socket?.send(JSON.stringify({ type: "ping" }));
768
+ }, PRESENCE_PING_INTERVAL_MS);
769
+ };
770
+ const reportFallbackTransition = (previousState, event) => {
771
+ const enteredFallback = previousState !== "backoff" && context.state === "backoff";
772
+ const enteredUnsupported = previousState !== "unsupported" && context.state === "unsupported";
773
+ if (enteredFallback && fallbackStartedAt === null) {
774
+ fallbackStartedAt = Date.now();
775
+ reportMetric("fallback_started", { transport: "http" });
776
+ }
777
+ if (enteredUnsupported) {
778
+ clearRetryTimer();
779
+ reportMetric("fallback_started", {
780
+ outcome: "unsupported",
781
+ transport: "http"
782
+ });
783
+ }
784
+ if (event.type === "ready" && fallbackStartedAt !== null) {
785
+ reportMetric("fallback_duration", {
786
+ durationMs: Date.now() - fallbackStartedAt,
787
+ transport: "http"
788
+ });
789
+ fallbackStartedAt = null;
790
+ }
791
+ };
792
+ const dispatch = async (event) => {
793
+ const previousState = context.state;
794
+ const next = presenceReduce(context, event, Math.random());
795
+ context = next.context;
796
+ reportFallbackTransition(previousState, event);
797
+ options.onHttpHeartbeat(next.effects.httpHeartbeat);
798
+ if (previousState === "connected" && context.state !== "connected") clearPingTimer();
799
+ if (previousState !== "connected" && context.state === "connected") startPinging();
800
+ if (next.effects.sendGoodbye) socket?.send(JSON.stringify({ type: "goodbye" }));
801
+ if (next.effects.retryInMs !== null) {
802
+ clearRetryTimer();
803
+ retryTimer = setTimeout(async () => {
804
+ retryTimer = null;
805
+ await dispatch({ type: "retryDue" });
806
+ }, next.effects.retryInMs);
807
+ }
808
+ if (next.effects.connect) await connect();
809
+ };
810
+ const failTicketIfCurrent = async (sequence, event = "ticketFailed") => {
811
+ if (sequence === connectionSequence) await dispatch({ type: event });
812
+ };
813
+ const requestTicket = async (sequence) => {
814
+ try {
815
+ const response = await options.fetcher(ticketUrl(options), {
816
+ headers: { authorization: `Bearer ${options.token}` },
817
+ method: "POST"
818
+ });
819
+ if (sequence !== connectionSequence) return null;
820
+ if (!response?.ok) {
821
+ await failTicketIfCurrent(sequence, response && UNSUPPORTED_TICKET_STATUSES.has(response.status) ? "unsupportedServer" : "ticketFailed");
822
+ return null;
823
+ }
824
+ if (sequence !== connectionSequence) return null;
825
+ const body = await response.json();
826
+ if (sequence !== connectionSequence) return null;
827
+ if (typeof body.ticket === "string" && body.ticket !== "") return body.ticket;
828
+ await failTicketIfCurrent(sequence);
829
+ return null;
830
+ } catch {
831
+ await failTicketIfCurrent(sequence);
832
+ return null;
833
+ }
834
+ };
835
+ const connect = async () => {
836
+ const sequence = connectionSequence + 1;
837
+ connectionSequence = sequence;
838
+ reportMetric("connection_attempt", {
839
+ attempt: context.attempt,
840
+ transport: "websocket"
841
+ });
842
+ if (context.attempt > 0) reportMetric("reconnect", {
843
+ attempt: context.attempt,
844
+ transport: "websocket"
845
+ });
846
+ const ticket = await requestTicket(sequence);
847
+ if (!(ticket && sequence === connectionSequence && context.state !== "stopped")) return;
848
+ const nextSocket = options.socketFactory(socketUrl(options, ticket));
849
+ socket = nextSocket;
850
+ nextSocket.addEventListener("open", async () => {
851
+ if (sequence !== connectionSequence) return;
852
+ const hello = options.getHello();
853
+ nextSocket.send(JSON.stringify({
854
+ deploymentId: options.deploymentId,
855
+ issues: hello.issues,
856
+ instanceId,
857
+ sdk: hello.sdk,
858
+ type: "hello"
859
+ }));
860
+ await dispatch({ type: "socketOpen" });
861
+ });
862
+ nextSocket.addEventListener("message", async (event) => {
863
+ if (sequence !== connectionSequence) return;
864
+ const data = event.data;
865
+ if (typeof data !== "string") return;
866
+ try {
867
+ if (JSON.parse(data).type === "ready") await dispatch({ type: "ready" });
868
+ } catch {}
869
+ });
870
+ nextSocket.addEventListener("close", async () => {
871
+ if (sequence !== connectionSequence) return;
872
+ connectionSequence += 1;
873
+ socket = null;
874
+ await dispatch({ type: "socketClosed" });
875
+ });
876
+ };
877
+ return {
878
+ start: async () => {
879
+ await dispatch({ type: "start" });
880
+ },
881
+ stop: async () => {
882
+ connectionSequence += 1;
883
+ clearRetryTimer();
884
+ await dispatch({ type: "stop" });
885
+ if (fallbackStartedAt !== null) {
886
+ reportMetric("fallback_duration", {
887
+ durationMs: Date.now() - fallbackStartedAt,
888
+ transport: "http"
889
+ });
890
+ fallbackStartedAt = null;
891
+ }
892
+ clearPingTimer();
893
+ socket?.close(1e3, "goodbye");
894
+ socket = null;
895
+ }
896
+ };
897
+ }
898
+ //#endregion
899
+ //#region ../sdk/src/presence-support.ts
900
+ /**
901
+ * This stays HTTP until the production gate in docs/presence-rollout-gate.md
902
+ * has recorded a passing seven-day window. The later default-on rollout is a
903
+ * one-line change here; all capability and escape-hatch behavior is stable.
904
+ */
905
+ const DEFAULT_TRANSPORT = "http";
906
+ function resolvePresenceTransport(host, configured) {
907
+ if (configured) return {
908
+ reason: "explicit configuration",
909
+ transport: configured
910
+ };
911
+ const environmentTransport = host.env.CLIVLY_PRESENCE_TRANSPORT;
912
+ if (environmentTransport === "http" || environmentTransport === "websocket") return {
913
+ reason: "CLIVLY_PRESENCE_TRANSPORT environment override",
914
+ transport: environmentTransport
915
+ };
916
+ if (host.serverless) return {
917
+ reason: "serverless hosts cannot retain a socket",
918
+ transport: "http"
919
+ };
920
+ if (!host.hasWebSocket) return {
921
+ reason: "WebSocket is unavailable in this host",
922
+ transport: "http"
923
+ };
924
+ return {
925
+ reason: "SDK default",
926
+ transport: DEFAULT_TRANSPORT
927
+ };
928
+ }
929
+ //#endregion
930
+ //#region ../sdk/src/push-outcome.ts
931
+ function isCount(value) {
932
+ return typeof value === "number" && Number.isFinite(value);
933
+ }
934
+ /**
935
+ * A message describing a push that stored nothing, or `null` when there is
936
+ * nothing to report.
937
+ *
938
+ * Deliberately silent unless the server reported all three counts as numbers: an
939
+ * older cloud answers 200 without them, and warning on an unrecognised shape
940
+ * would fire on every push against it.
941
+ */
942
+ function describeSilentPush(target, body) {
943
+ if (typeof body !== "object" || body === null) return null;
944
+ const { source, objectType } = target;
945
+ const { received, upserted, archived } = body;
946
+ if (!(isCount(received) && isCount(upserted) && isCount(archived))) return null;
947
+ if (received === 0 || upserted > 0 || archived > 0) return null;
948
+ 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.`;
949
+ }
950
+ //#endregion
951
+ //#region ../sdk/src/self-check.ts
952
+ function selfCheck(input) {
953
+ const issues = [];
954
+ if (input.reportsTriggerUrl && !input.hasTriggerSecret) issues.push({
955
+ code: "trigger_route_unauthenticated",
956
+ message: "Your app's sync route is reachable but is not checking signatures.",
957
+ fix: "Set CLIVLY_SYNC_TRIGGER_SECRET in the environment your app runs in, then restart it."
958
+ });
959
+ if (input.lastPushFailed) issues.push({
960
+ code: "sync_push_failing",
961
+ message: input.lastPushError ? `Your app could not send its data: ${input.lastPushError}` : "Your app could not send its data to Clivly.",
962
+ fix: "Check your app's logs for the failing sync, then run `npx clivly doctor`."
963
+ });
964
+ return issues;
965
+ }
966
+ //#endregion
449
967
  //#region ../sdk/src/sync.ts
450
968
  const DEFAULT_ID_FIELD = "id";
451
969
  function isAfter(candidate, current) {
@@ -988,16 +1506,41 @@ function createClivlySDK(config) {
988
1506
  const apiUrl = (config.apiUrl ?? DEFAULT_API_URL).replace(TRAILING_SLASH, "");
989
1507
  const heartbeatIntervalMs = config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_MS;
990
1508
  const syncIntervalMs = config.sync?.intervalMs ?? heartbeatIntervalMs;
1509
+ const serverless = isLikelyServerless();
1510
+ const presenceEnabled = resolvePresenceTransport({
1511
+ env: config.env ?? process.env,
1512
+ hasWebSocket: typeof WebSocket === "function",
1513
+ serverless
1514
+ }, config.presence?.transport).transport === "websocket";
1515
+ const deploymentId = presenceEnabled ? resolveDeploymentId(config.env ?? process.env, config.presence?.deploymentId) : null;
991
1516
  const timers = {
992
1517
  heartbeat: null,
993
1518
  sync: null
994
1519
  };
1520
+ let presenceClient = null;
1521
+ let httpHeartbeatActive = true;
995
1522
  const batchSize = config.sync?.batchSize ?? DEFAULT_BATCH_SIZE;
996
1523
  const cursorStore = config.cursorStore ?? createMemoryCursorStore();
997
1524
  const fetcher = createFetcher(config.retry);
998
1525
  let warnedUnsignedTrigger = false;
999
1526
  let observedUrl;
1000
1527
  let lastPushFailure = null;
1528
+ let lastPush = { failed: false };
1529
+ const warnedSilentTargets = /* @__PURE__ */ new Set();
1530
+ const warnIfSilent = async (target, response) => {
1531
+ if (typeof response.json !== "function") return;
1532
+ let outcome;
1533
+ try {
1534
+ outcome = describeSilentPush(target, await response.json());
1535
+ } catch {
1536
+ return;
1537
+ }
1538
+ if (!outcome) return;
1539
+ const key = `${target.source} ${target.objectType ?? ""}`;
1540
+ if (warnedSilentTargets.has(key)) return;
1541
+ warnedSilentTargets.add(key);
1542
+ console.warn(outcome);
1543
+ };
1001
1544
  const createPush = (runId) => async (path, body) => {
1002
1545
  const response = await fetcher(`${apiUrl}${path}`, {
1003
1546
  method: "POST",
@@ -1008,7 +1551,13 @@ function createClivlySDK(config) {
1008
1551
  },
1009
1552
  body: JSON.stringify(body)
1010
1553
  });
1011
- if (response?.ok) return true;
1554
+ if (response?.ok) {
1555
+ await warnIfSilent({
1556
+ source: body.source,
1557
+ objectType: body.objectType
1558
+ }, response);
1559
+ return true;
1560
+ }
1012
1561
  lastPushFailure ??= { status: response?.status ?? null };
1013
1562
  return false;
1014
1563
  };
@@ -1060,7 +1609,15 @@ function createClivlySDK(config) {
1060
1609
  if (!await syncOne(config.source.contacts, SYNC_CONTACTS_PATH, mode, void 0, runId)) failed.push(config.source.contacts.entity);
1061
1610
  if (config.source.deals && !await syncOne(config.source.deals, SYNC_DEALS_PATH, mode, void 0, runId)) failed.push(config.source.deals.entity);
1062
1611
  failed.push(...await syncCustomSources(config.source.custom, mode, runId));
1063
- if (failed.length > 0) throw new Error(describePushFailure(failed));
1612
+ if (failed.length > 0) {
1613
+ const message = describePushFailure(failed);
1614
+ lastPush = {
1615
+ failed: true,
1616
+ error: message
1617
+ };
1618
+ throw new Error(message);
1619
+ }
1620
+ lastPush = { failed: false };
1064
1621
  };
1065
1622
  const runSampleSync = async (entityKey, runId) => {
1066
1623
  const resolved = resolveSampleSource(config, entityKey);
@@ -1068,33 +1625,129 @@ function createClivlySDK(config) {
1068
1625
  lastPushFailure = null;
1069
1626
  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]));
1070
1627
  };
1071
- const sendHeartbeat = () => fetcher(`${apiUrl}/clivly/sdk/heartbeat`, {
1072
- method: "POST",
1073
- headers: {
1074
- "content-type": "application/json",
1075
- authorization: `Bearer ${config.apiKey}`
1076
- },
1077
- body: JSON.stringify({
1078
- framework: config.framework,
1079
- orm: config.orm,
1080
- sdkVersion: config.sdkVersion,
1081
- contactEntity: resolveContactEntity(config),
1082
- namespaceReady: config.namespaceReady ?? false,
1083
- schema: buildSchema(config),
1084
- ...(() => {
1085
- const url = resolveTriggerUrl({
1086
- configuredUrl: config.syncTrigger?.url,
1087
- configuredPath: config.syncTrigger?.path,
1088
- env: config.env ?? process.env,
1089
- observedUrl
1090
- });
1091
- return url ? { syncTrigger: { url } } : {};
1092
- })()
1093
- })
1094
- });
1628
+ const sendHeartbeat = () => {
1629
+ const triggerUrl = resolveTriggerUrl({
1630
+ configuredUrl: config.syncTrigger?.url,
1631
+ configuredPath: config.syncTrigger?.path,
1632
+ env: config.env ?? process.env,
1633
+ observedUrl
1634
+ });
1635
+ return fetcher(`${apiUrl}/clivly/sdk/heartbeat`, {
1636
+ method: "POST",
1637
+ headers: {
1638
+ "content-type": "application/json",
1639
+ authorization: `Bearer ${config.apiKey}`
1640
+ },
1641
+ body: JSON.stringify({
1642
+ ...deploymentId ? { deploymentId } : {},
1643
+ framework: config.framework,
1644
+ orm: config.orm,
1645
+ sdkVersion: config.sdkVersion,
1646
+ contactEntity: resolveContactEntity(config),
1647
+ namespaceReady: config.namespaceReady ?? false,
1648
+ schema: buildSchema(config),
1649
+ heartbeatIntervalMs,
1650
+ environment: detectHostEnvironment(config.env ?? process.env, config.environment),
1651
+ issues: selfCheck({
1652
+ hasTriggerSecret: Boolean(config.syncTrigger?.secret),
1653
+ reportsTriggerUrl: Boolean(triggerUrl),
1654
+ lastPushFailed: lastPush.failed,
1655
+ lastPushError: lastPush.error
1656
+ }),
1657
+ ...triggerUrl ? { syncTrigger: { url: triggerUrl } } : {}
1658
+ })
1659
+ });
1660
+ };
1095
1661
  const heartbeat = async () => {
1096
1662
  return (await sendHeartbeat())?.ok ?? false;
1097
1663
  };
1664
+ const startHeartbeatInterval = () => {
1665
+ if (timers.heartbeat) return;
1666
+ timers.heartbeat = setInterval(() => {
1667
+ heartbeat().catch(() => {});
1668
+ }, heartbeatIntervalMs);
1669
+ timers.heartbeat.unref?.();
1670
+ };
1671
+ const stopHeartbeatInterval = () => {
1672
+ if (timers.heartbeat) {
1673
+ clearInterval(timers.heartbeat);
1674
+ timers.heartbeat = null;
1675
+ }
1676
+ };
1677
+ /**
1678
+ * Create any custom object types this config declares that the workspace does
1679
+ * not have yet, so `clivly.config.ts` is self-contained — no dashboard visit
1680
+ * and no server-generated id pasted into the file. Keyed by entity slug.
1681
+ *
1682
+ * Create-only and idempotent server-side: existing slugs resolve to their
1683
+ * current id untouched, and nothing is renamed or removed. Returns `null` if
1684
+ * the request never landed, so callers can distinguish "nothing to do" (an
1685
+ * empty array) from "could not reach Clivly".
1686
+ */
1687
+ const ensureCustomObjects = async () => {
1688
+ const objects = config.entities ? customObjectDeclarations(config.entities) : [];
1689
+ if (objects.length === 0) return [];
1690
+ const response = await fetcher(`${apiUrl}/clivly/sdk/object-types`, {
1691
+ method: "POST",
1692
+ headers: {
1693
+ "content-type": "application/json",
1694
+ authorization: `Bearer ${config.apiKey}`
1695
+ },
1696
+ body: JSON.stringify({ objects })
1697
+ });
1698
+ if (!response?.ok) return null;
1699
+ return (await response.json()).objectTypes ?? [];
1700
+ };
1701
+ const readSchemaError = async (response) => {
1702
+ if (response?.status !== 400) return null;
1703
+ try {
1704
+ const body = await response.json();
1705
+ return typeof body.message === "string" ? body.message : null;
1706
+ } catch {
1707
+ return null;
1708
+ }
1709
+ };
1710
+ /**
1711
+ * Declare this host's whole schema: its custom object types AND the mappings
1712
+ * that make them receive rows.
1713
+ *
1714
+ * `ensureCustomObjects` above creates the object and nothing else, which left
1715
+ * config-as-code ending at an empty container and a sync that stored nothing.
1716
+ * This is the call that finishes it.
1717
+ *
1718
+ * Returns `null` when the request never landed — including against a server
1719
+ * old enough not to have the route — so a caller can tell "nothing to do"
1720
+ * from "could not reach Clivly", and fall back to the objects-only endpoint.
1721
+ */
1722
+ const ensureSchema = async () => {
1723
+ const objects = config.entities ? customObjectDeclarations(config.entities) : [];
1724
+ const mappings = config.entities ? entitiesToMappings(config.entities) : [];
1725
+ if (objects.length === 0 && mappings.length === 0) return {
1726
+ objectTypes: [],
1727
+ mappings: []
1728
+ };
1729
+ const response = await fetcher(`${apiUrl}/clivly/sdk/schema`, {
1730
+ method: "POST",
1731
+ headers: {
1732
+ "content-type": "application/json",
1733
+ authorization: `Bearer ${config.apiKey}`
1734
+ },
1735
+ body: JSON.stringify({
1736
+ objects,
1737
+ mappings
1738
+ })
1739
+ });
1740
+ if (!response?.ok) {
1741
+ const message = await readSchemaError(response);
1742
+ if (message) throw new Error(message);
1743
+ return null;
1744
+ }
1745
+ const body = await response.json();
1746
+ return {
1747
+ objectTypes: body.objectTypes ?? [],
1748
+ mappings: body.mappings ?? []
1749
+ };
1750
+ };
1098
1751
  const connect = async () => {
1099
1752
  const response = await sendHeartbeat();
1100
1753
  if (response === null) return {
@@ -1146,9 +1799,31 @@ function createClivlySDK(config) {
1146
1799
  retryable: isRetryableStatus(response.status)
1147
1800
  };
1148
1801
  };
1802
+ const probeReadiness = async (entities) => {
1803
+ const response = await fetcher(`${apiUrl}/clivly/sdk/readiness`, {
1804
+ method: "POST",
1805
+ headers: {
1806
+ "content-type": "application/json",
1807
+ authorization: `Bearer ${config.apiKey}`
1808
+ },
1809
+ body: JSON.stringify({ entities })
1810
+ });
1811
+ if (!response?.ok) return null;
1812
+ try {
1813
+ const body = await response.json();
1814
+ if (typeof body?.ready !== "boolean" || !Array.isArray(body.entities)) return null;
1815
+ return {
1816
+ ready: body.ready,
1817
+ entities: body.entities
1818
+ };
1819
+ } catch {
1820
+ return null;
1821
+ }
1822
+ };
1149
1823
  const doctor = () => runDoctor({
1150
1824
  config,
1151
- probe: ping
1825
+ probe: ping,
1826
+ readiness: probeReadiness
1152
1827
  });
1153
1828
  const runScheduled = async (options) => {
1154
1829
  await heartbeat();
@@ -1202,14 +1877,48 @@ function createClivlySDK(config) {
1202
1877
  }
1203
1878
  };
1204
1879
  const start = async () => {
1205
- 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.");
1880
+ 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.");
1206
1881
  await heartbeat();
1207
1882
  if (config.sync?.onBoot !== false) await runSync().catch(() => {});
1208
- if (!timers.heartbeat) {
1209
- timers.heartbeat = setInterval(() => {
1210
- heartbeat().catch(() => {});
1211
- }, heartbeatIntervalMs);
1212
- timers.heartbeat.unref?.();
1883
+ startHeartbeatInterval();
1884
+ if (presenceEnabled && deploymentId && !serverless && !presenceClient) try {
1885
+ presenceClient = createPresenceClient({
1886
+ apiUrl,
1887
+ deploymentId,
1888
+ environment: detectHostEnvironment(config.env ?? process.env, config.environment),
1889
+ fetcher,
1890
+ getHello: () => ({
1891
+ issues: selfCheck({
1892
+ hasTriggerSecret: Boolean(config.syncTrigger?.secret),
1893
+ reportsTriggerUrl: Boolean(resolveTriggerUrl({
1894
+ configuredUrl: config.syncTrigger?.url,
1895
+ configuredPath: config.syncTrigger?.path,
1896
+ env: config.env ?? process.env,
1897
+ observedUrl
1898
+ })),
1899
+ lastPushFailed: lastPush.failed,
1900
+ lastPushError: lastPush.error
1901
+ }),
1902
+ sdk: {
1903
+ framework: config.framework ?? null,
1904
+ orm: config.orm ?? null,
1905
+ version: config.sdkVersion ?? null
1906
+ }
1907
+ }),
1908
+ onHttpHeartbeat: (active) => {
1909
+ if (active) {
1910
+ startHeartbeatInterval();
1911
+ if (!httpHeartbeatActive) heartbeat().catch(() => {});
1912
+ } else stopHeartbeatInterval();
1913
+ httpHeartbeatActive = active;
1914
+ },
1915
+ socketFactory: (url) => new WebSocket(url),
1916
+ token: config.apiKey
1917
+ });
1918
+ await presenceClient.start();
1919
+ } catch (error) {
1920
+ presenceClient = null;
1921
+ console.warn("Clivly presence socket unavailable; using HTTP heartbeat", error);
1213
1922
  }
1214
1923
  if (!timers.sync && config.source) {
1215
1924
  timers.sync = setInterval(() => {
@@ -1218,11 +1927,10 @@ function createClivlySDK(config) {
1218
1927
  timers.sync.unref?.();
1219
1928
  }
1220
1929
  };
1221
- const stop = () => {
1222
- if (timers.heartbeat) {
1223
- clearInterval(timers.heartbeat);
1224
- timers.heartbeat = null;
1225
- }
1930
+ const stop = async () => {
1931
+ await presenceClient?.stop();
1932
+ presenceClient = null;
1933
+ stopHeartbeatInterval();
1226
1934
  if (timers.sync) {
1227
1935
  clearInterval(timers.sync);
1228
1936
  timers.sync = null;
@@ -1240,6 +1948,8 @@ function createClivlySDK(config) {
1240
1948
  start,
1241
1949
  stop,
1242
1950
  heartbeat,
1951
+ ensureCustomObjects,
1952
+ ensureSchema,
1243
1953
  connect,
1244
1954
  ping,
1245
1955
  doctor,