turbine-orm 0.36.0 → 0.37.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.
@@ -35,7 +35,8 @@
35
35
  * schema changes and bulk operations.
36
36
  */
37
37
  import { type IncomingMessage, type ServerResponse } from 'node:http';
38
- import pg from 'pg';
38
+ import type { PgCompatPool } from '../client.js';
39
+ import type { Dialect } from '../dialect.js';
39
40
  import type { SchemaMetadata, TableMetadata } from '../schema.js';
40
41
  export interface StudioOptions {
41
42
  url: string;
@@ -58,6 +59,13 @@ export interface StudioOptions {
58
59
  * Reveal PII-tagged column values instead of redacting them. Default `false`.
59
60
  */
60
61
  showPii?: boolean;
62
+ /**
63
+ * Boot with a seeded, in-memory sample database instead of connecting to a
64
+ * real one (no DATABASE_URL required). Backed by Turbine's own SQLite engine
65
+ * over `node:sqlite` `:memory:`; nothing is ever persisted. Enables the live
66
+ * three-mode switcher (`/api/demo/mode`). Default `false`.
67
+ */
68
+ demo?: boolean;
61
69
  }
62
70
  export interface StudioHandle {
63
71
  /** Shut down the server + pool cleanly. */
@@ -68,7 +76,14 @@ export interface StudioHandle {
68
76
  url: string;
69
77
  }
70
78
  export interface StudioContext {
71
- pool: pg.Pool;
79
+ /**
80
+ * The pg-compatible pool. A real `pg.Pool` in normal mode, a `SqlitePool`
81
+ * over an in-memory database in demo mode. Typed as the minimal
82
+ * `PgCompatPool` contract so both shapes work through one code path (pg.Pool
83
+ * satisfies it; the query builders take `pg.Pool` via a cast, matching the
84
+ * external-pool seam in client.ts).
85
+ */
86
+ pool: PgCompatPool;
72
87
  metadata: SchemaMetadata;
73
88
  options: StudioOptions;
74
89
  authToken: string;
@@ -85,11 +100,23 @@ export interface StudioContext {
85
100
  }>;
86
101
  /**
87
102
  * True when write mode is enabled (`--write`): the `/api/row/*` routes exist
88
- * and the UI renders write affordances. Absent/false → read-only.
103
+ * and the UI renders write affordances. Absent/false → read-only. In demo
104
+ * mode this is toggled live by the `/api/demo/mode` switcher.
89
105
  */
90
106
  writable?: boolean;
91
107
  /** True when PII redaction is disabled (`--show-pii`). Absent/false → redact. */
92
108
  showPii?: boolean;
109
+ /**
110
+ * True when Studio is running against the seeded in-memory demo store
111
+ * (`--demo`). Branches the handful of Postgres-specific statements onto their
112
+ * SQLite equivalents and enables the `/api/demo/mode` live switcher.
113
+ */
114
+ demo?: boolean;
115
+ /**
116
+ * SQL dialect the builder/write handlers compile against. Absent → Postgres
117
+ * (the default). Set to the SQLite dialect in demo mode.
118
+ */
119
+ dialect?: Dialect;
93
120
  }
94
121
  /**
95
122
  * Start the Studio server. Returns a handle with the session token, a pre-built
@@ -102,6 +129,7 @@ export interface StudioContext {
102
129
  */
103
130
  export declare function startStudio(options: StudioOptions): Promise<StudioHandle>;
104
131
  export declare function handleRequest(req: IncomingMessage, res: ServerResponse, ctx: StudioContext): Promise<void>;
132
+ export declare function apiDemoMode(req: IncomingMessage, res: ServerResponse, ctx: StudioContext): Promise<void>;
105
133
  export declare function apiTableRows(res: ServerResponse, ctx: StudioContext, rawTableName: string, params: URLSearchParams): Promise<void>;
106
134
  export declare function resolveColumnName(table: TableMetadata, nameOrField: string): string | null;
107
135
  export declare function isTextishType(pgType: string): boolean;
@@ -43,6 +43,7 @@ import { dirname, resolve as pathResolve } from 'node:path';
43
43
  import pg from 'pg';
44
44
  import { introspect } from '../introspect.js';
45
45
  import { QueryInterface, quoteIdent } from '../query/index.js';
46
+ import { createDemoContext } from './studio-demo.js';
46
47
  import { STUDIO_HTML } from './studio-ui.generated.js';
47
48
  // ---------------------------------------------------------------------------
48
49
  // Main entry point
@@ -57,35 +58,55 @@ import { STUDIO_HTML } from './studio-ui.generated.js';
57
58
  * process.on('SIGINT', () => studio.dispose().then(() => process.exit(0)));
58
59
  */
59
60
  export async function startStudio(options) {
60
- const pool = new pg.Pool({
61
- connectionString: options.url,
62
- max: 4, // small pool — single-user tool
63
- idleTimeoutMillis: 10_000,
64
- });
65
- // Verify connectivity before starting the server — fail fast.
66
- const probe = await pool.connect();
67
- try {
68
- await probe.query('SELECT 1');
61
+ const demo = options.demo === true;
62
+ let pool;
63
+ let metadata;
64
+ let dialect;
65
+ let statementTimeout;
66
+ if (demo) {
67
+ // Seeded in-memory SQLite store: no DATABASE_URL, no network. Each launch
68
+ // starts pristine and nothing is ever persisted.
69
+ const demoCtx = createDemoContext();
70
+ pool = demoCtx.pool;
71
+ metadata = demoCtx.metadata;
72
+ dialect = demoCtx.dialect;
73
+ // SQLite has no set_config / statement_timeout GUC; a harmless no-op keeps
74
+ // the shared execution path (which issues this before each query) uniform.
75
+ statementTimeout = { sql: 'SELECT 1', params: [] };
69
76
  }
70
- finally {
71
- probe.release();
77
+ else {
78
+ // pg.Pool satisfies the PgCompatPool contract (same as the external-pool
79
+ // seam in client.ts); the cast keeps one typed pool field for both modes.
80
+ pool = new pg.Pool({
81
+ connectionString: options.url,
82
+ max: 4, // small pool — single-user tool
83
+ idleTimeoutMillis: 10_000,
84
+ });
85
+ // Verify connectivity before starting the server — fail fast.
86
+ const probe = await pool.connect();
87
+ try {
88
+ await probe.query('SELECT 1');
89
+ }
90
+ finally {
91
+ probe.release();
92
+ }
93
+ metadata = await introspect({
94
+ connectionString: options.url,
95
+ schema: options.schema,
96
+ include: options.include,
97
+ exclude: options.exclude,
98
+ });
99
+ statementTimeout = options.adapter?.statementTimeout?.(30) ?? {
100
+ // Postgres rejects parameters in `SET LOCAL` (`SET LOCAL ... = $1` is a
101
+ // syntax error). `set_config(name, value, is_local=true)` is the
102
+ // parameterizable, transaction-local equivalent and works on every
103
+ // Postgres-compatible engine.
104
+ sql: `SELECT set_config('statement_timeout', $1, true)`,
105
+ params: ['30s'],
106
+ };
72
107
  }
73
- const metadata = await introspect({
74
- connectionString: options.url,
75
- schema: options.schema,
76
- include: options.include,
77
- exclude: options.exclude,
78
- });
79
108
  const authToken = randomBytes(24).toString('hex');
80
109
  const stateDir = pathResolve(options.stateDir ?? '.turbine');
81
- const statementTimeout = options.adapter?.statementTimeout?.(30) ?? {
82
- // Postgres rejects parameters in `SET LOCAL` (`SET LOCAL ... = $1` is a
83
- // syntax error). `set_config(name, value, is_local=true)` is the
84
- // parameterizable, transaction-local equivalent and works on every
85
- // Postgres-compatible engine.
86
- sql: `SELECT set_config('statement_timeout', $1, true)`,
87
- params: ['30s'],
88
- };
89
110
  const rateLimiter = new Map();
90
111
  const ctx = {
91
112
  pool,
@@ -95,8 +116,12 @@ export async function startStudio(options) {
95
116
  stateDir,
96
117
  statementTimeout,
97
118
  rateLimiter,
98
- writable: options.write === true,
99
- showPii: options.showPii === true,
119
+ // Demo always boots read-only + PII redacted; the in-UI switcher flips these
120
+ // live. Non-demo honors the CLI flags.
121
+ writable: demo ? false : options.write === true,
122
+ showPii: demo ? false : options.showPii === true,
123
+ demo,
124
+ dialect,
100
125
  };
101
126
  const server = createServer((req, res) => {
102
127
  handleRequest(req, res, ctx).catch((err) => {
@@ -229,9 +254,40 @@ export async function handleRequest(req, res, ctx) {
229
254
  if (op === 'delete')
230
255
  return apiRowWrite(req, res, ctx, 'delete');
231
256
  }
257
+ // Demo mode switcher: ONLY exists in demo mode (404 otherwise). Flips the live
258
+ // read-only / PII / write toggles on the in-memory store. State-changing, so it
259
+ // requires a matching Origin like the write routes.
260
+ if (ctx.demo && pathname === '/api/demo/mode' && req.method === 'POST') {
261
+ if (origin !== expectedOrigin) {
262
+ sendJson(res, 403, { error: 'a matching Origin header is required for mode changes' });
263
+ return;
264
+ }
265
+ return apiDemoMode(req, res, ctx);
266
+ }
232
267
  sendJson(res, 404, { error: 'not found' });
233
268
  }
234
269
  // ---------------------------------------------------------------------------
270
+ // API: /api/demo/mode: live mode switcher (demo mode only)
271
+ //
272
+ // Mutates the shared StudioContext so the change applies to every subsequent
273
+ // request: `writable` gates the (already-registered) `/api/row/*` routes and the
274
+ // UI's write affordances; `showPii` toggles server-side PII redaction. The two
275
+ // are independent toggles. The UI re-fetches `/api/schema` afterwards to re-read
276
+ // the effective state.
277
+ // ---------------------------------------------------------------------------
278
+ export async function apiDemoMode(req, res, ctx) {
279
+ const body = await readJsonBody(req);
280
+ if (typeof body.writable === 'boolean')
281
+ ctx.writable = body.writable;
282
+ if (typeof body.showPii === 'boolean')
283
+ ctx.showPii = body.showPii;
284
+ sendJson(res, 200, {
285
+ demo: true,
286
+ writable: ctx.writable === true,
287
+ showPii: ctx.showPii === true,
288
+ });
289
+ }
290
+ // ---------------------------------------------------------------------------
235
291
  // Auth
236
292
  // ---------------------------------------------------------------------------
237
293
  function isAuthorized(req, expectedToken) {
@@ -308,17 +364,28 @@ async function apiSchema(res, ctx) {
308
364
  referenceKey: rel.referenceKey,
309
365
  })),
310
366
  }));
311
- // Row counts cheap enough to fetch inline. Use pg_class reltuples as
312
- // a fast estimate so we don't hammer big tables with SELECT COUNT(*).
313
- const countsResult = await ctx.pool.query(`SELECT c.relname, c.reltuples::bigint::text AS reltuples
314
- FROM pg_class c
315
- JOIN pg_namespace n ON n.oid = c.relnamespace
316
- WHERE n.nspname = $1 AND c.relkind = 'r'`, [ctx.options.schema]);
367
+ // Row counts (cheap enough to fetch inline).
317
368
  const counts = new Map();
318
- for (const row of countsResult.rows) {
319
- // pg_class.reltuples is -1 on PG14+ until a table is ANALYZEd; clamp so the
320
- // sidebar never shows a negative estimate.
321
- counts.set(row.relname, Math.max(0, Number(row.reltuples)));
369
+ if (ctx.demo) {
370
+ // The demo dataset is tiny and in-memory: an exact per-table COUNT(*) is
371
+ // instant, and SQLite has no pg_class estimate to read.
372
+ for (const t of tables) {
373
+ const r = await ctx.pool.query(`SELECT COUNT(*) AS count FROM ${quoteIdent(t.name)}`);
374
+ counts.set(t.name, Number(r.rows[0]?.count ?? 0));
375
+ }
376
+ }
377
+ else {
378
+ // Use pg_class reltuples as a fast estimate so we don't hammer big tables
379
+ // with SELECT COUNT(*).
380
+ const countsResult = await ctx.pool.query(`SELECT c.relname, c.reltuples::bigint::text AS reltuples
381
+ FROM pg_class c
382
+ JOIN pg_namespace n ON n.oid = c.relnamespace
383
+ WHERE n.nspname = $1 AND c.relkind = 'r'`, [ctx.options.schema]);
384
+ for (const row of countsResult.rows) {
385
+ // pg_class.reltuples is -1 on PG14+ until a table is ANALYZEd; clamp so the
386
+ // sidebar never shows a negative estimate.
387
+ counts.set(row.relname, Math.max(0, Number(row.reltuples)));
388
+ }
322
389
  }
323
390
  sendJson(res, 200, {
324
391
  schema: ctx.options.schema,
@@ -328,6 +395,8 @@ async function apiSchema(res, ctx) {
328
395
  // Read-only Studio reports `writable: false` so the UI renders no write UI.
329
396
  writable: ctx.writable === true,
330
397
  showPii: ctx.showPii === true,
398
+ // Demo flag drives the in-UI mode switcher + persistent demo banner.
399
+ demo: ctx.demo === true,
331
400
  });
332
401
  }
333
402
  // ---------------------------------------------------------------------------
@@ -367,12 +436,21 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
367
436
  .map((c) => c.name);
368
437
  const hasSearch = search.length > 0 && textColumns.length > 0;
369
438
  const pattern = hasSearch ? `%${escapeLikePattern(search)}%` : null;
439
+ // Parameter placeholder + case-insensitive LIKE condition differ by engine.
440
+ // Postgres: numbered `$N` + `ILIKE`. Demo (SQLite): named `:pN` (bound by
441
+ // name from the positional value array, matching Turbine's own SQLite path)
442
+ // + `LOWER(col) LIKE LOWER(:pN)` (explicit case-fold, ASCII). The escape char
443
+ // (`\`) is identical. When demo is off these produce byte-identical SQL.
444
+ const ph = (n) => (ctx.demo ? `:p${n}` : `$${n}`);
445
+ const likeCond = (col, n) => ctx.demo
446
+ ? `LOWER(${quoteIdent(col)}) LIKE LOWER(${ph(n)}) ESCAPE '\\'`
447
+ : `${quoteIdent(col)} ILIKE ${ph(n)} ESCAPE '\\'`;
370
448
  // Main query: $1 = limit, $2 = offset, $3 = pattern (if search)
371
449
  const mainValues = [limit, offset];
372
450
  let mainWhere = '';
373
451
  if (hasSearch && pattern !== null) {
374
452
  mainValues.push(pattern);
375
- const conds = textColumns.map((c) => `${quoteIdent(c)} ILIKE $3 ESCAPE '\\'`);
453
+ const conds = textColumns.map((c) => likeCond(c, 3));
376
454
  mainWhere = `WHERE (${conds.join(' OR ')})`;
377
455
  }
378
456
  // Count query: $1 = pattern (if search)
@@ -380,22 +458,35 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
380
458
  let countWhere = '';
381
459
  if (hasSearch && pattern !== null) {
382
460
  countValues.push(pattern);
383
- const conds = textColumns.map((c) => `${quoteIdent(c)} ILIKE $1 ESCAPE '\\'`);
461
+ const conds = textColumns.map((c) => likeCond(c, 1));
384
462
  countWhere = `WHERE (${conds.join(' OR ')})`;
385
463
  }
386
- const qualifiedTable = `${quoteIdent(ctx.options.schema)}.${quoteIdent(table.name)}`;
387
- const sql = `SELECT * FROM ${qualifiedTable} ${mainWhere} ${orderByClause} LIMIT $1 OFFSET $2`;
388
- const countSql = `SELECT COUNT(*)::text AS count FROM ${qualifiedTable} ${countWhere}`;
464
+ // Demo runs against an unqualified in-memory SQLite table (no schemas);
465
+ // Postgres qualifies with the configured `--schema`.
466
+ const qualifiedTable = ctx.demo
467
+ ? quoteIdent(table.name)
468
+ : `${quoteIdent(ctx.options.schema)}.${quoteIdent(table.name)}`;
469
+ const sql = `SELECT * FROM ${qualifiedTable} ${mainWhere} ${orderByClause} LIMIT ${ph(1)} OFFSET ${ph(2)}`;
470
+ // Postgres casts the bigint COUNT to text to avoid int8 precision loss on the
471
+ // wire; SQLite returns a safe integer directly, so no cast.
472
+ const countSql = `SELECT COUNT(*)${ctx.demo ? '' : '::text'} AS count FROM ${qualifiedTable} ${countWhere}`;
389
473
  const client = await ctx.pool.connect();
390
474
  try {
391
- await client.query('BEGIN READ ONLY');
392
- await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
475
+ // Demo: the in-memory SQLite handle is a single synchronous connection with
476
+ // no READ ONLY txn mode or statement_timeout GUC, so we skip the read
477
+ // transaction wrapper entirely. Postgres keeps its belt-and-suspenders
478
+ // READ ONLY transaction + timeout.
479
+ if (!ctx.demo) {
480
+ await client.query('BEGIN READ ONLY');
481
+ await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
482
+ }
393
483
  const result = await client.query(sql, mainValues);
394
484
  const countResult = await client.query(countSql, countValues);
395
- await client.query('COMMIT');
485
+ if (!ctx.demo)
486
+ await client.query('COMMIT');
396
487
  sendJson(res, 200, {
397
488
  table: table.name,
398
- columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
489
+ columns: resultColumns(result, result.rows),
399
490
  rows: result.rows.map((r) => serializeRow(redactFlatRow(r, redactedPii))),
400
491
  total: Number(countResult.rows[0]?.count ?? 0),
401
492
  limit,
@@ -404,11 +495,13 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
404
495
  });
405
496
  }
406
497
  catch (err) {
407
- try {
408
- await client.query('ROLLBACK');
409
- }
410
- catch {
411
- /* ignore */
498
+ if (!ctx.demo) {
499
+ try {
500
+ await client.query('ROLLBACK');
501
+ }
502
+ catch {
503
+ /* ignore */
504
+ }
412
505
  }
413
506
  throw err;
414
507
  }
@@ -453,6 +546,9 @@ export async function apiBuilder(req, res, ctx) {
453
546
  warnOnUnlimited: false,
454
547
  sqlCache: false,
455
548
  preparedStatements: false,
549
+ // Demo compiles SQLite SQL (`:pN`, json_group_array, …); Postgres default
550
+ // when unset.
551
+ dialect: ctx.dialect,
456
552
  });
457
553
  deferred = qi.buildFindMany(args);
458
554
  }
@@ -462,33 +558,44 @@ export async function apiBuilder(req, res, ctx) {
462
558
  }
463
559
  const client = await ctx.pool.connect();
464
560
  try {
465
- await client.query('BEGIN READ ONLY');
466
- await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
467
- // QueryInterface emits unqualified table identifiers, which resolve via
468
- // the connection's search_path. Pin it to the configured --schema so the
469
- // Query tab reads the same schema as the Data tab (set_config is
470
- // transaction-local and fully parameterized).
471
- await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
561
+ if (!ctx.demo) {
562
+ await client.query('BEGIN READ ONLY');
563
+ await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
564
+ // QueryInterface emits unqualified table identifiers, which resolve via
565
+ // the connection's search_path. Pin it to the configured --schema so the
566
+ // Query tab reads the same schema as the Data tab (set_config is
567
+ // transaction-local and fully parameterized). Demo has no schemas.
568
+ await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
569
+ }
472
570
  const started = Date.now();
473
571
  const result = await client.query(deferred.sql, deferred.params);
474
572
  const elapsedMs = Date.now() - started;
475
- await client.query('COMMIT');
476
- const rawRows = result.rows;
573
+ if (!ctx.demo)
574
+ await client.query('COMMIT');
575
+ // Postgres auto-parses json/jsonb relation columns into JS values via its
576
+ // type parsers; the SQLite demo driver returns them as raw JSON strings. So
577
+ // in demo mode, parse relation columns back into arrays/objects (walking the
578
+ // `with` tree) to match the Postgres shape before redaction + serialization.
579
+ const rawRows = ctx.demo
580
+ ? parseDemoRelationRows(result.rows, tableName, args.with, ctx.metadata)
581
+ : result.rows;
477
582
  const redactedRows = ctx.showPii ? rawRows : redactBuilderRows(rawRows, tableName, args.with, ctx.metadata);
478
583
  sendJson(res, 200, {
479
584
  sql: deferred.sql,
480
- columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
585
+ columns: resultColumns(result, result.rows),
481
586
  rows: redactedRows.map((r) => serializeRow(r)),
482
587
  rowCount: result.rowCount ?? result.rows.length,
483
588
  elapsedMs,
484
589
  });
485
590
  }
486
591
  catch (err) {
487
- try {
488
- await client.query('ROLLBACK');
489
- }
490
- catch {
491
- /* ignore */
592
+ if (!ctx.demo) {
593
+ try {
594
+ await client.query('ROLLBACK');
595
+ }
596
+ catch {
597
+ /* ignore */
598
+ }
492
599
  }
493
600
  sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
494
601
  }
@@ -560,6 +667,7 @@ export async function apiRowWrite(req, res, ctx, op) {
560
667
  warnOnUnlimited: false,
561
668
  sqlCache: false,
562
669
  preparedStatements: false,
670
+ dialect: ctx.dialect,
563
671
  });
564
672
  if (op === 'insert') {
565
673
  deferred = qi.buildCreate({ data });
@@ -577,11 +685,15 @@ export async function apiRowWrite(req, res, ctx, op) {
577
685
  }
578
686
  const client = await ctx.pool.connect();
579
687
  try {
580
- // A real write transaction, NOT `READ ONLY`. Same parameterized
581
- // statement-timeout + search_path pin as the read paths.
688
+ // A real write transaction, NOT `READ ONLY`. Postgres also pins the
689
+ // parameterized statement-timeout + search_path; demo (SQLite) has neither
690
+ // GUC, so those are skipped, but the BEGIN/COMMIT is kept (SqlitePool
691
+ // supports it) so an in-memory write still applies atomically.
582
692
  await client.query('BEGIN');
583
- await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
584
- await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
693
+ if (!ctx.demo) {
694
+ await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
695
+ await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
696
+ }
585
697
  const result = await client.query(deferred.sql, deferred.params);
586
698
  await client.query('COMMIT');
587
699
  const row = result.rows[0];
@@ -820,6 +932,52 @@ function redactBuilderRows(rows, tableName, withClause, metadata) {
820
932
  return out;
821
933
  });
822
934
  }
935
+ /**
936
+ * Demo-only: parse relation columns that arrive as raw JSON strings from the
937
+ * SQLite driver back into arrays/objects, walking the `with` tree so nested
938
+ * relations are parsed at every level. This mirrors what Postgres' json/jsonb
939
+ * type parsers do automatically, so the builder response shape (and downstream
940
+ * redaction) is identical across engines. Rows without the named relation, or
941
+ * whose value is already a parsed object/array, pass through unchanged.
942
+ */
943
+ function parseDemoRelationRows(rows, tableName, withClause, metadata) {
944
+ const table = metadata.tables[tableName];
945
+ if (!table)
946
+ return rows;
947
+ const relEntries = withClause && typeof withClause === 'object'
948
+ ? Object.entries(withClause).filter(([, v]) => v)
949
+ : [];
950
+ if (relEntries.length === 0)
951
+ return rows;
952
+ return rows.map((row) => {
953
+ const out = { ...row };
954
+ for (const [relName, relVal] of relEntries) {
955
+ const rel = table.relations[relName];
956
+ if (!rel)
957
+ continue;
958
+ let child = out[relName];
959
+ if (typeof child === 'string') {
960
+ try {
961
+ child = JSON.parse(child);
962
+ }
963
+ catch {
964
+ continue;
965
+ }
966
+ }
967
+ const nestedWith = relVal && typeof relVal === 'object' ? relVal.with : undefined;
968
+ if (Array.isArray(child)) {
969
+ out[relName] = parseDemoRelationRows(child, rel.to, nestedWith, metadata);
970
+ }
971
+ else if (child && typeof child === 'object') {
972
+ out[relName] = parseDemoRelationRows([child], rel.to, nestedWith, metadata)[0];
973
+ }
974
+ else {
975
+ out[relName] = child;
976
+ }
977
+ }
978
+ return out;
979
+ });
980
+ }
823
981
  /**
824
982
  * A fresh CSP nonce for one HTML response. Base64 of 16 random bytes; the value
825
983
  * is stamped into both the `Content-Security-Policy` header and the inline
@@ -836,6 +994,20 @@ function clampInt(value, fallback, min, max) {
836
994
  return fallback;
837
995
  return Math.min(Math.max(n, min), max);
838
996
  }
997
+ /**
998
+ * Column descriptors for a result payload. Postgres results carry a `fields`
999
+ * array (name + OID); the SQLite demo driver does not, so we fall back to the
1000
+ * keys of the first returned row (dataTypeID 0 = "unknown", which the UI treats
1001
+ * generically). When `fields` is present this is byte-identical to the previous
1002
+ * inline `result.fields.map(...)`.
1003
+ */
1004
+ function resultColumns(result, rows) {
1005
+ if (result.fields) {
1006
+ return result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID }));
1007
+ }
1008
+ const first = rows[0];
1009
+ return first ? Object.keys(first).map((name) => ({ name, dataTypeID: 0 })) : [];
1010
+ }
839
1011
  function serializeRow(row) {
840
1012
  const out = {};
841
1013
  for (const [k, v] of Object.entries(row)) {
package/dist/powdb.d.ts CHANGED
@@ -599,7 +599,7 @@ export declare function encodePowqlLiteral(value: unknown): string;
599
599
  * the ceiling yet still routes through the string wire is refused rather than
600
600
  * materialized.
601
601
  */
602
- export declare const POWQL_LEXER_TESTED_CEILING = "0.15";
602
+ export declare const POWQL_LEXER_TESTED_CEILING = "0.16";
603
603
  /**
604
604
  * Substitute every `$N` placeholder in a generator-produced PowQL template with
605
605
  * the encoded literal of `params[N-1]`. Safe because the template is produced by
package/dist/powdb.js CHANGED
@@ -1391,7 +1391,7 @@ export function encodePowqlLiteral(value) {
1391
1391
  * the ceiling yet still routes through the string wire is refused rather than
1392
1392
  * materialized.
1393
1393
  */
1394
- export const POWQL_LEXER_TESTED_CEILING = '0.15';
1394
+ export const POWQL_LEXER_TESTED_CEILING = '0.16';
1395
1395
  /** Escape a string into a PowQL `"…"` literal, matching the engine lexer's escape rules. */
1396
1396
  function encodePowqlString(s) {
1397
1397
  let out = '"';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "exports": {
@@ -103,8 +103,8 @@
103
103
  "@size-limit/esbuild": "^12.1.0",
104
104
  "@size-limit/file": "^12.1.0",
105
105
  "@types/node": "^26.1.0",
106
- "@zvndev/powdb-client": "^0.15.0",
107
- "@zvndev/powdb-embedded": "^0.15.0",
106
+ "@zvndev/powdb-client": "^0.16.0",
107
+ "@zvndev/powdb-embedded": "^0.16.0",
108
108
  "c8": "^11.0.0",
109
109
  "husky": "^9.1.7",
110
110
  "lint-staged": "^17.0.8",