@unboundcx/sdk 4.0.15 → 4.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unboundcx/sdk",
3
- "version": "4.0.15",
3
+ "version": "4.1.1",
4
4
  "description": "Official JavaScript SDK for the Unbound API - A comprehensive toolkit for integrating with Unbound's communication, AI, and data management services",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Live Query - real-time object subscriptions over an injected Socket.IO
3
+ * client, backing `sdk.objects.liveQuery(...)`.
4
+ *
5
+ * *** KNOWN CONTRACT DEVIATION - BLOCKING, see plan Phase 3 ***
6
+ * The locked contract signature is
7
+ * `sdk.objects.liveQuery({ object, filter, fields, recordTypeId, onEvent, onStateChange })`
8
+ * with NO socket param — the SDK is supposed to own its own socket
9
+ * transport. That transport does not exist yet (base.js's `addTransport` is
10
+ * HTTP-request-only; video.js never holds a socket either; nothing in this
11
+ * repo ever sets `sdk.socket`), so THIS implementation requires the caller
12
+ * to inject an authed socket.io-client instance via `sdk.objects.liveQuery({
13
+ * socket, ... })` (or a future `sdk.socket`), and throws synchronously if
14
+ * neither is present. Any caller following the locked contract literally
15
+ * will fail. This must be resolved (build the socket transport per the plan,
16
+ * or amend the locked contract) before Phase 4 app1-client integration
17
+ * proceeds — do not build against `socket` as permanent public API.
18
+ *
19
+ * Contract (app1-object-stream / app1-socket, locked):
20
+ * - emit 'objects.liveQuery.subscribe' { objectName, filter, fields,
21
+ * recordTypeId? } with an ack callback -> { subscriptionId, mode } | { error }
22
+ * - one 30s heartbeat timer per socket, batching every live subscriptionId
23
+ * on that socket: emit 'objects.liveQuery.heartbeat' { subscriptionIds }
24
+ * - server pushes 'objects.liveQuery.event' { subscriptionId, type, seq,
25
+ * recordId, record, changedFields, rowVersion }
26
+ * - per-subscription seq gap -> synthetic { type: 'resync' } to onEvent
27
+ * - socket 'connect' (i.e. reconnect) -> transparently re-subscribe every
28
+ * live handle (new subscriptionId internally, handle object unchanged)
29
+ * and emit { type: 'resync' }
30
+ * - 'revoked' frames tear the handle down
31
+ */
32
+
33
+ const HEARTBEAT_INTERVAL_MS = 30000;
34
+
35
+ // One manager per socket instance so the heartbeat timer is shared across
36
+ // every liveQuery() subscription on that socket, not created per-sub.
37
+ const socketManagers = new WeakMap();
38
+
39
+ function getSocketManager(socket) {
40
+ let manager = socketManagers.get(socket);
41
+ if (!manager) {
42
+ manager = new SocketLiveQueryManager(socket);
43
+ socketManagers.set(socket, manager);
44
+ }
45
+ return manager;
46
+ }
47
+
48
+ class SocketLiveQueryManager {
49
+ constructor(socket) {
50
+ this.socket = socket;
51
+ this.handles = new Map(); // subscriptionId -> LiveQueryHandle
52
+ this.heartbeatTimer = null;
53
+ this._onConnect = this._onConnect.bind(this);
54
+ this._onEvent = this._onEvent.bind(this);
55
+ socket.on('connect', this._onConnect);
56
+ socket.on('objects.liveQuery.event', this._onEvent);
57
+ }
58
+
59
+ register(handle) {
60
+ this.handles.set(handle.subscriptionId, handle);
61
+ this._ensureHeartbeat();
62
+ }
63
+
64
+ unregister(subscriptionId) {
65
+ this.handles.delete(subscriptionId);
66
+ this._stopHeartbeatIfIdle();
67
+ }
68
+
69
+ // Final teardown of a handle: unregister, and if this was the last live
70
+ // handle on the socket, drop our socket-level listeners too.
71
+ destroy(subscriptionId) {
72
+ this.unregister(subscriptionId);
73
+ if (this.handles.size === 0) {
74
+ this.socket.off('connect', this._onConnect);
75
+ this.socket.off('objects.liveQuery.event', this._onEvent);
76
+ socketManagers.delete(this.socket);
77
+ }
78
+ }
79
+
80
+ _ensureHeartbeat() {
81
+ if (this.heartbeatTimer || this.handles.size === 0) return;
82
+ this.heartbeatTimer = setInterval(() => {
83
+ const subscriptionIds = Array.from(this.handles.keys());
84
+ if (subscriptionIds.length === 0) return;
85
+ this.socket.emit('objects.liveQuery.heartbeat', { subscriptionIds });
86
+ }, HEARTBEAT_INTERVAL_MS);
87
+ if (this.heartbeatTimer.unref) this.heartbeatTimer.unref();
88
+ }
89
+
90
+ _stopHeartbeatIfIdle() {
91
+ if (this.handles.size === 0 && this.heartbeatTimer) {
92
+ clearInterval(this.heartbeatTimer);
93
+ this.heartbeatTimer = null;
94
+ }
95
+ }
96
+
97
+ _onEvent(frame) {
98
+ const handle = frame && this.handles.get(frame.subscriptionId);
99
+ if (!handle) return;
100
+ handle._handleEvent(frame);
101
+ }
102
+
103
+ async _onConnect() {
104
+ // Transparent re-subscribe of every live handle on this socket.
105
+ const handles = Array.from(this.handles.values());
106
+ for (const handle of handles) {
107
+ await handle._resubscribe();
108
+ }
109
+ }
110
+ }
111
+
112
+ class LiveQueryHandle {
113
+ constructor({
114
+ manager,
115
+ socket,
116
+ object,
117
+ filter,
118
+ fields,
119
+ recordTypeId,
120
+ onEvent,
121
+ onStateChange,
122
+ }) {
123
+ this.manager = manager;
124
+ this.socket = socket;
125
+ this.object = object;
126
+ this.filter = filter;
127
+ this.fields = fields;
128
+ this.recordTypeId = recordTypeId;
129
+ this.onEvent = onEvent;
130
+ this.onStateChange = onStateChange;
131
+
132
+ this.subscriptionId = null;
133
+ this.mode = null;
134
+ this.seq = 0;
135
+ this.revoked = false;
136
+ }
137
+
138
+ async _subscribe() {
139
+ const payload = { objectName: this.object, filter: this.filter, fields: this.fields };
140
+ if (this.recordTypeId !== undefined) payload.recordTypeId = this.recordTypeId;
141
+
142
+ const ack = await new Promise((resolve, reject) => {
143
+ try {
144
+ this.socket.emit('objects.liveQuery.subscribe', payload, resolve);
145
+ } catch (err) {
146
+ reject(err);
147
+ }
148
+ });
149
+
150
+ if (!ack || ack.error) {
151
+ throw new Error(ack?.error || 'liveQuery :: subscribe :: no ack received');
152
+ }
153
+
154
+ this.subscriptionId = ack.subscriptionId;
155
+ this.mode = ack.mode;
156
+ this.seq = 0;
157
+ // Re-resolve the manager at registration time: if a concurrent
158
+ // unsubscribe drained the manager while this subscribe was in flight,
159
+ // destroy() stripped its socket listeners and dropped it from
160
+ // socketManagers - registering onto that dead instance would leave this
161
+ // handle deaf (server sub alive, frames arriving at the socket, nothing
162
+ // listening). getSocketManager() returns a fresh, listening manager in
163
+ // that case.
164
+ this.manager = getSocketManager(this.socket);
165
+ this.manager.register(this);
166
+ this._setState('active');
167
+ return ack;
168
+ }
169
+
170
+ async _resubscribe() {
171
+ if (this.revoked) return;
172
+
173
+ const previousId = this.subscriptionId;
174
+ this._setState('resubscribing');
175
+ // Stop routing events/heartbeats for the old (now-dead) subscriptionId;
176
+ // the handle object itself is kept and rebound to a new one below.
177
+ if (previousId) this.manager.unregister(previousId);
178
+
179
+ try {
180
+ await this._subscribe();
181
+ this._emitEvent({ type: 'resync' });
182
+ } catch (err) {
183
+ console.warn(`liveQuery :: resubscribe :: ${this.object} :: ${err.message}`);
184
+ // Left un-registered; the next 'connect' event will retry.
185
+ }
186
+ }
187
+
188
+ _handleEvent(frame) {
189
+ if (this.revoked) return;
190
+
191
+ if (frame.type === 'revoked') {
192
+ this._emitEvent(frame);
193
+ this._setState('revoked');
194
+ this._teardown();
195
+ return;
196
+ }
197
+
198
+ if (typeof frame.seq === 'number') {
199
+ if (this.seq && frame.seq > this.seq + 1) {
200
+ this._emitEvent({ type: 'resync' });
201
+ }
202
+ if (frame.seq > this.seq) this.seq = frame.seq;
203
+ }
204
+
205
+ this._emitEvent(frame);
206
+ }
207
+
208
+ _emitEvent(frame) {
209
+ if (typeof this.onEvent !== 'function') return;
210
+ try {
211
+ this.onEvent(frame);
212
+ } catch (err) {
213
+ console.error(`liveQuery :: onEvent handler :: ${this.object} :: threw :: ${err.message}`);
214
+ }
215
+ }
216
+
217
+ _setState(state) {
218
+ if (typeof this.onStateChange !== 'function') return;
219
+ try {
220
+ this.onStateChange(state);
221
+ } catch (err) {
222
+ console.error(`liveQuery :: onStateChange handler :: ${this.object} :: threw :: ${err.message}`);
223
+ }
224
+ }
225
+
226
+ _teardown() {
227
+ if (this.revoked) return;
228
+ this.revoked = true;
229
+ if (this.subscriptionId) this.manager.destroy(this.subscriptionId);
230
+ }
231
+
232
+ unsubscribe() {
233
+ if (this.revoked) return;
234
+ this.revoked = true;
235
+ if (this.subscriptionId) {
236
+ this.socket.emit('objects.liveQuery.unsubscribe', { subscriptionId: this.subscriptionId });
237
+ this.manager.destroy(this.subscriptionId);
238
+ }
239
+ }
240
+ }
241
+
242
+ /**
243
+ * sdk.objects.liveQuery({ socket, object, filter, fields, recordTypeId, onEvent, onStateChange })
244
+ *
245
+ * NOTE: `socket` is NOT part of the locked contract (see file header) — it's
246
+ * a stopgap until the SDK owns its own transport. Passing it is required
247
+ * today; treat this as a blocking open item, not a stable API.
248
+ * - socket: optional socket.io-client instance already connected/authed for
249
+ * this account; falls back to `sdk.socket` if the sdk instance holds one.
250
+ * - object, filter, fields, recordTypeId: subscribe-time query, same shape
251
+ * as `sdk.objects.query`.
252
+ * - onEvent(frame): called for every 'enter'|'change'|'leave'|'refresh'|
253
+ * 'resync'|'revoked' frame (resync frames are also synthesized locally on
254
+ * seq gaps and on reconnect).
255
+ * - onStateChange(state): optional, called with 'active' | 'resubscribing' | 'revoked'.
256
+ *
257
+ * Resolves to { subscriptionId, mode, unsubscribe() }.
258
+ */
259
+ export async function liveQuery(sdk, args = {}) {
260
+ const { socket: providedSocket, object, filter, fields, recordTypeId, onEvent, onStateChange } = args;
261
+
262
+ const socket = providedSocket || sdk.socket;
263
+ if (!socket || typeof socket.emit !== 'function' || typeof socket.on !== 'function') {
264
+ throw new Error(
265
+ 'liveQuery :: init :: a socket.io client instance is required (pass { socket } or set ' +
266
+ 'sdk.socket) — the SDK has no built-in socket transport yet; this is a known blocking ' +
267
+ 'deviation from the locked liveQuery contract, see plan Phase 3',
268
+ );
269
+ }
270
+ if (!object) {
271
+ throw new Error('liveQuery :: init :: object is required');
272
+ }
273
+
274
+ const manager = getSocketManager(socket);
275
+ const handle = new LiveQueryHandle({
276
+ manager,
277
+ socket,
278
+ object,
279
+ filter,
280
+ fields,
281
+ recordTypeId,
282
+ onEvent,
283
+ onStateChange,
284
+ });
285
+
286
+ const ack = await handle._subscribe();
287
+
288
+ return {
289
+ subscriptionId: handle.subscriptionId,
290
+ mode: ack.mode,
291
+ unsubscribe: () => handle.unsubscribe(),
292
+ };
293
+ }
@@ -17,11 +17,25 @@
17
17
  * // Legacy (deprecated) usage still supported:
18
18
  * const result = await sdk.objects.query('users', { status: 'active' });
19
19
  */
20
+ import { liveQuery } from './liveQuery.js';
21
+
20
22
  export class ObjectsService {
21
23
  constructor(sdk) {
22
24
  this.sdk = sdk;
23
25
  }
24
26
 
27
+ /**
28
+ * Subscribe to real-time changes on an object (see services/liveQuery.js
29
+ * for the full contract: heartbeat, seq-gap resync, reconnect
30
+ * re-subscribe, revoked teardown).
31
+ *
32
+ * sdk.objects.liveQuery({ socket, object, filter, fields, recordTypeId, onEvent, onStateChange })
33
+ * -> Promise<{ subscriptionId, mode, unsubscribe() }>
34
+ */
35
+ liveQuery(args) {
36
+ return liveQuery(this.sdk, args);
37
+ }
38
+
25
39
  /**
26
40
  * Retrieve an object by ID
27
41
  *
package/services/video.js CHANGED
@@ -1043,24 +1043,6 @@ export class VideoService {
1043
1043
  return result;
1044
1044
  }
1045
1045
 
1046
- /**
1047
- * Get live presence for a video room (current participants, grouped by
1048
- * waiting-room state)
1049
- * @param {string} roomId - The video room ID
1050
- * @returns {Promise} Live presence info
1051
- */
1052
- async getLivePresence(roomId) {
1053
- this.sdk.validateParams(
1054
- { roomId },
1055
- {
1056
- roomId: { type: 'string', required: true },
1057
- },
1058
- );
1059
-
1060
- const result = await this.sdk._fetch(`/video/${roomId}/livePresence`, 'GET');
1061
- return result;
1062
- }
1063
-
1064
1046
  /**
1065
1047
  * Get the AI-generated summary for a video room's transcript
1066
1048
  * @param {string} roomId - The video room ID
package/services/voice.js CHANGED
@@ -22,9 +22,9 @@ export class VoiceService {
22
22
  return result;
23
23
  }
24
24
 
25
- async call({ to, from, destination, app, timeout, customHeaders, statusWebhook }) {
25
+ async call({ to, from, destination, app, timeout, customHeaders }) {
26
26
  this.sdk.validateParams(
27
- { to, from, destination, app, timeout, customHeaders, statusWebhook },
27
+ { to, from, destination, app, timeout, customHeaders },
28
28
  {
29
29
  to: { type: 'string', required: true },
30
30
  from: { type: 'string', required: true },
@@ -32,10 +32,6 @@ export class VoiceService {
32
32
  app: { type: 'object', required: false },
33
33
  timeout: { type: 'number', required: false },
34
34
  customHeaders: { type: 'object', required: false },
35
- // { url, static } — internal endpoint that receives call progress
36
- // events (trying/ringing/answered/failed) with `static` fields
37
- // merged into each POST body
38
- statusWebhook: { type: 'object', required: false },
39
35
  },
40
36
  );
41
37
 
@@ -47,7 +43,6 @@ export class VoiceService {
47
43
  app,
48
44
  timeout,
49
45
  customHeaders,
50
- statusWebhook,
51
46
  },
52
47
  };
53
48