clivly 0.10.7 → 0.10.9

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.
@@ -77,6 +77,43 @@ function resolveEntitySource(config, entityKey) {
77
77
  function expectedSourceKey(concept) {
78
78
  return CONCEPT_KEYS[concept] ?? "custom";
79
79
  }
80
+ /**
81
+ * The `source` label a push must carry for the server to find its mapping.
82
+ *
83
+ * `entity` is the SDK's own name for a source — the cursor-store key, and what
84
+ * the developer wrote in `config.source`. It is NOT always what the server
85
+ * filed the mapping under: for a projection, `ensureSchema` stores
86
+ * `sourceTable` as `projection.baseTable`, because a projection entity's key
87
+ * doubles as a kebab-case object slug and cannot also be the snake_case
88
+ * `projection.entityKey`.
89
+ *
90
+ * Pushing under `entity` therefore dropped 100% of a projection's rows while
91
+ * every local signal said the setup was correct. One function so the three
92
+ * push sites cannot drift apart again.
93
+ */
94
+ function pushSourceOf(source) {
95
+ return source.pushSource ?? source.entity;
96
+ }
97
+ /**
98
+ * The push label for a custom source, resolved against the config.
99
+ *
100
+ * `fromDrizzleProjection` stamps `pushSource` itself, but a projection source
101
+ * can equally be hand-built — `fromDrizzle` with an overridden `fetchPage` is
102
+ * what the README's own example leads you to, and next-finance did exactly
103
+ * that. Such a source has no `pushSource`, so the label has to come from the
104
+ * one place that is true either way: the entity's declared
105
+ * `projection.baseTable`.
106
+ *
107
+ * Matched on the source's `entity`, which for a custom source is also the
108
+ * config entity key (that is what `resolveEntitySource` above pairs them by).
109
+ * Falls back to `pushSourceOf`, so a non-projection custom source and an
110
+ * already-stamped one both keep their current label.
111
+ */
112
+ function customPushSource(config, source) {
113
+ if (source.pushSource) return source.pushSource;
114
+ const entities = config.entities?.entities;
115
+ return (entities?.[source.entity] ?? Object.values(entities ?? {}).find((e) => e.source === source.entity))?.projection?.baseTable ?? source.entity;
116
+ }
80
117
  //#endregion
81
118
  //#region ../sdk/src/concept-coverage.ts
82
119
  const CHECK_NAME$1 = "Entity → source coverage";
@@ -355,11 +392,15 @@ function errorMessage(error) {
355
392
  /**
356
393
  * The entities to ask about, in the exact terms the sync pushes them.
357
394
  *
358
- * `source` is the resolved `SyncSource.entity` — the value that arrives at
395
+ * `source` is whatever the push will actually send — the value that arrives at
359
396
  * `ingestPush` as `input.source` and is compared against `mapping.sourceTable`.
397
+ * Usually that is `SyncSource.entity`, but a projection pushes under its base
398
+ * table, so this asks `customPushSource` rather than reading `entity` directly.
399
+ *
360
400
  * It is NOT the entity config's own `source` field, which names a host table;
361
- * the two are routinely conflated, and using the wrong one would report every
362
- * correctly mapped workspace as unmapped.
401
+ * the two are routinely conflated, and using the wrong one reports a correctly
402
+ * mapped workspace as unmapped — which is exactly what happened to every
403
+ * projection until this used the push label.
363
404
  */
364
405
  function readinessEntitiesFromConfig(config) {
365
406
  const entities = [];
@@ -368,7 +409,7 @@ function readinessEntitiesFromConfig(config) {
368
409
  if (!resolved) continue;
369
410
  const objectTypeSlug = resolved.source.objectType;
370
411
  entities.push({
371
- source: resolved.source.entity,
412
+ source: customPushSource(config, resolved.source),
372
413
  concept: resolved.concept,
373
414
  ...resolved.concept === "custom" && objectTypeSlug ? { objectTypeSlug } : {}
374
415
  });
@@ -1203,7 +1244,7 @@ async function pushRemoteProjections(args) {
1203
1244
  }
1204
1245
  try {
1205
1246
  const ok = await args.push(SYNC_CUSTOM_PATH$1, {
1206
- source: source.entity,
1247
+ source: pushSourceOf(source),
1207
1248
  rows: all,
1208
1249
  mode: "full",
1209
1250
  fetched: all.length,
@@ -1278,7 +1319,7 @@ async function syncSource(args) {
1278
1319
  if (rows.length === 0) break;
1279
1320
  const capped = maxRows === void 0 ? rows : rows.slice(0, maxRows);
1280
1321
  if (!await push(path, {
1281
- source: source.entity,
1322
+ source: pushSourceOf(source),
1282
1323
  rows: capped,
1283
1324
  mode: "delta",
1284
1325
  fetched: rows.length,
@@ -1322,7 +1363,7 @@ async function fullSyncSource(args) {
1322
1363
  ok: true
1323
1364
  };
1324
1365
  const ok = await push(path, {
1325
- source: source.entity,
1366
+ source: pushSourceOf(source),
1326
1367
  rows: all,
1327
1368
  mode: "full",
1328
1369
  fetched: all.length,
@@ -1431,8 +1472,18 @@ function jsonResponse$1(body, status) {
1431
1472
  function normalizeApiUrl(apiUrl) {
1432
1473
  return apiUrl.replace(TRAILING_SLASH$1, "");
1433
1474
  }
1434
- function resolveOrigin(req) {
1435
- const headerOrigin = req.headers.get("origin");
1475
+ /**
1476
+ * The origin *claimed* by the request, for reporting upstream.
1477
+ *
1478
+ * Falls back to the request URL so Clivly still receives an origin for a
1479
+ * non-browser caller. This value is reported, never trusted: only a real
1480
+ * `Origin` header is evidence of where a request came from, so the access
1481
+ * decision uses {@link trustedOrigin} instead. Keeping the two apart is the
1482
+ * point — conflating them let a caller that simply omitted the header be
1483
+ * judged against the server's own origin, which is usually in the allowlist.
1484
+ */
1485
+ function reportedOrigin(req) {
1486
+ const headerOrigin = trustedOrigin(req);
1436
1487
  if (headerOrigin) return headerOrigin;
1437
1488
  try {
1438
1489
  return new URL(req.url).origin;
@@ -1440,6 +1491,15 @@ function resolveOrigin(req) {
1440
1491
  return null;
1441
1492
  }
1442
1493
  }
1494
+ /**
1495
+ * The only origin worth an access decision: one the browser set itself.
1496
+ *
1497
+ * A missing header means "not a browser request we can vouch for", which is
1498
+ * untrusted — never the server's own origin.
1499
+ */
1500
+ function trustedOrigin(req) {
1501
+ return req.headers.get("origin");
1502
+ }
1443
1503
  function isChatSessionRequestBody(value) {
1444
1504
  if (typeof value !== "object" || value === null) return false;
1445
1505
  const body = value;
@@ -1450,6 +1510,17 @@ function isChatSessionRequestBody(value) {
1450
1510
  if (typeof visitor.email !== "string" || visitor.email.trim().length === 0) return false;
1451
1511
  return body.visitorToken === void 0 || body.visitorToken === null || typeof body.visitorToken === "string";
1452
1512
  }
1513
+ /**
1514
+ * Judge a request against the configured allowlist.
1515
+ *
1516
+ * `origin` must be a *trusted* origin (see {@link trustedOrigin}) — passing
1517
+ * the reported one would reinstate the bypass this guards against.
1518
+ *
1519
+ * An unset allowlist still admits every origin, because requiring it would
1520
+ * break every existing deployment on upgrade. The unset case is instead
1521
+ * warned about once at construction, so the permissive default is loud
1522
+ * rather than silent.
1523
+ */
1453
1524
  function originAllowed(origin, allowedOrigins) {
1454
1525
  if (!allowedOrigins || allowedOrigins.length === 0) return true;
1455
1526
  if (!origin) return false;
@@ -1457,6 +1528,7 @@ function originAllowed(origin, allowedOrigins) {
1457
1528
  }
1458
1529
  function createChatSessionHandler({ allowedOrigins, apiKey, apiUrl = DEFAULT_API_URL$1, fetchImpl = fetch, resolveUser }) {
1459
1530
  const sessionUrl = `${normalizeApiUrl(apiUrl)}/clivly/widget/session`;
1531
+ if (!allowedOrigins || allowedOrigins.length === 0) console.warn("[clivly] createChatSessionHandler has no allowedOrigins; every origin can open a chat session against this workspace. Pass allowedOrigins to restrict it.");
1460
1532
  return async (request) => {
1461
1533
  if (request.method !== "POST") return new Response(JSON.stringify({ error: "method_not_allowed" }), {
1462
1534
  status: 405,
@@ -1465,8 +1537,8 @@ function createChatSessionHandler({ allowedOrigins, apiKey, apiUrl = DEFAULT_API
1465
1537
  allow: "POST"
1466
1538
  }
1467
1539
  });
1468
- const origin = resolveOrigin(request);
1469
- if (!originAllowed(origin, allowedOrigins)) return jsonResponse$1({ error: "origin_not_allowed" }, 403);
1540
+ if (!originAllowed(trustedOrigin(request), allowedOrigins)) return jsonResponse$1({ error: "origin_not_allowed" }, 403);
1541
+ const origin = reportedOrigin(request);
1470
1542
  let body;
1471
1543
  try {
1472
1544
  body = await request.json();
@@ -1664,6 +1736,18 @@ var TriggerRequestError = class extends Error {
1664
1736
  function triggerErrorStatus(error) {
1665
1737
  return error instanceof TriggerRequestError ? error.status : 500;
1666
1738
  }
1739
+ const SENTENCE_END_RE = /[.!?]$/;
1740
+ async function readPushFailureMessage(response) {
1741
+ if (!response || typeof response.json !== "function") return null;
1742
+ try {
1743
+ const body = await response.json();
1744
+ if (typeof body !== "object" || body === null) return null;
1745
+ const { message } = body;
1746
+ return typeof message === "string" && message.trim().length > 0 ? message : null;
1747
+ } catch {
1748
+ return null;
1749
+ }
1750
+ }
1667
1751
  const SIGNATURE_TOLERANCE_SECONDS = 300;
1668
1752
  const SIGNATURE_HEADER_RE = /^t=(\d+),v1=([0-9a-f]+)$/;
1669
1753
  const SECONDS_PER_MS = 1e3;
@@ -1886,17 +1970,27 @@ function createClivlySDK(config) {
1886
1970
  }, response);
1887
1971
  return true;
1888
1972
  }
1889
- lastPushFailure ??= { status: response?.status ?? null };
1973
+ lastPushFailure ??= {
1974
+ status: response?.status ?? null,
1975
+ message: await readPushFailureMessage(response)
1976
+ };
1890
1977
  return false;
1891
1978
  };
1892
1979
  const describePushFailure = (entities) => {
1893
1980
  const base = `Clivly sync: push failed for ${entities.join(", ")}`;
1894
1981
  const status = lastPushFailure?.status ?? null;
1982
+ const serverMessage = lastPushFailure?.message ?? null;
1895
1983
  let cause;
1896
1984
  if (status === 401 || status === 403) cause = "the API key was rejected — check CLIVLY_API_KEY is set to a valid key for this org";
1897
1985
  else if (status === null) cause = `couldn't reach Clivly at ${apiUrl} — check network access and CLIVLY_API_URL`;
1986
+ else if (serverMessage) cause = `Clivly returned HTTP ${status}: ${serverMessage}`;
1898
1987
  else cause = `Clivly returned HTTP ${status}`;
1899
- return `${base}: ${cause}. Run \`clivly doctor\` to diagnose.`;
1988
+ const separator = SENTENCE_END_RE.test(cause) ? "" : ".";
1989
+ return `${base}: ${cause}${separator} Run \`clivly doctor\` to diagnose.`;
1990
+ };
1991
+ const pushFailureError = (message) => {
1992
+ const status = lastPushFailure?.status ?? null;
1993
+ return status !== null && status >= CLIENT_ERROR_FLOOR && status < RETRYABLE_STATUS_FLOOR ? new TriggerRequestError(status, message) : new Error(message);
1900
1994
  };
1901
1995
  const syncOne = async (source, path, mode, objectType, runId, batchSizeOverride, maxRowsOverride, persistCursor = true) => {
1902
1996
  const since = mode === "full" ? null : await cursorStore.get(source.entity) ?? null;
@@ -1922,7 +2016,10 @@ function createClivlySDK(config) {
1922
2016
  };
1923
2017
  const syncCustomSources = async (customSources, mode, runId) => {
1924
2018
  const failed = [];
1925
- for (const customSource of customSources ?? []) if (!await syncOne(customSource, SYNC_CUSTOM_PATH, mode, customSource.objectType, runId)) failed.push(customSource.entity);
2019
+ for (const customSource of customSources ?? []) {
2020
+ customSource.pushSource = customPushSource(config, customSource);
2021
+ if (!await syncOne(customSource, SYNC_CUSTOM_PATH, mode, customSource.objectType, runId)) failed.push(customSource.entity);
2022
+ }
1926
2023
  return failed;
1927
2024
  };
1928
2025
  const syncRemoteSources = async (sources, runId) => {
@@ -1983,7 +2080,7 @@ function createClivlySDK(config) {
1983
2080
  failed: true,
1984
2081
  error: message
1985
2082
  };
1986
- throw new Error(message);
2083
+ throw pushFailureError(message);
1987
2084
  }
1988
2085
  lastPush = { failed: false };
1989
2086
  };
@@ -1991,7 +2088,7 @@ function createClivlySDK(config) {
1991
2088
  const resolved = resolveSampleSource(config, entityKey);
1992
2089
  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.`);
1993
2090
  lastPushFailure = null;
1994
- 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]));
2091
+ if (!await syncOne(resolved.source, resolved.path, "delta", resolved.objectType, runId, SAMPLE_BATCH_SIZE, SAMPLE_BATCH_SIZE, false)) throw pushFailureError(describePushFailure([resolved.source.entity]));
1995
2092
  };
1996
2093
  const describeSyncTrigger = () => ({
1997
2094
  configured: config.syncTrigger?.url,
@@ -77,6 +77,43 @@ function resolveEntitySource(config, entityKey) {
77
77
  function expectedSourceKey(concept) {
78
78
  return CONCEPT_KEYS[concept] ?? "custom";
79
79
  }
80
+ /**
81
+ * The `source` label a push must carry for the server to find its mapping.
82
+ *
83
+ * `entity` is the SDK's own name for a source — the cursor-store key, and what
84
+ * the developer wrote in `config.source`. It is NOT always what the server
85
+ * filed the mapping under: for a projection, `ensureSchema` stores
86
+ * `sourceTable` as `projection.baseTable`, because a projection entity's key
87
+ * doubles as a kebab-case object slug and cannot also be the snake_case
88
+ * `projection.entityKey`.
89
+ *
90
+ * Pushing under `entity` therefore dropped 100% of a projection's rows while
91
+ * every local signal said the setup was correct. One function so the three
92
+ * push sites cannot drift apart again.
93
+ */
94
+ function pushSourceOf(source) {
95
+ return source.pushSource ?? source.entity;
96
+ }
97
+ /**
98
+ * The push label for a custom source, resolved against the config.
99
+ *
100
+ * `fromDrizzleProjection` stamps `pushSource` itself, but a projection source
101
+ * can equally be hand-built — `fromDrizzle` with an overridden `fetchPage` is
102
+ * what the README's own example leads you to, and next-finance did exactly
103
+ * that. Such a source has no `pushSource`, so the label has to come from the
104
+ * one place that is true either way: the entity's declared
105
+ * `projection.baseTable`.
106
+ *
107
+ * Matched on the source's `entity`, which for a custom source is also the
108
+ * config entity key (that is what `resolveEntitySource` above pairs them by).
109
+ * Falls back to `pushSourceOf`, so a non-projection custom source and an
110
+ * already-stamped one both keep their current label.
111
+ */
112
+ function customPushSource(config, source) {
113
+ if (source.pushSource) return source.pushSource;
114
+ const entities = config.entities?.entities;
115
+ return (entities?.[source.entity] ?? Object.values(entities ?? {}).find((e) => e.source === source.entity))?.projection?.baseTable ?? source.entity;
116
+ }
80
117
  //#endregion
81
118
  //#region ../sdk/src/concept-coverage.ts
82
119
  const CHECK_NAME$1 = "Entity → source coverage";
@@ -355,11 +392,15 @@ function errorMessage(error) {
355
392
  /**
356
393
  * The entities to ask about, in the exact terms the sync pushes them.
357
394
  *
358
- * `source` is the resolved `SyncSource.entity` — the value that arrives at
395
+ * `source` is whatever the push will actually send — the value that arrives at
359
396
  * `ingestPush` as `input.source` and is compared against `mapping.sourceTable`.
397
+ * Usually that is `SyncSource.entity`, but a projection pushes under its base
398
+ * table, so this asks `customPushSource` rather than reading `entity` directly.
399
+ *
360
400
  * It is NOT the entity config's own `source` field, which names a host table;
361
- * the two are routinely conflated, and using the wrong one would report every
362
- * correctly mapped workspace as unmapped.
401
+ * the two are routinely conflated, and using the wrong one reports a correctly
402
+ * mapped workspace as unmapped — which is exactly what happened to every
403
+ * projection until this used the push label.
363
404
  */
364
405
  function readinessEntitiesFromConfig(config) {
365
406
  const entities = [];
@@ -368,7 +409,7 @@ function readinessEntitiesFromConfig(config) {
368
409
  if (!resolved) continue;
369
410
  const objectTypeSlug = resolved.source.objectType;
370
411
  entities.push({
371
- source: resolved.source.entity,
412
+ source: customPushSource(config, resolved.source),
372
413
  concept: resolved.concept,
373
414
  ...resolved.concept === "custom" && objectTypeSlug ? { objectTypeSlug } : {}
374
415
  });
@@ -1203,7 +1244,7 @@ async function pushRemoteProjections(args) {
1203
1244
  }
1204
1245
  try {
1205
1246
  const ok = await args.push(SYNC_CUSTOM_PATH$1, {
1206
- source: source.entity,
1247
+ source: pushSourceOf(source),
1207
1248
  rows: all,
1208
1249
  mode: "full",
1209
1250
  fetched: all.length,
@@ -1278,7 +1319,7 @@ async function syncSource(args) {
1278
1319
  if (rows.length === 0) break;
1279
1320
  const capped = maxRows === void 0 ? rows : rows.slice(0, maxRows);
1280
1321
  if (!await push(path, {
1281
- source: source.entity,
1322
+ source: pushSourceOf(source),
1282
1323
  rows: capped,
1283
1324
  mode: "delta",
1284
1325
  fetched: rows.length,
@@ -1322,7 +1363,7 @@ async function fullSyncSource(args) {
1322
1363
  ok: true
1323
1364
  };
1324
1365
  const ok = await push(path, {
1325
- source: source.entity,
1366
+ source: pushSourceOf(source),
1326
1367
  rows: all,
1327
1368
  mode: "full",
1328
1369
  fetched: all.length,
@@ -1431,8 +1472,18 @@ function jsonResponse$1(body, status) {
1431
1472
  function normalizeApiUrl(apiUrl) {
1432
1473
  return apiUrl.replace(TRAILING_SLASH$1, "");
1433
1474
  }
1434
- function resolveOrigin(req) {
1435
- const headerOrigin = req.headers.get("origin");
1475
+ /**
1476
+ * The origin *claimed* by the request, for reporting upstream.
1477
+ *
1478
+ * Falls back to the request URL so Clivly still receives an origin for a
1479
+ * non-browser caller. This value is reported, never trusted: only a real
1480
+ * `Origin` header is evidence of where a request came from, so the access
1481
+ * decision uses {@link trustedOrigin} instead. Keeping the two apart is the
1482
+ * point — conflating them let a caller that simply omitted the header be
1483
+ * judged against the server's own origin, which is usually in the allowlist.
1484
+ */
1485
+ function reportedOrigin(req) {
1486
+ const headerOrigin = trustedOrigin(req);
1436
1487
  if (headerOrigin) return headerOrigin;
1437
1488
  try {
1438
1489
  return new URL(req.url).origin;
@@ -1440,6 +1491,15 @@ function resolveOrigin(req) {
1440
1491
  return null;
1441
1492
  }
1442
1493
  }
1494
+ /**
1495
+ * The only origin worth an access decision: one the browser set itself.
1496
+ *
1497
+ * A missing header means "not a browser request we can vouch for", which is
1498
+ * untrusted — never the server's own origin.
1499
+ */
1500
+ function trustedOrigin(req) {
1501
+ return req.headers.get("origin");
1502
+ }
1443
1503
  function isChatSessionRequestBody(value) {
1444
1504
  if (typeof value !== "object" || value === null) return false;
1445
1505
  const body = value;
@@ -1450,6 +1510,17 @@ function isChatSessionRequestBody(value) {
1450
1510
  if (typeof visitor.email !== "string" || visitor.email.trim().length === 0) return false;
1451
1511
  return body.visitorToken === void 0 || body.visitorToken === null || typeof body.visitorToken === "string";
1452
1512
  }
1513
+ /**
1514
+ * Judge a request against the configured allowlist.
1515
+ *
1516
+ * `origin` must be a *trusted* origin (see {@link trustedOrigin}) — passing
1517
+ * the reported one would reinstate the bypass this guards against.
1518
+ *
1519
+ * An unset allowlist still admits every origin, because requiring it would
1520
+ * break every existing deployment on upgrade. The unset case is instead
1521
+ * warned about once at construction, so the permissive default is loud
1522
+ * rather than silent.
1523
+ */
1453
1524
  function originAllowed(origin, allowedOrigins) {
1454
1525
  if (!allowedOrigins || allowedOrigins.length === 0) return true;
1455
1526
  if (!origin) return false;
@@ -1457,6 +1528,7 @@ function originAllowed(origin, allowedOrigins) {
1457
1528
  }
1458
1529
  function createChatSessionHandler({ allowedOrigins, apiKey, apiUrl = DEFAULT_API_URL$1, fetchImpl = fetch, resolveUser }) {
1459
1530
  const sessionUrl = `${normalizeApiUrl(apiUrl)}/clivly/widget/session`;
1531
+ if (!allowedOrigins || allowedOrigins.length === 0) console.warn("[clivly] createChatSessionHandler has no allowedOrigins; every origin can open a chat session against this workspace. Pass allowedOrigins to restrict it.");
1460
1532
  return async (request) => {
1461
1533
  if (request.method !== "POST") return new Response(JSON.stringify({ error: "method_not_allowed" }), {
1462
1534
  status: 405,
@@ -1465,8 +1537,8 @@ function createChatSessionHandler({ allowedOrigins, apiKey, apiUrl = DEFAULT_API
1465
1537
  allow: "POST"
1466
1538
  }
1467
1539
  });
1468
- const origin = resolveOrigin(request);
1469
- if (!originAllowed(origin, allowedOrigins)) return jsonResponse$1({ error: "origin_not_allowed" }, 403);
1540
+ if (!originAllowed(trustedOrigin(request), allowedOrigins)) return jsonResponse$1({ error: "origin_not_allowed" }, 403);
1541
+ const origin = reportedOrigin(request);
1470
1542
  let body;
1471
1543
  try {
1472
1544
  body = await request.json();
@@ -1664,6 +1736,18 @@ var TriggerRequestError = class extends Error {
1664
1736
  function triggerErrorStatus(error) {
1665
1737
  return error instanceof TriggerRequestError ? error.status : 500;
1666
1738
  }
1739
+ const SENTENCE_END_RE = /[.!?]$/;
1740
+ async function readPushFailureMessage(response) {
1741
+ if (!response || typeof response.json !== "function") return null;
1742
+ try {
1743
+ const body = await response.json();
1744
+ if (typeof body !== "object" || body === null) return null;
1745
+ const { message } = body;
1746
+ return typeof message === "string" && message.trim().length > 0 ? message : null;
1747
+ } catch {
1748
+ return null;
1749
+ }
1750
+ }
1667
1751
  const SIGNATURE_TOLERANCE_SECONDS = 300;
1668
1752
  const SIGNATURE_HEADER_RE = /^t=(\d+),v1=([0-9a-f]+)$/;
1669
1753
  const SECONDS_PER_MS = 1e3;
@@ -1886,17 +1970,27 @@ function createClivlySDK(config) {
1886
1970
  }, response);
1887
1971
  return true;
1888
1972
  }
1889
- lastPushFailure ??= { status: response?.status ?? null };
1973
+ lastPushFailure ??= {
1974
+ status: response?.status ?? null,
1975
+ message: await readPushFailureMessage(response)
1976
+ };
1890
1977
  return false;
1891
1978
  };
1892
1979
  const describePushFailure = (entities) => {
1893
1980
  const base = `Clivly sync: push failed for ${entities.join(", ")}`;
1894
1981
  const status = lastPushFailure?.status ?? null;
1982
+ const serverMessage = lastPushFailure?.message ?? null;
1895
1983
  let cause;
1896
1984
  if (status === 401 || status === 403) cause = "the API key was rejected — check CLIVLY_API_KEY is set to a valid key for this org";
1897
1985
  else if (status === null) cause = `couldn't reach Clivly at ${apiUrl} — check network access and CLIVLY_API_URL`;
1986
+ else if (serverMessage) cause = `Clivly returned HTTP ${status}: ${serverMessage}`;
1898
1987
  else cause = `Clivly returned HTTP ${status}`;
1899
- return `${base}: ${cause}. Run \`clivly doctor\` to diagnose.`;
1988
+ const separator = SENTENCE_END_RE.test(cause) ? "" : ".";
1989
+ return `${base}: ${cause}${separator} Run \`clivly doctor\` to diagnose.`;
1990
+ };
1991
+ const pushFailureError = (message) => {
1992
+ const status = lastPushFailure?.status ?? null;
1993
+ return status !== null && status >= CLIENT_ERROR_FLOOR && status < RETRYABLE_STATUS_FLOOR ? new TriggerRequestError(status, message) : new Error(message);
1900
1994
  };
1901
1995
  const syncOne = async (source, path, mode, objectType, runId, batchSizeOverride, maxRowsOverride, persistCursor = true) => {
1902
1996
  const since = mode === "full" ? null : await cursorStore.get(source.entity) ?? null;
@@ -1922,7 +2016,10 @@ function createClivlySDK(config) {
1922
2016
  };
1923
2017
  const syncCustomSources = async (customSources, mode, runId) => {
1924
2018
  const failed = [];
1925
- for (const customSource of customSources ?? []) if (!await syncOne(customSource, SYNC_CUSTOM_PATH, mode, customSource.objectType, runId)) failed.push(customSource.entity);
2019
+ for (const customSource of customSources ?? []) {
2020
+ customSource.pushSource = customPushSource(config, customSource);
2021
+ if (!await syncOne(customSource, SYNC_CUSTOM_PATH, mode, customSource.objectType, runId)) failed.push(customSource.entity);
2022
+ }
1926
2023
  return failed;
1927
2024
  };
1928
2025
  const syncRemoteSources = async (sources, runId) => {
@@ -1983,7 +2080,7 @@ function createClivlySDK(config) {
1983
2080
  failed: true,
1984
2081
  error: message
1985
2082
  };
1986
- throw new Error(message);
2083
+ throw pushFailureError(message);
1987
2084
  }
1988
2085
  lastPush = { failed: false };
1989
2086
  };
@@ -1991,7 +2088,7 @@ function createClivlySDK(config) {
1991
2088
  const resolved = resolveSampleSource(config, entityKey);
1992
2089
  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.`);
1993
2090
  lastPushFailure = null;
1994
- 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]));
2091
+ if (!await syncOne(resolved.source, resolved.path, "delta", resolved.objectType, runId, SAMPLE_BATCH_SIZE, SAMPLE_BATCH_SIZE, false)) throw pushFailureError(describePushFailure([resolved.source.entity]));
1995
2092
  };
1996
2093
  const describeSyncTrigger = () => ({
1997
2094
  configured: config.syncTrigger?.url,
package/sdk.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_entity_config = require("./entity-config-C2MfIDL2.cjs");
3
- const require_sdk = require("./sdk-DbvqXNSh.cjs");
3
+ const require_sdk = require("./sdk-BIYu0N3V.cjs");
4
4
  exports.ClivlyAuthError = require_sdk.ClivlyAuthError;
5
5
  exports.ClivlyError = require_sdk.ClivlyError;
6
6
  exports.ClivlyNetworkError = require_sdk.ClivlyNetworkError;
package/sdk.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { C as customObjectDeclarations, M as validateEntitiesConfig, a as ClivlyEntitiesConfig, f as CustomObjectDeclaration, l as ConfigValidationError, m as DiscoveredSchemaTable, u as ConfigValidationResult, w as defineClivlyConfig } from "./entity-config-w8oMBWf8.cjs";
2
- import { _ as RemoteProjectionsDescription, a as ClivlyConnectResult, c as ClivlyUser, d as DiscoveredTable, f as DoctorCheck, g as EnsuredObjectTypeResult, h as EnsuredMappingResult, i as ClivlyConnectReason, l as CreateChatSessionHandlerOptions, m as EnsureSchemaResult, n as ChatSessionRequestBody, o as ClivlySDK, p as DoctorReport, r as ChatWidgetVisitor, s as ClivlySDKConfig, u as CursorStore, y as SyncTriggerDescription } from "./namespace-probe-lj_0bJrc.cjs";
2
+ import { _ as RemoteProjectionsDescription, a as ClivlyConnectResult, c as ClivlyUser, d as DiscoveredTable, f as DoctorCheck, g as EnsuredObjectTypeResult, h as EnsuredMappingResult, i as ClivlyConnectReason, l as CreateChatSessionHandlerOptions, m as EnsureSchemaResult, n as ChatSessionRequestBody, o as ClivlySDK, p as DoctorReport, r as ChatWidgetVisitor, s as ClivlySDKConfig, u as CursorStore, y as SyncTriggerDescription } from "./namespace-probe-C3xMOqWE.cjs";
3
3
  //#region ../sdk/src/trigger-url.d.ts
4
4
  declare function isPlaceholderUrl(value: string): boolean;
5
5
  //#endregion
package/sdk.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { C as customObjectDeclarations, M as validateEntitiesConfig, a as ClivlyEntitiesConfig, f as CustomObjectDeclaration, l as ConfigValidationError, m as DiscoveredSchemaTable, u as ConfigValidationResult, w as defineClivlyConfig } from "./entity-config-w8oMBWf8.mjs";
2
- import { _ as RemoteProjectionsDescription, a as ClivlyConnectResult, c as ClivlyUser, d as DiscoveredTable, f as DoctorCheck, g as EnsuredObjectTypeResult, h as EnsuredMappingResult, i as ClivlyConnectReason, l as CreateChatSessionHandlerOptions, m as EnsureSchemaResult, n as ChatSessionRequestBody, o as ClivlySDK, p as DoctorReport, r as ChatWidgetVisitor, s as ClivlySDKConfig, u as CursorStore, y as SyncTriggerDescription } from "./namespace-probe-ouHKZas2.mjs";
2
+ import { _ as RemoteProjectionsDescription, a as ClivlyConnectResult, c as ClivlyUser, d as DiscoveredTable, f as DoctorCheck, g as EnsuredObjectTypeResult, h as EnsuredMappingResult, i as ClivlyConnectReason, l as CreateChatSessionHandlerOptions, m as EnsureSchemaResult, n as ChatSessionRequestBody, o as ClivlySDK, p as DoctorReport, r as ChatWidgetVisitor, s as ClivlySDKConfig, u as CursorStore, y as SyncTriggerDescription } from "./namespace-probe-BmxtnOfc.mjs";
3
3
  //#region ../sdk/src/trigger-url.d.ts
4
4
  declare function isPlaceholderUrl(value: string): boolean;
5
5
  //#endregion
package/sdk.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  import { c as defineClivlyConfig, g as validateEntitiesConfig, s as customObjectDeclarations } from "./entity-config-bC3DX9f1.mjs";
2
- import { a as ClivlyRateLimitError, i as ClivlyNetworkError, n as ClivlyAuthError, o as createChatSessionHandler, r as ClivlyError, s as isPlaceholderUrl, t as createClivlySDK } from "./sdk-BTzaM8bF.mjs";
2
+ import { a as ClivlyRateLimitError, i as ClivlyNetworkError, n as ClivlyAuthError, o as createChatSessionHandler, r as ClivlyError, s as isPlaceholderUrl, t as createClivlySDK } from "./sdk-DofHordq.mjs";
3
3
  export { ClivlyAuthError, ClivlyError, ClivlyNetworkError, ClivlyRateLimitError, createChatSessionHandler, createClivlySDK, customObjectDeclarations, defineClivlyConfig, isPlaceholderUrl, validateEntitiesConfig };
package/vite.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { o as ClivlySDK } from "./namespace-probe-lj_0bJrc.cjs";
1
+ import { o as ClivlySDK } from "./namespace-probe-C3xMOqWE.cjs";
2
2
  import { Plugin } from "vite";
3
3
  //#region ../sdk/src/vite.d.ts
4
4
  interface ClivlyViteOptions {
package/vite.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { o as ClivlySDK } from "./namespace-probe-ouHKZas2.mjs";
1
+ import { o as ClivlySDK } from "./namespace-probe-BmxtnOfc.mjs";
2
2
  import { Plugin } from "vite";
3
3
  //#region ../sdk/src/vite.d.ts
4
4
  interface ClivlyViteOptions {