turbine-orm 0.36.1 → 0.38.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 +6 -4
- 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 +502 -127
- package/dist/cjs/errors.js +27 -0
- package/dist/cjs/nested-write.js +7 -7
- package/dist/cjs/query/batched-loader.js +4 -3
- package/dist/cjs/query/builder.js +9 -5
- package/dist/cjs/query/relations.js +7 -6
- package/dist/cjs/query/utils.js +13 -0
- package/dist/cjs/query/where-compile.js +2 -1
- package/dist/cjs/query/where.js +3 -3
- 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 +58 -8
- package/dist/cli/studio.js +501 -127
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +26 -0
- package/dist/nested-write.js +8 -8
- package/dist/query/batched-loader.js +4 -3
- package/dist/query/builder.js +10 -6
- package/dist/query/relations.js +7 -6
- package/dist/query/utils.d.ts +10 -0
- package/dist/query/utils.js +12 -0
- package/dist/query/where-compile.js +2 -1
- package/dist/query/where.js +4 -4
- package/package.json +1 -1
package/dist/cjs/errors.js
CHANGED
|
@@ -9,6 +9,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
9
9
|
exports.ReadOnlyError = exports.UnsupportedFeatureError = exports.OptimisticLockError = exports.PipelineError = exports.ExclusionConstraintError = exports.CheckConstraintError = exports.SerializationFailureError = exports.DeadlockError = exports.NotNullViolationError = exports.ForeignKeyError = exports.UniqueConstraintError = exports.CircularRelationError = exports.MigrationError = exports.RelationError = exports.ConnectionError = exports.ValidationError = exports.TimeoutError = exports.NotFoundError = exports.TurbineError = exports.TurbineErrorCode = void 0;
|
|
10
10
|
exports.setErrorMessageMode = setErrorMessageMode;
|
|
11
11
|
exports.getErrorMessageMode = getErrorMessageMode;
|
|
12
|
+
exports.describeTargetForMessage = describeTargetForMessage;
|
|
12
13
|
exports.wrapPgError = wrapPgError;
|
|
13
14
|
/** Error codes for all Turbine errors */
|
|
14
15
|
exports.TurbineErrorCode = {
|
|
@@ -72,6 +73,32 @@ function setErrorMessageMode(mode) {
|
|
|
72
73
|
function getErrorMessageMode() {
|
|
73
74
|
return errorMessageMode;
|
|
74
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Render a user-supplied `where` / `connect` target for a "no row found" error
|
|
78
|
+
* message, honoring the global {@link ErrorMessageMode}. In 'safe' mode (the
|
|
79
|
+
* default) only the key names are shown (`keys [email, id]`) so that PII values
|
|
80
|
+
* never leak into logs; in 'verbose' mode the full JSON serialization is used.
|
|
81
|
+
*
|
|
82
|
+
* This mirrors {@link NotFoundError}'s redaction so that every "no row found"
|
|
83
|
+
* message in the library follows one convention, including the nested-write
|
|
84
|
+
* connect/update failures which historically embedded the raw values.
|
|
85
|
+
*/
|
|
86
|
+
function describeTargetForMessage(target) {
|
|
87
|
+
if (errorMessageMode === 'verbose') {
|
|
88
|
+
try {
|
|
89
|
+
return JSON.stringify(target);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return '[unserializable]';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// safe mode: key names only
|
|
96
|
+
if (target === null || target === undefined || typeof target !== 'object') {
|
|
97
|
+
return 'keys []';
|
|
98
|
+
}
|
|
99
|
+
const keys = Object.keys(target);
|
|
100
|
+
return `keys [${keys.join(', ')}]`;
|
|
101
|
+
}
|
|
75
102
|
/**
|
|
76
103
|
* Render a `where` clause for error messages. In 'safe' mode (the default),
|
|
77
104
|
* only the keys are shown; values are stripped to avoid leaking PII into logs.
|
package/dist/cjs/nested-write.js
CHANGED
|
@@ -39,7 +39,7 @@ function extractRelationFields(data, tableMeta) {
|
|
|
39
39
|
const scalars = {};
|
|
40
40
|
const relations = {};
|
|
41
41
|
for (const [key, value] of Object.entries(data)) {
|
|
42
|
-
if (
|
|
42
|
+
if (Object.hasOwn(tableMeta.relations, key) &&
|
|
43
43
|
value !== null &&
|
|
44
44
|
typeof value === 'object' &&
|
|
45
45
|
!Array.isArray(value) &&
|
|
@@ -59,7 +59,7 @@ function extractRelationFields(data, tableMeta) {
|
|
|
59
59
|
*/
|
|
60
60
|
function hasRelationFields(data, tableMeta) {
|
|
61
61
|
for (const key of Object.keys(data)) {
|
|
62
|
-
if (
|
|
62
|
+
if (Object.hasOwn(tableMeta.relations, key)) {
|
|
63
63
|
const val = data[key];
|
|
64
64
|
if (val !== null && typeof val === 'object' && !Array.isArray(val) && !(val instanceof Date)) {
|
|
65
65
|
return true;
|
|
@@ -214,7 +214,7 @@ async function executeNestedUpdate(ctx, tableName, where, data, depth = 0, path
|
|
|
214
214
|
else {
|
|
215
215
|
parentRow = (await ctx.tx.table(tableName).findUnique({ where }));
|
|
216
216
|
if (!parentRow) {
|
|
217
|
-
throw new errors_js_1.ValidationError(`[turbine] update: no ${tableName} row found matching ${
|
|
217
|
+
throw new errors_js_1.ValidationError(`[turbine] update: no ${tableName} row found matching ${(0, errors_js_1.describeTargetForMessage)(where)}.`);
|
|
218
218
|
}
|
|
219
219
|
}
|
|
220
220
|
// Process each relation
|
|
@@ -297,7 +297,7 @@ async function processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relNa
|
|
|
297
297
|
if (items.length > 0) {
|
|
298
298
|
// Check if any items have nested relations (need per-row recursion)
|
|
299
299
|
const childTable = ctx.schema.tables[rel.to];
|
|
300
|
-
const hasNested = childTable && items.some((item) => Object.keys(item).some((k) =>
|
|
300
|
+
const hasNested = childTable && items.some((item) => Object.keys(item).some((k) => Object.hasOwn(childTable.relations ?? {}, k)));
|
|
301
301
|
if (hasNested) {
|
|
302
302
|
// Per-row recursive create for items with nested relations
|
|
303
303
|
for (const item of items) {
|
|
@@ -357,7 +357,7 @@ async function resolveBelongsToForCreate(ctx, rel, ops, parentTable, depth, path
|
|
|
357
357
|
const target = items[0];
|
|
358
358
|
relatedRow = (await ctx.tx.table(rel.to).findUnique({ where: target }));
|
|
359
359
|
if (!relatedRow) {
|
|
360
|
-
throw new errors_js_1.ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${
|
|
360
|
+
throw new errors_js_1.ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${(0, errors_js_1.describeTargetForMessage)(target)}.`);
|
|
361
361
|
}
|
|
362
362
|
}
|
|
363
363
|
}
|
|
@@ -412,7 +412,7 @@ async function processBelongsToCreate(ctx, rel, ops, parentRow, parentTable, dep
|
|
|
412
412
|
const target = items[0];
|
|
413
413
|
const existing = await ctx.tx.table(rel.to).findUnique({ where: target });
|
|
414
414
|
if (!existing) {
|
|
415
|
-
throw new errors_js_1.ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${
|
|
415
|
+
throw new errors_js_1.ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${(0, errors_js_1.describeTargetForMessage)(target)}.`);
|
|
416
416
|
}
|
|
417
417
|
const updateData = {};
|
|
418
418
|
const relatedTable = ctx.schema.tables[rel.to];
|
|
@@ -442,7 +442,7 @@ async function batchConnect(ctx, rel, items, parentRow) {
|
|
|
442
442
|
for (const target of items) {
|
|
443
443
|
const existing = await ctx.tx.table(rel.to).findUnique({ where: target });
|
|
444
444
|
if (!existing) {
|
|
445
|
-
throw new errors_js_1.ValidationError(`[turbine] connect: no ${rel.to} row found matching ${
|
|
445
|
+
throw new errors_js_1.ValidationError(`[turbine] connect: no ${rel.to} row found matching ${(0, errors_js_1.describeTargetForMessage)(target)}.`);
|
|
446
446
|
}
|
|
447
447
|
}
|
|
448
448
|
// Build FK update data to point children at parent
|
|
@@ -57,6 +57,7 @@ exports.loadRelationsBatched = loadRelationsBatched;
|
|
|
57
57
|
const errors_js_1 = require("../errors.js");
|
|
58
58
|
const schema_js_1 = require("../schema.js");
|
|
59
59
|
const filters_js_1 = require("./filters.js");
|
|
60
|
+
const utils_js_1 = require("./utils.js");
|
|
60
61
|
/**
|
|
61
62
|
* Max parent keys per follow-up query. On Postgres the whole key set travels as
|
|
62
63
|
* ONE array parameter (`= ANY($1)`), so this is not a bind-parameter limit — it
|
|
@@ -131,7 +132,7 @@ function neededParentKeyFields(parentMeta, withClause) {
|
|
|
131
132
|
}
|
|
132
133
|
continue;
|
|
133
134
|
}
|
|
134
|
-
const rel = parentMeta.relations
|
|
135
|
+
const rel = (0, utils_js_1.ownLookup)(parentMeta.relations, relName);
|
|
135
136
|
if (!rel)
|
|
136
137
|
continue; // unknown relation — the join path throws; let the loader surface it
|
|
137
138
|
for (const col of localKeyColumns(rel)) {
|
|
@@ -169,7 +170,7 @@ function resolveCountRelations(parentMeta, countSpec) {
|
|
|
169
170
|
for (const [relName, enabled] of Object.entries(countSpec)) {
|
|
170
171
|
if (!enabled)
|
|
171
172
|
continue;
|
|
172
|
-
const rel = parentMeta.relations
|
|
173
|
+
const rel = (0, utils_js_1.ownLookup)(parentMeta.relations, relName);
|
|
173
174
|
if (!rel) {
|
|
174
175
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" in _count on table "${parentMeta.name}". ` +
|
|
175
176
|
`Available: ${Object.keys(parentMeta.relations).join(', ')}`);
|
|
@@ -240,7 +241,7 @@ async function loadRelationsBatched(ctx, parents, withClause, timeout, depth = 0
|
|
|
240
241
|
loads.push(loadCounts(ctx, parents, spec));
|
|
241
242
|
continue;
|
|
242
243
|
}
|
|
243
|
-
const rel = ctx.parentMeta.relations
|
|
244
|
+
const rel = (0, utils_js_1.ownLookup)(ctx.parentMeta.relations, relName);
|
|
244
245
|
if (!rel) {
|
|
245
246
|
throw new errors_js_1.ValidationError(`[turbine] Unknown relation "${relName}" on table "${ctx.parentMeta.name}". ` +
|
|
246
247
|
`Available: ${Object.keys(ctx.parentMeta.relations).join(', ')}`);
|
|
@@ -892,7 +892,7 @@ class QueryInterface {
|
|
|
892
892
|
!whereObj.NOT &&
|
|
893
893
|
whereKeys.every((k) => {
|
|
894
894
|
const v = whereObj[k];
|
|
895
|
-
return v !== null && !(0, filters_js_1.isWhereOperator)(v) && !this.tableMeta.relations
|
|
895
|
+
return v !== null && !(0, filters_js_1.isWhereOperator)(v) && !(0, utils_js_1.ownLookup)(this.tableMeta.relations, k);
|
|
896
896
|
});
|
|
897
897
|
// Simple path: plain equality, no operators/null/OR
|
|
898
898
|
if (!args.with && isSimpleWhere) {
|
|
@@ -1131,7 +1131,7 @@ class QueryInterface {
|
|
|
1131
1131
|
const withFp = args?.with ? this.withFingerprint(args.with) : '';
|
|
1132
1132
|
const orderFp = args?.orderBy
|
|
1133
1133
|
? Object.entries(args.orderBy)
|
|
1134
|
-
.map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, this.tableMeta.relations
|
|
1134
|
+
.map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, (0, utils_js_1.ownLookup)(this.tableMeta.relations, k)?.to)}`)
|
|
1135
1135
|
.join(',')
|
|
1136
1136
|
: '';
|
|
1137
1137
|
const cursorFp = args?.cursor
|
|
@@ -1717,7 +1717,11 @@ class QueryInterface {
|
|
|
1717
1717
|
}
|
|
1718
1718
|
/** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
|
|
1719
1719
|
toColumn(field) {
|
|
1720
|
-
|
|
1720
|
+
// Prototype-safe lookup: a plain-object `columnMap` would otherwise return
|
|
1721
|
+
// an inherited member (e.g. Object.prototype.constructor) for a field named
|
|
1722
|
+
// "constructor" / "toString" / "__proto__", bypassing the unknown-field
|
|
1723
|
+
// check below and returning a non-string as the column name.
|
|
1724
|
+
const mapped = (0, utils_js_1.ownLookup)(this.tableMeta.columnMap, field);
|
|
1721
1725
|
if (mapped)
|
|
1722
1726
|
return mapped;
|
|
1723
1727
|
// Fall back to camelToSnake ONLY if that snake_cased name also exists as a
|
|
@@ -1727,7 +1731,7 @@ class QueryInterface {
|
|
|
1727
1731
|
// SQL injection and catching typos like `where: { emial: 'x' }` with a
|
|
1728
1732
|
// clear error instead of a cryptic Postgres "column does not exist".
|
|
1729
1733
|
const snake = (0, schema_js_1.camelToSnake)(field);
|
|
1730
|
-
if (this.tableMeta.reverseColumnMap
|
|
1734
|
+
if (this.tableMeta.reverseColumnMap && (0, utils_js_1.ownLookup)(this.tableMeta.reverseColumnMap, snake)) {
|
|
1731
1735
|
return snake;
|
|
1732
1736
|
}
|
|
1733
1737
|
if (this.tableMeta.allColumns?.includes(snake)) {
|
|
@@ -1822,7 +1826,7 @@ class QueryInterface {
|
|
|
1822
1826
|
// pick.where / pick.orderBy paths). To-one relation orderBy carries the
|
|
1823
1827
|
// target's global filter once per ordered column.
|
|
1824
1828
|
if (this.isRelationOrderByValue(dir)) {
|
|
1825
|
-
const relDef = this.tableMeta.relations
|
|
1829
|
+
const relDef = (0, utils_js_1.ownLookup)(this.tableMeta.relations, key);
|
|
1826
1830
|
if (relDef && (0, filters_js_1.isRelationPickOrderBy)(dir)) {
|
|
1827
1831
|
this.collectRelationPickOrderParams(key, relDef, dir, params);
|
|
1828
1832
|
}
|
|
@@ -88,6 +88,7 @@ const index_advisor_js_1 = require("../index-advisor.js");
|
|
|
88
88
|
const schema_js_1 = require("../schema.js");
|
|
89
89
|
const batched_loader_js_1 = require("./batched-loader.js");
|
|
90
90
|
const filters_js_1 = require("./filters.js");
|
|
91
|
+
const utils_js_1 = require("./utils.js");
|
|
91
92
|
const whereMod = __importStar(require("./where.js"));
|
|
92
93
|
const writesMod = __importStar(require("./writes.js"));
|
|
93
94
|
/** Relations already warned about missing FK indexes (once per process, dev only). */
|
|
@@ -388,10 +389,10 @@ function buildOrderBy(qi, orderBy, params, lateralSink) {
|
|
|
388
389
|
// are validated in the relation branch below, so skip them here.
|
|
389
390
|
if (process.env.NODE_ENV !== 'production') {
|
|
390
391
|
for (const [key, value] of Object.entries(orderBy)) {
|
|
391
|
-
if (isRelationOrderByValue(qi, value) && qi.tableMeta.relations
|
|
392
|
+
if (isRelationOrderByValue(qi, value) && (0, utils_js_1.ownLookup)(qi.tableMeta.relations, key))
|
|
392
393
|
continue;
|
|
393
394
|
const snakeKey = (0, schema_js_1.camelToSnake)(key);
|
|
394
|
-
if (!qi.tableMeta.columns.some((c) => c.name === snakeKey) && !(
|
|
395
|
+
if (!qi.tableMeta.columns.some((c) => c.name === snakeKey) && !Object.hasOwn(qi.tableMeta.columnMap, key)) {
|
|
395
396
|
console.warn(`[turbine] Unknown orderBy field "${key}" for table "${qi.tableMeta.name}". ` +
|
|
396
397
|
'This will cause a runtime error.');
|
|
397
398
|
}
|
|
@@ -473,7 +474,7 @@ function nullsSuffix(qi, nulls) {
|
|
|
473
474
|
* camelCase-named DB columns like "sortOrder").
|
|
474
475
|
*/
|
|
475
476
|
function resolveOrderByColumn(_qi, table, meta, key) {
|
|
476
|
-
const col = meta.columnMap
|
|
477
|
+
const col = (0, utils_js_1.ownLookup)(meta.columnMap, key) ?? (0, schema_js_1.camelToSnake)(key);
|
|
477
478
|
if (!meta.allColumns.includes(col)) {
|
|
478
479
|
throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${table}". ` +
|
|
479
480
|
`Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
|
|
@@ -591,7 +592,7 @@ function buildRelationOrderBy(qi, relName, value, alias, params, ctx, lateralSin
|
|
|
591
592
|
.map(([col, dirValue]) => {
|
|
592
593
|
// columnMap-first resolution (camelToSnake fallback): mirrors the
|
|
593
594
|
// scalar orderBy path so camelCase-named DB columns resolve here too.
|
|
594
|
-
const snakeCol = targetMeta.columnMap
|
|
595
|
+
const snakeCol = (0, utils_js_1.ownLookup)(targetMeta.columnMap, col) ?? (0, schema_js_1.camelToSnake)(col);
|
|
595
596
|
if (!targetMeta.allColumns.includes(snakeCol)) {
|
|
596
597
|
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").`);
|
|
597
598
|
}
|
|
@@ -1059,7 +1060,7 @@ function resolveTargetColumns(qi, spec, targetMeta, includePii) {
|
|
|
1059
1060
|
// opt-in and comes back regardless of the query's `includePii`.
|
|
1060
1061
|
const selectedFields = Object.entries(spec.select)
|
|
1061
1062
|
.filter(([, v]) => v)
|
|
1062
|
-
.map(([k]) => targetMeta.columnMap
|
|
1063
|
+
.map(([k]) => (0, utils_js_1.ownLookup)(targetMeta.columnMap, k) ?? (0, schema_js_1.camelToSnake)(k));
|
|
1063
1064
|
return selectedFields.filter((col) => targetMeta.allColumns.includes(col));
|
|
1064
1065
|
}
|
|
1065
1066
|
// Default / omit-only relation projection: PII columns are excluded unless
|
|
@@ -1069,7 +1070,7 @@ function resolveTargetColumns(qi, spec, targetMeta, includePii) {
|
|
|
1069
1070
|
if (spec !== true && spec.omit) {
|
|
1070
1071
|
const omittedFields = new Set(Object.entries(spec.omit)
|
|
1071
1072
|
.filter(([, v]) => v)
|
|
1072
|
-
.map(([k]) => targetMeta.columnMap
|
|
1073
|
+
.map(([k]) => (0, utils_js_1.ownLookup)(targetMeta.columnMap, k) ?? (0, schema_js_1.camelToSnake)(k)));
|
|
1073
1074
|
return targetMeta.allColumns.filter((col) => !omittedFields.has(col) && !(hasPii && piiCols.has(col)));
|
|
1074
1075
|
}
|
|
1075
1076
|
if (hasPii) {
|
package/dist/cjs/query/utils.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
8
|
exports.OPERATOR_KEYS = exports.LRUCache = void 0;
|
|
9
9
|
exports.quoteIdent = quoteIdent;
|
|
10
|
+
exports.ownLookup = ownLookup;
|
|
10
11
|
exports.escSingleQuote = escSingleQuote;
|
|
11
12
|
exports.escapeLike = escapeLike;
|
|
12
13
|
exports.fnv1a64Hex = fnv1a64Hex;
|
|
@@ -28,6 +29,18 @@ exports.parseDbDate = parseDbDate;
|
|
|
28
29
|
function quoteIdent(name) {
|
|
29
30
|
return `"${name.replace(/"/g, '""')}"`;
|
|
30
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Prototype-safe own-property read for the plain metadata maps (columnMap,
|
|
34
|
+
* relations, reverseColumnMap). These are constructed as plain objects, so a
|
|
35
|
+
* bare `map[key]` for a user-supplied field name like "constructor",
|
|
36
|
+
* "toString", or "__proto__" returns an inherited member from
|
|
37
|
+
* `Object.prototype` — a truthy value that slips past validation and produces a
|
|
38
|
+
* cryptic `TypeError` instead of a clean `ValidationError`. Returns `undefined`
|
|
39
|
+
* unless `key` is an OWN enumerable/non-enumerable property.
|
|
40
|
+
*/
|
|
41
|
+
function ownLookup(map, key) {
|
|
42
|
+
return Object.hasOwn(map, key) ? map[key] : undefined;
|
|
43
|
+
}
|
|
31
44
|
/**
|
|
32
45
|
* Escape single quotes for use as string keys in json_build_object().
|
|
33
46
|
* Doubles single quotes per SQL quoting rules.
|
|
@@ -39,6 +39,7 @@ exports.walkWhere = walkWhere;
|
|
|
39
39
|
exports.classifyScalarForSql = classifyScalarForSql;
|
|
40
40
|
exports.fingerprintScalarToken = fingerprintScalarToken;
|
|
41
41
|
const filters_js_1 = require("./filters.js");
|
|
42
|
+
const utils_js_1 = require("./utils.js");
|
|
42
43
|
/** True when a normalized relation filter carries at least one cardinality key. */
|
|
43
44
|
function isRelationFilterObj(filterObj) {
|
|
44
45
|
return ('some' in filterObj || 'every' in filterObj || 'none' in filterObj || 'is' in filterObj || 'isNot' in filterObj);
|
|
@@ -78,7 +79,7 @@ function walkWhere(host, where) {
|
|
|
78
79
|
events.push({ kind: 'not', condition: value });
|
|
79
80
|
continue;
|
|
80
81
|
}
|
|
81
|
-
const relDef = host.tableMeta.relations
|
|
82
|
+
const relDef = (0, utils_js_1.ownLookup)(host.tableMeta.relations, key);
|
|
82
83
|
if (relDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
83
84
|
const filterObj = host.normalizeRelationFilter(relDef, value);
|
|
84
85
|
if (isRelationFilterObj(filterObj)) {
|
package/dist/cjs/query/where.js
CHANGED
|
@@ -723,7 +723,7 @@ function buildScopedWhere(qi, scope, where, params) {
|
|
|
723
723
|
*/
|
|
724
724
|
function buildScopedScalarClause(qi, scope, field, value, params, clauses) {
|
|
725
725
|
const meta = scope.meta;
|
|
726
|
-
const col = meta.columnMap
|
|
726
|
+
const col = (0, utils_js_1.ownLookup)(meta.columnMap, field) ?? (0, schema_js_1.camelToSnake)(field);
|
|
727
727
|
if (!meta.allColumns.includes(col))
|
|
728
728
|
throw scope.unknownColumn(field);
|
|
729
729
|
const qCol = `${scope.qualifier}${qi.q(col)}`;
|
|
@@ -800,7 +800,7 @@ function collectScopedScalarParams(qi, scope, field, value, params) {
|
|
|
800
800
|
if (value === null)
|
|
801
801
|
return;
|
|
802
802
|
const meta = scope.meta;
|
|
803
|
-
const col = meta.columnMap
|
|
803
|
+
const col = (0, utils_js_1.ownLookup)(meta.columnMap, field) ?? (0, schema_js_1.camelToSnake)(field);
|
|
804
804
|
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
805
805
|
const colType = pgTypeForColumn(qi, meta, col);
|
|
806
806
|
if (isJsonColumnType(qi, colType)) {
|
|
@@ -1071,7 +1071,7 @@ function resolveColumnRef(_qi, ref, ctx, mode) {
|
|
|
1071
1071
|
`Case-insensitive column-to-column comparison is not supported: use client.sql\`...\` ` +
|
|
1072
1072
|
`for lower(a) = lower(b).`);
|
|
1073
1073
|
}
|
|
1074
|
-
const col = ctx.meta.columnMap
|
|
1074
|
+
const col = (0, utils_js_1.ownLookup)(ctx.meta.columnMap, ref.col) ?? (0, schema_js_1.camelToSnake)(ref.col);
|
|
1075
1075
|
if (!ctx.meta.allColumns.includes(col)) {
|
|
1076
1076
|
throw new errors_js_1.ValidationError(`[turbine] Unknown field "${ref.col}" referenced by { col } in where on table "${ctx.table}". ` +
|
|
1077
1077
|
`Known fields: ${Object.keys(ctx.meta.columnMap).join(', ') || '(none)'}.`);
|
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;
|