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
|
@@ -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");
|
package/dist/cjs/schema.js
CHANGED
|
@@ -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;
|
package/dist/cli/studio.js
CHANGED
|
@@ -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
|
-
|
|
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
|
*
|