@hanzo/base 0.2.0

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.
@@ -0,0 +1,303 @@
1
+ /**
2
+ * RealtimeService -- SSE-based subscription manager.
3
+ *
4
+ * Features:
5
+ * - Query deduplication via hash(collection+topic) with reference counting
6
+ * - Auto-reconnect with exponential backoff
7
+ * - Max observed timestamp tracking
8
+ * - Connection state notifications
9
+ */
10
+
11
+ import type { BaseRecord } from './state.js'
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Types
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export type ConnectionState = 'disconnected' | 'connecting' | 'connected'
18
+
19
+ export interface RealtimeEvent {
20
+ action: 'create' | 'update' | 'delete'
21
+ record: BaseRecord
22
+ }
23
+
24
+ export type RealtimeCallback = (event: RealtimeEvent) => void
25
+ export type ConnectionCallback = (state: ConnectionState) => void
26
+
27
+ interface Subscription {
28
+ collection: string
29
+ topic: string
30
+ callbacks: Set<RealtimeCallback>
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // RealtimeService
35
+ // ---------------------------------------------------------------------------
36
+
37
+ export class RealtimeService {
38
+ private readonly _baseUrl: string
39
+ private readonly _getToken: () => string
40
+
41
+ private _eventSource: EventSource | null = null
42
+ private _state: ConnectionState = 'disconnected'
43
+ private _connectionListeners = new Set<ConnectionCallback>()
44
+
45
+ /** Dedup map: hash -> Subscription. */
46
+ private _subscriptions = new Map<string, Subscription>()
47
+
48
+ /** SSE client id assigned by the server on connect. */
49
+ private _clientId: string | null = null
50
+
51
+ /** Reconnect state. */
52
+ private _reconnectAttempts = 0
53
+ private _reconnectTimer: ReturnType<typeof setTimeout> | null = null
54
+ private _maxReconnectDelay = 30_000
55
+ private _baseReconnectDelay = 500
56
+
57
+ /** High-water mark of observed server timestamps. */
58
+ private _maxTimestamp = 0n
59
+
60
+ /** Set to true when disconnect() is called explicitly. */
61
+ private _intentionalDisconnect = false
62
+
63
+ constructor(baseUrl: string, getToken: () => string) {
64
+ this._baseUrl = baseUrl.replace(/\/$/, '')
65
+ this._getToken = getToken
66
+ }
67
+
68
+ // ---- Public API ---------------------------------------------------------
69
+
70
+ get state(): ConnectionState {
71
+ return this._state
72
+ }
73
+
74
+ get maxTimestamp(): bigint {
75
+ return this._maxTimestamp
76
+ }
77
+
78
+ /**
79
+ * Subscribe to realtime events for a collection topic.
80
+ *
81
+ * Topic is usually "*" (all changes) or a record id.
82
+ * Returns an unsubscribe function.
83
+ */
84
+ subscribe(
85
+ collection: string,
86
+ topic: string,
87
+ callback: RealtimeCallback,
88
+ ): () => void {
89
+ const hash = this._hash(collection, topic)
90
+ let sub = this._subscriptions.get(hash)
91
+
92
+ if (!sub) {
93
+ sub = { collection, topic, callbacks: new Set() }
94
+ this._subscriptions.set(hash, sub)
95
+ }
96
+ sub.callbacks.add(callback)
97
+
98
+ // If this is the first subscription, connect.
99
+ if (this._subscriptions.size === 1 && !this._eventSource) {
100
+ this._connect()
101
+ } else if (this._clientId) {
102
+ // Already connected -- submit this subscription to the server.
103
+ this._submitSubscriptions()
104
+ }
105
+
106
+ return () => {
107
+ sub!.callbacks.delete(callback)
108
+ if (sub!.callbacks.size === 0) {
109
+ this._subscriptions.delete(hash)
110
+ if (this._clientId) {
111
+ this._submitSubscriptions()
112
+ }
113
+ }
114
+ // If no subscriptions remain, disconnect.
115
+ if (this._subscriptions.size === 0) {
116
+ this.disconnect()
117
+ }
118
+ }
119
+ }
120
+
121
+ /** Remove all subscribers for a topic, or all topics if none specified. */
122
+ unsubscribe(topic?: string): void {
123
+ if (topic) {
124
+ this._subscriptions.delete(topic)
125
+ } else {
126
+ this._subscriptions.clear()
127
+ }
128
+ if (this._subscriptions.size === 0) {
129
+ this.disconnect()
130
+ }
131
+ }
132
+
133
+ /** Register a connection-state listener. Returns unsubscribe. */
134
+ onConnectionChange(callback: ConnectionCallback): () => void {
135
+ this._connectionListeners.add(callback)
136
+ return () => {
137
+ this._connectionListeners.delete(callback)
138
+ }
139
+ }
140
+
141
+ /** Explicitly disconnect. */
142
+ disconnect(): void {
143
+ this._intentionalDisconnect = true
144
+ this._clearReconnect()
145
+ if (this._eventSource) {
146
+ this._eventSource.close()
147
+ this._eventSource = null
148
+ }
149
+ this._clientId = null
150
+ this._setState('disconnected')
151
+ }
152
+
153
+ // ---- Connection ---------------------------------------------------------
154
+
155
+ private _connect(): void {
156
+ if (this._eventSource) return
157
+ this._intentionalDisconnect = false
158
+ this._setState('connecting')
159
+
160
+ const url = `${this._baseUrl}/api/realtime`
161
+ this._eventSource = new EventSource(url)
162
+
163
+ this._eventSource.addEventListener('PB_CONNECT', (e: MessageEvent) => {
164
+ try {
165
+ const data = JSON.parse(e.data) as { clientId: string }
166
+ this._clientId = data.clientId
167
+ this._reconnectAttempts = 0
168
+ this._setState('connected')
169
+ this._submitSubscriptions()
170
+ } catch {
171
+ // malformed connect event
172
+ }
173
+ })
174
+
175
+ // Listen for all SSE events. The server sends events named after
176
+ // the collection, e.g. event: "posts".
177
+ this._eventSource.onmessage = (e: MessageEvent) => {
178
+ this._handleMessage(e)
179
+ }
180
+
181
+ // The server sends named events for each collection.
182
+ // We use the generic handler plus specific ones registered dynamically.
183
+ this._eventSource.onerror = () => {
184
+ this._eventSource?.close()
185
+ this._eventSource = null
186
+ this._clientId = null
187
+ this._setState('disconnected')
188
+
189
+ if (!this._intentionalDisconnect) {
190
+ this._scheduleReconnect()
191
+ }
192
+ }
193
+ }
194
+
195
+ private _handleMessage(e: MessageEvent): void {
196
+ let payload: { action: string; record: BaseRecord }
197
+ try {
198
+ payload = JSON.parse(e.data)
199
+ } catch {
200
+ return
201
+ }
202
+
203
+ const action = payload.action as RealtimeEvent['action']
204
+ const record = payload.record
205
+ if (!record?.id) return
206
+
207
+ // Track timestamp high-water mark.
208
+ const ts = record.updated ?? record.created
209
+ if (ts) {
210
+ const n = BigInt(new Date(ts).getTime()) * 1000n
211
+ if (n > this._maxTimestamp) {
212
+ this._maxTimestamp = n
213
+ }
214
+ }
215
+
216
+ // Fan out to matching subscriptions.
217
+ const collectionName = record.collectionName ?? ''
218
+ for (const sub of this._subscriptions.values()) {
219
+ if (sub.collection !== collectionName) continue
220
+ if (sub.topic !== '*' && sub.topic !== record.id) continue
221
+ const event: RealtimeEvent = { action, record }
222
+ for (const cb of sub.callbacks) {
223
+ try {
224
+ cb(event)
225
+ } catch {
226
+ // subscriber errors must not break fanout
227
+ }
228
+ }
229
+ }
230
+ }
231
+
232
+ /** Submit current subscription set to the server via POST. */
233
+ private async _submitSubscriptions(): Promise<void> {
234
+ if (!this._clientId) return
235
+
236
+ const topics: string[] = []
237
+ for (const sub of this._subscriptions.values()) {
238
+ topics.push(`${sub.collection}/${sub.topic}`)
239
+ }
240
+
241
+ const token = this._getToken()
242
+ try {
243
+ await fetch(`${this._baseUrl}/api/realtime`, {
244
+ method: 'POST',
245
+ headers: {
246
+ 'Content-Type': 'application/json',
247
+ ...(token ? { Authorization: token } : {}),
248
+ },
249
+ body: JSON.stringify({
250
+ clientId: this._clientId,
251
+ subscriptions: topics,
252
+ }),
253
+ })
254
+ } catch {
255
+ // will retry on next reconnect
256
+ }
257
+ }
258
+
259
+ // ---- Reconnect ----------------------------------------------------------
260
+
261
+ private _scheduleReconnect(): void {
262
+ this._clearReconnect()
263
+ const delay = Math.min(
264
+ this._baseReconnectDelay * Math.pow(2, this._reconnectAttempts),
265
+ this._maxReconnectDelay,
266
+ )
267
+ // Add jitter: +/- 25%
268
+ const jitter = delay * (0.75 + Math.random() * 0.5)
269
+ this._reconnectAttempts++
270
+
271
+ this._reconnectTimer = setTimeout(() => {
272
+ this._reconnectTimer = null
273
+ this._connect()
274
+ }, jitter)
275
+ }
276
+
277
+ private _clearReconnect(): void {
278
+ if (this._reconnectTimer !== null) {
279
+ clearTimeout(this._reconnectTimer)
280
+ this._reconnectTimer = null
281
+ }
282
+ }
283
+
284
+ // ---- State --------------------------------------------------------------
285
+
286
+ private _setState(state: ConnectionState): void {
287
+ if (this._state === state) return
288
+ this._state = state
289
+ for (const cb of this._connectionListeners) {
290
+ try {
291
+ cb(state)
292
+ } catch {
293
+ // listener errors must not break notification
294
+ }
295
+ }
296
+ }
297
+
298
+ // ---- Helpers ------------------------------------------------------------
299
+
300
+ private _hash(collection: string, topic: string): string {
301
+ return `${collection}::${topic}`
302
+ }
303
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * State version tracking (Convex-inspired).
3
+ *
4
+ * Each query result is tagged with a StateVersion so the client can
5
+ * detect ordering, replay transitions, and drive optimistic rollbacks.
6
+ */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Types
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export interface StateVersion {
13
+ /** Monotonically increasing counter bumped on every query-set change. */
14
+ querySet: number
15
+ /** Server timestamp (microseconds since epoch) of the latest known event. */
16
+ ts: bigint
17
+ /** Identity hash -- changes when the authenticated user changes. */
18
+ identity: number
19
+ }
20
+
21
+ export type Modification =
22
+ | { type: 'QueryUpdated'; collection: string; record: BaseRecord }
23
+ | { type: 'QueryRemoved'; collection: string; id: string }
24
+ | { type: 'QueryFailed'; collection: string; error: string }
25
+
26
+ export interface Transition {
27
+ startVersion: StateVersion
28
+ endVersion: StateVersion
29
+ modifications: Modification[]
30
+ }
31
+
32
+ /** Minimal record shape that every Base record satisfies. */
33
+ export interface BaseRecord {
34
+ id: string
35
+ collectionId?: string
36
+ collectionName?: string
37
+ created?: string
38
+ updated?: string
39
+ [key: string]: unknown
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // VersionTracker
44
+ // ---------------------------------------------------------------------------
45
+
46
+ export class VersionTracker {
47
+ private _version: StateVersion
48
+ private _history: Transition[] = []
49
+ private readonly _maxHistory: number
50
+
51
+ constructor(maxHistory = 128) {
52
+ this._version = { querySet: 0, ts: 0n, identity: 0 }
53
+ this._maxHistory = maxHistory
54
+ }
55
+
56
+ get current(): Readonly<StateVersion> {
57
+ return { ...this._version }
58
+ }
59
+
60
+ get history(): readonly Transition[] {
61
+ return this._history
62
+ }
63
+
64
+ /**
65
+ * Advance the version and record a transition.
66
+ * Returns the new version.
67
+ */
68
+ advance(modifications: Modification[], serverTs?: bigint): StateVersion {
69
+ const start = { ...this._version }
70
+
71
+ this._version = {
72
+ querySet: this._version.querySet + 1,
73
+ ts: serverTs ?? this._version.ts,
74
+ identity: this._version.identity,
75
+ }
76
+
77
+ const transition: Transition = {
78
+ startVersion: start,
79
+ endVersion: { ...this._version },
80
+ modifications,
81
+ }
82
+
83
+ this._history.push(transition)
84
+ if (this._history.length > this._maxHistory) {
85
+ this._history.shift()
86
+ }
87
+
88
+ return { ...this._version }
89
+ }
90
+
91
+ /** Update identity hash (e.g. on auth change). */
92
+ setIdentity(identity: number): void {
93
+ this._version = { ...this._version, identity }
94
+ }
95
+
96
+ /** Update the high-water timestamp without bumping querySet. */
97
+ updateTimestamp(ts: bigint): void {
98
+ if (ts > this._version.ts) {
99
+ this._version = { ...this._version, ts }
100
+ }
101
+ }
102
+
103
+ /** Simple FNV-1a-like hash for identity derivation. */
104
+ static hashIdentity(token: string): number {
105
+ let h = 0x811c9dc5
106
+ for (let i = 0; i < token.length; i++) {
107
+ h ^= token.charCodeAt(i)
108
+ h = Math.imul(h, 0x01000193)
109
+ }
110
+ return h >>> 0
111
+ }
112
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * QueryStore -- manages server state + optimistic overlay.
3
+ *
4
+ * Subscribers are notified whenever the effective (server + optimistic)
5
+ * state for their query changes. Optimistic mutations are tracked by
6
+ * mutationId so they can be rolled back individually.
7
+ */
8
+
9
+ import type { BaseRecord, Modification } from './state.js'
10
+ import { VersionTracker } from './state.js'
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Types
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export interface QueryKey {
17
+ collection: string
18
+ filter: string
19
+ }
20
+
21
+ export type StoreCallback = (records: BaseRecord[]) => void
22
+
23
+ interface OptimisticEntry {
24
+ mutationId: string
25
+ collection: string
26
+ /** null means "delete this id" */
27
+ record: BaseRecord | null
28
+ deletedId?: string
29
+ createdAt: number
30
+ }
31
+
32
+ interface QuerySlot {
33
+ key: QueryKey
34
+ serverRecords: BaseRecord[]
35
+ listeners: Set<StoreCallback>
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Helpers
40
+ // ---------------------------------------------------------------------------
41
+
42
+ function queryHash(collection: string, filter: string): string {
43
+ return `${collection}::${filter}`
44
+ }
45
+
46
+ function mergeOptimistic(
47
+ server: BaseRecord[],
48
+ optimistic: OptimisticEntry[],
49
+ collection: string,
50
+ ): BaseRecord[] {
51
+ // Start with a mutable copy keyed by id.
52
+ const map = new Map<string, BaseRecord>()
53
+ for (const r of server) {
54
+ map.set(r.id, r)
55
+ }
56
+
57
+ for (const entry of optimistic) {
58
+ if (entry.collection !== collection) continue
59
+ if (entry.record === null && entry.deletedId) {
60
+ map.delete(entry.deletedId)
61
+ } else if (entry.record) {
62
+ map.set(entry.record.id, entry.record)
63
+ }
64
+ }
65
+
66
+ return Array.from(map.values())
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // QueryStore
71
+ // ---------------------------------------------------------------------------
72
+
73
+ export class QueryStore {
74
+ private readonly _slots = new Map<string, QuerySlot>()
75
+ private readonly _optimistic: OptimisticEntry[] = []
76
+ private readonly _version = new VersionTracker()
77
+
78
+ get version() {
79
+ return this._version.current
80
+ }
81
+
82
+ // ---- Query cache --------------------------------------------------------
83
+
84
+ /** Return cached effective (server+optimistic) result or undefined. */
85
+ getQuery(collection: string, filter = ''): BaseRecord[] | undefined {
86
+ const slot = this._slots.get(queryHash(collection, filter))
87
+ if (!slot) return undefined
88
+ return mergeOptimistic(slot.serverRecords, this._optimistic, collection)
89
+ }
90
+
91
+ /** Overwrite the server-truth cache for a query and notify. */
92
+ setQuery(collection: string, filter: string, data: BaseRecord[]): void {
93
+ const hash = queryHash(collection, filter)
94
+ let slot = this._slots.get(hash)
95
+ if (!slot) {
96
+ slot = { key: { collection, filter }, serverRecords: [], listeners: new Set() }
97
+ this._slots.set(hash, slot)
98
+ }
99
+ slot.serverRecords = data
100
+
101
+ this._version.advance(
102
+ data.map((r) => ({ type: 'QueryUpdated' as const, collection, record: r })),
103
+ )
104
+
105
+ this._notify(slot)
106
+ }
107
+
108
+ // ---- Optimistic mutations -----------------------------------------------
109
+
110
+ /** Apply an optimistic create/update. Returns a mutationId for rollback. */
111
+ optimisticSet(collection: string, record: BaseRecord): string {
112
+ const mutationId = `opt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
113
+ this._optimistic.push({
114
+ mutationId,
115
+ collection,
116
+ record,
117
+ createdAt: Date.now(),
118
+ })
119
+ this._notifyCollection(collection)
120
+ return mutationId
121
+ }
122
+
123
+ /** Apply an optimistic delete. */
124
+ optimisticDelete(collection: string, id: string): string {
125
+ const mutationId = `opt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
126
+ this._optimistic.push({
127
+ mutationId,
128
+ collection,
129
+ record: null,
130
+ deletedId: id,
131
+ createdAt: Date.now(),
132
+ })
133
+ this._notifyCollection(collection)
134
+ return mutationId
135
+ }
136
+
137
+ /** Remove a single optimistic mutation and re-derive. */
138
+ rollbackOptimistic(mutationId: string): void {
139
+ const idx = this._optimistic.findIndex((e) => e.mutationId === mutationId)
140
+ if (idx === -1) return
141
+ const entry = this._optimistic[idx]
142
+ this._optimistic.splice(idx, 1)
143
+ this._notifyCollection(entry.collection)
144
+ }
145
+
146
+ /** Drop all optimistic entries for a collection. */
147
+ clearOptimistic(collection: string): void {
148
+ for (let i = this._optimistic.length - 1; i >= 0; i--) {
149
+ if (this._optimistic[i].collection === collection) {
150
+ this._optimistic.splice(i, 1)
151
+ }
152
+ }
153
+ this._notifyCollection(collection)
154
+ }
155
+
156
+ // ---- Server event ingestion ---------------------------------------------
157
+
158
+ /**
159
+ * Apply a realtime SSE event from the server.
160
+ * `action` is one of "create", "update", "delete".
161
+ */
162
+ applyServerUpdate(
163
+ collection: string,
164
+ action: 'create' | 'update' | 'delete',
165
+ record: BaseRecord,
166
+ ): void {
167
+ const mods: Modification[] = []
168
+
169
+ for (const slot of this._slots.values()) {
170
+ if (slot.key.collection !== collection) continue
171
+
172
+ if (action === 'delete') {
173
+ const before = slot.serverRecords.length
174
+ slot.serverRecords = slot.serverRecords.filter((r) => r.id !== record.id)
175
+ if (slot.serverRecords.length !== before) {
176
+ mods.push({ type: 'QueryRemoved', collection, id: record.id })
177
+ }
178
+ } else {
179
+ // create or update -- upsert
180
+ const idx = slot.serverRecords.findIndex((r) => r.id === record.id)
181
+ if (idx >= 0) {
182
+ slot.serverRecords[idx] = record
183
+ } else {
184
+ slot.serverRecords.push(record)
185
+ }
186
+ mods.push({ type: 'QueryUpdated', collection, record })
187
+ }
188
+ }
189
+
190
+ if (mods.length > 0) {
191
+ const ts = record.updated
192
+ ? BigInt(new Date(record.updated).getTime()) * 1000n
193
+ : undefined
194
+ this._version.advance(mods, ts)
195
+ }
196
+
197
+ this._notifyCollection(collection)
198
+ }
199
+
200
+ // ---- Subscriptions ------------------------------------------------------
201
+
202
+ /** Subscribe to effective-state changes for a query. Returns unsubscribe. */
203
+ subscribe(collection: string, filter: string, callback: StoreCallback): () => void {
204
+ const hash = queryHash(collection, filter)
205
+ let slot = this._slots.get(hash)
206
+ if (!slot) {
207
+ slot = { key: { collection, filter }, serverRecords: [], listeners: new Set() }
208
+ this._slots.set(hash, slot)
209
+ }
210
+ slot.listeners.add(callback)
211
+ return () => {
212
+ slot!.listeners.delete(callback)
213
+ // GC empty slots
214
+ if (slot!.listeners.size === 0 && slot!.serverRecords.length === 0) {
215
+ this._slots.delete(hash)
216
+ }
217
+ }
218
+ }
219
+
220
+ // ---- Internal -----------------------------------------------------------
221
+
222
+ private _notify(slot: QuerySlot): void {
223
+ if (slot.listeners.size === 0) return
224
+ const effective = mergeOptimistic(slot.serverRecords, this._optimistic, slot.key.collection)
225
+ for (const cb of slot.listeners) {
226
+ try {
227
+ cb(effective)
228
+ } catch {
229
+ // listener errors must not break the store
230
+ }
231
+ }
232
+ }
233
+
234
+ private _notifyCollection(collection: string): void {
235
+ for (const slot of this._slots.values()) {
236
+ if (slot.key.collection === collection) {
237
+ this._notify(slot)
238
+ }
239
+ }
240
+ }
241
+ }