@spooky-sync/client-solid 0.0.1-canary.17 → 0.0.1-canary.170
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 +416 -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 +411 -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 +143 -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
package/dist/index.cjs
CHANGED
|
@@ -2,12 +2,13 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
|
2
2
|
let _spooky_sync_core = require("@spooky-sync/core");
|
|
3
3
|
let surrealdb = require("surrealdb");
|
|
4
4
|
let solid_js = require("solid-js");
|
|
5
|
+
let solid_js_store = require("solid-js/store");
|
|
5
6
|
|
|
6
7
|
//#region src/lib/context.ts
|
|
7
|
-
const
|
|
8
|
+
const Sp00kyContext = (0, solid_js.createContext)();
|
|
8
9
|
function useDb() {
|
|
9
|
-
const db = (0, solid_js.useContext)(
|
|
10
|
-
if (!db) throw new Error("useDb must be used within a <
|
|
10
|
+
const db = (0, solid_js.useContext)(Sp00kyContext);
|
|
11
|
+
if (!db) throw new Error("useDb must be used within a <Sp00kyProvider>. Wrap your app in <Sp00kyProvider config={...}>.");
|
|
11
12
|
return db;
|
|
12
13
|
}
|
|
13
14
|
|
|
@@ -22,30 +23,56 @@ function useQuery(dbOrQuery, queryOrOptions, maybeOptions) {
|
|
|
22
23
|
finalQuery = queryOrOptions;
|
|
23
24
|
options = maybeOptions;
|
|
24
25
|
} else {
|
|
25
|
-
const contextDb = (0, solid_js.useContext)(
|
|
26
|
-
if (!contextDb) throw new Error("useQuery: No db argument provided and no
|
|
26
|
+
const contextDb = (0, solid_js.useContext)(Sp00kyContext);
|
|
27
|
+
if (!contextDb) throw new Error("useQuery: No db argument provided and no Sp00kyContext found. Either pass a SyncedDb instance or wrap your app in <Sp00kyProvider>.");
|
|
27
28
|
db = contextDb;
|
|
28
29
|
finalQuery = dbOrQuery;
|
|
29
30
|
options = queryOrOptions;
|
|
30
31
|
}
|
|
31
|
-
const [data, setData] = (0, solid_js.createSignal)(void 0);
|
|
32
32
|
const [error, setError] = (0, solid_js.createSignal)(void 0);
|
|
33
33
|
const [isFetched, setIsFetched] = (0, solid_js.createSignal)(false);
|
|
34
|
-
const [
|
|
34
|
+
const [isFetching, setIsFetching] = (0, solid_js.createSignal)(false);
|
|
35
|
+
const [state, setState] = (0, solid_js_store.createStore)({ value: void 0 });
|
|
36
|
+
const [version, setVersion] = (0, solid_js.createSignal)(0);
|
|
37
|
+
const data = () => {
|
|
38
|
+
version();
|
|
39
|
+
return state.value;
|
|
40
|
+
};
|
|
35
41
|
let prevQueryString;
|
|
36
|
-
|
|
37
|
-
|
|
42
|
+
let runId = 0;
|
|
43
|
+
let activeUnsub;
|
|
44
|
+
let activeHash;
|
|
45
|
+
const teardownActive = () => {
|
|
46
|
+
activeUnsub?.();
|
|
47
|
+
activeUnsub = void 0;
|
|
48
|
+
};
|
|
49
|
+
const sp00ky = db.getSp00ky();
|
|
50
|
+
const initQuery = async (query, myRun) => {
|
|
38
51
|
const { hash } = await query.run();
|
|
52
|
+
if (myRun !== runId) return;
|
|
53
|
+
activeHash = hash;
|
|
39
54
|
setError(void 0);
|
|
40
55
|
let isFirstCall = true;
|
|
41
|
-
const unsub = await
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
56
|
+
const unsub = await sp00ky.subscribe(hash, (e) => {
|
|
57
|
+
const queryData = query.isOne ? e[0] : e;
|
|
58
|
+
const reconcileStart = performance.now();
|
|
59
|
+
setState("value", (0, solid_js_store.reconcile)(queryData, { key: "id" }));
|
|
60
|
+
setVersion((v) => v + 1);
|
|
61
|
+
sp00ky.reportFrontendTiming(hash, performance.now() - reconcileStart);
|
|
62
|
+
const hasData = query.isOne ? queryData !== null && queryData !== void 0 : e.length > 0;
|
|
45
63
|
if (!isFirstCall || hasData) setIsFetched(true);
|
|
46
64
|
isFirstCall = false;
|
|
47
65
|
}, { immediate: true });
|
|
48
|
-
|
|
66
|
+
const unsubStatus = sp00ky.subscribeQueryStatus(hash, (status) => setIsFetching(status === "fetching"), { immediate: true });
|
|
67
|
+
const teardown = () => {
|
|
68
|
+
unsub();
|
|
69
|
+
unsubStatus();
|
|
70
|
+
};
|
|
71
|
+
if (myRun !== runId) {
|
|
72
|
+
teardown();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
activeUnsub = teardown;
|
|
49
76
|
};
|
|
50
77
|
(0, solid_js.createEffect)(() => {
|
|
51
78
|
if (!(options?.enabled?.() ?? true)) {
|
|
@@ -54,22 +81,236 @@ function useQuery(dbOrQuery, queryOrOptions, maybeOptions) {
|
|
|
54
81
|
}
|
|
55
82
|
const query = typeof finalQuery === "function" ? finalQuery() : finalQuery;
|
|
56
83
|
if (!query) return;
|
|
57
|
-
const queryString =
|
|
84
|
+
const queryString = String(query.hash);
|
|
58
85
|
if (queryString === prevQueryString) return;
|
|
59
86
|
prevQueryString = queryString;
|
|
87
|
+
const myRun = ++runId;
|
|
88
|
+
teardownActive();
|
|
60
89
|
setIsFetched(false);
|
|
61
|
-
initQuery(query);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
90
|
+
initQuery(query, myRun);
|
|
91
|
+
});
|
|
92
|
+
(0, solid_js.onCleanup)(() => {
|
|
93
|
+
runId++;
|
|
94
|
+
teardownActive();
|
|
95
|
+
if (options?.deregisterOnCleanup && activeHash) sp00ky.deregisterQuery(activeHash);
|
|
65
96
|
});
|
|
66
97
|
const isLoading = () => {
|
|
67
98
|
return !isFetched() && error() === void 0;
|
|
68
99
|
};
|
|
100
|
+
const isSettled = () => isFetched() && !isFetching();
|
|
69
101
|
return {
|
|
70
102
|
data,
|
|
71
103
|
error,
|
|
72
|
-
isLoading
|
|
104
|
+
isLoading,
|
|
105
|
+
isFetching,
|
|
106
|
+
isSettled
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/lib/create-preload.ts
|
|
112
|
+
/**
|
|
113
|
+
* Reactive, fire-and-forget prewarm. Resolves the query (calling it if it's a
|
|
114
|
+
* function so it tracks reactive deps), dedupes on the query's stable identity
|
|
115
|
+
* hash, and warms it into the local cache via `db.preload`. No subscription and
|
|
116
|
+
* no cleanup: preload registers nothing that needs tearing down.
|
|
117
|
+
*
|
|
118
|
+
* Typical use: inside a list row, preload the detail query the user is likely
|
|
119
|
+
* to open next, so navigation paints from cache instead of the network.
|
|
120
|
+
*/
|
|
121
|
+
function createPreload(dbOrQuery, queryOrOptions, maybeOptions) {
|
|
122
|
+
let db;
|
|
123
|
+
let finalQuery;
|
|
124
|
+
let options;
|
|
125
|
+
if (dbOrQuery instanceof SyncedDb) {
|
|
126
|
+
db = dbOrQuery;
|
|
127
|
+
finalQuery = queryOrOptions;
|
|
128
|
+
options = maybeOptions;
|
|
129
|
+
} else {
|
|
130
|
+
const contextDb = (0, solid_js.useContext)(Sp00kyContext);
|
|
131
|
+
if (!contextDb) throw new Error("createPreload: No db argument provided and no Sp00kyContext found. Either pass a SyncedDb instance or wrap your app in <Sp00kyProvider>.");
|
|
132
|
+
db = contextDb;
|
|
133
|
+
finalQuery = dbOrQuery;
|
|
134
|
+
options = queryOrOptions;
|
|
135
|
+
}
|
|
136
|
+
let prevHash;
|
|
137
|
+
(0, solid_js.createEffect)(() => {
|
|
138
|
+
if (!(options?.enabled?.() ?? true)) return;
|
|
139
|
+
const query = typeof finalQuery === "function" ? finalQuery() : finalQuery;
|
|
140
|
+
if (!query) return;
|
|
141
|
+
if (query.hash === prevHash) return;
|
|
142
|
+
prevHash = query.hash;
|
|
143
|
+
db.getSp00ky().preload(query, {
|
|
144
|
+
refresh: options?.refresh,
|
|
145
|
+
staleTime: options?.staleTime
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/lib/use-sync-status.ts
|
|
152
|
+
/**
|
|
153
|
+
* Observe sync health for a "can't reach the server" banner / indicator.
|
|
154
|
+
*
|
|
155
|
+
* Backed by `db.subscribeToSyncHealth`. Individual sync failures (a transient
|
|
156
|
+
* remote 500 on query registration, a dropped socket) are absorbed by the
|
|
157
|
+
* retry and never flip this; `isDegraded()` only goes true once failures
|
|
158
|
+
* persist for the configured number of consecutive rounds (sp00ky core config
|
|
159
|
+
* `syncHealth.degradeAfterConsecutiveFailures`, default 3), and flips back on
|
|
160
|
+
* the next successful round. Must be used within a `<Sp00kyProvider>`.
|
|
161
|
+
*/
|
|
162
|
+
function useSyncStatus() {
|
|
163
|
+
const db = useDb();
|
|
164
|
+
const [health, setHealth] = (0, solid_js.createSignal)(db.syncHealth);
|
|
165
|
+
(0, solid_js.onCleanup)(db.subscribeToSyncHealth(setHealth));
|
|
166
|
+
return {
|
|
167
|
+
health,
|
|
168
|
+
status: () => health().status,
|
|
169
|
+
isHealthy: () => health().status === "healthy",
|
|
170
|
+
isDegraded: () => health().status === "degraded",
|
|
171
|
+
everConnected: () => health().everConnected,
|
|
172
|
+
isOffline: () => health().status === "degraded" && health().everConnected,
|
|
173
|
+
connection: () => health().connection,
|
|
174
|
+
isReconnecting: () => health().connection === "reconnecting"
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
//#endregion
|
|
179
|
+
//#region src/lib/use-storage-status.ts
|
|
180
|
+
/**
|
|
181
|
+
* Observe how durable the LOCAL cache is, for a "no local storage" warning.
|
|
182
|
+
*
|
|
183
|
+
* Under `localEngine: 'sqlite'` with `store: 'indexeddb'` the durable store is
|
|
184
|
+
* the OPFS SAHPool VFS, and only ONE client per bucket can hold it open: a
|
|
185
|
+
* second tab of the same app cannot get it and runs in memory instead (the
|
|
186
|
+
* engine retries first, so a closing tab's lock is usually waited out). Must be
|
|
187
|
+
* used within a `<Sp00kyProvider>`.
|
|
188
|
+
*/
|
|
189
|
+
function useStorageStatus() {
|
|
190
|
+
const db = useDb();
|
|
191
|
+
const [health, setHealth] = (0, solid_js.createSignal)(db.storageHealth);
|
|
192
|
+
(0, solid_js.onCleanup)(db.subscribeToStorageHealth(setHealth));
|
|
193
|
+
return {
|
|
194
|
+
health,
|
|
195
|
+
status: () => health().status,
|
|
196
|
+
isPersistent: () => health().status === "persistent",
|
|
197
|
+
isMemoryFallback: () => health().fallback
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
//#endregion
|
|
202
|
+
//#region src/lib/use-crdt-field.ts
|
|
203
|
+
function useCrdtField(table, recordId, field, fallbackText) {
|
|
204
|
+
const db = (0, solid_js.useContext)(Sp00kyContext);
|
|
205
|
+
if (!db) throw new Error("useCrdtField must be used within a <Sp00kyProvider>");
|
|
206
|
+
const [crdtField, setCrdtField] = (0, solid_js.createSignal)(null);
|
|
207
|
+
let currentId;
|
|
208
|
+
let initialized = false;
|
|
209
|
+
(0, solid_js.createEffect)(() => {
|
|
210
|
+
const id = recordId();
|
|
211
|
+
if (initialized && id === currentId) return;
|
|
212
|
+
if (currentId && crdtField()) {
|
|
213
|
+
db.getSp00ky().closeCrdtField(table, currentId, field);
|
|
214
|
+
setCrdtField(null);
|
|
215
|
+
}
|
|
216
|
+
currentId = id;
|
|
217
|
+
initialized = true;
|
|
218
|
+
if (!id) return;
|
|
219
|
+
const sp00ky = db.getSp00ky();
|
|
220
|
+
const text = fallbackText?.();
|
|
221
|
+
sp00ky.openCrdtField(table, id, field, text).then((cf) => {
|
|
222
|
+
if (currentId === id) setCrdtField(cf);
|
|
223
|
+
}).catch((err) => {
|
|
224
|
+
console.error(`[useCrdtField] Failed to open CRDT field ${table}.${field} on ${id}:`, err);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
(0, solid_js.onCleanup)(() => {
|
|
228
|
+
if (currentId && crdtField()) {
|
|
229
|
+
db.getSp00ky().closeCrdtField(table, currentId, field);
|
|
230
|
+
setCrdtField(null);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
return crdtField;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
//#endregion
|
|
237
|
+
//#region src/lib/use-feature-flag.ts
|
|
238
|
+
/**
|
|
239
|
+
* Subscribe to a feature flag for the currently authenticated user.
|
|
240
|
+
*
|
|
241
|
+
* Returns three Solid accessors that update reactively whenever the
|
|
242
|
+
* server-materialized assignment in `_00_user_feature` changes. Backed by
|
|
243
|
+
* the same SSP + sync pipeline that powers `useQuery`, so toggling a flag
|
|
244
|
+
* via `spky flag enable <key>` propagates to the UI without a refresh.
|
|
245
|
+
*
|
|
246
|
+
* `enabled()` is `true` when the resolved variant exists and is not 'off'.
|
|
247
|
+
* For multi-variant flags, prefer `variant()` directly.
|
|
248
|
+
*/
|
|
249
|
+
function useFeatureFlag(key, options) {
|
|
250
|
+
const handle = useDb().getSp00ky().feature(key, options);
|
|
251
|
+
const [variant, setVariant] = (0, solid_js.createSignal)(handle.variant());
|
|
252
|
+
const [payload, setPayload] = (0, solid_js.createSignal)(handle.payload());
|
|
253
|
+
const unsub = handle.subscribe((s) => {
|
|
254
|
+
setVariant(s.variant ?? options?.fallback);
|
|
255
|
+
setPayload(s.payload);
|
|
256
|
+
});
|
|
257
|
+
(0, solid_js.onCleanup)(() => {
|
|
258
|
+
unsub();
|
|
259
|
+
handle.close();
|
|
260
|
+
});
|
|
261
|
+
return {
|
|
262
|
+
variant,
|
|
263
|
+
payload,
|
|
264
|
+
enabled: () => {
|
|
265
|
+
const v = variant();
|
|
266
|
+
return v !== void 0 && v !== "off";
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
//#endregion
|
|
272
|
+
//#region src/lib/use-app-release.ts
|
|
273
|
+
async function reloadForSnapshot(snapshot) {
|
|
274
|
+
if (typeof window === "undefined") return;
|
|
275
|
+
if (snapshot.cacheBust) try {
|
|
276
|
+
if (window.caches) {
|
|
277
|
+
const keys = await window.caches.keys();
|
|
278
|
+
await Promise.all(keys.map((k) => window.caches.delete(k)));
|
|
279
|
+
}
|
|
280
|
+
if (navigator.serviceWorker) {
|
|
281
|
+
const regs = await navigator.serviceWorker.getRegistrations();
|
|
282
|
+
for (const r of regs) r.update().catch(() => {});
|
|
283
|
+
}
|
|
284
|
+
window.location.href = window.location.pathname + "?cb=" + Date.now();
|
|
285
|
+
return;
|
|
286
|
+
} catch {}
|
|
287
|
+
window.location.reload();
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Observe the app's announced release (`_00_app_release:<app>`, written by
|
|
291
|
+
* `spky deploy` / `spky release`) and compare it against the running build.
|
|
292
|
+
*
|
|
293
|
+
* Typical use: mount a small "new version available — Reload" notification
|
|
294
|
+
* gated on `updateAvailable()`, auto-invoking `reload()` when `mandatory()`
|
|
295
|
+
* (guard the auto path against reload loops with a per-version marker, since
|
|
296
|
+
* a client can reload while the deploy is still rolling out and land on the
|
|
297
|
+
* old bundle again).
|
|
298
|
+
*/
|
|
299
|
+
function useAppRelease(options) {
|
|
300
|
+
const handle = useDb().getSp00ky().appRelease(options.app, { ttl: options.ttl });
|
|
301
|
+
const [snapshot, setSnapshot] = (0, solid_js.createSignal)(handle.snapshot());
|
|
302
|
+
const unsub = handle.subscribe(setSnapshot);
|
|
303
|
+
(0, solid_js.onCleanup)(() => {
|
|
304
|
+
unsub();
|
|
305
|
+
handle.close();
|
|
306
|
+
});
|
|
307
|
+
const updateAvailable = () => (0, _spooky_sync_core.semverGt)(snapshot().version, options.currentVersion);
|
|
308
|
+
return {
|
|
309
|
+
latestVersion: () => snapshot().version,
|
|
310
|
+
updateAvailable,
|
|
311
|
+
mandatory: () => updateAvailable() && snapshot().mandatory,
|
|
312
|
+
cacheBust: () => snapshot().cacheBust,
|
|
313
|
+
reload: () => reloadForSnapshot(snapshot())
|
|
73
314
|
};
|
|
74
315
|
}
|
|
75
316
|
|
|
@@ -95,7 +336,7 @@ function useFileUpload(dbOrBucketName, maybeBucketName) {
|
|
|
95
336
|
const validate = (file) => {
|
|
96
337
|
const config = db.getBucketConfig(bucketName);
|
|
97
338
|
if (!config) return;
|
|
98
|
-
if (config.maxSize
|
|
339
|
+
if (config.maxSize !== null && config.maxSize !== void 0 && file.size > config.maxSize) {
|
|
99
340
|
const maxMB = (config.maxSize / (1024 * 1024)).toFixed(1);
|
|
100
341
|
throw new Error(`File exceeds maximum size of ${maxMB} MB.`);
|
|
101
342
|
}
|
|
@@ -168,20 +409,6 @@ function useFileUpload(dbOrBucketName, maybeBucketName) {
|
|
|
168
409
|
|
|
169
410
|
//#endregion
|
|
170
411
|
//#region src/lib/use-download-file.ts
|
|
171
|
-
const downloadCache = /* @__PURE__ */ new Map();
|
|
172
|
-
const inflightRequests = /* @__PURE__ */ new Map();
|
|
173
|
-
function cacheKey(bucket, path) {
|
|
174
|
-
return `${bucket}:${path}`;
|
|
175
|
-
}
|
|
176
|
-
function releaseEntry(key) {
|
|
177
|
-
const entry = downloadCache.get(key);
|
|
178
|
-
if (!entry) return;
|
|
179
|
-
entry.refCount--;
|
|
180
|
-
if (entry.refCount <= 0) {
|
|
181
|
-
URL.revokeObjectURL(entry.url);
|
|
182
|
-
downloadCache.delete(key);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
412
|
function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeOptions) {
|
|
186
413
|
let db;
|
|
187
414
|
let bucketName;
|
|
@@ -202,63 +429,15 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
|
|
|
202
429
|
const [url, setUrl] = (0, solid_js.createSignal)(null);
|
|
203
430
|
const [isLoading, setIsLoading] = (0, solid_js.createSignal)(false);
|
|
204
431
|
const [error, setError] = (0, solid_js.createSignal)(null);
|
|
205
|
-
let
|
|
432
|
+
let lease = null;
|
|
206
433
|
let privateUrl = null;
|
|
207
|
-
let refetchTrigger;
|
|
208
434
|
const [refetchSignal, setRefetchSignal] = (0, solid_js.createSignal)(0);
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
currentKey = key;
|
|
216
|
-
return cached.url;
|
|
217
|
-
}
|
|
218
|
-
const inflight = inflightRequests.get(key);
|
|
219
|
-
if (inflight) {
|
|
220
|
-
const result = await inflight;
|
|
221
|
-
if (result) {
|
|
222
|
-
const entry = downloadCache.get(key);
|
|
223
|
-
if (entry) {
|
|
224
|
-
entry.refCount++;
|
|
225
|
-
currentKey = key;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
return result;
|
|
229
|
-
}
|
|
230
|
-
const promise = (async () => {
|
|
231
|
-
const content = await db.bucket(bucketName).get(filePath);
|
|
232
|
-
if (!content) return null;
|
|
233
|
-
const objectUrl = URL.createObjectURL(new Blob([content]));
|
|
234
|
-
downloadCache.set(key, {
|
|
235
|
-
url: objectUrl,
|
|
236
|
-
refCount: 1
|
|
237
|
-
});
|
|
238
|
-
return objectUrl;
|
|
239
|
-
})();
|
|
240
|
-
inflightRequests.set(key, promise);
|
|
241
|
-
try {
|
|
242
|
-
const result = await promise;
|
|
243
|
-
currentKey = key;
|
|
244
|
-
return result;
|
|
245
|
-
} finally {
|
|
246
|
-
inflightRequests.delete(key);
|
|
247
|
-
}
|
|
248
|
-
} else {
|
|
249
|
-
const content = await db.bucket(bucketName).get(filePath);
|
|
250
|
-
if (!content) return null;
|
|
251
|
-
const objectUrl = URL.createObjectURL(new Blob([content]));
|
|
252
|
-
privateUrl = objectUrl;
|
|
253
|
-
return objectUrl;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
function releaseCurrentEntry() {
|
|
257
|
-
if (useCache && currentKey) {
|
|
258
|
-
releaseEntry(currentKey);
|
|
259
|
-
currentKey = null;
|
|
260
|
-
}
|
|
261
|
-
if (!useCache && privateUrl) {
|
|
435
|
+
/** Consumed by the next effect run, so `refetch()` bypasses every layer once. */
|
|
436
|
+
let reloadOnce = false;
|
|
437
|
+
function releaseCurrent() {
|
|
438
|
+
lease?.release();
|
|
439
|
+
lease = null;
|
|
440
|
+
if (privateUrl) {
|
|
262
441
|
URL.revokeObjectURL(privateUrl);
|
|
263
442
|
privateUrl = null;
|
|
264
443
|
}
|
|
@@ -266,29 +445,40 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
|
|
|
266
445
|
(0, solid_js.createEffect)(() => {
|
|
267
446
|
const filePath = path();
|
|
268
447
|
refetchSignal();
|
|
269
|
-
|
|
448
|
+
releaseCurrent();
|
|
270
449
|
if (!filePath) {
|
|
271
450
|
setUrl(null);
|
|
272
451
|
setIsLoading(false);
|
|
273
452
|
setError(null);
|
|
274
453
|
return;
|
|
275
454
|
}
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
const cached = downloadCache.get(key);
|
|
279
|
-
if (cached) {
|
|
280
|
-
cached.refCount++;
|
|
281
|
-
currentKey = key;
|
|
282
|
-
setUrl(cached.url);
|
|
283
|
-
setIsLoading(false);
|
|
284
|
-
setError(null);
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
455
|
+
const reload = reloadOnce;
|
|
456
|
+
reloadOnce = false;
|
|
288
457
|
let cancelled = false;
|
|
289
458
|
setIsLoading(true);
|
|
290
459
|
setError(null);
|
|
291
|
-
|
|
460
|
+
const bucket = db.bucket(bucketName);
|
|
461
|
+
(useCache ? bucket.url(filePath, {
|
|
462
|
+
persist: options.persist !== false,
|
|
463
|
+
pin: options.pin,
|
|
464
|
+
revalidate: options.revalidate,
|
|
465
|
+
reload
|
|
466
|
+
}).then((acquired) => {
|
|
467
|
+
if (!acquired) return null;
|
|
468
|
+
if (cancelled) {
|
|
469
|
+
acquired.release();
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
lease = acquired;
|
|
473
|
+
return acquired.url;
|
|
474
|
+
}) : bucket.read(filePath, {
|
|
475
|
+
persist: false,
|
|
476
|
+
reload: true
|
|
477
|
+
}).then((blob) => {
|
|
478
|
+
if (!blob || cancelled) return null;
|
|
479
|
+
privateUrl = URL.createObjectURL(blob);
|
|
480
|
+
return privateUrl;
|
|
481
|
+
})).then((result) => {
|
|
292
482
|
if (!cancelled) {
|
|
293
483
|
setUrl(result);
|
|
294
484
|
setIsLoading(false);
|
|
@@ -304,18 +494,11 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
|
|
|
304
494
|
});
|
|
305
495
|
});
|
|
306
496
|
(0, solid_js.onCleanup)(() => {
|
|
307
|
-
|
|
497
|
+
releaseCurrent();
|
|
308
498
|
});
|
|
309
499
|
const refetch = () => {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
if (entry) {
|
|
313
|
-
URL.revokeObjectURL(entry.url);
|
|
314
|
-
downloadCache.delete(currentKey);
|
|
315
|
-
}
|
|
316
|
-
currentKey = null;
|
|
317
|
-
}
|
|
318
|
-
refetchTrigger();
|
|
500
|
+
reloadOnce = true;
|
|
501
|
+
setRefetchSignal((n) => n + 1);
|
|
319
502
|
};
|
|
320
503
|
return {
|
|
321
504
|
url,
|
|
@@ -326,26 +509,39 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
|
|
|
326
509
|
}
|
|
327
510
|
|
|
328
511
|
//#endregion
|
|
329
|
-
//#region src/lib/
|
|
330
|
-
function
|
|
512
|
+
//#region src/lib/Sp00kyProvider.ts
|
|
513
|
+
function Sp00kyProvider(props) {
|
|
331
514
|
const merged = (0, solid_js.mergeProps)({ fallback: void 0 }, props);
|
|
332
515
|
const [db, setDb] = (0, solid_js.createSignal)(void 0);
|
|
516
|
+
let disposed = false;
|
|
517
|
+
(0, solid_js.onCleanup)(() => {
|
|
518
|
+
disposed = true;
|
|
519
|
+
});
|
|
333
520
|
(0, solid_js.onMount)(async () => {
|
|
334
521
|
try {
|
|
335
522
|
const instance = new SyncedDb(merged.config);
|
|
336
523
|
await instance.init();
|
|
524
|
+
if (disposed) {
|
|
525
|
+
await instance.close();
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (merged.preload) try {
|
|
529
|
+
await merged.preload(instance);
|
|
530
|
+
} catch (e) {
|
|
531
|
+
console.error("Sp00kyProvider: preload failed; revealing UI anyway", e);
|
|
532
|
+
}
|
|
337
533
|
setDb(() => instance);
|
|
338
534
|
merged.onReady?.(instance);
|
|
339
535
|
} catch (e) {
|
|
340
536
|
const error = e instanceof Error ? e : new Error(String(e));
|
|
341
537
|
if (merged.onError) merged.onError(error);
|
|
342
|
-
else console.error("
|
|
538
|
+
else console.error("Sp00kyProvider: Failed to initialize database", error);
|
|
343
539
|
}
|
|
344
540
|
});
|
|
345
541
|
return (0, solid_js.createMemo)(() => {
|
|
346
542
|
const instance = db();
|
|
347
543
|
if (!instance) return merged.fallback;
|
|
348
|
-
return (0, solid_js.createComponent)(
|
|
544
|
+
return (0, solid_js.createComponent)(Sp00kyContext.Provider, {
|
|
349
545
|
value: instance,
|
|
350
546
|
get children() {
|
|
351
547
|
return merged.children;
|
|
@@ -357,69 +553,94 @@ function SpookyProvider(props) {
|
|
|
357
553
|
//#endregion
|
|
358
554
|
//#region src/index.ts
|
|
359
555
|
/**
|
|
360
|
-
* SyncedDb - A thin wrapper around
|
|
361
|
-
* Delegates all logic to the underlying
|
|
556
|
+
* SyncedDb - A thin wrapper around sp00ky-ts for Solid.js integration
|
|
557
|
+
* Delegates all logic to the underlying sp00ky-ts instance
|
|
362
558
|
*/
|
|
363
559
|
var SyncedDb = class {
|
|
364
560
|
constructor(config) {
|
|
365
|
-
this.
|
|
561
|
+
this.sp00ky = null;
|
|
366
562
|
this._initialized = false;
|
|
367
563
|
this.config = config;
|
|
368
564
|
}
|
|
369
|
-
|
|
370
|
-
if (!this.
|
|
371
|
-
return this.
|
|
565
|
+
getSp00ky() {
|
|
566
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
567
|
+
return this.sp00ky;
|
|
372
568
|
}
|
|
373
569
|
/**
|
|
374
|
-
* Initialize the
|
|
570
|
+
* Initialize the sp00ky-ts instance
|
|
375
571
|
*/
|
|
376
572
|
async init() {
|
|
377
573
|
if (this._initialized) return;
|
|
378
|
-
this.
|
|
379
|
-
await this.
|
|
574
|
+
this.sp00ky = new _spooky_sync_core.Sp00kyClient(this.config);
|
|
575
|
+
await this.sp00ky.init();
|
|
380
576
|
this._initialized = true;
|
|
381
577
|
}
|
|
382
578
|
/**
|
|
579
|
+
* Tear down the client: leaves the tabs broker, closes the local store and
|
|
580
|
+
* remote socket, and frees the wasm circuit. Without this a remounted provider
|
|
581
|
+
* (or an HMR reload) strands a whole client, and the abandoned wasm heaps stay
|
|
582
|
+
* resident because V8 cannot see how much wasm memory a dropped wrapper holds.
|
|
583
|
+
*/
|
|
584
|
+
async close() {
|
|
585
|
+
const instance = this.sp00ky;
|
|
586
|
+
this.sp00ky = null;
|
|
587
|
+
this._initialized = false;
|
|
588
|
+
if (instance) await instance.close();
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
383
591
|
* Create a new record in the database
|
|
384
592
|
*/
|
|
385
593
|
async create(id, payload) {
|
|
386
|
-
if (!this.
|
|
387
|
-
await this.
|
|
594
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
595
|
+
await this.sp00ky.create(id, payload);
|
|
388
596
|
}
|
|
389
597
|
/**
|
|
390
598
|
* Update an existing record in the database
|
|
391
599
|
*/
|
|
392
600
|
async update(tableName, recordId, payload, options) {
|
|
393
|
-
if (!this.
|
|
394
|
-
await this.
|
|
601
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
602
|
+
await this.sp00ky.update(tableName, recordId, payload, options);
|
|
395
603
|
}
|
|
396
604
|
/**
|
|
397
605
|
* Delete an existing record in the database
|
|
398
606
|
*/
|
|
399
607
|
async delete(tableName, selector) {
|
|
400
|
-
if (!this.
|
|
401
|
-
|
|
402
|
-
|
|
608
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
609
|
+
const isRecordId = selector instanceof surrealdb.RecordId || selector?.constructor?.name === "RecordId";
|
|
610
|
+
let id;
|
|
611
|
+
if (typeof selector === "string") id = selector;
|
|
612
|
+
else if (isRecordId) id = `${tableName}:${selector.id}`;
|
|
613
|
+
else throw new Error("Only string ID or RecordId selectors are supported currently with core");
|
|
614
|
+
await this.sp00ky.delete(tableName, id);
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Preload/prewarm a built query into the local cache without registering a
|
|
618
|
+
* live view. Fetches once and stores the rows (+ embedded related children)
|
|
619
|
+
* locally so a later `useQuery` for the same data paints instantly. Best-effort.
|
|
620
|
+
*/
|
|
621
|
+
async preload(finalQuery, options) {
|
|
622
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
623
|
+
await this.sp00ky.preload(finalQuery, options);
|
|
403
624
|
}
|
|
404
625
|
/**
|
|
405
626
|
* Query data from the database
|
|
406
627
|
*/
|
|
407
628
|
query(table) {
|
|
408
|
-
if (!this.
|
|
409
|
-
return this.
|
|
629
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
630
|
+
return this.sp00ky.query(table, {});
|
|
410
631
|
}
|
|
411
632
|
/**
|
|
412
633
|
* Run a backend operation
|
|
413
634
|
*/
|
|
414
635
|
async run(backend, path, payload, options) {
|
|
415
|
-
if (!this.
|
|
416
|
-
await this.
|
|
636
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
637
|
+
await this.sp00ky.run(backend, path, payload, options);
|
|
417
638
|
}
|
|
418
639
|
/**
|
|
419
640
|
* Authenticate with the database
|
|
420
641
|
*/
|
|
421
642
|
async authenticate(token) {
|
|
422
|
-
await this.
|
|
643
|
+
await this.sp00ky?.authenticate(token);
|
|
423
644
|
return new surrealdb.RecordId("user", "me");
|
|
424
645
|
}
|
|
425
646
|
/**
|
|
@@ -433,48 +654,81 @@ var SyncedDb = class {
|
|
|
433
654
|
* Sign out, clear session and local storage
|
|
434
655
|
*/
|
|
435
656
|
async signOut() {
|
|
436
|
-
if (!this.
|
|
437
|
-
await this.
|
|
657
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
658
|
+
await this.sp00ky.auth.signOut();
|
|
438
659
|
}
|
|
439
660
|
/**
|
|
440
661
|
* Execute a function with direct access to the remote database connection
|
|
441
662
|
*/
|
|
442
663
|
async useRemote(fn) {
|
|
443
|
-
if (!this.
|
|
444
|
-
return await this.
|
|
664
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
665
|
+
return await this.sp00ky.useRemote(fn);
|
|
445
666
|
}
|
|
446
667
|
/**
|
|
447
668
|
* Access the remote database service directly
|
|
448
669
|
*/
|
|
449
670
|
get remote() {
|
|
450
|
-
if (!this.
|
|
451
|
-
return this.
|
|
671
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
672
|
+
return this.sp00ky.remoteClient;
|
|
452
673
|
}
|
|
453
674
|
/**
|
|
454
675
|
* Access the local database service directly
|
|
455
676
|
*/
|
|
456
677
|
get local() {
|
|
457
|
-
if (!this.
|
|
458
|
-
return this.
|
|
678
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
679
|
+
return this.sp00ky.localClient;
|
|
459
680
|
}
|
|
460
681
|
/**
|
|
461
682
|
* Access the auth service
|
|
462
683
|
*/
|
|
463
684
|
get auth() {
|
|
464
|
-
if (!this.
|
|
465
|
-
return this.
|
|
685
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
686
|
+
return this.sp00ky.auth;
|
|
466
687
|
}
|
|
467
688
|
get pendingMutationCount() {
|
|
468
|
-
if (!this.
|
|
469
|
-
return this.
|
|
689
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
690
|
+
return this.sp00ky.pendingMutationCount;
|
|
691
|
+
}
|
|
692
|
+
/** Diagnostic — see `Sp00kyClient.liveRetryCount`. */
|
|
693
|
+
get liveRetryCount() {
|
|
694
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
695
|
+
return this.sp00ky.liveRetryCount;
|
|
470
696
|
}
|
|
471
697
|
subscribeToPendingMutations(cb) {
|
|
472
|
-
if (!this.
|
|
473
|
-
return this.
|
|
698
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
699
|
+
return this.sp00ky.subscribeToPendingMutations(cb);
|
|
700
|
+
}
|
|
701
|
+
/** Current sync-health snapshot. See {@link useSyncStatus}. */
|
|
702
|
+
get syncHealth() {
|
|
703
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
704
|
+
return this.sp00ky.syncHealth;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Observe sync health. Fires immediately with the current status and again
|
|
708
|
+
* on every healthy↔degraded transition. Prefer the `useSyncStatus` hook in
|
|
709
|
+
* components; this is the imperative escape hatch.
|
|
710
|
+
*/
|
|
711
|
+
subscribeToSyncHealth(cb) {
|
|
712
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
713
|
+
return this.sp00ky.subscribeToSyncHealth(cb);
|
|
714
|
+
}
|
|
715
|
+
/** Current local-store durability snapshot. See {@link useStorageStatus}. */
|
|
716
|
+
get storageHealth() {
|
|
717
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
718
|
+
return this.sp00ky.storageHealth;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Observe local-store durability. Fires immediately with the current snapshot
|
|
722
|
+
* and again on change. Prefer the `useStorageStatus` hook in components; this
|
|
723
|
+
* is the imperative escape hatch.
|
|
724
|
+
*/
|
|
725
|
+
subscribeToStorageHealth(cb) {
|
|
726
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
727
|
+
return this.sp00ky.subscribeToStorageHealth(cb);
|
|
474
728
|
}
|
|
475
729
|
bucket(name) {
|
|
476
|
-
if (!this.
|
|
477
|
-
return this.
|
|
730
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
731
|
+
return this.sp00ky.bucket(name);
|
|
478
732
|
}
|
|
479
733
|
getBucketConfig(name) {
|
|
480
734
|
return this.config.schema.buckets?.find((b) => b.name === name);
|
|
@@ -483,11 +737,17 @@ var SyncedDb = class {
|
|
|
483
737
|
|
|
484
738
|
//#endregion
|
|
485
739
|
exports.RecordId = surrealdb.RecordId;
|
|
486
|
-
exports.
|
|
740
|
+
exports.Sp00kyProvider = Sp00kyProvider;
|
|
487
741
|
exports.SyncedDb = SyncedDb;
|
|
488
742
|
exports.Uuid = surrealdb.Uuid;
|
|
743
|
+
exports.createPreload = createPreload;
|
|
744
|
+
exports.useAppRelease = useAppRelease;
|
|
745
|
+
exports.useCrdtField = useCrdtField;
|
|
489
746
|
exports.useDb = useDb;
|
|
490
747
|
exports.useDownloadFile = useDownloadFile;
|
|
748
|
+
exports.useFeatureFlag = useFeatureFlag;
|
|
491
749
|
exports.useFileUpload = useFileUpload;
|
|
492
750
|
exports.useQuery = useQuery;
|
|
751
|
+
exports.useStorageStatus = useStorageStatus;
|
|
752
|
+
exports.useSyncStatus = useSyncStatus;
|
|
493
753
|
//# sourceMappingURL=index.cjs.map
|