diff --git a/packages/core/src/middleware/bootstrap.test.ts b/packages/core/src/middleware/bootstrap.test.ts index 587c54d37..d06924723 100644 --- a/packages/core/src/middleware/bootstrap.test.ts +++ b/packages/core/src/middleware/bootstrap.test.ts @@ -2,8 +2,8 @@ * Bootstrap Middleware Tests */ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' import { Hono } from 'hono' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { bootstrapMiddleware, resetBootstrap } from './bootstrap' // Mock the services that bootstrap depends on @@ -11,10 +11,14 @@ vi.mock('../services/collection-sync', () => ({ syncCollections: vi.fn().mockResolvedValue([]) })) +vi.mock('../services/form-collection-sync', () => ({ + syncAllFormCollections: vi.fn().mockResolvedValue(undefined) +})) + vi.mock('../services/migrations', () => { const mockRunPendingMigrations = vi.fn().mockResolvedValue(undefined) return { - MigrationService: vi.fn().mockImplementation(function() { + MigrationService: vi.fn().mockImplementation(function () { this.runPendingMigrations = mockRunPendingMigrations return this }) @@ -25,7 +29,7 @@ vi.mock('../services/plugin-bootstrap', () => { const mockIsBootstrapNeeded = vi.fn().mockResolvedValue(true) const mockBootstrapCorePlugins = vi.fn().mockResolvedValue(undefined) return { - PluginBootstrapService: vi.fn().mockImplementation(function() { + PluginBootstrapService: vi.fn().mockImplementation(function () { this.isBootstrapNeeded = mockIsBootstrapNeeded this.bootstrapCorePlugins = mockBootstrapCorePlugins return this @@ -35,6 +39,7 @@ vi.mock('../services/plugin-bootstrap', () => { // Import the mocked modules after mocking import { syncCollections } from '../services/collection-sync' +import { syncAllFormCollections } from '../services/form-collection-sync' import { MigrationService } from '../services/migrations' import { PluginBootstrapService } from '../services/plugin-bootstrap' @@ -64,8 +69,8 @@ describe('bootstrapMiddleware', () => { // Reset bootstrap state before each test resetBootstrap() vi.clearAllMocks() - consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { }) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) }) afterEach(() => { @@ -232,6 +237,58 @@ describe('bootstrapMiddleware', () => { expect(consoleSpy).toHaveBeenCalledWith('[Bootstrap] Plugin bootstrap skipped (disableAll is true)') }) + it('should run bootstrap only once for concurrent cold-start requests', async () => { + const app = new Hono() + const env = createMockEnv() + + let releaseMigration: (() => void) | undefined + let markMigrationStarted: (() => void) | undefined + const migrationGate = new Promise((resolve) => { + releaseMigration = resolve + }) + const migrationStarted = new Promise((resolve) => { + markMigrationStarted = resolve + }) + + const migrationServiceMock = vi.mocked(MigrationService) + migrationServiceMock.mockImplementation(function () { + this.runPendingMigrations = vi.fn().mockImplementation(async () => { + markMigrationStarted?.() + await migrationGate + }) + return this + }) + + app.use('*', async (c, next) => { + c.env = env as any + await next() + }) + app.use('*', bootstrapMiddleware()) + app.get('/test', (c) => c.json({ ok: true })) + + const firstRequest = app.request('/test') + await migrationStarted + const secondRequest = app.request('/test') + + expect(MigrationService).toHaveBeenCalledTimes(1) + + releaseMigration?.() + const [firstResponse, secondResponse] = await Promise.all([firstRequest, secondRequest]) + + expect(firstResponse.status).toBe(200) + expect(secondResponse.status).toBe(200) + expect(MigrationService).toHaveBeenCalledTimes(1) + expect(syncCollections).toHaveBeenCalledTimes(1) + expect(syncAllFormCollections).toHaveBeenCalledTimes(1) + expect(PluginBootstrapService).toHaveBeenCalledTimes(1) + + migrationServiceMock.mockReset() + migrationServiceMock.mockImplementation(function () { + this.runPendingMigrations = vi.fn().mockResolvedValue(undefined) + return this + }) + }) + it('should continue on fatal bootstrap error', async () => { const app = new Hono() const env = createMockEnv() @@ -258,6 +315,7 @@ describe('bootstrapMiddleware', () => { describe('resetBootstrap', () => { beforeEach(() => { + resetBootstrap() vi.clearAllMocks() }) @@ -266,7 +324,7 @@ describe('resetBootstrap', () => { }) it('should allow bootstrap to run again after reset', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { }) const app = new Hono() const env = createMockEnv() @@ -291,4 +349,4 @@ describe('resetBootstrap', () => { consoleSpy.mockRestore() }) -}) +}) \ No newline at end of file diff --git a/packages/core/src/middleware/bootstrap.ts b/packages/core/src/middleware/bootstrap.ts index bf6fc131e..8693bb33b 100644 --- a/packages/core/src/middleware/bootstrap.ts +++ b/packages/core/src/middleware/bootstrap.ts @@ -1,9 +1,9 @@ import { Context, Next } from "hono"; +import type { SonicJSConfig } from "../app"; import { syncCollections } from "../services/collection-sync"; import { syncAllFormCollections } from "../services/form-collection-sync"; import { MigrationService } from "../services/migrations"; import { PluginBootstrapService } from "../services/plugin-bootstrap"; -import type { SonicJSConfig } from "../app"; type Bindings = { DB: D1Database; @@ -15,6 +15,7 @@ type Bindings = { // Track if bootstrap has been run in this worker instance let bootstrapComplete = false; +let bootstrapInFlight: Promise | null = null; /** * Verify security-critical environment configuration at startup. @@ -67,7 +68,7 @@ export function verifySecurityConfig(env: Bindings): void { if (hasCritical) { throw new Error( "[SonicJS Security] CRITICAL: Production deployment is missing a secure JWT_SECRET. " + - "Set it via `wrangler secret put JWT_SECRET` before deploying." + "Set it via `wrangler secret put JWT_SECRET` before deploying." ); } } @@ -99,52 +100,59 @@ export function bootstrapMiddleware(config: SonicJSConfig = {}) { return next(); } - try { - console.log("[Bootstrap] Starting system initialization..."); - - // 1. Run database migrations first - console.log("[Bootstrap] Running database migrations..."); - const migrationService = new MigrationService(c.env.DB); - await migrationService.runPendingMigrations(); - - // 2. Sync collection configurations - console.log("[Bootstrap] Syncing collection configurations..."); - try { - await syncCollections(c.env.DB); - } catch (error) { - console.error("[Bootstrap] Error syncing collections:", error); - // Continue bootstrap even if collection sync fails - } - - // 2b. Sync form-derived shadow collections - console.log("[Bootstrap] Syncing form collections..."); - try { - await syncAllFormCollections(c.env.DB); - } catch (error) { - console.error("[Bootstrap] Error syncing form collections:", error); - } - - // 3. Bootstrap core plugins (unless disableAll is set) - if (!config.plugins?.disableAll) { - console.log("[Bootstrap] Bootstrapping core plugins..."); - const bootstrapService = new PluginBootstrapService(c.env.DB); - - // Check if bootstrap is needed - const needsBootstrap = await bootstrapService.isBootstrapNeeded(); - if (needsBootstrap) { - await bootstrapService.bootstrapCorePlugins(); + if (!bootstrapInFlight) { + bootstrapInFlight = (async () => { + try { + console.log("[Bootstrap] Starting system initialization..."); + + // 1. Run database migrations first + console.log("[Bootstrap] Running database migrations..."); + const migrationService = new MigrationService(c.env.DB); + await migrationService.runPendingMigrations(); + + // 2. Sync collection configurations + console.log("[Bootstrap] Syncing collection configurations..."); + try { + await syncCollections(c.env.DB); + } catch (error) { + console.error("[Bootstrap] Error syncing collections:", error); + // Continue bootstrap even if collection sync fails + } + + // 2b. Sync form-derived shadow collections + console.log("[Bootstrap] Syncing form collections..."); + try { + await syncAllFormCollections(c.env.DB); + } catch (error) { + console.error("[Bootstrap] Error syncing form collections:", error); + } + + // 3. Bootstrap core plugins (unless disableAll is set) + if (!config.plugins?.disableAll) { + console.log("[Bootstrap] Bootstrapping core plugins..."); + const bootstrapService = new PluginBootstrapService(c.env.DB); + + // Check if bootstrap is needed + const needsBootstrap = await bootstrapService.isBootstrapNeeded(); + if (needsBootstrap) { + await bootstrapService.bootstrapCorePlugins(); + } + } else { + console.log("[Bootstrap] Plugin bootstrap skipped (disableAll is true)"); + } + + // Mark bootstrap as complete for this worker instance + bootstrapComplete = true; + console.log("[Bootstrap] System initialization completed"); + } catch (error) { + console.error("[Bootstrap] Error during system initialization:", error); + // Don't prevent the app from starting, but log the error + } finally { + bootstrapInFlight = null; } - } else { - console.log("[Bootstrap] Plugin bootstrap skipped (disableAll is true)"); - } - - // Mark bootstrap as complete for this worker instance - bootstrapComplete = true; - console.log("[Bootstrap] System initialization completed"); - } catch (error) { - console.error("[Bootstrap] Error during system initialization:", error); - // Don't prevent the app from starting, but log the error + })(); } + await bootstrapInFlight; // 4. Verify security configuration (outside try/catch so critical // errors in production propagate and prevent insecure deployments) @@ -159,4 +167,5 @@ export function bootstrapMiddleware(config: SonicJSConfig = {}) { */ export function resetBootstrap() { bootstrapComplete = false; -} + bootstrapInFlight = null; +} \ No newline at end of file