@clivly/sdk 0.9.6 → 0.10.0
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 +34 -13
- package/dist/chat-session.d.cts +1 -1
- package/dist/chat-session.d.mts +1 -1
- package/dist/drizzle.cjs +56 -22
- package/dist/drizzle.d.cts +8 -2
- package/dist/drizzle.d.mts +9 -3
- package/dist/drizzle.mjs +56 -22
- package/dist/index.cjs +345 -15
- package/dist/index.d.cts +2 -2
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +345 -15
- package/dist/{types-CiFEalbP.d.cts → types-B7HKG2Ph.d.cts} +41 -1
- package/dist/{types-CiFEalbP.d.mts → types-B7HKG2Ph.d.mts} +41 -1
- 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 = [
|
|
@@ -990,6 +991,240 @@ function describeSilentPush(target, body) {
|
|
|
990
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.`;
|
|
991
992
|
}
|
|
992
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
|
|
993
1228
|
//#region src/self-check.ts
|
|
994
1229
|
function selfCheck(input) {
|
|
995
1230
|
const issues = [];
|
|
@@ -1249,12 +1484,25 @@ function resolveContactEntity(config) {
|
|
|
1249
1484
|
}
|
|
1250
1485
|
function collectSchemaMeta(config) {
|
|
1251
1486
|
const byTable = /* @__PURE__ */ new Map();
|
|
1252
|
-
|
|
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
|
+
});
|
|
1253
1498
|
const sources = [];
|
|
1254
1499
|
if (config.source) sources.push(config.source.contacts, config.source.companies, config.source.deals, ...config.source.custom ?? []);
|
|
1255
1500
|
for (const source of sources) {
|
|
1256
1501
|
const meta = source?.[DRIZZLE_META];
|
|
1257
|
-
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
|
+
});
|
|
1258
1506
|
}
|
|
1259
1507
|
return byTable;
|
|
1260
1508
|
}
|
|
@@ -1269,12 +1517,13 @@ function buildSchema(config) {
|
|
|
1269
1517
|
if (config.contactEntity && !byName.has(config.contactEntity)) byName.set(config.contactEntity, new Set(config.contactFields ?? []));
|
|
1270
1518
|
const metaByTable = collectSchemaMeta(config);
|
|
1271
1519
|
return [...byName].map(([name, columns]) => {
|
|
1272
|
-
const
|
|
1273
|
-
|
|
1520
|
+
const meta = metaByTable.get(name);
|
|
1521
|
+
for (const col of meta?.columnsMeta ?? []) columns.add(col.name);
|
|
1274
1522
|
return {
|
|
1275
1523
|
name,
|
|
1276
1524
|
columns: [...columns],
|
|
1277
|
-
...columnsMeta ? { columnsMeta } : {}
|
|
1525
|
+
...meta?.columnsMeta ? { columnsMeta: meta.columnsMeta } : {},
|
|
1526
|
+
...meta?.uniqueConstraints ? { uniqueConstraints: meta.uniqueConstraints } : {}
|
|
1278
1527
|
};
|
|
1279
1528
|
});
|
|
1280
1529
|
}
|
|
@@ -1309,6 +1558,24 @@ function timingSafeEqual(a, b) {
|
|
|
1309
1558
|
}
|
|
1310
1559
|
const RUN_ID_HEADER = "x-clivly-run-id";
|
|
1311
1560
|
const SIGNATURE_HEADER = "x-clivly-signature";
|
|
1561
|
+
/**
|
|
1562
|
+
* An error the mounted trigger route should answer with a specific HTTP
|
|
1563
|
+
* status, rather than the generic 500 it uses for a genuinely unhandled
|
|
1564
|
+
* error. A fail-safe rejection (e.g. an unresolvable sample `entityKey`) is a
|
|
1565
|
+
* configuration mismatch the caller can fix — surfacing it as "your app
|
|
1566
|
+
* crashed" sends a developer hunting through their own sync handler instead.
|
|
1567
|
+
*/
|
|
1568
|
+
var TriggerRequestError = class extends Error {
|
|
1569
|
+
status;
|
|
1570
|
+
constructor(status, message) {
|
|
1571
|
+
super(message);
|
|
1572
|
+
this.name = new.target.name;
|
|
1573
|
+
this.status = status;
|
|
1574
|
+
}
|
|
1575
|
+
};
|
|
1576
|
+
function triggerErrorStatus(error) {
|
|
1577
|
+
return error instanceof TriggerRequestError ? error.status : 500;
|
|
1578
|
+
}
|
|
1312
1579
|
const SIGNATURE_TOLERANCE_SECONDS = 300;
|
|
1313
1580
|
const SIGNATURE_HEADER_RE = /^t=(\d+),v1=([0-9a-f]+)$/;
|
|
1314
1581
|
const SECONDS_PER_MS = 1e3;
|
|
@@ -1490,9 +1757,15 @@ function createClivlySDK(config) {
|
|
|
1490
1757
|
const cursorStore = config.cursorStore ?? createMemoryCursorStore();
|
|
1491
1758
|
const fetcher = createFetcher(config.retry);
|
|
1492
1759
|
let warnedUnsignedTrigger = false;
|
|
1760
|
+
const remoteProjections = createRemoteProjectionFetcher({
|
|
1761
|
+
apiKey: config.apiKey,
|
|
1762
|
+
apiUrl,
|
|
1763
|
+
fetcher
|
|
1764
|
+
});
|
|
1493
1765
|
let observedUrl;
|
|
1494
1766
|
let lastPushFailure = null;
|
|
1495
1767
|
let lastPush = { failed: false };
|
|
1768
|
+
let lastRemoteOutcomes = [];
|
|
1496
1769
|
const warnedSilentTargets = /* @__PURE__ */ new Set();
|
|
1497
1770
|
const warnIfSilent = async (target, response) => {
|
|
1498
1771
|
if (typeof response.json !== "function") return;
|
|
@@ -1564,18 +1837,58 @@ function createClivlySDK(config) {
|
|
|
1564
1837
|
for (const customSource of customSources ?? []) if (!await syncOne(customSource, SYNC_CUSTOM_PATH, mode, customSource.objectType, runId)) failed.push(customSource.entity);
|
|
1565
1838
|
return failed;
|
|
1566
1839
|
};
|
|
1840
|
+
const syncRemoteSources = async (sources, runId) => {
|
|
1841
|
+
const pushed = await pushRemoteProjections({
|
|
1842
|
+
push: createPush(runId),
|
|
1843
|
+
sources
|
|
1844
|
+
});
|
|
1845
|
+
const failed = [];
|
|
1846
|
+
for (const [entity, result] of pushed) if (!result.ok) failed.push(entity);
|
|
1847
|
+
return {
|
|
1848
|
+
failed,
|
|
1849
|
+
pushed
|
|
1850
|
+
};
|
|
1851
|
+
};
|
|
1852
|
+
const reportRemoteOutcomes = async (outcomes, pushed, runId) => {
|
|
1853
|
+
if (outcomes.length === 0) return;
|
|
1854
|
+
try {
|
|
1855
|
+
const report = buildRemoteProjectionReport({
|
|
1856
|
+
outcomes,
|
|
1857
|
+
pushed,
|
|
1858
|
+
runId
|
|
1859
|
+
});
|
|
1860
|
+
await fetcher(`${apiUrl}${PROJECTIONS_REPORT_PATH}`, {
|
|
1861
|
+
method: "POST",
|
|
1862
|
+
headers: {
|
|
1863
|
+
authorization: `Bearer ${config.apiKey}`,
|
|
1864
|
+
"content-type": "application/json"
|
|
1865
|
+
},
|
|
1866
|
+
body: JSON.stringify(report)
|
|
1867
|
+
});
|
|
1868
|
+
} catch {}
|
|
1869
|
+
};
|
|
1567
1870
|
const runSync = async (options) => {
|
|
1568
|
-
|
|
1871
|
+
const remoteOutcomes = resolveRemoteProjections({
|
|
1872
|
+
entries: await remoteProjections.fetch(),
|
|
1873
|
+
introspector: config.introspector,
|
|
1874
|
+
schema: buildSchema(config)
|
|
1875
|
+
});
|
|
1876
|
+
lastRemoteOutcomes = remoteOutcomes;
|
|
1877
|
+
const remoteSources = remoteOutcomes.filter((o) => o.status === "ok").map((o) => o.source);
|
|
1878
|
+
if (!config.source && remoteSources.length === 0 && remoteOutcomes.length === 0) return;
|
|
1569
1879
|
lastPushFailure = null;
|
|
1570
1880
|
const mode = options?.mode ?? "delta";
|
|
1571
1881
|
const runId = options?.runId;
|
|
1572
1882
|
const failed = [];
|
|
1573
|
-
if (config.source
|
|
1883
|
+
if (config.source?.companies) {
|
|
1574
1884
|
if (!await syncOne(config.source.companies, SYNC_COMPANIES_PATH, mode, void 0, runId)) failed.push(config.source.companies.entity);
|
|
1575
1885
|
}
|
|
1576
|
-
if (!await syncOne(config.source.contacts, SYNC_CONTACTS_PATH, mode, void 0, runId)) failed.push(config.source.contacts.entity);
|
|
1577
|
-
if (config.source
|
|
1578
|
-
failed.push(...await syncCustomSources(config.source
|
|
1886
|
+
if (config.source?.contacts && !await syncOne(config.source.contacts, SYNC_CONTACTS_PATH, mode, void 0, runId)) failed.push(config.source.contacts.entity);
|
|
1887
|
+
if (config.source?.deals && !await syncOne(config.source.deals, SYNC_DEALS_PATH, mode, void 0, runId)) failed.push(config.source.deals.entity);
|
|
1888
|
+
failed.push(...await syncCustomSources(config.source?.custom, mode, runId));
|
|
1889
|
+
const remote = await syncRemoteSources(remoteSources, runId);
|
|
1890
|
+
failed.push(...remote.failed);
|
|
1891
|
+
await reportRemoteOutcomes(lastRemoteOutcomes, remote.pushed, runId);
|
|
1579
1892
|
if (failed.length > 0) {
|
|
1580
1893
|
const message = describePushFailure(failed);
|
|
1581
1894
|
lastPush = {
|
|
@@ -1588,7 +1901,7 @@ function createClivlySDK(config) {
|
|
|
1588
1901
|
};
|
|
1589
1902
|
const runSampleSync = async (entityKey, runId) => {
|
|
1590
1903
|
const resolved = resolveSampleSource(config, entityKey);
|
|
1591
|
-
if (!resolved) throw new
|
|
1904
|
+
if (!resolved) throw new TriggerRequestError(400, `Clivly sync: no source is configured for entity "${entityKey}". The dashboard maps this table, but clivly.config declares no matching entity — add it to \`entities\`, or remove the mapping in Integrations → Mapping.`);
|
|
1592
1905
|
lastPushFailure = null;
|
|
1593
1906
|
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]));
|
|
1594
1907
|
};
|
|
@@ -1601,6 +1914,20 @@ function createClivlySDK(config) {
|
|
|
1601
1914
|
observedUrl
|
|
1602
1915
|
})
|
|
1603
1916
|
});
|
|
1917
|
+
const describeRemoteProjections = () => {
|
|
1918
|
+
const counts = {
|
|
1919
|
+
failed: 0,
|
|
1920
|
+
invalid: 0,
|
|
1921
|
+
ok: 0,
|
|
1922
|
+
skipped: 0
|
|
1923
|
+
};
|
|
1924
|
+
for (const outcome of lastRemoteOutcomes) counts[outcome.status] += 1;
|
|
1925
|
+
return {
|
|
1926
|
+
capable: Boolean(config.introspector?.projectionSource),
|
|
1927
|
+
ran: lastRemoteOutcomes.length > 0,
|
|
1928
|
+
...counts
|
|
1929
|
+
};
|
|
1930
|
+
};
|
|
1604
1931
|
const sendHeartbeat = () => {
|
|
1605
1932
|
const triggerUrl = describeSyncTrigger().resolved;
|
|
1606
1933
|
return fetcher(`${apiUrl}/clivly/sdk/heartbeat`, {
|
|
@@ -1799,7 +2126,7 @@ function createClivlySDK(config) {
|
|
|
1799
2126
|
});
|
|
1800
2127
|
const runScheduled = async (options) => {
|
|
1801
2128
|
await heartbeat();
|
|
1802
|
-
if (config.source) await runSync(options);
|
|
2129
|
+
if (config.source || config.introspector) await runSync(options);
|
|
1803
2130
|
};
|
|
1804
2131
|
const triggerRequestAllowed = async (req, rawBody) => {
|
|
1805
2132
|
const secret = config.syncTrigger?.secret;
|
|
@@ -1813,7 +2140,9 @@ function createClivlySDK(config) {
|
|
|
1813
2140
|
const parseTriggerBody = (rawBody) => {
|
|
1814
2141
|
if (!rawBody) return {};
|
|
1815
2142
|
try {
|
|
1816
|
-
|
|
2143
|
+
const parsed = JSON.parse(rawBody);
|
|
2144
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
2145
|
+
return parsed;
|
|
1817
2146
|
} catch {
|
|
1818
2147
|
return {};
|
|
1819
2148
|
}
|
|
@@ -1846,7 +2175,7 @@ function createClivlySDK(config) {
|
|
|
1846
2175
|
ok: false,
|
|
1847
2176
|
runId,
|
|
1848
2177
|
error: error instanceof Error ? error.message : String(error)
|
|
1849
|
-
},
|
|
2178
|
+
}, triggerErrorStatus(error));
|
|
1850
2179
|
}
|
|
1851
2180
|
};
|
|
1852
2181
|
const start = async () => {
|
|
@@ -1893,7 +2222,7 @@ function createClivlySDK(config) {
|
|
|
1893
2222
|
presenceClient = null;
|
|
1894
2223
|
console.warn("Clivly presence socket unavailable; using HTTP heartbeat", error);
|
|
1895
2224
|
}
|
|
1896
|
-
if (!timers.sync && config.source) {
|
|
2225
|
+
if (!timers.sync && (config.source || config.introspector)) {
|
|
1897
2226
|
timers.sync = setInterval(() => {
|
|
1898
2227
|
runSync().catch(() => {});
|
|
1899
2228
|
}, syncIntervalMs);
|
|
@@ -1927,6 +2256,7 @@ function createClivlySDK(config) {
|
|
|
1927
2256
|
ping,
|
|
1928
2257
|
doctor,
|
|
1929
2258
|
describeSyncTrigger,
|
|
2259
|
+
describeRemoteProjections,
|
|
1930
2260
|
runScheduled,
|
|
1931
2261
|
createClivlyHandler,
|
|
1932
2262
|
sync: runSync,
|
|
@@ -18,6 +18,26 @@ 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
|
+
};
|
|
21
41
|
/**
|
|
22
42
|
* Read a few rows through an unsaved projection, for the dashboard's derived
|
|
23
43
|
* object preview. Rows are keyed by field name.
|
|
@@ -378,6 +398,17 @@ interface SyncTriggerDescription {
|
|
|
378
398
|
*/
|
|
379
399
|
resolved?: string;
|
|
380
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
|
+
}
|
|
381
412
|
interface ClivlySDK {
|
|
382
413
|
/**
|
|
383
414
|
* Send a single heartbeat and report the outcome richly: `ok`, the HTTP
|
|
@@ -413,6 +444,15 @@ interface ClivlySDK {
|
|
|
413
444
|
* anyone who can reach it — set a secret if it's publicly routable.
|
|
414
445
|
*/
|
|
415
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;
|
|
416
456
|
/**
|
|
417
457
|
* What this app would tell Clivly about its sync trigger route, right now.
|
|
418
458
|
*
|
|
@@ -497,4 +537,4 @@ interface ClivlySDK {
|
|
|
497
537
|
}): Promise<void>;
|
|
498
538
|
}
|
|
499
539
|
//#endregion
|
|
500
|
-
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 };
|
|
@@ -18,6 +18,26 @@ 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
|
+
};
|
|
21
41
|
/**
|
|
22
42
|
* Read a few rows through an unsaved projection, for the dashboard's derived
|
|
23
43
|
* object preview. Rows are keyed by field name.
|
|
@@ -378,6 +398,17 @@ interface SyncTriggerDescription {
|
|
|
378
398
|
*/
|
|
379
399
|
resolved?: string;
|
|
380
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
|
+
}
|
|
381
412
|
interface ClivlySDK {
|
|
382
413
|
/**
|
|
383
414
|
* Send a single heartbeat and report the outcome richly: `ok`, the HTTP
|
|
@@ -413,6 +444,15 @@ interface ClivlySDK {
|
|
|
413
444
|
* anyone who can reach it — set a secret if it's publicly routable.
|
|
414
445
|
*/
|
|
415
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;
|
|
416
456
|
/**
|
|
417
457
|
* What this app would tell Clivly about its sync trigger route, right now.
|
|
418
458
|
*
|
|
@@ -497,4 +537,4 @@ interface ClivlySDK {
|
|
|
497
537
|
}): Promise<void>;
|
|
498
538
|
}
|
|
499
539
|
//#endregion
|
|
500
|
-
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 };
|
package/dist/vite.d.cts
CHANGED
package/dist/vite.d.mts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clivly/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Clivly SDK — connect your app to Clivly Cloud (heartbeat, discovery, auth bridge).",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"access": "public"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@clivly/core": "0.
|
|
58
|
+
"@clivly/core": "0.10.0"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"drizzle-orm": ">=0.30 || >=1.0.0-rc.0",
|