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.
Files changed (41) hide show
  1. package/README.md +180 -12
  2. package/dist/adapters/cockroachdb.js +4 -2
  3. package/dist/adapters/index.js +4 -1
  4. package/dist/adapters/yugabytedb.js +4 -2
  5. package/dist/cjs/adapters/cockroachdb.js +4 -2
  6. package/dist/cjs/adapters/index.js +4 -1
  7. package/dist/cjs/adapters/yugabytedb.js +4 -2
  8. package/dist/cjs/cli/studio.js +5 -1
  9. package/dist/cjs/client.js +164 -0
  10. package/dist/cjs/errors.js +35 -5
  11. package/dist/cjs/generate.js +14 -3
  12. package/dist/cjs/index.js +10 -2
  13. package/dist/cjs/introspect.js +81 -0
  14. package/dist/cjs/nested-write.js +70 -6
  15. package/dist/cjs/query/builder.js +538 -12
  16. package/dist/cjs/realtime.js +147 -0
  17. package/dist/cjs/schema-builder.js +86 -0
  18. package/dist/cjs/schema.js +10 -0
  19. package/dist/cjs/typed-sql.js +149 -0
  20. package/dist/cli/studio.js +5 -1
  21. package/dist/client.d.ts +120 -0
  22. package/dist/client.js +165 -1
  23. package/dist/errors.js +35 -5
  24. package/dist/generate.js +14 -3
  25. package/dist/index.d.ts +4 -2
  26. package/dist/index.js +5 -1
  27. package/dist/introspect.js +81 -0
  28. package/dist/nested-write.js +70 -6
  29. package/dist/query/builder.d.ts +104 -1
  30. package/dist/query/builder.js +539 -13
  31. package/dist/query/index.d.ts +1 -1
  32. package/dist/query/types.d.ts +126 -2
  33. package/dist/realtime.d.ts +71 -0
  34. package/dist/realtime.js +144 -0
  35. package/dist/schema-builder.d.ts +68 -1
  36. package/dist/schema-builder.js +85 -0
  37. package/dist/schema.d.ts +18 -1
  38. package/dist/schema.js +10 -0
  39. package/dist/typed-sql.d.ts +101 -0
  40. package/dist/typed-sql.js +145 -0
  41. package/package.json +17 -15
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ /**
3
+ * turbine-orm — LISTEN/NOTIFY realtime pub/sub
4
+ *
5
+ * Postgres LISTEN/NOTIFY is a first-class realtime primitive that neither
6
+ * Prisma nor Drizzle expose ergonomically. This module backs the thin
7
+ * `$listen` / `$notify` methods on TurbineClient.
8
+ *
9
+ * Design — **one dedicated connection per subscription**:
10
+ *
11
+ * Each `$listen(channel, handler)` acquires its OWN long-lived client from
12
+ * the pool, runs `LISTEN "chan"`, and keeps that connection checked out for
13
+ * the life of the subscription. This is the simplest correct model: each
14
+ * subscription owns its lifecycle, `unsubscribe()` cleanly UNLISTENs and
15
+ * releases exactly one connection, and there is no shared multiplexing
16
+ * state to reason about. The trade-off is one pool slot per active channel
17
+ * — for the handful of channels a typical app listens on, that's a fine
18
+ * price for clarity. (A future optimization could multiplex many channels
19
+ * over a single shared notification connection.)
20
+ *
21
+ * Serverless / HTTP-pool caveat:
22
+ *
23
+ * LISTEN requires a *persistent* TCP connection that can push asynchronous
24
+ * notification messages back to the client. Stateless HTTP drivers
25
+ * (Neon HTTP, Vercel Postgres over fetch) cannot hold such a connection, so
26
+ * `$listen` will surface a clear error rather than hang. `$notify` works
27
+ * everywhere — it's a single round-trip `SELECT pg_notify(...)`.
28
+ */
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.validateChannel = validateChannel;
31
+ exports.createSubscription = createSubscription;
32
+ const errors_js_1 = require("./errors.js");
33
+ // ---------------------------------------------------------------------------
34
+ // Identifier validation
35
+ // ---------------------------------------------------------------------------
36
+ /**
37
+ * Strict Postgres identifier: a letter or underscore followed by letters,
38
+ * digits, or underscores. Channel names CANNOT be parameterized in
39
+ * LISTEN/UNLISTEN (`LISTEN $1` is a syntax error), so the channel is the one
40
+ * place an identifier is interpolated into SQL — it MUST pass this regex AND
41
+ * go through `quoteIdent` before reaching the SQL string.
42
+ */
43
+ const CHANNEL_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/;
44
+ /** Postgres NAMEDATALEN caps identifiers at 63 bytes. */
45
+ const MAX_CHANNEL_LEN = 63;
46
+ /**
47
+ * Validate a LISTEN/NOTIFY channel name. Throws ValidationError on anything
48
+ * that isn't a plain, reasonable-length SQL identifier. This is enforced for
49
+ * BOTH `$listen` (where the channel is interpolated) and `$notify` (where the
50
+ * channel is a bound param) — defensive parity, and it catches user typos
51
+ * loudly.
52
+ */
53
+ function validateChannel(channel) {
54
+ if (typeof channel !== 'string' || channel.length === 0) {
55
+ throw new errors_js_1.ValidationError('[turbine] $listen/$notify channel must be a non-empty string');
56
+ }
57
+ if (channel.length > MAX_CHANNEL_LEN) {
58
+ throw new errors_js_1.ValidationError(`[turbine] $listen/$notify channel "${channel}" exceeds the ${MAX_CHANNEL_LEN}-character Postgres identifier limit`);
59
+ }
60
+ if (!CHANNEL_REGEX.test(channel)) {
61
+ throw new errors_js_1.ValidationError(`[turbine] Invalid $listen/$notify channel "${channel}" — must match /^[A-Za-z_][A-Za-z0-9_]*$/ ` +
62
+ '(letters, digits, underscores; cannot start with a digit)');
63
+ }
64
+ }
65
+ /**
66
+ * Acquire a dedicated connection, run `LISTEN "channel"`, and wire the handler.
67
+ *
68
+ * @param pool the pg-compatible pool to check a long-lived client out of
69
+ * @param channel channel name — MUST already be validated by the caller
70
+ * @param quotedChannel the channel run through quoteIdent (interpolated into SQL)
71
+ * @param handler called with each notification's payload
72
+ * @param onClosed invoked when the subscription releases, so the client can
73
+ * drop it from its active-subscription registry
74
+ */
75
+ async function createSubscription(pool, channel, quotedChannel, handler, onClosed) {
76
+ let client;
77
+ try {
78
+ client = (await pool.connect());
79
+ }
80
+ catch (err) {
81
+ throw (0, errors_js_1.wrapPgError)(err);
82
+ }
83
+ // Verify the checked-out client can actually receive async notifications.
84
+ // Stateless HTTP drivers return a client with no `.on` — LISTEN would hang
85
+ // forever waiting for messages that can never arrive, so fail loudly now and
86
+ // give the connection straight back.
87
+ if (typeof client.on !== 'function') {
88
+ client.release?.();
89
+ throw new errors_js_1.ConnectionError('[turbine] $listen requires a persistent connection that can push notifications. ' +
90
+ 'The configured pool returned a client with no event support (stateless HTTP drivers ' +
91
+ 'like Neon HTTP / Vercel Postgres cannot LISTEN). Use a TCP pg.Pool for LISTEN/NOTIFY.');
92
+ }
93
+ const onNotification = (msg) => {
94
+ // pg delivers ALL notifications for the connection to every listener; a
95
+ // dedicated connection only ever LISTENs on one channel, but guard anyway.
96
+ if (msg.channel === channel) {
97
+ handler(msg.payload ?? '');
98
+ }
99
+ };
100
+ try {
101
+ client.on('notification', onNotification);
102
+ await client.query(`LISTEN ${quotedChannel}`);
103
+ }
104
+ catch (err) {
105
+ client.removeListener?.('notification', onNotification);
106
+ client.release?.();
107
+ throw (0, errors_js_1.wrapPgError)(err);
108
+ }
109
+ let closed = false;
110
+ const sub = {
111
+ channel,
112
+ async unsubscribe() {
113
+ if (closed)
114
+ return;
115
+ closed = true;
116
+ try {
117
+ await client.query(`UNLISTEN ${quotedChannel}`);
118
+ }
119
+ catch (err) {
120
+ // Best-effort: the connection may already be dead. Still detach +
121
+ // release below so we don't leak the pool slot.
122
+ client.removeListener?.('notification', onNotification);
123
+ client.release?.();
124
+ onClosed(sub);
125
+ throw (0, errors_js_1.wrapPgError)(err);
126
+ }
127
+ client.removeListener?.('notification', onNotification);
128
+ client.release?.();
129
+ onClosed(sub);
130
+ },
131
+ _forceRelease() {
132
+ if (closed)
133
+ return;
134
+ closed = true;
135
+ client.removeListener?.('notification', onNotification);
136
+ // Destroy the connection (release(true)) rather than return it to the pool:
137
+ // we skip UNLISTEN here (the pool is being torn down), so a recycled
138
+ // connection would otherwise carry a stale LISTEN registration. Destroying
139
+ // it guarantees no pooled backend keeps receiving NOTIFY traffic. Matters
140
+ // most for external/serverless pools, where disconnect() is a no-op and the
141
+ // pool outlives this client.
142
+ client.release?.(true);
143
+ onClosed(sub);
144
+ },
145
+ };
146
+ return sub;
147
+ }
@@ -27,6 +27,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
27
27
  exports.camelToSnake = exports.column = exports.ColumnBuilder = void 0;
28
28
  exports.defineSchema = defineSchema;
29
29
  exports.table = table;
30
+ exports.applyManyToManyRelations = applyManyToManyRelations;
30
31
  /** Maps shorthand names to actual Postgres type strings */
31
32
  const TYPE_MAP = {
32
33
  serial: 'BIGSERIAL',
@@ -102,7 +103,17 @@ function defineSchema(input) {
102
103
  const raw = value;
103
104
  const columns = {};
104
105
  let pk;
106
+ let m2m;
105
107
  for (const [fieldName, def] of Object.entries(raw)) {
108
+ if (fieldName === 'manyToMany') {
109
+ if (def !== undefined) {
110
+ if (!Array.isArray(def)) {
111
+ throw new Error(`Table "${accessor}": "manyToMany" must be an array of relation declarations`);
112
+ }
113
+ m2m = def;
114
+ }
115
+ continue;
116
+ }
106
117
  if (fieldName === 'primaryKey') {
107
118
  // Top-level composite primary key declaration
108
119
  if (def !== undefined) {
@@ -142,6 +153,7 @@ function defineSchema(input) {
142
153
  accessor,
143
154
  columns,
144
155
  ...(pk && pk.length > 0 ? { primaryKey: pk } : {}),
156
+ ...(m2m && m2m.length > 0 ? { manyToMany: m2m } : {}),
145
157
  };
146
158
  }
147
159
  }
@@ -303,6 +315,80 @@ function table(columns) {
303
315
  return { name: '', accessor: '', columns: built };
304
316
  }
305
317
  // ---------------------------------------------------------------------------
318
+ // Explicit many-to-many: merge declared relations into introspected metadata
319
+ // ---------------------------------------------------------------------------
320
+ /**
321
+ * Merge the explicit `manyToMany` declarations from a code-first {@link SchemaDef}
322
+ * into an introspected {@link SchemaMetadata}, returning a new metadata object
323
+ * with the `manyToMany` {@link RelationDef}s added.
324
+ *
325
+ * This is the runtime bridge for the code-first m2m API: `defineSchema` only
326
+ * produces DDL, so after `introspect()`ing the live database you call this to
327
+ * attach the m2m relations you declared. It is PURELY ADDITIVE — existing
328
+ * belongsTo/hasMany/hasOne relations are preserved, and a declared relation is
329
+ * skipped (not overwritten) if its name already exists on the source table.
330
+ *
331
+ * @example
332
+ * ```ts
333
+ * const def = defineSchema({
334
+ * posts: { id: { type: 'serial', primaryKey: true },
335
+ * manyToMany: [{ name: 'tags', target: 'tags', through: 'postsTags',
336
+ * sourceKey: 'postId', targetKey: 'tagId' }] },
337
+ * tags: { id: { type: 'serial', primaryKey: true } },
338
+ * postsTags: { postId: { type: 'integer', references: 'posts.id' },
339
+ * tagId: { type: 'integer', references: 'tags.id' },
340
+ * primaryKey: ['postId', 'tagId'] },
341
+ * });
342
+ * let meta = await introspect({ connectionString });
343
+ * meta = applyManyToManyRelations(meta, def);
344
+ * ```
345
+ */
346
+ function applyManyToManyRelations(meta, def) {
347
+ // Map accessor (camelCase key) → DDL snake_case table name.
348
+ const accessorToTable = new Map();
349
+ for (const [accessor, t] of Object.entries(def.tables)) {
350
+ accessorToTable.set(accessor, t.name);
351
+ }
352
+ const resolveTable = (accessor) => accessorToTable.get(accessor) ?? camelToSnakeLocal(accessor);
353
+ const resolveCols = (k) => {
354
+ if (Array.isArray(k))
355
+ return k.map(camelToSnakeLocal);
356
+ return camelToSnakeLocal(k);
357
+ };
358
+ // Deep-ish clone of the tables we touch so the input metadata is not mutated.
359
+ const tables = { ...meta.tables };
360
+ for (const tableDef of Object.values(def.tables)) {
361
+ if (!tableDef.manyToMany || tableDef.manyToMany.length === 0)
362
+ continue;
363
+ const sourceTable = tableDef.name;
364
+ const sourceMeta = tables[sourceTable];
365
+ if (!sourceMeta)
366
+ continue; // table not present in introspected metadata — skip
367
+ const relations = { ...sourceMeta.relations };
368
+ for (const m of tableDef.manyToMany) {
369
+ // Additive-only: never clobber an existing relation name.
370
+ if (relations[m.name])
371
+ continue;
372
+ const ref = m.references ?? 'id';
373
+ relations[m.name] = {
374
+ type: 'manyToMany',
375
+ name: m.name,
376
+ from: sourceTable,
377
+ to: resolveTable(m.target),
378
+ referenceKey: resolveCols(ref),
379
+ foreignKey: resolveCols(ref),
380
+ through: {
381
+ table: resolveTable(m.through),
382
+ sourceKey: resolveCols(m.sourceKey),
383
+ targetKey: resolveCols(m.targetKey),
384
+ },
385
+ };
386
+ }
387
+ tables[sourceTable] = { ...sourceMeta, relations };
388
+ }
389
+ return { ...meta, tables };
390
+ }
391
+ // ---------------------------------------------------------------------------
306
392
  // Helpers
307
393
  // ---------------------------------------------------------------------------
308
394
  var schema_js_1 = require("./schema.js");
@@ -76,6 +76,16 @@ const PG_TO_TS = {
76
76
  // TSVector
77
77
  tsvector: 'string',
78
78
  tsquery: 'string',
79
+ // pgvector — embeddings. Mapped to `number[]` for DX (the natural shape an app
80
+ // passes when inserting / comparing embeddings). NOTE: like `numeric` above,
81
+ // there is a runtime caveat — pg has no built-in parser for the `vector` type,
82
+ // so over the wire a fetched vector arrives as a string literal like
83
+ // '[1,2,3]' unless the app registers its own parser (e.g. via pgvector's
84
+ // `registerType`). Turbine never auto-registers one (no side-effecting type
85
+ // parsers outside the client constructor). The query-side helpers (KNN
86
+ // orderBy, distance WHERE) always bind the query vector as a `$n::vector`
87
+ // param, so writing/comparing is unaffected by the read-side caveat.
88
+ vector: 'number[]',
79
89
  };
80
90
  const DATE_TYPES = new Set(['timestamptz', 'timestamp', 'date']);
81
91
  const PG_TO_ARRAY = {
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+ /**
3
+ * turbine-orm — Typed raw SQL (Turbine's answer to Prisma's TypedSQL)
4
+ *
5
+ * `client.raw()` returns untyped rows. This module adds a *typed* escape hatch:
6
+ * a generic tagged template where the caller supplies the row shape, and the
7
+ * builder yields a typed result that can be awaited as an array of rows, or
8
+ * narrowed to a single row (`.one()`) or a single scalar value (`.scalar()`).
9
+ *
10
+ * Design goals & guarantees:
11
+ *
12
+ * 1. **Compile-time only types.** `T` is supplied by the caller and never
13
+ * validated at runtime — exactly like Prisma's TypedSQL and the existing
14
+ * `raw<T>()`. Postgres still returns whatever the SQL selects; the generic
15
+ * is a convenience for autocomplete and downstream type-checking.
16
+ *
17
+ * 2. **Mandatory parameterization.** Only the *static* string segments of the
18
+ * template literal ever reach the SQL text. Every interpolated `${value}`
19
+ * becomes a `$N` placeholder and is passed in the params array — it is
20
+ * impossible to string-concatenate a value into the query through this API.
21
+ * This is the whole point of the tagged-template shape: the literal segments
22
+ * are frozen by the compiler (`TemplateStringsArray`), and the only way to
23
+ * get a runtime value into the query is via `${...}`, which we bind.
24
+ *
25
+ * 3. **Rows are returned as-is (no snake→camel mapping).** This matches the
26
+ * existing `client.raw()` behavior: a typed raw query is a literal escape
27
+ * hatch, so the result columns are whatever your `SELECT` names them. Alias
28
+ * columns in SQL (`SELECT created_at AS "createdAt"`) if you want camelCase.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * // Awaited directly -> rows
33
+ * const rows = await db.sql<{ id: number; name: string }>`
34
+ * SELECT id, name FROM users WHERE org_id = ${orgId}
35
+ * `;
36
+ * // ^? { id: number; name: string }[]
37
+ *
38
+ * // .one() -> single row or null
39
+ * const user = await db.sql<{ id: number; name: string }>`
40
+ * SELECT id, name FROM users WHERE id = ${userId}
41
+ * `.one();
42
+ * // ^? { id: number; name: string } | null
43
+ *
44
+ * // .scalar() -> first column of first row, or null
45
+ * const total = await db.sql<{ count: number }>`
46
+ * SELECT COUNT(*)::int AS count FROM users WHERE org_id = ${orgId}
47
+ * `.scalar();
48
+ * // ^? number | null
49
+ * ```
50
+ */
51
+ Object.defineProperty(exports, "__esModule", { value: true });
52
+ exports.TypedSqlQuery = void 0;
53
+ exports.buildTypedSql = buildTypedSql;
54
+ const errors_js_1 = require("./errors.js");
55
+ /**
56
+ * Build a `(sql, params)` pair from a tagged-template invocation.
57
+ *
58
+ * Each interpolated value is replaced by a positional `$N` placeholder and
59
+ * pushed to the params array in order. The static string segments are the only
60
+ * thing concatenated into the SQL text. This is the single point that
61
+ * guarantees parameterization for the entire typed-SQL surface.
62
+ *
63
+ * Exported for unit testing the parameterization invariant without a database.
64
+ */
65
+ function buildTypedSql(strings, values) {
66
+ // The tagged-template API guarantees `strings.length === values.length + 1`.
67
+ // Guard the directly-callable (exported) surface so a mismatched call can't
68
+ // silently desync `$N` placeholders from params (pg would reject it, but fail
69
+ // loudly here instead).
70
+ if (strings.length !== values.length + 1) {
71
+ throw new errors_js_1.ValidationError(`[turbine] sql template segment/value count mismatch: ${strings.length} segments, ${values.length} values.`);
72
+ }
73
+ let sql = '';
74
+ for (let i = 0; i < strings.length; i++) {
75
+ sql += strings[i];
76
+ if (i < values.length) {
77
+ sql += `$${i + 1}`;
78
+ }
79
+ }
80
+ return { sql, params: values.slice() };
81
+ }
82
+ /**
83
+ * A pending typed raw SQL query. Implements the thenable contract, so it can be
84
+ * `await`ed directly to get `T[]`, or refined via `.one()` / `.scalar()` first.
85
+ *
86
+ * The query is executed lazily and exactly once per terminal call (`then`,
87
+ * `one`, `scalar`). Each terminal method runs the query independently — this is
88
+ * an escape hatch, not a cached query object, so don't call two terminals on
89
+ * the same builder expecting a single round-trip; build a fresh template each
90
+ * time (the common pattern is `await db.sql\`...\`` inline).
91
+ */
92
+ class TypedSqlQuery {
93
+ pool;
94
+ sql;
95
+ params;
96
+ logging;
97
+ constructor(pool, sql, params, logging) {
98
+ this.pool = pool;
99
+ this.sql = sql;
100
+ this.params = params;
101
+ this.logging = logging;
102
+ }
103
+ /** Execute and return all rows. Internal; powers `then`, `one`, and `scalar`. */
104
+ async run() {
105
+ if (this.logging) {
106
+ console.log(`[turbine] Typed SQL: ${this.sql.trim().substring(0, 120)}...`);
107
+ }
108
+ try {
109
+ const result = await this.pool.query(this.sql, this.params);
110
+ return result.rows;
111
+ }
112
+ catch (err) {
113
+ throw (0, errors_js_1.wrapPgError)(err);
114
+ }
115
+ }
116
+ /**
117
+ * PromiseLike implementation: `await db.sql<T>\`...\`` resolves to `T[]`.
118
+ */
119
+ // biome-ignore lint/suspicious/noThenProperty: intentional thenable — this IS the PromiseLike contract that makes `await db.sql\`...\`` resolve to rows
120
+ then(onfulfilled, onrejected) {
121
+ return this.run().then(onfulfilled, onrejected);
122
+ }
123
+ /**
124
+ * Execute and return the first row, or `null` if the query returns no rows.
125
+ * Use for queries you expect to match at most one row.
126
+ */
127
+ async one() {
128
+ const rows = await this.run();
129
+ return rows.length > 0 ? rows[0] : null;
130
+ }
131
+ /**
132
+ * Execute and return the first column of the first row, or `null` if there
133
+ * are no rows. Useful for `SELECT COUNT(*)`, `SELECT EXISTS(...)`, etc.
134
+ *
135
+ * The generic `V` defaults to the value type of `T`'s first property, but you
136
+ * can override it: `db.sql<{ count: number }>\`...\`.scalar<number>()`.
137
+ */
138
+ async scalar() {
139
+ const rows = await this.run();
140
+ if (rows.length === 0)
141
+ return null;
142
+ const first = rows[0];
143
+ const keys = Object.keys(first);
144
+ if (keys.length === 0)
145
+ return null;
146
+ return first[keys[0]];
147
+ }
148
+ }
149
+ exports.TypedSqlQuery = TypedSqlQuery;
@@ -60,7 +60,11 @@ export async function startStudio(options) {
60
60
  const authToken = randomBytes(24).toString('hex');
61
61
  const stateDir = pathResolve(options.stateDir ?? '.turbine');
62
62
  const statementTimeout = options.adapter?.statementTimeout?.(30) ?? {
63
- sql: `SET LOCAL statement_timeout = $1`,
63
+ // Postgres rejects parameters in `SET LOCAL` (`SET LOCAL ... = $1` is a
64
+ // syntax error). `set_config(name, value, is_local=true)` is the
65
+ // parameterizable, transaction-local equivalent and works on every
66
+ // Postgres-compatible engine.
67
+ sql: `SELECT set_config('statement_timeout', $1, true)`,
64
68
  params: ['30s'],
65
69
  };
66
70
  const rateLimiter = new Map();
package/dist/client.d.ts CHANGED
@@ -27,7 +27,9 @@ 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
29
  import { type DeferredQuery, type QueryEventListener, QueryInterface, type QueryInterfaceOptions } from './query/index.js';
30
+ import { type NotificationHandler, type Subscription } from './realtime.js';
30
31
  import type { SchemaMetadata } from './schema.js';
32
+ import { TypedSqlQuery } from './typed-sql.js';
31
33
  export interface RetryOptions {
32
34
  maxAttempts?: number;
33
35
  baseDelay?: number;
@@ -172,6 +174,30 @@ export interface TransactionOptions {
172
174
  timeout?: number;
173
175
  /** Isolation level for the transaction */
174
176
  isolationLevel?: 'ReadUncommitted' | 'ReadCommitted' | 'RepeatableRead' | 'Serializable';
177
+ /**
178
+ * Transaction-local session GUCs to set after BEGIN. The canonical use case
179
+ * is multi-tenant Postgres row-level security (RLS): your policies filter on
180
+ * `current_setting('app.current_tenant')`, and you set that value here so
181
+ * every query inside the transaction sees it.
182
+ *
183
+ * Each entry is applied via `SELECT set_config($1, $2, true)` — `is_local=true`
184
+ * scopes the value to this transaction, so it auto-resets on COMMIT/ROLLBACK
185
+ * and never leaks onto the pooled connection. Both the name and value are
186
+ * bound parameters (never interpolated); the GUC name is additionally
187
+ * validated against a strict identifier regex.
188
+ *
189
+ * @example
190
+ * ```ts
191
+ * await db.$transaction(
192
+ * async (tx) => {
193
+ * // every query here sees current_setting('app.current_tenant') = '42'
194
+ * return tx.invoices.findMany();
195
+ * },
196
+ * { sessionContext: { 'app.current_tenant': '42', 'app.current_user': userId } },
197
+ * );
198
+ * ```
199
+ */
200
+ sessionContext?: Record<string, string | number | boolean>;
175
201
  }
176
202
  /**
177
203
  * A transaction-scoped client that provides the same table accessor API as TurbineClient.
@@ -225,6 +251,8 @@ export declare class TurbineClient {
225
251
  private readonly errorMessagesSafe;
226
252
  /** True when Turbine created the pool and is responsible for tearing it down */
227
253
  private readonly ownsPool;
254
+ /** Active LISTEN subscriptions — torn down on disconnect() so it never hangs */
255
+ private readonly activeSubscriptions;
228
256
  constructor(config: TurbineConfig | undefined, schema: SchemaMetadata);
229
257
  /**
230
258
  * Register a middleware function that runs before/after every query.
@@ -298,6 +326,37 @@ export declare class TurbineClient {
298
326
  * ```
299
327
  */
300
328
  raw<T extends Record<string, unknown> = Record<string, unknown>>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T[]>;
329
+ /**
330
+ * Execute a **typed** raw SQL query — Turbine's answer to Prisma's TypedSQL.
331
+ *
332
+ * Like {@link raw}, every interpolated `${value}` becomes a `$N` parameter
333
+ * (never string-concatenated), so it is injection-safe by construction. The
334
+ * difference is the caller-supplied row type and the chainable result: the
335
+ * returned {@link TypedSqlQuery} can be `await`ed directly for `T[]`, or
336
+ * refined with `.one()` (→ `T | null`) or `.scalar<V>()` (→ `V | null`).
337
+ *
338
+ * Rows are returned as-is — no snake→camel mapping (matching `raw()`). Alias
339
+ * columns in SQL if you want camelCase keys.
340
+ *
341
+ * @example
342
+ * ```ts
343
+ * // rows
344
+ * const rows = await db.sql<{ id: number; name: string }>`
345
+ * SELECT id, name FROM users WHERE org_id = ${orgId}
346
+ * `;
347
+ *
348
+ * // single row or null
349
+ * const user = await db.sql<{ id: number; name: string }>`
350
+ * SELECT id, name FROM users WHERE id = ${userId}
351
+ * `.one();
352
+ *
353
+ * // scalar
354
+ * const total = await db.sql<{ count: number }>`
355
+ * SELECT COUNT(*)::int AS count FROM users
356
+ * `.scalar();
357
+ * ```
358
+ */
359
+ sql<T extends Record<string, unknown> = Record<string, unknown>>(strings: TemplateStringsArray, ...values: unknown[]): TypedSqlQuery<T>;
301
360
  /**
302
361
  * Execute a function within a database transaction (raw pg.PoolClient).
303
362
  * For the typed API, use `$transaction()` instead.
@@ -330,6 +389,67 @@ export declare class TurbineClient {
330
389
  * ```
331
390
  */
332
391
  $transaction<R>(fn: (tx: TransactionClient) => Promise<R>, options?: TransactionOptions): Promise<R>;
392
+ /**
393
+ * Convenience wrapper around `$transaction` for the multi-tenant / RLS case:
394
+ * runs `fn` inside a transaction with the given session GUCs applied via
395
+ * `set_config(..., is_local=true)`. Equivalent to
396
+ * `$transaction(fn, { sessionContext: context })`.
397
+ *
398
+ * @example
399
+ * ```ts
400
+ * const invoices = await db.$withSession(
401
+ * { 'app.current_tenant': tenantId },
402
+ * (tx) => tx.invoices.findMany(),
403
+ * );
404
+ * ```
405
+ */
406
+ $withSession<R>(context: Record<string, string | number | boolean>, fn: (tx: TransactionClient) => Promise<R>): Promise<R>;
407
+ /**
408
+ * Subscribe to a Postgres NOTIFY channel. The handler fires with each
409
+ * notification's payload string (the empty string when a payload-less
410
+ * NOTIFY is sent) for as long as the subscription is active.
411
+ *
412
+ * Each `$listen` checks out its OWN dedicated long-lived connection from the
413
+ * pool and runs `LISTEN "channel"` on it; `subscription.unsubscribe()`
414
+ * UNLISTENs, detaches the handler, and releases that connection. Active
415
+ * subscriptions are tracked and force-released on `disconnect()` so shutdown
416
+ * never hangs.
417
+ *
418
+ * The channel name CANNOT be a bound parameter (`LISTEN $1` is a syntax
419
+ * error), so it is validated against a strict identifier regex AND quoted via
420
+ * `quoteIdent` before interpolation — it is the only identifier this method
421
+ * places into SQL text.
422
+ *
423
+ * **Serverless caveat:** LISTEN needs a persistent connection that can push
424
+ * async notifications. Stateless HTTP drivers (Neon HTTP, Vercel Postgres)
425
+ * cannot do this — `$listen` throws a `ConnectionError` rather than hang.
426
+ * `$notify` works on every driver.
427
+ *
428
+ * @example
429
+ * ```ts
430
+ * const sub = await db.$listen('order_created', (payload) => {
431
+ * const order = JSON.parse(payload);
432
+ * console.log('new order', order.id);
433
+ * });
434
+ * // ...later
435
+ * await sub.unsubscribe();
436
+ * ```
437
+ */
438
+ $listen(channel: string, handler: NotificationHandler): Promise<Subscription>;
439
+ /**
440
+ * Send a Postgres NOTIFY on `channel` with an optional payload string.
441
+ *
442
+ * Issued as `SELECT pg_notify($1, $2)` — both the channel and payload are
443
+ * BOUND parameters (no quoting/injection concern). The channel is still
444
+ * validated against the identifier regex for parity with `$listen` and to
445
+ * catch typos loudly. Works on every driver, including serverless HTTP pools.
446
+ *
447
+ * @example
448
+ * ```ts
449
+ * await db.$notify('order_created', JSON.stringify({ id: 7 }));
450
+ * ```
451
+ */
452
+ $notify(channel: string, payload?: string): Promise<void>;
333
453
  /**
334
454
  * Execute an async function with automatic retry on retryable errors.
335
455
  *