hanzio 3.1.1 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +8 -1
  2. package/dist/cli/fields.d.ts +16 -6
  3. package/dist/cli/index.d.ts +57 -4
  4. package/dist/crypto/node.d.ts +17 -0
  5. package/dist/crypto/sealer.d.ts +34 -2
  6. package/dist/format/index.d.ts +6 -3
  7. package/dist/{index-nfvq3zax.js → index-818f7jz4.js} +228 -88
  8. package/dist/index-zz773050.js +135 -0
  9. package/dist/redis/concurrency-limit.d.ts +17 -7
  10. package/dist/src/cli/index.js +3 -1
  11. package/dist/src/crypto/index.js +15 -106
  12. package/dist/src/crypto/node.js +67 -0
  13. package/dist/src/format/index.js +4 -3
  14. package/dist/src/i18n/cli.js +33 -1
  15. package/dist/src/redis/index.js +5 -1
  16. package/dist/src/testing/index.js +23 -4
  17. package/dist/string/index.d.ts +1 -1
  18. package/dist/testing/module-mocker.d.ts +9 -9
  19. package/dist/testing/prisma.d.ts +5 -1
  20. package/dist/typedswitch/index.d.ts +26 -1
  21. package/package.json +8 -2
  22. package/src/cli/README.md +63 -6
  23. package/src/cli/fields.ts +95 -38
  24. package/src/cli/index.ts +302 -91
  25. package/src/crypto/README.md +21 -7
  26. package/src/crypto/node.ts +90 -0
  27. package/src/crypto/sealer.ts +120 -62
  28. package/src/format/README.md +7 -0
  29. package/src/format/index.ts +11 -5
  30. package/src/i18n/README.md +3 -1
  31. package/src/i18n/cli.ts +40 -0
  32. package/src/redis/README.md +9 -7
  33. package/src/redis/concurrency-limit.ts +26 -8
  34. package/src/string/index.ts +1 -1
  35. package/src/testing/README.md +12 -3
  36. package/src/testing/module-mocker.ts +40 -10
  37. package/src/testing/prisma.ts +6 -1
  38. package/src/typedswitch/README.md +20 -9
  39. package/src/typedswitch/index.ts +84 -8
@@ -0,0 +1,90 @@
1
+ // Synchronous twin of `createSealer`, on `node:crypto`, for code that cannot
2
+ // await: formatters, getters, Prisma result extensions. Same options, same
3
+ // envelope, so either sealer opens what the other sealed.
4
+
5
+ import {
6
+ createCipheriv,
7
+ createDecipheriv,
8
+ hkdfSync,
9
+ randomBytes
10
+ } from 'node:crypto'
11
+ import {
12
+ type Envelope,
13
+ formatEnvelope,
14
+ jsonToSeal,
15
+ parseEnvelope,
16
+ type SealerOptions,
17
+ SealerError,
18
+ sealerKeyInputs
19
+ } from './sealer'
20
+
21
+ export { SealerError, type SealerErrorCode, type SealerOptions } from './sealer'
22
+
23
+ export type SealerSync = {
24
+ seal(plaintext: string): string
25
+ open(sealed: string): string
26
+ sealJson(value: unknown): string
27
+ openJson<T = unknown>(sealed: unknown): T
28
+ }
29
+
30
+ const ALGORITHM = 'aes-256-gcm'
31
+
32
+ /**
33
+ * AES-256-GCM sealing with a random 96-bit IV per value, synchronously.
34
+ *
35
+ * @example
36
+ * const secrets = createSealerSync({ secret: process.env.APP_SECRET!, info: 'app.credentials.v1' })
37
+ * const stored = secrets.sealJson({ accessToken })
38
+ * const { accessToken } = secrets.openJson<{ accessToken: string }>(stored)
39
+ */
40
+ export function createSealerSync(options: SealerOptions): SealerSync {
41
+ const { secret, contexts } = sealerKeyInputs(options)
42
+
43
+ const keys: Buffer[] = []
44
+ const getKey = (index: number) => {
45
+ let key = keys[index]
46
+ if (!key) {
47
+ const { salt, info } = contexts[index] as (typeof contexts)[number]
48
+ key = Buffer.from(hkdfSync('sha256', secret, salt, info, 32))
49
+ keys[index] = key
50
+ }
51
+ return key
52
+ }
53
+
54
+ const seal = (plaintext: string) => {
55
+ const iv = randomBytes(12)
56
+ const cipher = createCipheriv(ALGORITHM, getKey(0), iv)
57
+ const ciphertext = Buffer.concat([
58
+ cipher.update(plaintext, 'utf8'),
59
+ cipher.final()
60
+ ])
61
+ return formatEnvelope(iv, cipher.getAuthTag(), ciphertext)
62
+ }
63
+
64
+ const openEnvelope = ({ iv, tag, ciphertext }: Envelope) => {
65
+ for (let index = 0; index < contexts.length; index++) {
66
+ try {
67
+ const decipher = createDecipheriv(ALGORITHM, getKey(index), iv)
68
+ decipher.setAuthTag(tag)
69
+ return Buffer.concat([
70
+ decipher.update(ciphertext),
71
+ decipher.final()
72
+ ]).toString('utf8')
73
+ } catch (_error) {}
74
+ }
75
+ throw new SealerError('decrypt_failed')
76
+ }
77
+
78
+ return {
79
+ seal,
80
+ open(sealed) {
81
+ const envelope = parseEnvelope(sealed, options.passthroughUnsealed)
82
+ return envelope ? openEnvelope(envelope) : sealed
83
+ },
84
+ sealJson: (value) => seal(jsonToSeal(value)),
85
+ openJson<T = unknown>(sealed: unknown) {
86
+ const envelope = parseEnvelope(sealed, options.passthroughUnsealed)
87
+ return (envelope ? JSON.parse(openEnvelope(envelope)) : sealed) as T
88
+ }
89
+ }
90
+ }
@@ -15,13 +15,24 @@ export type SealerOptions = {
15
15
  salt?: string
16
16
  /** HKDF info; use a distinct value per purpose. Default: `'hanzio.sealer.aes-256-gcm.v1'` */
17
17
  info?: string
18
+ /**
19
+ * Older `{ salt, info }` pairs that `open` also tries, so values sealed before
20
+ * a rename stay readable. `seal` always uses `salt` and `info`.
21
+ */
22
+ fallbacks?: readonly { salt?: string; info?: string }[]
23
+ /**
24
+ * `open` and `openJson` return a value that was never sealed unchanged
25
+ * (`{}`, a legacy plaintext) instead of throwing. Malformed envelopes and
26
+ * other envelope versions still throw.
27
+ */
28
+ passthroughUnsealed?: boolean
18
29
  }
19
30
 
20
31
  export type Sealer = {
21
32
  seal(plaintext: string): Promise<string>
22
33
  open(sealed: string): Promise<string>
23
34
  sealJson(value: unknown): Promise<string>
24
- openJson<T = unknown>(sealed: string): Promise<T>
35
+ openJson<T = unknown>(sealed: unknown): Promise<T>
25
36
  }
26
37
 
27
38
  export type SealerErrorCode =
@@ -50,26 +61,92 @@ const IV_BYTES = 12
50
61
  const TAG_BYTES = 16
51
62
  const DEFAULT_INFO = 'hanzio.sealer.aes-256-gcm.v1'
52
63
 
64
+ // The helpers below are shared with the `node:crypto` sealer in `./node`.
65
+
66
+ /** The secret's bytes and every HKDF context `open` tries, primary first. */
67
+ export function sealerKeyInputs(options: SealerOptions) {
68
+ const secret = toBytes(options.secret)
69
+ if (secret.length === 0) {
70
+ throw new TypeError('Sealer secret must not be empty')
71
+ }
72
+ const contexts = [options, ...(options.fallbacks ?? [])].map(
73
+ ({ salt = '', info = DEFAULT_INFO }) => ({ salt, info })
74
+ )
75
+ return { secret, contexts }
76
+ }
77
+
78
+ export function formatEnvelope(
79
+ iv: Uint8Array,
80
+ tag: Uint8Array,
81
+ ciphertext: Uint8Array
82
+ ): string {
83
+ return `${PREFIX}${encodeBase64Url(iv)}.${encodeBase64Url(tag)}.${encodeBase64Url(ciphertext)}`
84
+ }
85
+
86
+ export type Envelope = {
87
+ iv: Uint8Array<ArrayBuffer>
88
+ tag: Uint8Array<ArrayBuffer>
89
+ ciphertext: Uint8Array<ArrayBuffer>
90
+ }
91
+
92
+ /** `null` means `sealed` was never sealed and `passthroughUnsealed` allows it. */
93
+ export function parseEnvelope(
94
+ sealed: unknown,
95
+ passthroughUnsealed = false
96
+ ): Envelope | null {
97
+ if (typeof sealed !== 'string' || !sealed.startsWith(PREFIX)) {
98
+ const isEnvelope = typeof sealed === 'string' && /^enc:v\d+:/.test(sealed)
99
+ if (isEnvelope) throw new SealerError('unsupported_version')
100
+ if (passthroughUnsealed) return null
101
+ throw new SealerError('malformed')
102
+ }
103
+
104
+ const parts = sealed.slice(PREFIX.length).split('.')
105
+ let envelope: Envelope
106
+ try {
107
+ if (parts.length !== 3) throw new Error()
108
+ envelope = {
109
+ iv: decodeBase64Url(parts[0] as string),
110
+ tag: decodeBase64Url(parts[1] as string),
111
+ ciphertext: decodeBase64Url(parts[2] as string)
112
+ }
113
+ } catch (_error) {
114
+ throw new SealerError('malformed')
115
+ }
116
+ if (envelope.iv.length !== IV_BYTES || envelope.tag.length !== TAG_BYTES) {
117
+ throw new SealerError('malformed')
118
+ }
119
+ return envelope
120
+ }
121
+
122
+ export function jsonToSeal(value: unknown): string {
123
+ const json = JSON.stringify(value)
124
+ if (json === undefined) {
125
+ throw new TypeError('sealJson value is not JSON-serializable')
126
+ }
127
+ return json
128
+ }
129
+
53
130
  /**
54
131
  * AES-256-GCM sealing with a random 96-bit IV per value. Envelope:
55
- * `enc:v1:<b64url(iv)>.<b64url(tag)>.<b64url(ciphertext)>`.
132
+ * `enc:v1:<b64url(iv)>.<b64url(tag)>.<b64url(ciphertext)>`. Async, on Web
133
+ * Crypto; `hanzio/crypto/node` has a synchronous twin with the same envelope.
56
134
  */
57
135
  export function createSealer(options: SealerOptions): Sealer {
58
- const { secret, salt = '', info = DEFAULT_INFO } = options
59
- const secretBytes = toBytes(secret)
60
- if (secretBytes.length === 0) {
61
- throw new TypeError('Sealer secret must not be empty')
62
- }
63
-
64
- let keyPromise: Promise<CryptoKey> | undefined
65
- const getKey = () => {
66
- if (!keyPromise) {
67
- keyPromise = deriveKey(secretBytes, salt, info)
68
- keyPromise.catch(() => {
69
- keyPromise = undefined
136
+ const { secret, contexts } = sealerKeyInputs(options)
137
+
138
+ const keys: (Promise<CryptoKey> | undefined)[] = []
139
+ const getKey = (index: number) => {
140
+ let key = keys[index]
141
+ if (!key) {
142
+ const context = contexts[index] as (typeof contexts)[number]
143
+ key = deriveKey(secret, context.salt, context.info)
144
+ keys[index] = key
145
+ key.catch(() => {
146
+ keys[index] = undefined
70
147
  })
71
148
  }
72
- return keyPromise
149
+ return key
73
150
  }
74
151
 
75
152
  const seal = async (plaintext: string): Promise<string> => {
@@ -77,69 +154,50 @@ export function createSealer(options: SealerOptions): Sealer {
77
154
  const encrypted = new Uint8Array(
78
155
  await getSubtleCrypto().encrypt(
79
156
  { name: 'AES-GCM', iv },
80
- await getKey(),
157
+ await getKey(0),
81
158
  utf8Encode(plaintext)
82
159
  )
83
160
  )
84
161
  const tagStart = encrypted.length - TAG_BYTES
85
- return `${PREFIX}${encodeBase64Url(iv)}.${encodeBase64Url(encrypted.subarray(tagStart))}.${encodeBase64Url(encrypted.subarray(0, tagStart))}`
162
+ return formatEnvelope(
163
+ iv,
164
+ encrypted.subarray(tagStart),
165
+ encrypted.subarray(0, tagStart)
166
+ )
86
167
  }
87
168
 
88
- const open = async (sealed: string): Promise<string> => {
89
- if (typeof sealed !== 'string' || !sealed.startsWith(PREFIX)) {
90
- const isOtherVersion =
91
- typeof sealed === 'string' && /^enc:v\d+:/.test(sealed)
92
- throw new SealerError(
93
- isOtherVersion ? 'unsupported_version' : 'malformed'
94
- )
95
- }
96
-
97
- const parts = sealed.slice(PREFIX.length).split('.')
98
- let iv: Uint8Array<ArrayBuffer>
99
- let tag: Uint8Array
100
- let ciphertext: Uint8Array
101
- try {
102
- if (parts.length !== 3) throw new Error()
103
- iv = decodeBase64Url(parts[0] as string)
104
- tag = decodeBase64Url(parts[1] as string)
105
- ciphertext = decodeBase64Url(parts[2] as string)
106
- } catch (_error) {
107
- throw new SealerError('malformed')
108
- }
109
- if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) {
110
- throw new SealerError('malformed')
111
- }
112
-
169
+ const openEnvelope = async ({ iv, tag, ciphertext }: Envelope) => {
113
170
  const encrypted = new Uint8Array(ciphertext.length + TAG_BYTES)
114
171
  encrypted.set(ciphertext)
115
172
  encrypted.set(tag, ciphertext.length)
116
173
 
117
- const key = await getKey()
118
- let plaintext: ArrayBuffer
119
- try {
120
- plaintext = await getSubtleCrypto().decrypt(
121
- { name: 'AES-GCM', iv },
122
- key,
123
- encrypted
124
- )
125
- } catch (_error) {
126
- throw new SealerError('decrypt_failed')
174
+ for (let index = 0; index < contexts.length; index++) {
175
+ const key = await getKey(index)
176
+ try {
177
+ return utf8Decode(
178
+ await getSubtleCrypto().decrypt(
179
+ { name: 'AES-GCM', iv },
180
+ key,
181
+ encrypted
182
+ )
183
+ )
184
+ } catch (_error) {}
127
185
  }
128
- return utf8Decode(plaintext)
186
+ throw new SealerError('decrypt_failed')
187
+ }
188
+
189
+ const open = async (sealed: string): Promise<string> => {
190
+ const envelope = parseEnvelope(sealed, options.passthroughUnsealed)
191
+ return envelope ? openEnvelope(envelope) : sealed
129
192
  }
130
193
 
131
194
  return {
132
195
  seal,
133
196
  open,
134
- async sealJson(value) {
135
- const json = JSON.stringify(value)
136
- if (json === undefined) {
137
- throw new TypeError('sealJson value is not JSON-serializable')
138
- }
139
- return seal(json)
140
- },
141
- async openJson<T = unknown>(sealed: string) {
142
- return JSON.parse(await open(sealed)) as T
197
+ sealJson: async (value) => seal(jsonToSeal(value)),
198
+ async openJson<T = unknown>(sealed: unknown) {
199
+ const envelope = parseEnvelope(sealed, options.passthroughUnsealed)
200
+ return (envelope ? JSON.parse(await openEnvelope(envelope)) : sealed) as T
143
201
  }
144
202
  }
145
203
  }
@@ -87,6 +87,13 @@ formatDateTime(date, 'en-GB') // '5 Mar 2026, 07:04' (newer ICU: '5 Mar 2026 at
87
87
  Presets: `'DD/MM/YYYY'`, `'DD/MM/YYYY HH:mm'`, `'HH:mm'`, `'ddd'`, `'D MMM'`,
88
88
  `'D MMM YYYY'`, `'dddd, MMMM D'`, `'MMMM YYYY'`.
89
89
 
90
+ For any other layout, pass `Intl.DateTimeFormat` options instead of a preset:
91
+
92
+ ```ts
93
+ formatDate(date, { weekday: 'long', day: 'numeric', month: 'long' }, 'en-GB') // 'Thursday 5 March'
94
+ formatDate(date, { dateStyle: 'short', timeZone: 'Europe/Copenhagen' }, 'da-DK') // '05.03.2026'
95
+ ```
96
+
90
97
  `formatCompactTime(date, locale, { now? })` is an activity timestamp for list
91
98
  rows: the time today, "yesterday", else the date (with the year when it is not
92
99
  this year). `formatDayDivider(date, locale, { now? })` labels a chat timeline:
@@ -401,13 +401,17 @@ const DATE_PRESETS = {
401
401
  */
402
402
  export type DatePreset = keyof typeof DATE_PRESETS
403
403
 
404
- /** A date in one of the `DatePreset` layouts, in local time. */
404
+ /**
405
+ * A date in one of the `DatePreset` layouts, or with any
406
+ * `Intl.DateTimeFormat` options, in local time unless they set `timeZone`.
407
+ */
405
408
  export function formatDate(
406
409
  date: DateInput,
407
- preset: DatePreset,
410
+ format: DatePreset | Intl.DateTimeFormatOptions,
408
411
  locale: string
409
412
  ): string {
410
- return getDateTimeFormat(locale, DATE_PRESETS[preset]).format(toTime(date))
413
+ const options = typeof format === 'string' ? DATE_PRESETS[format] : format
414
+ return getDateTimeFormat(locale, options).format(toTime(date))
411
415
  }
412
416
 
413
417
  /** Medium date and short time: "5 Mar 2026, 14:30" (en-GB). */
@@ -519,8 +523,10 @@ export function pluralize(
519
523
  */
520
524
  export function withLocale(getLocale: () => string) {
521
525
  return {
522
- formatDate: (date: DateInput, preset: DatePreset) =>
523
- formatDate(date, preset, getLocale()),
526
+ formatDate: (
527
+ date: DateInput,
528
+ format: DatePreset | Intl.DateTimeFormatOptions
529
+ ) => formatDate(date, format, getLocale()),
524
530
  formatDateTime: (date: DateInput) => formatDateTime(date, getLocale()),
525
531
  formatCompactTime: (date: DateInput) =>
526
532
  formatCompactTime(date, getLocale()),
@@ -203,7 +203,9 @@ Both read `i18n.config.ts` (or `.mts`, `.js`, `.mjs`, or `--config <file>`)
203
203
  from the current directory; paths in it are relative to the config file.
204
204
  `defineI18nConfig` rejects a `baseLocale`, `disjointNamespaces` catalog or
205
205
  `identical.allowKeys` locale the config does not declare. A
206
- `.ts` config needs Bun, or a Node version that strips types. The bin is built
206
+ `.ts` config needs Bun or Node 22.18+. Its relative imports may leave out the
207
+ extension or `/index`, as TypeScript's bundler resolution allows, so
208
+ `npx hanzio-i18n check` works without `bun --bun`. The bin is built
207
209
  on [`hanzio/cli`](../cli/README.md), so it needs `zod` installed; `--help`
208
210
  lists its options and it exits 1 on catalog problems, 2 on bad arguments.
209
211
 
package/src/i18n/cli.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  // a Node version that strips types.
6
6
 
7
7
  import { existsSync } from 'node:fs'
8
+ import * as nodeModule from 'node:module'
8
9
  import { dirname, resolve } from 'node:path'
9
10
  import { pathToFileURL } from 'node:url'
10
11
  import { z } from 'zod'
@@ -25,6 +26,44 @@ const CONFIG_NAMES = [
25
26
 
26
27
  type Loaded = { config: I18nConfig; root: string }
27
28
 
29
+ // Node resolves a config's imports like a browser: `./src/locales` fails
30
+ // because it names neither a file nor an extension. Bun resolves it like a
31
+ // bundler. These are the suffixes TypeScript's bundler resolution tries.
32
+ const BUNDLER_SUFFIXES = [
33
+ '.ts',
34
+ '.mts',
35
+ '.js',
36
+ '.mjs',
37
+ '/index.ts',
38
+ '/index.mts',
39
+ '/index.js',
40
+ '/index.mjs'
41
+ ]
42
+
43
+ function resolveLikeBundlers() {
44
+ // Missing under Bun, which needs no help, and under Node before 22.15.
45
+ nodeModule.registerHooks?.({
46
+ resolve(specifier, context, nextResolve) {
47
+ try {
48
+ return nextResolve(specifier, context)
49
+ } catch (error) {
50
+ const code = (error as { code?: string }).code
51
+ const retry =
52
+ specifier.startsWith('.') &&
53
+ (code === 'ERR_MODULE_NOT_FOUND' ||
54
+ code === 'ERR_UNSUPPORTED_DIR_IMPORT')
55
+ if (!retry) throw error
56
+ for (const suffix of BUNDLER_SUFFIXES) {
57
+ try {
58
+ return nextResolve(specifier + suffix, context)
59
+ } catch {}
60
+ }
61
+ throw error
62
+ }
63
+ }
64
+ })
65
+ }
66
+
28
67
  async function loadConfig(file: string | undefined): Promise<Loaded> {
29
68
  const path = file
30
69
  ? resolve(file)
@@ -33,6 +72,7 @@ async function loadConfig(file: string | undefined): Promise<Loaded> {
33
72
  throw new CliError(
34
73
  `No i18n config found: pass --config or add ${CONFIG_NAMES[0]}.`
35
74
  )
75
+ resolveLikeBundlers()
36
76
  const loaded = (await import(pathToFileURL(path).href)) as {
37
77
  default?: I18nConfig
38
78
  }
@@ -89,17 +89,19 @@ Metrics and logs are hooks:
89
89
  ```ts
90
90
  const withLimit = createRedisConcurrencyLimit({
91
91
  ...options,
92
- logger, // throttled warnings; defaults to console
93
- onWait: (stage) => {
94
- const timer = waitHistogram.startTimer({ pool: 'openai', stage })
92
+ logger, // logger.warn(message, { key, error }), throttled; defaults to console
93
+ metricLabel: 'openai', // defaults to key
94
+ onWait: (stage, label) => {
95
+ const timer = waitHistogram.startTimer({ pool: label, stage })
95
96
  return () => timer.stop()
96
- }
97
+ },
98
+ onQueueDepth: (queueDepth, label) => queueDepthGauge.register(label, queueDepth)
97
99
  })
98
- queueDepthGauge.observe(() => withLimit.queueDepth())
99
100
  ```
100
101
 
101
- A limiter created per tenant or per host should report under a shared pool
102
- label, not its key, and must be `dispose()`d when discarded.
102
+ A limiter created per tenant or per host should share one `metricLabel`, not
103
+ default to its key, and must be `dispose()`d when discarded. `dispose()` runs
104
+ the function `onQueueDepth` returned, so the gauge stops reading the limiter.
103
105
 
104
106
  ## Lua scripts
105
107
 
@@ -176,10 +176,23 @@ export type RedisConcurrencyLimitOptions = {
176
176
  maxStartsPerSecond?: number
177
177
  failOpenAfterMs?: number
178
178
  commandTimeoutMs?: number
179
- /** Receives throttled warnings. Defaults to `console`. */
180
- logger?: Pick<Console, 'warn'>
179
+ /** Receives throttled warnings with `{ key, error }`. Defaults to `console`. */
180
+ logger?: { warn(message: string, attributes: Record<string, unknown>): void }
181
+ /** The label `onWait` and `onQueueDepth` report under. Default: `key`. */
182
+ metricLabel?: string
181
183
  /** Called when a caller starts queueing; the returned function runs once it stops. */
182
- onWait?: (stage: ConcurrencyLimitStage) => (() => void) | void
184
+ onWait?: (
185
+ stage: ConcurrencyLimitStage,
186
+ metricLabel: string
187
+ ) => (() => void) | void
188
+ /**
189
+ * Called once with `queueDepth`, to register a gauge; the returned function
190
+ * runs on `dispose()`.
191
+ */
192
+ onQueueDepth?: (
193
+ queueDepth: () => number,
194
+ metricLabel: string
195
+ ) => (() => void) | void
183
196
  }
184
197
 
185
198
  export type RedisConcurrencyLimit = {
@@ -216,10 +229,11 @@ export type RedisConcurrencyLimit = {
216
229
  * That verdict is shared per limiter: once tripped, later calls degrade
217
230
  * immediately while a background probe watches for Redis to come back.
218
231
  *
219
- * `onWait` and `queueDepth` feed metrics. A limiter minted per tenant or per
220
- * customer host should report under a shared pool label rather than its key,
221
- * or every key ever touched leaves a permanent series. Call `dispose()` when
222
- * such a limiter is discarded, and unregister any gauge reading `queueDepth`.
232
+ * `onWait` and `onQueueDepth` feed metrics under `metricLabel`. A limiter
233
+ * minted per tenant or per customer host should share one pool label rather
234
+ * than default to its key, or every key ever touched leaves a permanent
235
+ * series. Call `dispose()` when such a limiter is discarded; it also runs the
236
+ * unregister function `onQueueDepth` returned.
223
237
  *
224
238
  * @example
225
239
  * const withLimit = createRedisConcurrencyLimit({
@@ -236,6 +250,7 @@ export function createRedisConcurrencyLimit(
236
250
  if (!Number.isInteger(input.max) || input.max < 1)
237
251
  throw new Error(`max must be a positive integer, got ${input.max}`)
238
252
  const logger = input.logger ?? console
253
+ const metricLabel = input.metricLabel ?? input.key
239
254
  const commandTimeoutMs = input.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS
240
255
  const send = withCommandTimeout(input.send, commandTimeoutMs)
241
256
  const ttlMs = String(input.slotTtlMs)
@@ -399,7 +414,7 @@ export function createRedisConcurrencyLimit(
399
414
  depth: () => queue.length,
400
415
  /** Resolves true once the token holds a slot, false when failing open. */
401
416
  acquire(token: string) {
402
- const stopWait = input.onWait?.(stage)
417
+ const stopWait = input.onWait?.(stage, metricLabel)
403
418
  const granted = new Promise<boolean>((resolve) => {
404
419
  queue.push({ token, settle: resolve })
405
420
  })
@@ -437,6 +452,7 @@ export function createRedisConcurrencyLimit(
437
452
  )
438
453
 
439
454
  const queueDepth = () => slotPump.depth() + (startPump?.depth() ?? 0)
455
+ let unregisterQueueDepth = input.onQueueDepth?.(queueDepth, metricLabel)
440
456
 
441
457
  // -------------------------------------------------------------- heartbeat
442
458
 
@@ -573,6 +589,8 @@ export function createRedisConcurrencyLimit(
573
589
  * before it resolves - so there is nothing left to heartbeat.
574
590
  */
575
591
  function dispose() {
592
+ unregisterQueueDepth?.()
593
+ unregisterQueueDepth = undefined
576
594
  if (probeTimer) {
577
595
  clearTimeout(probeTimer)
578
596
  probeTimer = null
@@ -8,7 +8,7 @@ export type JsonValue =
8
8
  | JsonObject
9
9
  | JsonArray
10
10
  export type JsonObject = { [key: string]: JsonValue | undefined }
11
- export type JsonArray = Array<JsonValue | undefined>
11
+ export type JsonArray = JsonValue[]
12
12
 
13
13
  /**
14
14
  * Parses the first number-like token in a string. Only a `-` directly before
@@ -33,9 +33,18 @@ await moduleMocker.mock(path.resolve(import.meta.dir, '../lib/posthog.ts'), () =
33
33
  afterAll(() => moduleMocker.clear())
34
34
  ```
35
35
 
36
- Bun resolves a relative specifier from the file that registers the mock, which
37
- would be hanzio's. Pass absolute paths or package names, or give the
38
- constructor a resolver for your own shorthand:
36
+ Bun resolves a specifier from the file that registers the mock, which would be
37
+ hanzio's: a relative path points into hanzio, and under isolated installs a
38
+ package name can find another copy or none. Pass your source root, and the
39
+ mocker resolves package names from there and other paths relative to it:
40
+
41
+ ```ts
42
+ const moduleMocker = new ModuleMocker(path.resolve(import.meta.dir, '..'))
43
+ await moduleMocker.mock('lib/posthog.ts', () => ({ captureException: mock() }))
44
+ await moduleMocker.mock('ai', () => ({ generateText: mock() }))
45
+ ```
46
+
47
+ Or give the constructor a resolver for your own shorthand:
39
48
 
40
49
  ```ts
41
50
  const moduleMocker = new ModuleMocker((specifier) =>
@@ -1,5 +1,27 @@
1
+ import { isAbsolute } from 'node:path'
2
+
1
3
  type ModuleExports = Record<string, unknown>
2
4
 
5
+ /**
6
+ * Package names resolve from `baseDir`, so a mock targets the copy your code
7
+ * imports even under isolated installs; anything else is a path relative to it.
8
+ */
9
+ function resolveFrom(baseDir: string) {
10
+ return (specifier: string) => {
11
+ if (isAbsolute(specifier)) return specifier
12
+ if (specifier.startsWith('.')) return Bun.resolveSync(specifier, baseDir)
13
+ try {
14
+ return Bun.resolveSync(specifier, baseDir)
15
+ } catch (error) {
16
+ try {
17
+ return Bun.resolveSync(`./${specifier}`, baseDir)
18
+ } catch {
19
+ throw error
20
+ }
21
+ }
22
+ }
23
+ }
24
+
3
25
  /**
4
26
  * `mock.module` that can be undone. Bun replaces a mocked module for the whole
5
27
  * process and `mock.restore()` does not bring it back
@@ -10,24 +32,31 @@ type ModuleExports = Record<string, unknown>
10
32
  * keep working, and `clear()` re-registers the originals. Call it in
11
33
  * `afterEach` or `afterAll`.
12
34
  *
13
- * Bun resolves specifiers relative to the file that registers the mock, so
14
- * pass absolute paths or package names. A `resolve` function maps your own
15
- * shorthand (an `@/` alias, a source-root-relative path) to absolute paths.
35
+ * Without an argument, pass absolute paths. Pass your source root to resolve
36
+ * against it: package names from there (so mocks hit the same copy your code
37
+ * imports), and `./lib/x` or `lib/x` relative to it. A function maps your own
38
+ * shorthand, such as an `@/` alias, to absolute paths instead.
16
39
  *
17
40
  * @example
18
- * const moduleMocker = new ModuleMocker((specifier) =>
19
- * specifier.startsWith('@/') ? path.resolve(srcDir, specifier.slice(2)) : specifier
20
- * )
21
- * await moduleMocker.mock('@/lib/posthog', () => ({ captureException: mock() }))
41
+ * const moduleMocker = new ModuleMocker(srcDir)
42
+ * await moduleMocker.mock('lib/posthog.ts', () => ({ captureException: mock() }))
43
+ * await moduleMocker.mock('ai', () => ({ generateText: mock() }))
22
44
  * afterAll(() => moduleMocker.clear())
23
45
  */
24
46
  export class ModuleMocker {
25
47
  private restorers: (() => void)[] = []
26
48
 
49
+ private readonly resolve: (specifier: string) => string
50
+
27
51
  constructor(
28
- private readonly resolve: (specifier: string) => string = (specifier) =>
52
+ baseDirOrResolve: string | ((specifier: string) => string) = (specifier) =>
29
53
  specifier
30
- ) {}
54
+ ) {
55
+ this.resolve =
56
+ typeof baseDirOrResolve === 'string'
57
+ ? resolveFrom(baseDirOrResolve)
58
+ : baseDirOrResolve
59
+ }
31
60
 
32
61
  async mock(
33
62
  specifier: string,
@@ -44,7 +73,8 @@ export class ModuleMocker {
44
73
  }
45
74
 
46
75
  clear(): void {
47
- for (const restore of this.restorers) restore()
76
+ // Newest first, so a module mocked twice ends on its real exports.
77
+ for (const restore of this.restorers.toReversed()) restore()
48
78
  this.restorers = []
49
79
  }
50
80
  }
@@ -1,6 +1,11 @@
1
1
  import type { Mock, mock } from 'bun:test'
2
2
 
3
- export type PrismaMethodMock = Mock<(...args: unknown[]) => Promise<unknown>>
3
+ /**
4
+ * `never[]` parameters let `mockImplementation` take a callback that types its
5
+ * arguments; the plain call signature keeps the mock callable with any.
6
+ */
7
+ export type PrismaMethodMock = Mock<(...args: never[]) => Promise<unknown>> &
8
+ ((...args: unknown[]) => Promise<unknown>)
4
9
 
5
10
  /**
6
11
  * A Prisma client where every model delegate and every method is a Bun mock,