@spooky-sync/client-solid 0.0.1-canary.15 → 0.0.1-canary.151
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 +322 -65
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +173 -21
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +173 -21
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +318 -66
- package/dist/index.js.map +1 -1
- package/package.json +9 -7
- package/skills/sp00ky-solid/SKILL.md +335 -0
- package/skills/sp00ky-solid/references/file-hooks.md +112 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/surrealdb-wasm-factory.ts +4 -1
- package/src/index.ts +126 -55
- package/src/lib/Sp00kyProvider.ts +76 -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 +2 -2
- package/src/lib/use-feature-flag.ts +50 -0
- package/src/lib/use-file-upload.ts +2 -1
- package/src/lib/use-query.ts +143 -28
- package/src/lib/use-sync-status.ts +50 -0
- package/src/types/index.ts +3 -4
- package/src/lib/SpookyProvider.ts +0 -55
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# File Hooks Reference
|
|
2
|
+
|
|
3
|
+
## useFileUpload
|
|
4
|
+
|
|
5
|
+
Upload, download, and manage files in a SurrealDB bucket.
|
|
6
|
+
|
|
7
|
+
### Signatures
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
// Context-based (inside Sp00kyProvider)
|
|
11
|
+
useFileUpload<S>(bucketName: BucketNames<S>): FileUploadResult;
|
|
12
|
+
|
|
13
|
+
// Explicit db
|
|
14
|
+
useFileUpload<S>(db: SyncedDb<S>, bucketName: BucketNames<S>): FileUploadResult;
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Return Value
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
interface FileUploadResult {
|
|
21
|
+
isUploading: () => boolean;
|
|
22
|
+
error: () => Error | null;
|
|
23
|
+
clearError: () => void;
|
|
24
|
+
upload: (path: string, file: File | Blob) => Promise<void>;
|
|
25
|
+
download: (path: string) => Promise<string | null>; // Returns object URL
|
|
26
|
+
remove: (path: string) => Promise<void>;
|
|
27
|
+
exists: (path: string) => Promise<boolean>;
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Validation
|
|
32
|
+
|
|
33
|
+
If the bucket has `maxSize` or `allowedExtensions` configured in the schema, the hook validates files before upload and sets `error()` on failure.
|
|
34
|
+
|
|
35
|
+
### Example
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
function AvatarUpload() {
|
|
39
|
+
const { upload, isUploading, error, clearError } = useFileUpload('avatars');
|
|
40
|
+
|
|
41
|
+
const handleFile = async (e: Event) => {
|
|
42
|
+
const file = (e.target as HTMLInputElement).files?.[0];
|
|
43
|
+
if (file) {
|
|
44
|
+
await upload(`user/${userId()}/avatar.png`, file);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div>
|
|
50
|
+
<input type="file" onChange={handleFile} disabled={isUploading()} />
|
|
51
|
+
<Show when={error()}>
|
|
52
|
+
<p class="error">{error()!.message}</p>
|
|
53
|
+
<button onClick={clearError}>Dismiss</button>
|
|
54
|
+
</Show>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## useDownloadFile
|
|
61
|
+
|
|
62
|
+
Reactively download a file from a bucket. Re-fetches when the path changes.
|
|
63
|
+
|
|
64
|
+
### Signatures
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
// Context-based
|
|
68
|
+
useDownloadFile<S>(
|
|
69
|
+
bucketName: BucketNames<S>,
|
|
70
|
+
path: Accessor<string | null | undefined>,
|
|
71
|
+
options?: { cache?: boolean },
|
|
72
|
+
): UseDownloadFileResult;
|
|
73
|
+
|
|
74
|
+
// Explicit db
|
|
75
|
+
useDownloadFile<S>(
|
|
76
|
+
db: SyncedDb<S>,
|
|
77
|
+
bucketName: BucketNames<S>,
|
|
78
|
+
path: Accessor<string | null | undefined>,
|
|
79
|
+
options?: { cache?: boolean },
|
|
80
|
+
): UseDownloadFileResult;
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Return Value
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
interface UseDownloadFileResult {
|
|
87
|
+
url: Accessor<string | null>; // Object URL for the file
|
|
88
|
+
isLoading: Accessor<boolean>;
|
|
89
|
+
error: Accessor<Error | null>;
|
|
90
|
+
refetch: () => void; // Force re-download (evicts cache)
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Caching
|
|
95
|
+
|
|
96
|
+
By default, downloads are cached by `bucket:path` key with reference counting. Object URLs are revoked when no component references them. Set `cache: false` to disable.
|
|
97
|
+
|
|
98
|
+
### Example
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
function Avatar(props: { path: string | null }) {
|
|
102
|
+
const { url, isLoading } = useDownloadFile('avatars', () => props.path);
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<Show when={!isLoading()} fallback={<Spinner />}>
|
|
106
|
+
<Show when={url()}>
|
|
107
|
+
<img src={url()!} alt="Avatar" />
|
|
108
|
+
</Show>
|
|
109
|
+
</Show>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
```
|
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,17 @@
|
|
|
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 PreloadOptions,
|
|
11
|
+
type PreloadRefresh,
|
|
9
12
|
} from '@spooky-sync/core';
|
|
10
13
|
|
|
11
|
-
import {
|
|
14
|
+
import type {
|
|
12
15
|
GetTable,
|
|
13
16
|
QueryBuilder,
|
|
14
17
|
SchemaStructure,
|
|
@@ -19,25 +22,44 @@ import {
|
|
|
19
22
|
RelationshipFieldsFromSchema,
|
|
20
23
|
GetRelationship,
|
|
21
24
|
RelatedFieldMapEntry,
|
|
25
|
+
FinalQuery,
|
|
22
26
|
InnerQuery,
|
|
23
27
|
BackendNames,
|
|
24
28
|
BackendRoutes,
|
|
25
29
|
RoutePayload,
|
|
26
30
|
BucketNames,
|
|
27
31
|
BucketDefinitionSchema,
|
|
32
|
+
QueryModifier,
|
|
33
|
+
QueryModifierBuilder,
|
|
34
|
+
QueryInfo,
|
|
35
|
+
RelationshipsMetadata,
|
|
36
|
+
RelationshipDefinition,
|
|
37
|
+
InferRelatedModelFromMetadata,
|
|
38
|
+
GetCardinality,
|
|
28
39
|
} from '@spooky-sync/query-builder';
|
|
29
40
|
|
|
30
|
-
import { RecordId, Uuid, Surreal } from 'surrealdb';
|
|
41
|
+
import { RecordId, Uuid, type Surreal } from 'surrealdb';
|
|
31
42
|
export { RecordId, Uuid };
|
|
32
43
|
export type { Model, GenericModel, GenericSchema, ModelPayload } from './lib/models';
|
|
33
44
|
export { useQuery } from './lib/use-query';
|
|
45
|
+
export { createPreload } from './lib/create-preload';
|
|
46
|
+
export type { PreloadOptions, PreloadRefresh } from '@spooky-sync/core';
|
|
47
|
+
export { useSyncStatus, type UseSyncStatus } from './lib/use-sync-status';
|
|
48
|
+
export type { SyncHealth, SyncHealthStatus, SyncHealthConfig } from '@spooky-sync/core';
|
|
49
|
+
export { useCrdtField } from './lib/use-crdt-field';
|
|
50
|
+
export { useFeatureFlag, type UseFeatureFlag } from './lib/use-feature-flag';
|
|
51
|
+
export {
|
|
52
|
+
useAppRelease,
|
|
53
|
+
type UseAppRelease,
|
|
54
|
+
type UseAppReleaseOptions,
|
|
55
|
+
} from './lib/use-app-release';
|
|
34
56
|
export { useFileUpload, type FileUploadResult } from './lib/use-file-upload';
|
|
35
57
|
export { useDownloadFile, type UseDownloadFileOptions, type UseDownloadFileResult } from './lib/use-download-file';
|
|
36
|
-
export {
|
|
58
|
+
export { Sp00kyProvider, type Sp00kyProviderProps } from './lib/Sp00kyProvider';
|
|
37
59
|
export { useDb } from './lib/context';
|
|
38
60
|
|
|
39
61
|
// export { AuthEventTypes } from "@spooky-sync/core"; // TODO: Verify if AuthEventTypes exists in core
|
|
40
|
-
|
|
62
|
+
|
|
41
63
|
|
|
42
64
|
// Re-export query builder types for convenience
|
|
43
65
|
export type {
|
|
@@ -52,7 +74,7 @@ export type {
|
|
|
52
74
|
TableModel,
|
|
53
75
|
TableNames,
|
|
54
76
|
QueryResult,
|
|
55
|
-
}
|
|
77
|
+
};
|
|
56
78
|
|
|
57
79
|
export type RelationshipField<
|
|
58
80
|
Schema extends SchemaStructure,
|
|
@@ -94,30 +116,30 @@ export type WithRelatedMany<Field extends string, RelatedFields extends RelatedF
|
|
|
94
116
|
};
|
|
95
117
|
|
|
96
118
|
/**
|
|
97
|
-
* SyncedDb - A thin wrapper around
|
|
98
|
-
* Delegates all logic to the underlying
|
|
119
|
+
* SyncedDb - A thin wrapper around sp00ky-ts for Solid.js integration
|
|
120
|
+
* Delegates all logic to the underlying sp00ky-ts instance
|
|
99
121
|
*/
|
|
100
122
|
export class SyncedDb<S extends SchemaStructure> {
|
|
101
123
|
private config: SyncedDbConfig<S>;
|
|
102
|
-
private
|
|
124
|
+
private sp00ky: Sp00kyClient<S> | null = null;
|
|
103
125
|
private _initialized = false;
|
|
104
126
|
|
|
105
127
|
constructor(config: SyncedDbConfig<S>) {
|
|
106
128
|
this.config = config;
|
|
107
129
|
}
|
|
108
130
|
|
|
109
|
-
public
|
|
110
|
-
if (!this.
|
|
111
|
-
return this.
|
|
131
|
+
public getSp00ky(): Sp00kyClient<S> {
|
|
132
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
133
|
+
return this.sp00ky;
|
|
112
134
|
}
|
|
113
135
|
|
|
114
136
|
/**
|
|
115
|
-
* Initialize the
|
|
137
|
+
* Initialize the sp00ky-ts instance
|
|
116
138
|
*/
|
|
117
139
|
async init(): Promise<void> {
|
|
118
140
|
if (this._initialized) return;
|
|
119
|
-
this.
|
|
120
|
-
await this.
|
|
141
|
+
this.sp00ky = new Sp00kyClient<S>(this.config);
|
|
142
|
+
await this.sp00ky.init();
|
|
121
143
|
this._initialized = true;
|
|
122
144
|
}
|
|
123
145
|
|
|
@@ -125,8 +147,8 @@ export class SyncedDb<S extends SchemaStructure> {
|
|
|
125
147
|
* Create a new record in the database
|
|
126
148
|
*/
|
|
127
149
|
async create(id: string, payload: Record<string, unknown>): Promise<void> {
|
|
128
|
-
if (!this.
|
|
129
|
-
await this.
|
|
150
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
151
|
+
await this.sp00ky.create(id, payload as Record<string, unknown>);
|
|
130
152
|
}
|
|
131
153
|
|
|
132
154
|
/**
|
|
@@ -138,8 +160,8 @@ export class SyncedDb<S extends SchemaStructure> {
|
|
|
138
160
|
payload: Partial<TableModel<GetTable<S, TName>>>,
|
|
139
161
|
options?: UpdateOptions
|
|
140
162
|
): Promise<void> {
|
|
141
|
-
if (!this.
|
|
142
|
-
await this.
|
|
163
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
164
|
+
await this.sp00ky.update(
|
|
143
165
|
tableName as string,
|
|
144
166
|
recordId,
|
|
145
167
|
payload as Record<string, unknown>,
|
|
@@ -152,12 +174,39 @@ export class SyncedDb<S extends SchemaStructure> {
|
|
|
152
174
|
*/
|
|
153
175
|
async delete<TName extends TableNames<S>>(
|
|
154
176
|
tableName: TName,
|
|
155
|
-
selector: string | InnerQuery<GetTable<S, TName>, boolean>
|
|
177
|
+
selector: string | RecordId | InnerQuery<GetTable<S, TName>, boolean>
|
|
178
|
+
): Promise<void> {
|
|
179
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
180
|
+
// Accept a `"table:id"` string OR a RecordId — live-query rows carry their
|
|
181
|
+
// `id` as a RecordId, so callers can pass `db.delete('game', row.id)`
|
|
182
|
+
// directly. Build the canonical string from the raw id part (not
|
|
183
|
+
// `RecordId.toString()`, which escapes special chars) so it round-trips
|
|
184
|
+
// through the engine's `parseRecordIdString`. InnerQuery selectors are not
|
|
185
|
+
// supported yet. (cross-package RecordId instances → match by constructor name.)
|
|
186
|
+
const isRecordId =
|
|
187
|
+
selector instanceof RecordId || (selector as any)?.constructor?.name === 'RecordId';
|
|
188
|
+
let id: string;
|
|
189
|
+
if (typeof selector === 'string') {
|
|
190
|
+
id = selector;
|
|
191
|
+
} else if (isRecordId) {
|
|
192
|
+
id = `${tableName as string}:${(selector as RecordId).id}`;
|
|
193
|
+
} else {
|
|
194
|
+
throw new Error('Only string ID or RecordId selectors are supported currently with core');
|
|
195
|
+
}
|
|
196
|
+
await this.sp00ky.delete(tableName as string, id);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Preload/prewarm a built query into the local cache without registering a
|
|
201
|
+
* live view. Fetches once and stores the rows (+ embedded related children)
|
|
202
|
+
* locally so a later `useQuery` for the same data paints instantly. Best-effort.
|
|
203
|
+
*/
|
|
204
|
+
public async preload(
|
|
205
|
+
finalQuery: FinalQuery<S, any, any, any, any, Sp00kyQueryResultPromise>,
|
|
206
|
+
options?: PreloadOptions
|
|
156
207
|
): Promise<void> {
|
|
157
|
-
if (!this.
|
|
158
|
-
|
|
159
|
-
throw new Error('Only string ID selectors are supported currently with core');
|
|
160
|
-
await this.spooky.delete(tableName as string, selector);
|
|
208
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
209
|
+
await this.sp00ky.preload(finalQuery, options);
|
|
161
210
|
}
|
|
162
211
|
|
|
163
212
|
/**
|
|
@@ -165,9 +214,9 @@ export class SyncedDb<S extends SchemaStructure> {
|
|
|
165
214
|
*/
|
|
166
215
|
public query<TName extends TableNames<S>>(
|
|
167
216
|
table: TName
|
|
168
|
-
): QueryBuilder<S, TName,
|
|
169
|
-
if (!this.
|
|
170
|
-
return this.
|
|
217
|
+
): QueryBuilder<S, TName, Sp00kyQueryResultPromise, {}, false> {
|
|
218
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
219
|
+
return this.sp00ky.query(table, {});
|
|
171
220
|
}
|
|
172
221
|
|
|
173
222
|
/**
|
|
@@ -182,17 +231,17 @@ export class SyncedDb<S extends SchemaStructure> {
|
|
|
182
231
|
payload: RoutePayload<S, B, R>,
|
|
183
232
|
options?: RunOptions,
|
|
184
233
|
): Promise<void> {
|
|
185
|
-
if (!this.
|
|
186
|
-
await this.
|
|
234
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
235
|
+
await this.sp00ky.run(backend, path, payload, options);
|
|
187
236
|
}
|
|
188
237
|
|
|
189
238
|
/**
|
|
190
239
|
* Authenticate with the database
|
|
191
240
|
*/
|
|
192
241
|
public async authenticate(token: string): Promise<RecordId<string>> {
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
// Wait, checked
|
|
242
|
+
await this.sp00ky?.authenticate(token);
|
|
243
|
+
// Sp00kyClient.authenticate returns whatever remote.authenticate returns (boolean or token usually?)
|
|
244
|
+
// Wait, checked Sp00kyClient: return this.remote.getClient().authenticate(token);
|
|
196
245
|
// SurrealDB authenticate returns void? or token?
|
|
197
246
|
// Assuming void or token.
|
|
198
247
|
return new RecordId('user', 'me'); // Placeholder or actual?
|
|
@@ -210,54 +259,76 @@ export class SyncedDb<S extends SchemaStructure> {
|
|
|
210
259
|
* Sign out, clear session and local storage
|
|
211
260
|
*/
|
|
212
261
|
public async signOut(): Promise<void> {
|
|
213
|
-
if (!this.
|
|
214
|
-
await this.
|
|
262
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
263
|
+
await this.sp00ky.auth.signOut();
|
|
215
264
|
}
|
|
216
265
|
|
|
217
266
|
/**
|
|
218
267
|
* Execute a function with direct access to the remote database connection
|
|
219
268
|
*/
|
|
220
269
|
public async useRemote<T>(fn: (db: Surreal) => T | Promise<T>): Promise<T> {
|
|
221
|
-
if (!this.
|
|
222
|
-
return await this.
|
|
270
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
271
|
+
return await this.sp00ky.useRemote(fn);
|
|
223
272
|
}
|
|
224
273
|
/**
|
|
225
274
|
* Access the remote database service directly
|
|
226
275
|
*/
|
|
227
|
-
get remote():
|
|
228
|
-
if (!this.
|
|
229
|
-
return this.
|
|
276
|
+
get remote(): Sp00kyClient<S>['remoteClient'] {
|
|
277
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
278
|
+
return this.sp00ky.remoteClient;
|
|
230
279
|
}
|
|
231
280
|
|
|
232
281
|
/**
|
|
233
282
|
* Access the local database service directly
|
|
234
283
|
*/
|
|
235
|
-
get local():
|
|
236
|
-
if (!this.
|
|
237
|
-
return this.
|
|
284
|
+
get local(): Sp00kyClient<S>['localClient'] {
|
|
285
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
286
|
+
return this.sp00ky.localClient;
|
|
238
287
|
}
|
|
239
288
|
|
|
240
289
|
/**
|
|
241
290
|
* Access the auth service
|
|
242
291
|
*/
|
|
243
292
|
get auth(): AuthService<S> {
|
|
244
|
-
if (!this.
|
|
245
|
-
return this.
|
|
293
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
294
|
+
return this.sp00ky.auth;
|
|
246
295
|
}
|
|
247
296
|
|
|
248
297
|
get pendingMutationCount(): number {
|
|
249
|
-
if (!this.
|
|
250
|
-
return this.
|
|
298
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
299
|
+
return this.sp00ky.pendingMutationCount;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Diagnostic — see `Sp00kyClient.liveRetryCount`. */
|
|
303
|
+
get liveRetryCount(): number {
|
|
304
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
305
|
+
return this.sp00ky.liveRetryCount;
|
|
251
306
|
}
|
|
252
307
|
|
|
253
308
|
subscribeToPendingMutations(cb: (count: number) => void): () => void {
|
|
254
|
-
if (!this.
|
|
255
|
-
return this.
|
|
309
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
310
|
+
return this.sp00ky.subscribeToPendingMutations(cb);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Current sync-health snapshot. See {@link useSyncStatus}. */
|
|
314
|
+
get syncHealth(): SyncHealth {
|
|
315
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
316
|
+
return this.sp00ky.syncHealth;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Observe sync health. Fires immediately with the current status and again
|
|
321
|
+
* on every healthy↔degraded transition. Prefer the `useSyncStatus` hook in
|
|
322
|
+
* components; this is the imperative escape hatch.
|
|
323
|
+
*/
|
|
324
|
+
subscribeToSyncHealth(cb: (health: SyncHealth) => void): () => void {
|
|
325
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
326
|
+
return this.sp00ky.subscribeToSyncHealth(cb);
|
|
256
327
|
}
|
|
257
328
|
|
|
258
329
|
bucket<B extends BucketNames<S>>(name: B): BucketHandle {
|
|
259
|
-
if (!this.
|
|
260
|
-
return this.
|
|
330
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
331
|
+
return this.sp00ky.bucket(name);
|
|
261
332
|
}
|
|
262
333
|
|
|
263
334
|
getBucketConfig(name: string): BucketDefinitionSchema | undefined {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { JSX} from 'solid-js';
|
|
2
|
+
import { createSignal, onMount, createComponent, createMemo, mergeProps } from 'solid-js';
|
|
3
|
+
import type { SchemaStructure } from '@spooky/query-builder';
|
|
4
|
+
import type { SyncedDbConfig } from '../types';
|
|
5
|
+
import { SyncedDb } from '../index';
|
|
6
|
+
import { Sp00kyContext } from './context';
|
|
7
|
+
|
|
8
|
+
export interface Sp00kyProviderProps<S extends SchemaStructure> {
|
|
9
|
+
config: SyncedDbConfig<S>;
|
|
10
|
+
fallback?: JSX.Element;
|
|
11
|
+
onError?: (error: Error) => void;
|
|
12
|
+
onReady?: (db: SyncedDb<S>) => void;
|
|
13
|
+
/**
|
|
14
|
+
* Prewarm data into the local cache before revealing the UI. Runs after
|
|
15
|
+
* `init()`; the `fallback` stays visible until it resolves. Use awaitable
|
|
16
|
+
* `db.preload(...)` calls here to gate first-load on essential data (e.g.
|
|
17
|
+
* config). On warm loads preload returns instantly, so there's no perceptible
|
|
18
|
+
* gate after the first run. Best-effort: a rejection is caught and the UI is
|
|
19
|
+
* revealed anyway.
|
|
20
|
+
*/
|
|
21
|
+
preload?: (db: SyncedDb<S>) => Promise<void>;
|
|
22
|
+
children: JSX.Element;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function Sp00kyProvider<S extends SchemaStructure>(
|
|
26
|
+
props: Sp00kyProviderProps<S>
|
|
27
|
+
): JSX.Element {
|
|
28
|
+
const merged = mergeProps(
|
|
29
|
+
{
|
|
30
|
+
fallback: undefined as JSX.Element | undefined,
|
|
31
|
+
},
|
|
32
|
+
props
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const [db, setDb] = createSignal<SyncedDb<S> | undefined>(undefined);
|
|
36
|
+
|
|
37
|
+
onMount(async () => {
|
|
38
|
+
try {
|
|
39
|
+
const instance = new SyncedDb<S>(merged.config);
|
|
40
|
+
await instance.init();
|
|
41
|
+
// Gate first-load UI on prewarmed data. Best-effort: never let a preload
|
|
42
|
+
// failure keep the app stuck on the fallback.
|
|
43
|
+
if (merged.preload) {
|
|
44
|
+
try {
|
|
45
|
+
await merged.preload(instance);
|
|
46
|
+
} catch (e) {
|
|
47
|
+
// oxlint-disable-next-line no-console
|
|
48
|
+
console.error('Sp00kyProvider: preload failed; revealing UI anyway', e);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
setDb(() => instance);
|
|
52
|
+
merged.onReady?.(instance);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
55
|
+
if (merged.onError) {
|
|
56
|
+
merged.onError(error);
|
|
57
|
+
} else {
|
|
58
|
+
// oxlint-disable-next-line no-console
|
|
59
|
+
console.error('Sp00kyProvider: Failed to initialize database', error);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const content = createMemo(() => {
|
|
65
|
+
const instance = db();
|
|
66
|
+
if (!instance) return merged.fallback;
|
|
67
|
+
return createComponent(Sp00kyContext.Provider, {
|
|
68
|
+
value: instance,
|
|
69
|
+
get children() {
|
|
70
|
+
return merged.children;
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return content as unknown as JSX.Element;
|
|
76
|
+
}
|
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
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ColumnSchema,
|
|
3
|
+
FinalQuery,
|
|
4
|
+
SchemaStructure,
|
|
5
|
+
TableNames,
|
|
6
|
+
} from '@spooky-sync/query-builder';
|
|
7
|
+
import { createEffect, useContext } from 'solid-js';
|
|
8
|
+
import { SyncedDb } from '..';
|
|
9
|
+
import type { Sp00kyQueryResultPromise, PreloadOptions as CorePreloadOptions } from '@spooky-sync/core';
|
|
10
|
+
import { Sp00kyContext } from './context';
|
|
11
|
+
|
|
12
|
+
type PreloadArg<
|
|
13
|
+
S extends SchemaStructure,
|
|
14
|
+
TableName extends TableNames<S>,
|
|
15
|
+
T extends { columns: Record<string, ColumnSchema> },
|
|
16
|
+
RelatedFields extends Record<string, any>,
|
|
17
|
+
IsOne extends boolean,
|
|
18
|
+
> =
|
|
19
|
+
| FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
|
|
20
|
+
| (() =>
|
|
21
|
+
| FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
|
|
22
|
+
| null
|
|
23
|
+
| undefined);
|
|
24
|
+
|
|
25
|
+
type PreloadOptions = CorePreloadOptions & {
|
|
26
|
+
/** Only preload while this returns true (defaults to always). */
|
|
27
|
+
enabled?: () => boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Overload: context-based (no explicit db)
|
|
31
|
+
export function createPreload<
|
|
32
|
+
S extends SchemaStructure,
|
|
33
|
+
TableName extends TableNames<S>,
|
|
34
|
+
T extends { columns: Record<string, ColumnSchema> },
|
|
35
|
+
RelatedFields extends Record<string, any>,
|
|
36
|
+
IsOne extends boolean,
|
|
37
|
+
>(
|
|
38
|
+
finalQuery: PreloadArg<S, TableName, T, RelatedFields, IsOne>,
|
|
39
|
+
options?: PreloadOptions,
|
|
40
|
+
): void;
|
|
41
|
+
|
|
42
|
+
// Overload: explicit db
|
|
43
|
+
export function createPreload<
|
|
44
|
+
S extends SchemaStructure,
|
|
45
|
+
TableName extends TableNames<S>,
|
|
46
|
+
T extends { columns: Record<string, ColumnSchema> },
|
|
47
|
+
RelatedFields extends Record<string, any>,
|
|
48
|
+
IsOne extends boolean,
|
|
49
|
+
>(
|
|
50
|
+
db: SyncedDb<S>,
|
|
51
|
+
finalQuery: PreloadArg<S, TableName, T, RelatedFields, IsOne>,
|
|
52
|
+
options?: PreloadOptions,
|
|
53
|
+
): void;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Reactive, fire-and-forget prewarm. Resolves the query (calling it if it's a
|
|
57
|
+
* function so it tracks reactive deps), dedupes on the query's stable identity
|
|
58
|
+
* hash, and warms it into the local cache via `db.preload`. No subscription and
|
|
59
|
+
* no cleanup: preload registers nothing that needs tearing down.
|
|
60
|
+
*
|
|
61
|
+
* Typical use: inside a list row, preload the detail query the user is likely
|
|
62
|
+
* to open next, so navigation paints from cache instead of the network.
|
|
63
|
+
*/
|
|
64
|
+
export function createPreload<
|
|
65
|
+
S extends SchemaStructure,
|
|
66
|
+
TableName extends TableNames<S>,
|
|
67
|
+
T extends { columns: Record<string, ColumnSchema> },
|
|
68
|
+
RelatedFields extends Record<string, any>,
|
|
69
|
+
IsOne extends boolean,
|
|
70
|
+
>(
|
|
71
|
+
dbOrQuery: SyncedDb<S> | PreloadArg<S, TableName, T, RelatedFields, IsOne>,
|
|
72
|
+
queryOrOptions?: PreloadArg<S, TableName, T, RelatedFields, IsOne> | PreloadOptions,
|
|
73
|
+
maybeOptions?: PreloadOptions,
|
|
74
|
+
): void {
|
|
75
|
+
let db: SyncedDb<S>;
|
|
76
|
+
let finalQuery: PreloadArg<S, TableName, T, RelatedFields, IsOne>;
|
|
77
|
+
let options: PreloadOptions | undefined;
|
|
78
|
+
|
|
79
|
+
if (dbOrQuery instanceof SyncedDb) {
|
|
80
|
+
db = dbOrQuery;
|
|
81
|
+
finalQuery = queryOrOptions as PreloadArg<S, TableName, T, RelatedFields, IsOne>;
|
|
82
|
+
options = maybeOptions;
|
|
83
|
+
} else {
|
|
84
|
+
const contextDb = useContext(Sp00kyContext);
|
|
85
|
+
if (!contextDb) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
'createPreload: No db argument provided and no Sp00kyContext found. ' +
|
|
88
|
+
'Either pass a SyncedDb instance or wrap your app in <Sp00kyProvider>.',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
db = contextDb as SyncedDb<S>;
|
|
92
|
+
finalQuery = dbOrQuery;
|
|
93
|
+
options = queryOrOptions as PreloadOptions | undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let prevHash: number | undefined;
|
|
97
|
+
|
|
98
|
+
createEffect(() => {
|
|
99
|
+
if (!(options?.enabled?.() ?? true)) return;
|
|
100
|
+
|
|
101
|
+
const query = typeof finalQuery === 'function' ? finalQuery() : finalQuery;
|
|
102
|
+
if (!query) return;
|
|
103
|
+
|
|
104
|
+
// Dedupe on the query's stable identity hash so a reactive re-run with an
|
|
105
|
+
// unchanged query doesn't refetch (the core also dedupes per session).
|
|
106
|
+
if (query.hash === prevHash) return;
|
|
107
|
+
prevHash = query.hash;
|
|
108
|
+
|
|
109
|
+
void db.getSp00ky().preload(query, { refresh: options?.refresh, staleTime: options?.staleTime });
|
|
110
|
+
});
|
|
111
|
+
}
|
package/src/lib/models.ts
CHANGED