diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 45e5e5e..f375a56 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -46,12 +46,50 @@ jobs: docker compose -f tests/docker-compose.yml logs appwrite exit 1 + - name: Wait for executor + run: | + echo "Waiting for executor to be healthy..." + for i in $(seq 1 30); do + HEALTH=$(docker inspect --format='{{.State.Health.Status}}' openruntimes-executor 2>/dev/null || echo "not_found") + if [ "$HEALTH" = "healthy" ]; then + echo "Executor is healthy!" + exit 0 + fi + echo "Attempt $i/30 - executor status: $HEALTH - waiting 5s..." + sleep 5 + done + echo "Executor failed to become healthy" + docker compose -f tests/docker-compose.yml logs openruntimes-executor + docker compose -f tests/docker-compose.yml logs appwrite-worker-builds + exit 1 + - name: Setup test environment run: bun run test:setup - name: Run integration tests run: bun test --timeout 30000 + - name: Dump logs on failure + if: failure() + run: | + echo "=== Docker container status ===" + docker compose -f tests/docker-compose.yml ps + echo "" + echo "=== Executor logs ===" + docker compose -f tests/docker-compose.yml logs openruntimes-executor --tail=50 + echo "" + echo "=== Builds worker logs ===" + docker compose -f tests/docker-compose.yml logs appwrite-worker-builds --tail=50 + echo "" + echo "=== Appwrite API logs ===" + docker compose -f tests/docker-compose.yml logs appwrite --tail=50 + echo "" + echo "=== Docker networks ===" + docker network ls + echo "" + echo "=== Appwrite network inspect ===" + docker network inspect appwrite 2>/dev/null || echo "Network not found" + - name: Teardown if: always() run: docker compose -f tests/docker-compose.yml --env-file tests/.env down -v diff --git a/README.md b/README.md index fe6ddc8..30ee911 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,36 @@ # Appwrite GraphQL +![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) -This is a fully featured GraphQL library built with [@tanstack/react-query](https://github.com/TanStack/query) on top of the Appwrite web SDK. +Appwrite is an open source, BaaS in the same vein as Supabase and Firebase, but geared more toward self-hosting. -What this project handles for you: +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. + +## Getting Started + - [Installation](#installation) + - [Basic Usage](#usage) + +## Features - Dual build for both React and React Native -- Full Appwrite SDK v22 parity via React hooks -- Optimistic Mutations - - Documents -- Query Caching -- Offline-first support - - Built-in offline persisters (localStorage, AsyncStorage) - - Custom offline persister support -- SSR Support -- Field selection - - Prevent over-fetching -- Suspense queries - - Documents - - Collections -- Pagination hooks - - Standard Pagination - - Infinite Scroll -- Appwrite QueryBuilder -- Query key builder -- React Query Devtools support +- Full Appwrite SDK v23 parity using React hooks +- [Optimistic Mutations](#optimistic-mutations) + - Documents only +- [Query Caching](#query-caching) + - [QueryKey Builder](#querykey-builder) +- [Offline-first Support](#offline-first-support) + - [Built-in Offline Persisters](#built-in-offline-persisters) (localStorage, AsyncStorage) + - [Custom Offline Persister Support](#custom-offline-persister-support) + - [Conflict Resolution](#conflict-resolution) +- [SSR Support](#ssr-support) +- [Field Selection](#field-selection) +- [Suspense Queries](#suspense-queries) + - Documents + - Collections +- [Pagination Hooks](#pagination-hooks) + - Standard Pagination + - Infinite Scroll +- [Appwrite QueryBuilder](#appwrite-querybuilder) +- [React Query Devtools Support](#react-query-devtools-support) ## Installation @@ -35,26 +42,29 @@ bun add @zeroin.earth/appwrite-graphql ### Peer Dependencies - - `react` - `19.0.1` - - `appwrite` - `22.4.1` - - `@tanstack/react-query` - `^5.70.0` +- `react` - `^19.1.0` +- `appwrite` - `^23.0.0` +- `@tanstack/react-query` - `^5.70.0` -React Native: +**React Native:** - - `@react-native-async-storage/async-storage` - - `@react-native-community/netinfo` - - `react-native-appwrite` +- `@react-native-async-storage/async-storage` +- `@react-native-community/netinfo` +- `react-native-appwrite` ## Usage ### Provider -The library is designed to use a single wrapper, ``. There sre multiple ways you can configure the wrapper based upon your app's needs: +The library is designed to use a single wrapper, ``. There are multiple ways you can configure the wrapper based on your app's needs: -1. Basic (no offline) — React +1. Basic (no offline-first support) - React ```tsx -import { AppwriteProvider, createAppwriteClient } from '@zeroin.earth/appwrite-graphql' +import { + AppwriteProvider, + createAppwriteClient +} from '@zeroin.earth/appwrite-graphql' const client = createAppwriteClient({ endpoint: 'https://cloud.appwrite.io/v1', @@ -64,13 +74,13 @@ const client = createAppwriteClient({ function App() { return ( - {/* your app */} + {/* your app */} ) } ``` -2. Offline-first — React +2. Offline-first - React ```tsx import { @@ -89,18 +99,18 @@ const { appwrite, queryClient, persister } = createOfflineClient({ function App() { return ( console.log('Cache restored mutations replayed')} + client={appwrite} + queryClient={queryClient} + persister={persister} + onCacheRestored={() => console.log('Cache restored mutations replayed')} > - {/* your app */} + {/* your app */} ) } ``` -3. Offline-first — React Native +3. Offline-first - React Native ```tsx import AsyncStorage from '@react-native-async-storage/async-storage' @@ -108,7 +118,10 @@ import { AppwriteProvider, createOfflineClient, } from '@zeroin.earth/appwrite-graphql' -import { reactNativeNetworkAdapter } from '@zeroin.earth/appwrite-graphql/react-native' + +import { + reactNativeNetworkAdapter +} from '@zeroin.earth/appwrite-graphql/react-native' const { appwrite, queryClient, persister } = createOfflineClient({ endpoint: 'https://cloud.appwrite.io/v1', @@ -120,17 +133,17 @@ const { appwrite, queryClient, persister } = createOfflineClient({ function App() { return ( - {/* your app */} + {/* your app */} ) } ``` -4. Offline-first — React with custom persister +4. Offline-first - React with custom persister ```tsx import { @@ -156,17 +169,17 @@ const { appwrite, queryClient, persister } = createOfflineClient({ function App() { return ( - {/* your app */} + {/* your app */} ) } ``` -5. Offline — Imperative / non-React +5. Offline - Imperative / non-React ```tsx import { @@ -190,18 +203,392 @@ console.log('Cache restored, paused mutations replayed') // Use client.queryClient and client.appwrite directly // ... - // Cleanup when done + unsubscribe() ``` -### Hooks +## Optimistic Mutations + +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. + +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. + +## Query Caching + +All queries are assigned a unique queryKey and provide the developer with access to the underlying `staleTime` property. + +```tsx +type Person = { + name: string + age: number +} + +const person = useDocument( + { + databaseId: 'db1', + collectionId: 'col1', + documentId: 'doc1', + fields: ['name', 'age'], + }, + { + staleTime: 1000 * 60, // 1 minute + }, +) +``` + +### QueryKey Builder + +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. + +```tsx +import { Keys } from "@zeroin.earth/appwrite-graphql"; + +const queryKey = Keys.database(databaseId) + .collection(collectionId) + .document(documentId) + .key() +``` + +## Offline-first Support + +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. + +### Built-in Offline Persisters + +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. + +```tsx +const { appwrite, queryClient, persister } = createOfflineClient({ + endpoint: 'https://cloud.appwrite.io/v1', + projectId: 'my-project', + storage: localStorage, // or any AsyncStorage-compatible interface + networkAdapter: webNetworkAdapter(), +}) +``` + +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. + +### Custom Offline Persister Support + +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. + +### Conflict Resolution + +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. + +```tsx +createOfflineClient({ + endpoint: 'https://cloud.appwrite.io/v1', + projectId: 'my-project', + storage: localStorage, + networkAdapter: webNetworkAdapter(), + conflictStrategy: 'last-write-wins' +}) +``` + +**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. + +**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. + +**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. + +**custom**: A custom resolver function can be supplied with the following type: + +```tsx +conflictStrategy: ((context: ConflictContext) => Record | 'abort') +``` + +- `abort` signals to drop the replaying mutation and change nothing. -```jsx +## SSR Support + +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: + +- `getAccountQuery` +- `getDocumentQuery` +- `getCollectionQuery` + +```tsx +import * as React from "react"; +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from "@tanstack/react-query"; + +import { createAppwriteClient, useCollection } from "./"; +import { getCollectionQuery } from "./"; + +type PostType = { + title: string; + image: string; + description: string; +}; + +// This could also be getServerSideProps +export async function getStaticProps() { + const appwriteClient = createAppwriteClient({ + endpoint: "https://example.com/v1", + projectId: "project-id", + }); + + const queryClient = new QueryClient(); + + // Perform the prefetching of the collection query on the server. + await queryClient.prefetchQuery( + getCollectionQuery(appwriteClient, { + databaseId: "db1", + collectionId: "col1", + fields: ["title", "image", "description"], + }), + ); + + // Dehydrate the query client state and pass it as a + // prop to the page component. + return { + props: { + dehydratedState: dehydrate(queryClient), + }, + }; +} + +function Posts() { + // Since we prefetched the data on the server, this will use the + // cached data and not trigger a network request. If the cache is empty, + // it will fetch the data from the Appwrite server normally. + const { data } = useCollection({ + databaseId: "db1", + collectionId: "col1", + fields: ["title", "image", "description"], + }); + + return ( +
+ {data?.documents?.map((post) => ( +
+

{post.title}

+ {post.title} +

{post.description}

+
+ ))} +
+ ); +} + +// The dehydrated state from the server is passed to the HydrationBoundary, +// which allows the client-side React Query to rehydrate and use the prefetched +// data without making an additional network request. +export default function PostsRoute({ dehydratedState }) { + return ( + + + + ); +} +``` + +## Field Selection + +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: + +- `useDocument` +- `useCollection` +- `useCollectionWithPagination` +- `useInfiniteCollection` + +```tsx +type Person = { + name: string; + age: number; +}; + +const person = useDocument( + { + databaseId: "db1", + collectionId: "col1", + documentId: "doc1", + fields: ["name", "age"], + }, +); +``` + +## Suspense Queries + +When using a `` 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. + +- `useSuspenseCreateJWT` +- `useSuspenseCollection` +- `useSuspenseCollectionWithPagination` +- `useSuspenseDocument` +- `useSuspenseFunction` + +## Pagination Hooks + +We have included two pagination hooks out of the box + +**With Pagination:** + +```tsx +import * as React from "react"; +import { q, useCollectionWithPagination } from "./"; + +type Item = { + _id: string; + name: string; +}; + +export default function Test() { + const { + documents, + page, + total, + nextPage, + previousPage, + hasNextPage, + hasPreviousPage, + } = useCollectionWithPagination({ + databaseId: "your-database-id", + collectionId: "your-collection-id", + queries: q() + .equal("name", ["John", "Jane"]) + .createdBefore(new Date("2024-01-01").toDateString()) + .orderAsc("name") + .build(), + limit: 10, + fields: ["name", "_id"], + }); + + return ( +
+
    + {documents.map((item) => ( +
  • {item.name}
  • + ))} +
+ + + + + +

Page: {page}

+

Total: {total}

+
+ ); +} +``` + +**Infinite Scroll**: + +```tsx +import * as React from "react"; +import { q, useInfiniteCollection } from "./"; + +type Item = { + _id: string; + name: string; +}; + +export default function Test() { + const { documents, fetchNextPage, hasNextPage } = useInfiniteCollection( + { + databaseId: "your-database-id", + collectionId: "your-collection-id", + queries: q() + .equal("name", ["John", "Jane"]) + .createdBefore(new Date("2024-01-01").toDateString()) + .orderAsc("name") + .build(), + limit: 25, + fields: ["name", "_id"], + }, + ); + + return ( +
+
    + {documents.map((item) => ( +
  • {item.name}
  • + ))} +
+ + +
+ ); +} +``` + +## Appwrite QueryBuilder + +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. + +Our QueryBuilder is type safe and exposes all underlying functions from the built-in version 1 for 1. + +```tsx +import { q } from "@zeroin.earth/appwrite-graphql"; + +type YourType = { + name: string; + favNumber: number; + favColor: string; + favFood: string; +}; + +export function Profiles() { + const { documents, error, isLoading } = useCollection({ + databaseId: "your-database-id", + collectionId: "your-collection-id", + queries: q() + .or( + (q) => q.equal("favColor", "blue").greaterThan("favNumber", 18), + (q) => q.equal("favFood", "pizza").lessThan("favNumber", 10), + (q) => + q.and( + (q) => q.between("favNumber", 5, 15), + (q) => q.startsWith("name", "A"), + ), + ) + + .build(), + }); + + return ( +
+ {isLoading &&

Loading...

} + {error?.length > 0 &&

Error: {error[0].message}

} + {documents && ( +
    + {documents.map((doc) => ( +
  • + Name: {doc.name}, Fav Number: {doc.favNumber}, Fav Color:{" "} + {doc.favColor}, Fav Food: {doc.favFood} +
  • + ))} +
+ )} +
+ ); +} +``` + +## React Query Devtools Support + +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. + +## Examples + +```ts import { useLogin } from "@zeroin.earth/appwrite-graphql"; export function LogIn() { const router = useRouter(); + const { login, oAuthLogin } = useLogin(); const onSubmit: SubmitHandler = async (data) => { @@ -215,14 +602,16 @@ export function LogIn() { const loginWithGoogle = () => { oAuthLogin.mutate({ provider: "google", - success: 'successUrl', - failure: 'failureUrl', + success: "successUrl", + failure: "failureUrl", }); }; } ``` -```jsx +--- + +```ts import { useFunction } from "@zeroin.earth/appwrite-graphql"; export function Form() { @@ -231,7 +620,7 @@ export function Form() { const onSubmit: SubmitHandler = async (data) => { executeFunction.mutate( { - functionId: '6gibhbyy6tggdf', + functionId: "6gibhbyy6tggdf", body: { message: { ...data, diff --git a/bun.lock b/bun.lock index 32297fe..075e072 100644 --- a/bun.lock +++ b/bun.lock @@ -10,41 +10,42 @@ "@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-query-persist-client": "^5.90.24", "gql.tada": "^1.9.0", - "graphql": "^16.10.0", + "graphql": "^16.13.1", "graphql-scalars": "^1.24.2", "immer": "^11.1.4", }, "devDependencies": { "@eslint/js": "^10.0.1", - "@happy-dom/global-registrator": "^20.7.0", + "@happy-dom/global-registrator": "^20.8.4", "@react-native-async-storage/async-storage": "^3.0.1", "@react-native-community/netinfo": "^12.0.1", "@tanstack/react-query": "^5.70.0", "@testing-library/react": "^16.3.2", "@testing-library/react-hooks": "^8.0.1", - "@types/bun": "latest", + "@types/bun": "1.3.10", "@types/identity-obj-proxy": "^3.0.2", "@types/react": "^19.2.14", - "appwrite": "^22.4.1", - "eslint": "^10.0.2", + "appwrite": "^23.0.0", + "eslint": "^10.0.3", "eslint-plugin-simple-import-sort": "^12.1.1", - "happy-dom": "^20.7.0", + "happy-dom": "^20.8.4", "identity-obj-proxy": "^3.0.0", - "mailpit-api": "^1.7.1", - "node-appwrite": "^22.1.2", + "mailpit-api": "^1.7.2", + "node-appwrite": "^22.1.3", "otpauth": "^9.5.0", - "react": "19.1.0", - "react-dom": "19.1.0", - "react-native-appwrite": "^0.24.1", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-native-appwrite": "^0.25.0", "tsup": "^8.4.0", "typescript": "^5.9.3", - "typescript-eslint": "^8.56.1", + "typescript-eslint": "^8.57.0", }, "peerDependencies": { "@react-native-async-storage/async-storage": "^3.0.1", "@react-native-community/netinfo": "^12.0.1", "@tanstack/react-query": "^5.70.0", - "appwrite": "^22.4.1", + "appwrite": "^23.0.0", + "react": "19.1.0", "react-native-appwrite": "^0.24.1", }, "optionalPeers": [ @@ -299,17 +300,17 @@ "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - "@eslint/config-array": ["@eslint/config-array@0.23.2", "", { "dependencies": { "@eslint/object-schema": "^3.0.2", "debug": "^4.3.1", "minimatch": "^10.2.1" } }, "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A=="], + "@eslint/config-array": ["@eslint/config-array@0.23.3", "", { "dependencies": { "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw=="], "@eslint/config-helpers": ["@eslint/config-helpers@0.5.2", "", { "dependencies": { "@eslint/core": "^1.1.0" } }, "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ=="], - "@eslint/core": ["@eslint/core@1.1.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw=="], + "@eslint/core": ["@eslint/core@1.1.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ=="], "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], - "@eslint/object-schema": ["@eslint/object-schema@3.0.2", "", {}, "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw=="], + "@eslint/object-schema": ["@eslint/object-schema@3.0.3", "", {}, "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.0", "", { "dependencies": { "@eslint/core": "^1.1.0", "levn": "^0.4.1" } }, "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ=="], + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.1", "", { "dependencies": { "@eslint/core": "^1.1.1", "levn": "^0.4.1" } }, "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ=="], "@expo/cli": ["@expo/cli@55.0.12", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~55.0.8", "@expo/config-plugins": "~55.0.6", "@expo/devcert": "^1.2.1", "@expo/env": "~2.1.1", "@expo/image-utils": "^0.8.12", "@expo/json-file": "^10.0.12", "@expo/log-box": "55.0.7", "@expo/metro": "~54.2.0", "@expo/metro-config": "~55.0.9", "@expo/osascript": "^2.4.2", "@expo/package-manager": "^1.10.3", "@expo/plist": "^0.5.2", "@expo/prebuild-config": "^55.0.7", "@expo/require-utils": "^55.0.2", "@expo/router-server": "^55.0.8", "@expo/schema-utils": "^55.0.2", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.4.0", "@react-native/dev-middleware": "0.83.2", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.3", "expo-server": "^55.0.5", "fetch-nodeshim": "^0.4.6", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.0", "multitars": "^0.2.3", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.3", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "build/bin/cli" } }, "sha512-I2r0SPEx1svcaFXzyaC/Xd+U1y6mF7tbMRtVK3Z+ifB8pTe/H4UJ7CivTGpn8b5btsabNQjADAlnURgjP5dHYA=="], @@ -375,7 +376,7 @@ "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], - "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.7.0", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.7.0" } }, "sha512-JdsfSUVeWDP8klYL4y4C4Fae0nAv2V/2W+gHhdiuktyKGZvbSZfJpsk4loakPhTtxt91KHdDroXCCZcFIJrfYQ=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.8.4", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.8.4" } }, "sha512-cXGYd3xIAcviiGO6lPXdG6Yg244xwRgtY2dicAQ6HiB87E2IL2ekgfR5QIos18UtjiAsnCpLS3m78JfDorJcYg=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], @@ -531,7 +532,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], @@ -563,25 +564,25 @@ "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/type-utils": "8.57.0", "@typescript-eslint/utils": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.56.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.56.1", "@typescript-eslint/types": "^8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.57.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.0", "@typescript-eslint/types": "^8.57.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1" } }, "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0" } }, "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.56.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/utils": "8.57.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.57.0", "", {}, "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.56.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.56.1", "@typescript-eslint/tsconfig-utils": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.57.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.57.0", "@typescript-eslint/tsconfig-utils": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.56.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], @@ -611,7 +612,7 @@ "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], - "appwrite": ["appwrite@22.4.1", "", { "dependencies": { "json-bigint": "1.0.0" } }, "sha512-NlEgUvSo7A1m+TEHZ8zlWUIuCDtNg3VN6Mbxd6r+1618CiWokig630FpeKQ9/WD5Io92QxuTV/dVzBLPlQghqg=="], + "appwrite": ["appwrite@23.0.0", "", { "dependencies": { "json-bigint": "1.0.0" } }, "sha512-K11a597npl3jsnxWKzjw163n4GguH4+/zBCOiU15yc1u+7QF0nP9mxsY4JxKrBU6bmQRtgtMTPv/6YOLSwp/QQ=="], "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], @@ -677,7 +678,7 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], "bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="], @@ -791,11 +792,11 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@10.0.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.2", "@eslint/config-helpers": "^0.5.2", "@eslint/core": "^1.1.0", "@eslint/plugin-kit": "^0.6.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.1", "eslint-visitor-keys": "^5.0.1", "espree": "^11.1.1", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.1", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw=="], + "eslint": ["eslint@10.0.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.3", "@eslint/config-helpers": "^0.5.2", "@eslint/core": "^1.1.1", "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.1.1", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ=="], "eslint-plugin-simple-import-sort": ["eslint-plugin-simple-import-sort@12.1.1", "", { "peerDependencies": { "eslint": ">=5.0.0" } }, "sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA=="], - "eslint-scope": ["eslint-scope@9.1.1", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw=="], + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], @@ -903,11 +904,11 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "graphql": ["graphql@16.13.0", "", {}, "sha512-uSisMYERbaB9bkA9M4/4dnqyktaEkf1kMHNKq/7DHyxVeWqHQ2mBmVqm5u6/FVHwF3iCNalKcg82Zfl+tffWoA=="], + "graphql": ["graphql@16.13.1", "", {}, "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ=="], "graphql-scalars": ["graphql-scalars@1.25.0", "", { "dependencies": { "tslib": "^2.5.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-b0xyXZeRFkne4Eq7NAnL400gStGqG/Sx9VqX0A05nHyEbv57UJnWKsjNnrpVqv5e/8N1MUxkt0wwcRXbiyKcFg=="], - "happy-dom": ["happy-dom@20.7.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-hR/uLYQdngTyEfxnOoa+e6KTcfBFyc1hgFj/Cc144A5JJUuHFYqIEBDcD4FeGqUeKLRZqJ9eN9u7/GDjYEgS1g=="], + "happy-dom": ["happy-dom@20.8.4", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GKhjq4OQCYB4VLFBzv8mmccUadwlAusOZOI7hC1D9xDIT5HhzkJK17c4el2f6R6C715P9xB4uiMxeKUa2nHMwQ=="], "harmony-reflect": ["harmony-reflect@1.6.2", "", {}, "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g=="], @@ -1069,7 +1070,7 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "mailpit-api": ["mailpit-api@1.7.1", "", { "dependencies": { "axios": "^1.13.5", "partysocket": "^1.1.10", "ws": "^8.18.3" } }, "sha512-a4+shTeSYQiy5bADcsA/TXj+D9errJu9Z2ZE3vJAuI6sQxz9n/h3r/pVkKDyMIie6GsBXAzyooPl8X7hvXmnWQ=="], + "mailpit-api": ["mailpit-api@1.7.2", "", { "dependencies": { "axios": "^1.13.5", "partysocket": "^1.1.10", "ws": "^8.18.3" } }, "sha512-bk8D84CoCL4ztXx0a+fDspLkJacU+dMM41LDFlbPQoYpynMzHm4jrtM6ECOKmw30xovv0Ap5WO8mdAGOK65WwQ=="], "makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="], @@ -1139,7 +1140,7 @@ "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], - "node-appwrite": ["node-appwrite@22.1.2", "", { "dependencies": { "json-bigint": "1.0.0", "node-fetch-native-with-agent": "1.7.2" } }, "sha512-iK30EBV+/rkU2jbHR+i2BK+7axlxjuF/G7X290jGwOc7Wa/qkkK67icgU4M+XJgCUsInha4ZeH0dWs7Gw0RT9A=="], + "node-appwrite": ["node-appwrite@22.1.3", "", { "dependencies": { "json-bigint": "1.0.0", "node-fetch-native-with-agent": "1.7.2" } }, "sha512-pXxvojqgYY3HAiJAj+P6xb/CXVNsMUd+JXVOLrCCDzqZmpKeMF6OEt4mAoDEyKTnRaixUDHlPrdh7y1G/w3sbw=="], "node-fetch-native-with-agent": ["node-fetch-native-with-agent@1.7.2", "", {}, "sha512-5MaOOCuJEvcckoz7/tjdx1M6OusOY6Xc5f459IaruGStWnKzlI1qpNgaAwmn4LmFYcsSlj+jBMk84wmmRxfk5g=="], @@ -1235,11 +1236,11 @@ "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="], - "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], "react-error-boundary": ["react-error-boundary@3.1.4", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "react": ">=16.13.1" } }, "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA=="], @@ -1247,7 +1248,7 @@ "react-native": ["react-native@0.84.0", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.84.0", "@react-native/codegen": "0.84.0", "@react-native/community-cli-plugin": "0.84.0", "@react-native/gradle-plugin": "0.84.0", "@react-native/js-polyfills": "0.84.0", "@react-native/normalize-colors": "0.84.0", "@react-native/virtualized-lists": "0.84.0", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.32.0", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "hermes-compiler": "250829098.0.7", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.3", "metro-source-map": "^0.83.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "tinyglobby": "^0.2.15", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.1", "react": "^19.2.3" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-CcBfucLDHz8MAjQx9kFXasYtpcn8zP1YapUgGtAy0psRZTLShwF9yeh5+ErSgEK2gXV1CCSz7hqCZqx1eMyBLA=="], - "react-native-appwrite": ["react-native-appwrite@0.24.1", "", { "dependencies": { "expo-file-system": "18.*.*", "json-bigint": "1.0.0", "react-native": ">=0.76.7 <1.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-10POqOVq2JbO2szbk2rDSp/1SO7gnDhoe+w1pTU1lMVN9lOIYgGHUEYuGTZti242GFG/1O+6aplu8r8d+cb25Q=="], + "react-native-appwrite": ["react-native-appwrite@0.25.0", "", { "dependencies": { "expo-file-system": "18.*.*", "json-bigint": "1.0.0", "react-native": ">=0.76.7 <1.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-P6s9mNzmwWDnUImb53dzZ0giwv0CE+gav1blzA8skhTogdQruYO3WPASifEmNBQEJXExkyA7zBxoq6wPDrNDMw=="], "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], @@ -1283,7 +1284,7 @@ "sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="], - "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], @@ -1385,7 +1386,7 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "typescript-eslint": ["typescript-eslint@8.56.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/parser": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ=="], + "typescript-eslint": ["typescript-eslint@8.57.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.57.0", "@typescript-eslint/parser": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/utils": "8.57.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA=="], "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], @@ -1457,6 +1458,8 @@ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@0no-co/graphqlsp/graphql": ["graphql@16.13.0", "", {}, "sha512-uSisMYERbaB9bkA9M4/4dnqyktaEkf1kMHNKq/7DHyxVeWqHQ2mBmVqm5u6/FVHwF3iCNalKcg82Zfl+tffWoA=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1469,6 +1472,8 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@eslint/config-helpers/@eslint/core": ["@eslint/core@1.1.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw=="], + "@expo/cli/@react-native/dev-middleware": ["@react-native/dev-middleware@0.83.2", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.83.2", "@react-native/debugger-shell": "0.83.2", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.2.0", "connect": "^3.6.5", "debug": "^4.4.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "serve-static": "^1.16.2", "ws": "^7.5.10" } }, "sha512-Zi4EVaAm28+icD19NN07Gh8Pqg/84QQu+jn4patfWKNkcToRFP5vPEbbp0eLOGWS+BVB1d1Fn5lvMrJsBbFcOg=="], "@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], @@ -1487,6 +1492,8 @@ "@expo/prebuild-config/@react-native/normalize-colors": ["@react-native/normalize-colors@0.83.2", "", {}, "sha512-gkZAb9LoVVzNuYzzOviH7DiPTXQoZPHuiTH2+O2+VWNtOkiznjgvqpwYAhg58a5zfRq5GXlbBdf5mzRj5+3Y5Q=="], + "@gql.tada/cli-utils/graphql": ["graphql@16.13.0", "", {}, "sha512-uSisMYERbaB9bkA9M4/4dnqyktaEkf1kMHNKq/7DHyxVeWqHQ2mBmVqm5u6/FVHwF3iCNalKcg82Zfl+tffWoA=="], + "@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], "@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], @@ -1593,8 +1600,6 @@ "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], - "react-native/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "react-native/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], diff --git a/package.json b/package.json index 3392503..103d2f1 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-query-persist-client": "^5.90.24", "gql.tada": "^1.9.0", - "graphql": "^16.10.0", + "graphql": "^16.13.1", "graphql-scalars": "^1.24.2", "immer": "^11.1.4" }, @@ -56,9 +56,9 @@ "@react-native-async-storage/async-storage": "^3.0.1", "@react-native-community/netinfo": "^12.0.1", "@tanstack/react-query": "^5.70.0", - "appwrite": "^22.4.1", + "appwrite": "^23.0.0", "react": "19.1.0", - "react-native-appwrite": "^0.24.1" + "react-native-appwrite": "^0.25.0" }, "peerDependenciesMeta": { "react-native-appwrite": { @@ -73,29 +73,29 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@happy-dom/global-registrator": "^20.7.0", + "@happy-dom/global-registrator": "^20.8.4", "@react-native-async-storage/async-storage": "^3.0.1", "@react-native-community/netinfo": "^12.0.1", "@tanstack/react-query": "^5.70.0", "@testing-library/react": "^16.3.2", "@testing-library/react-hooks": "^8.0.1", - "@types/bun": "latest", + "@types/bun": "1.3.10", "@types/identity-obj-proxy": "^3.0.2", "@types/react": "^19.2.14", - "appwrite": "^22.4.1", - "eslint": "^10.0.2", + "appwrite": "^23.0.0", + "eslint": "^10.0.3", "eslint-plugin-simple-import-sort": "^12.1.1", - "happy-dom": "^20.7.0", + "happy-dom": "^20.8.4", "identity-obj-proxy": "^3.0.0", - "mailpit-api": "^1.7.1", - "node-appwrite": "^22.1.2", + "mailpit-api": "^1.7.2", + "node-appwrite": "^22.1.3", "otpauth": "^9.5.0", - "react": "19.1.0", - "react-dom": "19.1.0", - "react-native-appwrite": "^0.24.1", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-native-appwrite": "^0.25.0", "tsup": "^8.4.0", "typescript": "^5.9.3", - "typescript-eslint": "^8.56.1" + "typescript-eslint": "^8.57.0" }, "publishConfig": { "access": "public" diff --git a/src/account/index.ts b/src/account/index.ts index b31dd03..5d9374c 100644 --- a/src/account/index.ts +++ b/src/account/index.ts @@ -1,4 +1,4 @@ -export { accountQueryOptions } from './queryOptions' +export { getAccountQuery } from './queryOptions' export { useAccount, useLazyAccount } from './useAccount' export { useCreateAnonymousSession } from './useCreateAnonymousSession' export { useCreateEmailToken } from './useCreateEmailToken' diff --git a/src/account/queryOptions.ts b/src/account/queryOptions.ts index 2157dbb..d5b4f51 100644 --- a/src/account/queryOptions.ts +++ b/src/account/queryOptions.ts @@ -16,7 +16,7 @@ export const getAccount = gql(/* GraphQL */ ` } `) -export function accountQueryOptions(client: AppwriteClient) { +export function getAccountQuery(client: AppwriteClient) { return { queryKey: Keys.account().key(), queryFn: async () => { diff --git a/src/account/useAccount.ts b/src/account/useAccount.ts index b83cf75..f776e31 100644 --- a/src/account/useAccount.ts +++ b/src/account/useAccount.ts @@ -4,7 +4,7 @@ import { Channel } from 'appwrite' import { castDraft, produce } from 'immer' import type { getAccount } from './queryOptions' -import { accountQueryOptions } from './queryOptions' +import { getAccountQuery } from './queryOptions' import { Keys } from '../query/Keys' import type { AppwriteException, Models, QueryOptions, Realtime } from '../types' import { useAppwrite } from '../useAppwrite' @@ -61,7 +61,7 @@ export function useAccount(opts: QueryOptions = {}) { } function getAccountQueryOptions(client: ReturnType) { - return accountQueryOptions(client) + return getAccountQuery(client) } function subscribe( diff --git a/src/databases/index.ts b/src/databases/index.ts index 2c25507..53b1b3d 100644 --- a/src/databases/index.ts +++ b/src/databases/index.ts @@ -1,4 +1,4 @@ -export { documentQueryOptions, collectionQueryOptions } from './queryOptions' +export { getDocumentQuery, getCollectionQuery } from './queryOptions' export { useCollection, useSuspenseCollection } from './useCollection' export { useCollectionWithPagination, diff --git a/src/databases/queryOptions.ts b/src/databases/queryOptions.ts index b8373c1..f682991 100644 --- a/src/databases/queryOptions.ts +++ b/src/databases/queryOptions.ts @@ -35,7 +35,7 @@ export const getDocument = gql(/* GraphQL */ ` } `) -export function documentQueryOptions( +export function getDocumentQuery( client: AppwriteClient, { databaseId, @@ -104,7 +104,7 @@ export const listDocuments = gql(/* GraphQL */ ` } `) -export function collectionQueryOptions( +export function getCollectionQuery( client: AppwriteClient, { databaseId, diff --git a/src/databases/useCollection.ts b/src/databases/useCollection.ts index 8b08da6..f3dd0e1 100644 --- a/src/databases/useCollection.ts +++ b/src/databases/useCollection.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' import { Channel } from 'appwrite' -import { collectionQueryOptions } from './queryOptions' +import { getCollectionQuery } from './queryOptions' import type { Collection, Document } from './types' import { Keys } from '../query/Keys' import type { AppwriteException, QueryOptions } from '../types' @@ -15,7 +15,7 @@ type DocumentOperation = 'create' | 'update' | 'delete' type CollectionParams> = { databaseId: string collectionId: string - queries: string[] + queries?: string[] transactionId?: string subscribe?: boolean fields?: (keyof TDocument & string)[] @@ -30,7 +30,7 @@ function useCollectionQueryConfig({ }: Omit, 'subscribe'>) { const client = useAppwrite() - return collectionQueryOptions(client, { + return getCollectionQuery(client, { databaseId, collectionId, queries, @@ -88,7 +88,7 @@ export function useCollection( { databaseId, collectionId, - queries, + queries = [], transactionId, subscribe = true, fields, diff --git a/src/databases/useCreateDocument.ts b/src/databases/useCreateDocument.ts index 4156ff8..2235663 100644 --- a/src/databases/useCreateDocument.ts +++ b/src/databases/useCreateDocument.ts @@ -69,10 +69,20 @@ export function useCreateDocument() { } return mutationData.databasesCreateDocument }, - onSuccess: (_, variables) => { + onSuccess: (result, variables) => { + const documentKeyPrefix = Keys.database(variables.databaseId) + .collection(variables.collectionId) + .document(result._id) + .key() + void queryClient.invalidateQueries({ queryKey: Keys.database(variables.databaseId).collection(variables.collectionId).key(), }) + + queryClient.setQueryData(documentKeyPrefix, { + ...variables, + ...(variables.data as Record), + }) }, }) diff --git a/src/databases/useDecrementAttribute.ts b/src/databases/useDecrementAttribute.ts index 2479c3d..94a1451 100644 --- a/src/databases/useDecrementAttribute.ts +++ b/src/databases/useDecrementAttribute.ts @@ -79,20 +79,15 @@ export function useDecrementAttribute() { const previousEntries = queryClient.getQueriesData({ queryKey: documentKeyPrefix }) - queryClient.setQueriesData( - { queryKey: documentKeyPrefix }, - (old: Record | undefined) => { - if (!old) return old - const current = (old[variables.attribute] as number) ?? 0 - const decrement = variables.value ?? 1 - const newValue = - variables.min != null - ? Math.max(current - decrement, variables.min) - : current - decrement + queryClient.setQueryData(documentKeyPrefix, (old) => { + if (!old) return old + const current = (old[variables.attribute] as number) ?? 0 + const decrement = variables.value ?? 1 + const newValue = + variables.min != null ? Math.max(current - decrement, variables.min) : current - decrement - return { ...old, [variables.attribute]: newValue } - }, - ) + return { ...old, [variables.attribute]: newValue } + }) return { previousEntries, documentKeyPrefix } }, diff --git a/src/databases/useDocument.ts b/src/databases/useDocument.ts index 6f69e4b..e88a496 100644 --- a/src/databases/useDocument.ts +++ b/src/databases/useDocument.ts @@ -3,7 +3,7 @@ import { Channel } from 'appwrite' import type { VariablesOf } from 'gql.tada' import type { getDocument } from './queryOptions' -import { documentQueryOptions } from './queryOptions' +import { getDocumentQuery } from './queryOptions' import type { Document } from './types' import { Keys } from '../query/Keys' import type { AppwriteException, QueryOptions } from '../types' @@ -28,7 +28,7 @@ function useDocumentQueryConfig({ }: DocumentParams) { const client = useAppwrite() - return documentQueryOptions(client, { + return getDocumentQuery(client, { databaseId, collectionId, documentId, diff --git a/src/databases/useIncrementAttribute.ts b/src/databases/useIncrementAttribute.ts index 8ffaa08..457c3cc 100644 --- a/src/databases/useIncrementAttribute.ts +++ b/src/databases/useIncrementAttribute.ts @@ -79,20 +79,15 @@ export function useIncrementAttribute() { const previousEntries = queryClient.getQueriesData({ queryKey: documentKeyPrefix }) - queryClient.setQueriesData( - { queryKey: documentKeyPrefix }, - (old: Record | undefined) => { - if (!old) return old - const current = (old[variables.attribute] as number) ?? 0 - const increment = variables.value ?? 1 - const newValue = - variables.max != null - ? Math.min(current + increment, variables.max) - : current + increment + queryClient.setQueryData(documentKeyPrefix, (old) => { + if (!old) return old + const current = (old[variables.attribute] as number) ?? 0 + const increment = variables.value ?? 1 + const newValue = + variables.max != null ? Math.min(current + increment, variables.max) : current + increment - return { ...old, [variables.attribute]: newValue } - }, - ) + return { ...old, [variables.attribute]: newValue } + }) return { previousEntries, documentKeyPrefix } }, diff --git a/src/databases/useUpdateDocument.ts b/src/databases/useUpdateDocument.ts index e387501..d5eeb06 100644 --- a/src/databases/useUpdateDocument.ts +++ b/src/databases/useUpdateDocument.ts @@ -1,6 +1,9 @@ +import { onlineManager } from '@tanstack/react-query' import type { ResultOf, VariablesOf } from 'gql.tada' import { graphql as gql } from 'gql.tada' +import type { ConflictStrategy } from '../offline/conflictResolution/types' +import { conflictAwareUpdate } from '../offline/mutations/conflictAwareUpdate' import { Keys } from '../query/Keys' import type { AppwriteException } from '../types' import { useAppwrite } from '../useAppwrite' @@ -36,29 +39,52 @@ type UpdateDocumentVariables = Omit & { permissions?: string[] | null } +type MutationContext = { + previousEntries: [queryKey: readonly unknown[], data: unknown][] + documentKeyPrefix: readonly unknown[] + baseSnapshot: Record | undefined + willPerformOfflineMutation: boolean +} + export function useUpdateDocument() { - const { graphql } = useAppwrite() + const appwrite = useAppwrite() const queryClient = useQueryClient() const mutationResult = useMutation< Result, AppwriteException[], UpdateDocumentVariables, - { - previousEntries: [queryKey: readonly unknown[], data: unknown][] - documentKeyPrefix: readonly unknown[] - } + MutationContext >({ mutationKey: Keys.databases().collections().documents().update(), - mutationFn: async ({ - databaseId, - collectionId, - documentId, - data, - permissions, - transactionId, - }) => { - const { data: mutationData, errors } = await graphql.mutation({ + mutationFn: async ( + { databaseId, collectionId, documentId, data, permissions, transactionId }, + ctx, + ) => { + const wasOffline = ctx.meta.willPerformOfflineMutation ?? false + + if (ctx.meta.willPerformOfflineMutation != null) { + delete ctx.meta.willPerformOfflineMutation + } + + if (wasOffline) { + try { + const updateData = (await conflictAwareUpdate( + ctx.meta.conflictStrategy as ConflictStrategy, + )( + appwrite, + { databaseId, collectionId, documentId, data, permissions, transactionId }, + queryClient, + )) as Result + + return updateData + } catch (error) { + console.error('Conflict-aware update failed:', error) + throw error + } + } + + const { data: mutationData, errors } = await appwrite.graphql.mutation({ query: updateDocument, variables: { databaseId, @@ -76,7 +102,7 @@ export function useUpdateDocument() { return mutationData.databasesUpdateDocument }, - onMutate: async (variables) => { + onMutate: async (variables, ctx) => { const documentKeyPrefix = Keys.database(variables.databaseId) .collection(variables.collectionId) .document(variables.documentId) @@ -86,13 +112,30 @@ export function useUpdateDocument() { const previousEntries = queryClient.getQueriesData({ queryKey: documentKeyPrefix }) - queryClient.setQueriesData( - { queryKey: documentKeyPrefix }, - (old: Record | undefined) => - old ? { ...old, ...(variables.data as Record) } : old, + // Capture a deep copy of the document before optimistic update. + // This snapshot is persisted in MutationState.context through + // dehydration and serves as the "base" for three-way conflict + // resolution when a paused mutation is replayed. + const baseSnapshot = previousEntries.find(([, data]) => data != null)?.[1] as + | Record + | undefined + const baseSnapshotCopy = baseSnapshot + ? (JSON.parse(JSON.stringify(baseSnapshot)) as Record) + : undefined + + queryClient.setQueryData(documentKeyPrefix, (old) => + old ? { ...old, ...(variables.data as Record) } : old, ) - return { previousEntries, documentKeyPrefix } + const willPerformOfflineMutation = onlineManager.isOnline() === false + ctx.meta = { ...ctx.meta, willPerformOfflineMutation } + + return { + previousEntries, + documentKeyPrefix, + baseSnapshot: baseSnapshotCopy, + willPerformOfflineMutation, + } }, onError: (_, __, context) => { if (context?.previousEntries) { diff --git a/src/databases/useUpsertDocument.ts b/src/databases/useUpsertDocument.ts index fba96f0..a847fc8 100644 --- a/src/databases/useUpsertDocument.ts +++ b/src/databases/useUpsertDocument.ts @@ -1,6 +1,9 @@ +import { onlineManager } from '@tanstack/react-query' import type { ResultOf, VariablesOf } from 'gql.tada' import { graphql as gql } from 'gql.tada' +import type { ConflictStrategy } from '../offline' +import { conflictAwareUpdate } from '../offline/mutations/conflictAwareUpdate' import { Keys } from '../query/Keys' import type { AppwriteException } from '../types' import { useAppwrite } from '../useAppwrite' @@ -36,29 +39,52 @@ type UpsertDocumentVariables = Omit & { permissions?: string[] | null } +type MutationContext = { + previousEntries: [queryKey: readonly unknown[], data: unknown][] + documentKeyPrefix: readonly unknown[] + baseSnapshot: Record | undefined + willPerformOfflineMutation: boolean +} + export function useUpsertDocument() { - const { graphql } = useAppwrite() + const appwrite = useAppwrite() const queryClient = useQueryClient() const mutationResult = useMutation< Result, AppwriteException[], UpsertDocumentVariables, - { - previousEntries: [queryKey: readonly unknown[], data: unknown][] - documentKeyPrefix: readonly unknown[] - } + MutationContext >({ mutationKey: Keys.databases().collections().documents().upsert(), - mutationFn: async ({ - databaseId, - collectionId, - documentId, - data, - permissions, - transactionId, - }) => { - const { data: mutationData, errors } = await graphql.mutation({ + mutationFn: async ( + { databaseId, collectionId, documentId, data, permissions, transactionId }, + ctx, + ) => { + const wasOffline = ctx.meta.willPerformOfflineMutation ?? false + + if (ctx.meta.willPerformOfflineMutation != null) { + delete ctx.meta.willPerformOfflineMutation + } + + if (wasOffline) { + try { + const updateData = (await conflictAwareUpdate( + ctx.meta.conflictStrategy as ConflictStrategy, + )( + appwrite, + { databaseId, collectionId, documentId, data, permissions, transactionId }, + queryClient, + )) as Result + + return updateData + } catch (error) { + console.error('Conflict-aware update failed:', error) + throw error + } + } + + const { data: mutationData, errors } = await appwrite.graphql.mutation({ query: upsertDocument, variables: { databaseId, @@ -76,7 +102,7 @@ export function useUpsertDocument() { return mutationData.databasesUpsertDocument }, - onMutate: async (variables) => { + onMutate: async (variables, ctx) => { const documentKeyPrefix = Keys.database(variables.databaseId) .collection(variables.collectionId) .document(variables.documentId) @@ -86,13 +112,26 @@ export function useUpsertDocument() { const previousEntries = queryClient.getQueriesData({ queryKey: documentKeyPrefix }) - queryClient.setQueriesData( - { queryKey: documentKeyPrefix }, - (old: Record | undefined) => - old ? { ...old, ...(variables.data as Record) } : old, + const baseSnapshot = previousEntries.find(([, data]) => data != null)?.[1] as + | Record + | undefined + const baseSnapshotCopy = baseSnapshot + ? (JSON.parse(JSON.stringify(baseSnapshot)) as Record) + : undefined + + queryClient.setQueryData(documentKeyPrefix, (old) => + old ? { ...old, ...(variables.data as Record) } : old, ) - return { previousEntries, documentKeyPrefix } + const willPerformOfflineMutation = onlineManager.isOnline() === false + ctx.meta = { ...ctx.meta, willPerformOfflineMutation } + + return { + previousEntries, + documentKeyPrefix, + baseSnapshot: baseSnapshotCopy, + willPerformOfflineMutation, + } }, onError: (_, __, context) => { if (context?.previousEntries) { diff --git a/src/offline/conflictResolution/resolve.ts b/src/offline/conflictResolution/resolve.ts new file mode 100644 index 0000000..e5fae72 --- /dev/null +++ b/src/offline/conflictResolution/resolve.ts @@ -0,0 +1,51 @@ +import type { ConflictContext, ConflictStrategy } from './types' + +/** + * Applies a conflict resolution strategy to produce the merged document data + * that should be sent to the server, or `'abort'` to discard the mutation. + * + * @returns The resolved field-level data (not the full document — just the + * fields to write), or `'abort'` if the mutation should be dropped. + */ +export function resolveConflict( + context: ConflictContext, + strategy: ConflictStrategy, +): Record | 'abort' { + if (typeof strategy === 'function') { + return strategy(context) + } + + switch (strategy) { + case 'last-write-wins': + return extractChangedFields(context.base, context.local) + + case 'server-wins': + return 'abort' + + case 'merge-shallow': { + const localChanges = extractChangedFields(context.base, context.local) + const remoteChanges = extractChangedFields(context.base, context.remote) + + // Remote changes win where both sides modified the same field + return { ...localChanges, ...remoteChanges } + } + } +} + +/** + * Returns only the fields in `updated` that differ from `original`. + */ +function extractChangedFields( + original: Record, + updated: Record, +): Record { + const changes: Record = {} + + for (const key of Object.keys(updated)) { + if (JSON.stringify(original[key]) !== JSON.stringify(updated[key])) { + changes[key] = updated[key] + } + } + + return changes +} diff --git a/src/offline/conflictResolution/types.ts b/src/offline/conflictResolution/types.ts new file mode 100644 index 0000000..635c2ae --- /dev/null +++ b/src/offline/conflictResolution/types.ts @@ -0,0 +1,14 @@ +import type { Models } from 'appwrite' + +export type ConflictStrategy = + | 'last-write-wins' + | 'server-wins' // discard local mutation, keep remote + | 'merge-shallow' // { ...remote.data, ...local.data } + | ((context: ConflictContext) => Record | 'abort') + +export type ConflictContext = { + base: Models.DefaultDocument // snapshot from when mutation was created + remote: Models.DefaultDocument // current server state (fetched at replay time) + local: Models.DefaultDocument // what the user wanted to write + mutationKey: string[] +} diff --git a/src/offline/createOfflineClient.ts b/src/offline/createOfflineClient.ts index 159809f..9b6889f 100644 --- a/src/offline/createOfflineClient.ts +++ b/src/offline/createOfflineClient.ts @@ -3,6 +3,7 @@ import type { AsyncStorage, Persister } from '@tanstack/query-persist-client-cor import { persistQueryClient } from '@tanstack/query-persist-client-core' import { onlineManager, QueryClient } from '@tanstack/react-query' +import type { ConflictStrategy } from './conflictResolution/types' import { hydrateMutationDefaults } from './mutations/registry' import type { NetworkAdapter } from './types' import type { AppwriteClient } from '../client' @@ -12,6 +13,7 @@ export type OfflineClient = { appwrite: AppwriteClient queryClient: QueryClient persister: Persister | undefined + /** * Start persistence for non-React (imperative) usage. * Restores the persisted cache, subscribes to future changes, @@ -22,6 +24,8 @@ export type OfflineClient = { * @throws If no persister was configured via `storage` or `persister`. */ startPersistence: () => { unsubscribe: () => void; restored: Promise } + + conflictStrategy?: ConflictStrategy } const dehydrateOptions = { @@ -50,6 +54,7 @@ export function createOfflineClient({ persister: externalPersister, networkAdapter, throttleTime = 1000, + conflictStrategy = 'last-write-wins', }: { endpoint: string projectId: string @@ -60,6 +65,7 @@ export function createOfflineClient({ networkAdapter: NetworkAdapter /** Throttle time for network status changes to prevent rapid toggling. Default: 1000ms. */ throttleTime?: number + conflictStrategy?: ConflictStrategy }): OfflineClient { if (storage && externalPersister) { throw new Error('Provide either `storage` or `persister`, not both.') @@ -69,12 +75,17 @@ export function createOfflineClient({ const queryClient = new QueryClient({ defaultOptions: { - mutations: { networkMode: 'offlineFirst' }, + mutations: { + networkMode: 'offlineFirst', + meta: { + conflictStrategy, + }, + }, queries: { networkMode: 'offlineFirst', gcTime: 1000 * 60 * 60 * 24 }, }, }) - hydrateMutationDefaults(queryClient, appwrite) + hydrateMutationDefaults(queryClient, appwrite, { conflictStrategy }) const persister = externalPersister ?? diff --git a/src/offline/index.ts b/src/offline/index.ts index 378542e..7058d17 100644 --- a/src/offline/index.ts +++ b/src/offline/index.ts @@ -1,5 +1,7 @@ export { createOfflineClient } from './createOfflineClient' export type { OfflineClient } from './createOfflineClient' +export { resolveConflict } from './conflictResolution/resolve' +export type { ConflictContext, ConflictStrategy } from './conflictResolution/types' export { hydrateMutationDefaults, mutationRegistry } from './mutations/registry' export { webNetworkAdapter } from './network/web' export type { NetworkAdapter } from './types' diff --git a/src/offline/mutations/conflictAwareUpdate.ts b/src/offline/mutations/conflictAwareUpdate.ts new file mode 100644 index 0000000..1d8beff --- /dev/null +++ b/src/offline/mutations/conflictAwareUpdate.ts @@ -0,0 +1,103 @@ +import { Keys } from '../..' +import { getDocument } from '../../databases/queryOptions' +import { updateDocument } from '../../databases/useUpdateDocument' +import { resolveConflict } from '../conflictResolution/resolve' +import type { ConflictStrategy } from '../conflictResolution/types' +import type { MutationFn, Vars } from '../types' + +/** + * Creates a conflict-aware mutationFn for document updates. + * + * When a base snapshot is available (persisted in MutationState.context by + * useUpdateDocument's onMutate), the function fetches the current remote + * document, runs the configured conflict resolution strategy, and sends the + * resolved data. If no base snapshot exists (e.g. the mutation was created + * while online and executed immediately), it falls through to a normal update. + */ +export function conflictAwareUpdate(conflictStrategy: ConflictStrategy): MutationFn { + return async (client, variables, queryClient) => { + const { databaseId, collectionId, documentId } = variables as { + databaseId: string + collectionId: string + documentId: string + } + + // Look up the mutation instance to read the persisted onMutate context + const mutation = queryClient + .getMutationCache() + .getAll() + .find((m) => JSON.stringify(m.state.variables) === JSON.stringify(variables)) + + const baseSnapshot = (mutation?.state.context as { baseSnapshot?: Record }) + ?.baseSnapshot + + let resolvedData = variables.data as Record + if (baseSnapshot) { + const { data: remoteResult, errors: fetchErrors } = await client.graphql.query({ + query: getDocument, + variables: { databaseId, collectionId, documentId }, + }) + + if (fetchErrors) throw fetchErrors + + const rawRemote = remoteResult.databasesGetDocument + const remote = { + ...rawRemote, + ...(rawRemote ? (JSON.parse(rawRemote.data as string) as Record) : {}), + } as Record + delete remote.data // Remove the raw JSON string from the remote document + delete remote._id // Remove the _id field to avoid confusion during conflict resolution + + // Build the "local" document: the base with the user's changes applied + const local = { ...baseSnapshot, ...(variables.data as Record) } + + const result = resolveConflict( + { + base: baseSnapshot as never, + remote: remote as never, + local: local as never, + mutationKey: [databaseId, collectionId, documentId], + }, + conflictStrategy, + ) + + if (result === 'abort') { + await queryClient.setQueryData( + Keys.database(databaseId).collection(collectionId).document(documentId).key(), + remote, + ) + return { _id: documentId } + } + + resolvedData = result + } + + const { data, errors } = await client.graphql.mutation({ + query: updateDocument, + variables: { + ...variables, + data: JSON.stringify(resolvedData.data ?? resolvedData), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + }) + + const newData = (resolvedData.data ?? resolvedData) as Record + delete variables.data + + const thing = { ...baseSnapshot, ...newData, _id: documentId } + + await queryClient.setQueryData( + Keys.database(databaseId).collection(collectionId).document(documentId).key(), + (old: Record | undefined) => + old + ? { + ...old, + ...thing, + } + : old, + ) + + if (errors) throw errors + return (data as Vars).databasesUpdateDocument as { _id: string } + } +} diff --git a/src/offline/mutations/registry.ts b/src/offline/mutations/registry.ts index adf38d0..12046af 100644 --- a/src/offline/mutations/registry.ts +++ b/src/offline/mutations/registry.ts @@ -1,6 +1,7 @@ import type { QueryClient } from '@tanstack/react-query' import type { TadaDocumentNode } from 'gql.tada' +import { conflictAwareUpdate } from './conflictAwareUpdate' import { accountUpdateEmail } from '../../account/useUpdateEmail' import { accountUpdateName } from '../../account/useUpdateName' import { updatePassword } from '../../account/useUpdatePassword' @@ -11,7 +12,6 @@ import { createDocument } from '../../databases/useCreateDocument' import { decrementDocumentAttribute } from '../../databases/useDecrementAttribute' import { deleteDocument } from '../../databases/useDeleteDocument' import { incrementDocumentAttribute } from '../../databases/useIncrementAttribute' -import { updateDocument } from '../../databases/useUpdateDocument' import { upsertDocument } from '../../databases/useUpsertDocument' import { createSubscriber } from '../../messaging/useCreateSubscriber' import { deleteSubscriber } from '../../messaging/useDeleteSubscriber' @@ -23,9 +23,8 @@ import { deleteTeam } from '../../teams/useDeleteTeam' import { updateMembership } from '../../teams/useUpdateMembership' import { updateTeamName } from '../../teams/useUpdateTeamName' import { updateTeamPrefs } from '../../teams/useUpdateTeamPrefs' - -type Vars = Record -type MutationFn = (client: AppwriteClient, variables: Vars) => Promise +import type { ConflictStrategy } from '../conflictResolution/types' +import type { MutationFn, Vars } from '../types' /** * Creates a mutationFn that executes a GraphQL mutation and returns the @@ -59,10 +58,8 @@ export const mutationRegistry: MutationEntry[] = [ mutationKey: Keys.databases().collections().documents().create(), mutationFn: gqlMutation(createDocument, 'databasesCreateDocument', { serializeData: true }), }, - { - mutationKey: Keys.databases().collections().documents().update(), - mutationFn: gqlMutation(updateDocument, 'databasesUpdateDocument', { serializeData: true }), - }, + // The update document entry is registered separately by hydrateMutationDefaults + // so it can be configured with the user's conflict resolution strategy. { mutationKey: Keys.databases().collections().documents().delete(), mutationFn: gqlMutation(deleteDocument, 'databasesDeleteDocument'), @@ -71,7 +68,6 @@ export const mutationRegistry: MutationEntry[] = [ mutationKey: Keys.databases().collections().documents().upsert(), mutationFn: gqlMutation(upsertDocument, 'databasesUpsertDocument', { serializeData: true }), }, - { mutationKey: [...Keys.databases().transactions().operations().key(), 'incrementAttribute'], mutationFn: gqlMutation(incrementDocumentAttribute, 'databasesIncrementDocumentAttribute'), @@ -143,10 +139,22 @@ export const mutationRegistry: MutationEntry[] = [ * Call once during app initialization, before rehydrating the persisted * mutation cache. */ -export function hydrateMutationDefaults(queryClient: QueryClient, client: AppwriteClient) { +export function hydrateMutationDefaults( + queryClient: QueryClient, + client: AppwriteClient, + options?: { conflictStrategy?: ConflictStrategy }, +) { for (const entry of mutationRegistry) { queryClient.setMutationDefaults(entry.mutationKey, { - mutationFn: (variables: Vars) => entry.mutationFn(client, variables), + mutationFn: (variables: Vars) => entry.mutationFn(client, variables, queryClient), + scope: { id: 'appwrite' }, }) } + + // Register the update document mutation with conflict resolution awareness + const strategy = options?.conflictStrategy ?? 'last-write-wins' + queryClient.setMutationDefaults(Keys.databases().collections().documents().update(), { + mutationFn: (variables: Vars) => conflictAwareUpdate(strategy)(client, variables, queryClient), + scope: { id: 'appwrite' }, + }) } diff --git a/src/offline/types.ts b/src/offline/types.ts index cc3b27e..04f06d5 100644 --- a/src/offline/types.ts +++ b/src/offline/types.ts @@ -1,3 +1,14 @@ +import type { QueryClient } from '@tanstack/react-query' + +import type { AppwriteClient } from '../client' + export type NetworkAdapter = { listen: (callback: (isOnline: boolean) => void) => () => void } + +export type Vars = Record +export type MutationFn = ( + client: AppwriteClient, + variables: Vars, + queryClient: QueryClient, +) => Promise diff --git a/src/query/QueryBuilder.ts b/src/query/QueryBuilder.ts index bd428be..a761176 100644 --- a/src/query/QueryBuilder.ts +++ b/src/query/QueryBuilder.ts @@ -210,14 +210,14 @@ export class QueryBuilder> { return this } - or(...queries: QueryBuilder[]): this { - const orQueries = queries.flatMap((q) => q.queries) + or(...queries: ((q: QueryBuilder) => QueryBuilder)[]): this { + const orQueries = queries.map((fn) => fn(new QueryBuilder()).queries).flat() this.queries.push(Query.or(orQueries)) return this } - and(...queries: QueryBuilder[]): this { - const andQueries = queries.flatMap((q) => q.queries) + and(...queries: ((q: QueryBuilder) => QueryBuilder)[]): this { + const andQueries = queries.map((fn) => fn(new QueryBuilder()).queries).flat() this.queries.push(Query.and(andQueries)) return this } diff --git a/test.ts b/test.ts deleted file mode 100644 index 8b5dbb3..0000000 --- a/test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useDocument } from './src' - -type Person = { - name: string - age: number -} - -const person = useDocument( - { - databaseId: 'db1', - collectionId: 'col1', - documentId: 'doc1', - fields: ['name', 'age'], - }, - { - staleTime: 1000 * 60, // 1 minute - }, -) diff --git a/tests/.env b/tests/.env index 2d0d6ac..8d6f1c0 100644 --- a/tests/.env +++ b/tests/.env @@ -56,8 +56,8 @@ _APP_COMPUTE_MEMORY=0 _APP_FUNCTIONS_RUNTIMES=node-22 _APP_SITES_RUNTIMES=static-1 _APP_EXECUTOR_SECRET=executor-secret-key -_APP_EXECUTOR_HOST=http://exc1/v1 -_APP_EXECUTOR_RUNTIME_NETWORK=appwrite_runtimes +_APP_EXECUTOR_HOST=http://openruntimes-executor/v1 +_APP_EXECUTOR_RUNTIME_NETWORK=runtimes _APP_COMPUTE_RUNTIMES_NETWORK=runtimes _APP_COMPUTE_SIZE_LIMIT=30000000 _APP_COMPUTE_INACTIVE_THRESHOLD=60 diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index a47e961..ffd1cce 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -5,6 +5,14 @@ x-logging: &x-logging max-file: "5" max-size: "10m" +x-appwrite-service: &appwrite-service + image: appwrite/appwrite:1.8.1 + restart: unless-stopped + networks: + - appwrite + env_file: .env + <<: *x-logging + services: traefik: image: traefik:2.11 @@ -33,12 +41,8 @@ services: - appwrite appwrite: - image: appwrite/appwrite:1.8.1 + <<: *appwrite-service container_name: appwrite - <<: *x-logging - restart: unless-stopped - networks: - - appwrite labels: - traefik.enable=true - traefik.constraint-label-stack=appwrite @@ -63,96 +67,13 @@ services: depends_on: - mariadb - redis - environment: - - _APP_ENV - - _APP_WORKER_PER_CORE - - _APP_LOCALE - - _APP_COMPRESSION_MIN_SIZE_BYTES - - _APP_CONSOLE_WHITELIST_ROOT - - _APP_CONSOLE_WHITELIST_EMAILS - - _APP_CONSOLE_SESSION_ALERTS - - _APP_CONSOLE_WHITELIST_IPS - - _APP_CONSOLE_HOSTNAMES - - _APP_SYSTEM_EMAIL_NAME - - _APP_SYSTEM_EMAIL_ADDRESS - - _APP_EMAIL_SECURITY - - _APP_SYSTEM_RESPONSE_FORMAT - - _APP_OPTIONS_ABUSE - - _APP_OPTIONS_ROUTER_PROTECTION - - _APP_OPTIONS_FORCE_HTTPS - - _APP_OPTIONS_ROUTER_FORCE_HTTPS - - _APP_OPENSSL_KEY_V1 - - _APP_DOMAIN - - _APP_DOMAIN_TARGET_CNAME - - _APP_DOMAIN_TARGET_AAAA - - _APP_DOMAIN_TARGET_A - - _APP_DOMAIN_TARGET_CAA - - _APP_DNS - - _APP_DOMAIN_FUNCTIONS - - _APP_REDIS_HOST - - _APP_REDIS_PORT - - _APP_REDIS_USER - - _APP_REDIS_PASS - - _APP_DB_HOST - - _APP_DB_PORT - - _APP_DB_SCHEMA - - _APP_DB_USER - - _APP_DB_PASS - - _APP_SMTP_HOST - - _APP_SMTP_PORT - - _APP_SMTP_SECURE - - _APP_SMTP_USERNAME - - _APP_SMTP_PASSWORD - - _APP_USAGE_STATS - - _APP_STORAGE_LIMIT - - _APP_STORAGE_PREVIEW_LIMIT - - _APP_STORAGE_ANTIVIRUS - - _APP_STORAGE_ANTIVIRUS_HOST - - _APP_STORAGE_ANTIVIRUS_PORT - - _APP_STORAGE_DEVICE - - _APP_COMPUTE_SIZE_LIMIT - - _APP_FUNCTIONS_TIMEOUT - - _APP_SITES_TIMEOUT - - _APP_COMPUTE_BUILD_TIMEOUT - - _APP_COMPUTE_CPUS - - _APP_COMPUTE_MEMORY - - _APP_FUNCTIONS_RUNTIMES - - _APP_SITES_RUNTIMES - - _APP_DOMAIN_SITES - - _APP_EXECUTOR_SECRET - - _APP_EXECUTOR_HOST - - _APP_LOGGING_CONFIG - - _APP_MAINTENANCE_INTERVAL - - _APP_MAINTENANCE_DELAY - - _APP_MAINTENANCE_START_TIME - - _APP_MAINTENANCE_RETENTION_EXECUTION - - _APP_MAINTENANCE_RETENTION_CACHE - - _APP_MAINTENANCE_RETENTION_ABUSE - - _APP_MAINTENANCE_RETENTION_AUDIT - - _APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE - - _APP_MAINTENANCE_RETENTION_USAGE_HOURLY - - _APP_MAINTENANCE_RETENTION_SCHEDULES - - _APP_GRAPHQL_MAX_BATCH_SIZE - - _APP_GRAPHQL_MAX_COMPLEXITY - - _APP_GRAPHQL_MAX_DEPTH - - _APP_SMS_PROVIDER - - _APP_SMS_FROM - - _APP_VCS_GITHUB_APP_NAME - - _APP_VCS_GITHUB_PRIVATE_KEY - - _APP_VCS_GITHUB_APP_ID - - _APP_VCS_GITHUB_WEBHOOK_SECRET - - _APP_VCS_GITHUB_CLIENT_SECRET - - _APP_VCS_GITHUB_CLIENT_ID - - _APP_MIGRATIONS_FIREBASE_CLIENT_ID - - _APP_MIGRATIONS_FIREBASE_CLIENT_SECRET - - _APP_ASSISTANT_OPENAI_API_KEY + extra_hosts: + - "host.docker.internal:host-gateway" appwrite-realtime: - image: appwrite/appwrite:1.8.1 + <<: *appwrite-service entrypoint: realtime container_name: appwrite-realtime - <<: *x-logging - restart: unless-stopped labels: - traefik.enable=true - traefik.constraint-label-stack=appwrite @@ -165,89 +86,32 @@ services: - traefik.http.routers.appwrite_realtime_wss.rule=PathPrefix(`/v1/realtime`) - traefik.http.routers.appwrite_realtime_wss.service=appwrite_realtime - traefik.http.routers.appwrite_realtime_wss.tls=true - networks: - - appwrite depends_on: - mariadb - redis - environment: - - _APP_ENV - - _APP_WORKER_PER_CORE - - _APP_OPTIONS_ABUSE - - _APP_OPTIONS_ROUTER_PROTECTION - - _APP_OPENSSL_KEY_V1 - - _APP_REDIS_HOST - - _APP_REDIS_PORT - - _APP_REDIS_USER - - _APP_REDIS_PASS - - _APP_DB_HOST - - _APP_DB_PORT - - _APP_DB_SCHEMA - - _APP_DB_USER - - _APP_DB_PASS - - _APP_USAGE_STATS - - _APP_LOGGING_CONFIG + extra_hosts: + - "host.docker.internal:host-gateway" appwrite-worker-audits: - image: appwrite/appwrite:1.8.1 + <<: *appwrite-service entrypoint: worker-audits container_name: appwrite-worker-audits - <<: *x-logging - restart: unless-stopped - networks: - - appwrite depends_on: - redis - mariadb - environment: - - _APP_ENV - - _APP_WORKER_PER_CORE - - _APP_OPENSSL_KEY_V1 - - _APP_REDIS_HOST - - _APP_REDIS_PORT - - _APP_REDIS_USER - - _APP_REDIS_PASS - - _APP_DB_HOST - - _APP_DB_PORT - - _APP_DB_SCHEMA - - _APP_DB_USER - - _APP_DB_PASS - - _APP_LOGGING_CONFIG appwrite-worker-databases: - image: appwrite/appwrite:1.8.1 + <<: *appwrite-service entrypoint: worker-databases container_name: appwrite-worker-databases - <<: *x-logging - restart: unless-stopped - networks: - - appwrite depends_on: - redis - mariadb - environment: - - _APP_ENV - - _APP_WORKER_PER_CORE - - _APP_OPENSSL_KEY_V1 - - _APP_REDIS_HOST - - _APP_REDIS_PORT - - _APP_REDIS_USER - - _APP_REDIS_PASS - - _APP_DB_HOST - - _APP_DB_PORT - - _APP_DB_SCHEMA - - _APP_DB_USER - - _APP_DB_PASS - - _APP_LOGGING_CONFIG appwrite-worker-deletes: - image: appwrite/appwrite:1.8.1 + <<: *appwrite-service entrypoint: worker-deletes container_name: appwrite-worker-deletes - <<: *x-logging - restart: unless-stopped - networks: - - appwrite depends_on: - redis - mariadb @@ -258,96 +122,25 @@ services: - appwrite-sites:/storage/sites:rw - appwrite-builds:/storage/builds:rw - appwrite-certificates:/storage/certificates:rw - environment: - - _APP_ENV - - _APP_WORKER_PER_CORE - - _APP_OPENSSL_KEY_V1 - - _APP_REDIS_HOST - - _APP_REDIS_PORT - - _APP_REDIS_USER - - _APP_REDIS_PASS - - _APP_DB_HOST - - _APP_DB_PORT - - _APP_DB_SCHEMA - - _APP_DB_USER - - _APP_DB_PASS - - _APP_LOGGING_CONFIG - - _APP_EXECUTOR_SECRET - - _APP_EXECUTOR_HOST - - _APP_STORAGE_DEVICE - - _APP_MAINTENANCE_RETENTION_ABUSE - - _APP_MAINTENANCE_RETENTION_AUDIT - - _APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE - - _APP_MAINTENANCE_RETENTION_EXECUTION - - _APP_SYSTEM_SECURITY_EMAIL_ADDRESS - - _APP_EMAIL_CERTIFICATES appwrite-worker-mails: - image: appwrite/appwrite:1.8.1 + <<: *appwrite-service entrypoint: worker-mails container_name: appwrite-worker-mails - <<: *x-logging - restart: unless-stopped - networks: - - appwrite depends_on: - redis - environment: - - _APP_ENV - - _APP_WORKER_PER_CORE - - _APP_OPENSSL_KEY_V1 - - _APP_SYSTEM_EMAIL_NAME - - _APP_SYSTEM_EMAIL_ADDRESS - - _APP_DB_HOST - - _APP_DB_PORT - - _APP_DB_SCHEMA - - _APP_DB_USER - - _APP_DB_PASS - - _APP_REDIS_HOST - - _APP_REDIS_PORT - - _APP_REDIS_USER - - _APP_REDIS_PASS - - _APP_SMTP_HOST - - _APP_SMTP_PORT - - _APP_SMTP_SECURE - - _APP_SMTP_USERNAME - - _APP_SMTP_PASSWORD - - _APP_LOGGING_CONFIG - - _APP_DOMAIN - - _APP_OPTIONS_FORCE_HTTPS + extra_hosts: + - "host.docker.internal:host-gateway" appwrite-worker-messaging: - image: appwrite/appwrite:1.8.1 + <<: *appwrite-service entrypoint: worker-messaging container_name: appwrite-worker-messaging - <<: *x-logging - restart: unless-stopped - networks: - - appwrite depends_on: - redis - mariadb - environment: - - _APP_ENV - - _APP_WORKER_PER_CORE - - _APP_OPENSSL_KEY_V1 - - _APP_DB_HOST - - _APP_DB_PORT - - _APP_DB_SCHEMA - - _APP_DB_USER - - _APP_DB_PASS - - _APP_REDIS_HOST - - _APP_REDIS_PORT - - _APP_REDIS_USER - - _APP_REDIS_PASS - - _APP_SMTP_HOST - - _APP_SMTP_PORT - - _APP_SMTP_SECURE - - _APP_SMTP_USERNAME - - _APP_SMTP_PASSWORD - - _APP_LOGGING_CONFIG - - _APP_SMS_PROVIDER - - _APP_SMS_FROM + extra_hosts: + - "host.docker.internal:host-gateway" openruntimes-executor: container_name: openruntimes-executor @@ -367,6 +160,12 @@ services: # Host mount nessessary to share files between executor and runtimes. # It's not possible to share mount file between 2 containers without host mount (copying is too slow) - /tmp:/tmp:rw + healthcheck: + test: ["CMD-SHELL", "curl -so /dev/null http://localhost/v1/health || exit 1"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s environment: - OPR_EXECUTOR_IMAGE_PULL=enabled - OPR_EXECUTOR_INACTIVE_TRESHOLD=$_APP_COMPUTE_INACTIVE_THRESHOLD @@ -404,11 +203,8 @@ services: appwrite-worker-builds: entrypoint: worker-builds - <<: *x-logging + <<: *appwrite-service container_name: appwrite-worker-builds - image: appwrite/appwrite:1.8.1 - networks: - - appwrite volumes: - appwrite-functions:/storage/functions:rw - appwrite-sites:/storage/sites:rw @@ -417,104 +213,26 @@ services: # - ./app:/usr/src/code/app # - ./src:/usr/src/code/src depends_on: - - redis - - mariadb - environment: - - _APP_BROWSER_HOST - - _APP_ENV - - _APP_WORKER_PER_CORE - - _APP_OPENSSL_KEY_V1 - - _APP_EXECUTOR_SECRET - - _APP_EXECUTOR_HOST - - _APP_REDIS_HOST - - _APP_REDIS_PORT - - _APP_REDIS_USER - - _APP_REDIS_PASS - - _APP_DB_HOST - - _APP_DB_PORT - - _APP_DB_SCHEMA - - _APP_DB_USER - - _APP_DB_PASS - - _APP_LOGGING_CONFIG - - _APP_VCS_GITHUB_APP_NAME - - _APP_VCS_GITHUB_PRIVATE_KEY - - _APP_VCS_GITHUB_APP_ID - - _APP_FUNCTIONS_TIMEOUT - - _APP_SITES_TIMEOUT - - _APP_COMPUTE_BUILD_TIMEOUT - - _APP_COMPUTE_CPUS - - _APP_COMPUTE_MEMORY - - _APP_COMPUTE_SIZE_LIMIT - - _APP_OPTIONS_FORCE_HTTPS - - _APP_OPTIONS_ROUTER_FORCE_HTTPS - - _APP_DOMAIN - - _APP_CONSOLE_DOMAIN - - _APP_STORAGE_DEVICE - - _APP_STORAGE_S3_ACCESS_KEY - - _APP_STORAGE_S3_SECRET - - _APP_STORAGE_S3_REGION - - _APP_STORAGE_S3_BUCKET - - _APP_STORAGE_S3_ENDPOINT - - _APP_STORAGE_DO_SPACES_ACCESS_KEY - - _APP_STORAGE_DO_SPACES_SECRET - - _APP_STORAGE_DO_SPACES_REGION - - _APP_STORAGE_DO_SPACES_BUCKET - - _APP_STORAGE_BACKBLAZE_ACCESS_KEY - - _APP_STORAGE_BACKBLAZE_SECRET - - _APP_STORAGE_BACKBLAZE_REGION - - _APP_STORAGE_BACKBLAZE_BUCKET - - _APP_STORAGE_LINODE_ACCESS_KEY - - _APP_STORAGE_LINODE_SECRET - - _APP_STORAGE_LINODE_REGION - - _APP_STORAGE_LINODE_BUCKET - - _APP_STORAGE_WASABI_ACCESS_KEY - - _APP_STORAGE_WASABI_SECRET - - _APP_STORAGE_WASABI_REGION - - _APP_STORAGE_WASABI_BUCKET - - _APP_DATABASE_SHARED_TABLES - - _APP_DOMAIN_SITES + redis: + condition: service_started + mariadb: + condition: service_started + openruntimes-executor: + condition: service_healthy extra_hosts: - "host.docker.internal:host-gateway" appwrite-worker-functions: entrypoint: worker-functions - <<: *x-logging + <<: *appwrite-service container_name: appwrite-worker-functions - image: appwrite/appwrite:1.8.1 - networks: - - appwrite depends_on: - - redis - - mariadb - - openruntimes-executor - environment: - - _APP_ENV - - _APP_WORKER_PER_CORE - - _APP_OPENSSL_KEY_V1 - - _APP_DOMAIN - - _APP_OPTIONS_FORCE_HTTPS - - _APP_REDIS_HOST - - _APP_REDIS_PORT - - _APP_REDIS_USER - - _APP_REDIS_PASS - - _APP_DB_HOST - - _APP_DB_PORT - - _APP_DB_SCHEMA - - _APP_DB_USER - - _APP_DB_PASS - - _APP_FUNCTIONS_TIMEOUT - - _APP_SITES_TIMEOUT - - _APP_COMPUTE_BUILD_TIMEOUT - - _APP_COMPUTE_CPUS - - _APP_COMPUTE_MEMORY - - _APP_EXECUTOR_SECRET - - _APP_EXECUTOR_HOST - - _APP_USAGE_STATS - - _APP_DOCKER_HUB_USERNAME - - _APP_DOCKER_HUB_PASSWORD - - _APP_LOGGING_CONFIG - - _APP_LOGGING_PROVIDER - - _APP_DATABASE_SHARED_TABLES + redis: + condition: service_started + mariadb: + condition: service_started + openruntimes-executor: + condition: service_healthy mariadb: image: mariadb:10.11 diff --git a/tests/functions/functions.test.ts b/tests/functions/functions.test.ts index 252ecd5..ee0c85e 100644 --- a/tests/functions/functions.test.ts +++ b/tests/functions/functions.test.ts @@ -1,5 +1,5 @@ import { act, renderHook, waitFor } from '@testing-library/react' -import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { afterAll, beforeAll, describe, expect, spyOn, test } from 'bun:test' import { useGetExecution, useListExecutions } from '../../src' import { useFunction, useSuspenseFunction } from '../../src/functions/useFunction' @@ -145,13 +145,21 @@ describe('Function hooks', () => { { timeout: 15_000 }, ) - test.skip( - 'handles errors and returns the error message', + test( + 'handles errors and catches them', async () => { - const wrapper = createWrapper({ suspense: true }) + const spy = spyOn(console, 'error').mockImplementation(() => {}) + + let caughtError: Error | null = null + const wrapper = createWrapper({ + suspense: true, + onError: (error) => { + caughtError = error + }, + }) await loginUser(userEmail, userPassword, wrapper) - const { result } = renderHook( + renderHook( () => useSuspenseFunction({ functionId: 'test-function', @@ -160,9 +168,9 @@ describe('Function hooks', () => { { wrapper }, ) - await waitFor(() => expect(result.current.executeFunction).toBeDefined()) + await waitFor(() => expect(caughtError).not.toBeNull(), { timeout: 10_000 }) - console.log(result.current.executeFunction) + spy.mockRestore() }, { timeout: 15_000 }, ) diff --git a/tests/messaging/subscriber.test.tsx b/tests/messaging/subscriber.test.tsx index 588ea7d..1fbd27d 100644 --- a/tests/messaging/subscriber.test.tsx +++ b/tests/messaging/subscriber.test.tsx @@ -22,6 +22,7 @@ describe('Subscriber hooks', () => { let userEmail: string let userPassword: string let targetId: string + const subscriberIdsToCleanup: string[] = [] beforeAll(async () => { const user = await createTestUser({ name: 'Subscriber Test User' }) @@ -32,6 +33,15 @@ describe('Subscriber hooks', () => { }) afterAll(async () => { + // Clean up any leftover subscribers + const { messaging } = createServerClient() + for (const subscriberId of subscriberIdsToCleanup) { + try { + await messaging.deleteSubscriber({ topicId: TOPIC_ID, subscriberId }) + } catch { + // Already deleted or doesn't exist + } + } await deleteTestUser(userId) }) @@ -47,6 +57,7 @@ describe('Subscriber hooks', () => { const { result } = renderHook(() => useCreateSubscriber(), { wrapper }) const subscriberId = ID.unique() + subscriberIdsToCleanup.push(subscriberId) await act(async () => { await result.current.mutateAsync({ @@ -76,6 +87,7 @@ describe('Subscriber hooks', () => { const { result } = renderHook(() => useCreateSubscriber(), { wrapper }) const subscriberId = ID.unique() + subscriberIdsToCleanup.push(subscriberId) await act(async () => { await result.current.mutateAsync({ @@ -120,6 +132,7 @@ describe('Subscriber hooks', () => { const { result: createResult } = renderHook(() => useCreateSubscriber(), { wrapper }) const subscriberId = ID.unique() + subscriberIdsToCleanup.push(subscriberId) await act(async () => { await createResult.current.mutateAsync({ @@ -155,6 +168,7 @@ describe('Subscriber hooks', () => { const { result: createResult } = renderHook(() => useCreateSubscriber(), { wrapper }) const subscriberId = ID.unique() + subscriberIdsToCleanup.push(subscriberId) await act(async () => { await createResult.current.mutateAsync({ diff --git a/tests/offline/registry.test.ts b/tests/offline/registry.test.ts index 3f07590..1231f0c 100644 --- a/tests/offline/registry.test.ts +++ b/tests/offline/registry.test.ts @@ -92,7 +92,11 @@ describe('Mutation Registry', () => { }) test('mutations save to localStorage when offline', async () => { - const { appwrite, queryClient, persister } = createOfflineClient({ + // Clear any persisted data from the previous test so this test + // starts with an empty cache (prevents stale mutations from leaking). + localStorage.removeItem('appwrite-graphql-offline-cache') + + const offlineClient = createOfflineClient({ endpoint: config.endpoint, projectId: config.projectId, networkAdapter: { @@ -102,15 +106,19 @@ describe('Mutation Registry', () => { throttleTime: 0, // Disable throttling for testing }) + const { appwrite, queryClient } = offlineClient + queryClient.setDefaultOptions({ mutations: { networkMode: 'online' }, }) - const wrapper = createWrapper({ - client: appwrite, - queryClient, - persister, - }) + // Use a wrapper WITHOUT the persister to avoid each renderHook call + // creating a new PersistQueryClientProvider that re-restores from + // localStorage and duplicates mutations. + const wrapper = createWrapper({ client: appwrite, queryClient }) + + // Start persistence imperatively (single restore point) + const { unsubscribe: unsubscribePersister } = offlineClient.startPersistence() await loginUser(userEmail, userPassword, wrapper) onlineManager.setOnline(false) @@ -145,7 +153,7 @@ describe('Mutation Registry', () => { await waitFor(() => expect(updateResult.current.isPaused).toBe(true)) await new Promise((r) => setTimeout(r, 50)) // Wait for localStorage to update - const offlineCache = localStorage.getItem('appwrite-graphql-offline-cache') // For debugging: check the persisted mutations + const offlineCache = localStorage.getItem('appwrite-graphql-offline-cache') expect(offlineCache).toBeTruthy() const parsedCache = JSON.parse(offlineCache!) @@ -154,28 +162,175 @@ describe('Mutation Registry', () => { ) expect(mutationKeys).toContain(Keys.databases().collections().documents().create().join('.')) expect(mutationKeys).toContain(Keys.databases().collections().documents().update().join('.')) + + // Unsubscribe the persister FIRST (so clearing doesn't overwrite localStorage), + // then tear down the queryClient so its paused mutations' retryers don't + // resume when onlineManager goes online in the next test. + unsubscribePersister() + queryClient.unmount() + + // Replace each paused mutation's mutationFn with a no-op so that when the + // retryer's internal onlineManager subscription fires later, the zombie + // mutations complete harmlessly instead of making real HTTP requests. + for (const m of queryClient.getMutationCache().getAll()) { + m.setOptions({ ...m.options, mutationFn: () => Promise.resolve(undefined) as any }) + } + queryClient.getMutationCache().clear() }) - test('mutations are replayed when app restarts', async () => { - onlineManager.setOnline(true) + test( + 'mutations are replayed when app restarts', + async () => { + onlineManager.setOnline(true) - const { appwrite, queryClient, persister } = createOfflineClient({ + const offlineClient = createOfflineClient({ + endpoint: config.endpoint, + projectId: config.projectId, + networkAdapter: { + listen: mock(), + }, + storage: localStorage, + }) + + const { appwrite, queryClient } = offlineClient + + // Clear stale session from previous test so we can create a fresh one + if (typeof globalThis.localStorage !== 'undefined') { + globalThis.localStorage.removeItem('cookieFallback') + } + + await appwrite.account.createEmailPasswordSession({ + email: userEmail, + password: userPassword, + }) + + // Restore persisted cache and kick off mutation replay before rendering + const { restored } = offlineClient.startPersistence() + await restored + + // Wait for replayed mutations to fully settle + await waitFor( + () => { + const mutations = queryClient.getMutationCache().getAll() + const unfinished = mutations.filter( + (m) => m.state.status === 'pending' || m.state.isPaused, + ) + expect(unfinished.length).toBe(0) + }, + { timeout: 12_000 }, + ) + + // Now query the document — no persister on the wrapper since + // persistence is already handled imperatively above. + const wrapper = createWrapper({ client: appwrite, queryClient }) + + const { result: getResult } = renderHook( + () => + useDocument<{ + name: string + age: number + }>({ + databaseId, + collectionId, + documentId: documentCreatedOfflineId, + }), + { wrapper }, + ) + + await waitFor(() => expect(getResult.current.data).toBeTruthy(), { timeout: 6_000 }) + expect(getResult.current.data?.name).toBe('Test Document') + expect(getResult.current.data?.age).toBe(26) + + createdDocumentIds.push(documentCreatedOfflineId) + }, + { timeout: 20_000 }, + ) + + test('conflict resolution: server-wins strategy', async () => { + // This test covers the conflict-aware update mutation function registered in the registry. + // It simulates a conflict scenario and verifies that the "server wins" (remote) wins. + + const offlineClient = createOfflineClient({ endpoint: config.endpoint, projectId: config.projectId, networkAdapter: { listen: mock(), }, storage: localStorage, + conflictStrategy: 'server-wins', + throttleTime: 0, // Disable throttling for testing }) - const wrapper = createWrapper({ - client: appwrite, - queryClient, - persister, + const { appwrite, queryClient } = offlineClient + + const options = queryClient.getDefaultOptions() + queryClient.setDefaultOptions({ + ...options, + mutations: { + ...options.mutations, + networkMode: 'online', + }, + }) + + const wrapper = createWrapper({ client: appwrite, queryClient }) + + await loginUser(userEmail, userPassword, wrapper) + + // Create a document online to have a base snapshot + const documentId = ID.unique() + + const { result: createResult } = renderHook(() => useCreateDocument(), { wrapper }) + + act(() => { + createResult.current.mutate({ + databaseId, + collectionId, + documentId, + data: { name: 'Original Name', age: 20 }, + }) + }) + + await waitFor(() => expect(createResult.current.isSuccess).toBe(true)) + + createdDocumentIds.push(documentId) + + // Start an update mutation while offline + onlineManager.setOnline(false) + + const { result: updateResult } = renderHook(() => useUpdateDocument(), { wrapper }) + + await act(async () => { + updateResult.current.mutate({ + databaseId, + collectionId, + documentId, + data: { name: 'Offline Update', age: 21 }, + }) + }) + + await waitFor(() => expect(updateResult.current.isPaused).toBe(true)) + + // Simulate a remote update that happens while we're offline + await appwrite.tablesDB.updateRow({ + databaseId, + tableId: collectionId, + rowId: documentId, + data: { + name: 'Remote Update', + age: 22, + }, + }) + + await new Promise((r) => setTimeout(r, 50)) // Wait for the remote update to be registered + + // Go back online and trigger the conflict resolution + act(() => { + onlineManager.setOnline(true) }) - await appwrite.account.createEmailPasswordSession({ email: userEmail, password: userPassword }) + await waitFor(() => expect(updateResult.current.isSuccess).toBe(true)) + // Fetch the document to verify the final state const { result: getResult } = renderHook( () => useDocument<{ @@ -184,34 +339,223 @@ describe('Mutation Registry', () => { }>({ databaseId, collectionId, - documentId: documentCreatedOfflineId, + documentId, }), { wrapper }, ) - // Wait for the document to exist (create mutation replayed) - await waitFor(() => expect(getResult.current.data).toBeTruthy(), { timeout: 12_000 }) - expect(getResult.current.data?.name).toBe('Test Document') - - // Wait for all persisted mutations to finish replaying (including the update) - await waitFor( - () => { - const pending = queryClient - .getMutationCache() - .getAll() - .filter((m) => m.state.status === 'pending' || m.state.isPaused) - expect(pending.length).toBe(0) + await waitFor(() => expect(getResult.current.data).toBeTruthy()) + + // With "server-wins", the remote update should win over the offline update + expect(getResult.current.data?.name).toBe('Remote Update') + expect(getResult.current.data?.age).toBe(22) + }) + + test('conflict resolution: last-write-wins strategy', async () => { + // This test covers the conflict-aware update mutation function registered in the registry. + // It simulates a conflict scenario and verifies that the "last write" (any) wins. + + const offlineClient = createOfflineClient({ + endpoint: config.endpoint, + projectId: config.projectId, + networkAdapter: { + listen: mock(), + }, + storage: localStorage, + conflictStrategy: 'last-write-wins', + throttleTime: 0, // Disable throttling for testing + }) + + const { appwrite, queryClient } = offlineClient + + const options = queryClient.getDefaultOptions() + queryClient.setDefaultOptions({ + ...options, + mutations: { + ...options.mutations, + networkMode: 'online', }, - { timeout: 10_000 }, + }) + + const wrapper = createWrapper({ client: appwrite, queryClient }) + + await loginUser(userEmail, userPassword, wrapper) + + // Create a document online to have a base snapshot + const documentId = ID.unique() + + const { result: createResult } = renderHook(() => useCreateDocument(), { wrapper }) + + act(() => { + createResult.current.mutate({ + databaseId, + collectionId, + documentId, + data: { name: 'Original Name', age: 20 }, + }) + }) + + await waitFor(() => expect(createResult.current.isSuccess).toBe(true)) + + createdDocumentIds.push(documentId) + + // Start an update mutation while offline + onlineManager.setOnline(false) + + const { result: updateResult } = renderHook(() => useUpdateDocument(), { wrapper }) + + await act(async () => { + updateResult.current.mutate({ + databaseId, + collectionId, + documentId, + data: { name: 'Offline Update', age: 21 }, + }) + }) + + await waitFor(() => expect(updateResult.current.isPaused).toBe(true)) + + // Simulate a remote update that happens while we're offline + await appwrite.tablesDB.updateRow({ + databaseId, + tableId: collectionId, + rowId: documentId, + data: { + name: 'Remote Update', + age: 22, + }, + }) + + await new Promise((r) => setTimeout(r, 50)) // Wait for the remote update to be registered + + // Go back online and trigger the conflict resolution + act(() => { + onlineManager.setOnline(true) + }) + + await waitFor(() => expect(updateResult.current.isSuccess).toBe(true)) + + // Fetch the document to verify the final state + const { result: getResult } = renderHook( + () => + useDocument<{ + name: string + age: number + }>({ + databaseId, + collectionId, + documentId, + }), + { wrapper }, ) - // Refetch after mutations complete — replayed mutations lack onSuccess invalidation + await waitFor(() => expect(getResult.current.data).toBeTruthy()) + + // With "last-write-wins", the last update (offline or remote) should win + expect(getResult.current.data?.name).toBe('Offline Update') + expect(getResult.current.data?.age).toBe(21) + }) + + test('conflict resolution: merge-shallow strategy', async () => { + // This test covers the conflict-aware update mutation function registered in the registry. + + const offlineClient = createOfflineClient({ + endpoint: config.endpoint, + projectId: config.projectId, + networkAdapter: { + listen: mock(), + }, + storage: localStorage, + conflictStrategy: 'merge-shallow', + throttleTime: 0, // Disable throttling for testing + }) + + const { appwrite, queryClient } = offlineClient + + const options = queryClient.getDefaultOptions() + queryClient.setDefaultOptions({ + ...options, + mutations: { + ...options.mutations, + networkMode: 'online', + }, + }) + + const wrapper = createWrapper({ client: appwrite, queryClient }) + + await loginUser(userEmail, userPassword, wrapper) + + // Create a document online to have a base snapshot + const documentId = ID.unique() + + const { result: createResult } = renderHook(() => useCreateDocument(), { wrapper }) + + act(() => { + createResult.current.mutate({ + databaseId, + collectionId, + documentId, + data: { name: 'Original Name', age: 20 }, + }) + }) + + await waitFor(() => expect(createResult.current.isSuccess).toBe(true)) + + createdDocumentIds.push(documentId) + + // Start an update mutation while offline + onlineManager.setOnline(false) + + const { result: updateResult } = renderHook(() => useUpdateDocument(), { wrapper }) + await act(async () => { - await queryClient.refetchQueries() + updateResult.current.mutate({ + databaseId, + collectionId, + documentId, + data: { name: 'Offline Update' }, + }) }) - await waitFor(() => expect(getResult.current.data?.age).toBe(26), { timeout: 5_000 }) + await waitFor(() => expect(updateResult.current.isPaused).toBe(true)) - createdDocumentIds.push(documentCreatedOfflineId) - }, { timeout: 20_000 }) + // Simulate a remote update that happens while we're offline + await appwrite.tablesDB.updateRow({ + databaseId, + tableId: collectionId, + rowId: documentId, + data: { + age: 22, + }, + }) + + await new Promise((r) => setTimeout(r, 50)) // Wait for the remote update to be registered + + // Go back online and trigger the conflict resolution + act(() => { + onlineManager.setOnline(true) + }) + + await waitFor(() => expect(updateResult.current.isSuccess).toBe(true)) + + // Fetch the document to verify the final state + const { result: getResult } = renderHook( + () => + useDocument<{ + name: string + age: number + }>({ + databaseId, + collectionId, + documentId, + }), + { wrapper }, + ) + + await waitFor(() => expect(getResult.current.data).toBeTruthy()) + + // With "merge-shallow", the local changes should be merged with the remote changes + expect(getResult.current.data?.name).toBe('Offline Update') + expect(getResult.current.data?.age).toBe(22) + }) }) diff --git a/tests/query/query-builder.test.ts b/tests/query/query-builder.test.ts index 9035735..0f12905 100644 --- a/tests/query/query-builder.test.ts +++ b/tests/query/query-builder.test.ts @@ -280,7 +280,10 @@ describe('QueryBuilder', () => { describe('logical composition', () => { test('or with two sub-builders', () => { const result = q() - .or(q().equal('name', 'Alice'), q().equal('name', 'Bob')) + .or( + (q) => q.equal('name', 'Alice'), + (q) => q.equal('name', 'Bob'), + ) .build() expect(result).toEqual([Query.or([Query.equal('name', 'Alice'), Query.equal('name', 'Bob')])]) @@ -288,7 +291,10 @@ describe('QueryBuilder', () => { test('and with two sub-builders', () => { const result = q() - .and(q().equal('name', 'Alice'), q().greaterThan('age', 18)) + .and( + (q) => q.equal('name', 'Alice'), + (q) => q.greaterThan('age', 18), + ) .build() expect(result).toEqual([ @@ -299,8 +305,8 @@ describe('QueryBuilder', () => { test('or with multiple conditions per sub-builder', () => { const result = q() .or( - q().equal('name', 'Alice').greaterThan('age', 30), - q().equal('name', 'Bob').lessThan('age', 20), + (q) => q.equal('name', 'Alice').greaterThan('age', 30), + (q) => q.equal('name', 'Bob').lessThan('age', 20), ) .build() @@ -317,8 +323,12 @@ describe('QueryBuilder', () => { test('nested or inside and', () => { const result = q() .and( - q().greaterThan('age', 18), - q().or(q().equal('name', 'Alice'), q().equal('name', 'Bob')), + (q) => q.greaterThan('age', 18), + (q) => + q.or( + (q) => q.equal('name', 'Alice'), + (q) => q.equal('name', 'Bob'), + ), ) .build() @@ -333,9 +343,7 @@ describe('QueryBuilder', () => { describe('elemMatch', () => { test('elemMatch with sub-query', () => { - const result = q() - .elemMatch('scores', q().greaterThan('age', 90)) - .build() + const result = q().elemMatch('scores', q().greaterThan('age', 90)).build() expect(result).toEqual([Query.elemMatch('scores', [Query.greaterThan('age', 90)])]) }) diff --git a/tests/setup/ErrorBoundry.tsx b/tests/setup/ErrorBoundry.tsx index ceadd08..e4ffb22 100644 --- a/tests/setup/ErrorBoundry.tsx +++ b/tests/setup/ErrorBoundry.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from 'react' interface ErrorBoundaryProps { children: ReactNode fallback: ReactNode + onError?: (error: Error) => void } interface ErrorBoundaryState { @@ -21,10 +22,8 @@ class ErrorBoundary extends Component { } // This lifecycle method is used for logging error information - componentDidCatch(error, info) { - // Example: Log the error to an error reporting service - // logErrorToMyService(error, info.componentStack); - console.error('Error caught by ErrorBoundary:', error, info) + componentDidCatch(error: Error) { + this.props.onError?.(error) } render() { diff --git a/tests/setup/setup.ts b/tests/setup/setup.ts index f7eb6cf..62ee88c 100644 --- a/tests/setup/setup.ts +++ b/tests/setup/setup.ts @@ -553,7 +553,29 @@ async function deployFunction(apiKey: string) { const deploymentId = deployment.$id console.log('Waiting for deployment to be ready...') - await new Promise((r) => setTimeout(r, 3000)) + + for (let i = 0; i < 30; i++) { + const checkDeploymentStatus = await fetch( + `${ENDPOINT}/functions/test-function/deployments/${deploymentId}`, + { + headers: { + 'X-Appwrite-Project': PROJECT_ID, + 'X-Appwrite-Key': apiKey, + }, + }, + ).then((r) => r.json()) + + if (checkDeploymentStatus.status === 'ready') { + console.log('Deployment is ready!') + break + } else if (checkDeploymentStatus.status === 'failed') { + console.error('Deployment failed:', checkDeploymentStatus) + throw new Error(`Deployment failed: ${checkDeploymentStatus.buildLogs}`) + } else { + console.log(`Attempt ${i + 1}/30 - status: ${checkDeploymentStatus.status}`) + await new Promise((r) => setTimeout(r, 5000)) + } + } console.log(`Deployment "${deploymentId}" created, activating...`) diff --git a/tests/setup/wrapper.tsx b/tests/setup/wrapper.tsx index fd146b3..a4f92c8 100644 --- a/tests/setup/wrapper.tsx +++ b/tests/setup/wrapper.tsx @@ -14,6 +14,7 @@ export function createWrapper(opts?: { suspense?: boolean client?: AppwriteClient persister?: Persister + onError?: (error: Error) => void }) { const config = getTestConfig() const queryClient = @@ -50,7 +51,7 @@ export function createWrapper(opts?: { if (opts?.suspense) { return ( - Error occurred}> + Error occurred} onError={opts?.onError}> Loading...}>{inner} )