@prisma-next/sql-contract-psl 0.5.0-dev.7 → 0.5.0-dev.71

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.
@@ -1,11 +1,15 @@
1
1
  import type { ContractSourceDiagnostic } from '@prisma-next/config/config-types';
2
- import type { ColumnDefault, ExecutionMutationDefaultValue } from '@prisma-next/contract/types';
2
+ import type { ColumnDefault, ExecutionMutationDefaultPhases } from '@prisma-next/contract/types';
3
3
  import type {
4
4
  AuthoringContributions,
5
+ AuthoringFieldPresetDescriptor,
5
6
  AuthoringTypeConstructorDescriptor,
6
7
  } from '@prisma-next/framework-components/authoring';
7
8
  import {
9
+ hasRegisteredFieldNamespace,
10
+ instantiateAuthoringFieldPreset,
8
11
  instantiateAuthoringTypeConstructor,
12
+ isAuthoringFieldPresetDescriptor,
9
13
  isAuthoringTypeConstructorDescriptor,
10
14
  validateAuthoringHelperArguments,
11
15
  } from '@prisma-next/framework-components/authoring';
@@ -37,7 +41,7 @@ export type ColumnDescriptor = {
37
41
  readonly codecId: string;
38
42
  readonly nativeType: string;
39
43
  readonly typeRef?: string;
40
- readonly typeParams?: Record<string, unknown>;
44
+ readonly typeParams?: Record<string, unknown> | undefined;
41
45
  };
42
46
 
43
47
  export function toNamedTypeFieldDescriptor(
@@ -68,23 +72,45 @@ export function getAuthoringTypeConstructor(
68
72
  }
69
73
 
70
74
  /**
71
- * Returns the namespace prefix of `attributeName` if it references an
72
- * unrecognized extension namespace, otherwise `undefined`. A namespace is
73
- * considered recognized when it is:
75
+ * Walks `authoringContributions.field` segment-by-segment and returns the field-preset descriptor at the resolved path, or `undefined` if no descriptor is registered.
76
+ *
77
+ * Symmetric with `getAuthoringTypeConstructor`. Field presets are strictly richer than type constructors — they can contribute `default` / `executionDefaults` / `id` / `unique` / `nullable` in addition to the `codecId` / `nativeType` / `typeParams` triple. PSL resolution tries field presets first, then falls back to type constructors on miss (see `resolveFieldTypeDescriptor`).
78
+ */
79
+ export function getAuthoringFieldPreset(
80
+ contributions: AuthoringContributions | undefined,
81
+ path: readonly string[],
82
+ ): AuthoringFieldPresetDescriptor | undefined {
83
+ let current: unknown = contributions?.field;
84
+
85
+ for (const segment of path) {
86
+ if (typeof current !== 'object' || current === null || Array.isArray(current)) {
87
+ return undefined;
88
+ }
89
+ current = (current as Record<string, unknown>)[segment];
90
+ }
91
+
92
+ return isAuthoringFieldPresetDescriptor(current) ? current : undefined;
93
+ }
94
+
95
+ /**
96
+ * Returns the namespace prefix of `attributeName` if it references an unrecognized extension namespace, otherwise `undefined`. A namespace is considered recognized when it is:
74
97
  *
75
98
  * - `db` (native-type spec, always allowed),
76
99
  * - the active family id (e.g. `sql`),
77
100
  * - the active target id (e.g. `postgres`),
101
+ * - a registered field-preset namespace (e.g. `temporal`),
78
102
  * - present in `composedExtensions`.
79
103
  *
80
- * Family/target namespaces are exempted so that e.g. `@sql.foo` surfaces as
81
- * PSL_UNSUPPORTED_*_ATTRIBUTE (the attribute isn't defined) rather than
82
- * PSL_EXTENSION_NAMESPACE_NOT_COMPOSED (the namespace is already composed).
104
+ * Family/target/field-preset namespaces are exempted so that e.g. `@sql.foo` surfaces as PSL_UNSUPPORTED_*_ATTRIBUTE (the attribute isn't defined) rather than PSL_EXTENSION_NAMESPACE_NOT_COMPOSED (the namespace is already composed).
83
105
  */
84
106
  export function checkUncomposedNamespace(
85
107
  attributeName: string,
86
108
  composedExtensions: ReadonlySet<string>,
87
- context?: { readonly familyId?: string; readonly targetId?: string },
109
+ context?: {
110
+ readonly familyId?: string;
111
+ readonly targetId?: string;
112
+ readonly authoringContributions?: AuthoringContributions | undefined;
113
+ },
88
114
  ): string | undefined {
89
115
  const dotIndex = attributeName.indexOf('.');
90
116
  if (dotIndex <= 0 || dotIndex === attributeName.length - 1) {
@@ -95,6 +121,7 @@ export function checkUncomposedNamespace(
95
121
  namespace === 'db' ||
96
122
  namespace === context?.familyId ||
97
123
  namespace === context?.targetId ||
124
+ hasRegisteredFieldNamespace(context?.authoringContributions, namespace) ||
98
125
  composedExtensions.has(namespace)
99
126
  ) {
100
127
  return undefined;
@@ -103,12 +130,9 @@ export function checkUncomposedNamespace(
103
130
  }
104
131
 
105
132
  /**
106
- * Pushes the canonical `PSL_EXTENSION_NAMESPACE_NOT_COMPOSED` diagnostic for a
107
- * subject (attribute, model attribute, or type constructor) that references an
108
- * extension namespace which is not composed in the current contract.
133
+ * Pushes the canonical `PSL_EXTENSION_NAMESPACE_NOT_COMPOSED` diagnostic for a subject (attribute, model attribute, or type constructor) that references an extension namespace which is not composed in the current contract.
109
134
  *
110
- * The `data` payload carries the missing namespace so machine consumers
111
- * (agents, IDE extensions, CLI auto-fix) don't have to parse the prose.
135
+ * The `data` payload carries the missing namespace so machine consumers (agents, IDE extensions, CLI auto-fix) don't have to parse the prose.
112
136
  */
113
137
  export function reportUncomposedNamespace(input: {
114
138
  readonly subjectLabel: string;
@@ -126,6 +150,26 @@ export function reportUncomposedNamespace(input: {
126
150
  });
127
151
  }
128
152
 
153
+ /**
154
+ * Pushes the canonical `PSL_UNKNOWN_FIELD_PRESET` diagnostic when a typoed preset name is referenced inside a registered field-preset namespace. The `data` payload exposes the namespace and full helper path so machine consumers (agents, IDE extensions) don't have to parse the prose.
155
+ */
156
+ export function reportUnknownFieldPreset(input: {
157
+ readonly entityLabel: string;
158
+ readonly namespace: string;
159
+ readonly helperPath: string;
160
+ readonly sourceId: string;
161
+ readonly span: PslSpan;
162
+ readonly diagnostics: ContractSourceDiagnostic[];
163
+ }): void {
164
+ input.diagnostics.push({
165
+ code: 'PSL_UNKNOWN_FIELD_PRESET',
166
+ message: `${input.entityLabel} references unknown field preset "${input.helperPath}". Check the spelling against the available presets in the "${input.namespace}" namespace.`,
167
+ sourceId: input.sourceId,
168
+ span: input.span,
169
+ data: { namespace: input.namespace, helperPath: input.helperPath },
170
+ });
171
+ }
172
+
129
173
  export function instantiatePslTypeConstructor(input: {
130
174
  readonly call: PslTypeConstructorCall;
131
175
  readonly descriptor: AuthoringTypeConstructorDescriptor;
@@ -200,17 +244,19 @@ export function resolvePslTypeConstructorDescriptor(input: {
200
244
  return descriptor;
201
245
  }
202
246
 
203
- const namespace = input.call.path.length > 1 ? input.call.path[0] : undefined;
204
- if (
205
- namespace &&
206
- namespace !== 'db' &&
207
- namespace !== input.familyId &&
208
- namespace !== input.targetId &&
209
- !input.composedExtensions.has(namespace)
210
- ) {
247
+ const uncomposedNamespace = checkUncomposedNamespace(
248
+ input.call.path.join('.'),
249
+ input.composedExtensions,
250
+ {
251
+ familyId: input.familyId,
252
+ targetId: input.targetId,
253
+ authoringContributions: input.authoringContributions,
254
+ },
255
+ );
256
+ if (uncomposedNamespace) {
211
257
  reportUncomposedNamespace({
212
258
  subjectLabel: `Type constructor "${input.call.path.join('.')}"`,
213
- namespace,
259
+ namespace: uncomposedNamespace,
214
260
  sourceId: input.sourceId,
215
261
  span: input.call.span,
216
262
  diagnostics: input.diagnostics,
@@ -227,8 +273,89 @@ export function resolvePslTypeConstructorDescriptor(input: {
227
273
  });
228
274
  }
229
275
 
276
+ /**
277
+ * Instantiates a field-preset call against its descriptor, coercing PSL AST arguments into the descriptor's typed argument shape and returning the preset's full set of contract contributions.
278
+ *
279
+ * Symmetric with `instantiatePslTypeConstructor` but richer: a field preset can contribute `default`, `executionDefaults`, `id`, `unique`, and `nullable` in addition to the storage-type triple. PSL → typed-args coercion happens here (via `mapPslHelperArgs`) so that `instantiateAuthoringFieldPreset` itself stays typed-input-only and TS keeps its zero-runtime-validation cost.
280
+ */
281
+ export function instantiatePslFieldPreset(input: {
282
+ readonly call: PslTypeConstructorCall;
283
+ readonly descriptor: AuthoringFieldPresetDescriptor;
284
+ readonly diagnostics: ContractSourceDiagnostic[];
285
+ readonly sourceId: string;
286
+ readonly entityLabel: string;
287
+ }):
288
+ | {
289
+ readonly descriptor: ColumnDescriptor;
290
+ readonly nullable: boolean;
291
+ readonly default?: ColumnDefault;
292
+ readonly executionDefaults?: ExecutionMutationDefaultPhases;
293
+ readonly id: boolean;
294
+ readonly unique: boolean;
295
+ }
296
+ | undefined {
297
+ const helperPath = input.call.path.join('.');
298
+ const args = mapPslHelperArgs({
299
+ args: input.call.args,
300
+ descriptors: input.descriptor.args ?? [],
301
+ helperLabel: `preset "${helperPath}"`,
302
+ span: input.call.span,
303
+ diagnostics: input.diagnostics,
304
+ sourceId: input.sourceId,
305
+ entityLabel: input.entityLabel,
306
+ });
307
+ if (!args) {
308
+ return undefined;
309
+ }
310
+
311
+ try {
312
+ validateAuthoringHelperArguments(helperPath, input.descriptor.args, args);
313
+ const instantiated = instantiateAuthoringFieldPreset(input.descriptor, args);
314
+ return {
315
+ descriptor: {
316
+ codecId: instantiated.descriptor.codecId,
317
+ nativeType: instantiated.descriptor.nativeType,
318
+ ...(instantiated.descriptor.typeParams !== undefined
319
+ ? { typeParams: instantiated.descriptor.typeParams }
320
+ : {}),
321
+ },
322
+ nullable: instantiated.nullable,
323
+ ...(instantiated.default !== undefined ? { default: instantiated.default } : {}),
324
+ ...(instantiated.executionDefaults !== undefined
325
+ ? { executionDefaults: instantiated.executionDefaults }
326
+ : {}),
327
+ id: instantiated.id,
328
+ unique: instantiated.unique,
329
+ };
330
+ } catch (error) {
331
+ const message = error instanceof Error ? error.message : String(error);
332
+ input.diagnostics.push({
333
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
334
+ message: `${input.entityLabel} preset "${helperPath}" ${message}`,
335
+ sourceId: input.sourceId,
336
+ span: input.call.span,
337
+ });
338
+ return undefined;
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Contract contributions a field preset adds beyond the bare storage-type triple. Set when a field is resolved through the field-preset dispatch path; absent when resolved through the type-constructor path or as a scalar/enum/named-type lookup.
344
+ */
345
+ export type FieldPresetContributions = {
346
+ readonly nullable: boolean;
347
+ readonly id: boolean;
348
+ readonly unique: boolean;
349
+ readonly default?: ColumnDefault;
350
+ readonly executionDefaults?: ExecutionMutationDefaultPhases;
351
+ };
352
+
230
353
  export type ResolveFieldTypeResult =
231
- | { readonly ok: true; readonly descriptor: ColumnDescriptor }
354
+ | {
355
+ readonly ok: true;
356
+ readonly descriptor: ColumnDescriptor;
357
+ readonly presetContributions?: FieldPresetContributions;
358
+ }
232
359
  | { readonly ok: false; readonly alreadyReported: boolean };
233
360
 
234
361
  export function resolveFieldTypeDescriptor(input: {
@@ -245,18 +372,71 @@ export function resolveFieldTypeDescriptor(input: {
245
372
  readonly entityLabel: string;
246
373
  }): ResolveFieldTypeResult {
247
374
  if (input.field.typeConstructor) {
375
+ // Field presets carry richer semantics than type constructors, so a field preset match is the complete answer. Shared composition rejects exact cross-registry collisions before PSL resolution can observe them.
376
+ const presetDescriptor = getAuthoringFieldPreset(
377
+ input.authoringContributions,
378
+ input.field.typeConstructor.path,
379
+ );
380
+ if (presetDescriptor) {
381
+ const instantiated = instantiatePslFieldPreset({
382
+ call: input.field.typeConstructor,
383
+ descriptor: presetDescriptor,
384
+ diagnostics: input.diagnostics,
385
+ sourceId: input.sourceId,
386
+ entityLabel: input.entityLabel,
387
+ });
388
+ if (!instantiated) {
389
+ return { ok: false, alreadyReported: true };
390
+ }
391
+ const presetContributions: FieldPresetContributions = {
392
+ nullable: instantiated.nullable,
393
+ id: instantiated.id,
394
+ unique: instantiated.unique,
395
+ ...(instantiated.default !== undefined ? { default: instantiated.default } : {}),
396
+ ...(instantiated.executionDefaults !== undefined
397
+ ? { executionDefaults: instantiated.executionDefaults }
398
+ : {}),
399
+ };
400
+ return { ok: true, descriptor: instantiated.descriptor, presetContributions };
401
+ }
402
+
248
403
  const helperPath = input.field.typeConstructor.path.join('.');
249
- const descriptor = resolvePslTypeConstructorDescriptor({
250
- call: input.field.typeConstructor,
251
- authoringContributions: input.authoringContributions,
252
- composedExtensions: input.composedExtensions,
253
- familyId: input.familyId,
254
- targetId: input.targetId,
255
- diagnostics: input.diagnostics,
256
- sourceId: input.sourceId,
257
- unsupportedCode: 'PSL_UNSUPPORTED_FIELD_TYPE',
258
- unsupportedMessage: `${input.entityLabel} type constructor "${helperPath}" is not supported in SQL PSL provider v1`,
259
- });
404
+ const namespacePrefix =
405
+ input.field.typeConstructor.path.length > 1 ? input.field.typeConstructor.path[0] : undefined;
406
+ const typeDescriptor = getAuthoringTypeConstructor(
407
+ input.authoringContributions,
408
+ input.field.typeConstructor.path,
409
+ );
410
+
411
+ if (
412
+ !typeDescriptor &&
413
+ namespacePrefix &&
414
+ hasRegisteredFieldNamespace(input.authoringContributions, namespacePrefix)
415
+ ) {
416
+ reportUnknownFieldPreset({
417
+ entityLabel: input.entityLabel,
418
+ namespace: namespacePrefix,
419
+ helperPath,
420
+ sourceId: input.sourceId,
421
+ span: input.field.typeConstructor.span,
422
+ diagnostics: input.diagnostics,
423
+ });
424
+ return { ok: false, alreadyReported: true };
425
+ }
426
+
427
+ const descriptor =
428
+ typeDescriptor ??
429
+ resolvePslTypeConstructorDescriptor({
430
+ call: input.field.typeConstructor,
431
+ authoringContributions: input.authoringContributions,
432
+ composedExtensions: input.composedExtensions,
433
+ familyId: input.familyId,
434
+ targetId: input.targetId,
435
+ diagnostics: input.diagnostics,
436
+ sourceId: input.sourceId,
437
+ unsupportedCode: 'PSL_UNSUPPORTED_FIELD_TYPE',
438
+ unsupportedMessage: `${input.entityLabel} type constructor "${helperPath}" is not supported in SQL PSL provider v1`,
439
+ });
260
440
  if (!descriptor) {
261
441
  return { ok: false, alreadyReported: true };
262
442
  }
@@ -495,7 +675,7 @@ export function lowerDefaultForField(input: {
495
675
  readonly diagnostics: ContractSourceDiagnostic[];
496
676
  }): {
497
677
  readonly defaultValue?: ColumnDefault;
498
- readonly executionDefault?: ExecutionMutationDefaultValue;
678
+ readonly executionDefaults?: ExecutionMutationDefaultPhases;
499
679
  } {
500
680
  const positionalEntries = input.defaultAttribute.args.filter((arg) => arg.kind === 'positional');
501
681
  const namedEntries = input.defaultAttribute.args.filter((arg) => arg.kind === 'named');
@@ -568,6 +748,17 @@ export function lowerDefaultForField(input: {
568
748
  return {};
569
749
  }
570
750
 
751
+ // Preset-only generators (e.g. `timestampNow`) co-register their codec through the preset descriptor, so they don't carry an `applicableCodecIds` list. Such a generator surfacing on the `@default(...)` lowering path is itself the bug — emit a diagnostic pointing the user at the correct authoring surface.
752
+ if (generatorDescriptor.applicableCodecIds === undefined) {
753
+ input.diagnostics.push({
754
+ code: 'PSL_INVALID_DEFAULT_APPLICABILITY',
755
+ message: `Default generator "${generatorDescriptor.id}" is not applicable to "@default(...)" lowering. Use the corresponding field preset (e.g. \`temporal.${generatorDescriptor.id === 'timestampNow' ? 'updatedAt' : generatorDescriptor.id}()\`) instead.`,
756
+ sourceId: input.sourceId,
757
+ span: expressionEntry.span,
758
+ });
759
+ return {};
760
+ }
761
+
571
762
  if (!generatorDescriptor.applicableCodecIds.includes(input.columnDescriptor.codecId)) {
572
763
  input.diagnostics.push({
573
764
  code: 'PSL_INVALID_DEFAULT_APPLICABILITY',
@@ -578,7 +769,7 @@ export function lowerDefaultForField(input: {
578
769
  return {};
579
770
  }
580
771
 
581
- return { executionDefault: lowered.value.generated };
772
+ return { executionDefaults: { onCreate: lowered.value.generated } };
582
773
  }
583
774
 
584
775
  export function resolveColumnDescriptor(
@@ -1,5 +1,5 @@
1
1
  import type { ContractSourceDiagnostic } from '@prisma-next/config/config-types';
2
- import type { ColumnDefault, ExecutionMutationDefaultValue } from '@prisma-next/contract/types';
2
+ import type { ColumnDefault, ExecutionMutationDefaultPhases } from '@prisma-next/contract/types';
3
3
  import type { AuthoringContributions } from '@prisma-next/framework-components/authoring';
4
4
  import type {
5
5
  ControlMutationDefaultRegistry,
@@ -13,7 +13,7 @@ import {
13
13
  parseConstraintMapArgument,
14
14
  parseMapName,
15
15
  } from './psl-attribute-parsing';
16
- import type { ColumnDescriptor } from './psl-column-resolution';
16
+ import type { ColumnDescriptor, FieldPresetContributions } from './psl-column-resolution';
17
17
  import {
18
18
  checkUncomposedNamespace,
19
19
  lowerDefaultForField,
@@ -26,7 +26,7 @@ export type ResolvedField = {
26
26
  readonly columnName: string;
27
27
  readonly descriptor: ColumnDescriptor;
28
28
  readonly defaultValue?: ColumnDefault;
29
- readonly executionDefault?: ExecutionMutationDefaultValue;
29
+ readonly executionDefaults?: ExecutionMutationDefaultPhases;
30
30
  readonly isId: boolean;
31
31
  readonly isUnique: boolean;
32
32
  readonly idName?: string;
@@ -68,10 +68,54 @@ const BUILTIN_FIELD_ATTRIBUTE_NAMES: ReadonlySet<string> = new Set([
68
68
  'map',
69
69
  ]);
70
70
 
71
+ /**
72
+ * Per-attribute migration rule for attributes that have been removed
73
+ * from PSL in favor of the field-preset surface. The `hint` text is
74
+ * appended to the `PSL_UNSUPPORTED_FIELD_ATTRIBUTE` message so users
75
+ * porting Prisma 6 schemas see "use this preset instead" inline; the
76
+ * `suppressWhen` predicate skips the hint when the user has already
77
+ * migrated (so they don't get told to do what they just did).
78
+ *
79
+ * Pairing the suppression predicate with the hint makes each entry
80
+ * self-contained: a future entry for, say, `@id` ↔ `id.uuidv7()` cannot
81
+ * silently inherit the wrong predicate when added.
82
+ */
83
+ interface RemovedAttributeRule {
84
+ readonly hint: string;
85
+ readonly suppressWhen: (field: PslField) => boolean;
86
+ }
87
+
88
+ const REMOVED_ATTRIBUTE_RULES: ReadonlyMap<string, RemovedAttributeRule> = new Map([
89
+ [
90
+ 'updatedAt',
91
+ {
92
+ hint: 'Use `temporal.updatedAt()` as a field-preset call instead.',
93
+ suppressWhen: (field) => field.typeConstructor?.path[0] === 'temporal',
94
+ },
95
+ ],
96
+ ]);
97
+
98
+ // `validateFieldAttributes` short-circuits on `BUILTIN_FIELD_ATTRIBUTE_NAMES`
99
+ // before consulting `REMOVED_ATTRIBUTE_RULES`. A name appearing in both sets
100
+ // would silently suppress its migration hint, defeating the purpose of the
101
+ // hint table. Fail at module load with a clear message — the table is
102
+ // designed to grow and this is the cheap insurance against future drift.
103
+ {
104
+ const overlap = [...REMOVED_ATTRIBUTE_RULES.keys()].filter((name) =>
105
+ BUILTIN_FIELD_ATTRIBUTE_NAMES.has(name),
106
+ );
107
+ if (overlap.length > 0) {
108
+ throw new Error(
109
+ `BUILTIN_FIELD_ATTRIBUTE_NAMES and REMOVED_ATTRIBUTE_RULES must not overlap. Names in both: ${overlap.join(', ')}`,
110
+ );
111
+ }
112
+ }
113
+
71
114
  function validateFieldAttributes(input: {
72
115
  readonly model: PslModel;
73
116
  readonly field: PslField;
74
117
  readonly composedExtensions: ReadonlySet<string>;
118
+ readonly authoringContributions: AuthoringContributions | undefined;
75
119
  readonly diagnostics: ContractSourceDiagnostic[];
76
120
  readonly sourceId: string;
77
121
  readonly familyId: string;
@@ -85,6 +129,7 @@ function validateFieldAttributes(input: {
85
129
  const uncomposedNamespace = checkUncomposedNamespace(attribute.name, input.composedExtensions, {
86
130
  familyId: input.familyId,
87
131
  targetId: input.targetId,
132
+ authoringContributions: input.authoringContributions,
88
133
  });
89
134
  if (uncomposedNamespace) {
90
135
  reportUncomposedNamespace({
@@ -97,9 +142,16 @@ function validateFieldAttributes(input: {
97
142
  continue;
98
143
  }
99
144
 
145
+ const baseMessage = `Field "${input.model.name}.${input.field.name}" uses unsupported attribute "@${attribute.name}"`;
146
+ const removedRule = REMOVED_ATTRIBUTE_RULES.get(attribute.name);
147
+ const message =
148
+ removedRule && !removedRule.suppressWhen(input.field)
149
+ ? `${baseMessage}. ${removedRule.hint}`
150
+ : baseMessage;
151
+
100
152
  input.diagnostics.push({
101
153
  code: 'PSL_UNSUPPORTED_FIELD_ATTRIBUTE',
102
- message: `Field "${input.model.name}.${input.field.name}" uses unsupported attribute "@${attribute.name}"`,
154
+ message,
103
155
  sourceId: input.sourceId,
104
156
  span: attribute.span,
105
157
  });
@@ -159,7 +211,9 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv
159
211
  const resolvedFields: ResolvedField[] = [];
160
212
 
161
213
  for (const field of model.fields) {
162
- if (field.list && modelNames.has(field.typeName)) {
214
+ const isModelField = modelNames.has(field.typeName);
215
+
216
+ if (field.list && isModelField) {
163
217
  continue;
164
218
  }
165
219
 
@@ -167,6 +221,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv
167
221
  model,
168
222
  field,
169
223
  composedExtensions,
224
+ authoringContributions,
170
225
  diagnostics,
171
226
  sourceId,
172
227
  familyId,
@@ -174,7 +229,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv
174
229
  });
175
230
 
176
231
  const relationAttribute = getAttribute(field.attributes, 'relation');
177
- if (relationAttribute && modelNames.has(field.typeName)) {
232
+ if (isModelField && relationAttribute) {
178
233
  continue;
179
234
  }
180
235
 
@@ -183,6 +238,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv
183
238
 
184
239
  let descriptor: ColumnDescriptor | undefined;
185
240
  let scalarCodecId: string | undefined;
241
+ let presetContributions: FieldPresetContributions | undefined;
186
242
  const resolveInput = {
187
243
  field,
188
244
  enumTypeDescriptors,
@@ -212,6 +268,17 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv
212
268
  }
213
269
  continue;
214
270
  }
271
+ // Field presets are complete declarations — they carry their own codec
272
+ // and do not compose with `[]` list-of semantics. Reject early.
273
+ if (resolved.presetContributions) {
274
+ diagnostics.push({
275
+ code: 'PSL_PRESET_NOT_LIST',
276
+ message: `Field "${model.name}.${field.name}" uses a field-preset call as a list element type. Presets cannot be list elements; remove "[]" or use a scalar type.`,
277
+ sourceId,
278
+ span: field.span,
279
+ });
280
+ continue;
281
+ }
215
282
  scalarCodecId = resolved.descriptor.codecId;
216
283
  descriptor = scalarTypeDescriptors.get('Json');
217
284
  } else {
@@ -228,13 +295,37 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv
228
295
  continue;
229
296
  }
230
297
  descriptor = resolved.descriptor;
298
+ presetContributions = resolved.presetContributions;
231
299
  }
232
300
 
233
301
  if (!descriptor) {
234
302
  continue;
235
303
  }
236
304
 
305
+ // Field presets are complete declarations: the preset names its own codec
306
+ // and contributes any combination of default / executionDefaults / id /
307
+ // unique. Optional and `@default(...)` modifiers contradict that, so they
308
+ // are hard errors per spec FR7.
309
+ if (presetContributions && field.optional) {
310
+ diagnostics.push({
311
+ code: 'PSL_PRESET_NOT_OPTIONAL',
312
+ message: `Field "${model.name}.${field.name}" uses a field-preset call and cannot be optional. Remove "?" or use a different field type.`,
313
+ sourceId,
314
+ span: field.span,
315
+ });
316
+ continue;
317
+ }
318
+
237
319
  const defaultAttribute = getAttribute(field.attributes, 'default');
320
+ if (presetContributions && defaultAttribute) {
321
+ diagnostics.push({
322
+ code: 'PSL_PRESET_AND_DEFAULT_CONFLICT',
323
+ message: `Field "${model.name}.${field.name}" uses a field-preset call and cannot also declare @default(...). The preset already specifies the default value.`,
324
+ sourceId,
325
+ span: defaultAttribute.span,
326
+ });
327
+ continue;
328
+ }
238
329
  const loweredDefault = defaultAttribute
239
330
  ? lowerDefaultForField({
240
331
  modelName: model.name,
@@ -247,11 +338,10 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv
247
338
  diagnostics,
248
339
  })
249
340
  : {};
250
- if (field.optional && loweredDefault.executionDefault) {
341
+ const loweredOnCreate = loweredDefault.executionDefaults?.onCreate;
342
+ if (field.optional && loweredOnCreate) {
251
343
  const generatorDescription =
252
- loweredDefault.executionDefault.kind === 'generator'
253
- ? `"${loweredDefault.executionDefault.id}"`
254
- : 'for this field';
344
+ loweredOnCreate.kind === 'generator' ? `"${loweredOnCreate.id}"` : 'for this field';
255
345
  diagnostics.push({
256
346
  code: 'PSL_INVALID_DEFAULT_FUNCTION_ARGUMENT',
257
347
  message: `Field "${model.name}.${field.name}" cannot be optional when using execution default ${generatorDescription}. Remove "?" or use a storage default.`,
@@ -260,10 +350,10 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv
260
350
  });
261
351
  continue;
262
352
  }
263
- if (loweredDefault.executionDefault) {
264
- const generatorDescriptor = generatorDescriptorById.get(loweredDefault.executionDefault.id);
353
+ if (loweredOnCreate) {
354
+ const generatorDescriptor = generatorDescriptorById.get(loweredOnCreate.id);
265
355
  const generatedDescriptor = generatorDescriptor?.resolveGeneratedColumnDescriptor?.({
266
- generated: loweredDefault.executionDefault,
356
+ generated: loweredOnCreate,
267
357
  });
268
358
  if (generatedDescriptor) {
269
359
  descriptor = generatedDescriptor;
@@ -276,15 +366,46 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv
276
366
  sourceId,
277
367
  diagnostics,
278
368
  });
369
+ let isIdField = Boolean(idAttribute);
370
+ if (idAttribute && field.optional) {
371
+ diagnostics.push({
372
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
373
+ message: `Field "${model.name}.${field.name}" @id cannot be optional; primary key columns must be NOT NULL`,
374
+ sourceId,
375
+ span: idAttribute.span,
376
+ });
377
+ isIdField = false;
378
+ }
379
+
380
+ // Field presets contribute their own default / executionDefaults / id /
381
+ // unique. They take precedence over attribute-derived contributions for
382
+ // this field, since a preset *is* the field declaration. Conflicts with
383
+ // `@default` and optional are already rejected above; explicit `@id`
384
+ // would be redundant noise on the resolved field, so we surface it as
385
+ // a hard error here for symmetry.
386
+ if (presetContributions && idAttribute && !presetContributions.id) {
387
+ diagnostics.push({
388
+ code: 'PSL_PRESET_AND_ID_CONFLICT',
389
+ message: `Field "${model.name}.${field.name}" uses a field-preset call and cannot also declare @id. Use a preset that contributes id semantics, or drop @id.`,
390
+ sourceId,
391
+ span: idAttribute.span,
392
+ });
393
+ continue;
394
+ }
279
395
 
396
+ // Field-preset contributions take precedence over attribute-derived
397
+ // sources when present.
398
+ const fieldExecutionDefaults =
399
+ presetContributions?.executionDefaults ?? loweredDefault.executionDefaults;
400
+ const fieldDefaultValue = presetContributions?.default ?? loweredDefault.defaultValue;
280
401
  resolvedFields.push({
281
402
  field,
282
403
  columnName: mappedColumnName,
283
404
  descriptor,
284
- ...ifDefined('defaultValue', loweredDefault.defaultValue),
285
- ...ifDefined('executionDefault', loweredDefault.executionDefault),
286
- isId: Boolean(idAttribute),
287
- isUnique: Boolean(uniqueAttribute),
405
+ ...ifDefined('defaultValue', fieldDefaultValue),
406
+ ...ifDefined('executionDefaults', fieldExecutionDefaults),
407
+ isId: isIdField || Boolean(presetContributions?.id),
408
+ isUnique: Boolean(uniqueAttribute) || Boolean(presetContributions?.unique),
288
409
  ...ifDefined('idName', idName),
289
410
  ...ifDefined('uniqueName', uniqueName),
290
411
  ...ifDefined('many', isListField ? (true as const) : undefined),
@@ -1,4 +1,5 @@
1
1
  import type { ContractSourceDiagnostic } from '@prisma-next/config/config-types';
2
+ import type { AuthoringContributions } from '@prisma-next/framework-components/authoring';
2
3
  import type { PslAttribute, PslField, PslSpan } from '@prisma-next/psl-parser';
3
4
  import type { ReferentialAction } from '@prisma-next/sql-contract/types';
4
5
  import type { RelationNode } from '@prisma-next/sql-contract-ts/contract-builder';
@@ -334,6 +335,7 @@ export function validateNavigationListFieldAttributes(input: {
334
335
  readonly field: PslField;
335
336
  readonly sourceId: string;
336
337
  readonly composedExtensions: Set<string>;
338
+ readonly authoringContributions: AuthoringContributions | undefined;
337
339
  readonly diagnostics: ContractSourceDiagnostic[];
338
340
  readonly familyId: string;
339
341
  readonly targetId: string;
@@ -347,6 +349,7 @@ export function validateNavigationListFieldAttributes(input: {
347
349
  const uncomposedNamespace = checkUncomposedNamespace(attribute.name, input.composedExtensions, {
348
350
  familyId: input.familyId,
349
351
  targetId: input.targetId,
352
+ authoringContributions: input.authoringContributions,
350
353
  });
351
354
  if (uncomposedNamespace) {
352
355
  reportUncomposedNamespace({