@zeroin.earth/appwrite-graphql 22.4.1 → 23.0.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/README.md CHANGED
@@ -1,6 +1,36 @@
1
1
  # Appwrite GraphQL
2
+ ![Static Badge](https://img.shields.io/badge/coverage-95%25-brightgreen) ![NPM Version](https://img.shields.io/npm/v/%40zeroin.earth%2Fappwrite-graphql) ![Static Badge](https://img.shields.io/badge/appwrite-v1.8.1-%23FD366E)
2
3
 
3
- This is a GraphQL library for Appwrite, built with the power of [@tanstack/react-query](https://github.com/TanStack/query) and inspired by [react-appwrite](https://github.com/react-appwrite/react-appwrite).
4
+ Appwrite is an open source, BaaS in the same vein as Supabase and Firebase, but geared more toward self-hosting.
5
+
6
+ This is a fully featured GraphQL library built with [@tanstack/react-query](https://github.com/TanStack/query) on top of the Appwrite web SDK and is fully typed. Think of this library as the abstract wrapper you would have made yourself, but we already did it for you.
7
+
8
+ ## Getting Started
9
+ - [Installation](#installation)
10
+ - [Basic Usage](#usage)
11
+
12
+ ## Features
13
+
14
+ - Dual build for both React and React Native
15
+ - Full Appwrite SDK v23 parity using React hooks
16
+ - [Optimistic Mutations](#optimistic-mutations)
17
+ - Documents only
18
+ - [Query Caching](#query-caching)
19
+ - [QueryKey Builder](#querykey-builder)
20
+ - [Offline-first Support](#offline-first-support)
21
+ - [Built-in Offline Persisters](#built-in-offline-persisters) (localStorage, AsyncStorage)
22
+ - [Custom Offline Persister Support](#custom-offline-persister-support)
23
+ - [Conflict Resolution](#conflict-resolution)
24
+ - [SSR Support](#ssr-support)
25
+ - [Field Selection](#field-selection)
26
+ - [Suspense Queries](#suspense-queries)
27
+ - Documents
28
+ - Collections
29
+ - [Pagination Hooks](#pagination-hooks)
30
+ - Standard Pagination
31
+ - Infinite Scroll
32
+ - [Appwrite QueryBuilder](#appwrite-querybuilder)
33
+ - [React Query Devtools Support](#react-query-devtools-support)
4
34
 
5
35
  ## Installation
6
36
 
@@ -10,39 +40,555 @@ npm install --save @zeroin.earth/appwrite-graphql
10
40
  bun add @zeroin.earth/appwrite-graphql
11
41
  ```
12
42
 
43
+ ### Peer Dependencies
44
+
45
+ - `react` - `^19.1.0`
46
+ - `appwrite` - `^23.0.0`
47
+ - `@tanstack/react-query` - `^5.70.0`
48
+
49
+ **React Native:**
50
+
51
+ - `@react-native-async-storage/async-storage`
52
+ - `@react-native-community/netinfo`
53
+ - `react-native-appwrite`
54
+
13
55
  ## Usage
14
56
 
15
- ### Set up
16
- You must provide the Appwrite URL and Project ID as environment variables. It does not matter how they are provided as long as they can be accessed from `process.env.`:
57
+ ### Provider
58
+
59
+ The library is designed to use a single wrapper, `<AppwriteProvider>`. There are multiple ways you can configure the wrapper based on your app's needs:
60
+
61
+ 1. Basic (no offline-first support) - React
62
+
63
+ ```tsx
64
+ import {
65
+ AppwriteProvider,
66
+ createAppwriteClient
67
+ } from '@zeroin.earth/appwrite-graphql'
17
68
 
18
- ```js
19
- /* Endpoint - Pick one */
20
- APPWRITE_ENDPOINT=
21
- NEXT_PUBLIC_APPWRITE_URL=
22
- EXPO_PUBLIC_APPWRITE_URL=
69
+ const client = createAppwriteClient({
70
+ endpoint: 'https://cloud.appwrite.io/v1',
71
+ projectId: 'my-project',
72
+ })
23
73
 
24
- /* Project ID - Pick one */
25
- APPWRITE_PROJECT_ID=
26
- NEXT_PUBLIC_APPWRITE_PROJECT_ID
27
- EXPO_PUBLIC_APPWRITE_PROJECT_ID
74
+ function App() {
75
+ return (
76
+ <AppwriteProvider client={client}>
77
+ {/* your app */}
78
+ </AppwriteProvider>
79
+ )
80
+ }
28
81
  ```
29
82
 
30
- ### Provider
31
- If you need to provide a custom endpoint and project ID, and can't use one of the above environment variables, you may override the default variables using the `<AppwriteProvider>`:
83
+ 2. Offline-first - React
84
+
85
+ ```tsx
86
+ import {
87
+ AppwriteProvider,
88
+ createOfflineClient,
89
+ webNetworkAdapter,
90
+ } from '@zeroin.earth/appwrite-graphql'
91
+
92
+ const { appwrite, queryClient, persister } = createOfflineClient({
93
+ endpoint: 'https://cloud.appwrite.io/v1',
94
+ projectId: 'my-project',
95
+ storage: localStorage, // or any AsyncStorage-compatible interface
96
+ networkAdapter: webNetworkAdapter(),
97
+ })
32
98
 
33
- ```jsx
34
- <AppwriteProvider endpoint="https://api.example.com/v1" projectId="jhkeri4889dfg7fg78f7g">
35
- <App />
36
- </AppwriteProvider>
99
+ function App() {
100
+ return (
101
+ <AppwriteProvider
102
+ client={appwrite}
103
+ queryClient={queryClient}
104
+ persister={persister}
105
+ onCacheRestored={() => console.log('Cache restored mutations replayed')}
106
+ >
107
+ {/* your app */}
108
+ </AppwriteProvider>
109
+ )
110
+ }
37
111
  ```
38
112
 
39
- ### Hooks
113
+ 3. Offline-first - React Native
114
+
115
+ ```tsx
116
+ import AsyncStorage from '@react-native-async-storage/async-storage'
117
+ import {
118
+ AppwriteProvider,
119
+ createOfflineClient,
120
+ } from '@zeroin.earth/appwrite-graphql'
121
+
122
+ import {
123
+ reactNativeNetworkAdapter
124
+ } from '@zeroin.earth/appwrite-graphql/react-native'
125
+
126
+ const { appwrite, queryClient, persister } = createOfflineClient({
127
+ endpoint: 'https://cloud.appwrite.io/v1',
128
+ projectId: 'my-project',
129
+ storage: AsyncStorage,
130
+ networkAdapter: reactNativeNetworkAdapter(),
131
+ })
132
+
133
+ function App() {
134
+ return (
135
+ <AppwriteProvider
136
+ client={appwrite}
137
+ queryClient={queryClient}
138
+ persister={persister}
139
+ >
140
+ {/* your app */}
141
+ </AppwriteProvider>
142
+ )
143
+ }
144
+ ```
145
+
146
+ 4. Offline-first - React with custom persister
147
+
148
+ ```tsx
149
+ import {
150
+ AppwriteProvider,
151
+ createOfflineClient,
152
+ webNetworkAdapter,
153
+ type Persister,
154
+ } from '@zeroin.earth/appwrite-graphql'
155
+
156
+ const myPersister: Persister = {
157
+ persistClient: async (client) => { /* write to your storage */ },
158
+ restoreClient: async () => { /* read from your storage */ },
159
+ removeClient: async () => { /* clear your storage */ },
160
+ }
161
+
162
+ const { appwrite, queryClient, persister } = createOfflineClient({
163
+ endpoint: 'https://cloud.appwrite.io/v1',
164
+ projectId: 'my-project',
165
+ persister: myPersister,
166
+ networkAdapter: webNetworkAdapter(),
167
+ })
168
+
169
+ function App() {
170
+ return (
171
+ <AppwriteProvider
172
+ client={appwrite}
173
+ queryClient={queryClient}
174
+ persister={persister}
175
+ >
176
+ {/* your app */}
177
+ </AppwriteProvider>
178
+ )
179
+ }
180
+ ```
181
+
182
+ 5. Offline - Imperative / non-React
183
+
184
+ ```tsx
185
+ import {
186
+ createOfflineClient,
187
+ webNetworkAdapter,
188
+ } from '@zeroin.earth/appwrite-graphql'
189
+
190
+ const client = createOfflineClient({
191
+ endpoint: 'https://cloud.appwrite.io/v1',
192
+ projectId: 'my-project',
193
+ storage: localStorage,
194
+ networkAdapter: webNetworkAdapter(),
195
+ })
196
+
197
+ // Start persistence — restores cache from storage, subscribes to
198
+ // future changes, and replays paused mutations once restored.
199
+ const { unsubscribe, restored } = client.startPersistence()
200
+
201
+ await restored
202
+ console.log('Cache restored, paused mutations replayed')
203
+
204
+ // Use client.queryClient and client.appwrite directly
205
+ // ...
206
+ // Cleanup when done
207
+
208
+ unsubscribe()
209
+ ```
210
+
211
+ ## Optimistic Mutations
212
+
213
+ For this first iteration, the project provides optimistic mutations for document-related mutation hooks: `useUpdateDocument`, `useUpsertDocument`, `useIncrementAttribute`, `useDecrementAttribute`, and `useDeleteDocument`. Optimistic Mutations can easily be added to other hooks. We wanted to make sure the most important ones were covered first.
214
+
215
+ Optimistic mutations allow us to update the query cache while the mutation is in-flight, giving us the illusion of immediate updates without waiting for a server response. If the server update fails for any reason, the optimistic update is reverted to prevent incorrect data from being displayed.
216
+
217
+ ## Query Caching
218
+
219
+ All queries are assigned a unique queryKey and provide the developer with access to the underlying `staleTime` property.
220
+
221
+ ```tsx
222
+ type Person = {
223
+ name: string
224
+ age: number
225
+ }
226
+
227
+ const person = useDocument<Person>(
228
+ {
229
+ databaseId: 'db1',
230
+ collectionId: 'col1',
231
+ documentId: 'doc1',
232
+ fields: ['name', 'age'],
233
+ },
234
+ {
235
+ staleTime: 1000 * 60, // 1 minute
236
+ },
237
+ )
238
+ ```
239
+
240
+ ### QueryKey Builder
241
+
242
+ During development, we started getting annoyed with keeping our query keys straight, so we built a factory you can use to perform manual cache eviction. We tried to keep the pattern as close to Appwrite's `Channels` as possible.
243
+
244
+ ```tsx
245
+ import { Keys } from "@zeroin.earth/appwrite-graphql";
246
+
247
+ const queryKey = Keys.database(databaseId)
248
+ .collection(collectionId)
249
+ .document(documentId)
250
+ .key()
251
+ ```
252
+
253
+ ## Offline-first Support
254
+
255
+ We wanted to give developers the freedom to build projects that didn't require continual internet connectivity. Using React Query's `offlineFirst` network modes, we built in a way for mutations to queue up and replay in order once the device reconnects to the internet. We then built an offline client to wrap it all together.
256
+
257
+ ### Built-in Offline Persisters
258
+
259
+ With mutations queuing up, we needed to persist them in the event of an online connection being days away, rather than just a temporary outage. Enter the persisters. The library comes with two out-of-the-box options: localStorage and AsyncStorage, depending on what you're building.
260
+
261
+ ```tsx
262
+ const { appwrite, queryClient, persister } = createOfflineClient({
263
+ endpoint: 'https://cloud.appwrite.io/v1',
264
+ projectId: 'my-project',
265
+ storage: localStorage, // or any AsyncStorage-compatible interface
266
+ networkAdapter: webNetworkAdapter(),
267
+ })
268
+ ```
269
+
270
+ The above example will serialize the mutations to localStorage after a set `throttleTime` elapses (defaults to 1000ms). Once the `networkAdpater` detects the device is online, the serialized mutations will instantly start replaying in order.
271
+
272
+ ### Custom Offline Persister Support
273
+
274
+ Sometimes you want to bring your own persister, or just don't want to use localStorage or AsyncStorage. For this, you can build your own.
275
+
276
+ ### Conflict Resolution
277
+
278
+ While in offline-first mode, there will be times when an update can happen on the server without your device knowing about it, and the device will push its own mutation once it comes back online. To handle this, we have built in 3 conflict resolution paths and allowed the developer to bring their own if needed.
279
+
280
+ ```tsx
281
+ createOfflineClient({
282
+ endpoint: 'https://cloud.appwrite.io/v1',
283
+ projectId: 'my-project',
284
+ storage: localStorage,
285
+ networkAdapter: webNetworkAdapter(),
286
+ conflictStrategy: 'last-write-wins'
287
+ })
288
+ ```
289
+
290
+ **last-write-wins**: This is the default behavior, where whatever is in the most recent mutation is what is applied to the database, regardless of when and where it came from. This is also the default behavior of Appwrite.
291
+
292
+ **server-wins**: If the record was changed on the server and differs from the version cached locally on the device, the replayed mutation is dropped, preserving the server's version.
293
+
294
+ **merge-shallow**: A remote copy is pulled from the server, changes between the remote and local copies are identified, and the changes are merged into a final copy, giving precedence to fields that were updated on the server if both copies changed the same field.
295
+
296
+ **custom**: A custom resolver function can be supplied with the following type:
297
+
298
+ ```tsx
299
+ conflictStrategy: ((context: ConflictContext) => Record<string, string | number | boolean | null> | 'abort')
300
+ ```
301
+
302
+ - `abort` signals to drop the replaying mutation and change nothing.
303
+
304
+ ## SSR Support
305
+
306
+ We have exposed 3 of the most used queries Appwrite surfaces to be used in SSR preFetchQuery calls. This allows you to prefetch a page's content server-side:
307
+
308
+ - `getAccountQuery`
309
+ - `getDocumentQuery`
310
+ - `getCollectionQuery`
311
+
312
+ ```tsx
313
+ import * as React from "react";
314
+ import {
315
+ dehydrate,
316
+ HydrationBoundary,
317
+ QueryClient,
318
+ } from "@tanstack/react-query";
319
+
320
+ import { createAppwriteClient, useCollection } from "./";
321
+ import { getCollectionQuery } from "./";
322
+
323
+ type PostType = {
324
+ title: string;
325
+ image: string;
326
+ description: string;
327
+ };
328
+
329
+ // This could also be getServerSideProps
330
+ export async function getStaticProps() {
331
+ const appwriteClient = createAppwriteClient({
332
+ endpoint: "https://example.com/v1",
333
+ projectId: "project-id",
334
+ });
40
335
 
41
- ```jsx
336
+ const queryClient = new QueryClient();
337
+
338
+ // Perform the prefetching of the collection query on the server.
339
+ await queryClient.prefetchQuery(
340
+ getCollectionQuery<PostType>(appwriteClient, {
341
+ databaseId: "db1",
342
+ collectionId: "col1",
343
+ fields: ["title", "image", "description"],
344
+ }),
345
+ );
346
+
347
+ // Dehydrate the query client state and pass it as a
348
+ // prop to the page component.
349
+ return {
350
+ props: {
351
+ dehydratedState: dehydrate(queryClient),
352
+ },
353
+ };
354
+ }
355
+
356
+ function Posts() {
357
+ // Since we prefetched the data on the server, this will use the
358
+ // cached data and not trigger a network request. If the cache is empty,
359
+ // it will fetch the data from the Appwrite server normally.
360
+ const { data } = useCollection<PostType>({
361
+ databaseId: "db1",
362
+ collectionId: "col1",
363
+ fields: ["title", "image", "description"],
364
+ });
365
+
366
+ return (
367
+ <div>
368
+ {data?.documents?.map((post) => (
369
+ <div key={post.$id}>
370
+ <h2>{post.title}</h2>
371
+ <img src={post.image} alt={post.title} />
372
+ <p>{post.description}</p>
373
+ </div>
374
+ ))}
375
+ </div>
376
+ );
377
+ }
378
+
379
+ // The dehydrated state from the server is passed to the HydrationBoundary,
380
+ // which allows the client-side React Query to rehydrate and use the prefetched
381
+ // data without making an additional network request.
382
+ export default function PostsRoute({ dehydratedState }) {
383
+ return (
384
+ <HydrationBoundary state={dehydratedState}>
385
+ <Posts />
386
+ </HydrationBoundary>
387
+ );
388
+ }
389
+ ```
390
+
391
+ ## Field Selection
392
+
393
+ The most used query hooks allow you to specify the fields returned by Appwrite to prevent over-fetching. These fields select out of the `data` property that is returned:
394
+
395
+ - `useDocument`
396
+ - `useCollection`
397
+ - `useCollectionWithPagination`
398
+ - `useInfiniteCollection`
399
+
400
+ ```tsx
401
+ type Person = {
402
+ name: string;
403
+ age: number;
404
+ };
405
+
406
+ const person = useDocument<Person>(
407
+ {
408
+ databaseId: "db1",
409
+ collectionId: "col1",
410
+ documentId: "doc1",
411
+ fields: ["name", "age"],
412
+ },
413
+ );
414
+ ```
415
+
416
+ ## Suspense Queries
417
+
418
+ When using a `<Suspense>` boundary within React, you are able to utilize our selection of Suspense hooks. They are using `useSuspenseQuery` on the backside and will work out of the box with React Suspense.
419
+
420
+ - `useSuspenseCreateJWT`
421
+ - `useSuspenseCollection`
422
+ - `useSuspenseCollectionWithPagination`
423
+ - `useSuspenseDocument`
424
+ - `useSuspenseFunction`
425
+
426
+ ## Pagination Hooks
427
+
428
+ We have included two pagination hooks out of the box
429
+
430
+ **With Pagination:**
431
+
432
+ ```tsx
433
+ import * as React from "react";
434
+ import { q, useCollectionWithPagination } from "./";
435
+
436
+ type Item = {
437
+ _id: string;
438
+ name: string;
439
+ };
440
+
441
+ export default function Test() {
442
+ const {
443
+ documents,
444
+ page,
445
+ total,
446
+ nextPage,
447
+ previousPage,
448
+ hasNextPage,
449
+ hasPreviousPage,
450
+ } = useCollectionWithPagination<Item>({
451
+ databaseId: "your-database-id",
452
+ collectionId: "your-collection-id",
453
+ queries: q<Item>()
454
+ .equal("name", ["John", "Jane"])
455
+ .createdBefore(new Date("2024-01-01").toDateString())
456
+ .orderAsc("name")
457
+ .build(),
458
+ limit: 10,
459
+ fields: ["name", "_id"],
460
+ });
461
+
462
+ return (
463
+ <div>
464
+ <ul>
465
+ {documents.map((item) => (
466
+ <li key={item._id}>{item.name}</li>
467
+ ))}
468
+ </ul>
469
+
470
+ <button onClick={previousPage} disabled={!hasPreviousPage}>
471
+ Previous
472
+ </button>
473
+
474
+ <button onClick={nextPage} disabled={!hasNextPage}>
475
+ Next
476
+ </button>
477
+
478
+ <p>Page: {page}</p>
479
+ <p>Total: {total}</p>
480
+ </div>
481
+ );
482
+ }
483
+ ```
484
+
485
+ **Infinite Scroll**:
486
+
487
+ ```tsx
488
+ import * as React from "react";
489
+ import { q, useInfiniteCollection } from "./";
490
+
491
+ type Item = {
492
+ _id: string;
493
+ name: string;
494
+ };
495
+
496
+ export default function Test() {
497
+ const { documents, fetchNextPage, hasNextPage } = useInfiniteCollection<Item>(
498
+ {
499
+ databaseId: "your-database-id",
500
+ collectionId: "your-collection-id",
501
+ queries: q<Item>()
502
+ .equal("name", ["John", "Jane"])
503
+ .createdBefore(new Date("2024-01-01").toDateString())
504
+ .orderAsc("name")
505
+ .build(),
506
+ limit: 25,
507
+ fields: ["name", "_id"],
508
+ },
509
+ );
510
+
511
+ return (
512
+ <div>
513
+ <ul>
514
+ {documents.map((item) => (
515
+ <li key={item._id}>{item.name}</li>
516
+ ))}
517
+ </ul>
518
+
519
+ <button onClick={fetchNextPage} disabled={!hasNextPage}>
520
+ Load More...
521
+ </button>
522
+ </div>
523
+ );
524
+ }
525
+ ```
526
+
527
+ ## Appwrite QueryBuilder
528
+
529
+ Appwrite SDK includes a built-in Query factory, but we wanted to make something a little easier for ourselves while developing this library, so we are including what we put together. All `queries` props in all the hooks can take either the built-in Query factory, ours, or both, so you can do what makes the most sense for you.
530
+
531
+ Our QueryBuilder is type safe and exposes all underlying functions from the built-in version 1 for 1.
532
+
533
+ ```tsx
534
+ import { q } from "@zeroin.earth/appwrite-graphql";
535
+
536
+ type YourType = {
537
+ name: string;
538
+ favNumber: number;
539
+ favColor: string;
540
+ favFood: string;
541
+ };
542
+
543
+ export function Profiles() {
544
+ const { documents, error, isLoading } = useCollection<YourType>({
545
+ databaseId: "your-database-id",
546
+ collectionId: "your-collection-id",
547
+ queries: q<YourType>()
548
+ .or(
549
+ (q) => q.equal("favColor", "blue").greaterThan("favNumber", 18),
550
+ (q) => q.equal("favFood", "pizza").lessThan("favNumber", 10),
551
+ (q) =>
552
+ q.and(
553
+ (q) => q.between("favNumber", 5, 15),
554
+ (q) => q.startsWith("name", "A"),
555
+ ),
556
+ )
557
+
558
+ .build(),
559
+ });
560
+
561
+ return (
562
+ <div>
563
+ {isLoading && <p>Loading...</p>}
564
+ {error?.length > 0 && <p>Error: {error[0].message}</p>}
565
+ {documents && (
566
+ <ul>
567
+ {documents.map((doc) => (
568
+ <li key={doc.$id}>
569
+ Name: {doc.name}, Fav Number: {doc.favNumber}, Fav Color:{" "}
570
+ {doc.favColor}, Fav Food: {doc.favFood}
571
+ </li>
572
+ ))}
573
+ </ul>
574
+ )}
575
+ </div>
576
+ );
577
+ }
578
+ ```
579
+
580
+ ## React Query Devtools Support
581
+
582
+ React Query Devtools are bundled and ready to go. For any additional questions and information, please consult [Tanstack Query's](https://tanstack.com/query/latest/docs/framework/react/devtools#install-and-import-the-devtools) website.
583
+
584
+ ## Examples
585
+
586
+ ```ts
42
587
  import { useLogin } from "@zeroin.earth/appwrite-graphql";
43
588
 
44
589
  export function LogIn() {
45
590
  const router = useRouter();
591
+
46
592
  const { login, oAuthLogin } = useLogin();
47
593
 
48
594
  const onSubmit: SubmitHandler<Inputs> = async (data) => {
@@ -56,14 +602,16 @@ export function LogIn() {
56
602
  const loginWithGoogle = () => {
57
603
  oAuthLogin.mutate({
58
604
  provider: "google",
59
- success: 'successUrl',
60
- failure: 'failureUrl',
605
+ success: "successUrl",
606
+ failure: "failureUrl",
61
607
  });
62
608
  };
63
609
  }
64
610
  ```
65
611
 
66
- ```jsx
612
+ ---
613
+
614
+ ```ts
67
615
  import { useFunction } from "@zeroin.earth/appwrite-graphql";
68
616
 
69
617
  export function Form() {
@@ -72,7 +620,7 @@ export function Form() {
72
620
  const onSubmit: SubmitHandler<Input> = async (data) => {
73
621
  executeFunction.mutate(
74
622
  {
75
- functionId: '6gibhbyy6tggdf',
623
+ functionId: "6gibhbyy6tggdf",
76
624
  body: {
77
625
  message: {
78
626
  ...data,
@@ -88,26 +636,3 @@ export function Form() {
88
636
  };
89
637
  }
90
638
  ```
91
-
92
- ### Using Fragments
93
-
94
- ```jsx
95
- import {
96
- fragments,
97
- getFragmentData,
98
- useAccount,
99
- } from "@zeroin.earth/appwrite-graphql";
100
-
101
- export function Profile() {
102
- const { data, isLoading } = useAccount({});
103
- const account = getFragmentData(fragments.Account_UserFragment, data);
104
-
105
- return (
106
- <div>
107
- {data && (
108
- <h2>{`Welcome, ${account?.name ?? "Visitor"}!`}</h2>
109
- )}
110
- </div>
111
- );
112
- }
113
- ```