turbine-orm 0.27.0 → 0.28.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.
Files changed (52) hide show
  1. package/README.md +17 -13
  2. package/dist/cjs/cli/config.js +20 -3
  3. package/dist/cjs/cli/destructive.js +47 -31
  4. package/dist/cjs/cli/index.js +273 -71
  5. package/dist/cjs/cli/mcp.js +788 -0
  6. package/dist/cjs/cli/migrate.js +95 -20
  7. package/dist/cjs/cli/studio.js +3 -2
  8. package/dist/cjs/client.js +267 -34
  9. package/dist/cjs/dialect.js +2 -0
  10. package/dist/cjs/generate.js +171 -7
  11. package/dist/cjs/index.js +4 -1
  12. package/dist/cjs/introspect.js +177 -4
  13. package/dist/cjs/query/batched-loader.js +148 -0
  14. package/dist/cjs/query/builder.js +714 -133
  15. package/dist/cjs/schema-builder.js +59 -4
  16. package/dist/cjs/schema-sql.js +315 -6
  17. package/dist/cjs/seed.js +66 -0
  18. package/dist/cli/config.d.ts +9 -2
  19. package/dist/cli/config.js +19 -3
  20. package/dist/cli/destructive.js +47 -31
  21. package/dist/cli/index.d.ts +52 -1
  22. package/dist/cli/index.js +272 -74
  23. package/dist/cli/mcp.d.ts +17 -0
  24. package/dist/cli/mcp.js +781 -0
  25. package/dist/cli/migrate.d.ts +37 -0
  26. package/dist/cli/migrate.js +92 -20
  27. package/dist/cli/studio.d.ts +3 -2
  28. package/dist/cli/studio.js +3 -2
  29. package/dist/client.d.ts +136 -1
  30. package/dist/client.js +267 -34
  31. package/dist/dialect.d.ts +17 -0
  32. package/dist/dialect.js +2 -0
  33. package/dist/generate.d.ts +17 -0
  34. package/dist/generate.js +171 -10
  35. package/dist/index.d.ts +4 -3
  36. package/dist/index.js +2 -0
  37. package/dist/introspect.d.ts +20 -1
  38. package/dist/introspect.js +175 -4
  39. package/dist/query/batched-loader.d.ts +29 -2
  40. package/dist/query/batched-loader.js +148 -1
  41. package/dist/query/builder.d.ts +156 -8
  42. package/dist/query/builder.js +715 -134
  43. package/dist/query/index.d.ts +1 -1
  44. package/dist/query/types.d.ts +113 -8
  45. package/dist/schema-builder.d.ts +73 -8
  46. package/dist/schema-builder.js +59 -4
  47. package/dist/schema-sql.d.ts +67 -0
  48. package/dist/schema-sql.js +310 -6
  49. package/dist/schema.d.ts +53 -0
  50. package/dist/seed.d.ts +4 -0
  51. package/dist/seed.js +63 -0
  52. package/package.json +2 -3
@@ -97,6 +97,29 @@ export declare function createMigration(migrationsDir: string, name: string, aut
97
97
  * without contending on a single hardcoded lock ID.
98
98
  */
99
99
  export declare function deriveLockId(databaseName: string): number;
100
+ export interface ChecksumMismatch {
101
+ name: string;
102
+ expected: string;
103
+ actual: string;
104
+ /** 'modified' if file changed, 'missing' if file deleted */
105
+ type: 'modified' | 'missing';
106
+ }
107
+ export interface MigrationDeployPlan {
108
+ pending: MigrationFile[];
109
+ mismatches: ChecksumMismatch[];
110
+ }
111
+ /**
112
+ * Build a deploy plan from local migration files and applied migration rows.
113
+ * This is pure file-system planning; callers with a database connection should
114
+ * use `inspectMigrationDeploy()` to preserve legacy checksum upgrades.
115
+ */
116
+ export declare function planMigrationDeploy(migrationsDir: string, applied: AppliedMigration[]): MigrationDeployPlan;
117
+ /**
118
+ * Inspect deploy status without applying migrations.
119
+ */
120
+ export declare function inspectMigrationDeploy(connectionString: string, migrationsDir: string, options?: {
121
+ dialect?: Dialect;
122
+ }): Promise<MigrationDeployPlan>;
100
123
  /**
101
124
  * Apply all pending migrations (UP).
102
125
  *
@@ -126,6 +149,20 @@ export declare function migrateUp(connectionString: string, migrationsDir: strin
126
149
  error: string;
127
150
  }>;
128
151
  }>;
152
+ /**
153
+ * Production migration apply. This intentionally applies files as written and
154
+ * never performs interactive destructive confirmation.
155
+ */
156
+ export declare function migrateDeploy(connectionString: string, migrationsDir: string, options?: {
157
+ adapter?: DatabaseAdapter;
158
+ dialect?: Dialect;
159
+ }): Promise<{
160
+ applied: MigrationFile[];
161
+ errors: Array<{
162
+ file: MigrationFile;
163
+ error: string;
164
+ }>;
165
+ }>;
129
166
  /**
130
167
  * Rollback the last N migrations (DOWN).
131
168
  *
@@ -280,6 +280,85 @@ async function validateChecksums(client, migrationsDir, dialect = postgresDialec
280
280
  }
281
281
  return mismatches;
282
282
  }
283
+ function formatChecksumMismatchError(mismatches) {
284
+ const modified = mismatches.filter((m) => m.type === 'modified');
285
+ const missing = mismatches.filter((m) => m.type === 'missing');
286
+ const lines = [
287
+ '[turbine] Migration drift detected — refusing to apply pending migrations.',
288
+ '',
289
+ 'Applied migrations should be immutable. The following files no longer match their applied state:',
290
+ '',
291
+ ];
292
+ for (const m of modified) {
293
+ lines.push(` - ${m.name}.sql (modified on disk)`);
294
+ }
295
+ for (const m of missing) {
296
+ lines.push(` - ${m.name}.sql (deleted from disk)`);
297
+ }
298
+ lines.push('');
299
+ lines.push('Fix one of these:');
300
+ lines.push(' 1. Restore the file(s) to their original content, OR');
301
+ lines.push(' 2. Roll back the affected migrations with `npx turbine migrate down`, OR');
302
+ lines.push(' 3. Pass `--allow-drift` to bypass this check (advanced — make sure you know what you are doing).');
303
+ return lines.join('\n');
304
+ }
305
+ /**
306
+ * Build a deploy plan from local migration files and applied migration rows.
307
+ * This is pure file-system planning; callers with a database connection should
308
+ * use `inspectMigrationDeploy()` to preserve legacy checksum upgrades.
309
+ */
310
+ export function planMigrationDeploy(migrationsDir, applied) {
311
+ const allFiles = listMigrationFiles(migrationsDir);
312
+ const fileMap = new Map(allFiles.map((f) => [f.name, f]));
313
+ const appliedNames = new Set(applied.map((m) => m.name));
314
+ const mismatches = [];
315
+ for (const migration of applied) {
316
+ const file = fileMap.get(migration.name);
317
+ if (!file) {
318
+ mismatches.push({
319
+ name: migration.name,
320
+ expected: migration.checksum,
321
+ actual: '',
322
+ type: 'missing',
323
+ });
324
+ continue;
325
+ }
326
+ const currentHash = checksum(readFileSync(file.path, 'utf-8'));
327
+ if (currentHash !== migration.checksum && !isLegacyChecksum(migration.checksum)) {
328
+ mismatches.push({
329
+ name: migration.name,
330
+ expected: migration.checksum,
331
+ actual: currentHash,
332
+ type: 'modified',
333
+ });
334
+ }
335
+ }
336
+ return {
337
+ pending: allFiles.filter((f) => !appliedNames.has(f.name)),
338
+ mismatches,
339
+ };
340
+ }
341
+ /**
342
+ * Inspect deploy status without applying migrations.
343
+ */
344
+ export async function inspectMigrationDeploy(connectionString, migrationsDir, options) {
345
+ const client = new pg.Client({ connectionString });
346
+ await client.connect();
347
+ const dialect = options?.dialect ?? postgresDialect;
348
+ try {
349
+ await ensureTrackingTable(client, dialect);
350
+ const mismatches = await validateChecksums(client, migrationsDir, dialect);
351
+ const applied = await getAppliedMigrations(client, dialect);
352
+ const appliedNames = new Set(applied.map((m) => m.name));
353
+ return {
354
+ pending: listMigrationFiles(migrationsDir).filter((f) => !appliedNames.has(f.name)),
355
+ mismatches,
356
+ };
357
+ }
358
+ finally {
359
+ await client.end();
360
+ }
361
+ }
283
362
  /**
284
363
  * Apply all pending migrations (UP).
285
364
  *
@@ -323,26 +402,7 @@ export async function migrateUp(connectionString, migrationsDir, options) {
323
402
  if (!allowDrift) {
324
403
  const mismatches = await validateChecksums(client, migrationsDir, dialect);
325
404
  if (mismatches.length > 0) {
326
- const modified = mismatches.filter((m) => m.type === 'modified');
327
- const missing = mismatches.filter((m) => m.type === 'missing');
328
- const lines = [
329
- '[turbine] Migration drift detected — refusing to apply pending migrations.',
330
- '',
331
- 'Applied migrations should be immutable. The following files no longer match their applied state:',
332
- '',
333
- ];
334
- for (const m of modified) {
335
- lines.push(` - ${m.name}.sql (modified on disk)`);
336
- }
337
- for (const m of missing) {
338
- lines.push(` - ${m.name}.sql (deleted from disk)`);
339
- }
340
- lines.push('');
341
- lines.push('Fix one of these:');
342
- lines.push(' 1. Restore the file(s) to their original content, OR');
343
- lines.push(' 2. Roll back the affected migrations with `npx turbine migrate down`, OR');
344
- lines.push(' 3. Pass `--allow-drift` to bypass this check (advanced — make sure you know what you are doing).');
345
- throw new MigrationError(lines.join('\n'));
405
+ throw new MigrationError(formatChecksumMismatchError(mismatches));
346
406
  }
347
407
  }
348
408
  const applied = await getAppliedMigrations(client, dialect);
@@ -416,6 +476,18 @@ export async function migrateUp(connectionString, migrationsDir, options) {
416
476
  await client.end();
417
477
  }
418
478
  }
479
+ /**
480
+ * Production migration apply. This intentionally applies files as written and
481
+ * never performs interactive destructive confirmation.
482
+ */
483
+ export async function migrateDeploy(connectionString, migrationsDir, options) {
484
+ return migrateUp(connectionString, migrationsDir, {
485
+ allowDrift: false,
486
+ allowDestructive: true,
487
+ adapter: options?.adapter,
488
+ dialect: options?.dialect,
489
+ });
490
+ }
419
491
  /**
420
492
  * Rollback the last N migrations (DOWN).
421
493
  *
@@ -6,10 +6,11 @@
6
6
  * raw-SQL input surface — the Query tab builds `findMany` args that are
7
7
  * validated against introspected metadata and compiled by QueryInterface
8
8
  * (`/api/builder`). Pure Node (built-in `http` module), no runtime
9
- * dependencies beyond `pg`, bound to 127.0.0.1 only.
9
+ * dependencies beyond `pg`. CLI defaults to 127.0.0.1 and refuses non-loopback
10
+ * hosts unless `npx turbine studio --allow-remote` is set.
10
11
  *
11
12
  * Security model:
12
- * • Bind 127.0.0.1 only (never 0.0.0.0 no LAN exposure)
13
+ * • Loopback by default; CLI refuses non-loopback without --allow-remote
13
14
  * • Random auth token generated per process, required in Cookie header
14
15
  * • No SQL input surface at all — every identifier in a builder request is
15
16
  * validated against the introspected schema; all values are $N params
@@ -6,10 +6,11 @@
6
6
  * raw-SQL input surface — the Query tab builds `findMany` args that are
7
7
  * validated against introspected metadata and compiled by QueryInterface
8
8
  * (`/api/builder`). Pure Node (built-in `http` module), no runtime
9
- * dependencies beyond `pg`, bound to 127.0.0.1 only.
9
+ * dependencies beyond `pg`. CLI defaults to 127.0.0.1 and refuses non-loopback
10
+ * hosts unless `npx turbine studio --allow-remote` is set.
10
11
  *
11
12
  * Security model:
12
- * • Bind 127.0.0.1 only (never 0.0.0.0 no LAN exposure)
13
+ * • Loopback by default; CLI refuses non-loopback without --allow-remote
13
14
  * • Random auth token generated per process, required in Cookie header
14
15
  * • No SQL input surface at all — every identifier in a builder request is
15
16
  * validated against the introspected schema; all values are $N params
package/dist/client.d.ts CHANGED
@@ -26,7 +26,7 @@ import { type Dialect } from './dialect.js';
26
26
  import { type ErrorMessageMode } from './errors.js';
27
27
  import { type ObserveConfig, type ObserveHandle } from './observe.js';
28
28
  import { type PipelineOptions, type PipelineResults } from './pipeline.js';
29
- import { type DeferredQuery, type QueryEventListener, QueryInterface, type QueryInterfaceOptions, type RelationLoadStrategy } from './query/index.js';
29
+ import { type DeferredQuery, type GlobalFilters, type QueryEventListener, QueryInterface, type QueryInterfaceOptions, type RelationLoadStrategy } from './query/index.js';
30
30
  import { type NotificationHandler, type Subscription } from './realtime.js';
31
31
  import type { SchemaMetadata } from './schema.js';
32
32
  import { TypedSqlQuery } from './typed-sql.js';
@@ -211,6 +211,54 @@ export interface TurbineConfig {
211
211
  sqlCache?: boolean;
212
212
  /** SQL dialect implementation. Defaults to PostgreSQL. Internal Phase-1 seam for dialect packages. */
213
213
  dialect?: Dialect;
214
+ /**
215
+ * Read replicas. When set, read-only operations issued outside a transaction
216
+ * (`findMany`, `findFirst`, `findUnique`, `*OrThrow`, `count`, `aggregate`,
217
+ * `groupBy`, `findManyStream`) are round-robin load-balanced across these
218
+ * pools; the primary handles them along with every write. ALL writes,
219
+ * `$transaction` bodies, `pipeline`, `raw`/`sql`, `$listen`/`$notify`, and
220
+ * observability flushes always use the primary.
221
+ *
222
+ * - `string` entries are connection strings — Turbine constructs an owned
223
+ * `pg.Pool` for each (same pool-tuning knobs as the primary, and the same
224
+ * one-time, constructor-gated type-parser registration). `disconnect()`
225
+ * closes them.
226
+ * - `PgCompatPool` entries are external pools (Neon, Vercel, a shared
227
+ * `pg.Pool`) — Turbine registers no type parsers on them and never ends
228
+ * them; the caller owns their lifecycle.
229
+ *
230
+ * Use `client.$primary()` to get a view of the client that pins every
231
+ * operation (reads included) to the primary — e.g. to read your own write
232
+ * without replication lag. Omitting `replicas` (or passing `[]`) leaves the
233
+ * default single-pool path completely unchanged.
234
+ */
235
+ replicas?: readonly (string | PgCompatPool)[];
236
+ /**
237
+ * Automatic WHERE filters applied to every query, keyed by table accessor
238
+ * (`db[name]`). Each value is AND-merged into the compiled WHERE of every
239
+ * read and mutation on that table — and into every relation subquery that
240
+ * targets it — implementing soft-delete and multi-tenancy without repeating
241
+ * the predicate at each call site.
242
+ *
243
+ * - A `WhereClause` value is a static filter (e.g. `{ deletedAt: null }`).
244
+ * - A `() => WhereClause` value is evaluated at query-build time, so a
245
+ * closure over per-request state (the current tenant id) yields a
246
+ * request-scoped filter.
247
+ *
248
+ * `create`/`createMany` are never filtered. A per-query
249
+ * `skipGlobalFilters: true | string[]` opts out. The empty-`where` guard on
250
+ * `update`/`delete` still checks the USER-supplied `where`, so a global
251
+ * filter never turns an unguarded mass mutation into an allowed one.
252
+ *
253
+ * @example
254
+ * ```ts
255
+ * const db = turbine({ url, schema, globalFilters: {
256
+ * posts: { deletedAt: null }, // soft-delete
257
+ * orders: () => ({ tenantId: currentTenant() }), // per-request tenancy
258
+ * }});
259
+ * ```
260
+ */
261
+ globalFilters?: GlobalFilters;
214
262
  }
215
263
  /** Parameters passed to middleware functions */
216
264
  export interface MiddlewareParams {
@@ -314,6 +362,25 @@ export declare class TurbineClient {
314
362
  private readonly ownsPool;
315
363
  /** Active LISTEN subscriptions — torn down on disconnect() so it never hangs */
316
364
  private readonly activeSubscriptions;
365
+ /**
366
+ * Read-replica pools in round-robin order. Empty when no replicas are
367
+ * configured, in which case `table()` takes the original single-pool path.
368
+ */
369
+ private readonly replicaPools;
370
+ /**
371
+ * The subset of {@link replicaPools} that Turbine created from connection
372
+ * strings and must close on `disconnect()`. External replica pools are not
373
+ * listed here (caller owns their lifecycle).
374
+ */
375
+ private readonly ownedReplicaPools;
376
+ /** Rotating index for round-robin replica selection (advances per read op). */
377
+ private replicaCursor;
378
+ /** Per-replica `table → QueryInterface` caches, indexed like {@link replicaPools}. */
379
+ private readonly replicaTableCaches;
380
+ /** Cache of per-table routing proxies (only used when replicas are present). */
381
+ private readonly routingProxyCache;
382
+ /** Lazily-built, cached primary-only view returned by {@link $primary}. */
383
+ private primaryView?;
317
384
  constructor(config: TurbineConfig | undefined, schema: SchemaMetadata);
318
385
  /**
319
386
  * Register a middleware function that runs around every query.
@@ -353,8 +420,49 @@ export declare class TurbineClient {
353
420
  /**
354
421
  * Get a QueryInterface for a table.
355
422
  * Results are cached — calling `table('users')` twice returns the same instance.
423
+ *
424
+ * When read replicas are configured, this returns a thin routing proxy: the
425
+ * read-only operations in {@link READ_OPERATIONS} are dispatched to a
426
+ * round-robin replica-bound QueryInterface (so an entire read — base rows and
427
+ * any batched sub-queries — runs against a single consistent replica), while
428
+ * writes and every other member fall through to the primary-bound instance.
429
+ * With no replicas the original single-pool instance is returned directly.
356
430
  */
357
431
  table<T extends object = Record<string, unknown>>(name: string): QueryInterface<T>;
432
+ /** Get (and cache) the primary-pool-bound QueryInterface for a table. */
433
+ private primaryTableQI;
434
+ /**
435
+ * Advance the round-robin cursor and return the QueryInterface bound to the
436
+ * selected replica pool for `name` (cached per replica).
437
+ */
438
+ private nextReplicaTableQI;
439
+ /** Construct a QueryInterface bound to `pool` (honoring any injected factory). */
440
+ private buildTableQI;
441
+ /**
442
+ * Build the read/write routing proxy for a table. The proxy targets the
443
+ * primary QueryInterface (so writes, `build*`, and every non-read member work
444
+ * unchanged); read operations are intercepted and dispatched to a replica.
445
+ */
446
+ private createRoutingAccessor;
447
+ /**
448
+ * Return a view of this client that pins EVERY operation — reads included —
449
+ * to the primary pool, bypassing replica routing. Use it to read your own
450
+ * write without replication lag, or for any read that must see the latest
451
+ * committed data.
452
+ *
453
+ * The view shares the primary pool, schema, dialect, query options, and
454
+ * middleware; it owns nothing, so its `disconnect()` is a no-op. When no
455
+ * replicas are configured this simply returns the client itself (already
456
+ * primary-only). The view is cached — repeated calls return the same instance.
457
+ *
458
+ * @example
459
+ * ```ts
460
+ * await db.users.create({ data: { email: 'a@b.com' } });
461
+ * // Read-after-write: guaranteed to see the row just inserted.
462
+ * const user = await db.$primary().users.findFirst({ where: { email: 'a@b.com' } });
463
+ * ```
464
+ */
465
+ $primary(): TurbineClient;
358
466
  /**
359
467
  * Execute multiple queries in a single database round-trip.
360
468
  *
@@ -449,6 +557,33 @@ export declare class TurbineClient {
449
557
  * ```
450
558
  */
451
559
  $transaction<R>(fn: (tx: TransactionClient) => Promise<R>, options?: TransactionOptions): Promise<R>;
560
+ /**
561
+ * Batch form — run a tuple of {@link DeferredQuery} objects (produced by the
562
+ * `build*()` methods, e.g. `db.users.buildFindMany(...)`) atomically inside a
563
+ * single `BEGIN…COMMIT` on one connection. Returns a positionally-typed tuple
564
+ * of each query's transformed result; any failure rolls the whole batch back.
565
+ *
566
+ * Unlike {@link pipeline}, this never uses the extended-query pipeline
567
+ * protocol — it executes sequentially on the transaction connection, so it is
568
+ * safe on every driver (including HTTP/serverless pools).
569
+ *
570
+ * @example
571
+ * ```ts
572
+ * const [user, count] = await db.$transaction([
573
+ * db.users.buildFindUnique({ where: { id: 1 } }),
574
+ * db.posts.buildCount({ where: { userId: 1 } }),
575
+ * ]);
576
+ * ```
577
+ */
578
+ $transaction<T extends readonly DeferredQuery<unknown>[]>(queries: readonly [...T]): Promise<PipelineResults<T>>;
579
+ /**
580
+ * Execute a batch of {@link DeferredQuery} objects atomically inside one
581
+ * transaction. Backs the `$transaction([...])` array overload. Reuses the raw
582
+ * {@link transaction} machinery (BEGIN/COMMIT/ROLLBACK + connection release);
583
+ * queries run sequentially on the single transaction connection and each
584
+ * result is passed through its query's `transform`.
585
+ */
586
+ private transactionBatch;
452
587
  /**
453
588
  * Convenience wrapper around `$transaction` for the multi-tenant / RLS case:
454
589
  * runs `fn` inside a transaction with the given session GUCs applied via