@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.
- package/README.md +1 -1
- package/dist/commands/generate.d.ts +8 -0
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +53 -13
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/migrate.d.ts +25 -0
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +54 -3
- package/dist/commands/migrate.js.map +1 -1
- package/dist/generator/context.d.ts +5 -2
- package/dist/generator/context.d.ts.map +1 -1
- package/dist/generator/context.js +14 -8
- package/dist/generator/context.js.map +1 -1
- package/dist/generator/index.d.ts +2 -0
- package/dist/generator/index.d.ts.map +1 -1
- package/dist/generator/index.js +1 -0
- package/dist/generator/index.js.map +1 -1
- package/dist/generator/lists.d.ts.map +1 -1
- package/dist/generator/lists.js +16 -5
- package/dist/generator/lists.js.map +1 -1
- package/dist/generator/output-paths.d.ts +104 -0
- package/dist/generator/output-paths.d.ts.map +1 -0
- package/dist/generator/output-paths.js +94 -0
- package/dist/generator/output-paths.js.map +1 -0
- package/dist/generator/prisma-config.d.ts +20 -4
- package/dist/generator/prisma-config.d.ts.map +1 -1
- package/dist/generator/prisma-config.js +35 -8
- package/dist/generator/prisma-config.js.map +1 -1
- package/dist/generator/prisma-extensions.d.ts +5 -2
- package/dist/generator/prisma-extensions.d.ts.map +1 -1
- package/dist/generator/prisma-extensions.js +12 -4
- package/dist/generator/prisma-extensions.js.map +1 -1
- package/dist/generator/prisma.d.ts +31 -3
- package/dist/generator/prisma.d.ts.map +1 -1
- package/dist/generator/prisma.js +174 -387
- package/dist/generator/prisma.js.map +1 -1
- package/dist/generator/types.d.ts.map +1 -1
- package/dist/generator/types.js +34 -8
- package/dist/generator/types.js.map +1 -1
- package/dist/mcp/lib/documentation-provider.d.ts.map +1 -1
- package/dist/mcp/lib/documentation-provider.js +36 -39
- package/dist/mcp/lib/documentation-provider.js.map +1 -1
- package/dist/mcp/lib/wizards/migration-wizard.d.ts.map +1 -1
- package/dist/mcp/lib/wizards/migration-wizard.js +2 -1
- package/dist/mcp/lib/wizards/migration-wizard.js.map +1 -1
- package/dist/migration/generators/migration-generator.d.ts.map +1 -1
- package/dist/migration/generators/migration-generator.js +6 -4
- package/dist/migration/generators/migration-generator.js.map +1 -1
- package/dist/migration/introspectors/keystone-introspector.d.ts.map +1 -1
- package/dist/migration/introspectors/keystone-introspector.js +6 -1
- package/dist/migration/introspectors/keystone-introspector.js.map +1 -1
- package/dist/migration/introspectors/prisma-introspector.d.ts.map +1 -1
- package/dist/migration/introspectors/prisma-introspector.js +4 -1
- package/dist/migration/introspectors/prisma-introspector.js.map +1 -1
- package/package.json +3 -3
package/dist/generator/prisma.js
CHANGED
|
@@ -1,16 +1,45 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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 = "
|
|
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
|
-
|
|
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
|
-
//
|
|
217
|
-
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
const
|
|
221
|
-
//
|
|
222
|
-
const
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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
|
|
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
|
|
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
|
|
342
|
-
const
|
|
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
|
-
|
|
353
|
-
name: fieldName,
|
|
354
|
-
field: fieldConfig,
|
|
355
|
-
});
|
|
182
|
+
relationshipFieldNames.push(fieldName);
|
|
356
183
|
continue;
|
|
357
184
|
}
|
|
358
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
465
|
-
if (
|
|
466
|
-
for (const
|
|
467
|
-
|
|
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
|
-
//
|
|
472
|
-
|
|
473
|
-
|
|
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
|
|
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)) {
|