@spooky-sync/client-solid 0.0.1-canary.17 → 0.0.1-canary.170

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.
@@ -0,0 +1,111 @@
1
+ import type {
2
+ ColumnSchema,
3
+ FinalQuery,
4
+ SchemaStructure,
5
+ TableNames,
6
+ } from '@spooky-sync/query-builder';
7
+ import { createEffect, useContext } from 'solid-js';
8
+ import { SyncedDb } from '..';
9
+ import type { Sp00kyQueryResultPromise, PreloadOptions as CorePreloadOptions } from '@spooky-sync/core';
10
+ import { Sp00kyContext } from './context';
11
+
12
+ type PreloadArg<
13
+ S extends SchemaStructure,
14
+ TableName extends TableNames<S>,
15
+ T extends { columns: Record<string, ColumnSchema> },
16
+ RelatedFields extends Record<string, any>,
17
+ IsOne extends boolean,
18
+ > =
19
+ | FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
20
+ | (() =>
21
+ | FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
22
+ | null
23
+ | undefined);
24
+
25
+ type PreloadOptions = CorePreloadOptions & {
26
+ /** Only preload while this returns true (defaults to always). */
27
+ enabled?: () => boolean;
28
+ };
29
+
30
+ // Overload: context-based (no explicit db)
31
+ export function createPreload<
32
+ S extends SchemaStructure,
33
+ TableName extends TableNames<S>,
34
+ T extends { columns: Record<string, ColumnSchema> },
35
+ RelatedFields extends Record<string, any>,
36
+ IsOne extends boolean,
37
+ >(
38
+ finalQuery: PreloadArg<S, TableName, T, RelatedFields, IsOne>,
39
+ options?: PreloadOptions,
40
+ ): void;
41
+
42
+ // Overload: explicit db
43
+ export function createPreload<
44
+ S extends SchemaStructure,
45
+ TableName extends TableNames<S>,
46
+ T extends { columns: Record<string, ColumnSchema> },
47
+ RelatedFields extends Record<string, any>,
48
+ IsOne extends boolean,
49
+ >(
50
+ db: SyncedDb<S>,
51
+ finalQuery: PreloadArg<S, TableName, T, RelatedFields, IsOne>,
52
+ options?: PreloadOptions,
53
+ ): void;
54
+
55
+ /**
56
+ * Reactive, fire-and-forget prewarm. Resolves the query (calling it if it's a
57
+ * function so it tracks reactive deps), dedupes on the query's stable identity
58
+ * hash, and warms it into the local cache via `db.preload`. No subscription and
59
+ * no cleanup: preload registers nothing that needs tearing down.
60
+ *
61
+ * Typical use: inside a list row, preload the detail query the user is likely
62
+ * to open next, so navigation paints from cache instead of the network.
63
+ */
64
+ export function createPreload<
65
+ S extends SchemaStructure,
66
+ TableName extends TableNames<S>,
67
+ T extends { columns: Record<string, ColumnSchema> },
68
+ RelatedFields extends Record<string, any>,
69
+ IsOne extends boolean,
70
+ >(
71
+ dbOrQuery: SyncedDb<S> | PreloadArg<S, TableName, T, RelatedFields, IsOne>,
72
+ queryOrOptions?: PreloadArg<S, TableName, T, RelatedFields, IsOne> | PreloadOptions,
73
+ maybeOptions?: PreloadOptions,
74
+ ): void {
75
+ let db: SyncedDb<S>;
76
+ let finalQuery: PreloadArg<S, TableName, T, RelatedFields, IsOne>;
77
+ let options: PreloadOptions | undefined;
78
+
79
+ if (dbOrQuery instanceof SyncedDb) {
80
+ db = dbOrQuery;
81
+ finalQuery = queryOrOptions as PreloadArg<S, TableName, T, RelatedFields, IsOne>;
82
+ options = maybeOptions;
83
+ } else {
84
+ const contextDb = useContext(Sp00kyContext);
85
+ if (!contextDb) {
86
+ throw new Error(
87
+ 'createPreload: No db argument provided and no Sp00kyContext found. ' +
88
+ 'Either pass a SyncedDb instance or wrap your app in <Sp00kyProvider>.',
89
+ );
90
+ }
91
+ db = contextDb as SyncedDb<S>;
92
+ finalQuery = dbOrQuery;
93
+ options = queryOrOptions as PreloadOptions | undefined;
94
+ }
95
+
96
+ let prevHash: number | undefined;
97
+
98
+ createEffect(() => {
99
+ if (!(options?.enabled?.() ?? true)) return;
100
+
101
+ const query = typeof finalQuery === 'function' ? finalQuery() : finalQuery;
102
+ if (!query) return;
103
+
104
+ // Dedupe on the query's stable identity hash so a reactive re-run with an
105
+ // unchanged query doesn't refetch (the core also dedupes per session).
106
+ if (query.hash === prevHash) return;
107
+ prevHash = query.hash;
108
+
109
+ void db.getSp00ky().preload(query, { refresh: options?.refresh, staleTime: options?.staleTime });
110
+ });
111
+ }
package/src/lib/models.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { RecordId } from 'surrealdb';
1
+ import type { RecordId } from 'surrealdb';
2
2
 
3
3
  // Re-export types from query-builder for backward compatibility
4
4
  export type { GenericModel, GenericSchema } from '@spooky/query-builder';
@@ -0,0 +1,89 @@
1
+ import { createSignal, onCleanup, type Accessor } from 'solid-js';
2
+ import { useDb } from './context';
3
+ import { semverGt, type AppReleaseOptions, type AppReleaseSnapshot } from '@spooky-sync/core';
4
+
5
+ export interface UseAppReleaseOptions extends AppReleaseOptions {
6
+ /** App name from sp00ky.yml, e.g. `web`. */
7
+ app: string;
8
+ /**
9
+ * The running build's version (X.Y.Z), typically baked in at build time
10
+ * (e.g. a vite `define` from package.json). `updateAvailable()` is true when
11
+ * the announced release is semver-newer than this.
12
+ */
13
+ currentVersion: string;
14
+ }
15
+
16
+ export interface UseAppRelease {
17
+ /** Latest announced version for the app, or undefined when no row exists. */
18
+ latestVersion: Accessor<string | undefined>;
19
+ /** Announced version is semver-newer than the running build. */
20
+ updateAvailable: Accessor<boolean>;
21
+ /** The newer release asks clients to update/reload without prompting. */
22
+ mandatory: Accessor<boolean>;
23
+ /** The newer release asks reloads to clear service-worker caches first. */
24
+ cacheBust: Accessor<boolean>;
25
+ /**
26
+ * Reload onto the announced release. Plain `location.reload()` normally;
27
+ * when the release is flagged cache-bust, CacheStorage is cleared, the
28
+ * service-worker registration is nudged to update, and navigation carries a
29
+ * `?cb=` token to punch through intermediary caches. The service worker is
30
+ * deliberately NOT unregistered: navigating while still controlled by a
31
+ * just-unregistered worker strands subresource fetches on the dead worker
32
+ * and the page hangs until a manual reload.
33
+ */
34
+ reload: () => Promise<void>;
35
+ }
36
+
37
+ async function reloadForSnapshot(snapshot: AppReleaseSnapshot): Promise<void> {
38
+ if (typeof window === 'undefined') return;
39
+ if (snapshot.cacheBust) {
40
+ try {
41
+ if (window.caches) {
42
+ const keys = await window.caches.keys();
43
+ await Promise.all(keys.map((k) => window.caches.delete(k)));
44
+ }
45
+ if (navigator.serviceWorker) {
46
+ const regs = await navigator.serviceWorker.getRegistrations();
47
+ for (const r of regs) r.update().catch(() => {});
48
+ }
49
+ window.location.href = window.location.pathname + '?cb=' + Date.now();
50
+ return;
51
+ } catch {
52
+ /* fall through to a plain reload */
53
+ }
54
+ }
55
+ window.location.reload();
56
+ }
57
+
58
+ /**
59
+ * Observe the app's announced release (`_00_app_release:<app>`, written by
60
+ * `spky deploy` / `spky release`) and compare it against the running build.
61
+ *
62
+ * Typical use: mount a small "new version available — Reload" notification
63
+ * gated on `updateAvailable()`, auto-invoking `reload()` when `mandatory()`
64
+ * (guard the auto path against reload loops with a per-version marker, since
65
+ * a client can reload while the deploy is still rolling out and land on the
66
+ * old bundle again).
67
+ */
68
+ export function useAppRelease(options: UseAppReleaseOptions): UseAppRelease {
69
+ const db = useDb();
70
+ const handle = db.getSp00ky().appRelease(options.app, { ttl: options.ttl });
71
+
72
+ const [snapshot, setSnapshot] = createSignal<AppReleaseSnapshot>(handle.snapshot());
73
+ const unsub = handle.subscribe(setSnapshot);
74
+
75
+ onCleanup(() => {
76
+ unsub();
77
+ handle.close();
78
+ });
79
+
80
+ const updateAvailable = () => semverGt(snapshot().version, options.currentVersion);
81
+
82
+ return {
83
+ latestVersion: () => snapshot().version,
84
+ updateAvailable,
85
+ mandatory: () => updateAvailable() && snapshot().mandatory,
86
+ cacheBust: () => snapshot().cacheBust,
87
+ reload: () => reloadForSnapshot(snapshot()),
88
+ };
89
+ }
@@ -0,0 +1,68 @@
1
+ import { createEffect, createSignal, onCleanup, useContext, type Accessor } from 'solid-js';
2
+ import { Sp00kyContext } from './context';
3
+ import type { CrdtField } from '@spooky-sync/core';
4
+
5
+ export function useCrdtField(
6
+ table: string,
7
+ recordId: () => string | undefined,
8
+ field: string,
9
+ fallbackText?: () => string | undefined,
10
+ ): Accessor<CrdtField | null> {
11
+ const db = useContext(Sp00kyContext);
12
+ if (!db) {
13
+ throw new Error('useCrdtField must be used within a <Sp00kyProvider>');
14
+ }
15
+
16
+ const [crdtField, setCrdtField] = createSignal<CrdtField | null>(null);
17
+ let currentId: string | undefined;
18
+ let initialized = false;
19
+
20
+ createEffect(() => {
21
+ const id = recordId();
22
+
23
+ // Skip if the ID hasn't changed (but allow the first non-undefined value through)
24
+ if (initialized && id === currentId) return;
25
+
26
+ // Close previous field
27
+ if (currentId && crdtField()) {
28
+ db.getSp00ky().closeCrdtField(table, currentId, field);
29
+ setCrdtField(null);
30
+ }
31
+
32
+ currentId = id;
33
+ initialized = true;
34
+
35
+ if (!id) return;
36
+
37
+ const sp00ky = db.getSp00ky();
38
+ const text = fallbackText?.();
39
+ sp00ky
40
+ .openCrdtField(table, id, field, text)
41
+ .then((cf) => {
42
+ if (currentId === id) {
43
+ setCrdtField(cf);
44
+ }
45
+ })
46
+ .catch((err) => {
47
+ // Silent rejections here leave the consumer's `Show when={field()}`
48
+ // permanently stuck on its fallback (typically a static `<p>` with
49
+ // no editing UI), with no error trail. Surface the failure so the
50
+ // root cause (missing `@crdt` annotation, schema codegen drift,
51
+ // local DB query failure, etc.) is visible in the console instead
52
+ // of silently breaking collaborative fields.
53
+ console.error(
54
+ `[useCrdtField] Failed to open CRDT field ${table}.${field} on ${id}:`,
55
+ err,
56
+ );
57
+ });
58
+ });
59
+
60
+ onCleanup(() => {
61
+ if (currentId && crdtField()) {
62
+ db.getSp00ky().closeCrdtField(table, currentId, field);
63
+ setCrdtField(null);
64
+ }
65
+ });
66
+
67
+ return crdtField;
68
+ }
@@ -1,10 +1,31 @@
1
1
  import { createSignal, createEffect, onCleanup, type Accessor } from 'solid-js';
2
2
  import type { SchemaStructure, BucketNames } from '@spooky-sync/query-builder';
3
+ import type { BlobUrlLease } from '@spooky-sync/core';
3
4
  import type { SyncedDb } from '../index';
4
5
  import { useDb } from './context';
5
6
 
6
7
  export interface UseDownloadFileOptions {
8
+ /**
9
+ * Master switch, default `true`. `false` gives every hook instance its own
10
+ * private object URL fetched fresh from the bucket and revoked on unmount —
11
+ * no sharing, no persistence, no reuse.
12
+ */
7
13
  cache?: boolean;
14
+ /**
15
+ * Keep the bytes in OPFS so they survive a reload and are available offline.
16
+ * Default `true`. Turn off for one-shot or sensitive files; the in-tab object
17
+ * URL is still shared between components rendering the same path.
18
+ */
19
+ persist?: boolean;
20
+ /** Exempt this file from pressure eviction. Pinned bytes never expire. */
21
+ pin?: boolean;
22
+ /**
23
+ * `'never'` (default) treats a bucket path as immutable, which is how paths
24
+ * are written (`crypto.randomUUID() + ext`). `'head'` spends a remote `head()`
25
+ * to compare sizes before trusting the cached copy — for paths the app
26
+ * overwrites in place.
27
+ */
28
+ revalidate?: 'never' | 'head';
8
29
  }
9
30
 
10
31
  export interface UseDownloadFileResult {
@@ -14,28 +35,6 @@ export interface UseDownloadFileResult {
14
35
  refetch: () => void;
15
36
  }
16
37
 
17
- interface CacheEntry {
18
- url: string;
19
- refCount: number;
20
- }
21
-
22
- const downloadCache = new Map<string, CacheEntry>();
23
- const inflightRequests = new Map<string, Promise<string | null>>();
24
-
25
- function cacheKey(bucket: string, path: string): string {
26
- return `${bucket}:${path}`;
27
- }
28
-
29
- function releaseEntry(key: string): void {
30
- const entry = downloadCache.get(key);
31
- if (!entry) return;
32
- entry.refCount--;
33
- if (entry.refCount <= 0) {
34
- URL.revokeObjectURL(entry.url);
35
- downloadCache.delete(key);
36
- }
37
- }
38
-
39
38
  export function useDownloadFile<S extends SchemaStructure>(
40
39
  bucketName: BucketNames<S>,
41
40
  path: Accessor<string | null | undefined>,
@@ -76,69 +75,19 @@ export function useDownloadFile<S extends SchemaStructure>(
76
75
  const [isLoading, setIsLoading] = createSignal(false);
77
76
  const [error, setError] = createSignal<Error | null>(null);
78
77
 
79
- let currentKey: string | null = null;
78
+ // Exactly one of these is held at a time: a refcounted lease on the shared
79
+ // cache entry, or a private URL this instance minted and must revoke itself.
80
+ let lease: BlobUrlLease | null = null;
80
81
  let privateUrl: string | null = null;
81
- let refetchTrigger: () => void;
82
+
82
83
  const [refetchSignal, setRefetchSignal] = createSignal(0);
83
- refetchTrigger = () => setRefetchSignal((n) => n + 1);
84
-
85
- async function doDownload(key: string, filePath: string): Promise<string | null> {
86
- if (useCache) {
87
- // Check cache
88
- const cached = downloadCache.get(key);
89
- if (cached) {
90
- cached.refCount++;
91
- currentKey = key;
92
- return cached.url;
93
- }
94
-
95
- // Check inflight
96
- const inflight = inflightRequests.get(key);
97
- if (inflight) {
98
- const result = await inflight;
99
- if (result) {
100
- const entry = downloadCache.get(key);
101
- if (entry) {
102
- entry.refCount++;
103
- currentKey = key;
104
- }
105
- }
106
- return result;
107
- }
108
-
109
- // Start new download
110
- const promise = (async () => {
111
- const content = await db.bucket(bucketName).get(filePath);
112
- if (!content) return null;
113
- const objectUrl = URL.createObjectURL(new Blob([content as BlobPart]));
114
- downloadCache.set(key, { url: objectUrl, refCount: 1 });
115
- return objectUrl;
116
- })();
117
-
118
- inflightRequests.set(key, promise);
119
- try {
120
- const result = await promise;
121
- currentKey = key;
122
- return result;
123
- } finally {
124
- inflightRequests.delete(key);
125
- }
126
- } else {
127
- // No caching — private URL per instance
128
- const content = await db.bucket(bucketName).get(filePath);
129
- if (!content) return null;
130
- const objectUrl = URL.createObjectURL(new Blob([content as BlobPart]));
131
- privateUrl = objectUrl;
132
- return objectUrl;
133
- }
134
- }
84
+ /** Consumed by the next effect run, so `refetch()` bypasses every layer once. */
85
+ let reloadOnce = false;
135
86
 
136
- function releaseCurrentEntry() {
137
- if (useCache && currentKey) {
138
- releaseEntry(currentKey);
139
- currentKey = null;
140
- }
141
- if (!useCache && privateUrl) {
87
+ function releaseCurrent() {
88
+ lease?.release();
89
+ lease = null;
90
+ if (privateUrl) {
142
91
  URL.revokeObjectURL(privateUrl);
143
92
  privateUrl = null;
144
93
  }
@@ -149,8 +98,7 @@ export function useDownloadFile<S extends SchemaStructure>(
149
98
  // Subscribe to refetch signal so effect re-runs
150
99
  refetchSignal();
151
100
 
152
- // Release previous entry
153
- releaseCurrentEntry();
101
+ releaseCurrent();
154
102
 
155
103
  if (!filePath) {
156
104
  setUrl(null);
@@ -159,31 +107,47 @@ export function useDownloadFile<S extends SchemaStructure>(
159
107
  return;
160
108
  }
161
109
 
162
- const key = cacheKey(bucketName as string, filePath);
163
-
164
- // Synchronous cache hit
165
- if (useCache) {
166
- const cached = downloadCache.get(key);
167
- if (cached) {
168
- cached.refCount++;
169
- currentKey = key;
170
- setUrl(cached.url);
171
- setIsLoading(false);
172
- setError(null);
173
- return;
174
- }
175
- }
110
+ const reload = reloadOnce;
111
+ reloadOnce = false;
176
112
 
177
113
  let cancelled = false;
178
114
  setIsLoading(true);
179
115
  setError(null);
180
116
 
181
- doDownload(key, filePath).then(
117
+ const bucket = db.bucket(bucketName);
118
+ const resolve = useCache
119
+ ? bucket
120
+ .url(filePath, {
121
+ persist: options.persist !== false,
122
+ pin: options.pin,
123
+ revalidate: options.revalidate,
124
+ reload,
125
+ })
126
+ .then((acquired) => {
127
+ if (!acquired) return null;
128
+ if (cancelled) {
129
+ // Unmounted or the path changed mid-flight — hand the reference
130
+ // straight back, or the entry never drops to zero and its object
131
+ // URL leaks for the life of the tab.
132
+ acquired.release();
133
+ return null;
134
+ }
135
+ lease = acquired;
136
+ return acquired.url;
137
+ })
138
+ : bucket.read(filePath, { persist: false, reload: true }).then((blob) => {
139
+ if (!blob || cancelled) return null;
140
+ privateUrl = URL.createObjectURL(blob);
141
+ return privateUrl;
142
+ });
143
+
144
+ resolve.then(
182
145
  (result) => {
183
146
  if (!cancelled) {
184
147
  setUrl(result);
185
148
  setIsLoading(false);
186
149
  }
150
+ return undefined;
187
151
  },
188
152
  (err) => {
189
153
  if (!cancelled) {
@@ -199,20 +163,12 @@ export function useDownloadFile<S extends SchemaStructure>(
199
163
  });
200
164
 
201
165
  onCleanup(() => {
202
- releaseCurrentEntry();
166
+ releaseCurrent();
203
167
  });
204
168
 
205
169
  const refetch = () => {
206
- // Evict current entry from cache before re-triggering
207
- if (useCache && currentKey) {
208
- const entry = downloadCache.get(currentKey);
209
- if (entry) {
210
- URL.revokeObjectURL(entry.url);
211
- downloadCache.delete(currentKey);
212
- }
213
- currentKey = null;
214
- }
215
- refetchTrigger();
170
+ reloadOnce = true;
171
+ setRefetchSignal((n) => n + 1);
216
172
  };
217
173
 
218
174
  return { url, isLoading, error, refetch };
@@ -0,0 +1,50 @@
1
+ import { createSignal, onCleanup, type Accessor } from 'solid-js';
2
+ import { useDb } from './context';
3
+ import type { FeatureFlagOptions } from '@spooky-sync/core';
4
+
5
+ export interface UseFeatureFlag {
6
+ variant: Accessor<string | undefined>;
7
+ payload: Accessor<unknown | undefined>;
8
+ enabled: Accessor<boolean>;
9
+ }
10
+
11
+ /**
12
+ * Subscribe to a feature flag for the currently authenticated user.
13
+ *
14
+ * Returns three Solid accessors that update reactively whenever the
15
+ * server-materialized assignment in `_00_user_feature` changes. Backed by
16
+ * the same SSP + sync pipeline that powers `useQuery`, so toggling a flag
17
+ * via `spky flag enable <key>` propagates to the UI without a refresh.
18
+ *
19
+ * `enabled()` is `true` when the resolved variant exists and is not 'off'.
20
+ * For multi-variant flags, prefer `variant()` directly.
21
+ */
22
+ export function useFeatureFlag(
23
+ key: string,
24
+ options?: FeatureFlagOptions,
25
+ ): UseFeatureFlag {
26
+ const db = useDb();
27
+ const handle = db.getSp00ky().feature(key, options);
28
+
29
+ const [variant, setVariant] = createSignal<string | undefined>(handle.variant());
30
+ const [payload, setPayload] = createSignal<unknown | undefined>(handle.payload());
31
+
32
+ const unsub = handle.subscribe((s) => {
33
+ setVariant(s.variant ?? options?.fallback);
34
+ setPayload(s.payload);
35
+ });
36
+
37
+ onCleanup(() => {
38
+ unsub();
39
+ handle.close();
40
+ });
41
+
42
+ return {
43
+ variant,
44
+ payload,
45
+ enabled: () => {
46
+ const v = variant();
47
+ return v !== undefined && v !== 'off';
48
+ },
49
+ };
50
+ }
@@ -33,6 +33,7 @@ export function useFileUpload<S extends SchemaStructure>(
33
33
  bucketName = dbOrBucketName as BucketNames<S>;
34
34
  } else {
35
35
  db = dbOrBucketName as SyncedDb<S>;
36
+ // oxlint-disable-next-line no-non-null-assertion
36
37
  bucketName = maybeBucketName!;
37
38
  }
38
39
 
@@ -52,7 +53,7 @@ export function useFileUpload<S extends SchemaStructure>(
52
53
  const config = db.getBucketConfig(bucketName as string);
53
54
  if (!config) return;
54
55
 
55
- if (config.maxSize != null && file.size > config.maxSize) {
56
+ if (config.maxSize !== null && config.maxSize !== undefined && file.size > config.maxSize) {
56
57
  const maxMB = (config.maxSize / (1024 * 1024)).toFixed(1);
57
58
  throw new Error(`File exceeds maximum size of ${maxMB} MB.`);
58
59
  }