sounding 0.0.4 → 0.1.0

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.
@@ -5,28 +5,93 @@ const { getDefaultConfig } = require('./default-config')
5
5
  const { mergeConfig } = require('./merge-config')
6
6
  const { normalizeUserConfig } = require('./normalize-config')
7
7
  const { buildManagedSqlitePath, resolveManagedRoot } = require('./resolve-datastore')
8
-
9
- function resolveModuleFromApp(appPath, moduleId) {
10
- return require(require.resolve(moduleId, { paths: [appPath, process.cwd(), __dirname] }))
11
- }
12
-
8
+ const { createSoundingError } = require('./create-error')
9
+ const { loadDependencyFromApp, resolveDependencyFromApp } = require('./resolve-dependency')
10
+ const { validateConfig } = require('./validate-config')
11
+
12
+ /** @typedef {import('./types').AnyRecord} AnyRecord */
13
+ /** @typedef {import('./types').SoundingAppManager} SoundingAppManager */
14
+ /** @typedef {import('./types').SoundingAppManagerOptions} SoundingAppManagerOptions */
15
+ /** @typedef {import('./types').SoundingConfig} SoundingConfig */
16
+ /** @typedef {import('./types').SoundingRuntime} SoundingRuntime */
17
+ /** @typedef {import('./types').SoundingSailsApp} SoundingSailsApp */
18
+
19
+ /**
20
+ * @param {string} appPath
21
+ * @returns {SoundingConfig}
22
+ */
13
23
  function loadAppSoundingConfig(appPath) {
14
24
  const configPath = path.join(appPath, 'config', 'sounding.js')
15
25
 
16
26
  if (!fs.existsSync(configPath)) {
17
- return getDefaultConfig()
27
+ return validateConfig(getDefaultConfig())
18
28
  }
19
29
 
20
30
  delete require.cache[require.resolve(configPath)]
21
31
  const loaded = require(configPath)
22
- return mergeConfig(getDefaultConfig(), normalizeUserConfig(loaded?.sounding || {}))
32
+ return validateConfig(
33
+ /** @type {SoundingConfig} */ (
34
+ mergeConfig(getDefaultConfig(), normalizeUserConfig(loaded?.sounding || {}))
35
+ )
36
+ )
37
+ }
38
+
39
+ /**
40
+ * @param {string} appPath
41
+ * @param {{ resolveImplementation?: (moduleId: string, options?: { paths?: string[] }) => string }} [options]
42
+ * @returns {any}
43
+ */
44
+ function defaultLoadSails(appPath, options = {}) {
45
+ return loadDependencyFromApp({
46
+ appPath,
47
+ moduleId: 'sails',
48
+ purpose: 'load your Sails app',
49
+ install: 'npm install sails',
50
+ resolveImplementation: options.resolveImplementation,
51
+ })
23
52
  }
24
53
 
25
- function buildManagedDatastoreOverrides(config, appPath) {
54
+ /**
55
+ * @param {SoundingConfig} config
56
+ * @param {string} appPath
57
+ * @param {{ resolveImplementation?: (moduleId: string, options?: { paths?: string[] }) => string }} [options]
58
+ * @returns {void}
59
+ */
60
+ function assertManagedDatastoreDependency(config, appPath, options = {}) {
61
+ if (config.datastore?.mode !== 'managed') {
62
+ return
63
+ }
64
+
65
+ const adapter = config.datastore.adapter || 'sails-sqlite'
66
+
67
+ if (adapter !== 'sails-sqlite') {
68
+ return
69
+ }
70
+
71
+ resolveDependencyFromApp({
72
+ appPath,
73
+ moduleId: adapter,
74
+ purpose: 'run managed datastore trials',
75
+ install: 'npm install -D sails-sqlite',
76
+ suggestion:
77
+ 'Or set `sounding.datastore` to `inherit` or configure an external datastore if this app should reuse its own test database.',
78
+ resolveImplementation: options.resolveImplementation,
79
+ })
80
+ }
81
+
82
+ /**
83
+ * @param {SoundingConfig} config
84
+ * @param {string} appPath
85
+ * @param {{ resolveImplementation?: (moduleId: string, options?: { paths?: string[] }) => string }} [options]
86
+ * @returns {AnyRecord}
87
+ */
88
+ function buildManagedDatastoreOverrides(config, appPath, options = {}) {
26
89
  if (config.datastore?.mode !== 'managed') {
27
90
  return {}
28
91
  }
29
92
 
93
+ assertManagedDatastoreDependency(config, appPath, options)
94
+
30
95
  const identity = config.datastore.identity || 'default'
31
96
 
32
97
  return {
@@ -49,6 +114,10 @@ function buildManagedDatastoreOverrides(config, appPath) {
49
114
  }
50
115
  }
51
116
 
117
+ /**
118
+ * @param {Partial<SoundingConfig>} [config]
119
+ * @returns {{ install(): void, uninstall(): void }}
120
+ */
52
121
  function createOutputFilter(config = {}) {
53
122
  if (config.app?.quiet === false) {
54
123
  return {
@@ -121,6 +190,76 @@ function createOutputFilter(config = {}) {
121
190
  }
122
191
  }
123
192
 
193
+ /**
194
+ * @param {'load' | 'lift'} mode
195
+ * @returns {import('./types').SoundingAppLifecycleState}
196
+ */
197
+ function createLifecycleState(mode) {
198
+ return {
199
+ mode,
200
+ status: 'idle',
201
+ runs: 0,
202
+ reuses: 0,
203
+ reloads: 0,
204
+ durationMs: null,
205
+ startedAt: null,
206
+ readyAt: null,
207
+ error: null,
208
+ }
209
+ }
210
+
211
+ /**
212
+ * @returns {boolean}
213
+ */
214
+ function shouldReportLifecycle() {
215
+ return (
216
+ process.env.SOUNDING_LIFECYCLE === 'verbose' ||
217
+ process.env.SOUNDING_DIAGNOSTICS === 'verbose'
218
+ )
219
+ }
220
+
221
+ /**
222
+ * @param {string} message
223
+ * @returns {void}
224
+ */
225
+ function reportLifecycle(message) {
226
+ if (!shouldReportLifecycle()) {
227
+ return
228
+ }
229
+
230
+ process.stderr.write(`[sounding] ${message}\n`)
231
+ }
232
+
233
+ /**
234
+ * @param {string} method
235
+ * @param {Record<string, any>} options
236
+ * @param {string[]} allowed
237
+ * @returns {void}
238
+ */
239
+ function assertKnownAppManagerOptions(method, options, allowed) {
240
+ const unknown = Object.keys(options || {}).filter((key) => !allowed.includes(key))
241
+
242
+ if (unknown.length === 0) {
243
+ return
244
+ }
245
+
246
+ throw createSoundingError({
247
+ code: 'E_SOUNDING_APP_MANAGER_OPTION_UNKNOWN',
248
+ name: 'SoundingAppLifecycleError',
249
+ message: `Sounding app manager option \`${unknown[0]}\` is not supported for ${method}().`,
250
+ details: {
251
+ option: unknown[0],
252
+ allowed,
253
+ method,
254
+ },
255
+ })
256
+ }
257
+
258
+ /**
259
+ * @param {SoundingConfig} config
260
+ * @param {string} appPath
261
+ * @returns {string | null}
262
+ */
124
263
  function resolveManagedDatastoreFile(config, appPath) {
125
264
  if (config.datastore?.mode !== 'managed') {
126
265
  return null
@@ -138,11 +277,16 @@ function resolveManagedDatastoreFile(config, appPath) {
138
277
  })
139
278
  }
140
279
 
280
+ /**
281
+ * @param {SoundingAppManagerOptions} [options]
282
+ * @returns {SoundingAppManager}
283
+ */
141
284
  function createAppManager({
142
285
  appPath = process.cwd(),
143
286
  environment = 'test',
144
287
  liftOptions = {},
145
288
  SailsConstructor,
289
+ loadSails = defaultLoadSails,
146
290
  } = {}) {
147
291
  let loadedApp = null
148
292
  let liftedApp = null
@@ -150,6 +294,10 @@ function createAppManager({
150
294
  let liftPromise = null
151
295
  const managedArtifacts = new Set()
152
296
  let outputFilter = null
297
+ const lifecycle = {
298
+ load: createLifecycleState('load'),
299
+ lift: createLifecycleState('lift'),
300
+ }
153
301
 
154
302
  function resolveAppPath() {
155
303
  return path.resolve(appPath)
@@ -164,7 +312,7 @@ function createAppManager({
164
312
  return SailsConstructor
165
313
  }
166
314
 
167
- return resolveModuleFromApp(resolveAppPath(), 'sails').constructor
315
+ return loadSails(resolveAppPath()).constructor
168
316
  }
169
317
 
170
318
  function buildOptions(mode) {
@@ -176,7 +324,9 @@ function createAppManager({
176
324
  managedArtifacts.add(managedFile)
177
325
  }
178
326
 
327
+ /** @type {AnyRecord} */
179
328
  const appConfig = config.app || {}
329
+ /** @type {AnyRecord} */
180
330
  const baseOptions = {
181
331
  appPath: resolveAppPath(),
182
332
  environment: appConfig.environment || environment,
@@ -194,6 +344,7 @@ function createAppManager({
194
344
  appConfig.loadOptions || {}
195
345
  )
196
346
  : mergeConfig(appConfig.liftOptions || {}, liftOptions)
347
+ /** @type {AnyRecord} */
197
348
  const nextOptions = mergeConfig(baseOptions, modeOptions)
198
349
 
199
350
  if (mode === 'load' && nextOptions.datastores?.content) {
@@ -218,33 +369,199 @@ function createAppManager({
218
369
  }
219
370
  }
220
371
 
221
- async function load() {
372
+ /**
373
+ * @param {SoundingSailsApp} app
374
+ * @returns {void}
375
+ */
376
+ function activateGlobalApp(app) {
377
+ globalThis.sails = app
378
+ globalThis.sounding = app.sounding || app.hooks?.sounding
379
+ }
380
+
381
+ /**
382
+ * @returns {void}
383
+ */
384
+ function syncGlobalApp() {
385
+ const activeApp = liftedApp || loadedApp
386
+
387
+ if (activeApp) {
388
+ activateGlobalApp(activeApp)
389
+ return
390
+ }
391
+
392
+ delete globalThis.sails
393
+ delete globalThis.sounding
394
+ outputFilter?.uninstall()
395
+ }
396
+
397
+ /**
398
+ * @param {'load' | 'lift'} mode
399
+ * @returns {void}
400
+ */
401
+ function recordLifecycleStart(mode) {
402
+ const entry = lifecycle[mode]
403
+ entry.status = 'loading'
404
+ entry.runs += 1
405
+ entry.error = null
406
+ entry.startedAt = new Date().toISOString()
407
+ entry.readyAt = null
408
+ entry.durationMs = null
409
+ }
410
+
411
+ /**
412
+ * @param {'load' | 'lift'} mode
413
+ * @param {number} startedAt
414
+ * @returns {void}
415
+ */
416
+ function recordLifecycleReady(mode, startedAt) {
417
+ const entry = lifecycle[mode]
418
+ entry.status = 'ready'
419
+ entry.durationMs = Date.now() - startedAt
420
+ entry.readyAt = new Date().toISOString()
421
+ reportLifecycle(`app ${mode} ready in ${entry.durationMs}ms`)
422
+ }
423
+
424
+ /**
425
+ * @param {'load' | 'lift'} mode
426
+ * @param {unknown} error
427
+ * @returns {void}
428
+ */
429
+ function recordLifecycleError(mode, error) {
430
+ const entry = lifecycle[mode]
431
+ entry.status = 'error'
432
+ entry.error = error instanceof Error ? error.message : String(error)
433
+ reportLifecycle(`app ${mode} failed: ${entry.error}`)
434
+ }
435
+
436
+ /**
437
+ * @param {'load' | 'lift'} mode
438
+ * @returns {void}
439
+ */
440
+ function recordLifecycleReuse(mode) {
441
+ const entry = lifecycle[mode]
442
+ entry.reuses += 1
443
+ reportLifecycle(`app ${mode} reused warm instance`)
444
+ }
445
+
446
+ /**
447
+ * @param {'load' | 'lift'} mode
448
+ * @returns {void}
449
+ */
450
+ function recordLifecycleReload(mode) {
451
+ lifecycle[mode].reloads += 1
452
+ reportLifecycle(`app ${mode} reload requested`)
453
+ }
454
+
455
+ /**
456
+ * @param {'load' | 'lift'} mode
457
+ * @returns {void}
458
+ */
459
+ function recordLifecycleIdle(mode) {
460
+ lifecycle[mode].status = 'idle'
461
+ }
462
+
463
+ /**
464
+ * @param {SoundingSailsApp} app
465
+ * @returns {Promise<void>}
466
+ */
467
+ async function lowerApp(app) {
468
+ await new Promise((resolve) => {
469
+ app.lower(() => resolve())
470
+ })
471
+ }
472
+
473
+ /**
474
+ * @param {'load' | 'lift'} mode
475
+ * @returns {Promise<void>}
476
+ */
477
+ async function lowerMode(mode) {
478
+ const app = mode === 'load' ? loadedApp : liftedApp
479
+
480
+ if (mode === 'load') {
481
+ loadedApp = null
482
+ loadPromise = null
483
+ } else {
484
+ liftedApp = null
485
+ liftPromise = null
486
+ }
487
+
488
+ if (app) {
489
+ await lowerApp(app)
490
+ }
491
+
492
+ recordLifecycleIdle(mode)
493
+ syncGlobalApp()
494
+ }
495
+
496
+ /**
497
+ * @param {'load' | 'lift'} mode
498
+ * @param {{ reload?: boolean }} [options]
499
+ * @returns {Promise<void>}
500
+ */
501
+ async function prepareReload(mode, options = {}) {
502
+ if (!options.reload) {
503
+ return
504
+ }
505
+
506
+ recordLifecycleReload(mode)
507
+
508
+ if (mode === 'load' && loadPromise) {
509
+ await loadPromise.catch(() => {})
510
+ }
511
+
512
+ if (mode === 'lift' && liftPromise) {
513
+ await liftPromise.catch(() => {})
514
+ }
515
+
516
+ await lowerMode(mode)
517
+ }
518
+
519
+ /**
520
+ * @returns {SoundingAppManager['lifecycle']}
521
+ */
522
+ function getLifecycleSnapshot() {
523
+ return {
524
+ load: { ...lifecycle.load },
525
+ lift: { ...lifecycle.lift },
526
+ }
527
+ }
528
+
529
+ async function load(options = {}) {
530
+ assertKnownAppManagerOptions('load', options, ['reload'])
531
+ await prepareReload('load', options)
532
+
222
533
  if (loadedApp) {
534
+ activateGlobalApp(loadedApp)
535
+ recordLifecycleReuse('load')
223
536
  return loadedApp
224
537
  }
225
538
 
226
539
  if (loadPromise) {
540
+ recordLifecycleReuse('load')
227
541
  return loadPromise
228
542
  }
229
543
 
230
544
  const Sails = resolveSailsConstructor()
231
545
  const sailsApp = new Sails()
232
546
  const nextLoadOptions = buildOptions('load')
547
+ const startedAt = Date.now()
548
+ recordLifecycleStart('load')
233
549
  outputFilter?.install()
234
550
 
235
551
  loadPromise = new Promise((resolve, reject) => {
236
552
  sailsApp.load(nextLoadOptions, (error, loadedSails) => {
237
553
  if (error) {
238
554
  loadPromise = null
555
+ recordLifecycleError('load', error)
239
556
  cleanupManagedArtifacts().catch(() => {})
240
- outputFilter?.uninstall()
557
+ syncGlobalApp()
241
558
  reject(error)
242
559
  return
243
560
  }
244
561
 
245
562
  loadedApp = loadedSails
246
- globalThis.sails = loadedSails
247
- globalThis.sounding = loadedSails.sounding
563
+ activateGlobalApp(loadedSails)
564
+ recordLifecycleReady('load', startedAt)
248
565
  resolve(loadedSails)
249
566
  })
250
567
  })
@@ -252,33 +569,42 @@ function createAppManager({
252
569
  return loadPromise
253
570
  }
254
571
 
255
- async function lift() {
572
+ async function lift(options = {}) {
573
+ assertKnownAppManagerOptions('lift', options, ['reload'])
574
+ await prepareReload('lift', options)
575
+
256
576
  if (liftedApp) {
577
+ activateGlobalApp(liftedApp)
578
+ recordLifecycleReuse('lift')
257
579
  return liftedApp
258
580
  }
259
581
 
260
582
  if (liftPromise) {
583
+ recordLifecycleReuse('lift')
261
584
  return liftPromise
262
585
  }
263
586
 
264
587
  const Sails = resolveSailsConstructor()
265
588
  const sailsApp = new Sails()
266
589
  const nextLiftOptions = buildOptions('lift')
590
+ const startedAt = Date.now()
591
+ recordLifecycleStart('lift')
267
592
  outputFilter?.install()
268
593
 
269
594
  liftPromise = new Promise((resolve, reject) => {
270
595
  sailsApp.lift(nextLiftOptions, (error, liftedSails) => {
271
596
  if (error) {
272
597
  liftPromise = null
598
+ recordLifecycleError('lift', error)
273
599
  cleanupManagedArtifacts().catch(() => {})
274
- outputFilter?.uninstall()
600
+ syncGlobalApp()
275
601
  reject(error)
276
602
  return
277
603
  }
278
604
 
279
605
  liftedApp = liftedSails
280
- globalThis.sails = liftedSails
281
- globalThis.sounding = liftedSails.sounding
606
+ activateGlobalApp(liftedSails)
607
+ recordLifecycleReady('lift', startedAt)
282
608
  resolve(liftedSails)
283
609
  })
284
610
  })
@@ -286,8 +612,35 @@ function createAppManager({
286
612
  return liftPromise
287
613
  }
288
614
 
615
+ function resolveRuntimeMode(options = {}) {
616
+ if (options.app !== undefined) {
617
+ if (options.app === 'load' || options.app === 'lift') {
618
+ return options.app
619
+ }
620
+
621
+ throw createSoundingError({
622
+ code: 'E_SOUNDING_APP_MODE_UNKNOWN',
623
+ name: 'SoundingAppLifecycleError',
624
+ message: `Sounding app lifecycle mode must be \`load\` or \`lift\`. Received \`${options.app}\`.`,
625
+ details: {
626
+ app: options.app,
627
+ allowed: ['load', 'lift'],
628
+ },
629
+ })
630
+ }
631
+
632
+ if (options.transport === 'http') {
633
+ return 'lift'
634
+ }
635
+
636
+ return 'load'
637
+ }
638
+
289
639
  async function runtime(options = {}) {
290
- const app = options.http ? await lift() : await load()
640
+ assertKnownAppManagerOptions('runtime', options, ['app', 'transport', 'reload'])
641
+ const mode = resolveRuntimeMode(options)
642
+ const lifecycleOptions = { reload: options.reload }
643
+ const app = mode === 'lift' ? await lift(lifecycleOptions) : await load(lifecycleOptions)
291
644
  return app.sounding || app.hooks?.sounding
292
645
  }
293
646
 
@@ -299,18 +652,13 @@ function createAppManager({
299
652
  loadPromise = null
300
653
  liftPromise = null
301
654
 
302
- await Promise.all(
303
- apps.map(
304
- (app) =>
305
- new Promise((resolve) => {
306
- app.lower(() => resolve())
307
- })
308
- )
309
- )
655
+ await Promise.all(apps.map((app) => lowerApp(app)))
310
656
 
311
657
  delete globalThis.sails
312
658
  delete globalThis.sounding
313
659
  outputFilter?.uninstall()
660
+ recordLifecycleIdle('load')
661
+ recordLifecycleIdle('lift')
314
662
  await cleanupManagedArtifacts()
315
663
  }
316
664
 
@@ -320,10 +668,16 @@ function createAppManager({
320
668
  runtime,
321
669
  lower,
322
670
  resolveConfig,
671
+ get lifecycle() {
672
+ return getLifecycleSnapshot()
673
+ },
323
674
  }
324
675
  }
325
676
 
326
677
  module.exports = {
678
+ assertManagedDatastoreDependency,
679
+ buildManagedDatastoreOverrides,
327
680
  createAppManager,
681
+ defaultLoadSails,
328
682
  loadAppSoundingConfig,
329
683
  }