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.
@@ -23,6 +23,36 @@ export declare function quoteIdent(name: string): string;
23
23
  * unless `key` is an OWN enumerable/non-enumerable property.
24
24
  */
25
25
  export declare function ownLookup<T>(map: Record<string, T>, key: string): T | undefined;
26
+ /** The metadata a key needs to be resolved to a column. `TableMetadata` fits. */
27
+ export interface ColumnNameSource {
28
+ columnMap: Record<string, string>;
29
+ reverseColumnMap?: Record<string, string>;
30
+ allColumns?: string[];
31
+ }
32
+ /**
33
+ * Resolve a user-supplied key to its unquoted column name, or `undefined` when
34
+ * the key names no column on the table.
35
+ *
36
+ * THE key-resolution rule, in one place. `QueryInterface.toColumn` is this
37
+ * function plus the E003 throw, so every SQL builder resolves keys through it,
38
+ * and the value-side passes (write coercion, the `updatedAt` injector, the
39
+ * nested-write foreign-key merge) call it directly rather than re-deriving the
40
+ * rule. They used to read `columnMap` alone, which knows only the FIELD
41
+ * spelling, so a key spelled as the snake_case COLUMN, which the SQL builders
42
+ * accept and which is the natural spelling on an introspected schema, produced
43
+ * correct SQL with an unprocessed value: byte-identical statement, silently
44
+ * different bound param.
45
+ *
46
+ * The rule: the field map first, else `camelToSnake(key)` accepted ONLY when
47
+ * that name is a real column. `camelToSnake` is idempotent on an already-snake
48
+ * string, which is what makes the column spelling legal; arbitrary strings
49
+ * still fail to resolve, so identifier validation is unchanged.
50
+ *
51
+ * Prototype-safe: both maps are plain objects, so a key like "constructor" or
52
+ * "__proto__" would otherwise return an inherited member and pass for a column
53
+ * name (see {@link ownLookup}).
54
+ */
55
+ export declare function resolveColumnName(meta: ColumnNameSource, key: string): string | undefined;
26
56
  /**
27
57
  * Escape single quotes for use as string keys in json_build_object().
28
58
  * Doubles single quotes per SQL quoting rules.
@@ -11,6 +11,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
11
11
  exports.JSON_WIRE_COERCION_OIDS = exports.OPERATOR_KEYS = exports.LRUCache = void 0;
12
12
  exports.quoteIdent = quoteIdent;
13
13
  exports.ownLookup = ownLookup;
14
+ exports.resolveColumnName = resolveColumnName;
14
15
  exports.escSingleQuote = escSingleQuote;
15
16
  exports.escapeLike = escapeLike;
16
17
  exports.fnv1a64Hex = fnv1a64Hex;
@@ -54,6 +55,40 @@ function quoteIdent(name) {
54
55
  function ownLookup(map, key) {
55
56
  return Object.hasOwn(map, key) ? map[key] : undefined;
56
57
  }
58
+ /**
59
+ * Resolve a user-supplied key to its unquoted column name, or `undefined` when
60
+ * the key names no column on the table.
61
+ *
62
+ * THE key-resolution rule, in one place. `QueryInterface.toColumn` is this
63
+ * function plus the E003 throw, so every SQL builder resolves keys through it,
64
+ * and the value-side passes (write coercion, the `updatedAt` injector, the
65
+ * nested-write foreign-key merge) call it directly rather than re-deriving the
66
+ * rule. They used to read `columnMap` alone, which knows only the FIELD
67
+ * spelling, so a key spelled as the snake_case COLUMN, which the SQL builders
68
+ * accept and which is the natural spelling on an introspected schema, produced
69
+ * correct SQL with an unprocessed value: byte-identical statement, silently
70
+ * different bound param.
71
+ *
72
+ * The rule: the field map first, else `camelToSnake(key)` accepted ONLY when
73
+ * that name is a real column. `camelToSnake` is idempotent on an already-snake
74
+ * string, which is what makes the column spelling legal; arbitrary strings
75
+ * still fail to resolve, so identifier validation is unchanged.
76
+ *
77
+ * Prototype-safe: both maps are plain objects, so a key like "constructor" or
78
+ * "__proto__" would otherwise return an inherited member and pass for a column
79
+ * name (see {@link ownLookup}).
80
+ */
81
+ function resolveColumnName(meta, key) {
82
+ const mapped = ownLookup(meta.columnMap, key);
83
+ if (mapped)
84
+ return mapped;
85
+ const snake = (0, schema_js_1.camelToSnake)(key);
86
+ if (meta.reverseColumnMap && ownLookup(meta.reverseColumnMap, snake))
87
+ return snake;
88
+ if (meta.allColumns?.includes(snake))
89
+ return snake;
90
+ return undefined;
91
+ }
57
92
  /**
58
93
  * Escape single quotes for use as string keys in json_build_object().
59
94
  * Doubles single quotes per SQL quoting rules.
@@ -58,6 +58,13 @@ export declare const WARN_NS: {
58
58
  readonly unorderedPage: "unorderedPage";
59
59
  /** PowDB `emitLinks` DDL skips (name/column collision, endpoint drift). */
60
60
  readonly powdbLinks: "powdbLinks";
61
+ /**
62
+ * A key on the object passed as `TurbineConfig` that is not part of the
63
+ * config surface (client.ts `warnUnknownConfigKeys`). Keyed on the unknown
64
+ * key name, so a process that builds many clients from the same misspelled
65
+ * config says it once.
66
+ */
67
+ readonly unknownConfigKey: "unknownConfigKey";
61
68
  /**
62
69
  * `relationLoadStrategy: 'flatten'` was asked for but a relation stayed on the
63
70
  * correlated-subquery path (relations.ts `planFlattenWith`, builder.ts
@@ -99,6 +99,13 @@ exports.WARN_NS = {
99
99
  unorderedPage: 'unorderedPage',
100
100
  /** PowDB `emitLinks` DDL skips (name/column collision, endpoint drift). */
101
101
  powdbLinks: 'powdbLinks',
102
+ /**
103
+ * A key on the object passed as `TurbineConfig` that is not part of the
104
+ * config surface (client.ts `warnUnknownConfigKeys`). Keyed on the unknown
105
+ * key name, so a process that builds many clients from the same misspelled
106
+ * config says it once.
107
+ */
108
+ unknownConfigKey: 'unknownConfigKey',
102
109
  /**
103
110
  * `relationLoadStrategy: 'flatten'` was asked for but a relation stayed on the
104
111
  * correlated-subquery path (relations.ts `planFlattenWith`, builder.ts
@@ -112,7 +112,11 @@ export declare function piiFields(_qi: BuilderCtx, meta: TableMetadata): string[
112
112
  * `Date` and lands in UTC on every engine.
113
113
  *
114
114
  * An explicit value always wins, including an explicit `null`: naming the
115
- * column is a statement of intent.
115
+ * column is a statement of intent. "Named" is decided by resolving each data
116
+ * key to its COLUMN, not by matching the field spelling: the SET list resolves
117
+ * the caller's key the same way, so a key spelled as the snake_case column used
118
+ * to be missed here and the injected field assigned the same column a second
119
+ * time (`SET "updated_at" = $1, "updated_at" = $2`, PostgreSQL 42701).
116
120
  */
117
121
  export declare function applyUpdatedAtColumns(qi: BuilderCtx, data: Record<string, unknown>): Record<string, unknown>;
118
122
  export declare function writeReturningColumns(qi: BuilderCtx): ReturningSelection;
@@ -99,10 +99,14 @@ function coerceWriteValue(qi, key, value) {
99
99
  // Cheap shape check first: the common path costs one check and no lookup.
100
100
  if (!(value instanceof Date) && !Array.isArray(value))
101
101
  return value;
102
- // Non-throwing column resolution: this runs on the cache-HIT param-collect
103
- // path too, where an unknown key must not turn into a different error than
104
- // the build path already raises.
105
- const column = (0, utils_js_1.ownLookup)(qi.tableMeta.columnMap, key);
102
+ // THE key-resolution rule, shared with `toColumn` (the SQL side of this very
103
+ // statement) so the two can never disagree about which column a key names:
104
+ // resolving through `columnMap` alone missed the snake_case COLUMN spelling
105
+ // that the SQL builders accept, so those writes bound an unprocessed value
106
+ // under an identical statement. Non-throwing, because this also runs on the
107
+ // cache-HIT param-collect path, where an unknown key must not turn into a
108
+ // different error than the build path already raises.
109
+ const column = (0, utils_js_1.resolveColumnName)(qi.tableMeta, key);
106
110
  if (!column)
107
111
  return value;
108
112
  // Metadata generated by an older Turbine still carries per-column types
@@ -751,22 +755,43 @@ function piiFields(_qi, meta) {
751
755
  * `Date` and lands in UTC on every engine.
752
756
  *
753
757
  * An explicit value always wins, including an explicit `null`: naming the
754
- * column is a statement of intent.
758
+ * column is a statement of intent. "Named" is decided by resolving each data
759
+ * key to its COLUMN, not by matching the field spelling: the SET list resolves
760
+ * the caller's key the same way, so a key spelled as the snake_case column used
761
+ * to be missed here and the injected field assigned the same column a second
762
+ * time (`SET "updated_at" = $1, "updated_at" = $2`, PostgreSQL 42701).
755
763
  */
756
764
  function applyUpdatedAtColumns(qi, data) {
757
765
  const tagged = qi.tableMeta.columns.filter((c) => c.updatedAt);
758
766
  if (tagged.length === 0)
759
767
  return data;
768
+ const named = namedColumns(qi.tableMeta, data);
760
769
  let out = null;
761
770
  const now = new Date();
762
771
  for (const col of tagged) {
763
- if (Object.hasOwn(data, col.field) && data[col.field] !== undefined)
772
+ if (named.has(col.name))
764
773
  continue;
765
774
  out ??= { ...data };
766
775
  out[col.field] = now;
767
776
  }
768
777
  return out ?? data;
769
778
  }
779
+ /**
780
+ * The set of COLUMNS a `data` object names, under any accepted spelling of each
781
+ * key. A key set to `undefined` names nothing (see {@link definedKeys}), and a
782
+ * key that resolves to no column is left to the SQL builder's own E003.
783
+ */
784
+ function namedColumns(meta, data) {
785
+ const out = new Set();
786
+ for (const key of Object.keys(data)) {
787
+ if (data[key] === undefined)
788
+ continue;
789
+ const column = (0, utils_js_1.resolveColumnName)(meta, key);
790
+ if (column)
791
+ out.add(column);
792
+ }
793
+ return out;
794
+ }
770
795
  function writeReturningColumns(qi) {
771
796
  const piiCols = piiColumns(qi, qi.tableMeta);
772
797
  if (piiCols.size === 0)
package/dist/client.js CHANGED
@@ -27,7 +27,8 @@ import { setErrorMessageMode, TimeoutError, UnsupportedFeatureError, ValidationE
27
27
  import { ObserveEngine } from './observe.js';
28
28
  import { executePipeline, pipelineSupported } from './pipeline.js';
29
29
  import { QueryInterface, } from './query/index.js';
30
- import { quoteIdent } from './query/utils.js';
30
+ import { closestName, quoteIdent } from './query/utils.js';
31
+ import { shouldWarnOnce, WARN_NS } from './query/warn-registry.js';
31
32
  import { createSubscription, validateChannel, } from './realtime.js';
32
33
  import { buildTypedSql, TypedSqlQuery } from './typed-sql.js';
33
34
  export async function withRetry(fn, options) {
@@ -54,6 +55,154 @@ export async function withRetry(fn, options) {
54
55
  }
55
56
  throw lastError;
56
57
  }
58
+ // ---------------------------------------------------------------------------
59
+ // Unknown-config-key diagnostics
60
+ // ---------------------------------------------------------------------------
61
+ /**
62
+ * Every `TurbineConfig` key, as runtime data. TypeScript erases the interface,
63
+ * so the key set has to exist as a value; `Record<keyof TurbineConfig, true>`
64
+ * makes the compiler own it in BOTH directions: a field added to the interface
65
+ * fails typecheck until it is listed here, and a key listed here that is not a
66
+ * field fails as an excess property. So this can never drift into warning about
67
+ * a real option.
68
+ */
69
+ const TURBINE_CONFIG_KEYS = {
70
+ pool: true,
71
+ connectionString: true,
72
+ host: true,
73
+ port: true,
74
+ database: true,
75
+ user: true,
76
+ password: true,
77
+ ssl: true,
78
+ poolSize: true,
79
+ idleTimeoutMs: true,
80
+ connectionTimeoutMs: true,
81
+ max: true,
82
+ idleTimeoutMillis: true,
83
+ connectionTimeoutMillis: true,
84
+ logging: true,
85
+ defaultLimit: true,
86
+ warnOnUnlimited: true,
87
+ utcTimestamps: true,
88
+ scopedConnect: true,
89
+ relationLoadStrategy: true,
90
+ stableRelationOrder: true,
91
+ implicitPkOrdering: true,
92
+ autoToOneJoinMaxRows: true,
93
+ autoRoundTripMs: true,
94
+ jsonEncoding: true,
95
+ errorMessages: true,
96
+ logQueryParams: true,
97
+ preparedStatements: true,
98
+ sqlCache: true,
99
+ sqlCacheSize: true,
100
+ dialect: true,
101
+ replicas: true,
102
+ globalFilters: true,
103
+ };
104
+ /**
105
+ * {@link TURBINE_CONFIG_KEYS} as a lookup. A `Set` rather than an `in` test on
106
+ * the record, so an inherited `Object.prototype` name (`toString`, `constructor`)
107
+ * is treated as the unknown key it is.
108
+ */
109
+ const CONFIG_KEY_SET = new Set(Object.keys(TURBINE_CONFIG_KEYS));
110
+ /**
111
+ * Keys that are legitimately present on a config object but are not public
112
+ * `TurbineConfig` fields:
113
+ *
114
+ * - `queryInterfaceFactory`: the non-SQL-backend seam. `turbinePowDB` sets it
115
+ * through a cast so `table()` builds a `PowqlInterface`; it is `@internal`,
116
+ * deliberately absent from the public interface, and must not warn.
117
+ * - `schema`: `turbine.config.*` files carry a Postgres schema NAME for the
118
+ * CLI, and that same object is routinely spread into the client factory.
119
+ * The client ignores it (its schema metadata is the second argument), and
120
+ * shouting about a documented CLI field would be pure noise.
121
+ * - `url`: the connection-string spelling used by the CLI config and by the
122
+ * engine factories' first argument. Same story as `schema`.
123
+ */
124
+ const NON_CONFIG_KEYS = new Set(['queryInterfaceFactory', 'schema', 'url']);
125
+ /** camelCase name → its lowercased words (`logQueryParams` → log, query, params). */
126
+ function camelWords(name) {
127
+ return name
128
+ .split(/(?=[A-Z])/)
129
+ .map((w) => w.toLowerCase())
130
+ .filter(Boolean);
131
+ }
132
+ /**
133
+ * The real config key `key` most likely meant, or null when nothing is close.
134
+ *
135
+ * {@link closestName} (the same helper the unknown-COLUMN message uses) decides
136
+ * first, so both diagnostics rank near-misses identically. It is bounded by edit
137
+ * distance, which covers typos but not the miss this warning exists for: a
138
+ * guessed name that omits a whole word. `logParams` is five edits from
139
+ * `logQueryParams`, past the bound, yet it names the same words in the same
140
+ * order, so a second pass accepts a candidate whose camelCase words CONTAIN the
141
+ * guess's words in order, preferring the one that adds fewest words.
142
+ */
143
+ function suggestConfigKey(key) {
144
+ const direct = closestName(key, CONFIG_KEY_SET);
145
+ if (direct)
146
+ return direct;
147
+ const wanted = camelWords(key);
148
+ if (wanted.length < 2)
149
+ return null;
150
+ let best = null;
151
+ let bestExtra = Number.POSITIVE_INFINITY;
152
+ for (const candidate of CONFIG_KEY_SET) {
153
+ const words = camelWords(candidate);
154
+ if (words.length <= wanted.length)
155
+ continue;
156
+ let i = 0;
157
+ for (const w of words)
158
+ if (w === wanted[i])
159
+ i++;
160
+ if (i !== wanted.length)
161
+ continue;
162
+ const extra = words.length - wanted.length;
163
+ if (extra < bestExtra) {
164
+ bestExtra = extra;
165
+ best = candidate;
166
+ }
167
+ }
168
+ return best;
169
+ }
170
+ /**
171
+ * Dev-mode notice for a key on the config object that is not part of the config
172
+ * surface.
173
+ *
174
+ * An unknown key is silently ignored (JavaScript objects have no schema), which
175
+ * makes a typo or a wrong guess indistinguishable from a broken feature: a
176
+ * caller who wants query parameters in `$on('query')` events and reaches for a
177
+ * plausible-sounding `logParams` sees nothing happen and concludes the feature
178
+ * does not work, rather than that the option is spelled `logQueryParams`.
179
+ *
180
+ * Deliberately a warning, never an error. An app compiled against a NEWER
181
+ * turbine that passes a key this version has not heard of must keep running,
182
+ * and the whole check is wrapped so that a hostile / exotic config object
183
+ * (a Proxy whose `ownKeys` throws) cannot take down the constructor either.
184
+ * Dev-only (`NODE_ENV !== 'production'`) and once per key per process, like the
185
+ * other advisory diagnostics.
186
+ */
187
+ function warnUnknownConfigKeys(config) {
188
+ if (process.env.NODE_ENV === 'production')
189
+ return;
190
+ try {
191
+ for (const key of Object.keys(config)) {
192
+ if (CONFIG_KEY_SET.has(key) || NON_CONFIG_KEYS.has(key))
193
+ continue;
194
+ if (!shouldWarnOnce(WARN_NS.unknownConfigKey, key))
195
+ continue;
196
+ const suggestion = suggestConfigKey(key);
197
+ console.warn(`[turbine] Unknown option "${key}" in the config passed to TurbineClient, it is ignored.` +
198
+ (suggestion ? ` Did you mean "${suggestion}"?` : ''));
199
+ }
200
+ }
201
+ catch {
202
+ // Key enumeration is the only thing that can fail here, and a diagnostic
203
+ // must never be the reason a client fails to construct.
204
+ }
205
+ }
57
206
  /** Maps isolation level names to SQL */
58
207
  const ISOLATION_LEVELS = {
59
208
  ReadUncommitted: 'READ UNCOMMITTED',
@@ -378,6 +527,9 @@ export class TurbineClient {
378
527
  }
379
528
  break;
380
529
  }
530
+ // Name any key on the config object that is not part of the config surface
531
+ // (dev only, once per key, never throws). See warnUnknownConfigKeys.
532
+ warnUnknownConfigKeys(config);
381
533
  /**
382
534
  * Parse int8 (bigint, OID 20) as JavaScript number instead of string.
383
535
  * Safe for values up to Number.MAX_SAFE_INTEGER (9,007,199,254,740,991).
@@ -12,6 +12,7 @@
12
12
  * `NestedWriteContext`.
13
13
  */
14
14
  import { CircularRelationError, describeTargetForMessage, NotFoundError, RelationError, UnsupportedFeatureError, ValidationError, } from './errors.js';
15
+ import { resolveColumnName } from './query/utils.js';
15
16
  import { normalizeKeyColumns } from './schema.js';
16
17
  const MAX_DEPTH = 10;
17
18
  const CREATE_ONLY_OPS = new Set(['create', 'connect', 'connectOrCreate']);
@@ -75,10 +76,50 @@ export function injectForeignKey(childData, relation, parentRow, schema) {
75
76
  const refCol = refs[i];
76
77
  const refField = schema.tables[relation.from]?.reverseColumnMap[refCol] ?? refCol;
77
78
  const fkField = childTable?.reverseColumnMap[fkCol] ?? fkCol;
78
- result[fkField] = parentRow[refField];
79
+ assignByColumn(result, childTable, fkField, parentRow[refField]);
79
80
  }
80
81
  return result;
81
82
  }
83
+ /**
84
+ * Set `field` on `target`, first dropping any OTHER key that names the SAME
85
+ * column.
86
+ *
87
+ * The engine always writes the canonical FIELD spelling, while a caller's own
88
+ * `data` may legally spell the same column its snake_case way (the write
89
+ * builders resolve both). Overwriting only the identical key left both in the
90
+ * object, and the INSERT/UPDATE then named one column twice, which PostgreSQL
91
+ * refuses (42701 "specified more than once"). Dropping the alias makes the
92
+ * column spelling behave exactly as the field spelling always did: the value the
93
+ * relation dictates wins.
94
+ *
95
+ * Only SCALAR keys are droppable. A relation is resolved to a column by the very
96
+ * same rule (nothing stops a schema from naming a relation the way a column is
97
+ * spelled), but a relation key carries a nested write rather than a value, so
98
+ * dropping it would discard the whole operation silently, a strictly worse
99
+ * outcome than the duplicate-column error this drop exists to prevent. The
100
+ * relation-shape test matches {@link splitData}, so a key routed to `relations`
101
+ * there is never treated as an alias here.
102
+ */
103
+ function assignByColumn(target, meta, field, value) {
104
+ const column = meta && resolveColumnName(meta, field);
105
+ if (column) {
106
+ for (const key of Object.keys(target)) {
107
+ if (key === field || isRelationEntry(meta, key, target[key]))
108
+ continue;
109
+ if (resolveColumnName(meta, key) === column)
110
+ delete target[key];
111
+ }
112
+ }
113
+ target[field] = value;
114
+ }
115
+ /** Does `key` name a relation on `meta` AND carry a nested-write payload? */
116
+ function isRelationEntry(meta, key, value) {
117
+ return (Object.hasOwn(meta.relations, key) &&
118
+ value !== null &&
119
+ typeof value === 'object' &&
120
+ !Array.isArray(value) &&
121
+ !(value instanceof Date));
122
+ }
82
123
  /**
83
124
  * Split rows destined for `createMany` into CONTIGUOUS runs that each name the
84
125
  * same fields.
@@ -741,10 +782,15 @@ export async function executeNestedCreate(ctx, tableName, data, depth = 0, path
741
782
  assertManyToManyOpsSupported(relName, rel, ops);
742
783
  }
743
784
  }
744
- // Insert the parent row (scalars + resolved belongsTo foreign keys)
745
- const parentRow = (await ctx.tx.table(tableName).create({
746
- data: { ...scalars, ...belongsToFks },
747
- }));
785
+ // Insert the parent row (scalars + resolved belongsTo foreign keys). The
786
+ // resolved keys win over a caller-supplied value for the same column under
787
+ // either spelling (see assignByColumn), so `{ authorId: 1, author: { connect } }`
788
+ // and `{ author_id: 1, author: { connect } }` both take the connected row.
789
+ const parentData = { ...scalars };
790
+ for (const [field, value] of Object.entries(belongsToFks)) {
791
+ assignByColumn(parentData, tableMeta, field, value);
792
+ }
793
+ const parentRow = (await ctx.tx.table(tableName).create({ data: parentData }));
748
794
  // Process hasMany / hasOne relations, their FK lives on the CHILD, so they
749
795
  // need the parent row to exist first.
750
796
  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
  }