@spooky-sync/client-solid2 0.0.1-canary.200
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/LICENSE +21 -0
- package/QUICK_START.md +126 -0
- package/README.md +19 -0
- package/dist/index.cjs +903 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +498 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +498 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +884 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
- package/skills/sp00ky-solid2/SKILL.md +68 -0
- package/src/index.ts +365 -0
- package/src/lib/Sp00kyProvider.ts +104 -0
- package/src/lib/__tests__/conflate.test.ts +120 -0
- package/src/lib/__tests__/create-query.test.ts +284 -0
- package/src/lib/__tests__/rc-semantics.test.ts +389 -0
- package/src/lib/conflate.ts +74 -0
- package/src/lib/context.ts +28 -0
- package/src/lib/create-preload.ts +115 -0
- package/src/lib/create-query.ts +285 -0
- package/src/lib/create-submission.ts +57 -0
- package/src/lib/from-subscription.ts +32 -0
- package/src/lib/models.ts +8 -0
- package/src/lib/use-app-release.ts +89 -0
- package/src/lib/use-crdt-field.ts +57 -0
- package/src/lib/use-download-file.ts +181 -0
- package/src/lib/use-feature-flag.ts +43 -0
- package/src/lib/use-file-upload.ts +146 -0
- package/src/lib/use-storage-status.ts +44 -0
- package/src/lib/use-sync-status.ts +63 -0
- package/src/types/index.ts +83 -0
- package/tsconfig.json +27 -0
- package/tsdown.config.ts +18 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Latest-wins async iterable over a subscribe-callback source.
|
|
3
|
+
*
|
|
4
|
+
* Bridges spooky's push-callback subscriptions into the AsyncIterable shape
|
|
5
|
+
* Solid 2 computations consume natively. Each spooky emission is a full result
|
|
6
|
+
* set, so intermediate values are droppable: only the newest unconsumed value
|
|
7
|
+
* is buffered, and a pending pull resolves with it immediately.
|
|
8
|
+
*
|
|
9
|
+
* Teardown contract (probed in rc-semantics.test.ts): Solid 2 does NOT
|
|
10
|
+
* terminate a superseded/disposed computation's async generator — no
|
|
11
|
+
* `return()`, no `finally`. Consumers MUST call `it.return()` themselves from
|
|
12
|
+
* an `onCleanup` registered synchronously in the compute scope. `return()`
|
|
13
|
+
* unsubscribes (awaiting the unsubscribe if the subscribe returned a promise,
|
|
14
|
+
* as `sp00ky.subscribe` does) and resolves any parked pull as done.
|
|
15
|
+
*/
|
|
16
|
+
export function conflate<T>(
|
|
17
|
+
subscribe: (cb: (v: T) => void) => (() => void) | Promise<() => void>
|
|
18
|
+
): AsyncIterable<T> {
|
|
19
|
+
return {
|
|
20
|
+
[Symbol.asyncIterator](): AsyncIterator<T> {
|
|
21
|
+
let buffered: { v: T } | undefined;
|
|
22
|
+
let resolveNext: ((r: IteratorResult<T>) => void) | undefined;
|
|
23
|
+
let done = false;
|
|
24
|
+
|
|
25
|
+
const unsubMaybe = subscribe((v) => {
|
|
26
|
+
if (done) return;
|
|
27
|
+
if (resolveNext) {
|
|
28
|
+
const r = resolveNext;
|
|
29
|
+
resolveNext = undefined;
|
|
30
|
+
r({ value: v, done: false });
|
|
31
|
+
} else {
|
|
32
|
+
buffered = { v };
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const finish = () => {
|
|
37
|
+
if (done) return;
|
|
38
|
+
done = true;
|
|
39
|
+
buffered = undefined;
|
|
40
|
+
// Unsubscribe may still be in flight (async registration); chain it.
|
|
41
|
+
Promise.resolve(unsubMaybe)
|
|
42
|
+
.then((unsub) => unsub())
|
|
43
|
+
.catch(() => {
|
|
44
|
+
// Registration failed — there is nothing to unsubscribe.
|
|
45
|
+
});
|
|
46
|
+
if (resolveNext) {
|
|
47
|
+
const r = resolveNext;
|
|
48
|
+
resolveNext = undefined;
|
|
49
|
+
r({ value: undefined as never, done: true });
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
next(): Promise<IteratorResult<T>> {
|
|
55
|
+
if (done) return Promise.resolve({ value: undefined as never, done: true });
|
|
56
|
+
if (buffered) {
|
|
57
|
+
const v = buffered.v;
|
|
58
|
+
buffered = undefined;
|
|
59
|
+
return Promise.resolve({ value: v, done: false });
|
|
60
|
+
}
|
|
61
|
+
return new Promise<IteratorResult<T>>((r) => (resolveNext = r));
|
|
62
|
+
},
|
|
63
|
+
return(): Promise<IteratorResult<T>> {
|
|
64
|
+
finish();
|
|
65
|
+
return Promise.resolve({ value: undefined as never, done: true });
|
|
66
|
+
},
|
|
67
|
+
throw(e: unknown): Promise<IteratorResult<T>> {
|
|
68
|
+
finish();
|
|
69
|
+
return Promise.reject(e);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createContext, useContext, type Accessor } from 'solid-js';
|
|
2
|
+
import type { SchemaStructure } from '@spooky-sync/query-builder';
|
|
3
|
+
import type { SyncedDb } from '../index';
|
|
4
|
+
import { fromSubscription } from './from-subscription';
|
|
5
|
+
|
|
6
|
+
// Solid 2: the context object doubles as its provider component —
|
|
7
|
+
// <Sp00kyContext value={db}>{children}</Sp00kyContext>.
|
|
8
|
+
export const Sp00kyContext = createContext<SyncedDb<any>>();
|
|
9
|
+
|
|
10
|
+
export function useDb<S extends SchemaStructure>(): SyncedDb<S> {
|
|
11
|
+
try {
|
|
12
|
+
return useContext(Sp00kyContext) as SyncedDb<S>;
|
|
13
|
+
} catch {
|
|
14
|
+
// Solid 2 throws ContextNotFoundError; rethrow with actionable guidance.
|
|
15
|
+
throw new Error(
|
|
16
|
+
'useDb must be used within a <Sp00kyProvider>. Wrap your app in <Sp00kyProvider config={...}>.'
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Count of locally-committed mutations not yet acknowledged by the server.
|
|
23
|
+
* Drive an "unsaved changes" indicator off this.
|
|
24
|
+
*/
|
|
25
|
+
export function usePendingMutations(): Accessor<number> {
|
|
26
|
+
const db = useDb();
|
|
27
|
+
return fromSubscription((cb) => db.subscribeToPendingMutations(cb), db.pendingMutationCount);
|
|
28
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ColumnSchema,
|
|
3
|
+
FinalQuery,
|
|
4
|
+
SchemaStructure,
|
|
5
|
+
TableNames,
|
|
6
|
+
} from '@spooky-sync/query-builder';
|
|
7
|
+
import { createEffect } from 'solid-js';
|
|
8
|
+
import { SyncedDb } from '..';
|
|
9
|
+
import type {
|
|
10
|
+
Sp00kyQueryResultPromise,
|
|
11
|
+
PreloadOptions as CorePreloadOptions,
|
|
12
|
+
} from '@spooky-sync/core';
|
|
13
|
+
import { useDb } from './context';
|
|
14
|
+
|
|
15
|
+
type PreloadArg<
|
|
16
|
+
S extends SchemaStructure,
|
|
17
|
+
TableName extends TableNames<S>,
|
|
18
|
+
T extends { columns: Record<string, ColumnSchema> },
|
|
19
|
+
RelatedFields extends Record<string, any>,
|
|
20
|
+
IsOne extends boolean,
|
|
21
|
+
> =
|
|
22
|
+
| FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
|
|
23
|
+
| (() =>
|
|
24
|
+
| FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
|
|
25
|
+
| null
|
|
26
|
+
| undefined);
|
|
27
|
+
|
|
28
|
+
type PreloadOptions = CorePreloadOptions & {
|
|
29
|
+
/** Only preload while this returns true (defaults to always). */
|
|
30
|
+
enabled?: () => boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Overload: context-based (no explicit db)
|
|
34
|
+
export function createPreload<
|
|
35
|
+
S extends SchemaStructure,
|
|
36
|
+
TableName extends TableNames<S>,
|
|
37
|
+
T extends { columns: Record<string, ColumnSchema> },
|
|
38
|
+
RelatedFields extends Record<string, any>,
|
|
39
|
+
IsOne extends boolean,
|
|
40
|
+
>(
|
|
41
|
+
finalQuery: PreloadArg<S, TableName, T, RelatedFields, IsOne>,
|
|
42
|
+
options?: PreloadOptions
|
|
43
|
+
): void;
|
|
44
|
+
|
|
45
|
+
// Overload: explicit db
|
|
46
|
+
export function createPreload<
|
|
47
|
+
S extends SchemaStructure,
|
|
48
|
+
TableName extends TableNames<S>,
|
|
49
|
+
T extends { columns: Record<string, ColumnSchema> },
|
|
50
|
+
RelatedFields extends Record<string, any>,
|
|
51
|
+
IsOne extends boolean,
|
|
52
|
+
>(
|
|
53
|
+
db: SyncedDb<S>,
|
|
54
|
+
finalQuery: PreloadArg<S, TableName, T, RelatedFields, IsOne>,
|
|
55
|
+
options?: PreloadOptions
|
|
56
|
+
): void;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reactive, fire-and-forget prewarm. Resolves the query (calling it if it's a
|
|
60
|
+
* function so it tracks reactive deps), dedupes on the query's stable identity
|
|
61
|
+
* hash, and warms it into the local cache via `db.preload`. No subscription and
|
|
62
|
+
* no cleanup: preload registers nothing that needs tearing down.
|
|
63
|
+
*
|
|
64
|
+
* Typical use: inside a list row, preload the detail query the user is likely
|
|
65
|
+
* to open next, so navigation paints from cache instead of the network.
|
|
66
|
+
*/
|
|
67
|
+
export function createPreload<
|
|
68
|
+
S extends SchemaStructure,
|
|
69
|
+
TableName extends TableNames<S>,
|
|
70
|
+
T extends { columns: Record<string, ColumnSchema> },
|
|
71
|
+
RelatedFields extends Record<string, any>,
|
|
72
|
+
IsOne extends boolean,
|
|
73
|
+
>(
|
|
74
|
+
dbOrQuery: SyncedDb<S> | PreloadArg<S, TableName, T, RelatedFields, IsOne>,
|
|
75
|
+
queryOrOptions?: PreloadArg<S, TableName, T, RelatedFields, IsOne> | PreloadOptions,
|
|
76
|
+
maybeOptions?: PreloadOptions
|
|
77
|
+
): void {
|
|
78
|
+
let db: SyncedDb<S>;
|
|
79
|
+
let finalQuery: PreloadArg<S, TableName, T, RelatedFields, IsOne>;
|
|
80
|
+
let options: PreloadOptions | undefined;
|
|
81
|
+
|
|
82
|
+
if (dbOrQuery instanceof SyncedDb) {
|
|
83
|
+
db = dbOrQuery;
|
|
84
|
+
finalQuery = queryOrOptions as PreloadArg<S, TableName, T, RelatedFields, IsOne>;
|
|
85
|
+
options = maybeOptions;
|
|
86
|
+
} else {
|
|
87
|
+
db = useDb<S>();
|
|
88
|
+
finalQuery = dbOrQuery;
|
|
89
|
+
options = queryOrOptions as PreloadOptions | undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let prevHash: number | undefined;
|
|
93
|
+
|
|
94
|
+
// Two-arg Solid 2 effect: compute resolves the query (tracking its reactive
|
|
95
|
+
// deps) and dedupes on the identity hash; the untracked apply fires the
|
|
96
|
+
// preload. Returning `undefined` from compute skips nothing — apply guards.
|
|
97
|
+
createEffect(
|
|
98
|
+
() => {
|
|
99
|
+
if (!(options?.enabled?.() ?? true)) return undefined;
|
|
100
|
+
const query = typeof finalQuery === 'function' ? finalQuery() : finalQuery;
|
|
101
|
+
if (!query) return undefined;
|
|
102
|
+
// Dedupe on the query's stable identity hash so a reactive re-run with
|
|
103
|
+
// an unchanged query doesn't refetch (the core also dedupes per session).
|
|
104
|
+
if (query.hash === prevHash) return undefined;
|
|
105
|
+
prevHash = query.hash;
|
|
106
|
+
return query;
|
|
107
|
+
},
|
|
108
|
+
(query) => {
|
|
109
|
+
if (!query) return;
|
|
110
|
+
void db
|
|
111
|
+
.getSp00ky()
|
|
112
|
+
.preload(query, { refresh: options?.refresh, staleTime: options?.staleTime });
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ColumnSchema,
|
|
3
|
+
FinalQuery,
|
|
4
|
+
SchemaStructure,
|
|
5
|
+
TableNames,
|
|
6
|
+
QueryResult,
|
|
7
|
+
} from '@spooky-sync/query-builder';
|
|
8
|
+
import {
|
|
9
|
+
createMemo,
|
|
10
|
+
createProjection,
|
|
11
|
+
createSignal,
|
|
12
|
+
onCleanup,
|
|
13
|
+
type Accessor,
|
|
14
|
+
} from 'solid-js';
|
|
15
|
+
import { SyncedDb } from '..';
|
|
16
|
+
import type { Sp00kyQueryResultPromise } from '@spooky-sync/core';
|
|
17
|
+
import { useDb } from './context';
|
|
18
|
+
import { conflate } from './conflate';
|
|
19
|
+
|
|
20
|
+
type QueryArg<
|
|
21
|
+
S extends SchemaStructure,
|
|
22
|
+
TableName extends TableNames<S>,
|
|
23
|
+
T extends { columns: Record<string, ColumnSchema> },
|
|
24
|
+
RelatedFields extends Record<string, any>,
|
|
25
|
+
IsOne extends boolean,
|
|
26
|
+
> =
|
|
27
|
+
| FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
|
|
28
|
+
| (() =>
|
|
29
|
+
| FinalQuery<S, TableName, T, RelatedFields, IsOne, Sp00kyQueryResultPromise>
|
|
30
|
+
| null
|
|
31
|
+
| undefined);
|
|
32
|
+
|
|
33
|
+
export type QueryOptions = {
|
|
34
|
+
enabled?: () => boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Tear down the query (remote `_00_query` view + local WASM view) when this
|
|
37
|
+
* hook is disposed and no other subscriber remains, instead of keeping it
|
|
38
|
+
* resident for cheap re-subscription. Use for viewport-windowed lists that
|
|
39
|
+
* mount/unmount a query per scroll window and want off-screen windows
|
|
40
|
+
* cancelled. Trade-off: scrolling back to a torn-down window re-registers it.
|
|
41
|
+
*/
|
|
42
|
+
deregisterOnCleanup?: boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type CreateQueryResult<TData> = {
|
|
46
|
+
/**
|
|
47
|
+
* Reactive result. Never suspends and never throws: born as an empty
|
|
48
|
+
* committed value (`[]` / `null`) and reconciled in place (keyed by `id`) on
|
|
49
|
+
* every live emission — unchanged rows keep identity, and coarse readers
|
|
50
|
+
* (`<For>`) are notified on add/remove/reorder.
|
|
51
|
+
*/
|
|
52
|
+
data: Accessor<TData>;
|
|
53
|
+
/**
|
|
54
|
+
* Suspending read of the same result for `<Loading>` users: throws Solid's
|
|
55
|
+
* not-ready protocol until the query has delivered its first real result
|
|
56
|
+
* (or errored, in which case it returns the empty value and `error()` is
|
|
57
|
+
* set). Read this inside a `<Loading>` boundary.
|
|
58
|
+
*/
|
|
59
|
+
ready: Accessor<TData>;
|
|
60
|
+
error: Accessor<Error | undefined>;
|
|
61
|
+
isLoading: Accessor<boolean>;
|
|
62
|
+
isFetching: Accessor<boolean>;
|
|
63
|
+
/**
|
|
64
|
+
* True once the query has delivered a result AND no fetch cycle is in
|
|
65
|
+
* flight (registration + initial sync included). While settled, results are
|
|
66
|
+
* authoritative: a windowed query returning fewer rows than its LIMIT
|
|
67
|
+
* really is the end of the list. Resets when the query identity changes.
|
|
68
|
+
*/
|
|
69
|
+
isSettled: Accessor<boolean>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Overload: context-based (no explicit db)
|
|
73
|
+
export function createQuery<
|
|
74
|
+
S extends SchemaStructure,
|
|
75
|
+
TableName extends TableNames<S>,
|
|
76
|
+
T extends { columns: Record<string, ColumnSchema> },
|
|
77
|
+
RelatedFields extends Record<string, any>,
|
|
78
|
+
IsOne extends boolean,
|
|
79
|
+
TData = QueryResult<S, TableName, RelatedFields, IsOne> | null,
|
|
80
|
+
>(
|
|
81
|
+
finalQuery: QueryArg<S, TableName, T, RelatedFields, IsOne>,
|
|
82
|
+
options?: QueryOptions
|
|
83
|
+
): CreateQueryResult<TData>;
|
|
84
|
+
|
|
85
|
+
// Overload: explicit db
|
|
86
|
+
export function createQuery<
|
|
87
|
+
S extends SchemaStructure,
|
|
88
|
+
TableName extends TableNames<S>,
|
|
89
|
+
T extends { columns: Record<string, ColumnSchema> },
|
|
90
|
+
RelatedFields extends Record<string, any>,
|
|
91
|
+
IsOne extends boolean,
|
|
92
|
+
TData = QueryResult<S, TableName, RelatedFields, IsOne> | null,
|
|
93
|
+
>(
|
|
94
|
+
db: SyncedDb<S>,
|
|
95
|
+
finalQuery: QueryArg<S, TableName, T, RelatedFields, IsOne>,
|
|
96
|
+
options?: QueryOptions
|
|
97
|
+
): CreateQueryResult<TData>;
|
|
98
|
+
|
|
99
|
+
// Implementation
|
|
100
|
+
export function createQuery<
|
|
101
|
+
S extends SchemaStructure,
|
|
102
|
+
TableName extends TableNames<S>,
|
|
103
|
+
T extends {
|
|
104
|
+
columns: Record<string, ColumnSchema>;
|
|
105
|
+
},
|
|
106
|
+
RelatedFields extends Record<string, any>,
|
|
107
|
+
IsOne extends boolean,
|
|
108
|
+
TData = QueryResult<S, TableName, RelatedFields, IsOne> | null,
|
|
109
|
+
>(
|
|
110
|
+
dbOrQuery: SyncedDb<S> | QueryArg<S, TableName, T, RelatedFields, IsOne>,
|
|
111
|
+
queryOrOptions?: QueryArg<S, TableName, T, RelatedFields, IsOne> | QueryOptions,
|
|
112
|
+
maybeOptions?: QueryOptions
|
|
113
|
+
): CreateQueryResult<TData> {
|
|
114
|
+
let db: SyncedDb<S>;
|
|
115
|
+
let finalQuery: QueryArg<S, TableName, T, RelatedFields, IsOne>;
|
|
116
|
+
let options: QueryOptions | undefined;
|
|
117
|
+
|
|
118
|
+
if (dbOrQuery instanceof SyncedDb) {
|
|
119
|
+
db = dbOrQuery;
|
|
120
|
+
finalQuery = queryOrOptions as QueryArg<S, TableName, T, RelatedFields, IsOne>;
|
|
121
|
+
options = maybeOptions;
|
|
122
|
+
} else {
|
|
123
|
+
db = useDb<S>();
|
|
124
|
+
finalQuery = dbOrQuery;
|
|
125
|
+
options = queryOrOptions as QueryOptions | undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const sp00ky = db.getSp00ky();
|
|
129
|
+
|
|
130
|
+
// Status channel. Written from subscription callbacks and generator
|
|
131
|
+
// continuations, which run outside any tracking scope — `ownedWrite` opts
|
|
132
|
+
// these signals out of Solid 2's owned-scope write guard.
|
|
133
|
+
const [error, setError] = createSignal<Error | undefined>(undefined, { ownedWrite: true });
|
|
134
|
+
const [isFetched, setIsFetched] = createSignal(false, { ownedWrite: true });
|
|
135
|
+
const [isFetching, setIsFetching] = createSignal(false, { ownedWrite: true });
|
|
136
|
+
|
|
137
|
+
// The hash of the currently-installed subscription, for opt-in deregister on
|
|
138
|
+
// dispose (see `deregisterOnCleanup`).
|
|
139
|
+
let activeHash: string | undefined;
|
|
140
|
+
|
|
141
|
+
// Results live in a projection: each yielded emission is reconciled in place
|
|
142
|
+
// keyed by `id` (unchanged rows keep identity; coarse readers are notified
|
|
143
|
+
// on add/remove/reorder — probed in rc-semantics.test.ts, replacing the
|
|
144
|
+
// Solid 1 reconcile + version-signal hack). `seedLoadingValue` births the
|
|
145
|
+
// store committed, so `data()` reads never suspend.
|
|
146
|
+
//
|
|
147
|
+
// The compute's tracked reads (enabled, query thunk) all happen before the
|
|
148
|
+
// first await — Solid 2 only creates dependency edges for pre-await reads. A
|
|
149
|
+
// dep change restarts the generator; the superseded one is ABANDONED by
|
|
150
|
+
// Solid (no return()/finally — probed), so the onCleanup registered
|
|
151
|
+
// synchronously below is what tears down its subscriptions. This also
|
|
152
|
+
// replaces the Solid 1 hook's runId/prevQueryString supersede machinery:
|
|
153
|
+
// Solid dedupes re-runs whose tracked reads are unchanged, and identical
|
|
154
|
+
// query identity means an identical hash means the same compute inputs.
|
|
155
|
+
const store = createProjection(
|
|
156
|
+
async function* (): AsyncGenerator<{ value: TData }> {
|
|
157
|
+
const enabled = options?.enabled?.() ?? true;
|
|
158
|
+
const query = typeof finalQuery === 'function' ? finalQuery() : finalQuery;
|
|
159
|
+
|
|
160
|
+
if (!enabled || !query) {
|
|
161
|
+
setIsFetched(false);
|
|
162
|
+
setError(undefined);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// A new identity starts clean: a previous identity's failure must not
|
|
167
|
+
// keep this one out of its loading state.
|
|
168
|
+
setIsFetched(false);
|
|
169
|
+
setError(undefined);
|
|
170
|
+
|
|
171
|
+
const iterators: AsyncIterator<any>[] = [];
|
|
172
|
+
const cleanups: (() => void)[] = [];
|
|
173
|
+
onCleanup(() => {
|
|
174
|
+
for (const it of iterators) void it.return?.();
|
|
175
|
+
for (const c of cleanups) c();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
/**
|
|
180
|
+
* Registration can fail — the canonical case is the SSP answering 503
|
|
181
|
+
* NOT_READY while it bootstraps. Surface it as `error()` instead of
|
|
182
|
+
* throwing into the graph: the sync scheduler retries the
|
|
183
|
+
* registration underneath, so a transient failure still recovers, and
|
|
184
|
+
* a spinner driven by `isLoading()` resolves via `error()`.
|
|
185
|
+
*/
|
|
186
|
+
const { hash } = await query.run();
|
|
187
|
+
activeHash = hash;
|
|
188
|
+
|
|
189
|
+
// Mirror the query's fetch status so the UI can show a "loading more"
|
|
190
|
+
// state while the sync engine pulls missing records in the background.
|
|
191
|
+
cleanups.push(
|
|
192
|
+
sp00ky.subscribeQueryStatus(hash, (status) => setIsFetching(status === 'fetching'), {
|
|
193
|
+
immediate: true,
|
|
194
|
+
})
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const it = conflate<Record<string, any>[]>((cb) =>
|
|
198
|
+
sp00ky.subscribe(hash, cb, { immediate: true })
|
|
199
|
+
)[Symbol.asyncIterator]();
|
|
200
|
+
iterators.push(it);
|
|
201
|
+
|
|
202
|
+
let isFirstCall = true;
|
|
203
|
+
while (true) {
|
|
204
|
+
const r = await it.next();
|
|
205
|
+
if (r.done) break;
|
|
206
|
+
const e = r.value;
|
|
207
|
+
const queryData = (query.isOne ? (e[0] ?? null) : e) as TData;
|
|
208
|
+
// The first (immediate) callback with no data likely means the local
|
|
209
|
+
// DB hasn't synced yet — don't mark as fetched so UI shows loading.
|
|
210
|
+
const hasData = query.isOne
|
|
211
|
+
? queryData !== null && queryData !== undefined
|
|
212
|
+
: e.length > 0;
|
|
213
|
+
if (!isFirstCall || hasData) setIsFetched(true);
|
|
214
|
+
isFirstCall = false;
|
|
215
|
+
|
|
216
|
+
// Time the store commit (yield → resume) and report it as the
|
|
217
|
+
// "frontend" phase for DevTools/MCP. Approximate: Solid reconciles
|
|
218
|
+
// the yielded value before resuming the generator.
|
|
219
|
+
const t0 = performance.now();
|
|
220
|
+
yield { value: queryData };
|
|
221
|
+
sp00ky.reportFrontendTiming(hash, performance.now() - t0);
|
|
222
|
+
}
|
|
223
|
+
} catch (err) {
|
|
224
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
// Wrapped in an object so `one()` queries (row object or null) and list
|
|
228
|
+
// queries share one store shape; `key` reconciles `value`'s contents.
|
|
229
|
+
{ value: null as TData },
|
|
230
|
+
{ key: 'id', seedLoadingValue: true }
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// Fallback empty value served before the first emission of a list query.
|
|
234
|
+
const emptyList = [] as unknown as TData;
|
|
235
|
+
|
|
236
|
+
const data: Accessor<TData> = () => {
|
|
237
|
+
const v = store.value;
|
|
238
|
+
if (v === null || v === undefined) {
|
|
239
|
+
const query = typeof finalQuery === 'function' ? finalQuery() : finalQuery;
|
|
240
|
+
if (query && !query.isOne) return emptyList;
|
|
241
|
+
}
|
|
242
|
+
return v as TData;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// Suspending read: pends until the first real result (or error) via an async
|
|
246
|
+
// memo that resolves when isFetched/error flips. Reading it inside <Loading>
|
|
247
|
+
// integrates with Solid 2's boundary protocol; `data` stays non-throwing.
|
|
248
|
+
const readyGate = createMemo(async (): Promise<true> => {
|
|
249
|
+
if (isFetched() || error()) return true;
|
|
250
|
+
// Tracked reads above registered the deps; park until one flips.
|
|
251
|
+
await new Promise<void>(() => {});
|
|
252
|
+
return true;
|
|
253
|
+
});
|
|
254
|
+
const ready: Accessor<TData> = () => {
|
|
255
|
+
readyGate();
|
|
256
|
+
return data();
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// Tear down the live subscription when the hook's owner is disposed. The
|
|
260
|
+
// projection's own onCleanup (inside the compute) already unsubscribes; this
|
|
261
|
+
// hook-scope cleanup only handles the opt-in query deregistration.
|
|
262
|
+
onCleanup(() => {
|
|
263
|
+
// Opt-in: cancel the query once this hook (its last subscriber) is gone.
|
|
264
|
+
// The compute's cleanup removed this hook's callback, so deregisterQuery's
|
|
265
|
+
// refcount guard sees the true remaining-subscriber count.
|
|
266
|
+
if (options?.deregisterOnCleanup && activeHash) {
|
|
267
|
+
sp00ky.deregisterQuery(activeHash);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const isLoading = () => !isFetched() && error() === undefined;
|
|
272
|
+
const isSettled = () => isFetched() && !isFetching();
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
data,
|
|
276
|
+
ready,
|
|
277
|
+
error,
|
|
278
|
+
isLoading,
|
|
279
|
+
isFetching,
|
|
280
|
+
isSettled,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** @deprecated Renamed `createQuery` in the Solid 2 binding. */
|
|
285
|
+
export const useQuery = createQuery;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createSignal, type Accessor } from 'solid-js';
|
|
2
|
+
|
|
3
|
+
export interface Submission<Args extends unknown[], R> {
|
|
4
|
+
/** Run the wrapped async fn. Concurrent submits share the pending flag. */
|
|
5
|
+
submit: (...args: Args) => Promise<R | undefined>;
|
|
6
|
+
/** True while at least one submit is in flight. */
|
|
7
|
+
pending: Accessor<boolean>;
|
|
8
|
+
/** Error from the most recent settled submit, cleared on the next submit. */
|
|
9
|
+
error: Accessor<Error | undefined>;
|
|
10
|
+
/** Result of the most recent successful submit. */
|
|
11
|
+
result: Accessor<R | undefined>;
|
|
12
|
+
clearError: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Thin submission-state wrapper for mutations — button spinner/disable state
|
|
17
|
+
* around `db.create/update/delete/run` calls.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately NOT built on Solid 2's `action()`/`createOptimisticStore`: the
|
|
20
|
+
* spooky engine is already optimistic local-first (writes commit to the local
|
|
21
|
+
* DB and re-render through live queries before sync; `run()` is an outbox
|
|
22
|
+
* CREATE), so a transaction/revert layer on top buys nothing and `action()`'s
|
|
23
|
+
* await-vs-yield transaction escape is a real footgun. Errors here mean the
|
|
24
|
+
* LOCAL commit failed — sync/push failures surface through `useSyncStatus`
|
|
25
|
+
* and `usePendingMutations` instead.
|
|
26
|
+
*/
|
|
27
|
+
export function createSubmission<Args extends unknown[], R>(
|
|
28
|
+
fn: (...args: Args) => Promise<R>
|
|
29
|
+
): Submission<Args, R> {
|
|
30
|
+
// Written from promise continuations — outside any tracking scope.
|
|
31
|
+
const [inFlight, setInFlight] = createSignal(0, { ownedWrite: true });
|
|
32
|
+
const [error, setError] = createSignal<Error | undefined>(undefined, { ownedWrite: true });
|
|
33
|
+
const [result, setResult] = createSignal<R | undefined>(undefined, { ownedWrite: true });
|
|
34
|
+
|
|
35
|
+
const submit = async (...args: Args): Promise<R | undefined> => {
|
|
36
|
+
setError(undefined);
|
|
37
|
+
setInFlight((n) => n + 1);
|
|
38
|
+
try {
|
|
39
|
+
const r = await fn(...args);
|
|
40
|
+
setResult(() => r);
|
|
41
|
+
return r;
|
|
42
|
+
} catch (e) {
|
|
43
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
44
|
+
return undefined;
|
|
45
|
+
} finally {
|
|
46
|
+
setInFlight((n) => n - 1);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
submit,
|
|
52
|
+
pending: () => inFlight() > 0,
|
|
53
|
+
error,
|
|
54
|
+
result,
|
|
55
|
+
clearError: () => setError(undefined),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createMemo, onCleanup, type Accessor } from 'solid-js';
|
|
2
|
+
import { conflate } from './conflate';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reactive view over a spooky subscribe-callback API.
|
|
6
|
+
*
|
|
7
|
+
* The memo's async generator pulls from a conflated (latest-wins) iterator;
|
|
8
|
+
* `initial` is committed as the memo's `loadingValue`, so the accessor is
|
|
9
|
+
* readable synchronously from birth and never suspends. Spooky's subscribe
|
|
10
|
+
* APIs fire immediately with the current value, so the real value lands within
|
|
11
|
+
* a tick of the first read.
|
|
12
|
+
*
|
|
13
|
+
* Teardown is manual by contract (see conflate.ts): onCleanup terminates the
|
|
14
|
+
* iterator, which unsubscribes.
|
|
15
|
+
*/
|
|
16
|
+
export function fromSubscription<T>(
|
|
17
|
+
subscribe: (cb: (v: T) => void) => (() => void) | Promise<() => void>,
|
|
18
|
+
initial: T
|
|
19
|
+
): Accessor<T> {
|
|
20
|
+
return createMemo(
|
|
21
|
+
async function* (): AsyncGenerator<T> {
|
|
22
|
+
const it = conflate(subscribe)[Symbol.asyncIterator]();
|
|
23
|
+
onCleanup(() => void it.return?.());
|
|
24
|
+
while (true) {
|
|
25
|
+
const r = await it.next();
|
|
26
|
+
if (r.done) break;
|
|
27
|
+
yield r.value;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
{ loadingValue: initial }
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { RecordId } from 'surrealdb';
|
|
2
|
+
|
|
3
|
+
// Re-export types from query-builder for backward compatibility
|
|
4
|
+
export type { GenericModel, GenericSchema } from '@spooky-sync/query-builder';
|
|
5
|
+
|
|
6
|
+
// Model and ModelPayload types for the client
|
|
7
|
+
export type Model<T> = T;
|
|
8
|
+
export type ModelPayload<T> = T & { id: RecordId };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Accessor } from 'solid-js';
|
|
2
|
+
import { onCleanup } from 'solid-js';
|
|
3
|
+
import { useDb } from './context';
|
|
4
|
+
import { fromSubscription } from './from-subscription';
|
|
5
|
+
import { semverGt, type AppReleaseOptions, type AppReleaseSnapshot } from '@spooky-sync/core';
|
|
6
|
+
|
|
7
|
+
export interface UseAppReleaseOptions extends AppReleaseOptions {
|
|
8
|
+
/** App name from sp00ky.yml, e.g. `web`. */
|
|
9
|
+
app: string;
|
|
10
|
+
/**
|
|
11
|
+
* The running build's version (X.Y.Z), typically baked in at build time
|
|
12
|
+
* (e.g. a vite `define` from package.json). `updateAvailable()` is true when
|
|
13
|
+
* the announced release is semver-newer than this.
|
|
14
|
+
*/
|
|
15
|
+
currentVersion: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface UseAppRelease {
|
|
19
|
+
/** Latest announced version for the app, or undefined when no row exists. */
|
|
20
|
+
latestVersion: Accessor<string | undefined>;
|
|
21
|
+
/** Announced version is semver-newer than the running build. */
|
|
22
|
+
updateAvailable: Accessor<boolean>;
|
|
23
|
+
/** The newer release asks clients to update/reload without prompting. */
|
|
24
|
+
mandatory: Accessor<boolean>;
|
|
25
|
+
/** The newer release asks reloads to clear service-worker caches first. */
|
|
26
|
+
cacheBust: Accessor<boolean>;
|
|
27
|
+
/**
|
|
28
|
+
* Reload onto the announced release. Plain `location.reload()` normally;
|
|
29
|
+
* when the release is flagged cache-bust, CacheStorage is cleared, the
|
|
30
|
+
* service-worker registration is nudged to update, and navigation carries a
|
|
31
|
+
* `?cb=` token to punch through intermediary caches. The service worker is
|
|
32
|
+
* deliberately NOT unregistered: navigating while still controlled by a
|
|
33
|
+
* just-unregistered worker strands subresource fetches on the dead worker
|
|
34
|
+
* and the page hangs until a manual reload.
|
|
35
|
+
*/
|
|
36
|
+
reload: () => Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function reloadForSnapshot(snapshot: AppReleaseSnapshot): Promise<void> {
|
|
40
|
+
if (typeof window === 'undefined') return;
|
|
41
|
+
if (snapshot.cacheBust) {
|
|
42
|
+
try {
|
|
43
|
+
if (window.caches) {
|
|
44
|
+
const keys = await window.caches.keys();
|
|
45
|
+
await Promise.all(keys.map((k) => window.caches.delete(k)));
|
|
46
|
+
}
|
|
47
|
+
if (navigator.serviceWorker) {
|
|
48
|
+
const regs = await navigator.serviceWorker.getRegistrations();
|
|
49
|
+
for (const r of regs) r.update().catch(() => {});
|
|
50
|
+
}
|
|
51
|
+
window.location.href = window.location.pathname + '?cb=' + Date.now();
|
|
52
|
+
return;
|
|
53
|
+
} catch {
|
|
54
|
+
/* fall through to a plain reload */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
window.location.reload();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Observe the app's announced release (`_00_app_release:<app>`, written by
|
|
62
|
+
* `spky deploy` / `spky release`) and compare it against the running build.
|
|
63
|
+
*
|
|
64
|
+
* Typical use: mount a small "new version available — Reload" notification
|
|
65
|
+
* gated on `updateAvailable()`, auto-invoking `reload()` when `mandatory()`
|
|
66
|
+
* (guard the auto path against reload loops with a per-version marker, since
|
|
67
|
+
* a client can reload while the deploy is still rolling out and land on the
|
|
68
|
+
* old bundle again).
|
|
69
|
+
*/
|
|
70
|
+
export function useAppRelease(options: UseAppReleaseOptions): UseAppRelease {
|
|
71
|
+
const db = useDb();
|
|
72
|
+
const handle = db.getSp00ky().appRelease(options.app, { ttl: options.ttl });
|
|
73
|
+
onCleanup(() => handle.close());
|
|
74
|
+
|
|
75
|
+
const snapshot = fromSubscription<AppReleaseSnapshot>(
|
|
76
|
+
(cb) => handle.subscribe(cb),
|
|
77
|
+
handle.snapshot()
|
|
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
|
+
}
|