@spooky-sync/client-solid2 0.0.1-canary.237 → 0.0.1-canary.239
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 +5 -1
- package/QUICK_START.md +6 -3
- package/README.md +2 -2
- package/dist/index.cjs +88 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +72 -5
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +72 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +88 -11
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/skills/sp00ky-solid2/SKILL.md +6 -3
- package/src/index.ts +20 -0
- package/src/lib/__tests__/create-query.test.ts +117 -4
- package/src/lib/__tests__/use-sync-activity.test.ts +104 -0
- package/src/lib/create-query.ts +73 -18
- package/src/lib/use-sync-activity.ts +99 -0
package/AGENTS.md
CHANGED
|
@@ -39,9 +39,13 @@ Provider props are identical to client-solid: `config`, `fallback`, `preload` (a
|
|
|
39
39
|
- `data()` — non-suspending. `[]` / `null` before the first result, keyed-reconciled live rows after.
|
|
40
40
|
- `ready()` — suspending read for `<Loading fallback={...}>` (Solid 2's renamed `<Suspense>`).
|
|
41
41
|
- `error()` — registration/sync failure (e.g. SSP 503 NOT_READY). Never thrown into the render tree; the sync scheduler retries underneath.
|
|
42
|
-
- `isLoading()`
|
|
42
|
+
- `isLoading()` — cold first load only (nothing painted for this identity, server not heard, no error). A cached paint ends it; an authoritative empty answer ends it. Never true again for the same identity.
|
|
43
|
+
- `isFetching()` — a fetch cycle is in flight (registration, a sync round).
|
|
44
|
+
- `isAuthoritative()` — server membership is known for this identity (latched until it changes). `hasData()` — `data()` holds something. `isEmpty()` — authoritative AND no data: the server said "no rows"; render empty states on this, not on `!hasData()`.
|
|
45
|
+
- `isSettled()` — authoritative AND idle; gate windowed-list end detection on it. Not monotonic; latch it (a `createMemo(prev => prev || q.isSettled())`) for a verdict that must stick.
|
|
43
46
|
- Options: `enabled?: () => boolean`, `deregisterOnCleanup?: boolean`.
|
|
44
47
|
- **`createSubmission(fn)`** — button pending/error state around a mutation: `submit()`, `pending()`, `error()`, `result()`, `clearError()`. There is deliberately no `action()` / `createOptimisticStore` layer (see `src/lib/create-submission.ts`).
|
|
48
|
+
- **`useSyncActivity(options?)`** — `{ fetchingQueries, pendingMutations, isDownloading, isUploading }`. One subscription on the engine's aggregate fetch count plus the outbox depth; `isDownloading()` is debounced ON by `downloadDelayMs` (200) and off at once, `isUploading()` is `pendingMutations() > uploadThreshold` (1). Drive a logo/traffic indicator off it; pair with `useSyncStatus().isOffline()` to show the unsaved count while disconnected.
|
|
45
49
|
- **`createPreload`**, **`usePendingMutations`**, **`useSyncStatus`**, **`useStorageStatus`**, **`useFeatureFlag`**, **`useAppRelease`**, **`useCrdtField`**, **`useFileUpload`**, **`useDownloadFile`** — same shapes as client-solid.
|
|
46
50
|
- **`conflate`**, **`fromSubscription`** — the async-iterable helpers the hooks are built on; useful for wrapping other engine subscriptions.
|
|
47
51
|
|
package/QUICK_START.md
CHANGED
|
@@ -81,9 +81,12 @@ import { Loading } from 'solid-js';
|
|
|
81
81
|
| `data()` | Rows (or row/`null` for `.one()`). Never suspends, never throws. Keyed-reconciled in place: unchanged rows keep identity, `<For>` is notified on add/remove/reorder. |
|
|
82
82
|
| `ready()` | Same data, suspends into the nearest `<Loading>` until first result or error. |
|
|
83
83
|
| `error()` | Registration/sync error (e.g. SSP 503 during bootstrap). Never thrown into the render tree; the sync scheduler retries underneath. |
|
|
84
|
-
| `isLoading()` |
|
|
84
|
+
| `isLoading()` | Cold first load only: nothing painted for this identity yet, server not heard, no error. A cached paint ends it; so does the server answering with zero rows. |
|
|
85
85
|
| `isFetching()` | Sync engine is pulling records for this query. |
|
|
86
|
-
| `
|
|
86
|
+
| `isAuthoritative()` | Server membership is known for this identity (registration/poll landed, or a previous session's membership was read on boot). Latched until the identity changes. |
|
|
87
|
+
| `hasData()` | `data()` holds rows (or the `.one()` row). |
|
|
88
|
+
| `isEmpty()` | Authoritative AND no data — the server said "no rows". Render empty states on this. |
|
|
89
|
+
| `isSettled()` | Authoritative AND idle — windowed lists may trust a short result as the true end of the list. Re-enters on later sync rounds; latch it for a sticky verdict. |
|
|
87
90
|
|
|
88
91
|
Options: `{ enabled?: () => boolean, deregisterOnCleanup?: boolean }` — both as in client-solid.
|
|
89
92
|
|
|
@@ -113,7 +116,7 @@ Track a backend job's progress by querying its outbox row: `createQuery(() => db
|
|
|
113
116
|
|
|
114
117
|
## Status hooks
|
|
115
118
|
|
|
116
|
-
Same shapes as client-solid, rebuilt on async-iterable-backed memos: `useSyncStatus`, `useStorageStatus`, `usePendingMutations`, `useFeatureFlag`, `useAppRelease`, `useCrdtField`, `useFileUpload`, `useDownloadFile`, `createPreload`.
|
|
119
|
+
Same shapes as client-solid, rebuilt on async-iterable-backed memos: `useSyncStatus`, `useStorageStatus`, `usePendingMutations`, `useSyncActivity` (new: `isDownloading()` after 200 ms of fetching, `isUploading()` above one queued write, for a traffic indicator), `useFeatureFlag`, `useAppRelease`, `useCrdtField`, `useFileUpload`, `useDownloadFile`, `createPreload`.
|
|
117
120
|
|
|
118
121
|
## Migrating from client-solid
|
|
119
122
|
|
package/README.md
CHANGED
|
@@ -7,8 +7,8 @@ Counterpart of `@spooky-sync/client-solid` (Solid 1.x); the two coexist until So
|
|
|
7
7
|
What "native" means here:
|
|
8
8
|
|
|
9
9
|
- Query results are a plain store written from the engine's live subscription and merged keyed by `id`: row identity preserved, coarse `<For>` readers notified, only changed fields written. Not an async-generator projection - see `create-query.ts` for why a never-returning generator breaks navigation transitions.
|
|
10
|
-
- `createQuery` exposes both worlds: non-suspending accessors (`data`, `isLoading`, `isFetching`, `isSettled`, `error`) and a suspending `ready()` for `<Loading>` boundaries. Born committed, so local-first cache paints never suspend.
|
|
11
|
-
- Status hooks (`useSyncStatus`, `useStorageStatus`, `usePendingMutations`, feature flags, app release) are async-iterable-backed memos with `loadingValue`.
|
|
10
|
+
- `createQuery` exposes both worlds: non-suspending accessors (`data`, `isLoading`, `isFetching`, `isAuthoritative`, `hasData`, `isEmpty`, `isSettled`, `error`) and a suspending `ready()` for `<Loading>` boundaries. Born committed, so local-first cache paints never suspend. `isLoading()` is true only on a cold first load (nothing cached, server not heard yet); `isEmpty()` is the server saying "no rows".
|
|
11
|
+
- Status hooks (`useSyncStatus`, `useSyncActivity`, `useStorageStatus`, `usePendingMutations`, feature flags, app release) are async-iterable-backed memos with `loadingValue`. `useSyncActivity` is the traffic indicator pair: `isDownloading()` (queries fetching for longer than 200 ms) and `isUploading()` (more than one write queued), plus the raw `fetchingQueries()` / `pendingMutations()` counts.
|
|
12
12
|
- Mutations stay plain async calls — the engine is already optimistic local-first end to end (local commit → live re-emit → outbox sync). `createSubmission` adds button pending/error state.
|
|
13
13
|
|
|
14
14
|
See `QUICK_START.md` for usage and the migration table from client-solid, and `src/lib/__tests__/rc-semantics.test.ts` for the probed Solid 2 rc contracts this package depends on (run them after every Solid version bump).
|
package/dist/index.cjs
CHANGED
|
@@ -197,7 +197,8 @@ function createQuery(dbOrQuery, queryOrOptions, maybeOptions) {
|
|
|
197
197
|
}
|
|
198
198
|
const sp00ky = db.getSp00ky();
|
|
199
199
|
const [error, setError] = (0, solid_js.createSignal)(void 0, { ownedWrite: true });
|
|
200
|
-
const [
|
|
200
|
+
const [isAuthoritative, setIsAuthoritative] = (0, solid_js.createSignal)(false, { ownedWrite: true });
|
|
201
|
+
const [hasPainted, setHasPainted] = (0, solid_js.createSignal)(false, { ownedWrite: true });
|
|
201
202
|
const [isFetching, setIsFetching] = (0, solid_js.createSignal)(false, { ownedWrite: true });
|
|
202
203
|
let activeHash;
|
|
203
204
|
const [store, setStore] = (0, solid_js.createStore)({ value: null });
|
|
@@ -215,7 +216,8 @@ function createQuery(dbOrQuery, queryOrOptions, maybeOptions) {
|
|
|
215
216
|
};
|
|
216
217
|
}, ({ enabled, query }) => {
|
|
217
218
|
const myRun = ++runId;
|
|
218
|
-
|
|
219
|
+
setIsAuthoritative(false);
|
|
220
|
+
setHasPainted(false);
|
|
219
221
|
setError(void 0);
|
|
220
222
|
if (!enabled || !query) return;
|
|
221
223
|
const cleanups = [];
|
|
@@ -244,16 +246,19 @@ function createQuery(dbOrQuery, queryOrOptions, maybeOptions) {
|
|
|
244
246
|
if (disposed || myRun !== runId) return;
|
|
245
247
|
activeHash = hash;
|
|
246
248
|
addCleanup(sp00ky.subscribeQueryStatus(hash, (status) => setIsFetching(status === "fetching"), { immediate: true }));
|
|
247
|
-
|
|
249
|
+
addCleanup(sp00ky.subscribeQueryAuthority(hash, (known) => {
|
|
250
|
+
if (disposed || myRun !== runId) return;
|
|
251
|
+
setIsAuthoritative(known);
|
|
252
|
+
if (known) wakeReady();
|
|
253
|
+
else setHasPainted(false);
|
|
254
|
+
}, { immediate: true }));
|
|
248
255
|
addCleanup(sp00ky.subscribe(hash, (rows) => {
|
|
249
256
|
if (disposed || myRun !== runId) return;
|
|
250
257
|
const queryData = query.isOne ? rows[0] ?? null : rows;
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
setIsFetched(true);
|
|
258
|
+
if (query.isOne ? queryData !== null && queryData !== void 0 : rows.length > 0) {
|
|
259
|
+
setHasPainted(true);
|
|
254
260
|
wakeReady();
|
|
255
261
|
}
|
|
256
|
-
isFirstCall = false;
|
|
257
262
|
const t0 = performance.now();
|
|
258
263
|
setStore((s) => {
|
|
259
264
|
if (query.isOne || queryData === null || !Array.isArray(s.value)) s.value = queryData;
|
|
@@ -281,7 +286,7 @@ function createQuery(dbOrQuery, queryOrOptions, maybeOptions) {
|
|
|
281
286
|
return v;
|
|
282
287
|
};
|
|
283
288
|
const readyGate = (0, solid_js.createMemo)(async () => {
|
|
284
|
-
if (
|
|
289
|
+
if (isAuthoritative() || hasPainted() || error()) return true;
|
|
285
290
|
await new Promise((resolve) => readyWaiters.push(resolve));
|
|
286
291
|
return true;
|
|
287
292
|
}, { lazy: true });
|
|
@@ -292,14 +297,22 @@ function createQuery(dbOrQuery, queryOrOptions, maybeOptions) {
|
|
|
292
297
|
(0, solid_js.onCleanup)(() => {
|
|
293
298
|
if (options?.deregisterOnCleanup && activeHash) sp00ky.deregisterQuery(activeHash);
|
|
294
299
|
});
|
|
295
|
-
const
|
|
296
|
-
|
|
300
|
+
const hasData = () => {
|
|
301
|
+
const v = store.value;
|
|
302
|
+
return Array.isArray(v) ? v.length > 0 : v !== null && v !== void 0;
|
|
303
|
+
};
|
|
304
|
+
const isLoading = () => !isAuthoritative() && !hasPainted() && error() === void 0;
|
|
305
|
+
const isEmpty = () => isAuthoritative() && !hasData();
|
|
306
|
+
const isSettled = () => isAuthoritative() && !isFetching();
|
|
297
307
|
return {
|
|
298
308
|
data,
|
|
299
309
|
ready,
|
|
300
310
|
error,
|
|
301
311
|
isLoading,
|
|
302
312
|
isFetching,
|
|
313
|
+
isAuthoritative,
|
|
314
|
+
hasData,
|
|
315
|
+
isEmpty,
|
|
303
316
|
isSettled
|
|
304
317
|
};
|
|
305
318
|
}
|
|
@@ -374,6 +387,57 @@ function useSyncStatus() {
|
|
|
374
387
|
};
|
|
375
388
|
}
|
|
376
389
|
|
|
390
|
+
//#endregion
|
|
391
|
+
//#region src/lib/use-sync-activity.ts
|
|
392
|
+
/**
|
|
393
|
+
* The two directions of sync traffic, for an indicator in the app chrome.
|
|
394
|
+
*
|
|
395
|
+
* `fetchingQueries` is one subscription on the engine's aggregate fetch count,
|
|
396
|
+
* not one per query; `pendingMutations` is the outbox depth. `isDownloading`
|
|
397
|
+
* is the fetch count debounced ON by `downloadDelayMs` (and off at once), so a
|
|
398
|
+
* local-first page that answers from cache and confirms with the server in a
|
|
399
|
+
* few milliseconds never flickers. Must be used within a `<Sp00kyProvider>`, or
|
|
400
|
+
* pass the `SyncedDb` explicitly.
|
|
401
|
+
*/
|
|
402
|
+
function useSyncActivity(dbOrOptions, maybeOptions) {
|
|
403
|
+
const explicitDb = dbOrOptions && typeof dbOrOptions.subscribeToFetchActivity === "function" ? dbOrOptions : void 0;
|
|
404
|
+
const options = (explicitDb ? maybeOptions : dbOrOptions) ?? {};
|
|
405
|
+
const db = explicitDb ?? useDb();
|
|
406
|
+
const downloadDelayMs = options.downloadDelayMs ?? 200;
|
|
407
|
+
const uploadThreshold = options.uploadThreshold ?? 1;
|
|
408
|
+
const fetchingQueries = fromSubscription((cb) => db.subscribeToFetchActivity(cb), db.fetchingQueryCount);
|
|
409
|
+
const pendingMutations = fromSubscription((cb) => db.subscribeToPendingMutations(cb), db.pendingMutationCount);
|
|
410
|
+
const [isDownloading, setIsDownloading] = (0, solid_js.createSignal)(false, { ownedWrite: true });
|
|
411
|
+
let timer;
|
|
412
|
+
(0, solid_js.createEffect)(() => fetchingQueries() > 0, (busy) => {
|
|
413
|
+
if (timer !== void 0) {
|
|
414
|
+
clearTimeout(timer);
|
|
415
|
+
timer = void 0;
|
|
416
|
+
}
|
|
417
|
+
if (!busy) {
|
|
418
|
+
setIsDownloading(false);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (downloadDelayMs <= 0) {
|
|
422
|
+
setIsDownloading(true);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
timer = setTimeout(() => {
|
|
426
|
+
timer = void 0;
|
|
427
|
+
setIsDownloading(true);
|
|
428
|
+
}, downloadDelayMs);
|
|
429
|
+
});
|
|
430
|
+
(0, solid_js.onCleanup)(() => {
|
|
431
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
432
|
+
});
|
|
433
|
+
return {
|
|
434
|
+
fetchingQueries,
|
|
435
|
+
pendingMutations,
|
|
436
|
+
isDownloading,
|
|
437
|
+
isUploading: () => pendingMutations() > uploadThreshold
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
377
441
|
//#endregion
|
|
378
442
|
//#region src/lib/use-storage-status.ts
|
|
379
443
|
/**
|
|
@@ -1150,6 +1214,19 @@ var SyncedDb = class {
|
|
|
1150
1214
|
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
1151
1215
|
return this.sp00ky.subscribeToPendingMutations(cb);
|
|
1152
1216
|
}
|
|
1217
|
+
/** Queries mid-fetch right now. See {@link useSyncActivity}. */
|
|
1218
|
+
get fetchingQueryCount() {
|
|
1219
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
1220
|
+
return this.sp00ky.fetchingQueryCount;
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Observe how many queries are fetching from the server. Fires immediately
|
|
1224
|
+
* and on every change. Prefer the `useSyncActivity` hook in components.
|
|
1225
|
+
*/
|
|
1226
|
+
subscribeToFetchActivity(cb) {
|
|
1227
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
1228
|
+
return this.sp00ky.subscribeToFetchActivity(cb);
|
|
1229
|
+
}
|
|
1153
1230
|
/** Current sync-health snapshot. See {@link useSyncStatus}. */
|
|
1154
1231
|
get syncHealth() {
|
|
1155
1232
|
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
@@ -1210,5 +1287,6 @@ exports.useFileUpload = useFileUpload;
|
|
|
1210
1287
|
exports.usePendingMutations = usePendingMutations;
|
|
1211
1288
|
exports.useQuery = useQuery;
|
|
1212
1289
|
exports.useStorageStatus = useStorageStatus;
|
|
1290
|
+
exports.useSyncActivity = useSyncActivity;
|
|
1213
1291
|
exports.useSyncStatus = useSyncStatus;
|
|
1214
1292
|
//# sourceMappingURL=index.cjs.map
|