@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 +77 -0
- package/README.md +2 -2
- package/dist/index.cjs +340 -48
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +114 -9
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +114 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +339 -51
- package/dist/index.js.map +1 -1
- package/package.json +17 -16
- package/skills/sp00ky-solid2/SKILL.md +1 -1
- package/src/index.ts +36 -11
- package/src/lib/Blurhash.ts +64 -0
- package/src/lib/BucketImage.ts +170 -0
- package/src/lib/__tests__/rc-semantics.test.ts +9 -5
- package/src/lib/create-query.ts +135 -87
- package/src/lib/merge-rows.ts +46 -0
- package/src/lib/use-blurhash.ts +82 -0
- package/src/lib/use-bucket-image.ts +108 -0
- package/tsdown.config.ts +2 -0
- package/LICENSE +0 -21
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
|
|
10
|
-
- `createQuery` exposes both worlds: non-suspending accessors (`data`, `isLoading`, `isFetching`, `isSettled`, `error`) and a suspending `ready()` for `<Loading>` boundaries. Born committed
|
|
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.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
201
|
+
if (!enabled || !query) return;
|
|
159
202
|
const cleanups = [];
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
768
|
-
const
|
|
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
|
|
771
|
-
else if (isRecordId) id = `${tableName}:${
|
|
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;
|