@opensaas/stack-cli 0.20.1 → 0.22.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.
Files changed (55) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/generate.d.ts +8 -0
  3. package/dist/commands/generate.d.ts.map +1 -1
  4. package/dist/commands/generate.js +53 -13
  5. package/dist/commands/generate.js.map +1 -1
  6. package/dist/commands/migrate.d.ts +25 -0
  7. package/dist/commands/migrate.d.ts.map +1 -1
  8. package/dist/commands/migrate.js +54 -3
  9. package/dist/commands/migrate.js.map +1 -1
  10. package/dist/generator/context.d.ts +5 -2
  11. package/dist/generator/context.d.ts.map +1 -1
  12. package/dist/generator/context.js +14 -8
  13. package/dist/generator/context.js.map +1 -1
  14. package/dist/generator/index.d.ts +2 -0
  15. package/dist/generator/index.d.ts.map +1 -1
  16. package/dist/generator/index.js +1 -0
  17. package/dist/generator/index.js.map +1 -1
  18. package/dist/generator/lists.d.ts.map +1 -1
  19. package/dist/generator/lists.js +16 -5
  20. package/dist/generator/lists.js.map +1 -1
  21. package/dist/generator/output-paths.d.ts +104 -0
  22. package/dist/generator/output-paths.d.ts.map +1 -0
  23. package/dist/generator/output-paths.js +94 -0
  24. package/dist/generator/output-paths.js.map +1 -0
  25. package/dist/generator/prisma-config.d.ts +20 -4
  26. package/dist/generator/prisma-config.d.ts.map +1 -1
  27. package/dist/generator/prisma-config.js +35 -8
  28. package/dist/generator/prisma-config.js.map +1 -1
  29. package/dist/generator/prisma-extensions.d.ts +5 -2
  30. package/dist/generator/prisma-extensions.d.ts.map +1 -1
  31. package/dist/generator/prisma-extensions.js +12 -4
  32. package/dist/generator/prisma-extensions.js.map +1 -1
  33. package/dist/generator/prisma.d.ts +31 -3
  34. package/dist/generator/prisma.d.ts.map +1 -1
  35. package/dist/generator/prisma.js +174 -387
  36. package/dist/generator/prisma.js.map +1 -1
  37. package/dist/generator/types.d.ts.map +1 -1
  38. package/dist/generator/types.js +34 -8
  39. package/dist/generator/types.js.map +1 -1
  40. package/dist/mcp/lib/documentation-provider.d.ts.map +1 -1
  41. package/dist/mcp/lib/documentation-provider.js +36 -39
  42. package/dist/mcp/lib/documentation-provider.js.map +1 -1
  43. package/dist/mcp/lib/wizards/migration-wizard.d.ts.map +1 -1
  44. package/dist/mcp/lib/wizards/migration-wizard.js +2 -1
  45. package/dist/mcp/lib/wizards/migration-wizard.js.map +1 -1
  46. package/dist/migration/generators/migration-generator.d.ts.map +1 -1
  47. package/dist/migration/generators/migration-generator.js +6 -4
  48. package/dist/migration/generators/migration-generator.js.map +1 -1
  49. package/dist/migration/introspectors/keystone-introspector.d.ts.map +1 -1
  50. package/dist/migration/introspectors/keystone-introspector.js +6 -1
  51. package/dist/migration/introspectors/keystone-introspector.js.map +1 -1
  52. package/dist/migration/introspectors/prisma-introspector.d.ts.map +1 -1
  53. package/dist/migration/introspectors/prisma-introspector.js +4 -1
  54. package/dist/migration/introspectors/prisma-introspector.js.map +1 -1
  55. package/package.json +3 -3
@@ -1,16 +1,45 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  /**
4
- * Map OpenSaas field types to Prisma field types
4
+ * Decide which auto-timestamp columns the generator should inject into a list's model.
5
+ *
6
+ * Auto-timestamps are OFF by default (matching Keystone 6, which never adds them
7
+ * automatically — see ADR-0004). They are enabled globally via `db: { timestamps: true }`
8
+ * and can be overridden per-list via the list's `db.timestamps` option (which takes
9
+ * precedence over the global setting).
10
+ *
11
+ * When timestamps resolve to enabled, the auto column is skipped for any timestamp field
12
+ * the list already declares itself (`createdAt`/`updatedAt`), so Prisma never sees a
13
+ * duplicate field (`P1012`).
14
+ *
15
+ * @returns flags indicating whether to emit each auto column.
5
16
  */
6
- function mapFieldTypeToPrisma(fieldName, field, provider, listName) {
7
- // Relationships are handled separately
8
- if (field.type === 'relationship') {
9
- return null;
17
+ export function resolveListTimestamps(
18
+ // ListConfig is generic over per-list TypeInfo; the generator only reads `db`/`fields`,
19
+ // which are invariant across that generic, so the default `TypeInfo` is sufficient.
20
+ listConfig, dbConfig) {
21
+ // Per-list override wins over the global setting; otherwise fall back to the global
22
+ // default (off when unset).
23
+ const enabled = listConfig.db?.timestamps ?? dbConfig.timestamps ?? false;
24
+ if (!enabled) {
25
+ return { createdAt: false, updatedAt: false };
10
26
  }
27
+ // Skip the auto column for any timestamp the list already declares to avoid a
28
+ // duplicate-field error (Prisma P1012).
29
+ const declaresCreatedAt = Object.prototype.hasOwnProperty.call(listConfig.fields, 'createdAt');
30
+ const declaresUpdatedAt = Object.prototype.hasOwnProperty.call(listConfig.fields, 'updatedAt');
31
+ return {
32
+ createdAt: !declaresCreatedAt,
33
+ updatedAt: !declaresUpdatedAt,
34
+ };
35
+ }
36
+ /**
37
+ * Map OpenSaas field types to Prisma field types
38
+ */
39
+ function mapFieldTypeToPrisma(fieldName, field, provider, listName, keystoneCompat) {
11
40
  // Use field's own Prisma type generator if available
12
41
  if (field.getPrismaType) {
13
- const result = field.getPrismaType(fieldName, provider, listName);
42
+ const result = field.getPrismaType(fieldName, provider, listName, keystoneCompat);
14
43
  return result.type;
15
44
  }
16
45
  // Fallback for fields without generator methods
@@ -19,311 +48,112 @@ function mapFieldTypeToPrisma(fieldName, field, provider, listName) {
19
48
  /**
20
49
  * Get field modifiers (?, @default, @unique, etc.)
21
50
  */
22
- function getFieldModifiers(fieldName, field, provider, listName) {
23
- // Handle relationships separately
24
- if (field.type === 'relationship') {
25
- const relField = field;
26
- if (relField.many) {
27
- return '[]';
28
- }
29
- else {
30
- return '?';
31
- }
32
- }
51
+ function getFieldModifiers(fieldName, field, provider, listName, keystoneCompat) {
33
52
  // Use field's own Prisma type generator if available
34
53
  if (field.getPrismaType) {
35
- const result = field.getPrismaType(fieldName, provider, listName);
54
+ const result = field.getPrismaType(fieldName, provider, listName, keystoneCompat);
36
55
  return result.modifiers || '';
37
56
  }
38
57
  // Fallback for fields without generator methods
39
58
  return '';
40
59
  }
41
- /**
42
- * Parse relationship ref to get target list and optional field
43
- * Supports both 'ListName.fieldName' and 'ListName' formats
44
- */
45
- function parseRelationshipRef(ref) {
46
- const parts = ref.split('.');
47
- if (parts.length === 1) {
48
- // List-only ref (e.g., 'Term')
49
- const list = parts[0];
50
- if (!list) {
51
- throw new Error(`Invalid relationship ref: ${ref}`);
52
- }
53
- return { list };
54
- }
55
- else if (parts.length === 2) {
56
- // List and field ref (e.g., 'User.posts')
57
- const [list, field] = parts;
58
- if (!list || !field) {
59
- throw new Error(`Invalid relationship ref: ${ref}`);
60
- }
61
- return { list, field };
62
- }
63
- else {
64
- throw new Error(`Invalid relationship ref: ${ref}`);
65
- }
66
- }
67
- /**
68
- * Check if a relationship is one-to-one (bidirectional with both sides having many: false)
69
- */
70
- function isOneToOneRelationship(listName, fieldName, field, config) {
71
- // Must be bidirectional (has target field)
72
- const { list: targetList, field: targetField } = parseRelationshipRef(field.ref);
73
- if (!targetField) {
74
- return false;
75
- }
76
- // This side must be single (many: false or undefined)
77
- if (field.many) {
78
- return false;
79
- }
80
- // Check if target list exists
81
- const targetListConfig = config.lists[targetList];
82
- if (!targetListConfig) {
83
- throw new Error(`Referenced list "${targetList}" not found in config`);
84
- }
85
- // Check if target field exists and is a relationship
86
- const targetFieldConfig = targetListConfig.fields[targetField];
87
- if (!targetFieldConfig) {
88
- throw new Error(`Referenced field "${targetList}.${targetField}" not found. If you want a one-sided relationship, use ref: "${targetList}" instead of ref: "${targetList}.${targetField}"`);
89
- }
90
- if (targetFieldConfig.type !== 'relationship') {
91
- throw new Error(`Referenced field "${targetList}.${targetField}" is not a relationship field`);
92
- }
93
- const targetRelField = targetFieldConfig;
94
- return !targetRelField.many;
95
- }
96
- /**
97
- * Determine if this side of a relationship should have the foreign key
98
- * For one-to-one relationships, only one side should have the foreign key
99
- */
100
- function shouldHaveForeignKey(listName, fieldName, field, config) {
101
- // List-only refs always create foreign keys
102
- const { list: targetList, field: targetField } = parseRelationshipRef(field.ref);
103
- if (!targetField) {
104
- return true;
105
- }
106
- // Many-side never has foreign key (it's on the one-side)
107
- if (field.many) {
108
- return false;
109
- }
110
- // Check if this is a one-to-one relationship
111
- const isOneToOne = isOneToOneRelationship(listName, fieldName, field, config);
112
- if (!isOneToOne) {
113
- // One-to-many or many-to-one: the single side has the foreign key
114
- return true;
115
- }
116
- // One-to-one relationship: check db.foreignKey configuration
117
- const targetListConfig = config.lists[targetList];
118
- const targetFieldConfig = targetListConfig.fields[targetField];
119
- const thisSideExplicit = field.db?.foreignKey;
120
- const otherSideExplicit = targetFieldConfig.db?.foreignKey;
121
- // Validate: both sides cannot be true
122
- if (thisSideExplicit === true && otherSideExplicit === true) {
123
- throw new Error(`Invalid one-to-one relationship: both "${listName}.${fieldName}" and "${targetList}.${targetField}" have db.foreignKey set to true. Only one side can store the foreign key.`);
124
- }
125
- // If this side explicitly wants the foreign key, use it
126
- if (thisSideExplicit === true) {
127
- return true;
128
- }
129
- // If other side explicitly wants it, don't use it on this side
130
- if (otherSideExplicit === true) {
131
- return false;
132
- }
133
- // Default: use alphabetical ordering to ensure consistency
134
- // The "smaller" list name (alphabetically) gets the foreign key
135
- const comparison = listName.localeCompare(targetList);
136
- if (comparison !== 0) {
137
- return comparison < 0;
138
- }
139
- // Same list (self-referential): use field name ordering
140
- return fieldName.localeCompare(targetField) < 0;
141
- }
142
- /**
143
- * Check if a relationship field is many-to-many
144
- */
145
- function isManyToManyRelationship(listName, fieldName, field, config) {
146
- // Must be a many relationship
147
- if (!field.many) {
148
- return { isManyToMany: false, targetList: '', targetField: undefined };
149
- }
150
- const { list: targetList, field: targetField } = parseRelationshipRef(field.ref);
151
- // List-only refs with many: true are many-to-many (implicit single on other side)
152
- if (!targetField) {
153
- return { isManyToMany: true, targetList, targetField: undefined };
154
- }
155
- // Check if target field exists and is also many
156
- const targetListConfig = config.lists[targetList];
157
- if (!targetListConfig) {
158
- // Target list doesn't exist - not a valid many-to-many
159
- // (error will be thrown by existing validation code)
160
- return { isManyToMany: false, targetList, targetField };
161
- }
162
- const targetFieldConfig = targetListConfig.fields[targetField];
163
- if (!targetFieldConfig) {
164
- // Target field doesn't exist - not a valid many-to-many
165
- // (error will be thrown by existing validation code if needed)
166
- return { isManyToMany: false, targetList, targetField };
167
- }
168
- if (targetFieldConfig.type !== 'relationship') {
169
- // Target field is not a relationship - not a valid many-to-many
170
- return { isManyToMany: false, targetList, targetField };
171
- }
172
- const targetRelField = targetFieldConfig;
173
- // Both sides must have many: true for many-to-many
174
- return { isManyToMany: !!targetRelField.many, targetList, targetField };
175
- }
176
60
  /**
177
61
  * Generate Prisma schema from OpenSaas config
62
+ *
63
+ * @param prismaClientOutput - Module specifier for the patched Prisma client's
64
+ * `generator { output }`, relative to the schema file's directory. Defaults to
65
+ * the legacy `../<opensaasPath>/prisma-client` so existing projects are
66
+ * unaffected; the output-path resolver supplies a recomputed value when the
67
+ * schema or `.opensaas` dir is relocated via the `output` config block.
178
68
  */
179
- export function generatePrismaSchema(config) {
69
+ export function generatePrismaSchema(config, prismaClientOutput) {
180
70
  const lines = [];
181
71
  const opensaasPath = config.opensaasPath || '.opensaas';
182
- const joinTableNaming = config.db.joinTableNaming || 'prisma';
72
+ const clientOutput = prismaClientOutput ?? `../${opensaasPath}/prisma-client`;
73
+ // Keystone-compat mode: when on, non-null text without an explicit default
74
+ // gets Keystone's implicit empty-string default. Threaded to fields via
75
+ // getPrismaType, the same way provider/listName already reach them.
76
+ const keystoneCompat = config.db.keystoneCompat ?? false;
77
+ // Postgres multi-schema: when the datasource declares more than one schema,
78
+ // Prisma requires the `multiSchema` preview feature and a `schemas = [...]`
79
+ // array on the datasource. Each model then carries a `@@schema(...)`.
80
+ const schemas = config.db.schemas;
81
+ const multiSchema = Array.isArray(schemas) && schemas.length > 0;
183
82
  // Generator and datasource
184
83
  lines.push('generator client {');
185
84
  lines.push(' provider = "prisma-client"');
186
- lines.push(` output = "../${opensaasPath}/prisma-client"`);
85
+ lines.push(` output = "${clientOutput}"`);
86
+ if (multiSchema) {
87
+ lines.push(' previewFeatures = ["multiSchema"]');
88
+ }
187
89
  lines.push('}');
188
90
  lines.push('');
189
91
  lines.push('datasource db {');
190
92
  lines.push(` provider = "${config.db.provider}"`);
93
+ if (multiSchema) {
94
+ lines.push(` schemas = [${schemas.map((s) => `"${s}"`).join(', ')}]`);
95
+ }
191
96
  lines.push('}');
192
97
  lines.push('');
193
- // Collect enum definitions from all fields (first pass)
194
98
  const enumDefinitions = new Map();
195
99
  for (const [listName, listConfig] of Object.entries(config.lists)) {
196
100
  for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
197
101
  if (fieldConfig.type === 'relationship' || fieldConfig.virtual)
198
102
  continue;
199
103
  if (fieldConfig.getPrismaType) {
200
- const result = fieldConfig.getPrismaType(fieldName, config.db.provider, listName);
104
+ const result = fieldConfig.getPrismaType(fieldName, config.db.provider, listName, keystoneCompat);
201
105
  if (result.enumValues && result.enumValues.length > 0) {
202
- enumDefinitions.set(result.type, result.enumValues);
106
+ // The enum lives in the owning model's schema (so an enum used by an
107
+ // `auth`-schema model lands in `auth`, not `public`). Default to
108
+ // `public` when the list declares no schema.
109
+ enumDefinitions.set(result.type, {
110
+ values: result.enumValues,
111
+ schema: listConfig.db?.schema ?? 'public',
112
+ });
203
113
  }
204
114
  }
205
115
  }
206
116
  }
207
117
  // Generate enum blocks
208
- for (const [enumName, values] of enumDefinitions) {
118
+ for (const [enumName, { values, schema: enumSchema }] of enumDefinitions) {
209
119
  lines.push(`enum ${enumName} {`);
210
120
  for (const value of values) {
211
121
  lines.push(` ${value}`);
212
122
  }
123
+ // In multi-schema mode every enum must declare an `@@schema(...)` or Prisma
124
+ // rejects the schema (P1012). Greenfield (single schema) output is unchanged.
125
+ if (multiSchema) {
126
+ lines.push(` @@schema("${enumSchema}")`);
127
+ }
213
128
  lines.push('}');
214
129
  lines.push('');
215
130
  }
216
- // Track many-to-many relationships (for Keystone naming)
217
- const manyToManyRelationships = [];
218
- // Track synthetic relation fields that need to be added to target lists
219
- // Map of listName -> array of synthetic fields
220
- const syntheticFields = new Map();
221
- // First pass: collect all relationship info and identify synthetic fields and many-to-many relationships
222
- const processedManyToMany = new Set(); // Track processed many-to-many to avoid duplicates
131
+ // Compute every relationship field's Prisma contribution by delegating to the
132
+ // relationship field builder. The generator stays a neutral coordinator: it
133
+ // never inspects relationship topology itself, it only places the lines the
134
+ // field returns into the right model.
135
+ const relationResults = new Map();
136
+ // Synthetic back-relation lines keyed by the target model they belong to.
137
+ const backRelationsByTarget = new Map();
223
138
  for (const [listName, listConfig] of Object.entries(config.lists)) {
224
139
  for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
225
- if (fieldConfig.type === 'relationship') {
226
- const relField = fieldConfig;
227
- const { list: targetList, field: targetField } = parseRelationshipRef(relField.ref);
228
- // Check if this is a many-to-many relationship (only if it's a many relationship)
229
- const m2mCheck = relField.many
230
- ? isManyToManyRelationship(listName, fieldName, relField, config)
231
- : { isManyToMany: false, targetList: '', targetField: undefined };
232
- if (m2mCheck.isManyToMany) {
233
- // Create a unique key to avoid processing the same relationship twice
234
- const relationshipKey = targetField
235
- ? // Bidirectional: use sorted list names to ensure uniqueness
236
- [listName, fieldName, targetList, targetField].sort().join('.')
237
- : // List-only: just use source side
238
- `${listName}.${fieldName}`;
239
- if (!processedManyToMany.has(relationshipKey)) {
240
- processedManyToMany.add(relationshipKey);
241
- // Check for per-field relationName (takes precedence)
242
- const sourceRelationName = relField.db?.relationName;
243
- let targetRelationName;
244
- if (targetField) {
245
- const targetListConfig = config.lists[targetList];
246
- const targetFieldConfig = targetListConfig?.fields[targetField];
247
- if (targetFieldConfig?.type === 'relationship') {
248
- targetRelationName = targetFieldConfig.db?.relationName;
249
- }
250
- }
251
- // Validate both sides match if both specify relationName
252
- if (sourceRelationName &&
253
- targetRelationName &&
254
- sourceRelationName !== targetRelationName) {
255
- throw new Error(`Relation name mismatch: ${listName}.${fieldName} has relationName "${sourceRelationName}" but ${targetList}.${targetField} has "${targetRelationName}". Both sides must use the same relationName.`);
256
- }
257
- // Use per-field relationName if set (either side), otherwise fall back to global naming
258
- const explicitRelationName = sourceRelationName || targetRelationName;
259
- let relationName;
260
- let joinTableName;
261
- let ownerSide = 'source';
262
- if (explicitRelationName) {
263
- // Per-field relationName takes precedence
264
- relationName = explicitRelationName;
265
- joinTableName = `_${explicitRelationName}`;
266
- }
267
- else if (joinTableNaming === 'keystone') {
268
- // For Keystone naming, we need to determine which side owns the join table name
269
- // Keystone uses the side where the relationship is defined
270
- // For bidirectional many-to-many, we need to pick one side deterministically
271
- joinTableName = `_${listName}_${fieldName}`;
272
- relationName = `${listName}_${fieldName}`;
273
- if (targetField) {
274
- // Bidirectional many-to-many
275
- // Use alphabetical ordering to determine owner (ensures both sides agree)
276
- const sourceKey = `${listName}.${fieldName}`;
277
- const targetKey = `${targetList}.${targetField}`;
278
- if (sourceKey.localeCompare(targetKey) < 0) {
279
- ownerSide = 'source';
280
- joinTableName = `_${listName}_${fieldName}`;
281
- relationName = `${listName}_${fieldName}`;
282
- }
283
- else {
284
- ownerSide = 'target';
285
- joinTableName = `_${targetList}_${targetField}`;
286
- relationName = `${targetList}_${targetField}`;
287
- }
288
- }
289
- }
290
- else {
291
- // Default Prisma naming - no explicit relation name needed
292
- // Prisma will auto-generate join table name
293
- relationName = '';
294
- joinTableName = '';
295
- }
296
- // Only track M2M relationships that need explicit relation names
297
- if (relationName) {
298
- manyToManyRelationships.push({
299
- sourceList: listName,
300
- sourceField: fieldName,
301
- targetList,
302
- targetField,
303
- ownerSide,
304
- joinTableName,
305
- relationName,
306
- });
307
- }
308
- }
140
+ // Skip non-relationship fields and virtual relationships (which contribute
141
+ // no database columns and therefore no Prisma schema lines).
142
+ if (fieldConfig.type !== 'relationship' || fieldConfig.virtual)
143
+ continue;
144
+ const relField = fieldConfig;
145
+ if (!relField.getPrismaRelation) {
146
+ throw new Error(`Relationship field "${listName}.${fieldName}" does not implement getPrismaRelation method`);
147
+ }
148
+ const result = relField.getPrismaRelation(fieldName, listConfig.fields, listName, config);
149
+ relationResults.set(`${listName}.${fieldName}`, result);
150
+ if (result.backRelation) {
151
+ const existing = backRelationsByTarget.get(result.backRelation.targetList);
152
+ if (existing) {
153
+ existing.push(result.backRelation.line);
309
154
  }
310
- // If no target field specified, we need to add a synthetic back-reference field
311
- // on the target model. Prisma requires both sides of any relationship to be
312
- // defined, including implicit many-to-many relationships.
313
- // This applies to all list-only refs (both many-to-many and many-to-one).
314
- if (!targetField) {
315
- const syntheticFieldName = `from_${listName}_${fieldName}`;
316
- // Use explicit relation name if set, otherwise auto-generate
317
- const relationName = relField.db?.relationName ?? `${listName}_${fieldName}`;
318
- if (!syntheticFields.has(targetList)) {
319
- syntheticFields.set(targetList, []);
320
- }
321
- syntheticFields.get(targetList).push({
322
- fieldName: syntheticFieldName,
323
- sourceList: listName,
324
- sourceField: fieldName,
325
- relationName,
326
- });
155
+ else {
156
+ backRelationsByTarget.set(result.backRelation.targetList, [result.backRelation.line]);
327
157
  }
328
158
  }
329
159
  }
@@ -331,17 +161,17 @@ export function generatePrismaSchema(config) {
331
161
  // Generate models for each list
332
162
  for (const [listName, listConfig] of Object.entries(config.lists)) {
333
163
  lines.push(`model ${listName} {`);
334
- // Add id field - singleton lists use Int @id (always 1) to match Keystone 6 behaviour
164
+ // Add id field - singleton lists emit a bare `id Int @id` (no `@default(1)`) to
165
+ // match Keystone 6, which emits no column default for singleton ids (see ADR-0004).
166
+ // Non-singleton lists are unchanged: `id String @id @default(cuid())`.
335
167
  if (listConfig.isSingleton) {
336
- lines.push(' id Int @id @default(1)');
168
+ lines.push(' id Int @id');
337
169
  }
338
170
  else {
339
171
  lines.push(' id String @id @default(cuid())');
340
172
  }
341
- // Track relationship fields for later processing
342
- const relationshipFields = [];
343
- // Track foreign key fields that need indexes
344
- const foreignKeyIndexes = [];
173
+ // Track relationship field names (in declaration order) for later processing
174
+ const relationshipFieldNames = [];
345
175
  // Add regular fields
346
176
  for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
347
177
  // Skip virtual fields - they don't create database columns
@@ -349,16 +179,36 @@ export function generatePrismaSchema(config) {
349
179
  continue;
350
180
  }
351
181
  if (fieldConfig.type === 'relationship') {
352
- relationshipFields.push({
353
- name: fieldName,
354
- field: fieldConfig,
355
- });
182
+ relationshipFieldNames.push(fieldName);
356
183
  continue;
357
184
  }
358
- const prismaType = mapFieldTypeToPrisma(fieldName, fieldConfig, config.db.provider, listName);
185
+ // Multi-column fields (e.g. storage image()/file() in Keystone-parity
186
+ // mode) emit several physical columns instead of one. The generator stays
187
+ // neutral: it places whatever lines the field returns, the same way it
188
+ // delegates to relationship fields via getPrismaRelation. See ADR-0006.
189
+ if (fieldConfig.getPrismaColumns) {
190
+ const columns = fieldConfig.getPrismaColumns(fieldName);
191
+ if (columns && columns.length > 0) {
192
+ for (const column of columns) {
193
+ const colMods = (column.modifiers ?? '').trimStart();
194
+ const colNull = colMods.startsWith('?') ? '?' : '';
195
+ let colAttrs = colMods.startsWith('?') ? colMods.slice(1).trimStart() : colMods;
196
+ // Append @map to bind the column to its physical name (the live
197
+ // Keystone column). Emitted whenever a `map` is supplied so the
198
+ // mapping is explicit and configurable.
199
+ if (column.map) {
200
+ colAttrs = `${colAttrs ? colAttrs + ' ' : ''}@map("${column.map}")`;
201
+ }
202
+ const paddedColName = column.name.padEnd(12);
203
+ lines.push(` ${paddedColName} ${column.type}${colNull}${colAttrs ? ' ' + colAttrs : ''}`);
204
+ }
205
+ continue;
206
+ }
207
+ }
208
+ const prismaType = mapFieldTypeToPrisma(fieldName, fieldConfig, config.db.provider, listName, keystoneCompat);
359
209
  if (!prismaType)
360
210
  continue; // Skip if no type returned
361
- const modifiers = getFieldModifiers(fieldName, fieldConfig, config.db.provider, listName);
211
+ const modifiers = getFieldModifiers(fieldName, fieldConfig, config.db.provider, listName, keystoneCompat);
362
212
  // Format with proper spacing: '?' attaches to type directly, other modifiers get a space
363
213
  const paddedName = fieldName.padEnd(12);
364
214
  const modStr = modifiers.trimStart();
@@ -366,120 +216,54 @@ export function generatePrismaSchema(config) {
366
216
  const attrPart = modStr.startsWith('?') ? modStr.slice(1).trimStart() : modStr;
367
217
  lines.push(` ${paddedName} ${prismaType}${nullPart}${attrPart ? ' ' + attrPart : ''}`);
368
218
  }
369
- // Add relationship fields
370
- for (const { name: fieldName, field: relField } of relationshipFields) {
371
- const { list: targetList, field: targetField } = parseRelationshipRef(relField.ref);
372
- const paddedName = fieldName.padEnd(12);
373
- if (relField.many) {
374
- // Check if this is a many-to-many relationship
375
- const m2mCheck = isManyToManyRelationship(listName, fieldName, relField, config);
376
- let relationLine;
377
- // Check if this M2M relationship has an explicit relation name (per-field or global Keystone naming)
378
- const m2mRel = manyToManyRelationships.find((rel) => (rel.sourceList === listName && rel.sourceField === fieldName) ||
379
- (rel.targetList === listName && rel.targetField === fieldName));
380
- if (m2mCheck.isManyToMany && m2mRel) {
381
- // Many-to-many with explicit relation name (per-field or Keystone naming)
382
- relationLine = ` ${paddedName} ${targetList}[] @relation("${m2mRel.relationName}")`;
383
- }
384
- else if (targetField) {
385
- // Standard bidirectional one-to-many or many-to-many with Prisma naming
386
- relationLine = ` ${paddedName} ${targetList}[]`;
387
- }
388
- else {
389
- // List-only ref: use named relation
390
- const relationName = `${listName}_${fieldName}`;
391
- relationLine = ` ${paddedName} ${targetList}[] @relation("${relationName}")`;
392
- }
393
- // Apply extendPrismaSchema if defined (many side has no FK line)
394
- if (relField.db?.extendPrismaSchema) {
395
- const extended = relField.db.extendPrismaSchema({ relationLine });
396
- relationLine = extended.relationLine;
397
- }
398
- lines.push(relationLine);
399
- }
400
- else {
401
- // Single relationship - check if this side should have the foreign key
402
- const hasForeignKey = shouldHaveForeignKey(listName, fieldName, relField, config);
403
- if (hasForeignKey) {
404
- // This side has the foreign key
405
- const foreignKeyField = `${fieldName}Id`;
406
- const fkPaddedName = foreignKeyField.padEnd(12);
407
- // Check if this is a one-to-one relationship
408
- const isOneToOne = isOneToOneRelationship(listName, fieldName, relField, config);
409
- const uniqueModifier = isOneToOne ? ' @unique' : '';
410
- // Get the map attribute
411
- // If foreignKey is an object with map property, use it
412
- // Otherwise, default to fieldName (not fieldNameId)
413
- let mapModifier = '';
414
- if (typeof relField.db?.foreignKey === 'object' && relField.db.foreignKey.map) {
415
- mapModifier = ` @map("${relField.db.foreignKey.map}")`;
416
- }
417
- else {
418
- // Default to field name (not fieldNameId)
419
- mapModifier = ` @map("${fieldName}")`;
420
- }
421
- let fkLine = ` ${fkPaddedName} String?${uniqueModifier}${mapModifier}`;
422
- let relationLine;
423
- if (targetField) {
424
- // Standard bidirectional relationship
425
- relationLine = ` ${paddedName} ${targetList}? @relation(fields: [${foreignKeyField}], references: [id])`;
426
- }
427
- else {
428
- // List-only ref: use named relation
429
- const relationName = `${listName}_${fieldName}`;
430
- relationLine = ` ${paddedName} ${targetList}? @relation("${relationName}", fields: [${foreignKeyField}], references: [id])`;
431
- }
432
- // Apply extendPrismaSchema if defined
433
- if (relField.db?.extendPrismaSchema) {
434
- const extended = relField.db.extendPrismaSchema({ fkLine, relationLine });
435
- fkLine = extended.fkLine ?? fkLine;
436
- relationLine = extended.relationLine;
437
- }
438
- lines.push(fkLine);
439
- lines.push(relationLine);
440
- // Track foreign key for index generation
441
- // Default to true (matching Keystone behavior) unless explicitly set to false
442
- const indexType = relField.isIndexed ?? true;
443
- if (indexType !== false) {
444
- foreignKeyIndexes.push({
445
- foreignKeyField,
446
- indexType,
447
- });
448
- }
449
- }
450
- else {
451
- // This side does NOT have the foreign key (other side of one-to-one)
452
- // Just add the relation field without foreign key
453
- let relationLine = ` ${paddedName} ${targetList}?`;
454
- // Apply extendPrismaSchema if defined (no FK line on this side)
455
- if (relField.db?.extendPrismaSchema) {
456
- const extended = relField.db.extendPrismaSchema({ relationLine });
457
- relationLine = extended.relationLine;
458
- }
459
- lines.push(relationLine);
460
- }
219
+ // Add relationship fields (lines + foreign key indexes) from the precomputed results
220
+ const foreignKeyIndexes = [];
221
+ for (const fieldName of relationshipFieldNames) {
222
+ const result = relationResults.get(`${listName}.${fieldName}`);
223
+ lines.push(...result.modelLines);
224
+ if (result.foreignKeyIndex) {
225
+ foreignKeyIndexes.push(result.foreignKeyIndex);
461
226
  }
462
227
  }
463
228
  // Add synthetic relation fields for list-only refs pointing to this list
464
- const syntheticFieldsForList = syntheticFields.get(listName);
465
- if (syntheticFieldsForList) {
466
- for (const { fieldName: syntheticFieldName, sourceList, relationName, } of syntheticFieldsForList) {
467
- const paddedName = syntheticFieldName.padEnd(12);
468
- lines.push(` ${paddedName} ${sourceList}[] @relation("${relationName}")`);
229
+ const backRelations = backRelationsByTarget.get(listName);
230
+ if (backRelations) {
231
+ for (const line of backRelations) {
232
+ lines.push(line);
469
233
  }
470
234
  }
471
- // Always add timestamps
472
- lines.push(' createdAt DateTime @default(now())');
473
- lines.push(' updatedAt DateTime @default(now()) @updatedAt');
235
+ // Add auto-timestamps when enabled (off by default — see ADR-0004). The auto
236
+ // column is skipped for any timestamp the list declares itself (handled inside
237
+ // resolveListTimestamps) so Prisma never sees a duplicate field (P1012).
238
+ const timestamps = resolveListTimestamps(listConfig, config.db);
239
+ if (timestamps.createdAt) {
240
+ lines.push(' createdAt DateTime @default(now())');
241
+ }
242
+ if (timestamps.updatedAt) {
243
+ lines.push(' updatedAt DateTime @default(now()) @updatedAt');
244
+ }
474
245
  // Add indexes for foreign key fields
475
- for (const { foreignKeyField, indexType } of foreignKeyIndexes) {
476
- if (indexType === 'unique') {
477
- lines.push(` @@unique([${foreignKeyField}])`);
246
+ for (const index of foreignKeyIndexes) {
247
+ if (index.indexType === 'unique') {
248
+ lines.push(` @@unique([${index.foreignKeyField}])`);
478
249
  }
479
- else if (indexType === true) {
480
- lines.push(` @@index([${foreignKeyField}])`);
250
+ else if (index.indexType === true) {
251
+ lines.push(` @@index([${index.foreignKeyField}])`);
481
252
  }
482
253
  }
254
+ // Map the model to a custom table name when configured (e.g. adopting
255
+ // existing tables whose physical name differs from the list key).
256
+ if (listConfig.db?.map) {
257
+ lines.push(` @@map("${listConfig.db.map}")`);
258
+ }
259
+ // Place the model in a specific database schema (Postgres multi-schema).
260
+ // In multi-schema mode every model must declare an `@@schema(...)` or Prisma
261
+ // rejects the schema (P1012). A list inherits its own `db.schema` when set,
262
+ // otherwise it defaults to `public` (mirroring the enum default added in
263
+ // #504). Greenfield (single schema) output is unchanged — no `@@schema`.
264
+ if (multiSchema) {
265
+ lines.push(` @@schema("${listConfig.db?.schema ?? 'public'}")`);
266
+ }
483
267
  lines.push('}');
484
268
  lines.push('');
485
269
  }
@@ -495,9 +279,12 @@ export function generatePrismaSchema(config) {
495
279
  }
496
280
  /**
497
281
  * Write Prisma schema to file
282
+ *
283
+ * @param prismaClientOutput - Optional override for the patched Prisma client
284
+ * output path, forwarded to {@link generatePrismaSchema}.
498
285
  */
499
- export function writePrismaSchema(config, outputPath) {
500
- const schema = generatePrismaSchema(config);
286
+ export function writePrismaSchema(config, outputPath, prismaClientOutput) {
287
+ const schema = generatePrismaSchema(config, prismaClientOutput);
501
288
  // Ensure directory exists
502
289
  const dir = path.dirname(outputPath);
503
290
  if (!fs.existsSync(dir)) {