@syncular/react 0.4.1 → 0.5.1
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/README.md +134 -117
- package/dist/client.d.ts +13 -1
- package/dist/client.js +4 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +5 -4
- package/dist/provider.d.ts +6 -1
- package/dist/provider.js +53 -4
- package/dist/resource.d.ts +18 -0
- package/dist/resource.js +56 -0
- package/dist/use-client.d.ts +2 -0
- package/dist/use-client.js +8 -1
- package/dist/use-conflicts.d.ts +2 -5
- package/dist/use-conflicts.js +12 -35
- package/dist/use-mutation.d.ts +22 -12
- package/dist/use-mutation.js +61 -21
- package/dist/use-query.d.ts +10 -3
- package/dist/use-query.js +7 -2
- package/dist/use-raw-sql.d.ts +15 -29
- package/dist/use-raw-sql.js +54 -186
- package/dist/use-sync-status.d.ts +1 -18
- package/dist/use-sync-status.js +15 -65
- package/dist/use-window.d.ts +13 -25
- package/dist/use-window.js +55 -91
- package/package.json +4 -4
- package/src/client.ts +29 -0
- package/src/index.ts +26 -4
- package/src/provider.ts +89 -7
- package/src/resource.ts +77 -0
- package/src/use-client.ts +12 -1
- package/src/use-conflicts.ts +18 -37
- package/src/use-mutation.ts +126 -25
- package/src/use-query.ts +27 -8
- package/src/use-raw-sql.ts +83 -212
- package/src/use-sync-status.ts +20 -73
- package/src/use-window.ts +90 -103
- package/dist/query-churn.d.ts +0 -109
- package/dist/query-churn.js +0 -276
- package/src/query-churn.ts +0 -317
package/dist/use-mutation.d.ts
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `useMutation` — submit local mutations (§6.1). Returns a stable `mutate`
|
|
3
|
-
* that appends to the outbox and applies the optimistic overlay immediately
|
|
4
|
-
* (§7.1); the referencing `useRawSql` re-runs on the resulting
|
|
5
|
-
* invalidation batch, so optimistic writes appear without a manual refetch.
|
|
6
|
-
* `mutate` resolves to the `clientCommitId` (track it against
|
|
7
|
-
* `useConflicts`/status). `isPending`/`error` cover the (usually instant)
|
|
8
|
-
* submit; server acceptance is observed through status + conflicts, not
|
|
9
|
-
* here.
|
|
10
|
-
*/
|
|
11
1
|
import type { MutationInput } from '@syncular/client';
|
|
2
|
+
export interface SyncTableDescriptor<Row, Insert, Update, Id> {
|
|
3
|
+
readonly name: string;
|
|
4
|
+
readonly primaryKey: keyof Row & string;
|
|
5
|
+
readonly physicalPrimaryKey: string;
|
|
6
|
+
readonly __row?: Row;
|
|
7
|
+
readonly __insert?: Insert;
|
|
8
|
+
readonly __update?: Update;
|
|
9
|
+
readonly __id?: Id;
|
|
10
|
+
}
|
|
11
|
+
export interface UseMutationOptions {
|
|
12
|
+
readonly onSuccess?: (clientCommitId: string) => void;
|
|
13
|
+
readonly onError?: (error: Error) => void;
|
|
14
|
+
}
|
|
12
15
|
export interface UseMutationResult {
|
|
13
|
-
/** Submit mutations; resolves to the clientCommitId. */
|
|
14
16
|
mutate: (mutations: readonly MutationInput[]) => Promise<string>;
|
|
17
|
+
readonly pendingCount: number;
|
|
15
18
|
readonly isPending: boolean;
|
|
16
19
|
readonly error: Error | undefined;
|
|
20
|
+
readonly resetError: () => void;
|
|
21
|
+
}
|
|
22
|
+
export interface UseTableMutationResult<Insert, Update, Id> extends UseMutationResult {
|
|
23
|
+
readonly upsert: (values: Insert, baseVersion?: number) => Promise<string>;
|
|
24
|
+
readonly patch: (id: Id, partial: Partial<Update>, baseVersion?: number) => Promise<string>;
|
|
25
|
+
readonly remove: (id: Id, baseVersion?: number) => Promise<string>;
|
|
17
26
|
}
|
|
18
|
-
export declare function useMutation(): UseMutationResult;
|
|
27
|
+
export declare function useMutation(options?: UseMutationOptions): UseMutationResult;
|
|
28
|
+
export declare function useMutation<Row, Insert, Update, Id>(table: SyncTableDescriptor<Row, Insert, Update, Id>, options?: UseMutationOptions): UseTableMutationResult<Insert, Update, Id>;
|
package/dist/use-mutation.js
CHANGED
|
@@ -1,34 +1,74 @@
|
|
|
1
|
-
|
|
2
|
-
* `useMutation` — submit local mutations (§6.1). Returns a stable `mutate`
|
|
3
|
-
* that appends to the outbox and applies the optimistic overlay immediately
|
|
4
|
-
* (§7.1); the referencing `useRawSql` re-runs on the resulting
|
|
5
|
-
* invalidation batch, so optimistic writes appear without a manual refetch.
|
|
6
|
-
* `mutate` resolves to the `clientCommitId` (track it against
|
|
7
|
-
* `useConflicts`/status). `isPending`/`error` cover the (usually instant)
|
|
8
|
-
* submit; server acceptance is observed through status + conflicts, not
|
|
9
|
-
* here.
|
|
10
|
-
*/
|
|
11
|
-
import { useCallback, useState } from 'react';
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
12
2
|
import { useSyncClient } from './use-client.js';
|
|
13
|
-
export function useMutation() {
|
|
3
|
+
export function useMutation(tableOrOptions, maybeOptions) {
|
|
14
4
|
const client = useSyncClient();
|
|
15
|
-
const
|
|
5
|
+
const table = tableOrOptions !== undefined && 'name' in tableOrOptions
|
|
6
|
+
? tableOrOptions
|
|
7
|
+
: undefined;
|
|
8
|
+
const options = table === undefined
|
|
9
|
+
? tableOrOptions
|
|
10
|
+
: maybeOptions;
|
|
11
|
+
const optionsRef = useRef(options);
|
|
12
|
+
optionsRef.current = options;
|
|
13
|
+
const [pendingCount, setPendingCount] = useState(0);
|
|
16
14
|
const [error, setError] = useState(undefined);
|
|
17
|
-
const
|
|
18
|
-
|
|
15
|
+
const resetError = useCallback(() => setError(undefined), []);
|
|
16
|
+
const run = useCallback(async (operation) => {
|
|
17
|
+
setPendingCount((count) => count + 1);
|
|
19
18
|
setError(undefined);
|
|
20
19
|
try {
|
|
21
|
-
const id = await
|
|
20
|
+
const id = await operation();
|
|
21
|
+
optionsRef.current?.onSuccess?.(id);
|
|
22
22
|
return id;
|
|
23
23
|
}
|
|
24
|
-
catch (
|
|
25
|
-
const wrapped =
|
|
24
|
+
catch (caught) {
|
|
25
|
+
const wrapped = caught instanceof Error ? caught : new Error(String(caught));
|
|
26
26
|
setError(wrapped);
|
|
27
|
+
optionsRef.current?.onError?.(wrapped);
|
|
27
28
|
throw wrapped;
|
|
28
29
|
}
|
|
29
30
|
finally {
|
|
30
|
-
|
|
31
|
+
setPendingCount((count) => Math.max(0, count - 1));
|
|
31
32
|
}
|
|
32
|
-
}, [
|
|
33
|
-
|
|
33
|
+
}, []);
|
|
34
|
+
const mutate = useCallback((mutations) => run(() => client.mutate(mutations)), [client, run]);
|
|
35
|
+
const base = {
|
|
36
|
+
mutate,
|
|
37
|
+
pendingCount,
|
|
38
|
+
isPending: pendingCount > 0,
|
|
39
|
+
error,
|
|
40
|
+
resetError,
|
|
41
|
+
};
|
|
42
|
+
const upsert = useCallback((values, baseVersion) => {
|
|
43
|
+
if (table === undefined)
|
|
44
|
+
throw new Error('table mutation descriptor missing');
|
|
45
|
+
return mutate([
|
|
46
|
+
{
|
|
47
|
+
table: table.name,
|
|
48
|
+
op: 'upsert',
|
|
49
|
+
values: values,
|
|
50
|
+
...(baseVersion !== undefined ? { baseVersion } : {}),
|
|
51
|
+
},
|
|
52
|
+
]);
|
|
53
|
+
}, [mutate, table]);
|
|
54
|
+
const patch = useCallback((id, partial, baseVersion) => {
|
|
55
|
+
if (table === undefined)
|
|
56
|
+
throw new Error('table mutation descriptor missing');
|
|
57
|
+
return run(() => client.patch(table.name, String(id), partial, baseVersion !== undefined ? { baseVersion } : undefined));
|
|
58
|
+
}, [client, run, table]);
|
|
59
|
+
const remove = useCallback((id, baseVersion) => {
|
|
60
|
+
if (table === undefined)
|
|
61
|
+
throw new Error('table mutation descriptor missing');
|
|
62
|
+
return mutate([
|
|
63
|
+
{
|
|
64
|
+
table: table.name,
|
|
65
|
+
op: 'delete',
|
|
66
|
+
rowId: String(id),
|
|
67
|
+
...(baseVersion !== undefined ? { baseVersion } : {}),
|
|
68
|
+
},
|
|
69
|
+
]);
|
|
70
|
+
}, [mutate, table]);
|
|
71
|
+
if (table === undefined)
|
|
72
|
+
return base;
|
|
73
|
+
return { ...base, upsert, patch, remove };
|
|
34
74
|
}
|
package/dist/use-query.d.ts
CHANGED
|
@@ -18,10 +18,12 @@
|
|
|
18
18
|
* (typegen emits its own `NamedQuery` type), so this hook depends only on the
|
|
19
19
|
* descriptor's structural shape — no generated-file import coupling.
|
|
20
20
|
*/
|
|
21
|
-
import type { SqlValue } from '@syncular/client';
|
|
21
|
+
import type { QueryDependency, SqlValue, WindowCoverage } from '@syncular/client';
|
|
22
22
|
import { type UseRawSqlOptions, type UseRawSqlResult } from './use-raw-sql.js';
|
|
23
23
|
/** The structural shape typegen's `NamedQuery<Row, Params>` satisfies. */
|
|
24
24
|
export interface NamedQueryDescriptor<Row, Params> {
|
|
25
|
+
readonly id: string;
|
|
26
|
+
readonly hasParams: boolean;
|
|
25
27
|
readonly sql: string;
|
|
26
28
|
readonly tables: readonly string[];
|
|
27
29
|
readonly bind: (params: Params) => readonly SqlValue[];
|
|
@@ -29,10 +31,15 @@ export interface NamedQueryDescriptor<Row, Params> {
|
|
|
29
31
|
* generate-time-checked allowlist (identifiers never come from runtime
|
|
30
32
|
* input). Absent on knob-less queries — `sql` is the whole statement. */
|
|
31
33
|
readonly sqlFor?: (params: Params) => string;
|
|
34
|
+
readonly dependencies: (params: Params) => readonly QueryDependency[];
|
|
35
|
+
readonly coverage: (params: Params) => readonly WindowCoverage[];
|
|
36
|
+
readonly rowKey?: (row: Row) => readonly SqlValue[];
|
|
32
37
|
/** Phantom row carrier (never read at runtime). */
|
|
33
38
|
readonly __row?: Row;
|
|
34
39
|
}
|
|
40
|
+
type NamedQueryOptions<Row> = Omit<UseRawSqlOptions<Row>, 'tables' | 'scopeKeys' | 'dependencies' | 'coverage' | 'rowKey' | 'id'>;
|
|
35
41
|
/** Run a param-less named query live. */
|
|
36
|
-
export declare function useQuery<Row>(query: NamedQueryDescriptor<Row, undefined>, options?:
|
|
42
|
+
export declare function useQuery<Row>(query: NamedQueryDescriptor<Row, undefined>, options?: NamedQueryOptions<Row>): UseRawSqlResult<Row>;
|
|
37
43
|
/** Run a named query live with its typed params. */
|
|
38
|
-
export declare function useQuery<Row, Params>(query: NamedQueryDescriptor<Row, Params>, params: Params, options?:
|
|
44
|
+
export declare function useQuery<Row, Params>(query: NamedQueryDescriptor<Row, Params>, params: Params, options?: NamedQueryOptions<Row>): UseRawSqlResult<Row>;
|
|
45
|
+
export {};
|
package/dist/use-query.js
CHANGED
|
@@ -2,13 +2,18 @@ import { useRawSql, } from './use-raw-sql.js';
|
|
|
2
2
|
export function useQuery(query, paramsOrOptions, maybeOptions) {
|
|
3
3
|
// Overload disambiguation: a param-less query's second arg (if any) is the
|
|
4
4
|
// options object; a parameterized query's second arg is the params.
|
|
5
|
-
const hasParams = query.
|
|
5
|
+
const hasParams = query.hasParams;
|
|
6
6
|
const params = (hasParams ? paramsOrOptions : undefined);
|
|
7
7
|
const options = (hasParams ? maybeOptions : paramsOrOptions);
|
|
8
8
|
const bound = query.bind(params);
|
|
9
9
|
const sql = query.sqlFor === undefined ? query.sql : query.sqlFor(params);
|
|
10
|
+
const dependencies = query.dependencies(params);
|
|
11
|
+
const coverage = query.coverage(params);
|
|
10
12
|
return useRawSql(sql, bound, {
|
|
11
13
|
...options,
|
|
12
|
-
|
|
14
|
+
id: query.id,
|
|
15
|
+
dependencies,
|
|
16
|
+
coverage,
|
|
17
|
+
...(query.rowKey !== undefined ? { rowKey: query.rowKey } : {}),
|
|
13
18
|
});
|
|
14
19
|
}
|
package/dist/use-raw-sql.d.ts
CHANGED
|
@@ -1,39 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* re-runs ONLY when an invalidation event (one per apply batch, from the
|
|
5
|
-
* web-client choke point) touches a table this query depends on — never
|
|
6
|
-
* "re-run everything". Unrelated commits leave the result untouched (I4).
|
|
7
|
-
*
|
|
8
|
-
* Dependencies default to a conservative scan of the SQL text (FROM/JOIN
|
|
9
|
-
* identifiers, {@link inferTables}); pass the explicit `tables` option to
|
|
10
|
-
* override when the text cannot be read (dynamic SQL, views). `scopeKeys`
|
|
11
|
-
* further narrows re-runs to specific §3.1 scope keys when supplied — but
|
|
12
|
-
* a matching TABLE always re-runs (the table is the honest floor; a segment
|
|
13
|
-
* apply carries no per-row scope keys, so table-level is the safe default).
|
|
14
|
-
*/
|
|
15
|
-
import type { SqlRow, SqlValue } from '@syncular/client';
|
|
16
|
-
export interface UseRawSqlOptions {
|
|
17
|
-
/**
|
|
18
|
-
* Tables this query depends on. Defaults to a conservative scan of `sql`.
|
|
19
|
-
* Pass explicitly to override the heuristic (the escape hatch).
|
|
20
|
-
*/
|
|
1
|
+
import { type LiveQueryPhase, type QueryDependency, type SqlRow, type SqlValue, type WindowCoverage } from '@syncular/client';
|
|
2
|
+
export interface UseRawSqlOptions<Row = SqlRow> {
|
|
3
|
+
/** Legacy table list; prefer table-associated `dependencies`. */
|
|
21
4
|
readonly tables?: readonly string[];
|
|
22
|
-
/**
|
|
23
|
-
* When set, re-run only if the invalidation event's touched table is a
|
|
24
|
-
* dependency AND (the event has no scope keys — table-level, e.g. a
|
|
25
|
-
* bootstrap — OR it touches one of these §3.1 `prefix:value` keys). Leave
|
|
26
|
-
* unset to re-run on any dependency-table touch.
|
|
27
|
-
*/
|
|
5
|
+
/** Legacy narrowing applied to every table in `tables`. */
|
|
28
6
|
readonly scopeKeys?: readonly string[];
|
|
29
|
-
|
|
7
|
+
readonly dependencies?: readonly QueryDependency[];
|
|
8
|
+
readonly coverage?: readonly WindowCoverage[];
|
|
9
|
+
readonly rowKey?: (row: Row) => readonly SqlValue[];
|
|
10
|
+
/** Generated coverage claims its windows by default. */
|
|
11
|
+
readonly claimCoverage?: boolean;
|
|
30
12
|
readonly enabled?: boolean;
|
|
13
|
+
/** Stable identity override for a raw query cache entry. */
|
|
14
|
+
readonly id?: string;
|
|
31
15
|
}
|
|
32
16
|
export interface UseRawSqlResult<Row> {
|
|
33
17
|
readonly rows: readonly Row[];
|
|
18
|
+
readonly phase: LiveQueryPhase;
|
|
19
|
+
readonly revision: bigint | undefined;
|
|
34
20
|
readonly isLoading: boolean;
|
|
21
|
+
readonly isRefreshing: boolean;
|
|
35
22
|
readonly error: Error | undefined;
|
|
36
|
-
/** Force a re-run (identity-stable). */
|
|
37
23
|
readonly refresh: () => void;
|
|
38
24
|
}
|
|
39
|
-
export declare function useRawSql<Row = SqlRow>(sql: string, params?: readonly SqlValue[], options?: UseRawSqlOptions): UseRawSqlResult<Row>;
|
|
25
|
+
export declare function useRawSql<Row = SqlRow>(sql: string, params?: readonly SqlValue[], options?: UseRawSqlOptions<Row>): UseRawSqlResult<Row>;
|
package/dist/use-raw-sql.js
CHANGED
|
@@ -1,191 +1,59 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* (TODO 3.1 / DESIGN-eviction I1–I4). The query runs once on mount, then
|
|
4
|
-
* re-runs ONLY when an invalidation event (one per apply batch, from the
|
|
5
|
-
* web-client choke point) touches a table this query depends on — never
|
|
6
|
-
* "re-run everything". Unrelated commits leave the result untouched (I4).
|
|
7
|
-
*
|
|
8
|
-
* Dependencies default to a conservative scan of the SQL text (FROM/JOIN
|
|
9
|
-
* identifiers, {@link inferTables}); pass the explicit `tables` option to
|
|
10
|
-
* override when the text cannot be read (dynamic SQL, views). `scopeKeys`
|
|
11
|
-
* further narrows re-runs to specific §3.1 scope keys when supplied — but
|
|
12
|
-
* a matching TABLE always re-runs (the table is the honest floor; a segment
|
|
13
|
-
* apply carries no per-row scope keys, so table-level is the safe default).
|
|
14
|
-
*/
|
|
15
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
1
|
+
import { canonicalValue, } from '@syncular/client';
|
|
2
|
+
import { useCallback, useMemo, useSyncExternalStore } from 'react';
|
|
16
3
|
import { inferTables } from './infer-tables.js';
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
function eventMatches(event, tables, scopeKeys) {
|
|
28
|
-
let tableHit = false;
|
|
29
|
-
for (const table of event.tables) {
|
|
30
|
-
if (tables.has(table)) {
|
|
31
|
-
tableHit = true;
|
|
32
|
-
break;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
if (!tableHit)
|
|
36
|
-
return false;
|
|
37
|
-
// Table matched. If the caller narrowed by scope keys, honor it — but a
|
|
38
|
-
// table-level event (no scope keys, e.g. a segment bootstrap or reset)
|
|
39
|
-
// always re-runs, because it carries no key to discriminate on.
|
|
40
|
-
if (scopeKeys === undefined || scopeKeys.length === 0)
|
|
41
|
-
return true;
|
|
42
|
-
if (event.scopeKeys.size === 0)
|
|
43
|
-
return true;
|
|
44
|
-
for (const key of scopeKeys) {
|
|
45
|
-
if (event.scopeKeys.has(key))
|
|
46
|
-
return true;
|
|
47
|
-
}
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
4
|
+
import { useReactiveStore } from './use-client.js';
|
|
5
|
+
const DISABLED = {
|
|
6
|
+
rows: [],
|
|
7
|
+
phase: 'ready',
|
|
8
|
+
revision: undefined,
|
|
9
|
+
error: undefined,
|
|
10
|
+
isRefreshing: false,
|
|
11
|
+
};
|
|
12
|
+
const noSubscribe = () => () => { };
|
|
50
13
|
export function useRawSql(sql, params, options) {
|
|
51
|
-
const
|
|
14
|
+
const store = useReactiveStore();
|
|
52
15
|
const enabled = options?.enabled ?? true;
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
// ONLY on a real transition. React does not reliably bail on a no-op
|
|
96
|
-
// `setState(sameValue)` when other setters fire in the same batch (it can
|
|
97
|
-
// still commit a render), so lever 1a — zero re-render on unchanged data —
|
|
98
|
-
// requires us to guard every setter, not just setRows.
|
|
99
|
-
const isLoadingValueRef = useRef(isLoading);
|
|
100
|
-
isLoadingValueRef.current = isLoading;
|
|
101
|
-
const errorValueRef = useRef(error);
|
|
102
|
-
errorValueRef.current = error;
|
|
103
|
-
const setLoadingIfChanged = (value) => {
|
|
104
|
-
if (isLoadingValueRef.current !== value) {
|
|
105
|
-
isLoadingValueRef.current = value;
|
|
106
|
-
setIsLoading(value);
|
|
107
|
-
}
|
|
108
|
-
};
|
|
109
|
-
const setErrorIfChanged = (value) => {
|
|
110
|
-
if (errorValueRef.current !== value) {
|
|
111
|
-
errorValueRef.current = value;
|
|
112
|
-
setError(value);
|
|
113
|
-
}
|
|
114
|
-
};
|
|
115
|
-
// Keep the run closure current every render (it closes over client via ref).
|
|
116
|
-
runRef.current = async () => {
|
|
117
|
-
if (cancelledRef.current)
|
|
118
|
-
return;
|
|
119
|
-
try {
|
|
120
|
-
const result = await client.query(sqlRef.current, paramsRef.current);
|
|
121
|
-
if (cancelledRef.current)
|
|
122
|
-
return;
|
|
123
|
-
const fresh = result;
|
|
124
|
-
const { next } = reconcileRows(prevRef.current, fresh);
|
|
125
|
-
if (next !== undefined) {
|
|
126
|
-
prevRef.current = next;
|
|
127
|
-
setRows(next.rows);
|
|
128
|
-
}
|
|
129
|
-
// next === undefined → whole result unchanged: NO setRows (zero re-render).
|
|
130
|
-
setErrorIfChanged(undefined);
|
|
131
|
-
setLoadingIfChanged(false);
|
|
132
|
-
}
|
|
133
|
-
catch (err) {
|
|
134
|
-
if (cancelledRef.current)
|
|
135
|
-
return;
|
|
136
|
-
setErrorIfChanged(err instanceof Error ? err : new Error(String(err)));
|
|
137
|
-
setLoadingIfChanged(false);
|
|
138
|
-
}
|
|
16
|
+
const inferred = options?.tables ?? [...inferTables(sql)];
|
|
17
|
+
const dependencies = options?.dependencies ??
|
|
18
|
+
inferred.map((table) => ({
|
|
19
|
+
table,
|
|
20
|
+
...(options?.scopeKeys !== undefined
|
|
21
|
+
? { scopeKeys: options.scopeKeys }
|
|
22
|
+
: {}),
|
|
23
|
+
}));
|
|
24
|
+
const coverage = options?.coverage ?? [];
|
|
25
|
+
const identity = canonicalValue({
|
|
26
|
+
sql,
|
|
27
|
+
params: params ?? [],
|
|
28
|
+
dependencies,
|
|
29
|
+
coverage,
|
|
30
|
+
...(options?.id !== undefined ? { id: options.id } : {}),
|
|
31
|
+
});
|
|
32
|
+
// `identity` canonically contains every value-shaped input. Depending on
|
|
33
|
+
// the caller's array/object references would defeat value-stable query
|
|
34
|
+
// identity; executable rowKey intentionally follows function identity.
|
|
35
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: canonical value identity is the dependency
|
|
36
|
+
const entry = useMemo(() => store.query({
|
|
37
|
+
id: options?.id ?? `raw:${identity}`,
|
|
38
|
+
sql,
|
|
39
|
+
...(params !== undefined ? { params } : {}),
|
|
40
|
+
dependencies,
|
|
41
|
+
...(coverage.length > 0 ? { coverage } : {}),
|
|
42
|
+
...(options?.rowKey !== undefined ? { rowKey: options.rowKey } : {}),
|
|
43
|
+
claimCoverage: options?.claimCoverage ?? true,
|
|
44
|
+
}), [store, identity, options?.rowKey]);
|
|
45
|
+
const snapshot = useSyncExternalStore(enabled ? entry.subscribe : noSubscribe, enabled ? entry.getSnapshot : () => DISABLED, enabled ? entry.getSnapshot : () => DISABLED);
|
|
46
|
+
const refresh = useCallback(() => {
|
|
47
|
+
if (enabled)
|
|
48
|
+
entry.refresh();
|
|
49
|
+
}, [enabled, entry]);
|
|
50
|
+
return {
|
|
51
|
+
rows: snapshot.rows,
|
|
52
|
+
phase: snapshot.phase,
|
|
53
|
+
revision: snapshot.revision,
|
|
54
|
+
isLoading: snapshot.phase === 'loading',
|
|
55
|
+
isRefreshing: snapshot.isRefreshing,
|
|
56
|
+
error: snapshot.error,
|
|
57
|
+
refresh,
|
|
139
58
|
};
|
|
140
|
-
// Mount / query-identity / refresh: run immediately (not frame-coalesced —
|
|
141
|
-
// the caller changed the query, so it must reflect at once). Invalidations go
|
|
142
|
-
// through the scheduler instead.
|
|
143
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: key/tick are intentional re-run triggers
|
|
144
|
-
useEffect(() => {
|
|
145
|
-
cancelledRef.current = false;
|
|
146
|
-
if (!enabled) {
|
|
147
|
-
setLoadingIfChanged(false);
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
// A query-identity change invalidates the reconcile baseline (a different
|
|
151
|
-
// query's rows are not comparable to this one's), so reset it.
|
|
152
|
-
prevRef.current = undefined;
|
|
153
|
-
setLoadingIfChanged(true);
|
|
154
|
-
void runRef.current?.();
|
|
155
|
-
return () => {
|
|
156
|
-
cancelledRef.current = true;
|
|
157
|
-
};
|
|
158
|
-
}, [client, key, enabled, tick]);
|
|
159
|
-
// Subscribe once per client/enabled/query identity; a matching event asks the
|
|
160
|
-
// scheduler for a run (coalesced). `key`/`scopeKeysKey` re-key the sub. The
|
|
161
|
-
// scheduler is read from the ref AT EVENT TIME (never captured at effect
|
|
162
|
-
// setup): the lifecycle effect below may replace it on a remount, and a
|
|
163
|
-
// captured disposed instance would swallow every schedule() silently.
|
|
164
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: key/scopeKeysKey re-key the subscription intentionally
|
|
165
|
-
useEffect(() => {
|
|
166
|
-
if (!enabled)
|
|
167
|
-
return;
|
|
168
|
-
const unsubscribe = client.onInvalidate((event) => {
|
|
169
|
-
if (eventMatches(event, depTablesRef.current, scopeKeysRef.current)) {
|
|
170
|
-
schedulerRef.current?.schedule();
|
|
171
|
-
}
|
|
172
|
-
});
|
|
173
|
-
return unsubscribe;
|
|
174
|
-
}, [client, enabled, key, scopeKeysKey]);
|
|
175
|
-
// Scheduler lifecycle. Under StrictMode (and any future fiber remount)
|
|
176
|
-
// React runs mount → cleanup → mount on the SAME hook instance: the cleanup
|
|
177
|
-
// disposes the scheduler, so the setup must RE-CREATE it — the render-time
|
|
178
|
-
// lazy init above never runs again (the ref is non-undefined), and a
|
|
179
|
-
// disposed scheduler turns every later invalidation into a silent no-op
|
|
180
|
-
// that freezes the live query forever.
|
|
181
|
-
useEffect(() => {
|
|
182
|
-
if (schedulerRef.current === undefined) {
|
|
183
|
-
schedulerRef.current = new FrameScheduler(() => runRef.current?.());
|
|
184
|
-
}
|
|
185
|
-
return () => {
|
|
186
|
-
schedulerRef.current?.dispose();
|
|
187
|
-
schedulerRef.current = undefined;
|
|
188
|
-
};
|
|
189
|
-
}, []);
|
|
190
|
-
return { rows, isLoading, error, refresh };
|
|
191
59
|
}
|
|
@@ -1,29 +1,12 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `useSyncStatus` — the client's operational status: outbox depth,
|
|
3
|
-
* upgrading (§7.4.5), leaseState (§7.3.5), schemaFloor (§1.6), and
|
|
4
|
-
* syncNeeded (§8.4). Status is derived by polling the normalized accessors
|
|
5
|
-
* after every apply batch (invalidation is the natural "something changed"
|
|
6
|
-
* signal) plus an initial read; `refresh()` re-reads on demand.
|
|
7
|
-
*
|
|
8
|
-
* `online` is not a protocol concept the client exposes directly (§1.3
|
|
9
|
-
* transport-owned), so it is reported as `undefined` unless a future
|
|
10
|
-
* accessor lands — the hook surfaces what the core actually knows, never a
|
|
11
|
-
* guessed value.
|
|
12
|
-
*/
|
|
13
1
|
import type { LeaseState, SchemaFloor } from '@syncular/client';
|
|
14
2
|
export interface SyncStatus {
|
|
15
|
-
/** Pending outbox commits (unpushed local writes). */
|
|
16
3
|
readonly outbox: number;
|
|
17
|
-
/** §7.4.5: a schema-bump reset + first re-bootstrap is in flight. */
|
|
18
4
|
readonly upgrading: boolean;
|
|
19
|
-
/** §7.3.5: opaque auth-lease state, or undefined. */
|
|
20
5
|
readonly leaseState: LeaseState | undefined;
|
|
21
|
-
/** §1.6: server-declared schema floor (syncing stopped), or undefined. */
|
|
22
6
|
readonly schemaFloor: SchemaFloor | undefined;
|
|
23
|
-
/** §8.4: the host loop should run a pull soon. */
|
|
24
7
|
readonly syncNeeded: boolean;
|
|
25
|
-
/** True until the first status read resolves. */
|
|
26
8
|
readonly isLoading: boolean;
|
|
9
|
+
readonly error: Error | undefined;
|
|
27
10
|
readonly refresh: () => void;
|
|
28
11
|
}
|
|
29
12
|
export declare function useSyncStatus(): SyncStatus;
|
package/dist/use-sync-status.js
CHANGED
|
@@ -1,67 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* upgrading (§7.4.5), leaseState (§7.3.5), schemaFloor (§1.6), and
|
|
4
|
-
* syncNeeded (§8.4). Status is derived by polling the normalized accessors
|
|
5
|
-
* after every apply batch (invalidation is the natural "something changed"
|
|
6
|
-
* signal) plus an initial read; `refresh()` re-reads on demand.
|
|
7
|
-
*
|
|
8
|
-
* `online` is not a protocol concept the client exposes directly (§1.3
|
|
9
|
-
* transport-owned), so it is reported as `undefined` unless a future
|
|
10
|
-
* accessor lands — the hook surfaces what the core actually knows, never a
|
|
11
|
-
* guessed value.
|
|
12
|
-
*/
|
|
13
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
14
|
-
import { useSyncClient } from './use-client.js';
|
|
15
|
-
const INITIAL = {
|
|
16
|
-
outbox: 0,
|
|
17
|
-
upgrading: false,
|
|
18
|
-
leaseState: undefined,
|
|
19
|
-
schemaFloor: undefined,
|
|
20
|
-
syncNeeded: false,
|
|
21
|
-
isLoading: true,
|
|
22
|
-
};
|
|
1
|
+
import { useCallback, useSyncExternalStore } from 'react';
|
|
2
|
+
import { useReactiveStore } from './use-client.js';
|
|
23
3
|
export function useSyncStatus() {
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
client.schemaFloor(),
|
|
38
|
-
client.syncNeeded(),
|
|
39
|
-
])
|
|
40
|
-
.then(([pending, upgrading, leaseState, schemaFloor, syncNeeded]) => {
|
|
41
|
-
if (cancelled)
|
|
42
|
-
return;
|
|
43
|
-
setState({
|
|
44
|
-
outbox: pending.length,
|
|
45
|
-
upgrading,
|
|
46
|
-
leaseState,
|
|
47
|
-
schemaFloor,
|
|
48
|
-
syncNeeded,
|
|
49
|
-
isLoading: false,
|
|
50
|
-
});
|
|
51
|
-
})
|
|
52
|
-
.catch(() => {
|
|
53
|
-
if (!cancelled)
|
|
54
|
-
setState((s) => ({ ...s, isLoading: false }));
|
|
55
|
-
});
|
|
56
|
-
};
|
|
57
|
-
readRef.current = read;
|
|
58
|
-
read();
|
|
59
|
-
// Re-read on every apply batch — the "state changed" edge.
|
|
60
|
-
const unsubscribe = client.onInvalidate(read);
|
|
61
|
-
return () => {
|
|
62
|
-
cancelled = true;
|
|
63
|
-
unsubscribe();
|
|
64
|
-
};
|
|
65
|
-
}, [client]);
|
|
66
|
-
return { ...state, refresh };
|
|
4
|
+
const entry = useReactiveStore().status;
|
|
5
|
+
const snapshot = useSyncExternalStore(entry.subscribe, entry.getSnapshot, entry.getSnapshot);
|
|
6
|
+
const refresh = useCallback(() => entry.refresh(), [entry]);
|
|
7
|
+
return {
|
|
8
|
+
outbox: snapshot.status?.outbox ?? 0,
|
|
9
|
+
upgrading: snapshot.status?.upgrading ?? false,
|
|
10
|
+
leaseState: snapshot.status?.leaseState,
|
|
11
|
+
schemaFloor: snapshot.status?.schemaFloor,
|
|
12
|
+
syncNeeded: snapshot.status?.syncNeeded ?? false,
|
|
13
|
+
isLoading: snapshot.isLoading,
|
|
14
|
+
error: snapshot.error,
|
|
15
|
+
refresh,
|
|
16
|
+
};
|
|
67
17
|
}
|