@clivly/sdk 0.9.5 → 0.9.7
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 +40 -12
- package/dist/chat-session.d.cts +1 -1
- package/dist/chat-session.d.mts +1 -1
- package/dist/drizzle.cjs +59 -8
- package/dist/drizzle.d.cts +8 -2
- package/dist/drizzle.d.mts +9 -3
- package/dist/drizzle.mjs +59 -8
- package/dist/index.cjs +352 -18
- package/dist/index.d.cts +2 -2
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +352 -18
- package/dist/{types-Dz1LQYvQ.d.cts → types-B7HKG2Ph.d.cts} +85 -2
- package/dist/{types-Dz1LQYvQ.d.mts → types-B7HKG2Ph.d.mts} +85 -2
- package/dist/vite.d.cts +1 -1
- package/dist/vite.d.mts +1 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { t as DRIZZLE_META } from "./drizzle-meta-DNqzkREf.mjs";
|
|
|
2
2
|
import { createChatSessionHandler } from "./chat-session.mjs";
|
|
3
3
|
import { customObjectDeclarations, customObjectDeclarations as customObjectDeclarations$1, defineClivlyConfig, validateEntitiesConfig } from "@clivly/core/entity-config";
|
|
4
4
|
import { entitiesToMappings } from "@clivly/core/mappings-config";
|
|
5
|
+
import { primaryKeyColumns, projectionSchema, validateProjection } from "@clivly/core";
|
|
5
6
|
//#region src/deployment-identity.ts
|
|
6
7
|
const MAX_DEPLOYMENT_ID_LENGTH = 128;
|
|
7
8
|
const PLATFORM_DEPLOYMENT_KEYS = [
|
|
@@ -573,12 +574,21 @@ async function runDoctor(args) {
|
|
|
573
574
|
ok: entityCount > 0,
|
|
574
575
|
detail: entityCount > 0 ? `${entityCount} entit${entityCount === 1 ? "y" : "ies"} configured.` : "No `entities` config — pass defineClivlyConfig(...) as `entities`."
|
|
575
576
|
});
|
|
577
|
+
const sourcesDetail = (count, deferred) => {
|
|
578
|
+
if (deferred) return "Deferred — the coverage check below names the entity and the source key it expected.";
|
|
579
|
+
return count === 0 ? "No `source.contacts` — nothing to sync." : `${count} source(s) to mirror.`;
|
|
580
|
+
};
|
|
581
|
+
const coverage = checkConceptCoverage(config);
|
|
582
|
+
const coverageOwnsIt = !(coverage.ok || coverage.skipped);
|
|
583
|
+
const noSources = sources.length === 0;
|
|
584
|
+
const deferred = noSources && coverageOwnsIt;
|
|
576
585
|
checks.push({
|
|
577
586
|
name: "Sources configured",
|
|
578
|
-
ok:
|
|
579
|
-
|
|
587
|
+
ok: !noSources,
|
|
588
|
+
skipped: deferred,
|
|
589
|
+
detail: sourcesDetail(sources.length, deferred)
|
|
580
590
|
});
|
|
581
|
-
checks.push(
|
|
591
|
+
checks.push(coverage);
|
|
582
592
|
checks.push(...cursorColumnChecks(sources));
|
|
583
593
|
for (const source of sources) {
|
|
584
594
|
const check = cursorMonotonicityCheck(source);
|
|
@@ -981,6 +991,240 @@ function describeSilentPush(target, body) {
|
|
|
981
991
|
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.`;
|
|
982
992
|
}
|
|
983
993
|
//#endregion
|
|
994
|
+
//#region src/remote-projections.ts
|
|
995
|
+
const SYNC_CUSTOM_PATH$1 = "/clivly/sdk/sync/custom";
|
|
996
|
+
const MISSING_INTROSPECTOR = "this app cannot run dashboard-authored derived objects yet — add `introspector: drizzleIntrospector(db)` to clivly.config.ts";
|
|
997
|
+
/**
|
|
998
|
+
* Turn definitions fetched from the cloud into runnable sync sources.
|
|
999
|
+
*
|
|
1000
|
+
* Revalidates every projection locally against the schema THIS app discovered,
|
|
1001
|
+
* never against the cloud's stored copy. Two reasons, and both matter:
|
|
1002
|
+
*
|
|
1003
|
+
* 1. Security. `checkTablesExist`/`checkRefs` bound every table and column ref
|
|
1004
|
+
* to the schema passed in, so validating against the locally-discovered
|
|
1005
|
+
* schema makes the blast radius exactly the namespace the developer handed
|
|
1006
|
+
* to `discoverFromDrizzle`. Trusting the cloud's verdict instead would let a
|
|
1007
|
+
* compromised cloud read any table in the database.
|
|
1008
|
+
* 2. Truth. The stored copy can be stale. A column dropped in the host app
|
|
1009
|
+
* becomes a readable quarantine here instead of a raw Postgres error out of
|
|
1010
|
+
* `sql.raw` at execution time.
|
|
1011
|
+
*/
|
|
1012
|
+
function resolveRemoteProjections(args) {
|
|
1013
|
+
const { entries, introspector, schema } = args;
|
|
1014
|
+
return entries.map((entry) => resolveOne(entry, schema, introspector));
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* The base table's PK, resolved from the schema THIS app discovered — never
|
|
1018
|
+
* from the cloud's copy.
|
|
1019
|
+
*
|
|
1020
|
+
* `validateProjection` proved its uniqueness case against this exact source
|
|
1021
|
+
* (`primaryKeyColumns` over the locally-discovered schema, see
|
|
1022
|
+
* `projection-validate.ts`). Compiling a *different* column — the cloud's
|
|
1023
|
+
* `basePkColumn`, which can go stale the moment a host renames a PK and the
|
|
1024
|
+
* `updatedAt`-only ETag never invalidates — would defeat that proof and
|
|
1025
|
+
* revive the raw-Postgres-error failure mode local revalidation exists to
|
|
1026
|
+
* prevent. Only relevant when the identity is `base_pk` with no explicit
|
|
1027
|
+
* column; every other identity kind ignores `basePkColumn` entirely.
|
|
1028
|
+
*/
|
|
1029
|
+
function resolveLocalBasePk(projection, schema) {
|
|
1030
|
+
if (projection.identity.kind !== "base_pk" || projection.identity.column) return;
|
|
1031
|
+
const baseTable = schema.find((t) => t.name === projection.baseTable);
|
|
1032
|
+
if (!baseTable) return;
|
|
1033
|
+
return primaryKeyColumns(baseTable)[0];
|
|
1034
|
+
}
|
|
1035
|
+
function resolveOne(entry, schema, introspector) {
|
|
1036
|
+
const parsed = projectionSchema.safeParse(entry.projection);
|
|
1037
|
+
if (!parsed.success) return {
|
|
1038
|
+
id: entry.id,
|
|
1039
|
+
status: "invalid",
|
|
1040
|
+
reason: `projection did not parse: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`
|
|
1041
|
+
};
|
|
1042
|
+
const projection = parsed.data;
|
|
1043
|
+
const errors = validateProjection(projection, schema, entry.entityKey);
|
|
1044
|
+
if (errors.length > 0) return {
|
|
1045
|
+
id: entry.id,
|
|
1046
|
+
status: "invalid",
|
|
1047
|
+
reason: errors.map((e) => `${e.path}: ${e.message}`).join("; ")
|
|
1048
|
+
};
|
|
1049
|
+
if (!introspector?.projectionSource) return {
|
|
1050
|
+
id: entry.id,
|
|
1051
|
+
status: "skipped",
|
|
1052
|
+
reason: MISSING_INTROSPECTOR
|
|
1053
|
+
};
|
|
1054
|
+
try {
|
|
1055
|
+
const basePkColumn = resolveLocalBasePk(projection, schema) ?? entry.basePkColumn;
|
|
1056
|
+
const source = introspector.projectionSource(projection, {
|
|
1057
|
+
objectType: entry.objectType,
|
|
1058
|
+
...basePkColumn ? { basePkColumn } : {}
|
|
1059
|
+
});
|
|
1060
|
+
return {
|
|
1061
|
+
id: entry.id,
|
|
1062
|
+
status: "ok",
|
|
1063
|
+
source
|
|
1064
|
+
};
|
|
1065
|
+
} catch (error) {
|
|
1066
|
+
return {
|
|
1067
|
+
id: entry.id,
|
|
1068
|
+
status: "failed",
|
|
1069
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
const PROJECTIONS_PATH = "/clivly/sdk/projections";
|
|
1074
|
+
const PROJECTIONS_REPORT_PATH = "/clivly/sdk/projections/report";
|
|
1075
|
+
const MAX_REASON = 2e3;
|
|
1076
|
+
/**
|
|
1077
|
+
* Merge what resolution decided with what the push actually did.
|
|
1078
|
+
*
|
|
1079
|
+
* A `status: "ok"` outcome means the projection *resolved*, not that its rows
|
|
1080
|
+
* landed. If the push then failed, the object did not sync and must not be
|
|
1081
|
+
* reported as healthy — so the push result wins for those.
|
|
1082
|
+
*/
|
|
1083
|
+
function buildRemoteProjectionReport(args) {
|
|
1084
|
+
return {
|
|
1085
|
+
...args.runId ? { runId: args.runId } : {},
|
|
1086
|
+
results: args.outcomes.map((outcome) => {
|
|
1087
|
+
if (outcome.status !== "ok") return {
|
|
1088
|
+
id: outcome.id,
|
|
1089
|
+
status: outcome.status,
|
|
1090
|
+
reason: outcome.reason.slice(0, MAX_REASON)
|
|
1091
|
+
};
|
|
1092
|
+
const result = args.pushed.get(outcome.source.entity);
|
|
1093
|
+
if (result?.ok) return {
|
|
1094
|
+
id: outcome.id,
|
|
1095
|
+
status: "ok",
|
|
1096
|
+
rows: result.count
|
|
1097
|
+
};
|
|
1098
|
+
return {
|
|
1099
|
+
id: outcome.id,
|
|
1100
|
+
status: "failed",
|
|
1101
|
+
reason: (result?.reason ?? "the push to Clivly failed; rows were read but not stored").slice(0, MAX_REASON)
|
|
1102
|
+
};
|
|
1103
|
+
})
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
function createRemoteProjectionFetcher(args) {
|
|
1107
|
+
let etag = null;
|
|
1108
|
+
let cached = [];
|
|
1109
|
+
return { async fetch() {
|
|
1110
|
+
try {
|
|
1111
|
+
const response = await args.fetcher(`${args.apiUrl}${PROJECTIONS_PATH}`, { headers: {
|
|
1112
|
+
authorization: `Bearer ${args.apiKey}`,
|
|
1113
|
+
...etag ? { "if-none-match": etag } : {}
|
|
1114
|
+
} });
|
|
1115
|
+
if (response.status === 304) return cached;
|
|
1116
|
+
if (!response.ok) return [];
|
|
1117
|
+
const body = await response.json();
|
|
1118
|
+
etag = body.etag ?? null;
|
|
1119
|
+
cached = body.projections ?? [];
|
|
1120
|
+
return cached;
|
|
1121
|
+
} catch {
|
|
1122
|
+
return [];
|
|
1123
|
+
}
|
|
1124
|
+
} };
|
|
1125
|
+
}
|
|
1126
|
+
const REMOTE_PAGE_SIZE = 500;
|
|
1127
|
+
/**
|
|
1128
|
+
* Page one source to exhaustion, keyset-paging on identity alone (the id
|
|
1129
|
+
* field, no timestamp). Split out of `pushRemoteProjections` so that
|
|
1130
|
+
* function's own cognitive complexity stays under the lint threshold — this
|
|
1131
|
+
* is the piece that can throw (a `fetchPage` execution error), kept separate
|
|
1132
|
+
* from push-failure handling below.
|
|
1133
|
+
*/
|
|
1134
|
+
async function fetchAllPages(source, pageSize) {
|
|
1135
|
+
const idField = source.idField ?? "id";
|
|
1136
|
+
const all = [];
|
|
1137
|
+
let since = null;
|
|
1138
|
+
for (;;) {
|
|
1139
|
+
const rows = await source.fetchPage({
|
|
1140
|
+
since,
|
|
1141
|
+
limit: pageSize
|
|
1142
|
+
});
|
|
1143
|
+
if (rows.length === 0) break;
|
|
1144
|
+
all.push(...rows);
|
|
1145
|
+
const last = rows.at(-1);
|
|
1146
|
+
const nextId = String(last?.[idField]);
|
|
1147
|
+
if (since && nextId === since.id) break;
|
|
1148
|
+
since = { id: nextId };
|
|
1149
|
+
if (rows.length < pageSize) break;
|
|
1150
|
+
}
|
|
1151
|
+
return all;
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Page a remote projection by identity and push one `full` batch.
|
|
1155
|
+
*
|
|
1156
|
+
* `full` rather than `delta` because only a full push lets the server archive
|
|
1157
|
+
* rows that dropped out when a join or filter stopped matching — a delta cannot
|
|
1158
|
+
* express absence. And identity rather than a timestamp because a
|
|
1159
|
+
* dashboard-authored projection has no watermark column, while validation has
|
|
1160
|
+
* already proven the identity unique.
|
|
1161
|
+
*
|
|
1162
|
+
* `pageSize` defaults to `REMOTE_PAGE_SIZE` and exists so a test can force a
|
|
1163
|
+
* short page without either mutating the exported constant or constructing a
|
|
1164
|
+
* fixture hundreds of rows deep.
|
|
1165
|
+
*
|
|
1166
|
+
* Returns one entry per source, keyed by `source.entity`. `count` and `ok`
|
|
1167
|
+
* are independent: `count` is how many rows were pushed (0 for a projection
|
|
1168
|
+
* that legitimately matches nothing this run), `ok` is whether the push
|
|
1169
|
+
* itself succeeded. A caller that folds failures into a report must branch
|
|
1170
|
+
* on `ok`, never on `count === 0` — a correctly-working, legitimately-empty
|
|
1171
|
+
* projection has `ok: true, count: 0` and is not a failure.
|
|
1172
|
+
*
|
|
1173
|
+
* Each source's paging-and-push is wrapped individually: a projection can
|
|
1174
|
+
* validate cleanly and still fail at execution — permission denied, a type
|
|
1175
|
+
* error, a column dropped between validation and this run — and `fetchPage`
|
|
1176
|
+
* throwing is the realistic shape of that. Quarantine that one source as
|
|
1177
|
+
* `{ count: 0, ok: false, reason }` and keep going. The design's promise is
|
|
1178
|
+
* one bad object never takes down the others or the run's report; letting a
|
|
1179
|
+
* throw here escape the loop would break that promise for every source
|
|
1180
|
+
* queued after the failing one.
|
|
1181
|
+
*
|
|
1182
|
+
* Paging and pushing are caught separately, on purpose: `reason` is set only
|
|
1183
|
+
* when paging itself threw (no rows were ever read), so the reported cause
|
|
1184
|
+
* names execution — not the push, which is what actually failed when
|
|
1185
|
+
* `args.push` returns `false`. Conflating the two sent an author whose
|
|
1186
|
+
* projection can't even run to look at their push credentials instead of
|
|
1187
|
+
* their SQL.
|
|
1188
|
+
*/
|
|
1189
|
+
async function pushRemoteProjections(args) {
|
|
1190
|
+
const pageSize = args.pageSize ?? REMOTE_PAGE_SIZE;
|
|
1191
|
+
const pushed = /* @__PURE__ */ new Map();
|
|
1192
|
+
for (const source of args.sources) {
|
|
1193
|
+
let all;
|
|
1194
|
+
try {
|
|
1195
|
+
all = await fetchAllPages(source, pageSize);
|
|
1196
|
+
} catch (error) {
|
|
1197
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1198
|
+
pushed.set(source.entity, {
|
|
1199
|
+
count: 0,
|
|
1200
|
+
ok: false,
|
|
1201
|
+
reason: `the projection failed to execute: ${message}`.slice(0, MAX_REASON)
|
|
1202
|
+
});
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
try {
|
|
1206
|
+
const ok = await args.push(SYNC_CUSTOM_PATH$1, {
|
|
1207
|
+
source: source.entity,
|
|
1208
|
+
rows: all,
|
|
1209
|
+
mode: "full",
|
|
1210
|
+
fetched: all.length,
|
|
1211
|
+
cursor: null,
|
|
1212
|
+
objectType: source.objectType
|
|
1213
|
+
});
|
|
1214
|
+
pushed.set(source.entity, {
|
|
1215
|
+
count: all.length,
|
|
1216
|
+
ok
|
|
1217
|
+
});
|
|
1218
|
+
} catch {
|
|
1219
|
+
pushed.set(source.entity, {
|
|
1220
|
+
count: 0,
|
|
1221
|
+
ok: false
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
return pushed;
|
|
1226
|
+
}
|
|
1227
|
+
//#endregion
|
|
984
1228
|
//#region src/self-check.ts
|
|
985
1229
|
function selfCheck(input) {
|
|
986
1230
|
const issues = [];
|
|
@@ -1240,12 +1484,25 @@ function resolveContactEntity(config) {
|
|
|
1240
1484
|
}
|
|
1241
1485
|
function collectSchemaMeta(config) {
|
|
1242
1486
|
const byTable = /* @__PURE__ */ new Map();
|
|
1243
|
-
|
|
1487
|
+
const merge = (name, meta) => {
|
|
1488
|
+
const existing = byTable.get(name);
|
|
1489
|
+
byTable.set(name, {
|
|
1490
|
+
columnsMeta: meta.columnsMeta ?? existing?.columnsMeta,
|
|
1491
|
+
uniqueConstraints: meta.uniqueConstraints ?? existing?.uniqueConstraints
|
|
1492
|
+
});
|
|
1493
|
+
};
|
|
1494
|
+
for (const table of config.schema ?? []) if (table.columnsMeta || table.uniqueConstraints) merge(table.name, {
|
|
1495
|
+
columnsMeta: table.columnsMeta,
|
|
1496
|
+
uniqueConstraints: table.uniqueConstraints
|
|
1497
|
+
});
|
|
1244
1498
|
const sources = [];
|
|
1245
1499
|
if (config.source) sources.push(config.source.contacts, config.source.companies, config.source.deals, ...config.source.custom ?? []);
|
|
1246
1500
|
for (const source of sources) {
|
|
1247
1501
|
const meta = source?.[DRIZZLE_META];
|
|
1248
|
-
if (meta?.tableName && meta.columnsMeta)
|
|
1502
|
+
if (meta?.tableName && (meta.columnsMeta || meta.uniqueConstraints)) merge(meta.tableName, {
|
|
1503
|
+
columnsMeta: meta.columnsMeta,
|
|
1504
|
+
uniqueConstraints: meta.uniqueConstraints
|
|
1505
|
+
});
|
|
1249
1506
|
}
|
|
1250
1507
|
return byTable;
|
|
1251
1508
|
}
|
|
@@ -1260,12 +1517,13 @@ function buildSchema(config) {
|
|
|
1260
1517
|
if (config.contactEntity && !byName.has(config.contactEntity)) byName.set(config.contactEntity, new Set(config.contactFields ?? []));
|
|
1261
1518
|
const metaByTable = collectSchemaMeta(config);
|
|
1262
1519
|
return [...byName].map(([name, columns]) => {
|
|
1263
|
-
const
|
|
1264
|
-
|
|
1520
|
+
const meta = metaByTable.get(name);
|
|
1521
|
+
for (const col of meta?.columnsMeta ?? []) columns.add(col.name);
|
|
1265
1522
|
return {
|
|
1266
1523
|
name,
|
|
1267
1524
|
columns: [...columns],
|
|
1268
|
-
...columnsMeta ? { columnsMeta } : {}
|
|
1525
|
+
...meta?.columnsMeta ? { columnsMeta: meta.columnsMeta } : {},
|
|
1526
|
+
...meta?.uniqueConstraints ? { uniqueConstraints: meta.uniqueConstraints } : {}
|
|
1269
1527
|
};
|
|
1270
1528
|
});
|
|
1271
1529
|
}
|
|
@@ -1445,6 +1703,15 @@ async function handlePreviewRequest(config, preview) {
|
|
|
1445
1703
|
sampledCount: rows.length
|
|
1446
1704
|
}, 200);
|
|
1447
1705
|
}
|
|
1706
|
+
async function handleProjectionPreviewRequest(config, request) {
|
|
1707
|
+
const run = config.introspector?.runProjection;
|
|
1708
|
+
if (!run) return jsonResponse({ error: "This app cannot preview derived objects. Set `introspector: drizzleIntrospector(db)` in clivly.config.ts, on a clivly version that supports runProjection." }, 400);
|
|
1709
|
+
const rows = await run.call(config.introspector, request.projection, { limit: request.limit });
|
|
1710
|
+
return jsonResponse({
|
|
1711
|
+
rows,
|
|
1712
|
+
sampledCount: rows.length
|
|
1713
|
+
}, 200);
|
|
1714
|
+
}
|
|
1448
1715
|
/**
|
|
1449
1716
|
* Create the Clivly SDK. The implemented surface — heartbeat/discovery reporting
|
|
1450
1717
|
* and the auth-verify handler — connects a developer's app to a running Clivly
|
|
@@ -1472,9 +1739,15 @@ function createClivlySDK(config) {
|
|
|
1472
1739
|
const cursorStore = config.cursorStore ?? createMemoryCursorStore();
|
|
1473
1740
|
const fetcher = createFetcher(config.retry);
|
|
1474
1741
|
let warnedUnsignedTrigger = false;
|
|
1742
|
+
const remoteProjections = createRemoteProjectionFetcher({
|
|
1743
|
+
apiKey: config.apiKey,
|
|
1744
|
+
apiUrl,
|
|
1745
|
+
fetcher
|
|
1746
|
+
});
|
|
1475
1747
|
let observedUrl;
|
|
1476
1748
|
let lastPushFailure = null;
|
|
1477
1749
|
let lastPush = { failed: false };
|
|
1750
|
+
let lastRemoteOutcomes = [];
|
|
1478
1751
|
const warnedSilentTargets = /* @__PURE__ */ new Set();
|
|
1479
1752
|
const warnIfSilent = async (target, response) => {
|
|
1480
1753
|
if (typeof response.json !== "function") return;
|
|
@@ -1546,18 +1819,58 @@ function createClivlySDK(config) {
|
|
|
1546
1819
|
for (const customSource of customSources ?? []) if (!await syncOne(customSource, SYNC_CUSTOM_PATH, mode, customSource.objectType, runId)) failed.push(customSource.entity);
|
|
1547
1820
|
return failed;
|
|
1548
1821
|
};
|
|
1822
|
+
const syncRemoteSources = async (sources, runId) => {
|
|
1823
|
+
const pushed = await pushRemoteProjections({
|
|
1824
|
+
push: createPush(runId),
|
|
1825
|
+
sources
|
|
1826
|
+
});
|
|
1827
|
+
const failed = [];
|
|
1828
|
+
for (const [entity, result] of pushed) if (!result.ok) failed.push(entity);
|
|
1829
|
+
return {
|
|
1830
|
+
failed,
|
|
1831
|
+
pushed
|
|
1832
|
+
};
|
|
1833
|
+
};
|
|
1834
|
+
const reportRemoteOutcomes = async (outcomes, pushed, runId) => {
|
|
1835
|
+
if (outcomes.length === 0) return;
|
|
1836
|
+
try {
|
|
1837
|
+
const report = buildRemoteProjectionReport({
|
|
1838
|
+
outcomes,
|
|
1839
|
+
pushed,
|
|
1840
|
+
runId
|
|
1841
|
+
});
|
|
1842
|
+
await fetcher(`${apiUrl}${PROJECTIONS_REPORT_PATH}`, {
|
|
1843
|
+
method: "POST",
|
|
1844
|
+
headers: {
|
|
1845
|
+
authorization: `Bearer ${config.apiKey}`,
|
|
1846
|
+
"content-type": "application/json"
|
|
1847
|
+
},
|
|
1848
|
+
body: JSON.stringify(report)
|
|
1849
|
+
});
|
|
1850
|
+
} catch {}
|
|
1851
|
+
};
|
|
1549
1852
|
const runSync = async (options) => {
|
|
1550
|
-
|
|
1853
|
+
const remoteOutcomes = resolveRemoteProjections({
|
|
1854
|
+
entries: await remoteProjections.fetch(),
|
|
1855
|
+
introspector: config.introspector,
|
|
1856
|
+
schema: buildSchema(config)
|
|
1857
|
+
});
|
|
1858
|
+
lastRemoteOutcomes = remoteOutcomes;
|
|
1859
|
+
const remoteSources = remoteOutcomes.filter((o) => o.status === "ok").map((o) => o.source);
|
|
1860
|
+
if (!config.source && remoteSources.length === 0 && remoteOutcomes.length === 0) return;
|
|
1551
1861
|
lastPushFailure = null;
|
|
1552
1862
|
const mode = options?.mode ?? "delta";
|
|
1553
1863
|
const runId = options?.runId;
|
|
1554
1864
|
const failed = [];
|
|
1555
|
-
if (config.source
|
|
1865
|
+
if (config.source?.companies) {
|
|
1556
1866
|
if (!await syncOne(config.source.companies, SYNC_COMPANIES_PATH, mode, void 0, runId)) failed.push(config.source.companies.entity);
|
|
1557
1867
|
}
|
|
1558
|
-
if (!await syncOne(config.source.contacts, SYNC_CONTACTS_PATH, mode, void 0, runId)) failed.push(config.source.contacts.entity);
|
|
1559
|
-
if (config.source
|
|
1560
|
-
failed.push(...await syncCustomSources(config.source
|
|
1868
|
+
if (config.source?.contacts && !await syncOne(config.source.contacts, SYNC_CONTACTS_PATH, mode, void 0, runId)) failed.push(config.source.contacts.entity);
|
|
1869
|
+
if (config.source?.deals && !await syncOne(config.source.deals, SYNC_DEALS_PATH, mode, void 0, runId)) failed.push(config.source.deals.entity);
|
|
1870
|
+
failed.push(...await syncCustomSources(config.source?.custom, mode, runId));
|
|
1871
|
+
const remote = await syncRemoteSources(remoteSources, runId);
|
|
1872
|
+
failed.push(...remote.failed);
|
|
1873
|
+
await reportRemoteOutcomes(lastRemoteOutcomes, remote.pushed, runId);
|
|
1561
1874
|
if (failed.length > 0) {
|
|
1562
1875
|
const message = describePushFailure(failed);
|
|
1563
1876
|
lastPush = {
|
|
@@ -1574,13 +1887,31 @@ function createClivlySDK(config) {
|
|
|
1574
1887
|
lastPushFailure = null;
|
|
1575
1888
|
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]));
|
|
1576
1889
|
};
|
|
1577
|
-
const
|
|
1578
|
-
|
|
1890
|
+
const describeSyncTrigger = () => ({
|
|
1891
|
+
configured: config.syncTrigger?.url,
|
|
1892
|
+
resolved: resolveTriggerUrl({
|
|
1579
1893
|
configuredUrl: config.syncTrigger?.url,
|
|
1580
1894
|
configuredPath: config.syncTrigger?.path,
|
|
1581
1895
|
env: config.env ?? process.env,
|
|
1582
1896
|
observedUrl
|
|
1583
|
-
})
|
|
1897
|
+
})
|
|
1898
|
+
});
|
|
1899
|
+
const describeRemoteProjections = () => {
|
|
1900
|
+
const counts = {
|
|
1901
|
+
failed: 0,
|
|
1902
|
+
invalid: 0,
|
|
1903
|
+
ok: 0,
|
|
1904
|
+
skipped: 0
|
|
1905
|
+
};
|
|
1906
|
+
for (const outcome of lastRemoteOutcomes) counts[outcome.status] += 1;
|
|
1907
|
+
return {
|
|
1908
|
+
capable: Boolean(config.introspector?.projectionSource),
|
|
1909
|
+
ran: lastRemoteOutcomes.length > 0,
|
|
1910
|
+
...counts
|
|
1911
|
+
};
|
|
1912
|
+
};
|
|
1913
|
+
const sendHeartbeat = () => {
|
|
1914
|
+
const triggerUrl = describeSyncTrigger().resolved;
|
|
1584
1915
|
return fetcher(`${apiUrl}/clivly/sdk/heartbeat`, {
|
|
1585
1916
|
method: "POST",
|
|
1586
1917
|
headers: {
|
|
@@ -1777,7 +2108,7 @@ function createClivlySDK(config) {
|
|
|
1777
2108
|
});
|
|
1778
2109
|
const runScheduled = async (options) => {
|
|
1779
2110
|
await heartbeat();
|
|
1780
|
-
if (config.source) await runSync(options);
|
|
2111
|
+
if (config.source || config.introspector) await runSync(options);
|
|
1781
2112
|
};
|
|
1782
2113
|
const triggerRequestAllowed = async (req, rawBody) => {
|
|
1783
2114
|
const secret = config.syncTrigger?.secret;
|
|
@@ -1808,6 +2139,7 @@ function createClivlySDK(config) {
|
|
|
1808
2139
|
const startedAt = Date.now();
|
|
1809
2140
|
try {
|
|
1810
2141
|
if (payload.preview) return await handlePreviewRequest(config, payload.preview);
|
|
2142
|
+
if (payload.previewProjection) return await handleProjectionPreviewRequest(config, payload.previewProjection);
|
|
1811
2143
|
if (payload.sample) await runSampleSync(payload.sample.entityKey, payload.runId);
|
|
1812
2144
|
else await runScheduled({
|
|
1813
2145
|
mode: payload.mode,
|
|
@@ -1870,7 +2202,7 @@ function createClivlySDK(config) {
|
|
|
1870
2202
|
presenceClient = null;
|
|
1871
2203
|
console.warn("Clivly presence socket unavailable; using HTTP heartbeat", error);
|
|
1872
2204
|
}
|
|
1873
|
-
if (!timers.sync && config.source) {
|
|
2205
|
+
if (!timers.sync && (config.source || config.introspector)) {
|
|
1874
2206
|
timers.sync = setInterval(() => {
|
|
1875
2207
|
runSync().catch(() => {});
|
|
1876
2208
|
}, syncIntervalMs);
|
|
@@ -1903,6 +2235,8 @@ function createClivlySDK(config) {
|
|
|
1903
2235
|
connect,
|
|
1904
2236
|
ping,
|
|
1905
2237
|
doctor,
|
|
2238
|
+
describeSyncTrigger,
|
|
2239
|
+
describeRemoteProjections,
|
|
1906
2240
|
runScheduled,
|
|
1907
2241
|
createClivlyHandler,
|
|
1908
2242
|
sync: runSync,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ClivlyEntitiesConfig, IntegrationState } from "@clivly/core";
|
|
1
|
+
import { ClivlyEntitiesConfig, IntegrationState, ProjectionMapping } from "@clivly/core";
|
|
2
2
|
//#region src/drizzle-meta.d.ts
|
|
3
3
|
declare const DRIZZLE_META: "__clivly_drizzle_source_meta__";
|
|
4
4
|
interface DrizzleSourceMeta {
|
|
@@ -18,6 +18,43 @@ interface DrizzleSourceMeta {
|
|
|
18
18
|
interface NamespaceIntrospector {
|
|
19
19
|
/** All base table names in the reachable schema. */
|
|
20
20
|
listTables(): Promise<string[]>;
|
|
21
|
+
/**
|
|
22
|
+
* Build a runnable sync source from a projection, for derived objects
|
|
23
|
+
* authored in the dashboard rather than in `clivly.config.ts`.
|
|
24
|
+
*
|
|
25
|
+
* Optional for the same reason `runProjection` is: an introspector may be
|
|
26
|
+
* hand-written. Absent, remote projections report `skipped` with a reason
|
|
27
|
+
* naming this method, rather than failing the run.
|
|
28
|
+
*
|
|
29
|
+
* This is deliberately the only route to a database handle for remote
|
|
30
|
+
* projections. `drizzleIntrospector(db)` already closes over the
|
|
31
|
+
* connection, so `introspector:` is the single config line that turns
|
|
32
|
+
* dashboard-authored objects on — and the SDK core never holds a raw
|
|
33
|
+
* client.
|
|
34
|
+
*/
|
|
35
|
+
projectionSource?(projection: ProjectionMapping, opts: {
|
|
36
|
+
basePkColumn?: string;
|
|
37
|
+
objectType: string;
|
|
38
|
+
}): SyncSource & {
|
|
39
|
+
objectType: string;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Read a few rows through an unsaved projection, for the dashboard's derived
|
|
43
|
+
* object preview. Rows are keyed by field name.
|
|
44
|
+
*
|
|
45
|
+
* Optional because it is the only capability here that needs more than a
|
|
46
|
+
* table name, and because an introspector may be hand-written. Absent, the
|
|
47
|
+
* dashboard says preview is unavailable rather than failing — see
|
|
48
|
+
* `previewProjection`.
|
|
49
|
+
*
|
|
50
|
+
* This is what makes preview cost the host an SDK upgrade and nothing else:
|
|
51
|
+
* `drizzleIntrospector(db)` already closes over the connection, so a config
|
|
52
|
+
* that set `introspector` for the "Namespace reachable" check gains preview
|
|
53
|
+
* without touching a line.
|
|
54
|
+
*/
|
|
55
|
+
runProjection?(projection: ProjectionMapping, opts: {
|
|
56
|
+
limit: number;
|
|
57
|
+
}): Promise<Record<string, unknown>[]>;
|
|
21
58
|
/** Prove a table is genuinely selectable (SELECT ... LIMIT 1). Throws if unreachable/denied. */
|
|
22
59
|
sampleSelect(table: string): Promise<void>;
|
|
23
60
|
}
|
|
@@ -347,6 +384,31 @@ interface CursorStore {
|
|
|
347
384
|
get(entity: string): Promise<SyncCursor | null> | SyncCursor | null;
|
|
348
385
|
set(entity: string, cursor: SyncCursor | null): Promise<void> | void;
|
|
349
386
|
}
|
|
387
|
+
/** @see ClivlySDK.describeSyncTrigger */
|
|
388
|
+
interface SyncTriggerDescription {
|
|
389
|
+
/**
|
|
390
|
+
* `syncTrigger.url` exactly as configured, placeholder and all. The raw
|
|
391
|
+
* value, so a caller can say what is in the file rather than what survived
|
|
392
|
+
* resolution.
|
|
393
|
+
*/
|
|
394
|
+
configured?: string;
|
|
395
|
+
/**
|
|
396
|
+
* The URL that would be reported to Clivly on the next heartbeat, or
|
|
397
|
+
* `undefined` when nothing resolves. An unedited placeholder never resolves.
|
|
398
|
+
*/
|
|
399
|
+
resolved?: string;
|
|
400
|
+
}
|
|
401
|
+
/** @see ClivlySDK.describeRemoteProjections */
|
|
402
|
+
interface RemoteProjectionsDescription {
|
|
403
|
+
/** False when no `introspector` is configured — nothing can run. */
|
|
404
|
+
capable: boolean;
|
|
405
|
+
failed: number;
|
|
406
|
+
invalid: number;
|
|
407
|
+
ok: number;
|
|
408
|
+
/** False until a sync has run in this process. */
|
|
409
|
+
ran: boolean;
|
|
410
|
+
skipped: number;
|
|
411
|
+
}
|
|
350
412
|
interface ClivlySDK {
|
|
351
413
|
/**
|
|
352
414
|
* Send a single heartbeat and report the outcome richly: `ok`, the HTTP
|
|
@@ -382,6 +444,27 @@ interface ClivlySDK {
|
|
|
382
444
|
* anyone who can reach it — set a secret if it's publicly routable.
|
|
383
445
|
*/
|
|
384
446
|
createClivlyHandler(): (req: Request) => Promise<Response>;
|
|
447
|
+
/**
|
|
448
|
+
* What this app would tell Clivly about dashboard-authored derived objects,
|
|
449
|
+
* right now. Counts rather than the outcomes themselves: this reports
|
|
450
|
+
* health, and the per-object reasons already travel to the dashboard, which
|
|
451
|
+
* is where an author fixes them.
|
|
452
|
+
*
|
|
453
|
+
* Read by `clivly status`.
|
|
454
|
+
*/
|
|
455
|
+
describeRemoteProjections(): RemoteProjectionsDescription;
|
|
456
|
+
/**
|
|
457
|
+
* What this app would tell Clivly about its sync trigger route, right now.
|
|
458
|
+
*
|
|
459
|
+
* Computed per call rather than frozen at creation: `resolved` can start as
|
|
460
|
+
* `undefined` and become a real URL once the mounted route has served its
|
|
461
|
+
* first request and observed its own origin.
|
|
462
|
+
*
|
|
463
|
+
* Exists for `clivly status`, which has to tell "no trigger configured"
|
|
464
|
+
* apart from "configured, but still the scaffolded placeholder" — a
|
|
465
|
+
* distinction `resolved` alone erases, since it skips placeholders by design.
|
|
466
|
+
*/
|
|
467
|
+
describeSyncTrigger(): SyncTriggerDescription;
|
|
385
468
|
/** Validate this setup end-to-end (read-only) before the first sync. */
|
|
386
469
|
doctor(): Promise<DoctorReport>;
|
|
387
470
|
/**
|
|
@@ -454,4 +537,4 @@ interface ClivlySDK {
|
|
|
454
537
|
}): Promise<void>;
|
|
455
538
|
}
|
|
456
539
|
//#endregion
|
|
457
|
-
export {
|
|
540
|
+
export { SyncSource as _, ClivlySDK as a, CreateChatSessionHandlerOptions as c, DoctorCheck as d, DoctorReport as f, RemoteProjectionsDescription as g, EnsuredObjectTypeResult as h, ClivlyConnectResult as i, CursorStore as l, EnsuredMappingResult as m, ChatWidgetVisitor as n, ClivlySDKConfig as o, EnsureSchemaResult as p, ClivlyConnectReason as r, ClivlyUser as s, ChatSessionRequestBody as t, DiscoveredTable as u, SyncTriggerDescription as v, NamespaceIntrospector as y };
|