@spooky-sync/client-solid2 0.0.1-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +81 -0
- package/QUICK_START.md +129 -0
- package/README.md +19 -0
- package/dist/index.cjs +1290 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +670 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +670 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1266 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
- package/skills/sp00ky-solid2/SKILL.md +71 -0
- package/src/index.ts +410 -0
- package/src/lib/Blurhash.ts +64 -0
- package/src/lib/BucketImage.ts +170 -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 +397 -0
- package/src/lib/__tests__/rc-semantics.test.ts +393 -0
- package/src/lib/__tests__/use-sync-activity.test.ts +104 -0
- package/src/lib/conflate.ts +74 -0
- package/src/lib/context.ts +28 -0
- package/src/lib/create-preload.ts +116 -0
- package/src/lib/create-query.ts +388 -0
- package/src/lib/create-submission.ts +57 -0
- package/src/lib/from-subscription.ts +32 -0
- package/src/lib/merge-rows.test.ts +92 -0
- package/src/lib/merge-rows.ts +72 -0
- package/src/lib/models.ts +8 -0
- package/src/lib/use-app-release.ts +89 -0
- package/src/lib/use-blurhash.ts +82 -0
- package/src/lib/use-bucket-image.ts +108 -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-activity.ts +99 -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 +20 -0
- package/vitest.config.ts +14 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
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
|
+
import { snapshot } from 'solid-js';
|
|
44
|
+
export { RecordId, Uuid };
|
|
45
|
+
export type { Model, GenericModel, GenericSchema, ModelPayload } from './lib/models';
|
|
46
|
+
export { createQuery, useQuery, type CreateQueryResult, type QueryOptions } from './lib/create-query';
|
|
47
|
+
export { createPreload } from './lib/create-preload';
|
|
48
|
+
export type { PreloadOptions, PreloadRefresh } from '@spooky-sync/core';
|
|
49
|
+
export { useSyncStatus, type UseSyncStatus } from './lib/use-sync-status';
|
|
50
|
+
export {
|
|
51
|
+
useSyncActivity,
|
|
52
|
+
type UseSyncActivity,
|
|
53
|
+
type UseSyncActivityOptions,
|
|
54
|
+
} from './lib/use-sync-activity';
|
|
55
|
+
export type {
|
|
56
|
+
SyncHealth,
|
|
57
|
+
SyncHealthStatus,
|
|
58
|
+
SyncHealthConfig,
|
|
59
|
+
ConnectionState,
|
|
60
|
+
ReconnectConfig,
|
|
61
|
+
} from '@spooky-sync/core';
|
|
62
|
+
export { useStorageStatus, type UseStorageStatus } from './lib/use-storage-status';
|
|
63
|
+
export type { StorageHealth, StorageHealthStatus } from '@spooky-sync/core';
|
|
64
|
+
export { useCrdtField } from './lib/use-crdt-field';
|
|
65
|
+
export { useFeatureFlag, type UseFeatureFlag } from './lib/use-feature-flag';
|
|
66
|
+
export {
|
|
67
|
+
useAppRelease,
|
|
68
|
+
type UseAppRelease,
|
|
69
|
+
type UseAppReleaseOptions,
|
|
70
|
+
} from './lib/use-app-release';
|
|
71
|
+
export { useFileUpload, type FileUploadResult } from './lib/use-file-upload';
|
|
72
|
+
export {
|
|
73
|
+
useDownloadFile,
|
|
74
|
+
type UseDownloadFileOptions,
|
|
75
|
+
type UseDownloadFileResult,
|
|
76
|
+
} from './lib/use-download-file';
|
|
77
|
+
export { useBlurhash, type UseBlurhashResult } from './lib/use-blurhash';
|
|
78
|
+
export {
|
|
79
|
+
useBucketImage,
|
|
80
|
+
type UseBucketImageOptions,
|
|
81
|
+
type UseBucketImageResult,
|
|
82
|
+
} from './lib/use-bucket-image';
|
|
83
|
+
export { Blurhash, type BlurhashProps } from './lib/Blurhash';
|
|
84
|
+
export { BucketImage, type BucketImageProps } from './lib/BucketImage';
|
|
85
|
+
export { Sp00kyProvider, type Sp00kyProviderProps } from './lib/Sp00kyProvider';
|
|
86
|
+
export { useDb, usePendingMutations } from './lib/context';
|
|
87
|
+
export { createSubmission, type Submission } from './lib/create-submission';
|
|
88
|
+
export { conflate } from './lib/conflate';
|
|
89
|
+
export { fromSubscription } from './lib/from-subscription';
|
|
90
|
+
|
|
91
|
+
// Re-export query builder types for convenience
|
|
92
|
+
export type {
|
|
93
|
+
QueryModifier,
|
|
94
|
+
QueryModifierBuilder,
|
|
95
|
+
QueryInfo,
|
|
96
|
+
RelationshipsMetadata,
|
|
97
|
+
RelationshipDefinition,
|
|
98
|
+
InferRelatedModelFromMetadata,
|
|
99
|
+
GetCardinality,
|
|
100
|
+
GetTable,
|
|
101
|
+
TableModel,
|
|
102
|
+
TableNames,
|
|
103
|
+
QueryResult,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export type RelationshipField<
|
|
107
|
+
Schema extends SchemaStructure,
|
|
108
|
+
TableName extends TableNames<Schema>,
|
|
109
|
+
Field extends RelationshipFieldsFromSchema<Schema, TableName>,
|
|
110
|
+
> = GetRelationship<Schema, TableName, Field>;
|
|
111
|
+
|
|
112
|
+
export type RelatedFieldsTableScoped<
|
|
113
|
+
Schema extends SchemaStructure,
|
|
114
|
+
TableName extends TableNames<Schema>,
|
|
115
|
+
RelatedFields extends RelationshipFieldsFromSchema<Schema, TableName> =
|
|
116
|
+
RelationshipFieldsFromSchema<Schema, TableName>,
|
|
117
|
+
> = {
|
|
118
|
+
[K in RelatedFields]: {
|
|
119
|
+
to: RelationshipField<Schema, TableName, K>['to'];
|
|
120
|
+
relatedFields: RelatedFieldsMap;
|
|
121
|
+
cardinality: RelationshipField<Schema, TableName, K>['cardinality'];
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export type InferModel<
|
|
126
|
+
Schema extends SchemaStructure,
|
|
127
|
+
TableName extends TableNames<Schema>,
|
|
128
|
+
RelatedFields extends RelatedFieldsTableScoped<Schema, TableName>,
|
|
129
|
+
> = QueryResult<Schema, TableName, RelatedFields, true>;
|
|
130
|
+
|
|
131
|
+
export type WithRelated<Field extends string, RelatedFields extends RelatedFieldsMap = {}> = {
|
|
132
|
+
[K in Field]: Omit<RelatedFieldMapEntry, 'relatedFields'> & {
|
|
133
|
+
relatedFields: RelatedFields;
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export type WithRelatedMany<Field extends string, RelatedFields extends RelatedFieldsMap = {}> = {
|
|
138
|
+
[K in Field]: {
|
|
139
|
+
to: Field;
|
|
140
|
+
relatedFields: RelatedFields;
|
|
141
|
+
cardinality: 'many';
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* SyncedDb - A thin wrapper around sp00ky-ts for Solid.js integration.
|
|
147
|
+
* Delegates all logic to the underlying sp00ky-ts instance.
|
|
148
|
+
*
|
|
149
|
+
* NOTE: keep in sync with packages/client-solid/src/index.ts (SyncedDb).
|
|
150
|
+
* Copied rather than shared so this package's dependency graph never pulls
|
|
151
|
+
* in solid-js 1.x; fold the two together once client-solid moves to Solid 2.
|
|
152
|
+
* Solid-2-only differences: `delete` accepts 'bound RecordId', and write
|
|
153
|
+
* payloads go through `snapshot()` (see `unproxy` below).
|
|
154
|
+
*/
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Solid 2 stores wrap every object read out of them (query rows, their nested
|
|
158
|
+
* arrays, `createStore` docs) in a Proxy. Those proxies cannot cross
|
|
159
|
+
* `postMessage` (the sqlite and shared-tabs workers): structuredClone throws
|
|
160
|
+
* DataCloneError. `snapshot()` returns the underlying plain value for store
|
|
161
|
+
* proxies and passes anything else through untouched.
|
|
162
|
+
*/
|
|
163
|
+
function unproxy<T>(value: T): T {
|
|
164
|
+
if (value === null || typeof value !== 'object') return value;
|
|
165
|
+
return snapshot(value as object) as T;
|
|
166
|
+
}
|
|
167
|
+
export class SyncedDb<S extends SchemaStructure> {
|
|
168
|
+
private config: SyncedDbConfig<S>;
|
|
169
|
+
private sp00ky: Sp00kyClient<S> | null = null;
|
|
170
|
+
private _initialized = false;
|
|
171
|
+
|
|
172
|
+
constructor(config: SyncedDbConfig<S>) {
|
|
173
|
+
this.config = config;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
public getSp00ky(): Sp00kyClient<S> {
|
|
177
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
178
|
+
return this.sp00ky;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Initialize the sp00ky-ts instance
|
|
183
|
+
*/
|
|
184
|
+
async init(): Promise<void> {
|
|
185
|
+
if (this._initialized) return;
|
|
186
|
+
this.sp00ky = new Sp00kyClient<S>(this.config);
|
|
187
|
+
await this.sp00ky.init();
|
|
188
|
+
this._initialized = true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Tear down the client: leaves the tabs broker, closes the local store and
|
|
193
|
+
* remote socket, and frees the wasm circuit. Without this a remounted provider
|
|
194
|
+
* (or an HMR reload) strands a whole client, and the abandoned wasm heaps stay
|
|
195
|
+
* resident because V8 cannot see how much wasm memory a dropped wrapper holds.
|
|
196
|
+
*/
|
|
197
|
+
async close(): Promise<void> {
|
|
198
|
+
const instance = this.sp00ky;
|
|
199
|
+
this.sp00ky = null;
|
|
200
|
+
this._initialized = false;
|
|
201
|
+
if (instance) await instance.close();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Create a new record in the database
|
|
206
|
+
*/
|
|
207
|
+
async create(id: string, payload: Record<string, unknown>): Promise<void> {
|
|
208
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
209
|
+
await this.sp00ky.create(id, unproxy(payload) as Record<string, unknown>);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Update an existing record in the database
|
|
214
|
+
*/
|
|
215
|
+
async update<TName extends TableNames<S>>(
|
|
216
|
+
tableName: TName,
|
|
217
|
+
recordId: string,
|
|
218
|
+
payload: Partial<TableModel<GetTable<S, TName>>>,
|
|
219
|
+
options?: UpdateOptions
|
|
220
|
+
): Promise<void> {
|
|
221
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
222
|
+
await this.sp00ky.update(
|
|
223
|
+
tableName as string,
|
|
224
|
+
recordId,
|
|
225
|
+
unproxy(payload) as Record<string, unknown>,
|
|
226
|
+
options
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Delete an existing record in the database
|
|
232
|
+
*/
|
|
233
|
+
async delete<TName extends TableNames<S>>(
|
|
234
|
+
tableName: TName,
|
|
235
|
+
selector: string | RecordId | InnerQuery<GetTable<S, TName>, boolean>
|
|
236
|
+
): Promise<void> {
|
|
237
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
238
|
+
// Accept a `"table:id"` string OR a RecordId — live-query rows carry their
|
|
239
|
+
// `id` as a RecordId, so callers can pass `db.delete('game', row.id)`
|
|
240
|
+
// directly. Build the canonical string from the raw id part (not
|
|
241
|
+
// `RecordId.toString()`, which escapes special chars) so it round-trips
|
|
242
|
+
// through the engine's `parseRecordIdString`. InnerQuery selectors are not
|
|
243
|
+
// supported yet. A RecordId read out of a Solid 2 query row is a store
|
|
244
|
+
// proxy that hides its prototype (no instanceof, no constructor.name, on
|
|
245
|
+
// rc.1), so unwrap it first; cross-package RecordId instances then match
|
|
246
|
+
// by constructor name ('bound RecordId' covers rc.0 proxies).
|
|
247
|
+
const raw = unproxy(selector);
|
|
248
|
+
const ctorName = (raw as any)?.constructor?.name;
|
|
249
|
+
const isRecordId =
|
|
250
|
+
raw instanceof RecordId || ctorName === 'RecordId' || ctorName === 'bound RecordId';
|
|
251
|
+
let id: string;
|
|
252
|
+
if (typeof raw === 'string') {
|
|
253
|
+
id = raw;
|
|
254
|
+
} else if (isRecordId) {
|
|
255
|
+
id = `${tableName as string}:${(raw as RecordId).id}`;
|
|
256
|
+
} else {
|
|
257
|
+
throw new Error('Only string ID or RecordId selectors are supported currently with core');
|
|
258
|
+
}
|
|
259
|
+
await this.sp00ky.delete(tableName as string, id);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Preload/prewarm a built query into the local cache without registering a
|
|
264
|
+
* live view. Fetches once and stores the rows (+ embedded related children)
|
|
265
|
+
* locally so a later `createQuery` for the same data paints instantly. Best-effort.
|
|
266
|
+
*/
|
|
267
|
+
public async preload(
|
|
268
|
+
finalQuery: FinalQuery<S, any, any, any, any, Sp00kyQueryResultPromise>,
|
|
269
|
+
options?: PreloadOptions
|
|
270
|
+
): Promise<void> {
|
|
271
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
272
|
+
await this.sp00ky.preload(finalQuery, options);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Query data from the database
|
|
277
|
+
*/
|
|
278
|
+
public query<TName extends TableNames<S>>(
|
|
279
|
+
table: TName
|
|
280
|
+
): QueryBuilder<S, TName, Sp00kyQueryResultPromise, {}, false> {
|
|
281
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
282
|
+
return this.sp00ky.query(table, {});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Run a backend operation
|
|
287
|
+
*/
|
|
288
|
+
public async run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
|
|
289
|
+
backend: B,
|
|
290
|
+
path: R,
|
|
291
|
+
payload: RoutePayload<S, B, R>,
|
|
292
|
+
options?: RunOptions
|
|
293
|
+
): Promise<void> {
|
|
294
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
295
|
+
await this.sp00ky.run(backend, path, unproxy(payload), options);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Sign out, clear session and local storage
|
|
300
|
+
*/
|
|
301
|
+
public async signOut(): Promise<void> {
|
|
302
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
303
|
+
await this.sp00ky.auth.signOut();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Execute a function with direct access to the remote database connection
|
|
308
|
+
*/
|
|
309
|
+
public async useRemote<T>(fn: (db: Surreal) => T | Promise<T>): Promise<T> {
|
|
310
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
311
|
+
return await this.sp00ky.useRemote(fn);
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Access the remote database service directly
|
|
315
|
+
*/
|
|
316
|
+
get remote(): Sp00kyClient<S>['remoteClient'] {
|
|
317
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
318
|
+
return this.sp00ky.remoteClient;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Access the local database service directly
|
|
323
|
+
*/
|
|
324
|
+
get local(): Sp00kyClient<S>['localClient'] {
|
|
325
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
326
|
+
return this.sp00ky.localClient;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Access the auth service
|
|
331
|
+
*/
|
|
332
|
+
get auth(): AuthService<S> {
|
|
333
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
334
|
+
return this.sp00ky.auth;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
get pendingMutationCount(): number {
|
|
338
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
339
|
+
return this.sp00ky.pendingMutationCount;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Diagnostic — see `Sp00kyClient.liveRetryCount`. */
|
|
343
|
+
get liveRetryCount(): number {
|
|
344
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
345
|
+
return this.sp00ky.liveRetryCount;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
subscribeToPendingMutations(cb: (count: number) => void): () => void {
|
|
349
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
350
|
+
return this.sp00ky.subscribeToPendingMutations(cb);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Queries mid-fetch right now. See {@link useSyncActivity}. */
|
|
354
|
+
get fetchingQueryCount(): number {
|
|
355
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
356
|
+
return this.sp00ky.fetchingQueryCount;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Observe how many queries are fetching from the server. Fires immediately
|
|
361
|
+
* and on every change. Prefer the `useSyncActivity` hook in components.
|
|
362
|
+
*/
|
|
363
|
+
subscribeToFetchActivity(cb: (fetching: number) => void): () => void {
|
|
364
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
365
|
+
return this.sp00ky.subscribeToFetchActivity(cb);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Current sync-health snapshot. See {@link useSyncStatus}. */
|
|
369
|
+
get syncHealth(): SyncHealth {
|
|
370
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
371
|
+
return this.sp00ky.syncHealth;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Observe sync health. Fires immediately with the current status and again
|
|
376
|
+
* on every healthy↔degraded transition. Prefer the `useSyncStatus` hook in
|
|
377
|
+
* components; this is the imperative escape hatch.
|
|
378
|
+
*/
|
|
379
|
+
subscribeToSyncHealth(cb: (health: SyncHealth) => void): () => void {
|
|
380
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
381
|
+
return this.sp00ky.subscribeToSyncHealth(cb);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Current local-store durability snapshot. See {@link useStorageStatus}. */
|
|
385
|
+
get storageHealth(): StorageHealth {
|
|
386
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
387
|
+
return this.sp00ky.storageHealth;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Observe local-store durability. Fires immediately with the current snapshot
|
|
392
|
+
* and again on change. Prefer the `useStorageStatus` hook in components; this
|
|
393
|
+
* is the imperative escape hatch.
|
|
394
|
+
*/
|
|
395
|
+
subscribeToStorageHealth(cb: (health: StorageHealth) => void): () => void {
|
|
396
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
397
|
+
return this.sp00ky.subscribeToStorageHealth(cb);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
bucket<B extends BucketNames<S>>(name: B): BucketHandle {
|
|
401
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
402
|
+
return this.sp00ky.bucket(name);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
getBucketConfig(name: string): BucketDefinitionSchema | undefined {
|
|
406
|
+
return this.config.schema.buckets?.find((b) => b.name === name);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export * from './types';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createEffect, type Element } from 'solid-js';
|
|
2
|
+
import { decodeBlurhash } from '@spooky-sync/core';
|
|
3
|
+
|
|
4
|
+
export interface BlurhashProps {
|
|
5
|
+
/** The blurhash string. Nullish paints nothing (transparent canvas). */
|
|
6
|
+
hash: string | null | undefined;
|
|
7
|
+
/** Decode resolution. 32x32 is plenty: blurhash carries at most 9x9 DCT
|
|
8
|
+
* components, the canvas is meant to be CSS-scaled to fill. */
|
|
9
|
+
width?: number;
|
|
10
|
+
height?: number;
|
|
11
|
+
/** Contrast punch, see the blurhash reference decoder. Defaults to 1. */
|
|
12
|
+
punch?: number;
|
|
13
|
+
class?: string;
|
|
14
|
+
style?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A blurhash painted onto a canvas, once per hash change. Size the canvas via
|
|
19
|
+
* `class`/`style` (e.g. `absolute inset-0 w-full h-full`); the internal decode
|
|
20
|
+
* resolution stays tiny regardless of the displayed size.
|
|
21
|
+
*/
|
|
22
|
+
export function Blurhash(props: BlurhashProps): Element {
|
|
23
|
+
if (typeof document === 'undefined') return null;
|
|
24
|
+
const canvas = document.createElement('canvas');
|
|
25
|
+
|
|
26
|
+
createEffect(
|
|
27
|
+
() => props.class ?? '',
|
|
28
|
+
(className) => {
|
|
29
|
+
canvas.className = className;
|
|
30
|
+
}
|
|
31
|
+
);
|
|
32
|
+
createEffect(
|
|
33
|
+
() => props.style ?? '',
|
|
34
|
+
(style) => {
|
|
35
|
+
canvas.style.cssText = style;
|
|
36
|
+
}
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
createEffect(
|
|
40
|
+
() => ({
|
|
41
|
+
width: props.width ?? 32,
|
|
42
|
+
height: props.height ?? 32,
|
|
43
|
+
hash: props.hash,
|
|
44
|
+
punch: props.punch ?? 1,
|
|
45
|
+
}),
|
|
46
|
+
({ width, height, hash, punch }) => {
|
|
47
|
+
canvas.width = width;
|
|
48
|
+
canvas.height = height;
|
|
49
|
+
if (!hash) return;
|
|
50
|
+
try {
|
|
51
|
+
const pixels = decodeBlurhash(hash, width, height, punch);
|
|
52
|
+
const ctx = canvas.getContext('2d');
|
|
53
|
+
if (!ctx) return;
|
|
54
|
+
const imageData = ctx.createImageData(width, height);
|
|
55
|
+
imageData.data.set(pixels);
|
|
56
|
+
ctx.putImageData(imageData, 0, 0);
|
|
57
|
+
} catch {
|
|
58
|
+
// An invalid hash paints nothing; the layer below stays visible.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return canvas as unknown as Element;
|
|
64
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { createSignal, createEffect, onSettled, children, type Element } from 'solid-js';
|
|
2
|
+
import type { BucketNames, SchemaStructure } from '@spooky-sync/query-builder';
|
|
3
|
+
import { useBucketImage, type UseBucketImageOptions } from './use-bucket-image';
|
|
4
|
+
import { Blurhash } from './Blurhash';
|
|
5
|
+
|
|
6
|
+
export interface BucketImageProps {
|
|
7
|
+
/** Bucket name from the schema. */
|
|
8
|
+
bucket: string;
|
|
9
|
+
/** Path within the bucket. Nullish renders only the fallback layers. */
|
|
10
|
+
path: string | null | undefined;
|
|
11
|
+
alt?: string;
|
|
12
|
+
/** Classes for the container element (sizing/positioning). */
|
|
13
|
+
class?: string;
|
|
14
|
+
/** Classes for the inner `<img>` (the layout styles are inline). */
|
|
15
|
+
imgClass?: string;
|
|
16
|
+
/** `object-fit` for the image. Defaults to `cover`. */
|
|
17
|
+
fit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down';
|
|
18
|
+
/**
|
|
19
|
+
* Bottom placeholder layer (your own plate/skeleton), shown until the image
|
|
20
|
+
* settles. The blurhash layer paints on top of it once the sidecar resolves.
|
|
21
|
+
*/
|
|
22
|
+
fallback?: Element;
|
|
23
|
+
/** Crossfade duration in ms. Defaults to 300. */
|
|
24
|
+
transition?: number;
|
|
25
|
+
/** Crossfade easing. Defaults to an ease-out-expo curve. */
|
|
26
|
+
easing?: string;
|
|
27
|
+
/** Resolve the blurhash sidecar. Defaults to true. */
|
|
28
|
+
blurhash?: boolean;
|
|
29
|
+
/** Download tuning, forwarded to the underlying `useDownloadFile`. */
|
|
30
|
+
options?: UseBucketImageOptions;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const LAYER_STYLE = 'position:absolute;inset:0;width:100%;height:100%;';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A bucket image that never pops in: it layers (bottom to top) your `fallback`
|
|
37
|
+
* plate, the automatically stored blurhash, and the real image, which stays
|
|
38
|
+
* transparent until the bitmap is DECODED and then crossfades over the
|
|
39
|
+
* placeholders. Placeholder layers unmount once the fade settles. Respects
|
|
40
|
+
* prefers-reduced-motion (instant swap). The container is made
|
|
41
|
+
* `position: relative` unless your `class` positions it already.
|
|
42
|
+
*
|
|
43
|
+
* ```tsx
|
|
44
|
+
* <BucketImage bucket="covers" path={row.cover_key} class="absolute inset-0"
|
|
45
|
+
* fallback={<MyPlate />} alt="" />
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function BucketImage(props: BucketImageProps): Element {
|
|
49
|
+
if (typeof document === 'undefined') return null;
|
|
50
|
+
|
|
51
|
+
const image = useBucketImage(
|
|
52
|
+
props.bucket as BucketNames<SchemaStructure>,
|
|
53
|
+
() => props.path,
|
|
54
|
+
{ ...props.options, blurhash: props.blurhash !== false }
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const reducedMotion =
|
|
58
|
+
typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
59
|
+
|
|
60
|
+
const root = document.createElement('div');
|
|
61
|
+
createEffect(
|
|
62
|
+
() => props.class ?? '',
|
|
63
|
+
(className) => {
|
|
64
|
+
root.className = className;
|
|
65
|
+
}
|
|
66
|
+
);
|
|
67
|
+
// Layers are absolutely positioned; give them an anchor without stomping on
|
|
68
|
+
// a caller class that already positions the container (inline would win).
|
|
69
|
+
onSettled(() => {
|
|
70
|
+
if (getComputedStyle(root).position === 'static') root.style.position = 'relative';
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const placeholder = document.createElement('div');
|
|
74
|
+
placeholder.style.cssText = LAYER_STYLE;
|
|
75
|
+
// Solid hands JSX props over as lazy thunks; `children` resolves them (and
|
|
76
|
+
// nested arrays/functions) to real nodes and keeps them alive reactively.
|
|
77
|
+
const fallbackHolder = document.createElement('div');
|
|
78
|
+
fallbackHolder.style.cssText = LAYER_STYLE;
|
|
79
|
+
placeholder.append(fallbackHolder);
|
|
80
|
+
const resolvedFallback = children(() => props.fallback);
|
|
81
|
+
createEffect(
|
|
82
|
+
() => resolvedFallback.toArray().filter((node) => node instanceof Node) as Node[],
|
|
83
|
+
(nodes) => {
|
|
84
|
+
fallbackHolder.replaceChildren(...nodes);
|
|
85
|
+
}
|
|
86
|
+
);
|
|
87
|
+
const hashCanvas = Blurhash({
|
|
88
|
+
get hash() {
|
|
89
|
+
return image.blurhash();
|
|
90
|
+
},
|
|
91
|
+
style: LAYER_STYLE,
|
|
92
|
+
});
|
|
93
|
+
if (hashCanvas instanceof Node) placeholder.append(hashCanvas);
|
|
94
|
+
|
|
95
|
+
const img = document.createElement('img');
|
|
96
|
+
img.decoding = 'async';
|
|
97
|
+
img.style.cssText = `${LAYER_STYLE}opacity:0;`;
|
|
98
|
+
createEffect(
|
|
99
|
+
() => props.imgClass ?? '',
|
|
100
|
+
(className) => {
|
|
101
|
+
img.className = className;
|
|
102
|
+
}
|
|
103
|
+
);
|
|
104
|
+
createEffect(
|
|
105
|
+
() => props.fit ?? 'cover',
|
|
106
|
+
(fit) => {
|
|
107
|
+
img.style.objectFit = fit;
|
|
108
|
+
}
|
|
109
|
+
);
|
|
110
|
+
createEffect(
|
|
111
|
+
() => props.alt ?? '',
|
|
112
|
+
(alt) => {
|
|
113
|
+
img.alt = alt;
|
|
114
|
+
}
|
|
115
|
+
);
|
|
116
|
+
createEffect(
|
|
117
|
+
() =>
|
|
118
|
+
reducedMotion
|
|
119
|
+
? 'none'
|
|
120
|
+
: `opacity ${props.transition ?? 300}ms ${props.easing ?? 'cubic-bezier(0.16, 1, 0.3, 1)'}`,
|
|
121
|
+
(transition) => {
|
|
122
|
+
img.style.transition = transition;
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
createEffect(
|
|
126
|
+
() => image.url(),
|
|
127
|
+
(url) => {
|
|
128
|
+
if (!url) {
|
|
129
|
+
img.removeAttribute('src');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
img.src = url;
|
|
133
|
+
image.gate(img);
|
|
134
|
+
}
|
|
135
|
+
);
|
|
136
|
+
createEffect(
|
|
137
|
+
() => image.ready(),
|
|
138
|
+
(ready) => {
|
|
139
|
+
img.style.opacity = ready ? '1' : '0';
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// Placeholders leave the DOM once the fade is over (a shelf of covers should
|
|
144
|
+
// not composite three layers each forever) and come back when the path
|
|
145
|
+
// changes mid-life (`ready` re-arms via the hook). `settled` is written from
|
|
146
|
+
// the apply phase and a timer, outside any tracking scope.
|
|
147
|
+
const [settled, setSettled] = createSignal(false, { ownedWrite: true });
|
|
148
|
+
createEffect(
|
|
149
|
+
() => ({ ready: image.ready(), transition: props.transition ?? 300 }),
|
|
150
|
+
({ ready, transition }) => {
|
|
151
|
+
if (!ready) {
|
|
152
|
+
setSettled(false);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const wait = (reducedMotion ? 0 : transition) + 120;
|
|
156
|
+
const timer = setTimeout(() => setSettled(true), wait);
|
|
157
|
+
return () => clearTimeout(timer);
|
|
158
|
+
}
|
|
159
|
+
);
|
|
160
|
+
createEffect(
|
|
161
|
+
() => settled(),
|
|
162
|
+
(isSettled) => {
|
|
163
|
+
if (isSettled) placeholder.remove();
|
|
164
|
+
else if (!placeholder.isConnected) root.insertBefore(placeholder, img);
|
|
165
|
+
}
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
root.append(placeholder, img);
|
|
169
|
+
return root as unknown as Element;
|
|
170
|
+
}
|