@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.
@@ -0,0 +1,533 @@
1
+ /**
2
+ * Live query + aggregate primitives — pure compositions on `client.watch()`.
3
+ *
4
+ * Architecture:
5
+ *
6
+ * 1. Run the query / aggregate once to snapshot.
7
+ * 2. Derive interest:
8
+ * - record-xids from the snapshot — top-level row xids AND xids of
9
+ * any nested records reached via the selection (so a record/update
10
+ * on a nested row, e.g. `movie.movie_ratings[i].value`, triggers
11
+ * a refresh of the parent query).
12
+ * - relation-xids that could expand the result set (all relations
13
+ * on the queried entity).
14
+ * 3. Open a single `client.watch({records, relations})` covering both.
15
+ * This fuses onto the client's shared SSE + setSubscriptions POST.
16
+ * 4. On any event in the watch set, coalesced re-run of the query
17
+ * (or aggregate). Diff against last result, emit added/removed/
18
+ * changed (or 'aggregate/changed' with new value).
19
+ * 5. After each refresh, sync the watch's record set so newly-added
20
+ * members are watched and dropped members stop being watched.
21
+ *
22
+ * Zero new wire ops. Zero server-side state. SDK is in full control.
23
+ *
24
+ * Trade-off — re-runs the query on each event window: simplest and
25
+ * always correct. (A local predicate check against the event's `after`
26
+ * map could skip the HTTP for entity-scalar predicates; not currently
27
+ * done.)
28
+ *
29
+ * Cost coalescing — events arriving within REFRESH_COALESCE_MS collapse
30
+ * to one refresh. A burst of 100 events still produces at most a few
31
+ * refreshes (each takes one /data round-trip).
32
+ */
33
+
34
+ import { getMultiplexer, WatchError } from './watch.js'
35
+
36
+ const REFRESH_COALESCE_MS = 50
37
+
38
+ // ============================================================================
39
+ // EventBuffer — unbounded async queue feeding the public iterator
40
+ // ============================================================================
41
+
42
+ class EventBuffer {
43
+ constructor() {
44
+ this._queue = []
45
+ this._waiters = []
46
+ this._closed = false
47
+ }
48
+ push(ev) {
49
+ if (this._closed) return
50
+ if (this._waiters.length) {
51
+ this._waiters.shift()({ value: ev, done: false })
52
+ } else {
53
+ this._queue.push(ev)
54
+ }
55
+ }
56
+ close() {
57
+ this._closed = true
58
+ while (this._waiters.length) this._waiters.shift()({ done: true })
59
+ }
60
+ iterator() {
61
+ const buf = this
62
+ return {
63
+ next() {
64
+ if (buf._queue.length) {
65
+ return Promise.resolve({ value: buf._queue.shift(), done: false })
66
+ }
67
+ if (buf._closed) return Promise.resolve({ done: true })
68
+ return new Promise((resolve) => buf._waiters.push(resolve))
69
+ },
70
+ return() {
71
+ return Promise.resolve({ done: true })
72
+ },
73
+ }
74
+ }
75
+ }
76
+
77
+ // ============================================================================
78
+ // Row utilities — diff + equality (JSON-based)
79
+ // ============================================================================
80
+
81
+ function rowKey(r) { return r?.xid ?? r?.euuid ?? null }
82
+
83
+ // Walk the result tree and collect every xid found — top-level + nested.
84
+ // Used to widen the watch's records interest so record/update events on
85
+ // nested records (e.g. a rating's value) reach the parent QueryWatch and
86
+ // trigger a refresh. Plug gates by record set on its end, so a
87
+ // wider local interest doesn't widen the wire union beyond what we ask.
88
+ function collectAllXids(rows) {
89
+ const out = new Set()
90
+ const visit = (v) => {
91
+ if (v == null) return
92
+ if (Array.isArray(v)) { for (const e of v) visit(e); return }
93
+ if (typeof v !== 'object') return
94
+ if (typeof v.xid === 'string') out.add(v.xid)
95
+ for (const k of Object.keys(v)) {
96
+ if (k === 'xid') continue
97
+ visit(v[k])
98
+ }
99
+ }
100
+ for (const r of rows) visit(r)
101
+ return out
102
+ }
103
+
104
+ function shallowEq(a, b) {
105
+ if (a === b) return true
106
+ if (a == null || b == null) return false
107
+ try { return JSON.stringify(a) === JSON.stringify(b) } catch { return false }
108
+ }
109
+
110
+ function rowsEqual(a, b) {
111
+ if (a === b) return true
112
+ if (!a || !b) return false
113
+ const ka = Object.keys(a)
114
+ const kb = Object.keys(b)
115
+ if (ka.length !== kb.length) return false
116
+ for (const k of ka) if (!shallowEq(a[k], b[k])) return false
117
+ return true
118
+ }
119
+
120
+ function diffRow(before, after) {
121
+ const changed = []
122
+ const beforeMap = {}
123
+ const afterMap = {}
124
+ const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])
125
+ for (const k of keys) {
126
+ if (!shallowEq(before?.[k], after?.[k])) {
127
+ changed.push(k)
128
+ beforeMap[k] = before?.[k]
129
+ afterMap[k] = after?.[k]
130
+ }
131
+ }
132
+ return { changed, beforeMap, afterMap }
133
+ }
134
+
135
+ // ============================================================================
136
+ // QueryWatch — live result-set
137
+ // ============================================================================
138
+
139
+ export class QueryWatch {
140
+ constructor(client, kind, queryArgs, opts = {}) {
141
+ this._client = client
142
+ this._kind = kind // 'search' | 'xsql'
143
+ this._query = queryArgs // { entity, args, selection } or { xsql, params, opts }
144
+ this._opts = opts
145
+ this._records = new Map() // xid → record
146
+ this._initial = null
147
+ this._watch = null
148
+ this._buf = new EventBuffer()
149
+ this._closed = false
150
+ this._coalesceTimer = null
151
+ this._refreshInflight = false
152
+ this._refreshAgain = false
153
+ // Capture bootstrap failure without re-throwing — un-awaited _readyPromise
154
+ // used to surface as an unhandled rejection, forcing BFFs to install a
155
+ // process-wide AbortError swallow. ready() throws on demand instead.
156
+ this._readyError = null
157
+ this._readyPromise = this._bootstrap().catch((e) => {
158
+ this._readyError = e
159
+ this._buf.push({ type: 'subscription/rejected',
160
+ reason: e?.message ?? 'bootstrap failed', records: [] })
161
+ })
162
+ // Honor opts.signal so callers can cancel the watch via AbortController.
163
+ if (opts.signal) {
164
+ if (opts.signal.aborted) { this.close(); return }
165
+ opts.signal.addEventListener('abort', () => this.close(), { once: true })
166
+ }
167
+ }
168
+
169
+ async _bootstrap() {
170
+ // 1. Warm the schema resolver — we need entity + relation xids.
171
+ const mux = getMultiplexer(this._client)
172
+ if (mux.resolver) await mux.resolver.ensureLoaded()
173
+
174
+ // 2. Compute relation interest from the entity name.
175
+ const entityXid = mux.resolver?.entityXidByName(this._query.entity) ?? null
176
+ const relationXids = entityXid
177
+ ? mux.resolver.relationsForEntity(entityXid)
178
+ : []
179
+
180
+ // 3. Snapshot.
181
+ const rows = await this._runQuery()
182
+ for (const r of rows) {
183
+ const xid = rowKey(r)
184
+ if (xid) this._records.set(xid, r)
185
+ }
186
+ this._initial = rows
187
+
188
+ // 4. Open the underlying watch. Relations first so we don't miss
189
+ // link events for new members between snapshot and watch.add.
190
+ // (The set-replace contract makes this two POSTs; we accept the
191
+ // minor cost in exchange for the simpler ordering.)
192
+ // Interest covers top-level + nested xids so updates on either
193
+ // layer fire a refresh.
194
+ // relationXids gates local matching for relation/link / relation/unlink
195
+ // envelopes; it does NOT add anything to the wire (the data track
196
+ // narrows server-side by record xids and OR-matches relation endpoints).
197
+ const interest = { records: [...collectAllXids(rows)] }
198
+ if (relationXids.length) interest.relationXids = relationXids
199
+ this._watch = this._client.watch(interest)
200
+
201
+ // 5. Drain underlying watch events → trigger refresh.
202
+ this._drain()
203
+ }
204
+
205
+ async _runQuery() {
206
+ if (this._kind === 'search') {
207
+ const { entity, args, selection } = this._query
208
+ return this._client.search(entity, args, selection, this._opts.searchOpts)
209
+ }
210
+ if (this._kind === 'xsql') {
211
+ const { xsql, params, queryOpts } = this._query
212
+ const r = await this._client.query(xsql, params, queryOpts)
213
+ // get ops return a single record or null; search ops return an array
214
+ return Array.isArray(r) ? r : r ? [r] : []
215
+ }
216
+ return []
217
+ }
218
+
219
+ async _drain() {
220
+ try {
221
+ for await (const ev of this._watch.events) {
222
+ if (this._closed) break
223
+ if (typeof ev.type === 'string'
224
+ && (ev.type.startsWith('entity/') || ev.type.startsWith('relation/'))) {
225
+ this._scheduleRefresh(ev)
226
+ } else {
227
+ // sentinels pass through to the QueryWatch consumer
228
+ this._buf.push(ev)
229
+ }
230
+ }
231
+ } catch (e) {
232
+ this._buf.push({ type: 'subscription/rejected',
233
+ reason: e?.message ?? 'underlying watch failed', records: [] })
234
+ }
235
+ }
236
+
237
+ _scheduleRefresh(triggeringEvent) {
238
+ if (this._coalesceTimer) return
239
+ this._coalesceTimer = setTimeout(() => {
240
+ this._coalesceTimer = null
241
+ this._refresh(triggeringEvent).catch((e) => {
242
+ this._buf.push({ type: 'subscription/rejected',
243
+ reason: e?.message ?? 'refresh failed', records: [] })
244
+ })
245
+ }, REFRESH_COALESCE_MS)
246
+ }
247
+
248
+ async _refresh(triggeringEvent) {
249
+ if (this._refreshInflight) { this._refreshAgain = true; return }
250
+ this._refreshInflight = true
251
+ try {
252
+ const rows = await this._runQuery()
253
+ const prev = this._records
254
+ const next = new Map()
255
+ for (const r of rows) {
256
+ const xid = rowKey(r)
257
+ if (xid) next.set(xid, r)
258
+ }
259
+
260
+ const prov = {
261
+ ts: triggeringEvent?.ts,
262
+ txid: triggeringEvent?.txid,
263
+ actor: triggeringEvent?.actor,
264
+ request: triggeringEvent?.request,
265
+ }
266
+
267
+ // Added + changed.
268
+ for (const [xid, r] of next) {
269
+ const before = prev.get(xid)
270
+ if (!before) {
271
+ this._buf.push({ type: 'query/added', record: r, ...prov })
272
+ } else if (!rowsEqual(before, r)) {
273
+ const { changed, beforeMap, afterMap } = diffRow(before, r)
274
+ this._buf.push({
275
+ type: 'query/changed', record: r,
276
+ before: beforeMap, after: afterMap, changed,
277
+ ...prov,
278
+ })
279
+ }
280
+ }
281
+ // Removed.
282
+ for (const xid of prev.keys()) {
283
+ if (!next.has(xid)) {
284
+ this._buf.push({ type: 'query/removed', recordXid: xid, ...prov })
285
+ }
286
+ }
287
+
288
+ // Update record set + sync the underlying watch interest so new
289
+ // members get entity events and dropped ones stop firing. Sync the
290
+ // full xid tree (top-level + nested) so updates on freshly-arrived
291
+ // nested rows are picked up on the next event window.
292
+ this._records = next
293
+ if (this._watch) {
294
+ this._watch.setInterest({
295
+ records: [...collectAllXids(rows)],
296
+ relationXids: this._watch.interest.relationXids,
297
+ })
298
+ }
299
+ } finally {
300
+ this._refreshInflight = false
301
+ if (this._refreshAgain) {
302
+ this._refreshAgain = false
303
+ this._scheduleRefresh()
304
+ }
305
+ }
306
+ }
307
+
308
+ /** First page of rows, ordered as the server returned. Null until ready. */
309
+ get initial() { return this._initial }
310
+
311
+ /** Live record map; mutated in place by SDK as events arrive. */
312
+ get records() { return this._records }
313
+
314
+ /** Snapshot the current records as an array. */
315
+ list() { return [...this._records.values()] }
316
+
317
+ /** Resolves when the initial snapshot + watch are wired. Throws if bootstrap failed. */
318
+ ready() {
319
+ return this._readyPromise.then(() => {
320
+ if (this._readyError) throw this._readyError
321
+ })
322
+ }
323
+
324
+ /** Force a fresh server-side re-evaluation. */
325
+ async refresh() { return this._refresh() }
326
+
327
+ get events() {
328
+ const self = this
329
+ return {
330
+ [Symbol.asyncIterator]() { return self._buf.iterator() },
331
+ }
332
+ }
333
+
334
+ close() {
335
+ if (this._closed) return
336
+ this._closed = true
337
+ if (this._coalesceTimer) clearTimeout(this._coalesceTimer)
338
+ if (this._watch) this._watch.close()
339
+ this._buf.close()
340
+ }
341
+ }
342
+
343
+ // ============================================================================
344
+ // Public factories — wired onto the client in index.js
345
+ // ============================================================================
346
+
347
+ /** `client.watchQuery(entity, args, selection, opts?)` */
348
+ export function watchQuery(client, entity, args, selection, opts) {
349
+ return new QueryWatch(client, 'search',
350
+ { entity, args, selection }, opts)
351
+ }
352
+
353
+ /** `client.watchQuery.xsql(xsql, params?, opts?)` — XSQL variant. */
354
+ watchQuery.xsql = function watchQueryXsql(client, xsql, params, opts) {
355
+ // The xsql path runs `client.query(xsql, params, queryOpts)`. To know
356
+ // which relations to watch we still need the entity name — pass it via
357
+ // `opts.entity`. The server does not infer it from the XSQL.
358
+ const entity = opts?.entity
359
+ if (!entity) {
360
+ throw new Error('watchQuery.xsql requires opts.entity (kebab-case root)')
361
+ }
362
+ return new QueryWatch(client, 'xsql',
363
+ { xsql, params, queryOpts: { ...opts, entity }, entity }, opts)
364
+ }
365
+
366
+ // ============================================================================
367
+ // SqlTemplateWatch — live raw-SQL result
368
+ //
369
+ // Compose `client.sqlTemplate(template, params)` with `client.watch()` so a
370
+ // dashboard tile (counts, averages, anything sql-expressible) updates as
371
+ // the underlying data changes.
372
+ //
373
+ // The plug can only subscribe by record-xid or relation-xid — there's
374
+ // no entity-firehose. Since raw SQL is opaque to us, the caller must
375
+ // declare which entities the SQL reads from via `opts.entities: [...]`.
376
+ // We then watch each entity's relation xids (relation/link + /unlink
377
+ // cover inserts/deletes). For per-row updates on existing records to
378
+ // trigger refresh, pass `opts.records: [...]` explicitly.
379
+ // ============================================================================
380
+
381
+ export class SqlTemplateWatch {
382
+ constructor(client, template, params, opts = {}) {
383
+ this._client = client
384
+ this._template = template
385
+ this._params = params ?? {}
386
+ this._opts = opts
387
+ this._entities = Array.isArray(opts.entities) ? opts.entities : []
388
+ this._relations = Array.isArray(opts.relations) ? opts.relations : []
389
+ if (!this._entities.length && !this._relations.length) {
390
+ throw new WatchError(
391
+ "watchSqlTemplate requires opts.entities and/or opts.relations — " +
392
+ "list the entity names and/or relation names ('Entity.label') " +
393
+ "the SQL reads from (e.g. { entities: ['Movie'], relations: ['Movie.actors'] }).",
394
+ 'INVALID_INTEREST',
395
+ )
396
+ }
397
+ this._value = null
398
+ this._buf = new EventBuffer()
399
+ this._watch = null
400
+ this._closed = false
401
+ this._coalesceTimer = null
402
+ this._refreshInflight = false
403
+ this._refreshAgain = false
404
+ this._readyError = null
405
+ this._readyPromise = this._bootstrap().catch((e) => {
406
+ this._readyError = e
407
+ this._buf.push({ type: 'subscription/rejected',
408
+ reason: e?.message ?? 'bootstrap failed', records: [] })
409
+ })
410
+ if (opts.signal) {
411
+ if (opts.signal.aborted) { this.close(); return }
412
+ opts.signal.addEventListener('abort', () => this.close(), { once: true })
413
+ }
414
+ }
415
+
416
+ async _bootstrap() {
417
+ // 1. Snapshot the SQL result.
418
+ this._value = await this._client.sqlTemplate(
419
+ this._template, this._params, this._opts.searchOpts,
420
+ )
421
+
422
+ // 2. Subscribe via the entity + relation tracks — server matches by
423
+ // entity-xid / relation-xid and delivers coalesced
424
+ // `entity/touched` / `relation/touched` pokes (one per name per
425
+ // 100ms window). For per-row update refresh on specific rows
426
+ // the caller passes `opts.records: [...]`, which adds a data-
427
+ // track sub running in addition.
428
+ const interest = {}
429
+ if (this._entities.length) interest.entities = [...this._entities]
430
+ if (this._relations.length) interest.relations = [...this._relations]
431
+ if (Array.isArray(this._opts.records) && this._opts.records.length) {
432
+ interest.records = [...this._opts.records]
433
+ }
434
+ this._watch = this._client.watch(interest)
435
+ this._drain()
436
+ }
437
+
438
+ async _drain() {
439
+ try {
440
+ for await (const ev of this._watch.events) {
441
+ if (this._closed) break
442
+ // Cache-invalidation pokes from the entity / relation tracks
443
+ // (entities / relations interest) — server has coalesced these
444
+ // per name per 100ms window. Plus record-shaped data-track
445
+ // envelopes (record/insert etc., relation/link etc.) when the
446
+ // caller passed opts.records explicitly.
447
+ if (typeof ev.type === 'string'
448
+ && (ev.type === 'entity/touched'
449
+ || ev.type === 'relation/touched'
450
+ || ev.type.startsWith('record/')
451
+ || ev.type.startsWith('relation/'))) {
452
+ this._scheduleRefresh(ev)
453
+ } else {
454
+ this._buf.push(ev)
455
+ }
456
+ }
457
+ } catch (e) {
458
+ this._buf.push({ type: 'subscription/rejected',
459
+ reason: e?.message ?? 'underlying watch failed', records: [] })
460
+ }
461
+ }
462
+
463
+ _scheduleRefresh(triggeringEvent) {
464
+ if (this._coalesceTimer) return
465
+ this._coalesceTimer = setTimeout(() => {
466
+ this._coalesceTimer = null
467
+ this._refresh(triggeringEvent).catch((e) => {
468
+ this._buf.push({ type: 'subscription/rejected',
469
+ reason: e?.message ?? 'refresh failed', records: [] })
470
+ })
471
+ }, REFRESH_COALESCE_MS)
472
+ }
473
+
474
+ async _refresh(triggeringEvent) {
475
+ if (this._refreshInflight) { this._refreshAgain = true; return }
476
+ this._refreshInflight = true
477
+ try {
478
+ const before = this._value
479
+ const after = await this._client.sqlTemplate(
480
+ this._template, this._params, this._opts.searchOpts,
481
+ )
482
+ this._value = after
483
+ // Result is typically an array of rows; deep-compare via JSON.
484
+ if (JSON.stringify(before) !== JSON.stringify(after)) {
485
+ this._buf.push({
486
+ type: 'result/changed',
487
+ before, after,
488
+ ts: triggeringEvent?.ts,
489
+ txid: triggeringEvent?.txid,
490
+ actor: triggeringEvent?.actor,
491
+ request: triggeringEvent?.request,
492
+ })
493
+ }
494
+ } finally {
495
+ this._refreshInflight = false
496
+ if (this._refreshAgain) {
497
+ this._refreshAgain = false
498
+ this._scheduleRefresh()
499
+ }
500
+ }
501
+ }
502
+
503
+ /** Current SQL result (array of rows). */
504
+ get value() { return this._value }
505
+ /** Convenience: first row of the result, or null. */
506
+ get first() { return Array.isArray(this._value) ? this._value[0] ?? null : null }
507
+ ready() {
508
+ return this._readyPromise.then(() => {
509
+ if (this._readyError) throw this._readyError
510
+ })
511
+ }
512
+ async refresh() { return this._refresh() }
513
+
514
+ get events() {
515
+ const self = this
516
+ return {
517
+ [Symbol.asyncIterator]() { return self._buf.iterator() },
518
+ }
519
+ }
520
+
521
+ close() {
522
+ if (this._closed) return
523
+ this._closed = true
524
+ if (this._coalesceTimer) clearTimeout(this._coalesceTimer)
525
+ if (this._watch) this._watch.close()
526
+ this._buf.close()
527
+ }
528
+ }
529
+
530
+ /** `client.watchSqlTemplate(template, params?, opts?)` */
531
+ export function watchSqlTemplate(client, template, params, opts) {
532
+ return new SqlTemplateWatch(client, template, params, opts)
533
+ }