@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/index.js ADDED
@@ -0,0 +1,2535 @@
1
+ /**
2
+ * Synthigy Data API Client SDK
3
+ *
4
+ * Thin, zero-dependency client for Synthigy's /data endpoint.
5
+ * Handles service-identity token acquisition (client credentials) and
6
+ * user impersonation via `acting_as`.
7
+ *
8
+ * Intentionally out of scope — leave to the user's chosen libraries:
9
+ * - OAuth2 authorization-code redirect (framework concern)
10
+ * - ID-token JWT verification (pick `jose`, `jsonwebtoken`, etc.)
11
+ * - HTTP pooling / retry / backoff (wrap `fetch` as you see fit)
12
+ *
13
+ * @example
14
+ * ```js
15
+ * import { createClient } from '@synthigy/sdk'
16
+ *
17
+ * const client = createClient({
18
+ * endpoint: 'https://synthigy.example.com',
19
+ * clientId: 'my-service',
20
+ * clientSecret: process.env.SYNTHIGY_CLIENT_SECRET
21
+ * })
22
+ *
23
+ * // Entity names match your ERD model: 'User', 'user', 'user_role', 'UserRole'
24
+ * const users = await client.search('User', {
25
+ * active: eq(true),
26
+ * _limit: 10
27
+ * }, {
28
+ * name: null,
29
+ * email: null,
30
+ * roles: rel({ name: null })
31
+ * }, { actingAs: req.session.userEuuid })
32
+ * ```
33
+ */
34
+
35
+ import { watch as _watch, watchSchema as _watchSchema, getMultiplexer, Watch, SchemaWatch, WatchError } from './watch.js'
36
+ import { watchQuery as _watchQuery, watchSqlTemplate as _watchSqlTemplate, QueryWatch, SqlTemplateWatch } from './watch-query.js'
37
+
38
+ export { Watch, SchemaWatch, WatchError, QueryWatch, SqlTemplateWatch }
39
+
40
+ // ============================================================================
41
+ // Operators — helper functions for building where clauses
42
+ // ============================================================================
43
+
44
+ export const eq = (v) => ({ _eq: v })
45
+ export const neq = (v) => ({ _neq: v })
46
+ export const gt = (v) => ({ _gt: v })
47
+ export const gte = (v) => ({ _ge: v })
48
+ export const lt = (v) => ({ _lt: v })
49
+ export const lte = (v) => ({ _le: v })
50
+ export const in_ = (...v) => ({ _in: v.flat() })
51
+ export const nin = (...v) => ({ _nin: v.flat() })
52
+ export const like = (v) => ({ _like: v })
53
+ export const ilike = (v) => ({ _ilike: v })
54
+ export const isNull = () => ({ _is_null: true })
55
+ export const isNotNull = () => ({ _is_not_null: true })
56
+
57
+ // Boolean composers — combine where clauses.
58
+ // and({ active: eq(true) }, { age: gt(18) }) → { _and: [...] }
59
+ export const and = (...clauses) => ({ _and: clauses.flat() })
60
+ export const or = (...clauses) => ({ _or: clauses.flat() })
61
+ export const not = (clause) => ({ _not: clause })
62
+
63
+ // ============================================================================
64
+ // Selection helpers
65
+ // ============================================================================
66
+
67
+ /**
68
+ * Relation selection helper for complex cases (args, alias, multiple).
69
+ *
70
+ * For simple relations, just pass an object — the SDK wraps it automatically:
71
+ * { roles: { name: null } } → { roles: [{ selections: { name: null } }] }
72
+ *
73
+ * Use rel() when you need args, alias, or multiple occurrences:
74
+ * { roles: rel({ name: null }, { _limit: 5 }) }
75
+ * { roles: [
76
+ * rel({ name: null }, { _where: { active: { _eq: true } } }, "activeRoles"),
77
+ * rel({ name: null }, { _where: { active: { _eq: false } } }, "archived")
78
+ * ]}
79
+ *
80
+ * @param {Object} selections - Attribute selections for the relation
81
+ * @param {Object} [args] - Optional filter arguments for the relation
82
+ * @param {string} [alias] - Optional alias for the field
83
+ * @returns {Object} Selection config object; use as-is or in an array for multiple
84
+ */
85
+ export function rel(selections, args, alias) {
86
+ const config = { selections: normalizeSelection(selections) }
87
+ if (args) config.args = args
88
+ if (alias) config.alias = alias
89
+ return config
90
+ }
91
+
92
+ /**
93
+ * Normalize a selection for the wire format.
94
+ *
95
+ * Accepts multiple shorthand forms:
96
+ *
97
+ * Scalars:
98
+ * null, true → null (include field)
99
+ *
100
+ * Array of strings (flat selection):
101
+ * ['name', 'email'] → { name: null, email: null }
102
+ *
103
+ * Object shorthand (relation):
104
+ * { roles: { name: true } }
105
+ * → { roles: [{ selections: { name: null } }] }
106
+ *
107
+ * Explicit array (relation with args/alias/multiple):
108
+ * { roles: [{ selections: { name: null }, args: {...} }] }
109
+ * → passed through, nested selections normalized
110
+ *
111
+ * All forms can be mixed at any nesting level.
112
+ *
113
+ * Join semantics are the SERVER's: absent `_join` is LEFT (a selection is
114
+ * a projection and never drops parents; relation args filter the related
115
+ * rows). The client injects nothing — pass `args: { _join: 'inner' }`
116
+ * explicitly when the relation's existence should scope its parent.
117
+ */
118
+ /**
119
+ * Ensure an XSQL operation DOCUMENT (STRICT wire: XSQL travels only as
120
+ * {op: 'xsql', xsql: <document>}). Sources already starting with `@` pass
121
+ * through — their @verb is authoritative; bare rooted bodies get a
122
+ * synthetic `@<op> _q` header.
123
+ */
124
+ function xsqlDocument(source, op) {
125
+ return source.trimStart().startsWith('@') ? source : `@${op} _q\n${source}`
126
+ }
127
+
128
+ function normalizeSelection(selection) {
129
+ if (selection === null || selection === undefined) return selection
130
+ if (selection === true) return null
131
+
132
+ // Array of strings → expand to object
133
+ if (Array.isArray(selection)) {
134
+ if (selection.length > 0 && typeof selection[0] === 'string') {
135
+ const expanded = {}
136
+ for (const field of selection) expanded[field] = null
137
+ return expanded
138
+ }
139
+ // Array of rel() configs — normalize nested selections
140
+ return selection.map(config => ({
141
+ ...config,
142
+ ...(config.selections ? { selections: normalizeSelection(config.selections) } : {}),
143
+ }))
144
+ }
145
+
146
+ if (typeof selection !== 'object') return selection
147
+
148
+ const normalized = {}
149
+ for (const [key, value] of Object.entries(selection)) {
150
+ if (value === null || value === undefined || value === true) {
151
+ // Scalar field
152
+ normalized[key] = null
153
+ } else if (Array.isArray(value)) {
154
+ if (value.length > 0 && typeof value[0] === 'string') {
155
+ // Array of strings as flat selection shorthand
156
+ normalized[key] = [{ selections: normalizeSelection(value) }]
157
+ } else {
158
+ // Array of rel() configs — normalize nested selections
159
+ normalized[key] = value.map(config => ({
160
+ ...config,
161
+ ...(config.selections ? { selections: normalizeSelection(config.selections) } : {}),
162
+ }))
163
+ }
164
+ } else if (typeof value === 'object') {
165
+ if ('selections' in value) {
166
+ // rel() config object — wrap in array
167
+ normalized[key] = [{ ...value, selections: normalizeSelection(value.selections) }]
168
+ } else {
169
+ // Plain shorthand object → wrap as [{ selections: ... }]
170
+ normalized[key] = [{ selections: normalizeSelection(value) }]
171
+ }
172
+ } else {
173
+ normalized[key] = value
174
+ }
175
+ }
176
+ return normalized
177
+ }
178
+
179
+ // ============================================================================
180
+ // Tree Composition — nest flat tree/search-tree results
181
+ // ============================================================================
182
+
183
+ /**
184
+ * Read the parent-id off a record using the named FK relation.
185
+ *
186
+ * Accepts either shape:
187
+ * { father: { xid: "...", ... } } ← relation included in selection
188
+ * { father: "..." } ← or just the id string (rare)
189
+ * { father: null } or { father: undefined }
190
+ *
191
+ * Works on kebab, snake, or camel casings of the relation name.
192
+ */
193
+ function parentId(record, on) {
194
+ if (!record) return null
195
+ // Try a few key-case variants so callers don't have to care
196
+ const variants = [on, on.replace(/-/g, '_'), on.replace(/_/g, '-')]
197
+ for (const k of variants) {
198
+ if (k in record) {
199
+ const v = record[k]
200
+ if (v === null || v === undefined) return null
201
+ if (typeof v === 'object') return v.xid ?? v.euuid ?? v._eid ?? null
202
+ return v
203
+ }
204
+ }
205
+ return null
206
+ }
207
+
208
+ function recordId(record) {
209
+ return record?.xid ?? record?.euuid ?? record?._eid ?? null
210
+ }
211
+
212
+ /**
213
+ * Compose a flat list of records into a single tree rooted at `rootId`.
214
+ *
215
+ * Typical use after `client.getTree(entity, root, on, selection)`:
216
+ *
217
+ * const flat = await client.getTree('Human', howard, 'father',
218
+ * { first_name: null, father: { xid: null } })
219
+ * const tree = composeTree(flat, { on: 'father', rootId: howard })
220
+ * // → { xid, first_name, _children: [ {..., _children: [...]}, ... ] }
221
+ *
222
+ * Each record must include the parent-FK relation in the selection so the
223
+ * composer can link children to parents. Each child gets attached under the
224
+ * constant `children` key (regardless of which relation we walked).
225
+ *
226
+ * @param {Array<Object>} records - Flat result list (from getTree/searchTree)
227
+ * @param {Object} opts
228
+ * @param {string} opts.on - Relation name used to walk the tree
229
+ * @param {string} [opts.rootId] - Root's id; if omitted, first record is root
230
+ * @param {string} [opts.childrenKey='_children'] - Output nesting key
231
+ * @returns {Object|null} Nested tree, or null if root isn't found
232
+ */
233
+ // Two-pass indexing: record-by-id + parent→children-ids. Using id-level
234
+ // links avoids a snapshot bug where mutating a child's children after it's
235
+ // been copied into a parent leaves stale data in the parent.
236
+ function buildIndexes(records, on) {
237
+ const byId = new Map()
238
+ const kids = new Map()
239
+ for (const r of records) {
240
+ const id = recordId(r)
241
+ if (id == null) continue
242
+ byId.set(id, r)
243
+ const pid = parentId(r, on)
244
+ if (pid != null && pid !== id) {
245
+ if (!kids.has(pid)) kids.set(pid, [])
246
+ kids.get(pid).push(id)
247
+ }
248
+ }
249
+ return { byId, kids }
250
+ }
251
+
252
+ function buildSubtree(byId, kids, childrenKey, id, visited) {
253
+ if (visited.has(id)) return null // cycle — break
254
+ visited.add(id)
255
+ const record = byId.get(id)
256
+ const childIds = kids.get(id) ?? []
257
+ const result = {
258
+ ...record,
259
+ [childrenKey]: childIds
260
+ .map(cid => buildSubtree(byId, kids, childrenKey, cid, visited))
261
+ .filter(x => x !== null),
262
+ }
263
+ visited.delete(id)
264
+ return result
265
+ }
266
+
267
+ export function composeTree(records, { on, rootId, childrenKey = '_children' } = {}) {
268
+ if (!Array.isArray(records) || records.length === 0) return null
269
+ if (!on) throw new Error('composeTree: `on` (relation name) is required')
270
+
271
+ const { byId, kids } = buildIndexes(records, on)
272
+ const root = rootId ?? recordId(records[0])
273
+ return byId.has(root) ? buildSubtree(byId, kids, childrenKey, root, new Set()) : null
274
+ }
275
+
276
+ /**
277
+ * Compose a flat list into a forest — returns an array of trees where each
278
+ * tree is rooted at a record whose parent isn't present in the set. Useful
279
+ * for `search-tree` results where multiple independent ancestor chains may
280
+ * be returned.
281
+ *
282
+ * @param {Array<Object>} records
283
+ * @param {Object} opts
284
+ * @param {string} opts.on - Relation name
285
+ * @param {string} [opts.childrenKey='children']
286
+ * @returns {Array<Object>}
287
+ */
288
+ export function composeForest(records, { on, childrenKey = '_children' } = {}) {
289
+ if (!Array.isArray(records) || records.length === 0) return []
290
+ if (!on) throw new Error('composeForest: `on` (relation name) is required')
291
+
292
+ const { byId, kids } = buildIndexes(records, on)
293
+ const rootIds = []
294
+ for (const r of records) {
295
+ const id = recordId(r)
296
+ if (id == null) continue
297
+ const pid = parentId(r, on)
298
+ if (pid == null || !byId.has(pid) || pid === id) rootIds.push(id)
299
+ }
300
+ return rootIds.map(id => buildSubtree(byId, kids, childrenKey, id, new Set()))
301
+ }
302
+
303
+ // ============================================================================
304
+ // Subscription descriptors
305
+ // ============================================================================
306
+
307
+ /**
308
+ * Normalize a subscribe()/observe() descriptor argument.
309
+ *
310
+ * ['xid-a', 'xid-b'] → {records: Set(2)}
311
+ * { records: ['xid-a'], operations: ['update'] } → {records: Set(1), operations: Set(1)}
312
+ *
313
+ * Throws SynthigyError on bad input or the legacy entity-firehose form
314
+ * (a plain string).
315
+ *
316
+ * @private
317
+ */
318
+ function _normalizeDescriptor(arg) {
319
+ if (typeof arg === 'string') {
320
+ throw new SynthigyError(
321
+ 'Subscriptions are records-only. Pass an array of xids or {records: [...]} instead of an entity name.',
322
+ 'INVALID_SUBSCRIPTION'
323
+ )
324
+ }
325
+ if (Array.isArray(arg)) {
326
+ if (arg.length === 0) {
327
+ throw new SynthigyError('records must be a non-empty array of xid strings',
328
+ 'EMPTY_RECORDS')
329
+ }
330
+ return { records: new Set(arg) }
331
+ }
332
+ if (arg && typeof arg === 'object' && Array.isArray(arg.records)) {
333
+ if (arg.records.length === 0) {
334
+ throw new SynthigyError('records must be a non-empty array of xid strings',
335
+ 'EMPTY_RECORDS')
336
+ }
337
+ const out = { records: new Set(arg.records) }
338
+ if (arg.operations !== undefined) {
339
+ if (!Array.isArray(arg.operations)) {
340
+ throw new SynthigyError('operations must be an array of vocab strings',
341
+ 'INVALID_OPERATIONS')
342
+ }
343
+ out.operations = new Set(arg.operations)
344
+ }
345
+ return out
346
+ }
347
+ throw new SynthigyError(
348
+ 'Expected an array of xids or a {records, operations?} descriptor',
349
+ 'INVALID_SUBSCRIPTION'
350
+ )
351
+ }
352
+
353
+ /**
354
+ * Stable string key for a normalized descriptor — sorted records +
355
+ * sorted operations, JSON-stringified. Used as the local mirror key
356
+ * when the caller doesn't pass an explicit `key`. Re-subscribing with
357
+ * the same records + operations is therefore idempotent.
358
+ *
359
+ * @private
360
+ */
361
+ function _descriptorKey(descriptor) {
362
+ const records = [...descriptor.records].sort()
363
+ const operations = descriptor.operations
364
+ ? [...descriptor.operations].sort()
365
+ : null
366
+ return JSON.stringify({ records, operations })
367
+ }
368
+
369
+ // ============================================================================
370
+ // Token Manager — handles client credentials flow
371
+ // ============================================================================
372
+
373
+ class TokenManager {
374
+ constructor({ tokenUrl, clientId, clientSecret, scope, fetch: fetchImpl }) {
375
+ this._tokenUrl = tokenUrl
376
+ this._clientId = clientId
377
+ this._clientSecret = clientSecret
378
+ this._scope = scope
379
+ this._fetch = fetchImpl ?? globalThis.fetch.bind(globalThis)
380
+ this._tokens = new Map() // audience -> { token, expiresAt }
381
+ }
382
+
383
+ async getToken(audience) {
384
+ const key = audience || ''
385
+
386
+ // Return cached token if still valid (with 30s buffer)
387
+ const cached = this._tokens.get(key)
388
+ if (cached && Date.now() < cached.expiresAt - 30000) {
389
+ return cached.token
390
+ }
391
+
392
+ const params = new URLSearchParams({
393
+ grant_type: 'client_credentials',
394
+ client_id: this._clientId,
395
+ client_secret: this._clientSecret,
396
+ })
397
+ if (this._scope) params.set('scope', this._scope)
398
+ if (audience) params.set('audience', audience)
399
+
400
+ const resp = await this._fetch(this._tokenUrl, {
401
+ method: 'POST',
402
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
403
+ body: params,
404
+ })
405
+
406
+ if (!resp.ok) {
407
+ const text = await resp.text()
408
+ throw new Error(`Token request failed (${resp.status}): ${text}`)
409
+ }
410
+
411
+ const data = await resp.json()
412
+ this._tokens.set(key, {
413
+ token: data.access_token,
414
+ expiresAt: Date.now() + (data.expires_in || 3600) * 1000,
415
+ })
416
+ return data.access_token
417
+ }
418
+
419
+ /** Drop every cached token — the next getToken() call refetches. */
420
+ clear() {
421
+ this._tokens.clear()
422
+ }
423
+ }
424
+
425
+ // ============================================================================
426
+ // Supervised stdio — SYNTHIGY_SUPERVISED=1, see
427
+ // docs/plans/PLAN-EXEC-IDENTITY.md steps 2-4. The SDK never mints locally
428
+ // under this mode: the CLI/commander is the platform's stdio owner, so a
429
+ // token is asked for over JSON-RPC on the process's OWN stdio (supervise
430
+ // grammar) instead. {token, expires_in} is deliberately byte-compatible
431
+ // with robotics' request-access-token response, so this ask serves both
432
+ // parents (exec locally, the commander through a reacher agent in
433
+ // production). Node-only — a browser has neither process.stdin nor
434
+ // process.env, so this code path is only ever reached behind the
435
+ // `typeof process !== 'undefined'` guard in the constructor below.
436
+ //
437
+ // JS is single-threaded, so there's no read-race the way the Python SDK
438
+ // has (two OS threads calling readline() on the same stdin can steal each
439
+ // other's line there) — a persistent 'data' listener buffering chunks and
440
+ // splitting on '\n' is the whole story, no lock/thread machinery needed.
441
+ // Still ONE listener for the whole process lifetime (not one per ask):
442
+ // every SupervisedTokenSource shares the one supervising parent, so a
443
+ // second independent ask would just be a wasted round trip — and the
444
+ // cache lives here too, for the same reason.
445
+ // ============================================================================
446
+
447
+ const SUPERVISED_TIMEOUT_MS = 5000
448
+
449
+ class SupervisedIO {
450
+ constructor() {
451
+ this._pending = new Map() // request id -> deliver(msg|null)
452
+ this._nextId = 0
453
+ this._tokens = new Map() // audience -> { token, expiresAt }
454
+ this._buffer = ''
455
+ this._readerStarted = false
456
+ this._writeGuarded = false
457
+ this._writeBroken = false
458
+ }
459
+
460
+ _ensureReader() {
461
+ if (this._readerStarted) return
462
+ this._readerStarted = true
463
+ process.stdin.setEncoding('utf8')
464
+ process.stdin.on('data', (chunk) => this._onData(chunk))
465
+ process.stdin.on('end', () => this._onEnd())
466
+ process.stdin.on('error', () => this._onEnd())
467
+ }
468
+
469
+ // Node raises stdout write failures (EPIPE — parent already gone) as an
470
+ // async 'error' EVENT, not an exception from write() itself; an
471
+ // unhandled 'error' event on a stream crashes the whole process. This
472
+ // listener is the fix — once attached, a broken pipe just flips a flag
473
+ // that future asks check, instead of taking the host process down with
474
+ // it. Attached lazily (not at module load) so a process that never
475
+ // touches a supervised token never installs it.
476
+ _ensureWriteGuard() {
477
+ if (this._writeGuarded) return
478
+ this._writeGuarded = true
479
+ process.stdout.on('error', () => { this._writeBroken = true })
480
+ }
481
+
482
+ _onData(chunk) {
483
+ this._buffer += chunk
484
+ let idx
485
+ while ((idx = this._buffer.indexOf('\n')) >= 0) {
486
+ const line = this._buffer.slice(0, idx)
487
+ this._buffer = this._buffer.slice(idx + 1)
488
+ this._handleLine(line)
489
+ }
490
+ }
491
+
492
+ // Strict candidate test (first byte '{', then parses, then carries
493
+ // jsonrpc:"2.0") — the same rule the parent side uses. A malformed
494
+ // candidate line must not take the dispatcher down with it — the whole
495
+ // body is one try/catch, never letting a bad line kill future asks.
496
+ _handleLine(line) {
497
+ try {
498
+ const trimmed = line.trim()
499
+ if (!trimmed || trimmed[0] !== '{') return
500
+ const msg = JSON.parse(trimmed)
501
+ if (msg.jsonrpc !== '2.0') return
502
+ const deliver = this._pending.get(msg.id)
503
+ if (deliver) deliver(msg)
504
+ } catch {
505
+ // Malformed JSON, or anything else — keep listening.
506
+ }
507
+ }
508
+
509
+ // EOF, or a stdin stream error: there is no listener anymore. Flush
510
+ // every pending ask so it fails fast instead of idling to its own
511
+ // timeout.
512
+ _onEnd() {
513
+ const waiters = [...this._pending.values()]
514
+ this._pending.clear()
515
+ for (const deliver of waiters) deliver(null)
516
+ }
517
+
518
+ // Sends one JSON-RPC request, resolves with the matching response, or
519
+ // null on timeout, parent EOF, or a write failure.
520
+ _ask(method, params, timeoutMs) {
521
+ this._ensureReader()
522
+ this._ensureWriteGuard()
523
+ const id = ++this._nextId
524
+ return new Promise((resolve) => {
525
+ let settled = false
526
+ let timer
527
+ const deliver = (v) => {
528
+ if (settled) return
529
+ settled = true
530
+ clearTimeout(timer)
531
+ this._pending.delete(id)
532
+ resolve(v)
533
+ }
534
+ timer = setTimeout(() => deliver(null), timeoutMs)
535
+ this._pending.set(id, deliver)
536
+ if (this._writeBroken) { deliver(null); return }
537
+ try {
538
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, method, params: params ?? {} }) + '\n')
539
+ } catch {
540
+ deliver(null)
541
+ }
542
+ })
543
+ }
544
+
545
+ // Per-audience cache with a 30s pre-expiry buffer, matching
546
+ // TokenManager's own discipline — extended process-wide (one cache) since
547
+ // every SupervisedTokenSource in a process shares one supervising parent.
548
+ async getToken(audience) {
549
+ const key = audience || ''
550
+ const cached = this._tokens.get(key)
551
+ if (cached && Date.now() < cached.expiresAt - 30000) {
552
+ return cached.token
553
+ }
554
+
555
+ const params = audience ? { audience } : {}
556
+ const msg = await this._ask('auth.token', params, SUPERVISED_TIMEOUT_MS)
557
+
558
+ if (!msg) {
559
+ throw new SynthigyError(
560
+ 'Timed out waiting for auth.token from the supervising parent — ' +
561
+ 'a hung or missing parent must not hang the bot. Check the ' +
562
+ 'parent process (synthigy exec/agent, or the robotics commander) ' +
563
+ 'is still connected.', 'NO_TOKEN')
564
+ }
565
+ if (msg.error) {
566
+ throw new SynthigyError(msg.error.message || 'auth.token request denied', 'NO_TOKEN')
567
+ }
568
+ const token = msg.result?.token
569
+ if (!token) {
570
+ throw new SynthigyError('auth.token response carried no token', 'NO_TOKEN')
571
+ }
572
+ // A malformed/non-numeric expires_in from the parent must not crash
573
+ // the cache-expiry computation.
574
+ const raw = msg.result?.expires_in
575
+ const n = raw == null ? NaN : Number(raw)
576
+ const expiresIn = Number.isFinite(n) ? n : 300
577
+ this._tokens.set(key, { token, expiresAt: Date.now() + expiresIn * 1000 })
578
+ return token
579
+ }
580
+
581
+ clear() {
582
+ this._tokens.clear()
583
+ }
584
+ }
585
+
586
+ let _supervisedIO = null
587
+ function _getSupervisedIO() {
588
+ if (!_supervisedIO) _supervisedIO = new SupervisedIO()
589
+ return _supervisedIO
590
+ }
591
+
592
+ /**
593
+ * Token source for SYNTHIGY_SUPERVISED=1 — asks `auth.token` over the
594
+ * process's own stdio instead of minting locally (the CLI/commander is
595
+ * the platform's stdio owner; this SDK never mints, per
596
+ * PLAN-EXEC-IDENTITY step 2). Same public shape as TokenManager
597
+ * (getToken/clear) so call sites don't care which source is installed —
598
+ * a thin handle onto the real, process-wide SupervisedIO singleton
599
+ * (cache and all), so multiple clients in one process share one cache
600
+ * instead of each asking the parent independently.
601
+ */
602
+ class SupervisedTokenSource {
603
+ getToken(audience) {
604
+ return _getSupervisedIO().getToken(audience)
605
+ }
606
+
607
+ clear() {
608
+ _getSupervisedIO().clear()
609
+ }
610
+ }
611
+
612
+ /** The teaching throw — PLAN-EXEC-IDENTITY step 3: the error IS the UX, no flag, no silent anonymous fallback. */
613
+ function _noTokenError() {
614
+ return new SynthigyError(
615
+ 'no Synthigy token: set token, or clientId + clientSecret, or set ' +
616
+ 'SYNTHIGY_TOKEN, or run under `synthigy exec` (or a Synthigy agent) ' +
617
+ 'with SYNTHIGY_SUPERVISED=1 so a parent can supply one.', 'NO_TOKEN')
618
+ }
619
+
620
+ // ============================================================================
621
+ // Error
622
+ // ============================================================================
623
+
624
+ /**
625
+ * Map from error code → category. Codes not in the table fall back to
626
+ * `internal` at construction time.
627
+ *
628
+ * Categories are stable enums consumers can branch on:
629
+ * - auth — token / session / IdP issues. Re-authenticate.
630
+ * - iam — RBAC/RLS denial. Caller lacks permission.
631
+ * - validation — caller's request shape is invalid.
632
+ * - not_found — referenced entity/relation/record doesn't exist.
633
+ * - conflict — constraint violation, optimistic-concurrency, etc.
634
+ * - rate_limit — too many requests.
635
+ * - network — transport failure (DNS, TCP, timeout, abort).
636
+ * - internal — unexpected server error. Retry-safe; check logs.
637
+ */
638
+ const ERROR_CATEGORIES = {
639
+ // auth
640
+ UNAUTHORIZED: 'auth',
641
+ CLIENT_NOT_FOUND: 'auth',
642
+ CLIENT_INACTIVE: 'auth',
643
+ PUBLIC_CLIENT_FORBIDDEN: 'auth',
644
+ NOT_TRUSTED: 'auth',
645
+ USER_NOT_FOUND: 'auth',
646
+ USER_INACTIVE: 'auth',
647
+ PROVISION_FORBIDDEN: 'auth',
648
+ CLAIM_INVALID: 'auth',
649
+ NO_TOKEN: 'auth', // client-side: no token source configured — retrying can't fix it
650
+ // iam
651
+ FORBIDDEN: 'iam',
652
+ FORBIDDEN_OP: 'iam',
653
+ ENTITY_FORBIDDEN: 'iam',
654
+ ENTITY_NOT_READABLE: 'iam',
655
+ RELATION_NOT_READABLE: 'iam',
656
+ // validation
657
+ INVALID_BODY: 'validation',
658
+ NO_OPERATIONS: 'validation',
659
+ UNKNOWN_OP: 'validation',
660
+ UNKNOWN_OPERATOR: 'validation',
661
+ MISSING_ON: 'validation',
662
+ MISSING_ROOT: 'validation',
663
+ MISSING_RECORDS: 'validation',
664
+ EMPTY_RECORDS: 'validation',
665
+ MISSING_ENTITIES: 'validation',
666
+ EMPTY_ENTITIES: 'validation',
667
+ MISSING_RELATIONS: 'validation',
668
+ EMPTY_RELATIONS: 'validation',
669
+ INVALID_RELATION_NAME: 'validation',
670
+ INVALID_SUBSCRIPTION: 'validation',
671
+ INVALID_OPERATIONS: 'validation',
672
+ INVALID_INTEREST: 'validation',
673
+ EMPTY_INTEREST: 'validation',
674
+ UNSUPPORTED_TYPE: 'validation',
675
+ XSQL_PARSE_ERROR: 'validation',
676
+ TEMPLATE_BAD_CTE: 'validation',
677
+ TEMPLATE_UNBALANCED_PARENS: 'validation',
678
+ TEMPLATE_ERROR: 'validation',
679
+ TEMPLATE_PARAM_ERROR: 'validation',
680
+ QUERY_NOT_SELECT: 'validation',
681
+ PARAM_MISSING: 'validation',
682
+ PARAM_TYPE_MISMATCH: 'validation',
683
+ NOT_CONNECTED: 'validation', // client-side guard — retrying without connecting can't fix it
684
+ XID_REQUIRED: 'validation',
685
+ CLAIM_METHOD_NOT_ALLOWED: 'validation',
686
+ PASSWORD_TOO_WEAK: 'validation',
687
+ RETURN_URL_NOT_REGISTERED: 'validation',
688
+ // not_found
689
+ UNKNOWN_ENTITY: 'not_found',
690
+ UNKNOWN_RELATION: 'not_found',
691
+ UNKNOWN_TEMPLATE_RELATION: 'not_found',
692
+ HISTORY_UNAVAILABLE: 'not_found', // no audit provider — retrying can't fix it
693
+ // conflict (e.g., DB constraint violations the server translates)
694
+ FK_VIOLATION: 'conflict',
695
+ UNIQUE_VIOLATION: 'conflict',
696
+ CHECK_VIOLATION: 'conflict',
697
+ NOT_NULL_VIOLATION: 'conflict',
698
+ // rate / capacity
699
+ TIMEOUT: 'rate_limit',
700
+ // network
701
+ NETWORK_ERROR: 'network',
702
+ // internal — fallback
703
+ INTERNAL_ERROR: 'internal',
704
+ OPERATION_ERROR: 'internal',
705
+ HTTP_ERROR: 'internal',
706
+ }
707
+
708
+ /**
709
+ * Codes whose category is automatically retryable. Any other code is
710
+ * one-shot: the caller must fix something before retrying.
711
+ */
712
+ const RETRYABLE_CATEGORIES = new Set(['network', 'rate_limit', 'internal'])
713
+
714
+ /**
715
+ * SDK error class — every error the SDK throws is an instance.
716
+ *
717
+ * Always present:
718
+ * - .message — human-readable description
719
+ * - .code — stable string code (see ERRORS.md for the catalog)
720
+ * - .category — 'auth' | 'iam' | 'validation' | 'not_found' |
721
+ * 'conflict' | 'rate_limit' | 'network' | 'internal'
722
+ * - .retryable— boolean. True if the same request might succeed if retried.
723
+ *
724
+ * Optional, present when the server included them:
725
+ * - .details — opaque payload from the server (legacy field; new
726
+ * code should prefer the structured fields below)
727
+ * - .hint — "Did you mean X?" suggestion for typos
728
+ * - .available— array of valid alternatives (e.g., relations on the entity)
729
+ * - .path — JSON path into the request where validation failed
730
+ * - .entity — entity name involved
731
+ * - .relation — relation name involved
732
+ * - .operator — operator name involved
733
+ * - .requestId— request ID for cross-correlation with server logs
734
+ * - .status — HTTP status code (for transport-level errors)
735
+ */
736
+ export class SynthigyError extends Error {
737
+ constructor(message, code, details, extras) {
738
+ super(message)
739
+ this.name = 'SynthigyError'
740
+ this.code = code
741
+ this.category = ERROR_CATEGORIES[code] ?? 'internal'
742
+ this.retryable = RETRYABLE_CATEGORIES.has(this.category)
743
+ if (details !== undefined) this.details = details
744
+ if (extras) {
745
+ if (extras.hint !== undefined) this.hint = extras.hint
746
+ if (extras.available !== undefined) this.available = extras.available
747
+ if (extras.path !== undefined) this.path = extras.path
748
+ if (extras.entity !== undefined) this.entity = extras.entity
749
+ if (extras.relation !== undefined) this.relation = extras.relation
750
+ if (extras.operator !== undefined) this.operator = extras.operator
751
+ if (extras.requestId !== undefined) this.requestId = extras.requestId
752
+ if (extras.status !== undefined) this.status = extras.status
753
+ // Source position fields — populated for XSQL_PARSE_ERROR and
754
+ // TEMPLATE_* errors. `line`/`col` are 1-based, suitable for
755
+ // pointing an editor at the offending token. `start`/`end` are
756
+ // range forms (`{line, col}` each) when the error spans a token.
757
+ // `diagnostics` is the full array from the XSQL linter.
758
+ if (extras.line !== undefined) this.line = extras.line
759
+ if (extras.col !== undefined) this.col = extras.col
760
+ if (extras.start !== undefined) this.start = extras.start
761
+ if (extras.end !== undefined) this.end = extras.end
762
+ if (extras.diagnostics !== undefined) this.diagnostics = extras.diagnostics
763
+ }
764
+ }
765
+ }
766
+
767
+ /**
768
+ * Generate a fresh request ID for X-Request-Id headers. 16 chars of
769
+ * URL-safe random — enough entropy to not collide in a single client's
770
+ * lifetime, short enough to read in logs.
771
+ * @internal
772
+ */
773
+ function _generateRequestId() {
774
+ const buf = new Uint8Array(12)
775
+ ;(globalThis.crypto?.getRandomValues
776
+ ? globalThis.crypto.getRandomValues(buf)
777
+ : (() => { for (let i = 0; i < buf.length; i++) buf[i] = Math.floor(Math.random() * 256) })())
778
+ return Buffer.from(buf).toString('base64url')
779
+ }
780
+
781
+ /**
782
+ * Build a SynthigyError from a server-returned error object.
783
+ * @internal
784
+ */
785
+ function _errorFromServer(err, status, requestId) {
786
+ return new SynthigyError(err.message, err.code, err.details, {
787
+ hint: err.hint,
788
+ available: err.available,
789
+ path: err.path,
790
+ entity: err.entity,
791
+ relation: err.relation,
792
+ operator: err.operator,
793
+ line: err.line,
794
+ col: err.col,
795
+ start: err.start,
796
+ end: err.end,
797
+ diagnostics: err.diagnostics,
798
+ requestId,
799
+ status,
800
+ })
801
+ }
802
+
803
+ /**
804
+ * Build a SynthigyError from a non-ok /oauth/onboard or /oauth/onboard/complete
805
+ * response. Wire shape here is {error: "<snake_case_code>"} — a bare string,
806
+ * unlike the {error: {code, message}} envelope /data uses — so it's handled
807
+ * separately rather than through _errorFromServer (which would silently miss
808
+ * the code on a string).
809
+ * @internal
810
+ */
811
+ async function onboardError(resp) {
812
+ const data = await resp.json().catch(() => ({}))
813
+ const code = (typeof data.error === 'string' ? data.error : null)
814
+ if (code) return new SynthigyError(`onboard failed: ${code}`, code.toUpperCase(), undefined, { status: resp.status })
815
+ return new SynthigyError(`onboard request failed (${resp.status})`, 'HTTP_ERROR', undefined, { status: resp.status })
816
+ }
817
+
818
+ // ============================================================================
819
+ // Client
820
+ // ============================================================================
821
+
822
+ class SynthigyClient {
823
+ /**
824
+ * @param {Object} config
825
+ * @param {string} config.endpoint - Synthigy base URL
826
+ * @param {string} [config.clientId] - OAuth client ID (for client credentials)
827
+ * @param {string} [config.clientSecret] - OAuth client secret
828
+ * @param {string} [config.token] - Static bearer token (alternative to client credentials)
829
+ *
830
+ * On Node, with none of the above, the constructor also falls back to —
831
+ * when SYNTHIGY_SUPERVISED=1 — a supervised-stdio auth.token ask (the
832
+ * pipe beats the env var: it can refresh mid-run), then the
833
+ * SYNTHIGY_TOKEN env var (see docs/plans/PLAN-EXEC-IDENTITY.md step 3).
834
+ * With no source at all it throws a SynthigyError whose code is
835
+ * NO_TOKEN and whose message teaches the fix. A browser has neither
836
+ * env vars nor a stdio parent, so it always requires an explicit source.
837
+ * @param {string} [config.scope] - OAuth scopes
838
+ * @param {string} [config.actingAs] - Default impersonation target — applied
839
+ * to every request unless overridden
840
+ * per-call. Useful for per-session clients.
841
+ * @param {'kebab'|'snake'|'camel'} [config.keyFormat] - Default key-case for
842
+ * response payloads. Server defaults to
843
+ * snake_case; set "kebab" for kebab-case
844
+ * (or override per-call via opts.keyFormat).
845
+ * @param {Function} [config.fetch] - Optional fetch implementation. Defaults
846
+ * to `globalThis.fetch`. For Node services
847
+ * that need pooled HTTP connections, pass
848
+ * `undici.fetch` with a shared `Agent`:
849
+ *
850
+ * import { Agent, fetch } from "undici"
851
+ * const agent = new Agent({ keepAliveTimeout: 30_000, connections: 50 })
852
+ * const client = createClient({
853
+ * endpoint, clientId, clientSecret,
854
+ * fetch: (url, init) => fetch(url, { ...init, dispatcher: agent }),
855
+ * })
856
+ */
857
+ constructor(config) {
858
+ this._endpoint = config.endpoint.replace(/\/$/, '')
859
+ this._dataUrl = `${this._endpoint}/data`
860
+ this._defaultActingAs = config.actingAs ?? null
861
+ this._defaultKeyFormat = config.keyFormat ?? null
862
+ this._timeout = config.timeout ?? null
863
+ this._fetch = config.fetch ?? globalThis.fetch.bind(globalThis)
864
+ // Stored so the OIDC helpers can authenticate code-exchange and
865
+ // default the ID-token audience to this client's own id.
866
+ this._clientId = config.clientId ?? null
867
+ this._clientSecret = config.clientSecret ?? null
868
+ // Long-lived contexts (BFFs, Node services) want the upstream SSE pinned
869
+ // open across user-watch churn so the plug session keyed by
870
+ // [sub, client_id] isn't torn down between watches. Replaces the
871
+ // earlier "dummy keepalive watch" workaround.
872
+ this._keepAlive = config.keepAlive === true
873
+ // The platform's audience model is opt-in: a client_credentials mint
874
+ // naming no audience resolves to the identity-only OIDC audience, which
875
+ // /data rejects. Bound once here, applied to every mint this client makes.
876
+ this._defaultAudience = config.audience
877
+ ?? (typeof process !== 'undefined' ? process.env?.SYNTHIGY_AUDIENCE : undefined)
878
+ ?? undefined
879
+
880
+ if (config.token !== undefined && config.token !== null) {
881
+ // Static token mode — empty string allowed for unauth / dev servers.
882
+ this._tokenManager = null
883
+ this._getToken = async () => config.token
884
+ } else if (config.clientId && config.clientSecret) {
885
+ // Client credentials mode
886
+ this._tokenManager = new TokenManager({
887
+ tokenUrl: `${this._endpoint}/oauth/token`,
888
+ clientId: config.clientId,
889
+ clientSecret: config.clientSecret,
890
+ scope: config.scope,
891
+ fetch: this._fetch,
892
+ })
893
+ this._getToken = () => this._tokenManager.getToken(this._defaultAudience)
894
+ } else if (typeof process !== 'undefined' && process.env?.SYNTHIGY_SUPERVISED === '1') {
895
+ // The pipe beats the env var: exec injects the cached token AND
896
+ // supervises; only the pipe refreshes mid-run.
897
+ this._tokenManager = new SupervisedTokenSource()
898
+ this._getToken = () => this._tokenManager.getToken(this._defaultAudience)
899
+ } else if (typeof process !== 'undefined' && process.env?.SYNTHIGY_TOKEN) {
900
+ // A snapshot, not a live source: exec/connect refresh and rewrite
901
+ // the profile's cache on THEIR next run, not this process's.
902
+ const envToken = process.env.SYNTHIGY_TOKEN
903
+ this._tokenManager = null
904
+ this._getToken = async () => envToken
905
+ } else {
906
+ throw _noTokenError()
907
+ }
908
+
909
+ // Local mirror of the session's subscription set. The server uses
910
+ // full set-replacement semantics on /data/subscription/set, so the
911
+ // SDK tracks intent here and re-sends the full set on each change.
912
+ // Each entry is a record descriptor
913
+ // {records: Set<string>, operations?: Set<string>}. Keyed by either
914
+ // a caller-supplied `key` or a stable hash of the descriptor.
915
+ this._dataSubs = new Map() // key → descriptor (records, ops)
916
+ this._modelSubs = new Set() // 'deployed-model' | 'runtime-model'
917
+ this._entitySubs = new Set() // entity names — entity track union
918
+ this._relationSubs = new Set() // 'entity.label' strings — relation track union
919
+
920
+ // Eagerly construct the multiplexer when keepAlive is on so its
921
+ // constructor opens the SSE before any user watch lands.
922
+ if (this._keepAlive) getMultiplexer(this)
923
+ }
924
+
925
+ /**
926
+ * Tear the client down — close every live watch, release the keepAlive
927
+ * hold, close the SSE. Use this when a long-lived BFF / service is
928
+ * shutting down so the plug session is dropped cleanly server-side
929
+ * (rather than waiting for the SSE to time out).
930
+ */
931
+ close() {
932
+ if (this.__watchMux) this.__watchMux.close()
933
+ }
934
+
935
+ /**
936
+ * Shared fetch transport — attaches Authorization header, retries once on 401
937
+ * (token may have been rotated since it was cached). Throws SynthigyError on
938
+ * UNAUTHORIZED. All other status codes are returned to the caller to handle.
939
+ *
940
+ * Auto-injects an X-Request-Id header (caller-supplied or freshly
941
+ * generated). The server echoes it back as the response X-Request-Id
942
+ * header and logs include it via request-completed events, so callers
943
+ * can correlate client logs with server logs.
944
+ */
945
+ async _fetchAuth(url, fetchOpts = {}, { applyTimeout = true, requestId } = {}) {
946
+ const attempt = async () => {
947
+ const token = await this._getToken()
948
+ const headers = { ...fetchOpts.headers }
949
+ if (token) headers['Authorization'] = `Bearer ${token}`
950
+ if (!headers['X-Request-Id'] && !headers['x-request-id']) {
951
+ headers['X-Request-Id'] = requestId ?? _generateRequestId()
952
+ }
953
+ const opts = { ...fetchOpts, headers }
954
+ if (applyTimeout && this._timeout && !opts.signal) opts.signal = AbortSignal.timeout(this._timeout)
955
+ return this._fetch(url, opts)
956
+ }
957
+
958
+ let resp = await attempt()
959
+ if (resp.status === 401 && this._tokenManager) {
960
+ this._tokenManager.clear()
961
+ resp = await attempt()
962
+ }
963
+ if (resp.status === 401) {
964
+ const requestId = resp.headers?.get?.('x-request-id') ?? undefined
965
+ throw new SynthigyError('Unauthorized', 'UNAUTHORIZED', undefined,
966
+ { requestId, status: 401 })
967
+ }
968
+ return resp
969
+ }
970
+
971
+ /**
972
+ * Shared POST transport for the /data endpoint — handles 403, 5xx, and JSON parsing.
973
+ * Returns the parsed JSON body augmented with `_requestId` so callers
974
+ * can thread it into any error they throw downstream from per-op
975
+ * results (`{ok: false, error}`).
976
+ */
977
+ async _post(body) {
978
+ const resp = await this._fetchAuth(this._dataUrl, {
979
+ method: 'POST',
980
+ headers: { 'Content-Type': 'application/json' },
981
+ body: JSON.stringify(body),
982
+ })
983
+ const requestId = resp.headers?.get?.('x-request-id') ?? undefined
984
+ if (resp.status === 403) {
985
+ const data = await resp.json().catch(() => ({}))
986
+ throw _errorFromServer(
987
+ data.error ?? { message: 'Forbidden', code: 'FORBIDDEN' },
988
+ 403, requestId)
989
+ }
990
+ if (!resp.ok) {
991
+ const text = await resp.text()
992
+ let data
993
+ try { data = JSON.parse(text) } catch { /* not JSON */ }
994
+ if (data?.error) {
995
+ throw _errorFromServer(data.error, resp.status, requestId)
996
+ }
997
+ throw new SynthigyError(`Request failed (${resp.status})`, 'HTTP_ERROR', text,
998
+ { requestId, status: resp.status })
999
+ }
1000
+ const body_ = await resp.json()
1001
+ if (body_ && typeof body_ === 'object' && requestId) body_._requestId = requestId
1002
+ return body_
1003
+ }
1004
+
1005
+ /**
1006
+ * Produce a `FetchFn` suitable for feeding to `@synthigy/buffer`'s
1007
+ * `connect({fetchFn})`.
1008
+ *
1009
+ * Pure transport: takes a body, attaches auth, POSTs to `/data`. Does not
1010
+ * modify the body — add `acting_as` at the buffer level if needed.
1011
+ *
1012
+ * @returns {(body: object) => Promise<{results: Array}>} FetchFn
1013
+ *
1014
+ * @example
1015
+ * ```js
1016
+ * const buffer = await connect({
1017
+ * fetchFn: client.fetchFn(),
1018
+ * entities: ["user", "user-role"],
1019
+ * actingAs: req.session.userEuuid,
1020
+ * })
1021
+ * ```
1022
+ */
1023
+ fetchFn() {
1024
+ return (body) => this._post(body)
1025
+ }
1026
+
1027
+ /**
1028
+ * Get an access token for a specific audience.
1029
+ *
1030
+ * Synthigy acts as the identity provider. Use this to get tokens
1031
+ * for external services (robotics, project management, etc.) that
1032
+ * trust Synthigy as their IdP.
1033
+ *
1034
+ * Tokens are cached per audience and auto-refreshed before expiry.
1035
+ *
1036
+ * @param {Object} [opts] - Options
1037
+ * @param {string} [opts.audience] - Target audience (omit to use the
1038
+ * client's configured `audience`)
1039
+ * @returns {Promise<string>} Bearer access token
1040
+ *
1041
+ * @example
1042
+ * ```js
1043
+ * // Token for Synthigy itself (same as internal data operations)
1044
+ * const token = await client.token()
1045
+ *
1046
+ * // Token for Robotics API
1047
+ * const roboticsToken = await client.token({ audience: 'robotics' })
1048
+ *
1049
+ * // Use with any HTTP client
1050
+ * const resp = await fetch('https://robotics.example.com/api/robots', {
1051
+ * headers: { 'Authorization': `Bearer ${roboticsToken}` },
1052
+ * })
1053
+ * ```
1054
+ */
1055
+ async token(opts) {
1056
+ if (!this._tokenManager) {
1057
+ // Static token mode — return the static token regardless of audience
1058
+ return this._getToken()
1059
+ }
1060
+ return this._tokenManager.getToken(opts?.audience ?? this._defaultAudience)
1061
+ }
1062
+
1063
+ /**
1064
+ * Execute raw operations against the /data endpoint.
1065
+ *
1066
+ * @param {Array<Object>} operations - Array of operation objects
1067
+ * @param {Object} [opts] - Options
1068
+ * @param {string} [opts.actingAs] - User EUUID to act on behalf of
1069
+ * @param {'kebab'|'snake'|'camel'} [opts.keyFormat] - Response key-case override
1070
+ * @returns {Promise<Array>} Array of results
1071
+ */
1072
+ async exec(operations, opts) {
1073
+ const body = { operations }
1074
+ const actingAs = opts?.actingAs ?? this._defaultActingAs
1075
+ if (actingAs) body.acting_as = actingAs
1076
+ const keyFormat = opts?.keyFormat ?? this._defaultKeyFormat
1077
+ if (keyFormat) body.key_format = keyFormat
1078
+
1079
+ const respBody = await this._post(body)
1080
+ const { results, error, _requestId: requestId } = respBody
1081
+ if (error) throw _errorFromServer(error, undefined, requestId)
1082
+ // Tag each per-op result with the request-id so downstream throw
1083
+ // sites (search, get, sync, etc.) can attach it to the SynthigyError.
1084
+ if (requestId && Array.isArray(results)) {
1085
+ for (const r of results) {
1086
+ if (r && typeof r === 'object') r._requestId = requestId
1087
+ }
1088
+ }
1089
+ return results
1090
+ }
1091
+
1092
+ // --------------------------------------------------------------------------
1093
+ // Read operations
1094
+ // --------------------------------------------------------------------------
1095
+
1096
+ /**
1097
+ * Search for multiple entities.
1098
+ *
1099
+ * @param {string} entity - Entity name (e.g., 'user', 'iam/user')
1100
+ * @param {Object} args - Query arguments ({ _where, _limit, _offset, _order_by, _distinct, ...fieldConditions })
1101
+ * @param {Object} selection - Attributes and relations to return
1102
+ * @returns {Promise<Array>} Array of entity records
1103
+ *
1104
+ * @example
1105
+ * ```js
1106
+ * const users = await client.search('User', {
1107
+ * active: eq(true),
1108
+ * _limit: 10
1109
+ * }, {
1110
+ * name: null,
1111
+ * email: null,
1112
+ * roles: rel({ name: null })
1113
+ * })
1114
+ * ```
1115
+ */
1116
+ async search(entity, args, selection, opts) {
1117
+ const [result] = await this.exec([
1118
+ { op: 'search', entity, args, selections: normalizeSelection(selection) },
1119
+ ], opts)
1120
+ if (!result.ok) throw _errorFromServer(result.error, undefined, result._requestId)
1121
+ return result.data ?? []
1122
+ }
1123
+
1124
+ /**
1125
+ * Run an XSQL query — selection-DSL string with optional `?name:type[]`
1126
+ * placeholders. The wire envelope carries the XSQL source in
1127
+ * `selections` and a typed `params` map; the server compiles XSQL to
1128
+ * the wire AST and binds placeholder values via JDBC.
1129
+ *
1130
+ * `op` defaults to `"search"`. Pass `{ op: "get" }` for unique-key
1131
+ * identity reads; the XSQL `xid = ?xid:string` form lifts cleanly.
1132
+ * Other read-shape ops (`slice`, `purge`) work too.
1133
+ *
1134
+ * @param {string} xsql - XSQL source string
1135
+ * @param {Object} [params] - `{name: value}` map for `?name:type[]` refs
1136
+ * @param {Object} [opts] - Options
1137
+ * @param {'search'|'get'|'slice'|'purge'} [opts.op='search'] - Document
1138
+ * verb for bare-body sources (and the unwrap: `get` → one record)
1139
+ * @param {string} [opts.actingAs] - Per-call impersonation
1140
+ * @param {'kebab'|'snake'|'camel'} [opts.keyFormat] - Response key case
1141
+ * @returns {Promise<Array>} Query result rows
1142
+ *
1143
+ * @example
1144
+ * ```js
1145
+ * const users = await client.query(`
1146
+ * user (active = ?a:boolean, _limit ?n:int)
1147
+ *
1148
+ * name
1149
+ * email
1150
+ * -roles
1151
+ * name
1152
+ * `, { a: true, n: 50 })
1153
+ * ```
1154
+ *
1155
+ * @example
1156
+ * ```js
1157
+ * // get by unique key
1158
+ * const user = await client.query(`
1159
+ * xid = ?xid:string
1160
+ * name
1161
+ * active
1162
+ * `, { xid }, { op: 'get' })
1163
+ * ```
1164
+ */
1165
+ async query(xsql, params, opts) {
1166
+ // STRICT wire: XSQL travels only as the `xsql` DOCUMENT op — the
1167
+ // document carries verb/entity/selections/args. Bare rooted bodies get
1168
+ // a synthetic `@<op> _q` header client-side.
1169
+ const verb = opts?.op ?? 'search'
1170
+ const op = { op: 'xsql', xsql: xsqlDocument(xsql, verb) }
1171
+ if (params !== undefined) op.params = params
1172
+ const [result] = await this.exec([op], opts)
1173
+ if (!result.ok) throw _errorFromServer(result.error, undefined, result._requestId)
1174
+ // `get` returns a single record or null; everything else returns an array.
1175
+ return (verb === 'get') ? result.data : (result.data ?? [])
1176
+ }
1177
+
1178
+ /**
1179
+ * Get a single entity by ID or filter.
1180
+ *
1181
+ * @param {string} entity - Entity name
1182
+ * @param {Object} args - Query arguments (must match exactly one record)
1183
+ * @param {Object} selection - Attributes and relations to return
1184
+ * @param {Object} [opts] - Options ({ actingAs })
1185
+ * @returns {Promise<Object|null>} Entity record or null
1186
+ */
1187
+ async get(entity, args, selection, opts) {
1188
+ // get takes flat unique constraint values — NO implicit _eq normalization
1189
+ const [result] = await this.exec([
1190
+ { op: 'get', entity, args, selections: normalizeSelection(selection) },
1191
+ ], opts)
1192
+ if (!result.ok) throw _errorFromServer(result.error, undefined, result._requestId)
1193
+ return result.data
1194
+ }
1195
+
1196
+ /**
1197
+ * Execute a SQL template with ERD-aware placeholders. Auto-generates
1198
+ * FROM and JOINs from {entity.field} / {entity->rel.field} references;
1199
+ * supports junction, self-FK (tree) and field-ref relation traversal.
1200
+ *
1201
+ * @param {string} template - SQL with {entity.field} and {entity->rel} placeholders
1202
+ * @param {Array} [params] - Query parameters for ? placeholders
1203
+ * @param {Object} [opts] - Options ({ actingAs })
1204
+ * @returns {Promise<Array>} Query results
1205
+ *
1206
+ * @example
1207
+ * ```js
1208
+ * const report = await client.sqlTemplate(
1209
+ * 'SELECT {user.name}, COUNT({user->roles}._eid) as role_count FROM {user} WHERE {user.active} = ? GROUP BY {user.name}',
1210
+ * [true]
1211
+ * )
1212
+ * ```
1213
+ */
1214
+ async sqlTemplate(template, params, opts) {
1215
+ const op = { op: 'sql-template', template, params: params || [] }
1216
+ const [result] = await this.exec([op], opts)
1217
+ if (!result.ok) throw _errorFromServer(result.error, undefined, result._requestId)
1218
+ return result.data ?? []
1219
+ }
1220
+
1221
+ /**
1222
+ * Search for entities matching `args`, walk `on` UP to their ancestors,
1223
+ * then compose the flat result into a forest (one tree per independent root).
1224
+ *
1225
+ * The `on` relation must be included in `selection` so the composer can
1226
+ * link children to parents: `{ name: null, parent: { xid: null } }`
1227
+ *
1228
+ * @param {string} entity - Entity name
1229
+ * @param {string} on - Name of the tree (self-FK) relation to walk
1230
+ * @param {Object} args - Filter conditions
1231
+ * @param {Object} selection - Attributes/relations to return (include `on` relation)
1232
+ * @param {Object} [opts] - Options
1233
+ * @param {boolean} [opts.raw] - Return flat array instead of composing into a forest
1234
+ * @param {string} [opts.childrenKey='_children'] - Key for nested children
1235
+ * @param {string} [opts.actingAs] - User to act as
1236
+ * @returns {Promise<Array>} Forest of composed trees (or flat array if raw: true)
1237
+ */
1238
+ async searchTree(entity, on, args, selection, opts) {
1239
+ const [result] = await this.exec([
1240
+ { op: 'search-tree', entity, on, args, selections: normalizeSelection(selection) },
1241
+ ], opts)
1242
+ if (!result.ok) throw _errorFromServer(result.error, undefined, result._requestId)
1243
+ const flat = result.data ?? []
1244
+ if (opts?.raw) return flat
1245
+ return composeForest(flat, { on, childrenKey: opts?.childrenKey ?? '_children' })
1246
+ }
1247
+
1248
+ /**
1249
+ * Return the root + all descendants reachable via the tree relation `on`,
1250
+ * composed into a single nested tree.
1251
+ *
1252
+ * The `on` relation must be included in `selection` so the composer can
1253
+ * link children to parents: `{ name: null, parent: { xid: null } }`
1254
+ *
1255
+ * @param {string} entity - Entity name
1256
+ * @param {string} root - Root entity id (xid/euuid)
1257
+ * @param {string} on - Name of the tree (self-FK) relation to walk
1258
+ * @param {Object} selection - Attributes/relations to return (include `on` relation)
1259
+ * @param {Object} [opts] - Options
1260
+ * @param {boolean} [opts.raw] - Return flat array instead of composing into a tree
1261
+ * @param {string} [opts.childrenKey='_children'] - Key for nested children
1262
+ * @param {string} [opts.actingAs] - User to act as
1263
+ * @returns {Promise<Object|null>} Composed tree rooted at `root`, or null if not found (or flat array if raw: true)
1264
+ */
1265
+ async getTree(entity, root, on, selection, opts) {
1266
+ const [result] = await this.exec([
1267
+ { op: 'get-tree', entity, root, on, selections: normalizeSelection(selection) },
1268
+ ], opts)
1269
+ if (!result.ok) throw _errorFromServer(result.error, undefined, result._requestId)
1270
+ const flat = result.data ?? []
1271
+ if (opts?.raw) return flat
1272
+ return composeTree(flat, { on, rootId: root, childrenKey: opts?.childrenKey ?? '_children' })
1273
+ }
1274
+
1275
+ // --------------------------------------------------------------------------
1276
+ // Write operations
1277
+ // --------------------------------------------------------------------------
1278
+
1279
+ /**
1280
+ * Sync (upsert) an entity. Replaces the full state including relations.
1281
+ *
1282
+ * Writes are silent by default — the server answers `{ count }`. Pass
1283
+ * `returning: true` for the written records, or mint ids up front with
1284
+ * `newXid()`, which is the cheap way to know what you wrote.
1285
+ *
1286
+ * @param {string} entity - Entity name
1287
+ * @param {Object} data - Entity data
1288
+ * @param {Object} [opts] - Options ({ actingAs, returning })
1289
+ * @returns {Promise<Object>} `{ count }`, or the synced entity when returning
1290
+ */
1291
+ async sync(entity, data, opts) {
1292
+ const [result] = await this.exec(
1293
+ [{ op: 'sync', entity, data, returning: opts?.returning === true }], opts)
1294
+ if (!result.ok) throw _errorFromServer(result.error, undefined, result._requestId)
1295
+ return result.data
1296
+ }
1297
+
1298
+ /**
1299
+ * Stack (additive upsert) an entity. Adds relations without removing existing.
1300
+ * Same `returning` contract as sync.
1301
+ *
1302
+ * @param {string} entity - Entity name
1303
+ * @param {Object} data - Entity data
1304
+ * @param {Object} [opts] - Options ({ actingAs, returning })
1305
+ * @returns {Promise<Object>} `{ count }`, or the stacked entity when returning
1306
+ */
1307
+ async stack(entity, data, opts) {
1308
+ const [result] = await this.exec(
1309
+ [{ op: 'stack', entity, data, returning: opts?.returning === true }], opts)
1310
+ if (!result.ok) throw _errorFromServer(result.error, undefined, result._requestId)
1311
+ return result.data
1312
+ }
1313
+
1314
+ /**
1315
+ * Slice (remove) specific relations from an entity.
1316
+ *
1317
+ * @param {string} entity - Entity name
1318
+ * @param {Object} args - Filter to identify the entity (e.g. { xid: '...' })
1319
+ * @param {Object} [selection] - Which relations to slice (omit = all)
1320
+ * @param {Object} [opts] - Options ({ actingAs })
1321
+ * @returns {Promise<Record<string, boolean>>} Map of relation-name → success
1322
+ */
1323
+ async slice(entity, args, selection, opts) {
1324
+ const [result] = await this.exec([
1325
+ { op: 'slice', entity, args, selections: normalizeSelection(selection) },
1326
+ ], opts)
1327
+ if (!result.ok) throw _errorFromServer(result.error, undefined, result._requestId)
1328
+ return result.data
1329
+ }
1330
+
1331
+ /**
1332
+ * Soft delete an entity.
1333
+ *
1334
+ * @param {string} entity - Entity name
1335
+ * @param {Object} data - Entity identifier
1336
+ * @param {Object} [opts] - Options ({ actingAs })
1337
+ * @returns {Promise<boolean>} true on success
1338
+ */
1339
+ async delete(entity, data, opts) {
1340
+ const [result] = await this.exec([{ op: 'delete', entity, data }], opts)
1341
+ if (!result.ok) throw _errorFromServer(result.error, undefined, result._requestId)
1342
+ return result.data
1343
+ }
1344
+
1345
+ /**
1346
+ * Hard delete (purge) entities matching arguments.
1347
+ *
1348
+ * @param {string} entity - Entity name
1349
+ * @param {Object} args - Filter for records to purge
1350
+ * @param {Object} [selection] - Fields to return for each purged record
1351
+ * @param {Object} [opts] - Options ({ actingAs })
1352
+ * @returns {Promise<Object>} Purged records
1353
+ */
1354
+ async purge(entity, args, selection, opts) {
1355
+ const [result] = await this.exec([
1356
+ { op: 'purge', entity, args, selections: normalizeSelection(selection) },
1357
+ ], opts)
1358
+ if (!result.ok) throw _errorFromServer(result.error, undefined, result._requestId)
1359
+ return result.data
1360
+ }
1361
+
1362
+ // --------------------------------------------------------------------------
1363
+ // Subscriptions — records-only set-replace contract (POST /data/subscription/set)
1364
+ //
1365
+ // The wire is records-only: every data item carries
1366
+ // a `records: [xid, ...]` set. Record events fan out when record-xid is
1367
+ // in the set; relation events fan out when either endpoint of a link
1368
+ // is in the set (server rotates the wire so `data[0]` is always the
1369
+ // subscribed perspective). No entity field. No firehose.
1370
+ //
1371
+ // SDK pattern: caller passes a descriptor (`{records, operations?}` or
1372
+ // a bare array of xids); SDK keys it by an explicit `key` option or a
1373
+ // stable hash of the descriptor. Local mirror persists across calls so
1374
+ // multiple parallel `observe()` flows can coexist on one client.
1375
+ // --------------------------------------------------------------------------
1376
+
1377
+ /**
1378
+ * Compose the wire `subscriptions` array from local state.
1379
+ * @private
1380
+ */
1381
+ _buildSubscriptionsBody() {
1382
+ const items = []
1383
+ for (const descriptor of this._dataSubs.values()) {
1384
+ const item = { type: 'data', records: [...descriptor.records].sort() }
1385
+ if (descriptor.operations) item.operations = [...descriptor.operations].sort()
1386
+ items.push(item)
1387
+ }
1388
+ if (this._entitySubs.size) {
1389
+ items.push({ type: 'entity', entities: [...this._entitySubs].sort() })
1390
+ }
1391
+ if (this._relationSubs.size) {
1392
+ items.push({ type: 'relation', relations: [...this._relationSubs].sort() })
1393
+ }
1394
+ for (const t of this._modelSubs) items.push({ type: t })
1395
+ return { subscriptions: items }
1396
+ }
1397
+
1398
+ /**
1399
+ * POST the current local subscription state to the server.
1400
+ * @private
1401
+ */
1402
+ async _flushSubscriptions() {
1403
+ const body = this._buildSubscriptionsBody()
1404
+ if (typeof process !== 'undefined' && process.env?.SDK_DEBUG) {
1405
+ const summary = (body.subscriptions ?? []).map((s) => ({
1406
+ type: s.type,
1407
+ records: s.records?.length,
1408
+ operations: s.operations,
1409
+ entities: s.entities,
1410
+ relations: s.relations,
1411
+ }))
1412
+ console.log('[sdk-sub] POST /data/subscription/set', summary)
1413
+ }
1414
+ const resp = await this._fetchAuth(`${this._endpoint}/data/subscription/set`, {
1415
+ method: 'POST',
1416
+ headers: { 'Content-Type': 'application/json' },
1417
+ body: JSON.stringify(body),
1418
+ })
1419
+ if (!resp.ok) {
1420
+ const data = await resp.json().catch(() => ({}))
1421
+ throw new SynthigyError(
1422
+ data.error?.message || 'Subscription set failed',
1423
+ data.error?.code || 'HTTP_ERROR'
1424
+ )
1425
+ }
1426
+ return resp.json()
1427
+ }
1428
+
1429
+ /**
1430
+ * Subscribe to data change notifications for a set of record xids.
1431
+ * Adds the descriptor to the local subscription set and POSTs the
1432
+ * full set to the server.
1433
+ *
1434
+ * The descriptor can be:
1435
+ * - an array of xids (shorthand): `client.subscribe(['xid-a', 'xid-b'])`
1436
+ * - an object: `{ records: ['xid-a'], operations: ['update'] }`
1437
+ *
1438
+ * Pass `opts.key` to give the subscription a stable handle for later
1439
+ * `unsubscribe(key)`. Without an explicit key, the descriptor's
1440
+ * stable hash is used — re-subscribing with the same records+operations
1441
+ * is idempotent.
1442
+ *
1443
+ * Legacy entity-firehose form (`client.subscribe('User')`) is rejected
1444
+ * at the SDK with a clear migration error — the wire supports only
1445
+ * the records-only form.
1446
+ *
1447
+ * @param {string[]|{records: string[], operations?: string[]}} descriptor
1448
+ * @param {Object} [opts]
1449
+ * @param {string} [opts.key] - Stable handle for unsubscribe
1450
+ * @returns {Promise<{ok:boolean}>}
1451
+ */
1452
+ async subscribe(descriptor, opts) {
1453
+ const normalized = _normalizeDescriptor(descriptor)
1454
+ const key = opts?.key ?? _descriptorKey(normalized)
1455
+ this._dataSubs.set(key, normalized)
1456
+ return this._flushSubscriptions()
1457
+ }
1458
+
1459
+ /**
1460
+ * Unsubscribe from data change notifications. `handle` is either the
1461
+ * key passed at subscribe-time or a descriptor equal-by-hash to the
1462
+ * original (e.g. the same array of xids).
1463
+ *
1464
+ * @param {string|string[]|{records: string[], operations?: string[]}} handle
1465
+ * @returns {Promise<{ok:boolean}>}
1466
+ */
1467
+ async unsubscribe(handle) {
1468
+ let key
1469
+ if (typeof handle === 'string' && !Array.isArray(handle)) {
1470
+ // String handle — treat as the key first; if no match, the caller
1471
+ // may have passed a single xid (degenerate case) — try that too.
1472
+ if (this._dataSubs.has(handle)) {
1473
+ key = handle
1474
+ } else {
1475
+ const fallback = _descriptorKey({ records: new Set([handle]) })
1476
+ if (this._dataSubs.has(fallback)) key = fallback
1477
+ }
1478
+ } else {
1479
+ const normalized = _normalizeDescriptor(handle)
1480
+ key = _descriptorKey(normalized)
1481
+ }
1482
+ if (key == null || !this._dataSubs.delete(key)) return { ok: true }
1483
+ return this._flushSubscriptions()
1484
+ }
1485
+
1486
+ /**
1487
+ * Subscribe to model deploy notifications. Two variants:
1488
+ * - runtime-model (default): augmented IAM-projected model, what the
1489
+ * data console renders against.
1490
+ * - deployed-model: raw deployed model (modeler).
1491
+ *
1492
+ * @param {Object} [opts]
1493
+ * @param {boolean} [opts.raw=false] - Subscribe to raw deployed-model
1494
+ * instead of runtime-model.
1495
+ * @returns {Promise<{ok:boolean}>}
1496
+ */
1497
+ async subscribeModel(opts) {
1498
+ const t = opts?.raw ? 'deployed-model' : 'runtime-model'
1499
+ this._modelSubs.add(t)
1500
+ return this._flushSubscriptions()
1501
+ }
1502
+
1503
+ /**
1504
+ * Unsubscribe from model deploy notifications.
1505
+ *
1506
+ * @param {Object} [opts]
1507
+ * @param {boolean} [opts.raw=false] - Unsubscribe from raw deployed-model
1508
+ * instead of runtime-model.
1509
+ * @returns {Promise<{ok:boolean}>}
1510
+ */
1511
+ async unsubscribeModel(opts) {
1512
+ const t = opts?.raw ? 'deployed-model' : 'runtime-model'
1513
+ if (!this._modelSubs.delete(t)) return { ok: true }
1514
+ return this._flushSubscriptions()
1515
+ }
1516
+
1517
+ /**
1518
+ * Replace the entire subscription set in one call — full-set semantics
1519
+ * matching the wire contract. Each `data` item must carry a
1520
+ * non-empty `records` array of xid strings. Existing local state is
1521
+ * replaced.
1522
+ *
1523
+ * Items optionally carry a `key` field — used as the local-mirror
1524
+ * handle for subsequent `unsubscribe(key)` calls. Without one, a
1525
+ * stable hash of the descriptor is used.
1526
+ *
1527
+ * @param {Array<Object>} items - Subscription items
1528
+ * @returns {Promise<{ok:boolean}>}
1529
+ *
1530
+ * @example
1531
+ * ```js
1532
+ * await client.setSubscriptions([
1533
+ * { type: 'data', records: ['u-abc', 'u-def'], operations: ['update'] },
1534
+ * { type: 'data', records: ['r-1'] },
1535
+ * { type: 'runtime-model' },
1536
+ * ])
1537
+ * ```
1538
+ */
1539
+ async setSubscriptions(items) {
1540
+ this._dataSubs.clear()
1541
+ this._modelSubs.clear()
1542
+ this._entitySubs.clear()
1543
+ this._relationSubs.clear()
1544
+ for (const item of items) {
1545
+ const t = item.type ?? 'data'
1546
+ if (t === 'data') {
1547
+ const normalized = _normalizeDescriptor({
1548
+ records: item.records,
1549
+ ...(item.operations !== undefined ? { operations: item.operations } : {}),
1550
+ })
1551
+ const key = item.key ?? _descriptorKey(normalized)
1552
+ this._dataSubs.set(key, normalized)
1553
+ } else if (t === 'entity') {
1554
+ if (!Array.isArray(item.entities)) {
1555
+ throw new SynthigyError(`entity subscription requires entities array`, 'INVALID_SUBSCRIPTION')
1556
+ }
1557
+ for (const e of item.entities) this._entitySubs.add(e)
1558
+ } else if (t === 'relation') {
1559
+ if (!Array.isArray(item.relations)) {
1560
+ throw new SynthigyError(`relation subscription requires relations array`, 'INVALID_SUBSCRIPTION')
1561
+ }
1562
+ for (const r of item.relations) this._relationSubs.add(r)
1563
+ } else if (t === 'deployed-model' || t === 'runtime-model') {
1564
+ this._modelSubs.add(t)
1565
+ } else {
1566
+ throw new SynthigyError(`Unsupported subscription type: ${t}`, 'UNSUPPORTED_TYPE')
1567
+ }
1568
+ }
1569
+ return this._flushSubscriptions()
1570
+ }
1571
+
1572
+ /**
1573
+ * Clear all subscriptions for this session.
1574
+ *
1575
+ * @returns {Promise<{ok:boolean}>}
1576
+ */
1577
+ async clearSubscriptions() {
1578
+ this._dataSubs.clear()
1579
+ this._modelSubs.clear()
1580
+ this._entitySubs.clear()
1581
+ this._relationSubs.clear()
1582
+ return this._flushSubscriptions()
1583
+ }
1584
+
1585
+ /**
1586
+ * Get current subscription status from the server.
1587
+ *
1588
+ * @returns {Promise<{ subscriptions: Array<Object> }>}
1589
+ */
1590
+ async subscriptions() {
1591
+ const resp = await this._fetchAuth(`${this._endpoint}/data/subscription/status`)
1592
+ if (!resp.ok) {
1593
+ const data = await resp.json().catch(() => ({}))
1594
+ throw new SynthigyError(
1595
+ data.error?.message || 'Subscription status failed',
1596
+ data.error?.code || 'HTTP_ERROR'
1597
+ )
1598
+ }
1599
+ return resp.json()
1600
+ }
1601
+
1602
+ // --------------------------------------------------------------------------
1603
+ // watch() — live data primitive (multiplexed, schema-resolved)
1604
+ //
1605
+ // One SSE per client; many `watch()` calls fuse onto the same connection
1606
+ // and the same consolidated `setSubscriptions` POST. Each Watch yields a
1607
+ // typed, attribute-name-keyed event stream. See watch.js for the design.
1608
+ // --------------------------------------------------------------------------
1609
+
1610
+ /**
1611
+ * Open a live subscription on a set of records (and/or relations).
1612
+ *
1613
+ * @param {{records?: string[], relations?: string[],
1614
+ * ops?: Array<'insert'|'update'|'delete'|'link'|'unlink'>}} interest
1615
+ * @param {{backpressure?: 'coalesce'|'lossless'|'sliding',
1616
+ * bufferSize?: number,
1617
+ * signal?: AbortSignal}} [opts]
1618
+ * @returns {Watch}
1619
+ */
1620
+ watch(interest, opts) {
1621
+ return _watch(this, interest, opts)
1622
+ }
1623
+
1624
+ /**
1625
+ * Open a separate stream of schema-deploy events.
1626
+ * @returns {SchemaWatch}
1627
+ */
1628
+ watchSchema() {
1629
+ return _watchSchema(this)
1630
+ }
1631
+
1632
+ /**
1633
+ * Live result-set for a query. Builds on `watch()`:
1634
+ * - snapshots via search
1635
+ * - watches the result xids + entity's relations
1636
+ * - on any event, coalesced re-run + diff against last result
1637
+ * - emits {query/added, query/changed, query/removed}
1638
+ *
1639
+ * `watchQuery.xsql(xsql, params, { entity, ... })` — XSQL variant.
1640
+ *
1641
+ * @returns {QueryWatch}
1642
+ */
1643
+ get watchQuery() {
1644
+ if (!this._watchQueryFn) {
1645
+ const self = this
1646
+ const fn = (entity, args, selection, opts) =>
1647
+ _watchQuery(self, entity, args, selection, opts)
1648
+ fn.xsql = (xsql, params, opts) =>
1649
+ _watchQuery.xsql(self, xsql, params, opts)
1650
+ this._watchQueryFn = fn
1651
+ }
1652
+ return this._watchQueryFn
1653
+ }
1654
+
1655
+ /**
1656
+ * Live raw-SQL result. Composes `sqlTemplate()` snapshot with
1657
+ * `watch()` subscription on the named entities' relation xids.
1658
+ *
1659
+ * Required: `opts.entities` — list of entity names the SQL reads
1660
+ * from. The SDK subscribes to those entities' relation xids so the
1661
+ * SQL re-runs when records of those entities are linked/unlinked.
1662
+ * For per-row updates on existing rows to trigger refresh, also
1663
+ * pass `opts.records: [...]` explicitly.
1664
+ *
1665
+ * @example
1666
+ * ```js
1667
+ * const w = client.watchSqlTemplate(
1668
+ * "SELECT count(*) AS n FROM {user_rating}", {},
1669
+ * { entities: ['user_rating'] })
1670
+ * await w.ready()
1671
+ * w.first.n // → 100981
1672
+ * for await (const ev of w.events) {
1673
+ * if (ev.type === 'result/changed') console.log(ev.after[0].n)
1674
+ * }
1675
+ * ```
1676
+ *
1677
+ * @param {string} template - SQL with ?name:type placeholders
1678
+ * @param {Object|Array} [params] - named or positional params
1679
+ * @param {Object} [opts]
1680
+ * @param {string[]} opts.entities - entity names the SQL reads from (REQUIRED)
1681
+ * @param {string[]} [opts.records] - extra record xids to watch (for update refresh)
1682
+ * @param {Object} [opts.searchOpts] - forwarded to sqlTemplate (actingAs, etc.)
1683
+ * @returns {SqlTemplateWatch}
1684
+ */
1685
+ watchSqlTemplate(template, params, opts) {
1686
+ return _watchSqlTemplate(this, template, params, opts)
1687
+ }
1688
+
1689
+ // --------------------------------------------------------------------------
1690
+ // Schema introspection
1691
+ // --------------------------------------------------------------------------
1692
+
1693
+ /**
1694
+ * Fetch the IAM-filtered model schema via `GET /schema`.
1695
+ *
1696
+ * @param {string[]} [entities] - Optional kebab-case entity names to include. Omit for full schema.
1697
+ * @returns {Promise<Object>} `{ 'id-key', entities }` — entity name → `{ name, attributes, relations, constraints }`
1698
+ *
1699
+ * @example
1700
+ * ```js
1701
+ * const schema = await client.schema()
1702
+ * // schema['id-key'] → 'xid'
1703
+ * // schema.entities['user'].attributes → { name: 'string', active: 'boolean' }
1704
+ * // schema.entities['user'].relations['roles'] → { to: 'user-role', cardinality: 'many' }
1705
+ *
1706
+ * const partial = await client.schema(['user', 'user-role'])
1707
+ * ```
1708
+ */
1709
+ async schema(entities) {
1710
+ const url = new URL(`${this._endpoint}/schema`)
1711
+ if (entities?.length) url.searchParams.set('entities', entities.join(','))
1712
+ const resp = await this._fetchAuth(url.toString())
1713
+ if (!resp.ok) {
1714
+ const data = await resp.json().catch(() => ({}))
1715
+ throw new SynthigyError(data.error?.message || 'Schema request failed', data.error?.code || 'HTTP_ERROR')
1716
+ }
1717
+ return resp.json()
1718
+ }
1719
+
1720
+ /**
1721
+ * Lint an XSQL source string against the IAM-projected schema.
1722
+ *
1723
+ * Returns an array of diagnostics. Each diagnostic carries
1724
+ * `severity` (`"error"` typically), `message`, byte-offset `from`/`to`
1725
+ * and 1-based `start`/`end` `{line, col}` for editor integration.
1726
+ *
1727
+ * `opts.entity` (kebab-case entity name) enables schema-aware checks
1728
+ * — unknown attributes, type mismatches, etc. When omitted, only
1729
+ * syntax-level and parameter-ref diagnostics are reported.
1730
+ *
1731
+ * @param {string} source - XSQL source
1732
+ * @param {Object} [opts]
1733
+ * @param {string} [opts.entity] - Root entity (kebab-case)
1734
+ * @param {'search'|'get'|'slice'|'purge'} [opts.op='search']
1735
+ * @returns {Promise<Array<{severity: string, message: string, from: number, to: number, start: {line: number, col: number}, end: {line: number, col: number}}>>}
1736
+ *
1737
+ * @example
1738
+ * ```js
1739
+ * const diags = await client.lint(`user (active = ?a:bool)\n name\n`,
1740
+ * { entity: 'user' })
1741
+ * if (diags.length) console.error(diags)
1742
+ * ```
1743
+ */
1744
+ async lint(source, opts) {
1745
+ const body = { source }
1746
+ if (opts?.entity) body.entity = opts.entity
1747
+ if (opts?.op) body.op = opts.op
1748
+ const resp = await this._fetchAuth(`${this._endpoint}/lint`, {
1749
+ method: 'POST',
1750
+ headers: { 'Content-Type': 'application/json' },
1751
+ body: JSON.stringify(body),
1752
+ })
1753
+ if (!resp.ok) {
1754
+ const data = await resp.json().catch(() => ({}))
1755
+ throw new SynthigyError(
1756
+ data.error?.message || `Lint request failed (${resp.status})`,
1757
+ data.error?.code || 'HTTP_ERROR'
1758
+ )
1759
+ }
1760
+ const json = await resp.json()
1761
+ return json.diagnostics ?? []
1762
+ }
1763
+
1764
+ /**
1765
+ * Mint a one-time account-claim link via `POST /oauth/onboard`.
1766
+ * Confidential client whose principal administers the account — RBAC
1767
+ * update on User plus the row inside its owner-group write scope, which
1768
+ * the shipped User Provisioner role grants — else `PROVISION_FORBIDDEN`.
1769
+ * This client's own client_credentials identity IS that principal, so no
1770
+ * separate credential is needed here.
1771
+ *
1772
+ * `xid` addresses an EXISTING account — onboarding no longer creates
1773
+ * accounts; create it first via `sync('iam/user', ...)` (with person_info,
1774
+ * roles, groups in one tree), then mint a ticket for it. A blank xid fails
1775
+ * with `XID_REQUIRED`; one that doesn't resolve fails with
1776
+ * `USER_NOT_FOUND`. `opts.reset` soft-recycles the account first (strips
1777
+ * its federated identities, nulls its password, revokes its live
1778
+ * sessions/tokens — leaves `active` untouched) before minting.
1779
+ * `opts.methods` restricts which claim methods the page offers (e.g.
1780
+ * `['password']`); omitted allows every active federation provider plus
1781
+ * password.
1782
+ *
1783
+ * @param {string} xid
1784
+ * @param {Object} [opts]
1785
+ * @param {boolean} [opts.reset]
1786
+ * @param {string[]} [opts.methods]
1787
+ * @param {number} [opts.ttlSeconds]
1788
+ * @param {string} [opts.returnUrl] Where a successful DIRECT (browser) claim
1789
+ * redirects instead of Synthigy's generic status page. Must match one of
1790
+ * THIS client's registered redirections (or be a loopback URI).
1791
+ * @returns {Promise<{onboard_url: string, expires_at: number, user: {xid: string}}>}
1792
+ *
1793
+ * @example
1794
+ * ```js
1795
+ * const { xid } = await client.sync('iam/user', { name: 'alice@example.com', active: false })
1796
+ * const { onboard_url } = await client.onboard(xid, { methods: ['password'] })
1797
+ * // email onboard_url to alice yourself — the SDK doesn't send it
1798
+ * ```
1799
+ */
1800
+ async onboard(xid, opts) {
1801
+ const body = { xid }
1802
+ if (opts?.reset !== undefined) body.reset = opts.reset
1803
+ if (opts?.methods) body.methods = opts.methods
1804
+ if (opts?.ttlSeconds !== undefined) body.ttl_seconds = opts.ttlSeconds
1805
+ if (opts?.returnUrl !== undefined) body.return_url = opts.returnUrl
1806
+ const resp = await this._fetchAuth(`${this._endpoint}/oauth/onboard`, {
1807
+ method: 'POST',
1808
+ headers: { 'Content-Type': 'application/json' },
1809
+ body: JSON.stringify(body),
1810
+ })
1811
+ if (!resp.ok) {
1812
+ throw await onboardError(resp)
1813
+ }
1814
+ return resp.json()
1815
+ }
1816
+
1817
+ /**
1818
+ * Redeem an onboarding ticket without a browser via
1819
+ * `POST /oauth/onboard/complete` — the indirect face of the SAME ticket
1820
+ * `onboard()` mints. Must be called by the SAME client that minted the
1821
+ * ticket; any other client's bearer is rejected with `CLAIM_INVALID`, and
1822
+ * the client must still administer the account (`PROVISION_FORBIDDEN`).
1823
+ *
1824
+ * The caller runs its own out-of-band proofing (email link, SMS OTP, push
1825
+ * approval, KYC, a phone call — Synthigy never learns which) and, once
1826
+ * satisfied, redeems the ticket itself instead of bouncing the user's
1827
+ * browser through `/oauth/claim`. This never sets a credential —
1828
+ * credentials are subject-only. The account activates with none; give it
1829
+ * one via the claim page (password or a federated identity) or a later
1830
+ * ticket.
1831
+ *
1832
+ * @param {string} ticket
1833
+ * @returns {Promise<{user: {xid: string}, active: boolean}>}
1834
+ */
1835
+ async onboardComplete(ticket) {
1836
+ const resp = await this._fetchAuth(`${this._endpoint}/oauth/onboard/complete`, {
1837
+ method: 'POST',
1838
+ headers: { 'Content-Type': 'application/json' },
1839
+ body: JSON.stringify({ ticket }),
1840
+ })
1841
+ if (!resp.ok) {
1842
+ throw await onboardError(resp)
1843
+ }
1844
+ return resp.json()
1845
+ }
1846
+
1847
+ /**
1848
+ * Fetch the raw deployed ERD model — the modeler-authored shape,
1849
+ * without system attrs (xid/euuid/audit) or reference-as-relation
1850
+ * expansion. This is what the modeler/canvas reads. Most SDK
1851
+ * consumers want `runtimeModel()` instead.
1852
+ *
1853
+ * Requires `dataset:load` scope.
1854
+ *
1855
+ * Server returns this as a transit-encoded JSON string.
1856
+ *
1857
+ * @param {Object} [opts] - Options ({ actingAs })
1858
+ * @returns {Promise<Object|string>} Full ERD model
1859
+ */
1860
+ async deployedModel(opts) {
1861
+ const [result] = await this.exec([{ op: 'deployed-model' }], opts)
1862
+ if (!result.ok) throw _errorFromServer(result.error, undefined, result._requestId)
1863
+ return result.data
1864
+ }
1865
+
1866
+ /**
1867
+ * Fetch the runtime ERD model — the deployed model augmented with:
1868
+ * - `xid` / `euuid` identity attrs on every entity
1869
+ * - audit attrs (created-on / modified-on / created-by / modified-by)
1870
+ * based on each entity's audit configuration
1871
+ * - reference-typed attrs surfaced as first-class relations
1872
+ *
1873
+ * This is what the data console reads, and what the SDK's watch
1874
+ * resolver uses to map attr-xid → name for plug events. Use
1875
+ * this rather than `deployedModel()` if you want to see what the
1876
+ * running system actually emits (including audit timestamps).
1877
+ *
1878
+ * Requires `dataset:load` scope.
1879
+ *
1880
+ * Server returns this as a transit-encoded JSON string.
1881
+ *
1882
+ * @param {Object} [opts] - Options ({ actingAs })
1883
+ * @returns {Promise<Object|string>} Runtime ERD model
1884
+ */
1885
+ async runtimeModel(opts) {
1886
+ const [result] = await this.exec([{ op: 'runtime-model' }], opts)
1887
+ if (!result.ok) throw _errorFromServer(result.error, undefined, result._requestId)
1888
+ return result.data
1889
+ }
1890
+
1891
+ // --------------------------------------------------------------------------
1892
+ // History — temporal query surface (POST /history)
1893
+ //
1894
+ // Five ops over the audit plug: get-at, events, diff, timeline,
1895
+ // since. Endpoint returns 404 when no audit provider is loaded in
1896
+ // the deployment.
1897
+ // --------------------------------------------------------------------------
1898
+
1899
+ /** @private */
1900
+ async _historyPost(op, opts) {
1901
+ const resp = await this._fetchAuth(`${this._endpoint}/history`, {
1902
+ method: 'POST',
1903
+ headers: { 'Content-Type': 'application/json' },
1904
+ body: JSON.stringify({ op, opts }),
1905
+ })
1906
+ if (resp.status === 404) {
1907
+ throw new SynthigyError(
1908
+ 'History endpoint unavailable — no audit provider configured on the server',
1909
+ 'HISTORY_UNAVAILABLE'
1910
+ )
1911
+ }
1912
+ if (!resp.ok) {
1913
+ const data = await resp.json().catch(() => ({}))
1914
+ throw new SynthigyError(
1915
+ data.error?.message || `History op '${op}' failed (${resp.status})`,
1916
+ data.error?.code || 'HTTP_ERROR'
1917
+ )
1918
+ }
1919
+ const body = await resp.json()
1920
+ return body.result
1921
+ }
1922
+
1923
+ // Defined as a regular property so the .d.ts namespace `client.history.foo`
1924
+ // shape works at edit time.
1925
+ get history() {
1926
+ if (!this._history) {
1927
+ const self = this
1928
+ this._history = {
1929
+ /**
1930
+ * State of a record at timestamp T as an attribute map.
1931
+ */
1932
+ async getAt(recordXid, at, opts) {
1933
+ return self._historyPost('get-at', {
1934
+ 'record-xid': recordXid,
1935
+ at,
1936
+ ...(opts?.tenant !== undefined && { tenant: opts.tenant }),
1937
+ ...(opts?.includeDeleted !== undefined && {
1938
+ 'include-deleted?': opts.includeDeleted,
1939
+ }),
1940
+ })
1941
+ },
1942
+
1943
+ /**
1944
+ * Events for a record (or any record) over a time range.
1945
+ *
1946
+ * The server requires an upper time bound; when `between` is omitted
1947
+ * we default it to `[null, now]` so `events()` means "recent events up
1948
+ * to now" (the server still defaults the lower bound to now − 30d).
1949
+ */
1950
+ async events(opts) {
1951
+ return self._historyPost('events', {
1952
+ ...(opts?.recordXid !== undefined && { 'record-xid': opts.recordXid }),
1953
+ between: opts?.between ?? [null, new Date().toISOString()],
1954
+ ...(opts?.tenant !== undefined && { tenant: opts.tenant }),
1955
+ ...(opts?.limit !== undefined && { limit: opts.limit }),
1956
+ ...(opts?.track !== undefined && { track: opts.track }),
1957
+ })
1958
+ },
1959
+
1960
+ /**
1961
+ * Diff a record's state between two timestamps.
1962
+ */
1963
+ async diff(recordXid, fromTs, toTs, opts) {
1964
+ return self._historyPost('diff', {
1965
+ 'record-xid': recordXid,
1966
+ 'from-ts': fromTs,
1967
+ 'to-ts': toTs,
1968
+ ...(opts?.tenant !== undefined && { tenant: opts.tenant }),
1969
+ })
1970
+ },
1971
+
1972
+ /**
1973
+ * Events grouped by :request, :actor, or :scope.
1974
+ *
1975
+ * Defaults the upper time bound to "now" when `between` is omitted
1976
+ * (same contract as `events`).
1977
+ */
1978
+ async timeline(opts) {
1979
+ return self._historyPost('timeline', {
1980
+ between: opts?.between ?? [null, new Date().toISOString()],
1981
+ ...(opts?.groupBy !== undefined && { 'group-by': opts.groupBy }),
1982
+ ...(opts?.tenant !== undefined && { tenant: opts.tenant }),
1983
+ ...(opts?.limit !== undefined && { limit: opts.limit }),
1984
+ })
1985
+ },
1986
+
1987
+ /**
1988
+ * Events strictly after cursor timestamp T, oldest-first.
1989
+ */
1990
+ async since(opts) {
1991
+ return self._historyPost('since', {
1992
+ ...(opts?.cursor !== undefined && { cursor: opts.cursor }),
1993
+ ...(opts?.tenant !== undefined && { tenant: opts.tenant }),
1994
+ ...(opts?.limit !== undefined && { limit: opts.limit }),
1995
+ ...(opts?.track !== undefined && { track: opts.track }),
1996
+ })
1997
+ },
1998
+ }
1999
+ }
2000
+ return this._history
2001
+ }
2002
+
2003
+ // --------------------------------------------------------------------------
2004
+ // Subscriptions — SSE listener
2005
+ // --------------------------------------------------------------------------
2006
+
2007
+ /**
2008
+ * Open an SSE connection to `/data/events` and yield change notifications.
2009
+ *
2010
+ * Events are notification-only (no data payload). Fetch the updated records
2011
+ * via `exec`/`search`/`get` after receiving a notification.
2012
+ *
2013
+ * Yields objects: `{ type: 'change'|'delete', entity, relations?, xids? }`
2014
+ *
2015
+ * The async generator runs until the connection drops or the caller breaks.
2016
+ * Subscriptions persist across reconnects — call `subscribe()` once at
2017
+ * startup; the server remembers which entities this client watches.
2018
+ *
2019
+ * @param {Object} [opts]
2020
+ * @param {AbortSignal} [opts.signal] - Cancel the listener
2021
+ * @yields {{ type: string, entity: string, relations?: string[], xids?: string[] }}
2022
+ *
2023
+ * @example
2024
+ * ```js
2025
+ * await client.subscribe('User')
2026
+ * for await (const event of client.listen()) {
2027
+ * const users = await client.search('User', {}, { name: null })
2028
+ * render(users)
2029
+ * }
2030
+ * ```
2031
+ */
2032
+ /**
2033
+ * One SSE session against `/data/events`. Yields parsed event frames
2034
+ * as `{ _sseEvent, _sseId, ...payload }` — the payload is the SSE
2035
+ * `data:` line parsed as JSON, plus metadata fields with `_sse`
2036
+ * prefix so they can't collide with payload keys. Caller handles
2037
+ * reconnect (listen() runs an infinite reconnect loop around this;
2038
+ * observe() also re-subscribes between sessions because the server
2039
+ * tears down subscription state on SSE close).
2040
+ * @private
2041
+ */
2042
+ async *_sseSession(opts) {
2043
+ const signal = opts?.signal
2044
+ const headers = { Accept: 'text/event-stream' }
2045
+ if (opts?.lastEventId) headers['Last-Event-ID'] = opts.lastEventId
2046
+
2047
+ const resp = await this._fetchAuth(
2048
+ `${this._endpoint}/data/events`,
2049
+ { headers, signal },
2050
+ { applyTimeout: false }
2051
+ )
2052
+ if (!resp.ok) {
2053
+ const text = await resp.text().catch(() => '')
2054
+ throw new SynthigyError(`SSE connect failed (${resp.status})`, 'HTTP_ERROR', text)
2055
+ }
2056
+ if (!resp.body) throw new SynthigyError('SSE response has no body', 'HTTP_ERROR')
2057
+
2058
+ // Headers received = server has created the event stream session.
2059
+ // Emit a synthetic 'open' sentinel so callers can POST /subscription/set
2060
+ // here (and only here) — POSTing before this point races the
2061
+ // server's stream creation and silently drops subscriptions.
2062
+ yield { _sseEvent: 'open' }
2063
+
2064
+ const reader = resp.body.getReader()
2065
+ const decoder = new TextDecoder()
2066
+ let buf = ''
2067
+ let eventType = 'message'
2068
+ let dataLines = []
2069
+ let eventId = null
2070
+
2071
+ try {
2072
+ while (true) {
2073
+ const { done, value } = await reader.read()
2074
+ if (done) break
2075
+ buf += decoder.decode(value, { stream: true })
2076
+ const lines = buf.split('\n')
2077
+ buf = lines.pop()
2078
+
2079
+ for (const line of lines) {
2080
+ if (line.startsWith('event:')) {
2081
+ eventType = line.slice(6).trim()
2082
+ } else if (line.startsWith('data:')) {
2083
+ dataLines.push(line.slice(5).trim())
2084
+ } else if (line.startsWith('id:')) {
2085
+ eventId = line.slice(3).trim()
2086
+ } else if (line === '') {
2087
+ if (dataLines.length > 0) {
2088
+ try {
2089
+ const payload = JSON.parse(dataLines.join('\n'))
2090
+ // Underscore-prefixed metadata so it can't collide with
2091
+ // payload keys (the Option-B channel envelope has its
2092
+ // own `type` field).
2093
+ yield { _sseEvent: eventType, _sseId: eventId, ...payload }
2094
+ } catch { /* ignore malformed frames */ }
2095
+ }
2096
+ eventType = 'message'
2097
+ dataLines = []
2098
+ eventId = null
2099
+ }
2100
+ }
2101
+ }
2102
+ } finally {
2103
+ // `reader.cancel()` returns a promise that REJECTS when the stream was
2104
+ // aborted (the common teardown path). `try/catch` only traps a sync
2105
+ // throw, so the rejected promise would dangle as an unhandled rejection.
2106
+ // Attach a no-op `.catch` to swallow it — an aborted SSE is a clean stop.
2107
+ try { reader.cancel().catch(() => {}) } catch { /* ignore */ }
2108
+ }
2109
+ }
2110
+
2111
+ async *listen(opts) {
2112
+ const signal = opts?.signal
2113
+ let delay = 1000
2114
+ let lastEventId = null
2115
+
2116
+ while (!signal?.aborted) {
2117
+ try {
2118
+ for await (const event of this._sseSession({ signal, lastEventId })) {
2119
+ if (event._sseId) lastEventId = event._sseId
2120
+ delay = 1000
2121
+ // Surface the synthetic 'open' sentinel as `{ type: 'sse/open' }`
2122
+ // so the multiplexer can POST /subscription/set only after the
2123
+ // server has created the event stream (otherwise the POST races
2124
+ // stream creation and the subscription is silently lost).
2125
+ if (event._sseEvent === 'open') {
2126
+ yield { type: 'sse/open' }
2127
+ continue
2128
+ }
2129
+ // Strip internal SSE metadata before handing the payload to
2130
+ // callers. listen() exposes the channel-shaped event verbatim
2131
+ // — for Option B that means the full delta envelope.
2132
+ const { _sseEvent, _sseId, ...payload } = event
2133
+ yield payload
2134
+ }
2135
+ } catch (e) {
2136
+ if (signal?.aborted || e?.name === 'AbortError') return
2137
+ if (e?.code === 'UNAUTHORIZED' || e?.code === 'FORBIDDEN') throw e
2138
+ await new Promise(r => setTimeout(r, delay))
2139
+ delay = Math.min(delay * 2, 30000)
2140
+ }
2141
+ }
2142
+ }
2143
+
2144
+ // --------------------------------------------------------------------------
2145
+ // observe(descriptor) — one-call live primitive
2146
+ //
2147
+ // Bundles subscribe + SSE + auto-reconnect + (opt-in) history backfill into
2148
+ // a single async iterable. The server tears down subscription state on
2149
+ // SSE close, so observe() re-subscribes before each new SSE session.
2150
+ // --------------------------------------------------------------------------
2151
+
2152
+ /**
2153
+ * Subscribe to a record-set and yield change events as an async iterable.
2154
+ *
2155
+ * The descriptor identifies which records to watch — either a bare
2156
+ * array of xids or `{records, operations?}`. Record events fire when
2157
+ * an envelope's `record-xid` is in the set; relation events fire when
2158
+ * either endpoint of a relation link/unlink is in the set (server
2159
+ * rotates the wire so `data[0]` is always the subscribed endpoint).
2160
+ *
2161
+ * Each event is the record-shaped envelope: record
2162
+ * events carry `before` / `after` attribute maps (already keyed by
2163
+ * attribute key, not attribute-xid); relation events carry a `data`
2164
+ * tuple `[subscribed-xid, other-xid]`. Both carry `actor` / `txid` /
2165
+ * `request` / `scope` / `tenant` provenance as xids — resolve on
2166
+ * demand if you need names.
2167
+ *
2168
+ * On each SSE session this:
2169
+ * 1. POSTs `/data/subscription/set` with this descriptor (set-replace).
2170
+ * 2. Opens a single SSE session via `/data/events`.
2171
+ * 3. Yields events whose record-xid OR data[0] is in the set.
2172
+ * 4. On disconnect: optionally backfills missed events from
2173
+ * `/history events`, then re-subscribes and reconnects.
2174
+ *
2175
+ * Cleanup on iterator close: unsubscribes this descriptor.
2176
+ *
2177
+ * @param {string[]|{records: string[], operations?: string[]}} descriptor
2178
+ * @param {Object} [opts]
2179
+ * @param {string} [opts.key] - Stable subscription handle; defaults to
2180
+ * a hash of the descriptor.
2181
+ * @param {AbortSignal} [opts.signal] - Cancel the loop
2182
+ * @param {boolean} [opts.backfill=false] - Replay /history events
2183
+ * missed during disconnect.
2184
+ * @param {number} [opts.backfillLimit=1000] - Max history rows per
2185
+ * backfill pass.
2186
+ * @yields {ObserveEvent} — see ObserveEvent in index.d.ts for the
2187
+ * discriminated-union shape.
2188
+ *
2189
+ * @example
2190
+ * ```js
2191
+ * for await (const ev of client.observe(['u-abc'])) {
2192
+ * if (ev.type === 'record/update') diffRender(ev.before, ev.after)
2193
+ * }
2194
+ *
2195
+ * for await (const ev of client.observe({
2196
+ * records: [userXid, teamXid],
2197
+ * operations: ['update', 'link'],
2198
+ * })) { ... }
2199
+ * ```
2200
+ */
2201
+ async *observe(descriptor, opts) {
2202
+ const normalized = _normalizeDescriptor(descriptor)
2203
+ const key = opts?.key ?? _descriptorKey(normalized)
2204
+ const signal = opts?.signal
2205
+ const backfillEnabled = opts?.backfill === true
2206
+ const backfillLimit = opts?.backfillLimit ?? 1000
2207
+ let lastSeenTs = null
2208
+ let delay = 1000
2209
+
2210
+ try {
2211
+ while (!signal?.aborted) {
2212
+ try {
2213
+ // Server tears subscription state down on SSE close, so we
2214
+ // re-register the descriptor before each new session.
2215
+ this._dataSubs.set(key, normalized)
2216
+ await this._flushSubscriptions()
2217
+
2218
+ for await (const raw of this._sseSession({ signal })) {
2219
+ // `raw` is the channel payload plus _sseEvent / _sseId
2220
+ // metadata. Strip the metadata; emit the channel envelope.
2221
+ const { _sseEvent: _evt, _sseId: _id, ...payload } = raw
2222
+ // Only entity/relation channel events have a slash-typed
2223
+ // type; anything else (heartbeats, future event kinds) is
2224
+ // skipped.
2225
+ const channelType = payload.type
2226
+ if (typeof channelType !== 'string' || !channelType.includes('/')) continue
2227
+ // Defense-in-depth filter: plug already gates by records;
2228
+ // double-check here so multiple parallel observe()s on one
2229
+ // client don't cross-deliver.
2230
+ if (!_eventMatchesDescriptor(payload, normalized)) continue
2231
+ if (payload.ts) lastSeenTs = payload.ts
2232
+ delay = 1000
2233
+ yield payload
2234
+ }
2235
+ } catch (e) {
2236
+ if (signal?.aborted || e?.name === 'AbortError') return
2237
+ if (e?.code === 'UNAUTHORIZED' || e?.code === 'FORBIDDEN') throw e
2238
+ }
2239
+
2240
+ if (signal?.aborted) return
2241
+
2242
+ if (backfillEnabled && lastSeenTs) {
2243
+ try {
2244
+ const now = new Date().toISOString()
2245
+ const events = await this.history.events({
2246
+ between: [lastSeenTs, now],
2247
+ track: 'entity',
2248
+ limit: backfillLimit,
2249
+ })
2250
+ // /history events are attribute-primary (one row per changed
2251
+ // attribute). Fold into one observe event per (record, op)
2252
+ // and reconstruct a partial `after` map from the rows. The
2253
+ // shape is best-effort: not as rich as live, but a faithful
2254
+ // diff for the rows that landed in __audit_entity.
2255
+ const byKey = new Map() // `${recordXid}::${op}` → folded event
2256
+ for (const ev of events) {
2257
+ const xid = ev.recordXid ?? ev['record-xid']
2258
+ if (!xid) continue
2259
+ // Skip backfill rows for records outside our watch set.
2260
+ if (!normalized.records.has(xid)) continue
2261
+ const op = ev.op
2262
+ const foldKey = `${xid}::${op}`
2263
+ const channelType = `record/${op === 'change' ? 'update' : op}`
2264
+ let folded = byKey.get(foldKey)
2265
+ if (!folded) {
2266
+ folded = {
2267
+ type: channelType,
2268
+ ts: ev.ts,
2269
+ 'record-xid': xid,
2270
+ txid: ev.txid,
2271
+ actor: ev.actorXid ?? ev['actor-xid'],
2272
+ request: ev.requestId ?? ev['request-id'],
2273
+ scope: ev.scopeXid ?? ev['scope-xid'],
2274
+ after: {},
2275
+ fromBackfill: true,
2276
+ }
2277
+ byKey.set(foldKey, folded)
2278
+ }
2279
+ // Backfill rows are attribute-primary and key by attribute-xid;
2280
+ // live events come in attribute-key-keyed (server-translated).
2281
+ // We leave backfill as-is — consumer can detect via `fromBackfill`
2282
+ // and either translate or use as-is. Live events never carry
2283
+ // this flag.
2284
+ const attrXid = ev.attributeXid ?? ev['attribute-xid']
2285
+ if (attrXid && attrXid !== '__delete__') {
2286
+ folded.after[attrXid] = ev.value
2287
+ }
2288
+ if (ev.ts > (folded.ts ?? '')) folded.ts = ev.ts
2289
+ }
2290
+ for (const folded of byKey.values()) {
2291
+ if (folded.type === 'record/delete') delete folded.after
2292
+ if (folded.ts) lastSeenTs = folded.ts
2293
+ yield folded
2294
+ }
2295
+ } catch (_e) {
2296
+ // HISTORY_UNAVAILABLE / 404 / etc. — best-effort.
2297
+ }
2298
+ }
2299
+
2300
+ await new Promise((r) => setTimeout(r, delay))
2301
+ delay = Math.min(delay * 2, 30000)
2302
+ }
2303
+ } finally {
2304
+ if (this._dataSubs.delete(key)) {
2305
+ await this._flushSubscriptions().catch(() => {})
2306
+ }
2307
+ }
2308
+ }
2309
+ }
2310
+
2311
+ /**
2312
+ * True if the channel-shaped event matches the observer's descriptor.
2313
+ * Record events match by record-xid; relation events match on `data[0]`
2314
+ * (server already rotated so position 0 is the subscribed-perspective
2315
+ * endpoint — when both endpoints sat in the identity's union the server
2316
+ * emitted two events, one per perspective, so per-observer we just check
2317
+ * position 0). Used by observe() to demultiplex when multiple observe()
2318
+ * calls share one client's SSE session.
2319
+ *
2320
+ * @private
2321
+ */
2322
+ function _eventMatchesDescriptor(event, descriptor) {
2323
+ const records = descriptor.records
2324
+ if (!records || records.size === 0) return false
2325
+ const recordXid = event['record-xid']
2326
+ if (recordXid) return records.has(recordXid)
2327
+ if (Array.isArray(event.data) && event.data.length >= 2) {
2328
+ return records.has(event.data[0])
2329
+ }
2330
+ return false
2331
+ }
2332
+
2333
+ // ============================================================================
2334
+ // Client-minted identity
2335
+ // ============================================================================
2336
+
2337
+ const BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
2338
+
2339
+ /**
2340
+ * A fresh 22-char Base58 xid — client-minted identity for sync/stack, the
2341
+ * same derivation the server uses (UUID bytes → base58, left-padded with '1').
2342
+ *
2343
+ * Mint one before a write to know a record's id up front, or to make a retried
2344
+ * write idempotent — the server accepts a caller-supplied id as-is, and the
2345
+ * alternative (`returning: true`) costs the full echo on every write.
2346
+ *
2347
+ * @returns {string} 22-character Base58 xid
2348
+ */
2349
+ export function newXid() {
2350
+ const bytes = crypto.getRandomValues(new Uint8Array(16))
2351
+ // UUIDv4 bit layout, so the value round-trips the server's uuid->nanoid.
2352
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
2353
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
2354
+ // Schoolbook base-256 → base-58, the same conversion Bitcoin-style base58
2355
+ // libraries use.
2356
+ const digits = [0]
2357
+ for (const b of bytes) {
2358
+ let carry = b
2359
+ for (let i = 0; i < digits.length; i++) {
2360
+ const x = digits[i] * 256 + carry
2361
+ digits[i] = x % 58
2362
+ carry = Math.floor(x / 58)
2363
+ }
2364
+ while (carry > 0) {
2365
+ digits.push(carry % 58)
2366
+ carry = Math.floor(carry / 58)
2367
+ }
2368
+ }
2369
+ const s = digits.reverse().map(d => BASE58[d]).join('')
2370
+ return '1'.repeat(Math.max(0, 22 - s.length)) + s
2371
+ }
2372
+
2373
+ // ============================================================================
2374
+ // Operation builders — for use with exec()
2375
+ // ============================================================================
2376
+
2377
+ export const op = {
2378
+ search: (entity, args, selection) => ({ op: 'search', entity, args, selections: normalizeSelection(selection) }),
2379
+ get: (entity, args, selection) => ({ op: 'get', entity, args, selections: normalizeSelection(selection) }),
2380
+ sync: (entity, data, returning) => ({ op: 'sync', entity, data, returning: returning === true }),
2381
+ stack: (entity, data, returning) => ({ op: 'stack', entity, data, returning: returning === true }),
2382
+ slice: (entity, args, selection) => ({ op: 'slice', entity, args, selections: normalizeSelection(selection) }),
2383
+ delete: (entity, data) => ({ op: 'delete', entity, data }),
2384
+ purge: (entity, args, selection) => ({ op: 'purge', entity, args, selections: normalizeSelection(selection) }),
2385
+ searchTree: (entity, on, args, sel) => ({ op: 'search-tree', entity, on, args, selections: normalizeSelection(sel) }),
2386
+ getTree: (entity, root, on, sel) => ({ op: 'get-tree', entity, root, on, selections: normalizeSelection(sel) }),
2387
+ sqlTemplate: (template, params) => ({ op: 'sql-template', template, params: params || [] }),
2388
+ deployedModel: () => ({ op: 'deployed-model' }),
2389
+ runtimeModel: () => ({ op: 'runtime-model' }),
2390
+ }
2391
+
2392
+ // ============================================================================
2393
+ // Factory
2394
+ // ============================================================================
2395
+
2396
+ /**
2397
+ * Create a Synthigy client.
2398
+ *
2399
+ * @param {Object} config
2400
+ * @param {string} config.endpoint - Synthigy base URL
2401
+ * @param {string} [config.clientId] - OAuth client ID
2402
+ * @param {string} [config.clientSecret] - OAuth client secret
2403
+ * @param {string} [config.token] - Static bearer token (alternative to client credentials)
2404
+ * @param {string} [config.scope] - OAuth scopes
2405
+ * @returns {SynthigyClient}
2406
+ *
2407
+ * @example
2408
+ * ```js
2409
+ * // Client credentials (service-to-service)
2410
+ * const client = createClient({
2411
+ * endpoint: 'https://synthigy.example.com',
2412
+ * clientId: 'my-service',
2413
+ * clientSecret: process.env.SYNTHIGY_CLIENT_SECRET,
2414
+ * })
2415
+ *
2416
+ * // Per-request user context
2417
+ * const users = await client.search('iam/user', {}, { name: null },
2418
+ * { actingAs: req.session.userEuuid })
2419
+ *
2420
+ * // Static token (testing, scripts)
2421
+ * const client = createClient({
2422
+ * endpoint: 'https://synthigy.example.com',
2423
+ * token: 'my-access-token'
2424
+ * })
2425
+ * ```
2426
+ */
2427
+ export function createClient(config) {
2428
+ return new SynthigyClient(config)
2429
+ }
2430
+
2431
+ // ---------------------------------------------------------------------------
2432
+ // Single-client layer — the JS analog of the Go SDK's global.go and the
2433
+ // Clojure SDK's process-wide `*client*` dynvar.
2434
+ //
2435
+ // THE INVARIANT: one process, one client, one backend. A service connects to
2436
+ // Synthigy as exactly ONE OAuth client — identity is multiplexed per-call with
2437
+ // `opts.actingAs`, never a second `connect`. `connect` installs a module-wide
2438
+ // default and the bare verbs below (search, sync, watch, …) operate on it, so
2439
+ // callers never thread a client.
2440
+ //
2441
+ // The escape hatch is `createClient` itself: construct a client and call its
2442
+ // methods directly (tests, a rare second endpoint) — the analog of Clojure's
2443
+ // `binding *client*`.
2444
+ // ---------------------------------------------------------------------------
2445
+
2446
+ let _defaultClient = null
2447
+
2448
+ /**
2449
+ * Create a client from `config` and install it as the module-wide default —
2450
+ * the server-restart idiom: any PREVIOUS default is destroyed first (its live
2451
+ * watches close, its SSE session drops) before the new one replaces it. Call
2452
+ * once at startup; call again to reconnect.
2453
+ * @param {Object} config - same shape as {@link createClient}
2454
+ * @returns {SynthigyClient} the installed client (for introspection)
2455
+ */
2456
+ export function connect(config) {
2457
+ const prev = _defaultClient
2458
+ _defaultClient = new SynthigyClient(config)
2459
+ if (prev) prev.close() // destroy the old one, server-restart style
2460
+ return _defaultClient
2461
+ }
2462
+
2463
+ /**
2464
+ * Destroy the current default client — closing every live watch and dropping
2465
+ * the SSE session — and uninstall it. No-op when not connected. `connect`
2466
+ * calls this on the previous client automatically.
2467
+ */
2468
+ export function disconnect() {
2469
+ const prev = _defaultClient
2470
+ _defaultClient = null
2471
+ if (prev) prev.close()
2472
+ }
2473
+
2474
+ /**
2475
+ * The module-wide client installed by `connect`, or null when not connected.
2476
+ * The bare verbs already operate on it; this is for introspection or handing
2477
+ * the client to code that needs an explicit instance.
2478
+ * @returns {SynthigyClient|null}
2479
+ */
2480
+ export function getClient() {
2481
+ return _defaultClient
2482
+ }
2483
+
2484
+ /** Resolve the default client or throw — using the SDK before `connect`. */
2485
+ function _dflt() {
2486
+ if (!_defaultClient) {
2487
+ throw new SynthigyError('not connected — call connect() first', 'NOT_CONNECTED')
2488
+ }
2489
+ return _defaultClient
2490
+ }
2491
+
2492
+ // CRUD + XSQL
2493
+ export const search = (...a) => _dflt().search(...a)
2494
+ export const get = (...a) => _dflt().get(...a)
2495
+ export const sync = (...a) => _dflt().sync(...a)
2496
+ export const stack = (...a) => _dflt().stack(...a)
2497
+ export const slice = (...a) => _dflt().slice(...a)
2498
+ export const purge = (...a) => _dflt().purge(...a)
2499
+ export const sqlTemplate = (...a) => _dflt().sqlTemplate(...a)
2500
+ export const query = (...a) => _dflt().query(...a)
2501
+ export const searchTree = (...a) => _dflt().searchTree(...a)
2502
+ export const getTree = (...a) => _dflt().getTree(...a)
2503
+ export const exec = (...a) => _dflt().exec(...a)
2504
+ // `delete` is a reserved word — no top-level binding of that name is legal, so
2505
+ // it's exported under the `delete` key via aliasing. Reach it with a namespace
2506
+ // import (`synthigy.delete(...)`) or `import { delete as del }`.
2507
+ const _delete = (...a) => _dflt().delete(...a)
2508
+ export { _delete as delete }
2509
+
2510
+ // Schema / introspection
2511
+ export const schema = (...a) => _dflt().schema(...a)
2512
+ export const lint = (...a) => _dflt().lint(...a)
2513
+ export const onboard = (...a) => _dflt().onboard(...a)
2514
+ export const onboardComplete = (...a) => _dflt().onboardComplete(...a)
2515
+ export const deployedModel = (...a) => _dflt().deployedModel(...a)
2516
+ export const runtimeModel = (...a) => _dflt().runtimeModel(...a)
2517
+ export const token = (...a) => _dflt().token(...a)
2518
+ export const history = () => _dflt().history
2519
+
2520
+ // Streaming + live watches
2521
+ export const listen = (...a) => _dflt().listen(...a)
2522
+ export const observe = (...a) => _dflt().observe(...a)
2523
+ export const watch = (...a) => _dflt().watch(...a)
2524
+ export const watchSchema = (...a) => _dflt().watchSchema(...a)
2525
+ export const watchQuery = (...a) => _dflt().watchQuery(...a)
2526
+ export const watchSqlTemplate = (...a) => _dflt().watchSqlTemplate(...a)
2527
+
2528
+ // Subscriptions (advanced — prefer the watch family)
2529
+ export const subscribe = (...a) => _dflt().subscribe(...a)
2530
+ export const unsubscribe = (...a) => _dflt().unsubscribe(...a)
2531
+ export const subscribeModel = (...a) => _dflt().subscribeModel(...a)
2532
+ export const unsubscribeModel = (...a) => _dflt().unsubscribeModel(...a)
2533
+ export const setSubscriptions = (...a) => _dflt().setSubscriptions(...a)
2534
+ export const clearSubscriptions = (...a) => _dflt().clearSubscriptions(...a)
2535
+ export const subscriptions = (...a) => _dflt().subscriptions(...a)