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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Backend startup now verifies `guild_role_grants` relation availability before
booting the web app and fails fast with a migration-required error when schema
state is invalid
- Deploy/runtime DB guardrails now share the same required-relation verifier
(`guild_role_grants`, `guild_automation_manifests`,
`guild_automation_runs`, `guild_automation_drifts`) and deploy now enforces
`prisma migrate status` pre-rollout, failing fast on migration/schema drift
before restarting runtime services
- AutoMod web client now includes template listing/apply flows wired to
`/api/guilds/:guildId/automod/templates` and
`/api/guilds/:guildId/automod/templates/:templateId/apply`
Expand Down
12 changes: 12 additions & 0 deletions docs/CI_CD.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ If the configured URL is missing the deploy path, the workflow appends
de-duplicated, so it will never retry with malformed paths such as
`/webhook/deploy/webhook/deploy`.

Deploy webhook runtime (`scripts/deploy.sh`) now blocks rollout before restarting
services unless all database preconditions pass:

1. `npx prisma migrate deploy`
2. `npx prisma migrate status`
3. required relation guard
(`guild_role_grants`, `guild_automation_manifests`,
`guild_automation_runs`, `guild_automation_drifts`)

If any pre-rollout DB guard fails, deploy exits immediately with a classified
migration/schema error and runtime services are not restarted.

### Deploy secrets (how to add)

Add these repository secrets in **Settings → Secrets and variables → Actions**:
Expand Down
32 changes: 2 additions & 30 deletions packages/backend/src/startup/verifyRequiredDatabaseState.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,5 @@
import { getPrismaClient } from '@lucky/shared/utils'

const MISSING_SCHEMA_ERROR_CODE = 'ERR_DB_SCHEMA_MISSING'

type DatabaseStartupError = Error & { code?: string }
import { verifyRequiredDatabaseRelations } from '@lucky/shared/utils'

export async function verifyRequiredDatabaseState(): Promise<void> {
const prisma = getPrismaClient()

try {
await prisma.guildRoleGrant.count({
take: 1,
})
} catch (error) {
const maybePrismaError = error as {
code?: string
message?: string
meta?: { table?: string }
}

if (maybePrismaError.code === 'P2021') {
const table = maybePrismaError.meta?.table ?? 'guild_role_grants'
const startupError = new Error(
`Required database relation "${table}" is missing. Run migrations before starting backend.`,
{ cause: error },
) as DatabaseStartupError
startupError.code = MISSING_SCHEMA_ERROR_CODE
throw startupError
}

throw error
}
await verifyRequiredDatabaseRelations()
}
131 changes: 131 additions & 0 deletions packages/backend/tests/unit/startup/requiredDatabaseRelations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { describe, expect, test, jest } from '@jest/globals'
import {
REQUIRED_DATABASE_RELATIONS,
verifyRequiredDatabaseRelations,
} from '@lucky/shared/utils/requiredDatabaseRelations'

type CountMock = jest.MockedFunction<(args: { take: 1 }) => Promise<number>>

function createPrismaStub() {
const guildRoleGrantCount = jest.fn(async () => 1) as CountMock
const guildAutomationManifestCount = jest.fn(async () => 1) as CountMock
const guildAutomationRunCount = jest.fn(async () => 1) as CountMock
const guildAutomationDriftCount = jest.fn(async () => 1) as CountMock

const prisma = {
guildRoleGrant: { count: guildRoleGrantCount },
guildAutomationManifest: { count: guildAutomationManifestCount },
guildAutomationRun: { count: guildAutomationRunCount },
guildAutomationDrift: { count: guildAutomationDriftCount },
}

return {
prisma,
guildRoleGrantCount,
guildAutomationManifestCount,
guildAutomationRunCount,
guildAutomationDriftCount,
}
}

function missingRelationMessage(table: string): string {
return (
`Required database relation "${table}" is missing. ` +
'Run `npx prisma migrate deploy` before starting backend.'
)
}

describe('verifyRequiredDatabaseRelations', () => {
test('verifies all sentinel relations when schema is healthy', async () => {
const {
prisma,
guildRoleGrantCount,
guildAutomationManifestCount,
guildAutomationRunCount,
guildAutomationDriftCount,
} = createPrismaStub()

await expect(
verifyRequiredDatabaseRelations(prisma),
).resolves.toBeUndefined()

expect(REQUIRED_DATABASE_RELATIONS).toEqual([
'guild_role_grants',
'guild_automation_manifests',
'guild_automation_runs',
'guild_automation_drifts',
])
expect(guildRoleGrantCount).toHaveBeenCalledWith({ take: 1 })
expect(guildAutomationManifestCount).toHaveBeenCalledWith({ take: 1 })
expect(guildAutomationRunCount).toHaveBeenCalledWith({ take: 1 })
expect(guildAutomationDriftCount).toHaveBeenCalledWith({ take: 1 })
})

test('maps missing guild_role_grants relation to migration guidance', async () => {
const { prisma, guildRoleGrantCount } = createPrismaStub()
guildRoleGrantCount.mockRejectedValueOnce({
code: 'P2021',
meta: { table: 'guild_role_grants' },
})

await expect(verifyRequiredDatabaseRelations(prisma)).rejects.toMatchObject(
{
code: 'ERR_DB_SCHEMA_MISSING',
message: missingRelationMessage('guild_role_grants'),
},
)
})

test('maps missing guild_automation_manifests relation to migration guidance', async () => {
const { prisma, guildAutomationManifestCount } = createPrismaStub()
guildAutomationManifestCount.mockRejectedValueOnce({
code: 'P2021',
meta: { table: 'guild_automation_manifests' },
})

await expect(verifyRequiredDatabaseRelations(prisma)).rejects.toMatchObject(
{
code: 'ERR_DB_SCHEMA_MISSING',
message: missingRelationMessage('guild_automation_manifests'),
},
)
})

test('maps missing guild_automation_runs relation to migration guidance', async () => {
const { prisma, guildAutomationRunCount } = createPrismaStub()
guildAutomationRunCount.mockRejectedValueOnce({
code: 'P2021',
meta: { table: 'guild_automation_runs' },
})

await expect(verifyRequiredDatabaseRelations(prisma)).rejects.toMatchObject(
{
code: 'ERR_DB_SCHEMA_MISSING',
message: missingRelationMessage('guild_automation_runs'),
},
)
})

test('maps missing guild_automation_drifts relation to migration guidance', async () => {
const { prisma, guildAutomationDriftCount } = createPrismaStub()
guildAutomationDriftCount.mockRejectedValueOnce({
code: 'P2021',
meta: { table: 'guild_automation_drifts' },
})

await expect(verifyRequiredDatabaseRelations(prisma)).rejects.toMatchObject(
{
code: 'ERR_DB_SCHEMA_MISSING',
message: missingRelationMessage('guild_automation_drifts'),
},
)
})

test('rethrows non-P2021 errors unchanged', async () => {
const { prisma, guildRoleGrantCount } = createPrismaStub()
const error = new Error('database offline')
guildRoleGrantCount.mockRejectedValueOnce(error)

await expect(verifyRequiredDatabaseRelations(prisma)).rejects.toBe(error)
})
})
Original file line number Diff line number Diff line change
@@ -1,64 +1,47 @@
import { beforeEach, describe, expect, test, jest } from '@jest/globals'

const countMock = jest.fn()
const verifyRequiredDatabaseRelationsMock = jest.fn()

jest.mock('@lucky/shared/utils', () => ({
getPrismaClient: () => ({
guildRoleGrant: {
count: (...args: unknown[]) => countMock(...args),
},
}),
verifyRequiredDatabaseRelations: (...args: unknown[]) =>
verifyRequiredDatabaseRelationsMock(...args),
}))

import { verifyRequiredDatabaseState } from '../../../src/startup/verifyRequiredDatabaseState'

describe('verifyRequiredDatabaseState', () => {
const missingRelationMessage =
'Required database relation "guild_automation_runs" is missing. ' +
'Run `npx prisma migrate deploy` before starting backend.'

beforeEach(() => {
jest.clearAllMocks()
})

test('passes when guild_role_grants relation is available', async () => {
countMock.mockResolvedValue(1)
test('delegates schema verification to shared relation guard', async () => {
verifyRequiredDatabaseRelationsMock.mockResolvedValue(undefined)

await expect(verifyRequiredDatabaseState()).resolves.toBeUndefined()
expect(countMock).toHaveBeenCalledWith({ take: 1 })
expect(verifyRequiredDatabaseRelationsMock).toHaveBeenCalledTimes(1)
})

test('maps missing relation error to actionable migration message', async () => {
const prismaError = {
code: 'P2021',
meta: { table: 'guild_role_grants' },
}
countMock.mockRejectedValue(prismaError)

try {
await verifyRequiredDatabaseState()
throw new Error('Expected verification to throw')
} catch (error) {
expect(error).toBeInstanceOf(Error)
expect(error).toMatchObject({
code: 'ERR_DB_SCHEMA_MISSING',
message:
'Required database relation "guild_role_grants" is missing. Run migrations before starting backend.',
})
expect(error).toHaveProperty('cause', prismaError)
}
})

test('uses default table name when prisma error omits meta.table', async () => {
countMock.mockRejectedValue({
code: 'P2021',
meta: {},
})
test('propagates actionable missing-relation startup error unchanged', async () => {
const missingRelationError = new Error(
missingRelationMessage,
) as Error & { code?: string }
missingRelationError.code = 'ERR_DB_SCHEMA_MISSING'
verifyRequiredDatabaseRelationsMock.mockRejectedValueOnce(
missingRelationError,
)

await expect(verifyRequiredDatabaseState()).rejects.toThrow(
'Required database relation "guild_role_grants" is missing. Run migrations before starting backend.',
await expect(verifyRequiredDatabaseState()).rejects.toBe(
missingRelationError,
)
})

test('rethrows unknown prisma errors unchanged', async () => {
test('propagates unknown errors unchanged', async () => {
const error = new Error('database offline')
countMock.mockRejectedValue(error)
verifyRequiredDatabaseRelationsMock.mockRejectedValueOnce(error)

await expect(verifyRequiredDatabaseState()).rejects.toBe(error)
})
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export * from './ffmpeg/ffmpegWrapper'
export * from './timerManager'
export * from './general/embeds'
export * from './database/prismaClient'
export * from './requiredDatabaseRelations'
95 changes: 95 additions & 0 deletions packages/shared/src/utils/requiredDatabaseRelations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { PrismaClient } from '../generated/prisma/client.js'

import { getPrismaClient } from './database/prismaClient'

export const REQUIRED_DATABASE_STATE_ERROR_CODE = 'ERR_DB_SCHEMA_MISSING'
export const REQUIRED_DATABASE_RELATIONS = [
'guild_role_grants',
'guild_automation_manifests',
'guild_automation_runs',
'guild_automation_drifts',
] as const

type RequiredDatabaseRelationsPrismaModels = Pick<
PrismaClient,
| 'guildRoleGrant'
| 'guildAutomationManifest'
| 'guildAutomationRun'
| 'guildAutomationDrift'
>

export type RequiredDatabaseStatePrisma = {
guildRoleGrant: Pick<
RequiredDatabaseRelationsPrismaModels['guildRoleGrant'],
'count'
>
guildAutomationManifest: Pick<
RequiredDatabaseRelationsPrismaModels['guildAutomationManifest'],
'count'
>
guildAutomationRun: Pick<
RequiredDatabaseRelationsPrismaModels['guildAutomationRun'],
'count'
>
guildAutomationDrift: Pick<
RequiredDatabaseRelationsPrismaModels['guildAutomationDrift'],
'count'
>
}

type PrismaRelationError = {
code?: string
meta?: { table?: string }
}

type DatabaseStateError = Error & {
code?: string
}

type RelationCheck = {
table: (typeof REQUIRED_DATABASE_RELATIONS)[number]
check: (prisma: RequiredDatabaseStatePrisma) => Promise<unknown>
}

const relationChecks: RelationCheck[] = [
{
table: 'guild_role_grants',
check: (prisma) => prisma.guildRoleGrant.count({ take: 1 }),
},
{
table: 'guild_automation_manifests',
check: (prisma) => prisma.guildAutomationManifest.count({ take: 1 }),
},
{
table: 'guild_automation_runs',
check: (prisma) => prisma.guildAutomationRun.count({ take: 1 }),
},
{
table: 'guild_automation_drifts',
check: (prisma) => prisma.guildAutomationDrift.count({ take: 1 }),
},
]

export async function verifyRequiredDatabaseRelations(
prisma: RequiredDatabaseStatePrisma = getPrismaClient(),
): Promise<void> {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
for (const relationCheck of relationChecks) {
try {
await relationCheck.check(prisma)
} catch (error) {
const prismaError = error as PrismaRelationError
if (prismaError.code !== 'P2021') {
throw error
}

const table = prismaError.meta?.table ?? relationCheck.table
const startupError = new Error(
`Required database relation "${table}" is missing. ` +
'Run `npx prisma migrate deploy` before starting backend.',
{ cause: error },
) as DatabaseStateError
startupError.code = REQUIRED_DATABASE_STATE_ERROR_CODE
throw startupError
}
}
}
Loading
Loading