turbine-orm 0.8.0 → 0.9.1

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.
@@ -0,0 +1,627 @@
1
+ /**
2
+ * turbine-orm CLI — Studio
3
+ *
4
+ * A local, read-only web UI for browsing databases, exploring relations,
5
+ * and running SELECT queries. Pure Node (built-in `http` module), no
6
+ * runtime dependencies beyond `pg`, bound to 127.0.0.1 only.
7
+ *
8
+ * Security model:
9
+ * • Bind 127.0.0.1 only (never 0.0.0.0 — no LAN exposure)
10
+ * • Random auth token generated per process, required in Cookie header
11
+ * • SELECT/WITH-only guard on the query endpoint
12
+ * • Every query runs in a READ ONLY transaction (belt-and-suspenders)
13
+ * • 30s statement timeout
14
+ *
15
+ * Not implemented (deliberately): row editing, DDL, destructive operations.
16
+ * Studio is for inspection. Use the CLI, migrate, or raw SQL for writes.
17
+ */
18
+ import { spawn } from 'node:child_process';
19
+ import { randomBytes, randomUUID } from 'node:crypto';
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
21
+ import { createServer } from 'node:http';
22
+ import { platform } from 'node:os';
23
+ import { dirname, resolve as pathResolve } from 'node:path';
24
+ import pg from 'pg';
25
+ import { introspect } from '../introspect.js';
26
+ import { QueryInterface, quoteIdent } from '../query.js';
27
+ import { STUDIO_HTML } from './studio-ui.generated.js';
28
+ // ---------------------------------------------------------------------------
29
+ // Main entry point
30
+ // ---------------------------------------------------------------------------
31
+ /**
32
+ * Start the Studio server. Returns a handle with the session token, a pre-built
33
+ * URL (including the token) that the CLI can print, and a disposer.
34
+ *
35
+ * Typical usage from the CLI:
36
+ * const studio = await startStudio(options);
37
+ * console.log(studio.url);
38
+ * process.on('SIGINT', () => studio.dispose().then(() => process.exit(0)));
39
+ */
40
+ export async function startStudio(options) {
41
+ const pool = new pg.Pool({
42
+ connectionString: options.url,
43
+ max: 4, // small pool — single-user tool
44
+ idleTimeoutMillis: 10_000,
45
+ });
46
+ // Verify connectivity before starting the server — fail fast.
47
+ const probe = await pool.connect();
48
+ try {
49
+ await probe.query('SELECT 1');
50
+ }
51
+ finally {
52
+ probe.release();
53
+ }
54
+ const metadata = await introspect({
55
+ connectionString: options.url,
56
+ schema: options.schema,
57
+ include: options.include,
58
+ exclude: options.exclude,
59
+ });
60
+ const authToken = randomBytes(24).toString('hex');
61
+ const stateDir = pathResolve(options.stateDir ?? '.turbine');
62
+ const ctx = { pool, metadata, options, authToken, stateDir };
63
+ const server = createServer((req, res) => {
64
+ handleRequest(req, res, ctx).catch((err) => {
65
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
66
+ });
67
+ });
68
+ await new Promise((resolve, reject) => {
69
+ server.once('error', reject);
70
+ server.listen(options.port, options.host, () => {
71
+ server.off('error', reject);
72
+ resolve();
73
+ });
74
+ });
75
+ const url = buildStudioUrl(options.host, options.port, authToken);
76
+ if (options.openBrowser) {
77
+ openUrl(url);
78
+ }
79
+ return {
80
+ authToken,
81
+ url,
82
+ dispose: async () => {
83
+ await new Promise((resolve) => server.close(() => resolve()));
84
+ await pool.end();
85
+ },
86
+ };
87
+ }
88
+ /**
89
+ * Build a browser-safe URL for the given host/port/token. Wraps IPv6 addresses
90
+ * in brackets so `new URL(...)` inside the request handler works correctly.
91
+ */
92
+ function buildStudioUrl(host, port, token) {
93
+ const hostPart = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
94
+ return `http://${hostPart}:${port}/?token=${token}`;
95
+ }
96
+ function originFor(host, port) {
97
+ const hostPart = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
98
+ return `http://${hostPart}:${port}`;
99
+ }
100
+ // ---------------------------------------------------------------------------
101
+ // Request routing
102
+ // ---------------------------------------------------------------------------
103
+ async function handleRequest(req, res, ctx) {
104
+ const expectedOrigin = originFor(ctx.options.host, ctx.options.port);
105
+ // CORS: not needed — same-origin only. Explicitly refuse cross-origin.
106
+ const origin = req.headers.origin;
107
+ if (origin && origin !== expectedOrigin) {
108
+ sendJson(res, 403, { error: 'cross-origin requests not allowed' });
109
+ return;
110
+ }
111
+ const url = new URL(req.url ?? '/', expectedOrigin);
112
+ const pathname = url.pathname;
113
+ // The index route serves the HTML shell and embeds the auth token.
114
+ // All other routes require the token in the `x-turbine-token` header
115
+ // or the `token` cookie.
116
+ if (pathname === '/' || pathname === '/index.html') {
117
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
118
+ sendText(res, 405, 'Method Not Allowed');
119
+ return;
120
+ }
121
+ const queryToken = url.searchParams.get('token');
122
+ // If the URL includes a token, validate then set a cookie and redirect.
123
+ if (queryToken && constantTimeEqual(queryToken, ctx.authToken)) {
124
+ res.writeHead(302, {
125
+ Location: '/',
126
+ 'Set-Cookie': `turbine_studio_token=${ctx.authToken}; Path=/; HttpOnly; SameSite=Strict`,
127
+ });
128
+ res.end();
129
+ return;
130
+ }
131
+ sendHtml(res, 200, STUDIO_HTML);
132
+ return;
133
+ }
134
+ // API routes — all require auth.
135
+ if (!isAuthorized(req, ctx.authToken)) {
136
+ sendJson(res, 401, { error: 'unauthorized — use the URL printed in the terminal' });
137
+ return;
138
+ }
139
+ if (pathname === '/api/schema' && req.method === 'GET') {
140
+ return apiSchema(res, ctx);
141
+ }
142
+ if (pathname.startsWith('/api/tables/') && req.method === 'GET') {
143
+ const rawName = decodeURIComponent(pathname.slice('/api/tables/'.length));
144
+ return apiTableRows(res, ctx, rawName, url.searchParams);
145
+ }
146
+ if (pathname === '/api/query' && req.method === 'POST') {
147
+ return apiQuery(req, res, ctx);
148
+ }
149
+ if (pathname === '/api/builder' && req.method === 'POST') {
150
+ return apiBuilder(req, res, ctx);
151
+ }
152
+ if (pathname === '/api/saved-queries' && req.method === 'GET') {
153
+ return apiListSavedQueries(res, ctx, url.searchParams);
154
+ }
155
+ if (pathname === '/api/saved-queries' && req.method === 'POST') {
156
+ return apiCreateSavedQuery(req, res, ctx);
157
+ }
158
+ if (pathname.startsWith('/api/saved-queries/') && req.method === 'DELETE') {
159
+ const id = decodeURIComponent(pathname.slice('/api/saved-queries/'.length));
160
+ return apiDeleteSavedQuery(res, ctx, id);
161
+ }
162
+ sendJson(res, 404, { error: 'not found' });
163
+ }
164
+ // ---------------------------------------------------------------------------
165
+ // Auth
166
+ // ---------------------------------------------------------------------------
167
+ function isAuthorized(req, expectedToken) {
168
+ const headerToken = req.headers['x-turbine-token'];
169
+ if (typeof headerToken === 'string' && constantTimeEqual(headerToken, expectedToken)) {
170
+ return true;
171
+ }
172
+ const cookieHeader = req.headers.cookie ?? '';
173
+ const match = /turbine_studio_token=([a-f0-9]+)/.exec(cookieHeader);
174
+ if (match?.[1] && constantTimeEqual(match[1], expectedToken)) {
175
+ return true;
176
+ }
177
+ return false;
178
+ }
179
+ function constantTimeEqual(a, b) {
180
+ if (a.length !== b.length)
181
+ return false;
182
+ let result = 0;
183
+ for (let i = 0; i < a.length; i++) {
184
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
185
+ }
186
+ return result === 0;
187
+ }
188
+ // ---------------------------------------------------------------------------
189
+ // API: /api/schema
190
+ // ---------------------------------------------------------------------------
191
+ async function apiSchema(res, ctx) {
192
+ const tables = Object.values(ctx.metadata.tables).map((tbl) => ({
193
+ name: tbl.name,
194
+ primaryKey: tbl.primaryKey,
195
+ columns: tbl.columns.map((col) => ({
196
+ name: col.name,
197
+ field: col.field,
198
+ pgType: col.pgType,
199
+ tsType: col.tsType,
200
+ nullable: col.nullable,
201
+ hasDefault: col.hasDefault,
202
+ isPrimaryKey: tbl.primaryKey.includes(col.name),
203
+ })),
204
+ relations: Object.entries(tbl.relations).map(([name, rel]) => ({
205
+ name,
206
+ type: rel.type,
207
+ to: rel.to,
208
+ foreignKey: rel.foreignKey,
209
+ referenceKey: rel.referenceKey,
210
+ })),
211
+ }));
212
+ // Row counts — cheap enough to fetch inline. Use pg_class reltuples as
213
+ // a fast estimate so we don't hammer big tables with SELECT COUNT(*).
214
+ const countsResult = await ctx.pool.query(`SELECT c.relname, c.reltuples::bigint::text AS reltuples
215
+ FROM pg_class c
216
+ JOIN pg_namespace n ON n.oid = c.relnamespace
217
+ WHERE n.nspname = $1 AND c.relkind = 'r'`, [ctx.options.schema]);
218
+ const counts = new Map();
219
+ for (const row of countsResult.rows) {
220
+ counts.set(row.relname, Number(row.reltuples));
221
+ }
222
+ sendJson(res, 200, {
223
+ schema: ctx.options.schema,
224
+ tables: tables.map((t) => ({ ...t, estimatedRows: counts.get(t.name) ?? 0 })),
225
+ enums: ctx.metadata.enums,
226
+ });
227
+ }
228
+ // ---------------------------------------------------------------------------
229
+ // API: /api/tables/:name?limit=&offset=&orderBy=&dir=
230
+ // ---------------------------------------------------------------------------
231
+ export async function apiTableRows(res, ctx, rawTableName, params) {
232
+ const table = ctx.metadata.tables[rawTableName];
233
+ if (!table) {
234
+ sendJson(res, 404, { error: `unknown table: ${rawTableName}` });
235
+ return;
236
+ }
237
+ const limit = clampInt(params.get('limit'), 50, 1, 500);
238
+ const offset = clampInt(params.get('offset'), 0, 0, 10_000_000);
239
+ const orderByRaw = params.get('orderBy');
240
+ const dir = params.get('dir')?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
241
+ // orderBy — accept either the Postgres column name (snake) or the TS field
242
+ // name (camel). Always emit the Postgres column in the SQL.
243
+ let orderByClause = '';
244
+ if (orderByRaw) {
245
+ const col = resolveColumnName(table, orderByRaw);
246
+ if (col)
247
+ orderByClause = `ORDER BY ${quoteIdent(col)} ${dir}`;
248
+ }
249
+ if (!orderByClause && table.primaryKey.length > 0 && table.primaryKey[0]) {
250
+ orderByClause = `ORDER BY ${quoteIdent(table.primaryKey[0])} ${dir}`;
251
+ }
252
+ // Full-text-ish search: ILIKE across text/varchar columns. The value is
253
+ // parameterized so injection is impossible. Each query gets its own
254
+ // WHERE clause with parameter indices matching that query's param array.
255
+ const search = params.get('search')?.trim() ?? '';
256
+ const textColumns = table.columns.filter((c) => isTextishType(c.pgType)).map((c) => c.name);
257
+ const hasSearch = search.length > 0 && textColumns.length > 0;
258
+ const pattern = hasSearch ? `%${escapeLikePattern(search)}%` : null;
259
+ // Main query: $1 = limit, $2 = offset, $3 = pattern (if search)
260
+ const mainValues = [limit, offset];
261
+ let mainWhere = '';
262
+ if (hasSearch && pattern !== null) {
263
+ mainValues.push(pattern);
264
+ const conds = textColumns.map((c) => `${quoteIdent(c)} ILIKE $3`);
265
+ mainWhere = `WHERE (${conds.join(' OR ')})`;
266
+ }
267
+ // Count query: $1 = pattern (if search)
268
+ const countValues = [];
269
+ let countWhere = '';
270
+ if (hasSearch && pattern !== null) {
271
+ countValues.push(pattern);
272
+ const conds = textColumns.map((c) => `${quoteIdent(c)} ILIKE $1`);
273
+ countWhere = `WHERE (${conds.join(' OR ')})`;
274
+ }
275
+ const qualifiedTable = `${quoteIdent(ctx.options.schema)}.${quoteIdent(table.name)}`;
276
+ const sql = `SELECT * FROM ${qualifiedTable} ${mainWhere} ${orderByClause} LIMIT $1 OFFSET $2`;
277
+ const countSql = `SELECT COUNT(*)::text AS count FROM ${qualifiedTable} ${countWhere}`;
278
+ const client = await ctx.pool.connect();
279
+ try {
280
+ await client.query('BEGIN READ ONLY');
281
+ await client.query(`SET LOCAL statement_timeout = '30s'`);
282
+ const result = await client.query(sql, mainValues);
283
+ const countResult = await client.query(countSql, countValues);
284
+ await client.query('COMMIT');
285
+ sendJson(res, 200, {
286
+ table: table.name,
287
+ columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
288
+ rows: result.rows.map((r) => serializeRow(r)),
289
+ total: Number(countResult.rows[0]?.count ?? 0),
290
+ limit,
291
+ offset,
292
+ search,
293
+ });
294
+ }
295
+ catch (err) {
296
+ try {
297
+ await client.query('ROLLBACK');
298
+ }
299
+ catch {
300
+ /* ignore */
301
+ }
302
+ throw err;
303
+ }
304
+ finally {
305
+ client.release();
306
+ }
307
+ }
308
+ export function resolveColumnName(table, nameOrField) {
309
+ for (const c of table.columns) {
310
+ if (c.name === nameOrField || c.field === nameOrField)
311
+ return c.name;
312
+ }
313
+ return null;
314
+ }
315
+ export function isTextishType(pgType) {
316
+ return (pgType === 'text' ||
317
+ pgType === 'varchar' ||
318
+ pgType === 'character varying' ||
319
+ pgType === 'char' ||
320
+ pgType === 'character' ||
321
+ pgType === 'citext' ||
322
+ pgType === 'uuid');
323
+ }
324
+ export function escapeLikePattern(s) {
325
+ // Escape the LIKE wildcards so user input is treated literally.
326
+ return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
327
+ }
328
+ // ---------------------------------------------------------------------------
329
+ // API: /api/query — read-only SELECT/WITH runner
330
+ // ---------------------------------------------------------------------------
331
+ async function apiQuery(req, res, ctx) {
332
+ const body = await readJsonBody(req);
333
+ const rawSql = typeof body?.sql === 'string' ? body.sql.trim() : '';
334
+ if (!rawSql) {
335
+ sendJson(res, 400, { error: 'missing sql' });
336
+ return;
337
+ }
338
+ if (!isReadOnlyStatement(rawSql)) {
339
+ sendJson(res, 400, {
340
+ error: 'only SELECT / WITH statements are allowed in Studio — use the CLI for writes',
341
+ });
342
+ return;
343
+ }
344
+ const client = await ctx.pool.connect();
345
+ try {
346
+ await client.query('BEGIN READ ONLY');
347
+ await client.query(`SET LOCAL statement_timeout = '30s'`);
348
+ const started = Date.now();
349
+ const result = await client.query(rawSql);
350
+ const elapsedMs = Date.now() - started;
351
+ await client.query('COMMIT');
352
+ sendJson(res, 200, {
353
+ columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
354
+ rows: result.rows.map((r) => serializeRow(r)),
355
+ rowCount: result.rowCount ?? result.rows.length,
356
+ elapsedMs,
357
+ });
358
+ }
359
+ catch (err) {
360
+ try {
361
+ await client.query('ROLLBACK');
362
+ }
363
+ catch {
364
+ /* ignore */
365
+ }
366
+ sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
367
+ }
368
+ finally {
369
+ client.release();
370
+ }
371
+ }
372
+ // ---------------------------------------------------------------------------
373
+ // API: /api/builder — Turbine ORM findMany spec runner
374
+ // ---------------------------------------------------------------------------
375
+ export async function apiBuilder(req, res, ctx) {
376
+ const body = await readJsonBody(req);
377
+ const tableName = typeof body?.table === 'string' ? body.table : '';
378
+ const args = (body?.args ?? {});
379
+ if (!tableName || !ctx.metadata.tables[tableName]) {
380
+ sendJson(res, 400, { error: `unknown table: ${tableName}` });
381
+ return;
382
+ }
383
+ let deferred;
384
+ try {
385
+ const qi = new QueryInterface(ctx.pool, tableName, ctx.metadata, [], {
386
+ warnOnUnlimited: false,
387
+ sqlCache: false,
388
+ preparedStatements: false,
389
+ });
390
+ deferred = qi.buildFindMany(args);
391
+ }
392
+ catch (err) {
393
+ sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
394
+ return;
395
+ }
396
+ const client = await ctx.pool.connect();
397
+ try {
398
+ await client.query('BEGIN READ ONLY');
399
+ await client.query(`SET LOCAL statement_timeout = '30s'`);
400
+ const started = Date.now();
401
+ const result = await client.query(deferred.sql, deferred.params);
402
+ const elapsedMs = Date.now() - started;
403
+ await client.query('COMMIT');
404
+ sendJson(res, 200, {
405
+ sql: deferred.sql,
406
+ columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
407
+ rows: result.rows.map((r) => serializeRow(r)),
408
+ rowCount: result.rowCount ?? result.rows.length,
409
+ elapsedMs,
410
+ });
411
+ }
412
+ catch (err) {
413
+ try {
414
+ await client.query('ROLLBACK');
415
+ }
416
+ catch {
417
+ /* ignore */
418
+ }
419
+ sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
420
+ }
421
+ finally {
422
+ client.release();
423
+ }
424
+ }
425
+ function savedQueriesPath(ctx) {
426
+ return pathResolve(ctx.stateDir, 'studio-queries.json');
427
+ }
428
+ function loadSavedQueries(ctx) {
429
+ const file = savedQueriesPath(ctx);
430
+ if (!existsSync(file))
431
+ return { version: 1, queries: [] };
432
+ try {
433
+ const raw = readFileSync(file, 'utf8');
434
+ const parsed = JSON.parse(raw);
435
+ if (!parsed.queries || !Array.isArray(parsed.queries))
436
+ return { version: 1, queries: [] };
437
+ return { version: 1, queries: parsed.queries };
438
+ }
439
+ catch {
440
+ return { version: 1, queries: [] };
441
+ }
442
+ }
443
+ function writeSavedQueries(ctx, data) {
444
+ const file = savedQueriesPath(ctx);
445
+ const dir = dirname(file);
446
+ if (!existsSync(dir))
447
+ mkdirSync(dir, { recursive: true });
448
+ writeFileSync(file, JSON.stringify(data, null, 2));
449
+ }
450
+ export function apiListSavedQueries(res, ctx, params) {
451
+ const { queries } = loadSavedQueries(ctx);
452
+ const table = params.get('table');
453
+ const filtered = table ? queries.filter((q) => q.table === table) : queries;
454
+ sendJson(res, 200, { queries: filtered });
455
+ }
456
+ export async function apiCreateSavedQuery(req, res, ctx) {
457
+ const body = await readJsonBody(req);
458
+ const table = typeof body?.table === 'string' ? body.table : '';
459
+ const name = typeof body?.name === 'string' ? body.name.trim() : '';
460
+ const kind = body?.kind === 'builder' ? 'builder' : body?.kind === 'sql' ? 'sql' : null;
461
+ if (!table || !ctx.metadata.tables[table]) {
462
+ sendJson(res, 400, { error: `unknown table: ${table}` });
463
+ return;
464
+ }
465
+ if (!name) {
466
+ sendJson(res, 400, { error: 'name is required' });
467
+ return;
468
+ }
469
+ if (!kind) {
470
+ sendJson(res, 400, { error: 'kind must be "sql" or "builder"' });
471
+ return;
472
+ }
473
+ const sql = kind === 'sql' && typeof body?.sql === 'string' ? body.sql : undefined;
474
+ const args = kind === 'builder' ? body?.args : undefined;
475
+ if (kind === 'sql' && !sql) {
476
+ sendJson(res, 400, { error: 'sql is required for kind=sql' });
477
+ return;
478
+ }
479
+ if (kind === 'sql' && sql && !isReadOnlyStatement(sql)) {
480
+ sendJson(res, 400, { error: 'saved sql must be SELECT/WITH only' });
481
+ return;
482
+ }
483
+ const data = loadSavedQueries(ctx);
484
+ const entry = {
485
+ id: randomUUID(),
486
+ table,
487
+ name,
488
+ kind,
489
+ sql,
490
+ args,
491
+ createdAt: new Date().toISOString(),
492
+ };
493
+ data.queries.push(entry);
494
+ writeSavedQueries(ctx, data);
495
+ sendJson(res, 200, { query: entry });
496
+ }
497
+ export function apiDeleteSavedQuery(res, ctx, id) {
498
+ const data = loadSavedQueries(ctx);
499
+ const before = data.queries.length;
500
+ data.queries = data.queries.filter((q) => q.id !== id);
501
+ if (data.queries.length === before) {
502
+ sendJson(res, 404, { error: 'saved query not found' });
503
+ return;
504
+ }
505
+ writeSavedQueries(ctx, data);
506
+ sendJson(res, 200, { ok: true });
507
+ }
508
+ // ---------------------------------------------------------------------------
509
+ // Helpers
510
+ // ---------------------------------------------------------------------------
511
+ function clampInt(value, fallback, min, max) {
512
+ if (value == null)
513
+ return fallback;
514
+ const n = Number.parseInt(value, 10);
515
+ if (!Number.isFinite(n))
516
+ return fallback;
517
+ return Math.min(Math.max(n, min), max);
518
+ }
519
+ /**
520
+ * Accept only SELECT or WITH (CTE) statements. Reject any statement that
521
+ * contains a semicolon followed by non-whitespace (prevents statement
522
+ * stacking), and require the first non-comment keyword to be SELECT or WITH.
523
+ *
524
+ * This is a first-line filter — the transaction's READ ONLY mode is the
525
+ * second line of defense. Both must fail before a destructive statement
526
+ * could run.
527
+ */
528
+ export function isReadOnlyStatement(sql) {
529
+ const stripped = stripSqlComments(sql).trim();
530
+ if (!stripped)
531
+ return false;
532
+ // Disallow statement stacking. A single trailing `;` is fine.
533
+ const withoutTrailingSemi = stripped.replace(/;+\s*$/, '');
534
+ if (withoutTrailingSemi.includes(';'))
535
+ return false;
536
+ const firstWord = withoutTrailingSemi.slice(0, 6).toUpperCase();
537
+ if (firstWord.startsWith('SELECT'))
538
+ return true;
539
+ if (firstWord.startsWith('WITH'))
540
+ return true;
541
+ return false;
542
+ }
543
+ function stripSqlComments(sql) {
544
+ // Strip -- line comments and /* block comments */. Not a full SQL parser,
545
+ // but sufficient to catch the common bypass attempts.
546
+ return sql.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
547
+ }
548
+ function serializeRow(row) {
549
+ const out = {};
550
+ for (const [k, v] of Object.entries(row)) {
551
+ if (v instanceof Date) {
552
+ out[k] = v.toISOString();
553
+ }
554
+ else if (typeof v === 'bigint') {
555
+ out[k] = v.toString();
556
+ }
557
+ else if (Buffer.isBuffer(v)) {
558
+ out[k] = `\\x${v.toString('hex')}`;
559
+ }
560
+ else {
561
+ out[k] = v;
562
+ }
563
+ }
564
+ return out;
565
+ }
566
+ async function readJsonBody(req) {
567
+ const chunks = [];
568
+ let total = 0;
569
+ const MAX = 1 << 20; // 1 MB cap on query payloads
570
+ for await (const chunk of req) {
571
+ const buf = chunk;
572
+ total += buf.length;
573
+ if (total > MAX)
574
+ throw new Error('request body too large');
575
+ chunks.push(buf);
576
+ }
577
+ const raw = Buffer.concat(chunks).toString('utf8');
578
+ if (!raw)
579
+ return {};
580
+ try {
581
+ return JSON.parse(raw);
582
+ }
583
+ catch {
584
+ throw new Error('invalid json body');
585
+ }
586
+ }
587
+ function sendJson(res, status, body) {
588
+ const payload = JSON.stringify(body);
589
+ res.writeHead(status, {
590
+ 'Content-Type': 'application/json; charset=utf-8',
591
+ 'Content-Length': Buffer.byteLength(payload),
592
+ 'Cache-Control': 'no-store',
593
+ 'X-Content-Type-Options': 'nosniff',
594
+ 'Referrer-Policy': 'no-referrer',
595
+ });
596
+ res.end(payload);
597
+ }
598
+ function sendText(res, status, body) {
599
+ res.writeHead(status, {
600
+ 'Content-Type': 'text/plain; charset=utf-8',
601
+ 'Content-Length': Buffer.byteLength(body),
602
+ });
603
+ res.end(body);
604
+ }
605
+ function sendHtml(res, status, body) {
606
+ res.writeHead(status, {
607
+ 'Content-Type': 'text/html; charset=utf-8',
608
+ 'Content-Length': Buffer.byteLength(body),
609
+ 'Cache-Control': 'no-store',
610
+ 'X-Content-Type-Options': 'nosniff',
611
+ 'X-Frame-Options': 'DENY',
612
+ 'Referrer-Policy': 'no-referrer',
613
+ });
614
+ res.end(body);
615
+ }
616
+ function openUrl(url) {
617
+ const cmd = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'cmd' : 'xdg-open';
618
+ const args = platform() === 'win32' ? ['/c', 'start', '', url] : [url];
619
+ try {
620
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
621
+ child.unref();
622
+ }
623
+ catch {
624
+ // Non-fatal — user can click the URL manually.
625
+ }
626
+ }
627
+ //# sourceMappingURL=studio.js.map