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.
- package/README.md +194 -26
- package/dist/cjs/cli/config.js +5 -15
- package/dist/cjs/cli/index.js +240 -41
- package/dist/cjs/cli/migrate.js +71 -46
- package/dist/cjs/cli/ui.js +5 -9
- package/dist/cjs/client.js +109 -46
- package/dist/cjs/errors.js +293 -0
- package/dist/cjs/generate.js +33 -13
- package/dist/cjs/index.js +39 -20
- package/dist/cjs/introspect.js +3 -5
- package/dist/cjs/pipeline.js +9 -2
- package/dist/cjs/query.js +442 -109
- package/dist/cjs/schema-builder.js +93 -24
- package/dist/cjs/schema-sql.js +157 -19
- package/dist/cjs/schema.js +5 -2
- package/dist/cjs/serverless.js +87 -176
- package/dist/cli/config.js +6 -16
- package/dist/cli/index.js +245 -46
- package/dist/cli/migrate.d.ts +6 -1
- package/dist/cli/migrate.js +72 -47
- package/dist/cli/ui.js +5 -9
- package/dist/client.d.ts +77 -4
- package/dist/client.js +109 -46
- package/dist/errors.d.ts +138 -0
- package/dist/errors.js +278 -0
- package/dist/generate.d.ts +1 -1
- package/dist/generate.js +36 -16
- package/dist/index.d.ts +11 -9
- package/dist/index.js +16 -12
- package/dist/introspect.d.ts +1 -1
- package/dist/introspect.js +4 -6
- package/dist/pipeline.d.ts +1 -1
- package/dist/pipeline.js +9 -2
- package/dist/query.d.ts +257 -36
- package/dist/query.js +443 -110
- package/dist/schema-builder.d.ts +2 -2
- package/dist/schema-builder.js +93 -25
- package/dist/schema-sql.d.ts +7 -3
- package/dist/schema-sql.js +157 -19
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +5 -2
- package/dist/serverless.d.ts +91 -139
- package/dist/serverless.js +86 -173
- package/package.json +33 -16
- package/dist/types.d.ts +0 -93
- package/dist/types.js +0 -126
package/dist/cli/migrate.js
CHANGED
|
@@ -11,15 +11,19 @@
|
|
|
11
11
|
* -- DOWN
|
|
12
12
|
* DROP TABLE users;
|
|
13
13
|
*/
|
|
14
|
-
import {
|
|
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 ${
|
|
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 ${
|
|
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
|
-
*
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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)`, [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
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 ${
|
|
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
|
|
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
|
|
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'))}
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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 '
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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 '
|
|
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 {
|
|
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
|
-
|
|
123
|
-
|
|
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) =>
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
}
|