turbine-orm 0.23.2 → 0.24.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 CHANGED
@@ -51,8 +51,9 @@ Performance is at parity with Prisma and Drizzle — the real reasons to choose
51
51
  ## Quick Start
52
52
 
53
53
  ```bash
54
- # 1. Install
54
+ # 1. Install (the CLI also needs tsx to load .ts config/schema files)
55
55
  npm install turbine-orm
56
+ npm install --save-dev tsx
56
57
 
57
58
  # 2. Initialize project
58
59
  npx turbine init --url postgres://user:pass@localhost:5432/mydb
@@ -61,6 +62,8 @@ npx turbine init --url postgres://user:pass@localhost:5432/mydb
61
62
  npx turbine generate
62
63
  ```
63
64
 
65
+ > **CLI prerequisites.** The `turbine` CLI loads your `turbine.config.ts` / `turbine/schema.ts` directly, so a fresh project needs (1) `tsx` installed — otherwise `.ts` config loading fails with *"Loading .ts config / schema files requires tsx to be installed"* — and (2) `"type": "module"` in `package.json`, since Turbine is ESM. Without it you'll hit `Error [ERR_REQUIRE_ESM]: Cannot require() ES Module`. `create-next-app` sets neither by default. See [USING-TURBINE-ORM.md §0](docs/USING-TURBINE-ORM.md) for details.
66
+
64
67
  Works with both ESM and CommonJS:
65
68
 
66
69
  ```typescript
@@ -39,6 +39,7 @@ var __importStar = (this && this.__importStar) || (function () {
39
39
  };
40
40
  })();
41
41
  Object.defineProperty(exports, "__esModule", { value: true });
42
+ exports.looksLikeSchemaFilePath = looksLikeSchemaFilePath;
42
43
  exports.loadConfig = loadConfig;
43
44
  exports.findConfigFile = findConfigFile;
44
45
  exports.resolveConfig = resolveConfig;
@@ -46,6 +47,19 @@ exports.configTemplate = configTemplate;
46
47
  const node_fs_1 = require("node:fs");
47
48
  const node_path_1 = require("node:path");
48
49
  const node_url_1 = require("node:url");
50
+ /**
51
+ * Heuristic: does a configured `schema` value actually look like a schema FILE
52
+ * path rather than a Postgres schema name? `schema` is the Postgres namespace
53
+ * to introspect (default `public`); the schema-builder file goes in `schemaFile`.
54
+ * A value containing a path separator or a JS/TS extension is almost certainly a
55
+ * mis-set `schemaFile` — introspecting `WHERE table_schema = './turbine/schema.ts'`
56
+ * silently matches zero tables. Used by `turbine generate` to fail loudly.
57
+ */
58
+ function looksLikeSchemaFilePath(schema) {
59
+ if (!schema)
60
+ return false;
61
+ return schema.includes('/') || schema.includes('\\') || /\.(ts|mts|cts|js|mjs|cjs|json)$/i.test(schema);
62
+ }
49
63
  // ---------------------------------------------------------------------------
50
64
  // Config file names, in priority order
51
65
  // ---------------------------------------------------------------------------
@@ -120,6 +120,9 @@ function parseArgs() {
120
120
  case '--allow-drift':
121
121
  result.allowDrift = true;
122
122
  break;
123
+ case '--allow-empty':
124
+ result.allowEmpty = true;
125
+ break;
123
126
  case '--force':
124
127
  case '-f':
125
128
  result.force = true;
@@ -242,10 +245,28 @@ async function loadSchemaFile(schemaFile) {
242
245
  (0, ui_js_1.newline)();
243
246
  console.log(` ${(0, ui_js_1.dim)('Hint: install')} ${(0, ui_js_1.cyan)('tsx')} ${(0, ui_js_1.dim)('to load .ts files:')} ${(0, ui_js_1.cyan)('npm install --save-dev tsx')}`);
244
247
  }
248
+ printCjsHintIfApplicable(err);
245
249
  }
246
250
  process.exit(1);
247
251
  }
248
252
  }
253
+ /**
254
+ * When a config/schema import blows up with the CommonJS-vs-ESM interop error
255
+ * (`Cannot require() ES Module` / `ERR_REQUIRE_ESM`), the root cause is almost
256
+ * always a project whose `package.json` lacks `"type": "module"`: tsx transpiles
257
+ * the `.ts` file to CJS and then can't `require()` Turbine's ESM build. Point the
258
+ * user at the one-line fix instead of leaving them with a raw Node stack trace.
259
+ */
260
+ function printCjsHintIfApplicable(err) {
261
+ const msg = err.message;
262
+ if (msg.includes('ERR_REQUIRE_ESM') ||
263
+ msg.includes('require() of ES Module') ||
264
+ msg.includes('Cannot require() ES Module')) {
265
+ (0, ui_js_1.newline)();
266
+ console.log(` ${(0, ui_js_1.dim)('Hint: add')} ${(0, ui_js_1.cyan)('"type": "module"')} ${(0, ui_js_1.dim)('to your')} ${(0, ui_js_1.cyan)('package.json')}${(0, ui_js_1.dim)('.')}`);
267
+ console.log(` ${(0, ui_js_1.dim)('Turbine is an ESM package; without it, Node/tsx tries to')} ${(0, ui_js_1.cyan)('require()')} ${(0, ui_js_1.dim)('it and fails.')}`);
268
+ }
269
+ }
249
270
  // ---------------------------------------------------------------------------
250
271
  // Command: init
251
272
  // ---------------------------------------------------------------------------
@@ -443,6 +464,24 @@ async function cmdGenerate(args, config) {
443
464
  (0, ui_js_1.banner)();
444
465
  const url = requireUrl(config);
445
466
  const startTime = performance.now();
467
+ // Guard: `schema` is the Postgres NAMESPACE to introspect (default `public`),
468
+ // NOT the path to your schema-builder file — that goes in `schemaFile`. If the
469
+ // configured `schema` looks like a file path, introspection would silently
470
+ // match zero tables and emit an empty client. Fail loudly instead.
471
+ if (!args.allowEmpty && (0, config_js_1.looksLikeSchemaFilePath)(config.schema)) {
472
+ (0, ui_js_1.error)(`The "schema" option looks like a file path: ${(0, ui_js_1.cyan)(config.schema)}`);
473
+ (0, ui_js_1.newline)();
474
+ console.log(` ${(0, ui_js_1.dim)('"schema" is the Postgres schema NAME to introspect')} ${(0, ui_js_1.dim)('(default:')} ${(0, ui_js_1.cyan)('public')}${(0, ui_js_1.dim)(').')}`);
475
+ console.log(` ${(0, ui_js_1.dim)('The path to your defineSchema() file belongs in')} ${(0, ui_js_1.cyan)('schemaFile')}${(0, ui_js_1.dim)('.')}`);
476
+ (0, ui_js_1.newline)();
477
+ console.log(` ${(0, ui_js_1.dim)('Fix your')} ${(0, ui_js_1.cyan)('turbine.config.ts')}${(0, ui_js_1.dim)(':')}`);
478
+ console.log(` ${(0, ui_js_1.green)('schema:')} ${(0, ui_js_1.cyan)("'public'")}${(0, ui_js_1.dim)(", // or omit — introspects the 'public' schema")}`);
479
+ console.log(` ${(0, ui_js_1.green)('schemaFile:')} ${(0, ui_js_1.cyan)(`'${config.schema}'`)}${(0, ui_js_1.dim)(', // your defineSchema() file (used by `turbine push`)')}`);
480
+ (0, ui_js_1.newline)();
481
+ console.log(` ${(0, ui_js_1.dim)('Re-run with')} ${(0, ui_js_1.cyan)('--allow-empty')} ${(0, ui_js_1.dim)('to introspect this literal schema name anyway.')}`);
482
+ (0, ui_js_1.newline)();
483
+ process.exit(1);
484
+ }
446
485
  (0, ui_js_1.label)('Database', (0, ui_js_1.redactUrl)(url));
447
486
  (0, ui_js_1.label)('Schema', config.schema);
448
487
  (0, ui_js_1.label)('Output', config.out);
@@ -459,6 +498,24 @@ async function cmdGenerate(args, config) {
459
498
  const totalColumns = Object.values(schema.tables).reduce((sum, t) => sum + t.columns.length, 0);
460
499
  const totalRelations = Object.values(schema.tables).reduce((sum, t) => sum + Object.keys(t.relations).length, 0);
461
500
  spinner.succeed(`Found ${(0, ui_js_1.bold)(String(tableNames.length))} tables, ${(0, ui_js_1.bold)(String(totalColumns))} columns, ${(0, ui_js_1.bold)(String(totalRelations))} relations`);
501
+ // Guard: zero tables means the generated client would be empty. That is almost
502
+ // always a misconfiguration (wrong `schema`, an include/exclude that filtered
503
+ // everything, or a database with no tables yet) rather than intent. Fail loudly
504
+ // instead of silently emitting an empty typed client.
505
+ if (tableNames.length === 0 && !args.allowEmpty) {
506
+ (0, ui_js_1.newline)();
507
+ (0, ui_js_1.error)(`Introspection matched 0 tables in schema ${(0, ui_js_1.cyan)(config.schema)} — refusing to generate an empty client.`);
508
+ (0, ui_js_1.newline)();
509
+ console.log(` ${(0, ui_js_1.dim)('Common causes:')}`);
510
+ console.log(` ${(0, ui_js_1.dim)('•')} ${(0, ui_js_1.cyan)('schema')} ${(0, ui_js_1.dim)('points at the wrong Postgres namespace')} ${(0, ui_js_1.dim)('(it is the schema NAME, default')} ${(0, ui_js_1.cyan)('public')}${(0, ui_js_1.dim)(').')}`);
511
+ console.log(` ${(0, ui_js_1.dim)('•')} ${(0, ui_js_1.dim)('You meant to set')} ${(0, ui_js_1.cyan)('schemaFile')} ${(0, ui_js_1.dim)('(your defineSchema() file), not')} ${(0, ui_js_1.cyan)('schema')}${(0, ui_js_1.dim)('.')}`);
512
+ console.log(` ${(0, ui_js_1.dim)('•')} ${(0, ui_js_1.cyan)('include')}/${(0, ui_js_1.cyan)('exclude')} ${(0, ui_js_1.dim)('filtered out every table.')}`);
513
+ console.log(` ${(0, ui_js_1.dim)('•')} ${(0, ui_js_1.dim)('The database has no tables yet — run')} ${(0, ui_js_1.cyan)('turbine push')} ${(0, ui_js_1.dim)('or a migration first.')}`);
514
+ (0, ui_js_1.newline)();
515
+ console.log(` ${(0, ui_js_1.dim)('If an empty client is genuinely what you want, re-run with')} ${(0, ui_js_1.cyan)('--allow-empty')}${(0, ui_js_1.dim)('.')}`);
516
+ (0, ui_js_1.newline)();
517
+ process.exit(1);
518
+ }
462
519
  // Print table summary
463
520
  if (args.verbose) {
464
521
  (0, ui_js_1.newline)();
@@ -1134,6 +1191,7 @@ function showGenerateHelp() {
1134
1191
  console.log(` ${(0, ui_js_1.cyan)('--schema, -s')} ${(0, ui_js_1.dim)('<name>')} Postgres schema ${(0, ui_js_1.dim)('(default: public)')}`);
1135
1192
  console.log(` ${(0, ui_js_1.cyan)('--include')} ${(0, ui_js_1.dim)('<tables>')} Comma-separated tables to include`);
1136
1193
  console.log(` ${(0, ui_js_1.cyan)('--exclude')} ${(0, ui_js_1.dim)('<tables>')} Comma-separated tables to exclude`);
1194
+ console.log(` ${(0, ui_js_1.cyan)('--allow-empty')} Generate even when introspection matches 0 tables`);
1137
1195
  (0, ui_js_1.newline)();
1138
1196
  }
1139
1197
  function showPushHelp() {
@@ -1342,6 +1400,8 @@ async function main() {
1342
1400
  catch (err) {
1343
1401
  if (args.command !== 'init') {
1344
1402
  (0, ui_js_1.warn)(`Could not load config: ${err instanceof Error ? err.message : String(err)}`);
1403
+ if (err instanceof Error)
1404
+ printCjsHintIfApplicable(err);
1345
1405
  }
1346
1406
  }
1347
1407
  const overrides = {
@@ -30,16 +30,28 @@ exports.table = table;
30
30
  exports.applyManyToManyRelations = applyManyToManyRelations;
31
31
  /** Maps shorthand names to actual Postgres type strings */
32
32
  const TYPE_MAP = {
33
- serial: 'BIGSERIAL',
33
+ // `serial` maps to SERIAL (int4). Its values fit in a JS `number` and pg
34
+ // returns them as numbers — so the generated `number` type is accurate.
35
+ // For 64-bit auto-increment keys use `bigserial` (int8), noting that values
36
+ // above Number.MAX_SAFE_INTEGER read back as string. (Changed in 0.24.0;
37
+ // `serial` previously emitted BIGSERIAL.)
38
+ serial: 'SERIAL',
39
+ bigserial: 'BIGSERIAL',
34
40
  bigint: 'BIGINT',
35
41
  integer: 'INTEGER',
36
42
  smallint: 'SMALLINT',
37
43
  text: 'TEXT',
38
44
  varchar: 'VARCHAR',
39
45
  boolean: 'BOOLEAN',
46
+ // `timestamp` is an honest alias for TIMESTAMPTZ (timezone-aware) — Turbine
47
+ // has always emitted TIMESTAMPTZ for it. `timestamptz` is the explicit spelling.
40
48
  timestamp: 'TIMESTAMPTZ',
49
+ timestamptz: 'TIMESTAMPTZ',
41
50
  date: 'DATE',
51
+ // `json` is an alias for JSONB (Turbine has always emitted JSONB). `jsonb`
52
+ // is the explicit spelling.
42
53
  json: 'JSONB',
54
+ jsonb: 'JSONB',
43
55
  uuid: 'UUID',
44
56
  real: 'REAL',
45
57
  double: 'DOUBLE PRECISION',
@@ -184,6 +196,11 @@ class ColumnBuilder {
184
196
  };
185
197
  }
186
198
  serial() {
199
+ // See TYPE_MAP: `serial` is SERIAL (int4) as of 0.24.0. Use bigserial() for 64-bit.
200
+ this._config.type = 'SERIAL';
201
+ return this;
202
+ }
203
+ bigserial() {
187
204
  this._config.type = 'BIGSERIAL';
188
205
  return this;
189
206
  }
@@ -216,6 +233,10 @@ class ColumnBuilder {
216
233
  this._config.type = 'TIMESTAMPTZ';
217
234
  return this;
218
235
  }
236
+ timestamptz() {
237
+ this._config.type = 'TIMESTAMPTZ';
238
+ return this;
239
+ }
219
240
  date() {
220
241
  this._config.type = 'DATE';
221
242
  return this;
@@ -224,6 +245,10 @@ class ColumnBuilder {
224
245
  this._config.type = 'JSONB';
225
246
  return this;
226
247
  }
248
+ jsonb() {
249
+ this._config.type = 'JSONB';
250
+ return this;
251
+ }
227
252
  uuid() {
228
253
  this._config.type = 'UUID';
229
254
  return this;
@@ -276,14 +301,17 @@ exports.ColumnBuilder = ColumnBuilder;
276
301
  /** Type guard: is `prop` a known nullary ColumnBuilder type method? */
277
302
  function isNullaryColumnType(prop) {
278
303
  return (prop === 'serial' ||
304
+ prop === 'bigserial' ||
279
305
  prop === 'bigint' ||
280
306
  prop === 'integer' ||
281
307
  prop === 'smallint' ||
282
308
  prop === 'text' ||
283
309
  prop === 'boolean' ||
284
310
  prop === 'timestamp' ||
311
+ prop === 'timestamptz' ||
285
312
  prop === 'date' ||
286
313
  prop === 'json' ||
314
+ prop === 'jsonb' ||
287
315
  prop === 'uuid' ||
288
316
  prop === 'real' ||
289
317
  prop === 'doublePrecision' ||
@@ -16,6 +16,15 @@ exports.schemaToSQLString = schemaToSQLString;
16
16
  const pg_1 = __importDefault(require("pg"));
17
17
  const dialect_js_1 = require("./dialect.js");
18
18
  const schema_js_1 = require("./schema.js");
19
+ /**
20
+ * Whether a resolved column type is an auto-increment pseudo-type (SERIAL /
21
+ * BIGSERIAL). These carry an implicit sequence default and NOT NULL, and their
22
+ * underlying integer width (int4 vs int8) is never auto-migrated by diff — a
23
+ * width change on a live PK is destructive and must be done by hand.
24
+ */
25
+ function isSerialType(type) {
26
+ return type === 'SERIAL' || type === 'BIGSERIAL';
27
+ }
19
28
  // ---------------------------------------------------------------------------
20
29
  // SQL Generation — SchemaDef → CREATE TABLE statements
21
30
  // ---------------------------------------------------------------------------
@@ -163,7 +172,7 @@ function generateColumnDef(fieldName, config, resolveRef, dialect = dialect_js_1
163
172
  // 2. Is a serial (BIGSERIAL implies NOT NULL), OR
164
173
  // 3. Has a primary key (PKs are NOT NULL)
165
174
  // A column is left nullable if .nullable() was called.
166
- const isSerial = config.type === 'BIGSERIAL';
175
+ const isSerial = isSerialType(config.type);
167
176
  const implicitNotNull = isSerial || config.isPrimaryKey;
168
177
  const notNull = config.isNotNull && !implicitNotNull;
169
178
  // DEFAULT
@@ -352,9 +361,13 @@ async function schemaDiff(schema, connectionString) {
352
361
  result.reverseStatements.unshift(reverseSql);
353
362
  continue;
354
363
  }
355
- // Check type mismatch
364
+ // Check type mismatch. Serial/bigserial columns are exempt: their
365
+ // underlying int4/int8 width must never be auto-altered on a live table
366
+ // (a downcast on a PK loses data / breaks the sequence). This also
367
+ // preserves back-compat for DBs whose `serial` columns were created as
368
+ // BIGSERIAL (int8) before 0.24.0 — `push` won't try to shrink them.
356
369
  const expectedUdt = schemaTypeToUdt(config);
357
- if (expectedUdt && dbCol.udtName !== expectedUdt) {
370
+ if (expectedUdt && !isSerialType(config.type) && dbCol.udtName !== expectedUdt) {
358
371
  const sqlType = config.type === 'VARCHAR' && config.maxLength ? `VARCHAR(${config.maxLength})` : config.type;
359
372
  const oldSqlType = udtToSqlType(dbCol.udtName, dbCol.maxLength);
360
373
  const sql = `ALTER TABLE ${dialect.quoteIdentifier(tableName)} ALTER COLUMN ${dialect.quoteIdentifier(snakeName)} TYPE ${sqlType} USING ${dialect.quoteIdentifier(snakeName)}::${sqlType};`;
@@ -364,7 +377,7 @@ async function schemaDiff(schema, connectionString) {
364
377
  result.reverseStatements.unshift(reverseSql);
365
378
  }
366
379
  // Check NOT NULL mismatch
367
- const shouldBeNotNull = config.isNotNull || config.isPrimaryKey || config.type === 'BIGSERIAL';
380
+ const shouldBeNotNull = config.isNotNull || config.isPrimaryKey || isSerialType(config.type);
368
381
  const isCurrentlyNullable = dbCol.isNullable;
369
382
  if (shouldBeNotNull && isCurrentlyNullable && !config.isNullable) {
370
383
  const sql = `ALTER TABLE ${dialect.quoteIdentifier(tableName)} ALTER COLUMN ${dialect.quoteIdentifier(snakeName)} SET NOT NULL;`;
@@ -381,7 +394,7 @@ async function schemaDiff(schema, connectionString) {
381
394
  result.reverseStatements.unshift(reverseSql);
382
395
  }
383
396
  // Check DEFAULT value mismatch
384
- const isSerial = config.type === 'BIGSERIAL';
397
+ const isSerial = isSerialType(config.type);
385
398
  if (!isSerial) {
386
399
  const schemaDefault = config.defaultValue ? normalizeDefault(config.defaultValue) : null;
387
400
  const dbDefault = dbCol.columnDefault;
@@ -460,6 +473,7 @@ async function schemaDiff(schema, connectionString) {
460
473
  */
461
474
  function schemaTypeToUdt(config) {
462
475
  const map = {
476
+ SERIAL: 'int4',
463
477
  BIGSERIAL: 'int8',
464
478
  BIGINT: 'int8',
465
479
  INTEGER: 'int4',
@@ -33,6 +33,20 @@ export interface TurbineCliConfig {
33
33
  */
34
34
  adapter?: import('../adapters/index.js').DatabaseAdapter;
35
35
  }
36
+ /**
37
+ * Alias for {@link TurbineCliConfig}. Some docs and examples import the config
38
+ * type as `TurbineConfig`; both names refer to the same shape.
39
+ */
40
+ export type TurbineConfig = TurbineCliConfig;
41
+ /**
42
+ * Heuristic: does a configured `schema` value actually look like a schema FILE
43
+ * path rather than a Postgres schema name? `schema` is the Postgres namespace
44
+ * to introspect (default `public`); the schema-builder file goes in `schemaFile`.
45
+ * A value containing a path separator or a JS/TS extension is almost certainly a
46
+ * mis-set `schemaFile` — introspecting `WHERE table_schema = './turbine/schema.ts'`
47
+ * silently matches zero tables. Used by `turbine generate` to fail loudly.
48
+ */
49
+ export declare function looksLikeSchemaFilePath(schema: string): boolean;
36
50
  /**
37
51
  * Attempt to load a turbine config file from the current directory.
38
52
  * Returns the config if found, or an empty object.
@@ -7,6 +7,19 @@
7
7
  import { existsSync } from 'node:fs';
8
8
  import { join, resolve } from 'node:path';
9
9
  import { pathToFileURL } from 'node:url';
10
+ /**
11
+ * Heuristic: does a configured `schema` value actually look like a schema FILE
12
+ * path rather than a Postgres schema name? `schema` is the Postgres namespace
13
+ * to introspect (default `public`); the schema-builder file goes in `schemaFile`.
14
+ * A value containing a path separator or a JS/TS extension is almost certainly a
15
+ * mis-set `schemaFile` — introspecting `WHERE table_schema = './turbine/schema.ts'`
16
+ * silently matches zero tables. Used by `turbine generate` to fail loudly.
17
+ */
18
+ export function looksLikeSchemaFilePath(schema) {
19
+ if (!schema)
20
+ return false;
21
+ return schema.includes('/') || schema.includes('\\') || /\.(ts|mts|cts|js|mjs|cjs|json)$/i.test(schema);
22
+ }
10
23
  // ---------------------------------------------------------------------------
11
24
  // Config file names, in priority order
12
25
  // ---------------------------------------------------------------------------
package/dist/cli/index.js CHANGED
@@ -26,7 +26,7 @@ import { pathToFileURL } from 'node:url';
26
26
  import { generate } from '../generate.js';
27
27
  import { introspect } from '../introspect.js';
28
28
  import { schemaDiff, schemaPush } from '../schema-sql.js';
29
- import { configTemplate, findConfigFile, loadConfig, resolveConfig } from './config.js';
29
+ import { configTemplate, findConfigFile, loadConfig, looksLikeSchemaFilePath, resolveConfig } from './config.js';
30
30
  import { canResolveTsx, getTsLoaderError, needsTsLoader, registerTsLoader } from './loader.js';
31
31
  import { createMigration, listMigrationFiles, migrateDown, migrateStatus, migrateUp } from './migrate.js';
32
32
  import { startObserve } from './observe.js';
@@ -85,6 +85,9 @@ function parseArgs() {
85
85
  case '--allow-drift':
86
86
  result.allowDrift = true;
87
87
  break;
88
+ case '--allow-empty':
89
+ result.allowEmpty = true;
90
+ break;
88
91
  case '--force':
89
92
  case '-f':
90
93
  result.force = true;
@@ -207,10 +210,28 @@ async function loadSchemaFile(schemaFile) {
207
210
  newline();
208
211
  console.log(` ${dim('Hint: install')} ${cyan('tsx')} ${dim('to load .ts files:')} ${cyan('npm install --save-dev tsx')}`);
209
212
  }
213
+ printCjsHintIfApplicable(err);
210
214
  }
211
215
  process.exit(1);
212
216
  }
213
217
  }
218
+ /**
219
+ * When a config/schema import blows up with the CommonJS-vs-ESM interop error
220
+ * (`Cannot require() ES Module` / `ERR_REQUIRE_ESM`), the root cause is almost
221
+ * always a project whose `package.json` lacks `"type": "module"`: tsx transpiles
222
+ * the `.ts` file to CJS and then can't `require()` Turbine's ESM build. Point the
223
+ * user at the one-line fix instead of leaving them with a raw Node stack trace.
224
+ */
225
+ function printCjsHintIfApplicable(err) {
226
+ const msg = err.message;
227
+ if (msg.includes('ERR_REQUIRE_ESM') ||
228
+ msg.includes('require() of ES Module') ||
229
+ msg.includes('Cannot require() ES Module')) {
230
+ newline();
231
+ console.log(` ${dim('Hint: add')} ${cyan('"type": "module"')} ${dim('to your')} ${cyan('package.json')}${dim('.')}`);
232
+ console.log(` ${dim('Turbine is an ESM package; without it, Node/tsx tries to')} ${cyan('require()')} ${dim('it and fails.')}`);
233
+ }
234
+ }
214
235
  // ---------------------------------------------------------------------------
215
236
  // Command: init
216
237
  // ---------------------------------------------------------------------------
@@ -408,6 +429,24 @@ async function cmdGenerate(args, config) {
408
429
  banner();
409
430
  const url = requireUrl(config);
410
431
  const startTime = performance.now();
432
+ // Guard: `schema` is the Postgres NAMESPACE to introspect (default `public`),
433
+ // NOT the path to your schema-builder file — that goes in `schemaFile`. If the
434
+ // configured `schema` looks like a file path, introspection would silently
435
+ // match zero tables and emit an empty client. Fail loudly instead.
436
+ if (!args.allowEmpty && looksLikeSchemaFilePath(config.schema)) {
437
+ error(`The "schema" option looks like a file path: ${cyan(config.schema)}`);
438
+ newline();
439
+ console.log(` ${dim('"schema" is the Postgres schema NAME to introspect')} ${dim('(default:')} ${cyan('public')}${dim(').')}`);
440
+ console.log(` ${dim('The path to your defineSchema() file belongs in')} ${cyan('schemaFile')}${dim('.')}`);
441
+ newline();
442
+ console.log(` ${dim('Fix your')} ${cyan('turbine.config.ts')}${dim(':')}`);
443
+ console.log(` ${green('schema:')} ${cyan("'public'")}${dim(", // or omit — introspects the 'public' schema")}`);
444
+ console.log(` ${green('schemaFile:')} ${cyan(`'${config.schema}'`)}${dim(', // your defineSchema() file (used by `turbine push`)')}`);
445
+ newline();
446
+ console.log(` ${dim('Re-run with')} ${cyan('--allow-empty')} ${dim('to introspect this literal schema name anyway.')}`);
447
+ newline();
448
+ process.exit(1);
449
+ }
411
450
  label('Database', redactUrl(url));
412
451
  label('Schema', config.schema);
413
452
  label('Output', config.out);
@@ -424,6 +463,24 @@ async function cmdGenerate(args, config) {
424
463
  const totalColumns = Object.values(schema.tables).reduce((sum, t) => sum + t.columns.length, 0);
425
464
  const totalRelations = Object.values(schema.tables).reduce((sum, t) => sum + Object.keys(t.relations).length, 0);
426
465
  spinner.succeed(`Found ${bold(String(tableNames.length))} tables, ${bold(String(totalColumns))} columns, ${bold(String(totalRelations))} relations`);
466
+ // Guard: zero tables means the generated client would be empty. That is almost
467
+ // always a misconfiguration (wrong `schema`, an include/exclude that filtered
468
+ // everything, or a database with no tables yet) rather than intent. Fail loudly
469
+ // instead of silently emitting an empty typed client.
470
+ if (tableNames.length === 0 && !args.allowEmpty) {
471
+ newline();
472
+ error(`Introspection matched 0 tables in schema ${cyan(config.schema)} — refusing to generate an empty client.`);
473
+ newline();
474
+ console.log(` ${dim('Common causes:')}`);
475
+ console.log(` ${dim('•')} ${cyan('schema')} ${dim('points at the wrong Postgres namespace')} ${dim('(it is the schema NAME, default')} ${cyan('public')}${dim(').')}`);
476
+ console.log(` ${dim('•')} ${dim('You meant to set')} ${cyan('schemaFile')} ${dim('(your defineSchema() file), not')} ${cyan('schema')}${dim('.')}`);
477
+ console.log(` ${dim('•')} ${cyan('include')}/${cyan('exclude')} ${dim('filtered out every table.')}`);
478
+ console.log(` ${dim('•')} ${dim('The database has no tables yet — run')} ${cyan('turbine push')} ${dim('or a migration first.')}`);
479
+ newline();
480
+ console.log(` ${dim('If an empty client is genuinely what you want, re-run with')} ${cyan('--allow-empty')}${dim('.')}`);
481
+ newline();
482
+ process.exit(1);
483
+ }
427
484
  // Print table summary
428
485
  if (args.verbose) {
429
486
  newline();
@@ -1099,6 +1156,7 @@ function showGenerateHelp() {
1099
1156
  console.log(` ${cyan('--schema, -s')} ${dim('<name>')} Postgres schema ${dim('(default: public)')}`);
1100
1157
  console.log(` ${cyan('--include')} ${dim('<tables>')} Comma-separated tables to include`);
1101
1158
  console.log(` ${cyan('--exclude')} ${dim('<tables>')} Comma-separated tables to exclude`);
1159
+ console.log(` ${cyan('--allow-empty')} Generate even when introspection matches 0 tables`);
1102
1160
  newline();
1103
1161
  }
1104
1162
  function showPushHelp() {
@@ -1307,6 +1365,8 @@ async function main() {
1307
1365
  catch (err) {
1308
1366
  if (args.command !== 'init') {
1309
1367
  warn(`Could not load config: ${err instanceof Error ? err.message : String(err)}`);
1368
+ if (err instanceof Error)
1369
+ printCjsHintIfApplicable(err);
1310
1370
  }
1311
1371
  }
1312
1372
  const overrides = {
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import type { SchemaMetadata } from './schema.js';
26
26
  /** Shorthand type names that map to Postgres column types */
27
- export type ColumnTypeName = 'serial' | 'bigint' | 'integer' | 'smallint' | 'text' | 'varchar' | 'boolean' | 'timestamp' | 'date' | 'json' | 'uuid' | 'real' | 'double' | 'numeric' | 'bytea';
27
+ export type ColumnTypeName = 'serial' | 'bigserial' | 'bigint' | 'integer' | 'smallint' | 'text' | 'varchar' | 'boolean' | 'timestamp' | 'timestamptz' | 'date' | 'json' | 'jsonb' | 'uuid' | 'real' | 'double' | 'numeric' | 'bytea';
28
28
  /** Column definition as a plain object. This is what users write. */
29
29
  export interface ColumnDef {
30
30
  /** Column type (required) */
@@ -45,7 +45,7 @@ export interface ColumnDef {
45
45
  maxLength?: number;
46
46
  }
47
47
  /** Postgres-level column type (uppercase, as used in DDL) */
48
- export type ColumnType = 'BIGSERIAL' | 'BIGINT' | 'INTEGER' | 'SMALLINT' | 'TEXT' | 'BOOLEAN' | 'TIMESTAMPTZ' | 'JSONB' | 'UUID' | 'REAL' | 'DOUBLE PRECISION' | 'NUMERIC' | 'BYTEA' | 'DATE' | 'VARCHAR';
48
+ export type ColumnType = 'SERIAL' | 'BIGSERIAL' | 'BIGINT' | 'INTEGER' | 'SMALLINT' | 'TEXT' | 'BOOLEAN' | 'TIMESTAMPTZ' | 'JSONB' | 'UUID' | 'REAL' | 'DOUBLE PRECISION' | 'NUMERIC' | 'BYTEA' | 'DATE' | 'VARCHAR';
49
49
  export interface ColumnConfig {
50
50
  type: ColumnType;
51
51
  isPrimaryKey: boolean;
@@ -164,6 +164,7 @@ export declare class ColumnBuilder {
164
164
  private _config;
165
165
  constructor();
166
166
  serial(): this;
167
+ bigserial(): this;
167
168
  bigint(): this;
168
169
  integer(): this;
169
170
  smallint(): this;
@@ -171,8 +172,10 @@ export declare class ColumnBuilder {
171
172
  varchar(length: number): this;
172
173
  boolean(): this;
173
174
  timestamp(): this;
175
+ timestamptz(): this;
174
176
  date(): this;
175
177
  json(): this;
178
+ jsonb(): this;
176
179
  uuid(): this;
177
180
  real(): this;
178
181
  doublePrecision(): this;
@@ -188,7 +191,7 @@ export declare class ColumnBuilder {
188
191
  }
189
192
  /** @deprecated Use defineSchema() with plain objects instead */
190
193
  type ColumnProxy = {
191
- [K in 'serial' | 'bigint' | 'integer' | 'smallint' | 'text' | 'boolean' | 'timestamp' | 'date' | 'json' | 'uuid' | 'real' | 'doublePrecision' | 'numeric' | 'bytea']: () => ColumnBuilder;
194
+ [K in 'serial' | 'bigserial' | 'bigint' | 'integer' | 'smallint' | 'text' | 'boolean' | 'timestamp' | 'timestamptz' | 'date' | 'json' | 'jsonb' | 'uuid' | 'real' | 'doublePrecision' | 'numeric' | 'bytea']: () => ColumnBuilder;
192
195
  } & {
193
196
  varchar: (length: number) => ColumnBuilder;
194
197
  };
@@ -24,16 +24,28 @@
24
24
  */
25
25
  /** Maps shorthand names to actual Postgres type strings */
26
26
  const TYPE_MAP = {
27
- serial: 'BIGSERIAL',
27
+ // `serial` maps to SERIAL (int4). Its values fit in a JS `number` and pg
28
+ // returns them as numbers — so the generated `number` type is accurate.
29
+ // For 64-bit auto-increment keys use `bigserial` (int8), noting that values
30
+ // above Number.MAX_SAFE_INTEGER read back as string. (Changed in 0.24.0;
31
+ // `serial` previously emitted BIGSERIAL.)
32
+ serial: 'SERIAL',
33
+ bigserial: 'BIGSERIAL',
28
34
  bigint: 'BIGINT',
29
35
  integer: 'INTEGER',
30
36
  smallint: 'SMALLINT',
31
37
  text: 'TEXT',
32
38
  varchar: 'VARCHAR',
33
39
  boolean: 'BOOLEAN',
40
+ // `timestamp` is an honest alias for TIMESTAMPTZ (timezone-aware) — Turbine
41
+ // has always emitted TIMESTAMPTZ for it. `timestamptz` is the explicit spelling.
34
42
  timestamp: 'TIMESTAMPTZ',
43
+ timestamptz: 'TIMESTAMPTZ',
35
44
  date: 'DATE',
45
+ // `json` is an alias for JSONB (Turbine has always emitted JSONB). `jsonb`
46
+ // is the explicit spelling.
36
47
  json: 'JSONB',
48
+ jsonb: 'JSONB',
37
49
  uuid: 'UUID',
38
50
  real: 'REAL',
39
51
  double: 'DOUBLE PRECISION',
@@ -178,6 +190,11 @@ export class ColumnBuilder {
178
190
  };
179
191
  }
180
192
  serial() {
193
+ // See TYPE_MAP: `serial` is SERIAL (int4) as of 0.24.0. Use bigserial() for 64-bit.
194
+ this._config.type = 'SERIAL';
195
+ return this;
196
+ }
197
+ bigserial() {
181
198
  this._config.type = 'BIGSERIAL';
182
199
  return this;
183
200
  }
@@ -210,6 +227,10 @@ export class ColumnBuilder {
210
227
  this._config.type = 'TIMESTAMPTZ';
211
228
  return this;
212
229
  }
230
+ timestamptz() {
231
+ this._config.type = 'TIMESTAMPTZ';
232
+ return this;
233
+ }
213
234
  date() {
214
235
  this._config.type = 'DATE';
215
236
  return this;
@@ -218,6 +239,10 @@ export class ColumnBuilder {
218
239
  this._config.type = 'JSONB';
219
240
  return this;
220
241
  }
242
+ jsonb() {
243
+ this._config.type = 'JSONB';
244
+ return this;
245
+ }
221
246
  uuid() {
222
247
  this._config.type = 'UUID';
223
248
  return this;
@@ -269,14 +294,17 @@ export class ColumnBuilder {
269
294
  /** Type guard: is `prop` a known nullary ColumnBuilder type method? */
270
295
  function isNullaryColumnType(prop) {
271
296
  return (prop === 'serial' ||
297
+ prop === 'bigserial' ||
272
298
  prop === 'bigint' ||
273
299
  prop === 'integer' ||
274
300
  prop === 'smallint' ||
275
301
  prop === 'text' ||
276
302
  prop === 'boolean' ||
277
303
  prop === 'timestamp' ||
304
+ prop === 'timestamptz' ||
278
305
  prop === 'date' ||
279
306
  prop === 'json' ||
307
+ prop === 'jsonb' ||
280
308
  prop === 'uuid' ||
281
309
  prop === 'real' ||
282
310
  prop === 'doublePrecision' ||
@@ -7,6 +7,15 @@
7
7
  import pg from 'pg';
8
8
  import { postgresDialect } from './dialect.js';
9
9
  import { camelToSnake } from './schema.js';
10
+ /**
11
+ * Whether a resolved column type is an auto-increment pseudo-type (SERIAL /
12
+ * BIGSERIAL). These carry an implicit sequence default and NOT NULL, and their
13
+ * underlying integer width (int4 vs int8) is never auto-migrated by diff — a
14
+ * width change on a live PK is destructive and must be done by hand.
15
+ */
16
+ function isSerialType(type) {
17
+ return type === 'SERIAL' || type === 'BIGSERIAL';
18
+ }
10
19
  // ---------------------------------------------------------------------------
11
20
  // SQL Generation — SchemaDef → CREATE TABLE statements
12
21
  // ---------------------------------------------------------------------------
@@ -154,7 +163,7 @@ function generateColumnDef(fieldName, config, resolveRef, dialect = postgresDial
154
163
  // 2. Is a serial (BIGSERIAL implies NOT NULL), OR
155
164
  // 3. Has a primary key (PKs are NOT NULL)
156
165
  // A column is left nullable if .nullable() was called.
157
- const isSerial = config.type === 'BIGSERIAL';
166
+ const isSerial = isSerialType(config.type);
158
167
  const implicitNotNull = isSerial || config.isPrimaryKey;
159
168
  const notNull = config.isNotNull && !implicitNotNull;
160
169
  // DEFAULT
@@ -343,9 +352,13 @@ export async function schemaDiff(schema, connectionString) {
343
352
  result.reverseStatements.unshift(reverseSql);
344
353
  continue;
345
354
  }
346
- // Check type mismatch
355
+ // Check type mismatch. Serial/bigserial columns are exempt: their
356
+ // underlying int4/int8 width must never be auto-altered on a live table
357
+ // (a downcast on a PK loses data / breaks the sequence). This also
358
+ // preserves back-compat for DBs whose `serial` columns were created as
359
+ // BIGSERIAL (int8) before 0.24.0 — `push` won't try to shrink them.
347
360
  const expectedUdt = schemaTypeToUdt(config);
348
- if (expectedUdt && dbCol.udtName !== expectedUdt) {
361
+ if (expectedUdt && !isSerialType(config.type) && dbCol.udtName !== expectedUdt) {
349
362
  const sqlType = config.type === 'VARCHAR' && config.maxLength ? `VARCHAR(${config.maxLength})` : config.type;
350
363
  const oldSqlType = udtToSqlType(dbCol.udtName, dbCol.maxLength);
351
364
  const sql = `ALTER TABLE ${dialect.quoteIdentifier(tableName)} ALTER COLUMN ${dialect.quoteIdentifier(snakeName)} TYPE ${sqlType} USING ${dialect.quoteIdentifier(snakeName)}::${sqlType};`;
@@ -355,7 +368,7 @@ export async function schemaDiff(schema, connectionString) {
355
368
  result.reverseStatements.unshift(reverseSql);
356
369
  }
357
370
  // Check NOT NULL mismatch
358
- const shouldBeNotNull = config.isNotNull || config.isPrimaryKey || config.type === 'BIGSERIAL';
371
+ const shouldBeNotNull = config.isNotNull || config.isPrimaryKey || isSerialType(config.type);
359
372
  const isCurrentlyNullable = dbCol.isNullable;
360
373
  if (shouldBeNotNull && isCurrentlyNullable && !config.isNullable) {
361
374
  const sql = `ALTER TABLE ${dialect.quoteIdentifier(tableName)} ALTER COLUMN ${dialect.quoteIdentifier(snakeName)} SET NOT NULL;`;
@@ -372,7 +385,7 @@ export async function schemaDiff(schema, connectionString) {
372
385
  result.reverseStatements.unshift(reverseSql);
373
386
  }
374
387
  // Check DEFAULT value mismatch
375
- const isSerial = config.type === 'BIGSERIAL';
388
+ const isSerial = isSerialType(config.type);
376
389
  if (!isSerial) {
377
390
  const schemaDefault = config.defaultValue ? normalizeDefault(config.defaultValue) : null;
378
391
  const dbDefault = dbCol.columnDefault;
@@ -451,6 +464,7 @@ export async function schemaDiff(schema, connectionString) {
451
464
  */
452
465
  function schemaTypeToUdt(config) {
453
466
  const map = {
467
+ SERIAL: 'int4',
454
468
  BIGSERIAL: 'int8',
455
469
  BIGINT: 'int8',
456
470
  INTEGER: 'int4',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.23.2",
3
+ "version": "0.24.0",
4
4
  "description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "exports": {