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.
- package/README.md +3 -1
- package/dist/cjs/cli/index.js +53 -25
- package/dist/cjs/cli/studio-demo.js +310 -0
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +243 -70
- package/dist/cli/index.d.ts +3 -1
- package/dist/cli/index.js +53 -25
- package/dist/cli/studio-demo.d.ts +43 -0
- package/dist/cli/studio-demo.js +306 -0
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +31 -3
- package/dist/cli/studio.js +242 -70
- package/package.json +1 -1
package/dist/cjs/cli/studio.js
CHANGED
|
@@ -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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
|
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
|
-
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
|
|
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) =>
|
|
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) =>
|
|
479
|
+
const conds = textColumns.map((c) => likeCond(c, 1));
|
|
401
480
|
countWhere = `WHERE (${conds.join(' OR ')})`;
|
|
402
481
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const
|
|
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
|
-
|
|
409
|
-
|
|
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
|
-
|
|
503
|
+
if (!ctx.demo)
|
|
504
|
+
await client.query('COMMIT');
|
|
413
505
|
sendJson(res, 200, {
|
|
414
506
|
table: table.name,
|
|
415
|
-
columns: result
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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`.
|
|
598
|
-
// statement-timeout + search_path
|
|
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
|
-
|
|
601
|
-
|
|
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)) {
|
package/dist/cli/index.d.ts
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
1508
|
-
|
|
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
|
-
|
|
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
|
|
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;
|