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