@clivly/sdk 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -453,6 +453,7 @@ is honoured. Other `4xx` responses return immediately.
453
453
  | Method | Returns | Notes |
454
454
  | --- | --- | --- |
455
455
  | `connect()` | `Promise<ClivlyConnectResult>` | One heartbeat, structured. Never throws |
456
+ | `ping()` | `Promise<ClivlyConnectResult>` | Zero-write reachability probe. Same shape as `connect()`, but records no heartbeat — and it alone returns `workspace` |
456
457
  | `heartbeat()` | `Promise<boolean>` | One heartbeat, boolean |
457
458
  | `start()` | `Promise<void>` | Heartbeat + boot sync + timers. Long-lived hosts only; opens opt-in WebSocket presence |
458
459
  | `stop()` | `void` | Clears the timers |
@@ -461,6 +462,19 @@ is honoured. Other `4xx` responses return immediately.
461
462
  | `createClivlyHandler()` | `(Request) => Promise<Response>` | Mount at a route |
462
463
  | `createAuthVerifyHandler(resolveUser)` | `(Request) => Promise<Response>` | `503` unless `verifyToken` is set |
463
464
  | `doctor()` | `Promise<DoctorReport>` | `{ ok, checks }` |
465
+ | `ensureSchema()` | `Promise<EnsureSchemaResult \| null>` | Declares your custom object types **and** the mappings that make them receive rows. `null` = could not deliver; **rejects** if Clivly refuses the config |
466
+ | `ensureCustomObjects()` | `Promise<{ slug, id, created }[] \| null>` | Object types only, no mappings. Superseded by `ensureSchema()` |
467
+
468
+ `clivly push-schema` calls `ensureSchema()` for you, so you rarely need it
469
+ directly — reach for it when you want config-as-code to run from your own
470
+ deploy script or a CI step.
471
+
472
+ The `null`-vs-throw split is deliberate. `null` means the request never landed
473
+ (network, or a Clivly older than the route), which is worth a retry. A rejection
474
+ means Clivly understood the config and declined it — most often because an
475
+ entity would overwrite a mapping someone edited in the dashboard — which is
476
+ worth reading, not retrying. A config-declared mapping is owned by your config;
477
+ a dashboard-edited one is never silently overwritten.
464
478
 
465
479
  ### `ClivlyConnectResult.reason`
466
480
 
@@ -468,6 +482,17 @@ is honoured. Other `4xx` responses return immediately.
468
482
  `server_error` (5xx) · `client_error` (other 4xx) · `network_error` (no
469
483
  response).
470
484
 
485
+ ### `ClivlyConnectResult.workspace`
486
+
487
+ `ping()` also returns `workspace` — the setup ladder for the org behind your key
488
+ (`IntegrationState` from `@clivly/core`), computed by the server so the CLI and
489
+ the dashboard cannot disagree about how far setup has got. This is what
490
+ `clivly status` prints under **Workspace**.
491
+
492
+ It is `undefined` or `null` when the server predates the field or could not read
493
+ it. Treat that as *unknown*, never as *nothing is set up* — an old server, a
494
+ rejected key and a dead network all mean the question went unanswered.
495
+
471
496
  ### Subpath exports
472
497
 
473
498
  | From `clivly` | From `@clivly/sdk` | Contents |
@@ -1,4 +1,4 @@
1
- import { c as CreateChatSessionHandlerOptions } from "./types-DxG4rHyo.cjs";
1
+ import { c as CreateChatSessionHandlerOptions } from "./types-Dz1LQYvQ.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-DxG4rHyo.mjs";
1
+ import { c as CreateChatSessionHandlerOptions } from "./types-Dz1LQYvQ.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
package/dist/drizzle.cjs CHANGED
@@ -2,6 +2,76 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_drizzle_meta = require("./drizzle-meta-CdCs30q3.cjs");
3
3
  let _clivly_core_drizzle = require("@clivly/core/drizzle");
4
4
  let drizzle_orm = require("drizzle-orm");
5
+ let _clivly_core = require("@clivly/core");
6
+ //#region src/drizzle-projection.ts
7
+ /** `<ref>.<column>` — the token `fields` carries and cloud-ingest reads. */
8
+ function refToken(ref) {
9
+ return `${ref.table}.${ref.column}`;
10
+ }
11
+ function refSql(ref) {
12
+ return `${(0, _clivly_core.quoteIdent)(ref.table)}.${(0, _clivly_core.quoteIdent)(ref.column)}`;
13
+ }
14
+ function parseRef(value) {
15
+ if (typeof value !== "string") return value;
16
+ const dot = value.indexOf(".");
17
+ if (dot <= 0 || dot === value.length - 1) throw new Error(`fromDrizzleProjection: cursorField "${value}" must be a ref token like "base.updated_at".`);
18
+ return {
19
+ table: value.slice(0, dot),
20
+ column: value.slice(dot + 1)
21
+ };
22
+ }
23
+ /**
24
+ * Refs address tables symbolically — `base` or a join key — never by table name,
25
+ * which is what stops a projection reaching a table it did not declare. An
26
+ * unresolvable ref would otherwise compile to SQL referencing a missing alias
27
+ * and fail at the database with a message that never mentions the config.
28
+ */
29
+ function assertKnownRef(ref, known, context) {
30
+ if (!known.has(ref.table)) throw new Error(`fromDrizzleProjection: ${context} names unknown ref "${ref.table}" — expected "${[...known].join("\", \"")}".`);
31
+ }
32
+ function normalizeRows(result) {
33
+ if (Array.isArray(result)) return result;
34
+ const rows = result.rows;
35
+ if (Array.isArray(rows)) return rows;
36
+ throw new Error("fromDrizzleProjection: db.execute() returned neither an array of rows nor { rows }.");
37
+ }
38
+ function fromDrizzleProjection(db, projection, opts) {
39
+ const client = db;
40
+ const known = /* @__PURE__ */ new Set([_clivly_core.BASE_REF, ...projection.joins.map((j) => j.key)]);
41
+ if (projection.identity.kind === "base_pk" && projection.identity.column === void 0 && opts.basePkColumn === void 0) throw new Error(`fromDrizzleProjection: projection "${projection.entityKey}" has a base_pk identity with no column — pass { basePkColumn }.`);
42
+ const cursorRef = parseRef(opts.cursorField);
43
+ assertKnownRef(cursorRef, known, "cursorField");
44
+ for (const [field, binding] of Object.entries(projection.fieldBindings)) assertKnownRef(binding.from, known, `field "${field}"`);
45
+ const identityExpr = (0, _clivly_core.compileProjectionIdentity)(projection, opts.basePkColumn ?? "");
46
+ const cursorToken = refToken(cursorRef);
47
+ const cursorExpr = `CAST(${refSql(cursorRef)} AS timestamptz)`;
48
+ const cursorRead = `to_char(${cursorExpr} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
49
+ const selectItems = [`${identityExpr} AS ${(0, _clivly_core.quoteIdent)("id")}`, `${cursorRead} AS ${(0, _clivly_core.quoteIdent)(cursorToken)}`];
50
+ const emitted = /* @__PURE__ */ new Set([cursorToken]);
51
+ for (const binding of Object.values(projection.fieldBindings)) {
52
+ const token = refToken(binding.from);
53
+ if (emitted.has(token)) continue;
54
+ emitted.add(token);
55
+ selectItems.push(`${refSql(binding.from)} AS ${(0, _clivly_core.quoteIdent)(token)}`);
56
+ }
57
+ const head = [
58
+ `select ${selectItems.join(", ")}`,
59
+ (0, _clivly_core.compileProjectionFrom)(projection),
60
+ ...(0, _clivly_core.compileProjectionJoins)(projection)
61
+ ].join(" ");
62
+ return {
63
+ entity: opts.entity,
64
+ cursorField: cursorToken,
65
+ idField: "id",
66
+ ...opts.objectType === void 0 ? {} : { objectType: opts.objectType },
67
+ fetchPage: async ({ since, limit }) => {
68
+ const where = since ? drizzle_orm.sql` where (${drizzle_orm.sql.raw(cursorExpr)}, ${drizzle_orm.sql.raw(identityExpr)}) > (${since.time.toISOString()}::timestamptz, ${since.id})` : drizzle_orm.sql``;
69
+ const query = drizzle_orm.sql`${drizzle_orm.sql.raw(head)}${where} order by ${drizzle_orm.sql.raw(cursorExpr)} asc, ${drizzle_orm.sql.raw(identityExpr)} asc limit ${limit}`;
70
+ return normalizeRows(await client.execute(query));
71
+ }
72
+ };
73
+ }
74
+ //#endregion
5
75
  //#region src/drizzle.ts
6
76
  function inferIdField(table) {
7
77
  const columns = (0, drizzle_orm.getTableColumns)(table);
@@ -14,14 +84,6 @@ function inferIdField(table) {
14
84
  const kind = primaryKeys.length === 0 ? "no" : "a composite";
15
85
  throw new Error(`fromDrizzle: table "${(0, drizzle_orm.getTableName)(table)}" has ${kind} single-column primary key — pass { idField } explicitly.`);
16
86
  }
17
- /**
18
- * Build a `SyncSource` from a Drizzle table, generating the keyset-paginated
19
- * `fetchPage` the SDK needs. `cursorField`/`idField` are drizzle property keys;
20
- * `idField` defaults to the table's single-column primary key.
21
- *
22
- * `drizzle-orm` is an optional peer dependency — installed only when you import
23
- * `@clivly/sdk/drizzle`.
24
- */
25
87
  function fromDrizzle(db, table, opts) {
26
88
  const drizzleDb = db;
27
89
  const idField = opts.idField ?? inferIdField(table);
@@ -38,7 +100,8 @@ function fromDrizzle(db, table, opts) {
38
100
  entity: opts.entity,
39
101
  cursorField: cursorColumnName,
40
102
  idField: idColumnName,
41
- fetchPage: ({ since, limit }) => drizzleDb.select(projection).from(table).where(since ? drizzle_orm.sql`(${cursorColumn}, ${idColumn}) > (${since.time}, ${since.id})` : void 0).orderBy((0, drizzle_orm.asc)(cursorColumn), (0, drizzle_orm.asc)(idColumn)).limit(limit)
103
+ fetchPage: ({ since, limit }) => drizzleDb.select(projection).from(table).where(since ? drizzle_orm.sql`(${cursorColumn}, ${idColumn}) > (${since.time}, ${since.id})` : void 0).orderBy((0, drizzle_orm.asc)(cursorColumn), (0, drizzle_orm.asc)(idColumn)).limit(limit),
104
+ ...opts.objectType === void 0 ? {} : { objectType: opts.objectType }
42
105
  };
43
106
  const uniqueConstraints = (0, _clivly_core_drizzle.buildUniqueConstraints)(table);
44
107
  const meta = {
@@ -89,3 +152,4 @@ Object.defineProperty(exports, "discoverFromDrizzle", {
89
152
  });
90
153
  exports.drizzleIntrospector = drizzleIntrospector;
91
154
  exports.fromDrizzle = fromDrizzle;
155
+ exports.fromDrizzleProjection = fromDrizzleProjection;
@@ -1,23 +1,99 @@
1
- import { _ as NamespaceIntrospector, g as SyncSource } from "./types-DxG4rHyo.cjs";
1
+ import { _ as NamespaceIntrospector, g as SyncSource } from "./types-Dz1LQYvQ.cjs";
2
+ import { ProjectionMapping, ProjectionRef } from "@clivly/core";
2
3
  import { Table } from "drizzle-orm";
3
4
  import { buildUniqueConstraints, discoverFromDrizzle } from "@clivly/core/drizzle";
5
+ //#region src/drizzle-projection.d.ts
6
+ /**
7
+ * Build a `SyncSource` that actually executes a projection's joins.
8
+ *
9
+ * `fromDrizzle` emits a single-table select, so a projection entity paired with
10
+ * it pushed raw base-table rows and every joined field mirrored empty — the
11
+ * declared joins were inert, and the only symptom was a dry run reporting a
12
+ * missing column whose name was correct.
13
+ *
14
+ * The row shape is the contract the cloud already relies on. A projection's
15
+ * `fields` are ref *tokens* (`client_name -> "cl.full_name"`, from
16
+ * `projectionFields`), and `cloud-ingest` reads `row[token]` verbatim plus
17
+ * `row.id` for `sourceRef`. So this aliases every binding by its token rather
18
+ * than by its field name.
19
+ *
20
+ * That is the one deliberate difference from the view compiler, which aliases
21
+ * by field name because the sync engine reads the compiled view directly. FROM,
22
+ * JOIN and the identity expression all come from `@clivly/core`, so both paths
23
+ * join identically and produce the same `id` for the same row.
24
+ *
25
+ * `drizzle-orm` is an optional peer dependency — installed only when you import
26
+ * `@clivly/sdk/drizzle`. The `db` only needs `.execute()`, the same surface
27
+ * `drizzleIntrospector` requires.
28
+ */
29
+ interface FromDrizzleProjectionOptions {
30
+ /**
31
+ * The base table's primary key, required only when `identity` is `base_pk`
32
+ * without an explicit column. Unlike `fromDrizzle` there is no `Table` object
33
+ * to infer it from — a projection names tables as strings.
34
+ */
35
+ basePkColumn?: string;
36
+ /**
37
+ * The column to keyset-paginate on, as a ref token (`"base.updated_at"`) or
38
+ * a `ProjectionRef`. A projection spans several tables, so there is no
39
+ * single table to infer a cursor from.
40
+ */
41
+ cursorField: string | ProjectionRef;
42
+ /** The source name rows are pushed under. */
43
+ entity: string;
44
+ /**
45
+ * The custom object slug. Accepted here so the result is directly assignable
46
+ * to `source.custom` — bolting it on with a spread afterwards drops the
47
+ * non-enumerable drizzle metadata.
48
+ */
49
+ objectType?: string;
50
+ }
51
+ declare function fromDrizzleProjection(db: unknown, projection: ProjectionMapping, opts: FromDrizzleProjectionOptions & {
52
+ objectType: string;
53
+ }): SyncSource & {
54
+ objectType: string;
55
+ };
56
+ declare function fromDrizzleProjection(db: unknown, projection: ProjectionMapping, opts: FromDrizzleProjectionOptions): SyncSource;
57
+ //#endregion
4
58
  //#region src/drizzle.d.ts
5
59
  interface DrizzleDb {
6
60
  select: (...args: any[]) => any;
7
61
  }
62
+ interface FromDrizzleOptions {
63
+ cursorField: string;
64
+ entity: string;
65
+ idField?: string;
66
+ /**
67
+ * Custom-object slug, for sources destined for `source.custom`.
68
+ *
69
+ * Pass it here rather than attaching it afterwards. `source.custom` is typed
70
+ * `Array<SyncSource & { objectType: string }>`, so before this option existed
71
+ * the only way to satisfy that type was to bolt the property on post-hoc —
72
+ * and the idiomatic spread (`{ ...fromDrizzle(...), objectType }`) copies only
73
+ * ENUMERABLE own properties, silently dropping the non-enumerable
74
+ * `DRIZZLE_META` below. `doctor` would then report a `fromDrizzle` source as
75
+ * "hand-written" and skip its cursor/id column checks, which is both a lost
76
+ * check and a false explanation.
77
+ */
78
+ objectType?: string;
79
+ }
8
80
  /**
9
81
  * Build a `SyncSource` from a Drizzle table, generating the keyset-paginated
10
82
  * `fetchPage` the SDK needs. `cursorField`/`idField` are drizzle property keys;
11
83
  * `idField` defaults to the table's single-column primary key.
12
84
  *
85
+ * Pass `objectType` for custom objects — the result is then directly assignable
86
+ * to `source.custom` with no spread or mutation.
87
+ *
13
88
  * `drizzle-orm` is an optional peer dependency — installed only when you import
14
89
  * `@clivly/sdk/drizzle`.
15
90
  */
16
- declare function fromDrizzle(db: DrizzleDb, table: Table, opts: {
17
- entity: string;
18
- cursorField: string;
19
- idField?: string;
20
- }): SyncSource;
91
+ declare function fromDrizzle(db: DrizzleDb, table: Table, opts: FromDrizzleOptions & {
92
+ objectType: string;
93
+ }): SyncSource & {
94
+ objectType: string;
95
+ };
96
+ declare function fromDrizzle(db: DrizzleDb, table: Table, opts: FromDrizzleOptions): SyncSource;
21
97
  /**
22
98
  * Build a `NamespaceIntrospector` from any Drizzle client that exposes
23
99
  * `.execute()`. Reads `information_schema` in the connection's default schema
@@ -25,4 +101,4 @@ declare function fromDrizzle(db: DrizzleDb, table: Table, opts: {
25
101
  */
26
102
  declare function drizzleIntrospector(db: unknown): NamespaceIntrospector;
27
103
  //#endregion
28
- export { buildUniqueConstraints, discoverFromDrizzle, drizzleIntrospector, fromDrizzle };
104
+ export { type FromDrizzleProjectionOptions, buildUniqueConstraints, discoverFromDrizzle, drizzleIntrospector, fromDrizzle, fromDrizzleProjection };
@@ -1,23 +1,99 @@
1
- import { _ as NamespaceIntrospector, g as SyncSource } from "./types-DxG4rHyo.mjs";
1
+ import { _ as NamespaceIntrospector, g as SyncSource } from "./types-Dz1LQYvQ.mjs";
2
2
  import { buildUniqueConstraints, discoverFromDrizzle } from "@clivly/core/drizzle";
3
3
  import { Table } from "drizzle-orm";
4
+ import { ProjectionMapping, ProjectionRef } from "@clivly/core";
5
+ //#region src/drizzle-projection.d.ts
6
+ /**
7
+ * Build a `SyncSource` that actually executes a projection's joins.
8
+ *
9
+ * `fromDrizzle` emits a single-table select, so a projection entity paired with
10
+ * it pushed raw base-table rows and every joined field mirrored empty — the
11
+ * declared joins were inert, and the only symptom was a dry run reporting a
12
+ * missing column whose name was correct.
13
+ *
14
+ * The row shape is the contract the cloud already relies on. A projection's
15
+ * `fields` are ref *tokens* (`client_name -> "cl.full_name"`, from
16
+ * `projectionFields`), and `cloud-ingest` reads `row[token]` verbatim plus
17
+ * `row.id` for `sourceRef`. So this aliases every binding by its token rather
18
+ * than by its field name.
19
+ *
20
+ * That is the one deliberate difference from the view compiler, which aliases
21
+ * by field name because the sync engine reads the compiled view directly. FROM,
22
+ * JOIN and the identity expression all come from `@clivly/core`, so both paths
23
+ * join identically and produce the same `id` for the same row.
24
+ *
25
+ * `drizzle-orm` is an optional peer dependency — installed only when you import
26
+ * `@clivly/sdk/drizzle`. The `db` only needs `.execute()`, the same surface
27
+ * `drizzleIntrospector` requires.
28
+ */
29
+ interface FromDrizzleProjectionOptions {
30
+ /**
31
+ * The base table's primary key, required only when `identity` is `base_pk`
32
+ * without an explicit column. Unlike `fromDrizzle` there is no `Table` object
33
+ * to infer it from — a projection names tables as strings.
34
+ */
35
+ basePkColumn?: string;
36
+ /**
37
+ * The column to keyset-paginate on, as a ref token (`"base.updated_at"`) or
38
+ * a `ProjectionRef`. A projection spans several tables, so there is no
39
+ * single table to infer a cursor from.
40
+ */
41
+ cursorField: string | ProjectionRef;
42
+ /** The source name rows are pushed under. */
43
+ entity: string;
44
+ /**
45
+ * The custom object slug. Accepted here so the result is directly assignable
46
+ * to `source.custom` — bolting it on with a spread afterwards drops the
47
+ * non-enumerable drizzle metadata.
48
+ */
49
+ objectType?: string;
50
+ }
51
+ declare function fromDrizzleProjection(db: unknown, projection: ProjectionMapping, opts: FromDrizzleProjectionOptions & {
52
+ objectType: string;
53
+ }): SyncSource & {
54
+ objectType: string;
55
+ };
56
+ declare function fromDrizzleProjection(db: unknown, projection: ProjectionMapping, opts: FromDrizzleProjectionOptions): SyncSource;
57
+ //#endregion
4
58
  //#region src/drizzle.d.ts
5
59
  interface DrizzleDb {
6
60
  select: (...args: any[]) => any;
7
61
  }
62
+ interface FromDrizzleOptions {
63
+ cursorField: string;
64
+ entity: string;
65
+ idField?: string;
66
+ /**
67
+ * Custom-object slug, for sources destined for `source.custom`.
68
+ *
69
+ * Pass it here rather than attaching it afterwards. `source.custom` is typed
70
+ * `Array<SyncSource & { objectType: string }>`, so before this option existed
71
+ * the only way to satisfy that type was to bolt the property on post-hoc —
72
+ * and the idiomatic spread (`{ ...fromDrizzle(...), objectType }`) copies only
73
+ * ENUMERABLE own properties, silently dropping the non-enumerable
74
+ * `DRIZZLE_META` below. `doctor` would then report a `fromDrizzle` source as
75
+ * "hand-written" and skip its cursor/id column checks, which is both a lost
76
+ * check and a false explanation.
77
+ */
78
+ objectType?: string;
79
+ }
8
80
  /**
9
81
  * Build a `SyncSource` from a Drizzle table, generating the keyset-paginated
10
82
  * `fetchPage` the SDK needs. `cursorField`/`idField` are drizzle property keys;
11
83
  * `idField` defaults to the table's single-column primary key.
12
84
  *
85
+ * Pass `objectType` for custom objects — the result is then directly assignable
86
+ * to `source.custom` with no spread or mutation.
87
+ *
13
88
  * `drizzle-orm` is an optional peer dependency — installed only when you import
14
89
  * `@clivly/sdk/drizzle`.
15
90
  */
16
- declare function fromDrizzle(db: DrizzleDb, table: Table, opts: {
17
- entity: string;
18
- cursorField: string;
19
- idField?: string;
20
- }): SyncSource;
91
+ declare function fromDrizzle(db: DrizzleDb, table: Table, opts: FromDrizzleOptions & {
92
+ objectType: string;
93
+ }): SyncSource & {
94
+ objectType: string;
95
+ };
96
+ declare function fromDrizzle(db: DrizzleDb, table: Table, opts: FromDrizzleOptions): SyncSource;
21
97
  /**
22
98
  * Build a `NamespaceIntrospector` from any Drizzle client that exposes
23
99
  * `.execute()`. Reads `information_schema` in the connection's default schema
@@ -25,4 +101,4 @@ declare function fromDrizzle(db: DrizzleDb, table: Table, opts: {
25
101
  */
26
102
  declare function drizzleIntrospector(db: unknown): NamespaceIntrospector;
27
103
  //#endregion
28
- export { buildUniqueConstraints, discoverFromDrizzle, drizzleIntrospector, fromDrizzle };
104
+ export { type FromDrizzleProjectionOptions, buildUniqueConstraints, discoverFromDrizzle, drizzleIntrospector, fromDrizzle, fromDrizzleProjection };
package/dist/drizzle.mjs CHANGED
@@ -1,6 +1,76 @@
1
1
  import { t as DRIZZLE_META } from "./drizzle-meta-DNqzkREf.mjs";
2
2
  import { buildColumnsMeta, buildUniqueConstraints, buildUniqueConstraints as buildUniqueConstraints$1, discoverFromDrizzle } from "@clivly/core/drizzle";
3
3
  import { asc, getTableColumns, getTableName, sql } from "drizzle-orm";
4
+ import { BASE_REF, compileProjectionFrom, compileProjectionIdentity, compileProjectionJoins, quoteIdent } from "@clivly/core";
5
+ //#region src/drizzle-projection.ts
6
+ /** `<ref>.<column>` — the token `fields` carries and cloud-ingest reads. */
7
+ function refToken(ref) {
8
+ return `${ref.table}.${ref.column}`;
9
+ }
10
+ function refSql(ref) {
11
+ return `${quoteIdent(ref.table)}.${quoteIdent(ref.column)}`;
12
+ }
13
+ function parseRef(value) {
14
+ if (typeof value !== "string") return value;
15
+ const dot = value.indexOf(".");
16
+ if (dot <= 0 || dot === value.length - 1) throw new Error(`fromDrizzleProjection: cursorField "${value}" must be a ref token like "base.updated_at".`);
17
+ return {
18
+ table: value.slice(0, dot),
19
+ column: value.slice(dot + 1)
20
+ };
21
+ }
22
+ /**
23
+ * Refs address tables symbolically — `base` or a join key — never by table name,
24
+ * which is what stops a projection reaching a table it did not declare. An
25
+ * unresolvable ref would otherwise compile to SQL referencing a missing alias
26
+ * and fail at the database with a message that never mentions the config.
27
+ */
28
+ function assertKnownRef(ref, known, context) {
29
+ if (!known.has(ref.table)) throw new Error(`fromDrizzleProjection: ${context} names unknown ref "${ref.table}" — expected "${[...known].join("\", \"")}".`);
30
+ }
31
+ function normalizeRows(result) {
32
+ if (Array.isArray(result)) return result;
33
+ const rows = result.rows;
34
+ if (Array.isArray(rows)) return rows;
35
+ throw new Error("fromDrizzleProjection: db.execute() returned neither an array of rows nor { rows }.");
36
+ }
37
+ function fromDrizzleProjection(db, projection, opts) {
38
+ const client = db;
39
+ const known = /* @__PURE__ */ new Set([BASE_REF, ...projection.joins.map((j) => j.key)]);
40
+ if (projection.identity.kind === "base_pk" && projection.identity.column === void 0 && opts.basePkColumn === void 0) throw new Error(`fromDrizzleProjection: projection "${projection.entityKey}" has a base_pk identity with no column — pass { basePkColumn }.`);
41
+ const cursorRef = parseRef(opts.cursorField);
42
+ assertKnownRef(cursorRef, known, "cursorField");
43
+ for (const [field, binding] of Object.entries(projection.fieldBindings)) assertKnownRef(binding.from, known, `field "${field}"`);
44
+ const identityExpr = compileProjectionIdentity(projection, opts.basePkColumn ?? "");
45
+ const cursorToken = refToken(cursorRef);
46
+ const cursorExpr = `CAST(${refSql(cursorRef)} AS timestamptz)`;
47
+ const cursorRead = `to_char(${cursorExpr} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
48
+ const selectItems = [`${identityExpr} AS ${quoteIdent("id")}`, `${cursorRead} AS ${quoteIdent(cursorToken)}`];
49
+ const emitted = /* @__PURE__ */ new Set([cursorToken]);
50
+ for (const binding of Object.values(projection.fieldBindings)) {
51
+ const token = refToken(binding.from);
52
+ if (emitted.has(token)) continue;
53
+ emitted.add(token);
54
+ selectItems.push(`${refSql(binding.from)} AS ${quoteIdent(token)}`);
55
+ }
56
+ const head = [
57
+ `select ${selectItems.join(", ")}`,
58
+ compileProjectionFrom(projection),
59
+ ...compileProjectionJoins(projection)
60
+ ].join(" ");
61
+ return {
62
+ entity: opts.entity,
63
+ cursorField: cursorToken,
64
+ idField: "id",
65
+ ...opts.objectType === void 0 ? {} : { objectType: opts.objectType },
66
+ fetchPage: async ({ since, limit }) => {
67
+ const where = since ? sql` where (${sql.raw(cursorExpr)}, ${sql.raw(identityExpr)}) > (${since.time.toISOString()}::timestamptz, ${since.id})` : sql``;
68
+ const query = sql`${sql.raw(head)}${where} order by ${sql.raw(cursorExpr)} asc, ${sql.raw(identityExpr)} asc limit ${limit}`;
69
+ return normalizeRows(await client.execute(query));
70
+ }
71
+ };
72
+ }
73
+ //#endregion
4
74
  //#region src/drizzle.ts
5
75
  function inferIdField(table) {
6
76
  const columns = getTableColumns(table);
@@ -13,14 +83,6 @@ function inferIdField(table) {
13
83
  const kind = primaryKeys.length === 0 ? "no" : "a composite";
14
84
  throw new Error(`fromDrizzle: table "${getTableName(table)}" has ${kind} single-column primary key — pass { idField } explicitly.`);
15
85
  }
16
- /**
17
- * Build a `SyncSource` from a Drizzle table, generating the keyset-paginated
18
- * `fetchPage` the SDK needs. `cursorField`/`idField` are drizzle property keys;
19
- * `idField` defaults to the table's single-column primary key.
20
- *
21
- * `drizzle-orm` is an optional peer dependency — installed only when you import
22
- * `@clivly/sdk/drizzle`.
23
- */
24
86
  function fromDrizzle(db, table, opts) {
25
87
  const drizzleDb = db;
26
88
  const idField = opts.idField ?? inferIdField(table);
@@ -37,7 +99,8 @@ function fromDrizzle(db, table, opts) {
37
99
  entity: opts.entity,
38
100
  cursorField: cursorColumnName,
39
101
  idField: idColumnName,
40
- fetchPage: ({ since, limit }) => drizzleDb.select(projection).from(table).where(since ? sql`(${cursorColumn}, ${idColumn}) > (${since.time}, ${since.id})` : void 0).orderBy(asc(cursorColumn), asc(idColumn)).limit(limit)
102
+ fetchPage: ({ since, limit }) => drizzleDb.select(projection).from(table).where(since ? sql`(${cursorColumn}, ${idColumn}) > (${since.time}, ${since.id})` : void 0).orderBy(asc(cursorColumn), asc(idColumn)).limit(limit),
103
+ ...opts.objectType === void 0 ? {} : { objectType: opts.objectType }
41
104
  };
42
105
  const uniqueConstraints = buildUniqueConstraints$1(table);
43
106
  const meta = {
@@ -74,4 +137,4 @@ function drizzleIntrospector(db) {
74
137
  };
75
138
  }
76
139
  //#endregion
77
- export { buildUniqueConstraints, discoverFromDrizzle, drizzleIntrospector, fromDrizzle };
140
+ export { buildUniqueConstraints, discoverFromDrizzle, drizzleIntrospector, fromDrizzle, fromDrizzleProjection };
package/dist/index.cjs CHANGED
@@ -125,11 +125,16 @@ function checkConceptCoverage(config) {
125
125
  }
126
126
  //#endregion
127
127
  //#region src/custom-slug.ts
128
+ const SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/;
128
129
  const NON_ALNUM = /[^a-z0-9]+/g;
129
130
  const EDGE_HYPHENS = /(^-|-$)/g;
130
131
  function slugify(value) {
131
132
  return value.toLowerCase().trim().replace(NON_ALNUM, "-").replace(EDGE_HYPHENS, "");
132
133
  }
134
+ function suggestionFor(declared) {
135
+ const candidate = slugify(declared);
136
+ return candidate !== declared && SLUG_PATTERN.test(candidate) ? ` Did you mean "${candidate}"?` : "";
137
+ }
133
138
  function checkCustomObjectSlugs(config) {
134
139
  const custom = config.source?.custom ?? [];
135
140
  const declared = config.entities ? (0, _clivly_core_entity_config.customObjectDeclarations)(config.entities) : [];
@@ -143,12 +148,11 @@ function checkCustomObjectSlugs(config) {
143
148
  const seen = /* @__PURE__ */ new Map();
144
149
  for (const source of custom) {
145
150
  const declared = source.objectType;
146
- const canonical = slugify(declared);
147
- if (canonical === "") problems.push(`"${source.entity}" has an empty objectType.`);
148
- else if (canonical !== declared) problems.push(`"${declared}" is not a slug — the dashboard would store it as "${canonical}".`);
149
- const prior = seen.get(canonical);
150
- if (prior && canonical !== "") problems.push(`duplicate slug "${canonical}" declared by "${prior}" and "${source.entity}".`);
151
- else seen.set(canonical, source.entity);
151
+ if (declared === "") problems.push(`"${source.entity}" has an empty objectType.`);
152
+ else if (!SLUG_PATTERN.test(declared)) problems.push(`"${declared}" is not a valid object type slug — use lowercase letters, digits, "-" or "_", starting with a letter.${suggestionFor(declared)}`);
153
+ const prior = seen.get(declared);
154
+ if (prior && declared !== "") problems.push(`duplicate slug "${declared}" declared by "${prior}" and "${source.entity}".`);
155
+ else seen.set(declared, source.entity);
152
156
  }
153
157
  if (problems.length > 0) return {
154
158
  name: "Custom object slugs",
@@ -255,10 +259,20 @@ async function runMappingDryRun(config, entityKey) {
255
259
  detail: `"${resolved.source.entity}" is empty — nothing to map yet.`
256
260
  };
257
261
  const missing = Object.entries(fields).filter(([, column]) => !(column in row)).map(([field, column]) => `${field} → ${column}`);
262
+ if (missing.length === 0) return {
263
+ name,
264
+ ok: true,
265
+ detail: `Mapped ${Object.keys(fields).length} field(s) from a real "${resolved.source.entity}" row.`
266
+ };
267
+ if (config.entities?.entities?.[entityKey]?.projection !== void 0 && missing.length === Object.keys(fields).length) return {
268
+ name,
269
+ ok: false,
270
+ detail: `Row from "${resolved.source.entity}" carries none of the projection's columns (${missing.join(", ")}) — the source performs no joins, so the projection is declared but never executed. Build it with \`fromDrizzleProjection\` from \`clivly/drizzle\`, which runs the joins and returns rows keyed by ref token.`
271
+ };
258
272
  return {
259
273
  name,
260
- ok: missing.length === 0,
261
- detail: missing.length === 0 ? `Mapped ${Object.keys(fields).length} field(s) from a real "${resolved.source.entity}" row.` : `Row from "${resolved.source.entity}" is missing mapped column(s): ${missing.join(", ")}.`
274
+ ok: false,
275
+ detail: `Row from "${resolved.source.entity}" is missing mapped column(s): ${missing.join(", ")}.`
262
276
  };
263
277
  }
264
278
  //#endregion
@@ -467,9 +481,27 @@ async function mappingDryRunChecks(config) {
467
481
  return checks;
468
482
  }
469
483
  /**
484
+ * Why a source has no `DRIZZLE_META`, without asserting more than we know.
485
+ *
486
+ * There are two ways to get here and they are indistinguishable at runtime: the
487
+ * source really was hand-written, or it came from `fromDrizzle` and a spread
488
+ * stripped the metadata, which is non-enumerable. The second is easy to fall
489
+ * into precisely where `objectType` is present — `source.custom` is typed
490
+ * `Array<SyncSource & { objectType: string }>`, so attaching that property with
491
+ * `{ ...fromDrizzle(...), objectType }` used to be the only way to satisfy it.
492
+ *
493
+ * Flatly calling such a source "hand-written" sent developers looking for one
494
+ * that does not exist. Where `objectType` is present, name the likelier cause
495
+ * and the fix instead of guessing wrong with confidence.
496
+ */
497
+ function missingMetaDetail(source) {
498
+ const skipped = "column check skipped";
499
+ if (source.objectType === void 0) return `"${source.entity}" is a hand-written source — ${skipped}.`;
500
+ return `"${source.entity}" has no drizzle metadata — ${skipped}. If it came from \`fromDrizzle\`, a spread such as \`{ ...fromDrizzle(...), objectType }\` dropped it; pass \`objectType\` to \`fromDrizzle\` instead.`;
501
+ }
502
+ /**
470
503
  * Confirm each drizzle source actually has the cursor and id columns the sync
471
- * pages on. Hand-written sources have no column metadata, so they are skipped
472
- * rather than failed.
504
+ * pages on. Sources with no column metadata are skipped rather than failed.
473
505
  */
474
506
  function cursorColumnChecks(sources) {
475
507
  const checks = [];
@@ -480,7 +512,7 @@ function cursorColumnChecks(sources) {
480
512
  name: `Cursor/id columns exist (${source.entity})`,
481
513
  ok: false,
482
514
  skipped: true,
483
- detail: `"${source.entity}" is a hand-written source — column check skipped.`
515
+ detail: missingMetaDetail(source)
484
516
  });
485
517
  continue;
486
518
  }
@@ -1119,11 +1151,16 @@ function observedTriggerUrl(req) {
1119
1151
  return new URL(`${proto}://${host}${requestUrl.pathname}`).toString();
1120
1152
  } catch {}
1121
1153
  }
1154
+ const PLACEHOLDER_BRACKETS = /[<>]/;
1155
+ function isPlaceholderUrl(value) {
1156
+ return PLACEHOLDER_BRACKETS.test(value);
1157
+ }
1122
1158
  /**
1123
1159
  * Decide which trigger URL to report, in precedence order:
1124
1160
  *
1125
1161
  * 1. `configuredUrl` — an explicit `syncTrigger.url`. Someone typed this on
1126
- * purpose, so it beats anything inferred.
1162
+ * purpose, so it beats anything inferred. An unedited placeholder is not
1163
+ * "on purpose" and is skipped.
1127
1164
  * 2. platform env origin + `configuredPath` — available at boot, before any
1128
1165
  * request has arrived. Needs the path because platform variables only give
1129
1166
  * an origin.
@@ -1133,7 +1170,7 @@ function observedTriggerUrl(req) {
1133
1170
  * doesn't need to know about it.
1134
1171
  */
1135
1172
  function resolveTriggerUrl(args) {
1136
- if (args.configuredUrl) return args.configuredUrl;
1173
+ if (args.configuredUrl && !isPlaceholderUrl(args.configuredUrl)) return args.configuredUrl;
1137
1174
  const origin = originFromPlatformEnv(args.env);
1138
1175
  if (origin && args.configuredPath) return `${origin}${args.configuredPath.startsWith("/") ? args.configuredPath : `/${args.configuredPath}`}`;
1139
1176
  return args.observedUrl;
@@ -1702,6 +1739,7 @@ function createClivlySDK(config) {
1702
1739
  reason: "ok",
1703
1740
  retryable: false,
1704
1741
  org: body?.org,
1742
+ workspace: body?.workspace ?? null,
1705
1743
  ..."appLastSeenAt" in body ? { appLastSeenAt: body.appLastSeenAt ?? null } : {}
1706
1744
  };
1707
1745
  }
@@ -1891,6 +1929,7 @@ Object.defineProperty(exports, "defineClivlyConfig", {
1891
1929
  return _clivly_core_entity_config.defineClivlyConfig;
1892
1930
  }
1893
1931
  });
1932
+ exports.isPlaceholderUrl = isPlaceholderUrl;
1894
1933
  Object.defineProperty(exports, "validateEntitiesConfig", {
1895
1934
  enumerable: true,
1896
1935
  get: function() {
package/dist/index.d.cts CHANGED
@@ -1,6 +1,9 @@
1
- import { a as ClivlySDK, c as CreateChatSessionHandlerOptions, d as DoctorCheck, f as DoctorReport, 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 } from "./types-DxG4rHyo.cjs";
1
+ import { a as ClivlySDK, c as CreateChatSessionHandlerOptions, d as DoctorCheck, f as DoctorReport, 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 } from "./types-Dz1LQYvQ.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
+ //#region src/trigger-url.d.ts
5
+ declare function isPlaceholderUrl(value: string): boolean;
6
+ //#endregion
4
7
  //#region src/errors.d.ts
5
8
  declare class ClivlyError extends Error {
6
9
  /** HTTP status, or null when the request never completed (network error). */
@@ -31,4 +34,4 @@ declare class ClivlyNetworkError extends ClivlyError {}
31
34
  */
32
35
  declare function createClivlySDK(config: ClivlySDKConfig): ClivlySDK;
33
36
  //#endregion
34
- export { type ChatSessionRequestBody, type ChatWidgetVisitor, ClivlyAuthError, type ClivlyConnectReason, type ClivlyConnectResult, type ClivlyEntitiesConfig, ClivlyError, ClivlyNetworkError, ClivlyRateLimitError, type ClivlySDK, type ClivlySDKConfig, type ClivlyUser, type ConfigValidationError, type ConfigValidationResult, type CreateChatSessionHandlerOptions, type CursorStore, type CustomObjectDeclaration, type DiscoveredSchemaTable, type DiscoveredTable, type DoctorCheck, type DoctorReport, type EnsureSchemaResult, type EnsuredMappingResult, type EnsuredObjectTypeResult, createChatSessionHandler, createClivlySDK, customObjectDeclarations, defineClivlyConfig, validateEntitiesConfig };
37
+ export { type ChatSessionRequestBody, type ChatWidgetVisitor, ClivlyAuthError, type ClivlyConnectReason, type ClivlyConnectResult, type ClivlyEntitiesConfig, ClivlyError, ClivlyNetworkError, ClivlyRateLimitError, type ClivlySDK, type ClivlySDKConfig, type ClivlyUser, type ConfigValidationError, type ConfigValidationResult, type CreateChatSessionHandlerOptions, type CursorStore, type CustomObjectDeclaration, type DiscoveredSchemaTable, type DiscoveredTable, type DoctorCheck, type DoctorReport, type EnsureSchemaResult, type EnsuredMappingResult, type EnsuredObjectTypeResult, createChatSessionHandler, createClivlySDK, customObjectDeclarations, defineClivlyConfig, isPlaceholderUrl, validateEntitiesConfig };
package/dist/index.d.mts CHANGED
@@ -1,6 +1,9 @@
1
- import { a as ClivlySDK, c as CreateChatSessionHandlerOptions, d as DoctorCheck, f as DoctorReport, 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 } from "./types-DxG4rHyo.mjs";
1
+ import { a as ClivlySDK, c as CreateChatSessionHandlerOptions, d as DoctorCheck, f as DoctorReport, 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 } from "./types-Dz1LQYvQ.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
+ //#region src/trigger-url.d.ts
5
+ declare function isPlaceholderUrl(value: string): boolean;
6
+ //#endregion
4
7
  //#region src/errors.d.ts
5
8
  declare class ClivlyError extends Error {
6
9
  /** HTTP status, or null when the request never completed (network error). */
@@ -31,4 +34,4 @@ declare class ClivlyNetworkError extends ClivlyError {}
31
34
  */
32
35
  declare function createClivlySDK(config: ClivlySDKConfig): ClivlySDK;
33
36
  //#endregion
34
- export { type ChatSessionRequestBody, type ChatWidgetVisitor, ClivlyAuthError, type ClivlyConnectReason, type ClivlyConnectResult, type ClivlyEntitiesConfig, ClivlyError, ClivlyNetworkError, ClivlyRateLimitError, type ClivlySDK, type ClivlySDKConfig, type ClivlyUser, type ConfigValidationError, type ConfigValidationResult, type CreateChatSessionHandlerOptions, type CursorStore, type CustomObjectDeclaration, type DiscoveredSchemaTable, type DiscoveredTable, type DoctorCheck, type DoctorReport, type EnsureSchemaResult, type EnsuredMappingResult, type EnsuredObjectTypeResult, createChatSessionHandler, createClivlySDK, customObjectDeclarations, defineClivlyConfig, validateEntitiesConfig };
37
+ export { type ChatSessionRequestBody, type ChatWidgetVisitor, ClivlyAuthError, type ClivlyConnectReason, type ClivlyConnectResult, type ClivlyEntitiesConfig, ClivlyError, ClivlyNetworkError, ClivlyRateLimitError, type ClivlySDK, type ClivlySDKConfig, type ClivlyUser, type ConfigValidationError, type ConfigValidationResult, type CreateChatSessionHandlerOptions, type CursorStore, type CustomObjectDeclaration, type DiscoveredSchemaTable, type DiscoveredTable, type DoctorCheck, type DoctorReport, type EnsureSchemaResult, type EnsuredMappingResult, type EnsuredObjectTypeResult, createChatSessionHandler, createClivlySDK, customObjectDeclarations, defineClivlyConfig, isPlaceholderUrl, validateEntitiesConfig };
package/dist/index.mjs CHANGED
@@ -124,11 +124,16 @@ function checkConceptCoverage(config) {
124
124
  }
125
125
  //#endregion
126
126
  //#region src/custom-slug.ts
127
+ const SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/;
127
128
  const NON_ALNUM = /[^a-z0-9]+/g;
128
129
  const EDGE_HYPHENS = /(^-|-$)/g;
129
130
  function slugify(value) {
130
131
  return value.toLowerCase().trim().replace(NON_ALNUM, "-").replace(EDGE_HYPHENS, "");
131
132
  }
133
+ function suggestionFor(declared) {
134
+ const candidate = slugify(declared);
135
+ return candidate !== declared && SLUG_PATTERN.test(candidate) ? ` Did you mean "${candidate}"?` : "";
136
+ }
132
137
  function checkCustomObjectSlugs(config) {
133
138
  const custom = config.source?.custom ?? [];
134
139
  const declared = config.entities ? customObjectDeclarations$1(config.entities) : [];
@@ -142,12 +147,11 @@ function checkCustomObjectSlugs(config) {
142
147
  const seen = /* @__PURE__ */ new Map();
143
148
  for (const source of custom) {
144
149
  const declared = source.objectType;
145
- const canonical = slugify(declared);
146
- if (canonical === "") problems.push(`"${source.entity}" has an empty objectType.`);
147
- else if (canonical !== declared) problems.push(`"${declared}" is not a slug — the dashboard would store it as "${canonical}".`);
148
- const prior = seen.get(canonical);
149
- if (prior && canonical !== "") problems.push(`duplicate slug "${canonical}" declared by "${prior}" and "${source.entity}".`);
150
- else seen.set(canonical, source.entity);
150
+ if (declared === "") problems.push(`"${source.entity}" has an empty objectType.`);
151
+ else if (!SLUG_PATTERN.test(declared)) problems.push(`"${declared}" is not a valid object type slug — use lowercase letters, digits, "-" or "_", starting with a letter.${suggestionFor(declared)}`);
152
+ const prior = seen.get(declared);
153
+ if (prior && declared !== "") problems.push(`duplicate slug "${declared}" declared by "${prior}" and "${source.entity}".`);
154
+ else seen.set(declared, source.entity);
151
155
  }
152
156
  if (problems.length > 0) return {
153
157
  name: "Custom object slugs",
@@ -254,10 +258,20 @@ async function runMappingDryRun(config, entityKey) {
254
258
  detail: `"${resolved.source.entity}" is empty — nothing to map yet.`
255
259
  };
256
260
  const missing = Object.entries(fields).filter(([, column]) => !(column in row)).map(([field, column]) => `${field} → ${column}`);
261
+ if (missing.length === 0) return {
262
+ name,
263
+ ok: true,
264
+ detail: `Mapped ${Object.keys(fields).length} field(s) from a real "${resolved.source.entity}" row.`
265
+ };
266
+ if (config.entities?.entities?.[entityKey]?.projection !== void 0 && missing.length === Object.keys(fields).length) return {
267
+ name,
268
+ ok: false,
269
+ detail: `Row from "${resolved.source.entity}" carries none of the projection's columns (${missing.join(", ")}) — the source performs no joins, so the projection is declared but never executed. Build it with \`fromDrizzleProjection\` from \`clivly/drizzle\`, which runs the joins and returns rows keyed by ref token.`
270
+ };
257
271
  return {
258
272
  name,
259
- ok: missing.length === 0,
260
- detail: missing.length === 0 ? `Mapped ${Object.keys(fields).length} field(s) from a real "${resolved.source.entity}" row.` : `Row from "${resolved.source.entity}" is missing mapped column(s): ${missing.join(", ")}.`
273
+ ok: false,
274
+ detail: `Row from "${resolved.source.entity}" is missing mapped column(s): ${missing.join(", ")}.`
261
275
  };
262
276
  }
263
277
  //#endregion
@@ -466,9 +480,27 @@ async function mappingDryRunChecks(config) {
466
480
  return checks;
467
481
  }
468
482
  /**
483
+ * Why a source has no `DRIZZLE_META`, without asserting more than we know.
484
+ *
485
+ * There are two ways to get here and they are indistinguishable at runtime: the
486
+ * source really was hand-written, or it came from `fromDrizzle` and a spread
487
+ * stripped the metadata, which is non-enumerable. The second is easy to fall
488
+ * into precisely where `objectType` is present — `source.custom` is typed
489
+ * `Array<SyncSource & { objectType: string }>`, so attaching that property with
490
+ * `{ ...fromDrizzle(...), objectType }` used to be the only way to satisfy it.
491
+ *
492
+ * Flatly calling such a source "hand-written" sent developers looking for one
493
+ * that does not exist. Where `objectType` is present, name the likelier cause
494
+ * and the fix instead of guessing wrong with confidence.
495
+ */
496
+ function missingMetaDetail(source) {
497
+ const skipped = "column check skipped";
498
+ if (source.objectType === void 0) return `"${source.entity}" is a hand-written source — ${skipped}.`;
499
+ return `"${source.entity}" has no drizzle metadata — ${skipped}. If it came from \`fromDrizzle\`, a spread such as \`{ ...fromDrizzle(...), objectType }\` dropped it; pass \`objectType\` to \`fromDrizzle\` instead.`;
500
+ }
501
+ /**
469
502
  * Confirm each drizzle source actually has the cursor and id columns the sync
470
- * pages on. Hand-written sources have no column metadata, so they are skipped
471
- * rather than failed.
503
+ * pages on. Sources with no column metadata are skipped rather than failed.
472
504
  */
473
505
  function cursorColumnChecks(sources) {
474
506
  const checks = [];
@@ -479,7 +511,7 @@ function cursorColumnChecks(sources) {
479
511
  name: `Cursor/id columns exist (${source.entity})`,
480
512
  ok: false,
481
513
  skipped: true,
482
- detail: `"${source.entity}" is a hand-written source — column check skipped.`
514
+ detail: missingMetaDetail(source)
483
515
  });
484
516
  continue;
485
517
  }
@@ -1118,11 +1150,16 @@ function observedTriggerUrl(req) {
1118
1150
  return new URL(`${proto}://${host}${requestUrl.pathname}`).toString();
1119
1151
  } catch {}
1120
1152
  }
1153
+ const PLACEHOLDER_BRACKETS = /[<>]/;
1154
+ function isPlaceholderUrl(value) {
1155
+ return PLACEHOLDER_BRACKETS.test(value);
1156
+ }
1121
1157
  /**
1122
1158
  * Decide which trigger URL to report, in precedence order:
1123
1159
  *
1124
1160
  * 1. `configuredUrl` — an explicit `syncTrigger.url`. Someone typed this on
1125
- * purpose, so it beats anything inferred.
1161
+ * purpose, so it beats anything inferred. An unedited placeholder is not
1162
+ * "on purpose" and is skipped.
1126
1163
  * 2. platform env origin + `configuredPath` — available at boot, before any
1127
1164
  * request has arrived. Needs the path because platform variables only give
1128
1165
  * an origin.
@@ -1132,7 +1169,7 @@ function observedTriggerUrl(req) {
1132
1169
  * doesn't need to know about it.
1133
1170
  */
1134
1171
  function resolveTriggerUrl(args) {
1135
- if (args.configuredUrl) return args.configuredUrl;
1172
+ if (args.configuredUrl && !isPlaceholderUrl(args.configuredUrl)) return args.configuredUrl;
1136
1173
  const origin = originFromPlatformEnv(args.env);
1137
1174
  if (origin && args.configuredPath) return `${origin}${args.configuredPath.startsWith("/") ? args.configuredPath : `/${args.configuredPath}`}`;
1138
1175
  return args.observedUrl;
@@ -1701,6 +1738,7 @@ function createClivlySDK(config) {
1701
1738
  reason: "ok",
1702
1739
  retryable: false,
1703
1740
  org: body?.org,
1741
+ workspace: body?.workspace ?? null,
1704
1742
  ..."appLastSeenAt" in body ? { appLastSeenAt: body.appLastSeenAt ?? null } : {}
1705
1743
  };
1706
1744
  }
@@ -1872,4 +1910,4 @@ function createClivlySDK(config) {
1872
1910
  };
1873
1911
  }
1874
1912
  //#endregion
1875
- export { ClivlyAuthError, ClivlyError, ClivlyNetworkError, ClivlyRateLimitError, createChatSessionHandler, createClivlySDK, customObjectDeclarations, defineClivlyConfig, validateEntitiesConfig };
1913
+ export { ClivlyAuthError, ClivlyError, ClivlyNetworkError, ClivlyRateLimitError, createChatSessionHandler, createClivlySDK, customObjectDeclarations, defineClivlyConfig, isPlaceholderUrl, validateEntitiesConfig };
@@ -1,4 +1,4 @@
1
- import { ClivlyEntitiesConfig } from "@clivly/core";
1
+ import { ClivlyEntitiesConfig, IntegrationState } from "@clivly/core";
2
2
  //#region src/drizzle-meta.d.ts
3
3
  declare const DRIZZLE_META: "__clivly_drizzle_source_meta__";
4
4
  interface DrizzleSourceMeta {
@@ -309,6 +309,12 @@ interface ClivlyConnectResult {
309
309
  retryable: boolean;
310
310
  /** HTTP status, or null when the request never completed (network error). */
311
311
  status: number | null;
312
+ /**
313
+ * The workspace's setup ladder, computed by the server. Present only on
314
+ * `ping()`, and null when the server is older than this field or could not
315
+ * read it — which the CLI renders as "unavailable", never as "not set up".
316
+ */
317
+ workspace?: IntegrationState | null;
312
318
  }
313
319
  interface SyncCursor {
314
320
  /** The row's id, breaking ties when timestamps collide. */
@@ -325,6 +331,14 @@ interface SyncSource {
325
331
  }): Promise<Record<string, unknown>[]>;
326
332
  /** Stable, unique tie-breaker column for keyset paging. Defaults to "id". */
327
333
  idField?: string;
334
+ /**
335
+ * Custom-object slug this source mirrors into. Only meaningful for entries in
336
+ * `source.custom`, whose element type narrows it to required — declared here
337
+ * because the sync and readiness paths read `source.objectType` off a plain
338
+ * `SyncSource`, so leaving it off left the property required by one type,
339
+ * absent from another, and read by the runtime.
340
+ */
341
+ objectType?: string;
328
342
  /** Set by `fromDrizzle`; lets `doctor` introspect the backing table. */
329
343
  [DRIZZLE_META]?: DrizzleSourceMeta;
330
344
  }
@@ -1,4 +1,4 @@
1
- import { ClivlyEntitiesConfig } from "@clivly/core";
1
+ import { ClivlyEntitiesConfig, IntegrationState } from "@clivly/core";
2
2
  //#region src/drizzle-meta.d.ts
3
3
  declare const DRIZZLE_META: "__clivly_drizzle_source_meta__";
4
4
  interface DrizzleSourceMeta {
@@ -309,6 +309,12 @@ interface ClivlyConnectResult {
309
309
  retryable: boolean;
310
310
  /** HTTP status, or null when the request never completed (network error). */
311
311
  status: number | null;
312
+ /**
313
+ * The workspace's setup ladder, computed by the server. Present only on
314
+ * `ping()`, and null when the server is older than this field or could not
315
+ * read it — which the CLI renders as "unavailable", never as "not set up".
316
+ */
317
+ workspace?: IntegrationState | null;
312
318
  }
313
319
  interface SyncCursor {
314
320
  /** The row's id, breaking ties when timestamps collide. */
@@ -325,6 +331,14 @@ interface SyncSource {
325
331
  }): Promise<Record<string, unknown>[]>;
326
332
  /** Stable, unique tie-breaker column for keyset paging. Defaults to "id". */
327
333
  idField?: string;
334
+ /**
335
+ * Custom-object slug this source mirrors into. Only meaningful for entries in
336
+ * `source.custom`, whose element type narrows it to required — declared here
337
+ * because the sync and readiness paths read `source.objectType` off a plain
338
+ * `SyncSource`, so leaving it off left the property required by one type,
339
+ * absent from another, and read by the runtime.
340
+ */
341
+ objectType?: string;
328
342
  /** Set by `fromDrizzle`; lets `doctor` introspect the backing table. */
329
343
  [DRIZZLE_META]?: DrizzleSourceMeta;
330
344
  }
package/dist/vite.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as ClivlySDK } from "./types-DxG4rHyo.cjs";
1
+ import { a as ClivlySDK } from "./types-Dz1LQYvQ.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-DxG4rHyo.mjs";
1
+ import { a as ClivlySDK } from "./types-Dz1LQYvQ.mjs";
2
2
  import { Plugin } from "vite";
3
3
  //#region src/vite.d.ts
4
4
  interface ClivlyViteOptions {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clivly/sdk",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Clivly SDK — connect your app to Clivly Cloud (heartbeat, discovery, auth bridge).",
@@ -55,7 +55,7 @@
55
55
  "access": "public"
56
56
  },
57
57
  "dependencies": {
58
- "@clivly/core": "0.9.2"
58
+ "@clivly/core": "0.9.4"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "drizzle-orm": ">=0.30 || >=1.0.0-rc.0",