@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/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: sources.length > 0,
579
- detail: sources.length > 0 ? `${sources.length} source(s) to mirror.` : "No `source.contacts` — nothing to sync."
587
+ ok: !noSources,
588
+ skipped: deferred,
589
+ detail: sourcesDetail(sources.length, deferred)
580
590
  });
581
- checks.push(checkConceptCoverage(config));
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
- for (const table of config.schema ?? []) if (table.columnsMeta) byTable.set(table.name, table.columnsMeta);
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) byTable.set(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 columnsMeta = metaByTable.get(name);
1264
- if (columnsMeta) for (const col of columnsMeta) columns.add(col.name);
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
- if (!config.source) return;
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.companies) {
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.deals && !await syncOne(config.source.deals, SYNC_DEALS_PATH, mode, void 0, runId)) failed.push(config.source.deals.entity);
1560
- failed.push(...await syncCustomSources(config.source.custom, mode, runId));
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 sendHeartbeat = () => {
1578
- const triggerUrl = resolveTriggerUrl({
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 { NamespaceIntrospector as _, ClivlySDK as a, CreateChatSessionHandlerOptions as c, DoctorCheck as d, DoctorReport as f, SyncSource 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 };
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 };