@c9up/ream 0.2.13 → 0.2.15

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 (192) hide show
  1. package/README.md +1 -0
  2. package/dist/Application.d.ts.map +1 -1
  3. package/dist/Application.js +6 -1
  4. package/dist/Application.js.map +1 -1
  5. package/dist/GracefulShutdown.js +6 -5
  6. package/dist/GracefulShutdown.js.map +1 -1
  7. package/dist/Provider.d.ts +20 -2
  8. package/dist/Provider.d.ts.map +1 -1
  9. package/dist/Provider.js.map +1 -1
  10. package/dist/bodyparser/MultipartFile.d.ts +33 -7
  11. package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
  12. package/dist/bodyparser/MultipartFile.js +61 -12
  13. package/dist/bodyparser/MultipartFile.js.map +1 -1
  14. package/dist/bodyparser/parseSize.d.ts.map +1 -1
  15. package/dist/bodyparser/parseSize.js +3 -2
  16. package/dist/bodyparser/parseSize.js.map +1 -1
  17. package/dist/commands/MigrateBase.d.ts.map +1 -1
  18. package/dist/commands/MigrateBase.js +4 -1
  19. package/dist/commands/MigrateBase.js.map +1 -1
  20. package/dist/console/Kernel.d.ts.map +1 -1
  21. package/dist/console/Kernel.js +7 -0
  22. package/dist/console/Kernel.js.map +1 -1
  23. package/dist/console/types.d.ts +14 -0
  24. package/dist/console/types.d.ts.map +1 -1
  25. package/dist/console/types.js.map +1 -1
  26. package/dist/container/Container.d.ts +19 -3
  27. package/dist/container/Container.d.ts.map +1 -1
  28. package/dist/container/Container.js +89 -31
  29. package/dist/container/Container.js.map +1 -1
  30. package/dist/container/ContainerResolver.js.map +1 -1
  31. package/dist/container/types.d.ts +11 -1
  32. package/dist/container/types.d.ts.map +1 -1
  33. package/dist/decorators/Service.d.ts +1 -1
  34. package/dist/decorators/Service.d.ts.map +1 -1
  35. package/dist/env/Env.d.ts.map +1 -1
  36. package/dist/env/Env.js +4 -2
  37. package/dist/env/Env.js.map +1 -1
  38. package/dist/env/interpolate.d.ts.map +1 -1
  39. package/dist/env/interpolate.js +5 -3
  40. package/dist/env/interpolate.js.map +1 -1
  41. package/dist/errors/FuzzyMatcher.d.ts.map +1 -1
  42. package/dist/errors/FuzzyMatcher.js +30 -13
  43. package/dist/errors/FuzzyMatcher.js.map +1 -1
  44. package/dist/errors/PipelineStageError.d.ts.map +1 -1
  45. package/dist/errors/PipelineStageError.js +4 -2
  46. package/dist/errors/PipelineStageError.js.map +1 -1
  47. package/dist/events/Emitter.d.ts +21 -4
  48. package/dist/events/Emitter.d.ts.map +1 -1
  49. package/dist/events/Emitter.js +6 -2
  50. package/dist/events/Emitter.js.map +1 -1
  51. package/dist/events/testing/FakeBus.d.ts +1 -1
  52. package/dist/events/testing/FakeBus.d.ts.map +1 -1
  53. package/dist/events/testing/FakeBus.js.map +1 -1
  54. package/dist/graphql/GraphQLEngine.d.ts +2 -2
  55. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  56. package/dist/graphql/GraphQLEngine.js +4 -2
  57. package/dist/graphql/GraphQLEngine.js.map +1 -1
  58. package/dist/helpers/bytes.d.ts.map +1 -1
  59. package/dist/helpers/bytes.js +13 -2
  60. package/dist/helpers/bytes.js.map +1 -1
  61. package/dist/helpers/string.js +3 -3
  62. package/dist/helpers/string.js.map +1 -1
  63. package/dist/http/Exception.d.ts +8 -4
  64. package/dist/http/Exception.d.ts.map +1 -1
  65. package/dist/http/Exception.js +14 -8
  66. package/dist/http/Exception.js.map +1 -1
  67. package/dist/http/Request.d.ts.map +1 -1
  68. package/dist/http/Request.js +10 -7
  69. package/dist/http/Request.js.map +1 -1
  70. package/dist/http/Response.d.ts.map +1 -1
  71. package/dist/http/Response.js +4 -9
  72. package/dist/http/Response.js.map +1 -1
  73. package/dist/http/SseStream.d.ts.map +1 -1
  74. package/dist/http/SseStream.js +10 -1
  75. package/dist/http/SseStream.js.map +1 -1
  76. package/dist/http/etag.d.ts +44 -0
  77. package/dist/http/etag.d.ts.map +1 -0
  78. package/dist/http/etag.js +65 -0
  79. package/dist/http/etag.js.map +1 -0
  80. package/dist/http/mime.d.ts +33 -0
  81. package/dist/http/mime.d.ts.map +1 -0
  82. package/dist/http/mime.js +1347 -0
  83. package/dist/http/mime.js.map +1 -0
  84. package/dist/index.d.ts +3 -3
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +1 -1
  87. package/dist/native/generated.d.ts +12 -8
  88. package/dist/native/generated.d.ts.map +1 -1
  89. package/dist/openapi/OpenApiGenerator.d.ts.map +1 -1
  90. package/dist/openapi/OpenApiGenerator.js +6 -4
  91. package/dist/openapi/OpenApiGenerator.js.map +1 -1
  92. package/dist/router/Router.d.ts +10 -1
  93. package/dist/router/Router.d.ts.map +1 -1
  94. package/dist/router/Router.js +22 -10
  95. package/dist/router/Router.js.map +1 -1
  96. package/dist/rpc/RpcRouter.d.ts +16 -3
  97. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  98. package/dist/rpc/RpcRouter.js +34 -3
  99. package/dist/rpc/RpcRouter.js.map +1 -1
  100. package/dist/scheduler/Schedule.d.ts +6 -1
  101. package/dist/scheduler/Schedule.d.ts.map +1 -1
  102. package/dist/scheduler/Schedule.js +5 -0
  103. package/dist/scheduler/Schedule.js.map +1 -1
  104. package/dist/security/CookieSigner.d.ts.map +1 -1
  105. package/dist/security/CookieSigner.js +12 -5
  106. package/dist/security/CookieSigner.js.map +1 -1
  107. package/dist/security/SignedUrl.js +3 -2
  108. package/dist/security/SignedUrl.js.map +1 -1
  109. package/dist/session/drivers/DatabaseDriver.d.ts +13 -1
  110. package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
  111. package/dist/session/drivers/DatabaseDriver.js +23 -3
  112. package/dist/session/drivers/DatabaseDriver.js.map +1 -1
  113. package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
  114. package/dist/session/drivers/RedisDriver.js +10 -2
  115. package/dist/session/drivers/RedisDriver.js.map +1 -1
  116. package/dist/storage/StaticMiddleware.d.ts.map +1 -1
  117. package/dist/storage/StaticMiddleware.js +78 -59
  118. package/dist/storage/StaticMiddleware.js.map +1 -1
  119. package/dist/testing/ApiResponse.d.ts.map +1 -1
  120. package/dist/testing/ApiResponse.js +10 -11
  121. package/dist/testing/ApiResponse.js.map +1 -1
  122. package/dist/testing/TestClient.js +6 -6
  123. package/dist/testing/TestClient.js.map +1 -1
  124. package/dist/utils/Macroable.d.ts +6 -2
  125. package/dist/utils/Macroable.d.ts.map +1 -1
  126. package/dist/utils/Macroable.js +4 -0
  127. package/dist/utils/Macroable.js.map +1 -1
  128. package/dist/utils/objectPath.d.ts.map +1 -1
  129. package/dist/utils/objectPath.js +14 -6
  130. package/dist/utils/objectPath.js.map +1 -1
  131. package/events.darwin-arm64.node +0 -0
  132. package/events.darwin-x64.node +0 -0
  133. package/events.linux-arm64-gnu.node +0 -0
  134. package/events.linux-x64-gnu.node +0 -0
  135. package/events.win32-x64-msvc.node +0 -0
  136. package/index.darwin-arm64.node +0 -0
  137. package/index.darwin-x64.node +0 -0
  138. package/index.linux-arm64-gnu.node +0 -0
  139. package/index.linux-x64-gnu.node +0 -0
  140. package/index.win32-x64-msvc.node +0 -0
  141. package/package.json +5 -7
  142. package/scheduler.darwin-arm64.node +0 -0
  143. package/scheduler.darwin-x64.node +0 -0
  144. package/scheduler.linux-arm64-gnu.node +0 -0
  145. package/scheduler.linux-x64-gnu.node +0 -0
  146. package/scheduler.win32-x64-msvc.node +0 -0
  147. package/scripts/build-napi-types.mjs +4 -3
  148. package/scripts/generate-mime.mjs +119 -0
  149. package/scripts/generate-napi-types.mjs +28 -12
  150. package/src/Application.ts +6 -1
  151. package/src/GracefulShutdown.ts +5 -5
  152. package/src/Provider.ts +21 -2
  153. package/src/bodyparser/MultipartFile.ts +94 -16
  154. package/src/bodyparser/parseSize.ts +3 -2
  155. package/src/commands/MigrateBase.ts +4 -1
  156. package/src/console/BaseCommand.ts +1 -1
  157. package/src/console/Kernel.ts +8 -0
  158. package/src/console/types.ts +15 -0
  159. package/src/container/Container.ts +120 -57
  160. package/src/container/ContainerResolver.ts +1 -1
  161. package/src/container/types.ts +11 -1
  162. package/src/decorators/Service.ts +1 -1
  163. package/src/env/Env.ts +4 -2
  164. package/src/env/interpolate.ts +5 -3
  165. package/src/errors/FuzzyMatcher.ts +34 -13
  166. package/src/errors/PipelineStageError.ts +4 -2
  167. package/src/events/Emitter.ts +34 -8
  168. package/src/events/testing/FakeBus.ts +12 -5
  169. package/src/graphql/GraphQLEngine.ts +11 -7
  170. package/src/helpers/bytes.ts +13 -2
  171. package/src/helpers/string.ts +3 -3
  172. package/src/http/Exception.ts +14 -8
  173. package/src/http/Request.ts +8 -5
  174. package/src/http/Response.ts +4 -8
  175. package/src/http/SseStream.ts +16 -7
  176. package/src/http/etag.ts +68 -0
  177. package/src/http/mime.ts +1347 -0
  178. package/src/index.ts +3 -3
  179. package/src/native/generated.ts +12 -8
  180. package/src/openapi/OpenApiGenerator.ts +6 -4
  181. package/src/router/Router.ts +31 -11
  182. package/src/rpc/RpcRouter.ts +38 -8
  183. package/src/scheduler/Schedule.ts +8 -1
  184. package/src/security/CookieSigner.ts +15 -5
  185. package/src/security/SignedUrl.ts +3 -2
  186. package/src/session/drivers/DatabaseDriver.ts +36 -8
  187. package/src/session/drivers/RedisDriver.ts +13 -5
  188. package/src/storage/StaticMiddleware.ts +81 -66
  189. package/src/testing/ApiResponse.ts +9 -10
  190. package/src/testing/TestClient.ts +6 -6
  191. package/src/utils/Macroable.ts +6 -2
  192. package/src/utils/objectPath.ts +12 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/ream",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "Ream — Rust-powered Node.js application framework",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -212,9 +212,7 @@
212
212
  "tsconfig.client.json"
213
213
  ],
214
214
  "dependencies": {
215
- "etag": "^1.8.1",
216
215
  "file-type": "^22.0.1",
217
- "mime-types": "^3.0.1",
218
216
  "reflect-metadata": "^0.2"
219
217
  },
220
218
  "peerDependencies": {
@@ -230,11 +228,11 @@
230
228
  }
231
229
  },
232
230
  "devDependencies": {
233
- "@biomejs/biome": "^2.4.10",
231
+ "@biomejs/biome": "2.5.12",
234
232
  "@c9up/comet": "^0.1.0",
235
- "@types/etag": "^1.8.4",
236
- "@types/mime-types": "^3.0.1",
237
233
  "@types/node": "^22.19.15",
234
+ "mime-db": "^1.54.0",
235
+ "mime-types": "^3.0.2",
238
236
  "typescript": "^6.0.2",
239
237
  "vitest": "^4.1.6"
240
238
  },
@@ -259,6 +257,6 @@
259
257
  "lint": "biome check src/",
260
258
  "test:coverage": "vitest run --coverage",
261
259
  "typecheck": "tsc --noEmit",
262
- "typecheck:console-tests": "tsc --noEmit -p tsconfig.tests.json"
260
+ "typecheck:tests": "tsc --noEmit -p tsconfig.tests.json"
263
261
  }
264
262
  }
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -7,7 +7,8 @@
7
7
  * cargo a `/tmp/...` path the native proc-macro cannot write to, so the
8
8
  * type-def file comes back empty and the build fails for no visible reason.
9
9
  *
10
- * One crate at a time on purpose: napi-derive APPENDS to `TYPE_DEF_TMP_PATH`
10
+ * One crate at a time on purpose: with napi-derive 2 the writes all went to a
11
+ * single `TYPE_DEF_TMP_PATH` and a parallel build interleaved them
11
12
  * while cargo compiles, and a parallel build interleaves the writes —
12
13
  * definitions go missing, silently, and the generated file comes out short.
13
14
  */
@@ -28,11 +29,11 @@ const scratch = mkdtempSync(join(tmpdir(), 'napi-types-'))
28
29
  try {
29
30
  const lines = []
30
31
  for (const crate of CRATES) {
31
- const perCrate = join(scratch, `${crate}.jsonl`)
32
+ const perCrate = join(scratch, crate)
32
33
  writeFileSync(perCrate, '')
33
34
  execFileSync('cargo', ['build', '-p', crate], {
34
35
  cwd: packageRoot,
35
- env: { ...process.env, TYPE_DEF_TMP_PATH: perCrate },
36
+ env: { ...process.env, NAPI_TYPE_DEF_TMP_FOLDER: scratch },
36
37
  stdio: ['ignore', 'ignore', 'inherit'],
37
38
  })
38
39
  const emitted = readFileSync(perCrate, 'utf8').split('\n').filter(Boolean)
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Regenerate `src/http/mime.ts` from mime-db.
3
+ *
4
+ * The table is data, not logic, and hand-maintaining it is how `.yaml` and
5
+ * `.sql` went missing. `mime-types` reads the same two fields out of mime-db —
6
+ * the resolved extension map and the per-type `charset` — so generating from
7
+ * there keeps `contentType()` answering exactly what the package answered.
8
+ *
9
+ * mime-db and mime-types are dev-only: they are read HERE, at generation time,
10
+ * and never imported by the framework.
11
+ *
12
+ * node scripts/generate-mime.mjs
13
+ */
14
+
15
+ import { createRequire } from 'node:module'
16
+ import { writeFile } from 'node:fs/promises'
17
+ import { fileURLToPath } from 'node:url'
18
+ import { dirname, join } from 'node:path'
19
+
20
+ const require = createRequire(import.meta.url)
21
+ const mimeTypes = require('mime-types')
22
+ const db = require('mime-db')
23
+ const dbVersion = require('mime-db/package.json').version
24
+
25
+ const here = dirname(fileURLToPath(import.meta.url))
26
+ const target = join(here, '..', 'src', 'http', 'mime.ts')
27
+
28
+ /**
29
+ * Emitted in the package's own style — single quotes, no semicolons — so a
30
+ * regenerated file is already what `biome check` wants and the diff after a
31
+ * regeneration is the data that changed, nothing else.
32
+ */
33
+ const quote = (value) => `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
34
+ /** An identifier when it is one, a quoted key otherwise (`7z`, `3gp`…). */
35
+ const key = (ext) => (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(ext) ? ext : quote(ext))
36
+
37
+ const extensions = Object.keys(mimeTypes.types).sort()
38
+ const rows = extensions.map((e) => ` ${key(e)}: ${quote(mimeTypes.types[e])},`).join('\n')
39
+
40
+ const charsets = Object.entries(db)
41
+ .filter(([, entry]) => entry.charset)
42
+ .sort(([a], [b]) => a.localeCompare(b))
43
+ .map(([type, entry]) => ` ${quote(type)}: ${quote(entry.charset.toLowerCase())},`)
44
+ .join('\n')
45
+
46
+ await writeFile(target, render({ rows, charsets, dbVersion, count: extensions.length }))
47
+ process.stdout.write(`mime.ts: ${extensions.length} extensions from mime-db ${dbVersion}\n`)
48
+
49
+ function render({ rows, charsets, dbVersion, count }) {
50
+ return `/**
51
+ * Content types for a response, resolved here rather than pulled in.
52
+ *
53
+ * \`response.type("txt")\` has to become \`text/plain; charset=utf-8\`, which is a
54
+ * lookup table and one charset rule — the same job \`@c9up/archive\` already
55
+ * does in its own table, so the framework was carrying a dependency for work
56
+ * the ecosystem already owned.
57
+ *
58
+ * A hand-written table is what made that a regression rather than a saving:
59
+ * it knew the extensions someone thought of, so \`.yaml\`, \`.sql\`, \`.opus\` and
60
+ * \`.xhtml\` fell through and the caller's own string went out as the content
61
+ * type. The tables below are GENERATED from mime-db instead, which is where
62
+ * \`mime-types\` reads them too, so the resolution is the same one and stays
63
+ * the same one.
64
+ *
65
+ * Regenerate with:
66
+ *
67
+ * node scripts/generate-mime.mjs
68
+ *
69
+ * Do not edit {@link TYPES} or {@link CHARSETS} by hand.
70
+ *
71
+ * @generated from mime-db ${dbVersion} (${count} extensions)
72
+ */
73
+
74
+ /** Extension → type. No leading dot; lowercase. */
75
+ const TYPES: Record<string, string> = {
76
+ ${rows}
77
+ }
78
+
79
+ /**
80
+ * Types that declare a charset of their own, lowercased.
81
+ *
82
+ * Everything else under \`text/\` defaults to utf-8; everything else has none.
83
+ * That is mime-db's \`charset\` field plus \`mime-types\`' \`text/*\` fallback.
84
+ */
85
+ const CHARSETS: Record<string, string> = {
86
+ ${charsets}
87
+ }
88
+
89
+ /** The charset for a type, or \`undefined\` when it carries none. */
90
+ function charsetFor(type: string): string | undefined {
91
+ const base = type.split(';')[0].trim().toLowerCase()
92
+ const declared = CHARSETS[base]
93
+ if (declared !== undefined) return declared
94
+ return base.startsWith('text/') ? 'utf-8' : undefined
95
+ }
96
+
97
+ /** The type for a bare extension, or \`undefined\` when it is not one we know. */
98
+ export function lookupType(extension: string): string | undefined {
99
+ const ext = extension.replace(/^.*\\./, '').toLowerCase()
100
+ return TYPES[ext]
101
+ }
102
+
103
+ /**
104
+ * A full \`Content-Type\` for an extension or a type (\`mime-types.contentType\`).
105
+ *
106
+ * Returns \`false\` for something it cannot resolve, as the package did, so the
107
+ * caller keeps its existing fallback rather than writing \`content-type: false\`.
108
+ */
109
+ export function contentType(input: string): string | false {
110
+ if (input.length === 0) return false
111
+ // Already a type — possibly with parameters the caller set itself.
112
+ const type = input.includes('/') ? input : lookupType(input)
113
+ if (type === undefined) return false
114
+ if (type.includes('charset=')) return type
115
+ const charset = charsetFor(type)
116
+ return charset === undefined ? type : \`\${type}; charset=\${charset}\`
117
+ }
118
+ `
119
+ }
@@ -29,27 +29,40 @@ import { readFileSync, writeFileSync } from 'node:fs'
29
29
  * entry must match, or the script fails: a stale refinement is how a hand
30
30
  * annotation quietly stops describing the Rust.
31
31
  */
32
+ /**
33
+ * napi-derive 3 emits `callback: (arg: unknown) => unknown` where 2 emitted
34
+ * `(...args: any[]) => any` — better, since it no longer reaches for `any`,
35
+ * but a different string to match on. These refinements exist because the
36
+ * generated shape says nothing about what the callback actually receives.
37
+ */
32
38
  const CALLBACK_REFINEMENTS = {
33
39
  'EventBus.subscribe': {
34
- from: 'callback: (...args: any[]) => any',
40
+ from: 'callback: (arg: unknown) => unknown',
35
41
  to: 'callback: (eventJson: string) => void',
36
42
  },
37
43
  'EventBus.onRequest': {
38
- from: 'callback: (...args: any[]) => any',
44
+ from: 'callback: (arg: unknown) => unknown',
39
45
  to: 'callback: (eventJson: string, reply: (response: string) => void) => void',
40
46
  },
41
47
  'HyperServer.onRequest': {
42
- from: 'callback: (...args: any[]) => any',
43
- to: 'callback: (request: string) => Promise<string> | string',
48
+ // Only the argument is named. The `to` used to say
49
+ // `(request: string) => Promise<string> | string`, which described the
50
+ // boundary as it was BEFORE it stopped serialising: the Rust hands the
51
+ // handler a JsObject and awaits a `NapiResponse`, and napi's own output
52
+ // already said so. The refinement was overwriting a correct shape with a
53
+ // stale one, and nothing noticed because every caller of `onRequest` was
54
+ // outside the typecheck.
55
+ from: 'callback: (arg: unknown) => Promise<NapiResponse>',
56
+ to: 'callback: (request: Record<string, unknown>) => Promise<NapiResponse>',
44
57
  },
45
58
  'HyperServer.onStreamDisconnect': {
46
- from: 'callback: (...args: any[]) => any',
59
+ from: 'callback: (arg: unknown) => unknown',
47
60
  to: 'callback: (streamId: string) => void',
48
61
  },
49
62
  // The Rust hands over a serialised JsTaskPayload — `{ task_name,
50
63
  // scheduled_for_ms }` renamed to camelCase by serde.
51
64
  'RustScheduler.register': {
52
- from: 'callback: (...args: any[]) => any',
65
+ from: 'callback: (arg: unknown) => unknown',
53
66
  to: 'callback: (invocation: { taskName: string; scheduledForMs: number }) => void',
54
67
  },
55
68
  }
@@ -150,12 +163,15 @@ for (const fn of fns) {
150
163
  // napi-derive emits the whole declaration for a function, unlike a struct
151
164
  // where `def` holds only the members.
152
165
  const declaration = fn.def.trim()
153
- out.push(
154
- declaration.startsWith('export declare function')
155
- ? `${declaration};`
156
- : `export declare function ${fn.name}${declaration};`,
157
- '',
158
- )
166
+ // napi-derive 3 emits a bare `function name(...)` where 2 emitted the
167
+ // signature after the name; concatenating onto the former produced
168
+ // `function xfunction x(...)`.
169
+ const rendered = declaration.startsWith('export declare function')
170
+ ? declaration
171
+ : declaration.startsWith('function ')
172
+ ? `export declare ${declaration}`
173
+ : `export declare function ${fn.name}${declaration}`
174
+ out.push(`${rendered};`, '')
159
175
  }
160
176
 
161
177
  const stale = Object.keys(CALLBACK_REFINEMENTS).filter((k) => !used.has(k))
@@ -181,7 +181,12 @@ export class Application implements AppContext {
181
181
 
182
182
  /** Resolve inside one of the conventional directories. */
183
183
  #inDirectory(key: keyof DirectoriesNode, paths: string[]): string {
184
- return this.makePath(this.#directories[key], ...paths)
184
+ // `DirectoriesNode` is an index signature, so a key nobody registered
185
+ // reads as undefined rather than failing to compile. Falling back to the
186
+ // key itself keeps the convention: `configPath()` under a node with no
187
+ // `config` entry resolves to `<root>/config`, which is where it would
188
+ // have been anyway.
189
+ return this.makePath(this.#directories[key] ?? String(key), ...paths)
185
190
  }
186
191
 
187
192
  /** Absolute path inside the `config/` directory. */
@@ -65,12 +65,12 @@ export function installGracefulShutdown(options: ShutdownOptions): ShutdownHandl
65
65
  // Second signal while draining — escalate immediately.
66
66
  logger.error('Forced exit: second signal received during shutdown')
67
67
  process.exit(1)
68
- return
68
+ } else {
69
+ shutdown().catch((err) => {
70
+ logger.error(`Fatal: ${err}`)
71
+ process.exit(1)
72
+ })
69
73
  }
70
- shutdown().catch((err) => {
71
- logger.error(`Fatal: ${err}`)
72
- process.exit(1)
73
- })
74
74
  }
75
75
 
76
76
  const onSigterm = handleSignal
package/src/Provider.ts CHANGED
@@ -7,12 +7,31 @@
7
7
  * register() → boot() → start() → ready() → ... → shutdown()
8
8
  */
9
9
 
10
- import type { ConfigStore } from './ConfigLoader.js'
11
10
  import type { Container } from './container/Container.js'
12
11
 
12
+ /**
13
+ * The config a provider reads, as a CONTRACT rather than as the class.
14
+ *
15
+ * `ConfigStore` satisfies it, so nothing that holds a real one changes. But
16
+ * typed as the class, `AppContext` demanded a private `#tree` no one outside
17
+ * `ConfigLoader` can produce — so a test could not substitute a config, and an
18
+ * independently-published package could not describe an `AppContext` at all
19
+ * without importing `@c9up/ream`, which is the coupling the comment below this
20
+ * one exists to avoid. bay, station and blackhole each re-declared their own
21
+ * for exactly that reason.
22
+ *
23
+ * `get` and `set` are what providers actually use; a `ConfigStore`'s other
24
+ * methods stay reachable on the concrete type.
25
+ */
26
+ export interface ConfigReader {
27
+ get<T = unknown>(key: string): T | undefined
28
+ get<T = unknown>(key: string, defaultValue: T): T
29
+ set(key: string, value: unknown): void
30
+ }
31
+
13
32
  export interface AppContext {
14
33
  container: Container
15
- config: ConfigStore
34
+ config: ConfigReader
16
35
  }
17
36
 
18
37
  /**
@@ -1,3 +1,51 @@
1
+ /**
2
+ * The top-level mime types IANA registers.
3
+ *
4
+ * Closed on purpose. Typing {@link MultipartFile.type} as a bare `string` let
5
+ * `file.type === 'image/png'` compile and quietly never match — a mime filter
6
+ * that passes everything, which is the shape a comment used to warn about and
7
+ * a compiler can simply refuse. Against this union that comparison is an
8
+ * error, and {@link MultipartFile.mime} is what a full-mime check wants.
9
+ *
10
+ * A top-level type outside this list is malformed: an unregistered format
11
+ * belongs under one of these trees, `application/x-…` rather than `x-foo/bar`.
12
+ *
13
+ * Additive, not a replacement: {@link MultipartFile.type} keeps upstream's
14
+ * `string` and its raw segment, and {@link MultipartFile.registeredType}
15
+ * narrows it here for callers who want `=== 'image/png'` refused at compile
16
+ * time. Nothing a valid AdonisJS call site does stops compiling, and no type
17
+ * outside this set is swallowed — closing `type` itself would have done both.
18
+ */
19
+ export type MimeType =
20
+ | 'application'
21
+ | 'audio'
22
+ | 'example'
23
+ | 'font'
24
+ | 'image'
25
+ | 'message'
26
+ | 'model'
27
+ | 'multipart'
28
+ | 'text'
29
+ | 'video'
30
+
31
+ const MIME_TYPES = new Set<string>([
32
+ 'application',
33
+ 'audio',
34
+ 'example',
35
+ 'font',
36
+ 'image',
37
+ 'message',
38
+ 'model',
39
+ 'multipart',
40
+ 'text',
41
+ 'video',
42
+ ])
43
+
44
+ /** Whether a parsed top-level type is one IANA registers. */
45
+ function isMimeType(value: string): value is MimeType {
46
+ return MIME_TYPES.has(value)
47
+ }
48
+
1
49
  /**
2
50
  * MultipartFile — represents an uploaded file.
3
51
  *
@@ -28,14 +76,32 @@ export class MultipartFile {
28
76
  * `image/png` would otherwise sail through a mime allowlist. A renamed file
29
77
  * reports what it actually is.
30
78
  *
31
- * BREAKING as of 0.2.0: it used to hold the whole `image/png`. TypeScript
32
- * cannot catch a comparison against a full mime string — `file.type ===
33
- * 'image/png'` still compiles and simply never matches. Use {@link mime}.
79
+ * BREAKING as of 0.2.0: it used to hold the whole `image/png`. A comparison
80
+ * against a full mime — `file.type === 'image/png'` — compiles and never
81
+ * matches, which is what {@link mime} exists for, and what
82
+ * {@link registeredType} refuses at compile time for callers who want that.
83
+ *
84
+ * `string` and the raw segment, as upstream: a type outside the registered
85
+ * set is still reported rather than swallowed.
34
86
  */
35
87
  get type(): string | undefined {
36
88
  return this.#mimeParts()[0]
37
89
  }
38
90
 
91
+ /**
92
+ * The same primary type, narrowed to what IANA registers.
93
+ *
94
+ * Additive, and the point of it: against a closed union
95
+ * `file.registeredType === 'image/png'` is a compile error instead of a
96
+ * comparison that always fails. `undefined` where {@link type} holds
97
+ * something outside the set — a malformed or unregistered tree — so the two
98
+ * disagree exactly there and nowhere else.
99
+ */
100
+ get registeredType(): MimeType | undefined {
101
+ const type = this.#mimeParts()[0]
102
+ return type !== undefined && isMimeType(type) ? type : undefined
103
+ }
104
+
39
105
  /** The mime SUBTYPE — `png` for `image/png`. Same source as {@link type}. */
40
106
  get subtype(): string | undefined {
41
107
  return this.#mimeParts()[1]
@@ -59,7 +125,10 @@ export class MultipartFile {
59
125
  #mimeParts(): [string | undefined, string | undefined] {
60
126
  const source = this.#detected?.mime ?? this.#headerMime
61
127
  if (!source) return [undefined, undefined]
62
- const [type, subtype] = source.split(';')[0].trim().split('/')
128
+ // `split` always yields a first element; naming it is how that stops
129
+ // being something the compiler has to be told.
130
+ const [head = ''] = source.split(';')
131
+ const [type, subtype] = head.trim().split('/')
63
132
  return [type || undefined, subtype || undefined]
64
133
  }
65
134
 
@@ -297,7 +366,7 @@ export class MultipartFile {
297
366
  * passing `file.clientName` straight through.
298
367
  */
299
368
  async move(location: string, options?: { name?: string; overwrite?: boolean }): Promise<void> {
300
- const { mkdir, writeFile, access } = await import('node:fs/promises')
369
+ const { mkdir, writeFile } = await import('node:fs/promises')
301
370
  const { join } = await import('node:path')
302
371
 
303
372
  const name = options?.name ?? `${randomBytes(16).toString('hex')}.${this.extname || 'unknown'}`
@@ -308,20 +377,28 @@ export class MultipartFile {
308
377
  }
309
378
 
310
379
  const filePath = join(location, name)
311
- if (options?.overwrite === false) {
312
- const exists = await access(filePath).then(
313
- () => true,
314
- () => false,
315
- )
316
- if (exists) {
380
+ await mkdir(location, { recursive: true })
381
+
382
+ // NAMED DEVIATION — the refusal is ATOMIC where upstream's is not.
383
+ //
384
+ // Upstream checks `pathExists` and then renames, so two uploads landing on
385
+ // the same name both see nothing and both succeed: the second silently
386
+ // replaces the first, which is the one thing `overwrite: false` exists to
387
+ // prevent. `wx` is O_EXCL — the kernel decides, and exactly one of the two
388
+ // writes wins.
389
+ //
390
+ // The message is upstream's, word for word: a caller matching on it keeps
391
+ // working, and only the race is gone.
392
+ try {
393
+ await writeFile(filePath, this.content, options?.overwrite === false ? { flag: 'wx' } : {})
394
+ } catch (error) {
395
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
317
396
  throw new Error(
318
397
  `"${name}" already exists at "${location}". Set "overwrite = true" to overwrite it`,
319
398
  )
320
399
  }
400
+ throw error
321
401
  }
322
-
323
- await mkdir(location, { recursive: true })
324
- await writeFile(filePath, this.content)
325
402
  this.markAsMoved(name, filePath)
326
403
  }
327
404
 
@@ -358,8 +435,9 @@ function isSafeFilename(name: string): boolean {
358
435
  function parseFileSize(size: string): number {
359
436
  const match = size.match(/^(\d+)(kb|mb|gb)?$/i)
360
437
  if (!match) return 1024 * 1024
361
- const num = parseInt(match[1], 10)
362
- switch (match[2]?.toLowerCase()) {
438
+ const [, digits, unit] = match
439
+ const num = parseInt(digits ?? '0', 10)
440
+ switch (unit?.toLowerCase()) {
363
441
  case 'kb':
364
442
  return num * 1024
365
443
  case 'mb':
@@ -6,8 +6,9 @@
6
6
  export function parseSize(size: string): number {
7
7
  const match = size.match(/^(\d+)(kb|mb|gb)?$/i)
8
8
  if (!match) return 1024 * 1024 // default 1mb
9
- const num = parseInt(match[1], 10)
10
- switch (match[2]?.toLowerCase()) {
9
+ const [, digits, unit] = match
10
+ const num = parseInt(digits ?? '0', 10)
11
+ switch (unit?.toLowerCase()) {
11
12
  case 'kb':
12
13
  return num * 1024
13
14
  case 'mb':
@@ -19,7 +19,10 @@ import type { MigrationRegistry } from '../migrations/MigrationRegistry.js'
19
19
  import type { RegisteredMigrationSource } from '../migrations/types.js'
20
20
 
21
21
  export abstract class MigrateBase extends BaseCommand {
22
- static override options: CommandOptions = { startApp: true }
22
+ // `drivesMigrations` because these three ARE the migration run: without it the
23
+ // boot applies everything first, so `migrate` says "nothing to migrate" after
24
+ // the work is done, and `migrate:status` mutates the schema it reports on.
25
+ static override options: CommandOptions = { startApp: true, drivesMigrations: true }
23
26
 
24
27
  @flags.string({
25
28
  description: 'Only this migration source, by the name its package registered',
@@ -117,7 +117,7 @@ export abstract class BaseCommand {
117
117
  error?: unknown
118
118
 
119
119
  /** Assigned by the kernel — see {@link terminate}. */
120
- protected declare onTerminate?: () => void | Promise<void>
120
+ declare protected onTerminate?: () => void | Promise<void>
121
121
 
122
122
  /**
123
123
  * Lifecycle hooks, run in this order around `run()`: `prepare`, `interact`,
@@ -999,6 +999,14 @@ export class Kernel {
999
999
  )
1000
1000
  }
1001
1001
 
1002
+ // Before the boot, not after: a data package that migrates on boot for the
1003
+ // convenience of `dev` would otherwise apply everything a moment before the
1004
+ // command that exists to apply it runs — leaving `migrate` with nothing to
1005
+ // report and `migrate:status` having changed what it was asked to look at.
1006
+ if (Command.options?.drivesMigrations === true) {
1007
+ process.env.REAM_SKIP_BOOT_MIGRATE = '1'
1008
+ }
1009
+
1002
1010
  return this.#startApp()
1003
1011
  }
1004
1012
 
@@ -37,6 +37,21 @@ export interface CommandOptions {
37
37
  * default so a typo (`--warm` for `--warn`) is reported, not swallowed.
38
38
  */
39
39
  allowUnknownFlags?: boolean
40
+
41
+ /**
42
+ * This command drives migrations itself, so booting must not run them.
43
+ *
44
+ * A data package may migrate on boot for the convenience of `dev` — atlas
45
+ * does, outside production. For the commands that exist to migrate, that
46
+ * convenience is the bug: the boot applies everything, and then `migrate`
47
+ * reports "nothing to migrate" while `migrate:status`, asked only to look,
48
+ * has changed the schema it is reporting on.
49
+ *
50
+ * Declaring this sets `REAM_SKIP_BOOT_MIGRATE=1` before the application
51
+ * starts, which is the flag a data package watches for. Any package's own
52
+ * migration command opts in the same way; the framework names none of them.
53
+ */
54
+ drivesMigrations?: boolean
40
55
  }
41
56
 
42
57
  /** Metadata recorded by an `@args.*` decorator. */