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.
- 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/cjs/powdb.js +1 -1
- 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/dist/powdb.d.ts +1 -1
- package/dist/powdb.js +1 -1
- package/package.json +3 -3
package/dist/cli/studio.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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;
|
package/dist/cli/studio.js
CHANGED
|
@@ -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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
|
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
|
-
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
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) =>
|
|
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) =>
|
|
461
|
+
const conds = textColumns.map((c) => likeCond(c, 1));
|
|
384
462
|
countWhere = `WHERE (${conds.join(' OR ')})`;
|
|
385
463
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
const
|
|
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
|
-
|
|
392
|
-
|
|
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
|
-
|
|
485
|
+
if (!ctx.demo)
|
|
486
|
+
await client.query('COMMIT');
|
|
396
487
|
sendJson(res, 200, {
|
|
397
488
|
table: table.name,
|
|
398
|
-
columns: result
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
476
|
-
|
|
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
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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`.
|
|
581
|
-
// statement-timeout + search_path
|
|
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
|
-
|
|
584
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
107
|
-
"@zvndev/powdb-embedded": "^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",
|