turbine-orm 0.7.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.
@@ -81,23 +81,74 @@ function isTableDef(v) {
81
81
  */
82
82
  export function defineSchema(input) {
83
83
  const tables = {};
84
- for (const [tableName, value] of Object.entries(input)) {
84
+ for (const [accessor, value] of Object.entries(input)) {
85
+ // The user-facing key is the camelCase JS accessor; the DDL-facing
86
+ // table name is its snake_case form (e.g. `postTags` → `post_tags`).
87
+ const dbName = camelToSnakeLocal(accessor);
85
88
  if (isTableDef(value)) {
86
89
  // Legacy format: defineSchema({ users: table({ ... }) })
87
- value.name = tableName;
88
- tables[tableName] = value;
90
+ // Stamp both the DDL name and the JS accessor.
91
+ value.name = dbName;
92
+ value.accessor = accessor;
93
+ tables[accessor] = value;
89
94
  }
90
95
  else {
91
96
  // Object format: defineSchema({ users: { id: { type: 'serial' }, ... } })
97
+ const raw = value;
92
98
  const columns = {};
93
- for (const [fieldName, def] of Object.entries(value)) {
99
+ let pk;
100
+ for (const [fieldName, def] of Object.entries(raw)) {
101
+ if (fieldName === 'primaryKey') {
102
+ // Top-level composite primary key declaration
103
+ if (def !== undefined) {
104
+ if (!Array.isArray(def)) {
105
+ throw new Error(`Table "${accessor}": top-level "primaryKey" must be an array of column names (string[])`);
106
+ }
107
+ pk = def;
108
+ }
109
+ continue;
110
+ }
111
+ // Anything else is a ColumnDef
94
112
  columns[fieldName] = resolveColumn(def);
95
113
  }
96
- tables[tableName] = { name: tableName, columns };
114
+ // Validate composite PK references real columns and clear column-level PKs
115
+ // for those columns so we don't double-emit `PRIMARY KEY` clauses.
116
+ // Composite PK members are implicitly NOT NULL — preserve that even
117
+ // when the user clears the column-level `primaryKey: true` flag.
118
+ if (pk && pk.length > 0) {
119
+ for (const colName of pk) {
120
+ if (!(colName in columns)) {
121
+ throw new Error(`Table "${accessor}": composite primaryKey references unknown column "${colName}". ` +
122
+ `Known columns: ${Object.keys(columns).join(', ') || '(none)'}`);
123
+ }
124
+ // A composite PK at the table level supersedes any column-level
125
+ // `primaryKey: true` flag — silently clear it so DDL emission
126
+ // produces a single, valid table-level PRIMARY KEY constraint.
127
+ // Force NOT NULL since PK columns can never be nullable.
128
+ const c = columns[colName];
129
+ if (c) {
130
+ c.isPrimaryKey = false;
131
+ c.isNotNull = true;
132
+ }
133
+ }
134
+ }
135
+ tables[accessor] = {
136
+ name: dbName,
137
+ accessor,
138
+ columns,
139
+ ...(pk && pk.length > 0 ? { primaryKey: pk } : {}),
140
+ };
97
141
  }
98
142
  }
99
143
  return { tables };
100
144
  }
145
+ /**
146
+ * Local copy of camelToSnake to avoid a circular import dependency at the
147
+ * top of the file. Mirrors the implementation in `./schema.ts`.
148
+ */
149
+ function camelToSnakeLocal(s) {
150
+ return s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
151
+ }
101
152
  // ---------------------------------------------------------------------------
102
153
  // Legacy compat — ColumnBuilder still works for existing code
103
154
  // ---------------------------------------------------------------------------
@@ -223,7 +274,7 @@ export function table(columns) {
223
274
  for (const [fieldName, builder] of Object.entries(columns)) {
224
275
  built[fieldName] = builder.build();
225
276
  }
226
- return { name: '', columns: built };
277
+ return { name: '', accessor: '', columns: built };
227
278
  }
228
279
  // ---------------------------------------------------------------------------
229
280
  // Helpers
@@ -20,10 +20,11 @@ export function schemaToSQL(schema) {
20
20
  const statements = [];
21
21
  // Topologically sort tables by their foreign key references
22
22
  const sorted = topologicalSort(schema);
23
+ const resolveRef = makeRefResolver(schema);
23
24
  // Generate CREATE TABLE statements
24
25
  for (const tableName of sorted) {
25
26
  const table = schema.tables[tableName];
26
- statements.push(generateCreateTable(table));
27
+ statements.push(generateCreateTable(table, resolveRef));
27
28
  }
28
29
  // Generate CREATE INDEX for foreign key columns
29
30
  for (const tableName of sorted) {
@@ -33,12 +34,51 @@ export function schemaToSQL(schema) {
33
34
  }
34
35
  return statements;
35
36
  }
37
+ /**
38
+ * Build a function that resolves a raw `references: 'foo.id'` target name
39
+ * to the snake_case DDL table name. Accepts both the JS-facing camelCase
40
+ * accessor name and the snake_case DDL name; passes through unknown names
41
+ * unchanged so existing call sites continue to work.
42
+ */
43
+ function makeRefResolver(schema) {
44
+ const lookup = buildTableLookup(schema);
45
+ return (rawName) => {
46
+ const key = lookup[rawName];
47
+ if (key) {
48
+ const def = schema.tables[key];
49
+ if (def?.name)
50
+ return def.name;
51
+ }
52
+ return rawName;
53
+ };
54
+ }
55
+ /**
56
+ * Build a lookup index from both DDL names (snake_case) and JS accessor
57
+ * names (camelCase) to table keys, so `references: 'post_tags.id'` and
58
+ * `references: 'postTags.id'` both resolve to the same TableDef.
59
+ */
60
+ function buildTableLookup(schema) {
61
+ const lookup = {};
62
+ for (const [key, def] of Object.entries(schema.tables)) {
63
+ lookup[key] = key;
64
+ if (def.name && def.name !== key)
65
+ lookup[def.name] = key;
66
+ if (def.accessor && def.accessor !== key)
67
+ lookup[def.accessor] = key;
68
+ }
69
+ return lookup;
70
+ }
36
71
  /**
37
72
  * Topologically sort tables so that referenced tables come before referencing ones.
73
+ * Returns the table keys (the same keys used in `schema.tables`). The keys are
74
+ * the JS-facing accessor names; consumers should still call `table.name` to get
75
+ * the snake_case DDL name when emitting SQL.
76
+ *
38
77
  * Falls back to input order for tables with no dependency ordering.
39
78
  */
40
79
  function topologicalSort(schema) {
41
80
  const tableNames = Object.keys(schema.tables);
81
+ const lookup = buildTableLookup(schema);
42
82
  const resolved = new Set();
43
83
  const result = [];
44
84
  const visiting = new Set();
@@ -55,9 +95,10 @@ function topologicalSort(schema) {
55
95
  // Visit all tables this table references
56
96
  for (const col of Object.values(table.columns)) {
57
97
  if (col.referencesTarget) {
58
- const refTable = col.referencesTarget.split('.')[0];
59
- if (refTable !== name && schema.tables[refTable]) {
60
- visit(refTable);
98
+ const refRaw = col.referencesTarget.split('.')[0];
99
+ const refKey = lookup[refRaw];
100
+ if (refKey && refKey !== name) {
101
+ visit(refKey);
61
102
  }
62
103
  }
63
104
  }
@@ -73,12 +114,28 @@ function topologicalSort(schema) {
73
114
  }
74
115
  /**
75
116
  * Generate a CREATE TABLE statement for a single table definition.
117
+ *
118
+ * If `table.primaryKey` is set (composite primary key), emits a table-level
119
+ * `PRIMARY KEY ("col1", "col2", ...)` constraint instead of column-level
120
+ * `PRIMARY KEY` clauses on each member column. The composite PK column
121
+ * names are camelCase JS field names; they are converted to snake_case
122
+ * here.
123
+ *
124
+ * `resolveRef` (when supplied) maps raw `references: 'foo.id'` table names
125
+ * to their snake_case DDL form, so users can write either camelCase JS
126
+ * accessor names or snake_case DDL names.
76
127
  */
77
- function generateCreateTable(table) {
128
+ function generateCreateTable(table, resolveRef) {
78
129
  const tableName = table.name;
79
130
  const columnDefs = [];
131
+ const compositePk = table.primaryKey && table.primaryKey.length > 0 ? table.primaryKey : null;
80
132
  for (const [fieldName, config] of Object.entries(table.columns)) {
81
- columnDefs.push(generateColumnDef(fieldName, config));
133
+ columnDefs.push(generateColumnDef(fieldName, config, resolveRef));
134
+ }
135
+ // Append a table-level PRIMARY KEY constraint when a composite PK is set.
136
+ if (compositePk) {
137
+ const cols = compositePk.map((c) => quoteIdent(camelToSnake(c))).join(', ');
138
+ columnDefs.push(`PRIMARY KEY (${cols})`);
82
139
  }
83
140
  const body = columnDefs.map((d) => ` ${d}`).join(',\n');
84
141
  return `CREATE TABLE ${quoteIdent(tableName)} (\n${body}\n);`;
@@ -86,7 +143,7 @@ function generateCreateTable(table) {
86
143
  /**
87
144
  * Generate a single column definition line (e.g. "id BIGSERIAL PRIMARY KEY").
88
145
  */
89
- function generateColumnDef(fieldName, config) {
146
+ function generateColumnDef(fieldName, config, resolveRef) {
90
147
  const snakeName = camelToSnake(fieldName);
91
148
  const parts = [quoteIdent(snakeName)];
92
149
  // Type
@@ -120,11 +177,14 @@ function generateColumnDef(fieldName, config) {
120
177
  const sqlDefault = normalizeDefault(config.defaultValue);
121
178
  parts.push(`DEFAULT ${sqlDefault}`);
122
179
  }
123
- // REFERENCES
180
+ // REFERENCES — resolve the raw table name through the optional resolver so
181
+ // both camelCase accessor names and snake_case DDL names work.
124
182
  if (config.referencesTarget) {
125
183
  const refParts = config.referencesTarget.split('.');
126
184
  if (refParts.length === 2) {
127
- parts.push(`REFERENCES ${quoteIdent(refParts[0])}(${quoteIdent(refParts[1])})`);
185
+ const rawTable = refParts[0];
186
+ const refTable = resolveRef ? resolveRef(rawTable) : rawTable;
187
+ parts.push(`REFERENCES ${quoteIdent(refTable)}(${quoteIdent(refParts[1])})`);
128
188
  }
129
189
  }
130
190
  return parts.join(' ');
@@ -231,33 +291,39 @@ export async function schemaDiff(schema, connectionString) {
231
291
  dbUniques[row.table_name] = {};
232
292
  dbUniques[row.table_name][row.column_name] = row.constraint_name;
233
293
  }
234
- const schemaTableNames = new Set(Object.keys(schema.tables));
294
+ // Build a set of DDL-facing snake_case table names that the schema defines.
295
+ const schemaDdlNames = new Set();
296
+ for (const def of Object.values(schema.tables))
297
+ schemaDdlNames.add(def.name);
235
298
  const result = { create: [], alter: [], drop: [], statements: [], reverseStatements: [] };
299
+ const resolveRef = makeRefResolver(schema);
236
300
  // Tables to create (in schema but not in DB)
237
301
  const sorted = topologicalSort(schema);
238
- for (const tableName of sorted) {
239
- if (!existingTables.has(tableName)) {
240
- const tableDef = schema.tables[tableName];
302
+ for (const tableKey of sorted) {
303
+ const tableDef = schema.tables[tableKey];
304
+ const ddlName = tableDef.name;
305
+ if (!existingTables.has(ddlName)) {
241
306
  result.create.push(tableDef);
242
- result.statements.push(generateCreateTable(tableDef));
307
+ result.statements.push(generateCreateTable(tableDef, resolveRef));
243
308
  const fkIndexes = generateForeignKeyIndexes(tableDef);
244
309
  result.statements.push(...fkIndexes);
245
310
  // Reverse: DROP TABLE (with indexes — they drop automatically)
246
- result.reverseStatements.unshift(`DROP TABLE IF EXISTS ${quoteIdent(tableName)} CASCADE;`);
311
+ result.reverseStatements.unshift(`DROP TABLE IF EXISTS ${quoteIdent(ddlName)} CASCADE;`);
247
312
  }
248
313
  }
249
314
  // Tables to drop (in DB but not in schema)
250
315
  for (const existingTable of existingTables) {
251
- if (!schemaTableNames.has(existingTable)) {
316
+ if (!schemaDdlNames.has(existingTable)) {
252
317
  result.drop.push(existingTable);
253
318
  // We don't auto-generate DROP statements for safety
254
319
  }
255
320
  }
256
321
  // Tables to alter (exist in both)
257
- for (const tableName of sorted) {
322
+ for (const tableKey of sorted) {
323
+ const tableDef = schema.tables[tableKey];
324
+ const tableName = tableDef.name;
258
325
  if (!existingTables.has(tableName))
259
326
  continue;
260
- const tableDef = schema.tables[tableName];
261
327
  const dbCols = dbColumns[tableName] ?? {};
262
328
  const tableUniques = dbUniques[tableName] ?? {};
263
329
  const alterDef = { table: tableName, columns: [] };
@@ -266,7 +332,7 @@ export async function schemaDiff(schema, connectionString) {
266
332
  const dbCol = dbCols[snakeName];
267
333
  if (!dbCol) {
268
334
  // Column exists in schema but not in DB — ADD COLUMN
269
- const colDef = generateColumnDef(fieldName, config);
335
+ const colDef = generateColumnDef(fieldName, config, resolveRef);
270
336
  const sql = `ALTER TABLE ${quoteIdent(tableName)} ADD COLUMN ${colDef};`;
271
337
  const reverseSql = `ALTER TABLE ${quoteIdent(tableName)} DROP COLUMN ${quoteIdent(snakeName)};`;
272
338
  alterDef.columns.push({ column: snakeName, action: 'add', sql, reverseSql });
@@ -35,12 +35,12 @@
35
35
  * // app/api/users/route.ts
36
36
  * import { Pool } from '@neondatabase/serverless';
37
37
  * import { turbineHttp } from 'turbine-orm/serverless';
38
- * import { schema } from '@/generated/turbine/metadata';
38
+ * import { SCHEMA } from '../../generated/turbine/metadata';
39
39
  *
40
40
  * export const runtime = 'edge';
41
41
  *
42
42
  * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
43
- * const db = turbineHttp(pool, schema);
43
+ * const db = turbineHttp(pool, SCHEMA);
44
44
  *
45
45
  * export async function GET() {
46
46
  * const users = await db.table('users').findMany({ limit: 10 });
@@ -52,12 +52,12 @@
52
52
  *
53
53
  * ```ts
54
54
  * import { TurbineClient } from 'turbine-orm';
55
- * import { schema } from './generated/turbine/metadata.js';
55
+ * import { SCHEMA } from './generated/turbine/metadata.js';
56
56
  *
57
57
  * const db = new TurbineClient({
58
58
  * connectionString: process.env.SUPABASE_DB_URL,
59
59
  * ssl: { rejectUnauthorized: false },
60
- * }, schema);
60
+ * }, SCHEMA);
61
61
  * ```
62
62
  *
63
63
  * ## Example — Cloudflare Workers
@@ -66,11 +66,12 @@
66
66
  * // Use the Neon HTTP driver which works in Workers runtime
67
67
  * import { Pool } from '@neondatabase/serverless';
68
68
  * import { turbineHttp } from 'turbine-orm/serverless';
69
+ * import { SCHEMA } from './generated/turbine/metadata';
69
70
  *
70
71
  * export default {
71
72
  * async fetch(req: Request, env: Env) {
72
73
  * const pool = new Pool({ connectionString: env.DATABASE_URL });
73
- * const db = turbineHttp(pool, schema);
74
+ * const db = turbineHttp(pool, SCHEMA);
74
75
  * const users = await db.table('users').findMany({ limit: 10 });
75
76
  * return Response.json(users);
76
77
  * }
@@ -102,10 +103,10 @@ export interface TurbineHttpOptions extends Pick<TurbineConfig, 'logging' | 'def
102
103
  * ```ts
103
104
  * import { Pool } from '@neondatabase/serverless';
104
105
  * import { turbineHttp } from 'turbine-orm/serverless';
105
- * import { schema } from './generated/turbine/metadata.js';
106
+ * import { SCHEMA } from './generated/turbine/metadata.js';
106
107
  *
107
108
  * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
108
- * const db = turbineHttp(pool, schema);
109
+ * const db = turbineHttp(pool, SCHEMA);
109
110
  *
110
111
  * const users = await db.table('users').findMany({ limit: 10 });
111
112
  * ```
@@ -35,12 +35,12 @@
35
35
  * // app/api/users/route.ts
36
36
  * import { Pool } from '@neondatabase/serverless';
37
37
  * import { turbineHttp } from 'turbine-orm/serverless';
38
- * import { schema } from '@/generated/turbine/metadata';
38
+ * import { SCHEMA } from '../../generated/turbine/metadata';
39
39
  *
40
40
  * export const runtime = 'edge';
41
41
  *
42
42
  * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
43
- * const db = turbineHttp(pool, schema);
43
+ * const db = turbineHttp(pool, SCHEMA);
44
44
  *
45
45
  * export async function GET() {
46
46
  * const users = await db.table('users').findMany({ limit: 10 });
@@ -52,12 +52,12 @@
52
52
  *
53
53
  * ```ts
54
54
  * import { TurbineClient } from 'turbine-orm';
55
- * import { schema } from './generated/turbine/metadata.js';
55
+ * import { SCHEMA } from './generated/turbine/metadata.js';
56
56
  *
57
57
  * const db = new TurbineClient({
58
58
  * connectionString: process.env.SUPABASE_DB_URL,
59
59
  * ssl: { rejectUnauthorized: false },
60
- * }, schema);
60
+ * }, SCHEMA);
61
61
  * ```
62
62
  *
63
63
  * ## Example — Cloudflare Workers
@@ -66,11 +66,12 @@
66
66
  * // Use the Neon HTTP driver which works in Workers runtime
67
67
  * import { Pool } from '@neondatabase/serverless';
68
68
  * import { turbineHttp } from 'turbine-orm/serverless';
69
+ * import { SCHEMA } from './generated/turbine/metadata';
69
70
  *
70
71
  * export default {
71
72
  * async fetch(req: Request, env: Env) {
72
73
  * const pool = new Pool({ connectionString: env.DATABASE_URL });
73
- * const db = turbineHttp(pool, schema);
74
+ * const db = turbineHttp(pool, SCHEMA);
74
75
  * const users = await db.table('users').findMany({ limit: 10 });
75
76
  * return Response.json(users);
76
77
  * }
@@ -94,10 +95,10 @@ import { TurbineClient } from './client.js';
94
95
  * ```ts
95
96
  * import { Pool } from '@neondatabase/serverless';
96
97
  * import { turbineHttp } from 'turbine-orm/serverless';
97
- * import { schema } from './generated/turbine/metadata.js';
98
+ * import { SCHEMA } from './generated/turbine/metadata.js';
98
99
  *
99
100
  * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
100
- * const db = turbineHttp(pool, schema);
101
+ * const db = turbineHttp(pool, SCHEMA);
101
102
  *
102
103
  * const users = await db.table('users').findMany({ limit: 10 });
103
104
  * ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. 1 dependency, ~110KB",
5
5
  "type": "module",
6
6
  "exports": {
@@ -43,8 +43,8 @@
43
43
  "status": "tsx src/cli/index.ts status",
44
44
  "examples": "tsx examples/examples.ts",
45
45
  "test": "tsx --test src/test/*.test.ts",
46
- "test:unit": "tsx --test src/test/schema-builder.test.ts src/test/errors.test.ts src/test/stress.test.ts src/test/migrate.test.ts src/test/update-operators.test.ts src/test/empty-where-guard.test.ts src/test/cli.test.ts src/test/serverless.test.ts src/test/pipeline.test.ts",
47
- "test:coverage": "c8 tsx --test src/test/schema-builder.test.ts src/test/errors.test.ts src/test/stress.test.ts src/test/migrate.test.ts src/test/update-operators.test.ts src/test/empty-where-guard.test.ts src/test/cli.test.ts src/test/serverless.test.ts src/test/pipeline.test.ts",
46
+ "test:unit": "tsx --test src/test/schema-builder.test.ts src/test/errors.test.ts src/test/stress.test.ts src/test/migrate.test.ts src/test/update-operators.test.ts src/test/empty-where-guard.test.ts src/test/cli.test.ts src/test/serverless.test.ts src/test/pipeline.test.ts src/test/with-inference.test.ts src/test/operator-validation.test.ts src/test/unlimited-warning.test.ts src/test/generate-relations.test.ts",
47
+ "test:coverage": "c8 tsx --test src/test/schema-builder.test.ts src/test/errors.test.ts src/test/stress.test.ts src/test/migrate.test.ts src/test/update-operators.test.ts src/test/empty-where-guard.test.ts src/test/cli.test.ts src/test/serverless.test.ts src/test/pipeline.test.ts src/test/with-inference.test.ts src/test/operator-validation.test.ts src/test/unlimited-warning.test.ts src/test/generate-relations.test.ts",
48
48
  "lint": "biome check src/",
49
49
  "lint:fix": "biome check --write src/",
50
50
  "format": "biome format --write src/",