@spooky-sync/client-solid 0.0.0-canary.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@spooky-sync/client-solid",
3
+ "version": "0.0.0-canary.1",
4
+ "type": "module",
5
+ "description": "SurrealDB client with local and remote database support for browser applications",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "scripts": {
18
+ "build": "tsdown",
19
+ "build:watch": "tsdown --watch",
20
+ "dev:example": "pnpm build:watch",
21
+ "dev": "tsdown --watch",
22
+ "test": "vitest",
23
+ "type-check": "tsc --noEmit"
24
+ },
25
+ "keywords": [
26
+ "surrealdb",
27
+ "database",
28
+ "browser",
29
+ "offline",
30
+ "sync"
31
+ ],
32
+ "author": "",
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/mono424/spooky.git",
37
+ "directory": "packages/client-solid"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "packageManager": "pnpm@9.0.0",
43
+ "dependencies": {
44
+ "@spooky-sync/query-builder": "workspace:*",
45
+ "@spooky-sync/core": "workspace:*",
46
+ "@surrealdb/wasm": "^3.0.0",
47
+ "surrealdb": "2.0.0",
48
+ "valtio": "^2.1.8"
49
+ },
50
+ "peerDependencies": {
51
+ "solid-js": "^1.x.x"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^20.0.0",
55
+ "solid-js": "^1.8.7",
56
+ "tsdown": "^0.12.4",
57
+ "typescript": "^5.3.0",
58
+ "vitest": "^1.0.0"
59
+ }
60
+ }
@@ -0,0 +1,41 @@
1
+ export { SurrealDBWasmFactory } from './surrealdb-wasm-factory';
2
+
3
+ import { Surreal } from 'surrealdb';
4
+ import type { CacheStrategy } from '../types';
5
+
6
+ /**
7
+ * Creates a SurrealDB WASM instance with the specified storage strategy
8
+ */
9
+ export async function createSurrealDBWasm(
10
+ dbName: string,
11
+ strategy: CacheStrategy,
12
+ namespace?: string,
13
+ database?: string
14
+ ): Promise<Surreal> {
15
+ const { SurrealDBWasmFactory } = await import('./surrealdb-wasm-factory');
16
+ return SurrealDBWasmFactory.create(dbName, strategy, namespace, database);
17
+ }
18
+
19
+ /**
20
+ * Creates a memory-based SurrealDB WASM instance
21
+ */
22
+ export async function createMemoryDB(
23
+ dbName: string,
24
+ namespace?: string,
25
+ database?: string
26
+ ): Promise<Surreal> {
27
+ const { SurrealDBWasmFactory } = await import('./surrealdb-wasm-factory');
28
+ return SurrealDBWasmFactory.createMemory(dbName, namespace, database);
29
+ }
30
+
31
+ /**
32
+ * Creates an IndexedDB-based SurrealDB WASM instance
33
+ */
34
+ export async function createIndexedDBDatabase(
35
+ dbName: string,
36
+ namespace?: string,
37
+ database?: string
38
+ ): Promise<Surreal> {
39
+ const { SurrealDBWasmFactory } = await import('./surrealdb-wasm-factory');
40
+ return SurrealDBWasmFactory.createIndexedDB(dbName, namespace, database);
41
+ }
@@ -0,0 +1,64 @@
1
+ import { Diagnostic, Surreal, applyDiagnostics } from 'surrealdb';
2
+ import { createWasmEngines } from '@surrealdb/wasm';
3
+ import type { CacheStrategy } from '../types';
4
+
5
+ const printDiagnostic = ({ key, type, phase, ...other }: Diagnostic) => {
6
+ if (phase === 'progress' || phase === 'after') {
7
+ console.log(`[SurrealDB_WASM] [${key}] ${type}:${phase}\n${JSON.stringify(other, null, 2)}`);
8
+ }
9
+ };
10
+
11
+ /**
12
+ * SurrealDB WASM client factory for different storage strategies
13
+ */
14
+ export class SurrealDBWasmFactory {
15
+ /**
16
+ * Creates a SurrealDB WASM instance with the specified storage strategy
17
+ */
18
+ static async create(
19
+ dbName: string,
20
+ strategy: CacheStrategy,
21
+ namespace?: string,
22
+ database?: string
23
+ ): Promise<Surreal> {
24
+ // Create Surreal instance with WASM engines
25
+ const surreal = new Surreal({
26
+ engines: applyDiagnostics(createWasmEngines(), printDiagnostic),
27
+ });
28
+
29
+ // Connect to the appropriate storage backend
30
+ const connectionUrl = strategy === 'indexeddb' ? `indxdb://${dbName}` : 'mem://';
31
+
32
+ await surreal.connect(connectionUrl);
33
+
34
+ // Set namespace and database
35
+ await surreal.use({
36
+ namespace: namespace || 'main',
37
+ database: database || dbName,
38
+ });
39
+
40
+ return surreal;
41
+ }
42
+
43
+ /**
44
+ * Creates a memory-based SurrealDB WASM instance
45
+ */
46
+ static async createMemory(
47
+ dbName: string,
48
+ namespace?: string,
49
+ database?: string
50
+ ): Promise<Surreal> {
51
+ return this.create(dbName, 'memory', namespace, database);
52
+ }
53
+
54
+ /**
55
+ * Creates an IndexedDB-based SurrealDB WASM instance
56
+ */
57
+ static async createIndexedDB(
58
+ dbName: string,
59
+ namespace?: string,
60
+ database?: string
61
+ ): Promise<Surreal> {
62
+ return this.create(dbName, 'indexeddb', namespace, database);
63
+ }
64
+ }
package/src/index.ts ADDED
@@ -0,0 +1,254 @@
1
+ import type { SyncedDbConfig } from './types';
2
+ import {
3
+ SpookyClient,
4
+ AuthService,
5
+ type SpookyQueryResultPromise,
6
+ UpdateOptions,
7
+ RunOptions,
8
+ } from '@spooky-sync/core';
9
+
10
+ import {
11
+ GetTable,
12
+ QueryBuilder,
13
+ SchemaStructure,
14
+ TableModel,
15
+ TableNames,
16
+ QueryResult,
17
+ RelatedFieldsMap,
18
+ RelationshipFieldsFromSchema,
19
+ GetRelationship,
20
+ RelatedFieldMapEntry,
21
+ InnerQuery,
22
+ BackendNames,
23
+ BackendRoutes,
24
+ RoutePayload,
25
+ } from '@spooky-sync/query-builder';
26
+
27
+ import { RecordId, Uuid, Surreal } from 'surrealdb';
28
+ export { RecordId, Uuid };
29
+ export type { Model, GenericModel, GenericSchema, ModelPayload } from './lib/models';
30
+ export { useQuery } from './lib/use-query';
31
+ export { SpookyProvider, type SpookyProviderProps } from './lib/SpookyProvider';
32
+ export { useDb } from './lib/context';
33
+
34
+ // export { AuthEventTypes } from "@spooky-sync/core"; // TODO: Verify if AuthEventTypes exists in core
35
+ export type {};
36
+
37
+ // Re-export query builder types for convenience
38
+ export type {
39
+ QueryModifier,
40
+ QueryModifierBuilder,
41
+ QueryInfo,
42
+ RelationshipsMetadata,
43
+ RelationshipDefinition,
44
+ InferRelatedModelFromMetadata,
45
+ GetCardinality,
46
+ GetTable,
47
+ TableModel,
48
+ TableNames,
49
+ QueryResult,
50
+ } from '@spooky-sync/query-builder';
51
+
52
+ export type RelationshipField<
53
+ Schema extends SchemaStructure,
54
+ TableName extends TableNames<Schema>,
55
+ Field extends RelationshipFieldsFromSchema<Schema, TableName>,
56
+ > = GetRelationship<Schema, TableName, Field>;
57
+
58
+ export type RelatedFieldsTableScoped<
59
+ Schema extends SchemaStructure,
60
+ TableName extends TableNames<Schema>,
61
+ RelatedFields extends RelationshipFieldsFromSchema<Schema, TableName> =
62
+ RelationshipFieldsFromSchema<Schema, TableName>,
63
+ > = {
64
+ [K in RelatedFields]: {
65
+ to: RelationshipField<Schema, TableName, K>['to'];
66
+ relatedFields: RelatedFieldsMap;
67
+ cardinality: RelationshipField<Schema, TableName, K>['cardinality'];
68
+ };
69
+ };
70
+
71
+ export type InferModel<
72
+ Schema extends SchemaStructure,
73
+ TableName extends TableNames<Schema>,
74
+ RelatedFields extends RelatedFieldsTableScoped<Schema, TableName>,
75
+ > = QueryResult<Schema, TableName, RelatedFields, true>;
76
+
77
+ export type WithRelated<Field extends string, RelatedFields extends RelatedFieldsMap = {}> = {
78
+ [K in Field]: Omit<RelatedFieldMapEntry, 'relatedFields'> & {
79
+ relatedFields: RelatedFields;
80
+ };
81
+ };
82
+
83
+ export type WithRelatedMany<Field extends string, RelatedFields extends RelatedFieldsMap = {}> = {
84
+ [K in Field]: {
85
+ to: Field;
86
+ relatedFields: RelatedFields;
87
+ cardinality: 'many';
88
+ };
89
+ };
90
+
91
+ /**
92
+ * SyncedDb - A thin wrapper around spooky-ts for Solid.js integration
93
+ * Delegates all logic to the underlying spooky-ts instance
94
+ */
95
+ export class SyncedDb<S extends SchemaStructure> {
96
+ private config: SyncedDbConfig<S>;
97
+ private spooky: SpookyClient<S> | null = null;
98
+ private _initialized = false;
99
+
100
+ constructor(config: SyncedDbConfig<S>) {
101
+ this.config = config;
102
+ }
103
+
104
+ public getSpooky(): SpookyClient<S> {
105
+ if (!this.spooky) throw new Error('SyncedDb not initialized');
106
+ return this.spooky;
107
+ }
108
+
109
+ /**
110
+ * Initialize the spooky-ts instance
111
+ */
112
+ async init(): Promise<void> {
113
+ if (this._initialized) return;
114
+ this.spooky = new SpookyClient<S>(this.config);
115
+ await this.spooky.init();
116
+ this._initialized = true;
117
+ }
118
+
119
+ /**
120
+ * Create a new record in the database
121
+ */
122
+ async create(id: string, payload: Record<string, unknown>): Promise<void> {
123
+ if (!this.spooky) throw new Error('SyncedDb not initialized');
124
+ await this.spooky.create(id, payload as Record<string, unknown>);
125
+ }
126
+
127
+ /**
128
+ * Update an existing record in the database
129
+ */
130
+ async update<TName extends TableNames<S>>(
131
+ tableName: TName,
132
+ recordId: string,
133
+ payload: Partial<TableModel<GetTable<S, TName>>>,
134
+ options?: UpdateOptions
135
+ ): Promise<void> {
136
+ if (!this.spooky) throw new Error('SyncedDb not initialized');
137
+ await this.spooky.update(
138
+ tableName as string,
139
+ recordId,
140
+ payload as Record<string, unknown>,
141
+ options
142
+ );
143
+ }
144
+
145
+ /**
146
+ * Delete an existing record in the database
147
+ */
148
+ async delete<TName extends TableNames<S>>(
149
+ tableName: TName,
150
+ selector: string | InnerQuery<GetTable<S, TName>, boolean>
151
+ ): Promise<void> {
152
+ if (!this.spooky) throw new Error('SyncedDb not initialized');
153
+ if (typeof selector !== 'string')
154
+ throw new Error('Only string ID selectors are supported currently with core');
155
+ await this.spooky.delete(tableName as string, selector);
156
+ }
157
+
158
+ /**
159
+ * Query data from the database
160
+ */
161
+ public query<TName extends TableNames<S>>(
162
+ table: TName
163
+ ): QueryBuilder<S, TName, SpookyQueryResultPromise, {}, false> {
164
+ if (!this.spooky) throw new Error('SyncedDb not initialized');
165
+ return this.spooky.query(table, {});
166
+ }
167
+
168
+ /**
169
+ * Run a backend operation
170
+ */
171
+ public async run<
172
+ B extends BackendNames<S>,
173
+ R extends BackendRoutes<S, B>,
174
+ >(
175
+ backend: B,
176
+ path: R,
177
+ payload: RoutePayload<S, B, R>,
178
+ options?: RunOptions,
179
+ ): Promise<void> {
180
+ if (!this.spooky) throw new Error('SyncedDb not initialized');
181
+ await this.spooky.run(backend, path, payload, options);
182
+ }
183
+
184
+ /**
185
+ * Authenticate with the database
186
+ */
187
+ public async authenticate(token: string): Promise<RecordId<string>> {
188
+ const result = await this.spooky?.authenticate(token);
189
+ // SpookyClient.authenticate returns whatever remote.authenticate returns (boolean or token usually?)
190
+ // Wait, checked SpookyClient: return this.remote.getClient().authenticate(token);
191
+ // SurrealDB authenticate returns void? or token?
192
+ // Assuming void or token.
193
+ return new RecordId('user', 'me'); // Placeholder or actual?
194
+ }
195
+
196
+ /**
197
+ * Deauthenticate from the database
198
+ * @deprecated Use signOut() instead
199
+ */
200
+ public async deauthenticate(): Promise<void> {
201
+ await this.signOut();
202
+ }
203
+
204
+ /**
205
+ * Sign out, clear session and local storage
206
+ */
207
+ public async signOut(): Promise<void> {
208
+ if (!this.spooky) throw new Error('SyncedDb not initialized');
209
+ await this.spooky.auth.signOut();
210
+ }
211
+
212
+ /**
213
+ * Execute a function with direct access to the remote database connection
214
+ */
215
+ public async useRemote<T>(fn: (db: Surreal) => T | Promise<T>): Promise<T> {
216
+ if (!this.spooky) throw new Error('SyncedDb not initialized');
217
+ return await this.spooky.useRemote(fn);
218
+ }
219
+ /**
220
+ * Access the remote database service directly
221
+ */
222
+ get remote(): SpookyClient<S>['remoteClient'] {
223
+ if (!this.spooky) throw new Error('SyncedDb not initialized');
224
+ return this.spooky.remoteClient;
225
+ }
226
+
227
+ /**
228
+ * Access the local database service directly
229
+ */
230
+ get local(): SpookyClient<S>['localClient'] {
231
+ if (!this.spooky) throw new Error('SyncedDb not initialized');
232
+ return this.spooky.localClient;
233
+ }
234
+
235
+ /**
236
+ * Access the auth service
237
+ */
238
+ get auth(): AuthService<S> {
239
+ if (!this.spooky) throw new Error('SyncedDb not initialized');
240
+ return this.spooky.auth;
241
+ }
242
+
243
+ get pendingMutationCount(): number {
244
+ if (!this.spooky) throw new Error('SyncedDb not initialized');
245
+ return this.spooky.pendingMutationCount;
246
+ }
247
+
248
+ subscribeToPendingMutations(cb: (count: number) => void): () => void {
249
+ if (!this.spooky) throw new Error('SyncedDb not initialized');
250
+ return this.spooky.subscribeToPendingMutations(cb);
251
+ }
252
+ }
253
+
254
+ export * from './types';
@@ -0,0 +1,55 @@
1
+ import { createSignal, onMount, createComponent, createMemo, JSX, mergeProps } from 'solid-js';
2
+ import type { SchemaStructure } from '@spooky/query-builder';
3
+ import type { SyncedDbConfig } from '../types';
4
+ import { SyncedDb } from '../index';
5
+ import { SpookyContext } from './context';
6
+
7
+ export interface SpookyProviderProps<S extends SchemaStructure> {
8
+ config: SyncedDbConfig<S>;
9
+ fallback?: JSX.Element;
10
+ onError?: (error: Error) => void;
11
+ onReady?: (db: SyncedDb<S>) => void;
12
+ children: JSX.Element;
13
+ }
14
+
15
+ export function SpookyProvider<S extends SchemaStructure>(
16
+ props: SpookyProviderProps<S>
17
+ ): JSX.Element {
18
+ const merged = mergeProps(
19
+ {
20
+ fallback: undefined as JSX.Element | undefined,
21
+ },
22
+ props
23
+ );
24
+
25
+ const [db, setDb] = createSignal<SyncedDb<S> | undefined>(undefined);
26
+
27
+ onMount(async () => {
28
+ try {
29
+ const instance = new SyncedDb<S>(merged.config);
30
+ await instance.init();
31
+ setDb(() => instance);
32
+ merged.onReady?.(instance);
33
+ } catch (e) {
34
+ const error = e instanceof Error ? e : new Error(String(e));
35
+ if (merged.onError) {
36
+ merged.onError(error);
37
+ } else {
38
+ console.error('SpookyProvider: Failed to initialize database', error);
39
+ }
40
+ }
41
+ });
42
+
43
+ const content = createMemo(() => {
44
+ const instance = db();
45
+ if (!instance) return merged.fallback;
46
+ return createComponent(SpookyContext.Provider, {
47
+ value: instance,
48
+ get children() {
49
+ return merged.children;
50
+ },
51
+ });
52
+ });
53
+
54
+ return content as unknown as JSX.Element;
55
+ }
@@ -0,0 +1,13 @@
1
+ import { createContext, useContext } from 'solid-js';
2
+ import type { SchemaStructure } from '@spooky/query-builder';
3
+ import type { SyncedDb } from '../index';
4
+
5
+ export const SpookyContext = createContext<SyncedDb<any> | undefined>();
6
+
7
+ export function useDb<S extends SchemaStructure>(): SyncedDb<S> {
8
+ const db = useContext(SpookyContext);
9
+ if (!db) {
10
+ throw new Error('useDb must be used within a <SpookyProvider>. Wrap your app in <SpookyProvider config={...}>.');
11
+ }
12
+ return db as SyncedDb<S>;
13
+ }
@@ -0,0 +1,8 @@
1
+ import { RecordId } from 'surrealdb';
2
+
3
+ // Re-export types from query-builder for backward compatibility
4
+ export type { GenericModel, GenericSchema } from '@spooky/query-builder';
5
+
6
+ // Model and ModelPayload types for the client
7
+ export type Model<T> = T;
8
+ export type ModelPayload<T> = T & { id: RecordId };
@@ -0,0 +1,165 @@
1
+ import {
2
+ ColumnSchema,
3
+ FinalQuery,
4
+ SchemaStructure,
5
+ TableNames,
6
+ QueryResult,
7
+ } from '@spooky-sync/query-builder';
8
+ import { createEffect, createSignal, onCleanup, useContext } from 'solid-js';
9
+ import { SyncedDb } from '..';
10
+ import { SpookyQueryResultPromise } from '@spooky-sync/core';
11
+ import { SpookyContext } from './context';
12
+
13
+ type QueryArg<
14
+ S extends SchemaStructure,
15
+ TableName extends TableNames<S>,
16
+ T extends { columns: Record<string, ColumnSchema> },
17
+ RelatedFields extends Record<string, any>,
18
+ IsOne extends boolean,
19
+ > =
20
+ | FinalQuery<S, TableName, T, RelatedFields, IsOne, SpookyQueryResultPromise>
21
+ | (() =>
22
+ | FinalQuery<S, TableName, T, RelatedFields, IsOne, SpookyQueryResultPromise>
23
+ | null
24
+ | undefined);
25
+
26
+ type QueryOptions = { enabled?: () => boolean };
27
+
28
+ // Overload: context-based (no explicit db)
29
+ export function useQuery<
30
+ S extends SchemaStructure,
31
+ TableName extends TableNames<S>,
32
+ T extends { columns: Record<string, ColumnSchema> },
33
+ RelatedFields extends Record<string, any>,
34
+ IsOne extends boolean,
35
+ TData = QueryResult<S, TableName, RelatedFields, IsOne> | null,
36
+ >(
37
+ finalQuery: QueryArg<S, TableName, T, RelatedFields, IsOne>,
38
+ options?: QueryOptions,
39
+ ): { data: () => TData | undefined; error: () => Error | undefined; isLoading: () => boolean };
40
+
41
+ // Overload: explicit db (backward-compatible)
42
+ export function useQuery<
43
+ S extends SchemaStructure,
44
+ TableName extends TableNames<S>,
45
+ T extends { columns: Record<string, ColumnSchema> },
46
+ RelatedFields extends Record<string, any>,
47
+ IsOne extends boolean,
48
+ TData = QueryResult<S, TableName, RelatedFields, IsOne> | null,
49
+ >(
50
+ db: SyncedDb<S>,
51
+ finalQuery: QueryArg<S, TableName, T, RelatedFields, IsOne>,
52
+ options?: QueryOptions,
53
+ ): { data: () => TData | undefined; error: () => Error | undefined; isLoading: () => boolean };
54
+
55
+ // Implementation
56
+ export function useQuery<
57
+ S extends SchemaStructure,
58
+ TableName extends TableNames<S>,
59
+ T extends {
60
+ columns: Record<string, ColumnSchema>;
61
+ },
62
+ RelatedFields extends Record<string, any>,
63
+ IsOne extends boolean,
64
+ TData = QueryResult<S, TableName, RelatedFields, IsOne> | null,
65
+ >(
66
+ dbOrQuery:
67
+ | SyncedDb<S>
68
+ | QueryArg<S, TableName, T, RelatedFields, IsOne>,
69
+ queryOrOptions?:
70
+ | QueryArg<S, TableName, T, RelatedFields, IsOne>
71
+ | QueryOptions,
72
+ maybeOptions?: QueryOptions,
73
+ ) {
74
+ let db: SyncedDb<S>;
75
+ let finalQuery: QueryArg<S, TableName, T, RelatedFields, IsOne>;
76
+ let options: QueryOptions | undefined;
77
+
78
+ if (dbOrQuery instanceof SyncedDb) {
79
+ // Explicit db overload: useQuery(db, query, options?)
80
+ db = dbOrQuery;
81
+ finalQuery = queryOrOptions as QueryArg<S, TableName, T, RelatedFields, IsOne>;
82
+ options = maybeOptions;
83
+ } else {
84
+ // Context-based overload: useQuery(query, options?)
85
+ const contextDb = useContext(SpookyContext);
86
+ if (!contextDb) {
87
+ throw new Error(
88
+ 'useQuery: No db argument provided and no SpookyContext found. ' +
89
+ 'Either pass a SyncedDb instance or wrap your app in <SpookyProvider>.'
90
+ );
91
+ }
92
+ db = contextDb as SyncedDb<S>;
93
+ finalQuery = dbOrQuery;
94
+ options = queryOrOptions as QueryOptions | undefined;
95
+ }
96
+
97
+ const [data, setData] = createSignal<TData | undefined>(undefined);
98
+ const [error, setError] = createSignal<Error | undefined>(undefined);
99
+ const [isFetched, setIsFetched] = createSignal(false);
100
+ const [unsubscribe, setUnsubscribe] = createSignal<(() => void) | undefined>(undefined);
101
+ let prevQueryString: string | undefined;
102
+
103
+ const spooky = db.getSpooky();
104
+
105
+ const initQuery = async (
106
+ query: FinalQuery<S, TableName, T, RelatedFields, IsOne, SpookyQueryResultPromise>
107
+ ) => {
108
+ const { hash } = await query.run();
109
+ setError(undefined);
110
+
111
+ const unsub = await spooky.subscribe(
112
+ hash,
113
+ (e) => {
114
+ const data = (query.isOne ? e[0] : e) as TData;
115
+ setData(() => data);
116
+ setIsFetched(true);
117
+ },
118
+ { immediate: true }
119
+ );
120
+
121
+ setUnsubscribe(() => unsub);
122
+ };
123
+
124
+ createEffect(() => {
125
+ const enabled = options?.enabled?.() ?? true;
126
+
127
+ // If disabled, clear error and don't run query
128
+ if (!enabled) {
129
+ setError(undefined);
130
+ return;
131
+ }
132
+
133
+ // Init Query
134
+ const query = typeof finalQuery === 'function' ? finalQuery() : finalQuery;
135
+ if (!query) {
136
+ return;
137
+ }
138
+
139
+ // Prevent re-running if query hasn't changed
140
+ const queryString = JSON.stringify(query);
141
+ if (queryString === prevQueryString) {
142
+ return;
143
+ }
144
+ prevQueryString = queryString;
145
+
146
+ // Reset fetched state when query changes
147
+ setIsFetched(false);
148
+ initQuery(query);
149
+
150
+ // Cleanup
151
+ onCleanup(() => {
152
+ unsubscribe?.();
153
+ });
154
+ });
155
+
156
+ const isLoading = () => {
157
+ return !isFetched() && error() === undefined;
158
+ };
159
+
160
+ return {
161
+ data,
162
+ error,
163
+ isLoading,
164
+ };
165
+ }