@clivly/sdk 0.9.6 → 0.10.0

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
@@ -336,6 +336,13 @@ that query — read-only, capped at 50 rows, through the sync-trigger route you
336
336
  already expose. Without it the dashboard says preview is unavailable and names
337
337
  this setting rather than failing.
338
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
+
339
346
  ## Common errors and troubleshooting
340
347
 
341
348
  | Symptom | Cause | Fix |
@@ -453,7 +460,7 @@ is honoured. Other `4xx` responses return immediately.
453
460
  | `retry.maxAttempts` / `retry.baseDelayMs` | `number` | `3` / `500` | |
454
461
  | `syncTrigger.path` / `.url` / `.secret` | `string` | — | See above |
455
462
  | `verifyToken` | `string` | — | For `createAuthVerifyHandler`. **Nothing calls that endpoint today** |
456
- | `introspector` | `NamespaceIntrospector` | — | `drizzleIntrospector(db)`. Used only by `doctor()` |
463
+ | `introspector` | `NamespaceIntrospector` | — | `drizzleIntrospector(db)`. Used by `doctor()`, derived-object previews, and dashboard-authored derived-object sync |
457
464
  | `namespaceReady` | `boolean` | `false` | Self-reported; no live check |
458
465
  | `env` | `Record<string, string \| undefined>` | `process.env` | For trigger-URL discovery in non-Node runtimes |
459
466
  | `framework` / `orm` / `sdkVersion` | `string` | — | Reported for dashboard display |
@@ -519,27 +526,41 @@ rejected key and a dead network all mean the question went unanswered.
519
526
 
520
527
  A custom object can be derived from a base table plus explicit joins, instead of
521
528
  mapping exactly one table — an `Enrollment` built from `enrollments` joined to
522
- `students` and `courses`. You declare one on an entity in `clivly.config.ts`,
523
- alongside the object it materializes into; `validateEntitiesConfig` checks it in
524
- 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.
525
544
 
526
- Two things have to line up:
545
+ Two things have to line up either way:
527
546
 
528
547
  - **Discovery must be rich enough.** `discoverFromDrizzle` sends primary keys,
529
548
  unique constraints, nullability, column types and foreign-key edges.
530
549
  Validation is fail-closed, so if your config hand-writes `schema` as
531
- `{ name, columns }` pairs, every table is rejected with *"has no key/column
532
- metadata — upgrade `@clivly/sdk` and re-run discovery"*. Pass
533
- `discoverFromDrizzle(schema)` and redeploy so a fresh heartbeat carries the
534
- richer payload — and re-run it after schema changes, since projections
535
- 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.
536
554
  - **The server must have the capability enabled.** Authoring sits behind
537
555
  `CLIVLY_PROJECTION_MAPPINGS=true` while the surface settles. Reading and
538
556
  syncing an existing derived object is never gated.
539
557
 
540
- > Requires `clivly@0.9.0` / `@clivly/sdk@0.9.0` or later for the discovery
541
- > metadata. Earlier versions reported names only, so no host app could author a
542
- > 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.
543
564
 
544
565
  Full guide: [`docs/guides/derived-objects.md`](../../docs/guides/derived-objects.md).
545
566
 
@@ -1,4 +1,4 @@
1
- import { c as CreateChatSessionHandlerOptions } from "./types-CiFEalbP.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-CiFEalbP.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,22 +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
- //#region src/projection-preview.ts
7
- /** `db.execute` returns either an array or `{ rows }`, depending on driver. */
8
- function rowsOf(result) {
9
- if (Array.isArray(result)) return result;
10
- const rows = result.rows;
11
- if (Array.isArray(rows)) return rows;
12
- throw new Error("previewProjection: db.execute() returned neither an array of rows nor { rows }.");
13
- }
14
- async function previewProjection(db, projection, opts) {
15
- const client = db;
16
- const { sql } = await import("drizzle-orm");
17
- return rowsOf(await client.execute(sql.raw((0, _clivly_core.compileProjectionPreview)(projection, opts.limit))));
18
- }
19
- //#endregion
20
6
  //#region src/drizzle-projection.ts
21
7
  /** `<ref>.<column>` — the token `fields` carries and cloud-ingest reads. */
22
8
  function refToken(ref) {
@@ -53,10 +39,12 @@ function fromDrizzleProjection(db, projection, opts) {
53
39
  const client = db;
54
40
  const known = /* @__PURE__ */ new Set([_clivly_core.BASE_REF, ...projection.joins.map((j) => j.key)]);
55
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 }.`);
56
- const cursorRef = parseRef(opts.cursorField);
57
- assertKnownRef(cursorRef, known, "cursorField");
58
42
  for (const [field, binding] of Object.entries(projection.fieldBindings)) assertKnownRef(binding.from, known, `field "${field}"`);
59
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");
60
48
  const cursorToken = refToken(cursorRef);
61
49
  const cursorExpr = `CAST(${refSql(cursorRef)} AS timestamptz)`;
62
50
  const cursorRead = `to_char(${cursorExpr} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
@@ -68,11 +56,7 @@ function fromDrizzleProjection(db, projection, opts) {
68
56
  emitted.add(token);
69
57
  selectItems.push(`${refSql(binding.from)} AS ${(0, _clivly_core.quoteIdent)(token)}`);
70
58
  }
71
- const head = [
72
- `select ${selectItems.join(", ")}`,
73
- (0, _clivly_core.compileProjectionFrom)(projection),
74
- ...(0, _clivly_core.compileProjectionJoins)(projection)
75
- ].join(" ");
59
+ const head = [`select ${selectItems.join(", ")}`, fromClause].join(" ");
76
60
  return {
77
61
  entity: opts.entity,
78
62
  cursorField: cursorToken,
@@ -85,6 +69,49 @@ function fromDrizzleProjection(db, projection, opts) {
85
69
  }
86
70
  };
87
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
+ }
88
115
  //#endregion
89
116
  //#region src/drizzle.ts
90
117
  function inferIdField(table) {
@@ -151,6 +178,13 @@ function drizzleIntrospector(db) {
151
178
  },
152
179
  runProjection(projection, opts) {
153
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
+ });
154
188
  }
155
189
  };
156
190
  }
@@ -1,4 +1,4 @@
1
- import { g as SyncSource, v as NamespaceIntrospector } from "./types-CiFEalbP.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 { g as SyncSource, v as NamespaceIntrospector } from "./types-CiFEalbP.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,21 +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, compileProjectionPreview, quoteIdent } from "@clivly/core";
5
- //#region src/projection-preview.ts
6
- /** `db.execute` returns either an array or `{ rows }`, depending on driver. */
7
- function rowsOf(result) {
8
- if (Array.isArray(result)) return result;
9
- const rows = result.rows;
10
- if (Array.isArray(rows)) return rows;
11
- throw new Error("previewProjection: db.execute() returned neither an array of rows nor { rows }.");
12
- }
13
- async function previewProjection(db, projection, opts) {
14
- const client = db;
15
- const { sql } = await import("drizzle-orm");
16
- return rowsOf(await client.execute(sql.raw(compileProjectionPreview(projection, opts.limit))));
17
- }
18
- //#endregion
19
5
  //#region src/drizzle-projection.ts
20
6
  /** `<ref>.<column>` — the token `fields` carries and cloud-ingest reads. */
21
7
  function refToken(ref) {
@@ -52,10 +38,12 @@ function fromDrizzleProjection(db, projection, opts) {
52
38
  const client = db;
53
39
  const known = /* @__PURE__ */ new Set([BASE_REF, ...projection.joins.map((j) => j.key)]);
54
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 }.`);
55
- const cursorRef = parseRef(opts.cursorField);
56
- assertKnownRef(cursorRef, known, "cursorField");
57
41
  for (const [field, binding] of Object.entries(projection.fieldBindings)) assertKnownRef(binding.from, known, `field "${field}"`);
58
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");
59
47
  const cursorToken = refToken(cursorRef);
60
48
  const cursorExpr = `CAST(${refSql(cursorRef)} AS timestamptz)`;
61
49
  const cursorRead = `to_char(${cursorExpr} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
@@ -67,11 +55,7 @@ function fromDrizzleProjection(db, projection, opts) {
67
55
  emitted.add(token);
68
56
  selectItems.push(`${refSql(binding.from)} AS ${quoteIdent(token)}`);
69
57
  }
70
- const head = [
71
- `select ${selectItems.join(", ")}`,
72
- compileProjectionFrom(projection),
73
- ...compileProjectionJoins(projection)
74
- ].join(" ");
58
+ const head = [`select ${selectItems.join(", ")}`, fromClause].join(" ");
75
59
  return {
76
60
  entity: opts.entity,
77
61
  cursorField: cursorToken,
@@ -84,6 +68,49 @@ function fromDrizzleProjection(db, projection, opts) {
84
68
  }
85
69
  };
86
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
+ }
87
114
  //#endregion
88
115
  //#region src/drizzle.ts
89
116
  function inferIdField(table) {
@@ -150,6 +177,13 @@ function drizzleIntrospector(db) {
150
177
  },
151
178
  runProjection(projection, opts) {
152
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
+ });
153
187
  }
154
188
  };
155
189
  }