@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.
- package/README.md +1 -0
- package/dist/Application.d.ts +26 -6
- package/dist/Application.d.ts.map +1 -1
- package/dist/Application.js +40 -12
- package/dist/Application.js.map +1 -1
- package/dist/ErrorBoundary.d.ts.map +1 -1
- package/dist/ErrorBoundary.js +2 -1
- package/dist/ErrorBoundary.js.map +1 -1
- package/dist/Ignitor.d.ts +13 -0
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +149 -26
- package/dist/Ignitor.js.map +1 -1
- package/dist/bodyparser/MultipartFile.d.ts +8 -0
- package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
- package/dist/bodyparser/MultipartFile.js +46 -1
- package/dist/bodyparser/MultipartFile.js.map +1 -1
- package/dist/commands/Migrate.d.ts +9 -0
- package/dist/commands/Migrate.d.ts.map +1 -0
- package/dist/commands/Migrate.js +16 -0
- package/dist/commands/Migrate.js.map +1 -0
- package/dist/commands/MigrateBase.d.ts +27 -0
- package/dist/commands/MigrateBase.d.ts.map +1 -0
- package/dist/commands/MigrateBase.js +77 -0
- package/dist/commands/MigrateBase.js.map +1 -0
- package/dist/commands/MigrateRollback.d.ts +9 -0
- package/dist/commands/MigrateRollback.d.ts.map +1 -0
- package/dist/commands/MigrateRollback.js +16 -0
- package/dist/commands/MigrateRollback.js.map +1 -0
- package/dist/commands/MigrateStatus.d.ts +9 -0
- package/dist/commands/MigrateStatus.d.ts.map +1 -0
- package/dist/commands/MigrateStatus.js +19 -0
- package/dist/commands/MigrateStatus.js.map +1 -0
- package/dist/commands/ScheduleList.d.ts +18 -0
- package/dist/commands/ScheduleList.d.ts.map +1 -0
- package/dist/commands/ScheduleList.js +55 -0
- package/dist/commands/ScheduleList.js.map +1 -0
- package/dist/commands/ScheduleRun.d.ts +19 -0
- package/dist/commands/ScheduleRun.d.ts.map +1 -0
- package/dist/commands/ScheduleRun.js +61 -0
- package/dist/commands/ScheduleRun.js.map +1 -0
- package/dist/commands/index.d.ts +15 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +63 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/scheduler_resolver.d.ts +12 -0
- package/dist/commands/scheduler_resolver.d.ts.map +1 -0
- package/dist/commands/scheduler_resolver.js +19 -0
- package/dist/commands/scheduler_resolver.js.map +1 -0
- package/dist/env/Env.d.ts.map +1 -1
- package/dist/env/Env.js +2 -1
- package/dist/env/Env.js.map +1 -1
- package/dist/env/loadEnvFiles.d.ts.map +1 -1
- package/dist/env/loadEnvFiles.js +5 -1
- package/dist/env/loadEnvFiles.js.map +1 -1
- package/dist/env/nodeEnv.d.ts +24 -0
- package/dist/env/nodeEnv.d.ts.map +1 -0
- package/dist/env/nodeEnv.js +40 -0
- package/dist/env/nodeEnv.js.map +1 -0
- package/dist/errors/ReamError.d.ts +6 -1
- package/dist/errors/ReamError.d.ts.map +1 -1
- package/dist/errors/ReamError.js +8 -2
- package/dist/errors/ReamError.js.map +1 -1
- package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
- package/dist/graphql/GraphQLEngine.js +3 -2
- package/dist/graphql/GraphQLEngine.js.map +1 -1
- package/dist/http/Exception.d.ts.map +1 -1
- package/dist/http/Exception.js +2 -1
- package/dist/http/Exception.js.map +1 -1
- package/dist/http/Request.d.ts +18 -3
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +71 -8
- package/dist/http/Request.js.map +1 -1
- package/dist/http/Response.d.ts +11 -4
- package/dist/http/Response.d.ts.map +1 -1
- package/dist/http/Response.js +46 -17
- package/dist/http/Response.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +14 -5
- package/dist/router/Router.js.map +1 -1
- package/dist/rpc/RpcRouter.d.ts.map +1 -1
- package/dist/rpc/RpcRouter.js +2 -1
- package/dist/rpc/RpcRouter.js.map +1 -1
- package/dist/scheduler/ScheduleProvider.d.ts +14 -0
- package/dist/scheduler/ScheduleProvider.d.ts.map +1 -1
- package/dist/scheduler/ScheduleProvider.js +62 -14
- package/dist/scheduler/ScheduleProvider.js.map +1 -1
- package/dist/scheduler/config.d.ts +29 -0
- package/dist/scheduler/config.d.ts.map +1 -0
- package/dist/scheduler/config.js +14 -0
- package/dist/scheduler/config.js.map +1 -0
- package/dist/scheduler/index.d.ts +4 -0
- package/dist/scheduler/index.d.ts.map +1 -1
- package/dist/scheduler/index.js +3 -0
- package/dist/scheduler/index.js.map +1 -1
- package/dist/scheduler/locks/LockBackend.d.ts +13 -3
- package/dist/scheduler/locks/LockBackend.d.ts.map +1 -1
- package/dist/scheduler/locks/LockBackend.js +20 -4
- package/dist/scheduler/locks/LockBackend.js.map +1 -1
- package/dist/scheduler/locks/MemoryLockBackend.d.ts +5 -6
- package/dist/scheduler/locks/MemoryLockBackend.d.ts.map +1 -1
- package/dist/scheduler/locks/MemoryLockBackend.js +5 -13
- package/dist/scheduler/locks/MemoryLockBackend.js.map +1 -1
- package/dist/scheduler/locks/RedisLockBackend.d.ts +53 -0
- package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -0
- package/dist/scheduler/locks/RedisLockBackend.js +83 -0
- package/dist/scheduler/locks/RedisLockBackend.js.map +1 -0
- package/dist/scheduler/locks/locks.d.ts +39 -0
- package/dist/scheduler/locks/locks.d.ts.map +1 -0
- package/dist/scheduler/locks/locks.js +42 -0
- package/dist/scheduler/locks/locks.js.map +1 -0
- package/dist/scheduler/locks/quasar.d.ts +17 -0
- package/dist/scheduler/locks/quasar.d.ts.map +1 -0
- package/dist/scheduler/locks/quasar.js +51 -0
- package/dist/scheduler/locks/quasar.js.map +1 -0
- package/dist/security/CookieSigner.d.ts +16 -13
- package/dist/security/CookieSigner.d.ts.map +1 -1
- package/dist/security/CookieSigner.js +33 -14
- package/dist/security/CookieSigner.js.map +1 -1
- package/dist/services/encryption.d.ts +12 -2
- package/dist/services/encryption.d.ts.map +1 -1
- package/dist/services/encryption.js +11 -1
- package/dist/services/encryption.js.map +1 -1
- package/dist/session/Session.d.ts +8 -7
- package/dist/session/Session.d.ts.map +1 -1
- package/dist/session/Session.js.map +1 -1
- package/dist/session/SessionMiddleware.d.ts.map +1 -1
- package/dist/session/SessionMiddleware.js +4 -1
- package/dist/session/SessionMiddleware.js.map +1 -1
- package/dist/testing/ApiResponse.d.ts.map +1 -1
- package/dist/testing/ApiResponse.js +1 -1
- package/dist/testing/ApiResponse.js.map +1 -1
- package/dist/testing/RequestBuilder.d.ts.map +1 -1
- package/dist/testing/RequestBuilder.js +1 -1
- package/dist/testing/RequestBuilder.js.map +1 -1
- package/dist/utils/Macroable.d.ts.map +1 -1
- package/dist/utils/Macroable.js +1 -0
- package/dist/utils/Macroable.js.map +1 -1
- package/events.win32-x64-msvc.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +9 -1
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Application.ts +42 -15
- package/src/ErrorBoundary.ts +3 -1
- package/src/Ignitor.ts +160 -30
- package/src/bodyparser/MultipartFile.ts +48 -1
- package/src/commands/Migrate.ts +18 -0
- package/src/commands/MigrateBase.ts +81 -0
- package/src/commands/MigrateRollback.ts +18 -0
- package/src/commands/MigrateStatus.ts +22 -0
- package/src/commands/ScheduleList.ts +66 -0
- package/src/commands/ScheduleRun.ts +65 -0
- package/src/commands/index.ts +73 -0
- package/src/commands/scheduler_resolver.ts +23 -0
- package/src/env/Env.ts +2 -1
- package/src/env/loadEnvFiles.ts +5 -1
- package/src/env/nodeEnv.ts +38 -0
- package/src/errors/ReamError.ts +9 -2
- package/src/graphql/GraphQLEngine.ts +3 -2
- package/src/http/Exception.ts +2 -1
- package/src/http/Request.ts +73 -8
- package/src/http/Response.ts +58 -19
- package/src/index.ts +8 -1
- package/src/router/Router.ts +14 -5
- package/src/rpc/RpcRouter.ts +2 -1
- package/src/scheduler/ScheduleProvider.ts +65 -15
- package/src/scheduler/config.ts +34 -0
- package/src/scheduler/index.ts +8 -0
- package/src/scheduler/locks/LockBackend.ts +26 -3
- package/src/scheduler/locks/MemoryLockBackend.ts +5 -19
- package/src/scheduler/locks/RedisLockBackend.ts +114 -0
- package/src/scheduler/locks/locks.ts +49 -0
- package/src/scheduler/locks/quasar.ts +67 -0
- package/src/security/CookieSigner.ts +36 -14
- package/src/services/encryption.ts +12 -2
- package/src/session/Session.ts +8 -7
- package/src/session/SessionMiddleware.ts +4 -1
- package/src/testing/ApiResponse.ts +1 -2
- package/src/testing/RequestBuilder.ts +1 -2
- 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
|
}
|
package/src/env/loadEnvFiles.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/errors/ReamError.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
292
|
+
currentNodeEnv() !== 'production' && err instanceof Error
|
|
292
293
|
? err.message
|
|
293
294
|
: 'Internal server error'
|
|
294
295
|
ctx.response.status(500).json({ errors: [{ message }] })
|
package/src/http/Exception.ts
CHANGED
|
@@ -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 =
|
|
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`). */
|