turbine-orm 0.15.0 → 0.18.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.
Files changed (54) hide show
  1. package/README.md +180 -12
  2. package/dist/adapters/cockroachdb.js +4 -2
  3. package/dist/adapters/index.js +4 -1
  4. package/dist/adapters/yugabytedb.js +4 -2
  5. package/dist/cjs/adapters/cockroachdb.js +4 -2
  6. package/dist/cjs/adapters/index.js +4 -1
  7. package/dist/cjs/adapters/yugabytedb.js +4 -2
  8. package/dist/cjs/cli/index.js +64 -0
  9. package/dist/cjs/cli/observe-ui.js +182 -0
  10. package/dist/cjs/cli/observe.js +242 -0
  11. package/dist/cjs/cli/studio.js +5 -1
  12. package/dist/cjs/client.js +218 -0
  13. package/dist/cjs/errors.js +35 -5
  14. package/dist/cjs/generate.js +14 -3
  15. package/dist/cjs/index.js +10 -2
  16. package/dist/cjs/introspect.js +81 -0
  17. package/dist/cjs/nested-write.js +164 -10
  18. package/dist/cjs/observe.js +145 -0
  19. package/dist/cjs/query/builder.js +604 -25
  20. package/dist/cjs/realtime.js +147 -0
  21. package/dist/cjs/schema-builder.js +86 -0
  22. package/dist/cjs/schema.js +10 -0
  23. package/dist/cjs/typed-sql.js +149 -0
  24. package/dist/cli/index.d.ts +1 -0
  25. package/dist/cli/index.js +64 -0
  26. package/dist/cli/observe-ui.d.ts +2 -0
  27. package/dist/cli/observe-ui.js +180 -0
  28. package/dist/cli/observe.d.ts +20 -0
  29. package/dist/cli/observe.js +237 -0
  30. package/dist/cli/studio.js +5 -1
  31. package/dist/client.d.ts +129 -2
  32. package/dist/client.js +220 -2
  33. package/dist/errors.js +35 -5
  34. package/dist/generate.js +14 -3
  35. package/dist/index.d.ts +5 -2
  36. package/dist/index.js +5 -1
  37. package/dist/introspect.js +81 -0
  38. package/dist/nested-write.d.ts +2 -2
  39. package/dist/nested-write.js +164 -10
  40. package/dist/observe.d.ts +36 -0
  41. package/dist/observe.js +141 -0
  42. package/dist/query/builder.d.ts +121 -1
  43. package/dist/query/builder.js +605 -26
  44. package/dist/query/index.d.ts +2 -2
  45. package/dist/query/types.d.ts +126 -2
  46. package/dist/realtime.d.ts +71 -0
  47. package/dist/realtime.js +144 -0
  48. package/dist/schema-builder.d.ts +68 -1
  49. package/dist/schema-builder.js +85 -0
  50. package/dist/schema.d.ts +18 -1
  51. package/dist/schema.js +10 -0
  52. package/dist/typed-sql.d.ts +101 -0
  53. package/dist/typed-sql.js +145 -0
  54. package/package.json +18 -16
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ /**
3
+ * turbine-orm — LISTEN/NOTIFY realtime pub/sub
4
+ *
5
+ * Postgres LISTEN/NOTIFY is a first-class realtime primitive that neither
6
+ * Prisma nor Drizzle expose ergonomically. This module backs the thin
7
+ * `$listen` / `$notify` methods on TurbineClient.
8
+ *
9
+ * Design — **one dedicated connection per subscription**:
10
+ *
11
+ * Each `$listen(channel, handler)` acquires its OWN long-lived client from
12
+ * the pool, runs `LISTEN "chan"`, and keeps that connection checked out for
13
+ * the life of the subscription. This is the simplest correct model: each
14
+ * subscription owns its lifecycle, `unsubscribe()` cleanly UNLISTENs and
15
+ * releases exactly one connection, and there is no shared multiplexing
16
+ * state to reason about. The trade-off is one pool slot per active channel
17
+ * — for the handful of channels a typical app listens on, that's a fine
18
+ * price for clarity. (A future optimization could multiplex many channels
19
+ * over a single shared notification connection.)
20
+ *
21
+ * Serverless / HTTP-pool caveat:
22
+ *
23
+ * LISTEN requires a *persistent* TCP connection that can push asynchronous
24
+ * notification messages back to the client. Stateless HTTP drivers
25
+ * (Neon HTTP, Vercel Postgres over fetch) cannot hold such a connection, so
26
+ * `$listen` will surface a clear error rather than hang. `$notify` works
27
+ * everywhere — it's a single round-trip `SELECT pg_notify(...)`.
28
+ */
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.validateChannel = validateChannel;
31
+ exports.createSubscription = createSubscription;
32
+ const errors_js_1 = require("./errors.js");
33
+ // ---------------------------------------------------------------------------
34
+ // Identifier validation
35
+ // ---------------------------------------------------------------------------
36
+ /**
37
+ * Strict Postgres identifier: a letter or underscore followed by letters,
38
+ * digits, or underscores. Channel names CANNOT be parameterized in
39
+ * LISTEN/UNLISTEN (`LISTEN $1` is a syntax error), so the channel is the one
40
+ * place an identifier is interpolated into SQL — it MUST pass this regex AND
41
+ * go through `quoteIdent` before reaching the SQL string.
42
+ */
43
+ const CHANNEL_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/;
44
+ /** Postgres NAMEDATALEN caps identifiers at 63 bytes. */
45
+ const MAX_CHANNEL_LEN = 63;
46
+ /**
47
+ * Validate a LISTEN/NOTIFY channel name. Throws ValidationError on anything
48
+ * that isn't a plain, reasonable-length SQL identifier. This is enforced for
49
+ * BOTH `$listen` (where the channel is interpolated) and `$notify` (where the
50
+ * channel is a bound param) — defensive parity, and it catches user typos
51
+ * loudly.
52
+ */
53
+ function validateChannel(channel) {
54
+ if (typeof channel !== 'string' || channel.length === 0) {
55
+ throw new errors_js_1.ValidationError('[turbine] $listen/$notify channel must be a non-empty string');
56
+ }
57
+ if (channel.length > MAX_CHANNEL_LEN) {
58
+ throw new errors_js_1.ValidationError(`[turbine] $listen/$notify channel "${channel}" exceeds the ${MAX_CHANNEL_LEN}-character Postgres identifier limit`);
59
+ }
60
+ if (!CHANNEL_REGEX.test(channel)) {
61
+ throw new errors_js_1.ValidationError(`[turbine] Invalid $listen/$notify channel "${channel}" — must match /^[A-Za-z_][A-Za-z0-9_]*$/ ` +
62
+ '(letters, digits, underscores; cannot start with a digit)');
63
+ }
64
+ }
65
+ /**
66
+ * Acquire a dedicated connection, run `LISTEN "channel"`, and wire the handler.
67
+ *
68
+ * @param pool the pg-compatible pool to check a long-lived client out of
69
+ * @param channel channel name — MUST already be validated by the caller
70
+ * @param quotedChannel the channel run through quoteIdent (interpolated into SQL)
71
+ * @param handler called with each notification's payload
72
+ * @param onClosed invoked when the subscription releases, so the client can
73
+ * drop it from its active-subscription registry
74
+ */
75
+ async function createSubscription(pool, channel, quotedChannel, handler, onClosed) {
76
+ let client;
77
+ try {
78
+ client = (await pool.connect());
79
+ }
80
+ catch (err) {
81
+ throw (0, errors_js_1.wrapPgError)(err);
82
+ }
83
+ // Verify the checked-out client can actually receive async notifications.
84
+ // Stateless HTTP drivers return a client with no `.on` — LISTEN would hang
85
+ // forever waiting for messages that can never arrive, so fail loudly now and
86
+ // give the connection straight back.
87
+ if (typeof client.on !== 'function') {
88
+ client.release?.();
89
+ throw new errors_js_1.ConnectionError('[turbine] $listen requires a persistent connection that can push notifications. ' +
90
+ 'The configured pool returned a client with no event support (stateless HTTP drivers ' +
91
+ 'like Neon HTTP / Vercel Postgres cannot LISTEN). Use a TCP pg.Pool for LISTEN/NOTIFY.');
92
+ }
93
+ const onNotification = (msg) => {
94
+ // pg delivers ALL notifications for the connection to every listener; a
95
+ // dedicated connection only ever LISTENs on one channel, but guard anyway.
96
+ if (msg.channel === channel) {
97
+ handler(msg.payload ?? '');
98
+ }
99
+ };
100
+ try {
101
+ client.on('notification', onNotification);
102
+ await client.query(`LISTEN ${quotedChannel}`);
103
+ }
104
+ catch (err) {
105
+ client.removeListener?.('notification', onNotification);
106
+ client.release?.();
107
+ throw (0, errors_js_1.wrapPgError)(err);
108
+ }
109
+ let closed = false;
110
+ const sub = {
111
+ channel,
112
+ async unsubscribe() {
113
+ if (closed)
114
+ return;
115
+ closed = true;
116
+ try {
117
+ await client.query(`UNLISTEN ${quotedChannel}`);
118
+ }
119
+ catch (err) {
120
+ // Best-effort: the connection may already be dead. Still detach +
121
+ // release below so we don't leak the pool slot.
122
+ client.removeListener?.('notification', onNotification);
123
+ client.release?.();
124
+ onClosed(sub);
125
+ throw (0, errors_js_1.wrapPgError)(err);
126
+ }
127
+ client.removeListener?.('notification', onNotification);
128
+ client.release?.();
129
+ onClosed(sub);
130
+ },
131
+ _forceRelease() {
132
+ if (closed)
133
+ return;
134
+ closed = true;
135
+ client.removeListener?.('notification', onNotification);
136
+ // Destroy the connection (release(true)) rather than return it to the pool:
137
+ // we skip UNLISTEN here (the pool is being torn down), so a recycled
138
+ // connection would otherwise carry a stale LISTEN registration. Destroying
139
+ // it guarantees no pooled backend keeps receiving NOTIFY traffic. Matters
140
+ // most for external/serverless pools, where disconnect() is a no-op and the
141
+ // pool outlives this client.
142
+ client.release?.(true);
143
+ onClosed(sub);
144
+ },
145
+ };
146
+ return sub;
147
+ }
@@ -27,6 +27,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
27
27
  exports.camelToSnake = exports.column = exports.ColumnBuilder = void 0;
28
28
  exports.defineSchema = defineSchema;
29
29
  exports.table = table;
30
+ exports.applyManyToManyRelations = applyManyToManyRelations;
30
31
  /** Maps shorthand names to actual Postgres type strings */
31
32
  const TYPE_MAP = {
32
33
  serial: 'BIGSERIAL',
@@ -102,7 +103,17 @@ function defineSchema(input) {
102
103
  const raw = value;
103
104
  const columns = {};
104
105
  let pk;
106
+ let m2m;
105
107
  for (const [fieldName, def] of Object.entries(raw)) {
108
+ if (fieldName === 'manyToMany') {
109
+ if (def !== undefined) {
110
+ if (!Array.isArray(def)) {
111
+ throw new Error(`Table "${accessor}": "manyToMany" must be an array of relation declarations`);
112
+ }
113
+ m2m = def;
114
+ }
115
+ continue;
116
+ }
106
117
  if (fieldName === 'primaryKey') {
107
118
  // Top-level composite primary key declaration
108
119
  if (def !== undefined) {
@@ -142,6 +153,7 @@ function defineSchema(input) {
142
153
  accessor,
143
154
  columns,
144
155
  ...(pk && pk.length > 0 ? { primaryKey: pk } : {}),
156
+ ...(m2m && m2m.length > 0 ? { manyToMany: m2m } : {}),
145
157
  };
146
158
  }
147
159
  }
@@ -303,6 +315,80 @@ function table(columns) {
303
315
  return { name: '', accessor: '', columns: built };
304
316
  }
305
317
  // ---------------------------------------------------------------------------
318
+ // Explicit many-to-many: merge declared relations into introspected metadata
319
+ // ---------------------------------------------------------------------------
320
+ /**
321
+ * Merge the explicit `manyToMany` declarations from a code-first {@link SchemaDef}
322
+ * into an introspected {@link SchemaMetadata}, returning a new metadata object
323
+ * with the `manyToMany` {@link RelationDef}s added.
324
+ *
325
+ * This is the runtime bridge for the code-first m2m API: `defineSchema` only
326
+ * produces DDL, so after `introspect()`ing the live database you call this to
327
+ * attach the m2m relations you declared. It is PURELY ADDITIVE — existing
328
+ * belongsTo/hasMany/hasOne relations are preserved, and a declared relation is
329
+ * skipped (not overwritten) if its name already exists on the source table.
330
+ *
331
+ * @example
332
+ * ```ts
333
+ * const def = defineSchema({
334
+ * posts: { id: { type: 'serial', primaryKey: true },
335
+ * manyToMany: [{ name: 'tags', target: 'tags', through: 'postsTags',
336
+ * sourceKey: 'postId', targetKey: 'tagId' }] },
337
+ * tags: { id: { type: 'serial', primaryKey: true } },
338
+ * postsTags: { postId: { type: 'integer', references: 'posts.id' },
339
+ * tagId: { type: 'integer', references: 'tags.id' },
340
+ * primaryKey: ['postId', 'tagId'] },
341
+ * });
342
+ * let meta = await introspect({ connectionString });
343
+ * meta = applyManyToManyRelations(meta, def);
344
+ * ```
345
+ */
346
+ function applyManyToManyRelations(meta, def) {
347
+ // Map accessor (camelCase key) → DDL snake_case table name.
348
+ const accessorToTable = new Map();
349
+ for (const [accessor, t] of Object.entries(def.tables)) {
350
+ accessorToTable.set(accessor, t.name);
351
+ }
352
+ const resolveTable = (accessor) => accessorToTable.get(accessor) ?? camelToSnakeLocal(accessor);
353
+ const resolveCols = (k) => {
354
+ if (Array.isArray(k))
355
+ return k.map(camelToSnakeLocal);
356
+ return camelToSnakeLocal(k);
357
+ };
358
+ // Deep-ish clone of the tables we touch so the input metadata is not mutated.
359
+ const tables = { ...meta.tables };
360
+ for (const tableDef of Object.values(def.tables)) {
361
+ if (!tableDef.manyToMany || tableDef.manyToMany.length === 0)
362
+ continue;
363
+ const sourceTable = tableDef.name;
364
+ const sourceMeta = tables[sourceTable];
365
+ if (!sourceMeta)
366
+ continue; // table not present in introspected metadata — skip
367
+ const relations = { ...sourceMeta.relations };
368
+ for (const m of tableDef.manyToMany) {
369
+ // Additive-only: never clobber an existing relation name.
370
+ if (relations[m.name])
371
+ continue;
372
+ const ref = m.references ?? 'id';
373
+ relations[m.name] = {
374
+ type: 'manyToMany',
375
+ name: m.name,
376
+ from: sourceTable,
377
+ to: resolveTable(m.target),
378
+ referenceKey: resolveCols(ref),
379
+ foreignKey: resolveCols(ref),
380
+ through: {
381
+ table: resolveTable(m.through),
382
+ sourceKey: resolveCols(m.sourceKey),
383
+ targetKey: resolveCols(m.targetKey),
384
+ },
385
+ };
386
+ }
387
+ tables[sourceTable] = { ...sourceMeta, relations };
388
+ }
389
+ return { ...meta, tables };
390
+ }
391
+ // ---------------------------------------------------------------------------
306
392
  // Helpers
307
393
  // ---------------------------------------------------------------------------
308
394
  var schema_js_1 = require("./schema.js");
@@ -76,6 +76,16 @@ const PG_TO_TS = {
76
76
  // TSVector
77
77
  tsvector: 'string',
78
78
  tsquery: 'string',
79
+ // pgvector — embeddings. Mapped to `number[]` for DX (the natural shape an app
80
+ // passes when inserting / comparing embeddings). NOTE: like `numeric` above,
81
+ // there is a runtime caveat — pg has no built-in parser for the `vector` type,
82
+ // so over the wire a fetched vector arrives as a string literal like
83
+ // '[1,2,3]' unless the app registers its own parser (e.g. via pgvector's
84
+ // `registerType`). Turbine never auto-registers one (no side-effecting type
85
+ // parsers outside the client constructor). The query-side helpers (KNN
86
+ // orderBy, distance WHERE) always bind the query vector as a `$n::vector`
87
+ // param, so writing/comparing is unaffected by the read-side caveat.
88
+ vector: 'number[]',
79
89
  };
80
90
  const DATE_TYPES = new Set(['timestamptz', 'timestamp', 'date']);
81
91
  const PG_TO_ARRAY = {
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+ /**
3
+ * turbine-orm — Typed raw SQL (Turbine's answer to Prisma's TypedSQL)
4
+ *
5
+ * `client.raw()` returns untyped rows. This module adds a *typed* escape hatch:
6
+ * a generic tagged template where the caller supplies the row shape, and the
7
+ * builder yields a typed result that can be awaited as an array of rows, or
8
+ * narrowed to a single row (`.one()`) or a single scalar value (`.scalar()`).
9
+ *
10
+ * Design goals & guarantees:
11
+ *
12
+ * 1. **Compile-time only types.** `T` is supplied by the caller and never
13
+ * validated at runtime — exactly like Prisma's TypedSQL and the existing
14
+ * `raw<T>()`. Postgres still returns whatever the SQL selects; the generic
15
+ * is a convenience for autocomplete and downstream type-checking.
16
+ *
17
+ * 2. **Mandatory parameterization.** Only the *static* string segments of the
18
+ * template literal ever reach the SQL text. Every interpolated `${value}`
19
+ * becomes a `$N` placeholder and is passed in the params array — it is
20
+ * impossible to string-concatenate a value into the query through this API.
21
+ * This is the whole point of the tagged-template shape: the literal segments
22
+ * are frozen by the compiler (`TemplateStringsArray`), and the only way to
23
+ * get a runtime value into the query is via `${...}`, which we bind.
24
+ *
25
+ * 3. **Rows are returned as-is (no snake→camel mapping).** This matches the
26
+ * existing `client.raw()` behavior: a typed raw query is a literal escape
27
+ * hatch, so the result columns are whatever your `SELECT` names them. Alias
28
+ * columns in SQL (`SELECT created_at AS "createdAt"`) if you want camelCase.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * // Awaited directly -> rows
33
+ * const rows = await db.sql<{ id: number; name: string }>`
34
+ * SELECT id, name FROM users WHERE org_id = ${orgId}
35
+ * `;
36
+ * // ^? { id: number; name: string }[]
37
+ *
38
+ * // .one() -> single row or null
39
+ * const user = await db.sql<{ id: number; name: string }>`
40
+ * SELECT id, name FROM users WHERE id = ${userId}
41
+ * `.one();
42
+ * // ^? { id: number; name: string } | null
43
+ *
44
+ * // .scalar() -> first column of first row, or null
45
+ * const total = await db.sql<{ count: number }>`
46
+ * SELECT COUNT(*)::int AS count FROM users WHERE org_id = ${orgId}
47
+ * `.scalar();
48
+ * // ^? number | null
49
+ * ```
50
+ */
51
+ Object.defineProperty(exports, "__esModule", { value: true });
52
+ exports.TypedSqlQuery = void 0;
53
+ exports.buildTypedSql = buildTypedSql;
54
+ const errors_js_1 = require("./errors.js");
55
+ /**
56
+ * Build a `(sql, params)` pair from a tagged-template invocation.
57
+ *
58
+ * Each interpolated value is replaced by a positional `$N` placeholder and
59
+ * pushed to the params array in order. The static string segments are the only
60
+ * thing concatenated into the SQL text. This is the single point that
61
+ * guarantees parameterization for the entire typed-SQL surface.
62
+ *
63
+ * Exported for unit testing the parameterization invariant without a database.
64
+ */
65
+ function buildTypedSql(strings, values) {
66
+ // The tagged-template API guarantees `strings.length === values.length + 1`.
67
+ // Guard the directly-callable (exported) surface so a mismatched call can't
68
+ // silently desync `$N` placeholders from params (pg would reject it, but fail
69
+ // loudly here instead).
70
+ if (strings.length !== values.length + 1) {
71
+ throw new errors_js_1.ValidationError(`[turbine] sql template segment/value count mismatch: ${strings.length} segments, ${values.length} values.`);
72
+ }
73
+ let sql = '';
74
+ for (let i = 0; i < strings.length; i++) {
75
+ sql += strings[i];
76
+ if (i < values.length) {
77
+ sql += `$${i + 1}`;
78
+ }
79
+ }
80
+ return { sql, params: values.slice() };
81
+ }
82
+ /**
83
+ * A pending typed raw SQL query. Implements the thenable contract, so it can be
84
+ * `await`ed directly to get `T[]`, or refined via `.one()` / `.scalar()` first.
85
+ *
86
+ * The query is executed lazily and exactly once per terminal call (`then`,
87
+ * `one`, `scalar`). Each terminal method runs the query independently — this is
88
+ * an escape hatch, not a cached query object, so don't call two terminals on
89
+ * the same builder expecting a single round-trip; build a fresh template each
90
+ * time (the common pattern is `await db.sql\`...\`` inline).
91
+ */
92
+ class TypedSqlQuery {
93
+ pool;
94
+ sql;
95
+ params;
96
+ logging;
97
+ constructor(pool, sql, params, logging) {
98
+ this.pool = pool;
99
+ this.sql = sql;
100
+ this.params = params;
101
+ this.logging = logging;
102
+ }
103
+ /** Execute and return all rows. Internal; powers `then`, `one`, and `scalar`. */
104
+ async run() {
105
+ if (this.logging) {
106
+ console.log(`[turbine] Typed SQL: ${this.sql.trim().substring(0, 120)}...`);
107
+ }
108
+ try {
109
+ const result = await this.pool.query(this.sql, this.params);
110
+ return result.rows;
111
+ }
112
+ catch (err) {
113
+ throw (0, errors_js_1.wrapPgError)(err);
114
+ }
115
+ }
116
+ /**
117
+ * PromiseLike implementation: `await db.sql<T>\`...\`` resolves to `T[]`.
118
+ */
119
+ // biome-ignore lint/suspicious/noThenProperty: intentional thenable — this IS the PromiseLike contract that makes `await db.sql\`...\`` resolve to rows
120
+ then(onfulfilled, onrejected) {
121
+ return this.run().then(onfulfilled, onrejected);
122
+ }
123
+ /**
124
+ * Execute and return the first row, or `null` if the query returns no rows.
125
+ * Use for queries you expect to match at most one row.
126
+ */
127
+ async one() {
128
+ const rows = await this.run();
129
+ return rows.length > 0 ? rows[0] : null;
130
+ }
131
+ /**
132
+ * Execute and return the first column of the first row, or `null` if there
133
+ * are no rows. Useful for `SELECT COUNT(*)`, `SELECT EXISTS(...)`, etc.
134
+ *
135
+ * The generic `V` defaults to the value type of `T`'s first property, but you
136
+ * can override it: `db.sql<{ count: number }>\`...\`.scalar<number>()`.
137
+ */
138
+ async scalar() {
139
+ const rows = await this.run();
140
+ if (rows.length === 0)
141
+ return null;
142
+ const first = rows[0];
143
+ const keys = Object.keys(first);
144
+ if (keys.length === 0)
145
+ return null;
146
+ return first[keys[0]];
147
+ }
148
+ }
149
+ exports.TypedSqlQuery = TypedSqlQuery;
@@ -13,6 +13,7 @@
13
13
  * turbine seed — Run seed file
14
14
  * turbine status — Show schema summary
15
15
  * turbine studio — Launch local read-only web UI
16
+ * turbine observe — Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
16
17
  *
17
18
  * Usage:
18
19
  * DATABASE_URL=postgres://... npx turbine generate
package/dist/cli/index.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * turbine seed — Run seed file
14
14
  * turbine status — Show schema summary
15
15
  * turbine studio — Launch local read-only web UI
16
+ * turbine observe — Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
16
17
  *
17
18
  * Usage:
18
19
  * DATABASE_URL=postgres://... npx turbine generate
@@ -28,6 +29,7 @@ import { schemaDiff, schemaPush } from '../schema-sql.js';
28
29
  import { configTemplate, findConfigFile, loadConfig, resolveConfig } from './config.js';
29
30
  import { needsTsLoader, registerTsLoader } from './loader.js';
30
31
  import { createMigration, listMigrationFiles, migrateDown, migrateStatus, migrateUp } from './migrate.js';
32
+ import { startObserve } from './observe.js';
31
33
  import { startStudio } from './studio.js';
32
34
  import { banner, blue, bold, box, cyan, dim, divider, elapsed, error, table as formatTable, gray, green, header, info, label, magenta, newline, red, redactUrl, Spinner, success, symbols, warn, yellow, } from './ui.js';
33
35
  function parseArgs() {
@@ -970,6 +972,65 @@ async function cmdStudio(args, config) {
970
972
  });
971
973
  }
972
974
  // ---------------------------------------------------------------------------
975
+ // Command: observe
976
+ // ---------------------------------------------------------------------------
977
+ async function cmdObserve(args) {
978
+ banner();
979
+ const url = process.env.TURBINE_OBSERVE_URL;
980
+ if (!url) {
981
+ error('TURBINE_OBSERVE_URL environment variable is required for the observe command.');
982
+ newline();
983
+ console.log(` ${dim('Set it to the Postgres connection string where metrics are stored.')}`);
984
+ console.log(` ${dim('Example:')} ${cyan('TURBINE_OBSERVE_URL=postgres://... npx turbine observe')}`);
985
+ newline();
986
+ process.exit(1);
987
+ }
988
+ const port = args.port ?? 4984;
989
+ const host = args.host ?? '127.0.0.1';
990
+ const openBrowser = !args.noOpen;
991
+ if (!Number.isFinite(port) || port <= 0 || port > 65535) {
992
+ console.log(red(`✗ invalid port: ${args.port}`));
993
+ process.exit(1);
994
+ }
995
+ if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1') {
996
+ console.log(warn(`Observe is binding to ${yellow(host)} — this is NOT loopback. ` +
997
+ `Anyone on your network who can reach this port + guess the session token can read your metrics.`));
998
+ }
999
+ const spinner = new Spinner('Connecting to metrics database').start();
1000
+ let handle;
1001
+ try {
1002
+ handle = await startObserve({ url, port, host, openBrowser });
1003
+ spinner.succeed('Observe dashboard is running');
1004
+ }
1005
+ catch (err) {
1006
+ spinner.fail(`Failed to start Observe: ${err instanceof Error ? err.message : String(err)}`);
1007
+ process.exit(1);
1008
+ }
1009
+ newline();
1010
+ console.log(box([
1011
+ `${bold('Turbine Observe')} ${dim('— query metrics dashboard')}`,
1012
+ '',
1013
+ ` ${cyan('URL:')} ${bold(handle.url)}`,
1014
+ '',
1015
+ dim('Open the URL above in your browser. Press Ctrl+C to stop.'),
1016
+ ].join('\n'), { title: bold(cyan('Observe')), padding: 1 }));
1017
+ newline();
1018
+ await new Promise((resolve) => {
1019
+ const shutdown = async () => {
1020
+ console.log(dim('\n shutting down…'));
1021
+ try {
1022
+ await handle.dispose();
1023
+ }
1024
+ catch {
1025
+ /* ignore */
1026
+ }
1027
+ resolve();
1028
+ };
1029
+ process.once('SIGINT', shutdown);
1030
+ process.once('SIGTERM', shutdown);
1031
+ });
1032
+ }
1033
+ // ---------------------------------------------------------------------------
973
1034
  // Subcommand help
974
1035
  // ---------------------------------------------------------------------------
975
1036
  function showSubcommandHelp(command) {
@@ -1253,6 +1314,9 @@ async function main() {
1253
1314
  case 'studio':
1254
1315
  await cmdStudio(args, config);
1255
1316
  break;
1317
+ case 'observe':
1318
+ await cmdObserve(args);
1319
+ break;
1256
1320
  default:
1257
1321
  error(`Unknown command: ${bold(args.command)}`);
1258
1322
  newline();
@@ -0,0 +1,2 @@
1
+ export declare const OBSERVE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"color-scheme\" content=\"dark\" />\n <title>Turbine Observe</title>\n <style>\n :root {\n --bg: #0a0a0b;\n --bg-elev: #111113;\n --bg-hover: #1a1a1d;\n --border: #26262b;\n --text: #e6e6ea;\n --text-dim: #8a8a93;\n --accent: #60a5fa;\n --green: #4ade80;\n --red: #f87171;\n --orange: #fb923c;\n --purple: #a78bfa;\n --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;\n --sans: system-ui, -apple-system, sans-serif;\n --radius: 6px;\n }\n * { margin: 0; padding: 0; box-sizing: border-box; }\n body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; padding: 24px; }\n h1 { font-size: 20px; margin-bottom: 4px; }\n .subtitle { color: var(--text-dim); margin-bottom: 24px; }\n .controls { display: flex; gap: 8px; margin-bottom: 24px; }\n .controls button {\n background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);\n color: var(--text); padding: 6px 12px; cursor: pointer; font-size: 13px;\n }\n .controls button.active { border-color: var(--accent); color: var(--accent); }\n .card {\n background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);\n padding: 16px; margin-bottom: 16px;\n }\n .card h2 { font-size: 14px; color: var(--text-dim); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }\n table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }\n th { text-align: left; padding: 6px 8px; color: var(--text-dim); border-bottom: 1px solid var(--border); }\n td { padding: 6px 8px; border-bottom: 1px solid var(--border); }\n .num { text-align: right; }\n .error-rate { color: var(--red); }\n .low-error { color: var(--green); }\n svg { width: 100%; height: 200px; }\n .chart-line { fill: none; stroke-width: 1.5; }\n .line-avg { stroke: var(--accent); }\n .line-p95 { stroke: var(--orange); }\n .line-p99 { stroke: var(--red); }\n .legend { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: var(--text-dim); }\n .legend span::before { content: ''; display: inline-block; width: 12px; height: 2px; margin-right: 4px; vertical-align: middle; }\n .legend .l-avg::before { background: var(--accent); }\n .legend .l-p95::before { background: var(--orange); }\n .legend .l-p99::before { background: var(--red); }\n .empty { color: var(--text-dim); text-align: center; padding: 40px; }\n </style>\n</head>\n<body>\n <h1>Turbine Observe</h1>\n <p class=\"subtitle\">Query performance metrics</p>\n <div class=\"controls\">\n <button data-range=\"1h\" class=\"active\">1h</button>\n <button data-range=\"6h\">6h</button>\n <button data-range=\"24h\">24h</button>\n <button data-range=\"7d\">7d</button>\n </div>\n <div class=\"card\" id=\"latency-card\">\n <h2>Latency over time</h2>\n <div id=\"chart\"></div>\n <div class=\"legend\">\n <span class=\"l-avg\">avg</span>\n <span class=\"l-p95\">p95</span>\n <span class=\"l-p99\">p99</span>\n </div>\n </div>\n <div class=\"card\" id=\"models-card\">\n <h2>Top models</h2>\n <div id=\"models-table\"></div>\n </div>\n <div class=\"card\" id=\"errors-card\">\n <h2>Error rates</h2>\n <div id=\"errors-table\"></div>\n </div>\n <script>\n let currentRange = '1h';\n const token = document.cookie.match(/turbine_observe_token=([a-f0-9]+)/)?.[1] || '';\n const headers = { 'x-turbine-token': token };\n\n document.querySelector('.controls').addEventListener('click', e => {\n if (e.target.tagName !== 'BUTTON') return;\n document.querySelectorAll('.controls button').forEach(b => b.classList.remove('active'));\n e.target.classList.add('active');\n currentRange = e.target.dataset.range;\n refresh();\n });\n\n async function fetchJson(path) {\n const res = await fetch(path, { headers });\n if (!res.ok) return null;\n return res.json();\n }\n\n function buildSvgPath(points, width, height, maxY) {\n if (points.length === 0) return '';\n const xStep = width / Math.max(points.length - 1, 1);\n return points.map((y, i) => {\n const px = i * xStep;\n const py = height - (y / maxY) * height;\n return (i === 0 ? 'M' : 'L') + px.toFixed(1) + ',' + py.toFixed(1);\n }).join(' ');\n }\n\n function renderChart(data) {\n const el = document.getElementById('chart');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No data yet</p>'; return; }\n const width = 800; const height = 180;\n const allVals = data.flatMap(d => [d.avg_ms, d.p95_ms, d.p99_ms]);\n const maxY = Math.max(...allVals, 1) * 1.1;\n const avgPath = buildSvgPath(data.map(d => d.avg_ms), width, height, maxY);\n const p95Path = buildSvgPath(data.map(d => d.p95_ms), width, height, maxY);\n const p99Path = buildSvgPath(data.map(d => d.p99_ms), width, height, maxY);\n el.innerHTML = '<svg viewBox=\"0 0 ' + width + ' ' + height + '\" preserveAspectRatio=\"none\">'\n + '<path class=\"chart-line line-avg\" d=\"' + avgPath + '\"/>'\n + '<path class=\"chart-line line-p95\" d=\"' + p95Path + '\"/>'\n + '<path class=\"chart-line line-p99\" d=\"' + p99Path + '\"/>'\n + '</svg>';\n }\n\n function renderModels(data) {\n const el = document.getElementById('models-table');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No data yet</p>'; return; }\n let html = '<table><thead><tr><th>Model</th><th>Action</th><th class=\"num\">Count</th><th class=\"num\">Avg (ms)</th><th class=\"num\">P95 (ms)</th><th class=\"num\">P99 (ms)</th></tr></thead><tbody>';\n for (const row of data) {\n html += '<tr><td>' + row.model + '</td><td>' + row.action + '</td>'\n + '<td class=\"num\">' + row.count + '</td>'\n + '<td class=\"num\">' + row.avg_ms.toFixed(1) + '</td>'\n + '<td class=\"num\">' + row.p95_ms.toFixed(1) + '</td>'\n + '<td class=\"num\">' + row.p99_ms.toFixed(1) + '</td></tr>';\n }\n html += '</tbody></table>';\n el.innerHTML = html;\n }\n\n function renderErrors(data) {\n const el = document.getElementById('errors-table');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No errors</p>'; return; }\n let html = '<table><thead><tr><th>Model</th><th>Action</th><th class=\"num\">Total</th><th class=\"num\">Errors</th><th class=\"num\">Rate</th></tr></thead><tbody>';\n for (const row of data) {\n const rate = row.count > 0 ? (row.error_count / row.count * 100).toFixed(1) : '0.0';\n const cls = parseFloat(rate) > 5 ? 'error-rate' : 'low-error';\n html += '<tr><td>' + row.model + '</td><td>' + row.action + '</td>'\n + '<td class=\"num\">' + row.count + '</td>'\n + '<td class=\"num\">' + row.error_count + '</td>'\n + '<td class=\"num ' + cls + '\">' + rate + '%</td></tr>';\n }\n html += '</tbody></table>';\n el.innerHTML = html;\n }\n\n async function refresh() {\n const [latency, models] = await Promise.all([\n fetchJson('/api/latency?range=' + currentRange),\n fetchJson('/api/models?range=' + currentRange),\n ]);\n renderChart(latency);\n renderModels(models);\n // Derive errors from models data\n const withErrors = (models || []).filter(m => m.error_count > 0);\n renderErrors(withErrors);\n }\n\n refresh();\n setInterval(refresh, 60000);\n </script>\n</body>\n</html>";
2
+ //# sourceMappingURL=observe-ui.d.ts.map