mppx 0.3.3 → 0.3.5

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 (148) hide show
  1. package/README.md +0 -52
  2. package/dist/Challenge.d.ts +8 -0
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Challenge.js +20 -4
  5. package/dist/Challenge.js.map +1 -1
  6. package/dist/Errors.d.ts +7 -7
  7. package/dist/Errors.d.ts.map +1 -1
  8. package/dist/Errors.js +7 -7
  9. package/dist/Errors.js.map +1 -1
  10. package/dist/cli.js +280 -119
  11. package/dist/cli.js.map +1 -1
  12. package/dist/internal/env.js +2 -2
  13. package/dist/internal/env.js.map +1 -1
  14. package/dist/server/Mppx.d.ts +2 -0
  15. package/dist/server/Mppx.d.ts.map +1 -1
  16. package/dist/server/Mppx.js +4 -3
  17. package/dist/server/Mppx.js.map +1 -1
  18. package/dist/tempo/client/ChannelOps.d.ts +5 -5
  19. package/dist/tempo/client/ChannelOps.d.ts.map +1 -1
  20. package/dist/tempo/client/ChannelOps.js +3 -3
  21. package/dist/tempo/client/ChannelOps.js.map +1 -1
  22. package/dist/tempo/client/Session.d.ts +2 -2
  23. package/dist/tempo/client/Session.d.ts.map +1 -1
  24. package/dist/tempo/client/Session.js +3 -3
  25. package/dist/tempo/client/Session.js.map +1 -1
  26. package/dist/tempo/client/SessionManager.d.ts +4 -4
  27. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  28. package/dist/tempo/client/SessionManager.js +4 -4
  29. package/dist/tempo/client/SessionManager.js.map +1 -1
  30. package/dist/tempo/index.d.ts +1 -1
  31. package/dist/tempo/index.d.ts.map +1 -1
  32. package/dist/tempo/index.js +1 -1
  33. package/dist/tempo/index.js.map +1 -1
  34. package/dist/tempo/server/Charge.js +1 -1
  35. package/dist/tempo/server/Charge.js.map +1 -1
  36. package/dist/tempo/server/Methods.d.ts +1 -1
  37. package/dist/tempo/server/Methods.d.ts.map +1 -1
  38. package/dist/tempo/server/Session.d.ts +8 -8
  39. package/dist/tempo/server/Session.d.ts.map +1 -1
  40. package/dist/tempo/server/Session.js +24 -24
  41. package/dist/tempo/server/Session.js.map +1 -1
  42. package/dist/tempo/server/index.d.ts +2 -2
  43. package/dist/tempo/server/index.d.ts.map +1 -1
  44. package/dist/tempo/server/index.js +2 -2
  45. package/dist/tempo/server/index.js.map +1 -1
  46. package/dist/tempo/server/internal/transport.d.ts +4 -4
  47. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  48. package/dist/tempo/server/internal/transport.js +3 -3
  49. package/dist/tempo/server/internal/transport.js.map +1 -1
  50. package/dist/tempo/session/Chain.d.ts.map +1 -0
  51. package/dist/tempo/session/Chain.js.map +1 -0
  52. package/dist/tempo/session/Channel.d.ts.map +1 -0
  53. package/dist/tempo/session/Channel.js.map +1 -0
  54. package/dist/tempo/session/ChannelStore.d.ts.map +1 -0
  55. package/dist/tempo/session/ChannelStore.js.map +1 -0
  56. package/dist/tempo/session/Receipt.d.ts +22 -0
  57. package/dist/tempo/session/Receipt.d.ts.map +1 -0
  58. package/dist/tempo/{stream → session}/Receipt.js +6 -6
  59. package/dist/tempo/session/Receipt.js.map +1 -0
  60. package/dist/tempo/{stream → session}/Sse.d.ts +7 -7
  61. package/dist/tempo/session/Sse.d.ts.map +1 -0
  62. package/dist/tempo/{stream → session}/Sse.js +4 -4
  63. package/dist/tempo/session/Sse.js.map +1 -0
  64. package/dist/tempo/{stream → session}/Types.d.ts +4 -4
  65. package/dist/tempo/session/Types.d.ts.map +1 -0
  66. package/dist/tempo/{stream → session}/Types.js.map +1 -1
  67. package/dist/tempo/session/Voucher.d.ts.map +1 -0
  68. package/dist/tempo/session/Voucher.js.map +1 -0
  69. package/dist/tempo/{stream → session}/escrow.abi.d.ts.map +1 -1
  70. package/dist/tempo/session/escrow.abi.js.map +1 -0
  71. package/dist/tempo/session/index.d.ts.map +1 -0
  72. package/dist/tempo/session/index.js.map +1 -0
  73. package/package.json +1 -1
  74. package/src/Challenge.test.ts +201 -11
  75. package/src/Challenge.ts +34 -4
  76. package/src/Errors.test.ts +10 -10
  77. package/src/Errors.ts +7 -7
  78. package/src/Store.test.ts +93 -0
  79. package/src/cli.test.ts +234 -38
  80. package/src/cli.ts +340 -135
  81. package/src/client/Transport.test.ts +4 -4
  82. package/src/internal/env.test.ts +42 -0
  83. package/src/internal/env.ts +2 -2
  84. package/src/middlewares/express.test.ts +1 -1
  85. package/src/middlewares/hono.test.ts +1 -1
  86. package/src/middlewares/nextjs.test.ts +1 -1
  87. package/src/server/Mppx.test.ts +173 -0
  88. package/src/server/Mppx.ts +6 -3
  89. package/src/server/Transport.test.ts +6 -6
  90. package/src/tempo/client/ChannelOps.test.ts +2 -2
  91. package/src/tempo/client/ChannelOps.ts +8 -8
  92. package/src/tempo/client/Session.test.ts +3 -3
  93. package/src/tempo/client/Session.ts +9 -9
  94. package/src/tempo/client/SessionManager.test.ts +3 -3
  95. package/src/tempo/client/SessionManager.ts +9 -9
  96. package/src/tempo/index.ts +1 -1
  97. package/src/tempo/server/Charge.ts +1 -1
  98. package/src/tempo/server/Session.test.ts +61 -9
  99. package/src/tempo/server/Session.ts +47 -47
  100. package/src/tempo/server/Sse.test.ts +3 -3
  101. package/src/tempo/server/index.ts +2 -2
  102. package/src/tempo/server/internal/transport.test.ts +285 -0
  103. package/src/tempo/server/internal/transport.ts +6 -6
  104. package/src/tempo/{stream → session}/Chain.test.ts +1 -1
  105. package/src/tempo/{stream → session}/Receipt.test.ts +16 -12
  106. package/src/tempo/{stream → session}/Receipt.ts +9 -9
  107. package/src/tempo/{stream → session}/Sse.test.ts +5 -5
  108. package/src/tempo/{stream → session}/Sse.ts +11 -11
  109. package/src/tempo/{stream → session}/Types.ts +4 -4
  110. package/dist/tempo/stream/Chain.d.ts.map +0 -1
  111. package/dist/tempo/stream/Chain.js.map +0 -1
  112. package/dist/tempo/stream/Channel.d.ts.map +0 -1
  113. package/dist/tempo/stream/Channel.js.map +0 -1
  114. package/dist/tempo/stream/ChannelStore.d.ts.map +0 -1
  115. package/dist/tempo/stream/ChannelStore.js.map +0 -1
  116. package/dist/tempo/stream/Receipt.d.ts +0 -22
  117. package/dist/tempo/stream/Receipt.d.ts.map +0 -1
  118. package/dist/tempo/stream/Receipt.js.map +0 -1
  119. package/dist/tempo/stream/Sse.d.ts.map +0 -1
  120. package/dist/tempo/stream/Sse.js.map +0 -1
  121. package/dist/tempo/stream/Types.d.ts.map +0 -1
  122. package/dist/tempo/stream/Voucher.d.ts.map +0 -1
  123. package/dist/tempo/stream/Voucher.js.map +0 -1
  124. package/dist/tempo/stream/escrow.abi.js.map +0 -1
  125. package/dist/tempo/stream/index.d.ts.map +0 -1
  126. package/dist/tempo/stream/index.js.map +0 -1
  127. /package/dist/tempo/{stream → session}/Chain.d.ts +0 -0
  128. /package/dist/tempo/{stream → session}/Chain.js +0 -0
  129. /package/dist/tempo/{stream → session}/Channel.d.ts +0 -0
  130. /package/dist/tempo/{stream → session}/Channel.js +0 -0
  131. /package/dist/tempo/{stream → session}/ChannelStore.d.ts +0 -0
  132. /package/dist/tempo/{stream → session}/ChannelStore.js +0 -0
  133. /package/dist/tempo/{stream → session}/Types.js +0 -0
  134. /package/dist/tempo/{stream → session}/Voucher.d.ts +0 -0
  135. /package/dist/tempo/{stream → session}/Voucher.js +0 -0
  136. /package/dist/tempo/{stream → session}/escrow.abi.d.ts +0 -0
  137. /package/dist/tempo/{stream → session}/escrow.abi.js +0 -0
  138. /package/dist/tempo/{stream → session}/index.d.ts +0 -0
  139. /package/dist/tempo/{stream → session}/index.js +0 -0
  140. /package/src/tempo/{stream → session}/Chain.ts +0 -0
  141. /package/src/tempo/{stream → session}/Channel.test.ts +0 -0
  142. /package/src/tempo/{stream → session}/Channel.ts +0 -0
  143. /package/src/tempo/{stream → session}/ChannelStore.test.ts +0 -0
  144. /package/src/tempo/{stream → session}/ChannelStore.ts +0 -0
  145. /package/src/tempo/{stream → session}/Voucher.test.ts +0 -0
  146. /package/src/tempo/{stream → session}/Voucher.ts +0 -0
  147. /package/src/tempo/{stream → session}/escrow.abi.ts +0 -0
  148. /package/src/tempo/{stream → session}/index.ts +0 -0
package/src/Challenge.ts CHANGED
@@ -27,6 +27,8 @@ export const Schema = z.object({
27
27
  intent: z.string(),
28
28
  /** Payment method (e.g., "tempo", "stripe"). */
29
29
  method: z.string(),
30
+ /** Optional server-defined correlation data. Flat string-to-string map; clients MUST NOT modify. */
31
+ opaque: z.optional(z.record(z.string(), z.string())),
30
32
  /** Server realm (e.g., hostname). */
31
33
  realm: z.string(),
32
34
  /** Method-specific request data. */
@@ -111,11 +113,20 @@ export function from<
111
113
  const methods extends readonly Method.Method[] | undefined = undefined,
112
114
  >(parameters: parameters, options?: from.Options<methods>): from.ReturnType<parameters, methods> {
113
115
  void options
114
- const { description, digest, method: methodName, intent, realm, request, secretKey } = parameters
116
+ const {
117
+ description,
118
+ digest,
119
+ meta,
120
+ method: methodName,
121
+ intent,
122
+ realm,
123
+ request,
124
+ secretKey,
125
+ } = parameters
115
126
 
116
127
  const expires = (parameters.expires ?? request.expires) as string
117
128
  const id = secretKey
118
- ? computeId({ ...parameters, expires }, { secretKey })
129
+ ? computeId({ ...parameters, expires, ...(meta && { opaque: meta }) }, { secretKey })
119
130
  : (parameters as { id: string }).id
120
131
 
121
132
  return Schema.parse({
@@ -127,6 +138,7 @@ export function from<
127
138
  ...(description && { description }),
128
139
  ...(digest && { digest }),
129
140
  ...(expires && { expires }),
141
+ ...(meta && { opaque: meta }),
130
142
  }) as from.ReturnType<parameters, methods>
131
143
  }
132
144
 
@@ -153,6 +165,8 @@ export declare namespace from {
153
165
  expires?: string | undefined
154
166
  /** Intent type (e.g., "charge", "session"). */
155
167
  intent: string
168
+ /** Optional server-defined correlation data (serialized as `opaque` on the challenge). Flat string-to-string map; clients MUST NOT modify. */
169
+ meta?: Record<string, string> | undefined
156
170
  /** Payment method (e.g., "tempo", "stripe"). */
157
171
  method: string
158
172
  /** Server realm (e.g., hostname). */
@@ -206,7 +220,7 @@ export function fromMethod<const method extends Method.Method>(
206
220
  parameters: fromMethod.Parameters<method>,
207
221
  ): fromMethod.ReturnType<method> {
208
222
  const { name: methodName, intent } = method
209
- const { description, digest, expires, id, realm, secretKey } = parameters
223
+ const { description, digest, expires, id, meta, realm, secretKey } = parameters
210
224
 
211
225
  const request = PaymentRequest.fromMethod(method, parameters.request)
212
226
 
@@ -219,6 +233,7 @@ export function fromMethod<const method extends Method.Method>(
219
233
  description,
220
234
  digest,
221
235
  expires,
236
+ meta,
222
237
  } as from.Parameters) as fromMethod.ReturnType<method>
223
238
  }
224
239
 
@@ -239,6 +254,8 @@ export declare namespace fromMethod {
239
254
  digest?: string | undefined
240
255
  /** Optional expiration timestamp (ISO 8601). */
241
256
  expires?: string | undefined
257
+ /** Optional server-defined correlation data (serialized as `opaque` on the challenge). Flat string-to-string map; clients MUST NOT modify. */
258
+ meta?: Record<string, string> | undefined
242
259
  /** Server realm (e.g., hostname). */
243
260
  realm: string
244
261
  /** Method-specific request data. */
@@ -274,6 +291,8 @@ export function serialize(challenge: Challenge): string {
274
291
  if (challenge.description !== undefined) parts.push(`description="${challenge.description}"`)
275
292
  if (challenge.digest !== undefined) parts.push(`digest="${challenge.digest}"`)
276
293
  if (challenge.expires !== undefined) parts.push(`expires="${challenge.expires}"`)
294
+ if (challenge.opaque !== undefined)
295
+ parts.push(`opaque="${PaymentRequest.serialize(challenge.opaque)}"`)
277
296
 
278
297
  return `Payment ${parts.join(', ')}`
279
298
  }
@@ -314,13 +333,14 @@ export function deserialize<const methods extends readonly Method.Method[] | und
314
333
  }
315
334
  }
316
335
 
317
- const { request, ...rest } = result
336
+ const { request, opaque, ...rest } = result
318
337
  if (!request) throw new Error('Missing request parameter.')
319
338
 
320
339
  return from(
321
340
  {
322
341
  ...rest,
323
342
  request: PaymentRequest.deserialize(request),
343
+ ...(opaque && { meta: PaymentRequest.deserialize(opaque) as Record<string, string> }),
324
344
  } as from.Parameters,
325
345
  options,
326
346
  )
@@ -404,8 +424,17 @@ export declare namespace verify {
404
424
  }
405
425
  }
406
426
 
427
+ /** Alias for `challenge.opaque`. Extracts server-defined correlation data from a challenge. */
428
+ export function meta(challenge: Challenge): Record<string, string> | undefined {
429
+ return challenge.opaque
430
+ }
431
+
407
432
  /** @internal Computes HMAC-SHA256 challenge ID from parameters. */
408
433
  function computeId(challenge: Omit<Challenge, 'id'>, options: { secretKey: string }): string {
434
+ // Each field occupies a fixed positional slot joined by '|'. Optional fields
435
+ // use an empty string when absent so the slot count is stable — this avoids
436
+ // ambiguity between e.g. (expires set, no digest) vs (no expires, digest set)
437
+ // and means adding a new optional field changes all HMACs exactly once.
409
438
  const input = [
410
439
  challenge.realm,
411
440
  challenge.method,
@@ -413,6 +442,7 @@ function computeId(challenge: Omit<Challenge, 'id'>, options: { secretKey: strin
413
442
  PaymentRequest.serialize(challenge.request),
414
443
  challenge.expires ?? '',
415
444
  challenge.digest ?? '',
445
+ challenge.opaque ? PaymentRequest.serialize(challenge.opaque) : '',
416
446
  ].join('|')
417
447
 
418
448
  const key = Bytes.fromString(options.secretKey)
@@ -305,7 +305,7 @@ describe('InsufficientBalanceError', () => {
305
305
  "message": "Insufficient balance.",
306
306
  "name": "InsufficientBalanceError",
307
307
  "status": 402,
308
- "type": "https://paymentauth.org/problems/stream/insufficient-balance",
308
+ "type": "https://paymentauth.org/problems/session/insufficient-balance",
309
309
  }
310
310
  `)
311
311
  })
@@ -318,7 +318,7 @@ describe('InsufficientBalanceError', () => {
318
318
  "message": "Insufficient balance: requested 500, available 100.",
319
319
  "name": "InsufficientBalanceError",
320
320
  "status": 402,
321
- "type": "https://paymentauth.org/problems/stream/insufficient-balance",
321
+ "type": "https://paymentauth.org/problems/session/insufficient-balance",
322
322
  }
323
323
  `)
324
324
  })
@@ -331,7 +331,7 @@ describe('InvalidSignatureError', () => {
331
331
  "message": "Invalid signature.",
332
332
  "name": "InvalidSignatureError",
333
333
  "status": 402,
334
- "type": "https://paymentauth.org/problems/stream/invalid-signature",
334
+ "type": "https://paymentauth.org/problems/session/invalid-signature",
335
335
  }
336
336
  `)
337
337
  })
@@ -344,7 +344,7 @@ describe('InvalidSignatureError', () => {
344
344
  "message": "Invalid signature: ECDSA recovery failed.",
345
345
  "name": "InvalidSignatureError",
346
346
  "status": 402,
347
- "type": "https://paymentauth.org/problems/stream/invalid-signature",
347
+ "type": "https://paymentauth.org/problems/session/invalid-signature",
348
348
  }
349
349
  `)
350
350
  })
@@ -357,7 +357,7 @@ describe('SignerMismatchError', () => {
357
357
  "message": "Signer is not authorized for this channel.",
358
358
  "name": "SignerMismatchError",
359
359
  "status": 402,
360
- "type": "https://paymentauth.org/problems/stream/signer-mismatch",
360
+ "type": "https://paymentauth.org/problems/session/signer-mismatch",
361
361
  }
362
362
  `)
363
363
  })
@@ -370,7 +370,7 @@ describe('AmountExceedsDepositError', () => {
370
370
  "message": "Voucher amount exceeds channel deposit.",
371
371
  "name": "AmountExceedsDepositError",
372
372
  "status": 402,
373
- "type": "https://paymentauth.org/problems/stream/amount-exceeds-deposit",
373
+ "type": "https://paymentauth.org/problems/session/amount-exceeds-deposit",
374
374
  }
375
375
  `)
376
376
  })
@@ -383,7 +383,7 @@ describe('DeltaTooSmallError', () => {
383
383
  "message": "Amount increase below minimum voucher delta.",
384
384
  "name": "DeltaTooSmallError",
385
385
  "status": 402,
386
- "type": "https://paymentauth.org/problems/stream/delta-too-small",
386
+ "type": "https://paymentauth.org/problems/session/delta-too-small",
387
387
  }
388
388
  `)
389
389
  })
@@ -396,7 +396,7 @@ describe('ChannelNotFoundError', () => {
396
396
  "message": "No channel with this ID exists.",
397
397
  "name": "ChannelNotFoundError",
398
398
  "status": 410,
399
- "type": "https://paymentauth.org/problems/stream/channel-not-found",
399
+ "type": "https://paymentauth.org/problems/session/channel-not-found",
400
400
  }
401
401
  `)
402
402
  })
@@ -409,7 +409,7 @@ describe('ChannelClosedError', () => {
409
409
  "message": "Channel is closed.",
410
410
  "name": "ChannelClosedError",
411
411
  "status": 410,
412
- "type": "https://paymentauth.org/problems/stream/channel-finalized",
412
+ "type": "https://paymentauth.org/problems/session/channel-finalized",
413
413
  }
414
414
  `)
415
415
  })
@@ -422,7 +422,7 @@ describe('ChannelClosedError', () => {
422
422
  "message": "Channel closed: channel is finalized on-chain.",
423
423
  "name": "ChannelClosedError",
424
424
  "status": 410,
425
- "type": "https://paymentauth.org/problems/stream/channel-finalized",
425
+ "type": "https://paymentauth.org/problems/session/channel-finalized",
426
426
  }
427
427
  `)
428
428
  })
package/src/Errors.ts CHANGED
@@ -270,7 +270,7 @@ export class InsufficientBalanceError extends PaymentError {
270
270
  override readonly name = 'InsufficientBalanceError'
271
271
  readonly title = 'Insufficient Balance'
272
272
  override readonly status = 402
273
- readonly type = 'https://paymentauth.org/problems/stream/insufficient-balance'
273
+ readonly type = 'https://paymentauth.org/problems/session/insufficient-balance'
274
274
 
275
275
  constructor(options: InsufficientBalanceError.Options = {}) {
276
276
  const { reason } = options
@@ -292,7 +292,7 @@ export class InvalidSignatureError extends PaymentError {
292
292
  override readonly name = 'InvalidSignatureError'
293
293
  readonly title = 'Invalid Signature'
294
294
  override readonly status = 402
295
- readonly type = 'https://paymentauth.org/problems/stream/invalid-signature'
295
+ readonly type = 'https://paymentauth.org/problems/session/invalid-signature'
296
296
 
297
297
  constructor(options: InvalidSignatureError.Options = {}) {
298
298
  const { reason } = options
@@ -313,7 +313,7 @@ export class SignerMismatchError extends PaymentError {
313
313
  override readonly name = 'SignerMismatchError'
314
314
  readonly title = 'Signer Mismatch'
315
315
  override readonly status = 402
316
- readonly type = 'https://paymentauth.org/problems/stream/signer-mismatch'
316
+ readonly type = 'https://paymentauth.org/problems/session/signer-mismatch'
317
317
 
318
318
  constructor(options: SignerMismatchError.Options = {}) {
319
319
  const { reason } = options
@@ -334,7 +334,7 @@ export class AmountExceedsDepositError extends PaymentError {
334
334
  override readonly name = 'AmountExceedsDepositError'
335
335
  readonly title = 'Amount Exceeds Deposit'
336
336
  override readonly status = 402
337
- readonly type = 'https://paymentauth.org/problems/stream/amount-exceeds-deposit'
337
+ readonly type = 'https://paymentauth.org/problems/session/amount-exceeds-deposit'
338
338
 
339
339
  constructor(options: AmountExceedsDepositError.Options = {}) {
340
340
  const { reason } = options
@@ -355,7 +355,7 @@ export class DeltaTooSmallError extends PaymentError {
355
355
  override readonly name = 'DeltaTooSmallError'
356
356
  readonly title = 'Delta Too Small'
357
357
  override readonly status = 402
358
- readonly type = 'https://paymentauth.org/problems/stream/delta-too-small'
358
+ readonly type = 'https://paymentauth.org/problems/session/delta-too-small'
359
359
 
360
360
  constructor(options: DeltaTooSmallError.Options = {}) {
361
361
  const { reason } = options
@@ -376,7 +376,7 @@ export class ChannelNotFoundError extends PaymentError {
376
376
  override readonly name = 'ChannelNotFoundError'
377
377
  readonly title = 'Channel Not Found'
378
378
  override readonly status = 410
379
- readonly type = 'https://paymentauth.org/problems/stream/channel-not-found'
379
+ readonly type = 'https://paymentauth.org/problems/session/channel-not-found'
380
380
 
381
381
  constructor(options: ChannelNotFoundError.Options = {}) {
382
382
  const { reason } = options
@@ -397,7 +397,7 @@ export class ChannelClosedError extends PaymentError {
397
397
  override readonly name = 'ChannelClosedError'
398
398
  readonly title = 'Channel Closed'
399
399
  override readonly status = 410
400
- readonly type = 'https://paymentauth.org/problems/stream/channel-finalized'
400
+ readonly type = 'https://paymentauth.org/problems/session/channel-finalized'
401
401
 
402
402
  constructor(options: ChannelClosedError.Options = {}) {
403
403
  const { reason } = options
@@ -0,0 +1,93 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import * as Store from './Store.js'
3
+
4
+ const nested = {
5
+ name: 'alice',
6
+ scores: [1, 2, 3],
7
+ meta: { active: true, tags: ['a', 'b'] },
8
+ }
9
+
10
+ function fakeKv(): Store.cloudflare.Parameters {
11
+ const map = new Map<string, string>()
12
+ return {
13
+ async get(key) {
14
+ return map.get(key) ?? null
15
+ },
16
+ async put(key, value) {
17
+ map.set(key, value)
18
+ },
19
+ async delete(key) {
20
+ map.delete(key)
21
+ },
22
+ }
23
+ }
24
+
25
+ describe.each([
26
+ { label: 'memory', create: () => Store.memory() },
27
+ { label: 'cloudflare', create: () => Store.cloudflare(fakeKv()) },
28
+ {
29
+ label: 'upstash',
30
+ create: () => {
31
+ const kv = fakeKv()
32
+ return Store.upstash({
33
+ get: kv.get,
34
+ set: (key, value) => kv.put(key, value as string),
35
+ del: (key) => kv.delete(key),
36
+ })
37
+ },
38
+ },
39
+ ])('$label', ({ create }) => {
40
+ test('roundtrip', async () => {
41
+ const store = create()
42
+ await store.put('k', nested)
43
+ expect(await store.get('k')).toEqual(nested)
44
+ })
45
+
46
+ test('get missing key returns null', async () => {
47
+ const store = create()
48
+ expect(await store.get('missing')).toBeNull()
49
+ })
50
+
51
+ test('delete removes key', async () => {
52
+ const store = create()
53
+ await store.put('k', 'value')
54
+ await store.delete('k')
55
+ expect(await store.get('k')).toBeNull()
56
+ })
57
+
58
+ test('put overwrites existing value', async () => {
59
+ const store = create()
60
+ await store.put('k', 'first')
61
+ await store.put('k', 'second')
62
+ expect(await store.get('k')).toBe('second')
63
+ })
64
+ })
65
+
66
+ describe('json roundtrip behavior', () => {
67
+ test('memory json-roundtrips nested objects', async () => {
68
+ const store = Store.memory()
69
+ const value = { a: [1, { b: 'c' }], d: null }
70
+ await store.put('k', value)
71
+ expect(await store.get('k')).toEqual(value)
72
+ })
73
+
74
+ test('cloudflare json-roundtrips nested objects', async () => {
75
+ const store = Store.cloudflare(fakeKv())
76
+ const value = { a: [1, { b: 'c' }], d: null }
77
+ await store.put('k', value)
78
+ expect(await store.get('k')).toEqual(value)
79
+ })
80
+
81
+ test('upstash passes values through without json serialization', async () => {
82
+ const kv = fakeKv()
83
+ const store = Store.upstash({
84
+ get: kv.get,
85
+ set: (key, value) => kv.put(key, value as string),
86
+ del: (key) => kv.delete(key),
87
+ })
88
+ const value = { a: 1 }
89
+ await store.put('k', value)
90
+ // upstash store does not JSON-serialize; the fake map holds the original reference
91
+ expect(await kv.get('k')).toBe(value)
92
+ })
93
+ })