houdini-core 2.0.0-go.0
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/package.json +24 -0
- package/postInstall.js +117 -0
- package/runtime/cache.ts +5 -0
- package/runtime/client.ts +181 -0
- package/runtime/config.ts +79 -0
- package/runtime/generated.ts +1 -0
- package/runtime/imports/config.ts +3 -0
- package/runtime/imports/pluginConfig.ts +5 -0
- package/runtime/index.ts +38 -0
- package/runtime/package.json +1 -0
- package/runtime/plugins/cache.ts +178 -0
- package/runtime/plugins/fetch.ts +337 -0
- package/runtime/plugins/fetchParams.ts +36 -0
- package/runtime/plugins/fragment.ts +80 -0
- package/runtime/plugins/index.ts +9 -0
- package/runtime/plugins/injectedPlugins.ts +9 -0
- package/runtime/plugins/mutation.ts +98 -0
- package/runtime/plugins/optimisticKeys.ts +455 -0
- package/runtime/plugins/query.ts +93 -0
- package/runtime/plugins/subscription.ts +153 -0
- package/runtime/plugins/throwOnError.ts +44 -0
- package/runtime/plugins/utils/documentPlugins.ts +54 -0
- package/runtime/plugins/utils/index.ts +1 -0
- package/runtime/public/cache.ts +121 -0
- package/runtime/public/index.ts +1 -0
- package/runtime/public/list.ts +164 -0
- package/runtime/public/record.ts +113 -0
- package/runtime/public/types.ts +166 -0
- package/runtime/server/index.ts +1 -0
- package/shim.cjs +64 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import type { ClientPlugin, ClientPluginContext } from 'houdini/runtime/documentStore'
|
|
2
|
+
import { ArtifactKind, DataSource } from 'houdini/runtime/types'
|
|
3
|
+
import type { RequestPayload, FetchContext } from 'houdini/runtime/types'
|
|
4
|
+
|
|
5
|
+
export const fetch = (target?: RequestHandler | string): ClientPlugin => {
|
|
6
|
+
return () => {
|
|
7
|
+
return {
|
|
8
|
+
async network(ctx, { client, initialValue, resolve, marshalVariables }) {
|
|
9
|
+
// there is no fetch for a fragment
|
|
10
|
+
if (ctx.artifact.kind === ArtifactKind.Fragment) {
|
|
11
|
+
return resolve(ctx, initialValue)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// figure out which fetch to use
|
|
15
|
+
const fetch = ctx.fetch ?? globalThis.fetch
|
|
16
|
+
|
|
17
|
+
// build up the params object
|
|
18
|
+
const fetchParams: FetchParams = {
|
|
19
|
+
name: ctx.name,
|
|
20
|
+
text: ctx.text,
|
|
21
|
+
hash: ctx.hash,
|
|
22
|
+
variables: { ...marshalVariables(ctx) },
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// before we move onto the next plugin, we need to strip the variables as they go through
|
|
26
|
+
for (const variable of ctx.artifact.stripVariables) {
|
|
27
|
+
delete fetchParams.variables[variable]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let fetchFn = defaultFetch(client.url, ctx.fetchParams)
|
|
31
|
+
// the provided parameter either specifies the URL or is the entire function to
|
|
32
|
+
// use
|
|
33
|
+
if (target) {
|
|
34
|
+
if (typeof target === 'string') {
|
|
35
|
+
fetchFn = defaultFetch(target, ctx.fetchParams)
|
|
36
|
+
} else {
|
|
37
|
+
fetchFn = target
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const result = await fetchFn({
|
|
42
|
+
// wrap the user's fetch function so we can identify SSR by checking
|
|
43
|
+
// the response.url
|
|
44
|
+
fetch: (url: URL | RequestInfo, args: RequestInit | undefined) => {
|
|
45
|
+
// figure out if we need to do something special for multipart uploads
|
|
46
|
+
const newArgs = handleMultipart(fetchParams, args) ?? args
|
|
47
|
+
// use the new args if they exist, otherwise the old ones are good
|
|
48
|
+
return fetch(url, {
|
|
49
|
+
...newArgs,
|
|
50
|
+
signal: ctx.abortController.signal,
|
|
51
|
+
})
|
|
52
|
+
},
|
|
53
|
+
metadata: ctx.metadata,
|
|
54
|
+
session: ctx.session || {},
|
|
55
|
+
...fetchParams,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// return the result
|
|
59
|
+
resolve(ctx, {
|
|
60
|
+
fetching: false,
|
|
61
|
+
variables: ctx.variables ?? {},
|
|
62
|
+
data: result.data,
|
|
63
|
+
errors: !result.errors || result.errors.length === 0 ? null : result.errors,
|
|
64
|
+
partial: false,
|
|
65
|
+
stale: false,
|
|
66
|
+
source: DataSource.Network,
|
|
67
|
+
})
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const defaultFetch = (
|
|
74
|
+
url: string,
|
|
75
|
+
params?: Required<ClientPluginContext>['fetchParams']
|
|
76
|
+
): RequestHandler => {
|
|
77
|
+
// if there is no configured url, we can't use this plugin
|
|
78
|
+
if (!url) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
'Could not find configured client url. Please specify one in your HoudiniClient constructor.'
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return async ({ fetch, name, text, variables }) => {
|
|
85
|
+
// regular fetch (Server & Client)
|
|
86
|
+
const result = await fetch(url, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
body: JSON.stringify({ operationName: name, query: text, variables }),
|
|
89
|
+
...params,
|
|
90
|
+
headers: {
|
|
91
|
+
Accept: 'application/graphql+json, application/json',
|
|
92
|
+
'Content-Type': 'application/json',
|
|
93
|
+
...params?.headers,
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// Avoid parsing the response if it's not JSON, as that will throw a SyntaxError
|
|
98
|
+
if (
|
|
99
|
+
!result.ok &&
|
|
100
|
+
!result.headers.get('content-type')?.startsWith('application/json') &&
|
|
101
|
+
!result.headers.get('content-type')?.startsWith('application/graphql+json')
|
|
102
|
+
) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Failed to fetch: server returned invalid response with error ${result.status}: ${result.statusText}`
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return await result.json()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* ## Tip 👇
|
|
114
|
+
*
|
|
115
|
+
* To define types for your metadata, create a file `src/app.d.ts` containing the followingI:
|
|
116
|
+
*
|
|
117
|
+
* ```ts
|
|
118
|
+
* declare namespace App { *
|
|
119
|
+
* interface Metadata {}
|
|
120
|
+
* }
|
|
121
|
+
* ```
|
|
122
|
+
*
|
|
123
|
+
*/
|
|
124
|
+
export type RequestHandlerArgs = FetchContext & FetchParams
|
|
125
|
+
|
|
126
|
+
export type RequestHandler<_Data = any> = (
|
|
127
|
+
args: RequestHandlerArgs
|
|
128
|
+
) => Promise<RequestPayload<_Data>>
|
|
129
|
+
|
|
130
|
+
export type FetchParams = {
|
|
131
|
+
name: string
|
|
132
|
+
text: string
|
|
133
|
+
hash: string
|
|
134
|
+
variables: { [key: string]: any }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function handleMultipart(
|
|
138
|
+
params: FetchParams,
|
|
139
|
+
args: RequestInit | undefined
|
|
140
|
+
): RequestInit | undefined {
|
|
141
|
+
// process any files that could be included
|
|
142
|
+
const { files } = extractFiles({
|
|
143
|
+
variables: params.variables,
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// if there are files in the request
|
|
147
|
+
if (files.size) {
|
|
148
|
+
const req = args
|
|
149
|
+
let headers: Record<string, string> = {}
|
|
150
|
+
|
|
151
|
+
// filters `content-type: application/json` if received by client.ts
|
|
152
|
+
if (req?.headers) {
|
|
153
|
+
const filtered = Object.entries(req?.headers).filter(([key, value]) => {
|
|
154
|
+
return !(
|
|
155
|
+
key.toLowerCase() == 'content-type' && value.toLowerCase() == 'application/json'
|
|
156
|
+
)
|
|
157
|
+
})
|
|
158
|
+
headers = Object.fromEntries(filtered)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// See the GraphQL multipart request spec:
|
|
162
|
+
// https://github.com/jaydenseric/graphql-multipart-request-spec
|
|
163
|
+
const form = new FormData()
|
|
164
|
+
|
|
165
|
+
// if we have a body, just use it.
|
|
166
|
+
if (args && args?.body) {
|
|
167
|
+
form.set('operations', args?.body as string)
|
|
168
|
+
} else {
|
|
169
|
+
form.set(
|
|
170
|
+
'operations',
|
|
171
|
+
JSON.stringify({
|
|
172
|
+
operationName: params.name,
|
|
173
|
+
query: params.text,
|
|
174
|
+
variables: params.variables,
|
|
175
|
+
})
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const map: Record<string, Array<string>> = {}
|
|
180
|
+
|
|
181
|
+
let i = 0
|
|
182
|
+
files.forEach((paths) => {
|
|
183
|
+
map[++i] = paths
|
|
184
|
+
})
|
|
185
|
+
form.set('map', JSON.stringify(map))
|
|
186
|
+
|
|
187
|
+
i = 0
|
|
188
|
+
files.forEach((paths, file) => {
|
|
189
|
+
form.set(`${++i}`, file as Blob, (file as File).name)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
return { ...req, headers, body: form as any }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/// This file contains a modified version of the functions found here: https://github.com/jaydenseric/extract-files/blob/master/extractFiles.mjs
|
|
197
|
+
/// The associated license is at the end of the file (per the project's license agreement)
|
|
198
|
+
|
|
199
|
+
export function isExtractableFile(value: any): value is ExtractableFile {
|
|
200
|
+
return (
|
|
201
|
+
(typeof File !== 'undefined' && value instanceof File) ||
|
|
202
|
+
(typeof Blob !== 'undefined' && value instanceof Blob)
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
type ExtractableFile = File | Blob
|
|
207
|
+
|
|
208
|
+
/** @typedef {import("./isExtractableFile.mjs").default} isExtractableFile */
|
|
209
|
+
|
|
210
|
+
export function extractFiles(value: any) {
|
|
211
|
+
if (!arguments.length) throw new TypeError('Argument 1 `value` is required.')
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Map of values recursed within the input value and their clones, for reusing
|
|
215
|
+
* clones of values that are referenced multiple times within the input value.
|
|
216
|
+
* @type {Map<Cloneable, Clone>}
|
|
217
|
+
*/
|
|
218
|
+
const clones = new Map()
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Extracted files and their object paths within the input value.
|
|
222
|
+
* @type {Extraction<Extractable>["files"]}
|
|
223
|
+
*/
|
|
224
|
+
const files = new Map()
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Recursively clones the value, extracting files.
|
|
228
|
+
*/
|
|
229
|
+
function recurse(value: any, path: string | string[], recursed: Set<any>) {
|
|
230
|
+
if (isExtractableFile(value)) {
|
|
231
|
+
const filePaths = files.get(value)
|
|
232
|
+
|
|
233
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
234
|
+
filePaths ? filePaths.push(path) : files.set(value, [path])
|
|
235
|
+
|
|
236
|
+
return null
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const valueIsList =
|
|
240
|
+
Array.isArray(value) || (typeof FileList !== 'undefined' && value instanceof FileList)
|
|
241
|
+
const valueIsPlainObject = isPlainObject(value)
|
|
242
|
+
|
|
243
|
+
if (valueIsList || valueIsPlainObject) {
|
|
244
|
+
let clone = clones.get(value)
|
|
245
|
+
|
|
246
|
+
const uncloned = !clone
|
|
247
|
+
|
|
248
|
+
if (uncloned) {
|
|
249
|
+
clone = valueIsList
|
|
250
|
+
? []
|
|
251
|
+
: // Replicate if the plain object is an `Object` instance.
|
|
252
|
+
value instanceof /** @type {any} */ Object
|
|
253
|
+
? {}
|
|
254
|
+
: Object.create(null)
|
|
255
|
+
|
|
256
|
+
clones.set(value, /** @type {Clone} */ clone)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!recursed.has(value)) {
|
|
260
|
+
const pathPrefix = path ? `${path}.` : ''
|
|
261
|
+
const recursedDeeper = new Set(recursed).add(value)
|
|
262
|
+
|
|
263
|
+
if (valueIsList) {
|
|
264
|
+
let index = 0
|
|
265
|
+
|
|
266
|
+
// @ts-ignore
|
|
267
|
+
for (const item of value) {
|
|
268
|
+
const itemClone = recurse(item, pathPrefix + index++, recursedDeeper)
|
|
269
|
+
|
|
270
|
+
if (uncloned) /** @type {Array<unknown>} */ clone.push(itemClone)
|
|
271
|
+
}
|
|
272
|
+
} else
|
|
273
|
+
for (const key in value) {
|
|
274
|
+
const propertyClone = recurse(value[key], pathPrefix + key, recursedDeeper)
|
|
275
|
+
|
|
276
|
+
if (uncloned)
|
|
277
|
+
/** @type {Record<PropertyKey, unknown>} */ clone[key] = propertyClone
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return clone
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return value
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
clone: recurse(value, '', new Set()),
|
|
289
|
+
files,
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* An extraction result.
|
|
295
|
+
* @template [Extractable=unknown] Extractable file type.
|
|
296
|
+
* @typedef {object} Extraction
|
|
297
|
+
* @prop {unknown} clone Clone of the original value with extracted files
|
|
298
|
+
* recursively replaced with `null`.
|
|
299
|
+
* @prop {Map<Extractable, Array<ObjectPath>>} files Extracted files and their
|
|
300
|
+
* object paths within the original value.
|
|
301
|
+
*/
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* String notation for the path to a node in an object tree.
|
|
305
|
+
* @typedef {string} ObjectPath
|
|
306
|
+
* @see [`object-path` on npm](https://npm.im/object-path).
|
|
307
|
+
* @example
|
|
308
|
+
* An object path for object property `a`, array index `0`, object property `b`:
|
|
309
|
+
*
|
|
310
|
+
* ```
|
|
311
|
+
* a.0.b
|
|
312
|
+
* ```
|
|
313
|
+
*/
|
|
314
|
+
|
|
315
|
+
function isPlainObject(value: any) {
|
|
316
|
+
if (typeof value !== 'object' || value === null) {
|
|
317
|
+
return false
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const prototype = Object.getPrototypeOf(value)
|
|
321
|
+
return (
|
|
322
|
+
(prototype === null ||
|
|
323
|
+
prototype === Object.prototype ||
|
|
324
|
+
Object.getPrototypeOf(prototype) === null) &&
|
|
325
|
+
!(Symbol.toStringTag in value) &&
|
|
326
|
+
!(Symbol.iterator in value)
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// MIT License
|
|
331
|
+
// Copyright Jayden Seric
|
|
332
|
+
|
|
333
|
+
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
334
|
+
|
|
335
|
+
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
336
|
+
|
|
337
|
+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ClientPlugin, ClientPluginContext } from 'houdini/runtime/documentStore'
|
|
2
|
+
import type { DocumentArtifact } from 'houdini/runtime/types'
|
|
3
|
+
|
|
4
|
+
export type FetchParamFn = (ctx: FetchParamsInput) => Required<ClientPluginContext>['fetchParams']
|
|
5
|
+
|
|
6
|
+
export const fetchParams: (fn?: FetchParamFn) => ClientPlugin =
|
|
7
|
+
(fn = () => ({})) =>
|
|
8
|
+
() => ({
|
|
9
|
+
start(ctx, { next, marshalVariables }) {
|
|
10
|
+
next({
|
|
11
|
+
...ctx,
|
|
12
|
+
fetchParams: fn({
|
|
13
|
+
// most of the stuff comes straight from the context
|
|
14
|
+
config: ctx.config,
|
|
15
|
+
policy: ctx.policy,
|
|
16
|
+
metadata: ctx.metadata,
|
|
17
|
+
session: ctx.session,
|
|
18
|
+
stuff: ctx.stuff,
|
|
19
|
+
// a few fields are renamed or modified
|
|
20
|
+
document: ctx.artifact,
|
|
21
|
+
variables: marshalVariables(ctx),
|
|
22
|
+
text: ctx.text,
|
|
23
|
+
hash: ctx.hash,
|
|
24
|
+
}),
|
|
25
|
+
})
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export type FetchParamsInput = Pick<
|
|
30
|
+
ClientPluginContext,
|
|
31
|
+
'config' | 'policy' | 'variables' | 'metadata' | 'session' | 'stuff'
|
|
32
|
+
> & {
|
|
33
|
+
text: string
|
|
34
|
+
hash: string
|
|
35
|
+
document: DocumentArtifact
|
|
36
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { deepEquals } from 'houdini/runtime'
|
|
2
|
+
import type { Cache } from 'houdini/runtime/cache'
|
|
3
|
+
import { type SubscriptionSpec, ArtifactKind, DataSource } from 'houdini/runtime/types'
|
|
4
|
+
|
|
5
|
+
import { documentPlugin } from './utils'
|
|
6
|
+
|
|
7
|
+
// the purpose of the fragment plugin is to provide fine-reactivity for cache updates
|
|
8
|
+
// there are no network requests that get sent. send() always returns the initial value
|
|
9
|
+
export const fragment = (cache: Cache) =>
|
|
10
|
+
documentPlugin(ArtifactKind.Fragment, function () {
|
|
11
|
+
// track the bits of state we need to hold onto
|
|
12
|
+
let subscriptionSpec: SubscriptionSpec | null = null
|
|
13
|
+
|
|
14
|
+
// we need to track the last parents and variables used so we can re-subscribe
|
|
15
|
+
let lastReference: { parent: string; variables: any } | null = null
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
// establish the cache subscription
|
|
19
|
+
start(ctx, { next, resolve, variablesChanged, marshalVariables }) {
|
|
20
|
+
// if there's no parent id, there's nothing to do
|
|
21
|
+
if (!ctx.stuff.parentID) {
|
|
22
|
+
return next(ctx)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// the object describing the current parent reference
|
|
26
|
+
const currentReference = {
|
|
27
|
+
parent: ctx.stuff.parentID,
|
|
28
|
+
variables: marshalVariables(ctx),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// if the variables have changed we need to setup a new subscription with the cache
|
|
32
|
+
if (
|
|
33
|
+
!ctx.cacheParams?.disableSubscriptions &&
|
|
34
|
+
(!deepEquals(lastReference, currentReference) || variablesChanged(ctx))
|
|
35
|
+
) {
|
|
36
|
+
// if the variables changed we need to unsubscribe from the old fields and
|
|
37
|
+
// listen to the new ones
|
|
38
|
+
if (subscriptionSpec) {
|
|
39
|
+
cache.unsubscribe(subscriptionSpec, subscriptionSpec.variables?.() || {})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// we need to subscribe with the marshaled variables
|
|
43
|
+
const variables = marshalVariables(ctx)
|
|
44
|
+
|
|
45
|
+
// save the new subscription spec
|
|
46
|
+
subscriptionSpec = {
|
|
47
|
+
rootType: ctx.artifact.rootType,
|
|
48
|
+
selection: ctx.artifact.selection,
|
|
49
|
+
variables: () => variables,
|
|
50
|
+
parentID: ctx.stuff.parentID,
|
|
51
|
+
set: (newValue) => {
|
|
52
|
+
resolve(ctx, {
|
|
53
|
+
data: newValue,
|
|
54
|
+
errors: null,
|
|
55
|
+
fetching: false,
|
|
56
|
+
partial: false,
|
|
57
|
+
stale: false,
|
|
58
|
+
source: DataSource.Cache,
|
|
59
|
+
variables,
|
|
60
|
+
})
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// make sure we subscribe to the new values
|
|
65
|
+
cache.subscribe(subscriptionSpec, variables)
|
|
66
|
+
|
|
67
|
+
lastReference = currentReference
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// we're done
|
|
71
|
+
next(ctx)
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
cleanup() {
|
|
75
|
+
if (subscriptionSpec) {
|
|
76
|
+
cache.unsubscribe(subscriptionSpec, subscriptionSpec.variables?.())
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './fetch'
|
|
2
|
+
export * from './cache'
|
|
3
|
+
export * from './query'
|
|
4
|
+
export * from './fragment'
|
|
5
|
+
export * from './mutation'
|
|
6
|
+
export * from './subscription'
|
|
7
|
+
export * from './throwOnError'
|
|
8
|
+
export * from './fetchParams'
|
|
9
|
+
export { optimisticKeys } from './optimisticKeys'
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// the actual contents of this file will be overwritten by the runtime generator
|
|
2
|
+
// to include imports from plugins so that they can register middlewares as
|
|
3
|
+
// part of the generation pipeline
|
|
4
|
+
import type { ClientPlugin } from 'houdini/runtime/documentStore'
|
|
5
|
+
import type { NestedList } from 'houdini/runtime/types'
|
|
6
|
+
|
|
7
|
+
const plugins: NestedList<ClientPlugin> = []
|
|
8
|
+
|
|
9
|
+
export default plugins
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { marshalSelection } from 'houdini/runtime'
|
|
2
|
+
import type { SubscriptionSpec } from 'houdini/runtime'
|
|
3
|
+
import { ArtifactKind } from 'houdini/runtime'
|
|
4
|
+
import type { Cache } from 'houdini/runtime/cache'
|
|
5
|
+
|
|
6
|
+
import { documentPlugin } from './utils'
|
|
7
|
+
|
|
8
|
+
export const mutation = (cache: Cache) =>
|
|
9
|
+
documentPlugin(ArtifactKind.Mutation, () => {
|
|
10
|
+
return {
|
|
11
|
+
async start(ctx, { next, marshalVariables }) {
|
|
12
|
+
// treat a mutation like it has an optimistic layer regardless of
|
|
13
|
+
// whether there actually _is_ one. This ensures that a query which fires
|
|
14
|
+
// after this mutation has been sent will overwrite any return values from the mutation
|
|
15
|
+
//
|
|
16
|
+
// as far as I can tell, this is an arbitrary decision but it does give a
|
|
17
|
+
// well-defined ordering to a subtle situation so that seems like a win
|
|
18
|
+
const layerOptimistic = cache._internal_unstable.storage.createLayer(true)
|
|
19
|
+
|
|
20
|
+
// if there is an optimistic response then we need to write the value immediately
|
|
21
|
+
|
|
22
|
+
// hold onto the list of subscribers that we updated because of the optimistic response
|
|
23
|
+
// and make sure they are included in the final set of subscribers to notify
|
|
24
|
+
let toNotify: SubscriptionSpec[] = []
|
|
25
|
+
|
|
26
|
+
// the optimistic response gets passed in the context's stuff bag
|
|
27
|
+
const optimisticResponse = ctx.stuff.optimisticResponse
|
|
28
|
+
if (optimisticResponse) {
|
|
29
|
+
toNotify = cache.write({
|
|
30
|
+
selection: ctx.artifact.selection,
|
|
31
|
+
// make sure that any scalar values get processed into something we can cache
|
|
32
|
+
data: (await marshalSelection({
|
|
33
|
+
selection: ctx.artifact.selection,
|
|
34
|
+
data: optimisticResponse,
|
|
35
|
+
config: ctx.config,
|
|
36
|
+
}))!,
|
|
37
|
+
variables: marshalVariables(ctx),
|
|
38
|
+
layer: layerOptimistic.id,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// update cacheParams
|
|
43
|
+
ctx.cacheParams = {
|
|
44
|
+
...ctx.cacheParams,
|
|
45
|
+
// write to the mutation's layer
|
|
46
|
+
layer: layerOptimistic,
|
|
47
|
+
// notify any subscribers that we updated with the optimistic response
|
|
48
|
+
// in order to address situations where the optimistic update was wrong
|
|
49
|
+
notifySubscribers: toNotify,
|
|
50
|
+
// make sure that we notify subscribers for any values that we compare
|
|
51
|
+
// in order to address any race conditions when comparing the previous value
|
|
52
|
+
forceNotify: true,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// make sure we write to the correct layer in the cache
|
|
56
|
+
next(ctx)
|
|
57
|
+
},
|
|
58
|
+
afterNetwork(ctx, { resolve }) {
|
|
59
|
+
// before the cache sees the data, we need to clear the layer
|
|
60
|
+
if (ctx.cacheParams?.layer) {
|
|
61
|
+
cache.clearLayer(ctx.cacheParams.layer.id)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// we're done
|
|
65
|
+
resolve(ctx)
|
|
66
|
+
},
|
|
67
|
+
end(ctx, { resolve, value }) {
|
|
68
|
+
const hasErrors = value.errors && value.errors.length > 0
|
|
69
|
+
// if there are errors, we need to clear the layer before resolving
|
|
70
|
+
if (hasErrors) {
|
|
71
|
+
// if the mutation failed, roll the layer back and delete it
|
|
72
|
+
if (ctx.cacheParams?.layer) {
|
|
73
|
+
cache.clearLayer(ctx.cacheParams.layer.id)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// merge the layer back into the cache
|
|
78
|
+
if (ctx.cacheParams?.layer) {
|
|
79
|
+
cache._internal_unstable.storage.resolveLayer(ctx.cacheParams.layer.id)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// keep going
|
|
83
|
+
resolve(ctx)
|
|
84
|
+
},
|
|
85
|
+
catch(ctx, { error }) {
|
|
86
|
+
// if there was an error, we need to clear the mutation
|
|
87
|
+
if (ctx.cacheParams?.layer) {
|
|
88
|
+
const { layer } = ctx.cacheParams
|
|
89
|
+
|
|
90
|
+
// if the mutation failed, roll the layer back and delete it
|
|
91
|
+
cache.clearLayer(layer.id)
|
|
92
|
+
cache._internal_unstable.storage.resolveLayer(layer.id)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
throw error
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
})
|