mppx 0.5.6 → 0.5.8

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 (115) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/Challenge.d.ts +3 -2
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Challenge.js +27 -9
  5. package/dist/Challenge.js.map +1 -1
  6. package/dist/Html.d.ts +2 -1
  7. package/dist/Html.d.ts.map +1 -1
  8. package/dist/Html.js +1 -1
  9. package/dist/Html.js.map +1 -1
  10. package/dist/Method.d.ts +32 -14
  11. package/dist/Method.d.ts.map +1 -1
  12. package/dist/Method.js.map +1 -1
  13. package/dist/Store.d.ts +68 -2
  14. package/dist/Store.d.ts.map +1 -1
  15. package/dist/Store.js +41 -4
  16. package/dist/Store.js.map +1 -1
  17. package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
  18. package/dist/mcp-sdk/server/Transport.js +7 -0
  19. package/dist/mcp-sdk/server/Transport.js.map +1 -1
  20. package/dist/server/Mppx.d.ts +1 -1
  21. package/dist/server/Mppx.d.ts.map +1 -1
  22. package/dist/server/Mppx.js +133 -70
  23. package/dist/server/Mppx.js.map +1 -1
  24. package/dist/server/Transport.d.ts +8 -2
  25. package/dist/server/Transport.d.ts.map +1 -1
  26. package/dist/server/Transport.js +26 -1
  27. package/dist/server/Transport.js.map +1 -1
  28. package/dist/server/internal/html/config.d.ts +4 -0
  29. package/dist/server/internal/html/config.d.ts.map +1 -1
  30. package/dist/server/internal/html/config.js.map +1 -1
  31. package/dist/stripe/server/Charge.d.ts +2 -4
  32. package/dist/stripe/server/Charge.d.ts.map +1 -1
  33. package/dist/stripe/server/Charge.js.map +1 -1
  34. package/dist/tempo/client/SessionManager.d.ts +13 -2
  35. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  36. package/dist/tempo/client/SessionManager.js +429 -4
  37. package/dist/tempo/client/SessionManager.js.map +1 -1
  38. package/dist/tempo/internal/fee-payer.d.ts +28 -0
  39. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  40. package/dist/tempo/internal/fee-payer.js +89 -0
  41. package/dist/tempo/internal/fee-payer.js.map +1 -1
  42. package/dist/tempo/server/Charge.d.ts +5 -5
  43. package/dist/tempo/server/Charge.d.ts.map +1 -1
  44. package/dist/tempo/server/Charge.js +90 -66
  45. package/dist/tempo/server/Charge.js.map +1 -1
  46. package/dist/tempo/server/Methods.d.ts +3 -0
  47. package/dist/tempo/server/Methods.d.ts.map +1 -1
  48. package/dist/tempo/server/Methods.js +3 -0
  49. package/dist/tempo/server/Methods.js.map +1 -1
  50. package/dist/tempo/server/Session.d.ts +8 -2
  51. package/dist/tempo/server/Session.d.ts.map +1 -1
  52. package/dist/tempo/server/Session.js.map +1 -1
  53. package/dist/tempo/server/index.d.ts +1 -0
  54. package/dist/tempo/server/index.d.ts.map +1 -1
  55. package/dist/tempo/server/index.js +1 -0
  56. package/dist/tempo/server/index.js.map +1 -1
  57. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  58. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  59. package/dist/tempo/server/internal/html.gen.js +1 -1
  60. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  61. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  62. package/dist/tempo/server/internal/transport.js +16 -6
  63. package/dist/tempo/server/internal/transport.js.map +1 -1
  64. package/dist/tempo/session/ChannelStore.d.ts +12 -1
  65. package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
  66. package/dist/tempo/session/ChannelStore.js +55 -14
  67. package/dist/tempo/session/ChannelStore.js.map +1 -1
  68. package/dist/tempo/session/Sse.d.ts +11 -2
  69. package/dist/tempo/session/Sse.d.ts.map +1 -1
  70. package/dist/tempo/session/Sse.js +66 -25
  71. package/dist/tempo/session/Sse.js.map +1 -1
  72. package/dist/tempo/session/Ws.d.ts +87 -0
  73. package/dist/tempo/session/Ws.d.ts.map +1 -0
  74. package/dist/tempo/session/Ws.js +428 -0
  75. package/dist/tempo/session/Ws.js.map +1 -0
  76. package/dist/tempo/session/index.d.ts +1 -0
  77. package/dist/tempo/session/index.d.ts.map +1 -1
  78. package/dist/tempo/session/index.js +1 -0
  79. package/dist/tempo/session/index.js.map +1 -1
  80. package/package.json +1 -1
  81. package/src/Challenge.test.ts +1 -1
  82. package/src/Challenge.ts +28 -9
  83. package/src/Html.ts +11 -1
  84. package/src/Method.ts +61 -20
  85. package/src/Store.test-d.ts +80 -2
  86. package/src/Store.test.ts +150 -13
  87. package/src/Store.ts +140 -3
  88. package/src/mcp-sdk/server/Transport.test.ts +12 -0
  89. package/src/mcp-sdk/server/Transport.ts +8 -0
  90. package/src/server/Mppx.test.ts +105 -0
  91. package/src/server/Mppx.ts +179 -89
  92. package/src/server/Transport.test.ts +31 -0
  93. package/src/server/Transport.ts +31 -2
  94. package/src/server/internal/html/config.ts +5 -0
  95. package/src/stripe/server/Charge.ts +2 -4
  96. package/src/tempo/client/SessionManager.ts +510 -7
  97. package/src/tempo/internal/fee-payer.test.ts +115 -1
  98. package/src/tempo/internal/fee-payer.ts +138 -1
  99. package/src/tempo/server/AtomicStore.test-d.ts +34 -0
  100. package/src/tempo/server/Charge.test.ts +128 -0
  101. package/src/tempo/server/Charge.ts +119 -100
  102. package/src/tempo/server/Methods.ts +3 -0
  103. package/src/tempo/server/Session.test.ts +1044 -47
  104. package/src/tempo/server/Session.ts +8 -2
  105. package/src/tempo/server/Sse.test.ts +29 -0
  106. package/src/tempo/server/index.ts +1 -0
  107. package/src/tempo/server/internal/html/main.ts +9 -10
  108. package/src/tempo/server/internal/html.gen.ts +1 -1
  109. package/src/tempo/server/internal/transport.ts +19 -6
  110. package/src/tempo/session/ChannelStore.test.ts +20 -1
  111. package/src/tempo/session/ChannelStore.ts +77 -14
  112. package/src/tempo/session/Sse.ts +77 -24
  113. package/src/tempo/session/Ws.test.ts +410 -0
  114. package/src/tempo/session/Ws.ts +563 -0
  115. package/src/tempo/session/index.ts +1 -0
package/src/Method.ts CHANGED
@@ -67,6 +67,58 @@ export type Client<
67
67
  }
68
68
  export type AnyClient = Client<any, any>
69
69
 
70
+ /** Transport-captured request metadata used as the authoritative request snapshot. */
71
+ export type CapturedRequest = {
72
+ readonly headers: Headers
73
+ readonly method: string
74
+ readonly url: URL
75
+ }
76
+
77
+ /** Verified challenge + credential pair, bound to the captured request snapshot. */
78
+ export type VerifiedChallengeEnvelope<
79
+ request extends Record<string, unknown> = Record<string, unknown>,
80
+ payload = unknown,
81
+ intent extends string = string,
82
+ MethodName extends string = string,
83
+ > = {
84
+ readonly capturedRequest: CapturedRequest
85
+ readonly challenge: Challenge.Challenge<request, intent, MethodName>
86
+ readonly credential: Credential.Credential<
87
+ payload,
88
+ Challenge.Challenge<request, intent, MethodName>
89
+ >
90
+ }
91
+
92
+ /** Request hook parameters for a single method. */
93
+ export type RequestContext<method extends Method> = {
94
+ capturedRequest?: CapturedRequest
95
+ credential?: Credential.Credential | null
96
+ request: z.input<method['schema']['request']>
97
+ }
98
+
99
+ /** Verification hook parameters for a single method. */
100
+ export type VerifyContext<method extends Method> = {
101
+ credential: Credential.Credential<
102
+ z.output<method['schema']['credential']['payload']>,
103
+ Challenge.Challenge<z.output<method['schema']['request']>, method['intent'], method['name']>
104
+ >
105
+ envelope?:
106
+ | VerifiedChallengeEnvelope<
107
+ z.output<method['schema']['request']>,
108
+ z.output<method['schema']['credential']['payload']>,
109
+ method['intent'],
110
+ method['name']
111
+ >
112
+ | undefined
113
+ request: z.input<method['schema']['request']>
114
+ }
115
+
116
+ /** Response hook parameters for a single method. */
117
+ export type RespondContext<method extends Method> = VerifyContext<method> & {
118
+ input: globalThis.Request
119
+ receipt: Receipt.Receipt
120
+ }
121
+
70
122
  /**
71
123
  * A server-side configured method with verification logic.
72
124
  */
@@ -96,19 +148,14 @@ export type CreateCredentialFn<method extends Method, context = unknown> = (
96
148
  ) => Promise<string>
97
149
 
98
150
  /** Request transform function for a single method. */
99
- export type RequestFn<method extends Method> = (options: {
100
- credential?: Credential.Credential | null | undefined
101
- request: z.input<method['schema']['request']>
102
- }) => MaybePromise<z.input<method['schema']['request']>>
151
+ export type RequestFn<method extends Method> = (
152
+ options: RequestContext<method>,
153
+ ) => MaybePromise<z.input<method['schema']['request']>>
103
154
 
104
155
  /** Verification function for a single method. */
105
- export type VerifyFn<method extends Method> = (parameters: {
106
- credential: Credential.Credential<
107
- z.output<method['schema']['credential']['payload']>,
108
- Challenge.Challenge<z.output<method['schema']['request']>, method['intent'], method['name']>
109
- >
110
- request: z.input<method['schema']['request']>
111
- }) => Promise<Receipt.Receipt>
156
+ export type VerifyFn<method extends Method> = (
157
+ parameters: VerifyContext<method>,
158
+ ) => Promise<Receipt.Receipt>
112
159
 
113
160
  /**
114
161
  * Optional respond function for a server-side method.
@@ -123,15 +170,9 @@ export type VerifyFn<method extends Method> = (parameters: {
123
170
  * **HTTP-only.** The `input` parameter is a `Request` object; MCP transports
124
171
  * do not invoke this hook.
125
172
  */
126
- export type RespondFn<method extends Method> = (parameters: {
127
- credential: Credential.Credential<
128
- z.output<method['schema']['credential']['payload']>,
129
- Challenge.Challenge<z.output<method['schema']['request']>, method['intent'], method['name']>
130
- >
131
- input: globalThis.Request
132
- receipt: Receipt.Receipt
133
- request: z.input<method['schema']['request']>
134
- }) => MaybePromise<globalThis.Response | undefined>
173
+ export type RespondFn<method extends Method> = (
174
+ parameters: RespondContext<method>,
175
+ ) => MaybePromise<globalThis.Response | undefined>
135
176
 
136
177
  /** Partial request type for defaults. */
137
178
  export type RequestDefaults<method extends Method> = ExactPartial<
@@ -3,18 +3,39 @@ import { expectTypeOf, test } from 'vp/test'
3
3
  import * as Store from './Store.js'
4
4
 
5
5
  test('default Store accepts any string key', () => {
6
- const store = Store.memory()
6
+ const store = {} as Store.Store
7
7
  expectTypeOf(store.get).parameter(0).toBeString()
8
8
  expectTypeOf(store.put).parameter(0).toBeString()
9
9
  expectTypeOf(store.delete).parameter(0).toBeString()
10
10
  })
11
11
 
12
12
  test('default Store get returns unknown', async () => {
13
- const store = Store.memory()
13
+ const store = {} as Store.Store
14
14
  const value = await store.get('anything')
15
15
  expectTypeOf(value).toEqualTypeOf<unknown>()
16
16
  })
17
17
 
18
+ test('default AtomicStore accepts any string key on update', () => {
19
+ const store = {} as Store.AtomicStore
20
+ expectTypeOf(store.update).parameter(0).toBeString()
21
+ })
22
+
23
+ test('memory returns AtomicStore', () => {
24
+ const store = Store.memory()
25
+ expectTypeOf(store).toEqualTypeOf<Store.AtomicStore>()
26
+ })
27
+
28
+ test('AtomicStore is assignable to Store', () => {
29
+ const atomic = {} as Store.AtomicStore
30
+ expectTypeOf(atomic).toMatchTypeOf<Store.Store>()
31
+ })
32
+
33
+ test('Store is not assignable to AtomicStore', () => {
34
+ const store = {} as Store.Store
35
+ // @ts-expect-error — Store has no update method
36
+ const _atomic: Store.AtomicStore = store
37
+ })
38
+
18
39
  test('typed Store constrains keys', () => {
19
40
  type ItemMap = { [key: `mppx:charge:${string}`]: number }
20
41
  const store = {} as Store.Store<ItemMap>
@@ -24,6 +45,13 @@ test('typed Store constrains keys', () => {
24
45
  expectTypeOf(store.delete).parameter(0).toEqualTypeOf<`mppx:charge:${string}`>()
25
46
  })
26
47
 
48
+ test('typed AtomicStore constrains keys on update', () => {
49
+ type ItemMap = { [key: `mppx:charge:${string}`]: number }
50
+ const store = {} as Store.AtomicStore<ItemMap>
51
+
52
+ expectTypeOf(store.update).parameter(0).toEqualTypeOf<`mppx:charge:${string}`>()
53
+ })
54
+
27
55
  test('typed Store infers value from key', async () => {
28
56
  type ItemMap = { [key: `mppx:charge:${string}`]: number }
29
57
  const store = {} as Store.Store<ItemMap>
@@ -40,6 +68,26 @@ test('typed Store enforces value type on put', () => {
40
68
  store.put('mppx:charge:0x123', 'wrong')
41
69
  })
42
70
 
71
+ test('typed AtomicStore update infers value and result types', () => {
72
+ type ItemMap = { [key: `mppx:charge:${string}`]: number }
73
+ const store = {} as Store.AtomicStore<ItemMap>
74
+
75
+ const result = store.update('mppx:charge:0x123', (current) => {
76
+ if (current === null) return { op: 'set', value: 1, result: 'inserted' as const }
77
+ return { op: 'noop', result: 'existing' as const }
78
+ })
79
+
80
+ expectTypeOf(result).toEqualTypeOf<Promise<'inserted' | 'existing'>>()
81
+ })
82
+
83
+ test('typed AtomicStore update enforces set value type', () => {
84
+ type ItemMap = { [key: `mppx:charge:${string}`]: number }
85
+ const store = {} as Store.AtomicStore<ItemMap>
86
+
87
+ // @ts-expect-error — update set value must be number, not string
88
+ store.update('mppx:charge:0x123', (_current) => ({ op: 'set', value: 'wrong', result: true }))
89
+ })
90
+
43
91
  test('cloudflare returns generic Store', () => {
44
92
  const store = Store.cloudflare({
45
93
  get: async () => null,
@@ -57,3 +105,33 @@ test('upstash returns generic Store', () => {
57
105
  })
58
106
  expectTypeOf(store).toEqualTypeOf<Store.Store>()
59
107
  })
108
+
109
+ test('cloudflare with update returns AtomicStore', () => {
110
+ const store = Store.cloudflare({
111
+ get: async () => null,
112
+ put: async () => {},
113
+ delete: async () => {},
114
+ update: async (_key, fn) => fn(null).result,
115
+ })
116
+ expectTypeOf(store).toEqualTypeOf<Store.AtomicStore>()
117
+ })
118
+
119
+ test('redis with update returns AtomicStore', () => {
120
+ const store = Store.redis({
121
+ get: async () => null,
122
+ set: async () => null,
123
+ del: async () => null,
124
+ update: async (_key, fn) => fn(null).result,
125
+ })
126
+ expectTypeOf(store).toEqualTypeOf<Store.AtomicStore>()
127
+ })
128
+
129
+ test('upstash with update returns AtomicStore', () => {
130
+ const store = Store.upstash({
131
+ get: async () => null,
132
+ set: async () => null,
133
+ del: async () => null,
134
+ update: async (_key, fn) => fn(null).result,
135
+ })
136
+ expectTypeOf(store).toEqualTypeOf<Store.AtomicStore>()
137
+ })
package/src/Store.test.ts CHANGED
@@ -8,7 +8,17 @@ const nested = {
8
8
  meta: { active: true, tags: ['a', 'b'] },
9
9
  }
10
10
 
11
- function fakeKv() {
11
+ function applyChange<value, result>(
12
+ map: Map<string, value>,
13
+ key: string,
14
+ change: Store.Change<value, result>,
15
+ ) {
16
+ if (change.op === 'set') map.set(key, change.value)
17
+ if (change.op === 'delete') map.delete(key)
18
+ return change.result
19
+ }
20
+
21
+ function fakeStringKv() {
12
22
  const map = new Map<string, string>()
13
23
  return {
14
24
  async get(key: string) {
@@ -20,16 +30,43 @@ function fakeKv() {
20
30
  async delete(key: string) {
21
31
  map.delete(key)
22
32
  },
33
+ async update<result>(
34
+ key: string,
35
+ fn: (current: string | null) => Store.Change<string, result>,
36
+ ) {
37
+ return applyChange(map, key, fn(map.get(key) ?? null))
38
+ },
39
+ }
40
+ }
41
+
42
+ function fakeUnknownKv() {
43
+ const map = new Map<string, unknown>()
44
+ return {
45
+ async get(key: string) {
46
+ return map.get(key) ?? null
47
+ },
48
+ async set(key: string, value: unknown) {
49
+ map.set(key, value)
50
+ },
51
+ async del(key: string) {
52
+ map.delete(key)
53
+ },
54
+ async update<result>(
55
+ key: string,
56
+ fn: (current: unknown | null) => Store.Change<unknown, result>,
57
+ ) {
58
+ return applyChange(map, key, fn(map.get(key) ?? null))
59
+ },
23
60
  }
24
61
  }
25
62
 
26
63
  describe.each([
27
64
  { label: 'memory', create: () => Store.memory() },
28
- { label: 'cloudflare', create: () => Store.cloudflare(fakeKv()) },
65
+ { label: 'cloudflare', create: () => Store.cloudflare(fakeStringKv()) },
29
66
  {
30
67
  label: 'redis',
31
68
  create: () => {
32
- const kv = fakeKv()
69
+ const kv = fakeStringKv()
33
70
  return Store.redis({
34
71
  get: kv.get,
35
72
  set: kv.put,
@@ -40,11 +77,11 @@ describe.each([
40
77
  {
41
78
  label: 'upstash',
42
79
  create: () => {
43
- const kv = fakeKv()
80
+ const kv = fakeUnknownKv()
44
81
  return Store.upstash({
45
82
  get: kv.get,
46
- set: (key, value) => kv.put(key, value as string),
47
- del: (key) => kv.delete(key),
83
+ set: kv.set,
84
+ del: kv.del,
48
85
  })
49
86
  },
50
87
  },
@@ -77,14 +114,14 @@ describe.each([
77
114
 
78
115
  describe('json roundtrip behavior', () => {
79
116
  test('cloudflare json-roundtrips nested objects', async () => {
80
- const store = Store.cloudflare(fakeKv())
117
+ const store = Store.cloudflare(fakeStringKv())
81
118
  const value = { a: [1, { b: 'c' }], d: null }
82
119
  await store.put('k', value)
83
120
  expect(await store.get('k')).toEqual(value)
84
121
  })
85
122
 
86
123
  test('cloudflare roundtrips BigInt values', async () => {
87
- const store = Store.cloudflare(fakeKv())
124
+ const store = Store.cloudflare(fakeStringKv())
88
125
  const value = { amount: 1000000000000000000n, nested: { big: 42n } }
89
126
  await store.put('k', value)
90
127
  expect(await store.get('k')).toEqual(value)
@@ -105,7 +142,7 @@ describe('json roundtrip behavior', () => {
105
142
  })
106
143
 
107
144
  test('redis json-roundtrips nested objects', async () => {
108
- const kv = fakeKv()
145
+ const kv = fakeStringKv()
109
146
  const store = Store.redis({
110
147
  get: kv.get,
111
148
  set: kv.put,
@@ -117,7 +154,7 @@ describe('json roundtrip behavior', () => {
117
154
  })
118
155
 
119
156
  test('redis roundtrips BigInt values', async () => {
120
- const kv = fakeKv()
157
+ const kv = fakeStringKv()
121
158
  const store = Store.redis({
122
159
  get: kv.get,
123
160
  set: kv.put,
@@ -129,15 +166,115 @@ describe('json roundtrip behavior', () => {
129
166
  })
130
167
 
131
168
  test('upstash passes values through without json serialization', async () => {
132
- const kv = fakeKv()
169
+ const kv = fakeUnknownKv()
133
170
  const store = Store.upstash({
134
171
  get: kv.get,
135
- set: (key, value) => kv.put(key, value as string),
136
- del: (key) => kv.delete(key),
172
+ set: kv.set,
173
+ del: kv.del,
137
174
  })
138
175
  const value = { a: 1 }
139
176
  await store.put('k', value)
140
177
  // upstash store does not JSON-serialize; the fake map holds the original reference
141
178
  expect(await kv.get('k')).toBe(value)
142
179
  })
180
+
181
+ test('memory update can noop, set, and delete with typed results', async () => {
182
+ const store = Store.memory()
183
+
184
+ const inserted = await store.update('k', (current) => {
185
+ expect(current).toBeNull()
186
+ return { op: 'set', value: { count: 1 }, result: 'inserted' as const }
187
+ })
188
+ const preserved = await store.update('k', (current) => {
189
+ expect(current).toEqual({ count: 1 })
190
+ return { op: 'noop', result: 'unchanged' as const }
191
+ })
192
+ const deleted = await store.update('k', (current) => {
193
+ expect(current).toEqual({ count: 1 })
194
+ return { op: 'delete', result: 'removed' as const }
195
+ })
196
+
197
+ expect(inserted).toBe('inserted')
198
+ expect(preserved).toBe('unchanged')
199
+ expect(deleted).toBe('removed')
200
+ expect(await store.get('k')).toBeNull()
201
+ })
202
+
203
+ test('cloudflare update adapts JSON values through the wrapper', async () => {
204
+ const kv = fakeStringKv()
205
+ const store = Store.cloudflare(kv)
206
+
207
+ await store.put('k', { count: 1 })
208
+ const result = await store.update('k', (current) => {
209
+ expect(current).toEqual({ count: 1 })
210
+ return {
211
+ op: 'set',
212
+ value: { count: (current as { count: number }).count + 1 },
213
+ result: 'updated' as const,
214
+ }
215
+ })
216
+
217
+ expect(result).toBe('updated')
218
+ expect(await store.get('k')).toEqual({ count: 2 })
219
+ })
220
+
221
+ test('redis update adapts JSON values through the wrapper', async () => {
222
+ const kv = fakeStringKv()
223
+ const store = Store.redis({
224
+ get: kv.get,
225
+ set: kv.put,
226
+ del: (key) => kv.delete(key),
227
+ update: kv.update,
228
+ })
229
+
230
+ await store.put('k', { count: 1 })
231
+ const result = await store.update('k', (current) => {
232
+ expect(current).toEqual({ count: 1 })
233
+ return {
234
+ op: 'set',
235
+ value: { count: (current as { count: number }).count + 1 },
236
+ result: 'updated' as const,
237
+ }
238
+ })
239
+
240
+ expect(result).toBe('updated')
241
+ expect(await store.get('k')).toEqual({ count: 2 })
242
+ })
243
+
244
+ test('upstash update passes values through the wrapper', async () => {
245
+ const kv = fakeUnknownKv()
246
+ const store = Store.upstash({
247
+ get: kv.get,
248
+ set: kv.set,
249
+ del: kv.del,
250
+ update: kv.update,
251
+ })
252
+
253
+ await store.put('k', { count: 1 })
254
+ const result = await store.update('k', (current) => {
255
+ expect(current).toEqual({ count: 1 })
256
+ return {
257
+ op: 'set',
258
+ value: { count: (current as { count: number }).count + 1 },
259
+ result: 'updated' as const,
260
+ }
261
+ })
262
+
263
+ expect(result).toBe('updated')
264
+ expect(await store.get('k')).toEqual({ count: 2 })
265
+ })
266
+
267
+ test('upstash update passes through unencoded values', async () => {
268
+ const kv = fakeUnknownKv()
269
+ const store = Store.upstash(kv)
270
+
271
+ await store.put('k', { count: 1 })
272
+ const result = await store.update!('k', (current) => {
273
+ expect(current).toEqual({ count: 1 })
274
+ return { op: 'set', value: { count: (current as { count: number }).count + 1 }, result: 2 }
275
+ })
276
+
277
+ expect(result).toBe(2)
278
+ expect(await kv.get('k')).toEqual({ count: 2 })
279
+ })
143
280
  })
package/src/Store.ts CHANGED
@@ -3,23 +3,118 @@
3
3
  *
4
4
  * Modeled after Cloudflare KV's API (`get`/`put`/`delete`).
5
5
  * Implementations handle serialization internally.
6
+ *
7
+ * ## Type architecture
8
+ *
9
+ * Uses a two-slot generic pattern inspired by Viem's `Client` type:
10
+ *
11
+ * - `itemMap` — constrains keys and their value types
12
+ * - `extended` — accumulates additional capabilities (e.g., atomic `update`)
13
+ *
14
+ * `AtomicStore` is a type alias that fills the `extended` slot with
15
+ * `AtomicActions`, just like Viem's `PublicClient = Client<..., PublicActions>`.
6
16
  */
7
17
  import { Json } from 'ox'
8
18
 
9
19
  export type StoreItemMap = Record<string, unknown>
10
20
 
11
- export type Store<itemMap extends StoreItemMap = StoreItemMap> = {
21
+ /**
22
+ * Describes the outcome of an atomic {@link Update} callback.
23
+ *
24
+ * - `noop` — leave the stored value unchanged.
25
+ * - `set` — write `value` for the key.
26
+ * - `delete` — remove the key.
27
+ *
28
+ * Every variant carries a `result` that is forwarded to the caller.
29
+ */
30
+ export type Change<value, result> =
31
+ | { op: 'noop'; result: result }
32
+ | { op: 'set'; value: value; result: result }
33
+ | { op: 'delete'; result: result }
34
+
35
+ /**
36
+ * Atomic read-modify-write for a single key.
37
+ *
38
+ * `fn` receives the current value (or `null`) and returns a {@link Change}
39
+ * describing the write to perform. Implementations may retry `fn`, so it
40
+ * must be synchronous and free of side effects.
41
+ */
42
+ export type Update<itemMap extends StoreItemMap = StoreItemMap> = <
43
+ key extends keyof itemMap & string,
44
+ result,
45
+ >(
46
+ key: key,
47
+ fn: (current: itemMap[key] | null) => Change<itemMap[key], result>,
48
+ ) => Promise<result>
49
+
50
+ /** Base key-value actions available on every {@link Store}. */
51
+ export type StoreActions<itemMap extends StoreItemMap = StoreItemMap> = {
12
52
  get: <key extends keyof itemMap & string>(key: key) => Promise<itemMap[key] | null>
13
53
  put: <key extends keyof itemMap & string>(key: key, value: itemMap[key]) => Promise<void>
14
54
  delete: <key extends keyof itemMap & string>(key: key) => Promise<void>
15
55
  }
16
56
 
57
+ /** Atomic actions that can be provided via the `extended` slot. */
58
+ export type AtomicActions<itemMap extends StoreItemMap = StoreItemMap> = {
59
+ update: Update<itemMap>
60
+ }
61
+
62
+ /**
63
+ * Async key-value store.
64
+ *
65
+ * The second generic `extended` accumulates additional capabilities
66
+ * (like {@link AtomicActions}) without structural patching.
67
+ */
68
+ export type Store<
69
+ itemMap extends StoreItemMap = StoreItemMap,
70
+ extended extends Record<string, unknown> | undefined = undefined,
71
+ > = StoreActions<itemMap> & (extended extends Record<string, unknown> ? extended : unknown)
72
+
73
+ /**
74
+ * A {@link Store} whose atomic {@link Update} method is guaranteed to exist.
75
+ *
76
+ * Use this when atomicity is required (e.g., replay protection, channel
77
+ * deductions). Factory functions return `AtomicStore` when the backing
78
+ * adapter provides an `update` implementation.
79
+ *
80
+ * Equivalent to `Store<itemMap, AtomicActions<itemMap>>`.
81
+ */
82
+ export type AtomicStore<itemMap extends StoreItemMap = StoreItemMap> = Store<
83
+ itemMap,
84
+ AtomicActions<itemMap>
85
+ >
86
+
17
87
  /** Creates a {@link Store} from an existing implementation. */
18
- export function from<store extends Store>(store: store): store {
88
+ export function from<store extends Store>(store: store): store
89
+ export function from<store extends AtomicStore>(store: store): store
90
+ export function from(store: Store | AtomicStore) {
19
91
  return store
20
92
  }
21
93
 
94
+ function wrapJsonUpdate(
95
+ update:
96
+ | (<result>(
97
+ key: string,
98
+ fn: (current: string | null) => Change<string, result>,
99
+ ) => Promise<result>)
100
+ | undefined,
101
+ ): AtomicActions | {} {
102
+ if (!update) return {}
103
+ return {
104
+ async update(key, fn) {
105
+ return update(key, (current) => {
106
+ const parsed = current == null ? null : (Json.parse(current) as never)
107
+ const change = fn(parsed)
108
+ if (change.op !== 'set') return change
109
+ return { ...change, value: Json.stringify(change.value) }
110
+ })
111
+ },
112
+ } satisfies AtomicActions
113
+ }
114
+
22
115
  /** Wraps a Cloudflare KV namespace. */
116
+ export function cloudflare(kv: cloudflare.AtomicParameters): AtomicStore
117
+ export function cloudflare(kv: cloudflare.Parameters): Store
23
118
  export function cloudflare(kv: cloudflare.Parameters): Store {
24
119
  return from({
25
120
  async get(key) {
@@ -33,6 +128,7 @@ export function cloudflare(kv: cloudflare.Parameters): Store {
33
128
  async delete(key) {
34
129
  await kv.delete(key)
35
130
  },
131
+ ...wrapJsonUpdate(kv.update),
36
132
  })
37
133
  }
38
134
 
@@ -41,11 +137,19 @@ export declare namespace cloudflare {
41
137
  get: (key: string) => Promise<unknown>
42
138
  put: (key: string, value: string) => Promise<void>
43
139
  delete: (key: string) => Promise<void>
140
+ update?: <result>(
141
+ key: string,
142
+ fn: (current: string | null) => Change<string, result>,
143
+ ) => Promise<result>
144
+ }
145
+
146
+ export type AtomicParameters = Omit<Parameters, 'update'> & {
147
+ update: NonNullable<Parameters['update']>
44
148
  }
45
149
  }
46
150
 
47
151
  /** In-memory store backed by a `Map`. JSON-roundtrips values to match production behavior. */
48
- export function memory(): Store {
152
+ export function memory(): AtomicStore {
49
153
  const store = new Map<string, string>()
50
154
  return from({
51
155
  async get(key) {
@@ -59,10 +163,19 @@ export function memory(): Store {
59
163
  async delete(key) {
60
164
  store.delete(key)
61
165
  },
166
+ async update(key, fn) {
167
+ const current = store.has(key) ? (Json.parse(store.get(key)!) as never) : null
168
+ const change = fn(current)
169
+ if (change.op === 'set') store.set(key, Json.stringify(change.value))
170
+ if (change.op === 'delete') store.delete(key)
171
+ return change.result
172
+ },
62
173
  })
63
174
  }
64
175
 
65
176
  /** Wraps a standard Redis client (ioredis, node-redis, Valkey). */
177
+ export function redis(client: redis.AtomicParameters): AtomicStore
178
+ export function redis(client: redis.Parameters): Store
66
179
  export function redis(client: redis.Parameters): Store {
67
180
  return from({
68
181
  async get(key) {
@@ -76,6 +189,7 @@ export function redis(client: redis.Parameters): Store {
76
189
  async delete(key) {
77
190
  await client.del(key)
78
191
  },
192
+ ...wrapJsonUpdate(client.update),
79
193
  })
80
194
  }
81
195
 
@@ -84,10 +198,20 @@ export declare namespace redis {
84
198
  get: (key: string) => Promise<string | null>
85
199
  set: (key: string, value: string) => Promise<unknown>
86
200
  del: (key: string) => Promise<unknown>
201
+ update?: <result>(
202
+ key: string,
203
+ fn: (current: string | null) => Change<string, result>,
204
+ ) => Promise<result>
205
+ }
206
+
207
+ export type AtomicParameters = Omit<Parameters, 'update'> & {
208
+ update: NonNullable<Parameters['update']>
87
209
  }
88
210
  }
89
211
 
90
212
  /** Wraps an Upstash Redis instance (e.g. Vercel KV). */
213
+ export function upstash(redis: upstash.AtomicParameters): AtomicStore
214
+ export function upstash(redis: upstash.Parameters): Store
91
215
  export function upstash(redis: upstash.Parameters): Store {
92
216
  return from({
93
217
  async get(key) {
@@ -99,6 +223,11 @@ export function upstash(redis: upstash.Parameters): Store {
99
223
  async delete(key) {
100
224
  await redis.del(key)
101
225
  },
226
+ ...(redis.update
227
+ ? {
228
+ update: redis.update as Update,
229
+ }
230
+ : {}),
102
231
  })
103
232
  }
104
233
 
@@ -107,5 +236,13 @@ export declare namespace upstash {
107
236
  get: (key: string) => Promise<unknown>
108
237
  set: (key: string, value: unknown) => Promise<unknown>
109
238
  del: (key: string) => Promise<unknown>
239
+ update?: <result>(
240
+ key: string,
241
+ fn: (current: unknown | null) => Change<unknown, result>,
242
+ ) => Promise<result>
243
+ }
244
+
245
+ export type AtomicParameters = Omit<Parameters, 'update'> & {
246
+ update: NonNullable<Parameters['update']>
110
247
  }
111
248
  }
@@ -26,6 +26,18 @@ const credential: Credential = {
26
26
  }
27
27
 
28
28
  describe('mcpSdk', () => {
29
+ describe('captureRequest', () => {
30
+ test('captures a stable synthetic MCP SDK request snapshot', async () => {
31
+ const transport = mcpSdk()
32
+
33
+ expect(await transport.captureRequest?.({})).toEqual({
34
+ headers: new Headers(),
35
+ method: 'POST',
36
+ url: new URL('mcp://request/sdk'),
37
+ })
38
+ })
39
+ })
40
+
29
41
  describe('getCredential', () => {
30
42
  test('returns credential from _meta', () => {
31
43
  const transport = mcpSdk()