@spooky-sync/client-solid2 0.0.1-canary.200 → 0.0.1-canary.201

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 ADDED
@@ -0,0 +1,77 @@
1
+ # `@spooky-sync/client-solid2` — agent guide
2
+
3
+ ## What this package is
4
+
5
+ The **Solid 2.0** binding for sp00ky. Counterpart of `@spooky-sync/client-solid` (Solid 1.x); the two coexist until Solid 2.0 is stable and an app uses exactly one. Peers: `solid-js@^2.0.0-rc.0` + `@solidjs/signals@^2.0.0-rc.0` (coordinated RC, pin matching versions); apps also need `@solidjs/web` and `jsxImportSource: "@solidjs/web"`.
6
+
7
+ Solid-2 native internals: query results live in a `createProjection` fed by an async generator over the engine's live subscription (keyed reconcile by `id`, row identity preserved, `<For>` notified on add/remove/reorder — no version-signal hack). Status hooks are async-iterable-backed memos with a `loadingValue`. Teardown is `onCleanup`-driven, because Solid 2 abandons superseded async generators without terminating them.
8
+
9
+ ## Setup pattern
10
+
11
+ ```ts
12
+ // db.ts
13
+ import type { SyncedDbConfig } from '@spooky-sync/client-solid2';
14
+ import { schema, SURQL_SCHEMA } from './schema.gen'; // generated by `spky generate`
15
+
16
+ export const dbConfig: SyncedDbConfig<typeof schema> = {
17
+ schema,
18
+ schemaSurql: SURQL_SCHEMA,
19
+ database: {
20
+ namespace: 'main',
21
+ database: 'app',
22
+ endpoint: 'ws://localhost:8666/rpc',
23
+ store: 'indexeddb',
24
+ },
25
+ };
26
+ ```
27
+
28
+ ```tsx
29
+ // App.tsx
30
+ <Sp00kyProvider config={dbConfig} fallback={<Splash />}>{/* app */}</Sp00kyProvider>
31
+ ```
32
+
33
+ Provider props are identical to client-solid: `config`, `fallback`, `preload` (awaited gate before children render), `onReady`, `onError`. A mounted client is deliberately not closed on unmount.
34
+
35
+ ## Key API
36
+
37
+ - **`useDb<typeof schema>()`** — the `SyncedDb<S>` instance. Same surface as client-solid: `create`, `update`, `delete`, `query`, `preload`, `run`, `bucket`, `useRemote`, `auth`/`signOut`, `pendingMutationCount`, `subscribeToPendingMutations`, `syncHealth`/`storageHealth` + their subscribe methods.
38
+ - **`createQuery(query, options?)`** (or `createQuery(db, query, options?)`) — reactive query. `useQuery` is a deprecated alias. Pass a thunk when the query depends on signals; read the signals inside it.
39
+ - `data()` — non-suspending. `[]` / `null` before the first result, keyed-reconciled live rows after.
40
+ - `ready()` — suspending read for `<Loading fallback={...}>` (Solid 2's renamed `<Suspense>`).
41
+ - `error()` — registration/sync failure (e.g. SSP 503 NOT_READY). Never thrown into the render tree; the sync scheduler retries underneath.
42
+ - `isLoading()` / `isFetching()` / `isSettled()` — `isSettled()` means delivered AND idle; gate windowed-list end detection on it.
43
+ - Options: `enabled?: () => boolean`, `deregisterOnCleanup?: boolean`.
44
+ - **`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`).
45
+ - **`createPreload`**, **`usePendingMutations`**, **`useSyncStatus`**, **`useStorageStatus`**, **`useFeatureFlag`**, **`useAppRelease`**, **`useCrdtField`**, **`useFileUpload`**, **`useDownloadFile`** — same shapes as client-solid.
46
+ - **`conflate`**, **`fromSubscription`** — the async-iterable helpers the hooks are built on; useful for wrapping other engine subscriptions.
47
+
48
+ ## Mutations
49
+
50
+ The engine is optimistic local-first end to end (local commit → live queries re-emit → outbox sync; `db.run()` writes an outbox job row with `status: 'pending'`). So mutations are plain awaited calls, no optimistic-update layer on top:
51
+
52
+ ```ts
53
+ await db.create('post:' + crypto.randomUUID(), { title: 'Hi' });
54
+ await db.update('post', 'post:xyz', { title: 'Edited' });
55
+ await db.delete('post', row.id);
56
+ await db.run('backend', 'sendMail', { to });
57
+ ```
58
+
59
+ Track a backend job by querying its outbox row: `createQuery(() => db.query('job_outbox').where({ id: jobId() }).one().build())`.
60
+
61
+ ## Common gotchas
62
+
63
+ - **Rows are store proxies.** Solid 2 stores wrap class instances too and serve their methods **bound**, so a `RecordId` read out of a row reports `constructor.name === 'bound RecordId'`. `db.delete('post', row.id)` handles that; for surrealdb APIs that check `instanceof`, unwrap first with `snapshot(row)` from `solid-js`.
64
+ - **`<Loading>`, not `<Suspense>`.** Only `ready()` suspends; `data()` never does, which is what keeps local-first cache paints instant.
65
+ - **`data()` is never `undefined`.** It is born `[]` / `null` (`seedLoadingValue`), unlike client-solid. Branch on `isLoading()`, not on nullish data.
66
+ - **Coordinated RC.** `solid-js`, `@solidjs/web`, `@solidjs/signals` must be on matching `2.0.0-rc.x` versions. After any Solid bump, run `src/lib/__tests__/rc-semantics.test.ts` — it probes the RC contracts this package depends on.
67
+ - **Vitest resolves the SSR build.** Under Node, `solid-js`'s `node` export condition gives you the SSR build where user effects never run. Set `resolve.conditions: ['browser', 'development']` (see this package's `vitest.config.ts`).
68
+ - **`createQuery` factories must call `.build()`** (or `.all()`, `.one()`, …). A bare `db.query('post')` is a builder, not a query.
69
+ - **Provider is mandatory.** Any hook outside `<Sp00kyProvider>` throws.
70
+
71
+ ## Pointers
72
+
73
+ - Usage walkthrough + migration table: `QUICK_START.md` (same content as the docs' [Solid 2 guide](https://mono424.github.io/sp00ky/docs/guide/solid2))
74
+ - Skill for coding agents: `skills/sp00ky-solid2/SKILL.md`
75
+ - Solid 1.x binding: `node_modules/@spooky-sync/client-solid/AGENTS.md`
76
+ - Sync engine: `node_modules/@spooky-sync/core/AGENTS.md`
77
+ - Query builder DSL: `node_modules/@spooky-sync/query-builder/AGENTS.md`
package/README.md CHANGED
@@ -6,8 +6,8 @@ Counterpart of `@spooky-sync/client-solid` (Solid 1.x); the two coexist until So
6
6
 
7
7
  What "native" means here:
8
8
 
9
- - Query results are a `createProjection` fed by an async generator over the engine's live subscription: keyed reconcile by `id`, row identity preserved, coarse `<For>` readers notified no manual reconcile/version-signal plumbing.
10
- - `createQuery` exposes both worlds: non-suspending accessors (`data`, `isLoading`, `isFetching`, `isSettled`, `error`) and a suspending `ready()` for `<Loading>` boundaries. Born committed (`seedLoadingValue`), so local-first cache paints never suspend.
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
11
  - Status hooks (`useSyncStatus`, `useStorageStatus`, `usePendingMutations`, feature flags, app release) are async-iterable-backed memos with `loadingValue`.
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
 
package/dist/index.cjs CHANGED
@@ -125,6 +125,42 @@ function usePendingMutations() {
125
125
  return fromSubscription((cb) => db.subscribeToPendingMutations(cb), db.pendingMutationCount);
126
126
  }
127
127
 
128
+ //#endregion
129
+ //#region src/lib/merge-rows.ts
130
+ /**
131
+ * Keyed in-place merge of a fresh row list into a store draft array.
132
+ *
133
+ * Why not `reconcile(rows, 'id')`: in Solid 2 rc.1 a reconcile REPLACES the row
134
+ * objects, so a component that captured `data()[0]` sees a different object
135
+ * after the next emission, and anything keyed on row identity (`<For>` without
136
+ * an explicit key, a memo comparing rows) re-creates its subtree on every live
137
+ * update. Mutating the draft in place keeps the identity AND keeps updates
138
+ * fine-grained: only the fields that actually changed are written, so a row
139
+ * whose data is unchanged notifies nobody.
140
+ *
141
+ * Rows are matched by `key` (default `id`); unmatched incoming rows are
142
+ * inserted as-is and trailing rows are dropped, so add / remove / reorder all
143
+ * reach coarse readers.
144
+ */
145
+ function mergeRows(draft, next, key = "id") {
146
+ const byKey = /* @__PURE__ */ new Map();
147
+ for (const row of draft) {
148
+ const k = row?.[key];
149
+ if (k !== void 0) byKey.set(String(k), row);
150
+ }
151
+ for (let i = 0; i < next.length; i++) {
152
+ const incoming = next[i];
153
+ const k = incoming?.[key];
154
+ const reuse = k !== void 0 ? byKey.get(String(k)) : void 0;
155
+ if (reuse) {
156
+ for (const field of Object.keys(incoming)) if (reuse[field] !== incoming[field]) reuse[field] = incoming[field];
157
+ for (const field of Object.keys((0, solid_js.snapshot)(reuse))) if (!(field in incoming)) delete reuse[field];
158
+ if (draft[i] !== reuse) draft[i] = reuse;
159
+ } else if (draft[i] !== incoming) draft[i] = incoming;
160
+ }
161
+ if (draft.length > next.length) draft.splice(next.length);
162
+ }
163
+
128
164
  //#endregion
129
165
  //#region src/lib/create-query.ts
130
166
  function createQuery(dbOrQuery, queryOrOptions, maybeOptions) {
@@ -145,54 +181,76 @@ function createQuery(dbOrQuery, queryOrOptions, maybeOptions) {
145
181
  const [isFetched, setIsFetched] = (0, solid_js.createSignal)(false, { ownedWrite: true });
146
182
  const [isFetching, setIsFetching] = (0, solid_js.createSignal)(false, { ownedWrite: true });
147
183
  let activeHash;
148
- const store = (0, solid_js.createProjection)(async function* () {
149
- const enabled = options?.enabled?.() ?? true;
150
- const query = typeof finalQuery === "function" ? finalQuery() : finalQuery;
151
- if (!enabled || !query) {
152
- setIsFetched(false);
153
- setError(void 0);
154
- return;
155
- }
184
+ const [store, setStore] = (0, solid_js.createStore)({ value: null });
185
+ let runId = 0;
186
+ let readyWaiters = [];
187
+ const wakeReady = () => {
188
+ const waiters = readyWaiters;
189
+ readyWaiters = [];
190
+ for (const w of waiters) w();
191
+ };
192
+ (0, solid_js.createEffect)(() => {
193
+ return {
194
+ enabled: options?.enabled?.() ?? true,
195
+ query: typeof finalQuery === "function" ? finalQuery() : finalQuery
196
+ };
197
+ }, ({ enabled, query }) => {
198
+ const myRun = ++runId;
156
199
  setIsFetched(false);
157
200
  setError(void 0);
158
- const iterators = [];
201
+ if (!enabled || !query) return;
159
202
  const cleanups = [];
160
- (0, solid_js.onCleanup)(() => {
161
- for (const it of iterators) it.return?.();
162
- for (const c of cleanups) c();
163
- });
164
- try {
165
- /**
166
- * Registration can fail — the canonical case is the SSP answering 503
167
- * NOT_READY while it bootstraps. Surface it as `error()` instead of
168
- * throwing into the graph: the sync scheduler retries the
169
- * registration underneath, so a transient failure still recovers, and
170
- * a spinner driven by `isLoading()` resolves via `error()`.
171
- */
172
- const { hash } = await query.run();
203
+ let disposed = false;
204
+ const addCleanup = (c) => {
205
+ if (!c) return;
206
+ if (typeof c === "function") {
207
+ if (disposed) c();
208
+ else cleanups.push(c);
209
+ return;
210
+ }
211
+ Promise.resolve(c).then((fn) => {
212
+ if (typeof fn !== "function") return;
213
+ if (disposed) fn();
214
+ else cleanups.push(fn);
215
+ });
216
+ };
217
+ /**
218
+ * Registration can fail — the canonical case is the SSP answering 503
219
+ * NOT_READY while it bootstraps. Surface it as `error()` instead of
220
+ * throwing into the graph: the sync scheduler retries the registration
221
+ * underneath, so a transient failure still recovers, and a spinner
222
+ * driven by `isLoading()` resolves via `error()`.
223
+ */
224
+ query.run().then(({ hash }) => {
225
+ if (disposed || myRun !== runId) return;
173
226
  activeHash = hash;
174
- cleanups.push(sp00ky.subscribeQueryStatus(hash, (status) => setIsFetching(status === "fetching"), { immediate: true }));
175
- const it = conflate((cb) => sp00ky.subscribe(hash, cb, { immediate: true }))[Symbol.asyncIterator]();
176
- iterators.push(it);
227
+ addCleanup(sp00ky.subscribeQueryStatus(hash, (status) => setIsFetching(status === "fetching"), { immediate: true }));
177
228
  let isFirstCall = true;
178
- while (true) {
179
- const r = await it.next();
180
- if (r.done) break;
181
- const e = r.value;
182
- const queryData = query.isOne ? e[0] ?? null : e;
183
- const hasData = query.isOne ? queryData !== null && queryData !== void 0 : e.length > 0;
184
- if (!isFirstCall || hasData) setIsFetched(true);
229
+ addCleanup(sp00ky.subscribe(hash, (rows) => {
230
+ if (disposed || myRun !== runId) return;
231
+ const queryData = query.isOne ? rows[0] ?? null : rows;
232
+ const hasData = query.isOne ? queryData !== null && queryData !== void 0 : rows.length > 0;
233
+ if (!isFirstCall || hasData) {
234
+ setIsFetched(true);
235
+ wakeReady();
236
+ }
185
237
  isFirstCall = false;
186
238
  const t0 = performance.now();
187
- yield { value: queryData };
239
+ setStore((s) => {
240
+ if (query.isOne || queryData === null || !Array.isArray(s.value)) s.value = queryData;
241
+ else mergeRows(s.value, queryData);
242
+ });
188
243
  sp00ky.reportFrontendTiming(hash, performance.now() - t0);
189
- }
190
- } catch (err) {
244
+ }, { immediate: true }));
245
+ }).catch((err) => {
246
+ if (disposed || myRun !== runId) return;
191
247
  setError(err instanceof Error ? err : new Error(String(err)));
192
- }
193
- }, { value: null }, {
194
- key: "id",
195
- seedLoadingValue: true
248
+ wakeReady();
249
+ });
250
+ return () => {
251
+ disposed = true;
252
+ for (const c of cleanups) c();
253
+ };
196
254
  });
197
255
  const emptyList = [];
198
256
  const data = () => {
@@ -205,9 +263,9 @@ function createQuery(dbOrQuery, queryOrOptions, maybeOptions) {
205
263
  };
206
264
  const readyGate = (0, solid_js.createMemo)(async () => {
207
265
  if (isFetched() || error()) return true;
208
- await new Promise(() => {});
266
+ await new Promise((resolve) => readyWaiters.push(resolve));
209
267
  return true;
210
- });
268
+ }, { lazy: true });
211
269
  const ready = () => {
212
270
  readyGate();
213
271
  return data();
@@ -619,6 +677,222 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
619
677
  };
620
678
  }
621
679
 
680
+ //#endregion
681
+ //#region src/lib/use-blurhash.ts
682
+ function useBlurhash(dbOrBucketName, bucketNameOrPath, maybePath) {
683
+ let db;
684
+ let bucketName;
685
+ let path;
686
+ if (typeof dbOrBucketName === "string") {
687
+ db = useDb();
688
+ bucketName = dbOrBucketName;
689
+ path = bucketNameOrPath;
690
+ } else {
691
+ db = dbOrBucketName;
692
+ bucketName = bucketNameOrPath;
693
+ path = maybePath;
694
+ }
695
+ const [hash, setHash] = (0, solid_js.createSignal)(null, { ownedWrite: true });
696
+ const [isLoading, setIsLoading] = (0, solid_js.createSignal)(false, { ownedWrite: true });
697
+ (0, solid_js.createEffect)(() => path(), (filePath) => {
698
+ if (!filePath) {
699
+ setHash(null);
700
+ setIsLoading(false);
701
+ return;
702
+ }
703
+ let cancelled = false;
704
+ setIsLoading(true);
705
+ db.bucket(bucketName).blurhash(filePath).then((result) => {
706
+ if (cancelled) return;
707
+ setHash(result);
708
+ setIsLoading(false);
709
+ }).catch(() => {
710
+ if (cancelled) return;
711
+ setHash(null);
712
+ setIsLoading(false);
713
+ });
714
+ return () => {
715
+ cancelled = true;
716
+ };
717
+ });
718
+ return {
719
+ hash,
720
+ isLoading
721
+ };
722
+ }
723
+
724
+ //#endregion
725
+ //#region src/lib/use-bucket-image.ts
726
+ function useBucketImage(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeOptions) {
727
+ let db;
728
+ let bucketName;
729
+ let path;
730
+ let options;
731
+ if (typeof dbOrBucketName === "string") {
732
+ db = useDb();
733
+ bucketName = dbOrBucketName;
734
+ path = bucketNameOrPath;
735
+ options = pathOrOptions ?? {};
736
+ } else {
737
+ db = dbOrBucketName;
738
+ bucketName = bucketNameOrPath;
739
+ path = pathOrOptions;
740
+ options = maybeOptions ?? {};
741
+ }
742
+ const wantHash = options.blurhash !== false;
743
+ const { hash } = useBlurhash(db, bucketName, () => wantHash ? path() : null);
744
+ const file = useDownloadFile(db, bucketName, path, options);
745
+ const [ready, setReady] = (0, solid_js.createSignal)(false, { ownedWrite: true });
746
+ (0, solid_js.createEffect)(file.url, () => {
747
+ setReady(false);
748
+ }, { defer: true });
749
+ const gate = (img) => {
750
+ const done = () => setReady(true);
751
+ if (typeof img.decode === "function") img.decode().then(done, done);
752
+ else if (img.complete) done();
753
+ else {
754
+ img.onload = done;
755
+ img.onerror = done;
756
+ }
757
+ };
758
+ return {
759
+ ...file,
760
+ blurhash: hash,
761
+ ready,
762
+ gate
763
+ };
764
+ }
765
+
766
+ //#endregion
767
+ //#region src/lib/Blurhash.ts
768
+ /**
769
+ * A blurhash painted onto a canvas, once per hash change. Size the canvas via
770
+ * `class`/`style` (e.g. `absolute inset-0 w-full h-full`); the internal decode
771
+ * resolution stays tiny regardless of the displayed size.
772
+ */
773
+ function Blurhash(props) {
774
+ if (typeof document === "undefined") return null;
775
+ const canvas = document.createElement("canvas");
776
+ (0, solid_js.createEffect)(() => props.class ?? "", (className) => {
777
+ canvas.className = className;
778
+ });
779
+ (0, solid_js.createEffect)(() => props.style ?? "", (style) => {
780
+ canvas.style.cssText = style;
781
+ });
782
+ (0, solid_js.createEffect)(() => ({
783
+ width: props.width ?? 32,
784
+ height: props.height ?? 32,
785
+ hash: props.hash,
786
+ punch: props.punch ?? 1
787
+ }), ({ width, height, hash, punch }) => {
788
+ canvas.width = width;
789
+ canvas.height = height;
790
+ if (!hash) return;
791
+ try {
792
+ const pixels = (0, _spooky_sync_core.decodeBlurhash)(hash, width, height, punch);
793
+ const ctx = canvas.getContext("2d");
794
+ if (!ctx) return;
795
+ const imageData = ctx.createImageData(width, height);
796
+ imageData.data.set(pixels);
797
+ ctx.putImageData(imageData, 0, 0);
798
+ } catch {}
799
+ });
800
+ return canvas;
801
+ }
802
+
803
+ //#endregion
804
+ //#region src/lib/BucketImage.ts
805
+ const LAYER_STYLE = "position:absolute;inset:0;width:100%;height:100%;";
806
+ /**
807
+ * A bucket image that never pops in: it layers (bottom to top) your `fallback`
808
+ * plate, the automatically stored blurhash, and the real image, which stays
809
+ * transparent until the bitmap is DECODED and then crossfades over the
810
+ * placeholders. Placeholder layers unmount once the fade settles. Respects
811
+ * prefers-reduced-motion (instant swap). The container is made
812
+ * `position: relative` unless your `class` positions it already.
813
+ *
814
+ * ```tsx
815
+ * <BucketImage bucket="covers" path={row.cover_key} class="absolute inset-0"
816
+ * fallback={<MyPlate />} alt="" />
817
+ * ```
818
+ */
819
+ function BucketImage(props) {
820
+ if (typeof document === "undefined") return null;
821
+ const image = useBucketImage(props.bucket, () => props.path, {
822
+ ...props.options,
823
+ blurhash: props.blurhash !== false
824
+ });
825
+ const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
826
+ const root = document.createElement("div");
827
+ (0, solid_js.createEffect)(() => props.class ?? "", (className) => {
828
+ root.className = className;
829
+ });
830
+ (0, solid_js.onSettled)(() => {
831
+ if (getComputedStyle(root).position === "static") root.style.position = "relative";
832
+ });
833
+ const placeholder = document.createElement("div");
834
+ placeholder.style.cssText = LAYER_STYLE;
835
+ const fallbackHolder = document.createElement("div");
836
+ fallbackHolder.style.cssText = LAYER_STYLE;
837
+ placeholder.append(fallbackHolder);
838
+ const resolvedFallback = (0, solid_js.children)(() => props.fallback);
839
+ (0, solid_js.createEffect)(() => resolvedFallback.toArray().filter((node) => node instanceof Node), (nodes) => {
840
+ fallbackHolder.replaceChildren(...nodes);
841
+ });
842
+ const hashCanvas = Blurhash({
843
+ get hash() {
844
+ return image.blurhash();
845
+ },
846
+ style: LAYER_STYLE
847
+ });
848
+ if (hashCanvas instanceof Node) placeholder.append(hashCanvas);
849
+ const img = document.createElement("img");
850
+ img.decoding = "async";
851
+ img.style.cssText = `${LAYER_STYLE}opacity:0;`;
852
+ (0, solid_js.createEffect)(() => props.imgClass ?? "", (className) => {
853
+ img.className = className;
854
+ });
855
+ (0, solid_js.createEffect)(() => props.fit ?? "cover", (fit) => {
856
+ img.style.objectFit = fit;
857
+ });
858
+ (0, solid_js.createEffect)(() => props.alt ?? "", (alt) => {
859
+ img.alt = alt;
860
+ });
861
+ (0, solid_js.createEffect)(() => reducedMotion ? "none" : `opacity ${props.transition ?? 300}ms ${props.easing ?? "cubic-bezier(0.16, 1, 0.3, 1)"}`, (transition) => {
862
+ img.style.transition = transition;
863
+ });
864
+ (0, solid_js.createEffect)(() => image.url(), (url) => {
865
+ if (!url) {
866
+ img.removeAttribute("src");
867
+ return;
868
+ }
869
+ img.src = url;
870
+ image.gate(img);
871
+ });
872
+ (0, solid_js.createEffect)(() => image.ready(), (ready) => {
873
+ img.style.opacity = ready ? "1" : "0";
874
+ });
875
+ const [settled, setSettled] = (0, solid_js.createSignal)(false, { ownedWrite: true });
876
+ (0, solid_js.createEffect)(() => ({
877
+ ready: image.ready(),
878
+ transition: props.transition ?? 300
879
+ }), ({ ready, transition }) => {
880
+ if (!ready) {
881
+ setSettled(false);
882
+ return;
883
+ }
884
+ const wait = (reducedMotion ? 0 : transition) + 120;
885
+ const timer = setTimeout(() => setSettled(true), wait);
886
+ return () => clearTimeout(timer);
887
+ });
888
+ (0, solid_js.createEffect)(() => settled(), (isSettled) => {
889
+ if (isSettled) placeholder.remove();
890
+ else if (!placeholder.isConnected) root.insertBefore(placeholder, img);
891
+ });
892
+ root.append(placeholder, img);
893
+ return root;
894
+ }
895
+
622
896
  //#endregion
623
897
  //#region src/lib/Sp00kyProvider.ts
624
898
  function Sp00kyProvider(props) {
@@ -713,7 +987,20 @@ function createSubmission(fn) {
713
987
  * NOTE: keep in sync with packages/client-solid/src/index.ts (SyncedDb).
714
988
  * Copied rather than shared so this package's dependency graph never pulls
715
989
  * in solid-js 1.x; fold the two together once client-solid moves to Solid 2.
990
+ * Solid-2-only differences: `delete` accepts 'bound RecordId', and write
991
+ * payloads go through `snapshot()` (see `unproxy` below).
716
992
  */
993
+ /**
994
+ * Solid 2 stores wrap every object read out of them (query rows, their nested
995
+ * arrays, `createStore` docs) in a Proxy. Those proxies cannot cross
996
+ * `postMessage` (the sqlite and shared-tabs workers): structuredClone throws
997
+ * DataCloneError. `snapshot()` returns the underlying plain value for store
998
+ * proxies and passes anything else through untouched.
999
+ */
1000
+ function unproxy(value) {
1001
+ if (value === null || typeof value !== "object") return value;
1002
+ return (0, solid_js.snapshot)(value);
1003
+ }
717
1004
  var SyncedDb = class {
718
1005
  constructor(config) {
719
1006
  this.sp00ky = null;
@@ -750,25 +1037,26 @@ var SyncedDb = class {
750
1037
  */
751
1038
  async create(id, payload) {
752
1039
  if (!this.sp00ky) throw new Error("SyncedDb not initialized");
753
- await this.sp00ky.create(id, payload);
1040
+ await this.sp00ky.create(id, unproxy(payload));
754
1041
  }
755
1042
  /**
756
1043
  * Update an existing record in the database
757
1044
  */
758
1045
  async update(tableName, recordId, payload, options) {
759
1046
  if (!this.sp00ky) throw new Error("SyncedDb not initialized");
760
- await this.sp00ky.update(tableName, recordId, payload, options);
1047
+ await this.sp00ky.update(tableName, recordId, unproxy(payload), options);
761
1048
  }
762
1049
  /**
763
1050
  * Delete an existing record in the database
764
1051
  */
765
1052
  async delete(tableName, selector) {
766
1053
  if (!this.sp00ky) throw new Error("SyncedDb not initialized");
767
- const ctorName = selector?.constructor?.name;
768
- const isRecordId = selector instanceof surrealdb.RecordId || ctorName === "RecordId" || ctorName === "bound RecordId";
1054
+ const raw = unproxy(selector);
1055
+ const ctorName = raw?.constructor?.name;
1056
+ const isRecordId = raw instanceof surrealdb.RecordId || ctorName === "RecordId" || ctorName === "bound RecordId";
769
1057
  let id;
770
- if (typeof selector === "string") id = selector;
771
- else if (isRecordId) id = `${tableName}:${selector.id}`;
1058
+ if (typeof raw === "string") id = raw;
1059
+ else if (isRecordId) id = `${tableName}:${raw.id}`;
772
1060
  else throw new Error("Only string ID or RecordId selectors are supported currently with core");
773
1061
  await this.sp00ky.delete(tableName, id);
774
1062
  }
@@ -793,7 +1081,7 @@ var SyncedDb = class {
793
1081
  */
794
1082
  async run(backend, path, payload, options) {
795
1083
  if (!this.sp00ky) throw new Error("SyncedDb not initialized");
796
- await this.sp00ky.run(backend, path, payload, options);
1084
+ await this.sp00ky.run(backend, path, unproxy(payload), options);
797
1085
  }
798
1086
  /**
799
1087
  * Sign out, clear session and local storage
@@ -881,6 +1169,8 @@ var SyncedDb = class {
881
1169
  };
882
1170
 
883
1171
  //#endregion
1172
+ exports.Blurhash = Blurhash;
1173
+ exports.BucketImage = BucketImage;
884
1174
  exports.RecordId = surrealdb.RecordId;
885
1175
  exports.Sp00kyProvider = Sp00kyProvider;
886
1176
  exports.SyncedDb = SyncedDb;
@@ -891,6 +1181,8 @@ exports.createQuery = createQuery;
891
1181
  exports.createSubmission = createSubmission;
892
1182
  exports.fromSubscription = fromSubscription;
893
1183
  exports.useAppRelease = useAppRelease;
1184
+ exports.useBlurhash = useBlurhash;
1185
+ exports.useBucketImage = useBucketImage;
894
1186
  exports.useCrdtField = useCrdtField;
895
1187
  exports.useDb = useDb;
896
1188
  exports.useDownloadFile = useDownloadFile;