Skip to content

Commit cfe1839

Browse files
authored
feat(pinia-colada): define base options and interceptors via contract meta (#1718)
## Summary Port of #1705 to `@orpc/pinia-colada`. Define base Pinia Colada options and interceptors directly on a procedure contract via `piniaColada`, and apply them automatically with the new `ContractOptionsUtilsPlugin`. ```ts export const contract = { planet: { find: oc .input(z.object({ id: z.number() })) .meta(piniaColada({ queryOptions: { staleTime: 60 * 1000, }, })), }, } const orpc = createPiniaColadaUtils(client, { plugins: [new ContractOptionsUtilsPlugin(contract)], }) ``` - `piniaColada` infers input, output, and error types from the contract, supports all procedure utils options (keys, options, interceptors), and merges when applied multiple times - `ContractOptionsUtilsPlugin` applies meta options as the base layer: utils level options override them and utils interceptors run after meta interceptors - Merging is consistent across all layers: interceptors concatenate, plain option objects spread-merge, any pair involving a function modifier composes, and a key explicitly set to `undefined` resets the value from lower layers - `getPiniaColadaMeta` reads the stored options for custom integrations - `UseMutationFnContext` now matches the runtime merged mutation context, so values provided by a global `onMutate` are typed inside mutation interceptors via `UseMutationContextCommon` augmentation - Docs cover the plugin, the reference-only nature of contract types, and passing runtime values such as router utils through a global `onMutate` hook for optimistic updates
1 parent 6c2c35e commit cfe1839

10 files changed

Lines changed: 620 additions & 100 deletions

File tree

apps/content/docs/integrations/pinia-colada.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,104 @@ const orpc = createPiniaColadaUtils(client, {
288288
})
289289
```
290290

291+
### Contract Options Plugin
292+
293+
Use `piniaColada` to define base options and interceptors directly on a [procedure contract](/docs/contract/procedure), then pass the contract to `ContractOptionsUtilsPlugin` to apply them automatically. Meta options act as the base layer: [default options](#default-options) and [interceptors](#interceptors) defined on the utils merge on top of them. Passing `undefined` explicitly for a key resets the value from lower layers instead of merging.
294+
295+
```ts
296+
import { ContractOptionsUtilsPlugin, piniaColada } from '@orpc/pinia-colada'
297+
298+
export const contract = {
299+
planet: {
300+
find: oc
301+
.input(z.object({ id: z.number() }))
302+
.meta(piniaColada({
303+
queryOptions: {
304+
staleTime: 60 * 1000,
305+
},
306+
queryInterceptors: [
307+
async ({ input, next }) => {
308+
// input, output, and errors are typed based on the contract
309+
return await next()
310+
},
311+
],
312+
})),
313+
},
314+
}
315+
316+
const orpc = createPiniaColadaUtils(client, {
317+
plugins: [new ContractOptionsUtilsPlugin(contract)],
318+
})
319+
```
320+
321+
::: warning
322+
Types inferred from the contract are for reference only. The actual types depend on the client the utils are created from. For example, a `JsonifiedClient` created from [OpenAPI Link](/docs/openapi/link#typesafe-clients) returns jsonified outputs that may not match the contract schemas.
323+
:::
324+
325+
::: details Passing runtime values into contract meta?
326+
Contracts are defined separately from your app, so anything inside `piniaColada` cannot import runtime values such as your router utils. Instead, augment [`UseMutationContextCommon`](https://pinia-colada.esm.dev/api/@pinia/colada/interfaces/UseMutationContextCommon.html) and provide the values through a global `onMutate` hook, which merges them into the `fnContext` of every mutation. The example below reads router utils and the query cache from `fnContext` to optimistically update a query:
327+
328+
```ts
329+
import type { RouterContractClient } from '@orpc/contract'
330+
import type { RouterUtils } from '@orpc/pinia-colada'
331+
import type { QueryCache } from '@pinia/colada'
332+
333+
declare module '@pinia/colada' {
334+
interface UseMutationContextCommon {
335+
utils: RouterUtils<RouterContractClient<typeof contract>>
336+
queryCache: QueryCache
337+
}
338+
}
339+
340+
export const contract = {
341+
planet: {
342+
find: oc.input(z.object({ id: z.number() })),
343+
update: oc
344+
.input(z.object({ id: z.number(), name: z.string() }))
345+
.meta(piniaColada({
346+
mutationInterceptors: [
347+
async ({ input, next, fnContext }) => {
348+
const { utils, queryCache } = fnContext
349+
350+
if (!utils || !queryCache) {
351+
return next()
352+
}
353+
354+
const queryKey = utils.planet.find.queryKey({ input: { id: input.id } })
355+
const previous = queryCache.getQueryData(queryKey)
356+
357+
// optimistically update before the request
358+
queryCache.setQueryData(queryKey, input)
359+
360+
try {
361+
return await next()
362+
}
363+
catch (error) {
364+
// roll back on error
365+
queryCache.setQueryData(queryKey, previous)
366+
throw error
367+
}
368+
finally {
369+
queryCache.invalidateQueries({ key: queryKey })
370+
}
371+
},
372+
],
373+
})),
374+
},
375+
}
376+
377+
app.use(PiniaColada, {
378+
mutationOptions: {
379+
onMutate: () => ({
380+
utils: orpc,
381+
queryCache: useQueryCache(pinia),
382+
}),
383+
},
384+
})
385+
```
386+
387+
:::
388+
291389
## Client Context
292390

293391
When a client is invoked through the Pinia Colada integration, an **operation context** is automatically added to the [client context](/docs/client/client-side#client-context). You can use this context to configure request behavior, such as selecting the HTTP method for [RPC Link](/docs/rpc/link#request-method).

packages/pinia-colada/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './contract-utils'
22
export * from './key'
33
export * from './live-query'
4+
export * from './meta'
45
export * from './plugin'
56
export * from './procedure-utils'
67
export * from './router-utils'
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { ClientContext, ORPCError } from '@orpc/client'
2+
import type { PromiseWithError } from '@orpc/shared'
3+
import type { OperationContext } from './types'
4+
import { oc, type } from '@orpc/contract'
5+
import { piniaColada } from './meta'
6+
7+
describe('piniaColada', () => {
8+
it('infers input, output and error types from the contract', () => {
9+
oc
10+
.errors({ BAD_GATEWAY: { data: type<string, RegExp>(vi.fn()) } })
11+
.input(type<string, boolean>(vi.fn()))
12+
.output(type<number, Date>(vi.fn()))
13+
.meta(piniaColada({
14+
queryOptions: { staleTime: 1000 },
15+
queryInterceptors: [
16+
async ({ context, input, next }) => {
17+
expectTypeOf(input).toEqualTypeOf<string>()
18+
expectTypeOf(context).toEqualTypeOf<ClientContext & OperationContext>()
19+
20+
const result = next()
21+
22+
expectTypeOf(result).toEqualTypeOf<
23+
PromiseWithError<Date, Error | ORPCError<'BAD_GATEWAY', RegExp>>
24+
>()
25+
26+
return result
27+
},
28+
],
29+
mutationOptions: {
30+
onSuccess: (data, input) => {
31+
expectTypeOf(data).toEqualTypeOf<Date>()
32+
expectTypeOf(input).toEqualTypeOf<string>()
33+
},
34+
},
35+
}))
36+
})
37+
38+
it('rejects invalid base options', () => {
39+
oc.output(type<number, Date>(vi.fn())).meta(piniaColada({
40+
queryOptions: {
41+
// @ts-expect-error - staleTime must be a number
42+
staleTime: 'invalid',
43+
},
44+
}))
45+
})
46+
})
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { oc } from '@orpc/contract'
2+
import { ContractOptionsUtilsPlugin, getPiniaColadaMeta, piniaColada } from './meta'
3+
import { createRouterUtils } from './router-utils'
4+
5+
describe('piniaColada', () => {
6+
it('stores options readable via getPiniaColadaMeta', () => {
7+
const interceptor = vi.fn()
8+
const contract = oc.meta(piniaColada({
9+
queryOptions: { staleTime: 1000 },
10+
queryInterceptors: [interceptor],
11+
}))
12+
13+
expect(getPiniaColadaMeta(contract)).toEqual({
14+
queryOptions: { staleTime: 1000 },
15+
queryInterceptors: [interceptor],
16+
})
17+
18+
expect(getPiniaColadaMeta(oc)).toBeUndefined()
19+
})
20+
21+
it('merges options when applied multiple times', () => {
22+
const interceptor1 = vi.fn()
23+
const interceptor2 = vi.fn()
24+
const keyModifier = vi.fn()
25+
26+
const contract = oc
27+
.meta(piniaColada({
28+
queryKey: keyModifier,
29+
queryOptions: { staleTime: 1000, gcTime: 1 },
30+
queryInterceptors: [interceptor1],
31+
}))
32+
.meta(piniaColada({
33+
queryOptions: { gcTime: 2 },
34+
mutationInterceptors: [interceptor2],
35+
}))
36+
37+
expect(getPiniaColadaMeta(contract)).toEqual({
38+
queryKey: keyModifier,
39+
queryOptions: { staleTime: 1000, gcTime: 2 },
40+
queryInterceptors: [interceptor1],
41+
mutationInterceptors: [interceptor2],
42+
})
43+
})
44+
45+
it('propagates base meta to procedures via .router', () => {
46+
const router = oc
47+
.meta(piniaColada({ queryOptions: { staleTime: 1000 } }))
48+
.router({
49+
ping: oc.meta(piniaColada({ queryOptions: { gcTime: 2 } })),
50+
pong: oc,
51+
})
52+
53+
expect(getPiniaColadaMeta(router.ping)).toEqual({
54+
queryOptions: { staleTime: 1000, gcTime: 2 },
55+
})
56+
57+
expect(getPiniaColadaMeta(router.pong)).toEqual({
58+
queryOptions: { staleTime: 1000 },
59+
})
60+
})
61+
})
62+
63+
describe('contractOptionsUtilsPlugin', () => {
64+
it('leaves options unchanged when no procedure or no meta at path', () => {
65+
const plugin = new ContractOptionsUtilsPlugin({ planet: { find: oc } })
66+
const options = { prefix: '__prefix__' }
67+
68+
expect(plugin.initProcedureOptions(['unknown'], options)).toBe(options)
69+
expect(plugin.initProcedureOptions(['planet'], options)).toBe(options)
70+
expect(plugin.initProcedureOptions(['planet', 'find'], options)).toBe(options)
71+
})
72+
73+
it('merges meta options as base under current options', () => {
74+
const metaInterceptor = vi.fn()
75+
const existingInterceptor = vi.fn()
76+
77+
const plugin = new ContractOptionsUtilsPlugin({
78+
planet: {
79+
find: oc.meta(piniaColada({
80+
queryOptions: { staleTime: 1000, gcTime: 1 },
81+
queryInterceptors: [metaInterceptor],
82+
})),
83+
},
84+
})
85+
86+
const result = plugin.initProcedureOptions(['planet', 'find'], {
87+
prefix: '__prefix__',
88+
queryOptions: { gcTime: 2 },
89+
queryInterceptors: [existingInterceptor],
90+
} as any)
91+
92+
expect(result).toEqual({
93+
prefix: '__prefix__',
94+
queryOptions: { staleTime: 1000, gcTime: 2 },
95+
queryInterceptors: [metaInterceptor, existingInterceptor],
96+
})
97+
})
98+
99+
it('applies contract meta through createRouterUtils', async () => {
100+
const marks: string[] = []
101+
const metaInterceptor = vi.fn(({ next }: any) => {
102+
marks.push('meta')
103+
return next()
104+
})
105+
const utilsInterceptor = vi.fn(({ next }: any) => {
106+
marks.push('utils')
107+
return next()
108+
})
109+
110+
const contract = {
111+
planet: {
112+
find: oc.meta(piniaColada({
113+
queryOptions: { staleTime: 1000, gcTime: 1 },
114+
queryInterceptors: [metaInterceptor],
115+
})),
116+
},
117+
}
118+
119+
const client = {
120+
planet: {
121+
find: vi.fn(async () => '__found__'),
122+
},
123+
}
124+
125+
const utils = createRouterUtils(client as any, {
126+
queryInterceptors: [utilsInterceptor],
127+
scoped: {
128+
planet: {
129+
find: {
130+
queryOptions: { gcTime: 2 },
131+
},
132+
},
133+
},
134+
plugins: [new ContractOptionsUtilsPlugin(contract)],
135+
}) as any
136+
137+
const options = utils.planet.find.queryOptions({ input: { id: 1 } })
138+
139+
expect(options.staleTime).toBe(1000)
140+
expect(options.gcTime).toBe(2)
141+
142+
await expect(options.query({ signal: undefined })).resolves.toBe('__found__')
143+
144+
expect(marks).toEqual(['meta', 'utils'])
145+
expect(client.planet.find).toHaveBeenCalledTimes(1)
146+
})
147+
})

0 commit comments

Comments
 (0)