@spooky-sync/client-solid 0.0.1-canary.18 → 0.0.1-canary.180
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/AGENTS.md +68 -0
- package/README.md +20 -0
- package/dist/index.cjs +434 -156
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +248 -21
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +248 -21
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +429 -157
- package/dist/index.js.map +1 -1
- package/package.json +7 -6
- package/skills/sp00ky-solid/SKILL.md +335 -0
- package/skills/{spooky-solid → sp00ky-solid}/references/file-hooks.md +34 -5
- package/src/cache/index.ts +1 -1
- package/src/cache/surrealdb-wasm-factory.ts +4 -1
- package/src/index.ts +170 -61
- package/src/lib/Sp00kyProvider.ts +104 -0
- package/src/lib/context.ts +3 -3
- package/src/lib/create-preload.ts +111 -0
- package/src/lib/models.ts +1 -1
- package/src/lib/use-app-release.ts +89 -0
- package/src/lib/use-crdt-field.ts +68 -0
- package/src/lib/use-download-file.ts +66 -110
- package/src/lib/use-feature-flag.ts +50 -0
- package/src/lib/use-file-upload.ts +2 -1
- package/src/lib/use-query.ts +168 -28
- package/src/lib/use-storage-status.ts +44 -0
- package/src/lib/use-sync-status.ts +64 -0
- package/src/types/index.ts +3 -4
- package/skills/spooky-solid/SKILL.md +0 -217
- package/src/lib/SpookyProvider.ts +0 -55
|
@@ -7,7 +7,7 @@ Upload, download, and manage files in a SurrealDB bucket.
|
|
|
7
7
|
### Signatures
|
|
8
8
|
|
|
9
9
|
```typescript
|
|
10
|
-
// Context-based (inside
|
|
10
|
+
// Context-based (inside Sp00kyProvider)
|
|
11
11
|
useFileUpload<S>(bucketName: BucketNames<S>): FileUploadResult;
|
|
12
12
|
|
|
13
13
|
// Explicit db
|
|
@@ -64,11 +64,18 @@ Reactively download a file from a bucket. Re-fetches when the path changes.
|
|
|
64
64
|
### Signatures
|
|
65
65
|
|
|
66
66
|
```typescript
|
|
67
|
+
interface UseDownloadFileOptions {
|
|
68
|
+
cache?: boolean; // default true — false disables every layer
|
|
69
|
+
persist?: boolean; // default true — keep bytes in OPFS
|
|
70
|
+
pin?: boolean; // exempt from pressure eviction
|
|
71
|
+
revalidate?: 'never' | 'head'; // default 'never' (paths are immutable)
|
|
72
|
+
}
|
|
73
|
+
|
|
67
74
|
// Context-based
|
|
68
75
|
useDownloadFile<S>(
|
|
69
76
|
bucketName: BucketNames<S>,
|
|
70
77
|
path: Accessor<string | null | undefined>,
|
|
71
|
-
options?:
|
|
78
|
+
options?: UseDownloadFileOptions,
|
|
72
79
|
): UseDownloadFileResult;
|
|
73
80
|
|
|
74
81
|
// Explicit db
|
|
@@ -76,7 +83,7 @@ useDownloadFile<S>(
|
|
|
76
83
|
db: SyncedDb<S>,
|
|
77
84
|
bucketName: BucketNames<S>,
|
|
78
85
|
path: Accessor<string | null | undefined>,
|
|
79
|
-
options?:
|
|
86
|
+
options?: UseDownloadFileOptions,
|
|
80
87
|
): UseDownloadFileResult;
|
|
81
88
|
```
|
|
82
89
|
|
|
@@ -87,13 +94,35 @@ interface UseDownloadFileResult {
|
|
|
87
94
|
url: Accessor<string | null>; // Object URL for the file
|
|
88
95
|
isLoading: Accessor<boolean>;
|
|
89
96
|
error: Accessor<Error | null>;
|
|
90
|
-
refetch: () => void; // Force re-download
|
|
97
|
+
refetch: () => void; // Force re-download, bypassing every layer
|
|
91
98
|
}
|
|
92
99
|
```
|
|
93
100
|
|
|
94
101
|
### Caching
|
|
95
102
|
|
|
96
|
-
|
|
103
|
+
Three layers, checked in order:
|
|
104
|
+
|
|
105
|
+
1. **Object URLs**, refcounted per `bucket:path` and shared between components. Revoked once the last holder releases and the entry ages out of a 32-entry hot window.
|
|
106
|
+
2. **OPFS**, under `sp00ky-blobs/<bucketId>/<bucket>/<path>`. Survives reload, works offline, and is namespaced per signed-in user.
|
|
107
|
+
3. **The bucket**, over the sync WebSocket.
|
|
108
|
+
|
|
109
|
+
Nothing expires on a timer — an image whose row is still cached locally has to stay viewable offline. Bytes are dropped only when the app invalidates the path (`bucket.put`/`bucket.delete` do this automatically), when boot reconcile finds no file behind a row, or when the cache exceeds its byte budget, in which case the least-recently-used unpinned entries that nothing is rendering go first.
|
|
110
|
+
|
|
111
|
+
Configure with `blobCache: { enabled, maxBytes, clearOnSignOut }` on the client. The budget defaults to `min(512 MB, quota × 0.25)`. Inspect live numbers in the DevTools Storage tab under "Bucket file cache".
|
|
112
|
+
|
|
113
|
+
`persist: false` keeps the in-tab sharing but writes nothing durable. `cache: false` gives each hook instance a private URL fetched fresh and revoked on unmount.
|
|
114
|
+
|
|
115
|
+
A bucket path is treated as immutable, which is how paths are normally written (`crypto.randomUUID() + ext`). If your app overwrites a path in place from another device, pass `revalidate: 'head'` — it spends a remote `head()` to compare sizes before trusting the cached copy, and keeps the cached copy when that call fails so going offline never blanks an image.
|
|
116
|
+
|
|
117
|
+
### Pinning and prefetching
|
|
118
|
+
|
|
119
|
+
```tsx
|
|
120
|
+
const bucket = db.bucket('avatars');
|
|
121
|
+
await bucket.prefetch(paths); // warm the cache for offline use
|
|
122
|
+
bucket.pin('logo.png'); // never evicted under pressure
|
|
123
|
+
bucket.unpin('logo.png');
|
|
124
|
+
await bucket.evict('old.png'); // drop locally, leave the remote file alone
|
|
125
|
+
```
|
|
97
126
|
|
|
98
127
|
### Example
|
|
99
128
|
|
package/src/cache/index.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { Diagnostic
|
|
1
|
+
import type { Diagnostic} from 'surrealdb';
|
|
2
|
+
import { Surreal, applyDiagnostics } from 'surrealdb';
|
|
2
3
|
import { createWasmEngines } from '@surrealdb/wasm';
|
|
3
4
|
import type { CacheStrategy } from '../types';
|
|
4
5
|
|
|
5
6
|
const printDiagnostic = ({ key, type, phase, ...other }: Diagnostic) => {
|
|
6
7
|
if (phase === 'progress' || phase === 'after') {
|
|
8
|
+
// oxlint-disable-next-line no-console -- intentional diagnostic logging
|
|
7
9
|
console.log(`[SurrealDB_WASM] [${key}] ${type}:${phase}\n${JSON.stringify(other, null, 2)}`);
|
|
8
10
|
}
|
|
9
11
|
};
|
|
@@ -11,6 +13,7 @@ const printDiagnostic = ({ key, type, phase, ...other }: Diagnostic) => {
|
|
|
11
13
|
/**
|
|
12
14
|
* SurrealDB WASM client factory for different storage strategies
|
|
13
15
|
*/
|
|
16
|
+
// oxlint-disable-next-line no-extraneous-class -- factory pattern groups related static methods
|
|
14
17
|
export class SurrealDBWasmFactory {
|
|
15
18
|
/**
|
|
16
19
|
* Creates a SurrealDB WASM instance with the specified storage strategy
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import type { SyncedDbConfig } from './types';
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
type
|
|
7
|
-
UpdateOptions,
|
|
8
|
-
RunOptions,
|
|
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,
|
|
9
13
|
} from '@spooky-sync/core';
|
|
10
14
|
|
|
11
|
-
import {
|
|
15
|
+
import type {
|
|
12
16
|
GetTable,
|
|
13
17
|
QueryBuilder,
|
|
14
18
|
SchemaStructure,
|
|
@@ -19,25 +23,55 @@ import {
|
|
|
19
23
|
RelationshipFieldsFromSchema,
|
|
20
24
|
GetRelationship,
|
|
21
25
|
RelatedFieldMapEntry,
|
|
26
|
+
FinalQuery,
|
|
22
27
|
InnerQuery,
|
|
23
28
|
BackendNames,
|
|
24
29
|
BackendRoutes,
|
|
25
30
|
RoutePayload,
|
|
26
31
|
BucketNames,
|
|
27
32
|
BucketDefinitionSchema,
|
|
33
|
+
QueryModifier,
|
|
34
|
+
QueryModifierBuilder,
|
|
35
|
+
QueryInfo,
|
|
36
|
+
RelationshipsMetadata,
|
|
37
|
+
RelationshipDefinition,
|
|
38
|
+
InferRelatedModelFromMetadata,
|
|
39
|
+
GetCardinality,
|
|
28
40
|
} from '@spooky-sync/query-builder';
|
|
29
41
|
|
|
30
|
-
import { RecordId, Uuid, Surreal } from 'surrealdb';
|
|
42
|
+
import { RecordId, Uuid, type Surreal } from 'surrealdb';
|
|
31
43
|
export { RecordId, Uuid };
|
|
32
44
|
export type { Model, GenericModel, GenericSchema, ModelPayload } from './lib/models';
|
|
33
45
|
export { useQuery } from './lib/use-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';
|
|
34
65
|
export { useFileUpload, type FileUploadResult } from './lib/use-file-upload';
|
|
35
|
-
export {
|
|
36
|
-
|
|
66
|
+
export {
|
|
67
|
+
useDownloadFile,
|
|
68
|
+
type UseDownloadFileOptions,
|
|
69
|
+
type UseDownloadFileResult,
|
|
70
|
+
} from './lib/use-download-file';
|
|
71
|
+
export { Sp00kyProvider, type Sp00kyProviderProps } from './lib/Sp00kyProvider';
|
|
37
72
|
export { useDb } from './lib/context';
|
|
38
73
|
|
|
39
74
|
// export { AuthEventTypes } from "@spooky-sync/core"; // TODO: Verify if AuthEventTypes exists in core
|
|
40
|
-
export type {};
|
|
41
75
|
|
|
42
76
|
// Re-export query builder types for convenience
|
|
43
77
|
export type {
|
|
@@ -52,7 +86,7 @@ export type {
|
|
|
52
86
|
TableModel,
|
|
53
87
|
TableNames,
|
|
54
88
|
QueryResult,
|
|
55
|
-
}
|
|
89
|
+
};
|
|
56
90
|
|
|
57
91
|
export type RelationshipField<
|
|
58
92
|
Schema extends SchemaStructure,
|
|
@@ -94,39 +128,52 @@ export type WithRelatedMany<Field extends string, RelatedFields extends RelatedF
|
|
|
94
128
|
};
|
|
95
129
|
|
|
96
130
|
/**
|
|
97
|
-
* SyncedDb - A thin wrapper around
|
|
98
|
-
* Delegates all logic to the underlying
|
|
131
|
+
* SyncedDb - A thin wrapper around sp00ky-ts for Solid.js integration
|
|
132
|
+
* Delegates all logic to the underlying sp00ky-ts instance
|
|
99
133
|
*/
|
|
100
134
|
export class SyncedDb<S extends SchemaStructure> {
|
|
101
135
|
private config: SyncedDbConfig<S>;
|
|
102
|
-
private
|
|
136
|
+
private sp00ky: Sp00kyClient<S> | null = null;
|
|
103
137
|
private _initialized = false;
|
|
104
138
|
|
|
105
139
|
constructor(config: SyncedDbConfig<S>) {
|
|
106
140
|
this.config = config;
|
|
107
141
|
}
|
|
108
142
|
|
|
109
|
-
public
|
|
110
|
-
if (!this.
|
|
111
|
-
return this.
|
|
143
|
+
public getSp00ky(): Sp00kyClient<S> {
|
|
144
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
145
|
+
return this.sp00ky;
|
|
112
146
|
}
|
|
113
147
|
|
|
114
148
|
/**
|
|
115
|
-
* Initialize the
|
|
149
|
+
* Initialize the sp00ky-ts instance
|
|
116
150
|
*/
|
|
117
151
|
async init(): Promise<void> {
|
|
118
152
|
if (this._initialized) return;
|
|
119
|
-
this.
|
|
120
|
-
await this.
|
|
153
|
+
this.sp00ky = new Sp00kyClient<S>(this.config);
|
|
154
|
+
await this.sp00ky.init();
|
|
121
155
|
this._initialized = true;
|
|
122
156
|
}
|
|
123
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Tear down the client: leaves the tabs broker, closes the local store and
|
|
160
|
+
* remote socket, and frees the wasm circuit. Without this a remounted provider
|
|
161
|
+
* (or an HMR reload) strands a whole client, and the abandoned wasm heaps stay
|
|
162
|
+
* resident because V8 cannot see how much wasm memory a dropped wrapper holds.
|
|
163
|
+
*/
|
|
164
|
+
async close(): Promise<void> {
|
|
165
|
+
const instance = this.sp00ky;
|
|
166
|
+
this.sp00ky = null;
|
|
167
|
+
this._initialized = false;
|
|
168
|
+
if (instance) await instance.close();
|
|
169
|
+
}
|
|
170
|
+
|
|
124
171
|
/**
|
|
125
172
|
* Create a new record in the database
|
|
126
173
|
*/
|
|
127
174
|
async create(id: string, payload: Record<string, unknown>): Promise<void> {
|
|
128
|
-
if (!this.
|
|
129
|
-
await this.
|
|
175
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
176
|
+
await this.sp00ky.create(id, payload as Record<string, unknown>);
|
|
130
177
|
}
|
|
131
178
|
|
|
132
179
|
/**
|
|
@@ -138,8 +185,8 @@ export class SyncedDb<S extends SchemaStructure> {
|
|
|
138
185
|
payload: Partial<TableModel<GetTable<S, TName>>>,
|
|
139
186
|
options?: UpdateOptions
|
|
140
187
|
): Promise<void> {
|
|
141
|
-
if (!this.
|
|
142
|
-
await this.
|
|
188
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
189
|
+
await this.sp00ky.update(
|
|
143
190
|
tableName as string,
|
|
144
191
|
recordId,
|
|
145
192
|
payload as Record<string, unknown>,
|
|
@@ -152,12 +199,39 @@ export class SyncedDb<S extends SchemaStructure> {
|
|
|
152
199
|
*/
|
|
153
200
|
async delete<TName extends TableNames<S>>(
|
|
154
201
|
tableName: TName,
|
|
155
|
-
selector: string | InnerQuery<GetTable<S, TName>, boolean>
|
|
202
|
+
selector: string | RecordId | InnerQuery<GetTable<S, TName>, boolean>
|
|
156
203
|
): Promise<void> {
|
|
157
|
-
if (!this.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
204
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
205
|
+
// Accept a `"table:id"` string OR a RecordId — live-query rows carry their
|
|
206
|
+
// `id` as a RecordId, so callers can pass `db.delete('game', row.id)`
|
|
207
|
+
// directly. Build the canonical string from the raw id part (not
|
|
208
|
+
// `RecordId.toString()`, which escapes special chars) so it round-trips
|
|
209
|
+
// through the engine's `parseRecordIdString`. InnerQuery selectors are not
|
|
210
|
+
// supported yet. (cross-package RecordId instances → match by constructor name.)
|
|
211
|
+
const isRecordId =
|
|
212
|
+
selector instanceof RecordId || (selector as any)?.constructor?.name === 'RecordId';
|
|
213
|
+
let id: string;
|
|
214
|
+
if (typeof selector === 'string') {
|
|
215
|
+
id = selector;
|
|
216
|
+
} else if (isRecordId) {
|
|
217
|
+
id = `${tableName as string}:${(selector as RecordId).id}`;
|
|
218
|
+
} else {
|
|
219
|
+
throw new Error('Only string ID or RecordId selectors are supported currently with core');
|
|
220
|
+
}
|
|
221
|
+
await this.sp00ky.delete(tableName as string, id);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Preload/prewarm a built query into the local cache without registering a
|
|
226
|
+
* live view. Fetches once and stores the rows (+ embedded related children)
|
|
227
|
+
* locally so a later `useQuery` for the same data paints instantly. Best-effort.
|
|
228
|
+
*/
|
|
229
|
+
public async preload(
|
|
230
|
+
finalQuery: FinalQuery<S, any, any, any, any, Sp00kyQueryResultPromise>,
|
|
231
|
+
options?: PreloadOptions
|
|
232
|
+
): Promise<void> {
|
|
233
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
234
|
+
await this.sp00ky.preload(finalQuery, options);
|
|
161
235
|
}
|
|
162
236
|
|
|
163
237
|
/**
|
|
@@ -165,34 +239,31 @@ export class SyncedDb<S extends SchemaStructure> {
|
|
|
165
239
|
*/
|
|
166
240
|
public query<TName extends TableNames<S>>(
|
|
167
241
|
table: TName
|
|
168
|
-
): QueryBuilder<S, TName,
|
|
169
|
-
if (!this.
|
|
170
|
-
return this.
|
|
242
|
+
): QueryBuilder<S, TName, Sp00kyQueryResultPromise, {}, false> {
|
|
243
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
244
|
+
return this.sp00ky.query(table, {});
|
|
171
245
|
}
|
|
172
246
|
|
|
173
247
|
/**
|
|
174
248
|
* Run a backend operation
|
|
175
249
|
*/
|
|
176
|
-
public async run<
|
|
177
|
-
B extends BackendNames<S>,
|
|
178
|
-
R extends BackendRoutes<S, B>,
|
|
179
|
-
>(
|
|
250
|
+
public async run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
|
|
180
251
|
backend: B,
|
|
181
252
|
path: R,
|
|
182
253
|
payload: RoutePayload<S, B, R>,
|
|
183
|
-
options?: RunOptions
|
|
254
|
+
options?: RunOptions
|
|
184
255
|
): Promise<void> {
|
|
185
|
-
if (!this.
|
|
186
|
-
await this.
|
|
256
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
257
|
+
await this.sp00ky.run(backend, path, payload, options);
|
|
187
258
|
}
|
|
188
259
|
|
|
189
260
|
/**
|
|
190
261
|
* Authenticate with the database
|
|
191
262
|
*/
|
|
192
263
|
public async authenticate(token: string): Promise<RecordId<string>> {
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
// Wait, checked
|
|
264
|
+
await this.sp00ky?.authenticate(token);
|
|
265
|
+
// Sp00kyClient.authenticate returns whatever remote.authenticate returns (boolean or token usually?)
|
|
266
|
+
// Wait, checked Sp00kyClient: return this.remote.getClient().authenticate(token);
|
|
196
267
|
// SurrealDB authenticate returns void? or token?
|
|
197
268
|
// Assuming void or token.
|
|
198
269
|
return new RecordId('user', 'me'); // Placeholder or actual?
|
|
@@ -210,54 +281,92 @@ export class SyncedDb<S extends SchemaStructure> {
|
|
|
210
281
|
* Sign out, clear session and local storage
|
|
211
282
|
*/
|
|
212
283
|
public async signOut(): Promise<void> {
|
|
213
|
-
if (!this.
|
|
214
|
-
await this.
|
|
284
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
285
|
+
await this.sp00ky.auth.signOut();
|
|
215
286
|
}
|
|
216
287
|
|
|
217
288
|
/**
|
|
218
289
|
* Execute a function with direct access to the remote database connection
|
|
219
290
|
*/
|
|
220
291
|
public async useRemote<T>(fn: (db: Surreal) => T | Promise<T>): Promise<T> {
|
|
221
|
-
if (!this.
|
|
222
|
-
return await this.
|
|
292
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
293
|
+
return await this.sp00ky.useRemote(fn);
|
|
223
294
|
}
|
|
224
295
|
/**
|
|
225
296
|
* Access the remote database service directly
|
|
226
297
|
*/
|
|
227
|
-
get remote():
|
|
228
|
-
if (!this.
|
|
229
|
-
return this.
|
|
298
|
+
get remote(): Sp00kyClient<S>['remoteClient'] {
|
|
299
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
300
|
+
return this.sp00ky.remoteClient;
|
|
230
301
|
}
|
|
231
302
|
|
|
232
303
|
/**
|
|
233
304
|
* Access the local database service directly
|
|
234
305
|
*/
|
|
235
|
-
get local():
|
|
236
|
-
if (!this.
|
|
237
|
-
return this.
|
|
306
|
+
get local(): Sp00kyClient<S>['localClient'] {
|
|
307
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
308
|
+
return this.sp00ky.localClient;
|
|
238
309
|
}
|
|
239
310
|
|
|
240
311
|
/**
|
|
241
312
|
* Access the auth service
|
|
242
313
|
*/
|
|
243
314
|
get auth(): AuthService<S> {
|
|
244
|
-
if (!this.
|
|
245
|
-
return this.
|
|
315
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
316
|
+
return this.sp00ky.auth;
|
|
246
317
|
}
|
|
247
318
|
|
|
248
319
|
get pendingMutationCount(): number {
|
|
249
|
-
if (!this.
|
|
250
|
-
return this.
|
|
320
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
321
|
+
return this.sp00ky.pendingMutationCount;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Diagnostic — see `Sp00kyClient.liveRetryCount`. */
|
|
325
|
+
get liveRetryCount(): number {
|
|
326
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
327
|
+
return this.sp00ky.liveRetryCount;
|
|
251
328
|
}
|
|
252
329
|
|
|
253
330
|
subscribeToPendingMutations(cb: (count: number) => void): () => void {
|
|
254
|
-
if (!this.
|
|
255
|
-
return this.
|
|
331
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
332
|
+
return this.sp00ky.subscribeToPendingMutations(cb);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Current sync-health snapshot. See {@link useSyncStatus}. */
|
|
336
|
+
get syncHealth(): SyncHealth {
|
|
337
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
338
|
+
return this.sp00ky.syncHealth;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Observe sync health. Fires immediately with the current status and again
|
|
343
|
+
* on every healthy↔degraded transition. Prefer the `useSyncStatus` hook in
|
|
344
|
+
* components; this is the imperative escape hatch.
|
|
345
|
+
*/
|
|
346
|
+
subscribeToSyncHealth(cb: (health: SyncHealth) => void): () => void {
|
|
347
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
348
|
+
return this.sp00ky.subscribeToSyncHealth(cb);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Current local-store durability snapshot. See {@link useStorageStatus}. */
|
|
352
|
+
get storageHealth(): StorageHealth {
|
|
353
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
354
|
+
return this.sp00ky.storageHealth;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Observe local-store durability. Fires immediately with the current snapshot
|
|
359
|
+
* and again on change. Prefer the `useStorageStatus` hook in components; this
|
|
360
|
+
* is the imperative escape hatch.
|
|
361
|
+
*/
|
|
362
|
+
subscribeToStorageHealth(cb: (health: StorageHealth) => void): () => void {
|
|
363
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
364
|
+
return this.sp00ky.subscribeToStorageHealth(cb);
|
|
256
365
|
}
|
|
257
366
|
|
|
258
367
|
bucket<B extends BucketNames<S>>(name: B): BucketHandle {
|
|
259
|
-
if (!this.
|
|
260
|
-
return this.
|
|
368
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
369
|
+
return this.sp00ky.bucket(name);
|
|
261
370
|
}
|
|
262
371
|
|
|
263
372
|
getBucketConfig(name: string): BucketDefinitionSchema | undefined {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { JSX } from 'solid-js';
|
|
2
|
+
import {
|
|
3
|
+
createSignal,
|
|
4
|
+
onMount,
|
|
5
|
+
onCleanup,
|
|
6
|
+
createComponent,
|
|
7
|
+
createMemo,
|
|
8
|
+
mergeProps,
|
|
9
|
+
} from 'solid-js';
|
|
10
|
+
import type { SchemaStructure } from '@spooky/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?: JSX.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: JSX.Element;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function Sp00kyProvider<S extends SchemaStructure>(
|
|
33
|
+
props: Sp00kyProviderProps<S>
|
|
34
|
+
): JSX.Element {
|
|
35
|
+
const merged = mergeProps(
|
|
36
|
+
{
|
|
37
|
+
fallback: undefined as JSX.Element | undefined,
|
|
38
|
+
},
|
|
39
|
+
props
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const [db, setDb] = createSignal<SyncedDb<S> | undefined>(undefined);
|
|
43
|
+
|
|
44
|
+
// `onMount` is async, so a dispose can land mid-init. Only that narrow race is
|
|
45
|
+
// handled here: an instance whose init finished AFTER the provider was already
|
|
46
|
+
// gone is closed, because nothing will ever reference it.
|
|
47
|
+
//
|
|
48
|
+
// A live, mounted client is deliberately NOT closed on cleanup. Doing that
|
|
49
|
+
// nulls `SyncedDb.sp00ky`, so every later `create`/`update`/`delete` throws
|
|
50
|
+
// "SyncedDb not initialized" while reads keep rendering from state that is
|
|
51
|
+
// already subscribed — i.e. mutations die silently and the app looks fine. In
|
|
52
|
+
// a host app the provider wraps the whole tree and only unmounts with the
|
|
53
|
+
// page, where the browser reclaims the worker anyway, so the leak this was
|
|
54
|
+
// meant to fix is worth far less than that risk.
|
|
55
|
+
let disposed = false;
|
|
56
|
+
|
|
57
|
+
onCleanup(() => {
|
|
58
|
+
disposed = true;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
onMount(async () => {
|
|
62
|
+
try {
|
|
63
|
+
const instance = new SyncedDb<S>(merged.config);
|
|
64
|
+
await instance.init();
|
|
65
|
+
if (disposed) {
|
|
66
|
+
await instance.close();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// Gate first-load UI on prewarmed data. Best-effort: never let a preload
|
|
70
|
+
// failure keep the app stuck on the fallback.
|
|
71
|
+
if (merged.preload) {
|
|
72
|
+
try {
|
|
73
|
+
await merged.preload(instance);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
// oxlint-disable-next-line no-console
|
|
76
|
+
console.error('Sp00kyProvider: preload failed; revealing UI anyway', e);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
setDb(() => instance);
|
|
80
|
+
merged.onReady?.(instance);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
83
|
+
if (merged.onError) {
|
|
84
|
+
merged.onError(error);
|
|
85
|
+
} else {
|
|
86
|
+
// oxlint-disable-next-line no-console
|
|
87
|
+
console.error('Sp00kyProvider: Failed to initialize database', error);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const content = createMemo(() => {
|
|
93
|
+
const instance = db();
|
|
94
|
+
if (!instance) return merged.fallback;
|
|
95
|
+
return createComponent(Sp00kyContext.Provider, {
|
|
96
|
+
value: instance,
|
|
97
|
+
get children() {
|
|
98
|
+
return merged.children;
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return content as unknown as JSX.Element;
|
|
104
|
+
}
|
package/src/lib/context.ts
CHANGED
|
@@ -2,12 +2,12 @@ import { createContext, useContext } from 'solid-js';
|
|
|
2
2
|
import type { SchemaStructure } from '@spooky/query-builder';
|
|
3
3
|
import type { SyncedDb } from '../index';
|
|
4
4
|
|
|
5
|
-
export const
|
|
5
|
+
export const Sp00kyContext = createContext<SyncedDb<any> | undefined>();
|
|
6
6
|
|
|
7
7
|
export function useDb<S extends SchemaStructure>(): SyncedDb<S> {
|
|
8
|
-
const db = useContext(
|
|
8
|
+
const db = useContext(Sp00kyContext);
|
|
9
9
|
if (!db) {
|
|
10
|
-
throw new Error('useDb must be used within a <
|
|
10
|
+
throw new Error('useDb must be used within a <Sp00kyProvider>. Wrap your app in <Sp00kyProvider config={...}>.');
|
|
11
11
|
}
|
|
12
12
|
return db as SyncedDb<S>;
|
|
13
13
|
}
|