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.
@@ -8,9 +8,18 @@
8
8
  *
9
9
  * Strategy:
10
10
  * 1. If the file we're about to import ends in `.ts` / `.mts` / `.cts`,
11
- * probe whether `tsx/esm` is resolvable from the user's CWD.
12
- * 2. If yes, call `module.register('tsx/esm', ...)` ONCE per process.
13
- * 3. If no, surface an actionable error telling the user to install `tsx`.
11
+ * probe whether `tsx` is resolvable from the user's CWD.
12
+ * 2. Prefer tsx's supported programmatic API, `tsx/esm/api`'s `register()`.
13
+ * Calling Node's `module.register('tsx/esm', ...)` directly throws
14
+ * "tsx must be loaded with --import instead of --loader" on every Node
15
+ * version that has `module.register()` (>= 20.6) — tsx's hook file
16
+ * guards against being loaded that way. The `tsx/esm/api` entry point
17
+ * is the documented path and works everywhere `module.register()` does.
18
+ * 3. Fall back to `module.register('tsx/esm', ...)` only for very old tsx
19
+ * versions (< 4.0) that predate `tsx/esm/api`.
20
+ * 4. If tsx isn't installed, or registration genuinely fails, surface an
21
+ * actionable error — including the REAL underlying error message, never
22
+ * a misdiagnosed "tsx is not installed".
14
23
  *
15
24
  * `tsx` is intentionally NOT a runtime dependency — many projects already
16
25
  * have it, and adding a heavy dev tool to a 1-dependency ORM would be silly.
@@ -50,6 +59,14 @@ export function canResolveTsx(resolver) {
50
59
  }
51
60
  }
52
61
  let tsLoaderState = null;
62
+ let tsLoaderError = null;
63
+ /**
64
+ * The underlying error message from the last failed registration attempt,
65
+ * or null. Lets the CLI report the REAL cause instead of guessing.
66
+ */
67
+ export function getTsLoaderError() {
68
+ return tsLoaderError;
69
+ }
53
70
  /**
54
71
  * Register the tsx ESM loader so subsequent dynamic imports of `.ts` files
55
72
  * work. Safe to call multiple times — internal flag prevents double registration.
@@ -57,17 +74,51 @@ let tsLoaderState = null;
57
74
  * Returns:
58
75
  * - 'registered' loader was successfully registered this call
59
76
  * - 'already' a loader was previously registered (idempotent)
60
- * - 'unsupported' Node lacks `module.register()` (Node < 20.6)
77
+ * - 'unsupported' Node lacks `module.register()` (Node < 20.6) and tsx has
78
+ * no programmatic API to fall back to
61
79
  * - 'missing' `tsx` is not installed in the user's project
80
+ * - 'failed' tsx IS installed but registration threw — see
81
+ * {@link getTsLoaderError} for the underlying message
62
82
  */
63
83
  export async function registerTsLoader() {
64
84
  if (tsLoaderState === 'registered' || tsLoaderState === 'already') {
65
85
  return 'already';
66
86
  }
87
+ const userRequire = createRequire(`${process.cwd()}/`);
88
+ // Preferred: tsx's supported programmatic API (tsx >= 4.0).
89
+ let apiPath = null;
90
+ try {
91
+ apiPath = userRequire.resolve('tsx/esm/api');
92
+ }
93
+ catch {
94
+ apiPath = null;
95
+ }
96
+ if (apiPath) {
97
+ try {
98
+ const api = (await import(pathToFileURL(apiPath).href));
99
+ if (typeof api.register !== 'function') {
100
+ throw new Error(`tsx/esm/api resolved at ${apiPath} but exports no register() function`);
101
+ }
102
+ api.register();
103
+ tsLoaderState = 'registered';
104
+ tsLoaderError = null;
105
+ return 'registered';
106
+ }
107
+ catch (err) {
108
+ tsLoaderState = 'failed';
109
+ tsLoaderError = err instanceof Error ? err.message : String(err);
110
+ return 'failed';
111
+ }
112
+ }
113
+ // tsx/esm/api not resolvable — is tsx installed at all?
67
114
  if (!canResolveTsx()) {
68
115
  tsLoaderState = 'missing';
69
116
  return 'missing';
70
117
  }
118
+ // Legacy fallback for tsx < 4.0 (no tsx/esm/api): Node's module.register.
119
+ // On tsx >= 4.19 this path throws ("tsx must be loaded with --import
120
+ // instead of --loader") — but those versions all ship tsx/esm/api, so we
121
+ // only land here for genuinely old installs.
71
122
  try {
72
123
  const mod = await import('node:module');
73
124
  const register = mod.register;
@@ -77,15 +128,18 @@ export async function registerTsLoader() {
77
128
  }
78
129
  register('tsx/esm', pathToFileURL(`${process.cwd()}/`));
79
130
  tsLoaderState = 'registered';
131
+ tsLoaderError = null;
80
132
  return 'registered';
81
133
  }
82
- catch {
83
- tsLoaderState = 'missing';
84
- return 'missing';
134
+ catch (err) {
135
+ tsLoaderState = 'failed';
136
+ tsLoaderError = err instanceof Error ? err.message : String(err);
137
+ return 'failed';
85
138
  }
86
139
  }
87
140
  /** Reset the loader state — used by unit tests only. */
88
141
  export function _resetTsLoaderStateForTests() {
89
142
  tsLoaderState = null;
143
+ tsLoaderError = null;
90
144
  }
91
145
  //# sourceMappingURL=loader.js.map
@@ -2,15 +2,20 @@
2
2
  * turbine-orm CLI — Studio
3
3
  *
4
4
  * A local, read-only web UI for browsing databases, exploring relations,
5
- * and running SELECT queries. Pure Node (built-in `http` module), no
6
- * runtime dependencies beyond `pg`, bound to 127.0.0.1 only.
5
+ * and composing queries visually. ORM-native since v0.19: there is no
6
+ * raw-SQL input surface — the Query tab builds `findMany` args that are
7
+ * validated against introspected metadata and compiled by QueryInterface
8
+ * (`/api/builder`). Pure Node (built-in `http` module), no runtime
9
+ * dependencies beyond `pg`, bound to 127.0.0.1 only.
7
10
  *
8
11
  * Security model:
9
12
  * • Bind 127.0.0.1 only (never 0.0.0.0 — no LAN exposure)
10
13
  * • Random auth token generated per process, required in Cookie header
11
- * • SELECT/WITH-only guard on the query endpoint
14
+ * • No SQL input surface at all — every identifier in a builder request is
15
+ * validated against the introspected schema; all values are $N params
12
16
  * • Every query runs in a READ ONLY transaction (belt-and-suspenders)
13
- * • 30s statement timeout
17
+ * • 30s statement timeout via parameterized set_config()
18
+ * • Per-session rate limiting, CSP + security headers, cross-origin refusal
14
19
  *
15
20
  * Not implemented (deliberately): row editing, DDL, destructive operations.
16
21
  * Studio is for inspection. Use the CLI, migrate, or raw SQL for writes.
@@ -2,15 +2,20 @@
2
2
  * turbine-orm CLI — Studio
3
3
  *
4
4
  * A local, read-only web UI for browsing databases, exploring relations,
5
- * and running SELECT queries. Pure Node (built-in `http` module), no
6
- * runtime dependencies beyond `pg`, bound to 127.0.0.1 only.
5
+ * and composing queries visually. ORM-native since v0.19: there is no
6
+ * raw-SQL input surface — the Query tab builds `findMany` args that are
7
+ * validated against introspected metadata and compiled by QueryInterface
8
+ * (`/api/builder`). Pure Node (built-in `http` module), no runtime
9
+ * dependencies beyond `pg`, bound to 127.0.0.1 only.
7
10
  *
8
11
  * Security model:
9
12
  * • Bind 127.0.0.1 only (never 0.0.0.0 — no LAN exposure)
10
13
  * • Random auth token generated per process, required in Cookie header
11
- * • SELECT/WITH-only guard on the query endpoint
14
+ * • No SQL input surface at all — every identifier in a builder request is
15
+ * validated against the introspected schema; all values are $N params
12
16
  * • Every query runs in a READ ONLY transaction (belt-and-suspenders)
13
- * • 30s statement timeout
17
+ * • 30s statement timeout via parameterized set_config()
18
+ * • Per-session rate limiting, CSP + security headers, cross-origin refusal
14
19
  *
15
20
  * Not implemented (deliberately): row editing, DDL, destructive operations.
16
21
  * Studio is for inspection. Use the CLI, migrate, or raw SQL for writes.
@@ -405,6 +410,11 @@ export async function apiBuilder(req, res, ctx) {
405
410
  try {
406
411
  await client.query('BEGIN READ ONLY');
407
412
  await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
413
+ // QueryInterface emits unqualified table identifiers, which resolve via
414
+ // the connection's search_path. Pin it to the configured --schema so the
415
+ // Query tab reads the same schema as the Data tab (set_config is
416
+ // transaction-local and fully parameterized).
417
+ await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
408
418
  const started = Date.now();
409
419
  const result = await client.query(deferred.sql, deferred.params);
410
420
  const elapsedMs = Date.now() - started;
@@ -433,6 +443,8 @@ export async function apiBuilder(req, res, ctx) {
433
443
  function savedQueriesPath(ctx) {
434
444
  return pathResolve(ctx.stateDir, 'studio-queries.json');
435
445
  }
446
+ /** One-shot flag so the legacy saved-query notice isn't logged on every request. */
447
+ let legacyDropNoticeShown = false;
436
448
  function loadSavedQueries(ctx) {
437
449
  const file = savedQueriesPath(ctx);
438
450
  if (!existsSync(file))
@@ -442,8 +454,16 @@ function loadSavedQueries(ctx) {
442
454
  const parsed = JSON.parse(raw);
443
455
  if (!parsed.queries || !Array.isArray(parsed.queries))
444
456
  return { version: 1, queries: [] };
445
- // Drop any legacy raw-SQL entries — Studio is builder-only now.
457
+ // Drop any legacy raw-SQL entries — Studio is builder-only now. Tell the
458
+ // user instead of silently discarding their saved work (the file on disk
459
+ // is only rewritten when a new query is saved, so this is recoverable).
446
460
  const queries = parsed.queries.filter((q) => q && q.kind === 'builder');
461
+ const dropped = parsed.queries.length - queries.length;
462
+ if (dropped > 0 && !legacyDropNoticeShown) {
463
+ legacyDropNoticeShown = true;
464
+ console.warn(`[turbine studio] Ignoring ${dropped} legacy raw-SQL saved quer${dropped === 1 ? 'y' : 'ies'} in ${file} — ` +
465
+ 'Studio is builder-only since v0.19. The entries remain in the file until a new query is saved.');
466
+ }
447
467
  return { version: 1, queries };
448
468
  }
449
469
  catch {
package/dist/client.js CHANGED
@@ -202,6 +202,14 @@ export class TurbineClient {
202
202
  /** Active LISTEN subscriptions — torn down on disconnect() so it never hangs */
203
203
  activeSubscriptions = new Set();
204
204
  constructor(config = {}, schema) {
205
+ // Constructing without schema metadata previously crashed deep in the
206
+ // constructor with an opaque "Cannot read properties of undefined
207
+ // (reading 'tables')". Fail fast with an actionable message instead.
208
+ if (!schema || typeof schema !== 'object' || !schema.tables) {
209
+ throw new ValidationError('[turbine] TurbineClient requires schema metadata as its second argument. ' +
210
+ 'Run `npx turbine generate` and use the generated client (`turbine()` from your output dir), ' +
211
+ 'or pass the generated `schemaMetadata` object: new TurbineClient(config, schemaMetadata).');
212
+ }
205
213
  /**
206
214
  * Parse int8 (bigint, OID 20) as JavaScript number instead of string.
207
215
  * Safe for values up to Number.MAX_SAFE_INTEGER (9,007,199,254,740,991).
package/dist/index.d.ts CHANGED
@@ -43,7 +43,7 @@ export { type IntrospectOptions, introspect } from './introspect.js';
43
43
  export { executeNestedCreate, executeNestedUpdate, hasRelationFields, type NestedWriteContext, } from './nested-write.js';
44
44
  export type { ObserveConfig, ObserveHandle } from './observe.js';
45
45
  export { executePipeline, type PipelineOptions, type PipelineResults, pipelineSupported } from './pipeline.js';
46
- export { type AggregateArgs, type AggregateResult, type ArrayFilter, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GroupByArgs, type JsonFilter, type NestedCreateOp, type NestedUpdateOp, type OmitResult, type OrderByClause, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type SelectResult, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WithClause, type WithOptions, type WithResult, } from './query/index.js';
46
+ export { type AggregateArgs, type AggregateResult, type ArrayFilter, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GroupByArgs, type HavingClause, type JsonFilter, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type OmitResult, type OrderByClause, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type SelectResult, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithResult, } from './query/index.js';
47
47
  export { type ActiveSubscription, type NotificationHandler, type Subscription, validateChannel } from './realtime.js';
48
48
  export type { ColumnMetadata, IndexMetadata, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
49
49
  export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
@@ -402,6 +402,41 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
402
402
  * Uses the target table's column mapping to resolve field names.
403
403
  */
404
404
  private buildSubWhereForRelation;
405
+ /**
406
+ * Resolve a column's Postgres type from an arbitrary table's metadata
407
+ * (relation targets, not just `this.table`).
408
+ */
409
+ private pgTypeForColumn;
410
+ /**
411
+ * Equality-fallthrough guard shared by every SQL-build path AND every
412
+ * cache-hit param-collect path. A plain object literal that matched no known
413
+ * filter shape on a non-JSON column is almost always a misspelled operator
414
+ * (`startWith` for `startsWith`); binding it as `col = $1` silently returns
415
+ * wrong rows. Class instances (Buffer for bytea, Decimal wrappers, ...) are
416
+ * legitimate bind values and pass through, as do objects on json/jsonb
417
+ * columns (object equality).
418
+ */
419
+ private assertBindableEqualityValue;
420
+ /**
421
+ * Build the user-supplied `where` filter of a relation `with` clause against
422
+ * the relation's table alias. Supports the same scalar surface as the
423
+ * top-level WHERE builder — equality, IS NULL, operator objects (incl.
424
+ * `mode: 'insensitive'`), and OR/AND/NOT combinators. Unknown operator
425
+ * objects throw via {@link assertBindableEqualityValue}.
426
+ *
427
+ * Param push order MUST mirror {@link collectAliasWhereParams} exactly, or
428
+ * cache hits and pipeline batching will desync.
429
+ */
430
+ private buildAliasWhere;
431
+ /** Mirrors {@link buildAliasWhere} param-push order for the cache-hit collect path. */
432
+ private collectAliasWhereParams;
433
+ /**
434
+ * Value-invariant, shape-aware fingerprint for a relation `with` clause's
435
+ * `where` filter. Must distinguish every SQL shape {@link buildAliasWhere}
436
+ * can emit — equality vs null vs operator sets vs combinators — or two
437
+ * differently-shaped wheres would share one cached SQL string.
438
+ */
439
+ private fingerprintAliasWhere;
405
440
  /**
406
441
  * Build SQL clauses for a single operator object on a column.
407
442
  * Each operator key becomes its own clause, all ANDed together.