@spooky-sync/client-solid2 0.0.1-canary.200

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/src/index.ts ADDED
@@ -0,0 +1,365 @@
1
+ import type { SyncedDbConfig } from './types';
2
+ import {
3
+ Sp00kyClient,
4
+ type Sp00kyQueryResultPromise,
5
+ type AuthService,
6
+ type BucketHandle,
7
+ type UpdateOptions,
8
+ type RunOptions,
9
+ type SyncHealth,
10
+ type StorageHealth,
11
+ type PreloadOptions,
12
+ type PreloadRefresh,
13
+ } from '@spooky-sync/core';
14
+
15
+ import type {
16
+ GetTable,
17
+ QueryBuilder,
18
+ SchemaStructure,
19
+ TableModel,
20
+ TableNames,
21
+ QueryResult,
22
+ RelatedFieldsMap,
23
+ RelationshipFieldsFromSchema,
24
+ GetRelationship,
25
+ RelatedFieldMapEntry,
26
+ FinalQuery,
27
+ InnerQuery,
28
+ BackendNames,
29
+ BackendRoutes,
30
+ RoutePayload,
31
+ BucketNames,
32
+ BucketDefinitionSchema,
33
+ QueryModifier,
34
+ QueryModifierBuilder,
35
+ QueryInfo,
36
+ RelationshipsMetadata,
37
+ RelationshipDefinition,
38
+ InferRelatedModelFromMetadata,
39
+ GetCardinality,
40
+ } from '@spooky-sync/query-builder';
41
+
42
+ import { RecordId, Uuid, type Surreal } from 'surrealdb';
43
+ export { RecordId, Uuid };
44
+ export type { Model, GenericModel, GenericSchema, ModelPayload } from './lib/models';
45
+ export { createQuery, useQuery, type CreateQueryResult, type QueryOptions } from './lib/create-query';
46
+ export { createPreload } from './lib/create-preload';
47
+ export type { PreloadOptions, PreloadRefresh } from '@spooky-sync/core';
48
+ export { useSyncStatus, type UseSyncStatus } from './lib/use-sync-status';
49
+ export type {
50
+ SyncHealth,
51
+ SyncHealthStatus,
52
+ SyncHealthConfig,
53
+ ConnectionState,
54
+ ReconnectConfig,
55
+ } from '@spooky-sync/core';
56
+ export { useStorageStatus, type UseStorageStatus } from './lib/use-storage-status';
57
+ export type { StorageHealth, StorageHealthStatus } from '@spooky-sync/core';
58
+ export { useCrdtField } from './lib/use-crdt-field';
59
+ export { useFeatureFlag, type UseFeatureFlag } from './lib/use-feature-flag';
60
+ export {
61
+ useAppRelease,
62
+ type UseAppRelease,
63
+ type UseAppReleaseOptions,
64
+ } from './lib/use-app-release';
65
+ export { useFileUpload, type FileUploadResult } from './lib/use-file-upload';
66
+ export {
67
+ useDownloadFile,
68
+ type UseDownloadFileOptions,
69
+ type UseDownloadFileResult,
70
+ } from './lib/use-download-file';
71
+ export { Sp00kyProvider, type Sp00kyProviderProps } from './lib/Sp00kyProvider';
72
+ export { useDb, usePendingMutations } from './lib/context';
73
+ export { createSubmission, type Submission } from './lib/create-submission';
74
+ export { conflate } from './lib/conflate';
75
+ export { fromSubscription } from './lib/from-subscription';
76
+
77
+ // Re-export query builder types for convenience
78
+ export type {
79
+ QueryModifier,
80
+ QueryModifierBuilder,
81
+ QueryInfo,
82
+ RelationshipsMetadata,
83
+ RelationshipDefinition,
84
+ InferRelatedModelFromMetadata,
85
+ GetCardinality,
86
+ GetTable,
87
+ TableModel,
88
+ TableNames,
89
+ QueryResult,
90
+ };
91
+
92
+ export type RelationshipField<
93
+ Schema extends SchemaStructure,
94
+ TableName extends TableNames<Schema>,
95
+ Field extends RelationshipFieldsFromSchema<Schema, TableName>,
96
+ > = GetRelationship<Schema, TableName, Field>;
97
+
98
+ export type RelatedFieldsTableScoped<
99
+ Schema extends SchemaStructure,
100
+ TableName extends TableNames<Schema>,
101
+ RelatedFields extends RelationshipFieldsFromSchema<Schema, TableName> =
102
+ RelationshipFieldsFromSchema<Schema, TableName>,
103
+ > = {
104
+ [K in RelatedFields]: {
105
+ to: RelationshipField<Schema, TableName, K>['to'];
106
+ relatedFields: RelatedFieldsMap;
107
+ cardinality: RelationshipField<Schema, TableName, K>['cardinality'];
108
+ };
109
+ };
110
+
111
+ export type InferModel<
112
+ Schema extends SchemaStructure,
113
+ TableName extends TableNames<Schema>,
114
+ RelatedFields extends RelatedFieldsTableScoped<Schema, TableName>,
115
+ > = QueryResult<Schema, TableName, RelatedFields, true>;
116
+
117
+ export type WithRelated<Field extends string, RelatedFields extends RelatedFieldsMap = {}> = {
118
+ [K in Field]: Omit<RelatedFieldMapEntry, 'relatedFields'> & {
119
+ relatedFields: RelatedFields;
120
+ };
121
+ };
122
+
123
+ export type WithRelatedMany<Field extends string, RelatedFields extends RelatedFieldsMap = {}> = {
124
+ [K in Field]: {
125
+ to: Field;
126
+ relatedFields: RelatedFields;
127
+ cardinality: 'many';
128
+ };
129
+ };
130
+
131
+ /**
132
+ * SyncedDb - A thin wrapper around sp00ky-ts for Solid.js integration.
133
+ * Delegates all logic to the underlying sp00ky-ts instance.
134
+ *
135
+ * NOTE: keep in sync with packages/client-solid/src/index.ts (SyncedDb).
136
+ * Copied rather than shared so this package's dependency graph never pulls
137
+ * in solid-js 1.x; fold the two together once client-solid moves to Solid 2.
138
+ */
139
+ export class SyncedDb<S extends SchemaStructure> {
140
+ private config: SyncedDbConfig<S>;
141
+ private sp00ky: Sp00kyClient<S> | null = null;
142
+ private _initialized = false;
143
+
144
+ constructor(config: SyncedDbConfig<S>) {
145
+ this.config = config;
146
+ }
147
+
148
+ public getSp00ky(): Sp00kyClient<S> {
149
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
150
+ return this.sp00ky;
151
+ }
152
+
153
+ /**
154
+ * Initialize the sp00ky-ts instance
155
+ */
156
+ async init(): Promise<void> {
157
+ if (this._initialized) return;
158
+ this.sp00ky = new Sp00kyClient<S>(this.config);
159
+ await this.sp00ky.init();
160
+ this._initialized = true;
161
+ }
162
+
163
+ /**
164
+ * Tear down the client: leaves the tabs broker, closes the local store and
165
+ * remote socket, and frees the wasm circuit. Without this a remounted provider
166
+ * (or an HMR reload) strands a whole client, and the abandoned wasm heaps stay
167
+ * resident because V8 cannot see how much wasm memory a dropped wrapper holds.
168
+ */
169
+ async close(): Promise<void> {
170
+ const instance = this.sp00ky;
171
+ this.sp00ky = null;
172
+ this._initialized = false;
173
+ if (instance) await instance.close();
174
+ }
175
+
176
+ /**
177
+ * Create a new record in the database
178
+ */
179
+ async create(id: string, payload: Record<string, unknown>): Promise<void> {
180
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
181
+ await this.sp00ky.create(id, payload as Record<string, unknown>);
182
+ }
183
+
184
+ /**
185
+ * Update an existing record in the database
186
+ */
187
+ async update<TName extends TableNames<S>>(
188
+ tableName: TName,
189
+ recordId: string,
190
+ payload: Partial<TableModel<GetTable<S, TName>>>,
191
+ options?: UpdateOptions
192
+ ): Promise<void> {
193
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
194
+ await this.sp00ky.update(
195
+ tableName as string,
196
+ recordId,
197
+ payload as Record<string, unknown>,
198
+ options
199
+ );
200
+ }
201
+
202
+ /**
203
+ * Delete an existing record in the database
204
+ */
205
+ async delete<TName extends TableNames<S>>(
206
+ tableName: TName,
207
+ selector: string | RecordId | InnerQuery<GetTable<S, TName>, boolean>
208
+ ): Promise<void> {
209
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
210
+ // Accept a `"table:id"` string OR a RecordId — live-query rows carry their
211
+ // `id` as a RecordId, so callers can pass `db.delete('game', row.id)`
212
+ // directly. Build the canonical string from the raw id part (not
213
+ // `RecordId.toString()`, which escapes special chars) so it round-trips
214
+ // through the engine's `parseRecordIdString`. InnerQuery selectors are not
215
+ // supported yet. (cross-package RecordId instances → match by constructor
216
+ // name; Solid 2 store proxies serve methods BOUND, so a RecordId read out
217
+ // of a query row reports 'bound RecordId' — accept both.)
218
+ const ctorName = (selector as any)?.constructor?.name;
219
+ const isRecordId =
220
+ selector instanceof RecordId || ctorName === 'RecordId' || ctorName === 'bound RecordId';
221
+ let id: string;
222
+ if (typeof selector === 'string') {
223
+ id = selector;
224
+ } else if (isRecordId) {
225
+ id = `${tableName as string}:${(selector as RecordId).id}`;
226
+ } else {
227
+ throw new Error('Only string ID or RecordId selectors are supported currently with core');
228
+ }
229
+ await this.sp00ky.delete(tableName as string, id);
230
+ }
231
+
232
+ /**
233
+ * Preload/prewarm a built query into the local cache without registering a
234
+ * live view. Fetches once and stores the rows (+ embedded related children)
235
+ * locally so a later `createQuery` for the same data paints instantly. Best-effort.
236
+ */
237
+ public async preload(
238
+ finalQuery: FinalQuery<S, any, any, any, any, Sp00kyQueryResultPromise>,
239
+ options?: PreloadOptions
240
+ ): Promise<void> {
241
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
242
+ await this.sp00ky.preload(finalQuery, options);
243
+ }
244
+
245
+ /**
246
+ * Query data from the database
247
+ */
248
+ public query<TName extends TableNames<S>>(
249
+ table: TName
250
+ ): QueryBuilder<S, TName, Sp00kyQueryResultPromise, {}, false> {
251
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
252
+ return this.sp00ky.query(table, {});
253
+ }
254
+
255
+ /**
256
+ * Run a backend operation
257
+ */
258
+ public async run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
259
+ backend: B,
260
+ path: R,
261
+ payload: RoutePayload<S, B, R>,
262
+ options?: RunOptions
263
+ ): Promise<void> {
264
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
265
+ await this.sp00ky.run(backend, path, payload, options);
266
+ }
267
+
268
+ /**
269
+ * Sign out, clear session and local storage
270
+ */
271
+ public async signOut(): Promise<void> {
272
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
273
+ await this.sp00ky.auth.signOut();
274
+ }
275
+
276
+ /**
277
+ * Execute a function with direct access to the remote database connection
278
+ */
279
+ public async useRemote<T>(fn: (db: Surreal) => T | Promise<T>): Promise<T> {
280
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
281
+ return await this.sp00ky.useRemote(fn);
282
+ }
283
+ /**
284
+ * Access the remote database service directly
285
+ */
286
+ get remote(): Sp00kyClient<S>['remoteClient'] {
287
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
288
+ return this.sp00ky.remoteClient;
289
+ }
290
+
291
+ /**
292
+ * Access the local database service directly
293
+ */
294
+ get local(): Sp00kyClient<S>['localClient'] {
295
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
296
+ return this.sp00ky.localClient;
297
+ }
298
+
299
+ /**
300
+ * Access the auth service
301
+ */
302
+ get auth(): AuthService<S> {
303
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
304
+ return this.sp00ky.auth;
305
+ }
306
+
307
+ get pendingMutationCount(): number {
308
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
309
+ return this.sp00ky.pendingMutationCount;
310
+ }
311
+
312
+ /** Diagnostic — see `Sp00kyClient.liveRetryCount`. */
313
+ get liveRetryCount(): number {
314
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
315
+ return this.sp00ky.liveRetryCount;
316
+ }
317
+
318
+ subscribeToPendingMutations(cb: (count: number) => void): () => void {
319
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
320
+ return this.sp00ky.subscribeToPendingMutations(cb);
321
+ }
322
+
323
+ /** Current sync-health snapshot. See {@link useSyncStatus}. */
324
+ get syncHealth(): SyncHealth {
325
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
326
+ return this.sp00ky.syncHealth;
327
+ }
328
+
329
+ /**
330
+ * Observe sync health. Fires immediately with the current status and again
331
+ * on every healthy↔degraded transition. Prefer the `useSyncStatus` hook in
332
+ * components; this is the imperative escape hatch.
333
+ */
334
+ subscribeToSyncHealth(cb: (health: SyncHealth) => void): () => void {
335
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
336
+ return this.sp00ky.subscribeToSyncHealth(cb);
337
+ }
338
+
339
+ /** Current local-store durability snapshot. See {@link useStorageStatus}. */
340
+ get storageHealth(): StorageHealth {
341
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
342
+ return this.sp00ky.storageHealth;
343
+ }
344
+
345
+ /**
346
+ * Observe local-store durability. Fires immediately with the current snapshot
347
+ * and again on change. Prefer the `useStorageStatus` hook in components; this
348
+ * is the imperative escape hatch.
349
+ */
350
+ subscribeToStorageHealth(cb: (health: StorageHealth) => void): () => void {
351
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
352
+ return this.sp00ky.subscribeToStorageHealth(cb);
353
+ }
354
+
355
+ bucket<B extends BucketNames<S>>(name: B): BucketHandle {
356
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
357
+ return this.sp00ky.bucket(name);
358
+ }
359
+
360
+ getBucketConfig(name: string): BucketDefinitionSchema | undefined {
361
+ return this.config.schema.buckets?.find((b) => b.name === name);
362
+ }
363
+ }
364
+
365
+ export * from './types';
@@ -0,0 +1,104 @@
1
+ import type { Element } from 'solid-js';
2
+ import {
3
+ createSignal,
4
+ onSettled,
5
+ onCleanup,
6
+ createComponent,
7
+ createMemo,
8
+ merge,
9
+ } from 'solid-js';
10
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
11
+ import type { SyncedDbConfig } from '../types';
12
+ import { SyncedDb } from '../index';
13
+ import { Sp00kyContext } from './context';
14
+
15
+ export interface Sp00kyProviderProps<S extends SchemaStructure> {
16
+ config: SyncedDbConfig<S>;
17
+ fallback?: Element;
18
+ onError?: (error: Error) => void;
19
+ onReady?: (db: SyncedDb<S>) => void;
20
+ /**
21
+ * Prewarm data into the local cache before revealing the UI. Runs after
22
+ * `init()`; the `fallback` stays visible until it resolves. Use awaitable
23
+ * `db.preload(...)` calls here to gate first-load on essential data (e.g.
24
+ * config). On warm loads preload returns instantly, so there's no perceptible
25
+ * gate after the first run. Best-effort: a rejection is caught and the UI is
26
+ * revealed anyway.
27
+ */
28
+ preload?: (db: SyncedDb<S>) => Promise<void>;
29
+ children: Element;
30
+ }
31
+
32
+ export function Sp00kyProvider<S extends SchemaStructure>(
33
+ props: Sp00kyProviderProps<S>
34
+ ): Element {
35
+ const merged = merge({ fallback: undefined as Element | undefined }, props);
36
+
37
+ // Written from the async init continuation — outside any tracking scope.
38
+ const [db, setDb] = createSignal<SyncedDb<S> | undefined>(undefined, { ownedWrite: true });
39
+
40
+ // Init is async, so a dispose can land mid-init. Only that narrow race is
41
+ // handled here: an instance whose init finished AFTER the provider was
42
+ // already gone is closed, because nothing will ever reference it.
43
+ //
44
+ // A live, mounted client is deliberately NOT closed on cleanup. Doing that
45
+ // nulls `SyncedDb.sp00ky`, so every later `create`/`update`/`delete` throws
46
+ // "SyncedDb not initialized" while reads keep rendering from state that is
47
+ // already subscribed — i.e. mutations die silently and the app looks fine. In
48
+ // a host app the provider wraps the whole tree and only unmounts with the
49
+ // page, where the browser reclaims the worker anyway, so the leak this was
50
+ // meant to fix is worth far less than that risk.
51
+ let disposed = false;
52
+
53
+ onCleanup(() => {
54
+ disposed = true;
55
+ });
56
+
57
+ // `onSettled` replaces Solid 1's `onMount`.
58
+ onSettled(() => {
59
+ void (async () => {
60
+ try {
61
+ const instance = new SyncedDb<S>(merged.config);
62
+ await instance.init();
63
+ if (disposed) {
64
+ await instance.close();
65
+ return;
66
+ }
67
+ // Gate first-load UI on prewarmed data. Best-effort: never let a
68
+ // preload failure keep the app stuck on the fallback.
69
+ if (merged.preload) {
70
+ try {
71
+ await merged.preload(instance);
72
+ } catch (e) {
73
+ // oxlint-disable-next-line no-console
74
+ console.error('Sp00kyProvider: preload failed; revealing UI anyway', e);
75
+ }
76
+ }
77
+ setDb(() => instance);
78
+ merged.onReady?.(instance);
79
+ } catch (e) {
80
+ const error = e instanceof Error ? e : new Error(String(e));
81
+ if (merged.onError) {
82
+ merged.onError(error);
83
+ } else {
84
+ // oxlint-disable-next-line no-console
85
+ console.error('Sp00kyProvider: Failed to initialize database', error);
86
+ }
87
+ }
88
+ })();
89
+ });
90
+
91
+ const content = createMemo(() => {
92
+ const instance = db();
93
+ if (!instance) return merged.fallback;
94
+ // Solid 2: the context object IS the provider component.
95
+ return createComponent(Sp00kyContext, {
96
+ value: instance,
97
+ get children() {
98
+ return merged.children;
99
+ },
100
+ });
101
+ });
102
+
103
+ return content as unknown as Element;
104
+ }
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createEffect, createRoot, flush } from 'solid-js';
3
+ import { conflate } from '../conflate';
4
+ import { fromSubscription } from '../from-subscription';
5
+
6
+ const tick = () => new Promise<void>((r) => setTimeout(r, 0));
7
+
8
+ describe('conflate', () => {
9
+ it('delivers values pushed after a pull is parked', async () => {
10
+ let cb: ((v: number) => void) | undefined;
11
+ const it = conflate<number>((c) => {
12
+ cb = c;
13
+ return () => (cb = undefined);
14
+ })[Symbol.asyncIterator]();
15
+
16
+ const p = it.next();
17
+ cb!(1);
18
+ expect(await p).toEqual({ value: 1, done: false });
19
+ });
20
+
21
+ it('conflates: only the newest unconsumed value survives', async () => {
22
+ let cb: ((v: number) => void) | undefined;
23
+ const it = conflate<number>((c) => {
24
+ cb = c;
25
+ return () => (cb = undefined);
26
+ })[Symbol.asyncIterator]();
27
+
28
+ cb!(1);
29
+ cb!(2);
30
+ cb!(3);
31
+ expect(await it.next()).toEqual({ value: 3, done: false });
32
+ });
33
+
34
+ it('return() unsubscribes and resolves a parked pull as done', async () => {
35
+ let unsubscribed = false;
36
+ let cb: ((v: number) => void) | undefined;
37
+ const it = conflate<number>((c) => {
38
+ cb = c;
39
+ return () => {
40
+ unsubscribed = true;
41
+ cb = undefined;
42
+ };
43
+ })[Symbol.asyncIterator]();
44
+
45
+ const parked = it.next();
46
+ await it.return!();
47
+ await tick();
48
+ expect(unsubscribed).toBe(true);
49
+ expect(await parked).toEqual({ value: undefined, done: true });
50
+ expect(await it.next()).toEqual({ value: undefined, done: true });
51
+ });
52
+
53
+ it('supports async subscribe (unsubscribe still runs after return())', async () => {
54
+ let unsubscribed = false;
55
+ const it = conflate<number>(async () => {
56
+ await tick();
57
+ return () => {
58
+ unsubscribed = true;
59
+ };
60
+ })[Symbol.asyncIterator]();
61
+
62
+ await it.return!();
63
+ await tick();
64
+ await tick();
65
+ expect(unsubscribed).toBe(true);
66
+ });
67
+
68
+ it('values pushed after return() are dropped', async () => {
69
+ let cb: ((v: number) => void) | undefined;
70
+ const it = conflate<number>((c) => {
71
+ cb = c;
72
+ return () => {};
73
+ })[Symbol.asyncIterator]();
74
+ await it.return!();
75
+ cb!(42);
76
+ expect(await it.next()).toEqual({ value: undefined, done: true });
77
+ });
78
+ });
79
+
80
+ describe('fromSubscription', () => {
81
+ it('serves initial synchronously, then live values; unsubscribes on dispose', async () => {
82
+ let cb: ((v: number) => void) | undefined;
83
+ let unsubscribed = false;
84
+ const subscribe = (c: (v: number) => void) => {
85
+ cb = c;
86
+ // spooky-style: fire immediately with current value
87
+ c(10);
88
+ return () => {
89
+ unsubscribed = true;
90
+ cb = undefined;
91
+ };
92
+ };
93
+
94
+ await createRoot(async (dispose) => {
95
+ const v = fromSubscription(subscribe, -1);
96
+ const seen: number[] = [];
97
+ createEffect(
98
+ () => v(),
99
+ (x) => {
100
+ seen.push(x);
101
+ }
102
+ );
103
+ flush();
104
+ expect(v()).toBe(-1); // loadingValue readable synchronously
105
+ await tick();
106
+ flush();
107
+ expect(v()).toBe(10); // immediate emission landed
108
+
109
+ cb!(20);
110
+ await tick();
111
+ flush();
112
+ expect(v()).toBe(20);
113
+ expect(seen).toContain(20);
114
+
115
+ dispose();
116
+ await tick();
117
+ expect(unsubscribed).toBe(true);
118
+ });
119
+ });
120
+ });