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