@bardioc/app-sdk 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,616 @@
1
+ import { EntityNotFoundError, NetworkError, PermissionError, SdkError, TimeoutError, ValidationError, } from '../errors.js';
2
+ const GRAPH_BASE = '/bardioc/api/graph/7.4';
3
+ function buildQueryString(params) {
4
+ const searchParams = new URLSearchParams();
5
+ for (const [key, value] of Object.entries(params)) {
6
+ if (value !== undefined && value !== '') {
7
+ searchParams.set(key, String(value));
8
+ }
9
+ }
10
+ const qs = searchParams.toString();
11
+ return qs ? `?${qs}` : '';
12
+ }
13
+ // Maps backend errors to typed SDK errors
14
+ function parseError(error, operation, context) {
15
+ let statusCode;
16
+ let errorMessage = 'Unknown error';
17
+ // Check for direct message on error object (e.g. from fetch/network errors)
18
+ if (error.message && typeof error.message === 'string') {
19
+ errorMessage = error.message;
20
+ }
21
+ // Backend may return status in responsePayload
22
+ if (error.responsePayload && typeof error.responsePayload === 'object') {
23
+ statusCode = error.responsePayload.status || error.responsePayload.statusCode;
24
+ if (error.responsePayload.message) {
25
+ errorMessage = error.responsePayload.message;
26
+ }
27
+ }
28
+ // Check for timeout errors
29
+ if (errorMessage.includes('timed out') || errorMessage.includes('timeout')) {
30
+ return new TimeoutError(operation, 10000);
31
+ }
32
+ // Map by status code
33
+ if (statusCode) {
34
+ const fullContext = {
35
+ ...context,
36
+ operation,
37
+ timestamp: Date.now(),
38
+ statusCode,
39
+ };
40
+ switch (statusCode) {
41
+ case 404:
42
+ return new EntityNotFoundError(context?.id || context?.xid || 'unknown');
43
+ case 400:
44
+ return new ValidationError(errorMessage, context?.field);
45
+ case 403:
46
+ return new PermissionError(errorMessage);
47
+ case 408:
48
+ return new TimeoutError(operation, 10000);
49
+ case 500:
50
+ case 502:
51
+ case 503:
52
+ return new NetworkError(errorMessage, statusCode);
53
+ default:
54
+ if (statusCode >= 400) {
55
+ return new NetworkError(errorMessage, statusCode);
56
+ }
57
+ }
58
+ }
59
+ // Default to generic SDK error
60
+ return new SdkError(errorMessage, 'UNKNOWN_ERROR', statusCode, {
61
+ ...context,
62
+ operation,
63
+ timestamp: Date.now(),
64
+ });
65
+ }
66
+ /** Graph transport class providing graph database operations */
67
+ export class GraphTransport {
68
+ _request;
69
+ _debug;
70
+ constructor(_request, options) {
71
+ this._request = _request;
72
+ this._debug = options?.debug ?? false;
73
+ }
74
+ log(...args) {
75
+ if (this._debug)
76
+ console.log('[graph-transport]', ...args);
77
+ }
78
+ withScope(payload, options) {
79
+ if (!options?.scopeId) {
80
+ return payload;
81
+ }
82
+ return {
83
+ ...payload,
84
+ scopeId: options.scopeId,
85
+ scopePolicy: 'required',
86
+ };
87
+ }
88
+ /**
89
+ * Get single node by ID
90
+ * Returns raw backend format (ogit/* fields)
91
+ *
92
+ * @throws {EntityNotFoundError} if node doesn't exist
93
+ * @throws {NetworkError} on HTTP errors
94
+ * @throws {TimeoutError} on request timeout
95
+ */
96
+ async get(id, options) {
97
+ try {
98
+ const qs = buildQueryString({
99
+ fields: options?.fields,
100
+ includeDeleted: options?.includeDeleted,
101
+ listMeta: options?.listMeta,
102
+ vid: options?.vid,
103
+ });
104
+ return await this._request(this.withScope({
105
+ path: `${GRAPH_BASE}/${encodeURIComponent(id)}${qs}`,
106
+ method: 'GET',
107
+ }, options));
108
+ }
109
+ catch (error) {
110
+ throw parseError(error, 'graph.get', { id, ...options });
111
+ }
112
+ }
113
+ /**
114
+ * Get multiple nodes by IDs (batched request)
115
+ * Uses /query/ids endpoint
116
+ *
117
+ * @returns Array in same order as input IDs (missing nodes = undefined)
118
+ * @throws {ValidationError} if ids array is empty or invalid
119
+ * @throws {NetworkError} on HTTP errors
120
+ * @throws {TimeoutError} on request timeout
121
+ */
122
+ async getMany(ids, options) {
123
+ try {
124
+ const qs = buildQueryString({
125
+ query: ids.join(','),
126
+ fields: options?.fields,
127
+ includeDeleted: options?.includeDeleted,
128
+ listMeta: options?.listMeta,
129
+ });
130
+ this.log('getMany', { path: `${GRAPH_BASE}/query/ids${qs}`, count: ids.length });
131
+ return await this._request(this.withScope({
132
+ path: `${GRAPH_BASE}/query/ids${qs}`,
133
+ method: 'GET',
134
+ }, options));
135
+ }
136
+ catch (error) {
137
+ throw parseError(error, 'graph.getMany', { ids: ids.length, ...options });
138
+ }
139
+ }
140
+ /**
141
+ * Get node by external ID
142
+ *
143
+ * @throws {EntityNotFoundError} if XID doesn't exist
144
+ * @throws {NetworkError} on HTTP errors
145
+ * @throws {TimeoutError} on request timeout
146
+ */
147
+ async getByXid(xid, options) {
148
+ try {
149
+ const qs = buildQueryString({
150
+ fields: options?.fields,
151
+ includeDeleted: options?.includeDeleted,
152
+ listMeta: options?.listMeta,
153
+ });
154
+ return await this._request(this.withScope({
155
+ path: `${GRAPH_BASE}/xid/${encodeURIComponent(xid)}${qs}`,
156
+ method: 'GET',
157
+ }, options));
158
+ }
159
+ catch (error) {
160
+ throw parseError(error, 'graph.getByXid', { xid, ...options });
161
+ }
162
+ }
163
+ /**
164
+ * Query nodes using Lucene syntax
165
+ * Returns raw backend format (ogit/* fields)
166
+ *
167
+ * @example
168
+ * const people = await graph.query<GraphNodeRaw>(
169
+ * 'ogit/_type:ogit/Person',
170
+ * { limit: 50, offset: 0 }
171
+ * );
172
+ *
173
+ * @throws {ValidationError} if query syntax is invalid
174
+ * @throws {NetworkError} on HTTP errors
175
+ * @throws {TimeoutError} on request timeout
176
+ */
177
+ async query(lucene, options) {
178
+ try {
179
+ const qs = buildQueryString({
180
+ query: lucene,
181
+ limit: options?.limit,
182
+ offset: options?.offset,
183
+ fields: options?.fields,
184
+ includeDeleted: options?.includeDeleted,
185
+ listMeta: options?.listMeta,
186
+ order: options?.order,
187
+ });
188
+ this.log('query', {
189
+ path: `${GRAPH_BASE}/query/vertices${qs}`,
190
+ lucene,
191
+ limit: options?.limit,
192
+ offset: options?.offset,
193
+ });
194
+ return await this._request(this.withScope({
195
+ path: `${GRAPH_BASE}/query/vertices${qs}`,
196
+ method: 'GET',
197
+ }, options));
198
+ }
199
+ catch (error) {
200
+ throw parseError(error, 'graph.query', { lucene, ...options });
201
+ }
202
+ }
203
+ /**
204
+ * Execute Gremlin traversal query
205
+ * Returns raw results (structure varies by query)
206
+ *
207
+ * @example
208
+ * const edges = await graph.gremlin(rootId, "outE('ogit/relates')");
209
+ *
210
+ * @throws {EntityNotFoundError} if root node doesn't exist
211
+ * @throws {ValidationError} if query syntax is invalid
212
+ * @throws {NetworkError} on HTTP errors
213
+ * @throws {TimeoutError} on request timeout
214
+ */
215
+ async gremlin(rootId, query, options) {
216
+ try {
217
+ return await this._request(this.withScope({
218
+ path: `${GRAPH_BASE}/query/gremlin`,
219
+ method: 'POST',
220
+ body: {
221
+ root: rootId,
222
+ query,
223
+ fields: options?.fields,
224
+ includeDeleted: options?.includeDeleted,
225
+ listMeta: options?.listMeta,
226
+ },
227
+ contentType: 'application/json',
228
+ }, options));
229
+ }
230
+ catch (error) {
231
+ throw parseError(error, 'graph.gremlin', { rootId, query, ...options });
232
+ }
233
+ }
234
+ /**
235
+ * Execute combined query: Elasticsearch selects the start vertices, the Gremlin traversal
236
+ * runs from their IDs. Returns raw results (structure varies by the traversal).
237
+ *
238
+ * The result is one flat set for all start vertices — it carries no marker for which start
239
+ * vertex a row came from. Label and select in the traversal when attribution is needed.
240
+ *
241
+ * @example
242
+ * const speeches = await graph.combined(
243
+ * '+(ogit\\/_type: "ogit/Politics/AgendaItem")',
244
+ * "inE().outV().has('ogit/_type', 'ogit/Politics/Discussion')",
245
+ * { limit: 1000 }
246
+ * );
247
+ *
248
+ * @throws {ValidationError} if either query is invalid
249
+ * @throws {NetworkError} on HTTP errors
250
+ * @throws {TimeoutError} on request timeout
251
+ */
252
+ async combined(elasticsearchQuery, gremlinQuery, options) {
253
+ try {
254
+ const path = `${GRAPH_BASE}/query/combined`;
255
+ this.log('combined', { path, elasticsearchQuery, gremlinQuery, limit: options?.limit });
256
+ return await this._request(this.withScope({
257
+ path,
258
+ method: 'POST',
259
+ body: {
260
+ elasticsearch_query: elasticsearchQuery,
261
+ gremlin_query: gremlinQuery,
262
+ ...(options?.limit === undefined ? {} : { limit: options.limit }),
263
+ },
264
+ contentType: 'application/json',
265
+ }, options));
266
+ }
267
+ catch (error) {
268
+ throw parseError(error, 'graph.combined', { elasticsearchQuery, gremlinQuery, ...options });
269
+ }
270
+ }
271
+ /**
272
+ * Create new graph node
273
+ *
274
+ * @param type - OGIT type (e.g., 'ogit/Person')
275
+ * @param data - Node attributes (ogit/* format)
276
+ * @returns Created node (raw backend format)
277
+ *
278
+ * @throws {ValidationError} if data is invalid or missing required fields
279
+ * @throws {PermissionError} if user lacks permission to create this type
280
+ * @throws {NetworkError} on HTTP errors
281
+ * @throws {TimeoutError} on request timeout
282
+ */
283
+ async create(type, data, options) {
284
+ try {
285
+ const qs = buildQueryString({
286
+ fullResponse: options?.fullResponse,
287
+ listMeta: options?.listMeta,
288
+ });
289
+ return await this._request(this.withScope({
290
+ path: `${GRAPH_BASE}/new/${encodeURIComponent(type)}${qs}`,
291
+ method: 'POST',
292
+ body: data,
293
+ contentType: 'application/json',
294
+ }, options));
295
+ }
296
+ catch (error) {
297
+ throw parseError(error, 'graph.create', { type, ...options });
298
+ }
299
+ }
300
+ /**
301
+ * Update existing node
302
+ *
303
+ * @param id - Node ID
304
+ * @param data - Fields to update (ogit/* format)
305
+ * @returns Updated node (raw backend format)
306
+ *
307
+ * @throws {EntityNotFoundError} if node doesn't exist
308
+ * @throws {ValidationError} if data is invalid
309
+ * @throws {PermissionError} if user lacks permission to update this node
310
+ * @throws {NetworkError} on HTTP errors
311
+ * @throws {TimeoutError} on request timeout
312
+ */
313
+ async update(id, data, options) {
314
+ try {
315
+ const qs = buildQueryString({
316
+ fullResponse: options?.fullResponse,
317
+ listMeta: options?.listMeta,
318
+ });
319
+ return await this._request(this.withScope({
320
+ path: `${GRAPH_BASE}/${encodeURIComponent(id)}${qs}`,
321
+ method: 'POST',
322
+ body: data,
323
+ contentType: 'application/json',
324
+ }, options));
325
+ }
326
+ catch (error) {
327
+ throw parseError(error, 'graph.update', { id, ...options });
328
+ }
329
+ }
330
+ /**
331
+ * Delete node (soft delete by default)
332
+ *
333
+ * @throws {EntityNotFoundError} if node doesn't exist
334
+ * @throws {PermissionError} if user lacks permission to delete this node
335
+ * @throws {NetworkError} on HTTP errors
336
+ * @throws {TimeoutError} on request timeout
337
+ */
338
+ async delete(id, options) {
339
+ try {
340
+ const qs = buildQueryString({
341
+ hard: options?.hard,
342
+ });
343
+ await this._request(this.withScope({
344
+ path: `${GRAPH_BASE}/${encodeURIComponent(id)}${qs}`,
345
+ method: 'DELETE',
346
+ }, options));
347
+ }
348
+ catch (error) {
349
+ throw parseError(error, 'graph.delete', { id, ...options });
350
+ }
351
+ }
352
+ /**
353
+ * Create edge between two nodes
354
+ *
355
+ * @param from - Source node ID
356
+ * @param to - Target node ID
357
+ * @param type - Edge type (e.g., 'ogit/relates')
358
+ * @returns Created edge (raw backend format)
359
+ *
360
+ * @throws {EntityNotFoundError} if either node doesn't exist
361
+ * @throws {ValidationError} if edge type is invalid or not allowed
362
+ * @throws {PermissionError} if user lacks permission to create this edge
363
+ * @throws {NetworkError} on HTTP errors
364
+ * @throws {TimeoutError} on request timeout
365
+ */
366
+ async connect(from, to, type, options) {
367
+ try {
368
+ const qs = buildQueryString({
369
+ fullResponse: options?.fullResponse,
370
+ listMeta: options?.listMeta,
371
+ });
372
+ return await this._request(this.withScope({
373
+ path: `${GRAPH_BASE}/connect${qs}`,
374
+ method: 'POST',
375
+ body: { fromId: from, toId: to, edgeType: type },
376
+ contentType: 'application/json',
377
+ }, options));
378
+ }
379
+ catch (error) {
380
+ throw parseError(error, 'graph.connect', { from, to, type, ...options });
381
+ }
382
+ }
383
+ /**
384
+ * Get node history
385
+ *
386
+ * @param nodeId - Node ID
387
+ * @param options - Time range, limit, etc.
388
+ * @returns Array of historical versions
389
+ *
390
+ * @throws {EntityNotFoundError} if node doesn't exist
391
+ * @throws {NetworkError} on HTTP errors
392
+ * @throws {TimeoutError} on request timeout
393
+ */
394
+ async history(nodeId, options) {
395
+ try {
396
+ const qs = buildQueryString({
397
+ from: options?.from,
398
+ to: options?.to,
399
+ limit: options?.limit,
400
+ offset: options?.offset,
401
+ includeDeleted: options?.includeDeleted,
402
+ });
403
+ return await this._request(this.withScope({
404
+ path: `${GRAPH_BASE}/${encodeURIComponent(nodeId)}/history${qs}`,
405
+ method: 'GET',
406
+ }, options));
407
+ }
408
+ catch (error) {
409
+ throw parseError(error, 'graph.history', { nodeId, ...options });
410
+ }
411
+ }
412
+ /**
413
+ * Batch operations
414
+ */
415
+ batch = {
416
+ /**
417
+ * Delete multiple nodes in parallel
418
+ * Continues deleting even if some fail
419
+ *
420
+ * @param ids - Array of node IDs to delete
421
+ * @param options - Delete options (hard delete, etc.)
422
+ * @returns BatchResult with succeeded and failed IDs
423
+ *
424
+ * @example
425
+ * const result = await graph.batch.delete(['id1', 'id2', 'id3'], { hard: false });
426
+ * console.log(`Deleted: ${result.succeeded.length}, Failed: ${result.failed.length}`);
427
+ * result.failed.forEach(f => console.error(`Failed ${f.id}: ${f.error}`));
428
+ */
429
+ delete: async (ids, options) => {
430
+ const results = await Promise.allSettled(ids.map(id => this.delete(id, options)));
431
+ const succeeded = [];
432
+ const failed = [];
433
+ results.forEach((result, index) => {
434
+ const id = ids[index];
435
+ if (!id)
436
+ return;
437
+ if (result.status === 'fulfilled') {
438
+ succeeded.push(id);
439
+ }
440
+ else {
441
+ failed.push({
442
+ id,
443
+ error: result.reason?.message || 'Unknown error',
444
+ });
445
+ }
446
+ });
447
+ return { succeeded, failed };
448
+ },
449
+ };
450
+ /**
451
+ * Time series operations
452
+ */
453
+ timeseries = {
454
+ /**
455
+ * Get time series values.
456
+ *
457
+ * `withIds` pulls further time series into the same request and `aggregate` combines the
458
+ * values sharing a timestamp. Entries carry only timestamp and value, so a multi-ID request
459
+ * yields one merged series — read a single ID per request when values must stay attributable.
460
+ *
461
+ * @throws {EntityNotFoundError} if time series doesn't exist
462
+ * @throws {NetworkError} on HTTP errors
463
+ * @throws {TimeoutError} on request timeout
464
+ */
465
+ get: async (id, options) => {
466
+ try {
467
+ const qs = buildQueryString({
468
+ from: options?.from,
469
+ to: options?.to,
470
+ limit: options?.limit,
471
+ order: options?.order,
472
+ with: options?.withIds?.join(','),
473
+ aggregate: options?.aggregate,
474
+ includeDeleted: options?.includeDeleted,
475
+ });
476
+ return await this._request(this.withScope({
477
+ path: `${GRAPH_BASE}/${encodeURIComponent(id)}/values${qs}`,
478
+ method: 'GET',
479
+ }, options));
480
+ }
481
+ catch (error) {
482
+ throw parseError(error, 'graph.timeseries.get', { id, ...options });
483
+ }
484
+ },
485
+ /**
486
+ * Add time series values
487
+ *
488
+ * @throws {EntityNotFoundError} if time series doesn't exist
489
+ * @throws {ValidationError} if values format is invalid
490
+ * @throws {NetworkError} on HTTP errors
491
+ * @throws {TimeoutError} on request timeout
492
+ */
493
+ add: async (id, values, options) => {
494
+ try {
495
+ await this._request(this.withScope({
496
+ path: `${GRAPH_BASE}/${encodeURIComponent(id)}/values`,
497
+ method: 'POST',
498
+ body: { values },
499
+ contentType: 'application/json',
500
+ }, options));
501
+ }
502
+ catch (error) {
503
+ throw parseError(error, 'graph.timeseries.add', { id, count: values.length, ...options });
504
+ }
505
+ },
506
+ /**
507
+ * Query time series with filters
508
+ *
509
+ * @throws {ValidationError} if query options are invalid
510
+ * @throws {NetworkError} on HTTP errors
511
+ * @throws {TimeoutError} on request timeout
512
+ */
513
+ query: async (options) => {
514
+ try {
515
+ const qs = buildQueryString({
516
+ ids: options.ids?.join(','),
517
+ from: options.from,
518
+ to: options.to,
519
+ limit: options.limit,
520
+ order: options.order,
521
+ aggregate: options.aggregate,
522
+ });
523
+ return await this._request(this.withScope({
524
+ path: `${GRAPH_BASE}/query/values${qs}`,
525
+ method: 'GET',
526
+ }, options));
527
+ }
528
+ catch (error) {
529
+ throw parseError(error, 'graph.timeseries.query', { ...options });
530
+ }
531
+ },
532
+ /**
533
+ * Get every value ever written for one timestamp of a time series.
534
+ * Keyed by timestamp, so a single-timestamp request returns a single-entry map.
535
+ *
536
+ * @throws {EntityNotFoundError} if time series doesn't exist
537
+ * @throws {NetworkError} on HTTP errors
538
+ * @throws {TimeoutError} on request timeout
539
+ */
540
+ history: async (id, timestamp, options) => {
541
+ try {
542
+ const qs = buildQueryString({
543
+ timestamp,
544
+ includeDeleted: options?.includeDeleted,
545
+ });
546
+ return await this._request(this.withScope({
547
+ path: `${GRAPH_BASE}/${encodeURIComponent(id)}/values/history${qs}`,
548
+ method: 'GET',
549
+ }, options));
550
+ }
551
+ catch (error) {
552
+ throw parseError(error, 'graph.timeseries.history', { id, timestamp, ...options });
553
+ }
554
+ },
555
+ };
556
+ /**
557
+ * Content/blob operations
558
+ */
559
+ content = {
560
+ /**
561
+ * Upload binary content to a node
562
+ *
563
+ * @param nodeId - Node ID to attach content to
564
+ * @param data - Binary content as Blob
565
+ * @param contentType - MIME type (e.g., 'image/png', 'application/pdf')
566
+ * @returns Content ID for later retrieval
567
+ *
568
+ * @throws {EntityNotFoundError} if node doesn't exist
569
+ * @throws {ValidationError} if content is invalid
570
+ * @throws {NetworkError} on HTTP errors
571
+ * @throws {TimeoutError} on request timeout
572
+ */
573
+ set: async (nodeId, data, contentType, options) => {
574
+ try {
575
+ const result = await this._request(this.withScope({
576
+ path: `${GRAPH_BASE}/${encodeURIComponent(nodeId)}/content`,
577
+ method: 'POST',
578
+ body: data,
579
+ contentType,
580
+ }, options));
581
+ return result.contentId;
582
+ }
583
+ catch (error) {
584
+ throw parseError(error, 'graph.content.set', {
585
+ nodeId,
586
+ contentType,
587
+ size: data.size,
588
+ ...options,
589
+ });
590
+ }
591
+ },
592
+ /**
593
+ * Download binary content from a node
594
+ *
595
+ * @param nodeId - Node ID
596
+ * @param contentId - Content ID returned from set operation
597
+ * @returns Binary content as Blob
598
+ *
599
+ * @throws {EntityNotFoundError} if node or content doesn't exist
600
+ * @throws {NetworkError} on HTTP errors
601
+ * @throws {TimeoutError} on request timeout
602
+ */
603
+ get: async (nodeId, contentId, options) => {
604
+ try {
605
+ const qs = buildQueryString({ contentId });
606
+ return await this._request(this.withScope({
607
+ path: `${GRAPH_BASE}/${encodeURIComponent(nodeId)}/content${qs}`,
608
+ method: 'GET',
609
+ }, options));
610
+ }
611
+ catch (error) {
612
+ throw parseError(error, 'graph.content.get', { nodeId, contentId, ...options });
613
+ }
614
+ },
615
+ };
616
+ }