clivly 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.
Files changed (78) hide show
  1. package/README.md +22 -3
  2. package/core-auth-adapter.cjs +1 -1
  3. package/core-auth-adapter.mjs +1 -1
  4. package/core-drizzle.cjs +1 -1
  5. package/core-drizzle.d.cts +1 -1
  6. package/core-drizzle.d.mts +1 -1
  7. package/core-drizzle.mjs +1 -1
  8. package/core-entity-config.cjs +1 -1
  9. package/core-entity-config.mjs +1 -1
  10. package/core-mapping-form.cjs +1 -1
  11. package/core-mapping-form.mjs +1 -1
  12. package/core-mappings-config.cjs +1 -1
  13. package/core-mappings-config.mjs +1 -1
  14. package/core-sync-engine.cjs +1 -1
  15. package/core-sync-engine.mjs +1 -1
  16. package/core-view-compiler.cjs +1 -1
  17. package/core-view-compiler.mjs +1 -1
  18. package/core.cjs +14 -9
  19. package/core.d.cts +2 -2
  20. package/core.d.mts +2 -2
  21. package/core.mjs +9 -9
  22. package/{drizzle-CiK8MMEZ.d.mts → drizzle-BmDDgAIF.d.mts} +3 -1
  23. package/{drizzle-bfjVzYR6.cjs → drizzle-DcB7OfBp.cjs} +9 -1
  24. package/{drizzle-DEtRrqtc.d.cts → drizzle-ixVV4pPN.d.cts} +3 -1
  25. package/{drizzle-3kvhkwO7.mjs → drizzle-wbcuG-cy.mjs} +9 -1
  26. package/drizzle.cjs +61 -10
  27. package/drizzle.d.cts +9 -3
  28. package/drizzle.d.mts +9 -3
  29. package/drizzle.mjs +61 -10
  30. package/{entity-config-B74SPOUZ.mjs → entity-config-C8IIkrmX.mjs} +34 -5
  31. package/{entity-config-7sGCk_nw.cjs → entity-config-CPM-Tp-d.cjs} +34 -5
  32. package/index.cjs +41 -9
  33. package/index.mjs +41 -9
  34. package/internal-BLKMnIIZ.cjs +259 -0
  35. package/internal-BpVh_J77.mjs +212 -0
  36. package/{internal-o2M7FGlR.d.mts → internal-CvlFk3KC.d.mts} +82 -2
  37. package/{internal-wCXSELDN.d.cts → internal-DGUOLQAq.d.cts} +82 -2
  38. package/internal-drizzle.cjs +1 -1
  39. package/internal-drizzle.d.cts +1 -1
  40. package/internal-drizzle.d.mts +1 -1
  41. package/internal-drizzle.mjs +1 -1
  42. package/internal-entity-config.cjs +1 -1
  43. package/internal-entity-config.mjs +1 -1
  44. package/internal-entity-heuristics.cjs +1 -1
  45. package/internal-entity-heuristics.mjs +1 -1
  46. package/internal-mappings-config.cjs +1 -1
  47. package/internal-mappings-config.mjs +1 -1
  48. package/internal-sync-engine.cjs +1 -1
  49. package/internal-sync-engine.mjs +1 -1
  50. package/internal-view-compiler.cjs +1 -1
  51. package/internal-view-compiler.mjs +1 -1
  52. package/internal.cjs +13 -7
  53. package/internal.d.cts +2 -2
  54. package/internal.d.mts +2 -2
  55. package/internal.mjs +8 -8
  56. package/{mappings-config-CLyEg0sf.cjs → mappings-config-CBwL3FVh.cjs} +1 -1
  57. package/{mappings-config-DQS_s4dg.mjs → mappings-config-DUKBe6OS.mjs} +1 -1
  58. package/{namespace-probe-Cn7voAd0.d.mts → namespace-probe-BQ4imnV7.d.mts} +85 -2
  59. package/{namespace-probe-BoGI18dQ.d.cts → namespace-probe-DZIGdvlm.d.cts} +85 -2
  60. package/package.json +1 -1
  61. package/{projection-Dz6_i1-w.cjs → projection-DEwL-D8R.cjs} +6 -0
  62. package/{projection-CSGJbZlZ.mjs → projection-hnPSgH6G.mjs} +1 -1
  63. package/{projection-sql-BgnfiGqF.mjs → projection-sql-9zzTVGvf.mjs} +34 -2
  64. package/{projection-sql-Cl1qIn8m.cjs → projection-sql-iHy-Cx6V.cjs} +45 -1
  65. package/{sdk-CM1UO-I7.cjs → sdk-BL9BCzyD.cjs} +354 -20
  66. package/{sdk-DYBDR939.mjs → sdk-CubnsyD9.mjs} +354 -20
  67. package/sdk.cjs +2 -2
  68. package/sdk.d.cts +2 -2
  69. package/sdk.d.mts +2 -2
  70. package/sdk.mjs +2 -2
  71. package/{sync-engine-C4A5jgA4.cjs → sync-engine-BLgwLpR6.cjs} +1 -1
  72. package/{sync-engine-Dt5QQSKO.mjs → sync-engine-B_IzoUSx.mjs} +1 -1
  73. package/{view-compiler-B5nDal14.cjs → view-compiler-BHI5E4QI.cjs} +2 -2
  74. package/{view-compiler-CSYwEWf5.mjs → view-compiler-D_6gfXFr.mjs} +2 -2
  75. package/vite.d.cts +1 -1
  76. package/vite.d.mts +1 -1
  77. package/internal-CykXpEGk.mjs +0 -68
  78. package/internal-DqduZU3l.cjs +0 -97
@@ -1,5 +1,6 @@
1
- const require_entity_config = require("./entity-config-7sGCk_nw.cjs");
2
- const require_mappings_config = require("./mappings-config-CLyEg0sf.cjs");
1
+ const require_projection = require("./projection-DEwL-D8R.cjs");
2
+ const require_entity_config = require("./entity-config-CPM-Tp-d.cjs");
3
+ const require_mappings_config = require("./mappings-config-CBwL3FVh.cjs");
3
4
  const require_drizzle_meta = require("./drizzle-meta-Bb1JWYqf.cjs");
4
5
  //#region ../sdk/src/deployment-identity.ts
5
6
  const MAX_DEPLOYMENT_ID_LENGTH = 128;
@@ -572,12 +573,21 @@ async function runDoctor(args) {
572
573
  ok: entityCount > 0,
573
574
  detail: entityCount > 0 ? `${entityCount} entit${entityCount === 1 ? "y" : "ies"} configured.` : "No `entities` config — pass defineClivlyConfig(...) as `entities`."
574
575
  });
576
+ const sourcesDetail = (count, deferred) => {
577
+ if (deferred) return "Deferred — the coverage check below names the entity and the source key it expected.";
578
+ return count === 0 ? "No `source.contacts` — nothing to sync." : `${count} source(s) to mirror.`;
579
+ };
580
+ const coverage = checkConceptCoverage(config);
581
+ const coverageOwnsIt = !(coverage.ok || coverage.skipped);
582
+ const noSources = sources.length === 0;
583
+ const deferred = noSources && coverageOwnsIt;
575
584
  checks.push({
576
585
  name: "Sources configured",
577
- ok: sources.length > 0,
578
- detail: sources.length > 0 ? `${sources.length} source(s) to mirror.` : "No `source.contacts` — nothing to sync."
586
+ ok: !noSources,
587
+ skipped: deferred,
588
+ detail: sourcesDetail(sources.length, deferred)
579
589
  });
580
- checks.push(checkConceptCoverage(config));
590
+ checks.push(coverage);
581
591
  checks.push(...cursorColumnChecks(sources));
582
592
  for (const source of sources) {
583
593
  const check = cursorMonotonicityCheck(source);
@@ -980,6 +990,240 @@ function describeSilentPush(target, body) {
980
990
  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.`;
981
991
  }
982
992
  //#endregion
993
+ //#region ../sdk/src/remote-projections.ts
994
+ const SYNC_CUSTOM_PATH$1 = "/clivly/sdk/sync/custom";
995
+ const MISSING_INTROSPECTOR = "this app cannot run dashboard-authored derived objects yet — add `introspector: drizzleIntrospector(db)` to clivly.config.ts";
996
+ /**
997
+ * Turn definitions fetched from the cloud into runnable sync sources.
998
+ *
999
+ * Revalidates every projection locally against the schema THIS app discovered,
1000
+ * never against the cloud's stored copy. Two reasons, and both matter:
1001
+ *
1002
+ * 1. Security. `checkTablesExist`/`checkRefs` bound every table and column ref
1003
+ * to the schema passed in, so validating against the locally-discovered
1004
+ * schema makes the blast radius exactly the namespace the developer handed
1005
+ * to `discoverFromDrizzle`. Trusting the cloud's verdict instead would let a
1006
+ * compromised cloud read any table in the database.
1007
+ * 2. Truth. The stored copy can be stale. A column dropped in the host app
1008
+ * becomes a readable quarantine here instead of a raw Postgres error out of
1009
+ * `sql.raw` at execution time.
1010
+ */
1011
+ function resolveRemoteProjections(args) {
1012
+ const { entries, introspector, schema } = args;
1013
+ return entries.map((entry) => resolveOne(entry, schema, introspector));
1014
+ }
1015
+ /**
1016
+ * The base table's PK, resolved from the schema THIS app discovered — never
1017
+ * from the cloud's copy.
1018
+ *
1019
+ * `validateProjection` proved its uniqueness case against this exact source
1020
+ * (`primaryKeyColumns` over the locally-discovered schema, see
1021
+ * `projection-validate.ts`). Compiling a *different* column — the cloud's
1022
+ * `basePkColumn`, which can go stale the moment a host renames a PK and the
1023
+ * `updatedAt`-only ETag never invalidates — would defeat that proof and
1024
+ * revive the raw-Postgres-error failure mode local revalidation exists to
1025
+ * prevent. Only relevant when the identity is `base_pk` with no explicit
1026
+ * column; every other identity kind ignores `basePkColumn` entirely.
1027
+ */
1028
+ function resolveLocalBasePk(projection, schema) {
1029
+ if (projection.identity.kind !== "base_pk" || projection.identity.column) return;
1030
+ const baseTable = schema.find((t) => t.name === projection.baseTable);
1031
+ if (!baseTable) return;
1032
+ return require_entity_config.primaryKeyColumns(baseTable)[0];
1033
+ }
1034
+ function resolveOne(entry, schema, introspector) {
1035
+ const parsed = require_projection.projectionSchema.safeParse(entry.projection);
1036
+ if (!parsed.success) return {
1037
+ id: entry.id,
1038
+ status: "invalid",
1039
+ reason: `projection did not parse: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`
1040
+ };
1041
+ const projection = parsed.data;
1042
+ const errors = require_entity_config.validateProjection(projection, schema, entry.entityKey);
1043
+ if (errors.length > 0) return {
1044
+ id: entry.id,
1045
+ status: "invalid",
1046
+ reason: errors.map((e) => `${e.path}: ${e.message}`).join("; ")
1047
+ };
1048
+ if (!introspector?.projectionSource) return {
1049
+ id: entry.id,
1050
+ status: "skipped",
1051
+ reason: MISSING_INTROSPECTOR
1052
+ };
1053
+ try {
1054
+ const basePkColumn = resolveLocalBasePk(projection, schema) ?? entry.basePkColumn;
1055
+ const source = introspector.projectionSource(projection, {
1056
+ objectType: entry.objectType,
1057
+ ...basePkColumn ? { basePkColumn } : {}
1058
+ });
1059
+ return {
1060
+ id: entry.id,
1061
+ status: "ok",
1062
+ source
1063
+ };
1064
+ } catch (error) {
1065
+ return {
1066
+ id: entry.id,
1067
+ status: "failed",
1068
+ reason: error instanceof Error ? error.message : String(error)
1069
+ };
1070
+ }
1071
+ }
1072
+ const PROJECTIONS_PATH = "/clivly/sdk/projections";
1073
+ const PROJECTIONS_REPORT_PATH = "/clivly/sdk/projections/report";
1074
+ const MAX_REASON = 2e3;
1075
+ /**
1076
+ * Merge what resolution decided with what the push actually did.
1077
+ *
1078
+ * A `status: "ok"` outcome means the projection *resolved*, not that its rows
1079
+ * landed. If the push then failed, the object did not sync and must not be
1080
+ * reported as healthy — so the push result wins for those.
1081
+ */
1082
+ function buildRemoteProjectionReport(args) {
1083
+ return {
1084
+ ...args.runId ? { runId: args.runId } : {},
1085
+ results: args.outcomes.map((outcome) => {
1086
+ if (outcome.status !== "ok") return {
1087
+ id: outcome.id,
1088
+ status: outcome.status,
1089
+ reason: outcome.reason.slice(0, MAX_REASON)
1090
+ };
1091
+ const result = args.pushed.get(outcome.source.entity);
1092
+ if (result?.ok) return {
1093
+ id: outcome.id,
1094
+ status: "ok",
1095
+ rows: result.count
1096
+ };
1097
+ return {
1098
+ id: outcome.id,
1099
+ status: "failed",
1100
+ reason: (result?.reason ?? "the push to Clivly failed; rows were read but not stored").slice(0, MAX_REASON)
1101
+ };
1102
+ })
1103
+ };
1104
+ }
1105
+ function createRemoteProjectionFetcher(args) {
1106
+ let etag = null;
1107
+ let cached = [];
1108
+ return { async fetch() {
1109
+ try {
1110
+ const response = await args.fetcher(`${args.apiUrl}${PROJECTIONS_PATH}`, { headers: {
1111
+ authorization: `Bearer ${args.apiKey}`,
1112
+ ...etag ? { "if-none-match": etag } : {}
1113
+ } });
1114
+ if (response.status === 304) return cached;
1115
+ if (!response.ok) return [];
1116
+ const body = await response.json();
1117
+ etag = body.etag ?? null;
1118
+ cached = body.projections ?? [];
1119
+ return cached;
1120
+ } catch {
1121
+ return [];
1122
+ }
1123
+ } };
1124
+ }
1125
+ const REMOTE_PAGE_SIZE = 500;
1126
+ /**
1127
+ * Page one source to exhaustion, keyset-paging on identity alone (the id
1128
+ * field, no timestamp). Split out of `pushRemoteProjections` so that
1129
+ * function's own cognitive complexity stays under the lint threshold — this
1130
+ * is the piece that can throw (a `fetchPage` execution error), kept separate
1131
+ * from push-failure handling below.
1132
+ */
1133
+ async function fetchAllPages(source, pageSize) {
1134
+ const idField = source.idField ?? "id";
1135
+ const all = [];
1136
+ let since = null;
1137
+ for (;;) {
1138
+ const rows = await source.fetchPage({
1139
+ since,
1140
+ limit: pageSize
1141
+ });
1142
+ if (rows.length === 0) break;
1143
+ all.push(...rows);
1144
+ const last = rows.at(-1);
1145
+ const nextId = String(last?.[idField]);
1146
+ if (since && nextId === since.id) break;
1147
+ since = { id: nextId };
1148
+ if (rows.length < pageSize) break;
1149
+ }
1150
+ return all;
1151
+ }
1152
+ /**
1153
+ * Page a remote projection by identity and push one `full` batch.
1154
+ *
1155
+ * `full` rather than `delta` because only a full push lets the server archive
1156
+ * rows that dropped out when a join or filter stopped matching — a delta cannot
1157
+ * express absence. And identity rather than a timestamp because a
1158
+ * dashboard-authored projection has no watermark column, while validation has
1159
+ * already proven the identity unique.
1160
+ *
1161
+ * `pageSize` defaults to `REMOTE_PAGE_SIZE` and exists so a test can force a
1162
+ * short page without either mutating the exported constant or constructing a
1163
+ * fixture hundreds of rows deep.
1164
+ *
1165
+ * Returns one entry per source, keyed by `source.entity`. `count` and `ok`
1166
+ * are independent: `count` is how many rows were pushed (0 for a projection
1167
+ * that legitimately matches nothing this run), `ok` is whether the push
1168
+ * itself succeeded. A caller that folds failures into a report must branch
1169
+ * on `ok`, never on `count === 0` — a correctly-working, legitimately-empty
1170
+ * projection has `ok: true, count: 0` and is not a failure.
1171
+ *
1172
+ * Each source's paging-and-push is wrapped individually: a projection can
1173
+ * validate cleanly and still fail at execution — permission denied, a type
1174
+ * error, a column dropped between validation and this run — and `fetchPage`
1175
+ * throwing is the realistic shape of that. Quarantine that one source as
1176
+ * `{ count: 0, ok: false, reason }` and keep going. The design's promise is
1177
+ * one bad object never takes down the others or the run's report; letting a
1178
+ * throw here escape the loop would break that promise for every source
1179
+ * queued after the failing one.
1180
+ *
1181
+ * Paging and pushing are caught separately, on purpose: `reason` is set only
1182
+ * when paging itself threw (no rows were ever read), so the reported cause
1183
+ * names execution — not the push, which is what actually failed when
1184
+ * `args.push` returns `false`. Conflating the two sent an author whose
1185
+ * projection can't even run to look at their push credentials instead of
1186
+ * their SQL.
1187
+ */
1188
+ async function pushRemoteProjections(args) {
1189
+ const pageSize = args.pageSize ?? REMOTE_PAGE_SIZE;
1190
+ const pushed = /* @__PURE__ */ new Map();
1191
+ for (const source of args.sources) {
1192
+ let all;
1193
+ try {
1194
+ all = await fetchAllPages(source, pageSize);
1195
+ } catch (error) {
1196
+ const message = error instanceof Error ? error.message : String(error);
1197
+ pushed.set(source.entity, {
1198
+ count: 0,
1199
+ ok: false,
1200
+ reason: `the projection failed to execute: ${message}`.slice(0, MAX_REASON)
1201
+ });
1202
+ continue;
1203
+ }
1204
+ try {
1205
+ const ok = await args.push(SYNC_CUSTOM_PATH$1, {
1206
+ source: source.entity,
1207
+ rows: all,
1208
+ mode: "full",
1209
+ fetched: all.length,
1210
+ cursor: null,
1211
+ objectType: source.objectType
1212
+ });
1213
+ pushed.set(source.entity, {
1214
+ count: all.length,
1215
+ ok
1216
+ });
1217
+ } catch {
1218
+ pushed.set(source.entity, {
1219
+ count: 0,
1220
+ ok: false
1221
+ });
1222
+ }
1223
+ }
1224
+ return pushed;
1225
+ }
1226
+ //#endregion
983
1227
  //#region ../sdk/src/self-check.ts
984
1228
  function selfCheck(input) {
985
1229
  const issues = [];
@@ -1328,12 +1572,25 @@ function resolveContactEntity(config) {
1328
1572
  }
1329
1573
  function collectSchemaMeta(config) {
1330
1574
  const byTable = /* @__PURE__ */ new Map();
1331
- for (const table of config.schema ?? []) if (table.columnsMeta) byTable.set(table.name, table.columnsMeta);
1575
+ const merge = (name, meta) => {
1576
+ const existing = byTable.get(name);
1577
+ byTable.set(name, {
1578
+ columnsMeta: meta.columnsMeta ?? existing?.columnsMeta,
1579
+ uniqueConstraints: meta.uniqueConstraints ?? existing?.uniqueConstraints
1580
+ });
1581
+ };
1582
+ for (const table of config.schema ?? []) if (table.columnsMeta || table.uniqueConstraints) merge(table.name, {
1583
+ columnsMeta: table.columnsMeta,
1584
+ uniqueConstraints: table.uniqueConstraints
1585
+ });
1332
1586
  const sources = [];
1333
1587
  if (config.source) sources.push(config.source.contacts, config.source.companies, config.source.deals, ...config.source.custom ?? []);
1334
1588
  for (const source of sources) {
1335
1589
  const meta = source?.[require_drizzle_meta.DRIZZLE_META];
1336
- if (meta?.tableName && meta.columnsMeta) byTable.set(meta.tableName, meta.columnsMeta);
1590
+ if (meta?.tableName && (meta.columnsMeta || meta.uniqueConstraints)) merge(meta.tableName, {
1591
+ columnsMeta: meta.columnsMeta,
1592
+ uniqueConstraints: meta.uniqueConstraints
1593
+ });
1337
1594
  }
1338
1595
  return byTable;
1339
1596
  }
@@ -1348,12 +1605,13 @@ function buildSchema(config) {
1348
1605
  if (config.contactEntity && !byName.has(config.contactEntity)) byName.set(config.contactEntity, new Set(config.contactFields ?? []));
1349
1606
  const metaByTable = collectSchemaMeta(config);
1350
1607
  return [...byName].map(([name, columns]) => {
1351
- const columnsMeta = metaByTable.get(name);
1352
- if (columnsMeta) for (const col of columnsMeta) columns.add(col.name);
1608
+ const meta = metaByTable.get(name);
1609
+ for (const col of meta?.columnsMeta ?? []) columns.add(col.name);
1353
1610
  return {
1354
1611
  name,
1355
1612
  columns: [...columns],
1356
- ...columnsMeta ? { columnsMeta } : {}
1613
+ ...meta?.columnsMeta ? { columnsMeta: meta.columnsMeta } : {},
1614
+ ...meta?.uniqueConstraints ? { uniqueConstraints: meta.uniqueConstraints } : {}
1357
1615
  };
1358
1616
  });
1359
1617
  }
@@ -1533,6 +1791,15 @@ async function handlePreviewRequest(config, preview) {
1533
1791
  sampledCount: rows.length
1534
1792
  }, 200);
1535
1793
  }
1794
+ async function handleProjectionPreviewRequest(config, request) {
1795
+ const run = config.introspector?.runProjection;
1796
+ 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);
1797
+ const rows = await run.call(config.introspector, request.projection, { limit: request.limit });
1798
+ return jsonResponse({
1799
+ rows,
1800
+ sampledCount: rows.length
1801
+ }, 200);
1802
+ }
1536
1803
  /**
1537
1804
  * Create the Clivly SDK. The implemented surface — heartbeat/discovery reporting
1538
1805
  * and the auth-verify handler — connects a developer's app to a running Clivly
@@ -1560,9 +1827,15 @@ function createClivlySDK(config) {
1560
1827
  const cursorStore = config.cursorStore ?? createMemoryCursorStore();
1561
1828
  const fetcher = createFetcher(config.retry);
1562
1829
  let warnedUnsignedTrigger = false;
1830
+ const remoteProjections = createRemoteProjectionFetcher({
1831
+ apiKey: config.apiKey,
1832
+ apiUrl,
1833
+ fetcher
1834
+ });
1563
1835
  let observedUrl;
1564
1836
  let lastPushFailure = null;
1565
1837
  let lastPush = { failed: false };
1838
+ let lastRemoteOutcomes = [];
1566
1839
  const warnedSilentTargets = /* @__PURE__ */ new Set();
1567
1840
  const warnIfSilent = async (target, response) => {
1568
1841
  if (typeof response.json !== "function") return;
@@ -1634,18 +1907,58 @@ function createClivlySDK(config) {
1634
1907
  for (const customSource of customSources ?? []) if (!await syncOne(customSource, SYNC_CUSTOM_PATH, mode, customSource.objectType, runId)) failed.push(customSource.entity);
1635
1908
  return failed;
1636
1909
  };
1910
+ const syncRemoteSources = async (sources, runId) => {
1911
+ const pushed = await pushRemoteProjections({
1912
+ push: createPush(runId),
1913
+ sources
1914
+ });
1915
+ const failed = [];
1916
+ for (const [entity, result] of pushed) if (!result.ok) failed.push(entity);
1917
+ return {
1918
+ failed,
1919
+ pushed
1920
+ };
1921
+ };
1922
+ const reportRemoteOutcomes = async (outcomes, pushed, runId) => {
1923
+ if (outcomes.length === 0) return;
1924
+ try {
1925
+ const report = buildRemoteProjectionReport({
1926
+ outcomes,
1927
+ pushed,
1928
+ runId
1929
+ });
1930
+ await fetcher(`${apiUrl}${PROJECTIONS_REPORT_PATH}`, {
1931
+ method: "POST",
1932
+ headers: {
1933
+ authorization: `Bearer ${config.apiKey}`,
1934
+ "content-type": "application/json"
1935
+ },
1936
+ body: JSON.stringify(report)
1937
+ });
1938
+ } catch {}
1939
+ };
1637
1940
  const runSync = async (options) => {
1638
- if (!config.source) return;
1941
+ const remoteOutcomes = resolveRemoteProjections({
1942
+ entries: await remoteProjections.fetch(),
1943
+ introspector: config.introspector,
1944
+ schema: buildSchema(config)
1945
+ });
1946
+ lastRemoteOutcomes = remoteOutcomes;
1947
+ const remoteSources = remoteOutcomes.filter((o) => o.status === "ok").map((o) => o.source);
1948
+ if (!config.source && remoteSources.length === 0 && remoteOutcomes.length === 0) return;
1639
1949
  lastPushFailure = null;
1640
1950
  const mode = options?.mode ?? "delta";
1641
1951
  const runId = options?.runId;
1642
1952
  const failed = [];
1643
- if (config.source.companies) {
1953
+ if (config.source?.companies) {
1644
1954
  if (!await syncOne(config.source.companies, SYNC_COMPANIES_PATH, mode, void 0, runId)) failed.push(config.source.companies.entity);
1645
1955
  }
1646
- if (!await syncOne(config.source.contacts, SYNC_CONTACTS_PATH, mode, void 0, runId)) failed.push(config.source.contacts.entity);
1647
- if (config.source.deals && !await syncOne(config.source.deals, SYNC_DEALS_PATH, mode, void 0, runId)) failed.push(config.source.deals.entity);
1648
- failed.push(...await syncCustomSources(config.source.custom, mode, runId));
1956
+ if (config.source?.contacts && !await syncOne(config.source.contacts, SYNC_CONTACTS_PATH, mode, void 0, runId)) failed.push(config.source.contacts.entity);
1957
+ if (config.source?.deals && !await syncOne(config.source.deals, SYNC_DEALS_PATH, mode, void 0, runId)) failed.push(config.source.deals.entity);
1958
+ failed.push(...await syncCustomSources(config.source?.custom, mode, runId));
1959
+ const remote = await syncRemoteSources(remoteSources, runId);
1960
+ failed.push(...remote.failed);
1961
+ await reportRemoteOutcomes(lastRemoteOutcomes, remote.pushed, runId);
1649
1962
  if (failed.length > 0) {
1650
1963
  const message = describePushFailure(failed);
1651
1964
  lastPush = {
@@ -1662,13 +1975,31 @@ function createClivlySDK(config) {
1662
1975
  lastPushFailure = null;
1663
1976
  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]));
1664
1977
  };
1665
- const sendHeartbeat = () => {
1666
- const triggerUrl = resolveTriggerUrl({
1978
+ const describeSyncTrigger = () => ({
1979
+ configured: config.syncTrigger?.url,
1980
+ resolved: resolveTriggerUrl({
1667
1981
  configuredUrl: config.syncTrigger?.url,
1668
1982
  configuredPath: config.syncTrigger?.path,
1669
1983
  env: config.env ?? process.env,
1670
1984
  observedUrl
1671
- });
1985
+ })
1986
+ });
1987
+ const describeRemoteProjections = () => {
1988
+ const counts = {
1989
+ failed: 0,
1990
+ invalid: 0,
1991
+ ok: 0,
1992
+ skipped: 0
1993
+ };
1994
+ for (const outcome of lastRemoteOutcomes) counts[outcome.status] += 1;
1995
+ return {
1996
+ capable: Boolean(config.introspector?.projectionSource),
1997
+ ran: lastRemoteOutcomes.length > 0,
1998
+ ...counts
1999
+ };
2000
+ };
2001
+ const sendHeartbeat = () => {
2002
+ const triggerUrl = describeSyncTrigger().resolved;
1672
2003
  return fetcher(`${apiUrl}/clivly/sdk/heartbeat`, {
1673
2004
  method: "POST",
1674
2005
  headers: {
@@ -1865,7 +2196,7 @@ function createClivlySDK(config) {
1865
2196
  });
1866
2197
  const runScheduled = async (options) => {
1867
2198
  await heartbeat();
1868
- if (config.source) await runSync(options);
2199
+ if (config.source || config.introspector) await runSync(options);
1869
2200
  };
1870
2201
  const triggerRequestAllowed = async (req, rawBody) => {
1871
2202
  const secret = config.syncTrigger?.secret;
@@ -1896,6 +2227,7 @@ function createClivlySDK(config) {
1896
2227
  const startedAt = Date.now();
1897
2228
  try {
1898
2229
  if (payload.preview) return await handlePreviewRequest(config, payload.preview);
2230
+ if (payload.previewProjection) return await handleProjectionPreviewRequest(config, payload.previewProjection);
1899
2231
  if (payload.sample) await runSampleSync(payload.sample.entityKey, payload.runId);
1900
2232
  else await runScheduled({
1901
2233
  mode: payload.mode,
@@ -1958,7 +2290,7 @@ function createClivlySDK(config) {
1958
2290
  presenceClient = null;
1959
2291
  console.warn("Clivly presence socket unavailable; using HTTP heartbeat", error);
1960
2292
  }
1961
- if (!timers.sync && config.source) {
2293
+ if (!timers.sync && (config.source || config.introspector)) {
1962
2294
  timers.sync = setInterval(() => {
1963
2295
  runSync().catch(() => {});
1964
2296
  }, syncIntervalMs);
@@ -1991,6 +2323,8 @@ function createClivlySDK(config) {
1991
2323
  connect,
1992
2324
  ping,
1993
2325
  doctor,
2326
+ describeSyncTrigger,
2327
+ describeRemoteProjections,
1994
2328
  runScheduled,
1995
2329
  createClivlyHandler,
1996
2330
  sync: runSync,