@synthigy/sdk 0.1.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/LICENSE +21 -0
- package/README.md +102 -0
- package/codegen/codegen.mjs +499 -0
- package/codegen/lib.mjs +30 -0
- package/codegen/pull.mjs +47 -0
- package/package.json +63 -0
- package/src/index.d.ts +1108 -0
- package/src/index.js +2535 -0
- package/src/watch-query.js +533 -0
- package/src/watch.js +915 -0
package/src/watch.js
ADDED
|
@@ -0,0 +1,915 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live data primitive — `client.watch()` and `client.watchSchema()`.
|
|
3
|
+
*
|
|
4
|
+
* Design:
|
|
5
|
+
*
|
|
6
|
+
* - Client opens many `watch()`es; SDK fuses them onto ONE SSE
|
|
7
|
+
* connection and ONE consolidated POST /data/subscription/set.
|
|
8
|
+
* - Each Watch yields a thin, schema-resolved event stream.
|
|
9
|
+
* - `before`/`after` keys are resolved from attr-xid → attribute-name
|
|
10
|
+
* using a SchemaResolver fed by `client.schema()`; refreshed on
|
|
11
|
+
* `runtime-model` deploy events.
|
|
12
|
+
* - Coalesce-per-record by default — multiple pending updates on the
|
|
13
|
+
* same record collapse to the latest `after`. No silent UI drift
|
|
14
|
+
* across records; consumers can pick `lossless` or `sliding` modes
|
|
15
|
+
* when they want different.
|
|
16
|
+
* - Fresh AsyncIterator per `for await` so multiple consumers can
|
|
17
|
+
* share one Watch (e.g. siblings in a component tree).
|
|
18
|
+
* - Reconnect is transparent; consumer sees a `connection/resumed`
|
|
19
|
+
* sentinel on the same stream.
|
|
20
|
+
*
|
|
21
|
+
* This module covers the `watch(interest)` primitive and its schema
|
|
22
|
+
* resolver. The higher-level `watchQuery` / `watchSqlTemplate` build on
|
|
23
|
+
* it and live in watch-query.js.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// Errors
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
export class WatchError extends Error {
|
|
31
|
+
constructor(message, code, details) {
|
|
32
|
+
super(message)
|
|
33
|
+
this.name = 'WatchError'
|
|
34
|
+
this.code = code
|
|
35
|
+
this.details = details
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ============================================================================
|
|
40
|
+
// Schema resolver — attr-xid → attribute-name, entity-xid → entity-name
|
|
41
|
+
// ============================================================================
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Lazy cache: fetches `client.schema()` once per client, parses out the
|
|
45
|
+
* xid maps, refreshes on `runtime-model` deploy events. One per client.
|
|
46
|
+
*
|
|
47
|
+
* `schema` is plain JSON, IAM-projected, and carries
|
|
48
|
+
* `:xid` per entity + `:xids {:attributes, :relations}` map — exactly
|
|
49
|
+
* what the resolver needs to translate plug envelopes (which key
|
|
50
|
+
* `before`/`after` by attribute xid) back to attribute names.
|
|
51
|
+
*
|
|
52
|
+
* @private
|
|
53
|
+
*/
|
|
54
|
+
class SchemaResolver {
|
|
55
|
+
constructor(client) {
|
|
56
|
+
this._client = client
|
|
57
|
+
this._loaded = false
|
|
58
|
+
this._loading = null // in-flight promise; coalesces concurrent waiters
|
|
59
|
+
this._attrXidToName = new Map() // 'JAN1Rh8…' → 'title'
|
|
60
|
+
this._tableXidToName = new Map() // 'CuquTM…' → 'movie'
|
|
61
|
+
this._nameToTableXid = new Map() // 'movie' → 'CuquTM…'
|
|
62
|
+
this._relsByEntity = new Map() // entity-xid → [relation-xid]; both directions
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Best-effort: returns the cached map; loads if not yet loaded. */
|
|
66
|
+
async ensureLoaded() {
|
|
67
|
+
if (this._loaded) return
|
|
68
|
+
if (!this._loading) this._loading = this._fetch()
|
|
69
|
+
await this._loading
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Force a refresh (called on `runtime-model` deploy events). */
|
|
73
|
+
async refresh() {
|
|
74
|
+
this._loading = this._fetch()
|
|
75
|
+
await this._loading
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async _fetch() {
|
|
79
|
+
try {
|
|
80
|
+
// /schema is the SDK introspection surface — plain JSON, IAM-projected,
|
|
81
|
+
// includes xid maps as of the schema-xid-augment landing. Source is
|
|
82
|
+
// the runtime model (deployed + identity + audit + reference relations)
|
|
83
|
+
// so we cover everything the wire actually emits.
|
|
84
|
+
//
|
|
85
|
+
// Shape:
|
|
86
|
+
// { 'id-key',
|
|
87
|
+
// entities: { '<kebab-name>': {
|
|
88
|
+
// name, xid,
|
|
89
|
+
// attributes: {<kebab-attr>: type-string},
|
|
90
|
+
// relations: {<label>: {to, cardinality}},
|
|
91
|
+
// constraints, xids: {attributes, relations} }}}
|
|
92
|
+
const schema = await this._client.schema()
|
|
93
|
+
const attr = new Map()
|
|
94
|
+
const table = new Map()
|
|
95
|
+
const nameToTable = new Map()
|
|
96
|
+
const relsByEntity = new Map()
|
|
97
|
+
|
|
98
|
+
const entities = schema?.entities ?? {}
|
|
99
|
+
for (const [name, entity] of Object.entries(entities)) {
|
|
100
|
+
const xid = entity?.xid
|
|
101
|
+
if (xid) {
|
|
102
|
+
table.set(String(xid), name)
|
|
103
|
+
nameToTable.set(name, String(xid))
|
|
104
|
+
}
|
|
105
|
+
const attrXids = entity?.xids?.attributes ?? {}
|
|
106
|
+
for (const [attrName, attrXid] of Object.entries(attrXids)) {
|
|
107
|
+
if (attrXid) attr.set(String(attrXid), attrName)
|
|
108
|
+
}
|
|
109
|
+
const relXids = entity?.xids?.relations ?? {}
|
|
110
|
+
const xids = Object.values(relXids).map(String).filter(Boolean)
|
|
111
|
+
if (xid && xids.length) {
|
|
112
|
+
const existing = relsByEntity.get(String(xid)) ?? []
|
|
113
|
+
// Each side of a relation references the same xid; Set dedupes
|
|
114
|
+
// when a future caller fuses across entities.
|
|
115
|
+
relsByEntity.set(String(xid), [...new Set([...existing, ...xids])])
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this._attrXidToName = attr
|
|
120
|
+
this._tableXidToName = table
|
|
121
|
+
this._nameToTableXid = nameToTable
|
|
122
|
+
this._relsByEntity = relsByEntity
|
|
123
|
+
this._loaded = true
|
|
124
|
+
} catch (e) {
|
|
125
|
+
// Don't poison the cache on transient failure; next ensureLoaded retries.
|
|
126
|
+
this._loading = null
|
|
127
|
+
throw e
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Lookup; returns the xid string back if no mapping exists yet. */
|
|
132
|
+
attrName(xid) {
|
|
133
|
+
return this._attrXidToName.get(String(xid)) ?? String(xid)
|
|
134
|
+
}
|
|
135
|
+
entityName(xid) {
|
|
136
|
+
return this._tableXidToName.get(String(xid)) ?? null
|
|
137
|
+
}
|
|
138
|
+
/** Kebab-case entity-name → entity-xid; null if not found. */
|
|
139
|
+
entityXidByName(name) {
|
|
140
|
+
return this._nameToTableXid.get(kebab(name)) ?? null
|
|
141
|
+
}
|
|
142
|
+
/** All relation xids that have `entityXid` on either from or to side. */
|
|
143
|
+
relationsForEntity(entityXid) {
|
|
144
|
+
if (!entityXid) return []
|
|
145
|
+
return this._relsByEntity.get(String(entityXid)) ?? []
|
|
146
|
+
}
|
|
147
|
+
/** True if the resolver successfully populated its maps. */
|
|
148
|
+
isLoaded() { return this._loaded }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function kebab(s) {
|
|
152
|
+
if (s == null) return null
|
|
153
|
+
return String(s)
|
|
154
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
155
|
+
.replace(/[\s_]+/g, '-')
|
|
156
|
+
.toLowerCase()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ============================================================================
|
|
160
|
+
// Coalescing buffer — one per AsyncIterator
|
|
161
|
+
// ============================================================================
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Append-and-await queue with coalescing semantics:
|
|
165
|
+
* - 'coalesce' (default): updates on the same record collapse;
|
|
166
|
+
* delete supersedes prior updates; relations + adds always preserved
|
|
167
|
+
* - 'lossless': every event preserved; emits a 'paused'
|
|
168
|
+
* sentinel if `bufferSize` is exceeded
|
|
169
|
+
* - 'sliding': drop oldest when full (legacy SSE behaviour)
|
|
170
|
+
*
|
|
171
|
+
* @private
|
|
172
|
+
*/
|
|
173
|
+
export class CoalesceBuffer {
|
|
174
|
+
constructor({ mode = 'coalesce', size = 100 } = {}) {
|
|
175
|
+
this._mode = mode
|
|
176
|
+
this._size = size
|
|
177
|
+
this._queue = [] // ordered events
|
|
178
|
+
this._byRecord = new Map() // record-xid → queue index (for coalesce)
|
|
179
|
+
this._waiters = [] // pending resolvers when buffer is empty
|
|
180
|
+
this._closed = false
|
|
181
|
+
this._paused = false
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
push(ev) {
|
|
185
|
+
if (this._closed) return
|
|
186
|
+
if (this._mode === 'coalesce' && ev.type?.startsWith('record/')) {
|
|
187
|
+
const xid = ev.record
|
|
188
|
+
const existing = this._byRecord.get(xid)
|
|
189
|
+
if (existing !== undefined) {
|
|
190
|
+
const prev = this._queue[existing]
|
|
191
|
+
if (ev.type === 'record/delete') {
|
|
192
|
+
// Delete supersedes — replace prior in place.
|
|
193
|
+
this._queue[existing] = ev
|
|
194
|
+
return this._wake()
|
|
195
|
+
}
|
|
196
|
+
if (prev.type === 'record/delete') return // delete already wins
|
|
197
|
+
// Merge updates: latest 'after' wins; union 'changed'; keep earliest
|
|
198
|
+
// 'before' so the diff reflects "what the consumer last saw" → "current".
|
|
199
|
+
this._queue[existing] = {
|
|
200
|
+
...ev,
|
|
201
|
+
before: prev.before ?? ev.before,
|
|
202
|
+
changed: unionChanged(prev.changed, ev.changed),
|
|
203
|
+
}
|
|
204
|
+
return this._wake()
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (this._queue.length >= this._size) {
|
|
208
|
+
if (this._mode === 'sliding') {
|
|
209
|
+
const dropped = this._queue.shift()
|
|
210
|
+
this._reindexAfterShift(dropped)
|
|
211
|
+
} else if (this._mode === 'lossless') {
|
|
212
|
+
if (!this._paused) {
|
|
213
|
+
this._paused = true
|
|
214
|
+
this._queue.push({ type: 'paused' })
|
|
215
|
+
return this._wake()
|
|
216
|
+
}
|
|
217
|
+
return // already paused; drop silently
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (ev.type?.startsWith('record/')) this._byRecord.set(ev.record, this._queue.length)
|
|
221
|
+
this._queue.push(ev)
|
|
222
|
+
this._paused = false
|
|
223
|
+
this._wake()
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async next() {
|
|
227
|
+
if (this._queue.length) return this._shift()
|
|
228
|
+
if (this._closed) return { done: true }
|
|
229
|
+
return new Promise((resolve) => this._waiters.push(resolve))
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
close() {
|
|
233
|
+
this._closed = true
|
|
234
|
+
for (const w of this._waiters) w({ done: true })
|
|
235
|
+
this._waiters = []
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
_shift() {
|
|
239
|
+
const ev = this._queue.shift()
|
|
240
|
+
// Any shift decrements every following index, so rebuild the coalesce map
|
|
241
|
+
// whenever we're tracking one — not only when the shifted event was a
|
|
242
|
+
// record event (a shifted relation/poke still invalidates record indices).
|
|
243
|
+
if (this._mode === 'coalesce') this._reindexAfterShift(ev)
|
|
244
|
+
return { value: ev, done: false }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
_reindexAfterShift(_ev) {
|
|
248
|
+
// Recompute the index map cheaply: walk the (now-shorter) queue.
|
|
249
|
+
// Sized for typical UI buffers (<100); not worth a doubly-linked map.
|
|
250
|
+
this._byRecord.clear()
|
|
251
|
+
for (let i = 0; i < this._queue.length; i++) {
|
|
252
|
+
const e = this._queue[i]
|
|
253
|
+
if (e?.type?.startsWith('record/')) this._byRecord.set(e.record, i)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
_wake() {
|
|
258
|
+
while (this._waiters.length && this._queue.length) {
|
|
259
|
+
const w = this._waiters.shift()
|
|
260
|
+
w(this._shift())
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function unionChanged(a, b) {
|
|
266
|
+
if (!a) return b
|
|
267
|
+
if (!b) return a
|
|
268
|
+
const set = new Set(a)
|
|
269
|
+
for (const x of b) set.add(x)
|
|
270
|
+
return [...set]
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ============================================================================
|
|
274
|
+
// WatchMultiplexer — one per client. Owns the single SSE + the union set.
|
|
275
|
+
// ============================================================================
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Internal state for one client:
|
|
279
|
+
* - the single SSE session
|
|
280
|
+
* - the union of all live Watches' interests
|
|
281
|
+
* - the list of live Watches (with their interests + iterators)
|
|
282
|
+
* - the schema resolver
|
|
283
|
+
*
|
|
284
|
+
* @private
|
|
285
|
+
*/
|
|
286
|
+
export class WatchMultiplexer {
|
|
287
|
+
constructor(client, { schema = true, keepAlive = false } = {}) {
|
|
288
|
+
this._client = client
|
|
289
|
+
this._wantSchema = schema
|
|
290
|
+
// When true, the SSE stays open even when the user-watch set is empty.
|
|
291
|
+
// Set by `createClient({ keepAlive: true })` for long-lived servers
|
|
292
|
+
// (BFFs, services) where dropping to zero watches and tearing the
|
|
293
|
+
// SSE down would lose plug session state keyed by [sub, client_id].
|
|
294
|
+
this._keepAlive = keepAlive
|
|
295
|
+
this._watches = new Set()
|
|
296
|
+
this._sseAbort = null
|
|
297
|
+
this._ssePromise = null
|
|
298
|
+
this._flushScheduled = false
|
|
299
|
+
this._flushInflight = null // promise chain — serialize POSTs
|
|
300
|
+
this._lastUnion = null // for change detection
|
|
301
|
+
// True once the SSE fetch has handshaken at least once. Before this,
|
|
302
|
+
// any /data/subscription/set POST would race the server's stream
|
|
303
|
+
// creation; the sse/open sentinel branch in _runSse does the initial
|
|
304
|
+
// flush. After this, the SDK SSE is established (or in a reconnect
|
|
305
|
+
// loop) and it's safe for register/unregister/interest-change to
|
|
306
|
+
// schedule flushes directly.
|
|
307
|
+
this._sseEverOpened = false
|
|
308
|
+
this._resolver = schema ? new SchemaResolver(client) : null
|
|
309
|
+
// Warm the resolver eagerly when schema is wanted — design call:
|
|
310
|
+
// events resolve from the first one rather than racing the first
|
|
311
|
+
// event. Failures are non-fatal; events become {unresolved: true}.
|
|
312
|
+
if (this._resolver) this._resolver.ensureLoaded().catch(() => {})
|
|
313
|
+
// Eagerly open the SSE when keepAlive is on so the [sub, client_id]
|
|
314
|
+
// plug session lands BEFORE the first user watch races to use it.
|
|
315
|
+
if (this._keepAlive) this._ensureSseOpen()
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Internal — the SchemaResolver feeding event name resolution. */
|
|
319
|
+
get resolver() { return this._resolver }
|
|
320
|
+
|
|
321
|
+
register(watch) {
|
|
322
|
+
this._watches.add(watch)
|
|
323
|
+
this._ensureSseOpen()
|
|
324
|
+
// If the SSE has been opened at least once, the server already has a
|
|
325
|
+
// bound session and it's safe to POST the new union immediately.
|
|
326
|
+
// For the very first register (SSE not yet open), the flush is
|
|
327
|
+
// deferred to the sse/open sentinel branch in _runSse — that avoids
|
|
328
|
+
// racing the fetch handshake and having the subscription dropped.
|
|
329
|
+
if (this._sseEverOpened) this._scheduleFlush()
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
unregister(watch) {
|
|
333
|
+
if (!this._watches.delete(watch)) return
|
|
334
|
+
this._scheduleFlush()
|
|
335
|
+
// keepAlive holds the SSE open across empty-watch windows so the
|
|
336
|
+
// server-side plug session (keyed by [sub, client_id]) isn't
|
|
337
|
+
// torn down between user watches. Release explicitly via client.close().
|
|
338
|
+
if (this._watches.size === 0 && !this._keepAlive) this._closeSse()
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Explicit teardown. Closes every live watch, releases the keepAlive
|
|
343
|
+
* hold, and tears the SSE down. Called by `client.close()`.
|
|
344
|
+
*/
|
|
345
|
+
close() {
|
|
346
|
+
this._keepAlive = false
|
|
347
|
+
for (const w of [...this._watches]) {
|
|
348
|
+
try { w.close() } catch { /* ignore */ }
|
|
349
|
+
}
|
|
350
|
+
this._closeSse()
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Called by a Watch when its interest changes (add/remove/setInterest). */
|
|
354
|
+
notifyInterestChanged() { this._scheduleFlush() }
|
|
355
|
+
|
|
356
|
+
_scheduleFlush() {
|
|
357
|
+
if (this._flushScheduled) return
|
|
358
|
+
this._flushScheduled = true
|
|
359
|
+
queueMicrotask(() => {
|
|
360
|
+
this._flushScheduled = false
|
|
361
|
+
// Serialize: chain the next flush after any in-flight POST so the
|
|
362
|
+
// server always observes the LATEST union last. Without this, two
|
|
363
|
+
// POSTs can race and the smaller (older) union can land last.
|
|
364
|
+
const next = (this._flushInflight ?? Promise.resolve()).then(
|
|
365
|
+
() => this._flush(),
|
|
366
|
+
() => this._flush(),
|
|
367
|
+
)
|
|
368
|
+
this._flushInflight = next
|
|
369
|
+
next.catch((e) => {
|
|
370
|
+
for (const w of this._watches) w._pushSentinel({
|
|
371
|
+
type: 'subscription/rejected',
|
|
372
|
+
reason: e?.message ?? 'set-subscriptions failed',
|
|
373
|
+
records: [],
|
|
374
|
+
})
|
|
375
|
+
}).finally(() => {
|
|
376
|
+
if (this._flushInflight === next) this._flushInflight = null
|
|
377
|
+
})
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async _flush() {
|
|
382
|
+
if (!this._watches.size) {
|
|
383
|
+
// empty union: still POST so server drops what we held
|
|
384
|
+
const items = []
|
|
385
|
+
const sig = JSON.stringify(items)
|
|
386
|
+
if (sig === this._lastUnion) return
|
|
387
|
+
this._lastUnion = sig
|
|
388
|
+
await this._client.setSubscriptions(items)
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
// Compute the union across all live Watches. Two distinct things
|
|
392
|
+
// share unfortunate naming:
|
|
393
|
+
// - i.relationXids — relation-xid Set used LOCALLY by watchQuery
|
|
394
|
+
// to fan-out per-record relation events (NOT on the wire — the
|
|
395
|
+
// data track narrows by `records`/`endpoint-xids` server-side).
|
|
396
|
+
// - i.relations — relation NAME Set ("Movie.actors") for the
|
|
397
|
+
// new relation track on the wire.
|
|
398
|
+
const records = new Set()
|
|
399
|
+
const entities = new Set()
|
|
400
|
+
const namedRelations = new Set()
|
|
401
|
+
const ops = new Set()
|
|
402
|
+
let anyOps = false
|
|
403
|
+
for (const w of this._watches) {
|
|
404
|
+
const i = w.interest
|
|
405
|
+
if (i.records) for (const x of i.records) records.add(x)
|
|
406
|
+
if (i.entities) for (const e of i.entities) entities.add(e)
|
|
407
|
+
if (i.relations) for (const r of i.relations) namedRelations.add(r)
|
|
408
|
+
if (i.ops) { anyOps = true; for (const o of i.ops) ops.add(o) }
|
|
409
|
+
}
|
|
410
|
+
// Wire items: one per track that has interest.
|
|
411
|
+
const items = []
|
|
412
|
+
if (records.size) {
|
|
413
|
+
const item = { type: 'data', records: [...records].sort() }
|
|
414
|
+
if (anyOps) item.operations = [...ops].sort()
|
|
415
|
+
items.push(item)
|
|
416
|
+
}
|
|
417
|
+
if (entities.size) {
|
|
418
|
+
items.push({ type: 'entity', entities: [...entities].sort() })
|
|
419
|
+
}
|
|
420
|
+
if (namedRelations.size) {
|
|
421
|
+
items.push({ type: 'relation', relations: [...namedRelations].sort() })
|
|
422
|
+
}
|
|
423
|
+
if (this._wantSchema) items.push({ type: 'runtime-model' })
|
|
424
|
+
|
|
425
|
+
const sig = JSON.stringify(items)
|
|
426
|
+
if (sig === this._lastUnion) return
|
|
427
|
+
this._lastUnion = sig
|
|
428
|
+
await this._client.setSubscriptions(items)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
_ensureSseOpen() {
|
|
432
|
+
if (this._ssePromise) return
|
|
433
|
+
const ac = new AbortController()
|
|
434
|
+
this._sseAbort = ac
|
|
435
|
+
this._ssePromise = this._runSse(ac.signal).catch(() => null)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
_closeSse() {
|
|
439
|
+
if (this._sseAbort) {
|
|
440
|
+
// Defer abort() to a microtask so any sync rejections from the
|
|
441
|
+
// signal's listeners (Node 25's AbortController.abort() can
|
|
442
|
+
// surface AbortError through its listener chain) don't escape
|
|
443
|
+
// our caller's stack.
|
|
444
|
+
const ac = this._sseAbort
|
|
445
|
+
queueMicrotask(() => { try { ac.abort() } catch { /* ignore */ } })
|
|
446
|
+
}
|
|
447
|
+
this._sseAbort = null
|
|
448
|
+
this._ssePromise = null
|
|
449
|
+
this._lastUnion = null
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Long-lived SSE reader. Uses the client's existing `listen()` to keep
|
|
454
|
+
* one shared reconnect/backoff implementation. On each reconnect we
|
|
455
|
+
* re-flush so server-side state matches our union.
|
|
456
|
+
*/
|
|
457
|
+
async _runSse(signal) {
|
|
458
|
+
let firstSession = true
|
|
459
|
+
const debug = typeof process !== 'undefined' && process.env?.SDK_DEBUG
|
|
460
|
+
for (;;) {
|
|
461
|
+
if (signal.aborted) return
|
|
462
|
+
try {
|
|
463
|
+
// No pre-fetch flush — flushing here would POST /subscription/set
|
|
464
|
+
// before the SSE fetch handshake, which is exactly the race we're
|
|
465
|
+
// trying to avoid. Both first session and reconnects flush from
|
|
466
|
+
// the `sse/open` sentinel branch below.
|
|
467
|
+
if (debug) console.log('[sdk-sse] OPEN', { watches: this._watches.size })
|
|
468
|
+
// Use the client's listen() which handles SSE framing + backoff.
|
|
469
|
+
for await (const ev of this._client.listen({ signal })) {
|
|
470
|
+
if (debug) {
|
|
471
|
+
console.log('[sdk-sse] RAW', {
|
|
472
|
+
type: ev?.type,
|
|
473
|
+
recordXid: ev?.['record-xid'],
|
|
474
|
+
data: ev?.data, // relation tuple [subscribed, other]
|
|
475
|
+
entity: ev?.entity, // present on entity/touched poke only
|
|
476
|
+
relation: ev?.relation, // present on relation/touched poke only
|
|
477
|
+
actor: ev?.actor,
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
// SSE handshake landed — NOW it's safe to POST the subscription
|
|
481
|
+
// union (server has created the event stream session). Mark
|
|
482
|
+
// the mux as "SSE-opened-at-least-once" so subsequent
|
|
483
|
+
// register/interest-change can flush directly without racing.
|
|
484
|
+
if (ev?.type === 'sse/open') {
|
|
485
|
+
this._sseEverOpened = true
|
|
486
|
+
this._flush().catch((e) => {
|
|
487
|
+
for (const w of this._watches) w._pushSentinel({
|
|
488
|
+
type: 'subscription/rejected',
|
|
489
|
+
reason: e?.message ?? 'set-subscriptions failed',
|
|
490
|
+
records: [],
|
|
491
|
+
})
|
|
492
|
+
})
|
|
493
|
+
continue
|
|
494
|
+
}
|
|
495
|
+
// Schema deploy notification?
|
|
496
|
+
if (ev?.type === 'runtime-model' || ev?._sseEvent === 'runtime-model') {
|
|
497
|
+
if (this._resolver) {
|
|
498
|
+
this._resolver.refresh()
|
|
499
|
+
.then(() => this._fanOutSentinel({ type: 'schema/changed' }))
|
|
500
|
+
.catch(() => {})
|
|
501
|
+
}
|
|
502
|
+
continue
|
|
503
|
+
}
|
|
504
|
+
if (debug) {
|
|
505
|
+
const matchedWatches = [...this._watches].filter((w) => w._matches(ev))
|
|
506
|
+
console.log('[sdk-sse] DISPATCH', { matched: matchedWatches.length, of: this._watches.size })
|
|
507
|
+
}
|
|
508
|
+
await this._dispatch(ev, firstSession)
|
|
509
|
+
}
|
|
510
|
+
} catch (e) {
|
|
511
|
+
if (signal.aborted) return
|
|
512
|
+
// listen() already retries; if it throws here something is fatal.
|
|
513
|
+
for (const w of this._watches) w._pushSentinel({
|
|
514
|
+
type: 'subscription/rejected',
|
|
515
|
+
reason: e?.message ?? 'sse failed',
|
|
516
|
+
records: [],
|
|
517
|
+
})
|
|
518
|
+
return
|
|
519
|
+
} finally {
|
|
520
|
+
if (!firstSession) this._fanOutSentinel({
|
|
521
|
+
type: 'connection/resumed',
|
|
522
|
+
gap: [null, null],
|
|
523
|
+
})
|
|
524
|
+
firstSession = false
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
_fanOutSentinel(sentinel) {
|
|
530
|
+
for (const w of this._watches) w._pushSentinel(sentinel)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async _dispatch(envelope, _firstSession) {
|
|
534
|
+
const type = envelope?.type
|
|
535
|
+
if (typeof type !== 'string' || !type.includes('/')) return
|
|
536
|
+
const event = await this._shape(envelope)
|
|
537
|
+
if (!event) return
|
|
538
|
+
for (const w of this._watches) {
|
|
539
|
+
if (w._matches(envelope)) w._pushEvent(event)
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Translate a raw channel envelope into the user-facing event shape.
|
|
545
|
+
* Resolves attribute xids and (defensively) entity name via the schema
|
|
546
|
+
* resolver. Falls back to {unresolved: true} when the resolver is cold.
|
|
547
|
+
*/
|
|
548
|
+
async _shape(env) {
|
|
549
|
+
const [track, op] = env.type.split('/')
|
|
550
|
+
// Coalesced cache-invalidation pokes — entity/touched and
|
|
551
|
+
// relation/touched. Server has stripped everything except the
|
|
552
|
+
// client-supplied original entity (or relation) name + ts; we
|
|
553
|
+
// pass through verbatim so the consumer's literal string round-trips.
|
|
554
|
+
if (env.type === 'entity/touched') {
|
|
555
|
+
return { type: env.type, entity: env.entity, ts: env.ts }
|
|
556
|
+
}
|
|
557
|
+
if (env.type === 'relation/touched') {
|
|
558
|
+
return { type: env.type, relation: env.relation, ts: env.ts }
|
|
559
|
+
}
|
|
560
|
+
// Record-shaped envelopes on the data track (record/insert | update |
|
|
561
|
+
// delete and relation/link | unlink). The server drops modeling-internal
|
|
562
|
+
// decoration (element, entity name, entity-xid, relation label,
|
|
563
|
+
// from-eid/to-eid). before/after keys are already
|
|
564
|
+
// attribute-key keyed; data tuple `[subscribed, other]` carries
|
|
565
|
+
// relation endpoints from the observer's perspective.
|
|
566
|
+
const base = {
|
|
567
|
+
type: env.type,
|
|
568
|
+
ts: env.ts,
|
|
569
|
+
txid: env.txid,
|
|
570
|
+
actor: env.actor,
|
|
571
|
+
request: env.request,
|
|
572
|
+
tenant: env.tenant,
|
|
573
|
+
scope: env.scope,
|
|
574
|
+
}
|
|
575
|
+
if (track === 'record') {
|
|
576
|
+
const before = env.before
|
|
577
|
+
const after = env.after
|
|
578
|
+
const changed = computeChanged(before, after, op)
|
|
579
|
+
const out = { ...base, record: env['record-xid'] }
|
|
580
|
+
if (before) out.before = before
|
|
581
|
+
if (after) out.after = after
|
|
582
|
+
if (changed) out.changed = changed
|
|
583
|
+
return out
|
|
584
|
+
}
|
|
585
|
+
if (track === 'relation') {
|
|
586
|
+
return { ...base, data: env.data }
|
|
587
|
+
}
|
|
588
|
+
return null
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
_resolveAttrMap(m) {
|
|
592
|
+
if (!this._resolver) return { ...m }
|
|
593
|
+
const out = {}
|
|
594
|
+
for (const [k, v] of Object.entries(m)) {
|
|
595
|
+
out[this._resolver.attrName(k)] = v
|
|
596
|
+
}
|
|
597
|
+
return out
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function computeChanged(before, after, op) {
|
|
602
|
+
if (op === 'insert') return after ? Object.keys(after) : null
|
|
603
|
+
if (op === 'delete') return before ? Object.keys(before) : null
|
|
604
|
+
if (!before || !after) return null
|
|
605
|
+
const out = []
|
|
606
|
+
for (const k of Object.keys(after)) {
|
|
607
|
+
if (!shallowEq(before[k], after[k])) out.push(k)
|
|
608
|
+
}
|
|
609
|
+
return out
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function shallowEq(a, b) {
|
|
613
|
+
if (a === b) return true
|
|
614
|
+
if (a == null || b == null) return false
|
|
615
|
+
if (typeof a !== typeof b) return false
|
|
616
|
+
// Strings + numbers + booleans handled by ===; anything else: JSON compare,
|
|
617
|
+
// good enough for change detection on attribute scalars.
|
|
618
|
+
try { return JSON.stringify(a) === JSON.stringify(b) } catch { return false }
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ============================================================================
|
|
622
|
+
// Watch — public surface
|
|
623
|
+
// ============================================================================
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Live subscription handle. Returned by `client.watch()`.
|
|
627
|
+
*
|
|
628
|
+
* Iterate `watch.events` as many times as you like — each `for await`
|
|
629
|
+
* gets a fresh iterator with its own coalescing buffer, so two
|
|
630
|
+
* consumers (e.g. sibling React components) see the full stream
|
|
631
|
+
* independently. `close()` drops the watch from the multiplex union
|
|
632
|
+
* and closes every iterator.
|
|
633
|
+
*/
|
|
634
|
+
export class Watch {
|
|
635
|
+
constructor(mux, interest, { backpressure = 'coalesce', bufferSize = 100, signal } = {}) {
|
|
636
|
+
this._mux = mux
|
|
637
|
+
this._interest = normalizeInterest(interest)
|
|
638
|
+
this._iterators = new Set()
|
|
639
|
+
this._closed = false
|
|
640
|
+
this._mutedRequests = new Map() // request-id → timeoutId
|
|
641
|
+
this._opts = { backpressure, bufferSize }
|
|
642
|
+
this._signal = signal
|
|
643
|
+
if (signal) {
|
|
644
|
+
if (signal.aborted) { this._closed = true; return }
|
|
645
|
+
signal.addEventListener('abort', () => this.close(), { once: true })
|
|
646
|
+
}
|
|
647
|
+
mux.register(this)
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
get interest() { return this._interest }
|
|
651
|
+
get closed() { return this._closed }
|
|
652
|
+
|
|
653
|
+
add(xids) {
|
|
654
|
+
if (this._closed) return
|
|
655
|
+
if (!xids || !xids.length) return
|
|
656
|
+
const next = new Set(this._interest.records ?? [])
|
|
657
|
+
for (const x of xids) next.add(x)
|
|
658
|
+
this._interest = { ...this._interest, records: [...next] }
|
|
659
|
+
this._mux.notifyInterestChanged()
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
remove(xids) {
|
|
663
|
+
if (this._closed) return
|
|
664
|
+
if (!this._interest.records) return
|
|
665
|
+
const set = new Set(this._interest.records)
|
|
666
|
+
for (const x of xids) set.delete(x)
|
|
667
|
+
this._interest = { ...this._interest, records: [...set] }
|
|
668
|
+
this._mux.notifyInterestChanged()
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
setInterest(interest) {
|
|
672
|
+
if (this._closed) return
|
|
673
|
+
this._interest = normalizeInterest(interest)
|
|
674
|
+
this._mux.notifyInterestChanged()
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Tell the watch to drop the next event carrying this request-id.
|
|
679
|
+
* Used to suppress events caused by writes this client already
|
|
680
|
+
* applied optimistically. Auto-expires after `ttlMs` (default 30s).
|
|
681
|
+
*/
|
|
682
|
+
muteRequest(requestId, { ttlMs = 30000 } = {}) {
|
|
683
|
+
if (!requestId) return
|
|
684
|
+
const prior = this._mutedRequests.get(requestId)
|
|
685
|
+
if (prior) clearTimeout(prior)
|
|
686
|
+
const t = setTimeout(() => this._mutedRequests.delete(requestId), ttlMs)
|
|
687
|
+
this._mutedRequests.set(requestId, t)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
close() {
|
|
691
|
+
if (this._closed) return
|
|
692
|
+
this._closed = true
|
|
693
|
+
for (const it of this._iterators) it.buf.close()
|
|
694
|
+
this._iterators.clear()
|
|
695
|
+
for (const t of this._mutedRequests.values()) clearTimeout(t)
|
|
696
|
+
this._mutedRequests.clear()
|
|
697
|
+
this._mux.unregister(this)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
get events() {
|
|
701
|
+
const w = this
|
|
702
|
+
return {
|
|
703
|
+
[Symbol.asyncIterator]() {
|
|
704
|
+
if (w._closed) return { next: async () => ({ done: true }) }
|
|
705
|
+
const buf = new CoalesceBuffer({
|
|
706
|
+
mode: w._opts.backpressure,
|
|
707
|
+
size: w._opts.bufferSize,
|
|
708
|
+
})
|
|
709
|
+
const handle = { buf }
|
|
710
|
+
w._iterators.add(handle)
|
|
711
|
+
return {
|
|
712
|
+
async next() { return buf.next() },
|
|
713
|
+
async return() {
|
|
714
|
+
buf.close()
|
|
715
|
+
w._iterators.delete(handle)
|
|
716
|
+
return { done: true }
|
|
717
|
+
},
|
|
718
|
+
}
|
|
719
|
+
},
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// ── Internal — called by multiplexer ──────────────────────────────────────
|
|
724
|
+
|
|
725
|
+
_matches(envelope) {
|
|
726
|
+
const i = this._interest
|
|
727
|
+
// Data-track record events (record/insert | record/update | record/delete):
|
|
728
|
+
if (envelope.type === 'record/insert'
|
|
729
|
+
|| envelope.type === 'record/update'
|
|
730
|
+
|| envelope.type === 'record/delete') {
|
|
731
|
+
const xid = envelope['record-xid']
|
|
732
|
+
if (i.records?.length && i.records.includes(xid)) return _opMatch(i.ops, envelope.type)
|
|
733
|
+
return false
|
|
734
|
+
}
|
|
735
|
+
// Data-track relation events (relation/link | relation/unlink):
|
|
736
|
+
// Server has rotated so envelope.data = [subscribed-xid, other-xid].
|
|
737
|
+
// Per-watcher we only emit if data[0] is in this watcher's records
|
|
738
|
+
// (when both endpoints are in the identity's union the server sends
|
|
739
|
+
// two events — one per perspective — so each watcher gets the
|
|
740
|
+
// correctly-rotated copy).
|
|
741
|
+
if (envelope.type === 'relation/link' || envelope.type === 'relation/unlink') {
|
|
742
|
+
if (i.records?.length && Array.isArray(envelope.data) && envelope.data.length >= 2) {
|
|
743
|
+
if (i.records.includes(envelope.data[0])) {
|
|
744
|
+
return _opMatch(i.ops, envelope.type)
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return false
|
|
748
|
+
}
|
|
749
|
+
// Entity track — coalesced cache-invalidation poke. The server echoes
|
|
750
|
+
// back the client's original string verbatim, so a literal string
|
|
751
|
+
// match is sufficient — no kebab() normalization needed.
|
|
752
|
+
if (envelope.type === 'entity/touched') {
|
|
753
|
+
if (i.entities?.length) {
|
|
754
|
+
return i.entities.includes(envelope.entity)
|
|
755
|
+
}
|
|
756
|
+
return false
|
|
757
|
+
}
|
|
758
|
+
// Relation track — same echo-back contract as entity/touched. The
|
|
759
|
+
// server preserves the client's original `entity<sep>label` form;
|
|
760
|
+
// literal-string match is sufficient.
|
|
761
|
+
if (envelope.type === 'relation/touched') {
|
|
762
|
+
if (i.relations?.length) {
|
|
763
|
+
return i.relations.includes(envelope.relation)
|
|
764
|
+
}
|
|
765
|
+
return false
|
|
766
|
+
}
|
|
767
|
+
return false
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
_pushEvent(event) {
|
|
771
|
+
if (this._closed) return
|
|
772
|
+
if (event.request && this._mutedRequests.has(event.request)) {
|
|
773
|
+
// Consumed: drop the muted record entry so a second event with
|
|
774
|
+
// the same request-id (rare) is not silently dropped.
|
|
775
|
+
const t = this._mutedRequests.get(event.request)
|
|
776
|
+
if (t) clearTimeout(t)
|
|
777
|
+
this._mutedRequests.delete(event.request)
|
|
778
|
+
return
|
|
779
|
+
}
|
|
780
|
+
for (const it of this._iterators) it.buf.push(event)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
_pushSentinel(sentinel) {
|
|
784
|
+
if (this._closed) return
|
|
785
|
+
for (const it of this._iterators) it.buf.push(sentinel)
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function _opMatch(ops, type) {
|
|
790
|
+
if (!ops || !ops.length) return true
|
|
791
|
+
const op = type.split('/')[1]
|
|
792
|
+
return ops.includes(op)
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function normalizeInterest(interest) {
|
|
796
|
+
const out = {}
|
|
797
|
+
if (interest?.records) {
|
|
798
|
+
if (!Array.isArray(interest.records)) {
|
|
799
|
+
throw new WatchError('interest.records must be an array', 'INVALID_INTEREST')
|
|
800
|
+
}
|
|
801
|
+
out.records = [...new Set(interest.records)]
|
|
802
|
+
}
|
|
803
|
+
// relations: relation NAMES ("Movie.actors") for the relation track.
|
|
804
|
+
// Goes on the wire as {type:"relation", relations:[...]} and matches
|
|
805
|
+
// server-emitted relation/touched events.
|
|
806
|
+
if (interest?.relations) {
|
|
807
|
+
if (!Array.isArray(interest.relations)) {
|
|
808
|
+
throw new WatchError('interest.relations must be an array', 'INVALID_INTEREST')
|
|
809
|
+
}
|
|
810
|
+
out.relations = [...new Set(interest.relations)]
|
|
811
|
+
}
|
|
812
|
+
// relationXids: relation XIDS used LOCALLY by the data-track matcher
|
|
813
|
+
// for relation/link / relation/unlink envelopes. NOT on the wire —
|
|
814
|
+
// those events fan out per record xid via i.records server-side, and
|
|
815
|
+
// i.relationXids gates the local match.
|
|
816
|
+
if (interest?.relationXids) {
|
|
817
|
+
if (!Array.isArray(interest.relationXids)) {
|
|
818
|
+
throw new WatchError('interest.relationXids must be an array', 'INVALID_INTEREST')
|
|
819
|
+
}
|
|
820
|
+
out.relationXids = [...new Set(interest.relationXids)]
|
|
821
|
+
}
|
|
822
|
+
if (interest?.ops) {
|
|
823
|
+
if (!Array.isArray(interest.ops)) {
|
|
824
|
+
throw new WatchError('interest.ops must be an array', 'INVALID_INTEREST')
|
|
825
|
+
}
|
|
826
|
+
out.ops = [...new Set(interest.ops)]
|
|
827
|
+
}
|
|
828
|
+
if (interest?.entities) {
|
|
829
|
+
if (!Array.isArray(interest.entities)) {
|
|
830
|
+
throw new WatchError('interest.entities must be an array', 'INVALID_INTEREST')
|
|
831
|
+
}
|
|
832
|
+
out.entities = [...new Set(interest.entities)]
|
|
833
|
+
}
|
|
834
|
+
if (!out.records?.length
|
|
835
|
+
&& !out.relations?.length
|
|
836
|
+
&& !out.relationXids?.length
|
|
837
|
+
&& !out.entities?.length) {
|
|
838
|
+
throw new WatchError(
|
|
839
|
+
'watch() requires at least one of: interest.records, interest.entities, interest.relations, interest.relationXids',
|
|
840
|
+
'EMPTY_INTEREST'
|
|
841
|
+
)
|
|
842
|
+
}
|
|
843
|
+
return out
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// ============================================================================
|
|
847
|
+
// SchemaWatch — separate stream of deploy events
|
|
848
|
+
// ============================================================================
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Explicit schema-deploy stream. Yields `{ type: 'schema/changed', schema }`
|
|
852
|
+
* each time a deploy lands. Most apps don't need this — events from
|
|
853
|
+
* `watch()` are already resolved against the live schema.
|
|
854
|
+
*/
|
|
855
|
+
export class SchemaWatch {
|
|
856
|
+
constructor(mux) {
|
|
857
|
+
this._mux = mux
|
|
858
|
+
this._closed = false
|
|
859
|
+
this._buf = new CoalesceBuffer({ mode: 'lossless', size: 32 })
|
|
860
|
+
this._unsub = (sentinel) => {
|
|
861
|
+
if (sentinel.type === 'schema/changed') this._buf.push(sentinel)
|
|
862
|
+
}
|
|
863
|
+
mux._fanOutSinks = mux._fanOutSinks ?? new Set()
|
|
864
|
+
mux._fanOutSinks.add(this._unsub)
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
close() {
|
|
868
|
+
if (this._closed) return
|
|
869
|
+
this._closed = true
|
|
870
|
+
this._buf.close()
|
|
871
|
+
const sinks = this._mux._fanOutSinks
|
|
872
|
+
if (sinks) sinks.delete(this._unsub)
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
get events() {
|
|
876
|
+
const self = this
|
|
877
|
+
return {
|
|
878
|
+
[Symbol.asyncIterator]() {
|
|
879
|
+
return { async next() { return self._buf.next() } }
|
|
880
|
+
},
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// ============================================================================
|
|
886
|
+
// Public factory — wired onto the client in index.js
|
|
887
|
+
// ============================================================================
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Get (or lazily create) the shared multiplexer for a client.
|
|
891
|
+
* @private
|
|
892
|
+
*/
|
|
893
|
+
export function getMultiplexer(client) {
|
|
894
|
+
if (!client.__watchMux) {
|
|
895
|
+
client.__watchMux = new WatchMultiplexer(client, {
|
|
896
|
+
schema: client._schemaEnabled !== false,
|
|
897
|
+
// Honor the client-level keepAlive flag set in createClient. Eager
|
|
898
|
+
// SSE open + no teardown on empty-watch windows.
|
|
899
|
+
keepAlive: client._keepAlive === true,
|
|
900
|
+
})
|
|
901
|
+
}
|
|
902
|
+
return client.__watchMux
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** Create a new Watch on this client. */
|
|
906
|
+
export function watch(client, interest, opts) {
|
|
907
|
+
const mux = getMultiplexer(client)
|
|
908
|
+
return new Watch(mux, interest, opts)
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** Create a new SchemaWatch on this client. */
|
|
912
|
+
export function watchSchema(client) {
|
|
913
|
+
const mux = getMultiplexer(client)
|
|
914
|
+
return new SchemaWatch(mux)
|
|
915
|
+
}
|