Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/fix-singleton-int-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
'@opensaas/stack-core': patch
'@opensaas/stack-cli': patch
---

Fix singleton lists to use `Int @id @default(1)` matching Keystone 6 behaviour

Singleton lists now generate `Int @id @default(1)` in the Prisma schema instead of
`String @id @default(cuid())`. This matches Keystone 6's behaviour where singleton
records always use integer primary key `1`, making migration from Keystone 6 straightforward
without data loss.

**Migration guide for existing singleton lists:**

If you have an existing database with singleton models that use `String @id`, you will need
to run an SQL migration to convert the id column from text to integer:

```sql
-- Example for PostgreSQL (adjust table name as needed)
ALTER TABLE "EmailSettings" ALTER COLUMN id TYPE INTEGER USING id::integer;
UPDATE "EmailSettings" SET id = 1;
```

For SQLite (which does not support ALTER COLUMN):

```sql
-- Recreate the table with Int id
CREATE TABLE "EmailSettings_new" (id INTEGER PRIMARY KEY DEFAULT 1, ...);
INSERT INTO "EmailSettings_new" SELECT 1, ... FROM "EmailSettings";
DROP TABLE "EmailSettings";
ALTER TABLE "EmailSettings_new" RENAME TO "EmailSettings";
```

New projects and fresh databases will work automatically without any migration steps.
Fixes #350.
2 changes: 1 addition & 1 deletion examples/blog/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ datasource db {
}

model Settings {
id String @id @default(cuid())
id Int @id @default(1)
siteName String
maintenanceMode Boolean @default(false)
maxUploadSize Int?
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/generator/__snapshots__/prisma.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,26 @@ datasource db {
"
`;

exports[`Prisma Schema Generator > select field with enum type > should generate singleton model with Int @id @default(1) 1`] = `
"generator client {
provider = "prisma-client"
output = "../.opensaas/prisma-client"
}

datasource db {
provider = "sqlite"
}

model Settings {
id Int @id @default(1)
siteName String
maintenanceMode Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
}
"
`;

exports[`Prisma Schema Generator > select field with enum type > should match snapshot for enum select field 1`] = `
"generator client {
provider = "prisma-client"
Expand Down
43 changes: 43 additions & 0 deletions packages/cli/src/generator/prisma.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1543,5 +1543,48 @@ describe('Prisma Schema Generator', () => {
const schema = generatePrismaSchema(config)
expect(schema).toMatchSnapshot()
})

it('should generate singleton model with Int @id @default(1)', () => {
const config: OpenSaasConfig = {
db: {
provider: 'sqlite',
},
lists: {
Settings: {
fields: {
siteName: text({ validation: { isRequired: true } }),
maintenanceMode: checkbox({ defaultValue: false }),
},
isSingleton: true,
},
},
}

const schema = generatePrismaSchema(config)

expect(schema).toContain('id Int @id @default(1)')
expect(schema).not.toContain('id String @id @default(cuid())')
expect(schema).toMatchSnapshot()
})

it('should generate regular model with String @id @default(cuid())', () => {
const config: OpenSaasConfig = {
db: {
provider: 'sqlite',
},
lists: {
Post: {
fields: {
title: text(),
},
},
},
}

const schema = generatePrismaSchema(config)

expect(schema).toContain('id String @id @default(cuid())')
expect(schema).not.toContain('id Int @id @default(1)')
})
})
})
8 changes: 6 additions & 2 deletions packages/cli/src/generator/prisma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,8 +429,12 @@ export function generatePrismaSchema(config: OpenSaasConfig): string {
for (const [listName, listConfig] of Object.entries(config.lists)) {
lines.push(`model ${listName} {`)

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

// Track relationship fields for later processing
const relationshipFields: Array<{
Expand Down
11 changes: 8 additions & 3 deletions packages/cli/src/generator/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,16 @@
* Generate TypeScript Output type for a model (includes virtual fields)
* This is kept for backwards compatibility but CustomDB uses Prisma's GetPayload + VirtualFields
*/
function generateModelOutputType(listName: string, fields: Record<string, FieldConfig>): string {
function generateModelOutputType(
listName: string,
fields: Record<string, FieldConfig>,
isSingleton: boolean,
): string {
const lines: string[] = []

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

for (const [fieldName, fieldConfig] of Object.entries(fields)) {
// Skip virtual fields - they're in VirtualFields type
Expand Down Expand Up @@ -246,7 +251,7 @@
* @param ref - Relationship ref in format 'ListName' or 'ListName.fieldName'
* @returns The list name (first part before '.')
*/
function getRelatedListName(ref: string): string {

Check warning on line 254 in packages/cli/src/generator/types.ts

View workflow job for this annotation

GitHub Actions / test

'getRelatedListName' is defined but never used. Allowed unused vars must match /^_/u
return ref.split('.')[0]
}

Expand Down Expand Up @@ -993,7 +998,7 @@
// Generate TransformedFields type (needed by CustomDB)
lines.push(generateTransformedFieldsType(listName, listConfig.fields))
lines.push('')
lines.push(generateModelOutputType(listName, listConfig.fields))
lines.push(generateModelOutputType(listName, listConfig.fields, !!listConfig.isSingleton))
lines.push('')
lines.push(generateModelTypeAlias(listName))
lines.push('')
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -893,8 +893,10 @@ function createCreate<TPrisma extends PrismaClientLike>(
// Access Prisma model dynamically - required because model names are generated at runtime
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const model = (prisma as any)[getDbKey(listName)]
// Singleton lists use Int @id with value always 1 (matching Keystone 6 behaviour)
const createData = isSingletonList(listConfig) ? { id: 1, ...data } : data
const item = await model.create({
data,
data: createData,
})

// 9. Execute list-level afterOperation hook
Expand Down
27 changes: 14 additions & 13 deletions packages/core/tests/singleton.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ describe('Singleton Lists', () => {
it('should allow creating the first record', async () => {
mockPrisma.settings.count.mockResolvedValue(0)
mockPrisma.settings.create.mockResolvedValue({
id: '1',
id: 1,
siteName: 'Test Site',
maintenanceMode: false,
maxUploadSize: 10,
Expand Down Expand Up @@ -146,7 +146,7 @@ describe('Singleton Lists', () => {

it('should return existing record on get()', async () => {
const mockSettings = {
id: '1',
id: 1,
siteName: 'My Site',
maintenanceMode: false,
maxUploadSize: 10,
Expand All @@ -168,7 +168,7 @@ describe('Singleton Lists', () => {
mockPrisma.settings.findFirst.mockResolvedValue(null)
mockPrisma.settings.count.mockResolvedValue(0)
mockPrisma.settings.create.mockResolvedValue({
id: '1',
id: 1,
siteName: 'My Site',
maintenanceMode: false,
maxUploadSize: 10,
Expand All @@ -183,6 +183,7 @@ describe('Singleton Lists', () => {
expect(result?.siteName).toBe('My Site')
expect(mockPrisma.settings.create).toHaveBeenCalledWith({
data: {
id: 1,
siteName: 'My Site',
maintenanceMode: false,
maxUploadSize: 10,
Expand All @@ -207,26 +208,26 @@ describe('Singleton Lists', () => {
describe('delete operation', () => {
it('should block delete on singleton lists', async () => {
mockPrisma.settings.findUnique.mockResolvedValue({
id: '1',
id: 1,
siteName: 'My Site',
maintenanceMode: false,
maxUploadSize: 10,
})

const context = getContext(config, mockPrisma, null)

await expect(context.db.settings.delete({ where: { id: '1' } })).rejects.toThrow(
await expect(context.db.settings.delete({ where: { id: 1 } })).rejects.toThrow(
ValidationError,
)

await expect(context.db.settings.delete({ where: { id: '1' } })).rejects.toThrow(
await expect(context.db.settings.delete({ where: { id: 1 } })).rejects.toThrow(
'singleton list',
)
})

it('should block delete even in sudo mode', async () => {
mockPrisma.settings.findUnique.mockResolvedValue({
id: '1',
id: 1,
siteName: 'My Site',
maintenanceMode: false,
maxUploadSize: 10,
Expand All @@ -235,7 +236,7 @@ describe('Singleton Lists', () => {
const context = getContext(config, mockPrisma, null)
const sudoContext = context.sudo()

await expect(sudoContext.db.settings.delete({ where: { id: '1' } })).rejects.toThrow(
await expect(sudoContext.db.settings.delete({ where: { id: 1 } })).rejects.toThrow(
ValidationError,
)
})
Expand Down Expand Up @@ -267,14 +268,14 @@ describe('Singleton Lists', () => {
describe('update operation', () => {
it('should allow updating the singleton record', async () => {
mockPrisma.settings.findUnique.mockResolvedValue({
id: '1',
id: 1,
siteName: 'My Site',
maintenanceMode: false,
maxUploadSize: 10,
})

mockPrisma.settings.update.mockResolvedValue({
id: '1',
id: 1,
siteName: 'Updated Site',
maintenanceMode: true,
maxUploadSize: 20,
Expand All @@ -285,7 +286,7 @@ describe('Singleton Lists', () => {
const context = getContext(config, mockPrisma, null)

const result = await context.db.settings.update({
where: { id: '1' },
where: { id: 1 },
data: { siteName: 'Updated Site', maintenanceMode: true, maxUploadSize: 20 },
})

Expand All @@ -298,7 +299,7 @@ describe('Singleton Lists', () => {
describe('findUnique operation', () => {
it('should allow findUnique on singleton lists', async () => {
mockPrisma.settings.findFirst.mockResolvedValue({
id: '1',
id: 1,
siteName: 'My Site',
maintenanceMode: false,
maxUploadSize: 10,
Expand All @@ -307,7 +308,7 @@ describe('Singleton Lists', () => {
})

const context = getContext(config, mockPrisma, null)
const result = await context.db.settings.findUnique({ where: { id: '1' } })
const result = await context.db.settings.findUnique({ where: { id: 1 } })

expect(result).toBeDefined()
expect(result?.siteName).toBe('My Site')
Expand Down
Loading