@synthigy/sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/codegen/codegen.mjs +499 -0
- package/codegen/lib.mjs +30 -0
- package/codegen/pull.mjs +47 -0
- package/package.json +63 -0
- package/src/index.d.ts +1108 -0
- package/src/index.js +2535 -0
- package/src/watch-query.js +533 -0
- package/src/watch.js +915 -0
package/src/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)
|