@centrali-io/centrali-sdk 5.5.0 → 6.0.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.
Files changed (49) hide show
  1. package/README.md +164 -14
  2. package/dist/index.d.ts +1807 -878
  3. package/dist/index.js +9153 -4076
  4. package/index.ts +61 -7152
  5. package/package.json +10 -3
  6. package/query-types.ts +83 -2
  7. package/scripts/smoke-types.ts +145 -5
  8. package/src/client.ts +1507 -0
  9. package/src/internal/auth.ts +35 -0
  10. package/src/internal/deprecation.ts +11 -0
  11. package/src/internal/error.ts +90 -0
  12. package/src/internal/paths.ts +456 -0
  13. package/src/internal/queryGuard.ts +21 -0
  14. package/src/managers/allowedDomains.ts +90 -0
  15. package/src/managers/anomalyInsights.ts +215 -0
  16. package/src/managers/auditLog.ts +105 -0
  17. package/src/managers/collections.ts +197 -0
  18. package/src/managers/files.ts +182 -0
  19. package/src/managers/functionRuns.ts +229 -0
  20. package/src/managers/functions.ts +171 -0
  21. package/src/managers/orchestrationRuns.ts +122 -0
  22. package/src/managers/orchestrations.ts +297 -0
  23. package/src/managers/query.ts +199 -0
  24. package/src/managers/records.ts +186 -0
  25. package/src/managers/smartQueries.ts +374 -0
  26. package/src/managers/structures.ts +205 -0
  27. package/src/managers/triggers.ts +349 -0
  28. package/src/managers/validation.ts +303 -0
  29. package/src/managers/webhookSubscriptions.ts +206 -0
  30. package/src/realtime/manager.ts +292 -0
  31. package/src/types/allowedDomains.ts +29 -0
  32. package/src/types/auth.ts +83 -0
  33. package/src/types/common.ts +57 -0
  34. package/src/types/compute.ts +145 -0
  35. package/src/types/insights.ts +113 -0
  36. package/src/types/orchestrations.ts +460 -0
  37. package/src/types/realtime.ts +403 -0
  38. package/src/types/records.ts +261 -0
  39. package/src/types/search.ts +44 -0
  40. package/src/types/smartQueries.ts +303 -0
  41. package/src/types/structures.ts +203 -0
  42. package/src/types/triggers.ts +122 -0
  43. package/src/types/validation.ts +167 -0
  44. package/src/types/webhooks.ts +114 -0
  45. package/src/urls.ts +33 -0
  46. package/dist/query-types.d.ts +0 -187
  47. package/dist/query-types.js +0 -137
  48. package/dist/scripts/smoke-types.d.ts +0 -12
  49. package/dist/scripts/smoke-types.js +0 -102
package/src/client.ts ADDED
@@ -0,0 +1,1507 @@
1
+ import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosRequestHeaders, AxiosResponse, Method } from 'axios';
2
+ import qs from 'qs';
3
+
4
+ import { CentraliError, isCentraliError, toCentraliError } from './internal/error';
5
+ import { emitDeprecationWarning } from './internal/deprecation';
6
+ import { fetchClientToken } from './internal/auth';
7
+ import { getApiUrl } from './urls';
8
+ import { isCanonicalQueryDefinition } from './internal/queryGuard';
9
+
10
+ import {
11
+ getRecordApiPath,
12
+ getFileUploadApiPath,
13
+ getSearchApiPath,
14
+ } from './internal/paths';
15
+
16
+ import type { CentraliSDKOptions, ApiResponse } from './types/common';
17
+ import type { CheckAuthorizationOptions, AuthorizationResult } from './types/auth';
18
+ import type { DeleteRecordOptions, RecordTtlOptions, GetRecordOptions, QueryRecordOptions } from './types/records';
19
+ import type { SearchOptions, SearchResponse } from './types/search';
20
+ import type { QueryDefinition, QueryResult } from '../query-types';
21
+
22
+ import { RealtimeManager } from './realtime/manager';
23
+ import { OrchestrationsManager } from './managers/orchestrations';
24
+ import { TriggersManager } from './managers/triggers';
25
+ import { RecordsManager } from './managers/records';
26
+ import { AuditLogManager } from './managers/auditLog';
27
+ import { SmartQueriesManager } from './managers/smartQueries';
28
+ import { AnomalyInsightsManager } from './managers/anomalyInsights';
29
+ import { ValidationManager } from './managers/validation';
30
+ import { AllowedDomainsManager } from './managers/allowedDomains';
31
+ import { StructuresManager } from './managers/structures';
32
+ import { CollectionsManager } from './managers/collections';
33
+ import { ComputeFunctionsManager } from './managers/functions';
34
+ import { FunctionRunsManager } from './managers/functionRuns';
35
+ import { OrchestrationRunsManager } from './managers/orchestrationRuns';
36
+ import { FilesManager } from './managers/files';
37
+ import { WebhookSubscriptionsManager } from './managers/webhookSubscriptions';
38
+ import { QueryManager } from './managers/query';
39
+
40
+ export class CentraliSDK {
41
+ private axios: AxiosInstance;
42
+ private token: string | null = null;
43
+ private options: CentraliSDKOptions;
44
+ private _realtime: RealtimeManager | null = null;
45
+ private _triggers: TriggersManager | null = null;
46
+ private _records: RecordsManager | null = null;
47
+ private _auditLog: AuditLogManager | null = null;
48
+ private _smartQueries: SmartQueriesManager | null = null;
49
+ private _queryRecordsLegacyWarned: boolean = false;
50
+ private _anomalyInsights: AnomalyInsightsManager | null = null;
51
+ private _validation: ValidationManager | null = null;
52
+ private _orchestrations: OrchestrationsManager | null = null;
53
+ private _allowedDomains: AllowedDomainsManager | null = null;
54
+ private _structures: StructuresManager | null = null;
55
+ private _collections: CollectionsManager | null = null;
56
+ private _functions: ComputeFunctionsManager | null = null;
57
+ private _runs: FunctionRunsManager | null = null;
58
+ private _orchestrationRuns: OrchestrationRunsManager | null = null;
59
+ private _files: FilesManager | null = null;
60
+ private _webhookSubscriptions: WebhookSubscriptionsManager | null = null;
61
+ private _query: QueryManager | null = null;
62
+ private isRefreshingToken: boolean = false;
63
+ private tokenRefreshPromise: Promise<string> | null = null;
64
+
65
+ constructor(options: CentraliSDKOptions) {
66
+ this.options = options;
67
+ this.token = options.token || null;
68
+
69
+ // Validate mutually exclusive auth options
70
+ const authPaths = [
71
+ options.publishableKey ? 'publishableKey' : null,
72
+ options.getToken ? 'getToken' : null,
73
+ (options.clientId || options.clientSecret) ? 'clientId/clientSecret' : null,
74
+ ].filter(Boolean);
75
+
76
+ if (options.publishableKey && authPaths.length > 1) {
77
+ throw new Error(`Cannot use publishableKey with ${authPaths.filter(p => p !== 'publishableKey').join(', ')}. Use one auth method.`);
78
+ }
79
+ if (options.getToken && (options.clientId || options.clientSecret)) {
80
+ throw new Error('Cannot use getToken with clientId/clientSecret. Use one auth method.');
81
+ }
82
+
83
+ const apiUrl = getApiUrl(options.baseUrl);
84
+ this.axios = axios.create({
85
+ baseURL: apiUrl,
86
+ paramsSerializer: (params: Record<string, any>): string =>
87
+ qs.stringify(params, { arrayFormat: "repeat" }),
88
+ proxy: false,
89
+ ...options.axiosConfig,
90
+ });
91
+
92
+ // Attach async interceptor to fetch token on first request if needed
93
+ this.axios.interceptors.request.use(
94
+ async (config) => {
95
+ // Auth path 1: Publishable key — send as x-api-key header, no token logic
96
+ if (this.options.publishableKey) {
97
+ config.headers['x-api-key'] = this.options.publishableKey;
98
+ return config;
99
+ }
100
+
101
+ // Auth path 2: Dynamic token callback (getToken) — call on each request
102
+ if (this.options.getToken) {
103
+ this.token = await this.options.getToken();
104
+ if (this.token) {
105
+ config.headers.Authorization = `Bearer ${this.token}`;
106
+ }
107
+ return config;
108
+ }
109
+
110
+ // Auth path 3: Client credentials — fetch token on first request
111
+ if (!this.token && this.options.clientId && this.options.clientSecret) {
112
+ this.token = await fetchClientToken(
113
+ this.options.clientId,
114
+ this.options.clientSecret,
115
+ this.options.baseUrl
116
+ );
117
+ }
118
+ if (this.token) {
119
+ config.headers.Authorization = `Bearer ${this.token}`;
120
+ }
121
+ return config;
122
+ },
123
+ (error) => Promise.reject(error)
124
+ );
125
+
126
+ // Response interceptor for automatic token refresh on 401/403
127
+ this.axios.interceptors.response.use(
128
+ (response) => response,
129
+ async (error) => {
130
+ const originalRequest = error.config;
131
+
132
+ // Publishable keys: no retry — scope errors are permanent
133
+ if (this.options.publishableKey) {
134
+ return Promise.reject(error);
135
+ }
136
+
137
+ const isAuthError = error.response?.status === 401 || error.response?.status === 403;
138
+ const hasNotRetried = !originalRequest._hasRetried;
139
+
140
+ // getToken path: retry once with a fresh token on 401
141
+ if (isAuthError && this.options.getToken && hasNotRetried) {
142
+ originalRequest._hasRetried = true;
143
+ try {
144
+ this.token = await this.options.getToken();
145
+ originalRequest.headers.Authorization = `Bearer ${this.token}`;
146
+ return this.axios(originalRequest);
147
+ } catch (refreshError) {
148
+ return Promise.reject(error);
149
+ }
150
+ }
151
+
152
+ // Client credentials path: refresh token and retry on 401/403
153
+ const hasClientCredentials = this.options.clientId && this.options.clientSecret;
154
+
155
+ if (isAuthError && hasClientCredentials && hasNotRetried) {
156
+ // Mark request as retried to prevent infinite loops
157
+ originalRequest._hasRetried = true;
158
+
159
+ try {
160
+ // If already refreshing, wait for the existing refresh to complete
161
+ if (this.isRefreshingToken && this.tokenRefreshPromise) {
162
+ await this.tokenRefreshPromise;
163
+ } else {
164
+ // Start a new token refresh
165
+ this.isRefreshingToken = true;
166
+ this.tokenRefreshPromise = fetchClientToken(
167
+ this.options.clientId!,
168
+ this.options.clientSecret!,
169
+ this.options.baseUrl
170
+ );
171
+ this.token = await this.tokenRefreshPromise;
172
+ this.isRefreshingToken = false;
173
+ this.tokenRefreshPromise = null;
174
+ }
175
+
176
+ // Retry the original request with the new token
177
+ originalRequest.headers.Authorization = `Bearer ${this.token}`;
178
+ return this.axios(originalRequest);
179
+ } catch (refreshError) {
180
+ // Token refresh failed, clear state and reject
181
+ this.isRefreshingToken = false;
182
+ this.tokenRefreshPromise = null;
183
+ this.token = null;
184
+ return Promise.reject(toCentraliError(refreshError));
185
+ }
186
+ }
187
+
188
+ return Promise.reject(toCentraliError(error));
189
+ }
190
+ );
191
+ }
192
+
193
+ /**
194
+ * Realtime namespace for subscribing to SSE events.
195
+ *
196
+ * Usage:
197
+ * ```ts
198
+ * const sub = client.realtime.subscribe({
199
+ * structures: ['order'],
200
+ * events: ['record_created', 'record_updated'],
201
+ * onEvent: (event) => console.log(event),
202
+ * });
203
+ * // Later: sub.unsubscribe();
204
+ * ```
205
+ *
206
+ * IMPORTANT: Initial Sync Pattern
207
+ * Realtime delivers only new events after connection. For dashboards and lists:
208
+ * 1. Fetch current records first
209
+ * 2. Subscribe to realtime
210
+ * 3. Apply diffs while UI shows the snapshot
211
+ */
212
+ public get realtime(): RealtimeManager {
213
+ if (!this._realtime) {
214
+ this._realtime = new RealtimeManager(
215
+ this.options.baseUrl,
216
+ this.options.workspaceId,
217
+ () => this.getTokenOrFetch()
218
+ );
219
+ }
220
+ return this._realtime;
221
+ }
222
+
223
+ /**
224
+ * Get the current token, or fetch one using client credentials if available.
225
+ * This ensures realtime subscriptions work without needing a prior HTTP request.
226
+ */
227
+ private async getTokenOrFetch(): Promise<string | null> {
228
+ // If token exists, return it
229
+ if (this.token) {
230
+ return this.token;
231
+ }
232
+ // For client-credentials flow, proactively fetch token if not available
233
+ if (this.options.clientId && this.options.clientSecret) {
234
+ this.token = await fetchClientToken(
235
+ this.options.clientId,
236
+ this.options.clientSecret,
237
+ this.options.baseUrl
238
+ );
239
+ return this.token;
240
+ }
241
+ // No token and no credentials
242
+ return null;
243
+ }
244
+
245
+ /**
246
+ * Triggers namespace for invoking and managing function triggers.
247
+ *
248
+ * Usage:
249
+ * ```ts
250
+ * // Invoke an on-demand trigger
251
+ * const job = await client.triggers.invoke('trigger-id');
252
+ *
253
+ * // Invoke with custom payload
254
+ * const job = await client.triggers.invoke('trigger-id', {
255
+ * payload: { orderId: '12345' }
256
+ * });
257
+ *
258
+ * // Get trigger details
259
+ * const trigger = await client.triggers.get('trigger-id');
260
+ *
261
+ * // List all triggers
262
+ * const triggers = await client.triggers.list();
263
+ * ```
264
+ */
265
+ public get triggers(): TriggersManager {
266
+ if (!this._triggers) {
267
+ this._triggers = new TriggersManager(
268
+ this.options.workspaceId,
269
+ this.request.bind(this)
270
+ );
271
+ }
272
+ return this._triggers;
273
+ }
274
+
275
+ /**
276
+ * Records namespace — canonical query surface for records (CEN-1194).
277
+ *
278
+ * Three methods, one engine, one envelope:
279
+ * - `records.query(resource, definition)` — POST `/records/query` with a
280
+ * full `QueryDefinition` (boolean trees, `select`, `text`, `include`).
281
+ * - `records.list(resource, urlOpts?)` — GET adapter for simple URL-param
282
+ * queries. Bookmarkable; cannot express nested booleans.
283
+ * - `records.search(resource, text, opts?)` — sugar over `query({ text })`.
284
+ *
285
+ * @example
286
+ * ```ts
287
+ * // Boolean tree with sort + projection
288
+ * const orders = await client.records.query<Order>('orders', {
289
+ * resource: 'orders',
290
+ * where: {
291
+ * and: [
292
+ * { 'data.status': { eq: 'paid' } },
293
+ * { 'data.amount': { gte: 100 } }
294
+ * ]
295
+ * },
296
+ * sort: [{ field: 'createdAt', direction: 'desc' }],
297
+ * page: { limit: 50 },
298
+ * select: { fields: ['id', 'data.amount', 'data.customer'] }
299
+ * });
300
+ *
301
+ * // Simple GET
302
+ * const recent = await client.records.list<Order>('orders', {
303
+ * 'data.status': 'paid',
304
+ * sort: '-createdAt',
305
+ * });
306
+ *
307
+ * // Text search
308
+ * const matches = await client.records.search<Order>('orders', 'urgent');
309
+ * ```
310
+ */
311
+ public get records(): RecordsManager {
312
+ if (!this._records) {
313
+ this._records = new RecordsManager(
314
+ this.options.workspaceId,
315
+ this.request.bind(this)
316
+ );
317
+ }
318
+ return this._records;
319
+ }
320
+
321
+ /**
322
+ * Query namespace — canonical query primitives bundled into the SDK
323
+ * (CEN-1202). Validate, translate-from-legacy, and parse URL-style
324
+ * queries locally without a server roundtrip. Same source as the
325
+ * server-side validator, so behavior matches byte-for-byte.
326
+ *
327
+ * @example
328
+ * ```ts
329
+ * const r = client.query.validate({ resource: 'orders', page: { limit: 50 } });
330
+ * if (!r.ok) console.error(r.errors);
331
+ * ```
332
+ */
333
+ public get query(): QueryManager {
334
+ if (!this._query) {
335
+ this._query = new QueryManager();
336
+ }
337
+ return this._query;
338
+ }
339
+
340
+ /**
341
+ * Audit Log namespace — canonical query surface for the workspace audit
342
+ * log (CEN-1215, Phase 3 of the query foundation).
343
+ *
344
+ * Routes through `POST /workspace/<ws>/api/v1/audit/query` on the
345
+ * workspace service. Same `QueryDefinition` vocabulary as
346
+ * {@link CentraliSDK.records | records} so callers learn one shape and
347
+ * reuse it.
348
+ *
349
+ * @example
350
+ * ```ts
351
+ * // Per-resource history
352
+ * const history = await client.auditLog.query({
353
+ * resource: 'audit-log',
354
+ * where: {
355
+ * and: [
356
+ * { resourceType: { eq: 'structure' } },
357
+ * { resourceId: { eq: 'r-123' } },
358
+ * ],
359
+ * },
360
+ * sort: [{ field: 'createdAt', direction: 'desc' }],
361
+ * page: { limit: 50 },
362
+ * });
363
+ * ```
364
+ */
365
+ public get auditLog(): AuditLogManager {
366
+ if (!this._auditLog) {
367
+ this._auditLog = new AuditLogManager(
368
+ this.options.workspaceId,
369
+ this.request.bind(this)
370
+ );
371
+ }
372
+ return this._auditLog;
373
+ }
374
+
375
+ /**
376
+ * Saved Queries namespace — list, execute, create, update, and delete
377
+ * saved (formerly "smart") queries. Routes through the canonical
378
+ * `/saved-queries/*` endpoints (Phase 4 of the query foundation,
379
+ * CEN-1198). The data service dual-mounts the deprecated `/smart-queries`
380
+ * alias for the deprecation window.
381
+ *
382
+ * Usage:
383
+ * ```ts
384
+ * // List all saved queries in workspace
385
+ * const allQueries = await client.savedQueries.listAll();
386
+ *
387
+ * // List saved queries for a structure
388
+ * const queries = await client.savedQueries.list('employee');
389
+ *
390
+ * // Get a saved query by name
391
+ * const query = await client.savedQueries.getByName('employee', 'Active Employees');
392
+ *
393
+ * // Execute a saved query
394
+ * const results = await client.savedQueries.execute('employee', query.data.id);
395
+ * console.log('Found:', results.data.length, 'records');
396
+ * ```
397
+ */
398
+ public get savedQueries(): SmartQueriesManager {
399
+ if (!this._smartQueries) {
400
+ this._smartQueries = new SmartQueriesManager(
401
+ this.options.workspaceId,
402
+ this.request.bind(this)
403
+ );
404
+ }
405
+ return this._smartQueries;
406
+ }
407
+
408
+ /**
409
+ * @deprecated Use `client.savedQueries` instead. The "smart queries"
410
+ * surface was renamed to "saved queries" in Phase 4 of the query
411
+ * foundation (CEN-1198). This getter is a deprecated alias and will be
412
+ * removed in a future major SDK release.
413
+ */
414
+ public get smartQueries(): SmartQueriesManager {
415
+ emitDeprecationWarning('client.smartQueries is deprecated. Use client.savedQueries instead.');
416
+ if (!this._smartQueriesAlias) {
417
+ this._smartQueriesAlias = new SmartQueriesManager(
418
+ this.options.workspaceId,
419
+ this.requestWithDeprecationHeader('smartQueries'),
420
+ );
421
+ }
422
+ return this._smartQueriesAlias;
423
+ }
424
+ private _smartQueriesAlias?: SmartQueriesManager;
425
+
426
+ /**
427
+ * Anomaly Insights namespace for querying and managing AI-generated insights.
428
+ *
429
+ * Usage:
430
+ * ```ts
431
+ * // List all active critical insights
432
+ * const insights = await client.anomalyInsights.list({
433
+ * status: 'active',
434
+ * severity: 'critical'
435
+ * });
436
+ *
437
+ * // Get insights for a specific structure
438
+ * const orderInsights = await client.anomalyInsights.listByStructure('orders');
439
+ *
440
+ * // Get insight summary
441
+ * const summary = await client.anomalyInsights.getSummary();
442
+ * console.log('Critical:', summary.data.bySeverity.critical);
443
+ *
444
+ * // Acknowledge an insight
445
+ * await client.anomalyInsights.acknowledge('insight-id');
446
+ *
447
+ * // Dismiss an insight
448
+ * await client.anomalyInsights.dismiss('insight-id');
449
+ * ```
450
+ */
451
+ public get anomalyInsights(): AnomalyInsightsManager {
452
+ if (!this._anomalyInsights) {
453
+ this._anomalyInsights = new AnomalyInsightsManager(
454
+ this.options.workspaceId,
455
+ this.request.bind(this)
456
+ );
457
+ }
458
+ return this._anomalyInsights;
459
+ }
460
+
461
+ /**
462
+ * Validation namespace for AI-powered data quality validation.
463
+ *
464
+ * Features:
465
+ * - Trigger batch validation scans on structures
466
+ * - List and manage validation suggestions (typos, format issues, duplicates)
467
+ * - Accept or reject suggestions to fix data
468
+ *
469
+ * Usage:
470
+ * ```ts
471
+ * // Trigger a batch scan
472
+ * const batch = await client.validation.triggerScan('orders');
473
+ *
474
+ * // Wait for completion
475
+ * const result = await client.validation.waitForScan(batch.data.batchId);
476
+ *
477
+ * // List pending suggestions
478
+ * const suggestions = await client.validation.listSuggestions({ status: 'pending' });
479
+ *
480
+ * // Accept a suggestion (applies the fix)
481
+ * await client.validation.accept('suggestion-id');
482
+ * ```
483
+ */
484
+ public get validation(): ValidationManager {
485
+ if (!this._validation) {
486
+ this._validation = new ValidationManager(
487
+ this.options.workspaceId,
488
+ this.request.bind(this)
489
+ );
490
+ }
491
+ return this._validation;
492
+ }
493
+
494
+ /**
495
+ * Orchestrations namespace for managing multi-step workflows.
496
+ *
497
+ * Orchestrations chain compute functions together with conditional logic,
498
+ * delays, and decision branches to automate complex business processes.
499
+ *
500
+ * Usage:
501
+ * ```ts
502
+ * // List all orchestrations
503
+ * const orchestrations = await client.orchestrations.list();
504
+ *
505
+ * // Trigger an on-demand orchestration
506
+ * const run = await client.orchestrations.trigger('orch-id', {
507
+ * input: { orderId: '12345' }
508
+ * });
509
+ *
510
+ * // Get run status
511
+ * const runStatus = await client.orchestrations.getRun('orch-id', run.data.id);
512
+ *
513
+ * // Create a new orchestration
514
+ * const orch = await client.orchestrations.create({
515
+ * slug: 'order-processing',
516
+ * name: 'Order Processing',
517
+ * trigger: { type: 'on-demand' },
518
+ * steps: [
519
+ * { id: 'validate', type: 'compute', functionId: 'func_validate', onSuccess: { nextStepId: 'process' } },
520
+ * { id: 'process', type: 'compute', functionId: 'func_process' }
521
+ * ]
522
+ * });
523
+ * ```
524
+ */
525
+ public get orchestrations(): OrchestrationsManager {
526
+ if (!this._orchestrations) {
527
+ this._orchestrations = new OrchestrationsManager(
528
+ this.options.workspaceId,
529
+ this.request.bind(this)
530
+ );
531
+ }
532
+ return this._orchestrations;
533
+ }
534
+
535
+ /**
536
+ * Allowed Domains namespace for managing compute function external call domains.
537
+ *
538
+ * Usage:
539
+ * ```ts
540
+ * // List all allowed domains
541
+ * const domains = await client.allowedDomains.list();
542
+ *
543
+ * // Add a new domain
544
+ * const domain = await client.allowedDomains.add({ domain: 'api.stripe.com' });
545
+ *
546
+ * // Remove a domain
547
+ * await client.allowedDomains.remove('domain-id');
548
+ * ```
549
+ */
550
+ public get allowedDomains(): AllowedDomainsManager {
551
+ if (!this._allowedDomains) {
552
+ this._allowedDomains = new AllowedDomainsManager(
553
+ this.options.workspaceId,
554
+ this.request.bind(this)
555
+ );
556
+ }
557
+ return this._allowedDomains;
558
+ }
559
+
560
+ /**
561
+ * Structures namespace for managing data structures (schemas).
562
+ * Provides CRUD operations and validation for structure definitions.
563
+ *
564
+ * Usage:
565
+ * ```ts
566
+ * // List all structures
567
+ * const structures = await client.structures.list();
568
+ *
569
+ * // Create a structure
570
+ * const structure = await client.structures.create({
571
+ * name: 'Orders',
572
+ * slug: 'orders',
573
+ * properties: [{ name: 'title', type: 'string', required: true }]
574
+ * });
575
+ *
576
+ * // Validate before creating
577
+ * const result = await client.structures.validate({ slug: 'orders' });
578
+ * ```
579
+ */
580
+ public get structures(): StructuresManager {
581
+ emitDeprecationWarning('client.structures is deprecated. Use client.collections instead.');
582
+ if (!this._structures) {
583
+ this._structures = new StructuresManager(
584
+ this.options.workspaceId,
585
+ this.requestWithDeprecationHeader('structures'),
586
+ );
587
+ }
588
+ return this._structures;
589
+ }
590
+
591
+ /**
592
+ * Collections namespace for managing data collections (schemas).
593
+ * Provides CRUD operations and validation for collection definitions.
594
+ * This is the preferred API — use `client.collections` instead of `client.structures`.
595
+ *
596
+ * Usage:
597
+ * ```ts
598
+ * // List all collections
599
+ * const collections = await client.collections.list();
600
+ *
601
+ * // Create a collection
602
+ * const collection = await client.collections.create({
603
+ * name: 'Orders',
604
+ * slug: 'orders',
605
+ * properties: [{ name: 'title', type: 'string', required: true }]
606
+ * });
607
+ *
608
+ * // Validate before creating
609
+ * const result = await client.collections.validate({ slug: 'orders' });
610
+ * ```
611
+ */
612
+ public get collections(): CollectionsManager {
613
+ if (!this._collections) {
614
+ this._collections = new CollectionsManager(
615
+ this.options.workspaceId,
616
+ this.request.bind(this)
617
+ );
618
+ }
619
+ return this._collections;
620
+ }
621
+
622
+ /**
623
+ * Functions namespace for managing compute functions.
624
+ * Provides CRUD operations and test execution for compute function code.
625
+ *
626
+ * Usage:
627
+ * ```ts
628
+ * // List all functions
629
+ * const fns = await client.functions.list();
630
+ *
631
+ * // Create a function
632
+ * const fn = await client.functions.create({
633
+ * name: 'Process Order',
634
+ * code: 'async function run() { return { ok: true }; }'
635
+ * });
636
+ *
637
+ * // Test execute without saving
638
+ * const result = await client.functions.testExecute({
639
+ * code: 'async function run() { return executionParams; }',
640
+ * params: { test: true }
641
+ * });
642
+ * ```
643
+ */
644
+ public get functions(): ComputeFunctionsManager {
645
+ if (!this._functions) {
646
+ this._functions = new ComputeFunctionsManager(
647
+ this.options.workspaceId,
648
+ this.request.bind(this)
649
+ );
650
+ }
651
+ return this._functions;
652
+ }
653
+
654
+ /**
655
+ * Function Runs namespace — query function execution history. Canonical
656
+ * accessor (CEN-1227); pairs with `client.orchestrationRuns` for
657
+ * orchestration runs (CEN-1217).
658
+ *
659
+ * Usage:
660
+ * ```ts
661
+ * // Get a specific run
662
+ * const run = await client.functionRuns.get('run-id');
663
+ *
664
+ * // List runs for a trigger
665
+ * const runs = await client.functionRuns.listByTrigger('trigger-id');
666
+ *
667
+ * // List failed runs for a compute definition
668
+ * const failed = await client.functionRuns.listByFunction('fn-id', {
669
+ * status: 'failure'
670
+ * });
671
+ *
672
+ * // Canonical query surface
673
+ * const failures = await client.functionRuns.query({
674
+ * where: { and: [{ status: { eq: 'failure' } }] },
675
+ * limit: 50
676
+ * });
677
+ * ```
678
+ */
679
+ public get functionRuns(): FunctionRunsManager {
680
+ if (!this._runs) {
681
+ this._runs = new FunctionRunsManager(
682
+ this.options.workspaceId,
683
+ this.request.bind(this)
684
+ );
685
+ }
686
+ return this._runs;
687
+ }
688
+
689
+ /**
690
+ * Orchestration Runs namespace — canonical query surface for orchestration
691
+ * run history (CEN-1217 / Phase 3).
692
+ *
693
+ * Per-orchestration trigger / list / get-by-id helpers stay on
694
+ * `client.orchestrations.{trigger, listRuns, getRun, getRunSteps}`. Use
695
+ * this namespace when you want the canonical `QueryDefinition` shape
696
+ * (nested boolean trees, `select`, paging) or workspace-wide queries.
697
+ *
698
+ * Usage:
699
+ * ```ts
700
+ * // Recent failed runs across the workspace
701
+ * const failures = await client.orchestrationRuns.query({
702
+ * resource: 'orchestration-runs',
703
+ * where: { and: [{ status: { eq: 'failed' } }] },
704
+ * sort: [{ field: 'startedAt', direction: 'desc' }],
705
+ * page: { limit: 50 },
706
+ * });
707
+ *
708
+ * // Authoring dry-run from a query builder UI
709
+ * const plan = await client.orchestrationRuns.test({
710
+ * resource: 'orchestration-runs',
711
+ * where: { and: [{ orchestrationId: { eq: 'orch-123' } }] },
712
+ * });
713
+ * ```
714
+ */
715
+ public get orchestrationRuns(): OrchestrationRunsManager {
716
+ if (!this._orchestrationRuns) {
717
+ this._orchestrationRuns = new OrchestrationRunsManager(
718
+ this.options.workspaceId,
719
+ this.request.bind(this)
720
+ );
721
+ }
722
+ return this._orchestrationRuns;
723
+ }
724
+
725
+ /**
726
+ * Files namespace — canonical query surface for files (CEN-1218 /
727
+ * Phase 3). Pairs with the existing top-level helpers
728
+ * (`client.uploadFile`, `client.getFileRenderUrl`,
729
+ * `client.createFolder`, …) — those stay where they are. Use this
730
+ * namespace when you want the canonical `QueryDefinition` shape
731
+ * (nested boolean trees, projection, paging, range/array operators
732
+ * on `tags`).
733
+ *
734
+ * Usage:
735
+ * ```ts
736
+ * // Recent images
737
+ * const images = await client.files.query({
738
+ * resource: 'files',
739
+ * where: { fileType: { eq: 'image' } },
740
+ * sort: [{ field: 'createdAt', direction: 'desc' }],
741
+ * page: { limit: 50 },
742
+ * });
743
+ *
744
+ * // Authoring dry-run
745
+ * const plan = await client.files.test({
746
+ * resource: 'files',
747
+ * where: { folderId: { eq: 'folder-uuid' } },
748
+ * });
749
+ * ```
750
+ */
751
+ public get files(): FilesManager {
752
+ if (!this._files) {
753
+ this._files = new FilesManager(
754
+ this.options.workspaceId,
755
+ this.request.bind(this)
756
+ );
757
+ }
758
+ return this._files;
759
+ }
760
+
761
+ /**
762
+ * @deprecated Use `client.functionRuns` instead. Renamed in CEN-1227 ahead
763
+ * of `client.orchestrationRuns` (CEN-1217), at which point `client.runs`
764
+ * would be ambiguous (function runs vs. orchestration runs). This getter
765
+ * is a deprecated alias and will be removed in a future major SDK release.
766
+ */
767
+ public get runs(): FunctionRunsManager {
768
+ emitDeprecationWarning('client.runs is deprecated. Use client.functionRuns instead.');
769
+ if (!this._runsAlias) {
770
+ const Mgr = FunctionRunsManager;
771
+ this._runsAlias = new Mgr(
772
+ this.options.workspaceId,
773
+ this.requestWithDeprecationHeader('runs'),
774
+ );
775
+ }
776
+ return this._runsAlias;
777
+ }
778
+ private _runsAlias?: FunctionRunsManager;
779
+
780
+ /**
781
+ * Webhook subscriptions namespace for outbound webhooks — create, update,
782
+ * rotate signing secrets, inspect delivery history, and replay/cancel
783
+ * individual deliveries.
784
+ *
785
+ * Usage:
786
+ * ```ts
787
+ * // Create a subscription (capture secret immediately — not returned on reads)
788
+ * const sub = await centrali.webhookSubscriptions.create({
789
+ * name: 'Order notifications',
790
+ * url: 'https://api.example.com/hooks/centrali',
791
+ * events: [RecordEvents.CREATED, RecordEvents.UPDATED],
792
+ * recordSlugs: ['orders'],
793
+ * });
794
+ *
795
+ * // Rotate the signing secret (immediate cutover)
796
+ * const rotated = await centrali.webhookSubscriptions.rotateSecret(sub.data.id);
797
+ *
798
+ * // Inspect deliveries
799
+ * const deliveries = await centrali.webhookSubscriptions.deliveries.list(sub.data.id, {
800
+ * status: 'failed'
801
+ * });
802
+ *
803
+ * // Replay a failed delivery
804
+ * await centrali.webhookSubscriptions.deliveries.retry(deliveries.data[0].id);
805
+ * ```
806
+ */
807
+ public get webhookSubscriptions(): WebhookSubscriptionsManager {
808
+ if (!this._webhookSubscriptions) {
809
+ this._webhookSubscriptions = new WebhookSubscriptionsManager(
810
+ this.options.workspaceId,
811
+ this.request.bind(this)
812
+ );
813
+ }
814
+ return this._webhookSubscriptions;
815
+ }
816
+
817
+ /**
818
+ * Manually set or update the bearer token for subsequent requests.
819
+ */
820
+ public setToken(token: string): void {
821
+ this.token = token;
822
+ }
823
+
824
+ /**
825
+ * Fetch Service Account token using Client Credentials flow.
826
+ */
827
+ public async fetchServiceAccountToken(): Promise<string> {
828
+ if (!this.options.clientId || !this.options.clientSecret) {
829
+ throw new Error('Client ID and Client Secret are required to fetch Service Account token.');
830
+ }
831
+ const token = await fetchClientToken(
832
+ this.options.clientId,
833
+ this.options.clientSecret,
834
+ this.options.baseUrl
835
+ );
836
+ return token;
837
+ }
838
+
839
+ /**
840
+ * Build a manager-shaped request callback that tags every outbound call
841
+ * with `X-Centrali-Deprecated-Method`. Used by `@deprecated` SDK aliases
842
+ * (`client.smartQueries`, `client.structures`, `client.runs`) so the
843
+ * existing per-route deprecation counter on the data/workspace services
844
+ * (CEN-1196) can attribute hits back to which SDK surface the caller used,
845
+ * even when the underlying HTTP route is canonical and doesn't fire its
846
+ * own deprecation telemetry.
847
+ */
848
+ private requestWithDeprecationHeader(methodName: string) {
849
+ return <T>(
850
+ method: Method,
851
+ path: string,
852
+ data?: any,
853
+ queryParams?: Record<string, any>,
854
+ ): Promise<ApiResponse<T>> => {
855
+ return this.request<T>(method, path, data, queryParams, {
856
+ headers: { 'X-Centrali-Deprecated-Method': methodName },
857
+ });
858
+ };
859
+ }
860
+
861
+ /**
862
+ * Perform an HTTP request.
863
+ */
864
+ private async request<T>(
865
+ method: Method,
866
+ path: string,
867
+ data?: any,
868
+ queryParams?: Record<string, any>,
869
+ config?: AxiosRequestConfig
870
+ ): Promise<ApiResponse<T>> {
871
+ const resp = await this.axios.request<ApiResponse<T>>({
872
+ method,
873
+ url: path,
874
+ data,
875
+ params: queryParams,
876
+ ...config,
877
+ });
878
+
879
+ // 🔧 Normalize responses to always return { data: T, ... } format
880
+ // Handle primitives (strings, numbers, etc.)
881
+ if (typeof resp.data !== 'object' || resp.data === null) {
882
+ return { data: resp.data as T };
883
+ }
884
+ // Handle arrays (which are objects but should be wrapped in data property)
885
+ if (Array.isArray(resp.data)) {
886
+ return { data: resp.data as T };
887
+ }
888
+ // Handle { result } responses (smart queries and some other endpoints)
889
+ if ('result' in resp.data && !('data' in resp.data)) {
890
+ return { data: (resp.data as any).result as T };
891
+ }
892
+ // Handle objects that don't have a data property (legacy endpoints)
893
+ if (!('data' in resp.data)) {
894
+ return { data: resp.data as T };
895
+ }
896
+ return resp.data;
897
+ }
898
+
899
+ // ------------------ Data API Methods ------------------
900
+
901
+ /** Create a new record in a given recordSlug. */
902
+ public createRecord<T = any>(
903
+ recordSlug: string,
904
+ record: Record<string, any>,
905
+ options?: RecordTtlOptions
906
+ ): Promise<ApiResponse<T>> {
907
+ const path = getRecordApiPath(this.options.workspaceId, recordSlug);
908
+ const queryParams: Record<string, string> = {};
909
+ if (options?.ttlSeconds) queryParams.ttlSeconds = String(options.ttlSeconds);
910
+ if (options?.expiresAt) queryParams.expiresAt = options.expiresAt;
911
+ return this.request('POST', path, { ...record }, Object.keys(queryParams).length > 0 ? queryParams : undefined);
912
+ }
913
+
914
+ /** Get the token used for authentication. */
915
+ public getToken(): string | null {
916
+ return this.token;
917
+ }
918
+
919
+
920
+ /**
921
+ * Retrieve a record by ID.
922
+ *
923
+ * @param recordSlug - The structure's record slug
924
+ * @param id - The record ID
925
+ * @param options - Optional parameters including expand for reference fields
926
+ *
927
+ * @example
928
+ * // Basic fetch
929
+ * const order = await centrali.getRecord('Order', 'order-123');
930
+ *
931
+ * // With expanded references
932
+ * const order = await centrali.getRecord('Order', 'order-123', {
933
+ * expand: 'customer,items'
934
+ * });
935
+ * // Access expanded data: order.data.data._expanded.customer
936
+ */
937
+ public getRecord<T = any>(
938
+ recordSlug: string,
939
+ id: string,
940
+ options?: GetRecordOptions
941
+ ): Promise<ApiResponse<T>> {
942
+ const path = getRecordApiPath(this.options.workspaceId, recordSlug, id);
943
+ return this.request('GET', path, null, options);
944
+ }
945
+
946
+ /**
947
+ * Query records with filters, pagination, sorting, and reference expansion.
948
+ *
949
+ * IMPORTANT: Filters are passed at the TOP LEVEL, not nested under 'filter'.
950
+ * Use 'data.' prefix for custom fields and bracket notation for operators.
951
+ *
952
+ * @param recordSlug - The structure's record slug
953
+ * @param queryParams - Query parameters (filters at top level, plus sort, pagination, expand)
954
+ *
955
+ * @example
956
+ * // Simple equality filter
957
+ * const activeProducts = await centrali.queryRecords('Product', {
958
+ * 'data.status': 'active',
959
+ * sort: '-createdAt',
960
+ * page: 1,
961
+ * pageSize: 10
962
+ * });
963
+ *
964
+ * // Filter with operators (bracket notation)
965
+ * const products = await centrali.queryRecords('Product', {
966
+ * 'data.inStock': true,
967
+ * 'data.price[lte]': 100,
968
+ * sort: '-createdAt',
969
+ * pageSize: 10
970
+ * });
971
+ *
972
+ * // Multiple values with 'in' operator (comma-separated string)
973
+ * const orders = await centrali.queryRecords('Order', {
974
+ * 'data.status[in]': 'pending,processing',
975
+ * expand: 'customer,items'
976
+ * });
977
+ * // Access expanded data: orders.data[0].data._expanded.customer
978
+ *
979
+ * // Range filters
980
+ * const customers = await centrali.queryRecords('Customer', {
981
+ * 'data.age[gte]': 18,
982
+ * 'data.age[lte]': 65,
983
+ * 'data.verified': true
984
+ * });
985
+ *
986
+ * // Filter with 'ne' (not equal)
987
+ * const availableItems = await centrali.queryRecords('Product', {
988
+ * 'data.status[ne]': 'discontinued',
989
+ * pageSize: 100
990
+ * });
991
+ */
992
+ /**
993
+ * Canonical query (CEN-1194). When called with a `QueryDefinition` body,
994
+ * routes to `POST /records/query` and returns canonical `QueryResult<T>`.
995
+ *
996
+ * @example
997
+ * const result = await centrali.queryRecords<Order>('orders', {
998
+ * resource: 'orders',
999
+ * where: { 'data.status': { eq: 'paid' } },
1000
+ * page: { limit: 50 }
1001
+ * });
1002
+ * console.log(result.data, result.meta.hasMore);
1003
+ */
1004
+ public queryRecords<T = any>(
1005
+ resource: string,
1006
+ definition: QueryDefinition
1007
+ ): Promise<QueryResult<T>>;
1008
+ /**
1009
+ * Legacy GET-adapter form. Pass `QueryRecordOptions` (URL-param style with
1010
+ * `data.field[op]` keys, `sort: '-createdAt'`, `pageSize`, etc.) and the
1011
+ * call routes to `GET /records/slug/:rs`.
1012
+ *
1013
+ * @deprecated Since 5.5.0. Prefer the canonical overload above (pass a
1014
+ * `QueryDefinition`) or {@link CentraliSDK.records | client.records.list()}
1015
+ * for the GET adapter explicitly. The legacy form keeps working — server-side
1016
+ * it already routes through the canonical engine (CEN-1181 WS3) — but the
1017
+ * client-side type story diverges from the canonical surface. Emits a
1018
+ * one-shot `console.warn` per client.
1019
+ */
1020
+ public queryRecords<T = any>(
1021
+ recordSlug: string,
1022
+ queryParams?: QueryRecordOptions
1023
+ ): Promise<ApiResponse<T>>;
1024
+ public queryRecords<T = any>(
1025
+ resourceOrSlug: string,
1026
+ arg2?: QueryDefinition | QueryRecordOptions
1027
+ ): Promise<QueryResult<T>> | Promise<ApiResponse<T>> {
1028
+ if (isCanonicalQueryDefinition(arg2)) {
1029
+ return this.records.query<T>(resourceOrSlug, arg2);
1030
+ }
1031
+ if (!this._queryRecordsLegacyWarned) {
1032
+ this._queryRecordsLegacyWarned = true;
1033
+ // eslint-disable-next-line no-console
1034
+ console.warn(
1035
+ '[centrali-sdk] `client.queryRecords(slug, urlOpts)` (legacy URL-param form) is deprecated — pass a canonical `QueryDefinition` (`{ resource, where, sort, page, … }`) for `POST /records/query`, or use `client.records.list(resource, urlOpts)` for the GET adapter explicitly.'
1036
+ );
1037
+ }
1038
+ const path = getRecordApiPath(this.options.workspaceId, resourceOrSlug);
1039
+ return this.request<T>('GET', path, null, arg2);
1040
+ }
1041
+
1042
+ /** Get records by Ids. */
1043
+ public getRecordsByIds<T = any>(
1044
+ recordSlug: string,
1045
+ ids: string[]
1046
+ ): Promise<ApiResponse<T[]>> {
1047
+ const path = getRecordApiPath(this.options.workspaceId, recordSlug) + '/bulk/get';
1048
+ return this.request('POST', path, { ids });
1049
+ }
1050
+
1051
+ /** Update an existing record by ID. */
1052
+ public updateRecord<T = any>(
1053
+ recordSlug: string,
1054
+ id: string,
1055
+ updates: Record<string, any>,
1056
+ options?: RecordTtlOptions
1057
+ ): Promise<ApiResponse<T>> {
1058
+ const path = getRecordApiPath(this.options.workspaceId, recordSlug, id);
1059
+ const queryParams: Record<string, string> = {};
1060
+ if (options?.ttlSeconds) queryParams.ttlSeconds = String(options.ttlSeconds);
1061
+ if (options?.expiresAt) queryParams.expiresAt = options.expiresAt;
1062
+ if (options?.clearTtl) queryParams.clearTtl = 'true';
1063
+ return this.request('PUT', path, { ...updates }, Object.keys(queryParams).length > 0 ? queryParams : undefined);
1064
+ }
1065
+
1066
+ /**
1067
+ * Upsert a record: find by match fields, update if exists, create if not.
1068
+ * Uses advisory locking for atomicity — safe for concurrent calls.
1069
+ *
1070
+ * @param recordSlug - The structure's record slug
1071
+ * @param options - { match: key-value pairs to find existing record, data: full record data }
1072
+ * @returns Response where result.data is the record and result.operation indicates create/update
1073
+ *
1074
+ * @example
1075
+ * const result = await client.upsertRecord('HourlyRollup', {
1076
+ * match: { metricKey: 'pageviews', bucketHour: '2025-01-01T10:00' },
1077
+ * data: { metricKey: 'pageviews', bucketHour: '2025-01-01T10:00', count: 42 },
1078
+ * });
1079
+ * // result.data → the record
1080
+ * // result.operation → 'created' or 'updated'
1081
+ */
1082
+ public upsertRecord<T = any>(
1083
+ recordSlug: string,
1084
+ options: { match: Record<string, any>; data: Record<string, any> }
1085
+ ): Promise<ApiResponse<T> & { operation: 'created' | 'updated' }> {
1086
+ const path = getRecordApiPath(this.options.workspaceId, recordSlug) + '/upsert';
1087
+ return this.request('POST', path, { match: options.match, data: options.data }) as Promise<ApiResponse<T> & { operation: 'created' | 'updated' }>;
1088
+ }
1089
+
1090
+ /** Delete a record by ID (soft delete by default, can be restored). */
1091
+ public deleteRecord(
1092
+ recordSlug: string,
1093
+ id: string,
1094
+ options?: DeleteRecordOptions
1095
+ ): Promise<ApiResponse<null>> {
1096
+ const path = getRecordApiPath(this.options.workspaceId, recordSlug, id);
1097
+ const queryParams = options?.hard ? { hard: 'true' } : undefined;
1098
+ return this.request('DELETE', path, null, queryParams);
1099
+ }
1100
+
1101
+ /** Restore a soft-deleted record by ID. */
1102
+ public restoreRecord(
1103
+ recordSlug: string,
1104
+ id: string
1105
+ ): Promise<ApiResponse<null>> {
1106
+ const path = getRecordApiPath(this.options.workspaceId, recordSlug, id) + '/restore';
1107
+ return this.request('POST', path);
1108
+ }
1109
+
1110
+ // ------------------ Secrets API Methods ------------------
1111
+
1112
+ /**
1113
+ * Reveal plaintext values of secret fields for a record.
1114
+ * Requires secrets:reveal permission.
1115
+ *
1116
+ * @param recordSlug - The structure's record slug
1117
+ * @param id - The record ID
1118
+ * @param fields - Optional array of specific secret field names to reveal
1119
+ * @returns Object with field names as keys and plaintext secret values
1120
+ *
1121
+ * @example
1122
+ * ```ts
1123
+ * // Reveal all secrets
1124
+ * const result = await client.revealSecrets('users', 'record-123');
1125
+ * console.log(result.data.revealed.apiKey); // "sk_live_..."
1126
+ *
1127
+ * // Reveal specific fields
1128
+ * const result = await client.revealSecrets('users', 'record-123', ['apiKey']);
1129
+ * ```
1130
+ */
1131
+ public revealSecrets(
1132
+ recordSlug: string,
1133
+ id: string,
1134
+ fields?: string[]
1135
+ ): Promise<ApiResponse<{ revealed: Record<string, any> }>> {
1136
+ const path = getRecordApiPath(this.options.workspaceId, recordSlug, id) + '/secrets/reveal';
1137
+ return this.request('POST', path, fields ? { fields } : {});
1138
+ }
1139
+
1140
+ /**
1141
+ * Compare a candidate value against a secret field without revealing the stored secret.
1142
+ * Requires secrets:compare permission.
1143
+ *
1144
+ * @param recordSlug - The structure's record slug
1145
+ * @param id - The record ID
1146
+ * @param field - The secret field name to compare against
1147
+ * @param value - The candidate value to compare
1148
+ * @returns Boolean indicating if the values match
1149
+ *
1150
+ * @example
1151
+ * ```ts
1152
+ * const result = await client.compareSecret('users', 'record-123', 'apiKey', 'sk_live_test');
1153
+ * if (result.data.matches) {
1154
+ * console.log('API key is valid');
1155
+ * }
1156
+ * ```
1157
+ */
1158
+ public compareSecret(
1159
+ recordSlug: string,
1160
+ id: string,
1161
+ field: string,
1162
+ value: string
1163
+ ): Promise<ApiResponse<{ matches: boolean }>> {
1164
+ const path = getRecordApiPath(this.options.workspaceId, recordSlug, id) + '/secrets/compare';
1165
+ return this.request('POST', path, { field, value });
1166
+ }
1167
+
1168
+ // ------------------ Storage API Methods ------------------
1169
+
1170
+ /**
1171
+ * Upload a file to the storage service.
1172
+ *
1173
+ * @param file - The file to upload
1174
+ * @param location - Target folder path (e.g., '/root/shared/images'). Defaults to '/root/shared' if not specified.
1175
+ * /root/shared always exists. For custom subfolders, create them first with createFolder().
1176
+ * @param isPublic - If true, the file will be publicly accessible without authentication. Defaults to false.
1177
+ * @returns The file URL or render ID
1178
+ *
1179
+ * @example
1180
+ * ```ts
1181
+ * // Upload to default location (/root/shared)
1182
+ * const result = await client.uploadFile(file);
1183
+ *
1184
+ * // Upload to specific folder
1185
+ * const result = await client.uploadFile(file, '/root/shared/images');
1186
+ *
1187
+ * // Upload as public file
1188
+ * const result = await client.uploadFile(file, '/root/shared/public', true);
1189
+ * ```
1190
+ */
1191
+ public async uploadFile(
1192
+ file: File,
1193
+ location?: string,
1194
+ isPublic: boolean = false
1195
+ ): Promise<ApiResponse<string>> {
1196
+ const path = getFileUploadApiPath(this.options.workspaceId);
1197
+ const formData = new FormData();
1198
+ const fileName = this.options.workspaceId + Date.now() + file.name;
1199
+
1200
+ formData.append('file', file);
1201
+ formData.append('fileName', fileName);
1202
+ formData.append('isPublic', isPublic ? 'true' : 'false');
1203
+
1204
+ // Only append location if specified; backend defaults to /root/shared
1205
+ if (location) {
1206
+ formData.append('location', location);
1207
+ }
1208
+
1209
+ return this.request<string>('POST', path, formData, undefined, {
1210
+ headers: {
1211
+ 'Content-Type': 'multipart/form-data',
1212
+ },
1213
+ });
1214
+ }
1215
+
1216
+ // ------------------ Folder API Methods ------------------
1217
+
1218
+ /**
1219
+ * Create a folder in the storage service.
1220
+ * Use this to create subfolders under /root/shared (which always exists).
1221
+ *
1222
+ * @param name - The folder name (e.g., 'logos', 'avatars')
1223
+ * @param location - Parent folder path (e.g., '/root/shared'). Defaults to '/root/shared'.
1224
+ * @returns The created folder object
1225
+ *
1226
+ * @example
1227
+ * ```ts
1228
+ * // Create a folder under /root/shared
1229
+ * const folder = await client.createFolder('logos', '/root/shared');
1230
+ * // Result: folder at /root/shared/logos
1231
+ *
1232
+ * // Then upload to it
1233
+ * const { data: renderId } = await client.uploadFile(file, '/root/shared/logos', true);
1234
+ * ```
1235
+ */
1236
+ public async createFolder(
1237
+ name: string,
1238
+ location: string = '/root/shared'
1239
+ ): Promise<ApiResponse<any>> {
1240
+ const path = `storage/ws/${this.options.workspaceId}/api/v1/folders`;
1241
+ return this.request<any>('POST', path, { name, location });
1242
+ }
1243
+
1244
+ /**
1245
+ * List folders in the workspace, optionally filtered by parent location.
1246
+ *
1247
+ * @param location - Parent folder path to list contents of (e.g., '/root/shared'). If omitted, lists top-level folders.
1248
+ * @returns Array of folder objects
1249
+ *
1250
+ * @example
1251
+ * ```ts
1252
+ * // List all folders under /root/shared
1253
+ * const folders = await client.listFolders('/root/shared');
1254
+ *
1255
+ * // Check if a folder exists before uploading
1256
+ * const folders = await client.listFolders('/root/shared');
1257
+ * const hasLogos = folders.data.some(f => f.name === 'logos');
1258
+ * if (!hasLogos) {
1259
+ * await client.createFolder('logos', '/root/shared');
1260
+ * }
1261
+ * ```
1262
+ */
1263
+ public async listFolders(location?: string): Promise<ApiResponse<any[]>> {
1264
+ const path = `storage/ws/${this.options.workspaceId}/api/v1/folders`;
1265
+ const params = location ? { location } : undefined;
1266
+ return this.request<any[]>('GET', path, null, params);
1267
+ }
1268
+
1269
+ /**
1270
+ * Get a specific folder by ID.
1271
+ *
1272
+ * @param folderId - The folder ID (UUID)
1273
+ * @returns The folder object
1274
+ */
1275
+ public async getFolder(folderId: string): Promise<ApiResponse<any>> {
1276
+ const path = `storage/ws/${this.options.workspaceId}/api/v1/folders/${folderId}`;
1277
+ return this.request<any>('GET', path);
1278
+ }
1279
+
1280
+ /**
1281
+ * List sub-folders within a specific folder.
1282
+ *
1283
+ * @param folderId - The parent folder ID (UUID)
1284
+ * @returns Array of sub-folder objects
1285
+ */
1286
+ public async listSubFolders(folderId: string): Promise<ApiResponse<any[]>> {
1287
+ const path = `storage/ws/${this.options.workspaceId}/api/v1/folders/${folderId}/sub-folders`;
1288
+ return this.request<any[]>('GET', path);
1289
+ }
1290
+
1291
+ /**
1292
+ * Delete a folder by ID. System folders (/root, /root/shared, /root/users) cannot be deleted.
1293
+ *
1294
+ * @param folderId - The folder ID (UUID) to delete
1295
+ */
1296
+ public async deleteFolder(folderId: string): Promise<ApiResponse<void>> {
1297
+ const path = `storage/ws/${this.options.workspaceId}/api/v1/folders/${folderId}`;
1298
+ return this.request<void>('DELETE', path);
1299
+ }
1300
+
1301
+ // ------------------ File Render/Download URL Methods ------------------
1302
+
1303
+ /**
1304
+ * Get the render URL for a file. Use this URL to display files inline (e.g., images in img tags).
1305
+ * Supports optional image transformation parameters.
1306
+ *
1307
+ * @param renderId - The render ID returned from uploadFile()
1308
+ * @param options - Optional image transformation parameters
1309
+ * @returns The full render URL
1310
+ *
1311
+ * @example
1312
+ * ```ts
1313
+ * // Basic render URL
1314
+ * const url = client.getFileRenderUrl('abc123');
1315
+ * // => "https://api.centrali.io/storage/ws/my-workspace/api/v1/render/abc123"
1316
+ *
1317
+ * // With image transformations
1318
+ * const thumbUrl = client.getFileRenderUrl('abc123', { width: 200 });
1319
+ * const compressedUrl = client.getFileRenderUrl('abc123', { width: 800, quality: 60, format: 'webp' });
1320
+ * ```
1321
+ */
1322
+ public getFileRenderUrl(
1323
+ renderId: string,
1324
+ options?: {
1325
+ width?: number;
1326
+ height?: number;
1327
+ quality?: number;
1328
+ format?: 'jpeg' | 'png' | 'webp';
1329
+ }
1330
+ ): string {
1331
+ const apiUrl = getApiUrl(this.options.baseUrl);
1332
+ const baseUrl = `${apiUrl}/storage/ws/${this.options.workspaceId}/api/v1/render/${renderId}`;
1333
+
1334
+ if (!options) {
1335
+ return baseUrl;
1336
+ }
1337
+
1338
+ const params = new URLSearchParams();
1339
+ if (options.width) params.append('width', String(options.width));
1340
+ if (options.height) params.append('height', String(options.height));
1341
+ if (options.quality) params.append('quality', String(options.quality));
1342
+ if (options.format) params.append('format', options.format);
1343
+
1344
+ const queryString = params.toString();
1345
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl;
1346
+ }
1347
+
1348
+ /**
1349
+ * Get the download URL for a file. Use this URL to download files as attachments.
1350
+ *
1351
+ * @param renderId - The render ID returned from uploadFile()
1352
+ * @returns The full download URL
1353
+ *
1354
+ * @example
1355
+ * ```ts
1356
+ * const downloadUrl = client.getFileDownloadUrl('abc123');
1357
+ * // => "https://api.centrali.io/storage/ws/my-workspace/api/v1/download/abc123"
1358
+ * ```
1359
+ */
1360
+ public getFileDownloadUrl(renderId: string): string {
1361
+ const apiUrl = getApiUrl(this.options.baseUrl);
1362
+ return `${apiUrl}/storage/ws/${this.options.workspaceId}/api/v1/download/${renderId}`;
1363
+ }
1364
+
1365
+ // ------------------ Search API Methods ------------------
1366
+
1367
+ /**
1368
+ * Search records across the workspace using full-text search.
1369
+ *
1370
+ * @param query - The search query string
1371
+ * @param options - Optional search parameters
1372
+ * @returns Search results with hits and metadata
1373
+ *
1374
+ * @example
1375
+ * ```ts
1376
+ * // Basic search
1377
+ * const results = await client.search('customer email');
1378
+ * console.log('Found:', results.data.totalHits, 'results');
1379
+ * results.data.hits.forEach(hit => console.log(hit.id, hit.structureSlug));
1380
+ *
1381
+ * // Search with structure filter
1382
+ * const userResults = await client.search('john', { structures: 'users' });
1383
+ *
1384
+ * // Search multiple structures with limit
1385
+ * const results = await client.search('active', {
1386
+ * structures: ['users', 'orders'],
1387
+ * limit: 50
1388
+ * });
1389
+ * ```
1390
+ */
1391
+ public async search(
1392
+ query: string,
1393
+ options?: SearchOptions
1394
+ ): Promise<ApiResponse<SearchResponse>> {
1395
+ const path = getSearchApiPath(this.options.workspaceId);
1396
+
1397
+ const queryParams: Record<string, string> = { q: query };
1398
+
1399
+ if (options?.collections) {
1400
+ const collections = Array.isArray(options.collections)
1401
+ ? options.collections.join(',')
1402
+ : options.collections;
1403
+ queryParams.collections = collections;
1404
+ } else if (options?.structures) {
1405
+ emitDeprecationWarning("The 'structures' search option is deprecated. Use 'collections' instead.");
1406
+ const structures = Array.isArray(options.structures)
1407
+ ? options.structures.join(',')
1408
+ : options.structures;
1409
+ queryParams.collections = structures;
1410
+ }
1411
+
1412
+ if (options?.limit) {
1413
+ queryParams.limit = String(options.limit);
1414
+ }
1415
+
1416
+ return this.request<SearchResponse>('GET', path, null, queryParams);
1417
+ }
1418
+
1419
+ // ------------------ Authorization API Methods (BYOT) ------------------
1420
+
1421
+ /**
1422
+ * Check if an action is authorized for an external token.
1423
+ *
1424
+ * Use this method when you want to authorize access using tokens from your
1425
+ * own identity provider (Clerk, Auth0, Okta, etc.) instead of Centrali's
1426
+ * built-in authentication.
1427
+ *
1428
+ * **Use Cases:**
1429
+ * 1. **AuthZ-as-a-Service**: Define custom resources (orders, invoices) in Centrali
1430
+ * and use it purely for authorization decisions.
1431
+ * 2. **External IdP for Centrali resources**: Access Centrali data (records, files)
1432
+ * using your corporate IdP tokens.
1433
+ *
1434
+ * **Prerequisites:**
1435
+ * - Configure an External Auth Provider in Centrali Console (Settings → External Auth)
1436
+ * - Define claim mappings to extract attributes from your JWT
1437
+ * - Create policies that reference the extracted attributes (prefixed with `ext_`)
1438
+ *
1439
+ * @example
1440
+ * // Simple authorization check
1441
+ * const result = await client.checkAuthorization({
1442
+ * token: clerkJWT,
1443
+ * resource: 'orders',
1444
+ * action: 'read'
1445
+ * });
1446
+ *
1447
+ * if (result.data.allowed) {
1448
+ * // Proceed with the action
1449
+ * }
1450
+ *
1451
+ * @example
1452
+ * // Authorization with context for policy evaluation
1453
+ * const result = await client.checkAuthorization({
1454
+ * token: clerkJWT,
1455
+ * resource: 'orders',
1456
+ * action: 'approve',
1457
+ * context: {
1458
+ * orderId: 'order-123',
1459
+ * orderAmount: 50000,
1460
+ * department: 'sales'
1461
+ * }
1462
+ * });
1463
+ *
1464
+ * // Policy can check: ext_role == 'manager' AND request_metadata.orderAmount > 10000
1465
+ *
1466
+ * @param options - Authorization check options
1467
+ * @returns Promise resolving to the authorization result
1468
+ */
1469
+ public async checkAuthorization(
1470
+ options: CheckAuthorizationOptions
1471
+ ): Promise<ApiResponse<AuthorizationResult>> {
1472
+ const { token, resource, action, resourceCategory = 'custom', context } = options;
1473
+
1474
+ const path = `/workspace/${this.options.workspaceId}/api/v1/access/evaluate`;
1475
+
1476
+ const body = {
1477
+ action,
1478
+ resource_name: resource,
1479
+ resource_category: resourceCategory,
1480
+ request_data: context ? { request_metadata: context } : undefined
1481
+ };
1482
+
1483
+ // Make request with the external token
1484
+ const response = await this.axios.request<{
1485
+ status: string;
1486
+ response: 'allow' | 'deny' | 'not_applicable';
1487
+ message?: string;
1488
+ }>({
1489
+ method: 'POST',
1490
+ url: path,
1491
+ data: body,
1492
+ headers: {
1493
+ 'Authorization': `Bearer ${token}`,
1494
+ 'Content-Type': 'application/json'
1495
+ }
1496
+ });
1497
+
1498
+ return {
1499
+ data: {
1500
+ allowed: response.data.response === 'allow',
1501
+ decision: response.data.response,
1502
+ message: response.data.message
1503
+ }
1504
+ };
1505
+ }
1506
+
1507
+ }