@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.
- package/dist/chunk-5NHFZRMO.js +1011 -0
- package/dist/chunk-5NHFZRMO.js.map +1 -0
- package/dist/chunk-LBAV5X5P.js +996 -0
- package/dist/chunk-LBAV5X5P.js.map +1 -0
- package/dist/compat/index.d.ts +1 -0
- package/dist/compat/index.js +3 -0
- package/dist/compat/index.js.map +1 -0
- package/dist/core/index.d.ts +368 -0
- package/dist/core/index.js +3 -0
- package/dist/core/index.js.map +1 -0
- package/dist/crdt/index.d.ts +372 -0
- package/dist/crdt/index.js +3 -0
- package/dist/crdt/index.js.map +1 -0
- package/dist/react/index.d.ts +144 -0
- package/dist/react/index.js +283 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +71 -0
- package/src/compat/index.ts +24 -0
- package/src/core/client.ts +432 -0
- package/src/core/collection.ts +474 -0
- package/src/core/index.ts +37 -0
- package/src/core/realtime.ts +303 -0
- package/src/core/state.ts +112 -0
- package/src/core/store.ts +241 -0
- package/src/crdt/clock.ts +78 -0
- package/src/crdt/counter.ts +130 -0
- package/src/crdt/document.ts +194 -0
- package/src/crdt/index.ts +56 -0
- package/src/crdt/operations.ts +101 -0
- package/src/crdt/register.ts +106 -0
- package/src/crdt/set.ts +172 -0
- package/src/crdt/sync.ts +412 -0
- package/src/crdt/text.ts +274 -0
- package/src/react/context.tsx +87 -0
- package/src/react/hooks.ts +489 -0
- package/src/react/index.ts +56 -0
|
@@ -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
|
+
}
|