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