@spooky-sync/client-solid 0.0.1-canary.15 → 0.0.1-canary.151
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 +322 -65
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +173 -21
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +173 -21
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +318 -66
- package/dist/index.js.map +1 -1
- package/package.json +9 -7
- package/skills/sp00ky-solid/SKILL.md +335 -0
- package/skills/sp00ky-solid/references/file-hooks.md +112 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/surrealdb-wasm-factory.ts +4 -1
- package/src/index.ts +126 -55
- package/src/lib/Sp00kyProvider.ts +76 -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 +2 -2
- 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-sync-status.ts +50 -0
- package/src/types/index.ts +3 -4
- package/src/lib/SpookyProvider.ts +0 -55
package/AGENTS.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# `@spooky-sync/client-solid` — agent guide
|
|
2
|
+
|
|
3
|
+
## What this package is
|
|
4
|
+
|
|
5
|
+
The SolidJS binding for sp00ky. Exposes a `Sp00kyProvider` that initializes a `Sp00kyClient` and a set of reactive hooks (`useDb`, `useQuery`, `useCrdtField`, `useFileUpload`, `useDownloadFile`). All hooks expect to be called inside a `<Sp00kyProvider>` boundary.
|
|
6
|
+
|
|
7
|
+
## Setup pattern
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// db.ts
|
|
11
|
+
import type { SyncedDbConfig } from '@spooky-sync/client-solid';
|
|
12
|
+
import { schema, SURQL_SCHEMA } from './schema.gen'; // generated by `spky generate`
|
|
13
|
+
|
|
14
|
+
export const dbConfig: SyncedDbConfig<typeof schema> = {
|
|
15
|
+
schema,
|
|
16
|
+
schemaSurql: SURQL_SCHEMA,
|
|
17
|
+
database: {
|
|
18
|
+
namespace: 'main',
|
|
19
|
+
database: 'app',
|
|
20
|
+
endpoint: 'ws://localhost:8666/rpc',
|
|
21
|
+
store: 'memory', // or 'indexeddb' for persistence
|
|
22
|
+
persistenceClient: 'localstorage',
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
// App.tsx
|
|
29
|
+
<Sp00kyProvider config={dbConfig}>{/* app */}</Sp00kyProvider>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Key hooks
|
|
33
|
+
|
|
34
|
+
- **`useDb<typeof schema>()`** — returns the `SyncedDb<S>` instance. Methods:
|
|
35
|
+
- `db.create(id, payload)` — `id` is a full record ID like `'thread:abc'`.
|
|
36
|
+
- `db.update(table, id, payload, options?)` — `options.debounced` coalesces updates.
|
|
37
|
+
- `db.delete(table, idOrSelector)`.
|
|
38
|
+
- `db.query(table)` — returns a `QueryBuilder`. Chain `.related()`, `.orderBy()`, `.limit()`, etc., end with `.build()`.
|
|
39
|
+
- `db.preload(query, options?)` — cache-aware, awaitable prewarm. Cold (nothing cached in the bucket) fetches + persists and the promise awaits it (blocks); warm returns instantly. `options.refresh` (`'onUse'` default / `'background'` / `'stale'`) + `options.staleTime` control warm-refresh. One-shot snapshot, no live view — freshens on use.
|
|
40
|
+
- `db.run(backend, route, payload)` — call a backend RPC route.
|
|
41
|
+
- `db.bucket(name)` — get a `BucketHandle` for file storage.
|
|
42
|
+
- `db.useRemote(fn)` — escape hatch to the raw `Surreal` client (skips cache).
|
|
43
|
+
- `db.authenticate(token)`, `db.signOut()`, `db.auth`.
|
|
44
|
+
- `db.pendingMutationCount`, `db.subscribeToPendingMutations(cb)`.
|
|
45
|
+
- **`useQuery(() => db.query(...).build())`** — reactive query. Returns `{ data, status, error, ... }` accessors. The factory function is tracked, so passing reactive params (signals) re-runs the query.
|
|
46
|
+
- **`createPreload(() => db.query(...).build(), options?)`** — reactive, fire-and-forget prewarm (same overloads/`enabled` as `useQuery`, plus `refresh`/`staleTime`). Warms a query the user is likely to open next (e.g. a list row's detail) into the local cache. For blocking first-load, use the `Sp00kyProvider` `preload` prop instead.
|
|
47
|
+
- **`useCrdtField(table, () => recordId, field, () => valueAccessor)`** — wires a CRDT text field to a Loro doc. Pair with `db.update(table, id, { [field]: newValue }, { debounced: true })` so rapid keystrokes don't flood the queue. *All four arguments take accessor functions where reactive — that's deliberate, for SolidJS tracking.*
|
|
48
|
+
- **`useFileUpload()`** / **`useDownloadFile()`** — bucket helpers; the upload result includes the storage path you write into a record column.
|
|
49
|
+
|
|
50
|
+
## Re-exports for convenience
|
|
51
|
+
|
|
52
|
+
- `RecordId`, `Uuid` from `surrealdb`.
|
|
53
|
+
- Query-builder types: `TableModel`, `TableNames`, `GetTable`, `QueryResult`, etc. (see `@spooky-sync/query-builder/AGENTS.md`).
|
|
54
|
+
- `Model<S, T>`, `GenericModel`, `ModelPayload` — typed row shapes.
|
|
55
|
+
|
|
56
|
+
## Common gotchas
|
|
57
|
+
|
|
58
|
+
- **`useDb()` requires `<typeof schema>`.** Without the generic, all calls fall back to `unknown` and you lose type safety.
|
|
59
|
+
- **CRDT fields are not regular columns.** Don't read or write them via `useQuery` — read with `useCrdtField`, write via `db.update` with `{ debounced: true }`.
|
|
60
|
+
- **Generate IDs explicitly.** `const id = new Uuid().toString()`, then `db.create(\`thread:\${id}\`, ...)`. SurrealDB's auto-id only fires on direct DB writes, not through the sync queue.
|
|
61
|
+
- **`useQuery` factories must call `.build()` (or `.all()`, `.first()`, etc.).** A bare `db.query('thread')` is a builder, not a query — `useQuery` will throw or return forever-loading.
|
|
62
|
+
- **Provider is mandatory.** Calling any hook outside `<Sp00kyProvider>` throws.
|
|
63
|
+
|
|
64
|
+
## Pointers
|
|
65
|
+
|
|
66
|
+
- Sync engine: `node_modules/@spooky-sync/core/AGENTS.md`
|
|
67
|
+
- Query builder DSL: `node_modules/@spooky-sync/query-builder/AGENTS.md`
|
|
68
|
+
- Schema authoring + codegen: `node_modules/@spooky-sync/cli/AGENTS.md`
|
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ A SurrealDB client for Solid.js with automatic cache synchronization and live qu
|
|
|
11
11
|
- **Type-Safe**: Full TypeScript support with generated schema types
|
|
12
12
|
- **Reactive**: Seamless integration with Solid.js reactivity
|
|
13
13
|
- **Offline Support**: Local cache works even when remote is temporarily unavailable
|
|
14
|
+
- **Preload / Prewarm**: Warm data into the local cache ahead of time so screens open instantly — awaitable to gate first load, reactive to prefetch what's next
|
|
14
15
|
|
|
15
16
|
## Quick Start
|
|
16
17
|
|
|
@@ -215,6 +216,25 @@ const liveQuery = await db.query.thread
|
|
|
215
216
|
.orderBy('created_at', 'desc'); // ✅ Field names autocompleted
|
|
216
217
|
```
|
|
217
218
|
|
|
219
|
+
### Preload / Prewarm
|
|
220
|
+
|
|
221
|
+
Warm a query's results into the local cache before they're needed, so a later `useQuery` paints
|
|
222
|
+
instantly instead of waiting on the network. Preload is a one-shot snapshot — it does not register
|
|
223
|
+
a live view; the data freshens on use when the real `useQuery` mounts.
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
// Awaitable + cache-aware: blocks on the FIRST load (nothing cached), returns
|
|
227
|
+
// instantly on later loads (already cached). Great for gating the UI on config.
|
|
228
|
+
await db.preload(db.query('config').build());
|
|
229
|
+
|
|
230
|
+
// Opt into a one-time background refresh when the cache is stale:
|
|
231
|
+
await db.preload(db.query('config').build(), { refresh: 'stale', staleTime: '1d' });
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Block first-load UI on essential data via the `Sp00kyProvider` `preload` prop, or reactively
|
|
235
|
+
prefetch what the user is likely to open next with `createPreload(() => …build())`. Full details
|
|
236
|
+
in the [SKILL guide](./skills/sp00ky-solid/SKILL.md#preload).
|
|
237
|
+
|
|
218
238
|
## Documentation
|
|
219
239
|
|
|
220
240
|
- **[Quick Start Guide](./QUICK_START.md)**: Get up and running quickly
|
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,211 @@ 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
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
//#endregion
|
|
177
|
+
//#region src/lib/use-crdt-field.ts
|
|
178
|
+
function useCrdtField(table, recordId, field, fallbackText) {
|
|
179
|
+
const db = (0, solid_js.useContext)(Sp00kyContext);
|
|
180
|
+
if (!db) throw new Error("useCrdtField must be used within a <Sp00kyProvider>");
|
|
181
|
+
const [crdtField, setCrdtField] = (0, solid_js.createSignal)(null);
|
|
182
|
+
let currentId;
|
|
183
|
+
let initialized = false;
|
|
184
|
+
(0, solid_js.createEffect)(() => {
|
|
185
|
+
const id = recordId();
|
|
186
|
+
if (initialized && id === currentId) return;
|
|
187
|
+
if (currentId && crdtField()) {
|
|
188
|
+
db.getSp00ky().closeCrdtField(table, currentId, field);
|
|
189
|
+
setCrdtField(null);
|
|
190
|
+
}
|
|
191
|
+
currentId = id;
|
|
192
|
+
initialized = true;
|
|
193
|
+
if (!id) return;
|
|
194
|
+
const sp00ky = db.getSp00ky();
|
|
195
|
+
const text = fallbackText?.();
|
|
196
|
+
sp00ky.openCrdtField(table, id, field, text).then((cf) => {
|
|
197
|
+
if (currentId === id) setCrdtField(cf);
|
|
198
|
+
}).catch((err) => {
|
|
199
|
+
console.error(`[useCrdtField] Failed to open CRDT field ${table}.${field} on ${id}:`, err);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
(0, solid_js.onCleanup)(() => {
|
|
203
|
+
if (currentId && crdtField()) {
|
|
204
|
+
db.getSp00ky().closeCrdtField(table, currentId, field);
|
|
205
|
+
setCrdtField(null);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
return crdtField;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
//#endregion
|
|
212
|
+
//#region src/lib/use-feature-flag.ts
|
|
213
|
+
/**
|
|
214
|
+
* Subscribe to a feature flag for the currently authenticated user.
|
|
215
|
+
*
|
|
216
|
+
* Returns three Solid accessors that update reactively whenever the
|
|
217
|
+
* server-materialized assignment in `_00_user_feature` changes. Backed by
|
|
218
|
+
* the same SSP + sync pipeline that powers `useQuery`, so toggling a flag
|
|
219
|
+
* via `spky flag enable <key>` propagates to the UI without a refresh.
|
|
220
|
+
*
|
|
221
|
+
* `enabled()` is `true` when the resolved variant exists and is not 'off'.
|
|
222
|
+
* For multi-variant flags, prefer `variant()` directly.
|
|
223
|
+
*/
|
|
224
|
+
function useFeatureFlag(key, options) {
|
|
225
|
+
const handle = useDb().getSp00ky().feature(key, options);
|
|
226
|
+
const [variant, setVariant] = (0, solid_js.createSignal)(handle.variant());
|
|
227
|
+
const [payload, setPayload] = (0, solid_js.createSignal)(handle.payload());
|
|
228
|
+
const unsub = handle.subscribe((s) => {
|
|
229
|
+
setVariant(s.variant ?? options?.fallback);
|
|
230
|
+
setPayload(s.payload);
|
|
231
|
+
});
|
|
232
|
+
(0, solid_js.onCleanup)(() => {
|
|
233
|
+
unsub();
|
|
234
|
+
handle.close();
|
|
235
|
+
});
|
|
236
|
+
return {
|
|
237
|
+
variant,
|
|
238
|
+
payload,
|
|
239
|
+
enabled: () => {
|
|
240
|
+
const v = variant();
|
|
241
|
+
return v !== void 0 && v !== "off";
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
//#endregion
|
|
247
|
+
//#region src/lib/use-app-release.ts
|
|
248
|
+
async function reloadForSnapshot(snapshot) {
|
|
249
|
+
if (typeof window === "undefined") return;
|
|
250
|
+
if (snapshot.cacheBust) try {
|
|
251
|
+
if (window.caches) {
|
|
252
|
+
const keys = await window.caches.keys();
|
|
253
|
+
await Promise.all(keys.map((k) => window.caches.delete(k)));
|
|
254
|
+
}
|
|
255
|
+
if (navigator.serviceWorker) {
|
|
256
|
+
const regs = await navigator.serviceWorker.getRegistrations();
|
|
257
|
+
for (const r of regs) r.update().catch(() => {});
|
|
258
|
+
}
|
|
259
|
+
window.location.href = window.location.pathname + "?cb=" + Date.now();
|
|
260
|
+
return;
|
|
261
|
+
} catch {}
|
|
262
|
+
window.location.reload();
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Observe the app's announced release (`_00_app_release:<app>`, written by
|
|
266
|
+
* `spky deploy` / `spky release`) and compare it against the running build.
|
|
267
|
+
*
|
|
268
|
+
* Typical use: mount a small "new version available — Reload" notification
|
|
269
|
+
* gated on `updateAvailable()`, auto-invoking `reload()` when `mandatory()`
|
|
270
|
+
* (guard the auto path against reload loops with a per-version marker, since
|
|
271
|
+
* a client can reload while the deploy is still rolling out and land on the
|
|
272
|
+
* old bundle again).
|
|
273
|
+
*/
|
|
274
|
+
function useAppRelease(options) {
|
|
275
|
+
const handle = useDb().getSp00ky().appRelease(options.app, { ttl: options.ttl });
|
|
276
|
+
const [snapshot, setSnapshot] = (0, solid_js.createSignal)(handle.snapshot());
|
|
277
|
+
const unsub = handle.subscribe(setSnapshot);
|
|
278
|
+
(0, solid_js.onCleanup)(() => {
|
|
279
|
+
unsub();
|
|
280
|
+
handle.close();
|
|
281
|
+
});
|
|
282
|
+
const updateAvailable = () => (0, _spooky_sync_core.semverGt)(snapshot().version, options.currentVersion);
|
|
283
|
+
return {
|
|
284
|
+
latestVersion: () => snapshot().version,
|
|
285
|
+
updateAvailable,
|
|
286
|
+
mandatory: () => updateAvailable() && snapshot().mandatory,
|
|
287
|
+
cacheBust: () => snapshot().cacheBust,
|
|
288
|
+
reload: () => reloadForSnapshot(snapshot())
|
|
73
289
|
};
|
|
74
290
|
}
|
|
75
291
|
|
|
@@ -95,7 +311,7 @@ function useFileUpload(dbOrBucketName, maybeBucketName) {
|
|
|
95
311
|
const validate = (file) => {
|
|
96
312
|
const config = db.getBucketConfig(bucketName);
|
|
97
313
|
if (!config) return;
|
|
98
|
-
if (config.maxSize
|
|
314
|
+
if (config.maxSize !== null && config.maxSize !== void 0 && file.size > config.maxSize) {
|
|
99
315
|
const maxMB = (config.maxSize / (1024 * 1024)).toFixed(1);
|
|
100
316
|
throw new Error(`File exceeds maximum size of ${maxMB} MB.`);
|
|
101
317
|
}
|
|
@@ -204,9 +420,8 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
|
|
|
204
420
|
const [error, setError] = (0, solid_js.createSignal)(null);
|
|
205
421
|
let currentKey = null;
|
|
206
422
|
let privateUrl = null;
|
|
207
|
-
let refetchTrigger;
|
|
208
423
|
const [refetchSignal, setRefetchSignal] = (0, solid_js.createSignal)(0);
|
|
209
|
-
refetchTrigger = () => setRefetchSignal((n) => n + 1);
|
|
424
|
+
const refetchTrigger = () => setRefetchSignal((n) => n + 1);
|
|
210
425
|
async function doDownload(key, filePath) {
|
|
211
426
|
if (useCache) {
|
|
212
427
|
const cached = downloadCache.get(key);
|
|
@@ -326,26 +541,31 @@ function useDownloadFile(dbOrBucketName, bucketNameOrPath, pathOrOptions, maybeO
|
|
|
326
541
|
}
|
|
327
542
|
|
|
328
543
|
//#endregion
|
|
329
|
-
//#region src/lib/
|
|
330
|
-
function
|
|
544
|
+
//#region src/lib/Sp00kyProvider.ts
|
|
545
|
+
function Sp00kyProvider(props) {
|
|
331
546
|
const merged = (0, solid_js.mergeProps)({ fallback: void 0 }, props);
|
|
332
547
|
const [db, setDb] = (0, solid_js.createSignal)(void 0);
|
|
333
548
|
(0, solid_js.onMount)(async () => {
|
|
334
549
|
try {
|
|
335
550
|
const instance = new SyncedDb(merged.config);
|
|
336
551
|
await instance.init();
|
|
552
|
+
if (merged.preload) try {
|
|
553
|
+
await merged.preload(instance);
|
|
554
|
+
} catch (e) {
|
|
555
|
+
console.error("Sp00kyProvider: preload failed; revealing UI anyway", e);
|
|
556
|
+
}
|
|
337
557
|
setDb(() => instance);
|
|
338
558
|
merged.onReady?.(instance);
|
|
339
559
|
} catch (e) {
|
|
340
560
|
const error = e instanceof Error ? e : new Error(String(e));
|
|
341
561
|
if (merged.onError) merged.onError(error);
|
|
342
|
-
else console.error("
|
|
562
|
+
else console.error("Sp00kyProvider: Failed to initialize database", error);
|
|
343
563
|
}
|
|
344
564
|
});
|
|
345
565
|
return (0, solid_js.createMemo)(() => {
|
|
346
566
|
const instance = db();
|
|
347
567
|
if (!instance) return merged.fallback;
|
|
348
|
-
return (0, solid_js.createComponent)(
|
|
568
|
+
return (0, solid_js.createComponent)(Sp00kyContext.Provider, {
|
|
349
569
|
value: instance,
|
|
350
570
|
get children() {
|
|
351
571
|
return merged.children;
|
|
@@ -357,69 +577,82 @@ function SpookyProvider(props) {
|
|
|
357
577
|
//#endregion
|
|
358
578
|
//#region src/index.ts
|
|
359
579
|
/**
|
|
360
|
-
* SyncedDb - A thin wrapper around
|
|
361
|
-
* Delegates all logic to the underlying
|
|
580
|
+
* SyncedDb - A thin wrapper around sp00ky-ts for Solid.js integration
|
|
581
|
+
* Delegates all logic to the underlying sp00ky-ts instance
|
|
362
582
|
*/
|
|
363
583
|
var SyncedDb = class {
|
|
364
584
|
constructor(config) {
|
|
365
|
-
this.
|
|
585
|
+
this.sp00ky = null;
|
|
366
586
|
this._initialized = false;
|
|
367
587
|
this.config = config;
|
|
368
588
|
}
|
|
369
|
-
|
|
370
|
-
if (!this.
|
|
371
|
-
return this.
|
|
589
|
+
getSp00ky() {
|
|
590
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
591
|
+
return this.sp00ky;
|
|
372
592
|
}
|
|
373
593
|
/**
|
|
374
|
-
* Initialize the
|
|
594
|
+
* Initialize the sp00ky-ts instance
|
|
375
595
|
*/
|
|
376
596
|
async init() {
|
|
377
597
|
if (this._initialized) return;
|
|
378
|
-
this.
|
|
379
|
-
await this.
|
|
598
|
+
this.sp00ky = new _spooky_sync_core.Sp00kyClient(this.config);
|
|
599
|
+
await this.sp00ky.init();
|
|
380
600
|
this._initialized = true;
|
|
381
601
|
}
|
|
382
602
|
/**
|
|
383
603
|
* Create a new record in the database
|
|
384
604
|
*/
|
|
385
605
|
async create(id, payload) {
|
|
386
|
-
if (!this.
|
|
387
|
-
await this.
|
|
606
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
607
|
+
await this.sp00ky.create(id, payload);
|
|
388
608
|
}
|
|
389
609
|
/**
|
|
390
610
|
* Update an existing record in the database
|
|
391
611
|
*/
|
|
392
612
|
async update(tableName, recordId, payload, options) {
|
|
393
|
-
if (!this.
|
|
394
|
-
await this.
|
|
613
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
614
|
+
await this.sp00ky.update(tableName, recordId, payload, options);
|
|
395
615
|
}
|
|
396
616
|
/**
|
|
397
617
|
* Delete an existing record in the database
|
|
398
618
|
*/
|
|
399
619
|
async delete(tableName, selector) {
|
|
400
|
-
if (!this.
|
|
401
|
-
|
|
402
|
-
|
|
620
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
621
|
+
const isRecordId = selector instanceof surrealdb.RecordId || selector?.constructor?.name === "RecordId";
|
|
622
|
+
let id;
|
|
623
|
+
if (typeof selector === "string") id = selector;
|
|
624
|
+
else if (isRecordId) id = `${tableName}:${selector.id}`;
|
|
625
|
+
else throw new Error("Only string ID or RecordId selectors are supported currently with core");
|
|
626
|
+
await this.sp00ky.delete(tableName, id);
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Preload/prewarm a built query into the local cache without registering a
|
|
630
|
+
* live view. Fetches once and stores the rows (+ embedded related children)
|
|
631
|
+
* locally so a later `useQuery` for the same data paints instantly. Best-effort.
|
|
632
|
+
*/
|
|
633
|
+
async preload(finalQuery, options) {
|
|
634
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
635
|
+
await this.sp00ky.preload(finalQuery, options);
|
|
403
636
|
}
|
|
404
637
|
/**
|
|
405
638
|
* Query data from the database
|
|
406
639
|
*/
|
|
407
640
|
query(table) {
|
|
408
|
-
if (!this.
|
|
409
|
-
return this.
|
|
641
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
642
|
+
return this.sp00ky.query(table, {});
|
|
410
643
|
}
|
|
411
644
|
/**
|
|
412
645
|
* Run a backend operation
|
|
413
646
|
*/
|
|
414
647
|
async run(backend, path, payload, options) {
|
|
415
|
-
if (!this.
|
|
416
|
-
await this.
|
|
648
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
649
|
+
await this.sp00ky.run(backend, path, payload, options);
|
|
417
650
|
}
|
|
418
651
|
/**
|
|
419
652
|
* Authenticate with the database
|
|
420
653
|
*/
|
|
421
654
|
async authenticate(token) {
|
|
422
|
-
await this.
|
|
655
|
+
await this.sp00ky?.authenticate(token);
|
|
423
656
|
return new surrealdb.RecordId("user", "me");
|
|
424
657
|
}
|
|
425
658
|
/**
|
|
@@ -433,48 +666,67 @@ var SyncedDb = class {
|
|
|
433
666
|
* Sign out, clear session and local storage
|
|
434
667
|
*/
|
|
435
668
|
async signOut() {
|
|
436
|
-
if (!this.
|
|
437
|
-
await this.
|
|
669
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
670
|
+
await this.sp00ky.auth.signOut();
|
|
438
671
|
}
|
|
439
672
|
/**
|
|
440
673
|
* Execute a function with direct access to the remote database connection
|
|
441
674
|
*/
|
|
442
675
|
async useRemote(fn) {
|
|
443
|
-
if (!this.
|
|
444
|
-
return await this.
|
|
676
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
677
|
+
return await this.sp00ky.useRemote(fn);
|
|
445
678
|
}
|
|
446
679
|
/**
|
|
447
680
|
* Access the remote database service directly
|
|
448
681
|
*/
|
|
449
682
|
get remote() {
|
|
450
|
-
if (!this.
|
|
451
|
-
return this.
|
|
683
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
684
|
+
return this.sp00ky.remoteClient;
|
|
452
685
|
}
|
|
453
686
|
/**
|
|
454
687
|
* Access the local database service directly
|
|
455
688
|
*/
|
|
456
689
|
get local() {
|
|
457
|
-
if (!this.
|
|
458
|
-
return this.
|
|
690
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
691
|
+
return this.sp00ky.localClient;
|
|
459
692
|
}
|
|
460
693
|
/**
|
|
461
694
|
* Access the auth service
|
|
462
695
|
*/
|
|
463
696
|
get auth() {
|
|
464
|
-
if (!this.
|
|
465
|
-
return this.
|
|
697
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
698
|
+
return this.sp00ky.auth;
|
|
466
699
|
}
|
|
467
700
|
get pendingMutationCount() {
|
|
468
|
-
if (!this.
|
|
469
|
-
return this.
|
|
701
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
702
|
+
return this.sp00ky.pendingMutationCount;
|
|
703
|
+
}
|
|
704
|
+
/** Diagnostic — see `Sp00kyClient.liveRetryCount`. */
|
|
705
|
+
get liveRetryCount() {
|
|
706
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
707
|
+
return this.sp00ky.liveRetryCount;
|
|
470
708
|
}
|
|
471
709
|
subscribeToPendingMutations(cb) {
|
|
472
|
-
if (!this.
|
|
473
|
-
return this.
|
|
710
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
711
|
+
return this.sp00ky.subscribeToPendingMutations(cb);
|
|
712
|
+
}
|
|
713
|
+
/** Current sync-health snapshot. See {@link useSyncStatus}. */
|
|
714
|
+
get syncHealth() {
|
|
715
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
716
|
+
return this.sp00ky.syncHealth;
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Observe sync health. Fires immediately with the current status and again
|
|
720
|
+
* on every healthy↔degraded transition. Prefer the `useSyncStatus` hook in
|
|
721
|
+
* components; this is the imperative escape hatch.
|
|
722
|
+
*/
|
|
723
|
+
subscribeToSyncHealth(cb) {
|
|
724
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
725
|
+
return this.sp00ky.subscribeToSyncHealth(cb);
|
|
474
726
|
}
|
|
475
727
|
bucket(name) {
|
|
476
|
-
if (!this.
|
|
477
|
-
return this.
|
|
728
|
+
if (!this.sp00ky) throw new Error("SyncedDb not initialized");
|
|
729
|
+
return this.sp00ky.bucket(name);
|
|
478
730
|
}
|
|
479
731
|
getBucketConfig(name) {
|
|
480
732
|
return this.config.schema.buckets?.find((b) => b.name === name);
|
|
@@ -483,11 +735,16 @@ var SyncedDb = class {
|
|
|
483
735
|
|
|
484
736
|
//#endregion
|
|
485
737
|
exports.RecordId = surrealdb.RecordId;
|
|
486
|
-
exports.
|
|
738
|
+
exports.Sp00kyProvider = Sp00kyProvider;
|
|
487
739
|
exports.SyncedDb = SyncedDb;
|
|
488
740
|
exports.Uuid = surrealdb.Uuid;
|
|
741
|
+
exports.createPreload = createPreload;
|
|
742
|
+
exports.useAppRelease = useAppRelease;
|
|
743
|
+
exports.useCrdtField = useCrdtField;
|
|
489
744
|
exports.useDb = useDb;
|
|
490
745
|
exports.useDownloadFile = useDownloadFile;
|
|
746
|
+
exports.useFeatureFlag = useFeatureFlag;
|
|
491
747
|
exports.useFileUpload = useFileUpload;
|
|
492
748
|
exports.useQuery = useQuery;
|
|
749
|
+
exports.useSyncStatus = useSyncStatus;
|
|
493
750
|
//# sourceMappingURL=index.cjs.map
|