turbine-orm 0.5.0 → 0.7.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 (46) hide show
  1. package/README.md +194 -26
  2. package/dist/cjs/cli/config.js +5 -15
  3. package/dist/cjs/cli/index.js +240 -41
  4. package/dist/cjs/cli/migrate.js +71 -46
  5. package/dist/cjs/cli/ui.js +5 -9
  6. package/dist/cjs/client.js +109 -46
  7. package/dist/cjs/errors.js +293 -0
  8. package/dist/cjs/generate.js +33 -13
  9. package/dist/cjs/index.js +39 -20
  10. package/dist/cjs/introspect.js +3 -5
  11. package/dist/cjs/pipeline.js +9 -2
  12. package/dist/cjs/query.js +442 -109
  13. package/dist/cjs/schema-builder.js +93 -24
  14. package/dist/cjs/schema-sql.js +157 -19
  15. package/dist/cjs/schema.js +5 -2
  16. package/dist/cjs/serverless.js +87 -176
  17. package/dist/cli/config.js +6 -16
  18. package/dist/cli/index.js +245 -46
  19. package/dist/cli/migrate.d.ts +6 -1
  20. package/dist/cli/migrate.js +72 -47
  21. package/dist/cli/ui.js +5 -9
  22. package/dist/client.d.ts +77 -4
  23. package/dist/client.js +109 -46
  24. package/dist/errors.d.ts +138 -0
  25. package/dist/errors.js +278 -0
  26. package/dist/generate.d.ts +1 -1
  27. package/dist/generate.js +36 -16
  28. package/dist/index.d.ts +11 -9
  29. package/dist/index.js +16 -12
  30. package/dist/introspect.d.ts +1 -1
  31. package/dist/introspect.js +4 -6
  32. package/dist/pipeline.d.ts +1 -1
  33. package/dist/pipeline.js +9 -2
  34. package/dist/query.d.ts +257 -36
  35. package/dist/query.js +443 -110
  36. package/dist/schema-builder.d.ts +2 -2
  37. package/dist/schema-builder.js +93 -25
  38. package/dist/schema-sql.d.ts +7 -3
  39. package/dist/schema-sql.js +157 -19
  40. package/dist/schema.d.ts +1 -1
  41. package/dist/schema.js +5 -2
  42. package/dist/serverless.d.ts +91 -139
  43. package/dist/serverless.js +86 -173
  44. package/package.json +33 -16
  45. package/dist/types.d.ts +0 -93
  46. package/dist/types.js +0 -126
@@ -11,15 +11,19 @@
11
11
  * -- DOWN
12
12
  * DROP TABLE users;
13
13
  */
14
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
14
+ import { createHash } from 'node:crypto';
15
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
15
16
  import { join } from 'node:path';
16
17
  import pg from 'pg';
18
+ import { MigrationError } from '../errors.js';
19
+ import { quoteIdent } from '../query.js';
17
20
  // ---------------------------------------------------------------------------
18
21
  // Tracking table management
19
22
  // ---------------------------------------------------------------------------
20
23
  const TRACKING_TABLE = '_turbine_migrations';
24
+ const QUOTED_TRACKING_TABLE = quoteIdent(TRACKING_TABLE);
21
25
  const CREATE_TRACKING_TABLE = `
22
- CREATE TABLE IF NOT EXISTS ${TRACKING_TABLE} (
26
+ CREATE TABLE IF NOT EXISTS ${QUOTED_TRACKING_TABLE} (
23
27
  id SERIAL PRIMARY KEY,
24
28
  name TEXT NOT NULL UNIQUE,
25
29
  checksum TEXT NOT NULL,
@@ -31,7 +35,7 @@ async function ensureTrackingTable(client) {
31
35
  }
32
36
  async function getAppliedMigrations(client) {
33
37
  await ensureTrackingTable(client);
34
- const result = await client.query(`SELECT id, name, applied_at, checksum FROM ${TRACKING_TABLE} ORDER BY id ASC`);
38
+ const result = await client.query(`SELECT id, name, applied_at, checksum FROM ${QUOTED_TRACKING_TABLE} ORDER BY id ASC`);
35
39
  return result.rows;
36
40
  }
37
41
  // ---------------------------------------------------------------------------
@@ -139,30 +143,45 @@ export function parseMigrationSQL(filePath) {
139
143
  return parseMigrationContent(content);
140
144
  }
141
145
  /**
142
- * Simple checksum for a migration file (for drift detection).
146
+ * SHA-256 checksum for migration drift detection.
147
+ * Returns a hex-encoded hash of the file content.
143
148
  */
144
149
  function checksum(content) {
145
- let hash = 0;
146
- for (let i = 0; i < content.length; i++) {
147
- const chr = content.charCodeAt(i);
148
- hash = ((hash << 5) - hash + chr) | 0;
149
- }
150
- return Math.abs(hash).toString(36);
150
+ return createHash('sha256').update(content, 'utf-8').digest('hex');
151
+ }
152
+ /** Detect legacy djb2 checksums (short alphanumeric strings, pre-v0.6) */
153
+ function isLegacyChecksum(hash) {
154
+ return hash.length < 64;
151
155
  }
152
156
  // ---------------------------------------------------------------------------
153
157
  // Commands
154
158
  // ---------------------------------------------------------------------------
155
159
  /**
156
160
  * Create a new migration file.
161
+ * If `autoContent` is provided, the UP/DOWN sections are pre-populated with the given SQL.
157
162
  */
158
- export function createMigration(migrationsDir, name) {
163
+ export function createMigration(migrationsDir, name, autoContent) {
159
164
  mkdirSync(migrationsDir, { recursive: true });
160
165
  const now = new Date();
161
166
  const ts = formatTimestamp(now);
162
167
  const safeName = sanitizeName(name);
163
168
  const filename = `${ts}_${safeName}.sql`;
164
169
  const filePath = join(migrationsDir, filename);
165
- const template = `-- Migration: ${name}
170
+ let template;
171
+ if (autoContent) {
172
+ template = `-- Migration: ${name} (auto-generated from schema diff)
173
+ -- Created: ${now.toISOString()}
174
+ -- Review this file before running: npx turbine migrate up
175
+
176
+ -- UP
177
+ ${autoContent.up}
178
+
179
+ -- DOWN
180
+ ${autoContent.down}
181
+ `;
182
+ }
183
+ else {
184
+ template = `-- Migration: ${name}
166
185
  -- Created: ${now.toISOString()}
167
186
 
168
187
  -- UP
@@ -171,6 +190,7 @@ export function createMigration(migrationsDir, name) {
171
190
  -- DOWN
172
191
  -- Write your rollback SQL here
173
192
  `;
193
+ }
174
194
  writeFileSync(filePath, template, 'utf-8');
175
195
  return {
176
196
  filename,
@@ -185,17 +205,16 @@ export function createMigration(migrationsDir, name) {
185
205
  /** Fixed lock ID for Turbine migrations — prevents concurrent migrate runs */
186
206
  const MIGRATION_LOCK_ID = 8_347_291; // arbitrary but stable
187
207
  async function acquireLock(client) {
188
- const result = await client.query(`SELECT pg_try_advisory_lock($1)`, [MIGRATION_LOCK_ID]);
208
+ const result = await client.query(`SELECT pg_try_advisory_lock($1)`, [
209
+ MIGRATION_LOCK_ID,
210
+ ]);
189
211
  return result.rows[0]?.pg_try_advisory_lock ?? false;
190
212
  }
191
213
  async function releaseLock(client) {
192
214
  await client.query(`SELECT pg_advisory_unlock($1)`, [MIGRATION_LOCK_ID]);
193
215
  }
194
- // ---------------------------------------------------------------------------
195
- // Checksum validation
196
- // ---------------------------------------------------------------------------
197
216
  /**
198
- * Validate that applied migration files have not been modified since they were run.
217
+ * Validate that applied migration files have not been modified or deleted since they were run.
199
218
  * Returns an array of mismatched migrations (empty if all are clean).
200
219
  */
201
220
  async function validateChecksums(client, migrationsDir) {
@@ -205,15 +224,31 @@ async function validateChecksums(client, migrationsDir) {
205
224
  const mismatches = [];
206
225
  for (const migration of applied) {
207
226
  const file = fileMap.get(migration.name);
208
- if (!file)
209
- continue; // file deleted — not a checksum issue
227
+ if (!file) {
228
+ mismatches.push({
229
+ name: migration.name,
230
+ expected: migration.checksum,
231
+ actual: '',
232
+ type: 'missing',
233
+ });
234
+ continue;
235
+ }
210
236
  const content = readFileSync(file.path, 'utf-8');
211
237
  const currentHash = checksum(content);
212
238
  if (currentHash !== migration.checksum) {
239
+ // Auto-upgrade legacy djb2 checksums to SHA-256 without flagging as modified
240
+ if (isLegacyChecksum(migration.checksum)) {
241
+ await client.query(`UPDATE ${QUOTED_TRACKING_TABLE} SET checksum = $1 WHERE name = $2`, [
242
+ currentHash,
243
+ migration.name,
244
+ ]);
245
+ continue;
246
+ }
213
247
  mismatches.push({
214
248
  name: migration.name,
215
249
  expected: migration.checksum,
216
250
  actual: currentHash,
251
+ type: 'modified',
217
252
  });
218
253
  }
219
254
  }
@@ -235,27 +270,23 @@ export async function migrateUp(connectionString, migrationsDir, options) {
235
270
  // Acquire advisory lock to prevent concurrent migrations
236
271
  const gotLock = await acquireLock(client);
237
272
  if (!gotLock) {
238
- return {
239
- applied: [],
240
- errors: [{
241
- file: { filename: '', path: '', name: '', timestamp: '' },
242
- error: 'Could not acquire migration lock — another migration is already running',
243
- }],
244
- };
273
+ throw new MigrationError('[turbine] Could not acquire migration lock — another migration is already running');
245
274
  }
246
275
  try {
247
276
  await ensureTrackingTable(client);
248
- // Validate checksums of already-applied migrations
249
- const mismatches = await validateChecksums(client, migrationsDir);
250
- if (mismatches.length > 0) {
251
- const names = mismatches.map((m) => m.name).join(', ');
252
- return {
253
- applied: [],
254
- errors: [{
255
- file: { filename: '', path: '', name: '', timestamp: '' },
256
- error: `Checksum mismatch: migration file(s) modified after application: ${names}. This is dangerous — applied migrations should be immutable. Use --force to skip this check.`,
257
- }],
258
- };
277
+ // Validate checksums of already-applied migrations (skip with --force)
278
+ if (!options?.force) {
279
+ const mismatches = await validateChecksums(client, migrationsDir);
280
+ if (mismatches.length > 0) {
281
+ const modified = mismatches.filter((m) => m.type === 'modified');
282
+ const missing = mismatches.filter((m) => m.type === 'missing');
283
+ const parts = [];
284
+ if (modified.length > 0)
285
+ parts.push(`modified: ${modified.map((m) => m.name).join(', ')}`);
286
+ if (missing.length > 0)
287
+ parts.push(`deleted: ${missing.map((m) => m.name).join(', ')}`);
288
+ throw new MigrationError(`[turbine] Migration integrity check failed — ${parts.join('; ')}. Applied migrations should be immutable. Use --force to skip this check.`);
289
+ }
259
290
  }
260
291
  const applied = await getAppliedMigrations(client);
261
292
  const appliedNames = new Set(applied.map((m) => m.name));
@@ -277,7 +308,7 @@ export async function migrateUp(connectionString, migrationsDir, options) {
277
308
  try {
278
309
  await client.query('BEGIN');
279
310
  await client.query(up);
280
- await client.query(`INSERT INTO ${TRACKING_TABLE} (name, checksum) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING`, [file.name, hash]);
311
+ await client.query(`INSERT INTO ${QUOTED_TRACKING_TABLE} (name, checksum) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING`, [file.name, hash]);
281
312
  await client.query('COMMIT');
282
313
  results.push(file);
283
314
  }
@@ -313,13 +344,7 @@ export async function migrateDown(connectionString, migrationsDir, options) {
313
344
  try {
314
345
  const gotLock = await acquireLock(client);
315
346
  if (!gotLock) {
316
- return {
317
- rolledBack: [],
318
- errors: [{
319
- file: { filename: '', path: '', name: '', timestamp: '' },
320
- error: 'Could not acquire migration lock — another migration is already running',
321
- }],
322
- };
347
+ throw new MigrationError('[turbine] Could not acquire migration lock — another migration is already running');
323
348
  }
324
349
  try {
325
350
  await ensureTrackingTable(client);
@@ -337,7 +362,7 @@ export async function migrateDown(connectionString, migrationsDir, options) {
337
362
  const file = fileMap.get(migration.name);
338
363
  if (!file) {
339
364
  errors.push({
340
- file: { filename: migration.name + '.sql', path: '', name: migration.name, timestamp: '' },
365
+ file: { filename: `${migration.name}.sql`, path: '', name: migration.name, timestamp: '' },
341
366
  error: `Migration file not found for "${migration.name}"`,
342
367
  });
343
368
  continue;
@@ -350,7 +375,7 @@ export async function migrateDown(connectionString, migrationsDir, options) {
350
375
  try {
351
376
  await client.query('BEGIN');
352
377
  await client.query(down);
353
- await client.query(`DELETE FROM ${TRACKING_TABLE} WHERE name = $1`, [migration.name]);
378
+ await client.query(`DELETE FROM ${QUOTED_TRACKING_TABLE} WHERE name = $1`, [migration.name]);
354
379
  await client.query('COMMIT');
355
380
  results.push(file);
356
381
  }
package/dist/cli/ui.js CHANGED
@@ -7,9 +7,7 @@
7
7
  // ---------------------------------------------------------------------------
8
8
  // ANSI escape codes
9
9
  // ---------------------------------------------------------------------------
10
- const isColorSupported = process.env['NO_COLOR'] == null &&
11
- process.env['TERM'] !== 'dumb' &&
12
- (process.stdout.isTTY ?? false);
10
+ const isColorSupported = process.env.NO_COLOR == null && process.env.TERM !== 'dumb' && (process.stdout.isTTY ?? false);
13
11
  function code(open, close) {
14
12
  if (!isColorSupported)
15
13
  return (s) => s;
@@ -96,9 +94,7 @@ export function table(headers, rows) {
96
94
  return ` ${bold(h)}${' '.repeat(Math.max(0, w - stripAnsi(h).length))} `;
97
95
  })
98
96
  .join(dim(symbols.vertLine));
99
- const separator = colWidths
100
- .map((w) => symbols.line.repeat(w + 2))
101
- .join(dim(symbols.line));
97
+ const separator = colWidths.map((w) => symbols.line.repeat(w + 2)).join(dim(symbols.line));
102
98
  const bodyLines = rows.map((row) => row
103
99
  .map((cell, i) => {
104
100
  const w = colWidths[i];
@@ -177,7 +173,7 @@ export function info(msg) {
177
173
  console.log(` ${blue(symbols.info)} ${msg}`);
178
174
  }
179
175
  export function label(key, value) {
180
- console.log(` ${dim(key + ':')} ${value}`);
176
+ console.log(` ${dim(`${key}:`)} ${value}`);
181
177
  }
182
178
  export function newline() {
183
179
  console.log('');
@@ -191,7 +187,7 @@ export function divider() {
191
187
  // ---------------------------------------------------------------------------
192
188
  export function banner() {
193
189
  console.log('');
194
- console.log(` ${bold(cyan('turbine'))} ${dim('by')} ${bold('BataData')}`);
190
+ console.log(` ${bold(cyan('turbine-orm'))}`);
195
191
  console.log(` ${dim('TypeScript ORM with json_agg nested queries')}`);
196
192
  console.log('');
197
193
  }
@@ -208,7 +204,7 @@ export function elapsed(startMs) {
208
204
  // Strip ANSI codes (for width calculation)
209
205
  // ---------------------------------------------------------------------------
210
206
  export function stripAnsi(s) {
211
- // eslint-disable-next-line no-control-regex
207
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape codes require control characters
212
208
  return s.replace(/\x1b\[[0-9;]*m/g, '');
213
209
  }
214
210
  // ---------------------------------------------------------------------------
package/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @batadata/turbine — TurbineClient
2
+ * turbine-orm — TurbineClient
3
3
  *
4
4
  * The main entry point for the Turbine TypeScript SDK.
5
5
  * Manages connection pooling and provides typed table accessors.
@@ -16,16 +16,69 @@
16
16
  * const user = await db.users.findUnique({ where: { id: 1 } });
17
17
  *
18
18
  * // With base client (dynamic):
19
- * import { TurbineClient } from '@batadata/turbine';
19
+ * import { TurbineClient } from 'turbine-orm';
20
20
  * const db = new TurbineClient({ connectionString: '...' }, schema);
21
21
  * const users = db.table<User>('users');
22
22
  * ```
23
23
  */
24
24
  import pg from 'pg';
25
- import { QueryInterface, type DeferredQuery, type QueryInterfaceOptions } from './query.js';
26
25
  import { type PipelineResults } from './pipeline.js';
26
+ import { type DeferredQuery, QueryInterface, type QueryInterfaceOptions } from './query.js';
27
27
  import type { SchemaMetadata } from './schema.js';
28
+ /**
29
+ * Minimal pg-compatible query result.
30
+ * `pg.Pool`, `@neondatabase/serverless` Pool, `@vercel/postgres` Pool and
31
+ * any driver speaking the node-postgres API all satisfy this shape.
32
+ */
33
+ export interface PgCompatQueryResult<R = Record<string, unknown>> {
34
+ rows: R[];
35
+ rowCount: number | null;
36
+ fields?: Array<{
37
+ name: string;
38
+ dataTypeID: number;
39
+ }>;
40
+ }
41
+ /**
42
+ * Minimal pg-compatible client used by TurbineClient for transactions.
43
+ * `pg.PoolClient` satisfies this; so do Neon and Vercel's equivalents.
44
+ */
45
+ export interface PgCompatPoolClient {
46
+ query<R = Record<string, unknown>>(text: string, values?: unknown[]): Promise<PgCompatQueryResult<R>>;
47
+ release(err?: Error | boolean): void;
48
+ }
49
+ /**
50
+ * Minimal pg-compatible pool. Pass any driver that satisfies this interface
51
+ * via `TurbineConfig.pool` — lets Turbine run on Neon HTTP, Vercel Postgres,
52
+ * Cloudflare Hyperdrive, or any other serverless Postgres driver.
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * import { Pool } from '@neondatabase/serverless';
57
+ * import { TurbineClient } from 'turbine-orm';
58
+ *
59
+ * const neonPool = new Pool({ connectionString: process.env.DATABASE_URL });
60
+ * const db = new TurbineClient({ pool: neonPool }, schema);
61
+ * ```
62
+ */
63
+ export interface PgCompatPool {
64
+ query<R = Record<string, unknown>>(text: string, values?: unknown[]): Promise<PgCompatQueryResult<R>>;
65
+ connect(): Promise<PgCompatPoolClient>;
66
+ end(): Promise<void>;
67
+ /** Optional — pools that expose stats (pg.Pool does; Neon HTTP does not) */
68
+ readonly totalCount?: number;
69
+ readonly idleCount?: number;
70
+ readonly waitingCount?: number;
71
+ /** Optional — pg.Pool supports 'error' event; HTTP drivers typically do not */
72
+ on?(event: 'error', listener: (err: Error) => void): this;
73
+ }
28
74
  export interface TurbineConfig {
75
+ /**
76
+ * An external pg-compatible pool. Use this to plug in serverless drivers
77
+ * like `@neondatabase/serverless`, `@vercel/postgres`, or any other pg-API
78
+ * compatible pool. When provided, all connection-string fields are ignored
79
+ * and Turbine will NOT create its own pg.Pool.
80
+ */
81
+ pool?: PgCompatPool;
29
82
  /** Postgres connection string (e.g. postgres://user:pass@host:5432/db) */
30
83
  connectionString?: string;
31
84
  /** Host (used if connectionString is not set) */
@@ -38,6 +91,13 @@ export interface TurbineConfig {
38
91
  user?: string;
39
92
  /** Password */
40
93
  password?: string;
94
+ /** SSL/TLS options for the connection (required for most cloud providers) */
95
+ ssl?: boolean | {
96
+ rejectUnauthorized?: boolean;
97
+ ca?: string;
98
+ key?: string;
99
+ cert?: string;
100
+ };
41
101
  /** Maximum number of connections in the pool (default: 10) */
42
102
  poolSize?: number;
43
103
  /** Idle timeout in ms before a connection is closed (default: 30000) */
@@ -101,6 +161,10 @@ export declare class TransactionClient {
101
161
  * Create a pool-like wrapper around the transaction client.
102
162
  * This allows QueryInterface to work with the transaction connection
103
163
  * without knowing it's in a transaction.
164
+ *
165
+ * pg driver errors thrown by queries are translated into typed Turbine
166
+ * errors via wrapPgError so transaction-scoped queries surface the same
167
+ * typed errors as pool-scoped queries.
104
168
  */
105
169
  private createTxPool;
106
170
  }
@@ -109,10 +173,13 @@ export declare class TurbineClient {
109
173
  readonly pool: pg.Pool;
110
174
  /** The schema metadata this client was built from */
111
175
  readonly schema: SchemaMetadata;
176
+ private static int8ParserRegistered;
112
177
  private readonly logging;
113
178
  private readonly tableCache;
114
179
  private readonly middlewares;
115
180
  private readonly queryOptions;
181
+ /** True when Turbine created the pool and is responsible for tearing it down */
182
+ private readonly ownsPool;
116
183
  constructor(config: TurbineConfig | undefined, schema: SchemaMetadata);
117
184
  /**
118
185
  * Register a middleware function that runs before/after every query.
@@ -209,11 +276,17 @@ export declare class TurbineClient {
209
276
  connect(): Promise<void>;
210
277
  /**
211
278
  * Gracefully shut down the connection pool.
279
+ *
280
+ * If Turbine was given an external pool via `TurbineConfig.pool`, this
281
+ * method is a no-op — the caller is responsible for the pool's lifecycle.
212
282
  */
213
283
  disconnect(): Promise<void>;
214
284
  /** Alias for disconnect() */
215
285
  end(): Promise<void>;
216
- /** Pool statistics for monitoring. */
286
+ /**
287
+ * Pool statistics for monitoring. Returns zeros for pools that don't
288
+ * expose connection counts (e.g., stateless HTTP drivers like Neon).
289
+ */
217
290
  get stats(): {
218
291
  totalCount: number;
219
292
  idleCount: number;
package/dist/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @batadata/turbine — TurbineClient
2
+ * turbine-orm — TurbineClient
3
3
  *
4
4
  * The main entry point for the Turbine TypeScript SDK.
5
5
  * Manages connection pooling and provides typed table accessors.
@@ -16,28 +16,15 @@
16
16
  * const user = await db.users.findUnique({ where: { id: 1 } });
17
17
  *
18
18
  * // With base client (dynamic):
19
- * import { TurbineClient } from '@batadata/turbine';
19
+ * import { TurbineClient } from 'turbine-orm';
20
20
  * const db = new TurbineClient({ connectionString: '...' }, schema);
21
21
  * const users = db.table<User>('users');
22
22
  * ```
23
23
  */
24
24
  import pg from 'pg';
25
- import { QueryInterface } from './query.js';
25
+ import { TimeoutError, wrapPgError } from './errors.js';
26
26
  import { executePipeline } from './pipeline.js';
27
- /**
28
- * Parse int8 (bigint, OID 20) as JavaScript number instead of string.
29
- * Safe for values up to Number.MAX_SAFE_INTEGER (9,007,199,254,740,991).
30
- *
31
- * NOTE: For values exceeding Number.MAX_SAFE_INTEGER, the parser falls back
32
- * to returning the raw string to avoid precision loss. The generated TypeScript
33
- * type maps int8/bigint to `number`, which is correct for the vast majority of
34
- * use cases (IDs, counts, timestamps). If you store values > 2^53 - 1 in a
35
- * bigint column, the runtime return type will be `string` for those rows.
36
- */
37
- pg.types.setTypeParser(20, (val) => {
38
- const n = Number(val);
39
- return Number.isSafeInteger(n) ? n : val; // fall back to string for huge values
40
- });
27
+ import { QueryInterface } from './query.js';
41
28
  /** Maps isolation level names to SQL */
42
29
  const ISOLATION_LEVELS = {
43
30
  ReadUncommitted: 'READ UNCOMMITTED',
@@ -119,20 +106,36 @@ export class TransactionClient {
119
106
  sql += `$${i + 1}`;
120
107
  }
121
108
  });
122
- const result = await this.client.query(sql, values);
123
- return result.rows;
109
+ try {
110
+ const result = await this.client.query(sql, values);
111
+ return result.rows;
112
+ }
113
+ catch (err) {
114
+ throw wrapPgError(err);
115
+ }
124
116
  }
125
117
  /**
126
118
  * Create a pool-like wrapper around the transaction client.
127
119
  * This allows QueryInterface to work with the transaction connection
128
120
  * without knowing it's in a transaction.
121
+ *
122
+ * pg driver errors thrown by queries are translated into typed Turbine
123
+ * errors via wrapPgError so transaction-scoped queries surface the same
124
+ * typed errors as pool-scoped queries.
129
125
  */
130
126
  createTxPool() {
131
127
  const client = this.client;
132
128
  // Return a minimal pool-compatible object that routes queries
133
129
  // through the transaction client
134
130
  return {
135
- query: (text, values) => client.query(text, values),
131
+ query: async (text, values) => {
132
+ try {
133
+ return await client.query(text, values);
134
+ }
135
+ catch (err) {
136
+ throw wrapPgError(err);
137
+ }
138
+ },
136
139
  connect: () => Promise.resolve(client),
137
140
  };
138
141
  }
@@ -145,38 +148,81 @@ export class TurbineClient {
145
148
  pool;
146
149
  /** The schema metadata this client was built from */
147
150
  schema;
151
+ static int8ParserRegistered = false;
148
152
  logging;
149
153
  tableCache = new Map();
150
154
  middlewares = [];
151
155
  queryOptions;
156
+ /** True when Turbine created the pool and is responsible for tearing it down */
157
+ ownsPool = true;
152
158
  constructor(config = {}, schema) {
159
+ /**
160
+ * Parse int8 (bigint, OID 20) as JavaScript number instead of string.
161
+ * Safe for values up to Number.MAX_SAFE_INTEGER (9,007,199,254,740,991).
162
+ *
163
+ * NOTE: For values exceeding Number.MAX_SAFE_INTEGER, the parser falls back
164
+ * to returning the raw string to avoid precision loss. The generated TypeScript
165
+ * type maps int8/bigint to `number`, which is correct for the vast majority of
166
+ * use cases (IDs, counts, timestamps). If you store values > 2^53 - 1 in a
167
+ * bigint column, the runtime return type will be `string` for those rows.
168
+ *
169
+ * NOTE: We intentionally do NOT register a parser for numeric (OID 1700).
170
+ * Postgres numeric is arbitrary-precision, so the default pg driver behavior
171
+ * of returning a string is correct and matches the generated TypeScript type
172
+ * (numeric → string). Users who want number can cast explicitly in SQL.
173
+ */
174
+ // Only register the int8 parser when we own the pg driver. External
175
+ // pools (Neon HTTP, Vercel Postgres) may ship their own pg-types fork
176
+ // and rely on their own parser configuration — don't mutate global state
177
+ // we don't own.
178
+ if (!config.pool && !TurbineClient.int8ParserRegistered) {
179
+ pg.types.setTypeParser(20, (val) => {
180
+ const n = Number(val);
181
+ return Number.isSafeInteger(n) ? n : val;
182
+ });
183
+ TurbineClient.int8ParserRegistered = true;
184
+ }
153
185
  this.logging = config.logging ?? false;
154
186
  this.schema = schema;
155
187
  this.queryOptions = {
156
188
  defaultLimit: config.defaultLimit,
157
189
  warnOnUnlimited: config.warnOnUnlimited,
158
190
  };
159
- const poolConfig = {
160
- max: config.poolSize ?? 10,
161
- idleTimeoutMillis: config.idleTimeoutMs ?? 30_000,
162
- connectionTimeoutMillis: config.connectionTimeoutMs ?? 5_000,
163
- };
164
- if (config.connectionString) {
165
- poolConfig.connectionString = config.connectionString;
191
+ if (config.pool) {
192
+ // External pool — use directly. Turbine doesn't manage its lifecycle.
193
+ this.pool = config.pool;
194
+ this.ownsPool = false;
195
+ if (this.logging) {
196
+ console.log(`[turbine] Using external pool — ${Object.keys(schema.tables).length} tables`);
197
+ }
166
198
  }
167
199
  else {
168
- poolConfig.host = config.host ?? 'localhost';
169
- poolConfig.port = config.port ?? 5432;
170
- poolConfig.database = config.database ?? 'postgres';
171
- poolConfig.user = config.user ?? 'postgres';
172
- poolConfig.password = config.password;
173
- }
174
- this.pool = new pg.Pool(poolConfig);
175
- this.pool.on('error', (err) => {
176
- console.error('[turbine] Unexpected pool error:', err.message);
177
- });
178
- if (this.logging) {
179
- console.log(`[turbine] Pool created — max ${poolConfig.max} connections, ${Object.keys(schema.tables).length} tables`);
200
+ const poolConfig = {
201
+ max: config.poolSize ?? 10,
202
+ idleTimeoutMillis: config.idleTimeoutMs ?? 30_000,
203
+ connectionTimeoutMillis: config.connectionTimeoutMs ?? 5_000,
204
+ };
205
+ if (config.connectionString) {
206
+ poolConfig.connectionString = config.connectionString;
207
+ }
208
+ else {
209
+ poolConfig.host = config.host ?? 'localhost';
210
+ poolConfig.port = config.port ?? 5432;
211
+ poolConfig.database = config.database ?? 'postgres';
212
+ poolConfig.user = config.user ?? 'postgres';
213
+ poolConfig.password = config.password;
214
+ }
215
+ if (config.ssl !== undefined) {
216
+ poolConfig.ssl = config.ssl;
217
+ }
218
+ this.pool = new pg.Pool(poolConfig);
219
+ this.ownsPool = true;
220
+ this.pool.on('error', (err) => {
221
+ console.error('[turbine] Unexpected pool error:', err.message);
222
+ });
223
+ if (this.logging) {
224
+ console.log(`[turbine] Pool created — max ${poolConfig.max} connections, ${Object.keys(schema.tables).length} tables`);
225
+ }
180
226
  }
181
227
  // Auto-create typed table accessors for all tables in the schema
182
228
  for (const tableName of Object.keys(schema.tables)) {
@@ -283,8 +329,13 @@ export class TurbineClient {
283
329
  if (this.logging) {
284
330
  console.log(`[turbine] Raw SQL: ${sql.trim().substring(0, 120)}...`);
285
331
  }
286
- const result = await this.pool.query(sql, values);
287
- return result.rows;
332
+ try {
333
+ const result = await this.pool.query(sql, values);
334
+ return result.rows;
335
+ }
336
+ catch (err) {
337
+ throw wrapPgError(err);
338
+ }
288
339
  }
289
340
  // -------------------------------------------------------------------------
290
341
  // Transaction support (raw — legacy)
@@ -368,7 +419,7 @@ export class TurbineClient {
368
419
  let timer;
369
420
  const timeoutPromise = new Promise((_, reject) => {
370
421
  timer = setTimeout(() => {
371
- reject(new Error(`[turbine] Transaction timed out after ${timeout}ms`));
422
+ reject(new TimeoutError(timeout, 'Transaction'));
372
423
  }, timeout);
373
424
  });
374
425
  try {
@@ -419,8 +470,17 @@ export class TurbineClient {
419
470
  }
420
471
  /**
421
472
  * Gracefully shut down the connection pool.
473
+ *
474
+ * If Turbine was given an external pool via `TurbineConfig.pool`, this
475
+ * method is a no-op — the caller is responsible for the pool's lifecycle.
422
476
  */
423
477
  async disconnect() {
478
+ if (!this.ownsPool) {
479
+ if (this.logging) {
480
+ console.log('[turbine] disconnect() skipped — external pool is not owned by Turbine');
481
+ }
482
+ return;
483
+ }
424
484
  await this.pool.end();
425
485
  if (this.logging) {
426
486
  console.log('[turbine] Pool disconnected');
@@ -430,12 +490,15 @@ export class TurbineClient {
430
490
  async end() {
431
491
  return this.disconnect();
432
492
  }
433
- /** Pool statistics for monitoring. */
493
+ /**
494
+ * Pool statistics for monitoring. Returns zeros for pools that don't
495
+ * expose connection counts (e.g., stateless HTTP drivers like Neon).
496
+ */
434
497
  get stats() {
435
498
  return {
436
- totalCount: this.pool.totalCount,
437
- idleCount: this.pool.idleCount,
438
- waitingCount: this.pool.waitingCount,
499
+ totalCount: this.pool.totalCount ?? 0,
500
+ idleCount: this.pool.idleCount ?? 0,
501
+ waitingCount: this.pool.waitingCount ?? 0,
439
502
  };
440
503
  }
441
504
  }