Skip to content

Commit 9cba068

Browse files
authored
Merge pull request #239 from gadget-inc/live-queries
`@live` Queries
2 parents f95b7c9 + aca3e95 commit 9cba068

18 files changed

+771
-37
lines changed

packages/api-client-core/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
"prerelease": "gitpkg publish"
2929
},
3030
"dependencies": {
31+
"@n1ru4l/graphql-live-query": "^0.10.0",
32+
"@n1ru4l/graphql-live-query-patch-jsondiffpatch": "^0.8.0",
33+
"@n1ru4l/push-pull-async-iterable-iterator": "^3.2.0",
3134
"@urql/core": "^4.0.10",
3235
"cross-fetch": "^3.1.5",
3336
"graphql": "^16.8.1",

packages/api-client-core/spec/mockUrqlClient.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Client, GraphQLRequest, OperationContext, OperationResult, OperationResultSource } from "@urql/core";
22
import { createRequest, makeErrorResult } from "@urql/core";
3-
import type { DocumentNode, OperationDefinitionNode } from "graphql";
3+
import type { DocumentNode, ExecutionResult, OperationDefinitionNode } from "graphql";
4+
import type { SubscribePayload, Client as SubscriptionClient, Sink as SubscriptionSink } from "graphql-ws";
45
import { find, findLast } from "lodash";
56
import { act } from "react-dom/test-utils";
67
import type { Sink, Source, Subject } from "wonka";
@@ -168,7 +169,50 @@ export const createMockUrqlClient = (assertions?: {
168169
} as MockUrqlClient;
169170
};
170171

172+
export interface MockSubscription {
173+
payload: SubscribePayload;
174+
sink: SubscriptionSink<ExecutionResult<any, any>>;
175+
push: (result: ExecutionResult<any, any>) => void;
176+
disposed: boolean;
177+
}
178+
export type MockSubscribeFn = ((payload: SubscribePayload, sink: SubscriptionSink<ExecutionResult<any, any>>) => () => void) & {
179+
subscriptions: MockSubscription[];
180+
};
181+
182+
export interface MockGraphQLWSClient extends SubscriptionClient {
183+
subscribe: MockSubscribeFn;
184+
}
185+
186+
/**
187+
* Create a new function for mocking subscriptions passed to graphql-ws
188+
*/
189+
function newMockSubscribeFn(): MockSubscribeFn {
190+
const subscriptions: MockSubscription[] = [];
191+
192+
const fn: SubscriptionClient["subscribe"] = (payload: SubscribePayload, sink: SubscriptionSink<ExecutionResult<any, any>>) => {
193+
const subscription: MockSubscription = {
194+
payload,
195+
sink,
196+
disposed: false,
197+
push: (result) => {
198+
act(() => {
199+
sink.next(result);
200+
});
201+
},
202+
};
203+
204+
subscriptions.push(subscription);
205+
206+
return () => {
207+
subscription.disposed = true;
208+
};
209+
};
210+
211+
return Object.assign(fn, { subscriptions });
212+
}
213+
171214
export const mockUrqlClient = createMockUrqlClient();
215+
export const mockGraphQLWSClient = {} as MockGraphQLWSClient;
172216

173217
beforeEach(() => {
174218
const fetch = newMockFetchFn();
@@ -180,6 +224,8 @@ beforeEach(() => {
180224
fetch,
181225
};
182226
mockUrqlClient.mockFetch = fetch;
227+
228+
mockGraphQLWSClient.subscribe = newMockSubscribeFn();
183229
});
184230

185231
afterEach(() => {

packages/api-client-core/spec/operationBuilders.spec.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,27 @@ describe("operation builders", () => {
4242
}
4343
`);
4444
});
45+
46+
test("findOneOperation should build a live query for a model", () => {
47+
expect(findOneOperation("widget", "123", { __typename: true, id: true, state: true }, "widget", { live: true }))
48+
.toMatchInlineSnapshot(`
49+
{
50+
"query": "query widget($id: GadgetID!) @live {
51+
widget(id: $id) {
52+
__typename
53+
id
54+
state
55+
}
56+
gadgetMeta {
57+
hydrations(modelName: "widget")
58+
}
59+
}",
60+
"variables": {
61+
"id": "123",
62+
},
63+
}
64+
`);
65+
});
4566
});
4667

4768
describe("findManyOperation", () => {
@@ -209,6 +230,35 @@ describe("operation builders", () => {
209230
}
210231
`);
211232
});
233+
234+
test("findManyOperation should build a live findMany query for a model", () => {
235+
expect(findManyOperation("widgets", { __typename: true, id: true, state: true }, "widget", { live: true })).toMatchInlineSnapshot(`
236+
{
237+
"query": "query widgets($after: String, $first: Int, $before: String, $last: Int) @live {
238+
widgets(after: $after, first: $first, before: $before, last: $last) {
239+
pageInfo {
240+
hasNextPage
241+
hasPreviousPage
242+
startCursor
243+
endCursor
244+
}
245+
edges {
246+
cursor
247+
node {
248+
__typename
249+
id
250+
state
251+
}
252+
}
253+
}
254+
gadgetMeta {
255+
hydrations(modelName: "widget")
256+
}
257+
}",
258+
"variables": {},
259+
}
260+
`);
261+
});
212262
});
213263

214264
describe("findOneByFieldOperation", () => {
@@ -287,6 +337,43 @@ describe("operation builders", () => {
287337
}
288338
`);
289339
});
340+
341+
test("findOneByFieldOperation should build a live query", () => {
342+
expect(findOneByFieldOperation("widget", "foo", "bar", { __typename: true, id: true, state: true }, "widget", { live: true }))
343+
.toMatchInlineSnapshot(`
344+
{
345+
"query": "query widget($after: String, $first: Int, $before: String, $last: Int, $filter: [WidgetFilter!]) @live {
346+
widget(after: $after, first: $first, before: $before, last: $last, filter: $filter) {
347+
pageInfo {
348+
hasNextPage
349+
hasPreviousPage
350+
startCursor
351+
endCursor
352+
}
353+
edges {
354+
cursor
355+
node {
356+
__typename
357+
id
358+
state
359+
}
360+
}
361+
}
362+
gadgetMeta {
363+
hydrations(modelName: "widget")
364+
}
365+
}",
366+
"variables": {
367+
"filter": {
368+
"foo": {
369+
"equals": "bar",
370+
},
371+
},
372+
"first": 2,
373+
},
374+
}
375+
`);
376+
});
290377
});
291378

292379
describe("actionOperation", () => {

packages/api-client-core/src/GadgetConnection.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
/* eslint-disable @typescript-eslint/no-non-null-assertion */
2+
import { isLiveQueryOperationDefinitionNode } from "@n1ru4l/graphql-live-query";
3+
import { applyLiveQueryJSONDiffPatch } from "@n1ru4l/graphql-live-query-patch-jsondiffpatch";
4+
import { applyAsyncIterableIteratorToSink, makeAsyncIterableIteratorFromSink } from "@n1ru4l/push-pull-async-iterable-iterator";
25
import type { ClientOptions, RequestPolicy } from "@urql/core";
36
import { Client, cacheExchange, fetchExchange, subscriptionExchange } from "@urql/core";
4-
57
import type { ExecutionResult } from "graphql";
68
import type { Sink, Client as SubscriptionClient, ClientOptions as SubscriptionClientOptions } from "graphql-ws";
79
import { CloseCode, createClient as createSubscriptionClient } from "graphql-ws";
@@ -80,7 +82,9 @@ export class GadgetConnection {
8082

8183
// the base client using HTTP requests that non-transactional operations will use
8284
private baseClient: Client;
83-
private baseSubscriptionClient?: SubscriptionClient;
85+
86+
/** @private (but accessible for testing purposes) */
87+
baseSubscriptionClient?: SubscriptionClient;
8488

8589
// the transactional websocket client that will be used inside a transaction block
8690
private currentTransaction: GadgetTransaction | null = null;
@@ -341,10 +345,9 @@ export class GadgetConnection {
341345
if (typeof window != "undefined") {
342346
exchanges.push(cacheExchange);
343347
}
344-
345348
exchanges.push(
346349
...this.exchanges.beforeAsync,
347-
fetchExchange,
350+
// standard subscriptions for normal GraphQL subscriptions
348351
subscriptionExchange({
349352
forwardSubscription: (request) => {
350353
return {
@@ -359,6 +362,31 @@ export class GadgetConnection {
359362
};
360363
},
361364
}),
365+
// another subscription exchange for live queries
366+
// live queries pass through the same WS client, but use jsondiffs for patching in results
367+
subscriptionExchange({
368+
isSubscriptionOperation: (request) => {
369+
return request.query.definitions.some((definition) => isLiveQueryOperationDefinitionNode(definition, request.variables as any));
370+
},
371+
forwardSubscription: (request) => {
372+
return {
373+
subscribe: (sink) => {
374+
const input = { ...request, query: request.query || "" };
375+
return {
376+
unsubscribe: applyAsyncIterableIteratorToSink(
377+
applyLiveQueryJSONDiffPatch(
378+
makeAsyncIterableIteratorFromSink<ExecutionResult>((sink) =>
379+
this.getBaseSubscriptionClient().subscribe(input, sink as Sink<ExecutionResult>)
380+
)
381+
),
382+
sink
383+
),
384+
};
385+
},
386+
};
387+
},
388+
}),
389+
fetchExchange,
362390
...this.exchanges.afterAll
363391
);
364392

packages/api-client-core/src/operationBuilders.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { FieldSelection as BuilderFieldSelection, BuilderOperation, Variabl
22
import { Call, Var, compileWithVariableValues } from "tiny-graphql-query-compiler";
33
import type { FieldSelection } from "./FieldSelection.js";
44
import { filterTypeName, sortTypeName } from "./support.js";
5-
import type { FindManyOptions, SelectionOptions, VariablesOptions } from "./types.js";
5+
import type { BaseFindOptions, FindManyOptions, VariablesOptions } from "./types.js";
66

77
const hydrationOptions = (modelApiIdentifier: string): BuilderFieldSelection => {
88
return {
@@ -23,12 +23,17 @@ const fieldSelectionToQueryCompilerFields = (selection: FieldSelection, includeT
2323

2424
export type FindFirstPaginationOptions = Omit<FindManyOptions, "first" | "last" | "before" | "after">;
2525

26+
const directivesForOptions = (options?: BaseFindOptions | null) => {
27+
if (options?.live) return ["@live"];
28+
return undefined;
29+
};
30+
2631
export const findOneOperation = (
2732
operation: string,
2833
id: string | undefined,
2934
defaultSelection: FieldSelection,
3035
modelApiIdentifier: string,
31-
options?: SelectionOptions | null
36+
options?: BaseFindOptions | null
3237
) => {
3338
const variables: Record<string, Variable> = {};
3439
if (typeof id !== "undefined") variables.id = Var({ type: "GadgetID!", value: id });
@@ -39,6 +44,7 @@ export const findOneOperation = (
3944
[operation]: Call(variables, fieldSelectionToQueryCompilerFields(options?.select || defaultSelection, true)),
4045
...hydrationOptions(modelApiIdentifier),
4146
},
47+
directives: directivesForOptions(options),
4248
});
4349
};
4450

@@ -48,10 +54,10 @@ export const findOneByFieldOperation = (
4854
fieldValue: string,
4955
defaultSelection: FieldSelection,
5056
modelApiIdentifier: string,
51-
options?: SelectionOptions | null
57+
options?: BaseFindOptions | null
5258
) => {
5359
return findManyOperation(operation, defaultSelection, modelApiIdentifier, {
54-
select: options?.select,
60+
...options,
5561
first: 2,
5662
filter: {
5763
[fieldName]: {
@@ -91,6 +97,7 @@ export const findManyOperation = (
9197
),
9298
...hydrationOptions(modelApiIdentifier),
9399
},
100+
directives: directivesForOptions(options),
94101
});
95102
};
96103

@@ -115,7 +122,7 @@ export const actionOperation = (
115122
modelApiIdentifier: string,
116123
modelSelectionField: string,
117124
variables: VariablesOptions,
118-
options?: SelectionOptions | null,
125+
options?: BaseFindOptions | null,
119126
namespace?: string | null,
120127
isBulkAction?: boolean | null,
121128
hasReturnType?: boolean | null
@@ -144,12 +151,18 @@ export const actionOperation = (
144151
...fields,
145152
...hydrationOptions(modelApiIdentifier),
146153
},
154+
directives: directivesForOptions(options),
147155
};
148156

149157
return compileWithVariableValues(actionOperation);
150158
};
151159

152-
export const globalActionOperation = (operation: string, variables: VariablesOptions, namespace?: string | null) => {
160+
export const globalActionOperation = (
161+
operation: string,
162+
variables: VariablesOptions,
163+
namespace?: string | null,
164+
options?: { live?: boolean }
165+
) => {
153166
let fields: BuilderFieldSelection = {
154167
[operation]: Call(variableOptionsToVariables(variables), {
155168
success: true,
@@ -170,5 +183,6 @@ export const globalActionOperation = (operation: string, variables: VariablesOpt
170183
type: "mutation",
171184
name: operation,
172185
fields,
186+
directives: directivesForOptions(options),
173187
});
174188
};

0 commit comments

Comments
 (0)