turbine-orm 0.19.0 → 0.19.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 CHANGED
@@ -13,7 +13,7 @@ npm install turbine-orm
13
13
  Prisma ships a 1.6 MB WASM query engine. Drizzle ships zero runtime but no Studio, no typed errors, no migration checksums. Turbine ships **one dependency (`pg`) and no engine binary**, and bundles six things no other TS ORM has together:
14
14
 
15
15
  1. **One runtime dependency (`pg`).** No engine binary, no WASM adapter, no adapter packages to keep in lockstep. The main entry bundles to ~30 KB gzipped (~109 KB minified); the edge entry to ~21 KB gzipped. Prisma's WASM query engine alone is 1.6 MB.
16
- 2. **Built-in read-only Studio.** `npx turbine studio` spins up a loopback-bound web UI with 192-bit auth tokens, `BEGIN READ ONLY` transactions, and a statement-stacking guard. The only TS ORM Studio that physically cannot mutate your database. DBA-approvable.
16
+ 2. **Built-in read-only Studio.** `npx turbine studio` spins up a loopback-bound web UI with 192-bit auth tokens, `BEGIN READ ONLY` transactions, and since v0.19 — no raw-SQL surface at all: queries are composed in the ORM's own validated builder. The only TS ORM Studio that physically cannot mutate your database. DBA-approvable.
17
17
  3. **PII-safe error messages.** Turbine errors show WHERE keys, not values. A `UniqueConstraintError` says which column violated the constraint — never the actual user data. Safe to log, safe to surface to monitoring, no scrubbing needed.
18
18
  4. **SQL-first migrations with drift detection.** Write real SQL. SHA-256 checksums catch modified migration files. `pg_try_advisory_lock()` prevents concurrent runs. Each migration in its own transaction. No shadow database, no magic DSL.
19
19
  5. **Edge-native — one import swap.** `turbineHttp(pool, schema)` — same API on Neon, Vercel Postgres, Cloudflare Hyperdrive, Supabase. No WASM bundle, no adapter package, no separate serverless build.
@@ -614,7 +614,7 @@ npx turbine migrate status
614
614
 
615
615
  ## Studio
616
616
 
617
- The only Postgres ORM with a Studio your DBA will approve. `turbine studio` launches a local, read-only web UI for exploring your database — no mutations, no writes, no way around the transaction guard.
617
+ The only Postgres ORM with a Studio your DBA will approve. `turbine studio` launches a local, read-only web UI for exploring your database — no mutations, no writes, and since v0.19 **no raw-SQL surface at all**: every query is composed visually in the ORM and compiled by the same validated query builder your application uses.
618
618
 
619
619
  ```bash
620
620
  DATABASE_URL=postgres://user:pass@localhost:5432/mydb npx turbine studio
@@ -624,19 +624,19 @@ npx turbine studio --port 5173 --host 127.0.0.1 --no-open
624
624
 
625
625
  **Features**
626
626
 
627
- - **Data / Schema / SQL / Builder tabs.** Browse rows, inspect tables and relations, run ad-hoc `SELECT`s, or compose queries visually with a live TypeScript preview.
628
- - **Saved queries.** Named SQL snippets persisted to `.turbine/studio-queries.json` — share them across runs without committing them.
627
+ - **Query / Data / Schema tabs.** Compose queries visually, browse rows, and inspect tables and relations.
628
+ - **ORM-native query composer.** The Query tab builds a real `findMany` — drill into relations (`with`) to any depth, pick fields (`select`/`omit`), add filters (`where`), `orderBy`, and `limit` at every level with a live TypeScript preview of the exact call to copy into your codebase.
629
+ - **Saved queries.** Named builder queries persisted to `.turbine/studio-queries.json` — share them across runs without committing them.
629
630
  - **Cmd+K command palette.** Jump to any table, tab, or saved query in one keystroke.
630
631
  - **Full-text search across rows.** The Data tab supports substring search across every text column of the current table.
631
- - **Visual query composer.** The Builder tab lets you click together `where` / `orderBy` / `with` / `limit` clauses and renders the matching `db.table.findMany(...)` TypeScript in real time — copy it into your codebase.
632
632
 
633
633
  **Security posture (read-only by design)**
634
634
 
635
+ - **No SQL input surface.** There is nothing to inject into — builder requests are validated identifier-by-identifier against the introspected schema, and every value is bound as a `$N` parameter.
635
636
  - **Loopback by default** (`127.0.0.1`) with a loud warning if you bind to a non-loopback address.
636
637
  - **Per-process auth token** — 24 random bytes of hex, stored in a `SameSite=Strict` `HttpOnly` cookie.
637
- - **Every query runs inside `BEGIN READ ONLY` + `SET LOCAL statement_timeout = '30s'`.** Writes are physically impossible at the transaction level.
638
- - **SELECT/WITH-only SQL parser** strips comments and rejects non-trailing semicolons, blocking statement-stacking attacks.
639
- - **Security headers on every response** — `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`.
638
+ - **Every query runs inside `BEGIN READ ONLY`** with a 30s transaction-local statement timeout (parameterized `set_config`). Writes are physically impossible at the transaction level.
639
+ - **Security headers on every response** CSP, `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer` plus per-session rate limiting and cross-origin refusal.
640
640
 
641
641
  ## Serverless / Edge
642
642
 
@@ -55,8 +55,9 @@ export interface DatabaseAdapter {
55
55
  introspectionOverrides?: Partial<IntrospectionOverrides>;
56
56
  /**
57
57
  * Generate the SQL to set a statement timeout within a transaction.
58
- * PostgreSQL uses `SET LOCAL statement_timeout = $1`.
59
- * CockroachDB uses `SET transaction_timeout = $1` (v23.1+).
58
+ * PostgreSQL uses `SELECT set_config('statement_timeout', $1, true)`.
59
+ * CockroachDB uses `SELECT set_config('transaction_timeout', $1, true)` (v23.1+).
60
+ * (`SET LOCAL ... = $1` is a syntax error — SET takes no bind params.)
60
61
  *
61
62
  * @param seconds — timeout in seconds
62
63
  * @returns an object with the parameterized SQL and its bound values
@@ -167,6 +167,15 @@ function failMissingTsLoader(filePath, reason) {
167
167
  console.log(` ${(0, ui_js_1.dim)('Your Node.js version does not support')} ${(0, ui_js_1.cyan)('module.register()')}.`);
168
168
  console.log(` ${(0, ui_js_1.dim)('Upgrade to Node.js')} ${(0, ui_js_1.cyan)('20.6+')} ${(0, ui_js_1.dim)('or use a')} ${(0, ui_js_1.cyan)('.js')} ${(0, ui_js_1.dim)('/')} ${(0, ui_js_1.cyan)('.mjs')} ${(0, ui_js_1.dim)('config file.')}`);
169
169
  }
170
+ else if (reason === 'failed') {
171
+ // tsx IS installed but registering its loader threw. Report the real
172
+ // cause — telling the user to install tsx here would be a misdiagnosis.
173
+ console.log(` ${(0, ui_js_1.dim)('tsx is installed, but registering its TypeScript loader failed:')}`);
174
+ (0, ui_js_1.newline)();
175
+ console.log(` ${(0, loader_js_1.getTsLoaderError)() ?? '(unknown error)'}`);
176
+ (0, ui_js_1.newline)();
177
+ console.log(` ${(0, ui_js_1.dim)('Try upgrading tsx:')} ${(0, ui_js_1.cyan)('npm install --save-dev tsx@latest')}${(0, ui_js_1.dim)(', or rename your file to')} ${(0, ui_js_1.cyan)('.mjs')}.`);
178
+ }
170
179
  else {
171
180
  console.log(` ${(0, ui_js_1.dim)('Loading .ts config / schema files requires')} ${(0, ui_js_1.cyan)('tsx')} ${(0, ui_js_1.dim)('to be installed.')}`);
172
181
  (0, ui_js_1.newline)();
@@ -210,7 +219,7 @@ async function loadSchemaFile(schemaFile) {
210
219
  // ERR_UNKNOWN_FILE_EXTENSION for `.ts`.
211
220
  if ((0, loader_js_1.needsTsLoader)(absPath)) {
212
221
  const status = await (0, loader_js_1.registerTsLoader)();
213
- if (status === 'missing' || status === 'unsupported') {
222
+ if (status === 'missing' || status === 'unsupported' || status === 'failed') {
214
223
  failMissingTsLoader(schemaFile, status);
215
224
  }
216
225
  }
@@ -346,7 +355,7 @@ export default defineSchema({
346
355
  // id: { type: 'serial', primaryKey: true },
347
356
  // email: { type: 'text', notNull: true, unique: true },
348
357
  // name: { type: 'text', notNull: true },
349
- // created_at: { type: 'timestamptz', default: 'NOW()' },
358
+ // created_at: { type: 'timestamp', default: 'NOW()' },
350
359
  // },
351
360
  });
352
361
  `, 'utf-8');
@@ -409,6 +418,9 @@ export default defineSchema({
409
418
  console.log(` ${(0, ui_js_1.dim)('or create a')} ${(0, ui_js_1.cyan)('.env')} ${(0, ui_js_1.dim)('file with')} ${(0, ui_js_1.cyan)('DATABASE_URL=postgres://...')}`);
410
419
  }
411
420
  console.log(` ${(0, ui_js_1.dim)('2.')} Run ${(0, ui_js_1.cyan)('npx turbine generate')} to introspect your DB`);
421
+ if (!(0, loader_js_1.canResolveTsx)()) {
422
+ console.log(` ${(0, ui_js_1.dim)('Note: the TypeScript config requires')} ${(0, ui_js_1.cyan)('tsx')} ${(0, ui_js_1.dim)('—')} ${(0, ui_js_1.cyan)('npm install --save-dev tsx')}`);
423
+ }
412
424
  }
413
425
  else {
414
426
  console.log(` ${(0, ui_js_1.dim)('1.')} Import the generated client:`);
@@ -1249,7 +1261,17 @@ function showVersion() {
1249
1261
  // Using process.argv[1] instead of import.meta.url so the same code compiles
1250
1262
  // cleanly for both the ESM and CJS builds.
1251
1263
  try {
1252
- let dir = (0, node_path_1.dirname)(process.argv[1] ?? '');
1264
+ // Resolve symlinks first: `npx turbine` runs via node_modules/.bin/turbine,
1265
+ // a symlink whose dirname would walk the CONSUMER's tree and never find
1266
+ // turbine-orm's package.json (printing no version number at all).
1267
+ let entry = process.argv[1] ?? '';
1268
+ try {
1269
+ entry = (0, node_fs_1.realpathSync)(entry);
1270
+ }
1271
+ catch {
1272
+ // keep the raw path if realpath fails (e.g. deleted cwd)
1273
+ }
1274
+ let dir = (0, node_path_1.dirname)(entry);
1253
1275
  for (let i = 0; i < 6; i++) {
1254
1276
  const candidate = (0, node_path_1.resolve)(dir, 'package.json');
1255
1277
  if ((0, node_fs_1.existsSync)(candidate)) {
@@ -1297,7 +1319,7 @@ async function main() {
1297
1319
  const configPath = (0, config_js_1.findConfigFile)();
1298
1320
  if ((0, loader_js_1.needsTsLoader)(configPath)) {
1299
1321
  const status = await (0, loader_js_1.registerTsLoader)();
1300
- if (status === 'missing' || status === 'unsupported') {
1322
+ if (status === 'missing' || status === 'unsupported' || status === 'failed') {
1301
1323
  failMissingTsLoader(configPath ?? 'turbine.config.ts', status);
1302
1324
  }
1303
1325
  }
@@ -9,9 +9,18 @@
9
9
  *
10
10
  * Strategy:
11
11
  * 1. If the file we're about to import ends in `.ts` / `.mts` / `.cts`,
12
- * probe whether `tsx/esm` is resolvable from the user's CWD.
13
- * 2. If yes, call `module.register('tsx/esm', ...)` ONCE per process.
14
- * 3. If no, surface an actionable error telling the user to install `tsx`.
12
+ * probe whether `tsx` is resolvable from the user's CWD.
13
+ * 2. Prefer tsx's supported programmatic API, `tsx/esm/api`'s `register()`.
14
+ * Calling Node's `module.register('tsx/esm', ...)` directly throws
15
+ * "tsx must be loaded with --import instead of --loader" on every Node
16
+ * version that has `module.register()` (>= 20.6) — tsx's hook file
17
+ * guards against being loaded that way. The `tsx/esm/api` entry point
18
+ * is the documented path and works everywhere `module.register()` does.
19
+ * 3. Fall back to `module.register('tsx/esm', ...)` only for very old tsx
20
+ * versions (< 4.0) that predate `tsx/esm/api`.
21
+ * 4. If tsx isn't installed, or registration genuinely fails, surface an
22
+ * actionable error — including the REAL underlying error message, never
23
+ * a misdiagnosed "tsx is not installed".
15
24
  *
16
25
  * `tsx` is intentionally NOT a runtime dependency — many projects already
17
26
  * have it, and adding a heavy dev tool to a 1-dependency ORM would be silly.
@@ -52,6 +61,7 @@ var __importStar = (this && this.__importStar) || (function () {
52
61
  Object.defineProperty(exports, "__esModule", { value: true });
53
62
  exports.needsTsLoader = needsTsLoader;
54
63
  exports.canResolveTsx = canResolveTsx;
64
+ exports.getTsLoaderError = getTsLoaderError;
55
65
  exports.registerTsLoader = registerTsLoader;
56
66
  exports._resetTsLoaderStateForTests = _resetTsLoaderStateForTests;
57
67
  const node_module_1 = require("node:module");
@@ -89,6 +99,14 @@ function canResolveTsx(resolver) {
89
99
  }
90
100
  }
91
101
  let tsLoaderState = null;
102
+ let tsLoaderError = null;
103
+ /**
104
+ * The underlying error message from the last failed registration attempt,
105
+ * or null. Lets the CLI report the REAL cause instead of guessing.
106
+ */
107
+ function getTsLoaderError() {
108
+ return tsLoaderError;
109
+ }
92
110
  /**
93
111
  * Register the tsx ESM loader so subsequent dynamic imports of `.ts` files
94
112
  * work. Safe to call multiple times — internal flag prevents double registration.
@@ -96,17 +114,51 @@ let tsLoaderState = null;
96
114
  * Returns:
97
115
  * - 'registered' loader was successfully registered this call
98
116
  * - 'already' a loader was previously registered (idempotent)
99
- * - 'unsupported' Node lacks `module.register()` (Node < 20.6)
117
+ * - 'unsupported' Node lacks `module.register()` (Node < 20.6) and tsx has
118
+ * no programmatic API to fall back to
100
119
  * - 'missing' `tsx` is not installed in the user's project
120
+ * - 'failed' tsx IS installed but registration threw — see
121
+ * {@link getTsLoaderError} for the underlying message
101
122
  */
102
123
  async function registerTsLoader() {
103
124
  if (tsLoaderState === 'registered' || tsLoaderState === 'already') {
104
125
  return 'already';
105
126
  }
127
+ const userRequire = (0, node_module_1.createRequire)(`${process.cwd()}/`);
128
+ // Preferred: tsx's supported programmatic API (tsx >= 4.0).
129
+ let apiPath = null;
130
+ try {
131
+ apiPath = userRequire.resolve('tsx/esm/api');
132
+ }
133
+ catch {
134
+ apiPath = null;
135
+ }
136
+ if (apiPath) {
137
+ try {
138
+ const api = (await Promise.resolve(`${(0, node_url_1.pathToFileURL)(apiPath).href}`).then(s => __importStar(require(s))));
139
+ if (typeof api.register !== 'function') {
140
+ throw new Error(`tsx/esm/api resolved at ${apiPath} but exports no register() function`);
141
+ }
142
+ api.register();
143
+ tsLoaderState = 'registered';
144
+ tsLoaderError = null;
145
+ return 'registered';
146
+ }
147
+ catch (err) {
148
+ tsLoaderState = 'failed';
149
+ tsLoaderError = err instanceof Error ? err.message : String(err);
150
+ return 'failed';
151
+ }
152
+ }
153
+ // tsx/esm/api not resolvable — is tsx installed at all?
106
154
  if (!canResolveTsx()) {
107
155
  tsLoaderState = 'missing';
108
156
  return 'missing';
109
157
  }
158
+ // Legacy fallback for tsx < 4.0 (no tsx/esm/api): Node's module.register.
159
+ // On tsx >= 4.19 this path throws ("tsx must be loaded with --import
160
+ // instead of --loader") — but those versions all ship tsx/esm/api, so we
161
+ // only land here for genuinely old installs.
110
162
  try {
111
163
  const mod = await Promise.resolve().then(() => __importStar(require('node:module')));
112
164
  const register = mod.register;
@@ -116,14 +168,17 @@ async function registerTsLoader() {
116
168
  }
117
169
  register('tsx/esm', (0, node_url_1.pathToFileURL)(`${process.cwd()}/`));
118
170
  tsLoaderState = 'registered';
171
+ tsLoaderError = null;
119
172
  return 'registered';
120
173
  }
121
- catch {
122
- tsLoaderState = 'missing';
123
- return 'missing';
174
+ catch (err) {
175
+ tsLoaderState = 'failed';
176
+ tsLoaderError = err instanceof Error ? err.message : String(err);
177
+ return 'failed';
124
178
  }
125
179
  }
126
180
  /** Reset the loader state — used by unit tests only. */
127
181
  function _resetTsLoaderStateForTests() {
128
182
  tsLoaderState = null;
183
+ tsLoaderError = null;
129
184
  }
@@ -3,15 +3,20 @@
3
3
  * turbine-orm CLI — Studio
4
4
  *
5
5
  * A local, read-only web UI for browsing databases, exploring relations,
6
- * and running SELECT queries. Pure Node (built-in `http` module), no
7
- * runtime dependencies beyond `pg`, bound to 127.0.0.1 only.
6
+ * and composing queries visually. ORM-native since v0.19: there is no
7
+ * raw-SQL input surface — the Query tab builds `findMany` args that are
8
+ * validated against introspected metadata and compiled by QueryInterface
9
+ * (`/api/builder`). Pure Node (built-in `http` module), no runtime
10
+ * dependencies beyond `pg`, bound to 127.0.0.1 only.
8
11
  *
9
12
  * Security model:
10
13
  * • Bind 127.0.0.1 only (never 0.0.0.0 — no LAN exposure)
11
14
  * • Random auth token generated per process, required in Cookie header
12
- * • SELECT/WITH-only guard on the query endpoint
15
+ * • No SQL input surface at all — every identifier in a builder request is
16
+ * validated against the introspected schema; all values are $N params
13
17
  * • Every query runs in a READ ONLY transaction (belt-and-suspenders)
14
- * • 30s statement timeout
18
+ * • 30s statement timeout via parameterized set_config()
19
+ * • Per-session rate limiting, CSP + security headers, cross-origin refusal
15
20
  *
16
21
  * Not implemented (deliberately): row editing, DDL, destructive operations.
17
22
  * Studio is for inspection. Use the CLI, migrate, or raw SQL for writes.
@@ -420,6 +425,11 @@ async function apiBuilder(req, res, ctx) {
420
425
  try {
421
426
  await client.query('BEGIN READ ONLY');
422
427
  await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
428
+ // QueryInterface emits unqualified table identifiers, which resolve via
429
+ // the connection's search_path. Pin it to the configured --schema so the
430
+ // Query tab reads the same schema as the Data tab (set_config is
431
+ // transaction-local and fully parameterized).
432
+ await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
423
433
  const started = Date.now();
424
434
  const result = await client.query(deferred.sql, deferred.params);
425
435
  const elapsedMs = Date.now() - started;
@@ -448,6 +458,8 @@ async function apiBuilder(req, res, ctx) {
448
458
  function savedQueriesPath(ctx) {
449
459
  return (0, node_path_1.resolve)(ctx.stateDir, 'studio-queries.json');
450
460
  }
461
+ /** One-shot flag so the legacy saved-query notice isn't logged on every request. */
462
+ let legacyDropNoticeShown = false;
451
463
  function loadSavedQueries(ctx) {
452
464
  const file = savedQueriesPath(ctx);
453
465
  if (!(0, node_fs_1.existsSync)(file))
@@ -457,8 +469,16 @@ function loadSavedQueries(ctx) {
457
469
  const parsed = JSON.parse(raw);
458
470
  if (!parsed.queries || !Array.isArray(parsed.queries))
459
471
  return { version: 1, queries: [] };
460
- // Drop any legacy raw-SQL entries — Studio is builder-only now.
472
+ // Drop any legacy raw-SQL entries — Studio is builder-only now. Tell the
473
+ // user instead of silently discarding their saved work (the file on disk
474
+ // is only rewritten when a new query is saved, so this is recoverable).
461
475
  const queries = parsed.queries.filter((q) => q && q.kind === 'builder');
476
+ const dropped = parsed.queries.length - queries.length;
477
+ if (dropped > 0 && !legacyDropNoticeShown) {
478
+ legacyDropNoticeShown = true;
479
+ console.warn(`[turbine studio] Ignoring ${dropped} legacy raw-SQL saved quer${dropped === 1 ? 'y' : 'ies'} in ${file} — ` +
480
+ 'Studio is builder-only since v0.19. The entries remain in the file until a new query is saved.');
481
+ }
462
482
  return { version: 1, queries };
463
483
  }
464
484
  catch {
@@ -210,6 +210,14 @@ class TurbineClient {
210
210
  /** Active LISTEN subscriptions — torn down on disconnect() so it never hangs */
211
211
  activeSubscriptions = new Set();
212
212
  constructor(config = {}, schema) {
213
+ // Constructing without schema metadata previously crashed deep in the
214
+ // constructor with an opaque "Cannot read properties of undefined
215
+ // (reading 'tables')". Fail fast with an actionable message instead.
216
+ if (!schema || typeof schema !== 'object' || !schema.tables) {
217
+ throw new errors_js_1.ValidationError('[turbine] TurbineClient requires schema metadata as its second argument. ' +
218
+ 'Run `npx turbine generate` and use the generated client (`turbine()` from your output dir), ' +
219
+ 'or pass the generated `schemaMetadata` object: new TurbineClient(config, schemaMetadata).');
220
+ }
213
221
  /**
214
222
  * Parse int8 (bigint, OID 20) as JavaScript number instead of string.
215
223
  * Safe for values up to Number.MAX_SAFE_INTEGER (9,007,199,254,740,991).