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
package/dist/errors.js ADDED
@@ -0,0 +1,405 @@
1
+ /**
2
+ * turbine-orm — Error types
3
+ *
4
+ * Typed errors with error codes for programmatic handling.
5
+ * All Turbine errors extend TurbineError which includes a `code` property.
6
+ */
7
+ /** Error codes for all Turbine errors */
8
+ export const TurbineErrorCode = {
9
+ NOT_FOUND: 'TURBINE_E001',
10
+ TIMEOUT: 'TURBINE_E002',
11
+ VALIDATION: 'TURBINE_E003',
12
+ CONNECTION: 'TURBINE_E004',
13
+ RELATION: 'TURBINE_E005',
14
+ MIGRATION: 'TURBINE_E006',
15
+ CIRCULAR_RELATION: 'TURBINE_E007',
16
+ UNIQUE_VIOLATION: 'TURBINE_E008',
17
+ FOREIGN_KEY_VIOLATION: 'TURBINE_E009',
18
+ NOT_NULL_VIOLATION: 'TURBINE_E010',
19
+ CHECK_VIOLATION: 'TURBINE_E011',
20
+ DEADLOCK_DETECTED: 'TURBINE_E012',
21
+ SERIALIZATION_FAILURE: 'TURBINE_E013',
22
+ };
23
+ /** Base error class for all Turbine errors */
24
+ export class TurbineError extends Error {
25
+ code;
26
+ constructor(code, message, options) {
27
+ super(message, options);
28
+ this.name = 'TurbineError';
29
+ this.code = code;
30
+ }
31
+ }
32
+ let errorMessageMode = 'safe';
33
+ /**
34
+ * Set the global NotFoundError message mode. Called from the TurbineClient
35
+ * constructor when `TurbineConfig.errorMessages` is provided.
36
+ *
37
+ * - `'safe'` (default): the message includes only the keys of the where
38
+ * clause (e.g. `where: { id, email }`). Values are redacted.
39
+ * - `'verbose'`: the message includes the full JSON-serialized where
40
+ * clause (e.g. `where: {"id":1,"email":"alice@x.com"}`).
41
+ */
42
+ export function setErrorMessageMode(mode) {
43
+ errorMessageMode = mode;
44
+ }
45
+ /** Returns the current NotFoundError message mode. Exported for tests. */
46
+ export function getErrorMessageMode() {
47
+ return errorMessageMode;
48
+ }
49
+ /**
50
+ * Render a `where` clause for error messages. In 'safe' mode (the default),
51
+ * only the keys are shown; values are stripped to avoid leaking PII into logs.
52
+ * Nested AND/OR/NOT combinators are recursively rendered.
53
+ */
54
+ function renderWhereForMessage(where, mode) {
55
+ if (mode === 'verbose') {
56
+ try {
57
+ return JSON.stringify(where);
58
+ }
59
+ catch {
60
+ return '[unserializable]';
61
+ }
62
+ }
63
+ // safe mode: keys only
64
+ if (where === null || where === undefined)
65
+ return '';
66
+ if (typeof where !== 'object')
67
+ return '';
68
+ const keys = Object.keys(where);
69
+ if (keys.length === 0)
70
+ return '{}';
71
+ return `{ ${keys.join(', ')} }`;
72
+ }
73
+ /**
74
+ * Thrown when a record is not found (findUniqueOrThrow, findFirstOrThrow,
75
+ * update/delete against a non-matching row, etc.)
76
+ *
77
+ * Supports two call styles for back-compat:
78
+ * - `new NotFoundError()` / `new NotFoundError('custom message')`
79
+ * - `new NotFoundError({ table, where, operation, cause, message })`
80
+ *
81
+ * When called with an options object and no explicit `message`, a Prisma-style
82
+ * message is built automatically. By default, only the where-clause keys are
83
+ * shown to avoid leaking PII into logs:
84
+ * `[turbine] findUniqueOrThrow on "users" found no record matching where: { id }`
85
+ *
86
+ * Set `setErrorMessageMode('verbose')` (or pass `errorMessages: 'verbose'` to
87
+ * the TurbineClient constructor) to include the full where values:
88
+ * `[turbine] findUniqueOrThrow on "users" found no record matching where: {"id":1}`
89
+ *
90
+ * The full `where` object, `table`, and `operation` are always available as
91
+ * structured properties on the error instance regardless of mode.
92
+ */
93
+ export class NotFoundError extends TurbineError {
94
+ table;
95
+ where;
96
+ operation;
97
+ constructor(input) {
98
+ // Back-compat: string argument (or undefined) — replicate legacy behavior.
99
+ if (typeof input === 'string' || input === undefined) {
100
+ super(TurbineErrorCode.NOT_FOUND, input ?? 'Record not found');
101
+ this.name = 'NotFoundError';
102
+ return;
103
+ }
104
+ const { table, where, operation, cause } = input;
105
+ let message = input.message;
106
+ if (!message) {
107
+ if (operation && table) {
108
+ const wherePart = where !== undefined ? ` matching where: ${renderWhereForMessage(where, errorMessageMode)}` : '';
109
+ message = `[turbine] ${operation} on "${table}" found no record${wherePart}`;
110
+ }
111
+ else if (table) {
112
+ const wherePart = where !== undefined ? ` matching where ${renderWhereForMessage(where, errorMessageMode)}` : '';
113
+ message = `[turbine] No record found in "${table}"${wherePart}`;
114
+ }
115
+ else {
116
+ message = '[turbine] Record not found';
117
+ }
118
+ }
119
+ super(TurbineErrorCode.NOT_FOUND, message, { cause });
120
+ this.name = 'NotFoundError';
121
+ this.table = table;
122
+ this.where = where;
123
+ this.operation = operation;
124
+ }
125
+ }
126
+ /** Thrown when a query or transaction exceeds the configured timeout */
127
+ export class TimeoutError extends TurbineError {
128
+ timeoutMs;
129
+ constructor(timeoutMs, context = 'Query') {
130
+ super(TurbineErrorCode.TIMEOUT, `[turbine] ${context} timed out after ${timeoutMs}ms`);
131
+ this.name = 'TimeoutError';
132
+ this.timeoutMs = timeoutMs;
133
+ }
134
+ }
135
+ /** Thrown when query arguments fail validation (unknown column, invalid operator, etc.) */
136
+ export class ValidationError extends TurbineError {
137
+ constructor(message) {
138
+ super(TurbineErrorCode.VALIDATION, message);
139
+ this.name = 'ValidationError';
140
+ }
141
+ }
142
+ /** Thrown when a database connection fails */
143
+ export class ConnectionError extends TurbineError {
144
+ constructor(message) {
145
+ super(TurbineErrorCode.CONNECTION, message);
146
+ this.name = 'ConnectionError';
147
+ }
148
+ }
149
+ /** Thrown when a relation reference is invalid */
150
+ export class RelationError extends TurbineError {
151
+ constructor(message) {
152
+ super(TurbineErrorCode.RELATION, message);
153
+ this.name = 'RelationError';
154
+ }
155
+ }
156
+ /** Thrown when a migration operation fails */
157
+ export class MigrationError extends TurbineError {
158
+ constructor(message) {
159
+ super(TurbineErrorCode.MIGRATION, message);
160
+ this.name = 'MigrationError';
161
+ }
162
+ }
163
+ /** Thrown when circular relation nesting is detected */
164
+ export class CircularRelationError extends TurbineError {
165
+ path;
166
+ constructor(path) {
167
+ super(TurbineErrorCode.CIRCULAR_RELATION, `[turbine] Circular or too-deep relation nesting detected: ${path.join(' → ')}. Maximum nesting depth is 10.`);
168
+ this.name = 'CircularRelationError';
169
+ this.path = path;
170
+ }
171
+ }
172
+ // ---------------------------------------------------------------------------
173
+ // Database constraint violation errors
174
+ // ---------------------------------------------------------------------------
175
+ /**
176
+ * Extract the `detail` string from a pg-style error stored as `cause`.
177
+ * Returns undefined if the cause is not an object or has no detail.
178
+ */
179
+ function detailFromCause(cause) {
180
+ if (!cause || typeof cause !== 'object')
181
+ return undefined;
182
+ const d = cause.detail;
183
+ return typeof d === 'string' && d.length > 0 ? d : undefined;
184
+ }
185
+ /** Thrown when a UNIQUE constraint is violated (pg code 23505) */
186
+ export class UniqueConstraintError extends TurbineError {
187
+ constraint;
188
+ columns;
189
+ table;
190
+ constructor(opts = {}) {
191
+ const { constraint, columns, table, cause } = opts;
192
+ let message = opts.message;
193
+ if (!message) {
194
+ const constraintPart = constraint ? ` on ${constraint}` : '';
195
+ const columnsPart = columns && columns.length > 0 ? ` (${columns.join(', ')})` : '';
196
+ message = `[turbine] Unique constraint violation${constraintPart}${columnsPart}`;
197
+ const detail = detailFromCause(cause);
198
+ if (detail)
199
+ message += `: ${detail}`;
200
+ }
201
+ super(TurbineErrorCode.UNIQUE_VIOLATION, message, { cause });
202
+ this.name = 'UniqueConstraintError';
203
+ this.constraint = constraint;
204
+ this.columns = columns;
205
+ this.table = table;
206
+ }
207
+ }
208
+ /** Thrown when a FOREIGN KEY constraint is violated (pg code 23503) */
209
+ export class ForeignKeyError extends TurbineError {
210
+ constraint;
211
+ table;
212
+ constructor(opts = {}) {
213
+ const { constraint, table, cause } = opts;
214
+ let message = opts.message;
215
+ if (!message) {
216
+ const constraintPart = constraint ? ` on ${constraint}` : '';
217
+ message = `[turbine] Foreign key constraint violation${constraintPart}`;
218
+ const detail = detailFromCause(cause);
219
+ if (detail)
220
+ message += `: ${detail}`;
221
+ }
222
+ super(TurbineErrorCode.FOREIGN_KEY_VIOLATION, message, { cause });
223
+ this.name = 'ForeignKeyError';
224
+ this.constraint = constraint;
225
+ this.table = table;
226
+ }
227
+ }
228
+ /** Thrown when a NOT NULL constraint is violated (pg code 23502) */
229
+ export class NotNullViolationError extends TurbineError {
230
+ column;
231
+ table;
232
+ constructor(opts = {}) {
233
+ const { column, table, cause } = opts;
234
+ let message = opts.message;
235
+ if (!message) {
236
+ const columnPart = column ? ` on column "${column}"` : '';
237
+ message = `[turbine] NOT NULL constraint violation${columnPart}`;
238
+ const detail = detailFromCause(cause);
239
+ if (detail)
240
+ message += `: ${detail}`;
241
+ }
242
+ super(TurbineErrorCode.NOT_NULL_VIOLATION, message, { cause });
243
+ this.name = 'NotNullViolationError';
244
+ this.column = column;
245
+ this.table = table;
246
+ }
247
+ }
248
+ /**
249
+ * Thrown when Postgres detects a deadlock (pg code 40P01).
250
+ *
251
+ * This error is **retryable** — when caught, callers can safely retry the
252
+ * transaction (typically with backoff). Catch it explicitly:
253
+ *
254
+ * ```ts
255
+ * try {
256
+ * await db.$transaction(async (tx) => { ... });
257
+ * } catch (err) {
258
+ * if (err instanceof DeadlockError) {
259
+ * // safe to retry
260
+ * }
261
+ * }
262
+ * ```
263
+ */
264
+ export class DeadlockError extends TurbineError {
265
+ /** Marks this error as safe to retry */
266
+ isRetryable = true;
267
+ constraint;
268
+ constructor(opts = {}) {
269
+ const { constraint, cause } = opts;
270
+ let message = opts.message;
271
+ if (!message) {
272
+ const pgMessage = cause?.message;
273
+ message = pgMessage ? `[turbine] Deadlock detected: ${pgMessage}` : '[turbine] Deadlock detected';
274
+ }
275
+ super(TurbineErrorCode.DEADLOCK_DETECTED, message, { cause });
276
+ this.name = 'DeadlockError';
277
+ this.constraint = constraint;
278
+ }
279
+ }
280
+ /**
281
+ * Thrown when a Serializable transaction fails due to a serialization
282
+ * conflict (pg code 40001 — `could not serialize access due to ...`).
283
+ *
284
+ * This error is **retryable** — by Postgres documentation, the recommended
285
+ * response is to re-run the entire transaction. Catch it explicitly:
286
+ *
287
+ * ```ts
288
+ * try {
289
+ * await db.$transaction(async (tx) => { ... }, { isolationLevel: 'Serializable' });
290
+ * } catch (err) {
291
+ * if (err instanceof SerializationFailureError) {
292
+ * // safe to retry the whole transaction
293
+ * }
294
+ * }
295
+ * ```
296
+ */
297
+ export class SerializationFailureError extends TurbineError {
298
+ /** Marks this error as safe to retry */
299
+ isRetryable = true;
300
+ constructor(opts = {}) {
301
+ const { cause } = opts;
302
+ let message = opts.message;
303
+ if (!message) {
304
+ const pgMessage = cause?.message;
305
+ message = pgMessage
306
+ ? `[turbine] Serializable transaction conflict: ${pgMessage}`
307
+ : '[turbine] Serializable transaction conflict';
308
+ }
309
+ super(TurbineErrorCode.SERIALIZATION_FAILURE, message, { cause });
310
+ this.name = 'SerializationFailureError';
311
+ }
312
+ }
313
+ /** Thrown when a CHECK constraint is violated (pg code 23514) */
314
+ export class CheckConstraintError extends TurbineError {
315
+ constraint;
316
+ table;
317
+ constructor(opts = {}) {
318
+ const { constraint, table, cause } = opts;
319
+ let message = opts.message;
320
+ if (!message) {
321
+ const constraintPart = constraint ? ` on ${constraint}` : '';
322
+ message = `[turbine] Check constraint violation${constraintPart}`;
323
+ const detail = detailFromCause(cause);
324
+ if (detail)
325
+ message += `: ${detail}`;
326
+ }
327
+ super(TurbineErrorCode.CHECK_VIOLATION, message, { cause });
328
+ this.name = 'CheckConstraintError';
329
+ this.constraint = constraint;
330
+ this.table = table;
331
+ }
332
+ }
333
+ /**
334
+ * Parse column names out of a pg `detail` string like:
335
+ * "Key (email)=(foo@bar) already exists."
336
+ * "Key (col1, col2)=(v1, v2) already exists."
337
+ */
338
+ function parseColumnsFromDetail(detail) {
339
+ const m = detail.match(/^Key \(([^)]+)\)/);
340
+ if (!m)
341
+ return undefined;
342
+ return m[1].split(',').map((s) => s.trim());
343
+ }
344
+ /**
345
+ * Translate a pg driver error into a typed Turbine error.
346
+ * If the error doesn't match a known constraint code, returns it unchanged.
347
+ *
348
+ * Maps:
349
+ * 23505 (unique_violation) -> UniqueConstraintError
350
+ * 23503 (foreign_key_violation) -> ForeignKeyError
351
+ * 23502 (not_null_violation) -> NotNullViolationError
352
+ * 23514 (check_violation) -> CheckConstraintError
353
+ * 40P01 (deadlock_detected) -> DeadlockError (retryable)
354
+ * 40001 (serialization_failure) -> SerializationFailureError (retryable)
355
+ *
356
+ * The original pg error is preserved as `.cause` on the wrapped error.
357
+ */
358
+ export function wrapPgError(err) {
359
+ if (!err || typeof err !== 'object')
360
+ return err;
361
+ const e = err;
362
+ if (!e.code)
363
+ return err;
364
+ switch (e.code) {
365
+ case '23505': {
366
+ const cols = e.detail ? parseColumnsFromDetail(e.detail) : undefined;
367
+ return new UniqueConstraintError({
368
+ constraint: e.constraint,
369
+ columns: cols,
370
+ table: e.table,
371
+ cause: err,
372
+ });
373
+ }
374
+ case '23503':
375
+ return new ForeignKeyError({
376
+ constraint: e.constraint,
377
+ table: e.table,
378
+ cause: err,
379
+ });
380
+ case '23502':
381
+ return new NotNullViolationError({
382
+ column: e.column,
383
+ table: e.table,
384
+ cause: err,
385
+ });
386
+ case '23514':
387
+ return new CheckConstraintError({
388
+ constraint: e.constraint,
389
+ table: e.table,
390
+ cause: err,
391
+ });
392
+ case '40P01':
393
+ return new DeadlockError({
394
+ constraint: e.constraint,
395
+ cause: err,
396
+ });
397
+ case '40001':
398
+ return new SerializationFailureError({
399
+ cause: err,
400
+ });
401
+ default:
402
+ return err;
403
+ }
404
+ }
405
+ //# sourceMappingURL=errors.js.map
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @batadata/turbine — Code generator
2
+ * turbine-orm — Code generator
3
3
  *
4
4
  * Takes an IntrospectedSchema and emits TypeScript files:
5
5
  * - types.ts — Entity interfaces, Create/Update input types
@@ -21,4 +21,10 @@ export declare function generate(options: GenerateOptions): {
21
21
  outDir: string;
22
22
  files: string[];
23
23
  };
24
+ /**
25
+ * Generate the contents of `types.ts` (entity interfaces, *Create / *Update,
26
+ * and *Relations brand-field interfaces). Exported so tests can pin the
27
+ * generator output without writing files to disk.
28
+ */
29
+ export declare function generateTypes(schema: SchemaMetadata): string;
24
30
  //# sourceMappingURL=generate.d.ts.map
package/dist/generate.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @batadata/turbine — Code generator
2
+ * turbine-orm — Code generator
3
3
  *
4
4
  * Takes an IntrospectedSchema and emits TypeScript files:
5
5
  * - types.ts — Entity interfaces, Create/Update input types
@@ -8,9 +8,9 @@
8
8
  *
9
9
  * Output goes to the specified directory (default: ./generated/turbine/).
10
10
  */
11
- import { writeFileSync, mkdirSync } from 'node:fs';
12
- import { join, resolve, relative } from 'node:path';
13
- import { snakeToPascal, singularize, } from './schema.js';
11
+ import { mkdirSync, writeFileSync } from 'node:fs';
12
+ import { join, relative, resolve } from 'node:path';
13
+ import { singularize, snakeToPascal } from './schema.js';
14
14
  /** Get the TypeScript type name for a table (singularized PascalCase) */
15
15
  function entityName(tableName) {
16
16
  return snakeToPascal(singularize(tableName));
@@ -52,18 +52,41 @@ export function generate(options) {
52
52
  function generatedFileHeader() {
53
53
  return [
54
54
  '/**',
55
- ' * Auto-generated by @batadata/turbine — DO NOT EDIT',
55
+ ' * Auto-generated by turbine-orm — DO NOT EDIT',
56
56
  ' *',
57
57
  ` * Generated at: ${new Date().toISOString()}`,
58
- ' * @see https://batadata.com/docs/turbine',
58
+ ' * @see https://turbineorm.dev',
59
59
  ' */',
60
60
  '',
61
61
  ];
62
62
  }
63
- function generateTypes(schema) {
64
- const lines = [
65
- ...generatedFileHeader(),
66
- ];
63
+ /**
64
+ * Generate the contents of `types.ts` (entity interfaces, *Create / *Update,
65
+ * and *Relations brand-field interfaces). Exported so tests can pin the
66
+ * generator output without writing files to disk.
67
+ */
68
+ export function generateTypes(schema) {
69
+ const lines = [...generatedFileHeader()];
70
+ // We import UpdateOperatorInput so generated *Update types can express
71
+ // atomic increment / decrement / multiply / divide / set operators on
72
+ // numeric columns (TASK-3.4).
73
+ //
74
+ // RelationDescriptor is the brand-field interface that lets `WithResult`
75
+ // recurse through nested `with` clauses at any depth. The generator emits
76
+ // each `*Relations` member as a `RelationDescriptor<Target, Cardinality,
77
+ // TargetRelations>` so users get full deep `with`-clause type inference
78
+ // out of the box (TASK-2.1).
79
+ lines.push("import type { RelationDescriptor, UpdateOperatorInput } from 'turbine-orm';");
80
+ lines.push('');
81
+ // Pre-compute which tables have relations so we know whether to thread
82
+ // `${TargetType}Relations` (for deep inference) or `{}` (the no-relations
83
+ // default) into each `RelationDescriptor`. Built once up-front because
84
+ // relations can point at tables we haven't iterated to yet.
85
+ const tablesWithRelations = new Set();
86
+ for (const t of Object.values(schema.tables)) {
87
+ if (Object.keys(t.relations).length > 0)
88
+ tablesWithRelations.add(t.name);
89
+ }
67
90
  // Generate enum types
68
91
  for (const [enumName, labels] of Object.entries(schema.enums)) {
69
92
  const typeName = snakeToPascal(enumName);
@@ -105,17 +128,37 @@ function generateTypes(schema) {
105
128
  lines.push('};');
106
129
  lines.push('');
107
130
  // --- Update input type (all fields optional except PK) ---
131
+ // Numeric columns additionally accept `UpdateOperatorInput<number>` so
132
+ // users can write `{ viewCount: { increment: 1 } }` without an `as any`.
108
133
  const nonPkCols = table.columns.filter((c) => !table.primaryKey.includes(c.name));
109
134
  lines.push(`/** Input type for updating a row in \`${table.name}\` */`);
110
135
  lines.push(`export type ${typeName}Update = {`);
111
136
  for (const col of nonPkCols) {
112
- lines.push(` ${col.field}?: ${col.tsType};`);
137
+ lines.push(` ${col.field}?: ${updateFieldType(col.tsType)};`);
113
138
  }
114
139
  lines.push('};');
115
140
  lines.push('');
116
- // --- Relation types ---
141
+ // --- Relations map (for type-safe `with` clauses) ---
142
+ //
143
+ // Each relation is emitted as a `RelationDescriptor<Target, Cardinality,
144
+ // TargetRelations>` brand-field interface. This is what enables the
145
+ // recursive `WithResult` type to walk through nested `with` clauses at
146
+ // any depth — `RelationRelations<R[K]>` reads the third type parameter
147
+ // and threads it into the next recursion step. If the target table has
148
+ // no relations of its own, the descriptor uses `{}` (the default).
117
149
  const hasRelations = Object.keys(table.relations).length > 0;
118
150
  if (hasRelations) {
151
+ lines.push(`/** Available relations for the \`${table.name}\` table */`);
152
+ lines.push(`export interface ${typeName}Relations {`);
153
+ for (const [relName, rel] of Object.entries(table.relations)) {
154
+ const targetType = entityName(rel.to);
155
+ const cardinality = rel.type === 'hasMany' ? "'many'" : "'one'";
156
+ const targetRelations = tablesWithRelations.has(rel.to) ? `${targetType}Relations` : '{}';
157
+ lines.push(` ${relName}: RelationDescriptor<${targetType}, ${cardinality}, ${targetRelations}>;`);
158
+ }
159
+ lines.push('}');
160
+ lines.push('');
161
+ // --- Legacy per-relation interfaces (kept for backward compatibility) ---
119
162
  for (const [relName, rel] of Object.entries(table.relations)) {
120
163
  const targetType = entityName(rel.to);
121
164
  if (rel.type === 'hasMany') {
@@ -142,7 +185,7 @@ function generateTypes(schema) {
142
185
  function generateMetadata(schema) {
143
186
  const lines = [
144
187
  ...generatedFileHeader(),
145
- "import type { SchemaMetadata } from '@batadata/turbine';",
188
+ "import type { SchemaMetadata } from 'turbine-orm';",
146
189
  '',
147
190
  'export const SCHEMA: SchemaMetadata = {',
148
191
  ' tables: {',
@@ -215,14 +258,55 @@ function generateIndex(schema) {
215
258
  const tableEntries = Object.values(schema.tables);
216
259
  const lines = [
217
260
  ...generatedFileHeader(),
218
- "import { TurbineClient as BaseTurbineClient, QueryInterface } from '@batadata/turbine';",
219
- "import type { TurbineConfig } from '@batadata/turbine';",
261
+ "import { TurbineClient as BaseTurbineClient, TransactionClient as BaseTransactionClient, QueryInterface } from 'turbine-orm';",
262
+ "import type { TurbineConfig, TransactionOptions } from 'turbine-orm';",
220
263
  "import { SCHEMA } from './metadata.js';",
221
264
  ];
222
- // Import all entity types
223
- const typeImports = tableEntries.map((t) => entityName(t.name));
265
+ // Import all entity types and relations maps
266
+ const typeImports = [];
267
+ for (const t of tableEntries) {
268
+ typeImports.push(entityName(t.name));
269
+ if (Object.keys(t.relations).length > 0) {
270
+ typeImports.push(`${entityName(t.name)}Relations`);
271
+ }
272
+ }
224
273
  lines.push(`import type { ${typeImports.join(', ')} } from './types.js';`);
225
274
  lines.push('');
275
+ // -------------------------------------------------------------------------
276
+ // TypedTransactionClient — same typed table accessors as TurbineClient,
277
+ // but scoped to a single transaction connection. The runtime instance is
278
+ // an ordinary `TransactionClient` from turbine-orm; this declaration just
279
+ // teaches TypeScript about the auto-attached accessors so users get
280
+ // autocomplete inside `db.$transaction(async (tx) => tx.users.create(...))`.
281
+ // -------------------------------------------------------------------------
282
+ lines.push('/**');
283
+ lines.push(' * Transaction-scoped client with the same typed table accessors as TurbineClient.');
284
+ lines.push(' * Created automatically by `db.$transaction(async (tx) => ...)` — never instantiate');
285
+ lines.push(' * directly. All queries run on a dedicated connection within a BEGIN/COMMIT block.');
286
+ lines.push(' */');
287
+ lines.push('export class TypedTransactionClient extends BaseTransactionClient {');
288
+ for (const table of tableEntries) {
289
+ const typeName = entityName(table.name);
290
+ const accessor = snakeToCamelStr(table.name);
291
+ const hasRelations = Object.keys(table.relations).length > 0;
292
+ const genericArgs = hasRelations ? `${typeName}, ${typeName}Relations` : typeName;
293
+ lines.push(` /** Query interface for the \`${table.name}\` table (transaction-scoped) */`);
294
+ lines.push(` declare readonly ${accessor}: QueryInterface<${genericArgs}>;`);
295
+ }
296
+ lines.push('}');
297
+ lines.push('');
298
+ // Augment the class with a typed `$transaction` overload via interface
299
+ // merging. This adds an additional callable signature whose callback
300
+ // parameter is narrowed to `TypedTransactionClient`, while the base
301
+ // signature (callback parameter `BaseTransactionClient`) remains valid.
302
+ lines.push('export interface TypedTransactionClient {');
303
+ lines.push(' /**');
304
+ lines.push(' * Nested transaction via SAVEPOINT. The callback receives a typed');
305
+ lines.push(' * `TypedTransactionClient` so all table accessors auto-complete.');
306
+ lines.push(' */');
307
+ lines.push(' $transaction<R>(fn: (tx: TypedTransactionClient) => Promise<R>): Promise<R>;');
308
+ lines.push('}');
309
+ lines.push('');
226
310
  // Generate the client class with JSDoc
227
311
  lines.push('/**');
228
312
  lines.push(' * Generated Turbine client with typed table accessors.');
@@ -246,8 +330,10 @@ function generateIndex(schema) {
246
330
  for (const table of tableEntries) {
247
331
  const typeName = entityName(table.name);
248
332
  const accessor = snakeToCamelStr(table.name);
333
+ const hasRelations = Object.keys(table.relations).length > 0;
334
+ const genericArgs = hasRelations ? `${typeName}, ${typeName}Relations` : typeName;
249
335
  lines.push(` /** Query interface for the \`${table.name}\` table */`);
250
- lines.push(` declare readonly ${accessor}: QueryInterface<${typeName}>;`);
336
+ lines.push(` declare readonly ${accessor}: QueryInterface<${genericArgs}>;`);
251
337
  }
252
338
  lines.push('');
253
339
  lines.push(' constructor(config?: TurbineConfig) {');
@@ -255,6 +341,22 @@ function generateIndex(schema) {
255
341
  lines.push(' }');
256
342
  lines.push('}');
257
343
  lines.push('');
344
+ // Augment TurbineClient via interface merging with a typed $transaction
345
+ // overload. The callback parameter is narrowed to `TypedTransactionClient`
346
+ // so users get autocomplete on `tx.users`, `tx.posts`, etc. The base
347
+ // signature (callback parameter `BaseTransactionClient`) remains valid as
348
+ // an overload, so prior usage continues to typecheck.
349
+ lines.push('export interface TurbineClient {');
350
+ lines.push(' /**');
351
+ lines.push(' * Run a callback inside a transaction. The callback receives a typed');
352
+ lines.push(' * `TypedTransactionClient` with autocompletion for every table accessor.');
353
+ lines.push(' */');
354
+ lines.push(' $transaction<R>(');
355
+ lines.push(' fn: (tx: TypedTransactionClient) => Promise<R>,');
356
+ lines.push(' options?: TransactionOptions,');
357
+ lines.push(' ): Promise<R>;');
358
+ lines.push('}');
359
+ lines.push('');
258
360
  // Factory function with JSDoc
259
361
  lines.push('/**');
260
362
  lines.push(' * Create a new Turbine client instance.');
@@ -296,4 +398,32 @@ function quoteIfNeeded(s) {
296
398
  function snakeToCamelStr(s) {
297
399
  return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
298
400
  }
401
+ /**
402
+ * Build the update-input field type for a column. Numeric columns become
403
+ * `T | UpdateOperatorInput<number> | null?` so users can write atomic
404
+ * operators (`{ increment: 1 }`, `{ multiply: 2 }`, etc.) without casts.
405
+ *
406
+ * The check is purely structural — if the column's TS type contains
407
+ * `'number'` (e.g. `number`, `number | null`), it's eligible. Other
408
+ * scalar types (`string`, `boolean`, `Date`, `unknown`, `Buffer`,
409
+ * `Date | null`, etc.) pass through unchanged.
410
+ */
411
+ function updateFieldType(tsType) {
412
+ // Strip parens for the regex check; preserve the original string in the output.
413
+ if (containsNumberType(tsType)) {
414
+ return `${tsType} | UpdateOperatorInput<number>`;
415
+ }
416
+ return tsType;
417
+ }
418
+ /**
419
+ * Detect whether a TypeScript type expression contains the `number` primitive
420
+ * as a top-level union member. Conservative on purpose — only matches
421
+ * `number`, `number | null`, `null | number`, etc., not `number[]` or
422
+ * `Record<string, number>`.
423
+ */
424
+ function containsNumberType(tsType) {
425
+ // Tokenize on `|` and check each member.
426
+ const parts = tsType.split('|').map((p) => p.trim());
427
+ return parts.some((p) => p === 'number');
428
+ }
299
429
  //# sourceMappingURL=generate.js.map