@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/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 }