@clivly/sdk 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.
@@ -12,8 +12,18 @@ function jsonResponse(body, status) {
12
12
  function normalizeApiUrl(apiUrl) {
13
13
  return apiUrl.replace(TRAILING_SLASH, "");
14
14
  }
15
- function resolveOrigin(req) {
16
- const headerOrigin = req.headers.get("origin");
15
+ /**
16
+ * The origin *claimed* by the request, for reporting upstream.
17
+ *
18
+ * Falls back to the request URL so Clivly still receives an origin for a
19
+ * non-browser caller. This value is reported, never trusted: only a real
20
+ * `Origin` header is evidence of where a request came from, so the access
21
+ * decision uses {@link trustedOrigin} instead. Keeping the two apart is the
22
+ * point — conflating them let a caller that simply omitted the header be
23
+ * judged against the server's own origin, which is usually in the allowlist.
24
+ */
25
+ function reportedOrigin(req) {
26
+ const headerOrigin = trustedOrigin(req);
17
27
  if (headerOrigin) return headerOrigin;
18
28
  try {
19
29
  return new URL(req.url).origin;
@@ -21,6 +31,15 @@ function resolveOrigin(req) {
21
31
  return null;
22
32
  }
23
33
  }
34
+ /**
35
+ * The only origin worth an access decision: one the browser set itself.
36
+ *
37
+ * A missing header means "not a browser request we can vouch for", which is
38
+ * untrusted — never the server's own origin.
39
+ */
40
+ function trustedOrigin(req) {
41
+ return req.headers.get("origin");
42
+ }
24
43
  function isChatSessionRequestBody(value) {
25
44
  if (typeof value !== "object" || value === null) return false;
26
45
  const body = value;
@@ -31,6 +50,17 @@ function isChatSessionRequestBody(value) {
31
50
  if (typeof visitor.email !== "string" || visitor.email.trim().length === 0) return false;
32
51
  return body.visitorToken === void 0 || body.visitorToken === null || typeof body.visitorToken === "string";
33
52
  }
53
+ /**
54
+ * Judge a request against the configured allowlist.
55
+ *
56
+ * `origin` must be a *trusted* origin (see {@link trustedOrigin}) — passing
57
+ * the reported one would reinstate the bypass this guards against.
58
+ *
59
+ * An unset allowlist still admits every origin, because requiring it would
60
+ * break every existing deployment on upgrade. The unset case is instead
61
+ * warned about once at construction, so the permissive default is loud
62
+ * rather than silent.
63
+ */
34
64
  function originAllowed(origin, allowedOrigins) {
35
65
  if (!allowedOrigins || allowedOrigins.length === 0) return true;
36
66
  if (!origin) return false;
@@ -38,6 +68,7 @@ function originAllowed(origin, allowedOrigins) {
38
68
  }
39
69
  function createChatSessionHandler({ allowedOrigins, apiKey, apiUrl = DEFAULT_API_URL, fetchImpl = fetch, resolveUser }) {
40
70
  const sessionUrl = `${normalizeApiUrl(apiUrl)}/clivly/widget/session`;
71
+ 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.");
41
72
  return async (request) => {
42
73
  if (request.method !== "POST") return new Response(JSON.stringify({ error: "method_not_allowed" }), {
43
74
  status: 405,
@@ -46,8 +77,8 @@ function createChatSessionHandler({ allowedOrigins, apiKey, apiUrl = DEFAULT_API
46
77
  allow: "POST"
47
78
  }
48
79
  });
49
- const origin = resolveOrigin(request);
50
- if (!originAllowed(origin, allowedOrigins)) return jsonResponse({ error: "origin_not_allowed" }, 403);
80
+ if (!originAllowed(trustedOrigin(request), allowedOrigins)) return jsonResponse({ error: "origin_not_allowed" }, 403);
81
+ const origin = reportedOrigin(request);
51
82
  let body;
52
83
  try {
53
84
  body = await request.json();
@@ -1,4 +1,4 @@
1
- import { c as CreateChatSessionHandlerOptions } from "./types-B7HKG2Ph.cjs";
1
+ import { c as CreateChatSessionHandlerOptions } from "./types-CvhxV-IX.cjs";
2
2
  //#region src/chat-session.d.ts
3
3
  declare function createChatSessionHandler({ allowedOrigins, apiKey, apiUrl, fetchImpl, resolveUser }: CreateChatSessionHandlerOptions): (request: Request) => Promise<Response>;
4
4
  //#endregion
@@ -1,4 +1,4 @@
1
- import { c as CreateChatSessionHandlerOptions } from "./types-B7HKG2Ph.mjs";
1
+ import { c as CreateChatSessionHandlerOptions } from "./types-CvhxV-IX.mjs";
2
2
  //#region src/chat-session.d.ts
3
3
  declare function createChatSessionHandler({ allowedOrigins, apiKey, apiUrl, fetchImpl, resolveUser }: CreateChatSessionHandlerOptions): (request: Request) => Promise<Response>;
4
4
  //#endregion
@@ -11,8 +11,18 @@ function jsonResponse(body, status) {
11
11
  function normalizeApiUrl(apiUrl) {
12
12
  return apiUrl.replace(TRAILING_SLASH, "");
13
13
  }
14
- function resolveOrigin(req) {
15
- const headerOrigin = req.headers.get("origin");
14
+ /**
15
+ * The origin *claimed* by the request, for reporting upstream.
16
+ *
17
+ * Falls back to the request URL so Clivly still receives an origin for a
18
+ * non-browser caller. This value is reported, never trusted: only a real
19
+ * `Origin` header is evidence of where a request came from, so the access
20
+ * decision uses {@link trustedOrigin} instead. Keeping the two apart is the
21
+ * point — conflating them let a caller that simply omitted the header be
22
+ * judged against the server's own origin, which is usually in the allowlist.
23
+ */
24
+ function reportedOrigin(req) {
25
+ const headerOrigin = trustedOrigin(req);
16
26
  if (headerOrigin) return headerOrigin;
17
27
  try {
18
28
  return new URL(req.url).origin;
@@ -20,6 +30,15 @@ function resolveOrigin(req) {
20
30
  return null;
21
31
  }
22
32
  }
33
+ /**
34
+ * The only origin worth an access decision: one the browser set itself.
35
+ *
36
+ * A missing header means "not a browser request we can vouch for", which is
37
+ * untrusted — never the server's own origin.
38
+ */
39
+ function trustedOrigin(req) {
40
+ return req.headers.get("origin");
41
+ }
23
42
  function isChatSessionRequestBody(value) {
24
43
  if (typeof value !== "object" || value === null) return false;
25
44
  const body = value;
@@ -30,6 +49,17 @@ function isChatSessionRequestBody(value) {
30
49
  if (typeof visitor.email !== "string" || visitor.email.trim().length === 0) return false;
31
50
  return body.visitorToken === void 0 || body.visitorToken === null || typeof body.visitorToken === "string";
32
51
  }
52
+ /**
53
+ * Judge a request against the configured allowlist.
54
+ *
55
+ * `origin` must be a *trusted* origin (see {@link trustedOrigin}) — passing
56
+ * the reported one would reinstate the bypass this guards against.
57
+ *
58
+ * An unset allowlist still admits every origin, because requiring it would
59
+ * break every existing deployment on upgrade. The unset case is instead
60
+ * warned about once at construction, so the permissive default is loud
61
+ * rather than silent.
62
+ */
33
63
  function originAllowed(origin, allowedOrigins) {
34
64
  if (!allowedOrigins || allowedOrigins.length === 0) return true;
35
65
  if (!origin) return false;
@@ -37,6 +67,7 @@ function originAllowed(origin, allowedOrigins) {
37
67
  }
38
68
  function createChatSessionHandler({ allowedOrigins, apiKey, apiUrl = DEFAULT_API_URL, fetchImpl = fetch, resolveUser }) {
39
69
  const sessionUrl = `${normalizeApiUrl(apiUrl)}/clivly/widget/session`;
70
+ 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.");
40
71
  return async (request) => {
41
72
  if (request.method !== "POST") return new Response(JSON.stringify({ error: "method_not_allowed" }), {
42
73
  status: 405,
@@ -45,8 +76,8 @@ function createChatSessionHandler({ allowedOrigins, apiKey, apiUrl = DEFAULT_API
45
76
  allow: "POST"
46
77
  }
47
78
  });
48
- const origin = resolveOrigin(request);
49
- if (!originAllowed(origin, allowedOrigins)) return jsonResponse({ error: "origin_not_allowed" }, 403);
79
+ if (!originAllowed(trustedOrigin(request), allowedOrigins)) return jsonResponse({ error: "origin_not_allowed" }, 403);
80
+ const origin = reportedOrigin(request);
50
81
  let body;
51
82
  try {
52
83
  body = await request.json();
package/dist/drizzle.cjs CHANGED
@@ -59,6 +59,7 @@ function fromDrizzleProjection(db, projection, opts) {
59
59
  const head = [`select ${selectItems.join(", ")}`, fromClause].join(" ");
60
60
  return {
61
61
  entity: opts.entity,
62
+ pushSource: projection.baseTable,
62
63
  cursorField: cursorToken,
63
64
  idField: "id",
64
65
  ...opts.objectType === void 0 ? {} : { objectType: opts.objectType },
@@ -88,6 +89,7 @@ function fromDrizzleProjectionByIdentity(client, projection, identityExpr, fromC
88
89
  const head = [`select ${selectItems.join(", ")}`, fromClause].join(" ");
89
90
  return {
90
91
  entity: opts.entity,
92
+ pushSource: projection.baseTable,
91
93
  cursorField: "id",
92
94
  idField: "id",
93
95
  ...opts.objectType === void 0 ? {} : { objectType: opts.objectType },
@@ -1,4 +1,4 @@
1
- import { _ as SyncSource, y as NamespaceIntrospector } from "./types-B7HKG2Ph.cjs";
1
+ import { _ as SyncSource, y as NamespaceIntrospector } from "./types-CvhxV-IX.cjs";
2
2
  import { ProjectionMapping, ProjectionRef } from "@clivly/core";
3
3
  import { Table } from "drizzle-orm";
4
4
  import { buildUniqueConstraints, discoverFromDrizzle } from "@clivly/core/drizzle";
@@ -1,4 +1,4 @@
1
- import { _ as SyncSource, y as NamespaceIntrospector } from "./types-B7HKG2Ph.mjs";
1
+ import { _ as SyncSource, y as NamespaceIntrospector } from "./types-CvhxV-IX.mjs";
2
2
  import { ProjectionMapping, ProjectionRef } from "@clivly/core";
3
3
  import { buildUniqueConstraints, discoverFromDrizzle } from "@clivly/core/drizzle";
4
4
  import { Table } from "drizzle-orm";
package/dist/drizzle.mjs CHANGED
@@ -58,6 +58,7 @@ function fromDrizzleProjection(db, projection, opts) {
58
58
  const head = [`select ${selectItems.join(", ")}`, fromClause].join(" ");
59
59
  return {
60
60
  entity: opts.entity,
61
+ pushSource: projection.baseTable,
61
62
  cursorField: cursorToken,
62
63
  idField: "id",
63
64
  ...opts.objectType === void 0 ? {} : { objectType: opts.objectType },
@@ -87,6 +88,7 @@ function fromDrizzleProjectionByIdentity(client, projection, identityExpr, fromC
87
88
  const head = [`select ${selectItems.join(", ")}`, fromClause].join(" ");
88
89
  return {
89
90
  entity: opts.entity,
91
+ pushSource: projection.baseTable,
90
92
  cursorField: "id",
91
93
  idField: "id",
92
94
  ...opts.objectType === void 0 ? {} : { objectType: opts.objectType },
package/dist/index.cjs CHANGED
@@ -79,6 +79,43 @@ function resolveEntitySource(config, entityKey) {
79
79
  function expectedSourceKey(concept) {
80
80
  return CONCEPT_KEYS[concept] ?? "custom";
81
81
  }
82
+ /**
83
+ * The `source` label a push must carry for the server to find its mapping.
84
+ *
85
+ * `entity` is the SDK's own name for a source — the cursor-store key, and what
86
+ * the developer wrote in `config.source`. It is NOT always what the server
87
+ * filed the mapping under: for a projection, `ensureSchema` stores
88
+ * `sourceTable` as `projection.baseTable`, because a projection entity's key
89
+ * doubles as a kebab-case object slug and cannot also be the snake_case
90
+ * `projection.entityKey`.
91
+ *
92
+ * Pushing under `entity` therefore dropped 100% of a projection's rows while
93
+ * every local signal said the setup was correct. One function so the three
94
+ * push sites cannot drift apart again.
95
+ */
96
+ function pushSourceOf(source) {
97
+ return source.pushSource ?? source.entity;
98
+ }
99
+ /**
100
+ * The push label for a custom source, resolved against the config.
101
+ *
102
+ * `fromDrizzleProjection` stamps `pushSource` itself, but a projection source
103
+ * can equally be hand-built — `fromDrizzle` with an overridden `fetchPage` is
104
+ * what the README's own example leads you to, and next-finance did exactly
105
+ * that. Such a source has no `pushSource`, so the label has to come from the
106
+ * one place that is true either way: the entity's declared
107
+ * `projection.baseTable`.
108
+ *
109
+ * Matched on the source's `entity`, which for a custom source is also the
110
+ * config entity key (that is what `resolveEntitySource` above pairs them by).
111
+ * Falls back to `pushSourceOf`, so a non-projection custom source and an
112
+ * already-stamped one both keep their current label.
113
+ */
114
+ function customPushSource(config, source) {
115
+ if (source.pushSource) return source.pushSource;
116
+ const entities = config.entities?.entities;
117
+ return (entities?.[source.entity] ?? Object.values(entities ?? {}).find((e) => e.source === source.entity))?.projection?.baseTable ?? source.entity;
118
+ }
82
119
  //#endregion
83
120
  //#region src/concept-coverage.ts
84
121
  const CHECK_NAME$1 = "Entity → source coverage";
@@ -357,11 +394,15 @@ function errorMessage(error) {
357
394
  /**
358
395
  * The entities to ask about, in the exact terms the sync pushes them.
359
396
  *
360
- * `source` is the resolved `SyncSource.entity` — the value that arrives at
397
+ * `source` is whatever the push will actually send — the value that arrives at
361
398
  * `ingestPush` as `input.source` and is compared against `mapping.sourceTable`.
399
+ * Usually that is `SyncSource.entity`, but a projection pushes under its base
400
+ * table, so this asks `customPushSource` rather than reading `entity` directly.
401
+ *
362
402
  * It is NOT the entity config's own `source` field, which names a host table;
363
- * the two are routinely conflated, and using the wrong one would report every
364
- * correctly mapped workspace as unmapped.
403
+ * the two are routinely conflated, and using the wrong one reports a correctly
404
+ * mapped workspace as unmapped — which is exactly what happened to every
405
+ * projection until this used the push label.
365
406
  */
366
407
  function readinessEntitiesFromConfig(config) {
367
408
  const entities = [];
@@ -370,7 +411,7 @@ function readinessEntitiesFromConfig(config) {
370
411
  if (!resolved) continue;
371
412
  const objectTypeSlug = resolved.source.objectType;
372
413
  entities.push({
373
- source: resolved.source.entity,
414
+ source: customPushSource(config, resolved.source),
374
415
  concept: resolved.concept,
375
416
  ...resolved.concept === "custom" && objectTypeSlug ? { objectTypeSlug } : {}
376
417
  });
@@ -1205,7 +1246,7 @@ async function pushRemoteProjections(args) {
1205
1246
  }
1206
1247
  try {
1207
1248
  const ok = await args.push(SYNC_CUSTOM_PATH$1, {
1208
- source: source.entity,
1249
+ source: pushSourceOf(source),
1209
1250
  rows: all,
1210
1251
  mode: "full",
1211
1252
  fetched: all.length,
@@ -1280,7 +1321,7 @@ async function syncSource(args) {
1280
1321
  if (rows.length === 0) break;
1281
1322
  const capped = maxRows === void 0 ? rows : rows.slice(0, maxRows);
1282
1323
  if (!await push(path, {
1283
- source: source.entity,
1324
+ source: pushSourceOf(source),
1284
1325
  rows: capped,
1285
1326
  mode: "delta",
1286
1327
  fetched: rows.length,
@@ -1324,7 +1365,7 @@ async function fullSyncSource(args) {
1324
1365
  ok: true
1325
1366
  };
1326
1367
  const ok = await push(path, {
1327
- source: source.entity,
1368
+ source: pushSourceOf(source),
1328
1369
  rows: all,
1329
1370
  mode: "full",
1330
1371
  fetched: all.length,
@@ -1577,6 +1618,18 @@ var TriggerRequestError = class extends Error {
1577
1618
  function triggerErrorStatus(error) {
1578
1619
  return error instanceof TriggerRequestError ? error.status : 500;
1579
1620
  }
1621
+ const SENTENCE_END_RE = /[.!?]$/;
1622
+ async function readPushFailureMessage(response) {
1623
+ if (!response || typeof response.json !== "function") return null;
1624
+ try {
1625
+ const body = await response.json();
1626
+ if (typeof body !== "object" || body === null) return null;
1627
+ const { message } = body;
1628
+ return typeof message === "string" && message.trim().length > 0 ? message : null;
1629
+ } catch {
1630
+ return null;
1631
+ }
1632
+ }
1580
1633
  const SIGNATURE_TOLERANCE_SECONDS = 300;
1581
1634
  const SIGNATURE_HEADER_RE = /^t=(\d+),v1=([0-9a-f]+)$/;
1582
1635
  const SECONDS_PER_MS = 1e3;
@@ -1799,17 +1852,27 @@ function createClivlySDK(config) {
1799
1852
  }, response);
1800
1853
  return true;
1801
1854
  }
1802
- lastPushFailure ??= { status: response?.status ?? null };
1855
+ lastPushFailure ??= {
1856
+ status: response?.status ?? null,
1857
+ message: await readPushFailureMessage(response)
1858
+ };
1803
1859
  return false;
1804
1860
  };
1805
1861
  const describePushFailure = (entities) => {
1806
1862
  const base = `Clivly sync: push failed for ${entities.join(", ")}`;
1807
1863
  const status = lastPushFailure?.status ?? null;
1864
+ const serverMessage = lastPushFailure?.message ?? null;
1808
1865
  let cause;
1809
1866
  if (status === 401 || status === 403) cause = "the API key was rejected — check CLIVLY_API_KEY is set to a valid key for this org";
1810
1867
  else if (status === null) cause = `couldn't reach Clivly at ${apiUrl} — check network access and CLIVLY_API_URL`;
1868
+ else if (serverMessage) cause = `Clivly returned HTTP ${status}: ${serverMessage}`;
1811
1869
  else cause = `Clivly returned HTTP ${status}`;
1812
- return `${base}: ${cause}. Run \`clivly doctor\` to diagnose.`;
1870
+ const separator = SENTENCE_END_RE.test(cause) ? "" : ".";
1871
+ return `${base}: ${cause}${separator} Run \`clivly doctor\` to diagnose.`;
1872
+ };
1873
+ const pushFailureError = (message) => {
1874
+ const status = lastPushFailure?.status ?? null;
1875
+ return status !== null && status >= CLIENT_ERROR_FLOOR && status < RETRYABLE_STATUS_FLOOR ? new TriggerRequestError(status, message) : new Error(message);
1813
1876
  };
1814
1877
  const syncOne = async (source, path, mode, objectType, runId, batchSizeOverride, maxRowsOverride, persistCursor = true) => {
1815
1878
  const since = mode === "full" ? null : await cursorStore.get(source.entity) ?? null;
@@ -1835,7 +1898,10 @@ function createClivlySDK(config) {
1835
1898
  };
1836
1899
  const syncCustomSources = async (customSources, mode, runId) => {
1837
1900
  const failed = [];
1838
- for (const customSource of customSources ?? []) if (!await syncOne(customSource, SYNC_CUSTOM_PATH, mode, customSource.objectType, runId)) failed.push(customSource.entity);
1901
+ for (const customSource of customSources ?? []) {
1902
+ customSource.pushSource = customPushSource(config, customSource);
1903
+ if (!await syncOne(customSource, SYNC_CUSTOM_PATH, mode, customSource.objectType, runId)) failed.push(customSource.entity);
1904
+ }
1839
1905
  return failed;
1840
1906
  };
1841
1907
  const syncRemoteSources = async (sources, runId) => {
@@ -1896,7 +1962,7 @@ function createClivlySDK(config) {
1896
1962
  failed: true,
1897
1963
  error: message
1898
1964
  };
1899
- throw new Error(message);
1965
+ throw pushFailureError(message);
1900
1966
  }
1901
1967
  lastPush = { failed: false };
1902
1968
  };
@@ -1904,7 +1970,7 @@ function createClivlySDK(config) {
1904
1970
  const resolved = resolveSampleSource(config, entityKey);
1905
1971
  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.`);
1906
1972
  lastPushFailure = null;
1907
- 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]));
1973
+ if (!await syncOne(resolved.source, resolved.path, "delta", resolved.objectType, runId, SAMPLE_BATCH_SIZE, SAMPLE_BATCH_SIZE, false)) throw pushFailureError(describePushFailure([resolved.source.entity]));
1908
1974
  };
1909
1975
  const describeSyncTrigger = () => ({
1910
1976
  configured: config.syncTrigger?.url,
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as ClivlySDK, c as CreateChatSessionHandlerOptions, d as DoctorCheck, f as DoctorReport, g as RemoteProjectionsDescription, h as EnsuredObjectTypeResult, i as ClivlyConnectResult, l as CursorStore, m as EnsuredMappingResult, n as ChatWidgetVisitor, o as ClivlySDKConfig, p as EnsureSchemaResult, r as ClivlyConnectReason, s as ClivlyUser, t as ChatSessionRequestBody, u as DiscoveredTable, v as SyncTriggerDescription } from "./types-B7HKG2Ph.cjs";
1
+ import { a as ClivlySDK, c as CreateChatSessionHandlerOptions, d as DoctorCheck, f as DoctorReport, g as RemoteProjectionsDescription, h as EnsuredObjectTypeResult, i as ClivlyConnectResult, l as CursorStore, m as EnsuredMappingResult, n as ChatWidgetVisitor, o as ClivlySDKConfig, p as EnsureSchemaResult, r as ClivlyConnectReason, s as ClivlyUser, t as ChatSessionRequestBody, u as DiscoveredTable, v as SyncTriggerDescription } from "./types-CvhxV-IX.cjs";
2
2
  import { createChatSessionHandler } from "./chat-session.cjs";
3
3
  import { ClivlyEntitiesConfig, ConfigValidationError, ConfigValidationResult, CustomObjectDeclaration, DiscoveredSchemaTable, customObjectDeclarations, defineClivlyConfig, validateEntitiesConfig } from "@clivly/core/entity-config";
4
4
  //#region src/trigger-url.d.ts
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as ClivlySDK, c as CreateChatSessionHandlerOptions, d as DoctorCheck, f as DoctorReport, g as RemoteProjectionsDescription, h as EnsuredObjectTypeResult, i as ClivlyConnectResult, l as CursorStore, m as EnsuredMappingResult, n as ChatWidgetVisitor, o as ClivlySDKConfig, p as EnsureSchemaResult, r as ClivlyConnectReason, s as ClivlyUser, t as ChatSessionRequestBody, u as DiscoveredTable, v as SyncTriggerDescription } from "./types-B7HKG2Ph.mjs";
1
+ import { a as ClivlySDK, c as CreateChatSessionHandlerOptions, d as DoctorCheck, f as DoctorReport, g as RemoteProjectionsDescription, h as EnsuredObjectTypeResult, i as ClivlyConnectResult, l as CursorStore, m as EnsuredMappingResult, n as ChatWidgetVisitor, o as ClivlySDKConfig, p as EnsureSchemaResult, r as ClivlyConnectReason, s as ClivlyUser, t as ChatSessionRequestBody, u as DiscoveredTable, v as SyncTriggerDescription } from "./types-CvhxV-IX.mjs";
2
2
  import { createChatSessionHandler } from "./chat-session.mjs";
3
3
  import { ClivlyEntitiesConfig, ConfigValidationError, ConfigValidationResult, CustomObjectDeclaration, DiscoveredSchemaTable, customObjectDeclarations, defineClivlyConfig, validateEntitiesConfig } from "@clivly/core/entity-config";
4
4
  //#region src/trigger-url.d.ts
package/dist/index.mjs CHANGED
@@ -78,6 +78,43 @@ function resolveEntitySource(config, entityKey) {
78
78
  function expectedSourceKey(concept) {
79
79
  return CONCEPT_KEYS[concept] ?? "custom";
80
80
  }
81
+ /**
82
+ * The `source` label a push must carry for the server to find its mapping.
83
+ *
84
+ * `entity` is the SDK's own name for a source — the cursor-store key, and what
85
+ * the developer wrote in `config.source`. It is NOT always what the server
86
+ * filed the mapping under: for a projection, `ensureSchema` stores
87
+ * `sourceTable` as `projection.baseTable`, because a projection entity's key
88
+ * doubles as a kebab-case object slug and cannot also be the snake_case
89
+ * `projection.entityKey`.
90
+ *
91
+ * Pushing under `entity` therefore dropped 100% of a projection's rows while
92
+ * every local signal said the setup was correct. One function so the three
93
+ * push sites cannot drift apart again.
94
+ */
95
+ function pushSourceOf(source) {
96
+ return source.pushSource ?? source.entity;
97
+ }
98
+ /**
99
+ * The push label for a custom source, resolved against the config.
100
+ *
101
+ * `fromDrizzleProjection` stamps `pushSource` itself, but a projection source
102
+ * can equally be hand-built — `fromDrizzle` with an overridden `fetchPage` is
103
+ * what the README's own example leads you to, and next-finance did exactly
104
+ * that. Such a source has no `pushSource`, so the label has to come from the
105
+ * one place that is true either way: the entity's declared
106
+ * `projection.baseTable`.
107
+ *
108
+ * Matched on the source's `entity`, which for a custom source is also the
109
+ * config entity key (that is what `resolveEntitySource` above pairs them by).
110
+ * Falls back to `pushSourceOf`, so a non-projection custom source and an
111
+ * already-stamped one both keep their current label.
112
+ */
113
+ function customPushSource(config, source) {
114
+ if (source.pushSource) return source.pushSource;
115
+ const entities = config.entities?.entities;
116
+ return (entities?.[source.entity] ?? Object.values(entities ?? {}).find((e) => e.source === source.entity))?.projection?.baseTable ?? source.entity;
117
+ }
81
118
  //#endregion
82
119
  //#region src/concept-coverage.ts
83
120
  const CHECK_NAME$1 = "Entity → source coverage";
@@ -356,11 +393,15 @@ function errorMessage(error) {
356
393
  /**
357
394
  * The entities to ask about, in the exact terms the sync pushes them.
358
395
  *
359
- * `source` is the resolved `SyncSource.entity` — the value that arrives at
396
+ * `source` is whatever the push will actually send — the value that arrives at
360
397
  * `ingestPush` as `input.source` and is compared against `mapping.sourceTable`.
398
+ * Usually that is `SyncSource.entity`, but a projection pushes under its base
399
+ * table, so this asks `customPushSource` rather than reading `entity` directly.
400
+ *
361
401
  * It is NOT the entity config's own `source` field, which names a host table;
362
- * the two are routinely conflated, and using the wrong one would report every
363
- * correctly mapped workspace as unmapped.
402
+ * the two are routinely conflated, and using the wrong one reports a correctly
403
+ * mapped workspace as unmapped — which is exactly what happened to every
404
+ * projection until this used the push label.
364
405
  */
365
406
  function readinessEntitiesFromConfig(config) {
366
407
  const entities = [];
@@ -369,7 +410,7 @@ function readinessEntitiesFromConfig(config) {
369
410
  if (!resolved) continue;
370
411
  const objectTypeSlug = resolved.source.objectType;
371
412
  entities.push({
372
- source: resolved.source.entity,
413
+ source: customPushSource(config, resolved.source),
373
414
  concept: resolved.concept,
374
415
  ...resolved.concept === "custom" && objectTypeSlug ? { objectTypeSlug } : {}
375
416
  });
@@ -1204,7 +1245,7 @@ async function pushRemoteProjections(args) {
1204
1245
  }
1205
1246
  try {
1206
1247
  const ok = await args.push(SYNC_CUSTOM_PATH$1, {
1207
- source: source.entity,
1248
+ source: pushSourceOf(source),
1208
1249
  rows: all,
1209
1250
  mode: "full",
1210
1251
  fetched: all.length,
@@ -1279,7 +1320,7 @@ async function syncSource(args) {
1279
1320
  if (rows.length === 0) break;
1280
1321
  const capped = maxRows === void 0 ? rows : rows.slice(0, maxRows);
1281
1322
  if (!await push(path, {
1282
- source: source.entity,
1323
+ source: pushSourceOf(source),
1283
1324
  rows: capped,
1284
1325
  mode: "delta",
1285
1326
  fetched: rows.length,
@@ -1323,7 +1364,7 @@ async function fullSyncSource(args) {
1323
1364
  ok: true
1324
1365
  };
1325
1366
  const ok = await push(path, {
1326
- source: source.entity,
1367
+ source: pushSourceOf(source),
1327
1368
  rows: all,
1328
1369
  mode: "full",
1329
1370
  fetched: all.length,
@@ -1576,6 +1617,18 @@ var TriggerRequestError = class extends Error {
1576
1617
  function triggerErrorStatus(error) {
1577
1618
  return error instanceof TriggerRequestError ? error.status : 500;
1578
1619
  }
1620
+ const SENTENCE_END_RE = /[.!?]$/;
1621
+ async function readPushFailureMessage(response) {
1622
+ if (!response || typeof response.json !== "function") return null;
1623
+ try {
1624
+ const body = await response.json();
1625
+ if (typeof body !== "object" || body === null) return null;
1626
+ const { message } = body;
1627
+ return typeof message === "string" && message.trim().length > 0 ? message : null;
1628
+ } catch {
1629
+ return null;
1630
+ }
1631
+ }
1579
1632
  const SIGNATURE_TOLERANCE_SECONDS = 300;
1580
1633
  const SIGNATURE_HEADER_RE = /^t=(\d+),v1=([0-9a-f]+)$/;
1581
1634
  const SECONDS_PER_MS = 1e3;
@@ -1798,17 +1851,27 @@ function createClivlySDK(config) {
1798
1851
  }, response);
1799
1852
  return true;
1800
1853
  }
1801
- lastPushFailure ??= { status: response?.status ?? null };
1854
+ lastPushFailure ??= {
1855
+ status: response?.status ?? null,
1856
+ message: await readPushFailureMessage(response)
1857
+ };
1802
1858
  return false;
1803
1859
  };
1804
1860
  const describePushFailure = (entities) => {
1805
1861
  const base = `Clivly sync: push failed for ${entities.join(", ")}`;
1806
1862
  const status = lastPushFailure?.status ?? null;
1863
+ const serverMessage = lastPushFailure?.message ?? null;
1807
1864
  let cause;
1808
1865
  if (status === 401 || status === 403) cause = "the API key was rejected — check CLIVLY_API_KEY is set to a valid key for this org";
1809
1866
  else if (status === null) cause = `couldn't reach Clivly at ${apiUrl} — check network access and CLIVLY_API_URL`;
1867
+ else if (serverMessage) cause = `Clivly returned HTTP ${status}: ${serverMessage}`;
1810
1868
  else cause = `Clivly returned HTTP ${status}`;
1811
- return `${base}: ${cause}. Run \`clivly doctor\` to diagnose.`;
1869
+ const separator = SENTENCE_END_RE.test(cause) ? "" : ".";
1870
+ return `${base}: ${cause}${separator} Run \`clivly doctor\` to diagnose.`;
1871
+ };
1872
+ const pushFailureError = (message) => {
1873
+ const status = lastPushFailure?.status ?? null;
1874
+ return status !== null && status >= CLIENT_ERROR_FLOOR && status < RETRYABLE_STATUS_FLOOR ? new TriggerRequestError(status, message) : new Error(message);
1812
1875
  };
1813
1876
  const syncOne = async (source, path, mode, objectType, runId, batchSizeOverride, maxRowsOverride, persistCursor = true) => {
1814
1877
  const since = mode === "full" ? null : await cursorStore.get(source.entity) ?? null;
@@ -1834,7 +1897,10 @@ function createClivlySDK(config) {
1834
1897
  };
1835
1898
  const syncCustomSources = async (customSources, mode, runId) => {
1836
1899
  const failed = [];
1837
- for (const customSource of customSources ?? []) if (!await syncOne(customSource, SYNC_CUSTOM_PATH, mode, customSource.objectType, runId)) failed.push(customSource.entity);
1900
+ for (const customSource of customSources ?? []) {
1901
+ customSource.pushSource = customPushSource(config, customSource);
1902
+ if (!await syncOne(customSource, SYNC_CUSTOM_PATH, mode, customSource.objectType, runId)) failed.push(customSource.entity);
1903
+ }
1838
1904
  return failed;
1839
1905
  };
1840
1906
  const syncRemoteSources = async (sources, runId) => {
@@ -1895,7 +1961,7 @@ function createClivlySDK(config) {
1895
1961
  failed: true,
1896
1962
  error: message
1897
1963
  };
1898
- throw new Error(message);
1964
+ throw pushFailureError(message);
1899
1965
  }
1900
1966
  lastPush = { failed: false };
1901
1967
  };
@@ -1903,7 +1969,7 @@ function createClivlySDK(config) {
1903
1969
  const resolved = resolveSampleSource(config, entityKey);
1904
1970
  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.`);
1905
1971
  lastPushFailure = null;
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]));
1972
+ if (!await syncOne(resolved.source, resolved.path, "delta", resolved.objectType, runId, SAMPLE_BATCH_SIZE, SAMPLE_BATCH_SIZE, false)) throw pushFailureError(describePushFailure([resolved.source.entity]));
1907
1973
  };
1908
1974
  const describeSyncTrigger = () => ({
1909
1975
  configured: config.syncTrigger?.url,
@@ -376,6 +376,22 @@ interface SyncSource {
376
376
  * absent from another, and read by the runtime.
377
377
  */
378
378
  objectType?: string;
379
+ /**
380
+ * The `source` label a push carries, when it differs from `entity`.
381
+ *
382
+ * Only a projection sets this, and only because a projection entity has no
383
+ * choice but to carry two names: its entity key doubles as the custom-object
384
+ * slug and must be kebab-case, while `projection.entityKey` is validated by
385
+ * `/^[a-z][a-z0-9_]*$/` and rejects hyphens. The server resolves that by
386
+ * storing the mapping's `sourceTable` as `projection.baseTable` — so a push
387
+ * labelled with the entity key matched no mapping and every row was dropped,
388
+ * while `push-schema` reported the mapping created and exited 0.
389
+ *
390
+ * Deliberately NOT folded into `entity`: that value is also the cursor-store
391
+ * key, so redefining it would silently reset the sync cursor of every
392
+ * existing projection and re-push its whole history.
393
+ */
394
+ pushSource?: string;
379
395
  /** Set by `fromDrizzle`; lets `doctor` introspect the backing table. */
380
396
  [DRIZZLE_META]?: DrizzleSourceMeta;
381
397
  }
@@ -376,6 +376,22 @@ interface SyncSource {
376
376
  * absent from another, and read by the runtime.
377
377
  */
378
378
  objectType?: string;
379
+ /**
380
+ * The `source` label a push carries, when it differs from `entity`.
381
+ *
382
+ * Only a projection sets this, and only because a projection entity has no
383
+ * choice but to carry two names: its entity key doubles as the custom-object
384
+ * slug and must be kebab-case, while `projection.entityKey` is validated by
385
+ * `/^[a-z][a-z0-9_]*$/` and rejects hyphens. The server resolves that by
386
+ * storing the mapping's `sourceTable` as `projection.baseTable` — so a push
387
+ * labelled with the entity key matched no mapping and every row was dropped,
388
+ * while `push-schema` reported the mapping created and exited 0.
389
+ *
390
+ * Deliberately NOT folded into `entity`: that value is also the cursor-store
391
+ * key, so redefining it would silently reset the sync cursor of every
392
+ * existing projection and re-push its whole history.
393
+ */
394
+ pushSource?: string;
379
395
  /** Set by `fromDrizzle`; lets `doctor` introspect the backing table. */
380
396
  [DRIZZLE_META]?: DrizzleSourceMeta;
381
397
  }
package/dist/vite.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as ClivlySDK } from "./types-B7HKG2Ph.cjs";
1
+ import { a as ClivlySDK } from "./types-CvhxV-IX.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-B7HKG2Ph.mjs";
1
+ import { a as ClivlySDK } from "./types-CvhxV-IX.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.10.7",
3
+ "version": "0.10.9",
4
4
  "private": false,
5
5
  "description": "Clivly SDK — connect your app to Clivly Cloud (heartbeat, discovery, auth bridge).",
6
6
  "license": "MIT",
@@ -49,10 +49,11 @@
49
49
  }
50
50
  },
51
51
  "files": [
52
- "dist"
52
+ "dist",
53
+ "README.md"
53
54
  ],
54
55
  "dependencies": {
55
- "@clivly/core": "0.10.7"
56
+ "@clivly/core": "0.10.9"
56
57
  },
57
58
  "devDependencies": {
58
59
  "@electric-sql/pglite": "^0.5.4",