mppx 0.6.19 → 0.6.20

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/CHANGELOG.md +7 -0
  2. package/dist/Challenge.d.ts +2 -2
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Challenge.js +1 -1
  5. package/dist/Challenge.js.map +1 -1
  6. package/dist/Method.d.ts +34 -0
  7. package/dist/Method.d.ts.map +1 -1
  8. package/dist/Method.js +3 -1
  9. package/dist/Method.js.map +1 -1
  10. package/dist/Receipt.d.ts +1 -0
  11. package/dist/Receipt.d.ts.map +1 -1
  12. package/dist/Receipt.js +2 -0
  13. package/dist/Receipt.js.map +1 -1
  14. package/dist/client/Methods.d.ts +1 -0
  15. package/dist/client/Methods.d.ts.map +1 -1
  16. package/dist/client/Methods.js +1 -0
  17. package/dist/client/Methods.js.map +1 -1
  18. package/dist/middlewares/elysia.d.ts.map +1 -1
  19. package/dist/middlewares/elysia.js +14 -0
  20. package/dist/middlewares/elysia.js.map +1 -1
  21. package/dist/middlewares/express.d.ts.map +1 -1
  22. package/dist/middlewares/express.js +1 -2
  23. package/dist/middlewares/express.js.map +1 -1
  24. package/dist/middlewares/hono.d.ts.map +1 -1
  25. package/dist/middlewares/hono.js +14 -0
  26. package/dist/middlewares/hono.js.map +1 -1
  27. package/dist/middlewares/nextjs.d.ts.map +1 -1
  28. package/dist/middlewares/nextjs.js +14 -0
  29. package/dist/middlewares/nextjs.js.map +1 -1
  30. package/dist/proxy/Proxy.d.ts.map +1 -1
  31. package/dist/proxy/Proxy.js +2 -2
  32. package/dist/proxy/Proxy.js.map +1 -1
  33. package/dist/proxy/Service.d.ts.map +1 -1
  34. package/dist/proxy/Service.js +1 -1
  35. package/dist/proxy/Service.js.map +1 -1
  36. package/dist/server/Mppx.d.ts +15 -3
  37. package/dist/server/Mppx.d.ts.map +1 -1
  38. package/dist/server/Mppx.js +190 -40
  39. package/dist/server/Mppx.js.map +1 -1
  40. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  41. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  42. package/dist/stripe/server/internal/html.gen.js +1 -1
  43. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  44. package/dist/tempo/Methods.d.ts +96 -0
  45. package/dist/tempo/Methods.d.ts.map +1 -1
  46. package/dist/tempo/Methods.js +97 -0
  47. package/dist/tempo/Methods.js.map +1 -1
  48. package/dist/tempo/client/Methods.d.ts +3 -0
  49. package/dist/tempo/client/Methods.d.ts.map +1 -1
  50. package/dist/tempo/client/Methods.js +3 -0
  51. package/dist/tempo/client/Methods.js.map +1 -1
  52. package/dist/tempo/client/Subscription.d.ts +114 -0
  53. package/dist/tempo/client/Subscription.d.ts.map +1 -0
  54. package/dist/tempo/client/Subscription.js +100 -0
  55. package/dist/tempo/client/Subscription.js.map +1 -0
  56. package/dist/tempo/client/index.d.ts +1 -0
  57. package/dist/tempo/client/index.d.ts.map +1 -1
  58. package/dist/tempo/client/index.js +1 -0
  59. package/dist/tempo/client/index.js.map +1 -1
  60. package/dist/tempo/index.d.ts +1 -0
  61. package/dist/tempo/index.d.ts.map +1 -1
  62. package/dist/tempo/index.js +1 -0
  63. package/dist/tempo/index.js.map +1 -1
  64. package/dist/tempo/server/Methods.d.ts +5 -0
  65. package/dist/tempo/server/Methods.d.ts.map +1 -1
  66. package/dist/tempo/server/Methods.js +5 -0
  67. package/dist/tempo/server/Methods.js.map +1 -1
  68. package/dist/tempo/server/Subscription.d.ts +221 -0
  69. package/dist/tempo/server/Subscription.d.ts.map +1 -0
  70. package/dist/tempo/server/Subscription.js +637 -0
  71. package/dist/tempo/server/Subscription.js.map +1 -0
  72. package/dist/tempo/server/index.d.ts +1 -0
  73. package/dist/tempo/server/index.d.ts.map +1 -1
  74. package/dist/tempo/server/index.js +1 -0
  75. package/dist/tempo/server/index.js.map +1 -1
  76. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  77. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  78. package/dist/tempo/server/internal/html.gen.js +1 -1
  79. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  80. package/dist/tempo/subscription/KeyAuthorization.d.ts +282 -0
  81. package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -0
  82. package/dist/tempo/subscription/KeyAuthorization.js +297 -0
  83. package/dist/tempo/subscription/KeyAuthorization.js.map +1 -0
  84. package/dist/tempo/subscription/Receipt.d.ts +10 -0
  85. package/dist/tempo/subscription/Receipt.d.ts.map +1 -0
  86. package/dist/tempo/subscription/Receipt.js +16 -0
  87. package/dist/tempo/subscription/Receipt.js.map +1 -0
  88. package/dist/tempo/subscription/Store.d.ts +99 -0
  89. package/dist/tempo/subscription/Store.d.ts.map +1 -0
  90. package/dist/tempo/subscription/Store.js +292 -0
  91. package/dist/tempo/subscription/Store.js.map +1 -0
  92. package/dist/tempo/subscription/Types.d.ts +65 -0
  93. package/dist/tempo/subscription/Types.d.ts.map +1 -0
  94. package/dist/tempo/subscription/Types.js +2 -0
  95. package/dist/tempo/subscription/Types.js.map +1 -0
  96. package/dist/tempo/subscription/index.d.ts +6 -0
  97. package/dist/tempo/subscription/index.d.ts.map +1 -0
  98. package/dist/tempo/subscription/index.js +4 -0
  99. package/dist/tempo/subscription/index.js.map +1 -0
  100. package/dist/zod.d.ts +7 -0
  101. package/dist/zod.d.ts.map +1 -1
  102. package/dist/zod.js +18 -0
  103. package/dist/zod.js.map +1 -1
  104. package/package.json +3 -3
  105. package/src/Challenge.test.ts +13 -0
  106. package/src/Challenge.ts +3 -3
  107. package/src/Method.ts +46 -1
  108. package/src/Receipt.ts +2 -0
  109. package/src/client/Methods.ts +1 -0
  110. package/src/middlewares/elysia.test.ts +31 -1
  111. package/src/middlewares/elysia.ts +13 -0
  112. package/src/middlewares/express.ts +1 -5
  113. package/src/middlewares/hono.test.ts +30 -1
  114. package/src/middlewares/hono.ts +13 -0
  115. package/src/middlewares/nextjs.test.ts +28 -1
  116. package/src/middlewares/nextjs.ts +13 -0
  117. package/src/proxy/Proxy.ts +2 -5
  118. package/src/proxy/Service.test.ts +34 -0
  119. package/src/proxy/Service.ts +7 -0
  120. package/src/server/Mppx.authorize.test.ts +210 -0
  121. package/src/server/Mppx.test-d.ts +23 -1
  122. package/src/server/Mppx.test.ts +73 -3
  123. package/src/server/Mppx.ts +291 -58
  124. package/src/stripe/server/internal/html/package.json +1 -1
  125. package/src/stripe/server/internal/html.gen.ts +1 -1
  126. package/src/tempo/Methods.test.ts +131 -0
  127. package/src/tempo/Methods.ts +136 -0
  128. package/src/tempo/Subscription.integration.test.ts +591 -0
  129. package/src/tempo/client/Methods.ts +3 -0
  130. package/src/tempo/client/Subscription.test.ts +131 -0
  131. package/src/tempo/client/Subscription.ts +155 -0
  132. package/src/tempo/client/index.ts +1 -0
  133. package/src/tempo/index.ts +1 -0
  134. package/src/tempo/server/Methods.ts +5 -0
  135. package/src/tempo/server/Subscription.test.ts +1410 -0
  136. package/src/tempo/server/Subscription.ts +1014 -0
  137. package/src/tempo/server/index.ts +1 -0
  138. package/src/tempo/server/internal/html/package.json +1 -1
  139. package/src/tempo/server/internal/html.gen.ts +1 -1
  140. package/src/tempo/subscription/KeyAuthorization.test.ts +204 -0
  141. package/src/tempo/subscription/KeyAuthorization.ts +394 -0
  142. package/src/tempo/subscription/Receipt.ts +28 -0
  143. package/src/tempo/subscription/Store.test.ts +554 -0
  144. package/src/tempo/subscription/Store.ts +431 -0
  145. package/src/tempo/subscription/Types.ts +68 -0
  146. package/src/tempo/subscription/index.ts +23 -0
  147. package/src/zod.test.ts +23 -1
  148. package/src/zod.ts +24 -0
@@ -0,0 +1,554 @@
1
+ import { describe, expect, test } from 'vp/test'
2
+
3
+ import * as Store from '../../Store.js'
4
+ import { fromStore } from './Store.js'
5
+ import type { SubscriptionRecord } from './Types.js'
6
+
7
+ const subscriptionId = 'sub_123'
8
+
9
+ function createRecord(overrides: Partial<SubscriptionRecord> = {}): SubscriptionRecord {
10
+ return {
11
+ amount: '10000000',
12
+ billingAnchor: '2025-01-01T00:00:00.000Z',
13
+ chainId: 4217,
14
+ currency: '0x20c0000000000000000000000000000000000001',
15
+ lastChargedPeriod: 0,
16
+ lookupKey: 'user-1:plan:pro',
17
+ periodCount: '1',
18
+ periodUnit: 'day',
19
+ recipient: '0x1234567890abcdef1234567890abcdef12345678',
20
+ reference: `0x${'a'.repeat(64)}`,
21
+ subscriptionExpires: '2026-01-01T00:00:00.000Z',
22
+ subscriptionId,
23
+ timestamp: '2025-01-01T00:00:00.000Z',
24
+ ...overrides,
25
+ }
26
+ }
27
+
28
+ describe('tempo subscription store', () => {
29
+ test('rejects a replayed activation challenge', async () => {
30
+ const store = fromStore(Store.memory())
31
+
32
+ const first = await store.activate({
33
+ challengeId: 'challenge-1',
34
+ create: async () => ({ subscription: createRecord() }),
35
+ lookupKey: 'user-1:plan:pro',
36
+ })
37
+ expect(first.status).toBe('activated')
38
+
39
+ expect(
40
+ await store.activate({
41
+ challengeId: 'challenge-1',
42
+ create: async () => ({ subscription: createRecord() }),
43
+ lookupKey: 'user-1:plan:pro',
44
+ }),
45
+ ).toEqual({ status: 'replayed' })
46
+ })
47
+
48
+ test('tracks a resolved lookup key activation until committed', async () => {
49
+ const store = fromStore(Store.memory())
50
+ let finishActivation!: () => void
51
+ const pendingActivation = new Promise<void>((resolve) => {
52
+ finishActivation = resolve
53
+ })
54
+
55
+ const first = store.activate({
56
+ challengeId: 'challenge-1',
57
+ create: async () => {
58
+ await pendingActivation
59
+ return { subscription: createRecord() }
60
+ },
61
+ lookupKey: 'user-1:plan:pro',
62
+ })
63
+ expect(
64
+ await store.activate({
65
+ challengeId: 'challenge-2',
66
+ create: async () => ({ subscription: createRecord() }),
67
+ lookupKey: 'user-1:plan:pro',
68
+ }),
69
+ ).toEqual({ status: 'inFlight' })
70
+
71
+ finishActivation()
72
+ expect((await first).status).toBe('activated')
73
+ expect((await store.getByKey('user-1:plan:pro'))?.subscriptionId).toBe(subscriptionId)
74
+ })
75
+
76
+ test('replaces a stale activation marker after the timeout', async () => {
77
+ const store = fromStore(Store.memory(), { activationTimeoutMs: 0 })
78
+ let finishActivation!: () => void
79
+ const pendingActivation = new Promise<void>((resolve) => {
80
+ finishActivation = resolve
81
+ })
82
+
83
+ const first = store.activate({
84
+ challengeId: 'challenge-1',
85
+ create: async () => {
86
+ await pendingActivation
87
+ return { subscription: createRecord({ reference: `0x${'b'.repeat(64)}` }) }
88
+ },
89
+ lookupKey: 'user-1:plan:pro',
90
+ })
91
+
92
+ const second = await store.activate({
93
+ challengeId: 'challenge-2',
94
+ create: async () => ({ subscription: createRecord() }),
95
+ lookupKey: 'user-1:plan:pro',
96
+ })
97
+ expect(second.status).toBe('activated')
98
+
99
+ finishActivation()
100
+ expect(await first).toEqual({ status: 'claimMismatch' })
101
+ })
102
+
103
+ test('does not replace an activation marker that is committing', async () => {
104
+ const rawStore = Store.memory()
105
+ let store!: ReturnType<typeof fromStore>
106
+ let nestedStatus: string | undefined
107
+ let sawCommit = false
108
+ const wrapped = {
109
+ ...rawStore,
110
+ async update(key, change) {
111
+ const result = await rawStore.update(key, change)
112
+ const marker = await rawStore.get(key)
113
+ if (
114
+ !sawCommit &&
115
+ key === 'tempo:subscription:activation:user-1:plan:pro' &&
116
+ marker &&
117
+ typeof marker === 'object' &&
118
+ 'committingAt' in marker
119
+ ) {
120
+ sawCommit = true
121
+ const nested = await store.activate({
122
+ challengeId: 'challenge-2',
123
+ create: async () => ({ subscription: createRecord({ subscriptionId: 'sub_2' }) }),
124
+ lookupKey: 'user-1:plan:pro',
125
+ })
126
+ nestedStatus = nested.status
127
+ }
128
+ return result
129
+ },
130
+ } satisfies Store.AtomicStore<Record<string, unknown>>
131
+ store = fromStore(wrapped, { activationTimeoutMs: 0 })
132
+
133
+ const activated = await store.activate({
134
+ challengeId: 'challenge-1',
135
+ create: async () => ({ subscription: createRecord() }),
136
+ lookupKey: 'user-1:plan:pro',
137
+ })
138
+
139
+ expect(activated.status).toBe('activated')
140
+ expect(nestedStatus).toBe('inFlight')
141
+ expect((await store.getByKey('user-1:plan:pro'))?.subscriptionId).toBe(subscriptionId)
142
+ })
143
+
144
+ test('keeps a superseded activation record for reconciliation', async () => {
145
+ const store = fromStore(Store.memory(), { activationTimeoutMs: 0 })
146
+ let finishActivation!: () => void
147
+ const pendingActivation = new Promise<void>((resolve) => {
148
+ finishActivation = resolve
149
+ })
150
+
151
+ const first = store.activate({
152
+ challengeId: 'challenge-1',
153
+ create: async () => {
154
+ await pendingActivation
155
+ return {
156
+ subscription: createRecord({
157
+ reference: `0x${'b'.repeat(64)}`,
158
+ subscriptionId: 'sub_late',
159
+ }),
160
+ }
161
+ },
162
+ lookupKey: 'user-1:plan:pro',
163
+ })
164
+
165
+ const second = await store.activate({
166
+ challengeId: 'challenge-2',
167
+ create: async () => ({
168
+ subscription: createRecord({ reference: `0x${'c'.repeat(64)}`, subscriptionId: 'sub_2' }),
169
+ }),
170
+ lookupKey: 'user-1:plan:pro',
171
+ })
172
+ expect(second.status).toBe('activated')
173
+
174
+ finishActivation()
175
+ expect(await first).toEqual({ status: 'claimMismatch' })
176
+ expect((await store.get('sub_late'))?.canceledAt).toBeTruthy()
177
+ expect((await store.getByKey('user-1:plan:pro'))?.subscriptionId).toBe('sub_2')
178
+ })
179
+
180
+ test('rechecks the lookup key after claiming activation', async () => {
181
+ const rawStore = Store.memory()
182
+ const seeded = fromStore(rawStore)
183
+ const store = fromStore({
184
+ ...rawStore,
185
+ async update(key, change) {
186
+ const result = await rawStore.update(key, change)
187
+ if (key === 'tempo:subscription:activation:user-1:plan:pro') {
188
+ await seeded.put(createRecord())
189
+ }
190
+ return result
191
+ },
192
+ })
193
+ let createCalled = false
194
+
195
+ const activated = await store.activate({
196
+ challengeId: 'challenge-1',
197
+ create: async () => {
198
+ createCalled = true
199
+ return { subscription: createRecord({ subscriptionId: 'sub_new' }) }
200
+ },
201
+ isReusable: () => true,
202
+ lookupKey: 'user-1:plan:pro',
203
+ })
204
+
205
+ expect(activated).toEqual({ status: 'existing', subscription: createRecord() })
206
+ expect(createCalled).toBe(false)
207
+ })
208
+
209
+ test('clears the activation marker when creation fails', async () => {
210
+ const store = fromStore(Store.memory())
211
+
212
+ await expect(
213
+ store.activate({
214
+ challengeId: 'challenge-1',
215
+ create: async () => {
216
+ throw new Error('activation failed')
217
+ },
218
+ lookupKey: 'user-1:plan:pro',
219
+ }),
220
+ ).rejects.toThrow('activation failed')
221
+
222
+ const retried = await store.activate({
223
+ challengeId: 'challenge-2',
224
+ create: async () => ({ subscription: createRecord() }),
225
+ lookupKey: 'user-1:plan:pro',
226
+ })
227
+ expect(retried.status).toBe('activated')
228
+ })
229
+
230
+ test('tracks an in-flight renewal and commits it once', async () => {
231
+ const store = fromStore(Store.memory())
232
+ await store.put(createRecord())
233
+
234
+ const renewed = await store.renew({
235
+ inFlightReference: '0xrenewal',
236
+ periodIndex: 1,
237
+ renew: async ({ inFlightReference, subscription }) => {
238
+ expect(inFlightReference).toBe('0xrenewal')
239
+ return {
240
+ subscription: {
241
+ ...subscription,
242
+ lastChargedPeriod: 1,
243
+ reference: `0x${'b'.repeat(64)}`,
244
+ },
245
+ }
246
+ },
247
+ subscriptionId,
248
+ })
249
+ expect(renewed.status).toBe('renewed')
250
+ expect((await store.get(subscriptionId))?.inFlightPeriod).toBe(undefined)
251
+
252
+ expect(
253
+ await store.renew({
254
+ inFlightReference: '0xmissing',
255
+ periodIndex: 2,
256
+ renew: async () => ({ subscription: createRecord() }),
257
+ subscriptionId: 'sub_missing',
258
+ }),
259
+ ).toEqual({ status: 'missing' })
260
+
261
+ const committed = await store.get(subscriptionId)
262
+ expect(committed?.lastChargedPeriod).toBe(1)
263
+ expect(committed?.inFlightPeriod).toBe(undefined)
264
+
265
+ const charged = await store.renew({
266
+ inFlightReference: '0xrenewal',
267
+ periodIndex: 1,
268
+ renew: async () => ({ subscription: createRecord() }),
269
+ subscriptionId,
270
+ })
271
+ expect(charged.status).toBe('charged')
272
+ })
273
+
274
+ test('returns in-flight for a duplicate renewal period', async () => {
275
+ const store = fromStore(Store.memory())
276
+ await store.put(createRecord())
277
+ let finishRenewal!: () => void
278
+ const pendingRenewal = new Promise<void>((resolve) => {
279
+ finishRenewal = resolve
280
+ })
281
+
282
+ const first = store.renew({
283
+ inFlightReference: '0xrenewal',
284
+ periodIndex: 1,
285
+ renew: async ({ subscription }) => {
286
+ await pendingRenewal
287
+ return { subscription }
288
+ },
289
+ subscriptionId,
290
+ })
291
+
292
+ const duplicate = await store.renew({
293
+ inFlightReference: '0xrenewal',
294
+ periodIndex: 1,
295
+ renew: async ({ subscription }) => ({ subscription }),
296
+ subscriptionId,
297
+ })
298
+ expect(duplicate.status).toBe('inFlight')
299
+
300
+ finishRenewal()
301
+ expect((await first).status).toBe('renewed')
302
+ })
303
+
304
+ test('returns in-flight when any non-stale renewal is active', async () => {
305
+ const store = fromStore(Store.memory())
306
+ await store.put(
307
+ createRecord({ inFlightPeriod: 1, inFlightStartedAt: new Date().toISOString() }),
308
+ )
309
+ let renewCalled = false
310
+
311
+ const renewal = await store.renew({
312
+ inFlightReference: '0xrenewal',
313
+ periodIndex: 2,
314
+ renew: async ({ subscription }) => {
315
+ renewCalled = true
316
+ return { subscription }
317
+ },
318
+ subscriptionId,
319
+ })
320
+
321
+ expect(renewal.status).toBe('inFlight')
322
+ expect(renewCalled).toBe(false)
323
+ })
324
+
325
+ test('replaces a stale in-flight renewal after the timeout', async () => {
326
+ const store = fromStore(Store.memory(), { renewalTimeoutMs: 0 })
327
+ await store.put(createRecord())
328
+ let finishRenewal!: () => void
329
+ const pendingRenewal = new Promise<void>((resolve) => {
330
+ finishRenewal = resolve
331
+ })
332
+
333
+ const first = store.renew({
334
+ inFlightReference: '0xfirst',
335
+ periodIndex: 1,
336
+ renew: async ({ subscription }) => {
337
+ await pendingRenewal
338
+ return { subscription }
339
+ },
340
+ subscriptionId,
341
+ })
342
+
343
+ const second = await store.renew({
344
+ inFlightReference: '0xsecond',
345
+ periodIndex: 1,
346
+ renew: async ({ subscription }) => ({
347
+ subscription: {
348
+ ...subscription,
349
+ reference: `0x${'b'.repeat(64)}`,
350
+ },
351
+ }),
352
+ subscriptionId,
353
+ })
354
+ expect(second.status).toBe('renewed')
355
+
356
+ finishRenewal()
357
+ expect(await first).toEqual({ status: 'claimMismatch' })
358
+ })
359
+
360
+ test('stale renewal cannot commit over a newer in-flight attempt', async () => {
361
+ const store = fromStore(Store.memory(), { renewalTimeoutMs: 0 })
362
+ await store.put(createRecord())
363
+ let finishFirst!: () => void
364
+ let finishSecond!: () => void
365
+ const firstPending = new Promise<void>((resolve) => {
366
+ finishFirst = resolve
367
+ })
368
+ const secondPending = new Promise<void>((resolve) => {
369
+ finishSecond = resolve
370
+ })
371
+
372
+ const first = store.renew({
373
+ inFlightReference: '0xfirst',
374
+ periodIndex: 1,
375
+ renew: async ({ subscription }) => {
376
+ await firstPending
377
+ return {
378
+ subscription: {
379
+ ...subscription,
380
+ reference: `0x${'b'.repeat(64)}`,
381
+ },
382
+ }
383
+ },
384
+ subscriptionId,
385
+ })
386
+
387
+ const second = store.renew({
388
+ inFlightReference: '0xsecond',
389
+ periodIndex: 1,
390
+ renew: async ({ subscription }) => {
391
+ await secondPending
392
+ return {
393
+ subscription: {
394
+ ...subscription,
395
+ reference: `0x${'c'.repeat(64)}`,
396
+ },
397
+ }
398
+ },
399
+ subscriptionId,
400
+ })
401
+
402
+ finishFirst()
403
+ expect(await first).toEqual({ status: 'claimMismatch' })
404
+ expect((await store.get(subscriptionId))?.inFlightReference).toBe('0xsecond')
405
+
406
+ finishSecond()
407
+ expect((await second).status).toBe('renewed')
408
+ expect((await store.get(subscriptionId))?.reference).toBe(`0x${'c'.repeat(64)}`)
409
+ })
410
+
411
+ test('stale renewal failure cannot clear a newer in-flight attempt', async () => {
412
+ const store = fromStore(Store.memory(), { renewalTimeoutMs: 0 })
413
+ await store.put(createRecord())
414
+ let rejectFirst!: () => void
415
+ let finishSecond!: () => void
416
+ const firstPending = new Promise<void>((_resolve, reject) => {
417
+ rejectFirst = () => reject(new Error('first failed'))
418
+ })
419
+ const secondPending = new Promise<void>((resolve) => {
420
+ finishSecond = resolve
421
+ })
422
+
423
+ const first = store.renew({
424
+ inFlightReference: '0xfirst',
425
+ periodIndex: 1,
426
+ renew: async ({ subscription }) => {
427
+ await firstPending
428
+ return { subscription }
429
+ },
430
+ subscriptionId,
431
+ })
432
+
433
+ const second = store.renew({
434
+ inFlightReference: '0xsecond',
435
+ periodIndex: 1,
436
+ renew: async ({ subscription }) => {
437
+ await secondPending
438
+ return { subscription }
439
+ },
440
+ subscriptionId,
441
+ })
442
+
443
+ rejectFirst()
444
+ await expect(first).rejects.toThrow('first failed')
445
+ expect((await store.get(subscriptionId))?.inFlightReference).toBe('0xsecond')
446
+
447
+ finishSecond()
448
+ expect((await second).status).toBe('renewed')
449
+ })
450
+
451
+ test('clears an in-flight renewal after failure', async () => {
452
+ const store = fromStore(Store.memory())
453
+ await store.put(createRecord())
454
+
455
+ await expect(
456
+ store.renew({
457
+ inFlightReference: '0xrenewal',
458
+ periodIndex: 1,
459
+ renew: async () => {
460
+ throw new Error('renewal failed')
461
+ },
462
+ subscriptionId,
463
+ }),
464
+ ).rejects.toThrow('renewal failed')
465
+
466
+ expect((await store.get(subscriptionId))?.inFlightPeriod).toBe(undefined)
467
+ expect(
468
+ (
469
+ await store.renew({
470
+ inFlightReference: '0xrenewal',
471
+ periodIndex: 1,
472
+ renew: async ({ subscription }) => ({ subscription }),
473
+ subscriptionId,
474
+ })
475
+ ).status,
476
+ ).toBe('renewed')
477
+ })
478
+
479
+ test('preserves cancellation that lands during an in-flight renewal', async () => {
480
+ const store = fromStore(Store.memory())
481
+ await store.put(createRecord())
482
+
483
+ const renewed = await store.renew({
484
+ inFlightReference: '0xrenewal',
485
+ periodIndex: 1,
486
+ renew: async ({ subscription }) => {
487
+ await store.put({
488
+ ...subscription,
489
+ canceledAt: '2025-01-02T00:00:00.000Z',
490
+ })
491
+ return {
492
+ subscription: {
493
+ ...subscription,
494
+ lastChargedPeriod: 1,
495
+ reference: `0x${'b'.repeat(64)}`,
496
+ },
497
+ }
498
+ },
499
+ subscriptionId,
500
+ })
501
+ expect(renewed.status).toBe('renewed')
502
+
503
+ const committed = await store.get(subscriptionId)
504
+ expect(committed?.canceledAt).toBe('2025-01-02T00:00:00.000Z')
505
+ expect(committed?.lastChargedPeriod).toBe(1)
506
+ expect(committed?.inFlightPeriod).toBe(undefined)
507
+ })
508
+
509
+ test('does not renew a superseded subscription record', async () => {
510
+ const store = fromStore(Store.memory())
511
+ await store.put(createRecord({ subscriptionId: 'sub_old' }))
512
+ await store.put(createRecord({ reference: `0x${'b'.repeat(64)}`, subscriptionId: 'sub_new' }))
513
+ let renewCalled = false
514
+
515
+ const renewal = await store.renew({
516
+ inFlightReference: '0xrenewal',
517
+ periodIndex: 1,
518
+ renew: async ({ subscription }) => {
519
+ renewCalled = true
520
+ return { subscription }
521
+ },
522
+ subscriptionId: 'sub_old',
523
+ })
524
+
525
+ expect(renewal.status).toBe('superseded')
526
+ expect(renewCalled).toBe(false)
527
+ expect((await store.getByKey('user-1:plan:pro'))?.subscriptionId).toBe('sub_new')
528
+ })
529
+
530
+ test('does not reclaim lookup ownership when superseded during renewal', async () => {
531
+ const store = fromStore(Store.memory())
532
+ await store.put(createRecord({ subscriptionId: 'sub_old' }))
533
+
534
+ const renewal = await store.renew({
535
+ inFlightReference: '0xrenewal',
536
+ periodIndex: 1,
537
+ renew: async ({ subscription }) => {
538
+ await store.put(
539
+ createRecord({ reference: `0x${'c'.repeat(64)}`, subscriptionId: 'sub_new' }),
540
+ )
541
+ return {
542
+ subscription: {
543
+ ...subscription,
544
+ reference: `0x${'b'.repeat(64)}`,
545
+ },
546
+ }
547
+ },
548
+ subscriptionId: 'sub_old',
549
+ })
550
+
551
+ expect(renewal.status).toBe('superseded')
552
+ expect((await store.getByKey('user-1:plan:pro'))?.subscriptionId).toBe('sub_new')
553
+ })
554
+ })