@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/LICENSE +21 -0
- package/QUICK_START.md +126 -0
- package/README.md +19 -0
- package/dist/index.cjs +903 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +498 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +498 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +884 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
- package/skills/sp00ky-solid2/SKILL.md +68 -0
- package/src/index.ts +365 -0
- package/src/lib/Sp00kyProvider.ts +104 -0
- package/src/lib/__tests__/conflate.test.ts +120 -0
- package/src/lib/__tests__/create-query.test.ts +284 -0
- package/src/lib/__tests__/rc-semantics.test.ts +389 -0
- package/src/lib/conflate.ts +74 -0
- package/src/lib/context.ts +28 -0
- package/src/lib/create-preload.ts +115 -0
- package/src/lib/create-query.ts +285 -0
- package/src/lib/create-submission.ts +57 -0
- package/src/lib/from-subscription.ts +32 -0
- package/src/lib/models.ts +8 -0
- package/src/lib/use-app-release.ts +89 -0
- package/src/lib/use-crdt-field.ts +57 -0
- package/src/lib/use-download-file.ts +181 -0
- package/src/lib/use-feature-flag.ts +43 -0
- package/src/lib/use-file-upload.ts +146 -0
- package/src/lib/use-storage-status.ts +44 -0
- package/src/lib/use-sync-status.ts +63 -0
- package/src/types/index.ts +83 -0
- package/tsconfig.json +27 -0
- package/tsdown.config.ts +18 -0
- package/vitest.config.ts +14 -0
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
|
+
});
|