@c9up/ream 0.2.8 → 0.2.10

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.
Files changed (183) hide show
  1. package/README.md +1 -0
  2. package/dist/Application.d.ts +26 -6
  3. package/dist/Application.d.ts.map +1 -1
  4. package/dist/Application.js +40 -12
  5. package/dist/Application.js.map +1 -1
  6. package/dist/ErrorBoundary.d.ts.map +1 -1
  7. package/dist/ErrorBoundary.js +2 -1
  8. package/dist/ErrorBoundary.js.map +1 -1
  9. package/dist/Ignitor.d.ts +13 -0
  10. package/dist/Ignitor.d.ts.map +1 -1
  11. package/dist/Ignitor.js +149 -26
  12. package/dist/Ignitor.js.map +1 -1
  13. package/dist/bodyparser/MultipartFile.d.ts +8 -0
  14. package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
  15. package/dist/bodyparser/MultipartFile.js +46 -1
  16. package/dist/bodyparser/MultipartFile.js.map +1 -1
  17. package/dist/commands/Migrate.d.ts +9 -0
  18. package/dist/commands/Migrate.d.ts.map +1 -0
  19. package/dist/commands/Migrate.js +16 -0
  20. package/dist/commands/Migrate.js.map +1 -0
  21. package/dist/commands/MigrateBase.d.ts +27 -0
  22. package/dist/commands/MigrateBase.d.ts.map +1 -0
  23. package/dist/commands/MigrateBase.js +77 -0
  24. package/dist/commands/MigrateBase.js.map +1 -0
  25. package/dist/commands/MigrateRollback.d.ts +9 -0
  26. package/dist/commands/MigrateRollback.d.ts.map +1 -0
  27. package/dist/commands/MigrateRollback.js +16 -0
  28. package/dist/commands/MigrateRollback.js.map +1 -0
  29. package/dist/commands/MigrateStatus.d.ts +9 -0
  30. package/dist/commands/MigrateStatus.d.ts.map +1 -0
  31. package/dist/commands/MigrateStatus.js +19 -0
  32. package/dist/commands/MigrateStatus.js.map +1 -0
  33. package/dist/commands/ScheduleList.d.ts +18 -0
  34. package/dist/commands/ScheduleList.d.ts.map +1 -0
  35. package/dist/commands/ScheduleList.js +55 -0
  36. package/dist/commands/ScheduleList.js.map +1 -0
  37. package/dist/commands/ScheduleRun.d.ts +19 -0
  38. package/dist/commands/ScheduleRun.d.ts.map +1 -0
  39. package/dist/commands/ScheduleRun.js +61 -0
  40. package/dist/commands/ScheduleRun.js.map +1 -0
  41. package/dist/commands/index.d.ts +15 -0
  42. package/dist/commands/index.d.ts.map +1 -0
  43. package/dist/commands/index.js +63 -0
  44. package/dist/commands/index.js.map +1 -0
  45. package/dist/commands/scheduler_resolver.d.ts +12 -0
  46. package/dist/commands/scheduler_resolver.d.ts.map +1 -0
  47. package/dist/commands/scheduler_resolver.js +19 -0
  48. package/dist/commands/scheduler_resolver.js.map +1 -0
  49. package/dist/env/Env.d.ts.map +1 -1
  50. package/dist/env/Env.js +2 -1
  51. package/dist/env/Env.js.map +1 -1
  52. package/dist/env/loadEnvFiles.d.ts.map +1 -1
  53. package/dist/env/loadEnvFiles.js +5 -1
  54. package/dist/env/loadEnvFiles.js.map +1 -1
  55. package/dist/env/nodeEnv.d.ts +24 -0
  56. package/dist/env/nodeEnv.d.ts.map +1 -0
  57. package/dist/env/nodeEnv.js +40 -0
  58. package/dist/env/nodeEnv.js.map +1 -0
  59. package/dist/errors/ReamError.d.ts +6 -1
  60. package/dist/errors/ReamError.d.ts.map +1 -1
  61. package/dist/errors/ReamError.js +8 -2
  62. package/dist/errors/ReamError.js.map +1 -1
  63. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  64. package/dist/graphql/GraphQLEngine.js +3 -2
  65. package/dist/graphql/GraphQLEngine.js.map +1 -1
  66. package/dist/http/Exception.d.ts.map +1 -1
  67. package/dist/http/Exception.js +2 -1
  68. package/dist/http/Exception.js.map +1 -1
  69. package/dist/http/Request.d.ts +18 -3
  70. package/dist/http/Request.d.ts.map +1 -1
  71. package/dist/http/Request.js +71 -8
  72. package/dist/http/Request.js.map +1 -1
  73. package/dist/http/Response.d.ts +11 -4
  74. package/dist/http/Response.d.ts.map +1 -1
  75. package/dist/http/Response.js +46 -17
  76. package/dist/http/Response.js.map +1 -1
  77. package/dist/index.d.ts +3 -3
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +2 -2
  80. package/dist/index.js.map +1 -1
  81. package/dist/router/Router.d.ts.map +1 -1
  82. package/dist/router/Router.js +14 -5
  83. package/dist/router/Router.js.map +1 -1
  84. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  85. package/dist/rpc/RpcRouter.js +2 -1
  86. package/dist/rpc/RpcRouter.js.map +1 -1
  87. package/dist/scheduler/ScheduleProvider.d.ts +14 -0
  88. package/dist/scheduler/ScheduleProvider.d.ts.map +1 -1
  89. package/dist/scheduler/ScheduleProvider.js +62 -14
  90. package/dist/scheduler/ScheduleProvider.js.map +1 -1
  91. package/dist/scheduler/config.d.ts +29 -0
  92. package/dist/scheduler/config.d.ts.map +1 -0
  93. package/dist/scheduler/config.js +14 -0
  94. package/dist/scheduler/config.js.map +1 -0
  95. package/dist/scheduler/index.d.ts +4 -0
  96. package/dist/scheduler/index.d.ts.map +1 -1
  97. package/dist/scheduler/index.js +3 -0
  98. package/dist/scheduler/index.js.map +1 -1
  99. package/dist/scheduler/locks/LockBackend.d.ts +13 -3
  100. package/dist/scheduler/locks/LockBackend.d.ts.map +1 -1
  101. package/dist/scheduler/locks/LockBackend.js +20 -4
  102. package/dist/scheduler/locks/LockBackend.js.map +1 -1
  103. package/dist/scheduler/locks/MemoryLockBackend.d.ts +5 -6
  104. package/dist/scheduler/locks/MemoryLockBackend.d.ts.map +1 -1
  105. package/dist/scheduler/locks/MemoryLockBackend.js +5 -13
  106. package/dist/scheduler/locks/MemoryLockBackend.js.map +1 -1
  107. package/dist/scheduler/locks/RedisLockBackend.d.ts +53 -0
  108. package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -0
  109. package/dist/scheduler/locks/RedisLockBackend.js +83 -0
  110. package/dist/scheduler/locks/RedisLockBackend.js.map +1 -0
  111. package/dist/scheduler/locks/locks.d.ts +39 -0
  112. package/dist/scheduler/locks/locks.d.ts.map +1 -0
  113. package/dist/scheduler/locks/locks.js +42 -0
  114. package/dist/scheduler/locks/locks.js.map +1 -0
  115. package/dist/scheduler/locks/quasar.d.ts +17 -0
  116. package/dist/scheduler/locks/quasar.d.ts.map +1 -0
  117. package/dist/scheduler/locks/quasar.js +51 -0
  118. package/dist/scheduler/locks/quasar.js.map +1 -0
  119. package/dist/security/CookieSigner.d.ts +16 -13
  120. package/dist/security/CookieSigner.d.ts.map +1 -1
  121. package/dist/security/CookieSigner.js +33 -14
  122. package/dist/security/CookieSigner.js.map +1 -1
  123. package/dist/services/encryption.d.ts +12 -2
  124. package/dist/services/encryption.d.ts.map +1 -1
  125. package/dist/services/encryption.js +11 -1
  126. package/dist/services/encryption.js.map +1 -1
  127. package/dist/session/Session.d.ts +8 -7
  128. package/dist/session/Session.d.ts.map +1 -1
  129. package/dist/session/Session.js.map +1 -1
  130. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  131. package/dist/session/SessionMiddleware.js +4 -1
  132. package/dist/session/SessionMiddleware.js.map +1 -1
  133. package/dist/testing/ApiResponse.d.ts.map +1 -1
  134. package/dist/testing/ApiResponse.js +1 -1
  135. package/dist/testing/ApiResponse.js.map +1 -1
  136. package/dist/testing/RequestBuilder.d.ts.map +1 -1
  137. package/dist/testing/RequestBuilder.js +1 -1
  138. package/dist/testing/RequestBuilder.js.map +1 -1
  139. package/dist/utils/Macroable.d.ts.map +1 -1
  140. package/dist/utils/Macroable.js +1 -0
  141. package/dist/utils/Macroable.js.map +1 -1
  142. package/events.win32-x64-msvc.node +0 -0
  143. package/index.win32-x64-msvc.node +0 -0
  144. package/package.json +9 -1
  145. package/scheduler.win32-x64-msvc.node +0 -0
  146. package/src/Application.ts +42 -15
  147. package/src/ErrorBoundary.ts +3 -1
  148. package/src/Ignitor.ts +160 -30
  149. package/src/bodyparser/MultipartFile.ts +48 -1
  150. package/src/commands/Migrate.ts +18 -0
  151. package/src/commands/MigrateBase.ts +81 -0
  152. package/src/commands/MigrateRollback.ts +18 -0
  153. package/src/commands/MigrateStatus.ts +22 -0
  154. package/src/commands/ScheduleList.ts +66 -0
  155. package/src/commands/ScheduleRun.ts +65 -0
  156. package/src/commands/index.ts +73 -0
  157. package/src/commands/scheduler_resolver.ts +23 -0
  158. package/src/env/Env.ts +2 -1
  159. package/src/env/loadEnvFiles.ts +5 -1
  160. package/src/env/nodeEnv.ts +38 -0
  161. package/src/errors/ReamError.ts +9 -2
  162. package/src/graphql/GraphQLEngine.ts +3 -2
  163. package/src/http/Exception.ts +2 -1
  164. package/src/http/Request.ts +73 -8
  165. package/src/http/Response.ts +58 -19
  166. package/src/index.ts +8 -1
  167. package/src/router/Router.ts +14 -5
  168. package/src/rpc/RpcRouter.ts +2 -1
  169. package/src/scheduler/ScheduleProvider.ts +65 -15
  170. package/src/scheduler/config.ts +34 -0
  171. package/src/scheduler/index.ts +8 -0
  172. package/src/scheduler/locks/LockBackend.ts +26 -3
  173. package/src/scheduler/locks/MemoryLockBackend.ts +5 -19
  174. package/src/scheduler/locks/RedisLockBackend.ts +114 -0
  175. package/src/scheduler/locks/locks.ts +49 -0
  176. package/src/scheduler/locks/quasar.ts +67 -0
  177. package/src/security/CookieSigner.ts +36 -14
  178. package/src/services/encryption.ts +12 -2
  179. package/src/session/Session.ts +8 -7
  180. package/src/session/SessionMiddleware.ts +4 -1
  181. package/src/testing/ApiResponse.ts +1 -2
  182. package/src/testing/RequestBuilder.ts +1 -2
  183. package/src/utils/Macroable.ts +1 -0
@@ -110,6 +110,8 @@ export class MultipartFile {
110
110
 
111
111
  /** Magic-byte fingerprint — set by {@link detectType}; absent for content `file-type` can't detect (text: txt/csv/svg/json). */
112
112
  #detected?: { ext: string; mime: string }
113
+ /** Whether the magic-byte pass has run, however it turned out. */
114
+ #detectionRan = false
113
115
 
114
116
  constructor(options: {
115
117
  fieldName: string
@@ -136,9 +138,22 @@ export class MultipartFile {
136
138
  * extension in force — same fallback AdonisJS uses.
137
139
  */
138
140
  async detectType(): Promise<void> {
139
- const { fileTypeFromBuffer } = await import('file-type')
141
+ const { fileTypeFromBuffer, supportedExtensions } = await import('file-type')
142
+ detectableExtensions = supportedExtensions
140
143
  const result = await fileTypeFromBuffer(this.content)
141
144
  if (result) this.#detected = { ext: result.ext, mime: result.mime }
145
+ this.#detectionRan = true
146
+ }
147
+
148
+ /**
149
+ * Where {@link mime} and {@link extname} came from.
150
+ *
151
+ * `'detected'` means the bytes said so. `'claimed'` means they did not, and
152
+ * what is reported is the filename and the header the client sent — which
153
+ * the client chose.
154
+ */
155
+ get typeSource(): 'detected' | 'claimed' {
156
+ return this.#detected ? 'detected' : 'claimed'
142
157
  }
143
158
 
144
159
  /**
@@ -201,11 +216,35 @@ export class MultipartFile {
201
216
  this.errors.push(
202
217
  `Extension '${this.extname}' not allowed. Allowed: ${options.extnames.join(', ')}`,
203
218
  )
219
+ } else if (this.#claimsATypeItCannotBe(options.extnames)) {
220
+ // Every allowed format carries a signature, and none was found — so the
221
+ // bytes are definitively not one of them, whatever the name says. A
222
+ // shell script uploaded as `avatar.png` with `Content-Type: image/png`
223
+ // otherwise passes an image allowlist, because both halves of what was
224
+ // checked came from the client.
225
+ this.errors.push(
226
+ `Extension '${this.extname}' is claimed by the upload, not found in its content. ` +
227
+ `Every allowed format (${options.extnames.join(', ')}) has a signature, and this file carries none.`,
228
+ )
204
229
  }
205
230
  }
206
231
  return this.errors.length === 0
207
232
  }
208
233
 
234
+ /**
235
+ * Whether refusing this upload is warranted on the strength of the allowlist
236
+ * alone.
237
+ *
238
+ * Only when the magic-byte pass actually ran and found nothing, AND every
239
+ * allowed extension is one it would have recognised. For a list that allows
240
+ * `csv` or `txt`, finding nothing is the normal case and says nothing.
241
+ */
242
+ #claimsATypeItCannotBe(extnames: string[]): boolean {
243
+ if (this.#detected || !this.#detectionRan) return false
244
+ if (!detectableExtensions) return false
245
+ return extnames.every((extension) => detectableExtensions?.has(extension))
246
+ }
247
+
209
248
  get isValid(): boolean {
210
249
  return this.errors.length === 0
211
250
  }
@@ -363,3 +402,11 @@ export function hydrateMultipartPayload(payload: {
363
402
  )
364
403
  return { fields, files }
365
404
  }
405
+
406
+ /**
407
+ * The extensions `file-type` can recognise, captured on the first detection.
408
+ *
409
+ * Module-level because it is a property of the library, not of a file, and
410
+ * because `validate()` is synchronous while reading it is not.
411
+ */
412
+ let detectableExtensions: ReadonlySet<string> | undefined
@@ -0,0 +1,18 @@
1
+ /** `migrate` — apply every pending migration, in every registered store. */
2
+
3
+ import type { RegisteredMigrationSource } from '../migrations/types.js'
4
+ import { MigrateBase } from './MigrateBase.js'
5
+
6
+ export default class Migrate extends MigrateBase {
7
+ static override commandName = 'migrate'
8
+ static override description = 'Run pending migrations for every registered migration source'
9
+
10
+ protected async migrateSource(source: RegisteredMigrationSource): Promise<void> {
11
+ const executed = await source.runner.migrate()
12
+ if (executed.length === 0) {
13
+ this.report(source, 'nothing to migrate.')
14
+ return
15
+ }
16
+ for (const name of executed) this.report(source, `migrated: ${name}`)
17
+ }
18
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * What `migrate`, `migrate:rollback` and `migrate:status` share.
3
+ *
4
+ * All three drive the SAME thing — the `migrations` registry, where each data
5
+ * package puts its runner — and differ only in which method they call and how
6
+ * they print the result. A ream particularity, deliberate: AdonisJS has one
7
+ * store and can name it, while an application here may hold a relational store
8
+ * and a time-series one at once, so the command names none of them.
9
+ *
10
+ * Sequentially, never in parallel: two stores on one database server would
11
+ * contend on locks, and interleaved output makes a failure impossible to
12
+ * attribute to the store that caused it.
13
+ */
14
+
15
+ import { BaseCommand } from '../console/BaseCommand.js'
16
+ import { flags } from '../console/decorators.js'
17
+ import type { CommandOptions } from '../console/types.js'
18
+ import type { MigrationRegistry } from '../migrations/MigrationRegistry.js'
19
+ import type { RegisteredMigrationSource } from '../migrations/types.js'
20
+
21
+ export abstract class MigrateBase extends BaseCommand {
22
+ static override options: CommandOptions = { startApp: true }
23
+
24
+ @flags.string({
25
+ description: 'Only this migration source, by the name its package registered',
26
+ })
27
+ declare only?: string
28
+
29
+ /** What to do with one store. Implemented by each command. */
30
+ protected abstract migrateSource(source: RegisteredMigrationSource): Promise<void>
31
+
32
+ async run(): Promise<void> {
33
+ const sources = await this.#resolveSources()
34
+ if (sources === undefined) return
35
+
36
+ for (const source of sources) {
37
+ await this.migrateSource(source)
38
+ }
39
+ }
40
+
41
+ /** The line every message is prefixed with, so multi-store output stays legible. */
42
+ protected report(source: RegisteredMigrationSource, message: string): void {
43
+ this.logger.log(` [${source.name}] ${message}`)
44
+ }
45
+
46
+ /**
47
+ * The sources this run covers, or `undefined` when the run should stop —
48
+ * having said why.
49
+ *
50
+ * An empty registry is NOT a failure: an application may legitimately have no
51
+ * data package yet. Naming which thing is missing beats exiting non-zero on a
52
+ * state the user may have chosen.
53
+ */
54
+ async #resolveSources(): Promise<RegisteredMigrationSource[] | undefined> {
55
+ const registry = await this.app.container.resolve<MigrationRegistry>('migrations')
56
+
57
+ if (this.only !== undefined) {
58
+ const picked = registry.get(this.only)
59
+ if (picked === undefined) {
60
+ const names = registry.names()
61
+ this.logger.error(`No migration source named "${this.only}".`)
62
+ this.logger.error(
63
+ names.length > 0
64
+ ? ` Registered: ${names.join(', ')}`
65
+ : ' No data package registered one. Is its provider in reamrc.ts?',
66
+ )
67
+ this.exitCode = 1
68
+ return undefined
69
+ }
70
+ return [picked]
71
+ }
72
+
73
+ const sources = registry.all()
74
+ if (sources.length === 0) {
75
+ this.logger.info('No migration source registered.')
76
+ this.logger.info(' A data package registers one from its provider — check reamrc.ts.')
77
+ return undefined
78
+ }
79
+ return sources
80
+ }
81
+ }
@@ -0,0 +1,18 @@
1
+ /** `migrate:rollback` — undo the last batch, in every registered store. */
2
+
3
+ import type { RegisteredMigrationSource } from '../migrations/types.js'
4
+ import { MigrateBase } from './MigrateBase.js'
5
+
6
+ export default class MigrateRollback extends MigrateBase {
7
+ static override commandName = 'migrate:rollback'
8
+ static override description = 'Roll back the last batch for every registered migration source'
9
+
10
+ protected async migrateSource(source: RegisteredMigrationSource): Promise<void> {
11
+ const rolled = await source.runner.rollback()
12
+ if (rolled.length === 0) {
13
+ this.report(source, 'nothing to rollback.')
14
+ return
15
+ }
16
+ for (const name of rolled) this.report(source, `rolled back: ${name}`)
17
+ }
18
+ }
@@ -0,0 +1,22 @@
1
+ /** `migrate:status` — what has run and what has not, in every registered store. */
2
+
3
+ import type { RegisteredMigrationSource } from '../migrations/types.js'
4
+ import { MigrateBase } from './MigrateBase.js'
5
+
6
+ export default class MigrateStatus extends MigrateBase {
7
+ static override commandName = 'migrate:status'
8
+ static override description = 'Show applied and pending migrations for every source'
9
+
10
+ protected async migrateSource(source: RegisteredMigrationSource): Promise<void> {
11
+ const statuses = await source.runner.status()
12
+ if (statuses.length === 0) {
13
+ this.report(source, 'no migrations found.')
14
+ return
15
+ }
16
+ for (const node of statuses) {
17
+ const mark = node.status === 'applied' ? '✓' : '○'
18
+ const batch = node.batch === undefined ? '' : ` (batch ${node.batch})`
19
+ this.report(source, `${mark} ${node.name}${batch}`)
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `schedule:list` — every registered task, when it next fires, and how its past
3
+ * runs went.
4
+ *
5
+ * The columns are the operator's questions in order: is my task registered at
6
+ * all, on what schedule, when does it fire next, when did it last fire, and is
7
+ * it failing. Stats come from the scheduler's own tracker, so a task that has
8
+ * never run reads as `—` rather than a zero that looks like a result.
9
+ */
10
+
11
+ import { BaseCommand } from '../console/BaseCommand.js'
12
+ import type { CommandOptions } from '../console/types.js'
13
+ import { resolveScheduler } from './scheduler_resolver.js'
14
+
15
+ /** Over-long names and cron expressions are truncated so the table stays a table. */
16
+ function truncate(value: string, width: number): string {
17
+ return value.length > width ? `${value.slice(0, width - 1)}…` : value
18
+ }
19
+
20
+ function asDate(ms: number | null): string {
21
+ return ms === null ? '—' : new Date(ms).toISOString()
22
+ }
23
+
24
+ export default class ScheduleList extends BaseCommand {
25
+ static override commandName = 'schedule:list'
26
+ static override description = 'List every registered scheduled task, its next run and its stats'
27
+ static override options: CommandOptions = { startApp: true }
28
+
29
+ async run(): Promise<void> {
30
+ const scheduler = await resolveScheduler(this)
31
+ if (!scheduler) return
32
+
33
+ const tasks = scheduler.listTasks()
34
+ if (tasks.length === 0) {
35
+ this.logger.info('No scheduled tasks registered.')
36
+ return
37
+ }
38
+
39
+ this.logger.log(
40
+ [
41
+ 'NAME'.padEnd(32),
42
+ 'CRON'.padEnd(18),
43
+ 'NEXT RUN'.padEnd(22),
44
+ 'LAST RUN'.padEnd(22),
45
+ 'RUNS'.padStart(6),
46
+ 'ERR'.padStart(5),
47
+ 'AVG(ms)'.padStart(9),
48
+ ].join(' '),
49
+ )
50
+
51
+ for (const task of tasks) {
52
+ const stats = scheduler.getStats(task.name)
53
+ this.logger.log(
54
+ [
55
+ truncate(task.name, 32).padEnd(32),
56
+ truncate(task.cronExpr, 18).padEnd(18),
57
+ asDate(task.nextRun).padEnd(22),
58
+ asDate(stats.lastRunMs).padEnd(22),
59
+ String(stats.runCount).padStart(6),
60
+ String(stats.errorCount).padStart(5),
61
+ String(Math.round(stats.avgDurationMs)).padStart(9),
62
+ ].join(' '),
63
+ )
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `schedule:run <name>` — run one registered task immediately.
3
+ *
4
+ * An admin override: it calls the task body directly, so it bypasses both the
5
+ * cron schedule and the distributed lock. That is the point — you reach for it
6
+ * when a task must run now, or to see it fail with your own eyes — and it is
7
+ * also why the exit codes are distinct: `1` the task failed, `2` no such task,
8
+ * `3` an invocation of it is already in flight.
9
+ */
10
+
11
+ import { BaseCommand } from '../console/BaseCommand.js'
12
+ import { args } from '../console/decorators.js'
13
+ import type { CommandOptions } from '../console/types.js'
14
+ import { resolveScheduler } from './scheduler_resolver.js'
15
+
16
+ export default class ScheduleRun extends BaseCommand {
17
+ static override commandName = 'schedule:run'
18
+ static override description = 'Run a registered scheduled task once, now (bypasses the lock)'
19
+ static override options: CommandOptions = { startApp: true }
20
+
21
+ @args.string({ description: 'Task name, as `schedule:list` prints it' })
22
+ declare name: string
23
+
24
+ async run(): Promise<void> {
25
+ // Rejected here rather than in the scheduler so the message arrives without
26
+ // the operator first paying for a boot.
27
+ if (this.name.trim() === '') {
28
+ this.logger.error('Task name cannot be empty. Usage: schedule:run <name>')
29
+ this.exitCode = 1
30
+ return
31
+ }
32
+
33
+ const scheduler = await resolveScheduler(this)
34
+ if (!scheduler) return
35
+
36
+ const result = await scheduler.runTaskNow(this.name)
37
+ const duration = Math.round(result.durationMs)
38
+
39
+ if (result.outcome === 'unknown') {
40
+ this.logger.error(
41
+ `Unknown task: ${this.name}. Run "schedule:list" to see the registered tasks.`,
42
+ )
43
+ this.exitCode = 2
44
+ return
45
+ }
46
+
47
+ if (result.outcome === 'already-running') {
48
+ this.logger.error(
49
+ `Task ${this.name} is already running in this process — skipped. Try again once it completes.`,
50
+ )
51
+ this.exitCode = 3
52
+ return
53
+ }
54
+
55
+ if (result.outcome === 'completed') {
56
+ this.logger.success(`${this.name} completed in ${duration} ms`)
57
+ return
58
+ }
59
+
60
+ this.logger.error(
61
+ `${this.name} failed after ${duration} ms: ${result.error?.message || 'unknown error'}`,
62
+ )
63
+ this.exitCode = 1
64
+ }
65
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `@c9up/ream/commands` — the commands the framework itself ships.
3
+ *
4
+ * The shape AdonisJS uses for `@adonisjs/core/commands`: a module answering
5
+ * `getMetaData()` then `getCommand()`, so listing a command does not import it.
6
+ *
7
+ * The kernel registers this loader on its own rather than waiting for an entry
8
+ * in `reamrc.commands` — the deviation from AdonisJS, where the starter kit
9
+ * lists it. These commands were part of the `ream` binary before they were
10
+ * classes, and an application that upgrades must not silently lose them.
11
+ */
12
+
13
+ import type { CommandClass, SerializedCommand } from '../console/types.js'
14
+
15
+ interface Entry {
16
+ commandName: string
17
+ description: string
18
+ load: () => Promise<{ default: CommandClass }>
19
+ }
20
+
21
+ const COMMANDS: readonly Entry[] = [
22
+ // The registry commands first: a data package registers its runner, and
23
+ // these drive whatever registered — they name no store.
24
+ {
25
+ commandName: 'migrate',
26
+ description: 'Run pending migrations for every registered migration source',
27
+ load: () => import('./Migrate.js'),
28
+ },
29
+ {
30
+ commandName: 'migrate:rollback',
31
+ description: 'Roll back the last batch for every registered migration source',
32
+ load: () => import('./MigrateRollback.js'),
33
+ },
34
+ {
35
+ commandName: 'migrate:status',
36
+ description: 'Show applied and pending migrations for every source',
37
+ load: () => import('./MigrateStatus.js'),
38
+ },
39
+ {
40
+ commandName: 'schedule:list',
41
+ description: 'List every registered scheduled task, its next run and its stats',
42
+ load: () => import('./ScheduleList.js'),
43
+ },
44
+ {
45
+ commandName: 'schedule:run',
46
+ description: 'Run a registered scheduled task once, now (bypasses the lock)',
47
+ load: () => import('./ScheduleRun.js'),
48
+ },
49
+ ]
50
+
51
+ /** `migrate:status` → `migrate`; a name without a colon has no namespace. */
52
+ function namespaceOf(commandName: string): string | null {
53
+ const colon = commandName.indexOf(':')
54
+ return colon === -1 ? null : commandName.slice(0, colon)
55
+ }
56
+
57
+ export async function getMetaData(): Promise<SerializedCommand[]> {
58
+ return COMMANDS.map((entry) => ({
59
+ commandName: entry.commandName,
60
+ namespace: namespaceOf(entry.commandName),
61
+ description: entry.description,
62
+ aliases: [],
63
+ options: { startApp: true },
64
+ args: [],
65
+ flags: [],
66
+ }))
67
+ }
68
+
69
+ export async function getCommand(metadata: SerializedCommand): Promise<CommandClass | null> {
70
+ const entry = COMMANDS.find((command) => command.commandName === metadata.commandName)
71
+ if (!entry) return null
72
+ return (await entry.load()).default
73
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Resolve the scheduler for the `schedule:*` commands.
3
+ *
4
+ * Shared because the failure is the same one on both, and it is a
5
+ * configuration mistake rather than a crash: the provider is simply not in the
6
+ * list, and the message has to say so — a container error mentioning an
7
+ * unbound `scheduler` binding tells an operator nothing.
8
+ */
9
+
10
+ import type { BaseCommand } from '../console/BaseCommand.js'
11
+ import type { Scheduler } from '../scheduler/Scheduler.js'
12
+
13
+ export async function resolveScheduler(command: BaseCommand): Promise<Scheduler | undefined> {
14
+ try {
15
+ return await command.app.container.resolve<Scheduler>('scheduler')
16
+ } catch {
17
+ command.logger.error(
18
+ 'No ScheduleProvider is registered. Add ScheduleProvider to the providers list in reamrc.ts.',
19
+ )
20
+ command.exitCode = 2
21
+ return undefined
22
+ }
23
+ }
package/src/env/Env.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  removeIdentifier,
6
6
  } from './interpolate.js'
7
7
  import { loadEnvFiles } from './loadEnvFiles.js'
8
+ import { normalizeNodeEnv } from './nodeEnv.js'
8
9
  import { type SchemaNode, schema } from './schema.js'
9
10
 
10
11
  /**
@@ -87,7 +88,7 @@ export class Env<Values extends Record<string, unknown>> {
87
88
  appRoot: URL,
88
89
  schema: S,
89
90
  ): Promise<Env<EnvRecord<S>>> {
90
- loadEnvFiles(appRoot, { skipEnvLocal: process.env.NODE_ENV === 'test' })
91
+ loadEnvFiles(appRoot, { skipEnvLocal: normalizeNodeEnv(process.env.NODE_ENV) === 'test' })
91
92
 
92
93
  return new Env(Env.rules(schema).validate(process.env))
93
94
  }
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { parseEnv } from 'node:util'
4
4
  import { interpolate } from './interpolate.js'
5
+ import { normalizeNodeEnv } from './nodeEnv.js'
5
6
 
6
7
  /**
7
8
  * Load `.env` files into `process.env` — the shared primitive behind both the
@@ -17,7 +18,10 @@ import { interpolate } from './interpolate.js'
17
18
  * developer's local overrides don't leak into tests).
18
19
  */
19
20
  export function loadEnvFiles(appRoot: URL, options: { skipEnvLocal?: boolean } = {}): void {
20
- const nodeEnv = process.env.NODE_ENV
21
+ // Normalised, so `NODE_ENV=prod` loads `.env.production` — the file the
22
+ // deployment actually wrote — instead of looking for `.env.prod`.
23
+ const raw = normalizeNodeEnv(process.env.NODE_ENV)
24
+ const nodeEnv = raw === 'unknown' ? undefined : raw
21
25
  const files = [
22
26
  nodeEnv ? `.env.${nodeEnv}.local` : null,
23
27
  options.skipEnvLocal ? null : '.env.local',
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Reading `NODE_ENV`, with the aliases people actually set.
3
+ *
4
+ * `NODE_ENV=prod` is a normal thing to find in a Dockerfile or a platform's
5
+ * dashboard, and a bare `=== 'production'` reads it as "not production": the
6
+ * app then serves without Secure cookies, with development error pages, and
7
+ * loads the wrong `.env` file. Normalising once, here, is what stops that
8
+ * spelling from deciding how an application behaves.
9
+ *
10
+ * The tables mirror upstream exactly. Anything outside them is handed back
11
+ * lowercased rather than forced into a bucket — `staging` stays `staging`, so
12
+ * `.env.staging` still resolves.
13
+ */
14
+
15
+ const DEV_ENVS = ['dev', 'develop', 'development']
16
+ const PROD_ENVS = ['prod', 'production']
17
+ const TEST_ENVS = ['test', 'testing']
18
+
19
+ /**
20
+ * The canonical name for whatever `NODE_ENV` holds.
21
+ *
22
+ * Returns `'unknown'` when it is unset — an absent environment is not the
23
+ * development one, and pretending otherwise turns a misconfigured deploy into
24
+ * a silently permissive one.
25
+ */
26
+ export function normalizeNodeEnv(value: string | undefined): string {
27
+ if (!value || typeof value !== 'string') return 'unknown'
28
+ const env = value.toLowerCase()
29
+ if (DEV_ENVS.includes(env)) return 'development'
30
+ if (PROD_ENVS.includes(env)) return 'production'
31
+ if (TEST_ENVS.includes(env)) return 'test'
32
+ return env
33
+ }
34
+
35
+ /** The current environment, normalised. */
36
+ export function currentNodeEnv(): string {
37
+ return normalizeNodeEnv(process.env.NODE_ENV)
38
+ }
@@ -7,6 +7,8 @@
7
7
  * @implements FR71, FR72, FR73, FR77
8
8
  */
9
9
 
10
+ import { currentNodeEnv } from '../env/nodeEnv.js'
11
+
10
12
  /**
11
13
  * Is this a Ream error, and one of these codes?
12
14
  *
@@ -39,7 +41,12 @@ export function isReamError(error: unknown, ...codes: string[]): boolean {
39
41
  }
40
42
 
41
43
  export class ReamError extends Error {
42
- /** Error code (e.g., "ATLAS_QUERY_ERROR", "CONTAINER_NOT_FOUND") */
44
+ /**
45
+ * Error code, `E_`-prefixed as every framework code is — an application
46
+ * matching on one should never have to remember which convention a
47
+ * particular package followed (e.g. "E_ATLAS_QUERY_ERROR",
48
+ * "E_CONTAINER_NOT_FOUND").
49
+ */
43
50
  readonly code: string
44
51
 
45
52
  /** Additional context key-value pairs */
@@ -164,6 +171,6 @@ export class ReamError extends Error {
164
171
  * Auto-format based on NODE_ENV.
165
172
  */
166
173
  toFormattedString(): string {
167
- return process.env.NODE_ENV === 'production' ? this.toProdString() : this.toDevString()
174
+ return currentNodeEnv() === 'production' ? this.toProdString() : this.toDevString()
168
175
  }
169
176
  }
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import * as fs from 'node:fs'
25
+ import { currentNodeEnv } from '../env/nodeEnv.js'
25
26
  import { loadNapi } from '../helpers/napi-loader.js'
26
27
  import type { HttpContext } from '../http/HttpContext.js'
27
28
 
@@ -231,7 +232,7 @@ export class GraphQLEngine {
231
232
  }
232
233
  this.#schemaSource = fs.readFileSync(config.schemaPath, 'utf8')
233
234
  this.path = config.path ?? '/graphql'
234
- this.#playground = config.playground ?? process.env.NODE_ENV !== 'production'
235
+ this.#playground = config.playground ?? currentNodeEnv() !== 'production'
235
236
  this.#maxQueryBytes = config.maxQueryBytes ?? 100_000
236
237
  this.#maxDepth = config.maxDepth ?? 12
237
238
  this.#maxComplexity = config.maxComplexity ?? 1000
@@ -288,7 +289,7 @@ export class GraphQLEngine {
288
289
  ctx.response.status(200).json(result)
289
290
  } catch (err) {
290
291
  const message =
291
- process.env.NODE_ENV !== 'production' && err instanceof Error
292
+ currentNodeEnv() !== 'production' && err instanceof Error
292
293
  ? err.message
293
294
  : 'Internal server error'
294
295
  ctx.response.status(500).json({ errors: [{ message }] })
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { format } from 'node:util'
10
+ import { currentNodeEnv } from '../env/nodeEnv.js'
10
11
  import { Macroable } from '../utils/Macroable.js'
11
12
  import type { HttpContext } from './HttpContext.js'
12
13
 
@@ -272,7 +273,7 @@ type LogLevel = 'error' | 'warn' | 'info'
272
273
  export class ExceptionHandler extends Macroable {
273
274
  protected debug: boolean
274
275
  /** Render `statusPages` (browser HTML). Defaults to production only (AdonisJS). */
275
- protected renderStatusPages: boolean = process.env.NODE_ENV === 'production'
276
+ protected renderStatusPages: boolean = currentNodeEnv() === 'production'
276
277
  /** Status → HTML renderer, keys may be single codes or `'500..599'` ranges. */
277
278
  protected statusPages: Record<string, StatusPageRenderer> = {}
278
279
  /** Master reporting switch (AdonisJS `reportErrors`). */