@syncular/react 0.15.16 → 0.15.17

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 CHANGED
@@ -229,6 +229,10 @@ not need a custom retention effect.
229
229
 
230
230
  ## Other hooks
231
231
 
232
+ - `useDiagnostics({ expectedSubscriptions })` observes the versioned,
233
+ privacy-safe support snapshot. Native/Worker events trigger a fresh request,
234
+ preserving expected-but-unregistered intent on every host. Subscription ids
235
+ must be stable and PHI-free; scopes are not accepted.
232
236
  - `useSyncStatus()` observes the status domain without a follow-up read after
233
237
  every row change. It includes `availability` and `currentSchemaVersion`;
234
238
  `outbox` is local push work, while `syncNeeded` specifically means an inbound
@@ -243,3 +247,52 @@ not need a custom retention effect.
243
247
  surfaces for integrations.
244
248
 
245
249
  The hooks are SSR-safe: no local query runs during server rendering.
250
+
251
+ ### Privacy-safe support view
252
+
253
+ Use application-owned, stable, PHI-free subscription ids to make missing
254
+ registration distinguishable from a completed zero-row bootstrap. The hook
255
+ refreshes after diagnostics events on direct, Worker, Tauri, and React Native
256
+ hosts while preserving that expected intent:
257
+
258
+ ```tsx
259
+ import { useDiagnostics } from '@syncular/react';
260
+
261
+ const expectedSubscriptions = [
262
+ { id: 'membership-security', table: 'facility_memberships' },
263
+ { id: 'scheduler-window', table: 'surgeries' },
264
+ ] as const;
265
+
266
+ function SyncSupportPanel() {
267
+ const { snapshot, isLoading, error, refresh } = useDiagnostics({
268
+ expectedSubscriptions,
269
+ });
270
+
271
+ if (isLoading) return <p>Collecting local sync evidence…</p>;
272
+ if (error) return <p>Diagnostics unavailable: {error.message}</p>;
273
+
274
+ return (
275
+ <section>
276
+ <button type="button" onClick={() => void refresh()}>
277
+ Refresh
278
+ </button>
279
+ <button
280
+ type="button"
281
+ disabled={!snapshot}
282
+ onClick={() =>
283
+ snapshot &&
284
+ void navigator.clipboard.writeText(JSON.stringify(snapshot, null, 2))
285
+ }
286
+ >
287
+ Copy support snapshot
288
+ </button>
289
+ <pre>{JSON.stringify(snapshot, null, 2)}</pre>
290
+ </section>
291
+ );
292
+ }
293
+ ```
294
+
295
+ The snapshot deliberately excludes scopes, rows, clinical counts, SQL, paths,
296
+ identities, credentials, mutation bodies, stack traces, and arbitrary prose.
297
+ Do not enrich the copied bundle with database files, query results, or console
298
+ dumps. See SPEC §7.6 for the complete contract.
package/dist/client.d.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * value → read it) and wraps every result in `Promise.resolve`, so a hook
12
12
  * never has to care which core it holds.
13
13
  */
14
- import type { ClientChangeListener, CommitOutcome, CommitOutcomeQuery, ConflictRecord, InvalidationListener, LeadershipState, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, ResolveCommitOutcomeInput, SchemaFloor, SecurityLifecycle, SqlRow, SqlValue, SyncStatusSnapshot, WindowBase, WindowState } from '@syncular/client';
14
+ import type { ClientChangeListener, ClientDiagnosticsListener, ClientDiagnosticsRequest, ClientDiagnosticsSnapshot, CommitOutcome, CommitOutcomeQuery, ConflictRecord, InvalidationListener, LeadershipState, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, ResolveCommitOutcomeInput, SchemaFloor, SecurityLifecycle, SqlRow, SqlValue, SyncStatusSnapshot, WindowBase, WindowState } from '@syncular/client';
15
15
  /**
16
16
  * The structural union of `SyncClient` and `SyncClientHandle`. Members that
17
17
  * diverge are typed as "value or method, sync or promise"; {@link normalizeClient}
@@ -21,6 +21,7 @@ import type { ClientChangeListener, CommitOutcome, CommitOutcomeQuery, ConflictR
21
21
  export interface SyncClientLike {
22
22
  readonly currentSchemaVersion?: number;
23
23
  onChange(listener: ClientChangeListener): () => void;
24
+ onDiagnostics(listener: ClientDiagnosticsListener): () => void;
24
25
  onInvalidate(listener: InvalidationListener): () => void;
25
26
  onPresence(listener: (scopeKey: string) => void): () => void;
26
27
  onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
@@ -37,6 +38,7 @@ export interface SyncClientLike {
37
38
  purgeLocalData(input: LocalDataPurgeInput): LocalDataPurgeResult | Promise<LocalDataPurgeResult>;
38
39
  querySnapshot<Row = SqlRow>(spec: QueryReadSpec): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
39
40
  statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
41
+ diagnosticsSnapshot(request?: ClientDiagnosticsRequest): ClientDiagnosticsSnapshot | Promise<ClientDiagnosticsSnapshot>;
40
42
  conflicts: readonly ConflictRecord[] | (() => readonly ConflictRecord[] | Promise<readonly ConflictRecord[]>);
41
43
  rejections: readonly RejectionRecord[] | (() => readonly RejectionRecord[] | Promise<readonly RejectionRecord[]>);
42
44
  commitOutcome(clientCommitId: string): CommitOutcome | undefined | Promise<CommitOutcome | undefined>;
@@ -58,6 +60,7 @@ export interface SyncClientLike {
58
60
  export interface NormalizedClient {
59
61
  readonly currentSchemaVersion?: number;
60
62
  onChange(listener: ClientChangeListener): () => void;
63
+ onDiagnostics(listener: ClientDiagnosticsListener): () => void;
61
64
  onInvalidate(listener: InvalidationListener): () => void;
62
65
  onPresence(listener: (scopeKey: string) => void): () => void;
63
66
  onLeadershipChange(listener: (state: LeadershipState) => void): () => void;
@@ -74,6 +77,7 @@ export interface NormalizedClient {
74
77
  purgeLocalData(input: LocalDataPurgeInput): Promise<LocalDataPurgeResult>;
75
78
  querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
76
79
  statusSnapshot(): Promise<SyncStatusSnapshot>;
80
+ diagnosticsSnapshot(request?: ClientDiagnosticsRequest): Promise<ClientDiagnosticsSnapshot>;
77
81
  conflicts(): Promise<readonly ConflictRecord[]>;
78
82
  rejections(): Promise<readonly RejectionRecord[]>;
79
83
  commitOutcome(clientCommitId: string): Promise<CommitOutcome | undefined>;
package/dist/client.js CHANGED
@@ -16,6 +16,7 @@ export function normalizeClient(client) {
16
16
  ? { currentSchemaVersion: client.currentSchemaVersion }
17
17
  : {}),
18
18
  onChange: (listener) => client.onChange(listener),
19
+ onDiagnostics: (listener) => client.onDiagnostics(listener),
19
20
  onInvalidate: (listener) => client.onInvalidate(listener),
20
21
  onPresence: (listener) => client.onPresence(listener),
21
22
  onLeadershipChange: (listener) => client.onLeadershipChange?.(listener) ?? (() => { }),
@@ -29,6 +30,7 @@ export function normalizeClient(client) {
29
30
  purgeLocalData: (input) => Promise.resolve(client.purgeLocalData(input)),
30
31
  querySnapshot: (spec) => Promise.resolve(client.querySnapshot(spec)),
31
32
  statusSnapshot: () => Promise.resolve(client.statusSnapshot()),
33
+ diagnosticsSnapshot: (request) => Promise.resolve(client.diagnosticsSnapshot(request)),
32
34
  conflicts: () => resolveMember(client, 'conflicts'),
33
35
  rejections: () => resolveMember(client, 'rejections'),
34
36
  commitOutcome: (clientCommitId) => Promise.resolve(client.commitOutcome(clientCommitId)),
package/dist/index.d.ts CHANGED
@@ -15,6 +15,7 @@ export { createSyncClientResource, isSyncClientResource, type SyncClientResource
15
15
  export { useReactiveStore, useSyncClient } from './use-client.js';
16
16
  export { type UseCommitOutcomesResult, useCommitOutcomes, } from './use-commit-outcomes.js';
17
17
  export { type UseConflictsResult, useConflicts } from './use-conflicts.js';
18
+ export { type UseDiagnosticsOptions, type UseDiagnosticsResult, useDiagnostics, } from './use-diagnostics.js';
18
19
  export { type SyncTableDescriptor, type UseMutationOptions, type UseMutationResult, type UseTableMutationResult, useMutation, } from './use-mutation.js';
19
20
  export { usePresence } from './use-presence.js';
20
21
  export { type NamedQueryDescriptor, useQuery, } from './use-query.js';
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ export { createSyncClientResource, isSyncClientResource, } from './resource.js';
8
8
  export { useReactiveStore, useSyncClient } from './use-client.js';
9
9
  export { useCommitOutcomes, } from './use-commit-outcomes.js';
10
10
  export { useConflicts } from './use-conflicts.js';
11
+ export { useDiagnostics, } from './use-diagnostics.js';
11
12
  export { useMutation, } from './use-mutation.js';
12
13
  export { usePresence } from './use-presence.js';
13
14
  export { useQuery, } from './use-query.js';
@@ -0,0 +1,20 @@
1
+ import type { ClientDiagnosticsSnapshot, ExpectedDiagnosticSubscription } from '@syncular/client';
2
+ export interface UseDiagnosticsOptions {
3
+ /**
4
+ * Application-owned subscription intent. IDs must be stable and PHI-free;
5
+ * no scope values are accepted by the diagnostics contract.
6
+ */
7
+ readonly expectedSubscriptions?: readonly ExpectedDiagnosticSubscription[];
8
+ }
9
+ export interface UseDiagnosticsResult {
10
+ readonly snapshot: ClientDiagnosticsSnapshot | undefined;
11
+ readonly isLoading: boolean;
12
+ readonly error: Error | undefined;
13
+ readonly refresh: () => void;
14
+ }
15
+ /**
16
+ * Observe the versioned, privacy-safe diagnostics snapshot. Native/Worker
17
+ * events are treated as invalidation signals and followed by a fresh request,
18
+ * so expected-but-unregistered subscriptions remain visible on every host.
19
+ */
20
+ export declare function useDiagnostics(options?: UseDiagnosticsOptions): UseDiagnosticsResult;
@@ -0,0 +1,44 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useSyncClient } from './use-client.js';
3
+ /**
4
+ * Observe the versioned, privacy-safe diagnostics snapshot. Native/Worker
5
+ * events are treated as invalidation signals and followed by a fresh request,
6
+ * so expected-but-unregistered subscriptions remain visible on every host.
7
+ */
8
+ export function useDiagnostics(options = {}) {
9
+ const client = useSyncClient();
10
+ const requestKey = JSON.stringify(options.expectedSubscriptions ?? []);
11
+ const request = useMemo(() => {
12
+ const expectedSubscriptions = JSON.parse(requestKey);
13
+ return expectedSubscriptions.length === 0 ? {} : { expectedSubscriptions };
14
+ }, [requestKey]);
15
+ const generation = useRef(0);
16
+ const [snapshot, setSnapshot] = useState();
17
+ const [isLoading, setIsLoading] = useState(true);
18
+ const [error, setError] = useState();
19
+ const refresh = useCallback(() => {
20
+ const current = ++generation.current;
21
+ setIsLoading(true);
22
+ void client.diagnosticsSnapshot(request).then((next) => {
23
+ if (generation.current !== current)
24
+ return;
25
+ setSnapshot(next);
26
+ setError(undefined);
27
+ setIsLoading(false);
28
+ }, (reason) => {
29
+ if (generation.current !== current)
30
+ return;
31
+ setError(reason instanceof Error ? reason : new Error(String(reason)));
32
+ setIsLoading(false);
33
+ });
34
+ }, [client, request]);
35
+ useEffect(() => {
36
+ const unsubscribe = client.onDiagnostics(refresh);
37
+ refresh();
38
+ return () => {
39
+ generation.current += 1;
40
+ unsubscribe();
41
+ };
42
+ }, [client, refresh]);
43
+ return { snapshot, isLoading, error, refresh };
44
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/react",
3
- "version": "0.15.16",
3
+ "version": "0.15.17",
4
4
  "description": "React hooks for Syncular offline-first sync",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -48,15 +48,15 @@
48
48
  "test": "bun test --preload ./test/setup.ts"
49
49
  },
50
50
  "dependencies": {
51
- "@syncular/client": "0.15.16"
51
+ "@syncular/client": "0.15.17"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "react": ">=18.0.0"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@happy-dom/global-registrator": "^20.0.0",
58
- "@syncular/core": "0.15.16",
59
- "@syncular/server": "0.15.16",
58
+ "@syncular/core": "0.15.17",
59
+ "@syncular/server": "0.15.17",
60
60
  "@testing-library/react": "^16.1.0",
61
61
  "@types/react": "^18.3.0",
62
62
  "react": "^18.3.1",
package/src/client.ts CHANGED
@@ -13,6 +13,9 @@
13
13
  */
14
14
  import type {
15
15
  ClientChangeListener,
16
+ ClientDiagnosticsListener,
17
+ ClientDiagnosticsRequest,
18
+ ClientDiagnosticsSnapshot,
16
19
  CommitOutcome,
17
20
  CommitOutcomeQuery,
18
21
  ConflictRecord,
@@ -45,6 +48,7 @@ import type {
45
48
  export interface SyncClientLike {
46
49
  readonly currentSchemaVersion?: number;
47
50
  onChange(listener: ClientChangeListener): () => void;
51
+ onDiagnostics(listener: ClientDiagnosticsListener): () => void;
48
52
  onInvalidate(listener: InvalidationListener): () => void;
49
53
  onPresence(listener: (scopeKey: string) => void): () => void;
50
54
  onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
@@ -73,6 +77,9 @@ export interface SyncClientLike {
73
77
  spec: QueryReadSpec,
74
78
  ): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
75
79
  statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
80
+ diagnosticsSnapshot(
81
+ request?: ClientDiagnosticsRequest,
82
+ ): ClientDiagnosticsSnapshot | Promise<ClientDiagnosticsSnapshot>;
76
83
  conflicts:
77
84
  | readonly ConflictRecord[]
78
85
  | (() => readonly ConflictRecord[] | Promise<readonly ConflictRecord[]>);
@@ -133,6 +140,7 @@ function resolveMember<T>(
133
140
  export interface NormalizedClient {
134
141
  readonly currentSchemaVersion?: number;
135
142
  onChange(listener: ClientChangeListener): () => void;
143
+ onDiagnostics(listener: ClientDiagnosticsListener): () => void;
136
144
  onInvalidate(listener: InvalidationListener): () => void;
137
145
  onPresence(listener: (scopeKey: string) => void): () => void;
138
146
  onLeadershipChange(listener: (state: LeadershipState) => void): () => void;
@@ -152,6 +160,9 @@ export interface NormalizedClient {
152
160
  purgeLocalData(input: LocalDataPurgeInput): Promise<LocalDataPurgeResult>;
153
161
  querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
154
162
  statusSnapshot(): Promise<SyncStatusSnapshot>;
163
+ diagnosticsSnapshot(
164
+ request?: ClientDiagnosticsRequest,
165
+ ): Promise<ClientDiagnosticsSnapshot>;
155
166
  conflicts(): Promise<readonly ConflictRecord[]>;
156
167
  rejections(): Promise<readonly RejectionRecord[]>;
157
168
  commitOutcome(clientCommitId: string): Promise<CommitOutcome | undefined>;
@@ -179,6 +190,7 @@ export function normalizeClient(client: SyncClientLike): NormalizedClient {
179
190
  ? { currentSchemaVersion: client.currentSchemaVersion }
180
191
  : {}),
181
192
  onChange: (listener) => client.onChange(listener),
193
+ onDiagnostics: (listener) => client.onDiagnostics(listener),
182
194
  onInvalidate: (listener) => client.onInvalidate(listener),
183
195
  onPresence: (listener) => client.onPresence(listener),
184
196
  onLeadershipChange: (listener) =>
@@ -195,6 +207,8 @@ export function normalizeClient(client: SyncClientLike): NormalizedClient {
195
207
  purgeLocalData: (input) => Promise.resolve(client.purgeLocalData(input)),
196
208
  querySnapshot: (spec) => Promise.resolve(client.querySnapshot(spec)),
197
209
  statusSnapshot: () => Promise.resolve(client.statusSnapshot()),
210
+ diagnosticsSnapshot: (request) =>
211
+ Promise.resolve(client.diagnosticsSnapshot(request)),
198
212
  conflicts: () => resolveMember(client, 'conflicts'),
199
213
  rejections: () => resolveMember(client, 'rejections'),
200
214
  commitOutcome: (clientCommitId) =>
package/src/index.ts CHANGED
@@ -36,6 +36,11 @@ export {
36
36
  useCommitOutcomes,
37
37
  } from './use-commit-outcomes';
38
38
  export { type UseConflictsResult, useConflicts } from './use-conflicts';
39
+ export {
40
+ type UseDiagnosticsOptions,
41
+ type UseDiagnosticsResult,
42
+ useDiagnostics,
43
+ } from './use-diagnostics';
39
44
  export {
40
45
  type SyncTableDescriptor,
41
46
  type UseMutationOptions,
@@ -0,0 +1,75 @@
1
+ import type {
2
+ ClientDiagnosticsRequest,
3
+ ClientDiagnosticsSnapshot,
4
+ ExpectedDiagnosticSubscription,
5
+ } from '@syncular/client';
6
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
7
+ import { useSyncClient } from './use-client';
8
+
9
+ export interface UseDiagnosticsOptions {
10
+ /**
11
+ * Application-owned subscription intent. IDs must be stable and PHI-free;
12
+ * no scope values are accepted by the diagnostics contract.
13
+ */
14
+ readonly expectedSubscriptions?: readonly ExpectedDiagnosticSubscription[];
15
+ }
16
+
17
+ export interface UseDiagnosticsResult {
18
+ readonly snapshot: ClientDiagnosticsSnapshot | undefined;
19
+ readonly isLoading: boolean;
20
+ readonly error: Error | undefined;
21
+ readonly refresh: () => void;
22
+ }
23
+
24
+ /**
25
+ * Observe the versioned, privacy-safe diagnostics snapshot. Native/Worker
26
+ * events are treated as invalidation signals and followed by a fresh request,
27
+ * so expected-but-unregistered subscriptions remain visible on every host.
28
+ */
29
+ export function useDiagnostics(
30
+ options: UseDiagnosticsOptions = {},
31
+ ): UseDiagnosticsResult {
32
+ const client = useSyncClient();
33
+ const requestKey = JSON.stringify(options.expectedSubscriptions ?? []);
34
+ const request = useMemo<ClientDiagnosticsRequest>(() => {
35
+ const expectedSubscriptions = JSON.parse(
36
+ requestKey,
37
+ ) as ExpectedDiagnosticSubscription[];
38
+ return expectedSubscriptions.length === 0 ? {} : { expectedSubscriptions };
39
+ }, [requestKey]);
40
+ const generation = useRef(0);
41
+ const [snapshot, setSnapshot] = useState<
42
+ ClientDiagnosticsSnapshot | undefined
43
+ >();
44
+ const [isLoading, setIsLoading] = useState(true);
45
+ const [error, setError] = useState<Error | undefined>();
46
+
47
+ const refresh = useCallback(() => {
48
+ const current = ++generation.current;
49
+ setIsLoading(true);
50
+ void client.diagnosticsSnapshot(request).then(
51
+ (next) => {
52
+ if (generation.current !== current) return;
53
+ setSnapshot(next);
54
+ setError(undefined);
55
+ setIsLoading(false);
56
+ },
57
+ (reason: unknown) => {
58
+ if (generation.current !== current) return;
59
+ setError(reason instanceof Error ? reason : new Error(String(reason)));
60
+ setIsLoading(false);
61
+ },
62
+ );
63
+ }, [client, request]);
64
+
65
+ useEffect(() => {
66
+ const unsubscribe = client.onDiagnostics(refresh);
67
+ refresh();
68
+ return () => {
69
+ generation.current += 1;
70
+ unsubscribe();
71
+ };
72
+ }, [client, refresh]);
73
+
74
+ return { snapshot, isLoading, error, refresh };
75
+ }