@c9up/ream 0.2.14 → 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 (166) 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/Provider.d.ts +20 -2
  6. package/dist/Provider.d.ts.map +1 -1
  7. package/dist/Provider.js.map +1 -1
  8. package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
  9. package/dist/bodyparser/MultipartFile.js +7 -3
  10. package/dist/bodyparser/MultipartFile.js.map +1 -1
  11. package/dist/bodyparser/parseSize.d.ts.map +1 -1
  12. package/dist/bodyparser/parseSize.js +3 -2
  13. package/dist/bodyparser/parseSize.js.map +1 -1
  14. package/dist/commands/MigrateBase.d.ts.map +1 -1
  15. package/dist/commands/MigrateBase.js +4 -1
  16. package/dist/commands/MigrateBase.js.map +1 -1
  17. package/dist/console/Kernel.d.ts.map +1 -1
  18. package/dist/console/Kernel.js +7 -0
  19. package/dist/console/Kernel.js.map +1 -1
  20. package/dist/console/types.d.ts +14 -0
  21. package/dist/console/types.d.ts.map +1 -1
  22. package/dist/console/types.js.map +1 -1
  23. package/dist/container/Container.d.ts.map +1 -1
  24. package/dist/container/Container.js +6 -1
  25. package/dist/container/Container.js.map +1 -1
  26. package/dist/container/types.d.ts +11 -1
  27. package/dist/container/types.d.ts.map +1 -1
  28. package/dist/decorators/Service.d.ts +1 -1
  29. package/dist/decorators/Service.d.ts.map +1 -1
  30. package/dist/env/Env.d.ts.map +1 -1
  31. package/dist/env/Env.js +4 -2
  32. package/dist/env/Env.js.map +1 -1
  33. package/dist/env/interpolate.d.ts.map +1 -1
  34. package/dist/env/interpolate.js +5 -3
  35. package/dist/env/interpolate.js.map +1 -1
  36. package/dist/errors/FuzzyMatcher.d.ts.map +1 -1
  37. package/dist/errors/FuzzyMatcher.js +30 -13
  38. package/dist/errors/FuzzyMatcher.js.map +1 -1
  39. package/dist/errors/PipelineStageError.d.ts.map +1 -1
  40. package/dist/errors/PipelineStageError.js +4 -2
  41. package/dist/errors/PipelineStageError.js.map +1 -1
  42. package/dist/events/Emitter.d.ts +21 -4
  43. package/dist/events/Emitter.d.ts.map +1 -1
  44. package/dist/events/Emitter.js +6 -2
  45. package/dist/events/Emitter.js.map +1 -1
  46. package/dist/events/testing/FakeBus.d.ts +1 -1
  47. package/dist/events/testing/FakeBus.d.ts.map +1 -1
  48. package/dist/events/testing/FakeBus.js.map +1 -1
  49. package/dist/graphql/GraphQLEngine.d.ts +2 -2
  50. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  51. package/dist/graphql/GraphQLEngine.js +4 -2
  52. package/dist/graphql/GraphQLEngine.js.map +1 -1
  53. package/dist/helpers/bytes.d.ts.map +1 -1
  54. package/dist/helpers/bytes.js +13 -2
  55. package/dist/helpers/bytes.js.map +1 -1
  56. package/dist/helpers/string.js +3 -3
  57. package/dist/helpers/string.js.map +1 -1
  58. package/dist/http/Exception.d.ts +8 -4
  59. package/dist/http/Exception.d.ts.map +1 -1
  60. package/dist/http/Exception.js +14 -8
  61. package/dist/http/Exception.js.map +1 -1
  62. package/dist/http/Request.d.ts.map +1 -1
  63. package/dist/http/Request.js +10 -7
  64. package/dist/http/Request.js.map +1 -1
  65. package/dist/http/mime.d.ts.map +1 -1
  66. package/dist/http/mime.js +2 -1
  67. package/dist/http/mime.js.map +1 -1
  68. package/dist/index.d.ts +2 -2
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +1 -1
  71. package/dist/native/generated.d.ts +12 -8
  72. package/dist/native/generated.d.ts.map +1 -1
  73. package/dist/openapi/OpenApiGenerator.d.ts.map +1 -1
  74. package/dist/openapi/OpenApiGenerator.js +6 -4
  75. package/dist/openapi/OpenApiGenerator.js.map +1 -1
  76. package/dist/router/Router.d.ts +10 -1
  77. package/dist/router/Router.d.ts.map +1 -1
  78. package/dist/router/Router.js +22 -10
  79. package/dist/router/Router.js.map +1 -1
  80. package/dist/rpc/RpcRouter.d.ts +16 -3
  81. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  82. package/dist/rpc/RpcRouter.js +34 -3
  83. package/dist/rpc/RpcRouter.js.map +1 -1
  84. package/dist/scheduler/Schedule.d.ts +6 -1
  85. package/dist/scheduler/Schedule.d.ts.map +1 -1
  86. package/dist/scheduler/Schedule.js +5 -0
  87. package/dist/scheduler/Schedule.js.map +1 -1
  88. package/dist/security/CookieSigner.d.ts.map +1 -1
  89. package/dist/security/CookieSigner.js +12 -5
  90. package/dist/security/CookieSigner.js.map +1 -1
  91. package/dist/security/SignedUrl.js +3 -2
  92. package/dist/security/SignedUrl.js.map +1 -1
  93. package/dist/session/drivers/DatabaseDriver.d.ts +13 -1
  94. package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
  95. package/dist/session/drivers/DatabaseDriver.js +23 -3
  96. package/dist/session/drivers/DatabaseDriver.js.map +1 -1
  97. package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
  98. package/dist/session/drivers/RedisDriver.js +10 -2
  99. package/dist/session/drivers/RedisDriver.js.map +1 -1
  100. package/dist/testing/ApiResponse.d.ts.map +1 -1
  101. package/dist/testing/ApiResponse.js +10 -11
  102. package/dist/testing/ApiResponse.js.map +1 -1
  103. package/dist/testing/TestClient.js +6 -6
  104. package/dist/testing/TestClient.js.map +1 -1
  105. package/dist/utils/Macroable.d.ts +6 -2
  106. package/dist/utils/Macroable.d.ts.map +1 -1
  107. package/dist/utils/Macroable.js +4 -0
  108. package/dist/utils/Macroable.js.map +1 -1
  109. package/dist/utils/objectPath.d.ts.map +1 -1
  110. package/dist/utils/objectPath.js +14 -6
  111. package/dist/utils/objectPath.js.map +1 -1
  112. package/events.darwin-arm64.node +0 -0
  113. package/events.darwin-x64.node +0 -0
  114. package/events.linux-arm64-gnu.node +0 -0
  115. package/events.linux-x64-gnu.node +0 -0
  116. package/events.win32-x64-msvc.node +0 -0
  117. package/index.darwin-arm64.node +0 -0
  118. package/index.darwin-x64.node +0 -0
  119. package/index.linux-arm64-gnu.node +0 -0
  120. package/index.linux-x64-gnu.node +0 -0
  121. package/index.win32-x64-msvc.node +0 -0
  122. package/package.json +3 -3
  123. package/scheduler.darwin-arm64.node +0 -0
  124. package/scheduler.darwin-x64.node +0 -0
  125. package/scheduler.linux-arm64-gnu.node +0 -0
  126. package/scheduler.linux-x64-gnu.node +0 -0
  127. package/scheduler.win32-x64-msvc.node +0 -0
  128. package/scripts/build-napi-types.mjs +4 -3
  129. package/scripts/generate-napi-types.mjs +28 -12
  130. package/src/Application.ts +6 -1
  131. package/src/Provider.ts +21 -2
  132. package/src/bodyparser/MultipartFile.ts +7 -3
  133. package/src/bodyparser/parseSize.ts +3 -2
  134. package/src/commands/MigrateBase.ts +4 -1
  135. package/src/console/BaseCommand.ts +1 -1
  136. package/src/console/Kernel.ts +8 -0
  137. package/src/console/types.ts +15 -0
  138. package/src/container/Container.ts +15 -6
  139. package/src/container/types.ts +11 -1
  140. package/src/decorators/Service.ts +1 -1
  141. package/src/env/Env.ts +4 -2
  142. package/src/env/interpolate.ts +5 -3
  143. package/src/errors/FuzzyMatcher.ts +34 -13
  144. package/src/errors/PipelineStageError.ts +4 -2
  145. package/src/events/Emitter.ts +34 -8
  146. package/src/events/testing/FakeBus.ts +12 -5
  147. package/src/graphql/GraphQLEngine.ts +11 -7
  148. package/src/helpers/bytes.ts +13 -2
  149. package/src/helpers/string.ts +3 -3
  150. package/src/http/Exception.ts +14 -8
  151. package/src/http/Request.ts +8 -5
  152. package/src/http/mime.ts +2 -1
  153. package/src/index.ts +2 -2
  154. package/src/native/generated.ts +12 -8
  155. package/src/openapi/OpenApiGenerator.ts +6 -4
  156. package/src/router/Router.ts +31 -11
  157. package/src/rpc/RpcRouter.ts +38 -8
  158. package/src/scheduler/Schedule.ts +8 -1
  159. package/src/security/CookieSigner.ts +15 -5
  160. package/src/security/SignedUrl.ts +3 -2
  161. package/src/session/drivers/DatabaseDriver.ts +36 -8
  162. package/src/session/drivers/RedisDriver.ts +13 -5
  163. package/src/testing/ApiResponse.ts +9 -10
  164. package/src/testing/TestClient.ts +6 -6
  165. package/src/utils/Macroable.ts +6 -2
  166. package/src/utils/objectPath.ts +12 -6
@@ -170,9 +170,9 @@ export class OpenApiGenerator {
170
170
  }
171
171
 
172
172
  if (route.validators.length > 0 && ['post', 'put', 'patch'].includes(method)) {
173
- const validatorName = route.validators[0]
174
- const schema = this.#runeSchemas.get(validatorName)
175
- if (schema) {
173
+ const [validatorName] = route.validators
174
+ const schema = validatorName === undefined ? undefined : this.#runeSchemas.get(validatorName)
175
+ if (schema && validatorName !== undefined) {
176
176
  operation.requestBody = {
177
177
  required: true,
178
178
  content: { 'application/json': { schema: this.#runeToJsonSchema(schema) } },
@@ -197,7 +197,9 @@ export class OpenApiGenerator {
197
197
  /** Extract parameter names from path. */
198
198
  #extractPathParams(path: string): string[] {
199
199
  const matches = path.matchAll(/:(\w+)\??/g)
200
- return [...matches].map((m) => m[1])
200
+ // Group 1 is required by the pattern, so every match carries it; the
201
+ // filter is how that is stated instead of asserted.
202
+ return [...matches].map((m) => m[1]).filter((name) => name !== undefined)
201
203
  }
202
204
 
203
205
  /** Generate a summary from route metadata. */
@@ -25,7 +25,16 @@ export interface SignedUrlOptions {
25
25
 
26
26
  // ─── Types ──────────────────────────────────────────────────
27
27
 
28
- export type RouteHandlerFunction = (ctx: HttpContext) => Promise<void> | void
28
+ /**
29
+ * A route handler. Whatever it returns is awaited and dropped — the response
30
+ * is written through `ctx.response`.
31
+ *
32
+ * `unknown`, not `Promise<void> | void`: a bare `void` return type accepts a
33
+ * function returning anything, but a UNION containing it does not, so
34
+ * `router.get('/x', () => 'ok')` was a type error for returning a value the
35
+ * kernel already ignores.
36
+ */
37
+ export type RouteHandlerFunction = (ctx: HttpContext) => unknown
29
38
 
30
39
  /**
31
40
  * Controller tuple: [ControllerClass, 'methodName'].
@@ -267,7 +276,13 @@ export class RouteBuilder extends Macroable {
267
276
 
268
277
  /** The primary definition — accessors read from it (all verbs share pattern/name). */
269
278
  #primary(): RouteDefinition {
270
- return this.#routes[0]
279
+ const [first] = this.#routes
280
+ if (first === undefined) {
281
+ // A route is constructed with at least one verb, so this cannot happen —
282
+ // and this is where that is stated rather than asserted past.
283
+ throw new Error('Route has no definitions')
284
+ }
285
+ return first
271
286
  }
272
287
 
273
288
  /** Name this route (for URL generation and redirects). */
@@ -1557,9 +1572,13 @@ function matchPath(pattern: string, actual: string): MatchedParams | null {
1557
1572
  const params: MatchedParams = {}
1558
1573
  for (let i = 0; i < wildcardIdx; i++) {
1559
1574
  const part = patternParts[i]
1575
+ const actual = actualParts[i]
1576
+ // Both are read out of arrays whose length was checked above; naming
1577
+ // them is what turns that check into something the compiler shares.
1578
+ if (part === undefined || actual === undefined) return null
1560
1579
  if (part.startsWith(':')) {
1561
- params[part.substring(1)] = safeDecodeURIComponent(actualParts[i])
1562
- } else if (part !== actualParts[i]) {
1580
+ params[part.substring(1)] = safeDecodeURIComponent(actual)
1581
+ } else if (part !== actual) {
1563
1582
  return null
1564
1583
  }
1565
1584
  }
@@ -1579,22 +1598,22 @@ function matchPath(pattern: string, actual: string): MatchedParams | null {
1579
1598
 
1580
1599
  const params: MatchedParams = {}
1581
1600
 
1582
- for (let i = 0; i < patternParts.length; i++) {
1583
- const part = patternParts[i]
1601
+ for (const [i, part] of patternParts.entries()) {
1584
1602
  const isOptional = part.endsWith('?')
1603
+ const actual = actualParts[i]
1585
1604
 
1586
1605
  if (part.startsWith(':')) {
1587
1606
  const paramName = isOptional ? part.slice(1, -1) : part.substring(1)
1588
- if (i < actualParts.length) {
1607
+ if (actual !== undefined) {
1589
1608
  // Percent-decode the captured segment (query strings already are) so
1590
1609
  // `/users/Jos%C3%A9` yields `José` and matchers test the real value.
1591
1610
  // safeDecode falls back to the raw segment on malformed input.
1592
- params[paramName] = safeDecodeURIComponent(actualParts[i])
1611
+ params[paramName] = safeDecodeURIComponent(actual)
1593
1612
  }
1594
1613
  // Optional param with no actual part — skip (param not set)
1595
- } else if (i < actualParts.length && part !== actualParts[i]) {
1614
+ } else if (actual !== undefined && part !== actual) {
1596
1615
  return null
1597
- } else if (i >= actualParts.length && !isOptional) {
1616
+ } else if (actual === undefined && !isOptional) {
1598
1617
  return null
1599
1618
  }
1600
1619
  }
@@ -1612,7 +1631,8 @@ function matchPath(pattern: string, actual: string): MatchedParams | null {
1612
1631
  * Comparing them verbatim sends that request to a fallback, or to a 404.
1613
1632
  */
1614
1633
  function matchDomain(pattern: string, host: string): boolean {
1615
- const actualHost = host.split(':')[0].toLowerCase() // strip port
1634
+ const [hostname = ''] = host.split(':') // strip port
1635
+ const actualHost = hostname.toLowerCase()
1616
1636
  const wanted = pattern.toLowerCase()
1617
1637
  if (wanted === actualHost) return true
1618
1638
  if (wanted.startsWith('*.')) {
@@ -30,7 +30,7 @@ export type RpcHandler = (ctx: HttpContext, params: unknown) => Promise<unknown>
30
30
  * the shared `'middleware'` registry, and `validator:<name>` schemas.
31
31
  */
32
32
  interface RpcContainer {
33
- make<T>(target: new (...args: unknown[]) => T): Promise<T>
33
+ make<T>(target: abstract new (...args: never[]) => T): Promise<T>
34
34
  resolve<T>(token: string): Promise<T>
35
35
  has(token: string): boolean
36
36
  }
@@ -151,11 +151,21 @@ export class RpcRouter {
151
151
  return new RpcMethodBuilder(def)
152
152
  }
153
153
 
154
- /** Register methods from a controller — auto-registers all public methods. */
155
- namespace(
156
- prefix: string,
157
- controller: new (...args: unknown[]) => Record<string, (...args: unknown[]) => unknown>,
158
- ): void {
154
+ /**
155
+ * Register methods from a controller — auto-registers all public methods.
156
+ *
157
+ * The parameter used to demand
158
+ * `new (...args: unknown[]) => Record<string, (...args: unknown[]) => unknown>`,
159
+ * which NO real controller satisfies: a class with any non-method property
160
+ * fails the `Record`, and a method declared `bump(): number` fails the
161
+ * `unknown[]` parameters, which are compared contravariantly. So the
162
+ * documented call — `rpc.namespace('task', TaskController)` — did not
163
+ * typecheck, and nothing noticed because the tests were outside `tsc`.
164
+ *
165
+ * Any class, then. The body already checks that what it found is callable
166
+ * and names the method when it is not.
167
+ */
168
+ namespace(prefix: string, controller: abstract new (...args: never[]) => object): void {
159
169
  const proto = controller.prototype
160
170
  const methods = Object.getOwnPropertyNames(proto).filter(
161
171
  (m) => m !== 'constructor' && typeof proto[m] === 'function',
@@ -164,8 +174,18 @@ export class RpcRouter {
164
174
  this.method(`${prefix}.${methodName}`, async (ctx, params) => {
165
175
  // Resolve through the container on every call (fresh DI per request,
166
176
  // like GraphQLEngine), falling back to a bare `new` when unset.
167
- const instance = this.#container ? await this.#container.make(controller) : new controller()
168
- return instance[methodName](ctx, params)
177
+ // `Reflect.construct`, not `new controller()`: the token may be an
178
+ // abstract class, which the runtime builds happily and `new` refuses.
179
+ const instance: object = this.#container
180
+ ? await this.#container.make(controller)
181
+ : Reflect.construct(controller, [])
182
+ const handler = Reflect.get(instance, methodName)
183
+ if (typeof handler !== 'function') {
184
+ throw new Error(
185
+ `RPC method '${prefix}.${methodName}' is not a function on ${controller.name}`,
186
+ )
187
+ }
188
+ return handler.call(instance, ctx, params)
169
189
  })
170
190
  }
171
191
  }
@@ -199,6 +219,16 @@ export class RpcRouter {
199
219
 
200
220
  // Batch support (max 50 to prevent DoS)
201
221
  if (Array.isArray(body)) {
222
+ // §6: "If the batch rpc call itself fails to be recognized as an valid
223
+ // JSON or as an Array with at least one value, the response from the
224
+ // Server MUST be a single Response object." An empty batch answered with
225
+ // 204 No Content, which is what this replies when every call in a batch
226
+ // was a notification — so a client that sent nothing was told its calls
227
+ // had run.
228
+ if (body.length === 0) {
229
+ ctx.response.status(200).json(rpcError(-32600, 'Invalid Request', null))
230
+ return
231
+ }
202
232
  if (body.length > 50) {
203
233
  ctx.response.status(400).json(rpcError(-32600, 'Batch too large (max 50)', null))
204
234
  return
@@ -90,7 +90,14 @@ export function Schedule(cronExpr: string): MethodDecorator {
90
90
  * none). Returns a shallow copy so callers cannot mutate the stored
91
91
  * metadata by accident.
92
92
  */
93
- export function getScheduleMetadata(target: Constructor): ScheduleMetadata[] {
93
+ /**
94
+ * `abstract new`, not `Constructor`: reading metadata off a class never
95
+ * constructs it, and the service registry holds abstract ones too since a
96
+ * contextual binding binds against an abstract base.
97
+ */
98
+ export function getScheduleMetadata(
99
+ target: abstract new (...args: never[]) => unknown,
100
+ ): ScheduleMetadata[] {
94
101
  const stored: ScheduleMetadata[] = Reflect.getOwnMetadata(SCHEDULE_METADATA_KEY, target) ?? []
95
102
  return [...stored]
96
103
  }
@@ -225,12 +225,22 @@ export class CookieSigner {
225
225
  }
226
226
 
227
227
  decrypt<T = string>(encrypted: string, purpose?: string): T | null {
228
- const parts = encrypted.split('.')
229
- if (parts.length !== 3) return null
228
+ // Destructured rather than indexed, and `extra` is what keeps the old
229
+ // length check honest: three names bind on a four-part string too, so the
230
+ // fourth has to be looked at to reject it.
231
+ const [ivPart, dataPart, tagPart, extra] = encrypted.split('.')
232
+ if (
233
+ ivPart === undefined ||
234
+ dataPart === undefined ||
235
+ tagPart === undefined ||
236
+ extra !== undefined
237
+ ) {
238
+ return null
239
+ }
230
240
  try {
231
- const iv = Buffer.from(parts[0], 'base64url')
232
- const data = Buffer.from(parts[1], 'base64url')
233
- const tag = Buffer.from(parts[2], 'base64url')
241
+ const iv = Buffer.from(ivPart, 'base64url')
242
+ const data = Buffer.from(dataPart, 'base64url')
243
+ const tag = Buffer.from(tagPart, 'base64url')
234
244
  const decipher = createDecipheriv('aes-256-gcm', this.#keyBuffer, iv)
235
245
  decipher.setAuthTag(tag)
236
246
  const raw = Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
@@ -55,8 +55,9 @@ function parseExpiry(value: string | number): number {
55
55
  if (typeof value === 'number') return value
56
56
  const match = value.match(/^(\d+)(s|m|h|d)$/)
57
57
  if (!match) return 3600
58
- const num = parseInt(match[1], 10)
59
- switch (match[2]) {
58
+ const [, digits, unit] = match
59
+ const num = parseInt(digits ?? '0', 10)
60
+ switch (unit) {
60
61
  case 's':
61
62
  return num
62
63
  case 'm':
@@ -8,10 +8,28 @@ import type { SessionDriverWithTagging, TaggedSession } from '../Session.js'
8
8
  * app's own wrapper.
9
9
  */
10
10
  export interface SessionDbConnection {
11
- query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>
11
+ /**
12
+ * Rows, as rows: `Record<string, unknown>`, not a shape the caller names.
13
+ *
14
+ * `query<T>` let this driver ASSERT what a SELECT returned — a promise the
15
+ * database never made and the connection cannot keep. It also made the
16
+ * interface unimplementable by anything but a real driver: a generic method
17
+ * must work for every `T`, so no fake could satisfy it, and this file's tests
18
+ * sat outside the typecheck because of it.
19
+ *
20
+ * The columns are read through {@link textColumn} below instead, which is
21
+ * what a row from a database deserves.
22
+ */
23
+ query(sql: string, params?: unknown[]): Promise<Array<Record<string, unknown>>>
12
24
  execute(sql: string, params?: unknown[]): Promise<{ rowsAffected: number }>
13
25
  }
14
26
 
27
+ /** A column that must be text to be usable, or `null`. */
28
+ function textColumn(row: Record<string, unknown>, name: string): string | null {
29
+ const value = row[name]
30
+ return typeof value === 'string' ? value : null
31
+ }
32
+
15
33
  export interface DatabaseDriverOptions {
16
34
  connection: SessionDbConnection
17
35
  /** Table holding the sessions. Default `sessions`. */
@@ -71,17 +89,20 @@ export class DatabaseDriver implements SessionDriverWithTagging {
71
89
  }
72
90
 
73
91
  async read(sessionId: string): Promise<Record<string, unknown> | null> {
74
- const rows = await this.#db.query<{ data: string; expires_at: number | string }>(
75
- `SELECT data, expires_at FROM ${this.#table} WHERE id = ?`,
76
- [sessionId],
77
- )
92
+ const rows = await this.#db.query(`SELECT data, expires_at FROM ${this.#table} WHERE id = ?`, [
93
+ sessionId,
94
+ ])
78
95
  const row = rows[0]
79
96
  if (!row) return null
80
97
  if (Number(row.expires_at) < Date.now()) {
81
98
  await this.destroy(sessionId)
82
99
  return null
83
100
  }
84
- return parseData(row.data)
101
+ const data = textColumn(row, 'data')
102
+ // A row whose payload is not text is not a session — the same outcome as a
103
+ // row that will not parse.
104
+ if (data === null) return null
105
+ return parseData(data)
85
106
  }
86
107
 
87
108
  async write(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<void> {
@@ -157,11 +178,18 @@ export class DatabaseDriver implements SessionDriverWithTagging {
157
178
 
158
179
  /** Every non-expired session this user holds — their active devices. */
159
180
  async tagged(userId: string | number): Promise<TaggedSession[]> {
160
- const rows = await this.#db.query<{ id: string; data: string }>(
181
+ const rows = await this.#db.query(
161
182
  `SELECT id, data FROM ${this.#table} WHERE user_id = ? AND expires_at >= ?`,
162
183
  [String(userId), Date.now()],
163
184
  )
164
- return rows.map((row) => ({ id: row.id, data: parseData(row.data) }))
185
+ const sessions: TaggedSession[] = []
186
+ for (const row of rows) {
187
+ const id = textColumn(row, 'id')
188
+ const data = textColumn(row, 'data')
189
+ if (id === null || data === null) continue
190
+ sessions.push({ id, data: parseData(data) })
191
+ }
192
+ return sessions
165
193
  }
166
194
 
167
195
  /** Delete every expired row — for a scheduled job, since nothing else does. */
@@ -67,11 +67,19 @@ export class RedisDriver implements SessionDriverWithTagging {
67
67
  }
68
68
  if (!this.#pending) {
69
69
  const resolver = this.#source
70
- this.#pending = Promise.resolve(resolver()).then((client) => {
71
- this.#resolved = client
72
- this.#pending = undefined
73
- return client
74
- })
70
+ // `finally`, not the success path: clearing it only on success left a
71
+ // REJECTED promise in the slot, and every later call returned that same
72
+ // rejection. One refused connection at boot — Redis still starting, a
73
+ // network blip — and the driver never tried again for the life of the
74
+ // process. Bay and echo already clear it this way.
75
+ this.#pending = Promise.resolve(resolver())
76
+ .then((client) => {
77
+ this.#resolved = client
78
+ return client
79
+ })
80
+ .finally(() => {
81
+ this.#pending = undefined
82
+ })
75
83
  }
76
84
  return this.#pending
77
85
  }
@@ -149,15 +149,14 @@ function parseMultipartResponse(
149
149
  // Trim the trailing CRLF before the next boundary.
150
150
  let content = part.subarray(headerEnd + 4)
151
151
  if (content.subarray(-2).toString('binary') === '\r\n') content = content.subarray(0, -2)
152
- const nameMatch = headerText.match(/name="([^"]*)"/i)
153
- if (!nameMatch) continue
154
- const name = nameMatch[1]
155
- const fileMatch = headerText.match(/filename="([^"]*)"/i)
156
- if (fileMatch) {
152
+ const [, name] = headerText.match(/name="([^"]*)"/i) ?? []
153
+ if (name === undefined) continue
154
+ const [, filename] = headerText.match(/filename="([^"]*)"/i) ?? []
155
+ if (filename !== undefined) {
157
156
  const typeMatch = headerText.match(/content-type:\s*([^\r\n]+)/i)
158
157
  files[name] = {
159
158
  fieldName: name,
160
- filename: fileMatch[1],
159
+ filename,
161
160
  type: typeMatch?.[1]?.trim() ?? 'application/octet-stream',
162
161
  size: content.length,
163
162
  content,
@@ -355,8 +354,8 @@ export class ApiResponse {
355
354
  /** Charset from `Content-Type`, if any — helix `charset()`. */
356
355
  charset(): string | undefined {
357
356
  const ct = this.#raw.headers['content-type'] ?? ''
358
- const m = ct.match(/charset=([^;]+)/i)
359
- return m ? m[1].trim() : undefined
357
+ const [, charset] = ct.match(/charset=([^;]+)/i) ?? []
358
+ return charset?.trim()
360
359
  }
361
360
 
362
361
  /** All response cookies, parsed from `Set-Cookie` — helix `cookies()`. */
@@ -380,8 +379,8 @@ export class ApiResponse {
380
379
  if (!link) return {}
381
380
  const out: Record<string, string> = {}
382
381
  for (const part of link.split(',')) {
383
- const m = part.match(/<([^>]+)>\s*;\s*rel="?([^";]+)"?/)
384
- if (m) out[m[2]] = m[1]
382
+ const [, url, rel] = part.match(/<([^>]+)>\s*;\s*rel="?([^";]+)"?/) ?? []
383
+ if (url !== undefined && rel !== undefined) out[rel] = url
385
384
  }
386
385
  return out
387
386
  }
@@ -368,15 +368,15 @@ export function parseHttpResponse(raw: string | Buffer): TestResponse {
368
368
 
369
369
  const lines = headerSection.split('\r\n')
370
370
  const statusLine = lines[0] ?? ''
371
- const statusMatch = statusLine.match(/HTTP\/\d+\.\d+ (\d+)/)
372
- const status = statusMatch ? Number.parseInt(statusMatch[1], 10) : 0
371
+ const [, statusDigits] = statusLine.match(/HTTP\/\d+\.\d+ (\d+)/) ?? []
372
+ const status = statusDigits === undefined ? 0 : Number.parseInt(statusDigits, 10)
373
373
 
374
374
  const headers: Dict = {}
375
- for (let i = 1; i < lines.length; i++) {
376
- const colonIdx = lines[i].indexOf(':')
375
+ for (const line of lines.slice(1)) {
376
+ const colonIdx = line.indexOf(':')
377
377
  if (colonIdx === -1) continue
378
- const key = lines[i].slice(0, colonIdx).trim().toLowerCase()
379
- const value = lines[i].slice(colonIdx + 1).trim()
378
+ const key = line.slice(0, colonIdx).trim().toLowerCase()
379
+ const value = line.slice(colonIdx + 1).trim()
380
380
  headers[key] = value
381
381
  }
382
382
 
@@ -38,7 +38,11 @@ export class Macroable {
38
38
  * `HttpContext`, and two subclasses declaring the same name clobbered each
39
39
  * other silently.
40
40
  */
41
- static macro(this: typeof Macroable, name: string, fn: MacroFn): void {
41
+ // `{ prototype: object }`, not `typeof Macroable`: a subclass whose
42
+ // constructor takes arguments — `Request`, say — has a different static side,
43
+ // so `Request.macro(...)` was "the 'this' context of type 'typeof Request' is
44
+ // not assignable". What this needs from `this` is the prototype it defines on.
45
+ static macro(this: { prototype: object }, name: string, fn: MacroFn): void {
42
46
  Object.defineProperty(this.prototype, name, {
43
47
  value: fn,
44
48
  writable: true,
@@ -51,7 +55,7 @@ export class Macroable {
51
55
  * `getter`). With `singleton`, the value is computed once per instance and
52
56
  * cached.
53
57
  */
54
- static getter(this: typeof Macroable, name: string, fn: GetterFn, singleton = false): void {
58
+ static getter(this: { prototype: object }, name: string, fn: GetterFn, singleton = false): void {
55
59
  if (!singleton) {
56
60
  Object.defineProperty(this.prototype, name, { get: fn, configurable: true })
57
61
  return
@@ -77,9 +77,13 @@ export function hasPath(source: unknown, path: string): boolean {
77
77
  */
78
78
  export function setPath(target: UnknownRecord, path: string, value: unknown): UnknownRecord {
79
79
  const segments = toSegments(path)
80
+ // The last segment is the one written; everything before it is walked. Split
81
+ // off by name rather than by index, so neither has to be read back out of
82
+ // the array afterwards and re-checked for existence.
83
+ const last = segments.pop()
84
+ if (last === undefined) return target
80
85
  let current: UnknownRecord = target
81
- for (let i = 0; i < segments.length - 1; i += 1) {
82
- const key = segments[i]
86
+ for (const key of segments) {
83
87
  const next = current[key]
84
88
  if (!isIndexable(next)) {
85
89
  const created: UnknownRecord = {}
@@ -89,7 +93,7 @@ export function setPath(target: UnknownRecord, path: string, value: unknown): Un
89
93
  current = next
90
94
  }
91
95
  }
92
- current[segments[segments.length - 1]] = value
96
+ current[last] = value
93
97
  return target
94
98
  }
95
99
 
@@ -115,12 +119,14 @@ export function omitPaths(source: UnknownRecord, paths: string[]): UnknownRecord
115
119
  const result = structuredClone(source)
116
120
  for (const path of paths) {
117
121
  const segments = toSegments(path)
122
+ const last = segments.pop()
123
+ if (last === undefined) continue
118
124
  let current: unknown = result
119
- for (let i = 0; i < segments.length - 1; i += 1) {
125
+ for (const key of segments) {
120
126
  if (!isIndexable(current)) break
121
- current = current[segments[i]]
127
+ current = current[key]
122
128
  }
123
- if (isIndexable(current)) delete current[segments[segments.length - 1]]
129
+ if (isIndexable(current)) delete current[last]
124
130
  }
125
131
  return result
126
132
  }