@pikku/deploy-standalone 0.12.13 → 0.12.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -184,3 +184,542 @@ describe('StandaloneProviderAdapter deploy output', () => {
184
184
  assert.equal(existsSync(join(outDir, 'frontend-assets.gen.js')), false)
185
185
  })
186
186
  })
187
+
188
+ /**
189
+ * Runs the database-file choice the generated entry makes, rather than reading
190
+ * the source it is written in. Asserting on the text cannot tell a working
191
+ * precedence from one that always takes the same branch.
192
+ */
193
+ const chooseDatabaseFile = (source: string, env: NodeJS.ProcessEnv): string => {
194
+ const helper = source.match(/function __pikkuRequireDataDir\(\)[\s\S]*?\n\}/)
195
+ const selection = source.match(
196
+ /const __pikkuDbFile =([\s\S]*?)\n\s*__pikkuMkdirSync/
197
+ )
198
+ assert.ok(helper, 'the entry must define the data-dir helper it calls')
199
+ assert.ok(selection, 'the entry must choose a database file')
200
+
201
+ const choose = new Function(
202
+ 'process',
203
+ '__pikkuJoin',
204
+ `${helper[0]}\nreturn (${selection[1]})`
205
+ ) as (proc: { env: NodeJS.ProcessEnv }, join: typeof join) => string
206
+ return choose({ env }, join)
207
+ }
208
+
209
+ const withDb = {
210
+ ...(baseContext as object),
211
+ db: {
212
+ engine: 'sqlite' as const,
213
+ coercionImportPath: '../../.pikku/db/coercion.gen.js',
214
+ },
215
+ } as never
216
+
217
+ const withPostgres = {
218
+ ...(baseContext as object),
219
+ db: {
220
+ engine: 'postgres' as const,
221
+ coercionImportPath: '../../.pikku/db/coercion.gen.js',
222
+ },
223
+ } as never
224
+
225
+ const withPostgresNoCoercion = {
226
+ ...(baseContext as object),
227
+ db: { engine: 'postgres' as const },
228
+ } as never
229
+
230
+ const withSqliteNoCoercion = {
231
+ ...(baseContext as object),
232
+ db: { engine: 'sqlite' as const },
233
+ } as never
234
+
235
+ for (const runtime of ['node', 'bun'] as const) {
236
+ describe(`StandaloneProviderAdapter postgres wiring (${runtime})`, () => {
237
+ test('the connection is opened from DATABASE_URL', () => {
238
+ const source = new StandaloneProviderAdapter({
239
+ runtime,
240
+ }).generateEntrySource(withPostgres)
241
+
242
+ assert.match(
243
+ source,
244
+ /import \{ PikkuKysely \} from '@pikku\/kysely-postgres'/
245
+ )
246
+ assert.match(source, /process\.env\.DATABASE_URL/)
247
+ })
248
+
249
+ test('a missing DATABASE_URL fails by name rather than by driver error', () => {
250
+ const source = new StandaloneProviderAdapter({
251
+ runtime,
252
+ }).generateEntrySource(withPostgres)
253
+
254
+ assert.match(source, /needs DATABASE_URL set/)
255
+ })
256
+
257
+ test('postgres brings none of the sqlite file handling with it', () => {
258
+ const source = new StandaloneProviderAdapter({
259
+ runtime,
260
+ }).generateEntrySource(withPostgres)
261
+
262
+ // PIKKU_DATA_DIR describes where a bundled database file lives. A
263
+ // postgres build has no file, and demanding the variable anyway would
264
+ // refuse to boot over a directory it never reads.
265
+ assert.doesNotMatch(source, /PIKKU_DATA_DIR/)
266
+ assert.doesNotMatch(source, /PIKKU_DATABASE_FILE/)
267
+ assert.doesNotMatch(source, /SqliteKysely/)
268
+ })
269
+
270
+ test('the connection is handed to the services factory, opened first', () => {
271
+ const source = new StandaloneProviderAdapter({
272
+ runtime,
273
+ }).generateEntrySource(withPostgres)
274
+
275
+ assert.match(
276
+ source,
277
+ /createSingletonServices\(config, \{[\s\S]*?\n kysely,/
278
+ )
279
+ assert.ok(
280
+ source.indexOf('new PikkuKysely') <
281
+ source.indexOf('createSingletonServices(config')
282
+ )
283
+ })
284
+
285
+ test('the coercion map is applied to the postgres connection too', () => {
286
+ const source = new StandaloneProviderAdapter({
287
+ runtime,
288
+ }).generateEntrySource(withPostgres)
289
+
290
+ // The map comes from db/annotations.ts, not from the dialect: an
291
+ // annotated column needs coercing whichever database holds it.
292
+ assert.match(source, /withPlugin\(\s*createCoercionPlugin/)
293
+ })
294
+
295
+ test('an app with no coercion map still gets a database', () => {
296
+ const source = new StandaloneProviderAdapter({
297
+ runtime,
298
+ }).generateEntrySource(withPostgresNoCoercion)
299
+
300
+ // Nothing to coerce is a database with no annotated columns, not a
301
+ // reason to boot the app with no connection at all.
302
+ assert.match(source, /new PikkuKysely/)
303
+ assert.doesNotMatch(source, /createCoercionPlugin/)
304
+ assert.match(source, /\n kysely,/)
305
+ })
306
+
307
+ test('sqlite with no coercion map still gets a database', () => {
308
+ const source = new StandaloneProviderAdapter({
309
+ runtime,
310
+ }).generateEntrySource(withSqliteNoCoercion)
311
+
312
+ assert.match(source, /SqliteKysely/)
313
+ assert.doesNotMatch(source, /createCoercionPlugin/)
314
+ assert.match(source, /\n kysely,/)
315
+ })
316
+
317
+ test('the pool is closed on shutdown, after the server has stopped', () => {
318
+ const source = new StandaloneProviderAdapter({
319
+ runtime,
320
+ }).generateEntrySource(withPostgres)
321
+
322
+ // A pool closed in beforeStop would be gone while the app's own stop
323
+ // hook and the draining server are still entitled to query it.
324
+ assert.match(
325
+ source,
326
+ /afterStop: async \(\) => \{[^}]*__pikkuPg\.close\(\)/
327
+ )
328
+ assert.doesNotMatch(
329
+ source,
330
+ /beforeStop: async \(\) => \{[^}]*__pikkuPg\.close\(\)/
331
+ )
332
+ })
333
+
334
+ test('a sqlite build closes no pool', () => {
335
+ const source = new StandaloneProviderAdapter({
336
+ runtime,
337
+ }).generateEntrySource(withDb)
338
+
339
+ assert.doesNotMatch(source, /__pikkuPg/)
340
+ })
341
+ })
342
+ }
343
+
344
+ describe('StandaloneProviderAdapter database wiring', () => {
345
+ test('a node entry without a database opens none', () => {
346
+ const source = new StandaloneProviderAdapter({
347
+ runtime: 'node',
348
+ }).generateEntrySource(baseContext)
349
+
350
+ assert.doesNotMatch(source, /createNodeSqliteKysely/)
351
+ assert.doesNotMatch(source, /PIKKU_DATA_DIR/)
352
+ })
353
+
354
+ test('a node entry hands the connection to the services factory', () => {
355
+ const source = new StandaloneProviderAdapter({
356
+ runtime: 'node',
357
+ }).generateEntrySource(withDb)
358
+
359
+ assert.match(source, /createNodeSqliteKysely/)
360
+ // The whole point: app code receives `kysely` the way a hosted runtime
361
+ // would give it, rather than the factory finding nothing there.
362
+ assert.match(
363
+ source,
364
+ /createSingletonServices\(config, \{[\s\S]*?\n kysely,/,
365
+ 'kysely must be passed into the services factory, not merely constructed'
366
+ )
367
+ })
368
+
369
+ test('the connection is opened before the services that need it', () => {
370
+ const source = new StandaloneProviderAdapter({
371
+ runtime: 'node',
372
+ }).generateEntrySource(withDb)
373
+
374
+ assert.ok(
375
+ source.indexOf('createNodeSqliteKysely') <
376
+ source.indexOf('createSingletonServices(config'),
377
+ 'a connection built after the factory ran would arrive too late to be used'
378
+ )
379
+ })
380
+
381
+ test('the generated coercion map is applied to the connection', () => {
382
+ const source = new StandaloneProviderAdapter({
383
+ runtime: 'node',
384
+ }).generateEntrySource(withDb)
385
+
386
+ assert.match(source, /from '\.\.\/\.\.\/\.pikku\/db\/coercion\.gen\.js'/)
387
+ // Without it a `date` column reads back as a string and a `bool` as 0/1,
388
+ // so the deployed app disagrees with `pikku dev` about its own row shapes.
389
+ assert.match(
390
+ source,
391
+ /createCoercionPlugin\(\{ map: __pikkuCoercionMap \}\)/
392
+ )
393
+ })
394
+
395
+ test('the database file lives outside the release directory', () => {
396
+ const source = new StandaloneProviderAdapter({
397
+ runtime: 'node',
398
+ }).generateEntrySource(withDb)
399
+
400
+ assert.match(source, /PIKKU_DATA_DIR/)
401
+ // A path derived from the bundle's own location would be swapped out —
402
+ // and deleted — by the next release.
403
+ assert.doesNotMatch(
404
+ source,
405
+ /filename: __pikkuJoin\(__pikkuDirname\(__pikkuFileURLToPath/
406
+ )
407
+ })
408
+
409
+ test('an explicit database file overrides the data directory', () => {
410
+ const source = new StandaloneProviderAdapter({
411
+ runtime: 'node',
412
+ }).generateEntrySource(withDb)
413
+
414
+ // `pikku db migrate` has to open the same file this does; without an
415
+ // override the two can only agree by coincidence.
416
+ assert.equal(
417
+ chooseDatabaseFile(source, {
418
+ PIKKU_DATA_DIR: '/var/lib/pikku',
419
+ PIKKU_DATABASE_FILE: '/srv/shared/app.db',
420
+ }),
421
+ '/srv/shared/app.db',
422
+ 'the override has to win over the data directory, not merely be mentioned'
423
+ )
424
+ })
425
+
426
+ test('the data directory is used when nothing overrides it', () => {
427
+ const source = new StandaloneProviderAdapter({
428
+ runtime: 'node',
429
+ }).generateEntrySource(withDb)
430
+
431
+ assert.equal(
432
+ chooseDatabaseFile(source, { PIKKU_DATA_DIR: '/var/lib/pikku' }),
433
+ join('/var/lib/pikku', 'pikku.db')
434
+ )
435
+ })
436
+
437
+ test('a missing data directory fails by name', () => {
438
+ const source = new StandaloneProviderAdapter({
439
+ runtime: 'node',
440
+ }).generateEntrySource(withDb)
441
+
442
+ assert.throws(
443
+ () => chooseDatabaseFile(source, {}),
444
+ /Set PIKKU_DATA_DIR to a writable directory/,
445
+ 'the error has to name the variable, not surface as a path of undefined'
446
+ )
447
+ })
448
+
449
+ test('the directory is created rather than required to exist', () => {
450
+ const source = new StandaloneProviderAdapter({
451
+ runtime: 'node',
452
+ }).generateEntrySource(withDb)
453
+
454
+ assert.match(
455
+ source,
456
+ /__pikkuMkdirSync\(__pikkuDirname\(__pikkuDbFile\), \{ recursive: true \}\)/
457
+ )
458
+ })
459
+
460
+ test('a database and a frontend do not fight over their path aliases', () => {
461
+ const source = new StandaloneProviderAdapter({
462
+ runtime: 'node',
463
+ }).generateEntrySource({
464
+ ...(baseContext as object),
465
+ frontend: { urlPrefix: '/', spaFallback: true },
466
+ db: {
467
+ engine: 'sqlite' as const,
468
+ coercionImportPath: './coercion.gen.js',
469
+ },
470
+ } as never)
471
+
472
+ // Two imports of node:path are legal; two bindings of one name are not.
473
+ const bound = source.match(/(?:dirname|join) as (\w+)/g) ?? []
474
+ assert.equal(
475
+ new Set(bound).size,
476
+ bound.length,
477
+ `each path helper must bind a distinct name, got ${bound.join(', ')}`
478
+ )
479
+ assert.match(source, /__pikkuJoin\(__pikkuDirname\(__pikkuFileURLToPath/)
480
+ assert.match(source, /__pikkuJoin\(__pikkuRequireDataDir\(\)/)
481
+ })
482
+ })
483
+
484
+ describe('StandaloneProviderAdapter database wiring (bun)', () => {
485
+ test('a bun entry opens SQLite through the bun driver', () => {
486
+ const source = new StandaloneProviderAdapter({
487
+ runtime: 'bun',
488
+ }).generateEntrySource(withDb)
489
+
490
+ // node:sqlite is not available inside a compiled bun binary, so reaching
491
+ // for the node factory here produces an artifact that cannot start.
492
+ assert.match(source, /createBunSqliteKysely/)
493
+ assert.doesNotMatch(source, /kysely-node-sqlite/)
494
+ })
495
+
496
+ test('a bun entry hands the connection to the services factory', () => {
497
+ const source = new StandaloneProviderAdapter({
498
+ runtime: 'bun',
499
+ }).generateEntrySource(withDb)
500
+
501
+ assert.match(
502
+ source,
503
+ /createSingletonServices\(config, \{[\s\S]*?\n kysely,/
504
+ )
505
+ })
506
+
507
+ test('a bun entry defines the data-dir helper it calls', () => {
508
+ const source = new StandaloneProviderAdapter({
509
+ runtime: 'bun',
510
+ }).generateEntrySource(withDb)
511
+
512
+ // Calling it without defining it is a ReferenceError at first boot.
513
+ assert.match(source, /function __pikkuRequireDataDir\(\)/)
514
+ })
515
+
516
+ test('a bun entry without a database opens none', () => {
517
+ const source = new StandaloneProviderAdapter({
518
+ runtime: 'bun',
519
+ }).generateEntrySource(baseContext)
520
+
521
+ assert.doesNotMatch(source, /createBunSqliteKysely/)
522
+ assert.doesNotMatch(source, /PIKKU_DATA_DIR/)
523
+ })
524
+ })
525
+
526
+ const withLifecycle = {
527
+ ...(baseContext as object),
528
+ lifecycle: { importPath: './lifecycle.js', variable: 'lifecycle' },
529
+ } as never
530
+
531
+ for (const runtime of ['node', 'bun'] as const) {
532
+ describe(`StandaloneProviderAdapter server lifecycle (${runtime})`, () => {
533
+ test('an app that declares no lifecycle gets no hook calls', () => {
534
+ const source = new StandaloneProviderAdapter({
535
+ runtime,
536
+ }).generateEntrySource(baseContext)
537
+
538
+ assert.doesNotMatch(source, /__pikkuLifecycle/)
539
+ assert.match(source, /server\.enableExitOnSignals\(\)/)
540
+ })
541
+
542
+ test('the lifecycle is imported under a reserved name', () => {
543
+ const source = new StandaloneProviderAdapter({
544
+ runtime,
545
+ }).generateEntrySource(withLifecycle)
546
+
547
+ assert.match(
548
+ source,
549
+ /import \{ lifecycle as __pikkuLifecycle \} from '\.\/lifecycle\.js'/
550
+ )
551
+ })
552
+
553
+ test('beforeStart runs after init and before the port opens', () => {
554
+ const source = new StandaloneProviderAdapter({
555
+ runtime,
556
+ }).generateEntrySource(withLifecycle)
557
+
558
+ const init = source.indexOf('await server.init()')
559
+ const before = source.indexOf('__pikkuLifecycle?.beforeStart?.')
560
+ const start = source.indexOf('await server.start()')
561
+
562
+ assert.ok(init !== -1 && before !== -1 && start !== -1)
563
+ assert.ok(
564
+ init < before && before < start,
565
+ 'work a hook must finish before the first request has to run before the port opens'
566
+ )
567
+ })
568
+
569
+ test('afterStart runs once the server is listening', () => {
570
+ const source = new StandaloneProviderAdapter({
571
+ runtime,
572
+ }).generateEntrySource(withLifecycle)
573
+
574
+ assert.ok(
575
+ source.indexOf('await server.start()') <
576
+ source.indexOf('__pikkuLifecycle?.afterStart?.')
577
+ )
578
+ })
579
+
580
+ test('the hooks are handed the services the app was built with', () => {
581
+ const source = new StandaloneProviderAdapter({
582
+ runtime,
583
+ }).generateEntrySource(withLifecycle)
584
+
585
+ for (const hook of [
586
+ 'beforeStart',
587
+ 'afterStart',
588
+ 'beforeStop',
589
+ 'afterStop',
590
+ ]) {
591
+ assert.match(
592
+ source,
593
+ new RegExp(
594
+ `__pikkuLifecycle\\?\\.${hook}\\?\\.\\(singletonServices\\)`
595
+ ),
596
+ `${hook} must receive singletonServices`
597
+ )
598
+ }
599
+ })
600
+
601
+ test('the stop hooks are given to the signal handler that owns shutdown', () => {
602
+ const source = new StandaloneProviderAdapter({
603
+ runtime,
604
+ }).generateEntrySource(withLifecycle)
605
+
606
+ assert.match(
607
+ source,
608
+ /server\.enableExitOnSignals\(\{ beforeStop:/,
609
+ 'a separate signal listener would race the server teardown'
610
+ )
611
+ })
612
+
613
+ test('the lifecycle import is optional and never emitted twice', () => {
614
+ const source = new StandaloneProviderAdapter({
615
+ runtime,
616
+ }).generateEntrySource(withLifecycle)
617
+
618
+ assert.equal(
619
+ source.split('as __pikkuLifecycle').length - 1,
620
+ 1,
621
+ 'a duplicate binding would not compile'
622
+ )
623
+ })
624
+ })
625
+ }
626
+
627
+ for (const runtime of ['node', 'bun'] as const) {
628
+ describe(`StandaloneProviderAdapter command line (${runtime})`, () => {
629
+ const generate = (ctx: unknown) =>
630
+ new StandaloneProviderAdapter({ runtime }).generateEntrySource(
631
+ ctx as never
632
+ )
633
+
634
+ test('argv is parsed before the config factory or the database', () => {
635
+ const source = generate(withDb)
636
+
637
+ assert.ok(
638
+ source.indexOf('parseStandaloneCommand') <
639
+ source.indexOf('async function main()'),
640
+ 'version and help have to answer on a machine where neither works yet'
641
+ )
642
+ assert.match(source, /if \(__pikkuCommand\.kind === 'exit'\) process\.exit/)
643
+ })
644
+
645
+ test('the version reported is the project’s own', () => {
646
+ assert.match(
647
+ generate({ ...(withDb as object), version: '4.5.6' }),
648
+ /version: '4\.5\.6'/
649
+ )
650
+ assert.match(generate(withDb), /version: 'unknown'/)
651
+ })
652
+
653
+ test('a command runs against the database the app itself opened', () => {
654
+ const source = generate(withDb)
655
+
656
+ assert.ok(
657
+ source.indexOf('const kysely =') <
658
+ source.indexOf('await runStandaloneCommand('),
659
+ 'the database is opened first so a migration cannot target another one'
660
+ )
661
+ assert.match(source, /databaseFile: __pikkuDbFile,/)
662
+ })
663
+
664
+ test('a completed command returns before a port is bound', () => {
665
+ const source = generate(withDb)
666
+ const dispatch = source.indexOf('await runStandaloneCommand(')
667
+
668
+ assert.ok(dispatch < source.indexOf('createSingletonServices(config'))
669
+ assert.match(source, /=== 'done'\) \{\n {4}return\n {2}\}/)
670
+ })
671
+
672
+ test('a postgres build closes its pool before the process ends', () => {
673
+ assert.match(
674
+ generate(withPostgres),
675
+ /=== 'done'\) \{\n {4}await __pikkuPg\.close\(\)\n {4}return\n {2}\}/
676
+ )
677
+ })
678
+
679
+ test('the postgres command target is handed the live connection', () => {
680
+ assert.match(generate(withPostgres), /sql: __pikkuPg\.sql,/)
681
+ })
682
+
683
+ test('migrations are read from the engine directory the build wrote', () => {
684
+ assert.match(
685
+ generate(withDb),
686
+ /resolveMigrationsDir\(__pikkuJoin\(.*, 'db', 'sqlite'\)\)/
687
+ )
688
+ assert.match(
689
+ generate(withPostgres),
690
+ /resolveMigrationsDir\(__pikkuJoin\(.*, 'db', 'postgres'\)\)/
691
+ )
692
+ })
693
+
694
+ test('a build with no database announces none and answers no db command', () => {
695
+ const source = generate(baseContext)
696
+
697
+ assert.match(source, /hasDb: false,/)
698
+ assert.doesNotMatch(source, /engine:/)
699
+ assert.doesNotMatch(source, /runStandaloneCommand/)
700
+ assert.match(source, /if \(__pikkuCommand\.kind !== 'serve'\) process\.exit\(0\)/)
701
+ })
702
+ })
703
+ }
704
+
705
+ describe('StandaloneProviderAdapter migrations path per runtime', () => {
706
+ test('a node bundle reads them from its own directory', () => {
707
+ assert.match(
708
+ new StandaloneProviderAdapter({ runtime: 'node' }).generateEntrySource(
709
+ withDb
710
+ ),
711
+ /resolveMigrationsDir\(__pikkuJoin\(__pikkuDirname\(__pikkuFileURLToPath\(import\.meta\.url\)\), 'db', 'sqlite'\)\)/
712
+ )
713
+ })
714
+
715
+ test('a compiled bun binary reads them beside the executable', () => {
716
+ // import.meta.url points inside the embedded filesystem, which holds no
717
+ // migrations — the operator unpacked them next to the binary instead.
718
+ assert.match(
719
+ new StandaloneProviderAdapter({ runtime: 'bun' }).generateEntrySource(
720
+ withDb
721
+ ),
722
+ /resolveMigrationsDir\(__pikkuJoin\(__pikkuDirname\(process\.execPath\), 'db', 'sqlite'\)\)/
723
+ )
724
+ })
725
+ })