11import Fastify , { type FastifyRequest } from 'fastify'
22import { vi } from 'vitest'
3+ import { MultitenantMigrationStrategy } from '../../config'
34
45async function loadDbPlugins ( {
56 databaseEnableQueryCancellation = false ,
7+ dbMigrationStrategy = MultitenantMigrationStrategy . PROGRESSIVE ,
8+ isMultitenant = false ,
69} : {
710 databaseEnableQueryCancellation ?: boolean
11+ dbMigrationStrategy ?: MultitenantMigrationStrategy
12+ isMultitenant ?: boolean
813} = { } ) {
914 vi . resetModules ( )
1015
@@ -19,43 +24,54 @@ async function loadDbPlugins({
1924 role : 'service_role' ,
2025 } ,
2126 } )
27+ const getTenantConfig = vi . fn ( )
28+ const areMigrationsUpToDate = vi . fn ( )
29+ const runMigrationsOnTenant = vi . fn ( )
30+ const updateTenantMigrationsState = vi . fn ( )
31+ const progressiveMigrations = {
32+ addTenant : vi . fn ( ) ,
33+ }
2234
2335 vi . doMock ( '@internal/database' , ( ) => {
2436 return {
2537 getPostgresConnection,
2638 getServiceKeyUser,
27- getTenantConfig : vi . fn ( ) ,
39+ getTenantConfig,
2840 PgTenantConnection : class { } ,
2941 }
3042 } )
3143
3244 vi . doMock ( '@internal/database/migrations' , ( ) => {
3345 return {
34- areMigrationsUpToDate : vi . fn ( ) ,
46+ areMigrationsUpToDate,
3547 DBMigration : { } ,
3648 lastLocalMigrationName : vi . fn ( ) . mockResolvedValue ( 'initialmigration' ) ,
37- progressiveMigrations : {
38- addTenant : vi . fn ( ) ,
39- } ,
40- runMigrationsOnTenant : vi . fn ( ) ,
41- updateTenantMigrationsState : vi . fn ( ) ,
49+ progressiveMigrations,
50+ runMigrationsOnTenant,
51+ updateTenantMigrationsState,
4252 }
4353 } )
4454
4555 const configModule = await import ( '../../config' )
4656 configModule . getConfig ( { reload : true } )
4757 configModule . mergeConfig ( {
4858 databaseEnableQueryCancellation,
49- isMultitenant : false ,
59+ dbMigrationStrategy,
60+ isMultitenant,
5061 } )
5162
5263 const { db, dbSuperUser } = await import ( './db' )
5364
5465 return {
5566 db,
5667 dbSuperUser,
68+ areMigrationsUpToDate,
5769 getPostgresConnection,
70+ getTenantConfig,
71+ progressiveMigrations,
5872 requestDb,
73+ runMigrationsOnTenant,
74+ updateTenantMigrationsState,
5975 }
6076}
6177
@@ -99,6 +115,285 @@ describe('dbSuperUser plugin', () => {
99115 } )
100116} )
101117
118+ describe ( 'migrations plugin' , ( ) => {
119+ async function buildMigrationApp (
120+ plugins : Awaited < ReturnType < typeof loadDbPlugins > > ,
121+ getTenantId : ( request : FastifyRequest ) => string = ( ) => 'tenant-id'
122+ ) {
123+ const app = Fastify ( )
124+
125+ app . decorateRequest ( 'tenantId' )
126+ app . addHook ( 'onRequest' , async ( request ) => {
127+ request . tenantId = getTenantId ( request )
128+ } )
129+ await app . register ( plugins . dbSuperUser )
130+ app . get ( '/test' , async ( ) => ( { ok : true } ) )
131+
132+ const injectTenant = ( headers : Record < string , string > = { } ) =>
133+ app . inject ( { method : 'GET' , url : '/test' , headers } )
134+
135+ return { app, injectTenant }
136+ }
137+
138+ function waitForImmediate ( ) {
139+ return new Promise ( ( resolve ) => setImmediate ( resolve ) )
140+ }
141+
142+ afterEach ( ( ) => {
143+ vi . doUnmock ( '@internal/database' )
144+ vi . doUnmock ( '@internal/database/migrations' )
145+ vi . restoreAllMocks ( )
146+ vi . resetModules ( )
147+ } )
148+
149+ it ( 'shares the same on-request migration check across concurrent same-tenant requests' , async ( ) => {
150+ const plugins = await loadDbPlugins ( {
151+ dbMigrationStrategy : MultitenantMigrationStrategy . ON_REQUEST ,
152+ isMultitenant : true ,
153+ } )
154+ const tenant = {
155+ databaseUrl : 'postgres://tenant-db' ,
156+ migrationVersion : 'initialmigration' ,
157+ syncMigrationsDone : false ,
158+ }
159+ const migration = Promise . withResolvers < void > ( )
160+
161+ plugins . getTenantConfig . mockResolvedValue ( tenant )
162+ plugins . areMigrationsUpToDate . mockResolvedValue ( false )
163+ plugins . runMigrationsOnTenant . mockReturnValue ( migration . promise )
164+ plugins . updateTenantMigrationsState . mockResolvedValue ( undefined )
165+
166+ const { app, injectTenant } = await buildMigrationApp ( plugins )
167+
168+ try {
169+ const first = injectTenant ( )
170+ const second = injectTenant ( )
171+
172+ await waitForImmediate ( )
173+
174+ expect ( plugins . areMigrationsUpToDate ) . toHaveBeenCalledTimes ( 1 )
175+ expect ( plugins . runMigrationsOnTenant ) . toHaveBeenCalledTimes ( 1 )
176+
177+ migration . resolve ( )
178+
179+ const responses = await Promise . all ( [ first , second ] )
180+ expect ( responses ) . toEqual ( [
181+ expect . objectContaining ( { statusCode : 200 } ) ,
182+ expect . objectContaining ( { statusCode : 200 } ) ,
183+ ] )
184+ expect ( tenant . syncMigrationsDone ) . toBe ( true )
185+
186+ const third = await injectTenant ( )
187+
188+ expect ( third . statusCode ) . toBe ( 200 )
189+ expect ( plugins . areMigrationsUpToDate ) . toHaveBeenCalledTimes ( 1 )
190+ expect ( plugins . runMigrationsOnTenant ) . toHaveBeenCalledTimes ( 1 )
191+ } finally {
192+ await app . close ( )
193+ }
194+ } )
195+
196+ it ( 'shares the on-request migration check across route scopes' , async ( ) => {
197+ const plugins = await loadDbPlugins ( {
198+ dbMigrationStrategy : MultitenantMigrationStrategy . ON_REQUEST ,
199+ isMultitenant : true ,
200+ } )
201+ const tenant = {
202+ databaseUrl : 'postgres://tenant-db' ,
203+ migrationVersion : 'initialmigration' ,
204+ syncMigrationsDone : false ,
205+ }
206+ const migration = Promise . withResolvers < void > ( )
207+
208+ plugins . getTenantConfig . mockResolvedValue ( tenant )
209+ plugins . areMigrationsUpToDate . mockResolvedValue ( false )
210+ plugins . runMigrationsOnTenant . mockReturnValue ( migration . promise )
211+ plugins . updateTenantMigrationsState . mockResolvedValue ( undefined )
212+
213+ // Two separate Fastify apps stand in for two route scopes, each registering
214+ // its own copy of the migrations plugin against the same module state.
215+ const firstScope = await buildMigrationApp ( plugins )
216+ const secondScope = await buildMigrationApp ( plugins )
217+
218+ try {
219+ const first = firstScope . injectTenant ( )
220+ const second = secondScope . injectTenant ( )
221+
222+ await waitForImmediate ( )
223+
224+ expect ( plugins . runMigrationsOnTenant ) . toHaveBeenCalledTimes ( 1 )
225+
226+ migration . resolve ( )
227+
228+ await expect ( Promise . all ( [ first , second ] ) ) . resolves . toEqual ( [
229+ expect . objectContaining ( { statusCode : 200 } ) ,
230+ expect . objectContaining ( { statusCode : 200 } ) ,
231+ ] )
232+ } finally {
233+ await firstScope . app . close ( )
234+ await secondScope . app . close ( )
235+ }
236+ } )
237+
238+ it ( 'shares migration failures across concurrent same-tenant requests and retries later' , async ( ) => {
239+ const plugins = await loadDbPlugins ( {
240+ dbMigrationStrategy : MultitenantMigrationStrategy . ON_REQUEST ,
241+ isMultitenant : true ,
242+ } )
243+ const tenant = {
244+ databaseUrl : 'postgres://tenant-db' ,
245+ migrationVersion : 'initialmigration' ,
246+ syncMigrationsDone : false ,
247+ }
248+ const migration = Promise . withResolvers < void > ( )
249+
250+ plugins . getTenantConfig . mockResolvedValue ( tenant )
251+ plugins . areMigrationsUpToDate . mockResolvedValue ( false )
252+ plugins . runMigrationsOnTenant
253+ . mockReturnValueOnce ( migration . promise )
254+ . mockResolvedValueOnce ( undefined )
255+ plugins . updateTenantMigrationsState . mockResolvedValue ( undefined )
256+
257+ const { app, injectTenant } = await buildMigrationApp ( plugins )
258+
259+ try {
260+ const first = injectTenant ( )
261+ const second = injectTenant ( )
262+
263+ await waitForImmediate ( )
264+
265+ expect ( plugins . runMigrationsOnTenant ) . toHaveBeenCalledTimes ( 1 )
266+
267+ migration . reject ( new Error ( 'migration failed' ) )
268+
269+ const failedResponses = await Promise . all ( [ first , second ] )
270+ expect ( failedResponses ) . toEqual ( [
271+ expect . objectContaining ( { statusCode : 500 } ) ,
272+ expect . objectContaining ( { statusCode : 500 } ) ,
273+ ] )
274+ expect ( tenant . syncMigrationsDone ) . toBe ( false )
275+
276+ const retry = await injectTenant ( )
277+
278+ expect ( retry . statusCode ) . toBe ( 200 )
279+ expect ( plugins . areMigrationsUpToDate ) . toHaveBeenCalledTimes ( 2 )
280+ expect ( plugins . runMigrationsOnTenant ) . toHaveBeenCalledTimes ( 2 )
281+ expect ( tenant . syncMigrationsDone ) . toBe ( true )
282+ } finally {
283+ await app . close ( )
284+ }
285+ } )
286+
287+ it ( 'skips on-request migration checks when the tenant is already marked migrated' , async ( ) => {
288+ const plugins = await loadDbPlugins ( {
289+ dbMigrationStrategy : MultitenantMigrationStrategy . ON_REQUEST ,
290+ isMultitenant : true ,
291+ } )
292+
293+ plugins . getTenantConfig . mockResolvedValue ( {
294+ databaseUrl : 'postgres://tenant-db' ,
295+ migrationVersion : 'initialmigration' ,
296+ syncMigrationsDone : true ,
297+ } )
298+
299+ const { app, injectTenant } = await buildMigrationApp ( plugins )
300+
301+ try {
302+ const response = await injectTenant ( )
303+
304+ expect ( response . statusCode ) . toBe ( 200 )
305+ expect ( plugins . areMigrationsUpToDate ) . not . toHaveBeenCalled ( )
306+ expect ( plugins . runMigrationsOnTenant ) . not . toHaveBeenCalled ( )
307+ } finally {
308+ await app . close ( )
309+ }
310+ } )
311+
312+ it ( 'skips migration execution when tenant migrations are already up to date' , async ( ) => {
313+ const plugins = await loadDbPlugins ( {
314+ dbMigrationStrategy : MultitenantMigrationStrategy . ON_REQUEST ,
315+ isMultitenant : true ,
316+ } )
317+
318+ plugins . getTenantConfig . mockResolvedValue ( {
319+ databaseUrl : 'postgres://tenant-db' ,
320+ migrationVersion : 'initialmigration' ,
321+ syncMigrationsDone : false ,
322+ } )
323+ plugins . areMigrationsUpToDate . mockResolvedValue ( true )
324+
325+ const { app, injectTenant } = await buildMigrationApp ( plugins )
326+
327+ try {
328+ const response = await injectTenant ( )
329+
330+ expect ( response . statusCode ) . toBe ( 200 )
331+ expect ( plugins . areMigrationsUpToDate ) . toHaveBeenCalledTimes ( 1 )
332+ expect ( plugins . runMigrationsOnTenant ) . not . toHaveBeenCalled ( )
333+
334+ const secondResponse = await injectTenant ( )
335+
336+ expect ( secondResponse . statusCode ) . toBe ( 200 )
337+ expect ( plugins . areMigrationsUpToDate ) . toHaveBeenCalledTimes ( 1 )
338+ expect ( plugins . runMigrationsOnTenant ) . not . toHaveBeenCalled ( )
339+ } finally {
340+ await app . close ( )
341+ }
342+ } )
343+
344+ it ( 'does not coalesce on-request migrations for different tenants' , async ( ) => {
345+ const plugins = await loadDbPlugins ( {
346+ dbMigrationStrategy : MultitenantMigrationStrategy . ON_REQUEST ,
347+ isMultitenant : true ,
348+ } )
349+ const migrations = {
350+ 'tenant-a' : Promise . withResolvers < void > ( ) ,
351+ 'tenant-b' : Promise . withResolvers < void > ( ) ,
352+ }
353+
354+ plugins . getTenantConfig . mockImplementation ( async ( tenantId : string ) => ( {
355+ databaseUrl : `postgres://${ tenantId } ` ,
356+ migrationVersion : 'initialmigration' ,
357+ syncMigrationsDone : false ,
358+ } ) )
359+ plugins . areMigrationsUpToDate . mockResolvedValue ( false )
360+ plugins . runMigrationsOnTenant . mockImplementation (
361+ ( { tenantId } : { tenantId : keyof typeof migrations } ) => migrations [ tenantId ] . promise
362+ )
363+ plugins . updateTenantMigrationsState . mockResolvedValue ( undefined )
364+
365+ const { app, injectTenant } = await buildMigrationApp (
366+ plugins ,
367+ ( request ) => request . headers [ 'x-tenant-id' ] as string
368+ )
369+
370+ try {
371+ const first = injectTenant ( { 'x-tenant-id' : 'tenant-a' } )
372+ const second = injectTenant ( { 'x-tenant-id' : 'tenant-b' } )
373+
374+ await waitForImmediate ( )
375+
376+ expect ( plugins . runMigrationsOnTenant ) . toHaveBeenCalledTimes ( 2 )
377+ expect ( plugins . runMigrationsOnTenant ) . toHaveBeenCalledWith (
378+ expect . objectContaining ( { tenantId : 'tenant-a' } )
379+ )
380+ expect ( plugins . runMigrationsOnTenant ) . toHaveBeenCalledWith (
381+ expect . objectContaining ( { tenantId : 'tenant-b' } )
382+ )
383+
384+ migrations [ 'tenant-a' ] . resolve ( )
385+ migrations [ 'tenant-b' ] . resolve ( )
386+
387+ await expect ( Promise . all ( [ first , second ] ) ) . resolves . toEqual ( [
388+ expect . objectContaining ( { statusCode : 200 } ) ,
389+ expect . objectContaining ( { statusCode : 200 } ) ,
390+ ] )
391+ } finally {
392+ await app . close ( )
393+ }
394+ } )
395+ } )
396+
102397describe . each ( [
103398 {
104399 name : 'db plugin' ,
0 commit comments