@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/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
- 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
+ });
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) 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
+ });
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 columnsMeta = metaByTable.get(name);
1273
- 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);
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
- if (!config.source) return;
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.companies) {
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.deals && !await syncOne(config.source.deals, SYNC_DEALS_PATH, mode, void 0, runId)) failed.push(config.source.deals.entity);
1578
- failed.push(...await syncCustomSources(config.source.custom, mode, runId));
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 Error(`Clivly sync: sample entityKey "${entityKey}" did not resolve to a configured source`);
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
- return JSON.parse(rawBody);
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
- }, 500);
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 { SyncTriggerDescription 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, NamespaceIntrospector as v };
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 { SyncTriggerDescription 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, NamespaceIntrospector as v };
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
@@ -1,4 +1,4 @@
1
- import { a as ClivlySDK } from "./types-CiFEalbP.cjs";
1
+ import { a as ClivlySDK } from "./types-B7HKG2Ph.cjs";
2
2
  import { Plugin } from "vite";
3
3
  //#region src/vite.d.ts
4
4
  interface ClivlyViteOptions {
package/dist/vite.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as ClivlySDK } from "./types-CiFEalbP.mjs";
1
+ import { a as ClivlySDK } from "./types-B7HKG2Ph.mjs";
2
2
  import { Plugin } from "vite";
3
3
  //#region src/vite.d.ts
4
4
  interface ClivlyViteOptions {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clivly/sdk",
3
- "version": "0.9.6",
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.9.6"
58
+ "@clivly/core": "0.10.0"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "drizzle-orm": ">=0.30 || >=1.0.0-rc.0",