@omg-dev/sdk 0.4.24
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/dist/OmgBadge-LAcimQp0.mjs +345 -0
- package/dist/VibesFeedback-BF2Vf6FK.mjs +808 -0
- package/dist/brand/auto.mjs +18 -0
- package/dist/feedback/auto.mjs +26 -0
- package/dist/index.mjs +1611 -0
- package/package.json +43 -0
- package/src/auth/auto-prompt.tsx +50 -0
- package/src/auth/bridge.ts +63 -0
- package/src/auth/client.ts +222 -0
- package/src/auth/fetch.ts +23 -0
- package/src/auth/guard.tsx +24 -0
- package/src/auth/index.ts +23 -0
- package/src/auth/login.tsx +267 -0
- package/src/auth/mail-apps.ts +52 -0
- package/src/auth/react.tsx +248 -0
- package/src/brand/OmgBadge.tsx +366 -0
- package/src/brand/auto.tsx +35 -0
- package/src/brand/index.ts +1 -0
- package/src/feedback/VibesFeedback.tsx +360 -0
- package/src/feedback/auto.tsx +47 -0
- package/src/feedback/gestures.ts +296 -0
- package/src/feedback/index.ts +18 -0
- package/src/feedback/screenshot.ts +37 -0
- package/src/feedback/trace.ts +166 -0
- package/src/index.ts +1042 -0
- package/src/notifications/index.tsx +179 -0
- package/src/sandbox.test.ts +61 -0
- package/src/sandbox.ts +106 -0
- package/src/storage/VibesUpload.tsx +140 -0
- package/src/storage/index.ts +12 -0
- package/src/storage/useUpload.ts +167 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1042 @@
|
|
|
1
|
+
// @omg-dev/sdk — client-side React hooks with realtime updates
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react"
|
|
4
|
+
import { fetchEventSource, type EventSourceMessage } from "@microsoft/fetch-event-source"
|
|
5
|
+
import { getAuthContext, notifyAuthRequired, subscribeAuthChange } from "./auth/bridge"
|
|
6
|
+
|
|
7
|
+
// Re-export the auth surface from root so agents have a single import:
|
|
8
|
+
// `import { VibesAuthGuard, useUser } from "@omg-dev/sdk"`. The publish
|
|
9
|
+
// workflow's jq filter rewrites exports to only `"."` (drops subpaths),
|
|
10
|
+
// so `@omg-dev/sdk/auth` won't resolve in production deploy builds even
|
|
11
|
+
// though it works locally during dev. Re-export here = one import surface
|
|
12
|
+
// that always resolves.
|
|
13
|
+
export * from "./auth/index"
|
|
14
|
+
// Storage surface — useUpload() + <VibesUpload />. Re-exported here for
|
|
15
|
+
// the same publish-workflow reason as auth (only "." subpath survives).
|
|
16
|
+
export * from "./storage/index"
|
|
17
|
+
// Feedback surface — <VibesFeedback /> gesture widget + trace capture.
|
|
18
|
+
// Re-exported here for the same publish-workflow reason (only "." survives).
|
|
19
|
+
export * from "./feedback/index"
|
|
20
|
+
// Brand surface — <OmgBadge /> platform attribution badge.
|
|
21
|
+
export * from "./brand/index"
|
|
22
|
+
// Notifications surface — durable in-app inbox + Web Push registration.
|
|
23
|
+
export * from "./notifications/index"
|
|
24
|
+
// Sandbox router surface — server-side helpers for creating child sandboxes
|
|
25
|
+
// through the in-VM agent, which stamps parent authority.
|
|
26
|
+
export * from "./sandbox"
|
|
27
|
+
|
|
28
|
+
// Builds Headers with Authorization: Bearer <token> when a token is present
|
|
29
|
+
// in the auth bridge. Apps that don't wrap with <VibesAuthProvider> see no
|
|
30
|
+
// token; requests go through unauthenticated and the server returns 401 for
|
|
31
|
+
// scoped collections, 200 for global ones.
|
|
32
|
+
function authHeaders(extra?: HeadersInit): Headers {
|
|
33
|
+
const h = new Headers(extra)
|
|
34
|
+
const { token } = getAuthContext()
|
|
35
|
+
if (token) h.set("Authorization", `Bearer ${token}`)
|
|
36
|
+
return h
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Thrown when a request comes back with the server's auth-required signal
|
|
41
|
+
* (HTTP 401). Callers that don't catch it still won't get garbage data — the
|
|
42
|
+
* old paths swallowed 401 bodies and returned them as if they were rows.
|
|
43
|
+
*/
|
|
44
|
+
export class VibesAuthRequiredError extends Error {
|
|
45
|
+
constructor(message = "Authentication required") {
|
|
46
|
+
super(message)
|
|
47
|
+
this.name = "VibesAuthRequiredError"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Wraps fetch with the auth bridge headers and turns the server's auth-required
|
|
52
|
+
// signal (401) into a notifyAuthRequired() broadcast + a typed throw. Every REST
|
|
53
|
+
// path in this module goes through here so the auto-prompt fires consistently.
|
|
54
|
+
async function apiFetch(input: string, init?: RequestInit): Promise<Response> {
|
|
55
|
+
const res = await fetch(input, { ...init, headers: authHeaders(init?.headers) })
|
|
56
|
+
if (res.status === 401) {
|
|
57
|
+
// Only pop the login dialog once auth has settled — a 401 during the
|
|
58
|
+
// startup token race is transient (the bearer just isn't attached yet).
|
|
59
|
+
if (getAuthContext().authReady) notifyAuthRequired()
|
|
60
|
+
let message = "Authentication required"
|
|
61
|
+
try {
|
|
62
|
+
const body = await res.clone().json()
|
|
63
|
+
if (body && typeof body.error === "string") message = body.error
|
|
64
|
+
} catch {
|
|
65
|
+
// non-JSON body; keep the default message
|
|
66
|
+
}
|
|
67
|
+
throw new VibesAuthRequiredError(message)
|
|
68
|
+
}
|
|
69
|
+
return res
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── useQuery / useCollection ─────────────────────────────────────────────────
|
|
73
|
+
//
|
|
74
|
+
// useQuery is the WS-first default: it subscribes to a collection (optionally
|
|
75
|
+
// scoped by a `where` predicate evaluated server-side) over /__vibes_sub and
|
|
76
|
+
// receives a snapshot + live row deltas. `api` is optional — pass it to enable
|
|
77
|
+
// create/update/remove and the legacy SSE+REST fallback for pre-WS backends.
|
|
78
|
+
// useCollection is a thin, deprecated wrapper kept for back-compat.
|
|
79
|
+
|
|
80
|
+
export interface UseCollectionOptions {
|
|
81
|
+
/** API endpoint, e.g. "/api/entries" */
|
|
82
|
+
api: string
|
|
83
|
+
/** Collection name for filtering WS events, e.g. "entries" */
|
|
84
|
+
collection: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type QueryConnectionStatus =
|
|
88
|
+
| "loading"
|
|
89
|
+
| "live"
|
|
90
|
+
| "reconnecting"
|
|
91
|
+
| "stale"
|
|
92
|
+
| "auth_required"
|
|
93
|
+
| "unavailable"
|
|
94
|
+
| "error"
|
|
95
|
+
|
|
96
|
+
export interface UseCollectionReturn<T> {
|
|
97
|
+
data: T[]
|
|
98
|
+
loading: boolean
|
|
99
|
+
error: string | null
|
|
100
|
+
status: QueryConnectionStatus
|
|
101
|
+
create: (item: Partial<T>) => Promise<T>
|
|
102
|
+
update: (id: string, patch: Partial<T>) => Promise<T>
|
|
103
|
+
remove: (id: string) => Promise<void>
|
|
104
|
+
refresh: () => void
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface UseQueryOptions {
|
|
108
|
+
/** Collection name to subscribe to, e.g. "entries". */
|
|
109
|
+
collection: string
|
|
110
|
+
/** Optional predicate; scopes rows server-side (and client-side on the legacy fallback). */
|
|
111
|
+
where?: SubscribePredicate
|
|
112
|
+
/** Optional REST endpoint (e.g. "/api/entries"). Enables create/update/remove + the legacy fallback. */
|
|
113
|
+
api?: string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type UseQueryReturn<T> = UseCollectionReturn<T>
|
|
117
|
+
|
|
118
|
+
export function useQuery<T extends { id: string }>(
|
|
119
|
+
opts: UseQueryOptions
|
|
120
|
+
): UseQueryReturn<T> {
|
|
121
|
+
const optsRef = useRef(opts)
|
|
122
|
+
optsRef.current = opts
|
|
123
|
+
|
|
124
|
+
const url = defaultSubscribeUrl()
|
|
125
|
+
const collectionKey = opts.collection
|
|
126
|
+
const whereKey = canonicalPredicateString(opts.where)
|
|
127
|
+
const store = useMemo(
|
|
128
|
+
() => getCollectionQueryStore<T>(url, opts.collection, opts.where),
|
|
129
|
+
[url, collectionKey, whereKey],
|
|
130
|
+
)
|
|
131
|
+
const snapshot = useSyncExternalStore(
|
|
132
|
+
(listener) => store.subscribe(listener),
|
|
133
|
+
() => store.getSnapshot(),
|
|
134
|
+
() => store.getSnapshot(),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
const api = optsRef.current.api
|
|
139
|
+
if (snapshot.status === "unavailable" && api) store.ensureLegacyFallback(api)
|
|
140
|
+
}, [snapshot.status, store])
|
|
141
|
+
|
|
142
|
+
const refresh = useCallback(() => {
|
|
143
|
+
const api = optsRef.current.api
|
|
144
|
+
if (!api) return
|
|
145
|
+
void store.refreshFromApi(api)
|
|
146
|
+
}, [store])
|
|
147
|
+
|
|
148
|
+
const create = useCallback(async (item: Partial<T>): Promise<T> => {
|
|
149
|
+
const api = optsRef.current.api
|
|
150
|
+
if (!api) throw new Error("useQuery: `api` is required to call create()")
|
|
151
|
+
const res = await apiFetch(api, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers: { "content-type": "application/json" },
|
|
154
|
+
body: JSON.stringify(item),
|
|
155
|
+
})
|
|
156
|
+
return res.json()
|
|
157
|
+
}, [])
|
|
158
|
+
|
|
159
|
+
const update = useCallback(async (id: string, patch: Partial<T>): Promise<T> => {
|
|
160
|
+
const api = optsRef.current.api
|
|
161
|
+
if (!api) throw new Error("useQuery: `api` is required to call update()")
|
|
162
|
+
const res = await apiFetch(`${api}/${id}`, {
|
|
163
|
+
method: "PATCH",
|
|
164
|
+
headers: { "content-type": "application/json" },
|
|
165
|
+
body: JSON.stringify(patch),
|
|
166
|
+
})
|
|
167
|
+
return res.json()
|
|
168
|
+
}, [])
|
|
169
|
+
|
|
170
|
+
const remove = useCallback(async (id: string): Promise<void> => {
|
|
171
|
+
const api = optsRef.current.api
|
|
172
|
+
if (!api) throw new Error("useQuery: `api` is required to call remove()")
|
|
173
|
+
await apiFetch(`${api}/${id}`, { method: "DELETE" })
|
|
174
|
+
}, [])
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
data: snapshot.data,
|
|
178
|
+
loading: snapshot.loading,
|
|
179
|
+
error: snapshot.error,
|
|
180
|
+
status: snapshot.status,
|
|
181
|
+
create,
|
|
182
|
+
update,
|
|
183
|
+
remove,
|
|
184
|
+
refresh,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @deprecated Use {@link useQuery} instead — it's the WS-first default and adds
|
|
190
|
+
* server-side `where` predicate scoping. This wrapper forwards to `useQuery`
|
|
191
|
+
* (full collection, no predicate) and remains only for back-compat.
|
|
192
|
+
*/
|
|
193
|
+
export function useCollection<T extends { id: string }>(
|
|
194
|
+
opts: UseCollectionOptions
|
|
195
|
+
): UseCollectionReturn<T> {
|
|
196
|
+
return useQuery<T>({ collection: opts.collection, api: opts.api })
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Best-effort client-side evaluation of a SubscribePredicate, used only by the
|
|
201
|
+
* legacy SSE+REST fallback to scope the REST result the way the WS snapshot
|
|
202
|
+
* would. The WS path is authoritative; this mirrors the common cases (SQL
|
|
203
|
+
* three-valued-logic edge cases around NULL are not reproduced exactly).
|
|
204
|
+
*/
|
|
205
|
+
function matchesPredicate(row: Record<string, unknown>, p: SubscribePredicate): boolean {
|
|
206
|
+
switch (p.op) {
|
|
207
|
+
case "and": return p.clauses.every(c => matchesPredicate(row, c))
|
|
208
|
+
case "or": return p.clauses.some(c => matchesPredicate(row, c))
|
|
209
|
+
case "not": return !matchesPredicate(row, p.clause)
|
|
210
|
+
case "eq": return row[p.column] === p.value
|
|
211
|
+
case "ne": return row[p.column] !== p.value
|
|
212
|
+
case "gt": return (row[p.column] as number | string) > p.value
|
|
213
|
+
case "gte": return (row[p.column] as number | string) >= p.value
|
|
214
|
+
case "lt": return (row[p.column] as number | string) < p.value
|
|
215
|
+
case "lte": return (row[p.column] as number | string) <= p.value
|
|
216
|
+
case "in": return p.values.includes(row[p.column] as string | number | boolean | null)
|
|
217
|
+
case "like": {
|
|
218
|
+
const v = row[p.column]
|
|
219
|
+
if (typeof v !== "string") return false
|
|
220
|
+
const re = new RegExp(
|
|
221
|
+
"^" + p.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/%/g, ".*").replace(/_/g, ".") + "$",
|
|
222
|
+
"i",
|
|
223
|
+
)
|
|
224
|
+
return re.test(v)
|
|
225
|
+
}
|
|
226
|
+
case "isNull": return row[p.column] == null
|
|
227
|
+
case "isNotNull": return row[p.column] != null
|
|
228
|
+
default: return true
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Legacy SSE + REST refetch loop. Used only as fallback when the WS
|
|
234
|
+
* subscription path (subscribeCollection) gives up — typically on an old
|
|
235
|
+
* deploy that doesn't serve /__vibes_sub. Same shape as the pre-Phase-1
|
|
236
|
+
* useCollection internals so behavior on older backends is unchanged.
|
|
237
|
+
*
|
|
238
|
+
* Returns an AbortController whose abort() tears the SSE stream down.
|
|
239
|
+
*/
|
|
240
|
+
function startLegacySseRefetch(
|
|
241
|
+
// Only the collection name is read here (to match invalidation events); the
|
|
242
|
+
// refresh() closure owns the REST fetch. Accept the loosened shape so both
|
|
243
|
+
// useQuery (api optional) and the deprecated useCollection can pass their ref.
|
|
244
|
+
optsRef: { current: { collection: string } },
|
|
245
|
+
refresh: () => void,
|
|
246
|
+
setError: (m: string) => void,
|
|
247
|
+
): AbortController {
|
|
248
|
+
const evtUrl = `${typeof window !== "undefined" ? window.location.origin : ""}/__vibes_events`
|
|
249
|
+
const RECONNECT_BASE_MS = 1000
|
|
250
|
+
const RECONNECT_CAP_MS = 60_000
|
|
251
|
+
const RECONNECT_GIVE_UP = 6
|
|
252
|
+
const REFRESH_THROTTLE_MS = 2000
|
|
253
|
+
|
|
254
|
+
const ctl = new AbortController()
|
|
255
|
+
let consecutiveErrors = 0
|
|
256
|
+
let lastRefresh = 0
|
|
257
|
+
|
|
258
|
+
function throttledRefresh() {
|
|
259
|
+
const now = Date.now()
|
|
260
|
+
if (now - lastRefresh < REFRESH_THROTTLE_MS) return
|
|
261
|
+
lastRefresh = now
|
|
262
|
+
refresh()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
class FatalError extends Error {}
|
|
266
|
+
let isFirstOpen = true
|
|
267
|
+
|
|
268
|
+
// Kick the initial REST fetch so `data` populates before SSE delivers
|
|
269
|
+
// its first invalidate.
|
|
270
|
+
refresh()
|
|
271
|
+
|
|
272
|
+
void fetchEventSource(evtUrl, {
|
|
273
|
+
signal: ctl.signal,
|
|
274
|
+
async onopen(res) {
|
|
275
|
+
if (res.ok && res.headers.get("content-type")?.includes("text/event-stream")) {
|
|
276
|
+
consecutiveErrors = 0
|
|
277
|
+
if (!isFirstOpen) throttledRefresh()
|
|
278
|
+
isFirstOpen = false
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
if (res.status === 401 || res.status === 403 || res.status === 400) {
|
|
282
|
+
if (res.status === 401 && getAuthContext().authReady) notifyAuthRequired()
|
|
283
|
+
throw new FatalError(`fatal ${res.status}`)
|
|
284
|
+
}
|
|
285
|
+
throw new Error(`unexpected ${res.status}`)
|
|
286
|
+
},
|
|
287
|
+
onmessage(ev: EventSourceMessage) {
|
|
288
|
+
if (!ev.data) return
|
|
289
|
+
try {
|
|
290
|
+
const event = JSON.parse(ev.data) as { type?: string; collection?: string }
|
|
291
|
+
if (event.type === "invalidate" && event.collection === optsRef.current.collection) {
|
|
292
|
+
throttledRefresh()
|
|
293
|
+
}
|
|
294
|
+
} catch { /* ignore malformed messages */ }
|
|
295
|
+
},
|
|
296
|
+
onclose() {
|
|
297
|
+
throw new Error("connection closed")
|
|
298
|
+
},
|
|
299
|
+
onerror(err) {
|
|
300
|
+
if (err instanceof FatalError) throw err
|
|
301
|
+
consecutiveErrors++
|
|
302
|
+
if (consecutiveErrors >= RECONNECT_GIVE_UP) {
|
|
303
|
+
setError("realtime stream unavailable — falling back to manual refresh")
|
|
304
|
+
throw new FatalError("give up after consecutive errors")
|
|
305
|
+
}
|
|
306
|
+
return Math.min(
|
|
307
|
+
RECONNECT_CAP_MS,
|
|
308
|
+
RECONNECT_BASE_MS * Math.pow(2, consecutiveErrors - 1),
|
|
309
|
+
)
|
|
310
|
+
},
|
|
311
|
+
}).catch(() => { /* fatal errors throw from onerror */ })
|
|
312
|
+
|
|
313
|
+
return ctl
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── subscribeCollection ──────────────────────────────────────────────────────
|
|
317
|
+
//
|
|
318
|
+
// Low-level per-query reactive subscription over WebSocket (Phase 1 of the
|
|
319
|
+
// delta-push migration). Where useCollection above polls REST after each SSE
|
|
320
|
+
// invalidate, this subscribes to a specific collection on a dedicated WS
|
|
321
|
+
// channel and receives a full snapshot on subscribe and on every write.
|
|
322
|
+
//
|
|
323
|
+
// Phase 1 scope: full-collection snapshots. Phase 2 adds predicate filters in
|
|
324
|
+
// the subscribe payload; Phase 3 swaps snapshots for row-level deltas.
|
|
325
|
+
//
|
|
326
|
+
// This API is intentionally NOT wired into useCollection yet. Existing
|
|
327
|
+
// callers stay on the SSE+REST path until we've measured that the WS path
|
|
328
|
+
// is at least as fast, then useCollection swaps internals while keeping the
|
|
329
|
+
// hook signature.
|
|
330
|
+
|
|
331
|
+
// Mirrors the server-side Predicate AST. Defined here so SDK callers don't
|
|
332
|
+
// need to import from @omg-dev/server (and so the wire format stays a
|
|
333
|
+
// well-defined seam). When the SDK eventually grows a query-builder DSL,
|
|
334
|
+
// it'll target this shape.
|
|
335
|
+
export type SubscribePredicate =
|
|
336
|
+
| { op: "and"; clauses: SubscribePredicate[] }
|
|
337
|
+
| { op: "or"; clauses: SubscribePredicate[] }
|
|
338
|
+
| { op: "not"; clause: SubscribePredicate }
|
|
339
|
+
| { op: "eq" | "ne"; column: string; value: string | number | boolean | null }
|
|
340
|
+
| { op: "gt" | "gte" | "lt" | "lte"; column: string; value: string | number }
|
|
341
|
+
| { op: "in"; column: string; values: (string | number | boolean | null)[] }
|
|
342
|
+
| { op: "like"; column: string; pattern: string }
|
|
343
|
+
| { op: "isNull" | "isNotNull"; column: string }
|
|
344
|
+
|
|
345
|
+
export interface SubscribeCollectionOptions<T> {
|
|
346
|
+
/** Collection name, e.g. "posts". */
|
|
347
|
+
collection: string
|
|
348
|
+
/** Optional JSON predicate AST to narrow the snapshot server-side. */
|
|
349
|
+
where?: SubscribePredicate
|
|
350
|
+
/** Called with the full row set on initial subscribe and after each change. */
|
|
351
|
+
onSnapshot: (rows: T[]) => void
|
|
352
|
+
/** Called with an error code + message; subscription remains open. */
|
|
353
|
+
onError?: (code: string, message: string) => void
|
|
354
|
+
/** Override the WS URL (default: ws[s]://<location.host>/__vibes_sub). */
|
|
355
|
+
url?: string
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
interface InternalSubscribeCollectionOptions<T> extends SubscribeCollectionOptions<T> {
|
|
359
|
+
resumeFromSeq?: number | null
|
|
360
|
+
onSeq?: (seq: number) => void
|
|
361
|
+
onStatus?: (status: QueryConnectionStatus) => void
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export interface SubscribeHandle {
|
|
365
|
+
/** Closes the underlying WebSocket and stops reconnect attempts. */
|
|
366
|
+
unsubscribe(): void
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
interface ServerFrame {
|
|
370
|
+
type: "snapshot" | "delta" | "resumed" | "error"
|
|
371
|
+
subId?: string
|
|
372
|
+
collection?: string
|
|
373
|
+
seq?: number
|
|
374
|
+
rows?: unknown[]
|
|
375
|
+
op?: "insert" | "update" | "delete"
|
|
376
|
+
row?: Record<string, unknown>
|
|
377
|
+
id?: string
|
|
378
|
+
fromSeq?: number
|
|
379
|
+
toSeq?: number
|
|
380
|
+
replayed?: number
|
|
381
|
+
code?: string
|
|
382
|
+
message?: string
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function defaultSubscribeUrl(): string {
|
|
386
|
+
if (typeof window === "undefined") return ""
|
|
387
|
+
const proto = window.location.protocol === "https:" ? "wss:" : "ws:"
|
|
388
|
+
return `${proto}//${window.location.host}/__vibes_sub`
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
interface LogicalSub<T extends { id: string } = { id: string }> {
|
|
392
|
+
subId: string
|
|
393
|
+
collection: string
|
|
394
|
+
where?: SubscribePredicate
|
|
395
|
+
state: Map<string, T>
|
|
396
|
+
lastSeq: number | null
|
|
397
|
+
onSnapshot: (rows: T[]) => void
|
|
398
|
+
onError?: (code: string, message: string) => void
|
|
399
|
+
onSeq?: (seq: number) => void
|
|
400
|
+
onStatus?: (status: QueryConnectionStatus) => void
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function makeSubId(): string {
|
|
404
|
+
return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
|
405
|
+
? crypto.randomUUID()
|
|
406
|
+
: `s${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function protocolsForAuth(token: string | null): string[] | undefined {
|
|
410
|
+
return token ? [`vibes-bearer.${token}`] : undefined
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const RECONNECT_BASE_MS = 1000
|
|
414
|
+
const RECONNECT_CAP_MS = 60_000
|
|
415
|
+
const RECONNECT_GIVE_UP = 6
|
|
416
|
+
|
|
417
|
+
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
|
418
|
+
|
|
419
|
+
function canonicalJson(value: unknown): string {
|
|
420
|
+
return JSON.stringify(canonicalizeValue(value))
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function canonicalizeValue(value: unknown): JsonValue {
|
|
424
|
+
if (value === null) return null
|
|
425
|
+
if (Array.isArray(value)) return value.map(canonicalizeValue)
|
|
426
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value
|
|
427
|
+
if (typeof value !== "object") return null
|
|
428
|
+
const out: { [key: string]: JsonValue } = {}
|
|
429
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
430
|
+
out[key] = canonicalizeValue((value as Record<string, unknown>)[key])
|
|
431
|
+
}
|
|
432
|
+
return out
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function normalizePredicate(predicate: SubscribePredicate | undefined): SubscribePredicate | undefined {
|
|
436
|
+
if (!predicate) return undefined
|
|
437
|
+
switch (predicate.op) {
|
|
438
|
+
case "and":
|
|
439
|
+
case "or": {
|
|
440
|
+
const clauses = predicate.clauses
|
|
441
|
+
.map((clause) => normalizePredicate(clause)!)
|
|
442
|
+
.sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b)))
|
|
443
|
+
return { op: predicate.op, clauses }
|
|
444
|
+
}
|
|
445
|
+
case "not":
|
|
446
|
+
return { op: "not", clause: normalizePredicate(predicate.clause)! }
|
|
447
|
+
case "in":
|
|
448
|
+
return {
|
|
449
|
+
...predicate,
|
|
450
|
+
values: [...predicate.values].sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b))),
|
|
451
|
+
}
|
|
452
|
+
default:
|
|
453
|
+
return { ...predicate }
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function canonicalPredicateString(predicate: SubscribePredicate | undefined): string {
|
|
458
|
+
return canonicalJson(normalizePredicate(predicate) ?? null)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function rowOrderValue(row: { id: string } & Record<string, unknown>): string | number | null {
|
|
462
|
+
const createdAt = row.createdAt ?? row.created_at
|
|
463
|
+
if (typeof createdAt === "string" || typeof createdAt === "number") return createdAt
|
|
464
|
+
const updatedAt = row.updatedAt ?? row.updated_at
|
|
465
|
+
if (typeof updatedAt === "string" || typeof updatedAt === "number") return updatedAt
|
|
466
|
+
return null
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function compareRows(a: { id: string } & Record<string, unknown>, b: { id: string } & Record<string, unknown>): number {
|
|
470
|
+
const av = rowOrderValue(a)
|
|
471
|
+
const bv = rowOrderValue(b)
|
|
472
|
+
if (av !== null && bv !== null && av !== bv) return av > bv ? -1 : 1
|
|
473
|
+
if (av !== null && bv === null) return -1
|
|
474
|
+
if (av === null && bv !== null) return 1
|
|
475
|
+
return b.id.localeCompare(a.id)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function orderedRows<T extends { id: string }>(rows: Iterable<T>): T[] {
|
|
479
|
+
return Array.from(rows).sort((a, b) => compareRows(a as T & Record<string, unknown>, b as T & Record<string, unknown>))
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
class SharedCollectionSocket {
|
|
483
|
+
private ws: WebSocket | null = null
|
|
484
|
+
private connecting = false
|
|
485
|
+
private closedByManager = false
|
|
486
|
+
private consecutiveErrors = 0
|
|
487
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
488
|
+
private flushQueued = false
|
|
489
|
+
private readonly subs = new Map<string, LogicalSub>()
|
|
490
|
+
private readonly pendingSubIds = new Set<string>()
|
|
491
|
+
|
|
492
|
+
constructor(
|
|
493
|
+
private readonly url: string,
|
|
494
|
+
private readonly token: string | null,
|
|
495
|
+
) {}
|
|
496
|
+
|
|
497
|
+
add<T extends { id: string }>(sub: LogicalSub<T>): void {
|
|
498
|
+
this.subs.set(sub.subId, sub as LogicalSub)
|
|
499
|
+
this.pendingSubIds.add(sub.subId)
|
|
500
|
+
if (this.isOpen()) this.queueFlush()
|
|
501
|
+
else this.connect()
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
remove(subId: string): void {
|
|
505
|
+
const existed = this.subs.delete(subId)
|
|
506
|
+
this.pendingSubIds.delete(subId)
|
|
507
|
+
if (existed && this.isOpen()) this.sendFrame({ op: "unsub", subId })
|
|
508
|
+
if (this.subs.size === 0) this.shutdown()
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
isIdle(): boolean {
|
|
512
|
+
return this.subs.size === 0
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
shutdown(): void {
|
|
516
|
+
if (this.reconnectTimer) {
|
|
517
|
+
clearTimeout(this.reconnectTimer)
|
|
518
|
+
this.reconnectTimer = null
|
|
519
|
+
}
|
|
520
|
+
this.pendingSubIds.clear()
|
|
521
|
+
this.closedByManager = true
|
|
522
|
+
try { this.ws?.close() } catch { /* idempotent */ }
|
|
523
|
+
this.ws = null
|
|
524
|
+
this.connecting = false
|
|
525
|
+
this.consecutiveErrors = 0
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private connect(): void {
|
|
529
|
+
if (this.connecting || this.isOpen() || this.subs.size === 0) return
|
|
530
|
+
this.connecting = true
|
|
531
|
+
this.closedByManager = false
|
|
532
|
+
const protocols = protocolsForAuth(this.token)
|
|
533
|
+
|
|
534
|
+
try {
|
|
535
|
+
this.ws = protocols ? new WebSocket(this.url, protocols) : new WebSocket(this.url)
|
|
536
|
+
} catch (err) {
|
|
537
|
+
this.connecting = false
|
|
538
|
+
this.broadcastError("ws_construct_failed", (err as Error).message)
|
|
539
|
+
this.scheduleReconnect()
|
|
540
|
+
return
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
this.ws.addEventListener("open", () => {
|
|
544
|
+
this.connecting = false
|
|
545
|
+
this.consecutiveErrors = 0
|
|
546
|
+
this.broadcastStatus("live")
|
|
547
|
+
for (const subId of this.subs.keys()) this.pendingSubIds.add(subId)
|
|
548
|
+
this.queueFlush()
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
this.ws.addEventListener("message", (ev: MessageEvent<string>) => {
|
|
552
|
+
this.handleMessage(ev.data)
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
this.ws.addEventListener("close", () => {
|
|
556
|
+
this.ws = null
|
|
557
|
+
this.connecting = false
|
|
558
|
+
if (this.closedByManager || this.subs.size === 0) return
|
|
559
|
+
this.broadcastStatus("reconnecting")
|
|
560
|
+
this.scheduleReconnect()
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
this.ws.addEventListener("error", () => {
|
|
564
|
+
// The close event will follow; reconnect is scheduled there.
|
|
565
|
+
})
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private queueFlush(): void {
|
|
569
|
+
if (this.flushQueued) return
|
|
570
|
+
this.flushQueued = true
|
|
571
|
+
queueMicrotask(() => {
|
|
572
|
+
this.flushQueued = false
|
|
573
|
+
this.flushPendingSubs()
|
|
574
|
+
})
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private flushPendingSubs(): void {
|
|
578
|
+
if (!this.isOpen()) return
|
|
579
|
+
const ids = Array.from(this.pendingSubIds)
|
|
580
|
+
this.pendingSubIds.clear()
|
|
581
|
+
|
|
582
|
+
for (const subId of ids) {
|
|
583
|
+
const sub = this.subs.get(subId)
|
|
584
|
+
if (!sub) continue
|
|
585
|
+
const frame: Record<string, unknown> = {
|
|
586
|
+
op: "sub",
|
|
587
|
+
subId,
|
|
588
|
+
collection: sub.collection,
|
|
589
|
+
}
|
|
590
|
+
if (sub.where !== undefined) frame.where = sub.where
|
|
591
|
+
if (sub.lastSeq !== null) frame.resumeFromSeq = sub.lastSeq
|
|
592
|
+
this.sendFrame(frame)
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private handleMessage(data: string): void {
|
|
597
|
+
let frame: ServerFrame
|
|
598
|
+
try {
|
|
599
|
+
frame = JSON.parse(data) as ServerFrame
|
|
600
|
+
} catch {
|
|
601
|
+
this.broadcastError("bad_frame", "non-JSON frame from server")
|
|
602
|
+
return
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (!frame.subId) {
|
|
606
|
+
if (frame.type === "error") this.broadcastError(frame.code ?? "unknown", frame.message ?? "")
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const sub = this.subs.get(frame.subId)
|
|
611
|
+
if (!sub) return
|
|
612
|
+
|
|
613
|
+
if (frame.type === "snapshot") {
|
|
614
|
+
sub.state.clear()
|
|
615
|
+
for (const row of (frame.rows ?? []) as Array<{ id: string }>) {
|
|
616
|
+
if (row && typeof row.id === "string") sub.state.set(row.id, row)
|
|
617
|
+
}
|
|
618
|
+
if (typeof frame.seq === "number") sub.lastSeq = frame.seq
|
|
619
|
+
if (typeof frame.seq === "number") sub.onSeq?.(frame.seq)
|
|
620
|
+
this.publish(sub)
|
|
621
|
+
return
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (frame.type === "delta") {
|
|
625
|
+
if (frame.op === "insert" || frame.op === "update") {
|
|
626
|
+
const row = frame.row as { id: string } | undefined
|
|
627
|
+
if (!row || typeof row.id !== "string") {
|
|
628
|
+
sub.onError?.("bad_delta", "delta missing row.id")
|
|
629
|
+
return
|
|
630
|
+
}
|
|
631
|
+
sub.state.set(row.id, row)
|
|
632
|
+
if (typeof frame.seq === "number") sub.lastSeq = frame.seq
|
|
633
|
+
if (typeof frame.seq === "number") sub.onSeq?.(frame.seq)
|
|
634
|
+
this.publish(sub)
|
|
635
|
+
return
|
|
636
|
+
}
|
|
637
|
+
if (frame.op === "delete") {
|
|
638
|
+
if (typeof frame.id !== "string") {
|
|
639
|
+
sub.onError?.("bad_delta", "delete delta missing id")
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
sub.state.delete(frame.id)
|
|
643
|
+
if (typeof frame.seq === "number") sub.lastSeq = frame.seq
|
|
644
|
+
if (typeof frame.seq === "number") sub.onSeq?.(frame.seq)
|
|
645
|
+
this.publish(sub)
|
|
646
|
+
return
|
|
647
|
+
}
|
|
648
|
+
sub.onError?.("bad_delta", `unknown delta op: ${String(frame.op)}`)
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (frame.type === "resumed") {
|
|
653
|
+
if (typeof frame.toSeq === "number") sub.lastSeq = frame.toSeq
|
|
654
|
+
if (typeof frame.toSeq === "number") sub.onSeq?.(frame.toSeq)
|
|
655
|
+
return
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (frame.type === "error") {
|
|
659
|
+
sub.onError?.(frame.code ?? "unknown", frame.message ?? "")
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
private publish(sub: LogicalSub): void {
|
|
664
|
+
try { sub.onSnapshot(orderedRows(sub.state.values())) } catch { /* user cb */ }
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
private scheduleReconnect(): void {
|
|
668
|
+
if (this.subs.size === 0 || this.reconnectTimer) return
|
|
669
|
+
this.consecutiveErrors++
|
|
670
|
+
if (this.consecutiveErrors >= RECONNECT_GIVE_UP) {
|
|
671
|
+
this.broadcastError("give_up", `gave up after ${this.consecutiveErrors} reconnect attempts`)
|
|
672
|
+
return
|
|
673
|
+
}
|
|
674
|
+
const delay = Math.min(
|
|
675
|
+
RECONNECT_CAP_MS,
|
|
676
|
+
RECONNECT_BASE_MS * Math.pow(2, this.consecutiveErrors - 1),
|
|
677
|
+
)
|
|
678
|
+
this.reconnectTimer = setTimeout(() => {
|
|
679
|
+
this.reconnectTimer = null
|
|
680
|
+
this.connect()
|
|
681
|
+
}, delay)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private broadcastError(code: string, message: string): void {
|
|
685
|
+
for (const sub of this.subs.values()) sub.onError?.(code, message)
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
private broadcastStatus(status: QueryConnectionStatus): void {
|
|
689
|
+
for (const sub of this.subs.values()) sub.onStatus?.(status)
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
private sendFrame(frame: unknown): void {
|
|
693
|
+
try {
|
|
694
|
+
this.ws?.send(JSON.stringify(frame))
|
|
695
|
+
} catch (err) {
|
|
696
|
+
this.broadcastError("ws_send_failed", (err as Error).message)
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
private isOpen(): boolean {
|
|
701
|
+
return this.ws?.readyState === 1
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const sharedCollectionSockets = new Map<string, SharedCollectionSocket>()
|
|
706
|
+
|
|
707
|
+
function sharedSocketKey(url: string, token: string | null): string {
|
|
708
|
+
return `${url}\n${token ?? ""}`
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function getSharedCollectionSocket(url: string, token: string | null): SharedCollectionSocket {
|
|
712
|
+
const key = sharedSocketKey(url, token)
|
|
713
|
+
let socket = sharedCollectionSockets.get(key)
|
|
714
|
+
if (!socket) {
|
|
715
|
+
socket = new SharedCollectionSocket(url, token)
|
|
716
|
+
sharedCollectionSockets.set(key, socket)
|
|
717
|
+
}
|
|
718
|
+
return socket
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function openSocketSubscription<T = Record<string, unknown>>(
|
|
722
|
+
opts: InternalSubscribeCollectionOptions<T>,
|
|
723
|
+
): SubscribeHandle {
|
|
724
|
+
const url = opts.url ?? defaultSubscribeUrl()
|
|
725
|
+
const token = getAuthContext().token
|
|
726
|
+
const key = sharedSocketKey(url, token)
|
|
727
|
+
const socket = getSharedCollectionSocket(url, token)
|
|
728
|
+
const sub: LogicalSub<T & { id: string }> = {
|
|
729
|
+
subId: makeSubId(),
|
|
730
|
+
collection: opts.collection,
|
|
731
|
+
where: opts.where,
|
|
732
|
+
state: new Map<string, T & { id: string }>(),
|
|
733
|
+
lastSeq: opts.resumeFromSeq ?? null,
|
|
734
|
+
onSnapshot: opts.onSnapshot as (rows: Array<T & { id: string }>) => void,
|
|
735
|
+
onError: opts.onError,
|
|
736
|
+
onSeq: opts.onSeq,
|
|
737
|
+
onStatus: opts.onStatus,
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
socket.add(sub)
|
|
741
|
+
|
|
742
|
+
return {
|
|
743
|
+
unsubscribe() {
|
|
744
|
+
socket.remove(sub.subId)
|
|
745
|
+
if (socket.isIdle()) sharedCollectionSockets.delete(key)
|
|
746
|
+
},
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
interface QuerySnapshot<T> {
|
|
751
|
+
data: T[]
|
|
752
|
+
loading: boolean
|
|
753
|
+
error: string | null
|
|
754
|
+
status: QueryConnectionStatus
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
type QueryStoreListener = () => void
|
|
758
|
+
|
|
759
|
+
interface RowSubscriber<T> {
|
|
760
|
+
onSnapshot: (rows: T[]) => void
|
|
761
|
+
onError?: (code: string, message: string) => void
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function makeInitialQuerySnapshot<T>(): QuerySnapshot<T> {
|
|
765
|
+
return { data: [], loading: true, error: null, status: "loading" }
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
class CollectionQueryStore<T extends { id: string }> {
|
|
769
|
+
private readonly listeners = new Set<QueryStoreListener>()
|
|
770
|
+
private readonly rowSubscribers = new Set<RowSubscriber<T>>()
|
|
771
|
+
private socketHandle: SubscribeHandle | null = null
|
|
772
|
+
private fallbackHandle: AbortController | null = null
|
|
773
|
+
private authUnsub: (() => void) | null = null
|
|
774
|
+
private authToken: string | null = getAuthContext().token
|
|
775
|
+
private authReady = getAuthContext().authReady
|
|
776
|
+
private lastSeq: number | null = null
|
|
777
|
+
private pendingAuthRequired: string | null = null
|
|
778
|
+
private notifyQueued = false
|
|
779
|
+
private rowsDirty = false
|
|
780
|
+
private snapshot: QuerySnapshot<T> = makeInitialQuerySnapshot()
|
|
781
|
+
|
|
782
|
+
constructor(
|
|
783
|
+
readonly key: string,
|
|
784
|
+
private readonly url: string,
|
|
785
|
+
private readonly collection: string,
|
|
786
|
+
private readonly where: SubscribePredicate | undefined,
|
|
787
|
+
private readonly onIdle: (key: string) => void,
|
|
788
|
+
) {}
|
|
789
|
+
|
|
790
|
+
subscribe(listener: QueryStoreListener): () => void {
|
|
791
|
+
this.listeners.add(listener)
|
|
792
|
+
this.start()
|
|
793
|
+
return () => {
|
|
794
|
+
this.listeners.delete(listener)
|
|
795
|
+
this.stopIfIdle()
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
subscribeRows(subscriber: RowSubscriber<T>): () => void {
|
|
800
|
+
this.rowSubscribers.add(subscriber)
|
|
801
|
+
this.start()
|
|
802
|
+
if (!this.snapshot.loading) {
|
|
803
|
+
queueMicrotask(() => {
|
|
804
|
+
if (this.rowSubscribers.has(subscriber)) subscriber.onSnapshot(this.snapshot.data)
|
|
805
|
+
})
|
|
806
|
+
}
|
|
807
|
+
return () => {
|
|
808
|
+
this.rowSubscribers.delete(subscriber)
|
|
809
|
+
this.stopIfIdle()
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
destroy(): void {
|
|
814
|
+
this.stop()
|
|
815
|
+
this.listeners.clear()
|
|
816
|
+
this.rowSubscribers.clear()
|
|
817
|
+
this.lastSeq = null
|
|
818
|
+
this.notifyQueued = false
|
|
819
|
+
this.rowsDirty = false
|
|
820
|
+
this.snapshot = makeInitialQuerySnapshot()
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
getSnapshot(): QuerySnapshot<T> {
|
|
824
|
+
return this.snapshot
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
ensureLegacyFallback(api: string): void {
|
|
828
|
+
if (this.fallbackHandle) return
|
|
829
|
+
this.fallbackHandle = startLegacySseRefetch(
|
|
830
|
+
{ current: { collection: this.collection } },
|
|
831
|
+
() => this.refreshFromApi(api, "stale"),
|
|
832
|
+
(message) => this.setError("unavailable", message),
|
|
833
|
+
)
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
async refreshFromApi(api: string, status: QueryConnectionStatus = this.socketHandle ? "live" : "stale"): Promise<void> {
|
|
837
|
+
try {
|
|
838
|
+
const res = await apiFetch(api)
|
|
839
|
+
const body = await res.json()
|
|
840
|
+
let rows = Array.isArray(body) ? body as T[] : []
|
|
841
|
+
if (this.where) rows = rows.filter(row => matchesPredicate(row as Record<string, unknown>, this.where!))
|
|
842
|
+
this.setRows(orderedRows(rows), status)
|
|
843
|
+
} catch (err) {
|
|
844
|
+
this.setError("error", err instanceof Error ? err.message : String(err))
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
private start(): void {
|
|
849
|
+
if (!this.authUnsub) {
|
|
850
|
+
this.authUnsub = subscribeAuthChange((snap) => this.handleAuthChange(snap.token, snap.authReady))
|
|
851
|
+
}
|
|
852
|
+
if (!this.socketHandle) this.openSocket()
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
private stopIfIdle(): void {
|
|
856
|
+
if (this.listeners.size > 0 || this.rowSubscribers.size > 0) return
|
|
857
|
+
this.stop()
|
|
858
|
+
this.onIdle(this.key)
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
private stop(): void {
|
|
862
|
+
this.socketHandle?.unsubscribe()
|
|
863
|
+
this.socketHandle = null
|
|
864
|
+
this.fallbackHandle?.abort()
|
|
865
|
+
this.fallbackHandle = null
|
|
866
|
+
this.authUnsub?.()
|
|
867
|
+
this.authUnsub = null
|
|
868
|
+
this.pendingAuthRequired = null
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
private openSocket(): void {
|
|
872
|
+
this.authToken = getAuthContext().token
|
|
873
|
+
this.authReady = getAuthContext().authReady
|
|
874
|
+
this.socketHandle = openSocketSubscription<T>({
|
|
875
|
+
url: this.url,
|
|
876
|
+
collection: this.collection,
|
|
877
|
+
where: this.where,
|
|
878
|
+
resumeFromSeq: this.lastSeq,
|
|
879
|
+
onSeq: (seq) => { this.lastSeq = seq },
|
|
880
|
+
onStatus: (status) => {
|
|
881
|
+
if (status === "reconnecting") this.setStatus("reconnecting", null)
|
|
882
|
+
},
|
|
883
|
+
onSnapshot: (rows) => this.setRows(rows, "live"),
|
|
884
|
+
onError: (code, message) => this.handleSocketError(code, message),
|
|
885
|
+
})
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
private handleAuthChange(token: string | null, authReady: boolean): void {
|
|
889
|
+
const tokenChanged = token !== this.authToken
|
|
890
|
+
const readyChanged = authReady !== this.authReady
|
|
891
|
+
this.authToken = token
|
|
892
|
+
this.authReady = authReady
|
|
893
|
+
|
|
894
|
+
if (tokenChanged) {
|
|
895
|
+
this.socketHandle?.unsubscribe()
|
|
896
|
+
this.socketHandle = null
|
|
897
|
+
this.fallbackHandle?.abort()
|
|
898
|
+
this.fallbackHandle = null
|
|
899
|
+
this.lastSeq = null
|
|
900
|
+
this.pendingAuthRequired = null
|
|
901
|
+
this.setRows([], "loading", true)
|
|
902
|
+
this.openSocket()
|
|
903
|
+
return
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (readyChanged && authReady && this.pendingAuthRequired) {
|
|
907
|
+
const message = this.pendingAuthRequired
|
|
908
|
+
this.pendingAuthRequired = null
|
|
909
|
+
notifyAuthRequired()
|
|
910
|
+
this.setStatus("auth_required", `auth_required: ${message}`, true)
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
private handleSocketError(code: string, message: string): void {
|
|
915
|
+
this.broadcastRowError(code, message)
|
|
916
|
+
if (code === "auth_required") {
|
|
917
|
+
if (!getAuthContext().authReady) {
|
|
918
|
+
this.pendingAuthRequired = message
|
|
919
|
+
this.setStatus("loading", null)
|
|
920
|
+
return
|
|
921
|
+
}
|
|
922
|
+
notifyAuthRequired()
|
|
923
|
+
this.setStatus("auth_required", `${code}: ${message}`, true)
|
|
924
|
+
return
|
|
925
|
+
}
|
|
926
|
+
if (code === "give_up") {
|
|
927
|
+
this.setStatus("unavailable", "realtime stream unavailable", true)
|
|
928
|
+
return
|
|
929
|
+
}
|
|
930
|
+
this.setStatus("error", `${code}: ${message}`, true)
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
private setRows(rows: T[], status: QueryConnectionStatus, forceLoading = false): void {
|
|
934
|
+
this.snapshot = {
|
|
935
|
+
data: rows,
|
|
936
|
+
loading: forceLoading || status === "loading",
|
|
937
|
+
error: null,
|
|
938
|
+
status,
|
|
939
|
+
}
|
|
940
|
+
this.rowsDirty = true
|
|
941
|
+
this.queueNotify()
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
private setStatus(status: QueryConnectionStatus, error: string | null, doneLoading = false): void {
|
|
945
|
+
this.snapshot = {
|
|
946
|
+
...this.snapshot,
|
|
947
|
+
loading: status === "loading" ? true : doneLoading ? false : this.snapshot.loading,
|
|
948
|
+
error,
|
|
949
|
+
status,
|
|
950
|
+
}
|
|
951
|
+
this.queueNotify()
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
private setError(status: QueryConnectionStatus, error: string): void {
|
|
955
|
+
this.snapshot = {
|
|
956
|
+
...this.snapshot,
|
|
957
|
+
loading: false,
|
|
958
|
+
error,
|
|
959
|
+
status,
|
|
960
|
+
}
|
|
961
|
+
this.queueNotify()
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
private queueNotify(): void {
|
|
965
|
+
if (this.notifyQueued) return
|
|
966
|
+
this.notifyQueued = true
|
|
967
|
+
queueMicrotask(() => {
|
|
968
|
+
this.notifyQueued = false
|
|
969
|
+
const rowsDirty = this.rowsDirty
|
|
970
|
+
this.rowsDirty = false
|
|
971
|
+
for (const listener of this.listeners) listener()
|
|
972
|
+
if (rowsDirty) {
|
|
973
|
+
for (const subscriber of this.rowSubscribers) {
|
|
974
|
+
try { subscriber.onSnapshot(this.snapshot.data) } catch { /* user cb */ }
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
})
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
private broadcastRowError(code: string, message: string): void {
|
|
981
|
+
for (const subscriber of this.rowSubscribers) subscriber.onError?.(code, message)
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const collectionQueryStores = new Map<string, CollectionQueryStore<{ id: string }>>()
|
|
986
|
+
|
|
987
|
+
function collectionQueryKey(url: string, collection: string, where: SubscribePredicate | undefined): string {
|
|
988
|
+
return `${url}\n${collection}\n${canonicalPredicateString(where)}`
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function getCollectionQueryStore<T extends { id: string }>(
|
|
992
|
+
url: string,
|
|
993
|
+
collection: string,
|
|
994
|
+
where: SubscribePredicate | undefined,
|
|
995
|
+
): CollectionQueryStore<T> {
|
|
996
|
+
const normalizedWhere = normalizePredicate(where)
|
|
997
|
+
const key = collectionQueryKey(url, collection, normalizedWhere)
|
|
998
|
+
let store = collectionQueryStores.get(key) as CollectionQueryStore<T> | undefined
|
|
999
|
+
if (!store) {
|
|
1000
|
+
store = new CollectionQueryStore<T>(
|
|
1001
|
+
key,
|
|
1002
|
+
url,
|
|
1003
|
+
collection,
|
|
1004
|
+
normalizedWhere,
|
|
1005
|
+
(idleKey) => collectionQueryStores.delete(idleKey),
|
|
1006
|
+
)
|
|
1007
|
+
collectionQueryStores.set(key, store as CollectionQueryStore<{ id: string }>)
|
|
1008
|
+
}
|
|
1009
|
+
return store
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
export function subscribeCollection<T = Record<string, unknown>>(
|
|
1013
|
+
opts: SubscribeCollectionOptions<T>,
|
|
1014
|
+
): SubscribeHandle {
|
|
1015
|
+
const store = getCollectionQueryStore<T & { id: string }>(
|
|
1016
|
+
opts.url ?? defaultSubscribeUrl(),
|
|
1017
|
+
opts.collection,
|
|
1018
|
+
opts.where,
|
|
1019
|
+
)
|
|
1020
|
+
const unsubscribe = store.subscribeRows({
|
|
1021
|
+
onSnapshot: opts.onSnapshot as (rows: Array<T & { id: string }>) => void,
|
|
1022
|
+
onError: opts.onError,
|
|
1023
|
+
})
|
|
1024
|
+
return { unsubscribe }
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
export function _resetSharedCollectionSocketsForTests(): void {
|
|
1028
|
+
for (const store of collectionQueryStores.values()) {
|
|
1029
|
+
store.destroy()
|
|
1030
|
+
}
|
|
1031
|
+
collectionQueryStores.clear()
|
|
1032
|
+
for (const socket of sharedCollectionSockets.values()) socket.shutdown()
|
|
1033
|
+
sharedCollectionSockets.clear()
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
export function _canonicalPredicateForTests(predicate: SubscribePredicate | undefined): string {
|
|
1037
|
+
return canonicalPredicateString(predicate)
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// ── Re-exports for convenience ───────────────────────────────────────────────
|
|
1041
|
+
|
|
1042
|
+
export type { UseCollectionOptions as CollectionOptions }
|