turbine-orm 0.36.1 → 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.
@@ -42,6 +42,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
42
42
  exports.PII_REDACTED = void 0;
43
43
  exports.startStudio = startStudio;
44
44
  exports.handleRequest = handleRequest;
45
+ exports.apiDemoMode = apiDemoMode;
45
46
  exports.apiTableRows = apiTableRows;
46
47
  exports.resolveColumnName = resolveColumnName;
47
48
  exports.isTextishType = isTextishType;
@@ -60,6 +61,7 @@ const node_path_1 = require("node:path");
60
61
  const pg_1 = __importDefault(require("pg"));
61
62
  const introspect_js_1 = require("../introspect.js");
62
63
  const index_js_1 = require("../query/index.js");
64
+ const studio_demo_js_1 = require("./studio-demo.js");
63
65
  const studio_ui_generated_js_1 = require("./studio-ui.generated.js");
64
66
  // ---------------------------------------------------------------------------
65
67
  // Main entry point
@@ -74,35 +76,55 @@ const studio_ui_generated_js_1 = require("./studio-ui.generated.js");
74
76
  * process.on('SIGINT', () => studio.dispose().then(() => process.exit(0)));
75
77
  */
76
78
  async function startStudio(options) {
77
- const pool = new pg_1.default.Pool({
78
- connectionString: options.url,
79
- max: 4, // small pool — single-user tool
80
- idleTimeoutMillis: 10_000,
81
- });
82
- // Verify connectivity before starting the server — fail fast.
83
- const probe = await pool.connect();
84
- try {
85
- await probe.query('SELECT 1');
79
+ const demo = options.demo === true;
80
+ let pool;
81
+ let metadata;
82
+ let dialect;
83
+ let statementTimeout;
84
+ if (demo) {
85
+ // Seeded in-memory SQLite store: no DATABASE_URL, no network. Each launch
86
+ // starts pristine and nothing is ever persisted.
87
+ const demoCtx = (0, studio_demo_js_1.createDemoContext)();
88
+ pool = demoCtx.pool;
89
+ metadata = demoCtx.metadata;
90
+ dialect = demoCtx.dialect;
91
+ // SQLite has no set_config / statement_timeout GUC; a harmless no-op keeps
92
+ // the shared execution path (which issues this before each query) uniform.
93
+ statementTimeout = { sql: 'SELECT 1', params: [] };
86
94
  }
87
- finally {
88
- probe.release();
95
+ else {
96
+ // pg.Pool satisfies the PgCompatPool contract (same as the external-pool
97
+ // seam in client.ts); the cast keeps one typed pool field for both modes.
98
+ pool = new pg_1.default.Pool({
99
+ connectionString: options.url,
100
+ max: 4, // small pool — single-user tool
101
+ idleTimeoutMillis: 10_000,
102
+ });
103
+ // Verify connectivity before starting the server — fail fast.
104
+ const probe = await pool.connect();
105
+ try {
106
+ await probe.query('SELECT 1');
107
+ }
108
+ finally {
109
+ probe.release();
110
+ }
111
+ metadata = await (0, introspect_js_1.introspect)({
112
+ connectionString: options.url,
113
+ schema: options.schema,
114
+ include: options.include,
115
+ exclude: options.exclude,
116
+ });
117
+ statementTimeout = options.adapter?.statementTimeout?.(30) ?? {
118
+ // Postgres rejects parameters in `SET LOCAL` (`SET LOCAL ... = $1` is a
119
+ // syntax error). `set_config(name, value, is_local=true)` is the
120
+ // parameterizable, transaction-local equivalent and works on every
121
+ // Postgres-compatible engine.
122
+ sql: `SELECT set_config('statement_timeout', $1, true)`,
123
+ params: ['30s'],
124
+ };
89
125
  }
90
- const metadata = await (0, introspect_js_1.introspect)({
91
- connectionString: options.url,
92
- schema: options.schema,
93
- include: options.include,
94
- exclude: options.exclude,
95
- });
96
126
  const authToken = (0, node_crypto_1.randomBytes)(24).toString('hex');
97
127
  const stateDir = (0, node_path_1.resolve)(options.stateDir ?? '.turbine');
98
- const statementTimeout = options.adapter?.statementTimeout?.(30) ?? {
99
- // Postgres rejects parameters in `SET LOCAL` (`SET LOCAL ... = $1` is a
100
- // syntax error). `set_config(name, value, is_local=true)` is the
101
- // parameterizable, transaction-local equivalent and works on every
102
- // Postgres-compatible engine.
103
- sql: `SELECT set_config('statement_timeout', $1, true)`,
104
- params: ['30s'],
105
- };
106
128
  const rateLimiter = new Map();
107
129
  const ctx = {
108
130
  pool,
@@ -112,8 +134,12 @@ async function startStudio(options) {
112
134
  stateDir,
113
135
  statementTimeout,
114
136
  rateLimiter,
115
- writable: options.write === true,
116
- showPii: options.showPii === true,
137
+ // Demo always boots read-only + PII redacted; the in-UI switcher flips these
138
+ // live. Non-demo honors the CLI flags.
139
+ writable: demo ? false : options.write === true,
140
+ showPii: demo ? false : options.showPii === true,
141
+ demo,
142
+ dialect,
117
143
  };
118
144
  const server = (0, node_http_1.createServer)((req, res) => {
119
145
  handleRequest(req, res, ctx).catch((err) => {
@@ -246,9 +272,40 @@ async function handleRequest(req, res, ctx) {
246
272
  if (op === 'delete')
247
273
  return apiRowWrite(req, res, ctx, 'delete');
248
274
  }
275
+ // Demo mode switcher: ONLY exists in demo mode (404 otherwise). Flips the live
276
+ // read-only / PII / write toggles on the in-memory store. State-changing, so it
277
+ // requires a matching Origin like the write routes.
278
+ if (ctx.demo && pathname === '/api/demo/mode' && req.method === 'POST') {
279
+ if (origin !== expectedOrigin) {
280
+ sendJson(res, 403, { error: 'a matching Origin header is required for mode changes' });
281
+ return;
282
+ }
283
+ return apiDemoMode(req, res, ctx);
284
+ }
249
285
  sendJson(res, 404, { error: 'not found' });
250
286
  }
251
287
  // ---------------------------------------------------------------------------
288
+ // API: /api/demo/mode: live mode switcher (demo mode only)
289
+ //
290
+ // Mutates the shared StudioContext so the change applies to every subsequent
291
+ // request: `writable` gates the (already-registered) `/api/row/*` routes and the
292
+ // UI's write affordances; `showPii` toggles server-side PII redaction. The two
293
+ // are independent toggles. The UI re-fetches `/api/schema` afterwards to re-read
294
+ // the effective state.
295
+ // ---------------------------------------------------------------------------
296
+ async function apiDemoMode(req, res, ctx) {
297
+ const body = await readJsonBody(req);
298
+ if (typeof body.writable === 'boolean')
299
+ ctx.writable = body.writable;
300
+ if (typeof body.showPii === 'boolean')
301
+ ctx.showPii = body.showPii;
302
+ sendJson(res, 200, {
303
+ demo: true,
304
+ writable: ctx.writable === true,
305
+ showPii: ctx.showPii === true,
306
+ });
307
+ }
308
+ // ---------------------------------------------------------------------------
252
309
  // Auth
253
310
  // ---------------------------------------------------------------------------
254
311
  function isAuthorized(req, expectedToken) {
@@ -325,17 +382,28 @@ async function apiSchema(res, ctx) {
325
382
  referenceKey: rel.referenceKey,
326
383
  })),
327
384
  }));
328
- // Row counts cheap enough to fetch inline. Use pg_class reltuples as
329
- // a fast estimate so we don't hammer big tables with SELECT COUNT(*).
330
- const countsResult = await ctx.pool.query(`SELECT c.relname, c.reltuples::bigint::text AS reltuples
331
- FROM pg_class c
332
- JOIN pg_namespace n ON n.oid = c.relnamespace
333
- WHERE n.nspname = $1 AND c.relkind = 'r'`, [ctx.options.schema]);
385
+ // Row counts (cheap enough to fetch inline).
334
386
  const counts = new Map();
335
- for (const row of countsResult.rows) {
336
- // pg_class.reltuples is -1 on PG14+ until a table is ANALYZEd; clamp so the
337
- // sidebar never shows a negative estimate.
338
- counts.set(row.relname, Math.max(0, Number(row.reltuples)));
387
+ if (ctx.demo) {
388
+ // The demo dataset is tiny and in-memory: an exact per-table COUNT(*) is
389
+ // instant, and SQLite has no pg_class estimate to read.
390
+ for (const t of tables) {
391
+ const r = await ctx.pool.query(`SELECT COUNT(*) AS count FROM ${(0, index_js_1.quoteIdent)(t.name)}`);
392
+ counts.set(t.name, Number(r.rows[0]?.count ?? 0));
393
+ }
394
+ }
395
+ else {
396
+ // Use pg_class reltuples as a fast estimate so we don't hammer big tables
397
+ // with SELECT COUNT(*).
398
+ const countsResult = await ctx.pool.query(`SELECT c.relname, c.reltuples::bigint::text AS reltuples
399
+ FROM pg_class c
400
+ JOIN pg_namespace n ON n.oid = c.relnamespace
401
+ WHERE n.nspname = $1 AND c.relkind = 'r'`, [ctx.options.schema]);
402
+ for (const row of countsResult.rows) {
403
+ // pg_class.reltuples is -1 on PG14+ until a table is ANALYZEd; clamp so the
404
+ // sidebar never shows a negative estimate.
405
+ counts.set(row.relname, Math.max(0, Number(row.reltuples)));
406
+ }
339
407
  }
340
408
  sendJson(res, 200, {
341
409
  schema: ctx.options.schema,
@@ -345,6 +413,8 @@ async function apiSchema(res, ctx) {
345
413
  // Read-only Studio reports `writable: false` so the UI renders no write UI.
346
414
  writable: ctx.writable === true,
347
415
  showPii: ctx.showPii === true,
416
+ // Demo flag drives the in-UI mode switcher + persistent demo banner.
417
+ demo: ctx.demo === true,
348
418
  });
349
419
  }
350
420
  // ---------------------------------------------------------------------------
@@ -384,12 +454,21 @@ async function apiTableRows(res, ctx, rawTableName, params) {
384
454
  .map((c) => c.name);
385
455
  const hasSearch = search.length > 0 && textColumns.length > 0;
386
456
  const pattern = hasSearch ? `%${escapeLikePattern(search)}%` : null;
457
+ // Parameter placeholder + case-insensitive LIKE condition differ by engine.
458
+ // Postgres: numbered `$N` + `ILIKE`. Demo (SQLite): named `:pN` (bound by
459
+ // name from the positional value array, matching Turbine's own SQLite path)
460
+ // + `LOWER(col) LIKE LOWER(:pN)` (explicit case-fold, ASCII). The escape char
461
+ // (`\`) is identical. When demo is off these produce byte-identical SQL.
462
+ const ph = (n) => (ctx.demo ? `:p${n}` : `$${n}`);
463
+ const likeCond = (col, n) => ctx.demo
464
+ ? `LOWER(${(0, index_js_1.quoteIdent)(col)}) LIKE LOWER(${ph(n)}) ESCAPE '\\'`
465
+ : `${(0, index_js_1.quoteIdent)(col)} ILIKE ${ph(n)} ESCAPE '\\'`;
387
466
  // Main query: $1 = limit, $2 = offset, $3 = pattern (if search)
388
467
  const mainValues = [limit, offset];
389
468
  let mainWhere = '';
390
469
  if (hasSearch && pattern !== null) {
391
470
  mainValues.push(pattern);
392
- const conds = textColumns.map((c) => `${(0, index_js_1.quoteIdent)(c)} ILIKE $3 ESCAPE '\\'`);
471
+ const conds = textColumns.map((c) => likeCond(c, 3));
393
472
  mainWhere = `WHERE (${conds.join(' OR ')})`;
394
473
  }
395
474
  // Count query: $1 = pattern (if search)
@@ -397,22 +476,35 @@ async function apiTableRows(res, ctx, rawTableName, params) {
397
476
  let countWhere = '';
398
477
  if (hasSearch && pattern !== null) {
399
478
  countValues.push(pattern);
400
- const conds = textColumns.map((c) => `${(0, index_js_1.quoteIdent)(c)} ILIKE $1 ESCAPE '\\'`);
479
+ const conds = textColumns.map((c) => likeCond(c, 1));
401
480
  countWhere = `WHERE (${conds.join(' OR ')})`;
402
481
  }
403
- const qualifiedTable = `${(0, index_js_1.quoteIdent)(ctx.options.schema)}.${(0, index_js_1.quoteIdent)(table.name)}`;
404
- const sql = `SELECT * FROM ${qualifiedTable} ${mainWhere} ${orderByClause} LIMIT $1 OFFSET $2`;
405
- const countSql = `SELECT COUNT(*)::text AS count FROM ${qualifiedTable} ${countWhere}`;
482
+ // Demo runs against an unqualified in-memory SQLite table (no schemas);
483
+ // Postgres qualifies with the configured `--schema`.
484
+ const qualifiedTable = ctx.demo
485
+ ? (0, index_js_1.quoteIdent)(table.name)
486
+ : `${(0, index_js_1.quoteIdent)(ctx.options.schema)}.${(0, index_js_1.quoteIdent)(table.name)}`;
487
+ const sql = `SELECT * FROM ${qualifiedTable} ${mainWhere} ${orderByClause} LIMIT ${ph(1)} OFFSET ${ph(2)}`;
488
+ // Postgres casts the bigint COUNT to text to avoid int8 precision loss on the
489
+ // wire; SQLite returns a safe integer directly, so no cast.
490
+ const countSql = `SELECT COUNT(*)${ctx.demo ? '' : '::text'} AS count FROM ${qualifiedTable} ${countWhere}`;
406
491
  const client = await ctx.pool.connect();
407
492
  try {
408
- await client.query('BEGIN READ ONLY');
409
- await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
493
+ // Demo: the in-memory SQLite handle is a single synchronous connection with
494
+ // no READ ONLY txn mode or statement_timeout GUC, so we skip the read
495
+ // transaction wrapper entirely. Postgres keeps its belt-and-suspenders
496
+ // READ ONLY transaction + timeout.
497
+ if (!ctx.demo) {
498
+ await client.query('BEGIN READ ONLY');
499
+ await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
500
+ }
410
501
  const result = await client.query(sql, mainValues);
411
502
  const countResult = await client.query(countSql, countValues);
412
- await client.query('COMMIT');
503
+ if (!ctx.demo)
504
+ await client.query('COMMIT');
413
505
  sendJson(res, 200, {
414
506
  table: table.name,
415
- columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
507
+ columns: resultColumns(result, result.rows),
416
508
  rows: result.rows.map((r) => serializeRow(redactFlatRow(r, redactedPii))),
417
509
  total: Number(countResult.rows[0]?.count ?? 0),
418
510
  limit,
@@ -421,11 +513,13 @@ async function apiTableRows(res, ctx, rawTableName, params) {
421
513
  });
422
514
  }
423
515
  catch (err) {
424
- try {
425
- await client.query('ROLLBACK');
426
- }
427
- catch {
428
- /* ignore */
516
+ if (!ctx.demo) {
517
+ try {
518
+ await client.query('ROLLBACK');
519
+ }
520
+ catch {
521
+ /* ignore */
522
+ }
429
523
  }
430
524
  throw err;
431
525
  }
@@ -470,6 +564,9 @@ async function apiBuilder(req, res, ctx) {
470
564
  warnOnUnlimited: false,
471
565
  sqlCache: false,
472
566
  preparedStatements: false,
567
+ // Demo compiles SQLite SQL (`:pN`, json_group_array, …); Postgres default
568
+ // when unset.
569
+ dialect: ctx.dialect,
473
570
  });
474
571
  deferred = qi.buildFindMany(args);
475
572
  }
@@ -479,33 +576,44 @@ async function apiBuilder(req, res, ctx) {
479
576
  }
480
577
  const client = await ctx.pool.connect();
481
578
  try {
482
- await client.query('BEGIN READ ONLY');
483
- await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
484
- // QueryInterface emits unqualified table identifiers, which resolve via
485
- // the connection's search_path. Pin it to the configured --schema so the
486
- // Query tab reads the same schema as the Data tab (set_config is
487
- // transaction-local and fully parameterized).
488
- await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
579
+ if (!ctx.demo) {
580
+ await client.query('BEGIN READ ONLY');
581
+ await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
582
+ // QueryInterface emits unqualified table identifiers, which resolve via
583
+ // the connection's search_path. Pin it to the configured --schema so the
584
+ // Query tab reads the same schema as the Data tab (set_config is
585
+ // transaction-local and fully parameterized). Demo has no schemas.
586
+ await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
587
+ }
489
588
  const started = Date.now();
490
589
  const result = await client.query(deferred.sql, deferred.params);
491
590
  const elapsedMs = Date.now() - started;
492
- await client.query('COMMIT');
493
- const rawRows = result.rows;
591
+ if (!ctx.demo)
592
+ await client.query('COMMIT');
593
+ // Postgres auto-parses json/jsonb relation columns into JS values via its
594
+ // type parsers; the SQLite demo driver returns them as raw JSON strings. So
595
+ // in demo mode, parse relation columns back into arrays/objects (walking the
596
+ // `with` tree) to match the Postgres shape before redaction + serialization.
597
+ const rawRows = ctx.demo
598
+ ? parseDemoRelationRows(result.rows, tableName, args.with, ctx.metadata)
599
+ : result.rows;
494
600
  const redactedRows = ctx.showPii ? rawRows : redactBuilderRows(rawRows, tableName, args.with, ctx.metadata);
495
601
  sendJson(res, 200, {
496
602
  sql: deferred.sql,
497
- columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
603
+ columns: resultColumns(result, result.rows),
498
604
  rows: redactedRows.map((r) => serializeRow(r)),
499
605
  rowCount: result.rowCount ?? result.rows.length,
500
606
  elapsedMs,
501
607
  });
502
608
  }
503
609
  catch (err) {
504
- try {
505
- await client.query('ROLLBACK');
506
- }
507
- catch {
508
- /* ignore */
610
+ if (!ctx.demo) {
611
+ try {
612
+ await client.query('ROLLBACK');
613
+ }
614
+ catch {
615
+ /* ignore */
616
+ }
509
617
  }
510
618
  sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
511
619
  }
@@ -577,6 +685,7 @@ async function apiRowWrite(req, res, ctx, op) {
577
685
  warnOnUnlimited: false,
578
686
  sqlCache: false,
579
687
  preparedStatements: false,
688
+ dialect: ctx.dialect,
580
689
  });
581
690
  if (op === 'insert') {
582
691
  deferred = qi.buildCreate({ data });
@@ -594,11 +703,15 @@ async function apiRowWrite(req, res, ctx, op) {
594
703
  }
595
704
  const client = await ctx.pool.connect();
596
705
  try {
597
- // A real write transaction, NOT `READ ONLY`. Same parameterized
598
- // statement-timeout + search_path pin as the read paths.
706
+ // A real write transaction, NOT `READ ONLY`. Postgres also pins the
707
+ // parameterized statement-timeout + search_path; demo (SQLite) has neither
708
+ // GUC, so those are skipped, but the BEGIN/COMMIT is kept (SqlitePool
709
+ // supports it) so an in-memory write still applies atomically.
599
710
  await client.query('BEGIN');
600
- await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
601
- await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
711
+ if (!ctx.demo) {
712
+ await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
713
+ await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
714
+ }
602
715
  const result = await client.query(deferred.sql, deferred.params);
603
716
  await client.query('COMMIT');
604
717
  const row = result.rows[0];
@@ -837,6 +950,52 @@ function redactBuilderRows(rows, tableName, withClause, metadata) {
837
950
  return out;
838
951
  });
839
952
  }
953
+ /**
954
+ * Demo-only: parse relation columns that arrive as raw JSON strings from the
955
+ * SQLite driver back into arrays/objects, walking the `with` tree so nested
956
+ * relations are parsed at every level. This mirrors what Postgres' json/jsonb
957
+ * type parsers do automatically, so the builder response shape (and downstream
958
+ * redaction) is identical across engines. Rows without the named relation, or
959
+ * whose value is already a parsed object/array, pass through unchanged.
960
+ */
961
+ function parseDemoRelationRows(rows, tableName, withClause, metadata) {
962
+ const table = metadata.tables[tableName];
963
+ if (!table)
964
+ return rows;
965
+ const relEntries = withClause && typeof withClause === 'object'
966
+ ? Object.entries(withClause).filter(([, v]) => v)
967
+ : [];
968
+ if (relEntries.length === 0)
969
+ return rows;
970
+ return rows.map((row) => {
971
+ const out = { ...row };
972
+ for (const [relName, relVal] of relEntries) {
973
+ const rel = table.relations[relName];
974
+ if (!rel)
975
+ continue;
976
+ let child = out[relName];
977
+ if (typeof child === 'string') {
978
+ try {
979
+ child = JSON.parse(child);
980
+ }
981
+ catch {
982
+ continue;
983
+ }
984
+ }
985
+ const nestedWith = relVal && typeof relVal === 'object' ? relVal.with : undefined;
986
+ if (Array.isArray(child)) {
987
+ out[relName] = parseDemoRelationRows(child, rel.to, nestedWith, metadata);
988
+ }
989
+ else if (child && typeof child === 'object') {
990
+ out[relName] = parseDemoRelationRows([child], rel.to, nestedWith, metadata)[0];
991
+ }
992
+ else {
993
+ out[relName] = child;
994
+ }
995
+ }
996
+ return out;
997
+ });
998
+ }
840
999
  /**
841
1000
  * A fresh CSP nonce for one HTML response. Base64 of 16 random bytes; the value
842
1001
  * is stamped into both the `Content-Security-Policy` header and the inline
@@ -853,6 +1012,20 @@ function clampInt(value, fallback, min, max) {
853
1012
  return fallback;
854
1013
  return Math.min(Math.max(n, min), max);
855
1014
  }
1015
+ /**
1016
+ * Column descriptors for a result payload. Postgres results carry a `fields`
1017
+ * array (name + OID); the SQLite demo driver does not, so we fall back to the
1018
+ * keys of the first returned row (dataTypeID 0 = "unknown", which the UI treats
1019
+ * generically). When `fields` is present this is byte-identical to the previous
1020
+ * inline `result.fields.map(...)`.
1021
+ */
1022
+ function resultColumns(result, rows) {
1023
+ if (result.fields) {
1024
+ return result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID }));
1025
+ }
1026
+ const first = rows[0];
1027
+ return first ? Object.keys(first).map((name) => ({ name, dataTypeID: 0 })) : [];
1028
+ }
856
1029
  function serializeRow(row) {
857
1030
  const out = {};
858
1031
  for (const [k, v] of Object.entries(row)) {
@@ -14,7 +14,7 @@
14
14
  * turbine seed — Run seed file
15
15
  * turbine status — Show schema summary
16
16
  * turbine doctor — Check relations for missing FK indexes (--fix emits migration)
17
- * turbine studio Launch local read-only web UI
17
+ * turbine studio : Launch local read-only web UI (--demo for a seeded sample DB)
18
18
  * turbine mcp — Start read-only MCP server over JSON-RPC stdio
19
19
  * turbine observe — Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
20
20
  *
@@ -57,6 +57,8 @@ export interface CliArgs {
57
57
  write?: boolean;
58
58
  /** Reveal PII-tagged column values in Studio instead of redacting (`--show-pii`). */
59
59
  showPii?: boolean;
60
+ /** Launch Studio with a seeded in-memory sample database (`studio --demo`). */
61
+ demo?: boolean;
60
62
  }
61
63
  export declare function parseArgs(argv?: string[]): CliArgs;
62
64
  /** Where a resolved `DATABASE_URL` came from, after the `.env` load. */
package/dist/cli/index.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * turbine seed — Run seed file
15
15
  * turbine status — Show schema summary
16
16
  * turbine doctor — Check relations for missing FK indexes (--fix emits migration)
17
- * turbine studio Launch local read-only web UI
17
+ * turbine studio : Launch local read-only web UI (--demo for a seeded sample DB)
18
18
  * turbine mcp — Start read-only MCP server over JSON-RPC stdio
19
19
  * turbine observe — Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
20
20
  *
@@ -148,6 +148,9 @@ export function parseArgs(argv = process.argv.slice(2)) {
148
148
  case '--show-pii':
149
149
  result.showPii = true;
150
150
  break;
151
+ case '--demo':
152
+ result.demo = true;
153
+ break;
151
154
  default:
152
155
  if (!arg.startsWith('-')) {
153
156
  result.positional.push(arg);
@@ -1461,7 +1464,10 @@ export function isLoopbackHost(host) {
1461
1464
  // ---------------------------------------------------------------------------
1462
1465
  async function cmdStudio(args, config) {
1463
1466
  banner();
1464
- const url = requireUrl(config);
1467
+ const demo = args.demo === true;
1468
+ // Demo mode is self-contained (seeded in-memory database), so it never needs
1469
+ // a DATABASE_URL. The placeholder is only used for display.
1470
+ const url = demo ? 'demo://in-memory' : requireUrl(config);
1465
1471
  const port = args.port ?? 4983;
1466
1472
  const host = args.host ?? '127.0.0.1';
1467
1473
  const openBrowser = !args.noOpen;
@@ -1484,7 +1490,7 @@ async function cmdStudio(args, config) {
1484
1490
  console.log(warn(`Studio is binding to ${yellow(host)} — this is NOT loopback. ` +
1485
1491
  `Anyone on your network who can reach this port + guess the session token can read your database.`));
1486
1492
  }
1487
- const spinner = new Spinner('Introspecting database').start();
1493
+ const spinner = new Spinner(demo ? 'Seeding demo dataset' : 'Introspecting database').start();
1488
1494
  let studio;
1489
1495
  try {
1490
1496
  studio = await startStudio({
@@ -1495,39 +1501,60 @@ async function cmdStudio(args, config) {
1495
1501
  openBrowser,
1496
1502
  include: config.include.length ? config.include : undefined,
1497
1503
  exclude: config.exclude.length ? config.exclude : undefined,
1504
+ // Demo boots read-only + PII redacted; the flags are ignored in demo mode
1505
+ // (the in-UI switcher controls modes live).
1498
1506
  write: args.write === true,
1499
1507
  showPii: args.showPii === true,
1508
+ demo,
1500
1509
  });
1501
- spinner.succeed(`Studio is running`);
1510
+ spinner.succeed(demo ? 'Demo Studio is running' : 'Studio is running');
1502
1511
  }
1503
1512
  catch (err) {
1504
1513
  spinner.fail(`Failed to start Studio: ${err instanceof Error ? err.message : String(err)}`);
1505
1514
  process.exit(1);
1506
1515
  }
1507
- // Loud startup warnings for the opt-in modes that widen Studio's surface.
1508
- if (args.write) {
1516
+ if (demo) {
1517
+ newline();
1518
+ console.log(box([
1519
+ `${bold('Turbine Studio')} ${dim('DEMO MODE (seeded in-memory sample database)')}`,
1520
+ '',
1521
+ ` ${cyan('URL:')} ${bold(studio.url)}`,
1522
+ ` ${cyan('Data:')} seeded sample dataset (users, posts, comments, orgs)`,
1523
+ ` ${cyan('Modes:')} switch Read-only / Show PII / Write live from inside the UI`,
1524
+ '',
1525
+ dim('Nothing you do here is saved anywhere. The database lives only in'),
1526
+ dim('memory: every launch starts fresh and restarts reset all edits.'),
1527
+ dim('Open the URL above (it carries a one-time session token).'),
1528
+ dim('Press Ctrl+C to stop.'),
1529
+ ].join('\n'), { title: bold(cyan('Studio · demo')), padding: 1 }));
1509
1530
  newline();
1510
- console.log(warn('WRITE MODE is ON. Studio can update, insert, and delete single rows in ' +
1511
- `${redactUrl(url)}. Every change is committed directly to your database.`));
1512
1531
  }
1513
- if (args.showPii) {
1532
+ else {
1533
+ // Loud startup warnings for the opt-in modes that widen Studio's surface.
1534
+ if (args.write) {
1535
+ newline();
1536
+ console.log(warn('WRITE MODE is ON. Studio can update, insert, and delete single rows in ' +
1537
+ `${redactUrl(url)}. Every change is committed directly to your database.`));
1538
+ }
1539
+ if (args.showPii) {
1540
+ newline();
1541
+ console.log(warn('--show-pii is ON. PII-tagged column values are shown UNREDACTED in Studio.'));
1542
+ }
1543
+ newline();
1544
+ console.log(box([
1545
+ `${bold('Turbine Studio')} ${dim(args.write ? 'local UI (WRITE MODE)' : 'local read-only UI')}`,
1546
+ '',
1547
+ ` ${cyan('URL:')} ${bold(studio.url)}`,
1548
+ ` ${cyan('Schema:')} ${config.schema}`,
1549
+ ` ${cyan('DB:')} ${redactUrl(url)}`,
1550
+ ` ${cyan('Mode:')} ${args.write ? red('read-write (single-row)') : 'read-only'}`,
1551
+ '',
1552
+ dim('Open the URL above in your browser. It includes a one-time session'),
1553
+ dim('token that gets set as an HttpOnly cookie on first load.'),
1554
+ dim('Press Ctrl+C to stop.'),
1555
+ ].join('\n'), { title: bold(cyan('Studio')), padding: 1 }));
1514
1556
  newline();
1515
- console.log(warn('--show-pii is ON. PII-tagged column values are shown UNREDACTED in Studio.'));
1516
1557
  }
1517
- newline();
1518
- console.log(box([
1519
- `${bold('Turbine Studio')} ${dim(args.write ? 'local UI (WRITE MODE)' : 'local read-only UI')}`,
1520
- '',
1521
- ` ${cyan('URL:')} ${bold(studio.url)}`,
1522
- ` ${cyan('Schema:')} ${config.schema}`,
1523
- ` ${cyan('DB:')} ${redactUrl(url)}`,
1524
- ` ${cyan('Mode:')} ${args.write ? red('read-write (single-row)') : 'read-only'}`,
1525
- '',
1526
- dim('Open the URL above in your browser. It includes a one-time session'),
1527
- dim('token that gets set as an HttpOnly cookie on first load.'),
1528
- dim('Press Ctrl+C to stop.'),
1529
- ].join('\n'), { title: bold(cyan('Studio')), padding: 1 }));
1530
- newline();
1531
1558
  // Wait forever until SIGINT/SIGTERM, then dispose cleanly.
1532
1559
  await new Promise((resolve) => {
1533
1560
  const shutdown = async () => {
@@ -1808,7 +1835,7 @@ function showHelp() {
1808
1835
  console.log(` ${cyan('seed')} Run seed file`);
1809
1836
  console.log(` ${cyan('status')} ${dim('| info')} Show schema summary`);
1810
1837
  console.log(` ${cyan('doctor')} Check relations for missing FK indexes ${dim('(--fix emits migration)')}`);
1811
- console.log(` ${cyan('studio')} Launch local read-only web UI ${dim('(--write opts in to single-row writes)')}`);
1838
+ console.log(` ${cyan('studio')} Launch local read-only web UI ${dim('(--write for writes, --demo for a sample DB)')}`);
1812
1839
  console.log(` ${cyan('mcp')} Start read-only MCP server over stdio`);
1813
1840
  console.log(` ${cyan('observe')} Launch metrics dashboard ${dim('(requires TURBINE_OBSERVE_URL)')}`);
1814
1841
  newline();
@@ -1834,6 +1861,7 @@ function showHelp() {
1834
1861
  console.log(` ${cyan('--allow-remote')} Allow non-loopback --host ${dim('(refused without this flag)')}`);
1835
1862
  console.log(` ${cyan('--write')} Studio: enable single-row update/insert/delete ${dim('(read-only by default)')}`);
1836
1863
  console.log(` ${cyan('--show-pii')} Studio: show PII-tagged values unredacted ${dim('(redacted by default)')}`);
1864
+ console.log(` ${cyan('--demo')} Studio: launch with a seeded in-memory sample database ${dim('(no DATABASE_URL needed; nothing is saved)')}`);
1837
1865
  newline();
1838
1866
  console.log(` ${bold('Config file:')}`);
1839
1867
  console.log(` ${dim('Create')} ${cyan('turbine.config.ts')} ${dim('with')} ${cyan('npx turbine init')}`);
@@ -0,0 +1,43 @@
1
+ /**
2
+ * turbine-orm CLI: Studio demo mode (`turbine studio --demo`)
3
+ *
4
+ * Boots Studio with NO database and NO DATABASE_URL: a baked-in, seeded sample
5
+ * dataset served from an in-memory engine. It is the "feel the product in 10
6
+ * seconds" experience: read mode, PII redaction, and the single-row write flow,
7
+ * all safely fake.
8
+ *
9
+ * The store is backed by Turbine's OWN SQLite engine over `node:sqlite`'s
10
+ * `:memory:` database (a built-in on Node >= 22.5, zero new dependency). Because
11
+ * `:memory:` is per-handle, the store dies with the process and every launch
12
+ * starts pristine: writes genuinely apply (edits stick, a refresh shows them)
13
+ * but nothing is ever persisted anywhere.
14
+ *
15
+ * This module lives under `src/cli/` (coverage-excluded, never imported by
16
+ * library code) and reuses `SqlitePool` + `sqliteDialect` from `../sqlite.js`;
17
+ * it never writes its own SQL evaluator.
18
+ */
19
+ import type { PgCompatPool } from '../client.js';
20
+ import type { Dialect } from '../dialect.js';
21
+ import type { SchemaMetadata } from '../schema.js';
22
+ /**
23
+ * The seeded sample schema. Four tables with realistic relations; `email` and
24
+ * `phone` are tagged `pii` so Studio's redaction path is exercised out of the
25
+ * box.
26
+ */
27
+ export declare const DEMO_SCHEMA: SchemaMetadata;
28
+ export interface DemoContext {
29
+ /** In-memory SQLite pool (pg-compatible) backing the demo store. */
30
+ pool: PgCompatPool;
31
+ /** The seeded sample schema metadata. */
32
+ metadata: SchemaMetadata;
33
+ /** The SQLite dialect the Studio handlers compile against in demo mode. */
34
+ dialect: Dialect;
35
+ }
36
+ /**
37
+ * Open a fresh, seeded in-memory demo store and return the pool + metadata +
38
+ * dialect Studio needs. Each call yields an independent, pristine database
39
+ * (`:memory:` is per-handle), so demo launches never share state.
40
+ *
41
+ * @throws Error on Node < 22.5 (no built-in `node:sqlite`).
42
+ */
43
+ export declare function createDemoContext(): DemoContext;