From 655cdd49f6ab13d22255f570a45a97253b308268 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Mar 2026 09:39:22 +0000 Subject: [PATCH 1/2] 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 --- .changeset/fix-singleton-int-id.md | 34 +++++++++++++++ examples/blog/prisma/schema.prisma | 2 +- .../__snapshots__/prisma.test.ts.snap | 20 +++++++++ packages/cli/src/generator/prisma.test.ts | 43 +++++++++++++++++++ packages/cli/src/generator/prisma.ts | 8 +++- packages/cli/src/generator/types.ts | 11 +++-- packages/core/src/context/index.ts | 4 +- packages/core/tests/singleton.test.ts | 27 ++++++------ 8 files changed, 129 insertions(+), 20 deletions(-) create mode 100644 .changeset/fix-singleton-int-id.md diff --git a/.changeset/fix-singleton-int-id.md b/.changeset/fix-singleton-int-id.md new file mode 100644 index 00000000..ec41648d --- /dev/null +++ b/.changeset/fix-singleton-int-id.md @@ -0,0 +1,34 @@ +--- +'@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. diff --git a/examples/blog/prisma/schema.prisma b/examples/blog/prisma/schema.prisma index d10d0d6e..4b167e65 100644 --- a/examples/blog/prisma/schema.prisma +++ b/examples/blog/prisma/schema.prisma @@ -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? diff --git a/packages/cli/src/generator/__snapshots__/prisma.test.ts.snap b/packages/cli/src/generator/__snapshots__/prisma.test.ts.snap index 2e61b855..7a6347b8 100644 --- a/packages/cli/src/generator/__snapshots__/prisma.test.ts.snap +++ b/packages/cli/src/generator/__snapshots__/prisma.test.ts.snap @@ -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" diff --git a/packages/cli/src/generator/prisma.test.ts b/packages/cli/src/generator/prisma.test.ts index 9731a971..4df75d46 100644 --- a/packages/cli/src/generator/prisma.test.ts +++ b/packages/cli/src/generator/prisma.test.ts @@ -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)') + }) }) }) diff --git a/packages/cli/src/generator/prisma.ts b/packages/cli/src/generator/prisma.ts index ff90db60..2cc8f58a 100644 --- a/packages/cli/src/generator/prisma.ts +++ b/packages/cli/src/generator/prisma.ts @@ -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<{ diff --git a/packages/cli/src/generator/types.ts b/packages/cli/src/generator/types.ts index 61cf6ee3..3abb91aa 100644 --- a/packages/cli/src/generator/types.ts +++ b/packages/cli/src/generator/types.ts @@ -121,11 +121,16 @@ function generateTransformedFieldsType( * 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 { +function generateModelOutputType( + listName: string, + fields: Record, + 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 @@ -993,7 +998,7 @@ export function generateTypes(config: OpenSaasConfig): string { // 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('') diff --git a/packages/core/src/context/index.ts b/packages/core/src/context/index.ts index af57702d..27405295 100644 --- a/packages/core/src/context/index.ts +++ b/packages/core/src/context/index.ts @@ -893,8 +893,10 @@ function createCreate( // 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 diff --git a/packages/core/tests/singleton.test.ts b/packages/core/tests/singleton.test.ts index b073c25c..0e782592 100644 --- a/packages/core/tests/singleton.test.ts +++ b/packages/core/tests/singleton.test.ts @@ -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, @@ -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, @@ -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, @@ -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, @@ -207,7 +208,7 @@ 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, @@ -215,18 +216,18 @@ describe('Singleton Lists', () => { 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, @@ -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, ) }) @@ -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, @@ -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 }, }) @@ -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, @@ -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') From b25b71a2b721a976d97c022743b26241dadbdcfb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Mar 2026 09:49:07 +0000 Subject: [PATCH 2/2] Run pnpm format https://claude.ai/code/session_01Keyrjjdhy3ypkZNzeg34re --- .changeset/fix-singleton-int-id.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/fix-singleton-int-id.md b/.changeset/fix-singleton-int-id.md index ec41648d..5629ee91 100644 --- a/.changeset/fix-singleton-int-id.md +++ b/.changeset/fix-singleton-int-id.md @@ -22,6 +22,7 @@ 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, ...);