turbine-orm 0.5.0 → 0.7.1

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 (50) hide show
  1. package/README.md +292 -26
  2. package/dist/cjs/cli/config.js +5 -15
  3. package/dist/cjs/cli/index.js +311 -43
  4. package/dist/cjs/cli/loader.js +129 -0
  5. package/dist/cjs/cli/migrate.js +96 -47
  6. package/dist/cjs/cli/ui.js +5 -9
  7. package/dist/cjs/client.js +158 -49
  8. package/dist/cjs/errors.js +424 -0
  9. package/dist/cjs/generate.js +145 -14
  10. package/dist/cjs/index.js +43 -20
  11. package/dist/cjs/introspect.js +3 -5
  12. package/dist/cjs/pipeline.js +9 -2
  13. package/dist/cjs/query.js +544 -115
  14. package/dist/cjs/schema-builder.js +150 -30
  15. package/dist/cjs/schema-sql.js +241 -37
  16. package/dist/cjs/schema.js +5 -2
  17. package/dist/cjs/serverless.js +88 -176
  18. package/dist/cli/config.js +6 -16
  19. package/dist/cli/index.js +316 -48
  20. package/dist/cli/loader.d.ts +45 -0
  21. package/dist/cli/loader.js +91 -0
  22. package/dist/cli/migrate.d.ts +13 -2
  23. package/dist/cli/migrate.js +97 -48
  24. package/dist/cli/ui.d.ts +1 -1
  25. package/dist/cli/ui.js +5 -9
  26. package/dist/client.d.ts +92 -4
  27. package/dist/client.js +158 -49
  28. package/dist/errors.d.ts +225 -0
  29. package/dist/errors.js +405 -0
  30. package/dist/generate.d.ts +7 -1
  31. package/dist/generate.js +148 -18
  32. package/dist/index.d.ts +11 -9
  33. package/dist/index.js +16 -12
  34. package/dist/introspect.d.ts +1 -1
  35. package/dist/introspect.js +4 -6
  36. package/dist/pipeline.d.ts +1 -1
  37. package/dist/pipeline.js +9 -2
  38. package/dist/query.d.ts +374 -38
  39. package/dist/query.js +545 -116
  40. package/dist/schema-builder.d.ts +38 -5
  41. package/dist/schema-builder.js +150 -31
  42. package/dist/schema-sql.d.ts +7 -3
  43. package/dist/schema-sql.js +241 -37
  44. package/dist/schema.d.ts +1 -1
  45. package/dist/schema.js +5 -2
  46. package/dist/serverless.d.ts +92 -139
  47. package/dist/serverless.js +87 -173
  48. package/package.json +33 -16
  49. package/dist/types.d.ts +0 -93
  50. package/dist/types.js +0 -126
@@ -0,0 +1,91 @@
1
+ /**
2
+ * turbine-orm CLI — TypeScript loader registration
3
+ *
4
+ * The CLI loads user-supplied config and schema files via dynamic `import()`.
5
+ * Plain Node has no built-in `.ts` loader, so importing `turbine.config.ts`
6
+ * blows up with `ERR_UNKNOWN_FILE_EXTENSION` unless we register a TypeScript
7
+ * loader first.
8
+ *
9
+ * Strategy:
10
+ * 1. If the file we're about to import ends in `.ts` / `.mts` / `.cts`,
11
+ * probe whether `tsx/esm` is resolvable from the user's CWD.
12
+ * 2. If yes, call `module.register('tsx/esm', ...)` ONCE per process.
13
+ * 3. If no, surface an actionable error telling the user to install `tsx`.
14
+ *
15
+ * `tsx` is intentionally NOT a runtime dependency — many projects already
16
+ * have it, and adding a heavy dev tool to a 1-dependency ORM would be silly.
17
+ */
18
+ import { createRequire } from 'node:module';
19
+ import { pathToFileURL } from 'node:url';
20
+ /**
21
+ * Detect whether a config / schema file path needs the tsx ESM loader.
22
+ * Returns true for `.ts`, `.mts`, and `.cts` files; false for `.js`, `.mjs`,
23
+ * `.cjs`, `.json`, missing paths, or anything else.
24
+ */
25
+ export function needsTsLoader(filePath) {
26
+ if (!filePath)
27
+ return false;
28
+ return /\.(ts|mts|cts)$/i.test(filePath);
29
+ }
30
+ /**
31
+ * Probe whether `tsx/esm` is resolvable from the user's current working
32
+ * directory. Returns true if `tsx` is installed in the user's project.
33
+ *
34
+ * Accepts an injected `resolver` so unit tests don't need a real filesystem.
35
+ */
36
+ export function canResolveTsx(resolver) {
37
+ try {
38
+ if (resolver) {
39
+ resolver('tsx/esm');
40
+ return true;
41
+ }
42
+ // Probe relative to the user's CWD, not Turbine's install location.
43
+ // This way we honour whatever `tsx` version the user has pinned.
44
+ const userRequire = createRequire(`${process.cwd()}/`);
45
+ userRequire.resolve('tsx/esm');
46
+ return true;
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ let tsLoaderState = null;
53
+ /**
54
+ * Register the tsx ESM loader so subsequent dynamic imports of `.ts` files
55
+ * work. Safe to call multiple times — internal flag prevents double registration.
56
+ *
57
+ * Returns:
58
+ * - 'registered' loader was successfully registered this call
59
+ * - 'already' a loader was previously registered (idempotent)
60
+ * - 'unsupported' Node lacks `module.register()` (Node < 20.6)
61
+ * - 'missing' `tsx` is not installed in the user's project
62
+ */
63
+ export async function registerTsLoader() {
64
+ if (tsLoaderState === 'registered' || tsLoaderState === 'already') {
65
+ return 'already';
66
+ }
67
+ if (!canResolveTsx()) {
68
+ tsLoaderState = 'missing';
69
+ return 'missing';
70
+ }
71
+ try {
72
+ const mod = await import('node:module');
73
+ const register = mod.register;
74
+ if (typeof register !== 'function') {
75
+ tsLoaderState = 'unsupported';
76
+ return 'unsupported';
77
+ }
78
+ register('tsx/esm', pathToFileURL(`${process.cwd()}/`));
79
+ tsLoaderState = 'registered';
80
+ return 'registered';
81
+ }
82
+ catch {
83
+ tsLoaderState = 'missing';
84
+ return 'missing';
85
+ }
86
+ }
87
+ /** Reset the loader state — used by unit tests only. */
88
+ export function _resetTsLoaderStateForTests() {
89
+ tsLoaderState = null;
90
+ }
91
+ //# sourceMappingURL=loader.js.map
@@ -73,19 +73,30 @@ export declare function parseMigrationSQL(filePath: string): {
73
73
  };
74
74
  /**
75
75
  * Create a new migration file.
76
+ * If `autoContent` is provided, the UP/DOWN sections are pre-populated with the given SQL.
76
77
  */
77
- export declare function createMigration(migrationsDir: string, name: string): MigrationFile;
78
+ export declare function createMigration(migrationsDir: string, name: string, autoContent?: {
79
+ up: string;
80
+ down: string;
81
+ }): MigrationFile;
78
82
  /**
79
83
  * Apply all pending migrations (UP).
80
84
  *
81
85
  * Features:
82
86
  * - Idempotent: running twice is safe (already-applied migrations are skipped)
83
87
  * - Advisory lock: prevents concurrent migration runs
84
- * - Checksum validation: detects modified migration files
88
+ * - Checksum validation: detects modified migration files (BLOCKING — use
89
+ * `allowDrift: true` to bypass when intentionally rewriting history)
85
90
  * - Each migration runs in its own transaction
91
+ *
92
+ * Throws `MigrationError` if any applied migration has been modified or deleted
93
+ * on disk, listing the offending files. Pass `{ allowDrift: true }` to bypass
94
+ * this check (the CLI exposes this as `--allow-drift`).
86
95
  */
87
96
  export declare function migrateUp(connectionString: string, migrationsDir: string, options?: {
88
97
  step?: number;
98
+ allowDrift?: boolean /** @deprecated use allowDrift */;
99
+ force?: boolean;
89
100
  }): Promise<{
90
101
  applied: MigrationFile[];
91
102
  errors: Array<{
@@ -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
  }
@@ -225,37 +260,57 @@ async function validateChecksums(client, migrationsDir) {
225
260
  * Features:
226
261
  * - Idempotent: running twice is safe (already-applied migrations are skipped)
227
262
  * - Advisory lock: prevents concurrent migration runs
228
- * - Checksum validation: detects modified migration files
263
+ * - Checksum validation: detects modified migration files (BLOCKING — use
264
+ * `allowDrift: true` to bypass when intentionally rewriting history)
229
265
  * - Each migration runs in its own transaction
266
+ *
267
+ * Throws `MigrationError` if any applied migration has been modified or deleted
268
+ * on disk, listing the offending files. Pass `{ allowDrift: true }` to bypass
269
+ * this check (the CLI exposes this as `--allow-drift`).
230
270
  */
231
271
  export async function migrateUp(connectionString, migrationsDir, options) {
232
272
  const client = new pg.Client({ connectionString });
233
273
  await client.connect();
274
+ // Treat `force` as an alias for `allowDrift` for backwards compatibility.
275
+ const allowDrift = options?.allowDrift === true || options?.force === true;
234
276
  try {
235
277
  // Acquire advisory lock to prevent concurrent migrations
236
278
  const gotLock = await acquireLock(client);
237
279
  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
- };
280
+ throw new MigrationError('[turbine] Could not acquire migration lock — another migration is already running');
245
281
  }
246
282
  try {
247
283
  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
- };
284
+ // Validate checksums of already-applied migrations.
285
+ // Drift = an APPLIED migration's on-disk file has changed (or been deleted)
286
+ // since it was run. Either situation means the database state and the
287
+ // migration history no longer agree, so we BLOCK the run by default.
288
+ // Users can pass `allowDrift: true` (CLI: `--allow-drift`) to force past
289
+ // the block when they are intentionally rewriting history.
290
+ if (!allowDrift) {
291
+ const mismatches = await validateChecksums(client, migrationsDir);
292
+ if (mismatches.length > 0) {
293
+ const modified = mismatches.filter((m) => m.type === 'modified');
294
+ const missing = mismatches.filter((m) => m.type === 'missing');
295
+ const lines = [
296
+ '[turbine] Migration drift detected — refusing to apply pending migrations.',
297
+ '',
298
+ 'Applied migrations should be immutable. The following files no longer match their applied state:',
299
+ '',
300
+ ];
301
+ for (const m of modified) {
302
+ lines.push(` - ${m.name}.sql (modified on disk)`);
303
+ }
304
+ for (const m of missing) {
305
+ lines.push(` - ${m.name}.sql (deleted from disk)`);
306
+ }
307
+ lines.push('');
308
+ lines.push('Fix one of these:');
309
+ lines.push(' 1. Restore the file(s) to their original content, OR');
310
+ lines.push(' 2. Roll back the affected migrations with `npx turbine migrate down`, OR');
311
+ lines.push(' 3. Pass `--allow-drift` to bypass this check (advanced — make sure you know what you are doing).');
312
+ throw new MigrationError(lines.join('\n'));
313
+ }
259
314
  }
260
315
  const applied = await getAppliedMigrations(client);
261
316
  const appliedNames = new Set(applied.map((m) => m.name));
@@ -277,7 +332,7 @@ export async function migrateUp(connectionString, migrationsDir, options) {
277
332
  try {
278
333
  await client.query('BEGIN');
279
334
  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]);
335
+ await client.query(`INSERT INTO ${QUOTED_TRACKING_TABLE} (name, checksum) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING`, [file.name, hash]);
281
336
  await client.query('COMMIT');
282
337
  results.push(file);
283
338
  }
@@ -313,13 +368,7 @@ export async function migrateDown(connectionString, migrationsDir, options) {
313
368
  try {
314
369
  const gotLock = await acquireLock(client);
315
370
  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
- };
371
+ throw new MigrationError('[turbine] Could not acquire migration lock — another migration is already running');
323
372
  }
324
373
  try {
325
374
  await ensureTrackingTable(client);
@@ -337,7 +386,7 @@ export async function migrateDown(connectionString, migrationsDir, options) {
337
386
  const file = fileMap.get(migration.name);
338
387
  if (!file) {
339
388
  errors.push({
340
- file: { filename: migration.name + '.sql', path: '', name: migration.name, timestamp: '' },
389
+ file: { filename: `${migration.name}.sql`, path: '', name: migration.name, timestamp: '' },
341
390
  error: `Migration file not found for "${migration.name}"`,
342
391
  });
343
392
  continue;
@@ -350,7 +399,7 @@ export async function migrateDown(connectionString, migrationsDir, options) {
350
399
  try {
351
400
  await client.query('BEGIN');
352
401
  await client.query(down);
353
- await client.query(`DELETE FROM ${TRACKING_TABLE} WHERE name = $1`, [migration.name]);
402
+ await client.query(`DELETE FROM ${QUOTED_TRACKING_TABLE} WHERE name = $1`, [migration.name]);
354
403
  await client.query('COMMIT');
355
404
  results.push(file);
356
405
  }
package/dist/cli/ui.d.ts CHANGED
@@ -34,7 +34,7 @@ export declare const symbols: {
34
34
  readonly warning: "⚠" | "!";
35
35
  readonly dot: "." | "∙";
36
36
  readonly line: "─" | "-";
37
- readonly vertLine: "" | "|";
37
+ readonly vertLine: "|" | "";
38
38
  readonly topLeft: "╭" | "+";
39
39
  readonly topRight: "+" | "╮";
40
40
  readonly bottomLeft: "+" | "╰";
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,70 @@
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';
25
+ import { type ErrorMessageMode } from './errors.js';
26
26
  import { type PipelineResults } from './pipeline.js';
27
+ import { type DeferredQuery, QueryInterface, type QueryInterfaceOptions } from './query.js';
27
28
  import type { SchemaMetadata } from './schema.js';
29
+ /**
30
+ * Minimal pg-compatible query result.
31
+ * `pg.Pool`, `@neondatabase/serverless` Pool, `@vercel/postgres` Pool and
32
+ * any driver speaking the node-postgres API all satisfy this shape.
33
+ */
34
+ export interface PgCompatQueryResult<R = Record<string, unknown>> {
35
+ rows: R[];
36
+ rowCount: number | null;
37
+ fields?: Array<{
38
+ name: string;
39
+ dataTypeID: number;
40
+ }>;
41
+ }
42
+ /**
43
+ * Minimal pg-compatible client used by TurbineClient for transactions.
44
+ * `pg.PoolClient` satisfies this; so do Neon and Vercel's equivalents.
45
+ */
46
+ export interface PgCompatPoolClient {
47
+ query<R = Record<string, unknown>>(text: string, values?: unknown[]): Promise<PgCompatQueryResult<R>>;
48
+ release(err?: Error | boolean): void;
49
+ }
50
+ /**
51
+ * Minimal pg-compatible pool. Pass any driver that satisfies this interface
52
+ * via `TurbineConfig.pool` — lets Turbine run on Neon HTTP, Vercel Postgres,
53
+ * Cloudflare Hyperdrive, or any other serverless Postgres driver.
54
+ *
55
+ * @example
56
+ * ```ts
57
+ * import { Pool } from '@neondatabase/serverless';
58
+ * import { TurbineClient } from 'turbine-orm';
59
+ *
60
+ * const neonPool = new Pool({ connectionString: process.env.DATABASE_URL });
61
+ * const db = new TurbineClient({ pool: neonPool }, schema);
62
+ * ```
63
+ */
64
+ export interface PgCompatPool {
65
+ query<R = Record<string, unknown>>(text: string, values?: unknown[]): Promise<PgCompatQueryResult<R>>;
66
+ connect(): Promise<PgCompatPoolClient>;
67
+ end(): Promise<void>;
68
+ /** Optional — pools that expose stats (pg.Pool does; Neon HTTP does not) */
69
+ readonly totalCount?: number;
70
+ readonly idleCount?: number;
71
+ readonly waitingCount?: number;
72
+ /** Optional — pg.Pool supports 'error' event; HTTP drivers typically do not */
73
+ on?(event: 'error', listener: (err: Error) => void): this;
74
+ }
28
75
  export interface TurbineConfig {
76
+ /**
77
+ * An external pg-compatible pool. Use this to plug in serverless drivers
78
+ * like `@neondatabase/serverless`, `@vercel/postgres`, or any other pg-API
79
+ * compatible pool. When provided, all connection-string fields are ignored
80
+ * and Turbine will NOT create its own pg.Pool.
81
+ */
82
+ pool?: PgCompatPool;
29
83
  /** Postgres connection string (e.g. postgres://user:pass@host:5432/db) */
30
84
  connectionString?: string;
31
85
  /** Host (used if connectionString is not set) */
@@ -38,6 +92,13 @@ export interface TurbineConfig {
38
92
  user?: string;
39
93
  /** Password */
40
94
  password?: string;
95
+ /** SSL/TLS options for the connection (required for most cloud providers) */
96
+ ssl?: boolean | {
97
+ rejectUnauthorized?: boolean;
98
+ ca?: string;
99
+ key?: string;
100
+ cert?: string;
101
+ };
41
102
  /** Maximum number of connections in the pool (default: 10) */
42
103
  poolSize?: number;
43
104
  /** Idle timeout in ms before a connection is closed (default: 30000) */
@@ -50,6 +111,20 @@ export interface TurbineConfig {
50
111
  defaultLimit?: number;
51
112
  /** Log a warning when findMany() is called without a limit (default: false) */
52
113
  warnOnUnlimited?: boolean;
114
+ /**
115
+ * Controls how `NotFoundError` (and other where-aware errors) format their
116
+ * messages.
117
+ *
118
+ * - `'safe'` (default): the message includes only the keys of the where
119
+ * clause (e.g. `where: { id, email }`). Values are redacted to avoid
120
+ * leaking PII into error logs (Sentry, Datadog, etc.).
121
+ * - `'verbose'`: the message includes the full JSON-serialized where
122
+ * clause (e.g. `where: {"id":1,"email":"alice@x.com"}`).
123
+ *
124
+ * The full `where` object is always available as `err.where` for
125
+ * programmatic access regardless of mode.
126
+ */
127
+ errorMessages?: ErrorMessageMode;
53
128
  }
54
129
  /** Parameters passed to middleware functions */
55
130
  export interface MiddlewareParams {
@@ -101,6 +176,10 @@ export declare class TransactionClient {
101
176
  * Create a pool-like wrapper around the transaction client.
102
177
  * This allows QueryInterface to work with the transaction connection
103
178
  * without knowing it's in a transaction.
179
+ *
180
+ * pg driver errors thrown by queries are translated into typed Turbine
181
+ * errors via wrapPgError so transaction-scoped queries surface the same
182
+ * typed errors as pool-scoped queries.
104
183
  */
105
184
  private createTxPool;
106
185
  }
@@ -109,10 +188,13 @@ export declare class TurbineClient {
109
188
  readonly pool: pg.Pool;
110
189
  /** The schema metadata this client was built from */
111
190
  readonly schema: SchemaMetadata;
191
+ private static int8ParserRegistered;
112
192
  private readonly logging;
113
193
  private readonly tableCache;
114
194
  private readonly middlewares;
115
195
  private readonly queryOptions;
196
+ /** True when Turbine created the pool and is responsible for tearing it down */
197
+ private readonly ownsPool;
116
198
  constructor(config: TurbineConfig | undefined, schema: SchemaMetadata);
117
199
  /**
118
200
  * Register a middleware function that runs before/after every query.
@@ -209,11 +291,17 @@ export declare class TurbineClient {
209
291
  connect(): Promise<void>;
210
292
  /**
211
293
  * Gracefully shut down the connection pool.
294
+ *
295
+ * If Turbine was given an external pool via `TurbineConfig.pool`, this
296
+ * method is a no-op — the caller is responsible for the pool's lifecycle.
212
297
  */
213
298
  disconnect(): Promise<void>;
214
299
  /** Alias for disconnect() */
215
300
  end(): Promise<void>;
216
- /** Pool statistics for monitoring. */
301
+ /**
302
+ * Pool statistics for monitoring. Returns zeros for pools that don't
303
+ * expose connection counts (e.g., stateless HTTP drivers like Neon).
304
+ */
217
305
  get stats(): {
218
306
  totalCount: number;
219
307
  idleCount: number;