@prisma-next/sql-orm-client 0.5.0-dev.9 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,35 +1,36 @@
1
1
  {
2
2
  "name": "@prisma-next/sql-orm-client",
3
- "version": "0.5.0-dev.9",
3
+ "version": "0.5.0",
4
+ "license": "Apache-2.0",
4
5
  "type": "module",
5
6
  "sideEffects": false,
6
7
  "description": "ORM client for Prisma Next — fluent, type-safe model collections",
7
8
  "dependencies": {
8
- "@prisma-next/contract": "0.5.0-dev.9",
9
- "@prisma-next/operations": "0.5.0-dev.9",
10
- "@prisma-next/sql-operations": "0.5.0-dev.9",
11
- "@prisma-next/sql-contract": "0.5.0-dev.9",
12
- "@prisma-next/framework-components": "0.5.0-dev.9",
13
- "@prisma-next/sql-relational-core": "0.5.0-dev.9",
14
- "@prisma-next/sql-runtime": "0.5.0-dev.9",
15
- "@prisma-next/utils": "0.5.0-dev.9"
9
+ "@prisma-next/contract": "0.5.0",
10
+ "@prisma-next/framework-components": "0.5.0",
11
+ "@prisma-next/operations": "0.5.0",
12
+ "@prisma-next/sql-contract": "0.5.0",
13
+ "@prisma-next/sql-operations": "0.5.0",
14
+ "@prisma-next/sql-relational-core": "0.5.0",
15
+ "@prisma-next/sql-runtime": "0.5.0",
16
+ "@prisma-next/utils": "0.5.0"
16
17
  },
17
18
  "devDependencies": {
18
- "@types/pg": "8.16.0",
19
- "pg": "8.16.3",
20
- "tsdown": "0.18.4",
19
+ "@types/pg": "8.20.0",
20
+ "pg": "8.20.0",
21
+ "tsdown": "0.22.0",
21
22
  "typescript": "5.9.3",
22
- "vitest": "4.0.17",
23
- "@prisma-next/adapter-postgres": "0.5.0-dev.9",
24
- "@prisma-next/driver-postgres": "0.5.0-dev.9",
25
- "@prisma-next/extension-pgvector": "0.5.0-dev.9",
26
- "@prisma-next/family-sql": "0.5.0-dev.9",
27
- "@prisma-next/ids": "0.5.0-dev.9",
28
- "@prisma-next/cli": "0.5.0-dev.9",
29
- "@prisma-next/sql-contract-ts": "0.5.0-dev.9",
30
- "@prisma-next/target-postgres": "0.5.0-dev.9",
31
- "@prisma-next/tsconfig": "0.0.0",
23
+ "vitest": "4.1.5",
24
+ "@prisma-next/driver-postgres": "0.5.0",
25
+ "@prisma-next/adapter-postgres": "0.5.0",
26
+ "@prisma-next/cli": "0.5.0",
27
+ "@prisma-next/extension-pgvector": "0.5.0",
28
+ "@prisma-next/ids": "0.5.0",
29
+ "@prisma-next/family-sql": "0.5.0",
30
+ "@prisma-next/sql-contract-ts": "0.5.0",
31
+ "@prisma-next/target-postgres": "0.5.0",
32
32
  "@prisma-next/test-utils": "0.0.1",
33
+ "@prisma-next/tsconfig": "0.0.0",
33
34
  "@prisma-next/tsdown": "0.0.0"
34
35
  },
35
36
  "files": [
@@ -43,8 +44,6 @@
43
44
  ".": "./dist/index.mjs",
44
45
  "./package.json": "./package.json"
45
46
  },
46
- "main": "./dist/index.mjs",
47
- "module": "./dist/index.mjs",
48
47
  "types": "./dist/index.d.mts",
49
48
  "repository": {
50
49
  "type": "git",
@@ -322,6 +322,25 @@ export function resolvePrimaryKeyColumn(contract: Contract<SqlStorage>, tableNam
322
322
  return contract.storage.tables[tableName]?.primaryKey?.columns[0] ?? 'id';
323
323
  }
324
324
 
325
+ export function resolveRowIdentityColumns(
326
+ contract: Contract<SqlStorage>,
327
+ tableName: string,
328
+ ): readonly string[] {
329
+ const table = contract.storage.tables[tableName];
330
+ if (!table) {
331
+ return [];
332
+ }
333
+ if (table.primaryKey && table.primaryKey.columns.length > 0) {
334
+ return table.primaryKey.columns;
335
+ }
336
+ for (const unique of table.uniques) {
337
+ if (unique.columns.length > 0) {
338
+ return unique.columns;
339
+ }
340
+ }
341
+ return [];
342
+ }
343
+
325
344
  export function assertReturningCapability(contract: Contract<SqlStorage>, action: string): void {
326
345
  if (hasContractCapability(contract, 'returning')) {
327
346
  return;
package/src/collection.ts CHANGED
@@ -2,6 +2,7 @@ import type { Contract } from '@prisma-next/contract/types';
2
2
  import { AsyncIterableResult } from '@prisma-next/framework-components/runtime';
3
3
  import type { SqlStorage } from '@prisma-next/sql-contract/types';
4
4
  import {
5
+ type AnyExpression,
5
6
  BinaryExpr,
6
7
  ColumnRef,
7
8
  isWhereExpr,
@@ -25,6 +26,7 @@ import {
25
26
  resolveModelTableName,
26
27
  resolvePolymorphismInfo,
27
28
  resolvePrimaryKeyColumn,
29
+ resolveRowIdentityColumns,
28
30
  resolveUpsertConflictColumns,
29
31
  } from './collection-contract';
30
32
  import { dispatchCollectionRows } from './collection-dispatch';
@@ -107,6 +109,7 @@ import {
107
109
  type RelatedModelName,
108
110
  type RelationNames,
109
111
  type ResolvedCreateInput,
112
+ type RuntimeQueryable,
110
113
  type ShorthandWhereFilter,
111
114
  type UniqueConstraintCriterion,
112
115
  type VariantModelRow,
@@ -119,11 +122,17 @@ function applyCreateDefaults(
119
122
  tableName: string,
120
123
  rows: Record<string, unknown>[],
121
124
  ): void {
125
+ // Per-operation cache for generators with `stability: 'query'` (e.g.
126
+ // `timestampNow` for `temporal.updatedAt()`): one generated value
127
+ // shared across every row in this insert. Per-field generators
128
+ // (e.g. `cuid`) ignore the cache and vary per row.
129
+ const defaultValueCache = rows.length > 1 ? new Map<string, unknown>() : undefined;
122
130
  for (const row of rows) {
123
131
  const applied = ctx.context.applyMutationDefaults({
124
132
  op: 'create',
125
133
  table: tableName,
126
134
  values: row,
135
+ ...(defaultValueCache ? { defaultValueCache } : {}),
127
136
  });
128
137
  for (const def of applied) {
129
138
  row[def.column] = def.value;
@@ -131,6 +140,21 @@ function applyCreateDefaults(
131
140
  }
132
141
  }
133
142
 
143
+ function applyUpdateDefaults(
144
+ ctx: CollectionContext<Contract<SqlStorage>>,
145
+ tableName: string,
146
+ values: Record<string, unknown>,
147
+ ): void {
148
+ const applied = ctx.context.applyMutationDefaults({
149
+ op: 'update',
150
+ table: tableName,
151
+ values,
152
+ });
153
+ for (const def of applied) {
154
+ values[def.column] = def.value;
155
+ }
156
+ }
157
+
134
158
  type WhereDirectInput = WhereArg;
135
159
 
136
160
  function isToWhereExprInput(value: unknown): value is ToWhereExpr {
@@ -965,6 +989,9 @@ export class Collection<
965
989
  applyCreateDefaults(this.ctx, this.tableName, [createValues]);
966
990
  const updateValues = mapModelDataToStorageRow(this.contract, this.modelName, input.update);
967
991
  const hasUpdateValues = Object.keys(updateValues).length > 0;
992
+ if (hasUpdateValues) {
993
+ applyUpdateDefaults(this.ctx, this.tableName, updateValues);
994
+ }
968
995
  const conflictColumns = resolveUpsertConflictColumns(
969
996
  this.contract,
970
997
  this.modelName,
@@ -1038,12 +1065,20 @@ export class Collection<
1038
1065
  return this.#reloadMutationRowByPrimaryKey(pkCriterion);
1039
1066
  }
1040
1067
 
1041
- const rows = await this.updateAll(
1042
- data as State['hasWhere'] extends true
1043
- ? Partial<DefaultModelRow<TContract, ModelName>>
1044
- : never,
1045
- );
1046
- return rows[0] ?? null;
1068
+ return withMutationScope(this.ctx.runtime, async (scope) => {
1069
+ const scoped = this.#withRuntime(scope);
1070
+ const identityWhere = await scoped.#findFirstMatchingRowIdentityWhere();
1071
+ if (!identityWhere) {
1072
+ return null;
1073
+ }
1074
+ const narrowed = scoped.#clone({ filters: [identityWhere] });
1075
+ const rows = await narrowed.updateAll(
1076
+ data as State['hasWhere'] extends true
1077
+ ? Partial<DefaultModelRow<TContract, ModelName>>
1078
+ : never,
1079
+ );
1080
+ return rows[0] ?? null;
1081
+ });
1047
1082
  }
1048
1083
 
1049
1084
  updateAll(
@@ -1057,6 +1092,8 @@ export class Collection<
1057
1092
  return new AsyncIterableResult(generator());
1058
1093
  }
1059
1094
 
1095
+ applyUpdateDefaults(this.ctx, this.tableName, mappedData);
1096
+
1060
1097
  const parentJoinColumns = this.state.includes.map((include) => include.localColumn);
1061
1098
  const { selectedForQuery: selectedForUpdate, hiddenColumns } = augmentSelectionForJoinColumns(
1062
1099
  this.state.selectedFields,
@@ -1088,6 +1125,8 @@ export class Collection<
1088
1125
  return 0;
1089
1126
  }
1090
1127
 
1128
+ applyUpdateDefaults(this.ctx, this.tableName, mappedData);
1129
+
1091
1130
  const primaryKeyColumn = resolvePrimaryKeyColumn(this.contract, this.tableName);
1092
1131
  const countState: CollectionState = {
1093
1132
  ...emptyState(),
@@ -1115,15 +1154,26 @@ export class Collection<
1115
1154
  this: State['hasWhere'] extends true ? Collection<TContract, ModelName, Row, State> : never,
1116
1155
  ): Promise<Row | null> {
1117
1156
  assertReturningCapability(this.contract, 'delete()');
1118
- const rows = await this.deleteAll().toArray();
1119
- return rows[0] ?? null;
1157
+ return withMutationScope(this.ctx.runtime, async (scope) => {
1158
+ const scoped = this.#withRuntime(scope);
1159
+ const identityWhere = await scoped.#findFirstMatchingRowIdentityWhere();
1160
+ if (!identityWhere) {
1161
+ return null;
1162
+ }
1163
+ const narrowed = scoped.#clone({ filters: [identityWhere] });
1164
+ const rows = await narrowed.#executeDeleteReturning().toArray();
1165
+ return rows[0] ?? null;
1166
+ });
1120
1167
  }
1121
1168
 
1122
1169
  deleteAll(
1123
1170
  this: State['hasWhere'] extends true ? Collection<TContract, ModelName, Row, State> : never,
1124
1171
  ): AsyncIterableResult<Row> {
1125
1172
  assertReturningCapability(this.contract, 'deleteAll()');
1173
+ return this.#executeDeleteReturning();
1174
+ }
1126
1175
 
1176
+ #executeDeleteReturning(): AsyncIterableResult<Row> {
1127
1177
  const parentJoinColumns = this.state.includes.map((include) => include.localColumn);
1128
1178
  const { selectedForQuery: selectedForDelete, hiddenColumns } = augmentSelectionForJoinColumns(
1129
1179
  this.state.selectedFields,
@@ -1188,6 +1238,41 @@ export class Collection<
1188
1238
  return criterion;
1189
1239
  }
1190
1240
 
1241
+ async #findFirstMatchingRowIdentityWhere(): Promise<AnyExpression | null> {
1242
+ const identityColumns = resolveRowIdentityColumns(this.contract, this.tableName);
1243
+ if (identityColumns.length === 0) {
1244
+ throw new Error(
1245
+ `update()/delete() on model "${this.modelName}" requires the table to have a primary key or unique constraint`,
1246
+ );
1247
+ }
1248
+ const firstRow = await this.#clone({
1249
+ selectedFields: [...identityColumns],
1250
+ includes: [],
1251
+ }).first();
1252
+ if (!firstRow) {
1253
+ return null;
1254
+ }
1255
+ const columnToField = getColumnToFieldMap(this.contract, this.modelName);
1256
+ const criterion: Record<string, unknown> = {};
1257
+ for (const column of identityColumns) {
1258
+ const fieldName = columnToField[column] ?? column;
1259
+ const value = (firstRow as Record<string, unknown>)[fieldName];
1260
+ if (value === undefined) {
1261
+ throw new Error(
1262
+ `Missing identity field "${fieldName}" while resolving single-row scope for model "${this.modelName}"`,
1263
+ );
1264
+ }
1265
+ criterion[fieldName] = value;
1266
+ }
1267
+ return (
1268
+ shorthandToWhereExpr(
1269
+ this.ctx.context,
1270
+ this.modelName,
1271
+ criterion as ShorthandWhereFilter<TContract, ModelName>,
1272
+ ) ?? null
1273
+ );
1274
+ }
1275
+
1191
1276
  async #reloadMutationRowByPrimaryKey(criterion: Record<string, unknown>): Promise<Row | null> {
1192
1277
  return this.#reloadMutationRowByCriterion(criterion, 'primary key');
1193
1278
  }
@@ -1242,6 +1327,16 @@ export class Collection<
1242
1327
  });
1243
1328
  }
1244
1329
 
1330
+ #withRuntime(runtime: RuntimeQueryable): Collection<TContract, ModelName, Row, State> {
1331
+ const Ctor = this.constructor as CollectionConstructor<TContract>;
1332
+ return new Ctor({ ...this.ctx, runtime }, this.modelName, {
1333
+ tableName: this.tableName,
1334
+ state: this.state,
1335
+ registry: this.registry,
1336
+ includeRefinementMode: this.includeRefinementMode,
1337
+ }) as unknown as Collection<TContract, ModelName, Row, State>;
1338
+ }
1339
+
1245
1340
  #cloneWithRow<NextRow, NextState extends CollectionTypeState = State>(
1246
1341
  overrides: Partial<CollectionState>,
1247
1342
  ): Collection<TContract, ModelName, NextRow, NextState> {
package/src/filters.ts CHANGED
@@ -73,7 +73,7 @@ function assertFieldHasEqualityTrait(
73
73
  ): void {
74
74
  const fieldType = modelOf(context.contract, modelName)?.fields?.[fieldName]?.type;
75
75
  const codecId = fieldType?.kind === 'scalar' ? fieldType.codecId : undefined;
76
- const traits = codecId ? context.codecs.traitsOf(codecId) : [];
76
+ const traits = codecId ? (context.codecDescriptors.descriptorFor(codecId)?.traits ?? []) : [];
77
77
  if (!traits.includes('equality')) {
78
78
  throw new Error(
79
79
  `Shorthand filter on "${modelName}.${fieldName}": field does not support equality comparisons`,
@@ -3,10 +3,29 @@ import type { SqlStorage } from '@prisma-next/sql-contract/types';
3
3
 
4
4
  export type IncludeStrategy = 'lateral' | 'correlated' | 'multiQuery';
5
5
 
6
+ /**
7
+ * Choose the SQL emission strategy for nested includes based on the
8
+ * contract's declared capabilities.
9
+ *
10
+ * - `'lateral'`: outer SELECT with one LATERAL JOIN per relation,
11
+ * aggregating to JSON. Requires both `lateral` and `jsonAgg`.
12
+ * Postgres has both.
13
+ * - `'correlated'`: outer SELECT with one correlated subquery per
14
+ * relation, aggregating to JSON. Requires `jsonAgg` only.
15
+ * SQLite has `jsonAgg` (via `json_group_array`) but no LATERAL.
16
+ * - `'multiQuery'`: fallback. One SELECT per relation, stitched
17
+ * together in JS via `WHERE pk IN (parent-pk-values)`. Always
18
+ * correct; just N+1 round-trips.
19
+ *
20
+ * The capability flags are looked up under the contract's
21
+ * `targetFamily` and `target` namespaces — the two layers the contract
22
+ * emitter actually populates. Cross-namespace ("`postgres.lateral`
23
+ * found while running SQLite") false positives are impossible because
24
+ * we only inspect the running target's namespaces.
25
+ */
6
26
  export function selectIncludeStrategy(contract: Contract<SqlStorage>): IncludeStrategy {
7
- const capabilities = contract.capabilities as Record<string, unknown> | undefined;
8
- const hasLateral = hasCapability(capabilities?.['lateral']);
9
- const hasJsonAgg = hasCapability(capabilities?.['jsonAgg']);
27
+ const hasLateral = capabilityFlag(contract, 'lateral');
28
+ const hasJsonAgg = capabilityFlag(contract, 'jsonAgg');
10
29
 
11
30
  if (hasLateral && hasJsonAgg) {
12
31
  return 'lateral';
@@ -19,15 +38,18 @@ export function selectIncludeStrategy(contract: Contract<SqlStorage>): IncludeSt
19
38
  return 'multiQuery';
20
39
  }
21
40
 
22
- function hasCapability(value: unknown): boolean {
23
- if (value === true) {
24
- return true;
25
- }
26
-
27
- if (typeof value !== 'object' || value === null) {
28
- return false;
29
- }
30
-
31
- const flags = value as Record<string, unknown>;
32
- return Object.values(flags).some((flag) => flag === true);
41
+ /**
42
+ * Read a capability flag from the contract's target/family namespaces.
43
+ *
44
+ * The contract emitter populates `capabilities[targetFamily]` (universal
45
+ * SQL flags like `jsonAgg`, `returning`) and `capabilities[target]`
46
+ * (target-specific flags like `lateral` on Postgres). Either may
47
+ * declare a given flag; the family namespace declares the floor and the
48
+ * target namespace can extend on top.
49
+ */
50
+ function capabilityFlag(contract: Contract<SqlStorage>, flag: string): boolean {
51
+ return (
52
+ contract.capabilities[contract.targetFamily]?.[flag] === true ||
53
+ contract.capabilities[contract.target]?.[flag] === true
54
+ );
33
55
  }
@@ -7,12 +7,11 @@ import {
7
7
  BinaryExpr,
8
8
  ColumnRef,
9
9
  ExistsExpr,
10
- OperationExpr,
11
- ParamRef,
12
10
  ProjectionItem,
13
11
  SelectAst,
14
12
  TableSource,
15
13
  } from '@prisma-next/sql-relational-core/ast';
14
+ import type { Expression, ScopeField } from '@prisma-next/sql-relational-core/expression';
16
15
  import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context';
17
16
  import {
18
17
  getFieldToColumnMap,
@@ -67,15 +66,16 @@ export function createModelAccessor<
67
66
  }
68
67
 
69
68
  for (const [name, entry] of Object.entries(context.queryOperations.entries())) {
70
- const self = entry.args[0];
71
69
  const op: NamedOp = [name, entry];
72
- if (self?.codecId) {
70
+ const self = entry.self;
71
+ if (!self) continue;
72
+ if (self.codecId !== undefined) {
73
73
  registerOp(self.codecId, op);
74
- } else if (self?.traits) {
75
- for (const codec of context.codecs.values()) {
76
- const codecTraits: readonly string[] = codec.traits ?? [];
77
- if (self.traits.every((t) => codecTraits.includes(t))) {
78
- registerOp(codec.id, op);
74
+ } else if (self.traits !== undefined) {
75
+ for (const descriptor of context.codecDescriptors.values()) {
76
+ const descriptorTraits: readonly string[] = descriptor.traits;
77
+ if (self.traits.every((t) => descriptorTraits.includes(t))) {
78
+ registerOp(descriptor.codecId, op);
79
79
  }
80
80
  }
81
81
  }
@@ -93,96 +93,96 @@ export function createModelAccessor<
93
93
  }
94
94
 
95
95
  const columnName = fieldToColumn[prop] ?? prop;
96
- const traits = resolveFieldTraits(contract, modelName, prop, context);
97
- const codecId = resolveFieldCodecId(contract, tableName, columnName);
98
- const operations = codecId ? (opsByCodecId.get(codecId) ?? []) : [];
99
- return createScalarFieldAccessor(tableName, columnName, codecId, traits, operations, context);
96
+ const column = resolveColumn(contract, tableName, columnName);
97
+ // Unknown fields return `undefined`, matching plain JS object semantics.
98
+ // The `ModelAccessor<TContract, ModelName>` type already rejects typos
99
+ // at compile time for TS consumers, and contexts that iterate accessor
100
+ // keys (e.g. relation-shorthand predicates) can detect missing fields
101
+ // with an `undefined` check and raise their own, domain-specific error.
102
+ if (!column) {
103
+ return undefined;
104
+ }
105
+ const traits = context.codecDescriptors.descriptorFor(column.codecId)?.traits ?? [];
106
+ const operations = opsByCodecId.get(column.codecId) ?? [];
107
+ return createScalarFieldAccessor(
108
+ tableName,
109
+ columnName,
110
+ column.codecId,
111
+ column.nullable,
112
+ traits,
113
+ operations,
114
+ context,
115
+ );
100
116
  },
101
117
  });
102
118
  }
103
119
 
104
- function resolveFieldTraits(
105
- contract: Contract<SqlStorage>,
106
- modelName: string,
107
- fieldName: string,
108
- context: ExecutionContext,
109
- ): readonly string[] {
110
- const fieldType = modelOf(contract, modelName)?.fields?.[fieldName]?.type;
111
- const codecId = fieldType?.kind === 'scalar' ? fieldType.codecId : undefined;
112
- if (!codecId) return [];
113
- return context.codecs.traitsOf(codecId);
114
- }
115
-
116
- function resolveFieldCodecId(
120
+ function resolveColumn(
117
121
  contract: Contract<SqlStorage>,
118
122
  tableName: string,
119
123
  columnName: string,
120
- ): string | undefined {
121
- const table = contract.storage.tables?.[tableName];
122
- return table?.columns?.[columnName]?.codecId;
124
+ ): { readonly codecId: string; readonly nullable: boolean } | undefined {
125
+ const column = contract.storage.tables?.[tableName]?.columns?.[columnName];
126
+ if (!column) return undefined;
127
+ return { codecId: column.codecId, nullable: column.nullable };
123
128
  }
124
129
 
125
130
  function createScalarFieldAccessor(
126
131
  tableName: string,
127
132
  columnName: string,
128
- codecId: string | undefined,
133
+ codecId: string,
134
+ nullable: boolean,
129
135
  traits: readonly string[],
130
136
  operations: readonly NamedOp[],
131
137
  context: ExecutionContext,
132
138
  ): Partial<ComparisonMethodFns<unknown>> {
133
139
  const column = ColumnRef.of(tableName, columnName);
134
- const methods: Record<string, unknown> = {};
135
-
140
+ const comparisonEntries: Array<[string, unknown]> = [];
136
141
  for (const [name, meta] of Object.entries(COMPARISON_METHODS_META)) {
137
- if (meta.traits.some((t) => !traits.includes(t))) {
138
- continue;
139
- }
140
- methods[name] = meta.create(column, codecId);
142
+ if (meta.traits.some((t) => !traits.includes(t))) continue;
143
+ comparisonEntries.push([name, meta.create(column, codecId)]);
141
144
  }
142
145
 
146
+ const accessor = {
147
+ returnType: { codecId, nullable },
148
+ buildAst: () => column,
149
+ ...Object.fromEntries(comparisonEntries),
150
+ } as Expression<ScopeField> & Record<string, unknown>;
151
+
143
152
  for (const [name, entry] of operations) {
144
- methods[name] = createExtensionMethodFactory(column, name, entry, context);
153
+ accessor[name] = createExtensionMethodFactory(accessor, entry, context);
145
154
  }
146
155
 
147
- return methods as Partial<ComparisonMethodFns<unknown>>;
156
+ return accessor as Partial<ComparisonMethodFns<unknown>>;
148
157
  }
149
158
 
150
159
  function createExtensionMethodFactory(
151
- column: ColumnRef,
152
- methodName: string,
160
+ selfExpr: Expression<ScopeField>,
153
161
  entry: SqlOperationEntry,
154
162
  context: ExecutionContext,
155
163
  ): (...args: unknown[]) => unknown {
156
- const returnTraits = context.codecs.traitsOf(entry.returns.codecId);
157
- const isPredicate = returnTraits.includes('boolean');
158
-
159
164
  return (...args: unknown[]) => {
160
- const userArgSpecs = entry.args.slice(1);
161
- const astArgs = userArgSpecs.map((argSpec, i) => {
162
- return ParamRef.of(args[i], argSpec.codecId ? { codecId: argSpec.codecId } : undefined);
163
- });
164
-
165
- const opExpr = new OperationExpr({
166
- method: methodName,
167
- self: column,
168
- args: astArgs,
169
- returns: entry.returns,
170
- lowering: entry.lowering,
171
- });
165
+ // `entry.impl` is typed `(...args: never[]) => QueryOperationReturn` —
166
+ // `never[]` args block direct invocation with unknown values, and the
167
+ // declared return omits `buildAst` (sql-contract intentionally doesn't
168
+ // depend on relational-core). Cast here to the practical shape: authors
169
+ // always return Expression<ScopeField> via `buildOperation`.
170
+ const impl = entry.impl as (self: unknown, ...args: unknown[]) => Expression<ScopeField>;
171
+ const result = impl(selfExpr, ...args);
172
+ const returnCodecId = result.returnType.codecId;
173
+ const returnTraits = context.codecDescriptors.descriptorFor(returnCodecId)?.traits ?? [];
174
+ const isPredicate = returnTraits.includes('boolean');
172
175
 
173
176
  if (isPredicate) {
174
- return opExpr;
177
+ return result.buildAst();
175
178
  }
176
179
 
180
+ const resultAst = result.buildAst();
177
181
  const methods: Record<string, unknown> = {};
178
-
179
182
  for (const [resultMethodName, meta] of Object.entries(COMPARISON_METHODS_META)) {
180
- if (meta.traits.some((t) => !returnTraits.includes(t))) {
181
- continue;
182
- }
183
- methods[resultMethodName] = meta.create(opExpr, entry.returns.codecId);
183
+ if (meta.traits.some((t) => !returnTraits.includes(t))) continue;
184
+ methods[resultMethodName] = meta.create(resultAst, returnCodecId);
184
185
  }
185
-
186
186
  return methods;
187
187
  };
188
188
  }
@@ -293,8 +293,14 @@ function toRelationWhereExpr<TContract extends Contract<SqlStorage>>(
293
293
  const fieldAccessor = (accessor as Record<string, Partial<ComparisonMethodFns<unknown>>>)[
294
294
  fieldName
295
295
  ];
296
+ // Unknown field in the shorthand predicate — the Proxy returns undefined
297
+ // for fields the contract doesn't declare. Surface it explicitly: silent
298
+ // skip would drop user intent (e.g. a typo'd `nmae: 'Alice'` filter would
299
+ // match every row).
296
300
  if (!fieldAccessor) {
297
- continue;
301
+ throw new Error(
302
+ `Shorthand filter on "${relatedModelName}.${fieldName}": field is not defined on the model`,
303
+ );
298
304
  }
299
305
 
300
306
  if (value === null) {
@@ -236,6 +236,15 @@ async function updateFirstGraph(
236
236
 
237
237
  const mappedUpdateData = mapModelDataToStorageRow(contract, modelName, scalarData);
238
238
  if (Object.keys(mappedUpdateData).length > 0) {
239
+ const tableName = resolveModelTableName(contract, modelName);
240
+ const appliedUpdateDefaults = context.applyMutationDefaults({
241
+ op: 'update',
242
+ table: tableName,
243
+ values: mappedUpdateData,
244
+ });
245
+ for (const def of appliedUpdateDefaults) {
246
+ mappedUpdateData[def.column] = def.value;
247
+ }
239
248
  const pkFilter = buildPrimaryKeyFilterFromRow(contract, modelName, existingRow);
240
249
  const pkWhere = shorthandToWhereExpr(
241
250
  context,
@@ -246,7 +255,6 @@ async function updateFirstGraph(
246
255
  throw new Error(`Failed to build primary key filter for model "${modelName}"`);
247
256
  }
248
257
 
249
- const tableName = resolveModelTableName(contract, modelName);
250
258
  const compiled = compileUpdateReturning(
251
259
  contract,
252
260
  tableName,