turbine-orm 0.7.1 → 0.8.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.
@@ -0,0 +1,403 @@
1
+ "use strict";
2
+ /**
3
+ * turbine-orm — Real Postgres pipeline protocol implementation
4
+ *
5
+ * Uses the pg extended-query protocol wire methods (parse/bind/describe/execute/sync)
6
+ * exposed on pg.Client's Connection object to send multiple queries in a single
7
+ * TCP flush. This achieves true 1-RTT pipeline execution instead of the sequential
8
+ * await-per-query approach.
9
+ *
10
+ * The approach (listener-swap):
11
+ * 1. Detach the pg.Client's event listeners from the Connection
12
+ * 2. Attach our own state-machine listeners
13
+ * 3. Cork the TCP stream, push all protocol messages, uncork (one TCP write)
14
+ * 4. Drive a state machine over backend response events
15
+ * 5. Restore original listeners and release the client
16
+ *
17
+ * This is the same pattern used by pg-cursor and pg-query-stream, but extended
18
+ * to handle N queries in a single pipeline.
19
+ */
20
+ var __importDefault = (this && this.__importDefault) || function (mod) {
21
+ return (mod && mod.__esModule) ? mod : { "default": mod };
22
+ };
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.runPipelined = runPipelined;
25
+ exports.supportsExtendedPipeline = supportsExtendedPipeline;
26
+ const result_1 = __importDefault(require("pg/lib/result"));
27
+ const utils_1 = require("pg/lib/utils");
28
+ const errors_js_1 = require("./errors.js");
29
+ // ---------------------------------------------------------------------------
30
+ // Event names we intercept
31
+ // ---------------------------------------------------------------------------
32
+ /**
33
+ * All backend message event names that the pg Client listens for.
34
+ * We snapshot, detach, and restore listeners for these events.
35
+ */
36
+ const INTERCEPTED_EVENTS = [
37
+ 'readyForQuery',
38
+ 'rowDescription',
39
+ 'dataRow',
40
+ 'commandComplete',
41
+ 'parseComplete',
42
+ 'bindComplete',
43
+ 'errorMessage',
44
+ 'emptyQuery',
45
+ 'portalSuspended',
46
+ 'noData',
47
+ 'notice',
48
+ 'copyInResponse',
49
+ 'copyData',
50
+ ];
51
+ function snapshotListeners(emitter) {
52
+ const map = new Map();
53
+ for (const event of INTERCEPTED_EVENTS) {
54
+ const listeners = emitter.rawListeners(event);
55
+ if (listeners.length > 0) {
56
+ map.set(event, [...listeners]);
57
+ }
58
+ }
59
+ return map;
60
+ }
61
+ function detachListeners(emitter) {
62
+ for (const event of INTERCEPTED_EVENTS) {
63
+ emitter.removeAllListeners(event);
64
+ }
65
+ }
66
+ function restoreListeners(emitter, snapshot) {
67
+ for (const [event, listeners] of snapshot) {
68
+ for (const fn of listeners) {
69
+ emitter.on(event, fn);
70
+ }
71
+ }
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // Core pipeline execution
75
+ // ---------------------------------------------------------------------------
76
+ /**
77
+ * Execute multiple queries using the Postgres extended-query pipeline protocol.
78
+ *
79
+ * All protocol messages are buffered into a single TCP write via cork/uncork.
80
+ * The backend processes them in order and sends back results which our state
81
+ * machine collects.
82
+ *
83
+ * @param client - A pg PoolClient with an accessible Connection
84
+ * @param queries - Array of DeferredQuery descriptors
85
+ * @param options - Pipeline options (transactional, timeout)
86
+ * @returns Array of transformed results in the same order as queries
87
+ */
88
+ async function runPipelined(client, queries, options = {}) {
89
+ const { transactional = true, timeout } = options;
90
+ const connection = client.connection;
91
+ // Snapshot and detach the Client's listeners
92
+ const savedListeners = snapshotListeners(connection);
93
+ detachListeners(connection);
94
+ // Block the Client from processing any queued queries while we own the connection
95
+ const savedReadyForQuery = client.readyForQuery;
96
+ client.readyForQuery = false;
97
+ return new Promise((resolve, reject) => {
98
+ // -----------------------------------------------------------------------
99
+ // State machine
100
+ // -----------------------------------------------------------------------
101
+ /**
102
+ * Total expected commandComplete events:
103
+ * - Transactional: BEGIN + N queries + COMMIT = N + 2
104
+ * - Non-transactional: N queries
105
+ */
106
+ const totalCommands = transactional ? queries.length + 2 : queries.length;
107
+ /**
108
+ * Expected readyForQuery events:
109
+ * - Transactional: 1 (single Sync at end)
110
+ * - Non-transactional: N (one Sync per query)
111
+ */
112
+ const expectedRfq = transactional ? 1 : queries.length;
113
+ // Results array: one Result per query (not counting BEGIN/COMMIT)
114
+ const results = [];
115
+ for (let i = 0; i < queries.length; i++) {
116
+ results.push(new result_1.default(undefined, client._types));
117
+ }
118
+ // commandComplete counter — tracks position across all commands
119
+ let commandIndex = 0;
120
+ // readyForQuery counter
121
+ let rfqCount = 0;
122
+ // First error encountered
123
+ let pipelineError = null;
124
+ // For non-transactional mode: per-query error tracking
125
+ const queryErrors = new Array(queries.length).fill(null);
126
+ // Timeout handle
127
+ let timeoutHandle;
128
+ // Whether cleanup has already been performed
129
+ let cleaned = false;
130
+ /**
131
+ * Map commandComplete index to the corresponding results[] index.
132
+ * In transactional mode: index 0 = BEGIN, 1..N = queries, N+1 = COMMIT
133
+ * In non-transactional mode: index maps 1:1
134
+ */
135
+ function commandToQueryIndex(cmdIdx) {
136
+ if (transactional) {
137
+ if (cmdIdx === 0 || cmdIdx === totalCommands - 1)
138
+ return null;
139
+ return cmdIdx - 1;
140
+ }
141
+ return cmdIdx;
142
+ }
143
+ /** Get the current query-results index for row data */
144
+ function currentQueryIndex() {
145
+ return commandToQueryIndex(commandIndex);
146
+ }
147
+ // -----------------------------------------------------------------------
148
+ // Cleanup: restore listeners
149
+ // -----------------------------------------------------------------------
150
+ function cleanup() {
151
+ if (cleaned)
152
+ return;
153
+ cleaned = true;
154
+ if (timeoutHandle !== undefined) {
155
+ clearTimeout(timeoutHandle);
156
+ timeoutHandle = undefined;
157
+ }
158
+ // Remove our listeners
159
+ detachListeners(connection);
160
+ // Restore original listeners
161
+ restoreListeners(connection, savedListeners);
162
+ // Restore readyForQuery so the Client can process its queue
163
+ client.readyForQuery = savedReadyForQuery;
164
+ }
165
+ // -----------------------------------------------------------------------
166
+ // Finalize: called on final readyForQuery
167
+ // -----------------------------------------------------------------------
168
+ function finalize() {
169
+ cleanup();
170
+ if (transactional && pipelineError) {
171
+ reject(pipelineError);
172
+ return;
173
+ }
174
+ if (!transactional && pipelineError) {
175
+ // In non-transactional mode, attach partial results to the error
176
+ const partialResults = [];
177
+ for (let i = 0; i < queries.length; i++) {
178
+ const qErr = queryErrors[i];
179
+ if (qErr) {
180
+ partialResults.push({ status: 'error', error: qErr });
181
+ }
182
+ else {
183
+ try {
184
+ const q = queries[i];
185
+ partialResults.push({ status: 'ok', value: q.transform(results[i]) });
186
+ }
187
+ catch (transformErr) {
188
+ partialResults.push({ status: 'error', error: transformErr });
189
+ }
190
+ }
191
+ }
192
+ pipelineError.results = partialResults;
193
+ reject(pipelineError);
194
+ return;
195
+ }
196
+ // All succeeded — transform results
197
+ try {
198
+ const transformed = [];
199
+ for (let i = 0; i < queries.length; i++) {
200
+ const q = queries[i];
201
+ transformed.push(q.transform(results[i]));
202
+ }
203
+ resolve(transformed);
204
+ }
205
+ catch (err) {
206
+ reject(err);
207
+ }
208
+ }
209
+ // -----------------------------------------------------------------------
210
+ // Event handlers
211
+ // -----------------------------------------------------------------------
212
+ function onParseComplete() {
213
+ // No action needed — anonymous prepared statements
214
+ }
215
+ function onBindComplete() {
216
+ // No action needed
217
+ }
218
+ function onNoData() {
219
+ // DML without RETURNING — no RowDescription follows. Fine.
220
+ }
221
+ function onRowDescription(msg) {
222
+ const qIdx = currentQueryIndex();
223
+ if (qIdx !== null && qIdx >= 0 && qIdx < results.length) {
224
+ results[qIdx].addFields(msg.fields);
225
+ }
226
+ }
227
+ function onDataRow(msg) {
228
+ const qIdx = currentQueryIndex();
229
+ if (qIdx !== null && qIdx >= 0 && qIdx < results.length) {
230
+ const result = results[qIdx];
231
+ const row = result.parseRow(msg.fields);
232
+ result.addRow(row);
233
+ }
234
+ }
235
+ function onCommandComplete(msg) {
236
+ const qIdx = currentQueryIndex();
237
+ if (qIdx !== null && qIdx >= 0 && qIdx < results.length) {
238
+ results[qIdx].addCommandComplete(msg);
239
+ }
240
+ commandIndex++;
241
+ }
242
+ function onEmptyQuery() {
243
+ // Treat like a commandComplete with no data
244
+ commandIndex++;
245
+ }
246
+ function onErrorMessage(msg) {
247
+ const wrapped = (0, errors_js_1.wrapPgError)(msg);
248
+ const error = wrapped instanceof Error ? wrapped : new Error(String(wrapped));
249
+ if (transactional) {
250
+ // In transactional mode, the first error aborts everything.
251
+ // Postgres marks the transaction as aborted; subsequent commands
252
+ // until Sync all return errors which we absorb.
253
+ if (!pipelineError) {
254
+ const qIdx = currentQueryIndex();
255
+ if (qIdx !== null && qIdx >= 0 && qIdx < queries.length) {
256
+ error.failedIndex = qIdx;
257
+ error.failedTag = queries[qIdx].tag;
258
+ }
259
+ pipelineError = error;
260
+ }
261
+ }
262
+ else {
263
+ // Non-transactional: record per-query error
264
+ const qIdx = currentQueryIndex();
265
+ if (qIdx !== null && qIdx >= 0 && qIdx < queries.length) {
266
+ queryErrors[qIdx] = error;
267
+ if (!pipelineError) {
268
+ error.failedIndex = qIdx;
269
+ error.failedTag = queries[qIdx].tag;
270
+ pipelineError = error;
271
+ }
272
+ }
273
+ }
274
+ // Advance command index past the failed command
275
+ commandIndex++;
276
+ }
277
+ function onPortalSuspended() {
278
+ // We don't use row-limited portals
279
+ }
280
+ function onReadyForQuery() {
281
+ rfqCount++;
282
+ if (rfqCount >= expectedRfq) {
283
+ finalize();
284
+ }
285
+ }
286
+ // -----------------------------------------------------------------------
287
+ // Attach our listeners
288
+ // -----------------------------------------------------------------------
289
+ connection.on('parseComplete', onParseComplete);
290
+ connection.on('bindComplete', onBindComplete);
291
+ connection.on('noData', onNoData);
292
+ connection.on('rowDescription', onRowDescription);
293
+ connection.on('dataRow', onDataRow);
294
+ connection.on('commandComplete', onCommandComplete);
295
+ connection.on('emptyQuery', onEmptyQuery);
296
+ connection.on('errorMessage', onErrorMessage);
297
+ connection.on('portalSuspended', onPortalSuspended);
298
+ connection.on('readyForQuery', onReadyForQuery);
299
+ connection.on('notice', () => { });
300
+ connection.on('copyInResponse', () => { });
301
+ connection.on('copyData', () => { });
302
+ // -----------------------------------------------------------------------
303
+ // Timeout
304
+ // -----------------------------------------------------------------------
305
+ if (timeout && timeout > 0) {
306
+ timeoutHandle = setTimeout(() => {
307
+ pipelineError = new Error(`[turbine] Pipeline timed out after ${timeout}ms`);
308
+ if (connection.stream.destroy) {
309
+ connection.stream.destroy(pipelineError);
310
+ }
311
+ cleanup();
312
+ reject(pipelineError);
313
+ }, timeout);
314
+ }
315
+ // -----------------------------------------------------------------------
316
+ // Send protocol messages — all in one TCP flush
317
+ // -----------------------------------------------------------------------
318
+ try {
319
+ if (connection.stream.cork) {
320
+ connection.stream.cork();
321
+ }
322
+ if (transactional) {
323
+ // ---- Transactional mode ----
324
+ // BEGIN + N×(parse+bind+describe+execute) + COMMIT + sync
325
+ // One sync = one ReadyForQuery
326
+ // BEGIN
327
+ connection.parse({ text: 'BEGIN', name: '' });
328
+ connection.bind({ portal: '', statement: '', values: [], valueMapper: utils_1.prepareValue });
329
+ connection.execute({ portal: '', rows: 0 });
330
+ // Each query
331
+ for (const q of queries) {
332
+ connection.parse({ text: q.sql, name: '' });
333
+ connection.bind({
334
+ portal: '',
335
+ statement: '',
336
+ values: q.params,
337
+ valueMapper: utils_1.prepareValue,
338
+ });
339
+ connection.describe({ type: 'P', name: '' });
340
+ connection.execute({ portal: '', rows: 0 });
341
+ }
342
+ // COMMIT
343
+ connection.parse({ text: 'COMMIT', name: '' });
344
+ connection.bind({ portal: '', statement: '', values: [], valueMapper: utils_1.prepareValue });
345
+ connection.execute({ portal: '', rows: 0 });
346
+ // Single Sync
347
+ connection.sync();
348
+ }
349
+ else {
350
+ // ---- Non-transactional mode ----
351
+ // N×(parse+bind+describe+execute+sync)
352
+ // Each sync = one ReadyForQuery
353
+ // All messages still go in one cork/uncork (one TCP flush)
354
+ for (const q of queries) {
355
+ connection.parse({ text: q.sql, name: '' });
356
+ connection.bind({
357
+ portal: '',
358
+ statement: '',
359
+ values: q.params,
360
+ valueMapper: utils_1.prepareValue,
361
+ });
362
+ connection.describe({ type: 'P', name: '' });
363
+ connection.execute({ portal: '', rows: 0 });
364
+ connection.sync();
365
+ }
366
+ }
367
+ if (connection.stream.uncork) {
368
+ connection.stream.uncork();
369
+ }
370
+ }
371
+ catch (err) {
372
+ cleanup();
373
+ reject(err);
374
+ }
375
+ });
376
+ }
377
+ // ---------------------------------------------------------------------------
378
+ // Capability detection
379
+ // ---------------------------------------------------------------------------
380
+ /**
381
+ * Check whether a pool client supports the extended-query pipeline protocol.
382
+ *
383
+ * Returns true if the client has a Connection object with the required wire
384
+ * protocol methods (parse, bind, describe, execute, sync) and is an EventEmitter.
385
+ *
386
+ * Returns false for HTTP-based drivers (Neon HTTP, Vercel Postgres), mock pools,
387
+ * and any pool that doesn't expose pg internals.
388
+ */
389
+ function supportsExtendedPipeline(poolClient) {
390
+ if (!poolClient || typeof poolClient !== 'object')
391
+ return false;
392
+ const client = poolClient;
393
+ const conn = client.connection;
394
+ if (!conn || typeof conn !== 'object')
395
+ return false;
396
+ const c = conn;
397
+ return (typeof c.parse === 'function' &&
398
+ typeof c.bind === 'function' &&
399
+ typeof c.describe === 'function' &&
400
+ typeof c.execute === 'function' &&
401
+ typeof c.sync === 'function' &&
402
+ typeof c.on === 'function');
403
+ }
@@ -8,25 +8,74 @@
8
8
  * How it works:
9
9
  * 1. Each query method (findUnique, count, etc.) can produce a DeferredQuery descriptor
10
10
  * containing the SQL, params, and a transform function.
11
- * 2. pipeline() collects these descriptors, executes them in a single Postgres
12
- * pipeline/transaction, and maps each result through its transform.
11
+ * 2. pipeline() collects these descriptors, checks whether the underlying pool client
12
+ * supports the extended-query pipeline protocol, and either:
13
+ * (a) executes them via real Postgres pipeline protocol (one TCP flush), or
14
+ * (b) falls back to sequential execution on a single connection.
13
15
  *
14
- * In the production Turbine engine, this would go through the Rust proxy which uses
15
- * actual Postgres pipeline protocol (libpq PQpipelineEnter). For the TS SDK prototype,
16
- * we simulate it by running queries concurrently on a single connection or via
17
- * a multi-statement batch.
16
+ * Real pipeline mode uses `src/pipeline-submittable.ts` which drives the pg Connection's
17
+ * wire-protocol methods (parse/bind/describe/execute/sync) directly with listener-swap.
18
+ *
19
+ * Sequential fallback covers HTTP-based drivers (Neon HTTP, Vercel Postgres, Cloudflare
20
+ * Hyperdrive), mock pools in tests, and any pool that doesn't expose pg internals.
18
21
  */
19
22
  Object.defineProperty(exports, "__esModule", { value: true });
20
23
  exports.executePipeline = executePipeline;
24
+ exports.pipelineSupported = pipelineSupported;
21
25
  const errors_js_1 = require("./errors.js");
26
+ const pipeline_submittable_js_1 = require("./pipeline-submittable.js");
27
+ /**
28
+ * Execute queries sequentially on an already-acquired connection.
29
+ * This is the fallback path for clients that don't support the extended-query
30
+ * pipeline protocol (HTTP drivers, mocks, etc.).
31
+ *
32
+ * The caller is responsible for acquiring the client and releasing it after
33
+ * this function completes (in the finally block).
34
+ */
35
+ async function runSequential(client, queries, options = {}) {
36
+ const { transactional = true } = options;
37
+ try {
38
+ if (transactional) {
39
+ await client.query('BEGIN');
40
+ }
41
+ const results = [];
42
+ for (const q of queries) {
43
+ let raw;
44
+ try {
45
+ raw = await client.query(q.sql, q.params);
46
+ }
47
+ catch (err) {
48
+ throw (0, errors_js_1.wrapPgError)(err);
49
+ }
50
+ results.push(q.transform(raw));
51
+ }
52
+ if (transactional) {
53
+ await client.query('COMMIT');
54
+ }
55
+ return results;
56
+ }
57
+ catch (err) {
58
+ if (transactional) {
59
+ try {
60
+ await client.query('ROLLBACK');
61
+ }
62
+ catch {
63
+ // Best-effort rollback
64
+ }
65
+ }
66
+ throw err;
67
+ }
68
+ }
22
69
  // ---------------------------------------------------------------------------
23
- // Pipeline executor
70
+ // Pipeline executor (public)
24
71
  // ---------------------------------------------------------------------------
25
72
  /**
26
73
  * Execute multiple deferred queries in a single batch.
27
74
  *
28
- * Uses a single connection from the pool and runs all queries within
29
- * a transaction to guarantee consistency and minimize round-trips.
75
+ * On pg.Pool-backed connections with the standard TCP driver, this uses the
76
+ * real Postgres extended-query pipeline protocol for true 1-RTT execution.
77
+ * On HTTP-based drivers (Neon HTTP, Vercel Postgres, etc.) or mock pools,
78
+ * it falls back to sequential execution on a single connection.
30
79
  *
31
80
  * @example
32
81
  * ```ts
@@ -37,42 +86,46 @@ const errors_js_1 = require("./errors.js");
37
86
  * ]);
38
87
  * ```
39
88
  */
40
- async function executePipeline(pool, queries) {
89
+ async function executePipeline(pool, queries, options) {
41
90
  if (queries.length === 0) {
42
91
  return [];
43
92
  }
44
- // Acquire a single connection for the entire batch
93
+ // Acquire a single client — reused for both capability check and execution
45
94
  const client = await pool.connect();
46
95
  try {
47
- // Wrap in a transaction for consistency
48
- await client.query('BEGIN');
49
- // Execute all queries on the same connection — in sequence on a single
50
- // connection this avoids pool checkout overhead, and the Postgres server
51
- // processes them as a tight batch.
52
- //
53
- // Execute queries sequentially on the same connection to avoid the
54
- // "already executing a query" deprecation in pg@8. This is still faster
55
- // than separate pool checkouts because we skip N-1 acquire/release cycles.
56
- // Future: use actual Postgres pipeline protocol for true pipelining.
57
- const results = [];
58
- for (const q of queries) {
59
- let raw;
60
- try {
61
- raw = await client.query(q.sql, q.params);
62
- }
63
- catch (err) {
64
- throw (0, errors_js_1.wrapPgError)(err);
65
- }
66
- results.push(q.transform(raw));
96
+ if ((0, pipeline_submittable_js_1.supportsExtendedPipeline)(client)) {
97
+ // Real pipeline path — uses extended-query protocol wire methods
98
+ const pipelineOptions = {
99
+ transactional: options?.transactional ?? true,
100
+ timeout: options?.timeout,
101
+ };
102
+ const results = await (0, pipeline_submittable_js_1.runPipelined)(client, queries, pipelineOptions);
103
+ return results;
67
104
  }
68
- await client.query('COMMIT');
69
- return results;
70
- }
71
- catch (err) {
72
- await client.query('ROLLBACK');
73
- throw err;
105
+ // Sequential fallback — reuses the same client
106
+ return await runSequential(client, queries, options);
74
107
  }
75
108
  finally {
76
109
  client.release();
77
110
  }
78
111
  }
112
+ /**
113
+ * Check whether a pool supports the real pipeline protocol.
114
+ * Call this to determine at runtime whether pipelines will use the fast path
115
+ * or fall back to sequential execution.
116
+ *
117
+ * Note: This acquires and immediately releases a connection to inspect it.
118
+ */
119
+ async function pipelineSupported(pool) {
120
+ let client;
121
+ try {
122
+ client = await pool.connect();
123
+ return (0, pipeline_submittable_js_1.supportsExtendedPipeline)(client);
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ finally {
129
+ client?.release();
130
+ }
131
+ }