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.
@@ -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.
@@ -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 (key in tableMeta.relations &&
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 (key in tableMeta.relations) {
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 ${JSON.stringify(where)}.`);
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) => k in (childTable.relations ?? {})));
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 ${JSON.stringify(target)}.`);
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 ${JSON.stringify(target)}.`);
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 ${JSON.stringify(target)}.`);
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[relName];
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[relName];
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[relName];
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[k];
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[k]?.to)}`)
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
- const mapped = this.tableMeta.columnMap[field];
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?.[snake]) {
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[key];
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[key])
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) && !(key in qi.tableMeta.columnMap)) {
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[key] ?? (0, schema_js_1.camelToSnake)(key);
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[col] ?? (0, schema_js_1.camelToSnake)(col);
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[k] ?? (0, schema_js_1.camelToSnake)(k));
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[k] ?? (0, schema_js_1.camelToSnake)(k)));
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) {
@@ -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[key];
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)) {
@@ -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[field] ?? (0, schema_js_1.camelToSnake)(field);
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[field] ?? (0, schema_js_1.camelToSnake)(field);
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[ref.col] ?? (0, schema_js_1.camelToSnake)(ref.col);
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)'}.`);
@@ -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 Launch local read-only web UI
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 Launch local read-only web UI
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 url = requireUrl(config);
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(`Studio is running`);
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
- // Loud startup warnings for the opt-in modes that widen Studio's surface.
1508
- if (args.write) {
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
- if (args.showPii) {
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 opts in to single-row writes)')}`);
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;