@pylonsync/react 0.2.4

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/src/typed.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Typed database client — narrow pylon's untyped `db` object using your
3
+ * generated `AppSchema` (from `pylon codegen client`).
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * // src/pylon.client.ts (generated by `pylon codegen client`)
8
+ * export interface AppSchema {
9
+ * entities: { Todo: Todo; User: User };
10
+ * functions: {
11
+ * placeBid: { input: { lotId: string; amount: number }; output: { accepted: boolean } };
12
+ * };
13
+ * queries: {};
14
+ * }
15
+ *
16
+ * // src/db.ts
17
+ * import { createTypedDb } from "@pylonsync/react";
18
+ * import type { AppSchema } from "./pylon.client";
19
+ * export const db = createTypedDb<AppSchema>();
20
+ *
21
+ * // Components get full type safety:
22
+ * const { data } = db.useQuery("Todo"); // data: Todo[]
23
+ * const { data: user } = db.useQueryOne("User", "u_1"); // user: User | null
24
+ * const bid = db.useMutation("placeBid"); // typed args + result
25
+ * await bid.mutate({ lotId: "x", amount: 150 }); // TS checks input type
26
+ * ```
27
+ */
28
+
29
+ import type { Row } from "@pylonsync/sync";
30
+ import { db as untypedDb } from "./db";
31
+ import type {
32
+ QueryOptions,
33
+ UseQueryReturn,
34
+ UseQueryOneReturn,
35
+ UseMutationReturn,
36
+ UseInfiniteQueryReturn,
37
+ } from "./hooks";
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Schema shape
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Shape of the generated `AppSchema` interface.
45
+ *
46
+ * The CLI's `client_codegen` emits a type matching this shape. Consumers
47
+ * never construct `AppSchema` manually.
48
+ */
49
+ export interface AgentDBSchema {
50
+ entities: Record<string, unknown>;
51
+ functions: Record<string, { input: unknown; output: unknown }>;
52
+ queries: Record<string, { input: unknown; output: unknown }>;
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // TypedDb — mirrors `db` but with generics keyed on the schema
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export interface TypedDb<S extends AgentDBSchema> {
60
+ /** Live query with type inferred from the schema. */
61
+ useQuery<K extends keyof S["entities"]>(
62
+ entity: K,
63
+ options?: QueryOptions
64
+ ): UseQueryReturn<S["entities"][K]>;
65
+
66
+ /** Live single-row query. */
67
+ useQueryOne<K extends keyof S["entities"]>(
68
+ entity: K,
69
+ id: string
70
+ ): UseQueryOneReturn<S["entities"][K]>;
71
+
72
+ /** Server-side function call with typed input/output. */
73
+ useMutation<K extends keyof S["functions"]>(
74
+ fnName: K
75
+ ): UseMutationReturn<S["functions"][K]["input"], S["functions"][K]["output"]>;
76
+
77
+ /** Paginated live query. */
78
+ useInfiniteQuery<K extends keyof S["entities"]>(
79
+ entity: K,
80
+ options?: { pageSize?: number }
81
+ ): UseInfiniteQueryReturn<S["entities"][K]>;
82
+
83
+ /** Call a server-side function directly (outside React). */
84
+ fn<K extends keyof S["functions"]>(
85
+ name: K,
86
+ args: S["functions"][K]["input"]
87
+ ): Promise<S["functions"][K]["output"]>;
88
+
89
+ /** Insert a row (optimistic). */
90
+ insert<K extends keyof S["entities"]>(
91
+ entity: K,
92
+ data: Partial<S["entities"][K]>
93
+ ): unknown;
94
+
95
+ /** Update a row (optimistic). */
96
+ update<K extends keyof S["entities"]>(
97
+ entity: K,
98
+ id: string,
99
+ data: Partial<S["entities"][K]>
100
+ ): unknown;
101
+
102
+ /** Delete a row (optimistic). */
103
+ delete<K extends keyof S["entities"]>(entity: K, id: string): unknown;
104
+
105
+ /** Underlying untyped db (escape hatch). */
106
+ readonly untyped: typeof untypedDb;
107
+ }
108
+
109
+ /**
110
+ * Create a typed client from a generated `AppSchema`.
111
+ *
112
+ * At runtime this is literally the same object as `db`; types are narrowed
113
+ * at compile time via generics.
114
+ */
115
+ export function createTypedDb<S extends AgentDBSchema>(): TypedDb<S> {
116
+ const d = untypedDb as unknown as {
117
+ useQuery: <T>(entity: string, options?: QueryOptions) => UseQueryReturn<T>;
118
+ useQueryOne: <T>(entity: string, id: string) => UseQueryOneReturn<T>;
119
+ useMutation: <A, R>(fn: string) => UseMutationReturn<A, R>;
120
+ useInfiniteQuery: <T>(
121
+ entity: string,
122
+ options?: { pageSize?: number }
123
+ ) => UseInfiniteQueryReturn<T>;
124
+ fn: <R>(name: string, args?: Record<string, unknown>) => Promise<R>;
125
+ insert: (entity: string, data: Row) => unknown;
126
+ update: (entity: string, id: string, data: Partial<Row>) => unknown;
127
+ delete: (entity: string, id: string) => unknown;
128
+ };
129
+
130
+ return {
131
+ useQuery: ((entity, options) =>
132
+ d.useQuery(entity as string, options)) as TypedDb<S>["useQuery"],
133
+ useQueryOne: ((entity, id) =>
134
+ d.useQueryOne(entity as string, id)) as TypedDb<S>["useQueryOne"],
135
+ useMutation: ((fnName) =>
136
+ d.useMutation(fnName as string)) as TypedDb<S>["useMutation"],
137
+ useInfiniteQuery: ((entity, options) =>
138
+ d.useInfiniteQuery(entity as string, options)) as TypedDb<S>["useInfiniteQuery"],
139
+ fn: ((name, args) =>
140
+ d.fn(name as string, args as Record<string, unknown>)) as TypedDb<S>["fn"],
141
+ insert: ((entity, data) =>
142
+ d.insert(entity as string, data as Row)) as TypedDb<S>["insert"],
143
+ update: ((entity, id, data) =>
144
+ d.update(entity as string, id, data as Partial<Row>)) as TypedDb<S>["update"],
145
+ delete: ((entity, id) =>
146
+ d.delete(entity as string, id)) as TypedDb<S>["delete"],
147
+ untyped: untypedDb,
148
+ };
149
+ }
package/src/useRoom.ts ADDED
@@ -0,0 +1,231 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
+ import { getBaseUrl, getReactStorage, storageKey } from './index';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Room types
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export interface RoomPeer {
9
+ user_id: string;
10
+ data: any;
11
+ joined_at: string;
12
+ }
13
+
14
+ export interface RoomSnapshot {
15
+ room: string;
16
+ peers: RoomPeer[];
17
+ }
18
+
19
+ export interface UseRoomOptions {
20
+ /** Base URL of the pylon server. */
21
+ baseUrl?: string;
22
+ /** Auth token for API requests. */
23
+ token?: string;
24
+ /** Initial presence data sent on join. */
25
+ initialPresence?: Record<string, any>;
26
+ /** How often to poll for peer updates (ms). Defaults to 5 000. */
27
+ heartbeatInterval?: number;
28
+ }
29
+
30
+ export interface UseRoomReturn {
31
+ /** Current peers in the room (excluding self). */
32
+ peers: RoomPeer[];
33
+ /** Whether currently connected to the room. */
34
+ isConnected: boolean;
35
+ /** Update your presence data (e.g. cursor position, typing status). */
36
+ setPresence: (data: Record<string, any>) => void;
37
+ /** Broadcast a message to the room on a given topic. */
38
+ broadcast: (topic: string, data: any) => void;
39
+ /** Leave the room manually. */
40
+ leave: () => void;
41
+ /** Error message, if any. */
42
+ error: string | null;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Hook
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * Subscribe to a real-time room. Joins on mount, leaves on unmount, and
51
+ * polls for peer updates on a configurable interval.
52
+ *
53
+ * ```tsx
54
+ * const { peers, isConnected, setPresence, broadcast, leave, error } = useRoom(
55
+ * "project-42",
56
+ * currentUser.id,
57
+ * { baseUrl: "http://localhost:4321", token }
58
+ * );
59
+ * ```
60
+ */
61
+ /**
62
+ * Read the current pylon token from the configured storage adapter
63
+ * (default: localStorage on web, AsyncStorage on RN, etc). Keeps the
64
+ * hook working even when the caller doesn't explicitly thread a token
65
+ * — otherwise every useRoom request hits the server as anonymous and
66
+ * 401s under any authenticated room policy.
67
+ */
68
+ function readStoredToken(): string | undefined {
69
+ return getReactStorage().get(storageKey('token')) ?? undefined;
70
+ }
71
+
72
+ export function useRoom(
73
+ roomId: string,
74
+ userId: string,
75
+ options: UseRoomOptions = {},
76
+ ): UseRoomReturn {
77
+ const {
78
+ // Fall back to the globally configured baseUrl so room requests don't
79
+ // land on the Vite dev origin (localhost:5173) and 404 when the caller
80
+ // forgets to pass one.
81
+ baseUrl = getBaseUrl(),
82
+ token: explicitToken,
83
+ initialPresence = {},
84
+ heartbeatInterval = 5_000,
85
+ } = options;
86
+ // Resolve at render time rather than hook-creation time so the room
87
+ // reconnects with a fresh token after login.
88
+ const token = explicitToken ?? readStoredToken();
89
+
90
+ const [peers, setPeers] = useState<RoomPeer[]>([]);
91
+ const [isConnected, setIsConnected] = useState(false);
92
+ const [error, setError] = useState<string | null>(null);
93
+
94
+ const presenceRef = useRef(initialPresence);
95
+ const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
96
+
97
+ // Stable header builder -- only changes when `token` changes.
98
+ const headers = useCallback((): Record<string, string> => {
99
+ const h: Record<string, string> = { 'Content-Type': 'application/json' };
100
+ if (token) h['Authorization'] = `Bearer ${token}`;
101
+ return h;
102
+ }, [token]);
103
+
104
+ // ------- lifecycle: join / heartbeat / leave -------
105
+ //
106
+ // React StrictMode double-mounts every effect in dev: mount → unmount →
107
+ // re-mount. The `joined` ref tracks whether the join() call actually
108
+ // landed before the cleanup ran. If join hadn't completed yet, the
109
+ // cleanup skips leave entirely — there's nothing to leave on the
110
+ // server's side. If join did land, leave fires normally. Server-side
111
+ // leave is also idempotent now (200 with was_present:false on
112
+ // duplicate), so even a race on this can't error in the network tab.
113
+ useEffect(() => {
114
+ let mounted = true;
115
+ let joined = false;
116
+
117
+ const join = async () => {
118
+ try {
119
+ const res = await fetch(`${baseUrl}/api/rooms/join`, {
120
+ method: 'POST',
121
+ headers: headers(),
122
+ body: JSON.stringify({
123
+ room: roomId,
124
+ user_id: userId,
125
+ data: presenceRef.current,
126
+ }),
127
+ });
128
+ const body = await res.json();
129
+ if (!mounted) return;
130
+
131
+ if (res.ok) {
132
+ joined = true;
133
+ setIsConnected(true);
134
+ setError(null);
135
+ if (body.snapshot?.peers) {
136
+ setPeers(
137
+ (body.snapshot.peers as RoomPeer[]).filter(
138
+ (p) => p.user_id !== userId,
139
+ ),
140
+ );
141
+ }
142
+ } else {
143
+ setError(body.error?.message || 'Failed to join room');
144
+ }
145
+ } catch (e: any) {
146
+ if (mounted) setError(e.message);
147
+ }
148
+ };
149
+
150
+ join();
151
+
152
+ // Poll for peer list updates.
153
+ intervalRef.current = setInterval(async () => {
154
+ if (!mounted) return;
155
+ try {
156
+ const res = await fetch(
157
+ `${baseUrl}/api/rooms/${encodeURIComponent(roomId)}`,
158
+ { headers: headers() },
159
+ );
160
+ if (res.ok) {
161
+ const body = await res.json();
162
+ if (mounted) {
163
+ setPeers(
164
+ ((body.members ?? []) as RoomPeer[]).filter(
165
+ (p) => p.user_id !== userId,
166
+ ),
167
+ );
168
+ }
169
+ }
170
+ } catch {
171
+ // Swallow -- next heartbeat will retry.
172
+ }
173
+ }, heartbeatInterval);
174
+
175
+ return () => {
176
+ mounted = false;
177
+ if (intervalRef.current) clearInterval(intervalRef.current);
178
+
179
+ // Skip the leave call when join never completed — fixes the
180
+ // StrictMode double-mount race that produced spurious "user
181
+ // not in this room" errors. Server leave is also idempotent so
182
+ // a stray duplicate would 200 anyway, but we save the round trip.
183
+ if (joined) {
184
+ fetch(`${baseUrl}/api/rooms/leave`, {
185
+ method: 'POST',
186
+ headers: headers(),
187
+ body: JSON.stringify({ room: roomId, user_id: userId }),
188
+ }).catch(() => {});
189
+ }
190
+ };
191
+ // Re-run the entire lifecycle when identity or connection details change.
192
+ // eslint-disable-next-line react-hooks/exhaustive-deps
193
+ }, [roomId, userId, baseUrl, token, heartbeatInterval]);
194
+
195
+ // ------- actions -------
196
+
197
+ const setPresence = useCallback(
198
+ (data: Record<string, any>) => {
199
+ presenceRef.current = data;
200
+ fetch(`${baseUrl}/api/rooms/presence`, {
201
+ method: 'POST',
202
+ headers: headers(),
203
+ body: JSON.stringify({ room: roomId, user_id: userId, data }),
204
+ }).catch(() => {});
205
+ },
206
+ [roomId, userId, baseUrl, headers],
207
+ );
208
+
209
+ const broadcast = useCallback(
210
+ (topic: string, data: any) => {
211
+ fetch(`${baseUrl}/api/rooms/broadcast`, {
212
+ method: 'POST',
213
+ headers: headers(),
214
+ body: JSON.stringify({ room: roomId, user_id: userId, topic, data }),
215
+ }).catch(() => {});
216
+ },
217
+ [roomId, userId, baseUrl, headers],
218
+ );
219
+
220
+ const leave = useCallback(() => {
221
+ fetch(`${baseUrl}/api/rooms/leave`, {
222
+ method: 'POST',
223
+ headers: headers(),
224
+ body: JSON.stringify({ room: roomId, user_id: userId }),
225
+ }).catch(() => {});
226
+ setIsConnected(false);
227
+ setPeers([]);
228
+ }, [roomId, userId, baseUrl, headers]);
229
+
230
+ return { peers, isConnected, setPresence, broadcast, leave, error };
231
+ }
@@ -0,0 +1,71 @@
1
+ import { SyncEngine, type ResolvedSession } from "@pylonsync/sync";
2
+ import { useEffect, useSyncExternalStore } from "react";
3
+
4
+ export type { ResolvedSession };
5
+
6
+ export interface UseSessionReturn {
7
+ /** Server-resolved session. `userId=null` means anonymous. */
8
+ session: ResolvedSession;
9
+ /** Convenience accessors. */
10
+ userId: string | null;
11
+ tenantId: string | null;
12
+ isAdmin: boolean;
13
+ isAuthenticated: boolean;
14
+ /**
15
+ * Force a refresh of the cached session. Call after sign-in, sign-out,
16
+ * or `/api/auth/select-org` so the UI and sync engine pick up the
17
+ * change before the next pull/reconnect.
18
+ */
19
+ refresh: () => Promise<void>;
20
+ }
21
+
22
+ /**
23
+ * Subscribe to the server-resolved session held by the sync engine.
24
+ *
25
+ * The engine fetches `/api/auth/me` on start and on token flips, caches
26
+ * the result, and notifies the store on change — so this hook is
27
+ * purely a reader. Mutations (login/logout/select-org) are still the
28
+ * caller's responsibility; after them, invoke `refresh()` (or
29
+ * `engine.notifySessionChanged()`) to pull the new session immediately.
30
+ */
31
+ export function useSession(sync: SyncEngine): UseSessionReturn {
32
+ const session = useSyncExternalStore(
33
+ (cb) => sync.store.subscribe(cb),
34
+ () => sync.resolvedSession(),
35
+ () => sync.resolvedSession(),
36
+ );
37
+
38
+ // Watch the localStorage token key. If another tab signs in/out, or the
39
+ // app writes a new token without going through `notifySessionChanged`,
40
+ // our cached session still matches the old identity until the next
41
+ // pull. A `storage` event covers the cross-tab case; a mount-time
42
+ // refresh covers the same-tab write-then-mount race.
43
+ useEffect(() => {
44
+ void sync.notifySessionChanged();
45
+ const onStorage = (e: StorageEvent) => {
46
+ // Only refresh when the key that actually changed looks like a
47
+ // pylon token. Skip unrelated keys so we don't generate an
48
+ // /api/auth/me flood from noisy apps.
49
+ if (!e.key) return;
50
+ if (e.key.startsWith("pylon_") || e.key.startsWith("pylon:")) {
51
+ if (e.key.endsWith("token") || e.key.endsWith(":token")) {
52
+ void sync.notifySessionChanged();
53
+ }
54
+ }
55
+ };
56
+ if (typeof window !== "undefined") {
57
+ window.addEventListener("storage", onStorage);
58
+ return () => window.removeEventListener("storage", onStorage);
59
+ }
60
+ return undefined;
61
+ }, [sync]);
62
+
63
+ return {
64
+ session,
65
+ userId: session.userId,
66
+ tenantId: session.tenantId,
67
+ isAdmin: session.isAdmin,
68
+ isAuthenticated: session.userId != null,
69
+ refresh: () => sync.notifySessionChanged(),
70
+ };
71
+ }