Skip to content

Commit bd41b1e

Browse files
borisno2claude
andauthored
Fix singleton lists to use Int @id @default(1) matching Keystone 6 (#352)
* Fix singleton lists to use Int @id @default(1) matching Keystone 6 behaviour Singleton lists (isSingleton: true) previously generated String @id @default(cuid()) in the Prisma schema, which differed from Keystone 6's Int @id with a value always set to 1. This caused data loss issues when migrating from Keystone 6 as the integer primary key could not be automatically converted to text. Changes: - Prisma schema generator now emits `Int @id @default(1)` for singleton lists - Context create function injects `id: 1` into singleton record creation data - Types generator uses `id: number` in Output type for singleton lists - Blog example schema updated to reflect new singleton ID type - Tests updated to use numeric ID (1) for singleton records - Added explicit tests for singleton schema generation Fixes #350 https://claude.ai/code/session_01Keyrjjdhy3ypkZNzeg34re * Run pnpm format https://claude.ai/code/session_01Keyrjjdhy3ypkZNzeg34re --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5410cb6 commit bd41b1e

8 files changed

Lines changed: 130 additions & 20 deletions

File tree

.changeset/fix-singleton-int-id.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@opensaas/stack-core': patch
3+
'@opensaas/stack-cli': patch
4+
---
5+
6+
Fix singleton lists to use `Int @id @default(1)` matching Keystone 6 behaviour
7+
8+
Singleton lists now generate `Int @id @default(1)` in the Prisma schema instead of
9+
`String @id @default(cuid())`. This matches Keystone 6's behaviour where singleton
10+
records always use integer primary key `1`, making migration from Keystone 6 straightforward
11+
without data loss.
12+
13+
**Migration guide for existing singleton lists:**
14+
15+
If you have an existing database with singleton models that use `String @id`, you will need
16+
to run an SQL migration to convert the id column from text to integer:
17+
18+
```sql
19+
-- Example for PostgreSQL (adjust table name as needed)
20+
ALTER TABLE "EmailSettings" ALTER COLUMN id TYPE INTEGER USING id::integer;
21+
UPDATE "EmailSettings" SET id = 1;
22+
```
23+
24+
For SQLite (which does not support ALTER COLUMN):
25+
26+
```sql
27+
-- Recreate the table with Int id
28+
CREATE TABLE "EmailSettings_new" (id INTEGER PRIMARY KEY DEFAULT 1, ...);
29+
INSERT INTO "EmailSettings_new" SELECT 1, ... FROM "EmailSettings";
30+
DROP TABLE "EmailSettings";
31+
ALTER TABLE "EmailSettings_new" RENAME TO "EmailSettings";
32+
```
33+
34+
New projects and fresh databases will work automatically without any migration steps.
35+
Fixes #350.

examples/blog/prisma/schema.prisma

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ datasource db {
88
}
99

1010
model Settings {
11-
id String @id @default(cuid())
11+
id Int @id @default(1)
1212
siteName String
1313
maintenanceMode Boolean @default(false)
1414
maxUploadSize Int?

packages/cli/src/generator/__snapshots__/prisma.test.ts.snap

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,26 @@ datasource db {
175175
"
176176
`;
177177
178+
exports[`Prisma Schema Generator > select field with enum type > should generate singleton model with Int @id @default(1) 1`] = `
179+
"generator client {
180+
provider = "prisma-client"
181+
output = "../.opensaas/prisma-client"
182+
}
183+
184+
datasource db {
185+
provider = "sqlite"
186+
}
187+
188+
model Settings {
189+
id Int @id @default(1)
190+
siteName String
191+
maintenanceMode Boolean @default(false)
192+
createdAt DateTime @default(now())
193+
updatedAt DateTime @default(now()) @updatedAt
194+
}
195+
"
196+
`;
197+
178198
exports[`Prisma Schema Generator > select field with enum type > should match snapshot for enum select field 1`] = `
179199
"generator client {
180200
provider = "prisma-client"

packages/cli/src/generator/prisma.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1543,5 +1543,48 @@ describe('Prisma Schema Generator', () => {
15431543
const schema = generatePrismaSchema(config)
15441544
expect(schema).toMatchSnapshot()
15451545
})
1546+
1547+
it('should generate singleton model with Int @id @default(1)', () => {
1548+
const config: OpenSaasConfig = {
1549+
db: {
1550+
provider: 'sqlite',
1551+
},
1552+
lists: {
1553+
Settings: {
1554+
fields: {
1555+
siteName: text({ validation: { isRequired: true } }),
1556+
maintenanceMode: checkbox({ defaultValue: false }),
1557+
},
1558+
isSingleton: true,
1559+
},
1560+
},
1561+
}
1562+
1563+
const schema = generatePrismaSchema(config)
1564+
1565+
expect(schema).toContain('id Int @id @default(1)')
1566+
expect(schema).not.toContain('id String @id @default(cuid())')
1567+
expect(schema).toMatchSnapshot()
1568+
})
1569+
1570+
it('should generate regular model with String @id @default(cuid())', () => {
1571+
const config: OpenSaasConfig = {
1572+
db: {
1573+
provider: 'sqlite',
1574+
},
1575+
lists: {
1576+
Post: {
1577+
fields: {
1578+
title: text(),
1579+
},
1580+
},
1581+
},
1582+
}
1583+
1584+
const schema = generatePrismaSchema(config)
1585+
1586+
expect(schema).toContain('id String @id @default(cuid())')
1587+
expect(schema).not.toContain('id Int @id @default(1)')
1588+
})
15461589
})
15471590
})

packages/cli/src/generator/prisma.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -429,8 +429,12 @@ export function generatePrismaSchema(config: OpenSaasConfig): string {
429429
for (const [listName, listConfig] of Object.entries(config.lists)) {
430430
lines.push(`model ${listName} {`)
431431

432-
// Always add id field
433-
lines.push(' id String @id @default(cuid())')
432+
// Add id field - singleton lists use Int @id (always 1) to match Keystone 6 behaviour
433+
if (listConfig.isSingleton) {
434+
lines.push(' id Int @id @default(1)')
435+
} else {
436+
lines.push(' id String @id @default(cuid())')
437+
}
434438

435439
// Track relationship fields for later processing
436440
const relationshipFields: Array<{

packages/cli/src/generator/types.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,16 @@ function generateTransformedFieldsType(
121121
* Generate TypeScript Output type for a model (includes virtual fields)
122122
* This is kept for backwards compatibility but CustomDB uses Prisma's GetPayload + VirtualFields
123123
*/
124-
function generateModelOutputType(listName: string, fields: Record<string, FieldConfig>): string {
124+
function generateModelOutputType(
125+
listName: string,
126+
fields: Record<string, FieldConfig>,
127+
isSingleton: boolean,
128+
): string {
125129
const lines: string[] = []
126130

127131
lines.push(`export type ${listName}Output = {`)
128-
lines.push(' id: string')
132+
// Singleton lists use Int @id (always 1) matching Keystone 6 behaviour
133+
lines.push(isSingleton ? ' id: number' : ' id: string')
129134

130135
for (const [fieldName, fieldConfig] of Object.entries(fields)) {
131136
// Skip virtual fields - they're in VirtualFields type
@@ -993,7 +998,7 @@ export function generateTypes(config: OpenSaasConfig): string {
993998
// Generate TransformedFields type (needed by CustomDB)
994999
lines.push(generateTransformedFieldsType(listName, listConfig.fields))
9951000
lines.push('')
996-
lines.push(generateModelOutputType(listName, listConfig.fields))
1001+
lines.push(generateModelOutputType(listName, listConfig.fields, !!listConfig.isSingleton))
9971002
lines.push('')
9981003
lines.push(generateModelTypeAlias(listName))
9991004
lines.push('')

packages/core/src/context/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -893,8 +893,10 @@ function createCreate<TPrisma extends PrismaClientLike>(
893893
// Access Prisma model dynamically - required because model names are generated at runtime
894894
// eslint-disable-next-line @typescript-eslint/no-explicit-any
895895
const model = (prisma as any)[getDbKey(listName)]
896+
// Singleton lists use Int @id with value always 1 (matching Keystone 6 behaviour)
897+
const createData = isSingletonList(listConfig) ? { id: 1, ...data } : data
896898
const item = await model.create({
897-
data,
899+
data: createData,
898900
})
899901

900902
// 9. Execute list-level afterOperation hook

packages/core/tests/singleton.test.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ describe('Singleton Lists', () => {
7878
it('should allow creating the first record', async () => {
7979
mockPrisma.settings.count.mockResolvedValue(0)
8080
mockPrisma.settings.create.mockResolvedValue({
81-
id: '1',
81+
id: 1,
8282
siteName: 'Test Site',
8383
maintenanceMode: false,
8484
maxUploadSize: 10,
@@ -146,7 +146,7 @@ describe('Singleton Lists', () => {
146146

147147
it('should return existing record on get()', async () => {
148148
const mockSettings = {
149-
id: '1',
149+
id: 1,
150150
siteName: 'My Site',
151151
maintenanceMode: false,
152152
maxUploadSize: 10,
@@ -168,7 +168,7 @@ describe('Singleton Lists', () => {
168168
mockPrisma.settings.findFirst.mockResolvedValue(null)
169169
mockPrisma.settings.count.mockResolvedValue(0)
170170
mockPrisma.settings.create.mockResolvedValue({
171-
id: '1',
171+
id: 1,
172172
siteName: 'My Site',
173173
maintenanceMode: false,
174174
maxUploadSize: 10,
@@ -183,6 +183,7 @@ describe('Singleton Lists', () => {
183183
expect(result?.siteName).toBe('My Site')
184184
expect(mockPrisma.settings.create).toHaveBeenCalledWith({
185185
data: {
186+
id: 1,
186187
siteName: 'My Site',
187188
maintenanceMode: false,
188189
maxUploadSize: 10,
@@ -207,26 +208,26 @@ describe('Singleton Lists', () => {
207208
describe('delete operation', () => {
208209
it('should block delete on singleton lists', async () => {
209210
mockPrisma.settings.findUnique.mockResolvedValue({
210-
id: '1',
211+
id: 1,
211212
siteName: 'My Site',
212213
maintenanceMode: false,
213214
maxUploadSize: 10,
214215
})
215216

216217
const context = getContext(config, mockPrisma, null)
217218

218-
await expect(context.db.settings.delete({ where: { id: '1' } })).rejects.toThrow(
219+
await expect(context.db.settings.delete({ where: { id: 1 } })).rejects.toThrow(
219220
ValidationError,
220221
)
221222

222-
await expect(context.db.settings.delete({ where: { id: '1' } })).rejects.toThrow(
223+
await expect(context.db.settings.delete({ where: { id: 1 } })).rejects.toThrow(
223224
'singleton list',
224225
)
225226
})
226227

227228
it('should block delete even in sudo mode', async () => {
228229
mockPrisma.settings.findUnique.mockResolvedValue({
229-
id: '1',
230+
id: 1,
230231
siteName: 'My Site',
231232
maintenanceMode: false,
232233
maxUploadSize: 10,
@@ -235,7 +236,7 @@ describe('Singleton Lists', () => {
235236
const context = getContext(config, mockPrisma, null)
236237
const sudoContext = context.sudo()
237238

238-
await expect(sudoContext.db.settings.delete({ where: { id: '1' } })).rejects.toThrow(
239+
await expect(sudoContext.db.settings.delete({ where: { id: 1 } })).rejects.toThrow(
239240
ValidationError,
240241
)
241242
})
@@ -267,14 +268,14 @@ describe('Singleton Lists', () => {
267268
describe('update operation', () => {
268269
it('should allow updating the singleton record', async () => {
269270
mockPrisma.settings.findUnique.mockResolvedValue({
270-
id: '1',
271+
id: 1,
271272
siteName: 'My Site',
272273
maintenanceMode: false,
273274
maxUploadSize: 10,
274275
})
275276

276277
mockPrisma.settings.update.mockResolvedValue({
277-
id: '1',
278+
id: 1,
278279
siteName: 'Updated Site',
279280
maintenanceMode: true,
280281
maxUploadSize: 20,
@@ -285,7 +286,7 @@ describe('Singleton Lists', () => {
285286
const context = getContext(config, mockPrisma, null)
286287

287288
const result = await context.db.settings.update({
288-
where: { id: '1' },
289+
where: { id: 1 },
289290
data: { siteName: 'Updated Site', maintenanceMode: true, maxUploadSize: 20 },
290291
})
291292

@@ -298,7 +299,7 @@ describe('Singleton Lists', () => {
298299
describe('findUnique operation', () => {
299300
it('should allow findUnique on singleton lists', async () => {
300301
mockPrisma.settings.findFirst.mockResolvedValue({
301-
id: '1',
302+
id: 1,
302303
siteName: 'My Site',
303304
maintenanceMode: false,
304305
maxUploadSize: 10,
@@ -307,7 +308,7 @@ describe('Singleton Lists', () => {
307308
})
308309

309310
const context = getContext(config, mockPrisma, null)
310-
const result = await context.db.settings.findUnique({ where: { id: '1' } })
311+
const result = await context.db.settings.findUnique({ where: { id: 1 } })
311312

312313
expect(result).toBeDefined()
313314
expect(result?.siteName).toBe('My Site')

0 commit comments

Comments
 (0)