turbine-orm 0.35.0 → 0.36.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.
- package/README.md +18 -16
- package/dist/cjs/cli/index.js +109 -16
- package/dist/cjs/cli/migrate.js +78 -3
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +333 -22
- package/dist/cjs/cli/ui.js +7 -1
- package/dist/cjs/dialect.js +1 -1
- package/dist/cjs/generate.js +23 -2
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/mssql.js +22 -5
- package/dist/cjs/powdb.js +41 -1
- package/dist/cjs/powql.js +80 -25
- package/dist/cjs/query/aggregates.js +683 -0
- package/dist/cjs/query/batched-loader.js +2 -0
- package/dist/cjs/query/builder.js +297 -4504
- package/dist/cjs/query/filters.js +12 -0
- package/dist/cjs/query/relations.js +1698 -0
- package/dist/cjs/query/where-compile.js +180 -0
- package/dist/cjs/query/where.js +1491 -0
- package/dist/cjs/query/writes.js +680 -0
- package/dist/cjs/schema-builder.js +6 -0
- package/dist/cjs/schema-metadata.js +4 -0
- package/dist/cjs/schema-sql.js +265 -3
- package/dist/cjs/sqlite.js +1 -1
- package/dist/cli/index.d.ts +8 -2
- package/dist/cli/index.js +111 -18
- package/dist/cli/migrate.d.ts +24 -1
- package/dist/cli/migrate.js +77 -3
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +46 -13
- package/dist/cli/studio.js +331 -23
- package/dist/cli/ui.js +7 -1
- package/dist/dialect.d.ts +15 -6
- package/dist/dialect.js +1 -1
- package/dist/generate.js +23 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mssql.js +22 -5
- package/dist/powdb.d.ts +20 -0
- package/dist/powdb.js +40 -0
- package/dist/powql.d.ts +33 -1
- package/dist/powql.js +80 -25
- package/dist/query/aggregates.d.ts +74 -0
- package/dist/query/aggregates.js +641 -0
- package/dist/query/batched-loader.d.ts +6 -0
- package/dist/query/batched-loader.js +2 -0
- package/dist/query/builder.d.ts +62 -829
- package/dist/query/builder.js +302 -4509
- package/dist/query/deferred.d.ts +7 -0
- package/dist/query/filters.d.ts +7 -0
- package/dist/query/filters.js +11 -0
- package/dist/query/relations.d.ts +441 -0
- package/dist/query/relations.js +1627 -0
- package/dist/query/types.d.ts +15 -0
- package/dist/query/where-compile.d.ts +139 -0
- package/dist/query/where-compile.js +175 -0
- package/dist/query/where.d.ts +494 -0
- package/dist/query/where.js +1431 -0
- package/dist/query/writes.d.ts +131 -0
- package/dist/query/writes.js +626 -0
- package/dist/schema-builder.d.ts +18 -3
- package/dist/schema-builder.js +6 -0
- package/dist/schema-metadata.js +4 -0
- package/dist/schema-sql.d.ts +60 -3
- package/dist/schema-sql.js +261 -4
- package/dist/schema.d.ts +10 -0
- package/dist/sqlite.js +1 -1
- package/package.json +4 -4
package/dist/cjs/cli/studio.js
CHANGED
|
@@ -2,36 +2,52 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* turbine-orm CLI — Studio
|
|
4
4
|
*
|
|
5
|
-
* A local
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
5
|
+
* A local web UI for browsing databases, exploring relations, and composing
|
|
6
|
+
* queries visually. ORM-native since v0.19: there is no raw-SQL input surface.
|
|
7
|
+
* The Query tab builds `findMany` args that are validated against introspected
|
|
8
|
+
* metadata and compiled by QueryInterface (`/api/builder`). Pure Node (built-in
|
|
9
|
+
* `http` module), no runtime dependencies beyond `pg`. CLI defaults to 127.0.0.1
|
|
10
|
+
* and refuses non-loopback hosts unless `npx turbine studio --allow-remote`.
|
|
11
|
+
*
|
|
12
|
+
* Read-only by default. `turbine studio --write` opts in to single-row writes
|
|
13
|
+
* (see the write model below); without the flag the write API routes do not
|
|
14
|
+
* exist (they 404) and the UI renders no write affordances.
|
|
12
15
|
*
|
|
13
16
|
* Security model:
|
|
14
17
|
* • Loopback by default; CLI refuses non-loopback without --allow-remote
|
|
15
18
|
* • Random auth token generated per process, required in Cookie header
|
|
16
|
-
* • No SQL input surface at all
|
|
17
|
-
* validated against the introspected schema; all values are
|
|
18
|
-
*
|
|
19
|
+
* • No SQL input surface at all: every identifier in a builder or write
|
|
20
|
+
* request is validated against the introspected schema; all values are
|
|
21
|
+
* $N params compiled through the query builders
|
|
22
|
+
* • Read routes run in a READ ONLY transaction (belt-and-suspenders)
|
|
23
|
+
* • Write routes (only when `--write` is set) run in a plain BEGIN/COMMIT
|
|
24
|
+
* transaction, require a matching Origin header (CSRF), and target exactly
|
|
25
|
+
* one row by its full primary key
|
|
19
26
|
* • 30s statement timeout via parameterized set_config()
|
|
20
|
-
* • Per-session rate limiting,
|
|
27
|
+
* • Per-session rate limiting, cross-origin refusal, security headers, and a
|
|
28
|
+
* per-request CSP nonce for the inline script (no `unsafe-inline`)
|
|
29
|
+
*
|
|
30
|
+
* PII: columns tagged `pii` in code-first metadata are redacted server-side in
|
|
31
|
+
* every row-bearing response (the literal `•• redacted ••`) unless the server
|
|
32
|
+
* was started with `--show-pii`.
|
|
21
33
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
34
|
+
* Write model (opt-in): update/insert/delete a single row. DDL and multi-row or
|
|
35
|
+
* unconditional writes are deliberately unsupported. Use the CLI or migrate for
|
|
36
|
+
* schema changes and bulk operations.
|
|
24
37
|
*/
|
|
25
38
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
26
39
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
40
|
};
|
|
28
41
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
exports.PII_REDACTED = void 0;
|
|
29
43
|
exports.startStudio = startStudio;
|
|
44
|
+
exports.handleRequest = handleRequest;
|
|
30
45
|
exports.apiTableRows = apiTableRows;
|
|
31
46
|
exports.resolveColumnName = resolveColumnName;
|
|
32
47
|
exports.isTextishType = isTextishType;
|
|
33
48
|
exports.escapeLikePattern = escapeLikePattern;
|
|
34
49
|
exports.apiBuilder = apiBuilder;
|
|
50
|
+
exports.apiRowWrite = apiRowWrite;
|
|
35
51
|
exports.apiListSavedQueries = apiListSavedQueries;
|
|
36
52
|
exports.apiCreateSavedQuery = apiCreateSavedQuery;
|
|
37
53
|
exports.apiDeleteSavedQuery = apiDeleteSavedQuery;
|
|
@@ -88,7 +104,17 @@ async function startStudio(options) {
|
|
|
88
104
|
params: ['30s'],
|
|
89
105
|
};
|
|
90
106
|
const rateLimiter = new Map();
|
|
91
|
-
const ctx = {
|
|
107
|
+
const ctx = {
|
|
108
|
+
pool,
|
|
109
|
+
metadata,
|
|
110
|
+
options,
|
|
111
|
+
authToken,
|
|
112
|
+
stateDir,
|
|
113
|
+
statementTimeout,
|
|
114
|
+
rateLimiter,
|
|
115
|
+
writable: options.write === true,
|
|
116
|
+
showPii: options.showPii === true,
|
|
117
|
+
};
|
|
92
118
|
const server = (0, node_http_1.createServer)((req, res) => {
|
|
93
119
|
handleRequest(req, res, ctx).catch((err) => {
|
|
94
120
|
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
@@ -157,7 +183,7 @@ async function handleRequest(req, res, ctx) {
|
|
|
157
183
|
res.end();
|
|
158
184
|
return;
|
|
159
185
|
}
|
|
160
|
-
sendHtml(res, 200, studio_ui_generated_js_1.STUDIO_HTML);
|
|
186
|
+
sendHtml(res, 200, studio_ui_generated_js_1.STUDIO_HTML, cspNonce());
|
|
161
187
|
return;
|
|
162
188
|
}
|
|
163
189
|
// Favicon — answered before the auth gate so the browser's automatic request
|
|
@@ -200,6 +226,26 @@ async function handleRequest(req, res, ctx) {
|
|
|
200
226
|
const id = decodeURIComponent(pathname.slice('/api/saved-queries/'.length));
|
|
201
227
|
return apiDeleteSavedQuery(res, ctx, id);
|
|
202
228
|
}
|
|
229
|
+
// Write routes: ONLY exist in write mode. In read-only mode they fall through
|
|
230
|
+
// to the 404 below (deliberately not 403: a read-only Studio has no such API).
|
|
231
|
+
if (ctx.writable && pathname.startsWith('/api/row/') && req.method === 'POST') {
|
|
232
|
+
// CSRF: a state-changing request MUST carry a same-origin Origin header. The
|
|
233
|
+
// top-of-handler check already rejects a MISMATCHED origin (403); this also
|
|
234
|
+
// rejects an ABSENT one, which read (GET) routes tolerate for curl ergonomics
|
|
235
|
+
// but a browser always sends on a cross-scheme/site POST. `fetch` from the
|
|
236
|
+
// Studio page always sets it for same-origin, so the real UI is unaffected.
|
|
237
|
+
if (origin !== expectedOrigin) {
|
|
238
|
+
sendJson(res, 403, { error: 'a matching Origin header is required for write requests' });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const op = pathname.slice('/api/row/'.length);
|
|
242
|
+
if (op === 'update')
|
|
243
|
+
return apiRowWrite(req, res, ctx, 'update');
|
|
244
|
+
if (op === 'insert')
|
|
245
|
+
return apiRowWrite(req, res, ctx, 'insert');
|
|
246
|
+
if (op === 'delete')
|
|
247
|
+
return apiRowWrite(req, res, ctx, 'delete');
|
|
248
|
+
}
|
|
203
249
|
sendJson(res, 404, { error: 'not found' });
|
|
204
250
|
}
|
|
205
251
|
// ---------------------------------------------------------------------------
|
|
@@ -269,6 +315,7 @@ async function apiSchema(res, ctx) {
|
|
|
269
315
|
nullable: col.nullable,
|
|
270
316
|
hasDefault: col.hasDefault,
|
|
271
317
|
isPrimaryKey: tbl.primaryKey.includes(col.name),
|
|
318
|
+
pii: col.pii === true,
|
|
272
319
|
})),
|
|
273
320
|
relations: Object.entries(tbl.relations).map(([name, rel]) => ({
|
|
274
321
|
name,
|
|
@@ -294,6 +341,10 @@ async function apiSchema(res, ctx) {
|
|
|
294
341
|
schema: ctx.options.schema,
|
|
295
342
|
tables: tables.map((t) => ({ ...t, estimatedRows: counts.get(t.name) ?? 0 })),
|
|
296
343
|
enums: ctx.metadata.enums,
|
|
344
|
+
// Client-config flags the UI reads to gate write affordances / PII masking.
|
|
345
|
+
// Read-only Studio reports `writable: false` so the UI renders no write UI.
|
|
346
|
+
writable: ctx.writable === true,
|
|
347
|
+
showPii: ctx.showPii === true,
|
|
297
348
|
});
|
|
298
349
|
}
|
|
299
350
|
// ---------------------------------------------------------------------------
|
|
@@ -311,10 +362,14 @@ async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
311
362
|
const dir = params.get('dir')?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
312
363
|
// orderBy — accept either the Postgres column name (snake) or the TS field
|
|
313
364
|
// name (camel). Always emit the Postgres column in the SQL.
|
|
365
|
+
// When redaction is on, PII columns are excluded from orderBy (and from the
|
|
366
|
+
// search OR-set below): a redacted value must not be inferable through sort
|
|
367
|
+
// position or substring probing.
|
|
368
|
+
const redactedPii = ctx.showPii ? NO_PII_KEYS : piiKeysForTable(table);
|
|
314
369
|
let orderByClause = '';
|
|
315
370
|
if (orderByRaw) {
|
|
316
371
|
const col = resolveColumnName(table, orderByRaw);
|
|
317
|
-
if (col)
|
|
372
|
+
if (col && !redactedPii.has(col))
|
|
318
373
|
orderByClause = `ORDER BY ${(0, index_js_1.quoteIdent)(col)} ${dir}`;
|
|
319
374
|
}
|
|
320
375
|
if (!orderByClause && table.primaryKey.length > 0 && table.primaryKey[0]) {
|
|
@@ -324,7 +379,9 @@ async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
324
379
|
// parameterized so injection is impossible. Each query gets its own
|
|
325
380
|
// WHERE clause with parameter indices matching that query's param array.
|
|
326
381
|
const search = params.get('search')?.trim() ?? '';
|
|
327
|
-
const textColumns = table.columns
|
|
382
|
+
const textColumns = table.columns
|
|
383
|
+
.filter((c) => isTextishType(c.pgType) && !redactedPii.has(c.name))
|
|
384
|
+
.map((c) => c.name);
|
|
328
385
|
const hasSearch = search.length > 0 && textColumns.length > 0;
|
|
329
386
|
const pattern = hasSearch ? `%${escapeLikePattern(search)}%` : null;
|
|
330
387
|
// Main query: $1 = limit, $2 = offset, $3 = pattern (if search)
|
|
@@ -356,7 +413,7 @@ async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
356
413
|
sendJson(res, 200, {
|
|
357
414
|
table: table.name,
|
|
358
415
|
columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
|
|
359
|
-
rows: result.rows.map((r) => serializeRow(r)),
|
|
416
|
+
rows: result.rows.map((r) => serializeRow(redactFlatRow(r, redactedPii))),
|
|
360
417
|
total: Number(countResult.rows[0]?.count ?? 0),
|
|
361
418
|
limit,
|
|
362
419
|
offset,
|
|
@@ -433,10 +490,12 @@ async function apiBuilder(req, res, ctx) {
|
|
|
433
490
|
const result = await client.query(deferred.sql, deferred.params);
|
|
434
491
|
const elapsedMs = Date.now() - started;
|
|
435
492
|
await client.query('COMMIT');
|
|
493
|
+
const rawRows = result.rows;
|
|
494
|
+
const redactedRows = ctx.showPii ? rawRows : redactBuilderRows(rawRows, tableName, args.with, ctx.metadata);
|
|
436
495
|
sendJson(res, 200, {
|
|
437
496
|
sql: deferred.sql,
|
|
438
497
|
columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
|
|
439
|
-
rows:
|
|
498
|
+
rows: redactedRows.map((r) => serializeRow(r)),
|
|
440
499
|
rowCount: result.rowCount ?? result.rows.length,
|
|
441
500
|
elapsedMs,
|
|
442
501
|
});
|
|
@@ -454,6 +513,163 @@ async function apiBuilder(req, res, ctx) {
|
|
|
454
513
|
client.release();
|
|
455
514
|
}
|
|
456
515
|
}
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
// API: /api/row/update | /api/row/insert | /api/row/delete (single-row writes)
|
|
518
|
+
//
|
|
519
|
+
// Write mode only (the routes do not exist otherwise). Every column identifier
|
|
520
|
+
// is validated against the introspected metadata and the statement is compiled
|
|
521
|
+
// through the query builders (`buildUpdate`/`buildCreate`/`buildDelete`) so all
|
|
522
|
+
// values are $N params; there is no raw SQL. update/delete require the caller
|
|
523
|
+
// to supply the FULL primary key in `where`; the effective predicate is rebuilt
|
|
524
|
+
// from those PK values alone, so a write can only ever touch one row.
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
async function apiRowWrite(req, res, ctx, op) {
|
|
527
|
+
const body = await readJsonBody(req);
|
|
528
|
+
const tableName = typeof body?.table === 'string' ? body.table : '';
|
|
529
|
+
const table = ctx.metadata.tables[tableName];
|
|
530
|
+
if (!table) {
|
|
531
|
+
sendJson(res, 400, { error: unknownTableMessage(tableName, ctx) });
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (table.isView) {
|
|
535
|
+
sendJson(res, 400, { error: `[turbine] "${tableName}" is a view; Studio cannot write to it.` });
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const data = (body?.data && typeof body.data === 'object' ? body.data : {});
|
|
539
|
+
const rawWhere = (body?.where && typeof body.where === 'object' ? body.where : {});
|
|
540
|
+
// Validate every column name up front for a clean typed 400 (the builders
|
|
541
|
+
// would also reject unknowns, but an explicit check keeps the message clear).
|
|
542
|
+
if (op === 'insert' || op === 'update') {
|
|
543
|
+
const badKey = firstUnknownColumn(table, data);
|
|
544
|
+
if (badKey) {
|
|
545
|
+
sendJson(res, 400, { error: `[turbine] unknown column "${badKey}" on table "${tableName}"` });
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (!Object.keys(data).some((k) => data[k] !== undefined)) {
|
|
549
|
+
sendJson(res, 400, { error: '[turbine] `data` must include at least one column' });
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// update/delete: require the table to have a PK and the caller to cover it.
|
|
554
|
+
let effectiveWhere = rawWhere;
|
|
555
|
+
if (op === 'update' || op === 'delete') {
|
|
556
|
+
if (table.primaryKey.length === 0) {
|
|
557
|
+
sendJson(res, 400, {
|
|
558
|
+
error: `[turbine] "${tableName}" has no primary key; single-row writes require one.`,
|
|
559
|
+
});
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const pk = extractPkWhere(table, rawWhere);
|
|
563
|
+
if ('error' in pk) {
|
|
564
|
+
sendJson(res, 400, { error: `[turbine] ${pk.error}` });
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
// Empty-where can never happen by construction (PK covered above); assert.
|
|
568
|
+
if (Object.keys(pk.where).length === 0) {
|
|
569
|
+
sendJson(res, 400, { error: '[turbine] refusing a write with an empty predicate' });
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
effectiveWhere = pk.where;
|
|
573
|
+
}
|
|
574
|
+
let deferred;
|
|
575
|
+
try {
|
|
576
|
+
const qi = new index_js_1.QueryInterface(ctx.pool, tableName, ctx.metadata, [], {
|
|
577
|
+
warnOnUnlimited: false,
|
|
578
|
+
sqlCache: false,
|
|
579
|
+
preparedStatements: false,
|
|
580
|
+
});
|
|
581
|
+
if (op === 'insert') {
|
|
582
|
+
deferred = qi.buildCreate({ data });
|
|
583
|
+
}
|
|
584
|
+
else if (op === 'update') {
|
|
585
|
+
deferred = qi.buildUpdate({ where: effectiveWhere, data });
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
deferred = qi.buildDelete({ where: effectiveWhere });
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
catch (err) {
|
|
592
|
+
sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const client = await ctx.pool.connect();
|
|
596
|
+
try {
|
|
597
|
+
// A real write transaction, NOT `READ ONLY`. Same parameterized
|
|
598
|
+
// statement-timeout + search_path pin as the read paths.
|
|
599
|
+
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]);
|
|
602
|
+
const result = await client.query(deferred.sql, deferred.params);
|
|
603
|
+
await client.query('COMMIT');
|
|
604
|
+
const row = result.rows[0];
|
|
605
|
+
if (!row) {
|
|
606
|
+
const msg = op === 'insert' ? 'insert returned no row' : 'no row matched the primary key';
|
|
607
|
+
sendJson(res, 404, { error: `[turbine] ${msg}` });
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
// The echoed row is redacted the same way as any read (unless --show-pii),
|
|
611
|
+
// even though a write to a pii column is allowed.
|
|
612
|
+
const piiKeys = ctx.showPii ? NO_PII_KEYS : piiKeysForTable(table);
|
|
613
|
+
sendJson(res, 200, {
|
|
614
|
+
operation: op,
|
|
615
|
+
row: serializeRow(redactFlatRow(row, piiKeys)),
|
|
616
|
+
rowCount: result.rowCount ?? 1,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
catch (err) {
|
|
620
|
+
try {
|
|
621
|
+
await client.query('ROLLBACK');
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
/* ignore */
|
|
625
|
+
}
|
|
626
|
+
sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
627
|
+
}
|
|
628
|
+
finally {
|
|
629
|
+
client.release();
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Return the first key in `obj` that does not resolve to a real column on
|
|
634
|
+
* `table` (accepting either the camelCase field or snake_case column name), or
|
|
635
|
+
* `null` when every key is valid. Skips `undefined` values.
|
|
636
|
+
*/
|
|
637
|
+
function firstUnknownColumn(table, obj) {
|
|
638
|
+
for (const k of Object.keys(obj)) {
|
|
639
|
+
if (obj[k] === undefined)
|
|
640
|
+
continue;
|
|
641
|
+
if (!resolveColumnName(table, k))
|
|
642
|
+
return k;
|
|
643
|
+
}
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Build a primary-key-only `where` from the caller's `where`. Every PK column
|
|
648
|
+
* must be present (as its field or column name) with a scalar value; anything
|
|
649
|
+
* else is rejected so a write can only ever target one row. Keys are emitted as
|
|
650
|
+
* the camelCase field name (the query builder accepts field or column names).
|
|
651
|
+
*/
|
|
652
|
+
function extractPkWhere(table, where) {
|
|
653
|
+
const resolved = new Map();
|
|
654
|
+
for (const [k, v] of Object.entries(where)) {
|
|
655
|
+
const col = resolveColumnName(table, k);
|
|
656
|
+
if (col)
|
|
657
|
+
resolved.set(col, v);
|
|
658
|
+
}
|
|
659
|
+
const pkWhere = {};
|
|
660
|
+
for (const pkCol of table.primaryKey) {
|
|
661
|
+
if (!resolved.has(pkCol)) {
|
|
662
|
+
return { error: `\`where\` must fully cover the primary key (missing "${pkCol}")` };
|
|
663
|
+
}
|
|
664
|
+
const v = resolved.get(pkCol);
|
|
665
|
+
if (v === undefined || v === null || typeof v === 'object') {
|
|
666
|
+
return { error: `primary key "${pkCol}" must be a scalar value in \`where\`` };
|
|
667
|
+
}
|
|
668
|
+
const field = table.reverseColumnMap[pkCol] ?? pkCol;
|
|
669
|
+
pkWhere[field] = v;
|
|
670
|
+
}
|
|
671
|
+
return { where: pkWhere };
|
|
672
|
+
}
|
|
457
673
|
function savedQueriesPath(ctx) {
|
|
458
674
|
return (0, node_path_1.resolve)(ctx.stateDir, 'studio-queries.json');
|
|
459
675
|
}
|
|
@@ -541,6 +757,94 @@ function apiDeleteSavedQuery(res, ctx, id) {
|
|
|
541
757
|
// ---------------------------------------------------------------------------
|
|
542
758
|
// Helpers
|
|
543
759
|
// ---------------------------------------------------------------------------
|
|
760
|
+
// ---------------------------------------------------------------------------
|
|
761
|
+
// PII redaction
|
|
762
|
+
// ---------------------------------------------------------------------------
|
|
763
|
+
/** The literal replacement value for a redacted PII cell. */
|
|
764
|
+
exports.PII_REDACTED = '•• redacted ••';
|
|
765
|
+
/** Shared empty key set for the `--show-pii` fast path (no redaction). */
|
|
766
|
+
const NO_PII_KEYS = new Set();
|
|
767
|
+
/**
|
|
768
|
+
* The set of keys (both snake_case column and camelCase field names) for the
|
|
769
|
+
* table's PII-tagged columns. Covering both spellings means the same set works
|
|
770
|
+
* for `SELECT *` rows (snake keys) and for json_build_object relation rows
|
|
771
|
+
* (camel keys).
|
|
772
|
+
*/
|
|
773
|
+
function piiKeysForTable(table) {
|
|
774
|
+
const keys = new Set();
|
|
775
|
+
for (const col of table.columns) {
|
|
776
|
+
if (col.pii === true) {
|
|
777
|
+
keys.add(col.name);
|
|
778
|
+
keys.add(col.field);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return keys;
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Redact PII keys in a single flat row. Returns the row unchanged when there is
|
|
785
|
+
* nothing to redact (no allocation); otherwise a shallow copy with each present,
|
|
786
|
+
* non-null PII value replaced by {@link PII_REDACTED}. A null/undefined value
|
|
787
|
+
* carries no PII, so it is left as-is.
|
|
788
|
+
*/
|
|
789
|
+
function redactFlatRow(row, piiKeys) {
|
|
790
|
+
if (piiKeys.size === 0)
|
|
791
|
+
return row;
|
|
792
|
+
let out = null;
|
|
793
|
+
for (const k of Object.keys(row)) {
|
|
794
|
+
if (piiKeys.has(k) && row[k] !== null && row[k] !== undefined) {
|
|
795
|
+
if (!out)
|
|
796
|
+
out = { ...row };
|
|
797
|
+
out[k] = exports.PII_REDACTED;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return out ?? row;
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Redact PII in builder result rows, walking the `with` tree so nested relation
|
|
804
|
+
* rows are redacted against THEIR target table's PII columns (relation rows
|
|
805
|
+
* arrive as parsed json objects keyed by camelCase field names).
|
|
806
|
+
*/
|
|
807
|
+
function redactBuilderRows(rows, tableName, withClause, metadata) {
|
|
808
|
+
const table = metadata.tables[tableName];
|
|
809
|
+
if (!table)
|
|
810
|
+
return rows;
|
|
811
|
+
const piiKeys = piiKeysForTable(table);
|
|
812
|
+
const relEntries = withClause && typeof withClause === 'object'
|
|
813
|
+
? Object.entries(withClause).filter(([, v]) => v)
|
|
814
|
+
: [];
|
|
815
|
+
// Nothing to do at this level or below → return as-is.
|
|
816
|
+
if (piiKeys.size === 0 && relEntries.length === 0)
|
|
817
|
+
return rows;
|
|
818
|
+
return rows.map((row) => {
|
|
819
|
+
const out = { ...row };
|
|
820
|
+
for (const k of piiKeys) {
|
|
821
|
+
if (k in out && out[k] !== null && out[k] !== undefined)
|
|
822
|
+
out[k] = exports.PII_REDACTED;
|
|
823
|
+
}
|
|
824
|
+
for (const [relName, relVal] of relEntries) {
|
|
825
|
+
const rel = table.relations[relName];
|
|
826
|
+
if (!rel)
|
|
827
|
+
continue;
|
|
828
|
+
const nestedWith = relVal && typeof relVal === 'object' ? relVal.with : undefined;
|
|
829
|
+
const child = out[relName];
|
|
830
|
+
if (Array.isArray(child)) {
|
|
831
|
+
out[relName] = redactBuilderRows(child, rel.to, nestedWith, metadata);
|
|
832
|
+
}
|
|
833
|
+
else if (child && typeof child === 'object') {
|
|
834
|
+
out[relName] = redactBuilderRows([child], rel.to, nestedWith, metadata)[0];
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return out;
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* A fresh CSP nonce for one HTML response. Base64 of 16 random bytes; the value
|
|
842
|
+
* is stamped into both the `Content-Security-Policy` header and the inline
|
|
843
|
+
* `<script nonce="...">` tag(s) so `unsafe-inline` can be dropped from script-src.
|
|
844
|
+
*/
|
|
845
|
+
function cspNonce() {
|
|
846
|
+
return (0, node_crypto_1.randomBytes)(16).toString('base64');
|
|
847
|
+
}
|
|
544
848
|
function clampInt(value, fallback, min, max) {
|
|
545
849
|
if (value == null)
|
|
546
850
|
return fallback;
|
|
@@ -596,7 +900,9 @@ function sendJson(res, status, body) {
|
|
|
596
900
|
'Cache-Control': 'no-store',
|
|
597
901
|
'X-Content-Type-Options': 'nosniff',
|
|
598
902
|
'Referrer-Policy': 'no-referrer',
|
|
599
|
-
|
|
903
|
+
// JSON responses render no document; no inline script is needed, so keep
|
|
904
|
+
// script-src to 'self' with no 'unsafe-inline'.
|
|
905
|
+
'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'",
|
|
600
906
|
});
|
|
601
907
|
res.end(payload);
|
|
602
908
|
}
|
|
@@ -607,7 +913,10 @@ function sendText(res, status, body) {
|
|
|
607
913
|
});
|
|
608
914
|
res.end(body);
|
|
609
915
|
}
|
|
610
|
-
function sendHtml(res, status,
|
|
916
|
+
function sendHtml(res, status, template, nonce) {
|
|
917
|
+
// Stamp the per-request nonce into the inline <script nonce="__CSP_NONCE__">
|
|
918
|
+
// tag(s) so the CSP can use a nonce instead of 'unsafe-inline'.
|
|
919
|
+
const body = template.replaceAll('__CSP_NONCE__', nonce);
|
|
611
920
|
res.writeHead(status, {
|
|
612
921
|
'Content-Type': 'text/html; charset=utf-8',
|
|
613
922
|
'Content-Length': Buffer.byteLength(body),
|
|
@@ -615,7 +924,9 @@ function sendHtml(res, status, body) {
|
|
|
615
924
|
'X-Content-Type-Options': 'nosniff',
|
|
616
925
|
'X-Frame-Options': 'DENY',
|
|
617
926
|
'Referrer-Policy': 'no-referrer',
|
|
618
|
-
|
|
927
|
+
// style-src keeps 'unsafe-inline' (nonces don't cover style="" attributes,
|
|
928
|
+
// which the UI relies on); script-src moves to the per-request nonce.
|
|
929
|
+
'Content-Security-Policy': `default-src 'none'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'`,
|
|
619
930
|
});
|
|
620
931
|
res.end(body);
|
|
621
932
|
}
|
package/dist/cjs/cli/ui.js
CHANGED
|
@@ -229,5 +229,11 @@ function stripAnsi(s) {
|
|
|
229
229
|
// Redact password from connection URL
|
|
230
230
|
// ---------------------------------------------------------------------------
|
|
231
231
|
function redactUrl(url) {
|
|
232
|
-
return url
|
|
232
|
+
return (url
|
|
233
|
+
// Userinfo credentials: `:secret@` in any authority (global: a string may
|
|
234
|
+
// carry more than one URL, e.g. a primary + replica connection pair).
|
|
235
|
+
.replace(/:([^@/:]+)@/g, ':***@')
|
|
236
|
+
// Query-string password params: `password=`, `sslpassword=`, and similar,
|
|
237
|
+
// case-insensitive. Value runs up to the next `&`, `#`, or end of string.
|
|
238
|
+
.replace(/([?&][^=&#]*password)=([^&#]*)/gi, '$1=***'));
|
|
233
239
|
}
|
package/dist/cjs/dialect.js
CHANGED
|
@@ -105,7 +105,7 @@ exports.postgresDialect = {
|
|
|
105
105
|
return `COALESCE((${subquery}), ${fallback})`;
|
|
106
106
|
},
|
|
107
107
|
buildReturningClause(selection = '*') {
|
|
108
|
-
return ` RETURNING ${selection}`;
|
|
108
|
+
return ` RETURNING ${selection === '*' ? '*' : selection.join(', ')}`;
|
|
109
109
|
},
|
|
110
110
|
buildInsertStatement(input) {
|
|
111
111
|
return `INSERT INTO ${input.table} (${input.columns.join(', ')}) VALUES (${input.valuePlaceholders.join(', ')})${this.buildReturningClause(input.returning)}`;
|
package/dist/cjs/generate.js
CHANGED
|
@@ -168,8 +168,13 @@ function generateTypes(schema, options) {
|
|
|
168
168
|
for (const col of table.columns) {
|
|
169
169
|
const pkNote = table.primaryKey.includes(col.name) ? ' (primary key)' : '';
|
|
170
170
|
const nullNote = col.nullable ? ' (nullable)' : '';
|
|
171
|
-
|
|
172
|
-
|
|
171
|
+
// PII columns are excluded from default projections, so the field is
|
|
172
|
+
// absent unless the query names it in `select` or passes `includePii`.
|
|
173
|
+
// The emitted type marks it optional so it tells the truth about absence.
|
|
174
|
+
const piiNote = col.pii ? ' (PII: absent unless selected or includePii)' : '';
|
|
175
|
+
const optional = col.pii ? '?' : '';
|
|
176
|
+
lines.push(` /** Column: ${col.name}, ${col.pgType}${pkNote}${nullNote}${piiNote} */`);
|
|
177
|
+
lines.push(` ${col.field}${optional}: ${columnTsType(col, schema.enums)};`);
|
|
173
178
|
}
|
|
174
179
|
lines.push('}');
|
|
175
180
|
lines.push('');
|
|
@@ -531,6 +536,17 @@ function generateMetadata(schema, options) {
|
|
|
531
536
|
lines.push(` { name: '${escSQ(idx.name)}', columns: [${idx.columns.map((c) => `'${escSQ(c)}'`).join(', ')}], unique: ${idx.unique}, definition: ${JSON.stringify(idx.definition)} },`);
|
|
532
537
|
}
|
|
533
538
|
lines.push(' ],');
|
|
539
|
+
// checks: introspected named CHECK constraints. Emitted only when present
|
|
540
|
+
// (byte-stable for check-less tables) and sorted by name so `--no-timestamp`
|
|
541
|
+
// output is deterministic regardless of catalog row order.
|
|
542
|
+
if (table.checks && table.checks.length > 0) {
|
|
543
|
+
const sortedChecks = [...table.checks].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
544
|
+
lines.push(' checks: [');
|
|
545
|
+
for (const chk of sortedChecks) {
|
|
546
|
+
lines.push(` { name: '${escSQ(chk.name)}', expression: ${JSON.stringify(chk.expression)} },`);
|
|
547
|
+
}
|
|
548
|
+
lines.push(' ],');
|
|
549
|
+
}
|
|
534
550
|
// isView — read-only marker; the runtime write guard reads it.
|
|
535
551
|
if (table.isView)
|
|
536
552
|
lines.push(' isView: true,');
|
|
@@ -734,6 +750,11 @@ function serializeColumn(col) {
|
|
|
734
750
|
if (col.generationExpression !== undefined) {
|
|
735
751
|
parts.push(`generationExpression: '${escSQ(col.generationExpression)}'`);
|
|
736
752
|
}
|
|
753
|
+
// PII marker: emitted only when set, so untagged schemas stay byte-identical.
|
|
754
|
+
// Introspection never sets this (code-first declaration), but a metadata
|
|
755
|
+
// object built from `defineSchema` (pii: true) carries it through codegen.
|
|
756
|
+
if (col.pii)
|
|
757
|
+
parts.push(`pii: true`);
|
|
737
758
|
if (col.maxLength !== undefined)
|
|
738
759
|
parts.push(`maxLength: ${col.maxLength}`);
|
|
739
760
|
return `{ ${parts.join(', ')} }`;
|
package/dist/cjs/index.js
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
37
|
exports.applyManyToManyRelations = exports.snakeToPascal = exports.snakeToCamel = exports.singularize = exports.pgTypeToTs = exports.pgArrayType = exports.normalizeKeyColumns = exports.isDateType = exports.camelToSnake = exports.validateChannel = exports.QueryInterface = exports.pipelineSupported = exports.executePipeline = exports.hasRelationFields = exports.executeNestedUpdate = exports.executeNestedCreate = exports.introspect = exports.generate = exports.wrapPgError = exports.ValidationError = exports.UnsupportedFeatureError = exports.UniqueConstraintError = exports.TurbineErrorCode = exports.TurbineError = exports.TimeoutError = exports.setErrorMessageMode = exports.SerializationFailureError = exports.RelationError = exports.ReadOnlyError = exports.PipelineError = exports.OptimisticLockError = exports.NotNullViolationError = exports.NotFoundError = exports.MigrationError = exports.getErrorMessageMode = exports.ForeignKeyError = exports.ExclusionConstraintError = exports.DeadlockError = exports.ConnectionError = exports.CircularRelationError = exports.CheckConstraintError = exports.postgresDialect = exports.withRetry = exports.TurbineClient = exports.TransactionClient = exports.yugabytedb = exports.timescale = exports.postgresql = exports.cockroachdb = exports.alloydb = void 0;
|
|
38
|
-
exports.TypedSqlQuery = exports.buildTypedSql = exports.turbineHttp = exports.defineSeed = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.schemaDefToMetadata = exports.table = exports.isDocFieldIndexDef = exports.defineSchema = exports.column = exports.ColumnBuilder = void 0;
|
|
38
|
+
exports.TypedSqlQuery = exports.buildTypedSql = exports.turbineHttp = exports.defineSeed = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.DestructivePushRefusal = exports.schemaDefToMetadata = exports.table = exports.isDocFieldIndexDef = exports.defineSchema = exports.column = exports.ColumnBuilder = void 0;
|
|
39
39
|
var index_js_1 = require("./adapters/index.js");
|
|
40
40
|
Object.defineProperty(exports, "alloydb", { enumerable: true, get: function () { return index_js_1.alloydb; } });
|
|
41
41
|
Object.defineProperty(exports, "cockroachdb", { enumerable: true, get: function () { return index_js_1.cockroachdb; } });
|
|
@@ -119,6 +119,7 @@ var schema_metadata_js_1 = require("./schema-metadata.js");
|
|
|
119
119
|
Object.defineProperty(exports, "schemaDefToMetadata", { enumerable: true, get: function () { return schema_metadata_js_1.schemaDefToMetadata; } });
|
|
120
120
|
// Schema SQL — generate DDL, diff, and push
|
|
121
121
|
var schema_sql_js_1 = require("./schema-sql.js");
|
|
122
|
+
Object.defineProperty(exports, "DestructivePushRefusal", { enumerable: true, get: function () { return schema_sql_js_1.DestructivePushRefusal; } });
|
|
122
123
|
Object.defineProperty(exports, "schemaDiff", { enumerable: true, get: function () { return schema_sql_js_1.schemaDiff; } });
|
|
123
124
|
Object.defineProperty(exports, "schemaPush", { enumerable: true, get: function () { return schema_sql_js_1.schemaPush; } });
|
|
124
125
|
Object.defineProperty(exports, "schemaToSQL", { enumerable: true, get: function () { return schema_sql_js_1.schemaToSQL; } });
|
package/dist/cjs/mssql.js
CHANGED
|
@@ -459,6 +459,23 @@ function mssqlColumnType(type, maxLength) {
|
|
|
459
459
|
// ---------------------------------------------------------------------------
|
|
460
460
|
// mssqlDialect — the full Dialect contract for SQL Server 2016+
|
|
461
461
|
// ---------------------------------------------------------------------------
|
|
462
|
+
/**
|
|
463
|
+
* Render the SQL Server `OUTPUT` clause for a write's returning selection.
|
|
464
|
+
* `'*'` → ` OUTPUT INSERTED.*` (byte-identical to the historical default); a
|
|
465
|
+
* quoted column list → ` OUTPUT INSERTED.[c1], INSERTED.[c2]`; each column
|
|
466
|
+
* carries its own `INSERTED.`/`DELETED.` prefix (a bare comma list is invalid
|
|
467
|
+
* T-SQL). Used to exclude PII columns from a write's returned row. Empty
|
|
468
|
+
* selection → no clause.
|
|
469
|
+
*/
|
|
470
|
+
function mssqlOutput(returning, alias) {
|
|
471
|
+
if (!returning)
|
|
472
|
+
return '';
|
|
473
|
+
if (returning === '*')
|
|
474
|
+
return ` OUTPUT ${alias}.*`;
|
|
475
|
+
if (returning.length === 0)
|
|
476
|
+
return '';
|
|
477
|
+
return ` OUTPUT ${returning.map((col) => `${alias}.${col}`).join(', ')}`;
|
|
478
|
+
}
|
|
462
479
|
/**
|
|
463
480
|
* SQL Server 2016+ implementation of the {@link Dialect} contract. Bracket
|
|
464
481
|
* identifier quoting (`[…]`), named `@pN` placeholders, the `FOR JSON PATH`
|
|
@@ -522,7 +539,7 @@ exports.mssqlDialect = {
|
|
|
522
539
|
return '';
|
|
523
540
|
},
|
|
524
541
|
buildInsertStatement(input) {
|
|
525
|
-
const out = input.returning
|
|
542
|
+
const out = mssqlOutput(input.returning, 'INSERTED');
|
|
526
543
|
return `INSERT INTO ${input.table} (${input.columns.join(', ')})${out} VALUES (${input.valuePlaceholders.join(', ')})`;
|
|
527
544
|
},
|
|
528
545
|
buildBulkInsertStatement(input) {
|
|
@@ -542,7 +559,7 @@ exports.mssqlDialect = {
|
|
|
542
559
|
const placeholders = input.rowValues
|
|
543
560
|
.map((row) => `(${row.map(() => this.paramPlaceholder(++n)).join(', ')})`)
|
|
544
561
|
.join(', ');
|
|
545
|
-
const out = input.returning
|
|
562
|
+
const out = mssqlOutput(input.returning, 'INSERTED');
|
|
546
563
|
// skipDuplicates has no single-statement equivalent here; ignored (documented).
|
|
547
564
|
return {
|
|
548
565
|
sql: `INSERT INTO ${input.table} (${input.columns.join(', ')})${out} VALUES ${placeholders}`,
|
|
@@ -557,7 +574,7 @@ exports.mssqlDialect = {
|
|
|
557
574
|
const on = input.conflictColumns.map((c) => `T.${c} = S.${c}`).join(' AND ');
|
|
558
575
|
const insertCols = input.insertColumns.join(', ');
|
|
559
576
|
const sourceVals = input.insertColumns.map((c) => `S.${c}`).join(', ');
|
|
560
|
-
const out = input.returning
|
|
577
|
+
const out = mssqlOutput(input.returning, 'INSERTED');
|
|
561
578
|
return (`MERGE INTO ${input.table} AS T ` +
|
|
562
579
|
`USING (VALUES (${input.valuePlaceholders.join(', ')})) AS S (${insertCols}) ` +
|
|
563
580
|
`ON (${on}) ` +
|
|
@@ -568,11 +585,11 @@ exports.mssqlDialect = {
|
|
|
568
585
|
// UPDATE/DELETE inject OUTPUT mid-statement (between SET and WHERE / FROM and
|
|
569
586
|
// WHERE) — a trailing clause would be invalid T-SQL.
|
|
570
587
|
buildUpdateStatement(input) {
|
|
571
|
-
const out = input.returning
|
|
588
|
+
const out = mssqlOutput(input.returning, 'INSERTED');
|
|
572
589
|
return `UPDATE ${input.table} SET ${input.setClauses.join(', ')}${out}${input.whereSql}`;
|
|
573
590
|
},
|
|
574
591
|
buildDeleteStatement(input) {
|
|
575
|
-
const out = input.returning
|
|
592
|
+
const out = mssqlOutput(input.returning, 'DELETED');
|
|
576
593
|
return `DELETE FROM ${input.table}${out}${input.whereSql}`;
|
|
577
594
|
},
|
|
578
595
|
// SQL Server has no LIMIT — emit OFFSET/FETCH, injecting a stable ORDER BY when
|