@prisma-next/sql-contract-psl 0.5.0-dev.50 → 0.5.0-dev.52

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,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;
@@ -277,14 +367,35 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv
277
367
  diagnostics,
278
368
  });
279
369
 
370
+ // Field presets contribute their own default / executionDefaults / id /
371
+ // unique. They take precedence over attribute-derived contributions for
372
+ // this field, since a preset *is* the field declaration. Conflicts with
373
+ // `@default` and optional are already rejected above; explicit `@id`
374
+ // would be redundant noise on the resolved field, so we surface it as
375
+ // a hard error here for symmetry.
376
+ if (presetContributions && idAttribute && !presetContributions.id) {
377
+ diagnostics.push({
378
+ code: 'PSL_PRESET_AND_ID_CONFLICT',
379
+ 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.`,
380
+ sourceId,
381
+ span: idAttribute.span,
382
+ });
383
+ continue;
384
+ }
385
+
386
+ // Field-preset contributions take precedence over attribute-derived
387
+ // sources when present.
388
+ const fieldExecutionDefaults =
389
+ presetContributions?.executionDefaults ?? loweredDefault.executionDefaults;
390
+ const fieldDefaultValue = presetContributions?.default ?? loweredDefault.defaultValue;
280
391
  resolvedFields.push({
281
392
  field,
282
393
  columnName: mappedColumnName,
283
394
  descriptor,
284
- ...ifDefined('defaultValue', loweredDefault.defaultValue),
285
- ...ifDefined('executionDefault', loweredDefault.executionDefault),
286
- isId: Boolean(idAttribute),
287
- isUnique: Boolean(uniqueAttribute),
395
+ ...ifDefined('defaultValue', fieldDefaultValue),
396
+ ...ifDefined('executionDefaults', fieldExecutionDefaults),
397
+ isId: Boolean(idAttribute) || Boolean(presetContributions?.id),
398
+ isUnique: Boolean(uniqueAttribute) || Boolean(presetContributions?.unique),
288
399
  ...ifDefined('idName', idName),
289
400
  ...ifDefined('uniqueName', uniqueName),
290
401
  ...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({