@clivly/sdk 0.9.5 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -330,6 +330,19 @@ import { drizzleIntrospector } from "clivly/drizzle";
330
330
  createClivlySDK({ /* … */, introspector: drizzleIntrospector(db) });
331
331
  ```
332
332
 
333
+ Setting `introspector` also lets Clivly's dashboard **preview a derived object**
334
+ before you save it. Clivly never connects to your database, so your app runs
335
+ that query — read-only, capped at 50 rows, through the sync-trigger route you
336
+ already expose. Without it the dashboard says preview is unavailable and names
337
+ this setting rather than failing.
338
+
339
+ **From `0.9.7` the same setting is what runs derived objects, not just previews
340
+ them.** An object authored in Clivly's dashboard starts syncing on your app's
341
+ next run, with no per-object entry in `clivly.config.ts` — `introspector` is the
342
+ one line that makes it possible, and `npx clivly init` now scaffolds it. Without
343
+ it, dashboard-authored objects are fetched and then skipped; nothing errors, and
344
+ `clivly status` reports it. See [Derived objects](#derived-objects).
345
+
333
346
  ## Common errors and troubleshooting
334
347
 
335
348
  | Symptom | Cause | Fix |
@@ -468,6 +481,7 @@ is honoured. Other `4xx` responses return immediately.
468
481
  | `createClivlyHandler()` | `(Request) => Promise<Response>` | Mount at a route |
469
482
  | `createAuthVerifyHandler(resolveUser)` | `(Request) => Promise<Response>` | `503` unless `verifyToken` is set |
470
483
  | `doctor()` | `Promise<DoctorReport>` | `{ ok, checks }` |
484
+ | `describeSyncTrigger()` | `SyncTriggerDescription` | `{ configured, resolved }` — the raw `syncTrigger.url` and the URL that would actually be reported. Computed per call: `resolved` can be `undefined` until the mounted route has served its first request |
471
485
  | `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 |
472
486
  | `ensureCustomObjects()` | `Promise<{ slug, id, created }[] \| null>` | Object types only, no mappings. Superseded by `ensureSchema()` |
473
487
 
@@ -512,27 +526,41 @@ rejected key and a dead network all mean the question went unanswered.
512
526
 
513
527
  A custom object can be derived from a base table plus explicit joins, instead of
514
528
  mapping exactly one table — an `Enrollment` built from `enrollments` joined to
515
- `students` and `courses`. You declare one on an entity in `clivly.config.ts`,
516
- alongside the object it materializes into; `validateEntitiesConfig` checks it in
517
- CI.
529
+ `students` and `courses`.
530
+
531
+ There are two ways to author one, and from `0.9.7` the first needs no code:
532
+
533
+ - **In Clivly's dashboard.** It starts syncing on your app's next run. The only
534
+ requirement is `introspector: drizzleIntrospector(db)` in your config, which
535
+ `npx clivly init` scaffolds — Clivly never connects to your database, so your
536
+ app needs a handle to run the query with. Each sync fetches your org's active
537
+ objects, **revalidates every one against the schema your app itself
538
+ discovered** before compiling any SQL, runs them, and pushes the rows. That
539
+ revalidation is what confines a dashboard-authored object to the tables you
540
+ passed to `discoverFromDrizzle`, and nothing wider.
541
+ - **In `clivly.config.ts`.** Declare the projection on an entity alongside the
542
+ object it materializes into; `validateEntitiesConfig` checks it in CI. Useful
543
+ when you want the definition reviewed in your repo.
518
544
 
519
- Two things have to line up:
545
+ Two things have to line up either way:
520
546
 
521
547
  - **Discovery must be rich enough.** `discoverFromDrizzle` sends primary keys,
522
548
  unique constraints, nullability, column types and foreign-key edges.
523
549
  Validation is fail-closed, so if your config hand-writes `schema` as
524
- `{ name, columns }` pairs, every table is rejected with *"has no key/column
525
- metadata — upgrade `@clivly/sdk` and re-run discovery"*. Pass
526
- `discoverFromDrizzle(schema)` and redeploy so a fresh heartbeat carries the
527
- richer payload — and re-run it after schema changes, since projections
528
- validate against the last reported snapshot.
550
+ `{ name, columns }` pairs, every table is rejected with *"was discovered
551
+ without column types or unique keys"*. Pass `discoverFromDrizzle(schema)` and
552
+ redeploy so a fresh heartbeat carries the richer payload — and re-run it after
553
+ schema changes, since projections validate against the last reported snapshot.
529
554
  - **The server must have the capability enabled.** Authoring sits behind
530
555
  `CLIVLY_PROJECTION_MAPPINGS=true` while the surface settles. Reading and
531
556
  syncing an existing derived object is never gated.
532
557
 
533
- > Requires `clivly@0.9.0` / `@clivly/sdk@0.9.0` or later for the discovery
534
- > metadata. Earlier versions reported names only, so no host app could author a
535
- > derived object.
558
+ > **Requires `0.9.7` or later.** Every earlier version — `0.9.0` included, despite
559
+ > what this file used to claim — dropped the unique-key half of the discovery
560
+ > payload on the heartbeat, so validation failed closed and *no* host app could
561
+ > author a derived object at any version. `0.9.7` fixes that and is also the
562
+ > first release where a dashboard-authored object syncs without a per-object
563
+ > config entry.
536
564
 
537
565
  Full guide: [`docs/guides/derived-objects.md`](../../docs/guides/derived-objects.md).
538
566
 
@@ -1,4 +1,4 @@
1
- import { c as CreateChatSessionHandlerOptions } from "./types-Dz1LQYvQ.cjs";
1
+ import { c as CreateChatSessionHandlerOptions } from "./types-B7HKG2Ph.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-Dz1LQYvQ.mjs";
1
+ import { c as CreateChatSessionHandlerOptions } from "./types-B7HKG2Ph.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
@@ -1,8 +1,8 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_drizzle_meta = require("./drizzle-meta-CdCs30q3.cjs");
3
+ let _clivly_core = require("@clivly/core");
3
4
  let _clivly_core_drizzle = require("@clivly/core/drizzle");
4
5
  let drizzle_orm = require("drizzle-orm");
5
- let _clivly_core = require("@clivly/core");
6
6
  //#region src/drizzle-projection.ts
7
7
  /** `<ref>.<column>` — the token `fields` carries and cloud-ingest reads. */
8
8
  function refToken(ref) {
@@ -39,10 +39,12 @@ function fromDrizzleProjection(db, projection, opts) {
39
39
  const client = db;
40
40
  const known = /* @__PURE__ */ new Set([_clivly_core.BASE_REF, ...projection.joins.map((j) => j.key)]);
41
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
42
  for (const [field, binding] of Object.entries(projection.fieldBindings)) assertKnownRef(binding.from, known, `field "${field}"`);
45
43
  const identityExpr = (0, _clivly_core.compileProjectionIdentity)(projection, opts.basePkColumn ?? "");
44
+ const fromClause = [(0, _clivly_core.compileProjectionFrom)(projection), ...(0, _clivly_core.compileProjectionJoins)(projection)].join(" ");
45
+ if (opts.cursorField === void 0) return fromDrizzleProjectionByIdentity(client, projection, identityExpr, fromClause, opts);
46
+ const cursorRef = parseRef(opts.cursorField);
47
+ assertKnownRef(cursorRef, known, "cursorField");
46
48
  const cursorToken = refToken(cursorRef);
47
49
  const cursorExpr = `CAST(${refSql(cursorRef)} AS timestamptz)`;
48
50
  const cursorRead = `to_char(${cursorExpr} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
@@ -54,11 +56,7 @@ function fromDrizzleProjection(db, projection, opts) {
54
56
  emitted.add(token);
55
57
  selectItems.push(`${refSql(binding.from)} AS ${(0, _clivly_core.quoteIdent)(token)}`);
56
58
  }
57
- const head = [
58
- `select ${selectItems.join(", ")}`,
59
- (0, _clivly_core.compileProjectionFrom)(projection),
60
- ...(0, _clivly_core.compileProjectionJoins)(projection)
61
- ].join(" ");
59
+ const head = [`select ${selectItems.join(", ")}`, fromClause].join(" ");
62
60
  return {
63
61
  entity: opts.entity,
64
62
  cursorField: cursorToken,
@@ -71,6 +69,49 @@ function fromDrizzleProjection(db, projection, opts) {
71
69
  }
72
70
  };
73
71
  }
72
+ /**
73
+ * The identity-only paging path `fromDrizzleProjection` takes when
74
+ * `cursorField` is omitted. Deliberately not a generalisation of the
75
+ * timestamp path above — it selects no timestamp cursor, pages on the
76
+ * identity expression alone, and always reports `cursorField`/`idField` as
77
+ * `"id"`, the alias every row carries either way.
78
+ */
79
+ function fromDrizzleProjectionByIdentity(client, projection, identityExpr, fromClause, opts) {
80
+ const selectItems = [`${identityExpr} AS ${(0, _clivly_core.quoteIdent)("id")}`];
81
+ const emitted = /* @__PURE__ */ new Set();
82
+ for (const binding of Object.values(projection.fieldBindings)) {
83
+ const token = refToken(binding.from);
84
+ if (emitted.has(token)) continue;
85
+ emitted.add(token);
86
+ selectItems.push(`${refSql(binding.from)} AS ${(0, _clivly_core.quoteIdent)(token)}`);
87
+ }
88
+ const head = [`select ${selectItems.join(", ")}`, fromClause].join(" ");
89
+ return {
90
+ entity: opts.entity,
91
+ cursorField: "id",
92
+ idField: "id",
93
+ ...opts.objectType === void 0 ? {} : { objectType: opts.objectType },
94
+ fetchPage: async ({ since, limit }) => {
95
+ const where = since ? drizzle_orm.sql` where ${drizzle_orm.sql.raw(identityExpr)} > ${since.id}` : drizzle_orm.sql``;
96
+ const query = drizzle_orm.sql`${drizzle_orm.sql.raw(head)}${where} order by ${drizzle_orm.sql.raw(identityExpr)} asc limit ${limit}`;
97
+ return normalizeRows(await client.execute(query));
98
+ }
99
+ };
100
+ }
101
+ //#endregion
102
+ //#region src/projection-preview.ts
103
+ /** `db.execute` returns either an array or `{ rows }`, depending on driver. */
104
+ function rowsOf(result) {
105
+ if (Array.isArray(result)) return result;
106
+ const rows = result.rows;
107
+ if (Array.isArray(rows)) return rows;
108
+ throw new Error("previewProjection: db.execute() returned neither an array of rows nor { rows }.");
109
+ }
110
+ async function previewProjection(db, projection, opts) {
111
+ const client = db;
112
+ const { sql } = await import("drizzle-orm");
113
+ return rowsOf(await client.execute(sql.raw((0, _clivly_core.compileProjectionPreview)(projection, opts.limit))));
114
+ }
74
115
  //#endregion
75
116
  //#region src/drizzle.ts
76
117
  function inferIdField(table) {
@@ -134,6 +175,16 @@ function drizzleIntrospector(db) {
134
175
  },
135
176
  async sampleSelect(table) {
136
177
  await client.execute(drizzle_orm.sql`select 1 from ${drizzle_orm.sql.identifier(table)} limit 1`);
178
+ },
179
+ runProjection(projection, opts) {
180
+ return previewProjection(db, projection, opts);
181
+ },
182
+ projectionSource(projection, opts) {
183
+ return fromDrizzleProjection(db, projection, {
184
+ entity: opts.objectType,
185
+ objectType: opts.objectType,
186
+ ...opts.basePkColumn ? { basePkColumn: opts.basePkColumn } : {}
187
+ });
137
188
  }
138
189
  };
139
190
  }
@@ -1,4 +1,4 @@
1
- import { _ as NamespaceIntrospector, g as SyncSource } from "./types-Dz1LQYvQ.cjs";
1
+ import { _ as SyncSource, y as NamespaceIntrospector } from "./types-B7HKG2Ph.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";
@@ -37,8 +37,14 @@ interface FromDrizzleProjectionOptions {
37
37
  * The column to keyset-paginate on, as a ref token (`"base.updated_at"`) or
38
38
  * a `ProjectionRef`. A projection spans several tables, so there is no
39
39
  * single table to infer a cursor from.
40
+ *
41
+ * Omit it to page by identity alone — the mode a dashboard-authored
42
+ * projection uses, since it has no timestamp column and no UI to nominate
43
+ * one. `checkIdentitySafety` (`@clivly/core`) already proves the identity
44
+ * uniquely identifies a row, which makes it a sound keyset cursor with no
45
+ * timestamp involved.
40
46
  */
41
- cursorField: string | ProjectionRef;
47
+ cursorField?: string | ProjectionRef;
42
48
  /** The source name rows are pushed under. */
43
49
  entity: string;
44
50
  /**
@@ -1,7 +1,7 @@
1
- import { _ as NamespaceIntrospector, g as SyncSource } from "./types-Dz1LQYvQ.mjs";
1
+ import { _ as SyncSource, y as NamespaceIntrospector } from "./types-B7HKG2Ph.mjs";
2
+ import { ProjectionMapping, ProjectionRef } from "@clivly/core";
2
3
  import { buildUniqueConstraints, discoverFromDrizzle } from "@clivly/core/drizzle";
3
4
  import { Table } from "drizzle-orm";
4
- import { ProjectionMapping, ProjectionRef } from "@clivly/core";
5
5
  //#region src/drizzle-projection.d.ts
6
6
  /**
7
7
  * Build a `SyncSource` that actually executes a projection's joins.
@@ -37,8 +37,14 @@ interface FromDrizzleProjectionOptions {
37
37
  * The column to keyset-paginate on, as a ref token (`"base.updated_at"`) or
38
38
  * a `ProjectionRef`. A projection spans several tables, so there is no
39
39
  * single table to infer a cursor from.
40
+ *
41
+ * Omit it to page by identity alone — the mode a dashboard-authored
42
+ * projection uses, since it has no timestamp column and no UI to nominate
43
+ * one. `checkIdentitySafety` (`@clivly/core`) already proves the identity
44
+ * uniquely identifies a row, which makes it a sound keyset cursor with no
45
+ * timestamp involved.
40
46
  */
41
- cursorField: string | ProjectionRef;
47
+ cursorField?: string | ProjectionRef;
42
48
  /** The source name rows are pushed under. */
43
49
  entity: string;
44
50
  /**
package/dist/drizzle.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { t as DRIZZLE_META } from "./drizzle-meta-DNqzkREf.mjs";
2
+ import { BASE_REF, compileProjectionFrom, compileProjectionIdentity, compileProjectionJoins, compileProjectionPreview, quoteIdent } from "@clivly/core";
2
3
  import { buildColumnsMeta, buildUniqueConstraints, buildUniqueConstraints as buildUniqueConstraints$1, discoverFromDrizzle } from "@clivly/core/drizzle";
3
4
  import { asc, getTableColumns, getTableName, sql } from "drizzle-orm";
4
- import { BASE_REF, compileProjectionFrom, compileProjectionIdentity, compileProjectionJoins, quoteIdent } from "@clivly/core";
5
5
  //#region src/drizzle-projection.ts
6
6
  /** `<ref>.<column>` — the token `fields` carries and cloud-ingest reads. */
7
7
  function refToken(ref) {
@@ -38,10 +38,12 @@ function fromDrizzleProjection(db, projection, opts) {
38
38
  const client = db;
39
39
  const known = /* @__PURE__ */ new Set([BASE_REF, ...projection.joins.map((j) => j.key)]);
40
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
41
  for (const [field, binding] of Object.entries(projection.fieldBindings)) assertKnownRef(binding.from, known, `field "${field}"`);
44
42
  const identityExpr = compileProjectionIdentity(projection, opts.basePkColumn ?? "");
43
+ const fromClause = [compileProjectionFrom(projection), ...compileProjectionJoins(projection)].join(" ");
44
+ if (opts.cursorField === void 0) return fromDrizzleProjectionByIdentity(client, projection, identityExpr, fromClause, opts);
45
+ const cursorRef = parseRef(opts.cursorField);
46
+ assertKnownRef(cursorRef, known, "cursorField");
45
47
  const cursorToken = refToken(cursorRef);
46
48
  const cursorExpr = `CAST(${refSql(cursorRef)} AS timestamptz)`;
47
49
  const cursorRead = `to_char(${cursorExpr} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
@@ -53,11 +55,7 @@ function fromDrizzleProjection(db, projection, opts) {
53
55
  emitted.add(token);
54
56
  selectItems.push(`${refSql(binding.from)} AS ${quoteIdent(token)}`);
55
57
  }
56
- const head = [
57
- `select ${selectItems.join(", ")}`,
58
- compileProjectionFrom(projection),
59
- ...compileProjectionJoins(projection)
60
- ].join(" ");
58
+ const head = [`select ${selectItems.join(", ")}`, fromClause].join(" ");
61
59
  return {
62
60
  entity: opts.entity,
63
61
  cursorField: cursorToken,
@@ -70,6 +68,49 @@ function fromDrizzleProjection(db, projection, opts) {
70
68
  }
71
69
  };
72
70
  }
71
+ /**
72
+ * The identity-only paging path `fromDrizzleProjection` takes when
73
+ * `cursorField` is omitted. Deliberately not a generalisation of the
74
+ * timestamp path above — it selects no timestamp cursor, pages on the
75
+ * identity expression alone, and always reports `cursorField`/`idField` as
76
+ * `"id"`, the alias every row carries either way.
77
+ */
78
+ function fromDrizzleProjectionByIdentity(client, projection, identityExpr, fromClause, opts) {
79
+ const selectItems = [`${identityExpr} AS ${quoteIdent("id")}`];
80
+ const emitted = /* @__PURE__ */ new Set();
81
+ for (const binding of Object.values(projection.fieldBindings)) {
82
+ const token = refToken(binding.from);
83
+ if (emitted.has(token)) continue;
84
+ emitted.add(token);
85
+ selectItems.push(`${refSql(binding.from)} AS ${quoteIdent(token)}`);
86
+ }
87
+ const head = [`select ${selectItems.join(", ")}`, fromClause].join(" ");
88
+ return {
89
+ entity: opts.entity,
90
+ cursorField: "id",
91
+ idField: "id",
92
+ ...opts.objectType === void 0 ? {} : { objectType: opts.objectType },
93
+ fetchPage: async ({ since, limit }) => {
94
+ const where = since ? sql` where ${sql.raw(identityExpr)} > ${since.id}` : sql``;
95
+ const query = sql`${sql.raw(head)}${where} order by ${sql.raw(identityExpr)} asc limit ${limit}`;
96
+ return normalizeRows(await client.execute(query));
97
+ }
98
+ };
99
+ }
100
+ //#endregion
101
+ //#region src/projection-preview.ts
102
+ /** `db.execute` returns either an array or `{ rows }`, depending on driver. */
103
+ function rowsOf(result) {
104
+ if (Array.isArray(result)) return result;
105
+ const rows = result.rows;
106
+ if (Array.isArray(rows)) return rows;
107
+ throw new Error("previewProjection: db.execute() returned neither an array of rows nor { rows }.");
108
+ }
109
+ async function previewProjection(db, projection, opts) {
110
+ const client = db;
111
+ const { sql } = await import("drizzle-orm");
112
+ return rowsOf(await client.execute(sql.raw(compileProjectionPreview(projection, opts.limit))));
113
+ }
73
114
  //#endregion
74
115
  //#region src/drizzle.ts
75
116
  function inferIdField(table) {
@@ -133,6 +174,16 @@ function drizzleIntrospector(db) {
133
174
  },
134
175
  async sampleSelect(table) {
135
176
  await client.execute(sql`select 1 from ${sql.identifier(table)} limit 1`);
177
+ },
178
+ runProjection(projection, opts) {
179
+ return previewProjection(db, projection, opts);
180
+ },
181
+ projectionSource(projection, opts) {
182
+ return fromDrizzleProjection(db, projection, {
183
+ entity: opts.objectType,
184
+ objectType: opts.objectType,
185
+ ...opts.basePkColumn ? { basePkColumn: opts.basePkColumn } : {}
186
+ });
136
187
  }
137
188
  };
138
189
  }