mppx 0.5.7 → 0.5.9
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.
- package/CHANGELOG.md +14 -0
- package/dist/Challenge.d.ts +3 -2
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js +27 -9
- package/dist/Challenge.js.map +1 -1
- package/dist/Method.d.ts +32 -14
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js.map +1 -1
- package/dist/Store.d.ts +68 -2
- package/dist/Store.d.ts.map +1 -1
- package/dist/Store.js +41 -4
- package/dist/Store.js.map +1 -1
- package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
- package/dist/mcp-sdk/server/Transport.js +7 -0
- package/dist/mcp-sdk/server/Transport.js.map +1 -1
- package/dist/server/Mppx.d.ts +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +133 -70
- package/dist/server/Mppx.js.map +1 -1
- package/dist/server/Transport.d.ts +8 -2
- package/dist/server/Transport.d.ts.map +1 -1
- package/dist/server/Transport.js +26 -1
- package/dist/server/Transport.js.map +1 -1
- package/dist/tempo/client/SessionManager.d.ts +13 -2
- package/dist/tempo/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/client/SessionManager.js +429 -4
- package/dist/tempo/client/SessionManager.js.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +28 -0
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +89 -0
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +4 -1
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +90 -66
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +3 -0
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js +3 -0
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Session.d.ts +8 -2
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/server/index.d.ts +1 -0
- package/dist/tempo/server/index.d.ts.map +1 -1
- package/dist/tempo/server/index.js +1 -0
- package/dist/tempo/server/index.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +16 -6
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/dist/tempo/session/ChannelStore.d.ts +12 -1
- package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
- package/dist/tempo/session/ChannelStore.js +55 -14
- package/dist/tempo/session/ChannelStore.js.map +1 -1
- package/dist/tempo/session/Sse.d.ts +11 -2
- package/dist/tempo/session/Sse.d.ts.map +1 -1
- package/dist/tempo/session/Sse.js +66 -25
- package/dist/tempo/session/Sse.js.map +1 -1
- package/dist/tempo/session/Ws.d.ts +87 -0
- package/dist/tempo/session/Ws.d.ts.map +1 -0
- package/dist/tempo/session/Ws.js +428 -0
- package/dist/tempo/session/Ws.js.map +1 -0
- package/dist/tempo/session/index.d.ts +1 -0
- package/dist/tempo/session/index.d.ts.map +1 -1
- package/dist/tempo/session/index.js +1 -0
- package/dist/tempo/session/index.js.map +1 -1
- package/package.json +2 -2
- package/src/Challenge.test.ts +1 -1
- package/src/Challenge.ts +28 -9
- package/src/Method.ts +61 -20
- package/src/Store.test-d.ts +80 -2
- package/src/Store.test.ts +150 -13
- package/src/Store.ts +140 -3
- package/src/mcp-sdk/server/Transport.test.ts +12 -0
- package/src/mcp-sdk/server/Transport.ts +8 -0
- package/src/server/Mppx.test.ts +105 -0
- package/src/server/Mppx.ts +178 -88
- package/src/server/Transport.test.ts +31 -0
- package/src/server/Transport.ts +31 -2
- package/src/tempo/client/SessionManager.ts +510 -7
- package/src/tempo/internal/fee-payer.test.ts +115 -1
- package/src/tempo/internal/fee-payer.ts +138 -1
- package/src/tempo/server/AtomicStore.test-d.ts +34 -0
- package/src/tempo/server/Charge.test.ts +128 -0
- package/src/tempo/server/Charge.ts +118 -93
- package/src/tempo/server/Methods.ts +3 -0
- package/src/tempo/server/Session.test.ts +1044 -47
- package/src/tempo/server/Session.ts +8 -2
- package/src/tempo/server/Sse.test.ts +29 -0
- package/src/tempo/server/index.ts +1 -0
- package/src/tempo/server/internal/html/main.ts +9 -10
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/server/internal/transport.ts +19 -6
- package/src/tempo/session/ChannelStore.test.ts +20 -1
- package/src/tempo/session/ChannelStore.ts +77 -14
- package/src/tempo/session/Sse.ts +77 -24
- package/src/tempo/session/Ws.test.ts +410 -0
- package/src/tempo/session/Ws.ts +563 -0
- 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> = (
|
|
100
|
-
|
|
101
|
-
|
|
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> = (
|
|
106
|
-
|
|
107
|
-
|
|
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> = (
|
|
127
|
-
|
|
128
|
-
|
|
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<
|
package/src/Store.test-d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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(
|
|
65
|
+
{ label: 'cloudflare', create: () => Store.cloudflare(fakeStringKv()) },
|
|
29
66
|
{
|
|
30
67
|
label: 'redis',
|
|
31
68
|
create: () => {
|
|
32
|
-
const kv =
|
|
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 =
|
|
80
|
+
const kv = fakeUnknownKv()
|
|
44
81
|
return Store.upstash({
|
|
45
82
|
get: kv.get,
|
|
46
|
-
set:
|
|
47
|
-
del:
|
|
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(
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
169
|
+
const kv = fakeUnknownKv()
|
|
133
170
|
const store = Store.upstash({
|
|
134
171
|
get: kv.get,
|
|
135
|
-
set:
|
|
136
|
-
del:
|
|
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
|
-
|
|
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():
|
|
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()
|