turbine-orm 0.52.0 → 0.53.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.
@@ -35,6 +35,7 @@ const observe_js_1 = require("./observe.js");
35
35
  const pipeline_js_1 = require("./pipeline.js");
36
36
  const index_js_1 = require("./query/index.js");
37
37
  const utils_js_1 = require("./query/utils.js");
38
+ const warn_registry_js_1 = require("./query/warn-registry.js");
38
39
  const realtime_js_1 = require("./realtime.js");
39
40
  const typed_sql_js_1 = require("./typed-sql.js");
40
41
  async function withRetry(fn, options) {
@@ -61,6 +62,154 @@ async function withRetry(fn, options) {
61
62
  }
62
63
  throw lastError;
63
64
  }
65
+ // ---------------------------------------------------------------------------
66
+ // Unknown-config-key diagnostics
67
+ // ---------------------------------------------------------------------------
68
+ /**
69
+ * Every `TurbineConfig` key, as runtime data. TypeScript erases the interface,
70
+ * so the key set has to exist as a value; `Record<keyof TurbineConfig, true>`
71
+ * makes the compiler own it in BOTH directions: a field added to the interface
72
+ * fails typecheck until it is listed here, and a key listed here that is not a
73
+ * field fails as an excess property. So this can never drift into warning about
74
+ * a real option.
75
+ */
76
+ const TURBINE_CONFIG_KEYS = {
77
+ pool: true,
78
+ connectionString: true,
79
+ host: true,
80
+ port: true,
81
+ database: true,
82
+ user: true,
83
+ password: true,
84
+ ssl: true,
85
+ poolSize: true,
86
+ idleTimeoutMs: true,
87
+ connectionTimeoutMs: true,
88
+ max: true,
89
+ idleTimeoutMillis: true,
90
+ connectionTimeoutMillis: true,
91
+ logging: true,
92
+ defaultLimit: true,
93
+ warnOnUnlimited: true,
94
+ utcTimestamps: true,
95
+ scopedConnect: true,
96
+ relationLoadStrategy: true,
97
+ stableRelationOrder: true,
98
+ implicitPkOrdering: true,
99
+ autoToOneJoinMaxRows: true,
100
+ autoRoundTripMs: true,
101
+ jsonEncoding: true,
102
+ errorMessages: true,
103
+ logQueryParams: true,
104
+ preparedStatements: true,
105
+ sqlCache: true,
106
+ sqlCacheSize: true,
107
+ dialect: true,
108
+ replicas: true,
109
+ globalFilters: true,
110
+ };
111
+ /**
112
+ * {@link TURBINE_CONFIG_KEYS} as a lookup. A `Set` rather than an `in` test on
113
+ * the record, so an inherited `Object.prototype` name (`toString`, `constructor`)
114
+ * is treated as the unknown key it is.
115
+ */
116
+ const CONFIG_KEY_SET = new Set(Object.keys(TURBINE_CONFIG_KEYS));
117
+ /**
118
+ * Keys that are legitimately present on a config object but are not public
119
+ * `TurbineConfig` fields:
120
+ *
121
+ * - `queryInterfaceFactory`: the non-SQL-backend seam. `turbinePowDB` sets it
122
+ * through a cast so `table()` builds a `PowqlInterface`; it is `@internal`,
123
+ * deliberately absent from the public interface, and must not warn.
124
+ * - `schema`: `turbine.config.*` files carry a Postgres schema NAME for the
125
+ * CLI, and that same object is routinely spread into the client factory.
126
+ * The client ignores it (its schema metadata is the second argument), and
127
+ * shouting about a documented CLI field would be pure noise.
128
+ * - `url`: the connection-string spelling used by the CLI config and by the
129
+ * engine factories' first argument. Same story as `schema`.
130
+ */
131
+ const NON_CONFIG_KEYS = new Set(['queryInterfaceFactory', 'schema', 'url']);
132
+ /** camelCase name → its lowercased words (`logQueryParams` → log, query, params). */
133
+ function camelWords(name) {
134
+ return name
135
+ .split(/(?=[A-Z])/)
136
+ .map((w) => w.toLowerCase())
137
+ .filter(Boolean);
138
+ }
139
+ /**
140
+ * The real config key `key` most likely meant, or null when nothing is close.
141
+ *
142
+ * {@link closestName} (the same helper the unknown-COLUMN message uses) decides
143
+ * first, so both diagnostics rank near-misses identically. It is bounded by edit
144
+ * distance, which covers typos but not the miss this warning exists for: a
145
+ * guessed name that omits a whole word. `logParams` is five edits from
146
+ * `logQueryParams`, past the bound, yet it names the same words in the same
147
+ * order, so a second pass accepts a candidate whose camelCase words CONTAIN the
148
+ * guess's words in order, preferring the one that adds fewest words.
149
+ */
150
+ function suggestConfigKey(key) {
151
+ const direct = (0, utils_js_1.closestName)(key, CONFIG_KEY_SET);
152
+ if (direct)
153
+ return direct;
154
+ const wanted = camelWords(key);
155
+ if (wanted.length < 2)
156
+ return null;
157
+ let best = null;
158
+ let bestExtra = Number.POSITIVE_INFINITY;
159
+ for (const candidate of CONFIG_KEY_SET) {
160
+ const words = camelWords(candidate);
161
+ if (words.length <= wanted.length)
162
+ continue;
163
+ let i = 0;
164
+ for (const w of words)
165
+ if (w === wanted[i])
166
+ i++;
167
+ if (i !== wanted.length)
168
+ continue;
169
+ const extra = words.length - wanted.length;
170
+ if (extra < bestExtra) {
171
+ bestExtra = extra;
172
+ best = candidate;
173
+ }
174
+ }
175
+ return best;
176
+ }
177
+ /**
178
+ * Dev-mode notice for a key on the config object that is not part of the config
179
+ * surface.
180
+ *
181
+ * An unknown key is silently ignored (JavaScript objects have no schema), which
182
+ * makes a typo or a wrong guess indistinguishable from a broken feature: a
183
+ * caller who wants query parameters in `$on('query')` events and reaches for a
184
+ * plausible-sounding `logParams` sees nothing happen and concludes the feature
185
+ * does not work, rather than that the option is spelled `logQueryParams`.
186
+ *
187
+ * Deliberately a warning, never an error. An app compiled against a NEWER
188
+ * turbine that passes a key this version has not heard of must keep running,
189
+ * and the whole check is wrapped so that a hostile / exotic config object
190
+ * (a Proxy whose `ownKeys` throws) cannot take down the constructor either.
191
+ * Dev-only (`NODE_ENV !== 'production'`) and once per key per process, like the
192
+ * other advisory diagnostics.
193
+ */
194
+ function warnUnknownConfigKeys(config) {
195
+ if (process.env.NODE_ENV === 'production')
196
+ return;
197
+ try {
198
+ for (const key of Object.keys(config)) {
199
+ if (CONFIG_KEY_SET.has(key) || NON_CONFIG_KEYS.has(key))
200
+ continue;
201
+ if (!(0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.unknownConfigKey, key))
202
+ continue;
203
+ const suggestion = suggestConfigKey(key);
204
+ console.warn(`[turbine] Unknown option "${key}" in the config passed to TurbineClient, it is ignored.` +
205
+ (suggestion ? ` Did you mean "${suggestion}"?` : ''));
206
+ }
207
+ }
208
+ catch {
209
+ // Key enumeration is the only thing that can fail here, and a diagnostic
210
+ // must never be the reason a client fails to construct.
211
+ }
212
+ }
64
213
  /** Maps isolation level names to SQL */
65
214
  const ISOLATION_LEVELS = {
66
215
  ReadUncommitted: 'READ UNCOMMITTED',
@@ -386,6 +535,9 @@ class TurbineClient {
386
535
  }
387
536
  break;
388
537
  }
538
+ // Name any key on the config object that is not part of the config surface
539
+ // (dev only, once per key, never throws). See warnUnknownConfigKeys.
540
+ warnUnknownConfigKeys(config);
389
541
  /**
390
542
  * Parse int8 (bigint, OID 20) as JavaScript number instead of string.
391
543
  * Safe for values up to Number.MAX_SAFE_INTEGER (9,007,199,254,740,991).
@@ -20,6 +20,7 @@ exports.createManyShapeRuns = createManyShapeRuns;
20
20
  exports.executeNestedCreate = executeNestedCreate;
21
21
  exports.executeNestedUpdate = executeNestedUpdate;
22
22
  const errors_js_1 = require("./errors.js");
23
+ const utils_js_1 = require("./query/utils.js");
23
24
  const schema_js_1 = require("./schema.js");
24
25
  const MAX_DEPTH = 10;
25
26
  const CREATE_ONLY_OPS = new Set(['create', 'connect', 'connectOrCreate']);
@@ -83,10 +84,50 @@ function injectForeignKey(childData, relation, parentRow, schema) {
83
84
  const refCol = refs[i];
84
85
  const refField = schema.tables[relation.from]?.reverseColumnMap[refCol] ?? refCol;
85
86
  const fkField = childTable?.reverseColumnMap[fkCol] ?? fkCol;
86
- result[fkField] = parentRow[refField];
87
+ assignByColumn(result, childTable, fkField, parentRow[refField]);
87
88
  }
88
89
  return result;
89
90
  }
91
+ /**
92
+ * Set `field` on `target`, first dropping any OTHER key that names the SAME
93
+ * column.
94
+ *
95
+ * The engine always writes the canonical FIELD spelling, while a caller's own
96
+ * `data` may legally spell the same column its snake_case way (the write
97
+ * builders resolve both). Overwriting only the identical key left both in the
98
+ * object, and the INSERT/UPDATE then named one column twice, which PostgreSQL
99
+ * refuses (42701 "specified more than once"). Dropping the alias makes the
100
+ * column spelling behave exactly as the field spelling always did: the value the
101
+ * relation dictates wins.
102
+ *
103
+ * Only SCALAR keys are droppable. A relation is resolved to a column by the very
104
+ * same rule (nothing stops a schema from naming a relation the way a column is
105
+ * spelled), but a relation key carries a nested write rather than a value, so
106
+ * dropping it would discard the whole operation silently, a strictly worse
107
+ * outcome than the duplicate-column error this drop exists to prevent. The
108
+ * relation-shape test matches {@link splitData}, so a key routed to `relations`
109
+ * there is never treated as an alias here.
110
+ */
111
+ function assignByColumn(target, meta, field, value) {
112
+ const column = meta && (0, utils_js_1.resolveColumnName)(meta, field);
113
+ if (column) {
114
+ for (const key of Object.keys(target)) {
115
+ if (key === field || isRelationEntry(meta, key, target[key]))
116
+ continue;
117
+ if ((0, utils_js_1.resolveColumnName)(meta, key) === column)
118
+ delete target[key];
119
+ }
120
+ }
121
+ target[field] = value;
122
+ }
123
+ /** Does `key` name a relation on `meta` AND carry a nested-write payload? */
124
+ function isRelationEntry(meta, key, value) {
125
+ return (Object.hasOwn(meta.relations, key) &&
126
+ value !== null &&
127
+ typeof value === 'object' &&
128
+ !Array.isArray(value) &&
129
+ !(value instanceof Date));
130
+ }
90
131
  /**
91
132
  * Split rows destined for `createMany` into CONTIGUOUS runs that each name the
92
133
  * same fields.
@@ -749,10 +790,15 @@ async function executeNestedCreate(ctx, tableName, data, depth = 0, path = []) {
749
790
  assertManyToManyOpsSupported(relName, rel, ops);
750
791
  }
751
792
  }
752
- // Insert the parent row (scalars + resolved belongsTo foreign keys)
753
- const parentRow = (await ctx.tx.table(tableName).create({
754
- data: { ...scalars, ...belongsToFks },
755
- }));
793
+ // Insert the parent row (scalars + resolved belongsTo foreign keys). The
794
+ // resolved keys win over a caller-supplied value for the same column under
795
+ // either spelling (see assignByColumn), so `{ authorId: 1, author: { connect } }`
796
+ // and `{ author_id: 1, author: { connect } }` both take the connected row.
797
+ const parentData = { ...scalars };
798
+ for (const [field, value] of Object.entries(belongsToFks)) {
799
+ assignByColumn(parentData, tableMeta, field, value);
800
+ }
801
+ const parentRow = (await ctx.tx.table(tableName).create({ data: parentData }));
756
802
  // Process hasMany / hasOne relations, their FK lives on the CHILD, so they
757
803
  // need the parent row to exist first.
758
804
  for (const [relName, ops] of Object.entries(relations)) {
@@ -37,7 +37,15 @@
37
37
  * These cannot be faithfully translated and are not attempted; each throws or is
38
38
  * documented rather than silently returning wrong data:
39
39
  *
40
- * - `$extends` / client extensions, `$use` with Prisma's middleware param shape.
40
+ * - **`$extends` beyond `client` + `model`.** Client extensions ARE supported for
41
+ * those two components (plus the `Prisma.defineExtension` callback form), and
42
+ * return a new client whose delegates, `$transaction` and raw surface all
43
+ * survive. The `query` (interception) and `result` (computed fields)
44
+ * components, and any component this adapter does not recognize, throw an
45
+ * {@link UnsupportedFeatureError} naming the component AT `$extends` TIME
46
+ * rather than being accepted and quietly not applied.
47
+ * - `$use` with Prisma's middleware param shape (Turbine's own `client.$use` is
48
+ * the supported interception seam).
41
49
  * - `instanceof PrismaClientKnownRequestError`, `.meta`/message byte parity
42
50
  * (opt into `prismaErrorCodes` for a `.code` like `P2002`, without pretending
43
51
  * `instanceof` identity).
@@ -197,6 +205,19 @@ export declare const Prisma: {
197
205
  raw(sql: string): Sql;
198
206
  /** An empty fragment. */
199
207
  empty: Sql;
208
+ /**
209
+ * The extension context of `this` inside a client / model extension method.
210
+ * Turbine binds extension members directly onto the client and delegate
211
+ * objects, so the context IS `this`; the identity function exists so migrated
212
+ * `Prisma.getExtensionContext(this).$name` call sites keep working.
213
+ */
214
+ getExtensionContext<T>(that: T): T;
215
+ /**
216
+ * Type-preserving passthrough for `Prisma.defineExtension(ext)`. Prisma uses
217
+ * it purely for inference; the value is returned unchanged, so both the object
218
+ * and the callback form reach `$extends` intact.
219
+ */
220
+ defineExtension<E>(ext: E): E;
200
221
  };
201
222
  /** Options for {@link createPrismaCompatClient}. */
202
223
  export interface PrismaCompatOptions {
@@ -269,12 +290,69 @@ export interface PrismaCompatRawSurface {
269
290
  $executeRaw(strings: TemplateStringsArray, ...values: unknown[]): Promise<number>;
270
291
  $executeRawUnsafe(sql: string, ...params: unknown[]): Promise<number>;
271
292
  }
272
- /** The client-level surface (`$transaction` / raw), added to the model map. */
293
+ /**
294
+ * A Prisma client extension, restricted to the two components this adapter can
295
+ * honour faithfully.
296
+ *
297
+ * `client` members land on the returned client; `model` members land on the
298
+ * named model's delegate (under BOTH spellings), with `$allModels` applying to
299
+ * every delegate. The `query` and `result` components are declared `never` so
300
+ * passing one is a compile error, and {@link PrismaCompatClient.$extends} also
301
+ * refuses them at runtime with an {@link UnsupportedFeatureError} naming the
302
+ * component: an extension that was accepted and then quietly not applied would
303
+ * be far worse than one that is refused.
304
+ */
305
+ export interface PrismaCompatExtension {
306
+ /** Optional extension name, accepted and otherwise unused (as in Prisma). */
307
+ name?: string;
308
+ /** Extra client-level members, e.g. `{ $healthCheck() { … } }`. */
309
+ client?: Record<string, unknown>;
310
+ /** Extra delegate members per Prisma model name, plus `$allModels`. */
311
+ model?: Record<string, Record<string, unknown>>;
312
+ /** Not supported, see {@link PrismaCompatExtension}. */
313
+ query?: never;
314
+ /** Not supported, see {@link PrismaCompatExtension}. */
315
+ result?: never;
316
+ /** Any other component is refused at runtime by name. */
317
+ [component: string]: unknown;
318
+ }
319
+ /** Members an extension contributes to the delegate for Prisma model `K`. */
320
+ type ModelMembersOf<M, K extends string> = (K extends keyof M ? M[K] : unknown) & (Uncapitalize<K> extends keyof M ? M[Uncapitalize<K>] : unknown) & ('$allModels' extends keyof M ? M['$allModels'] : unknown);
321
+ type ExtraModelMembers<E, K extends string> = E extends {
322
+ model: infer M;
323
+ } ? ModelMembersOf<M, K> : unknown;
324
+ /**
325
+ * The client {@link PrismaCompatClient.$extends} returns: the same surface with
326
+ * the extension's `client` members on the client and its `model` members on
327
+ * every matching delegate (both spellings).
328
+ */
329
+ export type PrismaCompatExtendedClient<S extends Record<string, PrismaModelTypes>, E> = {
330
+ [K in keyof S]: PrismaModelDelegate<S[K]> & ExtraModelMembers<E, K & string>;
331
+ } & {
332
+ [K in keyof S as Uncapitalize<K & string>]: PrismaModelDelegate<S[K]> & ExtraModelMembers<E, K & string>;
333
+ } & PrismaCompatClientBase<S> & (E extends {
334
+ client: infer C;
335
+ } ? C : unknown);
336
+ /** The client-level surface (`$transaction` / raw / `$extends`), added to the model map. */
273
337
  export interface PrismaCompatClientBase<S extends Record<string, PrismaModelTypes> = Record<string, PrismaModelTypes>> extends PrismaCompatRawSurface {
274
338
  $transaction<R>(fn: (tx: PrismaCompatTransactionClient<S>) => Promise<R>, options?: PrismaCompatTxOptions): Promise<R>;
275
339
  $transaction<P extends readonly PromiseLike<unknown>[]>(promises: readonly [...P]): Promise<{
276
340
  [K in keyof P]: Awaited<P[K]>;
277
341
  }>;
342
+ /**
343
+ * Prisma's callback form (`Prisma.defineExtension((client) => …)`): the
344
+ * function is called with this client and its return value is the result,
345
+ * exactly as in Prisma. Declared first so a function argument never matches
346
+ * the all-optional object overload below.
347
+ */
348
+ $extends<R>(extension: (client: PrismaCompatClient<S>) => R): R;
349
+ /**
350
+ * Extend the client with a {@link PrismaCompatExtension}. Returns a NEW client
351
+ * (this one is untouched) carrying the extension's `client` and `model`
352
+ * members; the returned client is itself extendable. `query` and `result`
353
+ * extensions throw, see {@link PrismaCompatExtension}.
354
+ */
355
+ $extends<E extends PrismaCompatExtension>(extension: E): PrismaCompatExtendedClient<S, E>;
278
356
  $connect(): Promise<void>;
279
357
  $disconnect(): Promise<void>;
280
358
  }