turbine-orm 0.16.0 → 0.18.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 +180 -12
- package/dist/adapters/cockroachdb.js +4 -2
- package/dist/adapters/index.js +4 -1
- package/dist/adapters/yugabytedb.js +4 -2
- package/dist/cjs/adapters/cockroachdb.js +4 -2
- package/dist/cjs/adapters/index.js +4 -1
- package/dist/cjs/adapters/yugabytedb.js +4 -2
- package/dist/cjs/cli/studio.js +5 -1
- package/dist/cjs/client.js +164 -0
- package/dist/cjs/errors.js +35 -5
- package/dist/cjs/generate.js +14 -3
- package/dist/cjs/index.js +10 -2
- package/dist/cjs/introspect.js +81 -0
- package/dist/cjs/nested-write.js +70 -6
- package/dist/cjs/query/builder.js +538 -12
- package/dist/cjs/realtime.js +147 -0
- package/dist/cjs/schema-builder.js +86 -0
- package/dist/cjs/schema.js +10 -0
- package/dist/cjs/typed-sql.js +149 -0
- package/dist/cli/studio.js +5 -1
- package/dist/client.d.ts +120 -0
- package/dist/client.js +165 -1
- package/dist/errors.js +35 -5
- package/dist/generate.js +14 -3
- package/dist/index.d.ts +4 -2
- package/dist/index.js +5 -1
- package/dist/introspect.js +81 -0
- package/dist/nested-write.js +70 -6
- package/dist/query/builder.d.ts +104 -1
- package/dist/query/builder.js +539 -13
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +126 -2
- package/dist/realtime.d.ts +71 -0
- package/dist/realtime.js +144 -0
- package/dist/schema-builder.d.ts +68 -1
- package/dist/schema-builder.js +85 -0
- package/dist/schema.d.ts +18 -1
- package/dist/schema.js +10 -0
- package/dist/typed-sql.d.ts +101 -0
- package/dist/typed-sql.js +145 -0
- package/package.json +17 -15
package/dist/client.js
CHANGED
|
@@ -22,10 +22,13 @@
|
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
24
|
import pg from 'pg';
|
|
25
|
-
import { setErrorMessageMode, TimeoutError, wrapPgError } from './errors.js';
|
|
25
|
+
import { setErrorMessageMode, TimeoutError, ValidationError, wrapPgError } from './errors.js';
|
|
26
26
|
import { ObserveEngine } from './observe.js';
|
|
27
27
|
import { executePipeline, pipelineSupported } from './pipeline.js';
|
|
28
28
|
import { QueryInterface, } from './query/index.js';
|
|
29
|
+
import { quoteIdent } from './query/utils.js';
|
|
30
|
+
import { createSubscription, validateChannel, } from './realtime.js';
|
|
31
|
+
import { buildTypedSql, TypedSqlQuery } from './typed-sql.js';
|
|
29
32
|
export async function withRetry(fn, options) {
|
|
30
33
|
const maxAttempts = options?.maxAttempts ?? 3;
|
|
31
34
|
const baseDelay = options?.baseDelay ?? 50;
|
|
@@ -57,6 +60,13 @@ const ISOLATION_LEVELS = {
|
|
|
57
60
|
RepeatableRead: 'REPEATABLE READ',
|
|
58
61
|
Serializable: 'SERIALIZABLE',
|
|
59
62
|
};
|
|
63
|
+
/**
|
|
64
|
+
* Strict GUC (session variable) name: an optionally namespaced identifier such
|
|
65
|
+
* as `app.current_tenant` or `search_path`. Even though the name is passed as a
|
|
66
|
+
* bound parameter to `set_config`, a malformed name is a programmer error worth
|
|
67
|
+
* rejecting loudly before it reaches the database.
|
|
68
|
+
*/
|
|
69
|
+
const GUC_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/;
|
|
60
70
|
// ---------------------------------------------------------------------------
|
|
61
71
|
// TransactionClient — provides typed table accessors within a transaction
|
|
62
72
|
// ---------------------------------------------------------------------------
|
|
@@ -189,6 +199,8 @@ export class TurbineClient {
|
|
|
189
199
|
errorMessagesSafe;
|
|
190
200
|
/** True when Turbine created the pool and is responsible for tearing it down */
|
|
191
201
|
ownsPool = true;
|
|
202
|
+
/** Active LISTEN subscriptions — torn down on disconnect() so it never hangs */
|
|
203
|
+
activeSubscriptions = new Set();
|
|
192
204
|
constructor(config = {}, schema) {
|
|
193
205
|
/**
|
|
194
206
|
* Parse int8 (bigint, OID 20) as JavaScript number instead of string.
|
|
@@ -460,6 +472,40 @@ export class TurbineClient {
|
|
|
460
472
|
throw wrapPgError(err);
|
|
461
473
|
}
|
|
462
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* Execute a **typed** raw SQL query — Turbine's answer to Prisma's TypedSQL.
|
|
477
|
+
*
|
|
478
|
+
* Like {@link raw}, every interpolated `${value}` becomes a `$N` parameter
|
|
479
|
+
* (never string-concatenated), so it is injection-safe by construction. The
|
|
480
|
+
* difference is the caller-supplied row type and the chainable result: the
|
|
481
|
+
* returned {@link TypedSqlQuery} can be `await`ed directly for `T[]`, or
|
|
482
|
+
* refined with `.one()` (→ `T | null`) or `.scalar<V>()` (→ `V | null`).
|
|
483
|
+
*
|
|
484
|
+
* Rows are returned as-is — no snake→camel mapping (matching `raw()`). Alias
|
|
485
|
+
* columns in SQL if you want camelCase keys.
|
|
486
|
+
*
|
|
487
|
+
* @example
|
|
488
|
+
* ```ts
|
|
489
|
+
* // rows
|
|
490
|
+
* const rows = await db.sql<{ id: number; name: string }>`
|
|
491
|
+
* SELECT id, name FROM users WHERE org_id = ${orgId}
|
|
492
|
+
* `;
|
|
493
|
+
*
|
|
494
|
+
* // single row or null
|
|
495
|
+
* const user = await db.sql<{ id: number; name: string }>`
|
|
496
|
+
* SELECT id, name FROM users WHERE id = ${userId}
|
|
497
|
+
* `.one();
|
|
498
|
+
*
|
|
499
|
+
* // scalar
|
|
500
|
+
* const total = await db.sql<{ count: number }>`
|
|
501
|
+
* SELECT COUNT(*)::int AS count FROM users
|
|
502
|
+
* `.scalar();
|
|
503
|
+
* ```
|
|
504
|
+
*/
|
|
505
|
+
sql(strings, ...values) {
|
|
506
|
+
const { sql, params } = buildTypedSql(strings, values);
|
|
507
|
+
return new TypedSqlQuery(this.pool, sql, params, this.logging);
|
|
508
|
+
}
|
|
463
509
|
// -------------------------------------------------------------------------
|
|
464
510
|
// Transaction support (raw — legacy)
|
|
465
511
|
// -------------------------------------------------------------------------
|
|
@@ -542,6 +588,21 @@ export class TurbineClient {
|
|
|
542
588
|
beginSQL += ` ISOLATION LEVEL ${level}`;
|
|
543
589
|
}
|
|
544
590
|
await client.query(beginSQL);
|
|
591
|
+
// Apply transaction-local session context (RLS / multi-tenant GUCs).
|
|
592
|
+
// Order matters: BEGIN -> isolation level (above) -> set_config loop ->
|
|
593
|
+
// user fn. Any error here propagates to the catch below and rolls back
|
|
594
|
+
// like any other transaction failure. We use set_config(name, value,
|
|
595
|
+
// is_local=true) — the parameterizable, transaction-scoped equivalent of
|
|
596
|
+
// SET LOCAL — so both name and value are BOUND params, never interpolated.
|
|
597
|
+
if (options?.sessionContext) {
|
|
598
|
+
for (const [name, value] of Object.entries(options.sessionContext)) {
|
|
599
|
+
if (!GUC_NAME_REGEX.test(name)) {
|
|
600
|
+
throw new ValidationError(`[turbine] Invalid session-context GUC name "${name}" — must match ` +
|
|
601
|
+
'/^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)?$/ (optionally namespaced, e.g. "app.current_tenant")');
|
|
602
|
+
}
|
|
603
|
+
await client.query('SELECT set_config($1, $2, true)', [name, String(value)]);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
545
606
|
// Create the transaction client with typed table accessors
|
|
546
607
|
const tx = new TransactionClient(client, this.schema, this.middlewares, this.queryOptions);
|
|
547
608
|
// Dynamically attach table accessors to tx
|
|
@@ -613,6 +674,94 @@ export class TurbineClient {
|
|
|
613
674
|
releaseOnce();
|
|
614
675
|
}
|
|
615
676
|
}
|
|
677
|
+
/**
|
|
678
|
+
* Convenience wrapper around `$transaction` for the multi-tenant / RLS case:
|
|
679
|
+
* runs `fn` inside a transaction with the given session GUCs applied via
|
|
680
|
+
* `set_config(..., is_local=true)`. Equivalent to
|
|
681
|
+
* `$transaction(fn, { sessionContext: context })`.
|
|
682
|
+
*
|
|
683
|
+
* @example
|
|
684
|
+
* ```ts
|
|
685
|
+
* const invoices = await db.$withSession(
|
|
686
|
+
* { 'app.current_tenant': tenantId },
|
|
687
|
+
* (tx) => tx.invoices.findMany(),
|
|
688
|
+
* );
|
|
689
|
+
* ```
|
|
690
|
+
*/
|
|
691
|
+
async $withSession(context, fn) {
|
|
692
|
+
return this.$transaction(fn, { sessionContext: context });
|
|
693
|
+
}
|
|
694
|
+
// -------------------------------------------------------------------------
|
|
695
|
+
// LISTEN / NOTIFY — Postgres realtime pub/sub
|
|
696
|
+
// -------------------------------------------------------------------------
|
|
697
|
+
/**
|
|
698
|
+
* Subscribe to a Postgres NOTIFY channel. The handler fires with each
|
|
699
|
+
* notification's payload string (the empty string when a payload-less
|
|
700
|
+
* NOTIFY is sent) for as long as the subscription is active.
|
|
701
|
+
*
|
|
702
|
+
* Each `$listen` checks out its OWN dedicated long-lived connection from the
|
|
703
|
+
* pool and runs `LISTEN "channel"` on it; `subscription.unsubscribe()`
|
|
704
|
+
* UNLISTENs, detaches the handler, and releases that connection. Active
|
|
705
|
+
* subscriptions are tracked and force-released on `disconnect()` so shutdown
|
|
706
|
+
* never hangs.
|
|
707
|
+
*
|
|
708
|
+
* The channel name CANNOT be a bound parameter (`LISTEN $1` is a syntax
|
|
709
|
+
* error), so it is validated against a strict identifier regex AND quoted via
|
|
710
|
+
* `quoteIdent` before interpolation — it is the only identifier this method
|
|
711
|
+
* places into SQL text.
|
|
712
|
+
*
|
|
713
|
+
* **Serverless caveat:** LISTEN needs a persistent connection that can push
|
|
714
|
+
* async notifications. Stateless HTTP drivers (Neon HTTP, Vercel Postgres)
|
|
715
|
+
* cannot do this — `$listen` throws a `ConnectionError` rather than hang.
|
|
716
|
+
* `$notify` works on every driver.
|
|
717
|
+
*
|
|
718
|
+
* @example
|
|
719
|
+
* ```ts
|
|
720
|
+
* const sub = await db.$listen('order_created', (payload) => {
|
|
721
|
+
* const order = JSON.parse(payload);
|
|
722
|
+
* console.log('new order', order.id);
|
|
723
|
+
* });
|
|
724
|
+
* // ...later
|
|
725
|
+
* await sub.unsubscribe();
|
|
726
|
+
* ```
|
|
727
|
+
*/
|
|
728
|
+
async $listen(channel, handler) {
|
|
729
|
+
validateChannel(channel);
|
|
730
|
+
const quoted = quoteIdent(channel);
|
|
731
|
+
if (this.logging) {
|
|
732
|
+
console.log(`[turbine] LISTEN ${quoted}`);
|
|
733
|
+
}
|
|
734
|
+
const sub = await createSubscription(this.pool, channel, quoted, handler, (closed) => {
|
|
735
|
+
this.activeSubscriptions.delete(closed);
|
|
736
|
+
});
|
|
737
|
+
this.activeSubscriptions.add(sub);
|
|
738
|
+
return sub;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Send a Postgres NOTIFY on `channel` with an optional payload string.
|
|
742
|
+
*
|
|
743
|
+
* Issued as `SELECT pg_notify($1, $2)` — both the channel and payload are
|
|
744
|
+
* BOUND parameters (no quoting/injection concern). The channel is still
|
|
745
|
+
* validated against the identifier regex for parity with `$listen` and to
|
|
746
|
+
* catch typos loudly. Works on every driver, including serverless HTTP pools.
|
|
747
|
+
*
|
|
748
|
+
* @example
|
|
749
|
+
* ```ts
|
|
750
|
+
* await db.$notify('order_created', JSON.stringify({ id: 7 }));
|
|
751
|
+
* ```
|
|
752
|
+
*/
|
|
753
|
+
async $notify(channel, payload) {
|
|
754
|
+
validateChannel(channel);
|
|
755
|
+
if (this.logging) {
|
|
756
|
+
console.log(`[turbine] NOTIFY ${channel}`);
|
|
757
|
+
}
|
|
758
|
+
try {
|
|
759
|
+
await this.pool.query('SELECT pg_notify($1, $2)', [channel, payload ?? '']);
|
|
760
|
+
}
|
|
761
|
+
catch (err) {
|
|
762
|
+
throw wrapPgError(err);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
616
765
|
// -------------------------------------------------------------------------
|
|
617
766
|
// Retry — automatic retry for retryable errors (deadlock, serialization)
|
|
618
767
|
// -------------------------------------------------------------------------
|
|
@@ -660,6 +809,21 @@ export class TurbineClient {
|
|
|
660
809
|
* method is a no-op — the caller is responsible for the pool's lifecycle.
|
|
661
810
|
*/
|
|
662
811
|
async disconnect() {
|
|
812
|
+
// Tear down any live LISTEN subscriptions first. Each holds a dedicated
|
|
813
|
+
// pooled connection checked out; if we ended the pool (or returned for an
|
|
814
|
+
// external pool) without releasing them, pool.end() would wait forever for
|
|
815
|
+
// those connections to return. _forceRelease() detaches the handler and
|
|
816
|
+
// releases the client WITHOUT issuing UNLISTEN (pointless if we're ending
|
|
817
|
+
// the pool / the connection is going away anyway). This runs for both
|
|
818
|
+
// owned and external pools so subscriptions never leak.
|
|
819
|
+
if (this.activeSubscriptions.size > 0) {
|
|
820
|
+
// _forceRelease mutates activeSubscriptions via the onClosed callback,
|
|
821
|
+
// so iterate a snapshot.
|
|
822
|
+
for (const sub of [...this.activeSubscriptions]) {
|
|
823
|
+
sub._forceRelease();
|
|
824
|
+
}
|
|
825
|
+
this.activeSubscriptions.clear();
|
|
826
|
+
}
|
|
663
827
|
if (!this.ownsPool) {
|
|
664
828
|
if (this.logging) {
|
|
665
829
|
console.log('[turbine] disconnect() skipped — external pool is not owned by Turbine');
|
package/dist/errors.js
CHANGED
|
@@ -197,7 +197,13 @@ export class UniqueConstraintError extends TurbineError {
|
|
|
197
197
|
const constraintPart = constraint ? ` on ${constraint}` : '';
|
|
198
198
|
const columnsPart = columns && columns.length > 0 ? ` (${columns.join(', ')})` : '';
|
|
199
199
|
message = `[turbine] Unique constraint violation${constraintPart}${columnsPart}`;
|
|
200
|
-
|
|
200
|
+
// PII-safe by default: the raw pg `detail` string contains the
|
|
201
|
+
// conflicting row VALUES (e.g. `Key (email)=(alice@x.com) already
|
|
202
|
+
// exists.`). Only append it in 'verbose' mode. In 'safe' mode the
|
|
203
|
+
// message carries keys/constraint/column names only — the structured
|
|
204
|
+
// `.columns`/`.constraint`/`.column` fields and `.cause` still expose
|
|
205
|
+
// the full detail for programmatic use.
|
|
206
|
+
const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
|
|
201
207
|
if (detail)
|
|
202
208
|
message += `: ${detail}`;
|
|
203
209
|
}
|
|
@@ -218,7 +224,13 @@ export class ForeignKeyError extends TurbineError {
|
|
|
218
224
|
if (!message) {
|
|
219
225
|
const constraintPart = constraint ? ` on ${constraint}` : '';
|
|
220
226
|
message = `[turbine] Foreign key constraint violation${constraintPart}`;
|
|
221
|
-
|
|
227
|
+
// PII-safe by default: the raw pg `detail` string contains the
|
|
228
|
+
// conflicting row VALUES (e.g. `Key (email)=(alice@x.com) already
|
|
229
|
+
// exists.`). Only append it in 'verbose' mode. In 'safe' mode the
|
|
230
|
+
// message carries keys/constraint/column names only — the structured
|
|
231
|
+
// `.columns`/`.constraint`/`.column` fields and `.cause` still expose
|
|
232
|
+
// the full detail for programmatic use.
|
|
233
|
+
const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
|
|
222
234
|
if (detail)
|
|
223
235
|
message += `: ${detail}`;
|
|
224
236
|
}
|
|
@@ -238,7 +250,13 @@ export class NotNullViolationError extends TurbineError {
|
|
|
238
250
|
if (!message) {
|
|
239
251
|
const columnPart = column ? ` on column "${column}"` : '';
|
|
240
252
|
message = `[turbine] NOT NULL constraint violation${columnPart}`;
|
|
241
|
-
|
|
253
|
+
// PII-safe by default: the raw pg `detail` string contains the
|
|
254
|
+
// conflicting row VALUES (e.g. `Key (email)=(alice@x.com) already
|
|
255
|
+
// exists.`). Only append it in 'verbose' mode. In 'safe' mode the
|
|
256
|
+
// message carries keys/constraint/column names only — the structured
|
|
257
|
+
// `.columns`/`.constraint`/`.column` fields and `.cause` still expose
|
|
258
|
+
// the full detail for programmatic use.
|
|
259
|
+
const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
|
|
242
260
|
if (detail)
|
|
243
261
|
message += `: ${detail}`;
|
|
244
262
|
}
|
|
@@ -323,7 +341,13 @@ export class CheckConstraintError extends TurbineError {
|
|
|
323
341
|
if (!message) {
|
|
324
342
|
const constraintPart = constraint ? ` on ${constraint}` : '';
|
|
325
343
|
message = `[turbine] Check constraint violation${constraintPart}`;
|
|
326
|
-
|
|
344
|
+
// PII-safe by default: the raw pg `detail` string contains the
|
|
345
|
+
// conflicting row VALUES (e.g. `Key (email)=(alice@x.com) already
|
|
346
|
+
// exists.`). Only append it in 'verbose' mode. In 'safe' mode the
|
|
347
|
+
// message carries keys/constraint/column names only — the structured
|
|
348
|
+
// `.columns`/`.constraint`/`.column` fields and `.cause` still expose
|
|
349
|
+
// the full detail for programmatic use.
|
|
350
|
+
const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
|
|
327
351
|
if (detail)
|
|
328
352
|
message += `: ${detail}`;
|
|
329
353
|
}
|
|
@@ -342,7 +366,13 @@ export class ExclusionConstraintError extends TurbineError {
|
|
|
342
366
|
if (!message) {
|
|
343
367
|
const constraintPart = constraint ? ` on ${constraint}` : '';
|
|
344
368
|
message = `[turbine] Exclusion constraint violation${constraintPart}`;
|
|
345
|
-
|
|
369
|
+
// PII-safe by default: the raw pg `detail` string contains the
|
|
370
|
+
// conflicting row VALUES (e.g. `Key (email)=(alice@x.com) already
|
|
371
|
+
// exists.`). Only append it in 'verbose' mode. In 'safe' mode the
|
|
372
|
+
// message carries keys/constraint/column names only — the structured
|
|
373
|
+
// `.columns`/`.constraint`/`.column` fields and `.cause` still expose
|
|
374
|
+
// the full detail for programmatic use.
|
|
375
|
+
const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
|
|
346
376
|
if (detail)
|
|
347
377
|
message += `: ${detail}`;
|
|
348
378
|
}
|
package/dist/generate.js
CHANGED
|
@@ -152,7 +152,8 @@ export function generateTypes(schema) {
|
|
|
152
152
|
lines.push(`export interface ${typeName}Relations {`);
|
|
153
153
|
for (const [relName, rel] of Object.entries(table.relations)) {
|
|
154
154
|
const targetType = entityName(rel.to);
|
|
155
|
-
|
|
155
|
+
// manyToMany is a collection too → 'many' cardinality (same as hasMany).
|
|
156
|
+
const cardinality = rel.type === 'hasMany' || rel.type === 'manyToMany' ? "'many'" : "'one'";
|
|
156
157
|
const targetRelations = tablesWithRelations.has(rel.to) ? `${targetType}Relations` : '{}';
|
|
157
158
|
lines.push(` ${relName}: RelationDescriptor<${targetType}, ${cardinality}, ${targetRelations}>;`);
|
|
158
159
|
}
|
|
@@ -161,7 +162,7 @@ export function generateTypes(schema) {
|
|
|
161
162
|
// --- Legacy per-relation interfaces (kept for backward compatibility) ---
|
|
162
163
|
for (const [relName, rel] of Object.entries(table.relations)) {
|
|
163
164
|
const targetType = entityName(rel.to);
|
|
164
|
-
if (rel.type === 'hasMany') {
|
|
165
|
+
if (rel.type === 'hasMany' || rel.type === 'manyToMany') {
|
|
165
166
|
lines.push(`/** ${typeName} with \`${relName}\` relation loaded (${rel.type}: ${rel.to}) */`);
|
|
166
167
|
lines.push(`export interface ${typeName}With${snakeToPascal(relName)} extends ${typeName} {`);
|
|
167
168
|
lines.push(` ${relName}: ${targetType}[];`);
|
|
@@ -328,7 +329,17 @@ function generateMetadata(schema) {
|
|
|
328
329
|
const refLiteral = Array.isArray(rel.referenceKey)
|
|
329
330
|
? `[${rel.referenceKey.map((c) => `'${escSQ(c)}'`).join(', ')}]`
|
|
330
331
|
: `'${escSQ(rel.referenceKey)}'`;
|
|
331
|
-
|
|
332
|
+
// manyToMany relations carry a `through` junction descriptor — emit it so
|
|
333
|
+
// the runtime query builder can JOIN through the junction table.
|
|
334
|
+
let throughLiteral = '';
|
|
335
|
+
if (rel.through) {
|
|
336
|
+
const keyLiteral = (k) => Array.isArray(k) ? `[${k.map((c) => `'${escSQ(c)}'`).join(', ')}]` : `'${escSQ(k)}'`;
|
|
337
|
+
throughLiteral =
|
|
338
|
+
`, through: { table: '${escSQ(rel.through.table)}', ` +
|
|
339
|
+
`sourceKey: ${keyLiteral(rel.through.sourceKey)}, ` +
|
|
340
|
+
`targetKey: ${keyLiteral(rel.through.targetKey)} }`;
|
|
341
|
+
}
|
|
342
|
+
lines.push(` ${relName}: { type: '${escSQ(rel.type)}', name: '${escSQ(rel.name)}', from: '${escSQ(rel.from)}', to: '${escSQ(rel.to)}', foreignKey: ${fkLiteral}, referenceKey: ${refLiteral}${throughLiteral} },`);
|
|
332
343
|
}
|
|
333
344
|
lines.push(' },');
|
|
334
345
|
// indexes
|
package/dist/index.d.ts
CHANGED
|
@@ -43,10 +43,12 @@ 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 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 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 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';
|
|
47
|
+
export { type ActiveSubscription, type NotificationHandler, type Subscription, validateChannel } from './realtime.js';
|
|
47
48
|
export type { ColumnMetadata, IndexMetadata, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
|
|
48
49
|
export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
|
|
49
|
-
export { ColumnBuilder, type ColumnConfig, type ColumnDef, type ColumnType, type ColumnTypeName, column, defineSchema, type SchemaDef, type TableDef, table, } from './schema-builder.js';
|
|
50
|
+
export { applyManyToManyRelations, ColumnBuilder, type ColumnConfig, type ColumnDef, type ColumnType, type ColumnTypeName, column, defineSchema, type ManyToManyDef, type SchemaDef, type TableDef, table, } from './schema-builder.js';
|
|
50
51
|
export { type AlterColumnDef, type AlterDef, type DiffResult, type PushResult, type SchemaSqlOptions, schemaDiff, schemaPush, schemaToSQL, schemaToSQLString, } from './schema-sql.js';
|
|
51
52
|
export { type TurbineHttpOptions, turbineHttp } from './serverless.js';
|
|
53
|
+
export { buildTypedSql, TypedSqlQuery } from './typed-sql.js';
|
|
52
54
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -48,14 +48,18 @@ export { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from './
|
|
|
48
48
|
export { executePipeline, pipelineSupported } from './pipeline.js';
|
|
49
49
|
// Query builder
|
|
50
50
|
export { QueryInterface, } from './query/index.js';
|
|
51
|
+
// Realtime — LISTEN/NOTIFY pub/sub
|
|
52
|
+
export { validateChannel } from './realtime.js';
|
|
51
53
|
// Schema utilities
|
|
52
54
|
export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
|
|
53
55
|
// Schema builder — define schemas in TypeScript
|
|
54
|
-
export { ColumnBuilder, column, defineSchema,
|
|
56
|
+
export { applyManyToManyRelations, ColumnBuilder, column, defineSchema,
|
|
55
57
|
// Legacy compat (deprecated — use object format with defineSchema)
|
|
56
58
|
table, } from './schema-builder.js';
|
|
57
59
|
// Schema SQL — generate DDL, diff, and push
|
|
58
60
|
export { schemaDiff, schemaPush, schemaToSQL, schemaToSQLString, } from './schema-sql.js';
|
|
59
61
|
// Serverless / edge factory
|
|
60
62
|
export { turbineHttp } from './serverless.js';
|
|
63
|
+
// Typed raw SQL — Turbine's TypedSQL escape hatch
|
|
64
|
+
export { buildTypedSql, TypedSqlQuery } from './typed-sql.js';
|
|
61
65
|
//# sourceMappingURL=index.js.map
|
package/dist/introspect.js
CHANGED
|
@@ -275,6 +275,87 @@ export async function introspect(options) {
|
|
|
275
275
|
referenceKey,
|
|
276
276
|
};
|
|
277
277
|
}
|
|
278
|
+
// ----- Conservative many-to-many auto-detection (PURELY ADDITIVE) -----
|
|
279
|
+
//
|
|
280
|
+
// Auto-detecting m2m is a footgun: any table with two FKs *looks* like a
|
|
281
|
+
// junction, but a `enrollments(student_id, course_id, grade, enrolled_at)`
|
|
282
|
+
// table is a first-class entity, not a join table. Prisma and Drizzle both
|
|
283
|
+
// require explicit m2m declaration for exactly this reason.
|
|
284
|
+
//
|
|
285
|
+
// We only treat a table J as a PURE junction when ALL of these hold:
|
|
286
|
+
// 1. J's primary key is exactly two columns.
|
|
287
|
+
// 2. J has exactly two FKs, each single-column.
|
|
288
|
+
// 3. Each FK's source column is one of J's two PK columns (the PK *is* the
|
|
289
|
+
// two FK columns — no surrogate PK, no extra identity).
|
|
290
|
+
// 4. The two FKs target two DISTINCT tables (A and B).
|
|
291
|
+
// 5. J has no columns beyond those two FK/PK columns (no payload columns
|
|
292
|
+
// like `grade` or `created_at`).
|
|
293
|
+
//
|
|
294
|
+
// For such a J linking A and B we ADD a `manyToMany` relation on A → B and
|
|
295
|
+
// symmetrically on B → A, both routed `through` J. The existing belongsTo /
|
|
296
|
+
// hasMany relations derived from J's FKs are left untouched — this block
|
|
297
|
+
// never removes or renames anything. If the chosen relation name already
|
|
298
|
+
// exists on the source table (e.g. another relation grabbed it), we SKIP to
|
|
299
|
+
// stay additive.
|
|
300
|
+
for (const tableName of tableNames) {
|
|
301
|
+
const pk = pkByTable.get(tableName) ?? [];
|
|
302
|
+
if (pk.length !== 2)
|
|
303
|
+
continue;
|
|
304
|
+
// FKs whose source is this table.
|
|
305
|
+
const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
|
|
306
|
+
if (tableFks.length !== 2)
|
|
307
|
+
continue;
|
|
308
|
+
// Both FKs must be single-column.
|
|
309
|
+
if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
|
|
310
|
+
continue;
|
|
311
|
+
const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
|
|
312
|
+
const pkSet = new Set(pk);
|
|
313
|
+
// Both FK columns must be the PK columns (and vice-versa).
|
|
314
|
+
if (!fkCols.every((c) => pkSet.has(c)))
|
|
315
|
+
continue;
|
|
316
|
+
if (new Set(fkCols).size !== 2)
|
|
317
|
+
continue;
|
|
318
|
+
// Two DISTINCT target tables.
|
|
319
|
+
const [fkA, fkB] = tableFks;
|
|
320
|
+
if (fkA.targetTable === fkB.targetTable)
|
|
321
|
+
continue;
|
|
322
|
+
// No payload columns: J's columns are exactly the two FK/PK columns.
|
|
323
|
+
const jCols = (columnsByTable.get(tableName) ?? []).map((c) => c.name);
|
|
324
|
+
if (jCols.length !== 2)
|
|
325
|
+
continue;
|
|
326
|
+
// For each direction, the m2m `referenceKey` is the *targeted* table's
|
|
327
|
+
// referenced column(s); the junction's sourceKey is the FK column pointing
|
|
328
|
+
// to that table; the targetKey is the FK column pointing to the OTHER table.
|
|
329
|
+
const addM2M = (self, other) => {
|
|
330
|
+
const sourceTbl = self.targetTable; // A
|
|
331
|
+
const targetTbl = other.targetTable; // B
|
|
332
|
+
const relName = snakeToCamel(targetTbl); // plural table name → e.g. "tags"
|
|
333
|
+
if (!relationsByTable.has(sourceTbl))
|
|
334
|
+
relationsByTable.set(sourceTbl, {});
|
|
335
|
+
const existing = relationsByTable.get(sourceTbl);
|
|
336
|
+
// Additive-only: never clobber an existing relation name.
|
|
337
|
+
if (existing[relName])
|
|
338
|
+
return;
|
|
339
|
+
existing[relName] = {
|
|
340
|
+
type: 'manyToMany',
|
|
341
|
+
name: relName,
|
|
342
|
+
from: sourceTbl,
|
|
343
|
+
to: targetTbl,
|
|
344
|
+
// referenceKey = A's referenced column(s) that J's sourceKey points at.
|
|
345
|
+
referenceKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
|
|
346
|
+
// foreignKey is unused for m2m correlation but kept for shape parity
|
|
347
|
+
// (mirrors the source-side reference for back-compat consumers).
|
|
348
|
+
foreignKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
|
|
349
|
+
through: {
|
|
350
|
+
table: tableName,
|
|
351
|
+
sourceKey: self.sourceColumns[0], // J col → A
|
|
352
|
+
targetKey: other.sourceColumns[0], // J col → B
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
};
|
|
356
|
+
addM2M(fkA, fkB); // A → B
|
|
357
|
+
addM2M(fkB, fkA); // B → A
|
|
358
|
+
}
|
|
278
359
|
// ----- Assemble TableMetadata for each table -----
|
|
279
360
|
const tables = {};
|
|
280
361
|
for (const tableName of tableNames) {
|
package/dist/nested-write.js
CHANGED
|
@@ -137,17 +137,29 @@ export async function executeNestedCreate(ctx, tableName, data, depth = 0, path
|
|
|
137
137
|
}
|
|
138
138
|
validateOps(relName, ops, false);
|
|
139
139
|
}
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
//
|
|
140
|
+
// belongsTo relations put the foreign key on the PARENT row, so they must be
|
|
141
|
+
// resolved BEFORE the parent is inserted — otherwise a NOT NULL FK column
|
|
142
|
+
// fails on the initial INSERT. We resolve each belongsTo op (create/connect/
|
|
143
|
+
// connectOrCreate) to its referenced row and fold the FK values into the
|
|
144
|
+
// parent's own INSERT.
|
|
145
|
+
const belongsToFks = {};
|
|
146
|
+
for (const [relName, ops] of Object.entries(relations)) {
|
|
147
|
+
const rel = tableMeta.relations[relName];
|
|
148
|
+
if (rel.type === 'belongsTo') {
|
|
149
|
+
Object.assign(belongsToFks, await resolveBelongsToForCreate(ctx, rel, ops, tableName, depth, path, relName));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Insert the parent row (scalars + resolved belongsTo foreign keys)
|
|
153
|
+
const parentRow = (await ctx.tx.table(tableName).create({
|
|
154
|
+
data: { ...scalars, ...belongsToFks },
|
|
155
|
+
}));
|
|
156
|
+
// Process hasMany / hasOne relations — their FK lives on the CHILD, so they
|
|
157
|
+
// need the parent row to exist first.
|
|
143
158
|
for (const [relName, ops] of Object.entries(relations)) {
|
|
144
159
|
const rel = tableMeta.relations[relName];
|
|
145
160
|
if (rel.type === 'hasMany' || rel.type === 'hasOne') {
|
|
146
161
|
await processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relName);
|
|
147
162
|
}
|
|
148
|
-
else if (rel.type === 'belongsTo') {
|
|
149
|
-
await processBelongsToCreate(ctx, rel, ops, parentRow, tableName, depth, path, relName);
|
|
150
|
-
}
|
|
151
163
|
}
|
|
152
164
|
// Build the `with` clause for the final read to return the full tree
|
|
153
165
|
const withClause = {};
|
|
@@ -312,6 +324,58 @@ async function processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relNa
|
|
|
312
324
|
// ---------------------------------------------------------------------------
|
|
313
325
|
// belongsTo create operations
|
|
314
326
|
// ---------------------------------------------------------------------------
|
|
327
|
+
/**
|
|
328
|
+
* Resolve a belongsTo relation's create/connect/connectOrCreate op to the
|
|
329
|
+
* foreign-key value(s) that belong on the PARENT row, returning them keyed by
|
|
330
|
+
* the parent's own field names so they can be merged into the parent INSERT.
|
|
331
|
+
*
|
|
332
|
+
* Used by the create path only. (The update path uses processBelongsToCreate,
|
|
333
|
+
* which UPDATEs the FK after the parent already exists.)
|
|
334
|
+
*/
|
|
335
|
+
async function resolveBelongsToForCreate(ctx, rel, ops, parentTable, depth, path, relName) {
|
|
336
|
+
const fks = normalizeKeyColumns(rel.foreignKey);
|
|
337
|
+
const refs = normalizeKeyColumns(rel.referenceKey);
|
|
338
|
+
const parentMeta = ctx.schema.tables[parentTable];
|
|
339
|
+
const relatedTable = ctx.schema.tables[rel.to];
|
|
340
|
+
let relatedRow = null;
|
|
341
|
+
if (ops.create !== undefined) {
|
|
342
|
+
const items = toArray(ops.create);
|
|
343
|
+
if (items.length > 0) {
|
|
344
|
+
relatedRow = (await executeNestedCreate(ctx, rel.to, items[0], depth + 1, [...path, relName]));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
else if (ops.connect !== undefined) {
|
|
348
|
+
const items = toArray(ops.connect);
|
|
349
|
+
if (items.length > 0) {
|
|
350
|
+
const target = items[0];
|
|
351
|
+
relatedRow = (await ctx.tx.table(rel.to).findUnique({ where: target }));
|
|
352
|
+
if (!relatedRow) {
|
|
353
|
+
throw new ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${JSON.stringify(target)}.`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
else if (ops.connectOrCreate !== undefined) {
|
|
358
|
+
const items = toArray(ops.connectOrCreate);
|
|
359
|
+
if (items.length > 0) {
|
|
360
|
+
const op = items[0];
|
|
361
|
+
relatedRow = (await ctx.tx.table(rel.to).findUnique({ where: op.where }));
|
|
362
|
+
if (!relatedRow) {
|
|
363
|
+
// For belongsTo the FK lives on the parent, so the related row is
|
|
364
|
+
// created plainly (no FK injection) and we read its reference key.
|
|
365
|
+
relatedRow = (await ctx.tx.table(rel.to).create({ data: op.create }));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const fkScalars = {};
|
|
370
|
+
if (relatedRow) {
|
|
371
|
+
for (let i = 0; i < fks.length; i++) {
|
|
372
|
+
const fkField = parentMeta.reverseColumnMap[fks[i]] ?? fks[i];
|
|
373
|
+
const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
|
|
374
|
+
fkScalars[fkField] = relatedRow[refField];
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return fkScalars;
|
|
378
|
+
}
|
|
315
379
|
async function processBelongsToCreate(ctx, rel, ops, parentRow, parentTable, depth, path, relName) {
|
|
316
380
|
const fks = normalizeKeyColumns(rel.foreignKey);
|
|
317
381
|
const refs = normalizeKeyColumns(rel.referenceKey);
|