@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/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { Sp00kyClient, fileToUint8Array, semverGt } from "@spooky-sync/core";
1
+ import { Sp00kyClient, decodeBlurhash, fileToUint8Array, semverGt } from "@spooky-sync/core";
2
2
  import { RecordId, Uuid } from "surrealdb";
3
- import { createComponent, createContext, createEffect, createMemo, createProjection, createSignal, merge, onCleanup, onSettled, useContext } from "solid-js";
3
+ import { children, createComponent, createContext, createEffect, createMemo, createSignal, createStore, merge, onCleanup, onSettled, snapshot, useContext } from "solid-js";
4
4
 
5
5
  //#region src/lib/conflate.ts
6
6
  /**
@@ -124,6 +124,42 @@ function usePendingMutations() {
124
124
  return fromSubscription((cb) => db.subscribeToPendingMutations(cb), db.pendingMutationCount);
125
125
  }
126
126
 
127
+ //#endregion
128
+ //#region src/lib/merge-rows.ts
129
+ /**
130
+ * Keyed in-place merge of a fresh row list into a store draft array.
131
+ *
132
+ * Why not `reconcile(rows, 'id')`: in Solid 2 rc.1 a reconcile REPLACES the row
133
+ * objects, so a component that captured `data()[0]` sees a different object
134
+ * after the next emission, and anything keyed on row identity (`<For>` without
135
+ * an explicit key, a memo comparing rows) re-creates its subtree on every live
136
+ * update. Mutating the draft in place keeps the identity AND keeps updates
137
+ * fine-grained: only the fields that actually changed are written, so a row
138
+ * whose data is unchanged notifies nobody.
139
+ *
140
+ * Rows are matched by `key` (default `id`); unmatched incoming rows are
141
+ * inserted as-is and trailing rows are dropped, so add / remove / reorder all
142
+ * reach coarse readers.
143
+ */
144
+ function mergeRows(draft, next, key = "id") {
145
+ const byKey = /* @__PURE__ */ new Map();
146
+ for (const row of draft) {
147
+ const k = row?.[key];
148
+ if (k !== void 0) byKey.set(String(k), row);
149
+ }
150
+ for (let i = 0; i < next.length; i++) {
151
+ const incoming = next[i];
152
+ const k = incoming?.[key];
153
+ const reuse = k !== void 0 ? byKey.get(String(k)) : void 0;
154
+ if (reuse) {
155
+ for (const field of Object.keys(incoming)) if (reuse[field] !== incoming[field]) reuse[field] = incoming[field];
156
+ for (const field of Object.keys(snapshot(reuse))) if (!(field in incoming)) delete reuse[field];
157
+ if (draft[i] !== reuse) draft[i] = reuse;
158
+ } else if (draft[i] !== incoming) draft[i] = incoming;
159
+ }
160
+ if (draft.length > next.length) draft.splice(next.length);
161
+ }
162
+
127
163
  //#endregion
128
164
  //#region src/lib/create-query.ts
129
165
  function createQuery(dbOrQuery, queryOrOptions, maybeOptions) {
@@ -144,54 +180,76 @@ function createQuery(dbOrQuery, queryOrOptions, maybeOptions) {
144
180
  const [isFetched, setIsFetched] = createSignal(false, { ownedWrite: true });
145
181
  const [isFetching, setIsFetching] = createSignal(false, { ownedWrite: true });
146
182
  let activeHash;
147
- const store = createProjection(async function* () {
148
- const enabled = options?.enabled?.() ?? true;
149
- const query = typeof finalQuery === "function" ? finalQuery() : finalQuery;
150
- if (!enabled || !query) {
151
- setIsFetched(false);
152
- setError(void 0);
153
- return;
154
- }
183
+ const [store, setStore] = createStore({ value: null });
184
+ let runId = 0;
185
+ let readyWaiters = [];
186
+ const wakeReady = () => {
187
+ const waiters = readyWaiters;
188
+ readyWaiters = [];
189
+ for (const w of waiters) w();
190
+ };
191
+ createEffect(() => {
192
+ return {
193
+ enabled: options?.enabled?.() ?? true,
194
+ query: typeof finalQuery === "function" ? finalQuery() : finalQuery
195
+ };
196
+ }, ({ enabled, query }) => {
197
+ const myRun = ++runId;
155
198
  setIsFetched(false);
156
199
  setError(void 0);
157
- const iterators = [];
200
+ if (!enabled || !query) return;
158
201
  const cleanups = [];
159
- onCleanup(() => {
160
- for (const it of iterators) it.return?.();
161
- for (const c of cleanups) c();
162
- });
163
- try {
164
- /**
165
- * Registration can fail — the canonical case is the SSP answering 503
166
- * NOT_READY while it bootstraps. Surface it as `error()` instead of
167
- * throwing into the graph: the sync scheduler retries the
168
- * registration underneath, so a transient failure still recovers, and
169
- * a spinner driven by `isLoading()` resolves via `error()`.
170
- */
171
- const { hash } = await query.run();
202
+ let disposed = false;
203
+ const addCleanup = (c) => {
204
+ if (!c) return;
205
+ if (typeof c === "function") {
206
+ if (disposed) c();
207
+ else cleanups.push(c);
208
+ return;
209
+ }
210
+ Promise.resolve(c).then((fn) => {
211
+ if (typeof fn !== "function") return;
212
+ if (disposed) fn();
213
+ else cleanups.push(fn);
214
+ });
215
+ };
216
+ /**
217
+ * Registration can fail — the canonical case is the SSP answering 503
218
+ * NOT_READY while it bootstraps. Surface it as `error()` instead of
219
+ * throwing into the graph: the sync scheduler retries the registration
220
+ * underneath, so a transient failure still recovers, and a spinner
221
+ * driven by `isLoading()` resolves via `error()`.
222
+ */
223
+ query.run().then(({ hash }) => {
224
+ if (disposed || myRun !== runId) return;
172
225
  activeHash = hash;
173
- cleanups.push(sp00ky.subscribeQueryStatus(hash, (status) => setIsFetching(status === "fetching"), { immediate: true }));
174
- const it = conflate((cb) => sp00ky.subscribe(hash, cb, { immediate: true }))[Symbol.asyncIterator]();
175
- iterators.push(it);
226
+ addCleanup(sp00ky.subscribeQueryStatus(hash, (status) => setIsFetching(status === "fetching"), { immediate: true }));
176
227
  let isFirstCall = true;
177
- while (true) {
178
- const r = await it.next();
179
- if (r.done) break;
180
- const e = r.value;
181
- const queryData = query.isOne ? e[0] ?? null : e;
182
- const hasData = query.isOne ? queryData !== null && queryData !== void 0 : e.length > 0;
183
- if (!isFirstCall || hasData) setIsFetched(true);
228
+ addCleanup(sp00ky.subscribe(hash, (rows) => {
229
+ if (disposed || myRun !== runId) return;
230
+ const queryData = query.isOne ? rows[0] ?? null : rows;
231
+ const hasData = query.isOne ? queryData !== null && queryData !== void 0 : rows.length > 0;
232
+ if (!isFirstCall || hasData) {
233
+ setIsFetched(true);
234
+ wakeReady();
235
+ }
184
236
  isFirstCall = false;
185
237
  const t0 = performance.now();
186
- yield { value: queryData };
238
+ setStore((s) => {
239
+ if (query.isOne || queryData === null || !Array.isArray(s.value)) s.value = queryData;
240
+ else mergeRows(s.value, queryData);
241
+ });
187
242
  sp00ky.reportFrontendTiming(hash, performance.now() - t0);
188
- }
189
- } catch (err) {
243
+ }, { immediate: true }));
244
+ }).catch((err) => {
245
+ if (disposed || myRun !== runId) return;
190
246
  setError(err instanceof Error ? err : new Error(String(err)));
191
- }
192
- }, { value: null }, {
193
- key: "id",
194
- seedLoadingValue: true
247
+ wakeReady();
248
+ });
249
+ return () => {
250
+ disposed = true;
251
+ for (const c of cleanups) c();
252
+ };
195
253
  });
196
254
  const emptyList = [];
197
255
  const data = () => {
@@ -204,9 +262,9 @@ function createQuery(dbOrQuery, queryOrOptions, maybeOptions) {
204
262
  };
205
263
  const readyGate = createMemo(async () => {
206
264
  if (isFetched() || error()) return true;
207
- await new Promise(() => {});
265
+ await new Promise((resolve) => readyWaiters.push(resolve));
208
266
  return true;
209
- });
267
+ }, { lazy: true });
210
268
  const ready = () => {
211
269
  readyGate();
212
270
  return data();
@@ -618,6 +676,222 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
618
676
  };
619
677
  }
620
678
 
679
+ //#endregion
680
+ //#region src/lib/use-blurhash.ts
681
+ function useBlurhash(dbOrBucketName, bucketNameOrPath, maybePath) {
682
+ let db;
683
+ let bucketName;
684
+ let path;
685
+ if (typeof dbOrBucketName === "string") {
686
+ db = useDb();
687
+ bucketName = dbOrBucketName;
688
+ path = bucketNameOrPath;
689
+ } else {
690
+ db = dbOrBucketName;
691
+ bucketName = bucketNameOrPath;
692
+ path = maybePath;
693
+ }
694
+ const [hash, setHash] = createSignal(null, { ownedWrite: true });
695
+ const [isLoading, setIsLoading] = createSignal(false, { ownedWrite: true });
696
+ createEffect(() => path(), (filePath) => {
697
+ if (!filePath) {
698
+ setHash(null);
699
+ setIsLoading(false);
700
+ return;
701
+ }
702
+ let cancelled = false;
703
+ setIsLoading(true);
704
+ db.bucket(bucketName).blurhash(filePath).then((result) => {
705
+ if (cancelled) return;
706
+ setHash(result);
707
+ setIsLoading(false);
708
+ }).catch(() => {
709
+ if (cancelled) return;
710
+ setHash(null);
711
+ setIsLoading(false);
712
+ });
713
+ return () => {
714
+ cancelled = true;
715
+ };
716
+ });
717
+ return {
718
+ hash,
719
+ isLoading
720
+ };
721
+ }
722
+
723
+ //#endregion
724
+ //#region src/lib/use-bucket-image.ts
725
+ function useBucketImage(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeOptions) {
726
+ let db;
727
+ let bucketName;
728
+ let path;
729
+ let options;
730
+ if (typeof dbOrBucketName === "string") {
731
+ db = useDb();
732
+ bucketName = dbOrBucketName;
733
+ path = bucketNameOrPath;
734
+ options = pathOrOptions ?? {};
735
+ } else {
736
+ db = dbOrBucketName;
737
+ bucketName = bucketNameOrPath;
738
+ path = pathOrOptions;
739
+ options = maybeOptions ?? {};
740
+ }
741
+ const wantHash = options.blurhash !== false;
742
+ const { hash } = useBlurhash(db, bucketName, () => wantHash ? path() : null);
743
+ const file = useDownloadFile(db, bucketName, path, options);
744
+ const [ready, setReady] = createSignal(false, { ownedWrite: true });
745
+ createEffect(file.url, () => {
746
+ setReady(false);
747
+ }, { defer: true });
748
+ const gate = (img) => {
749
+ const done = () => setReady(true);
750
+ if (typeof img.decode === "function") img.decode().then(done, done);
751
+ else if (img.complete) done();
752
+ else {
753
+ img.onload = done;
754
+ img.onerror = done;
755
+ }
756
+ };
757
+ return {
758
+ ...file,
759
+ blurhash: hash,
760
+ ready,
761
+ gate
762
+ };
763
+ }
764
+
765
+ //#endregion
766
+ //#region src/lib/Blurhash.ts
767
+ /**
768
+ * A blurhash painted onto a canvas, once per hash change. Size the canvas via
769
+ * `class`/`style` (e.g. `absolute inset-0 w-full h-full`); the internal decode
770
+ * resolution stays tiny regardless of the displayed size.
771
+ */
772
+ function Blurhash(props) {
773
+ if (typeof document === "undefined") return null;
774
+ const canvas = document.createElement("canvas");
775
+ createEffect(() => props.class ?? "", (className) => {
776
+ canvas.className = className;
777
+ });
778
+ createEffect(() => props.style ?? "", (style) => {
779
+ canvas.style.cssText = style;
780
+ });
781
+ createEffect(() => ({
782
+ width: props.width ?? 32,
783
+ height: props.height ?? 32,
784
+ hash: props.hash,
785
+ punch: props.punch ?? 1
786
+ }), ({ width, height, hash, punch }) => {
787
+ canvas.width = width;
788
+ canvas.height = height;
789
+ if (!hash) return;
790
+ try {
791
+ const pixels = decodeBlurhash(hash, width, height, punch);
792
+ const ctx = canvas.getContext("2d");
793
+ if (!ctx) return;
794
+ const imageData = ctx.createImageData(width, height);
795
+ imageData.data.set(pixels);
796
+ ctx.putImageData(imageData, 0, 0);
797
+ } catch {}
798
+ });
799
+ return canvas;
800
+ }
801
+
802
+ //#endregion
803
+ //#region src/lib/BucketImage.ts
804
+ const LAYER_STYLE = "position:absolute;inset:0;width:100%;height:100%;";
805
+ /**
806
+ * A bucket image that never pops in: it layers (bottom to top) your `fallback`
807
+ * plate, the automatically stored blurhash, and the real image, which stays
808
+ * transparent until the bitmap is DECODED and then crossfades over the
809
+ * placeholders. Placeholder layers unmount once the fade settles. Respects
810
+ * prefers-reduced-motion (instant swap). The container is made
811
+ * `position: relative` unless your `class` positions it already.
812
+ *
813
+ * ```tsx
814
+ * <BucketImage bucket="covers" path={row.cover_key} class="absolute inset-0"
815
+ * fallback={<MyPlate />} alt="" />
816
+ * ```
817
+ */
818
+ function BucketImage(props) {
819
+ if (typeof document === "undefined") return null;
820
+ const image = useBucketImage(props.bucket, () => props.path, {
821
+ ...props.options,
822
+ blurhash: props.blurhash !== false
823
+ });
824
+ const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
825
+ const root = document.createElement("div");
826
+ createEffect(() => props.class ?? "", (className) => {
827
+ root.className = className;
828
+ });
829
+ onSettled(() => {
830
+ if (getComputedStyle(root).position === "static") root.style.position = "relative";
831
+ });
832
+ const placeholder = document.createElement("div");
833
+ placeholder.style.cssText = LAYER_STYLE;
834
+ const fallbackHolder = document.createElement("div");
835
+ fallbackHolder.style.cssText = LAYER_STYLE;
836
+ placeholder.append(fallbackHolder);
837
+ const resolvedFallback = children(() => props.fallback);
838
+ createEffect(() => resolvedFallback.toArray().filter((node) => node instanceof Node), (nodes) => {
839
+ fallbackHolder.replaceChildren(...nodes);
840
+ });
841
+ const hashCanvas = Blurhash({
842
+ get hash() {
843
+ return image.blurhash();
844
+ },
845
+ style: LAYER_STYLE
846
+ });
847
+ if (hashCanvas instanceof Node) placeholder.append(hashCanvas);
848
+ const img = document.createElement("img");
849
+ img.decoding = "async";
850
+ img.style.cssText = `${LAYER_STYLE}opacity:0;`;
851
+ createEffect(() => props.imgClass ?? "", (className) => {
852
+ img.className = className;
853
+ });
854
+ createEffect(() => props.fit ?? "cover", (fit) => {
855
+ img.style.objectFit = fit;
856
+ });
857
+ createEffect(() => props.alt ?? "", (alt) => {
858
+ img.alt = alt;
859
+ });
860
+ createEffect(() => reducedMotion ? "none" : `opacity ${props.transition ?? 300}ms ${props.easing ?? "cubic-bezier(0.16, 1, 0.3, 1)"}`, (transition) => {
861
+ img.style.transition = transition;
862
+ });
863
+ createEffect(() => image.url(), (url) => {
864
+ if (!url) {
865
+ img.removeAttribute("src");
866
+ return;
867
+ }
868
+ img.src = url;
869
+ image.gate(img);
870
+ });
871
+ createEffect(() => image.ready(), (ready) => {
872
+ img.style.opacity = ready ? "1" : "0";
873
+ });
874
+ const [settled, setSettled] = createSignal(false, { ownedWrite: true });
875
+ createEffect(() => ({
876
+ ready: image.ready(),
877
+ transition: props.transition ?? 300
878
+ }), ({ ready, transition }) => {
879
+ if (!ready) {
880
+ setSettled(false);
881
+ return;
882
+ }
883
+ const wait = (reducedMotion ? 0 : transition) + 120;
884
+ const timer = setTimeout(() => setSettled(true), wait);
885
+ return () => clearTimeout(timer);
886
+ });
887
+ createEffect(() => settled(), (isSettled) => {
888
+ if (isSettled) placeholder.remove();
889
+ else if (!placeholder.isConnected) root.insertBefore(placeholder, img);
890
+ });
891
+ root.append(placeholder, img);
892
+ return root;
893
+ }
894
+
621
895
  //#endregion
622
896
  //#region src/lib/Sp00kyProvider.ts
623
897
  function Sp00kyProvider(props) {
@@ -712,7 +986,20 @@ function createSubmission(fn) {
712
986
  * NOTE: keep in sync with packages/client-solid/src/index.ts (SyncedDb).
713
987
  * Copied rather than shared so this package's dependency graph never pulls
714
988
  * in solid-js 1.x; fold the two together once client-solid moves to Solid 2.
989
+ * Solid-2-only differences: `delete` accepts 'bound RecordId', and write
990
+ * payloads go through `snapshot()` (see `unproxy` below).
715
991
  */
992
+ /**
993
+ * Solid 2 stores wrap every object read out of them (query rows, their nested
994
+ * arrays, `createStore` docs) in a Proxy. Those proxies cannot cross
995
+ * `postMessage` (the sqlite and shared-tabs workers): structuredClone throws
996
+ * DataCloneError. `snapshot()` returns the underlying plain value for store
997
+ * proxies and passes anything else through untouched.
998
+ */
999
+ function unproxy(value) {
1000
+ if (value === null || typeof value !== "object") return value;
1001
+ return snapshot(value);
1002
+ }
716
1003
  var SyncedDb = class {
717
1004
  constructor(config) {
718
1005
  this.sp00ky = null;
@@ -749,25 +1036,26 @@ var SyncedDb = class {
749
1036
  */
750
1037
  async create(id, payload) {
751
1038
  if (!this.sp00ky) throw new Error("SyncedDb not initialized");
752
- await this.sp00ky.create(id, payload);
1039
+ await this.sp00ky.create(id, unproxy(payload));
753
1040
  }
754
1041
  /**
755
1042
  * Update an existing record in the database
756
1043
  */
757
1044
  async update(tableName, recordId, payload, options) {
758
1045
  if (!this.sp00ky) throw new Error("SyncedDb not initialized");
759
- await this.sp00ky.update(tableName, recordId, payload, options);
1046
+ await this.sp00ky.update(tableName, recordId, unproxy(payload), options);
760
1047
  }
761
1048
  /**
762
1049
  * Delete an existing record in the database
763
1050
  */
764
1051
  async delete(tableName, selector) {
765
1052
  if (!this.sp00ky) throw new Error("SyncedDb not initialized");
766
- const ctorName = selector?.constructor?.name;
767
- const isRecordId = selector instanceof RecordId || ctorName === "RecordId" || ctorName === "bound RecordId";
1053
+ const raw = unproxy(selector);
1054
+ const ctorName = raw?.constructor?.name;
1055
+ const isRecordId = raw instanceof RecordId || ctorName === "RecordId" || ctorName === "bound RecordId";
768
1056
  let id;
769
- if (typeof selector === "string") id = selector;
770
- else if (isRecordId) id = `${tableName}:${selector.id}`;
1057
+ if (typeof raw === "string") id = raw;
1058
+ else if (isRecordId) id = `${tableName}:${raw.id}`;
771
1059
  else throw new Error("Only string ID or RecordId selectors are supported currently with core");
772
1060
  await this.sp00ky.delete(tableName, id);
773
1061
  }
@@ -792,7 +1080,7 @@ var SyncedDb = class {
792
1080
  */
793
1081
  async run(backend, path, payload, options) {
794
1082
  if (!this.sp00ky) throw new Error("SyncedDb not initialized");
795
- await this.sp00ky.run(backend, path, payload, options);
1083
+ await this.sp00ky.run(backend, path, unproxy(payload), options);
796
1084
  }
797
1085
  /**
798
1086
  * Sign out, clear session and local storage
@@ -880,5 +1168,5 @@ var SyncedDb = class {
880
1168
  };
881
1169
 
882
1170
  //#endregion
883
- export { RecordId, Sp00kyProvider, SyncedDb, Uuid, conflate, createPreload, createQuery, createSubmission, fromSubscription, useAppRelease, useCrdtField, useDb, useDownloadFile, useFeatureFlag, useFileUpload, usePendingMutations, useQuery, useStorageStatus, useSyncStatus };
1171
+ export { Blurhash, BucketImage, RecordId, Sp00kyProvider, SyncedDb, Uuid, conflate, createPreload, createQuery, createSubmission, fromSubscription, useAppRelease, useBlurhash, useBucketImage, useCrdtField, useDb, useDownloadFile, useFeatureFlag, useFileUpload, usePendingMutations, useQuery, useStorageStatus, useSyncStatus };
884
1172
  //# sourceMappingURL=index.js.map